|
|
11 godzin temu | |
|---|---|---|
| .. | ||
| README.md | 12 godzin temu | |
| batch2_extract.go | 12 godzin temu | |
| batch2_load.go | 12 godzin temu | |
| batch2_mapping.go | 12 godzin temu | |
| batch2_model.go | 12 godzin temu | |
| batch2_report.go | 12 godzin temu | |
| batch2_run.go | 12 godzin temu | |
| batch2_test.go | 12 godzin temu | |
| batch2_verify.go | 12 godzin temu | |
| batch3_io.go | 12 godzin temu | |
| batch3_mapping.go | 12 godzin temu | |
| batch3_model.go | 12 godzin temu | |
| batch3_run.go | 12 godzin temu | |
| batch3_test.go | 12 godzin temu | |
| batch3_verify.go | 12 godzin temu | |
| config.go | 11 godzin temu | |
| ddl.go | 13 godzin temu | |
| decimal.go | 13 godzin temu | |
| extract.go | 13 godzin temu | |
| load.go | 13 godzin temu | |
| mapping.go | 13 godzin temu | |
| mapping_test.go | 13 godzin temu | |
| model.go | 13 godzin temu | |
| report.go | 13 godzin temu | |
| run.go | 12 godzin temu | |
| run_marker.go | 12 godzin temu | |
| schema_align.go | 12 godzin temu | |
| schema_align_test.go | 12 godzin temu | |
| verify.go | 12 godzin temu | |
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)+ 引用完整性 |
初版统计给的是 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。
所选 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 也会拒绝"给停用校区新增数据"(这是对的)。
两条硬约束:
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 域口径:
institution_id 如实保留 school_id=1,不修正、不反推、不特判;
数据里 (schoolid=63317, campusid=1/2) 的组合只在报告里标 cross-org-campus。campus_id=0 不建行:0 是"机构级/未分配"的约定值,建了会让前端校区下拉框多出一个"未知校区"。tinyint 1→active / 0→disabled,出现 1/0 之外的值直接报错,
绝不默认 active(那会把已停用的机构/校区悄悄"复活")。| 项 | 范围 |
|---|---|
| 不做的 | 课程链、8 个写 SP 的双跑比对;org 域(机构/校区)id 映射 |
| 不碰的 | internal/academic/{service,repository,handler} 任何文件;e2e_test 库;finance_bk 只 SELECT |
# 1) 补齐 core 结构(只 CREATE TABLE IF NOT EXISTS,DDL 里的 DROP TABLE 全部丢弃)
go run ./cmd/ac15fixture ddl
# 2) 抽样搬运(默认 userid=53691)
go run ./cmd/ac15fixture map --user 53691
# 3) 逐列核对(0=PASS,1=FAIL)
go run ./cmd/ac15fixture verify --user 53691
# 4) 清理并复核 4 张表回 0 行
go run ./cmd/ac15fixture clean --user 53691
# 单测(纯映射规则,不连库)
go test ./internal/academic/dualrun/fixture/
第二批(biz_no 口径):
go run ./cmd/ac15fixture map2 # 默认 --root 395 --buser 31348 --euser 395 --dict=true
go run ./cmd/ac15fixture verify2 # 逐列核对 + biz_no 四项 + D1 id 一致性
go run ./cmd/ac15fixture idem2 # 幂等实测:同一批跑两次,比行数与 id
go run ./cmd/ac15fixture clean2 # 清理并复核 7 张表回 0 行
go run ./cmd/ac15fixture count2 # 打印第二批涉及表行数
环境变量(口令不落盘、不打印):AC15_DB_HOST/PORT/USER/PASS、AC15_TARGET_DB(默认 yuansiwei_2026)、
AC15_LEGACY_DB(默认 finance_bk)、AC15_LEGACY_USER/PASS、AC15_CORE_DDL。
未设 AC15_DB_PASS 时回退读仓库既有 config/dev.json 的 db.dsn(只读,不新增口令副本)。
守卫:host 必须本机;target 库必须在允许清单内且只允许写 yuansiwei_2026;
legacy 连接建立后立刻 SET SESSION TRANSACTION READ ONLY,写语句会被服务端拒绝。
| 文件 | 职责 |
|---|---|
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 入口 |
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,
「解析成功」那一半就没被验证到。t_user_expense 用自然键 user_id(无自增 id);t_user_expense_school 用 uk_user_inst_campus
做幂等,不保留 legacy id(对齐键是业务复合键);t_user_expense_log 用 D1。INSERT ... (id, ...) VALUES (<legacy_id>, ...),同一 legacy 流水在目标侧 id 完全相同。
不从 legacy id 反算 target id。DECIMAL(12,2):逐行 ROUND(x,2),不是 ROUND(SUM,2);
读侧 CAST(x AS DECIMAL(20,3)) 拿十进制串,写侧把整数分格式化成 "-2160.49" 交给驱动,全程无 float。DATETIME(3):legacy datetime(0) 秒级原样保留,毫秒补 .000,不使用当前时间。create_user_id:legacy 0 / NULL / 无映射 → NULL(不是 0、不是空串)request_id:legacy 无此列 → NULLbalance_after:legacy 无快照 → NULL0.00,但必须产生告警(NULL ≠ 0,业务上是两件事)remark text → varchar(1000):按字符截断(不切半个汉字),超长产生告警。本 fixture 最长 271 字符,未触发。核对不用本包的映射代码产生期望值——否则映射写错时两边一起错,核对永远是绿的。
期望值一律由 MySQL 服务端算:CAST(x AS DECIMAL(12,2))、DATE_FORMAT(t,'%Y-%m-%d %H:%i:%s.%f')。
于是 Go 侧的取整/换算只要与 DECIMAL 语义不一致,核对立刻红;变异实验(故意改错映射)必然红。
information_schema.tables.AUTO_INCREMENT 是缓存值:MySQL 8.0 默认
information_schema_stats_expiry = 86400,读出来是 1。必须
SET SESSION information_schema_stats_expiry = 0 且 SET 与 SELECT 走同一条连接(连接池会换连接)。DELETE 不回退 AUTO_INCREMENT 计数器:fixture 反复跑时计数器单调增长,
真实表上「恰好 max+1」只有首次能观测到;因此另配 CREATE TEMPORARY TABLE ... LIKE 镜像,
每次都能可重复地演示「显式插 id=100 → 下一条 101」。SHOW TABLE STATUS LIKE ? 不支持占位符(Error 1064),表名只能内联(已加字符白名单校验)。t_user_expense_log.uk_request_type(request_id, type):若把 request_id 落成空串而不是 NULL,
同 type 的多条历史流水会撞唯一键(Error 1062)。NULL 不参与唯一键比较,这才是历史数据的正确落法。| 表 | 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 或业务键。
| 列 | 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 哨兵的适用边界(与上一条同等重要):
- 这两列对分账表没有任何业务含义,是纯占位值(legacy 连时间列都没有);
- 双跑时
created_at/updated_at不参与比对(列矩阵里标N/A);- 如果将来分账表真需要时间,必须新建列并回填,不能把 epoch 当真实时间用; 也不要把「1970-01-01」当成"早期数据"去统计——它是哨兵,不是时间点。
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 |
两点结论:
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 都按这个口径实现,两者必须一致,否则核对永远红。id 与 target 整批 biz_no 逐一比对,要求逐字符相等 + 长度 32 + hex 形态;idem2 先拍 biz_no → id 快照,再完整跑一次映射,然后比对「行数 / 同 biz_no 的 id / 新增 biz_no 数」;finance_bk.t_x r JOIN yuansiwei_2026.t_x t ON t.biz_no = r.id 必须命中全部行——这就是双跑的对齐路径。created_at / updated_at 没有默认值:DDL 文件写了 DEFAULT CURRENT_TIMESTAMP(3),
但实际库是 GORM AutoMigrate 建的,column_default 是 NULL → 不给值直接 Error 1364。
本批按纪律没有 ALTER,改为映射时显式赋值(并因此引入 epoch 哨兵口径)。CREATE TEMPORARY TABLE ... LIKE 是会话级的:连接池复用连接时临时表还在,
第二次 CREATE 会报 1050 already exists;用「表名带表名 + IF NOT EXISTS + DELETE 清空 + ALTER AUTO_INCREMENT=1」
处理(ALTER 只作用于会话临时表,不碰任何真实表)。tinyint(4) unsigned zerofill 读出来是 0001/0002 字样,必须 CAST(type AS SIGNED) 取整数。align)dev 库 yuansiwei_2026 是"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体。
GORM 不处理 DEFAULT CURRENT_TIMESTAMP(3),所以它建的表 created_at / updated_at
是 NOT NULL 且 无默认值 —— 裸 SQL 插入会在随机某几张表上直接 Error 1364。
go run ./cmd/ac15fixture align # 逐表比对 DDL 与实库 → 打印 ALTER 清单 + dry-run(不执行)
go run ./cmd/ac15fixture align --apply # 清单人工确认后执行
go run ./cmd/ac15fixture align --verify # 只复核两列 COLUMN_DEFAULT 是否全部非 NULL
已执行(2026-11-10,dev 库):
created_at/updated_at16 条(含t_campus两列按 DDL 改 NOT NULL)、create_time13 条,共 29 条 ALTER、0 失败;复核 33 + 13 列COLUMN_DEFAULT全非 NULL。 全库 40 表,除 4 张种子表(t_role7 /t_permission6 /t_role_permission10 /casbin_rule14 = 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。
设计上刻意的四条约束:
ALTER TABLE ... MODIFY,绝不 DROP 重建 —— 重建会把 GORM 建的索引/字符集一起改掉,产生新漂移;column_type / is_nullable / column_comment),
只把 DEFAULT 与 ON UPDATE 换成 DDL 口径 —— 除这两项外一个字节都不动;drift-nullable 等分类),等人工定夺;CREATE TEMPORARY TABLE ... LIKE + 同一句 MODIFY),
语法或属性不合法的语句在执行前就暴露。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(只迁数据里出现过的校区)。
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) → 红。
两批 fixture 共用 t_user / t_legacy_mapping(batch1 的创建人 20052/45014 也是 batch2 的申请人)。
因此清理要两批都跑一遍(clean2 → clean):单跑一批时,另一批的映射行还在,
该表的「全表 0 行」判定会显示 FAIL —— 这是预期的,不是漏删。
t_user_expense_log 里 create_userid = 0 或 NULL 的行数 = 0(90007 行全都有真实创建人)。
所以「0 → NULL」这条规则无法用真实数据证明,只能由 mapping_test.go 单测 + 变异实验 M3 证明。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 域(机构/校区)映射属另一条链路,本批不涉及。t_user_divide_into 没有任何时间列(连 created_at 都没有),所以 target 的
created_at/updated_at 只能落 epoch 哨兵;这两个值不代表真实业务时间。t_expenditure_log.year 最大到 2026,超出「历史数据」的直觉,但确实是库里的值(原样迁移)。