Kaynağa Gözat

feat(ac15): 3a W2 消耗/冲回额度 4 条(39→43 全绿)

四条:UR-1(RunTimes=2,期望逐次模拟)/ UR-2(额度用尽,显式断言零写入)/
      RR-1(正常冲回)/ RR-2(同订单下 type=2 与 type=4 并存,只处理 type=2)

一、种子能力扩展(都是"不种就没法验"的,不是顺手加)
  · `ActivityItem.ActivityMoneyUsedCents` —— W2 的"额度已用尽"要靠它
  · `UsedLogSeed` —— `refund_activity_recharge` 的游标是
    `WHERE orderid=? AND activity_money_used<0 ORDER BY id DESC`;
    不种那条负值行,冲回**根本进不去循环**,case 会退化成"两侧都 no-op"的空过

二、施工中撞到并修掉的三件事(都属"假红看起来像实现错")
  ① **两侧订单 id 空间不同**:legacy `used_log.orderid` 是旧 UUID,
     target `used_log.order_no` 是 `orderNoOf(StudentCourseID)` = 数字 id 的字符串。
     第一版我把种子 `OrderNo` 两侧写成同一个值 ⇒ target 按 id 查不到行、冲回写 0 条 ⇒ 假红。
     修法:种子 `OrderNo` 留空,两侧各按自己的口径填;比对时用 `orderToken` 把两侧订单号
     都归一成 `<order>`(`c.StuCourseID` / `fmt.Sprint(c.StuCourseNumericID)`)。
     ⚠ 这条和 `courses_student_no` 是同一族:**id 空间不同 ≠ 实现错**,但错误形态完全一样。
  ② **额度类 5 支不动 `signed.status`**:我漏了这个分支,`wantStatus` 默认成 0 ⇒ UR-1/UR-2 假红。
  ③ **`usedLogTuple` / `discountLogTuple` 需要 `Case`** 才能做①的归一。

三、顺带确认(你让我验的那条)
`UseRechargeGift` / `RefundRechargeGift` **不校验报课行存在性**:前者只用 `StudentCourseID`
做订单键,后者 `orderNoOf(id)` 查 used_log。legacy 那边 `inputStuCourseId` 也只写进 `orderid`。
⇒ **W2 不需要种 `t_courses_student`**,不存在"target 校验、legacy 不校验"的差异。

验收:43 条 43 PASS / 0 FAIL / Skipped 69→75(+6,全部来自 RR-1/RR-2 的零写入无关项)
      业务 241 | 基建 80 / 未分类 0 | 代价 SQL 2386 次 / 15.1s / 55.5 次每 case
Q 3 saat önce
ebeveyn
işleme
049bb16efc

+ 14 - 20
internal/academic/dualrun/stage2/activity_compare.go

@@ -99,6 +99,10 @@ func activityCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 				wantStatus = 1
 			}
 		}
+	case OpUseActivityRecharge, OpRefundActivityRecharge,
+		OpUserDiscountUse, OpUserDiscountRefund, OpUserDiscountRefundByActivity:
+		// 这 5 支不动 `signed.status`(只动金额列 / 额度列)⇒ 期望与种子一致
+		wantStatus = int64(c.ActivityPlan.Items[0].SignedStatus)
 	case OpSignedCancel:
 		if c.ExpectActivityZeroWrite {
 			wantStatus = int64(c.ActivityPlan.Items[0].SignedStatus)
@@ -143,19 +147,9 @@ func activityCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	_ = tBOK
 
 	// ---- ③ used_log 行集(多重集)----
-	lRows := make([]string, 0, len(lb.UsedLog))
-	for _, r := range lb.UsedLog {
-		lRows = append(lRows, usedLogTuple(r))
-	}
-	tRows := make([]string, 0, len(tb.UsedLog))
-	for _, r := range tb.UsedLog {
-		tRows = append(tRows, usedLogTuple(r))
-	}
 	// 只比**本 case 期间新增**的行:before 的行不该参与(其它 case 的历史行)
-	lNew := diffMultiset(lb.UsedLog, la.UsedLog)
-	tNew := diffMultiset(tb.UsedLog, ta.UsedLog)
-	_ = lRows
-	_ = tRows
+	lNew := diffMultiset(lb.UsedLog, la.UsedLog, c)
+	tNew := diffMultiset(tb.UsedLog, ta.UsedLog, c)
 	ok, detail := multisetDiff(lNew, tNew, "用量流水")
 	diffs = append(diffs, Diff{
 		Item:   "用量流水(多重集,仅本次新增行)",
@@ -204,8 +198,8 @@ func activityCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		})
 	}
 
-	lDNew := diffDiscountLogMultiset(lb.DiscountLog, la.DiscountLog)
-	tDNew := diffDiscountLogMultiset(tb.DiscountLog, ta.DiscountLog)
+	lDNew := diffDiscountLogMultiset(lb.DiscountLog, la.DiscountLog, c)
+	tDNew := diffDiscountLogMultiset(tb.DiscountLog, ta.DiscountLog, c)
 	okD, detailD := multisetDiff(lDNew, tDNew, "额度流水")
 	diffs = append(diffs, Diff{
 		Item:   "额度流水(多重集,仅本次新增行;父行 id 已归一成 (活动, 用户))",
@@ -329,14 +323,14 @@ func hasAnyDiscount(lb, ta *ActivitySnapshot, key ActivitySignedKey) bool {
 //
 // 为什么要差集:`used_log` 里可能有**本 case 之前**就存在的行(旧库真实数据),
 // 它们两侧天然不同(target 是空库),不排除就会让每条 case 都红在一个与 SP 无关的地方。
-func diffMultiset(after, before []UsedLogRow) map[string]int {
+func diffMultiset(after, before []UsedLogRow, c Case) map[string]int {
 	b := make(map[string]int, len(before))
 	for _, r := range before {
-		b[usedLogTuple(r)]++
+		b[usedLogTuple(r, c)]++
 	}
 	out := make(map[string]int, len(after))
 	for _, r := range after {
-		k := usedLogTuple(r)
+		k := usedLogTuple(r, c)
 		if b[k] > 0 {
 			b[k]--
 			continue
@@ -347,14 +341,14 @@ func diffMultiset(after, before []UsedLogRow) map[string]int {
 }
 
 // diffDiscountLogMultiset 同上,针对额度流水。
-func diffDiscountLogMultiset(after, before []DiscountLogRow) map[string]int {
+func diffDiscountLogMultiset(after, before []DiscountLogRow, c Case) map[string]int {
 	b := make(map[string]int, len(before))
 	for _, r := range before {
-		b[discountLogTuple(r)]++
+		b[discountLogTuple(r, c)]++
 	}
 	out := make(map[string]int, len(after))
 	for _, r := range after {
-		k := discountLogTuple(r)
+		k := discountLogTuple(r, c)
 		if b[k] > 0 {
 			b[k]--
 			continue

+ 32 - 4
internal/academic/dualrun/stage2/activity_snapshot.go

@@ -692,23 +692,51 @@ func readRound3(col string) string {
 // usedLogTuple 把一行用量流水拼成可比字符串。
 //
 // 进 tuple 的列见文件头"归一"清单:**不含**自增 id、**不含** `create_time`。
-func usedLogTuple(r UsedLogRow) string {
+func usedLogTuple(r UsedLogRow, c Case) string {
 	return strings.Join([]string{
 		fmt.Sprint(r.ActivityID),
 		r.UserUID,
 		identityPart(r.RecommendUserUID),
-		orNull(r.OrderNo, r.OrderNo == ""),
+		orderToken(r.OrderNo, c),
 		moneyPart(r.Money, r.MoneyNull),
 		orNull(r.Memo, r.Memo == ""),
 	}, tupleSep)
 }
 
+// orderToken 把"订单标识"归一到同一个标记。
+//
+// ⚠ 两侧的订单 id **空间不同**,这是迁移结构决定的,不是我们编的:
+//
+//	legacy `used_log.orderid`   = 报课记录**旧 UUID**(`t_courses_student.id` varchar)
+//	target `used_log.order_no`  = 报课记录的**数字 id**(bigint 自增)
+//
+// 两者永远不可能相等。所以比对口径上把它们都归一成 `<order>`:
+//   - legacy 侧认 `c.StuCourseID`
+//   - target 侧认 `fmt.Sprint(c.StuCourseNumericID)`
+//
+// 其它值原样保留(说明这一行不是本 case 声明的那个订单,要显形)。
+//
+// 不做归一就会出现"两侧各写对了自己的 id、但 tuple 永不相等"的假红 ——
+// 而那种红看起来像"SP 写错了订单号"。
+func orderToken(orderNo string, c Case) string {
+	if orderNo == "" {
+		return nullMark
+	}
+	if c.StuCourseID != "" && orderNo == c.StuCourseID {
+		return "<order>"
+	}
+	if c.StuCourseNumericID > 0 && orderNo == fmt.Sprint(c.StuCourseNumericID) {
+		return "<order>"
+	}
+	return orderNo
+}
+
 // discountLogTuple 同上。父行自增 id 已归一成 (活动 id, 用户 ULID)。
-func discountLogTuple(r DiscountLogRow) string {
+func discountLogTuple(r DiscountLogRow, c Case) string {
 	return strings.Join([]string{
 		fmt.Sprint(r.ParentActivityID),
 		r.ParentUserUID,
-		orNull(r.CoursesStudentNo, r.CoursesStudentNo == ""),
+		orderToken(r.CoursesStudentNo, c),
 		moneyPart(r.Money, false),
 		orNull(r.Memo, r.Memo == ""),
 	}, tupleSep)

+ 47 - 6
internal/academic/dualrun/stage2/c1_activity.go

@@ -37,6 +37,8 @@ type ActivityItem struct {
 	// "`signed_add` 把活动金额抄进 `signed.activity_money`" —— 种 0 的话
 	// "没抄"和"抄了 0"长得一样,那条断言就是空过。
 	MoneyCents int64
+	// ActivityMoneyUsedCents 种进 `signed.activity_money_used`(W2 的"额度已用尽"要靠它)。
+	ActivityMoneyUsedCents int64
 	// ResponseUserID >0 时给该报名行写 `response_userid`("已审核")。
 	//
 	// SC-2 要验 `type∈{2,3} 且 response_userid>0 → LEAVE`,这条守卫**只能**靠这个值触发。
@@ -58,6 +60,13 @@ type ActivitySeed struct {
 	// legacy 的 `t_courses_student.id` 就是旧 UUID,target 的 `id` 是 bigint 自增、
 	// 真正对应旧 UUID 的是 `biz_no`。用 biz_no 做锚点,两侧才指向同一行。
 	//
+	// UsedLogs 是本 case 要种的用量流水行(W2 的冲回路径靠它定位)。
+	//
+	// 为什么要种:`sp_courses_activity_refund_activity_recharge` 的游标是
+	// `WHERE orderid=? AND activity_money_used<0 ORDER BY id DESC` ——
+	// 没有这些行,冲回**根本进不去循环**,那条 case 就成了"两侧都 no-op"的空过。
+	UsedLogs []UsedLogSeed
+
 	// Items 非 nil 时**整体替换** `C1ActivityPlan` 的默认矩阵(case 自定义构型)。
 	//
 	// `ID` 留 0 即可:由 `buildPlan` 按 `base+i` 分配。**case 里不许写死 id** ——
@@ -108,6 +117,17 @@ func C1ActivityPlan(base int64, otherUsers map[int64]int32) *ActivityPlan {
 	}
 }
 
+// UsedLogSeed 是一条要种的用量流水。
+//
+// `OrderNo` 两侧写**同一个字符串**(legacy 写 `orderid`、target 写 `order_no`),
+// 这样两侧的游标才会定位到同一批行。
+type UsedLogSeed struct {
+	ActivityIndex int // plan.Items 的下标(id 在运行期才知道)
+	OrderNo       string
+	MoneyCents    int64 // 负数 = 消耗;正数 = 冲回
+	Memo          string
+}
+
 // buildPlan 按 case 的声明构造实际 plan(id 由 base 决定)。
 //
 // 两条路径:
@@ -280,12 +300,22 @@ VALUES (?, ?, ?, ?, ?, 1, ?, NOW())`,
 		// `activity_money` 与 `response_userid` 是活动链要验的两列:
 		// 前者验"`signed_add` 把活动金额抄进来",后者是"已审核不可取消"那条守卫的唯一触发器。
 		if err := tx.Exec(`
-INSERT INTO t_courses_activity_signed (activityid, userid, status, activity_money, response_userid, create_time)
-VALUES (?, ?, ?, ?, ?, NOW())`,
-			it.ID, uid, it.SignedStatus, yuan(it.MoneyCents), it.ResponseUserID).Error; err != nil {
+INSERT INTO t_courses_activity_signed (activityid, userid, status, activity_money, activity_money_used, response_userid, create_time)
+VALUES (?, ?, ?, ?, ?, ?, NOW())`,
+			it.ID, uid, it.SignedStatus, yuan(it.MoneyCents), yuan(it.ActivityMoneyUsedCents), it.ResponseUserID).Error; err != nil {
 			return fmt.Errorf("种子 legacy 报名 %d 失败:%w", it.ID, err)
 		}
 	}
+	// 用量流水(W2 冲回路径的定位依据)。两侧 `orderid` / `order_no` 写同一个字符串。
+	for _, ul := range c.Activity.UsedLogs {
+		aid := p.Items[ul.ActivityIndex].ID
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity_used_log (activityid, userid, recommend_userid, orderid, activity_money_used, create_time, memo)
+VALUES (?, ?, 0, ?, ?, NOW(), ?)`,
+			aid, uid, firstOr(ul.OrderNo, c.StuCourseID), yuan(ul.MoneyCents), ul.Memo).Error; err != nil {
+			return fmt.Errorf("种子 legacy 用量流水失败:%w", err)
+		}
+	}
 	for otherID, st := range p.OtherUsers {
 		// 其他用户报名在"应被失效"的活动上 —— 验证 SP 的 `WHERE userid=inputUserid` 不会波及旁人。
 		if err := tx.Exec(`
@@ -326,12 +356,23 @@ VALUES (?, ?, ?, ?, ?, 1, ?, NOW(3), NOW(3))`,
 		respUID := sqlNullUID(it.ResponseUserID)
 		if err := tx.Exec(`
 INSERT INTO t_courses_activity_signed
-  (activity_id, user_id, institution_id, campus_id, status, activity_money, response_user_id, create_time)
-VALUES (?, ?, ?, ?, ?, ?, ?, NOW(3))`,
-			it.ID, uid, it.InstitutionID, it.CampusID, it.SignedStatus, yuan(it.MoneyCents), respUID).Error; err != nil {
+  (activity_id, user_id, institution_id, campus_id, status, activity_money, activity_money_used, response_user_id, create_time)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(3))`,
+			it.ID, uid, it.InstitutionID, it.CampusID, it.SignedStatus,
+			yuan(it.MoneyCents), yuan(it.ActivityMoneyUsedCents), respUID).Error; err != nil {
 			return fmt.Errorf("种子 target 报名 %d 失败:%w", it.ID, err)
 		}
 	}
+	// 用量流水:`order_no` 与 legacy 的 `orderid` 同一个字符串(两侧游标靠它定位)。
+	for _, ul := range c.Activity.UsedLogs {
+		aid := p.Items[ul.ActivityIndex].ID
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity_used_log (activity_id, user_id, order_no, activity_money_used, create_time, memo)
+VALUES (?, ?, ?, ?, NOW(3), ?)`,
+			aid, uid, firstOr(ul.OrderNo, fmt.Sprint(c.StuCourseNumericID)), yuan(ul.MoneyCents), ul.Memo).Error; err != nil {
+			return fmt.Errorf("种子 target 用量流水失败:%w", err)
+		}
+	}
 	for otherID, st := range p.OtherUsers {
 		otherUID := CanonicalUIDFor(otherID)
 		// 其他用户也需要 t_user 行(t_courses_activity_signed 的逻辑 FK)

+ 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) != 39 {
-		t.Errorf("DefaultCases 长度应为 39,实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
+	if len(all) != 43 {
+		t.Errorf("DefaultCases 长度应为 43,实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
 	}
 	if c2 != 10 {
 		t.Errorf("C2 应恰好 10 条(E 探针不在此列),实得 %d", c2)

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

@@ -483,6 +483,82 @@ func DefaultCases(_ string) []Case {
 		},
 	)
 
+	// ---- 批 3a:W2(充值赠送额度的消耗与冲回)----
+	orderNo := "ac15-w2-order-1"
+	out = append(out,
+		Case{
+			Name: "UR-1 消耗额度·正常部分使用 + 同 orderid 跑 2 次(不幂等)",
+			Op:   OpUseActivityRecharge, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, AmountCents: 2000, RunTimes: 2,
+			StuCourseID: orderNo, StuCourseNumericID: 1, Remark: "W2 消耗",
+			Activity: &ActivitySeed{Items: []ActivityItem{
+				{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: 2, MoneyCents: 4500},
+			}},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "额度 45.00、每次用 20.00、**同一 orderid 跑 2 次**。dump 里没有 orderid 唯一性检查 ⇒ " +
+				"legacy 扣两次(used 0→20→40);target 同样没有幂等键 ⇒ 也扣两次。" +
+				"⚠ 期望必须**逐次模拟**:第 2 次游标看到的是扣完后的 `activity_money_used`,乘 2 会算错。" +
+				"这条同时钉「累加对」与「不幂等」两件事,两者后果不同、不会互相掩盖",
+		},
+		Case{
+			Name: "UR-2 消耗额度·额度已用尽 → 两侧都 no-op(显式断言 0)",
+			Op:   OpUseActivityRecharge, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, AmountCents: 2000,
+			StuCourseID: orderNo, StuCourseNumericID: 1, Remark: "W2 用尽",
+			ExpectActivityZeroWrite: true,
+			Activity: &ActivitySeed{Items: []ActivityItem{
+				{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: 2,
+					MoneyCents: 4500, ActivityMoneyUsedCents: 4500},
+			}},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "`activity_money_used == activity_money`。dump:`IF activityMoneyUsed>=activityMoney THEN LEAVE`;" +
+				"target 同样 `remaining <= 0 → no-op`。排除「用尽还能继续扣成负额度」。" +
+				"⚠ 两侧都是 no-op ⇒ 必须**显式断言零写入**,否则删掉守卫也看不出来(0 vs 0 本来就是绿的)",
+		},
+		Case{
+			Name: "RR-1 冲回额度·正常部分冲回(used_log 一条 **+**m)",
+			Op:   OpRefundActivityRecharge, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, AmountCents: 1000,
+			StuCourseID: orderNo, StuCourseNumericID: 1, Remark: "W2 冲回",
+			Activity: &ActivitySeed{
+				Items: []ActivityItem{
+					{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: 2,
+						MoneyCents: 4500, ActivityMoneyUsedCents: 2000},
+				},
+				UsedLogs: []UsedLogSeed{
+					{ActivityIndex: 0, MoneyCents: -2000, Memo: "W2 消耗"},
+				},
+			},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "已用 20.00,冲回 10.00。dump:`activity_money_used -= tempAmount`、`used_log` 写 **+tempAmount**(符号!)。" +
+				"⚠ 必须先种那条 `activity_money_used<0` 的 used_log —— 否则游标空转、冲回根本进不去循环," +
+				"那条 case 就退化成「两侧都 no-op」的空过。" +
+				"⚠ 种子的 `OrderNo` **留空**:两侧订单 id 空间不同(legacy 旧 UUID / target 数字 id)," +
+				"由种子按各自的口径填;填成同一个值会让 target 按 id 查不到行 —— 那是假红",
+		},
+		Case{
+			Name: "RR-2 冲回额度·该订单下同时有 type=2 与 type=4 的流水 → 只处理 type=2",
+			Op:   OpRefundActivityRecharge, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, AmountCents: 1000,
+			StuCourseID: orderNo, StuCourseNumericID: 1, Remark: "W2 冲回 type 过滤",
+			Activity: &ActivitySeed{
+				Items: []ActivityItem{
+					{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: 2,
+						MoneyCents: 4500, ActivityMoneyUsedCents: 2000},
+					{Type: 4, InstitutionID: 63317, CampusID: 5, SignedStatus: 2,
+						MoneyCents: 3000, ActivityMoneyUsedCents: 3000},
+				},
+				UsedLogs: []UsedLogSeed{
+					{ActivityIndex: 0, MoneyCents: -2000, Memo: "W2 消耗(type2)"},
+					{ActivityIndex: 1, MoneyCents: -3000, Memo: "W2 消耗(type4)"},
+				},
+			},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "dump:`if(selectActivityType=2) THEN …` ⇒ **type=4 那条一条都不许动**。" +
+				"排除「把所有活动的用量都冲回」——那会把老带新的额度也退掉",
+		},
+	)
+
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,