match.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. package migrate
  2. import (
  3. "yuansiwei/unified-backend/internal/migrate/norm"
  4. )
  5. // 置信度分级常量(match_confidence TINYINT 0-100,P0-07 §3 置信分级表落地)。
  6. // 单边 I 库迁移场景下的取值(mark 侧生产 SQL 验证后可能上调):
  7. // - seed(现映射 t_user_account_association 命中)→ 最高,直接信任
  8. // - phone(归一 phone 且该 phone 在 I 库内唯一)→ 高
  9. // - unionid(有微信 unionid 锚点)→ 中
  10. // - unmapped(无现映射、无可用 phone/unionid)→ 待生产交叉匹配
  11. // - conflict(phone 撞号等)→ 0,不自动合并,转 P0-08 人工
  12. const (
  13. ConfidenceSeed int8 = 100
  14. ConfidencePhone int8 = 90
  15. ConfidenceUnionid int8 = 70
  16. ConfidenceUnmapped int8 = 50
  17. ConfidenceConflict int8 = 0
  18. )
  19. // 匹配来源(match_source,P0-07 ADR-003 pipeline 阶段标识)。
  20. const (
  21. MatchSourceSeed = "seed" // 现映射种子命中
  22. MatchSourcePhone = "phone" // phone 唯一匹配
  23. MatchSourceUnionid = "unionid" // 微信 unionid 锚点
  24. MatchSourceUnmapped = "unmapped" // 暂无匹配依据(单边迁移,待生产验证)
  25. MatchSourceConflict = "conflict" // 冲突(撞号等),不自动合并
  26. )
  27. // MatchDecision 单个 legacy 用户的匹配决策(纯逻辑产物,由 Planner 产出)。
  28. type MatchDecision struct {
  29. MatchSource string // seed/phone/unionid/unmapped/conflict
  30. Confidence int8 // 0-100
  31. PhoneConflict bool // 该用户的归一 phone 在 I 库内对应 >1 个 legacy 账号(撞号)
  32. PhoneInvalid bool // phone 归一失败(phone_invalid,转 P0-08 人工,不写 t_identity.phone)
  33. HasWechat bool // 有微信身份(openid/unionid)
  34. }
  35. // Planner 匹配引擎:预构建索引,对每个 legacy 用户做置信分级 + 冲突检测。
  36. //
  37. // 纯逻辑(无 DB 写),可独立单测。索引在 NewPlanner 时一次性构建,
  38. // Decide 为 O(1) 查表。
  39. type Planner struct {
  40. // phoneIndex:归一 phone → 持有该 phone 的 legacy user id 列表(撞号检测)。
  41. phoneIndex map[string][]int32
  42. // seedSet:有现映射(t_user_account_association)的 legacy user id。
  43. seedSet map[int32]struct{}
  44. // wxByUserID:legacy user id → 微信身份(一个 user 至多一条 t_weixin_user)。
  45. wxByUserID map[int32]*LegacyWeixinUser
  46. }
  47. // NewPlanner 构建匹配引擎。
  48. //
  49. // 入参:
  50. // - users:t_user 全量(用于构建 phone 索引)。
  51. // - assoc:t_user_account_association 全量(现映射种子,userid 列)。
  52. // - wx:t_weixin_user 全量(用于构建 userid → 微信 索引)。
  53. //
  54. // 出参:*Planner(索引构建完成,可调用 Decide)。
  55. func NewPlanner(users []LegacyUser, assoc []LegacyAccountAssociation, wx []LegacyWeixinUser) *Planner {
  56. p := &Planner{
  57. phoneIndex: make(map[string][]int32),
  58. seedSet: make(map[int32]struct{}),
  59. wxByUserID: make(map[int32]*LegacyWeixinUser),
  60. }
  61. // phone 索引:仅对归一成功的 phone 建索引(phone_invalid 不参与撞号检测)
  62. for i := range users {
  63. u := &users[i]
  64. if u.PhoneNumber == nil || *u.PhoneNumber == "" {
  65. continue
  66. }
  67. canon, ok := norm.Phone(*u.PhoneNumber)
  68. if !ok {
  69. continue
  70. }
  71. p.phoneIndex[canon] = append(p.phoneIndex[canon], u.ID)
  72. }
  73. // 现映射种子
  74. for i := range assoc {
  75. if assoc[i].UserID > 0 {
  76. p.seedSet[assoc[i].UserID] = struct{}{}
  77. }
  78. }
  79. // 微信索引(openid 为 PK,userid 关联)
  80. for i := range wx {
  81. w := &wx[i]
  82. if w.UserID > 0 {
  83. // 同 userid 多条时取首条(t_weixin_user 实测 1:1,无重复)
  84. if _, exists := p.wxByUserID[w.UserID]; !exists {
  85. p.wxByUserID[w.UserID] = w
  86. }
  87. }
  88. }
  89. return p
  90. }
  91. // Decide 对单个 legacy 用户做置信分级 + 冲突检测(P0-07 §2.2 匹配优先级)。
  92. //
  93. // 优先级(高→低):
  94. // 1. 现映射种子命中 → seed / 100
  95. // 2. phone 归一成功且 I 库内唯一 → phone / 90
  96. // 3. phone 撞号(同 phone >1 账号)→ conflict / 0(不自动合并,转 P0-08)
  97. // 4. 有微信 unionid → unionid / 70
  98. // 5. 其余 → unmapped / 50(单边迁移,待生产交叉匹配)
  99. //
  100. // 入参:
  101. // - userID int32:legacy t_user.id。
  102. // - phoneRaw *string:该用户的原始手机号(可为 nil)。
  103. //
  104. // 出参:MatchDecision(置信分级 + 冲突标记,供 runner 驱动写入)。
  105. func (p *Planner) Decide(userID int32, phoneRaw *string) MatchDecision {
  106. // ① 现映射种子(最高优先,直接信任)
  107. if _, ok := p.seedSet[userID]; ok {
  108. return p.withPhoneFlags(userID, phoneRaw, MatchSourceSeed, ConfidenceSeed)
  109. }
  110. // phone 归一 + 撞号检测
  111. var canonPhone string
  112. phoneValid := false
  113. phoneConflict := false
  114. if phoneRaw != nil && *phoneRaw != "" {
  115. canon, ok := norm.Phone(*phoneRaw)
  116. if ok {
  117. phoneValid = true
  118. canonPhone = canon
  119. if n := len(p.phoneIndex[canonPhone]); n > 1 {
  120. phoneConflict = true
  121. }
  122. }
  123. }
  124. // ② phone 撞号 → 冲突(不自动合并,转 P0-08 人工定夺)
  125. if phoneConflict {
  126. return MatchDecision{
  127. MatchSource: MatchSourceConflict,
  128. Confidence: ConfidenceConflict,
  129. PhoneConflict: true,
  130. PhoneInvalid: false,
  131. HasWechat: p.hasWechat(userID),
  132. }
  133. }
  134. // ③ phone 唯一匹配 → 高置信
  135. if phoneValid {
  136. return p.withPhoneFlags(userID, phoneRaw, MatchSourcePhone, ConfidencePhone)
  137. }
  138. // ④ 微信 unionid 锚点 → 中置信
  139. if w := p.wxByUserID[userID]; w != nil && w.UnionID != nil && *w.UnionID != "" {
  140. return MatchDecision{
  141. MatchSource: MatchSourceUnionid,
  142. Confidence: ConfidenceUnionid,
  143. PhoneInvalid: !phoneValid && phoneRaw != nil && *phoneRaw != "",
  144. HasWechat: true,
  145. }
  146. }
  147. // ⑤ 无匹配依据 → 单边迁移待生产验证
  148. return p.withPhoneFlags(userID, phoneRaw, MatchSourceUnmapped, ConfidenceUnmapped)
  149. }
  150. // withPhoneFlags 构造带 phone 标志的决策(用于 seed/phone/unmapped 分支)。
  151. func (p *Planner) withPhoneFlags(userID int32, phoneRaw *string, source string, conf int8) MatchDecision {
  152. var phoneInvalid bool
  153. if phoneRaw != nil && *phoneRaw != "" {
  154. if _, ok := norm.Phone(*phoneRaw); !ok {
  155. phoneInvalid = true
  156. }
  157. }
  158. return MatchDecision{
  159. MatchSource: source,
  160. Confidence: conf,
  161. PhoneInvalid: phoneInvalid,
  162. HasWechat: p.hasWechat(userID),
  163. }
  164. }
  165. // hasWechat 判断 legacy 用户是否有微信身份。
  166. func (p *Planner) hasWechat(userID int32) bool {
  167. return p.wxByUserID[userID] != nil
  168. }
  169. // PhoneConflictGroups 返回所有撞号组(归一 phone → 多账号),供 P0-08 人工复核清单。
  170. //
  171. // 出参:map[归一phone][]legacyUserID(仅保留 >1 账号的组)。
  172. func (p *Planner) PhoneConflictGroups() map[string][]int32 {
  173. groups := make(map[string][]int32)
  174. for phone, ids := range p.phoneIndex {
  175. if len(ids) > 1 {
  176. groups[phone] = ids
  177. }
  178. }
  179. return groups
  180. }