Q ce1a5de666 docs(ac15): 批B 变异实验 M1/M2/M3 实测记录(spend扣balance / 206同向 / baddebt单增) hace 8 horas
..
README.md ce1a5de666 docs(ac15): 批B 变异实验 M1/M2/M3 实测记录(spend扣balance / 206同向 / baddebt单增) hace 8 horas
case.go 318210ab27 feat(ac15): 批B 三支写 SP 双跑(坏账双增 / 累计消费只动spend / 课费转结双流水)+ A7 R19 hace 8 horas
config.go dfb32b7153 feat(ac15): 写 SP 双跑第一支(消费)——两侧真写库 + 双回滚 + 零残留 hace 8 horas
legacy.go 318210ab27 feat(ac15): 批B 三支写 SP 双跑(坏账双增 / 累计消费只动spend / 课费转结双流水)+ A7 R19 hace 8 horas
run.go 318210ab27 feat(ac15): 批B 三支写 SP 双跑(坏账双增 / 累计消费只动spend / 课费转结双流水)+ A7 R19 hace 8 horas
stage2_test.go 318210ab27 feat(ac15): 批B 三支写 SP 双跑(坏账双增 / 累计消费只动spend / 课费转结双流水)+ A7 R19 hace 8 horas
target.go 318210ab27 feat(ac15): 批B 三支写 SP 双跑(坏账双增 / 累计消费只动spend / 课费转结双流水)+ A7 R19 hace 8 horas

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)

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              # 跑全部 13 个 case(A1–A7 / B1–B4 / 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 全部 13 个 case 一览

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 独有的列,旧库无从对照,但它自己必须与"调用后的余额"自洽 —— 这条自洽性检查在这次变异里起到了冗余防线的作用。