3 Коммиты 665e7eb479 ... 38f6ee646b

Автор SHA1 Сообщение Дата
  Q 38f6ee646b test: T8 测试基线固化(credential/id/scope 单测 + CGO 端口性) 2 дней назад
  Q e579b3e4b2 fix(migrate): t_legacy_mapping 补 legacy_teaching_user_id(P1-05 §2 三源语义拍定) 2 дней назад
  Q c754c2161f feat(migrate): P1-04/T5 数据迁移工具(幂等 upsert + 事务 + 置信分级) 2 дней назад

+ 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)
+	}
+}

+ 8 - 0
go.mod

@@ -4,6 +4,7 @@ go 1.27
 
 require (
 	github.com/alicebob/miniredis/v2 v2.34.0
+	github.com/glebarez/sqlite v1.11.0
 	github.com/go-playground/validator/v10 v10.27.0
 	github.com/gofiber/fiber/v2 v2.52.16
 	github.com/golang-jwt/jwt/v5 v5.3.1
@@ -21,7 +22,9 @@ require (
 	github.com/andybalholm/brotli v1.1.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+	github.com/glebarez/go-sqlite v1.21.2 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-sql-driver/mysql v1.8.1 // indirect
@@ -33,6 +36,7 @@ require (
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-runewidth v0.0.16 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasthttp v1.51.0 // indirect
@@ -42,4 +46,8 @@ require (
 	golang.org/x/net v0.34.0 // indirect
 	golang.org/x/sys v0.30.0 // indirect
 	golang.org/x/text v0.22.0 // indirect
+	modernc.org/libc v1.22.5 // indirect
+	modernc.org/mathutil v1.5.0 // indirect
+	modernc.org/memory v1.5.0 // indirect
+	modernc.org/sqlite v1.23.1 // indirect
 )

+ 19 - 0
go.sum

@@ -16,8 +16,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
 github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
+github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
+github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
+github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -32,6 +38,8 @@ github.com/gofiber/fiber/v2 v2.52.16 h1:lBemN7sjeTPwEdK/9AIAQ3YpYpzEZefiWI/vQT/6
 github.com/gofiber/fiber/v2 v2.52.16/go.mod h1:dFf1PKExrrSfg/hvoP2yXL35W3bGlzZf71KX+IYzkpg=
 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -57,6 +65,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
 github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
+github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
@@ -95,3 +106,11 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
 gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
 gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
 gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
+modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
+modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
+modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
+modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
+modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=

+ 112 - 0
internal/identity/credential/credential_test.go

@@ -0,0 +1,112 @@
+package credential
+
+import (
+	"context"
+	"crypto/md5"
+	"crypto/sha1"
+	"encoding/hex"
+	"strings"
+	"testing"
+)
+
+func md5hex(s string) string { sum := md5.Sum([]byte(s)); return hex.EncodeToString(sum[:]) }
+func sha1hex(s string) string { sum := sha1.Sum([]byte(s)); return hex.EncodeToString(sum[:]) }
+
+// TestHashArgon2RoundTrip 验证 argon2 哈希往返(新注册/改密/惰性升级入口)。
+func TestHashArgon2RoundTrip(t *testing.T) {
+	enc, err := HashArgon2("X#9f2k")
+	if err != nil {
+		t.Fatalf("HashArgon2 失败: %v", err)
+	}
+	if !strings.HasPrefix(enc, "argon2id$v=19$") {
+		t.Errorf("argon2 编码串应前缀 argon2id$v=19$,得 %q", enc)
+	}
+	if !VerifyArgon2(enc, "X#9f2k") {
+		t.Errorf("正确密码应匹配")
+	}
+	if VerifyArgon2(enc, "wrong") {
+		t.Errorf("错误密码应不匹配")
+	}
+}
+
+// TestCheckMD5Salt 验证教务旧算法 md5(password+salt) 路由 + 惰性升级标记。
+func TestCheckMD5Salt(t *testing.T) {
+	const pw, salt = "P@ssw0rd", "aBcDeF"
+	stored := md5hex(pw + salt)
+	res, err := Check(context.Background(), pw, stored, salt, HashVersionMD5Salt)
+	if err != nil {
+		t.Fatalf("Check 意外错误: %v", err)
+	}
+	if !res.Match || !res.NeedsUpgrade {
+		t.Errorf("md5_salt 命中应 Match=true 且 NeedsUpgrade=true,得 %+v", res)
+	}
+	// 错误密码
+	res2, _ := Check(context.Background(), "nope", stored, salt, HashVersionMD5Salt)
+	if res2.Match {
+		t.Errorf("md5 错误密码应不匹配")
+	}
+}
+
+// TestCheckShASalt 验证教学旧算法 sha1(password+salt) 路由。
+func TestCheckShASalt(t *testing.T) {
+	const pw, salt = "teach123", "sAlT12"
+	stored := sha1hex(pw + salt)
+	res, err := Check(context.Background(), pw, stored, salt, HashVersionShASalt)
+	if err != nil {
+		t.Fatalf("Check 意外错误: %v", err)
+	}
+	if !res.Match || !res.NeedsUpgrade {
+		t.Errorf("sha_salt 命中应 Match=true 且 NeedsUpgrade=true,得 %+v", res)
+	}
+}
+
+// TestCheckArgon2 验证目标算法 argon2 路由(命中不触发升级)。
+func TestCheckArgon2(t *testing.T) {
+	enc, _ := HashArgon2("argon-pw")
+	res, err := Check(context.Background(), "argon-pw", enc, "", HashVersionArgon2)
+	if err != nil {
+		t.Fatalf("Check 意外错误: %v", err)
+	}
+	if !res.Match || res.NeedsUpgrade {
+		t.Errorf("argon2 命中应 Match=true 且 NeedsUpgrade=false(已是终态),得 %+v", res)
+	}
+}
+
+// TestCheckHexCaseFold 验证旧 hex 大写 storedHash 折叠比较(历史脚本大小写差异,数据保真例外)。
+func TestCheckHexCaseFold(t *testing.T) {
+	const pw, salt = "fold123", "zz"
+	storedUpper := strings.ToUpper(md5hex(pw + salt))
+	res, err := Check(context.Background(), pw, storedUpper, salt, HashVersionMD5Salt)
+	if err != nil {
+		t.Fatalf("Check 意外错误: %v", err)
+	}
+	if !res.Match {
+		t.Errorf("大写 hex storedHash 应折叠命中,得 Match=%v", res.Match)
+	}
+}
+
+// TestCheckUnknownVersion 验证未知 hash_version 拒绝(非密码错误,走告警分支)。
+func TestCheckUnknownVersion(t *testing.T) {
+	res, err := Check(context.Background(), "pw", "abc", "", "weird_version")
+	if err == nil {
+		t.Fatalf("未知 version 应报错")
+	}
+	if !strings.Contains(err.Error(), "weird_version") {
+		t.Errorf("错误应含实际 version 值,得 %q", err.Error())
+	}
+	if res.Match {
+		t.Errorf("未知 version 不应 Match")
+	}
+	_ = ErrUnknownVersion
+}
+
+// TestCheckEmptyHash 验证空密码哈希(账号无凭证,需运维重建)。
+func TestCheckEmptyHash(t *testing.T) {
+	_, err := Check(context.Background(), "pw", "", "salt", HashVersionMD5Salt)
+	if err == nil {
+		t.Fatalf("空 storedHash 应报错")
+	}
+	if !strings.Contains(err.Error(), "无密码哈希") {
+		t.Errorf("错误应提示无密码哈希,得 %q", err.Error())
+	}
+}

+ 80 - 0
internal/identity/id/id_test.go

@@ -0,0 +1,80 @@
+package id
+
+import (
+	"strings"
+	"testing"
+	"time"
+)
+
+// TestNewCanonicalUserID 验证 ULID 生成基本性质(26 位、Crockford、可解码)。
+func TestNewCanonicalUserID(t *testing.T) {
+	uid, err := NewCanonicalUserID()
+	if err != nil {
+		t.Fatalf("NewCanonicalUserID 失败: %v", err)
+	}
+	if len(uid) != Length {
+		t.Fatalf("ULID 长度应为 %d,得 %d(%q)", Length, len(uid), uid)
+	}
+	if !Validate(uid) {
+		t.Fatalf("生成的 ULID 应通过 Validate:%q", uid)
+	}
+}
+
+// TestNewCanonicalUserIDAt 验证指定时间戳 + 时间有序性(前 48bit 毫秒时间戳)。
+func TestNewCanonicalUserIDAt(t *testing.T) {
+	base := time.Date(2026, 11, 7, 10, 0, 0, 0, time.UTC)
+	uidA, err := NewCanonicalUserIDAt(base)
+	if err != nil {
+		t.Fatalf("NewCanonicalUserIDAt 失败: %v", err)
+	}
+	uidB, err := NewCanonicalUserIDAt(base.Add(2 * time.Second))
+	if err != nil {
+		t.Fatalf("NewCanonicalUserIDAt(+2s) 失败: %v", err)
+	}
+	// 时间戳后移 → ULID 字典序应更大(前 48bit 时间戳递增)
+	if !(uidA < uidB) {
+		t.Errorf("时间有序的 ULID 应满足 uidA(%s) < uidB(%s)", uidA, uidB)
+	}
+}
+
+// TestValidate 校验合法/非法 ULID。
+func TestValidate(t *testing.T) {
+	uid, _ := NewCanonicalUserID()
+	cases := []struct {
+		in   string
+		want bool
+	}{
+		{uid, true},                                  // 合法(新生成)
+		{strings.ToLower(uid), false},                // 小写非法(统一大写)
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4T", true},         // 标准样例
+		{"abc", false},                               // 长度不符
+		{"", false},                                  // 空
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4I", false},        // 含 I(Crockford 剔除)
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4L", false},        // 含 L
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4O", false},        // 含 O
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4U", false},        // 含 U
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4T0", false},       // 27 位(长度)
+	}
+	for i, c := range cases {
+		if got := Validate(c.in); got != c.want {
+			t.Errorf("case %d: Validate(%q)=%v, want %v", i, c.in, got, c.want)
+		}
+	}
+}
+
+// TestNewCanonicalUserIDUniqueness 验证并发/批量生成的唯一性(80bit 随机熵)。
+func TestNewCanonicalUserIDUniqueness(t *testing.T) {
+	seen := make(map[string]struct{}, 1000)
+	// 同一毫秒内批量生成(随机部分保证唯一)
+	fixed := time.Date(2026, 11, 7, 8, 0, 0, 0, time.UTC)
+	for i := 0; i < 1000; i++ {
+		uid, err := NewCanonicalUserIDAt(fixed)
+		if err != nil {
+			t.Fatalf("第 %d 次生成失败: %v", i, err)
+		}
+		if _, dup := seen[uid]; dup {
+			t.Fatalf("第 %d 次生成重复 ULID:%s(随机熵不足)", i, uid)
+		}
+		seen[uid] = struct{}{}
+	}
+}

+ 145 - 0
internal/infra/scope/scope_test.go

@@ -0,0 +1,145 @@
+package scope
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gofiber/fiber/v2"
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+)
+
+// testCourse 带 institution_id/campus_id 列的 fixture model(scope 包不依赖业务域,
+// 测试用本地 model 承载两列,DryRun 捕获 SQL 验证 scoping 注入)。
+type testCourse struct {
+	ID            int64  `gorm:"column:id;primaryKey"`
+	InstitutionID int64  `gorm:"column:institution_id"`
+	CampusID      int64  `gorm:"column:campus_id"`
+	Name          string `gorm:"column:name"`
+}
+
+func (testCourse) TableName() string { return "t_course" }
+
+// newDryRunDB 建 GORM DryRun 句柄(仅构造 SQL,不真正读写数据)。
+//
+// 选用 glebarez/sqlite(纯 Go,基于 modernc.org/sqlite,**无 CGO**)内存库:
+//   - mysql 驱动在 gorm.Open 时会 ping TCP 建连,DryRun 也跳不过该初始化握手;
+//   - 纯 Go SQLite `:memory:` 进程内运行,无网络/外部依赖,且 **CGO_ENABLED=0 下可编译可运行**
+//     (对比 gorm.io/driver/sqlite 依赖 mattn/go-sqlite3,CGO_ENABLED=0 时是 stub 会失败);
+//   - DryRun 渲染的 WHERE 片段(institution_id/campus_id 过滤)与方言无关,断言逻辑不受影响。
+// 故本包单测在 CI/容器(常 CGO_ENABLED=0)亦稳定通过。
+func newDryRunDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	db, err := gorm.Open(gormsqlite.Open(":memory:"), &gorm.Config{DryRun: true})
+	if err != nil {
+		t.Fatalf("gorm.Open 失败: %v", err)
+	}
+	return db
+}
+
+// sqlOf 执行 DryRun Find,返回生成的 SQL 字符串。
+func sqlOf(t *testing.T, db *gorm.DB, q *gorm.DB) string {
+	t.Helper()
+	var out []testCourse
+	if err := q.Model(&testCourse{}).Find(&out).Error; err != nil {
+		t.Fatalf("DryRun Find 失败: %v", err)
+	}
+	return q.Statement.SQL.String()
+}
+
+// TestScopingNormalCampusScoped 普通用户 + 校区级表 → 强制 institution_id + campus_id。
+func TestScopingNormalCampusScoped(t *testing.T) {
+	db := newDryRunDB(t)
+	base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 7}, CampusScoped: true}
+	q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
+	sql := sqlOf(t, db, q)
+	if !strings.Contains(sql, "institution_id = ?") {
+		t.Errorf("应含 institution_id 过滤,SQL=%q", sql)
+	}
+	if !strings.Contains(sql, "campus_id = ?") {
+		t.Errorf("CampusScoped=true 且 campus>0 应含 campus_id 过滤,SQL=%q", sql)
+	}
+}
+
+// TestScopingInstitutionOnly 机构级资源(CampusScoped=false)→ 仅 institution_id,不加 campus_id。
+func TestScopingInstitutionOnly(t *testing.T) {
+	db := newDryRunDB(t)
+	base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 7}, CampusScoped: false}
+	q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
+	sql := sqlOf(t, db, q)
+	if !strings.Contains(sql, "institution_id = ?") {
+		t.Errorf("应含 institution_id 过滤,SQL=%q", sql)
+	}
+	if strings.Contains(sql, "campus_id = ?") {
+		t.Errorf("CampusScoped=false 不应含 campus_id 过滤(表无此列,加会 SQL 报错),SQL=%q", sql)
+	}
+}
+
+// TestScopingSuperAdmin 平台超管 → 不加任何作用域过滤(跨机构)。
+func TestScopingSuperAdmin(t *testing.T) {
+	db := newDryRunDB(t)
+	base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 7, SuperAdmin: true}, CampusScoped: true}
+	q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
+	sql := sqlOf(t, db, q)
+	if strings.Contains(sql, "institution_id = ?") || strings.Contains(sql, "campus_id = ?") {
+		t.Errorf("超管不应加作用域过滤,SQL=%q", sql)
+	}
+}
+
+// TestScopingNilScopePanics 无作用域上下文进入数据访问层 → panic(fail-fast 纵深防御)。
+func TestScopingNilScopePanics(t *testing.T) {
+	db := newDryRunDB(t)
+	base := BaseRepo{DB: db, Sc: nil, CampusScoped: true}
+	defer func() {
+		if r := recover(); r == nil {
+			t.Errorf("Sc=nil 应 panic(拒绝无作用域裸查),但正常返回了")
+		}
+	}()
+	_ = base.Scoping(db)
+}
+
+// TestScopingZeroCampus 普通用户 + CampusID=0(机构级视角,不限校区)→ 仅 institution_id。
+func TestScopingZeroCampus(t *testing.T) {
+	db := newDryRunDB(t)
+	base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 0}, CampusScoped: true}
+	q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
+	sql := sqlOf(t, db, q)
+	if !strings.Contains(sql, "institution_id = ?") {
+		t.Errorf("应含 institution_id 过滤,SQL=%q", sql)
+	}
+	if strings.Contains(sql, "campus_id = ?") {
+		t.Errorf("campus_id=0 不应加校区过滤(0=不限),SQL=%q", sql)
+	}
+}
+
+// TestSetFromFiber 验证作用域写入/读取 fiber.Ctx 局部值往返(真实 *fiber.Ctx,经 app.Test)。
+func TestSetFromFiber(t *testing.T) {
+	// nil 防御
+	if got := FromFiber(nil); got != nil {
+		t.Errorf("FromFiber(nil) 应返回 nil,得 %+v", got)
+	}
+
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	app.Get("/echo", func(c *fiber.Ctx) error {
+		sc := &Scope{InstitutionID: 9, CampusID: 3, UserID: "01ABC", Role: "teacher", SuperAdmin: false}
+		Set(c, sc) // 写入
+		got := FromFiber(c) // 读回
+		if got == nil {
+			return c.Status(fiber.StatusInternalServerError).SendString("FromFiber returned nil")
+		}
+		if got.InstitutionID != 9 || got.CampusID != 3 || got.UserID != "01ABC" || got.Role != "teacher" || got.SuperAdmin {
+			return c.Status(fiber.StatusInternalServerError).SendString("scope roundtrip mismatch")
+		}
+		return c.SendString("ok")
+	})
+	req := httptest.NewRequest(http.MethodGet, "/echo", nil)
+	resp, err := app.Test(req, -1)
+	if err != nil {
+		t.Fatalf("app.Test 失败: %v", err)
+	}
+	if resp.StatusCode != fiber.StatusOK {
+		t.Fatalf("roundtrip 状态码=%d(期望 200)", resp.StatusCode)
+	}
+}

+ 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
+}

+ 774 - 0
internal/migrate/runner.go

@@ -0,0 +1,774 @@
+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),
+			})
+		}
+	}
+
+	// 种子映射(userid → 阅卷/标记系统 id),供 t_legacy_mapping.legacy_teaching_user_id(P1-05 §2 拍定)。
+	teachingByUserID := make(map[int32]int32, len(assoc))
+	for i := range assoc {
+		if assoc[i].UserID > 0 && assoc[i].MarkingSystemUserID != nil && *assoc[i].MarkingSystemUserID > 0 {
+			if _, exists := teachingByUserID[assoc[i].UserID]; !exists {
+				teachingByUserID[assoc[i].UserID] = *assoc[i].MarkingSystemUserID
+			}
+		}
+	}
+
+	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, teachingByUserID, 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 时天然存在则跳过)。
+//
+// 三源 user_id(P1-05 §2 拍定):
+//   - LegacyIUserID        = 旧A yuansiwei_i.t_user.id(本地主源,恒有)
+//   - 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 // 已存在(重跑幂等)
+	}
+	legacyID := u.ID
+	role := mapRoleCode(u.Role)
+	lm := &identitymodel.LegacyMapping{
+		CanonicalUserID: canon,
+		LegacyIUserID:   &legacyID, // 旧A yuansiwei_i.t_user.id
+		InstitutionID:   0,         // 平台级(canonical 跨机构;代表机构非身份属性,P1-05 §6 拍定)
+		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,
+	}
+	// 阅卷/标记系统 id(种子用户才有;P1-05 §2:marking_system_userid → legacy_teaching_user_id)
+	if markingID, ok := teachingByUserID[u.ID]; ok {
+		lm.LegacyTeachingUserID = &markingID
+	}
+	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
+}