// 命令 migrate:legacy 三源 → 目标态 5 表的数据迁移工具(P1-04/T5)。 // // 设计(P0-06/P0-07 拍板规则落地): // - 双 DSN 只读源(教学 yuansiwei_i + 教务 finance_bk)+ 单 DSN 目标(unified 库) // - 幂等 upsert + 单事务(重跑不产生重复行) // - 置信分级:高(种子/phone 唯一)自动写;冲突(撞号)→ 仅出报告,转 P0-08 人工 // - dry-run 只读不写,先验证量级/冲突再 run // // 子命令: // // migrate dry-run 读库 + 归一 + 置信分级 + 产出报告(不写库) // migrate run 执行迁移(EnsureTables + 单事务写入 5 表 + 报告) // migrate verify 校验目标态一致性(计数对账 + 唯一键完整性 + 抽样回查) // // 敏感 DSN 一律走环境变量(不入库,架构规格 §3.4): // // UNIFIED_MIGRATE_TEACHING_DSN 教学库(yuansiwei_i)只读 DSN // UNIFIED_MIGRATE_FINANCE_DSN 教务库(finance_bk)只读 DSN // UNIFIED_MIGRATE_TARGET_DSN 目标库(unified)读写 DSN // // 启动方式(dev): // // export UNIFIED_MIGRATE_TEACHING_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai' // export UNIFIED_MIGRATE_FINANCE_DSN='dev:***@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai' // export UNIFIED_MIGRATE_TARGET_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai' // go run ./cmd/migrate dry-run // go run ./cmd/migrate run // go run ./cmd/migrate verify package main import ( "context" "flag" "fmt" "os" "time" "yuansiwei/unified-backend/internal/infra/tz" "yuansiwei/unified-backend/internal/migrate" ) const ( envTeaching = "UNIFIED_MIGRATE_TEACHING_DSN" envFinance = "UNIFIED_MIGRATE_FINANCE_DSN" envTarget = "UNIFIED_MIGRATE_TARGET_DSN" ) func main() { // 进程时区最先固定:迁移要把历史 datetime 按中国本地时区解释并回放, // 与 API 进程(cmd/api)及 DSN loc 保持同一口径(详见 internal/infra/tz)。 tz.Apply() if len(os.Args) < 2 { usage() os.Exit(2) } cmd := os.Args[1] fs := flag.NewFlagSet(cmd, flag.ExitOnError) now := fs.String("now", "", "时间锚点(RFC3339;默认当前时刻;测试/回放可指定)") _ = fs _ = now // 解析后续参数(如 -now) if err := fs.Parse(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "参数解析失败: %v\n", err) os.Exit(2) } teaching := os.Getenv(envTeaching) finance := os.Getenv(envFinance) target := os.Getenv(envTarget) switch cmd { case "dry-run": runMigrate(teaching, finance, target, true, *now) case "run": runMigrate(teaching, finance, target, false, *now) case "verify": runVerify(target) case "help", "-h", "--help": usage() default: fmt.Fprintf(os.Stderr, "未知子命令 %q\n\n", cmd) usage() os.Exit(2) } } // usage 打印用法。 func usage() { fmt.Fprintf(os.Stderr, `数据迁移工具(P1-04/T5) 用法: go run ./cmd/migrate 子命令: dry-run 读库 + 归一 + 置信分级 + 产出报告(不写库,先验证量级/冲突) run 执行迁移(EnsureTables + 单事务写 5 表 + 报告) verify 校验目标态一致性(计数对账 + 唯一键完整性) 必需环境变量(敏感 DSN,不入库): %s 教学库 yuansiwei_i 只读 DSN %s 教务库 finance_bk 只读 DSN %s 目标库 unified 读写 DSN 示例: go run ./cmd/migrate dry-run go run ./cmd/migrate run go run ./cmd/migrate verify `, envTeaching, envFinance, envTarget) } // parseTime 解析 RFC3339 时间字符串(-now 参数)。 func parseTime(s string) (time.Time, error) { return time.Parse(time.RFC3339, s) } // runMigrate 执行 dry-run 或 run。 func runMigrate(teaching, finance, target string, dryRun bool, nowStr string) { if teaching == "" || finance == "" || target == "" { missing := []string{} if teaching == "" { missing = append(missing, envTeaching) } if finance == "" { missing = append(missing, envFinance) } if target == "" { missing = append(missing, envTarget) } fmt.Fprintf(os.Stderr, "缺少必需环境变量: %v\n", missing) os.Exit(1) } opts := migrate.Options{ TeachingDSN: teaching, FinanceDSN: finance, TargetDSN: target, DryRun: dryRun, } if nowStr != "" { t, err := parseTime(nowStr) if err != nil { fmt.Fprintf(os.Stderr, "-now 参数非法(须 RFC3339): %v\n", err) os.Exit(1) } opts.Now = t } runner, err := migrate.NewRunner(opts) if err != nil { fmt.Fprintf(os.Stderr, "装配迁移编排器失败: %v\n", err) os.Exit(1) } defer func() { _ = runner.Close() }() ctx := context.Background() // run 模式先确保目标表存在(dev 期 AutoMigrate;生产以 DDL 为准) if !dryRun { if err := runner.EnsureTables(ctx); err != nil { fmt.Fprintf(os.Stderr, "EnsureTables 失败: %v\n", err) os.Exit(1) } } mode := "run" if dryRun { mode = "dry-run" } fmt.Fprintf(os.Stderr, "=== 开始 %s(教学库 + 教务库 → 目标库)===\n", mode) rep, err := runner.Run(ctx) if err != nil { fmt.Fprintf(os.Stderr, "迁移执行失败: %v\n", err) os.Exit(1) } // 报告输出到 stdout(可重定向留档) fmt.Print(rep.String()) // 有冲突/异常时退出码 0(迁移本身成功,冲突是预期产出,转 P0-08 人工) // 但若 Warnings 非空,提示用户关注 if len(rep.Warnings) > 0 { fmt.Fprintf(os.Stderr, "\n[提示] %d 条警告,详见报告\n", len(rep.Warnings)) } } // runVerify 校验目标态一致性(run 后的门禁)。 func runVerify(target string) { if target == "" { fmt.Fprintf(os.Stderr, "缺少必需环境变量: %s\n", envTarget) os.Exit(1) } v, err := migrate.NewVerifier(target) if err != nil { fmt.Fprintf(os.Stderr, "装配校验器失败: %v\n", err) os.Exit(1) } defer func() { _ = v.Close() }() report, err := v.Verify(context.Background()) if err != nil { fmt.Fprintf(os.Stderr, "校验失败: %v\n", err) os.Exit(1) } fmt.Print(report.String()) if !report.OK() { fmt.Fprintln(os.Stderr, "\n[FAIL] 校验未通过,须处理上述问题后重跑 verify") os.Exit(1) } }