Q dfb32b7153 feat(ac15): 写 SP 双跑第一支(消费)——两侧真写库 + 双回滚 + 零残留 9 ساعت پیش
..
README.md dfb32b7153 feat(ac15): 写 SP 双跑第一支(消费)——两侧真写库 + 双回滚 + 零残留 9 ساعت پیش
case.go dfb32b7153 feat(ac15): 写 SP 双跑第一支(消费)——两侧真写库 + 双回滚 + 零残留 9 ساعت پیش
config.go dfb32b7153 feat(ac15): 写 SP 双跑第一支(消费)——两侧真写库 + 双回滚 + 零残留 9 ساعت پیش
legacy.go dfb32b7153 feat(ac15): 写 SP 双跑第一支(消费)——两侧真写库 + 双回滚 + 零残留 9 ساعت پیش
run.go dfb32b7153 feat(ac15): 写 SP 双跑第一支(消费)——两侧真写库 + 双回滚 + 零残留 9 ساعت پیش
stage2_test.go dfb32b7153 feat(ac15): 写 SP 双跑第一支(消费)——两侧真写库 + 双回滚 + 零残留 9 ساعت پیش
target.go dfb32b7153 feat(ac15): 写 SP 双跑第一支(消费)——两侧真写库 + 双回滚 + 零残留 9 ساعت پیش

README.md

internal/academic/dualrun/stage2 —— 写 SP 双跑(第一支:消费)

sp_user_expense_school_expenditure ↔ service.FundService.Expenditure。

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

构造 case → legacy 侧 CALL SP(事务内)→ target 侧跑 Go service(事务内)
→ 各自读回写入后的状态 → 逐项比对 → 双方回滚 → 断言零残留
go run ./cmd/dualrun stage2              # 跑全部 3 个 case
go run ./cmd/dualrun stage2 --case C1    # 只跑某个 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) 把每侧应有的行为显式写出来,再拿实测去对 —— 偏离任一侧的预期都会红。

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/断言,C2 用实测把"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. 变异实验(3 组,都已还原)

# 改错什么 结果
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 条已提交的流水)。