Sfoglia il codice sorgente

wip(ac15): 3d 基建(二)—— 推荐人校区快照 / 删 Baseline 新字段 / AccountSeed 三态 / 无 dispatch 即 FATAL

四件 review 项,仍**不含任何 W4 case**。52 条零回归。

## 一、推荐人**校区**账户快照(`RecommenderCampus` + `HasRecommenderCampus`)

`readRecommenderAccount` → `readRecommenderAccounts`,多读一张 `t_user_expense_school`。
理由(W4-B):只读全局的话两侧在全局那一半**恰好相等**(`t_user_expense.balance` 有
`DEFAULT 0.000`),缺陷在校区表 ⇒ 会得到一条"只验了正确部分"的绿。
`mustCover` 也拆成**两块**(全局 / 校区),合并会让"校区那行没读到"被全局掩盖。
声明 `Absent`(不建行)时改成"两侧都**读不到**"才算通过 —— 否则 W4-B 的前提断言会自相矛盾。

顺带修了一处自己写坏的 SQL:六列拼字符串时括号错位,`Scan` 报列数不匹配、
看起来像"读不到账户"。现在抽成 `recAccountCols` 常量,六行只写一遍。

## 二、删 `Baseline.GlobalRecommended` / `CampusRecommended`

被 `RecommendSeed` 的绝对赋值取代。`Baseline` 的语义是"**重置已存在的行**"
(`RowsAffected==1` 否则报错),不承担"创建 / 不创建";
W4 要的恰恰是"故意不建行",`Baseline` 从语义上没有这个表达位。
硬塞进去会让"基线"变成"有时重置、有时创建",那是最难读的配置。

## 三、`AccountSeed` / `RecommendSeed`:三态互斥 + 构造即校验

```go
type AccountSeed struct { Absent bool; Balance *int64; Recommended *int64 }
```
- `Absent=true`:两张表都不建行(触发建行分支),金额字段必须 nil;
- `Balance/Recommended` 全 nil:**行在,但列是 NULL**(不是 0);
- 任一非 nil:行在,取显式值(含 0)。

`nil` 与 `0` 分开是硬要求:W4-B 的缺陷**恰恰是 NULL 不是 0**,用 `int64` 就等于
把要测的东西在种子里先抹平(种 0 进去 `0+m=m` 会到账,bug 被种子"修好")。
`validate` 拒绝两种自相矛盾:`Absent` 与金额字段同时给、三态一个都没选。

## 四、`hasDispatch` 闸门:没有分支就 FATAL,不许落到 default

`OpUseActivityRecommend` 现在只有枚举、没有分支。若有人先写 case 忘了接线,
`switch` 会落到 `default:` —— 而那是 **`Expenditure`,会真的扣钱**(不是 no-op)。
所以在**开事务之前**查白名单,没有分支直接报错。这条同时护住以后所有新增 Op。

## 五、README §17.2 更正(1158 行附近)

原句把两张表混成一句("钱一分没到账")。已按 DDL 实测改写:
`t_user_expense.balance` 有 `DEFAULT 0.000` ⇒ **全局到账**;
`t_user_expense_school` 四列无 DEFAULT ⇒ **校区没记**、`total_recommended_amount` 两边都没记。
并写明"建行分支只发生在校区行不存在时;行在但列 NULL 是另一条路径"(对应 `AccountSeed` 三态)。

## 报数
52 / 52 PASS / 0 FAIL | SQL 2970 次 | 57.1 次/case | 未 push
Q 8 ore fa
parent
commit
d0fda094e6

+ 14 - 3
internal/academic/dualrun/stage2/README.md

@@ -1154,9 +1154,20 @@ B/C 档的绿与 A 档的绿不是同一种绿,混在一张总表里就是把"
 不够用。缺两块:**推荐人账户**(否则"推荐人也 +m"不可断言,而它正是老带新的核心语义)
 + **课酬应收**(否则核销顺序与 `confirm_discount_userid>0` 过滤都验不了)。
 
-⚠ `t_user_expense_school` 的建行语句只写 `(userid, schoolid, campusid)` 三列 ⇒ 金额列
-`DEFAULT NULL` ⇒ `SET balance=balance+m` 仍是 **NULL** ⇒ **钱一分没到账、107 流水照写**。
-与 C1-11 同族但后果更重(不是"少扣",是"用户被告知发了奖励、余额没动"),线上有真实调用点。
+⚠ `t_user_expense_school` 的建行语句只写 `(userid, schoolid, campusid)` 三列 ⇒
+`balance` / `total_income` / `total_expenditure` / `total_recommended_amount` **四列都无 DEFAULT** ⇒ 建行后为 **NULL**
+⇒ `SET balance=balance+m` 仍是 **NULL** ⇒ **校区侧一分没记、推荐金额两边都没记,而 `107` 流水照写**。
+
+⚠ **两张表的默认值不对称,这是缺陷的成因,不是随机 NULL**:
+`t_user_expense.balance` 是 `float(10,3) NULL DEFAULT 0.000`(建行后**有值**,`+m` 到账),
+而 `t_user_expense_school` 对应列**无 DEFAULT**(建行后 **NULL**,`+m` 仍是 NULL)。
+⇒ 实际结果是「**全局余额到了、校区余额没记**」,**不是**"钱一分没到账"
+(这句是 3c 时的错结论,已按 DDL 实测更正)。
+建行分支只发生在**校区账户行不存在**时;"行在但列为 NULL"是**另一条路径**(同样结果、不同成因),
+两者都要能测 —— 见 `AccountSeed` 的三态。
+
+与 C1-11 同族(`_school` 那些列没有 DEFAULT),但**方向相反**:
+C1-11 是"全局扣了、校区没扣",W4-B 是"全局到了、校区没到"。线上有真实调用点。
 
 ### 17.3 `user_discount_usable`:**oracle 是 PHP 内联 SQL,不是 SP**
 

+ 68 - 36
internal/academic/dualrun/stage2/activity_snapshot.go

@@ -944,48 +944,80 @@ SELECT t1.biz_no, t1.id,
 	return out, rows.Err()
 }
 
-// readRecommenderAccount 读"推荐人"的资金账户(全局 + 本校区)。
+// recAccountCols 是推荐人账户六列的统一读法(2 位口径,与全仓一致)。
+//
+// 抽成常量而不是复制六遍:这六行里任何一处括号写错,`Row().Scan` 会报 column 数不匹配,
+// 那种错误看起来像"读不到账户",实际是 SQL 语法写坏了(本轮真踩了一次)。
+const recAccountCols = `CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)                  AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2)                    AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2)             AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2)        AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2)         AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_recommended_amount AS DECIMAL(20,3)),2) AS DECIMAL(12,2))`
+
+// readRecommenderAccounts 读"推荐人"的两个资金账户:全局(`t_user_expense`)+ 本校区(`t_user_expense_school`)。
 //
 // 只在 `c.RecommendUserID > 0` 时读 —— 其余 case 不付额外查询代价(报告尾部的"代价"行会体现)。
-func readRecommenderAccount(ctx context.Context, tx *gorm.DB, c Case, target bool) (Account, bool, error) {
+//
+// ⚠ **只读全局是不够的**(3d 的 W4-B 逼出来的):
+//   - `t_user_expense.balance` 有 `DEFAULT 0.000` ⇒ 建行后**有值** ⇒ `+m` 到账;
+//   - `t_user_expense_school.balance` **无 DEFAULT** ⇒ 建行后 **NULL** ⇒ `+m` 仍是 NULL ⇒ 没到账。
+//
+// 只比全局的话,两侧在全局那一半**恰好相等** ⇒ W4-B 红不出来,得到一条
+// "只验了正确部分"的绿。那比"少验"更坏,因为它看起来通过了。
+//
+// 两个 bool 各自独立:**行不存在**(会走建行分支)与**行在但列 NULL** 是两条不同路径。
+func readRecommenderAccounts(ctx context.Context, tx *gorm.DB, c Case, target bool) (
+	global Account, hasGlobal bool, campus Account, hasCampus bool, err error) {
 	if c.RecommendUserID <= 0 {
-		return Account{}, false, nil
+		return Account{}, false, Account{}, false, nil
 	}
+	var g, s accountNS
 	if target {
 		uid := CanonicalUIDFor(c.RecommendUserID)
-		var g accountNS
-		if 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(special_expenses AS DECIMAL(20,3)),2)            AS DECIMAL(12,2)),
-       CAST(ROUND(CAST(total_recommended_amount AS DECIMAL(20,3)),2)     AS DECIMAL(12,2))
-  FROM t_user_expense WHERE user_id = ?`, uid).
-			Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special, &g.Recommended); err != nil {
-			if errors.Is(err, sql.ErrNoRows) {
-				return Account{}, false, nil
-			}
-			return Account{}, false, fmt.Errorf("读 target 推荐人全局账户失败:%w", err)
-		}
-		return g.account(), true, nil
-	}
-	var g accountNS
-	if 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(special_expenses AS DECIMAL(20,3)),2)            AS DECIMAL(12,2)),
-       CAST(ROUND(CAST(total_recommended_amount AS DECIMAL(20,3)),2)     AS DECIMAL(12,2))
-  FROM t_user_expense WHERE userid = ?`, c.RecommendUserID).
-		Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special, &g.Recommended); err != nil {
-		if errors.Is(err, sql.ErrNoRows) {
-			return Account{}, false, nil
-		}
-		return Account{}, false, fmt.Errorf("读 legacy 推荐人全局账户失败:%w", err)
-	}
-	return g.account(), true, nil
+		err = tx.Raw(`SELECT `+recAccountCols+` FROM t_user_expense WHERE user_id = ?`, uid).
+			Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special, &g.Recommended)
+		if err == nil {
+			hasGlobal = true
+		} else if errors.Is(err, sql.ErrNoRows) {
+			err = nil
+		} else {
+			return Account{}, false, Account{}, false, fmt.Errorf("读 target 推荐人全局账户失败:%w", err)
+		}
+		err = tx.Raw(`SELECT `+recAccountCols+`
+  FROM t_user_expense_school WHERE user_id = ? AND institution_id = ? AND campus_id = ?`,
+			uid, c.InstitutionID, c.CampusID).
+			Row().Scan(&s.Balance, &s.Spend, &s.Income, &s.Expenditure, &s.Special, &s.Recommended)
+		if err == nil {
+			hasCampus = true
+		} else if errors.Is(err, sql.ErrNoRows) {
+			err = nil
+		} else {
+			return Account{}, false, Account{}, false, fmt.Errorf("读 target 推荐人校区账户失败:%w", err)
+		}
+		return g.account(), hasGlobal, s.account(), hasCampus, nil
+	}
+	err = tx.Raw(`SELECT `+recAccountCols+` FROM t_user_expense WHERE userid = ?`, c.RecommendUserID).
+		Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special, &g.Recommended)
+	if err == nil {
+		hasGlobal = true
+	} else if errors.Is(err, sql.ErrNoRows) {
+		err = nil
+	} else {
+		return Account{}, false, Account{}, false, fmt.Errorf("读 legacy 推荐人全局账户失败:%w", err)
+	}
+	err = tx.Raw(`SELECT `+recAccountCols+`
+  FROM t_user_expense_school WHERE userid = ? AND schoolid = ? AND campusid = ?`,
+		c.RecommendUserID, c.InstitutionID, c.CampusID).
+		Row().Scan(&s.Balance, &s.Spend, &s.Income, &s.Expenditure, &s.Special, &s.Recommended)
+	if err == nil {
+		hasCampus = true
+	} else if errors.Is(err, sql.ErrNoRows) {
+		err = nil
+	} else {
+		return Account{}, false, Account{}, false, fmt.Errorf("读 legacy 推荐人校区账户失败:%w", err)
+	}
+	return g.account(), hasGlobal, s.account(), hasCampus, nil
 }
 
 // studentBizNos 安全地取 case 声明的报课单号(两侧都为空时返回 nil)。

+ 25 - 17
internal/academic/dualrun/stage2/case.go

@@ -291,6 +291,10 @@ type Case struct {
 	// 为什么必须**两侧都种**:target 库整库为空,而比对锚是 `biz_no`。
 	// 只种一侧 ⇒ 另一侧读不到行 ⇒ `mustCover` 会 FAIL(这正是我们要的,见 §21.3)。
 	Arrears *ArrearsSeed
+	// Recommend 非 nil 时,两侧各自把"被推荐人 / 推荐人"的账户造成声明的那三态。
+	//
+	// 只有 `OpUseActivityRecommend`(W4)需要 —— 它是唯一**跨两个用户**的一支。
+	Recommend *RecommendSeed
 	// LegacyObservedNetDelta 是"1:N 扇出形态下 legacy 侧每行的 net_receipts 增量(分)"——
 	// **它是一次 legacy 实测的观测记录,不是期望值**。
 	//
@@ -345,6 +349,15 @@ type Case struct {
 }
 
 // Baseline 是一条账户基线(单位:分)。
+//
+// 语义边界(**别扩**):Baseline 是"**重置一条已经存在的行**"——`applyBaseline*` 的契约是
+// `RowsAffected == 1`,设不上就报错。"行不存在 ⇒ 建行 / 故意不建行"不归它管:
+// 那是**种子**的职责(见 `AccountSeed` / `RecommendSeed`)。
+//
+// 曾经在这里加过 `GlobalRecommended` / `CampusRecommended`,后来删掉了:
+// 加了它们会让"基线"这个概念变成"有时是重置、有时是创建",
+// 而 W4 真正要的是"两个账户各自处于哪种状态(不存在 / 行在列 NULL / 行在有值)"——
+// 那是 `AccountSeed` 的三态,`Baseline` 从语义上就没有"不建行"这个表达位。
 type Baseline struct {
 	GlobalBalance     int64
 	GlobalSpend       int64
@@ -354,23 +367,6 @@ type Baseline struct {
 	CampusSpend       int64
 	CampusIncome      int64
 	CampusExpenditure int64
-
-	// GlobalRecommended / CampusRecommended 是 `total_recommended_amount` 的基线,**三态**:
-	//
-	//	nil    = **不动这一列**(保留库里原值)
-	//	非 nil = 显式设成该值(**含 0**)
-	//
-	// 为什么必须是 `*int64` 而不是 `int64`:这一列**两张表都没有 DEFAULT**
-	//(`t_user_expense` / `t_user_expense_school` 实测,见 §17.2 的更正),所以
-	// "库里是 NULL" 与 "库里是 0" 是两件不同的事,而 `0` 又必须能表达成"设成 0"。
-	// 用 `int64` 的话,`0` 会同时表示"不动"和"设成 0" —— 那就是 §8「NULL ≠ 0」
-	// 那个老坑换了个字段重演一次。
-	//
-	// W4(老带新)是唯一写这一列的一支:基线不覆盖它,就等于"基线只覆盖了一半的列",
-	// 而 `NULL + m` 仍是 NULL ⇒ case 会测成"没记推荐金额",那是 W4-B 要证明的事,
-	// 不该让 W4-A 承担(否则 W4-A 变成第二条 W4-B,"两个用户各一份"反而没人钉)。
-	GlobalRecommended *int64
-	CampusRecommended *int64
 }
 
 // AccountNulls 记录 Account 每一列是否为 SQL NULL。
@@ -502,6 +498,18 @@ type SideSnapshot struct {
 	// `HasRecommender` 单独记:`c.RecommendUserID == 0` 时本块不适用(不是"读到 0")。
 	Recommender    Account
 	HasRecommender bool
+	// RecommenderCampus 是推荐人**在本校区**的账户(`t_user_expense_school` 一行)。
+	//
+	// 为什么全局不够、必须再加这一块:W4-B 的缺陷**正好在校区表** ——
+	// `t_user_expense.balance` 有 `DEFAULT 0.000`(建行后有值),而
+	// `t_user_expense_school.balance` **无 DEFAULT**(建行后 NULL)。
+	// 只读全局的话,两侧在全局那一半**恰好是相等的**,W4-B 会得到一条
+	// "只验了正确部分"的绿 —— 那比"少验一点"更坏,因为它看起来通过了。
+	//
+	// `HasRecommenderCampus` 同样单独记:行不存在(走建行分支)与"行在但列 NULL"
+	// 是两条不同路径,而这两种都有 case 要测。
+	RecommenderCampus    Account
+	HasRecommenderCampus bool
 
 	// Arrears 是课酬应收行(`t_courses_student`),**按核销 cursor 的顺序**排列。
 	//

+ 2 - 18
internal/academic/dualrun/stage2/legacy.go

@@ -304,23 +304,6 @@ UPDATE t_user_expense_school SET balance=?, spend=?, total_income=?, total_expen
 		return fmt.Errorf("legacy 设置校区基线影响 %d 行(期望 1):userid=%d inst=%d campus=%d 的校区账户不存在,基线没设上",
 			res.RowsAffected, c.LegacyUserID, c.InstitutionID, c.CampusID)
 	}
-	// `total_recommended_amount` 是**三态**:nil = 不动,非 nil = 显式设为该值(含 0)。
-	// 单独一条 UPDATE(而不是并进上面那句)是因为"不动"这个语义没法用 SQL 参数表达:
-	// 并进去就必然要传一个值,而 `0` 会同时表示"不动"和"设成 0"(§8 的老坑)。
-	if b.GlobalRecommended != nil {
-		if err := tx.Exec(`UPDATE t_user_expense SET total_recommended_amount=? WHERE userid=?`,
-			yuan(*b.GlobalRecommended), c.LegacyUserID).Error; err != nil {
-			return fmt.Errorf("legacy 设置全局 total_recommended_amount 基线失败:%w", err)
-		}
-	}
-	if b.CampusRecommended != nil {
-		if err := tx.Exec(`
-UPDATE t_user_expense_school SET total_recommended_amount=?
- WHERE userid=? AND schoolid=? AND campusid=?`,
-			yuan(*b.CampusRecommended), c.LegacyUserID, c.InstitutionID, c.CampusID).Error; err != nil {
-			return fmt.Errorf("legacy 设置校区 total_recommended_amount 基线失败:%w", err)
-		}
-	}
 	return nil
 }
 
@@ -508,7 +491,8 @@ SELECT id, IFNULL(type,0), CAST(ROUND(CAST(IFNULL(money,0) AS DECIMAL(20,3)),2)
 		return out, 0, err
 	}
 	// 第 4 块:推荐人账户(仅 `RecommendUserID > 0` 时读,其余 case 不付查询代价)
-	if out.Recommender, out.HasRecommender, err = readRecommenderAccount(ctx, tx, c, false); err != nil {
+	if out.Recommender, out.HasRecommender, out.RecommenderCampus, out.HasRecommenderCampus, err =
+		readRecommenderAccounts(ctx, tx, c, false); err != nil {
 		return out, 0, err
 	}
 	// 第 5 块:课酬应收(有序;仅声明了 `StudentBizNos` 时读)

+ 98 - 0
internal/academic/dualrun/stage2/recommend_seed.go

@@ -0,0 +1,98 @@
+package stage2
+
+import (
+	"fmt"
+)
+
+// 本文件是**老带新(W4)**两侧账户种子的数据形状与自洽校验。
+//
+// 为什么单独一个 `AccountSeed`,而不是复用 `Baseline`:
+//
+//	`Baseline` 的语义是"**重置一条已经存在的行**"(`applyBaseline*` 要求 `RowsAffected == 1`,
+//	设不上就报错)。而老带新要表达的是"这一侧账户**处于哪种状态**"——包括**故意不建行**
+//	(那正是 SP 的建行分支的触发条件)。硬塞进 `Baseline` 会让"基线"变成
+//	"有时是重置、有时是创建、有时是故意不创建",那是最难读的那种配置。
+
+// AccountSeed 描述**一侧账户行**要造成什么状态。
+//
+// 三态互斥,必须过 `validate()`:
+//
+//	① Absent=true            → 两张表都不建行(触发 SP 的建行分支);Balance/Recommended 必须为 nil
+//	② Balance=nil, Recommended=nil → 行**在**,但这两列是 **NULL**(不是 0!)
+//	③ 任一非 nil             → 行在,且这些列取显式值(**含 0**)
+//
+// 为什么必须能表达"NULL"(而不是只有 0):
+//
+//	W4-B 的缺陷**恰恰是 NULL 不是 0** —— 建行后 `balance` 是 NULL,`NULL + m` 仍是 NULL。
+//	如果种子只能用 `int64` 表达,那就等于**把要测的东西在种子里先抹平了**:
+//	种一个 0 进去,`0 + m = m` 会正常到账,bug 就消失了(是被种子"修好"的假绿)。
+//
+// 为什么 `Absent` 与金额字段必须互斥:
+//
+//	`Absent=true` + `Balance=&5000` 这个组合没有语义。类型上表达不了互斥,就只能靠校验;
+//	两条都不做就是"靠人记得"——而**这种自相矛盾的配置不会报错,只会种出一个你没预期的状态**。
+type AccountSeed struct {
+	Absent      bool
+	Balance     *int64
+	Recommended *int64
+}
+
+// validate 检查三态互斥。
+func (a AccountSeed) validate(which string) error {
+	if a.Absent && (a.Balance != nil || a.Recommended != nil) {
+		return fmt.Errorf("%s: Absent=true 与金额字段互斥(现在 Balance=%v Recommended=%v)—— "+
+			"「不建行」和「建行且有值」不能同时成立", which, a.Balance, a.Recommended)
+	}
+	if !a.Absent && a.Balance == nil && a.Recommended == nil {
+		return fmt.Errorf("%s: 三态里没选任何一种 —— 要么 Absent=true(不建行),"+
+			"要么把 Balance/Recommended 至少给一个(nil 表示该列 NULL,非 nil 表示显式值含 0)", which)
+	}
+	return nil
+}
+
+// state 返回人类可读的状态名(报告与断言里用)。
+func (a AccountSeed) state() string {
+	if a.Absent {
+		return "不建行"
+	}
+	if a.Balance == nil && a.Recommended == nil {
+		return "行在·两列 NULL"
+	}
+	return "行在·显式值"
+}
+
+// RecommendSeed 是老带新 case 的两侧账户声明。
+type RecommendSeed struct {
+	// Referred 是被推荐人(`Case.LegacyUserID`,即 SP 的 `inputUserid`)。
+	Referred AccountSeed
+	// Referrer 是推荐人(`Case.RecommendUserID`)。
+	//
+	// ⚠ 它与 `Referred` 是**两个不同的用户**:老带新的核心语义是"各拿一份"。
+	// 只种一侧、或把两侧种成同一个用户,都会让"镜像错误(两边只给一个人)"照样绿。
+	Referrer AccountSeed
+}
+
+// validate 校验两侧声明。
+func (s *RecommendSeed) validate(c Case) error {
+	if s == nil {
+		return nil
+	}
+	if c.RecommendUserID <= 0 {
+		return fmt.Errorf("case %q 声明了 RecommendSeed 但 RecommendUserID=%d(必须 >0,"+
+			"否则 SP 游标第一轮就 LEAVE,整条 case 会退化成零写入的空过)", c.Name, c.RecommendUserID)
+	}
+	if c.LegacyUserID <= 0 {
+		return fmt.Errorf("case %q 声明了 RecommendSeed 但 LegacyUserID=%d(必须 >0)", c.Name, c.LegacyUserID)
+	}
+	if c.LegacyUserID == c.RecommendUserID {
+		return fmt.Errorf("case %q 的被推荐人与推荐人是同一个 userid=%d:老带新必须两个用户,"+
+			"同一个人的话「各拿一份」这句话不成立", c.Name, c.LegacyUserID)
+	}
+	if err := s.Referred.validate("Referred(被推荐人)"); err != nil {
+		return fmt.Errorf("case %q 的 RecommendSeed.%w", c.Name, err)
+	}
+	if err := s.Referrer.validate("Referrer(推荐人)"); err != nil {
+		return fmt.Errorf("case %q 的 RecommendSeed.%w", c.Name, err)
+	}
+	return nil
+}

+ 31 - 3
internal/academic/dualrun/stage2/run.go

@@ -1014,14 +1014,42 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 	// 若该字段恰好没人读,它**永远 nil 且零症状**。所以每块都要一条"两侧都读到了"的基建断言,
 	// 并且**打出 N 项**——N==0 时不是"空过",而是 FAIL(§18.3 第 0 条)。
 	if c.RecommendUserID > 0 {
-		n := 0
+		// 两块分开数:**全局**与**校区**。合成一块会让"校区那行没读到"被全局那一半掩盖 ——
+		// 而 W4-B 的缺陷正好在校区表(`t_user_expense` 有 DEFAULT 0.000、`_school` 没有)。
+		nGlobal := 0
 		if lg.Before.HasRecommender && tg.Before.HasRecommender {
-			n = 1
+			nGlobal = 1
 		}
 		res.Assertions = append(res.Assertions, mustCover(
-			"case 有效性前提:**推荐人账户两侧都读到了**(否则「推荐人也 +m」不可断言)", n, 1,
+			"case 有效性前提:**推荐人全局账户两侧都读到了**(否则「推荐人也 +m」不可断言)", nGlobal, 1,
 			fmt.Sprintf("legacy 读到=%v target 读到=%v(推荐人 userid=%d)",
 				lg.Before.HasRecommender, tg.Before.HasRecommender, c.RecommendUserID)))
+		// 校区这块**允许声明为"不建行"**(W4-B 的前提),所以 want 由声明算:
+		// 声明了 Absent ⇒ 两侧都**应当读不到**(want=1 要求"判定成立",不是要求"读到")。
+		wantCampus := 1
+		detail := fmt.Sprintf("legacy 读到=%v target 读到=%v(inst=%d campus=%d)",
+			lg.Before.HasRecommenderCampus, tg.Before.HasRecommenderCampus, c.InstitutionID, c.CampusID)
+		if c.Recommend != nil {
+			detail += "| 声明状态:" + c.Recommend.Referrer.state()
+			if c.Recommend.Referrer.Absent {
+				// 声明的就是"不建行" ⇒ 读到才算错。
+				ok := !lg.Before.HasRecommenderCampus && !tg.Before.HasRecommenderCampus
+				res.Assertions = append(res.Assertions, Assertion{Kind: AssertionInfrastructure,
+					Name: "case 有效性前提:**推荐人校区账户两侧都读不到**(本 case 声明了「不建行」)",
+					Pass: ok, Detail: detail,
+				})
+				detail = ""
+			}
+		}
+		if detail != "" {
+			nCampus := 0
+			if lg.Before.HasRecommenderCampus && tg.Before.HasRecommenderCampus {
+				nCampus = 1
+			}
+			res.Assertions = append(res.Assertions, mustCover(
+				"case 有效性前提:**推荐人校区账户两侧都读到了**(否则「校区 +m / 校区 NULL」不可断言)",
+				nCampus, wantCampus, detail))
+		}
 	}
 	if want := arrearsSnapshotRowsOrDefault(c); want > 0 {
 		n := minInt(len(lg.Before.Arrears), len(tg.Before.Arrears))

+ 29 - 16
internal/academic/dualrun/stage2/target.go

@@ -37,8 +37,35 @@ type targetSide struct {
 
 func newTargetSide(db *gorm.DB) *targetSide { return &targetSide{db: db} }
 
+// hasDispatch 标记"target 侧确实有这个 Op 的分支"。
+//
+// 为什么需要它(§18.3 第 0 条的同族:**判定块整体不执行**):
+// `OpUseActivityRecommend` 当初只加了枚举与 `String()`,故意没接 dispatch。
+// 那种 Op 一旦被 case 引用,switch 会落到 `default:`(= `Expenditure`!)——
+// 不是"什么都不做",而是**按消费把钱扣了**:两侧可能都成功、断言全绿,而测的根本不是老带新。
+// 更隐蔽的变体是 no-op 分支:两侧都不动 ⇒ 全 0 相等 ⇒ 同样全绿。
+//
+// 所以这里在**开事务之前**做一次显式白名单检查:没有分支就 FATAL,
+// 不许静默落到 default。这条同时护住以后所有新增 Op —— 加枚举忘了接实现会立刻炸。
+func hasDispatch(op Op) bool {
+	switch op {
+	case OpExpenditure, OpRecharge, OpSchoolRecharge, OpRefund, OpBadDebt, OpSpend,
+		OpTransfer, OpClearBalance, OpCustomRefund, OpSignedAdd, OpSignedCancel,
+		OpUseActivityRecharge, OpRefundActivityRecharge, OpUserDiscountUse,
+		OpUserDiscountRefund, OpUserDiscountRefundByActivity, OpCancelRechargeActivity,
+		OpCounteractArrears:
+		return true
+	}
+	return false
+}
+
 // Run 在事务内种基线、跑 service、读回、回滚。
 func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*SideResult, error) {
+	// 先查"有没有实现",再开事务 —— 落在 `default:` 上会被当成 `Expenditure`(真扣钱)。
+	if !hasDispatch(c.Op) {
+		return nil, fmt.Errorf("target 侧**没有 %s 的 dispatch 分支**:Op 加了枚举但没接实现。"+
+			"拒绝落到 `default:`(那是 Expenditure,会真的扣钱);补 branches 后再跑本 case", c.Op)
+	}
 	res := &SideResult{Side: "target"}
 	tx := s.db.WithContext(ctx).Begin()
 	if tx.Error != nil {
@@ -486,21 +513,6 @@ UPDATE t_user_expense_school SET balance=?, spend=?, total_income=?, total_expen
 		return fmt.Errorf("target 设置校区基线影响 %d 行(期望 1):%s inst=%d campus=%d 的校区账户不存在,基线没设上",
 			res.RowsAffected, canonicalUID, c.InstitutionID, c.CampusID)
 	}
-	// `total_recommended_amount` 三态,理由同 legacy 侧(nil = 不动 / 非 nil = 显式设值,含 0)。
-	if b.GlobalRecommended != nil {
-		if err := tx.Exec(`UPDATE t_user_expense SET total_recommended_amount=? WHERE user_id=?`,
-			yuan(*b.GlobalRecommended), canonicalUID).Error; err != nil {
-			return fmt.Errorf("target 设置全局 total_recommended_amount 基线失败:%w", err)
-		}
-	}
-	if b.CampusRecommended != nil {
-		if err := tx.Exec(`
-UPDATE t_user_expense_school SET total_recommended_amount=?
- WHERE user_id=? AND institution_id=? AND campus_id=?`,
-			yuan(*b.CampusRecommended), canonicalUID, c.InstitutionID, c.CampusID).Error; err != nil {
-			return fmt.Errorf("target 设置校区 total_recommended_amount 基线失败:%w", err)
-		}
-	}
 	return nil
 }
 
@@ -600,7 +612,8 @@ SELECT id, type, CAST(money AS DECIMAL(12,2)), IFNULL(log_id,''), IFNULL(request
 	if out.Activity, err = readActivitySnapshotTarget(ctx, tx, c, canonicalUID); err != nil {
 		return out, 0, err
 	}
-	if out.Recommender, out.HasRecommender, err = readRecommenderAccount(ctx, tx, c, true); err != nil {
+	if out.Recommender, out.HasRecommender, out.RecommenderCampus, out.HasRecommenderCampus, err =
+		readRecommenderAccounts(ctx, tx, c, true); err != nil {
 		return out, 0, err
 	}
 	if out.Arrears, err = readArrearsTarget(ctx, tx, c); err != nil {