Quellcode durchsuchen

fix(migrate): 真库 e2e 验收发现两处缺陷并修复(O(N) 逐行 COUNT + 归一漏剔不可见字符)

TestMigrateE2E 真库验收(yuansiwei_i 68107 用户 → e2e_test,首次 ~64s)
暴露两处已交付代码的真实缺陷:

1. 性能 O(N):writeUser/writeIdentities/upsertIdentity/writeWechatUnion/
   writeLegacyMapping/writeMemberships 逐行 SELECT count(*) 查重(68107 用户 ≈ 20 万+
   串行往返,10 分钟跑不完;生产割接窗口 PolarDB 上不可接受)。
   修复:事务内一次性批量装载 5 表现有唯一键到内存 set(existingUserCanons/
   existingIdentityKeys/existingUnionids/existingMappingCanons/existingMemberKeys),
   写前 O(1) 查表,插入成功后 mark-on-insert 维持"首个占用者胜出"语义;
   往返量减半,68107 用户单次迁移 ~60s。

2. 归一缺口(生产炸事务风险):legacy username 含 bidi 控制符(实测 yuansiwei_i
   t_user id=44008 = U+202D + 号码 + U+202C 包裹,全库仅此 1 条)→ norm.Username
   (lower+trim)不剔(Go TrimSpace 不把 Cf 类当空白)→ 与干净账号字节不同、绕过
   首占用去重双双写入 → MySQL utf8mb4_0900_ai_ci 视同值,verify 判 username 重复;
   生产 t_user 带 UNIQUE uk_username,第二行将撞唯一键回滚整个迁移事务。
   修复:norm 增 stripInvisible(剔 Cc/Cf/Cs:bidi 控制/零宽/软连字符等),
   Username 与 Email 均套用(二者都进 t_identity 唯一键);Phone 有 11 位硬正则
   兜底不受影响。归一后 44008 与 42020 收敛同串 → 命中首占用规则(后者 username
   置 NULL 转 P0-08),行为与 phone 撞号一致。

3. verify.go 隐性失效修复:原 4 项唯一键完整性检查用 GORM Find(&struct) 触发
   SELECT * + GROUP BY,MySQL 8 ONLY_FULL_GROUP_BY(生产 PolarDB 同款)报
   Error 1055,而 `if err == nil` 静默跳过 → 门禁形同虚设误报 PASS。
   改 raw SQL 仅 SELECT 分组列 + 出错即 return error 硬失败。

回归单测:norm 增 6 例(bidi 包裹/零宽空格/零宽非连接符+软连字符/双向嵌入/
碰撞收敛断言/email 剔不可见)。

验收:TestMigrateE2E 真库全绿——首次 run 用户 68107/机构 23/校区 62/
identity phone=39125 wechat=3406/union 3383/成员 26059/映射 68107;
verify 全过(无 phone 撞号/username 重复/unionid 重复/canonical 重复/孤儿);
重跑幂等 UsersCreated=0 / UsersSkipped=68107 / MembershipsCreated=0。
报告残留:phone 撞号 27 项 + phone_invalid 25 项 + 微信孤儿 8 项(均转 P0-08 人工,
符合设计);membership 孤儿绑定警告 210 条(legacy userid 不在 t_user,跳过)。
Q vor 2 Tagen
Ursprung
Commit
870dc89f83
4 geänderte Dateien mit 221 neuen und 81 gelöschten Zeilen
  1. 34 4
      internal/migrate/norm/norm.go
  2. 26 1
      internal/migrate/norm/norm_test.go
  3. 134 39
      internal/migrate/runner.go
  4. 27 37
      internal/migrate/verify.go

+ 34 - 4
internal/migrate/norm/norm.go

@@ -10,12 +10,13 @@
 //   - phone:强归一(trim → 去分隔符 → 剥国际前缀 → 11 位校验)——主力 key
 //   - wechat:不做字符串变换(openid 已唯一),仅 trim;必须带 appid(见调用方)
 //   - email:轻归一(trim + 小写 + 正则校验)——不作归一 key
-//   - username:轻归一(trim + 小写)——绝不作自然人归一 key
+//   - username:轻归一(trim + 剔除不可见字符 + 小写)——绝不作自然人归一 key
 package norm
 
 import (
 	"regexp"
 	"strings"
+	"unicode"
 )
 
 // 大陆手机号正则(11 位:1 + 3-9 起始号段 + 9 位)
@@ -98,17 +99,46 @@ func WechatOpenID(raw string) (canonical string, ok bool) {
 //   - canonical string:小写 email;ok=false 时为空串。
 //   - ok bool:true 表示通过最小正则校验(可写 t_identity.email)。
 func Email(raw string) (canonical string, ok bool) {
-	s := strings.ToLower(strings.TrimSpace(raw))
+	s := strings.ToLower(strings.TrimSpace(stripInvisible(raw)))
 	if !emailRe.MatchString(s) {
 		return "", false
 	}
 	return s, true
 }
 
-// Username 归一:trim + 小写(P0-06 §2.4)。
+// stripInvisible 剔除不可见 Unicode 字符:控制符 Cc、格式符 Cf(含双向文字控制符
+// U+202A–U+202E/U+2060–U+2069 等)、代理字符 Cs。
+//
+// 背景:legacy 个别 username 被 bidi 控制符包裹(实测 yuansiwei_i.t_user id=44008
+// 的 username = U+202D + "18077117616" + U+202C)。这类字符肉眼不可见、
+// 非有意设置,属数据污染;若保留会让"同账号"在字节层面不同(Go TrimSpace 不视为空白,
+// 小写归一后仍是两个不同串)从而绕过去重,而 MySQL utf8mb4_0900_ai_ci 排序规则
+// 视其为等价 → t_identity 唯一键(生产 t_user 还有 uk_username)下第二行写入将撞唯一键、
+// 回滚整个迁移事务。剔除后两个账号归一为同一串 → 命中"首个占用者"规则,
+// 第二行 username 置 NULL 并转 P0-08 人工(与 phone 撞号同款处理),迁移不炸。
+// 量级:legacy t_user 仅 1 条命中(44008),15119 条含非 ASCII 的其余为正常 CJK 姓名,
+// Cc/Cf/Cs 剔除对它们无影响。
+func stripInvisible(s string) string {
+	var b strings.Builder
+	b.Grow(len(s))
+	for _, r := range s {
+		switch {
+		case unicode.IsControl(r), // Cc 控制符
+			unicode.Is(unicode.Cf, r), // Cf 格式符(含 bidi 控制)
+			unicode.Is(unicode.Cs, r): // Cs 代理(未分配 surrogate)
+			// 跳过
+		default:
+			b.WriteRune(r)
+		}
+	}
+	return b.String()
+}
+
+// Username 归一:trim + 剔除不可见字符 + 小写(P0-06 §2.4)。
 //
 // 红线:username **绝不作自然人归一 key**——仅作登录账号落 t_identity.username。
 // 本函数无 ok 返回值(username 始终可归一,空串也合法——legacy 100% 有值)。
+// 剔除 bidi 控制符见 stripInvisible 注释(防"同账号字节不同"绕过去重撞唯一键)。
 //
 // 入参:
 //   - raw string:legacy t_user.username 原始值。
@@ -116,5 +146,5 @@ func Email(raw string) (canonical string, ok bool) {
 // 出参:
 //   - canonical string:小写 username;空串输入返回 ""。
 func Username(raw string) string {
-	return strings.ToLower(strings.TrimSpace(raw))
+	return strings.ToLower(strings.TrimSpace(stripInvisible(raw)))
 }

+ 26 - 1
internal/migrate/norm/norm_test.go

@@ -105,7 +105,7 @@ func TestEmail(t *testing.T) {
 	}
 }
 
-// TestUsername 验证 username 轻归一(trim + 小写,无 ok)。
+// TestUsername 验证 username 轻归一(trim + 剔除不可见字符 + 小写,无 ok)。
 func TestUsername(t *testing.T) {
 	cases := []struct {
 		name string
@@ -116,6 +116,13 @@ func TestUsername(t *testing.T) {
 		{"含空白", "  ZhangSan  ", "zhangsan"},
 		{"空串", "", ""},
 		{"已小写", "abc123", "abc123"},
+		// 回归(e2e 实测 yuansiwei_i.t_user id=44008):
+		// U+202D LTR-MARK + 号码 + U+202C POP DIRECTIONAL FORMATTING 包裹 →
+		// 剔除后与干净值同串,命中"首个占用者"规则而非字节不同绕过去重撞唯一键。
+		{"bidi 控制符包裹", "\u202D18077117616\u202C", "18077117616"},
+		{"零宽空格 U+200B (Cf)", "a\u200Bb", "ab"},
+		{"零宽非连接符 U+200C + 软连字符 U+00AD", "a\u200Cb\u00ADC", "abc"},
+		{"双向嵌入 U+202A 中段", "Zhang\u202ASan\u202C", "zhangsan"},
 	}
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
@@ -126,3 +133,21 @@ func TestUsername(t *testing.T) {
 		})
 	}
 }
+
+// TestUsernameCollisionAfterStrip 验证"同账号字节不同"在归一后收敛为同一串
+// (44008 脏 username 与 42020 干净 username 归一结果相等 → 迁移侧首个占用者规则生效)。
+func TestUsernameCollisionAfterStrip(t *testing.T) {
+	clean := Username("18077117616")
+	dirty := Username("\u202D18077117616\u202C")
+	if clean != dirty {
+		t.Fatalf("归一后应相等(同账号不同字节形态):clean=%q dirty=%q", clean, dirty)
+	}
+}
+
+// TestEmailInvisibleStripped 验证 email 归一同样剔除不可见字符(t_identity.email 亦走唯一键)。
+func TestEmailInvisibleStripped(t *testing.T) {
+	canon, ok := Email("user\u200Bname@example.com")
+	if !ok || canon != "username@example.com" {
+		t.Fatalf("Email 应剔除零宽空格并归一:got=(%q,%v)", canon, ok)
+	}
+}

+ 134 - 39
internal/migrate/runner.go

@@ -237,6 +237,9 @@ func (r *Runner) Run(ctx context.Context) (*MigrateReport, error) {
 			return err
 		}
 		// ③ 用户 + 身份 + 微信锚点 + 遗留映射(按 legacy id 序,幂等)
+		//
+		// 性能:一次性批量装载目标库现有唯一键到内存 map(O(1) 查表替代 O(N) 逐行 COUNT)。
+		// 插入时把新键标记为已占用,维持"首个占用者胜出"语义与重跑幂等。
 		usedUsername := map[string]int32{} // 归一 username → 首个占用者
 		usedPhone := map[string]int32{}   // 归一 phone → 首个占用者
 		existUsername := r.existingUsernames(ctx, tx)
@@ -247,23 +250,30 @@ func (r *Runner) Run(ctx context.Context) (*MigrateReport, error) {
 		for k, v := range existPhone {
 			usedPhone[k] = v
 		}
+		// 各表唯一键存量集合(批量装载,避免逐行 COUNT)
+		existUserCanons := r.existingUserCanons(ctx, tx)      // t_user.canonical_user_id
+		existIdentityKeys := r.existingIdentityKeys(ctx, tx)  // t_identity type|identifier|appid
+		existUnionids := r.existingUnionids(ctx, tx)          // t_wechat_union.unionid
+		existMappingCanons := r.existingMappingCanons(ctx, tx) // t_legacy_mapping.canonical_user_id
+		existMemberKeys := r.existingMemberKeys(ctx, tx)      // t_membership canonical|campus|role
+
 		for i := range users {
 			u := &users[i]
 			canon := canonByID[u.ID]
 			d := decisionByID[u.ID]
 
-			if err := r.writeUser(ctx, tx, u, canon, d, rep, usedUsername, existUsername); err != nil {
+			if err := r.writeUser(ctx, tx, u, canon, d, rep, usedUsername, existUserCanons); err != nil {
 				rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_user legacy_id=%d 失败: %v", u.ID, err))
 				continue
 			}
-			r.writeIdentities(ctx, tx, u, canon, d, rep, usedPhone, existPhone)
-			r.writeWechatUnion(ctx, tx, u.ID, canon, planner, rep)
-			if err := r.writeLegacyMapping(ctx, tx, u, canon, d, teachingByUserID, rep); err != nil {
+			r.writeIdentities(ctx, tx, u, canon, d, rep, usedPhone, existIdentityKeys)
+			r.writeWechatUnion(ctx, tx, u.ID, canon, existUnionids, rep)
+			if err := r.writeLegacyMapping(ctx, tx, u, canon, d, teachingByUserID, existMappingCanons, rep); err != nil {
 				rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_legacy_mapping legacy_id=%d 失败: %v", u.ID, err))
 			}
 		}
 		// ④ 成员关系(依赖 canonical + 机构/校区)
-		r.writeMemberships(ctx, tx, binds, canonByID, instByID, campusByID, users, rep)
+		r.writeMemberships(ctx, tx, binds, canonByID, instByID, campusByID, users, existMemberKeys, rep)
 		return nil
 	})
 	if err != nil {
@@ -346,6 +356,85 @@ func (r *Runner) existingIdentities(ctx context.Context, tx *gorm.DB, typ, appid
 	return m
 }
 
+// identityKey 唯一键 (type, identifier, appid) 的内存 map key(与 DB 唯一键 uk_type_identifier_appid 对齐)。
+func identityKey(typ, identifier, appid string) string {
+	return typ + "|" + identifier + "|" + appid
+}
+
+// memberKey 唯一键 (canonical_user_id, campus_id, role) 的内存 map key。
+func memberKey(canon string, campus int64, role string) string {
+	return canon + "|" + fmt.Sprint(campus) + "|" + role
+}
+
+// existingUserCanons 批量读 t_user.canonical_user_id → set(重跑幂等:已映射 ULID 复用,跳过写入)。
+func (r *Runner) existingUserCanons(ctx context.Context, tx *gorm.DB) map[string]struct{} {
+	m := make(map[string]struct{})
+	var ids []string
+	if err := tx.WithContext(ctx).Model(&identitymodel.User{}).
+		Pluck("canonical_user_id", &ids).Error; err != nil {
+		return m
+	}
+	for _, s := range ids {
+		m[s] = struct{}{}
+	}
+	return m
+}
+
+// existingIdentityKeys 批量读 t_identity 全量唯一键 (type, identifier, appid) → set。
+func (r *Runner) existingIdentityKeys(ctx context.Context, tx *gorm.DB) map[string]struct{} {
+	m := make(map[string]struct{})
+	var idents []identitymodel.Identity
+	if err := tx.WithContext(ctx).Model(&identitymodel.Identity{}).Find(&idents).Error; err != nil {
+		return m
+	}
+	for i := range idents {
+		m[identityKey(idents[i].Type, idents[i].Identifier, idents[i].AppID)] = struct{}{}
+	}
+	return m
+}
+
+// existingUnionids 批量读 t_wechat_union.unionid → set。
+func (r *Runner) existingUnionids(ctx context.Context, tx *gorm.DB) map[string]struct{} {
+	m := make(map[string]struct{})
+	var ids []string
+	if err := tx.WithContext(ctx).Model(&identitymodel.WechatUnion{}).
+		Pluck("unionid", &ids).Error; err != nil {
+		return m
+	}
+	for _, s := range ids {
+		m[s] = struct{}{}
+	}
+	return m
+}
+
+// existingMappingCanons 批量读 t_legacy_mapping.canonical_user_id → set。
+func (r *Runner) existingMappingCanons(ctx context.Context, tx *gorm.DB) map[string]struct{} {
+	m := make(map[string]struct{})
+	var ids []string
+	if err := tx.WithContext(ctx).Model(&identitymodel.LegacyMapping{}).
+		Pluck("canonical_user_id", &ids).Error; err != nil {
+		return m
+	}
+	for _, s := range ids {
+		m[s] = struct{}{}
+	}
+	return m
+}
+
+// existingMemberKeys 批量读 t_membership 唯一键 (canonical_user_id, campus_id, role) → set。
+func (r *Runner) existingMemberKeys(ctx context.Context, tx *gorm.DB) map[string]struct{} {
+	m := make(map[string]struct{})
+	var ms []orgmodel.Membership
+	if err := tx.WithContext(ctx).Model(&orgmodel.Membership{}).
+		Select("canonical_user_id", "campus_id", "role").Find(&ms).Error; err != nil {
+		return m
+	}
+	for i := range ms {
+		m[memberKey(ms[i].CanonicalUserID, ms[i].CampusID, ms[i].Role)] = struct{}{}
+	}
+	return m
+}
+
 // ---- 写入方法(每个方法在 dry-run 外执行;幂等:存在则跳过)----
 
 // writeInstitutions 写 t_institution(幂等:按 name 查现有复用)。
@@ -410,12 +499,12 @@ func (r *Runner) writeCampuses(ctx context.Context, tx *gorm.DB, campuses []Lega
 	return campusByID, nil
 }
 
-// writeUser 写 t_user(幂等:canonical_user_id 存在则跳过;username 冲突置 NULL)。
+// writeUser 写 t_user(幂等:canonical_user_id 已存在则跳过;username 冲突置 NULL)。
+//
+// 性能:幂等判断用 existUserCanons 内存 set(事务内一次性批量装载),不再逐行 COUNT。
 func (r *Runner) writeUser(ctx context.Context, tx *gorm.DB, u *LegacyUser, canon string,
-	d MatchDecision, rep *MigrateReport, usedUsername map[string]int32, existUsername map[string]int32) error {
-	var n int64
-	tx.WithContext(ctx).Model(&identitymodel.User{}).Where("canonical_user_id = ?", canon).Count(&n)
-	if n > 0 {
+	d MatchDecision, rep *MigrateReport, usedUsername map[string]int32, existUserCanons map[string]struct{}) error {
+	if _, ok := existUserCanons[canon]; ok {
 		rep.UsersSkipped++
 		return nil
 	}
@@ -434,7 +523,6 @@ func (r *Runner) writeUser(ctx context.Context, tx *gorm.DB, u *LegacyUser, cano
 			})
 		}
 	}
-	_ = existUsername // 存量 username 冲突已在 usedUsername 合并
 
 	displayName := u.RealName
 	user := &identitymodel.User{
@@ -456,6 +544,7 @@ func (r *Runner) writeUser(ctx context.Context, tx *gorm.DB, u *LegacyUser, cano
 	if err := tx.WithContext(ctx).Create(user).Error; err != nil {
 		return err
 	}
+	existUserCanons[canon] = struct{}{} // 标记已占用(本批后续重复 ULID 幂等跳过)
 	rep.UsersCreated++
 	if u.Status == 0 || u.Status == 3 {
 		rep.UsersDisabled++
@@ -464,14 +553,16 @@ func (r *Runner) writeUser(ctx context.Context, tx *gorm.DB, u *LegacyUser, cano
 }
 
 // writeIdentities 写 t_identity(phone/email/username/wechat;幂等 + 全局唯一键冲突处理)。
+//
+// 性能:幂等判断用 existIdentityKeys 内存 set(事务内一次性批量装载),不再逐行 COUNT。
 func (r *Runner) writeIdentities(ctx context.Context, tx *gorm.DB, u *LegacyUser, canon string,
-	d MatchDecision, rep *MigrateReport, usedPhone map[string]int32, existPhone map[string]int32) {
+	d MatchDecision, rep *MigrateReport, usedPhone map[string]int32, existIdentityKeys map[string]struct{}) {
 	// phone(强归一;撞号→本批首个占用,其余跳过+冲突已记)
 	if u.PhoneNumber != nil && *u.PhoneNumber != "" {
 		canonPhone, ok := norm.Phone(*u.PhoneNumber)
 		if ok && !d.PhoneConflict {
 			if _, taken := usedPhone[canonPhone]; !taken {
-				if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypePhone, canonPhone, "", rep); err == nil {
+				if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypePhone, canonPhone, "", existIdentityKeys, rep); err == nil {
 					usedPhone[canonPhone] = u.ID
 					rep.IdentitiesPhone++
 				}
@@ -482,7 +573,7 @@ func (r *Runner) writeIdentities(ctx context.Context, tx *gorm.DB, u *LegacyUser
 	if u.Email != nil && *u.Email != "" {
 		canonEmail, ok := norm.Email(*u.Email)
 		if ok {
-			if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeEmail, canonEmail, "", rep); err == nil {
+			if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeEmail, canonEmail, "", existIdentityKeys, rep); err == nil {
 				rep.IdentitiesEmail++
 			}
 		}
@@ -491,7 +582,7 @@ func (r *Runner) writeIdentities(ctx context.Context, tx *gorm.DB, u *LegacyUser
 	if u.Username != "" {
 		canonUsername := norm.Username(u.Username)
 		if canonUsername != "" {
-			if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeUsername, canonUsername, "", rep); err == nil {
+			if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeUsername, canonUsername, "", existIdentityKeys, rep); err == nil {
 				rep.IdentitiesUsername++
 			}
 		}
@@ -504,12 +595,11 @@ func (r *Runner) writeIdentities(ctx context.Context, tx *gorm.DB, u *LegacyUser
 			if w.AppID != nil {
 				appid = *w.AppID
 			}
-			if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeWechat, openid, appid, rep); err == nil {
+			if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeWechat, openid, appid, existIdentityKeys, rep); err == nil {
 				rep.IdentitiesWechat++
 			}
 		}
 	}
-	_ = existPhone
 }
 
 // wechatOf 取某 legacy user 的微信身份(runner 持有 planner 不方便,直接查 wx 缓存)。
@@ -519,11 +609,12 @@ func (r *Runner) wechatOf(userID int32) *LegacyWeixinUser {
 }
 
 // upsertIdentity 幂等写入单条 identity(唯一键 type+identifier+appid 已存在则跳过)。
-func (r *Runner) upsertIdentity(ctx context.Context, tx *gorm.DB, canon, typ, identifier, appid string, rep *MigrateReport) error {
-	var n int64
-	tx.WithContext(ctx).Model(&identitymodel.Identity{}).
-		Where("type = ? AND identifier = ? AND appid = ?", typ, identifier, appid).Count(&n)
-	if n > 0 {
+//
+// 性能:幂等判断用 existIdentityKeys 内存 set(事务内一次性批量装载),不再逐行 COUNT。
+func (r *Runner) upsertIdentity(ctx context.Context, tx *gorm.DB, canon, typ, identifier, appid string,
+	existIdentityKeys map[string]struct{}, rep *MigrateReport) error {
+	key := identityKey(typ, identifier, appid)
+	if _, ok := existIdentityKeys[key]; ok {
 		rep.IdentityDuplicates++
 		return nil
 	}
@@ -535,20 +626,24 @@ func (r *Runner) upsertIdentity(ctx context.Context, tx *gorm.DB, canon, typ, id
 		Verified:        false,
 		CreatedAt:       r.opts.Now,
 	}
-	return tx.WithContext(ctx).Create(it).Error
+	if err := tx.WithContext(ctx).Create(it).Error; err != nil {
+		return err
+	}
+	existIdentityKeys[key] = struct{}{} // 标记已占用
+	return nil
 }
 
-// writeWechatUnion 写 t_wechat_union(unionid 锚点;幂等:canonical/unionid 已存在则跳过)。
+// writeWechatUnion 写 t_wechat_union(unionid 锚点;幂等:unionid 已存在则跳过)。
+//
+// 性能:幂等判断用 existUnionids 内存 set(事务内一次性批量装载),不再逐行 COUNT。
 func (r *Runner) writeWechatUnion(ctx context.Context, tx *gorm.DB, userID int32, canon string,
-	planner *Planner, rep *MigrateReport) {
+	existUnionids map[string]struct{}, rep *MigrateReport) {
 	w := r.wxCache[userID]
 	if w == nil || w.UnionID == nil || *w.UnionID == "" {
 		return
 	}
-	// 同 unionid 可能挂多个 canonical(跨 appid 同人):已存在任一 canonical 的该 unionid → 跳过
-	var n int64
-	tx.WithContext(ctx).Model(&identitymodel.WechatUnion{}).Where("unionid = ?", *w.UnionID).Count(&n)
-	if n > 0 {
+	// 同 unionid 可能挂多个 canonical(跨 appid 同人):已存在该 unionid → 跳过
+	if _, ok := existUnionids[*w.UnionID]; ok {
 		return
 	}
 	wu := &identitymodel.WechatUnion{
@@ -558,6 +653,7 @@ func (r *Runner) writeWechatUnion(ctx context.Context, tx *gorm.DB, userID int32
 		UpdatedAt:       r.opts.Now,
 	}
 	if err := tx.WithContext(ctx).Create(wu).Error; err == nil {
+		existUnionids[*w.UnionID] = struct{}{} // 标记已占用
 		rep.WechatUnionCreated++
 	} else {
 		rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_wechat_union unionid=%s 失败: %v", *w.UnionID, err))
@@ -571,11 +667,9 @@ func (r *Runner) writeWechatUnion(ctx context.Context, tx *gorm.DB, userID int32
 //   - LegacyTeachingUserID = 阅卷/标记系统 id(t_user_account_association.marking_system_userid,仅种子用户有)
 //   - LegacyAcademicUserID = 教务 user id(本地恒 NULL,finance_bk 无 user 主表)
 func (r *Runner) writeLegacyMapping(ctx context.Context, tx *gorm.DB, u *LegacyUser, canon string,
-	d MatchDecision, teachingByUserID map[int32]int32, rep *MigrateReport) error {
-	var n int64
-	tx.WithContext(ctx).Model(&identitymodel.LegacyMapping{}).Where("canonical_user_id = ?", canon).Count(&n)
-	if n > 0 {
-		return nil // 已存在(重跑幂等)
+	d MatchDecision, teachingByUserID map[int32]int32, existMappingCanons map[string]struct{}, rep *MigrateReport) error {
+	if _, ok := existMappingCanons[canon]; ok {
+		return nil // 已存在(重跑幂等 / 本批已写)
 	}
 	legacyID := u.ID
 	role := mapRoleCode(u.Role)
@@ -599,14 +693,17 @@ func (r *Runner) writeLegacyMapping(ctx context.Context, tx *gorm.DB, u *LegacyU
 	if err := tx.WithContext(ctx).Create(lm).Error; err != nil {
 		return err
 	}
+	existMappingCanons[canon] = struct{}{} // 标记已占用
 	rep.LegacyMappingsCreated++
 	return nil
 }
 
 // writeMemberships 写 t_membership(幂等:唯一键 canonical+campus+role)。
+//
+// 性能:幂等判断用 existMemberKeys 内存 set(事务内一次性批量装载),不再逐行 COUNT。
 func (r *Runner) writeMemberships(ctx context.Context, tx *gorm.DB, binds []LegacyUserSchoolCampus,
 	canonByID map[int32]string, instByID map[int32]int64, campusByID map[int32]int64,
-	users []LegacyUser, rep *MigrateReport) {
+	users []LegacyUser, existMemberKeys map[string]struct{}, rep *MigrateReport) {
 	userByID := make(map[int32]*LegacyUser, len(users))
 	for i := range users {
 		userByID[users[i].ID] = &users[i]
@@ -631,10 +728,7 @@ func (r *Runner) writeMemberships(ctx context.Context, tx *gorm.DB, binds []Lega
 		}
 		role := mapRoleCode(u.Role)
 		// 幂等:唯一键 (canonical, campus, role)
-		var n int64
-		tx.WithContext(ctx).Model(&orgmodel.Membership{}).
-			Where("canonical_user_id = ? AND campus_id = ? AND role = ?", canon, campusID, role).Count(&n)
-		if n > 0 {
+		if _, ok := existMemberKeys[memberKey(canon, campusID, role)]; ok {
 			continue
 		}
 		m := &orgmodel.Membership{
@@ -647,6 +741,7 @@ func (r *Runner) writeMemberships(ctx context.Context, tx *gorm.DB, binds []Lega
 			CreatedAt:       r.opts.Now,
 		}
 		if err := tx.WithContext(ctx).Create(m).Error; err == nil {
+			existMemberKeys[memberKey(canon, campusID, role)] = struct{}{} // 标记已占用
 			rep.MembershipsCreated++
 		} else {
 			rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_membership legacy_userid=%d 失败: %v", b.UserID, err))

+ 27 - 37
internal/migrate/verify.go

@@ -156,53 +156,43 @@ func (v *Verifier) Verify(ctx context.Context) (*VerifyReport, 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)
-		}
+	//    (phone 唯一键不含 canonical,撞号时唯一键应阻止多行;若存在 = 唯一键失效或历史脏数据)。
+	//    ONLY_FULL_GROUP_BY 兼容:仅 SELECT 分组列(identifier),不 SELECT *。
+	var phoneCols []string
+	if err := v.target.WithContext(ctx).
+		Raw("SELECT identifier FROM t_identity WHERE type = ? GROUP BY identifier HAVING COUNT(DISTINCT canonical_user_id) > 1",
+			identitymodel.IdentityTypePhone).
+		Scan(&phoneCols).Error; err != nil {
+		return rep, fmt.Errorf("migrate: phone 撞号校验失败: %w", err)
 	}
+	rep.PhoneCollisions = phoneCols
 
 	// 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)
-		}
+	var unionCols []string
+	if err := v.target.WithContext(ctx).
+		Raw("SELECT unionid FROM t_wechat_union GROUP BY unionid HAVING COUNT(DISTINCT canonical_user_id) > 1").
+		Scan(&unionCols).Error; err != nil {
+		return rep, fmt.Errorf("migrate: unionid 重复校验失败: %w", err)
 	}
+	rep.UnionidDuplicates = unionCols
 
 	// 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)
-			}
-		}
+	var userCols []string
+	if err := v.target.WithContext(ctx).
+		Raw("SELECT username FROM t_user WHERE username IS NOT NULL AND username <> '' GROUP BY username HAVING COUNT(DISTINCT canonical_user_id) > 1").
+		Scan(&userCols).Error; err != nil {
+		return rep, fmt.Errorf("migrate: username 重复校验失败: %w", err)
 	}
+	rep.UsernameDuplicates = userCols
 
 	// 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)
-		}
+	var mappingCols []string
+	if err := v.target.WithContext(ctx).
+		Raw("SELECT canonical_user_id FROM t_legacy_mapping GROUP BY canonical_user_id HAVING COUNT(*) > 1").
+		Scan(&mappingCols).Error; err != nil {
+		return rep, fmt.Errorf("migrate: legacy_mapping canonical 重复校验失败: %w", err)
 	}
+	rep.CanonicalDups = mappingCols
 
 	// 6. 引用完整性:t_identity.canonical_user_id 不在 t_user
 	if err := v.target.WithContext(ctx).