Просмотр исходного кода

feat(ac15): 批B 三支写 SP 双跑(坏账双增 / 累计消费只动spend / 课费转结双流水)+ A7 R19

批 B 三支的共同陷阱是"凭列名推断行为",实测 dump 后钉死:

- baddebt(SP3):balance 与 special_expenses **双增**(不是只加坏账专户),双表 + 1 条 type=108
  额外发现:SP3 的 COMMENT 写着 '用户余额充值'(从 recharge 复制忘改)—— 连注释都不能当依据
- school_spend(SP11):**只动 spend**,balance 一分不动,**0 条流水**(签名里连 requestId 都没有)
  额外发现:SP11 的 INSERT 分支 2 列配 3 值,是坏 SQL,线上从不触发 → 不能靠"照抄 SP"定义行为
- transfer(SP6):全局账户一分不动;转出校区 exp/income 各 −m、转入校区各 +m;
  每次 2 条流水 106(+m)/206(−m) 且 SUM(money)==0;两校区变化量互为反号
  额外发现:SP6 **没有默认备注**(inputRemark 原样落库),case 改为显式传备注以验证透传

A7(R19,campusid=0)走 IntentionalStrict 分支:legacy SP1 有 LEAVE 守卫
→ 静默返回成功且一行不写;Go validateUserScope → 3140。两侧本来就该不同,只把差异钉死成
三条断言(legacy 成功 / target 被拒 / 两侧都没写库 —— 后者同时比全局与校区,
避免"校区行不存在读成 0"把"只写校区表"的错误实现漏过去)。

纪律:**"不动 balance" 是断言 Δbalance==0,不是"不比这一列"**。
spend 与 transfer 都显式断言三块账户(全局/转出/转入)的 Δbalance 逐分为 0。

修掉三个自测假红:
1. sub() 漏了 Special → Δspecial_expenses 恒 0 → B1 假红 3 项
2. 流水条数一律按 runs 算 → B2(0 条)/B3(2 条) 假红
3. transfer 流水断言硬编码 2 条 → B4(runs=2/4 条) 假红 → 改 transferLogsOK(logs, pairs)

单测新增 4 组:expectedAccounts(批B三支,含"哪一块必须为 0")、expectedLogCount、
transferLogsOK(206 写成 +m 必须红)、remarkNote。

实测:13 个 case(A1–A7 / B1–B4 / R1 / R2)13 PASS,0 FAIL,两侧回滚后零残留。
Q 4 часов назад
Родитель
Сommit
318210ab27

+ 146 - 9
internal/academic/dualrun/stage2/README.md

@@ -1,11 +1,14 @@
 # `internal/academic/dualrun/stage2` —— 写 SP 双跑
 
-| 批 | SP | service 方法 | 列增量(照 dump) |
-|---|---|---|---|
-| 一 | `sp_user_expense_school_expenditure` | `Expenditure` | balance− / total_expenditure+(type=200 另加 spend+)|
-| **A** | `sp_user_expense_recharge` | `Recharge(Kind=Default)` | balance+ / total_income+,**双表** |
-| **A** | `sp_user_expense_school_recharge` | `Recharge(Kind=School)` | balance+ / total_income+,**双表** |
-| **A** | `sp_user_expense_school_refund` | `Refund` | balance+ / total_expenditure−,**双表** |
+| 批 | SP | service 方法 | 列增量(照 dump) | 流水 |
+|---|---|---|---|---|
+| 一 | `sp_user_expense_school_expenditure` | `Expenditure` | balance− / total_expenditure+(type=200 另加 spend+)|每次 1 条|
+| **A** | `sp_user_expense_recharge` | `Recharge(Kind=Default)` | balance+ / total_income+,**双表** |每次 1 条|
+| **A** | `sp_user_expense_school_recharge` | `Recharge(Kind=School)` | balance+ / total_income+,**双表** |每次 1 条|
+| **A** | `sp_user_expense_school_refund` | `Refund` | balance+ / total_expenditure−,**双表** |每次 1 条|
+| **B** | `sp_user_expense_baddebt` | `BadDebt` | **balance+ 且 special_expenses+**(双增),**双表** |每次 1 条(type=108)|
+| **B** | `sp_user_expense_school_spend` | `AccumulateSpend` | **只 spend+**,balance 不动,**双表** |**0 条**(SP11 全文没有 INSERT)|
+| **B** | `sp_user_expense_school_transfer_expenditure` | `Transfer` | 转出校区 exp−/income−,转入校区 exp+/income+,**balance 两侧都不动** |每次**2 条**(106 +m / 206 −m)|
 
 ## 0. 一个必须先纠正的事实:**没有"只写全局"的充值 SP**
 
@@ -37,8 +40,8 @@ UPDATE `t_user_expense`        SET `balance`=`balance`+inputMoney,`total_income`
 ```
 
 ```bash
-go run ./cmd/dualrun stage2              # 跑全部 3 个 case
-go run ./cmd/dualrun stage2 --case C1    # 只跑某个 case(名字子串匹配)
+go run ./cmd/dualrun stage2              # 跑全部 13 个 case(A1–A7 / B1–B4 / R1 / R2)
+go run ./cmd/dualrun stage2 --case B2    # 只跑某个 case(名字子串匹配)
 ```
 
 ---
@@ -92,6 +95,140 @@ out, err := svc.Expenditure(ctx, service.ExpenditureInput{...})
 C2 里两侧本来就**不应该**相等。写成"两侧相等"只能得到 FAIL,而它其实是设计内的已知差异。
 所以 `expectedDelta(typ, amount, runs)` 把每侧应有的行为显式写出来,再拿实测去对 —— 偏离任一侧的预期都会红。
 
+## 3.5 批 B:三种"看着该动 balance 但其实不动 / 动两次"的 SP
+
+批 B 最容易出的错不是"漏写一列",而是**凭列名推断行为**。dump 里这三支的真实行为:
+
+### 3.5.1 `baddebt`(SP3):**balance 与 special_expenses 双增**
+
+```sql
+UPDATE t_user_expense_school SET `balance`=`balance`+inputMoney, `special_expenses`=`special_expenses`+inputMoney WHERE ...;
+UPDATE t_user_expense        SET `balance`=`balance`+inputMoney, `special_expenses`=`special_expenses`+inputMoney WHERE ...;
+INSERT INTO t_expenditure_log (..., `type`, ...) VALUES (..., 108, ...);
+```
+
+坏账是"钱收不回来了但账上要留着" —— 所以**余额和坏账专户同时加**。
+直觉容易写成"只加 `special_expenses`",那样 `t_user_expense.balance` 就与旧库差一整个金额。
+
+断言形状(B1 实测):
+
+```
+✔ 坏账:balance 与 special_expenses **必须同时 +金额**(只加其中一个 → 红)
+   legacy 全局 Δbal=80.00 Δspec=80.00 | target 全局 Δbal=80.00 Δspec=80.00(期望各 +80.00)
+```
+
+> ⚠ 这条断言能成立的前提是 `sub()` 里**真的减了 `Special`**。
+> 一开始 `sub()` 漏了 `Special` 字段 → `Δspecial_expenses` 恒为 `0.00` → B1 假红 3 项。
+> "我没写这一列的比对" 和 "我断言这一列是 0" 是两件事,前者会让错误静默通过。
+
+> ⚠ 顺手记一个 dump 事实:`sp_user_expense_baddebt` 的 `COMMENT` 写的是 **'用户余额充值'** ——
+> 从 recharge 复制过来忘改了。**连 SP 自己的注释都不能当行为依据**:
+> 按 COMMENT 判断会把坏账当成充值;按 UPDATE 语句判断才知道它同时动 `balance` 和 `special_expenses`。
+> 这和"按表名推断 recharge 只写全局"是同一个错误的两种形态。
+
+### 3.5.2 `school_spend`(SP11):**只动 spend,不动 balance,且一条流水都不写**
+
+```sql
+UPDATE t_user_expense_school SET `spend`=`spend`+inputSpendMoney WHERE ...;
+UPDATE t_user_expense        SET `spend`=`spend`+inputSpendMoney WHERE ...;
+-- 全文没有 INSERT INTO t_expenditure_log
+```
+
+SP11 的签名只有 4 个参数(`inputUserid, inputSchoolid, inputCampusid, inputSpendMoney`)——
+**连请求号都没有**,所以它不可能写流水,也不可能幂等。这条"签名反推行为"比读 UPDATE 更快:
+没有 requestId 就没有落 `logid` 的素材。
+
+断言形状(B2 实测):
+
+```
+✔ 累计消费(spend):Δbalance 必须为 0(不是不比较,是断言它为 0)
+   全局 0.00/0.00;转出校区 0.00/0.00;转入校区 0.00/0.00(都应为 0.00)
+✔ 累计消费:**不写流水**(dump 的 SP11 全文没有 INSERT log)
+   legacy 0 条 / target 0 条(都应为 0)
+```
+
+> ⚠ SP11 的 `INSERT` 分支其实是**坏的**:
+> `INSERT INTO t_user_expense(userid,spend) VALUES(inputUserid, inputMoney, inputSpendMoney);`
+> —— **2 个列名配 3 个值**,`inputMoney` 这个变量 SP11 里根本不存在(又是从别的 SP 复制来的)。
+> 只在"用户还没有账户行"时才会走到,线上从不触发,所以我们**测不到也修不了**(迁移不改旧库)。
+> 记它的用处是:**不能靠"照抄 SP 逻辑"来定义 target 行为** ——
+> 照抄会把这段坏 SQL 一起搬过来。定义行为的依据只能是"实测的列增量 + case 断言"。
+
+### 3.5.3 `transfer_expenditure`(SP6):**钱没消失,只是换了校区**
+
+```
+转出校区:total_expenditure −m, total_income −m
+转入校区:total_expenditure +m, total_income +m
+全局    :一分不动(全局余额是两个校区之和的视图,转结是内部搬运)
+流水    :每次 2 条 —— type=106 (+m) 与 type=206 (−m),Σmoney == 0
+```
+
+断言形状(B3 实测):
+
+```
+✔ 课费转结(transfer):Δbalance 必须为 0(不是不比较,是断言它为 0)
+   全局 0.00/0.00;转出校区 0.00/0.00;转入校区 0.00/0.00(都应为 0.00)
+✔ 转结:每次**一对流水(106 +m / 206 −m)且 SUM(money)==0**(钱没消失,只是换了校区)
+   legacy 2 条 types=106,206 SUM=0.00(期望 2 条)| target 2 条 types=106,206 SUM=0.00(期望 2 条)
+✔ 转结:两校区 total_expenditure 变化量**互为反号**(转出 −m / 转入 +m)
+   legacy 转出=-50.00 转入=50.00 | target 转出=-50.00 转入=50.00
+```
+
+> ⚠ `SUM(money)==0` 和"两校区反号"是**两条独立**断言:前者防"206 写成了 +m"(钱凭空多出来),
+> 后者防"两个校区往同方向动了"(钱没多但也没搬)。变异 M2 专门打前者。
+
+### 3.5.4 批 B 的公共纪律:**"不动 balance" 必须断言为 0,不能只是不比较**
+
+`spend` 与 `transfer` 都有一个"我不关心"的列 —— `balance`。
+如果把这一列从比对表里**删掉**,实现里多扣一笔 balance 也不会有任何红:
+因为"没有比较"和"比较结果是 0"在报告里长一样。
+所以这里写的是显式断言(`Δbalance == 0`,六个数字逐分核),变异 M1 实测能抓住。
+
+## 3.6 A7:R19 `campusid=0` —— 两侧**本来就该不同**
+
+| 侧 | 行为 |
+|---|---|
+| legacy SP1 | `IF inputSchoolid < 1 OR inputCampusid < 1 THEN LEAVE ...` → **静默返回成功、一行不写** |
+| Go `Recharge` | `validateUserScope` 前置校验 → **3140 拒绝** |
+
+这不是待修的不一致,是**登记过的裁决 R19**(legacy 静默 no-op 是缺陷,Go 侧显式报错是设计内增强)。
+所以这个 case 不能走"两侧相等"的判定,走 `IntentionalStrict` 分支,把差异**钉死**成三条断言:
+
+```
+✔ intentional-strict:legacy 静默成功 + target 显式拒绝(R19)
+   legacy success=true(期望 true,SP 静默 no-op);target 被拒=true err="业务参数错误: service: Recharge 需要 campus_id > 0(dump:必须有校区信息)"(期望 被拒=true + 3140)
+✔ intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条)
+   legacy 写库=false(全局/校区)流水=0 | target 写库=false(全局/校区)流水=0 —— 都应为 未写库/0 条
+✔ 回滚后零残留(两侧都回到调用前状态)
+```
+
+第二条断言特意**同时比全局和校区**:`campusid=0` 的 case 若只比全局,
+一个"只写校区表"的错误实现会静默漏过(校区行本来就不存在,读出来是 0,比较也相等)。
+"不存在的校区"读成 0 这件事,必须靠"我们主动建了 0 行校区数据 + 显式断言没有写"来覆盖。
+
+同理,`seedTarget` 在 `campus_id == 0` 时**不建校区行、不建 `t_legacy_mapping` 的 campus 关联**——
+如果为了"让 case 能跑"而造一个 `campus_id=0` 的校区行,就绕过了要验证的守卫。
+
+## 3.7 全部 13 个 case 一览
+
+|case|Op|入参|期望|
+|---|---|---|---|
+|A1|`OpRecharge`|17735 / 63317 / 5,200.00,type=100|1 次|双表 balance+ / income+,1 条流水,备注"余额充值"|
+|A2|`OpRecharge`|同上,**2 次**|target 幂等只入账 1 次;legacy 重复入账 2 次(已知差异)|
+|A3|`OpSchoolRecharge`|40575 / 63317 / 4,150.00|1 次|双表;type 由 SP 固定 100|
+|A4|`OpSchoolRecharge`|17735 / 63317 / 5,**2 次**|同上,幂等|
+|A5|`OpRefund`|31477 / 63317 / 5,100.00,type=207|1 次|balance+ / total_expenditure−(方向与消费相反)|
+|A6|`OpRefund`|40575 / 63317 / 4,**2 次**|同上,幂等|
+|**A7**|`OpRecharge`|31477 / 63317 / **campus=0**|1 次|**intentional-strict**:legacy 静默成功、target 3140,两侧都没写库|
+|B1|`OpBadDebt`|17735 / 63317 / 5,80.00|1 次|balance+ **且** special_expenses+(双表),1 条流水 type=108|
+|B2|`OpSpend`|31477 / 63317 / 5,25.00|1 次|只 spend+(双表),balance 不动,**0 条流水**|
+|B3|`OpTransfer`|31477 / 63317 / 5 → **campus 1**,50.00|1 次|双流水 106/206 Σ=0,两校区反号,balance 不动|
+|B4|`OpTransfer`|17735 / 63317 / 5 → 1,**2 次**|106 是主流水,幂等按 `(request_id, 106)` 判定|
+|R1|`OpExpenditure`|17735 / 63317 / 5,30.00,type=201|1 次|第二批的消费 case,留作回归|
+|R2|`OpExpenditure`|同上,100.00,type=200,基线置 10.00|R15:余额不足两侧都成功且都扣成 −90.00|
+
+
+
 ## 4. 三处**已确认的行为差异**(不是缺陷,是设计内的 Go 增强)
 
 | 项 | legacy SP | Go `FundService.Expenditure` | 依据 |
@@ -100,7 +237,7 @@ C2 里两侧本来就**不应该**相等。写成"两侧相等"只能得到 FAIL
 |**`type` 越界**|`type<200 或 >299` **静默改成 200**(把"传错"记成"历史扣费")|白名单 101/104/105/200/201/204/205,非法 → **2002**|设计 §3.1 #3 |
 |**`balance_after`**|无此列|写"操作后**全局**账户余额"快照|设计 §10.2 |
 
-前两条在报告里都单独列了 diff/断言,C2 用实测把"legacy 重复入账 2 次 / target 1 次"钉死。
+前两条在报告里都单独列了 diff/断言,A2 用实测把"legacy 重复入账 2 次 / target 1 次"钉死。
 
 ## 5. 回滚与残留
 

+ 50 - 3
internal/academic/dualrun/stage2/case.go

@@ -34,6 +34,12 @@ const (
 	OpSchoolRecharge
 	// OpRefund 余额退费(SP4,7 参,**无 schoolid**)。
 	OpRefund
+	// OpBadDebt 坏账处理(SP7):balance+ 与 special_expenses+ **双加**。
+	OpBadDebt
+	// OpSpend 累计消费(SP11):**只动 spend,不动 balance,且不写流水**。
+	OpSpend
+	// OpTransfer 课费转结(SP6):**不动 balance**,双流水 106(+m)/206(−m),两个校区 total_* 各自反向变化。
+	OpTransfer
 )
 
 // String 返回可读名字(报告用)。
@@ -45,6 +51,12 @@ func (o Op) String() string {
 		return "sp_user_expense_school_recharge"
 	case OpRefund:
 		return "sp_user_expense_school_refund"
+	case OpBadDebt:
+		return "sp_user_expense_baddebt"
+	case OpSpend:
+		return "sp_user_expense_school_spend"
+	case OpTransfer:
+		return "sp_user_expense_school_transfer_expenditure"
 	default:
 		return "sp_user_expense_school_expenditure"
 	}
@@ -66,6 +78,15 @@ type Case struct {
 	RequestID     string // 幂等请求号(legacy 落在 logid,target 落在 request_id + log_id)
 	Remark        string // 空 → 两侧各自落默认备注(应都为"历史扣费")
 
+	// ToInstitutionID / ToCampusID 仅转结(OpTransfer)使用:转入侧的机构/校区。
+	ToInstitutionID int64
+	ToCampusID      int64
+
+	// IntentionalStrict true = 这个 case **两侧不应一致**,而是"legacy 静默 / Go 显式拒绝"。
+	// 用于 R19(campusid<1 → SP LEAVE 静默 no-op,Go 报 3140)。
+	// 这类 case 不参与"两侧相等"判定,只断言双方各自的既定行为都发生。
+	IntentionalStrict bool
+
 	// Baseline 非 nil 时,两侧在调用前**各自 UPDATE 成这份基线**(R15 用)。
 	// 语义:这是同一个基线,不是"各自造一个" —— 设置后仍会断言两侧逐分相等。
 	Baseline *Baseline
@@ -103,6 +124,7 @@ type Account struct {
 	Spend       int64
 	Income      int64
 	Expenditure int64
+	Special     int64 // special_expenses(只有坏账会动)
 }
 
 // LogRow 是一条流水的快照(两侧字段对齐后)。
@@ -121,9 +143,10 @@ type LogRow struct {
 
 // SideSnapshot 是一侧「调用前 / 调用后」的完整状态。
 type SideSnapshot struct {
-	Global Account
-	Campus Account
-	Logs   []LogRow // 只含本 case 产生的流水
+	Global  Account
+	Campus  Account // 转出校区(非转结场景 = 唯一那个校区)
+	Campus2 Account // 转入校区(只有 transfer 会用到;其余场景为零值)
+	Logs    []LogRow
 }
 
 // SideResult 是一侧的运行结果。
@@ -218,6 +241,30 @@ var (
 //	充值(SP1/SP2):balance + , total_income +
 //	退费(SP4)    :balance + , total_expenditure −      ← 与消费方向相反
 //	消费(SP3)    :balance − , total_expenditure +(type=200 另加 spend +)
+// expectedAccounts 返回一个 case 的**三份**期望增量:全局 / 转出校区 / 转入校区。
+//
+// 转结(OpTransfer)是唯一"三份不相同"的场景:全局不动,转出与转入各自反向。
+func expectedAccounts(op Op, typ int32, amount int64, runs int) (global, from, to Account) {
+	n := int64(runs)
+	switch op {
+	case OpTransfer:
+		// dump:只动两个校区的 total_expenditure / total_income,**不动 balance,也不动全局**
+		from = Account{Expenditure: -amount * n, Income: -amount * n}
+		to = Account{Expenditure: amount * n, Income: amount * n}
+		return Account{}, from, to
+	case OpBadDebt:
+		// dump:balance + 与 special_expenses + **双加**
+		g := Account{Balance: amount * n, Special: amount * n}
+		return g, g, Account{}
+	case OpSpend:
+		// dump:**只动 spend**,不动 balance,且不写流水
+		g := Account{Spend: amount * n}
+		return g, g, Account{}
+	}
+	g := expectedDeltaFor(op, typ, amount, runs)
+	return g, g, Account{}
+}
+
 func expectedDeltaFor(op Op, typ int32, amount int64, runs int) Account {
 	var exp Account
 	n := int64(runs)

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

@@ -39,6 +39,9 @@ const (
 	callRecharge       = "CALL sp_user_expense_recharge(?, ?, ?, ?, ?, ?, ?, ?)"
 	callSchoolRecharge = "CALL sp_user_expense_school_recharge(?, ?, ?, ?, ?, ?, ?)"
 	callRefund         = "CALL sp_user_expense_school_refund(?, ?, ?, ?, ?, ?, ?)"
+	callBadDebt        = "CALL sp_user_expense_baddebt(?, ?, ?, ?, ?, ?, ?)"
+	callSpend          = "CALL sp_user_expense_school_spend(?, ?, ?, ?)"
+	callTransfer       = "CALL sp_user_expense_school_transfer_expenditure(?, ?, ?, ?, ?, ?, ?, ?)"
 )
 
 // callArgs 按 Op 组出 CALL 的实参(顺序与 dump 一致)。
@@ -54,6 +57,16 @@ func callArgs(c Case) []any {
 	case OpRefund:
 		return []any{c.LegacyUserID, c.CampusID, moneyYuan,
 			c.Type, c.RequestID, c.Remark, c.LegacyUserID}
+	case OpBadDebt:
+		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan,
+			c.RequestID, c.Remark, c.LegacyUserID}
+	case OpSpend:
+		// SP11 只有 4 参:userid, schoolid, campusid, spendMoney
+		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan}
+	case OpTransfer:
+		// SP6:(userid, campusid1, schoolid2, campusid2, money, createUserid, requestid, remark)
+		return []any{c.LegacyUserID, c.CampusID, c.ToInstitutionID, c.ToCampusID,
+			moneyYuan, c.LegacyUserID, c.RequestID, c.Remark}
 	default:
 		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan,
 			c.Type, c.RequestID, c.Remark, c.LegacyUserID}
@@ -69,6 +82,12 @@ func callStmt(op Op) string {
 		return callSchoolRecharge
 	case OpRefund:
 		return callRefund
+	case OpBadDebt:
+		return callBadDebt
+	case OpSpend:
+		return callSpend
+	case OpTransfer:
+		return callTransfer
 	default:
 		return callExpenditure
 	}
@@ -149,15 +168,16 @@ func snapshotLegacy(ctx context.Context, tx *gorm.DB, c Case) (SideSnapshot, int
 	var out SideSnapshot
 
 	var g struct {
-		Balance, Spend, Income, Expenditure string
+		Balance, Spend, Income, Expenditure, Special string
 	}
 	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(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(&g.Balance, &g.Spend, &g.Income, &g.Expenditure)
+		Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special)
 	if err != nil {
 		return out, 0, fmt.Errorf("读 legacy 全局账户失败:%w", err)
 	}
@@ -168,27 +188,54 @@ SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2))
 	gsp, _ := centsOf(g.Spend)
 	gi, _ := centsOf(g.Income)
 	ge, _ := centsOf(g.Expenditure)
-	out.Global = Account{Balance: gs, Spend: gsp, Income: gi, Expenditure: ge}
+	gsp2, _ := centsOf(g.Special)
+	out.Global = Account{Balance: gs, Spend: gsp, Income: gi, Expenditure: ge, Special: gsp2}
 
 	var sc struct {
-		Balance, Spend, Income, Expenditure string
+		Balance, Spend, Income, Expenditure, Special string
 	}
 	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(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_school WHERE userid = ? AND schoolid = ? AND campusid = ?`,
 		c.LegacyUserID, c.InstitutionID, c.CampusID).
-		Row().Scan(&sc.Balance, &sc.Spend, &sc.Income, &sc.Expenditure)
-	if err != nil {
+		Row().Scan(&sc.Balance, &sc.Spend, &sc.Income, &sc.Expenditure, &sc.Special)
+	if err != nil && c.CampusID > 0 {
 		return out, 0, fmt.Errorf("读 legacy 校区账户失败:%w", err)
 	}
 	cs, _ := centsOf(sc.Balance)
 	csp, _ := centsOf(sc.Spend)
 	ci, _ := centsOf(sc.Income)
 	ce, _ := centsOf(sc.Expenditure)
-	out.Campus = Account{Balance: cs, Spend: csp, Income: ci, Expenditure: ce}
+	cspec, _ := centsOf(sc.Special)
+	out.Campus = Account{Balance: cs, Spend: csp, Income: ci, Expenditure: ce, Special: cspec}
+
+	// 转入校区(只有 transfer 用得到;不存在时为零值)
+	if c.ToCampusID > 0 {
+		var tc struct {
+			Balance, Spend, Income, Expenditure, Special string
+		}
+		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))
+  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}
+		}
+	}
 
 	rows, err := tx.Raw(`
 SELECT id, IFNULL(type,0), CAST(ROUND(CAST(IFNULL(money,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),

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

@@ -67,6 +67,54 @@ func DefaultCases(_ string) []Case {
 		},
 	)
 
+	// ---- 批 B ----
+	out = append(out,
+		Case{
+			Name: "B1 坏账 sp_user_expense_baddebt(80.00)",
+			Op:   OpBadDebt, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 8000, RequestID: "ac15-b1", RunTimes: 1,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "**balance 与 special_expenses 双增**(不是只加特殊费用);流水 type=108;默认备注\"坏账处理\"",
+		},
+		Case{
+			Name: "B2 累计消费 sp_user_expense_school_spend(25.00)",
+			Op:   OpSpend, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 2500, RequestID: "ac15-b2", RunTimes: 1,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "**只动 spend,不动 balance,且 dump 不写流水**(SP11 签名里连请求号都没有)",
+		},
+		Case{
+			Name: "B3 课费转结 sp_user_expense_school_transfer_expenditure(50.00,campus 5→1)",
+			Op:   OpTransfer, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 5,
+			ToInstitutionID: 63317, ToCampusID: 1,
+			AmountCents: 5000, RequestID: "ac15-b3", RunTimes: 1,
+			Remark: "ac15 课费转结",
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "**不动 balance**;双流水 106(+50)/206(−50) Σ=0;两校区 total_expenditure/total_income 各自反向。" +
+				"SP6 **没有默认备注**(inputRemark 直接用),所以这里显式传一条,顺带验证备注透传",
+		},
+		Case{
+			Name: "B4 课费转结·幂等(同一 requestId 跑 2 次)",
+			Op:   OpTransfer, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
+			ToInstitutionID: 63317, ToCampusID: 1,
+			AmountCents: 5000, RequestID: "ac15-b4", RunTimes: 2,
+			Remark: "ac15 课费转结·幂等",
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "106 是主流水,幂等按 (request_id, type=106) 判定",
+		},
+	)
+
+	// ---- A7:R19(campusid=0)intentional-strict ----
+	out = append(out, Case{
+		Name: "A7 R19·campusid=0 调充值(legacy 静默 no-op vs Go 3140)",
+		Op:   OpRecharge, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 0,
+		AmountCents: 10000, Type: 100, RequestID: "ac15-a7", RunTimes: 1,
+		ExpectTargetSuccess: false, ExpectLegacySuccess: true,
+		IntentionalStrict: true,
+		Note: "R19:SP1 有 `schoolid<1||campusid<1 → LEAVE` 守卫,**静默不写、返回成功**;" +
+			"Go 走 validateUserScope 报 3140。两侧**本来就该不同**,本 case 只把差异钉死",
+	})
+
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
@@ -167,6 +215,37 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
 	}
 
+	// ⓪ intentional-strict(R19):两侧**本来就该不同**,不参与"两侧相等"判定
+	if c.IntentionalStrict {
+		lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
+		tgErr := errOf(tg.Calls)
+		tgRejected := len(tg.Calls) > 0 && !tg.Calls[0].Success
+		// 「没写库」必须两侧都比**全局+校区**两块:campusid=0 的用例里
+		// 如果只比全局,误写成"只写校区表"的实现会静默漏过去。
+		lgWrote := lg.Before.Global != lg.After.Global || lg.Before.Campus != lg.After.Campus
+		tgWrote := tg.Before.Global != tg.After.Global || tg.Before.Campus != tg.After.Campus
+		noWrite := !lgWrote && !tgWrote && len(lg.After.Logs) == 0 && len(tg.After.Logs) == 0
+		add("legacy 是否「成功返回」", fmt.Sprint(lgOK), "N/A", lgOK,
+			"R19:SP 静默 LEAVE,调用方看到的是成功")
+		add("target 是否显式拒绝", "N/A", fmt.Sprint(tgRejected), tgRejected,
+			"Go 应报 3140(unscoped),错误文本:"+tgErr)
+		asserts = append(asserts,
+			Assertion{
+				Name: "intentional-strict:legacy 静默成功 + target 显式拒绝(R19)",
+				Pass: lgOK && tgRejected,
+				Detail: fmt.Sprintf("legacy success=%v(期望 true,SP 静默 no-op);target 被拒=%v err=%q(期望 被拒=true + 3140)",
+					lgOK, tgRejected, tgErr),
+			},
+			Assertion{
+				Name: "intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条)",
+				Pass: noWrite,
+				Detail: fmt.Sprintf("legacy 写库=%v(全局/校区)流水=%d | target 写库=%v(全局/校区)流水=%d —— 都应为 未写库/0 条",
+					lgWrote, len(lg.After.Logs), tgWrote, len(tg.After.Logs)),
+			},
+		)
+		return diffs, asserts
+	}
+
 	// ① 基线逐分相等(case 有效性前提)
 	baseEqual := lg.Before.Global == tg.Before.Global && lg.Before.Campus == tg.Before.Campus
 	asserts = append(asserts, Assertion{
@@ -177,49 +256,71 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			fmtAcc(tg.Before.Global), fmtAcc(tg.Before.Campus)),
 	})
 
-	// ② Δ 逐分相等(核心):拿每侧实测 Δ 去对**该侧应有的期望值**
-	//
-	// runsLegacy:legacy SP 每次都真入账(无幂等键)→ 调用几次就入账几次
-	// runsTarget:Go 侧有 uk_request_type 幂等 → 无论调用几次只入账一次
+	// ② Δ 逐分相等(核心):三块账户(全局 / 转出校区 / 转入校区)各对各自的期望
 	runsLegacy := maxInt(1, c.RunTimes)
 	runsTarget := 1
 	noteRuns := ""
 	if c.RunTimes >= 2 {
 		noteRuns = fmt.Sprintf("预期差异:legacy 入账 %d 次(SP 无幂等)/ target 入账 1 次(幂等命中)", runsLegacy)
 	}
-	wantL := expectedDeltaFor(c.Op, c.Type, c.AmountCents, runsLegacy)
-	wantT := expectedDeltaFor(c.Op, c.Type, c.AmountCents, runsTarget)
+	wgL, wfL, wtL := expectedAccounts(c.Op, c.Type, c.AmountCents, runsLegacy)
+	wgT, wfT, wtT := expectedAccounts(c.Op, c.Type, c.AmountCents, runsTarget)
 
 	dGlobal := sub(lg.After.Global, lg.Before.Global)
 	dCampus := sub(lg.After.Campus, lg.Before.Campus)
+	dCampus2 := sub(lg.After.Campus2, lg.Before.Campus2)
 	tGlobal := sub(tg.After.Global, tg.Before.Global)
 	tCampus := sub(tg.After.Campus, tg.Before.Campus)
-
-	add("Δbalance(全局)", yuan(dGlobal.Balance), yuan(tGlobal.Balance),
-		dGlobal.Balance == wantL.Balance && tGlobal.Balance == wantT.Balance,
-		"期望 "+yuan(wantL.Balance)+" / "+yuan(wantT.Balance)+";容差 0 "+noteRuns)
-	add("Δbalance(校区)", yuan(dCampus.Balance), yuan(tCampus.Balance),
-		dCampus.Balance == wantL.Balance && tCampus.Balance == wantT.Balance, "同上")
-	add("Δspend(全局)", yuan(dGlobal.Spend), yuan(tGlobal.Spend),
-		dGlobal.Spend == wantL.Spend && tGlobal.Spend == wantT.Spend,
-		"只有 type=200 的消费才动 spend;期望 "+yuan(wantL.Spend)+" / "+yuan(wantT.Spend))
-	add("Δspend(校区)", yuan(dCampus.Spend), yuan(tCampus.Spend),
-		dCampus.Spend == wantL.Spend && tCampus.Spend == wantT.Spend, "同上")
-	add("Δtotal_income(全局)", yuan(dGlobal.Income), yuan(tGlobal.Income),
-		dGlobal.Income == wantL.Income && tGlobal.Income == wantT.Income,
-		"充值才动 total_income;期望 "+yuan(wantL.Income)+" / "+yuan(wantT.Income))
-	add("Δtotal_income(校区)", yuan(dCampus.Income), yuan(tCampus.Income),
-		dCampus.Income == wantL.Income && tCampus.Income == wantT.Income, "同上")
-	add("Δtotal_expenditure(全局)", yuan(dGlobal.Expenditure), yuan(tGlobal.Expenditure),
-		dGlobal.Expenditure == wantL.Expenditure && tGlobal.Expenditure == wantT.Expenditure,
-		"期望 "+yuan(wantL.Expenditure)+" / "+yuan(wantT.Expenditure))
-	add("Δtotal_expenditure(校区)", yuan(dCampus.Expenditure), yuan(tCampus.Expenditure),
-		dCampus.Expenditure == wantL.Expenditure && tCampus.Expenditure == wantT.Expenditure, "同上")
+	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))
+	}
+	addDelta("全局", dGlobal, tGlobal, wgL, wgT)
+	addDelta("转出校区", dCampus, tCampus, wfL, wfT)
+	if c.Op == OpTransfer {
+		addDelta("转入校区", dCampus2, tCampus2, wtL, wtT)
+	}
+
+	// ②.5 「不动的列」必须**显式断言为 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
+		if c.Op == OpTransfer {
+			ok = ok && dCampus.Balance == 0 && tCampus.Balance == 0 &&
+				dCampus2.Balance == 0 && tCampus2.Balance == 0
+		} else {
+			ok = ok && dCampus.Balance == 0 && tCampus.Balance == 0
+		}
+		asserts = append(asserts, Assertion{
+			Name: names + ":Δbalance 必须为 0(不是不比较,是断言它为 0)",
+			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)),
+		})
+	}
 
 	// ③ 流水:条数 / type / money / logId
+	// 期望流水条数按 Op 决定:spend 不写流水(0)、transfer 每次 2 条、其余每次 1 条
+	wantLogsL := expectedLogCount(c.Op, runsLegacy)
+	wantLogsT := expectedLogCount(c.Op, runsTarget)
 	add("流水条数", fmt.Sprint(len(lg.After.Logs)), fmt.Sprint(len(tg.After.Logs)),
-		len(lg.After.Logs) == runsLegacy && len(tg.After.Logs) == runsTarget,
-		fmt.Sprintf("期望 legacy=%d / target=%d", runsLegacy, runsTarget))
+		len(lg.After.Logs) == wantLogsL && len(tg.After.Logs) == wantLogsT,
+		fmt.Sprintf("期望 legacy=%d / target=%d", wantLogsL, wantLogsT))
 
 	n := len(lg.After.Logs)
 	if len(tg.After.Logs) < n {
@@ -282,7 +383,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		}
 	}
 	add("流水 remark(默认备注)", firstOr(detailJoin(remarkDetail), "逐字一致"), fmt.Sprint(n)+" 条",
-		remarkOK, "SP1\"余额充值\"/SP2\"充值缴费\"/SP4\"报课的费用退回\"")
+		remarkOK, remarkNote(c.Op))
 
 	// ⑤ 返回码 / 成功与否
 	lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
@@ -318,14 +419,59 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	// **都** UPDATE 了 t_user_expense_school 和 t_user_expense(两段 UPDATE 都在)。
 	// 所以真正要防的错是"把充值写成只写全局"——那样校区表 Δ=0,这条会立刻红。
 	if c.Op == OpRecharge || c.Op == OpSchoolRecharge {
-		okL := dCampus.Balance == wantL.Balance && dCampus.Income == wantL.Income
-		okT := tCampus.Balance == wantT.Balance && tCampus.Income == wantT.Income
+		okL := dCampus.Balance == wfL.Balance && dCampus.Income == wfL.Income
+		okT := tCampus.Balance == wfT.Balance && tCampus.Income == wfT.Income
 		asserts = append(asserts, Assertion{
 			Name: "双表写入:校区表 Δbalance / Δtotal_income 必须等于充值额(若被写成\"只写全局\"会红)",
 			Pass: okL && okT,
 			Detail: fmt.Sprintf("校区 Δbalance legacy=%s target=%s(期望 %s / %s);Δincome legacy=%s target=%s(期望 %s / %s)",
-				yuan(dCampus.Balance), yuan(tCampus.Balance), yuan(wantL.Balance), yuan(wantT.Balance),
-				yuan(dCampus.Income), yuan(tCampus.Income), yuan(wantL.Income), yuan(wantT.Income)),
+				yuan(dCampus.Balance), yuan(tCampus.Balance), yuan(wfL.Balance), yuan(wfT.Balance),
+				yuan(dCampus.Income), yuan(tCampus.Income), yuan(wfL.Income), yuan(wfT.Income)),
+		})
+	}
+
+	// ⑥.6 批 B 的 op 专属断言
+	switch c.Op {
+	case OpSpend:
+		asserts = append(asserts, Assertion{
+			Name:   "累计消费:**不写流水**(dump 的 SP11 全文没有 INSERT log)",
+			Pass:   len(lg.After.Logs) == 0 && len(tg.After.Logs) == 0,
+			Detail: fmt.Sprintf("legacy %d 条 / target %d 条(都应为 0)", len(lg.After.Logs), len(tg.After.Logs)),
+		})
+	case OpTransfer:
+		sumL, sumT := sumMoney(lg.After.Logs), sumMoney(tg.After.Logs)
+		typesL, typesT := logTypes(lg.After.Logs), logTypes(tg.After.Logs)
+		okL := transferLogsOK(lg.After.Logs, runsLegacy)
+		okT := transferLogsOK(tg.After.Logs, runsTarget)
+		asserts = append(asserts, Assertion{
+			Name: "转结:每次**一对流水(106 +m / 206 −m)且 SUM(money)==0**(钱没消失,只是换了校区)",
+			Pass: okL && okT && sumL == 0 && sumT == 0,
+			Detail: fmt.Sprintf("legacy %d 条 types=%s SUM=%s(期望 %d 条)| target %d 条 types=%s SUM=%s(期望 %d 条)",
+				len(lg.After.Logs), typesL, yuan(sumL), 2*runsLegacy,
+				len(tg.After.Logs), typesT, yuan(sumT), 2*runsTarget),
+		})
+		// 转结的核心语义:两个校区**各自**变化,且互为反号
+		ok := dCampus.Expenditure == -dCampus2.Expenditure && tCampus.Expenditure == -tCampus2.Expenditure &&
+			dCampus.Expenditure != 0
+		asserts = append(asserts, Assertion{
+			Name: "转结:两校区 total_expenditure 变化量**互为反号**(转出 −m / 转入 +m)",
+			Pass: ok,
+			Detail: fmt.Sprintf("legacy 转出=%s 转入=%s | target 转出=%s 转入=%s",
+				yuan(dCampus.Expenditure), yuan(dCampus2.Expenditure),
+				yuan(tCampus.Expenditure), yuan(tCampus2.Expenditure)),
+		})
+	case OpBadDebt:
+		// 双增:balance 与 special_expenses 都必须 +amount(不能只加其中一个)
+		ok := dGlobal.Balance == c.AmountCents && dGlobal.Special == c.AmountCents &&
+			tGlobal.Balance == c.AmountCents && tGlobal.Special == c.AmountCents &&
+			dCampus.Balance == c.AmountCents && dCampus.Special == c.AmountCents &&
+			tCampus.Balance == c.AmountCents && tCampus.Special == c.AmountCents
+		asserts = append(asserts, Assertion{
+			Name: "坏账:balance 与 special_expenses **必须同时 +金额**(只加其中一个 → 红)",
+			Pass: ok,
+			Detail: fmt.Sprintf("legacy 全局 Δbal=%s Δspec=%s | target 全局 Δbal=%s Δspec=%s(期望各 +%s)",
+				yuan(dGlobal.Balance), yuan(dGlobal.Special),
+				yuan(tGlobal.Balance), yuan(tGlobal.Special), yuan(c.AmountCents)),
 		})
 	}
 
@@ -421,6 +567,7 @@ func sub(a, b Account) Account {
 		Spend:       a.Spend - b.Spend,
 		Income:      a.Income - b.Income,
 		Expenditure: a.Expenditure - b.Expenditure,
+		Special:     a.Special - b.Special, // 漏掉这一行会让 Δspecial 恒为 0(坏账 case 假红)
 	}
 }
 
@@ -438,6 +585,83 @@ func abs64(v int64) int64 {
 
 func detailJoin(ss []string) string { return strings.Join(ss, ";") }
 
+// expectedLogCount 按 Op 给出**期望流水条数**。
+//
+//	dump 事实:SP11(spend)**不写流水**;SP6(transfer)每次写 2 条(106/206);其余每次 1 条。
+func expectedLogCount(op Op, runs int) int {
+	switch op {
+	case OpSpend:
+		return 0
+	case OpTransfer:
+		return 2 * runs
+	default:
+		return runs
+	}
+}
+
+// sumMoney 求一批流水的 money 之和(分)。
+func sumMoney(logs []LogRow) int64 {
+	var sum int64
+	for _, l := range logs {
+		sum += l.Money
+	}
+	return sum
+}
+
+// transferLogsOK 校验转结流水:每对必须是 (106, +m) 然后 (206, −m),共 pairs 对。
+func transferLogsOK(logs []LogRow, pairs int) bool {
+	if len(logs) != 2*pairs {
+		return false
+	}
+	for i := 0; i+1 < len(logs); i += 2 {
+		a, b := logs[i], logs[i+1]
+		if a.Type != 106 || b.Type != 206 {
+			return false
+		}
+		if a.Money <= 0 || b.Money != -a.Money {
+			return false
+		}
+	}
+	return true
+}
+
+// logTypes 把流水 type 按出现顺序拼成 "106,206" 形式。
+func logTypes(logs []LogRow) string {
+	parts := make([]string, len(logs))
+	for i, l := range logs {
+		parts[i] = fmt.Sprint(l.Type)
+	}
+	return strings.Join(parts, ",")
+}
+
+// remarkNote 说明该 Op 下两侧默认备注的出处(照 dump 抄的固定文案)。
+//
+// 默认备注是同一批 SP 之间少数"只能逐字比对、无法从列名推断"的东西:
+// 名字里都没有"备注"二字,值却各不相同,写错不会让任何金额断言变红。
+func remarkNote(op Op) string {
+	switch op {
+	case OpRecharge:
+		return "SP1 默认备注「余额充值」"
+	case OpSchoolRecharge:
+		return "SP2 默认备注「充值缴费」"
+	case OpRefund:
+		return "SP4 默认备注「报课的费用退回」"
+	case OpBadDebt:
+		return "SP3 默认备注「坏账处理」"
+	case OpTransfer:
+		return "SP6 **没有默认备注**(inputRemark 原样落库),case 里显式传入以验证透传"
+	case OpSpend:
+		return "SP11 不写流水,无备注可比(0 条)"
+	default:
+		return "消费 SP 的默认备注(照 dump 逐字)"
+	}
+}
+
+// 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})
+}
+
 func firstOr(s, def string) string {
 	if s == "" {
 		return def

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

@@ -105,3 +105,143 @@ func TestCallArgsOrder(t *testing.T) {
 		t.Errorf("SP3 应有 8 个入参,实际 %d", len(args4))
 	}
 }
+
+// TestExpectedAccountsBatchB 锁住批 B 三支 SP 的**账户级**增量。
+//
+// 返回三块:(全局, 转出校区, 转入校区)。批 B 的重点是"哪一块**不动**":
+// spend 与 transfer 的 balance 三块都必须恰好是 0,baddebt 的 balance
+// 和 special_expenses **两块都要加**。
+func TestExpectedAccountsBatchB(t *testing.T) {
+	const m = int64(5000) // 50.00
+
+	// spend:只有 spend +m,balance 三块全 0
+	g, f, to := expectedAccounts(OpSpend, 0, m, 1)
+	if g != (Account{Spend: m}) {
+		t.Errorf("spend 全局应为 {Spend:%d},得到 %+v", m, g)
+	}
+	if f.Balance != 0 || f.Spend != m {
+		t.Errorf("spend 转出校区应为 {Spend:%d},得到 %+v", m, f)
+	}
+	if to != (Account{}) {
+		t.Errorf("spend 不该有转入校区增量,得到 %+v", to)
+	}
+
+	// transfer:balance 一律 0;转出校区 exp/income 各 −m,转入校区各 +m
+	g, f, to = expectedAccounts(OpTransfer, 0, m, 1)
+	if g != (Account{}) {
+		t.Errorf("transfer **全局账户一分不动**,得到 %+v", g)
+	}
+	if f != (Account{Expenditure: -m, Income: -m}) {
+		t.Errorf("transfer 转出校区应为 {exp:-%d, income:-%d},得到 %+v", m, m, f)
+	}
+	if to != (Account{Expenditure: m, Income: m}) {
+		t.Errorf("transfer 转入校区应为 {exp:+%d, income:+%d},得到 %+v", m, m, to)
+	}
+	// 转结"钱没消失":两个校区 expenditure 之和必须是 0
+	if f.Expenditure+to.Expenditure != 0 || g.Expenditure != 0 {
+		t.Errorf("transfer 不该改变总账:全局 %d,转出+转入 %d", g.Expenditure, f.Expenditure+to.Expenditure)
+	}
+
+	// baddebt:balance 与 special_expenses **双增**
+	g, f, to = expectedAccounts(OpBadDebt, 0, m, 1)
+	if g != (Account{Balance: m, Special: m}) {
+		t.Errorf("baddebt 全局应为 {bal:+%d, special:+%d},得到 %+v", m, m, g)
+	}
+	if f != (Account{Balance: m, Special: m}) {
+		t.Errorf("baddebt 校区应为 {bal:+%d, special:+%d},得到 %+v", m, m, f)
+	}
+	if to != (Account{}) {
+		t.Errorf("baddebt 不该有转入校区增量,得到 %+v", to)
+	}
+	if g.Special == 0 {
+		// 单独立一条:`Special` 从 sub() 里漏掉时 Δspecial 恒为 0,这条会立刻红
+		t.Fatal("baddebt 必须动 special_expenses;若为 0 说明 sub()/期望里漏了这一列")
+	}
+
+	// 幂等:target 只入账 1 次(runs 传 1),legacy 入账 2 次 —— 两侧期望**本来就不同**
+	_, f1, _ := expectedAccounts(OpBadDebt, 0, m, 1)
+	_, f2, _ := expectedAccounts(OpBadDebt, 0, m, 2)
+	if f2.Balance != 2*f1.Balance {
+		t.Errorf("legacy 无幂等应重复入账:runs=2 期望 %d,得到 %d", 2*f1.Balance, f2.Balance)
+	}
+}
+
+// TestExpectedLogCount 锁住"每次写几条流水"。
+//
+// 批 B 的坑:一开始一律按 runs 算,B2(spend,0 条)和 B3(transfer,2 条)都假红。
+func TestExpectedLogCount(t *testing.T) {
+	cases := []struct {
+		op    Op
+		runs  int
+		want  int
+		label string
+	}{
+		{OpSpend, 1, 0, "SP11 全文没有 INSERT log"},
+		{OpSpend, 2, 0, "SP11 跑两次也是 0 条"},
+		{OpTransfer, 1, 2, "106 + 206"},
+		{OpTransfer, 2, 4, "幂等失效时是 4 条(legacy 侧)"},
+		{OpExpenditure, 1, 1, "消费写 1 条"},
+		{OpRecharge, 2, 2, "充值无幂等 → 2 条"},
+		{OpBadDebt, 1, 1, "坏账写 1 条(type=108)"},
+		{OpRefund, 1, 1, "退费写 1 条"},
+	}
+	for _, c := range cases {
+		if got := expectedLogCount(c.op, c.runs); got != c.want {
+			t.Errorf("%v×%d(%s):expectedLogCount = %d,want %d", c.op, c.runs, c.label, got, c.want)
+		}
+	}
+}
+
+// TestTransferLogsOK 锁住 transfer 的流水形状:每对 (106, +m) 然后 (206, −m)。
+//
+// 变异 M2 的靶子:把 206 写成 +m(同向)——SUM(money) 变 2m 而不是 0。
+func TestTransferLogsOK(t *testing.T) {
+	const m = int64(5000)
+	good := []LogRow{{Type: 106, Money: m}, {Type: 206, Money: -m}}
+	if !transferLogsOK(good, 1) {
+		t.Error("正常一对流水应通过")
+	}
+	if !transferLogsOK(append(append([]LogRow{}, good...), LogRow{Type: 106, Money: m}, LogRow{Type: 206, Money: -m}), 2) {
+		t.Error("两对流水应通过")
+	}
+
+	bothPositive := []LogRow{{Type: 106, Money: m}, {Type: 206, Money: m}}
+	if transferLogsOK(bothPositive, 1) {
+		t.Error("206 写成 +m(同向)必须被判红")
+	}
+	if sumMoney(bothPositive) == 0 {
+		t.Error("206 写成 +m 时 SUM(money) 不应为 0 —— 否则 Σ=0 这条断言是假的")
+	}
+
+	wrongType := []LogRow{{Type: 106, Money: m}, {Type: 106, Money: -m}}
+	if transferLogsOK(wrongType, 1) {
+		t.Error("对子里第二条 type 必须是 206")
+	}
+	if transferLogsOK([]LogRow{{Type: 106, Money: m}}, 1) {
+		t.Error("只有一条(缺 206)必须红")
+	}
+	if transferLogsOK([]LogRow{{Type: 106, Money: -m}, {Type: 206, Money: m}}, 1) {
+		t.Error("106 必须是 +m(钱转出为正),方向反了要红")
+	}
+	if transferLogsOK(nil, 1) {
+		t.Error("空流水在期望 2 条时必须红")
+	}
+}
+
+// TestRemarkNote 默认备注必须按 Op 给出正确出处 —— 备注只有逐字比对才能发现写错。
+func TestRemarkNote(t *testing.T) {
+	want := map[Op]string{
+		OpRecharge:       "SP1 默认备注「余额充值」",
+		OpSchoolRecharge: "SP2 默认备注「充值缴费」",
+		OpRefund:         "SP4 默认备注「报课的费用退回」",
+		OpBadDebt:        "SP3 默认备注「坏账处理」",
+	}
+	for op, w := range want {
+		if got := remarkNote(op); got != w {
+			t.Errorf("%v 的备注说明 = %q,want %q", op, got, w)
+		}
+	}
+	if remarkNote(OpSpend) == "" || remarkNote(OpTransfer) == "" {
+		t.Error("每个 Op 都必须有备注说明,不能留空")
+	}
+}

+ 102 - 18
internal/academic/dualrun/stage2/target.go

@@ -108,6 +108,43 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 				oc.Success, oc.Repeated, oc.Balance, oc.LogID =
 					true, out.Repeated, int64(out.Balance), out.LogID
 			}
+		case OpBadDebt:
+			out, err := svc.BadDebt(ctx, service.BadDebtInput{
+				UserID: canonicalUID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,
+				Money: money.Amount(c.AmountCents), RequestID: c.RequestID,
+				Remark: c.Remark, OperatorID: canonicalUID,
+			})
+			if err != nil {
+				oc.ErrMsg = err.Error()
+			} else {
+				oc.Success, oc.Repeated, oc.LogID = true, out.Repeated, out.LogID
+				oc.Balance = int64(out.Balance)
+			}
+		case OpSpend:
+			// SP11:只动 spend、不写流水、无幂等键
+			out, err := svc.AccumulateSpend(ctx, service.SpendInput{
+				UserID: canonicalUID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,
+				SpendMoney: money.Amount(c.AmountCents),
+			})
+			if err != nil {
+				oc.ErrMsg = err.Error()
+			} else {
+				oc.Success = true
+				oc.Balance = int64(out.Spend)
+			}
+		case OpTransfer:
+			out, err := svc.Transfer(ctx, service.TransferInput{
+				UserID:          canonicalUID,
+				FromInstitutionID: c.InstitutionID, FromCampusID: c.CampusID,
+				ToInstitutionID: c.ToInstitutionID, ToCampusID: c.ToCampusID,
+				Money:     money.Amount(c.AmountCents),
+				RequestID: c.RequestID, Remark: c.Remark, OperatorID: canonicalUID,
+			})
+			if err != nil {
+				oc.ErrMsg = err.Error()
+			} else {
+				oc.Success, oc.Repeated, oc.LogID = true, out.Repeated, out.LogID106
+			}
 		default:
 			out, err := svc.Expenditure(ctx, service.ExpenditureInput{
 				UserID: canonicalUID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,
@@ -145,12 +182,15 @@ ON DUPLICATE KEY UPDATE institution_name = VALUES(institution_name)`,
 		c.InstitutionID, c.InstitutionID).Error; err != nil {
 		return fmt.Errorf("种子 t_institution 失败:%w", err)
 	}
-	if err := tx.Exec(`
+	// campus_id=0 是"机构级/未分配"的约定值,R19 用例专门用它验证 SP 的 LEAVE 守卫 → 不建校区行
+	if c.CampusID > 0 {
+		if err := tx.Exec(`
 INSERT INTO t_campus (campus_id, institution_id, campus_name, status, self_support, created_at, updated_at)
 VALUES (?, ?, CONCAT('ac15-case-campus-', ?), 'active', 1, NOW(3), NOW(3))
 ON DUPLICATE KEY UPDATE campus_name = VALUES(campus_name)`,
-		c.CampusID, c.InstitutionID, c.CampusID).Error; err != nil {
-		return fmt.Errorf("种子 t_campus 失败:%w", err)
+			c.CampusID, c.InstitutionID, c.CampusID).Error; err != nil {
+			return fmt.Errorf("种子 t_campus 失败:%w", err)
+		}
 	}
 
 	// ② 人 + 映射 + 成员关系(assertInScope 必须有这行)
@@ -195,8 +235,9 @@ ON DUPLICATE KEY UPDATE balance = VALUES(balance), spend = VALUES(spend),
 		return fmt.Errorf("种子 t_user_expense 失败:%w", err)
 	}
 
-	// ④ 校区账户
-	if err := tx.Exec(`
+	// ④ 校区账户(campus_id=0 的 R19 用例不建)
+	if c.CampusID > 0 {
+		if err := tx.Exec(`
 INSERT INTO t_user_expense_school
   (user_id, institution_id, campus_id, balance, spend, total_income, total_expenditure,
    total_recommended_amount, special_expenses)
@@ -212,9 +253,27 @@ SELECT ?, ?, ?,
 ON DUPLICATE KEY UPDATE balance = VALUES(balance), spend = VALUES(spend),
   total_income = VALUES(total_income), total_expenditure = VALUES(total_expenditure),
   total_recommended_amount = VALUES(total_recommended_amount), special_expenses = VALUES(special_expenses)`,
-		canonicalUID, c.InstitutionID, c.CampusID,
-		c.LegacyUserID, c.InstitutionID, c.CampusID).Error; err != nil {
-		return fmt.Errorf("种子 t_user_expense_school 失败:%w", err)
+			canonicalUID, c.InstitutionID, c.CampusID,
+			c.LegacyUserID, c.InstitutionID, c.CampusID).Error; err != nil {
+			return fmt.Errorf("种子 t_user_expense_school 失败:%w", err)
+		}
+	}
+	// ⑤ 转入校区账户(仅 transfer):该校区若在 finance_bk 里有账户就照搬,没有就建空户
+	if c.ToCampusID > 0 {
+		if err := tx.Exec(`
+INSERT INTO t_user_expense_school
+  (user_id, institution_id, campus_id, balance, spend, total_income, total_expenditure)
+SELECT ?, ?, ?,
+       CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
+  FROM finance_bk.t_user_expense_school WHERE userid = ? AND campusid = ?
+ON DUPLICATE KEY UPDATE balance = VALUES(balance), spend = VALUES(spend),
+  total_income = VALUES(total_income), total_expenditure = VALUES(total_expenditure)`,
+			canonicalUID, c.ToInstitutionID, c.ToCampusID, c.LegacyUserID, c.ToCampusID).Error; err != nil {
+			return fmt.Errorf("种子转入校区账户失败:%w", err)
+		}
 	}
 	return nil
 }
@@ -242,34 +301,59 @@ 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 string }
+	var g struct{ Balance, Spend, Income, Expenditure, Special string }
 	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(total_income AS DECIMAL(12,2)), CAST(total_expenditure AS DECIMAL(12,2)),
+       CAST(special_expenses AS DECIMAL(12,2))
   FROM t_user_expense WHERE user_id = ?`, canonicalUID).
-		Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure); err != nil {
+		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)
-	out.Global = Account{Balance: gs, Spend: gsp, Income: gi, Expenditure: ge}
+	gpc, _ := centsOf(g.Special)
+	out.Global = Account{Balance: gs, Spend: gsp, Income: gi, Expenditure: ge, Special: gpc}
 
-	var sc struct{ Balance, Spend, Income, Expenditure string }
-	if err := tx.Raw(`
+	var sc struct{ Balance, Spend, Income, Expenditure, Special string }
+	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(total_income AS DECIMAL(12,2)), CAST(total_expenditure AS DECIMAL(12,2)),
+       CAST(special_expenses 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); err != nil {
-		return out, 0, fmt.Errorf("读 target 校区账户失败:%w", err)
+		Row().Scan(&sc.Balance, &sc.Spend, &sc.Income, &sc.Expenditure, &sc.Special)
+	if qerrCampus != nil && c.CampusID > 0 {
+		return out, 0, fmt.Errorf("读 target 校区账户失败:%w", qerrCampus)
 	}
 	cs, _ := centsOf(sc.Balance)
 	csp, _ := centsOf(sc.Spend)
 	ci, _ := centsOf(sc.Income)
 	ce, _ := centsOf(sc.Expenditure)
-	out.Campus = Account{Balance: cs, Spend: csp, Income: ci, Expenditure: ce}
+	cpc, _ := centsOf(sc.Special)
+	out.Campus = Account{Balance: cs, Spend: csp, Income: ci, Expenditure: ce, Special: cpc}
+
+	// 转入校区(只有 transfer 用得到)
+	if c.ToCampusID > 0 {
+		var tc struct{ Balance, Spend, Income, Expenditure, Special string }
+		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))
+  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)
+		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}
+		}
+	}
 
 	rows, err := tx.Raw(`
 SELECT id, type, CAST(money AS DECIMAL(12,2)), IFNULL(log_id,''), IFNULL(request_id,''),