Jelajahi Sumber

feat(ac15): 3d W4-A 老带新正常双边入账 —— 3b 第 4/5 块自本次起被激活(52→53)

## 一、W4-A 证明的唯一一件事:**两个用户各拿一份**

八个增量全部逐项比对并断言(少一个就会出现"镜像错误照样绿"):

```
被推荐人·全局 Δbalance +5000 / Δrecommended +5000
被推荐人·校区 Δbalance +5000 / Δrecommended +5000
推荐人  ·全局 Δbalance +5000 / Δrecommended +5000
推荐人  ·校区 Δbalance +5000 / Δrecommended +5000
```

另有正向断言「**两个用户**的 Δbalance 均 > 0,任一为 0 ⇒ FAIL」——
这条挡的是"两侧都不动 ⇒ 全 0 相等"那种绿(游标第一轮 LEAVE 的形态)。

## 二、3b 的两块快照自本次起**第一次被真正激活**

| 块 | 状态 | 证据 |
|---|---|---|
| 课酬应收(`Arrears`) | 3c 已激活 | 2 行 `本块比对了` |
| **推荐人全局账户** | **本次首次激活** | `本块比对了 1 项 | legacy 读到=true target 读到=true` |
| **推荐人校区账户** | **本次首次激活**(3d 新增块) | 同上,独立一行 |

⇒ 3b 的验收到这一步**才算完成**;此前只能记"已就位"。`grep "本块比对了"` 由 2 → **4**。

## 三、踩到并修掉的一个真坑:`Logs` 是**窗口**不是全表

`countLogsByType` 第一版写成 `afterCount − beforeCount`,跑出 **−2**。
原因是 `SideSnapshot.Logs` 按 id 倒序取最近 N 条:新增两条后窗口滑动、旧的被挤出。
负数不是"流水变少了",是**窗口变了**。
改成"按 id 集合求差"(新增行 id 最大、必在窗口内),结果 1 / 1。
⇒ 凡是"数本次新增"的断言,**都不能用计数差**,必须用集合差。

## 四、账户种子:三态落库 + 读回校验(FATAL 而非红)

`AccountSeed`(Absent / 列 NULL / 显式值)用 `DELETE` + `INSERT` 绝对值落两侧;
写完**读回来逐列比**,不一致直接 FATAL —— 因为三种状态写错的方向都是"看起来种上了",
不会让任何业务断言红,只会让 case 测出一个别的东西。

## 五、口径与未覆盖,两条都显式登记

- `total_recommended_amount` 在 W4-A 里**显式种 0**(不是留 NULL):真实行这一列很多是 NULL,
  `NULL + m` 仍是 NULL ⇒ 不种就会让 W4-A 变成第二条 W4-B,而"两人各一份"反而没人钉。
- **推荐人那条 type=107 流水仍不可断言**:共享快照只收本 case 用户的流水。
  已写成 `Skipped` 的显式断言(打 `—`,不算通过),并写明补齐方式(加 `RecommenderLogs`)。
  **不默认没测。**

## 报数
DefaultCases 53 / 实跑 53 / PASS 53 / FAIL 0 / 崩溃 0
Skipped 99 | 业务 318 | 基建 110 | 未分类 0
代价 SQL 3084 次 / 28.308s / 58.2 次 per case / 534ms per case
grep "本块比对了" = 4(课酬 2 + 推荐人全局 1 + 推荐人校区 1)
形态:活动 15(2/2/1/10)| 课酬 2(1/1)| 跨域 17 + 资金 36 = 53
未 push
Q 8 jam lalu
induk
melakukan
0ab449f5e7

+ 19 - 0
internal/academic/dualrun/stage2/legacy.go

@@ -73,6 +73,10 @@ const (
 	// ⚠ 第一个参数是**学生** id,不是 userid。这一支与资金域全部 21 支的根本区别就在这里:
 	//   钱一分不流动,只改 `t_courses_student` 的两列。
 	callCounteractArrears = "CALL sp_courses_student_counteract_arrears_course(?, ?, ?, ?)"
+
+	// SP18 老带新:(userid, order, remark, createUserid) —— 4 参,**有结果集**
+	//(正文末尾 `SELECT recommendUserid,selectSchoolid,selectCampusid,success`)。
+	callUseActivityRecommend = "CALL sp_courses_activity_use_activity_recommend(?, ?, ?, ?)"
 )
 
 // returnsResultSet 标记"该 SP 会 SELECT 出结果集"。
@@ -93,6 +97,10 @@ func returnsResultSet(op Op) bool {
 	// 把它们的调用走 `Rows()` 会拿到空结果集,`Success` 永远是 false —— 那会让 4 条 case 假红。
 	case OpSignedAdd, OpSignedCancel:
 		return true
+	// SP18 老带新:正文末尾 `SELECT recommendUserid,selectSchoolid,selectCampusid,success`。
+	// 不读结果集的话:`Exec` 会留下未消费的结果集,且 `success=0`(LEAVE 分支)看不出来。
+	case OpUseActivityRecommend:
+		return true
 	}
 	return false
 }
@@ -149,6 +157,10 @@ func callArgs(c Case) []any {
 	case OpCounteractArrears:
 		// SP27:(studentid, schoolid, campusid, money) —— 第一个是**学生**,不是 userid。
 		return []any{c.LegacyStudentID, c.InstitutionID, c.CampusID, moneyYuan}
+	case OpUseActivityRecommend:
+		// SP18:(userid, order, remark, createUserid)。`order` 同时充当 107 流水的 `logid`
+		//(varchar(32),所以 `StuCourseID` 不能超过 32 字符)。
+		return []any{c.LegacyUserID, c.StuCourseID, c.Remark, c.LegacyUserID}
 	default:
 		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan,
 			c.Type, c.RequestID, c.Remark, c.LegacyUserID}
@@ -192,6 +204,8 @@ func callStmt(op Op) string {
 		return callUserDiscountRefundByActivity
 	case OpCounteractArrears:
 		return callCounteractArrears
+	case OpUseActivityRecommend:
+		return callUseActivityRecommend
 	default:
 		return callExpenditure
 	}
@@ -233,6 +247,11 @@ UPDATE t_user_expense_school SET balance = NULL
 	if err := seedLegacyArrears(ctx, tx, c); err != nil {
 		return nil, err
 	}
+	// 老带新的**两用户账户**种子(W4)。必须在 before 快照之前 ——
+	// 这一支比的就是"两个用户的四个增量",before 读不到就等于把增量算成凭空出现。
+	if err := seedLegacyRecommend(ctx, tx, c); err != nil {
+		return nil, err
+	}
 
 	before, maxLogID, err := snapshotLegacy(ctx, tx, c)
 	if err != nil {

+ 307 - 0
internal/academic/dualrun/stage2/recommend_accounts.go

@@ -0,0 +1,307 @@
+package stage2
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/dualrun/fixture"
+)
+
+// 本文件是 `RecommendSeed` 的两侧落库:把"两个用户各自处于哪种账户状态"造出来。
+//
+// 为什么用 `DELETE` + `INSERT` 绝对值、而不是 `UPDATE`:
+//
+//	`UPDATE` 只能改"已经存在的行",而本 seed 要表达的第一种状态就是**行不存在**
+//	(W4-B 的触发条件)。而且绝对值能给"NULL / 0 / 非 0"三种状态各自定位,
+//	`+`/`-` 做不到。事务内 DELETE 随回滚恢复,不会污染真实数据。
+//
+// 为什么写完要**读回来比一次**:
+//
+//	三种状态下"写错"的方向都是"看起来种上了"(NULL 写成 0、0 写成 NULL、该删的没删),
+//	而它们**不会让任何业务断言红**,只会让 case 测出一个别的东西。
+//	所以必须在种子阶段就把"种出来的 == 声明的"钉死;不一致直接 FATAL(不是红一条断言)。
+
+// recommendAccountState 是"读回来的实际状态",用于和声明比对。
+type recommendAccountState struct {
+	GlobalExists      bool
+	GlobalBalance     *int64
+	GlobalRecommended *int64
+	CampusExists      bool
+	CampusBalance     *int64
+	CampusRecommended *int64
+}
+
+// seedLegacyRecommend 在 legacy 事务内造两侧账户(`t_user_expense` + `t_user_expense_school`)。
+func seedLegacyRecommend(ctx context.Context, tx *gorm.DB, c Case) error {
+	if c.Recommend == nil {
+		return nil
+	}
+	if err := c.Recommend.validate(c); err != nil {
+		return err
+	}
+	for _, u := range []struct {
+		legacyID int64
+		seed     AccountSeed
+		role     string
+	}{
+		{c.LegacyUserID, c.Recommend.Referred, "被推荐人"},
+		{c.RecommendUserID, c.Recommend.Referrer, "推荐人"},
+	} {
+		// 先无条件清干净:不能假设库里有/没有。target 库整库为空,legacy 侧则
+		// 可能有 `INSERT…SELECT FROM finance_bk` 搬进来的真实行 —— 不清就会
+		// 把"不建行"那条 case 种成"行还在",而它在报告上只表现为"断言不成立"。
+		if err := tx.Exec(`DELETE FROM t_user_expense_school WHERE userid=? AND schoolid=? AND campusid=?`,
+			u.legacyID, c.InstitutionID, c.CampusID).Error; err != nil {
+			return fmt.Errorf("清 legacy %s 校区账户失败:%w", u.role, err)
+		}
+		if err := tx.Exec(`DELETE FROM t_user_expense WHERE userid=?`, u.legacyID).Error; err != nil {
+			return fmt.Errorf("清 legacy %s 全局账户失败:%w", u.role, err)
+		}
+		if u.seed.Absent {
+			continue
+		}
+		// `balance` 显式给 NULL 或值;`total_recommended_amount` 同理。
+		// 其余四列(spend/total_income/total_expenditure/special_expenses)本 case 不关心,
+		// 给 0(legacy 侧 t_user_expense 有 DEFAULT 0.000,_school 那四列无 DEFAULT ⇒ 必须显式写)。
+		if err := tx.Exec(`
+INSERT INTO t_user_expense (userid, balance, spend, total_income, total_expenditure, total_recommended_amount, special_expenses)
+VALUES (?, ?, 0, 0, 0, ?, 0)`,
+			u.legacyID, nullableCents(u.seed.Balance), nullableCents(u.seed.Recommended)).Error; err != nil {
+			return fmt.Errorf("种 legacy %s 全局账户失败:%w", u.role, err)
+		}
+		if err := tx.Exec(`
+INSERT INTO t_user_expense_school (userid, schoolid, campusid, balance, spend, total_income, total_expenditure, total_recommended_amount, special_expenses)
+VALUES (?, ?, ?, ?, 0, 0, 0, ?, 0)`,
+			u.legacyID, c.InstitutionID, c.CampusID,
+			nullableCents(u.seed.Balance), nullableCents(u.seed.Recommended)).Error; err != nil {
+			return fmt.Errorf("种 legacy %s 校区账户失败:%w", u.role, err)
+		}
+	}
+	return verifyLegacyRecommendSeed(ctx, tx, c)
+}
+
+// seedTargetRecommend 同上(target 列名/ULID 不同)。
+func seedTargetRecommend(ctx context.Context, tx *gorm.DB, c Case) error {
+	if c.Recommend == nil {
+		return nil
+	}
+	if err := c.Recommend.validate(c); err != nil {
+		return err
+	}
+	for _, u := range []struct {
+		legacyID int64
+		seed     AccountSeed
+		role     string
+	}{
+		{c.LegacyUserID, c.Recommend.Referred, "被推荐人"},
+		{c.RecommendUserID, c.Recommend.Referrer, "推荐人"},
+	} {
+		uid := fixture.FixtureCanonicalUserID(u.legacyID)
+		if err := tx.Exec(`DELETE FROM t_user_expense_school WHERE user_id=? AND institution_id=? AND campus_id=?`,
+			uid, c.InstitutionID, c.CampusID).Error; err != nil {
+			return fmt.Errorf("清 target %s 校区账户失败:%w", u.role, err)
+		}
+		if err := tx.Exec(`DELETE FROM t_user_expense WHERE user_id=?`, uid).Error; err != nil {
+			return fmt.Errorf("清 target %s 全局账户失败:%w", u.role, err)
+		}
+		if u.seed.Absent {
+			continue
+		}
+		// target 这几列是 `NOT NULL DEFAULT 0.00` —— **存不下 NULL**。
+		// 所以 target 侧只能是"有值"(`Balance`/`Recommended` 为 nil 时按 0 落)。
+		// 这一点必须显式说明:否则看代码的人会以为两侧种子同构,而实际上
+		// "行在列 NULL"这个状态在 target 侧**不可表达**(和 C2-7 的 `balance` 同理)。
+		if err := tx.Exec(`
+INSERT INTO t_user_expense (user_id, balance, spend, total_income, total_expenditure, total_recommended_amount, special_expenses)
+VALUES (?, ?, 0, 0, 0, ?, 0)`,
+			uid, centsOrDefault(u.seed.Balance), centsOrDefault(u.seed.Recommended)).Error; err != nil {
+			return fmt.Errorf("种 target %s 全局账户失败:%w", u.role, err)
+		}
+		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)
+VALUES (?, ?, ?, ?, 0, 0, 0, ?, 0)`,
+			uid, c.InstitutionID, c.CampusID,
+			centsOrDefault(u.seed.Balance), centsOrDefault(u.seed.Recommended)).Error; err != nil {
+			return fmt.Errorf("种 target %s 校区账户失败:%w", u.role, err)
+		}
+	}
+	return verifyTargetRecommendSeed(ctx, tx, c)
+}
+
+// nullableCents 把 `*int64` 变成 `any`:nil 落 SQL NULL。
+func nullableCents(p *int64) any {
+	if p == nil {
+		return nil
+	}
+	return arrearsMoney(*p)
+}
+
+// centsOrDefault 用于 target(列 NOT NULL):nil 落 0。
+func centsOrDefault(p *int64) string {
+	if p == nil {
+		return arrearsMoney(0)
+	}
+	return arrearsMoney(*p)
+}
+
+// readLegacyRecommendState 读回 legacy 侧某用户的两张账户(不存在 ⇒ Exists=false)。
+func readLegacyRecommendState(ctx context.Context, tx *gorm.DB, c Case, legacyID int64) (recommendAccountState, error) {
+	var st recommendAccountState
+	var gb, gr, cb, cr *string
+	err := tx.Raw(`SELECT CAST(balance AS DECIMAL(20,3)), CAST(total_recommended_amount AS DECIMAL(20,3))
+  FROM t_user_expense WHERE userid=?`, legacyID).Row().Scan(&gb, &gr)
+	if err == nil {
+		st.GlobalExists = true
+		st.GlobalBalance, st.GlobalRecommended = centsPtr(gb), centsPtr(gr)
+	} else if !isNoRows(err) {
+		return st, fmt.Errorf("读回 legacy 全局账户失败:%w", err)
+	}
+	err = tx.Raw(`SELECT CAST(balance AS DECIMAL(20,3)), CAST(total_recommended_amount AS DECIMAL(20,3))
+  FROM t_user_expense_school WHERE userid=? AND schoolid=? AND campusid=?`,
+		legacyID, c.InstitutionID, c.CampusID).Row().Scan(&cb, &cr)
+	if err == nil {
+		st.CampusExists = true
+		st.CampusBalance, st.CampusRecommended = centsPtr(cb), centsPtr(cr)
+	} else if !isNoRows(err) {
+		return st, fmt.Errorf("读回 legacy 校区账户失败:%w", err)
+	}
+	return st, nil
+}
+
+// verifyLegacyRecommendSeed 断言"种出来的状态 == 声明的状态"。
+func verifyLegacyRecommendSeed(ctx context.Context, tx *gorm.DB, c Case) error {
+	for _, u := range []struct {
+		legacyID int64
+		seed     AccountSeed
+		role     string
+	}{
+		{c.LegacyUserID, c.Recommend.Referred, "被推荐人"},
+		{c.RecommendUserID, c.Recommend.Referrer, "推荐人"},
+	} {
+		st, err := readLegacyRecommendState(ctx, tx, c, u.legacyID)
+		if err != nil {
+			return err
+		}
+		if err := compareSeedState(u.role, u.seed, st); err != nil {
+			return fmt.Errorf("case %q:%w", c.Name, err)
+		}
+	}
+	return nil
+}
+
+// verifyTargetRecommendSeed 同上(target 侧 NULL 不可表达,按 0 比)。
+func verifyTargetRecommendSeed(ctx context.Context, tx *gorm.DB, c Case) error {
+	for _, u := range []struct {
+		legacyID int64
+		seed     AccountSeed
+		role     string
+	}{
+		{c.LegacyUserID, c.Recommend.Referred, "被推荐人"},
+		{c.RecommendUserID, c.Recommend.Referrer, "推荐人"},
+	} {
+		uid := fixture.FixtureCanonicalUserID(u.legacyID)
+		var gb, gr, cb, cr *string
+		var st recommendAccountState
+		err := tx.Raw(`SELECT CAST(balance AS DECIMAL(20,3)), CAST(total_recommended_amount AS DECIMAL(20,3))
+  FROM t_user_expense WHERE user_id=?`, uid).Row().Scan(&gb, &gr)
+		if err == nil {
+			st.GlobalExists = true
+			st.GlobalBalance, st.GlobalRecommended = centsPtr(gb), centsPtr(gr)
+		} else if !isNoRows(err) {
+			return fmt.Errorf("读回 target 全局账户失败:%w", err)
+		}
+		err = tx.Raw(`SELECT CAST(balance AS DECIMAL(20,3)), CAST(total_recommended_amount AS DECIMAL(20,3))
+  FROM t_user_expense_school WHERE user_id=? AND institution_id=? AND campus_id=?`,
+			uid, c.InstitutionID, c.CampusID).Row().Scan(&cb, &cr)
+		if err == nil {
+			st.CampusExists = true
+			st.CampusBalance, st.CampusRecommended = centsPtr(cb), centsPtr(cr)
+		} else if !isNoRows(err) {
+			return fmt.Errorf("读回 target 校区账户失败:%w", err)
+		}
+		// target 的列 NOT NULL ⇒ 声明里的 nil("列为 NULL")在这一侧不适用,
+		// 按 0 参与比对;这一点在 `seedTargetRecommend` 里已写过原因。
+		normalized := u.seed
+		if !normalized.Absent {
+			if normalized.Balance == nil {
+				zero := int64(0)
+				normalized.Balance = &zero
+			}
+			if normalized.Recommended == nil {
+				zero := int64(0)
+				normalized.Recommended = &zero
+			}
+		}
+		if err := compareSeedState(u.role+"(target)", normalized, st); err != nil {
+			return fmt.Errorf("case %q:%w", c.Name, err)
+		}
+	}
+	return nil
+}
+
+// compareSeedState 把"声明"与"读回"逐列比。
+func compareSeedState(role string, seed AccountSeed, st recommendAccountState) error {
+	if seed.Absent {
+		if st.GlobalExists || st.CampusExists {
+			return fmt.Errorf("%s 声明「不建行」,但读回:全局行=%v 校区行=%v",
+				role, st.GlobalExists, st.CampusExists)
+		}
+		return nil
+	}
+	if !st.GlobalExists || !st.CampusExists {
+		return fmt.Errorf("%s 声明「行在」,但读回:全局行=%v 校区行=%v",
+			role, st.GlobalExists, st.CampusExists)
+	}
+	if !sameCents(seed.Balance, st.GlobalBalance) || !sameCents(seed.Balance, st.CampusBalance) {
+		return fmt.Errorf("%s 的 balance 与声明不符:声明 %s,全局读回 %s、校区读回 %s",
+			role, fmtCentsPtr(seed.Balance), fmtCentsPtr(st.GlobalBalance), fmtCentsPtr(st.CampusBalance))
+	}
+	if !sameCents(seed.Recommended, st.GlobalRecommended) || !sameCents(seed.Recommended, st.CampusRecommended) {
+		return fmt.Errorf("%s 的 total_recommended_amount 与声明不符:声明 %s,全局读回 %s、校区读回 %s",
+			role, fmtCentsPtr(seed.Recommended), fmtCentsPtr(st.GlobalRecommended), fmtCentsPtr(st.CampusRecommended))
+	}
+	return nil
+}
+
+// ---- 三态小工具(读回校验用)----
+
+// centsPtr 把"可空十进制字符串"转成 `*int64`:NULL/空 ⇒ nil(**不是 0**)。
+func centsPtr(s *string) *int64 {
+	if s == nil || *s == "" {
+		return nil
+	}
+	v, err := fixture.DecimalToCents(*s)
+	if err != nil {
+		return nil
+	}
+	return &v
+}
+
+// isNoRows 判断"没有行",用于把"行不存在"与"读失败"分开。
+//
+// 为什么要分开:`Row().Scan` 在没有行时返回 `sql.ErrNoRows`。
+// 把它和真错误混在一起,会让"账户不存在(本 case 故意造的)"被当成崩溃。
+func isNoRows(err error) bool { return errors.Is(err, sql.ErrNoRows) }
+
+// sameCents 比较"声明值"与"读回值",**nil 与 0 必须不同**。
+//
+// 这就是本文件存在的理由:W4-B 的缺陷是 NULL 不是 0,
+// 如果这里把 nil 当成 0,"种成 NULL"与"种成 0"就会得到同一个判定。
+func sameCents(want *int64, got *int64) bool {
+	if want == nil || got == nil {
+		return want == nil && got == nil
+	}
+	return *want == *got
+}
+
+// fmtCentsPtr 渲染三态金额:nil 显示 "NULL",不是 "0.00"。
+func fmtCentsPtr(p *int64) string {
+	if p == nil {
+		return "NULL"
+	}
+	return moneyStr(*p, false)
+}

+ 155 - 0
internal/academic/dualrun/stage2/recommend_compare.go

@@ -0,0 +1,155 @@
+package stage2
+
+import "fmt"
+
+// 本文件是**老带新(SP18)**的比对。
+//
+// 为什么不能复用通用比对:通用比对的主语是"**一个用户**的 5 个资金增量",
+// 而这一支的核心语义是"**两个用户各拿一份**"。只比一个人,镜像错误
+//(两边都只给推荐人 / 都只给被推荐人)会照样绿 —— 那正是 3b 第 4 块存在的理由。
+//
+// 四个增量 × 两个用户 = 8 个数,全部要出现:
+//
+//	全局 balance / 全局 total_recommended_amount / 校区 balance / 校区 total_recommended_amount
+//
+// 另外活动域两项:`signed.status` 必须变 2、`used_log` 必须恰好新增 1 条。
+
+// recommendCompare 比对老带新的一侧三域(资金两用户 + 活动结算态)。
+func recommendCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
+	var diffs []Diff
+	var asserts []Assertion
+
+	add := func(item, l, t string, equal bool, note string) {
+		diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
+	}
+
+	type acct struct {
+		name           string
+		lgA, lgB       Account
+		tgA, tgB       Account
+		lgHasA, lgHasB bool
+		tgHasA, tgHasB bool
+	}
+	// 四个账户,两两一组(A=调用前,B=调用后)。
+	groups := []acct{
+		{"被推荐人·全局", lg.Before.Global, lg.After.Global, tg.Before.Global, tg.After.Global, true, true, true, true},
+		{"被推荐人·校区", lg.Before.Campus, lg.After.Campus, tg.Before.Campus, tg.After.Campus,
+			lg.Before.HasCampusAccount, lg.After.HasCampusAccount, tg.Before.HasCampusAccount, tg.After.HasCampusAccount},
+		{"推荐人·全局", lg.Before.Recommender, lg.After.Recommender, tg.Before.Recommender, tg.After.Recommender,
+			lg.Before.HasRecommender, lg.After.HasRecommender, tg.Before.HasRecommender, tg.After.HasRecommender},
+		{"推荐人·校区", lg.Before.RecommenderCampus, lg.After.RecommenderCampus,
+			tg.Before.RecommenderCampus, tg.After.RecommenderCampus,
+			lg.Before.HasRecommenderCampus, lg.After.HasRecommenderCampus,
+			tg.Before.HasRecommenderCampus, tg.After.HasRecommenderCampus},
+	}
+
+	// W4-A 的期望:每个用户、每张表、`balance` 与 `total_recommended_amount` 各 +m。
+	want := c.AmountCents
+	for _, g := range groups {
+		lgDBal := g.lgB.Balance - g.lgA.Balance
+		lgDRec := g.lgB.Recommended - g.lgA.Recommended
+		tgDBal := g.tgB.Balance - g.tgA.Balance
+		tgDRec := g.tgB.Recommended - g.tgA.Recommended
+		add(g.name+"·Δbalance", fmt.Sprintf("%+d", lgDBal), fmt.Sprintf("%+d", tgDBal),
+			lgDBal == tgDBal, "分")
+		add(g.name+"·Δtotal_recommended_amount", fmt.Sprintf("%+d", lgDRec), fmt.Sprintf("%+d", tgDRec),
+			lgDRec == tgDRec, "分")
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: fmt.Sprintf("%s 两侧 Δbalance 与 Δtotal_recommended_amount 都是 +m", g.name),
+			Pass: lgDBal == want && tgDBal == want && lgDRec == want && tgDRec == want,
+			Detail: fmt.Sprintf("m=%d | legacy bal %+d / rec %+d | target bal %+d / rec %+d",
+				want, lgDBal, lgDRec, tgDBal, tgDRec),
+		})
+	}
+
+	// 正向断言(F22 同族):**两个用户**的钱都真的动了。
+	// 少了这条,"两侧都不动 ⇒ 全 0 相等"会全绿 —— 而那正是"游标没进循环"的形态。
+	lgUser := lg.After.Global.Balance - lg.Before.Global.Balance
+	tgUser := tg.After.Global.Balance - tg.Before.Global.Balance
+	lgRec := lg.After.Recommender.Balance - lg.Before.Recommender.Balance
+	tgRec := tg.After.Recommender.Balance - tg.Before.Recommender.Balance
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "本 case 的**两个用户**的钱都真的动了(被推荐人与推荐人的 Δbalance 均 > 0;任一为 0 ⇒ FAIL)",
+		Pass: lgUser > 0 && tgUser > 0 && lgRec > 0 && tgRec > 0,
+		Detail: fmt.Sprintf("legacy 被推荐人 %+d / 推荐人 %+d | target 被推荐人 %+d / 推荐人 %+d。"+
+			"若为 0:先查 signed 行是否种了 recommend_userid 与 activity_money(游标第一轮就 LEAVE 的形态)",
+			lgUser, lgRec, tgUser, tgRec),
+	})
+
+	// 107 流水:**每人一条**(`type=107`、`money=+m`)。
+	// 只数本 case 用户的(推荐人的流水不在共享快照里,见文件末的未覆盖说明)。
+	lgLogs := countLogsByType(lg.Before, lg.After, 107)
+	tgLogs := countLogsByType(tg.Before, tg.After, 107)
+	add("被推荐人新增 type=107 流水条数", fmt.Sprint(lgLogs), fmt.Sprint(tgLogs), lgLogs == tgLogs, "")
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name:   "被推荐人新增**恰好 1 条** type=107 流水(奖励入账凭证)",
+		Pass:   lgLogs == 1 && tgLogs == 1,
+		Detail: fmt.Sprintf("legacy=%d target=%d(期望各 1)。0 条 = 没入账;2 条 = 重复入账", lgLogs, tgLogs),
+	})
+
+	// 活动结算态:`signed.status` 变 2。
+	lgSt, tgSt := signedStatus(lg, c.ActivityID), signedStatus(tg, c.ActivityID)
+	add("报名状态(结算后)", fmt.Sprint(lgSt), fmt.Sprint(tgSt), lgSt == tgSt, "dump: status=2")
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name:   "活动结算态:报名状态两侧都变 2(`UPDATE t_courses_activity_signed SET status=2`)",
+		Pass:   lgSt == 2 && tgSt == 2,
+		Detail: fmt.Sprintf("legacy=%v target=%v(期望 2)", lgSt, tgSt),
+	})
+
+	// 调用结果。
+	lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
+	tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
+	add("首次调用是否成功", fmt.Sprint(lgOK), fmt.Sprint(tgOK), lgOK == tgOK || c.IntentionalStrict, "")
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "首次调用结果与预期一致",
+		Pass: lgOK == c.ExpectLegacySuccess && tgOK == c.ExpectTargetSuccess,
+		Detail: fmt.Sprintf("legacy success=%v(期望 %v,err=%q);target success=%v(期望 %v,err=%q)",
+			lgOK, c.ExpectLegacySuccess, errOf(lg.Calls), tgOK, c.ExpectTargetSuccess, errOf(tg.Calls)),
+	})
+
+	// 未覆盖项显式登记(不允许"没测"冒充"测过")。
+	asserts = append(asserts, Assertion{Kind: AssertionInfrastructure, Skipped: true,
+		Name: "未覆盖:**推荐人**那条 type=107 流水(共享快照只收本 case 用户的流水)",
+		Pass: true,
+		Detail: "推荐人的 `t_user_expense_log` 不在 `SideSnapshot.Logs` 里 ⇒ 本条打 `—` 不算通过。" +
+			"「两人各一条凭证」目前由「两个用户 Δbalance/Δrecommended 都 =+m」间接支撑;" +
+			"补齐方式:给快照加 `RecommenderLogs`(未做,**主动登记**而不是默认没测)",
+	})
+
+	return diffs, asserts
+}
+
+// countLogsByType 数"本次**新增**"的某类流水条数。
+//
+// 为什么不能写成 `afterCount − beforeCount`(本轮真踩了,出来是 **−2**):
+//
+//	`SideSnapshot.Logs` 是**窗口**(按 id 倒序取最近若干条),不是全表。
+//	新增两条后窗口滑动,旧的两条被挤出去 ⇒ 差值可能是负数,
+//	而负数的含义是"快照窗口变了",不是"流水变少了"。
+//
+// 正确判据是**按 id 集合求差**:`after` 里 id 不在 `before` 集合中的那些行就是本次新增。
+// 新增行的 id 最大,必然落在窗口内,所以这个判据不受窗口大小影响。
+func countLogsByType(before, after SideSnapshot, typ int32) int {
+	seen := make(map[int64]bool, len(before.Logs))
+	for _, r := range before.Logs {
+		seen[r.ID] = true
+	}
+	n := 0
+	for _, r := range after.Logs {
+		if r.Type == typ && !seen[r.ID] {
+			n++
+		}
+	}
+	return n
+}
+
+// signedStatus 取某活动的报名状态(读不到返回 −1,与"状态是 0"区分开)。
+func signedStatus(s *SideResult, activityID int64) int32 {
+	if s.After.Signed == nil {
+		return -1
+	}
+	if v, ok := s.After.Signed[activityID]; ok {
+		return v
+	}
+	return -1
+}

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

@@ -96,3 +96,10 @@ func (s *RecommendSeed) validate(c Case) error {
 	}
 	return nil
 }
+
+// i64 取一个 int64 的指针(构造 `AccountSeed` 的显式值时用)。
+//
+// 有它就写不出 `Balance: &[]int64{0}[0]` 这种为了拿地址而造切片的怪写法;
+// 而 `Balance: nil` 与 `Balance: i64(0)` 在类型上**长得一样但含义相反**(NULL vs 0),
+// 所以这个函数必须是显式的、有名字的 —— 让 diff 里"从 0 改成 NULL"也能被看出来。
+func i64(v int64) *int64 { return &v }

+ 42 - 1
internal/academic/dualrun/stage2/run.go

@@ -815,6 +815,42 @@ func DefaultCases(_ string) []Case {
 		},
 	)
 
+	// ---- 批 3d:W4 老带新(SP18)----
+	//
+	// 唯一**跨三域、两用户**的一支:资金(两人各 +m)+ 活动(used_log/signed 结算态)
+	// + 课酬(两次嵌调 counteract_arrears)。所以三条 case 都是"三域同时验"。
+	out = append(out,
+		Case{
+			// W4-A:正常老带新。它存在的理由只有一句 —— **两个用户各拿一份**。
+			//
+			// 为什么要两个用户都种账户:3b 第 4 块(推荐人账户快照)就是为了这个。
+			// 只种一侧、"两侧总额相等"照样绿 —— 镜像错误(两边都只给一个人)看不出来。
+			//
+			// 为什么 `total_recommended_amount` 要显式种 0(而不是留 NULL):
+			// 真实账户行的这一列**很多是 NULL**,而 `NULL + m` 仍是 NULL ⇒
+			// W4-A 会测成"legacy 没记推荐金额"—— 那是 W4-B 要证明的事,不该让 W4-A 承担。
+			// 种 0 才能让本条只回答"两个人各拿一份了吗"。
+			Name:         "W4-A 老带新·正常双边入账(两个用户各 +m,两条 type=107)",
+			Op:           OpUseActivityRecommend,
+			LegacyUserID: 17735, RecommendUserID: 17736,
+			InstitutionID: 63317, CampusID: 5, AmountCents: 5000,
+			StuCourseID: "ac15-w4a-order",
+			Activity: &ActivitySeed{Items: []ActivityItem{{
+				Type: 4, InstitutionID: 63317, CampusID: 5,
+				SignedStatus: 1, MoneyCents: 5000, RecommendUserID: 17736,
+			}}},
+			Recommend: &RecommendSeed{
+				Referred: AccountSeed{Balance: i64(10000), Recommended: i64(0)},
+				Referrer: AccountSeed{Balance: i64(0), Recommended: i64(0)},
+			},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "活动 type=4、`money=50.00`;报名行 `recommend_userid=17736`、`activity_money=50.00`;入参 order=`ac15-w4a-order`。" +
+				"dump 语义:游标取本人的 type=4 报名 → 无 used_log(非幂等)→ 被推荐人 `balance +50` 且 `total_recommended_amount +50`(双表)" +
+				"→ 107 流水一条 → 核销欠费(无欠费 ⇒ no-op)→ 推荐人同样一套 → used_log(−50) → `signed.status=2`、`activity_money_used=50`。" +
+				"断言重点:**四个账户 × 两个用户 = 8 个增量都为 +m**;任一为 0 ⇒ FAIL(防'两侧都不动也全绿')。",
+		},
+	)
+
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
@@ -944,7 +980,7 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 		if c.Op == OpCancelRechargeActivity ||
 			c.Op == OpSignedAdd || c.Op == OpSignedCancel ||
 			c.Op == OpUseActivityRecharge || c.Op == OpUserDiscountUse ||
-			c.Op == OpUserDiscountRefundByActivity {
+			c.Op == OpUserDiscountRefundByActivity || c.Op == OpUseActivityRecommend {
 			if len(c.ActivityPlan.Items) > 0 {
 				c.ActivityID = c.ActivityPlan.Items[0].ID
 			}
@@ -1220,6 +1256,11 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		return arrearsCompare(c, lg, tg)
 	}
 
+	// ⓪-1c 老带新(SP18):**跨三域两用户** ⇒ 既不是通用比对也不是活动域比对。
+	if c.Op == OpUseActivityRecommend {
+		return recommendCompare(c, lg, tg)
+	}
+
 	// ⓪-1 SP9 自定义退费:legacy 把金额整数化、target 用精确值 ⇒ **per-side 比对**
 	// (通用比对的"两侧 Δ 相等"在这一支上必然红,因为两侧本来就是两个算法)。
 	//

+ 22 - 1
internal/academic/dualrun/stage2/target.go

@@ -53,7 +53,7 @@ func hasDispatch(op Op) bool {
 		OpTransfer, OpClearBalance, OpCustomRefund, OpSignedAdd, OpSignedCancel,
 		OpUseActivityRecharge, OpRefundActivityRecharge, OpUserDiscountUse,
 		OpUserDiscountRefund, OpUserDiscountRefundByActivity, OpCancelRechargeActivity,
-		OpCounteractArrears:
+		OpCounteractArrears, OpUseActivityRecommend:
 		return true
 	}
 	return false
@@ -89,6 +89,11 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 	if err := seedTargetArrears(ctx, tx, c); err != nil {
 		return nil, err
 	}
+	// 老带新两用户账户种子(W4)。target 侧那几列是 NOT NULL ⇒ "行在列 NULL" 不可表达,
+	// 已在 `seedTargetRecommend` 里写明;W4-B 的 NULL 毒化只在 legacy 侧发生。
+	if err := seedTargetRecommend(ctx, tx, c); err != nil {
+		return nil, err
+	}
 
 	before, maxLogID, err := snapshotTarget(ctx, tx, canonicalUID, c)
 	if err != nil {
@@ -320,6 +325,22 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 				oc.Balance = int64(out.Balance)
 				oc.LogID = out.LogID
 			}
+		case OpUseActivityRecommend:
+			// SP18 → RecommendService.SettleRecommend(单事务 6 步,含两次欠费核销)。
+			//
+			// `Settled=false` 是 dump 的 LEAVE 分支(无推荐人 / 金额<=0 / 回查不到校区),
+			// 那一支**零写入**;`Repeated=true` 是 used_log 幂等命中。
+			out, err := recommendSvc.SettleRecommend(ctx, service.SettleRecommendInput{
+				UserID: canonicalUID, OrderNo: c.StuCourseID,
+				Remark: c.Remark, CreateUserID: canonicalUID,
+			})
+			if err != nil {
+				setCallErr(&oc, err)
+			} else {
+				oc.Success, oc.Repeated = out.Settled, out.Repeated
+				oc.Balance = int64(out.UserBalance)
+				oc.LogID = out.UserLogID
+			}
 		case OpCounteractArrears:
 			// SP27 → MaintenanceService.CounteractArrears。
 			//