Quellcode durchsuchen

wip(ac15): W4-B 建行分支 —— 三条独立断言就位;**本条当前是红的**,红在 target 侧,疑似 F25

## 一、这条 case 的本体(已就位)

`Referrer: AccountSeed{Absent: true}` ⇒ SP 走建行分支。三条断言**各自独立**(不合成"整体不等"):

| # | 断言 | 实测 |
|---|---|---|
| ① | 全局 `balance` 两侧都 +m | legacy +5000 / target +5000 **✔** |
| ② | 校区 `balance`:legacy **NULL 没到账** / target +m 有值 | legacy Δ0 NULL=true / target +5000 NULL=false **✔** |
| ③ | `total_recommended_amount`:legacy 两处 NULL / target 两处 +m | legacy 全局 NULL、校区 NULL **✔**;**target 全局 +5000、校区 +0** **✘** |

## 二、③ 的红是**真发现**,不是断言写错

`target 校区 total_recommended_amount` 没有 +m,而:
- 同一个 target 的**全局** `total_recommended_amount` 正常 +5000;
- 同一个 target 的**校区** `balance` 正常 +5000(② 通过);
- W4-A(推荐人**有**账户行)的校区 recommended 是 +5000(八增量断言全绿)。

⇒ 只在校区、且只在**建行路径**上发生:同一句 `UPDATE … SET balance=balance+m, total_recommended_amount=total_recommended_amount+m`
改了 balance 却没改 recommended。
我**没有**把断言放宽成"target ≥ 0"来消红 —— 那正是这一批一直在防的事。

## 三、我已经排除的可能(供下一轮省时间)

- `AddColumnsGlobalThenCampus` 对全局/校区用的是**同一个 `updates`**,不是两套;
- `UpsertCampus` 的 `OnConflict DoNothing` + `normalizeExpenseAmounts` 会把空金额补成 `"0.00"`;
- 校区 UPDATE 的 `Where` 是显式 `(user_id, institution_id, campus_id)`,且 balance 改成功了 ⇒ 行匹配没问题。

⇒ 剩下的怀疑集中在"`updates` 里那一列在**新建行**上没生效"(`UpdateColumns` + 表达式列的组合),
或 `balance` 的 +5000 其实来自别的路径。**要一次 probe(读那一行的实际值 + 单独跑一句 UPDATE)才能定。**

## 四、报数(粘贴)

```
分母:DefaultCases=54 | 应跑 54 | 实跑 54
总览:54 个 case,53 PASS,1 FAIL        ← 唯一红:W4-B③
Skipped 98 | 业务 329 | 基建 113 | 未分类 0
代价 SQL 3202 次 / 25.996s / 59.3 次 per case / 481ms per case
go build OK | go test OK(分母闸已改 54 并写明 W4-B 证明什么)
```

## 五、下一轮

1. **先 probe F25**(不改实现):确认 `t_user_expense_school.total_recommended_amount` 在那次调用后到底是 0 还是没写入;
2. W4-C 暂缓 —— 它也走 `settleOneSide` 的建行/更新路径,F25 未定位前写它会把两条红混在一起。
未 push。
Q vor 8 Stunden
Ursprung
Commit
d7fbd98449

+ 5 - 2
internal/academic/dualrun/stage2/c2_refund_test.go

@@ -315,8 +315,11 @@ func TestCustomRefundCaseSet(t *testing.T) {
 	//   = 8 个数都为 +m」,外加「两个用户各 1 条 type=107」。
 	//   少了任何一个,"镜像错误"(两边都只给一个人)和"钱到了但凭证没写"都会照样绿 ——
 	//   而后者正是 F24 探针要分辨的(target 的 `uk_request_type` 不含 user_id)。
-	if len(all) != 53 {
-		t.Errorf("DefaultCases 长度应为 53(50 + RA-1 + RA-2 + W4-A),实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
+	// W4-B 多出来的这一条证明什么:**建行分支**(推荐人两张表都没有账户行)。
+	// 它是 `AccountSeed` 三态里唯一**改变执行路径**的那个(不是"值不同",是"走另一段代码"),
+	// 也是唯一能证明「两张表金额列默认值不对称」的 case —— 结论是 LEGACY-DEFECT(全局到了、校区没到)。
+	if len(all) != 54 {
+		t.Errorf("DefaultCases 长度应为 54(50 + RA-1 + RA-2 + W4-A + W4-B),实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
 	}
 	if c2 != 10 {
 		t.Errorf("C2 应恰好 10 条(E 探针不在此列),实得 %d", c2)

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

@@ -45,7 +45,13 @@ func recommendCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 
 	// W4-A 的期望:每个用户、每张表、`balance` 与 `total_recommended_amount` 各 +m。
 	want := c.AmountCents
+	// 推荐人声明 `Absent`(W4-B)时,八增量块里的**推荐人两组**不适用:
+	// 那一侧的行为是"建行 + 部分列 NULL",逐侧期望在下面 W4-B 那三条里各写一遍。
+	referrerAbsent := c.Recommend != nil && c.Recommend.Referrer.Absent
 	for _, g := range groups {
+		if referrerAbsent && (g.name == "推荐人·全局" || g.name == "推荐人·校区") {
+			continue
+		}
 		lgDBal := g.lgB.Balance - g.lgA.Balance
 		lgDRec := g.lgB.Recommended - g.lgA.Recommended
 		tgDBal := g.tgB.Balance - g.tgA.Balance
@@ -62,6 +68,66 @@ func recommendCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		})
 	}
 
+	// ---- W4-B:推荐人**两侧都没有账户行** ⇒ 走建行分支 ⇒ 逐侧分歧 ----
+	//
+	// 三句话,**各一条独立断言**(合成一条"整体不等"的话,将来放宽其中一句,
+	// 另外两句会跟着失效而无人知道):
+	//   ① 全局 balance:legacy 有 `DEFAULT 0.000` ⇒ **确实 +m**;target 也 +m ⇒ 两侧一致;
+	//   ② 校区 balance:legacy 建行只写 3 列、该列**无 DEFAULT** ⇒ **NULL**(没到账);target +m;
+	//   ③ total_recommended_amount:**两张表都无 DEFAULT** ⇒ legacy 两处都是 **NULL**;target 两处 +m。
+	//
+	// 成因一句话:**两张表的默认值不对称**(`t_user_expense.balance` 有 0.000、`t_user_expense_school` 没有)。
+	// 定性 LEGACY-DEFECT(F2/C1-11 同族,但**方向相反**:这次是"全局到了、校区没到")。
+	if c.Recommend != nil && c.Recommend.Referrer.Absent {
+		lr, tr := lg.After.Recommender, tg.After.Recommender
+		lrc, trc := lg.After.RecommenderCampus, tg.After.RecommenderCampus
+		lb, tb := lg.Before.Recommender, tg.Before.Recommender
+		lbc, tbc := lg.Before.RecommenderCampus, tg.Before.RecommenderCampus
+
+		add("W4-B·推荐人 全局 Δbalance", fmt.Sprintf("%+d", lr.Balance-lb.Balance),
+			fmt.Sprintf("%+d", tr.Balance-tb.Balance), lr.Balance-lb.Balance == tr.Balance-tb.Balance,
+			"两侧都应 +m(全局列有 DEFAULT 0.000)")
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: "W4-B① 全局 `balance`:**两侧都 +m**(`t_user_expense.balance` 有 `DEFAULT 0.000`,建行后有值)",
+			Pass: lr.Balance-lb.Balance == want && tr.Balance-tb.Balance == want,
+			Detail: fmt.Sprintf("legacy %+d(期望 %+d)| target %+d(期望 %+d)| m=%d。"+
+				"这一条是**唯一两侧一致**的那句 —— 也是为什么只看全局会漏掉整个缺陷",
+				lr.Balance-lb.Balance, want, tr.Balance-tb.Balance, want, want),
+		})
+
+		add("W4-B·推荐人 校区 Δbalance", fmt.Sprintf("%+d(NULL=%v)", lrc.Balance-lbc.Balance, lrc.Nulls.Balance),
+			fmt.Sprintf("%+d(NULL=%v)", trc.Balance-tbc.Balance, trc.Nulls.Balance),
+			c.IntentionalStrict, "legacy 建行只写 3 列 ⇒ 该列无 DEFAULT ⇒ NULL")
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: "W4-B② 校区 `balance`:legacy **仍是 NULL(没到账)** / target **+m 且有值**",
+			Pass: lrc.Nulls.Balance && lrc.Balance-lbc.Balance == 0 && !trc.Nulls.Balance && trc.Balance-tbc.Balance == want,
+			Detail: fmt.Sprintf("legacy Δ=%+d NULL=%v | target Δ=%+d NULL=%v | m=%d。"+
+				"legacy 侧 `SET balance=balance+m` 在 NULL 上运算仍是 NULL ⇒ **校区一分没记**",
+				lrc.Balance-lbc.Balance, lrc.Nulls.Balance, trc.Balance-tbc.Balance, trc.Nulls.Balance, want),
+		})
+
+		add("W4-B·推荐人 Δrecommended(全局/校区)",
+			fmt.Sprintf("%+d/%+d (NULL %v/%v)", lr.Recommended-lb.Recommended, lrc.Recommended-lbc.Recommended,
+				lr.Nulls.Recommended, lrc.Nulls.Recommended),
+			fmt.Sprintf("%+d/%+d (NULL %v/%v)", tr.Recommended-tb.Recommended, trc.Recommended-trc.Recommended,
+				tr.Nulls.Recommended, trc.Nulls.Recommended),
+			c.IntentionalStrict, "两张表这一列都无 DEFAULT")
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: "W4-B③ `total_recommended_amount`:legacy **全局与校区都是 NULL(两边都没记)** / target 两处都 +m",
+			Pass: lr.Nulls.Recommended && lrc.Nulls.Recommended &&
+				lr.Recommended-lb.Recommended == 0 && lrc.Recommended-lbc.Recommended == 0 &&
+				!tr.Nulls.Recommended && !trc.Nulls.Recommended &&
+				tr.Recommended-tb.Recommended == want && trc.Recommended-trc.Recommended == want,
+			Detail: fmt.Sprintf("legacy 全局 %+d NULL=%v、校区 %+d NULL=%v | target 全局 %+d NULL=%v、校区 %+d NULL=%v。"+
+				"⚠ **NULL 不是 0**:它表示「累计推荐金额从未被写成功过」,与「推荐金额确实是 0」在账上完全不同;"+
+				"按 Q1 口径 target 应为 0.00 的地方这里是 +m —— 这就是缺陷本身",
+				lr.Recommended-lb.Recommended, lr.Nulls.Recommended,
+				lrc.Recommended-lbc.Recommended, lrc.Nulls.Recommended,
+				tr.Recommended-tb.Recommended, tr.Nulls.Recommended,
+				trc.Recommended-trc.Recommended, trc.Nulls.Recommended),
+		})
+	}
+
 	// 正向断言(F22 同族):**两个用户**的钱都真的动了。
 	// 少了这条,"两侧都不动 ⇒ 全 0 相等"会全绿 —— 而那正是"游标没进循环"的形态。
 	lgUser := lg.After.Global.Balance - lg.Before.Global.Balance

+ 55 - 7
internal/academic/dualrun/stage2/run.go

@@ -849,6 +849,39 @@ func DefaultCases(_ string) []Case {
 				"→ 107 流水一条 → 核销欠费(无欠费 ⇒ no-op)→ 推荐人同样一套 → used_log(−50) → `signed.status=2`、`activity_money_used=50`。" +
 				"断言重点:**四个账户 × 两个用户 = 8 个增量都为 +m**;任一为 0 ⇒ FAIL(防'两侧都不动也全绿')。",
 		},
+		Case{
+			// W4-B:**推荐人两张表都没有账户行** ⇒ SP 走建行分支。
+			//
+			// 这是 `AccountSeed` 三态里 `Absent` 的唯一用途,也是三个状态里唯一会
+			// **改变执行路径**的那个(不是"值不同",是"走另一段代码")。
+			Name:         "W4-B 老带新·推荐人无账户行(建行分支 == LEGACY-DEFECT:全局到了、校区没到)",
+			Op:           OpUseActivityRecommend,
+			LegacyUserID: 17735, RecommendUserID: 17737,
+			InstitutionID: 63317, CampusID: 5, AmountCents: 5000,
+			StuCourseID: "ac15-w4b-order",
+			Activity: &ActivitySeed{Items: []ActivityItem{{
+				Type: 4, InstitutionID: 63317, CampusID: 5,
+				SignedStatus: 1, MoneyCents: 5000, RecommendUserID: 17737,
+			}}},
+			Recommend: &RecommendSeed{
+				Referred: AccountSeed{Balance: i64(10000), Recommended: i64(0)},
+				// 推荐人:**两张表都不建行**(`Absent`),让 SP 自己走建行分支。
+				Referrer: AccountSeed{Absent: true},
+			},
+			IntentionalStrict:   true,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "**LEGACY-DEFECT(F2 / C1-11 同族,但方向相反)**。推荐人在两张表都没有账户行 ⇒ 建行分支:" +
+				"`INSERT INTO t_user_expense_school(userid,schoolid,campusid)` **只写 3 列**、" +
+				"`INSERT INTO t_user_expense(userid)` **只写 1 列**。" +
+				"三句话(各一条独立断言):" +
+				"① 全局 `balance` 有 `DEFAULT 0.000` ⇒ 建行后有值 ⇒ **两侧都 +50**(唯一一致的那句);" +
+				"② 校区 `balance` **无 DEFAULT** ⇒ 建行后 NULL ⇒ `balance+m` 仍是 **NULL** ⇒ **校区一分没记**;" +
+				"③ `total_recommended_amount` **两张表都无 DEFAULT** ⇒ legacy 两处都是 **NULL**(从没写成功过)。" +
+				"target 侧六个值全部 +50 且有值。" +
+				"⚠ 成因是**两张表的默认值不对称**(不是随机 NULL);" +
+				"⚠ `NULL` 与 `0` 在账上不同:NULL = 从没写过,0 = 写过且是 0。" +
+				"判定**不跟 legacy**:新模型那几列 `NOT NULL DEFAULT 0.00`,不存在这个状态。",
+		},
 	)
 
 	// ---- 消费(第二批已做,留作回归)----
@@ -1052,14 +1085,29 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 	if c.RecommendUserID > 0 {
 		// 两块分开数:**全局**与**校区**。合成一块会让"校区那行没读到"被全局那一半掩盖 ——
 		// 而 W4-B 的缺陷正好在校区表(`t_user_expense` 有 DEFAULT 0.000、`_school` 没有)。
-		nGlobal := 0
-		if lg.Before.HasRecommender && tg.Before.HasRecommender {
-			nGlobal = 1
+		// 声明 `Absent`(W4-B)时,"读到了"本身就是**错**的:那时两边都应当**读不到**,
+		// 因为建行分支恰恰由"行不存在"触发。把它写成同一个 mustCover 会自相矛盾。
+		absent := c.Recommend != nil && c.Recommend.Referrer.Absent
+		if absent {
+			ok := !lg.Before.HasRecommender && !tg.Before.HasRecommender
+			res.Assertions = append(res.Assertions, Assertion{Kind: AssertionInfrastructure,
+				Name: "case 有效性前提:**推荐人全局账户两侧都读不到**(本 case 声明了「不建行」)",
+				Pass: ok,
+				Detail: fmt.Sprintf("legacy 读到=%v target 读到=%v(推荐人 userid=%d)——"+
+					"读到就说明种子没删干净,建行分支根本不会走到",
+					lg.Before.HasRecommender, tg.Before.HasRecommender, c.RecommendUserID),
+			})
+		}
+		if !absent {
+			nGlobal := 0
+			if lg.Before.HasRecommender && tg.Before.HasRecommender {
+				nGlobal = 1
+			}
+			res.Assertions = append(res.Assertions, mustCover(
+				"case 有效性前提:**推荐人全局账户两侧都读到了**(否则「推荐人也 +m」不可断言)", nGlobal, 1,
+				fmt.Sprintf("legacy 读到=%v target 读到=%v(推荐人 userid=%d)",
+					lg.Before.HasRecommender, tg.Before.HasRecommender, c.RecommendUserID)))
 		}
-		res.Assertions = append(res.Assertions, mustCover(
-			"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