verify.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. package migrate
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. "gorm.io/driver/mysql"
  7. "gorm.io/gorm"
  8. identitymodel "yuansiwei/unified-backend/internal/identity/model"
  9. orgmodel "yuansiwei/unified-backend/internal/org/model"
  10. )
  11. // Verifier 目标态一致性校验器(run 后门禁,P1-04 验收"重跑幂等 + 事务边界正确")。
  12. //
  13. // 校验项:
  14. // 1. 计数对账:t_user / t_identity / t_wechat_union / t_membership / t_legacy_mapping 行数;
  15. // 2. 唯一键完整性:phone 撞号(同 identifier 多 canonical)/ unionid 重复 / username 重复 /
  16. // canonical_user_id 在 legacy_mapping 重复;
  17. // 3. 引用完整性:t_identity / t_membership 的 canonical_user_id 必须存在于 t_user。
  18. type Verifier struct {
  19. target *gorm.DB
  20. }
  21. // NewVerifier 构建校验器(连目标库)。
  22. func NewVerifier(targetDSN string) (*Verifier, error) {
  23. if targetDSN == "" {
  24. return nil, fmt.Errorf("migrate: 目标库 DSN 为空")
  25. }
  26. gdb, err := gorm.Open(mysql.Open(targetDSN), &gorm.Config{
  27. DisableForeignKeyConstraintWhenMigrating: true,
  28. })
  29. if err != nil {
  30. return nil, fmt.Errorf("migrate: 目标库连接失败: %w", err)
  31. }
  32. if sqlDB, err := gdb.DB(); err == nil {
  33. if err := sqlDB.Ping(); err != nil {
  34. return nil, fmt.Errorf("migrate: 目标库 Ping 失败: %w", err)
  35. }
  36. }
  37. return &Verifier{target: gdb}, nil
  38. }
  39. // Close 关闭连接。
  40. func (v *Verifier) Close() error {
  41. if v.target == nil {
  42. return nil
  43. }
  44. if sqlDB, err := v.target.DB(); err == nil {
  45. return sqlDB.Close()
  46. }
  47. return nil
  48. }
  49. // VerifyReport 校验报告。
  50. type VerifyReport struct {
  51. // 计数
  52. UsersCount int64
  53. IdentitiesCount int64
  54. WechatUnion int64
  55. Memberships int64
  56. LegacyMappings int64
  57. Institutions int64
  58. Campuses int64
  59. // 唯一键完整性违规(应为 0)
  60. PhoneCollisions []string // 同 identifier 多 canonical 的 phone
  61. UnionidDuplicates []string // 同 unionid 多 canonical
  62. UsernameDuplicates []string // 同 username 多 canonical
  63. CanonicalDups []string // legacy_mapping 中同 canonical 多行
  64. OrphanIdentities int64 // t_identity.canonical_user_id 不在 t_user
  65. OrphanMemberships int64 // t_membership.canonical_user_id 不在 t_user
  66. }
  67. // OK 是否全部通过(所有违规项为 0 / 空)。
  68. func (r *VerifyReport) OK() bool {
  69. return len(r.PhoneCollisions) == 0 &&
  70. len(r.UnionidDuplicates) == 0 &&
  71. len(r.UsernameDuplicates) == 0 &&
  72. len(r.CanonicalDups) == 0 &&
  73. r.OrphanIdentities == 0 &&
  74. r.OrphanMemberships == 0
  75. }
  76. // String 渲染为可读报告。
  77. func (r *VerifyReport) String() string {
  78. var b strings.Builder
  79. b.WriteString("=== 目标态一致性校验 ===\n")
  80. b.WriteString(fmt.Sprintf("t_user=%d t_identity=%d t_wechat_union=%d t_membership=%d t_legacy_mapping=%d\n",
  81. r.UsersCount, r.IdentitiesCount, r.WechatUnion, r.Memberships, r.LegacyMappings))
  82. b.WriteString(fmt.Sprintf("t_institution=%d t_campus=%d\n", r.Institutions, r.Campuses))
  83. pass := true
  84. if len(r.PhoneCollisions) > 0 {
  85. pass = false
  86. b.WriteString(fmt.Sprintf("[FAIL] phone 撞号 %d 组:\n", len(r.PhoneCollisions)))
  87. for _, p := range r.PhoneCollisions {
  88. b.WriteString(" " + p + "\n")
  89. }
  90. }
  91. if len(r.UnionidDuplicates) > 0 {
  92. pass = false
  93. b.WriteString(fmt.Sprintf("[FAIL] unionid 重复 %d 项:\n", len(r.UnionidDuplicates)))
  94. for _, u := range r.UnionidDuplicates {
  95. b.WriteString(" " + u + "\n")
  96. }
  97. }
  98. if len(r.UsernameDuplicates) > 0 {
  99. pass = false
  100. b.WriteString(fmt.Sprintf("[FAIL] username 重复 %d 项:\n", len(r.UsernameDuplicates)))
  101. for _, u := range r.UsernameDuplicates {
  102. b.WriteString(" " + u + "\n")
  103. }
  104. }
  105. if len(r.CanonicalDups) > 0 {
  106. pass = false
  107. b.WriteString(fmt.Sprintf("[FAIL] legacy_mapping canonical 重复 %d 项:\n", len(r.CanonicalDups)))
  108. for _, c := range r.CanonicalDups {
  109. b.WriteString(" " + c + "\n")
  110. }
  111. }
  112. if r.OrphanIdentities > 0 {
  113. pass = false
  114. b.WriteString(fmt.Sprintf("[FAIL] t_identity 孤儿 canonical(不在 t_user)%d 条\n", r.OrphanIdentities))
  115. }
  116. if r.OrphanMemberships > 0 {
  117. pass = false
  118. b.WriteString(fmt.Sprintf("[FAIL] t_membership 孤儿 canonical(不在 t_user)%d 条\n", r.OrphanMemberships))
  119. }
  120. if pass {
  121. b.WriteString("[PASS] 全部唯一键/引用完整性校验通过\n")
  122. }
  123. return b.String()
  124. }
  125. // Verify 执行全部校验项。
  126. func (v *Verifier) Verify(ctx context.Context) (*VerifyReport, error) {
  127. rep := &VerifyReport{}
  128. // 1. 计数
  129. var n int64
  130. _ = v.target.WithContext(ctx).Table("t_user").Count(&n).Error
  131. rep.UsersCount = n
  132. _ = v.target.WithContext(ctx).Table("t_identity").Count(&n).Error
  133. rep.IdentitiesCount = n
  134. _ = v.target.WithContext(ctx).Table("t_wechat_union").Count(&n).Error
  135. rep.WechatUnion = n
  136. _ = v.target.WithContext(ctx).Table("t_membership").Count(&n).Error
  137. rep.Memberships = n
  138. _ = v.target.WithContext(ctx).Table("t_legacy_mapping").Count(&n).Error
  139. rep.LegacyMappings = n
  140. _ = v.target.WithContext(ctx).Table("t_institution").Count(&n).Error
  141. rep.Institutions = n
  142. _ = v.target.WithContext(ctx).Table("t_campus").Count(&n).Error
  143. rep.Campuses = n
  144. // 2. phone 撞号:t_identity 中 type=phone 且同 identifier 出现 >1 个不同 canonical
  145. // (phone 唯一键不含 canonical,撞号时唯一键应阻止多行;若存在 = 唯一键失效或历史脏数据)
  146. var phoneRows []identitymodel.Identity
  147. if err := v.target.WithContext(ctx).Model(&identitymodel.Identity{}).
  148. Where("type = ?", identitymodel.IdentityTypePhone).
  149. Group("identifier").
  150. Having("COUNT(DISTINCT canonical_user_id) > 1").
  151. Find(&phoneRows).Error; err == nil {
  152. for i := range phoneRows {
  153. rep.PhoneCollisions = append(rep.PhoneCollisions, phoneRows[i].Identifier)
  154. }
  155. }
  156. // 3. unionid 重复:t_wechat_union 同 unionid 多 canonical(uk_unionid 应阻止;存在 = 脏数据)
  157. var unionRows []identitymodel.WechatUnion
  158. if err := v.target.WithContext(ctx).Model(&identitymodel.WechatUnion{}).
  159. Group("unionid").
  160. Having("COUNT(DISTINCT canonical_user_id) > 1").
  161. Find(&unionRows).Error; err == nil {
  162. for i := range unionRows {
  163. rep.UnionidDuplicates = append(rep.UnionidDuplicates, unionRows[i].UnionID)
  164. }
  165. }
  166. // 4. username 重复:t_user 同 username 多 canonical(uk_username 应阻止;存在 = 脏数据)
  167. var userRows []identitymodel.User
  168. if err := v.target.WithContext(ctx).Model(&identitymodel.User{}).
  169. Where("username IS NOT NULL AND username <> ''").
  170. Group("username").
  171. Having("COUNT(DISTINCT canonical_user_id) > 1").
  172. Find(&userRows).Error; err == nil {
  173. for i := range userRows {
  174. if userRows[i].Username != nil {
  175. rep.UsernameDuplicates = append(rep.UsernameDuplicates, *userRows[i].Username)
  176. }
  177. }
  178. }
  179. // 5. legacy_mapping canonical 重复:同 canonical_user_id 多行(uk_canonical 应阻止)
  180. var mappingRows []identitymodel.LegacyMapping
  181. if err := v.target.WithContext(ctx).Model(&identitymodel.LegacyMapping{}).
  182. Group("canonical_user_id").
  183. Having("COUNT(*) > 1").
  184. Find(&mappingRows).Error; err == nil {
  185. for i := range mappingRows {
  186. rep.CanonicalDups = append(rep.CanonicalDups, mappingRows[i].CanonicalUserID)
  187. }
  188. }
  189. // 6. 引用完整性:t_identity.canonical_user_id 不在 t_user
  190. if err := v.target.WithContext(ctx).
  191. Raw("SELECT COUNT(DISTINCT i.canonical_user_id) FROM t_identity i LEFT JOIN t_user u ON u.canonical_user_id = i.canonical_user_id WHERE u.canonical_user_id IS NULL").
  192. Scan(&rep.OrphanIdentities).Error; err != nil {
  193. return rep, fmt.Errorf("migrate: 引用完整性校验(identity)失败: %w", err)
  194. }
  195. // 7. t_membership.canonical_user_id 不在 t_user
  196. if err := v.target.WithContext(ctx).
  197. Raw("SELECT COUNT(DISTINCT m.canonical_user_id) FROM t_membership m LEFT JOIN t_user u ON u.canonical_user_id = m.canonical_user_id WHERE u.canonical_user_id IS NULL").
  198. Scan(&rep.OrphanMemberships).Error; err != nil {
  199. return rep, fmt.Errorf("migrate: 引用完整性校验(membership)失败: %w", err)
  200. }
  201. _ = orgmodel.Membership{} // 保持 orgmodel 引用(校验仅用 raw SQL,model 备用)
  202. return rep, nil
  203. }