README.md 13 KB

internal/academic/dualrun/fixture —— AC-15 阶段 2 前置件(资金域映射器)

本包做一件事:把 legacy 的真实数据,按目标态口径搬到 yuansiwei_2026,再逐列核对、清理干净。

它是「阶段 2 双跑」的前置件——先证明「结构 + 映射 + 取整口径」这条链能跑通,再去比 8 个写 SP 的产出。

已落地两批(主键口径各不相同,这是设计里最容易出错的地方):

批 表 口径
一 t_user_expense / _school / _log + t_user / t_legacy_mapping 自然键 + D1(显式插 legacy id)
二 t_user_divide_into / _log / t_user_balance_request / t_expenditure_log(+ 字典 t_expenditure) 业务键 + D1 + biz_no(旧 UUID 原样保留)
项 范围
不做的 课程链、8 个写 SP 的双跑比对;org 域(机构/校区)id 映射
不碰的 internal/academic/{service,repository,handler} 任何文件;e2e_test 库;finance_bk 只 SELECT

1. 怎么跑

# 1) 补齐 core 结构(只 CREATE TABLE IF NOT EXISTS,DDL 里的 DROP TABLE 全部丢弃)
go run ./cmd/ac15fixture ddl

# 2) 抽样搬运(默认 userid=53691)
go run ./cmd/ac15fixture map    --user 53691

# 3) 逐列核对(0=PASS,1=FAIL)
go run ./cmd/ac15fixture verify --user 53691

# 4) 清理并复核 4 张表回 0 行
go run ./cmd/ac15fixture clean  --user 53691

# 单测(纯映射规则,不连库)
go test ./internal/academic/dualrun/fixture/

第二批(biz_no 口径):

go run ./cmd/ac15fixture map2      # 默认 --root 395 --buser 31348 --euser 395 --dict=true
go run ./cmd/ac15fixture verify2   # 逐列核对 + biz_no 四项 + D1 id 一致性
go run ./cmd/ac15fixture idem2     # 幂等实测:同一批跑两次,比行数与 id
go run ./cmd/ac15fixture clean2    # 清理并复核 7 张表回 0 行
go run ./cmd/ac15fixture count2    # 打印第二批涉及表行数

环境变量(口令不落盘、不打印):AC15_DB_HOST/PORT/USER/PASS、AC15_TARGET_DB(默认 yuansiwei_2026)、 AC15_LEGACY_DB(默认 finance_bk)、AC15_LEGACY_USER/PASS、AC15_CORE_DDL。

未设 AC15_DB_PASS 时回退读仓库既有 config/dev.json 的 db.dsn(只读,不新增口令副本)。

守卫:host 必须本机;target 库必须在允许清单内且只允许写 yuansiwei_2026; legacy 连接建立后立刻 SET SESSION TRANSACTION READ ONLY,写语句会被服务端拒绝。

2. 文件

文件 职责
config.go 连接参数、DSN、白名单守卫、脱敏
decimal.go 十进制串 ↔ 整数分(唯一金额换算通道,全程不经过 float64)
model.go legacy 行 / 目标行 / 报告结构
mapping.go 纯映射规则(取整、空值语义、时间、D1 id、确定性 ULID),可单测
extract.go legacy 只读查询(金额一律 CAST(... AS DECIMAL(20,3)))
load.go 幂等写入(ON DUPLICATE KEY UPDATE)
verify.go 逐列核对 + 语义断言 + float 陷阱 + AUTO_INCREMENT
ddl.go 把 core-model-ddl.sql 安全套到目标库(丢弃 DROP、改写 IF NOT EXISTS)
run.go 流程编排(PrepareAndLoad / Cleanup)
report.go 终端报告排版
mapping_test.go 纯规则单测(含变异实验的红/绿基线)
cmd/ac15fixture CLI 入口

3. 映射规则(已拍板,代码即口径)

  1. 用户 id 走 t_legacy_mapping:legacy_academic_user_id(int) → canonical_user_id(VARCHAR(26) ULID)。 fixture 的 ULID 由 FixtureCanonicalUserID(legacyID) 确定性派生 (时间戳固定 2026-01-01 + 熵 = sha256("ac15-fixture:user:<id>")[:10]): 同一 legacy id 每次得到同一 ULID,于是 map/verify/clean 三步可独立执行、天然幂等。 生产迁移的 canonical_user_id 由归一 pipeline 的种子脚本发放,与此无关。 注意:流水里出现的 4 个「创建人」也各插 1 行映射 —— 否则 create_user_id 会全部退化成 NULL, 「解析成功」那一半就没被验证到。
  2. 主键:t_user_expense 用自然键 user_id(无自增 id);t_user_expense_school 用 uk_user_inst_campus 做幂等,不保留 legacy id(对齐键是业务复合键);t_user_expense_log 用 D1。
  3. D1(流水表):INSERT ... (id, ...) VALUES (<legacy_id>, ...),同一 legacy 流水在目标侧 id 完全相同。 不从 legacy id 反算 target id。
  4. 金额 DECIMAL(12,2):逐行 ROUND(x,2),不是 ROUND(SUM,2); 读侧 CAST(x AS DECIMAL(20,3)) 拿十进制串,写侧把整数分格式化成 "-2160.49" 交给驱动,全程无 float。
  5. 时间 DATETIME(3):legacy datetime(0) 秒级原样保留,毫秒补 .000,不使用当前时间。
  6. 空值语义(逐列判断):
    • create_user_id:legacy 0 / NULL / 无映射 → NULL(不是 0、不是空串)
    • request_id:legacy 无此列 → NULL
    • balance_after:legacy 无快照 → NULL
    • legacy 金额列 NULL(目标 NOT NULL)→ 落 0.00,但必须产生告警(NULL ≠ 0,业务上是两件事)
  7. remark text → varchar(1000):按字符截断(不切半个汉字),超长产生告警。本 fixture 最长 271 字符,未触发。

4. 核对为什么可信(期望值的来源)

核对不用本包的映射代码产生期望值——否则映射写错时两边一起错,核对永远是绿的。

期望值一律由 MySQL 服务端算:CAST(x AS DECIMAL(12,2))、DATE_FORMAT(t,'%Y-%m-%d %H:%i:%s.%f')。 于是 Go 侧的取整/换算只要与 DECIMAL 语义不一致,核对立刻红;变异实验(故意改错映射)必然红。

5. 踩到的四个真坑(都已固化在代码里,别再踩)

  1. information_schema.tables.AUTO_INCREMENT 是缓存值:MySQL 8.0 默认 information_schema_stats_expiry = 86400,读出来是 1。必须 SET SESSION information_schema_stats_expiry = 0 且 SET 与 SELECT 走同一条连接(连接池会换连接)。
  2. DELETE 不回退 AUTO_INCREMENT 计数器:fixture 反复跑时计数器单调增长, 真实表上「恰好 max+1」只有首次能观测到;因此另配 CREATE TEMPORARY TABLE ... LIKE 镜像, 每次都能可重复地演示「显式插 id=100 → 下一条 101」。
  3. SHOW TABLE STATUS LIKE ? 不支持占位符(Error 1064),表名只能内联(已加字符白名单校验)。
  4. t_user_expense_log.uk_request_type(request_id, type):若把 request_id 落成空串而不是 NULL, 同 type 的多条历史流水会撞唯一键(Error 1062)。NULL 不参与唯一键比较,这才是历史数据的正确落法。

6. 第二批:biz_no 口径(旧 UUID → int64 自增 + biz_no UNIQUE)

表 legacy 主键 target 主键 对齐键 幂等键
t_user_divide_into userid int(自然键) user_id VARCHAR(26) user_id PK user_id
t_user_divide_into_log id int AUTO_INC id BIGINT AUTO_INC id 原样(D1) PK id
t_user_balance_request id varchar(32) UUID id BIGINT AUTO_INC biz_no = 旧 id uk_biz_no
t_expenditure_log id varchar(32) UUID id BIGINT AUTO_INC biz_no = 旧 id uk_biz_no
t_expenditure(字典) id int AUTO_INC id BIGINT AUTO_INC id 原样(D1) PK id

biz_no 的四条硬约束(NormalizeBizNo):非空、长度 == 32、32 位小写 hex; 任一不满足 → 报错中止,绝不截断、绝不重新生成。依据:biz_no 是双跑与回滚的唯一对齐锚点。

写入一律 INSERT ... ON DUPLICATE KEY UPDATE: 命中 uk_biz_no 时 MySQL 只更新指定列、不改写 id,所以重跑行数不变、id 不变。 写成纯 INSERT 会在第二次跑报 1062 Duplicate entry ... for key 'uk_biz_no'(变异 M2 实测)。

t_expenditure 走 D1 的理由:t_expenditure_log.expenditureid 指向它,而它既无旧 UUID 也无业务唯一键(name 不唯一、parentid 自引用)。要让 expenditure_id 在双跑里可对齐, 只有「字典 id 原样保留」一条路。这超出本批 4 表范围,属必要补充,已在报告中待拍板。

7. 第二批:空值与时间口径(逐列判断)

列 legacy target 判定
parent_userid 0(根节点) NULL 0 是哨兵,不是真实用户
root_userid 自身 id 自身 ULID 原样映射
response_userid 0 NULL 未审批/系统
create_userid 0 NULL 系统
response_msg / remark '' 空串 保留空串 legacy 语义是「填了但为空」;这两列也无唯一键依赖(不像 uk_request_type)
item / imgs / transfer_accounts_time / response_time /orderid NULL NULL 原样
to_institution_id / to_campus_id 无此列 NULL 转结目标机构/校区在 legacy 落在 item 的 JSON 里,本批不解析(见报告「外键未做清单」)
created_at / updated_at(balance_request) 无此列 request_time / response_time ?? request_time 由真实时间派生
created_at / updated_at(divide_into) 无此列 1970-01-01 00:00:00(epoch 哨兵) legacy 无任何时间信息,绝不用 NOW()

8. 第二批:float(10,3) 的精度真相(本批最重要的实测结论)

t_expenditure_log.cost 是 FLOAT(10,3) = 4 字节单精度(约 7 位有效数字),大金额存不住 3 位小数:

legacy 显示值 实际存储(CAST(x AS DECIMAL(20,6))) 3 位取整 2 位取整(迁移口径)
204527.05 204527.046875 204527.047 204527.05
74570.19 74570.187500 74570.188 74570.19
10274.366 10274.366211 10274.366 10274.37
3326.355 3326.354980 3326.355 3326.36

两点结论:

  1. 必须 ROUND,绝不能截断——截断会系统性偏低(204527.05 → 204527.04,差 1 分;变异 M3 实测 10 行里 5 行红)。
  2. 「3 位取整再取 2 位」与「直接取 2 位」在 .xx5 边界会差 1 分: 3326.355 直接 CAST AS DECIMAL(12,2) = 3326.35,而 ROUND(CAST AS DECIMAL(20,3),2) = 3326.36。 全表 734 行里有 12 行落在这种边界上。已拍板的口径是后者(先降到 3 位再 ROUND 到 2 位), 本包 mapper 与 verify 都按这个口径实现,两者必须一致,否则核对永远红。

9. 第二批:biz_no 四项怎么验的

  1. 原样保留:把 legacy 整批 id 与 target 整批 biz_no 逐一比对,要求逐字符相等 + 长度 32 + hex 形态;
  2. 幂等:idem2 先拍 biz_no → id 快照,再完整跑一次映射,然后比对「行数 / 同 biz_no 的 id / 新增 biz_no 数」;
  3. id 解耦:目标 id 是 1..N 的连续小整数(不是 32 位 hex 的任何派生),并额外在会话临时表里 倒序插入同一批 biz_no,观察 id 仍按插入顺序 1..N 递增、biz_no 保持原值;
  4. 反查可用:finance_bk.t_x r JOIN yuansiwei_2026.t_x t ON t.biz_no = r.id 必须命中全部行——这就是双跑的对齐路径。

10. 第二批踩到的坑

  1. 目标表的 created_at / updated_at 没有默认值:DDL 文件写了 DEFAULT CURRENT_TIMESTAMP(3), 但实际库是 GORM AutoMigrate 建的,column_default 是 NULL → 不给值直接 Error 1364。 本批按纪律没有 ALTER,改为映射时显式赋值(并因此引入 epoch 哨兵口径)。
  2. CREATE TEMPORARY TABLE ... LIKE 是会话级的:连接池复用连接时临时表还在, 第二次 CREATE 会报 1050 already exists;用「表名带表名 + IF NOT EXISTS + DELETE 清空 + ALTER AUTO_INCREMENT=1」 处理(ALTER 只作用于会话临时表,不碰任何真实表)。
  3. tinyint(4) unsigned zerofill 读出来是 0001/0002 字样,必须 CAST(type AS SIGNED) 取整数。

11. 已知事实(不是缺陷,是数据现状)

  • legacy t_user_expense_log 里 create_userid = 0 或 NULL 的行数 = 0(90007 行全都有真实创建人)。 所以「0 → NULL」这条规则无法用真实数据证明,只能由 mapping_test.go 单测 + 变异实验 M3 证明。
  • legacy t_user_expense.balance/spend/total_income/total_expenditure 是 float(10,3), t_user_expense_school.* 与 t_user_expense_log.money 是 decimal(10,3)——同一业务列两边类型不同, 所以读侧统一 CAST(... AS DECIMAL(20,3)),不按列假设类型。
  • t_user_expense_school.schoolid 直迁为 institution_id(本 fixture 取到 63317), org 域(机构/校区)映射属另一条链路,本批不涉及。
  • legacy t_user_divide_into 没有任何时间列(连 created_at 都没有),所以 target 的 created_at/updated_at 只能落 epoch 哨兵;这两个值不代表真实业务时间。
  • legacy t_expenditure_log.year 最大到 2026,超出「历史数据」的直觉,但确实是库里的值(原样迁移)。