# `internal/academic/dualrun/fixture` —— AC-15 阶段 2 前置件(资金域映射器) 本包做一件事:**把 legacy 的真实数据,按目标态口径搬到 `yuansiwei_2026`,再逐列核对、清理干净**。 它是「阶段 2 双跑」的**前置件**——先证明「结构 + 映射 + 取整口径」这条链能跑通,再去比 8 个写 SP 的产出。 已落地三批(主键口径各不相同,这是设计里最容易出错的地方): | 批 | 表 | 口径 | |---|---|---| | 一 | `t_user_expense` / `_school` / `_log` + `t_user` / `t_legacy_mapping` | 自然键 + **D1**(显式插 legacy id) | | 二 | `t_user_divide_into` / `_log` / `t_user_balance_request` / `t_expenditure_log`(+ 字典 `t_expenditure`) | 业务键 + **D1** + **biz_no**(旧 UUID 原样保留) | | 三 | `t_institution` / `t_campus`(org 域) | **双 D1**(`t_school.id`→`institution_id`、`t_school_campus.id`→`campus_id`)+ **引用完整性** | ## 3.5 org 域(第三批):7 个机构,不是 5 个 初版统计给的是 **5 个机构**(`SELECT DISTINCT schoolid FROM t_courses` 得 4 个 + campus 1/2 的 `school_id=1`)。 按列名逐来源清点后发现**漏了 2 个**,各自都带孤儿引用: | 漏掉的机构 | 被谁引用 | 行数 | |---|---|---| | **63332**(用户注册,campus 57) | `t_user_balance_request` | 1 行(schoolid=63332, campusid=57) | | **63330**(弘毅教育,campus 42) | **`t_courses_classify`** | 1 行(schoolid=63330) | `t_courses_classify` 甚至不在原统计的 7 个来源清单里——它是靠"扫所有带 `schoolid` 列的表"找出来的。 只迁 5 个的实测后果:**2 个来源各留 1 条孤儿机构 + 1 条孤儿校区**(验证见报告 M1 变异)。 最终集合 = `1, 63317, 63323, 63324, 63325, 63330, 63332`(7 个机构 / 29 个校区),孤儿引用 = 0。 ### 8 个停用校区上有 7,734 条记录(与备忘 7 同源) 所选 29 个校区里 **21 个 status=1、8 个 status=0**(`[3,4,8,9,14,25,58,60]`)。那 8 个"停用"校区上挂着: | campus | 名称 | 课程 | 上课记录 | 校区账户 | 充值工单 | 收支记录 | |---|---|---|---|---|---|---| |3|普罗旺斯校区|299|**4,126**|209|462|52| |4|凤岭北校区|82|954|80|114|65| |8|华奥校区|0|15|4|4|12| |9|雅维思校区|2|128|17|43|32| |14|源思维B区|1|10|1|0|0| |25|作废|0|0|0|0|0| |58|普罗旺斯校区(新)|166|**2,460**|138|239|28| |60|新民校区|0|41|11|28|12| |**合计**| |**550**|**7,734**|**460**|**890**|**201**| 迁移后这 8 个校区是 `disabled` → 挂在它们上面的 7,734 条上课记录在「按启用校区筛选」时看不到; 写入侧 `assertInScope` 也会拒绝"给停用校区新增数据"(这是对的)。 **两条硬约束**: 1. **status 必须如实写 0**,不许为了"让数据可见"改成 1 —— 那是在用数据说谎; 2. 断言:`count(t_campus WHERE status='disabled') == 8` 且 id 精确等于 `[3,4,8,9,14,25,58,60]`, 另加一条「这 8 个没有被改成非 disabled」。 > 业务含义与处置方式见**迁移备忘 7**("按校区筛选看不到历史数据")。 ### 跨机构有三个来源,不是三个说法 | 类 | 比较 | 总行数 | 一致 | 不一致 | 性质 | |---|---|---|---|---|---| |**A** 课程级|`t_courses.schoolid` ↔ 其 `campusid` 的校区真实所属|3142|1928|**250**|R17 主体(校区 1/2 属机构 1,课程普遍挂 63317)| |**B** 记录级|`t_courses_record.schoolid` ↔ 其 `campusid` 的校区真实所属|74870|70801|**4068**|含反向跨机构(记录挂机构 1、校区属 63317)| |**C** 记录 vs 课程|`t_courses_record.schoolid` ↔ 其 `coursesid` 的 `schoolid`|74870|74868|**2**|与校区无关,纯粹记录挂错机构| C 的 2 行明细:`f296ac36…`(record.schoolid=1 / campusid=0 / 课程 2933 属 63317)、 `f0abeebb…`(record.schoolid=1 / campusid=5 / 课程 2934 属 63317)—— 后者同时也是 B 里那条反向记录。 **为什么要分三层**:混在一起报时,"250 / 4068 / 2" 会被读成一个数,看不出到底是"课程挂错机构" 还是"记录挂错机构"还是"记录与课程不一致"——而 SP 双跑时这三层的处理方式不同。 三条 org 域口径: - **R17**:campus 1/2 的 `institution_id` 如实保留 `school_id=1`,**不修正、不反推、不特判**; 数据里 `(schoolid=63317, campusid=1/2)` 的组合只在报告里标 `cross-org-campus`。 - **`campus_id=0` 不建行**:0 是"机构级/未分配"的约定值,建了会让前端校区下拉框多出一个"未知校区"。 - **status 是值域翻译不是占位**:`tinyint 1→active / 0→disabled`,出现 1/0 之外的值直接**报错**, 绝不默认 active(那会把已停用的机构/校区悄悄"复活")。 | 项 | 范围 | |---|---| | 不做的 | 课程链、8 个写 SP 的双跑比对;org 域(机构/校区)id 映射 | | 不碰的 | `internal/academic/{service,repository,handler}` 任何文件;`e2e_test` 库;`finance_bk` 只 SELECT | --- ## 1. 怎么跑 ```bash # 1) 补齐 core 结构(只 CREATE TABLE IF NOT EXISTS,DDL 里的 DROP TABLE 全部丢弃) go run ./cmd/ac15fixture ddl # 2) 抽样搬运(默认 userid=53691) go run ./cmd/ac15fixture map --user 53691 # 3) 逐列核对(0=PASS,1=FAIL) go run ./cmd/ac15fixture verify --user 53691 # 4) 清理并复核 4 张表回 0 行 go run ./cmd/ac15fixture clean --user 53691 # 单测(纯映射规则,不连库) go test ./internal/academic/dualrun/fixture/ ``` 第二批(biz_no 口径): ```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`。 未设 `AC15_DB_PASS` 时回退读仓库既有 `config/dev.json` 的 `db.dsn`(只读,不新增口令副本)。 守卫:host 必须本机;target 库必须在允许清单内且**只允许写 `yuansiwei_2026`**; legacy 连接建立后立刻 `SET SESSION TRANSACTION READ ONLY`,写语句会被服务端拒绝。 ## 2. 文件 | 文件 | 职责 | |---|---| | `config.go` | 连接参数、DSN、白名单守卫、脱敏 | | `decimal.go` | 十进制串 ↔ 整数分(**唯一金额换算通道**,全程不经过 float64) | | `model.go` | legacy 行 / 目标行 / 报告结构 | | `mapping.go` | **纯映射规则**(取整、空值语义、时间、D1 id、确定性 ULID),可单测 | | `extract.go` | legacy 只读查询(金额一律 `CAST(... AS DECIMAL(20,3))`) | | `load.go` | 幂等写入(`ON DUPLICATE KEY UPDATE`) | | `verify.go` | 逐列核对 + 语义断言 + float 陷阱 + AUTO_INCREMENT | | `ddl.go` | 把 `core-model-ddl.sql` 安全套到目标库(丢弃 DROP、改写 IF NOT EXISTS) | | `run.go` | 流程编排(PrepareAndLoad / Cleanup) | | `report.go` | 终端报告排版 | | `mapping_test.go` | 纯规则单测(含变异实验的红/绿基线) | | `cmd/ac15fixture` | CLI 入口 | ## 3. 映射规则(已拍板,代码即口径) 1. **用户 id 走 `t_legacy_mapping`**:`legacy_academic_user_id`(int) → `canonical_user_id`(VARCHAR(26) ULID)。 fixture 的 ULID 由 `FixtureCanonicalUserID(legacyID)` **确定性**派生 (时间戳固定 2026-01-01 + 熵 = `sha256("ac15-fixture:user:")[:10]`): 同一 legacy id 每次得到同一 ULID,于是 map/verify/clean 三步可独立执行、天然幂等。 **生产迁移的 canonical_user_id 由归一 pipeline 的种子脚本发放,与此无关。** 注意:流水里出现的 4 个「创建人」也各插 1 行映射 —— 否则 `create_user_id` 会全部退化成 NULL, 「解析成功」那一半就没被验证到。 2. **主键**:`t_user_expense` 用自然键 `user_id`(无自增 id);`t_user_expense_school` 用 `uk_user_inst_campus` 做幂等,**不保留** legacy id(对齐键是业务复合键);`t_user_expense_log` 用 **D1**。 3. **D1(流水表)**:`INSERT ... (id, ...) VALUES (, ...)`,同一 legacy 流水在目标侧 `id` 完全相同。 **不从 legacy id 反算 target id**。 4. **金额 `DECIMAL(12,2)`**:**逐行 `ROUND(x,2)`**,不是 `ROUND(SUM,2)`; 读侧 `CAST(x AS DECIMAL(20,3))` 拿十进制串,写侧把整数分格式化成 `"-2160.49"` 交给驱动,全程无 float。 5. **时间 `DATETIME(3)`**:legacy `datetime(0)` 秒级**原样**保留,毫秒补 `.000`,**不使用当前时间**。 6. **空值语义(逐列判断)**: - `create_user_id`:legacy `0` / NULL / 无映射 → **NULL**(不是 0、不是空串) - `request_id`:legacy 无此列 → **NULL** - `balance_after`:legacy 无快照 → **NULL** - legacy 金额列 NULL(目标 NOT NULL)→ 落 `0.00`,但**必须产生告警**(NULL ≠ 0,业务上是两件事) 7. **`remark` text → varchar(1000)**:按**字符**截断(不切半个汉字),超长产生告警。本 fixture 最长 271 字符,未触发。 ## 4. 核对为什么可信(期望值的来源) 核对**不用本包的映射代码产生期望值**——否则映射写错时两边一起错,核对永远是绿的。 期望值一律由 **MySQL 服务端**算:`CAST(x AS DECIMAL(12,2))`、`DATE_FORMAT(t,'%Y-%m-%d %H:%i:%s.%f')`。 于是 Go 侧的取整/换算只要与 DECIMAL 语义不一致,核对立刻红;变异实验(故意改错映射)必然红。 ## 5. 踩到的四个真坑(都已固化在代码里,别再踩) 1. **`information_schema.tables.AUTO_INCREMENT` 是缓存值**:MySQL 8.0 默认 `information_schema_stats_expiry = 86400`,读出来是 `1`。必须 `SET SESSION information_schema_stats_expiry = 0` 且 SET 与 SELECT 走**同一条连接**(连接池会换连接)。 2. **`DELETE` 不回退 AUTO_INCREMENT 计数器**:fixture 反复跑时计数器单调增长, 真实表上「恰好 max+1」只有首次能观测到;因此另配 `CREATE TEMPORARY TABLE ... LIKE` 镜像, 每次都能可重复地演示「显式插 id=100 → 下一条 101」。 3. **`SHOW TABLE STATUS LIKE ?` 不支持占位符**(Error 1064),表名只能内联(已加字符白名单校验)。 4. **`t_user_expense_log.uk_request_type(request_id, type)`**:若把 `request_id` 落成空串而不是 NULL, 同 `type` 的多条历史流水会撞唯一键(Error 1062)。NULL 不参与唯一键比较,这才是历史数据的正确落法。 ## 6. 第二批:biz_no 口径(旧 UUID → int64 自增 + biz_no UNIQUE) | 表 | legacy 主键 | target 主键 | 对齐键 | 幂等键 | |---|---|---|---|---| |`t_user_divide_into`|`userid` int(自然键)|`user_id` VARCHAR(26)|`user_id`|PK `user_id`| |`t_user_divide_into_log`|`id` int AUTO_INC|`id` BIGINT AUTO_INC|**id 原样(D1)**|PK `id`| |`t_user_balance_request`|`id` varchar(32) UUID|`id` BIGINT AUTO_INC|**`biz_no` = 旧 id**|`uk_biz_no`| |`t_expenditure_log`|`id` varchar(32) UUID|`id` BIGINT AUTO_INC|**`biz_no` = 旧 id**|`uk_biz_no`| |`t_expenditure`(字典)|`id` int AUTO_INC|`id` BIGINT AUTO_INC|**id 原样(D1)**|PK `id`| biz_no 的四条硬约束(`NormalizeBizNo`):非空、长度 == 32、32 位小写 hex; 任一不满足 → **报错中止**,绝不截断、绝不重新生成。依据:biz_no 是双跑与回滚的唯一对齐锚点。 写入一律 `INSERT ... ON DUPLICATE KEY UPDATE`: 命中 `uk_biz_no` 时 MySQL **只更新指定列、不改写 id**,所以重跑行数不变、id 不变。 写成纯 `INSERT` 会在第二次跑报 `1062 Duplicate entry ... for key 'uk_biz_no'`(变异 M2 实测)。 `t_expenditure` 走 D1 的理由:`t_expenditure_log.expenditureid` 指向它,而它既无旧 UUID 也无业务唯一键(`name` 不唯一、`parentid` 自引用)。要让 `expenditure_id` 在双跑里可对齐, 只有「字典 id 原样保留」一条路。 > ⚠ **字典的 D1 是"临时手段",与流水表的 D1 不同性质**: > 流水表只增不改,历史行的 id 不会再变,D1 永久成立; > 而**字典项会被业务新增**——新增项在 legacy 与 target 两边各涨各的 id,一旦不齐,"按 id 对齐"立刻失效。 > 因此:**字典的 D1 只在「两边都从同一份 legacy 快照起步」时成立;割接后新增字典项必须走 biz_no 或业务键。** ## 7. 第二批:空值与时间口径(逐列判断) | 列 | legacy | target | 判定 | |---|---|---|---| |`parent_userid`|`0`(根节点)|`NULL`|0 是哨兵,不是真实用户| |`root_userid`|自身 id|自身 ULID|原样映射| |`response_userid`|`0`|`NULL`|未审批/系统| |`create_userid`|`0`|`NULL`|系统| |`response_msg` / `remark`|`''` 空串|**保留空串**|legacy 语义是「填了但为空」;这两列也无唯一键依赖(不像 `uk_request_type`)| |`item` / `imgs` / `transfer_accounts_time` / `response_time` /`orderid`|`NULL`|`NULL`|原样| |`to_institution_id` / `to_campus_id`|**无此列**|`NULL`|转结目标机构/校区在 legacy 落在 `item` 的 JSON 里,本批不解析(见报告「外键未做清单」)| |`created_at` / `updated_at`(balance_request)|无此列|`request_time` / `response_time ?? request_time`|由真实时间派生| |`created_at` / `updated_at`(divide_into)|无此列|`1970-01-01 00:00:00`(epoch 哨兵)|legacy 无任何时间信息,**绝不用 NOW()**| > ⚠ **epoch 哨兵的适用边界(与上一条同等重要)**: > 1. 这两列对分账表**没有任何业务含义,是纯占位值**(legacy 连时间列都没有); > 2. **双跑时 `created_at` / `updated_at` 不参与比对**(列矩阵里标 `N/A`); > 3. 如果将来分账表真需要时间,**必须新建列并回填**,不能把 epoch 当真实时间用; > 也不要把「1970-01-01」当成"早期数据"去统计——它是哨兵,不是时间点。 ## 8. 第二批:float(10,3) 的精度真相(本批最重要的实测结论) `t_expenditure_log.cost` 是 **`FLOAT(10,3)` = 4 字节单精度**(约 7 位有效数字),大金额存不住 3 位小数: | legacy 显示值 | 实际存储(`CAST(x AS DECIMAL(20,6))`)| 3 位取整 | 2 位取整(迁移口径)| |---|---|---|---| |204527.05|204527.**046875**|204527.047|204527.05| |74570.19|74570.**187500**|74570.188|74570.19| |10274.366|10274.**366211**|10274.366|10274.37| |3326.355|3326.**354980**|3326.355|3326.36| 两点结论: 1. **必须 ROUND,绝不能截断**——截断会系统性偏低(204527.05 → 204527.04,差 1 分;变异 M3 实测 10 行里 5 行红)。 2. **「3 位取整再取 2 位」与「直接取 2 位」在 .xx5 边界会差 1 分**: `3326.355` 直接 `CAST AS DECIMAL(12,2)` = 3326.35,而 `ROUND(CAST AS DECIMAL(20,3),2)` = 3326.36。 全表 734 行里有 **12 行**落在这种边界上。已拍板的口径是**后者**(先降到 3 位再 ROUND 到 2 位), 本包 mapper 与 verify 都按这个口径实现,两者必须一致,否则核对永远红。 ## 9. 第二批:biz_no 四项怎么验的 1. **原样保留**:把 legacy 整批 `id` 与 target 整批 `biz_no` 逐一比对,要求逐字符相等 + 长度 32 + hex 形态; 2. **幂等**:`idem2` 先拍 `biz_no → id` 快照,再完整跑一次映射,然后比对「行数 / 同 biz_no 的 id / 新增 biz_no 数」; 3. **id 解耦**:目标 id 是 1..N 的连续小整数(不是 32 位 hex 的任何派生),并额外在会话临时表里 **倒序**插入同一批 biz_no,观察 id 仍按插入顺序 1..N 递增、biz_no 保持原值; 4. **反查可用**:`finance_bk.t_x r JOIN yuansiwei_2026.t_x t ON t.biz_no = r.id` 必须命中全部行——这就是双跑的对齐路径。 ## 10. 第二批踩到的坑 1. **目标表的 `created_at` / `updated_at` 没有默认值**:DDL 文件写了 `DEFAULT CURRENT_TIMESTAMP(3)`, 但实际库是 GORM AutoMigrate 建的,`column_default` 是 NULL → 不给值直接 `Error 1364`。 **本批按纪律没有 ALTER**,改为映射时显式赋值(并因此引入 epoch 哨兵口径)。 2. **`CREATE TEMPORARY TABLE ... LIKE` 是会话级的**:连接池复用连接时临时表还在, 第二次 `CREATE` 会报 `1050 already exists`;用「表名带表名 + IF NOT EXISTS + DELETE 清空 + ALTER AUTO_INCREMENT=1」 处理(`ALTER` 只作用于会话临时表,不碰任何真实表)。 3. **`tinyint(4) unsigned zerofill`** 读出来是 `0001`/`0002` 字样,必须 `CAST(type AS SIGNED)` 取整数。 ## 11. 结构对齐工具(`align`) dev 库 `yuansiwei_2026` 是**"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体**。 GORM 不处理 `DEFAULT CURRENT_TIMESTAMP(3)`,所以它建的表 `created_at` / `updated_at` 是 `NOT NULL` 且 **无默认值** —— 裸 SQL 插入会在**随机某几张表**上直接 `Error 1364`。 ```bash go run ./cmd/ac15fixture align # 逐表比对 DDL 与实库 → 打印 ALTER 清单 + dry-run(不执行) go run ./cmd/ac15fixture align --apply # 清单人工确认后执行 go run ./cmd/ac15fixture align --verify # 只复核两列 COLUMN_DEFAULT 是否全部非 NULL ``` > **已执行(2026-11-10,dev 库)**:`created_at`/`updated_at` 16 条(含 `t_campus` 两列按 DDL 改 NOT NULL)、 > `create_time` 13 条,共 **29 条 ALTER、0 失败**;复核 33 + 13 列 `COLUMN_DEFAULT` 全非 NULL。 > 全库 40 表,除 4 张种子表(`t_role` 7 / `t_permission` 6 / `t_role_permission` 10 / `casbin_rule` 14 = 37 行)外全 0 行。 > > ⚠ **`ALGORITHM` 的版本限制(实测)**:MySQL **8.0.23** 上 `MODIFY COLUMN` 改默认值 > **不支持 `ALGORITHM=INSTANT`,也不支持 `INPLACE`**(`Error 1845 ... Try ALGORITHM=COPY`); > 该能力要 8.0.29+ 才有。因此本机执行时用 `--algorithm copy` **显式声明"我知道要重建"** > (0 行表重建零成本,已复核 `TABLE_ROWS` 未变)。升级到 8.0.29+ 后应改回 `--algorithm instant`。 设计上刻意的四条约束: 1. **只 `ALTER TABLE ... MODIFY`,绝不 DROP 重建** —— 重建会把 GORM 建的索引/字符集一起改掉,产生新漂移; 2. MODIFY 子句**用实库现有属性**(`column_type` / `is_nullable` / `column_comment`), 只把 `DEFAULT` 与 `ON UPDATE` 换成 DDL 口径 —— 除这两项外一个字节都不动; 3. **NULL 性 / 类型 / 注释等其它差异只报告不执行**(`drift-nullable` 等分类),等人工定夺; 4. 每条 ALTER 先在**会话临时表**上 dry-run(`CREATE TEMPORARY TABLE ... LIKE` + 同一句 MODIFY), 语法或属性不合法的语句在执行前就暴露。 ## 11.5 第三批怎么跑 ```bash go run ./cmd/ac15fixture map3 # 默认 7 个机构、29 个校区 go run ./cmd/ac15fixture verify3 # 逐列 + 机构集合 + 引用完整性(8 个来源逐个统计孤儿)+ R17 + bit(1) go run ./cmd/ac15fixture idem3 # 跑两次比行数与 id 集合 go run ./cmd/ac15fixture clean3 # 两表回 0 行 ``` 参数:`--ids 1,63317,...`(显式指定机构集合)、`--all-campuses=false`(只迁数据里出现过的校区)。 ## 12. `create_time` 的静默错:为什么必须配断言 补了 `DEFAULT CURRENT_TIMESTAMP(3)` 之后,`create_time` 的风险等级**反而变高**: | 列 | 语义 | 漏传时的后果(补默认前 → 补默认后)| |---|---|---| | `created_at` | 审计:这行何时入库 | 1364 报错 → 取当前时间(**合理**)| | `create_time` | 业务:这笔钱何时发生 | 1364 报错 → 取插入时刻(**静默写错**,前端要展示/排序/按区间查)| 所以映射侧根本不靠默认值:`create_time` 一律由 legacy 显式赋值;同时 verify 侧加了一条硬断言 (`run_marker.go`): ``` 迁移执行时刻(t_audit_log 里 action='ac15_fixture_run' 的标记) ↓ count(该表 create_time >= 迁移执行时刻) == 0 ``` **为什么必须落库取时间戳**:map 与 verify 是两个独立进程,时间戳无法经内存传递; 而 legacy 的历史业务时间必然早于本次迁移,所以「>= 迁移执行时刻」就是「被 DB 默认值填过」的判据。 变异 M5(映射时故意不传 `create_time`)实测:`create_time >= 该时刻的行数 = 10(期望 0)` → 红。 ## 13. 清理要注意的跨批耦合 两批 fixture **共用** `t_user` / `t_legacy_mapping`(batch1 的创建人 20052/45014 也是 batch2 的申请人)。 因此清理要**两批都跑一遍**(`clean2` → `clean`):单跑一批时,另一批的映射行还在, 该表的「全表 0 行」判定会显示 FAIL —— 这是预期的,不是漏删。 ## 14. 已知事实(不是缺陷,是数据现状) - legacy `t_user_expense_log` 里 `create_userid = 0` 或 NULL 的行数 = **0**(90007 行全都有真实创建人)。 所以「0 → NULL」这条规则**无法用真实数据证明**,只能由 `mapping_test.go` 单测 + 变异实验 M3 证明。 - legacy `t_user_expense.balance/spend/total_income/total_expenditure` 是 `float(10,3)`, `t_user_expense_school.*` 与 `t_user_expense_log.money` 是 `decimal(10,3)`——**同一业务列两边类型不同**, 所以读侧统一 `CAST(... AS DECIMAL(20,3))`,不按列假设类型。 - `t_user_expense_school.schoolid` 直迁为 `institution_id`(本 fixture 取到 63317), org 域(机构/校区)映射属另一条链路,本批不涉及。 - legacy `t_user_divide_into` **没有任何时间列**(连 created_at 都没有),所以 target 的 `created_at`/`updated_at` 只能落 epoch 哨兵;这两个值**不代表真实业务时间**。 - legacy `t_expenditure_log.year` 最大到 **2026**,超出「历史数据」的直觉,但确实是库里的值(原样迁移)。