|
@@ -88,7 +88,7 @@ func DefaultCases(_ string) []Case {
|
|
|
Op: OpTransfer, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 5,
|
|
Op: OpTransfer, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 5,
|
|
|
ToInstitutionID: 63317, ToCampusID: 1,
|
|
ToInstitutionID: 63317, ToCampusID: 1,
|
|
|
AmountCents: 5000, RequestID: "ac15-b3", RunTimes: 1,
|
|
AmountCents: 5000, RequestID: "ac15-b3", RunTimes: 1,
|
|
|
- Remark: "ac15 课费转结",
|
|
|
|
|
|
|
+ Remark: "ac15 课费转结",
|
|
|
ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
|
Note: "**不动 balance**;双流水 106(+50)/206(−50) Σ=0;两校区 total_expenditure/total_income 各自反向。" +
|
|
Note: "**不动 balance**;双流水 106(+50)/206(−50) Σ=0;两校区 total_expenditure/total_income 各自反向。" +
|
|
|
"SP6 **没有默认备注**(inputRemark 直接用),所以这里显式传一条,顺带验证备注透传",
|
|
"SP6 **没有默认备注**(inputRemark 直接用),所以这里显式传一条,顺带验证备注透传",
|
|
@@ -98,7 +98,7 @@ func DefaultCases(_ string) []Case {
|
|
|
Op: OpTransfer, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
|
|
Op: OpTransfer, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
|
|
|
ToInstitutionID: 63317, ToCampusID: 1,
|
|
ToInstitutionID: 63317, ToCampusID: 1,
|
|
|
AmountCents: 5000, RequestID: "ac15-b4", RunTimes: 2,
|
|
AmountCents: 5000, RequestID: "ac15-b4", RunTimes: 2,
|
|
|
- Remark: "ac15 课费转结·幂等",
|
|
|
|
|
|
|
+ Remark: "ac15 课费转结·幂等",
|
|
|
ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
|
Note: "106 是主流水,幂等按 (request_id, type=106) 判定",
|
|
Note: "106 是主流水,幂等按 (request_id, type=106) 判定",
|
|
|
},
|
|
},
|
|
@@ -110,11 +110,112 @@ func DefaultCases(_ string) []Case {
|
|
|
Op: OpRecharge, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 0,
|
|
Op: OpRecharge, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 0,
|
|
|
AmountCents: 10000, Type: 100, RequestID: "ac15-a7", RunTimes: 1,
|
|
AmountCents: 10000, Type: 100, RequestID: "ac15-a7", RunTimes: 1,
|
|
|
ExpectTargetSuccess: false, ExpectLegacySuccess: true,
|
|
ExpectTargetSuccess: false, ExpectLegacySuccess: true,
|
|
|
|
|
+ ExpectTargetCode: 3140,
|
|
|
IntentionalStrict: true,
|
|
IntentionalStrict: true,
|
|
|
Note: "R19:SP1 有 `schoolid<1||campusid<1 → LEAVE` 守卫,**静默不写、返回成功**;" +
|
|
Note: "R19:SP1 有 `schoolid<1||campusid<1 → LEAVE` 守卫,**静默不写、返回成功**;" +
|
|
|
"Go 走 validateUserScope 报 3140。两侧**本来就该不同**,本 case 只把差异钉死",
|
|
"Go 走 validateUserScope 报 3140。两侧**本来就该不同**,本 case 只把差异钉死",
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ // ---- 批 C1:余额清零 SP10(5 参,**无金额、无请求号**)----
|
|
|
|
|
+ //
|
|
|
|
|
+ // 选人依据(与批 A 同一条纪律:基线必须是 2 位小数,否则 float 收敛会让 Δ 天然不等):
|
|
|
|
|
+ // 68542 → 63317 / campus 5(**自营 self_support=1**),校区与全局余额都是 9740.00
|
|
|
|
|
+ // 40583 → 63317 / campus 4(**加盟 self_support=0**),校区与全局余额都是 5400.00
|
|
|
|
|
+ // 两人在旧库都**没有**别的可失效报名(preflight 会显式断言这一点)。
|
|
|
|
|
+ out = append(out,
|
|
|
|
|
+ Case{
|
|
|
|
|
+ Name: "C1-1 余额清零·自营校区(失效范围含 campus_id=0)",
|
|
|
|
|
+ Op: OpClearBalance, LegacyUserID: 68542, InstitutionID: 63317, CampusID: 5,
|
|
|
|
|
+ RequestID: "ac15-c1-1", RunTimes: 1, SelfSupport: true,
|
|
|
|
|
+ // 关键:**故意让全局余额 > 校区余额**(5000 vs 1200)。
|
|
|
|
|
+ // 如果两者相等(比如这个用户天然就是 9740/9740),那么"减 campusBalance"与
|
|
|
|
|
+ // "全局归 0"会得到同一个结果,断言就分不出对错 —— 变异 M1 实测过这一条。
|
|
|
|
|
+ Baseline: &Baseline{GlobalBalance: 500000, CampusBalance: 120000},
|
|
|
|
|
+ Activity: &ActivitySeed{OtherUsers: map[int64]int32{65055: 2}},
|
|
|
|
|
+ ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
|
|
|
+ Note: "**校区**余额清零(不是全局);special_expenses 与 spend 同额 +;流水 type=208 money=−campusBalance 且无 logid;" +
|
|
|
|
|
+ "自营 → 本校区 + 全机构池(campus_id=0) 的报名都置 4,其他校区/其他机构/代金券/已过期一条都不许动",
|
|
|
|
|
+ },
|
|
|
|
|
+ Case{
|
|
|
|
|
+ Name: "C1-2 余额清零·加盟校区(失效范围只含本校区)",
|
|
|
|
|
+ Op: OpClearBalance, LegacyUserID: 40583, InstitutionID: 63317, CampusID: 4,
|
|
|
|
|
+ RequestID: "ac15-c1-2", RunTimes: 1, SelfSupport: false,
|
|
|
|
|
+ Baseline: &Baseline{GlobalBalance: 300000, CampusBalance: 90000},
|
|
|
|
|
+ Activity: &ActivitySeed{},
|
|
|
|
|
+ ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
|
|
|
+ Note: "与 C1-1 唯一的差别是 self_support=0:**campus_id=0 的全机构池活动不能被失效**(SP 的 ELSE 分支)",
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ // 三条前置校验:legacy **静默 LEAVE**(不报错、不写库),Go **3120 拒绝** —— 又一组 intentional-strict。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 派单里写的是"余额已 0 时再清零 → 3120",那是 **Go 侧**的行为;
|
|
|
|
|
+ // SP 的写法是三条 `IF … THEN LEAVE leaveProcedure`,**静默 no-op**,调用方看不出失败。
|
|
|
|
|
+ out = append(out,
|
|
|
|
|
+ Case{
|
|
|
|
|
+ Name: "C1-3 清零前置校验·校区余额 ≤ 0(legacy 静默 vs Go 3120)",
|
|
|
|
|
+ Op: OpClearBalance, LegacyUserID: 40575, InstitutionID: 63317, CampusID: 4,
|
|
|
|
|
+ RequestID: "ac15-c1-3", RunTimes: 1, SelfSupport: false,
|
|
|
|
|
+ Baseline: &Baseline{GlobalBalance: 100000, CampusBalance: 0},
|
|
|
|
|
+ ExpectTargetSuccess: false, ExpectLegacySuccess: true,
|
|
|
|
|
+ ExpectTargetCode: 3120, IntentionalStrict: true,
|
|
|
|
|
+ Note: "SP10 第一条守卫 `ISNULL(campusBalance)||campusBalance<=0 → LEAVE`;" +
|
|
|
|
|
+ "「清零后再清零」踩的就是这条(清完校区余额正好是 0)",
|
|
|
|
|
+ },
|
|
|
|
|
+ Case{
|
|
|
|
|
+ Name: "C1-4 清零前置校验·全局余额 ≤ 0(legacy 静默 vs Go 3120)",
|
|
|
|
|
+ Op: OpClearBalance, LegacyUserID: 40575, InstitutionID: 63317, CampusID: 4,
|
|
|
|
|
+ RequestID: "ac15-c1-4", RunTimes: 1, SelfSupport: false,
|
|
|
|
|
+ Baseline: &Baseline{GlobalBalance: 0, CampusBalance: 10000},
|
|
|
|
|
+ ExpectTargetSuccess: false, ExpectLegacySuccess: true,
|
|
|
|
|
+ ExpectTargetCode: 3120, IntentionalStrict: true,
|
|
|
|
|
+ Note: "SP10 第二条守卫 `ISNULL(totalBalance)||totalBalance<=0 → LEAVE`",
|
|
|
|
|
+ },
|
|
|
|
|
+ Case{
|
|
|
|
|
+ Name: "C1-5 清零前置校验·校区余额 > 全局余额(legacy 静默 vs Go 3120)",
|
|
|
|
|
+ Op: OpClearBalance, LegacyUserID: 40575, InstitutionID: 63317, CampusID: 4,
|
|
|
|
|
+ RequestID: "ac15-c1-5", RunTimes: 1, SelfSupport: false,
|
|
|
|
|
+ Baseline: &Baseline{GlobalBalance: 50000, CampusBalance: 80000},
|
|
|
|
|
+ ExpectTargetSuccess: false, ExpectLegacySuccess: true,
|
|
|
|
|
+ ExpectTargetCode: 3120, IntentionalStrict: true,
|
|
|
|
|
+ Note: "SP10 第三条守卫 `campusBalance>totalBalance → LEAVE`(数据异常,拒绝对负余额清零)",
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 批 C1:取消充值活动 SP8(7 参,**无 schoolid**,自带结果集)----
|
|
|
|
|
+ out = append(out,
|
|
|
|
|
+ Case{
|
|
|
|
|
+ Name: "C1-6 取消充值活动(30.00,报名状态 2→0)",
|
|
|
|
|
+ Op: OpCancelRechargeActivity, LegacyUserID: 68542, InstitutionID: 63317, CampusID: 5,
|
|
|
|
|
+ AmountCents: 3000, RequestID: "ac15-c1-6", RunTimes: 1, SelfSupport: true,
|
|
|
|
|
+ Activity: &ActivitySeed{},
|
|
|
|
|
+ ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
|
|
|
+ Note: "充值的**反向**:balance− / total_income−(双表);流水 type=209 money=−30;" +
|
|
|
|
|
+ "报名状态归 **0(取消报名)**,不是 4(过期);机构 id 由 campusid 反查,SP 无 schoolid 入参",
|
|
|
|
|
+ },
|
|
|
|
|
+ Case{
|
|
|
|
|
+ Name: "C1-7 取消充值活动·幂等(同一 requestId 跑 2 次)",
|
|
|
|
|
+ Op: OpCancelRechargeActivity, LegacyUserID: 65055, InstitutionID: 63317, CampusID: 5,
|
|
|
|
|
+ AmountCents: 2000, RequestID: "ac15-c1-7", RunTimes: 2, SelfSupport: true,
|
|
|
|
|
+ Activity: &ActivitySeed{},
|
|
|
|
|
+ ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
|
|
|
+ Note: "target 按 (request_id, 209) 幂等;legacy SP 无幂等键 → 重复扣两次(已知且有意)",
|
|
|
|
|
+ },
|
|
|
|
|
+ Case{
|
|
|
|
|
+ Name: "C1-8 取消充值活动·校区不存在(SP 返回 success=0 vs Go 3104)",
|
|
|
|
|
+ Op: OpCancelRechargeActivity, LegacyUserID: 68542, InstitutionID: 63317, CampusID: 999999,
|
|
|
|
|
+ // 必须显式声明"这个校区不存在":否则 seedTarget 会顺手给它建一行 t_campus,
|
|
|
|
|
+ // Go 就"找到了校区"并成功 —— 一条看起来在测缺失、其实校区存在的空测试。
|
|
|
|
|
+ CampusMissing: true,
|
|
|
|
|
+ AmountCents: 3000, RequestID: "ac15-c1-8", RunTimes: 1, SelfSupport: true,
|
|
|
|
|
+ Activity: &ActivitySeed{},
|
|
|
|
|
+ ExpectTargetSuccess: false, ExpectLegacySuccess: true,
|
|
|
|
|
+ ExpectTargetCode: 3104, IntentionalStrict: true,
|
|
|
|
|
+ Note: "SP8 先按 campusid 反查 `yuansiwei.t_school_campus`:查不到 → `SELECT success,msg`(msg='找不到学校信息')+ LEAVE," +
|
|
|
|
|
+ "**不抛错**;Go 报 3104。这条同时验证「SP 的返回值本身是契约」",
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
// ---- 消费(第二批已做,留作回归)----
|
|
// ---- 消费(第二批已做,留作回归)----
|
|
|
base := Case{
|
|
base := Case{
|
|
|
Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
|
|
Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
|
|
@@ -132,6 +233,7 @@ func DefaultCases(_ string) []Case {
|
|
|
c2.Baseline = &Baseline{
|
|
c2.Baseline = &Baseline{
|
|
|
GlobalBalance: 1000, CampusBalance: 1000,
|
|
GlobalBalance: 1000, CampusBalance: 1000,
|
|
|
}
|
|
}
|
|
|
|
|
+ c2.ExpectNegativeBalance = true
|
|
|
out = append(out, c1, c2)
|
|
out = append(out, c1, c2)
|
|
|
return out
|
|
return out
|
|
|
}
|
|
}
|
|
@@ -150,9 +252,25 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
|
|
|
uid := CanonicalUIDFor(c.LegacyUserID)
|
|
uid := CanonicalUIDFor(c.LegacyUserID)
|
|
|
res := &CaseResult{Case: c}
|
|
res := &CaseResult{Case: c}
|
|
|
|
|
|
|
|
|
|
+ // 活动侧种子需要"两侧同 id 的起点":取 legacy 现有 max(id)+1。
|
|
|
|
|
+ // 必须在事务**之外**读一次(自增计数器不回滚,但行会回滚,所以水位稳定)。
|
|
|
|
|
+ if c.Activity != nil {
|
|
|
|
|
+ base, err := baseActivityID(ctx, legacyDB)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ c.ActivityPlan = C1ActivityPlan(base, c.Activity.OtherUsers)
|
|
|
|
|
+ // 取消充值的对象是矩阵里的**第一条**(本级校区、type=2、status=2)——
|
|
|
|
|
+ // 用 plan 里的 id 而不是写死,避免"活动 id 换了但 case 没跟上"。
|
|
|
|
|
+ if c.Op == OpCancelRechargeActivity {
|
|
|
|
|
+ c.ActivityID = c.ActivityPlan.Items[0].ID
|
|
|
|
|
+ }
|
|
|
|
|
+ res.Case = c // 报告里也要能看到实际用的活动 id
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 事务**之前**先读一次 legacy 的天然状态:回滚之后要拿它做对照。
|
|
// 事务**之前**先读一次 legacy 的天然状态:回滚之后要拿它做对照。
|
|
|
// (不能用 lg.Before —— R15 用例会在事务内把基线覆盖成 10.00,那不是"调用前"的真实状态。)
|
|
// (不能用 lg.Before —— R15 用例会在事务内把基线覆盖成 10.00,那不是"调用前"的真实状态。)
|
|
|
- natural, err := readLegacyNatural(ctx, cfg, c.LegacyUserID)
|
|
|
|
|
|
|
+ natural, naturalLogs, err := readLegacyNatural(ctx, cfg, c.LegacyUserID)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return nil, err
|
|
return nil, err
|
|
|
}
|
|
}
|
|
@@ -174,8 +292,28 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
|
|
|
// ---- 比对 ----
|
|
// ---- 比对 ----
|
|
|
res.Diffs, res.Assertions = compare(c, lg, tg)
|
|
res.Diffs, res.Assertions = compare(c, lg, tg)
|
|
|
|
|
|
|
|
|
|
+ // ---- case 有效性前提(活动域)----
|
|
|
|
|
+ //
|
|
|
|
|
+ // 旧库里如果还有"本 case 之外、同样可被失效"的报名行,legacy 会多失效几条,
|
|
|
|
|
+ // 而 target 侧没有那些活动(活动域还没迁移)—— 两侧失效集合天然不同。
|
|
|
|
|
+ // 那种差异是"数据没搬全",不是"SP 行为不对"。与其让它以一个看不懂的差异出现,
|
|
|
|
|
+ // 不如在 case 开始就断言它是 0(和"两侧基线逐分相等"同一性质的前置断言)。
|
|
|
|
|
+ if c.ActivityPlan != nil && c.Op == OpClearBalance {
|
|
|
|
|
+ n, err := countOtherExpirableLegacy(ctx, legacyDB, c.LegacyUserID,
|
|
|
|
|
+ c.InstitutionID, c.CampusID, c.SelfSupport, c.ActivityPlan.ActivityIDs())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ res.Assertions = append(res.Assertions, Assertion{
|
|
|
|
|
+ Name: "case 有效性前提:旧库里该用户**没有别的**可失效报名(否则两侧失效范围天然不同)",
|
|
|
|
|
+ Pass: n == 0,
|
|
|
|
|
+ Detail: fmt.Sprintf("本 case 之外可失效报名行=%d(应为 0;机构=%d 校区=%d 自营=%v)",
|
|
|
|
|
+ n, c.InstitutionID, c.CampusID, c.SelfSupport),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// ---- 残留检查(两边都回滚之后)----
|
|
// ---- 残留检查(两边都回滚之后)----
|
|
|
- res.Residue = checkResidue(ctx, cfg, c, uid, natural)
|
|
|
|
|
|
|
+ res.Residue = checkResidue(ctx, cfg, c, uid, natural, naturalLogs)
|
|
|
if !res.Residue.Pass {
|
|
if !res.Residue.Pass {
|
|
|
res.Assertions = append(res.Assertions, Assertion{
|
|
res.Assertions = append(res.Assertions, Assertion{
|
|
|
Name: "回滚后零残留(两侧都回到调用前状态)", Pass: false, Detail: res.Residue.Detail,
|
|
Name: "回滚后零残留(两侧都回到调用前状态)", Pass: false, Detail: res.Residue.Detail,
|
|
@@ -188,6 +326,23 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
|
|
|
return res, nil
|
|
return res, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// needsCampusMeta 标记"该 Op 会跨域读校区元数据"(清零判自营 / 取消活动反查机构)。
|
|
|
|
|
+func needsCampusMeta(op Op) bool { return op == OpClearBalance || op == OpCancelRechargeActivity }
|
|
|
|
|
+
|
|
|
|
|
+// opUsesRequestID 标记"该 Op 对应的 SP 有请求号入参"。
|
|
|
|
|
+//
|
|
|
|
|
+// SP10(清零)的签名是 (userid, schoolid, campusid, remark, createUserid) —— **没有请求号**,
|
|
|
|
|
+// 所以它天然不幂等。断言"target 的 request_id 为空"才是对的;断言它等于某个入参反而会遮蔽错误。
|
|
|
|
|
+func opUsesRequestID(op Op) bool { return op != OpClearBalance }
|
|
|
|
|
+
|
|
|
|
|
+// reqNote 说明该 Op 下 request_id 的期望来源。
|
|
|
|
|
+func reqNote(op Op) string {
|
|
|
|
|
+ if !opUsesRequestID(op) {
|
|
|
|
|
+ return "SP10 无请求号入参 → target request_id 必须为空(不幂等是 SP 签名决定的)"
|
|
|
|
|
+ }
|
|
|
|
|
+ return "Go 增强:幂等键落库"
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// expectedDelta 返回一侧在某 case 下的**期望增量**。
|
|
// expectedDelta 返回一侧在某 case 下的**期望增量**。
|
|
|
//
|
|
//
|
|
|
// 为什么要有"期望"而不只是"两侧相等":
|
|
// 为什么要有"期望"而不只是"两侧相等":
|
|
@@ -215,34 +370,50 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
|
|
diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ⓪ intentional-strict(R19):两侧**本来就该不同**,不参与"两侧相等"判定
|
|
|
|
|
|
|
+ // ⓪ intentional-strict(R19 / 3120 / 3104):两侧**本来就该不同**,不参与"两侧相等"判定
|
|
|
if c.IntentionalStrict {
|
|
if c.IntentionalStrict {
|
|
|
lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
|
|
lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
|
|
|
tgErr := errOf(tg.Calls)
|
|
tgErr := errOf(tg.Calls)
|
|
|
|
|
+ tgCode := codeOf(tg.Calls)
|
|
|
tgRejected := len(tg.Calls) > 0 && !tg.Calls[0].Success
|
|
tgRejected := len(tg.Calls) > 0 && !tg.Calls[0].Success
|
|
|
|
|
+ // 码比文案强:同一个业务码可能在多处被复用文案,码是设计文档定死的契约。
|
|
|
|
|
+ codeOK := c.ExpectTargetCode == 0 || tgCode == c.ExpectTargetCode
|
|
|
// 「没写库」必须两侧都比**全局+校区**两块:campusid=0 的用例里
|
|
// 「没写库」必须两侧都比**全局+校区**两块:campusid=0 的用例里
|
|
|
// 如果只比全局,误写成"只写校区表"的实现会静默漏过去。
|
|
// 如果只比全局,误写成"只写校区表"的实现会静默漏过去。
|
|
|
lgWrote := lg.Before.Global != lg.After.Global || lg.Before.Campus != lg.After.Campus
|
|
lgWrote := lg.Before.Global != lg.After.Global || lg.Before.Campus != lg.After.Campus
|
|
|
tgWrote := tg.Before.Global != tg.After.Global || tg.Before.Campus != tg.After.Campus
|
|
tgWrote := tg.Before.Global != tg.After.Global || tg.Before.Campus != tg.After.Campus
|
|
|
noWrite := !lgWrote && !tgWrote && len(lg.After.Logs) == 0 && len(tg.After.Logs) == 0
|
|
noWrite := !lgWrote && !tgWrote && len(lg.After.Logs) == 0 && len(tg.After.Logs) == 0
|
|
|
|
|
+ // 活动状态也不能动(清零的守卫分支在动活动之前就 LEAVE 了)
|
|
|
|
|
+ signedSame := sameSigned(lg.Before.Signed, lg.After.Signed) &&
|
|
|
|
|
+ sameSigned(tg.Before.Signed, tg.After.Signed)
|
|
|
add("legacy 是否「成功返回」", fmt.Sprint(lgOK), "N/A", lgOK,
|
|
add("legacy 是否「成功返回」", fmt.Sprint(lgOK), "N/A", lgOK,
|
|
|
"R19:SP 静默 LEAVE,调用方看到的是成功")
|
|
"R19:SP 静默 LEAVE,调用方看到的是成功")
|
|
|
add("target 是否显式拒绝", "N/A", fmt.Sprint(tgRejected), tgRejected,
|
|
add("target 是否显式拒绝", "N/A", fmt.Sprint(tgRejected), tgRejected,
|
|
|
- "Go 应报 3140(unscoped),错误文本:"+tgErr)
|
|
|
|
|
|
|
+ "Go 应显式报错(业务码 "+fmt.Sprint(c.ExpectTargetCode)+"),错误文本:"+tgErr)
|
|
|
asserts = append(asserts,
|
|
asserts = append(asserts,
|
|
|
Assertion{
|
|
Assertion{
|
|
|
- Name: "intentional-strict:legacy 静默成功 + target 显式拒绝(R19)",
|
|
|
|
|
- Pass: lgOK && tgRejected,
|
|
|
|
|
- Detail: fmt.Sprintf("legacy success=%v(期望 true,SP 静默 no-op);target 被拒=%v err=%q(期望 被拒=true + 3140)",
|
|
|
|
|
- lgOK, tgRejected, tgErr),
|
|
|
|
|
|
|
+ Name: "intentional-strict:legacy 静默不报错 + target 显式拒绝(码要对)",
|
|
|
|
|
+ Pass: lgOK && tgRejected && codeOK,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy 抛错=%v(期望 false,SP 静默 no-op);target 被拒=%v 码=%d(期望 被拒=true 码=%d)err=%q",
|
|
|
|
|
+ !lgOK, tgRejected, tgCode, c.ExpectTargetCode, tgErr),
|
|
|
},
|
|
},
|
|
|
Assertion{
|
|
Assertion{
|
|
|
- Name: "intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条)",
|
|
|
|
|
- Pass: noWrite,
|
|
|
|
|
- Detail: fmt.Sprintf("legacy 写库=%v(全局/校区)流水=%d | target 写库=%v(全局/校区)流水=%d —— 都应为 未写库/0 条",
|
|
|
|
|
- lgWrote, len(lg.After.Logs), tgWrote, len(tg.After.Logs)),
|
|
|
|
|
|
|
+ Name: "intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条、活动状态不变)",
|
|
|
|
|
+ Pass: noWrite && signedSame,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy 写库=%v(全局/校区)流水=%d 活动变=%v | target 写库=%v(全局/校区)流水=%d 活动变=%v —— 都应为 未写库/0 条/不变",
|
|
|
|
|
+ lgWrote, len(lg.After.Logs), !sameSigned(lg.Before.Signed, lg.After.Signed),
|
|
|
|
|
+ tgWrote, len(tg.After.Logs), !sameSigned(tg.Before.Signed, tg.After.Signed)),
|
|
|
},
|
|
},
|
|
|
)
|
|
)
|
|
|
|
|
+ // SP 若自带结果集,它的 success/msg 也是契约:SP8 找不到校区时返回 success=0 而不是抛错。
|
|
|
|
|
+ if c.Op == OpCancelRechargeActivity && len(lg.Calls) > 0 && lg.Calls[0].Returned != nil {
|
|
|
|
|
+ r := lg.Calls[0].Returned
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "SP8 返回值:找不到校区时 **success=0 + msg='找不到学校信息'**(不抛错,靠返回值表达失败)",
|
|
|
|
|
+ Pass: r["success"] == "0" && r["msg"] == "找不到学校信息",
|
|
|
|
|
+ Detail: fmt.Sprintf("实测 schoolid=%q success=%q msg=%q", r["schoolid"], r["success"], r["msg"]),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
return diffs, asserts
|
|
return diffs, asserts
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -265,6 +436,13 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
}
|
|
}
|
|
|
wgL, wfL, wtL := expectedAccounts(c.Op, c.Type, c.AmountCents, runsLegacy)
|
|
wgL, wfL, wtL := expectedAccounts(c.Op, c.Type, c.AmountCents, runsLegacy)
|
|
|
wgT, wfT, wtT := expectedAccounts(c.Op, c.Type, c.AmountCents, runsTarget)
|
|
wgT, wfT, wtT := expectedAccounts(c.Op, c.Type, c.AmountCents, runsTarget)
|
|
|
|
|
+ if c.Op == OpClearBalance {
|
|
|
|
|
+ // 清零的金额**由调用前校区余额决定**(SP 里 campusBalance 是从账户读出来的,不是入参),
|
|
|
|
|
+ // 所以两侧各按自己的 before 算期望。这不是"拿两侧互相比"——
|
|
|
|
|
+ // 前提"两侧基线逐分相等"是上面单独的一条断言。
|
|
|
|
|
+ wgL, wfL, wtL = clearedBalanceExpected(lg.Before.Campus.Balance)
|
|
|
|
|
+ wgT, wfT, wtT = clearedBalanceExpected(tg.Before.Campus.Balance)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
dGlobal := sub(lg.After.Global, lg.Before.Global)
|
|
dGlobal := sub(lg.After.Global, lg.Before.Global)
|
|
|
dCampus := sub(lg.After.Campus, lg.Before.Campus)
|
|
dCampus := sub(lg.After.Campus, lg.Before.Campus)
|
|
@@ -350,13 +528,21 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
"legacy logid = inputRequestid;target 同时写 log_id 与 request_id")
|
|
"legacy logid = inputRequestid;target 同时写 log_id 与 request_id")
|
|
|
|
|
|
|
|
// ④ target 独有列:request_id / balance_after(legacy 无此列 → N/A,但必须自洽)
|
|
// ④ target 独有列:request_id / balance_after(legacy 无此列 → N/A,但必须自洽)
|
|
|
|
|
+ //
|
|
|
|
|
+ // SP10(清零)**没有请求号入参**,所以 target 的 request_id 应为空 ——
|
|
|
|
|
+ // 这不是"漏写幂等键",是"这个操作本来就不幂等"。断言它为空,
|
|
|
|
|
+ // 而不是断言它等于 c.RequestID(否则会把"凭空生成一个请求号"当成正确)。
|
|
|
|
|
+ wantReq := c.RequestID
|
|
|
|
|
+ if !opUsesRequestID(c.Op) {
|
|
|
|
|
+ wantReq = ""
|
|
|
|
|
+ }
|
|
|
reqOK := true
|
|
reqOK := true
|
|
|
balOK := true
|
|
balOK := true
|
|
|
var reqDetail, balDetail []string
|
|
var reqDetail, balDetail []string
|
|
|
for _, t := range tg.After.Logs {
|
|
for _, t := range tg.After.Logs {
|
|
|
- if t.RequestID != c.RequestID {
|
|
|
|
|
|
|
+ if t.RequestID != wantReq {
|
|
|
reqOK = false
|
|
reqOK = false
|
|
|
- reqDetail = append(reqDetail, fmt.Sprintf("id=%d request_id=%q(期望 %q)", t.ID, t.RequestID, c.RequestID))
|
|
|
|
|
|
|
+ reqDetail = append(reqDetail, fmt.Sprintf("id=%d request_id=%q(期望 %q)", t.ID, t.RequestID, wantReq))
|
|
|
}
|
|
}
|
|
|
if t.BalanceAfter == nil {
|
|
if t.BalanceAfter == nil {
|
|
|
balOK = false
|
|
balOK = false
|
|
@@ -368,9 +554,9 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
add("target.request_id(legacy 无此列)", "N/A", firstOr(detailJoin(reqDetail), "全部等于入参"),
|
|
add("target.request_id(legacy 无此列)", "N/A", firstOr(detailJoin(reqDetail), "全部等于入参"),
|
|
|
- reqOK, "Go 增强:幂等键落库")
|
|
|
|
|
|
|
+ reqOK, reqNote(c.Op))
|
|
|
add("target.balance_after(legacy 无此列)", "N/A",
|
|
add("target.balance_after(legacy 无此列)", "N/A",
|
|
|
- firstOr(detailJoin(balDetail), "全部 == 调用后全局余额"), balOK, "Go 增强:余额快照")
|
|
|
|
|
|
|
+ firstOr(detailJoin(balDetail), "全部 == 调用后全局余额"), balOK, "Go 增强:余额快照(口径是**全局**账户,不是校区)")
|
|
|
|
|
|
|
|
// ④.5 remark(默认备注是 SP 之间唯一的"文案差异",必须逐字比对)
|
|
// ④.5 remark(默认备注是 SP 之间唯一的"文案差异",必须逐字比对)
|
|
|
remarkOK := true
|
|
remarkOK := true
|
|
@@ -475,8 +661,13 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ⑦ R15:两侧余额都必须为负
|
|
|
|
|
- if c.Baseline != nil {
|
|
|
|
|
|
|
+ // ⑥.7 批 C1:校区元数据 / 活动失效 / SP 返回值
|
|
|
|
|
+ if c.Op == OpClearBalance || c.Op == OpCancelRechargeActivity {
|
|
|
|
|
+ asserts = append(asserts, c1Assertions(c, lg, tg, dGlobal, dCampus, tGlobal, tCampus, runsLegacy, runsTarget)...)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ⑦ R15:两侧余额都必须为负(只有**显式声明**的 case 才断言,见 Case.ExpectNegativeBalance)
|
|
|
|
|
+ if c.ExpectNegativeBalance {
|
|
|
negOK := lg.After.Global.Balance < 0 && tg.After.Global.Balance < 0 &&
|
|
negOK := lg.After.Global.Balance < 0 && tg.After.Global.Balance < 0 &&
|
|
|
lg.After.Campus.Balance < 0 && tg.After.Campus.Balance < 0
|
|
lg.After.Campus.Balance < 0 && tg.After.Campus.Balance < 0
|
|
|
asserts = append(asserts, Assertion{
|
|
asserts = append(asserts, Assertion{
|
|
@@ -492,30 +683,40 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// readLegacyNatural 用独立连接读 legacy 侧全局账户的"天然状态"(事务外)。
|
|
// readLegacyNatural 用独立连接读 legacy 侧全局账户的"天然状态"(事务外)。
|
|
|
-func readLegacyNatural(ctx context.Context, cfg Config, legacyUserID int64) (Account, error) {
|
|
|
|
|
|
|
+//
|
|
|
|
|
+// 同时返回该用户的**流水总条数**:清零类用例没有请求号,没法按 logid 找残留,
|
|
|
|
|
+// 只能用"条数没变"来判 —— 而条数要跟事务**之前**比,不能用事务内的快照。
|
|
|
|
|
+func readLegacyNatural(ctx context.Context, cfg Config, legacyUserID int64) (Account, int64, error) {
|
|
|
db, err := cfg.OpenWriteLegacy()
|
|
db, err := cfg.OpenWriteLegacy()
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
- return Account{}, err
|
|
|
|
|
|
|
+ return Account{}, 0, err
|
|
|
}
|
|
}
|
|
|
- var bal, spend, inc, exp string
|
|
|
|
|
|
|
+ var bal, spend, inc, exp, spec string
|
|
|
err = db.Raw(`
|
|
err = db.Raw(`
|
|
|
SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
- CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
|
|
|
|
|
- FROM t_user_expense WHERE userid = ?`, legacyUserID).Row().Scan(&bal, &spend, &inc, &exp)
|
|
|
|
|
|
|
+ CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
|
|
|
|
|
+ FROM t_user_expense WHERE userid = ?`, legacyUserID).Row().Scan(&bal, &spend, &inc, &exp, &spec)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
- return Account{}, fmt.Errorf("读 legacy 天然基线失败:%w", err)
|
|
|
|
|
|
|
+ return Account{}, 0, fmt.Errorf("读 legacy 天然基线失败:%w", err)
|
|
|
}
|
|
}
|
|
|
b, _ := centsOf(bal)
|
|
b, _ := centsOf(bal)
|
|
|
sp, _ := centsOf(spend)
|
|
sp, _ := centsOf(spend)
|
|
|
i, _ := centsOf(inc)
|
|
i, _ := centsOf(inc)
|
|
|
e, _ := centsOf(exp)
|
|
e, _ := centsOf(exp)
|
|
|
- return Account{Balance: b, Spend: sp, Income: i, Expenditure: e}, nil
|
|
|
|
|
|
|
+ pc, _ := centsOf(spec)
|
|
|
|
|
+ var logCount int64
|
|
|
|
|
+ if err := db.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ?`, legacyUserID).
|
|
|
|
|
+ Row().Scan(&logCount); err != nil {
|
|
|
|
|
+ return Account{}, 0, fmt.Errorf("读 legacy 天然流水条数失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return Account{Balance: b, Spend: sp, Income: i, Expenditure: e, Special: pc}, logCount, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// checkResidue 在两个事务都回滚之后,用**新连接**核对残留。
|
|
// checkResidue 在两个事务都回滚之后,用**新连接**核对残留。
|
|
|
-func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural Account) ResidueReport {
|
|
|
|
|
|
|
+func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural Account, naturalLogs int64) ResidueReport {
|
|
|
var rep ResidueReport
|
|
var rep ResidueReport
|
|
|
|
|
|
|
|
// target:本 case 种进去的行必须全部消失(这些行本来就只在事务里存在)
|
|
// target:本 case 种进去的行必须全部消失(这些行本来就只在事务里存在)
|
|
@@ -535,22 +736,37 @@ func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural A
|
|
|
// legacy:用户本来就存在,核对"余额回到调用前"
|
|
// legacy:用户本来就存在,核对"余额回到调用前"
|
|
|
ldb, err := cfg.OpenWriteLegacy()
|
|
ldb, err := cfg.OpenWriteLegacy()
|
|
|
if err == nil {
|
|
if err == nil {
|
|
|
- var bal, spend, inc, exp string
|
|
|
|
|
|
|
+ var bal, spend, inc, exp, spec string
|
|
|
err := ldb.Raw(`
|
|
err := ldb.Raw(`
|
|
|
SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
- CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
|
|
|
|
|
- FROM t_user_expense WHERE userid = ?`, c.LegacyUserID).Row().Scan(&bal, &spend, &inc, &exp)
|
|
|
|
|
|
|
+ CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
|
|
|
|
|
+ FROM t_user_expense WHERE userid = ?`, c.LegacyUserID).Row().Scan(&bal, &spend, &inc, &exp, &spec)
|
|
|
if err == nil {
|
|
if err == nil {
|
|
|
b, _ := centsOf(bal)
|
|
b, _ := centsOf(bal)
|
|
|
s, _ := centsOf(spend)
|
|
s, _ := centsOf(spend)
|
|
|
i, _ := centsOf(inc)
|
|
i, _ := centsOf(inc)
|
|
|
e, _ := centsOf(exp)
|
|
e, _ := centsOf(exp)
|
|
|
- rep.LegacyGlobalExist = Account{Balance: b, Spend: s, Income: i, Expenditure: e} != natural
|
|
|
|
|
|
|
+ pc, _ := centsOf(spec)
|
|
|
|
|
+ // special_expenses 也进"是否偏离"的比较:清零/坏账会动它,漏比就漏残留
|
|
|
|
|
+ rep.LegacyGlobalExist = Account{Balance: b, Spend: s, Income: i, Expenditure: e, Special: pc} != natural
|
|
|
|
|
+ }
|
|
|
|
|
+ if c.RequestID != "" {
|
|
|
|
|
+ // 有请求号的 case:按 logid 精确找本 case 的残留
|
|
|
|
|
+ _ = ldb.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ? AND logid IN (?, ?)`,
|
|
|
|
|
+ c.LegacyUserID, c.RequestID, c.RequestID).Row().Scan(&rep.LegacyLogCount)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 无请求号(清零):用"流水总条数是否回到事务前"来判断
|
|
|
|
|
+ var n int64
|
|
|
|
|
+ if err := ldb.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ?`, c.LegacyUserID).
|
|
|
|
|
+ Row().Scan(&n); err == nil {
|
|
|
|
|
+ if n > naturalLogs {
|
|
|
|
|
+ rep.LegacyLogCount = n - naturalLogs
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- _ = ldb.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ? AND logid IN (?, ?)`,
|
|
|
|
|
- c.LegacyUserID, c.RequestID, c.RequestID).Row().Scan(&rep.LegacyLogCount)
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
rep.Pass = !rep.TargetGlobalExist && rep.TargetLogCount == 0 && !rep.LegacyGlobalExist && rep.LegacyLogCount == 0
|
|
rep.Pass = !rep.TargetGlobalExist && rep.TargetLogCount == 0 && !rep.LegacyGlobalExist && rep.LegacyLogCount == 0
|
|
@@ -652,6 +868,10 @@ func remarkNote(op Op) string {
|
|
|
return "SP6 **没有默认备注**(inputRemark 原样落库),case 里显式传入以验证透传"
|
|
return "SP6 **没有默认备注**(inputRemark 原样落库),case 里显式传入以验证透传"
|
|
|
case OpSpend:
|
|
case OpSpend:
|
|
|
return "SP11 不写流水,无备注可比(0 条)"
|
|
return "SP11 不写流水,无备注可比(0 条)"
|
|
|
|
|
+ case OpClearBalance:
|
|
|
|
|
+ return "SP10 默认备注「余额清零」(有 5 种写法各不相同的默认备注,只能逐字比)"
|
|
|
|
|
+ case OpCancelRechargeActivity:
|
|
|
|
|
+ return "SP8 默认备注「取消充值活动」"
|
|
|
default:
|
|
default:
|
|
|
return "消费 SP 的默认备注(照 dump 逐字)"
|
|
return "消费 SP 的默认备注(照 dump 逐字)"
|
|
|
}
|
|
}
|
|
@@ -678,6 +898,16 @@ func errOf(calls []CallOutcome) string {
|
|
|
return ""
|
|
return ""
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// codeOf 取第一次调用的业务错误码(0 = 不是 AppError 或没有报错)。
|
|
|
|
|
+func codeOf(calls []CallOutcome) int {
|
|
|
|
|
+ for _, c := range calls {
|
|
|
|
|
+ if c.Code != 0 {
|
|
|
|
|
+ return c.Code
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return 0
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// PrintResult 打印一个 case 的双跑结果。
|
|
// PrintResult 打印一个 case 的双跑结果。
|
|
|
func PrintResult(w io.Writer, r *CaseResult) {
|
|
func PrintResult(w io.Writer, r *CaseResult) {
|
|
|
fmt.Fprintf(w, "──────────────────────────────────────────────────────────────\n")
|
|
fmt.Fprintf(w, "──────────────────────────────────────────────────────────────\n")
|