Selaa lähdekoodia

feat(ac15): 第三批 org 域映射(机构+校区,双 D1 + 引用完整性);实测机构是 7 个不是 5 个

一、范围修正:5 → 7 个机构(原统计漏了 2 个,各自都带孤儿引用)
- 63332(用户注册,campus 57)← finance_bk.t_user_balance_request 1 行
- 63330(弘毅教育,campus 42)← finance_bk.t_courses_classify 1 行
  ★ t_courses_classify 不在原统计的 7 个来源清单里,是靠"扫所有带 schoolid 列的表"找出来的
- 只迁 5 个的实测后果:2 个来源各留 1 条孤儿机构 + 1 条孤儿校区(M1 变异已复现)
- 最终集合 1,63317,63323,63324,63325,63330,63332 = 7 机构 / 29 校区,孤儿引用 = 0

二、口径
- 双 D1:t_school.id → t_institution.institution_id、t_school_campus.id → t_campus.campus_id
- R17:campus 1/2 的 institution_id 如实保留 school_id=1,不修正;数据里
  (schoolid=63317, campusid=1/2) 只标 cross-org-campus(报告里逐条列出 12 组)
- campus_id=0 不建行(机构级约定值);964+1+86 行 campus_id=0 已单独统计、不计入孤儿
- status 是值域翻译不是占位:tinyint 1→active / 0→disabled,1/0 之外直接报错
- institution_code / address / phone:legacy 无对应列 → NULL(不编造);省市区 id 产生告警
- created_at / updated_at:legacy 无时间列 → epoch 哨兵(与 t_user_divide_into 同口径)
- self_support:bit(1) 用 `self_support+0` 读(直接 Scan 会拿到 '0'/'1' 的 ASCII 48/49)

三、验收
- 逐列:t_institution 7 行 × 8 列、t_campus 29 行 × 10 列,全部相等
- 机构集合断言:精确等于 {1,63317,63323,63324,63325,63330,63332}(不多不少)
- 引用完整性:8 个来源逐个统计,孤儿机构 0 行、孤儿校区 0 行
- R17 断言:campus 1/2 institution_id=1 且 t_institution(1) 存在
- campus_id=0 断言:t_campus 里 0 行
- 幂等:7→7、29→29,id 集合不变
- 变异:M1 漏迁 63330 → 孤儿断言红(t_courses_classify 孤儿机构 1 行 [63330])
        M2 campus 1/2 改成 63317 → institution_id 27/29 + R17 断言红
- 清理:t_campus 29 行、t_institution 7 行删除,两表回 0 行

四、代码
- batch3_model/mapping/io/verify/run + batch3_test(6 个单测全绿)
- config.go:allowedDatabases 增加只读源 yuansiwei_i / yuansiwei,新增 OpenOrgSource
  (连接后 SET SESSION TRANSACTION READ ONLY)
- CLI:map3 / verify3 / idem3 / run3 / clean3 / count3
- README:新增 §3.5(7 个机构、为什么不是 5 个)、§11.5(怎么跑)

只写 yuansiwei_2026;finance_bk / yuansiwei_i 全程只读;未 DROP 任何表。
Q 5 tuntia sitten
vanhempi
sitoutus
9fc57999ed

+ 149 - 0
cmd/ac15fixture/main.go

@@ -38,6 +38,7 @@ import (
 	"flag"
 	"fmt"
 	"os"
+	"strconv"
 	"strings"
 
 	"yuansiwei/unified-backend/internal/academic/dualrun/fixture"
@@ -77,6 +78,18 @@ func main() {
 		os.Exit(runCount2(args))
 	case "align":
 		os.Exit(runAlign(args))
+	case "map3":
+		os.Exit(runMap3(args))
+	case "verify3":
+		os.Exit(runVerify3(args))
+	case "idem3":
+		os.Exit(runIdem3(args))
+	case "run3":
+		os.Exit(runRun3(args))
+	case "clean3":
+		os.Exit(runClean3(args))
+	case "count3":
+		os.Exit(runCount3(args))
 	case "-h", "--help", "help":
 		usage()
 		os.Exit(0)
@@ -106,6 +119,15 @@ func usage() {
   clean2    清理并复核 7 张表回 0 行
   count2    打印第二批涉及表当前行数
 
+第三批(org 域:t_institution / t_campus,D1 + 引用完整性):
+  map3      迁移指定机构与其校区(默认 1,63317,63323,63324,63325,63332)
+  verify3   逐列核对 + 机构集合 + 引用完整性(孤儿=0)+ R17 + bit(1)
+  idem3     幂等实测(跑两次比行数与 id)
+  run3      map3 + verify3 + idem3
+  clean3    清理并复核两表回 0 行
+  count3    打印 org 两表行数
+  参数:--ids 1,63317,...   --all-campuses=true
+
 结构对齐(created_at / updated_at 补 DEFAULT,只 MODIFY 不重建):
   align             逐表比对 DDL 与实库,打印 ALTER 清单 + dry-run(不执行)
   align --apply     清单确认后执行
@@ -452,6 +474,133 @@ func parseColumns(s string) ([]string, error) {
 	return out, nil
 }
 
+// ---------- 第三批 ----------
+
+func bootstrap3(args []string) (fixture.Config, fixture.OrgOptions, int) {
+	def := fixture.DefaultOrgOptions()
+	fs := flag.NewFlagSet("ac15fixture-b3", flag.ContinueOnError)
+	fs.SetOutput(os.Stderr)
+	ids := fs.String("ids", "", "逗号分隔的机构 id(留空=默认 6 个)")
+	allCampuses := fs.Bool("all-campuses", def.WithAllCampuses, "迁移所选机构的全部校区(否则只迁数据里出现过的)")
+	orgDB := fs.String("org-db", def.OrgSourceDB, "org 源库(默认 yuansiwei_i)")
+	if err := fs.Parse(args); err != nil {
+		return fixture.Config{}, fixture.OrgOptions{}, 2
+	}
+	opts := def
+	if strings.TrimSpace(*ids) != "" {
+		opts.InstitutionIDs = nil
+		for _, p := range strings.Split(*ids, ",") {
+			p = strings.TrimSpace(p)
+			if p == "" {
+				continue
+			}
+			v, err := strconv.ParseInt(p, 10, 64)
+			if err != nil {
+				fmt.Fprintf(os.Stderr, "FATAL: 非法机构 id %q\n", p)
+				return fixture.Config{}, fixture.OrgOptions{}, 2
+			}
+			opts.InstitutionIDs = append(opts.InstitutionIDs, v)
+		}
+	}
+	opts.WithAllCampuses = *allCampuses
+	opts.OrgSourceDB = *orgDB
+
+	cfg, code := loadConfig()
+	if code != 0 {
+		return fixture.Config{}, fixture.OrgOptions{}, code
+	}
+	fmt.Printf("org 源=%s\n", cfg.OrgMasked())
+	return cfg, opts, 0
+}
+
+func runMap3(args []string) int {
+	cfg, opts, code := bootstrap3(args)
+	if code != 0 {
+		return code
+	}
+	rep, err := fixture.PrepareOrg(context.Background(), cfg, opts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	fixture.PrintOrgReport(os.Stdout, rep)
+	return 0
+}
+
+func runVerify3(args []string) int {
+	cfg, opts, code := bootstrap3(args)
+	if code != 0 {
+		return code
+	}
+	rep, err := fixture.VerifyOrg(context.Background(), cfg, opts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	fixture.PrintOrgVerify(os.Stdout, rep)
+	if !rep.OK() {
+		return 1
+	}
+	return 0
+}
+
+func runIdem3(args []string) int {
+	cfg, opts, code := bootstrap3(args)
+	if code != 0 {
+		return code
+	}
+	rep, err := fixture.IdempotencyCheckOrg(context.Background(), cfg, opts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	fixture.PrintOrgIdem(os.Stdout, rep)
+	if !rep.AllOK() {
+		return 1
+	}
+	return 0
+}
+
+func runRun3(args []string) int {
+	if code := runMap3(args); code != 0 {
+		return code
+	}
+	fmt.Println()
+	if code := runVerify3(args); code != 0 {
+		return code
+	}
+	fmt.Println()
+	return runIdem3(args)
+}
+
+func runClean3(args []string) int {
+	cfg, opts, code := bootstrap3(args)
+	if code != 0 {
+		return code
+	}
+	rep, err := fixture.CleanupOrg(context.Background(), cfg, opts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	fixture.PrintOrgClean(os.Stdout, rep)
+	return 0
+}
+
+func runCount3(args []string) int {
+	cfg, _, code := bootstrap3(args)
+	if code != 0 {
+		return code
+	}
+	counts, err := fixture.CountOrgTables(context.Background(), cfg)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	fixture.PrintCounts(os.Stdout, "第三批 org 表行数", counts, []string{"t_institution", "t_campus"})
+	return 0
+}
+
 func runCount2(args []string) int {
 	cfg, _, code := bootstrap2(args)
 	if code != 0 {

+ 34 - 1
internal/academic/dualrun/fixture/README.md

@@ -4,12 +4,35 @@
 
 它是「阶段 2 双跑」的**前置件**——先证明「结构 + 映射 + 取整口径」这条链能跑通,再去比 8 个写 SP 的产出。
 
-已落地两批(主键口径各不相同,这是设计里最容易出错的地方):
+已落地三批(主键口径各不相同,这是设计里最容易出错的地方):
 
 | 批 | 表 | 口径 |
 |---|---|---|
 | 一 | `t_user_expense` / `_school` / `_log` + `t_user` / `t_legacy_mapping` | 自然键 + **D1**(显式插 legacy id) |
 | 二 | `t_user_divide_into` / `_log` / `t_user_balance_request` / `t_expenditure_log`(+ 字典 `t_expenditure`) | 业务键 + **D1** + **biz_no**(旧 UUID 原样保留) |
+| 三 | `t_institution` / `t_campus`(org 域) | **双 D1**(`t_school.id`→`institution_id`、`t_school_campus.id`→`campus_id`)+ **引用完整性** |
+
+## 3.5 org 域(第三批):7 个机构,不是 5 个
+
+初版统计给的是 **5 个机构**(`SELECT DISTINCT schoolid FROM t_courses` 得 4 个 + campus 1/2 的 `school_id=1`)。
+按列名逐来源清点后发现**漏了 2 个**,各自都带孤儿引用:
+
+| 漏掉的机构 | 被谁引用 | 行数 |
+|---|---|---|
+| **63332**(用户注册,campus 57) | `t_user_balance_request` | 1 行(schoolid=63332, campusid=57) |
+| **63330**(弘毅教育,campus 42) | **`t_courses_classify`** | 1 行(schoolid=63330) |
+
+`t_courses_classify` 甚至不在原统计的 7 个来源清单里——它是靠"扫所有带 `schoolid` 列的表"找出来的。
+只迁 5 个的实测后果:**2 个来源各留 1 条孤儿机构 + 1 条孤儿校区**(验证见报告 M1 变异)。
+
+最终集合 = `1, 63317, 63323, 63324, 63325, 63330, 63332`(7 个机构 / 29 个校区),孤儿引用 = 0。
+
+三条 org 域口径:
+- **R17**:campus 1/2 的 `institution_id` 如实保留 `school_id=1`,**不修正、不反推、不特判**;
+  数据里 `(schoolid=63317, campusid=1/2)` 的组合只在报告里标 `cross-org-campus`。
+- **`campus_id=0` 不建行**:0 是"机构级/未分配"的约定值,建了会让前端校区下拉框多出一个"未知校区"。
+- **status 是值域翻译不是占位**:`tinyint 1→active / 0→disabled`,出现 1/0 之外的值直接**报错**,
+  绝不默认 active(那会把已停用的机构/校区悄悄"复活")。
 
 | 项 | 范围 |
 |---|---|
@@ -226,6 +249,16 @@ go run ./cmd/ac15fixture align --verify   # 只复核两列 COLUMN_DEFAULT 是
 4. 每条 ALTER 先在**会话临时表**上 dry-run(`CREATE TEMPORARY TABLE ... LIKE` + 同一句 MODIFY),
    语法或属性不合法的语句在执行前就暴露。
 
+## 11.5 第三批怎么跑
+
+```bash
+go run ./cmd/ac15fixture map3      # 默认 7 个机构、29 个校区
+go run ./cmd/ac15fixture verify3   # 逐列 + 机构集合 + 引用完整性(8 个来源逐个统计孤儿)+ R17 + bit(1)
+go run ./cmd/ac15fixture idem3     # 跑两次比行数与 id 集合
+go run ./cmd/ac15fixture clean3    # 两表回 0 行
+```
+参数:`--ids 1,63317,...`(显式指定机构集合)、`--all-campuses=false`(只迁数据里出现过的校区)。
+
 ## 12. `create_time` 的静默错:为什么必须配断言
 
 补了 `DEFAULT CURRENT_TIMESTAMP(3)` 之后,`create_time` 的风险等级**反而变高**:

+ 194 - 0
internal/academic/dualrun/fixture/batch3_io.go

@@ -0,0 +1,194 @@
+package fixture
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"strings"
+)
+
+// 本文件是第三批的读写层:从 `yuansiwei_i` 只读取数 → 写入 `yuansiwei_2026`。
+//
+// 取样纪律:`t_school` 有 61905 行,**只按 id 列表取 6 行**,绝不 `SELECT *` 全表。
+
+// extractSchools 按 id 列表取机构(只取这几个 id)。
+func extractSchools(ctx context.Context, db *sql.DB, ids []int64) ([]LegacySchool, error) {
+	if len(ids) == 0 {
+		return nil, fmt.Errorf("institution id 列表为空")
+	}
+	ph := placeholders(len(ids))
+	args := make([]any, len(ids))
+	for i, v := range ids {
+		args[i] = v
+	}
+	q := fmt.Sprintf(`
+SELECT id, name, province_id, city_id, district_id, grade_period, status, ifnull(is_open_user_login,0)
+  FROM t_school WHERE id IN (%s) ORDER BY id`, ph)
+	rows, err := db.QueryContext(ctx, q, args...)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []LegacySchool
+	for rows.Next() {
+		var r LegacySchool
+		if err := rows.Scan(&r.ID, &r.Name, &r.ProvinceID, &r.CityID, &r.DistrictID,
+			&r.GradePeriod, &r.Status, &r.IsOpenUserLogin); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+// extractCampuses 取所选机构下的校区。
+//
+// self_support 是 `bit(1)`:go-sql-driver 会把它当 []byte(0x00/0x01)返回,
+// 直接 Scan 进 int8 会得到 48/49('0'/'1' 的 ASCII)——这里显式 `+0` 让 MySQL 先转成整数。
+func extractCampuses(ctx context.Context, db *sql.DB, schoolIDs []int64) ([]LegacyCampus, error) {
+	if len(schoolIDs) == 0 {
+		return nil, fmt.Errorf("school id 列表为空")
+	}
+	ph := placeholders(len(schoolIDs))
+	args := make([]any, len(schoolIDs))
+	for i, v := range schoolIDs {
+		args[i] = v
+	}
+	q := fmt.Sprintf(`
+SELECT id, name, school_id, province_id, city_id, district_id, status, code,
+       self_support+0, ifnull(is_open_user_login,0)
+  FROM t_school_campus WHERE school_id IN (%s) ORDER BY school_id, id`, ph)
+	rows, err := db.QueryContext(ctx, q, args...)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []LegacyCampus
+	for rows.Next() {
+		var r LegacyCampus
+		if err := rows.Scan(&r.ID, &r.Name, &r.SchoolID, &r.ProvinceID, &r.CityID, &r.DistrictID,
+			&r.Status, &r.Code, &r.SelfSupport, &r.IsOpenUserLogin); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+// extractReferencedCampuses 取「数据里真正出现过的校区 id」集合(用于 WithAllCampuses=false)。
+func extractReferencedCampuses(ctx context.Context, db *sql.DB) (map[int64]bool, error) {
+	out := map[int64]bool{}
+	for _, q := range refCampusQueries() {
+		rows, err := db.QueryContext(ctx, q)
+		if err != nil {
+			return nil, err
+		}
+		for rows.Next() {
+			var id sql.NullInt64
+			if err := rows.Scan(&id); err != nil {
+				rows.Close()
+				return nil, err
+			}
+			if id.Valid && id.Int64 != 0 {
+				out[id.Int64] = true
+			}
+		}
+		rows.Close()
+	}
+	return out, nil
+}
+
+// refCampusQueries 返回各来源的 distinct campusid 查询(只取非 0)。
+func refCampusQueries() []string {
+	var out []string
+	for _, s := range orgRefSources() {
+		if s.CampusCol == "" {
+			continue
+		}
+		out = append(out, fmt.Sprintf(
+			"SELECT DISTINCT %s FROM `%s` WHERE %s <> 0", s.CampusCol, s.Table, s.CampusCol))
+	}
+	return out
+}
+
+// orgRefSources 是「带机构/校区引用的数据来源」清单(验收 §3 要逐个来源统计孤儿)。
+func orgRefSources() []RefSource {
+	return []RefSource{
+		{Table: "t_courses", InstCol: "schoolid", CampusCol: "campusid", Note: "课程"},
+		{Table: "t_courses_class", InstCol: "schoolid", CampusCol: "campusid", Note: "班次"},
+		{Table: "t_courses_record", InstCol: "schoolid", CampusCol: "campusid", Note: "上课记录"},
+		{Table: "t_courses_activity", InstCol: "schoolid", CampusCol: "campusid", Note: "活动"},
+		{Table: "t_user_expense_school", InstCol: "schoolid", CampusCol: "campusid", Note: "校区账户"},
+		{Table: "t_expenditure_log", InstCol: "schoolid", CampusCol: "campusid", Note: "收支记录"},
+		{Table: "t_user_balance_request", InstCol: "schoolid", CampusCol: "campusid", Note: "充值工单"},
+		// 课程分类只有机构列
+		{Table: "t_courses_classify", InstCol: "schoolid", CampusCol: "", Note: "课程分类(无校区列)"},
+	}
+}
+
+// ---------- 写入(D1) ----------
+
+// upsertInstitution 写机构(D1:显式插 institution_id)。
+func upsertInstitution(ctx context.Context, db *sql.DB, r TargetInstitution) error {
+	const q = `
+INSERT INTO t_institution
+  (institution_id, institution_name, institution_code, address, phone, status, created_at, updated_at)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ON DUPLICATE KEY UPDATE
+  institution_name = VALUES(institution_name),
+  institution_code = VALUES(institution_code),
+  address          = VALUES(address),
+  phone            = VALUES(phone),
+  status           = VALUES(status)`
+	_, err := db.ExecContext(ctx, q, r.InstitutionID, r.InstitutionName,
+		nullStr(r.InstitutionCode), nullStr(r.Address), nullStr(r.Phone), r.Status,
+		r.CreatedAt.Format("2006-01-02 15:04:05"), r.UpdatedAt.Format("2006-01-02 15:04:05"))
+	if err != nil {
+		return fmt.Errorf("写 t_institution(id=%d) 失败:%w", r.InstitutionID, err)
+	}
+	return nil
+}
+
+// upsertCampus 写校区(D1:显式插 campus_id)。
+func upsertCampus(ctx context.Context, db *sql.DB, r TargetCampus) error {
+	const q = `
+INSERT INTO t_campus
+  (campus_id, institution_id, campus_name, campus_code, address, phone,
+   status, self_support, created_at, updated_at)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ON DUPLICATE KEY UPDATE
+  institution_id = VALUES(institution_id),
+  campus_name    = VALUES(campus_name),
+  campus_code    = VALUES(campus_code),
+  address        = VALUES(address),
+  phone          = VALUES(phone),
+  status         = VALUES(status),
+  self_support   = VALUES(self_support)`
+	_, err := db.ExecContext(ctx, q, r.CampusID, r.InstitutionID, r.CampusName,
+		nullStr(r.CampusCode), nullStr(r.Address), nullStr(r.Phone), r.Status, r.SelfSupport,
+		r.CreatedAt.Format("2006-01-02 15:04:05"), r.UpdatedAt.Format("2006-01-02 15:04:05"))
+	if err != nil {
+		return fmt.Errorf("写 t_campus(id=%d) 失败:%w", r.CampusID, err)
+	}
+	return nil
+}
+
+// countTargetOrg 统计 target 两张表的行数。
+func countTargetOrg(ctx context.Context, db *sql.DB) (inst, camp int64, err error) {
+	if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM t_institution").Scan(&inst); err != nil {
+		return 0, 0, err
+	}
+	if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM t_campus").Scan(&camp); err != nil {
+		return 0, 0, err
+	}
+	return inst, camp, nil
+}
+
+// joinList 把 id 列表拼成 "1,63317" 形式(仅用于报告与 SQL 的 IN 列表构造)。
+func joinList(ids []int64) string {
+	parts := make([]string, len(ids))
+	for i, v := range ids {
+		parts[i] = fmt.Sprint(v)
+	}
+	return strings.Join(parts, ",")
+}

+ 125 - 0
internal/academic/dualrun/fixture/batch3_mapping.go

@@ -0,0 +1,125 @@
+package fixture
+
+import (
+	"database/sql"
+	"fmt"
+	"time"
+)
+
+// 本文件是第三批的**纯映射规则**(不连库、不取当前时间)。
+//
+// 三条需要写清楚、否则会被当成"随手填"的判断:
+//
+//  1. `status`:legacy 是 `tinyint(1)`(1=正常 / 0=停用),target 是 `VARCHAR(16)`(active/disabled)。
+//     这是**值域翻译**,不是占位:1→active、0→disabled;出现 1/0 之外的值直接报错,
+//     不猜(更不默认 active —— 那会把已停用的机构/校区悄悄"复活")。
+//  2. `institution_code` / `address` / `phone`:legacy **根本没有这三列**
+//     (t_school 只有 province_id/city_id/district_id)→ 一律 NULL,不编造。
+//     `uk_institution_code` 是 UNIQUE 但允许 NULL(MySQL 唯一键对 NULL 放行多行),不会撞键。
+//  3. `created_at` / `updated_at`:legacy 无时间列 → **epoch 哨兵**(与 t_user_divide_into 同口径)。
+//     这两列虽然已有 `DEFAULT CURRENT_TIMESTAMP(3)`,但取默认值会让同一份数据每次迁移结果不同,
+//     双跑/回滚就失去可比性。审计列也不需要"迁移时刻"以外的语义。
+
+// legacyStatusToTarget 把 legacy 的 tinyint 状态翻译成 target 的 active/disabled。
+//
+// 只接受 1 和 0:其它值说明这一列在旧系统里有我们没搞懂的含义,报错比猜安全。
+func legacyStatusToTarget(v int8, what string) (string, error) {
+	switch v {
+	case 1:
+		return "active", nil
+	case 0:
+		return "disabled", nil
+	default:
+		return "", fmt.Errorf("%s.status = %d,超出可解释范围(1=active/0=disabled),拒绝猜测", what, v)
+	}
+}
+
+// MapInstitution 映射一个机构(D1:institution_id 原值)。
+func MapInstitution(l LegacySchool) (TargetInstitution, []string, error) {
+	var warns []string
+	status, err := legacyStatusToTarget(l.Status, fmt.Sprintf("t_school(id=%d)", l.ID))
+	if err != nil {
+		return TargetInstitution{}, warns, err
+	}
+	if l.Name == "" {
+		return TargetInstitution{}, warns, fmt.Errorf("t_school(id=%d).name 为空,目标态 institution_name NOT NULL,拒绝填占位值", l.ID)
+	}
+	// 省市区有值但 target 无对应列(address 是自由文本)—— 记录一条告警,不编造地址
+	if l.ProvinceID > 0 || l.CityID > 0 || l.DistrictID > 0 {
+		warns = append(warns, fmt.Sprintf(
+			"t_school(id=%d) 有省市区 id(%d/%d/%d)但 target t_institution 无对应列,address 落 NULL",
+			l.ID, l.ProvinceID, l.CityID, l.DistrictID))
+	}
+	return TargetInstitution{
+		InstitutionID:   l.ID, // D1
+		InstitutionName: l.Name,
+		InstitutionCode: sql.NullString{}, // legacy 无此列
+		Address:         sql.NullString{}, // 同上
+		Phone:           sql.NullString{}, // 同上
+		Status:          status,
+		CreatedAt:       epochSentinel(),
+		UpdatedAt:       epochSentinel(),
+	}, warns, nil
+}
+
+// MapCampus 映射一个校区(D1:campus_id 原值)。
+//
+// `institution_id` **如实保留** legacy `school_id`(R17):campus 1/2 的 school_id=1,
+// 而数据里它们常与 schoolid=63317 一起出现 —— 这是旧系统的跨机构校区,
+// 这里不修正、不反推、不特判,只在报告里标 cross-org-campus。
+func MapCampus(l LegacyCampus) (TargetCampus, []string, error) {
+	var warns []string
+	if l.ID == 0 {
+		return TargetCampus{}, warns, fmt.Errorf("campus_id=0 不建行(0 是机构级约定值),调用方应过滤")
+	}
+	if l.SchoolID == 0 {
+		return TargetCampus{}, warns, fmt.Errorf(
+			"t_school_campus(id=%d).school_id=0,无法确定所属机构,拒绝建孤儿校区", l.ID)
+	}
+	if l.Name == "" {
+		return TargetCampus{}, warns, fmt.Errorf(
+			"t_school_campus(id=%d).name 为空,目标态 campus_name NOT NULL,拒绝填占位值", l.ID)
+	}
+	status, err := legacyStatusToTarget(l.Status, fmt.Sprintf("t_school_campus(id=%d)", l.ID))
+	if err != nil {
+		return TargetCampus{}, warns, err
+	}
+	if l.SelfSupport != 0 && l.SelfSupport != 1 {
+		return TargetCampus{}, warns, fmt.Errorf(
+			"t_school_campus(id=%d).self_support = %d,bit(1) 只应取 0/1,拒绝猜测", l.ID, l.SelfSupport)
+	}
+	if l.ProvinceID > 0 || l.CityID > 0 || l.DistrictID > 0 {
+		warns = append(warns, fmt.Sprintf(
+			"t_school_campus(id=%d) 有省市区 id(%d/%d/%d)但 target t_campus 无对应列,address 落 NULL",
+			l.ID, l.ProvinceID, l.CityID, l.DistrictID))
+	}
+	return TargetCampus{
+		CampusID:      l.ID, // D1
+		InstitutionID: l.SchoolID,
+		CampusName:    l.Name,
+		CampusCode:    truncateTo(l.Code, 32, fmt.Sprintf("t_school_campus(id=%d).code", l.ID), &warns),
+		Address:       sql.NullString{},
+		Phone:         sql.NullString{},
+		Status:        status,
+		SelfSupport:   l.SelfSupport,
+		CreatedAt:     epochSentinel(),
+		UpdatedAt:     epochSentinel(),
+	}, warns, nil
+}
+
+// IsCrossOrgCampus 判定「数据里的 (schoolid, campusid) 与校区的真实所属机构不一致」。
+//
+// 只用于报告标注(R17),不参与任何修正。campusOwner 是 campus_id → 真实 school_id 的索引。
+func IsCrossOrgCampus(schoolID, campusID int64, campusOwner map[int64]int64) bool {
+	if campusID == 0 {
+		return false
+	}
+	owner, ok := campusOwner[campusID]
+	if !ok {
+		return false
+	}
+	return owner != schoolID
+}
+
+// orgEpoch 便于单测断言哨兵值。
+func orgEpoch() time.Time { return epochSentinel() }

+ 128 - 0
internal/academic/dualrun/fixture/batch3_model.go

@@ -0,0 +1,128 @@
+package fixture
+
+import (
+	"database/sql"
+	"time"
+)
+
+// 本文件是**第三批(org 域)的数据形状**:机构 t_institution + 校区 t_campus。
+//
+// 口径(已拍板):
+//   - **两张表都走 D1**:`t_school.id` → `t_institution.institution_id`、`t_school_campus.id`
+//     → `t_campus.campus_id`,**原值插入**。理由:两者都是 int 自然主键、数据量极小,
+//     而 institution_id / campus_id 在几十张表里是裸 int,双跑要靠 id 直接对齐;
+//     走 biz_no 反而要多一跳。
+//   - **只迁数据用得到的机构**,不全量迁 t_school(61905 行里 61900 行与本项目无关)。
+//   - `campus_id = 0` **不建行**:0 是"机构级/未分配"的约定值(DDL 里 `campus_id ... DEFAULT 0
+//     COMMENT '校区ID(0=平台级/机构级)'`),建了会让前端校区下拉框多出一个"未知校区"。
+
+// OrgOptions 是第三批的取样参数。
+type OrgOptions struct {
+	// InstitutionIDs 是要迁移的机构 id 集合(D1 原值)。
+	//
+	// 这是**显式决策**,不是从数据里自动推导的:多迁一个机构 = 多一个孤儿机构;
+	// 少迁一个 = 引用它的一整批数据变成孤儿引用。集合必须由人确认。
+	InstitutionIDs []int64
+	// WithAllCampuses 是否迁移所选机构的**全部**校区(true)
+	// 还是只迁数据里出现过的校区(false)。默认 true:机构迁了、校区不迁会让
+	// 前端校区下拉框与机构对不上,且属于 org 域自身的不一致。
+	WithAllCampuses bool
+	OrgSourceDB     string
+}
+
+// DefaultOrgOptions 是默认取样 = **7 个机构**。
+//
+// ⚠ 初版统计给的是 5 个(`SELECT DISTINCT schoolid FROM t_courses` + campus 1/2 的 school_id=1),
+// 实测逐来源清点后发现**漏了 2 个**,各自都带孤儿引用:
+//
+//	63332(用户注册)← finance_bk.t_user_balance_request  1 行(schoolid=63332, campusid=57)
+//	63330(弘毅教育)← finance_bk.t_courses_classify    1 行(schoolid=63330)
+//	                    —— 这张表不在原统计的 7 个来源清单里,是靠"按列名反查"找出来的
+//
+// 只迁 5 个会让这两个来源各留 1 条孤儿机构 + 1 条孤儿校区(实测见报告)。
+func DefaultOrgOptions() OrgOptions {
+	return OrgOptions{
+		InstitutionIDs:  []int64{1, 63317, 63323, 63324, 63325, 63330, 63332},
+		WithAllCampuses: true,
+		OrgSourceDB:     "yuansiwei_i",
+	}
+}
+
+// ---------- legacy(yuansiwei_i) ----------
+
+// LegacySchool 对应 `yuansiwei_i.t_school`。
+type LegacySchool struct {
+	ID              int64
+	Name            string
+	ProvinceID      int64
+	CityID          int64
+	DistrictID      int64
+	GradePeriod     string
+	Status          int8 // tinyint(1):1=正常 0=停用(业务口径,见 mapping 注释)
+	IsOpenUserLogin int8
+}
+
+// LegacyCampus 对应 `yuansiwei_i.t_school_campus`。
+type LegacyCampus struct {
+	ID              int64
+	Name            string
+	SchoolID        int64
+	ProvinceID      int64
+	CityID          int64
+	DistrictID      int64
+	Status          int8
+	Code            sql.NullString
+	SelfSupport     int8 // bit(1) → 0/1
+	IsOpenUserLogin int8
+}
+
+// ---------- target ----------
+
+// TargetInstitution 对应 `t_institution`(16 列里只用得到的那些)。
+type TargetInstitution struct {
+	InstitutionID   int64
+	InstitutionName string
+	InstitutionCode sql.NullString // legacy 无对应列 → NULL(uk_institution_code 允许多 NULL)
+	Address         sql.NullString // legacy 只有省市区 id,没有地址文本 → NULL
+	Phone           sql.NullString // legacy 无 → NULL
+	Status          string         // active / disabled
+	CreatedAt       time.Time      // legacy 无时间列 → epoch 哨兵
+	UpdatedAt       time.Time
+}
+
+// TargetCampus 对应 `t_campus`。
+type TargetCampus struct {
+	CampusID      int64
+	InstitutionID int64
+	CampusName    string
+	CampusCode    sql.NullString // legacy code(varchar(16))→ campus_code
+	Address       sql.NullString
+	Phone         sql.NullString
+	Status        string
+	SelfSupport   int8 // bit(1) → TINYINT(1)
+	CreatedAt     time.Time
+	UpdatedAt     time.Time
+}
+
+// ---------- 引用完整性 ----------
+
+// RefSource 是一个「带 institution_id / campus_id 的数据来源」。
+type RefSource struct {
+	Table     string // finance_bk 里的表名
+	InstCol   string // 机构列
+	CampusCol string // 校区列(空 = 该表没有校区列)
+	Note      string
+}
+
+// RefStat 是一个来源的引用统计结果。
+type RefStat struct {
+	Source           string
+	Rows             int64 // 该表总行数
+	DistinctInst     int   // distinct institution_id
+	DistinctCampus   int   // distinct campus_id(不含 0)
+	OrphanInst       int64 // institution_id 在 target 里找不到的行数
+	OrphanCampus     int64 // campus_id != 0 但在 target 里找不到的行数
+	CampusZeroRows   int64 // campus_id = 0(机构级,不建行)的行数
+	OrphanInstList   []string
+	OrphanCampusList []string
+}

+ 326 - 0
internal/academic/dualrun/fixture/batch3_run.go

@@ -0,0 +1,326 @@
+package fixture
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"io"
+	"sort"
+)
+
+// 本文件是第三批的流程编排与报告排版。
+
+// OrgReport 是一次「搬 org 域」的结果。
+type OrgReport struct {
+	Options  OrgOptions
+	Schools  int
+	Campuses int
+	Warnings []string
+	InstIDs  []int64
+	CampIDs  []int64
+}
+
+// PrepareOrg 把选中的机构与其校区搬进目标库(D1 原值插入)。
+func PrepareOrg(ctx context.Context, cfg Config, opts OrgOptions) (*OrgReport, error) {
+	src, err := cfg.OpenOrgSource()
+	if err != nil {
+		return nil, err
+	}
+	defer src.Close()
+	target, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, err
+	}
+	defer target.Close()
+
+	rep := &OrgReport{Options: opts}
+
+	schools, err := extractSchools(ctx, src, opts.InstitutionIDs)
+	if err != nil {
+		return nil, err
+	}
+	if len(schools) == 0 {
+		return nil, fmt.Errorf("t_school 里找不到任何一个指定机构 id(%v)", opts.InstitutionIDs)
+	}
+	// 请求了但库里没有的 id 必须显式报出来(不允许"少迁一个却当成功")
+	found := map[int64]bool{}
+	for _, s := range schools {
+		found[s.ID] = true
+	}
+	var missing []int64
+	for _, id := range opts.InstitutionIDs {
+		if !found[id] {
+			missing = append(missing, id)
+		}
+	}
+	if len(missing) > 0 {
+		return nil, fmt.Errorf("指定的机构 id 在 t_school 里不存在:%v(拒绝静默跳过)", missing)
+	}
+
+	campuses, err := extractCampuses(ctx, src, opts.InstitutionIDs)
+	if err != nil {
+		return nil, err
+	}
+	if !opts.WithAllCampuses {
+		legacy, err := cfg.OpenLegacy()
+		if err != nil {
+			return nil, err
+		}
+		refd, err := extractReferencedCampuses(ctx, legacy)
+		legacy.Close()
+		if err != nil {
+			return nil, err
+		}
+		var kept []LegacyCampus
+		for _, c := range campuses {
+			if refd[c.ID] {
+				kept = append(kept, c)
+			}
+		}
+		campuses = kept
+	}
+
+	// 机构
+	for _, s := range schools {
+		ti, w, err := MapInstitution(s)
+		if err != nil {
+			return nil, err
+		}
+		rep.Warnings = append(rep.Warnings, w...)
+		if err := upsertInstitution(ctx, target, ti); err != nil {
+			return nil, err
+		}
+		rep.InstIDs = append(rep.InstIDs, ti.InstitutionID)
+	}
+	rep.Schools = len(schools)
+
+	// 校区
+	for _, c := range campuses {
+		tc, w, err := MapCampus(c)
+		if err != nil {
+			return nil, err
+		}
+		rep.Warnings = append(rep.Warnings, w...)
+		if err := upsertCampus(ctx, target, tc); err != nil {
+			return nil, err
+		}
+		rep.CampIDs = append(rep.CampIDs, tc.CampusID)
+	}
+	rep.Campuses = len(campuses)
+	sort.Slice(rep.CampIDs, func(i, j int) bool { return rep.CampIDs[i] < rep.CampIDs[j] })
+	return rep, nil
+}
+
+// OrgIdemResult 是 org 域「跑两次」的比对结论。
+type OrgIdemResult struct {
+	InstBefore, InstAfter map[int64]string
+	CampBefore, CampAfter map[int64]string
+}
+
+// AllOK 表示两张表都完全幂等(行数与 id 集合不变)。
+func (r *OrgIdemResult) AllOK() bool {
+	return sameIDSet(r.InstBefore, r.InstAfter) && sameIDSet(r.CampBefore, r.CampAfter)
+}
+
+func sameIDSet(a, b map[int64]string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for k := range a {
+		if _, ok := b[k]; !ok {
+			return false
+		}
+	}
+	return true
+}
+
+// IdempotencyCheckOrg 先拍快照 → 再跑一次完全相同的映射 → 比对。
+func IdempotencyCheckOrg(ctx context.Context, cfg Config, opts OrgOptions) (*OrgIdemResult, error) {
+	ib, cb, err := SnapshotOrg(ctx, cfg)
+	if err != nil {
+		return nil, err
+	}
+	if _, err := PrepareOrg(ctx, cfg, opts); err != nil {
+		return nil, fmt.Errorf("第二次映射失败(幂等被破坏):%w", err)
+	}
+	ia, ca, err := SnapshotOrg(ctx, cfg)
+	if err != nil {
+		return nil, err
+	}
+	return &OrgIdemResult{InstBefore: ib, InstAfter: ia, CampBefore: cb, CampAfter: ca}, nil
+}
+
+// OrgCleanReport 是清理结果。
+type OrgCleanReport struct {
+	DeletedInst int64
+	DeletedCamp int64
+	Remaining   map[string]int64
+}
+
+// CleanupOrg 删除第三批造的数据(按 D1 id 精确删)。
+func CleanupOrg(ctx context.Context, cfg Config, opts OrgOptions) (*OrgCleanReport, error) {
+	src, err := cfg.OpenOrgSource()
+	if err != nil {
+		return nil, err
+	}
+	defer src.Close()
+	target, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, err
+	}
+	defer target.Close()
+
+	schools, err := extractSchools(ctx, src, opts.InstitutionIDs)
+	if err != nil {
+		return nil, err
+	}
+	campuses, err := extractCampuses(ctx, src, opts.InstitutionIDs)
+	if err != nil {
+		return nil, err
+	}
+
+	rep := &OrgCleanReport{Remaining: map[string]int64{}}
+	// 先删校区(引用方),再删机构
+	for _, c := range campuses {
+		res, err := target.ExecContext(ctx, `DELETE FROM t_campus WHERE campus_id = ?`, c.ID)
+		if err != nil {
+			return nil, err
+		}
+		n, _ := res.RowsAffected()
+		rep.DeletedCamp += n
+	}
+	for _, s := range schools {
+		res, err := target.ExecContext(ctx, `DELETE FROM t_institution WHERE institution_id = ?`, s.ID)
+		if err != nil {
+			return nil, err
+		}
+		n, _ := res.RowsAffected()
+		rep.DeletedInst += n
+	}
+	for _, t := range []string{"t_institution", "t_campus"} {
+		var n int64
+		if err := target.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&n); err != nil {
+			return nil, err
+		}
+		rep.Remaining[t] = n
+	}
+	return rep, nil
+}
+
+// CountOrgTables 统计 org 两张表行数。
+func CountOrgTables(ctx context.Context, cfg Config) (map[string]int64, error) {
+	db, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, err
+	}
+	defer db.Close()
+	inst, camp, err := countTargetOrg(ctx, db)
+	if err != nil {
+		return nil, err
+	}
+	return map[string]int64{"t_institution": inst, "t_campus": camp}, nil
+}
+
+// PrintOrgReport 打印搬数据结果。
+func PrintOrgReport(w io.Writer, r *OrgReport) {
+	fmt.Fprintf(w, "== 第三批 org 域映射 ==\n")
+	fmt.Fprintf(w, "机构 id 集合:%v(%d 个)\n", r.Options.InstitutionIDs, len(r.Options.InstitutionIDs))
+	fmt.Fprintf(w, "迁移校区策略:WithAllCampuses=%v\n", r.Options.WithAllCampuses)
+	fmt.Fprintf(w, "写入:t_institution=%d 行,t_campus=%d 行\n", r.Schools, r.Campuses)
+	fmt.Fprintf(w, "institution_id:%v\n", r.InstIDs)
+	fmt.Fprintf(w, "campus_id:%v\n", r.CampIDs)
+	if len(r.Warnings) > 0 {
+		fmt.Fprintf(w, "映射告警(%d):\n", len(r.Warnings))
+		for _, x := range r.Warnings {
+			fmt.Fprintf(w, "  - %s\n", x)
+		}
+	} else {
+		fmt.Fprintf(w, "映射告警:无\n")
+	}
+}
+
+// PrintOrgVerify 打印核对结果。
+func PrintOrgVerify(w io.Writer, r *OrgVerifyReport) {
+	fmt.Fprintf(w, "== 第三批逐列核对(列 × 行 聚合)==\n")
+	last := ""
+	for _, c := range r.Cols {
+		if c.Table != last {
+			cnt := r.TargetInst
+			if c.Table == "t_campus" {
+				cnt = r.TargetCamp
+			}
+			fmt.Fprintf(w, "\n-- %s(target %d 行)--\n", c.Table, cnt)
+			last = c.Table
+		}
+		st := markOK
+		if !c.OK() {
+			st = markFail
+		}
+		fmt.Fprintf(w, "  %s %-18s %d/%d 相等   %s\n", st, c.Column, c.Equal, c.Total, c.Note)
+		for _, s := range c.Samples {
+			fmt.Fprintf(w, "        ✘ %s\n", s)
+		}
+	}
+
+	fmt.Fprintf(w, "\n== 引用完整性(逐个来源)==\n")
+	fmt.Fprintf(w, "  %-28s %-8s %-10s %-10s %-10s %s\n", "来源", "行数", "机构数", "校区数", "孤儿机构", "孤儿校区")
+	for _, s := range r.RefStats {
+		st := markOK
+		if s.OrphanInst > 0 || s.OrphanCampus > 0 {
+			st = markFail
+		}
+		fmt.Fprintf(w, "  %s %-26s %-8d %-10d %-10d %-10d %d\n",
+			st, s.Source, s.Rows, s.DistinctInst, s.DistinctCampus, s.OrphanInst, s.OrphanCampus)
+		if len(s.OrphanInstList) > 0 || len(s.OrphanCampusList) > 0 {
+			fmt.Fprintf(w, "        孤儿机构 id:%v   孤儿校区 id:%v\n", s.OrphanInstList, s.OrphanCampusList)
+		}
+	}
+	fmt.Fprintf(w, "  (campus_id=0 表示机构级、不建行,已按来源单独统计,不计入孤儿)\n")
+	for _, s := range r.RefStats {
+		if s.CampusZeroRows > 0 {
+			fmt.Fprintf(w, "    %-26s campus_id=0 的行数:%d\n", s.Source, s.CampusZeroRows)
+		}
+	}
+
+	if len(r.CrossOrg) > 0 {
+		fmt.Fprintf(w, "\n== R17 标注:cross-org-campus(只标注,不修正)==\n")
+		for _, c := range r.CrossOrg {
+			fmt.Fprintf(w, "  · %s\n", c)
+		}
+	}
+
+	fmt.Fprintf(w, "\n== 语义断言 ==\n")
+	for _, a := range r.Assertions {
+		st := markOK
+		if !a.Pass {
+			st = markFail
+		}
+		fmt.Fprintf(w, "  %s %s\n     %s\n", st, a.Name, a.Detail)
+	}
+	fmt.Fprintf(w, "\n核对结论:%s(不一致列 + 失败断言 = %d)\n", verdict(r.OK()), r.FailCount())
+}
+
+// PrintOrgIdem 打印幂等结论。
+func PrintOrgIdem(w io.Writer, r *OrgIdemResult) {
+	fmt.Fprintf(w, "== 幂等实测(同一批跑两次)==\n")
+	st := markOK
+	if !r.AllOK() {
+		st = markFail
+	}
+	fmt.Fprintf(w, "  %s t_institution:%d 行 → %d 行\n", st, len(r.InstBefore), len(r.InstAfter))
+	fmt.Fprintf(w, "  %s t_campus:%d 行 → %d 行\n", st, len(r.CampBefore), len(r.CampAfter))
+	fmt.Fprintf(w, "  id 集合不变:institution=%v campus=%v\n",
+		sameIDSet(r.InstBefore, r.InstAfter), sameIDSet(r.CampBefore, r.CampAfter))
+	fmt.Fprintf(w, "\n幂等结论:%s\n", verdict(r.AllOK()))
+}
+
+// PrintOrgClean 打印清理结果。
+func PrintOrgClean(w io.Writer, r *OrgCleanReport) {
+	fmt.Fprintf(w, "== 第三批清理 ==\n")
+	fmt.Fprintf(w, "  t_campus      删除 %d 行,剩余全表 %d 行\n", r.DeletedCamp, r.Remaining["t_campus"])
+	fmt.Fprintf(w, "  t_institution 删除 %d 行,剩余全表 %d 行\n", r.DeletedInst, r.Remaining["t_institution"])
+	ok := r.Remaining["t_campus"] == 0 && r.Remaining["t_institution"] == 0
+	fmt.Fprintf(w, "两表回 0 行:%s\n", verdict(ok))
+}
+
+var _ = sql.ErrNoRows

+ 131 - 0
internal/academic/dualrun/fixture/batch3_test.go

@@ -0,0 +1,131 @@
+package fixture
+
+import (
+	"database/sql"
+	"testing"
+)
+
+// 本文件单测第三批(org 域)的纯映射规则:D1、status 值域翻译、bit(1)、NULL 列、哨兵时间。
+
+func TestLegacyStatusToTarget(t *testing.T) {
+	if v, err := legacyStatusToTarget(1, "x"); err != nil || v != "active" {
+		t.Errorf("1 应映射为 active,得到 %q / %v", v, err)
+	}
+	if v, err := legacyStatusToTarget(0, "x"); err != nil || v != "disabled" {
+		t.Errorf("0 应映射为 disabled,得到 %q / %v", v, err)
+	}
+	// 1/0 之外必须报错,绝不默认 active(否则会把已停用的机构悄悄复活)
+	for _, bad := range []int8{2, -1, 9} {
+		if _, err := legacyStatusToTarget(bad, "x"); err == nil {
+			t.Errorf("status=%d 应报错(拒绝猜测),实际通过", bad)
+		}
+	}
+}
+
+func TestMapInstitutionD1AndNulls(t *testing.T) {
+	l := LegacySchool{
+		ID: 63317, Name: "源思维",
+		ProvinceID: 20, CityID: 218, DistrictID: 1869,
+		GradePeriod: "1,2,3", Status: 1,
+	}
+	got, warns, err := MapInstitution(l)
+	if err != nil {
+		t.Fatalf("MapInstitution 出错:%v", err)
+	}
+	if got.InstitutionID != 63317 {
+		t.Errorf("D1:institution_id 必须是 63317,实际 %d", got.InstitutionID)
+	}
+	if got.InstitutionName != "源思维" {
+		t.Errorf("名称应原样,实际 %q", got.InstitutionName)
+	}
+	if got.Status != "active" {
+		t.Errorf("status=1 → active,实际 %q", got.Status)
+	}
+	// legacy 无 code/address/phone 三列 → 必须 NULL,不编造
+	if got.InstitutionCode.Valid || got.Address.Valid || got.Phone.Valid {
+		t.Error("legacy 无 code/address/phone,必须落 NULL")
+	}
+	// 有省市区 id 时必须产生一条告警(提示 address 无法还原)
+	if len(warns) == 0 {
+		t.Error("有省市区 id 时应产生告警(target 无对应列)")
+	}
+	if !got.CreatedAt.Equal(orgEpoch()) || !got.UpdatedAt.Equal(orgEpoch()) {
+		t.Error("legacy 无时间列,created_at/updated_at 应落 epoch 哨兵")
+	}
+}
+
+func TestMapInstitutionRejectsEmptyName(t *testing.T) {
+	if _, _, err := MapInstitution(LegacySchool{ID: 1, Name: "", Status: 1}); err == nil {
+		t.Error("name 为空且目标列 NOT NULL 时应报错(不得填占位值),实际未报错")
+	}
+}
+
+func TestMapCampusD1R17AndBit(t *testing.T) {
+	// R17:campus 1 的 school_id=1,必须**如实保留**,不能"修正"成数据里常见的 63317
+	l := LegacyCampus{ID: 1, Name: "城东校区", SchoolID: 1, Status: 1, SelfSupport: 0,
+		Code: sql.NullString{String: "CD", Valid: true}}
+	got, _, err := MapCampus(l)
+	if err != nil {
+		t.Fatalf("MapCampus 出错:%v", err)
+	}
+	if got.CampusID != 1 {
+		t.Errorf("D1:campus_id 必须是 1,实际 %d", got.CampusID)
+	}
+	if got.InstitutionID != 1 {
+		t.Errorf("R17:campus 1 的 institution_id 必须是 1,实际 %d", got.InstitutionID)
+	}
+	if got.SelfSupport != 0 {
+		t.Errorf("bit(1)=0 应映射为 0,实际 %d", got.SelfSupport)
+	}
+	if got.CampusCode.String != "CD" {
+		t.Errorf("code 应原样,实际 %q", got.CampusCode.String)
+	}
+
+	// bit(1)=1 → 1
+	l2 := LegacyCampus{ID: 5, Name: "金湖校区", SchoolID: 63317, Status: 1, SelfSupport: 1}
+	got2, _, err := MapCampus(l2)
+	if err != nil {
+		t.Fatalf("MapCampus 出错:%v", err)
+	}
+	if got2.SelfSupport != 1 {
+		t.Errorf("bit(1)=1 应映射为 1,实际 %d", got2.SelfSupport)
+	}
+
+	// bit(1) 只应取 0/1
+	bad := LegacyCampus{ID: 9, Name: "x", SchoolID: 1, Status: 1, SelfSupport: 48}
+	if _, _, err := MapCampus(bad); err == nil {
+		t.Error("self_support=48('0' 的 ASCII)应报错,不得当成 0/1")
+	}
+}
+
+func TestMapCampusRejectsCampusZero(t *testing.T) {
+	if _, _, err := MapCampus(LegacyCampus{ID: 0, Name: "x", SchoolID: 1, Status: 1}); err == nil {
+		t.Error("campus_id=0 不应建行,应报错")
+	}
+}
+
+func TestMapCampusRejectsOrphan(t *testing.T) {
+	if _, _, err := MapCampus(LegacyCampus{ID: 9, Name: "x", SchoolID: 0, Status: 1}); err == nil {
+		t.Error("school_id=0 会造出孤儿校区,应报错")
+	}
+}
+
+func TestIsCrossOrgCampus(t *testing.T) {
+	owner := map[int64]int64{1: 1, 2: 1, 5: 63317}
+	// (63317, 1):校区 1 真实属于机构 1 → 跨机构
+	if !IsCrossOrgCampus(63317, 1, owner) {
+		t.Error("(63317,1) 应判为 cross-org-campus")
+	}
+	// (1, 1):一致
+	if IsCrossOrgCampus(1, 1, owner) {
+		t.Error("(1,1) 不应判为 cross-org-campus")
+	}
+	// campusid=0:机构级,永远不算跨机构
+	if IsCrossOrgCampus(63317, 0, owner) {
+		t.Error("campusid=0 不应判为 cross-org-campus")
+	}
+	// 未知校区:不判
+	if IsCrossOrgCampus(63317, 999, owner) {
+		t.Error("未知校区不应判为 cross-org-campus")
+	}
+}

+ 428 - 0
internal/academic/dualrun/fixture/batch3_verify.go

@@ -0,0 +1,428 @@
+package fixture
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+// 本文件是第三批的**核对**:逐列 + 机构集合 + 引用完整性(孤儿=0)+ R17 + bit(1)。
+//
+// 期望值来源与前三批一致:机构/校区字段由 **MySQL 服务端**取值(`self_support+0`、
+// `CAST(...)`),不由映射代码产生;只有 `status` 的 active/disabled 是规则翻译,
+// 单独作为断言说明(并在映射里对非法值报错)。
+
+// OrgVerifyReport 是第三批核对结果。
+type OrgVerifyReport struct {
+	Options    OrgOptions
+	Cols       []ColumnAgg
+	Assertions []Assertion
+	RefStats   []RefStat
+	CrossOrg   []string // R17:数据里 (schoolid, campusid) 与校区真实所属机构不一致的组合
+	TargetInst int64
+	TargetCamp int64
+	Warnings   []string
+}
+
+// FailCount 统计不一致列 + 失败断言。
+func (r *OrgVerifyReport) FailCount() int {
+	n := 0
+	for _, c := range r.Cols {
+		if !c.OK() {
+			n++
+		}
+	}
+	for _, a := range r.Assertions {
+		if !a.Pass {
+			n++
+		}
+	}
+	return n
+}
+
+// OK 表示整轮通过。
+func (r *OrgVerifyReport) OK() bool { return r.FailCount() == 0 }
+
+// VerifyOrg 执行第三批核对。
+func VerifyOrg(ctx context.Context, cfg Config, opts OrgOptions) (*OrgVerifyReport, error) {
+	src, err := cfg.OpenOrgSource()
+	if err != nil {
+		return nil, err
+	}
+	defer src.Close()
+	legacy, err := cfg.OpenLegacy() // finance_bk:引用完整性统计用
+	if err != nil {
+		return nil, err
+	}
+	defer legacy.Close()
+	target, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, err
+	}
+	defer target.Close()
+
+	rep := &OrgVerifyReport{Options: opts}
+
+	// ---------- 1. t_institution 逐列 ----------
+	expSchools, err := extractSchools(ctx, src, opts.InstitutionIDs)
+	if err != nil {
+		return nil, err
+	}
+	cID := newCol("t_institution", "institution_id", "D1:与 t_school.id 完全相同")
+	cName := newCol("t_institution", "institution_name", "t_school.name 原样")
+	cCode := newCol("t_institution", "institution_code", "legacy 无此列 → 必须 NULL")
+	cAddr := newCol("t_institution", "address", "legacy 无此列 → 必须 NULL")
+	cPhone := newCol("t_institution", "phone", "legacy 无此列 → 必须 NULL")
+	cStatus := newCol("t_institution", "status", "tinyint 1→active / 0→disabled(规则翻译)")
+	cCreated := newCol("t_institution", "created_at", "legacy 无时间列 → epoch 占位")
+	cUpdated := newCol("t_institution", "updated_at", "legacy 无时间列 → epoch 占位")
+
+	var legacyIDs []int64
+	for _, s := range expSchools {
+		legacyIDs = append(legacyIDs, s.ID)
+		rowKey := "id=" + strconv.FormatInt(s.ID, 10)
+		var got struct {
+			ID      int64
+			Name    string
+			Code    sql.NullString
+			Addr    sql.NullString
+			Phone   sql.NullString
+			Status  string
+			Created sql.NullString
+			Updated sql.NullString
+		}
+		err := target.QueryRowContext(ctx, `
+SELECT institution_id, institution_name, institution_code, address, phone, status,
+       DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s'), DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i:%s')
+  FROM t_institution WHERE institution_id = ?`, s.ID).Scan(
+			&got.ID, &got.Name, &got.Code, &got.Addr, &got.Phone, &got.Status, &got.Created, &got.Updated)
+		if err == sql.ErrNoRows {
+			cID.cmp(rowKey, strconv.FormatInt(s.ID, 10), "<缺失>", false)
+			continue
+		}
+		if err != nil {
+			return nil, err
+		}
+		wantStatus := "active"
+		if s.Status == 0 {
+			wantStatus = "disabled"
+		}
+		cID.cmp(rowKey, strconv.FormatInt(s.ID, 10), strconv.FormatInt(got.ID, 10), got.ID == s.ID)
+		cName.cmp(rowKey, s.Name, got.Name, got.Name == s.Name)
+		cCode.cmp(rowKey, "NULL", nullStrOrNull(got.Code), !got.Code.Valid)
+		cAddr.cmp(rowKey, "NULL", nullStrOrNull(got.Addr), !got.Addr.Valid)
+		cPhone.cmp(rowKey, "NULL", nullStrOrNull(got.Phone), !got.Phone.Valid)
+		cStatus.cmp(rowKey, fmt.Sprintf("%d→%s", s.Status, wantStatus), got.Status, got.Status == wantStatus)
+		cCreated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Created),
+			strings.HasPrefix(nullStrOrNull(got.Created), LegacyMissingTime[:10]))
+		cUpdated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Updated),
+			strings.HasPrefix(nullStrOrNull(got.Updated), LegacyMissingTime[:10]))
+	}
+	rep.Cols = append(rep.Cols, cID.done(), cName.done(), cCode.done(), cAddr.done(),
+		cPhone.done(), cStatus.done(), cCreated.done(), cUpdated.done())
+
+	// ---------- 2. 机构集合断言 ----------
+	var gotIDs []int64
+	rows, err := target.QueryContext(ctx, `SELECT institution_id FROM t_institution ORDER BY institution_id`)
+	if err != nil {
+		return nil, err
+	}
+	for rows.Next() {
+		var id int64
+		if err := rows.Scan(&id); err != nil {
+			rows.Close()
+			return nil, err
+		}
+		gotIDs = append(gotIDs, id)
+	}
+	rows.Close()
+	rep.TargetInst = int64(len(gotIDs))
+	want := append([]int64(nil), opts.InstitutionIDs...)
+	sort.Slice(want, func(i, j int) bool { return want[i] < want[j] })
+	rep.Assertions = append(rep.Assertions, Assertion{
+		Name: "机构集合精确等于期望集合(不多不少)",
+		Pass: int64SliceEqual(gotIDs, want),
+		Detail: fmt.Sprintf("target t_institution = %v(%d 行);期望 = %v(%d 行)",
+			gotIDs, len(gotIDs), want, len(want)),
+	})
+
+	// ---------- 3. t_campus 逐列 ----------
+	expCampuses, err := extractCampuses(ctx, src, opts.InstitutionIDs)
+	if err != nil {
+		return nil, err
+	}
+	if !opts.WithAllCampuses {
+		refd, err := extractReferencedCampuses(ctx, legacy)
+		if err != nil {
+			return nil, err
+		}
+		var kept []LegacyCampus
+		for _, c := range expCampuses {
+			if refd[c.ID] {
+				kept = append(kept, c)
+			}
+		}
+		expCampuses = kept
+	}
+
+	cCampusID := newCol("t_campus", "campus_id", "D1:与 t_school_campus.id 完全相同")
+	cInstID := newCol("t_campus", "institution_id", "R17:如实保留 school_id(不修正跨机构校区)")
+	cCampusName := newCol("t_campus", "campus_name", "name 原样")
+	cCampusCode := newCol("t_campus", "campus_code", "code → campus_code(NULL 保留)")
+	cCampusAddr := newCol("t_campus", "address", "legacy 无此列 → 必须 NULL")
+	cCampusPhone := newCol("t_campus", "phone", "legacy 无此列 → 必须 NULL")
+	cCampusStatus := newCol("t_campus", "status", "tinyint 1→active / 0→disabled")
+	cSelfSupport := newCol("t_campus", "self_support", "bit(1) → TINYINT(1),逐行相等")
+	cCampusCreated := newCol("t_campus", "created_at", "epoch 占位")
+	cCampusUpdated := newCol("t_campus", "updated_at", "epoch 占位")
+
+	campusOwner := map[int64]int64{}
+	var gotCampusIDs []int64
+	for _, c := range expCampuses {
+		campusOwner[c.ID] = c.SchoolID
+		rowKey := "id=" + strconv.FormatInt(c.ID, 10)
+		var got struct {
+			ID, InstID int64
+			Name       string
+			Code       sql.NullString
+			Addr       sql.NullString
+			Phone      sql.NullString
+			Status     string
+			SelfSup    int8
+			Created    sql.NullString
+			Updated    sql.NullString
+		}
+		err := target.QueryRowContext(ctx, `
+SELECT campus_id, institution_id, campus_name, campus_code, address, phone, status,
+       self_support, DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s'), DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i:%s')
+  FROM t_campus WHERE campus_id = ?`, c.ID).Scan(
+			&got.ID, &got.InstID, &got.Name, &got.Code, &got.Addr, &got.Phone, &got.Status,
+			&got.SelfSup, &got.Created, &got.Updated)
+		if err == sql.ErrNoRows {
+			cCampusID.cmp(rowKey, strconv.FormatInt(c.ID, 10), "<缺失>", false)
+			continue
+		}
+		if err != nil {
+			return nil, err
+		}
+		gotCampusIDs = append(gotCampusIDs, got.ID)
+		wantStatus := "active"
+		if c.Status == 0 {
+			wantStatus = "disabled"
+		}
+		cCampusID.cmp(rowKey, strconv.FormatInt(c.ID, 10), strconv.FormatInt(got.ID, 10), got.ID == c.ID)
+		cInstID.cmp(rowKey, strconv.FormatInt(c.SchoolID, 10), strconv.FormatInt(got.InstID, 10), got.InstID == c.SchoolID)
+		cCampusName.cmp(rowKey, c.Name, got.Name, got.Name == c.Name)
+		cCampusCode.cmp(rowKey, nullStrOrNull(c.Code), nullStrOrNull(got.Code), nullStrOrNull(c.Code) == nullStrOrNull(got.Code))
+		cCampusAddr.cmp(rowKey, "NULL", nullStrOrNull(got.Addr), !got.Addr.Valid)
+		cCampusPhone.cmp(rowKey, "NULL", nullStrOrNull(got.Phone), !got.Phone.Valid)
+		cCampusStatus.cmp(rowKey, fmt.Sprintf("%d→%s", c.Status, wantStatus), got.Status, got.Status == wantStatus)
+		cSelfSupport.cmp(rowKey, strconv.Itoa(int(c.SelfSupport)), strconv.Itoa(int(got.SelfSup)),
+			got.SelfSup == c.SelfSupport)
+		cCampusCreated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Created),
+			strings.HasPrefix(nullStrOrNull(got.Created), LegacyMissingTime[:10]))
+		cCampusUpdated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Updated),
+			strings.HasPrefix(nullStrOrNull(got.Updated), LegacyMissingTime[:10]))
+	}
+	rep.Cols = append(rep.Cols, cCampusID.done(), cInstID.done(), cCampusName.done(),
+		cCampusCode.done(), cCampusAddr.done(), cCampusPhone.done(), cCampusStatus.done(),
+		cSelfSupport.done(), cCampusCreated.done(), cCampusUpdated.done())
+	rep.TargetCamp = int64(len(gotCampusIDs))
+
+	// ---------- 4. campus_id = 0 不得建行 ----------
+	var zeroCampus int
+	if err := target.QueryRowContext(ctx, `SELECT COUNT(*) FROM t_campus WHERE campus_id = 0`).Scan(&zeroCampus); err != nil {
+		return nil, err
+	}
+	rep.Assertions = append(rep.Assertions, Assertion{
+		Name:   "t_campus 不建 campus_id=0 的行(0 是机构级约定值)",
+		Pass:   zeroCampus == 0,
+		Detail: fmt.Sprintf("t_campus 里 campus_id=0 的行数 = %d(期望 0)", zeroCampus),
+	})
+
+	// ---------- 5. R17 ----------
+	r17OK := true
+	var r17Detail []string
+	for _, cid := range []int64{1, 2} {
+		var inst sql.NullInt64
+		err := target.QueryRowContext(ctx, `SELECT institution_id FROM t_campus WHERE campus_id = ?`, cid).Scan(&inst)
+		if err != nil {
+			r17OK = false
+			r17Detail = append(r17Detail, fmt.Sprintf("campus %d 读不到:%v", cid, err))
+			continue
+		}
+		ok := inst.Valid && inst.Int64 == 1
+		if !ok {
+			r17OK = false
+		}
+		r17Detail = append(r17Detail, fmt.Sprintf("campus %d institution_id=%d(期望 1)", cid, inst.Int64))
+	}
+	var inst1 int
+	if err := target.QueryRowContext(ctx, `SELECT COUNT(*) FROM t_institution WHERE institution_id = 1`).Scan(&inst1); err != nil {
+		return nil, err
+	}
+	rep.Assertions = append(rep.Assertions, Assertion{
+		Name:   "R17:campus 1/2 的 institution_id = 1,且 t_institution(1) 存在",
+		Pass:   r17OK && inst1 == 1,
+		Detail: strings.Join(r17Detail, ";") + fmt.Sprintf(";t_institution(1) 行数=%d", inst1),
+	})
+
+	// ---------- 6. 引用完整性(每个来源逐个统计孤儿) ----------
+	for _, s := range orgRefSources() {
+		st := RefStat{Source: s.Table}
+		if err := legacy.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM `%s`", s.Table)).Scan(&st.Rows); err != nil {
+			return nil, err
+		}
+		if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
+			"SELECT COUNT(DISTINCT `%s`) FROM `%s`", s.InstCol, s.Table)).Scan(&st.DistinctInst); err != nil {
+			return nil, err
+		}
+		// 孤儿机构
+		orphanInstSQL := fmt.Sprintf(`
+SELECT COUNT(*) FROM `+"`%s`"+` s
+  LEFT JOIN %s.t_institution i ON i.institution_id = s.`+"`%s`"+`
+ WHERE i.institution_id IS NULL`, s.Table, cfg.TargetDB, s.InstCol)
+		if err := legacy.QueryRowContext(ctx, orphanInstSQL).Scan(&st.OrphanInst); err != nil {
+			return nil, fmt.Errorf("%s 孤儿机构统计失败:%w", s.Table, err)
+		}
+		if st.OrphanInst > 0 {
+			rows, err := legacy.QueryContext(ctx, fmt.Sprintf(`
+SELECT DISTINCT s.`+"`%s`"+` FROM `+"`%s`"+` s
+  LEFT JOIN %s.t_institution i ON i.institution_id = s.`+"`%s`"+`
+ WHERE i.institution_id IS NULL ORDER BY 1`, s.InstCol, s.Table, cfg.TargetDB, s.InstCol))
+			if err == nil {
+				for rows.Next() {
+					var v int64
+					_ = rows.Scan(&v)
+					st.OrphanInstList = append(st.OrphanInstList, strconv.FormatInt(v, 10))
+				}
+				rows.Close()
+			}
+		}
+		if s.CampusCol != "" {
+			if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
+				"SELECT COUNT(DISTINCT `%s`) FROM `%s` WHERE `%s` <> 0", s.CampusCol, s.Table, s.CampusCol)).Scan(&st.DistinctCampus); err != nil {
+				return nil, err
+			}
+			if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
+				"SELECT COUNT(*) FROM `%s` WHERE `%s` = 0", s.Table, s.CampusCol)).Scan(&st.CampusZeroRows); err != nil {
+				return nil, err
+			}
+			orphanCampusSQL := fmt.Sprintf(`
+SELECT COUNT(*) FROM `+"`%s`"+` s
+  LEFT JOIN %s.t_campus c ON c.campus_id = s.`+"`%s`"+`
+ WHERE s.`+"`%s`"+` <> 0 AND c.campus_id IS NULL`, s.Table, cfg.TargetDB, s.CampusCol, s.CampusCol)
+			if err := legacy.QueryRowContext(ctx, orphanCampusSQL).Scan(&st.OrphanCampus); err != nil {
+				return nil, fmt.Errorf("%s 孤儿校区统计失败:%w", s.Table, err)
+			}
+			if st.OrphanCampus > 0 {
+				rows, err := legacy.QueryContext(ctx, fmt.Sprintf(`
+SELECT DISTINCT s.`+"`%s`"+` FROM `+"`%s`"+` s
+  LEFT JOIN %s.t_campus c ON c.campus_id = s.`+"`%s`"+`
+ WHERE s.`+"`%s`"+` <> 0 AND c.campus_id IS NULL ORDER BY 1`, s.CampusCol, s.Table, cfg.TargetDB, s.CampusCol, s.CampusCol))
+				if err == nil {
+					for rows.Next() {
+						var v int64
+						_ = rows.Scan(&v)
+						st.OrphanCampusList = append(st.OrphanCampusList, strconv.FormatInt(v, 10))
+					}
+					rows.Close()
+				}
+			}
+		}
+		rep.RefStats = append(rep.RefStats, st)
+	}
+
+	totalOrphanInst, totalOrphanCampus := int64(0), int64(0)
+	var orphanDetail []string
+	for _, st := range rep.RefStats {
+		totalOrphanInst += st.OrphanInst
+		totalOrphanCampus += st.OrphanCampus
+		if st.OrphanInst > 0 {
+			orphanDetail = append(orphanDetail, fmt.Sprintf("%s 孤儿机构 %d 行 %v", st.Source, st.OrphanInst, st.OrphanInstList))
+		}
+		if st.OrphanCampus > 0 {
+			orphanDetail = append(orphanDetail, fmt.Sprintf("%s 孤儿校区 %d 行 %v", st.Source, st.OrphanCampus, st.OrphanCampusList))
+		}
+	}
+	detail := fmt.Sprintf("8 个来源合计:孤儿机构 %d 行、孤儿校区 %d 行(期望都为 0)", totalOrphanInst, totalOrphanCampus)
+	if len(orphanDetail) > 0 {
+		detail += ";" + strings.Join(orphanDetail, ";")
+	}
+	rep.Assertions = append(rep.Assertions, Assertion{
+		Name:   "引用完整性:数据里出现过的每个 institution_id / campus_id 都能在 target 找到(孤儿 = 0)",
+		Pass:   totalOrphanInst == 0 && totalOrphanCampus == 0,
+		Detail: detail,
+	})
+
+	// ---------- 7. cross-org-campus 标注(只报告,不判失败) ----------
+	for _, s := range orgRefSources() {
+		if s.CampusCol == "" {
+			continue
+		}
+		rows, err := legacy.QueryContext(ctx, fmt.Sprintf(
+			"SELECT DISTINCT `%s`, `%s`, COUNT(*) FROM `%s` WHERE `%s` <> 0 GROUP BY 1,2 ORDER BY 1,2",
+			s.InstCol, s.CampusCol, s.Table, s.CampusCol))
+		if err != nil {
+			return nil, err
+		}
+		for rows.Next() {
+			var inst, camp, n int64
+			if err := rows.Scan(&inst, &camp, &n); err != nil {
+				rows.Close()
+				return nil, err
+			}
+			if IsCrossOrgCampus(inst, camp, campusOwner) {
+				rep.CrossOrg = append(rep.CrossOrg, fmt.Sprintf(
+					"%s: schoolid=%d campusid=%d(校区真实所属=%d)%d 行",
+					s.Table, inst, camp, campusOwner[camp], n))
+			}
+		}
+		rows.Close()
+	}
+	sort.Strings(rep.CrossOrg)
+	return rep, nil
+}
+
+// SnapshotOrg 采集 target 两张表的 (id → 名称) 快照,用于幂等比对的「id 不变」判定。
+func SnapshotOrg(ctx context.Context, cfg Config) (map[int64]string, map[int64]string, error) {
+	db, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, nil, err
+	}
+	defer db.Close()
+	inst := map[int64]string{}
+	camp := map[int64]string{}
+	rows, err := db.QueryContext(ctx, `SELECT institution_id, institution_name FROM t_institution`)
+	if err != nil {
+		return nil, nil, err
+	}
+	for rows.Next() {
+		var id int64
+		var n string
+		if err := rows.Scan(&id, &n); err != nil {
+			rows.Close()
+			return nil, nil, err
+		}
+		inst[id] = n
+	}
+	rows.Close()
+	rows2, err := db.QueryContext(ctx, `SELECT campus_id, campus_name FROM t_campus`)
+	if err != nil {
+		return nil, nil, err
+	}
+	for rows2.Next() {
+		var id int64
+		var n string
+		if err := rows2.Scan(&id, &n); err != nil {
+			rows2.Close()
+			return nil, nil, err
+		}
+		camp[id] = n
+	}
+	rows2.Close()
+	return inst, camp, nil
+}

+ 50 - 0
internal/academic/dualrun/fixture/config.go

@@ -48,6 +48,9 @@ var (
 		"finance_bk":     true,
 		"legacy_oracle":  true,
 		"yuansiwei_2026": true,
+		// org 域(机构/校区)迁移源,**只读**。见 readOnlyDatabases。
+		"yuansiwei_i": true,
+		"yuansiwei":   true,
 	}
 	// writableDatabases 明确「哪些库允许写」:目标库只白名单 yuansiwei_2026。
 	writableDatabases = map[string]bool{
@@ -57,6 +60,8 @@ var (
 	readOnlyDatabases = map[string]bool{
 		"finance_bk":    true,
 		"legacy_oracle": true,
+		"yuansiwei_i":   true,
+		"yuansiwei":     true,
 		// e2e_test 有独立的建表逻辑,本包不碰。
 		"e2e_test": true,
 	}
@@ -81,6 +86,11 @@ type Config struct {
 	LegacyDB   string
 	LegacyUser string
 	LegacyPass string
+
+	// OrgSourceDB 是 org 域(t_school / t_school_campus)的迁移源库,只读。
+	OrgSourceDB   string
+	OrgSourceUser string
+	OrgSourcePass string
 }
 
 // dsnFile 是仓库里已存在的 dev 配置(口令已在磁盘上,本包只读取、不新增副本)。
@@ -97,6 +107,11 @@ func (c Config) TargetMasked() string {
 	return fmt.Sprintf("%s@tcp(%s:%d)/%s", c.TargetUser, c.Host, c.Port, c.TargetDB)
 }
 
+// OrgMasked 返回 org 源库不含口令的 DSN 形态。
+func (c Config) OrgMasked() string {
+	return fmt.Sprintf("%s@tcp(%s:%d)/%s", c.OrgSourceUser, c.Host, c.Port, c.OrgSourceDB)
+}
+
 // LegacyMasked 返回 legacy 侧不含口令的 DSN 形态。
 func (c Config) LegacyMasked() string {
 	return fmt.Sprintf("%s@tcp(%s:%d)/%s", c.LegacyUser, c.Host, c.Port, c.LegacyDB)
@@ -117,6 +132,10 @@ func LoadConfig(repoRoot string) (Config, error) {
 		LegacyDB:   envOr("AC15_LEGACY_DB", "finance_bk"),
 		LegacyUser: envOr("AC15_LEGACY_USER", ""),
 		LegacyPass: os.Getenv("AC15_LEGACY_PASS"),
+
+		OrgSourceDB:   envOr("AC15_ORG_DB", "yuansiwei_i"),
+		OrgSourceUser: os.Getenv("AC15_ORG_USER"),
+		OrgSourcePass: os.Getenv("AC15_ORG_PASS"),
 	}
 
 	// 目标库口令缺失时回退到仓库既有 dev.json(只读其 db.dsn,不写回)。
@@ -145,6 +164,12 @@ func LoadConfig(repoRoot string) (Config, error) {
 	if cfg.LegacyPass == "" {
 		cfg.LegacyPass = cfg.TargetPass
 	}
+	if cfg.OrgSourceUser == "" {
+		cfg.OrgSourceUser = cfg.TargetUser
+	}
+	if cfg.OrgSourcePass == "" {
+		cfg.OrgSourcePass = cfg.TargetPass
+	}
 
 	if cfg.TargetPass == "" {
 		return Config{}, fmt.Errorf("目标库口令不可得:请设置 AC15_DB_PASS 或在 %s/config/dev.json 提供 db.dsn", repoRoot)
@@ -185,6 +210,9 @@ func (c Config) Guard() error {
 	if !allowedDatabases[c.LegacyDB] {
 		return fmt.Errorf("数据源守卫失败:legacy 库 %q 不在允许清单内", c.LegacyDB)
 	}
+	if c.OrgSourceDB != "" && !allowedDatabases[c.OrgSourceDB] {
+		return fmt.Errorf("数据源守卫失败:org 源库 %q 不在允许清单内", c.OrgSourceDB)
+	}
 	if readOnlyDatabases[c.LegacyDB] == false {
 		// legacy_oracle 虽在 allowedDatabases 内,但本包对 legacy 侧一律只读;
 		// 这里只做提示性约束:真正保证由 extract.go 只发 SELECT 实现。
@@ -207,6 +235,28 @@ func (c Config) targetDSN() string {
 		c.TargetUser, c.TargetPass, c.Host, c.Port, c.TargetDB)
 }
 
+func (c Config) orgSourceDSN() string {
+	return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
+		c.OrgSourceUser, c.OrgSourcePass, c.Host, c.Port, c.OrgSourceDB)
+}
+
+// OpenOrgSource 打开 org 域迁移源(`yuansiwei_i`)的**只读**连接。
+func (c Config) OpenOrgSource() (*sql.DB, error) {
+	db, err := openGorm(c.orgSourceDSN())
+	if err != nil {
+		return nil, fmt.Errorf("连接 org 源库失败:%w", err)
+	}
+	if err := assertDatabase(db, c.OrgSourceDB); err != nil {
+		_ = db.Close()
+		return nil, err
+	}
+	if _, err := db.Exec("SET SESSION TRANSACTION READ ONLY"); err != nil {
+		_ = db.Close()
+		return nil, fmt.Errorf("org 源库会话只读设置失败:%w", err)
+	}
+	return db, nil
+}
+
 func (c Config) legacyDSN() string {
 	return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
 		c.LegacyUser, c.LegacyPass, c.Host, c.Port, c.LegacyDB)