瀏覽代碼

feat(ac15): 批 C2 自定义退费 SP9 双跑(9 条,25→34)+ 三处结构性修正

分类:LEGACY-DEFECT / VARIABLE-ROUNDING(旧 SP 的 DECLARE 变量把金额整数化两次)。

一、C2 的比对形态改成 per-side + 共同不变量
dont-cmp 的是"两侧之间",每一侧自己仍要断言不变量。共同不变量两条:
`207.money == −inputMoney`、`Δtotal_income == Δbalance` —— 它们证明"结构性复刻对了,
只是舍入不同";否则"结果不同"分不清是舍入还是我们没复刻那套结构。
legacy 专属:`108.money == round0(inputMoney − round0(balance_before))`、坏账分支有 108。
target 专属:`refunded + badDebt == inputMoney`、`refunded == min(inputMoney, balance)`、
`balance_after >= 0`(全部用实测 Δ,不用 Service 返回值)。

二、修一个按直觉写就会错的期望
dump 是两个独立的 IF,不是 if/else:`inputMoney > _balance` 成立但差额不足 0.5 时
`_badDate` 收敛成 0,于是**仍然走 ELSE、扣 inputMoney 而不是 _money**。
锁在 TestLegacyRefundExpectBadDateRoundsToZero。

三、Assertion 增加第三态 Skipped
两侧都被拒 ⇒ 没有 207 可查;走 ELSE ⇒ 没有 108 可查。照常打 ✔ 就是 `0 == 0` 的空过。
Skipped 打 "—" 且不计入 FailCount。⚠ 置错等于让真实失败静默消失,所以只在
"从 case 构造就能看出结构不适用"时使用。

四、修连接泄漏(34 条 case 才暴露)
RunOne 每个 case 打开 5 个 gorm 句柄、一个都没关:25 条 = 125 连接(压在边缘),
34 条 = 170 连接 → Error 1040 Too many connections 整批中断。报错在第 N 条、根因在第 1 条,
按"分批跑"处置会把泄漏永久掩埋。改为每处 defer closeDB。

五、E 探针不进 DefaultCases
C2-7 的第二侧不同构(target 列 not null default 0.00,NULL 存不下),
"两侧都拒绝"那个绿的含金量是 0。新增 `stage2 -probe`,分母单独标 ProbeCases。

存量影响面(t_user_expense_school, balance>0, 566 账户):
  拒绝退费 166 个 / ¥18.177;多扣成负数 95 个 / ¥21.155(其中 7 个是 3→2 位收敛已吸收,
  纯属变量类型 88 个 / ¥21.130);少扣留零头 37 个 / ¥6.986;整数余额 268 个无差异。

自检:go build ./... / go vet / go test ./... 全绿;sentinel 两列 PASS(非零样本 154/783)。
Q 2 小時之前
父節點
當前提交
e3ab052f9f

+ 14 - 3
cmd/dualrun/main.go

@@ -182,7 +182,8 @@ func runSentinel(args []string) int {
 func runStage2(args []string) int {
 	fs := flag.NewFlagSet("stage2", flag.ContinueOnError)
 	fs.SetOutput(os.Stderr)
-	only := fs.String("case", "", "只跑指定 case(按名字子串匹配)")
+	only := fs.String("case", "", "只跑指定 case(按名字首段精确匹配,不是子串)")
+	probe := fs.Bool("probe", false, "跑**探针**(非同构 case,不进 DefaultCases、不构成验收)")
 	if err := fs.Parse(args); err != nil {
 		return 2
 	}
@@ -204,12 +205,19 @@ func runStage2(args []string) int {
 
 	ctx := context.Background()
 	all := stage2.DefaultCases("")
+	source := "DefaultCases"
+	if *probe {
+		// 探针:非同构 case(第二侧喂不到同构输入)⇒ **不进分母、不算验收**。
+		// 见 stage2.ProbeCases 的注释。
+		all = stage2.ProbeCases()
+		source = "ProbeCases"
+	}
 
 	// 检查 ①:case 列表不能为空。
 	// 防"case 列表被清空 / build tag 没生效 / DefaultCases 被改坏" —— 那种情况下
 	// `Summarize` 会返回 `0 == 0 = true`,跑了个寂寞但退出 0。
 	if len(all) == 0 {
-		fmt.Fprintf(os.Stderr, "FATAL: DefaultCases 长度为 0,没有任何 case 可跑\n")
+		fmt.Fprintf(os.Stderr, "FATAL: %s 长度为 0,没有任何 case 可跑\n", source)
 		return 3
 	}
 
@@ -253,10 +261,13 @@ func runStage2(args []string) int {
 		return 3
 	}
 
-	fmt.Printf("分母:DefaultCases=%d | 本次应跑=%d | 实跑=%d\n", len(all), matched, len(results))
+	fmt.Printf("分母:%s=%d | 本次应跑=%d | 实跑=%d\n", source, len(all), matched, len(results))
 	if *only != "" {
 		fmt.Printf("⚠ 定向跑:仅 1 条,**不构成验收** —— 验收必须全量跑(不带 -case)\n")
 	}
+	if *probe {
+		fmt.Printf("⚠ 探针模式:%s 里的 case **非同构**(第二侧喂不到同构输入),只登记事实、不算验收\n", source)
+	}
 	fmt.Println()
 
 	if ok := stage2.Summarize(os.Stdout, results); !ok {

+ 125 - 0
internal/academic/dualrun/stage2/README.md

@@ -780,3 +780,128 @@ go run ./cmd/dualrun sentinel -db yuansiwei_2026
 
 现在改成 SQL 里 `CAST(self_support AS UNSIGNED)` 扫 `int64`,让类型系统表达,注释改成实测事实。
 改完的回归点:C1-1 必须仍显示 `自营=true`、C1-2 仍显示 `自营=false`。
+
+## 13. 批 C2:`DECLARE` 变量会把金额**收敛两次**(SP9 自定义退费)
+
+`sp_user_expense_custom_refund` 的正文里有三个变量:
+
+```sql
+DECLARE _money   DECIMAL DEFAULT 0;
+DECLARE _badDate decimal DEFAULT 0;
+DECLARE _balance DECIMAL DEFAULT 0;
+```
+
+`DECIMAL` **不带精度参数时是 `DECIMAL(10,0)`**,即整数。于是 legacy 把金额整数化了**两次**:
+
+```
+_balance ← t_user_expense_school.balance     第一层:0.280→0、475.376→475、12.500→13
+_badDate ← inputMoney − _balance             第二层:1.50 − 1 = 0.5 → 1
+_money   ← _balance
+```
+
+`target`(`RefundService.CustomRefund`)用**精确十进制约**:`refunded = min(requested, balance)`、
+`badDebt = requested − refunded`。只要余额带小数,两侧就是**两个不同的算法**。
+
+### 13.1 归类:`LEGACY-DEFECT / VARIABLE-ROUNDING`
+
+| 候选名 | 采纳? | 理由 |
+|---|---|---|
+| `PRECISION-CONVERGENCE` | ✗ | 那是"迁移把 3 位收敛成 2 位",成因在迁移。这里在旧库原样跑就错。 |
+| `PRECISION-CONVERGENCE / PREDICATE-COLLAPSE` | ✗ | 那个专指"判据输入被抹平 ⇒ 分支不同"。这里的成因是**变量类型**,且坏账/流水金额也跟着变。 |
+| `DECIMAL-TRUNCATION` | ✗ | `CAST(0.5)=1`、`CAST(2.5)=3` 是**四舍五入**不是截断(截断给 0 / 2)。**名字与实测语义不一致**,下一个人按名字判断会做错 —— 命名本身就是判据。 |
+| **`LEGACY-DEFECT / VARIABLE-ROUNDING`** | ✓ | 顶层类 = 成因(变量声明缺精度 ⇒ 赋值即整数化);subtype = 可观测形态(逐次舍入,不是一次性)。 |
+
+### 13.2 存量影响面(`t_user_expense_school`,`balance > 0`,共 **566** 个账户)
+
+| 形态 | 账户数 | 金额 | 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)` 的过滤,就是为了不让这两种成因互相污染。
+
+### 13.3 断言形态:**per-side + 共同不变量**
+
+「不比对」的是**两侧之间**;**每一侧自己仍要断言不变量**。否则这一支就只剩打印数值,与空过断言同源。
+
+| 不变量 | legacy | target |
+|---|:--:|:--:|
+| `207.money == −inputMoney` | ✔ | ✔ |
+| `Δtotal_income == Δbalance` | ✔ | ✔ |
+| `108.money == round0(inputMoney − round0(balance_before))` | ✔ | — |
+| 坏账分支写 108、ELSE 分支不写 | ✔ | — |
+| `refunded + badDebt == inputMoney`(实测 `−Δbal + Δspec`) | — | ✔ |
+| `refunded == min(inputMoney, balance_before)` | — | ✔ |
+| `balance_after >= 0` | — | ✔ |
+
+前两条两侧都成立 —— 它们证明的是「**结构性复刻对了,只是舍入不同**」。没有它们,
+"两侧结果不同"就分不清是"舍入"还是"我们根本没复刻那套结构"。
+
+### 13.4 一个必须按 SQL 直读、不能按直觉写的分支
+
+dump 是**两个独立的 `IF`**,不是 if/else:
+
+```sql
+IF(inputMoney > _balance) THEN SET _badDate = inputMoney - _balance; SET _money = _balance; END IF;
+IF(_badDate > 0) THEN ... ELSE UPDATE ... `balance` - inputMoney ... END IF;
+```
+
+所以 `inputMoney > _balance` 成立、但差额不足 0.5 时,`_badDate` 收敛成 0 ⇒ **仍然走 ELSE、
+扣的是 `inputMoney` 而不是 `_money`**。按 if/else 的直觉写,会期望扣 1.00 而实测扣 1.30。
+锁在 `TestLegacyRefundExpectBadDateRoundsToZero`。
+
+### 13.5 case 与分母
+
+`DefaultCases` **25 → 34**(C2 共 9 条):
+
+| case | 余额 | 请求 | 结论 |
+|---|---:|---:|---|
+| C2-1 | 9740.00 | 300.00 | 两侧**一致**(整数余额,ELSE) |
+| C2-1b | 6800.00 | 7000.00 | 两侧**一致**(整数余额,坏账分支) |
+| C2-2 | 0.28 | 0.28 | A 象限:legacy 拒绝 / target 退得出来 |
+| C2-3 | 0.832 | 1.50 | B 象限:`_badDate` 第二次整数化(108 = **+1.00**,不是 +0.67) |
+| C2-4 | 475.376 | 600.00 | C 象限:legacy 留正零头 0.376 |
+| C2-5 | **12.500** | 20.00 | D 象限:**半进远离零的唯一哨兵**(13 而不是 12) |
+| 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-7(E)不在其中**,只在 `dualrun stage2 -probe` 里跑:它靠"legacy 侧余额置 NULL"构造,
+而 target 的列是 `not null default 0.00`、**NULL 存不下**,所以第二侧**不同构**。
+"两侧都拒绝"这个绿的含金量是 0(换任何一条余额为 0 的记录都能绿),算进分母等于用必然绿的 case 抬通过率。
+
+## 14. `Assertion.Skipped`:断言的第三态
+
+`Diff.Skipped` 打 `—`(§ 里已述)之后,**断言**也需要同一个第三态,否则会出现
+"`0 == 0` 打 ✔"的空过:
+
+- 两侧都被前置校验拒绝 ⇒ `207.money == −inputMoney` **无从验证**(根本没有 207);
+- 走的是 ELSE 分支 ⇒ `108.money == round0(...)` **无从验证**(根本没有 108)。
+
+现在 `Assertion` 也有 `Skipped`,报告打 `—`,且 `FailCount` **不计入**。
+
+⚠ 这是**必须由构造方负责**的地方:把 `Skipped` 置错 = 让真实失败静默消失。
+所以每个 `Skipped` 的产生条件都必须是"从 case 的构造就能看出结构上不适用",
+**不能**是"实测值和我预期的不一样,所以跳过"。
+
+## 15. 一次真实的连接泄漏(34 条 case 才暴露)
+
+`RunOne` 原来每个 case 打开 **5 个** gorm 句柄(legacy / target / 天然基线 / 残留检查 ×2),
+**一个都没关**。`cfg.open` 每次新建 `*sql.DB`(`MaxOpenConns=1`),连接要等 GC finalizer 才释放:
+
+```
+25 条 case(批 C1)= 125 个连接 → 压在 max_connections 边缘,没露头
+34 条 case(批 C2)= 170 个连接 → Error 1040 (08004): Too many connections,整批中断
+```
+
+⚠ **报错发生在第 N 条,根因却在第 1 条**。如果按"case 太多、要分批跑"处置,
+这个泄漏就被永久掩埋了:以后每加一条 case 都要重新踩一次,而且每次都看起来像"量的刻度没选好"。
+
+修法是给每个打开点加 `defer closeDB(...)`,并在 `closeDB` 的注释里记下这个坑。

+ 704 - 0
internal/academic/dualrun/stage2/c2_refund.go

@@ -0,0 +1,704 @@
+package stage2
+
+import (
+	"fmt"
+	"math"
+	"strings"
+)
+
+// 本文件是批 C2:`sp_user_expense_custom_refund`(SP9,7 参、**带 schoolid**、自带结果集)的双跑比对。
+//
+// # 一、这一支为什么不能"两侧互相比 Δ"
+//
+// SP9 正文里有三个 `DECLARE ... DECIMAL DEFAULT 0` 的变量 —— DECIMAL 不带精度参数时是
+// **DECIMAL(10,0)**,即**整数**。于是 legacy 把金额整数化了**两次**:
+//
+//	_balance ← t_user_expense_school.balance   ← 第一层(0.280 → 0、475.376 → 475、12.500 → 13)
+//	_badDate ← inputMoney - _balance           ← **第二层**(1.50 − 1 = 0.5 → 1)
+//	_money   ← _balance
+//
+// 而 target(`RefundService.CustomRefund`)用**精确十进制约**:
+//
+//	refunded = min(requested, balance);badDebt = requested - refunded
+//
+// ⇒ 只要余额带小数,两侧就是**两个不同的算法**:扣减额、坏账额、流水金额都可能不同,
+// 甚至**分支都可能不同**(缝区间:`inputMoney ∈ (真实余额, 整数化余额]`,legacy 判
+// `inputMoney > _balance` 为假走 ELSE、target 走坏账分支)。
+//
+// # 二、归类:LEGACY-DEFECT / VARIABLE-ROUNDING
+//
+//   - 顶层类 = **成因**:`DECLARE 变量声明时没写精度` ⇒ 赋值即按整数四舍五入。
+//     这是**旧系统自身的缺陷**,在旧库上原样跑就错,与迁移无关;
+//   - 不是 `PRECISION-CONVERGENCE`:那是"迁移把 3 位收敛成 2 位"造成的差异,成因在迁移;
+//   - 不是 `PREDICATE-COLLAPSE`(`PRECISION-CONVERGENCE` 的 subtype):那个专指
+//     "判据的输入被抹平 ⇒ 分支不同",这里的成因是**变量类型**,而且 badDate / 流水金额
+//     也跟着变,不是单纯的分支塌陷;
+//   - 也不是 `DECIMAL-TRUNCATION`:`CAST(0.5)=1`、`CAST(2.5)=3` 是**四舍五入**不是截断
+//     (截断会得 0 / 2)。命名必须与实测语义一致,否则下一个人按名字做判断会做错。
+//
+// # 三、断言形态:per-side + 共同不变量
+//
+// "两侧之间不比对" 只对 **Δ 金额**成立;**每一侧自己仍要断言不变量**:
+//
+//	不变量                                                legacy        target
+//	207.money == −inputMoney                                ✔             ✔
+//	Δtotal_income == Δbalance                               ✔             ✔
+//	108.money == round0(inputMoney − round0(balance))       ✔             —
+//	坏账分支写 108、ELSE 分支不写                             ✔             —
+//	refunded + badDebt == inputMoney                        —             ✔
+//	refunded == min(inputMoney, balance)                    —             ✔
+//	balance_after >= 0                                      —             ✔
+//
+// ★ 这张表是"**不比对的是两侧之间,但每一侧自己仍要断言**"的落地:
+//   如果因为"两侧本来就不一样"就把断言整体删掉,那这一支就成了**只打印数值、不做判定**,
+//   与"空过断言"是同一个后果。前两条两侧都成立,恰好证明**结构性复刻是对的,只是舍入不同**。
+
+// C2VerdictClass 是本批的归类标签(报告与台账共用一份文案,避免两处不一致)。
+const C2VerdictClass = "LEGACY-DEFECT / VARIABLE-ROUNDING"
+
+// 一次调用走到的分支(legacy 的 IF 链 / target 的校验顺序)。
+const (
+	// refundBranchReject 前置校验拒绝(legacy `ISNULL(_balance) OR _balance<=0`;target `balance<=0`)。
+	refundBranchReject = "reject"
+	// refundBranchBadDebt `_badDate>0` ⇒ 扣 `_money`、special_expenses 累加坏账、写 108。
+	refundBranchBadDebt = "baddebt"
+	// refundBranchElse 扣**原始 inputMoney**、不写 108。
+	//
+	// ⚠ 有两种情形会落到这里,不只是 "inputMoney <= _balance":
+	//   ① `inputMoney <= _balance`;
+	//   ② `inputMoney > _balance` 但 `round0(inputMoney − _balance) == 0`(差额不足 0.5),
+	//      此时 `_badDate = 0`,`IF(_badDate>0)` 为假 ⇒ **仍然走 ELSE、扣的是 inputMoney**。
+	//   漏掉 ② 会把期望算成"扣 _balance",实测扣的是 inputMoney,凭空制造一条假红。
+	refundBranchElse = "else"
+)
+
+// refundExpect 是一侧对「自定义退费」的完整期望(金额单位:分)。
+type refundExpect struct {
+	// Rejected 是否被前置校验拒绝(拒绝时下面金额字段全为 0、流水为空)。
+	Rejected bool
+	// Branches 是**逐次调用**走到的分支,长度 = 调用次数。
+	Branches []string
+	// Money 是实际从 balance 里扣掉的金额之和:legacy 是整数化的 `_money`(ELSE 分支为 inputMoney),
+	// target 是 `refunded`。
+	Money int64
+	// BadDebt 是累加到 special_expenses 的坏账之和:legacy 是整数化的 `_badDate`,target 是精确差额。
+	BadDebt int64
+	// Logs 是新增流水(按插入顺序,**同一侧内**有序;两侧之间不比较顺序,见 checkRefundLogs)。
+	Logs []refundLogExpect
+}
+
+// refundLogExpect 是一条流水的期望。
+type refundLogExpect struct {
+	Type   int32
+	Money  int64
+	Remark string
+}
+
+// round0Cents 把"分"按 **元级四舍五入(半进远离零)** 收敛成整数元,再换回分。
+//
+// 这是 `DECIMAL(10,0)` 赋值 / 比较的语义,已在本机实测:
+//
+//	CAST(0.4)=0   CAST(0.5)=1   CAST(2.5)=3   CAST(-0.5)=-1
+//
+// 用**整数运算**实现,不经过 float:`.xx5` 在二进制浮点里不是精确值,
+// 阶段 1 已经因为 `ROUND(float,2)` 与 `ROUND(CAST(float AS DECIMAL),2)` 不同栽过一次。
+//
+// ⚠ 必须是"半进远离零",不是 Go 的 `math.RoundToEven`(半进取偶):
+//   12.500 在两者下分别是 13 / 12,C2-5 就是这条的唯一哨兵。
+func round0Cents(cents int64) int64 {
+	q := cents / 100
+	r := cents % 100
+	switch {
+	case r*2 >= 100:
+		q++
+	case r*2 <= -100:
+		q--
+	}
+	return q * 100
+}
+
+// mathRound0Cents 是 round0Cents 的"用 math.Round 表达同一语义"版本,**只给单测做交叉校验**。
+//
+// 生产路径一律用 round0Cents:整数运算无精度损失,且不依赖 float64 的舍入模式。
+func mathRound0Cents(cents int64) int64 {
+	return int64(math.Round(float64(cents)/100.0)) * 100
+}
+
+// refundRemark 生成两侧的备注(dump 逐字)。
+//
+//	dump:`IF inputRemark='' THEN SET inputRemark='余额退费'; END IF;`
+//	     坏账分支 `_balanceRemark = CONCAT(inputRemark,'<br/>退费的金额大于可退费部分,超出部分为坏账部分。')`
+//
+// ⚠ 后缀是 **108 独有**:207 的备注只有 inputRemark 本身。
+//   把后缀也拼到 207 上会立刻红 —— 这正是这条断言存在的意义。
+//   `<br/>` 原样保留(前端按 HTML 渲染),**不做任何转义或替换**。
+func refundRemark(inputRemark string) (remark207, remark108 string) {
+	remark207 = inputRemark
+	if strings.TrimSpace(inputRemark) == "" {
+		remark207 = "余额退费"
+	}
+	return remark207, remark207 + "<br/>退费的金额大于可退费部分,超出部分为坏账部分。"
+}
+
+// ⚠ 一处**已知但本批不覆盖**的细差(写在这里免得被当成"已验证"):
+//
+//	dump  SP9  :`IF inputRemark='' THEN SET inputRemark='余额退费'`   —— 只认**空串**
+//	target     :`strings.TrimSpace(in.Remark) == ""`                   —— 认**全空白**
+//
+// 所以 `inputRemark='   '` 时 legacy 会把三个空格原样写进流水、target 会换成"余额退费"。
+// C2 里没有这条 case:本批 9 条要用真实账户,而"空白备注"不是真实构型。
+// 若将来要补,它属于 **INTENTIONAL-WIDEN**(target 的判据更宽),不是缺陷,别混进 C2VerdictClass。
+
+// legacyRefundExpect 按 **legacy 的两次整数化算法** 算期望。
+//
+// 逐次模拟,不能"乘一次":SP9 **没有幂等键**,同一 requestId 跑两次就是扣两次,
+// 而且第二次的 `_balance` 是第一次扣完之后的余额(收敛点不同,可能跳变)。
+//
+// 模拟在 **2 位口径**(harness 的读数口径)上进行:真实列是 3 位,Δ 由
+// "after − before(两侧都按迁移口径读到 2 位)"自动吸收,不影响期望的正确性。
+// 前提是选人时已核过 `ROUND(balance,0) == ROUND(ROUND(balance,2),0)`
+// —— 否则"迁移 3→2 位收敛"与"SP 整数化"两个成因会混在一起,红了也分不清是谁。
+func legacyRefundExpect(balanceBefore, requested int64, runs int, inputRemark string) refundExpect {
+	remark207, remark108 := refundRemark(inputRemark)
+	bal := balanceBefore
+	exp := refundExpect{}
+	for i := 0; i < maxInt(1, runs); i++ {
+		intBal := round0Cents(bal) // _balance ← DECIMAL(10,0):第一层整数化
+		if intBal <= 0 {           // IF(ISNULL(_balance) OR _balance<=0) THEN ... LEAVE
+			exp.Rejected = true
+			exp.Branches = append(exp.Branches, refundBranchReject)
+			return exp
+		}
+
+		// 不写成 if/else:SP 是**两个独立的 IF**,第二个判的是 `_badDate>0` 而不是第一个的条件。
+		// 差额不足 0.5 时 `_badDate` 收敛成 0,于是"进了第一个 IF"却"走了 ELSE 分支"。
+		var badDate, money int64
+		if requested > intBal { // IF(inputMoney>_balance)
+			badDate = round0Cents(requested - intBal) // 第二层整数化
+			money = intBal                            // _money ← _balance
+		}
+
+		if badDate > 0 { // IF(_badDate>0)
+			exp.Branches = append(exp.Branches, refundBranchBadDebt)
+			exp.BadDebt += badDate
+			exp.Money += money
+			bal -= money
+			exp.Logs = append(exp.Logs, refundLogExpect{Type: 108, Money: badDate, Remark: remark108})
+		} else {
+			exp.Branches = append(exp.Branches, refundBranchElse)
+			exp.Money += requested // ELSE 分支扣的是**原始 inputMoney**,不是 `_money`
+			bal -= requested
+		}
+		// 207 恒写全额、恒在 108 之后
+		exp.Logs = append(exp.Logs, refundLogExpect{Type: 207, Money: -requested, Remark: remark207})
+	}
+	return exp
+}
+
+// targetRefundExpect 按 **target 的精确十进制约** 算期望。
+//
+// `RefundService.CustomRefund` ④:
+//
+//	refundable = max(balance, 0);refunded = min(requested, refundable);badDebt = requested − refunded
+//
+// target **有幂等键**:同一 requestId 跑 N 次只落账 1 次(第 2 次起 `repeated=true`、零写入),
+// 所以这里不乘 runs —— 这正是与 legacy 最本质的一处差异。
+func targetRefundExpect(balanceBefore, requested int64, inputRemark string) refundExpect {
+	remark207, remark108 := refundRemark(inputRemark)
+	if balanceBefore <= 0 {
+		return refundExpect{Rejected: true, Branches: []string{refundBranchReject}}
+	}
+	refundable := balanceBefore
+	refunded := requested
+	if refunded > refundable {
+		refunded = refundable
+	}
+	badDebt := requested - refunded
+	exp := refundExpect{Money: refunded, Branches: []string{refundBranchElse}}
+	if badDebt > 0 {
+		exp.Branches = []string{refundBranchBadDebt}
+		exp.BadDebt = badDebt
+		exp.Logs = append(exp.Logs, refundLogExpect{Type: 108, Money: badDebt, Remark: remark108})
+	}
+	exp.Logs = append(exp.Logs, refundLogExpect{Type: 207, Money: -requested, Remark: remark207})
+	return exp
+}
+
+// refundExpectDiff 打印一份期望(报告 Note 用)。
+func refundExpectBrief(exp refundExpect) string {
+	if exp.Rejected {
+		return "拒绝(零写入、0 条流水)"
+	}
+	branches := strings.Join(exp.Branches, "→")
+	if branches == "" {
+		branches = "-"
+	}
+	return fmt.Sprintf("扣 %s、坏账 %s、%d 条流水(分支 %s)",
+		yuan(exp.Money), yuan(exp.BadDebt), len(exp.Logs), branches)
+}
+
+// fmtRefundLogs 把流水渲染成 "108 +7.00 / 207 -20.00"(无流水时 "0 条")。
+func fmtRefundLogs(logs []LogRow) string {
+	if len(logs) == 0 {
+		return "0 条"
+	}
+	parts := make([]string, 0, len(logs))
+	for _, l := range logs {
+		parts = append(parts, fmt.Sprintf("%d %s", l.Type, yuan(l.Money)))
+	}
+	return strings.Join(parts, " / ")
+}
+
+// fmtRefundLogsExpect 同上,但渲染期望(期望是值类型切片)。
+func fmtRefundLogsExpect(logs []refundLogExpect) string {
+	if len(logs) == 0 {
+		return "0 条"
+	}
+	parts := make([]string, 0, len(logs))
+	for _, l := range logs {
+		parts = append(parts, fmt.Sprintf("%d %s", l.Type, yuan(l.Money)))
+	}
+	return strings.Join(parts, " / ")
+}
+
+// countLogType 数某一 type 的流水条数。
+func countLogType(logs []LogRow, typ int32) int {
+	n := 0
+	for _, l := range logs {
+		if l.Type == typ {
+			n++
+		}
+	}
+	return n
+}
+
+// sumLogType 求某一 type 的流水金额之和(分)。
+func sumLogType(logs []LogRow, typ int32) int64 {
+	var s int64
+	for _, l := range logs {
+		if l.Type == typ {
+			s += l.Money
+		}
+	}
+	return s
+}
+
+// checkRefundLogs 校验**某一侧**的流水与它自己的期望。
+//
+// 判据:条数相等、逐条 type 相等、逐条 money 相等、逐条 remark 逐字相等。
+//
+// ⚠ 只比"同一侧内"的集合,不跨侧比顺序 —— 两侧的顺序是各自 SP / Service 的实现细节,
+//   跨侧比顺序会在"同一侧换了实现"时制造无关的红。跨侧比**内容**才有意义,
+//   而内容差异正是本批要记录的结论(见 C2VerdictClass)。
+func checkRefundLogs(logs []LogRow, exp []refundLogExpect) (bool, string) {
+	if len(logs) != len(exp) {
+		return false, fmt.Sprintf("条数实测 %d、期望 %d(实测:%s | 期望:%s)",
+			len(logs), len(exp), fmtRefundLogs(logs), fmtRefundLogsExpect(exp))
+	}
+	for i := range logs {
+		if logs[i].Type != exp[i].Type {
+			return false, fmt.Sprintf("#%d type 实测 %d、期望 %d", i, logs[i].Type, exp[i].Type)
+		}
+		if logs[i].Money != exp[i].Money {
+			return false, fmt.Sprintf("#%d(type=%d) money 实测 %s、期望 %s",
+				i, logs[i].Type, yuan(logs[i].Money), yuan(exp[i].Money))
+		}
+		if logs[i].Remark != exp[i].Remark {
+			return false, fmt.Sprintf("#%d(type=%d) remark 实测 %q、期望 %q(逐字比对,含 <br/> 拼接)",
+				i, logs[i].Type, logs[i].Remark, exp[i].Remark)
+		}
+	}
+	return true, fmt.Sprintf("%d 条逐条相符(type / money / remark)", len(exp))
+}
+
+// legacySPReturn 取 legacy 侧 SP 自带的那个结果集(没有则空 map)。
+func legacySPReturn(lg *SideResult) map[string]string {
+	if len(lg.Calls) == 0 || lg.Calls[0].Returned == nil {
+		return map[string]string{}
+	}
+	return lg.Calls[0].Returned
+}
+
+// targetRejected 判断 target 侧首次调用是否被拒(AppError)。
+func targetRejected(tg *SideResult) bool {
+	return len(tg.Calls) > 0 && !tg.Calls[0].Success
+}
+
+// minI64 取两者较小值。
+func minI64(a, b int64) int64 {
+	if a < b {
+		return a
+	}
+	return b
+}
+
+// customRefundCompare 是 SP9 的比对:**每一侧各自对着自己的期望**比,另加两侧共同不变量。
+//
+// 报告形态(每行都是"两侧实际值并排",不把标签塞进数值列):
+//
+//	Δbalance          legacy=-13.00   target=-12.50   ✘/✔  期望 legacy=-13.00 | target=-12.50
+//	余额(调用后)    legacy=-0.50    target=0.00      —      两侧算法不同 ⇒ 两侧之间不比
+func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
+	var diffs []Diff
+	var asserts []Assertion
+
+	lgB := lg.Before.Campus.Balance
+	tgB := tg.Before.Campus.Balance
+	lgExp := legacyRefundExpect(lgB, c.AmountCents, c.RunTimes, c.Remark)
+	tgExp := targetRefundExpect(tgB, c.AmountCents, c.Remark)
+
+	dL := sub(lg.After.Campus, lg.Before.Campus)
+	dT := sub(tg.After.Campus, tg.Before.Campus)
+	// 全局表的 Δ 也必须一起看:SP9 与 Service 都是**双表同额** UPDATE,
+	// 只比校区会让"只写了校区表"的实现静默通过。
+	dGL := sub(lg.After.Global, lg.Before.Global)
+	dGT := sub(tg.After.Global, tg.Before.Global)
+
+	lgRet := legacySPReturn(lg)
+	lgRejectedActual := lgRet["success"] == "0"
+	tgRejectedActual := targetRejected(tg)
+
+	// ① 前提:两侧 2 位基线逐分相等。不相等时下面的"分歧"可能来自基线没对齐,而不是算法差异。
+	diffs = append(diffs, Diff{
+		Item:   "两侧 2 位基线(校区余额)",
+		Legacy: moneyStr(lgB, lg.Before.Campus.Nulls.Balance),
+		Target: moneyStr(tgB, tg.Before.Campus.Nulls.Balance),
+		Equal:  sameMoney(lgB, lg.Before.Campus.Nulls.Balance, tgB, tg.Before.Campus.Nulls.Balance),
+		Note:   "前提:2 位口径相同 ⇒ 下面的分歧只可能来自两侧算法(legacy 的两次整数化)",
+	})
+
+	// ② 三个 Δ:两侧**实际值并排**,是否通过按"各侧是否符合自己的期望"算。
+	//    期望写进 Note —— 绝不能写进数值列(那会让"标签"和"实测值"长一样,C1-5b 就踩过)。
+	lgMoneyOK := !dL.Nulls.Balance && dL.Balance == -lgExp.Money
+	tgMoneyOK := !dT.Nulls.Balance && dT.Balance == -tgExp.Money
+	lgIncomeOK := sameMoney(dL.Income, dL.Nulls.Income, dL.Balance, dL.Nulls.Balance)
+	tgIncomeOK := sameMoney(dT.Income, dT.Nulls.Income, dT.Balance, dT.Nulls.Balance)
+	lgSpecialOK := !dL.Nulls.Special && dL.Special == lgExp.BadDebt
+	tgSpecialOK := !dT.Nulls.Special && dT.Special == tgExp.BadDebt
+
+	diffs = append(diffs, Diff{
+		Item:   "Δbalance(校区)",
+		Legacy: moneyStr(dL.Balance, dL.Nulls.Balance),
+		Target: moneyStr(dT.Balance, dT.Nulls.Balance),
+		Equal:  lgMoneyOK && tgMoneyOK,
+		Note: fmt.Sprintf("期望 legacy=%s(扣 _money/ELSE 时的 inputMoney)| target=%s(refunded)",
+			yuan(-lgExp.Money), yuan(-tgExp.Money)),
+	})
+	diffs = append(diffs, Diff{
+		Item:   "Δbalance(全局)",
+		Legacy: moneyStr(dGL.Balance, dGL.Nulls.Balance),
+		Target: moneyStr(dGT.Balance, dGT.Nulls.Balance),
+		Equal:  sameMoney(dGL.Balance, dGL.Nulls.Balance, dL.Balance, dL.Nulls.Balance) &&
+			sameMoney(dGT.Balance, dGT.Nulls.Balance, dT.Balance, dT.Nulls.Balance),
+		Note: "双表同额:全局 Δ 必须与校区 Δ 相同(只写一张表 → 红)",
+	})
+	diffs = append(diffs, Diff{
+		Item:   "Δtotal_income(校区)",
+		Legacy: moneyStr(dL.Income, dL.Nulls.Income),
+		Target: moneyStr(dT.Income, dT.Nulls.Income),
+		Equal:  lgIncomeOK && tgIncomeOK,
+		Note:   "共同不变量:Δtotal_income == Δbalance(两侧都必须成立)",
+	})
+	diffs = append(diffs, Diff{
+		Item:   "Δspecial_expenses(校区)",
+		Legacy: moneyStr(dL.Special, dL.Nulls.Special),
+		Target: moneyStr(dT.Special, dT.Nulls.Special),
+		Equal:  lgSpecialOK && tgSpecialOK,
+		Note: fmt.Sprintf("期望 legacy=%s(_badDate,两层整数化)| target=%s(精确坏账)",
+			yuan(lgExp.BadDebt), yuan(tgExp.BadDebt)),
+	})
+
+	// ③ 结果余额:**两侧之间不比**(算法本来就不同),但值要打出来给人看。
+	//    用 Skipped ⇒ 报告里打 "—" 而不是 "✔",避免"没比"伪装成"比过且相等"。
+	diffs = append(diffs, Diff{
+		Item:   "余额(调用后,校区)",
+		Legacy: moneyStr(lg.After.Campus.Balance, lg.After.Campus.Nulls.Balance),
+		Target: moneyStr(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance),
+		Equal:  true, Skipped: true,
+		Note: "两侧算法不同 ⇒ 两侧之间**不比对**;各自的结果已在上面的 Δ 行按其自身期望断言",
+	})
+
+	// ④ 流水:条数 / 明细 / 备注,各自对自己的期望。
+	lgLogOK, lgLogDetail := checkRefundLogs(lg.After.Logs, lgExp.Logs)
+	tgLogOK, tgLogDetail := checkRefundLogs(tg.After.Logs, tgExp.Logs)
+	diffs = append(diffs, Diff{
+		Item:   "流水条数",
+		Legacy: fmt.Sprint(len(lg.After.Logs)),
+		Target: fmt.Sprint(len(tg.After.Logs)),
+		Equal:  len(lg.After.Logs) == len(lgExp.Logs) && len(tg.After.Logs) == len(tgExp.Logs),
+		Note: fmt.Sprintf("期望 legacy=%d(108+207,%d 次调用)| target=%d(幂等,只落账 1 次)",
+			len(lgExp.Logs), maxInt(1, c.RunTimes), len(tgExp.Logs)),
+	})
+	diffs = append(diffs, Diff{
+		Item:   "流水明细(type + money)",
+		Legacy: fmtRefundLogs(lg.After.Logs),
+		Target: fmtRefundLogs(tg.After.Logs),
+		Equal:  lgLogOK && tgLogOK,
+		Note: fmt.Sprintf("期望 legacy:%s | target:%s;逐条校验:legacy %s | target %s",
+			fmtRefundLogsExpect(lgExp.Logs), fmtRefundLogsExpect(tgExp.Logs), lgLogDetail, tgLogDetail),
+	})
+	diffs = append(diffs, Diff{
+		Item:   "流水备注(逐字,108 含 <br/> 拼接)",
+		Legacy: refundRemarkBrief(lg.After.Logs, 108),
+		Target: refundRemarkBrief(tg.After.Logs, 108),
+		Equal:  lgLogOK && tgLogOK,
+		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 覆盖",
+	})
+
+	// ---- 断言 ①:各侧前置校验的结论符合**各自**的期望 ----
+	asserts = append(asserts, Assertion{
+		Name: "两侧前置校验:各侧是否符合**自己**的期望(legacy `ISNULL(_balance) OR _balance<=0` / target `balance<=0`)",
+		Pass: lgRejectedActual == lgExp.Rejected && tgRejectedActual == tgExp.Rejected,
+		Detail: fmt.Sprintf("legacy 实测拒绝=%v(期望 %v,整数化余额=%s,success=%q msg=%q)| target 实测拒绝=%v(期望 %v,真实余额=%s,码=%d err=%q)",
+			lgRejectedActual, lgExp.Rejected, yuan(round0Cents(lgB)), lgRet["success"], lgRet["msg"],
+			tgRejectedActual, tgExp.Rejected, yuan(tgB), codeOf(tg.Calls), errOf(tg.Calls)),
+	})
+	if c.ExpectTargetCode != 0 {
+		asserts = append(asserts, Assertion{
+			Name:   fmt.Sprintf("target 业务码 == %d(码比文案强:同一文案可能被多处复用)", c.ExpectTargetCode),
+			Pass:   targetRejected(tg) && codeOf(tg.Calls) == c.ExpectTargetCode,
+			Detail: fmt.Sprintf("实测码=%d err=%q", codeOf(tg.Calls), errOf(tg.Calls)),
+		})
+	}
+
+	// ---- 断言 ②:legacy 拒绝时的契约(success=0 + msg + 零写入) ----
+	lgNoWrite := lg.Before.Campus == lg.After.Campus && lg.Before.Global == lg.After.Global &&
+		len(lg.After.Logs) == 0
+	tgNoWrite := tg.Before.Campus == tg.After.Campus && tg.Before.Global == tg.After.Global &&
+		len(tg.After.Logs) == 0
+	if lgExp.Rejected {
+		asserts = append(asserts, Assertion{
+			Name: "legacy 拒绝契约:`success=0` + `msg='余额不能≤0'` + **零写入 / 0 条流水**(不抛错)",
+			Pass: lgRejectedActual && lgRet["msg"] == "余额不能≤0" && lgNoWrite,
+			Detail: fmt.Sprintf("success=%q msg=%q 零写入=%v 流水=%d 条",
+				lgRet["success"], lgRet["msg"], lgNoWrite, len(lg.After.Logs)),
+		})
+	} else {
+		asserts = append(asserts, Assertion{
+			Name:   "【不适用】legacy 本条未被拒绝 ⇒ 拒绝契约无从验证",
+			Pass:   true, Skipped: true,
+			Detail: "整数化余额=" + yuan(round0Cents(lgB)) + " > 0",
+		})
+	}
+	if tgExp.Rejected {
+		asserts = append(asserts, Assertion{
+			Name:   "target 拒绝契约:显式报错(3102)+ **零写入 / 0 条流水**",
+			Pass:   tgRejectedActual && tgNoWrite,
+			Detail: fmt.Sprintf("被拒=%v 码=%d 零写入=%v 流水=%d 条", tgRejectedActual, codeOf(tg.Calls), tgNoWrite, len(tg.After.Logs)),
+		})
+	}
+
+	// ---- 断言 ③:共同不变量(两侧都成立)----
+	// 这两条是本批最重要的"没删干净"的部分:它们**不比较两侧之间**,而是各自对着
+	// 一个与实现无关的恒等式。如果因为"两侧本来就不同"把断言整体删掉,这一支就只剩打印。
+	sharedSkip := lgExp.Rejected || tgExp.Rejected
+	asserts = append(asserts, Assertion{
+		Name:   "共同不变量:`207.money == −inputMoney`(两侧都必须成立)",
+		Pass:   !sharedSkip && sameLog207(lg.After.Logs, c.AmountCents) && sameLog207(tg.After.Logs, c.AmountCents),
+		Skipped: sharedSkip,
+		Detail: fmt.Sprintf("legacy 207 合计=%s | target 207 合计=%s(期望各 %s)",
+			yuan(sumLogType(lg.After.Logs, 207)), yuan(sumLogType(tg.After.Logs, 207)), yuan(-c.AmountCents)),
+	})
+	asserts = append(asserts, Assertion{
+		Name:   "共同不变量:`Δtotal_income == Δbalance`(两侧都必须成立 —— 证明结构性复刻对了,只是舍入不同)",
+		Pass:   !sharedSkip && lgIncomeOK && tgIncomeOK,
+		Skipped: sharedSkip,
+		Detail: fmt.Sprintf("legacy Δincome=%s Δbal=%s | target Δincome=%s Δbal=%s",
+			moneyStr(dL.Income, dL.Nulls.Income), moneyStr(dL.Balance, dL.Nulls.Balance),
+			moneyStr(dT.Income, dT.Nulls.Income), moneyStr(dT.Balance, dT.Nulls.Balance)),
+	})
+
+	// ---- 断言 ④:legacy 专属不变量 ----
+	// `108.money == round0(inputMoney − round0(balance_before))`:把"两层整数化"这个成因
+	// 直接写成一条等式。如果哪天有人把 round0Cents 改成 RoundToEven,C2-5 会红在这条上。
+	lgBadDebtRuns := 0
+	for _, b := range lgExp.Branches {
+		if b == refundBranchBadDebt {
+			lgBadDebtRuns++
+		}
+	}
+	want108Each := round0Cents(c.AmountCents - round0Cents(lgB))
+	want108Total := int64(lgBadDebtRuns) * want108Each
+	lg108OK := lgBadDebtRuns > 0 &&
+		countLogType(lg.After.Logs, 108) == lgBadDebtRuns &&
+		sumLogType(lg.After.Logs, 108) == want108Total
+	asserts = append(asserts, Assertion{
+		Name:    "legacy 专属不变量:`108.money == round0(inputMoney − round0(balance_before))`(第二次整数化只在这里可见)",
+		Pass:    lg108OK,
+		Skipped: lgBadDebtRuns == 0,
+		Detail: fmt.Sprintf("108 实测 %d 条 / 合计 %s | 期望 %d 条 / 合计 %s(每条 = round0(%s − round0(%s)) = %s)",
+			countLogType(lg.After.Logs, 108), yuan(sumLogType(lg.After.Logs, 108)),
+			lgBadDebtRuns, yuan(want108Total),
+			yuan(c.AmountCents), yuan(lgB), yuan(want108Each)),
+	})
+	asserts = append(asserts, Assertion{
+		Name: "legacy 专属不变量:**坏账分支写 108、ELSE 分支不写**(108 条数 == 走坏账分支的次数)",
+		Pass: countLogType(lg.After.Logs, 108) == lgBadDebtRuns,
+		Detail: fmt.Sprintf("108 实测 %d 条 | 坏账分支 %d 次(分支序列:%s)",
+			countLogType(lg.After.Logs, 108), lgBadDebtRuns, strings.Join(lgExp.Branches, "→")),
+	})
+
+	// ---- 断言 ⑤:target 专属不变量 ----
+	// 用**实测值**而不是 Service 返回值:返回值只能证明"它自己这么报",实测 Δ 才能证明"库真变了"。
+	tgSplitOK := !tgExp.Rejected && -dT.Balance+dT.Special == c.AmountCents
+	asserts = append(asserts, Assertion{
+		Name:    "target 专属不变量:`refunded + badDebt == inputMoney`(实测:`−Δbalance + Δspecial == inputMoney`)",
+		Pass:    tgSplitOK,
+		Skipped: tgExp.Rejected,
+		Detail: fmt.Sprintf("−Δbal=%s + Δspec=%s = %s | inputMoney=%s",
+			yuan(-dT.Balance), yuan(dT.Special), yuan(-dT.Balance+dT.Special), yuan(c.AmountCents)),
+	})
+	tgRefundedOK := !tgExp.Rejected && -dT.Balance == minI64(c.AmountCents, tgB)
+	asserts = append(asserts, Assertion{
+		Name:    "target 专属不变量:`refunded == min(inputMoney, balance_before)`(实测:`−Δbalance` 等于两者较小值)",
+		Pass:    tgRefundedOK,
+		Skipped: tgExp.Rejected,
+		Detail: fmt.Sprintf("实测 refunded=−Δbal=%s | min(%s, %s)=%s",
+			yuan(-dT.Balance), yuan(c.AmountCents), yuan(tgB), yuan(minI64(c.AmountCents, tgB))),
+	})
+	asserts = append(asserts, Assertion{
+		Name:    "target 专属不变量:`balance_after >= 0`(精确口径下退费**不应该把余额退成负数**)",
+		Pass:    tgExp.Rejected || tg.After.Campus.Balance >= 0,
+		Skipped: tgExp.Rejected,
+		Detail:  "调用后校区余额=" + moneyStr(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance),
+	})
+
+	// ---- 断言 ⑥:幂等(只有 RunTimes>=2 才有意义)----
+	if c.RunTimes >= 2 {
+		tgRepeated := len(tg.Calls) >= 2 && tg.Calls[1].Repeated
+		lgRuns := len(lg.Calls)
+		asserts = append(asserts, Assertion{
+			Name: "幂等:target 第 2 次 `repeated=true` 且**只扣 1 次**;legacy SP 无幂等键 ⇒ 真的扣 N 次",
+			Pass: tgRepeated && lgRuns == maxInt(1, c.RunTimes) &&
+				-dT.Balance == tgExp.Money && -dL.Balance == lgExp.Money,
+			Detail: fmt.Sprintf("target repeated=%v 实测扣 %s(期望 %s)| legacy 调用 %d 次 实测扣 %s(期望 %s)",
+				tgRepeated, yuan(-dT.Balance), yuan(tgExp.Money), lgRuns, yuan(-dL.Balance), yuan(lgExp.Money)),
+		})
+	}
+
+	// ---- 断言 ⑦:判定行(**不是** PASS/FAIL,是结论登记)----
+	same := lgExp.Rejected == tgExp.Rejected && lgExp.Money == tgExp.Money &&
+		lgExp.BadDebt == tgExp.BadDebt && len(lgExp.Logs) == len(tgExp.Logs)
+	if same {
+		asserts = append(asserts, Assertion{
+			Name: "判定:**两侧一致**(整数余额 ⇒ legacy 的两次整数化不产生任何差异)",
+			Pass: true,
+			Detail: fmt.Sprintf("余额=%s 请求=%s ⇒ 两侧都 %s",
+				yuan(lgB), yuan(c.AmountCents), refundExpectBrief(lgExp)),
+		})
+	} else {
+		asserts = append(asserts, Assertion{
+			Name: "判定:**" + C2VerdictClass + "** —— 旧 SP 的 `DECLARE ... DECIMAL DEFAULT 0` 会把金额整数化两次,target 有意不复刻",
+			Pass: true,
+			Detail: fmt.Sprintf("余额=%s 请求=%s | legacy(整数化余额 %s):%s | target(精确余额 %s):%s",
+				yuan(lgB), yuan(c.AmountCents),
+				yuan(round0Cents(lgB)), refundExpectBrief(lgExp),
+				yuan(tgB), refundExpectBrief(tgExp)),
+		})
+	}
+	return diffs, asserts
+}
+
+// sameLog207 判断"某一侧 207 流水合计恰好是 −inputMoney"。
+//
+// 单独拎出来是因为它要**两侧各算一次**:这条不是"两侧互相比",而是各自对一个
+// 与实现无关的恒等式。写成函数才能在两侧复用同一判据(避免"只给 legacy 断言了"这种
+// 半截覆盖 —— 那比不断言更容易骗人)。
+func sameLog207(logs []LogRow, requested int64) bool {
+	n := countLogType(logs, 207)
+	if n == 0 {
+		return false
+	}
+	return sumLogType(logs, 207) == -requested*int64(n)
+}
+
+// refundRemarkBrief 渲染某一 type 流水的备注(无该 type 时 "—")。
+func refundRemarkBrief(logs []LogRow, typ int32) string {
+	var parts []string
+	for _, l := range logs {
+		if l.Type == typ {
+			parts = append(parts, l.Remark)
+		}
+	}
+	if len(parts) == 0 {
+		return "—"
+	}
+	return strings.Join(parts, " | ")
+}
+
+// refundRemarkNote 说明备注的期望来源(逐字,不转义 <br/>)。
+func refundRemarkNote(inputRemark string) string {
+	r207, r108 := refundRemark(inputRemark)
+	return fmt.Sprintf("期望 207=%q(inputRemark 为空时落在 '余额退费')| 108=%q(207 + 固定后缀,**只有 108 拼**)",
+		r207, r108)
+}
+
+// nullBalanceRejectCompare 是 **C2-7(E 探针)** 的专用比对:legacy 侧校区余额为 NULL。
+//
+// 这条 case **非同构**:target 的 `t_user_expense_school.balance` 是 `not null default 0.00`,
+// **存不下 NULL**。所以两侧喂的是不同数据(legacy NULL / target 0.00),
+// 结论一致(**都拒绝**),但拒绝的**理由不同**:
+//
+//	legacy:`SELECT ... INTO _balance` 把 NULL 读进来 ⇒ `ISNULL(_balance)` 短路 ⇒ LEAVE
+//	        (⚠ 不是"NULL 落到 ELSE 分支"—— 短路发生在 `OR` 的左边,右边根本不求值)
+//	target:`balance <= 0` ⇒ 3102
+//
+// 它证明的是"**NULL 余额这个状态在新库不可表达**" —— 迁移从 schema 层修掉了一个数据缺陷。
+// ⚠ 它**不进 DefaultCases**:没有同构的第二侧,"两侧都拒绝"这条绿的含金量是 0
+//    (换任何一条余额为 0 的记录都能绿),所以只在探针里登记。
+func nullBalanceRejectCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
+	var diffs []Diff
+	var asserts []Assertion
+
+	lgRet := legacySPReturn(lg)
+	lgNull := lg.Before.Campus.Nulls.Balance
+	lgRejected := lgRet["success"] == "0"
+	tgRejected := targetRejected(tg)
+	lgNoWrite := lg.Before.Campus == lg.After.Campus && len(lg.After.Logs) == 0
+	tgNoWrite := tg.Before.Campus == tg.After.Campus && len(tg.After.Logs) == 0
+
+	diffs = append(diffs,
+		Diff{
+			Item:   "legacy 校区余额是否为 NULL(该侧前置)",
+			Legacy: fmt.Sprint(lgNull),
+			Target: "不可表达",
+			Equal:  lgNull,
+			Note:   "target 列是 `not null default 0.00` ⇒ NULL 存不下;本 case **非同构**,两侧故意喂不同数据",
+		},
+		Diff{
+			Item:   "两侧是否都拒绝",
+			Legacy: fmt.Sprint(lgRejected),
+			Target: fmt.Sprint(tgRejected),
+			Equal:  lgRejected && tgRejected,
+			Note:   "拒绝**理由**不同(legacy `ISNULL` 短路 / target `balance<=0`),结论一致",
+		},
+	)
+	asserts = append(asserts, Assertion{
+		Name: "legacy:余额为 NULL ⇒ `ISNULL(_balance)` 短路 ⇒ 静默拒绝(`success=0` / `msg='余额不能≤0'`)+ 零写入",
+		Pass: lgNull && lgRejected && lgRet["msg"] == "余额不能≤0" && lgNoWrite,
+		Detail: fmt.Sprintf("NULL=%v success=%q msg=%q 零变化=%v 流水=%d 条",
+			lgNull, lgRet["success"], lgRet["msg"], lgNoWrite, len(lg.After.Logs)),
+	})
+	asserts = append(asserts, Assertion{
+		Name:   "target:余额 0.00 ⇒ `balance<=0` ⇒ 显式拒绝(3102)+ 零写入",
+		Pass:   tgRejected && codeOf(tg.Calls) == 3102 && tgNoWrite,
+		Detail: fmt.Sprintf("被拒=%v 码=%d err=%q 零变化=%v 流水=%d 条", tgRejected, codeOf(tg.Calls), errOf(tg.Calls), tgNoWrite, len(tg.After.Logs)),
+	})
+	asserts = append(asserts, Assertion{
+		Name: "判定:**迁移修掉的缺陷** —— NULL 余额在新库不可表达,两侧殊途同归(都拒绝);本探针**不构成验收**",
+		Pass: true,
+		Detail: "legacy 侧的 NULL 是数据缺陷(建行只写 3 列,见 C1-11);target 的 `not null default 0.00` " +
+			"从 schema 层消除了该状态。非同构 case 只能证明'都拒绝',不能证明'行为一致'",
+	})
+	return diffs, asserts
+}

+ 356 - 0
internal/academic/dualrun/stage2/c2_refund_test.go

@@ -0,0 +1,356 @@
+package stage2
+
+import (
+	"strings"
+	"testing"
+)
+
+// 本文件锁住批 C2(`sp_user_expense_custom_refund`)的**期望值算法**。
+//
+// 为什么这一块必须单测而不是只靠双跑:
+//
+//	双跑只能证明"实测 == 我的期望"。如果期望本身把 SP 的语义理解错了
+//	(比如把 `_badDate` 的第二次整数化漏掉、或把 ELSE 分支当成"扣 `_money`"),
+//	那双跑会**稳定地红**,然后最省事的做法就是把断言放宽 —— 那等于删判据。
+//	所以期望先在这一层用 dump 的语句逐字核对一遍,双跑只负责"库里的值对不对"。
+//
+// 下面每条断言的注释都引了 dump 的原句,改期望的人可以直接对照。
+
+// TestRound0CentsHalfAwayFromZero 锁住 `DECLARE ... DECIMAL DEFAULT 0` 的赋值语义。
+//
+// dump:`DECLARE _balance DECIMAL DEFAULT 0; ... SET _money = _balance;`
+// DECIMAL 不带精度参数 ⇒ DECIMAL(10,0) ⇒ **半进远离零**。
+//
+// 必须与 `math.RoundToEven`(半进取偶)区分开:12.5 在两者下分别是 13 / 12。
+func TestRound0CentsHalfAwayFromZero(t *testing.T) {
+	cases := []struct {
+		in, want int64
+		note     string
+	}{
+		{0, 0, "0.00 → 0"},
+		{40, 0, "0.40 → 0(.4 舍)"},
+		{49, 0, "0.49 → 0"},
+		{50, 100, "0.50 → 1(半进**远离零**,RoundToEven 会给 0)"},
+		{51, 100, "0.51 → 1"},
+		{149, 100, "1.49 → 1"},
+		{150, 200, "1.50 → 2"},
+		{250, 300, "2.50 → 3(RoundToEven 会给 2)"},
+		{1250, 1300, "12.500 → 13(C2-5 的哨兵)"},
+		{47538, 47500, "475.376 → 475(C2-4)"},
+		{69998, 70000, "699.980 → 700(C2-6)"},
+		{28, 0, "0.280 → 0(C2-2 的 A 象限来源)"},
+		{83, 100, "0.832 → 1(C2-3)"},
+		{-50, -100, "-0.50 → -1(对称,远离零)"},
+		{-49, 0, "-0.49 → 0"},
+	}
+	for _, c := range cases {
+		if got := round0Cents(c.in); got != c.want {
+			t.Errorf("round0Cents(%d) = %d, want %d —— %s", c.in, got, c.want, c.note)
+		}
+	}
+}
+
+// TestRound0CentsAgreesWithMathRound 交叉校验:整数实现与大整数范围内的 float 实现必须同结果。
+//
+// 只用于自检(生产路径用整数实现,不依赖 float64 的舍入模式)。
+func TestRound0CentsAgreesWithMathRound(t *testing.T) {
+	for cents := int64(-20000); cents <= 20000; cents++ {
+		if a, b := round0Cents(cents), mathRound0Cents(cents); a != b {
+			t.Fatalf("cents=%d:round0Cents=%d、mathRound0Cents=%d —— 两个实现不一致", cents, a, b)
+		}
+	}
+}
+
+// TestLegacyRefundExpectRealShapes 用**真实账户余额**核对 legacy 侧的期望。
+//
+// 这五组数字全部来自旧库实测(campus 5、自营、可失效报名 0),不是人造边界:
+//
+//	userid  balance     C2 里的象限
+//	29131   0.280       A:(0, 0.5) ⇒ 整数化后 0 ⇒ 拒绝
+//	6449    0.832       B:[0.5, 1) ⇒ 扣 1、余额 −0.168
+//	68563   475.376     C:≥1 且分位 <0.5 ⇒ 扣 475、留 0.376 零头
+//	68785   12.500      D:分位恰 .500 ⇒ 扣 **13**(不是 12)
+//	67978   699.980     缝:inputMoney ∈ (真实余额, 整数化余额]
+func TestLegacyRefundExpectRealShapes(t *testing.T) {
+	const remark = "C2"
+	cases := []struct {
+		name     string
+		balance  int64 // 2 位口径(分)
+		request  int64
+		want     refundExpect
+		contains string // 断言说明里应出现的特征(便于红的时候一眼看出是哪一个象限)
+	}{
+		{
+			name: "A 象限 0.280 退 0.28:整数化后 _balance=0 ⇒ 拒绝",
+			balance: 28, request: 28,
+			want:     refundExpect{Rejected: true, Branches: []string{refundBranchReject}},
+			contains: "拒绝了就不该有流水",
+		},
+		{
+			name: "B 象限 0.832 退 1.50:_balance=1、_badDate=round0(0.5)=1",
+			balance: 83, request: 150,
+			want: refundExpect{
+				Branches: []string{refundBranchBadDebt},
+				Money:    100, BadDebt: 100,
+				Logs: []refundLogExpect{
+					{Type: 108, Money: 100, Remark: "C2<br/>退费的金额大于可退费部分,超出部分为坏账部分。"},
+					{Type: 207, Money: -150, Remark: "C2"},
+				},
+			},
+			contains: "108 是 +1.00 不是 +0.80",
+		},
+		{
+			name: "C 象限 475.376 退 600:_balance=475、_badDate=125",
+			balance: 47538, request: 60000,
+			want: refundExpect{
+				Branches: []string{refundBranchBadDebt},
+				Money:    47500, BadDebt: 12500,
+				Logs: []refundLogExpect{
+					{Type: 108, Money: 12500, Remark: "C2<br/>退费的金额大于可退费部分,超出部分为坏账部分。"},
+					{Type: 207, Money: -60000, Remark: "C2"},
+				},
+			},
+			contains: "迁移 3→2 位后仍留正零头",
+		},
+		{
+			name: "D 象限 12.500 退 20:_balance 半进远离零 = 13",
+			balance: 1250, request: 2000,
+			want: refundExpect{
+				Branches: []string{refundBranchBadDebt},
+				Money:    1300, BadDebt: 700,
+				Logs: []refundLogExpect{
+					{Type: 108, Money: 700, Remark: "C2<br/>退费的金额大于可退费部分,超出部分为坏账部分。"},
+					{Type: 207, Money: -2000, Remark: "C2"},
+				},
+			},
+			contains: "RoundToEven 会给 12",
+		},
+		{
+			name: "缝区间 699.980 退 699.99:`699.99 > 700` 为假 ⇒ **ELSE,无 108**",
+			balance: 69998, request: 69999,
+			want: refundExpect{
+				Branches: []string{refundBranchElse},
+				Money:    69999, BadDebt: 0,
+				Logs: []refundLogExpect{{Type: 207, Money: -69999, Remark: "C2"}},
+			},
+			contains: "同一笔操作 legacy 连分支都和 target 不同",
+		},
+		{
+			name: "整数余额 9740 退 300:整数化不产生任何差异(ELSE)",
+			balance: 974000, request: 30000,
+			want: refundExpect{
+				Branches: []string{refundBranchElse},
+				Money:    30000,
+				Logs:     []refundLogExpect{{Type: 207, Money: -30000, Remark: "C2"}},
+			},
+			contains: "C2-1",
+		},
+	}
+	for _, c := range cases {
+		got := legacyRefundExpect(c.balance, c.request, 1, remark)
+		assertRefundExpectEqual(t, c.name+"("+c.contains+")", got, c.want)
+	}
+}
+
+// TestLegacyRefundExpectBadDateRoundsToZero 锁住一个**很容易写错**的分支:
+// `inputMoney > _balance` 成立,但差额不足 0.5 ⇒ `_badDate` 收敛成 0
+// ⇒ `IF(_badDate>0)` 为假 ⇒ **仍然走 ELSE,扣的是 inputMoney**。
+//
+// dump 是两个**独立的 IF**,不是 if/else:
+//
+//	IF(inputMoney>_balance) THEN SET _badDate = inputMoney - _balance; SET _money = _balance; END IF;
+//	IF(_badDate>0) THEN ... ELSE UPDATE ... `balance`-inputMoney ... END IF;
+//
+// 如果按 if/else 的直觉写成"进了第一个 IF 就扣 _money",这条会期望扣 1.00 而实测扣 1.30。
+func TestLegacyRefundExpectBadDateRoundsToZero(t *testing.T) {
+	// 余额 0.832 → _balance=1.00;退 1.30 → 差额 0.30 → round0(0.30)=0
+	got := legacyRefundExpect(83, 130, 1, "C2")
+	want := refundExpect{
+		Branches: []string{refundBranchElse},
+		Money:    130, BadDebt: 0,
+		Logs: []refundLogExpect{{Type: 207, Money: -130, Remark: "C2"}},
+	}
+	assertRefundExpectEqual(t, "差额收敛成 0 ⇒ 落 ELSE、扣 inputMoney 而不是 _money", got, want)
+}
+
+// TestLegacyRefundExpectNoIdempotency 锁住"SP9 没有幂等键 ⇒ 同一 requestId 真扣两次"。
+//
+// dump 里没有任何按 requestid 查重的语句,`inputRequestid` 只被写进 `logid`。
+// 第二次的 `_balance` 是第一次扣完之后的余额,所以必须**逐次模拟**(不能乘 2 就完事)。
+func TestLegacyRefundExpectNoIdempotency(t *testing.T) {
+	got := legacyRefundExpect(500000, 30000, 2, "C2")
+	want := refundExpect{
+		Branches: []string{refundBranchElse, refundBranchElse},
+		Money:    60000,
+		Logs: []refundLogExpect{
+			{Type: 207, Money: -30000, Remark: "C2"},
+			{Type: 207, Money: -30000, Remark: "C2"},
+		},
+	}
+	assertRefundExpectEqual(t, "幂等:legacy 扣 600.00、2 条 207", got, want)
+}
+
+// TestTargetRefundExpectPrecise 锁住 target 侧"精确十进制 + 幂等"的口径。
+func TestTargetRefundExpectPrecise(t *testing.T) {
+	cases := []struct {
+		name    string
+		balance int64
+		request int64
+		want    refundExpect
+	}{
+		{
+			name: "B 象限 0.83 退 1.50:refunded=0.83、badDebt=0.67",
+			balance: 83, request: 150,
+			want: refundExpect{
+				Branches: []string{refundBranchBadDebt},
+				Money:    83, BadDebt: 67,
+				Logs: []refundLogExpect{
+					{Type: 108, Money: 67, Remark: "C2<br/>退费的金额大于可退费部分,超出部分为坏账部分。"},
+					{Type: 207, Money: -150, Remark: "C2"},
+				},
+			},
+		},
+		{
+			name: "缝区间 699.98 退 699.99:refunded=699.98、badDebt=0.01 ⇒ **有 108**",
+			balance: 69998, request: 69999,
+			want: refundExpect{
+				Branches: []string{refundBranchBadDebt},
+				Money:    69998, BadDebt: 1,
+				Logs: []refundLogExpect{
+					{Type: 108, Money: 1, Remark: "C2<br/>退费的金额大于可退费部分,超出部分为坏账部分。"},
+					{Type: 207, Money: -69999, Remark: "C2"},
+				},
+			},
+		},
+		{
+			name: "余额 0.28 退 0.28:target 能退(legacy 拒绝)",
+			balance: 28, request: 28,
+			want: refundExpect{
+				Branches: []string{refundBranchElse},
+				Money:    28,
+				Logs:     []refundLogExpect{{Type: 207, Money: -28, Remark: "C2"}},
+			},
+		},
+		{
+			name: "余额 0:拒绝(3102)",
+			balance: 0, request: 28,
+			want: refundExpect{Rejected: true, Branches: []string{refundBranchReject}},
+		},
+	}
+	for _, c := range cases {
+		got := targetRefundExpect(c.balance, c.request, "C2")
+		assertRefundExpectEqual(t, c.name, got, c.want)
+	}
+
+	// 幂等:target 只有 1 次落账,与 runs 无关(函数签名里就没有 runs —— 这是故意的,
+	// 避免有人"顺手"传个 runs 进去把它写成跟 legacy 一样重复扣)。
+	if got := targetRefundExpect(500000, 30000, "C2"); got.Money != 30000 || len(got.Logs) != 1 {
+		t.Fatalf("target 幂等:应只扣 1 次 300.00、1 条流水,实得 扣 %s、%d 条",
+			yuan(got.Money), len(got.Logs))
+	}
+}
+
+// TestRefundRemarkSuffixOnlyOn108 锁住"`<br/>` 后缀只有 108 拼"。
+//
+// dump:`_balanceRemark = CONCAT(inputRemark,'<br/>退费的金额大于可退费部分,超出部分为坏账部分。')`,
+// 而 207 的 INSERT 写的是 `inputRemark` 本身。
+// 把后缀也拼到 207 上会立刻红 —— 所以这条断言必须存在,否则"拼错"与"拼对"看起来一样。
+func TestRefundRemarkSuffixOnlyOn108(t *testing.T) {
+	const suffix = "<br/>退费的金额大于可退费部分,超出部分为坏账部分。"
+	for _, in := range []string{"C2", "", "   "} {
+		r207, r108 := refundRemark(in)
+		if strings.Contains(r207, "<br/>") {
+			t.Errorf("inputRemark=%q:207 的备注不该有 <br/> 后缀,实得 %q", in, r207)
+		}
+		if !strings.HasSuffix(r108, suffix) {
+			t.Errorf("inputRemark=%q:108 的备注必须以 %q 结尾,实得 %q", in, suffix, r108)
+		}
+		if !strings.HasPrefix(r108, r207) {
+			t.Errorf("inputRemark=%q:108 应以 207 的备注开头,实得 207=%q 108=%q", in, r207, r108)
+		}
+	}
+	// 空串走 SP 的默认值(dump:`IF inputRemark='' THEN SET inputRemark='余额退费'`)。
+	if r207, _ := refundRemark(""); r207 != "余额退费" {
+		t.Errorf("空备注应落到 SP 默认值 '余额退费',实得 %q", r207)
+	}
+}
+
+// TestCustomRefundCaseSet 锁住 C2 的 case 数量与"E 探针不进 DefaultCases"。
+//
+// 分母纪律:`DefaultCases` 的长度是**验收分母**。E 那条非同构 case 混进来会把分母抬到 35、
+// 并让通过率里多一条**必然绿**的 case —— 那正是 team-lead 说的"那个绿的含金量是 0"。
+func TestCustomRefundCaseSet(t *testing.T) {
+	all := DefaultCases("")
+	c2 := 0
+	for _, c := range all {
+		if c.Op != OpCustomRefund {
+			continue
+		}
+		c2++
+		if c.Name == "" || c.Note == "" {
+			t.Errorf("C2 的 case %q 必须有 Name 与 Note(报告要能自解释)", c.Name)
+		}
+		if c.SpecialCase == SpecialNullBalanceReject {
+			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))
+	}
+	if c2 != 9 {
+		t.Errorf("C2 应恰好 9 条(E 探针不在此列),实得 %d", c2)
+	}
+	probes := ProbeCases()
+	if len(probes) != 1 || probes[0].SpecialCase != SpecialNullBalanceReject {
+		t.Fatalf("ProbeCases 应恰好是 E 探针那一条,实得 %d 条", len(probes))
+	}
+}
+
+// TestCustomRefundCaseUserBalancesReal 检查 9 条 case 用的都是**真实账户**(没有 Baseline 造数)。
+//
+// 理由(team-lead 定的口径):造数能凑出任意象限,但"这个象限真实存在多少条"只能从库里数。
+// C2 的每个象限都挑了真实账户,所以 9 条 case 的余额分布本身就是存量分布的证据。
+func TestCustomRefundCaseUserBalancesReal(t *testing.T) {
+	for _, c := range DefaultCases("") {
+		if c.Op != OpCustomRefund {
+			continue
+		}
+		if c.Baseline != nil {
+			t.Errorf("%q 用了 Baseline 造数 —— C2 要求真实构型(余额分布要能代表存量)", c.Name)
+		}
+		if c.AmountCents <= 0 {
+			t.Errorf("%q 的 AmountCents 必须 > 0", c.Name)
+		}
+		if c.RequestID == "" {
+			t.Errorf("%q 缺 RequestID(target 侧幂等键必填,会直接 1004)", c.Name)
+		}
+	}
+}
+
+// assertRefundExpectEqual 逐字段比两份期望(切片要逐元素比,不能只比长度)。
+func assertRefundExpectEqual(t *testing.T, name string, got, want refundExpect) {
+	t.Helper()
+	if got.Rejected != want.Rejected {
+		t.Errorf("%s:Rejected 实得 %v、期望 %v", name, got.Rejected, want.Rejected)
+		return
+	}
+	if strings.Join(got.Branches, "→") != strings.Join(want.Branches, "→") {
+		t.Errorf("%s:分支序列 实得 %v、期望 %v", name, got.Branches, want.Branches)
+	}
+	if got.Money != want.Money {
+		t.Errorf("%s:Money 实得 %s、期望 %s", name, yuan(got.Money), yuan(want.Money))
+	}
+	if got.BadDebt != want.BadDebt {
+		t.Errorf("%s:BadDebt 实得 %s、期望 %s", name, yuan(got.BadDebt), yuan(want.BadDebt))
+	}
+	if len(got.Logs) != len(want.Logs) {
+		t.Fatalf("%s:流水条数 实得 %d、期望 %d(实测 %s | 期望 %s)",
+			name, len(got.Logs), len(want.Logs), fmtRefundLogsExpect(got.Logs), fmtRefundLogsExpect(want.Logs))
+	}
+	for i := range want.Logs {
+		if got.Logs[i] != want.Logs[i] {
+			t.Errorf("%s:#%d 实得 %+v、期望 %+v", name, i, got.Logs[i], want.Logs[i])
+		}
+	}
+}

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

@@ -39,6 +39,12 @@ const (
 	// 注意:`INPUT-TRUNCATION`(字符串截断导致的判据变化,如 logid varchar(32) vs (64))
 	// **不属于**这个 subtype,要另开一类 —— 处置动作不同,不能混。
 	SpecialPredicateCollapse = "predicate-collapse"
+
+	// SpecialNullBalanceReject 是 C2-7:legacy 侧校区余额为 NULL、target 侧 0.00(NULL 不可表达)。
+	//
+	// 这条**非同构**:两侧喂的数据故意不同,只说"都拒绝"。
+	// 归类:迁移修掉的缺陷(NULL 状态消失),与 C1-11 的 LEGACY-DEFECT 同族。
+	SpecialNullBalanceReject = "null-balance-reject"
 )
 
 // Op 是"跑哪个写 SP"。
@@ -77,6 +83,12 @@ const (
 	// ⚠ 名字骗人:"清零"清的是**校区**余额,全局只减去同一个 campusBalance ——
 	//   所以清零后全局余额一般**不是 0**(它等于其他校区余额之和)。
 	OpClearBalance
+	// OpCustomRefund 自定义退费(SP9):**legacy 把余额整数化,target 用精确值** ⇒ 两侧结果通常不同。
+	//
+	// dump:`refunded = min(requested, _money)`、`badDebt = requested - refunded`;
+	// 但 `_balance`/`_money`/`_badDate` 都是 `DECLARE ... DECIMAL DEFAULT 0`(= DECIMAL(10,0)),
+	// 于是 100.70 → 101、50.9 → 51。target 不复刻这套整数化(见 c2_refund.go 抬头)。
+	OpCustomRefund
 	// OpCancelRechargeActivity 取消充值活动(SP8):balance−、total_income−(双表),
 	// 报名状态置 **0**(不是 4),1 条流水 type=209;机构 id 由**校区**反查(SP 无 schoolid 入参)。
 	OpCancelRechargeActivity
@@ -99,6 +111,8 @@ func (o Op) String() string {
 		return "sp_user_expense_school_transfer_expenditure"
 	case OpClearBalance:
 		return "sp_user_expense_school_cleared_balance"
+	case OpCustomRefund:
+		return "sp_user_expense_custom_refund"
 	case OpCancelRechargeActivity:
 		return "sp_user_expense_cancel_recharge_activity"
 	default:
@@ -136,6 +150,14 @@ type Case struct {
 	// 由 case **显式声明**,再分别断言两侧看到的校区元数据与它一致 —— 而不是从哪一侧读出来直接喂给另一侧。
 	SelfSupport bool
 
+	// LegacyCampusBalanceNull true = **只在 legacy 侧**把校区余额改成 NULL。
+	//
+	// 为什么要这个开关:target 的 `t_user_expense_school.balance` 是 `not null default 0.00`,
+	// **存不下 NULL** —— 这个状态在新库不可表达。所以 C2-7 这条 case 是**非同构**的:
+	// legacy 侧 NULL(触发 `ISNULL(_balance)` 短路)、target 侧 0.00(触发 `balance<=0`),
+	// 两侧数据故意不同,只钉"都拒绝"这个结论。
+	LegacyCampusBalanceNull bool
+
 	// CampusMissing true = 本 case 故意用一个**不存在的校区**(如 999999)。
 	//
 	// 为什么必须显式声明:目标侧需要在事务内 seed `t_campus` 才能让 Go 找到校区;
@@ -418,6 +440,15 @@ type Assertion struct {
 	Name   string
 	Pass   bool
 	Detail string
+	// Skipped 表示"这条断言在当前 case 上**结构性不适用**",报告里打 "—" 而不是 "✔"。
+	//
+	// 与 Diff.Skipped 同源,理由也一样:断言「本 case 不涉及」时必须**显形**。
+	// 典型场景(批 C2):
+	//   - 两侧都被前置校验拒绝 ⇒ `207.money == −inputMoney` 无从验证(没有 207),
+	//     照常打 ✔ 就是一次 `0 == 0` 的空过;
+	//   - 走的不是坏账分支 ⇒ `108.money == round0(...)` 无从验证。
+	// 置为 true 时调用方应同时把 Pass 置 true(Skipped 参与不了 FailCount 的语义)。
+	Skipped bool
 }
 
 // ResidueReport 是「回滚后有没有残留」的检查结果。
@@ -431,14 +462,25 @@ type ResidueReport struct {
 }
 
 // FailCount 统计不一致项 + 失败断言。
+//
+// `Skipped` 的项**不计入**:它的语义是"本 case 结构性不适用",与"测过且通过"和"测过且失败"
+// 都不同。⚠ 这是一处**必须由构造方负责**的地方:把 Skipped 置错等于让真实失败静默消失,
+// 所以每个 Skipped 的产生条件都必须是"从 case 的构造就能看出不适用"(例如"走的不是坏账分支
+// ⇒ 没有 108 流水可查"),而**不能**是"实测值和我预期的不一样,所以跳过"。
 func (r *CaseResult) FailCount() int {
 	n := 0
 	for _, d := range r.Diffs {
+		if d.Skipped {
+			continue
+		}
 		if !d.Equal {
 			n++
 		}
 	}
 	for _, a := range r.Assertions {
+		if a.Skipped {
+			continue
+		}
 		if !a.Pass {
 			n++
 		}

+ 23 - 1
internal/academic/dualrun/stage2/legacy.go

@@ -49,6 +49,8 @@ const (
 	callClearBalance = "CALL sp_user_expense_school_cleared_balance(?, ?, ?, ?, ?)"
 	// SP8 取消充值活动:**没有 schoolid**(由 campusid 反查机构),有 activityid。
 	callCancelRecharge = "CALL sp_user_expense_cancel_recharge_activity(?, ?, ?, ?, ?, ?, ?)"
+	// SP9 自定义退费:(userid, schoolid, campusid, money, requestid, remark, createUserid)
+	callCustomRefund = "CALL sp_user_expense_custom_refund(?, ?, ?, ?, ?, ?, ?)"
 )
 
 // returnsResultSet 标记"该 SP 会 SELECT 出结果集"。
@@ -57,7 +59,10 @@ const (
 // (成功:`SELECT schoolid, success, msg`;找不到校区:`SELECT success, msg`)。
 // 这类 SP 不能用 `Exec` 调 —— 必须把结果集读干净,否则连接上会留下未消费的结果集。
 // 而且**结果集本身是契约**:`success=0` 是"成功返回但业务失败",只看 error 会漏掉。
-func returnsResultSet(op Op) bool { return op == OpCancelRechargeActivity }
+func returnsResultSet(op Op) bool {
+	// SP8 与 SP9 的每条路径都以 `SELECT ...` 结尾(成功 `success,msg`;失败也 `success,msg`)。
+	return op == OpCancelRechargeActivity || op == OpCustomRefund
+}
 
 // callArgs 按 Op 组出 CALL 的实参(顺序与 dump 一致)。
 func callArgs(c Case) []any {
@@ -89,6 +94,10 @@ func callArgs(c Case) []any {
 		// SP8:(userid, activityid, campusid, money, requestid, remark, createUserid) —— **无 schoolid**
 		return []any{c.LegacyUserID, c.ActivityID, c.CampusID, moneyYuan,
 			c.RequestID, c.Remark, c.LegacyUserID}
+	case OpCustomRefund:
+		// SP9:(userid, schoolid, campusid, money, requestid, remark, createUserid) —— **有 schoolid**
+		return []any{c.LegacyUserID, c.InstitutionID, 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}
@@ -114,6 +123,8 @@ func callStmt(op Op) string {
 		return callClearBalance
 	case OpCancelRechargeActivity:
 		return callCancelRecharge
+	case OpCustomRefund:
+		return callCustomRefund
 	default:
 		return callExpenditure
 	}
@@ -133,6 +144,17 @@ func (s *legacySide) Run(ctx context.Context, c Case) (*SideResult, error) {
 			return nil, err
 		}
 	}
+	// C2-7 专用:**只在 legacy 侧**把校区余额改成 NULL(target 的列不允许 NULL)。
+	// 必须在 before 快照之前,否则 before 里读不到 NULL。
+	if c.LegacyCampusBalanceNull {
+		if err := tx.Exec(`
+UPDATE t_user_expense_school SET balance = NULL
+ WHERE userid = ? AND schoolid = ? AND campusid = ?`,
+			c.LegacyUserID, c.InstitutionID, c.CampusID).Error; err != nil {
+			return nil, fmt.Errorf("legacy 注入 NULL 余额失败:%w", err)
+		}
+	}
+
 	// 活动侧种子(只有会动活动状态的 case 需要)。必须在 before 快照之前,
 	// 否则 before 里读不到刚种下的报名行,"变化量"会算错。
 	if err := seedLegacyActivities(ctx, tx, c, c.LegacyUserID); err != nil {

+ 182 - 0
internal/academic/dualrun/stage2/run.go

@@ -5,6 +5,8 @@ import (
 	"fmt"
 	"io"
 	"strings"
+
+	"gorm.io/gorm"
 )
 
 // 本文件是比对与编排。
@@ -300,6 +302,100 @@ func DefaultCases(_ string) []Case {
 		},
 	)
 
+	// ---- 批 C2:自定义退费 SP9(7 参,**带 schoolid**,自带结果集)----
+	//
+	// 全部用**真实账户**(campus 5 自营、可失效报名 0、余额 2 位/3 位如实),不用 Baseline 造数。
+	// 选人时额外核了一层:`ROUND(balance,0)` 与 `ROUND(ROUND(balance,2),0)` 必须相等 ——
+	// 否则"迁移 3→2 位收敛"会和"SP 整数化"两个成因混在一起,看不出是哪一条在起作用。
+	out = append(out,
+		Case{
+			Name: "C2-1 自定义退费·整数余额不超退(两侧一致)",
+			Op:   OpCustomRefund, LegacyUserID: 68542, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 30000, RequestID: "ac15-c2-1", RunTimes: 1, SelfSupport: true,
+			Remark:              "C2 正常不超退",
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "余额 9740.00、退 300.00:整数余额 ⇒ legacy 的 DECIMAL(10,0) 整数化不产生差异 ⇒ " +
+				"**两侧应当完全一致**(走 ELSE:无 108、1 条 207)",
+		},
+		Case{
+			Name: "C2-1b 自定义退费·整数余额超退(两侧一致)",
+			Op:   OpCustomRefund, LegacyUserID: 67991, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 700000, RequestID: "ac15-c2-1b", RunTimes: 1, SelfSupport: true,
+			Remark:              "C2 整数超退",
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "余额 6800.00、退 7000.00:整数余额 + 超退 ⇒ 两侧走同一条坏账分支且金额都是整数 ⇒ " +
+				"**第二条能求一致的 case**(108=+200 / 207=−7000)。没有它,10 条里只有 1 条在验一致性",
+		},
+		Case{
+			Name: "C2-2 【A 象限】余额 (0,0.5):legacy 拒绝 / target 退得出来",
+			Op:   OpCustomRefund, LegacyUserID: 29131, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 28, RequestID: "ac15-c2-2", RunTimes: 1, SelfSupport: true,
+			Remark:              "C2 A 象限",
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "余额 0.280:legacy `_balance=ROUND(0.28,0)=0` ⇒ **拒绝「余额不能≤0」、零写入**;" +
+				"target 余额 0.28>0 ⇒ **照常退 0.28**。方向相反:旧系统退不出、新系统能退",
+		},
+		Case{
+			Name: "C2-3 【B 象限】余额 [0.5,1):两侧金额全不同(空 remark 覆盖默认值)",
+			Op:   OpCustomRefund, LegacyUserID: 6449, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 150, RequestID: "ac15-c2-3", RunTimes: 1, SelfSupport: true,
+			Remark:              "", // 故意留空:覆盖「先置 '余额退费' 再拼 <br/>」这条默认值路径
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "余额 0.832、退 1.50(**主件 remark 留空**):legacy `_balance=1`、`_badDate=ROUND(0.5,0)=1`、" +
+				"扣 1 → 余额 −0.168;target refunded=0.83、badDebt=0.67 → 余额 0.00。108 的默认备注应是 " +
+				"「余额退费<br/>退费的金额大于可退费部分,超出部分为坏账部分。」,207 是「余额退费」",
+		},
+		Case{
+			Name: "C2-4 【C 象限】余额 ≥1 且分位<0.5:legacy 留正零头",
+			Op:   OpCustomRefund, LegacyUserID: 68563, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 60000, RequestID: "ac15-c2-4", RunTimes: 1, SelfSupport: true,
+			Remark:              "C2 C 象限",
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "余额 475.376、退 600.00:legacy `_balance=475` ⇒ 扣 475、余额留 0.376、坏账 125;" +
+				"target refunded=475.38、badDebt=124.62、余额 0.00",
+		},
+		Case{
+			Name: "C2-5 【D 象限】余额 ≥1 且分位恰为 .500(半进远离零的哨兵)",
+			Op:   OpCustomRefund, LegacyUserID: 68785, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 2000, RequestID: "ac15-c2-5", RunTimes: 1, SelfSupport: true,
+			Remark:              "C2 D 象限",
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "余额 **12.500**、退 20.00:`ROUND(12.5,0)` 半进远离零 = **13**(不是 12)⇒ legacy 扣 13、余额 −0.5;" +
+				"target refunded=12.50、badDebt=7.50、余额 0。真实库里有 6 个余额恰为 .500 的账户,不是人造边界;" +
+				"这条同时是「round0 实现成 math.Round 还是 RoundToEven」的唯一哨兵",
+		},
+		Case{
+			Name: "C2-6 【缝区间】inputMoney ∈ (真实余额, 整数化余额]:连分支都不同",
+			Op:   OpCustomRefund, LegacyUserID: 67978, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 69999, RequestID: "ac15-c2-6", RunTimes: 1, SelfSupport: true,
+			Remark:              "C2 缝区间",
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "余额 699.980、退 699.99:legacy `_balance=700`,`699.99 > 700` 为**假** ⇒ 走 **ELSE**、" +
+				"**无 108 凭证**、余额 −0.01;target refunded=699.98、badDebt=0.01 ⇒ **有 108**、余额 0。" +
+				"同一笔操作,一边「变负数且无凭证」、一边「转坏账且有凭证」",
+		},
+		Case{
+			Name: "C2-8 【F】校区账户行不存在(legacy 静默 success=0 / target 3102)",
+			Op:   OpCustomRefund, LegacyUserID: 40575, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 10000, RequestID: "ac15-c2-8", RunTimes: 1, SelfSupport: false,
+			NoCampusAccount: true, IntentionalStrict: true,
+			ExpectTargetSuccess: false, ExpectLegacySuccess: true,
+			ExpectTargetCode: 3102,
+			Note: "40575 只有 campus 4 账户、campus 5 没有行。legacy 的 `SELECT ... INTO` **不改变量** ⇒ `_balance` 停在默认值 0 ⇒ " +
+				"静默 `success=0`;Go `GetCampusForUpdate` 找不到 ⇒ **3102**。" +
+				"⚠ 与 SP8(cancel_recharge_activity)**相反**:SP8 会先 `INSERT` 建行(金额留 NULL),SP9 不建行 —— 两条 SP 的同类分支行为不同",
+		},
+		Case{
+			Name: "C2-9 自定义退费·幂等(同一 requestId 跑 2 次)",
+			Op:   OpCustomRefund, LegacyUserID: 65055, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 30000, RequestID: "ac15-c2-9", RunTimes: 2, SelfSupport: true,
+			Remark:              "C2 幂等",
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "target 第 2 次 `repeated=true`、只扣 1 次;legacy SP **没有幂等键** ⇒ 真的扣 2 次(600.00、2 条 207)。" +
+				"两侧金额本来就不该相等,按 per-side 断言处理,不为消红放宽判据",
+		},
+	)
+
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
@@ -322,16 +418,72 @@ func DefaultCases(_ string) []Case {
 	return out
 }
 
+// ProbeCases 返回**不构成验收**的探针 case(不参与 DefaultCases、不进分母)。
+//
+// 目前只有一条:C2-7(E 象限,legacy 侧校区余额为 NULL)。
+//
+// 为什么必须把它单独摘出去,而不是塞在 DefaultCases 里"顺手跑一下":
+//
+//	它的第二侧**不同构** —— target 的 `t_user_expense_school.balance` 是 `not null default 0.00`,
+//	NULL 存不下,只能喂 0.00。于是"两侧都拒绝"这个结论在任何一条余额为 0 的记录上都成立,
+//	**那条绿的含金量是 0**:它证明的不是"行为一致",而是"我们没能构造出同构输入"。
+//	把它算进 PASS 数,等于用一条必然绿的 case 抬高分母的通过率。
+//
+// 保留它的理由只有一个:它是"NULL 余额在新库不可表达"这个事实的**可执行证据**,
+// 而那条事实正是 C1-11(建行留 NULL、钱扣不动)能变成 LEGACY-DEFECT 的依据。
+//
+// 用法:`dualrun stage2 -probe`(分母里会明确写 `探针`,且不会被当成验收)。
+func ProbeCases() []Case {
+	return []Case{
+		{
+			Name: "C2-7 【E·探针】legacy 余额 NULL / target 0.00(非同构,两侧都拒绝;**不进 DefaultCases**)",
+			Op:   OpCustomRefund, LegacyUserID: 29131, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 28, RequestID: "ac15-c2-7e", RunTimes: 1, SelfSupport: true,
+			Baseline:                &Baseline{GlobalBalance: 0, CampusBalance: 0},
+			LegacyCampusBalanceNull: true,
+			SpecialCase:             SpecialNullBalanceReject,
+			ExpectTargetSuccess:     false, ExpectLegacySuccess: true,
+			ExpectTargetCode:        3102,
+			Note: "**非同构探针**:target 的 `balance` 是 `not null default 0.00`,NULL 存不下 ⇒ 只能给 0.00。" +
+				"legacy 侧 NULL 走 `ISNULL(_balance)` **短路**(`OR` 左边就 LEAVE,右边不求值)、target 侧 0.00 走 `balance<=0` —— " +
+				"理由不同、结论一致(都拒绝)。它证明的是「NULL 余额这个状态在新库不可表达」,**不是**「两侧行为一致」",
+		},
+	}
+}
+
+// closeDB 立刻释放 gorm 句柄底层的连接池。
+//
+// 为什么必须显式关(这是一个**真实踩到的坑**,不是洁癖):
+// `cfg.open` 每次调用都新建一个 `*sql.DB`(`MaxOpenConns=1`),而 `RunOne` **每个 case**
+// 会打开 5 个句柄 —— legacy 侧 / target 侧 / 天然基线 / 残留检查 ×2,每个只为几次查询服务。
+// 不关的话连接要等 GC 的 finalizer 才释放,于是连接数随 case 数线性增长:
+//
+//	25 条 case(批 C1)= 125 个连接 → 刚好压在 MySQL 的 `max_connections` 边缘
+//	34 条 case(批 C2)= 170 个连接 → `Error 1040 (08004): Too many connections`,整批中断
+//
+// ⚠ 这个失败**报在第 N 条 case 上,根因却在第 1 条** —— 看起来像"case 太多了、要分批跑",
+// 实际是连接泄漏。按"分批跑"处置会永久掩埋它:以后每加一条 case 都要重新踩一次。
+func closeDB(db *gorm.DB) {
+	if db == nil {
+		return
+	}
+	if sqlDB, err := db.DB(); err == nil {
+		_ = sqlDB.Close()
+	}
+}
+
 // RunOne 跑一个 case 的完整双跑闭环。
 func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 	legacyDB, err := cfg.OpenWriteLegacy()
 	if err != nil {
 		return nil, err
 	}
+	defer closeDB(legacyDB) // 每个 case 都有自己的句柄,跑完必须还回去(见 closeDB 的注释)
 	targetDB, err := cfg.OpenTarget()
 	if err != nil {
 		return nil, err
 	}
+	defer closeDB(targetDB)
 
 	uid := CanonicalUIDFor(c.LegacyUserID)
 	res := &CaseResult{Case: c}
@@ -462,6 +614,18 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
 	}
 
+	// ⓪-1 SP9 自定义退费:legacy 把金额整数化、target 用精确值 ⇒ **per-side 比对**
+	// (通用比对的"两侧 Δ 相等"在这一支上必然红,因为两侧本来就是两个算法)。
+	//
+	// ⚠ 必须排除 `IntentionalStrict`(C2-8:校区账户行不存在)—— 那条是
+	// "legacy 静默 success=0 / target 显式 3102",连 per-side 期望都不成立
+	// (legacy 侧根本没有账户行可比),要交给下面的 intentional-strict 分支。
+	// 如果这里把它一并接过来,C2-8 会走 per-side 然后因为"期望扣款"而假红 ——
+	// 而且更糟:它会被当成"两侧算法差异",把一条**结构性缺失**混进舍入差异里。
+	if c.Op == OpCustomRefund && c.SpecialCase == "" && !c.IntentionalStrict {
+		return customRefundCompare(c, lg, tg)
+	}
+
 	// ⓪-0 特殊 case:自带整套断言,**不参与通用比对**(见 Case.SpecialCase 的说明)
 	if c.SpecialCase != "" {
 		switch c.SpecialCase {
@@ -471,6 +635,8 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			return campusRowNullCompare(c, lg, tg)
 		case SpecialPredicateCollapse:
 			return predicateCollapseCompare(c, lg, tg)
+		case SpecialNullBalanceReject:
+			return nullBalanceRejectCompare(c, lg, tg)
 		}
 		return nil, []Assertion{{
 			Name: "未知的 SpecialCase:" + c.SpecialCase, Pass: false,
@@ -522,6 +688,16 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 				Detail: fmt.Sprintf("实测 schoolid=%q success=%q msg=%q", r["schoolid"], r["success"], r["msg"]),
 			})
 		}
+		// SP9 同理:校区账户行不存在时 `SELECT ... INTO` 不改变量 ⇒ `_balance` 停在 0
+		// ⇒ 走 `_balance<=0` 的 LEAVE,返回 **success=0 + msg='余额不能≤0'**,调用方看到的是"成功"。
+		if c.Op == OpCustomRefund && len(lg.Calls) > 0 && lg.Calls[0].Returned != nil {
+			r := lg.Calls[0].Returned
+			asserts = append(asserts, Assertion{
+				Name:   "SP9 返回值:校区账户行不存在时 **success=0 + msg='余额不能≤0'**(不抛错,靠返回值表达失败)",
+				Pass:   r["success"] == "0" && r["msg"] == "余额不能≤0",
+				Detail: fmt.Sprintf("实测 success=%q msg=%q(SP 的 `SELECT ... INTO` 无行可赋 ⇒ 变量停在默认 0,与'余额真的是 0'不可区分)", r["success"], r["msg"]),
+			})
+		}
 		return diffs, asserts
 	}
 
@@ -835,6 +1011,7 @@ func readLegacyNatural(ctx context.Context, cfg Config, legacyUserID int64) (Acc
 	if err != nil {
 		return Account{}, 0, err
 	}
+	defer closeDB(db)
 	var nat accountNS
 	err = db.Raw(`
 SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
@@ -862,6 +1039,7 @@ func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural A
 	// target:本 case 种进去的行必须全部消失(这些行本来就只在事务里存在)
 	tdb, err := cfg.OpenTarget()
 	if err == nil {
+		defer closeDB(tdb)
 		var n int64
 		_ = tdb.Raw(`SELECT COUNT(*) FROM t_user_expense WHERE user_id = ?`, uid).Row().Scan(&n)
 		rep.TargetGlobalExist = n > 0
@@ -876,6 +1054,7 @@ func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural A
 	// legacy:用户本来就存在,核对"余额回到调用前"
 	ldb, err := cfg.OpenWriteLegacy()
 	if err == nil {
+		defer closeDB(ldb)
 		var cur accountNS
 		err := ldb.Raw(`
 SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
@@ -1148,6 +1327,9 @@ func PrintResult(w io.Writer, r *CaseResult) {
 		if !a.Pass {
 			mark = "✘"
 		}
+		if a.Skipped {
+			mark = "—" // 结构性不适用:不是"证明过",是"本 case 无从验证"
+		}
 		fmt.Fprintf(w, "    %s %s\n       %s\n", mark, a.Name, a.Detail)
 	}
 

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

@@ -181,6 +181,20 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 				oc.Balance = int64(out.Balance)
 				oc.LogID = out.LogID208
 			}
+		case OpCustomRefund:
+			// SP9 → RefundService.CustomRefund(双 log:先 108 后 207)
+			out, err := refundSvc.CustomRefund(ctx, service.CustomRefundInput{
+				UserID: canonicalUID, InstitutionID: c.InstitutionID, 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.LogID207
+			}
 		case OpCancelRechargeActivity:
 			// SP8 → RecommendService.CancelRechargeActivity(它内部再转 RefundService,同一事务)
 			out, err := recommendSvc.CancelRechargeActivity(ctx, service.CancelRechargeActivityInput{