Jelajahi Sumber

feat(migrate): P1-04/T5 数据迁移工具(幂等 upsert + 事务 + 置信分级)

- internal/migrate/norm: 标识归一纯函数(Phone 强归一 11 位/WechatOpenID 仅 trim/Email 小写/Username 小写)+ 单测
- internal/migrate: 迁移引擎
  - source.go: legacy 双库只读数据源(yuansiwei_i 教学 + finance_bk 教务,7 表读取)
  - match.go: 置信分级匹配引擎(seed 100/phone 90/unionid 70/unmapped 50/conflict 0 + 撞号检测)
  - runner.go: 单事务编排(机构→校区→用户/身份/锚点/映射→成员;ULID 复用幂等 + phone/username 全局唯一键冲突处理)
  - verify.go: 目标态一致性校验(计数对账 + 唯一键完整性 + 引用完整性)
  - report.go: 迁移报告(含 P0-08 冲突/异常清单)
  - migrate_e2e_test.go: 真库端到端(首次 run + verify 唯一键 + 重跑幂等断言,DSN 走 UNIFIED_MIGRATE_*_DSN 环境变量,缺失即 skip)
- cmd/migrate: CLI(dry-run/run/verify,敏感 DSN 走环境变量不入库)

门禁:go build/vet 0 错误,单测全绿(norm 12 + match 3 组断言),e2e 无 DSN 时正确 skip
P1-04 验收:重跑幂等(ULID 复用 + 唯一键去重)+ 事务边界(单事务提交/回滚)
Q 2 hari lalu
induk
melakukan
c754c2161f

+ 206 - 0
cmd/migrate/main.go

@@ -0,0 +1,206 @@
+// 命令 migrate:legacy 三源 → 目标态 5 表的数据迁移工具(P1-04/T5)。
+//
+// 设计(P0-06/P0-07 拍板规则落地):
+//   - 双 DSN 只读源(教学 yuansiwei_i + 教务 finance_bk)+ 单 DSN 目标(unified 库)
+//   - 幂等 upsert + 单事务(重跑不产生重复行)
+//   - 置信分级:高(种子/phone 唯一)自动写;冲突(撞号)→ 仅出报告,转 P0-08 人工
+//   - dry-run 只读不写,先验证量级/冲突再 run
+//
+// 子命令:
+//
+//	migrate dry-run   读库 + 归一 + 置信分级 + 产出报告(不写库)
+//	migrate run       执行迁移(EnsureTables + 单事务写入 5 表 + 报告)
+//	migrate verify    校验目标态一致性(计数对账 + 唯一键完整性 + 抽样回查)
+//
+// 敏感 DSN 一律走环境变量(不入库,架构规格 §3.4):
+//
+//	UNIFIED_MIGRATE_TEACHING_DSN  教学库(yuansiwei_i)只读 DSN
+//	UNIFIED_MIGRATE_FINANCE_DSN  教务库(finance_bk)只读 DSN
+//	UNIFIED_MIGRATE_TARGET_DSN   目标库(unified)读写 DSN
+//
+// 启动方式(dev):
+//
+//	export UNIFIED_MIGRATE_TEACHING_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Local'
+//	export UNIFIED_MIGRATE_FINANCE_DSN='dev:***@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Local'
+//	export UNIFIED_MIGRATE_TARGET_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=True&loc=Local'
+//	go run ./cmd/migrate dry-run
+//	go run ./cmd/migrate run
+//	go run ./cmd/migrate verify
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"os"
+	"time"
+
+	"yuansiwei/unified-backend/internal/migrate"
+)
+
+const (
+	envTeaching = "UNIFIED_MIGRATE_TEACHING_DSN"
+	envFinance  = "UNIFIED_MIGRATE_FINANCE_DSN"
+	envTarget   = "UNIFIED_MIGRATE_TARGET_DSN"
+)
+
+func main() {
+	if len(os.Args) < 2 {
+		usage()
+		os.Exit(2)
+	}
+	cmd := os.Args[1]
+
+	fs := flag.NewFlagSet(cmd, flag.ExitOnError)
+	now := fs.String("now", "", "时间锚点(RFC3339;默认当前时刻;测试/回放可指定)")
+	_ = fs
+	_ = now
+	// 解析后续参数(如 -now)
+	if err := fs.Parse(os.Args[2:]); err != nil {
+		fmt.Fprintf(os.Stderr, "参数解析失败: %v\n", err)
+		os.Exit(2)
+	}
+
+	teaching := os.Getenv(envTeaching)
+	finance := os.Getenv(envFinance)
+	target := os.Getenv(envTarget)
+
+	switch cmd {
+	case "dry-run":
+		runMigrate(teaching, finance, target, true, *now)
+	case "run":
+		runMigrate(teaching, finance, target, false, *now)
+	case "verify":
+		runVerify(target)
+	case "help", "-h", "--help":
+		usage()
+	default:
+		fmt.Fprintf(os.Stderr, "未知子命令 %q\n\n", cmd)
+		usage()
+		os.Exit(2)
+	}
+}
+
+// usage 打印用法。
+func usage() {
+	fmt.Fprintf(os.Stderr, `数据迁移工具(P1-04/T5)
+
+用法:
+  go run ./cmd/migrate <command>
+
+子命令:
+  dry-run   读库 + 归一 + 置信分级 + 产出报告(不写库,先验证量级/冲突)
+  run       执行迁移(EnsureTables + 单事务写 5 表 + 报告)
+  verify    校验目标态一致性(计数对账 + 唯一键完整性)
+
+必需环境变量(敏感 DSN,不入库):
+  %s   教学库 yuansiwei_i 只读 DSN
+  %s   教务库 finance_bk 只读 DSN
+  %s   目标库 unified 读写 DSN
+
+示例:
+  go run ./cmd/migrate dry-run
+  go run ./cmd/migrate run
+  go run ./cmd/migrate verify
+`, envTeaching, envFinance, envTarget)
+}
+
+// parseTime 解析 RFC3339 时间字符串(-now 参数)。
+func parseTime(s string) (time.Time, error) {
+	return time.Parse(time.RFC3339, s)
+}
+
+// runMigrate 执行 dry-run 或 run。
+func runMigrate(teaching, finance, target string, dryRun bool, nowStr string) {
+	if teaching == "" || finance == "" || target == "" {
+		missing := []string{}
+		if teaching == "" {
+			missing = append(missing, envTeaching)
+		}
+		if finance == "" {
+			missing = append(missing, envFinance)
+		}
+		if target == "" {
+			missing = append(missing, envTarget)
+		}
+		fmt.Fprintf(os.Stderr, "缺少必需环境变量: %v\n", missing)
+		os.Exit(1)
+	}
+
+	opts := migrate.Options{
+		TeachingDSN: teaching,
+		FinanceDSN:  finance,
+		TargetDSN:   target,
+		DryRun:      dryRun,
+	}
+	if nowStr != "" {
+		t, err := parseTime(nowStr)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "-now 参数非法(须 RFC3339): %v\n", err)
+			os.Exit(1)
+		}
+		opts.Now = t
+	}
+
+	runner, err := migrate.NewRunner(opts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "装配迁移编排器失败: %v\n", err)
+		os.Exit(1)
+	}
+	defer func() { _ = runner.Close() }()
+
+	ctx := context.Background()
+
+	// run 模式先确保目标表存在(dev 期 AutoMigrate;生产以 DDL 为准)
+	if !dryRun {
+		if err := runner.EnsureTables(ctx); err != nil {
+			fmt.Fprintf(os.Stderr, "EnsureTables 失败: %v\n", err)
+			os.Exit(1)
+		}
+	}
+
+	mode := "run"
+	if dryRun {
+		mode = "dry-run"
+	}
+	fmt.Fprintf(os.Stderr, "=== 开始 %s(教学库 + 教务库 → 目标库)===\n", mode)
+
+	rep, err := runner.Run(ctx)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "迁移执行失败: %v\n", err)
+		os.Exit(1)
+	}
+	// 报告输出到 stdout(可重定向留档)
+	fmt.Print(rep.String())
+
+	// 有冲突/异常时退出码 0(迁移本身成功,冲突是预期产出,转 P0-08 人工)
+	// 但若 Warnings 非空,提示用户关注
+	if len(rep.Warnings) > 0 {
+		fmt.Fprintf(os.Stderr, "\n[提示] %d 条警告,详见报告\n", len(rep.Warnings))
+	}
+}
+
+// runVerify 校验目标态一致性(run 后的门禁)。
+func runVerify(target string) {
+	if target == "" {
+		fmt.Fprintf(os.Stderr, "缺少必需环境变量: %s\n", envTarget)
+		os.Exit(1)
+	}
+	v, err := migrate.NewVerifier(target)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "装配校验器失败: %v\n", err)
+		os.Exit(1)
+	}
+	defer func() { _ = v.Close() }()
+
+	report, err := v.Verify(context.Background())
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "校验失败: %v\n", err)
+		os.Exit(1)
+	}
+	fmt.Print(report.String())
+	if !report.OK() {
+		fmt.Fprintln(os.Stderr, "\n[FAIL] 校验未通过,须处理上述问题后重跑 verify")
+		os.Exit(1)
+	}
+}

+ 190 - 0
internal/migrate/match.go

@@ -0,0 +1,190 @@
+package migrate
+
+import (
+	"yuansiwei/unified-backend/internal/migrate/norm"
+)
+
+// 置信度分级常量(match_confidence TINYINT 0-100,P0-07 §3 置信分级表落地)。
+// 单边 I 库迁移场景下的取值(mark 侧生产 SQL 验证后可能上调):
+//   - seed(现映射 t_user_account_association 命中)→ 最高,直接信任
+//   - phone(归一 phone 且该 phone 在 I 库内唯一)→ 高
+//   - unionid(有微信 unionid 锚点)→ 中
+//   - unmapped(无现映射、无可用 phone/unionid)→ 待生产交叉匹配
+//   - conflict(phone 撞号等)→ 0,不自动合并,转 P0-08 人工
+const (
+	ConfidenceSeed     int8 = 100
+	ConfidencePhone    int8 = 90
+	ConfidenceUnionid  int8 = 70
+	ConfidenceUnmapped int8 = 50
+	ConfidenceConflict int8 = 0
+)
+
+// 匹配来源(match_source,P0-07 ADR-003 pipeline 阶段标识)。
+const (
+	MatchSourceSeed     = "seed"     // 现映射种子命中
+	MatchSourcePhone    = "phone"    // phone 唯一匹配
+	MatchSourceUnionid  = "unionid"  // 微信 unionid 锚点
+	MatchSourceUnmapped = "unmapped" // 暂无匹配依据(单边迁移,待生产验证)
+	MatchSourceConflict = "conflict" // 冲突(撞号等),不自动合并
+)
+
+// MatchDecision 单个 legacy 用户的匹配决策(纯逻辑产物,由 Planner 产出)。
+type MatchDecision struct {
+	MatchSource   string // seed/phone/unionid/unmapped/conflict
+	Confidence    int8   // 0-100
+	PhoneConflict bool   // 该用户的归一 phone 在 I 库内对应 >1 个 legacy 账号(撞号)
+	PhoneInvalid  bool   // phone 归一失败(phone_invalid,转 P0-08 人工,不写 t_identity.phone)
+	HasWechat     bool   // 有微信身份(openid/unionid)
+}
+
+// Planner 匹配引擎:预构建索引,对每个 legacy 用户做置信分级 + 冲突检测。
+//
+// 纯逻辑(无 DB 写),可独立单测。索引在 NewPlanner 时一次性构建,
+// Decide 为 O(1) 查表。
+type Planner struct {
+	// phoneIndex:归一 phone → 持有该 phone 的 legacy user id 列表(撞号检测)。
+	phoneIndex map[string][]int32
+	// seedSet:有现映射(t_user_account_association)的 legacy user id。
+	seedSet map[int32]struct{}
+	// wxByUserID:legacy user id → 微信身份(一个 user 至多一条 t_weixin_user)。
+	wxByUserID map[int32]*LegacyWeixinUser
+}
+
+// NewPlanner 构建匹配引擎。
+//
+// 入参:
+//   - users:t_user 全量(用于构建 phone 索引)。
+//   - assoc:t_user_account_association 全量(现映射种子,userid 列)。
+//   - wx:t_weixin_user 全量(用于构建 userid → 微信 索引)。
+//
+// 出参:*Planner(索引构建完成,可调用 Decide)。
+func NewPlanner(users []LegacyUser, assoc []LegacyAccountAssociation, wx []LegacyWeixinUser) *Planner {
+	p := &Planner{
+		phoneIndex: make(map[string][]int32),
+		seedSet:    make(map[int32]struct{}),
+		wxByUserID: make(map[int32]*LegacyWeixinUser),
+	}
+	// phone 索引:仅对归一成功的 phone 建索引(phone_invalid 不参与撞号检测)
+	for i := range users {
+		u := &users[i]
+		if u.PhoneNumber == nil || *u.PhoneNumber == "" {
+			continue
+		}
+		canon, ok := norm.Phone(*u.PhoneNumber)
+		if !ok {
+			continue
+		}
+		p.phoneIndex[canon] = append(p.phoneIndex[canon], u.ID)
+	}
+	// 现映射种子
+	for i := range assoc {
+		if assoc[i].UserID > 0 {
+			p.seedSet[assoc[i].UserID] = struct{}{}
+		}
+	}
+	// 微信索引(openid 为 PK,userid 关联)
+	for i := range wx {
+		w := &wx[i]
+		if w.UserID > 0 {
+			// 同 userid 多条时取首条(t_weixin_user 实测 1:1,无重复)
+			if _, exists := p.wxByUserID[w.UserID]; !exists {
+				p.wxByUserID[w.UserID] = w
+			}
+		}
+	}
+	return p
+}
+
+// Decide 对单个 legacy 用户做置信分级 + 冲突检测(P0-07 §2.2 匹配优先级)。
+//
+// 优先级(高→低):
+//  1. 现映射种子命中 → seed / 100
+//  2. phone 归一成功且 I 库内唯一 → phone / 90
+//  3. phone 撞号(同 phone >1 账号)→ conflict / 0(不自动合并,转 P0-08)
+//  4. 有微信 unionid → unionid / 70
+//  5. 其余 → unmapped / 50(单边迁移,待生产交叉匹配)
+//
+// 入参:
+//   - userID int32:legacy t_user.id。
+//   - phoneRaw *string:该用户的原始手机号(可为 nil)。
+//
+// 出参:MatchDecision(置信分级 + 冲突标记,供 runner 驱动写入)。
+func (p *Planner) Decide(userID int32, phoneRaw *string) MatchDecision {
+	// ① 现映射种子(最高优先,直接信任)
+	if _, ok := p.seedSet[userID]; ok {
+		return p.withPhoneFlags(userID, phoneRaw, MatchSourceSeed, ConfidenceSeed)
+	}
+	// phone 归一 + 撞号检测
+	var canonPhone string
+	phoneValid := false
+	phoneConflict := false
+	if phoneRaw != nil && *phoneRaw != "" {
+		canon, ok := norm.Phone(*phoneRaw)
+		if ok {
+			phoneValid = true
+			canonPhone = canon
+			if n := len(p.phoneIndex[canonPhone]); n > 1 {
+				phoneConflict = true
+			}
+		}
+	}
+	// ② phone 撞号 → 冲突(不自动合并,转 P0-08 人工定夺)
+	if phoneConflict {
+		return MatchDecision{
+			MatchSource:   MatchSourceConflict,
+			Confidence:    ConfidenceConflict,
+			PhoneConflict: true,
+			PhoneInvalid:  false,
+			HasWechat:     p.hasWechat(userID),
+		}
+	}
+	// ③ phone 唯一匹配 → 高置信
+	if phoneValid {
+		return p.withPhoneFlags(userID, phoneRaw, MatchSourcePhone, ConfidencePhone)
+	}
+	// ④ 微信 unionid 锚点 → 中置信
+	if w := p.wxByUserID[userID]; w != nil && w.UnionID != nil && *w.UnionID != "" {
+		return MatchDecision{
+			MatchSource: MatchSourceUnionid,
+			Confidence:  ConfidenceUnionid,
+			PhoneInvalid: !phoneValid && phoneRaw != nil && *phoneRaw != "",
+			HasWechat:   true,
+		}
+	}
+	// ⑤ 无匹配依据 → 单边迁移待生产验证
+	return p.withPhoneFlags(userID, phoneRaw, MatchSourceUnmapped, ConfidenceUnmapped)
+}
+
+// withPhoneFlags 构造带 phone 标志的决策(用于 seed/phone/unmapped 分支)。
+func (p *Planner) withPhoneFlags(userID int32, phoneRaw *string, source string, conf int8) MatchDecision {
+	var phoneInvalid bool
+	if phoneRaw != nil && *phoneRaw != "" {
+		if _, ok := norm.Phone(*phoneRaw); !ok {
+			phoneInvalid = true
+		}
+	}
+	return MatchDecision{
+		MatchSource: source,
+		Confidence:  conf,
+		PhoneInvalid: phoneInvalid,
+		HasWechat:   p.hasWechat(userID),
+	}
+}
+
+// hasWechat 判断 legacy 用户是否有微信身份。
+func (p *Planner) hasWechat(userID int32) bool {
+	return p.wxByUserID[userID] != nil
+}
+
+// PhoneConflictGroups 返回所有撞号组(归一 phone → 多账号),供 P0-08 人工复核清单。
+//
+// 出参:map[归一phone][]legacyUserID(仅保留 >1 账号的组)。
+func (p *Planner) PhoneConflictGroups() map[string][]int32 {
+	groups := make(map[string][]int32)
+	for phone, ids := range p.phoneIndex {
+		if len(ids) > 1 {
+			groups[phone] = ids
+		}
+	}
+	return groups
+}

+ 116 - 0
internal/migrate/match_test.go

@@ -0,0 +1,116 @@
+package migrate
+
+import "testing"
+
+// TestPlannerDecide 验证置信分级 + 冲突检测(P0-07 §2.2 匹配优先级)。
+func TestPlannerDecide(t *testing.T) {
+	// 构造测试数据:
+	// - user 1: phone 13800000001(唯一)+ 现映射种子 → 应判 seed/100
+	// - user 2: phone 13800000002(唯一,无种子)→ 应判 phone/90
+	// - user 3: phone 13900000003(撞号,user 4 同号)→ 应判 conflict/0
+	// - user 4: phone 13900000003(撞号)→ 应判 conflict/0
+	// - user 5: 无 phone,有微信 unionid → 应判 unionid/70
+	// - user 6: 无 phone、无微信 → 应判 unmapped/50
+	// - user 7: phone 归一失败(非大陆号)→ 应判 unmapped/50 + PhoneInvalid
+	users := []LegacyUser{
+		{ID: 1, PhoneNumber: ptr("13800000001")},
+		{ID: 2, PhoneNumber: ptr("13800000002")},
+		{ID: 3, PhoneNumber: ptr("13900000003")},
+		{ID: 4, PhoneNumber: ptr("13900000003")},
+		{ID: 5},
+		{ID: 6},
+		{ID: 7, PhoneNumber: ptr("12345")},
+	}
+	assoc := []LegacyAccountAssociation{
+		{UserID: 1, MarkingSystemUserID: ptr[int32](999)}, // user 1 有现映射
+	}
+	wx := []LegacyWeixinUser{
+		{OpenID: "openid5", UserID: 5, UnionID: ptr("union5"), AppID: ptr("appid5")},
+	}
+	p := NewPlanner(users, assoc, wx)
+
+	t.Run("seed 优先", func(t *testing.T) {
+		d := p.Decide(1, users[0].PhoneNumber)
+		if d.MatchSource != MatchSourceSeed || d.Confidence != ConfidenceSeed {
+			t.Fatalf("user1 期望 seed/100, 得 %s/%d", d.MatchSource, d.Confidence)
+		}
+	})
+	t.Run("phone 唯一", func(t *testing.T) {
+		d := p.Decide(2, users[1].PhoneNumber)
+		if d.MatchSource != MatchSourcePhone || d.Confidence != ConfidencePhone {
+			t.Fatalf("user2 期望 phone/90, 得 %s/%d", d.MatchSource, d.Confidence)
+		}
+	})
+	t.Run("phone 撞号冲突", func(t *testing.T) {
+		for _, id := range []int32{3, 4} {
+			raw := users[id-1].PhoneNumber
+			d := p.Decide(id, raw)
+			if d.MatchSource != MatchSourceConflict || !d.PhoneConflict || d.Confidence != ConfidenceConflict {
+				t.Fatalf("user%d 期望 conflict/0+PhoneConflict, 得 %s/%d conflict=%v",
+					id, d.MatchSource, d.Confidence, d.PhoneConflict)
+			}
+		}
+	})
+	t.Run("unionid 锚点", func(t *testing.T) {
+		d := p.Decide(5, nil)
+		if d.MatchSource != MatchSourceUnionid || d.Confidence != ConfidenceUnionid || !d.HasWechat {
+			t.Fatalf("user5 期望 unionid/70+HasWechat, 得 %s/%d wechat=%v", d.MatchSource, d.Confidence, d.HasWechat)
+		}
+	})
+	t.Run("unmapped", func(t *testing.T) {
+		d := p.Decide(6, nil)
+		if d.MatchSource != MatchSourceUnmapped || d.Confidence != ConfidenceUnmapped {
+			t.Fatalf("user6 期望 unmapped/50, 得 %s/%d", d.MatchSource, d.Confidence)
+		}
+	})
+	t.Run("phone 归一失败", func(t *testing.T) {
+		d := p.Decide(7, users[6].PhoneNumber)
+		if !d.PhoneInvalid {
+			t.Fatalf("user7 期望 PhoneInvalid=true, 得 false")
+		}
+	})
+
+	// 撞号组:仅 13900000003 一组(user 3,4)
+	groups := p.PhoneConflictGroups()
+	if len(groups) != 1 {
+		t.Fatalf("期望 1 组撞号, 得 %d 组", len(groups))
+	}
+	if g, ok := groups["13900000003"]; !ok || len(g) != 2 {
+		t.Fatalf("撞号组 13900000003 应含 2 账号, 得 %v", g)
+	}
+}
+
+// TestPlannerSeedPriorityOverPhone 验证现映射种子优先级高于 phone(即使 phone 也撞号)。
+func TestPlannerSeedPriorityOverPhone(t *testing.T) {
+	users := []LegacyUser{
+		{ID: 10, PhoneNumber: ptr("13700000000")},
+		{ID: 11, PhoneNumber: ptr("13700000000")}, // 撞号
+	}
+	assoc := []LegacyAccountAssociation{{UserID: 10, MarkingSystemUserID: ptr[int32](1)}} // user 10 有种子
+	p := NewPlanner(users, assoc, nil)
+	d := p.Decide(10, users[0].PhoneNumber)
+	// 即使 phone 撞号,user 10 有现映射种子 → seed/100(优先级最高)
+	if d.MatchSource != MatchSourceSeed || d.Confidence != ConfidenceSeed {
+		t.Fatalf("user10 期望 seed/100(种子优先), 得 %s/%d", d.MatchSource, d.Confidence)
+	}
+	// user 11 无种子 → conflict
+	d2 := p.Decide(11, users[1].PhoneNumber)
+	if d2.MatchSource != MatchSourceConflict {
+		t.Fatalf("user11 期望 conflict, 得 %s", d2.MatchSource)
+	}
+}
+
+// TestPlannerEmptyPhoneSkipsIndex 验证空/无效 phone 不进撞号索引。
+func TestPlannerEmptyPhoneSkipsIndex(t *testing.T) {
+	users := []LegacyUser{
+		{ID: 1, PhoneNumber: nil},
+		{ID: 2, PhoneNumber: ptr("")},
+		{ID: 3, PhoneNumber: ptr("   ")},
+	}
+	p := NewPlanner(users, nil, nil)
+	if len(p.phoneIndex) != 0 {
+		t.Fatalf("空 phone 不进索引, 得 %d 项", len(p.phoneIndex))
+	}
+}
+
+func ptr[T any](v T) *T { return &v }

+ 157 - 0
internal/migrate/migrate_e2e_test.go

@@ -0,0 +1,157 @@
+// Package migrate 数据迁移端到端测试(真库,P1-04 验收:重跑幂等 + 事务边界正确)。
+//
+// DSN 纪律(对齐 identity e2e):
+//   - 三个 DSN 走环境变量,**不经测试代码硬编码、不落盘**:
+//       UNIFIED_MIGRATE_TEACHING_DSN  教学库 yuansiwei_i(只读源)
+//       UNIFIED_MIGRATE_FINANCE_DSN   教务库 finance_bk(只读源)
+//       UNIFIED_MIGRATE_TARGET_DSN    目标库(读写,建议独立测试库如 e2e_migrate)
+//   - 任一缺失 → t.Skip(CI/无库环境 go test ./... 不受影响)。
+//
+// 验证链路(TestMigrateE2E):
+//  1. EnsureTables:目标库建 12 表(AutoMigrate 幂等);
+//  2. 首次 run:读 legacy 全量 → 归一 → 置信分级 → 写 5 表 + 机构/校区;
+//  3. verify:唯一键完整性 + 引用完整性(phone 撞号/unionid 重复/孤儿 均应为 0);
+//  4. 重跑 run(幂等):ULID 复用(读 t_legacy_mapping)→ UsersCreated 恒 0、
+//     全部 Skipped;身份/成员/锚点经唯一键去重,量趋 0;
+//  5. 清理:drop 12 表(回到无表态,不污染其他 e2e)。
+//
+// 跑法(终端手动,密码不经 agent):
+//
+//	export UNIFIED_MIGRATE_TEACHING_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Local'
+//	export UNIFIED_MIGRATE_FINANCE_DSN='dev:***@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Local'
+//	export UNIFIED_MIGRATE_TARGET_DSN='dev:***@tcp(127.0.0.1:3306)/e2e_migrate?charset=utf8mb4&parseTime=True&loc=Local'
+//	go test ./internal/migrate/ -run TestMigrateE2E -v
+package migrate
+
+import (
+	"context"
+	"os"
+	"testing"
+
+	"gorm.io/gorm"
+)
+
+// migrateDSNsOrSkip 读三个迁移 DSN,任一缺失则跳过测试。
+func migrateDSNsOrSkip(t *testing.T) (teaching, finance, target string) {
+	teaching = os.Getenv("UNIFIED_MIGRATE_TEACHING_DSN")
+	finance = os.Getenv("UNIFIED_MIGRATE_FINANCE_DSN")
+	target = os.Getenv("UNIFIED_MIGRATE_TARGET_DSN")
+	if teaching == "" || finance == "" || target == "" {
+		t.Skip("未设置 UNIFIED_MIGRATE_{TEACHING,FINANCE,TARGET}_DSN,跳过迁移端到端测试")
+	}
+	return
+}
+
+// dropAllTables 清理目标库 12 表(回到无表态,隔离其他 e2e)。
+func dropAllTables(t *testing.T, gdb *gorm.DB) {
+	t.Helper()
+	tables := []string{
+		"t_membership", "t_guardian_link", "t_role_permission",
+		"t_identity", "t_wechat_union", "t_legacy_mapping", "t_audit_log",
+		"t_user", "t_campus", "t_institution", "t_permission", "t_role",
+	}
+	for _, tb := range tables {
+		// 不建物理 FK,drop 顺序无关;忽略"表不存在"错误(首次跑前无表)
+		_ = gdb.Migrator().DropTable(tb)
+	}
+}
+
+// TestMigrateE2E 迁移端到端全链路(首次 run + verify 唯一键 + 重跑幂等)。
+func TestMigrateE2E(t *testing.T) {
+	teaching, finance, target := migrateDSNsOrSkip(t)
+	ctx := context.Background()
+
+	// 目标库连接 + 清理旧表(幂等起点)
+	targetDB, err := gorm.Open(gormMySQLTarget(target), gormMigrateCfg())
+	if err != nil {
+		t.Fatalf("目标库连接失败: %v", err)
+	}
+	defer func() {
+		if sqlDB, err := targetDB.DB(); err == nil {
+			_ = sqlDB.Close()
+		}
+	}()
+	dropAllTables(t, targetDB)
+
+	// ---- ① 首次 run(EnsureTables + 单事务写 5 表)----
+	runner, err := NewRunner(Options{
+		TeachingDSN: teaching,
+		FinanceDSN:  finance,
+		TargetDSN:   target,
+		DryRun:      false,
+	})
+	if err != nil {
+		t.Fatalf("装配 Runner 失败: %v", err)
+	}
+	if err := runner.EnsureTables(ctx); err != nil {
+		t.Fatalf("EnsureTables 失败: %v", err)
+	}
+	first, err := runner.Run(ctx)
+	if err != nil {
+		t.Fatalf("首次 run 失败: %v", err)
+	}
+	_ = runner.Close()
+
+	// 首次 run 断言:用户全量新建
+	if first.UsersCreated != first.UsersTotal {
+		t.Errorf("首次 run UsersCreated=%d ≠ UsersTotal=%d(应有少量 username 冲突跳过)",
+			first.UsersCreated, first.UsersTotal)
+	}
+	if first.UsersTotal <= 0 {
+		t.Fatalf("UsersTotal=0(legacy t_user 读取异常)")
+	}
+	t.Logf("首次 run:用户 %d / 机构 %d / 校区 %d / 身份(phone=%d wechat=%d) / 锚点 %d / 成员 %d / 映射 %d",
+		first.UsersCreated, first.InstitutionsCreated, first.CampusesCreated,
+		first.IdentitiesPhone, first.IdentitiesWechat,
+		first.WechatUnionCreated, first.MembershipsCreated, first.LegacyMappingsCreated)
+
+	// ---- ② verify:唯一键 + 引用完整性 ----
+	verifier, err := NewVerifier(target)
+	if err != nil {
+		t.Fatalf("装配 Verifier 失败: %v", err)
+	}
+	vrep, err := verifier.Verify(ctx)
+	if err != nil {
+		t.Fatalf("verify 失败: %v", err)
+	}
+	if !vrep.OK() {
+		t.Errorf("verify 未通过(唯一键/引用完整性违规):\n%s", vrep.String())
+	}
+	_ = verifier.Close()
+
+	// ---- ③ 重跑 run(幂等断言:ULID 复用 → 0 新建)----
+	runner2, err := NewRunner(Options{
+		TeachingDSN: teaching,
+		FinanceDSN:  finance,
+		TargetDSN:   target,
+		DryRun:      false,
+	})
+	if err != nil {
+		t.Fatalf("装配 Runner(重跑) 失败: %v", err)
+	}
+	second, err := runner2.Run(ctx)
+	if err != nil {
+		t.Fatalf("重跑 run 失败: %v", err)
+	}
+	_ = runner2.Close()
+
+	// 幂等断言:重跑时所有 legacy id 已有 ULID 映射 → t_user 0 新建、全 Skipped
+	if second.UsersCreated != 0 {
+		t.Errorf("幂等违反:重跑 UsersCreated=%d(期望 0,ULID 应复用)", second.UsersCreated)
+	}
+	if second.UsersSkipped != first.UsersTotal {
+		t.Errorf("幂等违反:重跑 UsersSkipped=%d(期望 %d=首次全量)", second.UsersSkipped, first.UsersTotal)
+	}
+	// 成员/映射重跑也应 0 新建(唯一键去重)
+	if second.MembershipsCreated != 0 {
+		t.Errorf("幂等违反:重跑 MembershipsCreated=%d(期望 0)", second.MembershipsCreated)
+	}
+	if second.LegacyMappingsCreated != 0 {
+		t.Errorf("幂等违反:重跑 LegacyMappingsCreated=%d(期望 0)", second.LegacyMappingsCreated)
+	}
+	t.Logf("重跑 run:UsersCreated=%d UsersSkipped=%d MembershipsCreated=%d(幂等验证)",
+		second.UsersCreated, second.UsersSkipped, second.MembershipsCreated)
+
+	// ---- ④ 清理 ----
+	dropAllTables(t, targetDB)
+}

+ 120 - 0
internal/migrate/norm/norm.go

@@ -0,0 +1,120 @@
+// Package norm 标识归一化纯函数(P0-06 §2/§3.3 设计稿落地)。
+//
+// 职责:把 legacy 原始标识收敛为目标态 canonical 形态。
+// 设计铁律:
+//   - **纯函数**:无副作用、确定(同一输入恒同输出),可独立单测;
+//   - **幂等**:归一(归一(x)) == 归一(x)(对已归一值再归一不变);
+//   - **先归一后匹配**:归一失败绝不写脏 canonical(调用方据此跳过 t_identity 写入)。
+//
+// 四类标识归一力度(P0-06 §1):
+//   - phone:强归一(trim → 去分隔符 → 剥国际前缀 → 11 位校验)——主力 key
+//   - wechat:不做字符串变换(openid 已唯一),仅 trim;必须带 appid(见调用方)
+//   - email:轻归一(trim + 小写 + 正则校验)——不作归一 key
+//   - username:轻归一(trim + 小写)——绝不作自然人归一 key
+package norm
+
+import (
+	"regexp"
+	"strings"
+)
+
+// 大陆手机号正则(11 位:1 + 3-9 起始号段 + 9 位)
+var cnPhoneRe = regexp.MustCompile(`^1[3-9]\d{9}$`)
+
+// 分隔符:空白、横线、点(P0-06 §2.1 步 2)
+var sepRe = regexp.MustCompile(`[\s\-\.]`)
+
+// 邮箱最小可判定正则(P0-06 §2.3 步 4,不强求 RFC 5322 全量)
+var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
+
+// Phone 归一:trim → 去分隔符 → 剥国际前缀 → 11 位大陆号校验。
+//
+// 规则(P0-06 §2.1 顺序执行):
+//  1. trim 去首尾空白
+//  2. 去掉所有空格、横线、点(sepRe)
+//  3. 去 + 号前缀
+//  4. 剥国际前缀(仅当剥后 11 位匹配大陆号段才剥,P0-06 §2.1 步 3):
+//     - 前导 "0086"(4 位)
+//     - 前导 "86"(2 位,须后接 11 位大陆号)
+//  5. 11 位大陆号校验:正则 ^1[3-9]\d{9}$
+//  6. 否则 → ok=false(phone_invalid,转 P0-08 人工)
+//
+// 入参:
+//   - raw string:legacy t_user.phone_number 原始值(可能含分隔符/国际前缀/空白)。
+//
+// 出参:
+//   - canonical string:归一后的 11 位大陆号(如 "13800000000");ok=false 时为空串。
+//   - ok bool:true 表示归一成功(可写 t_identity.phone);false 表示 phone_invalid。
+//
+// 幂等验证:对已归一值 "13800000000" 再调用 → ("13800000000", true),不变。
+func Phone(raw string) (canonical string, ok bool) {
+	s := strings.TrimSpace(raw)
+	s = sepRe.ReplaceAllString(s, "")
+	s = strings.TrimPrefix(s, "+")
+	// 剥 0086 前缀(须后接 11 位大陆号,避免误剥正常号段)
+	if strings.HasPrefix(s, "0086") {
+		rest := s[4:]
+		if cnPhoneRe.MatchString(rest) {
+			return rest, true
+		}
+	}
+	// 剥 86 前缀(+86 已去 +,此处剩 "86"+11 位)
+	if strings.HasPrefix(s, "86") && len(s) == 13 {
+		rest := s[2:]
+		if cnPhoneRe.MatchString(rest) {
+			return rest, true
+		}
+	}
+	if cnPhoneRe.MatchString(s) {
+		return s, true
+	}
+	return "", false
+}
+
+// WechatOpenID 归一:仅 trim(微信侧已清洗,不做字符串变换)。
+//
+// 入参:
+//   - raw string:legacy t_weixin_user.openid 原始值。
+//
+// 出参:
+//   - canonical string:trim 后的 openid;空串返回 ""。
+//   - ok bool:true 表示非空可落 t_identity.wechat。
+func WechatOpenID(raw string) (canonical string, ok bool) {
+	s := strings.TrimSpace(raw)
+	if s == "" {
+		return "", false
+	}
+	return s, true
+}
+
+// Email 归一:trim + 小写 + 正则校验(P0-06 §2.3)。
+//
+// 保留 +tag(P0-06 §5 拍板:量仅 167 条,保守不破坏既有邮箱)。
+//
+// 入参:
+//   - raw string:legacy t_user.email 原始值。
+//
+// 出参:
+//   - 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))
+	if !emailRe.MatchString(s) {
+		return "", false
+	}
+	return s, true
+}
+
+// Username 归一:trim + 小写(P0-06 §2.4)。
+//
+// 红线:username **绝不作自然人归一 key**——仅作登录账号落 t_identity.username。
+// 本函数无 ok 返回值(username 始终可归一,空串也合法——legacy 100% 有值)。
+//
+// 入参:
+//   - raw string:legacy t_user.username 原始值。
+//
+// 出参:
+//   - canonical string:小写 username;空串输入返回 ""。
+func Username(raw string) string {
+	return strings.ToLower(strings.TrimSpace(raw))
+}

+ 128 - 0
internal/migrate/norm/norm_test.go

@@ -0,0 +1,128 @@
+package norm
+
+import "testing"
+
+// TestPhone 验证 phone 强归一规则(P0-06 §2.1 五步顺序)。
+func TestPhone(t *testing.T) {
+	cases := []struct {
+		name      string
+		raw       string
+		wantCanon string
+		wantOK    bool
+	}{
+		// 标准 11 位
+		{"纯 11 位", "13800000000", "13800000000", true},
+		{"前后空白", "  13800000000  ", "13800000000", true},
+		// 去分隔符
+		{"横线分隔", "138-0000-0000", "13800000000", true},
+		{"点号分隔", "138.0000.0000", "13800000000", true},
+		{"空格分隔", "138 0000 0000", "13800000000", true},
+		// 国际前缀
+		{"+86 前缀", "+8613800000000", "13800000000", true},
+		{"0086 前缀", "008613800000000", "13800000000", true},
+		{"86 前缀无加号", "8613800000000", "13800000000", true},
+		{"0086 后非大陆号段", "008610800000000", "", false}, // 剥 0086 后 108... 号段不符 → 不剥,整体也非 11 位 → false
+		{"86 后非大陆号段", "8610800000000", "", false},
+		// 异常
+		{"空串", "", "", false},
+		{"仅空白", "   ", "", false},
+		{"10 位", "1380000000", "", false},
+		{"12 位", "138000000001", "", false},
+		{"号段不符 10 开头", "10800000000", "", false},
+		{"号段不符 12 开头", "12800000000", "", false},
+		{"含字母", "1380000000a", "", false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got, ok := Phone(c.raw)
+			if got != c.wantCanon || ok != c.wantOK {
+				t.Fatalf("Phone(%q) = (%q, %v), want (%q, %v)", c.raw, got, ok, c.wantCanon, c.wantOK)
+			}
+		})
+	}
+}
+
+// TestPhoneIdempotent 验证幂等性:归一(归一(x)) == 归一(x)。
+func TestPhoneIdempotent(t *testing.T) {
+	raws := []string{"+8613800000000", "138-0000-0000", "138.0000.0000", " 13800000000 ", "8613800000000"}
+	for _, raw := range raws {
+		first, ok1 := Phone(raw)
+		if !ok1 {
+			t.Fatalf("Phone(%q) 首次归一失败,无法验证幂等", raw)
+		}
+		second, ok2 := Phone(first)
+		if second != first || !ok2 {
+			t.Fatalf("Phone 非幂等:Phone(%q)=(%q,%v) → 再归一 (%q,%v)", raw, first, ok1, second, ok2)
+		}
+	}
+}
+
+// TestWechatOpenID 验证 wechat openid 仅 trim。
+func TestWechatOpenID(t *testing.T) {
+	cases := []struct {
+		name      string
+		raw       string
+		wantCanon string
+		wantOK    bool
+	}{
+		{"标准", "openid_abc123", "openid_abc123", true},
+		{"含空白", "  openid_abc123  ", "openid_abc123", true},
+		{"空串", "", "", false},
+		{"仅空白", "   ", "", false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got, ok := WechatOpenID(c.raw)
+			if got != c.wantCanon || ok != c.wantOK {
+				t.Fatalf("WechatOpenID(%q) = (%q, %v), want (%q, %v)", c.raw, got, ok, c.wantCanon, c.wantOK)
+			}
+		})
+	}
+}
+
+// TestEmail 验证 email 轻归一(trim + 小写 + 正则)。
+func TestEmail(t *testing.T) {
+	cases := []struct {
+		name      string
+		raw       string
+		wantCanon string
+		wantOK    bool
+	}{
+		{"标准", "User@Example.COM", "user@example.com", true},
+		{"含空白", "  user@example.com  ", "user@example.com", true},
+		{"保留 +tag", "user+tag@example.com", "user+tag@example.com", true},
+		{"缺域名点", "user@examplecom", "", false},
+		{"缺 @", "userexample.com", "", false},
+		{"空串", "", "", false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got, ok := Email(c.raw)
+			if got != c.wantCanon || ok != c.wantOK {
+				t.Fatalf("Email(%q) = (%q, %v), want (%q, %v)", c.raw, got, ok, c.wantCanon, c.wantOK)
+			}
+		})
+	}
+}
+
+// TestUsername 验证 username 轻归一(trim + 小写,无 ok)。
+func TestUsername(t *testing.T) {
+	cases := []struct {
+		name string
+		raw  string
+		want string
+	}{
+		{"标准", "ZhangSan", "zhangsan"},
+		{"含空白", "  ZhangSan  ", "zhangsan"},
+		{"空串", "", ""},
+		{"已小写", "abc123", "abc123"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got := Username(c.raw)
+			if got != c.want {
+				t.Fatalf("Username(%q) = %q, want %q", c.raw, got, c.want)
+			}
+		})
+	}
+}

+ 123 - 0
internal/migrate/report.go

@@ -0,0 +1,123 @@
+package migrate
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+)
+
+// MigrateReport 一次迁移运行的完整报告(dry-run 与 run 共用,dry-run 的 *Inserted 恒 0)。
+//
+// 字段分组:
+//   - 机构/校区:Institutions / Campuses 处理量 + 冲突
+//   - 用户归一:UsersTotal / UsersInserted / UsersSkipped(重跑幂等跳过)
+//   - 身份:各 type 写入量(Phone / Email / Username / Wechat)
+//   - 微信锚点:WechatUnion
+//   - 成员关系:Memberships
+//   - 冲突/异常清单:PhoneConflicts / UsernameConflicts / PhoneInvalid / WechatOrphans
+//     (P0-08 人工复核输入,不自动处理)
+type MigrateReport struct {
+	DryRun bool // true=仅预演不写库
+
+	// 机构/校区
+	InstitutionsTotal   int
+	InstitutionsCreated int
+	CampusesTotal       int
+	CampusesCreated     int
+
+	// 用户归一
+	UsersTotal      int
+	UsersCreated    int // 新插入 t_user
+	UsersSkipped    int // 重跑幂等:canonical_user_id 已存在
+	UsersDisabled   int // status 映射为 disabled(0/3)
+
+	// 身份(t_identity)
+	IdentitiesPhone     int
+	IdentitiesEmail     int
+	IdentitiesUsername  int
+	IdentitiesWechat    int
+	IdentityDuplicates  int // 全局唯一键已存在而跳过的 identity(幂等)
+
+	// 微信锚点(t_wechat_union)
+	WechatUnionCreated int
+
+	// 成员关系(t_membership)
+	MembershipsCreated int
+
+	// 遗留映射(t_legacy_mapping)
+	LegacyMappingsCreated int
+
+	// 冲突/异常清单(P0-08 人工复核输入,不自动合并/丢弃)
+	PhoneConflicts    []ConflictItem // phone 撞号(同归一 phone >1 legacy id)
+	UsernameConflicts []ConflictItem // username 归一后全局重复
+	PhoneInvalid      []ConflictItem // phone 归一失败(phone_invalid)
+	WechatOrphans     []ConflictItem // 微信身份 userid 不在 t_user(孤儿,丢弃)
+
+	// 错误(非阻断性警告:单行失败记录,不中断整批)
+	Warnings []string
+}
+
+// ConflictItem 冲突/异常清单的单项(进 P0-08 人工复核)。
+type ConflictItem struct {
+	LegacyID int32  // legacy t_user.id(或关联表主键)
+	Detail   string // 人类可读描述(phone 值 / username 值 / 孤儿 openid)
+}
+
+// String 渲染为可读报告文本(stdout 输出 / 留档)。
+func (r *MigrateReport) String() string {
+	var b strings.Builder
+	mode := "RUN"
+	if r.DryRun {
+		mode = "DRY-RUN"
+	}
+	b.WriteString(fmt.Sprintf("=== 数据迁移报告(%s)===\n", mode))
+	b.WriteString(fmt.Sprintf("机构: %d 读取 / %d 新建\n", r.InstitutionsTotal, r.InstitutionsCreated))
+	b.WriteString(fmt.Sprintf("校区: %d 读取 / %d 新建\n", r.CampusesTotal, r.CampusesCreated))
+	b.WriteString(fmt.Sprintf("用户: %d 读取 / %d 新建 / %d 幂等跳过 / %d disabled\n",
+		r.UsersTotal, r.UsersCreated, r.UsersSkipped, r.UsersDisabled))
+	b.WriteString(fmt.Sprintf("身份: phone=%d email=%d username=%d wechat=%d(幂等跳过 %d)\n",
+		r.IdentitiesPhone, r.IdentitiesEmail, r.IdentitiesUsername, r.IdentitiesWechat, r.IdentityDuplicates))
+	b.WriteString(fmt.Sprintf("微信锚点(t_wechat_union): %d 新建\n", r.WechatUnionCreated))
+	b.WriteString(fmt.Sprintf("成员关系(t_membership): %d 新建\n", r.MembershipsCreated))
+	b.WriteString(fmt.Sprintf("遗留映射(t_legacy_mapping): %d 新建\n", r.LegacyMappingsCreated))
+
+	if len(r.PhoneConflicts) > 0 {
+		b.WriteString(fmt.Sprintf("\n[冲突] phone 撞号 %d 项(转 P0-08 人工):\n", len(r.PhoneConflicts)))
+		for _, c := range sortedConflicts(r.PhoneConflicts) {
+			b.WriteString(fmt.Sprintf("  legacy_id=%d %s\n", c.LegacyID, c.Detail))
+		}
+	}
+	if len(r.UsernameConflicts) > 0 {
+		b.WriteString(fmt.Sprintf("\n[冲突] username 归一后重复 %d 项(转 P0-08 人工):\n", len(r.UsernameConflicts)))
+		for _, c := range sortedConflicts(r.UsernameConflicts) {
+			b.WriteString(fmt.Sprintf("  legacy_id=%d %s\n", c.LegacyID, c.Detail))
+		}
+	}
+	if len(r.PhoneInvalid) > 0 {
+		b.WriteString(fmt.Sprintf("\n[异常] phone 归一失败 %d 项(phone_invalid,原始值留 t_legacy_mapping):\n", len(r.PhoneInvalid)))
+		for _, c := range sortedConflicts(r.PhoneInvalid) {
+			b.WriteString(fmt.Sprintf("  legacy_id=%d %s\n", c.LegacyID, c.Detail))
+		}
+	}
+	if len(r.WechatOrphans) > 0 {
+		b.WriteString(fmt.Sprintf("\n[丢弃] 微信孤儿 %d 项(userid 不在 t_user,openid 无法归属):\n", len(r.WechatOrphans)))
+		for _, c := range sortedConflicts(r.WechatOrphans) {
+			b.WriteString(fmt.Sprintf("  legacy_id=%d %s\n", c.LegacyID, c.Detail))
+		}
+	}
+	if len(r.Warnings) > 0 {
+		b.WriteString(fmt.Sprintf("\n[警告] %d 条:\n", len(r.Warnings)))
+		for _, w := range r.Warnings {
+			b.WriteString("  " + w + "\n")
+		}
+	}
+	return b.String()
+}
+
+// sortedConflicts 按 LegacyID 排序(报告稳定输出,防 map 遍历序漂移)。
+func sortedConflicts(items []ConflictItem) []ConflictItem {
+	out := make([]ConflictItem, len(items))
+	copy(out, items)
+	sort.Slice(out, func(i, j int) bool { return out[i].LegacyID < out[j].LegacyID })
+	return out
+}

+ 755 - 0
internal/migrate/runner.go

@@ -0,0 +1,755 @@
+package migrate
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"gorm.io/driver/mysql"
+	"gorm.io/gorm"
+
+	identitymodel "yuansiwei/unified-backend/internal/identity/model"
+	"yuansiwei/unified-backend/internal/identity/id"
+	"yuansiwei/unified-backend/internal/migrate/norm"
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
+)
+
+// Options 迁移运行配置。
+type Options struct {
+	// TeachingDSN 教学库 yuansiwei_i(只读源)。
+	TeachingDSN string
+	// FinanceDSN 教务库 finance_bk(只读源)。
+	FinanceDSN string
+	// TargetDSN 目标态 unified 库(读写,写入 5 表)。
+	TargetDSN string
+	// DryRun 仅预演:读库做存在性检查 + 产出报告,不执行任何写入。
+	DryRun bool
+	// Now 时间锚点(regTime 为空时用于 ULID 发放 / created_at)。可注入便于测试。
+	Now time.Time
+}
+
+// Runner 迁移编排器(P1-04/T5 主体)。
+//
+// 职责:
+//  1. 读 legacy 双库(只读);
+//  2. 按 P0-06 归一 + P0-07 置信分级规划;
+//  3. 幂等 upsert 写目标态 5 表(t_institution/t_campus 维度 + t_user/t_identity/
+//     t_wechat_union/t_membership/t_legacy_mapping);
+//  4. 产出 MigrateReport(含 P0-08 冲突/异常清单)。
+//
+// 事务边界:所有写入在单个 GORM 事务中(dry-run 不进入事务,仅读)。
+type Runner struct {
+	opts   Options
+	src    *Source
+	target *gorm.DB
+	// wxCache:legacy userid → 微信身份(Run 时填充,writeIdentities/writeWechatUnion 消费)。
+	// 放在 Runner 上避免大参数透传;非并发(单事务串行处理)。
+	wxCache map[int32]*LegacyWeixinUser
+}
+
+// NewRunner 构建迁移编排器(连接 legacy 双库 + 目标库,均 fail-fast)。
+func NewRunner(opts Options) (*Runner, error) {
+	if opts.Now.IsZero() {
+		opts.Now = time.Now()
+	}
+	src, err := NewSource(opts.TeachingDSN, opts.FinanceDSN)
+	if err != nil {
+		return nil, err
+	}
+	target, err := gorm.Open(gormMySQLTarget(opts.TargetDSN), gormMigrateCfg())
+	if err != nil {
+		_ = src.Close()
+		return nil, fmt.Errorf("migrate: 目标库连接失败: %w", err)
+	}
+	if sqlDB, err := target.DB(); err == nil {
+		if err := sqlDB.Ping(); err != nil {
+			_ = src.Close()
+			return nil, fmt.Errorf("migrate: 目标库 Ping 失败: %w", err)
+		}
+	}
+	return &Runner{opts: opts, src: src, target: target}, nil
+}
+
+// Close 关闭所有连接。
+func (r *Runner) Close() error {
+	var err error
+	if r.src != nil {
+		err = r.src.Close()
+	}
+	if r.target != nil {
+		if sqlDB, derr := r.target.DB(); derr == nil {
+			_ = sqlDB.Close()
+		}
+	}
+	return err
+}
+
+// EnsureTables 确保目标态 12 表存在(dev 期 AutoMigrate;生产以 DDL 为准,幂等对齐)。
+// 迁移前调用,避免"写入时表不存在"。
+func (r *Runner) EnsureTables(ctx context.Context) error {
+	tables := append(identitymodel.AllTables(), orgmodel.AllTables()...)
+	return r.target.WithContext(ctx).AutoMigrate(tables...)
+}
+
+// Run 执行迁移(单事务)。
+//
+// 出参:
+//   - *MigrateReport:完整报告(含冲突/异常清单,P0-08 输入)。
+//   - error:仅当读取/事务提交失败时返回;单行写入失败记录进 report.Warnings 不中断。
+func (r *Runner) Run(ctx context.Context) (*MigrateReport, error) {
+	rep := &MigrateReport{DryRun: r.opts.DryRun}
+
+	// ---- 读取 legacy 全量(只读)----
+	users, err := r.src.LoadAllUsers(ctx)
+	if err != nil {
+		return rep, fmt.Errorf("migrate: 读 t_user 失败: %w", err)
+	}
+	rep.UsersTotal = len(users)
+
+	wx, err := r.src.LoadAllWeixinUsers(ctx)
+	if err != nil {
+		return rep, fmt.Errorf("migrate: 读 t_weixin_user 失败: %w", err)
+	}
+	// 填充微信缓存(userid → 身份,1:1)
+	r.wxCache = make(map[int32]*LegacyWeixinUser, len(wx))
+	for i := range wx {
+		if wx[i].UserID > 0 {
+			if _, exists := r.wxCache[wx[i].UserID]; !exists {
+				r.wxCache[wx[i].UserID] = &wx[i]
+			}
+		}
+	}
+	assoc, err := r.src.LoadAllAssociations(ctx)
+	if err != nil {
+		return rep, fmt.Errorf("migrate: 读 t_user_account_association 失败: %w", err)
+	}
+	schoolIDs, err := r.src.LoadDistinctSchoolIDs(ctx)
+	if err != nil {
+		return rep, fmt.Errorf("migrate: 读 t_user_school_campus distinct schoolid 失败: %w", err)
+	}
+	schools, err := r.src.LoadSchools(ctx, schoolIDs)
+	if err != nil {
+		return rep, fmt.Errorf("migrate: 读 t_school 失败: %w", err)
+	}
+	rep.InstitutionsTotal = len(schools)
+	campuses, err := r.src.LoadCampuses(ctx)
+	if err != nil {
+		return rep, fmt.Errorf("migrate: 读 t_school_campus 失败: %w", err)
+	}
+	rep.CampusesTotal = len(campuses)
+	binds, err := r.src.LoadUserSchoolCampus(ctx)
+	if err != nil {
+		return rep, fmt.Errorf("migrate: 读 t_user_school_campus 失败: %w", err)
+	}
+
+	// ---- 规划(纯逻辑:ULID 发放 + 置信分级 + 冲突检测)----
+	// 已有映射(重跑幂等:legacy id → canonical_user_id 复用)
+	existingCanon, err := r.loadExistingMappings(ctx)
+	if err != nil {
+		return rep, fmt.Errorf("migrate: 读 t_legacy_mapping 失败: %w", err)
+	}
+	userSet := make(map[int32]struct{}, len(users))
+	for i := range users {
+		userSet[users[i].ID] = struct{}{}
+	}
+	// 微信孤儿(openid 的 userid 不在 t_user)
+	for i := range wx {
+		if _, ok := userSet[wx[i].UserID]; !ok {
+			rep.WechatOrphans = append(rep.WechatOrphans, ConflictItem{
+				LegacyID: wx[i].UserID,
+				Detail:   fmt.Sprintf("openid=%s 的 userid 不在 t_user,丢弃", wx[i].OpenID),
+			})
+		}
+	}
+
+	planner := NewPlanner(users, assoc, wx)
+	// ULID 发放:已映射复用,新 id 发放(时间有序:以 regTime 为锚)
+	canonByID := make(map[int32]string, len(users))
+	for i := range users {
+		u := &users[i]
+		if c, ok := existingCanon[u.ID]; ok && c != "" {
+			canonByID[u.ID] = c
+			continue
+		}
+		var t time.Time
+		if u.RegTime != nil {
+			t = *u.RegTime
+		} else {
+			t = r.opts.Now
+		}
+		c, err := id.NewCanonicalUserIDAt(t)
+		if err != nil {
+			return rep, fmt.Errorf("migrate: ULID 发放失败(legacy id=%d): %w", u.ID, err)
+		}
+		canonByID[u.ID] = c
+	}
+
+	// 决策表(每个 user 的匹配决策)
+	decisionByID := make(map[int32]MatchDecision, len(users))
+	for i := range users {
+		u := &users[i]
+		d := planner.Decide(u.ID, u.PhoneNumber)
+		decisionByID[u.ID] = d
+		// phone 撞号冲突清单(每组只记一次,避免重复)
+		if d.PhoneConflict {
+			if u.PhoneNumber != nil {
+				rep.PhoneConflicts = appendUniqueConflict(rep.PhoneConflicts, ConflictItem{
+					LegacyID: u.ID,
+					Detail:   fmt.Sprintf("phone=%s 撞号(同归一 phone 多账号,不自动合并)", normPhoneDisplay(*u.PhoneNumber)),
+				})
+			}
+		}
+		if d.PhoneInvalid && u.PhoneNumber != nil {
+			rep.PhoneInvalid = append(rep.PhoneInvalid, ConflictItem{
+				LegacyID: u.ID,
+				Detail:   fmt.Sprintf("phone 归一失败,原始值=%q", *u.PhoneNumber),
+			})
+		}
+	}
+
+	// ---- 写入(单事务;dry-run 仅读不写)----
+	if r.opts.DryRun {
+		// dry-run:只做存在性预检 + 计数(不实际写入)。
+		// 机构/校区/用户/身份/成员 的新建量按"目标库已有则复用"估算。
+		r.simulateCounts(ctx, rep, schools, campuses, users, canonByID, binds)
+		return rep, nil
+	}
+
+	err = r.target.Transaction(func(tx *gorm.DB) error {
+		// ① 机构(维度表,幂等:已存在则复用)
+		instByID, err := r.writeInstitutions(ctx, tx, schools, rep)
+		if err != nil {
+			return err
+		}
+		// ② 校区(依赖机构)
+		campusByID, err := r.writeCampuses(ctx, tx, campuses, instByID, rep)
+		if err != nil {
+			return err
+		}
+		// ③ 用户 + 身份 + 微信锚点 + 遗留映射(按 legacy id 序,幂等)
+		usedUsername := map[string]int32{} // 归一 username → 首个占用者
+		usedPhone := map[string]int32{}   // 归一 phone → 首个占用者
+		existUsername := r.existingUsernames(ctx, tx)
+		existPhone := r.existingIdentities(ctx, tx, identitymodel.IdentityTypePhone, "")
+		for k, v := range existUsername {
+			usedUsername[k] = v
+		}
+		for k, v := range existPhone {
+			usedPhone[k] = v
+		}
+		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 {
+				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, 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)
+		return nil
+	})
+	if err != nil {
+		return rep, fmt.Errorf("migrate: 事务提交失败(已回滚): %w", err)
+	}
+	return rep, nil
+}
+
+// normPhoneDisplay 展示用(截断,防超长)。
+func normPhoneDisplay(raw string) string {
+	c, ok := norm.Phone(raw)
+	if ok {
+		return c
+	}
+	return raw
+}
+
+// appendUniqueConflict 去重追加冲突项(同 LegacyID 只记一次)。
+func appendUniqueConflict(items []ConflictItem, item ConflictItem) []ConflictItem {
+	for _, e := range items {
+		if e.LegacyID == item.LegacyID {
+			return items
+		}
+	}
+	return append(items, item)
+}
+
+// ---- 幂等辅助(读目标库现有状态)----
+
+// loadExistingMappings 读 t_legacy_mapping,返回 legacy_i_user_id → canonical_user_id。
+//
+// dry-run 容错:目标表未建(MySQL 1146 表不存在)时按"空表"处理(全量首次迁移,ULID 全发放)。
+// 这保证 dry-run 纯只读、不依赖目标库已建表。
+func (r *Runner) loadExistingMappings(ctx context.Context) (map[int32]string, error) {
+	m := make(map[int32]string)
+	var rows []identitymodel.LegacyMapping
+	if err := r.target.WithContext(ctx).Model(&identitymodel.LegacyMapping{}).Find(&rows).Error; err != nil {
+		if isMissingTable(err) {
+			return m, nil // 表不存在(dry-run 首次)→ 空映射
+		}
+		return nil, err
+	}
+	for i := range rows {
+		if rows[i].LegacyIUserID != nil && *rows[i].LegacyIUserID > 0 {
+			m[*rows[i].LegacyIUserID] = rows[i].CanonicalUserID
+		}
+	}
+	return m, nil
+}
+
+// existingUsernames 读 t_user 现有 username(归一 username → canonical_user_id 占位,用于冲突检测)。
+func (r *Runner) existingUsernames(ctx context.Context, tx *gorm.DB) map[string]int32 {
+	m := make(map[string]int32)
+	var users []identitymodel.User
+	if err := tx.WithContext(ctx).Model(&identitymodel.User{}).Find(&users).Error; err != nil {
+		return m
+	}
+	for i := range users {
+		if users[i].Username != nil && *users[i].Username != "" {
+			canon := norm.Username(*users[i].Username)
+			if _, ok := m[canon]; !ok {
+				m[canon] = 0 // 占位(无 legacy id,仅标记"已占用")
+			}
+		}
+	}
+	return m
+}
+
+// existingIdentities 读 t_identity 现有指定 type 的 identifier(归一值 → 占位)。
+func (r *Runner) existingIdentities(ctx context.Context, tx *gorm.DB, typ, appid string) map[string]int32 {
+	m := make(map[string]int32)
+	var idents []identitymodel.Identity
+	q := tx.WithContext(ctx).Model(&identitymodel.Identity{}).Where("type = ?", typ)
+	if err := q.Find(&idents).Error; err != nil {
+		return m
+	}
+	for i := range idents {
+		m[idents[i].Identifier] = 0
+	}
+	return m
+}
+
+// ---- 写入方法(每个方法在 dry-run 外执行;幂等:存在则跳过)----
+
+// writeInstitutions 写 t_institution(幂等:按 name 查现有复用)。
+func (r *Runner) writeInstitutions(ctx context.Context, tx *gorm.DB, schools []LegacySchool, rep *MigrateReport) (map[int32]int64, error) {
+	instByID := make(map[int32]int64, len(schools))
+	for i := range schools {
+		s := &schools[i]
+		var existing []orgmodel.Institution
+		tx.WithContext(ctx).Model(&orgmodel.Institution{}).
+			Where("institution_name = ?", s.Name).Limit(1).Find(&existing)
+		if len(existing) > 0 {
+			instByID[s.ID] = existing[0].InstitutionID
+			continue
+		}
+		inst := &orgmodel.Institution{
+			InstitutionName: s.Name,
+			Status:          mapOrgStatus(s.Status),
+			CreatedAt:       r.opts.Now,
+			UpdatedAt:       r.opts.Now,
+		}
+		if err := tx.WithContext(ctx).Create(inst).Error; err != nil {
+			return instByID, err
+		}
+		instByID[s.ID] = inst.InstitutionID
+		rep.InstitutionsCreated++
+	}
+	return instByID, nil
+}
+
+// writeCampuses 写 t_campus(幂等:按 campus_name + institution 复用)。
+func (r *Runner) writeCampuses(ctx context.Context, tx *gorm.DB, campuses []LegacySchoolCampus,
+	instByID map[int32]int64, rep *MigrateReport) (map[int32]int64, error) {
+	campusByID := make(map[int32]int64, len(campuses))
+	for i := range campuses {
+		c := &campuses[i]
+		// 孤儿校区(school_id 不在被引用机构集):institution_id 置 0(机构级/平台级)
+		instID, ok := instByID[c.SchoolID]
+		if !ok {
+			instID = 0
+		}
+		var existing []orgmodel.Campus
+		tx.WithContext(ctx).Model(&orgmodel.Campus{}).
+			Where("campus_name = ? AND institution_id = ?", c.Name, instID).Limit(1).Find(&existing)
+		if len(existing) > 0 {
+			campusByID[c.ID] = existing[0].CampusID
+			continue
+		}
+		campus := &orgmodel.Campus{
+			InstitutionID: instID,
+			CampusName:    c.Name,
+			CampusCode:    c.Code,
+			Status:        mapOrgStatus(c.Status),
+			CreatedAt:     r.opts.Now,
+			UpdatedAt:     r.opts.Now,
+		}
+		if err := tx.WithContext(ctx).Create(campus).Error; err != nil {
+			return campusByID, err
+		}
+		campusByID[c.ID] = campus.CampusID
+		rep.CampusesCreated++
+	}
+	return campusByID, nil
+}
+
+// writeUser 写 t_user(幂等:canonical_user_id 存在则跳过;username 冲突置 NULL)。
+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 {
+		rep.UsersSkipped++
+		return nil
+	}
+	// username:归一后若已占用(存量或本批首个外)→ 置 NULL + 记冲突
+	var username *string
+	if u.Username != "" {
+		canonUsername := norm.Username(u.Username)
+		if _, taken := usedUsername[canonUsername]; !taken {
+			s := u.Username
+			username = &s
+			usedUsername[canonUsername] = u.ID
+		} else {
+			rep.UsernameConflicts = append(rep.UsernameConflicts, ConflictItem{
+				LegacyID: u.ID,
+				Detail:   fmt.Sprintf("username=%q 归一后与已有用户重复,置 NULL", u.Username),
+			})
+		}
+	}
+	_ = existUsername // 存量 username 冲突已在 usedUsername 合并
+
+	displayName := u.RealName
+	user := &identitymodel.User{
+		CanonicalUserID: canon,
+		Username:        username,
+		DisplayName:     displayName,
+		AvatarURL:       u.Avatar,
+		PersonType:      mapPersonType(u.Role),
+		PasswordHash:    &u.Password,
+		PasswordSalt:    &u.Salt,
+		HashVersion:     strPtr(identitymodel.HashVersionShaSalt), // I 库=教学侧,sha_salt(ADR-016 惰性升级)
+		Status:          mapUserStatus(u.Status),
+		CreatedAt:       r.opts.Now,
+		UpdatedAt:       r.opts.Now,
+	}
+	if u.RegTime != nil {
+		user.CreatedAt = *u.RegTime
+	}
+	if err := tx.WithContext(ctx).Create(user).Error; err != nil {
+		return err
+	}
+	rep.UsersCreated++
+	if u.Status == 0 || u.Status == 3 {
+		rep.UsersDisabled++
+	}
+	return nil
+}
+
+// writeIdentities 写 t_identity(phone/email/username/wechat;幂等 + 全局唯一键冲突处理)。
+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) {
+	// 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 {
+					usedPhone[canonPhone] = u.ID
+					rep.IdentitiesPhone++
+				}
+			}
+		}
+	}
+	// email(轻归一)
+	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 {
+				rep.IdentitiesEmail++
+			}
+		}
+	}
+	// username(轻归一;与 t_user.username 一致)
+	if u.Username != "" {
+		canonUsername := norm.Username(u.Username)
+		if canonUsername != "" {
+			if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeUsername, canonUsername, "", rep); err == nil {
+				rep.IdentitiesUsername++
+			}
+		}
+	}
+	// wechat(openid + appid,经 t_weixin_user)
+	if w := r.wechatOf(u.ID); w != nil {
+		openid, ok := norm.WechatOpenID(w.OpenID)
+		if ok {
+			appid := ""
+			if w.AppID != nil {
+				appid = *w.AppID
+			}
+			if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeWechat, openid, appid, rep); err == nil {
+				rep.IdentitiesWechat++
+			}
+		}
+	}
+	_ = existPhone
+}
+
+// wechatOf 取某 legacy user 的微信身份(runner 持有 planner 不方便,直接查 wx 缓存)。
+// 注:实现上在 Run 里通过闭包注入更合适;此处用字段缓存。
+func (r *Runner) wechatOf(userID int32) *LegacyWeixinUser {
+	return r.wxCache[userID]
+}
+
+// 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 {
+		rep.IdentityDuplicates++
+		return nil
+	}
+	it := &identitymodel.Identity{
+		CanonicalUserID: canon,
+		Type:            typ,
+		Identifier:      identifier,
+		AppID:           appid,
+		Verified:        false,
+		CreatedAt:       r.opts.Now,
+	}
+	return tx.WithContext(ctx).Create(it).Error
+}
+
+// writeWechatUnion 写 t_wechat_union(unionid 锚点;幂等:canonical/unionid 已存在则跳过)。
+func (r *Runner) writeWechatUnion(ctx context.Context, tx *gorm.DB, userID int32, canon string,
+	planner *Planner, 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 {
+		return
+	}
+	wu := &identitymodel.WechatUnion{
+		CanonicalUserID: canon,
+		UnionID:         *w.UnionID,
+		CreatedAt:       r.opts.Now,
+		UpdatedAt:       r.opts.Now,
+	}
+	if err := tx.WithContext(ctx).Create(wu).Error; err == nil {
+		rep.WechatUnionCreated++
+	} else {
+		rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_wechat_union unionid=%s 失败: %v", *w.UnionID, err))
+	}
+}
+
+// writeLegacyMapping 写 t_legacy_mapping(canonical_user_id UNIQUE 幂等;重跑复用 ULID 时天然存在则跳过)。
+func (r *Runner) writeLegacyMapping(ctx context.Context, tx *gorm.DB, u *LegacyUser, canon string,
+	d MatchDecision, rep *MigrateReport) error {
+	var n int64
+	tx.WithContext(ctx).Model(&identitymodel.LegacyMapping{}).Where("canonical_user_id = ?", canon).Count(&n)
+	if n > 0 {
+		return nil // 已存在(重跑幂等)
+	}
+	legacyID := u.ID
+	role := mapRoleCode(u.Role)
+	lm := &identitymodel.LegacyMapping{
+		CanonicalUserID:      canon,
+		LegacyIUserID:        &legacyID, // I 库=教学侧,legacy_i_user_id
+		InstitutionID:        0,         // 平台级/待 P1-05 细化(canonical 用户跨机构)
+		CampusID:             0,
+		Role:                 &role,
+		MatchSource:          strPtr(d.MatchSource),
+		MatchConfidence:      d.Confidence,
+		AuditRef:             strPtr(fmt.Sprintf("migrate/v1.2/%d", u.ID)),
+		MergedAt:             &r.opts.Now,
+		CreatedAt:            r.opts.Now,
+		UpdatedAt:            r.opts.Now,
+	}
+	if err := tx.WithContext(ctx).Create(lm).Error; err != nil {
+		return err
+	}
+	rep.LegacyMappingsCreated++
+	return nil
+}
+
+// writeMemberships 写 t_membership(幂等:唯一键 canonical+campus+role)。
+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) {
+	userByID := make(map[int32]*LegacyUser, len(users))
+	for i := range users {
+		userByID[users[i].ID] = &users[i]
+	}
+	for i := range binds {
+		b := &binds[i]
+		u, ok := userByID[b.UserID]
+		if !ok {
+			// 绑定用户不在 t_user(孤儿)→ 跳过 + 警告
+			rep.Warnings = append(rep.Warnings, fmt.Sprintf("t_membership 跳过:legacy 绑定 userid=%d 不在 t_user", b.UserID))
+			continue
+		}
+		canon, ok := canonByID[b.UserID]
+		if !ok {
+			continue
+		}
+		instID, ok1 := instByID[b.SchoolID]
+		campusID, ok2 := campusByID[b.CampusID]
+		if !ok1 || !ok2 {
+			rep.Warnings = append(rep.Warnings, fmt.Sprintf("t_membership 跳过:institution/campus 映射缺失 schoolid=%d campusid=%d", b.SchoolID, b.CampusID))
+			continue
+		}
+		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 {
+			continue
+		}
+		m := &orgmodel.Membership{
+			CanonicalUserID: canon,
+			InstitutionID:   instID,
+			CampusID:        campusID,
+			Role:            role,
+			Status:          orgmodel.MembershipStatusActive,
+			JoinedAt:        &r.opts.Now,
+			CreatedAt:       r.opts.Now,
+		}
+		if err := tx.WithContext(ctx).Create(m).Error; err == nil {
+			rep.MembershipsCreated++
+		} else {
+			rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_membership legacy_userid=%d 失败: %v", b.UserID, err))
+		}
+	}
+}
+
+// simulateCounts dry-run 计数预检(只读,不写):估算各表新建量。
+func (r *Runner) simulateCounts(ctx context.Context, rep *MigrateReport, schools []LegacySchool,
+	campuses []LegacySchoolCampus, users []LegacyUser, canonByID map[int32]string, binds []LegacyUserSchoolCampus) {
+	existingUsers := r.countTable(ctx, "t_user")
+	existingInst := r.countTable(ctx, "t_institution")
+	existingCampus := r.countTable(ctx, "t_campus")
+	existingMapping := r.countTable(ctx, "t_legacy_mapping")
+	// 幂等估算:新建 = 目标量 - 已存在量(取下限 0)
+	if len(users) > int(existingUsers) {
+		rep.UsersCreated = len(users) - int(existingUsers)
+	}
+	if len(schools) > int(existingInst) {
+		rep.InstitutionsCreated = len(schools) - int(existingInst)
+	}
+	if len(campuses) > int(existingCampus) {
+		rep.CampusesCreated = len(campuses) - int(existingCampus)
+	}
+	if len(users) > int(existingMapping) {
+		rep.LegacyMappingsCreated = len(users) - int(existingMapping)
+	}
+	// 身份/成员/锚点按"全量首次写入"估算(重跑时这些由唯一键去重,量趋 0)
+	for i := range users {
+		u := &users[i]
+		_ = canonByID[u.ID]
+		if u.PhoneNumber != nil && *u.PhoneNumber != "" {
+			if _, ok := norm.Phone(*u.PhoneNumber); ok {
+				rep.IdentitiesPhone++
+			}
+		}
+		if u.Email != nil && *u.Email != "" {
+			if _, ok := norm.Email(*u.Email); ok {
+				rep.IdentitiesEmail++
+			}
+		}
+		if u.Username != "" {
+			rep.IdentitiesUsername++
+		}
+	}
+	for i := range users {
+		if w := r.wxCache[users[i].ID]; w != nil {
+			rep.IdentitiesWechat++
+			if w.UnionID != nil && *w.UnionID != "" {
+				rep.WechatUnionCreated++
+			}
+		}
+	}
+	rep.MembershipsCreated = len(binds)
+}
+
+// countTable 统计表行数(dry-run 幂等估算用)。
+func (r *Runner) countTable(ctx context.Context, table string) int64 {
+	var n int64
+	_ = r.target.WithContext(ctx).Table(table).Count(&n).Error
+	return n
+}
+
+// ---- 映射函数 ----
+
+// mapUserStatus legacy status int → 目标态 string。
+// 1=active,0/2/3=disabled(P0-02 §4 拍板:0/3 都迁 disabled 保留可追溯)。
+func mapUserStatus(legacy int32) string {
+	if legacy == 1 {
+		return identitymodel.UserStatusActive
+	}
+	return identitymodel.UserStatusDisabled
+}
+
+// mapOrgStatus 机构/校区 legacy status(tinyint)→ 目标态 string。
+func mapOrgStatus(legacy int8) string {
+	if legacy == 1 {
+		return orgmodel.StatusActive
+	}
+	return orgmodel.StatusDisabled
+}
+
+// mapPersonType legacy role 数字码 → 目标态 person_type(P0-02 §3.1)。
+func mapPersonType(legacy int32) string {
+	switch legacy {
+	case 2:
+		return identitymodel.PersonTypeStudent
+	case 4:
+		return identitymodel.PersonTypeTeacher
+	case 3:
+		return identitymodel.PersonTypeParent
+	default:
+		return identitymodel.PersonTypeStaff
+	}
+}
+
+// mapRoleCode legacy role 数字码 → 目标态 7 角色字符串码(P0-02 §2.5 拍板映射)。
+func mapRoleCode(legacy int32) string {
+	switch legacy {
+	case 1:
+		return orgmodel.RoleSuperAdmin
+	case 2:
+		return orgmodel.RoleStudent
+	case 3:
+		return orgmodel.RoleParent
+	case 4:
+		return orgmodel.RoleTeacher
+	case 5, 6:
+		return orgmodel.RoleAcademic // admin/redactor → 教务
+	case 8:
+		return orgmodel.RoleFinance
+	case 7, 9:
+		return orgmodel.RoleMarket // prototype/marketer → 市场
+	default:
+		return orgmodel.RoleAcademic // 未知角色兜底为教务(运营角色)
+	}
+}
+
+// strPtr 字符串指针辅助。
+func strPtr(s string) *string { return &s }
+
+// gormMySQLTarget 目标库 GORM 驱动(mysql.Open 返回 Dialector,与 infra/db 同款)。
+func gormMySQLTarget(dsn string) gorm.Dialector {
+	return mysql.Open(dsn)
+}

+ 249 - 0
internal/migrate/source.go

@@ -0,0 +1,249 @@
+// Package migrate 数据迁移引擎(P1-04/T5,ADR-003 归一 pipeline 落地)。
+//
+// 职责:从 legacy 双库(yuansiwei_i 教学 + finance_bk 教务,只读)读取三源用户数据,
+// 按 P0-06 归一规则 + P0-07 置信分级执行匹配,幂等 upsert 写入目标态 5 表
+// (t_user/t_identity/t_wechat_union/t_membership/t_legacy_mapping)。
+//
+// 设计铁律:
+//   - **幂等**:重跑不产生重复行(按 legacy id 顺序处理 + 存在性检查)
+//   - **先归一后匹配**:归一失败绝不写脏 canonical(phone_invalid → 仅记录 raw)
+//   - **置信分级**:高置信(种子 + phone 唯一)自动写;中/低/冲突 → 仅出报告
+//   - **事务边界**:整批在单个 GORM 事务中(dry-run 不写)
+//   - **ULID 发放**:id.NewCanonicalUserIDAt(regTime) 保证时间有序
+//   - **legacy 只读**:Source 连接仅执行 SELECT,无任何 INSERT/UPDATE/DELETE
+package migrate
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"gorm.io/driver/mysql"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+// gormSilent 静默 logger(迁移批量读写高频 SQL,不刷日志;SLOW SQL 阈值噪音在此屏蔽)。
+var gormSilent = logger.Default.LogMode(logger.Silent)
+
+// gormMigrateCfg 迁移包共用的 GORM 配置(不建物理 FK + 静默日志)。
+func gormMigrateCfg() *gorm.Config {
+	return &gorm.Config{
+		DisableForeignKeyConstraintWhenMigrating: true,
+		Logger:                                   gormSilent,
+	}
+}
+
+// isMissingTable 判断 GORM 错误是否为"表不存在"(MySQL 1146)。
+// dry-run / 幂等读路径据此容错:目标表未建时按"空表"处理(全量首次迁移)。
+func isMissingTable(err error) bool {
+	if err == nil {
+		return false
+	}
+	msg := err.Error()
+	return strings.Contains(msg, "1146") || strings.Contains(msg, "doesn't exist")
+}
+
+// ---- legacy 数据源结构体(GORM 只读映射,不 AutoMigrate)----
+
+// LegacyUser 教学侧 yuansiwei_i.t_user(Canonical 主表,68107 行)。
+type LegacyUser struct {
+	ID          int32     `gorm:"column:id;primaryKey"`
+	Username    string    `gorm:"column:username"`
+	RealName    *string   `gorm:"column:real_name"`
+	Password    string    `gorm:"column:password"`
+	Salt        string    `gorm:"column:salt"`
+	Role        int32     `gorm:"column:role"`
+	Email       *string   `gorm:"column:email"`
+	PhoneNumber *string   `gorm:"column:phone_number"`
+	Avatar      *string   `gorm:"column:avatar"`
+	RegTime     *time.Time `gorm:"column:reg_time"`
+	Status      int32     `gorm:"column:status"`
+	ParentID    int32     `gorm:"column:parent_id"`
+	GradeID     int32     `gorm:"column:grade_id"`
+}
+
+// TableName 指定 legacy 表名。
+func (LegacyUser) TableName() string { return "t_user" }
+
+// LegacyWeixinUser 教学侧 yuansiwei_i.t_weixin_user(微信身份,3491 行)。
+type LegacyWeixinUser struct {
+	OpenID   string  `gorm:"column:openid;primaryKey"`
+	Nickname string  `gorm:"column:nickname"`
+	UserID   int32   `gorm:"column:userid"`
+	UnionID  *string `gorm:"column:unionid"`
+	AppID    *string `gorm:"column:appid"`
+}
+
+// TableName 指定 legacy 表名。
+func (LegacyWeixinUser) TableName() string { return "t_weixin_user" }
+
+// LegacyAccountAssociation 教学侧 yuansiwei_i.t_user_account_association(现映射种子,16077 行)。
+type LegacyAccountAssociation struct {
+	UserID              int32 `gorm:"column:userid;primaryKey"`
+	MarkingSystemUserID *int32 `gorm:"column:marking_system_userid"`
+}
+
+// TableName 指定 legacy 表名。
+func (LegacyAccountAssociation) TableName() string { return "t_user_account_association" }
+
+// LegacySchool 教学侧 yuansiwei_i.t_school(全国名录,仅取被引用的 23 所)。
+type LegacySchool struct {
+	ID     int32  `gorm:"column:id;primaryKey"`
+	Name   string `gorm:"column:name"`
+	Status int8   `gorm:"column:status"` // tinyint
+}
+
+// TableName 指定 legacy 表名。
+func (LegacySchool) TableName() string { return "t_school" }
+
+// LegacySchoolCampus 教学侧 yuansiwei_i.t_school_campus(校区,63 行)。
+type LegacySchoolCampus struct {
+	ID        int32   `gorm:"column:id;primaryKey"`
+	Name      string  `gorm:"column:name"`
+	SchoolID  int32   `gorm:"column:school_id"`
+	Status    int8    `gorm:"column:status"` // tinyint
+	Code      *string `gorm:"column:code"`
+}
+
+// TableName 指定 legacy 表名。
+func (LegacySchoolCampus) TableName() string { return "t_school_campus" }
+
+// LegacyUserSchoolCampus 教务侧 finance_bk.t_user_school_campus(用户-机构-校区绑定,26308 行)。
+type LegacyUserSchoolCampus struct {
+	ID       int32 `gorm:"column:id;primaryKey"`
+	UserID   int32 `gorm:"column:userid"`
+	SchoolID int32 `gorm:"column:schoolid"`
+	CampusID int32 `gorm:"column:campusid"`
+}
+
+// TableName 指定 legacy 表名(finance_bk 库)。
+func (LegacyUserSchoolCampus) TableName() string { return "t_user_school_campus" }
+
+// ---- 数据源接口 ----
+
+// Source 封装 legacy 双库的只读 GORM 连接(教学 + 教务)。
+//
+// 两个 *gorm.DB 分别指向 yuansiwei_i 和 finance_bk(同一 MySQL 实例,不同 database);
+// 若 DSN 未指定 database,用 UseDB 切换。
+type Source struct {
+	teaching *gorm.DB // yuansiwei_i(t_user/t_weixin_user/t_user_account_association/t_school/t_school_campus)
+	finance  *gorm.DB // finance_bk(t_user_school_campus)
+}
+
+// NewSource 创建 legacy 数据源(两个 DSN 指向同一 MySQL 实例的不同库)。
+//
+// 入参:
+//   - teachingDsn string:教学库 DSN(如 "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Local")。
+//   - financeDsn string:教务库 DSN(如 "dev:pass@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Local")。
+//
+// 出参:
+//   - *Source:就绪的只读数据源。
+//   - error:连接失败时返回。
+func NewSource(teachingDsn, financeDsn string) (*Source, error) {
+	var missing []string
+	if teachingDsn == "" {
+		missing = append(missing, "teachingDsn")
+	}
+	if financeDsn == "" {
+		missing = append(missing, "financeDsn")
+	}
+	if len(missing) > 0 {
+		return nil, fmt.Errorf("migrate: legacy DSN 不能为空: %v", missing)
+	}
+	teaching, err := gorm.Open(mysql.Open(teachingDsn), gormMigrateCfg())
+	if err != nil {
+		return nil, fmt.Errorf("migrate: 教学库连接失败: %w", err)
+	}
+	if sqlDB, err := teaching.DB(); err == nil {
+		if err := sqlDB.Ping(); err != nil {
+			_ = sqlDB.Close()
+			return nil, fmt.Errorf("migrate: 教学库 Ping 失败: %w", err)
+		}
+	}
+	finance, err := gorm.Open(mysql.Open(financeDsn), gormMigrateCfg())
+	if err != nil {
+		return nil, fmt.Errorf("migrate: 教务库连接失败: %w", err)
+	}
+	if sqlDB, err := finance.DB(); err == nil {
+		if err := sqlDB.Ping(); err != nil {
+			_ = sqlDB.Close()
+			return nil, fmt.Errorf("migrate: 教务库 Ping 失败: %w", err)
+		}
+	}
+	return &Source{teaching: teaching, finance: finance}, nil
+}
+
+// Close 关闭两个连接池。
+func (s *Source) Close() error {
+	var firstErr error
+	if s.teaching != nil {
+		if sqlDB, err := s.teaching.DB(); err == nil {
+			_ = sqlDB.Close()
+		}
+	}
+	if s.finance != nil {
+		if sqlDB, err := s.finance.DB(); err == nil {
+			_ = sqlDB.Close()
+		} else {
+			firstErr = err
+		}
+	}
+	return firstErr
+}
+
+// ---- 读取方法(全部 SELECT,无写操作)----
+
+// LoadAllUsers 读取 t_user 全量(68107 行,按 id 排序保证幂等处理顺序)。
+func (s *Source) LoadAllUsers(ctx context.Context) ([]LegacyUser, error) {
+	var users []LegacyUser
+	err := s.teaching.WithContext(ctx).Model(&LegacyUser{}).Order("id").Find(&users).Error
+	return users, err
+}
+
+// LoadAllWeixinUsers 读取 t_weixin_user 全量(3491 行)。
+func (s *Source) LoadAllWeixinUsers(ctx context.Context) ([]LegacyWeixinUser, error) {
+	var rows []LegacyWeixinUser
+	err := s.teaching.WithContext(ctx).Model(&LegacyWeixinUser{}).Order("openid").Find(&rows).Error
+	return rows, err
+}
+
+// LoadAllAssociations 读取 t_user_account_association 全量(16077 行,现映射种子)。
+func (s *Source) LoadAllAssociations(ctx context.Context) ([]LegacyAccountAssociation, error) {
+	var rows []LegacyAccountAssociation
+	err := s.teaching.WithContext(ctx).Model(&LegacyAccountAssociation{}).Order("userid").Find(&rows).Error
+	return rows, err
+}
+
+// LoadSchools 读取 t_school 中被 t_user_school_campus 引用的学校(23 所)。
+func (s *Source) LoadSchools(ctx context.Context, schoolIDs []int32) ([]LegacySchool, error) {
+	if len(schoolIDs) == 0 {
+		return nil, nil
+	}
+	var rows []LegacySchool
+	err := s.teaching.WithContext(ctx).Model(&LegacySchool{}).Where("id IN ?", schoolIDs).Find(&rows).Error
+	return rows, err
+}
+
+// LoadCampuses 读取 t_school_campus 全量(63 行)。
+func (s *Source) LoadCampuses(ctx context.Context) ([]LegacySchoolCampus, error) {
+	var rows []LegacySchoolCampus
+	err := s.teaching.WithContext(ctx).Model(&LegacySchoolCampus{}).Order("id").Find(&rows).Error
+	return rows, err
+}
+
+// LoadUserSchoolCampus 读取 finance_bk.t_user_school_campus 全量(26308 行)。
+func (s *Source) LoadUserSchoolCampus(ctx context.Context) ([]LegacyUserSchoolCampus, error) {
+	var rows []LegacyUserSchoolCampus
+	err := s.finance.WithContext(ctx).Model(&LegacyUserSchoolCampus{}).Order("id").Find(&rows).Error
+	return rows, err
+}
+
+// LoadDistinctSchoolIDs 从 t_user_school_campus 提取去重 schoolid(23 所)。
+func (s *Source) LoadDistinctSchoolIDs(ctx context.Context) ([]int32, error) {
+	var ids []int32
+	err := s.finance.WithContext(ctx).Model(&LegacyUserSchoolCampus{}).
+		Distinct().Pluck("schoolid", &ids).Error
+	return ids, err
+}

+ 221 - 0
internal/migrate/verify.go

@@ -0,0 +1,221 @@
+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
+}