Ver código fonte

feat(ac15): 3b 两块新快照(推荐人账户 + 课酬应收)——纯基建,case 数仍 50

## 一、第 4 块:推荐人账户(`SideSnapshot.Recommender` / `HasRecommender`)
按 `c.RecommendUserID` 读 `t_user_expense` 的六列三态(含 `total_recommended_amount`)。
为什么必须有:老带新的核心语义是"**两个用户各拿一份**"。少了这块,"推荐人也 +m"只能靠
"两侧总额恰好相等"侧面推 —— 而**镜像错误(两边都只给一个人)照样绿**。

## 二、第 5 块:课酬应收(`SideSnapshot.Arrears []ArrearsRow`,**有序**)
· 锚用 `biz_no`(两侧同一旧 UUID)。**不用 target 的 `id`(自增,两侧不可比)**,
  也不用 `courses_student_no`(那是活动域那张表的列,红线「禁止新写入」,见 §19)。
· **按核销 cursor 的顺序**(`ORDER BY t2.start_time ASC`,末尾用锚兜底保证确定性)。
  理由:`counteract_arrears_course` 的排序本身就是被验对象 ——
  按 id 或无序取会把"先核哪一行"这个分歧抹平。
· **故意不加 cursor 的两个过滤**(`remainder>0` / `confirm>0`):
  把"该进候选却没进"的反例行也读出来,否则过滤条件写错永远测不到
  (过滤条件没有反例输入 = 等于没测,同 §18.3 第 0 条)。
· 顺带删掉旧的 `ActivitySnapshot.Students`(map 版、无序),避免**两个真相源**。

## 三、防"静默不比对"的两条机制(§18.3 第 0 / 2 条)
· 新增 `mustCover(name, n, want, detail)`:**N 必须 ≥ want 且 > 0**,否则 FAIL。
  两块各配一条基建断言("两侧都读到了 + 条数 == 声明"),detail 里显式打"本块比对了 N 项"。
· 单测 `TestMustCoverZeroIsFail` 锁住 `N == 0 ⇒ FAIL`(0 == 0 的比对本来就是绿的,
  必须由这条断言把"没测"显形)。

## 四、修一个空指针崩溃(本轮真踩)
`readArrears*` 直接写 `c.Activity.StudentBizNos`,而 `c.Activity` 对**另外 30 多条 case 是 nil**
⇒ 第 3 条 case 就 panic。加 `studentBizNos(c)` 包装后恢复。
**这是好事**:它证明"新块对旧 case 为空"必须显式处理,不能想当然。

## 五、代价(team-lead 要看的那行)
```
代价:SQL 执行 2870 次 | 耗时 17.4s | 平均 57.4 次/case | 平均 349ms/case
```
**与 3a 收口时完全一致(2870)** —— 因为两块都按声明开关:`RecommendUserID > 0` 才读推荐人、
`StudentBizNos` 非空才读应收,而 50 条里没有任何一条声明它们。
⇒ 覆盖断言本轮也**一条都不出现**(`grep "本块比对了"` = 0),符合预期:
它们是给 W4(3d)用的,声明一加上就会激活。这正是"零成本直到需要"的设计。

验收:50 / 50 / 50 PASS / 0 FAIL / Skipped 98 / 业务 290 | 基建 99 / 未分类 0
      case 数仍 50(本批不加 case,符合纪律)
Q 2 horas atrás
pai
commit
4f393d95bf

+ 154 - 64
internal/academic/dualrun/stage2/activity_snapshot.go

@@ -3,6 +3,7 @@ package stage2
 import (
 	"context"
 	"database/sql"
+	"errors"
 	"fmt"
 	"sort"
 	"strings"
@@ -74,8 +75,8 @@ type ActivitySnapshot struct {
 	Discounts map[ActivitySignedKey]DiscountState
 	// DiscountLog 是赠送额度流水**整表行集**。
 	DiscountLog []DiscountLogRow
-	// Students 是报课欠费(key = `biz_no`,即旧 `t_courses_student.id`)。
-	Students map[string]StudentArrears
+	// 课酬应收**不放在这里**:它是 `SideSnapshot.Arrears`(有序切片),
+	// 因为"核销顺序"是被验对象,map 会把它抹平。
 }
 
 // ActivityState 是 `t_courses_activity` 里本 case 关心的列。
@@ -206,7 +207,6 @@ func readActivitySnapshotLegacy(ctx context.Context, tx *gorm.DB, c Case) (*Acti
 		Activities: map[int64]ActivityState{},
 		Signed:     map[ActivitySignedKey]SignedState{},
 		Discounts:  map[ActivitySignedKey]DiscountState{},
-		Students:   map[string]StudentArrears{},
 	}
 
 	// ① 活动行
@@ -373,36 +373,6 @@ SELECT d.activityid, d.userid, IFNULL(l.courses_studentid,''),
 		_ = rows.Close()
 	}
 
-	// ⑥ 报课欠费(按 biz_no;legacy 的 `t_courses_student.id` 就是旧 UUID)
-	if len(c.Activity.StudentBizNos) > 0 {
-		rows, err := tx.Raw(`
-SELECT id, `+readRound2("net_receipts")+`, `+readRound2("remainder_receivable")+`,
-       IF(IFNULL(confirm_discount_userid,0) > 0, 1, 0)
-  FROM t_courses_student WHERE id IN (?)`, c.Activity.StudentBizNos).Rows()
-		if err != nil {
-			return nil, fmt.Errorf("读 legacy 报课欠费失败:%w", err)
-		}
-		for rows.Next() {
-			var biz string
-			var st StudentArrears
-			var net, rem sql.NullString
-			var confirm int64
-			if err := rows.Scan(&biz, &net, &rem, &confirm); err != nil {
-				_ = rows.Close()
-				return nil, err
-			}
-			st.NetReceipts, _ = centsOrNull(net)
-			st.RemainderReceivable, _ = centsOrNull(rem)
-			st.ConfirmDiscountSet = confirm == 1
-			st.Rows++
-			out.Students[biz] = st
-		}
-		if err := rows.Err(); err != nil {
-			_ = rows.Close()
-			return nil, err
-		}
-		_ = rows.Close()
-	}
 	return out, nil
 }
 
@@ -429,7 +399,6 @@ func readActivitySnapshotTarget(ctx context.Context, tx *gorm.DB, c Case, uid st
 		Activities: map[int64]ActivityState{},
 		Signed:     map[ActivitySignedKey]SignedState{},
 		Discounts:  map[ActivitySignedKey]DiscountState{},
-		Students:   map[string]StudentArrears{},
 	}
 
 	// ① 活动行
@@ -608,36 +577,6 @@ SELECT d.activity_id, d.user_id,
 		_ = rows.Close()
 	}
 
-	// ⑥ 报课欠费(按 biz_no 定位)
-	if len(c.Activity.StudentBizNos) > 0 {
-		rows, err := tx.Raw(`
-SELECT biz_no, `+readRound2("net_receipts")+`, `+readRound2("remainder_receivable")+`,
-       IF(confirm_discount_user_id IS NULL OR confirm_discount_user_id = '', 0, 1)
-  FROM t_courses_student WHERE biz_no IN (?)`, c.Activity.StudentBizNos).Rows()
-		if err != nil {
-			return nil, fmt.Errorf("读 target 报课欠费失败:%w", err)
-		}
-		for rows.Next() {
-			var biz string
-			var st StudentArrears
-			var net, rem sql.NullString
-			var confirm int64
-			if err := rows.Scan(&biz, &net, &rem, &confirm); err != nil {
-				_ = rows.Close()
-				return nil, err
-			}
-			st.NetReceipts, _ = centsOrNull(net)
-			st.RemainderReceivable, _ = centsOrNull(rem)
-			st.ConfirmDiscountSet = confirm == 1
-			st.Rows++
-			out.Students[biz] = st
-		}
-		if err := rows.Err(); err != nil {
-			_ = rows.Close()
-			return nil, err
-		}
-		_ = rows.Close()
-	}
 	return out, nil
 }
 
@@ -877,3 +816,154 @@ func fmtSnapN(n int) string {
 	}
 	return fmt.Sprint(n)
 }
+
+// ArrearsRow 是一条课酬应收(`t_courses_student`),**按核销 cursor 顺序**排列后的元素。
+//
+// 两侧的锚:legacy 是 `t_courses_student.id`(旧 UUID);target 是 `biz_no`(同一个 UUID)。
+// **不能用 target 的 `id`(bigint 自增)**,也不能用 `courses_student_no`(那是活动域那张表的列)。
+type ArrearsRow struct {
+	AnchorBizNo         string // 两侧同一锚(旧 UUID)
+	CoursesStudentID    int64  // target 的数字 id;legacy 恒 0(两侧不可比,仅供报告)
+	RemainderReceivable int64
+	NetReceipts         int64
+	ConfirmDiscountSet  bool
+	// StartTime 是 cursor 的排序键(`t_courses_class.start_time`),已格式化成可比较字符串。
+	StartTime string
+}
+
+// readArrearsLegacy 读 legacy 侧的课酬应收,**按核销 cursor 的顺序**。
+//
+// cursor(`sp_courses_student_counteract_arrears_course`):
+//
+//	SELECT t1.id, t1.remainder_receivable FROM t_courses_student t1
+//	  JOIN t_courses_class t2 ON t1.coursesid=t2.coursesid AND t1.classid=t2.classid
+//	 WHERE t1.studentid=? AND t1.schoolid=? AND t1.campusid=?
+//	   AND t1.remainder_receivable>0 AND t1.confirm_discount_userid>0
+//	 ORDER BY t2.start_time ASC;
+//
+// 这里**故意不加后两个过滤**:把"该进候选却没进"的两种行也读出来(`remainder<=0` / `confirm=0`),
+// 否则"过滤条件写错"这类错永远测不到 —— 过滤条件没有反例输入,等于没测(§18.3 第 0 条同一思路)。
+// 排序仍照 cursor 的 `start_time ASC`,并在末尾用 anchor 兜底保证确定性。
+func readArrearsLegacy(ctx context.Context, tx *gorm.DB, c Case) ([]ArrearsRow, error) {
+	if len(studentBizNos(c)) == 0 {
+		return nil, nil
+	}
+	rows, err := tx.Raw(`
+SELECT t1.id,
+       `+readRound2("t1.remainder_receivable")+`,
+       `+readRound2("t1.net_receipts")+`,
+       IF(IFNULL(t1.confirm_discount_userid,0) > 0, 1, 0),
+       IFNULL(DATE_FORMAT(t2.start_time,'%Y-%m-%d %H:%i:%s'), '0000-00-00 00:00:00')
+  FROM t_courses_student t1
+  LEFT JOIN t_courses_class t2 ON t1.coursesid=t2.coursesid AND t1.classid=t2.classid
+ WHERE t1.id IN (?)
+ ORDER BY t2.start_time ASC, t1.id ASC`, studentBizNos(c)).Rows()
+	if err != nil {
+		return nil, fmt.Errorf("读 legacy 课酬应收失败:%w", err)
+	}
+	defer func() { _ = rows.Close() }()
+	var out []ArrearsRow
+	for rows.Next() {
+		var r ArrearsRow
+		var rem, net sql.NullString
+		var confirm int64
+		if err := rows.Scan(&r.AnchorBizNo, &rem, &net, &confirm, &r.StartTime); err != nil {
+			return nil, err
+		}
+		r.RemainderReceivable, _ = centsOrNull(rem)
+		r.NetReceipts, _ = centsOrNull(net)
+		r.ConfirmDiscountSet = confirm == 1
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+// readArrearsTarget 同上(target 侧)。锚用 `biz_no`,**不用 `id`**(两侧自增起点不同)。
+func readArrearsTarget(ctx context.Context, tx *gorm.DB, c Case) ([]ArrearsRow, error) {
+	if len(studentBizNos(c)) == 0 {
+		return nil, nil
+	}
+	rows, err := tx.Raw(`
+SELECT t1.biz_no, t1.id,
+       `+readRound2("t1.remainder_receivable")+`,
+       `+readRound2("t1.net_receipts")+`,
+       IF(t1.confirm_discount_user_id IS NULL OR t1.confirm_discount_user_id='', 0, 1),
+       IFNULL(DATE_FORMAT(t2.start_time,'%Y-%m-%d %H:%i:%s'), '0000-00-00 00:00:00')
+  FROM t_courses_student t1
+  LEFT JOIN t_courses_class t2 ON t1.courses_id=t2.courses_id AND t1.class_id=t2.class_id
+ WHERE t1.biz_no IN (?)
+ ORDER BY t2.start_time ASC, t1.biz_no ASC`, studentBizNos(c)).Rows()
+	if err != nil {
+		return nil, fmt.Errorf("读 target 课酬应收失败:%w", err)
+	}
+	defer func() { _ = rows.Close() }()
+	var out []ArrearsRow
+	for rows.Next() {
+		var r ArrearsRow
+		var rem, net sql.NullString
+		var confirm int64
+		if err := rows.Scan(&r.AnchorBizNo, &r.CoursesStudentID, &rem, &net, &confirm, &r.StartTime); err != nil {
+			return nil, err
+		}
+		r.RemainderReceivable, _ = centsOrNull(rem)
+		r.NetReceipts, _ = centsOrNull(net)
+		r.ConfirmDiscountSet = confirm == 1
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+// readRecommenderAccount 读"推荐人"的资金账户(全局 + 本校区)。
+//
+// 只在 `c.RecommendUserID > 0` 时读 —— 其余 case 不付额外查询代价(报告尾部的"代价"行会体现)。
+func readRecommenderAccount(ctx context.Context, tx *gorm.DB, c Case, target bool) (Account, bool, error) {
+	if c.RecommendUserID <= 0 {
+		return Account{}, false, nil
+	}
+	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
+}
+
+// studentBizNos 安全地取 case 声明的报课单号(`c.Activity` 为 nil 时返回 nil)。
+//
+// 为什么要这个包装:`c.Activity` 只对"涉及活动域的 case"非 nil,
+// 直接写 `c.Activity.StudentBizNos` 会对另外 30 多条 case **空指针崩**(本轮真踩了一次)。
+func studentBizNos(c Case) []string {
+	if c.Activity == nil {
+		return nil
+	}
+	return c.Activity.StudentBizNos
+}

+ 23 - 0
internal/academic/dualrun/stage2/activity_snapshot_test.go

@@ -146,3 +146,26 @@ func TestRenderTupleShowsNull(t *testing.T) {
 		t.Errorf("NULL 必须显示成 NULL,实得 %q", got)
 	}
 }
+
+// TestMustCoverZeroIsFail 锁住"比对了 0 项 ⇒ FAIL"这条防法。
+//
+// 它是 §18.3 第 0 / 2 条的共同出口:字段加错结构体、key 写错、种子没进去,
+// 三种错的共同症状都是"这一块静默地什么都没比" —— 而 `0 == 0` 的比对**本来就是绿的**,
+// 所以必须由这条断言把"没测"显形。
+func TestMustCoverZeroIsFail(t *testing.T) {
+	if a := mustCover("x", 0, 1, ""); a.Pass {
+		t.Error("比对了 0 项必须判 FAIL")
+	}
+	if a := mustCover("x", 1, 1, ""); !a.Pass {
+		t.Error("比对了 1 项(期望 1)应通过")
+	}
+	if a := mustCover("x", 2, 3, ""); a.Pass {
+		t.Error("比对了 2 项 < 期望 3 应判 FAIL")
+	}
+	if a := mustCover("x", 3, 3, ""); !a.Pass {
+		t.Error("比对了 3 项(期望 3)应通过")
+	}
+	if a := mustCover("x", 1, 1, ""); a.Kind != AssertionInfrastructure {
+		t.Error("覆盖断言是基建断言,不能充抵业务闸门")
+	}
+}

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

@@ -427,6 +427,22 @@ type SideSnapshot struct {
 	// 而这个 bool 只能回答"有没有 NULL",用它写断言会漏掉"哪一列"。
 	CampusNullBalance bool
 
+	// Recommender 是**推荐人**(`c.RecommendUserID`)的资金账户。
+	//
+	// 为什么必须有:老带新的核心语义是"**两个用户各拿一份**"。少了这块,
+	// "推荐人也 +m"只能靠"两侧总额恰好相等"侧面推 —— 而镜像错误(两边都只给一个人)照样绿。
+	//
+	// `HasRecommender` 单独记:`c.RecommendUserID == 0` 时本块不适用(不是"读到 0")。
+	Recommender    Account
+	HasRecommender bool
+
+	// Arrears 是课酬应收行(`t_courses_student`),**按核销 cursor 的顺序**排列。
+	//
+	// 为什么要"有序":`counteract_arrears_course` 的判据是
+	// `remainder_receivable>0 AND confirm_discount_userid>0 ORDER BY start_time ASC` ——
+	// **核销顺序本身就是被验对象**。按 id 或无序取,会把"先核哪一行"这个分歧抹平。
+	Arrears []ArrearsRow
+
 	// Activity 是活动域快照(六张表、逐列三态),见 `activity_snapshot.go`。
 	//
 	// **`nil` = 本 case 不涉及活动域**。nil 时所有活动域比对一律 `Skipped`,

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

@@ -473,6 +473,14 @@ SELECT id, IFNULL(type,0), CAST(ROUND(CAST(IFNULL(money,0) AS DECIMAL(20,3)),2)
 	if out.Activity, err = readActivitySnapshotLegacy(ctx, tx, c); err != nil {
 		return out, 0, err
 	}
+	// 第 4 块:推荐人账户(仅 `RecommendUserID > 0` 时读,其余 case 不付查询代价)
+	if out.Recommender, out.HasRecommender, err = readRecommenderAccount(ctx, tx, c, false); err != nil {
+		return out, 0, err
+	}
+	// 第 5 块:课酬应收(有序;仅声明了 `StudentBizNos` 时读)
+	if out.Arrears, err = readArrearsLegacy(ctx, tx, c); err != nil {
+		return out, 0, err
+	}
 	return out, maxID, nil
 }
 

+ 46 - 0
internal/academic/dualrun/stage2/run.go

@@ -790,6 +790,28 @@ func ProbeCases() []Case {
 //
 // ⚠ 这个失败**报在第 N 条 case 上,根因却在第 1 条** —— 看起来像"case 太多了、要分批跑",
 // 实际是连接泄漏。按"分批跑"处置会永久掩埋它:以后每加一条 case 都要重新踩一次。
+// mustCover 生成一条"快照覆盖"基建断言:**N 必须 ≥ want 且 > 0**,否则 FAIL。
+//
+// 这是 §18.3 第 0 / 2 条的共同防法:字段加错结构体、key 写错、种子没进去——
+// 三种错的共同症状都是"这一块静默地什么都没比"。所以每块都要显式打出比对了 N 项,
+// 且 **N == 0 判 FAIL**(不是"空过",是"没测")。
+func mustCover(name string, n, want int, detail string) Assertion {
+	return Assertion{
+		Kind:   AssertionInfrastructure,
+		Name:   name,
+		Pass:   n >= want && n > 0,
+		Detail: fmt.Sprintf("本块比对了 %d 项(0 ⇒ FAIL;期望 ≥ %d)| %s", n, want, detail),
+	}
+}
+
+// minInt 取较小值(本地小工具,避免为此引 math)。
+func minInt(a, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}
+
 func closeDB(db *gorm.DB) {
 	if db == nil {
 		return
@@ -894,6 +916,30 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 		})
 	}
 
+	// ①-c 两块新快照的"读到了没有 + 条数对不对"(§18.3 第 2 条的防法)。
+	//
+	// 为什么必须有:字段加到 `SideResult` 而不是 `SideSnapshot` 那次,编译期只偶尔报 undefined;
+	// 若该字段恰好没人读,它**永远 nil 且零症状**。所以每块都要一条"两侧都读到了"的基建断言,
+	// 并且**打出 N 项**——N==0 时不是"空过",而是 FAIL(§18.3 第 0 条)。
+	if c.RecommendUserID > 0 {
+		n := 0
+		if lg.Before.HasRecommender && tg.Before.HasRecommender {
+			n = 1
+		}
+		res.Assertions = append(res.Assertions, mustCover(
+			"case 有效性前提:**推荐人账户两侧都读到了**(否则「推荐人也 +m」不可断言)", n, 1,
+			fmt.Sprintf("legacy 读到=%v target 读到=%v(推荐人 userid=%d)",
+				lg.Before.HasRecommender, tg.Before.HasRecommender, c.RecommendUserID)))
+	}
+	if want := len(studentBizNos(c)); want > 0 {
+		n := minInt(len(lg.Before.Arrears), len(tg.Before.Arrears))
+		res.Assertions = append(res.Assertions, mustCover(
+			"case 有效性前提:**课酬应收两侧都读到且条数与声明一致**(按核销 cursor 顺序)",
+			n, want,
+			fmt.Sprintf("声明 %d 条,legacy 读到 %d、target 读到 %d",
+				want, len(lg.Before.Arrears), len(tg.Before.Arrears))))
+	}
+
 	// ①-b 赠送额度的退回:游标**按订单号**、**不带 userid**
 	//(`WHERE courses_studentid=? AND discount_amount_used<0 ORDER BY id DESC`)。
 	// 旧库真有 162 行额度流水,只要订单号撞上真实行,两侧看到的行集天然不同 ——

+ 6 - 0
internal/academic/dualrun/stage2/target.go

@@ -553,6 +553,12 @@ 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 {
+		return out, 0, err
+	}
+	if out.Arrears, err = readArrearsTarget(ctx, tx, c); err != nil {
+		return out, 0, err
+	}
 	return out, maxID, nil
 }