mapping.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. package fixture
  2. import (
  3. "bytes"
  4. "crypto/sha256"
  5. "database/sql"
  6. "fmt"
  7. "time"
  8. "unicode/utf8"
  9. "github.com/oklog/ulid"
  10. )
  11. // 本文件是**纯映射规则**:不碰数据库、不读环境变量、不取当前时间,全部输入即输出。
  12. // 这样每条规则都能单测,变异实验(故意改错一处)也只需改这里一处即可复现红/绿。
  13. // fixtureEpochMs 是 fixture ULID 的固定时间戳(2026-01-01T00:00:00Z)。
  14. //
  15. // 固定而非 Now():ULID 单调性对 fixture 无意义,但**确定性**很有意义——
  16. // 同一 legacy user_id 每次跑都得到同一个 canonical_user_id,于是
  17. //
  18. // · 重复执行 map/verify/clean 天然幂等(不必落盘中间状态);
  19. // · 报告里的 ULID 可复现、可对比。
  20. const fixtureEpochMs uint64 = 1767225600000 // 2026-01-01T00:00:00Z
  21. // FixtureCanonicalUserID 由 legacy int user_id **确定性**派生一个 ULID。
  22. //
  23. // 熵 = sha256("ac15-fixture:user:<id>") 前 10 字节(80 位,ULID 上限)。
  24. // 生产迁移的 canonical_user_id 由归一 pipeline 的种子脚本发放,与此无关;
  25. // 这里只要「同一 legacy id ↔ 同一 ULID」,且不撞车即可。
  26. func FixtureCanonicalUserID(legacyUserID int64) string {
  27. sum := sha256.Sum256([]byte(fmt.Sprintf("ac15-fixture:user:%d", legacyUserID)))
  28. var entropy [10]byte
  29. copy(entropy[:], sum[:10])
  30. id, err := ulid.New(fixtureEpochMs, bytes.NewReader(entropy[:]))
  31. if err != nil {
  32. // entropy 长度固定 10 字节,ulid.New 只会在熵不足时报错,这里不可达。
  33. panic(fmt.Sprintf("fixture: ULID 生成失败:%v", err))
  34. }
  35. return id.String()
  36. }
  37. // FixtureCanonicalStudentID 把 legacy 的**学生 int id**映射成确定性的学生 ULID。
  38. //
  39. // 与 `FixtureCanonicalUserID` 的关系:**同一个算法、不同的命名空间前缀**,因此
  40. // 同一个 int 在两侧得到的是**两个不同的 ULID**。
  41. //
  42. // 为什么必须分开(不是"装饰性"的两行常量):
  43. //
  44. // `ac15-fixture:user:` 与 `ac15-fixture:student:` 是让"学生 id 与用户 id 串用"
  45. // 这类错**在 case 上变红**的唯一手段。ULID 本身不携带类型信息 —— 一旦两个命名空间
  46. // 合并成一个,"把 student_id 写成 user_id 的 ULID"在库里是**完全合法的 26 字符串**,
  47. // 查询照样命中、断言照样通过,这类错就**永久不可见**了。
  48. //
  49. // 所以:任何"这一列是学生"的种子都必须走本函数,任何"这一列是用户"的走
  50. // `FixtureCanonicalUserID`。混用会立刻表现为"JOIN 不上 / 候选集为空",
  51. // 而不是一个安静的错值。
  52. func FixtureCanonicalStudentID(legacyStudentID int64) string {
  53. sum := sha256.Sum256([]byte(fmt.Sprintf("ac15-fixture:student:%d", legacyStudentID)))
  54. var entropy [10]byte
  55. copy(entropy[:], sum[:10])
  56. id, err := ulid.New(fixtureEpochMs, bytes.NewReader(entropy[:]))
  57. if err != nil {
  58. // entropy 长度固定 10 字节,ulid.New 只会在熵不足时报错,这里不可达。
  59. panic(fmt.Sprintf("fixture: 学生 ULID 生成失败:%v", err))
  60. }
  61. return id.String()
  62. }
  63. // moneyOrZero 把可空的 legacy 金额列统一成十进制字符串。
  64. //
  65. // 语义:legacy 侧该列 NULL(历史上没记过)→ 目标态 NOT NULL DEFAULT 0,
  66. // 只能落 0;但**必须记录**,因为这与「真的是 0」在业务上是两件事。
  67. func moneyOrZero(v sql.NullString) (string, bool) {
  68. if !v.Valid || v.String == "" {
  69. return "0.000", false
  70. }
  71. return v.String, true
  72. }
  73. // MapExpense 映射 t_user_expense(1 个账户行)。
  74. func MapExpense(l LegacyExpense, canonicalUserID string) (TargetExpense, []string, error) {
  75. var warns []string
  76. out := TargetExpense{UserID: canonicalUserID}
  77. type col struct {
  78. name string
  79. in sql.NullString
  80. out *int64
  81. }
  82. cols := []col{
  83. {"balance", l.Balance, &out.BalanceCents},
  84. {"spend", l.Spend, &out.SpendCents},
  85. {"total_income", l.TotalIncome, &out.TotalIncomeCents},
  86. {"total_expenditure", l.TotalExpenditure, &out.TotalExpenditureCents},
  87. {"total_recommended_amount", l.TotalRecommendedAmount, &out.TotalRecommendedCents},
  88. {"special_expenses", l.SpecialExpenses, &out.SpecialExpensesCents},
  89. }
  90. for _, c := range cols {
  91. s, present := moneyOrZero(c.in)
  92. if !present {
  93. warns = append(warns, fmt.Sprintf("t_user_expense.%s 在 legacy 为 NULL,目标态 NOT NULL → 落 0.00", c.name))
  94. }
  95. cents, err := DecimalToCents(s)
  96. if err != nil {
  97. return TargetExpense{}, warns, fmt.Errorf("t_user_expense.%s:%w", c.name, err)
  98. }
  99. *c.out = cents
  100. }
  101. return out, warns, nil
  102. }
  103. // MapExpenseSchool 映射一个校区账户行(复合业务键 user_id + institution_id + campus_id)。
  104. func MapExpenseSchool(l LegacyExpenseSchool, canonicalUserID string) (TargetExpenseSchool, []string, error) {
  105. var warns []string
  106. out := TargetExpenseSchool{
  107. UserID: canonicalUserID,
  108. InstitutionID: l.SchoolID, // legacy schoolid → target institution_id(1:1 直迁)
  109. CampusID: l.CampusID, // legacy campusid → target campus_id(NULL 已在读取侧 IFNULL 0)
  110. }
  111. type col struct {
  112. name string
  113. in sql.NullString
  114. out *int64
  115. }
  116. cols := []col{
  117. {"balance", l.Balance, &out.BalanceCents},
  118. {"spend", l.Spend, &out.SpendCents},
  119. {"total_income", l.TotalIncome, &out.TotalIncomeCents},
  120. {"total_expenditure", l.TotalExpenditure, &out.TotalExpenditureCents},
  121. {"total_recommended_amount", l.TotalRecommendedAmount, &out.TotalRecommendedCents},
  122. {"special_expenses", l.SpecialExpenses, &out.SpecialExpensesCents},
  123. }
  124. for _, c := range cols {
  125. s, present := moneyOrZero(c.in)
  126. if !present {
  127. warns = append(warns, fmt.Sprintf("t_user_expense_school(id=%d).%s 在 legacy 为 NULL → 落 0.00", l.ID, c.name))
  128. }
  129. cents, err := DecimalToCents(s)
  130. if err != nil {
  131. return TargetExpenseSchool{}, warns, fmt.Errorf("t_user_expense_school(id=%d).%s:%w", l.ID, c.name, err)
  132. }
  133. *c.out = cents
  134. }
  135. return out, warns, nil
  136. }
  137. // CreatorResolver 把 legacy int create_userid 解析成 canonical_user_id(ULID)。
  138. // 返回 ok=false 表示该 legacy id 没有映射行。
  139. type CreatorResolver func(legacyUserID int64) (canonicalUserID string, ok bool)
  140. // MapExpenseLog 映射一条流水(D1:id 原样保留)。
  141. //
  142. // 三条已拍板的空值语义(逐列判断,不一律转 0):
  143. // 1. create_user_id:legacy 0(或解析不到的旧 id)→ **NULL**,不是 0;
  144. // 2. request_id:legacy 无此列 → **NULL**(历史数据没有幂等号);
  145. // 3. balance_after:legacy 无此列 → **NULL**(历史上没记余额快照)。
  146. func MapExpenseLog(l LegacyExpenseLog, canonicalUserID string, resolve CreatorResolver) (TargetExpenseLog, []string, error) {
  147. var warns []string
  148. out := TargetExpenseLog{
  149. ID: l.ID, // D1:显式插 legacy id
  150. UserID: canonicalUserID,
  151. Type: l.Type,
  152. RequestID: sql.NullString{}, // 恒 NULL
  153. BalanceAfter: sql.NullString{}, // 恒 NULL
  154. }
  155. // log_id:legacy logid(varchar(32) 旧 UUID) → target log_id(varchar(64))
  156. if l.LogID.Valid && l.LogID.String != "" {
  157. out.LogID = sql.NullString{String: l.LogID.String, Valid: true}
  158. }
  159. // money:decimal(10,3) → 整数分。legacy 为 NULL 时目标 NOT NULL,
  160. // 记告警并落 0(本 fixture 命中 0 行,规则仍需成立)。
  161. s, present := moneyOrZero(l.Money)
  162. if !present {
  163. warns = append(warns, fmt.Sprintf("t_user_expense_log(id=%d).money 在 legacy 为 NULL → 落 0.00", l.ID))
  164. }
  165. cents, err := DecimalToCents(s)
  166. if err != nil {
  167. return TargetExpenseLog{}, warns, fmt.Errorf("t_user_expense_log(id=%d).money:%w", l.ID, err)
  168. }
  169. out.MoneyCents = cents
  170. // create_user_id:0 / NULL / 解析不到 → NULL(不落 0、不落空串)。
  171. if l.CreateUserID.Valid && l.CreateUserID.Int64 > 0 {
  172. if uid, ok := resolve(l.CreateUserID.Int64); ok {
  173. out.CreateUserID = sql.NullString{String: uid, Valid: true}
  174. } else {
  175. warns = append(warns, fmt.Sprintf(
  176. "t_user_expense_log(id=%d).create_user_id:legacy %d 无 t_legacy_mapping 行 → NULL",
  177. l.ID, l.CreateUserID.Int64))
  178. }
  179. }
  180. // create_time:datetime(0) → datetime(3),**秒级原样**,毫秒补 .000。
  181. if !l.CreateTime.Valid {
  182. return TargetExpenseLog{}, warns, fmt.Errorf(
  183. "t_user_expense_log(id=%d).create_time 在 legacy 为 NULL,目标态 NOT NULL —— 拒绝用当前时间填充", l.ID)
  184. }
  185. out.CreateTime = time.Date(
  186. l.CreateTime.Time.Year(), l.CreateTime.Time.Month(), l.CreateTime.Time.Day(),
  187. l.CreateTime.Time.Hour(), l.CreateTime.Time.Minute(), l.CreateTime.Time.Second(),
  188. 0, l.CreateTime.Time.Location(),
  189. )
  190. // remark:text → varchar(1000)。按**字符数**截断(不是字节),并记告警。
  191. if l.Remark.Valid && l.Remark.String != "" {
  192. out.Remark = sql.NullString{String: truncateRunes(l.Remark.String, 1000), Valid: true}
  193. if utf8.RuneCountInString(l.Remark.String) > 1000 {
  194. warns = append(warns, fmt.Sprintf(
  195. "t_user_expense_log(id=%d).remark 超过 1000 字符,已按字符截断(目标列 varchar(1000))", l.ID))
  196. }
  197. }
  198. return out, warns, nil
  199. }
  200. // truncateRunes 按字符(rune)截断,避免把一个多字节汉字截成半个。
  201. func truncateRunes(s string, max int) string {
  202. if utf8.RuneCountInString(s) <= max {
  203. return s
  204. }
  205. rs := []rune(s)
  206. return string(rs[:max])
  207. }