| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198 |
- package fixture
- import (
- "bytes"
- "crypto/sha256"
- "database/sql"
- "fmt"
- "time"
- "unicode/utf8"
- "github.com/oklog/ulid"
- )
- // 本文件是**纯映射规则**:不碰数据库、不读环境变量、不取当前时间,全部输入即输出。
- // 这样每条规则都能单测,变异实验(故意改错一处)也只需改这里一处即可复现红/绿。
- // fixtureEpochMs 是 fixture ULID 的固定时间戳(2026-01-01T00:00:00Z)。
- //
- // 固定而非 Now():ULID 单调性对 fixture 无意义,但**确定性**很有意义——
- // 同一 legacy user_id 每次跑都得到同一个 canonical_user_id,于是
- //
- // · 重复执行 map/verify/clean 天然幂等(不必落盘中间状态);
- // · 报告里的 ULID 可复现、可对比。
- const fixtureEpochMs uint64 = 1767225600000 // 2026-01-01T00:00:00Z
- // FixtureCanonicalUserID 由 legacy int user_id **确定性**派生一个 ULID。
- //
- // 熵 = sha256("ac15-fixture:user:<id>") 前 10 字节(80 位,ULID 上限)。
- // 生产迁移的 canonical_user_id 由归一 pipeline 的种子脚本发放,与此无关;
- // 这里只要「同一 legacy id ↔ 同一 ULID」,且不撞车即可。
- func FixtureCanonicalUserID(legacyUserID int64) string {
- sum := sha256.Sum256([]byte(fmt.Sprintf("ac15-fixture:user:%d", legacyUserID)))
- var entropy [10]byte
- copy(entropy[:], sum[:10])
- id, err := ulid.New(fixtureEpochMs, bytes.NewReader(entropy[:]))
- if err != nil {
- // entropy 长度固定 10 字节,ulid.New 只会在熵不足时报错,这里不可达。
- panic(fmt.Sprintf("fixture: ULID 生成失败:%v", err))
- }
- return id.String()
- }
- // moneyOrZero 把可空的 legacy 金额列统一成十进制字符串。
- //
- // 语义:legacy 侧该列 NULL(历史上没记过)→ 目标态 NOT NULL DEFAULT 0,
- // 只能落 0;但**必须记录**,因为这与「真的是 0」在业务上是两件事。
- func moneyOrZero(v sql.NullString) (string, bool) {
- if !v.Valid || v.String == "" {
- return "0.000", false
- }
- return v.String, true
- }
- // MapExpense 映射 t_user_expense(1 个账户行)。
- func MapExpense(l LegacyExpense, canonicalUserID string) (TargetExpense, []string, error) {
- var warns []string
- out := TargetExpense{UserID: canonicalUserID}
- type col struct {
- name string
- in sql.NullString
- out *int64
- }
- cols := []col{
- {"balance", l.Balance, &out.BalanceCents},
- {"spend", l.Spend, &out.SpendCents},
- {"total_income", l.TotalIncome, &out.TotalIncomeCents},
- {"total_expenditure", l.TotalExpenditure, &out.TotalExpenditureCents},
- {"total_recommended_amount", l.TotalRecommendedAmount, &out.TotalRecommendedCents},
- {"special_expenses", l.SpecialExpenses, &out.SpecialExpensesCents},
- }
- for _, c := range cols {
- s, present := moneyOrZero(c.in)
- if !present {
- warns = append(warns, fmt.Sprintf("t_user_expense.%s 在 legacy 为 NULL,目标态 NOT NULL → 落 0.00", c.name))
- }
- cents, err := DecimalToCents(s)
- if err != nil {
- return TargetExpense{}, warns, fmt.Errorf("t_user_expense.%s:%w", c.name, err)
- }
- *c.out = cents
- }
- return out, warns, nil
- }
- // MapExpenseSchool 映射一个校区账户行(复合业务键 user_id + institution_id + campus_id)。
- func MapExpenseSchool(l LegacyExpenseSchool, canonicalUserID string) (TargetExpenseSchool, []string, error) {
- var warns []string
- out := TargetExpenseSchool{
- UserID: canonicalUserID,
- InstitutionID: l.SchoolID, // legacy schoolid → target institution_id(1:1 直迁)
- CampusID: l.CampusID, // legacy campusid → target campus_id(NULL 已在读取侧 IFNULL 0)
- }
- type col struct {
- name string
- in sql.NullString
- out *int64
- }
- cols := []col{
- {"balance", l.Balance, &out.BalanceCents},
- {"spend", l.Spend, &out.SpendCents},
- {"total_income", l.TotalIncome, &out.TotalIncomeCents},
- {"total_expenditure", l.TotalExpenditure, &out.TotalExpenditureCents},
- {"total_recommended_amount", l.TotalRecommendedAmount, &out.TotalRecommendedCents},
- {"special_expenses", l.SpecialExpenses, &out.SpecialExpensesCents},
- }
- for _, c := range cols {
- s, present := moneyOrZero(c.in)
- if !present {
- warns = append(warns, fmt.Sprintf("t_user_expense_school(id=%d).%s 在 legacy 为 NULL → 落 0.00", l.ID, c.name))
- }
- cents, err := DecimalToCents(s)
- if err != nil {
- return TargetExpenseSchool{}, warns, fmt.Errorf("t_user_expense_school(id=%d).%s:%w", l.ID, c.name, err)
- }
- *c.out = cents
- }
- return out, warns, nil
- }
- // CreatorResolver 把 legacy int create_userid 解析成 canonical_user_id(ULID)。
- // 返回 ok=false 表示该 legacy id 没有映射行。
- type CreatorResolver func(legacyUserID int64) (canonicalUserID string, ok bool)
- // MapExpenseLog 映射一条流水(D1:id 原样保留)。
- //
- // 三条已拍板的空值语义(逐列判断,不一律转 0):
- // 1. create_user_id:legacy 0(或解析不到的旧 id)→ **NULL**,不是 0;
- // 2. request_id:legacy 无此列 → **NULL**(历史数据没有幂等号);
- // 3. balance_after:legacy 无此列 → **NULL**(历史上没记余额快照)。
- func MapExpenseLog(l LegacyExpenseLog, canonicalUserID string, resolve CreatorResolver) (TargetExpenseLog, []string, error) {
- var warns []string
- out := TargetExpenseLog{
- ID: l.ID, // D1:显式插 legacy id
- UserID: canonicalUserID,
- Type: l.Type,
- RequestID: sql.NullString{}, // 恒 NULL
- BalanceAfter: sql.NullString{}, // 恒 NULL
- }
- // log_id:legacy logid(varchar(32) 旧 UUID) → target log_id(varchar(64))
- if l.LogID.Valid && l.LogID.String != "" {
- out.LogID = sql.NullString{String: l.LogID.String, Valid: true}
- }
- // money:decimal(10,3) → 整数分。legacy 为 NULL 时目标 NOT NULL,
- // 记告警并落 0(本 fixture 命中 0 行,规则仍需成立)。
- s, present := moneyOrZero(l.Money)
- if !present {
- warns = append(warns, fmt.Sprintf("t_user_expense_log(id=%d).money 在 legacy 为 NULL → 落 0.00", l.ID))
- }
- cents, err := DecimalToCents(s)
- if err != nil {
- return TargetExpenseLog{}, warns, fmt.Errorf("t_user_expense_log(id=%d).money:%w", l.ID, err)
- }
- out.MoneyCents = cents
- // create_user_id:0 / NULL / 解析不到 → NULL(不落 0、不落空串)。
- if l.CreateUserID.Valid && l.CreateUserID.Int64 > 0 {
- if uid, ok := resolve(l.CreateUserID.Int64); ok {
- out.CreateUserID = sql.NullString{String: uid, Valid: true}
- } else {
- warns = append(warns, fmt.Sprintf(
- "t_user_expense_log(id=%d).create_user_id:legacy %d 无 t_legacy_mapping 行 → NULL",
- l.ID, l.CreateUserID.Int64))
- }
- }
- // create_time:datetime(0) → datetime(3),**秒级原样**,毫秒补 .000。
- if !l.CreateTime.Valid {
- return TargetExpenseLog{}, warns, fmt.Errorf(
- "t_user_expense_log(id=%d).create_time 在 legacy 为 NULL,目标态 NOT NULL —— 拒绝用当前时间填充", l.ID)
- }
- out.CreateTime = time.Date(
- l.CreateTime.Time.Year(), l.CreateTime.Time.Month(), l.CreateTime.Time.Day(),
- l.CreateTime.Time.Hour(), l.CreateTime.Time.Minute(), l.CreateTime.Time.Second(),
- 0, l.CreateTime.Time.Location(),
- )
- // remark:text → varchar(1000)。按**字符数**截断(不是字节),并记告警。
- if l.Remark.Valid && l.Remark.String != "" {
- out.Remark = sql.NullString{String: truncateRunes(l.Remark.String, 1000), Valid: true}
- if utf8.RuneCountInString(l.Remark.String) > 1000 {
- warns = append(warns, fmt.Sprintf(
- "t_user_expense_log(id=%d).remark 超过 1000 字符,已按字符截断(目标列 varchar(1000))", l.ID))
- }
- }
- return out, warns, nil
- }
- // truncateRunes 按字符(rune)截断,避免把一个多字节汉字截成半个。
- func truncateRunes(s string, max int) string {
- if utf8.RuneCountInString(s) <= max {
- return s
- }
- rs := []rune(s)
- return string(rs[:max])
- }
|