| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- 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
- }
|