|
@@ -0,0 +1,451 @@
|
|
|
|
|
+package stage2
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "io"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// 本文件是比对与编排。
|
|
|
|
|
+
|
|
|
|
|
+// DefaultCases 返回三支 case:正常 / 幂等 / R15(余额不足)。
|
|
|
|
|
+//
|
|
|
|
|
+// 用户与机构校区取自真实数据(userid=17735 / 63317 / 5):
|
|
|
|
|
+// 该用户全局与校区余额都是 100.00、且四个金额列在 legacy 侧都是 2 位小数,
|
|
|
|
|
+// 因此**两侧基线天然逐分相等**(不需要人为造基线)—— 这正是 case 有效性的前提。
|
|
|
|
|
+func DefaultCases(uid string) []Case {
|
|
|
|
|
+ base := Case{
|
|
|
|
|
+ LegacyUserID: 17735,
|
|
|
|
|
+ InstitutionID: 63317,
|
|
|
|
|
+ CampusID: 5,
|
|
|
|
|
+ Type: 201,
|
|
|
|
|
+ Remark: "",
|
|
|
|
|
+ RunTimes: 1,
|
|
|
|
|
+ ExpectTargetSuccess: true,
|
|
|
|
|
+ ExpectLegacySuccess: true,
|
|
|
|
|
+ }
|
|
|
|
|
+ _ = uid
|
|
|
|
|
+
|
|
|
|
|
+ c1 := base
|
|
|
|
|
+ c1.Name = "C1 正常消费(type=201,30.00)"
|
|
|
|
|
+ c1.AmountCents = 3000
|
|
|
|
|
+ c1.RequestID = "ac15-s2-c1"
|
|
|
|
|
+ c1.Note = "两侧都应成功,Δbalance 同为 -30.00,各写 1 条流水"
|
|
|
|
|
+
|
|
|
|
|
+ c2 := base
|
|
|
|
|
+ c2.Name = "C2 幂等(同一 requestId 连跑两次)"
|
|
|
|
|
+ c2.AmountCents = 3000
|
|
|
|
|
+ c2.RequestID = "ac15-s2-c2"
|
|
|
|
|
+ c2.RunTimes = 2
|
|
|
|
|
+ c2.Note = "target(Go)应 repeated=true 且只入账一次;legacy SP **没有幂等**,会重复入账 —— 这是设计内的 Go 增强"
|
|
|
|
|
+
|
|
|
|
|
+ c3 := base
|
|
|
|
|
+ c3.Name = "C3 R15 余额不足(余额 10.00,消费 100.00)"
|
|
|
|
|
+ c3.AmountCents = 10000
|
|
|
|
|
+ c3.RequestID = "ac15-s2-c3"
|
|
|
|
|
+ c3.Type = 200 // 历史扣费(同时动 spend,覆盖三分支里最复杂的一支)
|
|
|
|
|
+ c3.Note = "R15:SP 不校验余额 → 两侧都应成功且扣成 -90.00"
|
|
|
|
|
+ c3.Baseline = &Baseline{
|
|
|
|
|
+ GlobalBalance: 1000, GlobalSpend: 0, GlobalIncome: 0, GlobalExpenditure: 0,
|
|
|
|
|
+ CampusBalance: 1000, CampusSpend: 0, CampusIncome: 0, CampusExpenditure: 0,
|
|
|
|
|
+ }
|
|
|
|
|
+ return []Case{c1, c2, c3}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// RunOne 跑一个 case 的完整双跑闭环。
|
|
|
|
|
+func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
|
|
|
|
|
+ legacyDB, err := cfg.OpenWriteLegacy()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ targetDB, err := cfg.OpenTarget()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ uid := CanonicalUIDFor(c.LegacyUserID)
|
|
|
|
|
+ res := &CaseResult{Case: c}
|
|
|
|
|
+
|
|
|
|
|
+ // 事务**之前**先读一次 legacy 的天然状态:回滚之后要拿它做对照。
|
|
|
|
|
+ // (不能用 lg.Before —— R15 用例会在事务内把基线覆盖成 10.00,那不是"调用前"的真实状态。)
|
|
|
|
|
+ natural, err := readLegacyNatural(ctx, cfg, c.LegacyUserID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- legacy 侧(事务内 CALL → 读回 → 回滚)----
|
|
|
|
|
+ lg, err := newLegacySide(legacyDB).Run(ctx, c)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ res.Legacy = lg
|
|
|
|
|
+
|
|
|
|
|
+ // ---- target 侧(事务内 seed → service → 读回 → 回滚)----
|
|
|
|
|
+ tg, err := newTargetSide(targetDB).Run(ctx, c, uid)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ res.Target = tg
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 比对 ----
|
|
|
|
|
+ res.Diffs, res.Assertions = compare(c, lg, tg)
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 残留检查(两边都回滚之后)----
|
|
|
|
|
+ res.Residue = checkResidue(ctx, cfg, c, uid, natural)
|
|
|
|
|
+ if !res.Residue.Pass {
|
|
|
|
|
+ res.Assertions = append(res.Assertions, Assertion{
|
|
|
|
|
+ Name: "回滚后零残留(两侧都回到调用前状态)", Pass: false, Detail: res.Residue.Detail,
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ res.Assertions = append(res.Assertions, Assertion{
|
|
|
|
|
+ Name: "回滚后零残留(两侧都回到调用前状态)", Pass: true, Detail: res.Residue.Detail,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return res, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// expectedDelta 返回一侧在某 case 下的**期望增量**。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么要有"期望"而不只是"两侧相等":
|
|
|
|
|
+// - 幂等用例里两侧本来就**不应该**相等(legacy SP 没有幂等,会重复入账);
|
|
|
|
|
+// - 只比"相等"的话,这种情况只能算 FAIL,而它其实是设计内的已知差异。
|
|
|
|
|
+//
|
|
|
|
|
+// 所以这里把"每侧应有的行为"显式写出来,再拿实测去对 —— 偏离任何一侧的预期都会红。
|
|
|
|
|
+func expectedDelta(typ int32, amount int64, runs int) Account {
|
|
|
|
|
+ exp := Account{
|
|
|
|
|
+ Balance: -amount * int64(runs),
|
|
|
|
|
+ Expenditure: amount * int64(runs),
|
|
|
|
|
+ }
|
|
|
|
|
+ if typ == 200 { // 历史扣费:同时计入 spend
|
|
|
|
|
+ exp.Spend = amount * int64(runs)
|
|
|
|
|
+ }
|
|
|
|
|
+ return exp
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// compare 逐项比对两侧。
|
|
|
|
|
+func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
|
|
+ var diffs []Diff
|
|
|
|
|
+ var asserts []Assertion
|
|
|
|
|
+
|
|
|
|
|
+ add := func(item, l, t string, equal bool, note string) {
|
|
|
|
|
+ diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ① 基线逐分相等(case 有效性前提)
|
|
|
|
|
+ baseEqual := lg.Before.Global == tg.Before.Global && lg.Before.Campus == tg.Before.Campus
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "两侧基线逐分相等(case 有效性前提)",
|
|
|
|
|
+ Pass: baseEqual,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy 全局=%s 校区=%s | target 全局=%s 校区=%s",
|
|
|
|
|
+ fmtAcc(lg.Before.Global), fmtAcc(lg.Before.Campus),
|
|
|
|
|
+ fmtAcc(tg.Before.Global), fmtAcc(tg.Before.Campus)),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ② Δ 逐分相等(核心):拿每侧实测 Δ 去对**该侧应有的期望值**
|
|
|
|
|
+ //
|
|
|
|
|
+ // runsLegacy:legacy SP 每次都真入账(无幂等键)→ 调用几次就入账几次
|
|
|
|
|
+ // runsTarget:Go 侧有 uk_request_type 幂等 → 无论调用几次只入账一次
|
|
|
|
|
+ runsLegacy := maxInt(1, c.RunTimes)
|
|
|
|
|
+ runsTarget := 1
|
|
|
|
|
+ noteRuns := ""
|
|
|
|
|
+ if c.RunTimes >= 2 {
|
|
|
|
|
+ noteRuns = fmt.Sprintf("预期差异:legacy 入账 %d 次(SP 无幂等)/ target 入账 1 次(幂等命中)", runsLegacy)
|
|
|
|
|
+ }
|
|
|
|
|
+ wantL := expectedDelta(c.Type, c.AmountCents, runsLegacy)
|
|
|
|
|
+ wantT := expectedDelta(c.Type, c.AmountCents, runsTarget)
|
|
|
|
|
+
|
|
|
|
|
+ dGlobal := sub(lg.After.Global, lg.Before.Global)
|
|
|
|
|
+ dCampus := sub(lg.After.Campus, lg.Before.Campus)
|
|
|
|
|
+ tGlobal := sub(tg.After.Global, tg.Before.Global)
|
|
|
|
|
+ tCampus := sub(tg.After.Campus, tg.Before.Campus)
|
|
|
|
|
+
|
|
|
|
|
+ add("Δbalance(全局)", yuan(dGlobal.Balance), yuan(tGlobal.Balance),
|
|
|
|
|
+ dGlobal.Balance == wantL.Balance && tGlobal.Balance == wantT.Balance,
|
|
|
|
|
+ "期望 "+yuan(wantL.Balance)+" / "+yuan(wantT.Balance)+";容差 0 "+noteRuns)
|
|
|
|
|
+ add("Δbalance(校区)", yuan(dCampus.Balance), yuan(tCampus.Balance),
|
|
|
|
|
+ dCampus.Balance == wantL.Balance && tCampus.Balance == wantT.Balance, "同上")
|
|
|
|
|
+ add("Δspend(全局)", yuan(dGlobal.Spend), yuan(tGlobal.Spend),
|
|
|
|
|
+ dGlobal.Spend == wantL.Spend && tGlobal.Spend == wantT.Spend,
|
|
|
|
|
+ "type=200 才动 spend;期望 "+yuan(wantL.Spend)+" / "+yuan(wantT.Spend))
|
|
|
|
|
+ add("Δspend(校区)", yuan(dCampus.Spend), yuan(tCampus.Spend),
|
|
|
|
|
+ dCampus.Spend == wantL.Spend && tCampus.Spend == wantT.Spend, "同上")
|
|
|
|
|
+ add("Δtotal_income(全局)", yuan(dGlobal.Income), yuan(tGlobal.Income),
|
|
|
|
|
+ dGlobal.Income == wantL.Income && tGlobal.Income == wantT.Income, "whitelist 内 type 都不动 total_income")
|
|
|
|
|
+ add("Δtotal_income(校区)", yuan(dCampus.Income), yuan(tCampus.Income),
|
|
|
|
|
+ dCampus.Income == wantL.Income && tCampus.Income == wantT.Income, "同上")
|
|
|
|
|
+ add("Δtotal_expenditure(全局)", yuan(dGlobal.Expenditure), yuan(tGlobal.Expenditure),
|
|
|
|
|
+ dGlobal.Expenditure == wantL.Expenditure && tGlobal.Expenditure == wantT.Expenditure,
|
|
|
|
|
+ "期望 "+yuan(wantL.Expenditure)+" / "+yuan(wantT.Expenditure))
|
|
|
|
|
+ add("Δtotal_expenditure(校区)", yuan(dCampus.Expenditure), yuan(tCampus.Expenditure),
|
|
|
|
|
+ dCampus.Expenditure == wantL.Expenditure && tCampus.Expenditure == wantT.Expenditure, "同上")
|
|
|
|
|
+
|
|
|
|
|
+ // ③ 流水:条数 / type / money / logId
|
|
|
|
|
+ add("流水条数", fmt.Sprint(len(lg.After.Logs)), fmt.Sprint(len(tg.After.Logs)),
|
|
|
|
|
+ len(lg.After.Logs) == runsLegacy && len(tg.After.Logs) == runsTarget,
|
|
|
|
|
+ fmt.Sprintf("期望 legacy=%d / target=%d", runsLegacy, runsTarget))
|
|
|
|
|
+
|
|
|
|
|
+ n := len(lg.After.Logs)
|
|
|
|
|
+ if len(tg.After.Logs) < n {
|
|
|
|
|
+ n = len(tg.After.Logs)
|
|
|
|
|
+ }
|
|
|
|
|
+ sameType, sameMoney, sameLogID := true, true, true
|
|
|
|
|
+ var tDetail, mDetail, lDetail []string
|
|
|
|
|
+ for i := 0; i < n; i++ {
|
|
|
|
|
+ l, t := lg.After.Logs[i], tg.After.Logs[i]
|
|
|
|
|
+ if l.Type != t.Type {
|
|
|
|
|
+ sameType = false
|
|
|
|
|
+ tDetail = append(tDetail, fmt.Sprintf("#%d legacy=%d target=%d", i, l.Type, t.Type))
|
|
|
|
|
+ }
|
|
|
|
|
+ if l.Money != t.Money {
|
|
|
|
|
+ sameMoney = false
|
|
|
|
|
+ mDetail = append(mDetail, fmt.Sprintf("#%d legacy=%s target=%s", i, yuan(l.Money), yuan(t.Money)))
|
|
|
|
|
+ }
|
|
|
|
|
+ if l.LogID != t.LogID {
|
|
|
|
|
+ sameLogID = false
|
|
|
|
|
+ lDetail = append(lDetail, fmt.Sprintf("#%d legacy=%q target=%q", i, l.LogID, t.LogID))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ add("流水 type", firstOr(detailJoin(tDetail), "全部一致"), fmt.Sprint(n)+" 条", sameType, "")
|
|
|
|
|
+ add("流水 money", firstOr(detailJoin(mDetail), "逐分相等"), fmt.Sprint(n)+" 条", sameMoney, "两侧都为负数(支出方向)")
|
|
|
|
|
+ add("流水 logid/log_id", firstOr(detailJoin(lDetail), "全部一致"), fmt.Sprint(n)+" 条", sameLogID,
|
|
|
|
|
+ "legacy logid = inputRequestid;target 同时写 log_id 与 request_id")
|
|
|
|
|
+
|
|
|
|
|
+ // ④ target 独有列:request_id / balance_after(legacy 无此列 → N/A,但必须自洽)
|
|
|
|
|
+ reqOK := true
|
|
|
|
|
+ balOK := true
|
|
|
|
|
+ var reqDetail, balDetail []string
|
|
|
|
|
+ for _, t := range tg.After.Logs {
|
|
|
|
|
+ if t.RequestID != c.RequestID {
|
|
|
|
|
+ reqOK = false
|
|
|
|
|
+ reqDetail = append(reqDetail, fmt.Sprintf("id=%d request_id=%q(期望 %q)", t.ID, t.RequestID, c.RequestID))
|
|
|
|
|
+ }
|
|
|
|
|
+ if t.BalanceAfter == nil {
|
|
|
|
|
+ balOK = false
|
|
|
|
|
+ balDetail = append(balDetail, fmt.Sprintf("id=%d balance_after=NULL", t.ID))
|
|
|
|
|
+ } else if *t.BalanceAfter != tg.After.Global.Balance {
|
|
|
|
|
+ balOK = false
|
|
|
|
|
+ balDetail = append(balDetail, fmt.Sprintf("id=%d balance_after=%s ≠ 调用后全局余额 %s",
|
|
|
|
|
+ t.ID, yuan(*t.BalanceAfter), yuan(tg.After.Global.Balance)))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ add("target.request_id(legacy 无此列)", "N/A", firstOr(detailJoin(reqDetail), "全部等于入参"),
|
|
|
|
|
+ reqOK, "Go 增强:幂等键落库")
|
|
|
|
|
+ add("target.balance_after(legacy 无此列)", "N/A",
|
|
|
|
|
+ firstOr(detailJoin(balDetail), "全部 == 调用后全局余额"), balOK, "Go 增强:余额快照")
|
|
|
|
|
+
|
|
|
|
|
+ // ⑤ 返回码 / 成功与否
|
|
|
|
|
+ lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
|
|
|
|
|
+ tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
|
|
|
|
|
+ add("首次调用是否成功", fmt.Sprint(lgOK), fmt.Sprint(tgOK), lgOK == tgOK, "R15 场景两侧都应成功")
|
|
|
|
|
+
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "首次调用结果与预期一致(success / R15 不报余额不足)",
|
|
|
|
|
+ Pass: lgOK == c.ExpectLegacySuccess && tgOK == c.ExpectTargetSuccess,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy success=%v(期望 %v);target success=%v(期望 %v)|target err=%q",
|
|
|
|
|
+ lgOK, c.ExpectLegacySuccess, tgOK, c.ExpectTargetSuccess, errOf(tg.Calls)),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ⑥ 幂等(两次调用时):target 必须 repeated,legacy 没有幂等 → 重复入账
|
|
|
|
|
+ if c.RunTimes >= 2 {
|
|
|
|
|
+ lgSecondOK := len(lg.Calls) >= 2 && lg.Calls[1].Success
|
|
|
|
|
+ tgRepeated := len(tg.Calls) >= 2 && tg.Calls[1].Repeated
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "幂等:target 第二次 repeated=true;legacy SP 无幂等(会重复入账)",
|
|
|
|
|
+ Pass: tgRepeated && lgSecondOK,
|
|
|
|
|
+ Detail: fmt.Sprintf("target 第2次 repeated=%v(期望 true);legacy 第2次 success=%v(SP 无幂等键,确实重复入账)",
|
|
|
|
|
+ tgRepeated, lgSecondOK),
|
|
|
|
|
+ })
|
|
|
|
|
+ // legacy 侧流水应为 2 条、target 侧 1 条 —— 这是**已知且有意**的差异,单独记一条 diff
|
|
|
|
|
+ add("重复提交后的流水条数(已知差异)",
|
|
|
|
|
+ fmt.Sprint(len(lg.After.Logs)), fmt.Sprint(len(tg.After.Logs)),
|
|
|
|
|
+ true, "legacy=2(重复入账)/ target=1(幂等命中)—— 设计内的 Go 增强,不是缺陷")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ⑦ R15:两侧余额都必须为负
|
|
|
|
|
+ if c.Baseline != nil {
|
|
|
|
|
+ negOK := lg.After.Global.Balance < 0 && tg.After.Global.Balance < 0 &&
|
|
|
|
|
+ lg.After.Campus.Balance < 0 && tg.After.Campus.Balance < 0
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "R15:余额不足时两侧都扣成负数且都返回成功(不校验余额)",
|
|
|
|
|
+ Pass: negOK,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy 全局=%s 校区=%s | target 全局=%s 校区=%s(都应为负)",
|
|
|
|
|
+ yuan(lg.After.Global.Balance), yuan(lg.After.Campus.Balance),
|
|
|
|
|
+ yuan(tg.After.Global.Balance), yuan(tg.After.Campus.Balance)),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return diffs, asserts
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// readLegacyNatural 用独立连接读 legacy 侧全局账户的"天然状态"(事务外)。
|
|
|
|
|
+func readLegacyNatural(ctx context.Context, cfg Config, legacyUserID int64) (Account, error) {
|
|
|
|
|
+ db, err := cfg.OpenWriteLegacy()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return Account{}, err
|
|
|
|
|
+ }
|
|
|
|
|
+ var bal, spend, inc, exp string
|
|
|
|
|
+ err = db.Raw(`
|
|
|
|
|
+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(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)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return Account{}, fmt.Errorf("读 legacy 天然基线失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ b, _ := centsOf(bal)
|
|
|
|
|
+ sp, _ := centsOf(spend)
|
|
|
|
|
+ i, _ := centsOf(inc)
|
|
|
|
|
+ e, _ := centsOf(exp)
|
|
|
|
|
+ return Account{Balance: b, Spend: sp, Income: i, Expenditure: e}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// checkResidue 在两个事务都回滚之后,用**新连接**核对残留。
|
|
|
|
|
+func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural Account) ResidueReport {
|
|
|
|
|
+ var rep ResidueReport
|
|
|
|
|
+
|
|
|
|
|
+ // target:本 case 种进去的行必须全部消失(这些行本来就只在事务里存在)
|
|
|
|
|
+ tdb, err := cfg.OpenTarget()
|
|
|
|
|
+ if err == nil {
|
|
|
|
|
+ var n int64
|
|
|
|
|
+ _ = tdb.Raw(`SELECT COUNT(*) FROM t_user_expense WHERE user_id = ?`, uid).Row().Scan(&n)
|
|
|
|
|
+ rep.TargetGlobalExist = n > 0
|
|
|
|
|
+ _ = tdb.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE user_id = ?`, uid).Row().Scan(&n)
|
|
|
|
|
+ rep.TargetLogCount = n
|
|
|
|
|
+ _ = tdb.Raw(`SELECT COUNT(*) FROM t_membership WHERE canonical_user_id = ?`, uid).Row().Scan(&n)
|
|
|
|
|
+ if n > 0 {
|
|
|
|
|
+ rep.TargetGlobalExist = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // legacy:用户本来就存在,核对"余额回到调用前"
|
|
|
|
|
+ ldb, err := cfg.OpenWriteLegacy()
|
|
|
|
|
+ if err == nil {
|
|
|
|
|
+ var bal, spend, inc, exp string
|
|
|
|
|
+ err := ldb.Raw(`
|
|
|
|
|
+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(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)
|
|
|
|
|
+ if err == nil {
|
|
|
|
|
+ b, _ := centsOf(bal)
|
|
|
|
|
+ s, _ := centsOf(spend)
|
|
|
|
|
+ i, _ := centsOf(inc)
|
|
|
|
|
+ e, _ := centsOf(exp)
|
|
|
|
|
+ rep.LegacyGlobalExist = Account{Balance: b, Spend: s, Income: i, Expenditure: e} != natural
|
|
|
|
|
+ }
|
|
|
|
|
+ _ = 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.Detail = fmt.Sprintf("target 残留账户行=%v 流水=%d | legacy 余额偏离事务前=%v 本 case 流水残留=%d",
|
|
|
|
|
+ rep.TargetGlobalExist, rep.TargetLogCount, rep.LegacyGlobalExist, rep.LegacyLogCount)
|
|
|
|
|
+ return rep
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 小工具 ----------
|
|
|
|
|
+
|
|
|
|
|
+func sub(a, b Account) Account {
|
|
|
|
|
+ return Account{
|
|
|
|
|
+ Balance: a.Balance - b.Balance,
|
|
|
|
|
+ Spend: a.Spend - b.Spend,
|
|
|
|
|
+ Income: a.Income - b.Income,
|
|
|
|
|
+ Expenditure: a.Expenditure - b.Expenditure,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func fmtAcc(a Account) string {
|
|
|
|
|
+ return fmt.Sprintf("{bal=%s spend=%s income=%s exp=%s}",
|
|
|
|
|
+ yuan(a.Balance), yuan(a.Spend), yuan(a.Income), yuan(a.Expenditure))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func abs64(v int64) int64 {
|
|
|
|
|
+ if v < 0 {
|
|
|
|
|
+ return -v
|
|
|
|
|
+ }
|
|
|
|
|
+ return v
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func detailJoin(ss []string) string { return strings.Join(ss, ";") }
|
|
|
|
|
+
|
|
|
|
|
+func firstOr(s, def string) string {
|
|
|
|
|
+ if s == "" {
|
|
|
|
|
+ return def
|
|
|
|
|
+ }
|
|
|
|
|
+ return s
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func errOf(calls []CallOutcome) string {
|
|
|
|
|
+ for _, c := range calls {
|
|
|
|
|
+ if c.ErrMsg != "" {
|
|
|
|
|
+ return c.ErrMsg
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return ""
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// PrintResult 打印一个 case 的双跑结果。
|
|
|
|
|
+func PrintResult(w io.Writer, r *CaseResult) {
|
|
|
|
|
+ fmt.Fprintf(w, "──────────────────────────────────────────────────────────────\n")
|
|
|
|
|
+ fmt.Fprintf(w, "▶ %s\n", r.Case.Name)
|
|
|
|
|
+ if r.Case.Note != "" {
|
|
|
|
|
+ fmt.Fprintf(w, " 说明:%s\n", r.Case.Note)
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, " 入参:userid=%d inst=%d campus=%d money=%s type=%d requestId=%q 调用 %d 次\n",
|
|
|
|
|
+ r.Case.LegacyUserID, r.Case.InstitutionID, r.Case.CampusID,
|
|
|
|
|
+ yuan(r.Case.AmountCents), r.Case.Type, r.Case.RequestID, maxInt(1, r.Case.RunTimes))
|
|
|
|
|
+
|
|
|
|
|
+ fmt.Fprintf(w, " ── 账户变化 ──\n")
|
|
|
|
|
+ fmt.Fprintf(w, " legacy 前:全局 %s 校区 %s\n", fmtAcc(r.Legacy.Before.Global), fmtAcc(r.Legacy.Before.Campus))
|
|
|
|
|
+ fmt.Fprintf(w, " legacy 后:全局 %s 校区 %s\n", fmtAcc(r.Legacy.After.Global), fmtAcc(r.Legacy.After.Campus))
|
|
|
|
|
+ fmt.Fprintf(w, " target 前:全局 %s 校区 %s\n", fmtAcc(r.Target.Before.Global), fmtAcc(r.Target.Before.Campus))
|
|
|
|
|
+ fmt.Fprintf(w, " target 后:全局 %s 校区 %s\n", fmtAcc(r.Target.After.Global), fmtAcc(r.Target.After.Campus))
|
|
|
|
|
+
|
|
|
|
|
+ fmt.Fprintf(w, " ── 逐项比对 ──\n")
|
|
|
|
|
+ for _, d := range r.Diffs {
|
|
|
|
|
+ mark := "✔"
|
|
|
|
|
+ if !d.Equal {
|
|
|
|
|
+ mark = "✘"
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, " %s %-32s legacy=%-22s target=%-22s %s\n", mark, d.Item, d.Legacy, d.Target, d.Note)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fmt.Fprintf(w, " ── 断言 ──\n")
|
|
|
|
|
+ for _, a := range r.Assertions {
|
|
|
|
|
+ mark := "✔"
|
|
|
|
|
+ if !a.Pass {
|
|
|
|
|
+ mark = "✘"
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, " %s %s\n %s\n", mark, a.Name, a.Detail)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ verdict := "PASS"
|
|
|
|
|
+ if !r.OK() {
|
|
|
|
|
+ verdict = "FAIL"
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, " 结论:%s(不一致 %d 项)\n\n", verdict, r.FailCount())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Summarize 打印总体结论。
|
|
|
|
|
+func Summarize(w io.Writer, results []*CaseResult) bool {
|
|
|
|
|
+ total, pass := 0, 0
|
|
|
|
|
+ for _, r := range results {
|
|
|
|
|
+ total++
|
|
|
|
|
+ if r.OK() {
|
|
|
|
|
+ pass++
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, "══════════════════════════════════════════════════════════════\n")
|
|
|
|
|
+ fmt.Fprintf(w, "总览:%d 个 case,%d PASS,%d FAIL\n", total, pass, total-pass)
|
|
|
|
|
+ for _, r := range results {
|
|
|
|
|
+ st := "PASS"
|
|
|
|
|
+ if !r.OK() {
|
|
|
|
|
+ st = "FAIL"
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, " %s %-40s 不一致 %d\n", st, r.Case.Name, r.FailCount())
|
|
|
|
|
+ }
|
|
|
|
|
+ return pass == total
|
|
|
|
|
+}
|