report.go 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. package migrate
  2. import (
  3. "fmt"
  4. "sort"
  5. "strings"
  6. )
  7. // MigrateReport 一次迁移运行的完整报告(dry-run 与 run 共用,dry-run 的 *Inserted 恒 0)。
  8. //
  9. // 字段分组:
  10. // - 机构/校区:Institutions / Campuses 处理量 + 冲突
  11. // - 用户归一:UsersTotal / UsersInserted / UsersSkipped(重跑幂等跳过)
  12. // - 身份:各 type 写入量(Phone / Email / Username / Wechat)
  13. // - 微信锚点:WechatUnion
  14. // - 成员关系:Memberships
  15. // - 冲突/异常清单:PhoneConflicts / UsernameConflicts / PhoneInvalid / WechatOrphans
  16. // (P0-08 人工复核输入,不自动处理)
  17. type MigrateReport struct {
  18. DryRun bool // true=仅预演不写库
  19. // 机构/校区
  20. InstitutionsTotal int
  21. InstitutionsCreated int
  22. CampusesTotal int
  23. CampusesCreated int
  24. // 用户归一
  25. UsersTotal int
  26. UsersCreated int // 新插入 t_user
  27. UsersSkipped int // 重跑幂等:canonical_user_id 已存在
  28. UsersDisabled int // status 映射为 disabled(0/3)
  29. // 身份(t_identity)
  30. IdentitiesPhone int
  31. IdentitiesEmail int
  32. IdentitiesUsername int
  33. IdentitiesWechat int
  34. IdentityDuplicates int // 全局唯一键已存在而跳过的 identity(幂等)
  35. // 微信锚点(t_wechat_union)
  36. WechatUnionCreated int
  37. // 成员关系(t_membership)
  38. MembershipsCreated int
  39. // 遗留映射(t_legacy_mapping)
  40. LegacyMappingsCreated int
  41. // 冲突/异常清单(P0-08 人工复核输入,不自动合并/丢弃)
  42. PhoneConflicts []ConflictItem // phone 撞号(同归一 phone >1 legacy id)
  43. UsernameConflicts []ConflictItem // username 归一后全局重复
  44. PhoneInvalid []ConflictItem // phone 归一失败(phone_invalid)
  45. WechatOrphans []ConflictItem // 微信身份 userid 不在 t_user(孤儿,丢弃)
  46. // 错误(非阻断性警告:单行失败记录,不中断整批)
  47. Warnings []string
  48. }
  49. // ConflictItem 冲突/异常清单的单项(进 P0-08 人工复核)。
  50. type ConflictItem struct {
  51. LegacyID int32 // legacy t_user.id(或关联表主键)
  52. Detail string // 人类可读描述(phone 值 / username 值 / 孤儿 openid)
  53. }
  54. // String 渲染为可读报告文本(stdout 输出 / 留档)。
  55. func (r *MigrateReport) String() string {
  56. var b strings.Builder
  57. mode := "RUN"
  58. if r.DryRun {
  59. mode = "DRY-RUN"
  60. }
  61. b.WriteString(fmt.Sprintf("=== 数据迁移报告(%s)===\n", mode))
  62. b.WriteString(fmt.Sprintf("机构: %d 读取 / %d 新建\n", r.InstitutionsTotal, r.InstitutionsCreated))
  63. b.WriteString(fmt.Sprintf("校区: %d 读取 / %d 新建\n", r.CampusesTotal, r.CampusesCreated))
  64. b.WriteString(fmt.Sprintf("用户: %d 读取 / %d 新建 / %d 幂等跳过 / %d disabled\n",
  65. r.UsersTotal, r.UsersCreated, r.UsersSkipped, r.UsersDisabled))
  66. b.WriteString(fmt.Sprintf("身份: phone=%d email=%d username=%d wechat=%d(幂等跳过 %d)\n",
  67. r.IdentitiesPhone, r.IdentitiesEmail, r.IdentitiesUsername, r.IdentitiesWechat, r.IdentityDuplicates))
  68. b.WriteString(fmt.Sprintf("微信锚点(t_wechat_union): %d 新建\n", r.WechatUnionCreated))
  69. b.WriteString(fmt.Sprintf("成员关系(t_membership): %d 新建\n", r.MembershipsCreated))
  70. b.WriteString(fmt.Sprintf("遗留映射(t_legacy_mapping): %d 新建\n", r.LegacyMappingsCreated))
  71. if len(r.PhoneConflicts) > 0 {
  72. b.WriteString(fmt.Sprintf("\n[冲突] phone 撞号 %d 项(转 P0-08 人工):\n", len(r.PhoneConflicts)))
  73. for _, c := range sortedConflicts(r.PhoneConflicts) {
  74. b.WriteString(fmt.Sprintf(" legacy_id=%d %s\n", c.LegacyID, c.Detail))
  75. }
  76. }
  77. if len(r.UsernameConflicts) > 0 {
  78. b.WriteString(fmt.Sprintf("\n[冲突] username 归一后重复 %d 项(转 P0-08 人工):\n", len(r.UsernameConflicts)))
  79. for _, c := range sortedConflicts(r.UsernameConflicts) {
  80. b.WriteString(fmt.Sprintf(" legacy_id=%d %s\n", c.LegacyID, c.Detail))
  81. }
  82. }
  83. if len(r.PhoneInvalid) > 0 {
  84. b.WriteString(fmt.Sprintf("\n[异常] phone 归一失败 %d 项(phone_invalid,原始值留 t_legacy_mapping):\n", len(r.PhoneInvalid)))
  85. for _, c := range sortedConflicts(r.PhoneInvalid) {
  86. b.WriteString(fmt.Sprintf(" legacy_id=%d %s\n", c.LegacyID, c.Detail))
  87. }
  88. }
  89. if len(r.WechatOrphans) > 0 {
  90. b.WriteString(fmt.Sprintf("\n[丢弃] 微信孤儿 %d 项(userid 不在 t_user,openid 无法归属):\n", len(r.WechatOrphans)))
  91. for _, c := range sortedConflicts(r.WechatOrphans) {
  92. b.WriteString(fmt.Sprintf(" legacy_id=%d %s\n", c.LegacyID, c.Detail))
  93. }
  94. }
  95. if len(r.Warnings) > 0 {
  96. b.WriteString(fmt.Sprintf("\n[警告] %d 条:\n", len(r.Warnings)))
  97. for _, w := range r.Warnings {
  98. b.WriteString(" " + w + "\n")
  99. }
  100. }
  101. return b.String()
  102. }
  103. // sortedConflicts 按 LegacyID 排序(报告稳定输出,防 map 遍历序漂移)。
  104. func sortedConflicts(items []ConflictItem) []ConflictItem {
  105. out := make([]ConflictItem, len(items))
  106. copy(out, items)
  107. sort.Slice(out, func(i, j int) bool { return out[i].LegacyID < out[j].LegacyID })
  108. return out
  109. }