| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190 |
- 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
- }
|