Pārlūkot izejas kodu

fix(ac15): C1 收口六项——recommended 进快照、C1-5 拆两条、self_support 改 CAST、存量哨兵、CLI 四道闸

## 1. 三态保留(不变)
`2513908` 的 `AccountNulls` / `Account.Nulls` / `sub()` NULL 传播原样保留。

## 2. 补第六个可空列 total_recommended_amount
它此前**从未进过快照**(只出现在 target.go 的种子 INSERT),不是"三态漏了它"。
改动:`AccountNulls.Recommended` +1 字段、`Account.Recommended` +1 列、legacy/target 各 3 处 SELECT +1 列
(legacy 侧不包 IFNULL)、`sub()` +1 行、`addDelta` +1 行。

按 team-lead 的要求**不做成 per-case 空过**:报告里这一行打 `—`(`Diff.Skipped` 第三态),
不是 ✔ —— 当前 7 支 SP 都不写该列,`0 == 0` 与"真的比过且相等"同形就是空过。
只要实现意外写了它(非 0)就不再 skip ⇒ 立刻红;等 use_activity_recommend 进来,
把 Op 加进 `opWritesRecommended`,这一行自动变真断言。

## 3. C1-5 拆两条
- C1-5 恢复 `Baseline{50000, 80000}`(500/800 元):两位小数下不等式仍成立 ⇒ **测守卫本身**,两侧都 LEAVE。
- C1-5b 新增,真实构型 65496(campus 5 自营;校区 1565.688 / 全局 1565.687):
  分类 PRECISION-CONVERGENCE + subtype PREDICATE-COLLAPSE;两侧**各自**断言。
  detail 打 3 位原值(`readRaw3dp` 走 `CAST(balance AS CHAR)`,**只在** SpecialPredicateCollapse 分支,
  不进共享快照);target 侧**不打三位**(存储是 decimal(12,2),多打一位等于造精度)。
  case 总数 24 → 25。

## 4. self_support 改 CAST(self_support AS UNSIGNED) 扫 int64
旧代码 `== "1" || == "\x01"` 是"两支猜测兼容",注释还写错成"不是 bit(1)"。
实测 `yuansiwei.t_school_campus`(VIEW) 与 `yuansiwei_i.t_school_campus`(BASE TABLE) 都是 bit(1)。
按注释删掉 `\x01` 会**静默恒 false** ⇒ 活动失效范围错且不报错。
回归点已核:C1-1 `自营=true`、C1-2 `自营=false`。

## 5. 新增 `dualrun sentinel`:存量哨兵(不进 DefaultCases)
扫 `全局列 <=> Σ各校区列`(**NULL 安全相等,禁止用 <>**)。硬要求:
`nonzeroSamples` 一起报、为 0 判 FAIL("样本消失,断言失效")、独立入口。
实测 finance_bk:special_expenses 4661/0/154 PASS;total_recommended_amount 4661/0/783 PASS。

## 6. CLI 四道闸(分母收缩护栏)
① DefaultCases 为空 → FATAL(3);② `-case` 按**名字首段精确匹配**(`C1-5` 不带出 `C1-5b`);
③ 匹配不到/匹配到多条 → FATAL(2)(原来打错名字会"0 个 case、退出 0");
④ 实跑数 ≠ 本次应跑数 → FATAL(3)。摘要先打一行分母,定向跑额外警告"不构成验收"。

## 验证(干净工作区)
hash / DefaultCases / 实跑 / PASS / FAIL / 崩溃 =
2513908 之后本 commit:25 / 25 / 25 / 0 / 0
哨兵:2 列均 PASS;单测新增 TestThousandths / TestStockSentinelVerdict /
TestRecommendedRowIsSkipped / TestCaseNameMatches。
Q 3 stundas atpakaļ
vecāks
revīzija
f73f560528

+ 98 - 2
cmd/dualrun/main.go

@@ -38,6 +38,8 @@ func main() {
 		os.Exit(runStage1(os.Args[2:]))
 	case "stage2":
 		os.Exit(runStage2(os.Args[2:]))
+	case "sentinel":
+		os.Exit(runSentinel(os.Args[2:]))
 	case "-h", "--help", "help":
 		usage()
 		os.Exit(0)
@@ -54,6 +56,7 @@ func usage() {
 子命令:
   stage1    跑课酬链比对(课酬,纯函数)
   stage2    跑写 SP 双跑(消费:sp_user_expense_school_expenditure ↔ FundService.Expenditure)
+  sentinel  跑存量哨兵:全库扫「全局列 <=> Σ校区列」(不挂在 stage2 的 case 里)
 
 stage1 参数:
   --case-set string   real100 | synthetic | all(默认 all)
@@ -125,6 +128,56 @@ func runStage1(args []string) int {
 	return exit
 }
 
+// caseNameMatches 判断 `-case` 是否命中该 case 名。
+//
+// 语义:**按名字首段精确匹配**,不是子串。
+//
+//	"-case C1-5"  → 命中 "C1-5 清零前置校验·…",**不命中** "C1-5b …"("C1-5b 清零" 不以 "C1-5 " 开头)
+//	"-case C1-5xx" → 一条都不命中 → FATAL
+//
+// 为什么不做纯子串:子串会让 "C1-5" 把 "C1-5b" 一起带进来,"case 总数"就错了,
+// 正好让 team-lead 定的"hash + 总数 + 通过数三者对齐"失效。
+// 为什么不做纯全等:那要用户连中文后缀一起打,实际不可用。
+func caseNameMatches(name, only string) bool {
+	if name == only {
+		return true
+	}
+	return strings.HasPrefix(name, only+" ")
+}
+
+// runSentinel 跑存量哨兵(全库扫描,与具体 case 无关)。
+func runSentinel(args []string) int {
+	fs := flag.NewFlagSet("sentinel", flag.ContinueOnError)
+	fs.SetOutput(os.Stderr)
+	dbName := fs.String("db", "finance_bk", "扫描的库(finance_bk | yuansiwei_2026)")
+	if err := fs.Parse(args); err != nil {
+		return 2
+	}
+	repoRoot, err := os.Getwd()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: 取工作目录失败:%v\n", err)
+		return 3
+	}
+	cfg, err := stage2.LoadConfig(repoRoot)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	if err := cfg.Guard(); err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	ok, err := stage2.RunStockSentinels(context.Background(), cfg, *dbName, os.Stdout)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: 哨兵执行失败:%v\n", err)
+		return 3
+	}
+	if !ok {
+		return 1
+	}
+	return 0
+}
+
 // runStage2 跑写 SP 双跑(第一支:消费)。
 func runStage2(args []string) int {
 	fs := flag.NewFlagSet("stage2", flag.ContinueOnError)
@@ -150,11 +203,25 @@ func runStage2(args []string) int {
 	fmt.Printf("%s\n\n", cfg.Masked())
 
 	ctx := context.Background()
+	all := stage2.DefaultCases("")
+
+	// 检查 ①:case 列表不能为空。
+	// 防"case 列表被清空 / build tag 没生效 / DefaultCases 被改坏" —— 那种情况下
+	// `Summarize` 会返回 `0 == 0 = true`,跑了个寂寞但退出 0。
+	if len(all) == 0 {
+		fmt.Fprintf(os.Stderr, "FATAL: DefaultCases 长度为 0,没有任何 case 可跑\n")
+		return 3
+	}
+
+	// 检查 ②:`-case` **精确匹配**(不是子串)。
+	// 子串匹配会让 `-case C1-5` 把 `C1-5b` 一起带进来,"case 总数"就错了。
+	matched := 0
 	var results []*stage2.CaseResult
-	for _, c := range stage2.DefaultCases("") {
-		if *only != "" && !strings.Contains(c.Name, *only) {
+	for _, c := range all {
+		if *only != "" && !caseNameMatches(c.Name, *only) {
 			continue
 		}
+		matched++
 		r, err := stage2.RunOne(ctx, cfg, c)
 		if err != nil {
 			fmt.Fprintf(os.Stderr, "FATAL: case %q 执行失败:%v\n", c.Name, err)
@@ -163,6 +230,35 @@ func runStage2(args []string) int {
 		stage2.PrintResult(os.Stdout, r)
 		results = append(results, r)
 	}
+
+	// 检查 ③:`-case` 必须**恰好**匹配 1 条 —— 匹配不到或匹配到多条都报错退非 0。
+	// 否则 0 个 case 会以退出码 0 "通过"(`Summarize` 的 `0 == 0` 是 true)。
+	if *only != "" && matched == 0 {
+		fmt.Fprintf(os.Stderr, "FATAL: -case %q 没有匹配到任何 case(按名字首段精确匹配,不是子串)\n", *only)
+		return 2
+	}
+	if *only != "" && matched > 1 {
+		fmt.Fprintf(os.Stderr, "FATAL: -case %q 匹配到 %d 条,定向跑必须恰好 1 条\n", *only, matched)
+		return 2
+	}
+	if *only != "" {
+		fmt.Printf("定向 case:%s\n", results[0].Case.Name)
+	}
+
+	// 检查 ④:实跑数必须等于"本次应跑数"(**分母收缩**护栏)。
+	// 防将来有人把 `RunOne` 的 `return 3` 改成 `continue` —— 那时分母会悄悄从 25 变 22,
+	// 而结论仍是"全 PASS"。分母必须是"应该跑的全部",不是"真正跑完的"。
+	if len(results) != matched {
+		fmt.Fprintf(os.Stderr, "FATAL: 分母收缩 —— 应跑 %d 条,实跑 %d 条\n", matched, len(results))
+		return 3
+	}
+
+	fmt.Printf("分母:DefaultCases=%d | 本次应跑=%d | 实跑=%d\n", len(all), matched, len(results))
+	if *only != "" {
+		fmt.Printf("⚠ 定向跑:仅 1 条,**不构成验收** —— 验收必须全量跑(不带 -case)\n")
+	}
+	fmt.Println()
+
 	if ok := stage2.Summarize(os.Stdout, results); !ok {
 		return 1
 	}

+ 32 - 0
cmd/dualrun/main_test.go

@@ -0,0 +1,32 @@
+package main
+
+import "testing"
+
+// TestCaseNameMatches 锁住 `-case` 的匹配语义:**按名字首段精确匹配**。
+//
+// 这条不是洁癖:纯子串匹配会让 `-case C1-5` 把 `C1-5b` 一起带进来,
+// "本次应跑/实跑"的数就错了,正好让"hash + 总数 + 通过数三者对齐"失效。
+func TestCaseNameMatches(t *testing.T) {
+	name5 := "C1-5 清零前置校验·校区余额 > 全局余额(legacy 静默 vs Go 3120)"
+	name5b := "C1-5b 清零前置校验·校区余额 > 全局余额(真实 3 位构型,2 位收敛后守卫消失)"
+
+	if !caseNameMatches(name5, "C1-5") {
+		t.Error("-case C1-5 应命中 C1-5")
+	}
+	if caseNameMatches(name5b, "C1-5") {
+		t.Error("-case C1-5 **不得**命中 C1-5b(子串匹配的坑)")
+	}
+	if !caseNameMatches(name5b, "C1-5b") {
+		t.Error("-case C1-5b 应命中 C1-5b")
+	}
+	if caseNameMatches(name5, "C1-5xx") {
+		t.Error("打错名字必须一条都不命中(否则会以 0 个 case 退出 0)")
+	}
+	if !caseNameMatches(name5, name5) {
+		t.Error("全名必须能精确命中")
+	}
+	// "C1-1" 不得命中 "C1-10"/"C1-11"(前缀但不同 token)
+	if caseNameMatches("C1-10 取消充值活动·退成负数", "C1-1") {
+		t.Error("-case C1-1 不得命中 C1-10")
+	}
+}

+ 135 - 11
internal/academic/dualrun/stage2/README.md

@@ -640,19 +640,143 @@ if out.HasCampusAccount { out.Campus = sc.account() }   // 无行 → 保持零
 
 判定 **LEGACY-DEFECT**:legacy 两表自相矛盾,target(`NOT NULL DEFAULT 0.00`)才是对的,不跟。
 
-### 8.5 一个仍然红灯的 case(留给决策,不是缺陷修复)
+### 8.5 判据被小数收敛抹平:C1-5 与 C1-5b(两条并存)
 
-`C1-5 清零前置校验·校区余额 > 全局余额(真实构型)`:legacy 静默 LEAVE、Go 却**成功扣款**。
-根因是**两位数迁移让不等式消失了**:
+**先说一个被订正过的错误结论**:本文件早期写过"这条守卫在两位小数下**不可达**"——**错的**。
+按迁移口径复算(4661 个有校区行的用户):
 
+| 口径 | 3 位成立 | 2 位仍成立 | 2 位被抹平 |
+|---|---|---|---|
+| `campus>0` | 49 | 36 | 13 |
+| `campus>0 且 total>0`(第三条守卫**真正可达**) | **21** | **11** | **10** |
+
+`49` 里有 28 行 `total<=0`,那些 legacy 是被**第二条**守卫挡的,而 target 的 `ClearBalance`
+同样有 `totalBalance<=0 → 3120` ⇒ 两侧一致、**不分叉**。所以真正会分叉的只有 **10 行**:
+
+> **在第三条守卫真正起作用的 21 个真实构型里,有 10 个(47.6%)迁移后失去触发条件。**
+
+**不是"该守卫失效",是"它仍在起作用,但近一半输入迁移后不再触发"** —— 所以守卫要保留,
+这条差异要登记,但不能让它把主干搞红。
+
+因此拆成两条:
+
+| case | 构型 | 测什么 | 断言 |
+|---|---|---|---|
+| **C1-5** | `Baseline{500, 800}`(造数) | **守卫本身**:两位小数下不等式仍成立 | 两侧都 LEAVE、零写入 |
+| **C1-5b** | 真实 `65496`:校区 1565.688 / 全局 1565.687 | **判据输入被收敛抹平** | 两侧**各自**断言,不做"两侧相等" |
+
+C1-5b 的分类是 `PRECISION-CONVERGENCE` + subtype `PREDICATE-COLLAPSE`:
+
+- **顶层类 = 成因**(3→2 位收敛,与阶段 1 同源,所以不新造顶层类);
+- **subtype = 可观测形态**(**分支不同**,不是"差几分钱")。按 subtype 过滤才能找到它,
+  否则会被"只差几分钱"的噪声淹掉;
+- `INPUT-TRUNCATION`(字符串截断导致判据变化,如 `logid varchar(32)` vs `varchar(64)`)
+  **不属于**这个 subtype —— 处置动作不同,混在一起子类就白分了。
+
+**C1-5b 的报告必须打 3 位原值。** 否则两侧都显示 `1565.69`,读者只会认为 harness 坏了:
+
+```
+legacy 校区余额(原值 3 位)   1565.688 → 1565.688(LEAVE,未写入)
+legacy 全局余额(原值 3 位)   1565.687
+target 校区余额(存储 2 位)   1565.69  → 0.00
+判据:legacy 1565.688 > 1565.687 成立 ⇒ LEAVE|target 1565.69 > 1565.69 不成立 ⇒ 放行
+```
+
+实现上:3 位原值**只在 `SpecialPredicateCollapse` 分支读**(`readRaw3dp`,`CAST(balance AS CHAR)`),
+**不进共享快照** —— 进了就会改变另外 24 条的读数口径。target 侧**不许**打三位:
+它的存储是 `decimal(12,2)`,打 `1565.690` 等于凭空造一个它没有的精度位,
+与"把 NULL 折成 0"是同一类错误(**用呈现掩盖真实数据形状**)。
+
+---
+
+## 9. 第六个可空列:`total_recommended_amount`
+
+`AccountNulls` 原来只有 5 列,漏了 `total_recommended_amount`(两张表都是 `DEFAULT NULL`)。
+它此前**从未进过快照** —— 不是"三态漏了它",是"整个 harness 从没看它"。
+
+补上之后(`AccountNulls.Recommended` + `Account.Recommended` + 3 处 SELECT + `sub()` 一行 + `addDelta` 一行),
+报告里每个 case 多一行 `Δtotal_recommended_amount`。**但这一行打的是 `—` 而不是 `✔`**:
+
+```
+— 全局 Δtotal_recommended_amount  legacy=0.00  target=0.00  期望 0.00 / 0.00(本 Op 不写该列 ⇒ 结构性不适用)
+```
+
+为什么不用 ✔:当前 7 支 SP **都不写**这一列,`0 == 0` 会和"真的比过且相等"长得一样 —— **空过**。
+所以 `Diff` 加第三态 `Skipped`,报告打 `—`。
+
+这一行的判定是 `opWritesRecommended(op)`:
+
+- 现在恒 `false` ⇒ 只要两侧实测都是 0 就打 `—`;
+- **一旦哪天有实现意外写了它(非 0),就不再 skip ⇒ 立刻红**;
+- 等 `sp_courses_activity_use_activity_recommend`(唯一写它的 SP)进来,把对应 Op 加进
+  那个 switch,这一行自动变成**真断言** —— 位置早就留好了,不会因为"这条不存在"而漏掉。
+
+## 10. 存量哨兵:与 case 无关的全库扫描
+
+```bash
+go run ./cmd/dualrun sentinel              # 默认扫 finance_bk
+go run ./cmd/dualrun sentinel -db yuansiwei_2026
 ```
-48170: 全局 balance = 0.269 ( float )  → ROUND 后 0.27
-       校区 balance = 0.270 (decimal)  → ROUND 后 0.27
-legacy 拿三位小数比:0.270 > 0.269 → LEAVE
-target 只能拿两位小数比:0.27 > 0.27 → 不成立 → 继续清零
+
+为什么**不能**挂在 case 里:`special_expenses` / `total_recommended_amount` 在迁移后
+**0 不一致**(154 / 783 个非零样本全对上),但把它写成 per-case 断言,
+在 96.7% / 83.2% 的 case 上就是 `0 == 0` —— 又一个空过。它守的是**结构性问题**
+("有没有出现只写一边的新路径"),所以只能全库扫。
+
+它断言的是:**`全局列 <=> Σ各校区列`**(NULL 安全相等)。三条硬要求写死在实现里:
+
+1. **禁止用 `<>` 判相等**:`NULL <> x` 求值为 NULL,`CASE WHEN NULL` 会被静默算成"一致"。
+   必须用 `<=>`(NULL 安全)。这是最阴的一种空过 —— 它连"不一致"都算不出来。
+2. **`nonzeroSamples` 必须一起报,且为 0 时判 FAIL**("样本消失,断言失效")。
+   一个全 0 的库上,`0 == 0` 会永远绿。
+3. **独立入口,不进 `DefaultCases`**。
+
+实测(`finance_bk`):
+
+```
+  列                        扫描用户  不一致  非零样本  结论
+  special_expenses              4661       0       154  PASS
+  total_recommended_amount      4661       0       783  PASS
 ```
 
-这不是 Go 的缺陷(它看不到第三位),也不是 harness 的缺陷,而是**迁移口径丢失了业务规则依赖的信息**。
-两条可选处置(未实施,等裁决):
-1. 换一个"不等式在两位下仍成立"的构型(如校区 800 / 全局 500),判据不变;
-2. 登记为 `INTENTIONAL-COLLAPSE`,两侧各自断言实际行为(legacy 不动 / target 清零)。
+顺带一条**断言设计规则**(这批数据支撑的):跨表存量恒等式**不能一刀切**,要按列实证 ——
+
+| 列 | 迁移后不一致 | 结论 |
+|---|---|---|
+| balance / spend / total_income / total_expenditure | 3 / 197 / 27 / 109(占 4661 的 0.06%~4.23%) | **禁止**断言相等(历史单边写入留下的,红得没意义) |
+| special_expenses / total_recommended_amount | **0 / 0**(154 / 783 非零样本) | **可以**断言,且比 Δ **更强** |
+
+注意 `balance` 只有 3 例也**不能当 0 用**(那 3 个人迟早被挑中)。
+而能断言的那两列要按**哨兵**语义用:它测的是"还没有出现单边写入路径",不是"数据本来就该这样"。
+
+## 11. 分母收缩(denominator shrinkage)与 CLI 三道闸
+
+报数时**分母必须是"应该跑的全部",不能是"真正跑完的"**。分母从 25 变成 22 而结论仍是"全 PASS",
+等于什么都没测。它与"空过断言"并列。
+
+`cmd/dualrun stage2` 现在有四道闸:
+
+| # | 闸 | 触发 | 退出码 |
+|---|---|---|---|
+| ① | `DefaultCases` 长度为 0 | 列表被清空 / build tag 没生效 | 3 |
+| ② | `-case` **按名字首段精确匹配**(不是子串) | `-case C1-5` **不会**带出 `C1-5b` | — |
+| ③ | `-case` 匹配不到 / 匹配到多条 | 打错名字(原来会"0 个 case、退出 0") | 2 |
+| ④ | 实跑数 ≠ 本次应跑数 | 有人把 `return 3` 改成 `continue` | 3 |
+
+全量跑时摘要会先打一行分母,定向跑额外打一条 **"不构成验收"** 的警告:
+
+```
+分母:DefaultCases=25 | 本次应跑=25 | 实跑=25
+```
+
+## 12. `self_support` 是 `bit(1)`(订正一处写错的注释)
+
+`yuansiwei.t_school_campus`(VIEW)与 `yuansiwei_i.t_school_campus`(BASE TABLE)的
+`self_support` 都是 **`bit(1)`**,`HEX()` 出来是 `'0'` / `'1'`、`LENGTH()` = 1。
+
+早先的代码用 `== "1" || == "\x01"` **两支猜测**兼容,注释还写"不是 bit(1)"——**注释是错的**。
+功能上恰好走对(bit 扫成字符串是 `\x01`),但下一个人按注释把 `\x01` 删掉留下 `== "1"`,
+就会**静默恒 false**,后果是活动失效范围错(C1 的核心判据),而且**不报错、只假绿**。
+
+现在改成 SQL 里 `CAST(self_support AS UNSIGNED)` 扫 `int64`,让类型系统表达,注释改成实测事实。
+改完的回归点:C1-1 必须仍显示 `自营=true`、C1-2 仍显示 `自营=false`。

+ 8 - 5
internal/academic/dualrun/stage2/c1_activity.go

@@ -354,18 +354,21 @@ func baseActivityID(ctx context.Context, db *gorm.DB) (int64, error) {
 func readLegacyCampusMeta(ctx context.Context, tx *gorm.DB, campusID int64) *CampusMeta {
 	meta := &CampusMeta{}
 	var schoolID sql.NullInt64
-	var selfSupport sql.NullString
+	var selfSupport sql.NullInt64
+	// self_support 在旧库是 **bit(1)**(实测:`yuansiwei.t_school_campus`(VIEW) 与
+	// `yuansiwei_i.t_school_campus`(BASE TABLE) 都是 bit(1);HEX() 出来是 '0'/'1',LENGTH=1)。
+	// 所以**不要**写成 `== "1"` —— bit 值扫成字符串是 `\x01`,`== "1"` 永远 false 且不报错,
+	// 后果是活动失效范围静默错(C1 的核心判据只假绿不报错)。
+	// 让类型系统表达:SQL 里 CAST 成 UNSIGNED,扫 int64。
 	err := tx.Raw(`
-SELECT school_id, self_support FROM yuansiwei.t_school_campus WHERE id = ?`, campusID).
+SELECT school_id, CAST(self_support AS UNSIGNED) 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"
+	meta.SelfSupport = selfSupport.Int64 == 1
 	return meta
 }
 

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

@@ -1,8 +1,13 @@
 package stage2
 
 import (
+	"context"
+	"database/sql"
 	"fmt"
+	"strconv"
 	"strings"
+
+	"gorm.io/gorm"
 )
 
 // 本文件是批 C1(清零 / 取消充值活动)的专属断言。
@@ -422,6 +427,179 @@ func campusRowNullCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	return diffs, asserts
 }
 
+// predicateCollapseCompare 是 PREDICATE-COLLAPSE 类的专用比对。
+//
+// 这类 case 的两侧**本来就不同**,所以不能走通用比对(通用比对的"两侧 Δ 相等"在这里必然红)。
+// 要断言的是三件事:
+//  1. 前提:两侧 **2 位**基线逐分相等 —— 这是"分歧只能来自判据口径"的根据;
+//  2. legacy:按 **3 位**判据 LEAVE、零写入;
+//  3. target:只有 2 位、判据不成立 ⇒ 放行清零,且清零的账要对。
+func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
+	var diffs []Diff
+	var asserts []Assertion
+
+	c3, c3ok := thousandths(lg.RawCampusBalance)
+	t3, t3ok := thousandths(lg.RawTotalBalance)
+	rawCampus, rawTotal := strOr(lg.RawCampusBalance, "<未读到>"), strOr(lg.RawTotalBalance, "<未读到>")
+
+	campusBefore := lg.Before.Campus.Balance
+	totalBefore := lg.Before.Global.Balance
+	amount := campusBefore // 清零金额 = 调用前校区余额
+
+	diffs = append(diffs,
+		Diff{Item: "legacy 校区余额(原值 3 位)",
+			Legacy: rawCampus + " → " + rawCampus, Target: "(target 存储 2 位)", Equal: true, Skipped: true,
+			Note: "LEAVE,未写入;打 3 位是为了让读者看见判据输入"},
+		Diff{Item: "legacy 全局余额(原值 3 位)",
+			Legacy: rawTotal, Target: "(target 存储 2 位)", Equal: true, Skipped: true,
+			Note: "同一时刻的全局余额原值"},
+		Diff{Item: "两侧 2 位基线(校区 / 全局)",
+			Legacy: yuan(campusBefore) + " / " + yuan(totalBefore),
+			Target: yuan(tg.Before.Campus.Balance) + " / " + yuan(tg.Before.Global.Balance),
+			Equal:  campusBefore == tg.Before.Campus.Balance && totalBefore == tg.Before.Global.Balance,
+			Note:   "**前提**:2 位口径下两侧相同 ⇒ 分歧只能来自判据口径"},
+		Diff{Item: "target 校区余额(存储 2 位)",
+			Legacy: "—", Target: yuan(tg.Before.Campus.Balance) + " → " + yuan(tg.After.Campus.Balance),
+			Equal: tg.After.Campus.Balance == 0, Note: "判据不成立 ⇒ 放行清零"},
+	)
+
+	// ① 前提
+	baseOK := campusBefore == tg.Before.Campus.Balance && totalBefore == tg.Before.Global.Balance
+	asserts = append(asserts, Assertion{
+		Name: "前提:两侧 **2 位**基线逐分相等(校区与全局都要相等)",
+		Pass: baseOK,
+		Detail: fmt.Sprintf("legacy 校区=%s 全局=%s | target 校区=%s 全局=%s",
+			yuan(campusBefore), yuan(totalBefore),
+			yuan(tg.Before.Campus.Balance), yuan(tg.Before.Global.Balance)),
+	})
+
+	// ② legacy 的 3 位判据必须**成立**(否则构型选错了,这条 case 什么都证明不了)
+	asserts = append(asserts, Assertion{
+		Name: "legacy:3 位判据 `校区 > 全局` **成立**(构型前提;不成立说明选错人)",
+		Pass: c3ok && t3ok && c3 > t3,
+		Detail: fmt.Sprintf("campus=%s total=%s → %s > %s 成立=%v",
+			rawCampus, rawTotal, rawCampus, rawTotal, c3ok && t3ok && c3 > t3),
+	})
+
+	// ③ target 的 2 位判据必须**不成立**(这就是"放行"的原因)
+	asserts = append(asserts, Assertion{
+		Name: "target:2 位判据 `校区 > 全局` **不成立** ⇒ 放行(分歧的成因:第 3 位被收敛抹平)",
+		Pass: tg.Before.Campus.Balance <= tg.Before.Global.Balance,
+		Detail: fmt.Sprintf("campus=%s total=%s → %s > %s 不成立",
+			yuan(tg.Before.Campus.Balance), yuan(tg.Before.Global.Balance),
+			yuan(tg.Before.Campus.Balance), yuan(tg.Before.Global.Balance)),
+	})
+
+	// ④ legacy 行为:LEAVE + 零写入 + 0 流水
+	lgLEAVE := len(lg.Calls) > 0 && lg.Calls[0].Success
+	lgNoWrite := lg.Before.Global == lg.After.Global && lg.Before.Campus == lg.After.Campus
+	asserts = append(asserts, Assertion{
+		Name: "legacy:`LEAVE` 静默返回、**零写入**、0 条流水",
+		Pass: lgLEAVE && lgNoWrite && len(lg.After.Logs) == 0,
+		Detail: fmt.Sprintf("抛错=%v 全局变=%v 校区变=%v 流水=%d(都应为 false/false/0)",
+			!lgLEAVE, lg.Before.Global != lg.After.Global, lg.Before.Campus != lg.After.Campus,
+			len(lg.After.Logs)),
+	})
+
+	// ⑤ target 行为:清零成功,三列同额 +,1 条 type=208 money=-amount
+	dg := sub(tg.After.Global, tg.Before.Global)
+	dc := sub(tg.After.Campus, tg.Before.Campus)
+	logOK := len(tg.After.Logs) == 1 && tg.After.Logs[0].Type == 208 &&
+		tg.After.Logs[0].Money == -amount && tg.After.Logs[0].LogID == "" && tg.After.Logs[0].RequestID == ""
+	tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
+	asserts = append(asserts, Assertion{
+		Name: "target:清零成功 —— 校区 0、全局 0、special 与 spend 各 +金额、1 条 `type=208 money=−金额`(无 logid/request_id)",
+		Pass: tgOK && isZero(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance) &&
+			isZero(tg.After.Global.Balance, tg.After.Global.Nulls.Balance) &&
+			dc.Special == amount && dg.Special == amount &&
+			dc.Spend == amount && dg.Spend == amount && logOK,
+		Detail: fmt.Sprintf("success=%v 校区余额=%s 全局余额=%s Δspecial(校/全)=%s/%s Δspend(校/全)=%s/%s 流水=%s(期望各 %s)",
+			tgOK,
+			moneyStr(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance),
+			moneyStr(tg.After.Global.Balance, tg.After.Global.Nulls.Balance),
+			yuan(dc.Special), yuan(dg.Special), yuan(dc.Spend), yuan(dg.Spend),
+			fmtLogs(tg.After.Logs), yuan(amount)),
+	})
+
+	// ⑥ 结论行:把归类写进报告,免得下一个人当它是缺陷
+	asserts = append(asserts, Assertion{
+		Name: "判定:**PRECISION-CONVERGENCE / PREDICATE-COLLAPSE** —— 迁移的 2 位收敛抹平了判据输入,两侧分支不同,**不是缺陷**",
+		Pass: true,
+		Detail: fmt.Sprintf("legacy 1565.688>1565.687 成立 ⇒ LEAVE(未写入)|target %s>%s 不成立 ⇒ 清零 %s。"+
+			"该构型在真实库共 10 行(占「第三条守卫可达构型」21 行的 47.6%%)",
+			yuan(tg.Before.Campus.Balance), yuan(tg.Before.Global.Balance), yuan(amount)),
+	})
+	return diffs, asserts
+}
+
+// readRaw3dp 读**不经 ROUND** 的原始余额(`CAST(x AS CHAR)`),只给 PREDICATE-COLLAPSE 用。
+//
+// 为什么必须读原值:harness 的共享快照按迁移口径收敛到 2 位,两侧会显示成一样的数字,
+// 那样这条 case 的红就**不可读**(读者只会认为 harness 坏了)。
+func readRaw3dp(ctx context.Context, db *gorm.DB, c Case, lg *SideResult) error {
+	var campus, total sql.NullString
+	err := db.WithContext(ctx).Raw(`
+SELECT CAST(s.balance AS CHAR), CAST(u.balance AS CHAR)
+  FROM t_user_expense_school s JOIN t_user_expense u ON u.userid = s.userid
+ WHERE s.userid = ? AND s.schoolid = ? AND s.campusid = ?`,
+		c.LegacyUserID, c.InstitutionID, c.CampusID).Row().Scan(&campus, &total)
+	if err != nil {
+		return fmt.Errorf("读 3 位原值失败:%w", err)
+	}
+	if campus.Valid {
+		v := campus.String
+		lg.RawCampusBalance = &v
+	}
+	if total.Valid {
+		v := total.String
+		lg.RawTotalBalance = &v
+	}
+	return nil
+}
+
+// thousandths 把 "1565.688" 解析成千分之一整数(**不经 float**,避免 .xx5 陷阱)。
+func thousandths(s *string) (int64, bool) {
+	if s == nil {
+		return 0, false
+	}
+	t := strings.TrimSpace(*s)
+	neg := false
+	if strings.HasPrefix(t, "-") {
+		neg, t = true, t[1:]
+	}
+	intPart, fracPart := t, ""
+	if i := strings.IndexByte(t, '.'); i >= 0 {
+		intPart, fracPart = t[:i], t[i+1:]
+	}
+	for len(fracPart) < 3 {
+		fracPart += "0"
+	}
+	if len(fracPart) > 3 { // 更多位直接截断:我们的列都是 3 位以内
+		fracPart = fracPart[:3]
+	}
+	ip, err := strconv.ParseInt(intPart, 10, 64)
+	if err != nil {
+		return 0, false
+	}
+	fp, err := strconv.ParseInt(fracPart, 10, 64)
+	if err != nil {
+		return 0, false
+	}
+	v := ip*1000 + fp
+	if neg {
+		v = -v
+	}
+	return v, true
+}
+
+// strOr 取指针值或默认串。
+func strOr(s *string, def string) string {
+	if s == nil {
+		return def
+	}
+	return *s
+}
+
 // ---------- 小工具 ----------
 
 func errSuffix(e string) string {

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

@@ -29,6 +29,16 @@ const (
 	//   target:校区行 `NOT NULL DEFAULT 0.00` → 真扣成 −金额,两表一致
 	// 归类 **LEGACY-DEFECT**:legacy 侧不一致,target 侧正确,不跟。
 	SpecialCampusRowNullBalance = "campus-row-null-balance"
+
+	// SpecialPredicateCollapse 比较判据的**输入被小数收敛抹平**,导致两侧分支不同。
+	//
+	// 归类:`PRECISION-CONVERGENCE` + subtype `PREDICATE-COLLAPSE`。
+	//   - 顶层类 = 成因(3→2 位收敛),与阶段 1 同源,所以不新造顶层类;
+	//   - subtype = 可观测形态(**分支不同**,不是"差几分钱")——按 subtype 过滤才能找到它,
+	//     否则会被"只差几分钱"的噪声淹掉。
+	// 注意:`INPUT-TRUNCATION`(字符串截断导致的判据变化,如 logid varchar(32) vs (64))
+	// **不属于**这个 subtype,要另开一类 —— 处置动作不同,不能混。
+	SpecialPredicateCollapse = "predicate-collapse"
 )
 
 // Op 是"跑哪个写 SP"。
@@ -228,11 +238,16 @@ type AccountNulls struct {
 	Income      bool
 	Expenditure bool
 	Special     bool
+	// Recommended 是 total_recommended_amount(累计推荐金额)。
+	//
+	// 它是第 6 个可空金额列,`DEFAULT NULL`(两张表都是)。之前**从未进过快照**,
+	// 所以"7 支 SP 都不写它"这件事既没被断言、也没被排除 —— 补上之后它才成为可观测的列。
+	Recommended bool
 }
 
 // Any 表示"至少有一列是 NULL"(报告与断言里常用的快捷判断)。
 func (n AccountNulls) Any() bool {
-	return n.Balance || n.Spend || n.Income || n.Expenditure || n.Special
+	return n.Balance || n.Spend || n.Income || n.Expenditure || n.Special || n.Recommended
 }
 
 // String 把 NULL 分布渲染成 "balance,income" 形式(无 NULL 时返回 "-")。
@@ -253,6 +268,9 @@ func (n AccountNulls) String() string {
 	if n.Special {
 		parts = append(parts, "special_expenses")
 	}
+	if n.Recommended {
+		parts = append(parts, "total_recommended_amount")
+	}
 	if len(parts) == 0 {
 		return "-"
 	}
@@ -269,6 +287,7 @@ type Account struct {
 	Income      int64
 	Expenditure int64
 	Special     int64 // special_expenses(只有坏账/清零会动)
+	Recommended int64 // total_recommended_amount(老带新双边入账会动,当前 7 支 SP 都不写)
 	Nulls       AccountNulls
 }
 
@@ -326,6 +345,14 @@ type SideResult struct {
 	Calls         []CallOutcome
 	Err           error
 	BaselineEqual bool
+	// RawCampusBalance / RawTotalBalance 是**原始 3 位小数字符串**(不经 ROUND)。
+	//
+	// 只有 `SpecialCase == SpecialPredicateCollapse` 会填;其它 case 永远是 nil。
+	// 为什么不进共享快照:共享快照的 5 列是按迁移口径读到 2 位的,进第 3 位会改变
+	// 另外 24 条的读数含义;而只有"判据被收敛抹平"这一种 case 需要解释第 3 位。
+	RawCampusBalance *string
+	RawTotalBalance  *string
+
 	// CampusMeta 是**该侧实际看到的**校区元数据(机构 + 是否自营)。
 	//
 	// 为什么必须有:批 C1 的两支 SP 都会跨域读校区 ——
@@ -369,6 +396,11 @@ type Diff struct {
 	Target string
 	Equal  bool
 	Note   string
+	// Skipped 表示"这条比对在当前 case 上**结构性不适用**",报告里打 "—" 而不是 "✔"。
+	//
+	// 为什么需要第三态:如果照常打 ✔,一个 `0 == 0`(两侧都没写这一列)会和
+	// "真的比过且相等"长得一样 —— 那就是一次空过。打 "—" 才能让"没测"显形。
+	Skipped bool
 }
 
 // CaseResult 是一个 case 的完整双跑结论。

+ 13 - 9
internal/academic/dualrun/stage2/legacy.go

@@ -298,9 +298,10 @@ 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)),
-       CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2)                 AS DECIMAL(12,2))
+       CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2)                 AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_recommended_amount AS DECIMAL(20,3)),2)          AS DECIMAL(12,2))
   FROM t_user_expense WHERE userid = ?`, c.LegacyUserID).
-		Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special)
+		Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special, &g.Recommended)
 	if err != nil {
 		return out, 0, fmt.Errorf("读 legacy 全局账户失败:%w", err)
 	}
@@ -315,10 +316,11 @@ 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)),
-       CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2)                 AS DECIMAL(12,2))
+       CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2)                 AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_recommended_amount 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)
+		Row().Scan(&sc.Balance, &sc.Spend, &sc.Income, &sc.Expenditure, &sc.Special, &sc.Recommended)
 	if err != nil {
 		// 校区账户不存在是**合法状态**(CampusMissing 用例就是要它不存在),记成零值 + 标记;
 		// 其它错误照旧上抛,别把"数据库出问题"伪装成"账户不存在"。
@@ -346,10 +348,11 @@ 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)),
-       CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2)                 AS DECIMAL(12,2))
+       CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2)                 AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_recommended_amount AS DECIMAL(20,3)),2)          AS DECIMAL(12,2))
   FROM t_user_expense_school WHERE userid = ? AND campusid = ?`,
 			c.LegacyUserID, c.ToCampusID).
-			Row().Scan(&tc.Balance, &tc.Spend, &tc.Income, &tc.Expenditure, &tc.Special)
+			Row().Scan(&tc.Balance, &tc.Spend, &tc.Income, &tc.Expenditure, &tc.Special, &tc.Recommended)
 		if qerr == nil {
 			out.Campus2 = tc.account()
 		}
@@ -411,7 +414,7 @@ func centsOf(s string) (int64, error) { return fixture.DecimalToCents(s) }
 // 三处(全局 / 校区 / 转入校区)必须用**完全一样**的读法,否则某一处忘了 NULL
 // 就会在别处静默变回 0 —— 那正是 C1-11 崩溃的成因(只有一处用了 NullString)。
 type accountNS struct {
-	Balance, Spend, Income, Expenditure, Special sql.NullString
+	Balance, Spend, Income, Expenditure, Special, Recommended sql.NullString
 }
 
 // account 把扫描结果转成三态 Account(NULL 列的值恒为 0,靠 Nulls 标记)。
@@ -421,10 +424,11 @@ func (n accountNS) account() Account {
 	in, inn := centsOrNull(n.Income)
 	ex, exn := centsOrNull(n.Expenditure)
 	pc, pcn := centsOrNull(n.Special)
+	rc, rcn := centsOrNull(n.Recommended)
 	return Account{
-		Balance: b, Spend: sp, Income: in, Expenditure: ex, Special: pc,
+		Balance: b, Spend: sp, Income: in, Expenditure: ex, Special: pc, Recommended: rc,
 		Nulls: AccountNulls{
-			Balance: bn, Spend: spn, Income: inn, Expenditure: exn, Special: pcn,
+			Balance: bn, Spend: spn, Income: inn, Expenditure: exn, Special: pcn, Recommended: rcn,
 		},
 	}
 }

+ 87 - 9
internal/academic/dualrun/stage2/run.go

@@ -177,17 +177,36 @@ func DefaultCases(_ string) []Case {
 			Note: "SP10 第二条守卫 `ISNULL(totalBalance)||totalBalance<=0 → LEAVE`",
 		},
 		Case{
-			// 这一条**用真实构型,不用 Baseline 造数**:
-			// `finance_bk` 里 `schoolid=63317 且 校区余额 > 全局余额 > 0` 有 21 行,
-			// 48170 是其中最干净的一个(campus 5 = 0.270,全局 = 0.269,都是 2 位小数)。
-			// 造数能造出"实际不会发生的组合",绿了也没意义;真实构型证明这个守卫在真数据上会被触发。
-			Name: "C1-5 清零前置校验·校区余额 > 全局余额(真实构型,legacy 静默 vs Go 3120)",
-			Op:   OpClearBalance, LegacyUserID: 48170, InstitutionID: 63317, CampusID: 5,
-			RequestID: "ac15-c1-5", RunTimes: 1, SelfSupport: true,
+			// 这一条测的是**守卫本身**:用 Baseline 造 500/800,两位小数下不等式仍成立,
+			// 所以迁移不会改变判据 —— 两侧都该 LEAVE。
+			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`。真实数据:校区 0.270 > 全局 0.269(差 1 分)," +
-				"正是「余额 ≠ 流水累计」那 6.6% 用户的一种表现",
+			Note: "SP10 第三条守卫 `campusBalance>totalBalance → LEAVE`(数据异常,拒绝对负余额清零)。" +
+				"**测守卫本身**:500/800 在两位小数下不等式仍成立。真实 3 位构型(守卫被收敛抹平)见 C1-5b",
+		},
+		Case{
+			// 这一条测的是**判据输入被小数收敛抹平**:真实构型,不做任何造数。
+			//
+			// 65496:校区 balance=1565.688 / 全局 balance=1565.687(只差 0.001)
+			//   legacy 按 3 位比:1565.688 > 1565.687 成立 ⇒ LEAVE(零写入)
+			//   target 只有 2 位:1565.69 > 1565.69 不成立 ⇒ 放行清零
+			// ⇒ 归类 PRECISION-CONVERGENCE / PREDICATE-COLLAPSE(分支不同,不是"差几分钱")。
+			//
+			// 选 65496 而不是 48170:① 1565 元级、报告好读;② 它的**可失效报名 = 0**
+			// (唯一那条是 activity 72 的 type=4 老带新,天然不参与失效),
+			// 将来谁给这条 case 补 `Activity:` 种子也不会突然变成"两侧失效范围不同"。
+			Name: "C1-5b 清零前置校验·校区余额 > 全局余额(真实 3 位构型,2 位收敛后守卫消失)",
+			Op:   OpClearBalance, LegacyUserID: 65496, InstitutionID: 63317, CampusID: 5,
+			RequestID: "ac15-c1-5b", RunTimes: 1, SelfSupport: true,
+			SpecialCase:         SpecialPredicateCollapse,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "PRECISION-CONVERGENCE / PREDICATE-COLLAPSE:校区 1565.688 > 全局 1565.687," +
+				"两位收敛后都是 1565.69 ⇒ legacy 按 3 位判据 LEAVE、target 按 2 位放行清零。两侧**本来就不同**," +
+				"本 case 只把差异钉死;报告 detail 会打 3 位原值,否则两侧都显示 1565.69、红得不可读",
 		},
 	)
 
@@ -354,6 +373,14 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 	}
 	res.Target = tg
 
+	// 3 位原值:**只在** SpecialPredicateCollapse 上读,不进共享快照
+	// (进了就会改变另外 24 条的读数口径,而其它 case 不需要第 3 位)。
+	if c.SpecialCase == SpecialPredicateCollapse {
+		if err := readRaw3dp(ctx, legacyDB, c, lg); err != nil {
+			return nil, err
+		}
+	}
+
 	// ---- 比对 ----
 	res.Diffs, res.Assertions = compare(c, lg, tg)
 
@@ -442,6 +469,8 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			return requestIDOverflowCompare(c, lg, tg)
 		case SpecialCampusRowNullBalance:
 			return campusRowNullCompare(c, lg, tg)
+		case SpecialPredicateCollapse:
+			return predicateCollapseCompare(c, lg, tg)
 		}
 		return nil, []Assertion{{
 			Name: "未知的 SpecialCase:" + c.SpecialCase, Pass: false,
@@ -558,6 +587,25 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			sameMoney(dl.Special, dl.Nulls.Special, wl.Special, wl.Nulls.Special) &&
 				sameMoney(dt.Special, dt.Nulls.Special, wt.Special, wt.Nulls.Special),
 			"期望 "+moneyStr(wl.Special, wl.Nulls.Special)+" / "+moneyStr(wt.Special, wt.Nulls.Special))
+
+		// total_recommended_amount:当前 7 支 SP **都不写**它 ⇒ 期望恒为 0。
+		//
+		// 但"两侧都恰好是 0"在 25 条里会占绝大多数,照常打 ✔ 就等于造一个空过
+		// (`0 == 0` 与"真的比过且相等"长得一样)。所以这里分两种:
+		//   - 结构性不适用(本 Op 不写该列,且两侧实测都是 0)→ 打 "—",并写明原因;
+		//   - 其它一律真断言:一旦哪天有实现意外写了它,这里立刻红。
+		// 位置必须保留:等 `use_activity_recommend`(唯一写它的 SP)进来,它自动变成真断言。
+		recSkipped := !opWritesRecommended(c.Op) &&
+			sameMoney(dl.Recommended, dl.Nulls.Recommended, 0, false) &&
+			sameMoney(dt.Recommended, dt.Nulls.Recommended, 0, false)
+		recEqual := sameMoney(dl.Recommended, dl.Nulls.Recommended, wl.Recommended, wl.Nulls.Recommended) &&
+			sameMoney(dt.Recommended, dt.Nulls.Recommended, wt.Recommended, wt.Nulls.Recommended)
+		addDeltaRowEx(&diffs, name+" Δtotal_recommended_amount",
+			moneyStr(dl.Recommended, dl.Nulls.Recommended), moneyStr(dt.Recommended, dt.Nulls.Recommended),
+			recEqual, recSkipped,
+			"期望 "+moneyStr(wl.Recommended, wl.Nulls.Recommended)+" / "+
+				moneyStr(wt.Recommended, wt.Nulls.Recommended)+
+				"("+recommendedNote(c.Op)+")")
 	}
 	addDelta("全局", dGlobal, tGlobal, wgL, wgT)
 	addDelta("转出校区", dCampus, tCampus, wfL, wfT)
@@ -880,6 +928,7 @@ func sub(a, b Account) Account {
 	d.Income, d.Nulls.Income = subOne(a.Income, a.Nulls.Income, b.Income, b.Nulls.Income)
 	d.Expenditure, d.Nulls.Expenditure = subOne(a.Expenditure, a.Nulls.Expenditure, b.Expenditure, b.Nulls.Expenditure)
 	d.Special, d.Nulls.Special = subOne(a.Special, a.Nulls.Special, b.Special, b.Nulls.Special)
+	d.Recommended, d.Nulls.Recommended = subOne(a.Recommended, a.Nulls.Recommended, b.Recommended, b.Nulls.Recommended)
 	return d
 }
 
@@ -1007,6 +1056,32 @@ func remarkNote(op Op) string {
 	}
 }
 
+// opWritesRecommended 标记"该 Op 对应的 SP 会写 total_recommended_amount"。
+//
+// 当前 7 支**都不写**(只有 `sp_courses_activity_use_activity_recommend` 写,它还没进来)。
+// 这条函数就是那个"将来会变 true"的开关:谁把推荐链做进来,把对应 Op 加进来即可,
+// 断言会自动从"不适用"变成"真断言"。
+func opWritesRecommended(op Op) bool {
+	switch op {
+	// 预留:OpUseActivityRecommend(老带新双边入账,4 处 UPDATE)
+	default:
+		return false
+	}
+}
+
+// recommendedNote 说明该 Op 下 total_recommended_amount 这一行的性质。
+func recommendedNote(op Op) string {
+	if opWritesRecommended(op) {
+		return "本 Op 会写该列,逐分比对"
+	}
+	return "本 Op 不写该列 ⇒ 结构性不适用(若真被写了会变红)"
+}
+
+// addDeltaRowEx 是 addDeltaRow 的扩展版:多一个 skipped 维度(报告里打 "—")。
+func addDeltaRowEx(diffs *[]Diff, item, l, t string, equal, skipped bool, note string) {
+	*diffs = append(*diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note, Skipped: skipped})
+}
+
 // addDeltaRow 往 diffs 追加一行 Δ 比对。
 func addDeltaRow(diffs *[]Diff, item, l, t string, equal bool, note string) {
 	*diffs = append(*diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
@@ -1061,6 +1136,9 @@ func PrintResult(w io.Writer, r *CaseResult) {
 		if !d.Equal {
 			mark = "✘"
 		}
+		if d.Skipped {
+			mark = "—" // 结构性不适用:不是"测过且相等",是"本 case 不涉及这一列"
+		}
 		fmt.Fprintf(w, "    %s %-32s legacy=%-22s target=%-22s %s\n", mark, d.Item, d.Legacy, d.Target, d.Note)
 	}
 

+ 141 - 0
internal/academic/dualrun/stage2/sentinel.go

@@ -0,0 +1,141 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+	"io"
+
+	"gorm.io/gorm"
+)
+
+// 本文件是**存量哨兵**:与具体 case 无关的全库不变量扫描。
+//
+// 为什么它不能挂在 case 里:
+//   `special_expenses` / `total_recommended_amount` 这两列在当前 7 支 SP 里
+//   只有"双表同额写入"(坏账、清零),所以"全局 == Σ校区"是成立的。
+//   但把它写成 per-case 断言,绝大多数 case 上就是 `0 == 0` —— 又是一次空过。
+//   它守的其实是**结构性问题**:"有没有出现只写一边的新路径",因此只能全库扫。
+//
+// 两条硬要求(写死在实现里,不靠纪律):
+//  1. **禁止用 `<>` 判相等**:`NULL <> x` 求值为 NULL,`CASE WHEN NULL` 会被静默算成
+//     "一致" —— 这是最阴的一种空过。必须用 `<=>`(NULL 安全等于)。
+//  2. `nonzeroSamples` 必须一起报出来;一旦某个时点它变成 0,直接判"样本消失、断言失效",
+//     不许继续绿(否则一个全 0 的库上,这条断言永远是绿的)。
+
+// StockSentinel 是一列的扫瞄结果。
+type StockSentinel struct {
+	Column string
+	// Scanned 扫描的用户数(有校区行且能配对到全局行的用户)。
+	Scanned int
+	// Mismatches 是"全局 `<=>` Σ校区"不成立的用户数。
+	Mismatches int
+	// NonzeroSamples 是"全局或 Σ校区 非 0"的用户数 —— 哨兵的**有效性样本量**。
+	NonzeroSamples int
+}
+
+// Pass 返回该列是否通过:不一致为 0 **且** 有效样本量 > 0。
+func (s StockSentinel) Pass() bool { return s.Mismatches == 0 && s.NonzeroSamples > 0 }
+
+// Verdict 返回一句话结论(报告用)。
+func (s StockSentinel) Verdict() string {
+	switch {
+	case s.Mismatches > 0:
+		return "FAIL —— 出现存量不一致:说明有**只写一边**的写入路径"
+	case s.NonzeroSamples == 0:
+		return "FAIL(样本消失,断言失效)—— 全 0 数据上这条永远绿,等于没测"
+	default:
+		return "PASS"
+	}
+}
+
+// sentinelColumns 是受扫的列(列名进 SQL,必须是**白名单**,不能由调用方拼)。
+var sentinelColumns = []string{"special_expenses", "total_recommended_amount"}
+
+// sentinelDatabases 允许扫描的库:finance_bk(迁移前基线)与 yuansiwei_2026(迁移后现状)。
+//
+// 只读:连接只发 SELECT,且这两个库在本工具里都不需要写。
+var sentinelDatabases = map[string]bool{
+	"finance_bk":     true,
+	"yuansiwei_2026": true,
+}
+
+// RunStockSentinels 扫全库的存量不变式(`全局 <=> Σ校区`),逐列打印并返回总体结论。
+//
+// 入参:ctx / cfg(连接参数)/ dbName(**必须在 sentinelDatabases 白名单**)/ w 输出。
+// 出参:ok(全部通过)/ error(连接或查询失败)。
+func RunStockSentinels(ctx context.Context, cfg Config, dbName string, w io.Writer) (bool, error) {
+	if !sentinelDatabases[dbName] {
+		return false, fmt.Errorf("哨兵只允许扫描 %v,收到 %q", sentinelDatabases, dbName)
+	}
+	db, err := cfg.open(dbName)
+	if err != nil {
+		return false, err
+	}
+
+	fmt.Fprintf(w, "存量哨兵 —— 库:%s(只读)\n", dbName)
+	fmt.Fprintf(w, "不变式:`全局列 <=> Σ各校区列`(NULL 安全相等;NULL 与 0 **不**等价)\n\n")
+	fmt.Fprintf(w, "  %-26s %8s %10s %14s  %s\n", "列", "扫描用户", "不一致", "非零样本", "结论")
+	fmt.Fprintf(w, "  %s\n", "--------------------------------------------------------------------------")
+
+	ok := true
+	for _, col := range sentinelColumns {
+		s, err := scanStockSentinel(ctx, db, col)
+		if err != nil {
+			return false, err
+		}
+		if !s.Pass() {
+			ok = false
+		}
+		fmt.Fprintf(w, "  %-26s %8d %10d %14d  %s\n",
+			s.Column, s.Scanned, s.Mismatches, s.NonzeroSamples, s.Verdict())
+	}
+	fmt.Fprintln(w)
+	if ok {
+		fmt.Fprintln(w, "结论:PASS —— 这两列存在「双表同额写入」的**结构性保证**,尚未出现单边写入路径")
+	} else {
+		fmt.Fprintln(w, "结论:FAIL —— 见上表;FAIL 的含义不是'数据脏',而是'**出现了只写一边的新路径**'")
+	}
+	return ok, nil
+}
+
+// scanStockSentinel 扫一列。
+func scanStockSentinel(ctx context.Context, db *gorm.DB, column string) (StockSentinel, error) {
+	out := StockSentinel{Column: column}
+
+	// 列名来自白名单(sentinelColumns),但仍然显式复核一次:拼进 SQL 的东西不许"假设安全"。
+	if !isSentinelColumn(column) {
+		return out, fmt.Errorf("哨兵列名不在白名单:%q", column)
+	}
+
+	// 逐用户聚合后比较:
+	//   Count(*)                                    → 扫描了多少用户
+	//   SUM(NOT (g <=> s))                          → 不一致(NULL 安全)
+	//   SUM((g IS NOT NULL AND g<>0) OR (s IS NOT NULL AND s<>0)) → 有效样本
+	//
+	// 注意 `SUM(s.col)` 在"全为 NULL"时返回 NULL,`<=>` 会把"NULL vs NULL"判为一致 —— 正确。
+	q := fmt.Sprintf(`
+SELECT COUNT(*),
+       IFNULL(SUM(NOT (g <=> s)), 0),
+       IFNULL(SUM((g IS NOT NULL AND g <> 0) OR (s IS NOT NULL AND s <> 0)), 0)
+  FROM (
+    SELECT u.%[1]s AS g, SUM(s.%[1]s) AS s
+      FROM t_user_expense u
+      JOIN t_user_expense_school s ON s.userid = u.userid
+     GROUP BY u.userid, u.%[1]s
+  ) x`, column)
+
+	if err := db.WithContext(ctx).Raw(q).Row().Scan(&out.Scanned, &out.Mismatches, &out.NonzeroSamples); err != nil {
+		return out, fmt.Errorf("哨兵扫描 %s 失败:%w", column, err)
+	}
+	return out, nil
+}
+
+// isSentinelColumn 复核列名白名单。
+func isSentinelColumn(c string) bool {
+	for _, x := range sentinelColumns {
+		if x == c {
+			return true
+		}
+	}
+	return false
+}

+ 81 - 0
internal/academic/dualrun/stage2/stage2_test.go

@@ -477,3 +477,84 @@ func TestAccountNullsString(t *testing.T) {
 		t.Error("Any() 判断错误")
 	}
 }
+
+// TestThousandths 锁住 3 位原值的解析:**不经 float**,否则 .xx5 会重演收敛陷阱。
+func TestThousandths(t *testing.T) {
+	str := func(s string) *string { return &s }
+	cases := []struct {
+		in   *string
+		want int64
+		ok   bool
+	}{
+		{str("1565.688"), 1565688, true},
+		{str("1565.687"), 1565687, true},
+		{str("0.270"), 270, true},
+		{str("0.269"), 269, true},
+		{str("1.005"), 1005, true}, // ROUND(float,2) 会给 1.00,这里必须保持 1005
+		{str("301.995"), 301995, true},
+		{str("12"), 12000, true},
+		{str("-3.5"), -3500, true},
+		{nil, 0, false},
+		{str("abc"), 0, false},
+	}
+	for _, c := range cases {
+		got, ok := thousandths(c.in)
+		if ok != c.ok || (ok && got != c.want) {
+			t.Errorf("thousandths(%v) = (%d,%v),期望 (%d,%v)", c.in, got, ok, c.want, c.ok)
+		}
+	}
+	// 关键性质:差距 1 个千分位必须能区分(0.270 vs 0.269 是这条 case 的全部依据)
+	a, _ := thousandths(str("0.270"))
+	b, _ := thousandths(str("0.269"))
+	if a <= b {
+		t.Fatal("0.270 必须 > 0.269 —— 否则 PREDICATE-COLLAPSE 类 case 无法成立")
+	}
+}
+
+// TestStockSentinelVerdict 锁住哨兵的三态结论。
+//
+// 最关键的一条:`NonzeroSamples == 0` 必须**判失败**("样本消失,断言失效"),
+// 而不是"没有不一致所以通过" —— 一个全 0 的库上,这条断言永远不会红。
+func TestStockSentinelVerdict(t *testing.T) {
+	ok := StockSentinel{Column: "special_expenses", Scanned: 4661, Mismatches: 0, NonzeroSamples: 154}
+	if !ok.Pass() || ok.Verdict() != "PASS" {
+		t.Errorf("正常样本应 PASS,得到 %q", ok.Verdict())
+	}
+	bad := StockSentinel{Scanned: 4661, Mismatches: 3, NonzeroSamples: 154}
+	if bad.Pass() || bad.Verdict() == "PASS" {
+		t.Error("有不一致必须 FAIL")
+	}
+	// 样本消失:不一致为 0,但非零样本也为 0 ⇒ 不许绿
+	empty := StockSentinel{Scanned: 4661, Mismatches: 0, NonzeroSamples: 0}
+	if empty.Pass() {
+		t.Fatal("非零样本为 0 时必须判 FAIL(样本消失,断言失效)—— 否则这是一条永远绿的断言")
+	}
+	if got := empty.Verdict(); got == "PASS" {
+		t.Errorf("样本消失时结论不能是 PASS,得到 %q", got)
+	}
+}
+
+// TestRecommendedRowIsSkipped 锁住"本 Op 不写 total_recommended_amount ⇒ 打 — 而不是 ✔"。
+//
+// 打 ✔ 会让 `0 == 0` 与"真的比过且相等"同形,那就是空过。
+func TestRecommendedRowIsSkipped(t *testing.T) {
+	if opWritesRecommended(OpRecharge) || opWritesRecommended(OpBadDebt) {
+		t.Error("当前 7 支 SP 都不写 total_recommended_amount")
+	}
+	// 不写该列 + 两侧实测都是 0 ⇒ skipped
+	dl, dt := Account{}, Account{}
+	skipped := !opWritesRecommended(OpRecharge) &&
+		sameMoney(dl.Recommended, dl.Nulls.Recommended, 0, false) &&
+		sameMoney(dt.Recommended, dt.Nulls.Recommended, 0, false)
+	if !skipped {
+		t.Error("不写该列且两侧为 0 时应标为 skipped")
+	}
+	// 一旦真的有实现写了它(非 0),必须变成真断言(不 skip)→ 会红
+	dl.Recommended = 500
+	skipped = !opWritesRecommended(OpRecharge) &&
+		sameMoney(dl.Recommended, dl.Nulls.Recommended, 0, false) &&
+		sameMoney(dt.Recommended, dt.Nulls.Recommended, 0, false)
+	if skipped {
+		t.Fatal("实现意外写了该列时不能 skip —— 否则这条变成永远绿")
+	}
+}

+ 6 - 6
internal/academic/dualrun/stage2/target.go

@@ -378,9 +378,9 @@ func snapshotTarget(ctx context.Context, tx *gorm.DB, canonicalUID string, c Cas
 	if err := tx.Raw(`
 SELECT CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
        CAST(total_income AS DECIMAL(12,2)), CAST(total_expenditure AS DECIMAL(12,2)),
-       CAST(special_expenses AS DECIMAL(12,2))
+       CAST(special_expenses AS DECIMAL(12,2)), CAST(total_recommended_amount AS DECIMAL(12,2))
   FROM t_user_expense WHERE user_id = ?`, canonicalUID).
-		Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special); err != nil {
+		Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special, &g.Recommended); err != nil {
 		return out, 0, fmt.Errorf("读 target 全局账户失败:%w", err)
 	}
 	out.Global = g.account()
@@ -389,10 +389,10 @@ SELECT CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
 	qerrCampus := tx.Raw(`
 SELECT CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
        CAST(total_income AS DECIMAL(12,2)), CAST(total_expenditure AS DECIMAL(12,2)),
-       CAST(special_expenses AS DECIMAL(12,2))
+       CAST(special_expenses AS DECIMAL(12,2)), CAST(total_recommended_amount 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)
+		Row().Scan(&sc.Balance, &sc.Spend, &sc.Income, &sc.Expenditure, &sc.Special, &sc.Recommended)
 	if qerrCampus != nil {
 		// 同 legacy 侧:校区账户不存在是合法状态(CampusMissing 用例),记零值 + 标记。
 		if !errors.Is(qerrCampus, sql.ErrNoRows) {
@@ -413,10 +413,10 @@ SELECT CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
 		qerr := tx.Raw(`
 SELECT CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
        CAST(total_income AS DECIMAL(12,2)), CAST(total_expenditure AS DECIMAL(12,2)),
-       CAST(special_expenses AS DECIMAL(12,2))
+       CAST(special_expenses AS DECIMAL(12,2)), CAST(total_recommended_amount AS DECIMAL(12,2))
   FROM t_user_expense_school WHERE user_id = ? AND campus_id = ?`,
 			canonicalUID, c.ToCampusID).
-			Row().Scan(&tc.Balance, &tc.Spend, &tc.Income, &tc.Expenditure, &tc.Special)
+			Row().Scan(&tc.Balance, &tc.Spend, &tc.Income, &tc.Expenditure, &tc.Special, &tc.Recommended)
 		if qerr == nil {
 			out.Campus2 = tc.account()
 		}