Przeglądaj źródła

feat(ac15): 3a 收口 —— DR-3/DRA-1/DRA-2(47→50 全绿)+ §18.3 第 0 条自伤形态

一、三条 case → DefaultCases 47→50
  DR-3  同一订单两条负流水:legacy 只退第一条(LEGACY-DEFECT,少退 20.00)
  DRA-1 按活动退回·正常:used 30.00→20.00、log +10.00(两侧一致)
  DRA-2 按活动退回·type=3 + 退超:**legacy 超退**(如下)

二、施工中挖到两个真缺陷(都不是我们的实现错)
  ① **DR-3**:`read_loop` 内层套 `WHILE done=0 DO FETCH` 把 cursor 一次抽干 ⇒
     多条负流水时**只处理第一条**。实测 legacy used 50→**30.00**、target 50→**10.00**,
     用户少拿 20.00,且两边都返回成功。判 LEGACY-DEFECT。
  ② **DRA-2(这条更值钱)**:旧 SP 里那句
       `IF (inputAmount>logDiscountAmountUsed) THEN SELECT inputAmount = logDiscountAmountUsed; END IF;`
     **`SELECT x = y` 不是赋值**(`SET` 才是)!所以"退超就压到流水额"这道钳制**从未生效**。
     type=3 把 inputAmount 覆盖成面额 100.00 之后直接退 100.00 ⇒ 池行 used 30→**−70.00**(超退)。
     target 正确(压到 30.00 ⇒ used 0.00)。判 LEGACY-DEFECT。
     ⇒ 一句话:**`SELECT var = expr` 与 `SET var = expr` 在 MySQL 里完全不同**,
       前者只返回一个布尔列、不改变量。这条要进 spdump 的「读 SP 正文的坑」。

三、机制新增(都是为了不把"登记分歧"写成"红")
  · `Case.LegacyDrainsCursorAfterFirst` / `Case.LegacyRefundTakesFullFace`:
    置位时额度断言改 **per-side**(legacy 对"少退/超退"的期望、target 对"逐条退"的期望),
    且额度流水行由"判相等"改为 `Skipped` 登记(打 `—`),避免红/假绿两种错。
  · `wantDiscountUsedLegacy`:legacy 的期望**单独一条公式**,不套 target 的。
  · 修一个 bug:`ORDER BY id DESC` ⇒ "第一条"要**倒着**遍历 `DiscountLogs`。
    第一版按顺推写,DR-3 的期望就错了一条(拿了 −30 而非 −20)。
  · `wantDiscountUsed` 加 `allLogs` 参数:legacy 只看第一条、target 逐条。

四、§18.3 加**第 0 条**自伤形态(排第一,因为最隐蔽)
  「断言键写错 ⇒ 断言块整体不执行」:不跑 ⇒ 既不红也不绿,报告里那一栏**根本不存在**。
  防法两条:① 每个按 key 取快照的断言块打"本块比对了 N 项",N==0 判 FAIL;
  ② 反向自检:key 换成不存在的值,必须**报"取不到快照"**而不是静默跳过。
  本轮已落地第 ① 条的局部实现:额度块"声明了就必须执行,两侧都取不到 ⇒ 判 FAIL 并打出键"。

验收:50 条 50 PASS / 0 FAIL / Skipped 98 / 业务 290 | 基建 99 / 未分类 0
      代价 SQL 2870 次 / 19.5s / 57.4 次每 case
      形态:equality 12 | intentional-strict 2(含零写入)| 零写入守卫 1 | LEGACY-DEFECT 2
Q 2 godzin temu
rodzic
commit
a4c0897f9e

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

@@ -1220,16 +1220,18 @@ $sql = "SELECT SUM(t1.discount_amount-t1.discount_amount_used) as amount FROM ..
 | `Diff.Skipped` / `Assertion.Skipped` | 不打 `—` ⇒ `0 == 0` 冒充"比过且相等" |
 | `FailCount` 对 `Skipped` | 计入 FAIL ⇒ 结构性不适用被当成待修的红 |
 | `Assertion.Kind` | 零值算业务 ⇒ 忘标即放宽 |
+| **断言块的 key** | 键恒 0 ⇒ 整块静默不执行,**连红都不给**(第 0 条) |
 
 反过来,三处**故意选严**的:`toleranceCents = 0`(容差永不放宽)、
 哨兵的 `nonzeroSamples > 0`(非空自检)、`未分类断言数 == 0`。
 
-### 18.3 「自伤清单」:新基建最容易把旧判据悄悄弄坏的五种方式
+### 18.3 「自伤清单」:新基建最容易把旧判据悄悄弄坏的六种方式
 
 每一条都**不产生明显症状**,只让断言变松或整块消失。加基建/加 case 时逐条对照。
 
 | # | 自伤方式 | 症状 | 防法 |
 |---|---|---|---|
+| **0** | **断言键写错 ⇒ 断言块整体不执行**(批 3a 真踩:`dKey` 用 `c.ActivityID`,而 `user_discount_refund` 没有 activity 入参 ⇒ ActivityID 恒 0 ⇒ 键永不匹配) | **零症状,且连绿色行都不生成** —— 它不跑,所以既不红也不绿,报告里那一栏干脆不存在 | ① 每个"按 key 取快照"的断言块**打一行"本块比对了 N 项"**,`N == 0` 判 **FAIL**(同存量哨兵 `nonzeroSamples > 0`);② 反向自检:把 key 换成不存在的值,该块必须**报"取不到快照"而不是静默跳过** |
 | 1 | **新字段对旧 case 为空,却被当成"两侧不等/相等"** | 旧 case 集体红或集体多一条假绿 | 空值必须映射成 `Skipped`;`nil` 与 `nil` **不许**比出"相等"。锁在 `TestActivityDomainApplicableNilIsNotEqual` |
 | 2 | **字段加错结构体**(`SideResult` 而不是 `SideSnapshot`,或反过来) | 编译期偶尔报 `undefined`;**若那个字段恰好没人读,就永远为 nil,零症状** | 每个"本 case 声明了就必须读到"的快照块,配一条**基建断言**:"两侧都读到了 + 条数 == 预期" |
 | 3 | **两个不同口径的列共用一个读函数** | 比对变松,永不报错(例:`float(10,2)` 与 `float(10,3)` 同表) | 读函数按口径**分成两个**(`readRound2` / `readRound3`),并加单测断言两者字符串不同 |

+ 73 - 11
internal/academic/dualrun/stage2/activity_compare.go

@@ -202,18 +202,42 @@ func activityCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		dActID = c.ActivityPlan.Items[c.Activity.Discounts[0].ActivityIndex].ID
 	}
 	dKey := ActivitySignedKey{ActivityID: dActID, UserUID: uid}
-	if ld, lok := lb.Discounts[dKey]; lok || hasAnyDiscount(lb, ta, dKey) {
+	// 声明了就必须执行(自伤清单第 0 条的防法)。
+	divergent := c.LegacyDrainsCursorAfterFirst || c.LegacyRefundTakesFullFace
+	if len(c.Activity.Discounts) > 0 {
+		ld, lok := lb.Discounts[dKey]
 		td, tok := tb.Discounts[dKey]
+		// 自伤清单第 0 条的防法:**声明了就必须执行**。
+		// 若两侧都取不到这一行,不能静默跳过(那样"键写错"和"本 case 不涉及"长得一样),
+		// 而要判 FAIL 并把键打出来。
+		if !lok && !tok {
+			asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+				Name: "额度行快照**必须能取到**(取不到 ⇒ 判 FAIL,不静默跳过)",
+				Pass: false,
+				Detail: fmt.Sprintf("key=(activity=%d, user=%s) 两侧都取不到额度行 —— "+
+					"本 case 声明了 `Discounts`,所以这不是「不涉及」,是**键不对或种子没进去**", dActID, uid),
+			})
+		}
 		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
-			Name: "额度行 `discount_amount_used` 两侧一致(2 位口径;`discount_amount` 走 2 位、`used` 走 3 位,**不共用读函数**)",
-			Pass: lok == tok && (!lok || ld.DiscountAmountUsed == td.DiscountAmountUsed) &&
+			Name: firstOr(ternaryName(divergent),
+				"额度行 `discount_amount_used` 两侧一致(2 位口径;`discount_amount` 走 2 位、`used` 走 3 位,**不共用读函数**)"),
+			// 分歧 case(LEGACY-DEFECT)**不判两侧相等** —— 它们本来就该不同;
+			// 改成 per-side 各自对期望。否则这条断言会把"登记分歧"记成红。
+			Pass: lok == tok &&
+				(divergent || !lok || ld.DiscountAmountUsed == td.DiscountAmountUsed) &&
 				(!lok || ld.DiscountAmount == td.DiscountAmount) &&
-				(!lok || !isRefundOp(c.Op) || ld.DiscountAmountUsed == wantDiscountUsed(c)),
+				// 分歧 case 下 legacy 有**自己的**期望(少退/超退),不能套 target 的公式
+				(!lok || !isRefundOp(c.Op) || divergent ||
+					ld.DiscountAmountUsed == wantDiscountUsed(c, false)) &&
+				(!lok || !(c.LegacyDrainsCursorAfterFirst || c.LegacyRefundTakesFullFace) ||
+					ld.DiscountAmountUsed == wantDiscountUsedLegacy(c)) &&
+				(!tok || !(c.LegacyDrainsCursorAfterFirst || c.LegacyRefundTakesFullFace) ||
+					td.DiscountAmountUsed == wantDiscountUsed(c, true)),
 			Detail: fmt.Sprintf("legacy used=%s amount=%s | target used=%s amount=%s | 期望 used=%s"+
 				"(退回类才校验期望;使用类的扣减由两侧相等 + 额度流水保证)(存在 legacy=%v target=%v)",
 				yuan(ld.DiscountAmountUsed), yuan(ld.DiscountAmount),
 				yuan(td.DiscountAmountUsed), yuan(td.DiscountAmount),
-				yuan(wantDiscountUsed(c)), lok, tok),
+				yuan(wantDiscountUsed(c, false)), lok, tok),
 		})
 		// Q3:3a 不做 id 重映射 ⇒ 两侧该列都必须是 NULL,**显式断言**(不是"不写就不管")
 		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
@@ -229,8 +253,11 @@ func activityCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		Item:   "额度流水(多重集,仅本次新增行;父行 id 已归一成 (活动, 用户))",
 		Legacy: fmt.Sprint(len(lDNew)),
 		Target: fmt.Sprint(len(tDNew)),
-		Equal:  okD,
-		Note:   detailD,
+		Equal:  okD || divergent,
+		// 分歧 case(LEGACY-DEFECT):**登记**两侧实际值,不做"相等"判定 ——
+		// 灰色地带用 Skipped 打 "—",避免"没比"冒充"比过且相等"。
+		Skipped: divergent,
+		Note:    firstOr(ternaryNote(divergent), detailD),
 	})
 
 	// ---- ④b intentional-strict:legacy 静默 / target 显式拒绝 ----
@@ -419,7 +446,20 @@ func activityChainVerdict(c Case) string {
 // ⚠ 它**不是**"退成负数":申请退超时只退到 0 为止、剩余不退也不报错。
 // 这里按种子声明的 `UsedCents` 与负流水之和算,不写死数字 ——
 // 写死会让"期望"和"种子"脱钩,改种子就悄悄失效。
-func wantDiscountUsed(c Case) int64 {
+// wantDiscountUsedLegacy 是**旧 SP 侧**的期望(它有自己的缺陷,不能和 target 共用一条公式)。
+func wantDiscountUsedLegacy(c Case) int64 {
+	if len(c.Activity.Discounts) == 0 {
+		return 0
+	}
+	used := c.Activity.Discounts[0].UsedCents
+	// `SELECT inputAmount = logDiscountAmountUsed` 不是赋值 ⇒ 钳制没生效 ⇒ 按面额退
+	if c.LegacyRefundTakesFullFace {
+		return used - c.Activity.Discounts[0].AmountCents
+	}
+	return wantDiscountUsed(c, false)
+}
+
+func wantDiscountUsed(c Case, allLogs bool) int64 {
 	if len(c.Activity.Discounts) == 0 {
 		return 0
 	}
@@ -432,12 +472,18 @@ func wantDiscountUsed(c Case) int64 {
 	}
 	used := c.Activity.Discounts[0].UsedCents
 	var refundable int64
-	for _, dl := range c.Activity.DiscountLogs {
+	// 游标是 `ORDER BY id DESC` ⇒ **最后插入的那条排第一**。所以"只看第一条"要倒着遍历,
+	// 顺推会取错那条 —— 第一版我按顺推写,DR-3 的期望就错了 10.00。
+	logs := c.Activity.DiscountLogs
+	for i := len(logs) - 1; i >= 0; i-- {
+		dl := logs[i]
 		if dl.MoneyCents < 0 {
 			refundable += -dl.MoneyCents
 		}
-		if refundable > 0 {
-			break // 游标按 id DESC 取**第一条**,所以只看第一条负流水
+		// 旧 SP 的游标只取第一条(内层 WHILE 把 cursor 抽干);target 会逐条退。
+		// `allLogs` 决定算到第几条 —— 这正是 DR-3 要登记的分歧。
+		if refundable > 0 && !allLogs {
+			break
 		}
 	}
 	take := c.AmountCents
@@ -451,3 +497,19 @@ func wantDiscountUsed(c Case) int64 {
 func isRefundOp(op Op) bool {
 	return op == OpUserDiscountRefund || op == OpUserDiscountRefundByActivity
 }
+
+// ternaryNote 给"分歧 case"的流水行加一句说明(不是分歧时返回空串)。
+func ternaryNote(divergent bool) string {
+	if !divergent {
+		return ""
+	}
+	return "两侧本就该不同(LEGACY-DEFECT:旧 SP 少退 / 超退)⇒ 只登记实际值,不做相等判定"
+}
+
+// ternaryName 给"分歧 case"的额度断言换一个名字 —— 叫"两侧一致"会误导。
+func ternaryName(divergent bool) string {
+	if !divergent {
+		return ""
+	}
+	return "额度行 `discount_amount_used`:**per-side 各自对期望**(LEGACY-DEFECT,两侧本就该不同)"
+}

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

@@ -295,8 +295,8 @@ func TestCustomRefundCaseSet(t *testing.T) {
 		}
 	}
 	// 25(A/B/C1 期)+ 10(C2:C2-1、C2-1b、C2-2…C2-6、C2-8、C2-9、C2-10)
-	if len(all) != 47 {
-		t.Errorf("DefaultCases 长度应为 47,实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
+	if len(all) != 50 {
+		t.Errorf("DefaultCases 长度应为 50,实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
 	}
 	if c2 != 10 {
 		t.Errorf("C2 应恰好 10 条(E 探针不在此列),实得 %d", c2)

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

@@ -187,6 +187,20 @@ type Case struct {
 	// JOIN 回 `biz_no`,于是两侧的"订单号"在比对口径上又变成同一个字符串。
 	StuCourseNumericID int64
 
+	// LegacyDrainsCursorAfterFirst true = 旧 SP 的内层 `WHILE done=0 DO FETCH` 会把 cursor
+	// **一次抽干**,所以带多条负流水的退回**只处理第一条**(DR-3 抓的就是这个)。
+	//
+	// 置 true 时额度断言改成 **per-side**:legacy 对着"只算第一条"的期望、target 对着"算全部"的期望,
+	// 两个都各自成立才算通过 —— 它登记的是「legacy 少退」这个真实分歧,不是一致性。
+	LegacyDrainsCursorAfterFirst bool
+
+	// LegacyRefundTakesFullFace true = 旧 SP 里那句 `SELECT inputAmount = logDiscountAmountUsed`
+	// **不是赋值**(`SET` 才是),所以"退超就压到流水额"这道钳制**从未生效**:
+	// type=3 代金券把 inputAmount 覆盖成面额后,直接按**面额**退 ⇒ 池行 used 变成**负数**(超退)。
+	//
+	// 置 true 时额度断言同样走 per-side:legacy 对"按面额退"、target 对"压到流水额"。
+	LegacyRefundTakesFullFace bool
+
 	// ExpectActivityZeroWrite true = 本 case 期望**两侧都不写库**(守卫类 case)。
 	//
 	// 显式声明而不是"靠比对发现":`0 vs 0` 的比对本身就是绿的,

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

@@ -651,6 +651,78 @@ func DefaultCases(_ string) []Case {
 		},
 	)
 
+	// ---- 批 3a:W3 收尾三条(DR-3 / DRA-1 / DRA-2)----
+	out = append(out,
+		Case{
+			// 这条是 3a 里**唯一一条"照 dump 实现会照抄缺陷"**的构型:
+			// `sp_courses_activity_user_discount_refund` 的 read_loop 内层套了
+			// `WHILE done=0 DO FETCH ... END WHILE`,把 cursor **一次抽干** ⇒
+			// 同一订单下**多条**负流水时,它只处理**第一条**,剩下的不退、也不报错。
+			Name: "DR-3 退回赠送额度·同一订单两条负流水(legacy 只退第一条 == LEGACY-DEFECT)",
+			Op:   OpUserDiscountRefund, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, AmountCents: 4000,
+			StuCourseID: drStuCourse, StuCourseNumericID: 1, Remark: "W3 两条流水",
+			LegacyDrainsCursorAfterFirst: true,
+			Activity: &ActivitySeed{
+				Items: []ActivityItem{
+					{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: -1, MoneyCents: 0},
+				},
+				Discounts: []DiscountSeed{{ActivityIndex: 0, AmountCents: 10000, UsedCents: 5000}},
+				DiscountLogs: []DiscountLogSeed{
+					{DiscountIndex: 0, MoneyCents: -3000, Memo: "W3 消费-1"},
+					{DiscountIndex: 0, MoneyCents: -2000, Memo: "W3 消费-2"},
+				},
+			},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "池行已用 50.00(两条负流水 −30 / −20),申请退 40.00。" +
+				"**legacy(内层 WHILE 抽干 cursor)只处理第一条** ⇒ tempAmount=min(40,30)=30 ⇒ used 50→20、log 一条 +30;" +
+				"**target 逐条退** ⇒ 30 然后 10 ⇒ used 50→10、log 两条 +30 / +10。" +
+				"判定 **LEGACY-DEFECT**:旧系统少退、且不报错,用户少拿 10.00。" +
+				"⚠ 这条**不比对两侧相等**(它们本来就该不同),改成 per-side 各自对期望",
+		},
+		Case{
+			Name: "DRA-1 按活动退回·正常部分退回(LIMIT 0,1 取最新一条)",
+			Op:   OpUserDiscountRefundByActivity, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, AmountCents: 1000,
+			StuCourseID: drStuCourse, StuCourseNumericID: 1, Remark: "W3 按活动退回",
+			Activity: &ActivitySeed{
+				Items: []ActivityItem{
+					{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: -1, MoneyCents: 0},
+				},
+				Discounts: []DiscountSeed{{ActivityIndex: 0, AmountCents: 10000, UsedCents: 3000}},
+				DiscountLogs: []DiscountLogSeed{
+					{DiscountIndex: 0, MoneyCents: -3000, Memo: "W3 消费"},
+				},
+			},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "已用 30.00,退 10.00。dump:`UPDATE ... discount_amount_used -= inputAmount` + `_log` 写 **+inputAmount**," +
+				"默认备注「**退回活动金额**」(注意与 DR 的「退回赠送金额」不是同一句)。" +
+				"游标带 `LIMIT 0,1` 且 `ORDER BY t1.id DESC` ⇒ 取**最新一条**负流水",
+		},
+		Case{
+			Name: "DRA-2 按活动退回·type=3 代金券 + 退超(两道覆盖:先按面额、再压到流水额)",
+			Op:   OpUserDiscountRefundByActivity, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, AmountCents: 3000,
+			StuCourseID: drStuCourse, StuCourseNumericID: 1, Remark: "W3 代金券退回",
+			LegacyRefundTakesFullFace: true,
+			Activity: &ActivitySeed{
+				Items: []ActivityItem{
+					{Type: 3, InstitutionID: 63317, CampusID: 5, SignedStatus: -1, MoneyCents: 0},
+				},
+				Discounts: []DiscountSeed{{ActivityIndex: 0, AmountCents: 10000, UsedCents: 3000}},
+				DiscountLogs: []DiscountLogSeed{
+					{DiscountIndex: 0, MoneyCents: -3000, Memo: "W3 消费"},
+				},
+			},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "代金券(type=3)、入参 30.00、流水 −30.00。dump 是**两道覆盖**:" +
+				"先 `IF activityType=3 THEN SET inputAmount=discountAmount`(30→**100**)," +
+				"再 `IF inputAmount>|log| THEN inputAmount=|log|`(100→**30**)⇒ 实际退 30.00、used 归 0。" +
+				"⚠ 两道都必须生效:只写第一道会退 100(超退),只写第二道会退 30 但走不到 type=3 分支的语义。" +
+				"期望用「入参被忽略 ⇒ 最终等于流水额」表达,不写死数字",
+		},
+	)
+
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,