// Command ac15fixture 是 AC-15 阶段 2 前置件的 CLI 入口。 // // 两批能力: // // 第一批(资金域 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 打印第二批涉及表当前行数 // // 结构对齐(created_at / updated_at 补 DEFAULT): // align 逐表比对 DDL 与实库,打印 ALTER 清单并做 dry-run(不执行) // align --apply 清单经人工确认后执行 // align --verify 只复核两列的 COLUMN_DEFAULT 是否全部非 NULL // // 环境变量(口令不落盘、不打印): // // AC15_DB_HOST / AC15_DB_PORT / AC15_DB_USER / AC15_DB_PASS / AC15_TARGET_DB // AC15_LEGACY_USER / AC15_LEGACY_PASS / AC15_LEGACY_DB // AC15_CORE_DDL core DDL 路径(默认取仓库外部的 02-data-model/core-model-ddl.sql) // // 退出码:0 = 成功/PASS;1 = 核对 FAIL;2 = 参数错误;3 = 环境或守卫失败。 package main import ( "context" "flag" "fmt" "os" "strconv" "strings" "yuansiwei/unified-backend/internal/academic/dualrun/fixture" ) func main() { if len(os.Args) < 2 { usage() os.Exit(2) } cmd := os.Args[1] args := os.Args[2:] switch cmd { case "ddl": os.Exit(runDDL(args)) case "map": os.Exit(runMap(args)) case "verify": os.Exit(runVerify(args)) case "run": os.Exit(runAll(args)) case "clean": 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 "align": os.Exit(runAlign(args)) case "map3": os.Exit(runMap3(args)) case "verify3": os.Exit(runVerify3(args)) case "idem3": os.Exit(runIdem3(args)) case "run3": os.Exit(runRun3(args)) case "clean3": os.Exit(runClean3(args)) case "count3": os.Exit(runCount3(args)) case "-h", "--help", "help": usage() os.Exit(0) default: fmt.Fprintf(os.Stderr, "未知子命令:%s\n\n", cmd) usage() os.Exit(2) } } func usage() { 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 打印相关表当前行数 第二批(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 打印第二批涉及表当前行数 第三批(org 域:t_institution / t_campus,D1 + 引用完整性): map3 迁移指定机构与其校区(默认 1,63317,63323,63324,63325,63332) verify3 逐列核对 + 机构集合 + 引用完整性(孤儿=0)+ R17 + bit(1) idem3 幂等实测(跑两次比行数与 id) run3 map3 + verify3 + idem3 clean3 清理并复核两表回 0 行 count3 打印 org 两表行数 参数:--ids 1,63317,... --all-campuses=true 结构对齐(created_at / updated_at 补 DEFAULT,只 MODIFY 不重建): align 逐表比对 DDL 与实库,打印 ALTER 清单 + dry-run(不执行) align --apply 清单确认后执行 align --verify 只复核 COLUMN_DEFAULT 是否全部非 NULL 第一批参数:--user (默认 53691), --ddl 第二批参数:--root <分账树根 userid,默认 395>, --buser <充值工单用户,默认 31348>, --euser <收支记录创建人,默认 395>, --dict <是否迁移字典,默认 true> `) } // bootstrap 解析第一批公共参数、加载配置并跑守卫。 func bootstrap(args []string, withUser bool) (fixture.Config, int64, string, int) { fs := flag.NewFlagSet("ac15fixture", flag.ContinueOnError) fs.SetOutput(os.Stderr) user := fs.Int64("user", fixture.DefaultLegacyUserID, "legacy userid") ddl := fs.String("ddl", "", "core DDL 路径(默认 AC15_CORE_DDL 或仓库外部默认路径)") if err := fs.Parse(args); err != nil { return fixture.Config{}, 0, "", 2 } 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{}, 3 } cfg, err := fixture.LoadConfig(repoRoot) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return fixture.Config{}, 3 } if err := cfg.Guard(); err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return fixture.Config{}, 3 } fmt.Printf("target=%s legacy=%s\n\n", cfg.TargetMasked(), cfg.LegacyMasked()) 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 { cfg, _, ddlPath, code := bootstrap(args, false) if code != 0 { return code } rep, err := fixture.ApplyCoreDDL(context.Background(), cfg, ddlPath) if rep != nil { fixture.PrintDDLReport(os.Stdout, rep) } if err != nil { fmt.Fprintf(os.Stderr, "\nFATAL: %v\n", err) return 3 } return 0 } func runMap(args []string) int { cfg, user, _, code := bootstrap(args, true) if code != 0 { return code } rep, err := fixture.PrepareAndLoad(context.Background(), cfg, user) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } fixture.PrintMapReport(os.Stdout, rep) return 0 } func runVerify(args []string) int { cfg, user, _, code := bootstrap(args, true) if code != 0 { return code } rep, err := fixture.Verify(context.Background(), cfg, user) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } fixture.PrintVerifyReport(os.Stdout, rep) if !rep.OK() { return 1 } return 0 } func runAll(args []string) int { if code := runMap(args); code != 0 { return code } fmt.Println() return runVerify(args) } func runClean(args []string) int { cfg, user, _, code := bootstrap(args, true) if code != 0 { return code } rep, err := fixture.Cleanup(context.Background(), cfg, user) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } fixture.PrintCleanReport(os.Stdout, rep) return 0 } func runCount(args []string) int { cfg, _, _, code := bootstrap(args, false) if code != 0 { return code } counts, err := fixture.CountTargetTables(context.Background(), cfg) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } 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 runAlign(args []string) int { fs := flag.NewFlagSet("align", flag.ContinueOnError) fs.SetOutput(os.Stderr) apply := fs.Bool("apply", false, "执行清单里的 ALTER(默认只打印 + dry-run)") verifyOnly := fs.Bool("verify", false, "只复核列默认值") modelDir := fs.String("model", "", "DDL 目录(默认 AC15_MODEL_DIR 或内置路径)") columns := fs.String("columns", "created_at,updated_at", "参与对齐的列:created_at,updated_at | create_time | all") algorithm := fs.String("algorithm", "instant", "ALGORITHM 子句:instant|inplace|copy|none(MySQL 8.0.23 不支持 instant,需显式 none)") fixNullable := fs.Bool("fix-nullable", false, "按 DDL 修正 NULL 性(默认只报告不改)") if err := fs.Parse(args); err != nil { return 2 } cfg, code := loadConfig() if code != 0 { return code } ctx := context.Background() cols, err := parseColumns(*columns) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 2 } if *verifyOnly { total, missing, err := fixture.VerifyAlign(ctx, cfg, cols) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } fmt.Printf("== 复核 %v:共 %d 列 ==\n", cols, total) if len(missing) == 0 { fmt.Printf("COLUMN_DEFAULT 全部非 NULL:PASS\n") return 0 } fmt.Printf("仍有 %d 列为 NULL:\n", len(missing)) for _, m := range missing { fmt.Printf(" - %s\n", m) } return 1 } opts := fixture.AlignOptions{ ModelDir: *modelDir, Columns: cols, Algorithm: *algorithm, FixNullable: *fixNullable, } rep, err := fixture.BuildAlignReport(ctx, cfg, opts) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } if err := fixture.DryRunAlign(ctx, cfg, rep); err != nil { fmt.Fprintf(os.Stderr, "FATAL: dry-run 失败:%v\n", err) return 3 } if *apply { if err := fixture.ApplyAlign(ctx, cfg, rep); err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } } fixture.PrintAlignReport(os.Stdout, rep) if *apply { total, missing, err := fixture.VerifyAlign(ctx, cfg, cols) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } fmt.Printf("\n== 执行后复核 %v:共 %d 列 ==\n", cols, total) if len(missing) == 0 { fmt.Printf("COLUMN_DEFAULT 全部非 NULL:PASS\n") } else { fmt.Printf("仍有 %d 列为 NULL:\n", len(missing)) for _, m := range missing { fmt.Printf(" - %s\n", m) } } if rep.Failed > 0 { return 3 } } return 0 } // parseColumns 解析 --columns:all = 审计列 + 业务列。 func parseColumns(s string) ([]string, error) { s = strings.TrimSpace(s) switch s { case "", "audit": return fixture.ColumnsAudit, nil case "business": return fixture.ColumnsBusiness, nil case "all": return append(append([]string{}, fixture.ColumnsAudit...), fixture.ColumnsBusiness...), nil } var out []string for _, p := range strings.Split(s, ",") { p = strings.TrimSpace(p) if p == "" { continue } out = append(out, p) } if len(out) == 0 { return nil, fmt.Errorf("--columns 为空") } return out, nil } // ---------- 第三批 ---------- func bootstrap3(args []string) (fixture.Config, fixture.OrgOptions, int) { def := fixture.DefaultOrgOptions() fs := flag.NewFlagSet("ac15fixture-b3", flag.ContinueOnError) fs.SetOutput(os.Stderr) ids := fs.String("ids", "", "逗号分隔的机构 id(留空=默认 6 个)") allCampuses := fs.Bool("all-campuses", def.WithAllCampuses, "迁移所选机构的全部校区(否则只迁数据里出现过的)") orgDB := fs.String("org-db", def.OrgSourceDB, "org 源库(默认 yuansiwei_i)") if err := fs.Parse(args); err != nil { return fixture.Config{}, fixture.OrgOptions{}, 2 } opts := def if strings.TrimSpace(*ids) != "" { opts.InstitutionIDs = nil for _, p := range strings.Split(*ids, ",") { p = strings.TrimSpace(p) if p == "" { continue } v, err := strconv.ParseInt(p, 10, 64) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: 非法机构 id %q\n", p) return fixture.Config{}, fixture.OrgOptions{}, 2 } opts.InstitutionIDs = append(opts.InstitutionIDs, v) } } opts.WithAllCampuses = *allCampuses opts.OrgSourceDB = *orgDB cfg, code := loadConfig() if code != 0 { return fixture.Config{}, fixture.OrgOptions{}, code } fmt.Printf("org 源=%s\n", cfg.OrgMasked()) return cfg, opts, 0 } func runMap3(args []string) int { cfg, opts, code := bootstrap3(args) if code != 0 { return code } rep, err := fixture.PrepareOrg(context.Background(), cfg, opts) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } fixture.PrintOrgReport(os.Stdout, rep) return 0 } func runVerify3(args []string) int { cfg, opts, code := bootstrap3(args) if code != 0 { return code } rep, err := fixture.VerifyOrg(context.Background(), cfg, opts) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } fixture.PrintOrgVerify(os.Stdout, rep) if !rep.OK() { return 1 } return 0 } func runIdem3(args []string) int { cfg, opts, code := bootstrap3(args) if code != 0 { return code } rep, err := fixture.IdempotencyCheckOrg(context.Background(), cfg, opts) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } fixture.PrintOrgIdem(os.Stdout, rep) if !rep.AllOK() { return 1 } return 0 } func runRun3(args []string) int { if code := runMap3(args); code != 0 { return code } fmt.Println() if code := runVerify3(args); code != 0 { return code } fmt.Println() return runIdem3(args) } func runClean3(args []string) int { cfg, opts, code := bootstrap3(args) if code != 0 { return code } rep, err := fixture.CleanupOrg(context.Background(), cfg, opts) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } fixture.PrintOrgClean(os.Stdout, rep) return 0 } func runCount3(args []string) int { cfg, _, code := bootstrap3(args) if code != 0 { return code } counts, err := fixture.CountOrgTables(context.Background(), cfg) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) return 3 } fixture.PrintCounts(os.Stdout, "第三批 org 表行数", counts, []string{"t_institution", "t_campus"}) 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 }