Q d7fbd98449 wip(ac15): W4-B 建行分支 —— 三条独立断言就位;**本条当前是红的**,红在 target 侧,疑似 F25 11 ore în urmă
..
BACKLOG.md e5c23522ec docs(ac15): 新增 stage2/BACKLOG.md(B1 绝对期望值缺失 / B2 归一架空结论强度 / B3 证据强度未分级) 12 ore în urmă
README.md d0fda094e6 wip(ac15): 3d 基建(二)—— 推荐人校区快照 / 删 Baseline 新字段 / AccountSeed 三态 / 无 dispatch 即 FATAL 11 ore în urmă
activity_compare.go 9e2b0b7dc5 fix(ac15): 形态统计唯一化 —— 12+2+1+2=17 那个数是错的,真值 2+2+1+10=15 12 ore în urmă
activity_shape_test.go 9e2b0b7dc5 fix(ac15): 形态统计唯一化 —— 12+2+1+2=17 那个数是错的,真值 2+2+1+10=15 12 ore în urmă
activity_snapshot.go d0fda094e6 wip(ac15): 3d 基建(二)—— 推荐人校区快照 / 删 Baseline 新字段 / AccountSeed 三态 / 无 dispatch 即 FATAL 11 ore în urmă
activity_snapshot_test.go 4f393d95bf feat(ac15): 3b 两块新快照(推荐人账户 + 课酬应收)——纯基建,case 数仍 50 12 ore în urmă
arrears_compare.go aa9989fa46 refactor(ac15): 3c 收口补强 —— mustCover 三数闭合 / tripwire 改名 / 课酬域形态行 / 自伤第 7 条 11 ore în urmă
arrears_seed.go aa9989fa46 refactor(ac15): 3c 收口补强 —— mustCover 三数闭合 / tripwire 改名 / 课酬域形态行 / 自伤第 7 条 11 ore în urmă
c1_activity.go 344909ed7c wip(ac15): 3d 前置件 —— Baseline 覆盖 total_recommended_amount + signed 种子支持 recommend_userid 11 ore în urmă
c1_compare.go 80b0dd0b1a feat(ac15): 活动域快照 —— 六表三态 + 多重集行集比对(commit 1,不动 case) 13 ore în urmă
c2_refund.go 80b0dd0b1a feat(ac15): 活动域快照 —— 六表三态 + 多重集行集比对(commit 1,不动 case) 13 ore în urmă
c2_refund_test.go d7fbd98449 wip(ac15): W4-B 建行分支 —— 三条独立断言就位;**本条当前是红的**,红在 target 侧,疑似 F25 11 ore în urmă
case.go 65c965e24a feat(ac15): W4-A 补 RecommenderLogs —— F24 探针上线,实测**未命中** 11 ore în urmă
config.go 421a5849a7 feat(ac15): 代价可读 —— 报告尾部打 SQL 执行次数与耗时 + README 自伤清单 12 ore în urmă
legacy.go 65c965e24a feat(ac15): W4-A 补 RecommenderLogs —— F24 探针上线,实测**未命中** 11 ore în urmă
recommend_accounts.go 0ab449f5e7 feat(ac15): 3d W4-A 老带新正常双边入账 —— 3b 第 4/5 块自本次起被激活(52→53) 11 ore în urmă
recommend_compare.go d7fbd98449 wip(ac15): W4-B 建行分支 —— 三条独立断言就位;**本条当前是红的**,红在 target 侧,疑似 F25 11 ore în urmă
recommend_seed.go 0ab449f5e7 feat(ac15): 3d W4-A 老带新正常双边入账 —— 3b 第 4/5 块自本次起被激活(52→53) 11 ore în urmă
run.go d7fbd98449 wip(ac15): W4-B 建行分支 —— 三条独立断言就位;**本条当前是红的**,红在 target 侧,疑似 F25 11 ore în urmă
sentinel.go f73f560528 fix(ac15): C1 收口六项——recommended 进快照、C1-5 拆两条、self_support 改 CAST、存量哨兵、CLI 四道闸 14 ore în urmă
stage2_test.go f73f560528 fix(ac15): C1 收口六项——recommended 进快照、C1-5 拆两条、self_support 改 CAST、存量哨兵、CLI 四道闸 14 ore în urmă
stats.go 421a5849a7 feat(ac15): 代价可读 —— 报告尾部打 SQL 执行次数与耗时 + README 自伤清单 12 ore în urmă
target.go 65c965e24a feat(ac15): W4-A 补 RecommenderLogs —— F24 探针上线,实测**未命中** 11 ore în urmă

README.md

internal/academic/dualrun/stage2 —— 写 SP 双跑

批 SP service 方法 列增量(照 dump) 流水
一 sp_user_expense_school_expenditure Expenditure balance− / total_expenditure+(type=200 另加 spend+) 每次 1 条
A sp_user_expense_recharge Recharge(Kind=Default) balance+ / total_income+,双表 每次 1 条
A sp_user_expense_school_recharge Recharge(Kind=School) balance+ / total_income+,双表 每次 1 条
A sp_user_expense_school_refund Refund balance+ / total_expenditure−,双表 每次 1 条
B sp_user_expense_baddebt BadDebt balance+ 且 special_expenses+(双增),双表 每次 1 条(type=108)
B sp_user_expense_school_spend AccumulateSpend 只 spend+,balance 不动,双表 0 条(SP11 全文没有 INSERT)
B sp_user_expense_school_transfer_expenditure Transfer 转出校区 exp−/income−,转入校区 exp+/income+,balance 两侧都不动 每次2 条(106 +m / 206 −m)
C1 sp_user_expense_school_cleared_balance RefundService.ClearBalance balance−/special+/spend+(三列同额,双表),清的是校区行 每次 1 条(type=208,无 logid)
C1 sp_user_expense_cancel_recharge_activity RecommendService.CancelRechargeActivity balance−/total_income−(双表,充值的反向) 每次 1 条(type=209)

另:sp_user_expense_cancel_recharge_activity 会改 t_courses_activity_signed.status(→0), sp_user_expense_school_cleared_balance 会改(→4)—— 见 §7。

0. 一个必须先纠正的事实:没有"只写全局"的充值 SP

派单里写「sp_user_expense_recharge 只写全局、school_recharge 双表」。dump 不是这样:

-- sp_user_expense_recharge(SP1)765xxx 段
UPDATE `t_user_expense_school` SET `balance`=`balance`+inputMoney,`total_income`=`total_income`+inputMoney WHERE ...;
UPDATE `t_user_expense`        SET `balance`=`balance`+inputMoney,`total_income`=`total_income`+inputMoney WHERE `userid`=inputUserid;

-- sp_user_expense_school_recharge(SP2)同两段,一字不差

两个 SP 都是双表。两者真正的差异只有三处:

差异 SP1 recharge SP2 school_recharge
默认备注 余额充值 充值缴费
type 来源 入参(<100 → 100) 固定 100(无 type 入参)
入参守卫 schoolid<1 \|\| campusid<1 → LEAVE(静默 no-op) 无守卫

所以要求里的第 6 条我按正确前提实现:断言「校区表 Δbalance / Δtotal_income 必须等于充值额」—— 如果有人把充值写成"只写全局",这条立刻红(变异 M1 实测)。

这是第一次两侧都真的写库的双跑:

构造 case → legacy 侧 CALL SP(事务内)→ target 侧跑 Go service(事务内)
→ 各自读回写入后的状态 → 逐项比对 → 双方回滚 → 断言零残留
go run ./cmd/dualrun stage2              # 跑全部 21 个 case(A1–A7 / B1–B4 / C1-1…C1-8 / R1 / R2)
go run ./cmd/dualrun stage2 --case B2    # 只跑某个 case(名字子串匹配)

1. scope 怎么构造的(没有绕过 assertInScope)

tx := targetDB.Begin()                       // ① 我自己持有事务
defer tx.Rollback()

// ② 把 **tx 本身** 传给 service:TxRunner.WithinTx 开头判 InTx(t.DB) → 已处于事务中就直接透传(不再 BEGIN),
//    所以 service 的写入落在我的事务里,我可以读完再回滚。
sc := &appscope.Scope{
    InstitutionID: c.InstitutionID,          // 63317
    CampusID:      c.CampusID,               // 5
    UserID:        canonicalUID,             // 实际操作人(本 case 里 = 消费者本人)
    Role:          "finance",
}
svc := service.NewFundService(tx, sc, port.NewOrgMembershipChecker(tx))
out, err := svc.Expenditure(ctx, service.ExpenditureInput{...})

三条说明:

  1. checker 是真的:port.NewOrgMembershipChecker(tx) 读 t_membership,case 里真的插了一行 (user, 63317, 5, 'student', 'active')。没有 nil checker、没有 SuperAdmin=true、没有改 service 签名。
  2. Scope 不是"造一个能过的":它的 InstitutionID/CampusID 就是 case 的业务参数;如果 case 的机构/校区 与该用户的成员关系不匹配,assertInScope 会返回 1004 —— 那正是要验证的东西,不回避。
  3. 操作人 = 消费者本人:OperatorID 也传 canonicalUID。真实场景里操作人是财务/教务, 但 assertInScope 校验的是被扣款用户的成员关系,与本 case 目标无关,所以仍用同一 ULID 以少插一行数据。

2. 两侧怎么保证"同一份基线"

侧 来源 口径
legacy legacy_oracle(finance_bk 的完整副本)天然值 读成 ROUND(CAST(x AS DECIMAL(20,3)),2) 再按分解析
target INSERT INTO … SELECT … FROM finance_bk.… SQL 里就写 ROUND(CAST(x AS DECIMAL(20,3)),2)

两侧都用同一个 fixture.DecimalToCents 把十进制串转成分,所以"分"不会各算各的。 调用前有硬断言「两侧基线逐分相等」,不等直接红(那说明映射有问题,不是双跑问题)。

3. 三个 case 与各自的期望

case 入参 期望
C1 正常消费 type=201,30.00,调用 1 次 两侧都成功;Δbalance 都 −30.00;各 1 条流水
C2 幂等 type=201,30.00,同一 requestId 调用 2 次 target:第 2 次 repeated=true、只入账 1 次;legacy:SP 没有幂等键,会重复入账 2 次
C3 R15 余额不足 type=200,余额置 10.00,消费 100.00 两侧都成功且都扣成 −90.00(SP 不校验余额)

为什么 Δ 比对不是"两侧相等"而是"各自等于自己的期望": C2 里两侧本来就不应该相等。写成"两侧相等"只能得到 FAIL,而它其实是设计内的已知差异。 所以 expectedDelta(typ, amount, runs) 把每侧应有的行为显式写出来,再拿实测去对 —— 偏离任一侧的预期都会红。

3.5 批 B:三种"看着该动 balance 但其实不动 / 动两次"的 SP

批 B 最容易出的错不是"漏写一列",而是凭列名推断行为。dump 里这三支的真实行为:

3.5.1 baddebt(SP3):balance 与 special_expenses 双增

UPDATE t_user_expense_school SET `balance`=`balance`+inputMoney, `special_expenses`=`special_expenses`+inputMoney WHERE ...;
UPDATE t_user_expense        SET `balance`=`balance`+inputMoney, `special_expenses`=`special_expenses`+inputMoney WHERE ...;
INSERT INTO t_expenditure_log (..., `type`, ...) VALUES (..., 108, ...);

坏账是"钱收不回来了但账上要留着" —— 所以余额和坏账专户同时加。 直觉容易写成"只加 special_expenses",那样 t_user_expense.balance 就与旧库差一整个金额。

断言形状(B1 实测):

✔ 坏账:balance 与 special_expenses **必须同时 +金额**(只加其中一个 → 红)
   legacy 全局 Δbal=80.00 Δspec=80.00 | target 全局 Δbal=80.00 Δspec=80.00(期望各 +80.00)

⚠ 这条断言能成立的前提是 sub() 里真的减了 Special。 一开始 sub() 漏了 Special 字段 → Δspecial_expenses 恒为 0.00 → B1 假红 3 项。 "我没写这一列的比对" 和 "我断言这一列是 0" 是两件事,前者会让错误静默通过。

⚠ 顺手记一个 dump 事实:sp_user_expense_baddebt 的 COMMENT 写的是 '用户余额充值' —— 从 recharge 复制过来忘改了。连 SP 自己的注释都不能当行为依据: 按 COMMENT 判断会把坏账当成充值;按 UPDATE 语句判断才知道它同时动 balance 和 special_expenses。 这和"按表名推断 recharge 只写全局"是同一个错误的两种形态。

3.5.2 school_spend(SP11):只动 spend,不动 balance,且一条流水都不写

UPDATE t_user_expense_school SET `spend`=`spend`+inputSpendMoney WHERE ...;
UPDATE t_user_expense        SET `spend`=`spend`+inputSpendMoney WHERE ...;
-- 全文没有 INSERT INTO t_expenditure_log

SP11 的签名只有 4 个参数(inputUserid, inputSchoolid, inputCampusid, inputSpendMoney)—— 连请求号都没有,所以它不可能写流水,也不可能幂等。这条"签名反推行为"比读 UPDATE 更快: 没有 requestId 就没有落 logid 的素材。

断言形状(B2 实测):

✔ 累计消费(spend):Δbalance 必须为 0(不是不比较,是断言它为 0)
   全局 0.00/0.00;转出校区 0.00/0.00;转入校区 0.00/0.00(都应为 0.00)
✔ 累计消费:**不写流水**(dump 的 SP11 全文没有 INSERT log)
   legacy 0 条 / target 0 条(都应为 0)

⚠ SP11 的 INSERT 分支其实是坏的: INSERT INTO t_user_expense(userid,spend) VALUES(inputUserid, inputMoney, inputSpendMoney); —— 2 个列名配 3 个值,inputMoney 这个变量 SP11 里根本不存在(又是从别的 SP 复制来的)。 只在"用户还没有账户行"时才会走到,线上从不触发,所以我们测不到也修不了(迁移不改旧库)。 记它的用处是:不能靠"照抄 SP 逻辑"来定义 target 行为 —— 照抄会把这段坏 SQL 一起搬过来。定义行为的依据只能是"实测的列增量 + case 断言"。

3.5.3 transfer_expenditure(SP6):钱没消失,只是换了校区

转出校区:total_expenditure −m, total_income −m
转入校区:total_expenditure +m, total_income +m
全局    :一分不动(全局余额是两个校区之和的视图,转结是内部搬运)
流水    :每次 2 条 —— type=106 (+m) 与 type=206 (−m),Σmoney == 0

断言形状(B3 实测):

✔ 课费转结(transfer):Δbalance 必须为 0(不是不比较,是断言它为 0)
   全局 0.00/0.00;转出校区 0.00/0.00;转入校区 0.00/0.00(都应为 0.00)
✔ 转结:每次**一对流水(106 +m / 206 −m)且 SUM(money)==0**(钱没消失,只是换了校区)
   legacy 2 条 types=106,206 SUM=0.00(期望 2 条)| target 2 条 types=106,206 SUM=0.00(期望 2 条)
✔ 转结:两校区 total_expenditure 变化量**互为反号**(转出 −m / 转入 +m)
   legacy 转出=-50.00 转入=50.00 | target 转出=-50.00 转入=50.00

⚠ SUM(money)==0 和"两校区反号"是两条独立断言:前者防"206 写成了 +m"(钱凭空多出来), 后者防"两个校区往同方向动了"(钱没多但也没搬)。变异 M2 专门打前者。

3.5.4 批 B 的公共纪律:"不动 balance" 必须断言为 0,不能只是不比较

spend 与 transfer 都有一个"我不关心"的列 —— balance。 如果把这一列从比对表里删掉,实现里多扣一笔 balance 也不会有任何红: 因为"没有比较"和"比较结果是 0"在报告里长一样。 所以这里写的是显式断言(Δbalance == 0,六个数字逐分核),变异 M1 实测能抓住。

3.6 A7:R19 campusid=0 —— 两侧本来就该不同

侧 行为
legacy SP1 IF inputSchoolid < 1 OR inputCampusid < 1 THEN LEAVE ... → 静默返回成功、一行不写
Go Recharge validateUserScope 前置校验 → 3140 拒绝

这不是待修的不一致,是登记过的裁决 R19(legacy 静默 no-op 是缺陷,Go 侧显式报错是设计内增强)。 所以这个 case 不能走"两侧相等"的判定,走 IntentionalStrict 分支,把差异钉死成三条断言:

✔ intentional-strict:legacy 静默成功 + target 显式拒绝(R19)
   legacy success=true(期望 true,SP 静默 no-op);target 被拒=true err="业务参数错误: service: Recharge 需要 campus_id > 0(dump:必须有校区信息)"(期望 被拒=true + 3140)
✔ intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条)
   legacy 写库=false(全局/校区)流水=0 | target 写库=false(全局/校区)流水=0 —— 都应为 未写库/0 条
✔ 回滚后零残留(两侧都回到调用前状态)

第二条断言特意同时比全局和校区:campusid=0 的 case 若只比全局, 一个"只写校区表"的错误实现会静默漏过(校区行本来就不存在,读出来是 0,比较也相等)。 "不存在的校区"读成 0 这件事,必须靠"我们主动建了 0 行校区数据 + 显式断言没有写"来覆盖。

同理,seedTarget 在 campus_id == 0 时不建校区行、不建 t_legacy_mapping 的 campus 关联—— 如果为了"让 case 能跑"而造一个 campus_id=0 的校区行,就绕过了要验证的守卫。

3.7 批 A / 批 B 的 13 个 case 一览(批 C1 见 §7,批 C1 补齐后全量 24 个)

case Op 入参 期望
A1 OpRecharge 17735 / 63317 / 5,200.00,type=100|1 次 双表 balance+ / income+,1 条流水,备注"余额充值"
A2 OpRecharge 同上,2 次 target 幂等只入账 1 次;legacy 重复入账 2 次(已知差异)
A3 OpSchoolRecharge 40575 / 63317 / 4,150.00|1 次 双表;type 由 SP 固定 100
A4 OpSchoolRecharge 17735 / 63317 / 5,2 次 同上,幂等
A5 OpRefund 31477 / 63317 / 5,100.00,type=207|1 次 balance+ / total_expenditure−(方向与消费相反)
A6 OpRefund 40575 / 63317 / 4,2 次 同上,幂等
A7 OpRecharge 31477 / 63317 / campus=0|1 次 intentional-strict:legacy 静默成功、target 3140,两侧都没写库
B1 OpBadDebt 17735 / 63317 / 5,80.00|1 次 balance+ 且 special_expenses+(双表),1 条流水 type=108
B2 OpSpend 31477 / 63317 / 5,25.00|1 次 只 spend+(双表),balance 不动,0 条流水
B3 OpTransfer 31477 / 63317 / 5 → campus 1,50.00|1 次 双流水 106/206 Σ=0,两校区反号,balance 不动
B4 OpTransfer 17735 / 63317 / 5 → 1,2 次 106 是主流水,幂等按 (request_id, 106) 判定
R1 OpExpenditure 17735 / 63317 / 5,30.00,type=201|1 次 第二批的消费 case,留作回归
R2 OpExpenditure 同上,100.00,type=200,基线置 10.00 R15:余额不足两侧都成功且都扣成 −90.00

4. 三处已确认的行为差异(不是缺陷,是设计内的 Go 增强)

项 legacy SP Go FundService.Expenditure 依据
幂等 无 request_id 概念,重复提交重复入账 request_id + type 唯一键,命中即 repeated=true 且不入账 设计 §1.5
type 越界 type<200 或 >299 静默改成 200(把"传错"记成"历史扣费") 白名单 101/104/105/200/201/204/205,非法 → 2002 设计 §3.1 #3
balance_after 无此列 写"操作后全局账户余额"快照 设计 §10.2

前两条在报告里都单独列了 diff/断言,A2 用实测把"legacy 重复入账 2 次 / target 1 次"钉死。

5. 回滚与残留

  • legacy:tx.Begin() → 可选基线覆盖 → CALL → 读回 → Rollback
  • target:tx.Begin() → 种基线(org/人/成员关系/两份账户)→ service → 读回 → Rollback
  • 残留检查用新连接、在事务之外读:
    • target 的种子行必须全部消失(t_user_expense / _log / t_membership 计数为 0)
    • legacy 的余额必须回到事务之前的天然状态(不能拿事务内的基线比 —— R15 用例会把基线覆盖成 10.00)

t_user_expense_log.id 是自增,回滚后不会回退;重跑时 id 会跳号。断言里一律不用固定 id。

6. 变异实验(都已还原)

批一 / 批 A

# 改错什么 结果
M1a target 侧多扣 1 分(AmountCents + 1) 红 5 项:Δbalance -30.00 vs -30.01、Δtotal_expenditure、流水 money
M1b 在 M1a 基础上把 Δ 断言放宽 1 分 变绿(PASS)—— 证明"放宽 1 分"会掩盖真实的 1 分误差,所以容差必须是 0
M2 把 target 侧 Rollback 改成 Commit 红:target 残留账户行=true 流水=1(回滚断言抓住)
M3 把 C3 的预期改成"应当报错 3101" 红:首次调用结果与预期一致 断言失败 —— 反证 R15(不校验余额)是真的

M2 的说明:种子 SQL 每次都把账户 UPDATE 回 finance_bk 基线,所以"第二次跑基线不等"这个症状不会出现; 真正抓住它的是残留断言(并且在截图里能看到 target 侧多出 1 条已提交的流水)。

批 B(三支各打一处,全部"红成应红的那一条")

# 改错什么 靶子断言 实测输出
M1 spend 也扣 balance(全局+校区) Δbalance == 0 ✘ 累计消费(spend):Δbalance 必须为 0 → 全局 0.00/-25.00;转出校区 0.00/-25.00;FAIL 3 项
M2 transfer 的 206 写成 +m(同向) SUM(money) == 0 ✘ 转结:… SUM(money)==0 → target 2 条 types=106,206 SUM=100.00(期望 0);FAIL 2 项
M3 baddebt 只加 special_expenses、不加 balance 双增断言 ✘ 坏账:balance 与 special_expenses 必须同时 +金额 → target 全局 Δbal=0.00 Δspec=80.00;FAIL 4 项

M2 有一个值得记的细节:"两校区反号"那条仍然是绿的。 206 写成 +m 时两个校区的 total_expenditure 依然是 −m/+m(账户更新是对的,只有流水符号错了)—— 所以 SUM(money)==0 和"两校区反号"不能互相替代,必须两条都在。 这正是"一条断言只钉住一件事"的价值:如果把它们合成一条"转结语义正确", M2 会让整条一起红,但看不出到底是账户错了还是流水错了。

M3 还顺带被第三条断言抓到:target.balance_after=180.00 ≠ 调用后全局余额 100.00。 balance_after 是 Go 独有的列,旧库无从对照,但它自己必须与"调用后的余额"自洽 —— 这条自洽性检查在这次变异里起到了冗余防线的作用。


7. 批 C1:余额清零(SP10)+ 取消充值活动(SP8)

这两支的资金部分很简单,「跨域 + 活动状态」部分才是重点。

SP 签名(照 dump) 资金增量 流水 活动状态
sp_user_expense_school_cleared_balance(SP10) (userid, schoolid, campusid, remark, createUserid) —— 无金额、无请求号 balance−campusBalance、special_expenses+campusBalance、spend+campusBalance(双表同额) type=208,money=−campusBalance,无 logid 充值赠送报名 → 4(过期)
sp_user_expense_cancel_recharge_activity(SP8) (userid, activityid, campusid, money, requestid, remark, createUserid) —— 无 schoolid balance−money、total_income−money(双表) type=209,money=−money 该活动报名 → 0(取消报名)

7.1 「清零」清的是校区,不是全局

UPDATE t_user_expense_school SET balance=balance-campusBalance,
       special_expenses=special_expenses+campusBalance, spend=spend+campusBalance WHERE ...;
UPDATE t_user_expense        SET balance=balance-campusBalance,
       special_expenses=special_expenses+campusBalance, spend=spend+campusBalance WHERE userid=...;

全局那一行减的是同一个 campusBalance,所以清零后全局余额 = 原全局 − 本校区余额, 一般不是 0(它等于其他校区余额之和)。名字叫"余额清零",清的只有校区这一行。

三条断言分别钉住三件事:

✔ 清零:**校区**余额恰好归 0(「清零」清的是校区这一行)
✔ 清零:全局余额只减 campusBalance(**不是**归 0,除非原本只有一个校区有钱)
   legacy 全局 5000.00→3800.00(Δ-1200.00,campusBalance=1200.00)| target 全局 5000.00→3800.00(…)
✔ 清零:special_expenses 与 spend **同额增加** campusBalance(三列一起动,只动一列会红)

⚠ 基线的选择决定了断言能不能分辨对错。 一开始 C1-1 用的是用户天然余额:全局 9740 = 校区 9740。 这时"减 campusBalance"和"全局归 0"结果完全相同,变异 M1 打过去是绿的 —— 断言看起来在跑,实际什么都没测。 所以 C1-1/C1-2 用 Baseline 把全局造得比校区大(5000 vs 1200),C1-1 的 Δ 才第一次真正区分开。

7.2 三条前置校验:legacy 静默 LEAVE,Go 3120

IF (ISNULL(campusBalance) || campusBalance<=0) THEN LEAVE leaveProcedure; END IF;
IF (ISNULL(totalBalance)  || totalBalance <=0) THEN LEAVE leaveProcedure; END IF;
IF (campusBalance > totalBalance)              THEN LEAVE leaveProcedure; END IF;

派单里写"余额已 0 时再清零 → 3120"——那是 Go 侧的行为。SP 三条守卫都是静默 no-op, 调用方看到的是"成功"。所以这又是一组 intentional-strict(和 A7 同一形状), 三条守卫各一个 case(C1-3 / C1-4 / C1-5),断言改成:

✔ intentional-strict:legacy 静默不报错 + target 显式拒绝(码要对)
   legacy 抛错=false(期望 false,SP 静默 no-op);target 被拒=true 码=3120(期望 被拒=true 码=3120)
✔ intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条、活动状态不变)

比"报错了"强一档的是"报了哪个码":CallOutcome.Code 用 errors.As 解出 *AppError.Code, 断言 3120 / 3104 / 3140。同一个文案可能被别处复用,码是设计文档里定死的契约。

7.3 两支 SP 都会跨域读校区 —— 两侧读的还不是同一张表

侧 读的是 SQL
legacy SP yuansiwei.t_school_campus(旧库视图) SELECT self_support FROM yuansiwei.t_school_campus WHERE id=?
Go yuansiwei_2026.t_campus(迁移后的表) SELECT institution_id, self_support FROM t_campus WHERE campus_id=?

两个来源只要在某校区上不一致,两侧就会走不同分支(自营 campus_id IN (x,0) vs 加盟 = x), 而那种红看起来像"SP 行为不对",其实是"校区元数据没对齐"。所以单独一条断言:

✔ 两侧看到的校区元数据一致(机构 + 是否自营)—— 不同就会走不同失效分支
   legacy 视图 yuansiwei.t_school_campus:机构=63317 自营=true | target t_campus:机构=63317 自营=true | case 声明:机构=63317 自营=true

配套两条纪律:

  1. Case.SelfSupport 由 case 显式声明,而不是"从哪一侧读出来喂给另一侧";
  2. seedTarget 的 t_campus.self_support 不许硬编码。原写法就是硬编码 1, 后果是"加盟校区"这条分支永远走不到 —— 变异 M2 打过去立刻红 (92:2→4 多失效了一条 campus_id=0 的全机构池活动)。

7.4 活动矩阵:6 条活动覆盖 6 个判据

C1ActivityPlan(base)(id 由运行期取 legacy max(id)+1,两侧同值):

偏移 type 机构 校区 报名状态 用来验证
+0 2 63317 5(本级) 2 自营/加盟都应失效
+1 2 63317 0(全机构池) 2 只有自营失效 ← 关键分支
+2 2 63317 4(其他校区) 2 校区过滤
+3 2 63322(其他机构) 5 2 机构过滤
+4 3(代金券) 63317 5 2 type 过滤
+5 2 63317 5 4(已过期) status<>4 过滤
+6 2 63317 5 未报名 JOIN 过滤(没有行就不出现)

外加 OtherUsers:让别人在"应被失效"的活动上也有一条报名,验证 WHERE userid=inputUserid 不波及旁人。

自营 vs 加盟的实测差异(同一批活动、不同校区):

C1-1 自营:期望 {91=4, 92=4, 93=2, 94=2, 95=2, 96=4}   本次变化:91:2→4, 92:2→4
C1-2 加盟:期望 {91=2, 92=2, 93=4, 94=2, 95=2, 96=4}   本次变化:93:2→4

TestExpectedSignedAfter 里还硬性断言"这两个期望不能相同" —— 相同就说明分支退化成了一条, 那两个 case 只测了一遍。

7.5 case 有效性前提:旧库里没有"别的"可失效报名

活动域还没迁移,所以旧库里真实存在的可失效报名,target 侧没有。 若不清零,legacy 会多失效几条而 target 不失效,两侧失效集合天然不同 —— 那种红是"数据没搬全",不是"SP 行为不对"。所以每个清零 case 开头先断言:

✔ case 有效性前提:旧库里该用户**没有别的**可失效报名(否则两侧失效范围天然不同)
   本 case 之外可失效报名行=0

选人就是这么选出来的:68542 / 40583 在旧库都恰好没有 type=2 的可失效报名 (反例:17735 有 8 条、31477 有 3 条,都不能用来跑清零)。

7.6 SP8 的返回值本身是契约

sp_user_expense_cancel_recharge_activity 的每条路径都以 SELECT 结尾:

-- 正常结束
SELECT selectSchoolid AS schoolid, success, msg;   -- success=1, msg='ok'
-- 找不到校区
SET msg='找不到学校信息'; SELECT success, msg; LEAVE leaveProcedure;   -- success=0,**不抛错**

两件事因此变了:

  1. 它不能用 Exec 调(结果集必须读干净,否则连接上留下未消费的结果集)→ returnsResultSet(op) 分流;
  2. "成功返回但业务失败"(success=0)只能靠读返回值发现 → CallOutcome.Returned 按列名保存。

    ✔ SP8 返回值:success=1 / msg=ok / schoolid 由校区反查得到
    ✔ SP8 返回值:找不到校区时 **success=0 + msg='找不到学校信息'**(不抛错,靠返回值表达失败)
    

7.7 一个"看起来在测、其实没测"的坑:CampusMissing

C1-8 要验证"校区不存在"。但 seedTarget 需要先建 t_campus 行,Go 才找得到校区 —— 如果不加声明,它会顺手给 999999 也建一行,Go 就"找到了校区"并成功。 第一次跑 C1-8 就是这样:legacy 正确返回 success=0,target 却 success=true(FAIL 3 项)。

修法不是"让它过",而是把这个前提显式声明出来:

CampusMissing: true   // 本 case 故意用不存在的校区;不建 t_campus、不建校区账户

再配一条对称断言(campusAccountAssertion):该存在的存在、该缺失的缺失。 没有它,"读不到账户"会被读成 0 余额,两侧都是 0 → 基线相等断言空过, 整个 case 看起来在跑、其实什么都没测。

7.8 批 C1 变异实验(全部还原)

变异分两侧打,结论不一样,所以要分开记: 实现侧变异证明"代码写错会被抓住",判据侧变异证明"判据本身不是摆设" (判据侧若改了还不红,说明那条断言是空的 —— 它绿不绿跟被测代码无关)。

7.8.1 实现侧(3 组)

# 改错什么 靶子 实测
M1 把"清零"实现成 全局余额归 0(而不是减 campusBalance) 全局只减 campusBalance ✘ 全局 Δbalance legacy=-1200.00 target=-5000.00;✘ 清零:全局余额只减 campusBalance → target 5000.00→0.00(改前是绿的,见 7.1 的注)
M2 t_campus.self_support 硬编码成 1 校区元数据一致 + 活动失效范围 ✘ 两侧看到的校区元数据一致(target 自营=true ≠ 声明 false);✘ 活动失效范围 → target 多失效 92:2→4
M3 取消活动把报名状态写成 4(过期)而非 0(取消) 归 0 不是 4 ✘ 报名状态归 **0** → target {91=4,…};✘ 只有本活动的报名状态变

M1 的注:68542 / 40583 的真实余额都是"全局 == 该校区"(9740/9740、5400/5400), 在这种基线上"减 campusBalance"与"归 0"结果完全一样,M1 改错也不会红。 所以 C1-1/C1-2 用 Baseline 把两者拉开成 5000/1200、3000/900 —— 差值 3800 / 2100, 这条断言才真正 load-bearing。绿了不等于测到了,这一条是本批最大的教训。

7.8.2 判据侧(4 组,批 C1 交付时补做)

# 改错什么(改判据,不改实现) 预期红 实际红 一致?
M1' expirable() 自营分支删掉 \|\| it.CampusID == 0 只 C1-1 红,C1-2 保持绿 ✘ 活动失效范围:期望 {91=4, 92=2, …} vs 实测 {91=4, 92=4, …};C1-1 FAIL 1 项;C1-2 仍 PASS ✅ 完全一致
M2' clearedBalanceExpected() 删掉 Special: campusBalance C1-1 / C1-2 红在 Δspecial_expenses C1-2:✘ 全局 Δspecial_expenses legacy=900.00 期望 0.00、✘ 转出校区 Δspecial_expenses …,FAIL 2 项 ✅ 一致(C1-1 同机理,未单独跑)
M3' signedAll(c, lg, 0) 的 0 改成 4 C1-6 红 ✘ 取消充值活动:报名状态归 0(不是 4),FAIL 1 项 ✅ 一致
M4' 清零断言改成"期望全局归 0"(-Before.Global.Balance) C1-1 红 ✘ 清零:全局余额只减 campusBalance(实测 Δ−1200.00,期望 −5000.00),FAIL 1 项 ✅ 一致

M3' 有一个值得记的细节:改错 signedAll 之后,只有本活动的报名状态变(整张 map 全等那条)仍然是绿的。 这两条不是重复:signedAll 只盯"活动 91 是不是 0",整表全等盯"别的活动有没有被顺手动"。 判据 M3' 只动前者 → 只有前者红;实现 M3(改实现)两条都红。一条断言只钉一件事, 所以两条都要留。

M2 特别值得记:它同时红了两条,而且第一条直接指出原因(元数据不一致), 第二条才是指标(失效范围错)。"断言拆得细"不是为了严格,是为了出错时能定位。

7.9 Baseline 有两种用途,别混

Case.Baseline 原来只有一个用途(把余额压低验证 R15)。批 C1 又用它造 "全局 > 校区"的基线。于是 if c.Baseline != nil { 断言余额为负 } 这条假红了 —— C1-1 的余额根本不该为负。

改成显式声明:Case.ExpectNegativeBalance,只有 R2 设它。 "用到了某个字段"不等于"这个字段的原意成立" —— 判据要写在 case 上,不要从别的字段反推。

7.10 补齐的三个边界 case(C1-9 / C1-10 / C1-11)

前 8 条都在"正常金额 + 正常账户 + 正常请求号"的区间里,三个边界一条都没踩到。

case 构型 真实/合成 结论
C1-9 requestid 33 字符 真实构型(68542 + campus 5),请求号是构造的(生产不会有 33 字符) INTENTIONAL-WIDEN
C1-10 余额 100.00 退 500.00 金额构造(Baseline 压到 100.00),用户/校区真实 两侧都 −400.00(R15 第 3 条来源)
C1-11 校区在、账户行不存在 真实构型:40585 全局 5280.00、campus 5 无账户行(已核) LEGACY-DEFECT(见 7.11)

C1-9 的真正发现不是"列宽不同",是"legacy 半途失败会留下无凭证的扣款":

legacy: UPDATE balance=balance-m, total_income=total_income-m   ← 成功
        INSERT t_user_expense_log(logid=33 字符)                 ← 1406,整个 CALL 中止
        ⇒ money 扣了,log 没有
target: log_id / request_id 都是 varchar(64) ⇒ 正常入库

MySQL 只回滚失败的那条语句,不回滚同一次 CALL 里已经执行过的前置语句。 所以 legacy 的终态是"余额少了 30,但查不到任何凭证"。这不是我们引入的差异, 但必须在台账里写清楚:迁移后如果只按流水对账,这 30 元会变成"凭空消失"。

列宽实测(information_schema):

列 legacy target
t_user_expense_log.logid / log_id varchar(32) varchar(64)
(target 独有)request_id — varchar(64)

判定原则:target 列宽 > 32 → INTENTIONAL-WIDEN(我们更宽,不是缺陷,也不并进 IntentionalStrict —— Strict 是"我们更严",Widen 是"我们更宽",台账要能分开数); = 32 → 两侧一致,按普通 case;< 32 → 真缺陷(我们比 legacy 窄,部分历史请求号无法复用)。 本次实测是 64 > 32。

7.11 C1-11 挖出的 legacy 数据缺陷:建行之后金额列是 NULL

dump 的建行语句只写三列:

IF logCount=0 THEN
  INSERT INTO `t_user_expense_school`(`userid`,`schoolid`,`campusid`) VALUES(...);
END IF;

而两张表的列定义不一样(information_schema.COLUMNS):

表 列 类型 默认值
t_user_expense_school balance / spend / total_income / total_expenditure / special_expenses decimal(10,3) **NULL** 无(DEFAULT NULL)
t_user_expense balance / total_income float(10,3) 0.000

于是:

校区分支:INSERT → balance=NULL → UPDATE balance=balance-30 → NULL-30 = **NULL**(NULL 传染)
全局分支:INSERT → balance=0.000 → UPDATE balance=balance-30 → **-30.000**

同一笔业务,校区一分没扣、全局扣了 30,而 type=209 的凭证照写。 两表从此永久不一致。

target 侧同名列是 decimal(12,2) NOT NULL DEFAULT 0.00,建行即 0,真扣成 −30.00,两表一致。 所以这条判定是 LEGACY-DEFECT:legacy 错、target 对,不跟。

附带一个工程教训:这第一条 case 一跑就在读回阶段崩了 (converting NULL to string is unsupported),连断言都走不到。 当时的直觉是"case 写错了",实际是"读的工具不够宽容" —— snapshotLegacy 的校区列必须用 sql.NullString,并用 SideSnapshot.CampusNullBalance 把"行在、但金额是 NULL"显式记下来。 读不出来的状态,等于没有这个状态。


8. 金额三态:NULL ≠ 0(C1-11 逼出来的结构性改动)

8.1 为什么必须区分

legacy 两张账户表的金额列全部 NULL 可空,而且默认值不一样:

表 四列金额(balance/spend/total_income/total_expenditure) special_expenses / total_recommended_amount
t_user_expense float(10,3),DEFAULT 0.000 decimal(10,3),DEFAULT NULL
t_user_expense_school decimal(10,3),DEFAULT NULL decimal(10,3),DEFAULT NULL

SP 的"先建行"分支只写三列:

IF logCount=0 THEN
    INSERT INTO t_user_expense_school(userid, schoolid, campusid) VALUES (...);
END IF;
UPDATE t_user_expense_school SET balance=balance-inputMoney, total_income=total_income-inputMoney WHERE ...;

于是新行的余额是 NULL,NULL - 30 还是 NULL —— 钱一分没扣,凭证照写。 (t_user_expense 那边因为 DEFAULT 0.000,全局真扣了 → 同一笔业务两表自相矛盾。实测 5287 行里 balance IS NULL 为 0,说明这条分支在生产上从未触发,但代码路径在。)

如果把 NULL 读成 0,两种后果完全不同的状态会合并成同一个数:

状态 下一次 balance-30 含义
0 变成 -30 扣完了 / 本来就是 0
NULL 还是 NULL 这行从没被写过,后续所有算术静默失效

合并之后,"建行后扣不动"会显示成"扣了 0 元",断言静默通过 —— harness 自己制造的空过。

8.2 实现

type AccountNulls struct { Balance, Spend, Income, Expenditure, Special bool }
type Account struct { ...五列 int64...; Nulls AccountNulls }

三个要点:

  1. AccountNulls 是可比较结构体,加进 Account 后 == 自动区分 NULL 与 0, 所有既有比对点不改也变严(这是这次改动没有扩大风险面的原因)。
  2. sub() 逐列传播 NULL:任一侧 NULL → Δ 为 NULL(NULL-30 是 NULL,不是 −30)。 五列都要显式处理,漏一列那列 Δ 恒为 0(批 B 的 Special 就是这么假红过的)。
  3. 读回来一律走 accountNS(sql.NullString ×5)+ centsOrNull(); 不要再写 IFNULL(x,0) —— 那正是把 NULL 抹成 0 的地方。

报告渲染:moneyStr(cents, isNull) → NULL 显示成 NULL,不能显示成 0.00; fmtAcc 末尾会追加 NULL:balance,total_income 这样的列名。

8.3 两个必须记住的坑

坑一:x == 0 在 NULL 上为真。 本轮改动同时暴露了旧代码里 dGlobal.Balance == 0 这类写法("不动的列必须为 0")。 Δ 为 NULL 时值也是 0,直接比 int64 会把「NULL(压根没算出来)」当成「确实是 0」。 统一改成 isZero(v, isNull)(= !isNull && v == 0)。

坑二:行不存在 ≠ 行内全 NULL。 accountNS 的零值五个字段都是 invalid,直接 account() 会得到"五列全 NULL"。 于是"没有这一行"被伪装成"有一行但没写过",建行分支的断言就假绿了。 所以两侧都写成:

if out.HasCampusAccount { out.Campus = sc.account() }   // 无行 → 保持零值 Account(Nulls 全 false)

8.4 C1-11 实测(LEGACY-DEFECT)

用户 40585 在 campus 5 没有校区账户行(全局有 5280.00),取消充值 30.00:

项 legacy target
校区行 建出来了 建出来了
校区 balance NULL -30.00
校区五列 NULL 分布 balance,spend,total_income,total_expenditure,special_expenses(全 NULL) -(无 NULL)
全局 Δbalance / Δtotal_income -30.00 / -30.00(真扣了) -30.00 / -30.00
流水 1 条 type=209 money=-30.00(钱没扣、凭证有了) 1 条,同
Δ校区 NULL -30.00

判定 LEGACY-DEFECT:legacy 两表自相矛盾,target(NOT NULL DEFAULT 0.00)才是对的,不跟。

8.5 判据被小数收敛抹平:C1-5 与 C1-5b(两条并存)

先说一个被订正过的错误结论:本文件早期写过"这条守卫在两位小数下不可达"——错的。 按迁移口径复算(4661 个有校区行的用户):

口径 3 位成立 2 位仍成立 2 位被抹平
campus>0 49 36 13
campus>0 且 total>0(第三条守卫真正可达) 21 11 10

49 里有 28 行 total<=0,那些 legacy 是被第二条守卫挡的,而 target 的 ClearBalance 同样有 totalBalance<=0 → 3120 ⇒ 两侧一致、不分叉。所以真正会分叉的只有 10 行:

在第三条守卫真正起作用的 21 个真实构型里,有 10 个(47.6%)迁移后失去触发条件。

不是"该守卫失效",是"它仍在起作用,但近一半输入迁移后不再触发" —— 所以守卫要保留, 这条差异要登记,但不能让它把主干搞红。

因此拆成两条:

case 构型 测什么 断言
C1-5 Baseline{500, 800}(造数) 守卫本身:两位小数下不等式仍成立 两侧都 LEAVE、零写入
C1-5b 真实 65496:校区 1565.688 / 全局 1565.687 判据输入被收敛抹平 两侧各自断言,不做"两侧相等"

C1-5b 的分类是 PRECISION-CONVERGENCE + subtype PREDICATE-COLLAPSE:

  • 顶层类 = 成因(3→2 位收敛,与阶段 1 同源,所以不新造顶层类);
  • subtype = 可观测形态(分支不同,不是"差几分钱")。按 subtype 过滤才能找到它, 否则会被"只差几分钱"的噪声淹掉;
  • INPUT-TRUNCATION(字符串截断导致判据变化,如 logid varchar(32) vs varchar(64)) 不属于这个 subtype —— 处置动作不同,混在一起子类就白分了。

C1-5b 的报告必须打 3 位原值。 否则两侧都显示 1565.69,读者只会认为 harness 坏了:

legacy 校区余额(原值 3 位)   1565.688 → 1565.688(LEAVE,未写入)
legacy 全局余额(原值 3 位)   1565.687
target 校区余额(存储 2 位)   1565.69  → 0.00
判据:legacy 1565.688 > 1565.687 成立 ⇒ LEAVE|target 1565.69 > 1565.69 不成立 ⇒ 放行

实现上:3 位原值只在 SpecialPredicateCollapse 分支读(readRaw3dp,CAST(balance AS CHAR)), 不进共享快照 —— 进了就会改变另外 24 条的读数口径。target 侧不许打三位: 它的存储是 decimal(12,2),打 1565.690 等于凭空造一个它没有的精度位, 与"把 NULL 折成 0"是同一类错误(用呈现掩盖真实数据形状)。


9. 第六个可空列:total_recommended_amount

AccountNulls 原来只有 5 列,漏了 total_recommended_amount(两张表都是 DEFAULT NULL)。 它此前从未进过快照 —— 不是"三态漏了它",是"整个 harness 从没看它"。

补上之后(AccountNulls.Recommended + Account.Recommended + 3 处 SELECT + sub() 一行 + addDelta 一行), 报告里每个 case 多一行 Δtotal_recommended_amount。但这一行打的是 — 而不是 ✔:

— 全局 Δtotal_recommended_amount  legacy=0.00  target=0.00  期望 0.00 / 0.00(本 Op 不写该列 ⇒ 结构性不适用)

为什么不用 ✔:当前 7 支 SP 都不写这一列,0 == 0 会和"真的比过且相等"长得一样 —— 空过。 所以 Diff 加第三态 Skipped,报告打 —。

这一行的判定是 opWritesRecommended(op):

  • 现在恒 false ⇒ 只要两侧实测都是 0 就打 —;
  • 一旦哪天有实现意外写了它(非 0),就不再 skip ⇒ 立刻红;
  • 等 sp_courses_activity_use_activity_recommend(唯一写它的 SP)进来,把对应 Op 加进 那个 switch,这一行自动变成真断言 —— 位置早就留好了,不会因为"这条不存在"而漏掉。

10. 存量哨兵:与 case 无关的全库扫描

go run ./cmd/dualrun sentinel              # 默认扫 finance_bk
go run ./cmd/dualrun sentinel -db yuansiwei_2026

为什么不能挂在 case 里:special_expenses / total_recommended_amount 在迁移后 0 不一致(154 / 783 个非零样本全对上),但把它写成 per-case 断言, 在 96.7% / 83.2% 的 case 上就是 0 == 0 —— 又一个空过。它守的是结构性问题 ("有没有出现只写一边的新路径"),所以只能全库扫。

它断言的是:全局列 <=> Σ各校区列(NULL 安全相等)。三条硬要求写死在实现里:

  1. 禁止用 <> 判相等:NULL <> x 求值为 NULL,CASE WHEN NULL 会被静默算成"一致"。 必须用 <=>(NULL 安全)。这是最阴的一种空过 —— 它连"不一致"都算不出来。
  2. nonzeroSamples 必须一起报,且为 0 时判 FAIL("样本消失,断言失效")。 一个全 0 的库上,0 == 0 会永远绿。
  3. 独立入口,不进 DefaultCases。

实测(finance_bk):

  列                        扫描用户  不一致  非零样本  结论
  special_expenses              4661       0       154  PASS
  total_recommended_amount      4661       0       783  PASS

顺带一条断言设计规则(这批数据支撑的):跨表存量恒等式不能一刀切,要按列实证 ——

列 迁移后不一致 结论
balance / spend / total_income / total_expenditure 3 / 197 / 27 / 109(占 4661 的 0.06%~4.23%) 禁止断言相等(历史单边写入留下的,红得没意义)
special_expenses / total_recommended_amount 0 / 0(154 / 783 非零样本) 可以断言,且比 Δ 更强

注意 balance 只有 3 例也不能当 0 用(那 3 个人迟早被挑中)。 而能断言的那两列要按哨兵语义用:它测的是"还没有出现单边写入路径",不是"数据本来就该这样"。

11. 分母收缩(denominator shrinkage)与 CLI 三道闸

报数时分母必须是"应该跑的全部",不能是"真正跑完的"。分母从 25 变成 22 而结论仍是"全 PASS", 等于什么都没测。它与"空过断言"并列。

cmd/dualrun stage2 现在有四道闸:

# 闸 触发 退出码
① DefaultCases 长度为 0 列表被清空 / build tag 没生效 3
② -case 按名字首段精确匹配(不是子串) -case C1-5 不会带出 C1-5b —
③ -case 匹配不到 / 匹配到多条 打错名字(原来会"0 个 case、退出 0") 2
④ 实跑数 ≠ 本次应跑数 有人把 return 3 改成 continue 3

全量跑时摘要会先打一行分母,定向跑额外打一条 "不构成验收" 的警告:

分母:DefaultCases=25 | 本次应跑=25 | 实跑=25

12. self_support 是 bit(1)(订正一处写错的注释)

yuansiwei.t_school_campus(VIEW)与 yuansiwei_i.t_school_campus(BASE TABLE)的 self_support 都是 bit(1),HEX() 出来是 '0' / '1'、LENGTH() = 1。

早先的代码用 == "1" || == "\x01" 两支猜测兼容,注释还写"不是 bit(1)"——注释是错的。 功能上恰好走对(bit 扫成字符串是 \x01),但下一个人按注释把 \x01 删掉留下 == "1", 就会静默恒 false,后果是活动失效范围错(C1 的核心判据),而且不报错、只假绿。

现在改成 SQL 里 CAST(self_support AS UNSIGNED) 扫 int64,让类型系统表达,注释改成实测事实。 改完的回归点:C1-1 必须仍显示 自营=true、C1-2 仍显示 自营=false。

13. 批 C2:DECLARE 变量会把金额收敛两次(SP9 自定义退费)

sp_user_expense_custom_refund 的正文里有三个变量:

DECLARE _money   DECIMAL DEFAULT 0;
DECLARE _badDate decimal DEFAULT 0;
DECLARE _balance DECIMAL DEFAULT 0;

DECIMAL 不带精度参数时是 DECIMAL(10,0),即整数。于是 legacy 把金额整数化了两次:

_balance ← t_user_expense_school.balance     第一层:0.280→0、475.376→475、12.500→13
_badDate ← inputMoney − _balance             第二层:1.50 − 1 = 0.5 → 1
_money   ← _balance

target(RefundService.CustomRefund)用精确十进制约:refunded = min(requested, balance)、 badDebt = requested − refunded。只要余额带小数,两侧就是两个不同的算法。

13.1 归类:LEGACY-DEFECT / VARIABLE-ROUNDING

候选名 采纳? 理由
PRECISION-CONVERGENCE ✗ 那是"迁移把 3 位收敛成 2 位",成因在迁移。这里在旧库原样跑就错。
PRECISION-CONVERGENCE / PREDICATE-COLLAPSE ✗ 那个专指"判据输入被抹平 ⇒ 分支不同"。这里的成因是变量类型,且坏账/流水金额也跟着变。
DECIMAL-TRUNCATION ✗ CAST(0.5)=1、CAST(2.5)=3 是四舍五入不是截断(截断给 0 / 2)。名字与实测语义不一致,下一个人按名字判断会做错 —— 命名本身就是判据。
LEGACY-DEFECT / VARIABLE-ROUNDING ✓ 顶层类 = 成因(变量声明缺精度 ⇒ 赋值即整数化);subtype = 可观测形态(逐次舍入,不是一次性)。

13.2 存量影响面(t_user_expense_school,balance > 0,共 566 个账户)

先定义两个口径,别混(这是本节唯一容易搞错的地方):

  • raw = 旧库列原值(decimal(10,3),3 位)
  • r0 = ROUND(raw, 0) —— legacy 的 _balance(第一层整数化)
  • r2 = ROUND(raw, 2) —— 迁移后 target 读到的值(2 位)
  • 缺陷面 = r0 与 raw 的差(旧系统自己的问题,与迁移无关)
  • 差异面 = r0 与 r2 的差(割接后新旧系统会不会表现出不同)
形态 账户数 金额 legacy 的行为 target 的行为
r0 = raw(整数余额) 268 — 与 target 逐分一致 同左
r0 = 0(0 < raw < 0.5) 166 ¥18.177 判"余额不能≤0"、拒绝退费(零写入) 照常退,退得出来
r0 > raw(多扣) 95 ¥21.155 多扣到整数,余额变负数(最多 −0.5) 精确扣到 0.00
r0 < raw(少扣) 37 ¥6.986 少扣、留正零头,坏账被低估 精确扣到 0.00

加法闭合(每一步都要能相加等于分母,这是最廉价也最容易被跳过的检查):

166 + 95 + 37 + 268 = 566   ← 缺陷面

再按「SP 归因差异面」切一次(同一批账户,换一个判据):

⚠ 口径名必须写全:"SP 归因"差异面,不是"差异面"。 那 11 个"重合"账户割接后结果仍然不同,只是差异不再来自 DECLARE 整数化, 而是来自"迁移把 3 位收敛成 2 位"。写成"差异面 88"会被读成"88 个账户割接后结果相同", 那是反的:剩下 11 个也有差异,只是归因不同。归因不同决定了它是"我们不复刻"还是"迁移本来就要做"。

SP 归因差异面 账户数 差额合计 说明
r0 > r2(legacy 比 target 多扣) 88 ¥21.130 95 减 7
r0 < r2(legacy 比 target 少扣) 33 ¥7.000 37 减 4
r0 = r2 但 r0 ≠ raw(重合:差异改归因给迁移) 11 见下 0.996 → 1.00 这类
r0 = 0(拒绝 vs 退款) 166 — 差异是"退不出 vs 退得出",不是金额
r0 = raw(整数余额) 268 — 无差异
88 + 33 + 11 + 166 + 268 = 566   ← 加法必须闭合
95 − 88 = 7,37 − 33 = 4,7 + 4 = 11   ← 两个口径的差恰好是"重合"那 11 个

那 11 个重合是什么:3 位小数向上/向下进位恰好落在整数上,2 位迁移后 r2 就等于 legacy 算出来的 r0,于是退的金额一样、但期末余额不同(差额来自迁移,不来自 SP)。 真实样本(已列出,可直接复跑):

userid raw r2 r0 期末余额差 归因
48510 0.996 1.00 1 0.004 迁移(3→2 位)
66449 0.999 1.00 1 0.001 迁移
66743 0.998 1.00 1 0.002 迁移
27676 1.999 2.00 2 0.001 迁移
67983 7.998 8.00 8 0.002 迁移
68584 99.996 100.00 100 0.004 迁移
47613 301.996 302.00 302 0.004 迁移
合计 7 个 0.018
65321 1100.004 1100.00 1100 0.004 迁移(反方向)
66688 1.004 1.00 1 0.004 迁移
67617 400.002 400.00 400 0.002 迁移
68184 200.004 200.00 200 0.004 迁移
合计 4 个 0.014
11 个的 |r2 − raw| 求和 = 0.018 + 0.014 = 0.032
带符号求和 SUM(r2 − raw)      = 0.018 − 0.014 = 0.004   ← 两个方向大部分互相抵消

⚠ 这三个数(0.018 / 0.014 / 0.004)必须分开说。把它们当成"每个 0.004 × 7 = 0.028"是错的: 7 个的差额是 {0.001, 0.001, 0.002, 0.002, 0.004, 0.004, 0.004},不同质; 而且反方向还有 4 个。假设"同一类账户的差额相同"就是又一次"用了一个真实数据里不存在的值域" —— 与 §13.2 开头那两处错是同一族。

复跑判据(注意不是 ROUND(balance,0) <> ROUND(ROUND(balance,2),0),那个在全库是 0 行):

-- 11 个重合账户(7 多扣侧 + 4 少扣侧),一条 SQL 全出
SELECT userid, balance,
       ROUND(balance,2)        AS r2,
       ROUND(balance,0)        AS r0,
       ROUND(balance,2) - balance AS balance_diff
  FROM t_user_expense_school
 WHERE balance > 0
   AND ROUND(balance,0) > 0          -- 不是"整数化后归 0"那 166 个
   AND ROUND(balance,0) = ROUND(balance,2)  -- 2 位口径下已与整数重合
   AND ROUND(balance,0) <> balance;         -- 但 raw 不是整数

⚠ 两个坑,都是我自己踩过的:

  1. 举例用了 4 位小数(早先写成 3.9995 → 4.00)。旧列是 decimal(10,3),存不下 4 位 —— 例子本身就不成立,等于用了一个真实数据里不存在的值域。真实的形状是 0.996 → 1.00。
  2. 把"选 case 的过滤条件"说成了"标识这 11 个"。ROUND(balance,0) = ROUND(ROUND(balance,2),0) 在真实数据上对所有账户都成立(要让两者不等,分位必须落在 [0.495, 0.5),全库 0 个), 所以它不能用来找重合账户 —— 它只是当时选 case 时"确认不会混成因"的一个始终为真的检查。

教训:引用一个判据时,要说清它筛的是什么、以及在真实数据上会不会全真/全假。 一个恒真的过滤条件看起来像"检查过",实际什么都没筛。

13.3 断言形态:per-side + 共同不变量

「不比对」的是两侧之间;每一侧自己仍要断言不变量。否则这一支就只剩打印数值,与空过断言同源。

不变量 legacy target
207.money == −inputMoney ✔ ✔
Δtotal_income == Δbalance ✔ ✔
108.money == round0(inputMoney − round0(balance_before)) ✔ —
坏账分支写 108、ELSE 分支不写 ✔ —
refunded + badDebt == inputMoney(实测 −Δbal + Δspec) — ✔
refunded == min(inputMoney, balance_before) — ✔
balance_after >= 0 — ✔

前两条两侧都成立 —— 它们证明的是「结构性复刻对了,只是舍入不同」。没有它们, "两侧结果不同"就分不清是"舍入"还是"我们根本没复刻那套结构"。

13.4 一个必须按 SQL 直读、不能按直觉写的分支

dump 是两个独立的 IF,不是 if/else:

IF(inputMoney > _balance) THEN SET _badDate = inputMoney - _balance; SET _money = _balance; END IF;
IF(_badDate > 0) THEN ... ELSE UPDATE ... `balance` - inputMoney ... END IF;

所以 inputMoney > _balance 成立、但差额不足 0.5 时,_badDate 收敛成 0 ⇒ 仍然走 ELSE、 扣的是 inputMoney 而不是 _money。按 if/else 的直觉写,会期望扣 1.00 而实测扣 1.30。 锁在 TestLegacyRefundExpectBadDateRoundsToZero。

13.5 case 与分母

DefaultCases 25 → 35(C2 共 10 条):

case 余额 请求 结论
C2-1 9740.00 300.00 两侧一致(整数余额,ELSE)
C2-1b 6800.00 7000.00 两侧一致(整数余额,坏账分支)
C2-2 0.28 0.28 A 象限:legacy 拒绝 / target 退得出来
C2-3 0.832 1.50 B 象限:_badDate 第二次整数化(108 = +1.00,不是 +0.67)
C2-4 475.376 600.00 C 象限:legacy 留正零头 0.376
C2-5 12.500 20.00 D 象限:半进远离零的唯一哨兵(13 而不是 12)
C2-6 699.980 699.99 缝区间:699.99 > 700 为假 ⇒ 连分支都不同
C2-8 无校区行 100.00 legacy 静默 success=0 / target 3102
C2-9 5000.00 300.00×2 幂等:target 扣 1 次、legacy 扣 2 次
C2-10 4950.00 5300.00 活动失效:整数余额 + 自营 ⇒ 两侧一致,且本校区与全机构池都置 4

C2-7(E)不在其中,只在 dualrun stage2 -probe 里跑:它靠"legacy 侧余额置 NULL"构造, 而 target 的列是 not null default 0.00、NULL 存不下,所以第二侧不同构。 "两侧都拒绝"这个绿的含金量是 0(换任何一条余额为 0 的记录都能绿),算进分母等于用必然绿的 case 抬通过率。

C2-10 为什么必须单独存在(即使 SQL 与 cleared_balance 逐字相同)

SP9 里那段 tmp_user_activity + UPDATE t_courses_activity_signed SET status=4 与 sp_user_expense_school_cleared_balance 逐字相同。但有两条理由不能"因为一样就不测":

  1. 执行位置不同:清零在写流水之前跑这段,退费在写流水之后跑。 事务内顺序不同 ⇒ 失败时的终态组合不同("流水写了、报名没失效"只有这一支才可能)。
  2. 复制粘贴的 SQL 最怕改了一个字:同一段逻辑出现在两处,就该在两处各验一次 —— 否则哪天有人在 c2 那份里手滑改了 campusid IN (x, 0) 的括号,没人会发现。

它是第二条 equality case(整数余额 + 有可失效报名):legacy _balance=4950、 _badDate=round0(5300−4950)=350;target refunded=4950、badDebt=350 —— 逐分相同。

⚠ 活动侧必然是种子,资金侧才是真实构型。 实测 target 侧活动域是空的 (t_courses_activity=0、t_courses_activity_signed=0;legacy 侧 90 / 1288), 所以任何真实报名都只有 legacy 有、target 看不到。要两侧看到同一批活动,只能像 C1 那样 在事务内种(C1ActivityPlan),并用"旧库该用户没有别的可失效报名"这条前置断言钉住有效性。 这不是偷懒,是活动域未迁移的必然结果;想改成"用真实报名",得先迁活动域。

14. Assertion.Skipped:断言的第三态

Diff.Skipped 打 —(§ 里已述)之后,断言也需要同一个第三态,否则会出现 "0 == 0 打 ✔"的空过:

  • 两侧都被前置校验拒绝 ⇒ 207.money == −inputMoney 无从验证(根本没有 207);
  • 走的是 ELSE 分支 ⇒ 108.money == round0(...) 无从验证(根本没有 108)。

现在 Assertion 也有 Skipped,报告打 —,且 FailCount 不计入。

⚠ 这是必须由构造方负责的地方:把 Skipped 置错 = 让真实失败静默消失。 所以每个 Skipped 的产生条件都必须是"从 case 的构造就能看出结构上不适用", 不能是"实测值和我预期的不一样,所以跳过"。

14.1 闸:Skipped 不许把整条 case 掏空

FailCount 跳过 Skipped 之后,出现了一个新的空过通道:

一条 case 的全部断言都是 Skipped ⇒ 0 FAIL 的 PASS。 分母没变(case 还在),但分子全是空的 —— 这是分母收缩的又一个变种。

它可能真的发生:有人给某条 case 改了构型,把所有断言都变成结构性不适用,报告照样全绿。

所以 RunOne 末尾加了一道闸 CaseResult.EnsureNotAllSkipped():

情形 处置
有 ≥1 条非 Skipped 断言 放行
断言一条都没有 FATAL(退出码 3)
断言全 Skipped(即使 Pass 全是 true) FATAL(退出码 3)
断言全 Skipped 但 Diff 里有非 Skipped 比对 仍然 FATAL

报错而不记 FAIL 的理由:这不是"行为不对",是"这条 case 什么都没测"—— 与 -case 匹配不到、分母收缩同级,属于测试基础设施失效。 记 FAIL 会被当成"待修的红"排进队列,而它根本不该被容忍。

于是报数从六个数变成七个数:

hash / DefaultCases / 实跑 / PASS / FAIL / 崩溃 / Skipped

Skipped 必须一起报,否则"PASS 34"和"PASS 34 但有 60 项结构性不适用"看起来一样。

15. 一次真实的连接泄漏(34 条 case 才暴露)

RunOne 原来每个 case 打开 5 个 gorm 句柄(legacy / target / 天然基线 / 残留检查 ×2), 一个都没关。cfg.open 每次新建 *sql.DB(MaxOpenConns=1),连接要等 GC finalizer 才释放:

25 条 case(批 C1)= 125 个连接 → 压在 max_connections 边缘,没露头
34 条 case(批 C2)= 170 个连接 → Error 1040 (08004): Too many connections,整批中断

⚠ 报错发生在第 N 条,根因却在第 1 条。如果按"case 太多、要分批跑"处置, 这个泄漏就被永久掩埋了:以后每加一条 case 都要重新踩一次,而且每次都看起来像"量的刻度没选好"。

修法是给每个打开点加 defer closeDB(...),并在 closeDB 的注释里记下这个坑。

16. 69 项 Skipped 的分解("结构性不适用"总量,不含任何"没测到")

报数第七个数是 Skipped。它必须是可解释的:一个孤零零的 69 会被后来人当成"漏测了 69 项"。 以下是当前 35 条 case 的完整分解(grep '^ — ' 报告 可直接复核,各类相加 = 69):

类 条数 为什么是"结构性不适用"
全局 Δtotal_recommended_amount 17 当前所有 Op 都不写该列(唯一会写的是老带新 use_activity_recommend,尚未进批次)。§9 已说明。
转出校区 Δtotal_recommended_amount 17 同上,校区维度。
转入校区 Δtotal_recommended_amount 2 同上,仅转结那两条有第二校区。
余额(调用后,校区) 9 C2 的 per-side 比对:两侧算法不同 ⇒ 两侧之间不比。各自的结果已由 Δ 行按其自身期望断言。
【不适用】legacy 本条未被拒绝 ⇒ 拒绝契约无从验证 8 C2 里未被前置校验拒绝的 case:没有 success=0 可查。
legacy 专属不变量:108.money == round0(...) 4 C2 里走 ELSE 分支的 case:没有 108 流水可查。
共同不变量:207.money / Δtotal_income 2 C2-2(两侧都被拒)与另一条:没有 207 可查,恒等式无从验证。
活动失效(t_courses_activity_signed) 8 C2 里不种活动的 case:快照为空,{} == {} 不构成验证。
legacy 校区余额 / 全局余额(原值 3 位) 2 C1-5b:target 侧只有 2 位存储,没有可比的值。
17 + 17 + 2 + 9 + 8 + 4 + 2 + 8 + 2 = 69

两条纪律:

  1. Skipped 不等于"测不了"。上表每一行都写清了"为什么这一项在本 case 上无从验证", 而不是"实测和我预期不一样所以跳过"(见 §14 的警告)。
  2. Skipped 不是免检通道。EnsureNotAllSkipped 保证每条 case 至少有 1 条真断言(§14.1), 所以"69 项 Skipped"配的是"35 条 case 每条都有实质比对",不是空壳。

17. 活动链 9 支的证据强度上限(写在这里,因为它是这个批次的先天约束)

17.0 先说清"两侧的数据是怎么来的"——「输入来源」≠「真实度」

yuansiwei_2026 整个库一行数据都没有(实测):

表                                 target   legacy
t_user_expense                          0     8918
t_user_expense_school                   0     5287
t_user_expense_log                      0    90007
t_campus                                0       63
t_user                                  0     68107
t_courses_activity                      0       90
t_courses_activity_signed               0     1288
t_user_divide_into                      0       86
t_user_divide_into_log                  0      474
t_courses_activity_user_discount        0       81
t_courses_activity_used_log             0     8185
t_course_payment                        0        7

所以不能说"资金侧是真实构型、活动侧是种子"——target 侧从来没有真实数据,两侧都是按 case 现造的。 真正有区别的是输入来源:

维度 资金 / 机构 / 校区 / 人 活动 / 分账 / 课酬
造法 原值复制真实行:INSERT … SELECT … FROM finance_bk.…,同一套 ROUND 口径 构造行:C1ActivityPlan 之类的字面值,id 取 max+1 可控
为什么 真实行可用,复制最省事也最可信 活动域未迁移(target 侧虽有 schema 但 0 行)+ id 空间要可控

⚠ 一处必须记住的精度:两侧读的不是同一个库——target 的基线来自只读源 finance_bk, legacy 侧 SP 读克隆 legacy_oracle。两者能比,靠的是每个 case 都跑的 两侧基线逐分相等 那条显式断言。"原值复制"也有前提断言,不是天然相等。

对外必带的一句证据强度说明:

target 库无任何迁移数据。双跑验证的是「同一操作在两种实现下的等价性」, 不验证迁移后的数据分布。活动侧为构造行 —— 这不是"没找真实数据",而是活动域未迁移, 真实活动数据没测、也测不了。

17.1 三条实测事实

  1. target 侧活动域 schema 齐、数据空: t_courses_activity / _signed / _used_log / _user_discount / _user_discount_log 全部 0 行 (见 §17.0,其实整个 target 库都是 0 行); legacy 侧 t_courses_activity = 90、t_courses_activity_signed = 1288。 ⇒ 任何真实报名 / 真实赠送额度都只有 legacy 有。活动侧只能构造行。
  2. 9 支里只有 1 支碰资金表: sp_courses_activity_use_activity_recommend(t_user_expense / t_user_expense_school / t_user_expense_log type=107 / total_recommended_amount,共 14 处引用)。 其余 8 支的资金表引用数 = 0 —— 它们在 SP 层面根本不写资金,资金变动在 PHP 应用层。 ⇒ 沿用 C1/C2 那套"Δbalance / Δspecial / Δincome / 流水 type"的比对机器对 8 支完全不适用, 必须新增活动域快照(t_courses_activity / _signed / _used_log / _user_discount / _user_discount_log)。
  3. 生产构型分三档(PHP 仓 grep '<method>(' 去掉定义行):

| 档 | SP | 外部调用点 | 含义 | |---|---:|---:|---| | A 有生产调用 | signed_add(4) / signed_cancel(4) / use_activity_recharge(3) / refund_activity_recharge(4) / use_activity_recommend(1) | ≥1 | 线上真的会走,能构造真实业务链 | | B 封装方法 0 调用点 | user_discount_use / user_discount_refund / user_discount_refund_user_activity | 0 | 只有"把 dump 正文实现对了"这一层 | | C SP 的 CALL 被注释掉 | user_discount_usable | 0(CoursesActivityUserDiscountHelper.php:94 整行被 //) | 死代码:连"照 dump 实现"的驱动力都没有 |

⇒ 活动链批次的证据强度上限:

| 支 | 输入来源 | 能证明什么 | |---|---|---| | 5 支 A 档(其中 4 支无资金写) | 活动:构造行 | "照 dump 正文实现对了" + "构造行上的状态演化对" | | use_activity_recommend(A 档唯一碰资金的) | 活动:构造行 | 资金:原值复制真实行 | 课酬:原值复制真实行(t_courses_student 42050 行,其中 24133 行满足 remainder_receivable>0 AND confirm_discount_userid>0) | 上面两条 + 双表双列 balance/total_recommended_amount 同额 + 107 流水双发 + 嵌套 CALL 的欠费核销(第三域) | | 3 支 B 档 | 活动:构造行 | 只有"照 dump 正文实现对了" | | user_discount_usable(C 档) | 只读、无写 | 只有"返回值口径对了";它是死代码,建议不进 DefaultCases |

报告里必须逐支标注上表的档位,不能笼统写"9 支全绿" —— B/C 档的绿与 A 档的绿不是同一种绿,混在一张总表里就是把"没验证过生产构型"读成"生产上验证过了"。

17.2 W4(use_activity_recommend)的写入面是三个域、两个用户

这是本批唯一跨域的一支,也是唯一需要新增快照维度的一支:

域 写入
资金 t_user_expense_school / t_user_expense(可能 INSERT)、balance +m、total_recommended_amount +m、t_user_expense_log type=107 ×2(用户 + 推荐人)
活动 t_courses_activity_used_log INSERT(−m)、t_courses_activity_signed(activity_money_used=m, status=2)
课酬 CALL sp_courses_student_counteract_arrears_course ×2 → t_courses_student.remainder_receivable − / net_receipts +,cursor 判据 remainder_receivable>0 AND confirm_discount_userid>0 ORDER BY start_time ASC

⇒ 现有 SideSnapshot 的 3 块(Global / Campus / Campus2)全是 c.LegacyUserID 的, 不够用。缺两块:推荐人账户(否则"推荐人也 +m"不可断言,而它正是老带新的核心语义)

  • 课酬应收(否则核销顺序与 confirm_discount_userid>0 过滤都验不了)。

⚠ t_user_expense_school 的建行语句只写 (userid, schoolid, campusid) 三列 ⇒ balance / total_income / total_expenditure / total_recommended_amount 四列都无 DEFAULT ⇒ 建行后为 NULL ⇒ SET balance=balance+m 仍是 NULL ⇒ 校区侧一分没记、推荐金额两边都没记,而 107 流水照写。

⚠ 两张表的默认值不对称,这是缺陷的成因,不是随机 NULL: t_user_expense.balance 是 float(10,3) NULL DEFAULT 0.000(建行后有值,+m 到账), 而 t_user_expense_school 对应列无 DEFAULT(建行后 NULL,+m 仍是 NULL)。 ⇒ 实际结果是「全局余额到了、校区余额没记」,不是"钱一分没到账" (这句是 3c 时的错结论,已按 DDL 实测更正)。 建行分支只发生在校区账户行不存在时;"行在但列为 NULL"是另一条路径(同样结果、不同成因), 两者都要能测 —— 见 AccountSeed 的三态。

与 C1-11 同族(_school 那些列没有 DEFAULT),但方向相反: C1-11 是"全局扣了、校区没扣",W4-B 是"全局到了、校区没到"。线上有真实调用点。

17.3 user_discount_usable:oracle 是 PHP 内联 SQL,不是 SP

CoursesActivityUserDiscountHelper.php:94 把它 CALL 的那一行整行注释掉了, 但上面一行有生效的等价 SQL:

$sql = "SELECT SUM(t1.discount_amount-t1.discount_amount_used) as amount FROM ... WHERE {$condition}";

⇒ 生产实际跑的是那段 PHP 内联 SQL,SP 从未被调用过。

两条结论:

  1. 这一支不进 DefaultCases。拿 SP 当双跑基准,验的是"我们照着一段死代码实现对了", 而生产跑的是另一段代码 —— 那种绿是假绿。
  2. 将来若真要验 target 的"可用赠送金额",基准必须取 PHP 内联 SQL(getUsableDiscountAmount 那段),不是 SP。

给后端的一句话:这一支照 PHP 抄,不要照 dump 抄。 照 dump 抄会把 IF inputSchoolid>0 THEN … AND campusid=… 那个签名缺陷引进来 (两个参数绑成一个开关:传 schoolid>0, campusid=0 时条件变成 AND campusid=0, 只统计全机构池、漏掉本校区全部额度)。而那个缺陷在生产上从未发生,因为这段 SP 根本没被调用过。 它不是"待迁移的风险",而是"如果照 dump 抄就会被引入的风险" —— 这两句话的处置完全不同。

18. 断言分「业务 / 基建」:闸门只认业务断言

18.1 为什么必须分

活动链那 8 支 SP 不碰资金表(§17.1),它们的 Δbalance / Δincome / Δspecial 全是结构性 Skipped。 而「回滚后零残留」这类断言永远为真。于是:

"每条 case 至少一条非 Skipped 断言"这道闸 形同虚设 —— 残留那条在,闸就满足了, 而那条 case 什么都没测。这是"空过断言"换了个域复发。

所以 Assertion 加 Kind,闸门改成 EnsureHasBusiness:只认 AssertionBusiness。

Kind 包含哪些 计入闸门?
AssertionBusiness 金额 Δ、流水明细、活动状态演化、SP 返回值、拒绝契约、共同不变量… ✔
AssertionInfrastructure ① 回滚后零残留 ② 两侧基线逐分相等 ③ case 有效性前提(旧库无别的可失效报名 / 无别的负 log) ✘
AssertionUnclassified(零值) 不许出现。报告里打 ⚠,且不计入业务 ✘

当前 35 条的分布:业务 181 条 / 基建 55 条(残留 35 + 基线 17 + 有效性前提 3)/ 未分类 0 条。

18.2 工程纪律:默认值永远选严的那一侧

AssertionUnclassified 是零值,而且不算业务断言。这是故意的:

  • 若把 AssertionBusiness 设成零值(iota 的第一个),那么"新建 Assertion{} 忘了标 Kind" 会静默变成业务断言 ⇒ 闸门随"忘写"而放宽,正是我们要防的那类退化;
  • 现在默认是 Unclassified ⇒ 忘写会让该 case 因为"业务断言 = 0"直接 FATAL, 而且 Summarize 会把"未分类断言数"打出来(> 0 时整批不通过)。

默认值永远要选严的那一侧,因为它会在人忘记的时候替人做决定。

同一个道理已经在这个包里出现过四次,只是当时没起名字:

位置 默认宽松会造成什么
金额三态 NULL 折成 0 ⇒ "没写过"和"恰好是 0"混成一个数
Diff.Skipped / Assertion.Skipped 不打 — ⇒ 0 == 0 冒充"比过且相等"
FailCount 对 Skipped 计入 FAIL ⇒ 结构性不适用被当成待修的红
Assertion.Kind 零值算业务 ⇒ 忘标即放宽
断言块的 key 键恒 0 ⇒ 整块静默不执行,连红都不给(第 0 条)

反过来,三处故意选严的:toleranceCents = 0(容差永不放宽)、 哨兵的 nonzeroSamples > 0(非空自检)、未分类断言数 == 0。

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),并加单测断言两者字符串不同
4 行集"靠键配对" 少写一条 + 多写一条落在同一键上 ⇒ 互相抵消,绿 多重集(map[tuple]int),并把自增 id / 时间戳排除在 tuple 外
5 闸门只数"非 Skipped 断言" 基建断言(残留 / 基线)永远为真 ⇒ 闸恒过 断言分 Business / Infrastructure,闸门只认业务;Kind == 0 算非业务(§18.2)
6 一个对象挂多个分类标签,然后"按标签计数"(批 3a 真踩:activityChainVerdict 把命中的标签用 " + " 拼起来 ⇒ SA-2 / SC-2 同时是 intentional-strict 与 零写入守卫) 两份数各自都对,加起来不等于总数 —— 报出 12 + 2 + 1 + 2 = 17,而该批只有 15 条。没有任何一条断言会红,因为它错在记账不在判定 分类唯一化:Case.ActivityShape() 按优先级(legacy-defect > strict > 零写入 > equality)取第一个命中;统计只走 ActivityShapeTally,并把 Σ N == len(cases) 与"判定文案恰好命中 1 个标签"写成单测
7 需要读库的断言写进"通用断言块"(批 3c 真踩:精度口径那条要在原始 3 位精度上读一次种子行) 红的是断言自己,而 case 是绿的。 通用断言块跑在两侧各自 Rollback() 之后 ⇒ 种子行已随事务回滚,查回来 0 行、SUM(...) 返回 NULL(sql: Scan error: converting NULL to int64 is unsupported)。报告上是一条红,看着像"实现错了",实际是断言读了一个已经不存在的数据 需要读库的断言只能放在 per-side 快照(事务内)或 xxxCompare 阶段(它拿到的是 SideResult.Before/After 快照)。本批的落地方式:在 readArrears* 的 SELECT 里多读两列原始精度(ArrearsRow.RemainderRaw3/NetRaw3,不参与比对口径),断言搬到 arrearsCompare

第 7 条的现场(写在这儿免得下一个人再踩):第一版把精度断言放在 run.go 的通用断言块里, 报数是 legacy 非 2 位 0/0 行(err=...converting NULL to int64 is unsupported)—— 0/0 就是信号:它一行都没读到。当时 case 的 11 条业务断言全绿,只有这一条红。 判断"是断言坏了还是实现坏了"的捷径:看那个分母 —— 分母为 0 一定是断言自己的问题。

第 6 条和前面五条不是一类:前面五条让断言变松,第 6 条只让报出来的数字变错。 但后果一样严重 —— 数字是决策输入。12 + 2 + 1 + 2 = 17 那份统计如果被拿去写周报, "12 条在验一致性"会让人以为覆盖够了,而真实值是 10(见 §18.5)。 能自己加一遍的账,一定要让程序加。

18.5 活动域形态的真实分布(ActivityShapeTally 输出)

活动域形态:15 条:legacy-defect 2 | intentional-strict 2 | zero-write 1 | equality 10
课酬域形态:2 条:intentional-strict 1 | equality 1
跨域合计:域内 case 17(活动 15 + 课酬 2)| 资金域 35 | 合计 52

读法:15 条里只有 10 条在验"两侧一致",另有 4 条(legacy-defect 2 + strict 2) 本来就是"两侧该不同"的登记,1 条是零写入守卫。所以"15 条全绿"这句 不能翻译成"15 条都证明了实现正确" —— 有 5 条证明的是别的事。 这跟 §16 的 Skipped 分解是同一个道理:绿的总数不等于检出力。

一个域一行,不许合并:课酬域单列一行(2 条:intentional-strict 1 | equality 1), 不并进活动域 —— 合并会把"活动域 15 条"静默改成 17 条,而它本该是 15(9e2b0b7 的口径)。 两级恒等式都要成立:

级 不变量 本次实测
域内(活动) Σ 各形态数 = 该域 case 数 2 + 2 + 1 + 10 = 15 ✓
域内(课酬) 同上 1 + 1 = 2 ✓
跨域 域内 case 之和 + 资金域 case = 总 case 数 15 + 2 + 35 = 52 ✓

⚠ 上表是本次的读数,不是让人每次手算的公式。ActivityShapeTally 保证 Σ N == len(cases), 报告那一行已经把总数打在最前面(15 条:…),跨域那行也由程序算 —— 人只读,不加。

这一行现在由报告自动打印(Summarize → shapeLineOf,活动/课酬两个谓词各一次),不要手算。

18.4 代价必须可读:SQL 执行次数 + 耗时

报告尾部固定打一行:

代价:SQL 执行 1870 次 | 耗时 12.377s | 平均 53.4 次/case | 平均 354ms/case

(这是 35 条 case 的实测值,不是估的。加活动域快照之前没有计数,所以没有对照 —— 这一行从此就是对照基线,以后每次报数都贴它。)

为什么要有它:活动域快照让每个带活动种子的 case 多打 12 次库(六表 × 两侧)。没有这个数, "慢了"只能等跑不动才发现,而那时已经分不清两种东西:

情况 处置
读得对、只是慢 可以晚点优化(先让"读得对"成立)
读错了、靠重试/多次读掩盖 必须立刻修,优化只会把错误藏得更深

实现挂在 gorm 的 logger.Trace 上而不是 callback:Trace 是每条 SQL 的必经之路 (Query / Row / Exec / Raw 全走它),挂在上面不会漏;按 callback 注册要漏一个就少算一类, 而"少算"在报告上表现为"次数偏低",读的人只会以为"没有额外开销"。

19. 同一列上的两套 id 空间(同一族的三个实例)

「两套 id / 命名空间在同一列上相遇」已经出现三次。它每次的症状都是 假红看起来像实现错,或假绿看起来像一致 —— 所以单列一节。

# 列 空间 A 空间 B 后果
1 pay 支付方式 支付状态 同名三义,读错就断言错
2 t_courses_activity_user_discount_log.courses_student_no 迁移搬入的旧 UUID 新写入落 courses_student_id(数字),此列禁止新写入 直接比 courses_student_no 在迁移数据上对、在新写入上恒 NULL
3 t_courses_activity_used_log.order_no 历史 8185 行全是 32 位十六进制 UUID(实测:纯数字 0 行) target 新写入是 orderNoOf(StudentCourseID) = 数字字符串 迁移来的历史额度通过新 API 永远冲回不了(台账 F18)

19.1 双跑里的处置:归一 + 注明这是测试归一

// legacy   used_log.orderid  = 旧 UUID(c.StuCourseID)
// target   used_log.order_no = 数字 id 字符串(fmt.Sprint(c.StuCourseNumericID))
// 两侧永远不可能相等 ⇒ 比对时都归一成 "<order>"(orderToken)

⚠ 必须在报告与 doc 里写清:这是"测试口径归一",不代表生产上两列形态一致。 否则将来有人看到"两侧一致",会以为数据形态也一致 —— 而实际上第 3 例是一个功能缺口(F18), 不是测试细节:RefundRechargeGift 的游标是 WHERE orderid = ?,而 target 的入参是 int64, 前端只能传数字 id ⇒ 历史 8185 条额度没有 API 能冲回。

19.2 检查方法(三步,照做就不会漏)

  1. 看列注释里的"写入:谁"。courses_student_no 写着「写入:迁移搬入|红线:禁止新写入」—— 这就等于告诉你"新老数据在这一列上不同形态"。读注释比读代码快,也比读直觉准。
  2. 数一下真实数据的形态分布:

    SELECT SUM(orderid REGEXP '^[0-9]+$') AS 纯数字,      -- 0
          SUM(orderid REGEXP '^[0-9a-f]{32}$') AS 十六进制, -- 8185
          SUM(orderid IS NULL OR orderid='') AS 空
     FROM finance_bk.t_courses_activity_used_log;
    

    全落在一个桶里 ⇒ 单形态;落在两个桶里 ⇒ 这一列上有两套空间。

  3. 问一句"新写入走哪条路":如果写入方拿的是 int64 而列里存的是 UUID, 那两套必然相遇,且新老数据互相看不见。

19.3 另一条同族的坑:种子必须"先造出前一个状态"

refund_activity_recharge 的游标是 WHERE orderid=? AND activity_money_used<0 ORDER BY id DESC。 不先种一条负值 used_log,冲回根本进不去循环 ⇒ case 会退化成"两侧都 no-op"的空过 —— 而它照样全绿。这和 C2 的 Baseline 是同一个道理:要测"后一个动作",必须先把"前一个状态"造出来, 并且显式断言那个状态确实被造出来了(否则空过的形态和正常形态无法区分)。

20.1 「非空自检」判据变更(commit c5e3f5b)

旧写法 len(lNew) > 0 || Op == OpSignedAdd || Op == OpSignedCancel 只看 legacy 一侧; 新写法按 Op 真写的那张表数,且两侧都要有(lCnt > 0 && tCnt > 0)。

旧写法有两个方向的洞:

  1. 只看 legacy ⇒ target 一条没写也能绿 —— 那正是"target 漏写流水"这种最该抓的错;
  2. 表选错 ⇒ user_discount_* 写的是额度流水(_discount_log),不是用量流水(used_log), 按用量流水数会假红,而假红会把人引去改实现。

影响面为零的依据:W1(signed_add / signed_cancel)不写任何流水,走的是"恒真/不适用"分支、 等价旧行为;W2 两侧都写 used_log,两版判据同结论。45 条全绿已证这一点, 47 条(加 DR-1/DR-2)复跑仍全绿。

这类"自检本身假红"比"断言漏了"更危险:断言写错方向会把真实现判成 bug, 看到红的人第一反应是去改实现。所以判据变更必须显式记录,不能留在"顺手改"里。

§21 3c 前置探测:t_courses_class(只读,未写任何 case)

sp_courses_student_counteract_arrears_course(W4)的游标要 JOIN t_courses_class 按 t2.start_time ASC 排序,而这张表我们没有种子能力。所以 3c 动手前先只读探了 5 项。 下面每条都是 cmd/spdump 的实测输出,不是按命名猜的。

21.1 两侧 DDL:18 列 vs 19 列,语义同构、字面几乎全不同构

# legacy legacy_oracle target yuansiwei_2026 差异性质
1 id int PK auto_increment id bigint PK auto_increment 宽度不同
2 status int NOT NULL DEFAULT 1 同 —
3 coursesid int NOT NULL courses_id bigint NOT NULL 改命名 + 宽
4 classid int NOT NULL class_id bigint NOT NULL 改命名 + 宽
5 teacherid int NOT NULL DEFAULT 0 teacher_id varchar(26) NULL int → ULID 且 NOT NULL → NULL
6 schoolid int unsigned NOT NULL DEFAULT 0 institution_id bigint NOT NULL DEFAULT 0 改名(学校 → 机构)
7 campusid int unsigned DEFAULT 0 campus_id bigint DEFAULT 0 改名
8 standard_lesson_cost float(10,3) NULL decimal(12,2) NOT NULL DEFAULT 0.00 3 位 → 2 位;可空 → 非空
9 number int DEFAULT 0 同 —
10 lesson_cost float(10,3) NULL decimal(12,2) NOT NULL DEFAULT 0.00 同 #8
11 discount_cash float(10,3) NOT NULL DEFAULT 0 decimal(12,2) NOT NULL DEFAULT 0.00 3 位 → 2 位
12 finished_number int DEFAULT 0 同 —
13 confirm tinyint DEFAULT 0 同 —
14 week varchar(50) NOT NULL DEFAULT '-1' varchar(50) NOT NULL DEFAULT '-1' —
15 start_time datetime NOT NULL start_time datetime(3) NULL 精度 3 位;NOT NULL → NULL
16 end_time datetime NOT NULL end_time datetime(3) NULL 同 #15
17 address text NULL longtext NULL —
18 remark text NULL longtext NULL —
19 — created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) target 独有

结论:字面上"几乎全不同构"(9 处差异),但语义上同构 —— 列集合是 1:1 对应的 (schoolid→institution_id 是唯一需要判断的映射,t_courses_student 的 target 查询 WHERE t1.institution_id = ? AND t1.campus_id = ? 已经证实这条映射)。 没有任何一列在两侧含义不同,所以种子可以逐列对照地写。

没有的东西同样重要(都实测过):两侧 t_courses_class / t_courses_student 外键 0 个、触发器 0 个;唯一键只有 PK,target 另有 t_courses_student.uk_biz_no。 ⇒ 插入不会被参照完整性挡住,start_time 也不会被 ON UPDATE 之类偷改。

21.2 最小插入集(只数"NOT NULL 且无默认值",auto_increment 除外)

表 legacy target
t_courses_class 4:coursesid, classid, start_time, end_time 2:courses_id, class_id(start_time 可空)
t_courses_student 10:id, coursesid, studentid, receivable, net_receipts, unpaid, lesson_cost, discount_cash, remainder_cost, remainder_receivable 3:biz_no, courses_id, student_id

target 少得多的原因很实在:所有金额列都收敛成 NOT NULL DEFAULT 0.00, 而 legacy 的金额列是"NOT NULL 无默认"(必须显式给)。

但"能插进去"不等于"能测" —— 本 case 还要显式控制的列(两侧都要): start_time(排序键)、remainder_receivable + net_receipts(被改的列)、 confirm_discount_userid / confirm_discount_user_id(过滤键)、 legacy schoolid / target institution_id + campus_id(游标的三个 WHERE 之一)。 ⇒ 实际每条 case 的种子列数:class 5 / 3,student 13 / 8。

一个省掉整类麻烦的事实:游标 JOIN 用的是 (coursesid, classid) 这一对, 不是 t_courses_class.id ⇒ 两侧 class 行的自增 id 不需要对齐, §19 那种"同一个 id 两套空间"的问题在这条链上不存在。

21.3 现成行能复用吗:不能,两个独立理由(任一条都够)

理由一(结构性):target 库 t_courses_class 0 行(COUNT(*) = 0,实测)。 target 侧必须构造,而两侧要按同一锚比对(biz_no)⇒ 只能两侧同构构造。

理由二(更致命,且是实测出来的):legacy 侧候选行大量在 start_time 上并列。

  • 满足游标过滤(remainder_receivable>0 AND confirm_discount_userid>0)的行:24133 行,分属 1919 个 (studentid, schoolid, campusid)。
  • 按 (学生, start_time) 分组:5535 组,其中 4846 组有 >1 行 = 87.5%。
  • 实测学生 398(schoolid=63324 campus=15)的游标集合里就有 8 行挤在 2020-02-18 14:30:00; 学生 45718 的前 4 行全是 2018-10-11 17:40:00(coursesid 673/671/672/669)。

而两侧的排序不对称:

legacy  ORDER BY t2.start_time ASC                    (无 tie-break)
target  ORDER BY t2.first_start ASC, t1.id ASC        (有 tie-break,见 course_repo.go)

⇒ 真实的并列行会让 legacy 侧顺序由执行计划决定,重复跑都可能不同。 拿真实行做双跑,"先核哪一行"这个被验对象本身就不可复现 —— 这种红/绿都不算数。

21.4 start_time 两侧可控性:都可控,但必须互不相同

legacy target
可空性 datetime NOT NULL,无默认 datetime(3) NULL,无默认
能否显式插值 能(必须给) 能(给了就用)
触发器/ON UPDATE 无 无
精度 秒 毫秒

两侧都受我们控制,但种子必须保证"同一学生下所有 class 行的 start_time 两两不同" —— 不是为了排序好看,是因为 §21.3 那条 tie-break 不对称:一旦并列,两侧的循环顺序就可能不同, 而循环顺序决定钱落到哪一行。精度取整秒即可(两侧都按 %Y-%m-%d %H:%i:%s 读回)。

21.5 反例行可行性:一个可行、一个是假测

游标有 4 个过滤条件,逐个看"去掉它能不能被观测到":

过滤条件 真实库存量 构造可行性 去掉后是否可观测
remainder_receivable > 0 17917 行 =0 易 不可观测:0 元 UPDATE 是恒等变换(net +0 / rem −0),且 inputMoney 不减 ⇒ 终态一模一样。Go 侧还专门有 if !amount.GreaterThan(0) { continue } 兜这个。
confirm_discount_userid > 0 15 行 =0,其中 remainder>0 的 0 行 易(两侧都能造) 可观测:该行若被纳入,会吃掉本该给别的行的钱 ⇒ net_receipts 落错行。
INNER JOIN t_courses_class 孤儿行 0 行(LEFT JOIN 反查实测) 易(种一条没有 class 的报课行) 可观测:被 JOIN 排掉的行拿不到钱。
studentid/schoolid/campusid — 易(其余行放别的 school/campus) 可观测:作用域串了就是别的学生的钱被动。

⇒ confirm_discount_userid=0 与 remainder_receivable=0 能在同一学生下同时造出来(答案:能), 但只有前者值得写成断言。后者单独写一条 case 是恒真断言—— 和 §18.3 第 0 条同一族:断言恒成立 = 没测。

而且要能被观测,反例行必须放在最早 start_time 上:先被过滤掉、钱才能落到正确行; 如果反例行排在后面,正确行先吃饱了,过滤条件写错也看不出来。

21.6 顺带挖出的三件事(都不在原 5 问里,但影响 3c 怎么写)

  1. (coursesid, classid) 没有唯一索引,但实际 0 重复(6173 行实测 COUNT=0 组)。 ⇒ "同一 (courses_id, class_id) 多排班"这种 1:N 形态只在构造时出现,真实数据里不存在。
  2. target 侧已经把 1:N 折平了,且是既有裁决:course_repo.go 用 MIN(start_time) GROUP BY (courses_id, class_id) + t1.id ASC,注释写明理由是 "旧 SP 游标会重复核销同一报课行(dump 缺陷)"。⇒ 这天然是一条 INTENTIONAL-STRICT, 但终态到底是什么必须真跑:第二趟 remainder_receivable - x 在 legacy 是 decimal(10,3) unsigned,减到负数可能直接报 1690 越界、也可能重复累加 —— 读代码只能给假设,不能给期望值。
  3. ArrearsRow 的"有序"目前只在 legacy 侧有意义:target 的 Remaining(未用完额) 是 Go 侧新增的出参,legacy SP 不返回它;但 inputMoney − ΣΔnet_receipts 两侧都算得出来 ⇒ 这条可以做成"逐侧不变量",不需要 target 独有字段。

21.7 3c 走哪一档:写 2 条

判据(按 §21.1~21.5 实测):

  • 两侧语义同构、插入集小(5/3 列)、无 FK/无唯一键/无触发器 ⇒ 种子可控;
  • start_time 可控且能保证唯一 ⇒ 顺序可复现;
  • 有一条 equality(多行按课次先后核销、金额不够时截断)和一条 INTENTIONAL-STRICT (1:N 折平)都成立 ⇒ 够写 2 条。

不写第 3 条(remainder_receivable=0 的单开 case),理由见 §21.5:它是恒真断言。 反例行改为塞进 RA-1 的最早期次,这样"过滤条件写错"会直接表现为"钱落错行"。

一条纪律留在这儿:3b 的两块基建断言(推荐人账户 / 课酬应收)至今在运行输出里 grep = 0。 实测原因是没有一条 case 声明 RecommendUserID>0 或 StudentBizNos,两处 mustCover 的 入口条件都不成立、整块代码路径没被走到。这不是"不需要",是"还没通电"。 ⇒ 3c 的验收硬指标:跑完 grep "本块比对了" ≥ 1(只有课酬应收块;推荐人块要到 3d 的 W4 才会激活), 且断言里的 N 要能与 RA-1/RA-2 的声明条数对上(含排班扇出)。

21.8 3c 落地:RA-1 / RA-2(50 → 52)

验收状态(这一段是给下一个人看的)

块 状态
课酬应收(SideSnapshot.Arrears) 已激活、已验收:grep "本块比对了" = 2 行,N 分别为 3 / 3
推荐人账户(SideSnapshot.Recommender) 仍未激活,仍未验收(RA-1/RA-2 不声明 RecommendUserID,那是 3d 的 W4)

上一句必须留着。否则下一轮看到"3b 已验收"会以为两块都验过了 —— 这正是 3b 当时只能记"已就位"的原因,不要在 3c 上重演。

RA-2 的真实终态(实测,不是从代码推的)

(course=990004, class=990014) 两条排班、剩余应收 60.00,第二行另有 60.00,入参 200.00:

legacy  第 1 趟:ac15-ra2-0  net +6000 / rem −6000 → rem 0
        第 2 趟:同一行再 `rem = rem − 6000` ⇒ rem = −60
                列是 decimal(10,3) **unsigned** ⇒
                Error 1264 (22003) Out of range value for column 'remainder_receivable'
                ⇒ **整个 CALL 中断**,ac15-ra2-1 一分钱没拿到
        终态:net +6000(**部分写入**:报错前已落库)/ ra2-1 完全未动
target  MIN(start_time) 折平 ⇒ 60 + 60 = 120.00,两行都核完,不报错

三条结论:

  1. legacy 的后果比"重复核销"重得多:它是一行崩掉整个循环,后面的报课行全部漏核。 所以这条 case 的判定标签是 INTENTIONAL-STRICT —— 我们不是"更严",是避免了一次会让用户少拿 140.00 的中断。
  2. 必须按"部分写入"写断言:报错发生在第一趟 UPDATE 之后,所以不能写成"legacy 没动"。 本 case 的形态是「legacy 报 1264、终态 = 第一趟写入后的状态」,并且逐行钉住 ra2-1 = 0。
  3. 可复现:同配置连跑两次,终态逐字段一致(+6000 / 0)。不一致就该删掉这条 case。

这一批新增的两个"防自伤"点

  • F22 的正向断言:「本 case 的钱真的动了(两侧 ΣΔnet_receipts > 0 且 ΣΔremainder < 0), 全 0 ⇒ FAIL」。它挡的是 mustCover 挡不住的那条路:快照读的是 WHERE biz_no IN (...)(无机构过滤),而候选集由 BaseRepo.Scoped 追加 institution_id 过滤 —— 种子机构与 case 不一致时,快照照读到 3 行、mustCover 照通过,而两侧都 no-op。 ⇒ "什么都没发生"必须是 FAIL,不能是 PASS。
  • PRECISION-CONVERGENCE 的业务断言:种子两列的第 3 位小数恒为 0。 它把"2 位比较无损失"从假设变成断言(快照里看不到第 3 位,不读原始精度就无法验证)。 文案限定"本 case 的种子":对真实数据不是无损的(net_receipts 2687 行 / remainder_receivable 17 行第 3 位非零,并集 2694 行 = 6.4%)。 实现上多读两列(ArrearsRow.RemainderRaw3 / NetRaw3),不参与比对口径。

F22 台账(结构性缺口,后续批次逐支补)

改 Scoped 等于改 SP 语义。 counteract_arrears_course 的候选集过滤原本写在 dump 的 WHERE 里(studentid/schoolid/campusid),新实现里 institution_id/campus_id 那两条 由 repository/base.go:90 的 Scoped 提供 —— 它是所有仓储共用的基类代码。 语义等价(institution_id ↔ schoolid 迁移 1:1),但执行位置变了:哪天有人为别的端点 放宽/收紧 Scoped,这支 SP 的候选集跟着变,而双跑看不出来(种子机构恰好等于 case 机构时, 两种口径吃得下同一批行)。

⇒ 凡候选集由 repo 层过滤提供的 SP,双跑都缺一层"候选集非空/钱真的动了"的正向断言。 这是结构性缺口,不是 RA 两支的局部问题。