|
|
5 godzin temu | |
|---|---|---|
| .. | ||
| README.md | 5 godzin temu | |
| c1_activity.go | 6 godzin temu | |
| c1_compare.go | 5 godzin temu | |
| case.go | 5 godzin temu | |
| config.go | 6 godzin temu | |
| legacy.go | 5 godzin temu | |
| run.go | 5 godzin temu | |
| stage2_test.go | 6 godzin temu | |
| target.go | 5 godzin temu | |
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。
派单里写「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(名字子串匹配)
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) 把每侧应有的行为显式写出来,再拿实测去对 —— 偏离任一侧的预期都会红。
批 B 最容易出的错不是"漏写一列",而是凭列名推断行为。dump 里这三支的真实行为:
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 只写全局"是同一个错误的两种形态。
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 断言"。
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 专门打前者。
spend 与 transfer 都有一个"我不关心"的列 —— balance。
如果把这一列从比对表里删掉,实现里多扣一笔 balance 也不会有任何红:
因为"没有比较"和"比较结果是 0"在报告里长一样。
所以这里写的是显式断言(Δbalance == 0,六个数字逐分核),变异 M1 实测能抓住。
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 的校区行,就绕过了要验证的守卫。
| 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 |
| 项 | 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 次"钉死。
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 条已提交的流水)。
| # | 改错什么 | 靶子断言 | 实测输出 |
|---|---|---|---|
| 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 独有的列,旧库无从对照,但它自己必须与"调用后的余额"自洽 ——
这条自洽性检查在这次变异里起到了冗余防线的作用。
这两支的资金部分很简单,「跨域 + 活动状态」部分才是重点。
| 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(取消报名) |
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 的 Δ 才第一次真正区分开。
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。同一个文案可能被别处复用,码是设计文档里定死的契约。
| 侧 | 读的是 | 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
配套两条纪律:
Case.SelfSupport 由 case 显式声明,而不是"从哪一侧读出来喂给另一侧";seedTarget 的 t_campus.self_support 不许硬编码。原写法就是硬编码 1,
后果是"加盟校区"这条分支永远走不到 —— 变异 M2 打过去立刻红
(92:2→4 多失效了一条 campus_id=0 的全机构池活动)。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 只测了一遍。
活动域还没迁移,所以旧库里真实存在的可失效报名,target 侧没有。 若不清零,legacy 会多失效几条而 target 不失效,两侧失效集合天然不同 —— 那种红是"数据没搬全",不是"SP 行为不对"。所以每个清零 case 开头先断言:
✔ case 有效性前提:旧库里该用户**没有别的**可失效报名(否则两侧失效范围天然不同)
本 case 之外可失效报名行=0
选人就是这么选出来的:68542 / 40583 在旧库都恰好没有 type=2 的可失效报名
(反例:17735 有 8 条、31477 有 3 条,都不能用来跑清零)。
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,**不抛错**
两件事因此变了:
Exec 调(结果集必须读干净,否则连接上留下未消费的结果集)→ returnsResultSet(op) 分流;"成功返回但业务失败"(success=0)只能靠读返回值发现 → CallOutcome.Returned 按列名保存。
✔ SP8 返回值:success=1 / msg=ok / schoolid 由校区反查得到
✔ SP8 返回值:找不到校区时 **success=0 + msg='找不到学校信息'**(不抛错,靠返回值表达失败)
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 看起来在跑、其实什么都没测。
变异分两侧打,结论不一样,所以要分开记: 实现侧变异证明"代码写错会被抓住",判据侧变异证明"判据本身不是摆设" (判据侧若改了还不红,说明那条断言是空的 —— 它绿不绿跟被测代码无关)。
| # | 改错什么 | 靶子 | 实测 |
|---|---|---|---|
| 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。绿了不等于测到了,这一条是本批最大的教训。
| # | 改错什么(改判据,不改实现) | 预期红 | 实际红 | 一致? |
|---|---|---|---|---|
| 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 特别值得记:它同时红了两条,而且第一条直接指出原因(元数据不一致), 第二条才是指标(失效范围错)。"断言拆得细"不是为了严格,是为了出错时能定位。
Baseline 有两种用途,别混Case.Baseline 原来只有一个用途(把余额压低验证 R15)。批 C1 又用它造
"全局 > 校区"的基线。于是 if c.Baseline != nil { 断言余额为负 } 这条假红了 ——
C1-1 的余额根本不该为负。
改成显式声明:Case.ExpectNegativeBalance,只有 R2 设它。
"用到了某个字段"不等于"这个字段的原意成立" —— 判据要写在 case 上,不要从别的字段反推。
前 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。
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"显式记下来。 读不出来的状态,等于没有这个状态。