# `internal/academic/dualrun/fixture` —— AC-15 阶段 2 前置件(资金域 4 表映射器) 本包做一件事:**把 legacy 的一个真实用户,按目标态口径搬到 `yuansiwei_2026`,再逐列核对、清理干净**。 它是「阶段 2 双跑」的**前置件**——先证明「结构 + 映射 + 取整口径」这条链能跑通,再去比 8 个写 SP 的产出。 | 项 | 范围 | |---|---| | 表 | 资金域 4 表:`t_user_expense` / `t_user_expense_school` / `t_user_expense_log` + 解析用户所需的 `t_user` / `t_legacy_mapping` | | 不做的 | 其余 8 张资金/活动表、课程链、8 个写 SP 的双跑比对(下一批) | | 不碰的 | `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/ ``` 环境变量(口令不落盘、不打印):`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. 已知事实(不是缺陷,是数据现状) - 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 域(机构/校区)映射属另一条链路,本批不涉及。