package migrate import ( "yuansiwei/unified-backend/internal/migrate/norm" ) // 置信度分级常量(match_confidence TINYINT 0-100,P0-07 §3 置信分级表落地)。 // 单边 I 库迁移场景下的取值(mark 侧生产 SQL 验证后可能上调): // - seed(现映射 t_user_account_association 命中)→ 最高,直接信任 // - phone(归一 phone 且该 phone 在 I 库内唯一)→ 高 // - unionid(有微信 unionid 锚点)→ 中 // - unmapped(无现映射、无可用 phone/unionid)→ 待生产交叉匹配 // - conflict(phone 撞号等)→ 0,不自动合并,转 P0-08 人工 const ( ConfidenceSeed int8 = 100 ConfidencePhone int8 = 90 ConfidenceUnionid int8 = 70 ConfidenceUnmapped int8 = 50 ConfidenceConflict int8 = 0 ) // 匹配来源(match_source,P0-07 ADR-003 pipeline 阶段标识)。 const ( MatchSourceSeed = "seed" // 现映射种子命中 MatchSourcePhone = "phone" // phone 唯一匹配 MatchSourceUnionid = "unionid" // 微信 unionid 锚点 MatchSourceUnmapped = "unmapped" // 暂无匹配依据(单边迁移,待生产验证) MatchSourceConflict = "conflict" // 冲突(撞号等),不自动合并 ) // MatchDecision 单个 legacy 用户的匹配决策(纯逻辑产物,由 Planner 产出)。 type MatchDecision struct { MatchSource string // seed/phone/unionid/unmapped/conflict Confidence int8 // 0-100 PhoneConflict bool // 该用户的归一 phone 在 I 库内对应 >1 个 legacy 账号(撞号) PhoneInvalid bool // phone 归一失败(phone_invalid,转 P0-08 人工,不写 t_identity.phone) HasWechat bool // 有微信身份(openid/unionid) } // Planner 匹配引擎:预构建索引,对每个 legacy 用户做置信分级 + 冲突检测。 // // 纯逻辑(无 DB 写),可独立单测。索引在 NewPlanner 时一次性构建, // Decide 为 O(1) 查表。 type Planner struct { // phoneIndex:归一 phone → 持有该 phone 的 legacy user id 列表(撞号检测)。 phoneIndex map[string][]int32 // seedSet:有现映射(t_user_account_association)的 legacy user id。 seedSet map[int32]struct{} // wxByUserID:legacy user id → 微信身份(一个 user 至多一条 t_weixin_user)。 wxByUserID map[int32]*LegacyWeixinUser } // NewPlanner 构建匹配引擎。 // // 入参: // - users:t_user 全量(用于构建 phone 索引)。 // - assoc:t_user_account_association 全量(现映射种子,userid 列)。 // - wx:t_weixin_user 全量(用于构建 userid → 微信 索引)。 // // 出参:*Planner(索引构建完成,可调用 Decide)。 func NewPlanner(users []LegacyUser, assoc []LegacyAccountAssociation, wx []LegacyWeixinUser) *Planner { p := &Planner{ phoneIndex: make(map[string][]int32), seedSet: make(map[int32]struct{}), wxByUserID: make(map[int32]*LegacyWeixinUser), } // phone 索引:仅对归一成功的 phone 建索引(phone_invalid 不参与撞号检测) for i := range users { u := &users[i] if u.PhoneNumber == nil || *u.PhoneNumber == "" { continue } canon, ok := norm.Phone(*u.PhoneNumber) if !ok { continue } p.phoneIndex[canon] = append(p.phoneIndex[canon], u.ID) } // 现映射种子 for i := range assoc { if assoc[i].UserID > 0 { p.seedSet[assoc[i].UserID] = struct{}{} } } // 微信索引(openid 为 PK,userid 关联) for i := range wx { w := &wx[i] if w.UserID > 0 { // 同 userid 多条时取首条(t_weixin_user 实测 1:1,无重复) if _, exists := p.wxByUserID[w.UserID]; !exists { p.wxByUserID[w.UserID] = w } } } return p } // Decide 对单个 legacy 用户做置信分级 + 冲突检测(P0-07 §2.2 匹配优先级)。 // // 优先级(高→低): // 1. 现映射种子命中 → seed / 100 // 2. phone 归一成功且 I 库内唯一 → phone / 90 // 3. phone 撞号(同 phone >1 账号)→ conflict / 0(不自动合并,转 P0-08) // 4. 有微信 unionid → unionid / 70 // 5. 其余 → unmapped / 50(单边迁移,待生产交叉匹配) // // 入参: // - userID int32:legacy t_user.id。 // - phoneRaw *string:该用户的原始手机号(可为 nil)。 // // 出参:MatchDecision(置信分级 + 冲突标记,供 runner 驱动写入)。 func (p *Planner) Decide(userID int32, phoneRaw *string) MatchDecision { // ① 现映射种子(最高优先,直接信任) if _, ok := p.seedSet[userID]; ok { return p.withPhoneFlags(userID, phoneRaw, MatchSourceSeed, ConfidenceSeed) } // phone 归一 + 撞号检测 var canonPhone string phoneValid := false phoneConflict := false if phoneRaw != nil && *phoneRaw != "" { canon, ok := norm.Phone(*phoneRaw) if ok { phoneValid = true canonPhone = canon if n := len(p.phoneIndex[canonPhone]); n > 1 { phoneConflict = true } } } // ② phone 撞号 → 冲突(不自动合并,转 P0-08 人工定夺) if phoneConflict { return MatchDecision{ MatchSource: MatchSourceConflict, Confidence: ConfidenceConflict, PhoneConflict: true, PhoneInvalid: false, HasWechat: p.hasWechat(userID), } } // ③ phone 唯一匹配 → 高置信 if phoneValid { return p.withPhoneFlags(userID, phoneRaw, MatchSourcePhone, ConfidencePhone) } // ④ 微信 unionid 锚点 → 中置信 if w := p.wxByUserID[userID]; w != nil && w.UnionID != nil && *w.UnionID != "" { return MatchDecision{ MatchSource: MatchSourceUnionid, Confidence: ConfidenceUnionid, PhoneInvalid: !phoneValid && phoneRaw != nil && *phoneRaw != "", HasWechat: true, } } // ⑤ 无匹配依据 → 单边迁移待生产验证 return p.withPhoneFlags(userID, phoneRaw, MatchSourceUnmapped, ConfidenceUnmapped) } // withPhoneFlags 构造带 phone 标志的决策(用于 seed/phone/unmapped 分支)。 func (p *Planner) withPhoneFlags(userID int32, phoneRaw *string, source string, conf int8) MatchDecision { var phoneInvalid bool if phoneRaw != nil && *phoneRaw != "" { if _, ok := norm.Phone(*phoneRaw); !ok { phoneInvalid = true } } return MatchDecision{ MatchSource: source, Confidence: conf, PhoneInvalid: phoneInvalid, HasWechat: p.hasWechat(userID), } } // hasWechat 判断 legacy 用户是否有微信身份。 func (p *Planner) hasWechat(userID int32) bool { return p.wxByUserID[userID] != nil } // PhoneConflictGroups 返回所有撞号组(归一 phone → 多账号),供 P0-08 人工复核清单。 // // 出参:map[归一phone][]legacyUserID(仅保留 >1 账号的组)。 func (p *Planner) PhoneConflictGroups() map[string][]int32 { groups := make(map[string][]int32) for phone, ids := range p.phoneIndex { if len(ids) > 1 { groups[phone] = ids } } return groups }