Explorar el Código

feat(ac15): 阶段2 第二批——资金域剩余4表映射,biz_no 口径首次落地

范围:t_user_divide_into / t_user_divide_into_log / t_user_balance_request /
t_expenditure_log(+ 解析 expenditure_id 所需的字典 t_expenditure)。

一、按主键类型分三种口径(先 SHOW CREATE TABLE 核实,未照猜)
- t_user_divide_into:legacy PK = userid int(自然键)→ 对齐键 user_id(ULID)
- t_user_divide_into_log:legacy PK = id int AUTO_INCREMENT → D1 显式插 id
- t_user_balance_request:legacy PK = id varchar(32) UUID → biz_no = 旧 id
- t_expenditure_log:legacy PK = id varchar(32) UUID → biz_no = 旧 id
- t_expenditure(字典):id int AUTO_INCREMENT、无 UUID 无业务唯一键 → D1 保留 id,
  否则 expenditure_id 在双跑里无法对齐

二、biz_no 四项验证(全部实测)
1) 原样保留:legacy 8/10 行 vs target 8/10 行,逐字符相等 8/8、10/10,长度=32、hex 形态全通过
2) 幂等:idem2 跑两次,8 行→8 行、10 行→10 行,同 biz_no 的 id 不变,新增 biz_no = 0
3) id 解耦:target id 是 1..N 连续自增;临时表反序插入 biz_no 后 id 仍按插入顺序 1..N 递增
4) 反查可用:biz_no JOIN 回 legacy 命中 8/8、10/10 行(双跑对齐路径)

三、D1 id 一致性:t_user_divide_into_log legacy=[26 90 113 137] = target=[26 90 113 137]

四、逐列核对:5 张表 60 个列聚合 + 8 条断言全绿(84/84 行级相等)
- 金额 float(10,3) → DECIMAL(12,2),逐行 ROUND 到分
- 空值:parent_userid=0 → NULL(根节点)、response_userid=0 → NULL、create_userid=0 → NULL、
  item/imgs/时间列 NULL → NULL、response_msg/remark 空串保留空串
- 引用人 17 个 legacy uid → ULID,期望值经 t_legacy_mapping 反查(不由映射代码产生)

五、变异实验 4 组(全部实测红→绿,已还原,git diff 干净)
- M1 biz_no 改成派生 UUID:逐字符相等断言红(target 16 行 vs legacy 8 行)
- M2 去掉 ON DUPLICATE KEY UPDATE:第二次跑 Error 1062 uk_biz_no
- M3 取整改截断:cost 5/10 行红(204527.05→204527.04 …)
- M4 parent_userid/response_userid 的 0 落成 "0":2 列 + 2 断言红

六、本批最重要的实测结论:float(10,3) 是单精度(4 字节)
- 204527.05 实际存 204527.046875;10274.366 存 10274.366211;3326.355 存 3326.354980
- 必须 ROUND(截断系统性偏低);且「3 位取整再取 2 位」与「直接取 2 位」在 .xx5 边界差 1 分,
  全表 734 行有 12 行落在边界上 —— 已拍板口径取前者,mapper 与 verify 必须同口径

七、结构漂移(未 ALTER,按纪律显式赋值)
- DDL 文件写 created_at/updated_at DEFAULT CURRENT_TIMESTAMP(3),实际库 column_default = NULL
  (GORM AutoMigrate 建的)→ 不赋值直接 Error 1364
- 处理:balance_request 由 request_time / response_time 派生;
  divide_into legacy 无任何时间列 → epoch 哨兵 1970-01-01(绝不用 NOW())

八、清理:7 张表回 0 行(含 biz_no 变异残留的安全网:按「fixture 用户 + 非期望 biz_no」双条件删)

未改动 internal/academic/{service,repository,handler};finance_bk 全程只读。
Q hace 6 horas
padre
commit
e924c42531

+ 176 - 24
cmd/ac15fixture/main.go

@@ -1,13 +1,23 @@
 // Command ac15fixture 是 AC-15 阶段 2 前置件的 CLI 入口。
 //
-// 用法(在仓库根目录执行):
+// 两批能力:
 //
-//	go run ./cmd/ac15fixture ddl                     # 用 core-model-ddl.sql 补齐目标库结构(只 CREATE,不 DROP)
-//	go run ./cmd/ac15fixture count                   # 打印 5 张相关表当前行数
-//	go run ./cmd/ac15fixture map    --user 53691     # 抽样搬运(不改任何已有 target 业务代码)
-//	go run ./cmd/ac15fixture verify --user 53691     # 逐列核对(退出码 0=PASS,1=FAIL)
-//	go run ./cmd/ac15fixture run    --user 53691     # map + verify
-//	go run ./cmd/ac15fixture clean  --user 53691     # 删除 fixture 数据并复核回 0 行
+//	第一批(资金域 4 表:t_user_expense / _school / _log + t_user / t_legacy_mapping)
+//	  ddl                    用 core-model-ddl.sql 补齐目标库结构(只 CREATE,不 DROP)
+//	  map    --user 53691    抽样搬运
+//	  verify --user 53691    逐列核对(退出码 0=PASS,1=FAIL)
+//	  run    --user 53691    map + verify
+//	  clean  --user 53691    删除 fixture 数据并复核回 0 行
+//	  count                  打印相关表当前行数
+//
+//	第二批(t_user_divide_into / _log / t_user_balance_request / t_expenditure_log
+//	        + 字典 t_expenditure,biz_no 口径首次落地)
+//	  map2                    抽样搬运 4 表
+//	  verify2                 逐列核对 + biz_no 四项 + D1 id 一致性
+//	  idem2                   幂等实测(同一批跑两次,比对行数与 id)
+//	  run2                    map2 + verify2 + idem2
+//	  clean2                  清理并复核 7 张表回 0 行
+//	  count2                  打印第二批涉及表当前行数
 //
 // 环境变量(口令不落盘、不打印):
 //
@@ -47,6 +57,18 @@ func main() {
 		os.Exit(runClean(args))
 	case "count":
 		os.Exit(runCount(args))
+	case "map2":
+		os.Exit(runMap2(args))
+	case "verify2":
+		os.Exit(runVerify2(args))
+	case "idem2":
+		os.Exit(runIdem2(args))
+	case "run2":
+		os.Exit(runRun2(args))
+	case "clean2":
+		os.Exit(runClean2(args))
+	case "count2":
+		os.Exit(runCount2(args))
 	case "-h", "--help", "help":
 		usage()
 		os.Exit(0)
@@ -58,21 +80,31 @@ func main() {
 }
 
 func usage() {
-	fmt.Fprint(os.Stderr, `ac15fixture —— AC-15 阶段 2 前置件(资金域 4 表映射 + 逐列核对)
+	fmt.Fprint(os.Stderr, `ac15fixture —— AC-15 阶段 2 前置件(资金域映射 + 逐列核对)
+
+第一批(D1 口径为主):
+  ddl       补齐 core-model-ddl.sql 的 13 张表(只 CREATE TABLE IF NOT EXISTS,禁止 DROP)
+  map       抽样搬运一个 legacy 用户(账户 + 校区账户 + 流水)
+  verify    逐列核对 4 张表 + AUTO_INCREMENT + 空值语义 + float 陷阱
+  run       map + verify
+  clean     删除 fixture 数据并复核 4 张表回 0 行
+  count     打印相关表当前行数
 
-子命令:
-  ddl      补齐 core-model-ddl.sql 的 13 张表(只 CREATE TABLE IF NOT EXISTS,禁止 DROP)
-  map      抽样搬运一个 legacy 用户(账户 + 校区账户 + 流水)
-  verify   逐列核对(4 张表 + AUTO_INCREMENT + 空值语义 + float 陷阱)
-  run      map + verify
-  clean    删除 fixture 数据并复核 4 张表回 0 行
-  count    打印相关表当前行数
+第二批(biz_no 口径首次落地):
+  map2      搬运 t_user_divide_into / _log / t_user_balance_request / t_expenditure_log(+字典)
+  verify2   逐列核对 + biz_no 四项 + D1 id 一致性
+  idem2     幂等实测(同一批跑两次,比行数与 id)
+  run2      map2 + verify2 + idem2
+  clean2    清理并复核 7 张表回 0 行
+  count2    打印第二批涉及表当前行数
 
-通用参数:--user <legacy userid>(默认 53691), --ddl <path>
+第一批参数:--user <legacy userid>(默认 53691), --ddl <path>
+第二批参数:--root <分账树根 userid,默认 395>, --buser <充值工单用户,默认 31348>,
+            --euser <收支记录创建人,默认 395>, --dict <是否迁移字典,默认 true>
 `)
 }
 
-// bootstrap 解析公共参数、加载配置并跑守卫。
+// bootstrap 解析第一批公共参数、加载配置并跑守卫。
 func bootstrap(args []string, withUser bool) (fixture.Config, int64, string, int) {
 	fs := flag.NewFlagSet("ac15fixture", flag.ContinueOnError)
 	fs.SetOutput(os.Stderr)
@@ -84,23 +116,55 @@ func bootstrap(args []string, withUser bool) (fixture.Config, int64, string, int
 	if !withUser {
 		*user = fixture.DefaultLegacyUserID
 	}
+	cfg, code := loadConfig()
+	if code != 0 {
+		return fixture.Config{}, 0, "", code
+	}
+	return cfg, *user, *ddl, 0
+}
 
+// loadConfig 取工作目录 → 读配置 → 守卫 → 打印脱敏后的连接串。
+func loadConfig() (fixture.Config, int) {
 	repoRoot, err := os.Getwd()
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "FATAL: 取工作目录失败:%v\n", err)
-		return fixture.Config{}, 0, "", 3
+		return fixture.Config{}, 3
 	}
 	cfg, err := fixture.LoadConfig(repoRoot)
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
-		return fixture.Config{}, 0, "", 3
+		return fixture.Config{}, 3
 	}
 	if err := cfg.Guard(); err != nil {
 		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
-		return fixture.Config{}, 0, "", 3
+		return fixture.Config{}, 3
 	}
 	fmt.Printf("target=%s  legacy=%s\n\n", cfg.TargetMasked(), cfg.LegacyMasked())
-	return cfg, *user, *ddl, 0
+	return cfg, 0
+}
+
+// bootstrap2 解析第二批参数。
+func bootstrap2(args []string) (fixture.Config, fixture.Batch2Options, int) {
+	def := fixture.DefaultBatch2Options()
+	fs := flag.NewFlagSet("ac15fixture-b2", flag.ContinueOnError)
+	fs.SetOutput(os.Stderr)
+	root := fs.Int64("root", def.DivideRootUserID, "分账树根 userid(取整棵子树)")
+	buser := fs.Int64("buser", def.BalanceRequestUserID, "充值工单所属 userid")
+	euser := fs.Int64("euser", def.ExpenditureLogUserID, "收支记录创建人 userid")
+	dict := fs.Bool("dict", def.WithExpenditureDict, "是否同时 D1 迁移 t_expenditure 字典")
+	if err := fs.Parse(args); err != nil {
+		return fixture.Config{}, fixture.Batch2Options{}, 2
+	}
+	cfg, code := loadConfig()
+	if code != 0 {
+		return fixture.Config{}, fixture.Batch2Options{}, code
+	}
+	return cfg, fixture.Batch2Options{
+		DivideRootUserID:     *root,
+		BalanceRequestUserID: *buser,
+		ExpenditureLogUserID: *euser,
+		WithExpenditureDict:  *dict,
+	}, 0
 }
 
 func runDDL(args []string) int {
@@ -182,9 +246,97 @@ func runCount(args []string) int {
 		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
 		return 3
 	}
-	fmt.Println("== 目标表行数 ==")
-	for _, t := range []string{"t_user", "t_legacy_mapping", "t_user_expense", "t_user_expense_school", "t_user_expense_log"} {
-		fmt.Printf("  %-24s %d\n", t, counts[t])
+	fixture.PrintCounts(os.Stdout, "第一批目标表行数", counts,
+		[]string{"t_user", "t_legacy_mapping", "t_user_expense", "t_user_expense_school", "t_user_expense_log"})
+	return 0
+}
+
+// ---------- 第二批 ----------
+
+func runMap2(args []string) int {
+	cfg, opts, code := bootstrap2(args)
+	if code != 0 {
+		return code
+	}
+	rep, err := fixture.PrepareBatch2(context.Background(), cfg, opts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	fixture.PrintBatch2Report(os.Stdout, rep)
+	return 0
+}
+
+func runVerify2(args []string) int {
+	cfg, opts, code := bootstrap2(args)
+	if code != 0 {
+		return code
+	}
+	rep, err := fixture.VerifyBatch2(context.Background(), cfg, opts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	fixture.PrintBatch2Verify(os.Stdout, rep)
+	if !rep.OK() {
+		return 1
+	}
+	return 0
+}
+
+func runIdem2(args []string) int {
+	cfg, opts, code := bootstrap2(args)
+	if code != 0 {
+		return code
+	}
+	rep, err := fixture.IdempotencyCheck(context.Background(), cfg, opts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	fixture.PrintIdempotency(os.Stdout, rep)
+	if !rep.AllOK() {
+		return 1
+	}
+	return 0
+}
+
+func runRun2(args []string) int {
+	if code := runMap2(args); code != 0 {
+		return code
+	}
+	fmt.Println()
+	if code := runVerify2(args); code != 0 {
+		return code
+	}
+	fmt.Println()
+	return runIdem2(args)
+}
+
+func runClean2(args []string) int {
+	cfg, opts, code := bootstrap2(args)
+	if code != 0 {
+		return code
+	}
+	rep, err := fixture.CleanupBatch2(context.Background(), cfg, opts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	fixture.PrintBatch2Clean(os.Stdout, rep)
+	return 0
+}
+
+func runCount2(args []string) int {
+	cfg, _, code := bootstrap2(args)
+	if code != 0 {
+		return code
+	}
+	counts, err := fixture.CountBatch2Tables(context.Background(), cfg)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
 	}
+	fixture.PrintCounts(os.Stdout, "第二批目标表行数", counts, fixture.Batch2Tables())
 	return 0
 }

+ 95 - 5
internal/academic/dualrun/fixture/README.md

@@ -1,13 +1,19 @@
-# `internal/academic/dualrun/fixture` —— AC-15 阶段 2 前置件(资金域 4 表映射器)
+# `internal/academic/dualrun/fixture` —— AC-15 阶段 2 前置件(资金域映射器)
 
-本包做一件事:**把 legacy 的一个真实用户,按目标态口径搬到 `yuansiwei_2026`,再逐列核对、清理干净**。
+本包做一件事:**把 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 原样保留) |
+
 | 项 | 范围 |
 |---|---|
-| 表 | 资金域 4 表:`t_user_expense` / `t_user_expense_school` / `t_user_expense_log` + 解析用户所需的 `t_user` / `t_legacy_mapping` |
-| 不做的 | 其余 8 张资金/活动表、课程链、8 个写 SP 的双跑比对(下一批) |
+| 不做的 | 课程链、8 个写 SP 的双跑比对;org 域(机构/校区)id 映射 |
 | 不碰的 | `internal/academic/{service,repository,handler}` 任何文件;`e2e_test` 库;`finance_bk` 只 SELECT |
 
 ---
@@ -31,6 +37,16 @@ go run ./cmd/ac15fixture clean  --user 53691
 go test ./internal/academic/dualrun/fixture/
 ```
 
+第二批(biz_no 口径):
+
+```bash
+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`。
 
@@ -98,7 +114,78 @@ legacy 连接建立后立刻 `SET SESSION TRANSACTION READ ONLY`,写语句会
 4. **`t_user_expense_log.uk_request_type(request_id, type)`**:若把 `request_id` 落成空串而不是 NULL,
    同 `type` 的多条历史流水会撞唯一键(Error 1062)。NULL 不参与唯一键比较,这才是历史数据的正确落法。
 
-## 6. 已知事实(不是缺陷,是数据现状)
+## 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 证明。
@@ -107,3 +194,6 @@ legacy 连接建立后立刻 `SET SESSION TRANSACTION READ ONLY`,写语句会
   所以读侧统一 `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**,超出「历史数据」的直觉,但确实是库里的值(原样迁移)。

+ 240 - 0
internal/academic/dualrun/fixture/batch2_extract.go

@@ -0,0 +1,240 @@
+package fixture
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+)
+
+// 本文件是第二批的 legacy **只读**查询。金额列一律 `CAST(... AS DECIMAL(20,3))`,
+// 浮点误差在读取源头就被掐掉(与上一批同一口径)。
+//
+// fixture 取样范围(可由 CLI 覆盖):
+//   分账树:root_userid = 395 的整棵子树(8 个节点:1 根 + 7 子)
+//   分账明细:userid = 395 的 4 条
+//   充值工单:userid = 31348 的 8 条(含 response_userid=0、3 位小数金额、NULL 时间)
+//   收支记录:create_userid = 395 的 10 条(含 3 位小数 cost、字典引用 15/23)
+//   收支字典:全表 45 条(解析 expenditure_id 用)
+
+// Batch2Options 是第二批 fixture 的取样参数。
+type Batch2Options struct {
+	DivideRootUserID     int64 // 分账树根(取整棵子树)
+	BalanceRequestUserID int64 // 充值工单所属用户
+	ExpenditureLogUserID int64 // 收支记录创建人
+	WithExpenditureDict  bool  // 是否同时 D1 迁移 t_expenditure 字典(解析 expenditure_id 必需)
+}
+
+// DefaultBatch2Options 是默认取样(选择依据见 README §7)。
+func DefaultBatch2Options() Batch2Options {
+	return Batch2Options{
+		DivideRootUserID:     395,
+		BalanceRequestUserID: 31348,
+		ExpenditureLogUserID: 395,
+		WithExpenditureDict:  true,
+	}
+}
+
+func extractDivides(ctx context.Context, db *sql.DB, rootUserID int64) ([]LegacyDivideInto, error) {
+	const q = `
+SELECT userid, cast(ifnull(type,1) as signed),
+       CAST(ifnull(quota,0)              AS DECIMAL(20,3)),
+       CAST(ifnull(total_income,0)       AS DECIMAL(20,3)),
+       CAST(ifnull(total_income_payed,0) AS DECIMAL(20,3)),
+       root_userid, parent_userid,
+       cast(ifnull(left_val,1) as signed), cast(ifnull(right_val,2) as signed),
+       cast(ifnull(layer,1) as signed)
+  FROM t_user_divide_into
+ WHERE root_userid = ?
+ ORDER BY left_val, userid`
+	rows, err := db.QueryContext(ctx, q, rootUserID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []LegacyDivideInto
+	for rows.Next() {
+		var r LegacyDivideInto
+		if err := rows.Scan(&r.UserID, &r.Type, &r.Quota, &r.TotalIncome, &r.TotalIncomePayed,
+			&r.RootUserID, &r.ParentUserID, &r.LeftVal, &r.RightVal, &r.Layer); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+func extractDivideLogs(ctx context.Context, db *sql.DB, userID int64) ([]LegacyDivideIntoLog, error) {
+	const q = `
+SELECT id, cast(type as signed), userid, studentid, orderid,
+       CAST(ifnull(money,0) AS DECIMAL(20,3)),
+       create_time, remark
+  FROM t_user_divide_into_log
+ WHERE userid = ?
+ ORDER BY id`
+	rows, err := db.QueryContext(ctx, q, userID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []LegacyDivideIntoLog
+	for rows.Next() {
+		var r LegacyDivideIntoLog
+		if err := rows.Scan(&r.ID, &r.Type, &r.UserID, &r.StudentID, &r.OrderNo,
+			&r.Money, &r.CreateTime, &r.Remark); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+func extractBalanceRequests(ctx context.Context, db *sql.DB, userID int64) ([]LegacyBalanceRequest, error) {
+	const q = `
+SELECT id, type, userid, schoolid, campusid,
+       CAST(money AS DECIMAL(20,3)),
+       transfer_accounts_type, transfer_accounts_time, orderid,
+       request_userid, request_time, response_userid, response_time,
+       response_msg, aproved, item, imgs, remark
+  FROM t_user_balance_request
+ WHERE userid = ?
+ ORDER BY request_time, id`
+	rows, err := db.QueryContext(ctx, q, userID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []LegacyBalanceRequest
+	for rows.Next() {
+		var r LegacyBalanceRequest
+		if err := rows.Scan(&r.BizNo, &r.Type, &r.UserID, &r.SchoolID, &r.CampusID, &r.Money,
+			&r.TransferAccountsType, &r.TransferAccountsTime, &r.OrderNo,
+			&r.RequestUserID, &r.RequestTime, &r.ResponseUserID, &r.ResponseTime,
+			&r.ResponseMsg, &r.Aproved, &r.Item, &r.Imgs, &r.Remark); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+func extractExpenditureLogs(ctx context.Context, db *sql.DB, createUserID int64) ([]LegacyExpenditureLog, error) {
+	const q = `
+SELECT id, schoolid, campusid, expenditureid,
+       CAST(cost AS DECIMAL(20,3)),
+       create_userid, create_time, year, month, remark, imgs
+  FROM t_expenditure_log
+ WHERE create_userid = ?
+ ORDER BY id`
+	rows, err := db.QueryContext(ctx, q, createUserID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []LegacyExpenditureLog
+	for rows.Next() {
+		var r LegacyExpenditureLog
+		if err := rows.Scan(&r.BizNo, &r.SchoolID, &r.CampusID, &r.ExpenditureID, &r.Cost,
+			&r.CreateUserID, &r.CreateTime, &r.Year, &r.Month, &r.Remark, &r.Imgs); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+func extractExpenditures(ctx context.Context, db *sql.DB) ([]LegacyExpenditure, error) {
+	const q = `SELECT id, type, parentid, name, remark FROM t_expenditure ORDER BY id`
+	rows, err := db.QueryContext(ctx, q)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []LegacyExpenditure
+	for rows.Next() {
+		var r LegacyExpenditure
+		if err := rows.Scan(&r.ID, &r.Type, &r.ParentID, &r.Name, &r.Remark); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+// collectUserRefs 汇总这批数据里出现的**全部 legacy 用户引用**,
+// 用于一次性补 t_legacy_mapping / t_user(否则引用列会全退化成 NULL)。
+func collectUserRefs(divides []LegacyDivideInto, dlog []LegacyDivideIntoLog,
+	brs []LegacyBalanceRequest, elogs []LegacyExpenditureLog) []int64 {
+	set := map[int64]bool{}
+	add := func(v int64) {
+		if v > 0 {
+			set[v] = true
+		}
+	}
+	for _, d := range divides {
+		add(d.UserID)
+		if d.RootUserID.Valid {
+			add(d.RootUserID.Int64)
+		}
+		if d.ParentUserID.Valid {
+			add(d.ParentUserID.Int64)
+		}
+	}
+	for _, l := range dlog {
+		add(l.UserID)
+		add(l.StudentID)
+	}
+	for _, r := range brs {
+		add(r.UserID)
+		add(r.RequestUserID)
+		if r.ResponseUserID.Valid {
+			add(r.ResponseUserID.Int64)
+		}
+	}
+	for _, e := range elogs {
+		if e.CreateUserID.Valid {
+			add(e.CreateUserID.Int64)
+		}
+	}
+	out := make([]int64, 0, len(set))
+	for v := range set {
+		out = append(out, v)
+	}
+	sortInt64s(out)
+	return out
+}
+
+func sortInt64s(v []int64) {
+	for i := 1; i < len(v); i++ {
+		for j := i; j > 0 && v[j] < v[j-1]; j-- {
+			v[j], v[j-1] = v[j-1], v[j]
+		}
+	}
+}
+
+// countBatch2Legacy 统计这批在 legacy 侧的行数。
+func countBatch2Legacy(ctx context.Context, db *sql.DB, opts Batch2Options) (map[string]int, error) {
+	out := map[string]int{}
+	type q1 struct {
+		name string
+		sql  string
+		arg  int64
+	}
+	for _, x := range []q1{
+		{"t_user_divide_into", `SELECT COUNT(*) FROM t_user_divide_into WHERE root_userid = ?`, opts.DivideRootUserID},
+		{"t_user_divide_into_log", `SELECT COUNT(*) FROM t_user_divide_into_log WHERE userid = ?`, opts.DivideRootUserID},
+		{"t_user_balance_request", `SELECT COUNT(*) FROM t_user_balance_request WHERE userid = ?`, opts.BalanceRequestUserID},
+		{"t_expenditure_log", `SELECT COUNT(*) FROM t_expenditure_log WHERE create_userid = ?`, opts.ExpenditureLogUserID},
+	} {
+		var n int
+		if err := db.QueryRowContext(ctx, x.sql, x.arg).Scan(&n); err != nil {
+			return nil, fmt.Errorf("%s:%w", x.name, err)
+		}
+		out[x.name] = n
+	}
+	var n int
+	if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM t_expenditure`).Scan(&n); err != nil {
+		return nil, err
+	}
+	out["t_expenditure"] = n
+	return out, nil
+}

+ 175 - 0
internal/academic/dualrun/fixture/batch2_load.go

@@ -0,0 +1,175 @@
+package fixture
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+)
+
+// 本文件是第二批的写入层,只写 5 张表:
+//   t_expenditure(字典,D1)/ t_user_divide_into / t_user_divide_into_log /
+//   t_user_balance_request / t_expenditure_log
+//
+// 两种主键口径各自的幂等写法,是本批的核心:
+//   · D1(显式插 id):主键就是 legacy id,天然幂等 → `ON DUPLICATE KEY UPDATE` 命中 PK
+//   · biz_no:target 的 id 由自增发号,幂等键是 `uk_biz_no` 唯一键 → `ON DUPLICATE KEY UPDATE`
+//     命中唯一键时 **MySQL 只更新指定列,不会改写 id**,所以重跑 id 不变。
+//     切记不能写成纯 `INSERT`——那会在第二次跑报 1062,或(若无唯一键)插出重复行。
+
+// upsertExpenditureDict 写收支类型字典(D1)。
+func upsertExpenditureDict(ctx context.Context, db *sql.DB, r TargetExpenditure) error {
+	const q = `
+INSERT INTO t_expenditure (id, type, parent_id, name, remark, institution_id)
+VALUES (?, ?, ?, ?, ?, ?)
+ON DUPLICATE KEY UPDATE
+  type           = VALUES(type),
+  parent_id      = VALUES(parent_id),
+  name           = VALUES(name),
+  remark         = VALUES(remark),
+  institution_id = VALUES(institution_id)`
+	if _, err := db.ExecContext(ctx, q, r.ID, r.Type, r.ParentID, r.Name,
+		nullStr(r.Remark), r.InstitutionID); err != nil {
+		return fmt.Errorf("写 t_expenditure(id=%d) 失败:%w", r.ID, err)
+	}
+	return nil
+}
+
+// upsertDivideInto 写分账树节点(自然键 user_id)。
+func upsertDivideInto(ctx context.Context, db *sql.DB, r TargetDivideInto) error {
+	const q = `
+INSERT INTO t_user_divide_into
+  (user_id, type, quota, total_income, total_income_payed,
+   root_user_id, parent_user_id, left_val, right_val, layer, created_at, updated_at)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ON DUPLICATE KEY UPDATE
+  type               = VALUES(type),
+  quota              = VALUES(quota),
+  total_income       = VALUES(total_income),
+  total_income_payed = VALUES(total_income_payed),
+  root_user_id       = VALUES(root_user_id),
+  parent_user_id     = VALUES(parent_user_id),
+  left_val           = VALUES(left_val),
+  right_val          = VALUES(right_val),
+  layer              = VALUES(layer)`
+	_, err := db.ExecContext(ctx, q, r.UserID, r.Type,
+		CentsToDecimal(r.QuotaCents), CentsToDecimal(r.TotalIncomeCents),
+		CentsToDecimal(r.TotalIncomePayedCents),
+		nullStr(r.RootUserID), nullStr(r.ParentUserID),
+		r.LeftVal, r.RightVal, r.Layer,
+		r.CreatedAt.Format("2006-01-02 15:04:05"), r.UpdatedAt.Format("2006-01-02 15:04:05"))
+	if err != nil {
+		return fmt.Errorf("写 t_user_divide_into(user=%s) 失败:%w", r.UserID, err)
+	}
+	return nil
+}
+
+// upsertDivideIntoLog 写分账明细(D1:显式插 legacy id)。
+func upsertDivideIntoLog(ctx context.Context, db *sql.DB, r TargetDivideIntoLog) error {
+	const q = `
+INSERT INTO t_user_divide_into_log
+  (id, type, user_id, student_id, order_no, money, create_time, remark)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ON DUPLICATE KEY UPDATE
+  type        = VALUES(type),
+  user_id     = VALUES(user_id),
+  student_id  = VALUES(student_id),
+  order_no    = VALUES(order_no),
+  money       = VALUES(money),
+  create_time = VALUES(create_time),
+  remark      = VALUES(remark)`
+	_, err := db.ExecContext(ctx, q, r.ID, r.Type, r.UserID, r.StudentID,
+		nullStr(r.OrderNo), CentsToDecimal(r.MoneyCents),
+		r.CreateTime.Format("2006-01-02 15:04:05"), nullStr(r.Remark))
+	if err != nil {
+		return fmt.Errorf("写 t_user_divide_into_log(id=%d) 失败:%w", r.ID, err)
+	}
+	return nil
+}
+
+// upsertBalanceRequest 写充值/退费工单(**biz_no 口径**)。
+//
+// biz_no 是唯一键 uk_biz_no;命中时只更新业务列,**id 保持首次分配值**。
+func upsertBalanceRequest(ctx context.Context, db *sql.DB, r TargetBalanceRequest) error {
+	const q = `
+INSERT INTO t_user_balance_request
+  (biz_no, type, user_id, institution_id, campus_id, to_institution_id, to_campus_id,
+   money, transfer_accounts_type, transfer_accounts_time, order_no,
+   request_user_id, request_time, response_user_id, response_time,
+   response_msg, approved, item, imgs, remark, created_at, updated_at)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ON DUPLICATE KEY UPDATE
+  type                    = VALUES(type),
+  user_id                 = VALUES(user_id),
+  institution_id          = VALUES(institution_id),
+  campus_id               = VALUES(campus_id),
+  to_institution_id       = VALUES(to_institution_id),
+  to_campus_id            = VALUES(to_campus_id),
+  money                   = VALUES(money),
+  transfer_accounts_type  = VALUES(transfer_accounts_type),
+  transfer_accounts_time  = VALUES(transfer_accounts_time),
+  order_no                = VALUES(order_no),
+  request_user_id         = VALUES(request_user_id),
+  request_time            = VALUES(request_time),
+  response_user_id        = VALUES(response_user_id),
+  response_time           = VALUES(response_time),
+  response_msg            = VALUES(response_msg),
+  approved                = VALUES(approved),
+  item                    = VALUES(item),
+  imgs                    = VALUES(imgs),
+  remark                  = VALUES(remark)`
+	_, err := db.ExecContext(ctx, q,
+		r.BizNo, r.Type, r.UserID, r.InstitutionID, r.CampusID,
+		nullInt(r.ToInstitutionID), nullInt(r.ToCampusID),
+		CentsToDecimal(r.MoneyCents), r.TransferAccountsType,
+		nullTime(r.TransferAccountsTime), nullStr(r.OrderNo),
+		r.RequestUserID, r.RequestTime.Format("2006-01-02 15:04:05"),
+		nullStr(r.ResponseUserID), nullTime(r.ResponseTime),
+		nullStr(r.ResponseMsg), r.Approved, nullStr(r.Item), nullStr(r.Imgs), nullStr(r.Remark),
+		r.CreatedAt.Format("2006-01-02 15:04:05"), r.UpdatedAt.Format("2006-01-02 15:04:05"))
+	if err != nil {
+		return fmt.Errorf("写 t_user_balance_request(biz_no=%s) 失败:%w", r.BizNo, err)
+	}
+	return nil
+}
+
+// upsertExpenditureLog 写收支记录(**biz_no 口径**)。
+func upsertExpenditureLog(ctx context.Context, db *sql.DB, r TargetExpenditureLog) error {
+	const q = `
+INSERT INTO t_expenditure_log
+  (biz_no, institution_id, campus_id, expenditure_id, cost,
+   create_user_id, create_time, year, month, remark, imgs)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ON DUPLICATE KEY UPDATE
+  institution_id  = VALUES(institution_id),
+  campus_id       = VALUES(campus_id),
+  expenditure_id  = VALUES(expenditure_id),
+  cost            = VALUES(cost),
+  create_user_id  = VALUES(create_user_id),
+  create_time     = VALUES(create_time),
+  year            = VALUES(year),
+  month           = VALUES(month),
+  remark          = VALUES(remark),
+  imgs            = VALUES(imgs)`
+	_, err := db.ExecContext(ctx, q, r.BizNo, r.InstitutionID, r.CampusID, r.ExpenditureID,
+		CentsToDecimal(r.CostCents), nullStr(r.CreateUserID),
+		r.CreateTime.Format("2006-01-02 15:04:05"),
+		nullInt(r.Year), nullInt(r.Month), nullStr(r.Remark), nullStr(r.Imgs))
+	if err != nil {
+		return fmt.Errorf("写 t_expenditure_log(biz_no=%s) 失败:%w", r.BizNo, err)
+	}
+	return nil
+}
+
+func nullInt(v sql.NullInt64) any {
+	if !v.Valid {
+		return nil
+	}
+	return v.Int64
+}
+
+func nullTime(v sql.NullTime) any {
+	if !v.Valid {
+		return nil
+	}
+	return v.Time.Format("2006-01-02 15:04:05")
+}

+ 311 - 0
internal/academic/dualrun/fixture/batch2_mapping.go

@@ -0,0 +1,311 @@
+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
+}

+ 193 - 0
internal/academic/dualrun/fixture/batch2_model.go

@@ -0,0 +1,193 @@
+package fixture
+
+import (
+	"database/sql"
+	"time"
+)
+
+// 本文件是**资金域第二批 4 张表**的数据形状(外加解析 t_expenditure_log.expenditure_id
+// 所需的字典表 t_expenditure)。
+//
+// 与上一批的关键差别:这批第一次落地 **biz_no 口径**(旧 UUID 主键 → target int64 自增
+// + `biz_no varchar(32) UNIQUE` 保留旧 UUID)。对齐键因此分三类:
+//
+//	表                           legacy 主键        口径      对齐键
+//	t_user_divide_into           userid int(自然键) 业务键    user_id(ULID)
+//	t_user_divide_into_log       id int AUTO_INCREMENT D1        id 原样
+//	t_user_balance_request       id varchar(32) UUID   biz_no    biz_no = legacy id
+//	t_expenditure_log            id varchar(32) UUID   biz_no    biz_no = legacy id
+//	t_expenditure(字典)         id int AUTO_INCREMENT D1        id 原样(见下表说明)
+//
+// t_expenditure 为什么也做 D1:`t_expenditure_log.expenditureid` 指向它,而它既无旧 UUID
+// 也无业务唯一键(name 不唯一、树结构 parentid 自引用)。要让 expenditure_id 在双跑里可对齐,
+// 只有「字典 id 原样保留」这一条路。目标表当前 0 行、字典 id 是 1..45 连续,D1 安全。
+// ⚠ 这一条属**超出本批 4 表范围的必要补充**,已在报告中标注待拍板。
+
+// ---------- t_user_divide_into ----------
+
+// LegacyDivideInto 对应 legacy `t_user_divide_into`(PK userid int,Nested Set 树)。
+type LegacyDivideInto struct {
+	UserID           int64
+	Type             int32
+	Quota            sql.NullString // float(10,2)
+	TotalIncome      sql.NullString // float(10,2)
+	TotalIncomePayed sql.NullString // float(10,2)
+	RootUserID       sql.NullInt64  // 根节点时 = 自己
+	ParentUserID     sql.NullInt64  // 根节点时 = 0
+	LeftVal          int32
+	RightVal         int32
+	Layer            int32
+}
+
+// TargetDivideInto 对应目标 `t_user_divide_into`(自然键 user_id)。
+type TargetDivideInto struct {
+	UserID                string // canonical ULID
+	Type                  int8
+	QuotaCents            int64
+	TotalIncomeCents      int64
+	TotalIncomePayedCents int64
+	RootUserID            sql.NullString // ULID;legacy 0 → NULL
+	ParentUserID          sql.NullString // ULID;legacy 0(根)→ NULL
+	LeftVal               int32
+	RightVal              int32
+	Layer                 int32
+	// legacy **无** created_at/updated_at 列;目标表这两列 NOT NULL 且**无默认值**
+	// (DDL 文件写了 DEFAULT CURRENT_TIMESTAMP(3),实际库是 GORM AutoMigrate 建的,没有默认值
+	//  —— 见报告「结构漂移」一节)。因此落 epoch 哨兵,绝不用当前时间。
+	CreatedAt time.Time
+	UpdatedAt time.Time
+}
+
+// ---------- t_user_divide_into_log ----------
+
+// LegacyDivideIntoLog 对应 legacy `t_user_divide_into_log`(PK id int AUTO_INCREMENT → D1)。
+type LegacyDivideIntoLog struct {
+	ID         int64
+	Type       int32 // 列类型是 tinyint unsigned zerofill,读出即 1/2
+	UserID     int64
+	StudentID  int64
+	OrderNo    sql.NullString // orderid varchar(50)
+	Money      sql.NullString // float(10,2)
+	CreateTime sql.NullTime
+	Remark     sql.NullString // text → LONGTEXT
+}
+
+// TargetDivideIntoLog 对应目标 `t_user_divide_into_log`。
+type TargetDivideIntoLog struct {
+	ID         int64 // D1:= legacy id
+	Type       int8
+	UserID     string
+	StudentID  string
+	OrderNo    sql.NullString
+	MoneyCents int64
+	CreateTime time.Time
+	Remark     sql.NullString
+}
+
+// ---------- t_user_balance_request ----------
+
+// LegacyBalanceRequest 对应 legacy `t_user_balance_request`(PK id varchar(32) UUID → biz_no)。
+type LegacyBalanceRequest struct {
+	BizNo                string // 旧 UUID 主键(32 位 hex)
+	Type                 int32
+	UserID               int64
+	SchoolID             int64
+	CampusID             int64
+	Money                sql.NullString // decimal(10,3)
+	TransferAccountsType int8
+	TransferAccountsTime sql.NullTime
+	OrderNo              sql.NullString
+	RequestUserID        int64
+	RequestTime          sql.NullTime
+	ResponseUserID       sql.NullInt64 // 0 → NULL
+	ResponseTime         sql.NullTime
+	ResponseMsg          sql.NullString
+	Aproved              int8 // 旧列名错拼,target 修正为 approved
+	Item                 sql.NullString
+	Imgs                 sql.NullString
+	Remark               sql.NullString
+}
+
+// TargetBalanceRequest 对应目标 `t_user_balance_request`。
+type TargetBalanceRequest struct {
+	ID                   int64 // target 自增;**与 legacy 无关**
+	BizNo                string
+	Type                 int32
+	UserID               string
+	InstitutionID        int64
+	CampusID             int64
+	ToInstitutionID      sql.NullInt64 // legacy 无此列 → NULL
+	ToCampusID           sql.NullInt64 // legacy 无此列 → NULL
+	MoneyCents           int64
+	TransferAccountsType int8
+	TransferAccountsTime sql.NullTime
+	OrderNo              sql.NullString
+	RequestUserID        string
+	RequestTime          time.Time
+	ResponseUserID       sql.NullString
+	ResponseTime         sql.NullTime
+	ResponseMsg          sql.NullString
+	Approved             int8
+	Item                 sql.NullString
+	Imgs                 sql.NullString
+	Remark               sql.NullString
+	// target 的 created_at/updated_at NOT NULL 且无默认值;由 legacy 真实时间派生:
+	//   created_at = request_time(工单产生时刻)
+	//   updated_at = response_time ?? request_time(最后处理时刻)
+	CreatedAt time.Time
+	UpdatedAt time.Time
+}
+
+// ---------- t_expenditure_log ----------
+
+// LegacyExpenditureLog 对应 legacy `t_expenditure_log`(PK id varchar(32) UUID → biz_no)。
+type LegacyExpenditureLog struct {
+	BizNo         string
+	SchoolID      int64
+	CampusID      int64
+	ExpenditureID int64          // → t_expenditure.id
+	Cost          sql.NullString // float(10,3)
+	CreateUserID  sql.NullInt64  // 0 → NULL
+	CreateTime    sql.NullTime
+	Year          sql.NullInt64 // MySQL YEAR 类型
+	Month         sql.NullInt64
+	Remark        sql.NullString
+	Imgs          sql.NullString
+}
+
+// TargetExpenditureLog 对应目标 `t_expenditure_log`。
+type TargetExpenditureLog struct {
+	ID            int64
+	BizNo         string
+	InstitutionID int64
+	CampusID      int64
+	ExpenditureID int64
+	CostCents     int64
+	CreateUserID  sql.NullString
+	CreateTime    time.Time
+	Year          sql.NullInt64
+	Month         sql.NullInt64
+	Remark        sql.NullString
+	Imgs          sql.NullString
+}
+
+// ---------- t_expenditure(字典,D1) ----------
+
+// LegacyExpenditure 对应 legacy `t_expenditure`(PK id int AUTO_INCREMENT,自引用树)。
+type LegacyExpenditure struct {
+	ID       int64
+	Type     int32
+	ParentID int64
+	Name     string
+	Remark   sql.NullString
+}
+
+// TargetExpenditure 对应目标 `t_expenditure`(D1 保留 id)。
+type TargetExpenditure struct {
+	ID            int64
+	Type          int32
+	ParentID      int64
+	Name          string
+	Remark        sql.NullString
+	InstitutionID int64 // legacy 无此列 → 0(平台通用字典)
+}

+ 154 - 0
internal/academic/dualrun/fixture/batch2_report.go

@@ -0,0 +1,154 @@
+package fixture
+
+import (
+	"fmt"
+	"io"
+	"strings"
+)
+
+// 本文件只做第二批报告的终端排版。
+
+// PrintBatch2Report 打印「搬 4 表」结果。
+func PrintBatch2Report(w io.Writer, r *Batch2Report) {
+	fmt.Fprintf(w, "== 第二批映射与装载 ==\n")
+	fmt.Fprintf(w, "取样:分账树根=%d,充值工单用户=%d,收支记录创建人=%d,字典=%v\n",
+		r.Options.DivideRootUserID, r.Options.BalanceRequestUserID,
+		r.Options.ExpenditureLogUserID, r.Options.WithExpenditureDict)
+	fmt.Fprintf(w, "legacy 行数:divide_into=%d, divide_into_log=%d, balance_request=%d, expenditure_log=%d, 字典=%d\n",
+		r.LegacyCounts["t_user_divide_into"], r.LegacyCounts["t_user_divide_into_log"],
+		r.LegacyCounts["t_user_balance_request"], r.LegacyCounts["t_expenditure_log"],
+		r.LegacyCounts["t_expenditure"])
+	fmt.Fprintf(w, "写入:t_expenditure=%d, t_user_divide_into=%d, t_user_divide_into_log=%d, "+
+		"t_user_balance_request=%d, t_expenditure_log=%d\n",
+		r.LoadedCounts["t_expenditure"], r.LoadedCounts["t_user_divide_into"],
+		r.LoadedCounts["t_user_divide_into_log"], r.LoadedCounts["t_user_balance_request"],
+		r.LoadedCounts["t_expenditure_log"])
+	fmt.Fprintf(w, "引用人映射:%d 个 legacy uid → ULID(t_user %d 行 / t_legacy_mapping %d 行)\n",
+		len(r.RefUsers), r.UsersWritten, r.MappingsWritten)
+	fmt.Fprintf(w, "分账明细 id(D1 原样保留):%v\n", r.DivideLogIDs)
+	if len(r.BizNoSamples) > 0 {
+		fmt.Fprintf(w, "biz_no 样本(文本列已脱敏):\n")
+		for _, s := range r.BizNoSamples {
+			fmt.Fprintf(w, "  %-24s biz=%s type=%-4d money=%s time=%s remark=%s\n",
+				s.Table, s.BizNo, s.Type, CentsToDecimal(s.MoneyCents), s.CreateTime, s.RemarkMask)
+		}
+	}
+	if len(r.Warnings) > 0 {
+		fmt.Fprintf(w, "映射告警(%d):\n", len(r.Warnings))
+		for _, x := range r.Warnings {
+			fmt.Fprintf(w, "  - %s\n", x)
+		}
+	} else {
+		fmt.Fprintf(w, "映射告警:无\n")
+	}
+}
+
+// PrintBatch2Verify 打印第二批核对结果(按「列 × N 行」聚合,避免逐格刷屏)。
+func PrintBatch2Verify(w io.Writer, r *Batch2VerifyReport) {
+	fmt.Fprintf(w, "== 第二批逐列核对(列 × 行 聚合)==\n")
+	lastTable := ""
+	for _, c := range r.Cols {
+		if c.Table != lastTable {
+			fmt.Fprintf(w, "\n-- %s(target %d 行)--\n", c.Table, r.TargetRows[c.Table])
+			lastTable = c.Table
+		}
+		st := markOK
+		if !c.OK() {
+			st = markFail
+		}
+		fmt.Fprintf(w, "  %s %-24s %d/%d 相等   %s\n", st, c.Column, c.Equal, c.Total, c.Note)
+		for _, s := range c.Samples {
+			fmt.Fprintf(w, "        ✘ %s\n", s)
+		}
+	}
+
+	fmt.Fprintf(w, "\n== D1:id 与 legacy 完全相同 ==\n")
+	for _, d := range r.D1 {
+		st := markOK
+		if !d.Pass {
+			st = markFail
+		}
+		fmt.Fprintf(w, "  %s %-24s legacy=%v\n        target=%v\n", st, d.Table, d.LegacyIDs, d.TargetIDs)
+	}
+
+	fmt.Fprintf(w, "\n== biz_no 四项验证 ==\n")
+	for _, v := range r.BizNo {
+		fmt.Fprintf(w, "  %s:legacy %d 行 / target %d 行\n", v.Table, v.LegacyRows, v.Rows)
+		fmt.Fprintf(w, "    ① 逐字符相等 %d 行;长度=32 的 %d 行;hex 形态 %d 行\n", v.CharEqual, v.Len32, v.HexOK)
+		fmt.Fprintf(w, "    ② 唯一:DISTINCT biz_no = %d(总 %d)\n", v.UniqueBizNo, v.Rows)
+		fmt.Fprintf(w, "    ③ id 解耦:max(id)=%d, AUTO_INCREMENT=%d;反序镜像 %v → %v\n",
+			v.MaxID, v.AutoIncrementVal, v.MirrorOK, v.MirrorSample)
+		fmt.Fprintf(w, "    ④ 反查:biz_no JOIN 回 legacy 命中 %d 行\n", v.ReverseJoin)
+	}
+
+	fmt.Fprintf(w, "\n== 语义断言 ==\n")
+	for _, a := range r.Assertions {
+		st := markOK
+		if !a.Pass {
+			st = markFail
+		}
+		fmt.Fprintf(w, "  %s %s\n     %s\n", st, a.Name, a.Detail)
+	}
+	fmt.Fprintf(w, "\n核对结论:%s(不一致列 + 失败断言 = %d)\n", verdict(r.OK()), r.FailCount())
+}
+
+// PrintIdempotency 打印「跑两次」的幂等结论。
+func PrintIdempotency(w io.Writer, r *IdempotencyResult) {
+	fmt.Fprintf(w, "== 幂等实测(同一批数据跑两次)==\n")
+	for _, t := range []string{"t_user_balance_request", "t_expenditure_log"} {
+		st := markOK
+		if !r.SameRows[t] || !r.SameIDs[t] || r.NewBizNos[t] != 0 {
+			st = markFail
+		}
+		fmt.Fprintf(w, "  %s %s\n", st, t)
+		fmt.Fprintf(w, "     第一次:%d 行,id=%v\n", len(r.Before[t]), r.IDListFirst[t])
+		fmt.Fprintf(w, "     第二次:%d 行,id=%v\n", len(r.After[t]), r.IDListAgain[t])
+		fmt.Fprintf(w, "     行数不变=%v,已存在行 id 不变=%v,新增 biz_no=%d\n",
+			r.SameRows[t], r.SameIDs[t], r.NewBizNos[t])
+	}
+	fmt.Fprintf(w, "\n幂等结论:%s\n", verdict(r.AllOK()))
+}
+
+// PrintBatch2Clean 打印清理结果。
+func PrintBatch2Clean(w io.Writer, r *Batch2CleanReport) {
+	fmt.Fprintf(w, "== 第二批清理 ==\n")
+	fmt.Fprintf(w, "按 biz_no 精确删除 %d 行(balance_request + expenditure_log)\n", r.BizNos)
+	for _, t := range []string{"t_expenditure", "t_user_divide_into", "t_user_divide_into_log",
+		"t_user_balance_request", "t_expenditure_log", "t_legacy_mapping", "t_user"} {
+		fmt.Fprintf(w, "  %-26s 删除 %-4d 行,剩余全表 %d 行\n", t, r.Deleted[t], r.Remaining[t])
+	}
+	zero := true
+	for _, t := range []string{"t_expenditure", "t_user_divide_into", "t_user_divide_into_log",
+		"t_user_balance_request", "t_expenditure_log", "t_legacy_mapping", "t_user"} {
+		if r.Remaining[t] != 0 {
+			zero = false
+		}
+	}
+	fmt.Fprintf(w, "7 张表回 0 行:%s\n", verdict(zero))
+}
+
+// PrintCounts 打印一组表的行数。
+func PrintCounts(w io.Writer, title string, counts map[string]int64, tables []string) {
+	fmt.Fprintf(w, "== %s ==\n", title)
+	for _, t := range tables {
+		fmt.Fprintf(w, "  %-26s %d\n", t, counts[t])
+	}
+}
+
+// Batch2Tables 是第二批涉及的表名(固定顺序,报告用)。
+func Batch2Tables() []string {
+	return []string{"t_expenditure", "t_user_divide_into", "t_user_divide_into_log",
+		"t_user_balance_request", "t_expenditure_log", "t_legacy_mapping", "t_user"}
+}
+
+// JoinIDs 把 id 列表拼成一行(报告用,避免超长时刷屏)。
+func JoinIDs(ids []int64) string {
+	if len(ids) == 0 {
+		return "[]"
+	}
+	parts := make([]string, len(ids))
+	for i, v := range ids {
+		parts[i] = fmt.Sprint(v)
+	}
+	return "[" + strings.Join(parts, " ") + "]"
+}

+ 507 - 0
internal/academic/dualrun/fixture/batch2_run.go

@@ -0,0 +1,507 @@
+package fixture
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"sort"
+	"strings"
+)
+
+// 本文件是第二批的流程编排:Extract → Map → Load →(Verify)→(Idempotency)→ Clean。
+
+// Batch2Report 是一次「搬 4 表」的结果。
+type Batch2Report struct {
+	Options         Batch2Options
+	LegacyCounts    map[string]int
+	LoadedCounts    map[string]int
+	RefUsers        []int64
+	UsersWritten    int
+	MappingsWritten int
+	Warnings        []string
+	DivideLogIDs    []int64
+	BizNoSamples    []BizNoSample
+}
+
+// BizNoSample 是 biz_no 口径的样本行(报告用,文本列已脱敏)。
+type BizNoSample struct {
+	Table      string
+	BizNo      string
+	Type       int32
+	MoneyCents int64
+	CreateTime string
+	RemarkMask string
+}
+
+// PrepareBatch2 把第二批 4 张表(+ 字典)搬进目标库。
+//
+// 步骤:
+//  1. legacy 只读取出 4 张表的取样行 + 字典全表;
+//  2. 汇总这批数据里出现的**全部 legacy 用户引用**,一次性补 t_legacy_mapping + t_user;
+//  3. 逐行映射(batch2_mapping.go 纯函数)→ 幂等写入(batch2_load.go)。
+func PrepareBatch2(ctx context.Context, cfg Config, opts Batch2Options) (*Batch2Report, error) {
+	legacy, err := cfg.OpenLegacy()
+	if err != nil {
+		return nil, err
+	}
+	defer legacy.Close()
+
+	target, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, err
+	}
+	defer target.Close()
+
+	rep := &Batch2Report{Options: opts, LoadedCounts: map[string]int{}}
+
+	divides, err := extractDivides(ctx, legacy, opts.DivideRootUserID)
+	if err != nil {
+		return nil, err
+	}
+	dlogs, err := extractDivideLogs(ctx, legacy, opts.DivideRootUserID)
+	if err != nil {
+		return nil, err
+	}
+	brs, err := extractBalanceRequests(ctx, legacy, opts.BalanceRequestUserID)
+	if err != nil {
+		return nil, err
+	}
+	elogs, err := extractExpenditureLogs(ctx, legacy, opts.ExpenditureLogUserID)
+	if err != nil {
+		return nil, err
+	}
+	if len(divides) == 0 || len(dlogs) == 0 || len(brs) == 0 || len(elogs) == 0 {
+		return nil, fmt.Errorf(
+			"取样不满足前置条件:divide_into=%d, divide_into_log=%d, balance_request=%d, expenditure_log=%d"+
+				"(4 张表都必须有行)", len(divides), len(dlogs), len(brs), len(elogs))
+	}
+
+	var dicts []LegacyExpenditure
+	if opts.WithExpenditureDict {
+		dicts, err = extractExpenditures(ctx, legacy)
+		if err != nil {
+			return nil, err
+		}
+		if len(dicts) == 0 {
+			return nil, fmt.Errorf("t_expenditure 字典为空,无法解析 expenditure_id")
+		}
+	}
+
+	rep.LegacyCounts, err = countBatch2Legacy(ctx, legacy, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	// ---- 1) 引用人映射行 ----
+	rep.RefUsers = collectUserRefs(divides, dlogs, brs, elogs)
+	for _, uid := range rep.RefUsers {
+		canonical := FixtureCanonicalUserID(uid)
+		if err := upsertUser(ctx, target, canonical, "AC15 fixture b2(legacy="+itoa(uid)+")"); err != nil {
+			return nil, err
+		}
+		if err := upsertMapping(ctx, target, MappingEntry{
+			CanonicalUserID:      canonical,
+			LegacyAcademicUserID: uid,
+			InstitutionID:        brs[0].SchoolID,
+			CampusID:             brs[0].CampusID,
+			Role:                 "student",
+			MatchSource:          "seed",
+			MatchConfidence:      100,
+		}); err != nil {
+			return nil, err
+		}
+		rep.UsersWritten++
+		rep.MappingsWritten++
+	}
+	resolve := func(id int64) (string, bool) {
+		if id <= 0 {
+			return "", false
+		}
+		return FixtureCanonicalUserID(id), true
+	}
+
+	// ---- 2) 字典(D1,先写,供 expenditure_id 引用)----
+	for _, d := range dicts {
+		td, w, err := MapExpenditure(d)
+		if err != nil {
+			return nil, err
+		}
+		rep.Warnings = append(rep.Warnings, w...)
+		if err := upsertExpenditureDict(ctx, target, td); err != nil {
+			return nil, err
+		}
+	}
+	rep.LoadedCounts["t_expenditure"] = len(dicts)
+
+	// ---- 3) 分账树 ----
+	for _, d := range divides {
+		td, w, err := MapDivideInto(d, resolve)
+		if err != nil {
+			return nil, err
+		}
+		rep.Warnings = append(rep.Warnings, w...)
+		if err := upsertDivideInto(ctx, target, td); err != nil {
+			return nil, err
+		}
+	}
+	rep.LoadedCounts["t_user_divide_into"] = len(divides)
+
+	// ---- 4) 分账明细(D1)----
+	for _, l := range dlogs {
+		tl, w, err := MapDivideIntoLog(l, resolve)
+		if err != nil {
+			return nil, err
+		}
+		rep.Warnings = append(rep.Warnings, w...)
+		if err := upsertDivideIntoLog(ctx, target, tl); err != nil {
+			return nil, err
+		}
+		rep.DivideLogIDs = append(rep.DivideLogIDs, tl.ID)
+	}
+	rep.LoadedCounts["t_user_divide_into_log"] = len(dlogs)
+
+	// ---- 5) 充值工单(biz_no)----
+	for _, r := range brs {
+		tr, w, err := MapBalanceRequest(r, resolve)
+		if err != nil {
+			return nil, err
+		}
+		rep.Warnings = append(rep.Warnings, w...)
+		if err := upsertBalanceRequest(ctx, target, tr); err != nil {
+			return nil, err
+		}
+		if len(rep.BizNoSamples) < 3 {
+			rep.BizNoSamples = append(rep.BizNoSamples, BizNoSample{
+				Table: "t_user_balance_request", BizNo: tr.BizNo, Type: tr.Type,
+				MoneyCents: tr.MoneyCents,
+				CreateTime: tr.RequestTime.Format("2006-01-02 15:04:05"),
+				RemarkMask: maskRemark(tr.Remark),
+			})
+		}
+	}
+	rep.LoadedCounts["t_user_balance_request"] = len(brs)
+
+	// ---- 6) 收支记录(biz_no)----
+	for _, e := range elogs {
+		te, w, err := MapExpenditureLog(e, resolve)
+		if err != nil {
+			return nil, err
+		}
+		rep.Warnings = append(rep.Warnings, w...)
+		if err := upsertExpenditureLog(ctx, target, te); err != nil {
+			return nil, err
+		}
+		rep.BizNoSamples = append(rep.BizNoSamples, BizNoSample{
+			Table: "t_expenditure_log", BizNo: te.BizNo, Type: int32(te.ExpenditureID),
+			MoneyCents: te.CostCents,
+			CreateTime: te.CreateTime.Format("2006-01-02 15:04:05"),
+			RemarkMask: maskRemark(te.Remark),
+		})
+	}
+	rep.LoadedCounts["t_expenditure_log"] = len(elogs)
+
+	return rep, nil
+}
+
+// ---------- biz_no 快照与幂等比对 ----------
+
+// BizNoSnapshot 记录 biz_no → target id 的映射(幂等比对的基线)。
+type BizNoSnapshot map[string]map[string]int64 // table -> biz_no -> id
+
+// SnapshotBizNos 采集两张 biz_no 表当前的 (biz_no, id)。
+func SnapshotBizNos(ctx context.Context, cfg Config) (BizNoSnapshot, error) {
+	db, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, err
+	}
+	defer db.Close()
+	snap := BizNoSnapshot{}
+	for _, t := range []string{"t_user_balance_request", "t_expenditure_log"} {
+		rows, err := db.QueryContext(ctx, "SELECT biz_no, id FROM "+t)
+		if err != nil {
+			return nil, err
+		}
+		m := map[string]int64{}
+		for rows.Next() {
+			var b string
+			var id int64
+			if err := rows.Scan(&b, &id); err != nil {
+				rows.Close()
+				return nil, err
+			}
+			m[b] = id
+		}
+		rows.Close()
+		if err := rows.Err(); err != nil {
+			return nil, err
+		}
+		snap[t] = m
+	}
+	return snap, nil
+}
+
+// IdempotencyResult 是「跑两次」的比对结论。
+type IdempotencyResult struct {
+	Before      BizNoSnapshot
+	After       BizNoSnapshot
+	SameRows    map[string]bool // 行数不变
+	SameIDs     map[string]bool // 同 biz_no 的 id 不变
+	NewBizNos   map[string]int  // 第二次跑新增了几条(必须为 0)
+	IDListFirst map[string][]int64
+	IDListAgain map[string][]int64
+}
+
+// AllOK 表示两张 biz_no 表都完全幂等。
+func (r *IdempotencyResult) AllOK() bool {
+	for _, t := range []string{"t_user_balance_request", "t_expenditure_log"} {
+		if !r.SameRows[t] || !r.SameIDs[t] || r.NewBizNos[t] != 0 {
+			return false
+		}
+	}
+	return true
+}
+
+// IdempotencyCheck 先拍快照,再跑一次完全相同的映射,然后比对 ——
+// 这是 biz_no 口径「必须实测跑两次」的落地。
+func IdempotencyCheck(ctx context.Context, cfg Config, opts Batch2Options) (*IdempotencyResult, error) {
+	before, err := SnapshotBizNos(ctx, cfg)
+	if err != nil {
+		return nil, err
+	}
+	if _, err := PrepareBatch2(ctx, cfg, opts); err != nil {
+		return nil, fmt.Errorf("第二次映射失败(幂等被破坏):%w", err)
+	}
+	after, err := SnapshotBizNos(ctx, cfg)
+	if err != nil {
+		return nil, err
+	}
+
+	res := &IdempotencyResult{
+		Before: before, After: after,
+		SameRows: map[string]bool{}, SameIDs: map[string]bool{}, NewBizNos: map[string]int{},
+		IDListFirst: map[string][]int64{}, IDListAgain: map[string][]int64{},
+	}
+	for _, t := range []string{"t_user_balance_request", "t_expenditure_log"} {
+		b, a := before[t], after[t]
+		res.SameRows[t] = len(b) == len(a)
+		same := true
+		for bizNo, id := range b {
+			if a[bizNo] != id {
+				same = false
+			}
+		}
+		res.SameIDs[t] = same
+		newCount := 0
+		for bizNo := range a {
+			if _, ok := b[bizNo]; !ok {
+				newCount++
+			}
+		}
+		res.NewBizNos[t] = newCount
+		for _, id := range b {
+			res.IDListFirst[t] = append(res.IDListFirst[t], id)
+		}
+		for _, id := range a {
+			res.IDListAgain[t] = append(res.IDListAgain[t], id)
+		}
+		sort.Slice(res.IDListFirst[t], func(i, j int) bool { return res.IDListFirst[t][i] < res.IDListFirst[t][j] })
+		sort.Slice(res.IDListAgain[t], func(i, j int) bool { return res.IDListAgain[t][i] < res.IDListAgain[t][j] })
+	}
+	return res, nil
+}
+
+// ---------- 清理 ----------
+
+// Batch2CleanReport 是清理结果。
+type Batch2CleanReport struct {
+	Deleted   map[string]int64
+	Remaining map[string]int64
+	BizNos    int
+}
+
+// CleanupBatch2 删除第二批 fixture 造的全部数据。
+//
+// 删除范围严格按「这批取样出来的 biz_no / legacy id / 引用人 ULID」限定,不误删。
+func CleanupBatch2(ctx context.Context, cfg Config, opts Batch2Options) (*Batch2CleanReport, error) {
+	legacy, err := cfg.OpenLegacy()
+	if err != nil {
+		return nil, err
+	}
+	defer legacy.Close()
+	target, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, err
+	}
+	defer target.Close()
+
+	divides, err := extractDivides(ctx, legacy, opts.DivideRootUserID)
+	if err != nil {
+		return nil, err
+	}
+	dlogs, err := extractDivideLogs(ctx, legacy, opts.DivideRootUserID)
+	if err != nil {
+		return nil, err
+	}
+	brs, err := extractBalanceRequests(ctx, legacy, opts.BalanceRequestUserID)
+	if err != nil {
+		return nil, err
+	}
+	elogs, err := extractExpenditureLogs(ctx, legacy, opts.ExpenditureLogUserID)
+	if err != nil {
+		return nil, err
+	}
+
+	rep := &Batch2CleanReport{Deleted: map[string]int64{}, Remaining: map[string]int64{}}
+
+	// 1) biz_no 表:按 biz_no 精确删除
+	del := func(table string, cond string, args []any) error {
+		res, err := target.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE %s", table, cond), args...)
+		if err != nil {
+			return fmt.Errorf("清理 %s 失败:%w", table, err)
+		}
+		n, _ := res.RowsAffected()
+		rep.Deleted[table] += n
+		return nil
+	}
+	for _, r := range brs {
+		if err := del("t_user_balance_request", "biz_no = ?", []any{r.BizNo}); err != nil {
+			return nil, err
+		}
+	}
+	for _, e := range elogs {
+		if err := del("t_expenditure_log", "biz_no = ?", []any{e.BizNo}); err != nil {
+			return nil, err
+		}
+	}
+	rep.BizNos = len(brs) + len(elogs)
+
+	// 变异实验残留清理:只删「属于本批 fixture 用户、但 biz_no 不是 legacy 原值」的行。
+	// 存在的理由:把 biz_no 改成 UUID 的变异(M1)会插进一批 biz_no 错误的新行,
+	// 按正确 biz_no 删不掉;这里用「fixture 用户 + 非期望 biz_no」这个双重条件兜住,
+	// 既不会漏,也不会误删任何非 fixture 行。
+	refsForResidue := collectUserRefs(divides, dlogs, brs, elogs)
+	canonicals := make([]any, 0, len(refsForResidue))
+	for _, uid := range refsForResidue {
+		canonicals = append(canonicals, FixtureCanonicalUserID(uid))
+	}
+	if len(canonicals) > 0 {
+		brNos := make([]any, 0, len(brs))
+		for _, r := range brs {
+			brNos = append(brNos, r.BizNo)
+		}
+		elNos := make([]any, 0, len(elogs))
+		for _, e := range elogs {
+			elNos = append(elNos, e.BizNo)
+		}
+		inCanon := placeholders(len(canonicals))
+		if err := del("t_user_balance_request",
+			fmt.Sprintf("user_id IN (%s) AND biz_no NOT IN (%s)", inCanon, placeholders(len(brNos))),
+			append(append([]any{}, canonicals...), brNos...)); err != nil {
+			return nil, err
+		}
+		if err := del("t_expenditure_log",
+			fmt.Sprintf("create_user_id IN (%s) AND biz_no NOT IN (%s)", inCanon, placeholders(len(elNos))),
+			append(append([]any{}, canonicals...), elNos...)); err != nil {
+			return nil, err
+		}
+	}
+
+	// 2) D1 表:按 legacy id 删除
+	for _, l := range dlogs {
+		if err := del("t_user_divide_into_log", "id = ?", []any{l.ID}); err != nil {
+			return nil, err
+		}
+	}
+
+	// 3) 分账树:按 ULID 删除
+	for _, d := range divides {
+		if err := del("t_user_divide_into", "user_id = ?", []any{FixtureCanonicalUserID(d.UserID)}); err != nil {
+			return nil, err
+		}
+	}
+
+	// 4) 字典:只删这批 D1 进去的 id(其余行不动)
+	if opts.WithExpenditureDict {
+		dicts, err := extractExpenditures(ctx, legacy)
+		if err != nil {
+			return nil, err
+		}
+		for _, d := range dicts {
+			if err := del("t_expenditure", "id = ?", []any{d.ID}); err != nil {
+				return nil, err
+			}
+		}
+	}
+
+	// 5) 引用人映射 + 用户行
+	refs := collectUserRefs(divides, dlogs, brs, elogs)
+	for _, uid := range refs {
+		canonical := FixtureCanonicalUserID(uid)
+		if err := del("t_legacy_mapping", "canonical_user_id = ?", []any{canonical}); err != nil {
+			return nil, err
+		}
+		if err := del("t_user", "canonical_user_id = ?", []any{canonical}); err != nil {
+			return nil, err
+		}
+	}
+
+	// 6) 复核 5 张表**全表**行数
+	for _, t := range []string{"t_expenditure", "t_user_divide_into", "t_user_divide_into_log",
+		"t_user_balance_request", "t_expenditure_log", "t_legacy_mapping", "t_user"} {
+		var n int64
+		if err := target.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&n); err != nil {
+			return nil, err
+		}
+		rep.Remaining[t] = n
+	}
+	return rep, nil
+}
+
+// placeholders 生成 n 个 "?" 的占位符串。
+func placeholders(n int) string {
+	if n <= 0 {
+		return "NULL"
+	}
+	out := make([]string, n)
+	for i := range out {
+		out[i] = "?"
+	}
+	return strings.Join(out, ",")
+}
+
+// CountBatch2Tables 统计这批涉及的表的当前行数。
+func CountBatch2Tables(ctx context.Context, cfg Config) (map[string]int64, error) {
+	db, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, err
+	}
+	defer db.Close()
+	out := map[string]int64{}
+	for _, t := range []string{"t_expenditure", "t_user_divide_into", "t_user_divide_into_log",
+		"t_user_balance_request", "t_expenditure_log", "t_legacy_mapping", "t_user"} {
+		var n int64
+		if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&n); err != nil {
+			return nil, err
+		}
+		out[t] = n
+	}
+	return out, nil
+}
+
+// fetchDivideIntoIDs 取分账明细的 target id 集合(D1 一致性对比用)。
+func fetchDivideIntoIDs(ctx context.Context, db *sql.DB) ([]int64, error) {
+	rows, err := db.QueryContext(ctx, "SELECT id FROM t_user_divide_into_log ORDER BY id")
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []int64
+	for rows.Next() {
+		var id int64
+		if err := rows.Scan(&id); err != nil {
+			return nil, err
+		}
+		out = append(out, id)
+	}
+	return out, rows.Err()
+}

+ 255 - 0
internal/academic/dualrun/fixture/batch2_test.go

@@ -0,0 +1,255 @@
+package fixture
+
+import (
+	"database/sql"
+	"testing"
+	"time"
+)
+
+// 本文件单测**第二批的纯映射规则**(不连库):biz_no 校验、空值语义、金额取整、时间口径。
+// 端到端核对由 cmd/ac15fixture verify2 / idem2 完成(需要真库,不进单测)。
+
+const validBizNo = "f117520d3fde2c0db866e5bdb010d8af"
+
+func TestNormalizeBizNo(t *testing.T) {
+	// 正常:32 位小写 hex 原样返回
+	got, err := NormalizeBizNo(validBizNo)
+	if err != nil {
+		t.Fatalf("正常 biz_no 不应报错:%v", err)
+	}
+	if got != validBizNo {
+		t.Errorf("biz_no 必须原样返回,得到 %q", got)
+	}
+
+	// 三类必须拒绝且**不截断、不改写**
+	bad := []struct{ name, in string }{
+		{"空串", ""},
+		{"31 位(短)", validBizNo[:31]},
+		{"33 位(长)", validBizNo + "a"},
+		{"大写 hex", "F117520D3FDE2C0DB866E5BDB010D8AF"},
+		{"非 hex 字符", "z117520d3fde2c0db866e5bdb010d8af"},
+		{"带横线 UUID", "f117520d-3fde-2c0d-b866-e5bdb010d8af"},
+	}
+	for _, b := range bad {
+		if _, err := NormalizeBizNo(b.in); err == nil {
+			t.Errorf("%s:%q 应被拒绝(不允许截断/改写),实际通过", b.name, b.in)
+		}
+	}
+}
+
+func TestMapBalanceRequestBizNoAndNulls(t *testing.T) {
+	l := LegacyBalanceRequest{
+		BizNo:                validBizNo,
+		Type:                 202,
+		UserID:               31348,
+		SchoolID:             63317,
+		CampusID:             5,
+		Money:                sql.NullString{String: "1737.360", Valid: true},
+		TransferAccountsType: 0,
+		TransferAccountsTime: sql.NullTime{},   // NULL → NULL
+		OrderNo:              sql.NullString{}, // NULL → NULL
+		RequestUserID:        45268,
+		RequestTime:          sql.NullTime{Time: time.Date(2020, 9, 29, 19, 37, 15, 0, time.Local), Valid: true},
+		ResponseUserID:       sql.NullInt64{Int64: 0, Valid: true}, // 0 → NULL
+		ResponseTime:         sql.NullTime{},                       // NULL → NULL
+		ResponseMsg:          sql.NullString{String: "", Valid: true},
+		Aproved:              -2,
+		Item:                 sql.NullString{String: `{"schoolid":"63317"}`, Valid: true},
+		Imgs:                 sql.NullString{},
+		Remark:               sql.NullString{String: "", Valid: true},
+	}
+	resolve := func(id int64) (string, bool) {
+		if id == 31348 {
+			return "01USER000000000000000003", true
+		}
+		if id == 45268 {
+			return "01USER000000000000000452", true
+		}
+		return "", false
+	}
+	got, warns, err := MapBalanceRequest(l, resolve)
+	if err != nil {
+		t.Fatalf("MapBalanceRequest 出错:%v", err)
+	}
+	if got.BizNo != validBizNo {
+		t.Errorf("biz_no 应原样保留,得到 %q", got.BizNo)
+	}
+	if got.MoneyCents != 173736 {
+		t.Errorf("money 1737.360 → 173736 分,实际 %d", got.MoneyCents)
+	}
+	if got.ResponseUserID.Valid {
+		t.Errorf("response_userid=0 应映射为 NULL,实际 %q", got.ResponseUserID.String)
+	}
+	if got.TransferAccountsTime.Valid || got.ResponseTime.Valid || got.OrderNo.Valid {
+		t.Error("legacy NULL 的三列应落 NULL")
+	}
+	if got.ToInstitutionID.Valid || got.ToCampusID.Valid {
+		t.Error("legacy 无转入机构/校区列,应落 NULL")
+	}
+	if !got.ResponseMsg.Valid || got.ResponseMsg.String != "" {
+		t.Error("response_msg 的空串应保留空串(不转 NULL)")
+	}
+	if got.Approved != -2 {
+		t.Errorf("approved 应原样保留 -2,实际 %d", got.Approved)
+	}
+	// created_at = request_time;response_time 为 NULL 时 updated_at 退回 request_time
+	if !got.CreatedAt.Equal(l.RequestTime.Time) || !got.UpdatedAt.Equal(l.RequestTime.Time) {
+		t.Errorf("created_at/updated_at 应由 request_time 派生,实际 %v / %v", got.CreatedAt, got.UpdatedAt)
+	}
+	if len(warns) != 0 {
+		t.Errorf("不应有告警,实际 %v", warns)
+	}
+}
+
+// TestMapBalanceRequestBizNoRejected 证明「biz_no 长度不为 32 时直接报错,不截断」。
+func TestMapBalanceRequestBizNoRejected(t *testing.T) {
+	l := LegacyBalanceRequest{BizNo: "abc", UserID: 1, RequestUserID: 1,
+		RequestTime: sql.NullTime{Time: time.Now(), Valid: true}}
+	if _, _, err := MapBalanceRequest(l, func(int64) (string, bool) { return "01X", true }); err == nil {
+		t.Error("biz_no 长度不为 32 应报错(拒绝截断),实际未报错")
+	}
+}
+
+// TestMapDivideIntoRootSemantics 证明「根节点 parent_userid=0 → NULL,root_userid=自身」。
+func TestMapDivideIntoRootSemantics(t *testing.T) {
+	root := LegacyDivideInto{
+		UserID: 395, Type: 1,
+		Quota:            sql.NullString{String: "300.00", Valid: true},
+		TotalIncome:      sql.NullString{String: "1200.00", Valid: true},
+		TotalIncomePayed: sql.NullString{String: "900.00", Valid: true},
+		RootUserID:       sql.NullInt64{Int64: 395, Valid: true},
+		ParentUserID:     sql.NullInt64{Int64: 0, Valid: true},
+		LeftVal:          1, RightVal: 16, Layer: 1,
+	}
+	resolve := func(id int64) (string, bool) { return FixtureCanonicalUserID(id), id > 0 }
+	got, _, err := MapDivideInto(root, resolve)
+	if err != nil {
+		t.Fatalf("MapDivideInto 出错:%v", err)
+	}
+	if got.ParentUserID.Valid {
+		t.Errorf("根节点 parent_userid=0 应落 NULL,实际 %q", got.ParentUserID.String)
+	}
+	if !got.RootUserID.Valid || got.RootUserID.String != FixtureCanonicalUserID(395) {
+		t.Error("根节点 root_userid 应解析成自身 ULID")
+	}
+	if got.QuotaCents != 30000 || got.TotalIncomeCents != 120000 || got.TotalIncomePayedCents != 90000 {
+		t.Errorf("金额换算错误:%d/%d/%d", got.QuotaCents, got.TotalIncomeCents, got.TotalIncomePayedCents)
+	}
+	// legacy 无时间列 → 必须落 epoch 哨兵,绝不是当前时间
+	if !got.CreatedAt.Equal(epochSentinel()) || !got.UpdatedAt.Equal(epochSentinel()) {
+		t.Errorf("legacy 无时间列时应落 epoch 哨兵,实际 %v / %v", got.CreatedAt, got.UpdatedAt)
+	}
+}
+
+// TestMapDivideIntoChildParent 证明子节点 parent_userid 解析成 ULID。
+func TestMapDivideIntoChildParent(t *testing.T) {
+	child := LegacyDivideInto{
+		UserID: 43210, Type: 1,
+		RootUserID:   sql.NullInt64{Int64: 395, Valid: true},
+		ParentUserID: sql.NullInt64{Int64: 395, Valid: true},
+		LeftVal:      6, RightVal: 7, Layer: 2,
+	}
+	got, warns, err := MapDivideInto(child, func(id int64) (string, bool) { return FixtureCanonicalUserID(id), true })
+	if err != nil {
+		t.Fatalf("MapDivideInto 出错:%v", err)
+	}
+	if got.ParentUserID.String != FixtureCanonicalUserID(395) {
+		t.Errorf("子节点 parent_user_id 应为 395 的 ULID,实际 %q", got.ParentUserID.String)
+	}
+	// quota/total_income 全 NULL → 落 0.00 且必须告警
+	if got.QuotaCents != 0 || got.TotalIncomeCents != 0 {
+		t.Error("NULL 金额应落 0")
+	}
+	if len(warns) != 3 {
+		t.Errorf("3 个 NULL 金额列应产生 3 条告警,实际 %d", len(warns))
+	}
+}
+
+// TestMapDivideIntoLogD1 证明流水表 D1(id 原样)+ 学生引用解析。
+func TestMapDivideIntoLogD1(t *testing.T) {
+	l := LegacyDivideIntoLog{
+		ID: 26, Type: 1, UserID: 395, StudentID: 43210,
+		OrderNo:    sql.NullString{String: validBizNo, Valid: true},
+		Money:      sql.NullString{String: "300.00", Valid: true},
+		CreateTime: sql.NullTime{Time: time.Date(2018, 7, 10, 17, 3, 39, 0, time.Local), Valid: true},
+		Remark:     sql.NullString{String: "推广分成", Valid: true},
+	}
+	got, _, err := MapDivideIntoLog(l, func(id int64) (string, bool) { return FixtureCanonicalUserID(id), true })
+	if err != nil {
+		t.Fatalf("MapDivideIntoLog 出错:%v", err)
+	}
+	if got.ID != 26 {
+		t.Errorf("D1:id 应原样保留 26,实际 %d", got.ID)
+	}
+	if got.MoneyCents != 30000 {
+		t.Errorf("money 300.00 → 30000 分,实际 %d", got.MoneyCents)
+	}
+	if got.CreateTime.Nanosecond() != 0 {
+		t.Error("create_time 毫秒应为 0")
+	}
+}
+
+// TestMapExpenditureLogBizNoAndRefs 证明 biz_no 原样 + create_userid 0 → NULL。
+func TestMapExpenditureLogBizNoAndRefs(t *testing.T) {
+	l := LegacyExpenditureLog{
+		BizNo: validBizNo, SchoolID: 63317, CampusID: 2, ExpenditureID: 23,
+		Cost:         sql.NullString{String: "4952.953", Valid: true},
+		CreateUserID: sql.NullInt64{Int64: 0, Valid: true},
+		CreateTime:   sql.NullTime{Time: time.Date(2019, 10, 7, 16, 39, 45, 0, time.Local), Valid: true},
+		Year:         sql.NullInt64{Int64: 2019, Valid: true},
+		Month:        sql.NullInt64{Int64: 10, Valid: true},
+	}
+	got, warns, err := MapExpenditureLog(l, func(int64) (string, bool) { return "", false })
+	if err != nil {
+		t.Fatalf("MapExpenditureLog 出错:%v", err)
+	}
+	if got.BizNo != validBizNo {
+		t.Errorf("biz_no 应原样保留,得到 %q", got.BizNo)
+	}
+	if got.CostCents != 495295 {
+		t.Errorf("cost 4952.953 → 4952.95(495295 分),实际 %d", got.CostCents)
+	}
+	if got.CreateUserID.Valid {
+		t.Error("create_userid=0 应落 NULL")
+	}
+	if got.ExpenditureID != 23 {
+		t.Errorf("expenditure_id 应恒等映射 23,实际 %d", got.ExpenditureID)
+	}
+	if len(warns) != 0 {
+		t.Errorf("不应有告警,实际 %v", warns)
+	}
+}
+
+// TestMapExpenditureLogRejectsNullCreateTime 证明 target NOT NULL 的时间列不会被当前时间填充。
+func TestMapExpenditureLogRejectsNullCreateTime(t *testing.T) {
+	l := LegacyExpenditureLog{BizNo: validBizNo, Cost: sql.NullString{String: "1.000", Valid: true}}
+	if _, _, err := MapExpenditureLog(l, func(int64) (string, bool) { return "", false }); err == nil {
+		t.Error("create_time 为 NULL 时应报错(不得用当前时间填充),实际未报错")
+	}
+}
+
+// TestRequireUserRefRejectsUnmapped 证明目标态 NOT NULL 的引用人列解析不到时**报错而不是 NULL**。
+func TestRequireUserRefRejectsUnmapped(t *testing.T) {
+	if _, err := requireUserRef(999999, func(int64) (string, bool) { return "", false }, "user_id"); err == nil {
+		t.Error("无映射行的 NOT NULL 引用列应报错,实际未报错")
+	}
+	if _, err := requireUserRef(0, func(int64) (string, bool) { return "x", true }, "user_id"); err == nil {
+		t.Error("legacy 0 的 NOT NULL 引用列应报错,实际未报错")
+	}
+}
+
+// TestMapExpenditureDict 证明字典 D1 + 名称超长拒绝截断。
+func TestMapExpenditureDict(t *testing.T) {
+	d := LegacyExpenditure{ID: 23, Type: 1, ParentID: 0, Name: "水电费"}
+	got, _, err := MapExpenditure(d)
+	if err != nil {
+		t.Fatalf("MapExpenditure 出错:%v", err)
+	}
+	if got.ID != 23 || got.InstitutionID != 0 {
+		t.Errorf("字典应 D1 保留 id=23 且 institution_id=0,实际 %d/%d", got.ID, got.InstitutionID)
+	}
+	long := LegacyExpenditure{ID: 1, Name: string(make([]rune, 501))}
+	if _, _, err := MapExpenditure(long); err == nil {
+		t.Error("name 超 500 字符应报错(拒绝截断字典名称),实际未报错")
+	}
+}

+ 1044 - 0
internal/academic/dualrun/fixture/batch2_verify.go

@@ -0,0 +1,1044 @@
+package fixture
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+// 本文件是第二批的**逐列核对**。两条与上一批相同的铁律:
+//  1. 期望值**不由本包映射代码产生**:金额/时间一律由 MySQL 服务端
+//     `CAST(... AS DECIMAL(12,2))` / `DATE_FORMAT(...)` 算出;
+//  2. 用户引用(ULID)的期望值来自**目标库 t_legacy_mapping 的实际数据**
+//     (反向 JOIN 查出来),而不是调用 FixtureCanonicalUserID —— 否则映射写错时两边一起错。
+//
+// 另外本批专门验证 biz_no 四项(原样保留 / 幂等 / id 解耦 / 反查可用)。
+
+// ColumnAgg 是「一列 × N 行」的聚合核对结果(避免 600+ 行逐格输出)。
+type ColumnAgg struct {
+	Table   string
+	Column  string
+	Total   int
+	Equal   int
+	Samples []string // 最多 3 条不一致样例
+	Note    string
+}
+
+// OK 表示该列所有行都相等。
+func (c ColumnAgg) OK() bool { return c.Total > 0 && c.Equal == c.Total }
+
+// colAcc 是聚合累加器。
+type colAcc struct {
+	table, column, note string
+	total, equal        int
+	samples             []string
+}
+
+func newCol(table, column, note string) *colAcc {
+	return &colAcc{table: table, column: column, note: note}
+}
+
+// cmp 比一行;不一致时留样例(最多 3 条)。
+func (a *colAcc) cmp(rowKey, legacy, target string, ok bool) {
+	a.total++
+	if ok {
+		a.equal++
+		return
+	}
+	if len(a.samples) < 3 {
+		a.samples = append(a.samples, fmt.Sprintf("%s: legacy=%q target=%q", rowKey, truncate(legacy, 28), truncate(target, 28)))
+	}
+}
+
+func (a *colAcc) done() ColumnAgg {
+	return ColumnAgg{Table: a.table, Column: a.column, Total: a.total, Equal: a.equal,
+		Samples: a.samples, Note: a.note}
+}
+
+// BizNoVerify 是 biz_no 四项验证的结果。
+type BizNoVerify struct {
+	Table            string
+	Rows             int
+	CharEqual        int  // 逐字符相等的行数
+	Len32            int  // 长度为 32 的行数
+	HexOK            int  // 32 位小写 hex 的行数
+	UniqueBizNo      int  // DISTINCT biz_no 数
+	IDEqualsPrefix   bool // id 是否等于 biz_no 派生的数字(**必须为 false**,证明未做反算)
+	ReverseJoin      int  // 用 biz_no JOIN 回 legacy 的行数
+	LegacyRows       int
+	MirrorOK         bool     // 反序插入镜像:id 跟插入顺序、不跟 biz_no
+	MirrorSample     []string // 镜像里 (id, biz_no) 前 5 行
+	AutoIncrementVal int64
+	MaxID            int64
+}
+
+// D1Verify 是 D1 口径的 id 一致性结果。
+type D1Verify struct {
+	Table     string
+	LegacyIDs []int64
+	TargetIDs []int64
+	Pass      bool
+}
+
+// Batch2VerifyReport 是整轮核对结果。
+type Batch2VerifyReport struct {
+	Options    Batch2Options
+	Cols       []ColumnAgg
+	Assertions []Assertion
+	BizNo      []BizNoVerify
+	D1         []D1Verify
+	TargetRows map[string]int64
+	Warnings   []string
+}
+
+// FailCount 统计不一致列数 + 失败断言数。
+func (r *Batch2VerifyReport) FailCount() int {
+	n := 0
+	for _, c := range r.Cols {
+		if !c.OK() {
+			n++
+		}
+	}
+	for _, a := range r.Assertions {
+		if !a.Pass {
+			n++
+		}
+	}
+	return n
+}
+
+// OK 表示整轮通过。
+func (r *Batch2VerifyReport) OK() bool { return r.FailCount() == 0 }
+
+// ulidIndex 从目标库 t_legacy_mapping 反查 legacy int → ULID(数据驱动的期望值来源)。
+func ulidIndex(ctx context.Context, db *sql.DB) (map[int64]string, error) {
+	rows, err := db.QueryContext(ctx,
+		`SELECT legacy_academic_user_id, canonical_user_id FROM t_legacy_mapping
+		  WHERE legacy_academic_user_id IS NOT NULL`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	out := map[int64]string{}
+	for rows.Next() {
+		var id int64
+		var uid string
+		if err := rows.Scan(&id, &uid); err != nil {
+			return nil, err
+		}
+		out[id] = uid
+	}
+	return out, rows.Err()
+}
+
+// expectedULID 用映射表数据解析期望 ULID;0/NULL/无映射 → "NULL"。
+func expectedULID(idx map[int64]string, v sql.NullInt64) string {
+	if !v.Valid || v.Int64 <= 0 {
+		return "NULL"
+	}
+	if uid, ok := idx[v.Int64]; ok {
+		return uid
+	}
+	return "UNMAPPED(" + strconv.FormatInt(v.Int64, 10) + ")"
+}
+
+func nullStrOrNull(v sql.NullString) string {
+	if !v.Valid {
+		return "NULL"
+	}
+	return v.String
+}
+
+// VerifyBatch2 执行第二批的完整核对。
+func VerifyBatch2(ctx context.Context, cfg Config, opts Batch2Options) (*Batch2VerifyReport, error) {
+	legacy, err := cfg.OpenLegacy()
+	if err != nil {
+		return nil, err
+	}
+	defer legacy.Close()
+	target, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, err
+	}
+	defer target.Close()
+
+	idx, err := ulidIndex(ctx, target)
+	if err != nil {
+		return nil, err
+	}
+
+	rep := &Batch2VerifyReport{Options: opts, TargetRows: map[string]int64{}}
+	for _, t := range []string{"t_expenditure", "t_user_divide_into", "t_user_divide_into_log",
+		"t_user_balance_request", "t_expenditure_log"} {
+		var n int64
+		if err := target.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&n); err != nil {
+			return nil, err
+		}
+		rep.TargetRows[t] = n
+	}
+
+	if err := verifyDivideInto(ctx, legacy, target, opts, idx, rep); err != nil {
+		return nil, err
+	}
+	if err := verifyDivideIntoLog(ctx, legacy, target, opts, idx, rep); err != nil {
+		return nil, err
+	}
+	if err := verifyBalanceRequest(ctx, legacy, target, opts, idx, rep); err != nil {
+		return nil, err
+	}
+	if err := verifyExpenditureLog(ctx, legacy, target, opts, idx, rep); err != nil {
+		return nil, err
+	}
+	if opts.WithExpenditureDict {
+		if err := verifyExpenditureDict(ctx, legacy, target, rep); err != nil {
+			return nil, err
+		}
+	}
+	if err := verifyBizNoFour(ctx, legacy, target, opts, rep); err != nil {
+		return nil, err
+	}
+	return rep, nil
+}
+
+// ---------- t_user_divide_into ----------
+
+func verifyDivideInto(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
+	idx map[int64]string, rep *Batch2VerifyReport) error {
+	type row struct {
+		UserID               int64
+		Type                 int32
+		Quota, Income, Payed string
+		Root, Parent         sql.NullInt64
+		Left, Right, Layer   int32
+	}
+	const q = `
+SELECT userid, cast(ifnull(type,1) as signed),
+       CAST(ROUND(CAST(ifnull(quota,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(ifnull(total_income,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(ifnull(total_income_payed,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       root_userid, parent_userid,
+       cast(ifnull(left_val,1) as signed), cast(ifnull(right_val,2) as signed),
+       cast(ifnull(layer,1) as signed)
+  FROM t_user_divide_into WHERE root_userid = ? ORDER BY left_val, userid`
+	rows, err := legacy.QueryContext(ctx, q, opts.DivideRootUserID)
+	if err != nil {
+		return err
+	}
+	var exp []row
+	for rows.Next() {
+		var r row
+		if err := rows.Scan(&r.UserID, &r.Type, &r.Quota, &r.Income, &r.Payed,
+			&r.Root, &r.Parent, &r.Left, &r.Right, &r.Layer); err != nil {
+			rows.Close()
+			return err
+		}
+		exp = append(exp, r)
+	}
+	rows.Close()
+
+	cUser := newCol("t_user_divide_into", "user_id", "legacy userid → ULID")
+	cType := newCol("t_user_divide_into", "type", "")
+	cQuota := newCol("t_user_divide_into", "quota", "float(10,2) → DECIMAL(12,2)")
+	cIncome := newCol("t_user_divide_into", "total_income", "")
+	cPayed := newCol("t_user_divide_into", "total_income_payed", "")
+	cRoot := newCol("t_user_divide_into", "root_user_id", "根节点=自身")
+	cParent := newCol("t_user_divide_into", "parent_user_id", "legacy parent_userid=0(根)→ NULL")
+	cLeft := newCol("t_user_divide_into", "left_val", "Nested Set")
+	cRight := newCol("t_user_divide_into", "right_val", "Nested Set")
+	cLayer := newCol("t_user_divide_into", "layer", "")
+
+	for _, e := range exp {
+		rowKey := strconv.FormatInt(e.UserID, 10)
+		wantUID := expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true})
+		var got struct {
+			UserID               string
+			Type                 int8
+			Quota, Income, Payed string
+			Root, Parent         sql.NullString
+			Left, Right, Layer   int32
+		}
+		err := target.QueryRowContext(ctx, `
+SELECT user_id, type, CAST(quota AS DECIMAL(12,2)), CAST(total_income AS DECIMAL(12,2)),
+       CAST(total_income_payed AS DECIMAL(12,2)), root_user_id, parent_user_id,
+       left_val, right_val, layer
+  FROM t_user_divide_into WHERE user_id = ?`, wantUID).Scan(
+			&got.UserID, &got.Type, &got.Quota, &got.Income, &got.Payed,
+			&got.Root, &got.Parent, &got.Left, &got.Right, &got.Layer)
+		if err == sql.ErrNoRows {
+			cUser.cmp(rowKey, wantUID, "<缺失>", false)
+			continue
+		}
+		if err != nil {
+			return err
+		}
+		cUser.cmp(rowKey, wantUID, got.UserID, got.UserID == wantUID)
+		cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(got.Type), int32(got.Type) == e.Type)
+		cQuota.cmp(rowKey, e.Quota, got.Quota, normalizeMoney(e.Quota) == normalizeMoney(got.Quota))
+		cIncome.cmp(rowKey, e.Income, got.Income, normalizeMoney(e.Income) == normalizeMoney(got.Income))
+		cPayed.cmp(rowKey, e.Payed, got.Payed, normalizeMoney(e.Payed) == normalizeMoney(got.Payed))
+		cRoot.cmp(rowKey, expectedULID(idx, e.Root), nullStrOrNull(got.Root),
+			nullStrOrNull(got.Root) == expectedULID(idx, e.Root))
+		cParent.cmp(rowKey, expectedULID(idx, e.Parent), nullStrOrNull(got.Parent),
+			nullStrOrNull(got.Parent) == expectedULID(idx, e.Parent))
+		cLeft.cmp(rowKey, fmt.Sprint(e.Left), fmt.Sprint(got.Left), got.Left == e.Left)
+		cRight.cmp(rowKey, fmt.Sprint(e.Right), fmt.Sprint(got.Right), got.Right == e.Right)
+		cLayer.cmp(rowKey, fmt.Sprint(e.Layer), fmt.Sprint(got.Layer), got.Layer == e.Layer)
+	}
+	if len(exp) == 0 {
+		cUser.cmp("-", "0 行", "0 行", false)
+	}
+	rep.Cols = append(rep.Cols, cUser.done(), cType.done(), cQuota.done(), cIncome.done(),
+		cPayed.done(), cRoot.done(), cParent.done(), cLeft.done(), cRight.done(), cLayer.done())
+
+	// 断言:树上**恰好 1 个根**(parent_user_id IS NULL),且该根的 root_user_id = user_id
+	var total, nullParent, selfRoot int
+	if err := target.QueryRowContext(ctx, `
+SELECT COUNT(*),
+       SUM(parent_user_id IS NULL),
+       SUM(parent_user_id IS NULL AND root_user_id = user_id)
+  FROM t_user_divide_into`).Scan(&total, &nullParent, &selfRoot); err != nil {
+		return err
+	}
+	rep.Assertions = append(rep.Assertions, Assertion{
+		Name: "分账树根节点 parent_user_id 为 NULL,且根节点 root_user_id = user_id(legacy parent_userid=0 / root_userid=自身)",
+		Pass: nullParent == 1 && selfRoot == 1,
+		Detail: fmt.Sprintf("t_user_divide_into 共 %d 行:parent_user_id IS NULL 的 %d 行(应=1),"+
+			"其中 root_user_id=user_id 的 %d 行(应=1)", total, nullParent, selfRoot),
+	})
+	return nil
+}
+
+// ---------- t_user_divide_into_log ----------
+
+func verifyDivideIntoLog(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
+	idx map[int64]string, rep *Batch2VerifyReport) error {
+	type row struct {
+		ID      int64
+		Type    int32
+		UserID  int64
+		Student int64
+		OrderNo sql.NullString
+		Money   string
+		TimeStr sql.NullString
+		Remark  sql.NullString
+	}
+	const q = `
+SELECT id, cast(type as signed), userid, studentid, orderid,
+       CAST(ROUND(CAST(ifnull(money,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'), remark
+  FROM t_user_divide_into_log WHERE userid = ? ORDER BY id`
+	rows, err := legacy.QueryContext(ctx, q, opts.DivideRootUserID)
+	if err != nil {
+		return err
+	}
+	var exp []row
+	for rows.Next() {
+		var r row
+		if err := rows.Scan(&r.ID, &r.Type, &r.UserID, &r.Student, &r.OrderNo, &r.Money, &r.TimeStr, &r.Remark); err != nil {
+			rows.Close()
+			return err
+		}
+		exp = append(exp, r)
+	}
+	rows.Close()
+
+	cID := newCol("t_user_divide_into_log", "id", "D1:与 legacy id 完全相同")
+	cType := newCol("t_user_divide_into_log", "type", "tinyint zerofill → TINYINT")
+	cUser := newCol("t_user_divide_into_log", "user_id", "userid → ULID")
+	cStudent := newCol("t_user_divide_into_log", "student_id", "studentid → ULID")
+	cOrder := newCol("t_user_divide_into_log", "order_no", "orderid → order_no")
+	cMoney := newCol("t_user_divide_into_log", "money", "float(10,2) → DECIMAL(12,2)")
+	cTime := newCol("t_user_divide_into_log", "create_time", "秒级相同,毫秒 .000")
+	cRemark := newCol("t_user_divide_into_log", "remark", "text → LONGTEXT,报告脱敏")
+
+	var legacyIDs, targetIDs []int64
+	for _, e := range exp {
+		rowKey := "id=" + strconv.FormatInt(e.ID, 10)
+		legacyIDs = append(legacyIDs, e.ID)
+		var g struct {
+			ID      int64
+			Type    int8
+			UserID  string
+			Student string
+			OrderNo sql.NullString
+			Money   string
+			TimeStr sql.NullString
+			Remark  sql.NullString
+		}
+		err := target.QueryRowContext(ctx, `
+SELECT id, type, user_id, student_id, order_no,
+       CAST(money AS DECIMAL(12,2)),
+       DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'), remark
+  FROM t_user_divide_into_log WHERE id = ?`, e.ID).Scan(
+			&g.ID, &g.Type, &g.UserID, &g.Student, &g.OrderNo, &g.Money, &g.TimeStr, &g.Remark)
+		if err == sql.ErrNoRows {
+			cID.cmp(rowKey, strconv.FormatInt(e.ID, 10), "<缺失>", false)
+			continue
+		}
+		if err != nil {
+			return err
+		}
+		targetIDs = append(targetIDs, g.ID)
+		cID.cmp(rowKey, strconv.FormatInt(e.ID, 10), strconv.FormatInt(g.ID, 10), g.ID == e.ID)
+		cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(g.Type), int32(g.Type) == e.Type)
+		cUser.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}), g.UserID,
+			g.UserID == expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}))
+		cStudent.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.Student, Valid: true}), g.Student,
+			g.Student == expectedULID(idx, sql.NullInt64{Int64: e.Student, Valid: true}))
+		cOrder.cmp(rowKey, nullStrOrNull(e.OrderNo), nullStrOrNull(g.OrderNo), nullStrOrNull(e.OrderNo) == nullStrOrNull(g.OrderNo))
+		cMoney.cmp(rowKey, e.Money, g.Money, normalizeMoney(e.Money) == normalizeMoney(g.Money))
+		cTime.cmp(rowKey, nullStrOrNull(e.TimeStr), nullStrOrNull(g.TimeStr), nullStrOrNull(e.TimeStr) == nullStrOrNull(g.TimeStr))
+		cRemark.cmp(rowKey, maskRemark(e.Remark), maskRemark(g.Remark), sameRemark(e.Remark, g.Remark))
+	}
+	rep.Cols = append(rep.Cols, cID.done(), cType.done(), cUser.done(), cStudent.done(),
+		cOrder.done(), cMoney.done(), cTime.done(), cRemark.done())
+
+	rep.D1 = append(rep.D1, D1Verify{
+		Table:     "t_user_divide_into_log",
+		LegacyIDs: legacyIDs,
+		TargetIDs: targetIDs,
+		Pass:      int64SliceEqual(legacyIDs, targetIDs),
+	})
+	return nil
+}
+
+func int64SliceEqual(a, b []int64) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	x := append([]int64(nil), a...)
+	y := append([]int64(nil), b...)
+	sort.Slice(x, func(i, j int) bool { return x[i] < x[j] })
+	sort.Slice(y, func(i, j int) bool { return y[i] < y[j] })
+	for i := range x {
+		if x[i] != y[i] {
+			return false
+		}
+	}
+	return true
+}
+
+// ---------- t_user_balance_request ----------
+
+func verifyBalanceRequest(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
+	idx map[int64]string, rep *Batch2VerifyReport) error {
+	type row struct {
+		BizNo                       string
+		Type                        int32
+		UserID                      int64
+		SchoolID, CampusID          int64
+		Money                       sql.NullString
+		TransferType                int8
+		TransferTime                sql.NullString
+		OrderNo                     sql.NullString
+		ReqUser                     int64
+		ReqTime                     sql.NullString
+		RespUser                    sql.NullInt64
+		RespTime                    sql.NullString
+		RespMsg, Item, Imgs, Remark sql.NullString
+		Aproved                     int8
+	}
+	const q = `
+SELECT id, type, userid, schoolid, campusid,
+       CAST(ROUND(CAST(money AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       transfer_accounts_type, DATE_FORMAT(transfer_accounts_time,'%Y-%m-%d %H:%i:%s.%f'),
+       orderid, request_userid, DATE_FORMAT(request_time,'%Y-%m-%d %H:%i:%s.%f'),
+       response_userid, DATE_FORMAT(response_time,'%Y-%m-%d %H:%i:%s.%f'),
+       response_msg, item, imgs, remark, aproved
+  FROM t_user_balance_request WHERE userid = ? ORDER BY request_time, id`
+	rows, err := legacy.QueryContext(ctx, q, opts.BalanceRequestUserID)
+	if err != nil {
+		return err
+	}
+	var exp []row
+	for rows.Next() {
+		var r row
+		if err := rows.Scan(&r.BizNo, &r.Type, &r.UserID, &r.SchoolID, &r.CampusID, &r.Money,
+			&r.TransferType, &r.TransferTime, &r.OrderNo, &r.ReqUser, &r.ReqTime,
+			&r.RespUser, &r.RespTime, &r.RespMsg, &r.Item, &r.Imgs, &r.Remark, &r.Aproved); err != nil {
+			rows.Close()
+			return err
+		}
+		exp = append(exp, r)
+	}
+	rows.Close()
+
+	const t = "t_user_balance_request"
+	cBiz := newCol(t, "biz_no", "旧 UUID 主键原样保留(逐字符)")
+	cType := newCol(t, "type", "")
+	cUser := newCol(t, "user_id", "userid → ULID")
+	cInst := newCol(t, "institution_id", "schoolid → institution_id(org 域未映射,直迁)")
+	cCampus := newCol(t, "campus_id", "campusid → campus_id(同上)")
+	cToInst := newCol(t, "to_institution_id", "legacy 无此列 → 必须 NULL")
+	cToCampus := newCol(t, "to_campus_id", "legacy 无此列 → 必须 NULL")
+	cMoney := newCol(t, "money", "decimal(10,3) → DECIMAL(12,2)")
+	cTT := newCol(t, "transfer_accounts_type", "")
+	cTTime := newCol(t, "transfer_accounts_time", "NULL → NULL")
+	cOrder := newCol(t, "order_no", "orderid → order_no")
+	cReqUser := newCol(t, "request_user_id", "request_userid → ULID")
+	cReqTime := newCol(t, "request_time", "秒级相同")
+	cRespUser := newCol(t, "response_user_id", "0 → NULL")
+	cRespTime := newCol(t, "response_time", "NULL → NULL")
+	cRespMsg := newCol(t, "response_msg", "空串保留空串")
+	cApproved := newCol(t, "approved", "aproved 错拼修正;-2/1 原样")
+	cItem := newCol(t, "item", "text → varchar(1000),报告脱敏")
+	cImgs := newCol(t, "imgs", "text → LONGTEXT,比对原文")
+	cRemark := newCol(t, "remark", "空串保留空串,报告脱敏")
+
+	var zeroResp int
+	for _, e := range exp {
+		rowKey := "biz=" + shortBizNo(e.BizNo)
+		if e.RespUser.Valid && e.RespUser.Int64 == 0 {
+			zeroResp++
+		}
+		var g struct {
+			ID                                    int64
+			BizNo                                 string
+			Type                                  int32
+			UserID                                string
+			Inst, Campus                          int64
+			ToInst, ToCampus                      sql.NullInt64
+			Money                                 string
+			TT                                    int8
+			TTime, OrderNo, ReqTime               sql.NullString
+			ReqUser                               string
+			RespUser                              sql.NullString
+			RespTime, RespMsg, Item, Imgs, Remark sql.NullString
+			Approved                              int8
+		}
+		err := target.QueryRowContext(ctx, `
+SELECT id, biz_no, type, user_id, institution_id, campus_id, to_institution_id, to_campus_id,
+       CAST(money AS DECIMAL(12,2)), transfer_accounts_type,
+       DATE_FORMAT(transfer_accounts_time,'%Y-%m-%d %H:%i:%s.%f'), order_no,
+       request_user_id, DATE_FORMAT(request_time,'%Y-%m-%d %H:%i:%s.%f'),
+       response_user_id, DATE_FORMAT(response_time,'%Y-%m-%d %H:%i:%s.%f'),
+       response_msg, approved, item, imgs, remark
+  FROM t_user_balance_request WHERE biz_no = ?`, e.BizNo).Scan(
+			&g.ID, &g.BizNo, &g.Type, &g.UserID, &g.Inst, &g.Campus, &g.ToInst, &g.ToCampus,
+			&g.Money, &g.TT, &g.TTime, &g.OrderNo, &g.ReqUser, &g.ReqTime, &g.RespUser,
+			&g.RespTime, &g.RespMsg, &g.Approved, &g.Item, &g.Imgs, &g.Remark)
+		if err == sql.ErrNoRows {
+			cBiz.cmp(rowKey, e.BizNo, "<缺失>", false)
+			continue
+		}
+		if err != nil {
+			return err
+		}
+		cBiz.cmp(rowKey, e.BizNo, g.BizNo, g.BizNo == e.BizNo)
+		cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(g.Type), g.Type == e.Type)
+		cUser.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}), g.UserID,
+			g.UserID == expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}))
+		cInst.cmp(rowKey, fmt.Sprint(e.SchoolID), fmt.Sprint(g.Inst), g.Inst == e.SchoolID)
+		cCampus.cmp(rowKey, fmt.Sprint(e.CampusID), fmt.Sprint(g.Campus), g.Campus == e.CampusID)
+		cToInst.cmp(rowKey, "NULL", fmt.Sprint(g.ToInst.Int64), !g.ToInst.Valid)
+		cToCampus.cmp(rowKey, "NULL", fmt.Sprint(g.ToCampus.Int64), !g.ToCampus.Valid)
+		cMoney.cmp(rowKey, nullStrOrNull(e.Money), g.Money,
+			normalizeMoney(orZero(e.Money)) == normalizeMoney(g.Money))
+		cTT.cmp(rowKey, fmt.Sprint(e.TransferType), fmt.Sprint(g.TT), g.TT == e.TransferType)
+		cTTime.cmp(rowKey, nullStrOrNull(e.TransferTime), nullStrOrNull(g.TTime),
+			nullStrOrNull(e.TransferTime) == nullStrOrNull(g.TTime))
+		cOrder.cmp(rowKey, nullStrOrNull(e.OrderNo), nullStrOrNull(g.OrderNo),
+			nullStrOrNull(e.OrderNo) == nullStrOrNull(g.OrderNo))
+		cReqUser.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.ReqUser, Valid: true}), g.ReqUser,
+			g.ReqUser == expectedULID(idx, sql.NullInt64{Int64: e.ReqUser, Valid: true}))
+		cReqTime.cmp(rowKey, nullStrOrNull(e.ReqTime), nullStrOrNull(g.ReqTime),
+			nullStrOrNull(e.ReqTime) == nullStrOrNull(g.ReqTime))
+		cRespUser.cmp(rowKey, expectedULID(idx, e.RespUser), nullStrOrNull(g.RespUser),
+			nullStrOrNull(g.RespUser) == expectedULID(idx, e.RespUser))
+		cRespTime.cmp(rowKey, nullStrOrNull(e.RespTime), nullStrOrNull(g.RespTime),
+			nullStrOrNull(e.RespTime) == nullStrOrNull(g.RespTime))
+		cRespMsg.cmp(rowKey, maskText(e.RespMsg, 10), maskText(fromStr(g.RespMsg), 10), sameNullString(e.RespMsg, g.RespMsg))
+		cApproved.cmp(rowKey, fmt.Sprint(e.Aproved), fmt.Sprint(g.Approved), g.Approved == e.Aproved)
+		cItem.cmp(rowKey, maskText(e.Item, 16), maskText(fromStr(g.Item), 16), sameNullString(e.Item, g.Item))
+		cImgs.cmp(rowKey, maskText(e.Imgs, 8), maskText(fromStr(g.Imgs), 8), sameNullString(e.Imgs, g.Imgs))
+		cRemark.cmp(rowKey, maskText(e.Remark, 10), maskText(fromStr(g.Remark), 10), sameNullString(e.Remark, g.Remark))
+	}
+	rep.Cols = append(rep.Cols, cBiz.done(), cType.done(), cUser.done(), cInst.done(), cCampus.done(),
+		cToInst.done(), cToCampus.done(), cMoney.done(), cTT.done(), cTTime.done(), cOrder.done(),
+		cReqUser.done(), cReqTime.done(), cRespUser.done(), cRespTime.done(), cRespMsg.done(),
+		cApproved.done(), cItem.done(), cImgs.done(), cRemark.done())
+
+	rep.Assertions = append(rep.Assertions, Assertion{
+		Name: "response_userid=0 → response_user_id 为 NULL(不是 0/空串)",
+		Pass: countNonNullRespUser(ctx, target, opts.BalanceRequestUserID) == 0,
+		Detail: fmt.Sprintf("legacy 有 %d 行 response_userid=0;target 侧该用户 response_user_id 非 NULL 且为空串的行数必须为 0",
+			zeroResp),
+	})
+	return nil
+}
+
+// countNonNullRespUser 统计 fixture 用户下 response_user_id 落成 0/空串的行数(必须为 0)。
+func countNonNullRespUser(ctx context.Context, db *sql.DB, legacyUserID int64) int {
+	uid := FixtureCanonicalUserID(legacyUserID)
+	var n int
+	if err := db.QueryRowContext(ctx,
+		`SELECT COUNT(*) FROM t_user_balance_request
+		  WHERE user_id = ? AND (response_user_id = '0' OR response_user_id = '')`, uid).Scan(&n); err != nil {
+		return -1
+	}
+	return n
+}
+
+func orZero(v sql.NullString) string {
+	if !v.Valid || v.String == "" {
+		return "0.000"
+	}
+	return v.String
+}
+
+func fromStr(v sql.NullString) sql.NullString { return v }
+
+func sameNullString(a, b sql.NullString) bool {
+	if a.Valid != b.Valid {
+		return false
+	}
+	if !a.Valid {
+		return true
+	}
+	return a.String == b.String
+}
+
+// maskText 报告脱敏:只展示前 n 个字符 + 总长度。比对用原文(sameNullString)。
+func maskText(v sql.NullString, n int) string {
+	if !v.Valid {
+		return "NULL"
+	}
+	r := []rune(v.String)
+	head := r
+	if len(head) > n {
+		head = head[:n]
+	}
+	return fmt.Sprintf("%s…(len=%d)", string(head), len(r))
+}
+
+func shortBizNo(s string) string {
+	if len(s) > 8 {
+		return s[:8] + "…"
+	}
+	return s
+}
+
+// ---------- t_expenditure_log ----------
+
+func verifyExpenditureLog(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
+	idx map[int64]string, rep *Batch2VerifyReport) error {
+	type row struct {
+		BizNo              string
+		SchoolID, CampusID int64
+		ExpenditureID      int64
+		Cost               string
+		CreateUser         sql.NullInt64
+		TimeStr            sql.NullString
+		Year, Month        sql.NullInt64
+		Remark, Imgs       sql.NullString
+	}
+	const q = `
+SELECT id, schoolid, campusid, expenditureid,
+       CAST(ROUND(CAST(cost AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       create_userid, DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'),
+       year, month, remark, imgs
+  FROM t_expenditure_log WHERE create_userid = ? ORDER BY id`
+	rows, err := legacy.QueryContext(ctx, q, opts.ExpenditureLogUserID)
+	if err != nil {
+		return err
+	}
+	var exp []row
+	for rows.Next() {
+		var r row
+		if err := rows.Scan(&r.BizNo, &r.SchoolID, &r.CampusID, &r.ExpenditureID, &r.Cost,
+			&r.CreateUser, &r.TimeStr, &r.Year, &r.Month, &r.Remark, &r.Imgs); err != nil {
+			rows.Close()
+			return err
+		}
+		exp = append(exp, r)
+	}
+	rows.Close()
+
+	const t = "t_expenditure_log"
+	cBiz := newCol(t, "biz_no", "旧 UUID 主键原样保留(逐字符)")
+	cInst := newCol(t, "institution_id", "schoolid → institution_id(org 域未映射,直迁)")
+	cCampus := newCol(t, "campus_id", "campusid → campus_id(同上)")
+	cExp := newCol(t, "expenditure_id", "expenditureid → t_expenditure.id(字典 D1,恒等)")
+	cCost := newCol(t, "cost", "float(10,3) → DECIMAL(12,2)")
+	cUser := newCol(t, "create_user_id", "create_userid → ULID;0 → NULL")
+	cTime := newCol(t, "create_time", "秒级相同")
+	cYear := newCol(t, "year", "MySQL YEAR → SMALLINT")
+	cMonth := newCol(t, "month", "")
+	cRemark := newCol(t, "remark", "text → LONGTEXT,报告脱敏")
+	cImgs := newCol(t, "imgs", "text → LONGTEXT")
+
+	for _, e := range exp {
+		rowKey := "biz=" + shortBizNo(e.BizNo)
+		var g struct {
+			ID           int64
+			BizNo        string
+			Inst, Campus int64
+			ExpID        int64
+			Cost         string
+			CreateUser   sql.NullString
+			TimeStr      sql.NullString
+			Year, Month  sql.NullInt64
+			Remark, Imgs sql.NullString
+		}
+		err := target.QueryRowContext(ctx, `
+SELECT id, biz_no, institution_id, campus_id, expenditure_id, CAST(cost AS DECIMAL(12,2)),
+       create_user_id, DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'),
+       year, month, remark, imgs
+  FROM t_expenditure_log WHERE biz_no = ?`, e.BizNo).Scan(
+			&g.ID, &g.BizNo, &g.Inst, &g.Campus, &g.ExpID, &g.Cost, &g.CreateUser,
+			&g.TimeStr, &g.Year, &g.Month, &g.Remark, &g.Imgs)
+		if err == sql.ErrNoRows {
+			cBiz.cmp(rowKey, e.BizNo, "<缺失>", false)
+			continue
+		}
+		if err != nil {
+			return err
+		}
+		cBiz.cmp(rowKey, e.BizNo, g.BizNo, g.BizNo == e.BizNo)
+		cInst.cmp(rowKey, fmt.Sprint(e.SchoolID), fmt.Sprint(g.Inst), g.Inst == e.SchoolID)
+		cCampus.cmp(rowKey, fmt.Sprint(e.CampusID), fmt.Sprint(g.Campus), g.Campus == e.CampusID)
+		cExp.cmp(rowKey, fmt.Sprint(e.ExpenditureID), fmt.Sprint(g.ExpID), g.ExpID == e.ExpenditureID)
+		cCost.cmp(rowKey, e.Cost, g.Cost, normalizeMoney(e.Cost) == normalizeMoney(g.Cost))
+		cUser.cmp(rowKey, expectedULID(idx, e.CreateUser), nullStrOrNull(g.CreateUser),
+			nullStrOrNull(g.CreateUser) == expectedULID(idx, e.CreateUser))
+		cTime.cmp(rowKey, nullStrOrNull(e.TimeStr), nullStrOrNull(g.TimeStr),
+			nullStrOrNull(e.TimeStr) == nullStrOrNull(g.TimeStr))
+		cYear.cmp(rowKey, nullIntStr(e.Year), nullIntStr(g.Year), nullIntStr(e.Year) == nullIntStr(g.Year))
+		cMonth.cmp(rowKey, nullIntStr(e.Month), nullIntStr(g.Month), nullIntStr(e.Month) == nullIntStr(g.Month))
+		cRemark.cmp(rowKey, maskText(e.Remark, 10), maskText(g.Remark, 10), sameNullString(e.Remark, g.Remark))
+		cImgs.cmp(rowKey, maskText(e.Imgs, 8), maskText(g.Imgs, 8), sameNullString(e.Imgs, g.Imgs))
+	}
+	rep.Cols = append(rep.Cols, cBiz.done(), cInst.done(), cCampus.done(), cExp.done(), cCost.done(),
+		cUser.done(), cTime.done(), cYear.done(), cMonth.done(), cRemark.done(), cImgs.done())
+
+	// 断言:expenditure_id 必须全部能在 t_expenditure 字典里找到
+	var orphans int
+	if err := target.QueryRowContext(ctx, `
+SELECT COUNT(*) FROM t_expenditure_log l
+  LEFT JOIN t_expenditure d ON d.id = l.expenditure_id
+ WHERE d.id IS NULL`).Scan(&orphans); err != nil {
+		return err
+	}
+	rep.Assertions = append(rep.Assertions, Assertion{
+		Name:   "expenditure_id 全部可解析到 t_expenditure(无孤儿引用)",
+		Pass:   orphans == 0,
+		Detail: fmt.Sprintf("LEFT JOIN t_expenditure 后孤儿行数 = %d(字典 45 行走 D1,id 原样)", orphans),
+	})
+	return nil
+}
+
+func nullIntStr(v sql.NullInt64) string {
+	if !v.Valid {
+		return "NULL"
+	}
+	return strconv.FormatInt(v.Int64, 10)
+}
+
+// ---------- t_expenditure 字典 ----------
+
+func verifyExpenditureDict(ctx context.Context, legacy, target *sql.DB, rep *Batch2VerifyReport) error {
+	type row struct {
+		ID       int64
+		Type     int32
+		ParentID int64
+		Name     string
+		Remark   sql.NullString
+	}
+	rows, err := legacy.QueryContext(ctx,
+		`SELECT id, type, parentid, name, remark FROM t_expenditure ORDER BY id`)
+	if err != nil {
+		return err
+	}
+	var exp []row
+	for rows.Next() {
+		var r row
+		if err := rows.Scan(&r.ID, &r.Type, &r.ParentID, &r.Name, &r.Remark); err != nil {
+			rows.Close()
+			return err
+		}
+		exp = append(exp, r)
+	}
+	rows.Close()
+
+	const t = "t_expenditure"
+	cID := newCol(t, "id", "D1:与 legacy id 完全相同")
+	cType := newCol(t, "type", "")
+	cParent := newCol(t, "parent_id", "自引用树,0=顶级")
+	cName := newCol(t, "name", "字典名称原样")
+	cRemark := newCol(t, "remark", "text → LONGTEXT")
+	cInst := newCol(t, "institution_id", "legacy 无作用域列 → 0")
+
+	for _, e := range exp {
+		rowKey := "id=" + strconv.FormatInt(e.ID, 10)
+		var g struct {
+			ID, ParentID int64
+			Type         int32
+			Name         string
+			Remark       sql.NullString
+			Inst         int64
+		}
+		err := target.QueryRowContext(ctx,
+			`SELECT id, type, parent_id, name, remark, institution_id FROM t_expenditure WHERE id = ?`,
+			e.ID).Scan(&g.ID, &g.Type, &g.ParentID, &g.Name, &g.Remark, &g.Inst)
+		if err == sql.ErrNoRows {
+			cID.cmp(rowKey, fmt.Sprint(e.ID), "<缺失>", false)
+			continue
+		}
+		if err != nil {
+			return err
+		}
+		cID.cmp(rowKey, fmt.Sprint(e.ID), fmt.Sprint(g.ID), g.ID == e.ID)
+		cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(g.Type), g.Type == e.Type)
+		cParent.cmp(rowKey, fmt.Sprint(e.ParentID), fmt.Sprint(g.ParentID), g.ParentID == e.ParentID)
+		cName.cmp(rowKey, e.Name, g.Name, g.Name == e.Name)
+		cRemark.cmp(rowKey, maskText(e.Remark, 10), maskText(g.Remark, 10), sameNullString(e.Remark, g.Remark))
+		cInst.cmp(rowKey, "0", fmt.Sprint(g.Inst), g.Inst == 0)
+	}
+	rep.Cols = append(rep.Cols, cID.done(), cType.done(), cParent.done(), cName.done(),
+		cRemark.done(), cInst.done())
+	return nil
+}
+
+// ---------- biz_no 四项验证 ----------
+
+// verifyBizNoFour 逐条验证 biz_no 口径的四件事。
+func verifyBizNoFour(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
+	rep *Batch2VerifyReport) error {
+	cases := []struct {
+		table    string
+		legacyQ  string
+		arg      int64
+		mirrorID func(bizNo string) string
+	}{
+		{
+			table:   "t_user_balance_request",
+			legacyQ: `SELECT id FROM t_user_balance_request WHERE userid = ? ORDER BY request_time, id`,
+			arg:     opts.BalanceRequestUserID,
+		},
+		{
+			table:   "t_expenditure_log",
+			legacyQ: `SELECT id FROM t_expenditure_log WHERE create_userid = ? ORDER BY id`,
+			arg:     opts.ExpenditureLogUserID,
+		},
+	}
+	for _, c := range cases {
+		v := BizNoVerify{Table: c.table}
+
+		rows, err := legacy.QueryContext(ctx, c.legacyQ, c.arg)
+		if err != nil {
+			return err
+		}
+		var legacyBizNos []string
+		for rows.Next() {
+			var s string
+			if err := rows.Scan(&s); err != nil {
+				rows.Close()
+				return err
+			}
+			legacyBizNos = append(legacyBizNos, s)
+		}
+		rows.Close()
+		v.LegacyRows = len(legacyBizNos)
+
+		// ① 逐字符相等 + ② 长度/hex + 唯一性
+		trows, err := target.QueryContext(ctx, "SELECT biz_no, id FROM "+c.table)
+		if err != nil {
+			return err
+		}
+		targetByBiz := map[string]int64{}
+		for trows.Next() {
+			var b string
+			var id int64
+			if err := trows.Scan(&b, &id); err != nil {
+				trows.Close()
+				return err
+			}
+			targetByBiz[b] = id
+		}
+		trows.Close()
+		v.Rows = len(targetByBiz)
+		v.UniqueBizNo = len(targetByBiz)
+		for _, b := range legacyBizNos {
+			if _, ok := targetByBiz[b]; ok {
+				v.CharEqual++
+			}
+			if len(b) == 32 {
+				v.Len32++
+			}
+			if hex32.MatchString(b) {
+				v.HexOK++
+			}
+		}
+
+		// ③ id 与 biz_no 解耦:目标 id 是 1..N 的连续小整数,而 biz_no 是 32 位 hex;
+		//    断言「id != biz_no 的任一数字派生」——这里用「id 必然 <= N」这一硬事实
+		//    (若做过任何 hash/截断反算,id 会是大整数)。
+		maxAllowed := int64(len(targetByBiz)) + 100
+		derived := false
+		for _, id := range targetByBiz {
+			if id > maxAllowed {
+				derived = true
+			}
+		}
+		v.IDEqualsPrefix = derived // true = 疑似由 biz_no 反算,必须为 false
+
+		// ④ 反查可用:用 biz_no JOIN 回 legacy
+		var joined int
+		if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
+			`SELECT COUNT(*) FROM %s r JOIN %s.%s t ON t.biz_no = r.id WHERE r.%s = ?`,
+			c.table, "yuansiwei_2026", c.table, joinUserCol(c.table)), c.arg).Scan(&joined); err != nil {
+			return err
+		}
+		v.ReverseJoin = joined
+
+		// ⑤ id 解耦的**反序插入实验**(临时表,会话结束自动消失):
+		//    把同一批 biz_no **倒序**插进镜像表,若 id 跟着插入顺序涨、biz_no 保持原值,
+		//    就说明 id 是纯插入计数器,与 biz_no 无关。
+		mirrorOK, sample, err := mirrorBizNoOrder(ctx, target, c.table, legacyBizNos)
+		if err != nil {
+			return err
+		}
+		v.MirrorOK, v.MirrorSample = mirrorOK, sample
+
+		// ⑥ AUTO_INCREMENT 未倒退
+		ai, err := readAutoIncrement(ctx, target, c.table)
+		if err != nil {
+			return err
+		}
+		v.AutoIncrementVal = ai
+		if err := target.QueryRowContext(ctx, "SELECT IFNULL(MAX(id),0) FROM "+c.table).Scan(&v.MaxID); err != nil {
+			return err
+		}
+		rep.BizNo = append(rep.BizNo, v)
+	}
+
+	for _, v := range rep.BizNo {
+		rep.Assertions = append(rep.Assertions,
+			Assertion{
+				Name: v.Table + ".biz_no 与 legacy id 逐字符相等",
+				Pass: v.LegacyRows > 0 && v.CharEqual == v.LegacyRows && v.Rows == v.LegacyRows,
+				Detail: fmt.Sprintf("legacy %d 行 / target %d 行 / 逐字符相等 %d 行;长度=32 的 %d 行,hex 形态 %d 行",
+					v.LegacyRows, v.Rows, v.CharEqual, v.Len32, v.HexOK),
+			},
+			Assertion{
+				Name:   v.Table + ".biz_no 唯一(uk_biz_no 生效,无重复)",
+				Pass:   v.UniqueBizNo == v.Rows,
+				Detail: fmt.Sprintf("DISTINCT biz_no = %d,总行数 = %d", v.UniqueBizNo, v.Rows),
+			},
+			Assertion{
+				Name: v.Table + ".id 与 biz_no 解耦(id 是自增,不由 biz_no 派生)",
+				Pass: !v.IDEqualsPrefix && v.MirrorOK,
+				Detail: fmt.Sprintf("max(id)=%d,AUTO_INCREMENT=%d;反序插入镜像:%v",
+					v.MaxID, v.AutoIncrementVal, v.MirrorSample),
+			},
+			Assertion{
+				Name:   v.Table + ".biz_no 反查可用(JOIN 回 legacy 命中全部行)",
+				Pass:   v.ReverseJoin == v.LegacyRows,
+				Detail: fmt.Sprintf("JOIN finance_bk 命中 %d 行 / legacy %d 行", v.ReverseJoin, v.LegacyRows),
+			},
+			Assertion{
+				Name:   v.Table + ".AUTO_INCREMENT >= max(id)+1(biz_no 口径下计数器正常递增)",
+				Pass:   v.AutoIncrementVal >= v.MaxID+1,
+				Detail: fmt.Sprintf("AUTO_INCREMENT=%d, max(id)=%d", v.AutoIncrementVal, v.MaxID),
+			},
+		)
+	}
+	return nil
+}
+
+// joinUserCol 返回各 biz_no 表在 JOIN 时用于筛选的列名。
+func joinUserCol(table string) string {
+	if table == "t_expenditure_log" {
+		return "create_userid"
+	}
+	return "userid"
+}
+
+// mirrorRequiredCols 返回各 biz_no 表「NOT NULL 且无默认值」的列与哨兵值。
+func mirrorRequiredCols(table string) ([]string, []any) {
+	switch table {
+	case "t_user_balance_request":
+		// created_at / updated_at 在真实库里没有 DEFAULT(DDL 文件写了,实际库没生效)
+		return []string{"created_at", "updated_at"}, []any{LegacyMissingTime, LegacyMissingTime}
+	case "t_expenditure_log":
+		return []string{"create_time"}, []any{LegacyMissingTime}
+	default:
+		return nil, nil
+	}
+}
+
+// mirrorBizNoOrder 在临时表里**倒序**插入这批 biz_no,验证 id 只跟插入顺序、与 biz_no 无关。
+func mirrorBizNoOrder(ctx context.Context, db *sql.DB, table string, bizNos []string) (bool, []string, error) {
+	conn, err := db.Conn(ctx)
+	if err != nil {
+		return false, nil, err
+	}
+	defer conn.Close()
+
+	tmp := "tmp_b2_bizno_" + table // 表名进临时表名:连接复用时避免残留旧结构
+	// TEMPORARY TABLE 是**会话级**的:连接被复用时它还在,所以用 IF NOT EXISTS + 清空,
+	// 全程不 DROP 任何真实表(DROP TEMPORARY 也只影响会话临时表,这里索性不用)。
+	if _, err := conn.ExecContext(ctx,
+		fmt.Sprintf("CREATE TEMPORARY TABLE IF NOT EXISTS `%s` LIKE %s", tmp, table)); err != nil {
+		return false, nil, fmt.Errorf("建临时镜像表失败:%w", err)
+	}
+	if _, err := conn.ExecContext(ctx, fmt.Sprintf("DELETE FROM `%s`", tmp)); err != nil {
+		return false, nil, fmt.Errorf("清空临时镜像表失败:%w", err)
+	}
+	if _, err := conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE `%s` AUTO_INCREMENT = 1", tmp)); err != nil {
+		// 临时镜像表只用于观察发号起点;ALTER 只作用于会话临时表,不碰任何真实表。
+		return false, nil, fmt.Errorf("重置临时镜像表自增失败:%w", err)
+	}
+	// 目标表的 NOT NULL 且无默认值的列必须显式给值(见报告「结构漂移」一节)。
+	// 镜像表只用来观察 id 的发号规律,这些列填哨兵即可。
+	extraCols, extraVals := mirrorRequiredCols(table)
+
+	rev := append([]string(nil), bizNos...)
+	sort.Sort(sort.Reverse(sort.StringSlice(rev)))
+	for _, b := range rev {
+		cols := "biz_no"
+		holders := "?"
+		args := []any{b}
+		for i, c := range extraCols {
+			cols += ", " + c
+			holders += ", ?"
+			args = append(args, extraVals[i])
+		}
+		if _, err := conn.ExecContext(ctx,
+			fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)", tmp, cols, holders), args...); err != nil {
+			return false, nil, fmt.Errorf("镜像插入失败:%w", err)
+		}
+	}
+	rows, err := conn.QueryContext(ctx, fmt.Sprintf("SELECT id, biz_no FROM `%s` ORDER BY id", tmp))
+	if err != nil {
+		return false, nil, err
+	}
+	defer rows.Close()
+	var sample []string
+	var prevBiz string
+	ok := true
+	i := 0
+	for rows.Next() {
+		var id int64
+		var b string
+		if err := rows.Scan(&id, &b); err != nil {
+			return false, nil, err
+		}
+		if len(sample) < 4 {
+			sample = append(sample, fmt.Sprintf("id=%d←biz=%s", id, shortBizNo(b)))
+		}
+		// id 必须严格从 1 递增(插入顺序),而 biz_no 必须是**倒序**(与 id 顺序相反)
+		if id != int64(i+1) {
+			ok = false
+		}
+		if i > 0 && strings.Compare(b, prevBiz) >= 0 {
+			ok = false
+		}
+		prevBiz = b
+		i++
+	}
+	return ok && i == len(bizNos), sample, rows.Err()
+}