main.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. // 命令 migrate:legacy 三源 → 目标态 5 表的数据迁移工具(P1-04/T5)。
  2. //
  3. // 设计(P0-06/P0-07 拍板规则落地):
  4. // - 双 DSN 只读源(教学 yuansiwei_i + 教务 finance_bk)+ 单 DSN 目标(unified 库)
  5. // - 幂等 upsert + 单事务(重跑不产生重复行)
  6. // - 置信分级:高(种子/phone 唯一)自动写;冲突(撞号)→ 仅出报告,转 P0-08 人工
  7. // - dry-run 只读不写,先验证量级/冲突再 run
  8. //
  9. // 子命令:
  10. //
  11. // migrate dry-run 读库 + 归一 + 置信分级 + 产出报告(不写库)
  12. // migrate run 执行迁移(EnsureTables + 单事务写入 5 表 + 报告)
  13. // migrate verify 校验目标态一致性(计数对账 + 唯一键完整性 + 抽样回查)
  14. //
  15. // 敏感 DSN 一律走环境变量(不入库,架构规格 §3.4):
  16. //
  17. // UNIFIED_MIGRATE_TEACHING_DSN 教学库(yuansiwei_i)只读 DSN
  18. // UNIFIED_MIGRATE_FINANCE_DSN 教务库(finance_bk)只读 DSN
  19. // UNIFIED_MIGRATE_TARGET_DSN 目标库(unified)读写 DSN
  20. //
  21. // 启动方式(dev):
  22. //
  23. // export UNIFIED_MIGRATE_TEACHING_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Local'
  24. // export UNIFIED_MIGRATE_FINANCE_DSN='dev:***@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Local'
  25. // export UNIFIED_MIGRATE_TARGET_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=True&loc=Local'
  26. // go run ./cmd/migrate dry-run
  27. // go run ./cmd/migrate run
  28. // go run ./cmd/migrate verify
  29. package main
  30. import (
  31. "context"
  32. "flag"
  33. "fmt"
  34. "os"
  35. "time"
  36. "yuansiwei/unified-backend/internal/migrate"
  37. )
  38. const (
  39. envTeaching = "UNIFIED_MIGRATE_TEACHING_DSN"
  40. envFinance = "UNIFIED_MIGRATE_FINANCE_DSN"
  41. envTarget = "UNIFIED_MIGRATE_TARGET_DSN"
  42. )
  43. func main() {
  44. if len(os.Args) < 2 {
  45. usage()
  46. os.Exit(2)
  47. }
  48. cmd := os.Args[1]
  49. fs := flag.NewFlagSet(cmd, flag.ExitOnError)
  50. now := fs.String("now", "", "时间锚点(RFC3339;默认当前时刻;测试/回放可指定)")
  51. _ = fs
  52. _ = now
  53. // 解析后续参数(如 -now)
  54. if err := fs.Parse(os.Args[2:]); err != nil {
  55. fmt.Fprintf(os.Stderr, "参数解析失败: %v\n", err)
  56. os.Exit(2)
  57. }
  58. teaching := os.Getenv(envTeaching)
  59. finance := os.Getenv(envFinance)
  60. target := os.Getenv(envTarget)
  61. switch cmd {
  62. case "dry-run":
  63. runMigrate(teaching, finance, target, true, *now)
  64. case "run":
  65. runMigrate(teaching, finance, target, false, *now)
  66. case "verify":
  67. runVerify(target)
  68. case "help", "-h", "--help":
  69. usage()
  70. default:
  71. fmt.Fprintf(os.Stderr, "未知子命令 %q\n\n", cmd)
  72. usage()
  73. os.Exit(2)
  74. }
  75. }
  76. // usage 打印用法。
  77. func usage() {
  78. fmt.Fprintf(os.Stderr, `数据迁移工具(P1-04/T5)
  79. 用法:
  80. go run ./cmd/migrate <command>
  81. 子命令:
  82. dry-run 读库 + 归一 + 置信分级 + 产出报告(不写库,先验证量级/冲突)
  83. run 执行迁移(EnsureTables + 单事务写 5 表 + 报告)
  84. verify 校验目标态一致性(计数对账 + 唯一键完整性)
  85. 必需环境变量(敏感 DSN,不入库):
  86. %s 教学库 yuansiwei_i 只读 DSN
  87. %s 教务库 finance_bk 只读 DSN
  88. %s 目标库 unified 读写 DSN
  89. 示例:
  90. go run ./cmd/migrate dry-run
  91. go run ./cmd/migrate run
  92. go run ./cmd/migrate verify
  93. `, envTeaching, envFinance, envTarget)
  94. }
  95. // parseTime 解析 RFC3339 时间字符串(-now 参数)。
  96. func parseTime(s string) (time.Time, error) {
  97. return time.Parse(time.RFC3339, s)
  98. }
  99. // runMigrate 执行 dry-run 或 run。
  100. func runMigrate(teaching, finance, target string, dryRun bool, nowStr string) {
  101. if teaching == "" || finance == "" || target == "" {
  102. missing := []string{}
  103. if teaching == "" {
  104. missing = append(missing, envTeaching)
  105. }
  106. if finance == "" {
  107. missing = append(missing, envFinance)
  108. }
  109. if target == "" {
  110. missing = append(missing, envTarget)
  111. }
  112. fmt.Fprintf(os.Stderr, "缺少必需环境变量: %v\n", missing)
  113. os.Exit(1)
  114. }
  115. opts := migrate.Options{
  116. TeachingDSN: teaching,
  117. FinanceDSN: finance,
  118. TargetDSN: target,
  119. DryRun: dryRun,
  120. }
  121. if nowStr != "" {
  122. t, err := parseTime(nowStr)
  123. if err != nil {
  124. fmt.Fprintf(os.Stderr, "-now 参数非法(须 RFC3339): %v\n", err)
  125. os.Exit(1)
  126. }
  127. opts.Now = t
  128. }
  129. runner, err := migrate.NewRunner(opts)
  130. if err != nil {
  131. fmt.Fprintf(os.Stderr, "装配迁移编排器失败: %v\n", err)
  132. os.Exit(1)
  133. }
  134. defer func() { _ = runner.Close() }()
  135. ctx := context.Background()
  136. // run 模式先确保目标表存在(dev 期 AutoMigrate;生产以 DDL 为准)
  137. if !dryRun {
  138. if err := runner.EnsureTables(ctx); err != nil {
  139. fmt.Fprintf(os.Stderr, "EnsureTables 失败: %v\n", err)
  140. os.Exit(1)
  141. }
  142. }
  143. mode := "run"
  144. if dryRun {
  145. mode = "dry-run"
  146. }
  147. fmt.Fprintf(os.Stderr, "=== 开始 %s(教学库 + 教务库 → 目标库)===\n", mode)
  148. rep, err := runner.Run(ctx)
  149. if err != nil {
  150. fmt.Fprintf(os.Stderr, "迁移执行失败: %v\n", err)
  151. os.Exit(1)
  152. }
  153. // 报告输出到 stdout(可重定向留档)
  154. fmt.Print(rep.String())
  155. // 有冲突/异常时退出码 0(迁移本身成功,冲突是预期产出,转 P0-08 人工)
  156. // 但若 Warnings 非空,提示用户关注
  157. if len(rep.Warnings) > 0 {
  158. fmt.Fprintf(os.Stderr, "\n[提示] %d 条警告,详见报告\n", len(rep.Warnings))
  159. }
  160. }
  161. // runVerify 校验目标态一致性(run 后的门禁)。
  162. func runVerify(target string) {
  163. if target == "" {
  164. fmt.Fprintf(os.Stderr, "缺少必需环境变量: %s\n", envTarget)
  165. os.Exit(1)
  166. }
  167. v, err := migrate.NewVerifier(target)
  168. if err != nil {
  169. fmt.Fprintf(os.Stderr, "装配校验器失败: %v\n", err)
  170. os.Exit(1)
  171. }
  172. defer func() { _ = v.Close() }()
  173. report, err := v.Verify(context.Background())
  174. if err != nil {
  175. fmt.Fprintf(os.Stderr, "校验失败: %v\n", err)
  176. os.Exit(1)
  177. }
  178. fmt.Print(report.String())
  179. if !report.OK() {
  180. fmt.Fprintln(os.Stderr, "\n[FAIL] 校验未通过,须处理上述问题后重跑 verify")
  181. os.Exit(1)
  182. }
  183. }