| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- // 命令 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=Local'
- // export UNIFIED_MIGRATE_FINANCE_DSN='dev:***@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Local'
- // export UNIFIED_MIGRATE_TARGET_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=True&loc=Local'
- // 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/migrate"
- )
- const (
- envTeaching = "UNIFIED_MIGRATE_TEACHING_DSN"
- envFinance = "UNIFIED_MIGRATE_FINANCE_DSN"
- envTarget = "UNIFIED_MIGRATE_TARGET_DSN"
- )
- func main() {
- 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 <command>
- 子命令:
- 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)
- }
- }
|