Browse Source

fix(ac15): 金额改三态(NULL≠0)—— 修 C1-11 崩溃,并暴露 5 处"==0"空过

## 触发

C1-11 跑不通:`读 legacy 校区账户失败:converting NULL to string is unsupported`。
根因不是 harness 太脆,是 legacy 的真行为:SP8 的建行分支只写
`(userid,schoolid,campusid)`,而 `t_user_expense_school` 的金额列是
`decimal(10,3) NULL DEFAULT NULL`(**没有默认值**),于是新行金额全是 NULL,
紧接着 `SET balance=balance-30` 在 NULL 上仍是 NULL —— **钱一分没扣,凭证照写**。

## 结构性改动:Account 三态

    type AccountNulls struct { Balance, Spend, Income, Expenditure, Special bool }
    type Account struct { ...五列 int64...; Nulls AccountNulls }

- `AccountNulls` 是可比较结构体 → 加进 `Account` 后 `==` 自动区分 NULL 与 0,
  既有比对点不改也变严(这是本次改动没扩大风险面的原因)。
- `sub()` 逐列**传播 NULL**:任一侧 NULL → Δ 为 NULL(`NULL-30` 是 NULL,不是 −30)。
- 读回一律走 `accountNS`(sql.NullString×5) + `centsOrNull()`,**删掉全部 `IFNULL(x,0)`** ——
  那正是把 NULL 抹成 0 的地方;`readLegacyNatural` / `checkResidue` 一并改。
- 报告渲染 `moneyStr(cents,isNull)`:NULL 显示 `NULL`,不显示 `0.00`;
  `fmtAcc` 追加 `NULL:balance,total_income` 列名。

## 顺带暴露并修掉的两个空过点(不是新增判据,是原本就空)

1. `dGlobal.Balance == 0`("不动的列必须为 0"):Δ 为 NULL 时值也是 0,直接比 int64
   会把「NULL(压根没算出来)」当成「确实是 0」→ 改 `isZero(v,isNull)`。
   清零的"校区余额恰好归 0"同理。
2. `accountNS` 零值五列都 invalid,无条件 `account()` 会得到"五列全 NULL"——
   "没有这一行"被伪装成"有一行没写过",建行分支断言假绿 → 改成
   `if HasCampusAccount { out.Campus = sc.account() }`。
   **行不存在 ≠ 行内全 NULL**,这两态必须分开。

## C1-11 判据重写(按 team-lead 的口径:钉"NULL 这个事实")

legacy:行建出来了、**五列全 NULL**、**Δ校区也是 NULL**(不是 Δ=0.00)、
全局却真扣 −30(两表矛盾)、流水照写 1 条 type=209。
target:五列都不为 NULL、真扣成 −30.00。
判定 **LEGACY-DEFECT**(legacy 缺陷,target 不跟)。

新增单测 4 组:TestNullNotEqualZero / TestSubNullPropagation / TestMoneyStrRendersNull /
TestAccountNullsString。

## 实测(commit 上的真实数字,不引用工作区)

24 个 case:**23 PASS / 1 FAIL / 0 崩溃**。

唯一红的是 `C1-5 清零前置校验·校区余额 > 全局余额(真实构型)`,且**不是本次改动引入**:
用户 48170 的全局 0.269(float) 与校区 0.270(decimal) 经 ROUND 后都变成 0.27,
legacy 按三位小数比 `0.270 > 0.269` → LEAVE;target 只能按两位小数比 `0.27 > 0.27` → 不成立 → 照常清零。
即**两位数迁移让这条业务守卫消失了**。已写进 README §8.5,给了两条处置方案,未擅自改判据。
Q 5 hours ago
parent
commit
25139083bc

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

@@ -555,3 +555,104 @@ target 侧同名列是 `decimal(12,2) NOT NULL DEFAULT 0.00`,建行即 0,真
 > `snapshotLegacy` 的校区列必须用 `sql.NullString`,并用
 > `SideSnapshot.CampusNullBalance` 把"行在、但金额是 NULL"显式记下来。
 > **读不出来的状态,等于没有这个状态。**
+
+---
+
+## 8. 金额三态:NULL ≠ 0(C1-11 逼出来的结构性改动)
+
+### 8.1 为什么必须区分
+
+legacy 两张账户表的金额列**全部 `NULL` 可空**,而且默认值不一样:
+
+| 表 | 四列金额(balance/spend/total_income/total_expenditure) | special_expenses / total_recommended_amount |
+|---|---|---|
+| `t_user_expense` | `float(10,3)`,`DEFAULT 0.000` | `decimal(10,3)`,`DEFAULT NULL` |
+| `t_user_expense_school` | `decimal(10,3)`,**`DEFAULT NULL`** | `decimal(10,3)`,`DEFAULT NULL` |
+
+SP 的"先建行"分支只写三列:
+
+```sql
+IF logCount=0 THEN
+    INSERT INTO t_user_expense_school(userid, schoolid, campusid) VALUES (...);
+END IF;
+UPDATE t_user_expense_school SET balance=balance-inputMoney, total_income=total_income-inputMoney WHERE ...;
+```
+
+于是新行的余额是 **NULL**,`NULL - 30` **还是 NULL** —— 钱一分没扣,凭证照写。
+(`t_user_expense` 那边因为 `DEFAULT 0.000`,全局真扣了 → 同一笔业务两表自相矛盾。实测 5287 行里 `balance IS NULL` 为 0,说明这条分支在生产上从未触发,但代码路径在。)
+
+**如果把 NULL 读成 0,两种后果完全不同的状态会合并成同一个数:**
+
+| 状态 | 下一次 `balance-30` | 含义 |
+|---|---|---|
+| `0` | 变成 `-30` | 扣完了 / 本来就是 0 |
+| `NULL` | **还是 `NULL`** | 这行从没被写过,后续所有算术静默失效 |
+
+合并之后,"建行后扣不动"会显示成"扣了 0 元",断言静默通过 —— **harness 自己制造的空过**。
+
+### 8.2 实现
+
+```go
+type AccountNulls struct { Balance, Spend, Income, Expenditure, Special bool }
+type Account struct { ...五列 int64...; Nulls AccountNulls }
+```
+
+三个要点:
+
+1. **`AccountNulls` 是可比较结构体**,加进 `Account` 后 `==` 自动区分 NULL 与 0,
+   所有既有比对点**不改也变严**(这是这次改动没有扩大风险面的原因)。
+2. `sub()` **逐列传播 NULL**:任一侧 NULL → Δ 为 NULL(`NULL-30` 是 NULL,不是 −30)。
+   五列都要显式处理,漏一列那列 Δ 恒为 0(批 B 的 `Special` 就是这么假红过的)。
+3. 读回来一律走 `accountNS`(`sql.NullString` ×5)+ `centsOrNull()`;
+   **不要再写 `IFNULL(x,0)`** —— 那正是把 NULL 抹成 0 的地方。
+
+报告渲染:`moneyStr(cents, isNull)` → NULL 显示成 `NULL`,**不能显示成 `0.00`**;
+`fmtAcc` 末尾会追加 `NULL:balance,total_income` 这样的列名。
+
+### 8.3 两个必须记住的坑
+
+**坑一:`x == 0` 在 NULL 上为真。**
+本轮改动同时暴露了旧代码里 `dGlobal.Balance == 0` 这类写法("不动的列必须为 0")。
+Δ 为 NULL 时值也是 0,直接比 int64 会把「NULL(压根没算出来)」当成「确实是 0」。
+统一改成 `isZero(v, isNull)`(= `!isNull && v == 0`)。
+
+**坑二:行不存在 ≠ 行内全 NULL。**
+`accountNS` 的零值五个字段都是 invalid,直接 `account()` 会得到"五列全 NULL"。
+于是"没有这一行"被伪装成"有一行但没写过",建行分支的断言就假绿了。
+所以两侧都写成:
+
+```go
+if out.HasCampusAccount { out.Campus = sc.account() }   // 无行 → 保持零值 Account(Nulls 全 false)
+```
+
+### 8.4 C1-11 实测(`LEGACY-DEFECT`)
+
+用户 40585 在 campus 5 **没有**校区账户行(全局有 5280.00),取消充值 30.00:
+
+| 项 | legacy | target |
+|---|---|---|
+| 校区行 | 建出来了 | 建出来了 |
+| 校区 balance | **NULL** | `-30.00` |
+| 校区五列 NULL 分布 | balance,spend,total_income,total_expenditure,special_expenses(**全 NULL**) | `-`(无 NULL) |
+| 全局 Δbalance / Δtotal_income | `-30.00` / `-30.00`(真扣了) | `-30.00` / `-30.00` |
+| 流水 | 1 条 `type=209 money=-30.00`(**钱没扣、凭证有了**) | 1 条,同 |
+| Δ校区 | **NULL** | `-30.00` |
+
+判定 **LEGACY-DEFECT**:legacy 两表自相矛盾,target(`NOT NULL DEFAULT 0.00`)才是对的,不跟。
+
+### 8.5 一个仍然红灯的 case(留给决策,不是缺陷修复)
+
+`C1-5 清零前置校验·校区余额 > 全局余额(真实构型)`:legacy 静默 LEAVE、Go 却**成功扣款**。
+根因是**两位数迁移让不等式消失了**:
+
+```
+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 → 不成立 → 继续清零
+```
+
+这不是 Go 的缺陷(它看不到第三位),也不是 harness 的缺陷,而是**迁移口径丢失了业务规则依赖的信息**。
+两条可选处置(未实施,等裁决):
+1. 换一个"不等式在两位下仍成立"的构型(如校区 800 / 全局 500),判据不变;
+2. 登记为 `INTENTIONAL-COLLAPSE`,两侧各自断言实际行为(legacy 不动 / target 清零)。

+ 49 - 30
internal/academic/dualrun/stage2/c1_compare.go

@@ -34,12 +34,15 @@ func c1Assertions(
 
 	// ③ 清零语义:校区余额恰好归 0,全局余额**减去同一个 campusBalance**(一般不是 0)
 	if c.Op == OpClearBalance {
-		okL := lg.After.Campus.Balance == 0 && tg.After.Campus.Balance == 0
+		// 同上:校区余额"恰好是 0"必须排除 NULL —— NULL 的值也是 0,直接比 int64 会空过
+		okL := isZero(lg.After.Campus.Balance, lg.After.Campus.Nulls.Balance) &&
+			isZero(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance)
 		out = append(out, Assertion{
 			Name: "清零:**校区**余额恰好归 0(「清零」清的是校区这一行)",
 			Pass: okL,
-			Detail: fmt.Sprintf("legacy 校区余额后=%s | target 校区余额后=%s(都应为 0.00)",
-				yuan(lg.After.Campus.Balance), yuan(tg.After.Campus.Balance)),
+			Detail: fmt.Sprintf("legacy 校区余额后=%s | target 校区余额后=%s(都应为 0.00,且不能是 NULL)",
+				moneyStr(lg.After.Campus.Balance, lg.After.Campus.Nulls.Balance),
+				moneyStr(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance)),
 		})
 		out = append(out, Assertion{
 			Name: "清零:全局余额只减 campusBalance(**不是**归 0,除非原本只有一个校区有钱)",
@@ -336,15 +339,29 @@ func campusRowNullCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	tGlobal := sub(tg.After.Global, tg.Before.Global)
 	tCampus := sub(tg.After.Campus, tg.Before.Campus)
 
+	// legacy 侧:建行语句只写 `(userid,schoolid,campusid)`,六列金额全是 NULL,
+	// 紧随其后的 `SET balance=balance-m` 在 NULL 上仍是 NULL —— 五列**应当全是 NULL**。
+	lgAllNull := lg.After.Campus.Nulls.Balance && lg.After.Campus.Nulls.Spend &&
+		lg.After.Campus.Nulls.Income && lg.After.Campus.Nulls.Expenditure &&
+		lg.After.Campus.Nulls.Special
+	// target 侧:`not null default 0.00` → 五列都不该是 NULL。
+	tgNoNull := !tg.After.Campus.Nulls.Any()
+
 	diffs = append(diffs,
 		Diff{Item: "调用前校区账户行", Legacy: fmt.Sprint(lg.Before.HasCampusAccount),
 			Target: fmt.Sprint(tg.Before.HasCampusAccount), Equal: true,
 			Note: "都应为 false(本条的**前提**,由 case 显式声明 NoCampusAccount)"},
 		Diff{Item: "调用后校区行 · balance",
-			Legacy: nullWord(lg.After), Target: yuan(tg.After.Campus.Balance), Equal: true,
-			Note: "**本来就不同**:legacy 建成 NULL 且扣不动;target 真扣成 −金额"},
-		Diff{Item: "调用后全局 · Δbalance", Legacy: yuan(dGlobal.Balance), Target: yuan(tGlobal.Balance),
-			Equal: true, Note: "两侧全局都扣了(全局列有 DEFAULT 0.000)"},
+			Legacy: moneyStr(lg.After.Campus.Balance, lg.After.Campus.Nulls.Balance),
+			Target: moneyStr(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance), Equal: true,
+			Note: "**本来就不同**:legacy 建成 NULL(不是 0)且扣不动;target 真扣成 −金额"},
+		Diff{Item: "调用后校区行 · NULL 列",
+			Legacy: lg.After.Campus.Nulls.String(), Target: tg.After.Campus.Nulls.String(), Equal: true,
+			Note: "legacy 应为五列全 NULL;target 应为 -(无 NULL)"},
+		Diff{Item: "调用后全局 · Δbalance",
+			Legacy: moneyStr(dGlobal.Balance, dGlobal.Nulls.Balance),
+			Target: moneyStr(tGlobal.Balance, tGlobal.Nulls.Balance),
+			Equal:  true, Note: "两侧全局都扣了(`t_user_expense` 的金额列 DEFAULT 0.000)"},
 		Diff{Item: "流水条数", Legacy: fmt.Sprint(len(lg.After.Logs)), Target: fmt.Sprint(len(tg.After.Logs)),
 			Equal: true, Note: "都是 1 条 type=209 —— legacy 是「没扣钱却写了凭证」"},
 	)
@@ -357,10 +374,16 @@ func campusRowNullCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 				lg.Before.HasCampusAccount, tg.Before.HasCampusAccount),
 		},
 		Assertion{
-			Name: "legacy:校区行**被建出来了**,但金额列仍是 **NULL**(`SET balance=balance-m` 在 NULL 上仍是 NULL)",
-			Pass: lg.After.HasCampusAccount && lg.After.CampusNullBalance,
-			Detail: fmt.Sprintf("legacy 行存在=%v 金额列为 NULL=%v(都应为 true)",
-				lg.After.HasCampusAccount, lg.After.CampusNullBalance),
+			Name: "legacy:校区行**被建出来了**,且五列金额**全是 NULL**(不是 0,也不是 −金额)",
+			Pass: lg.After.HasCampusAccount && lgAllNull,
+			Detail: fmt.Sprintf("legacy 行存在=%v NULL 列=%s(期望 true / 五列全 NULL)",
+				lg.After.HasCampusAccount, lg.After.Campus.Nulls.String()),
+		},
+		Assertion{
+			Name: "legacy:**Δ校区也要如实表示 NULL**——不能因为读成 0 就显示成 「Δ=0.00」(那与「扣了 0 元」无法区分)",
+			Pass: dCampus.Nulls.Any(),
+			Detail: fmt.Sprintf("Δ校区 balance=%s(NULL 列=%s)",
+				moneyStr(dCampus.Balance, dCampus.Nulls.Balance), dCampus.Nulls.String()),
 		},
 		Assertion{
 			Name: "legacy:**全局**被扣了 −金额(`t_user_expense` 的列有 DEFAULT 0.000)—— 同一笔业务两表矛盾",
@@ -375,34 +398,30 @@ func campusRowNullCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			Detail: fmt.Sprintf("legacy %s(期望 type=209 money=%s ×1)", fmtLogs(lg.After.Logs), yuan(want)),
 		},
 		Assertion{
-			Name: "target:校区行被建出来且**真扣成 −金额**(列是 NOT NULL DEFAULT 0.00),两表一致",
-			Pass: tg.After.HasCampusAccount && !tg.After.CampusNullBalance &&
+			Name: "target:校区行被建出来、**五列都不为 NULL**、且真扣成 −金额(列是 NOT NULL DEFAULT 0.00)",
+			Pass: tg.After.HasCampusAccount && tgNoNull &&
 				tg.After.Campus.Balance == want && tCampus.Balance == want && tCampus.Income == want,
-			Detail: fmt.Sprintf("target 行存在=%v 余额=%s Δbalance=%s Δtotal_income=%s(期望 true / %s / %s / %s)",
-				tg.After.HasCampusAccount, yuan(tg.After.Campus.Balance),
-				yuan(tCampus.Balance), yuan(tCampus.Income),
+			Detail: fmt.Sprintf("target 行存在=%v NULL 列=%s 余额=%s Δbalance=%s Δtotal_income=%s(期望 true / - / %s / %s / %s)",
+				tg.After.HasCampusAccount, tg.After.Campus.Nulls.String(),
+				moneyStr(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance),
+				moneyStr(tCampus.Balance, tCampus.Nulls.Balance),
+				moneyStr(tCampus.Income, tCampus.Nulls.Income),
 				yuan(want), yuan(want), yuan(want)),
 		},
 		Assertion{
-			Name: "判定:**LEGACY-DEFECT** —— legacy 两表自相矛盾(校区 NULL / 全局 −金额),target 侧正确,不跟",
-			Pass: lg.After.CampusNullBalance && !tg.After.CampusNullBalance &&
-				dGlobal.Balance == tGlobal.Balance && dCampus.Balance != tCampus.Balance,
-			Detail: fmt.Sprintf("legacy 校区 NULL=%v Δ校区=%s | target Δ校区=%s | 两侧全局 Δ 相同=%v",
-				lg.After.CampusNullBalance, yuan(dCampus.Balance), yuan(tCampus.Balance),
-				dGlobal.Balance == tGlobal.Balance),
+			Name: "判定:**LEGACY-DEFECT** —— legacy 两表自相矛盾(校区五列 NULL / 全局 −金额),target 侧正确,不跟",
+			Pass: lgAllNull && tgNoNull &&
+				dGlobal == tGlobal &&
+				!sameMoney(dCampus.Balance, dCampus.Nulls.Balance, tCampus.Balance, tCampus.Nulls.Balance),
+			Detail: fmt.Sprintf("legacy 五列全 NULL=%v Δ校区=%s | target NULL 列=%s Δ校区=%s | 两侧全局 Δ 完全相同=%v",
+				lgAllNull, moneyStr(dCampus.Balance, dCampus.Nulls.Balance),
+				tg.After.Campus.Nulls.String(), moneyStr(tCampus.Balance, tCampus.Nulls.Balance),
+				dGlobal == tGlobal),
 		},
 	)
 	return diffs, asserts
 }
 
-// nullWord 把"NULL / 具体值"渲染成可读串(报告里 NULL 必须显形,不能显示成 0.00)。
-func nullWord(s SideSnapshot) string {
-	if s.CampusNullBalance {
-		return "NULL(未扣款)"
-	}
-	return yuan(s.Campus.Balance)
-}
-
 // ---------- 小工具 ----------
 
 func errSuffix(e string) string {

+ 65 - 4
internal/academic/dualrun/stage2/case.go

@@ -3,6 +3,7 @@ package stage2
 import (
 	"context"
 	"fmt"
+	"strings"
 	"time"
 
 	"gorm.io/gorm"
@@ -203,16 +204,72 @@ type Baseline struct {
 	CampusExpenditure int64
 }
 
-// Account 是一个账户行的快照(单位:分)。
+// AccountNulls 记录 Account 每一列是否为 SQL NULL。
 //
-// legacy 侧是 float(10,3) → 读成 DECIMAL(20,3) 再按分取整(与迁移同一口径);
-// target 侧是 decimal(12,2) → 直接读。
+// 为什么必须有它(不是"顺手 IFNULL 成 0 就行"):
+//
+//	legacy 两张账户表的金额列全是 `NULL` 可空,且 `t_user_expense_school` 的六列
+//	`COLUMN_DEFAULT` 都是 NULL。而 SP 的"先建行"分支只写三列:
+//	    INSERT INTO t_user_expense_school(userid, schoolid, campusid) VALUES (...)
+//	于是新行的余额列是 **NULL**,接着 `SET balance=balance-30` 在 NULL 上运算**仍是 NULL**
+//	—— 钱一分没扣,凭证却照写。这就是 C1-11 抓到的 LEGACY-DEFECT。
+//
+// 如果把 NULL 读成 0,两种**后果完全不同**的状态会合并成同一个数:
+//
+//	0    = 扣完了 / 本来就是 0     → 下一次 balance-30 会变成 −30
+//	NULL = 这行从没被写过         → 下一次 balance-30 **还是 NULL**(静默不动)
+//
+// 那就等于 harness 自己制造了一次"空过断言"。所以 NULL 必须是独立一态,
+// 并且参与 `Account` 的相等判断 —— `AccountNulls` 是可比较结构体,
+// 加进 `Account` 之后 `==` 自动区分「NULL」与「0」,所有既有比对点不改也变严。
+type AccountNulls struct {
+	Balance     bool
+	Spend       bool
+	Income      bool
+	Expenditure bool
+	Special     bool
+}
+
+// Any 表示"至少有一列是 NULL"(报告与断言里常用的快捷判断)。
+func (n AccountNulls) Any() bool {
+	return n.Balance || n.Spend || n.Income || n.Expenditure || n.Special
+}
+
+// String 把 NULL 分布渲染成 "balance,income" 形式(无 NULL 时返回 "-")。
+func (n AccountNulls) String() string {
+	var parts []string
+	if n.Balance {
+		parts = append(parts, "balance")
+	}
+	if n.Spend {
+		parts = append(parts, "spend")
+	}
+	if n.Income {
+		parts = append(parts, "total_income")
+	}
+	if n.Expenditure {
+		parts = append(parts, "total_expenditure")
+	}
+	if n.Special {
+		parts = append(parts, "special_expenses")
+	}
+	if len(parts) == 0 {
+		return "-"
+	}
+	return strings.Join(parts, ",")
+}
+
+// Account 是一个账户行的快照(金额单位:分)。
+//
+// 三态:`Nulls.X == true` 表示该列是 SQL NULL,此时 `X` 字段本身**无意义**(恒为 0)。
+// 读的时候一律按 (值, 是否NULL) 成对处理;比的时候用结构体相等(Nulls 一并参与)。
 type Account struct {
 	Balance     int64
 	Spend       int64
 	Income      int64
 	Expenditure int64
-	Special     int64 // special_expenses(只有坏账会动)
+	Special     int64 // special_expenses(只有坏账/清零会动)
+	Nulls       AccountNulls
 }
 
 // LogRow 是一条流水的快照(两侧字段对齐后)。
@@ -254,6 +311,10 @@ type SideSnapshot struct {
 	// `SET balance=balance-inputMoney` 在 NULL 上运算仍然是 NULL ——
 	// **钱根本没扣,但 type=209 的流水照写**。target 侧同名列是 `NOT NULL DEFAULT 0.00`,
 	// 所以那一侧会真的扣成 −30.00。两侧从此不一致,这是 **legacy 的数据缺陷**。
+	//
+	// 已被 `Account.Nulls` 取代(保留此字段只为不破坏既有引用,语义 = Campus.Nulls.Any())。
+	// 新代码请直接用 `s.Campus.Nulls`:它**逐列**记录,能分辨"只有 total_income 是 NULL"这种情形,
+	// 而这个 bool 只能回答"有没有 NULL",用它写断言会漏掉"哪一列"。
 	CampusNullBalance bool
 }
 

+ 68 - 45
internal/academic/dualrun/stage2/legacy.go

@@ -291,45 +291,31 @@ func sqlCell(v any) string {
 func snapshotLegacy(ctx context.Context, tx *gorm.DB, c Case) (SideSnapshot, int64, error) {
 	var out SideSnapshot
 
-	var g struct {
-		Balance, Spend, Income, Expenditure, Special string
-	}
+	// 全局账户:五列**全部**按 (值, 是否NULL) 成对读。
+	var g accountNS
 	err := tx.Raw(`
 SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2)),
        CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2)              AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2)       AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2)  AS DECIMAL(12,2)),
-       CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
+       CAST(ROUND(CAST(special_expenses 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)
 	if err != nil {
 		return out, 0, fmt.Errorf("读 legacy 全局账户失败:%w", err)
 	}
-	gs, err := centsOf(g.Balance)
-	if err != nil {
-		return out, 0, err
-	}
-	gsp, _ := centsOf(g.Spend)
-	gi, _ := centsOf(g.Income)
-	ge, _ := centsOf(g.Expenditure)
-	gsp2, _ := centsOf(g.Special)
-	out.Global = Account{Balance: gs, Spend: gsp, Income: gi, Expenditure: ge, Special: gsp2}
-
-	// 校区账户:**必须容忍 NULL**。
-	//
-	// `t_user_expense_school` 的金额列是 `decimal(10,3) NULL DEFAULT NULL`(没有默认值)。
-	// SP8 的建行语句只写三列,于是新行金额全是 NULL;而 `SET balance=balance-m` 在 NULL 上
-	// 运算仍是 NULL。Scan 进 string 会直接报 "converting NULL to string is unsupported",
-	// 整条 case 连"读回状态"都做不到 —— 那不是数据问题,是**读的工具不够宽容**。
-	var sc struct {
-		Balance, Spend, Income, Expenditure, Special sql.NullString
-	}
+	out.Global = g.account()
+
+	// 校区账户:`t_user_expense_school` 的金额列是 `decimal(10,3) NULL DEFAULT NULL`(**没有默认值**),
+	// SP8 的建行语句只写三列 → 新行金额全是 NULL;`SET balance=balance-m` 在 NULL 上仍是 NULL。
+	// 所以这一块必须区分"NULL"和"0"(见 AccountNulls 的注释)。
+	var sc accountNS
 	err = tx.Raw(`
 SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2)),
        CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2)              AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2)       AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2)  AS DECIMAL(12,2)),
-       CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
+       CAST(ROUND(CAST(special_expenses 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)
@@ -339,39 +325,33 @@ SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2))
 		if !errors.Is(err, sql.ErrNoRows) {
 			return out, 0, fmt.Errorf("读 legacy 校区账户失败:%w", err)
 		}
-		err = nil
 	} else {
 		out.HasCampusAccount = true
-		out.CampusNullBalance = !sc.Balance.Valid
 	}
-	cs, _ := centsOf(nullStr(sc.Balance))
-	csp, _ := centsOf(nullStr(sc.Spend))
-	ci, _ := centsOf(nullStr(sc.Income))
-	ce, _ := centsOf(nullStr(sc.Expenditure))
-	cspec, _ := centsOf(nullStr(sc.Special))
-	out.Campus = Account{Balance: cs, Spend: csp, Income: ci, Expenditure: ce, Special: cspec}
+	// ⚠ 「行不存在」与「行内金额全是 NULL」是**两件事**,不能都写成零值 Account:
+	//   - 行不存在  → Campus 保持零值(Nulls 全 false,表示"没有这一行",由 HasCampusAccount 说明)
+	//   - 行存在且 NULL → Campus.Nulls 全 true
+	// 如果无条件 `sc.account()`,零值 NullString 会被读成"五列全 NULL",
+	// 于是"没有这一行"被伪装成"有一行但没写过" —— 建行分支的断言就会假绿。
+	if out.HasCampusAccount {
+		out.Campus = sc.account()
+	}
+	out.CampusNullBalance = out.Campus.Nulls.Any()
 
 	// 转入校区(只有 transfer 用得到;不存在时为零值)
 	if c.ToCampusID > 0 {
-		var tc struct {
-			Balance, Spend, Income, Expenditure, Special string
-		}
+		var tc accountNS
 		qerr := tx.Raw(`
 SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2)),
        CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2)              AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2)       AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2)  AS DECIMAL(12,2)),
-       CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
+       CAST(ROUND(CAST(special_expenses 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)
 		if qerr == nil {
-			b, _ := centsOf(tc.Balance)
-			sp, _ := centsOf(tc.Spend)
-			in, _ := centsOf(tc.Income)
-			ex, _ := centsOf(tc.Expenditure)
-			spc, _ := centsOf(tc.Special)
-			out.Campus2 = Account{Balance: b, Spend: sp, Income: in, Expenditure: ex, Special: spc}
+			out.Campus2 = tc.account()
 		}
 	}
 
@@ -425,16 +405,59 @@ func filterLogs(logs []LogRow, afterID int64) []LogRow {
 func centsOf(s string) (int64, error) { return fixture.DecimalToCents(s) }
 
 // nullStr 把可空列取成字符串(NULL → "",`DecimalToCents("")` 归 0)。
-func nullStr(v sql.NullString) string {
+// accountNS 是"账户五列 + NULL 标记"的扫描目标。
+//
+// 为什么单独抽一个类型,而不是在三个 SELECT 里各写一遍 `var x struct{...}`:
+// 三处(全局 / 校区 / 转入校区)必须用**完全一样**的读法,否则某一处忘了 NULL
+// 就会在别处静默变回 0 —— 那正是 C1-11 崩溃的成因(只有一处用了 NullString)。
+type accountNS struct {
+	Balance, Spend, Income, Expenditure, Special sql.NullString
+}
+
+// account 把扫描结果转成三态 Account(NULL 列的值恒为 0,靠 Nulls 标记)。
+func (n accountNS) account() Account {
+	b, bn := centsOrNull(n.Balance)
+	sp, spn := centsOrNull(n.Spend)
+	in, inn := centsOrNull(n.Income)
+	ex, exn := centsOrNull(n.Expenditure)
+	pc, pcn := centsOrNull(n.Special)
+	return Account{
+		Balance: b, Spend: sp, Income: in, Expenditure: ex, Special: pc,
+		Nulls: AccountNulls{
+			Balance: bn, Spend: spn, Income: inn, Expenditure: exn, Special: pcn,
+		},
+	}
+}
+
+// centsOrNull 把可空十进制串换成分;!Valid(SQL NULL)时返回 (0, true)。
+//
+// 注意这里**不**把 NULL 折成 0 再当"值 0"用:返回的第二项就是"它是 NULL"这个事实本身,
+// 由调用方存进 AccountNulls。折平了就等于放弃分辨能力(见 AccountNulls 的注释)。
+func centsOrNull(v sql.NullString) (int64, bool) {
 	if !v.Valid {
-		return ""
+		return 0, true
 	}
-	return v.String
+	c, err := fixture.DecimalToCents(v.String)
+	if err != nil {
+		// 解析失败不是 NULL,宁可当 0 但也别谎报 NULL;错误已在上游 SQL 的 CAST 里被约束住。
+		return 0, false
+	}
+	return c, false
 }
 
 // yuan 把分格式化成 2 位十进制串(写回账户列用)。
 func yuan(cents int64) string { return fixture.CentsToDecimal(cents) }
 
+// moneyStr 渲染一个三态金额:NULL 显示成 "NULL",**不能显示成 0.00**。
+//
+// 报告里 "NULL" 与 "0.00" 必须长得不一样,否则"没扣动"和"扣了 0 元"在输出上无法区分。
+func moneyStr(cents int64, isNull bool) string {
+	if isNull {
+		return "NULL"
+	}
+	return yuan(cents)
+}
+
 func maxInt(a, b int) int {
 	if a > b {
 		return a

+ 108 - 53
internal/academic/dualrun/stage2/run.go

@@ -177,13 +177,17 @@ func DefaultCases(_ string) []Case {
 			Note: "SP10 第二条守卫 `ISNULL(totalBalance)||totalBalance<=0 → LEAVE`",
 		},
 		Case{
-			Name: "C1-5 清零前置校验·校区余额 > 全局余额(legacy 静默 vs Go 3120)",
-			Op:   OpClearBalance, LegacyUserID: 40575, InstitutionID: 63317, CampusID: 4,
-			RequestID: "ac15-c1-5", RunTimes: 1, SelfSupport: false,
-			Baseline:            &Baseline{GlobalBalance: 50000, CampusBalance: 80000},
+			// 这一条**用真实构型,不用 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,
 			ExpectTargetSuccess: false, ExpectLegacySuccess: true,
 			ExpectTargetCode: 3120, IntentionalStrict: true,
-			Note: "SP10 第三条守卫 `campusBalance>totalBalance → LEAVE`(数据异常,拒绝对负余额清零)",
+			Note: "SP10 第三条守卫 `campusBalance>totalBalance → LEAVE`。真实数据:校区 0.270 > 全局 0.269(差 1 分)," +
+				"正是「余额 ≠ 流水累计」那 6.6% 用户的一种表现",
 		},
 	)
 
@@ -527,19 +531,33 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	tCampus2 := sub(tg.After.Campus2, tg.Before.Campus2)
 
 	addDelta := func(name string, dl, dt, wl, wt Account) {
-		addDeltaRow(&diffs, name+" Δbalance", yuan(dl.Balance), yuan(dt.Balance),
-			dl.Balance == wl.Balance && dt.Balance == wt.Balance,
-			"期望 "+yuan(wl.Balance)+" / "+yuan(wt.Balance)+";容差 0 "+noteRuns)
-		addDeltaRow(&diffs, name+" Δspend", yuan(dl.Spend), yuan(dt.Spend),
-			dl.Spend == wl.Spend && dt.Spend == wt.Spend, "期望 "+yuan(wl.Spend)+" / "+yuan(wt.Spend))
-		addDeltaRow(&diffs, name+" Δtotal_income", yuan(dl.Income), yuan(dt.Income),
-			dl.Income == wl.Income && dt.Income == wt.Income, "期望 "+yuan(wl.Income)+" / "+yuan(wt.Income))
-		addDeltaRow(&diffs, name+" Δtotal_expenditure", yuan(dl.Expenditure), yuan(dt.Expenditure),
-			dl.Expenditure == wl.Expenditure && dt.Expenditure == wt.Expenditure,
-			"期望 "+yuan(wl.Expenditure)+" / "+yuan(wt.Expenditure))
-		addDeltaRow(&diffs, name+" Δspecial_expenses", yuan(dl.Special), yuan(dt.Special),
-			dl.Special == wl.Special && dt.Special == wt.Special,
-			"期望 "+yuan(wl.Special)+" / "+yuan(wt.Special))
+		addDeltaRow(&diffs, name+" Δbalance",
+			moneyStr(dl.Balance, dl.Nulls.Balance), moneyStr(dt.Balance, dt.Nulls.Balance),
+			sameMoney(dl.Balance, dl.Nulls.Balance, wl.Balance, wl.Nulls.Balance) &&
+				sameMoney(dt.Balance, dt.Nulls.Balance, wt.Balance, wt.Nulls.Balance),
+			"期望 "+moneyStr(wl.Balance, wl.Nulls.Balance)+" / "+
+				moneyStr(wt.Balance, wt.Nulls.Balance)+";容差 0 "+noteRuns)
+		addDeltaRow(&diffs, name+" Δspend",
+			moneyStr(dl.Spend, dl.Nulls.Spend), moneyStr(dt.Spend, dt.Nulls.Spend),
+			sameMoney(dl.Spend, dl.Nulls.Spend, wl.Spend, wl.Nulls.Spend) &&
+				sameMoney(dt.Spend, dt.Nulls.Spend, wt.Spend, wt.Nulls.Spend),
+			"期望 "+moneyStr(wl.Spend, wl.Nulls.Spend)+" / "+moneyStr(wt.Spend, wt.Nulls.Spend))
+		addDeltaRow(&diffs, name+" Δtotal_income",
+			moneyStr(dl.Income, dl.Nulls.Income), moneyStr(dt.Income, dt.Nulls.Income),
+			sameMoney(dl.Income, dl.Nulls.Income, wl.Income, wl.Nulls.Income) &&
+				sameMoney(dt.Income, dt.Nulls.Income, wt.Income, wt.Nulls.Income),
+			"期望 "+moneyStr(wl.Income, wl.Nulls.Income)+" / "+moneyStr(wt.Income, wt.Nulls.Income))
+		addDeltaRow(&diffs, name+" Δtotal_expenditure",
+			moneyStr(dl.Expenditure, dl.Nulls.Expenditure), moneyStr(dt.Expenditure, dt.Nulls.Expenditure),
+			sameMoney(dl.Expenditure, dl.Nulls.Expenditure, wl.Expenditure, wl.Nulls.Expenditure) &&
+				sameMoney(dt.Expenditure, dt.Nulls.Expenditure, wt.Expenditure, wt.Nulls.Expenditure),
+			"期望 "+moneyStr(wl.Expenditure, wl.Nulls.Expenditure)+" / "+
+				moneyStr(wt.Expenditure, wt.Nulls.Expenditure))
+		addDeltaRow(&diffs, name+" Δspecial_expenses",
+			moneyStr(dl.Special, dl.Nulls.Special), moneyStr(dt.Special, dt.Nulls.Special),
+			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))
 	}
 	addDelta("全局", dGlobal, tGlobal, wgL, wgT)
 	addDelta("转出校区", dCampus, tCampus, wfL, wfT)
@@ -548,22 +566,25 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	}
 
 	// ②.5 「不动的列」必须**显式断言为 0**,不能只是"不比较"
+	//
+	// ⚠ 判"等于 0"必须用 `isZero`,不能写 `d.Balance == 0`:Δ 为 NULL 时值也是 0,
+	// 直接比 int64 会把「NULL(压根没算出来)」当成「确实是 0」,断言就空了。
 	if c.Op == OpSpend || c.Op == OpTransfer {
 		names := map[Op]string{OpSpend: "累计消费(spend)", OpTransfer: "课费转结(transfer)"}[c.Op]
-		ok := dGlobal.Balance == 0 && tGlobal.Balance == 0
+		ok := isZero(dGlobal.Balance, dGlobal.Nulls.Balance) && isZero(tGlobal.Balance, tGlobal.Nulls.Balance)
 		if c.Op == OpTransfer {
-			ok = ok && dCampus.Balance == 0 && tCampus.Balance == 0 &&
-				dCampus2.Balance == 0 && tCampus2.Balance == 0
+			ok = ok && isZero(dCampus.Balance, dCampus.Nulls.Balance) && isZero(tCampus.Balance, tCampus.Nulls.Balance) &&
+				isZero(dCampus2.Balance, dCampus2.Nulls.Balance) && isZero(tCampus2.Balance, tCampus2.Nulls.Balance)
 		} else {
-			ok = ok && dCampus.Balance == 0 && tCampus.Balance == 0
+			ok = ok && isZero(dCampus.Balance, dCampus.Nulls.Balance) && isZero(tCampus.Balance, tCampus.Nulls.Balance)
 		}
 		asserts = append(asserts, Assertion{
-			Name: names + ":Δbalance 必须为 0(不是不比较,是断言它为 0)",
+			Name: names + ":Δbalance 必须**恰好是 0**(不是不比较,也不是 NULL)",
 			Pass: ok,
 			Detail: fmt.Sprintf("全局 %s/%s;转出校区 %s/%s;转入校区 %s/%s(都应为 0.00)",
-				yuan(dGlobal.Balance), yuan(tGlobal.Balance),
-				yuan(dCampus.Balance), yuan(tCampus.Balance),
-				yuan(dCampus2.Balance), yuan(tCampus2.Balance)),
+				moneyStr(dGlobal.Balance, dGlobal.Nulls.Balance), moneyStr(tGlobal.Balance, tGlobal.Nulls.Balance),
+				moneyStr(dCampus.Balance, dCampus.Nulls.Balance), moneyStr(tCampus.Balance, tCampus.Nulls.Balance),
+				moneyStr(dCampus2.Balance, dCampus2.Nulls.Balance), moneyStr(tCampus2.Balance, tCampus2.Nulls.Balance)),
 		})
 	}
 
@@ -766,28 +787,24 @@ func readLegacyNatural(ctx context.Context, cfg Config, legacyUserID int64) (Acc
 	if err != nil {
 		return Account{}, 0, err
 	}
-	var bal, spend, inc, exp, spec string
+	var nat accountNS
 	err = db.Raw(`
 SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
        CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
-       CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
-  FROM t_user_expense WHERE userid = ?`, legacyUserID).Row().Scan(&bal, &spend, &inc, &exp, &spec)
+       CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
+  FROM t_user_expense WHERE userid = ?`, legacyUserID).
+		Row().Scan(&nat.Balance, &nat.Spend, &nat.Income, &nat.Expenditure, &nat.Special)
 	if err != nil {
 		return Account{}, 0, fmt.Errorf("读 legacy 天然基线失败:%w", err)
 	}
-	b, _ := centsOf(bal)
-	sp, _ := centsOf(spend)
-	i, _ := centsOf(inc)
-	e, _ := centsOf(exp)
-	pc, _ := centsOf(spec)
 	var logCount int64
 	if err := db.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ?`, legacyUserID).
 		Row().Scan(&logCount); err != nil {
 		return Account{}, 0, fmt.Errorf("读 legacy 天然流水条数失败:%w", err)
 	}
-	return Account{Balance: b, Spend: sp, Income: i, Expenditure: e, Special: pc}, logCount, nil
+	return nat.account(), logCount, nil
 }
 
 // checkResidue 在两个事务都回滚之后,用**新连接**核对残留。
@@ -811,22 +828,19 @@ func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural A
 	// legacy:用户本来就存在,核对"余额回到调用前"
 	ldb, err := cfg.OpenWriteLegacy()
 	if err == nil {
-		var bal, spend, inc, exp, spec string
+		var cur accountNS
 		err := ldb.Raw(`
 SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
        CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
        CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
-       CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
-  FROM t_user_expense WHERE userid = ?`, c.LegacyUserID).Row().Scan(&bal, &spend, &inc, &exp, &spec)
+       CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
+  FROM t_user_expense WHERE userid = ?`, c.LegacyUserID).
+			Row().Scan(&cur.Balance, &cur.Spend, &cur.Income, &cur.Expenditure, &cur.Special)
 		if err == nil {
-			b, _ := centsOf(bal)
-			s, _ := centsOf(spend)
-			i, _ := centsOf(inc)
-			e, _ := centsOf(exp)
-			pc, _ := centsOf(spec)
-			// special_expenses 也进"是否偏离"的比较:清零/坏账会动它,漏比就漏残留
-			rep.LegacyGlobalExist = Account{Balance: b, Spend: s, Income: i, Expenditure: e, Special: pc} != natural
+			// 五列全比(含 special_expenses 与 **NULL 状态**):
+			// 清零/坏账会动 special,漏比就漏残留;而 NULL 与 0 不等价,漏比会漏掉"写到 NULL"。
+			rep.LegacyGlobalExist = cur.account() != natural
 		}
 		if c.RequestID != "" {
 			// 有请求号的 case:按 logid 精确找本 case 的残留
@@ -852,19 +866,60 @@ SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
 
 // ---------- 小工具 ----------
 
+// sub 计算 Δ = a − b,并**传播 NULL**:任一侧是 NULL,结果就是 NULL。
+//
+// 为什么不能简单相减:NULL 在 SQL 里不是 0 —— `NULL - 30` 是 NULL,`0 - 30` 是 −30。
+// 如果在这里把 NULL 当 0 算,C1-11 那种"建行后余额是 NULL、扣不动"的场景会被算成
+// "Δ = 0.00",在报告里和"余额本来就是 0、扣了 0"完全一样 —— 一次空过断言。
+//
+// 每个字段都要显式处理:漏掉一列(比如 Special)会让该列 Δ 恒为 0,坏账 case 会假红。
 func sub(a, b Account) Account {
-	return Account{
-		Balance:     a.Balance - b.Balance,
-		Spend:       a.Spend - b.Spend,
-		Income:      a.Income - b.Income,
-		Expenditure: a.Expenditure - b.Expenditure,
-		Special:     a.Special - b.Special, // 漏掉这一行会让 Δspecial 恒为 0(坏账 case 假红)
+	d := Account{}
+	d.Balance, d.Nulls.Balance = subOne(a.Balance, a.Nulls.Balance, b.Balance, b.Nulls.Balance)
+	d.Spend, d.Nulls.Spend = subOne(a.Spend, a.Nulls.Spend, b.Spend, b.Nulls.Spend)
+	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)
+	return d
+}
+
+// subOne 是单列减法:任一侧 NULL → 结果 NULL(值取 0,由 null 标记说明它无意义)。
+func subOne(av int64, an bool, bv int64, bn bool) (int64, bool) {
+	if an || bn {
+		return 0, true
+	}
+	return av - bv, false
+}
+
+// isZero 判断"三态金额恰好是数值 0(且不是 NULL)"。
+//
+// 为什么不能写 `v == 0`:NULL 的值也是 0,直接比 int64 会把"没算出来"当成"确实是 0"。
+func isZero(v int64, isNull bool) bool { return !isNull && v == 0 }
+
+// sameMoney 判断"两个三态金额相等":NULL 只等于 NULL,0 只等于 0。
+//
+// 直接写 `a == b` 也可以(int64 相等),但那样会漏掉 null 标记 ——
+// 于是 "NULL" 与 "0.00" 会被判成相等。所有金额比较都走这里。
+func sameMoney(av int64, an bool, bv int64, bn bool) bool {
+	if an || bn {
+		return an == bn
+	}
+	return av == bv
+}
+
+// nullSuffix 在报告里把 NULL 列名附在账户后面(无 NULL 时不加任何东西)。
+func nullSuffix(n AccountNulls) string {
+	if !n.Any() {
+		return ""
 	}
+	return " NULL:" + n.String()
 }
 
 func fmtAcc(a Account) string {
-	return fmt.Sprintf("{bal=%s spend=%s income=%s exp=%s}",
-		yuan(a.Balance), yuan(a.Spend), yuan(a.Income), yuan(a.Expenditure))
+	return fmt.Sprintf("{bal=%s spend=%s income=%s exp=%s%s}",
+		moneyStr(a.Balance, a.Nulls.Balance), moneyStr(a.Spend, a.Nulls.Spend),
+		moneyStr(a.Income, a.Nulls.Income), moneyStr(a.Expenditure, a.Nulls.Expenditure),
+		nullSuffix(a.Nulls))
 }
 
 func abs64(v int64) int64 {

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

@@ -383,3 +383,97 @@ func TestOpFlags(t *testing.T) {
 		t.Error("SP10 只有 SELECT ... INTO,不产生结果集")
 	}
 }
+
+// TestNullNotEqualZero 锁住"NULL 与 0 是两态"。
+//
+// 这是整个三态模型的根:如果 `Account` 的相等判断看不区分 NULL 和 0,
+// 那 C1-11 的"建行后余额是 NULL、扣不动"会被读成"扣了 0 元",断言静默通过。
+// `AccountNulls` 参与结构体相等,所以下面第一行必须**不相等**。
+func TestNullNotEqualZero(t *testing.T) {
+	zero := Account{Balance: 0}
+	null := Account{Balance: 0, Nulls: AccountNulls{Balance: true}}
+	if zero == null {
+		t.Fatal("余额 0 与余额 NULL 被判定相等 —— 三态模型失效")
+	}
+	if sameMoney(0, false, 0, true) {
+		t.Fatal("sameMoney 把 0 与 NULL 判成相等")
+	}
+	if !sameMoney(0, true, 0, true) {
+		t.Fatal("NULL 与 NULL 应当相等")
+	}
+	if !sameMoney(1250, false, 1250, false) {
+		t.Fatal("同值非 NULL 应当相等")
+	}
+	if sameMoney(1250, false, 1251, false) {
+		t.Fatal("差 1 分必须不相等(容差恒为 0)")
+	}
+}
+
+// TestSubNullPropagation 锁住 Δ 的 NULL 传播:任一侧 NULL → 结果 NULL。
+//
+// SQL 语义:`NULL - 30` 是 NULL,`0 - 30` 是 −30。这两件事在报告里必须长得不一样。
+func TestSubNullPropagation(t *testing.T) {
+	val := Account{Balance: 5000, Spend: 100, Income: 200, Expenditure: 300, Special: 400}
+	nulled := Account{Nulls: AccountNulls{Balance: true}}
+
+	d := sub(val, nulled)
+	if !d.Nulls.Balance {
+		t.Error("a 有值 - b 为 NULL:Δ 必须是 NULL")
+	}
+	if d.Nulls.Spend || d.Nulls.Income || d.Nulls.Expenditure || d.Nulls.Special {
+		t.Errorf("NULL 只应传播到 Balance 那一列,得到 %+v", d.Nulls)
+	}
+	if d.Spend != 100 || d.Income != 200 || d.Expenditure != 300 || d.Special != 400 {
+		t.Errorf("其它列应正常相减,得到 %+v", d)
+	}
+
+	d = sub(nulled, val)
+	if !d.Nulls.Balance {
+		t.Error("a 为 NULL - b 有值:Δ 也必须是 NULL")
+	}
+
+	d = sub(nulled, nulled)
+	if !d.Nulls.Balance {
+		t.Error("NULL - NULL 仍是 NULL(不是 0)")
+	}
+
+	// 每一列都要单独传播:漏掉一列(比如 Special)就会让那一列 Δ 恒为 0
+	allNull := Account{Nulls: AccountNulls{Balance: true, Spend: true, Income: true, Expenditure: true, Special: true}}
+	d = sub(val, allNull)
+	if !(d.Nulls.Balance && d.Nulls.Spend && d.Nulls.Income && d.Nulls.Expenditure && d.Nulls.Special) {
+		t.Errorf("五列都必须传播 NULL,得到 %+v", d.Nulls)
+	}
+
+	// 常规路径不能被 NULL 逻辑影响
+	d = sub(Account{Balance: 3000, Special: 800}, Account{Balance: 1000, Special: 800})
+	if d.Balance != 2000 || d.Special != 0 || d.Nulls.Any() {
+		t.Errorf("常规相减出错:%+v", d)
+	}
+}
+
+// TestMoneyStrRendersNull 锁住报告渲染:NULL 必须显形,不能显示成 0.00。
+func TestMoneyStrRendersNull(t *testing.T) {
+	if got := moneyStr(0, true); got != "NULL" {
+		t.Errorf("NULL 应渲染成 \"NULL\",得到 %q", got)
+	}
+	if got := moneyStr(0, false); got != "0.00" {
+		t.Errorf("值为 0 应渲染成 \"0.00\",得到 %q", got)
+	}
+	if moneyStr(0, true) == moneyStr(0, false) {
+		t.Fatal("NULL 与 0.00 渲染成同一个串 —— 报告里两者将无法区分")
+	}
+}
+
+// TestAccountNullsString 锁住 NULL 列名列表(报告与断言 detail 都用它)。
+func TestAccountNullsString(t *testing.T) {
+	if got := (AccountNulls{}).String(); got != "-" {
+		t.Errorf("无 NULL 应为 \"-\",得到 %q", got)
+	}
+	n := AccountNulls{Balance: true, Income: true}
+	if got := n.String(); got != "balance,total_income" {
+		t.Errorf("应为 \"balance,total_income\",得到 %q", got)
+	}
+	if !n.Any() || (AccountNulls{}).Any() {
+		t.Error("Any() 判断错误")
+	}
+}

+ 13 - 21
internal/academic/dualrun/stage2/target.go

@@ -371,7 +371,10 @@ UPDATE t_user_expense_school SET balance=?, spend=?, total_income=?, total_expen
 func snapshotTarget(ctx context.Context, tx *gorm.DB, canonicalUID string, c Case) (SideSnapshot, int64, error) {
 	var out SideSnapshot
 
-	var g struct{ Balance, Spend, Income, Expenditure, Special string }
+	// target 的金额列是 `not null default 0.00`,理论上不会有 NULL;
+	// 但仍然用同一个三态扫描目标 —— 一旦 schema 漂移,这里是"读到 NULL 就报错"还是
+	// "静默变成 0",区别就是会不会生成一次空过断言。统一走 accountNS。
+	var g accountNS
 	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)),
@@ -380,14 +383,9 @@ SELECT CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
 		Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special); err != nil {
 		return out, 0, fmt.Errorf("读 target 全局账户失败:%w", err)
 	}
-	gs, _ := centsOf(g.Balance)
-	gsp, _ := centsOf(g.Spend)
-	gi, _ := centsOf(g.Income)
-	ge, _ := centsOf(g.Expenditure)
-	gpc, _ := centsOf(g.Special)
-	out.Global = Account{Balance: gs, Spend: gsp, Income: gi, Expenditure: ge, Special: gpc}
+	out.Global = g.account()
 
-	var sc struct{ Balance, Spend, Income, Expenditure, Special string }
+	var sc accountNS
 	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)),
@@ -403,16 +401,15 @@ SELECT CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
 	} else {
 		out.HasCampusAccount = true
 	}
-	cs, _ := centsOf(sc.Balance)
-	csp, _ := centsOf(sc.Spend)
-	ci, _ := centsOf(sc.Income)
-	ce, _ := centsOf(sc.Expenditure)
-	cpc, _ := centsOf(sc.Special)
-	out.Campus = Account{Balance: cs, Spend: csp, Income: ci, Expenditure: ce, Special: cpc}
+	// 同 legacy 侧:行不存在 → 零值(Nulls 全 false);行存在 → 三态账户。
+	if out.HasCampusAccount {
+		out.Campus = sc.account()
+	}
+	out.CampusNullBalance = out.Campus.Nulls.Any()
 
 	// 转入校区(只有 transfer 用得到)
 	if c.ToCampusID > 0 {
-		var tc struct{ Balance, Spend, Income, Expenditure, Special string }
+		var tc accountNS
 		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)),
@@ -421,12 +418,7 @@ SELECT CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
 			canonicalUID, c.ToCampusID).
 			Row().Scan(&tc.Balance, &tc.Spend, &tc.Income, &tc.Expenditure, &tc.Special)
 		if qerr == nil {
-			b, _ := centsOf(tc.Balance)
-			sp, _ := centsOf(tc.Spend)
-			in, _ := centsOf(tc.Income)
-			ex, _ := centsOf(tc.Expenditure)
-			spc, _ := centsOf(tc.Special)
-			out.Campus2 = Account{Balance: b, Spend: sp, Income: in, Expenditure: ex, Special: spc}
+			out.Campus2 = tc.account()
 		}
 	}