Ver Fonte

feat(ac15): 批C1 补三个边界缺口 + 判据加固(全量 24/24 PASS)

三个派单要求、原 8 条 case 都没覆盖的缺口:

- C1-9 requestid 33 字符:legacy `t_user_expense_log.logid` 是 varchar(32)、
  SP8 入参却是 varchar(50) ⇒ INSERT log 报 1406。关键在于**同一次 CALL 里前置的
  两条 UPDATE 不回滚**(MySQL 只回滚失败的那条语句),所以 legacy 终态是
  「钱扣了、凭证没有」;target `log_id/request_id` varchar(64) 完整落库。
  归类 INTENTIONAL-WIDEN(我们更宽,不是缺陷)。
- C1-10 退成负数:SP8 正文只有 `IF inputMoney<0 THEN SET inputMoney=0`,
  **没有任何 balance >= money 校验**。余额压到 100.00 再退 500.00 → 两侧都 −400.00。
  这是第 3 条负余额来源(另两条:SP11 spend、custom_refund 坏账分支)。
- C1-11 校区账户不存在(dump 的 `IF logCount=0 THEN INSERT` 分支):
  legacy 该表金额列是 `decimal(10,3) NULL DEFAULT NULL`,建行语句只写三列
  ⇒ 新行金额全 NULL,`SET balance=balance-m` 在 NULL 上仍是 NULL
  ⇒ **校区一分没扣,但 type=209 凭证照写**;全局表有 DEFAULT 0.000 所以真扣了,
  同一笔业务两表自相矛盾。判定 LEGACY-DEFECT:target 侧(NOT NULL DEFAULT 0.00)
  才是对的,不跟。

判据加固(这几处不改就会静默退化成"空断言"):

- applyBaselineLegacy / applyBaselineTarget 校验 RowsAffected == 1。
  账户行不存在时 `UPDATE ... WHERE` 影响 0 行且不报错,基线根本没设上、
  case 却拿真实余额跑完了 —— 绿得毫无信息。
- legacy 校区账户快照容忍 NULL(`sql.NullString`)。否则 C1-11 在读回阶段就报
  "converting NULL to string is unsupported",连断言都走不到;
  新增 `SideSnapshot.CampusNullBalance` 显式记录"行在、但金额是 NULL"。
- `campusAccountAssertion` 的期望值改由 `CampusMissing` / `NoCampusAccount`
  **显式声明**,不再用 `!CampusMissing` 单条件推(后者一遇"校区在、账户不在"就假红)。
- 新增 `Case.SpecialCase`:给"两侧本来就该不同"的 case 一条专用比对通道,
  避免为了消红去放宽通用判据。未知取值会直接报红,不能变成空测试。

回归:全量 24 case(批A 6 + 批B 4 + A7 + C1-1..C1-11 + 回归 2)
24 PASS / 0 FAIL —— 批 A/批 B 的 13 条判据在混合状态后依然全绿。

cmd/ac15sp 将在 spdump 合并后删除。
Q há 5 horas atrás
pai
commit
666146f368

+ 220 - 2
internal/academic/dualrun/stage2/c1_compare.go

@@ -23,6 +23,9 @@ func c1Assertions(
 	// ① 两侧看到的校区元数据必须与 case 声明一致(自营/加盟走不同分支的根源)
 	out = append(out, campusMetaAssertion(c, lg, tg))
 	out = append(out, campusAccountAssertion(c, lg, tg))
+	if c.NoCampusAccount {
+		out = append(out, campusAccountCreatedAssertion(c, lg, tg))
+	}
 
 	// ② 活动失效范围(清零)或报名状态归 0(取消活动)
 	if c.ActivityPlan != nil {
@@ -132,21 +135,46 @@ func campusMetaAssertion(c Case, lg, tg *SideResult) Assertion {
 //
 // 没有这条,"读不到账户"会被读成 0 余额,两侧都是 0 → 基线相等"空过",
 // 整个 case 看起来在跑、其实什么都没测。
+//
+// 期望值由 case **显式声明**(CampusMissing / NoCampusAccount),不是"看实测是什么就认什么"。
 func campusAccountAssertion(c Case, lg, tg *SideResult) Assertion {
-	want := !c.CampusMissing
+	want := !c.CampusMissing && !c.NoCampusAccount
 	ok := lg.Before.HasCampusAccount == want && tg.Before.HasCampusAccount == want
 	word := "存在"
+	reason := ""
 	if !want {
 		word = "不存在"
+		if c.CampusMissing {
+			reason = "(CampusMissing:校区本身就不存在)"
+		} else {
+			reason = "(NoCampusAccount:校区存在但该用户没有账户行)"
+		}
 	}
 	return Assertion{
-		Name: "校区账户行" + word + "(CampusMissing 时故意不存在)",
+		Name: "调用前:校区账户行" + word + reason,
 		Pass: ok,
 		Detail: fmt.Sprintf("legacy 存在=%v | target 存在=%v(期望都为 %v)",
 			lg.Before.HasCampusAccount, tg.Before.HasCampusAccount, want),
 	}
 }
 
+// campusAccountCreatedAssertion 断言"调用后校区账户行确实被建出来了"。
+//
+// 只在 NoCampusAccount 用例上有意义:SP8 的 `IF logCount=0 THEN INSERT INTO t_user_expense_school`
+// 这段的全部作用就是"先建行再扣"。如果 Go 侧只做 UPDATE 不 UPSERT,
+// 那条 UPDATE 会静默影响 0 行 —— 余额、流水全都对不上,但**错误会被误读成"金额算错"**。
+// 单独把"行被建出来了"钉成一条,红的时候一眼知道是建户分支没走到。
+func campusAccountCreatedAssertion(c Case, lg, tg *SideResult) Assertion {
+	ok := lg.After.HasCampusAccount && tg.After.HasCampusAccount
+	return Assertion{
+		Name: "调用后:校区账户行被建出来(dump 的 `IF logCount=0 THEN INSERT` 分支)",
+		Pass: ok,
+		Detail: fmt.Sprintf("legacy 存在=%v 余额=%s | target 存在=%v 余额=%s(都应为 true)",
+			lg.After.HasCampusAccount, yuan(lg.After.Campus.Balance),
+			tg.After.HasCampusAccount, yuan(tg.After.Campus.Balance)),
+	}
+}
+
 // signedAssertion 断言"调用后的报名状态 = 按 dump 判据算出的期望"(整张 map 全等)。
 func signedAssertion(c Case, lg, tg *SideResult) Assertion {
 	var want map[int64]int32
@@ -185,6 +213,196 @@ func spReturnAssertion(c Case, lg *SideResult) Assertion {
 	}
 }
 
+// ---------- 请求号 33 字符(INTENTIONAL-WIDEN) ----------
+
+// requestIDOverflowCompare 是 `SpecialRequestIDOverflow` 的专用比对。
+//
+// 为什么不能走通用比对:这条 case 的结论**本来就是"两侧不相等"**——
+// legacy 在 `INSERT t_user_expense_log` 那一步被 varchar(32) 挡住(1406),
+// 而它前面的两条 UPDATE 已经执行过(MySQL 只回滚**失败的那条语句**,不回滚同一次 CALL 里的前置语句),
+// 所以 legacy 的终态是"钱扣了、流水没有";target 则正常落库。
+//
+// 这条 case 要钉死四件事:
+//  1. legacy **确实**是 1406(而不是别的错),且点到了 logid 列 —— 证明是列宽,不是逻辑
+//  2. legacy 钱**已经扣了**、但流水 0 条 —— 把"半途失败留下无凭证扣款"这个真实缺陷摆出来
+//  3. target 成功,且 33 字符请求号**完整落库**(没被静默截断)—— 这是 WIDEN 的直接证据
+//  4. 两侧 Δbalance 相同 —— 钱的结果一致,差异只在"有没有凭证"
+func requestIDOverflowCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
+	var diffs []Diff
+	var asserts []Assertion
+
+	lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
+	tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
+	lgErr := errOf(lg.Calls)
+	dGlobal := sub(lg.After.Global, lg.Before.Global)
+	tGlobal := sub(tg.After.Global, tg.Before.Global)
+	dCampus := sub(lg.After.Campus, lg.Before.Campus)
+	tCampus := sub(tg.After.Campus, tg.Before.Campus)
+	want := -c.AmountCents
+
+	diffs = append(diffs,
+		Diff{Item: "请求号长度", Legacy: fmt.Sprint(len(c.RequestID)) + " 字符 → logid varchar(32)",
+			Target: fmt.Sprint(len(c.RequestID)) + " 字符 → request_id varchar(64)",
+			Equal:  true, Note: "列宽不同是本条的**结论**,不是被测行为"},
+		Diff{Item: "legacy 是否成功", Legacy: fmt.Sprint(lgOK), Target: "N/A",
+			Equal: !lgOK, Note: "期望 false:1406(logid 放不下)"},
+		Diff{Item: "target 是否成功", Legacy: "N/A", Target: fmt.Sprint(tgOK),
+			Equal: tgOK, Note: "期望 true:varchar(64) 放得下"},
+		Diff{Item: "Δbalance(两侧都应为 −金额)",
+			Legacy: yuan(dGlobal.Balance), Target: yuan(tGlobal.Balance),
+			Equal: dGlobal.Balance == want && tGlobal.Balance == want,
+			Note:  "legacy 的两条 UPDATE 在失败的 INSERT **之前**,所以钱照扣"},
+		Diff{Item: "流水条数", Legacy: fmt.Sprint(len(lg.After.Logs)), Target: fmt.Sprint(len(tg.After.Logs)),
+			Equal: true, Note: "**本来就不同**:legacy 0 条(写 log 失败)/ target 1 条 —— 这正是要记录的差异"},
+	)
+
+	asserts = append(asserts,
+		Assertion{
+			Name:   "前提:入参请求号 **确实 33 字符**(> legacy 的 32,否则本条退化成普通 case)",
+			Pass:   len(c.RequestID) == 33,
+			Detail: fmt.Sprintf("len(requestId)=%d(期望 33)", len(c.RequestID)),
+		},
+		Assertion{
+			Name:   "legacy 报的是 **1406(Data too long)且点名字段 logid** —— 证明是列宽不是逻辑",
+			Pass:   !lgOK && strings.Contains(lgErr, "1406") && strings.Contains(strings.ToLower(lgErr), "logid"),
+			Detail: fmt.Sprintf("legacy success=%v err=%q", lgOK, lgErr),
+		},
+		Assertion{
+			Name: "legacy **钱已扣、流水 0 条**(同一次 CALL 里前置 UPDATE 不回滚)—— 缺陷在 legacy 一侧",
+			Pass: dGlobal.Balance == want && dCampus.Balance == want && len(lg.After.Logs) == 0,
+			Detail: fmt.Sprintf("Δ全局=%s Δ校区=%s 流水=%d 条(期望各 %s、0 条)",
+				yuan(dGlobal.Balance), yuan(dCampus.Balance), len(lg.After.Logs), yuan(want)),
+		},
+		Assertion{
+			Name: "target 成功且 **33 字符请求号完整落库**(没被静默截断 / 没报错)",
+			Pass: tgOK && len(tg.After.Logs) == 1 &&
+				tg.After.Logs[0].RequestID == c.RequestID && tg.After.Logs[0].LogID == c.RequestID &&
+				len(tg.After.Logs[0].RequestID) == len(c.RequestID),
+			Detail: fmt.Sprintf("target success=%v 流水=%d 条 request_id 长度=%d log_id 长度=%d(期望 %v/1/%d/%d)",
+				tgOK, len(tg.After.Logs), len(reqIDOf(tg.After.Logs)), len(logIDOf(tg.After.Logs)),
+				true, len(c.RequestID), len(c.RequestID)),
+		},
+		Assertion{
+			Name: "两侧 Δbalance 相同(钱的结果一致,差异只在**有没有凭证**)",
+			Pass: dGlobal.Balance == tGlobal.Balance && dCampus.Balance == tCampus.Balance,
+			Detail: fmt.Sprintf("全局 legacy=%s target=%s | 校区 legacy=%s target=%s",
+				yuan(dGlobal.Balance), yuan(tGlobal.Balance),
+				yuan(dCampus.Balance), yuan(tCampus.Balance)),
+		},
+	)
+	return diffs, asserts
+}
+
+// reqIDOf / logIDOf 取第一条流水的请求号(没有流水时返回空串,长度 0)。
+func reqIDOf(logs []LogRow) string {
+	if len(logs) == 0 {
+		return ""
+	}
+	return logs[0].RequestID
+}
+
+func logIDOf(logs []LogRow) string {
+	if len(logs) == 0 {
+		return ""
+	}
+	return logs[0].LogID
+}
+
+// ---------- 校区账户不存在 → 先建行 → 金额列 NULL(LEGACY-DEFECT) ----------
+
+// campusRowNullCompare 是 `SpecialCampusRowNullBalance` 的专用比对。
+//
+// 背景(全部来自 `information_schema` + SP8 正文,不是猜的):
+//
+//	legacy `t_user_expense_school`.{balance,spend,total_income,…} = decimal(10,3) **NULL DEFAULT NULL**
+//	legacy `t_user_expense`.{balance,total_income}                = float(10,3)   **DEFAULT 0.000**
+//	SP8:`IF logCount=0 THEN INSERT INTO t_user_expense_school(userid,schoolid,campusid)` —— 只写三列
+//
+// 于是:新行金额全 NULL → `SET balance=balance-inputMoney` 仍是 NULL(NULL 传染)→
+// **校区那一分钱都没扣**,但 `INSERT t_user_expense_log(type=209, money=-inputMoney)` 照样写。
+// 全局表因为 DEFAULT 0.000 反而真的扣成 −inputMoney ⇒ **同一笔业务,两张表自相矛盾**。
+//
+// target 侧同名列是 `NOT NULL DEFAULT 0.00` → 建行即 0 → 真扣成 −inputMoney,两表一致。
+//
+// 结论:这是 **legacy 的数据缺陷**,target 侧的行为是正确的那一个,**不跟**。
+// 所以这条 case 不断言"两侧相等",只断言"两侧各自确实是这样"。
+func campusRowNullCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
+	var diffs []Diff
+	var asserts []Assertion
+
+	want := -c.AmountCents
+	dGlobal := sub(lg.After.Global, lg.Before.Global)
+	dCampus := sub(lg.After.Campus, lg.Before.Campus)
+	tGlobal := sub(tg.After.Global, tg.Before.Global)
+	tCampus := sub(tg.After.Campus, tg.Before.Campus)
+
+	diffs = append(diffs,
+		Diff{Item: "调用前校区账户行", Legacy: fmt.Sprint(lg.Before.HasCampusAccount),
+			Target: fmt.Sprint(tg.Before.HasCampusAccount), Equal: true,
+			Note: "都应为 false(本条的**前提**,由 case 显式声明 NoCampusAccount)"},
+		Diff{Item: "调用后校区行 · balance",
+			Legacy: nullWord(lg.After), Target: yuan(tg.After.Campus.Balance), Equal: true,
+			Note: "**本来就不同**:legacy 建成 NULL 且扣不动;target 真扣成 −金额"},
+		Diff{Item: "调用后全局 · Δbalance", Legacy: yuan(dGlobal.Balance), Target: yuan(tGlobal.Balance),
+			Equal: true, Note: "两侧全局都扣了(全局列有 DEFAULT 0.000)"},
+		Diff{Item: "流水条数", Legacy: fmt.Sprint(len(lg.After.Logs)), Target: fmt.Sprint(len(tg.After.Logs)),
+			Equal: true, Note: "都是 1 条 type=209 —— legacy 是「没扣钱却写了凭证」"},
+	)
+
+	asserts = append(asserts,
+		Assertion{
+			Name: "前提:两侧调用前**都**没有该校区账户行(否则本条测不到建行分支)",
+			Pass: !lg.Before.HasCampusAccount && !tg.Before.HasCampusAccount,
+			Detail: fmt.Sprintf("legacy 存在=%v | target 存在=%v(都应为 false)",
+				lg.Before.HasCampusAccount, tg.Before.HasCampusAccount),
+		},
+		Assertion{
+			Name: "legacy:校区行**被建出来了**,但金额列仍是 **NULL**(`SET balance=balance-m` 在 NULL 上仍是 NULL)",
+			Pass: lg.After.HasCampusAccount && lg.After.CampusNullBalance,
+			Detail: fmt.Sprintf("legacy 行存在=%v 金额列为 NULL=%v(都应为 true)",
+				lg.After.HasCampusAccount, lg.After.CampusNullBalance),
+		},
+		Assertion{
+			Name: "legacy:**全局**被扣了 −金额(`t_user_expense` 的列有 DEFAULT 0.000)—— 同一笔业务两表矛盾",
+			Pass: dGlobal.Balance == want && dGlobal.Income == want,
+			Detail: fmt.Sprintf("Δ全局 balance=%s total_income=%s(期望各 %s)",
+				yuan(dGlobal.Balance), yuan(dGlobal.Income), yuan(want)),
+		},
+		Assertion{
+			Name: "legacy:流水照写 1 条 type=209 / money=−金额(**钱没扣,凭证有了**)",
+			Pass: len(lg.After.Logs) == 1 && lg.After.Logs[0].Type == 209 &&
+				lg.After.Logs[0].Money == want,
+			Detail: fmt.Sprintf("legacy %s(期望 type=209 money=%s ×1)", fmtLogs(lg.After.Logs), yuan(want)),
+		},
+		Assertion{
+			Name: "target:校区行被建出来且**真扣成 −金额**(列是 NOT NULL DEFAULT 0.00),两表一致",
+			Pass: tg.After.HasCampusAccount && !tg.After.CampusNullBalance &&
+				tg.After.Campus.Balance == want && tCampus.Balance == want && tCampus.Income == want,
+			Detail: fmt.Sprintf("target 行存在=%v 余额=%s Δbalance=%s Δtotal_income=%s(期望 true / %s / %s / %s)",
+				tg.After.HasCampusAccount, yuan(tg.After.Campus.Balance),
+				yuan(tCampus.Balance), yuan(tCampus.Income),
+				yuan(want), yuan(want), yuan(want)),
+		},
+		Assertion{
+			Name: "判定:**LEGACY-DEFECT** —— legacy 两表自相矛盾(校区 NULL / 全局 −金额),target 侧正确,不跟",
+			Pass: lg.After.CampusNullBalance && !tg.After.CampusNullBalance &&
+				dGlobal.Balance == tGlobal.Balance && dCampus.Balance != tCampus.Balance,
+			Detail: fmt.Sprintf("legacy 校区 NULL=%v Δ校区=%s | target Δ校区=%s | 两侧全局 Δ 相同=%v",
+				lg.After.CampusNullBalance, yuan(dCampus.Balance), yuan(tCampus.Balance),
+				dGlobal.Balance == tGlobal.Balance),
+		},
+	)
+	return diffs, asserts
+}
+
+// nullWord 把"NULL / 具体值"渲染成可读串(报告里 NULL 必须显形,不能显示成 0.00)。
+func nullWord(s SideSnapshot) string {
+	if s.CampusNullBalance {
+		return "NULL(未扣款)"
+	}
+	return yuan(s.Campus.Balance)
+}
+
 // ---------- 小工具 ----------
 
 func errSuffix(e string) string {

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

@@ -10,6 +10,26 @@ import (
 
 // 本文件是 case 定义与两侧快照的数据形状。
 
+// SpecialCase 的取值。
+const (
+	// SpecialRequestIDOverflow 请求号 33 字符(超过 legacy `t_user_expense_log.logid` 的 varchar(32))。
+	//
+	// 两侧的既定行为(列宽实测,见报告):
+	//   legacy logid      = varchar(32) → STRICT 模式下 INSERT log 报 1406(Data too long for column 'logid')
+	//   target log_id     = varchar(64)、request_id = varchar(64) → 正常落库
+	// 归类 **INTENTIONAL-WIDEN**(我们比 legacy 更宽,不是缺陷,也不同于"我们更严"的 IntentionalStrict)。
+	SpecialRequestIDOverflow = "requestid-overflow"
+
+	// SpecialCampusRowNullBalance 校区账户**不存在 → SP 先建行 → 金额列是 NULL → 扣不动**。
+	//
+	// 两侧断言完全不同(这是 legacy 的数据缺陷,不是我们要复刻的行为):
+	//   legacy:校区行被建出来,但 balance / total_income 仍是 NULL(未扣),type=209 流水照写;
+	//           全局表因为 `DEFAULT 0.000` 所以真的扣成 −金额 → **两表自相矛盾**
+	//   target:校区行 `NOT NULL DEFAULT 0.00` → 真扣成 −金额,两表一致
+	// 归类 **LEGACY-DEFECT**:legacy 侧不一致,target 侧正确,不跟。
+	SpecialCampusRowNullBalance = "campus-row-null-balance"
+)
+
 // Op 是"跑哪个写 SP"。
 //
 // 批 A 覆盖三个简单写路径;它们的列增量方向完全不同,所以 Op 必须进期望值计算:
@@ -112,6 +132,25 @@ type Case struct {
 	// 它会变成一个"看起来在测校区缺失、实际校区存在"的空测试。
 	CampusMissing bool
 
+	// NoCampusAccount true = 校区**存在**,但该用户在这个校区**没有账户行**。
+	//
+	// 与 CampusMissing 是两件事,别混:
+	//   CampusMissing   → 校区本身查不到(SP8 走 `找不到学校信息`,Go 报 3104)
+	//   NoCampusAccount → 校区查得到,只是账户行不存在(SP8 会 `INSERT INTO t_user_expense_school` 先建行)
+	//
+	// 为什么必须区分:`campusAccountAssertion` 原来只按 CampusMissing 推期望
+	// (`want = !CampusMissing`),这条 case 一进来就会假红;而如果为了消红把它删掉,
+	// 就等于放弃了"该缺失的确实缺失"这条前提 —— 那正是这条 case 唯一能证明的东西。
+	NoCampusAccount bool
+
+	// SpecialCase 非空 = 这条 case **不参与通用比对**,只跑该分支自带的断言。
+	//
+	// 为什么需要:`requestid` 33 字符那一条的行为是「legacy 半途失败 / target 成功」,
+	// 通用比对的"流水条数必须相等""两侧 must 一致"在这里**本来就该不成立**。
+	// 硬塞进通用流程只有两种结果:要么假红,要么把判据改宽(那等于删判据)。
+	// 取值见 SpecialRequestIDOverflow。
+	SpecialCase string
+
 	// Activity 非 nil 时,两侧各自在事务内建"标准活动矩阵"(`C1ActivityPlan`)+ 该用户的报名行。
 	// 只有"会动活动状态"的 case(OpClearBalance / OpCancelRechargeActivity)需要它。
 	Activity *ActivitySeed
@@ -207,6 +246,15 @@ type SideSnapshot struct {
 	// 基线相等断言会**空过**,整个 case 看起来在跑其实什么都没测。
 	// 显式记下来,再断言"该存在的存在 / 该缺失的缺失"。
 	HasCampusAccount bool
+	// CampusNullBalance 是"校区账户行存在,但金额列是 NULL"。
+	//
+	// 只有 legacy 会出现:`t_user_expense_school` 的 balance/spend/total_income/… 是
+	// `decimal(10,3) NULL DEFAULT NULL`(**没有默认值**)。SP8 的建行语句只写
+	// `(userid,schoolid,campusid)`,于是新行金额全是 NULL;紧接着的
+	// `SET balance=balance-inputMoney` 在 NULL 上运算仍然是 NULL ——
+	// **钱根本没扣,但 type=209 的流水照写**。target 侧同名列是 `NOT NULL DEFAULT 0.00`,
+	// 所以那一侧会真的扣成 −30.00。两侧从此不一致,这是 **legacy 的数据缺陷**。
+	CampusNullBalance bool
 }
 
 // SideResult 是一侧的运行结果。

+ 42 - 13
internal/academic/dualrun/stage2/legacy.go

@@ -178,22 +178,36 @@ func (s *legacySide) Run(ctx context.Context, c Case) (*SideResult, error) {
 	return res, nil
 }
 
-// applyBaselineLegacy 把两侧基线强制设置成同一份(R15 用)。
+// applyBaselineLegacy 把两侧基线强制设置成同一份(R15 / C1-1、C1-2 用)。
+//
+// ⚠ 必须校验受影响行数:这两个 UPDATE 都带 `WHERE`,**账户行不存在时 0 行受影响且不报错**。
+// 那样基线根本没设上,case 却会用真实余额跑完 —— 结果就是"看起来很绿、其实测的是另一条数据"。
+// 这类失败最贵:它不报错,只是把断言悄悄变成空断言。
 func applyBaselineLegacy(ctx context.Context, tx *gorm.DB, c Case) error {
 	b := c.Baseline
-	if err := tx.Exec(`
+	res := tx.Exec(`
 UPDATE t_user_expense SET balance=?, spend=?, total_income=?, total_expenditure=?
  WHERE userid=?`,
 		yuan(b.GlobalBalance), yuan(b.GlobalSpend), yuan(b.GlobalIncome), yuan(b.GlobalExpenditure),
-		c.LegacyUserID).Error; err != nil {
-		return fmt.Errorf("legacy 设置全局基线失败:%w", err)
+		c.LegacyUserID)
+	if res.Error != nil {
+		return fmt.Errorf("legacy 设置全局基线失败:%w", res.Error)
+	}
+	if res.RowsAffected != 1 {
+		return fmt.Errorf("legacy 设置全局基线影响 %d 行(期望 1):userid=%d 的全局账户不存在,基线没设上",
+			res.RowsAffected, c.LegacyUserID)
 	}
-	if err := tx.Exec(`
+	res = tx.Exec(`
 UPDATE t_user_expense_school SET balance=?, spend=?, total_income=?, total_expenditure=?
  WHERE userid=? AND schoolid=? AND campusid=?`,
 		yuan(b.CampusBalance), yuan(b.CampusSpend), yuan(b.CampusIncome), yuan(b.CampusExpenditure),
-		c.LegacyUserID, c.InstitutionID, c.CampusID).Error; err != nil {
-		return fmt.Errorf("legacy 设置校区基线失败:%w", err)
+		c.LegacyUserID, c.InstitutionID, c.CampusID)
+	if res.Error != nil {
+		return fmt.Errorf("legacy 设置校区基线失败:%w", res.Error)
+	}
+	if res.RowsAffected != 1 {
+		return fmt.Errorf("legacy 设置校区基线影响 %d 行(期望 1):userid=%d inst=%d campus=%d 的校区账户不存在,基线没设上",
+			res.RowsAffected, c.LegacyUserID, c.InstitutionID, c.CampusID)
 	}
 	return nil
 }
@@ -301,8 +315,14 @@ SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2))
 	gsp2, _ := centsOf(g.Special)
 	out.Global = Account{Balance: gs, Spend: gsp, Income: gi, Expenditure: ge, Special: gsp2}
 
+	// 校区账户:**必须容忍 NULL**。
+	//
+	// `t_user_expense_school` 的金额列是 `decimal(10,3) NULL DEFAULT NULL`(没有默认值)。
+	// SP8 的建行语句只写三列,于是新行金额全是 NULL;而 `SET balance=balance-m` 在 NULL 上
+	// 运算仍是 NULL。Scan 进 string 会直接报 "converting NULL to string is unsupported",
+	// 整条 case 连"读回状态"都做不到 —— 那不是数据问题,是**读的工具不够宽容**。
 	var sc struct {
-		Balance, Spend, Income, Expenditure, Special string
+		Balance, Spend, Income, Expenditure, Special sql.NullString
 	}
 	err = tx.Raw(`
 SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2)),
@@ -322,12 +342,13 @@ SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2))
 		err = nil
 	} else {
 		out.HasCampusAccount = true
+		out.CampusNullBalance = !sc.Balance.Valid
 	}
-	cs, _ := centsOf(sc.Balance)
-	csp, _ := centsOf(sc.Spend)
-	ci, _ := centsOf(sc.Income)
-	ce, _ := centsOf(sc.Expenditure)
-	cspec, _ := centsOf(sc.Special)
+	cs, _ := centsOf(nullStr(sc.Balance))
+	csp, _ := centsOf(nullStr(sc.Spend))
+	ci, _ := centsOf(nullStr(sc.Income))
+	ce, _ := centsOf(nullStr(sc.Expenditure))
+	cspec, _ := centsOf(nullStr(sc.Special))
 	out.Campus = Account{Balance: cs, Spend: csp, Income: ci, Expenditure: ce, Special: cspec}
 
 	// 转入校区(只有 transfer 用得到;不存在时为零值)
@@ -403,6 +424,14 @@ func filterLogs(logs []LogRow, afterID int64) []LogRow {
 // centsOf 用**迁移同一口径**把十进制串转成分(float 3 位 → ROUND 到 2 位)。
 func centsOf(s string) (int64, error) { return fixture.DecimalToCents(s) }
 
+// nullStr 把可空列取成字符串(NULL → "",`DecimalToCents("")` 归 0)。
+func nullStr(v sql.NullString) string {
+	if !v.Valid {
+		return ""
+	}
+	return v.String
+}
+
 // yuan 把分格式化成 2 位十进制串(写回账户列用)。
 func yuan(cents int64) string { return fixture.CentsToDecimal(cents) }
 

+ 79 - 4
internal/academic/dualrun/stage2/run.go

@@ -118,10 +118,15 @@ func DefaultCases(_ string) []Case {
 
 	// ---- 批 C1:余额清零 SP10(5 参,**无金额、无请求号**)----
 	//
-	// 选人依据(与批 A 同一条纪律:基线必须是 2 位小数,否则 float 收敛会让 Δ 天然不等):
-	//	68542 → 63317 / campus 5(**自营 self_support=1**),校区与全局余额都是 9740.00
-	//	40583 → 63317 / campus 4(**加盟 self_support=0**),校区与全局余额都是 5400.00
+	// 选人依据(与批 A 同一条纪律):
+	//	68542 → 63317 / campus 5(**自营 self_support=1**,旧库真实值)
+	//	40583 → 63317 / campus 4(**加盟 self_support=0**,旧库真实值)
 	// 两人在旧库都**没有**别的可失效报名(preflight 会显式断言这一点)。
+	//
+	// 余额基线则**显式覆盖**(Baseline),而不是沿用真实余额:
+	// 68542 真实是 9740/9740、40583 是 5400/5400,都恰好"全局 == 该校区",
+	// 那样"全局减 campusBalance"与"全局归 0"结果相同,断言分不出对错(变异 M4 实测:改错也不红)。
+	// 覆盖成 5000/1200 与 3000/900 之后,两者差 3800 / 2100,那条断言才真正 load-bearing。
 	out = append(out,
 		Case{
 			Name: "C1-1 余额清零·自营校区(失效范围含 campus_id=0)",
@@ -129,7 +134,7 @@ func DefaultCases(_ string) []Case {
 			RequestID: "ac15-c1-1", RunTimes: 1, SelfSupport: true,
 			// 关键:**故意让全局余额 > 校区余额**(5000 vs 1200)。
 			// 如果两者相等(比如这个用户天然就是 9740/9740),那么"减 campusBalance"与
-			// "全局归 0"会得到同一个结果,断言就分不出对错 —— 变异 M1 实测过这一条。
+			// "全局归 0"会得到同一个结果,断言就分不出对错 —— 变异 M4 实测过这一条。
 			Baseline:            &Baseline{GlobalBalance: 500000, CampusBalance: 120000},
 			Activity:            &ActivitySeed{OtherUsers: map[int64]int32{65055: 2}},
 			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
@@ -216,6 +221,62 @@ func DefaultCases(_ string) []Case {
 		},
 	)
 
+	// ---- 批 C1 补的三个缺口(派单明确要求,前八条里没有覆盖)----
+	//
+	// 三条都是"列宽 / 无上限校验 / 建户分支"这类**只在边界上暴露**的行为,
+	// 用正常金额的正常账户永远测不到。
+	out = append(out,
+		Case{
+			// 缺口 ①:requestid 33 字符。
+			// legacy `t_user_expense_log.logid` = varchar(32),SP8 入参却是 varchar(50);
+			// STRICT 模式下 INSERT log 报 1406(Data too long for column 'logid')。
+			// target `log_id` / `request_id` = varchar(64) → 正常入库。
+			// 归类 **INTENTIONAL-WIDEN**:我们比 legacy 更宽,不是缺陷。
+			Name: "C1-9 取消充值活动·requestid 33 字符(legacy 1406 vs target 完整落库)",
+			Op:   OpCancelRechargeActivity, LegacyUserID: 68542, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 3000,
+			// 33 字符(刻意多一位,正好越过 legacy 的 varchar(32))
+			RequestID: "ac15-c1-9-request-id-33-chars-xxx",
+			RunTimes:  1, SelfSupport: true,
+			Activity:            &ActivitySeed{},
+			SpecialCase:         SpecialRequestIDOverflow,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: false,
+			Note: "legacy:logid varchar(32) 放不下 → INSERT log 报 1406;但**它前面两条 UPDATE 不回滚**," +
+				"所以终态是「钱扣了、凭证没有」。target:varchar(64) 正常入库。归类 INTENTIONAL-WIDEN(我们更宽)",
+		},
+		Case{
+			// 缺口 ②:退成负数(F5:SP8 正文只有 `IF inputMoney<0 THEN SET inputMoney=0`,
+			// **没有任何 balance >= money 的校验**,所以必能把余额扣成负)。
+			// 用 Baseline 把两表都压到 100.00,再退 500.00 → 两侧都应为 −400.00。
+			Name: "C1-10 取消充值活动·退成负数(余额 100 退 500,F5 无上限校验)",
+			Op:   OpCancelRechargeActivity, LegacyUserID: 68542, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 50000, RequestID: "ac15-c1-10", RunTimes: 1, SelfSupport: true,
+			Baseline:              &Baseline{GlobalBalance: 10000, CampusBalance: 10000},
+			ExpectNegativeBalance: true,
+			Activity:              &ActivitySeed{},
+			ExpectTargetSuccess:   true, ExpectLegacySuccess: true,
+			Note: "SP8 正文**没有余额校验**(只有一句 `IF inputMoney<0 → 0`),target 同样不做余额校验 → " +
+				"两侧都应扣成 −400.00。这是第 3 条负余额来源(另两条:SP11 spend、custom_refund 坏账分支)",
+		},
+		Case{
+			// 缺口 ③:账户不存在先建行(dump 的 `IF logCount=0 THEN INSERT INTO t_user_expense_school(userid,schoolid,campusid)`)。
+			// 40585 在旧库有全局账户(5280.00)但**在 campus 5 没有账户行**(已核,其唯一校区账户在别的校区)。
+			// ⚠ 实测结论与预期不同,且更有价值:建行语句只写三列,而 legacy 该表金额列是
+			// `decimal(10,3) NULL DEFAULT NULL` → 新行金额为 NULL → `SET balance=balance-m` 仍是 NULL
+			// ⇒ **校区一分没扣,但 type=209 凭证照写**;全局表有 DEFAULT 0.000 所以真扣了。
+			// 这是 legacy 的数据缺陷(两表自相矛盾),target 侧(NOT NULL DEFAULT 0.00)才是对的。
+			Name: "C1-11 取消充值活动·校区账户不存在(先建行,但金额列是 NULL)",
+			Op:   OpCancelRechargeActivity, LegacyUserID: 40585, InstitutionID: 63317, CampusID: 5,
+			AmountCents: 3000, RequestID: "ac15-c1-11", RunTimes: 1, SelfSupport: true,
+			NoCampusAccount:     true,
+			Activity:            &ActivitySeed{},
+			SpecialCase:         SpecialCampusRowNullBalance,
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "dump 的建行只写 `(userid,schoolid,campusid)`;legacy 金额列 `decimal(10,3) NULL DEFAULT NULL`," +
+				"→ 新行全 NULL、扣不动、凭证照写;全局表 DEFAULT 0.000 却真扣了。判定 LEGACY-DEFECT,target 不跟",
+		},
+	)
+
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
@@ -370,6 +431,20 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
 	}
 
+	// ⓪-0 特殊 case:自带整套断言,**不参与通用比对**(见 Case.SpecialCase 的说明)
+	if c.SpecialCase != "" {
+		switch c.SpecialCase {
+		case SpecialRequestIDOverflow:
+			return requestIDOverflowCompare(c, lg, tg)
+		case SpecialCampusRowNullBalance:
+			return campusRowNullCompare(c, lg, tg)
+		}
+		return nil, []Assertion{{
+			Name: "未知的 SpecialCase:" + c.SpecialCase, Pass: false,
+			Detail: "compare 里没有这个分支,case 会变成空测试",
+		}}
+	}
+
 	// ⓪ intentional-strict(R19 / 3120 / 3104):两侧**本来就该不同**,不参与"两侧相等"判定
 	if c.IntentionalStrict {
 		lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success

+ 19 - 7
internal/academic/dualrun/stage2/target.go

@@ -336,21 +336,33 @@ ON DUPLICATE KEY UPDATE balance = VALUES(balance), spend = VALUES(spend),
 	return nil
 }
 
-// applyBaselineTarget 把 target 侧强制设置成与 legacy 同一份基线(R15 用)。
+// applyBaselineTarget 把 target 侧强制设置成与 legacy 同一份基线(R15 / C1-1、C1-2 用)。
+//
+// 与 legacy 侧同理:**必须校验受影响行数**,否则"账户行不存在 → 基线没设上"会静默变成空断言。
 func applyBaselineTarget(ctx context.Context, tx *gorm.DB, c Case, canonicalUID string) error {
 	b := c.Baseline
-	if err := tx.Exec(`
+	res := tx.Exec(`
 UPDATE t_user_expense SET balance=?, spend=?, total_income=?, total_expenditure=? WHERE user_id=?`,
 		yuan(b.GlobalBalance), yuan(b.GlobalSpend), yuan(b.GlobalIncome), yuan(b.GlobalExpenditure),
-		canonicalUID).Error; err != nil {
-		return fmt.Errorf("target 设置全局基线失败:%w", err)
+		canonicalUID)
+	if res.Error != nil {
+		return fmt.Errorf("target 设置全局基线失败:%w", res.Error)
 	}
-	if err := tx.Exec(`
+	if res.RowsAffected != 1 {
+		return fmt.Errorf("target 设置全局基线影响 %d 行(期望 1):%s 的全局账户不存在,基线没设上",
+			res.RowsAffected, canonicalUID)
+	}
+	res = tx.Exec(`
 UPDATE t_user_expense_school SET balance=?, spend=?, total_income=?, total_expenditure=?
  WHERE user_id=? AND institution_id=? AND campus_id=?`,
 		yuan(b.CampusBalance), yuan(b.CampusSpend), yuan(b.CampusIncome), yuan(b.CampusExpenditure),
-		canonicalUID, c.InstitutionID, c.CampusID).Error; err != nil {
-		return fmt.Errorf("target 设置校区基线失败:%w", err)
+		canonicalUID, c.InstitutionID, c.CampusID)
+	if res.Error != nil {
+		return fmt.Errorf("target 设置校区基线失败:%w", res.Error)
+	}
+	if res.RowsAffected != 1 {
+		return fmt.Errorf("target 设置校区基线影响 %d 行(期望 1):%s inst=%d campus=%d 的校区账户不存在,基线没设上",
+			res.RowsAffected, canonicalUID, c.InstitutionID, c.CampusID)
 	}
 	return nil
 }