mapping.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  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. // moneyOrZero 把可空的 legacy 金额列统一成十进制字符串。
  38. //
  39. // 语义:legacy 侧该列 NULL(历史上没记过)→ 目标态 NOT NULL DEFAULT 0,
  40. // 只能落 0;但**必须记录**,因为这与「真的是 0」在业务上是两件事。
  41. func moneyOrZero(v sql.NullString) (string, bool) {
  42. if !v.Valid || v.String == "" {
  43. return "0.000", false
  44. }
  45. return v.String, true
  46. }
  47. // MapExpense 映射 t_user_expense(1 个账户行)。
  48. func MapExpense(l LegacyExpense, canonicalUserID string) (TargetExpense, []string, error) {
  49. var warns []string
  50. out := TargetExpense{UserID: canonicalUserID}
  51. type col struct {
  52. name string
  53. in sql.NullString
  54. out *int64
  55. }
  56. cols := []col{
  57. {"balance", l.Balance, &out.BalanceCents},
  58. {"spend", l.Spend, &out.SpendCents},
  59. {"total_income", l.TotalIncome, &out.TotalIncomeCents},
  60. {"total_expenditure", l.TotalExpenditure, &out.TotalExpenditureCents},
  61. {"total_recommended_amount", l.TotalRecommendedAmount, &out.TotalRecommendedCents},
  62. {"special_expenses", l.SpecialExpenses, &out.SpecialExpensesCents},
  63. }
  64. for _, c := range cols {
  65. s, present := moneyOrZero(c.in)
  66. if !present {
  67. warns = append(warns, fmt.Sprintf("t_user_expense.%s 在 legacy 为 NULL,目标态 NOT NULL → 落 0.00", c.name))
  68. }
  69. cents, err := DecimalToCents(s)
  70. if err != nil {
  71. return TargetExpense{}, warns, fmt.Errorf("t_user_expense.%s:%w", c.name, err)
  72. }
  73. *c.out = cents
  74. }
  75. return out, warns, nil
  76. }
  77. // MapExpenseSchool 映射一个校区账户行(复合业务键 user_id + institution_id + campus_id)。
  78. func MapExpenseSchool(l LegacyExpenseSchool, canonicalUserID string) (TargetExpenseSchool, []string, error) {
  79. var warns []string
  80. out := TargetExpenseSchool{
  81. UserID: canonicalUserID,
  82. InstitutionID: l.SchoolID, // legacy schoolid → target institution_id(1:1 直迁)
  83. CampusID: l.CampusID, // legacy campusid → target campus_id(NULL 已在读取侧 IFNULL 0)
  84. }
  85. type col struct {
  86. name string
  87. in sql.NullString
  88. out *int64
  89. }
  90. cols := []col{
  91. {"balance", l.Balance, &out.BalanceCents},
  92. {"spend", l.Spend, &out.SpendCents},
  93. {"total_income", l.TotalIncome, &out.TotalIncomeCents},
  94. {"total_expenditure", l.TotalExpenditure, &out.TotalExpenditureCents},
  95. {"total_recommended_amount", l.TotalRecommendedAmount, &out.TotalRecommendedCents},
  96. {"special_expenses", l.SpecialExpenses, &out.SpecialExpensesCents},
  97. }
  98. for _, c := range cols {
  99. s, present := moneyOrZero(c.in)
  100. if !present {
  101. warns = append(warns, fmt.Sprintf("t_user_expense_school(id=%d).%s 在 legacy 为 NULL → 落 0.00", l.ID, c.name))
  102. }
  103. cents, err := DecimalToCents(s)
  104. if err != nil {
  105. return TargetExpenseSchool{}, warns, fmt.Errorf("t_user_expense_school(id=%d).%s:%w", l.ID, c.name, err)
  106. }
  107. *c.out = cents
  108. }
  109. return out, warns, nil
  110. }
  111. // CreatorResolver 把 legacy int create_userid 解析成 canonical_user_id(ULID)。
  112. // 返回 ok=false 表示该 legacy id 没有映射行。
  113. type CreatorResolver func(legacyUserID int64) (canonicalUserID string, ok bool)
  114. // MapExpenseLog 映射一条流水(D1:id 原样保留)。
  115. //
  116. // 三条已拍板的空值语义(逐列判断,不一律转 0):
  117. // 1. create_user_id:legacy 0(或解析不到的旧 id)→ **NULL**,不是 0;
  118. // 2. request_id:legacy 无此列 → **NULL**(历史数据没有幂等号);
  119. // 3. balance_after:legacy 无此列 → **NULL**(历史上没记余额快照)。
  120. func MapExpenseLog(l LegacyExpenseLog, canonicalUserID string, resolve CreatorResolver) (TargetExpenseLog, []string, error) {
  121. var warns []string
  122. out := TargetExpenseLog{
  123. ID: l.ID, // D1:显式插 legacy id
  124. UserID: canonicalUserID,
  125. Type: l.Type,
  126. RequestID: sql.NullString{}, // 恒 NULL
  127. BalanceAfter: sql.NullString{}, // 恒 NULL
  128. }
  129. // log_id:legacy logid(varchar(32) 旧 UUID) → target log_id(varchar(64))
  130. if l.LogID.Valid && l.LogID.String != "" {
  131. out.LogID = sql.NullString{String: l.LogID.String, Valid: true}
  132. }
  133. // money:decimal(10,3) → 整数分。legacy 为 NULL 时目标 NOT NULL,
  134. // 记告警并落 0(本 fixture 命中 0 行,规则仍需成立)。
  135. s, present := moneyOrZero(l.Money)
  136. if !present {
  137. warns = append(warns, fmt.Sprintf("t_user_expense_log(id=%d).money 在 legacy 为 NULL → 落 0.00", l.ID))
  138. }
  139. cents, err := DecimalToCents(s)
  140. if err != nil {
  141. return TargetExpenseLog{}, warns, fmt.Errorf("t_user_expense_log(id=%d).money:%w", l.ID, err)
  142. }
  143. out.MoneyCents = cents
  144. // create_user_id:0 / NULL / 解析不到 → NULL(不落 0、不落空串)。
  145. if l.CreateUserID.Valid && l.CreateUserID.Int64 > 0 {
  146. if uid, ok := resolve(l.CreateUserID.Int64); ok {
  147. out.CreateUserID = sql.NullString{String: uid, Valid: true}
  148. } else {
  149. warns = append(warns, fmt.Sprintf(
  150. "t_user_expense_log(id=%d).create_user_id:legacy %d 无 t_legacy_mapping 行 → NULL",
  151. l.ID, l.CreateUserID.Int64))
  152. }
  153. }
  154. // create_time:datetime(0) → datetime(3),**秒级原样**,毫秒补 .000。
  155. if !l.CreateTime.Valid {
  156. return TargetExpenseLog{}, warns, fmt.Errorf(
  157. "t_user_expense_log(id=%d).create_time 在 legacy 为 NULL,目标态 NOT NULL —— 拒绝用当前时间填充", l.ID)
  158. }
  159. out.CreateTime = time.Date(
  160. l.CreateTime.Time.Year(), l.CreateTime.Time.Month(), l.CreateTime.Time.Day(),
  161. l.CreateTime.Time.Hour(), l.CreateTime.Time.Minute(), l.CreateTime.Time.Second(),
  162. 0, l.CreateTime.Time.Location(),
  163. )
  164. // remark:text → varchar(1000)。按**字符数**截断(不是字节),并记告警。
  165. if l.Remark.Valid && l.Remark.String != "" {
  166. out.Remark = sql.NullString{String: truncateRunes(l.Remark.String, 1000), Valid: true}
  167. if utf8.RuneCountInString(l.Remark.String) > 1000 {
  168. warns = append(warns, fmt.Sprintf(
  169. "t_user_expense_log(id=%d).remark 超过 1000 字符,已按字符截断(目标列 varchar(1000))", l.ID))
  170. }
  171. }
  172. return out, warns, nil
  173. }
  174. // truncateRunes 按字符(rune)截断,避免把一个多字节汉字截成半个。
  175. func truncateRunes(s string, max int) string {
  176. if utf8.RuneCountInString(s) <= max {
  177. return s
  178. }
  179. rs := []rune(s)
  180. return string(rs[:max])
  181. }