Explorar o código

feat(ac15): 批C1 双跑——余额清零(SP10) + 取消充值活动(SP8),21/21 PASS

按裁决把批 C 拆两支,本批是 C1(cleared_balance + cancel_recharge_activity)。

## 两支 SP 的真实行为(读定义得来,不是按名字猜)

- SP10 清零:`balance−campusBalance`、`special_expenses+campusBalance`、`spend+campusBalance`
  三列同额、**双表**;流水 type=208 money=−campusBalance 且**无 logid**。
  ⚠ 名字骗人:清的是**校区**余额,全局只减同一个 campusBalance → 清零后全局一般**不是 0**。
- SP8 取消充值:`balance−money`、`total_income−money`(双表),流水 type=209;
  报名状态置 **0(取消报名)**,不是 4(过期)—— 与 SP10 的 4 正好相反。

## 三条前置校验:legacy 静默 LEAVE vs Go 3120

派单说"余额已 0 时再清零 → 3120",那是 **Go 侧**;SP 的三条守卫都是 `LEAVE leaveProcedure`,
**静默 no-op、调用方看到成功**。所以三条守卫各一个 intentional-strict case(C1-3/4/5),
并且把断言从"报错了"加强到"报了哪个码"(CallOutcome.Code 用 errors.As 解 *AppError.Code):
3120 / 3104 / 3140 都是设计文档里定死的契约。

## 两侧跨域读的还不是同一张表

legacy SP 读 `yuansiwei.t_school_campus`(旧库视图),Go 读 `yuansiwei_2026.t_campus`(迁移后表)。
两者只要不一致,两侧就走不同分支(自营 `campus_id IN (x,0)` vs 加盟 `= x`),
而那种红看起来像"SP 行为不对"。所以单独一条断言「两侧看到的校区元数据一致(机构+是否自营)」,
并去掉 seedTarget 里 t_campus.self_support 的硬编码 1(硬编码会让加盟分支永远走不到)。

## 活动矩阵:6 条活动覆盖 6 个判据

本级校区 / 全机构池(campus_id=0) / 其他校区 / 其他机构 / type=3(代金券) / 已过期(4) / 未报名,
外加"其他用户在同一活动上的报名"验证 `WHERE userid=?` 不波及旁人。
自营实测变化 91,92;加盟实测变化 93 —— 两条 case 结果必须不同,单测里硬性断言了这一点。

## 三处"看起来在测、其实没测",都是自己发现的

1. **C1-1 基线选错**:原用用户天然余额(全局 9740 = 校区 9740),
   此时"减 campusBalance"与"全局归 0"结果完全相同 → 变异 M1 打过去是**绿的**。
   改用 Baseline 造 5000/1200,Δ 才第一次能区分对错。
2. **C1-8 校区缺失**:seedTarget 会顺手给 999999 建一行 t_campus → Go "找到了校区"并成功。
   改法是显式声明 `CampusMissing: true`(不建校区、不建校区账户),
   再配一条对称断言「该存在的存在、该缺失的缺失」——
   否则"读不到账户"被读成 0 余额,两侧都是 0,基线相等断言会空过。
3. **Baseline 双用途**:批 C1 用它造"全局>校区",结果 `if Baseline != nil { 断言余额为负 }`
   假红。改成显式 `ExpectNegativeBalance`,只有 R2 设它。
   "用到了某个字段"不等于"这个字段的原意成立"。

另修 3 个自测假红:readLegacySigned/readTargetSigned 的 nil plan 解引用(panic)、
清零无请求号导致 request_id 断言期望错、E2 cancel 的"本次变化"只统计→4 导致打印 <无>。

## SP8 的返回值也是契约

它每条路径都以 SELECT 结尾(成功 schoolid/success/msg;找不到校区 success=0 + '找不到学校信息')。
所以:不能用 Exec 调(结果集必须读干净)→ returnsResultSet(op) 分流;
"成功返回但业务失败"只能靠读返回值发现 → CallOutcome.Returned 按列名保存。

## 变异实验(3 组,全部还原)

M1 清零写成"全局归 0"     → ✘ 全局 Δbalance legacy=-1200.00 target=-5000.00
M2 self_support 硬编码 1   → ✘ 校区元数据一致(第一条直接指出原因)+ ✘ target 多失效 92:2→4
M3 取消写成 status=4       → ✘ target {91=4,…}(期望 0)

## 验证

21 个 case(A1–A7 / B1–B4 / C1-1…C1-8 / R1 / R2)**21 PASS,0 FAIL**,两侧回滚后零残留;
单测 14 组(新增 clearedBalanceExpected / expirable / expectedSignedAfter / fmtChanges / opFlags)。
批 A、批 B 未重跑(只加了断言门禁,未改其判据)。
Q hai 6 horas
pai
achega
3af533e5a3

+ 179 - 1
internal/academic/dualrun/stage2/README.md

@@ -9,6 +9,11 @@
 | **B** | `sp_user_expense_baddebt` | `BadDebt` | **balance+ 且 special_expenses+**(双增),**双表** |每次 1 条(type=108)|
 | **B** | `sp_user_expense_school_spend` | `AccumulateSpend` | **只 spend+**,balance 不动,**双表** |**0 条**(SP11 全文没有 INSERT)|
 | **B** | `sp_user_expense_school_transfer_expenditure` | `Transfer` | 转出校区 exp−/income−,转入校区 exp+/income+,**balance 两侧都不动** |每次**2 条**(106 +m / 206 −m)|
+| **C1** | `sp_user_expense_school_cleared_balance` | `RefundService.ClearBalance` | balance−/special+/**spend+**(三列同额,双表),**清的是校区行** |每次 1 条(type=208,**无 logid**)|
+| **C1** | `sp_user_expense_cancel_recharge_activity` | `RecommendService.CancelRechargeActivity` | balance−/total_income−(双表,充值的反向) |每次 1 条(type=209)|
+
+> 另:`sp_user_expense_cancel_recharge_activity` 会改 `t_courses_activity_signed.status`(→0),
+> `sp_user_expense_school_cleared_balance` 会改(→4)—— 见 §7。
 
 ## 0. 一个必须先纠正的事实:**没有"只写全局"的充值 SP**
 
@@ -40,7 +45,7 @@ UPDATE `t_user_expense`        SET `balance`=`balance`+inputMoney,`total_income`
 ```
 
 ```bash
-go run ./cmd/dualrun stage2              # 跑全部 13 个 case(A1–A7 / B1–B4 / R1 / R2)
+go run ./cmd/dualrun stage2              # 跑全部 21 个 case(A1–A7 / B1–B4 / C1-1…C1-8 / R1 / R2)
 go run ./cmd/dualrun stage2 --case B2    # 只跑某个 case(名字子串匹配)
 ```
 
@@ -281,3 +286,176 @@ M3 还顺带被**第三条**断言抓到:`target.balance_after=180.00 ≠ 调
 `balance_after` 是 Go 独有的列,旧库无从对照,但它自己必须与"调用后的余额"自洽 ——
 这条自洽性检查在这次变异里起到了冗余防线的作用。
 
+
+---
+
+## 7. 批 C1:余额清零(SP10)+ 取消充值活动(SP8)
+
+这两支的**资金**部分很简单,「跨域 + 活动状态」部分才是重点。
+
+| SP | 签名(照 dump) | 资金增量 | 流水 | 活动状态 |
+|---|---|---|---|---|
+| `sp_user_expense_school_cleared_balance`(SP10) | `(userid, schoolid, campusid, remark, createUserid)` —— **无金额、无请求号** | `balance−campusBalance`、`special_expenses+campusBalance`、`spend+campusBalance`(双表同额) | type=**208**,money=`−campusBalance`,**无 logid** | 充值赠送报名 → **4**(过期) |
+| `sp_user_expense_cancel_recharge_activity`(SP8) | `(userid, activityid, campusid, money, requestid, remark, createUserid)` —— **无 schoolid** | `balance−money`、`total_income−money`(双表) | type=**209**,money=`−money` | 该活动报名 → **0**(取消报名) |
+
+### 7.1 「清零」清的是**校区**,不是全局
+
+```sql
+UPDATE t_user_expense_school SET balance=balance-campusBalance,
+       special_expenses=special_expenses+campusBalance, spend=spend+campusBalance WHERE ...;
+UPDATE t_user_expense        SET balance=balance-campusBalance,
+       special_expenses=special_expenses+campusBalance, spend=spend+campusBalance WHERE userid=...;
+```
+
+全局那一行减的是**同一个 `campusBalance`**,所以清零后全局余额 = 原全局 − 本校区余额,
+**一般不是 0**(它等于其他校区余额之和)。名字叫"余额清零",清的只有校区这一行。
+
+三条断言分别钉住三件事:
+```
+✔ 清零:**校区**余额恰好归 0(「清零」清的是校区这一行)
+✔ 清零:全局余额只减 campusBalance(**不是**归 0,除非原本只有一个校区有钱)
+   legacy 全局 5000.00→3800.00(Δ-1200.00,campusBalance=1200.00)| target 全局 5000.00→3800.00(…)
+✔ 清零:special_expenses 与 spend **同额增加** campusBalance(三列一起动,只动一列会红)
+```
+
+> ⚠ **基线的选择决定了断言能不能分辨对错**。
+> 一开始 C1-1 用的是用户天然余额:全局 9740 = 校区 9740。
+> 这时"减 campusBalance"和"全局归 0"**结果完全相同**,变异 M1 打过去是绿的 ——
+> 断言看起来在跑,实际什么都没测。
+> 所以 C1-1/C1-2 用 `Baseline` 把全局造得比校区大(5000 vs 1200),C1-1 的 Δ 才第一次真正区分开。
+
+### 7.2 三条前置校验:legacy 静默 LEAVE,Go 3120
+
+```sql
+IF (ISNULL(campusBalance) || campusBalance<=0) THEN LEAVE leaveProcedure; END IF;
+IF (ISNULL(totalBalance)  || totalBalance <=0) THEN LEAVE leaveProcedure; END IF;
+IF (campusBalance > totalBalance)              THEN LEAVE leaveProcedure; END IF;
+```
+
+派单里写"余额已 0 时再清零 → 3120"——**那是 Go 侧**的行为。SP 三条守卫都是**静默 no-op**,
+调用方看到的是"成功"。所以这又是一组 `intentional-strict`(和 A7 同一形状),
+三条守卫各一个 case(C1-3 / C1-4 / C1-5),断言改成:
+
+```
+✔ intentional-strict:legacy 静默不报错 + target 显式拒绝(码要对)
+   legacy 抛错=false(期望 false,SP 静默 no-op);target 被拒=true 码=3120(期望 被拒=true 码=3120)
+✔ intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条、活动状态不变)
+```
+
+**比"报错了"强一档的是"报了哪个码"**:`CallOutcome.Code` 用 `errors.As` 解出 `*AppError.Code`,
+断言 3120 / 3104 / 3140。同一个文案可能被别处复用,**码是设计文档里定死的契约**。
+
+### 7.3 两支 SP 都会跨域读校区 —— 两侧读的还不是同一张表
+
+| 侧 | 读的是 | SQL |
+|---|---|---|
+| legacy SP | `yuansiwei.t_school_campus`(**旧库视图**) | `SELECT self_support FROM yuansiwei.t_school_campus WHERE id=?` |
+| Go | `yuansiwei_2026.t_campus`(**迁移后的表**) | `SELECT institution_id, self_support FROM t_campus WHERE campus_id=?` |
+
+两个来源只要在某校区上不一致,两侧就会走**不同分支**(自营 `campus_id IN (x,0)` vs 加盟 `= x`),
+而那种红看起来像"SP 行为不对",其实是"校区元数据没对齐"。所以单独一条断言:
+
+```
+✔ 两侧看到的校区元数据一致(机构 + 是否自营)—— 不同就会走不同失效分支
+   legacy 视图 yuansiwei.t_school_campus:机构=63317 自营=true | target t_campus:机构=63317 自营=true | case 声明:机构=63317 自营=true
+```
+
+配套两条纪律:
+1. `Case.SelfSupport` 由 case **显式声明**,而不是"从哪一侧读出来喂给另一侧";
+2. `seedTarget` 的 `t_campus.self_support` **不许硬编码**。原写法就是硬编码 1,
+   后果是"加盟校区"这条分支永远走不到 —— 变异 M2 打过去立刻红
+   (`92:2→4` 多失效了一条 `campus_id=0` 的全机构池活动)。
+
+### 7.4 活动矩阵:6 条活动覆盖 6 个判据
+
+`C1ActivityPlan(base)`(id 由运行期取 legacy `max(id)+1`,两侧同值):
+
+| 偏移 | type | 机构 | 校区 | 报名状态 | 用来验证 |
+|---|---|---|---|---|---|
+| +0 | 2 | 63317 | **5(本级)** | 2 | 自营/加盟**都**应失效 |
+| +1 | 2 | 63317 | **0(全机构池)** | 2 | **只有自营**失效 ← 关键分支 |
+| +2 | 2 | 63317 | 4(其他校区) | 2 | 校区过滤 |
+| +3 | 2 | **63322(其他机构)** | 5 | 2 | 机构过滤 |
+| +4 | **3(代金券)** | 63317 | 5 | 2 | type 过滤 |
+| +5 | 2 | 63317 | 5 | **4(已过期)** | `status<>4` 过滤 |
+| +6 | 2 | 63317 | 5 | **未报名** | JOIN 过滤(没有行就不出现) |
+
+外加 `OtherUsers`:让**别人**在"应被失效"的活动上也有一条报名,验证 `WHERE userid=inputUserid` 不波及旁人。
+
+自营 vs 加盟的实测差异(同一批活动、不同校区):
+```
+C1-1 自营:期望 {91=4, 92=4, 93=2, 94=2, 95=2, 96=4}   本次变化:91:2→4, 92:2→4
+C1-2 加盟:期望 {91=2, 92=2, 93=4, 94=2, 95=2, 96=4}   本次变化:93:2→4
+```
+`TestExpectedSignedAfter` 里还硬性断言"这两个期望**不能相同**" —— 相同就说明分支退化成了一条,
+那两个 case 只测了一遍。
+
+### 7.5 case 有效性前提:旧库里没有"别的"可失效报名
+
+活动域**还没迁移**,所以旧库里真实存在的可失效报名,target 侧没有。
+若不清零,legacy 会多失效几条而 target 不失效,两侧失效集合天然不同 ——
+那种红是"数据没搬全",不是"SP 行为不对"。所以每个清零 case 开头先断言:
+
+```
+✔ case 有效性前提:旧库里该用户**没有别的**可失效报名(否则两侧失效范围天然不同)
+   本 case 之外可失效报名行=0
+```
+
+选人就是这么选出来的:`68542` / `40583` 在旧库都恰好没有 type=2 的可失效报名
+(反例:`17735` 有 8 条、`31477` 有 3 条,都不能用来跑清零)。
+
+### 7.6 SP8 的返回值本身是契约
+
+`sp_user_expense_cancel_recharge_activity` 的**每条路径**都以 `SELECT` 结尾:
+
+```sql
+-- 正常结束
+SELECT selectSchoolid AS schoolid, success, msg;   -- success=1, msg='ok'
+-- 找不到校区
+SET msg='找不到学校信息'; SELECT success, msg; LEAVE leaveProcedure;   -- success=0,**不抛错**
+```
+
+两件事因此变了:
+1. 它不能用 `Exec` 调(结果集必须读干净,否则连接上留下未消费的结果集)→ `returnsResultSet(op)` 分流;
+2. "成功返回但业务失败"(`success=0`)只能靠读返回值发现 → `CallOutcome.Returned` 按列名保存。
+
+```
+✔ SP8 返回值:success=1 / msg=ok / schoolid 由校区反查得到
+✔ SP8 返回值:找不到校区时 **success=0 + msg='找不到学校信息'**(不抛错,靠返回值表达失败)
+```
+
+### 7.7 一个"看起来在测、其实没测"的坑:CampusMissing
+
+C1-8 要验证"校区不存在"。但 `seedTarget` 需要**先建 `t_campus` 行**,Go 才找得到校区 ——
+如果不加声明,它会顺手给 999999 也建一行,Go 就"找到了校区"并成功。
+第一次跑 C1-8 就是这样:legacy 正确返回 `success=0`,target 却 `success=true`(FAIL 3 项)。
+
+修法不是"让它过",而是把这个前提**显式声明**出来:
+
+```go
+CampusMissing: true   // 本 case 故意用不存在的校区;不建 t_campus、不建校区账户
+```
+
+再配一条对称断言(`campusAccountAssertion`):**该存在的存在、该缺失的缺失**。
+没有它,"读不到账户"会被读成 0 余额,两侧都是 0 → 基线相等断言**空过**,
+整个 case 看起来在跑、其实什么都没测。
+
+### 7.8 批 C1 变异实验(3 组,全部还原)
+
+| # | 改错什么 | 靶子 | 实测 |
+|---|---|---|---|
+| **M1** | 把"清零"实现成 **全局余额归 0**(而不是减 campusBalance) | `全局只减 campusBalance` | `✘ 全局 Δbalance legacy=-1200.00 target=-5000.00`;`✘ 清零:全局余额只减 campusBalance → target 5000.00→0.00`(**改前是绿的**,见 7.1 的注) |
+| **M2** | `t_campus.self_support` 硬编码成 1 | 校区元数据一致 + 活动失效范围 | `✘ 两侧看到的校区元数据一致`(target 自营=true ≠ 声明 false);`✘ 活动失效范围 → target 多失效 92:2→4` |
+| **M3** | 取消活动把报名状态写成 **4**(过期)而非 **0**(取消) | `归 0 不是 4` | `✘ 报名状态归 **0** → target {91=4,…}`;`✘ 只有本活动的报名状态变` |
+
+M2 特别值得记:它同时红了两条,而且**第一条直接指出原因**(元数据不一致),
+第二条才是指标(失效范围错)。"断言拆得细"不是为了严格,是为了**出错时能定位**。
+
+### 7.9 `Baseline` 有两种用途,别混
+
+`Case.Baseline` 原来只有一个用途(把余额压低验证 R15)。批 C1 又用它造
+"全局 > 校区"的基线。于是 `if c.Baseline != nil { 断言余额为负 }` 这条**假红**了 ——
+C1-1 的余额根本不该为负。
+
+改成显式声明:`Case.ExpectNegativeBalance`,只有 R2 设它。
+**"用到了某个字段"不等于"这个字段的原意成立"** —— 判据要写在 case 上,不要从别的字段反推。

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

@@ -0,0 +1,419 @@
+package stage2
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"sort"
+	"strings"
+
+	"gorm.io/gorm"
+)
+
+// 本文件是批 C1 的**活动侧种子**:`cleared_balance` / `cancel_recharge_activity` 的 SP
+// 除了动资金,还会动 `t_courses_activity_signed.status`。要验证这件事,
+// 就必须让两侧**看到同一批活动**,否则"活动失效"这条断言等于没写。
+//
+// 三个设计要点:
+//
+//  1. **D1 显式 id**:活动 id 由两侧同一份 base 决定(= legacy 现有 max(id)+1),
+//     这样"哪几个活动被失效"可以直接按 id 对比,不需要反查名字。
+//  2. **判据照 dump**:`type=2`(充值赠送)、`signed.status<>4`、机构匹配、
+//     以及自营/加盟的 `campus_id IN (x,0)` vs `= x` 分流 —— 都在
+//     `expectedExpiredActivities` 里按 dump 写一遍,而不是"看 Go 怎么写的然后照着写"。
+//  3. **覆盖四个分支**:本校区 / 全机构池(campus_id=0) / 其他校区 / 其他机构 / type=3 / 已过期(4)。
+//     少任何一个分支,"自营 vs 加盟"就退化成同一个结果,断言也就失去意义。
+
+// ActivityItem 是一条活动定义(活动表里的一行)。
+type ActivityItem struct {
+	ID            int64 // 活动 id(两侧同值,D1)
+	Type          int8  // 2=充值赠送 3=代金券
+	InstitutionID int64 // 旧 schoolid
+	CampusID      int64 // 0 = 全机构池(所有自营校区)
+	SignedStatus  int32 // 该用户在本次活动上的报名状态(只对"本 case 用户"有意义)
+}
+
+// ActivitySeed 是"这个 case 要建活动种子"的声明。
+//
+// 活动 id 由运行期决定(legacy 现有 max(id)+1),所以这里**不能**写死 id ——
+// 写死会让 `t_courses_activity.id`(int unsigned 自增、且自增不回滚)
+// 被永久推到那个值,对真实数据造成不必要的偏移。
+type ActivitySeed struct {
+	// OtherUsers 是同一批活动上的**其他用户**报名行(验证"只动本人")。
+	// key 是 legacy int userid(target 侧按同一函数换成 ULID)。
+	OtherUsers map[int64]int32
+}
+
+// ActivityPlan 是运行期实际使用的活动种子(id 已确定)。
+//
+//	Items 里每一条都会在两侧各插一行活动 + 一行报名(若 SignedStatus >= 0)。
+//	SignedStatus < 0 表示"该用户没有报名这条活动",用于验证 JOIN 的过滤。
+type ActivityPlan struct {
+	BaseID     int64
+	Items      []ActivityItem
+	OtherUsers map[int64]int32
+}
+
+// C1ActivityPlan 返回批 C1 用的活动矩阵。
+//
+// base 由运行期决定(legacy max(id)+1),保证不与既有数据冲突、且两侧同 id。
+//
+//	id+0  充值赠送 · 本级校区(5)   · status=2   ← 自营/加盟都应被失效
+//	id+1  充值赠送 · 全机构池(0)   · status=2   ← **只有自营**才失效(关键分支)
+//	id+2  充值赠送 · 其他校区(4)   · status=2   ← 都不应被失效
+//	id+3  充值赠送 · 其他机构      · status=2   ← 机构过滤,都不应被失效
+//	id+4  代金券   · 本级校区(5)   · status=2   ← type 过滤,都不应被失效
+//	id+5  充值赠送 · 本级校区(5)   · status=4   ← 已过期,不应再被"失效"一次
+//	id+6  充值赠送 · 本级校区(5)   · 未报名     ← 没有报名行,JOIN 后不该出现
+func C1ActivityPlan(base int64, otherUsers map[int64]int32) *ActivityPlan {
+	const inst = int64(63317)
+	return &ActivityPlan{
+		BaseID: base,
+		Items: []ActivityItem{
+			{ID: base + 0, Type: 2, InstitutionID: inst, CampusID: 5, SignedStatus: 2},
+			{ID: base + 1, Type: 2, InstitutionID: inst, CampusID: 0, SignedStatus: 2},
+			{ID: base + 2, Type: 2, InstitutionID: inst, CampusID: 4, SignedStatus: 2},
+			{ID: base + 3, Type: 2, InstitutionID: 63322, CampusID: 5, SignedStatus: 2},
+			{ID: base + 4, Type: 3, InstitutionID: inst, CampusID: 5, SignedStatus: 2},
+			{ID: base + 5, Type: 2, InstitutionID: inst, CampusID: 5, SignedStatus: 4},
+			{ID: base + 6, Type: 2, InstitutionID: inst, CampusID: 5, SignedStatus: -1},
+		},
+		OtherUsers: otherUsers,
+	}
+}
+
+// ActivityIDs 返回本 plan 全部活动 id(升序)。
+func (p *ActivityPlan) ActivityIDs() []int64 {
+	if p == nil {
+		return nil
+	}
+	out := make([]int64, 0, len(p.Items))
+	for _, it := range p.Items {
+		out = append(out, it.ID)
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
+	return out
+}
+
+// expectedSignedAfter 返回"调用**之后**该用户在 plan 各活动上的报名状态"。
+//
+// expire=true 时按 dump 的失效判据把该失效的置 4;cancelActivityID > 0 时把该活动置 0。
+// 两者互斥(一个 case 只做一件事)。
+func (p *ActivityPlan) expectedSignedAfter(
+	expire bool, institutionID, campusID int64, selfSupport bool, cancelActivityID int64,
+) map[int64]int32 {
+	out := make(map[int64]int32, len(p.Items))
+	for _, it := range p.Items {
+		if it.SignedStatus < 0 {
+			continue // 没有报名行,调用后也没有
+		}
+		st := it.SignedStatus
+		if expire && p.expirable(it, institutionID, campusID, selfSupport) {
+			st = 4
+		}
+		if cancelActivityID > 0 && it.ID == cancelActivityID {
+			st = 0
+		}
+		out[it.ID] = st
+	}
+	return out
+}
+
+// expirable 是 dump 里活动失效判据的**逐条复刻**(SP10 的 tmp_user_activity 两段 INSERT):
+//
+//	t2.type = 2                      -- 只处理充值赠送
+//	t1.status != 4                   -- 已经过期的不用再处理
+//	t2.schoolid = inputSchoolid      -- 机构过滤
+//	自营: t2.campusid IN (inputCampusid, 0)
+//	加盟: t2.campusid  = inputCampusid
+func (p *ActivityPlan) expirable(it ActivityItem, institutionID, campusID int64, selfSupport bool) bool {
+	if it.Type != 2 || it.SignedStatus == 4 || it.SignedStatus < 0 {
+		return false
+	}
+	if it.InstitutionID != institutionID {
+		return false
+	}
+	if selfSupport {
+		return it.CampusID == campusID || it.CampusID == 0
+	}
+	return it.CampusID == campusID
+}
+
+// fmtChanges 列出"所有发生变化"的活动("91:2→4" 形式,按 id 升序)。
+//
+// 为什么不用"哪些变成了 4":取消充值是变成 **0**,只盯 4 会让这条打印出"<无>",
+// 而"<无>"和"没做断言"在报告里长得一样 —— 那正是要避免的。
+func fmtChanges(before, after map[int64]int32) string {
+	ids := make([]int64, 0, len(after))
+	for id := range after {
+		if before[id] != after[id] {
+			ids = append(ids, id)
+		}
+	}
+	if len(ids) == 0 {
+		return "<无>"
+	}
+	sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+	parts := make([]string, len(ids))
+	for i, id := range ids {
+		parts[i] = fmt.Sprintf("%d:%d→%d", id, before[id], after[id])
+	}
+	return strings.Join(parts, ", ")
+}
+
+// fmtSigned 把报名状态 map 格式化成 "91=2, 92=4" 形式(按 id 升序,报告可读)。
+func fmtSigned(m map[int64]int32) string {
+	if len(m) == 0 {
+		return "{}"
+	}
+	ids := make([]int64, 0, len(m))
+	for id := range m {
+		ids = append(ids, id)
+	}
+	sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+	parts := make([]string, 0, len(ids))
+	for _, id := range ids {
+		parts = append(parts, fmt.Sprintf("%d=%d", id, m[id]))
+	}
+	return "{" + strings.Join(parts, ", ") + "}"
+}
+
+// sameSigned 比较两张报名状态 map(键集合与值都要相同)。
+func sameSigned(a, b map[int64]int32) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for k, v := range a {
+		if bv, ok := b[k]; !ok || bv != v {
+			return false
+		}
+	}
+	return true
+}
+
+// seedLegacyActivities 在 legacy 事务内建活动 + 报名(幂等:先删后插)。
+//
+// 为什么这里敢 DELETE:**只在事务内**,事务结束一律 Rollback;且删的是本 plan 的 id,
+// 也就是"上一次运行的残留"(若有,说明上一次没回滚干净——那正是要暴露的问题)。
+func seedLegacyActivities(ctx context.Context, tx *gorm.DB, c Case, uid int64) error {
+	p := c.ActivityPlan
+	if p == nil {
+		return nil
+	}
+	ids := p.ActivityIDs()
+	if err := tx.Exec(`DELETE FROM t_courses_activity_signed WHERE activityid IN (?)`, ids).Error; err != nil {
+		return fmt.Errorf("清理 legacy 报名残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_courses_activity WHERE id IN (?)`, ids).Error; err != nil {
+		return fmt.Errorf("清理 legacy 活动残留失败:%w", err)
+	}
+	for _, it := range p.Items {
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity (id, type, schoolid, campusid, money, status, create_time)
+VALUES (?, ?, ?, ?, ?, 1, NOW())`,
+			it.ID, it.Type, it.InstitutionID, it.CampusID, "0.00").Error; err != nil {
+			return fmt.Errorf("种子 legacy 活动 %d 失败:%w", it.ID, err)
+		}
+		if it.SignedStatus < 0 {
+			continue
+		}
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity_signed (activityid, userid, status, create_time)
+VALUES (?, ?, ?, NOW())`, it.ID, uid, it.SignedStatus).Error; err != nil {
+			return fmt.Errorf("种子 legacy 报名 %d 失败:%w", it.ID, err)
+		}
+	}
+	for otherID, st := range p.OtherUsers {
+		// 其他用户报名在"应被失效"的活动上 —— 验证 SP 的 `WHERE userid=inputUserid` 不会波及旁人。
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity_signed (activityid, userid, status, create_time)
+VALUES (?, ?, ?, NOW())`, p.BaseID, otherID, st).Error; err != nil {
+			return fmt.Errorf("种子 legacy 他人报名失败:%w", err)
+		}
+	}
+	return nil
+}
+
+// seedTargetActivities 在 target 事务内建活动 + 报名(列名是目标态命名,不是旧命名)。
+func seedTargetActivities(ctx context.Context, tx *gorm.DB, c Case, uid string) error {
+	p := c.ActivityPlan
+	if p == nil {
+		return nil
+	}
+	ids := p.ActivityIDs()
+	if err := tx.Exec(`DELETE FROM t_courses_activity_signed WHERE activity_id IN (?)`, ids).Error; err != nil {
+		return fmt.Errorf("清理 target 报名残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_courses_activity WHERE id IN (?)`, ids).Error; err != nil {
+		return fmt.Errorf("清理 target 活动残留失败:%w", err)
+	}
+	for _, it := range p.Items {
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity (id, type, institution_id, campus_id, money, status, create_time, updated_at)
+VALUES (?, ?, ?, ?, ?, 1, NOW(3), NOW(3))`,
+			it.ID, it.Type, it.InstitutionID, it.CampusID, "0.00").Error; err != nil {
+			return fmt.Errorf("种子 target 活动 %d 失败:%w", it.ID, err)
+		}
+		if it.SignedStatus < 0 {
+			continue
+		}
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity_signed
+  (activity_id, user_id, institution_id, campus_id, status, create_time)
+VALUES (?, ?, ?, ?, ?, NOW(3))`,
+			it.ID, uid, it.InstitutionID, it.CampusID, it.SignedStatus).Error; err != nil {
+			return fmt.Errorf("种子 target 报名 %d 失败:%w", it.ID, err)
+		}
+	}
+	for otherID, st := range p.OtherUsers {
+		otherUID := CanonicalUIDFor(otherID)
+		// 其他用户也需要 t_user 行(t_courses_activity_signed 的逻辑 FK)
+		if err := tx.Exec(`
+INSERT INTO t_user (canonical_user_id, display_name, person_type, status)
+VALUES (?, CONCAT('ac15-c1-other-', ?), 'student', 'active')
+ON DUPLICATE KEY UPDATE display_name = VALUES(display_name)`, otherUID, otherID).Error; err != nil {
+			return fmt.Errorf("种子 target 他人 t_user 失败:%w", err)
+		}
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity_signed
+  (activity_id, user_id, institution_id, campus_id, status, create_time)
+VALUES (?, ?, ?, ?, ?, NOW(3))`, p.BaseID, otherUID, c.InstitutionID, c.CampusID, st).Error; err != nil {
+			return fmt.Errorf("种子 target 他人报名失败:%w", err)
+		}
+	}
+	return nil
+}
+
+// readLegacySigned 读"该用户在 plan 各活动上"的报名状态(legacy 侧,旧列名)。
+func readLegacySigned(ctx context.Context, tx *gorm.DB, p *ActivityPlan, uid int64) (map[int64]int32, error) {
+	if p == nil {
+		return map[int64]int32{}, nil
+	}
+	out := make(map[int64]int32, len(p.Items))
+	rows, err := tx.Raw(`
+SELECT activityid, status FROM t_courses_activity_signed
+ WHERE userid = ? AND activityid IN (?)`, uid, p.ActivityIDs()).Rows()
+	if err != nil {
+		return nil, fmt.Errorf("读 legacy 报名状态失败:%w", err)
+	}
+	defer func() { _ = rows.Close() }()
+	for rows.Next() {
+		var id int64
+		var st int32
+		if err := rows.Scan(&id, &st); err != nil {
+			return nil, err
+		}
+		out[id] = st
+	}
+	return out, rows.Err()
+}
+
+// readTargetSigned 读 target 侧报名状态(新列名 activity_id / user_id)。
+func readTargetSigned(ctx context.Context, tx *gorm.DB, p *ActivityPlan, uid string) (map[int64]int32, error) {
+	if p == nil {
+		return map[int64]int32{}, nil
+	}
+	out := make(map[int64]int32, len(p.Items))
+	rows, err := tx.Raw(`
+SELECT activity_id, status FROM t_courses_activity_signed
+ WHERE user_id = ? AND activity_id IN (?)`, uid, p.ActivityIDs()).Rows()
+	if err != nil {
+		return nil, fmt.Errorf("读 target 报名状态失败:%w", err)
+	}
+	defer func() { _ = rows.Close() }()
+	for rows.Next() {
+		var id int64
+		var st int32
+		if err := rows.Scan(&id, &st); err != nil {
+			return nil, err
+		}
+		out[id] = st
+	}
+	return out, rows.Err()
+}
+
+// baseActivityID 取"legacy 现有活动 id 的最大值 + 1",作为本 case 的活动 id 起点。//
+// 为什么用 max+1 而不是固定一个大数:`t_courses_activity.id` 是 int unsigned 自增,
+// 插一个 9xxxxxx 会把表的 AUTO_INCREMENT 永久推到那个值(自增不回滚),
+// 而 91 这种"紧邻水位"的值只让计数器多走 7 格,对真实数据无影响。
+//
+// 注意:**自增计数器不随事务回滚** —— 每跑一次,AUTO_INCREMENT 前进 7;
+// 下次仍是 max(id)+1(行已回滚),所以 id 稳定可复现。
+func baseActivityID(ctx context.Context, db *gorm.DB) (int64, error) {
+	var maxID int64
+	if err := db.Raw(`SELECT IFNULL(MAX(id),0) FROM t_courses_activity`).Row().Scan(&maxID); err != nil {
+		return 0, fmt.Errorf("读 legacy 活动 id 水位失败:%w", err)
+	}
+	return maxID + 1, nil
+}
+
+// readLegacyCampusMeta 读**旧库视图** `yuansiwei.t_school_campus` 里的校区元数据。
+//
+// 这就是 SP 自己读的那一份(SP 全文用 `yuansiwei.t_school_campus`),
+// 而不是我们迁移后的 `t_campus` —— 断言它,才能证明"SP 看到的校区"与"Go 看到的校区"是同一个。
+func readLegacyCampusMeta(ctx context.Context, tx *gorm.DB, campusID int64) *CampusMeta {
+	meta := &CampusMeta{}
+	var schoolID sql.NullInt64
+	var selfSupport sql.NullString
+	err := tx.Raw(`
+SELECT school_id, self_support FROM yuansiwei.t_school_campus WHERE id = ?`, campusID).
+		Row().Scan(&schoolID, &selfSupport)
+	if err != nil {
+		meta.Err = err.Error()
+		return meta
+	}
+	meta.InstitutionID = schoolID.Int64
+	// 视图里 self_support 是 char(1) '0'/'1'(不是 bit(1)、也不是 tinyint)
+	meta.SelfSupport = strings.TrimSpace(selfSupport.String) == "1" ||
+		strings.TrimSpace(selfSupport.String) == "\x01"
+	return meta
+}
+
+// readTargetCampusMeta 读迁移后的 `t_campus` 里的校区元数据(Go 侧只读端口的同一来源)。
+func readTargetCampusMeta(ctx context.Context, tx *gorm.DB, campusID int64) *CampusMeta {
+	meta := &CampusMeta{}
+	var instID int64
+	var selfSupport int64
+	err := tx.Raw(`
+SELECT institution_id, self_support FROM t_campus WHERE campus_id = ?`, campusID).
+		Row().Scan(&instID, &selfSupport)
+	if err != nil {
+		meta.Err = err.Error()
+		return meta
+	}
+	meta.InstitutionID = instID
+	meta.SelfSupport = selfSupport == 1
+	return meta
+}
+
+// countOtherExpirableLegacy 统计"该用户在旧库里、**本 case 活动之外**的可失效报名行数"。
+//
+// 这是 case 有效性的前提:旧库里真实存在的可失效报名,target 侧没有(活动域未迁移),
+// 若不为 0,两侧失效的条数天然不同 —— 那种红是"数据没搬全",不是"SP 行为不对"。
+// 与其让它以一个看不懂的差异出现,不如在 case 开始就断言它是 0。
+func countOtherExpirableLegacy(
+	ctx context.Context, db *gorm.DB, uid int64, institutionID, campusID int64,
+	selfSupport bool, planIDs []int64,
+) (int, error) {
+	q := `
+SELECT COUNT(*) FROM t_courses_activity_signed s
+  JOIN t_courses_activity a ON a.id = s.activityid
+ WHERE s.userid = ? AND a.type = 2 AND s.status <> 4 AND a.schoolid = ?`
+	args := []any{uid, institutionID}
+	if selfSupport {
+		q += ` AND a.campusid IN (?, 0)`
+		args = append(args, campusID)
+	} else {
+		q += ` AND a.campusid = ?`
+		args = append(args, campusID)
+	}
+	if len(planIDs) > 0 {
+		q += ` AND a.id NOT IN (?)`
+		args = append(args, planIDs)
+	}
+	var n int
+	if err := db.Raw(q, args...).Row().Scan(&n); err != nil {
+		return 0, fmt.Errorf("统计旧库其他可失效报名失败:%w", err)
+	}
+	return n, nil
+}

+ 245 - 0
internal/academic/dualrun/stage2/c1_compare.go

@@ -0,0 +1,245 @@
+package stage2
+
+import (
+	"fmt"
+	"strings"
+)
+
+// 本文件是批 C1(清零 / 取消充值活动)的专属断言。
+//
+// 拆出来的原因:这两支 SP 的"资金"部分其实很简单(清零是三条同额 UPDATE,
+// 取消活动是充值的反向),真正难的是**两侧都会跨域读校区、都会改活动状态**。
+// 那两块如果不单独断言,出问题时只会表现为"某个金额对不上",
+// 定位成本远高于把它们各自钉成一条。
+
+// c1Assertions 生成批 C1 的三类断言:校区元数据 / 活动状态 / SP 返回值。
+func c1Assertions(
+	c Case, lg, tg *SideResult,
+	dGlobal, dCampus, tGlobal, tCampus Account,
+	runsLegacy, runsTarget int,
+) []Assertion {
+	var out []Assertion
+
+	// ① 两侧看到的校区元数据必须与 case 声明一致(自营/加盟走不同分支的根源)
+	out = append(out, campusMetaAssertion(c, lg, tg))
+	out = append(out, campusAccountAssertion(c, lg, tg))
+
+	// ② 活动失效范围(清零)或报名状态归 0(取消活动)
+	if c.ActivityPlan != nil {
+		out = append(out, signedAssertion(c, lg, tg))
+	}
+
+	// ③ 清零语义:校区余额恰好归 0,全局余额**减去同一个 campusBalance**(一般不是 0)
+	if c.Op == OpClearBalance {
+		okL := lg.After.Campus.Balance == 0 && tg.After.Campus.Balance == 0
+		out = append(out, Assertion{
+			Name: "清零:**校区**余额恰好归 0(「清零」清的是校区这一行)",
+			Pass: okL,
+			Detail: fmt.Sprintf("legacy 校区余额后=%s | target 校区余额后=%s(都应为 0.00)",
+				yuan(lg.After.Campus.Balance), yuan(tg.After.Campus.Balance)),
+		})
+		out = append(out, Assertion{
+			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)",
+				yuan(lg.Before.Global.Balance), yuan(lg.After.Global.Balance), yuan(dGlobal.Balance),
+				yuan(lg.Before.Campus.Balance),
+				yuan(tg.Before.Global.Balance), yuan(tg.After.Global.Balance), yuan(tGlobal.Balance),
+				yuan(tg.Before.Campus.Balance)),
+		})
+		// special_expenses 与 spend 同额加 —— 与"坏账双增"不同,这里是三列一起动
+		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{
+			Name: "清零:special_expenses 与 spend **同额增加** campusBalance(三列一起动,只动一列会红)",
+			Pass: okT,
+			Detail: fmt.Sprintf("legacy 全局 Δspec=%s Δspend=%s | target 全局 Δspec=%s Δspend=%s(期望各 +%s / +%s)",
+				yuan(dGlobal.Special), yuan(dGlobal.Spend),
+				yuan(tGlobal.Special), yuan(tGlobal.Spend),
+				yuan(lg.Before.Campus.Balance), yuan(tg.Before.Campus.Balance)),
+		})
+		// 无请求号 → log_id / request_id 都应为空("不享幂等"是 SP 签名决定的,不是漏写)
+		out = append(out, Assertion{
+			Name: "清零:流水 log_id 与 request_id **都为空**(SP10 没有请求号入参,天然不幂等)",
+			Pass: allLogsWithoutID(lg.After.Logs) && allLogsWithoutID(tg.After.Logs),
+			Detail: fmt.Sprintf("legacy %d 条 %s | target %d 条 %s",
+				len(lg.After.Logs), fmtLogIDs(lg.After.Logs),
+				len(tg.After.Logs), fmtLogIDs(tg.After.Logs)),
+		})
+	}
+
+	// ④ 取消充值活动:流水 type=209、money=−m,且报名状态归 **0**(不是 4)
+	if c.Op == OpCancelRechargeActivity {
+		out = append(out, Assertion{
+			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{
+			Name: "取消充值活动:报名状态归 **0(取消报名)**,不是 4(过期)—— SP8 与 SP10 的差别",
+			Pass: signedAll(c, lg, 0) && signedAll(c, tg, 0),
+			Detail: fmt.Sprintf("legacy %s | target %s(活动 %d 应为 0)",
+				fmtSigned(lg.After.Signed), fmtSigned(tg.After.Signed), c.ActivityID),
+		})
+		// SP 的结果集本身就是契约:success=1/msg=ok,且回显 schoolid
+		out = append(out, spReturnAssertion(c, lg))
+	}
+
+	return out
+}
+
+// campusMetaAssertion 断言"两侧看到的校区元数据 = case 声明,且互相一致"。
+func campusMetaAssertion(c Case, lg, tg *SideResult) Assertion {
+	lm, tm := lg.CampusMeta, tg.CampusMeta
+	if lm == nil || tm == nil {
+		return Assertion{
+			Name:   "两侧看到的校区元数据一致(机构 + 是否自营)",
+			Pass:   false,
+			Detail: "有一侧没读到校区元数据(读失败或未触发读取)",
+		}
+	}
+	// CampusMissing:两侧都应该**查不到**这个校区(不是"查到了但值是 0")
+	if c.CampusMissing {
+		ok := lm.Err != "" && tm.Err != "" && lm.InstitutionID == 0 && tm.InstitutionID == 0
+		return Assertion{
+			Name: "两侧都**查不到**该校区(故意用不存在的校区 id)",
+			Pass: ok,
+			Detail: fmt.Sprintf("legacy 视图:机构=%d%s | target t_campus:机构=%d%s(都应为 0 + 查不到)",
+				lm.InstitutionID, errSuffix(lm.Err), tm.InstitutionID, errSuffix(tm.Err)),
+		}
+	}
+	wantSelf := c.SelfSupport
+	ok := lm.Err == "" && tm.Err == "" &&
+		lm.InstitutionID == c.InstitutionID && tm.InstitutionID == c.InstitutionID &&
+		lm.SelfSupport == wantSelf && tm.SelfSupport == wantSelf &&
+		lm.InstitutionID == tm.InstitutionID && lm.SelfSupport == tm.SelfSupport
+	detail := fmt.Sprintf(
+		"legacy 视图 yuansiwei.t_school_campus:机构=%d 自营=%v%s | target t_campus:机构=%d 自营=%v%s | case 声明:机构=%d 自营=%v",
+		lm.InstitutionID, lm.SelfSupport, errSuffix(lm.Err),
+		tm.InstitutionID, tm.SelfSupport, errSuffix(tm.Err),
+		c.InstitutionID, wantSelf)
+	return Assertion{
+		Name:   "两侧看到的校区元数据一致(机构 + 是否自营)—— 不同就会走不同失效分支",
+		Pass:   ok,
+		Detail: detail,
+	}
+}
+
+// campusAccountAssertion 断言"校区账户该存在的存在、该缺失的缺失"。
+//
+// 没有这条,"读不到账户"会被读成 0 余额,两侧都是 0 → 基线相等"空过",
+// 整个 case 看起来在跑、其实什么都没测。
+func campusAccountAssertion(c Case, lg, tg *SideResult) Assertion {
+	want := !c.CampusMissing
+	ok := lg.Before.HasCampusAccount == want && tg.Before.HasCampusAccount == want
+	word := "存在"
+	if !want {
+		word = "不存在"
+	}
+	return Assertion{
+		Name: "校区账户行" + word + "(CampusMissing 时故意不存在)",
+		Pass: ok,
+		Detail: fmt.Sprintf("legacy 存在=%v | target 存在=%v(期望都为 %v)",
+			lg.Before.HasCampusAccount, tg.Before.HasCampusAccount, want),
+	}
+}
+
+// signedAssertion 断言"调用后的报名状态 = 按 dump 判据算出的期望"(整张 map 全等)。
+func signedAssertion(c Case, lg, tg *SideResult) Assertion {
+	var want map[int64]int32
+	if c.Op == OpClearBalance {
+		want = c.ActivityPlan.expectedSignedAfter(true, c.InstitutionID, c.CampusID, c.SelfSupport, 0)
+	} else {
+		want = c.ActivityPlan.expectedSignedAfter(false, c.InstitutionID, c.CampusID, c.SelfSupport, c.ActivityID)
+	}
+	ok := sameSigned(lg.After.Signed, want) && sameSigned(tg.After.Signed, want)
+	name := "活动失效范围:报名状态必须与 dump 判据逐一相符(自营含 campus_id=0 / 加盟不含)"
+	if c.Op == OpCancelRechargeActivity {
+		name = "取消活动:只有本活动的报名状态变,其他活动一条都不许动"
+	}
+	detail := fmt.Sprintf("期望 %s\n       legacy %s(本次变化:%s)| target %s(本次变化:%s)",
+		fmtSigned(want),
+		fmtSigned(lg.After.Signed), fmtChanges(lg.Before.Signed, lg.After.Signed),
+		fmtSigned(tg.After.Signed), fmtChanges(tg.Before.Signed, tg.After.Signed))
+	return Assertion{Name: name, Pass: ok, Detail: detail}
+}
+
+// spReturnAssertion 断言 SP 自己 SELECT 出来的结果集(这是 SP 契约的一部分)。
+func spReturnAssertion(c Case, lg *SideResult) Assertion {
+	if len(lg.Calls) == 0 || lg.Calls[0].Returned == nil {
+		return Assertion{
+			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{
+		Name: "SP8 返回值:success=1 / msg=ok / schoolid 由校区反查得到",
+		Pass: ok,
+		Detail: fmt.Sprintf("实测 schoolid=%q success=%q msg=%q(期望 %q / \"1\" / \"ok\")",
+			r["schoolid"], r["success"], r["msg"], fmt.Sprint(c.InstitutionID)),
+	}
+}
+
+// ---------- 小工具 ----------
+
+func errSuffix(e string) string {
+	if e == "" {
+		return ""
+	}
+	return "(读失败:" + e + ")"
+}
+
+// allLogsWithoutID 判断一批流水的 log_id 与 request_id 是否都为空。
+func allLogsWithoutID(logs []LogRow) bool {
+	for _, l := range logs {
+		if l.LogID != "" || l.RequestID != "" {
+			return false
+		}
+	}
+	return true
+}
+
+func fmtLogIDs(logs []LogRow) string {
+	parts := make([]string, len(logs))
+	for i, l := range logs {
+		parts[i] = fmt.Sprintf("log_id=%q request_id=%q", l.LogID, l.RequestID)
+	}
+	return "[" + strings.Join(parts, "; ") + "]"
+}
+
+func logsAllType(logs []LogRow, typ int32) bool {
+	for _, l := range logs {
+		if l.Type != typ {
+			return false
+		}
+	}
+	return true
+}
+
+func logsAllMoney(logs []LogRow, money int64) bool {
+	for _, l := range logs {
+		if l.Money != money {
+			return false
+		}
+	}
+	return true
+}
+
+func fmtLogs(logs []LogRow) string {
+	parts := make([]string, len(logs))
+	for i, l := range logs {
+		parts[i] = fmt.Sprintf("type=%d money=%s", l.Type, yuan(l.Money))
+	}
+	return "[" + strings.Join(parts, "; ") + "]"
+}
+
+// signedAll 判断"该 case 指定活动的报名状态是否全为 st",且该活动确实有报名行。
+func signedAll(c Case, s *SideResult, st int32) bool {
+	v, ok := s.After.Signed[c.ActivityID]
+	return ok && v == st
+}

+ 110 - 0
internal/academic/dualrun/stage2/case.go

@@ -40,6 +40,15 @@ const (
 	OpSpend
 	// OpTransfer 课费转结(SP6):**不动 balance**,双流水 106(+m)/206(−m),两个校区 total_* 各自反向变化。
 	OpTransfer
+	// OpClearBalance 余额清零(SP10):**balance−campusBalance、special_expenses+campusBalance、
+	// spend+campusBalance**(双表同额),1 条流水 type=208(无 logid),并把"充值赠送"类报名置 4。
+	//
+	// ⚠ 名字骗人:"清零"清的是**校区**余额,全局只减去同一个 campusBalance ——
+	//   所以清零后全局余额一般**不是 0**(它等于其他校区余额之和)。
+	OpClearBalance
+	// OpCancelRechargeActivity 取消充值活动(SP8):balance−、total_income−(双表),
+	// 报名状态置 **0**(不是 4),1 条流水 type=209;机构 id 由**校区**反查(SP 无 schoolid 入参)。
+	OpCancelRechargeActivity
 )
 
 // String 返回可读名字(报告用)。
@@ -57,6 +66,10 @@ func (o Op) String() string {
 		return "sp_user_expense_school_spend"
 	case OpTransfer:
 		return "sp_user_expense_school_transfer_expenditure"
+	case OpClearBalance:
+		return "sp_user_expense_school_cleared_balance"
+	case OpCancelRechargeActivity:
+		return "sp_user_expense_cancel_recharge_activity"
 	default:
 		return "sp_user_expense_school_expenditure"
 	}
@@ -82,6 +95,30 @@ type Case struct {
 	ToInstitutionID int64
 	ToCampusID      int64
 
+	// ActivityID 仅 OpCancelRechargeActivity 使用:被取消的活动 id(SP8 的 inputActivityid)。
+	ActivityID int64
+
+	// SelfSupport 仅 OpClearBalance 使用:校区是否自营。
+	// 它是 SP 里 `IF(campusSelfSupport)` 的唯一判据,决定活动失效范围:
+	//   self_support=1(自营)→ `campus_id IN (campusID, 0)`(含"全机构池"活动)
+	//   self_support=0(加盟)→ `campus_id = campusID`
+	// 由 case **显式声明**,再分别断言两侧看到的校区元数据与它一致 —— 而不是从哪一侧读出来直接喂给另一侧。
+	SelfSupport bool
+
+	// CampusMissing true = 本 case 故意用一个**不存在的校区**(如 999999)。
+	//
+	// 为什么必须显式声明:目标侧需要在事务内 seed `t_campus` 才能让 Go 找到校区;
+	// 如果"顺手给不存在的校区也 seed 一行",那这条 case 就永远测不到 3104 ——
+	// 它会变成一个"看起来在测校区缺失、实际校区存在"的空测试。
+	CampusMissing bool
+
+	// Activity 非 nil 时,两侧各自在事务内建"标准活动矩阵"(`C1ActivityPlan`)+ 该用户的报名行。
+	// 只有"会动活动状态"的 case(OpClearBalance / OpCancelRechargeActivity)需要它。
+	Activity *ActivitySeed
+	// ActivityPlan 是运行期按 legacy 水位解析出来的实际计划(id 已确定)。
+	// **不要在外面设它**:由 RunOne 填,避免两边用不同 id。
+	ActivityPlan *ActivityPlan
+
 	// IntentionalStrict true = 这个 case **两侧不应一致**,而是"legacy 静默 / Go 显式拒绝"。
 	// 用于 R19(campusid<1 → SP LEAVE 静默 no-op,Go 报 3140)。
 	// 这类 case 不参与"两侧相等"判定,只断言双方各自的既定行为都发生。
@@ -89,8 +126,18 @@ type Case struct {
 
 	// Baseline 非 nil 时,两侧在调用前**各自 UPDATE 成这份基线**(R15 用)。
 	// 语义:这是同一个基线,不是"各自造一个" —— 设置后仍会断言两侧逐分相等。
+	//
+	// ⚠ 它有两种用途,别混:
+	//   - 「把余额压到不够扣」验证 R15(配 ExpectNegativeBalance)
+	//   - 「把全局余额造得比校区余额大」验证清零只减 campusBalance(C1-1/C1-2)
 	Baseline *Baseline
 
+	// ExpectNegativeBalance 期望"调用后两侧余额都是负数"(R15:SP 不校验余额)。
+	//
+	// 不能写成 `Baseline != nil` 就断言——批 C1 也用 Baseline 造基线(为了区分
+	// "减 campusBalance" 和 "归 0"),那样会平白多出一条 R15 断言并假红。
+	ExpectNegativeBalance bool
+
 	// RunTimes 调用次数(2 = 幂等用例:同一 requestId 连跑两次)。
 	RunTimes int
 
@@ -98,6 +145,8 @@ type Case struct {
 	ExpectTargetSuccess bool
 	// ExpectLegacySuccess 期望 legacy 成功(SP 无余额校验 → 恒 true)。
 	ExpectLegacySuccess bool
+	// ExpectTargetCode 期望 target 的业务错误码(0 = 不检查)。仅在被拒绝的 case 上有意义。
+	ExpectTargetCode int
 
 	// Note 报告里的说明。
 	Note string
@@ -147,6 +196,17 @@ type SideSnapshot struct {
 	Campus  Account // 转出校区(非转结场景 = 唯一那个校区)
 	Campus2 Account // 转入校区(只有 transfer 会用到;其余场景为零值)
 	Logs    []LogRow
+	// Signed 是该用户**在本次 case 涉及的活动上**的报名状态(activityID → status)。
+	//
+	// 只收 case 涉及的活动,但**断言的是整张表**:期望值由 case 声明 + dump 判据算出,
+	// 再与实测 map 全等比较 —— 多改一条或少改一条都会红。
+	Signed map[int64]int32
+	// HasCampusAccount 是"该校区账户行是否存在"。
+	//
+	// 为什么不直接把"读不到"当成 0:校区 id 打错时两侧都会读成 0,
+	// 基线相等断言会**空过**,整个 case 看起来在跑其实什么都没测。
+	// 显式记下来,再断言"该存在的存在 / 该缺失的缺失"。
+	HasCampusAccount bool
 }
 
 // SideResult 是一侧的运行结果。
@@ -157,6 +217,20 @@ type SideResult struct {
 	Calls         []CallOutcome
 	Err           error
 	BaselineEqual bool
+	// CampusMeta 是**该侧实际看到的**校区元数据(机构 + 是否自营)。
+	//
+	// 为什么必须有:批 C1 的两支 SP 都会跨域读校区 ——
+	//   legacy 读 `yuansiwei.t_school_campus`(旧库视图),target 读 `yuansiwei_2026.t_campus`(迁移后表)。
+	// 两个来源若在某校区上不一致,两侧会**走不同分支**(自营 vs 加盟),
+	// 而那种红看起来像"SP 行为不对",实际是"校区元数据没对齐"。把它单独断言出来,红的时候能立刻定位。
+	CampusMeta *CampusMeta
+}
+
+// CampusMeta 是"某一侧看到的校区元数据"。
+type CampusMeta struct {
+	InstitutionID int64
+	SelfSupport   bool
+	Err           string
 }
 
 // CallOutcome 是一次调用的返回。
@@ -166,6 +240,17 @@ type CallOutcome struct {
 	Balance  int64 // 响应里的余额(分)
 	LogID    int64
 	ErrMsg   string
+	// Returned 是"SP 自己 SELECT 出来的结果集"(按列名)。
+	//
+	// 为什么需要它:`sp_user_expense_cancel_recharge_activity` 的每条路径都以
+	// `SELECT selectSchoolid AS schoolid, success, msg;` 结尾(找不到校区时是 `SELECT success,msg;`)——
+	// **返回值本身就是契约的一部分**,只看"有没有报错"会漏掉 `success=0` 这种"成功返回但业务失败"。
+	Returned map[string]string
+	// Code 是 target 侧业务错误码(*infraerrors.AppError.Code;非 AppError 时为 0)。
+	//
+	// 断言"报了 3120"比断言"报错了"强一档:同一个错误文案可能被别处复用,
+	// 而码是设计文档里定死的契约(1004/2002/3102/3104/3120/3140…)。
+	Code int
 }
 
 // Diff 是一条比对结论。
@@ -241,6 +326,7 @@ var (
 //	充值(SP1/SP2):balance + , total_income +
 //	退费(SP4)    :balance + , total_expenditure −      ← 与消费方向相反
 //	消费(SP3)    :balance − , total_expenditure +(type=200 另加 spend +)
+//
 // expectedAccounts 返回一个 case 的**三份**期望增量:全局 / 转出校区 / 转入校区。
 //
 // 转结(OpTransfer)是唯一"三份不相同"的场景:全局不动,转出与转入各自反向。
@@ -260,11 +346,35 @@ func expectedAccounts(op Op, typ int32, amount int64, runs int) (global, from, t
 		// dump:**只动 spend**,不动 balance,且不写流水
 		g := Account{Spend: amount * n}
 		return g, g, Account{}
+	case OpCancelRechargeActivity:
+		// dump:balance−inputMoney、total_income−inputMoney(双表)—— 充值的反向
+		g := Account{Balance: -amount * n, Income: -amount * n}
+		return g, g, Account{}
 	}
 	g := expectedDeltaFor(op, typ, amount, runs)
 	return g, g, Account{}
 }
 
+// clearedBalanceExpected 返回"余额清零"的期望增量。
+//
+// ⚠ 清零的金额**不是入参**:SP 里 `campusBalance` 是从校区账户读出来的,
+// 所以这条期望必须由**调用前校区余额**算出来,而不是写死一个数。
+// 两侧各按自己的 before 算(基线相等由前面的断言保证),因此这也不是"拿两侧互相比"。
+//
+// dump:
+//
+//	UPDATE ... SET balance=balance−campusBalance,
+//	               special_expenses=special_expenses+campusBalance,
+//	               spend=spend+campusBalance            -- 双表同额
+func clearedBalanceExpected(campusBalance int64) (global, from, to Account) {
+	exp := Account{
+		Balance: -campusBalance,
+		Special: campusBalance,
+		Spend:   campusBalance,
+	}
+	return exp, exp, Account{}
+}
+
 func expectedDeltaFor(op Op, typ int32, amount int64, runs int) Account {
 	var exp Account
 	n := int64(runs)

+ 133 - 7
internal/academic/dualrun/stage2/legacy.go

@@ -2,7 +2,10 @@ package stage2
 
 import (
 	"context"
+	"database/sql"
+	"errors"
 	"fmt"
+	"strconv"
 	"time"
 
 	"gorm.io/gorm"
@@ -42,8 +45,20 @@ const (
 	callBadDebt        = "CALL sp_user_expense_baddebt(?, ?, ?, ?, ?, ?, ?)"
 	callSpend          = "CALL sp_user_expense_school_spend(?, ?, ?, ?)"
 	callTransfer       = "CALL sp_user_expense_school_transfer_expenditure(?, ?, ?, ?, ?, ?, ?, ?)"
+	// SP10 余额清零:**没有金额入参**(金额由 SP 自己从校区账户读 campusBalance)。
+	callClearBalance = "CALL sp_user_expense_school_cleared_balance(?, ?, ?, ?, ?)"
+	// SP8 取消充值活动:**没有 schoolid**(由 campusid 反查机构),有 activityid。
+	callCancelRecharge = "CALL sp_user_expense_cancel_recharge_activity(?, ?, ?, ?, ?, ?, ?)"
 )
 
+// returnsResultSet 标记"该 SP 会 SELECT 出结果集"。
+//
+// `sp_user_expense_cancel_recharge_activity` 的每条路径都以 SELECT 结尾
+// (成功:`SELECT schoolid, success, msg`;找不到校区:`SELECT success, msg`)。
+// 这类 SP 不能用 `Exec` 调 —— 必须把结果集读干净,否则连接上会留下未消费的结果集。
+// 而且**结果集本身是契约**:`success=0` 是"成功返回但业务失败",只看 error 会漏掉。
+func returnsResultSet(op Op) bool { return op == OpCancelRechargeActivity }
+
 // callArgs 按 Op 组出 CALL 的实参(顺序与 dump 一致)。
 func callArgs(c Case) []any {
 	moneyYuan := float64(c.AmountCents) / 100.0 // 所有 SP 的金额入参都是 FLOAT(元)
@@ -67,6 +82,13 @@ func callArgs(c Case) []any {
 		// SP6:(userid, campusid1, schoolid2, campusid2, money, createUserid, requestid, remark)
 		return []any{c.LegacyUserID, c.CampusID, c.ToInstitutionID, c.ToCampusID,
 			moneyYuan, c.LegacyUserID, c.RequestID, c.Remark}
+	case OpClearBalance:
+		// SP10:(userid, schoolid, campusid, remark, createUserid) —— **无金额、无 requestid**
+		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, c.Remark, c.LegacyUserID}
+	case OpCancelRechargeActivity:
+		// SP8:(userid, activityid, campusid, money, requestid, remark, createUserid) —— **无 schoolid**
+		return []any{c.LegacyUserID, c.ActivityID, c.CampusID, moneyYuan,
+			c.RequestID, c.Remark, c.LegacyUserID}
 	default:
 		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan,
 			c.Type, c.RequestID, c.Remark, c.LegacyUserID}
@@ -88,6 +110,10 @@ func callStmt(op Op) string {
 		return callSpend
 	case OpTransfer:
 		return callTransfer
+	case OpClearBalance:
+		return callClearBalance
+	case OpCancelRechargeActivity:
+		return callCancelRecharge
 	default:
 		return callExpenditure
 	}
@@ -107,23 +133,32 @@ func (s *legacySide) Run(ctx context.Context, c Case) (*SideResult, error) {
 			return nil, err
 		}
 	}
+	// 活动侧种子(只有会动活动状态的 case 需要)。必须在 before 快照之前,
+	// 否则 before 里读不到刚种下的报名行,"变化量"会算错。
+	if err := seedLegacyActivities(ctx, tx, c, c.LegacyUserID); err != nil {
+		return nil, err
+	}
 
 	before, maxLogID, err := snapshotLegacy(ctx, tx, c)
 	if err != nil {
 		return nil, err
 	}
 	res.Before = before
+	// SP 会跨库读 `yuansiwei.t_school_campus`(清零判自营、取消活动反查机构)。
+	// 把它读到的值与 case 声明对齐 —— 两侧若看到不同校区元数据,就会走不同分支。
+	if c.CampusID > 0 && needsCampusMeta(c.Op) {
+		res.CampusMeta = readLegacyCampusMeta(ctx, tx, c.CampusID)
+	}
 
 	// 按 dump 口径把入参规整成 SP 会收到的值(type<200 / >299 → 200 是 SP 内部行为,这里不改,
 	// 让 SP 自己做,才能验证"SP 会静默改写 type"这件事)。
 	for i := 0; i < maxInt(1, c.RunTimes); i++ {
-		err := tx.Exec(callStmt(c.Op), callArgs(c)...).Error
-		out := CallOutcome{Success: err == nil}
+		out, err := callLegacySP(ctx, tx, c)
 		if err != nil {
-			out.ErrMsg = err.Error()
+			return nil, err
 		}
 		res.Calls = append(res.Calls, out)
-		if err != nil {
+		if !out.Success {
 			// SP 抛错后事务可能已不可用,直接读回会失败;保留错误并继续读尽力而为
 			break
 		}
@@ -163,6 +198,81 @@ UPDATE t_user_expense_school SET balance=?, spend=?, total_income=?, total_expen
 	return nil
 }
 
+// callLegacySP 调一次 SP,并按"是否返回结果集"分两条路。
+//
+// 为什么不能一律 `Exec`:MySQL 的 CALL 若产生结果集,客户端必须把它读干净;
+// 用 Exec 调 `sp_user_expense_cancel_recharge_activity` 会留下未消费的结果集,
+// 后续读回的快照查询会失败(甚至报 commands out of sync)。
+//
+// 出参:CallOutcome(Success=没抛错;Returned=SP 自己 SELECT 出来的那一行,按列名)。
+func callLegacySP(ctx context.Context, tx *gorm.DB, c Case) (CallOutcome, error) {
+	stmt, args := callStmt(c.Op), callArgs(c)
+	if !returnsResultSet(c.Op) {
+		err := tx.WithContext(ctx).Exec(stmt, args...).Error
+		out := CallOutcome{Success: err == nil}
+		if err != nil {
+			out.ErrMsg = err.Error()
+		}
+		return out, nil
+	}
+
+	rows, err := tx.WithContext(ctx).Raw(stmt, args...).Rows()
+	if err != nil {
+		return CallOutcome{Success: false, ErrMsg: err.Error()}, nil
+	}
+	defer func() { _ = rows.Close() }()
+	out := CallOutcome{Success: true}
+	cols, err := rows.Columns()
+	if err != nil {
+		return CallOutcome{}, fmt.Errorf("读 SP 结果集列名失败:%w", err)
+	}
+	if rows.Next() {
+		vals := make([]any, len(cols))
+		ptrs := make([]any, len(cols))
+		for i := range vals {
+			ptrs[i] = &vals[i]
+		}
+		if err := rows.Scan(ptrs...); err != nil {
+			return CallOutcome{}, fmt.Errorf("读 SP 结果集失败:%w", err)
+		}
+		out.Returned = make(map[string]string, len(cols))
+		for i, name := range cols {
+			out.Returned[name] = sqlCell(vals[i])
+		}
+	}
+	// 把剩余结果集读干净(SP 可能返回多段),否则连接上会残留。
+	for rows.NextResultSet() {
+		for rows.Next() {
+		}
+	}
+	return out, rows.Err()
+}
+
+// sqlCell 把驱动返回的任意值转成字符串(NULL → "",避免与 0 混淆时另用 hasKey 判断)。
+func sqlCell(v any) string {
+	switch t := v.(type) {
+	case nil:
+		return ""
+	case []byte:
+		return string(t)
+	case string:
+		return t
+	case int64:
+		return strconv.FormatInt(t, 10)
+	case uint64:
+		return strconv.FormatUint(t, 10)
+	case float64:
+		return strconv.FormatFloat(t, 'f', -1, 64)
+	case bool:
+		if t {
+			return "1"
+		}
+		return "0"
+	default:
+		return fmt.Sprintf("%v", t)
+	}
+}
+
 // snapshotLegacy 读 legacy 侧当前状态(全局 + 校区 + 该用户的全部流水)。
 func snapshotLegacy(ctx context.Context, tx *gorm.DB, c Case) (SideSnapshot, int64, error) {
 	var out SideSnapshot
@@ -203,8 +313,15 @@ SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2))
   FROM t_user_expense_school WHERE userid = ? AND schoolid = ? AND campusid = ?`,
 		c.LegacyUserID, c.InstitutionID, c.CampusID).
 		Row().Scan(&sc.Balance, &sc.Spend, &sc.Income, &sc.Expenditure, &sc.Special)
-	if err != nil && c.CampusID > 0 {
-		return out, 0, fmt.Errorf("读 legacy 校区账户失败:%w", err)
+	if err != nil {
+		// 校区账户不存在是**合法状态**(CampusMissing 用例就是要它不存在),记成零值 + 标记;
+		// 其它错误照旧上抛,别把"数据库出问题"伪装成"账户不存在"。
+		if !errors.Is(err, sql.ErrNoRows) {
+			return out, 0, fmt.Errorf("读 legacy 校区账户失败:%w", err)
+		}
+		err = nil
+	} else {
+		out.HasCampusAccount = true
 	}
 	cs, _ := centsOf(sc.Balance)
 	csp, _ := centsOf(sc.Spend)
@@ -260,7 +377,16 @@ SELECT id, IFNULL(type,0), CAST(ROUND(CAST(IFNULL(money,0) AS DECIMAL(20,3)),2)
 		}
 		out.Logs = append(out.Logs, l)
 	}
-	return out, maxID, rows.Err()
+	if err := rows.Err(); err != nil {
+		return out, 0, err
+	}
+	// 活动报名状态(只有带活动种子的 case 才读)
+	signed, err := readLegacySigned(ctx, tx, c.ActivityPlan, c.LegacyUserID)
+	if err != nil {
+		return out, 0, err
+	}
+	out.Signed = signed
+	return out, maxID, nil
 }
 
 // filterLogs 只保留 id > 阈值的流水(= 本 case 新产生的)。

+ 264 - 34
internal/academic/dualrun/stage2/run.go

@@ -88,7 +88,7 @@ func DefaultCases(_ string) []Case {
 			Op:   OpTransfer, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 5,
 			ToInstitutionID: 63317, ToCampusID: 1,
 			AmountCents: 5000, RequestID: "ac15-b3", RunTimes: 1,
-			Remark: "ac15 课费转结",
+			Remark:              "ac15 课费转结",
 			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
 			Note: "**不动 balance**;双流水 106(+50)/206(−50) Σ=0;两校区 total_expenditure/total_income 各自反向。" +
 				"SP6 **没有默认备注**(inputRemark 直接用),所以这里显式传一条,顺带验证备注透传",
@@ -98,7 +98,7 @@ func DefaultCases(_ string) []Case {
 			Op:   OpTransfer, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
 			ToInstitutionID: 63317, ToCampusID: 1,
 			AmountCents: 5000, RequestID: "ac15-b4", RunTimes: 2,
-			Remark: "ac15 课费转结·幂等",
+			Remark:              "ac15 课费转结·幂等",
 			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
 			Note: "106 是主流水,幂等按 (request_id, type=106) 判定",
 		},
@@ -110,11 +110,112 @@ func DefaultCases(_ string) []Case {
 		Op:   OpRecharge, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 0,
 		AmountCents: 10000, Type: 100, RequestID: "ac15-a7", RunTimes: 1,
 		ExpectTargetSuccess: false, ExpectLegacySuccess: true,
+		ExpectTargetCode:  3140,
 		IntentionalStrict: true,
 		Note: "R19:SP1 有 `schoolid<1||campusid<1 → LEAVE` 守卫,**静默不写、返回成功**;" +
 			"Go 走 validateUserScope 报 3140。两侧**本来就该不同**,本 case 只把差异钉死",
 	})
 
+	// ---- 批 C1:余额清零 SP10(5 参,**无金额、无请求号**)----
+	//
+	// 选人依据(与批 A 同一条纪律:基线必须是 2 位小数,否则 float 收敛会让 Δ 天然不等):
+	//	68542 → 63317 / campus 5(**自营 self_support=1**),校区与全局余额都是 9740.00
+	//	40583 → 63317 / campus 4(**加盟 self_support=0**),校区与全局余额都是 5400.00
+	// 两人在旧库都**没有**别的可失效报名(preflight 会显式断言这一点)。
+	out = append(out,
+		Case{
+			Name: "C1-1 余额清零·自营校区(失效范围含 campus_id=0)",
+			Op:   OpClearBalance, LegacyUserID: 68542, InstitutionID: 63317, CampusID: 5,
+			RequestID: "ac15-c1-1", RunTimes: 1, SelfSupport: true,
+			// 关键:**故意让全局余额 > 校区余额**(5000 vs 1200)。
+			// 如果两者相等(比如这个用户天然就是 9740/9740),那么"减 campusBalance"与
+			// "全局归 0"会得到同一个结果,断言就分不出对错 —— 变异 M1 实测过这一条。
+			Baseline:            &Baseline{GlobalBalance: 500000, CampusBalance: 120000},
+			Activity:            &ActivitySeed{OtherUsers: map[int64]int32{65055: 2}},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "**校区**余额清零(不是全局);special_expenses 与 spend 同额 +;流水 type=208 money=−campusBalance 且无 logid;" +
+				"自营 → 本校区 + 全机构池(campus_id=0) 的报名都置 4,其他校区/其他机构/代金券/已过期一条都不许动",
+		},
+		Case{
+			Name: "C1-2 余额清零·加盟校区(失效范围只含本校区)",
+			Op:   OpClearBalance, LegacyUserID: 40583, InstitutionID: 63317, CampusID: 4,
+			RequestID: "ac15-c1-2", RunTimes: 1, SelfSupport: false,
+			Baseline:            &Baseline{GlobalBalance: 300000, CampusBalance: 90000},
+			Activity:            &ActivitySeed{},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "与 C1-1 唯一的差别是 self_support=0:**campus_id=0 的全机构池活动不能被失效**(SP 的 ELSE 分支)",
+		},
+	)
+
+	// 三条前置校验:legacy **静默 LEAVE**(不报错、不写库),Go **3120 拒绝** —— 又一组 intentional-strict。
+	//
+	// 派单里写的是"余额已 0 时再清零 → 3120",那是 **Go 侧**的行为;
+	// SP 的写法是三条 `IF … THEN LEAVE leaveProcedure`,**静默 no-op**,调用方看不出失败。
+	out = append(out,
+		Case{
+			Name: "C1-3 清零前置校验·校区余额 ≤ 0(legacy 静默 vs Go 3120)",
+			Op:   OpClearBalance, LegacyUserID: 40575, InstitutionID: 63317, CampusID: 4,
+			RequestID: "ac15-c1-3", RunTimes: 1, SelfSupport: false,
+			Baseline:            &Baseline{GlobalBalance: 100000, CampusBalance: 0},
+			ExpectTargetSuccess: false, ExpectLegacySuccess: true,
+			ExpectTargetCode: 3120, IntentionalStrict: true,
+			Note: "SP10 第一条守卫 `ISNULL(campusBalance)||campusBalance<=0 → LEAVE`;" +
+				"「清零后再清零」踩的就是这条(清完校区余额正好是 0)",
+		},
+		Case{
+			Name: "C1-4 清零前置校验·全局余额 ≤ 0(legacy 静默 vs Go 3120)",
+			Op:   OpClearBalance, LegacyUserID: 40575, InstitutionID: 63317, CampusID: 4,
+			RequestID: "ac15-c1-4", RunTimes: 1, SelfSupport: false,
+			Baseline:            &Baseline{GlobalBalance: 0, CampusBalance: 10000},
+			ExpectTargetSuccess: false, ExpectLegacySuccess: true,
+			ExpectTargetCode: 3120, IntentionalStrict: true,
+			Note: "SP10 第二条守卫 `ISNULL(totalBalance)||totalBalance<=0 → LEAVE`",
+		},
+		Case{
+			Name: "C1-5 清零前置校验·校区余额 > 全局余额(legacy 静默 vs Go 3120)",
+			Op:   OpClearBalance, LegacyUserID: 40575, InstitutionID: 63317, CampusID: 4,
+			RequestID: "ac15-c1-5", RunTimes: 1, SelfSupport: false,
+			Baseline:            &Baseline{GlobalBalance: 50000, CampusBalance: 80000},
+			ExpectTargetSuccess: false, ExpectLegacySuccess: true,
+			ExpectTargetCode: 3120, IntentionalStrict: true,
+			Note: "SP10 第三条守卫 `campusBalance>totalBalance → LEAVE`(数据异常,拒绝对负余额清零)",
+		},
+	)
+
+	// ---- 批 C1:取消充值活动 SP8(7 参,**无 schoolid**,自带结果集)----
+	out = append(out,
+		Case{
+			Name: "C1-6 取消充值活动(30.00,报名状态 2→0)",
+			Op:   OpCancelRechargeActivity, LegacyUserID: 68542, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 3000, RequestID: "ac15-c1-6", RunTimes: 1, SelfSupport: true,
+			Activity:            &ActivitySeed{},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "充值的**反向**:balance− / total_income−(双表);流水 type=209 money=−30;" +
+				"报名状态归 **0(取消报名)**,不是 4(过期);机构 id 由 campusid 反查,SP 无 schoolid 入参",
+		},
+		Case{
+			Name: "C1-7 取消充值活动·幂等(同一 requestId 跑 2 次)",
+			Op:   OpCancelRechargeActivity, LegacyUserID: 65055, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 2000, RequestID: "ac15-c1-7", RunTimes: 2, SelfSupport: true,
+			Activity:            &ActivitySeed{},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "target 按 (request_id, 209) 幂等;legacy SP 无幂等键 → 重复扣两次(已知且有意)",
+		},
+		Case{
+			Name: "C1-8 取消充值活动·校区不存在(SP 返回 success=0 vs Go 3104)",
+			Op:   OpCancelRechargeActivity, LegacyUserID: 68542, InstitutionID: 63317, CampusID: 999999,
+			// 必须显式声明"这个校区不存在":否则 seedTarget 会顺手给它建一行 t_campus,
+			// Go 就"找到了校区"并成功 —— 一条看起来在测缺失、其实校区存在的空测试。
+			CampusMissing: true,
+			AmountCents:   3000, RequestID: "ac15-c1-8", RunTimes: 1, SelfSupport: true,
+			Activity:            &ActivitySeed{},
+			ExpectTargetSuccess: false, ExpectLegacySuccess: true,
+			ExpectTargetCode: 3104, IntentionalStrict: true,
+			Note: "SP8 先按 campusid 反查 `yuansiwei.t_school_campus`:查不到 → `SELECT success,msg`(msg='找不到学校信息')+ LEAVE," +
+				"**不抛错**;Go 报 3104。这条同时验证「SP 的返回值本身是契约」",
+		},
+	)
+
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
@@ -132,6 +233,7 @@ func DefaultCases(_ string) []Case {
 	c2.Baseline = &Baseline{
 		GlobalBalance: 1000, CampusBalance: 1000,
 	}
+	c2.ExpectNegativeBalance = true
 	out = append(out, c1, c2)
 	return out
 }
@@ -150,9 +252,25 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 	uid := CanonicalUIDFor(c.LegacyUserID)
 	res := &CaseResult{Case: c}
 
+	// 活动侧种子需要"两侧同 id 的起点":取 legacy 现有 max(id)+1。
+	// 必须在事务**之外**读一次(自增计数器不回滚,但行会回滚,所以水位稳定)。
+	if c.Activity != nil {
+		base, err := baseActivityID(ctx, legacyDB)
+		if err != nil {
+			return nil, err
+		}
+		c.ActivityPlan = C1ActivityPlan(base, c.Activity.OtherUsers)
+		// 取消充值的对象是矩阵里的**第一条**(本级校区、type=2、status=2)——
+		// 用 plan 里的 id 而不是写死,避免"活动 id 换了但 case 没跟上"。
+		if c.Op == OpCancelRechargeActivity {
+			c.ActivityID = c.ActivityPlan.Items[0].ID
+		}
+		res.Case = c // 报告里也要能看到实际用的活动 id
+	}
+
 	// 事务**之前**先读一次 legacy 的天然状态:回滚之后要拿它做对照。
 	// (不能用 lg.Before —— R15 用例会在事务内把基线覆盖成 10.00,那不是"调用前"的真实状态。)
-	natural, err := readLegacyNatural(ctx, cfg, c.LegacyUserID)
+	natural, naturalLogs, err := readLegacyNatural(ctx, cfg, c.LegacyUserID)
 	if err != nil {
 		return nil, err
 	}
@@ -174,8 +292,28 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 	// ---- 比对 ----
 	res.Diffs, res.Assertions = compare(c, lg, tg)
 
+	// ---- case 有效性前提(活动域)----
+	//
+	// 旧库里如果还有"本 case 之外、同样可被失效"的报名行,legacy 会多失效几条,
+	// 而 target 侧没有那些活动(活动域还没迁移)—— 两侧失效集合天然不同。
+	// 那种差异是"数据没搬全",不是"SP 行为不对"。与其让它以一个看不懂的差异出现,
+	// 不如在 case 开始就断言它是 0(和"两侧基线逐分相等"同一性质的前置断言)。
+	if c.ActivityPlan != nil && c.Op == OpClearBalance {
+		n, err := countOtherExpirableLegacy(ctx, legacyDB, c.LegacyUserID,
+			c.InstitutionID, c.CampusID, c.SelfSupport, c.ActivityPlan.ActivityIDs())
+		if err != nil {
+			return nil, err
+		}
+		res.Assertions = append(res.Assertions, Assertion{
+			Name: "case 有效性前提:旧库里该用户**没有别的**可失效报名(否则两侧失效范围天然不同)",
+			Pass: n == 0,
+			Detail: fmt.Sprintf("本 case 之外可失效报名行=%d(应为 0;机构=%d 校区=%d 自营=%v)",
+				n, c.InstitutionID, c.CampusID, c.SelfSupport),
+		})
+	}
+
 	// ---- 残留检查(两边都回滚之后)----
-	res.Residue = checkResidue(ctx, cfg, c, uid, natural)
+	res.Residue = checkResidue(ctx, cfg, c, uid, natural, naturalLogs)
 	if !res.Residue.Pass {
 		res.Assertions = append(res.Assertions, Assertion{
 			Name: "回滚后零残留(两侧都回到调用前状态)", Pass: false, Detail: res.Residue.Detail,
@@ -188,6 +326,23 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 	return res, nil
 }
 
+// needsCampusMeta 标记"该 Op 会跨域读校区元数据"(清零判自营 / 取消活动反查机构)。
+func needsCampusMeta(op Op) bool { return op == OpClearBalance || op == OpCancelRechargeActivity }
+
+// opUsesRequestID 标记"该 Op 对应的 SP 有请求号入参"。
+//
+// SP10(清零)的签名是 (userid, schoolid, campusid, remark, createUserid) —— **没有请求号**,
+// 所以它天然不幂等。断言"target 的 request_id 为空"才是对的;断言它等于某个入参反而会遮蔽错误。
+func opUsesRequestID(op Op) bool { return op != OpClearBalance }
+
+// reqNote 说明该 Op 下 request_id 的期望来源。
+func reqNote(op Op) string {
+	if !opUsesRequestID(op) {
+		return "SP10 无请求号入参 → target request_id 必须为空(不幂等是 SP 签名决定的)"
+	}
+	return "Go 增强:幂等键落库"
+}
+
 // expectedDelta 返回一侧在某 case 下的**期望增量**。
 //
 // 为什么要有"期望"而不只是"两侧相等":
@@ -215,34 +370,50 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
 	}
 
-	// ⓪ intentional-strict(R19):两侧**本来就该不同**,不参与"两侧相等"判定
+	// ⓪ intentional-strict(R19 / 3120 / 3104):两侧**本来就该不同**,不参与"两侧相等"判定
 	if c.IntentionalStrict {
 		lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
 		tgErr := errOf(tg.Calls)
+		tgCode := codeOf(tg.Calls)
 		tgRejected := len(tg.Calls) > 0 && !tg.Calls[0].Success
+		// 码比文案强:同一个业务码可能在多处被复用文案,码是设计文档定死的契约。
+		codeOK := c.ExpectTargetCode == 0 || tgCode == c.ExpectTargetCode
 		// 「没写库」必须两侧都比**全局+校区**两块:campusid=0 的用例里
 		// 如果只比全局,误写成"只写校区表"的实现会静默漏过去。
 		lgWrote := lg.Before.Global != lg.After.Global || lg.Before.Campus != lg.After.Campus
 		tgWrote := tg.Before.Global != tg.After.Global || tg.Before.Campus != tg.After.Campus
 		noWrite := !lgWrote && !tgWrote && len(lg.After.Logs) == 0 && len(tg.After.Logs) == 0
+		// 活动状态也不能动(清零的守卫分支在动活动之前就 LEAVE 了)
+		signedSame := sameSigned(lg.Before.Signed, lg.After.Signed) &&
+			sameSigned(tg.Before.Signed, tg.After.Signed)
 		add("legacy 是否「成功返回」", fmt.Sprint(lgOK), "N/A", lgOK,
 			"R19:SP 静默 LEAVE,调用方看到的是成功")
 		add("target 是否显式拒绝", "N/A", fmt.Sprint(tgRejected), tgRejected,
-			"Go 应报 3140(unscoped),错误文本:"+tgErr)
+			"Go 应显式报错(业务码 "+fmt.Sprint(c.ExpectTargetCode)+"),错误文本:"+tgErr)
 		asserts = append(asserts,
 			Assertion{
-				Name: "intentional-strict:legacy 静默成功 + target 显式拒绝(R19)",
-				Pass: lgOK && tgRejected,
-				Detail: fmt.Sprintf("legacy success=%v(期望 true,SP 静默 no-op);target 被拒=%v err=%q(期望 被拒=true + 3140)",
-					lgOK, tgRejected, tgErr),
+				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{
-				Name: "intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条)",
-				Pass: noWrite,
-				Detail: fmt.Sprintf("legacy 写库=%v(全局/校区)流水=%d | target 写库=%v(全局/校区)流水=%d —— 都应为 未写库/0 条",
-					lgWrote, len(lg.After.Logs), tgWrote, len(tg.After.Logs)),
+				Name: "intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条、活动状态不变)",
+				Pass: noWrite && signedSame,
+				Detail: fmt.Sprintf("legacy 写库=%v(全局/校区)流水=%d 活动变=%v | target 写库=%v(全局/校区)流水=%d 活动变=%v —— 都应为 未写库/0 条/不变",
+					lgWrote, len(lg.After.Logs), !sameSigned(lg.Before.Signed, lg.After.Signed),
+					tgWrote, len(tg.After.Logs), !sameSigned(tg.Before.Signed, tg.After.Signed)),
 			},
 		)
+		// 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{
+				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"]),
+			})
+		}
 		return diffs, asserts
 	}
 
@@ -265,6 +436,13 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	}
 	wgL, wfL, wtL := expectedAccounts(c.Op, c.Type, c.AmountCents, runsLegacy)
 	wgT, wfT, wtT := expectedAccounts(c.Op, c.Type, c.AmountCents, runsTarget)
+	if c.Op == OpClearBalance {
+		// 清零的金额**由调用前校区余额决定**(SP 里 campusBalance 是从账户读出来的,不是入参),
+		// 所以两侧各按自己的 before 算期望。这不是"拿两侧互相比"——
+		// 前提"两侧基线逐分相等"是上面单独的一条断言。
+		wgL, wfL, wtL = clearedBalanceExpected(lg.Before.Campus.Balance)
+		wgT, wfT, wtT = clearedBalanceExpected(tg.Before.Campus.Balance)
+	}
 
 	dGlobal := sub(lg.After.Global, lg.Before.Global)
 	dCampus := sub(lg.After.Campus, lg.Before.Campus)
@@ -350,13 +528,21 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		"legacy logid = inputRequestid;target 同时写 log_id 与 request_id")
 
 	// ④ target 独有列:request_id / balance_after(legacy 无此列 → N/A,但必须自洽)
+	//
+	// SP10(清零)**没有请求号入参**,所以 target 的 request_id 应为空 ——
+	// 这不是"漏写幂等键",是"这个操作本来就不幂等"。断言它为空,
+	// 而不是断言它等于 c.RequestID(否则会把"凭空生成一个请求号"当成正确)。
+	wantReq := c.RequestID
+	if !opUsesRequestID(c.Op) {
+		wantReq = ""
+	}
 	reqOK := true
 	balOK := true
 	var reqDetail, balDetail []string
 	for _, t := range tg.After.Logs {
-		if t.RequestID != c.RequestID {
+		if t.RequestID != wantReq {
 			reqOK = false
-			reqDetail = append(reqDetail, fmt.Sprintf("id=%d request_id=%q(期望 %q)", t.ID, t.RequestID, c.RequestID))
+			reqDetail = append(reqDetail, fmt.Sprintf("id=%d request_id=%q(期望 %q)", t.ID, t.RequestID, wantReq))
 		}
 		if t.BalanceAfter == nil {
 			balOK = false
@@ -368,9 +554,9 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		}
 	}
 	add("target.request_id(legacy 无此列)", "N/A", firstOr(detailJoin(reqDetail), "全部等于入参"),
-		reqOK, "Go 增强:幂等键落库")
+		reqOK, reqNote(c.Op))
 	add("target.balance_after(legacy 无此列)", "N/A",
-		firstOr(detailJoin(balDetail), "全部 == 调用后全局余额"), balOK, "Go 增强:余额快照")
+		firstOr(detailJoin(balDetail), "全部 == 调用后全局余额"), balOK, "Go 增强:余额快照(口径是**全局**账户,不是校区)")
 
 	// ④.5 remark(默认备注是 SP 之间唯一的"文案差异",必须逐字比对)
 	remarkOK := true
@@ -475,8 +661,13 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		})
 	}
 
-	// ⑦ R15:两侧余额都必须为负
-	if c.Baseline != nil {
+	// ⑥.7 批 C1:校区元数据 / 活动失效 / SP 返回值
+	if c.Op == OpClearBalance || c.Op == OpCancelRechargeActivity {
+		asserts = append(asserts, c1Assertions(c, lg, tg, dGlobal, dCampus, tGlobal, tCampus, runsLegacy, runsTarget)...)
+	}
+
+	// ⑦ R15:两侧余额都必须为负(只有**显式声明**的 case 才断言,见 Case.ExpectNegativeBalance)
+	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{
@@ -492,30 +683,40 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 }
 
 // readLegacyNatural 用独立连接读 legacy 侧全局账户的"天然状态"(事务外)。
-func readLegacyNatural(ctx context.Context, cfg Config, legacyUserID int64) (Account, error) {
+//
+// 同时返回该用户的**流水总条数**:清零类用例没有请求号,没法按 logid 找残留,
+// 只能用"条数没变"来判 —— 而条数要跟事务**之前**比,不能用事务内的快照。
+func readLegacyNatural(ctx context.Context, cfg Config, legacyUserID int64) (Account, int64, error) {
 	db, err := cfg.OpenWriteLegacy()
 	if err != nil {
-		return Account{}, err
+		return Account{}, 0, err
 	}
-	var bal, spend, inc, exp string
+	var bal, spend, inc, exp, spec string
 	err = db.Raw(`
 SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
        CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
-       CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
-  FROM t_user_expense WHERE userid = ?`, legacyUserID).Row().Scan(&bal, &spend, &inc, &exp)
+       CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
+  FROM t_user_expense WHERE userid = ?`, legacyUserID).Row().Scan(&bal, &spend, &inc, &exp, &spec)
 	if err != nil {
-		return Account{}, fmt.Errorf("读 legacy 天然基线失败:%w", err)
+		return Account{}, 0, fmt.Errorf("读 legacy 天然基线失败:%w", err)
 	}
 	b, _ := centsOf(bal)
 	sp, _ := centsOf(spend)
 	i, _ := centsOf(inc)
 	e, _ := centsOf(exp)
-	return Account{Balance: b, Spend: sp, Income: i, Expenditure: e}, nil
+	pc, _ := centsOf(spec)
+	var logCount int64
+	if err := db.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ?`, legacyUserID).
+		Row().Scan(&logCount); err != nil {
+		return Account{}, 0, fmt.Errorf("读 legacy 天然流水条数失败:%w", err)
+	}
+	return Account{Balance: b, Spend: sp, Income: i, Expenditure: e, Special: pc}, logCount, nil
 }
 
 // checkResidue 在两个事务都回滚之后,用**新连接**核对残留。
-func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural Account) ResidueReport {
+func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural Account, naturalLogs int64) ResidueReport {
 	var rep ResidueReport
 
 	// target:本 case 种进去的行必须全部消失(这些行本来就只在事务里存在)
@@ -535,22 +736,37 @@ func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural A
 	// legacy:用户本来就存在,核对"余额回到调用前"
 	ldb, err := cfg.OpenWriteLegacy()
 	if err == nil {
-		var bal, spend, inc, exp string
+		var bal, spend, inc, exp, spec string
 		err := ldb.Raw(`
 SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
        CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
-       CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
-  FROM t_user_expense WHERE userid = ?`, c.LegacyUserID).Row().Scan(&bal, &spend, &inc, &exp)
+       CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
+  FROM t_user_expense WHERE userid = ?`, c.LegacyUserID).Row().Scan(&bal, &spend, &inc, &exp, &spec)
 		if err == nil {
 			b, _ := centsOf(bal)
 			s, _ := centsOf(spend)
 			i, _ := centsOf(inc)
 			e, _ := centsOf(exp)
-			rep.LegacyGlobalExist = Account{Balance: b, Spend: s, Income: i, Expenditure: e} != natural
+			pc, _ := centsOf(spec)
+			// special_expenses 也进"是否偏离"的比较:清零/坏账会动它,漏比就漏残留
+			rep.LegacyGlobalExist = Account{Balance: b, Spend: s, Income: i, Expenditure: e, Special: pc} != natural
+		}
+		if c.RequestID != "" {
+			// 有请求号的 case:按 logid 精确找本 case 的残留
+			_ = ldb.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ? AND logid IN (?, ?)`,
+				c.LegacyUserID, c.RequestID, c.RequestID).Row().Scan(&rep.LegacyLogCount)
+		} else {
+			// 无请求号(清零):用"流水总条数是否回到事务前"来判断
+			var n int64
+			if err := ldb.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ?`, c.LegacyUserID).
+				Row().Scan(&n); err == nil {
+				if n > naturalLogs {
+					rep.LegacyLogCount = n - naturalLogs
+				}
+			}
 		}
-		_ = ldb.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ? AND logid IN (?, ?)`,
-			c.LegacyUserID, c.RequestID, c.RequestID).Row().Scan(&rep.LegacyLogCount)
 	}
 
 	rep.Pass = !rep.TargetGlobalExist && rep.TargetLogCount == 0 && !rep.LegacyGlobalExist && rep.LegacyLogCount == 0
@@ -652,6 +868,10 @@ func remarkNote(op Op) string {
 		return "SP6 **没有默认备注**(inputRemark 原样落库),case 里显式传入以验证透传"
 	case OpSpend:
 		return "SP11 不写流水,无备注可比(0 条)"
+	case OpClearBalance:
+		return "SP10 默认备注「余额清零」(有 5 种写法各不相同的默认备注,只能逐字比)"
+	case OpCancelRechargeActivity:
+		return "SP8 默认备注「取消充值活动」"
 	default:
 		return "消费 SP 的默认备注(照 dump 逐字)"
 	}
@@ -678,6 +898,16 @@ func errOf(calls []CallOutcome) string {
 	return ""
 }
 
+// codeOf 取第一次调用的业务错误码(0 = 不是 AppError 或没有报错)。
+func codeOf(calls []CallOutcome) int {
+	for _, c := range calls {
+		if c.Code != 0 {
+			return c.Code
+		}
+	}
+	return 0
+}
+
 // PrintResult 打印一个 case 的双跑结果。
 func PrintResult(w io.Writer, r *CaseResult) {
 	fmt.Fprintf(w, "──────────────────────────────────────────────────────────────\n")

+ 139 - 1
internal/academic/dualrun/stage2/stage2_test.go

@@ -1,6 +1,8 @@
 package stage2
 
-import "testing"
+import (
+	"testing"
+)
 
 // 本文件单测「期望增量」这一块纯逻辑:
 // 它决定了"每侧应该发生什么",比错一次双跑就全绿/全红,属于必须锁住的部分。
@@ -245,3 +247,139 @@ func TestRemarkNote(t *testing.T) {
 		t.Error("每个 Op 都必须有备注说明,不能留空")
 	}
 }
+
+// TestClearedBalanceExpected 锁住"余额清零"的三列同额增量。
+//
+// SP10 的写法是三条同额 UPDATE(balance−、special_expenses+、spend+),
+// 且金额来自**校区余额**而不是入参。少一列或把 special 写成不动,都会在这里红。
+func TestClearedBalanceExpected(t *testing.T) {
+	const cb = int64(974000) // 9740.00
+	g, f, to := clearedBalanceExpected(cb)
+	want := Account{Balance: -cb, Special: cb, Spend: cb}
+	if g != want {
+		t.Errorf("全局应为 %+v,得到 %+v", want, g)
+	}
+	if f != want {
+		t.Errorf("校区应为 %+v,得到 %+v", want, f)
+	}
+	if to != (Account{}) {
+		t.Errorf("清零不该产生转入校区增量,得到 %+v", to)
+	}
+	// 三个字段都必须非 0:任何一个漏赋值(Go 的零值)都会让断言静默通过
+	if want.Balance == 0 || want.Special == 0 || want.Spend == 0 {
+		t.Fatal("期望值的三个字段都不能是零值 —— 零值和'没赋值'无法区分")
+	}
+	if g.Income != 0 || g.Expenditure != 0 {
+		t.Errorf("清零不动 total_income / total_expenditure,得到 %+v", g)
+	}
+}
+
+// TestExpirable 锁住活动失效判据(SP10 的 tmp_user_activity 两段 INSERT)。
+func TestExpirable(t *testing.T) {
+	p := C1ActivityPlan(100, nil)
+	const inst = int64(63317)
+
+	cases := []struct {
+		name        string
+		item        ActivityItem
+		institution int64
+		campus      int64
+		selfSupport bool
+		want        bool
+	}{
+		{"本级校区·自营", p.Items[0], inst, 5, true, true},
+		{"本级校区·加盟", p.Items[0], inst, 5, false, true},
+		{"全机构池·自营(含 campus_id=0)", p.Items[1], inst, 5, true, true},
+		{"全机构池·加盟(不含 campus_id=0)", p.Items[1], inst, 5, false, false},
+		{"其他校区·自营", p.Items[2], inst, 5, true, false},
+		{"其他机构·自营(机构过滤)", p.Items[3], inst, 5, true, false},
+		{"代金券 type=3·自营(type 过滤)", p.Items[4], inst, 5, true, false},
+		{"已过期 status=4·自营", p.Items[5], inst, 5, true, false},
+		{"未报名(status<0)", p.Items[6], inst, 5, true, false},
+	}
+	for _, c := range cases {
+		if got := p.expirable(c.item, c.institution, c.campus, c.selfSupport); got != c.want {
+			t.Errorf("%s:expirable = %v,want %v", c.name, got, c.want)
+		}
+	}
+}
+
+// TestExpectedSignedAfter 锁住"调用后报名状态的整张 map"。
+//
+// 用的是**全等**而不是"变了几个":多改一条(比如加盟校区把 campus_id=0 的活动也失效了)
+// 必须能被抓出来 —— 这正是变异 M2 的靶子。
+func TestExpectedSignedAfter(t *testing.T) {
+	p := C1ActivityPlan(100, nil)
+
+	self := p.expectedSignedAfter(true, 63317, 5, true, 0)
+	wantSelf := map[int64]int32{100: 4, 101: 4, 102: 2, 103: 2, 104: 2, 105: 4}
+	if !sameSigned(self, wantSelf) {
+		t.Errorf("自营期望 %s,want %s", fmtSigned(self), fmtSigned(wantSelf))
+	}
+
+	franchise := p.expectedSignedAfter(true, 63317, 4, false, 0)
+	wantFranchise := map[int64]int32{100: 2, 101: 2, 102: 4, 103: 2, 104: 2, 105: 4}
+	if !sameSigned(franchise, wantFranchise) {
+		t.Errorf("加盟期望 %s,want %s", fmtSigned(franchise), fmtSigned(wantFranchise))
+	}
+	// 两者必须不同 —— 若相同,说明自营/加盟分支退化成了一条,那两个 case 就只测了一遍
+	if sameSigned(self, franchise) {
+		t.Fatal("自营与加盟的失效结果不应相同(否则分支没被覆盖)")
+	}
+
+	// 取消活动:只把指定活动置 0,其余不动
+	cancel := p.expectedSignedAfter(false, 63317, 5, true, 100)
+	if cancel[100] != 0 {
+		t.Errorf("被取消的活动应为 0,得到 %d", cancel[100])
+	}
+	for _, id := range []int64{101, 102, 103, 104} {
+		if cancel[id] != 2 {
+			t.Errorf("活动 %d 不该被取消,得到 %d", id, cancel[id])
+		}
+	}
+	// 已过期的那条保持 4(不因为"取消"被改回 0)
+	if cancel[105] != 4 {
+		t.Errorf("已过期的活动应保持 4,得到 %d", cancel[105])
+	}
+	// 未报名的活动不该出现在 map 里(没有行,也就没有状态)
+	if _, ok := cancel[106]; ok {
+		t.Error("未报名的活动不该出现在结果里")
+	}
+}
+
+// TestFmtChanges 报告里"本次变化"必须能显示 2→0(取消),不能只显示"变成 4"。
+//
+// 这条来自一个真实的可读性坑:一开始只统计"变成 4"的,取消活动 case 打印出「<无>」,
+// 而「<无>」和「没做断言」在报告里长得一样。
+func TestFmtChanges(t *testing.T) {
+	before := map[int64]int32{100: 2, 101: 2, 102: 4}
+	after := map[int64]int32{100: 4, 101: 0, 102: 4}
+	if got := fmtChanges(before, after); got != "100:2→4, 101:2→0" {
+		t.Errorf("fmtChanges = %q", got)
+	}
+	if got := fmtChanges(after, after); got != "<无>" {
+		t.Errorf("无变化时应为 <无>,得到 %q", got)
+	}
+}
+
+// TestOpFlags 锁住"哪些 Op 会跨域读校区 / 有请求号"这两张开关表。
+func TestOpFlags(t *testing.T) {
+	if !needsCampusMeta(OpClearBalance) || !needsCampusMeta(OpCancelRechargeActivity) {
+		t.Error("清零与取消活动都会跨域读校区")
+	}
+	if needsCampusMeta(OpRecharge) || needsCampusMeta(OpTransfer) {
+		t.Error("充值/转结不读校区元数据(不产生额外断言)")
+	}
+	if opUsesRequestID(OpClearBalance) {
+		t.Error("SP10 没有请求号入参,必须标记为不使用请求号(否则会断言 request_id 等于入参)")
+	}
+	if !opUsesRequestID(OpCancelRechargeActivity) || !opUsesRequestID(OpRecharge) {
+		t.Error("其余写 SP 都有请求号入参")
+	}
+	if !returnsResultSet(OpCancelRechargeActivity) {
+		t.Error("SP8 每条路径都以 SELECT 结尾,必须走结果集读取路径")
+	}
+	if returnsResultSet(OpClearBalance) {
+		t.Error("SP10 只有 SELECT ... INTO,不产生结果集")
+	}
+}

+ 100 - 17
internal/academic/dualrun/stage2/target.go

@@ -2,6 +2,8 @@ package stage2
 
 import (
 	"context"
+	"database/sql"
+	"errors"
 	"fmt"
 	"time"
 
@@ -11,6 +13,7 @@ import (
 	"yuansiwei/unified-backend/internal/academic/money"
 	"yuansiwei/unified-backend/internal/academic/port"
 	"yuansiwei/unified-backend/internal/academic/service"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 	appscope "yuansiwei/unified-backend/internal/infra/scope"
 )
 
@@ -51,12 +54,19 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 			return nil, err
 		}
 	}
+	// 活动侧种子:两侧各插同一批活动 + 报名(id 由 case 的 Activity.BaseID 决定,两侧同值)
+	if err := seedTargetActivities(ctx, tx, c, canonicalUID); err != nil {
+		return nil, err
+	}
 
 	before, maxLogID, err := snapshotTarget(ctx, tx, canonicalUID, c)
 	if err != nil {
 		return nil, err
 	}
 	res.Before = before
+	if c.CampusID > 0 && needsCampusMeta(c.Op) {
+		res.CampusMeta = readTargetCampusMeta(ctx, tx, c.CampusID)
+	}
 
 	// 真实 service 装配:scope 来自 case(机构/校区/角色),checker 读 t_membership
 	sc := &appscope.Scope{
@@ -67,6 +77,19 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 	}
 	svc := service.NewFundService(tx, sc, port.NewOrgMembershipChecker(tx))
 
+	// 批 C1 的两支 SP 不在 FundService 上:
+	//   SP10 余额清零       → RefundService.ClearBalance
+	//   SP8  取消充值活动   → RecommendService.CancelRechargeActivity(内部转 RefundService,同一事务)
+	// 三者都绑定**同一个 tx**,所以读写落在我的事务里,可以读完再回滚。
+	// 校区端口用 `NewOrgCampusReader(tx)`:它实现 TxCampusReader,
+	// 读取会走**我的事务**,因此能看到我在事务内种子进去的 t_campus 行。
+	campusReader := port.NewOrgCampusReader(tx)
+	memberChecker := port.NewOrgMembershipChecker(tx)
+	refundSvc := service.NewRefundService(tx, sc, campusReader, memberChecker)
+	recommendSvc := service.NewRecommendService(tx, sc).
+		WithCampusReader(campusReader).
+		WithMembershipChecker(memberChecker)
+
 	for i := 0; i < maxInt(1, c.RunTimes); i++ {
 		oc := CallOutcome{}
 		switch c.Op {
@@ -78,7 +101,7 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 				Remark: c.Remark, OperatorID: canonicalUID, Kind: service.RechargeKindDefault,
 			})
 			if err != nil {
-				oc.ErrMsg = err.Error()
+				setCallErr(&oc, err)
 			} else {
 				oc.Success, oc.Repeated, oc.Balance, oc.LogID =
 					true, out.Repeated, int64(out.Balance), out.LogID
@@ -91,7 +114,7 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 				Remark: c.Remark, OperatorID: canonicalUID, Kind: service.RechargeKindSchool,
 			})
 			if err != nil {
-				oc.ErrMsg = err.Error()
+				setCallErr(&oc, err)
 			} else {
 				oc.Success, oc.Repeated, oc.Balance, oc.LogID =
 					true, out.Repeated, int64(out.Balance), out.LogID
@@ -103,7 +126,7 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 				Remark: c.Remark, OperatorID: canonicalUID,
 			})
 			if err != nil {
-				oc.ErrMsg = err.Error()
+				setCallErr(&oc, err)
 			} else {
 				oc.Success, oc.Repeated, oc.Balance, oc.LogID =
 					true, out.Repeated, int64(out.Balance), out.LogID
@@ -115,7 +138,7 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 				Remark: c.Remark, OperatorID: canonicalUID,
 			})
 			if err != nil {
-				oc.ErrMsg = err.Error()
+				setCallErr(&oc, err)
 			} else {
 				oc.Success, oc.Repeated, oc.LogID = true, out.Repeated, out.LogID
 				oc.Balance = int64(out.Balance)
@@ -127,24 +150,51 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 				SpendMoney: money.Amount(c.AmountCents),
 			})
 			if err != nil {
-				oc.ErrMsg = err.Error()
+				setCallErr(&oc, err)
 			} else {
 				oc.Success = true
 				oc.Balance = int64(out.Spend)
 			}
 		case OpTransfer:
 			out, err := svc.Transfer(ctx, service.TransferInput{
-				UserID:          canonicalUID,
+				UserID:            canonicalUID,
 				FromInstitutionID: c.InstitutionID, FromCampusID: c.CampusID,
 				ToInstitutionID: c.ToInstitutionID, ToCampusID: c.ToCampusID,
 				Money:     money.Amount(c.AmountCents),
 				RequestID: c.RequestID, Remark: c.Remark, OperatorID: canonicalUID,
 			})
 			if err != nil {
-				oc.ErrMsg = err.Error()
+				setCallErr(&oc, err)
 			} else {
 				oc.Success, oc.Repeated, oc.LogID = true, out.Repeated, out.LogID106
 			}
+		case OpClearBalance:
+			// SP10 → RefundService.ClearBalance(不在 FundService 上)
+			out, err := refundSvc.ClearBalance(ctx, service.ClearBalanceInput{
+				UserID: canonicalUID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,
+				Remark: c.Remark, OperatorID: canonicalUID,
+			})
+			if err != nil {
+				setCallErr(&oc, err)
+			} else {
+				oc.Success, oc.Repeated = true, out.Repeated
+				oc.Balance = int64(out.Balance)
+				oc.LogID = out.LogID208
+			}
+		case OpCancelRechargeActivity:
+			// SP8 → RecommendService.CancelRechargeActivity(它内部再转 RefundService,同一事务)
+			out, err := recommendSvc.CancelRechargeActivity(ctx, service.CancelRechargeActivityInput{
+				UserID: canonicalUID, ActivityID: c.ActivityID, CampusID: c.CampusID,
+				Money:     money.Amount(c.AmountCents),
+				RequestID: c.RequestID, Remark: c.Remark, OperatorID: canonicalUID,
+			})
+			if err != nil {
+				setCallErr(&oc, err)
+			} else {
+				oc.Success, oc.Repeated = true, out.Repeated
+				oc.Balance = int64(out.Balance)
+				oc.LogID = out.LogID
+			}
 		default:
 			out, err := svc.Expenditure(ctx, service.ExpenditureInput{
 				UserID: canonicalUID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,
@@ -152,7 +202,7 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 				Remark: c.Remark, OperatorID: canonicalUID,
 			})
 			if err != nil {
-				oc.ErrMsg = err.Error()
+				setCallErr(&oc, err)
 			} else {
 				oc.Success, oc.Repeated, oc.Balance, oc.LogID =
 					true, out.Repeated, int64(out.Balance), out.LogID
@@ -183,12 +233,20 @@ ON DUPLICATE KEY UPDATE institution_name = VALUES(institution_name)`,
 		return fmt.Errorf("种子 t_institution 失败:%w", err)
 	}
 	// campus_id=0 是"机构级/未分配"的约定值,R19 用例专门用它验证 SP 的 LEAVE 守卫 → 不建校区行
-	if c.CampusID > 0 {
+	// CampusMissing:本 case 故意用不存在的校区,**不能顺手建一行**(建了这条用例就失去意义)。
+	if c.CampusID > 0 && !c.CampusMissing {
+		// self_support 必须由 case 声明并与旧库视图一致:它是 SP 里活动失效范围的唯一判据,
+		// 这里硬编码成 1 会让"加盟校区"这条分支永远走不到(曾经的写法就是硬编码 1)。
+		selfSupport := 0
+		if c.SelfSupport {
+			selfSupport = 1
+		}
 		if err := tx.Exec(`
 INSERT INTO t_campus (campus_id, institution_id, campus_name, status, self_support, created_at, updated_at)
-VALUES (?, ?, CONCAT('ac15-case-campus-', ?), 'active', 1, NOW(3), NOW(3))
-ON DUPLICATE KEY UPDATE campus_name = VALUES(campus_name)`,
-			c.CampusID, c.InstitutionID, c.CampusID).Error; err != nil {
+VALUES (?, ?, CONCAT('ac15-case-campus-', ?), 'active', ?, NOW(3), NOW(3))
+ON DUPLICATE KEY UPDATE campus_name = VALUES(campus_name),
+  institution_id = VALUES(institution_id), self_support = VALUES(self_support)`,
+			c.CampusID, c.InstitutionID, c.CampusID, selfSupport).Error; err != nil {
 			return fmt.Errorf("种子 t_campus 失败:%w", err)
 		}
 	}
@@ -235,8 +293,8 @@ ON DUPLICATE KEY UPDATE balance = VALUES(balance), spend = VALUES(spend),
 		return fmt.Errorf("种子 t_user_expense 失败:%w", err)
 	}
 
-	// ④ 校区账户(campus_id=0 的 R19 用例不建)
-	if c.CampusID > 0 {
+	// ④ 校区账户(campus_id=0 的 R19 用例、CampusMissing 用例都不建)
+	if c.CampusID > 0 && !c.CampusMissing {
 		if err := tx.Exec(`
 INSERT INTO t_user_expense_school
   (user_id, institution_id, campus_id, balance, spend, total_income, total_expenditure,
@@ -325,8 +383,13 @@ SELECT CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
   FROM t_user_expense_school WHERE user_id = ? AND institution_id = ? AND campus_id = ?`,
 		canonicalUID, c.InstitutionID, c.CampusID).
 		Row().Scan(&sc.Balance, &sc.Spend, &sc.Income, &sc.Expenditure, &sc.Special)
-	if qerrCampus != nil && c.CampusID > 0 {
-		return out, 0, fmt.Errorf("读 target 校区账户失败:%w", qerrCampus)
+	if qerrCampus != nil {
+		// 同 legacy 侧:校区账户不存在是合法状态(CampusMissing 用例),记零值 + 标记。
+		if !errors.Is(qerrCampus, sql.ErrNoRows) {
+			return out, 0, fmt.Errorf("读 target 校区账户失败:%w", qerrCampus)
+		}
+	} else {
+		out.HasCampusAccount = true
 	}
 	cs, _ := centsOf(sc.Balance)
 	csp, _ := centsOf(sc.Spend)
@@ -384,8 +447,28 @@ SELECT id, type, CAST(money AS DECIMAL(12,2)), IFNULL(log_id,''), IFNULL(request
 		}
 		out.Logs = append(out.Logs, l)
 	}
-	return out, maxID, rows.Err()
+	if err := rows.Err(); err != nil {
+		return out, 0, err
+	}
+	signed, err := readTargetSigned(ctx, tx, c.ActivityPlan, canonicalUID)
+	if err != nil {
+		return out, 0, err
+	}
+	out.Signed = signed
+	return out, maxID, nil
 }
 
 // CanonicalUIDFor 返回 case 用户的确定性 ULID(与迁移器同一函数,保证可复现)。
 func CanonicalUIDFor(legacyUserID int64) string { return fixture.FixtureCanonicalUserID(legacyUserID) }
+
+// setCallErr 记录错误文本,并尽量解出业务错误码。
+//
+// 为什么要单独取 Code:断言"报了 3120"比断言"报错了"强一档 —— 同一句文案可能被别处复用,
+// 而码是设计文档里定死的契约(1004/2002/3102/3104/3120/3140…)。
+func setCallErr(oc *CallOutcome, err error) {
+	oc.ErrMsg = err.Error()
+	var ae *infraerrors.AppError
+	if errors.As(err, &ae) {
+		oc.Code = ae.Code
+	}
+}