Quellcode durchsuchen

feat(ac15): W4-A 补 RecommenderLogs —— F24 探针上线,实测**未命中**

## 一、F24 探针:推荐人的 107 凭证现在可断言了

新增 `SideSnapshot.RecommenderLogs`(仅 `RecommendUserID>0` 时读,两侧 before/after **都读全量**),
断言「**推荐人也恰好 1 条 type=107 且 money = +m**」。

实测(W4-A):

```
被推荐人 type=107  legacy 1 / target 1
推荐人   type=107  legacy 1 / target 1  金额合计 +5000 / +5000
```

⇒ **F24 未命中**:两条 107 都写成了,没有撞键、没有丢凭证、没有半写。

## 二、为什么没撞:`request_id` 是 **NULL**,不是同一个单号

team-lead 问的"两侧 `request_id` 到底是什么",答案分两层:

- legacy:`logid = inputOrder`(同一个字符串),**没有 request_id 这一列**;
- target:`newExpenseLog(userID, 107, amount, **""**, remark, operatorID, …)`
  —— 第 4 个实参传的是**空串**,`optString("")` 返回 **nil**;
  随后 `lg.LogID = optString(orderNo)` 只写 `log_id`。

而 `uk_request_type(request_id, type)` 在 MySQL 里**对 NULL 不生效**(唯一索引允许多个 NULL)
⇒ 两条 107 都能插入。

⚠ 但这不是"安全",而是**另一种形态**:`request_id=NULL` 意味着
**107 这条流水在 target 侧根本没有幂等键**。W4 的幂等锚是 `used_log` 的存在性
(`SettleRecommend` 命中即 `Repeated=true`),不是流水唯一键 —— **与 legacy 一致,不算偏离**。
登记为"F24 未命中,但幂等锚是 used_log 而非流水",避免下一个人以为流水有唯一性保护。

## 三、顺带更正我上一轮一个**说错的理由**

上一轮我说"`Logs` 是**窗口**(倒序取最近 N 条)",**这是错的**。
实际是:`After.Logs` 被 `filterLogs(…, maxLogID)` **过滤成仅本次新增行**(两侧都是),
而 `Before.Logs` 是全量 ⇒ 拿"仅新增"减"全量"当然得负数 **−2**。

所以那条坑的准确表述是:**不能用两组不同口径的数做差**,而不是"窗口滑动"。
代码注释已按事实改写,并保留"一律按 id 集合求差"这个能同时吃两种输入的做法。
(这条**还没**写进 README §18.3 —— 我要先把理由写对,下一轮并进自伤清单。)

## 报数
53 / 53 PASS / 0 FAIL | SQL 3106 次 | Skipped 99 | 业务 322 | 基建 110 | 未分类 0
grep "本块比对了" = 4 | 未 push
Q vor 8 Stunden
Ursprung
Commit
65c965e24a

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

@@ -510,6 +510,15 @@ type SideSnapshot struct {
 	// 是两条不同路径,而这两种都有 case 要测。
 	RecommenderCampus    Account
 	HasRecommenderCampus bool
+	// RecommenderLogs 是推荐人的流水(**全量**,不按 maxID 过滤 —— 两侧 before/after 都读全量,
+	// 比对时按 id 集合求差,这样不依赖"after 已过滤"这个实现细节)。
+	//
+	// 为什么必须有:W4 的 107 凭证走的是**两条独立的 INSERT**(每个用户一条),
+	// 而 `balance` 到账与凭证写入是**两条独立路径** —— 前者到账不保证后者写成。
+	// 少了这块,"两人各一条凭证"只能靠"两人 Δbalance 都 = +m"间接支撑,
+	// 而 target 侧 `uk_request_type(request_id,type)` **不含 user_id**,
+	// 两条 107 用同一个 `inputOrder` 做 `request_id` 时有撞键风险(F24)。
+	RecommenderLogs []LogRow
 
 	// Arrears 是课酬应收行(`t_courses_student`),**按核销 cursor 的顺序**排列。
 	//

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

@@ -498,6 +498,13 @@ SELECT id, IFNULL(type,0), CAST(ROUND(CAST(IFNULL(money,0) AS DECIMAL(20,3)),2)
 	if err := rows.Err(); err != nil {
 		return out, 0, err
 	}
+
+	// 推荐人流水(全量)。只在 W4 需要;其余 case `RecommendUserID==0` ⇒ 直接返回。
+	if c.RecommendUserID > 0 {
+		if out.RecommenderLogs, err = readLogsOfLegacyUser(ctx, tx, c.RecommendUserID); err != nil {
+			return out, 0, err
+		}
+	}
 	// 活动报名状态(只有带活动种子的 case 才读)
 	signed, err := readLegacySigned(ctx, tx, c.ActivityPlan, c.LegacyUserID)
 	if err != nil {
@@ -596,3 +603,32 @@ func maxInt(a, b int) int {
 	}
 	return b
 }
+
+// readLogsOfLegacyUser 读某个 legacy 用户的**全量**流水(不按 id 过滤)。
+//
+// 为什么不复用上面那段内联 SQL:那段把 `maxID` 顺手算出来给 `filterLogs` 用,
+// 而推荐人这边的比对口径是"按 id 集合求差",不需要 maxID。
+// 硬塞进去会让快照函数多一个返回值,而那个值没人用。
+func readLogsOfLegacyUser(ctx context.Context, tx *gorm.DB, legacyUserID int64) ([]LogRow, error) {
+	rows, err := tx.Raw(`
+SELECT id, IFNULL(type,0), CAST(ROUND(CAST(IFNULL(money,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       IFNULL(logid,''), IFNULL(create_userid,0), IFNULL(remark,''), create_time
+  FROM t_user_expense_log WHERE userid = ? ORDER BY id`, legacyUserID).Rows()
+	if err != nil {
+		return nil, fmt.Errorf("读 legacy 推荐人流水失败:%w", err)
+	}
+	defer rows.Close()
+	var out []LogRow
+	for rows.Next() {
+		var l LogRow
+		var money string
+		var ct time.Time
+		if err := rows.Scan(&l.ID, &l.Type, &money, &l.LogID, &l.CreateUserLegacyID, &l.Remark, &ct); err != nil {
+			return nil, err
+		}
+		l.Money, _ = centsOf(money)
+		l.CreateTime = ct
+		out = append(out, l)
+	}
+	return out, rows.Err()
+}

+ 64 - 24
internal/academic/dualrun/stage2/recommend_compare.go

@@ -77,9 +77,12 @@ func recommendCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	})
 
 	// 107 流水:**每人一条**(`type=107`、`money=+m`)。
-	// 只数本 case 用户的(推荐人的流水不在共享快照里,见文件末的未覆盖说明)。
-	lgLogs := countLogsByType(lg.Before, lg.After, 107)
-	tgLogs := countLogsByType(tg.Before, tg.After, 107)
+	//
+	// 两条 INSERT 是**独立的**(dump 763386 / 763407),各用同一个 `inputOrder` 作 `logid`。
+	// 所以"金额到账"不能推出"凭证写成" —— 必须分别数。
+	lgNew := newLogs(lg.Before.Logs, lg.After.Logs)
+	tgNew := newLogs(tg.Before.Logs, tg.After.Logs)
+	lgLogs, tgLogs := countType(lgNew, 107), countType(tgNew, 107)
 	add("被推荐人新增 type=107 流水条数", fmt.Sprint(lgLogs), fmt.Sprint(tgLogs), lgLogs == tgLogs, "")
 	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		Name:   "被推荐人新增**恰好 1 条** type=107 流水(奖励入账凭证)",
@@ -87,6 +90,26 @@ func recommendCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		Detail: fmt.Sprintf("legacy=%d target=%d(期望各 1)。0 条 = 没入账;2 条 = 重复入账", lgLogs, tgLogs),
 	})
 
+	// 推荐人的 107:**这一条是 F24 的探针**。
+	//
+	// target 侧的幂等键是 `uk_request_type(request_id, type)` —— **不含 `user_id`**。
+	// 而本 SP 的语义是"同一个 `inputOrder` 下**两个用户各写一条 type=107**"。
+	// ⇒ 若 target 把 `inputOrder` 直接当 `request_id`,第二条 107 会撞键:
+	//   要么凭证丢失、要么整块回滚(半写)、要么 target 自己加了用户维度(需登记)。
+	// 三种后果完全不同,**只有把推荐人那条数出来才能分辨**。
+	lgRNew := newLogs(lg.Before.RecommenderLogs, lg.After.RecommenderLogs)
+	tgRNew := newLogs(tg.Before.RecommenderLogs, tg.After.RecommenderLogs)
+	lgR, tgR := countType(lgRNew, 107), countType(tgRNew, 107)
+	lgRSum, tgRSum := sumMoneyOfType(lgRNew, 107), sumMoneyOfType(tgRNew, 107)
+	add("推荐人新增 type=107 流水条数", fmt.Sprint(lgR), fmt.Sprint(tgR), lgR == tgR, "F24 探针")
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "**推荐人也新增恰好 1 条** type=107 流水,且 money = +m(F24:uk_request_type 不含 user_id,两人同单号有撞键风险)",
+		Pass: lgR == 1 && tgR == 1 && lgRSum == want && tgRSum == want,
+		Detail: fmt.Sprintf("legacy 条数=%d 金额合计=%+d | target 条数=%d 金额合计=%+d | m=%d。"+
+			"条数 0 ⇒ 凭证丢失(钱到了、账没记);金额不符 ⇒ 记错数;两侧条数不同 ⇒ 幂等键口径不同(需登记)",
+			lgR, lgRSum, tgR, tgRSum, want),
+	})
+
 	// 活动结算态:`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")
@@ -107,42 +130,59 @@ func recommendCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			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 数"本次**新增**"的某类流水条数。
+// newLogs 取"本次新增"的流水行。
+//
+// ⚠ 这里要写清两个**看起来相反**的事实(本轮的坑就在这儿):
 //
-// 为什么不能写成 `afterCount − beforeCount`(本轮真踩了,出来是 **−2**):
+//	① `After.Logs` **已经**被 `filterLogs(…, maxLogID)` 过滤成"仅本次新增行"
+//	   (两侧都是,`legacy.go` / `target.go` 的快照函数里);
+//	② 而 `RecommenderLogs` 是**全量**(读的时候没算推荐人的 maxID)。
 //
-//	`SideSnapshot.Logs` 是**窗口**(按 id 倒序取最近若干条),不是全表。
-//	新增两条后窗口滑动,旧的两条被挤出去 ⇒ 差值可能是负数,
-//	而负数的含义是"快照窗口变了",不是"流水变少了"。
+// 所以同一套判据必须能同时吃这两种输入 ⇒ 一律用**按 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 {
+// 为什么不能写成 `afterCount − beforeCount`:第一版就是这么写的,跑出 **−2**。
+// 因为 `after` 是"仅新增"、`before` 是"全量",相减是拿两组不同口径的数做差 ——
+// **负数不是"流水变少了",是两组数根本不可比**。
+func newLogs(before, after []LogRow) []LogRow {
+	seen := make(map[int64]bool, len(before))
+	for _, r := range before {
 		seen[r.ID] = true
 	}
+	out := make([]LogRow, 0, 2)
+	for _, r := range after {
+		if !seen[r.ID] {
+			out = append(out, r)
+		}
+	}
+	return out
+}
+
+// countType 数某类条数。
+func countType(rows []LogRow, typ int32) int {
 	n := 0
-	for _, r := range after.Logs {
-		if r.Type == typ && !seen[r.ID] {
+	for _, r := range rows {
+		if r.Type == typ {
 			n++
 		}
 	}
 	return n
 }
 
+// sumMoneyOfType 求某类流水的金额合计(分)。
+func sumMoneyOfType(rows []LogRow, typ int32) int64 {
+	var s int64
+	for _, r := range rows {
+		if r.Type == typ {
+			s += r.Money
+		}
+	}
+	return s
+}
+
 // signedStatus 取某活动的报名状态(读不到返回 −1,与"状态是 0"区分开)。
 func signedStatus(s *SideResult, activityID int64) int32 {
 	if s.After.Signed == nil {

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

@@ -640,6 +640,12 @@ SELECT id, type, CAST(money AS DECIMAL(12,2)), IFNULL(log_id,''), IFNULL(request
 	if out.Arrears, err = readArrearsTarget(ctx, tx, c); err != nil {
 		return out, 0, err
 	}
+	// 推荐人流水(全量、两侧 before/after 都读,比对时按 id 集合求差)。
+	if c.RecommendUserID > 0 {
+		if out.RecommenderLogs, err = readLogsOfTargetUser(ctx, tx, CanonicalUIDFor(c.RecommendUserID)); err != nil {
+			return out, 0, err
+		}
+	}
 	return out, maxID, nil
 }
 
@@ -665,3 +671,28 @@ func sqlNullUIDStr(legacyID int64) string {
 	}
 	return CanonicalUIDFor(legacyID)
 }
+
+// readLogsOfTargetUser 读某个 target 用户的**全量**流水。
+func readLogsOfTargetUser(ctx context.Context, tx *gorm.DB, uid string) ([]LogRow, error) {
+	rows, err := tx.Raw(`
+SELECT id, IFNULL(type,0), CAST(ROUND(CAST(IFNULL(money,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       IFNULL(log_id,''), IFNULL(remark,''), create_time
+  FROM t_user_expense_log WHERE user_id = ? ORDER BY id`, uid).Rows()
+	if err != nil {
+		return nil, fmt.Errorf("读 target 推荐人流水失败:%w", err)
+	}
+	defer rows.Close()
+	var out []LogRow
+	for rows.Next() {
+		var l LogRow
+		var money string
+		var ct time.Time
+		if err := rows.Scan(&l.ID, &l.Type, &money, &l.LogID, &l.Remark, &ct); err != nil {
+			return nil, err
+		}
+		l.Money, _ = centsOf(money)
+		l.CreateTime = ct
+		out = append(out, l)
+	}
+	return out, rows.Err()
+}