package fixture import ( "database/sql" "fmt" "regexp" "time" ) // 本文件是**第二批 4 张表的纯映射规则**(不连库、不取当前时间,输入即输出,全部可单测)。 // // 沿用的既有口径(上一批实测拍板,不重复讨论): // · 金额 DECIMAL(12,2):逐行 ROUND(x,2);legacy float 先 CAST 成十进制串再整数分换算 // · 时间 datetime(0) → DATETIME(3):秒级原样,毫秒补 .000,**不使用当前时间** // · request_id / balance_after / create_user_id=0 一律 NULL(不落 0、不落空串) // hex32 校验旧 UUID 主键形态(32 位小写 hex)。 var hex32 = regexp.MustCompile(`^[0-9a-f]{32}$`) // NormalizeBizNo 校验并原样返回 biz_no。 // // 三条硬约束(任一条不满足就**报错中止**,绝不截断、绝不改成新 UUID): // 1. 非空 // 2. 长度 == 32(target 是 VARCHAR(32)) // 3. 全为小写 hex // // 依据:biz_no 是双跑与回滚的**唯一对齐锚点**,一旦被截断/改写,割接期就再也找不回旧行。 func NormalizeBizNo(legacyID string) (string, error) { if legacyID == "" { return "", fmt.Errorf("biz_no: legacy 主键为空") } if len(legacyID) != 32 { return "", fmt.Errorf("biz_no %q 长度 %d ≠ 32(target 是 VARCHAR(32)),拒绝截断", legacyID, len(legacyID)) } if !hex32.MatchString(legacyID) { return "", fmt.Errorf("biz_no %q 不是 32 位小写 hex,形态与旧 UUID 主键约定不符,拒绝猜测", legacyID) } return legacyID, nil } // resolveUserRef 把 legacy int 用户引用解析成 ULID;legacy 0 / NULL / 无映射 → NULL。 // // 这是本批所有「引用人」列的统一口径:root_userid / parent_userid / userid / studentid / // request_userid / response_userid / create_userid 全部走它。 func resolveUserRef(v sql.NullInt64, resolve CreatorResolver, col string, warns *[]string) sql.NullString { if !v.Valid || v.Int64 <= 0 { return sql.NullString{} } uid, ok := resolve(v.Int64) if !ok { *warns = append(*warns, fmt.Sprintf("%s:legacy %d 无 t_legacy_mapping 行 → NULL", col, v.Int64)) return sql.NullString{} } return sql.NullString{String: uid, Valid: true} } // requireUserRef 用于**目标态 NOT NULL** 的引用人列(user_id / student_id / request_user_id): // 解析不到就报错,不允许静默落 NULL 或空串。 func requireUserRef(v int64, resolve CreatorResolver, col string) (string, error) { if v <= 0 { return "", fmt.Errorf("%s:legacy 值为 %d,目标态 NOT NULL 且语义上必须指向一个真实用户", col, v) } uid, ok := resolve(v) if !ok { return "", fmt.Errorf("%s:legacy %d 无 t_legacy_mapping 行,目标态 NOT NULL —— 需先补映射行", col, v) } return uid, nil } // LegacyMissingTime 是「legacy 侧没有这个字段」时的确定性哨兵值。 // // 用途:目标态 created_at / updated_at 是 NOT NULL 且**无默认值**(GORM AutoMigrate 建的), // 而 legacy `t_user_divide_into` 根本没有时间列。此时只能用哨兵,**绝不能用 NOW()**—— // 一旦用当前时间,同一份数据每次迁移结果都不同,双跑/回滚就失去可比性。 const LegacyMissingTime = "1970-01-01 00:00:00" // epochSentinel 返回 LegacyMissingTime 对应的 time.Time(本地时区,秒级)。 func epochSentinel() time.Time { return time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local) } // mapTime3 把 legacy datetime(0) 规整成「秒级、毫秒 0」的时间,落 DATETIME(3) 即 .000。 func mapTime3(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, t.Location()) } // truncateTo 按字符截断到 max(target VARCHAR 列),返回是否发生截断。 func truncateTo(v sql.NullString, max int, col string, warns *[]string) sql.NullString { if !v.Valid { return sql.NullString{} } rs := []rune(v.String) if len(rs) <= max { return sql.NullString{String: v.String, Valid: true} } *warns = append(*warns, fmt.Sprintf("%s 超过 %d 字符,已按字符截断(目标列长度限制)", col, max)) return sql.NullString{String: string(rs[:max]), Valid: true} } // MapDivideInto 映射分账树节点。 // // 空值语义: // - `parent_userid = 0` → **NULL**(根节点无上级);legacy 里根节点的 root_userid = 自己,原样保留 // - 金额 NULL → 0.00 且告警(目标 NOT NULL) func MapDivideInto(l LegacyDivideInto, resolve CreatorResolver) (TargetDivideInto, []string, error) { var warns []string uid, err := requireUserRef(l.UserID, resolve, "t_user_divide_into.userid") if err != nil { return TargetDivideInto{}, warns, err } out := TargetDivideInto{ UserID: uid, Type: int8(l.Type), RootUserID: resolveUserRef(l.RootUserID, resolve, "t_user_divide_into.root_userid", &warns), ParentUserID: resolveUserRef(l.ParentUserID, resolve, "t_user_divide_into.parent_userid", &warns), LeftVal: l.LeftVal, RightVal: l.RightVal, Layer: l.Layer, // legacy 无时间列 → epoch 哨兵(确定性,不取当前时间) CreatedAt: epochSentinel(), UpdatedAt: epochSentinel(), } type col struct { name string in sql.NullString out *int64 } for _, c := range []col{ {"quota", l.Quota, &out.QuotaCents}, {"total_income", l.TotalIncome, &out.TotalIncomeCents}, {"total_income_payed", l.TotalIncomePayed, &out.TotalIncomePayedCents}, } { s, present := moneyOrZero(c.in) if !present { warns = append(warns, fmt.Sprintf("t_user_divide_into(%d).%s 在 legacy 为 NULL → 落 0.00", l.UserID, c.name)) } cents, err := DecimalToCents(s) if err != nil { return TargetDivideInto{}, warns, fmt.Errorf("t_user_divide_into(%d).%s:%w", l.UserID, c.name, err) } *c.out = cents } return out, warns, nil } // MapDivideIntoLog 映射分账明细(D1:id 原样保留)。 func MapDivideIntoLog(l LegacyDivideIntoLog, resolve CreatorResolver) (TargetDivideIntoLog, []string, error) { var warns []string uid, err := requireUserRef(l.UserID, resolve, "t_user_divide_into_log.userid") if err != nil { return TargetDivideIntoLog{}, warns, err } sid, err := requireUserRef(l.StudentID, resolve, "t_user_divide_into_log.studentid") if err != nil { return TargetDivideIntoLog{}, warns, err } out := TargetDivideIntoLog{ ID: l.ID, // D1 Type: int8(l.Type), UserID: uid, StudentID: sid, OrderNo: truncateTo(l.OrderNo, 50, fmt.Sprintf("t_user_divide_into_log(%d).orderid", l.ID), &warns), Remark: l.Remark, // text → LONGTEXT,不截断 } s, present := moneyOrZero(l.Money) if !present { warns = append(warns, fmt.Sprintf("t_user_divide_into_log(%d).money 在 legacy 为 NULL → 落 0.00", l.ID)) } cents, err := DecimalToCents(s) if err != nil { return TargetDivideIntoLog{}, warns, fmt.Errorf("t_user_divide_into_log(%d).money:%w", l.ID, err) } out.MoneyCents = cents if !l.CreateTime.Valid { return TargetDivideIntoLog{}, warns, fmt.Errorf( "t_user_divide_into_log(%d).create_time 在 legacy 为 NULL,目标态 NOT NULL —— 拒绝用当前时间填充", l.ID) } out.CreateTime = mapTime3(l.CreateTime.Time) return out, warns, nil } // MapBalanceRequest 映射充值/退费工单(**biz_no 口径**:旧 UUID 主键原样进 biz_no)。 // // 空值语义: // - `response_userid = 0` → **NULL**(未审批/系统) // - `response_time` / `transfer_accounts_time` / `orderid` NULL → NULL // - `response_msg` / `remark` 的**空串保留空串**(legacy 语义就是「填了但为空」, // 不是「没填」;这两个列在 target 侧也没有唯一键依赖,不存在上一批 // `uk_request_type` 那种「空串撞键」的风险) // - `to_institution_id` / `to_campus_id`:legacy **无对应列** → NULL(见报告「外键未做清单」) func MapBalanceRequest(l LegacyBalanceRequest, resolve CreatorResolver) (TargetBalanceRequest, []string, error) { var warns []string bizNo, err := NormalizeBizNo(l.BizNo) if err != nil { return TargetBalanceRequest{}, warns, fmt.Errorf("t_user_balance_request.%w", err) } uid, err := requireUserRef(l.UserID, resolve, "t_user_balance_request.userid") if err != nil { return TargetBalanceRequest{}, warns, err } reqUID, err := requireUserRef(l.RequestUserID, resolve, "t_user_balance_request.request_userid") if err != nil { return TargetBalanceRequest{}, warns, err } if !l.RequestTime.Valid { return TargetBalanceRequest{}, warns, fmt.Errorf( "t_user_balance_request(%s).request_time 在 legacy 为 NULL,目标态 NOT NULL —— 拒绝用当前时间填充", bizNo) } out := TargetBalanceRequest{ BizNo: bizNo, Type: l.Type, UserID: uid, InstitutionID: l.SchoolID, // schoolid → institution_id(org 域映射未做,直迁) CampusID: l.CampusID, // campusid → campus_id(同上) TransferAccountsType: l.TransferAccountsType, OrderNo: truncateTo(l.OrderNo, 50, "t_user_balance_request("+bizNo+").orderid", &warns), RequestUserID: reqUID, RequestTime: mapTime3(l.RequestTime.Time), ResponseUserID: resolveUserRef(l.ResponseUserID, resolve, "t_user_balance_request.response_userid", &warns), ResponseMsg: truncateTo(l.ResponseMsg, 1000, "t_user_balance_request("+bizNo+").response_msg", &warns), Approved: l.Aproved, Item: truncateTo(l.Item, 1000, "t_user_balance_request("+bizNo+").item", &warns), Imgs: l.Imgs, // text → LONGTEXT Remark: truncateTo(l.Remark, 1000, "t_user_balance_request("+bizNo+").remark", &warns), } if l.TransferAccountsTime.Valid { out.TransferAccountsTime = sql.NullTime{Time: mapTime3(l.TransferAccountsTime.Time), Valid: true} } if l.ResponseTime.Valid { out.ResponseTime = sql.NullTime{Time: mapTime3(l.ResponseTime.Time), Valid: true} } // created_at / updated_at:目标态 NOT NULL 且无默认值,由 legacy 真实时间派生(不取当前时间) out.CreatedAt = mapTime3(l.RequestTime.Time) out.UpdatedAt = out.CreatedAt if l.ResponseTime.Valid { out.UpdatedAt = mapTime3(l.ResponseTime.Time) } s, present := moneyOrZero(l.Money) if !present { warns = append(warns, fmt.Sprintf("t_user_balance_request(%s).money 在 legacy 为 NULL → 落 0.00", bizNo)) } cents, err := DecimalToCents(s) if err != nil { return TargetBalanceRequest{}, warns, fmt.Errorf("t_user_balance_request(%s).money:%w", bizNo, err) } out.MoneyCents = cents return out, warns, nil } // MapExpenditureLog 映射收支记录(**biz_no 口径**)。 // // `expenditure_id`:legacy `expenditureid` 指向 `t_expenditure.id`,字典表走 D1(id 原样), // 因此这里是**恒等映射**;校验在 verify 阶段用 JOIN 字典表做,不在这里猜。 func MapExpenditureLog(l LegacyExpenditureLog, resolve CreatorResolver) (TargetExpenditureLog, []string, error) { var warns []string bizNo, err := NormalizeBizNo(l.BizNo) if err != nil { return TargetExpenditureLog{}, warns, fmt.Errorf("t_expenditure_log.%w", err) } out := TargetExpenditureLog{ BizNo: bizNo, InstitutionID: l.SchoolID, // schoolid → institution_id(org 域映射未做,直迁) CampusID: l.CampusID, ExpenditureID: l.ExpenditureID, CreateUserID: resolveUserRef(l.CreateUserID, resolve, "t_expenditure_log("+bizNo+").create_userid", &warns), Year: l.Year, Month: l.Month, Remark: l.Remark, // text → LONGTEXT Imgs: l.Imgs, } s, present := moneyOrZero(l.Cost) if !present { warns = append(warns, fmt.Sprintf("t_expenditure_log(%s).cost 在 legacy 为 NULL → 落 0.00", bizNo)) } cents, err := DecimalToCents(s) if err != nil { return TargetExpenditureLog{}, warns, fmt.Errorf("t_expenditure_log(%s).cost:%w", bizNo, err) } out.CostCents = cents if !l.CreateTime.Valid { return TargetExpenditureLog{}, warns, fmt.Errorf( "t_expenditure_log(%s).create_time 在 legacy 为 NULL,目标态 NOT NULL —— 拒绝用当前时间填充", bizNo) } out.CreateTime = mapTime3(l.CreateTime.Time) return out, warns, nil } // MapExpenditure 映射收支类型字典(D1:id 原样保留,使 expenditure_id 在双跑里可对齐)。 func MapExpenditure(l LegacyExpenditure) (TargetExpenditure, []string, error) { var warns []string out := TargetExpenditure{ ID: l.ID, // D1 Type: l.Type, ParentID: l.ParentID, Name: l.Name, Remark: l.Remark, InstitutionID: 0, // 旧库无作用域列 → 0(平台通用字典) } if len([]rune(l.Name)) > 500 { return TargetExpenditure{}, warns, fmt.Errorf( "t_expenditure(%d).name 超 500 字符(目标 varchar(500)),拒绝截断字典名称", l.ID) } return out, warns, nil }