|
|
7 小時之前 | |
|---|---|---|
| .. | ||
| README.md | 7 小時之前 | |
| case.go | 7 小時之前 | |
| config.go | 8 小時之前 | |
| legacy.go | 7 小時之前 | |
| run.go | 7 小時之前 | |
| stage2_test.go | 7 小時之前 | |
| target.go | 7 小時之前 | |
internal/academic/dualrun/stage2 —— 写 SP 双跑| 批 | SP | service 方法 | 列增量(照 dump) |
|---|---|---|---|
| 一 | sp_user_expense_school_expenditure |
Expenditure |
balance− / total_expenditure+(type=200 另加 spend+) |
| A | sp_user_expense_recharge |
Recharge(Kind=Default) |
balance+ / total_income+,双表 |
| A | sp_user_expense_school_recharge |
Recharge(Kind=School) |
balance+ / total_income+,双表 |
| A | sp_user_expense_school_refund |
Refund |
balance+ / total_expenditure−,双表 |
派单里写「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 # 跑全部 3 个 case
go run ./cmd/dualrun stage2 --case C1 # 只跑某个 case(名字子串匹配)
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{...})
三条说明:
port.NewOrgMembershipChecker(tx) 读 t_membership,case 里真的插了一行
(user, 63317, 5, 'student', 'active')。没有 nil checker、没有 SuperAdmin=true、没有改 service 签名。Scope 不是"造一个能过的":它的 InstitutionID/CampusID 就是 case 的业务参数;如果 case 的机构/校区
与该用户的成员关系不匹配,assertInScope 会返回 1004 —— 那正是要验证的东西,不回避。OperatorID 也传 canonicalUID。真实场景里操作人是财务/教务,
但 assertInScope 校验的是被扣款用户的成员关系,与本 case 目标无关,所以仍用同一 ULID 以少插一行数据。| 侧 | 来源 | 口径 |
|---|---|---|
| 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 把十进制串转成分,所以"分"不会各算各的。
调用前有硬断言「两侧基线逐分相等」,不等直接红(那说明映射有问题,不是双跑问题)。
| 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) 把每侧应有的行为显式写出来,再拿实测去对 —— 偏离任一侧的预期都会红。
| 项 | 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 次"钉死。
tx.Begin() → 可选基线覆盖 → CALL → 读回 → Rollbacktx.Begin() → 种基线(org/人/成员关系/两份账户)→ service → 读回 → Rollbackt_user_expense / _log / t_membership 计数为 0)
t_user_expense_log.id是自增,回滚后不会回退;重跑时 id 会跳号。断言里一律不用固定 id。
| # | 改错什么 | 结果 |
|---|---|---|
| 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 条已提交的流水)。