| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221 |
- package migrate
- import (
- "context"
- "fmt"
- "strings"
- "gorm.io/driver/mysql"
- "gorm.io/gorm"
- identitymodel "yuansiwei/unified-backend/internal/identity/model"
- orgmodel "yuansiwei/unified-backend/internal/org/model"
- )
- // Verifier 目标态一致性校验器(run 后门禁,P1-04 验收"重跑幂等 + 事务边界正确")。
- //
- // 校验项:
- // 1. 计数对账:t_user / t_identity / t_wechat_union / t_membership / t_legacy_mapping 行数;
- // 2. 唯一键完整性:phone 撞号(同 identifier 多 canonical)/ unionid 重复 / username 重复 /
- // canonical_user_id 在 legacy_mapping 重复;
- // 3. 引用完整性:t_identity / t_membership 的 canonical_user_id 必须存在于 t_user。
- type Verifier struct {
- target *gorm.DB
- }
- // NewVerifier 构建校验器(连目标库)。
- func NewVerifier(targetDSN string) (*Verifier, error) {
- if targetDSN == "" {
- return nil, fmt.Errorf("migrate: 目标库 DSN 为空")
- }
- gdb, err := gorm.Open(mysql.Open(targetDSN), &gorm.Config{
- DisableForeignKeyConstraintWhenMigrating: true,
- })
- if err != nil {
- return nil, fmt.Errorf("migrate: 目标库连接失败: %w", err)
- }
- if sqlDB, err := gdb.DB(); err == nil {
- if err := sqlDB.Ping(); err != nil {
- return nil, fmt.Errorf("migrate: 目标库 Ping 失败: %w", err)
- }
- }
- return &Verifier{target: gdb}, nil
- }
- // Close 关闭连接。
- func (v *Verifier) Close() error {
- if v.target == nil {
- return nil
- }
- if sqlDB, err := v.target.DB(); err == nil {
- return sqlDB.Close()
- }
- return nil
- }
- // VerifyReport 校验报告。
- type VerifyReport struct {
- // 计数
- UsersCount int64
- IdentitiesCount int64
- WechatUnion int64
- Memberships int64
- LegacyMappings int64
- Institutions int64
- Campuses int64
- // 唯一键完整性违规(应为 0)
- PhoneCollisions []string // 同 identifier 多 canonical 的 phone
- UnionidDuplicates []string // 同 unionid 多 canonical
- UsernameDuplicates []string // 同 username 多 canonical
- CanonicalDups []string // legacy_mapping 中同 canonical 多行
- OrphanIdentities int64 // t_identity.canonical_user_id 不在 t_user
- OrphanMemberships int64 // t_membership.canonical_user_id 不在 t_user
- }
- // OK 是否全部通过(所有违规项为 0 / 空)。
- func (r *VerifyReport) OK() bool {
- return len(r.PhoneCollisions) == 0 &&
- len(r.UnionidDuplicates) == 0 &&
- len(r.UsernameDuplicates) == 0 &&
- len(r.CanonicalDups) == 0 &&
- r.OrphanIdentities == 0 &&
- r.OrphanMemberships == 0
- }
- // String 渲染为可读报告。
- func (r *VerifyReport) String() string {
- var b strings.Builder
- b.WriteString("=== 目标态一致性校验 ===\n")
- b.WriteString(fmt.Sprintf("t_user=%d t_identity=%d t_wechat_union=%d t_membership=%d t_legacy_mapping=%d\n",
- r.UsersCount, r.IdentitiesCount, r.WechatUnion, r.Memberships, r.LegacyMappings))
- b.WriteString(fmt.Sprintf("t_institution=%d t_campus=%d\n", r.Institutions, r.Campuses))
- pass := true
- if len(r.PhoneCollisions) > 0 {
- pass = false
- b.WriteString(fmt.Sprintf("[FAIL] phone 撞号 %d 组:\n", len(r.PhoneCollisions)))
- for _, p := range r.PhoneCollisions {
- b.WriteString(" " + p + "\n")
- }
- }
- if len(r.UnionidDuplicates) > 0 {
- pass = false
- b.WriteString(fmt.Sprintf("[FAIL] unionid 重复 %d 项:\n", len(r.UnionidDuplicates)))
- for _, u := range r.UnionidDuplicates {
- b.WriteString(" " + u + "\n")
- }
- }
- if len(r.UsernameDuplicates) > 0 {
- pass = false
- b.WriteString(fmt.Sprintf("[FAIL] username 重复 %d 项:\n", len(r.UsernameDuplicates)))
- for _, u := range r.UsernameDuplicates {
- b.WriteString(" " + u + "\n")
- }
- }
- if len(r.CanonicalDups) > 0 {
- pass = false
- b.WriteString(fmt.Sprintf("[FAIL] legacy_mapping canonical 重复 %d 项:\n", len(r.CanonicalDups)))
- for _, c := range r.CanonicalDups {
- b.WriteString(" " + c + "\n")
- }
- }
- if r.OrphanIdentities > 0 {
- pass = false
- b.WriteString(fmt.Sprintf("[FAIL] t_identity 孤儿 canonical(不在 t_user)%d 条\n", r.OrphanIdentities))
- }
- if r.OrphanMemberships > 0 {
- pass = false
- b.WriteString(fmt.Sprintf("[FAIL] t_membership 孤儿 canonical(不在 t_user)%d 条\n", r.OrphanMemberships))
- }
- if pass {
- b.WriteString("[PASS] 全部唯一键/引用完整性校验通过\n")
- }
- return b.String()
- }
- // Verify 执行全部校验项。
- func (v *Verifier) Verify(ctx context.Context) (*VerifyReport, error) {
- rep := &VerifyReport{}
- // 1. 计数
- var n int64
- _ = v.target.WithContext(ctx).Table("t_user").Count(&n).Error
- rep.UsersCount = n
- _ = v.target.WithContext(ctx).Table("t_identity").Count(&n).Error
- rep.IdentitiesCount = n
- _ = v.target.WithContext(ctx).Table("t_wechat_union").Count(&n).Error
- rep.WechatUnion = n
- _ = v.target.WithContext(ctx).Table("t_membership").Count(&n).Error
- rep.Memberships = n
- _ = v.target.WithContext(ctx).Table("t_legacy_mapping").Count(&n).Error
- rep.LegacyMappings = n
- _ = v.target.WithContext(ctx).Table("t_institution").Count(&n).Error
- rep.Institutions = n
- _ = v.target.WithContext(ctx).Table("t_campus").Count(&n).Error
- rep.Campuses = n
- // 2. phone 撞号:t_identity 中 type=phone 且同 identifier 出现 >1 个不同 canonical
- // (phone 唯一键不含 canonical,撞号时唯一键应阻止多行;若存在 = 唯一键失效或历史脏数据)
- var phoneRows []identitymodel.Identity
- if err := v.target.WithContext(ctx).Model(&identitymodel.Identity{}).
- Where("type = ?", identitymodel.IdentityTypePhone).
- Group("identifier").
- Having("COUNT(DISTINCT canonical_user_id) > 1").
- Find(&phoneRows).Error; err == nil {
- for i := range phoneRows {
- rep.PhoneCollisions = append(rep.PhoneCollisions, phoneRows[i].Identifier)
- }
- }
- // 3. unionid 重复:t_wechat_union 同 unionid 多 canonical(uk_unionid 应阻止;存在 = 脏数据)
- var unionRows []identitymodel.WechatUnion
- if err := v.target.WithContext(ctx).Model(&identitymodel.WechatUnion{}).
- Group("unionid").
- Having("COUNT(DISTINCT canonical_user_id) > 1").
- Find(&unionRows).Error; err == nil {
- for i := range unionRows {
- rep.UnionidDuplicates = append(rep.UnionidDuplicates, unionRows[i].UnionID)
- }
- }
- // 4. username 重复:t_user 同 username 多 canonical(uk_username 应阻止;存在 = 脏数据)
- var userRows []identitymodel.User
- if err := v.target.WithContext(ctx).Model(&identitymodel.User{}).
- Where("username IS NOT NULL AND username <> ''").
- Group("username").
- Having("COUNT(DISTINCT canonical_user_id) > 1").
- Find(&userRows).Error; err == nil {
- for i := range userRows {
- if userRows[i].Username != nil {
- rep.UsernameDuplicates = append(rep.UsernameDuplicates, *userRows[i].Username)
- }
- }
- }
- // 5. legacy_mapping canonical 重复:同 canonical_user_id 多行(uk_canonical 应阻止)
- var mappingRows []identitymodel.LegacyMapping
- if err := v.target.WithContext(ctx).Model(&identitymodel.LegacyMapping{}).
- Group("canonical_user_id").
- Having("COUNT(*) > 1").
- Find(&mappingRows).Error; err == nil {
- for i := range mappingRows {
- rep.CanonicalDups = append(rep.CanonicalDups, mappingRows[i].CanonicalUserID)
- }
- }
- // 6. 引用完整性:t_identity.canonical_user_id 不在 t_user
- if err := v.target.WithContext(ctx).
- 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").
- Scan(&rep.OrphanIdentities).Error; err != nil {
- return rep, fmt.Errorf("migrate: 引用完整性校验(identity)失败: %w", err)
- }
- // 7. t_membership.canonical_user_id 不在 t_user
- if err := v.target.WithContext(ctx).
- 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").
- Scan(&rep.OrphanMemberships).Error; err != nil {
- return rep, fmt.Errorf("migrate: 引用完整性校验(membership)失败: %w", err)
- }
- _ = orgmodel.Membership{} // 保持 orgmodel 引用(校验仅用 raw SQL,model 备用)
- return rep, nil
- }
|