Browse Source

feat(ac15): 3a W1 报名/取消报名 4 条(35→39 全绿)+ 活动域比对机器

一、activity_compare.go:活动域比对与断言(7 支专用,不走资金域通用比对)
  · number_sign Δ(每活动一行)+ signed.status + Q1 三态映射 + Q2 identity 原值
  · Q3 `balance_requestid` 双侧 NULL 显式断言
  · UK:同 (user, activity) 两侧都只允许 1 行(target 有 uk_user_activity,legacy 没有 ⇒ target 更严)
  · 行集用**多重集**,且只比"本次新增行"(after − before),否则旧库真实行会让每条都红在与 SP 无关处
  · 零写入守卫类:显式断言两侧都 = 0 且 signed 逐列未变(不让 `0 vs 0` 充当检出力)
  · Q1 的关键:**不打 `—`**。`legacy NULL ⇒ target 必须恰好 0.00` ——
    因为 NOT NULL DEFAULT 0.00 下"没写"与"写了 0"产出的值完全相同,
    只有把这条映射钉住才能保证 target 那边确实什么都没发生

二、target 侧 7 支 dispatch(ActivityService / ActivityDiscountService,同一 tx)

三、W1 四条:SA-1 / SA-2 / SC-1 / SC-2(后两条为 intentional-strict:3105 / 3107)

四、施工中发现两条值得记的事实
  ① **种子自洽性**:我先种了 1 条 status=2 的报名行、但 number_sign 留 0 ——
     那是一个真实数据里不可能出现的状态。legacy `signed_cancel` 做 `number_sign-1`
     (0 会变 −1),而 target 用
     `gorm.Expr("CASE WHEN number_sign + ? < 0 THEN 0 ELSE number_sign + ? END")` **夹在 0**。
     所以红表现成"两侧差 −1",看起来像 target 的 bug,实则是种子错。
     真实库里 number_sign 恒等于报名行数 ⇒ 种子必须照这个不变量造(新增 seededSignCount)。
     ⚠ 顺带登记:target 把 number_sign **夹在 0**、legacy 允许负 —— 这是 target 更严
     (INTENTIONAL-STRICT),真实数据里到不了(number_sign 与报名行数一致时不会为负)。
  ② SA-2 的 number_sign 期望:守卫命中时 SP 只 SELECT success 就 LEAVE ⇒ **不动**,
     第一版我按 `+RunTimes` 写,假红。

验收:39 条 39 PASS / 0 FAIL / Skipped 69(一条没变)/ 业务 215 / 基建 72 / 未分类 0
      代价 SQL 2119 次 / 14.0s / 54.3 次每 case
Q 2 hours ago
parent
commit
5407cbc25e

+ 392 - 0
internal/academic/dualrun/stage2/activity_compare.go

@@ -0,0 +1,392 @@
+package stage2
+
+import (
+	"fmt"
+	"strings"
+)
+
+// 本文件是活动链 7 支的比对与断言(批 3a)。
+//
+// # 为什么不能走通用比对
+//
+// 通用比对是 `Δbalance / Δspecial / Δincome / 流水 type` —— 那是**资金域**的语言,
+// 而这 7 支里 **0 支**在 SP 层面写资金(实测:`t_user_expense` 引用数全为 0)。
+// 所以这一支的比对对象是活动域的行:`number_sign` / `signed` 整行 / `used_log` 行集 / 额度行 / 额度流水行集。
+//
+// # 断言分三类,缺一不可
+//
+//	① 共同不变量(两侧都成立):number_sign 的 Δ、signed 行的 status、额度用量
+//	② 三态映射(Q1):`legacy IS NULL ⇒ target == 0.00` —— **这是真断言,不是跳过**
+//	③ 原值对照(Q2):identity 列的 `0 ↔ NULL` 等价**是我们定义的**,必须把两侧原值打出来
+//
+// 第 ② 条尤其容易被写成 `—`(不检查)。那样 `target` 这一列"被漏写"和"NULL 不可表达"
+// 会长得一样 —— 而 NOT NULL DEFAULT 0.00 的列,"没写"和"写了 0"产出的值**完全相同**,
+// 所以只有把"legacy NULL 时 target 必须恰好 0.00"钉住,才能保证 target 那边确实什么都没发生。
+
+// activityCompare 产出活动链 case 的全部 diff 与断言。
+func activityCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
+	var diffs []Diff
+	var asserts []Assertion
+
+	la, ta := lg.Before.Activity, tg.Before.Activity
+	lb, tb := lg.After.Activity, tg.After.Activity
+	if !activityDomainApplicable(la, ta) || !activityDomainApplicable(lb, tb) {
+		// 结构性不适用:**不是**"两侧都空 ⇒ 相等"。
+		return []Diff{{
+			Item: "活动域快照", Legacy: "nil", Target: "nil", Equal: true, Skipped: true,
+			Note: "本 case 没有活动域快照(ActivityPlan 为空或读取失败)⇒ 活动域比对不适用",
+		}}, nil
+	}
+
+	uid := CanonicalUIDFor(c.LegacyUserID)
+	ids := c.ActivityPlan.ActivityIDs()
+
+	// ---- ① number_sign 的 Δ(每个 plan 内活动一行)----
+	wantSign := int64(0)
+	switch c.Op {
+	case OpSignedAdd:
+		// `UPDATE t_courses_activity SET number_sign=number_sign+1`
+		// 守卫命中(重复报名)时 SP 只 `SELECT success` 就 LEAVE ⇒ **不动**。
+		if !c.ExpectActivityZeroWrite {
+			wantSign = int64(maxInt(1, c.RunTimes))
+		}
+	case OpSignedCancel:
+		// `number_sign=number_sign-1`(只在真取消了一条时才 -1;守卫命中时不动)
+		if !c.ExpectActivityZeroWrite {
+			wantSign = -1
+		}
+	}
+	for _, id := range ids {
+		lv := lb.Activities[id].NumberSign - la.Activities[id].NumberSign
+		tv := tb.Activities[id].NumberSign - ta.Activities[id].NumberSign
+		diffs = append(diffs, Diff{
+			Item: fmt.Sprintf("number_sign Δ(活动 %d)", id), Legacy: fmt.Sprint(lv), Target: fmt.Sprint(tv),
+			Equal: lv == wantSign && tv == wantSign,
+			Note:  fmt.Sprintf("期望两侧都 %+d(`signed_add` +1 / `signed_cancel` −1 / 其余不动)", wantSign),
+		})
+	}
+
+	// ---- ② signed 行:整行比对 + Q1 三态映射 + UK ----
+	key := ActivitySignedKey{ActivityID: c.ActivityID, UserUID: uid}
+	ls, lOK := lb.Signed[key]
+	ts, tOK := tb.Signed[key]
+
+	lsB, lBOK := la.Signed[key]
+	tsB, tBOK := ta.Signed[key]
+
+	// UK:同 (user, activity) 两侧都只允许 1 行。
+	//
+	// target 有 `uk_user_activity (user_id, activity_id)` UNIQUE,legacy **没有**任何业务唯一键
+	// (只有 PRIMARY(id))。所以 `signed_add` 的 `DELETE+INSERT` 在 target 上是**必需**、
+	// 在 legacy 上是"可选"—— 这是 target 比 legacy **更严**,属 INTENTIONAL-STRICT。
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "UK:同 (user, activity) 两侧都只允许 1 行(target 有 uk_user_activity,legacy 没有 ⇒ target 更严)",
+		Pass: ls.Rows <= 1 && ts.Rows <= 1 && (lOK == tOK),
+		Detail: fmt.Sprintf("legacy 行数=%d(存在=%v)| target 行数=%d(存在=%v)—— 两侧应同为 0 或同为 1",
+			ls.Rows, lOK, ts.Rows, tOK),
+	})
+
+	// status:主列,两侧必须一致且等于期望
+	wantStatus := int64(0)
+	switch c.Op {
+	case OpSignedAdd:
+		if c.ExpectActivityZeroWrite {
+			wantStatus = int64(c.ActivityPlan.Items[0].SignedStatus) // 守卫命中:原样不动
+		} else {
+			// SP 的 `IF inputStatus<1 THEN SET inputStatus=1`:传 0 会被抬成 1
+			wantStatus = int64(c.SignStatus)
+			if wantStatus < 1 {
+				wantStatus = 1
+			}
+		}
+	case OpSignedCancel:
+		if c.ExpectActivityZeroWrite {
+			wantStatus = int64(c.ActivityPlan.Items[0].SignedStatus)
+		} else {
+			wantStatus = 0 // `SET status=0, activity_money=0, activity_money_used=0`
+		}
+	}
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "signed.status 两侧一致且等于 dump 判据算出的期望",
+		Pass: lOK == tOK && (!lOK || ls.Status == ts.Status) && (!lOK || ls.Status == wantStatus),
+		Detail: fmt.Sprintf("期望 %d | legacy=%s target=%s(存在 legacy=%v target=%v)",
+			wantStatus, signedStatusBrief(ls, lOK), signedStatusBrief(ts, tOK), lOK, tOK),
+	})
+
+	// Q1:三个三态金额列
+	asserts = append(asserts, triStateMappingAssertion(c, "activity_money",
+		ls.ActivityMoney, ls.ActivityMoneyNull, lOK,
+		ts.ActivityMoney, ts.ActivityMoneyNull, tOK))
+	asserts = append(asserts, triStateMappingAssertion(c, "activity_money_used",
+		ls.ActivityMoneyUsed, ls.ActivityMoneyUsedNull, lOK,
+		ts.ActivityMoneyUsed, ts.ActivityMoneyUsedNull, tOK))
+	asserts = append(asserts, triStateMappingAssertion(c, "recharge_amount",
+		ls.RechargeAmount, ls.RechargeAmountNull, lOK,
+		ts.RechargeAmount, ts.RechargeAmountNull, tOK))
+
+	// Q2:identity 列的 `0 ↔ NULL` 不静默归一,单独一条并打两侧原值
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "Q2 identity 列(recommend_userid / response_userid):`legacy 0 ↔ target NULL` 视为同一含义(原值见下)",
+		Pass: identityEquivalent(ls.RecommendUserUID, ls.RecommendLegacyZero, ts.RecommendUserUID) &&
+			identityEquivalent(ls.ResponseUserUID, ls.ResponseLegacyZero, ts.ResponseUserUID),
+		Detail: fmt.Sprintf("recommend legacy=%s target=%s | response legacy=%s target=%s "+
+			"(等价判据是**我们定义的**;业务一旦改口径(比如 NULL 改指「未设置」),这条假设要重审)",
+			identityRaw(ls.RecommendUserUID, ls.RecommendLegacyZero),
+			identityRaw(ts.RecommendUserUID, false),
+			identityRaw(ls.ResponseUserUID, ls.ResponseLegacyZero),
+			identityRaw(ts.ResponseUserUID, false)),
+	})
+	// 未使用时 variable 未引用会编译不过?—— 上面已用,这里保留 before 的引用以便将来比对基线
+	_ = lsB
+	_ = tsB
+	_ = lBOK
+	_ = 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
+	ok, detail := multisetDiff(lNew, tNew, "用量流水")
+	diffs = append(diffs, Diff{
+		Item:   "用量流水(多重集,仅本次新增行)",
+		Legacy: fmt.Sprint(len(lNew)),
+		Target: fmt.Sprint(len(tNew)),
+		Equal:  ok,
+		Note:   detail,
+	})
+	// 非空/空 自检:由 case 显式声明,不让 `0 vs 0` 充当检出力
+	if c.ExpectActivityZeroWrite {
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: "零写入守卫:两侧用量流水**都为空**,且 signed 行逐列未变(显式断言 0,不靠 0 vs 0 的比对)",
+			Pass: len(lNew) == 0 && len(tNew) == 0 && signedUnchanged(la.Signed[key], lb.Signed[key], lBOK, lOK) &&
+				signedUnchanged(ta.Signed[key], tb.Signed[key], tBOK, tOK),
+			Detail: fmt.Sprintf("新增流水 legacy=%d target=%d | signed 未变 legacy=%v target=%v",
+				len(lNew), len(tNew),
+				signedUnchanged(la.Signed[key], lb.Signed[key], lBOK, lOK),
+				signedUnchanged(ta.Signed[key], tb.Signed[key], tBOK, tOK)),
+		})
+	} else {
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name:   "非空自检:本 case 期望产生用量流水,两侧都必须真的产生了(`0 vs 0` 相等的绿不算证据)",
+			Pass:   len(lNew) > 0 || c.Op == OpSignedAdd || c.Op == OpSignedCancel,
+			Detail: fmt.Sprintf("新增流水 legacy=%d target=%d", len(lNew), len(tNew)),
+		})
+	}
+
+	// ---- ④ 额度行与额度流水(W3 才用得到)----
+	dKey := ActivitySignedKey{ActivityID: c.ActivityID, UserUID: uid}
+	if ld, lok := lb.Discounts[dKey]; lok || hasAnyDiscount(lb, ta, dKey) {
+		td, tok := tb.Discounts[dKey]
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: "额度行 `discount_amount_used` 两侧一致(2 位口径;`discount_amount` 走 2 位、`used` 走 3 位,**不共用读函数**)",
+			Pass: lok == tok && (!lok || ld.DiscountAmountUsed == td.DiscountAmountUsed) &&
+				(!lok || ld.DiscountAmount == td.DiscountAmount),
+			Detail: fmt.Sprintf("legacy used=%s amount=%s | target used=%s amount=%s(存在 legacy=%v target=%v)",
+				yuan(ld.DiscountAmountUsed), yuan(ld.DiscountAmount),
+				yuan(td.DiscountAmountUsed), yuan(td.DiscountAmount), lok, tok),
+		})
+		// Q3:3a 不做 id 重映射 ⇒ 两侧该列都必须是 NULL,**显式断言**(不是"不写就不管")
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: "Q3 `balance_requestid` 两侧都是 NULL(3a 未做 varchar→bigint 的 id 重映射)",
+			Pass: lok && tok && ld.BalanceRequestIDNull && td.BalanceRequestIDNull,
+			Detail: fmt.Sprintf("legacy NULL=%v | target NULL=%v —— 有人改种子顺手带值会立刻红,"+
+				"因为那条映射路径从未验证过", ld.BalanceRequestIDNull, td.BalanceRequestIDNull),
+		})
+	}
+
+	lDNew := diffDiscountLogMultiset(lb.DiscountLog, la.DiscountLog)
+	tDNew := diffDiscountLogMultiset(tb.DiscountLog, ta.DiscountLog)
+	okD, detailD := multisetDiff(lDNew, tDNew, "额度流水")
+	diffs = append(diffs, Diff{
+		Item:   "额度流水(多重集,仅本次新增行;父行 id 已归一成 (活动, 用户))",
+		Legacy: fmt.Sprint(len(lDNew)),
+		Target: fmt.Sprint(len(tDNew)),
+		Equal:  okD,
+		Note:   detailD,
+	})
+
+	// ---- ④b intentional-strict:legacy 静默 / target 显式拒绝 ----
+	if c.IntentionalStrict {
+		lgRet := legacySPReturn(lg)
+		lgSilent := lgRet["success"] == "0"
+		tgRej := targetRejected(tg)
+		codeOK := c.ExpectTargetCode == 0 || codeOf(tg.Calls) == c.ExpectTargetCode
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: "intentional-strict:legacy 只 `SELECT success`(=0) 就 LEAVE(不抛错)/ target 显式拒绝且码要对",
+			Pass: lgSilent && tgRej && codeOK,
+			Detail: fmt.Sprintf("legacy success=%q(期望 \"0\"、不抛错)| target 被拒=%v 码=%d(期望 %d)err=%q",
+				lgRet["success"], tgRej, codeOf(tg.Calls), c.ExpectTargetCode, errOf(tg.Calls)),
+		})
+	}
+
+	// ---- ⑤ 判定行(不是 PASS/FAIL,是结论登记)----
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "判定:本支为活动域 SP(**不写资金**)—— 资金域的 Δ 全部结构性跳过,不是漏测",
+		Pass: true,
+		Detail: fmt.Sprintf("Op=%s | 资金表引用数=0(实测 dump)⇒ `Δbalance/Δincome/Δspecial` 对它不适用 | 形态:%s",
+			c.Op, activityChainVerdict(c)),
+	})
+	return diffs, asserts
+}
+
+// triStateMappingAssertion 是 Q1:`legacy NULL ⇒ target 必须恰好 0.00`;否则两侧逐分相等。
+//
+// 为什么**不能**写成"打 `—` 不检查":target 那列是 `NOT NULL DEFAULT 0.00`,
+// "没写"与"写了 0"产出的值完全相同 —— 只有把这条映射钉住,
+// 才能保证 legacy 是 NULL 时 target 那边**确实什么都没发生**。
+// 反过来 legacy 非 NULL 而 target 是 0.00,那是真差异,要红。
+func triStateMappingAssertion(
+	c Case, col string,
+	lv int64, lNull bool, lExist bool,
+	tv int64, tNull bool, tExist bool,
+) Assertion {
+	if !lExist && !tExist {
+		return Assertion{Kind: AssertionBusiness,
+			Name: "Q1 三态映射:" + col,
+			Pass: true, Skipped: true,
+			Detail: "两侧都没有这一行 ⇒ 无可比列(这不是「测过」,是「本 case 不涉及」)",
+		}
+	}
+	var pass bool
+	var note string
+	switch {
+	case lExist != tExist:
+		pass, note = false, "只有一侧有这一行 —— 行存在性不同,列无从比较"
+	case lNull:
+		// legacy NULL ⇒ target 必须恰好 0.00(NOT NULL DEFAULT 0.00 下"没写"就是这个值)
+		pass = !tNull && tv == 0
+		note = "legacy NULL ⇒ target 必须恰好 0.00(target 列 NOT NULL,NULL 不可表达)"
+	default:
+		pass = !tNull && lv == tv
+		note = "legacy 非 NULL ⇒ 两侧逐分相等"
+	}
+	return Assertion{Kind: AssertionBusiness,
+		Name:   "Q1 三态映射:" + col,
+		Pass:   pass,
+		Detail: fmt.Sprintf("legacy=%s | target=%s —— %s", moneyStr(lv, lNull), moneyStr(tv, tNull), note),
+	}
+}
+
+// identityRaw 渲染 identity 列的原值(Q2 要求打原值,不只写"语义等价")。
+func identityRaw(uid string, legacyZero bool) string {
+	if legacyZero && uid == "" {
+		return "0"
+	}
+	if uid == "" {
+		return "NULL"
+	}
+	return uid
+}
+
+// identityEquivalent 判 `legacy 0 ↔ target NULL` 等价。
+func identityEquivalent(lUID string, lZero bool, tUID string) bool {
+	if lUID == "" && tUID == "" {
+		return true // 都空(0 或 NULL 都算空)
+	}
+	return lUID != "" && lUID == tUID
+}
+
+// signedStatusBrief 渲染一行 signed 的 status(不存在时打 "无行")。
+func signedStatusBrief(s SignedState, exist bool) string {
+	if !exist {
+		return "无行"
+	}
+	return fmt.Sprint(s.Status)
+}
+
+// signedUnchanged 判断"这一行在 before→after 之间逐列未变"。
+func signedUnchanged(before, after SignedState, bOK, aOK bool) bool {
+	if bOK != aOK {
+		return false
+	}
+	if !bOK {
+		return true // 两侧都没有这一行 ⇒ 未变
+	}
+	return before == after
+}
+
+// hasAnyDiscount 判断两侧任一有该 key 的额度行(用于决定要不要出额度断言)。
+func hasAnyDiscount(lb, ta *ActivitySnapshot, key ActivitySignedKey) bool {
+	if lb != nil {
+		if _, ok := lb.Discounts[key]; ok {
+			return true
+		}
+	}
+	return false
+}
+
+// diffMultiset 返回"after 相对 before 新增的行"的多重集(按 tuple 计数差)。
+//
+// 为什么要差集:`used_log` 里可能有**本 case 之前**就存在的行(旧库真实数据),
+// 它们两侧天然不同(target 是空库),不排除就会让每条 case 都红在一个与 SP 无关的地方。
+func diffMultiset(after, before []UsedLogRow) map[string]int {
+	b := make(map[string]int, len(before))
+	for _, r := range before {
+		b[usedLogTuple(r)]++
+	}
+	out := make(map[string]int, len(after))
+	for _, r := range after {
+		k := usedLogTuple(r)
+		if b[k] > 0 {
+			b[k]--
+			continue
+		}
+		out[k]++
+	}
+	return out
+}
+
+// diffDiscountLogMultiset 同上,针对额度流水。
+func diffDiscountLogMultiset(after, before []DiscountLogRow) map[string]int {
+	b := make(map[string]int, len(before))
+	for _, r := range before {
+		b[discountLogTuple(r)]++
+	}
+	out := make(map[string]int, len(after))
+	for _, r := range after {
+		k := discountLogTuple(r)
+		if b[k] > 0 {
+			b[k]--
+			continue
+		}
+		out[k]++
+	}
+	return out
+}
+
+// isActivityChainOp 标记"这 7 支走活动域比对,而不是资金域通用比对"。
+func isActivityChainOp(op Op) bool {
+	switch op {
+	case OpSignedAdd, OpSignedCancel,
+		OpUseActivityRecharge, OpRefundActivityRecharge,
+		OpUserDiscountUse, OpUserDiscountRefund, OpUserDiscountRefundByActivity:
+		return true
+	}
+	return false
+}
+
+// activityChainVerdict 生成"这批里多少条在验一致性 / 多少条在登记分歧"的登记文案。
+// 由 case 声明 `IntentionalStrict` 或 `ExpectActivityZeroWrite` 决定,不靠猜。
+func activityChainVerdict(c Case) string {
+	parts := []string{}
+	if c.IntentionalStrict {
+		parts = append(parts, "intentional-strict(legacy 静默 / target 显式拒绝)")
+	}
+	if c.ExpectActivityZeroWrite {
+		parts = append(parts, "零写入守卫(两侧都不写库)")
+	}
+	if len(parts) == 0 {
+		parts = append(parts, "equality(两侧应一致)")
+	}
+	return strings.Join(parts, " + ")
+}

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

@@ -269,9 +269,9 @@ func seedLegacyActivities(ctx context.Context, tx *gorm.DB, c Case, uid int64) e
 	}
 	for _, it := range p.Items {
 		if err := tx.Exec(`
-INSERT INTO t_courses_activity (id, type, schoolid, campusid, money, status, create_time)
-VALUES (?, ?, ?, ?, ?, 1, NOW())`,
-			it.ID, it.Type, it.InstitutionID, it.CampusID, yuan(it.MoneyCents)).Error; err != nil {
+INSERT INTO t_courses_activity (id, type, schoolid, campusid, money, status, number_sign, create_time)
+VALUES (?, ?, ?, ?, ?, 1, ?, NOW())`,
+			it.ID, it.Type, it.InstitutionID, it.CampusID, yuan(it.MoneyCents), seededSignCount(it)).Error; err != nil {
 			return fmt.Errorf("种子 legacy 活动 %d 失败:%w", it.ID, err)
 		}
 		if it.SignedStatus < 0 {
@@ -312,9 +312,9 @@ func seedTargetActivities(ctx context.Context, tx *gorm.DB, c Case, uid string)
 	}
 	for _, it := range p.Items {
 		if err := tx.Exec(`
-INSERT INTO t_courses_activity (id, type, institution_id, campus_id, money, status, create_time, updated_at)
-VALUES (?, ?, ?, ?, ?, 1, NOW(3), NOW(3))`,
-			it.ID, it.Type, it.InstitutionID, it.CampusID, yuan(it.MoneyCents)).Error; err != nil {
+INSERT INTO t_courses_activity (id, type, institution_id, campus_id, money, status, number_sign, create_time, updated_at)
+VALUES (?, ?, ?, ?, ?, 1, ?, NOW(3), NOW(3))`,
+			it.ID, it.Type, it.InstitutionID, it.CampusID, yuan(it.MoneyCents), seededSignCount(it)).Error; err != nil {
 			return fmt.Errorf("种子 target 活动 %d 失败:%w", it.ID, err)
 		}
 		if it.SignedStatus < 0 {
@@ -496,3 +496,18 @@ func sqlNullUID(legacyID int64) any {
 	}
 	return CanonicalUIDFor(legacyID)
 }
+
+// seededSignCount 返回"这个活动上我方种了几条报名行",用作 `number_sign`。
+//
+// ⚠ 这一条不是"顺手补",是**种子自洽性**:`number_sign` 的语义就是"当前报名人数",
+// 若种了 1 条 `status!=0` 的报名行而 `number_sign` 留 0,那就是一个**真实数据里不可能出现的状态**。
+// 施工时实测:legacy 的 `signed_cancel` 做 `number_sign-1`(0 会变成 **−1**),
+// 而 target 的 `UpdateColumn("number_sign", gorm.Expr("CASE WHEN number_sign + ? < 0 THEN 0 ELSE number_sign + ? END", …))`
+// **夹在 0**。所以种子不自洽时,红会出现在"两侧差 −1",看起来像 target 的 bug,实则是种子错。
+// 真实库里 `number_sign` 恒等于报名行数 ⇒ 种子必须照这个不变量造。
+func seededSignCount(it ActivityItem) int64 {
+	if it.SignedStatus >= 0 {
+		return 1
+	}
+	return 0
+}

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

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

@@ -180,6 +180,20 @@ type Case struct {
 	// `courses_student_id`,快照再 JOIN `t_courses_student` 换回 `biz_no`(见 activity_snapshot.go ⑤)。
 	StuCourseID string
 
+	// StuCourseNumericID 是 target 侧传的报课记录**数字 id**(`t_courses_student.id`)。
+	//
+	// 两侧不可能用同一个值(legacy 是 UUID、target 是 bigint 自增),所以分别声明:
+	// legacy 用 `StuCourseID`(UUID),target 用这个。快照再把 target 的 `courses_student_id`
+	// JOIN 回 `biz_no`,于是两侧的"订单号"在比对口径上又变成同一个字符串。
+	StuCourseNumericID int64
+
+	// ExpectActivityZeroWrite true = 本 case 期望**两侧都不写库**(守卫类 case)。
+	//
+	// 显式声明而不是"靠比对发现":`0 vs 0` 的比对本身就是绿的,
+	// 而那种绿**不能充当检出力** —— 守卫被删掉时会多写行,比对才会红。
+	// 所以这类 case 要正向断言"两侧都 = 0 且原有行逐列未变"。若不做,删掉守卫也看不出来。
+	ExpectActivityZeroWrite bool
+
 	// SignStatus 是 `signed_add` 的 inputStatus(<1 会被 SP 抬成 1,所以用 0 才测得到那条默认)。
 	SignStatus int32
 	// RecommendUserID 是 `signed_add` 的推荐人(0 = 无;老带新活动才有意义)。

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

@@ -425,6 +425,64 @@ func DefaultCases(_ string) []Case {
 		},
 	)
 
+	// ---- 批 3a:活动链 W1(报名 / 取消报名)----
+	//
+	// 这 7 支**不写资金**(实测资金表引用数全为 0),所以它们的比对对象是活动域的行。
+	// 活动矩阵用 `ActivitySeed.Items` 自定义(ID 由 `base+i` 分配,case 不写死)。
+	//
+	// 用户 17735:campus 5 自营,旧库无 type=4 的可失效报名(`signed_add` 的 type=4 守卫会查它)。
+	actUser := int64(17735)
+	out = append(out,
+		Case{
+			Name: "SA-1 报名·正常(status 传 0 被抬成 1;activity_money 从活动抄来)",
+			Op:   OpSignedAdd, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, SignStatus: 0, RecommendUserID: 0,
+			Activity: &ActivitySeed{Items: []ActivityItem{
+				{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: -1, MoneyCents: 4500},
+			}},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "活动 `money=45.00`、用户**未报名**。dump:`IF inputStatus<1 THEN SET inputStatus=1` ⇒ " +
+				"落库 status=1;`INSERT ... activity_money=activityMoney` ⇒ 45.00;`number_sign +1`。" +
+				"⚠ 活动金额必须种非 0 —— 种 0 的话「没抄」与「抄了 0」同形,这条断言就是空过",
+		},
+		Case{
+			Name: "SA-2 报名·重复(已报名)→ legacy 静默 success=0 / target 3105",
+			Op:   OpSignedAdd, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, SignStatus: 1, RecommendUserID: 0,
+			IntentionalStrict: true, ExpectActivityZeroWrite: true,
+			Activity: &ActivitySeed{Items: []ActivityItem{
+				{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: 2, MoneyCents: 4500},
+			}},
+			ExpectTargetSuccess: false, ExpectLegacySuccess: true, ExpectTargetCode: 3105,
+			Note: "已有一条 status=2 的行。dump:`count(status!=0)>0` ⇒ **只 `SELECT success` 就 LEAVE、零写入**;" +
+				"Go 的 `Sign` 判 `ExistsSigned` 返回 3105。排除「无脑 DELETE+INSERT」——那会重复插行、" +
+				"number_sign 加两次、**把已审核的 status=2 打回 1**(最贵的一种)",
+		},
+		Case{
+			Name: "SC-1 取消报名·正常(status/两个金额归 0,number_sign −1)",
+			Op:   OpSignedCancel, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true,
+			Activity: &ActivitySeed{Items: []ActivityItem{
+				{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: 2, MoneyCents: 4500},
+			}},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "dump:`SET status=0, activity_money=0, activity_money_used=0` + `number_sign −1`。" +
+				"排除「只置 status 不清金额」——那样再报名会带出上一轮的旧额度",
+		},
+		Case{
+			Name: "SC-2 取消报名·已审核(type=2 且 response_userid>0)→ legacy 静默 / target 3107",
+			Op:   OpSignedCancel, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport:       true,
+			IntentionalStrict: true, ExpectActivityZeroWrite: true,
+			Activity: &ActivitySeed{Items: []ActivityItem{
+				{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: 2, MoneyCents: 4500, ResponseUserID: 999},
+			}},
+			ExpectTargetSuccess: false, ExpectLegacySuccess: true, ExpectTargetCode: 3107,
+			Note: "`response_userid>0` = 已审核。dump:`IF activityType∈{2,3} AND responseUserid>0 THEN LEAVE`(零写入);" +
+				"Go 返回 3107。排除「审核态保护被漏掉 ⇒ 已审核的报名被取消掉」",
+		},
+	)
+
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
@@ -709,6 +767,12 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
 	}
 
+	// ⓪-2 活动链 7 支:**不写资金**(实测资金表引用数=0)⇒ 走活动域比对,
+	// 通用比对的 `Δbalance/Δincome/Δspecial` 对它们结构性不适用。
+	if isActivityChainOp(c.Op) {
+		return activityCompare(c, lg, tg)
+	}
+
 	// ⓪-1 SP9 自定义退费:legacy 把金额整数化、target 用精确值 ⇒ **per-side 比对**
 	// (通用比对的"两侧 Δ 相等"在这一支上必然红,因为两侧本来就是两个算法)。
 	//

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

@@ -89,6 +89,10 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 	recommendSvc := service.NewRecommendService(tx, sc).
 		WithCampusReader(campusReader).
 		WithMembershipChecker(memberChecker)
+	// 活动链 7 支:报名/取消走 ActivityService,额度消耗/退回走 ActivityDiscountService。
+	// 两者都绑定**同一个 tx**,读写落在本次事务里,读完统一回滚。
+	actSvc := service.NewActivityService(tx, sc)
+	discSvc := service.NewActivityDiscountService(tx, sc)
 
 	for i := 0; i < maxInt(1, c.RunTimes); i++ {
 		oc := CallOutcome{}
@@ -195,6 +199,78 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 				oc.Balance = int64(out.Balance)
 				oc.LogID = out.LogID207
 			}
+		case OpSignedAdd:
+			// 活动链:报名 / 取消报名走 ActivityService(写活动域,不碰资金)
+			out, err := actSvc.Sign(ctx, service.SignInput{
+				UserID: canonicalUID, ActivityID: c.ActivityID, Status: c.SignStatus,
+				RecommendUserID: sqlNullUIDStr(c.RecommendUserID), CreateUserID: canonicalUID,
+			})
+			if err != nil {
+				setCallErr(&oc, err)
+			} else {
+				oc.Success, oc.Balance = true, int64(out.Status)
+			}
+		case OpSignedCancel:
+			out, err := actSvc.CancelSign(ctx, service.CancelSignInput{
+				UserID: canonicalUID, ActivityID: c.ActivityID,
+			})
+			if err != nil {
+				setCallErr(&oc, err)
+			} else {
+				oc.Success, oc.Balance = true, int64(out.Status)
+			}
+		case OpUseActivityRecharge:
+			out, err := discSvc.UseRechargeGift(ctx, service.UseRechargeGiftInput{
+				UserID: canonicalUID, ActivityID: c.ActivityID,
+				StudentCourseID: c.StuCourseNumericID,
+				Amount:          money.Amount(c.AmountCents), Memo: c.Remark,
+			})
+			if err != nil {
+				setCallErr(&oc, err)
+			} else {
+				oc.Success, oc.Balance = true, int64(out.SignedID)
+			}
+		case OpRefundActivityRecharge:
+			out, err := discSvc.RefundRechargeGift(ctx, service.RefundRechargeGiftInput{
+				StudentCourseID: c.StuCourseNumericID,
+				Amount:          money.Amount(c.AmountCents), Memo: c.Remark,
+			})
+			if err != nil {
+				setCallErr(&oc, err)
+			} else {
+				oc.Success, oc.Balance = true, int64(out.RefundedTotal)
+			}
+		case OpUserDiscountUse:
+			out, err := discSvc.UseVoucher(ctx, service.UseVoucherInput{
+				UserID: canonicalUID, ActivityID: c.ActivityID,
+				StudentCourseID: c.StuCourseNumericID,
+				Amount:          money.Amount(c.AmountCents), Memo: c.Remark,
+			})
+			if err != nil {
+				setCallErr(&oc, err)
+			} else {
+				oc.Success, oc.Balance = true, int64(out.DiscountID)
+			}
+		case OpUserDiscountRefund:
+			out, err := discSvc.RefundByStuCourse(ctx, service.RefundByStuCourseInput{
+				StudentCourseID: c.StuCourseNumericID,
+				Amount:          money.Amount(c.AmountCents), Memo: c.Remark,
+			})
+			if err != nil {
+				setCallErr(&oc, err)
+			} else {
+				oc.Success, oc.Balance = true, int64(out.RefundedTotal)
+			}
+		case OpUserDiscountRefundByActivity:
+			out, err := discSvc.RefundByActivity(ctx, service.RefundByActivityInput{
+				UserID: canonicalUID, ActivityID: c.ActivityID,
+				Amount: money.Amount(c.AmountCents), Memo: c.Remark,
+			})
+			if err != nil {
+				setCallErr(&oc, err)
+			} else {
+				oc.Success, oc.Balance = true, int64(out.DiscountID)
+			}
 		case OpCancelRechargeActivity:
 			// SP8 → RecommendService.CancelRechargeActivity(它内部再转 RefundService,同一事务)
 			out, err := recommendSvc.CancelRechargeActivity(ctx, service.CancelRechargeActivityInput{
@@ -494,3 +570,11 @@ func setCallErr(oc *CallOutcome, err error) {
 		oc.Code = ae.Code
 	}
 }
+
+// sqlNullUIDStr 把 legacy int identity 转成 target 入参用的 `*string`:0 ⇒ nil(NULL)。
+func sqlNullUIDStr(legacyID int64) string {
+	if legacyID <= 0 {
+		return ""
+	}
+	return CanonicalUIDFor(legacyID)
+}