Q 3744037dd5 feat(ac15): 3c 课酬应收核销双跑(SP27)—— RA-1/RA-2,50→52 全绿 11 ore fa
..
README.md 8f280d0493 feat(ac15): org 域补强——停用校区影响面 + 跨机构拆成三个来源统计 15 ore fa
batch2_extract.go e924c42531 feat(ac15): 阶段2 第二批——资金域剩余4表映射,biz_no 口径首次落地 16 ore fa
batch2_load.go e924c42531 feat(ac15): 阶段2 第二批——资金域剩余4表映射,biz_no 口径首次落地 16 ore fa
batch2_mapping.go e924c42531 feat(ac15): 阶段2 第二批——资金域剩余4表映射,biz_no 口径首次落地 16 ore fa
batch2_model.go e924c42531 feat(ac15): 阶段2 第二批——资金域剩余4表映射,biz_no 口径首次落地 16 ore fa
batch2_report.go e924c42531 feat(ac15): 阶段2 第二批——资金域剩余4表映射,biz_no 口径首次落地 16 ore fa
batch2_run.go b14ab0ad19 feat(ac15): 结构对齐落地(29 条 ALTER)+ create_time 静默默认值断言 16 ore fa
batch2_test.go e924c42531 feat(ac15): 阶段2 第二批——资金域剩余4表映射,biz_no 口径首次落地 16 ore fa
batch2_verify.go b14ab0ad19 feat(ac15): 结构对齐落地(29 条 ALTER)+ create_time 静默默认值断言 16 ore fa
batch3_io.go 9fc57999ed feat(ac15): 第三批 org 域映射(机构+校区,双 D1 + 引用完整性);实测机构是 7 个不是 5 个 16 ore fa
batch3_mapping.go 9fc57999ed feat(ac15): 第三批 org 域映射(机构+校区,双 D1 + 引用完整性);实测机构是 7 个不是 5 个 16 ore fa
batch3_model.go 8f280d0493 feat(ac15): org 域补强——停用校区影响面 + 跨机构拆成三个来源统计 15 ore fa
batch3_run.go 8f280d0493 feat(ac15): org 域补强——停用校区影响面 + 跨机构拆成三个来源统计 15 ore fa
batch3_test.go 9fc57999ed feat(ac15): 第三批 org 域映射(机构+校区,双 D1 + 引用完整性);实测机构是 7 个不是 5 个 16 ore fa
batch3_verify.go 8f280d0493 feat(ac15): org 域补强——停用校区影响面 + 跨机构拆成三个来源统计 15 ore fa
config.go 695308f72c feat(ac15): 把「读旧库定义」固化成常备工具 cmd/spdump(并修掉它自己的一个静默错误) 15 ore fa
ddl.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 17 ore fa
decimal.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 17 ore fa
extract.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 17 ore fa
load.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 17 ore fa
mapping.go 3744037dd5 feat(ac15): 3c 课酬应收核销双跑(SP27)—— RA-1/RA-2,50→52 全绿 11 ore fa
mapping_test.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 17 ore fa
model.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 17 ore fa
report.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 17 ore fa
run.go b14ab0ad19 feat(ac15): 结构对齐落地(29 条 ALTER)+ create_time 静默默认值断言 16 ore fa
run_marker.go b14ab0ad19 feat(ac15): 结构对齐落地(29 条 ALTER)+ create_time 静默默认值断言 16 ore fa
schema_align.go b14ab0ad19 feat(ac15): 结构对齐落地(29 条 ALTER)+ create_time 静默默认值断言 16 ore fa
schema_align_test.go b14ab0ad19 feat(ac15): 结构对齐落地(29 条 ALTER)+ create_time 静默默认值断言 16 ore fa
verify.go b14ab0ad19 feat(ac15): 结构对齐落地(29 条 ALTER)+ create_time 静默默认值断言 16 ore fa

README.md

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 原样保留)
三 t_institution / t_campus(org 域) 双 D1(t_school.id→institution_id、t_school_campus.id→campus_id)+ 引用完整性

3.5 org 域(第三批):7 个机构,不是 5 个

初版统计给的是 5 个机构(SELECT DISTINCT schoolid FROM t_courses 得 4 个 + campus 1/2 的 school_id=1)。 按列名逐来源清点后发现漏了 2 个,各自都带孤儿引用:

漏掉的机构 被谁引用 行数
63332(用户注册,campus 57) t_user_balance_request 1 行(schoolid=63332, campusid=57)
63330(弘毅教育,campus 42) t_courses_classify 1 行(schoolid=63330)

t_courses_classify 甚至不在原统计的 7 个来源清单里——它是靠"扫所有带 schoolid 列的表"找出来的。 只迁 5 个的实测后果:2 个来源各留 1 条孤儿机构 + 1 条孤儿校区(验证见报告 M1 变异)。

最终集合 = 1, 63317, 63323, 63324, 63325, 63330, 63332(7 个机构 / 29 个校区),孤儿引用 = 0。

8 个停用校区上有 7,734 条记录(与备忘 7 同源)

所选 29 个校区里 21 个 status=1、8 个 status=0([3,4,8,9,14,25,58,60])。那 8 个"停用"校区上挂着:

campus 名称 课程 上课记录 校区账户 充值工单 收支记录
3 普罗旺斯校区 299 4,126 209 462 52
4 凤岭北校区 82 954 80 114 65
8 华奥校区 0 15 4 4 12
9 雅维思校区 2 128 17 43 32
14 源思维B区 1 10 1 0 0
25 作废 0 0 0 0 0
58 普罗旺斯校区(新) 166 2,460 138 239 28
60 新民校区 0 41 11 28 12
合计 550 7,734 460 890 201

迁移后这 8 个校区是 disabled → 挂在它们上面的 7,734 条上课记录在「按启用校区筛选」时看不到; 写入侧 assertInScope 也会拒绝"给停用校区新增数据"(这是对的)。

两条硬约束:

  1. status 必须如实写 0,不许为了"让数据可见"改成 1 —— 那是在用数据说谎;
  2. 断言:count(t_campus WHERE status='disabled') == 8 且 id 精确等于 [3,4,8,9,14,25,58,60], 另加一条「这 8 个没有被改成非 disabled」。

业务含义与处置方式见迁移备忘 7("按校区筛选看不到历史数据")。

跨机构有三个来源,不是三个说法

类 比较 总行数 一致 不一致 性质
A 课程级 t_courses.schoolid ↔ 其 campusid 的校区真实所属 3142 1928 250 R17 主体(校区 1/2 属机构 1,课程普遍挂 63317)
B 记录级 t_courses_record.schoolid ↔ 其 campusid 的校区真实所属 74870 70801 4068 含反向跨机构(记录挂机构 1、校区属 63317)
C 记录 vs 课程 t_courses_record.schoolid ↔ 其 coursesid 的 schoolid 74870 74868 2 与校区无关,纯粹记录挂错机构

C 的 2 行明细:f296ac36…(record.schoolid=1 / campusid=0 / 课程 2933 属 63317)、 f0abeebb…(record.schoolid=1 / campusid=5 / 课程 2934 属 63317)—— 后者同时也是 B 里那条反向记录。

为什么要分三层:混在一起报时,"250 / 4068 / 2" 会被读成一个数,看不出到底是"课程挂错机构" 还是"记录挂错机构"还是"记录与课程不一致"——而 SP 双跑时这三层的处理方式不同。

三条 org 域口径:

  • R17:campus 1/2 的 institution_id 如实保留 school_id=1,不修正、不反推、不特判; 数据里 (schoolid=63317, campusid=1/2) 的组合只在报告里标 cross-org-campus。
  • campus_id=0 不建行:0 是"机构级/未分配"的约定值,建了会让前端校区下拉框多出一个"未知校区"。
  • status 是值域翻译不是占位:tinyint 1→active / 0→disabled,出现 1/0 之外的值直接报错, 绝不默认 active(那会把已停用的机构/校区悄悄"复活")。
项 范围
不做的 课程链、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 原样保留」一条路。

⚠ 字典的 D1 是"临时手段",与流水表的 D1 不同性质: 流水表只增不改,历史行的 id 不会再变,D1 永久成立; 而字典项会被业务新增——新增项在 legacy 与 target 两边各涨各的 id,一旦不齐,"按 id 对齐"立刻失效。 因此:字典的 D1 只在「两边都从同一份 legacy 快照起步」时成立;割接后新增字典项必须走 biz_no 或业务键。

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()

⚠ epoch 哨兵的适用边界(与上一条同等重要):

  1. 这两列对分账表没有任何业务含义,是纯占位值(legacy 连时间列都没有);
  2. 双跑时 created_at / updated_at 不参与比对(列矩阵里标 N/A);
  3. 如果将来分账表真需要时间,必须新建列并回填,不能把 epoch 当真实时间用; 也不要把「1970-01-01」当成"早期数据"去统计——它是哨兵,不是时间点。

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. 结构对齐工具(align)

dev 库 yuansiwei_2026 是"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体。 GORM 不处理 DEFAULT CURRENT_TIMESTAMP(3),所以它建的表 created_at / updated_at 是 NOT NULL 且 无默认值 —— 裸 SQL 插入会在随机某几张表上直接 Error 1364。

go run ./cmd/ac15fixture align            # 逐表比对 DDL 与实库 → 打印 ALTER 清单 + dry-run(不执行)
go run ./cmd/ac15fixture align --apply    # 清单人工确认后执行
go run ./cmd/ac15fixture align --verify   # 只复核两列 COLUMN_DEFAULT 是否全部非 NULL

已执行(2026-11-10,dev 库):created_at/updated_at 16 条(含 t_campus 两列按 DDL 改 NOT NULL)、 create_time 13 条,共 29 条 ALTER、0 失败;复核 33 + 13 列 COLUMN_DEFAULT 全非 NULL。 全库 40 表,除 4 张种子表(t_role 7 / t_permission 6 / t_role_permission 10 / casbin_rule 14 = 37 行)外全 0 行。

⚠ ALGORITHM 的版本限制(实测):MySQL 8.0.23 上 MODIFY COLUMN 改默认值 不支持 ALGORITHM=INSTANT,也不支持 INPLACE(Error 1845 ... Try ALGORITHM=COPY); 该能力要 8.0.29+ 才有。因此本机执行时用 --algorithm copy 显式声明"我知道要重建" (0 行表重建零成本,已复核 TABLE_ROWS 未变)。升级到 8.0.29+ 后应改回 --algorithm instant。

设计上刻意的四条约束:

  1. 只 ALTER TABLE ... MODIFY,绝不 DROP 重建 —— 重建会把 GORM 建的索引/字符集一起改掉,产生新漂移;
  2. MODIFY 子句用实库现有属性(column_type / is_nullable / column_comment), 只把 DEFAULT 与 ON UPDATE 换成 DDL 口径 —— 除这两项外一个字节都不动;
  3. NULL 性 / 类型 / 注释等其它差异只报告不执行(drift-nullable 等分类),等人工定夺;
  4. 每条 ALTER 先在会话临时表上 dry-run(CREATE TEMPORARY TABLE ... LIKE + 同一句 MODIFY), 语法或属性不合法的语句在执行前就暴露。

11.5 第三批怎么跑

go run ./cmd/ac15fixture map3      # 默认 7 个机构、29 个校区
go run ./cmd/ac15fixture verify3   # 逐列 + 机构集合 + 引用完整性(8 个来源逐个统计孤儿)+ R17 + bit(1)
go run ./cmd/ac15fixture idem3     # 跑两次比行数与 id 集合
go run ./cmd/ac15fixture clean3    # 两表回 0 行

参数:--ids 1,63317,...(显式指定机构集合)、--all-campuses=false(只迁数据里出现过的校区)。

12. create_time 的静默错:为什么必须配断言

补了 DEFAULT CURRENT_TIMESTAMP(3) 之后,create_time 的风险等级反而变高:

列 语义 漏传时的后果(补默认前 → 补默认后)
created_at 审计:这行何时入库 1364 报错 → 取当前时间(合理)
create_time 业务:这笔钱何时发生 1364 报错 → 取插入时刻(静默写错,前端要展示/排序/按区间查)

所以映射侧根本不靠默认值:create_time 一律由 legacy 显式赋值;同时 verify 侧加了一条硬断言 (run_marker.go):

迁移执行时刻(t_audit_log 里 action='ac15_fixture_run' 的标记)
   ↓
count(该表 create_time >= 迁移执行时刻) == 0

为什么必须落库取时间戳:map 与 verify 是两个独立进程,时间戳无法经内存传递; 而 legacy 的历史业务时间必然早于本次迁移,所以「>= 迁移执行时刻」就是「被 DB 默认值填过」的判据。 变异 M5(映射时故意不传 create_time)实测:create_time >= 该时刻的行数 = 10(期望 0) → 红。

13. 清理要注意的跨批耦合

两批 fixture 共用 t_user / t_legacy_mapping(batch1 的创建人 20052/45014 也是 batch2 的申请人)。 因此清理要两批都跑一遍(clean2 → clean):单跑一批时,另一批的映射行还在, 该表的「全表 0 行」判定会显示 FAIL —— 这是预期的,不是漏删。

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

  • 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,超出「历史数据」的直觉,但确实是库里的值(原样迁移)。