Sfoglia il codice sorgente

feat(ac15): 活动域快照 —— 六表三态 + 多重集行集比对(commit 1,不动 case)

活动链 9 支里只有 1 支碰资金表,其余 8 支的资金表引用数 = 0 ⇒ C1/C2 那套
Δbalance/Δspecial/流水 type 对它们完全不适用。本 commit 只加"读活动域、比活动域"的基建,
一条 case 都不加,目的一是让比对机器独立可审,二是首红时能分清是新机器错还是 SP 错。

新增 activity_snapshot.go:
  · 六表快照:t_courses_activity / _signed / _used_log / _user_discount / _user_discount_log / t_courses_student
  · 逐列三态(NULL 折 0 是 C1-11 花一整轮修掉的东西,不在新域重犯)
  · readRound2 与 readRound3 **两个独立函数**(user_discount 同表两列精度不同,合并会静默降精度)
  · 行集用**多重集**(map[tuple]int):这三张 log 表两侧都没有业务唯一键,
    "按键配对"会让"少写一条+多写一条"互相抵消;多重集下抵消只可能落在同一 tuple,
    而那本身就是计数不同 = 红
  · 归一清单:排除自增 id 与时间戳;父行 activity_user_discount_id → (activity_id,user_id);
    courses_student_id → courses_student_no;user_id 两侧统一成 ULID
  · countOtherDiscountLogLegacy:user_discount_refund 的游标只按 courses_studentid、不带 userid,
    旧库真有 162 行 log ⇒ 必须在 case 开始把"污染源为 0"钉住

SideSnapshot 加 Activity *ActivitySnapshot:
  · nil = 不涉及活动域 ⇒ 所有活动域比对 Skipped,**不把 nil == nil 当相等**
  · 旧的 Signed(只投影 status)一行不改,7 条已绿 case 不受影响;
    两者合并要动绿的,属于"改绿的东西",单开
  · 顺带修一个差点静默的 bug:首版把 Activity 加进了 SideResult(CampusMeta 在那儿),
    编译期就报了 undefined —— 若那个结构体字段没人读,它会静默"永远为 nil"。
    为此新增一条基建断言"活动域快照两侧都读到了(否则整块活动比对会无声 Skipped)",
    实测 9 条活动 case 都是 plan 7 条 / 读到 7 条 / 两侧一致

验收(干净树):35 条 **35 PASS / 0 FAIL**,Skipped 仍 69(一条判据没动),
业务断言 181(不变),基建 55→64(+9 = 新增的快照前提断言),未分类 0。
go build / vet / test 全绿;新增 activity_snapshot_test.go 9 个纯逻辑单测
(多重集防抵消、重复计数、NULL≠0、2位/3位读法不可合并、nil 不许当相等)。
Q 2 ore fa
parent
commit
80b0dd0b1a

+ 828 - 0
internal/academic/dualrun/stage2/activity_snapshot.go

@@ -0,0 +1,828 @@
+package stage2
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"sort"
+	"strings"
+
+	"gorm.io/gorm"
+)
+
+// 本文件是批 3a 的**活动域快照**:六张表、逐列三态、行集多重集比对。
+//
+// # 为什么必须有它
+//
+// 活动链 9 支 SP 里 **只有 1 支碰资金表**(`use_activity_recommend`,14 处引用),
+// 其余 8 支的资金表引用数 = 0 —— 它们在 SP 层面根本不写资金。
+// 所以 C1/C2 那套 `Δbalance / Δspecial / Δincome / 流水 type` 比对机器对它们**完全不适用**,
+// 必须换成"读活动域的表,比活动域的行"。
+//
+// # 三条设计红线(都是踩过的坑,不要"优化"掉)
+//
+//  1. **逐列三态**:六张表的金额列大量 `DEFAULT NULL`(`_signed.activity_money`、
+//     `_user_discount.discount_amount`、`used_log.activity_money_used` ……)。
+//     把 NULL 折成 0,"这行从没被写过"和"这列恰好是 0"就混成一个数 —— 那是 C1-11 那一整轮
+//     花代价修掉的东西,不能在新域重犯。
+//
+//  2. **`readRound2` 与 `readRound3` 不共用**:`t_courses_activity_user_discount` 同一张表里
+//     `discount_amount` 是 `float(10,2)`、`discount_amount_used` 是 `float(10,3)`。
+//     抽一个"公共读金额"函数就会把 3 位那列悄悄降成 2 位,而且**不会报错**。
+//     所以两个函数分开写,函数名就是口径。
+//
+//  3. **行集比对用多重集**,不用"靠某个唯一键配对":
+//     legacy 这五张表**只有 `PRIMARY(id)`**,没有任何业务唯一键(实测);
+//     target 侧也只给 `t_courses_activity_signed` 补了 `uk_user_activity`,
+//     `used_log` / `user_discount` / `discount_log` 仍然全是非唯一索引。
+//     "靠键配对"必然要么撞键、要么漏行;而多重集对"少写一条 / 多写一条 / 写错一条"
+//     三种错法全部可检(抵消只可能发生在同一个 tuple 上,而那本身就是计数不同 = 红)。
+//
+// # 归一:哪些列不能直接进 tuple
+//
+//	自增 id(两侧起点不同)           → 排除
+//	create_time / response_time       → 排除(NOW() 逐条不同)
+//	`activity_user_discount_id`(父行自增 id) → 换成 (activity_id, user_id)
+//	`courses_student_id`(bigint)    → 换成 `courses_student_no`(旧 UUID)
+//	`user_id`(ULID)                 → legacy 侧经 CanonicalUIDFor 换成 ULID,两侧同键
+
+// tupleSep / nullMark 是"把一行拼成一个可比较的字符串"用的分隔符。
+//
+// 用 `\x1f`(ASCII Unit Separator):这些字段的取值都是金额 / 主键 / 备注 / 单号,
+// 不会出现控制字符。NULL 用 `\x00` 单独标记 —— **不能**用空串代替,
+// 否则"列是 NULL"与"列是空字符串"会拼成同一个 tuple(又一次 NULL 折 0)。
+const (
+	tupleSep = "\x1f"
+	nullMark = "\x00"
+)
+
+// ---------------- 快照类型 ----------------
+
+// ActivitySnapshot 是一侧活动域的完整快照。
+//
+// `nil` 表示"本 case 不涉及活动域"(例如纯资金 case)。**nil 与 nil 不许当成"两侧相等"** ——
+// 那是"没测"而不是"测过且相等",所以所有活动域比对在 nil 时一律 `Skipped`。
+type ActivitySnapshot struct {
+	// Activities 是本 case plan 内每个活动的状态(key = 活动 id)。
+	Activities map[int64]ActivityState
+	// Signed 是本 case 用户在 plan 内活动上的报名整行(key = 活动 id + 用户 ULID)。
+	Signed map[ActivitySignedKey]SignedState
+	// UsedLog 是本 case plan 内活动上的用量流水**整表行集**。
+	UsedLog []UsedLogRow
+	// Discounts 是赠送额度整行(key = 活动 id + 用户 ULID)。
+	Discounts map[ActivitySignedKey]DiscountState
+	// DiscountLog 是赠送额度流水**整表行集**。
+	DiscountLog []DiscountLogRow
+	// Students 是报课欠费(key = `biz_no`,即旧 `t_courses_student.id`)。
+	Students map[string]StudentArrears
+}
+
+// ActivityState 是 `t_courses_activity` 里本 case 关心的列。
+//
+// 只有 `number_sign` 会被 SP 改(signed_add +1 / signed_cancel −1);
+// 其余列是种子写的,由「活动种子基线」那条断言覆盖。
+type ActivityState struct {
+	Type          int64
+	Money         int64 // readRound2:`float(10,2)`
+	InstitutionID int64
+	CampusID      int64
+	NumberSign    int64
+}
+
+// ActivitySignedKey 是报名行 / 赠送额度行的自然键。
+//
+// `UserUID` 两侧统一成 ULID 字符串(legacy 侧经 `CanonicalUIDFor`),
+// 这样同一个人的行在两侧落进同一个 key —— 而不是拿 int 与 ULID 硬比。
+type ActivitySignedKey struct {
+	ActivityID int64
+	UserUID    string
+}
+
+// SignedState 是 `t_courses_activity_signed` 的一行。
+//
+// 三个金额列在 legacy 是 `decimal(10,3) NULL`、在 target 是 `decimal(12,2) NOT NULL`:
+// **NULL 在 target 不可表达**。所以这里三态照记,比对上由 `assertTriStateMapping` 按
+// "legacy NULL ⇒ target 必须恰好 0.00" 的映射去判(Q1:这是**真断言**,不是跳过)。
+type SignedState struct {
+	Status int64
+
+	ActivityMoney         int64
+	ActivityMoneyNull     bool
+	ActivityMoneyUsed     int64
+	ActivityMoneyUsedNull bool
+	RechargeAmount        int64
+	RechargeAmountNull    bool
+
+	// RecommendUserUID `""` = 两侧都 NULL;`RecommendLegacyZero` 记"legacy 是 0 而非 NULL"。
+	//
+	// 为什么要这个标记:`0 ↔ NULL` 的等价**是我们定义的**,不能静默归一 ——
+	// 归一之后"legacy 写了 0、target 写了 NULL"这种真差异会被吃掉。
+	// 所以保留原始事实,等价性交给 `assertIdentityZeroNull` 单独断言并打两侧原值(Q2)。
+	RecommendUserUID    string
+	RecommendLegacyZero bool
+	ResponseUserUID     string
+	ResponseLegacyZero  bool
+
+	RequestNo string
+	Memo      string
+	// Rows 是同键行数。两侧都应恒为 1(target 有 `uk_user_activity`;legacy 没有,
+	// 靠 SP 的 DELETE+INSERT 保证)—— 由 `assertSingleSignedRow` 断言(补充③)。
+	Rows int
+}
+
+// UsedLogRow 是 `t_courses_activity_used_log` 的一行(进 tuple 的列)。
+type UsedLogRow struct {
+	ActivityID          int64
+	UserUID             string
+	RecommendUserUID    string
+	RecommendLegacyZero bool
+	OrderNo             string
+	Money               int64 // readRound2(旧 `decimal(10,3) NULL`)
+	MoneyNull           bool
+	Memo                string
+}
+
+// DiscountState 是 `t_courses_activity_user_discount` 的一行。
+//
+// ⚠ 同一张表内两列精度不同:`DiscountAmount` 走 `readRound2`、
+// `DiscountAmountUsed` 走 `readRound3`→2。**不要合并成一个读函数。**
+type DiscountState struct {
+	DiscountAmount     int64
+	DiscountAmountUsed int64
+	Income             int64
+	// BalanceRequestIDNull 记"该列为 NULL"。3a 的 case 一律置 NULL 并**显式断言两侧都是 NULL**
+	// (Q3:将来有人改种子顺手带值,没有断言就会静默引入未验证的 id 重映射路径)。
+	BalanceRequestIDNull bool
+	Rows                 int
+}
+
+// DiscountLogRow 是 `t_courses_activity_user_discount_log` 的一行(进 tuple 的列)。
+//
+// 父行自增 id 两侧必然不同 ⇒ 归一成父行的 `(ActivityID, UserUID)` 再进 tuple。
+type DiscountLogRow struct {
+	ParentActivityID int64
+	ParentUserUID    string
+	CoursesStudentNo string
+	Money            int64 // readRound3 → 2
+	Memo             string
+}
+
+// StudentArrears 是 `t_courses_student` 的欠费两列(key = `biz_no`)。
+//
+// ⚠ **只读 `net_receipts` 与 `remainder_receivable`**。
+// 不读 `unpaid`:它是 2018-05 起停更的僵尸列(`course_handler.go:647`、设计 §3.8-E4),
+// 禁展示禁下发;拿它当真值会比出一个不存在的差异。
+type StudentArrears struct {
+	NetReceipts         int64
+	RemainderReceivable int64
+	// ConfirmDiscountSet = legacy `confirm_discount_userid > 0` / target `confirm_discount_user_id IS NOT NULL`。
+	//
+	// 它是**核销候选判据**的一半(另一半是 `remainder_receivable > 0`)。
+	// target 侧该列是 `varchar(26) NULL`,**NULL 语义 = 不可充值/不可核销**(模型注释原话:
+	// "NULL 语义=不可充值,勿用 0 替代")。所以种 target 侧必须给 ULID,
+	// 否则 target "不可核销"、legacy "可核销",红在种子而不是 SP。
+	ConfirmDiscountSet bool
+	Rows               int
+}
+
+// ---------------- 读取:legacy 侧 ----------------
+
+// readActivitySnapshotLegacy 读 legacy 侧的活动域快照。
+//
+// 只读 **本 case plan 内** 的行:活动表按 id、报名/额度按 (活动 id, 本 case 用户)、
+// 用量流水按活动 id、赠送流水按父行 join、报课欠费按 `Activity.StudentBizNos`。
+// 不做全表扫描 —— 一是慢,二是会把"别的用户的行"卷进比对,制造不可读的红。
+func readActivitySnapshotLegacy(ctx context.Context, tx *gorm.DB, c Case) (*ActivitySnapshot, error) {
+	p := c.ActivityPlan
+	if p == nil {
+		return nil, nil
+	}
+	ids := p.ActivityIDs()
+	if len(ids) == 0 {
+		return nil, nil
+	}
+	out := &ActivitySnapshot{
+		Activities: map[int64]ActivityState{},
+		Signed:     map[ActivitySignedKey]SignedState{},
+		Discounts:  map[ActivitySignedKey]DiscountState{},
+		Students:   map[string]StudentArrears{},
+	}
+
+	// ① 活动行
+	{
+		rows, err := tx.Raw(`
+SELECT id, IFNULL(type,0), `+readRound2("money")+`, IFNULL(schoolid,0), IFNULL(campusid,0), IFNULL(number_sign,0)
+  FROM t_courses_activity WHERE id IN (?)`, ids).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 legacy 活动行失败:%w", err)
+		}
+		for rows.Next() {
+			var id int64
+			var st ActivityState
+			var money sql.NullString
+			if err := rows.Scan(&id, &st.Type, &money, &st.InstitutionID, &st.CampusID, &st.NumberSign); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			st.Money, _ = centsOrNull(money)
+			out.Activities[id] = st
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+
+	// ② 报名行(整行,含三态金额与两个 identity 列)
+	{
+		rows, err := tx.Raw(`
+SELECT activityid,
+       IFNULL(status,0),
+       `+readRound2("activity_money")+`,
+       `+readRound2("activity_money_used")+`,
+       `+readRound2("recharge_amount")+`,
+       recommend_userid, response_userid,
+       IFNULL(requestid,''), IFNULL(memo,'')
+  FROM t_courses_activity_signed
+ WHERE activityid IN (?) AND userid = ?`, ids, c.LegacyUserID).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 legacy 报名行失败:%w", err)
+		}
+		uid := CanonicalUIDFor(c.LegacyUserID)
+		for rows.Next() {
+			var aid int64
+			var st SignedState
+			var money, used, recharge, rec, resp sql.NullString
+			if err := rows.Scan(&aid, &st.Status, &money, &used, &recharge, &rec, &resp,
+				&st.RequestNo, &st.Memo); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			st.ActivityMoney, st.ActivityMoneyNull = centsOrNull(money)
+			st.ActivityMoneyUsed, st.ActivityMoneyUsedNull = centsOrNull(used)
+			st.RechargeAmount, st.RechargeAmountNull = centsOrNull(recharge)
+			st.RecommendUserUID, st.RecommendLegacyZero = legacyIdentity(rec)
+			st.ResponseUserUID, st.ResponseLegacyZero = legacyIdentity(resp)
+			key := ActivitySignedKey{ActivityID: aid, UserUID: uid}
+			prev := out.Signed[key]
+			st.Rows = prev.Rows + 1
+			out.Signed[key] = st
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+
+	// ③ 用量流水(整表行集)
+	{
+		rows, err := tx.Raw(`
+SELECT activityid, userid, IFNULL(recommend_userid,0),
+       IFNULL(orderid,''), `+readRound2("activity_money_used")+`, IFNULL(memo,'')
+  FROM t_courses_activity_used_log WHERE activityid IN (?)`, ids).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 legacy 用量流水失败:%w", err)
+		}
+		for rows.Next() {
+			var r UsedLogRow
+			var uidInt, recInt int64
+			var money sql.NullString
+			if err := rows.Scan(&r.ActivityID, &uidInt, &recInt, &r.OrderNo, &money, &r.Memo); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			r.UserUID = CanonicalUIDFor(uidInt)
+			r.RecommendUserUID, r.RecommendLegacyZero = legacyIdentity(sql.NullString{String: fmt.Sprint(recInt), Valid: true})
+			r.Money, r.MoneyNull = centsOrNull(money)
+			out.UsedLog = append(out.UsedLog, r)
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+
+	// ④ 赠送额度行
+	{
+		rows, err := tx.Raw(`
+SELECT d.activityid, d.userid,
+       `+readRound2("d.discount_amount")+`,
+       `+readRound3("d.discount_amount_used")+`,
+       `+readRound2("d.income")+`,
+       d.balance_requestid
+  FROM t_courses_activity_user_discount d
+ WHERE d.activityid IN (?) AND d.userid = ?`, ids, c.LegacyUserID).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 legacy 赠送额度失败:%w", err)
+		}
+		for rows.Next() {
+			var aid, uidInt int64
+			var amount, used, income, brid sql.NullString
+			if err := rows.Scan(&aid, &uidInt, &amount, &used, &income, &brid); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			var st DiscountState
+			st.DiscountAmount, _ = centsOrNull(amount)
+			st.DiscountAmountUsed, _ = centsOrNull(used)
+			st.Income, _ = centsOrNull(income)
+			st.BalanceRequestIDNull = !brid.Valid
+			key := ActivitySignedKey{ActivityID: aid, UserUID: CanonicalUIDFor(uidInt)}
+			prev := out.Discounts[key]
+			st.Rows = prev.Rows + 1
+			out.Discounts[key] = st
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+
+	// ⑤ 赠送额度流水(按父行 join,把父行自增 id 归一成 (activityid, userid))
+	{
+		rows, err := tx.Raw(`
+SELECT d.activityid, d.userid, IFNULL(l.courses_studentid,''),
+       `+readRound3("l.discount_amount_used")+`, IFNULL(l.memo,'')
+  FROM t_courses_activity_user_discount_log l
+  JOIN t_courses_activity_user_discount d ON d.id = l.activity_user_discountid
+ WHERE d.activityid IN (?)`, ids).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 legacy 赠送流水失败:%w", err)
+		}
+		for rows.Next() {
+			var r DiscountLogRow
+			var uidInt int64
+			var money sql.NullString
+			if err := rows.Scan(&r.ParentActivityID, &uidInt, &r.CoursesStudentNo, &money, &r.Memo); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			r.ParentUserUID = CanonicalUIDFor(uidInt)
+			r.Money, _ = centsOrNull(money)
+			out.DiscountLog = append(out.DiscountLog, r)
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+
+	// ⑥ 报课欠费(按 biz_no;legacy 的 `t_courses_student.id` 就是旧 UUID)
+	if len(c.Activity.StudentBizNos) > 0 {
+		rows, err := tx.Raw(`
+SELECT id, `+readRound2("net_receipts")+`, `+readRound2("remainder_receivable")+`,
+       IF(IFNULL(confirm_discount_userid,0) > 0, 1, 0)
+  FROM t_courses_student WHERE id IN (?)`, c.Activity.StudentBizNos).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 legacy 报课欠费失败:%w", err)
+		}
+		for rows.Next() {
+			var biz string
+			var st StudentArrears
+			var net, rem sql.NullString
+			var confirm int64
+			if err := rows.Scan(&biz, &net, &rem, &confirm); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			st.NetReceipts, _ = centsOrNull(net)
+			st.RemainderReceivable, _ = centsOrNull(rem)
+			st.ConfirmDiscountSet = confirm == 1
+			st.Rows++
+			out.Students[biz] = st
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+	return out, nil
+}
+
+// ---------------- 读取:target 侧 ----------------
+
+// readActivitySnapshotTarget 读 target 侧的活动域快照(列名是目标态命名)。
+//
+// ⚠ 三处必须记住的映射:
+//   - `t_courses_activity.schoolid/campusid` → `institution_id/campus_id`
+//   - `t_courses_activity_user_discount_log.courses_studentid` → **`courses_student_no`**
+//     (不是 `courses_student_id`;后者是 bigint 自增,两侧必然不同)
+//   - `t_courses_student.id`(bigint 自增)→ 定位用 **`biz_no`**(旧 UUID)
+func readActivitySnapshotTarget(ctx context.Context, tx *gorm.DB, c Case, uid string) (*ActivitySnapshot, error) {
+	p := c.ActivityPlan
+	if p == nil {
+		return nil, nil
+	}
+	ids := p.ActivityIDs()
+	if len(ids) == 0 {
+		return nil, nil
+	}
+	out := &ActivitySnapshot{
+		Activities: map[int64]ActivityState{},
+		Signed:     map[ActivitySignedKey]SignedState{},
+		Discounts:  map[ActivitySignedKey]DiscountState{},
+		Students:   map[string]StudentArrears{},
+	}
+
+	// ① 活动行
+	{
+		rows, err := tx.Raw(`
+SELECT id, type, `+readRound2("money")+`, institution_id, campus_id, number_sign
+  FROM t_courses_activity WHERE id IN (?)`, ids).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 target 活动行失败:%w", err)
+		}
+		for rows.Next() {
+			var id int64
+			var st ActivityState
+			var money sql.NullString
+			if err := rows.Scan(&id, &st.Type, &money, &st.InstitutionID, &st.CampusID, &st.NumberSign); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			st.Money, _ = centsOrNull(money)
+			out.Activities[id] = st
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+
+	// ② 报名行
+	{
+		rows, err := tx.Raw(`
+SELECT activity_id, status,
+       `+readRound2("activity_money")+`,
+       `+readRound2("activity_money_used")+`,
+       `+readRound2("recharge_amount")+`,
+       recommend_user_id, response_user_id,
+       IFNULL(request_no,''), IFNULL(memo,'')
+  FROM t_courses_activity_signed
+ WHERE activity_id IN (?) AND user_id = ?`, ids, uid).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 target 报名行失败:%w", err)
+		}
+		for rows.Next() {
+			var aid int64
+			var st SignedState
+			var money, used, recharge, rec, resp sql.NullString
+			if err := rows.Scan(&aid, &st.Status, &money, &used, &recharge, &rec, &resp,
+				&st.RequestNo, &st.Memo); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			st.ActivityMoney, st.ActivityMoneyNull = centsOrNull(money)
+			st.ActivityMoneyUsed, st.ActivityMoneyUsedNull = centsOrNull(used)
+			st.RechargeAmount, st.RechargeAmountNull = centsOrNull(recharge)
+			st.RecommendUserUID = strings.TrimSpace(rec.String)
+			st.ResponseUserUID = strings.TrimSpace(resp.String)
+			key := ActivitySignedKey{ActivityID: aid, UserUID: uid}
+			prev := out.Signed[key]
+			st.Rows = prev.Rows + 1
+			out.Signed[key] = st
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+
+	// ③ 用量流水
+	{
+		rows, err := tx.Raw(`
+SELECT activity_id, user_id, recommend_user_id,
+       IFNULL(order_no,''), `+readRound2("activity_money_used")+`, IFNULL(memo,'')
+  FROM t_courses_activity_used_log WHERE activity_id IN (?)`, ids).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 target 用量流水失败:%w", err)
+		}
+		for rows.Next() {
+			var r UsedLogRow
+			var rec sql.NullString
+			var money sql.NullString
+			if err := rows.Scan(&r.ActivityID, &r.UserUID, &rec, &r.OrderNo, &money, &r.Memo); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			r.RecommendUserUID = strings.TrimSpace(rec.String)
+			r.Money, r.MoneyNull = centsOrNull(money)
+			out.UsedLog = append(out.UsedLog, r)
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+
+	// ④ 赠送额度行
+	{
+		rows, err := tx.Raw(`
+SELECT activity_id, user_id,
+       `+readRound2("discount_amount")+`,
+       `+readRound3("discount_amount_used")+`,
+       `+readRound2("income")+`,
+       balance_request_id
+  FROM t_courses_activity_user_discount
+ WHERE activity_id IN (?) AND user_id = ?`, ids, uid).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 target 赠送额度失败:%w", err)
+		}
+		for rows.Next() {
+			var aid int64
+			var uidStr string
+			var amount, used, income, brid sql.NullString
+			if err := rows.Scan(&aid, &uidStr, &amount, &used, &income, &brid); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			var st DiscountState
+			st.DiscountAmount, _ = centsOrNull(amount)
+			st.DiscountAmountUsed, _ = centsOrNull(used)
+			st.Income, _ = centsOrNull(income)
+			st.BalanceRequestIDNull = !brid.Valid
+			key := ActivitySignedKey{ActivityID: aid, UserUID: uidStr}
+			prev := out.Discounts[key]
+			st.Rows = prev.Rows + 1
+			out.Discounts[key] = st
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+
+	// ⑤ 赠送额度流水:父行 id 归一成 (activity_id, user_id);订单号用 `courses_student_no`
+	{
+		rows, err := tx.Raw(`
+SELECT d.activity_id, d.user_id, IFNULL(l.courses_student_no,''),
+       `+readRound3("l.discount_amount_used")+`, IFNULL(l.memo,'')
+  FROM t_courses_activity_user_discount_log l
+  JOIN t_courses_activity_user_discount d ON d.id = l.activity_user_discount_id
+ WHERE d.activity_id IN (?)`, ids).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 target 赠送流水失败:%w", err)
+		}
+		for rows.Next() {
+			var r DiscountLogRow
+			var money sql.NullString
+			if err := rows.Scan(&r.ParentActivityID, &r.ParentUserUID, &r.CoursesStudentNo, &money, &r.Memo); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			r.Money, _ = centsOrNull(money)
+			out.DiscountLog = append(out.DiscountLog, r)
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+
+	// ⑥ 报课欠费(按 biz_no 定位)
+	if len(c.Activity.StudentBizNos) > 0 {
+		rows, err := tx.Raw(`
+SELECT biz_no, `+readRound2("net_receipts")+`, `+readRound2("remainder_receivable")+`,
+       IF(confirm_discount_user_id IS NULL OR confirm_discount_user_id = '', 0, 1)
+  FROM t_courses_student WHERE biz_no IN (?)`, c.Activity.StudentBizNos).Rows()
+		if err != nil {
+			return nil, fmt.Errorf("读 target 报课欠费失败:%w", err)
+		}
+		for rows.Next() {
+			var biz string
+			var st StudentArrears
+			var net, rem sql.NullString
+			var confirm int64
+			if err := rows.Scan(&biz, &net, &rem, &confirm); err != nil {
+				_ = rows.Close()
+				return nil, err
+			}
+			st.NetReceipts, _ = centsOrNull(net)
+			st.RemainderReceivable, _ = centsOrNull(rem)
+			st.ConfirmDiscountSet = confirm == 1
+			st.Rows++
+			out.Students[biz] = st
+		}
+		if err := rows.Err(); err != nil {
+			_ = rows.Close()
+			return nil, err
+		}
+		_ = rows.Close()
+	}
+	return out, nil
+}
+
+// ---------------- 归一与比对 ----------------
+
+// legacyIdentity 把 legacy 的 int identity 列(`recommend_userid` / `response_userid` 等)
+// 转成 (ULID, 是否"恰好是 0")。
+//
+// 为什么保留 `isZero` 而不直接返回 `""`:`0 ↔ NULL` 的等价**是我们定义的**,
+// 静默归一会把"legacy 写了 0、target 写了 NULL"这种真差异吃掉(Q2)。
+// 所以这里只做**取值**,等价性交给 `assertIdentityZeroNull` 单独断言并打两侧原值。
+func legacyIdentity(v sql.NullString) (uid string, isZero bool) {
+	if !v.Valid {
+		return "", false // NULL
+	}
+	s := strings.TrimSpace(v.String)
+	if s == "" || s == "0" {
+		return "", s == "0"
+	}
+	if n, err := parseInt64(s); err == nil {
+		return CanonicalUIDFor(n), false
+	}
+	return s, false
+}
+
+// parseInt64 是只接受十进制整数的解析(不吞掉小数与符号异常)。
+func parseInt64(s string) (int64, error) {
+	var n int64
+	if _, err := fmt.Sscan(s, &n); err != nil {
+		return 0, err
+	}
+	return n, nil
+}
+
+// readRound2 是**2 位口径**的金额读法(迁移比较器:先 CAST 到 3 位定标,再 ROUND 到 2 位)。
+//
+// ⚠ 不要把它当成"通用金额读法":`t_courses_activity_user_discount.discount_amount_used`
+// 在旧库是 `float(10,3)`,必须走 `readRound3`。两列同表不同精度,
+// 抽一个公共函数就会把那列静默降成 2 位 —— 而且不会报错,只会让比对变松。
+func readRound2(col string) string {
+	return "CAST(ROUND(CAST(" + col + " AS DECIMAL(20,3)), 2) AS DECIMAL(20,2))"
+}
+
+// readRound3 是**3 位口径**的金额读法,用于 `discount_amount_used` 这类 `float(10,3)` 列。
+//
+// 3 位读出来后由 `centsOrNull` 转成分;与 2 位列相比**多一位可观测精度**,
+// 这正是 `user_discount_*` 那几支要的(它们的列宽是 3 位)。
+func readRound3(col string) string {
+	return "CAST(ROUND(CAST(" + col + " AS DECIMAL(20,3)), 3) AS DECIMAL(20,3))"
+}
+
+// usedLogTuple 把一行用量流水拼成可比字符串。
+//
+// 进 tuple 的列见文件头"归一"清单:**不含**自增 id、**不含** `create_time`。
+func usedLogTuple(r UsedLogRow) string {
+	return strings.Join([]string{
+		fmt.Sprint(r.ActivityID),
+		r.UserUID,
+		identityPart(r.RecommendUserUID),
+		orNull(r.OrderNo, r.OrderNo == ""),
+		moneyPart(r.Money, r.MoneyNull),
+		orNull(r.Memo, r.Memo == ""),
+	}, tupleSep)
+}
+
+// discountLogTuple 同上。父行自增 id 已归一成 (活动 id, 用户 ULID)。
+func discountLogTuple(r DiscountLogRow) string {
+	return strings.Join([]string{
+		fmt.Sprint(r.ParentActivityID),
+		r.ParentUserUID,
+		orNull(r.CoursesStudentNo, r.CoursesStudentNo == ""),
+		moneyPart(r.Money, false),
+		orNull(r.Memo, r.Memo == ""),
+	}, tupleSep)
+}
+
+// identityPart 渲染 identity 列:`""` 记成 NULL。
+//
+// ⚠ 这里把"legacy 0"与"target NULL"**都渲染成 NULL** —— 也就是说多重集比对里
+// `0 ↔ NULL` 被当作同一含义。这**是有意的**,但**不允许**它成为唯一的验证:
+// 若某一侧写了 0、另一侧写了 NULL,`assertIdentityZeroNull` 会单独把两侧原值打出来。
+// 也就是说:**多重集负责"行数/金额对不对",那条断言负责"等价判据本身是否仍成立"**,
+// 两者分工不同,缺一不可。
+func identityPart(uid string) string {
+	if uid == "" {
+		return nullMark
+	}
+	return uid
+}
+
+// moneyPart 渲染三态金额:NULL 用 `nullMark`,**不能渲染成 0**。
+func moneyPart(cents int64, isNull bool) string {
+	if isNull {
+		return nullMark
+	}
+	return fmt.Sprint(cents)
+}
+
+// orNull 把"空字符串"渲染成 NULL 标记(数据库里大部分文本列 NULL 与 ” 混用,
+// 本库里 SP 一律写 `IFNULL(x,”)` 读,所以比对时把两者视作同一含义并**在注释里写明**)。
+func orNull(s string, isNull bool) string {
+	if isNull {
+		return nullMark
+	}
+	return s
+}
+
+// buildMultiset 把行集渲染成"tuple → 出现次数"的多重集。
+func buildMultiset(rows []string) map[string]int {
+	m := make(map[string]int, len(rows))
+	for _, r := range rows {
+		m[r]++
+	}
+	return m
+}
+
+// multisetDiff 比较两个多重集,返回 (是否相等, 可读明细)。
+//
+// 为什么用多重集而不是"按键配对":
+//   - 这三张 log 表**两侧都没有业务唯一键**(legacy 只有 `PRIMARY(id)`,target 也只有非唯一索引);
+//   - "靠键配对"遇到重复键时只能取一条,于是"少写一条 + 多写一条"可能落在同一个键上互相抵消;
+//   - 多重集下抵消只可能发生在**同一个 tuple** 上,而同一个 tuple 计数不同本身就是红。
+//
+// 明细按 tuple 排序输出(map 遍历无序,不排序的报告每次不一样,没法 diff)。
+func multisetDiff(lhs, rhs map[string]int, label string) (bool, string) {
+	keys := make([]string, 0, len(lhs)+len(rhs))
+	seen := make(map[string]struct{}, len(lhs)+len(rhs))
+	for k := range lhs {
+		if _, ok := seen[k]; !ok {
+			seen[k] = struct{}{}
+			keys = append(keys, k)
+		}
+	}
+	for k := range rhs {
+		if _, ok := seen[k]; !ok {
+			seen[k] = struct{}{}
+			keys = append(keys, k)
+		}
+	}
+	sort.Strings(keys)
+
+	var parts []string
+	for _, k := range keys {
+		l, r := lhs[k], rhs[k]
+		if l == r {
+			continue
+		}
+		parts = append(parts, fmt.Sprintf("legacy<%s>×%d | target<%s>×%d", renderTuple(k), l, renderTuple(k), r))
+	}
+	if len(parts) == 0 {
+		return true, fmt.Sprintf("%s:%d 个 tuple 两侧多重集相同", label, len(keys))
+	}
+	return false, fmt.Sprintf("%s:%d 处计数不同 —— %s", label, len(parts), strings.Join(parts, ";"))
+}
+
+// renderTuple 把 tuple 字符串还原成可读形式(把分隔符换成 " | "、把 NULL 标记显示成 NULL)。
+func renderTuple(t string) string {
+	t = strings.ReplaceAll(t, nullMark, "NULL")
+	return strings.ReplaceAll(t, tupleSep, " | ")
+}
+
+// countOtherDiscountLogLegacy 统计"该 `courses_studentid` 下、除本 case 种的那几条之外"的负 log 条数。
+//
+// 为什么必须查这个:`sp_courses_activity_user_discount_refund` 的游标是
+//
+//	WHERE courses_studentid = ? AND discount_amount_used < 0 ORDER BY id DESC
+//
+// —— **没有 userid 过滤**。而旧库真实有 162 行 log(target 0 行)。
+// 只要 case 用的 `courses_studentid` 撞上真实行,两侧游标看到的行集天然不同,
+// 那种红是【数据碰撞】,不是 SP 行为错。
+//
+// 与 `countOtherExpirableLegacy` 同一成例:**在 case 开始就把"污染源为 0"钉住**,
+// 而不是等它以一个看不懂的差异出现。
+func countOtherDiscountLogLegacy(
+	ctx context.Context, db *gorm.DB, coursesStudentNo string, seedIDs []int64,
+) (int, error) {
+	q := `SELECT COUNT(*) FROM t_courses_activity_user_discount_log
+	       WHERE courses_studentid = ? AND discount_amount_used < 0`
+	args := []any{coursesStudentNo}
+	if len(seedIDs) > 0 {
+		q += ` AND id NOT IN (?)`
+		args = append(args, seedIDs)
+	}
+	var n int
+	if err := db.Raw(q, args...).Row().Scan(&n); err != nil {
+		return 0, fmt.Errorf("统计旧库其他负 log 失败:%w", err)
+	}
+	return n, nil
+}
+
+// activityDomainApplicable 判断"本 case 是否真的涉及活动域"。
+//
+// **两侧任一为 nil ⇒ 不适用**(所有活动域比对 Skipped)。
+// 绝不写成 `lg == tg`:`nil == nil` 会让"两侧都没读到"变成"两侧相等",
+// 而那正是"新字段对旧 case 为空被当成两侧不等/相等"的自伤点。
+func activityDomainApplicable(lg, tg *ActivitySnapshot) bool {
+	return lg != nil && tg != nil
+}
+
+// fmtSnapN 渲染"快照读到的活动条数",`-1` 表示整块 `Activity` 为 nil。
+func fmtSnapN(n int) string {
+	if n < 0 {
+		return "nil"
+	}
+	return fmt.Sprint(n)
+}

+ 148 - 0
internal/academic/dualrun/stage2/activity_snapshot_test.go

@@ -0,0 +1,148 @@
+package stage2
+
+import (
+	"database/sql"
+	"strings"
+	"testing"
+)
+
+// 本文件单测活动域快照的**纯逻辑**部分:tuple 归一、多重集比对、口径函数。
+//
+// 为什么不靠双跑覆盖:这些函数错了,双跑会"稳定地绿或稳定地红",而两种都读不出原因 ——
+// 绿是因为两侧都被同一段错误归一抹平,红是因为键选错导致全行不匹配。所以先在这一层钉住。
+
+// TestMultisetDiffDetectsSwap 锁住"一条消失 + 一条新增"不会互相抵消。
+//
+// 这是"行集比对最容易假绿"那条:如果实现成"按键配对 / 比条数",1 vs 1 会对上,
+// 而实际写错了行。多重集下两个 tuple 各自计数不同 ⇒ 两处都红。
+func TestMultisetDiffDetectsSwap(t *testing.T) {
+	lhs := buildMultiset([]string{"t1", "t2"})
+	rhs := buildMultiset([]string{"t1", "t3"})
+	ok, detail := multisetDiff(lhs, rhs, "用量流水")
+	if ok {
+		t.Fatal("一条 t1 消失、一条 t3 新增,必须判不等")
+	}
+	if !strings.Contains(detail, "t2") || !strings.Contains(detail, "t3") {
+		t.Errorf("明细应同时点出缺失的 t2 与新增的 t3,实得:%s", detail)
+	}
+}
+
+// TestMultisetDiffDetectsDuplicateCount 锁住"多写一条"(tuple 相同但次数不同)。
+func TestMultisetDiffDetectsDuplicateCount(t *testing.T) {
+	lhs := buildMultiset([]string{"t1", "t1"})
+	rhs := buildMultiset([]string{"t1"})
+	ok, detail := multisetDiff(lhs, rhs, "用量流水")
+	if ok {
+		t.Fatal("同 tuple 计数 2 vs 1 必须判不等 —— 这正是'重复扣款'的形状")
+	}
+	if !strings.Contains(detail, "×2") || !strings.Contains(detail, "×1") {
+		t.Errorf("明细应打出两侧计数,实得:%s", detail)
+	}
+}
+
+// TestMultisetDiffEmptyIsNotEvidence 说明并锁住"空多重集相等"这件事的性质。
+//
+// `{} == {}` 在实现上确实相等 —— 但**它不是证据**。所以调用方必须另外断言
+// "本次应该有行"(`MustBeNonEmpty`)或"本次应该一行都没有"(`MustBeEmpty`),
+// 由 case 显式声明。这条测试的作用是把这件事写下来,而不是让 `true` 悄悄充当"测过"。
+func TestMultisetDiffEmptyIsNotEvidence(t *testing.T) {
+	ok, detail := multisetDiff(buildMultiset(nil), buildMultiset(nil), "用量流水")
+	if !ok {
+		t.Fatalf("两个空集在实现上应相等,实得:%s", detail)
+	}
+	if !strings.Contains(detail, "0 个 tuple") {
+		t.Errorf("明细必须显式写出'0 个 tuple',否则读报告的人会把空比对当成验过;实得:%s", detail)
+	}
+}
+
+// TestMoneyPartNullIsNotZero 锁住 NULL 与 0 在 tuple 里长得不一样。
+//
+// 折平了的话,`activity_money_used IS NULL`(从没写过)与 `= 0`(写过但为 0)
+// 会拼成同一个 tuple —— 又一次 "NULL 折 0",与 C1-11 同族。
+func TestMoneyPartNullIsNotZero(t *testing.T) {
+	if moneyPart(0, true) == moneyPart(0, false) {
+		t.Fatal("NULL 金额与 0 金额不能渲染成同一个字符串")
+	}
+	if moneyPart(0, false) == moneyPart(1, false) {
+		t.Fatal("0 分与 1 分不能渲染成同一个字符串")
+	}
+}
+
+// TestIdentityPartCollapsesZeroAndNull 记录"多重集里 0 与 NULL 被当作同一含义"这个**有意的选择**。
+//
+// 为什么可以有:`0 ↔ NULL` 是迁移映射的既定口径(legacy 的 int identity 列用 0 表示空,
+// target 用 NULL)。为什么不够:这个等价判据是**我们定义的**,将来业务改口径就会过期,
+// 所以必须另有 `assertIdentityZeroNull` 把两侧**原值**打出来(Q2)。
+func TestIdentityPartCollapsesZeroAndNull(t *testing.T) {
+	if identityPart("") != nullMark {
+		t.Errorf(`identityPart("") 应渲染成 NULL 标记,实得 %q`, identityPart(""))
+	}
+	if identityPart("01ABC") == nullMark {
+		t.Error("非空 ULID 不能被渲染成 NULL 标记")
+	}
+}
+
+// TestLegacyIdentity 锁住 legacy int identity 列的三种取值。
+//
+// 三种取值必须区分开:NULL(列就是空)、`0`(迁移口径里的"空")、正数(真的人)。
+// 前两者在**多重集**里被当作同一含义,但 `0` 必须留下 `isZero` 标记,
+// 好让 `assertIdentityZeroNull` 能把两侧原值打出来(Q2)。
+func TestLegacyIdentity(t *testing.T) {
+	uid, zero := legacyIdentity(sql.NullString{})
+	if uid != "" || zero {
+		t.Errorf(`NULL ⇒ ("", false),实得 (%q, %v)`, uid, zero)
+	}
+	uid, zero = legacyIdentity(sql.NullString{String: "0", Valid: true})
+	if uid != "" || !zero {
+		t.Errorf(`"0" ⇒ ("", true),实得 (%q, %v)`, uid, zero)
+	}
+	uid, zero = legacyIdentity(sql.NullString{String: "12", Valid: true})
+	if zero || uid != CanonicalUIDFor(12) {
+		t.Errorf(`"12" ⇒ (CanonicalUIDFor(12), false),实得 (%q, %v)`, uid, zero)
+	}
+}
+
+// TestReadRound2And3AreDistinct 锁住"两个列宽不共用同一个读函数"。
+//
+// `t_courses_activity_user_discount` 同一张表里 `discount_amount` 是 float(10,2)、
+// `discount_amount_used` 是 float(10,3)。抽一个公共函数把那列静默降成 2 位**不会报错**,
+// 只会让比对变松 —— 所以这里直接比字符串,任何"合并重构"都会红。
+func TestReadRound2And3AreDistinct(t *testing.T) {
+	r2, r3 := readRound2("x"), readRound3("x")
+	if r2 == r3 {
+		t.Fatal("2 位与 3 位的读法不能是同一个表达式 —— 合并会把 float(10,3) 那列静默降成 2 位")
+	}
+	if !strings.Contains(r2, ", 2)") {
+		t.Errorf("readRound2 应以 2 位收尾,实得 %s", r2)
+	}
+	if !strings.Contains(r3, ", 3)") {
+		t.Errorf("readRound3 应以 3 位收尾,实得 %s", r3)
+	}
+}
+
+// TestActivityDomainApplicableNilIsNotEqual 锁住"nil 不许当成两侧相等"。
+//
+// 这是新基建最容易的自伤方式:`Activity` 字段对旧 case 全是 nil,
+// 若写成 `lg == tg` 或"两边都空就算过",旧 35 条会集体多出一条**假绿**的活动域断言。
+func TestActivityDomainApplicableNilIsNotEqual(t *testing.T) {
+	if activityDomainApplicable(nil, nil) {
+		t.Fatal("两侧都 nil 必须判不适用(Skipped),不能判相等")
+	}
+	if activityDomainApplicable(&ActivitySnapshot{}, nil) {
+		t.Fatal("一侧 nil 必须判不适用")
+	}
+	if !activityDomainApplicable(&ActivitySnapshot{}, &ActivitySnapshot{}) {
+		t.Fatal("两侧都非 nil 才适用")
+	}
+}
+
+// TestRenderTupleShowsNull 锁住报告可读性:NULL 要显示成 NULL,分隔符要换成可见的 " | "。
+func TestRenderTupleShowsNull(t *testing.T) {
+	got := renderTuple(strings.Join([]string{"1", nullMark, "207"}, tupleSep))
+	if strings.ContainsRune(got, '\x00') || strings.Contains(got, tupleSep) {
+		t.Errorf("渲染后不该残留控制字符,实得 %q", got)
+	}
+	if !strings.Contains(got, "NULL") {
+		t.Errorf("NULL 必须显示成 NULL,实得 %q", got)
+	}
+}

+ 10 - 0
internal/academic/dualrun/stage2/c1_activity.go

@@ -42,6 +42,16 @@ type ActivitySeed struct {
 	// OtherUsers 是同一批活动上的**其他用户**报名行(验证"只动本人")。
 	// key 是 legacy int userid(target 侧按同一函数换成 ULID)。
 	OtherUsers map[int64]int32
+
+	// StudentBizNos 是"本 case 要读报课欠费的那几条 `t_courses_student`",
+	// 值用 **`biz_no`(旧 UUID)** 而不是自增 id ——
+	// legacy 的 `t_courses_student.id` 就是旧 UUID,target 的 `id` 是 bigint 自增、
+	// 真正对应旧 UUID 的是 `biz_no`。用 biz_no 做锚点,两侧才指向同一行。
+	//
+	// 只有 `use_activity_recommend`(W4)需要它:它嵌调两次
+	// `sp_courses_student_counteract_arrears_course`,写的是 `t_courses_student` 的
+	// `net_receipts` / `remainder_receivable`(第三个域)。
+	StudentBizNos []string
 }
 
 // ActivityPlan 是运行期实际使用的活动种子(id 已确定)。

+ 31 - 31
internal/academic/dualrun/stage2/c1_compare.go

@@ -42,14 +42,14 @@ func c1Assertions(
 		// 同上:校区余额"恰好是 0"必须排除 NULL —— NULL 的值也是 0,直接比 int64 会空过
 		okL := isZero(lg.After.Campus.Balance, lg.After.Campus.Nulls.Balance) &&
 			isZero(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance)
-		out = append(out, Assertion{Kind: AssertionBusiness, 
+		out = append(out, Assertion{Kind: AssertionBusiness,
 			Name: "清零:**校区**余额恰好归 0(「清零」清的是校区这一行)",
 			Pass: okL,
 			Detail: fmt.Sprintf("legacy 校区余额后=%s | target 校区余额后=%s(都应为 0.00,且不能是 NULL)",
 				moneyStr(lg.After.Campus.Balance, lg.After.Campus.Nulls.Balance),
 				moneyStr(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance)),
 		})
-		out = append(out, Assertion{Kind: AssertionBusiness, 
+		out = append(out, Assertion{Kind: AssertionBusiness,
 			Name: "清零:全局余额只减 campusBalance(**不是**归 0,除非原本只有一个校区有钱)",
 			Pass: dGlobal.Balance == -lg.Before.Campus.Balance && tGlobal.Balance == -tg.Before.Campus.Balance,
 			Detail: fmt.Sprintf("legacy 全局 %s→%s(Δ%s,campusBalance=%s)| target 全局 %s→%s(Δ%s,campusBalance=%s)",
@@ -62,7 +62,7 @@ func c1Assertions(
 		okT := dGlobal.Special == lg.Before.Campus.Balance && dCampus.Special == lg.Before.Campus.Balance &&
 			tGlobal.Special == tg.Before.Campus.Balance && tCampus.Special == tg.Before.Campus.Balance &&
 			dGlobal.Spend == lg.Before.Campus.Balance && tGlobal.Spend == tg.Before.Campus.Balance
-		out = append(out, Assertion{Kind: AssertionBusiness, 
+		out = append(out, Assertion{Kind: AssertionBusiness,
 			Name: "清零:special_expenses 与 spend **同额增加** campusBalance(三列一起动,只动一列会红)",
 			Pass: okT,
 			Detail: fmt.Sprintf("legacy 全局 Δspec=%s Δspend=%s | target 全局 Δspec=%s Δspend=%s(期望各 +%s / +%s)",
@@ -71,7 +71,7 @@ func c1Assertions(
 				yuan(lg.Before.Campus.Balance), yuan(tg.Before.Campus.Balance)),
 		})
 		// 无请求号 → log_id / request_id 都应为空("不享幂等"是 SP 签名决定的,不是漏写)
-		out = append(out, Assertion{Kind: AssertionBusiness, 
+		out = append(out, Assertion{Kind: AssertionBusiness,
 			Name: "清零:流水 log_id 与 request_id **都为空**(SP10 没有请求号入参,天然不幂等)",
 			Pass: allLogsWithoutID(lg.After.Logs) && allLogsWithoutID(tg.After.Logs),
 			Detail: fmt.Sprintf("legacy %d 条 %s | target %d 条 %s",
@@ -82,14 +82,14 @@ func c1Assertions(
 
 	// ④ 取消充值活动:流水 type=209、money=−m,且报名状态归 **0**(不是 4)
 	if c.Op == OpCancelRechargeActivity {
-		out = append(out, Assertion{Kind: AssertionBusiness, 
+		out = append(out, Assertion{Kind: AssertionBusiness,
 			Name: "取消充值活动:流水 type 必须是 **209**、money 必须是 **−金额**(照 dump,不改符号)",
 			Pass: logsAllType(lg.After.Logs, 209) && logsAllType(tg.After.Logs, 209) &&
 				logsAllMoney(lg.After.Logs, -c.AmountCents) && logsAllMoney(tg.After.Logs, -c.AmountCents),
 			Detail: fmt.Sprintf("legacy %s | target %s(期望 type=209 money=%s ×%d)",
 				fmtLogs(lg.After.Logs), fmtLogs(tg.After.Logs), yuan(-c.AmountCents), runsLegacy),
 		})
-		out = append(out, Assertion{Kind: AssertionBusiness, 
+		out = append(out, Assertion{Kind: AssertionBusiness,
 			Name: "取消充值活动:报名状态归 **0(取消报名)**,不是 4(过期)—— SP8 与 SP10 的差别",
 			Pass: signedAll(c, lg, 0) && signedAll(c, tg, 0),
 			Detail: fmt.Sprintf("legacy %s | target %s(活动 %d 应为 0)",
@@ -106,7 +106,7 @@ func c1Assertions(
 func campusMetaAssertion(c Case, lg, tg *SideResult) Assertion {
 	lm, tm := lg.CampusMeta, tg.CampusMeta
 	if lm == nil || tm == nil {
-		return Assertion{Kind: AssertionBusiness, 
+		return Assertion{Kind: AssertionBusiness,
 			Name:   "两侧看到的校区元数据一致(机构 + 是否自营)",
 			Pass:   false,
 			Detail: "有一侧没读到校区元数据(读失败或未触发读取)",
@@ -115,7 +115,7 @@ func campusMetaAssertion(c Case, lg, tg *SideResult) Assertion {
 	// CampusMissing:两侧都应该**查不到**这个校区(不是"查到了但值是 0")
 	if c.CampusMissing {
 		ok := lm.Err != "" && tm.Err != "" && lm.InstitutionID == 0 && tm.InstitutionID == 0
-		return Assertion{Kind: AssertionBusiness, 
+		return Assertion{Kind: AssertionBusiness,
 			Name: "两侧都**查不到**该校区(故意用不存在的校区 id)",
 			Pass: ok,
 			Detail: fmt.Sprintf("legacy 视图:机构=%d%s | target t_campus:机构=%d%s(都应为 0 + 查不到)",
@@ -132,7 +132,7 @@ func campusMetaAssertion(c Case, lg, tg *SideResult) Assertion {
 		lm.InstitutionID, lm.SelfSupport, errSuffix(lm.Err),
 		tm.InstitutionID, tm.SelfSupport, errSuffix(tm.Err),
 		c.InstitutionID, wantSelf)
-	return Assertion{Kind: AssertionBusiness, 
+	return Assertion{Kind: AssertionBusiness,
 		Name:   "两侧看到的校区元数据一致(机构 + 是否自营)—— 不同就会走不同失效分支",
 		Pass:   ok,
 		Detail: detail,
@@ -158,7 +158,7 @@ func campusAccountAssertion(c Case, lg, tg *SideResult) Assertion {
 			reason = "(NoCampusAccount:校区存在但该用户没有账户行)"
 		}
 	}
-	return Assertion{Kind: AssertionBusiness, 
+	return Assertion{Kind: AssertionBusiness,
 		Name: "调用前:校区账户行" + word + reason,
 		Pass: ok,
 		Detail: fmt.Sprintf("legacy 存在=%v | target 存在=%v(期望都为 %v)",
@@ -174,7 +174,7 @@ func campusAccountAssertion(c Case, lg, tg *SideResult) Assertion {
 // 单独把"行被建出来了"钉成一条,红的时候一眼知道是建户分支没走到。
 func campusAccountCreatedAssertion(c Case, lg, tg *SideResult) Assertion {
 	ok := lg.After.HasCampusAccount && tg.After.HasCampusAccount
-	return Assertion{Kind: AssertionBusiness, 
+	return Assertion{Kind: AssertionBusiness,
 		Name: "调用后:校区账户行被建出来(dump 的 `IF logCount=0 THEN INSERT` 分支)",
 		Pass: ok,
 		Detail: fmt.Sprintf("legacy 存在=%v 余额=%s | target 存在=%v 余额=%s(都应为 true)",
@@ -222,14 +222,14 @@ func opExpiresActivities(op Op) bool {
 // spReturnAssertion 断言 SP 自己 SELECT 出来的结果集(这是 SP 契约的一部分)。
 func spReturnAssertion(c Case, lg *SideResult) Assertion {
 	if len(lg.Calls) == 0 || lg.Calls[0].Returned == nil {
-		return Assertion{Kind: AssertionBusiness, 
+		return Assertion{Kind: AssertionBusiness,
 			Name: "SP8 返回值:success=1 / msg=ok / schoolid 由校区反查得到",
 			Pass: false, Detail: "没有读到 SP 的结果集(SP8 每条路径都应以 SELECT 结尾)",
 		}
 	}
 	r := lg.Calls[0].Returned
 	ok := r["success"] == "1" && r["msg"] == "ok" && r["schoolid"] == fmt.Sprint(c.InstitutionID)
-	return Assertion{Kind: AssertionBusiness, 
+	return Assertion{Kind: AssertionBusiness,
 		Name: "SP8 返回值:success=1 / msg=ok / schoolid 由校区反查得到",
 		Pass: ok,
 		Detail: fmt.Sprintf("实测 schoolid=%q success=%q msg=%q(期望 %q / \"1\" / \"ok\")",
@@ -281,23 +281,23 @@ func requestIDOverflowCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	)
 
 	asserts = append(asserts,
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name:   "前提:入参请求号 **确实 33 字符**(> legacy 的 32,否则本条退化成普通 case)",
 			Pass:   len(c.RequestID) == 33,
 			Detail: fmt.Sprintf("len(requestId)=%d(期望 33)", len(c.RequestID)),
 		},
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name:   "legacy 报的是 **1406(Data too long)且点名字段 logid** —— 证明是列宽不是逻辑",
 			Pass:   !lgOK && strings.Contains(lgErr, "1406") && strings.Contains(strings.ToLower(lgErr), "logid"),
 			Detail: fmt.Sprintf("legacy success=%v err=%q", lgOK, lgErr),
 		},
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name: "legacy **钱已扣、流水 0 条**(同一次 CALL 里前置 UPDATE 不回滚)—— 缺陷在 legacy 一侧",
 			Pass: dGlobal.Balance == want && dCampus.Balance == want && len(lg.After.Logs) == 0,
 			Detail: fmt.Sprintf("Δ全局=%s Δ校区=%s 流水=%d 条(期望各 %s、0 条)",
 				yuan(dGlobal.Balance), yuan(dCampus.Balance), len(lg.After.Logs), yuan(want)),
 		},
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name: "target 成功且 **33 字符请求号完整落库**(没被静默截断 / 没报错)",
 			Pass: tgOK && len(tg.After.Logs) == 1 &&
 				tg.After.Logs[0].RequestID == c.RequestID && tg.After.Logs[0].LogID == c.RequestID &&
@@ -306,7 +306,7 @@ func requestIDOverflowCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 				tgOK, len(tg.After.Logs), len(reqIDOf(tg.After.Logs)), len(logIDOf(tg.After.Logs)),
 				true, len(c.RequestID), len(c.RequestID)),
 		},
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name: "两侧 Δbalance 相同(钱的结果一致,差异只在**有没有凭证**)",
 			Pass: dGlobal.Balance == tGlobal.Balance && dCampus.Balance == tCampus.Balance,
 			Detail: fmt.Sprintf("全局 legacy=%s target=%s | 校区 legacy=%s target=%s",
@@ -388,37 +388,37 @@ func campusRowNullCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	)
 
 	asserts = append(asserts,
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name: "前提:两侧调用前**都**没有该校区账户行(否则本条测不到建行分支)",
 			Pass: !lg.Before.HasCampusAccount && !tg.Before.HasCampusAccount,
 			Detail: fmt.Sprintf("legacy 存在=%v | target 存在=%v(都应为 false)",
 				lg.Before.HasCampusAccount, tg.Before.HasCampusAccount),
 		},
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name: "legacy:校区行**被建出来了**,且五列金额**全是 NULL**(不是 0,也不是 −金额)",
 			Pass: lg.After.HasCampusAccount && lgAllNull,
 			Detail: fmt.Sprintf("legacy 行存在=%v NULL 列=%s(期望 true / 五列全 NULL)",
 				lg.After.HasCampusAccount, lg.After.Campus.Nulls.String()),
 		},
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name: "legacy:**Δ校区也要如实表示 NULL**——不能因为读成 0 就显示成 「Δ=0.00」(那与「扣了 0 元」无法区分)",
 			Pass: dCampus.Nulls.Any(),
 			Detail: fmt.Sprintf("Δ校区 balance=%s(NULL 列=%s)",
 				moneyStr(dCampus.Balance, dCampus.Nulls.Balance), dCampus.Nulls.String()),
 		},
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name: "legacy:**全局**被扣了 −金额(`t_user_expense` 的列有 DEFAULT 0.000)—— 同一笔业务两表矛盾",
 			Pass: dGlobal.Balance == want && dGlobal.Income == want,
 			Detail: fmt.Sprintf("Δ全局 balance=%s total_income=%s(期望各 %s)",
 				yuan(dGlobal.Balance), yuan(dGlobal.Income), yuan(want)),
 		},
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name: "legacy:流水照写 1 条 type=209 / money=−金额(**钱没扣,凭证有了**)",
 			Pass: len(lg.After.Logs) == 1 && lg.After.Logs[0].Type == 209 &&
 				lg.After.Logs[0].Money == want,
 			Detail: fmt.Sprintf("legacy %s(期望 type=209 money=%s ×1)", fmtLogs(lg.After.Logs), yuan(want)),
 		},
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name: "target:校区行被建出来、**五列都不为 NULL**、且真扣成 −金额(列是 NOT NULL DEFAULT 0.00)",
 			Pass: tg.After.HasCampusAccount && tgNoNull &&
 				tg.After.Campus.Balance == want && tCampus.Balance == want && tCampus.Income == want,
@@ -429,7 +429,7 @@ func campusRowNullCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 				moneyStr(tCampus.Income, tCampus.Nulls.Income),
 				yuan(want), yuan(want), yuan(want)),
 		},
-		Assertion{Kind: AssertionBusiness, 
+		Assertion{Kind: AssertionBusiness,
 			Name: "判定:**LEGACY-DEFECT** —— legacy 两表自相矛盾(校区五列 NULL / 全局 −金额),target 侧正确,不跟",
 			Pass: lgAllNull && tgNoNull &&
 				dGlobal == tGlobal &&
@@ -484,7 +484,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 
 	// ① 前提
 	baseOK := campusBefore == tg.Before.Campus.Balance && totalBefore == tg.Before.Global.Balance
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name: "前提:两侧 **2 位**基线逐分相等(校区与全局都要相等)",
 		Pass: baseOK,
 		Detail: fmt.Sprintf("legacy 校区=%s 全局=%s | target 校区=%s 全局=%s",
@@ -493,7 +493,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	})
 
 	// ② legacy 的 3 位判据必须**成立**(否则构型选错了,这条 case 什么都证明不了)
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name: "legacy:3 位判据 `校区 > 全局` **成立**(构型前提;不成立说明选错人)",
 		Pass: c3ok && t3ok && c3 > t3,
 		Detail: fmt.Sprintf("campus=%s total=%s → %s > %s 成立=%v",
@@ -501,7 +501,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	})
 
 	// ③ target 的 2 位判据必须**不成立**(这就是"放行"的原因)
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name: "target:2 位判据 `校区 > 全局` **不成立** ⇒ 放行(分歧的成因:第 3 位被收敛抹平)",
 		Pass: tg.Before.Campus.Balance <= tg.Before.Global.Balance,
 		Detail: fmt.Sprintf("campus=%s total=%s → %s > %s 不成立",
@@ -512,7 +512,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	// ④ legacy 行为:LEAVE + 零写入 + 0 流水
 	lgLEAVE := len(lg.Calls) > 0 && lg.Calls[0].Success
 	lgNoWrite := lg.Before.Global == lg.After.Global && lg.Before.Campus == lg.After.Campus
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name: "legacy:`LEAVE` 静默返回、**零写入**、0 条流水",
 		Pass: lgLEAVE && lgNoWrite && len(lg.After.Logs) == 0,
 		Detail: fmt.Sprintf("抛错=%v 全局变=%v 校区变=%v 流水=%d(都应为 false/false/0)",
@@ -526,7 +526,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	logOK := len(tg.After.Logs) == 1 && tg.After.Logs[0].Type == 208 &&
 		tg.After.Logs[0].Money == -amount && tg.After.Logs[0].LogID == "" && tg.After.Logs[0].RequestID == ""
 	tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name: "target:清零成功 —— 校区 0、全局 0、special 与 spend 各 +金额、1 条 `type=208 money=−金额`(无 logid/request_id)",
 		Pass: tgOK && isZero(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance) &&
 			isZero(tg.After.Global.Balance, tg.After.Global.Nulls.Balance) &&
@@ -541,7 +541,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	})
 
 	// ⑥ 结论行:把归类写进报告,免得下一个人当它是缺陷
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name: "判定:**PRECISION-CONVERGENCE / PREDICATE-COLLAPSE** —— 迁移的 2 位收敛抹平了判据输入,两侧分支不同,**不是缺陷**",
 		Pass: true,
 		Detail: fmt.Sprintf("legacy 1565.688>1565.687 成立 ⇒ LEAVE(未写入)|target %s>%s 不成立 ⇒ 清零 %s。"+

+ 36 - 32
internal/academic/dualrun/stage2/c2_refund.go

@@ -104,7 +104,8 @@ type refundLogExpect struct {
 // 阶段 1 已经因为 `ROUND(float,2)` 与 `ROUND(CAST(float AS DECIMAL),2)` 不同栽过一次。
 //
 // ⚠ 必须是"半进远离零",不是 Go 的 `math.RoundToEven`(半进取偶):
-//   12.500 在两者下分别是 13 / 12,C2-5 就是这条的唯一哨兵。
+//
+//	12.500 在两者下分别是 13 / 12,C2-5 就是这条的唯一哨兵。
 func round0Cents(cents int64) int64 {
 	q := cents / 100
 	r := cents % 100
@@ -130,8 +131,9 @@ func mathRound0Cents(cents int64) int64 {
 //	     坏账分支 `_balanceRemark = CONCAT(inputRemark,'<br/>退费的金额大于可退费部分,超出部分为坏账部分。')`
 //
 // ⚠ 后缀是 **108 独有**:207 的备注只有 inputRemark 本身。
-//   把后缀也拼到 207 上会立刻红 —— 这正是这条断言存在的意义。
-//   `<br/>` 原样保留(前端按 HTML 渲染),**不做任何转义或替换**。
+//
+//	把后缀也拼到 207 上会立刻红 —— 这正是这条断言存在的意义。
+//	`<br/>` 原样保留(前端按 HTML 渲染),**不做任何转义或替换**。
 func refundRemark(inputRemark string) (remark207, remark108 string) {
 	remark207 = inputRemark
 	if strings.TrimSpace(inputRemark) == "" {
@@ -288,8 +290,9 @@ func sumLogType(logs []LogRow, typ int32) int64 {
 // 判据:条数相等、逐条 type 相等、逐条 money 相等、逐条 remark 逐字相等。
 //
 // ⚠ 只比"同一侧内"的集合,不跨侧比顺序 —— 两侧的顺序是各自 SP / Service 的实现细节,
-//   跨侧比顺序会在"同一侧换了实现"时制造无关的红。跨侧比**内容**才有意义,
-//   而内容差异正是本批要记录的结论(见 C2VerdictClass)。
+//
+//	跨侧比顺序会在"同一侧换了实现"时制造无关的红。跨侧比**内容**才有意义,
+//	而内容差异正是本批要记录的结论(见 C2VerdictClass)。
 func checkRefundLogs(logs []LogRow, exp []refundLogExpect) (bool, string) {
 	if len(logs) != len(exp) {
 		return false, fmt.Sprintf("条数实测 %d、期望 %d(实测:%s | 期望:%s)",
@@ -388,7 +391,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		Item:   "Δbalance(全局)",
 		Legacy: moneyStr(dGL.Balance, dGL.Nulls.Balance),
 		Target: moneyStr(dGT.Balance, dGT.Nulls.Balance),
-		Equal:  sameMoney(dGL.Balance, dGL.Nulls.Balance, dL.Balance, dL.Nulls.Balance) &&
+		Equal: sameMoney(dGL.Balance, dGL.Nulls.Balance, dL.Balance, dL.Nulls.Balance) &&
 			sameMoney(dGT.Balance, dGT.Nulls.Balance, dT.Balance, dT.Nulls.Balance),
 		Note: "双表同额:全局 Δ 必须与校区 Δ 相同(只写一张表 → 红)",
 	})
@@ -472,7 +475,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	}
 
 	// ---- 断言 ①:各侧前置校验的结论符合**各自**的期望 ----
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name: "两侧前置校验:各侧是否符合**自己**的期望(legacy `ISNULL(_balance) OR _balance<=0` / target `balance<=0`)",
 		Pass: lgRejectedActual == lgExp.Rejected && tgRejectedActual == tgExp.Rejected,
 		Detail: fmt.Sprintf("legacy 实测拒绝=%v(期望 %v,整数化余额=%s,success=%q msg=%q)| target 实测拒绝=%v(期望 %v,真实余额=%s,码=%d err=%q)",
@@ -480,7 +483,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			tgRejectedActual, tgExp.Rejected, yuan(tgB), codeOf(tg.Calls), errOf(tg.Calls)),
 	})
 	if c.ExpectTargetCode != 0 {
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name:   fmt.Sprintf("target 业务码 == %d(码比文案强:同一文案可能被多处复用)", c.ExpectTargetCode),
 			Pass:   targetRejected(tg) && codeOf(tg.Calls) == c.ExpectTargetCode,
 			Detail: fmt.Sprintf("实测码=%d err=%q", codeOf(tg.Calls), errOf(tg.Calls)),
@@ -493,21 +496,21 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	tgNoWrite := tg.Before.Campus == tg.After.Campus && tg.Before.Global == tg.After.Global &&
 		len(tg.After.Logs) == 0
 	if lgExp.Rejected {
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: "legacy 拒绝契约:`success=0` + `msg='余额不能≤0'` + **零写入 / 0 条流水**(不抛错)",
 			Pass: lgRejectedActual && lgRet["msg"] == "余额不能≤0" && lgNoWrite,
 			Detail: fmt.Sprintf("success=%q msg=%q 零写入=%v 流水=%d 条",
 				lgRet["success"], lgRet["msg"], lgNoWrite, len(lg.After.Logs)),
 		})
 	} else {
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
-			Name:   "【不适用】legacy 本条未被拒绝 ⇒ 拒绝契约无从验证",
-			Pass:   true, Skipped: true,
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: "【不适用】legacy 本条未被拒绝 ⇒ 拒绝契约无从验证",
+			Pass: true, Skipped: true,
 			Detail: "整数化余额=" + yuan(round0Cents(lgB)) + " > 0",
 		})
 	}
 	if tgExp.Rejected {
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name:   "target 拒绝契约:显式报错(3102)+ **零写入 / 0 条流水**",
 			Pass:   tgRejectedActual && tgNoWrite,
 			Detail: fmt.Sprintf("被拒=%v 码=%d 零写入=%v 流水=%d 条", tgRejectedActual, codeOf(tg.Calls), tgNoWrite, len(tg.After.Logs)),
@@ -518,16 +521,16 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	// 这两条是本批最重要的"没删干净"的部分:它们**不比较两侧之间**,而是各自对着
 	// 一个与实现无关的恒等式。如果因为"两侧本来就不同"把断言整体删掉,这一支就只剩打印。
 	sharedSkip := lgExp.Rejected || tgExp.Rejected
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
-		Name:   "共同不变量:`207.money == −inputMoney`(两侧都必须成立)",
-		Pass:   !sharedSkip && sameLog207(lg.After.Logs, c.AmountCents) && sameLog207(tg.After.Logs, c.AmountCents),
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name:    "共同不变量:`207.money == −inputMoney`(两侧都必须成立)",
+		Pass:    !sharedSkip && sameLog207(lg.After.Logs, c.AmountCents) && sameLog207(tg.After.Logs, c.AmountCents),
 		Skipped: sharedSkip,
 		Detail: fmt.Sprintf("legacy 207 合计=%s | target 207 合计=%s(期望各 %s)",
 			yuan(sumLogType(lg.After.Logs, 207)), yuan(sumLogType(tg.After.Logs, 207)), yuan(-c.AmountCents)),
 	})
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
-		Name:   "共同不变量:`Δtotal_income == Δbalance`(两侧都必须成立 —— 证明结构性复刻对了,只是舍入不同)",
-		Pass:   !sharedSkip && lgIncomeOK && tgIncomeOK,
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name:    "共同不变量:`Δtotal_income == Δbalance`(两侧都必须成立 —— 证明结构性复刻对了,只是舍入不同)",
+		Pass:    !sharedSkip && lgIncomeOK && tgIncomeOK,
 		Skipped: sharedSkip,
 		Detail: fmt.Sprintf("legacy Δincome=%s Δbal=%s | target Δincome=%s Δbal=%s",
 			moneyStr(dL.Income, dL.Nulls.Income), moneyStr(dL.Balance, dL.Nulls.Balance),
@@ -548,7 +551,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	lg108OK := lgBadDebtRuns > 0 &&
 		countLogType(lg.After.Logs, 108) == lgBadDebtRuns &&
 		sumLogType(lg.After.Logs, 108) == want108Total
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name:    "legacy 专属不变量:`108.money == round0(inputMoney − round0(balance_before))`(第二次整数化只在这里可见)",
 		Pass:    lg108OK,
 		Skipped: lgBadDebtRuns == 0,
@@ -557,7 +560,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			lgBadDebtRuns, yuan(want108Total),
 			yuan(c.AmountCents), yuan(lgB), yuan(want108Each)),
 	})
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name: "legacy 专属不变量:**坏账分支写 108、ELSE 分支不写**(108 条数 == 走坏账分支的次数)",
 		Pass: countLogType(lg.After.Logs, 108) == lgBadDebtRuns,
 		Detail: fmt.Sprintf("108 实测 %d 条 | 坏账分支 %d 次(分支序列:%s)",
@@ -567,7 +570,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	// ---- 断言 ⑤:target 专属不变量 ----
 	// 用**实测值**而不是 Service 返回值:返回值只能证明"它自己这么报",实测 Δ 才能证明"库真变了"。
 	tgSplitOK := !tgExp.Rejected && -dT.Balance+dT.Special == c.AmountCents
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name:    "target 专属不变量:`refunded + badDebt == inputMoney`(实测:`−Δbalance + Δspecial == inputMoney`)",
 		Pass:    tgSplitOK,
 		Skipped: tgExp.Rejected,
@@ -575,14 +578,14 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			yuan(-dT.Balance), yuan(dT.Special), yuan(-dT.Balance+dT.Special), yuan(c.AmountCents)),
 	})
 	tgRefundedOK := !tgExp.Rejected && -dT.Balance == minI64(c.AmountCents, tgB)
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name:    "target 专属不变量:`refunded == min(inputMoney, balance_before)`(实测:`−Δbalance` 等于两者较小值)",
 		Pass:    tgRefundedOK,
 		Skipped: tgExp.Rejected,
 		Detail: fmt.Sprintf("实测 refunded=−Δbal=%s | min(%s, %s)=%s",
 			yuan(-dT.Balance), yuan(c.AmountCents), yuan(tgB), yuan(minI64(c.AmountCents, tgB))),
 	})
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name:    "target 专属不变量:`balance_after >= 0`(精确口径下退费**不应该把余额退成负数**)",
 		Pass:    tgExp.Rejected || tg.After.Campus.Balance >= 0,
 		Skipped: tgExp.Rejected,
@@ -593,7 +596,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	if c.RunTimes >= 2 {
 		tgRepeated := len(tg.Calls) >= 2 && tg.Calls[1].Repeated
 		lgRuns := len(lg.Calls)
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: "幂等:target 第 2 次 `repeated=true` 且**只扣 1 次**;legacy SP 无幂等键 ⇒ 真的扣 N 次",
 			Pass: tgRepeated && lgRuns == maxInt(1, c.RunTimes) &&
 				-dT.Balance == tgExp.Money && -dL.Balance == lgExp.Money,
@@ -612,7 +615,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		// 最后把 `campus_id=0`(全机构池)单拎一条 —— 它是自营/加盟唯一的分支差别,
 		// 混在整张 map 里被别的活动一衬托就不显眼了。
 		if poolID, ok := c.ActivityPlan.poolActivityID(); ok {
-			asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+			asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 				Name: "自营 ⇒ **全机构池活动(campus_id=0)也必须失效**(这是 self_support 分支的唯一判据)",
 				Pass: lg.After.Signed[poolID] == 4 && tg.After.Signed[poolID] == 4,
 				Detail: fmt.Sprintf("活动 %d(campus_id=0):legacy %d→%d | target %d→%d(期望 2→4)",
@@ -626,14 +629,14 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	same := lgExp.Rejected == tgExp.Rejected && lgExp.Money == tgExp.Money &&
 		lgExp.BadDebt == tgExp.BadDebt && len(lgExp.Logs) == len(tgExp.Logs)
 	if same {
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: "判定:**两侧一致**(整数余额 ⇒ legacy 的两次整数化不产生任何差异)",
 			Pass: true,
 			Detail: fmt.Sprintf("余额=%s 请求=%s ⇒ 两侧都 %s",
 				yuan(lgB), yuan(c.AmountCents), refundExpectBrief(lgExp)),
 		})
 	} else {
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: "判定:**" + C2VerdictClass + "** —— 旧 SP 的 `DECLARE ... DECIMAL DEFAULT 0` 会把金额整数化两次,target 有意不复刻",
 			Pass: true,
 			Detail: fmt.Sprintf("余额=%s 请求=%s | legacy(整数化余额 %s):%s | target(精确余额 %s):%s",
@@ -691,7 +694,8 @@ func refundRemarkNote(inputRemark string) string {
 //
 // 它证明的是"**NULL 余额这个状态在新库不可表达**" —— 迁移从 schema 层修掉了一个数据缺陷。
 // ⚠ 它**不进 DefaultCases**:没有同构的第二侧,"两侧都拒绝"这条绿的含金量是 0
-//    (换任何一条余额为 0 的记录都能绿),所以只在探针里登记。
+//
+//	(换任何一条余额为 0 的记录都能绿),所以只在探针里登记。
 func nullBalanceRejectCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	var diffs []Diff
 	var asserts []Assertion
@@ -719,18 +723,18 @@ func nullBalanceRejectCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 			Note:   "拒绝**理由**不同(legacy `ISNULL` 短路 / target `balance<=0`),结论一致",
 		},
 	)
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name: "legacy:余额为 NULL ⇒ `ISNULL(_balance)` 短路 ⇒ 静默拒绝(`success=0` / `msg='余额不能≤0'`)+ 零写入",
 		Pass: lgNull && lgRejected && lgRet["msg"] == "余额不能≤0" && lgNoWrite,
 		Detail: fmt.Sprintf("NULL=%v success=%q msg=%q 零变化=%v 流水=%d 条",
 			lgNull, lgRet["success"], lgRet["msg"], lgNoWrite, len(lg.After.Logs)),
 	})
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name:   "target:余额 0.00 ⇒ `balance<=0` ⇒ 显式拒绝(3102)+ 零写入",
 		Pass:   tgRejected && codeOf(tg.Calls) == 3102 && tgNoWrite,
 		Detail: fmt.Sprintf("被拒=%v 码=%d err=%q 零变化=%v 流水=%d 条", tgRejected, codeOf(tg.Calls), errOf(tg.Calls), tgNoWrite, len(tg.After.Logs)),
 	})
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name: "判定:**迁移修掉的缺陷** —— NULL 余额在新库不可表达,两侧殊途同归(都拒绝);本探针**不构成验收**",
 		Pass: true,
 		Detail: "legacy 侧的 NULL 是数据缺陷(建行只写 3 列,见 C1-11);target 的 `not null default 0.00` " +

+ 10 - 10
internal/academic/dualrun/stage2/c2_refund_test.go

@@ -81,13 +81,13 @@ func TestLegacyRefundExpectRealShapes(t *testing.T) {
 		contains string // 断言说明里应出现的特征(便于红的时候一眼看出是哪一个象限)
 	}{
 		{
-			name: "A 象限 0.280 退 0.28:整数化后 _balance=0 ⇒ 拒绝",
+			name:    "A 象限 0.280 退 0.28:整数化后 _balance=0 ⇒ 拒绝",
 			balance: 28, request: 28,
 			want:     refundExpect{Rejected: true, Branches: []string{refundBranchReject}},
 			contains: "拒绝了就不该有流水",
 		},
 		{
-			name: "B 象限 0.832 退 1.50:_balance=1、_badDate=round0(0.5)=1",
+			name:    "B 象限 0.832 退 1.50:_balance=1、_badDate=round0(0.5)=1",
 			balance: 83, request: 150,
 			want: refundExpect{
 				Branches: []string{refundBranchBadDebt},
@@ -100,7 +100,7 @@ func TestLegacyRefundExpectRealShapes(t *testing.T) {
 			contains: "108 是 +1.00 不是 +0.80",
 		},
 		{
-			name: "C 象限 475.376 退 600:_balance=475、_badDate=125",
+			name:    "C 象限 475.376 退 600:_balance=475、_badDate=125",
 			balance: 47538, request: 60000,
 			want: refundExpect{
 				Branches: []string{refundBranchBadDebt},
@@ -113,7 +113,7 @@ func TestLegacyRefundExpectRealShapes(t *testing.T) {
 			contains: "迁移 3→2 位后仍留正零头",
 		},
 		{
-			name: "D 象限 12.500 退 20:_balance 半进远离零 = 13",
+			name:    "D 象限 12.500 退 20:_balance 半进远离零 = 13",
 			balance: 1250, request: 2000,
 			want: refundExpect{
 				Branches: []string{refundBranchBadDebt},
@@ -126,7 +126,7 @@ func TestLegacyRefundExpectRealShapes(t *testing.T) {
 			contains: "RoundToEven 会给 12",
 		},
 		{
-			name: "缝区间 699.980 退 699.99:`699.99 > 700` 为假 ⇒ **ELSE,无 108**",
+			name:    "缝区间 699.980 退 699.99:`699.99 > 700` 为假 ⇒ **ELSE,无 108**",
 			balance: 69998, request: 69999,
 			want: refundExpect{
 				Branches: []string{refundBranchElse},
@@ -136,7 +136,7 @@ func TestLegacyRefundExpectRealShapes(t *testing.T) {
 			contains: "同一笔操作 legacy 连分支都和 target 不同",
 		},
 		{
-			name: "整数余额 9740 退 300:整数化不产生任何差异(ELSE)",
+			name:    "整数余额 9740 退 300:整数化不产生任何差异(ELSE)",
 			balance: 974000, request: 30000,
 			want: refundExpect{
 				Branches: []string{refundBranchElse},
@@ -199,7 +199,7 @@ func TestTargetRefundExpectPrecise(t *testing.T) {
 		want    refundExpect
 	}{
 		{
-			name: "B 象限 0.83 退 1.50:refunded=0.83、badDebt=0.67",
+			name:    "B 象限 0.83 退 1.50:refunded=0.83、badDebt=0.67",
 			balance: 83, request: 150,
 			want: refundExpect{
 				Branches: []string{refundBranchBadDebt},
@@ -211,7 +211,7 @@ func TestTargetRefundExpectPrecise(t *testing.T) {
 			},
 		},
 		{
-			name: "缝区间 699.98 退 699.99:refunded=699.98、badDebt=0.01 ⇒ **有 108**",
+			name:    "缝区间 699.98 退 699.99:refunded=699.98、badDebt=0.01 ⇒ **有 108**",
 			balance: 69998, request: 69999,
 			want: refundExpect{
 				Branches: []string{refundBranchBadDebt},
@@ -223,7 +223,7 @@ func TestTargetRefundExpectPrecise(t *testing.T) {
 			},
 		},
 		{
-			name: "余额 0.28 退 0.28:target 能退(legacy 拒绝)",
+			name:    "余额 0.28 退 0.28:target 能退(legacy 拒绝)",
 			balance: 28, request: 28,
 			want: refundExpect{
 				Branches: []string{refundBranchElse},
@@ -232,7 +232,7 @@ func TestTargetRefundExpectPrecise(t *testing.T) {
 			},
 		},
 		{
-			name: "余额 0:拒绝(3102)",
+			name:    "余额 0:拒绝(3102)",
 			balance: 0, request: 28,
 			want: refundExpect{Rejected: true, Branches: []string{refundBranchReject}},
 		},

+ 10 - 1
internal/academic/dualrun/stage2/case.go

@@ -345,7 +345,6 @@ type SideSnapshot struct {
 	// 显式记下来,再断言"该存在的存在 / 该缺失的缺失"。
 	HasCampusAccount bool
 	// CampusNullBalance 是"校区账户行存在,但金额列是 NULL"。
-	//
 	// 只有 legacy 会出现:`t_user_expense_school` 的 balance/spend/total_income/… 是
 	// `decimal(10,3) NULL DEFAULT NULL`(**没有默认值**)。SP8 的建行语句只写
 	// `(userid,schoolid,campusid)`,于是新行金额全是 NULL;紧接着的
@@ -357,6 +356,16 @@ type SideSnapshot struct {
 	// 新代码请直接用 `s.Campus.Nulls`:它**逐列**记录,能分辨"只有 total_income 是 NULL"这种情形,
 	// 而这个 bool 只能回答"有没有 NULL",用它写断言会漏掉"哪一列"。
 	CampusNullBalance bool
+
+	// Activity 是活动域快照(六张表、逐列三态),见 `activity_snapshot.go`。
+	//
+	// **`nil` = 本 case 不涉及活动域**。nil 时所有活动域比对一律 `Skipped`,
+	// **绝不**把 `nil == nil` 当成"两侧相等" —— 那是"没测",不是"测过且相等"。
+	//
+	// 为什么单独一块而不是并进 `Signed`:`Signed` 是 C1 时代的"只投影 status",
+	// 现有 7 条 case(C1-1/2/6/7/10/11、C2-10)的活动断言全挂在它上面。
+	// 合并要动那些已绿的 case,属于"改绿的东西",必须单开一次。
+	Activity *ActivitySnapshot
 }
 
 // SideResult 是一侧的运行结果。

+ 5 - 0
internal/academic/dualrun/stage2/legacy.go

@@ -412,6 +412,11 @@ SELECT id, IFNULL(type,0), CAST(ROUND(CAST(IFNULL(money,0) AS DECIMAL(20,3)),2)
 		return out, 0, err
 	}
 	out.Signed = signed
+	// 活动域整行快照(六表三态)。`ActivityPlan == nil` 时返回 nil ⇒ 活动域比对全部 Skipped。
+	// 与上面那块 `Signed`(只投影 status)并存:合并要动 7 条已绿的 case,单开。
+	if out.Activity, err = readActivitySnapshotLegacy(ctx, tx, c); err != nil {
+		return out, 0, err
+	}
 	return out, maxID, nil
 }
 

+ 37 - 15
internal/academic/dualrun/stage2/run.go

@@ -472,7 +472,7 @@ func ProbeCases() []Case {
 			LegacyCampusBalanceNull: true,
 			SpecialCase:             SpecialNullBalanceReject,
 			ExpectTargetSuccess:     false, ExpectLegacySuccess: true,
-			ExpectTargetCode:        3102,
+			ExpectTargetCode: 3102,
 			Note: "**非同构探针**:target 的 `balance` 是 `not null default 0.00`,NULL 存不下 ⇒ 只能给 0.00。" +
 				"legacy 侧 NULL 走 `ISNULL(_balance)` **短路**(`OR` 左边就 LEAVE,右边不求值)、target 侧 0.00 走 `balance<=0` —— " +
 				"理由不同、结论一致(都拒绝)。它证明的是「NULL 余额这个状态在新库不可表达」,**不是**「两侧行为一致」",
@@ -567,7 +567,29 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 
 	// ---- case 有效性前提(活动域)----
 	//
-	// 旧库里如果还有"本 case 之外、同样可被失效"的报名行,legacy 会多失效几条,
+	// ① 活动域快照到底读到了没有。
+	//    `Activity` 为 nil 时所有活动域比对都 `Skipped` —— 这是**有意**的(不把 nil 当相等),
+	//    但也意味着"读取器悄悄返回 nil"会让整块比对无声消失。
+	//    所以只要 case 声明了 `ActivityPlan`,就断言两侧都真读到了、且活动条数与 plan 一致。
+	//    这是**基建断言**:它证明"器材到位",不证明 SP 行为。
+	if c.ActivityPlan != nil {
+		want := len(c.ActivityPlan.Items)
+		lgN, tgN := -1, -1
+		if lg.Before.Activity != nil {
+			lgN = len(lg.Before.Activity.Activities)
+		}
+		if tg.Before.Activity != nil {
+			tgN = len(tg.Before.Activity.Activities)
+		}
+		res.Assertions = append(res.Assertions, Assertion{Kind: AssertionInfrastructure,
+			Name: "case 有效性前提:**活动域快照两侧都读到了**(否则整块活动比对会无声 Skipped)",
+			Pass: lgN == want && tgN == want,
+			Detail: fmt.Sprintf("plan 内活动 %d 条 | 快照读到 legacy=%s target=%s(−1 = 整块 Activity 为 nil)",
+				want, fmtSnapN(lgN), fmtSnapN(tgN)),
+		})
+	}
+
+	// ② 旧库里如果还有"本 case 之外、同样可被失效"的报名行,legacy 会多失效几条,
 	// 而 target 侧没有那些活动(活动域还没迁移)—— 两侧失效集合天然不同。
 	// 那种差异是"数据没搬全",不是"SP 行为不对"。与其让它以一个看不懂的差异出现,
 	// 不如在 case 开始就断言它是 0(和"两侧基线逐分相等"同一性质的前置断言)。
@@ -732,13 +754,13 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		add("target 是否显式拒绝", "N/A", fmt.Sprint(tgRejected), tgRejected,
 			"Go 应显式报错(业务码 "+fmt.Sprint(c.ExpectTargetCode)+"),错误文本:"+tgErr)
 		asserts = append(asserts,
-			Assertion{Kind: AssertionBusiness, 
+			Assertion{Kind: AssertionBusiness,
 				Name: "intentional-strict:legacy 静默不报错 + target 显式拒绝(码要对)",
 				Pass: lgOK && tgRejected && codeOK,
 				Detail: fmt.Sprintf("legacy 抛错=%v(期望 false,SP 静默 no-op);target 被拒=%v 码=%d(期望 被拒=true 码=%d)err=%q",
 					!lgOK, tgRejected, tgCode, c.ExpectTargetCode, tgErr),
 			},
-			Assertion{Kind: AssertionBusiness, 
+			Assertion{Kind: AssertionBusiness,
 				Name: "intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条、活动状态不变)",
 				Pass: noWrite && signedSame,
 				Detail: fmt.Sprintf("legacy 写库=%v(全局/校区)流水=%d 活动变=%v | target 写库=%v(全局/校区)流水=%d 活动变=%v —— 都应为 未写库/0 条/不变",
@@ -749,7 +771,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		// SP 若自带结果集,它的 success/msg 也是契约:SP8 找不到校区时返回 success=0 而不是抛错。
 		if c.Op == OpCancelRechargeActivity && len(lg.Calls) > 0 && lg.Calls[0].Returned != nil {
 			r := lg.Calls[0].Returned
-			asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+			asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 				Name:   "SP8 返回值:找不到校区时 **success=0 + msg='找不到学校信息'**(不抛错,靠返回值表达失败)",
 				Pass:   r["success"] == "0" && r["msg"] == "找不到学校信息",
 				Detail: fmt.Sprintf("实测 schoolid=%q success=%q msg=%q", r["schoolid"], r["success"], r["msg"]),
@@ -759,7 +781,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		// ⇒ 走 `_balance<=0` 的 LEAVE,返回 **success=0 + msg='余额不能≤0'**,调用方看到的是"成功"。
 		if c.Op == OpCustomRefund && len(lg.Calls) > 0 && lg.Calls[0].Returned != nil {
 			r := lg.Calls[0].Returned
-			asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+			asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 				Name:   "SP9 返回值:校区账户行不存在时 **success=0 + msg='余额不能≤0'**(不抛错,靠返回值表达失败)",
 				Pass:   r["success"] == "0" && r["msg"] == "余额不能≤0",
 				Detail: fmt.Sprintf("实测 success=%q msg=%q(SP 的 `SELECT ... INTO` 无行可赋 ⇒ 变量停在默认 0,与'余额真的是 0'不可区分)", r["success"], r["msg"]),
@@ -873,7 +895,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		} else {
 			ok = ok && isZero(dCampus.Balance, dCampus.Nulls.Balance) && isZero(tCampus.Balance, tCampus.Nulls.Balance)
 		}
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: names + ":Δbalance 必须**恰好是 0**(不是不比较,也不是 NULL)",
 			Pass: ok,
 			Detail: fmt.Sprintf("全局 %s/%s;转出校区 %s/%s;转入校区 %s/%s(都应为 0.00)",
@@ -967,7 +989,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
 	add("首次调用是否成功", fmt.Sprint(lgOK), fmt.Sprint(tgOK), lgOK == tgOK, "R15 场景两侧都应成功")
 
-	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name: "首次调用结果与预期一致(success / R15 不报余额不足)",
 		Pass: lgOK == c.ExpectLegacySuccess && tgOK == c.ExpectTargetSuccess,
 		Detail: fmt.Sprintf("legacy success=%v(期望 %v);target success=%v(期望 %v)|target err=%q",
@@ -978,7 +1000,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	if c.RunTimes >= 2 {
 		lgSecondOK := len(lg.Calls) >= 2 && lg.Calls[1].Success
 		tgRepeated := len(tg.Calls) >= 2 && tg.Calls[1].Repeated
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: "幂等:target 第二次 repeated=true;legacy SP 无幂等(会重复入账)",
 			Pass: tgRepeated && lgSecondOK,
 			Detail: fmt.Sprintf("target 第2次 repeated=%v(期望 true);legacy 第2次 success=%v(SP 无幂等键,确实重复入账)",
@@ -998,7 +1020,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	if c.Op == OpRecharge || c.Op == OpSchoolRecharge {
 		okL := dCampus.Balance == wfL.Balance && dCampus.Income == wfL.Income
 		okT := tCampus.Balance == wfT.Balance && tCampus.Income == wfT.Income
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: "双表写入:校区表 Δbalance / Δtotal_income 必须等于充值额(若被写成\"只写全局\"会红)",
 			Pass: okL && okT,
 			Detail: fmt.Sprintf("校区 Δbalance legacy=%s target=%s(期望 %s / %s);Δincome legacy=%s target=%s(期望 %s / %s)",
@@ -1010,7 +1032,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	// ⑥.6 批 B 的 op 专属断言
 	switch c.Op {
 	case OpSpend:
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name:   "累计消费:**不写流水**(dump 的 SP11 全文没有 INSERT log)",
 			Pass:   len(lg.After.Logs) == 0 && len(tg.After.Logs) == 0,
 			Detail: fmt.Sprintf("legacy %d 条 / target %d 条(都应为 0)", len(lg.After.Logs), len(tg.After.Logs)),
@@ -1020,7 +1042,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		typesL, typesT := logTypes(lg.After.Logs), logTypes(tg.After.Logs)
 		okL := transferLogsOK(lg.After.Logs, runsLegacy)
 		okT := transferLogsOK(tg.After.Logs, runsTarget)
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: "转结:每次**一对流水(106 +m / 206 −m)且 SUM(money)==0**(钱没消失,只是换了校区)",
 			Pass: okL && okT && sumL == 0 && sumT == 0,
 			Detail: fmt.Sprintf("legacy %d 条 types=%s SUM=%s(期望 %d 条)| target %d 条 types=%s SUM=%s(期望 %d 条)",
@@ -1030,7 +1052,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		// 转结的核心语义:两个校区**各自**变化,且互为反号
 		ok := dCampus.Expenditure == -dCampus2.Expenditure && tCampus.Expenditure == -tCampus2.Expenditure &&
 			dCampus.Expenditure != 0
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: "转结:两校区 total_expenditure 变化量**互为反号**(转出 −m / 转入 +m)",
 			Pass: ok,
 			Detail: fmt.Sprintf("legacy 转出=%s 转入=%s | target 转出=%s 转入=%s",
@@ -1043,7 +1065,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			tGlobal.Balance == c.AmountCents && tGlobal.Special == c.AmountCents &&
 			dCampus.Balance == c.AmountCents && dCampus.Special == c.AmountCents &&
 			tCampus.Balance == c.AmountCents && tCampus.Special == c.AmountCents
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: "坏账:balance 与 special_expenses **必须同时 +金额**(只加其中一个 → 红)",
 			Pass: ok,
 			Detail: fmt.Sprintf("legacy 全局 Δbal=%s Δspec=%s | target 全局 Δbal=%s Δspec=%s(期望各 +%s)",
@@ -1061,7 +1083,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	if c.ExpectNegativeBalance {
 		negOK := lg.After.Global.Balance < 0 && tg.After.Global.Balance < 0 &&
 			lg.After.Campus.Balance < 0 && tg.After.Campus.Balance < 0
-		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: "R15:余额不足时两侧都扣成负数且都返回成功(不校验余额)",
 			Pass: negOK,
 			Detail: fmt.Sprintf("legacy 全局=%s 校区=%s | target 全局=%s 校区=%s(都应为负)",

+ 4 - 0
internal/academic/dualrun/stage2/target.go

@@ -473,6 +473,10 @@ SELECT id, type, CAST(money AS DECIMAL(12,2)), IFNULL(log_id,''), IFNULL(request
 		return out, 0, err
 	}
 	out.Signed = signed
+	// 活动域整行快照(六表三态)。`ActivityPlan == nil` 时返回 nil ⇒ 活动域比对全部 Skipped。
+	if out.Activity, err = readActivitySnapshotTarget(ctx, tx, c, canonicalUID); err != nil {
+		return out, 0, err
+	}
 	return out, maxID, nil
 }