Explorar el Código

feat(ac15): C2-10 活动失效(34→35)+ Skipped 掏空闸 + 订正 §13.2 的一处错误举例

一、C2-10:自定义退费的活动失效(SP9 里那段 tmp_user_activity)
与 cleared_balance 的那段 SQL **逐字相同**,但**执行位置不同**:清零在写流水**之前**、
退费在写流水**之后**。事务内顺序不同 ⇒ 不能因为 SQL 一样就认定风险一样。
同时它是**第二条 equality case**(整数余额 + 有可失效报名)。
真实构型:53708 / campus 5 自营 / 校区余额 4950.000(整数)/ 旧库无别的可失效报名 / 49 条历史流水。
退 5300.00 ⇒ 两侧都走坏账分支:扣 4950、坏账 350、108+207,逐分一致。
活动侧断言:整张 status map 全等(不是"变了几个")+ 全机构池(campus_id=0) 单独一条
+ "旧库没有别的可失效报名"前置断言。

⚠ 活动侧**必然是种子**:实测 target 活动域为空(0 / 0,legacy 90 / 1288),
任何真实报名都只有 legacy 有。这不是偷懒,是活动域未迁移的必然结果,已写进 README。

二、Skipped 掏空闸(FailCount 跳过 Skipped 后的新空过通道)
"一条 case 的全部断言都是 Skipped" ⇒ 0 FAIL 的 PASS —— 分母没变、分子全空,
是**分母收缩的又一个变种**。新增 CaseResult.EnsureNotAllSkipped(),在 RunOne 末尾
(残留断言生成之后)判:断言为空或全 Skipped ⇒ FATAL(退出码 3)。
报错而非记 FAIL:这不是"行为不对",是"这条 case 什么都没测",属测试基础设施失效。
报数因此从六个数变成七个数:hash / DefaultCases / 实跑 / PASS / FAIL / 崩溃 / Skipped。

三、订正 §13.2 里我写错的两处(不是数字错,是判据错)
1. 举例用了 4 位小数(3.9995)。旧列是 decimal(10,3),**存不下** —— 例子本身不成立。
   真实形状是 0.996 → 1.00(7 个真实账户已列出、可直接复跑)。
2. 把"选 case 的过滤条件"说成了"标识这 7 个"。ROUND(balance,0) = ROUND(ROUND(balance,2),0)
   在真实数据上**对所有账户都成立**(要让它不等,分位须落在 [0.495,0.5),全库 0 个),
   所以它恒真、筛不出任何东西。
现在把两个口径分开写并做加法闭合:
  缺陷面 166 + 95 + 37 + 268 = 566(r0 vs raw)
  差异面 166 + 88 + 33 + 11 + 268 = 566(r0 vs r2)
  95−88=7、37−33=4、7+4=11(重合),两个口径的差恰好是重合那 11 个。

四、新增 cmd/spdump/README.md「读 SP 正文的五个坑」
含"连续两个 IF 不等于 if/else"(差额不足 0.5 时进第一个 IF 却走 ELSE、扣 inputMoney)、
"DECLARE 不带精度 = DECIMAL(10,0)"、"SELECT INTO 无行不改变量"、"OR 短路吃掉 NULL"、
"同名逻辑出现在多处但执行位置不同"。

自检:go build ./... / go vet / go test ./... 全绿;全量 35 条 35 PASS、Skipped 69、崩溃 0。
Q hace 2 horas
padre
commit
23a6240f89

+ 10 - 0
cmd/spdump/README.md

@@ -34,6 +34,16 @@ AC-15 阶段 2 用三次实测证明:**「照抄 SP」是不可行的策略**
 
 ⇒ **证据强度:SQL 语句 > 参数签名 > COMMENT > 表名/函数名。**
 
+## 读 SP 正文的五个坑(每一条都真的让我们算错过)
+
+| # | 坑 | 错在哪 | 怎么防 |
+|---|---|---|---|
+| 1 | **`DECLARE x DECIMAL` 不带精度就是 `DECIMAL(10,0)`** | `sp_user_expense_custom_refund` 的 `_balance` / `_money` / `_badDate` 全是整数,赋值即四舍五入,而且**收敛两次**(`_balance` 一次、`_badDate` 再算一次)。照"变量就是数字"的直觉写期望,会整整差一元。 | 逐个变量看有没有精度参数;把每一层收敛写成一条等式(见 `c2_refund.go` 的 `round0Cents`)。 |
+| 2 | **连续两个 `IF` 不等于 `if/else`** | `IF(inputMoney>_balance) THEN SET _badDate=…; SET _money=…; END IF;` 之后另起 `IF(_badDate>0) THEN … ELSE 扣 inputMoney END IF;`。差额不足 0.5 时 `_badDate` 收敛成 0 ⇒ **进了第一个 IF 却走 ELSE,扣的是 `inputMoney` 而不是 `_money`**。按 if/else 读会期望扣 1.00 而实测 1.30。 | **任何 SP 出现连续两个 `IF`,都要单独确认它们是否互斥**;不互斥就分别判断,别合成一个二分支。锁在 `TestLegacyRefundExpectBadDateRoundsToZero`。 |
+| 3 | **`SELECT … INTO` 无行可赋时不改变量** | 变量停在 `DEFAULT 0`,于是"账户行不存在"与"余额真的是 0"在 SP 里**不可区分** —— `custom_refund` 会因此静默 `success=0`,调用方看到的是"成功"。 | 断言"账户行是否存在"要单独读,不能从变量值反推。 |
+| 4 | **`OR` 的短路能把 NULL 变成"走另一条分支"** | `IF(ISNULL(_balance) OR _balance<=0)`:NULL 时**左边**就成立、右边根本不求值 ⇒ 结果是"拒绝",不是"NULL 参与比较"。 | 遇到 `ISNULL(x) OR y` 一定要想"x 为 NULL 时 y 会不会被求值"。 |
+| 5 | **同名逻辑出现在多处,执行位置可能不同** | `tmp_user_activity` 那段在 `cleared_balance` 里跑在**写流水之前**、在 `custom_refund` 里跑在**写流水之后**。SQL 逐字相同,事务内顺序不同 ⇒ 失败终态组合不同。 | 同一段 SQL 出现在 N 处就测 N 次;并且各自记下"它在流水之前还是之后"(见 C2-10)。 |
+
 ## 安全约束(硬性,不是约定)
 
 - 只连 `fixture.allowedDatabases` 白名单里的**本机**库(`OpenReadOnlyDB` 里守);

+ 117 - 12
internal/academic/dualrun/stage2/README.md

@@ -813,19 +813,74 @@ _money   ← _balance
 
 ### 13.2 存量影响面(`t_user_expense_school`,`balance > 0`,共 **566** 个账户)
 
+先定义两个口径,**别混**(这是本节唯一容易搞错的地方):
+
+- `raw` = 旧库列原值(`decimal(10,3)`,3 位)
+- `r0` = `ROUND(raw, 0)` —— legacy 的 `_balance`(第一层整数化)
+- `r2` = `ROUND(raw, 2)` —— **迁移后** target 读到的值(2 位)
+- **缺陷面** = `r0` 与 `raw` 的差(旧系统自己的问题,与迁移无关)
+- **差异面** = `r0` 与 `r2` 的差(割接后新旧系统会不会表现出不同)
+
 | 形态 | 账户数 | 金额 | legacy 的行为 | target 的行为 |
 |---|---:|---:|---|---|
-| `r0 = 0`(余额 < 0.5) | **166** | **¥18.177** | 判"余额不能≤0"、**拒绝退费**(零写入) | 照常退,退得出来 |
-| `r0 > raw`(分位 ≥ 0.5) | **95** | **¥21.155** | 多扣到整数,余额变**负数**(最多 −0.5) | 精确扣到 0.00 |
-| ‣ 其中"迁移已吸收" | 7 | ¥0.025 | 3 位→2 位收敛后 `r0 = r2`,**与 SP 无关** | — |
-| ‣ 纯属变量类型 | **88** | **¥21.130** | 同上 | 同上 |
-| `r0 < raw`(分位 < 0.5) | **37** | **¥6.986** | 少扣、**留正零头**,坏账被低估 | 精确扣到 0.00 |
-| `r0 = raw`(整数余额) | 268 | — | 与 target 逐分一致 | — |
-
-> ⚠ 那 **7 个**单独列出来,是因为它们是**另一个成因**:3 位小数向上进位恰好跨过整数
-> (如 `3.9995 → 4.00`),2 位迁移**已经把它修好了**。把它和"变量类型"混在一起讲,
-> 会让"我们修好了多少"和"旧 SP 本来就错多少"两笔账算不清。
-> 选 case 时那条 `ROUND(balance,0) == ROUND(ROUND(balance,2),0)` 的过滤,就是为了不让这两种成因互相污染。
+| `r0 = raw`(整数余额) | 268 | — | 与 target 逐分一致 | 同左 |
+| `r0 = 0`(`0 < raw < 0.5`) | **166** | **¥18.177** | 判"余额不能≤0"、**拒绝退费**(零写入) | 照常退,退得出来 |
+| `r0 > raw`(**多扣**) | **95** | **¥21.155** | 多扣到整数,余额变**负数**(最多 −0.5) | 精确扣到 0.00 |
+| `r0 < raw`(**少扣**) | **37** | **¥6.986** | 少扣、**留正零头**,坏账被低估 | 精确扣到 0.00 |
+
+加法闭合(**每一步都要能相加等于分母**,这是最廉价也最容易被跳过的检查):
+
+```
+166 + 95 + 37 + 268 = 566   ← 缺陷面
+```
+
+再按**差异面**切一次(同一批账户,换一个判据):
+
+| 差异面 | 账户数 | 差额 | 说明 |
+|---|---:|---:|---|
+| `r0 > r2`(legacy 比 target 多扣) | **88** | ¥21.130 | 95 减 7 |
+| `r0 < r2`(legacy 比 target 少扣) | **33** | ¥7.000 | 37 减 4 |
+| `r0 = r2` 但 `r0 ≠ raw`(**重合**) | **11** | — | `0.996 → 1.00` 这类 |
+| `r0 = 0`(拒绝 vs 退款) | 166 | — | 差异是"退不出 vs 退得出",不是金额 |
+
+```
+88 + 33 + 11 + 166 + 268 = 566   ← 差异面,同样必须闭合
+95 − 88 = 7,37 − 33 = 4,7 + 4 = 11   ← 两个口径的差恰好是"重合"那 11 个
+```
+
+那 **11 个重合**是什么:3 位小数**向上进位恰好跨过整数**,2 位迁移后 `r2` 就等于 legacy 算出来的
+`r0`,于是两侧扣的一样多。真实样本(已列出,可直接复跑):
+
+| userid | raw | `r2` | `r0` |
+|---|---:|---:|---:|
+| 48510 | 0.996 | 1.00 | 1 |
+| 66449 | 0.999 | 1.00 | 1 |
+| 66743 | 0.998 | 1.00 | 1 |
+| 27676 | 1.999 | 2.00 | 2 |
+| 67983 | 7.998 | 8.00 | 8 |
+| 68584 | 99.996 | 100.00 | 100 |
+| 47613 | 301.996 | 302.00 | 302 |
+
+复跑判据(**注意不是** `ROUND(balance,0) <> ROUND(ROUND(balance,2),0)`,那个在全库是 0 行):
+
+```sql
+-- 多扣侧的重合:r0 > raw(缺陷面里)但 r0 = r2(差异面里没有)
+SELECT userid, balance FROM t_user_expense_school
+ WHERE balance > 0
+   AND ROUND(balance,0) > balance                 -- raw 口径:整数化后更大
+   AND ROUND(balance,0) = ROUND(balance,2);       -- 但 2 位口径下已重合
+```
+
+> ⚠ 两个坑,都是我自己踩过的:
+>
+> 1. **举例用了 4 位小数**(早先写成 `3.9995 → 4.00`)。旧列是 `decimal(10,3)`,**存不下 4 位** ——
+>    例子本身就不成立,等于用了一个真实数据里不存在的值域。真实的形状是 `0.996 → 1.00`。
+> 2. **把"选 case 的过滤条件"说成了"标识这 11 个"**。`ROUND(balance,0) = ROUND(ROUND(balance,2),0)`
+>    在真实数据上**对所有账户都成立**(要让两者不等,分位必须落在 `[0.495, 0.5)`,全库 0 个),
+>    所以它不能用来找重合账户 —— 它只是当时选 case 时"确认不会混成因"的一个**始终为真**的检查。
+>
+> 教训:**引用一个判据时,要说清它筛的是什么、以及在真实数据上会不会全真/全假。**
+> 一个恒真的过滤条件看起来像"检查过",实际什么都没筛。
 
 ### 13.3 断言形态:**per-side + 共同不变量**
 
@@ -859,7 +914,7 @@ IF(_badDate > 0) THEN ... ELSE UPDATE ... `balance` - inputMoney ... END IF;
 
 ### 13.5 case 与分母
 
-`DefaultCases` **25 → 34**(C2 共 9 条):
+`DefaultCases` **25 → 35**(C2 共 10 条):
 
 | case | 余额 | 请求 | 结论 |
 |---|---:|---:|---|
@@ -872,11 +927,31 @@ IF(_badDate > 0) THEN ... ELSE UPDATE ... `balance` - inputMoney ... END IF;
 | C2-6 | 699.980 | 699.99 | 缝区间:`699.99 > 700` 为假 ⇒ **连分支都不同** |
 | C2-8 | 无校区行 | 100.00 | legacy 静默 `success=0` / target 3102 |
 | C2-9 | 5000.00 | 300.00×2 | 幂等:target 扣 1 次、legacy 扣 2 次 |
+| **C2-10** | **4950.00** | 5300.00 | **活动失效**:整数余额 + 自营 ⇒ 两侧**一致**,且本校区与全机构池都置 4 |
 
 **C2-7(E)不在其中**,只在 `dualrun stage2 -probe` 里跑:它靠"legacy 侧余额置 NULL"构造,
 而 target 的列是 `not null default 0.00`、**NULL 存不下**,所以第二侧**不同构**。
 "两侧都拒绝"这个绿的含金量是 0(换任何一条余额为 0 的记录都能绿),算进分母等于用必然绿的 case 抬通过率。
 
+#### C2-10 为什么必须单独存在(即使 SQL 与 cleared_balance 逐字相同)
+
+SP9 里那段 `tmp_user_activity` + `UPDATE t_courses_activity_signed SET status=4` 与
+`sp_user_expense_school_cleared_balance` **逐字相同**。但有两条理由不能"因为一样就不测":
+
+1. **执行位置不同**:清零在写流水**之前**跑这段,退费在写流水**之后**跑。
+   事务内顺序不同 ⇒ 失败时的终态组合不同("流水写了、报名没失效"只有这一支才可能)。
+2. **复制粘贴的 SQL 最怕改了一个字**:同一段逻辑出现在两处,就该在两处各验一次 ——
+   否则哪天有人在 c2 那份里手滑改了 `campusid IN (x, 0)` 的括号,没人会发现。
+
+它是**第二条 equality case**(整数余额 + 有可失效报名):legacy `_balance=4950`、
+`_badDate=round0(5300−4950)=350`;target `refunded=4950`、`badDebt=350` —— 逐分相同。
+
+> ⚠ **活动侧必然是种子,资金侧才是真实构型。** 实测 target 侧活动域是空的
+> (`t_courses_activity`=0、`t_courses_activity_signed`=0;legacy 侧 90 / 1288),
+> 所以**任何真实报名都只有 legacy 有**、target 看不到。要两侧看到同一批活动,只能像 C1 那样
+> 在事务内种(`C1ActivityPlan`),并用"旧库该用户没有别的可失效报名"这条前置断言钉住有效性。
+> 这不是偷懒,是活动域未迁移的必然结果;想改成"用真实报名",得先迁活动域。
+
 ## 14. `Assertion.Skipped`:断言的第三态
 
 `Diff.Skipped` 打 `—`(§ 里已述)之后,**断言**也需要同一个第三态,否则会出现
@@ -891,6 +966,36 @@ IF(_badDate > 0) THEN ... ELSE UPDATE ... `balance` - inputMoney ... END IF;
 所以每个 `Skipped` 的产生条件都必须是"从 case 的构造就能看出结构上不适用",
 **不能**是"实测值和我预期的不一样,所以跳过"。
 
+### 14.1 闸:`Skipped` 不许把整条 case 掏空
+
+`FailCount` 跳过 `Skipped` 之后,出现了一个**新的空过通道**:
+
+> **一条 case 的全部断言都是 `Skipped`** ⇒ 0 FAIL 的 PASS。
+> 分母没变(case 还在),但分子全是空的 —— 这是**分母收缩的又一个变种**。
+
+它可能真的发生:有人给某条 case 改了构型,把所有断言都变成结构性不适用,报告照样全绿。
+
+所以 `RunOne` 末尾加了一道闸 `CaseResult.EnsureNotAllSkipped()`:
+
+| 情形 | 处置 |
+|---|---|
+| 有 ≥1 条非 `Skipped` 断言 | 放行 |
+| 断言一条都没有 | **FATAL**(退出码 3) |
+| 断言全 `Skipped`(**即使 Pass 全是 true**) | **FATAL**(退出码 3) |
+| 断言全 `Skipped` 但 Diff 里有非 `Skipped` 比对 | **仍然 FATAL** |
+
+报错而不记 FAIL 的理由:这不是"行为不对",是"这条 case 什么都没测"——
+与 `-case` 匹配不到、分母收缩同级,属于**测试基础设施失效**。
+记 FAIL 会被当成"待修的红"排进队列,而它根本不该被容忍。
+
+于是报数从**六个数**变成**七个数**:
+
+```
+hash / DefaultCases / 实跑 / PASS / FAIL / 崩溃 / Skipped
+```
+
+`Skipped` 必须一起报,否则"PASS 34"和"PASS 34 但有 60 项结构性不适用"看起来一样。
+
 ## 15. 一次真实的连接泄漏(34 条 case 才暴露)
 
 `RunOne` 原来每个 case 打开 **5 个** gorm 句柄(legacy / target / 天然基线 / 残留检查 ×2),

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

@@ -95,6 +95,23 @@ func (p *ActivityPlan) ActivityIDs() []int64 {
 	return out
 }
 
+// poolActivityID 返回"全机构池活动(campus_id=0)"的 id。
+//
+// 单独拎出来是因为它是**自营 vs 加盟唯一的判据差别**:自营的 `campusid IN (x,0)` 会连带
+// 失效它、加盟的 `campusid = x` 不会。如果这条活动混在整张 map 里,被别的活动一衬托
+// 就不显眼了 —— 而它错了正是"分支读错"的典型症状。
+func (p *ActivityPlan) poolActivityID() (int64, bool) {
+	if p == nil {
+		return 0, false
+	}
+	for _, it := range p.Items {
+		if it.CampusID == 0 && it.SignedStatus >= 0 {
+			return it.ID, true
+		}
+	}
+	return 0, false
+}
+
 // expectedSignedAfter 返回"调用**之后**该用户在 plan 各活动上的报名状态"。
 //
 // expire=true 时按 dump 的失效判据把该失效的置 4;cancelActivityID > 0 时把该活动置 0。

+ 17 - 1
internal/academic/dualrun/stage2/c1_compare.go

@@ -186,7 +186,11 @@ func campusAccountCreatedAssertion(c Case, lg, tg *SideResult) Assertion {
 // signedAssertion 断言"调用后的报名状态 = 按 dump 判据算出的期望"(整张 map 全等)。
 func signedAssertion(c Case, lg, tg *SideResult) Assertion {
 	var want map[int64]int32
-	if c.Op == OpClearBalance {
+	// 「失效」类(把 status 置 4):清零、自定义退费 —— 两者 dump 里的那段 SQL **逐字相同**,
+	// 但**执行位置不同**:cleared_balance 在写流水之前、custom_refund 在写流水之后。
+	// 事务内顺序不同就不能因为 SQL 一样而认定风险一样,所以两支各验一次。
+	// 「取消」类(把 status 置 0):cancel_recharge_activity 只动指定的那一条。
+	if opExpiresActivities(c.Op) {
 		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)
@@ -203,6 +207,18 @@ func signedAssertion(c Case, lg, tg *SideResult) Assertion {
 	return Assertion{Name: name, Pass: ok, Detail: detail}
 }
 
+// opExpiresActivities 标记"该 Op 会把可失效报名置 4"(而不是置 0)。
+//
+//	sp_user_expense_school_cleared_balance     → 置 4,在**写流水之前**
+//	sp_user_expense_custom_refund              → 置 4,在**写流水之后**
+//	sp_user_expense_cancel_recharge_activity   → 置 **0**,只动指定的那一条
+//
+// 前两者那段 SQL 逐字相同(同一段复制粘贴),但执行位置不同 ⇒ 两支各验一次。
+// 这条判断只回答"置 4 还是置 0",不回答"哪些该置"—— 后者在 `ActivityPlan.expirable`。
+func opExpiresActivities(op Op) bool {
+	return op == OpClearBalance || op == OpCustomRefund
+}
+
 // spReturnAssertion 断言 SP 自己 SELECT 出来的结果集(这是 SP 契约的一部分)。
 func spReturnAssertion(c Case, lg *SideResult) Assertion {
 	if len(lg.Calls) == 0 || lg.Calls[0].Returned == nil {

+ 45 - 9
internal/academic/dualrun/stage2/c2_refund.go

@@ -445,15 +445,31 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		Note:   refundRemarkNote(c.Remark),
 	})
 
-	// ⑤ 活动失效:本批**没有种活动**,两侧快照都是空的 ⇒ 打 "—"。
-	//    如果不打 "—" 而是打 "✔",那就是一次标准的空过断言(0 == 0)。
-	diffs = append(diffs, Diff{
-		Item:   "活动失效(t_courses_activity_signed)",
-		Legacy: fmtSigned(lg.Before.Signed) + " → " + fmtSigned(lg.After.Signed),
-		Target: fmtSigned(tg.Before.Signed) + " → " + fmtSigned(tg.After.Signed),
-		Equal:  true, Skipped: true,
-		Note: "本批不种活动 ⇒ 快照为空,0 vs 0 **不构成验证**;SP9 的这段与 cleared_balance 逐字相同,已在 C1 覆盖",
-	})
+	// ⑤ 活动失效。
+	//
+	// SP9 里那段 `tmp_user_activity` + `UPDATE ... SET status=4` 与 cleared_balance **逐字相同**,
+	// 但**执行位置不同**:cleared_balance 在写流水**之前**、custom_refund 在写流水**之后**。
+	// 事务内顺序不同 ⇒ 不能因为 SQL 一样就认定风险一样(顺序错了会表现为"流水写了、报名没失效"
+	// 这种只在同一事务里才看得见的组合),所以 C2 单独种一批活动、单独验一次。
+	//
+	// 不种活动时(C2-1…C2-9)这一行打 "—":`{} == {}` 不是"验过",是"没验"。
+	if c.ActivityPlan != nil {
+		diffs = append(diffs, Diff{
+			Item:   "活动失效(status map 全等,不是「变了几个」)",
+			Legacy: fmtChanges(lg.Before.Signed, lg.After.Signed),
+			Target: fmtChanges(tg.Before.Signed, tg.After.Signed),
+			Equal:  sameSigned(lg.After.Signed, tg.After.Signed),
+			Note:   "两侧的**变化集合**必须完全相同;逐条期望见下面「活动失效范围」断言",
+		})
+	} else {
+		diffs = append(diffs, Diff{
+			Item:   "活动失效(t_courses_activity_signed)",
+			Legacy: fmtSigned(lg.Before.Signed) + " → " + fmtSigned(lg.After.Signed),
+			Target: fmtSigned(tg.Before.Signed) + " → " + fmtSigned(tg.After.Signed),
+			Equal:  true, Skipped: true,
+			Note: "本条 case 不种活动 ⇒ 快照为空,0 vs 0 **不构成验证**;带活动的那条见 C2-10",
+		})
+	}
 
 	// ---- 断言 ①:各侧前置校验的结论符合**各自**的期望 ----
 	asserts = append(asserts, Assertion{
@@ -586,6 +602,26 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		})
 	}
 
+	// ---- 断言 ⑥:活动侧(只有种了活动的 C2-10 才有)----
+	if c.ActivityPlan != nil {
+		// 先钉"两侧看到的校区元数据 = case 声明":自营/加盟的分流判据完全依赖它,
+		// 它错了会让"失效范围"这条断言红在一个看不出原因的地方。
+		asserts = append(asserts, campusMetaAssertion(c, lg, tg))
+		// 再钉"该失效的逐条都失效、不该动的一条都没动"(整张 map 全等,不是"变了几个")
+		asserts = append(asserts, signedAssertion(c, lg, tg))
+		// 最后把 `campus_id=0`(全机构池)单拎一条 —— 它是自营/加盟唯一的分支差别,
+		// 混在整张 map 里被别的活动一衬托就不显眼了。
+		if poolID, ok := c.ActivityPlan.poolActivityID(); ok {
+			asserts = append(asserts, Assertion{
+				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)",
+					poolID, lg.Before.Signed[poolID], lg.After.Signed[poolID],
+					tg.Before.Signed[poolID], tg.After.Signed[poolID]),
+			})
+		}
+	}
+
 	// ---- 断言 ⑦:判定行(**不是** PASS/FAIL,是结论登记)----
 	same := lgExp.Rejected == tgExp.Rejected && lgExp.Money == tgExp.Money &&
 		lgExp.BadDebt == tgExp.BadDebt && len(lgExp.Logs) == len(tgExp.Logs)

+ 84 - 5
internal/academic/dualrun/stage2/c2_refund_test.go

@@ -294,12 +294,12 @@ func TestCustomRefundCaseSet(t *testing.T) {
 			t.Errorf("E 探针(%q)不该出现在 DefaultCases 里", c.Name)
 		}
 	}
-	// 25(A/B/C1 期)+ 9(C2:C2-1、C2-1b、C2-2…C2-6、C2-8、C2-9)
-	if len(all) != 34 {
-		t.Errorf("DefaultCases 长度应为 34,实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
+	// 25(A/B/C1 期)+ 10(C2:C2-1、C2-1b、C2-2…C2-6、C2-8、C2-9、C2-10)
+	if len(all) != 35 {
+		t.Errorf("DefaultCases 长度应为 35,实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
 	}
-	if c2 != 9 {
-		t.Errorf("C2 应恰好 9 条(E 探针不在此列),实得 %d", c2)
+	if c2 != 10 {
+		t.Errorf("C2 应恰好 10 条(E 探针不在此列),实得 %d", c2)
 	}
 	probes := ProbeCases()
 	if len(probes) != 1 || probes[0].SpecialCase != SpecialNullBalanceReject {
@@ -328,6 +328,85 @@ func TestCustomRefundCaseUserBalancesReal(t *testing.T) {
 	}
 }
 
+// TestEnsureNotAllSkipped 锁住"Skipped 不许把整条 case 掏空"这道闸。
+//
+// 背景:`FailCount` 跳过 Skipped 之后,"全部断言都是 Skipped"会变成 0 FAIL 的 PASS ——
+// **分母收缩的又一个变种**:分母没变,但分子全是空的。这条闸就是让它变成非 0 退出码。
+func TestEnsureNotAllSkipped(t *testing.T) {
+	cases := []struct {
+		name    string
+		r       CaseResult
+		wantErr bool
+	}{
+		{
+			name: "有一条真断言 ⇒ 放行",
+			r: CaseResult{Case: Case{Name: "x"}, Assertions: []Assertion{
+				{Name: "真", Pass: true}, {Name: "空", Pass: true, Skipped: true},
+			}},
+		},
+		{
+			name: "断言全 Skipped ⇒ 报错(哪怕 Pass 全是 true)",
+			r: CaseResult{Case: Case{Name: "x"}, Assertions: []Assertion{
+				{Name: "a", Pass: true, Skipped: true},
+				{Name: "b", Pass: true, Skipped: true},
+			}},
+			wantErr: true,
+		},
+		{
+			name:    "一条断言都没有 ⇒ 报错",
+			r:       CaseResult{Case: Case{Name: "x"}},
+			wantErr: true,
+		},
+		{
+			name: "断言全 Skipped,但 Diff 里有非 Skipped 的比对 ⇒ **仍然报错**",
+			r: CaseResult{Case: Case{Name: "x"},
+				Diffs:      []Diff{{Item: "基线", Equal: true}},
+				Assertions: []Assertion{{Name: "a", Pass: true, Skipped: true}}},
+			wantErr: true,
+		},
+	}
+	for _, c := range cases {
+		err := c.r.EnsureNotAllSkipped()
+		if c.wantErr && err == nil {
+			t.Errorf("%s:应当报错,实得 nil", c.name)
+		}
+		if !c.wantErr && err != nil {
+			t.Errorf("%s:不应报错,实得 %v", c.name, err)
+		}
+		if !c.wantErr && c.r.SkippedCount() == 0 && len(c.r.Assertions) > 0 {
+			// 这条只是提醒:SkippedCount 要能把 Skipped 数出来(报数第七个数靠它)
+			t.Logf("%s:SkippedCount=%d(本例无 Skipped,正常)", c.name, c.r.SkippedCount())
+		}
+	}
+}
+
+// TestSkipCountAndFailCountDisjoint 锁住"Skipped 既不进 FAIL、也不进 PASS 的分子"。
+func TestSkipCountAndFailCountDisjoint(t *testing.T) {
+	r := &CaseResult{
+		Case: Case{Name: "x"},
+		Diffs: []Diff{
+			{Item: "真通过", Equal: true},
+			{Item: "结构性不适用", Equal: true, Skipped: true},
+		},
+		Assertions: []Assertion{
+			{Name: "真通过", Pass: true},
+			{Name: "不适用(Pass 为 false 也不该算失败)", Pass: false, Skipped: true},
+		},
+	}
+	if got := r.FailCount(); got != 0 {
+		t.Errorf("FailCount 应为 0(Skipped 不计入),实得 %d", got)
+	}
+	if got := r.SkippedCount(); got != 2 {
+		t.Errorf("SkippedCount 应为 2,实得 %d", got)
+	}
+	if got := r.LiveAssertionCount(); got != 1 {
+		t.Errorf("LiveAssertionCount 应为 1,实得 %d", got)
+	}
+	if err := r.EnsureNotAllSkipped(); err != nil {
+		t.Errorf("有一条真断言,不该报错:%v", err)
+	}
+}
+
 // assertRefundExpectEqual 逐字段比两份期望(切片要逐元素比,不能只比长度)。
 func assertRefundExpectEqual(t *testing.T, name string, got, want refundExpect) {
 	t.Helper()

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

@@ -491,6 +491,65 @@ func (r *CaseResult) FailCount() int {
 // OK 表示该 case 通过。
 func (r *CaseResult) OK() bool { return r.FailCount() == 0 }
 
+// SkippedCount 统计结构性不适用的条数(Diff + Assertion)。
+//
+// 为什么必须单独报出来:`FailCount` 跳过 Skipped 之后,"**一条 case 的全部断言都是 Skipped**"
+// 会变成 0 FAIL 的 PASS —— **这是分母收缩的又一个变种**:分母没变,但分子全是空的。
+// 所以报数时 Skipped 要跟着一起报(第七个数),并由 `EnsureNotAllSkipped` 兜底。
+func (r *CaseResult) SkippedCount() int {
+	n := 0
+	for _, d := range r.Diffs {
+		if d.Skipped {
+			n++
+		}
+	}
+	for _, a := range r.Assertions {
+		if a.Skipped {
+			n++
+		}
+	}
+	return n
+}
+
+// LiveAssertionCount 返回**非 Skipped 的断言条数**。
+//
+// 只看断言、不看 Diff:Diff 全 ✔ 而断言全 Skipped 的 case 看起来"比过了",
+// 实际一条语义断言都没做(Diff 的 Equal 可能只是 `lgB == tgB` 这种基线前提)。
+func (r *CaseResult) LiveAssertionCount() int {
+	n := 0
+	for _, a := range r.Assertions {
+		if !a.Skipped {
+			n++
+		}
+	}
+	return n
+}
+
+// EnsureNotAllSkipped 是"Skipped 不许把整条 case 掏空"的闸。
+//
+// 触发条件:断言**一条都没有**,或**全部**都是 Skipped。
+//
+// 报错而不是记 FAIL,理由:这不是"行为不对",是"这条 case 什么都没测"——
+// 与 `-case` 匹配不到、分母收缩同级,属于**测试基础设施失效**,
+// 必须以非 0 退出码冒出来(记 FAIL 会被当成"待修的红"排进队列,而它根本不该被容忍)。
+//
+// 一个例外:一条 case 若有 0 条断言却有非 Skipped 的 Diff,仍然放行 ——
+// 那种 case 的判定完全由 Diff 的 Equal 承担(例如 C1-5b 的 3 位 detail 行)。
+// 但**断言全 Skipped 时一律报错**,因为 `FailCount` 此时只剩 Diff 在兜底,
+// 而 Diff 里那些 `Equal: true` 的基线行会把结论染绿。
+func (r *CaseResult) EnsureNotAllSkipped() error {
+	live := r.LiveAssertionCount()
+	if live > 0 {
+		return nil
+	}
+	allSkipped := len(r.Assertions) > 0
+	return fmt.Errorf(
+		"case %q 没有一条**非 Skipped** 的断言(断言共 %d 条,全部 Skipped=%v)—— "+
+			"Skipped 不计入 FailCount,这条 case 会以 0 FAIL 通过,但它什么都没测;"+
+			"请检查构型是否把所有断言都变成了结构性不适用",
+		r.Case.Name, len(r.Assertions), allSkipped)
+}
+
 // toleranceCents 是 Δ 比对的容差。
 //
 // **必须为 0**:迁移口径把 float 3 位收敛到 2 位,但每个 case 的 Δ 是"同一笔金额在两侧的增量",

+ 67 - 5
internal/academic/dualrun/stage2/run.go

@@ -394,6 +394,35 @@ func DefaultCases(_ string) []Case {
 			Note: "target 第 2 次 `repeated=true`、只扣 1 次;legacy SP **没有幂等键** ⇒ 真的扣 2 次(600.00、2 条 207)。" +
 				"两侧金额本来就不该相等,按 per-side 断言处理,不为消红放宽判据",
 		},
+		Case{
+			// 活动失效:SP9 里那段 `tmp_user_activity` + `UPDATE ... SET status=4` 与 cleared_balance
+			// **逐字相同**,但执行位置不同 —— 清零在写流水**之前**、退费在写流水**之后**。
+			// 事务内顺序不同,就不能因为 SQL 一样而认定风险一样,所以单独种一批活动验一次。
+			//
+			// 选人(真实构型,未造数):53708,campus 5 自营、校区余额 **4950.000(整数)**、
+			// 旧库里该用户**没有别的**可失效报名(已核 n_exp=0)、有 49 条历史流水。
+			// 整数余额是关键:两侧才会走**同一条**分支(坏账分支、且 `_badDate` 也是整数),
+			// 于是这条同时是**第二条能求一致的 case**,比 per-side 断言的信息量大。
+			//
+			// 退 5300.00 > 4950.00 ⇒ legacy `_balance=4950`、`_badDate=round0(350)=350`、
+			// 扣 4950、坏账 350;target `refunded=4950`、`badDebt=350` —— 逐分相同。
+			//
+			// ⚠ 关于"真实报名":target 侧活动域**是空的**(实测 `t_courses_activity`=0、
+			// `t_courses_activity_signed`=0,legacy 侧 90 / 1288),所以**任何真实报名都只有
+			// legacy 有**、target 看不到。要两侧看到同一批活动,只能像 C1 那样在事务内种
+			// (`C1ActivityPlan`),并用"旧库没有别的可失效报名"这条前置断言钉住有效性。
+			// 也就是说:**资金侧是真实构型,活动侧必然是种子** —— 这是活动域未迁移的必然结果,
+			// 不是偷懒。有人若想改成"用真实报名",得先迁活动域。
+			Name: "C2-10 自定义退费·活动失效(整数余额 + 自营含全机构池,两侧一致)",
+			Op:   OpCustomRefund, LegacyUserID: 53708, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 530000, RequestID: "ac15-c2-10", RunTimes: 1, SelfSupport: true,
+			Remark:              "C2 活动失效",
+			Activity:            &ActivitySeed{},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "余额 4950.00、退 5300.00:两侧都走坏账分支且金额都是整数 ⇒ **逐分一致**(108=+350 / 207=−5300)。" +
+				"活动侧按 dump 判据失效:本校区(5) 与 **全机构池(campus_id=0)** 都应置 4," +
+				"其他校区(4)/其他机构/type=3/已过期 一条都不许动 —— 整张 status map 全等比对,不是「变了几个」",
+		},
 	)
 
 	// ---- 消费(第二批已做,留作回归)----
@@ -542,7 +571,7 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 	// 而 target 侧没有那些活动(活动域还没迁移)—— 两侧失效集合天然不同。
 	// 那种差异是"数据没搬全",不是"SP 行为不对"。与其让它以一个看不懂的差异出现,
 	// 不如在 case 开始就断言它是 0(和"两侧基线逐分相等"同一性质的前置断言)。
-	if c.ActivityPlan != nil && c.Op == OpClearBalance {
+	if c.ActivityPlan != nil && opMovesActivityStatus(c.Op) {
 		n, err := countOtherExpirableLegacy(ctx, legacyDB, c.LegacyUserID,
 			c.InstitutionID, c.CampusID, c.SelfSupport, c.ActivityPlan.ActivityIDs())
 		if err != nil {
@@ -567,11 +596,41 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 			Name: "回滚后零残留(两侧都回到调用前状态)", Pass: true, Detail: res.Residue.Detail,
 		})
 	}
+
+	// ---- 闸:Skipped 不许把整条 case 掏空(见 CaseResult.EnsureNotAllSkipped)----
+	//
+	// 放在**最后**:前面所有断言(含残留检查)都已生成,此时才能准确判断"还有没有真断言"。
+	// 放在 compare 之后但在残留检查之前是错的位置 —— 残留那条本身也是一条真断言,
+	// 提前判断会把"只有残留断言"的 case 误判成空。
+	if err := res.EnsureNotAllSkipped(); err != nil {
+		return nil, err
+	}
 	return res, nil
 }
 
-// needsCampusMeta 标记"该 Op 会跨域读校区元数据"(清零判自营 / 取消活动反查机构)。
-func needsCampusMeta(op Op) bool { return op == OpClearBalance || op == OpCancelRechargeActivity }
+// needsCampusMeta 标记"该 Op 会跨域读校区元数据"(清零判自营 / 取消活动反查机构 / 退费判失效范围)。
+//
+// `sp_user_expense_custom_refund` 的正文里也有
+// `SELECT (SELECT self_support FROM yuansiwei.t_school_campus WHERE id=inputCampusid) INTO campusSelfSupport`,
+// 之后按自营/加盟分流失效范围 —— 与 cleared_balance 逐字相同。所以它也要读、也要断言。
+func needsCampusMeta(op Op) bool {
+	return op == OpClearBalance || op == OpCancelRechargeActivity || op == OpCustomRefund
+}
+
+// opMovesActivityStatus 标记"该 Op 会改 t_courses_activity_signed.status"。
+//
+// 与 `opExpiresActivities`(置 4 还是置 0)是两个问题,别合并:
+//   - 本函数回答"要不要做活动域的前置有效性检查"(有就会去查旧库有没有别的可失效报名);
+//   - `opExpiresActivities` 回答"期望的终态是 4 还是由 ActivityID 指定"。
+//
+// ⚠ 这里**故意没有**包含 `OpCancelRechargeActivity`:它的失效判据是
+// `status != 4 AND activityid = inputActivityid`(只动指定的那一条),
+// 与"按机构/校区扫一批"是两条不同的 SQL;把它并进来会让 C1-6…C1-11 六条
+// 突然多出一条从未被审视过的前置断言(而且那六条当前是绿的,改了就是**扩大改动面**)。
+// 要加也应该单开一条 commit 并重跑 C1 —— 不在 C2 的范围里。
+func opMovesActivityStatus(op Op) bool {
+	return op == OpClearBalance || op == OpCustomRefund
+}
 
 // opUsesRequestID 标记"该 Op 对应的 SP 有请求号入参"。
 //
@@ -1342,21 +1401,24 @@ func PrintResult(w io.Writer, r *CaseResult) {
 
 // Summarize 打印总体结论。
 func Summarize(w io.Writer, results []*CaseResult) bool {
-	total, pass := 0, 0
+	total, pass, skipped := 0, 0, 0
 	for _, r := range results {
 		total++
 		if r.OK() {
 			pass++
 		}
+		skipped += r.SkippedCount()
 	}
 	fmt.Fprintf(w, "══════════════════════════════════════════════════════════════\n")
 	fmt.Fprintf(w, "总览:%d 个 case,%d PASS,%d FAIL\n", total, pass, total-pass)
+	// Skipped 是**第七个数**:`FailCount` 跳过它之后,"这条 case 是不是什么都没测"只能靠它看出来。
+	fmt.Fprintf(w, "Skipped:%d 项(结构性不适用,不计入 FAIL;每条 case 仍须有 ≥1 条真断言,否则 FATAL)\n", skipped)
 	for _, r := range results {
 		st := "PASS"
 		if !r.OK() {
 			st = "FAIL"
 		}
-		fmt.Fprintf(w, "  %s  %-40s 不一致 %d\n", st, r.Case.Name, r.FailCount())
+		fmt.Fprintf(w, "  %s  %-40s 不一致 %d  Skipped %d\n", st, r.Case.Name, r.FailCount(), r.SkippedCount())
 	}
 	return pass == total
 }