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:") 前 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() } // FixtureCanonicalStudentID 把 legacy 的**学生 int id**映射成确定性的学生 ULID。 // // 与 `FixtureCanonicalUserID` 的关系:**同一个算法、不同的命名空间前缀**,因此 // 同一个 int 在两侧得到的是**两个不同的 ULID**。 // // 为什么必须分开(不是"装饰性"的两行常量): // // `ac15-fixture:user:` 与 `ac15-fixture:student:` 是让"学生 id 与用户 id 串用" // 这类错**在 case 上变红**的唯一手段。ULID 本身不携带类型信息 —— 一旦两个命名空间 // 合并成一个,"把 student_id 写成 user_id 的 ULID"在库里是**完全合法的 26 字符串**, // 查询照样命中、断言照样通过,这类错就**永久不可见**了。 // // 所以:任何"这一列是学生"的种子都必须走本函数,任何"这一列是用户"的走 // `FixtureCanonicalUserID`。混用会立刻表现为"JOIN 不上 / 候选集为空", // 而不是一个安静的错值。 func FixtureCanonicalStudentID(legacyStudentID int64) string { sum := sha256.Sum256([]byte(fmt.Sprintf("ac15-fixture:student:%d", legacyStudentID))) 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]) }