Эх сурвалжийг харах

feat(ac15): 批A 三个简单写 SP 双跑(充值×2 + 退费),并纠正"recharge 只写全局"的错误前提

范围:sp_user_expense_recharge / sp_user_expense_school_recharge / sp_user_expense_school_refund。
复用 stage2 骨架(双事务 + 期望值比对 + 残留断言),8 个 case 全 PASS。

一、纠正派单前提:**没有"只写全局"的充值 SP**
- dump 里 SP1 `sp_user_expense_recharge` 与 SP2 `sp_user_expense_school_recharge`
  **都是双表**(两段 UPDATE 一字不差:t_user_expense_school 与 t_user_expense 各一段)
- 两者真正的差异只有三处:默认备注(余额充值/充值缴费)、
  type 来源(SP1 入参<100→100 / SP2 固定 100)、入参守卫(SP1 有 schoolid<1||campusid<1→LEAVE,SP2 没有)
- 所以要求 6 按正确前提实现:断言「校区表 Δbalance/Δtotal_income 必须等于充值额」,
  若有人把充值写成只写全局,这条立刻红(变异 M1 实测)

二、实现
- case.go 新增 Op(Expenditure/Recharge/SchoolRecharge/Refund)+ expectedDeltaFor(列增量方向照 dump)
- legacy.go:四条 CALL 语句逐字对齐 dump 入参顺序
  (SP1 8 参含 type+order;SP2 7 参无 type;SP4 7 参**无 schoolid**)
- target.go:按 Op 分派 Recharge(Kind=Default/School) / Refund / Expenditure
- run.go:8 个 case(A1~A6 + 消费回归 R1/R2),新增 remark 比对与「双表写入」断言

三、case 与实测结论
| case | SP | 结论 |
|---|---|---|
|A1|recharge 200.00|两侧 Δbalance/Δincome 双表都 +200.00;流水 1 条;备注"余额充值"一致|
|A2|recharge 幂等|target 只入账 1 次;legacy 无幂等重复入账 2 次(设计内增强)|
|A3|school_recharge 150.00|两侧双表 +150.00;type 固定 100;备注"充值缴费"一致|
|A4|school_recharge 幂等|同上幂等结论|
|A5|refund 100.00|两侧 Δbalance +100.00、Δtotal_expenditure **−100.00**(方向与消费相反);备注"报课的费用退回"一致|
|A6|refund 幂等|幂等命中|
|R1/R2|消费回归(含 R15)|保持绿|

四、变异 3 组(均已还原)
- M1 按"充值只写全局"的错误前提写期望 → 红 3 项(Δincome 双侧 + 双表断言)
- M2 把退费方向写反(expenditure 加)→ 红 2 项(实测 −100 vs 期望 +100)
- M3 每次调用换 requestId(幂等键失效)→ 红 7 项(Δ 双倍、条数 2/1、logid、request_id、balance_after)

五、单测新增
- expectedDeltaFor:四个 SP 的列增量方向(含"充值不动 expenditure/spend、退费不动 income")
- callArgs/callStmt:SP4 七参无 schoolid、SP2 七参无 type、SP1/SP3 八参

六、残留:8 个 case 全部双回滚,target 5 张表复核 0 行;legacy 回到事务前天然状态
Q 7 цаг өмнө
parent
commit
9e8044716f

+ 29 - 2
internal/academic/dualrun/stage2/README.md

@@ -1,6 +1,33 @@
-# `internal/academic/dualrun/stage2` —— 写 SP 双跑(第一支:消费)
+# `internal/academic/dualrun/stage2` —— 写 SP 双跑
 
-`sp_user_expense_school_expenditure` ↔ `service.FundService.Expenditure`。
+| 批 | 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−,**双表** |
+
+## 0. 一个必须先纠正的事实:**没有"只写全局"的充值 SP**
+
+派单里写「`sp_user_expense_recharge` 只写全局、`school_recharge` 双表」。**dump 不是这样**:
+
+```sql
+-- sp_user_expense_recharge(SP1)765xxx 段
+UPDATE `t_user_expense_school` SET `balance`=`balance`+inputMoney,`total_income`=`total_income`+inputMoney WHERE ...;
+UPDATE `t_user_expense`        SET `balance`=`balance`+inputMoney,`total_income`=`total_income`+inputMoney WHERE `userid`=inputUserid;
+
+-- sp_user_expense_school_recharge(SP2)同两段,一字不差
+```
+两个 SP **都是双表**。两者真正的差异只有三处:
+
+| 差异 | SP1 recharge | SP2 school_recharge |
+|---|---|---|
+|默认备注|`余额充值`|`充值缴费`|
+|`type` 来源|**入参**(`<100` → 100)|**固定 100**(无 type 入参)|
+|入参守卫|`schoolid<1 \|\| campusid<1 → LEAVE`(静默 no-op)|**无守卫**|
+
+所以要求里的第 6 条我按**正确前提**实现:断言「校区表 Δbalance / Δtotal_income 必须等于充值额」——
+如果有人把充值写成"只写全局",这条立刻红(变异 M1 实测)。
 
 这是**第一次两侧都真的写库**的双跑:
 

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

@@ -10,10 +10,53 @@ import (
 
 // 本文件是 case 定义与两侧快照的数据形状。
 
+// Op 是"跑哪个写 SP"。
+//
+// 批 A 覆盖三个简单写路径;它们的列增量方向完全不同,所以 Op 必须进期望值计算:
+//
+//	OpRecharge        sp_user_expense_recharge         → balance+, total_income+(**双表**)
+//	OpSchoolRecharge  sp_user_expense_school_recharge  → balance+, total_income+(**双表**)
+//	OpRefund          sp_user_expense_school_refund    → balance+, total_expenditure−(**双表**)
+//	OpExpenditure     sp_user_expense_school_expenditure → balance−, total_expenditure+(双表)
+//
+// ⚠ 关于"充值是否只写全局":dump 里两个充值 SP **都** UPDATE 了 t_user_expense_school 与
+// t_user_expense(见 sp_user_expense_recharge 的 7642xx 段),Go 侧 `Recharge` 两种 Kind 也都是双表。
+// 所以"只写全局"的那个 SP **不存在**;两者的真实差异是:默认备注 / type 来源 / 是否有
+// `schoolid<1||campusid<1 → LEAVE` 守卫(详见 run.go 的守卫用例与报告)。
+type Op int
+
+const (
+	// OpExpenditure 消费扣费(第二批已做,这里保留以便回归)。
+	OpExpenditure Op = iota
+	// OpRecharge 余额充值(SP1,8 参,type 由入参决定)。
+	OpRecharge
+	// OpSchoolRecharge 校区充值(SP2,7 参,type 固定 100)。
+	OpSchoolRecharge
+	// OpRefund 余额退费(SP4,7 参,**无 schoolid**)。
+	OpRefund
+)
+
+// String 返回可读名字(报告用)。
+func (o Op) String() string {
+	switch o {
+	case OpRecharge:
+		return "sp_user_expense_recharge"
+	case OpSchoolRecharge:
+		return "sp_user_expense_school_recharge"
+	case OpRefund:
+		return "sp_user_expense_school_refund"
+	default:
+		return "sp_user_expense_school_expenditure"
+	}
+}
+
 // Case 是一个双跑用例。
 type Case struct {
 	Name string
 
+	// Op 决定调哪个 SP / service 方法,以及列增量的方向。
+	Op Op
+
 	// 业务输入(两侧完全一致的入参)
 	LegacyUserID  int64
 	InstitutionID int64
@@ -169,3 +212,28 @@ var (
 	_ = gorm.ErrRecordNotFound
 	_ = fmt.Sprintf
 )
+
+// expectedDeltaFor 按 Op 计算一侧的**期望增量**(方向一律照 dump,不得"顺手修正")。
+//
+//	充值(SP1/SP2):balance + , total_income +
+//	退费(SP4)    :balance + , total_expenditure −      ← 与消费方向相反
+//	消费(SP3)    :balance − , total_expenditure +(type=200 另加 spend +)
+func expectedDeltaFor(op Op, typ int32, amount int64, runs int) Account {
+	var exp Account
+	n := int64(runs)
+	switch op {
+	case OpRecharge, OpSchoolRecharge:
+		exp.Balance = amount * n
+		exp.Income = amount * n
+	case OpRefund:
+		exp.Balance = amount * n
+		exp.Expenditure = -amount * n
+	default:
+		exp.Balance = -amount * n
+		exp.Expenditure = amount * n
+		if typ == 200 {
+			exp.Spend = amount * n
+		}
+	}
+	return exp
+}

+ 46 - 7
internal/academic/dualrun/stage2/legacy.go

@@ -28,8 +28,51 @@ type legacySide struct {
 
 func newLegacySide(db *gorm.DB) *legacySide { return &legacySide{db: db} }
 
-// callSQL 是 SP 的调用语句(8 个入参,顺序与 dump 一致)。
-const callSQL = "CALL sp_user_expense_school_expenditure(?, ?, ?, ?, ?, ?, ?, ?)"
+// CALL 语句(**入参顺序逐字对齐 dump**,不得"顺手规整")。
+//
+//	SP1 recharge          (userid, schoolid, campusid, money, order,      type, remark, createUserid)
+//	SP2 school_recharge   (userid, schoolid, campusid, money, requestid,       remark, createUserid)  ← 无 type
+//	SP3 school_expenditure(userid, schoolid, campusid, money, type, requestid, remark, createUserid)
+//	SP4 school_refund     (userid, campusid, money, type, requestid, remark, createUserid)            ← 无 schoolid
+const (
+	callExpenditure    = "CALL sp_user_expense_school_expenditure(?, ?, ?, ?, ?, ?, ?, ?)"
+	callRecharge       = "CALL sp_user_expense_recharge(?, ?, ?, ?, ?, ?, ?, ?)"
+	callSchoolRecharge = "CALL sp_user_expense_school_recharge(?, ?, ?, ?, ?, ?, ?)"
+	callRefund         = "CALL sp_user_expense_school_refund(?, ?, ?, ?, ?, ?, ?)"
+)
+
+// callArgs 按 Op 组出 CALL 的实参(顺序与 dump 一致)。
+func callArgs(c Case) []any {
+	moneyYuan := float64(c.AmountCents) / 100.0 // 所有 SP 的金额入参都是 FLOAT(元)
+	switch c.Op {
+	case OpRecharge:
+		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan,
+			c.RequestID, c.Type, c.Remark, c.LegacyUserID}
+	case OpSchoolRecharge:
+		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan,
+			c.RequestID, c.Remark, c.LegacyUserID}
+	case OpRefund:
+		return []any{c.LegacyUserID, c.CampusID, moneyYuan,
+			c.Type, c.RequestID, c.Remark, c.LegacyUserID}
+	default:
+		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan,
+			c.Type, c.RequestID, c.Remark, c.LegacyUserID}
+	}
+}
+
+// callStmt 返回该 Op 的 CALL 语句。
+func callStmt(op Op) string {
+	switch op {
+	case OpRecharge:
+		return callRecharge
+	case OpSchoolRecharge:
+		return callSchoolRecharge
+	case OpRefund:
+		return callRefund
+	default:
+		return callExpenditure
+	}
+}
 
 // Run 在事务内跑 case,读回状态后回滚。
 func (s *legacySide) Run(ctx context.Context, c Case) (*SideResult, error) {
@@ -55,11 +98,7 @@ func (s *legacySide) Run(ctx context.Context, c Case) (*SideResult, error) {
 	// 按 dump 口径把入参规整成 SP 会收到的值(type<200 / >299 → 200 是 SP 内部行为,这里不改,
 	// 让 SP 自己做,才能验证"SP 会静默改写 type"这件事)。
 	for i := 0; i < maxInt(1, c.RunTimes); i++ {
-		err := tx.Exec(callSQL,
-			c.LegacyUserID, c.InstitutionID, c.CampusID,
-			float64(c.AmountCents)/100.0, // SP 的第 4 参是 FLOAT(金额元)
-			c.Type, c.RequestID, c.Remark, c.LegacyUserID,
-		).Error
+		err := tx.Exec(callStmt(c.Op), callArgs(c)...).Error
 		out := CallOutcome{Success: err == nil}
 		if err != nil {
 			out.ErrMsg = err.Error()

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

@@ -9,48 +9,83 @@ import (
 
 // 本文件是比对与编排。
 
-// DefaultCases 返回三支 case:正常 / 幂等 / R15(余额不足)。
+// DefaultCases 返回批 A 的 6 个 case(3 个 SP × 正常 + 幂等)+ 3 个消费回归 case。
 //
-// 用户与机构校区取自真实数据(userid=17735 / 63317 / 5):
-// 该用户全局与校区余额都是 100.00、且四个金额列在 legacy 侧都是 2 位小数,
-// 因此**两侧基线天然逐分相等**(不需要人为造基线)—— 这正是 case 有效性的前提。
-func DefaultCases(uid string) []Case {
+// 用户选择:三个 SP 各用一个**不同**的真实用户,且这些用户的
+// balance / spend / total_income / total_expenditure 在 legacy 侧都是 2 位小数,
+// 因此两侧基线天然逐分相等,不需要人为造基线。
+//
+//	17735 → 机构 63317 / 校区 5,全局与校区余额都是 100.00
+//	31477 → 机构 63317 / 校区 5,余额 1350.00
+//	40575 → 机构 63317 / 校区 4,余额 1920.00
+func DefaultCases(_ string) []Case {
+	var out []Case
+
+	// ---- 批 A ----
+	out = append(out,
+		Case{
+			Name: "A1 充值 sp_user_expense_recharge(200.00,type=100)",
+			Op:   OpRecharge, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 20000, Type: 100, RequestID: "ac15-a1", RunTimes: 1,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "balance+200 / total_income+200,**全局与校区两张表都写**;默认备注\"余额充值\"",
+		},
+		Case{
+			Name: "A2 充值·幂等(同一 requestId 跑 2 次)",
+			Op:   OpRecharge, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 20000, Type: 100, RequestID: "ac15-a2", RunTimes: 2,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "target 幂等只入账 1 次;legacy SP 无幂等键,会重复入账 2 次(设计内 Go 增强)",
+		},
+		Case{
+			Name: "A3 校区充值 sp_user_expense_school_recharge(150.00)",
+			Op:   OpSchoolRecharge, LegacyUserID: 40575, InstitutionID: 63317, CampusID: 4,
+			AmountCents: 15000, Type: 0, RequestID: "ac15-a3", RunTimes: 1,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "balance+150 / total_income+150,双表;**type 由 SP 固定 100**;默认备注\"充值缴费\"",
+		},
+		Case{
+			Name: "A4 校区充值·幂等(同一 requestId 跑 2 次)",
+			Op:   OpSchoolRecharge, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 15000, Type: 0, RequestID: "ac15-a4", RunTimes: 2,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "同上,验证 uk_request_type 在充值路径上的幂等",
+		},
+		Case{
+			Name: "A5 退费 sp_user_expense_school_refund(100.00,type=207)",
+			Op:   OpRefund, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 10000, Type: 207, RequestID: "ac15-a5", RunTimes: 1,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "**方向与消费相反**:balance+100 / total_expenditure−100(双表);默认备注\"报课的费用退回\"",
+		},
+		Case{
+			Name: "A6 退费·幂等(同一 requestId 跑 2 次)",
+			Op:   OpRefund, LegacyUserID: 40575, InstitutionID: 63317, CampusID: 4,
+			AmountCents: 10000, Type: 207, RequestID: "ac15-a6", RunTimes: 2,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "退费路径的幂等",
+		},
+	)
+
+	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
-		LegacyUserID:        17735,
-		InstitutionID:       63317,
-		CampusID:            5,
-		Type:                201,
-		Remark:              "",
-		RunTimes:            1,
-		ExpectTargetSuccess: true,
-		ExpectLegacySuccess: true,
+		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
+		Type: 201, RunTimes: 1, ExpectTargetSuccess: true, ExpectLegacySuccess: true,
 	}
-	_ = uid
-
 	c1 := base
-	c1.Name = "C1 正常消费(type=201,30.00)"
-	c1.AmountCents = 3000
-	c1.RequestID = "ac15-s2-c1"
-	c1.Note = "两侧都应成功,Δbalance 同为 -30.00,各写 1 条流水"
+	c1.Name = "R1 回归·消费(type=201,30.00)"
+	c1.AmountCents, c1.RequestID = 3000, "ac15-s2-c1"
+	c1.Note = "第二批的消费 case,留作回归"
 
 	c2 := base
-	c2.Name = "C2 幂等(同一 requestId 连跑两次)"
-	c2.AmountCents = 3000
-	c2.RequestID = "ac15-s2-c2"
-	c2.RunTimes = 2
-	c2.Note = "target(Go)应 repeated=true 且只入账一次;legacy SP **没有幂等**,会重复入账 —— 这是设计内的 Go 增强"
-
-	c3 := base
-	c3.Name = "C3 R15 余额不足(余额 10.00,消费 100.00)"
-	c3.AmountCents = 10000
-	c3.RequestID = "ac15-s2-c3"
-	c3.Type = 200 // 历史扣费(同时动 spend,覆盖三分支里最复杂的一支)
-	c3.Note = "R15:SP 不校验余额 → 两侧都应成功且扣成 -90.00"
-	c3.Baseline = &Baseline{
-		GlobalBalance: 1000, GlobalSpend: 0, GlobalIncome: 0, GlobalExpenditure: 0,
-		CampusBalance: 1000, CampusSpend: 0, CampusIncome: 0, CampusExpenditure: 0,
-	}
-	return []Case{c1, c2, c3}
+	c2.Name = "R2 回归·消费 R15 余额不足"
+	c2.AmountCents, c2.RequestID, c2.Type = 10000, "ac15-s2-c3", 200
+	c2.Note = "R15:余额不足两侧都成功且扣成负数"
+	c2.Baseline = &Baseline{
+		GlobalBalance: 1000, CampusBalance: 1000,
+	}
+	out = append(out, c1, c2)
+	return out
 }
 
 // RunOne 跑一个 case 的完整双跑闭环。
@@ -152,8 +187,8 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	if c.RunTimes >= 2 {
 		noteRuns = fmt.Sprintf("预期差异:legacy 入账 %d 次(SP 无幂等)/ target 入账 1 次(幂等命中)", runsLegacy)
 	}
-	wantL := expectedDelta(c.Type, c.AmountCents, runsLegacy)
-	wantT := expectedDelta(c.Type, c.AmountCents, runsTarget)
+	wantL := expectedDeltaFor(c.Op, c.Type, c.AmountCents, runsLegacy)
+	wantT := expectedDeltaFor(c.Op, c.Type, c.AmountCents, runsTarget)
 
 	dGlobal := sub(lg.After.Global, lg.Before.Global)
 	dCampus := sub(lg.After.Campus, lg.Before.Campus)
@@ -167,11 +202,12 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		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))
+		"只有 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, "whitelist 内 type 都不动 total_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),
@@ -207,7 +243,8 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		}
 	}
 	add("流水 type", firstOr(detailJoin(tDetail), "全部一致"), fmt.Sprint(n)+" 条", sameType, "")
-	add("流水 money", firstOr(detailJoin(mDetail), "逐分相等"), fmt.Sprint(n)+" 条", sameMoney, "两侧都为负数(支出方向)")
+	moneyNote := "符号:消费为负、充值与退费为正(照 dump,不修正)"
+	add("流水 money", firstOr(detailJoin(mDetail), "逐分相等"), fmt.Sprint(n)+" 条", sameMoney, moneyNote)
 	add("流水 logid/log_id", firstOr(detailJoin(lDetail), "全部一致"), fmt.Sprint(n)+" 条", sameLogID,
 		"legacy logid = inputRequestid;target 同时写 log_id 与 request_id")
 
@@ -234,6 +271,19 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	add("target.balance_after(legacy 无此列)", "N/A",
 		firstOr(detailJoin(balDetail), "全部 == 调用后全局余额"), balOK, "Go 增强:余额快照")
 
+	// ④.5 remark(默认备注是 SP 之间唯一的"文案差异",必须逐字比对)
+	remarkOK := true
+	var remarkDetail []string
+	for i := 0; i < n; i++ {
+		if lg.After.Logs[i].Remark != tg.After.Logs[i].Remark {
+			remarkOK = false
+			remarkDetail = append(remarkDetail, fmt.Sprintf("#%d legacy=%q target=%q",
+				i, lg.After.Logs[i].Remark, tg.After.Logs[i].Remark))
+		}
+	}
+	add("流水 remark(默认备注)", firstOr(detailJoin(remarkDetail), "逐字一致"), fmt.Sprint(n)+" 条",
+		remarkOK, "SP1\"余额充值\"/SP2\"充值缴费\"/SP4\"报课的费用退回\"")
+
 	// ⑤ 返回码 / 成功与否
 	lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
 	tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
@@ -262,6 +312,23 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			true, "legacy=2(重复入账)/ target=1(幂等命中)—— 设计内的 Go 增强,不是缺陷")
 	}
 
+	// ⑥.5 双表写入断言(替代"recharge 只写全局"这个**错误前提**)
+	//
+	// dump 事实:SP1 `sp_user_expense_recharge` 与 SP2 `sp_user_expense_school_recharge`
+	// **都** 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
+		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)),
+		})
+	}
+
 	// ⑦ R15:两侧余额都必须为负
 	if c.Baseline != nil {
 		negOK := lg.After.Global.Balance < 0 && tg.After.Global.Balance < 0 &&

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

@@ -46,3 +46,62 @@ func TestSubAccount(t *testing.T) {
 		t.Errorf("sub() = %+v, want %+v", got, want)
 	}
 }
+
+// TestExpectedDeltaFor 锁住四个写 SP 的**列增量方向**(照 dump,不许"顺手修正")。
+func TestExpectedDeltaFor(t *testing.T) {
+	cases := []struct {
+		name string
+		op   Op
+		typ  int32
+		amt  int64
+		runs int
+		want Account
+	}{
+		{"消费 type=201", OpExpenditure, 201, 3000, 1, Account{Balance: -3000, Expenditure: 3000}},
+		{"消费 type=200 动 spend", OpExpenditure, 200, 3000, 1, Account{Balance: -3000, Spend: 3000, Expenditure: 3000}},
+		{"充值 SP1", OpRecharge, 100, 20000, 1, Account{Balance: 20000, Income: 20000}},
+		{"充值 SP2", OpSchoolRecharge, 0, 15000, 1, Account{Balance: 15000, Income: 15000}},
+		{"退费方向相反", OpRefund, 207, 10000, 1, Account{Balance: 10000, Expenditure: -10000}},
+		{"退费两次", OpRefund, 207, 10000, 2, Account{Balance: 20000, Expenditure: -20000}},
+	}
+	for _, c := range cases {
+		if got := expectedDeltaFor(c.op, c.typ, c.amt, c.runs); got != c.want {
+			t.Errorf("%s:expectedDeltaFor = %+v, want %+v", c.name, got, c.want)
+		}
+	}
+	// 充值**绝不**动 total_expenditure / spend;退费**绝不**动 total_income
+	if d := expectedDeltaFor(OpRecharge, 100, 100, 1); d.Expenditure != 0 || d.Spend != 0 {
+		t.Errorf("充值不该动 expenditure/spend,得到 %+v", d)
+	}
+	if d := expectedDeltaFor(OpRefund, 207, 100, 1); d.Income != 0 || d.Spend != 0 {
+		t.Errorf("退费不该动 income/spend,得到 %+v", d)
+	}
+}
+
+// TestCallArgsOrder 锁住三个 SP 的**入参顺序**(SP4 没有 schoolid、SP2 没有 type)。
+func TestCallArgsOrder(t *testing.T) {
+	c := Case{Op: OpRefund, LegacyUserID: 7, InstitutionID: 63317, CampusID: 5, AmountCents: 12300,
+		Type: 207, RequestID: "rid", Remark: "rm"}
+	if got := callStmt(OpRefund); got != callRefund {
+		t.Errorf("退费语句不符:%s", got)
+	}
+	args := callArgs(c)
+	if len(args) != 7 {
+		t.Fatalf("SP4 应有 7 个入参(无 schoolid),实际 %d", len(args))
+	}
+	if args[1].(int64) != 5 {
+		t.Errorf("SP4 第 2 参必须是 campusid(5),实际 %v", args[1])
+	}
+	if args[2].(float64) != 123.0 {
+		t.Errorf("SP4 第 3 参必须是金额(元),实际 %v", args[2])
+	}
+	if args2 := callArgs(Case{Op: OpSchoolRecharge}); len(args2) != 7 {
+		t.Errorf("SP2 应有 7 个入参(无 type),实际 %d", len(args2))
+	}
+	if args3 := callArgs(Case{Op: OpRecharge}); len(args3) != 8 {
+		t.Errorf("SP1 应有 8 个入参,实际 %d", len(args3))
+	}
+	if args4 := callArgs(Case{Op: OpExpenditure}); len(args4) != 8 {
+		t.Errorf("SP3 应有 8 个入参,实际 %d", len(args4))
+	}
+}

+ 51 - 17
internal/academic/dualrun/stage2/target.go

@@ -68,24 +68,58 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 	svc := service.NewFundService(tx, sc, port.NewOrgMembershipChecker(tx))
 
 	for i := 0; i < maxInt(1, c.RunTimes); i++ {
-		out, err := svc.Expenditure(ctx, service.ExpenditureInput{
-			UserID:        canonicalUID,
-			InstitutionID: c.InstitutionID,
-			CampusID:      c.CampusID,
-			Money:         money.Amount(c.AmountCents),
-			Type:          c.Type,
-			RequestID:     c.RequestID,
-			Remark:        c.Remark,
-			OperatorID:    canonicalUID,
-		})
 		oc := CallOutcome{}
-		if err != nil {
-			oc.ErrMsg = err.Error()
-		} else {
-			oc.Success = true
-			oc.Repeated = out.Repeated
-			oc.Balance = int64(out.Balance)
-			oc.LogID = out.LogID
+		switch c.Op {
+		case OpRecharge:
+			// SP1:Kind=Default(默认备注"余额充值"),type 由入参决定
+			out, err := svc.Recharge(ctx, service.RechargeInput{
+				UserID: canonicalUID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,
+				Money: money.Amount(c.AmountCents), Type: c.Type, RequestID: c.RequestID,
+				Remark: c.Remark, OperatorID: canonicalUID, Kind: service.RechargeKindDefault,
+			})
+			if err != nil {
+				oc.ErrMsg = err.Error()
+			} else {
+				oc.Success, oc.Repeated, oc.Balance, oc.LogID =
+					true, out.Repeated, int64(out.Balance), out.LogID
+			}
+		case OpSchoolRecharge:
+			// SP2:Kind=School(默认备注"充值缴费"),type 固定 100(service 内部)
+			out, err := svc.Recharge(ctx, service.RechargeInput{
+				UserID: canonicalUID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,
+				Money: money.Amount(c.AmountCents), Type: c.Type, RequestID: c.RequestID,
+				Remark: c.Remark, OperatorID: canonicalUID, Kind: service.RechargeKindSchool,
+			})
+			if err != nil {
+				oc.ErrMsg = err.Error()
+			} else {
+				oc.Success, oc.Repeated, oc.Balance, oc.LogID =
+					true, out.Repeated, int64(out.Balance), out.LogID
+			}
+		case OpRefund:
+			out, err := svc.Refund(ctx, service.RefundInput{
+				UserID: canonicalUID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,
+				Money: money.Amount(c.AmountCents), Type: c.Type, RequestID: c.RequestID,
+				Remark: c.Remark, OperatorID: canonicalUID,
+			})
+			if err != nil {
+				oc.ErrMsg = err.Error()
+			} else {
+				oc.Success, oc.Repeated, oc.Balance, oc.LogID =
+					true, out.Repeated, int64(out.Balance), out.LogID
+			}
+		default:
+			out, err := svc.Expenditure(ctx, service.ExpenditureInput{
+				UserID: canonicalUID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,
+				Money: money.Amount(c.AmountCents), Type: c.Type, RequestID: c.RequestID,
+				Remark: c.Remark, OperatorID: canonicalUID,
+			})
+			if err != nil {
+				oc.ErrMsg = err.Error()
+			} else {
+				oc.Success, oc.Repeated, oc.Balance, oc.LogID =
+					true, out.Repeated, int64(out.Balance), out.LogID
+			}
 		}
 		res.Calls = append(res.Calls, oc)
 	}