norm.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. // Package norm 标识归一化纯函数(P0-06 §2/§3.3 设计稿落地)。
  2. //
  3. // 职责:把 legacy 原始标识收敛为目标态 canonical 形态。
  4. // 设计铁律:
  5. // - **纯函数**:无副作用、确定(同一输入恒同输出),可独立单测;
  6. // - **幂等**:归一(归一(x)) == 归一(x)(对已归一值再归一不变);
  7. // - **先归一后匹配**:归一失败绝不写脏 canonical(调用方据此跳过 t_identity 写入)。
  8. //
  9. // 四类标识归一力度(P0-06 §1):
  10. // - phone:强归一(trim → 去分隔符 → 剥国际前缀 → 11 位校验)——主力 key
  11. // - wechat:不做字符串变换(openid 已唯一),仅 trim;必须带 appid(见调用方)
  12. // - email:轻归一(trim + 小写 + 正则校验)——不作归一 key
  13. // - username:轻归一(trim + 剔除不可见字符 + 小写)——绝不作自然人归一 key
  14. package norm
  15. import (
  16. "regexp"
  17. "strings"
  18. "unicode"
  19. )
  20. // 大陆手机号正则(11 位:1 + 3-9 起始号段 + 9 位)
  21. var cnPhoneRe = regexp.MustCompile(`^1[3-9]\d{9}$`)
  22. // 分隔符:空白、横线、点(P0-06 §2.1 步 2)
  23. var sepRe = regexp.MustCompile(`[\s\-\.]`)
  24. // 邮箱最小可判定正则(P0-06 §2.3 步 4,不强求 RFC 5322 全量)
  25. var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
  26. // Phone 归一:trim → 去分隔符 → 剥国际前缀 → 11 位大陆号校验。
  27. //
  28. // 规则(P0-06 §2.1 顺序执行):
  29. // 1. trim 去首尾空白
  30. // 2. 去掉所有空格、横线、点(sepRe)
  31. // 3. 去 + 号前缀
  32. // 4. 剥国际前缀(仅当剥后 11 位匹配大陆号段才剥,P0-06 §2.1 步 3):
  33. // - 前导 "0086"(4 位)
  34. // - 前导 "86"(2 位,须后接 11 位大陆号)
  35. // 5. 11 位大陆号校验:正则 ^1[3-9]\d{9}$
  36. // 6. 否则 → ok=false(phone_invalid,转 P0-08 人工)
  37. //
  38. // 入参:
  39. // - raw string:legacy t_user.phone_number 原始值(可能含分隔符/国际前缀/空白)。
  40. //
  41. // 出参:
  42. // - canonical string:归一后的 11 位大陆号(如 "13800000000");ok=false 时为空串。
  43. // - ok bool:true 表示归一成功(可写 t_identity.phone);false 表示 phone_invalid。
  44. //
  45. // 幂等验证:对已归一值 "13800000000" 再调用 → ("13800000000", true),不变。
  46. func Phone(raw string) (canonical string, ok bool) {
  47. s := strings.TrimSpace(raw)
  48. s = sepRe.ReplaceAllString(s, "")
  49. s = strings.TrimPrefix(s, "+")
  50. // 剥 0086 前缀(须后接 11 位大陆号,避免误剥正常号段)
  51. if strings.HasPrefix(s, "0086") {
  52. rest := s[4:]
  53. if cnPhoneRe.MatchString(rest) {
  54. return rest, true
  55. }
  56. }
  57. // 剥 86 前缀(+86 已去 +,此处剩 "86"+11 位)
  58. if strings.HasPrefix(s, "86") && len(s) == 13 {
  59. rest := s[2:]
  60. if cnPhoneRe.MatchString(rest) {
  61. return rest, true
  62. }
  63. }
  64. if cnPhoneRe.MatchString(s) {
  65. return s, true
  66. }
  67. return "", false
  68. }
  69. // WechatOpenID 归一:仅 trim(微信侧已清洗,不做字符串变换)。
  70. //
  71. // 入参:
  72. // - raw string:legacy t_weixin_user.openid 原始值。
  73. //
  74. // 出参:
  75. // - canonical string:trim 后的 openid;空串返回 ""。
  76. // - ok bool:true 表示非空可落 t_identity.wechat。
  77. func WechatOpenID(raw string) (canonical string, ok bool) {
  78. s := strings.TrimSpace(raw)
  79. if s == "" {
  80. return "", false
  81. }
  82. return s, true
  83. }
  84. // Email 归一:trim + 小写 + 正则校验(P0-06 §2.3)。
  85. //
  86. // 保留 +tag(P0-06 §5 拍板:量仅 167 条,保守不破坏既有邮箱)。
  87. //
  88. // 入参:
  89. // - raw string:legacy t_user.email 原始值。
  90. //
  91. // 出参:
  92. // - canonical string:小写 email;ok=false 时为空串。
  93. // - ok bool:true 表示通过最小正则校验(可写 t_identity.email)。
  94. func Email(raw string) (canonical string, ok bool) {
  95. s := strings.ToLower(strings.TrimSpace(stripInvisible(raw)))
  96. if !emailRe.MatchString(s) {
  97. return "", false
  98. }
  99. return s, true
  100. }
  101. // stripInvisible 剔除不可见 Unicode 字符:控制符 Cc、格式符 Cf(含双向文字控制符
  102. // U+202A–U+202E/U+2060–U+2069 等)、代理字符 Cs。
  103. //
  104. // 背景:legacy 个别 username 被 bidi 控制符包裹(实测 yuansiwei_i.t_user id=44008
  105. // 的 username = U+202D + "18077117616" + U+202C)。这类字符肉眼不可见、
  106. // 非有意设置,属数据污染;若保留会让"同账号"在字节层面不同(Go TrimSpace 不视为空白,
  107. // 小写归一后仍是两个不同串)从而绕过去重,而 MySQL utf8mb4_0900_ai_ci 排序规则
  108. // 视其为等价 → t_identity 唯一键(生产 t_user 还有 uk_username)下第二行写入将撞唯一键、
  109. // 回滚整个迁移事务。剔除后两个账号归一为同一串 → 命中"首个占用者"规则,
  110. // 第二行 username 置 NULL 并转 P0-08 人工(与 phone 撞号同款处理),迁移不炸。
  111. // 量级:legacy t_user 仅 1 条命中(44008),15119 条含非 ASCII 的其余为正常 CJK 姓名,
  112. // Cc/Cf/Cs 剔除对它们无影响。
  113. func stripInvisible(s string) string {
  114. var b strings.Builder
  115. b.Grow(len(s))
  116. for _, r := range s {
  117. switch {
  118. case unicode.IsControl(r), // Cc 控制符
  119. unicode.Is(unicode.Cf, r), // Cf 格式符(含 bidi 控制)
  120. unicode.Is(unicode.Cs, r): // Cs 代理(未分配 surrogate)
  121. // 跳过
  122. default:
  123. b.WriteRune(r)
  124. }
  125. }
  126. return b.String()
  127. }
  128. // Username 归一:trim + 剔除不可见字符 + 小写(P0-06 §2.4)。
  129. //
  130. // 红线:username **绝不作自然人归一 key**——仅作登录账号落 t_identity.username。
  131. // 本函数无 ok 返回值(username 始终可归一,空串也合法——legacy 100% 有值)。
  132. // 剔除 bidi 控制符见 stripInvisible 注释(防"同账号字节不同"绕过去重撞唯一键)。
  133. //
  134. // 入参:
  135. // - raw string:legacy t_user.username 原始值。
  136. //
  137. // 出参:
  138. // - canonical string:小写 username;空串输入返回 ""。
  139. func Username(raw string) string {
  140. return strings.ToLower(strings.TrimSpace(stripInvisible(raw)))
  141. }