Q 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
..
README.md 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
config.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
ddl.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
decimal.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
extract.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
load.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
mapping.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
mapping_test.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
model.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
report.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
run.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu
verify.go 2ac36255bd feat(ac15): 阶段2前置——core 结构补齐 + 资金域4表映射器 + 最小 fixture 逐列核对 10 godzin temu

README.md

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. 怎么跑

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

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

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

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

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

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

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

  • 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 域(机构/校区)映射属另一条链路,本批不涉及。