// Package norm 标识归一化纯函数(P0-06 §2/§3.3 设计稿落地)。 // // 职责:把 legacy 原始标识收敛为目标态 canonical 形态。 // 设计铁律: // - **纯函数**:无副作用、确定(同一输入恒同输出),可独立单测; // - **幂等**:归一(归一(x)) == 归一(x)(对已归一值再归一不变); // - **先归一后匹配**:归一失败绝不写脏 canonical(调用方据此跳过 t_identity 写入)。 // // 四类标识归一力度(P0-06 §1): // - phone:强归一(trim → 去分隔符 → 剥国际前缀 → 11 位校验)——主力 key // - wechat:不做字符串变换(openid 已唯一),仅 trim;必须带 appid(见调用方) // - email:轻归一(trim + 小写 + 正则校验)——不作归一 key // - username:轻归一(trim + 剔除不可见字符 + 小写)——绝不作自然人归一 key package norm import ( "regexp" "strings" "unicode" ) // 大陆手机号正则(11 位:1 + 3-9 起始号段 + 9 位) var cnPhoneRe = regexp.MustCompile(`^1[3-9]\d{9}$`) // 分隔符:空白、横线、点(P0-06 §2.1 步 2) var sepRe = regexp.MustCompile(`[\s\-\.]`) // 邮箱最小可判定正则(P0-06 §2.3 步 4,不强求 RFC 5322 全量) var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) // Phone 归一:trim → 去分隔符 → 剥国际前缀 → 11 位大陆号校验。 // // 规则(P0-06 §2.1 顺序执行): // 1. trim 去首尾空白 // 2. 去掉所有空格、横线、点(sepRe) // 3. 去 + 号前缀 // 4. 剥国际前缀(仅当剥后 11 位匹配大陆号段才剥,P0-06 §2.1 步 3): // - 前导 "0086"(4 位) // - 前导 "86"(2 位,须后接 11 位大陆号) // 5. 11 位大陆号校验:正则 ^1[3-9]\d{9}$ // 6. 否则 → ok=false(phone_invalid,转 P0-08 人工) // // 入参: // - raw string:legacy t_user.phone_number 原始值(可能含分隔符/国际前缀/空白)。 // // 出参: // - canonical string:归一后的 11 位大陆号(如 "13800000000");ok=false 时为空串。 // - ok bool:true 表示归一成功(可写 t_identity.phone);false 表示 phone_invalid。 // // 幂等验证:对已归一值 "13800000000" 再调用 → ("13800000000", true),不变。 func Phone(raw string) (canonical string, ok bool) { s := strings.TrimSpace(raw) s = sepRe.ReplaceAllString(s, "") s = strings.TrimPrefix(s, "+") // 剥 0086 前缀(须后接 11 位大陆号,避免误剥正常号段) if strings.HasPrefix(s, "0086") { rest := s[4:] if cnPhoneRe.MatchString(rest) { return rest, true } } // 剥 86 前缀(+86 已去 +,此处剩 "86"+11 位) if strings.HasPrefix(s, "86") && len(s) == 13 { rest := s[2:] if cnPhoneRe.MatchString(rest) { return rest, true } } if cnPhoneRe.MatchString(s) { return s, true } return "", false } // WechatOpenID 归一:仅 trim(微信侧已清洗,不做字符串变换)。 // // 入参: // - raw string:legacy t_weixin_user.openid 原始值。 // // 出参: // - canonical string:trim 后的 openid;空串返回 ""。 // - ok bool:true 表示非空可落 t_identity.wechat。 func WechatOpenID(raw string) (canonical string, ok bool) { s := strings.TrimSpace(raw) if s == "" { return "", false } return s, true } // Email 归一:trim + 小写 + 正则校验(P0-06 §2.3)。 // // 保留 +tag(P0-06 §5 拍板:量仅 167 条,保守不破坏既有邮箱)。 // // 入参: // - raw string:legacy t_user.email 原始值。 // // 出参: // - canonical string:小写 email;ok=false 时为空串。 // - ok bool:true 表示通过最小正则校验(可写 t_identity.email)。 func Email(raw string) (canonical string, ok bool) { s := strings.ToLower(strings.TrimSpace(stripInvisible(raw))) if !emailRe.MatchString(s) { return "", false } return s, true } // stripInvisible 剔除不可见 Unicode 字符:控制符 Cc、格式符 Cf(含双向文字控制符 // U+202A–U+202E/U+2060–U+2069 等)、代理字符 Cs。 // // 背景:legacy 个别 username 被 bidi 控制符包裹(实测 yuansiwei_i.t_user id=44008 // 的 username = U+202D + "18077117616" + U+202C)。这类字符肉眼不可见、 // 非有意设置,属数据污染;若保留会让"同账号"在字节层面不同(Go TrimSpace 不视为空白, // 小写归一后仍是两个不同串)从而绕过去重,而 MySQL utf8mb4_0900_ai_ci 排序规则 // 视其为等价 → t_identity 唯一键(生产 t_user 还有 uk_username)下第二行写入将撞唯一键、 // 回滚整个迁移事务。剔除后两个账号归一为同一串 → 命中"首个占用者"规则, // 第二行 username 置 NULL 并转 P0-08 人工(与 phone 撞号同款处理),迁移不炸。 // 量级:legacy t_user 仅 1 条命中(44008),15119 条含非 ASCII 的其余为正常 CJK 姓名, // Cc/Cf/Cs 剔除对它们无影响。 func stripInvisible(s string) string { var b strings.Builder b.Grow(len(s)) for _, r := range s { switch { case unicode.IsControl(r), // Cc 控制符 unicode.Is(unicode.Cf, r), // Cf 格式符(含 bidi 控制) unicode.Is(unicode.Cs, r): // Cs 代理(未分配 surrogate) // 跳过 default: b.WriteRune(r) } } return b.String() } // Username 归一:trim + 剔除不可见字符 + 小写(P0-06 §2.4)。 // // 红线:username **绝不作自然人归一 key**——仅作登录账号落 t_identity.username。 // 本函数无 ok 返回值(username 始终可归一,空串也合法——legacy 100% 有值)。 // 剔除 bidi 控制符见 stripInvisible 注释(防"同账号字节不同"绕过去重撞唯一键)。 // // 入参: // - raw string:legacy t_user.username 原始值。 // // 出参: // - canonical string:小写 username;空串输入返回 ""。 func Username(raw string) string { return strings.ToLower(strings.TrimSpace(stripInvisible(raw))) }