|
@@ -0,0 +1,392 @@
|
|
|
|
|
+package stage2
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// 本文件是活动链 7 支的比对与断言(批 3a)。
|
|
|
|
|
+//
|
|
|
|
|
+// # 为什么不能走通用比对
|
|
|
|
|
+//
|
|
|
|
|
+// 通用比对是 `Δbalance / Δspecial / Δincome / 流水 type` —— 那是**资金域**的语言,
|
|
|
|
|
+// 而这 7 支里 **0 支**在 SP 层面写资金(实测:`t_user_expense` 引用数全为 0)。
|
|
|
|
|
+// 所以这一支的比对对象是活动域的行:`number_sign` / `signed` 整行 / `used_log` 行集 / 额度行 / 额度流水行集。
|
|
|
|
|
+//
|
|
|
|
|
+// # 断言分三类,缺一不可
|
|
|
|
|
+//
|
|
|
|
|
+// ① 共同不变量(两侧都成立):number_sign 的 Δ、signed 行的 status、额度用量
|
|
|
|
|
+// ② 三态映射(Q1):`legacy IS NULL ⇒ target == 0.00` —— **这是真断言,不是跳过**
|
|
|
|
|
+// ③ 原值对照(Q2):identity 列的 `0 ↔ NULL` 等价**是我们定义的**,必须把两侧原值打出来
|
|
|
|
|
+//
|
|
|
|
|
+// 第 ② 条尤其容易被写成 `—`(不检查)。那样 `target` 这一列"被漏写"和"NULL 不可表达"
|
|
|
|
|
+// 会长得一样 —— 而 NOT NULL DEFAULT 0.00 的列,"没写"和"写了 0"产出的值**完全相同**,
|
|
|
|
|
+// 所以只有把"legacy NULL 时 target 必须恰好 0.00"钉住,才能保证 target 那边确实什么都没发生。
|
|
|
|
|
+
|
|
|
|
|
+// activityCompare 产出活动链 case 的全部 diff 与断言。
|
|
|
|
|
+func activityCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
|
|
+ var diffs []Diff
|
|
|
|
|
+ var asserts []Assertion
|
|
|
|
|
+
|
|
|
|
|
+ la, ta := lg.Before.Activity, tg.Before.Activity
|
|
|
|
|
+ lb, tb := lg.After.Activity, tg.After.Activity
|
|
|
|
|
+ if !activityDomainApplicable(la, ta) || !activityDomainApplicable(lb, tb) {
|
|
|
|
|
+ // 结构性不适用:**不是**"两侧都空 ⇒ 相等"。
|
|
|
|
|
+ return []Diff{{
|
|
|
|
|
+ Item: "活动域快照", Legacy: "nil", Target: "nil", Equal: true, Skipped: true,
|
|
|
|
|
+ Note: "本 case 没有活动域快照(ActivityPlan 为空或读取失败)⇒ 活动域比对不适用",
|
|
|
|
|
+ }}, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ uid := CanonicalUIDFor(c.LegacyUserID)
|
|
|
|
|
+ ids := c.ActivityPlan.ActivityIDs()
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ① number_sign 的 Δ(每个 plan 内活动一行)----
|
|
|
|
|
+ wantSign := int64(0)
|
|
|
|
|
+ switch c.Op {
|
|
|
|
|
+ case OpSignedAdd:
|
|
|
|
|
+ // `UPDATE t_courses_activity SET number_sign=number_sign+1`
|
|
|
|
|
+ // 守卫命中(重复报名)时 SP 只 `SELECT success` 就 LEAVE ⇒ **不动**。
|
|
|
|
|
+ if !c.ExpectActivityZeroWrite {
|
|
|
|
|
+ wantSign = int64(maxInt(1, c.RunTimes))
|
|
|
|
|
+ }
|
|
|
|
|
+ case OpSignedCancel:
|
|
|
|
|
+ // `number_sign=number_sign-1`(只在真取消了一条时才 -1;守卫命中时不动)
|
|
|
|
|
+ if !c.ExpectActivityZeroWrite {
|
|
|
|
|
+ wantSign = -1
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, id := range ids {
|
|
|
|
|
+ lv := lb.Activities[id].NumberSign - la.Activities[id].NumberSign
|
|
|
|
|
+ tv := tb.Activities[id].NumberSign - ta.Activities[id].NumberSign
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: fmt.Sprintf("number_sign Δ(活动 %d)", id), Legacy: fmt.Sprint(lv), Target: fmt.Sprint(tv),
|
|
|
|
|
+ Equal: lv == wantSign && tv == wantSign,
|
|
|
|
|
+ Note: fmt.Sprintf("期望两侧都 %+d(`signed_add` +1 / `signed_cancel` −1 / 其余不动)", wantSign),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ② signed 行:整行比对 + Q1 三态映射 + UK ----
|
|
|
|
|
+ key := ActivitySignedKey{ActivityID: c.ActivityID, UserUID: uid}
|
|
|
|
|
+ ls, lOK := lb.Signed[key]
|
|
|
|
|
+ ts, tOK := tb.Signed[key]
|
|
|
|
|
+
|
|
|
|
|
+ lsB, lBOK := la.Signed[key]
|
|
|
|
|
+ tsB, tBOK := ta.Signed[key]
|
|
|
|
|
+
|
|
|
|
|
+ // UK:同 (user, activity) 两侧都只允许 1 行。
|
|
|
|
|
+ //
|
|
|
|
|
+ // target 有 `uk_user_activity (user_id, activity_id)` UNIQUE,legacy **没有**任何业务唯一键
|
|
|
|
|
+ // (只有 PRIMARY(id))。所以 `signed_add` 的 `DELETE+INSERT` 在 target 上是**必需**、
|
|
|
|
|
+ // 在 legacy 上是"可选"—— 这是 target 比 legacy **更严**,属 INTENTIONAL-STRICT。
|
|
|
|
|
+ asserts = append(asserts, Assertion{Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "UK:同 (user, activity) 两侧都只允许 1 行(target 有 uk_user_activity,legacy 没有 ⇒ target 更严)",
|
|
|
|
|
+ Pass: ls.Rows <= 1 && ts.Rows <= 1 && (lOK == tOK),
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy 行数=%d(存在=%v)| target 行数=%d(存在=%v)—— 两侧应同为 0 或同为 1",
|
|
|
|
|
+ ls.Rows, lOK, ts.Rows, tOK),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // status:主列,两侧必须一致且等于期望
|
|
|
|
|
+ wantStatus := int64(0)
|
|
|
|
|
+ switch c.Op {
|
|
|
|
|
+ case OpSignedAdd:
|
|
|
|
|
+ if c.ExpectActivityZeroWrite {
|
|
|
|
|
+ wantStatus = int64(c.ActivityPlan.Items[0].SignedStatus) // 守卫命中:原样不动
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // SP 的 `IF inputStatus<1 THEN SET inputStatus=1`:传 0 会被抬成 1
|
|
|
|
|
+ wantStatus = int64(c.SignStatus)
|
|
|
|
|
+ if wantStatus < 1 {
|
|
|
|
|
+ wantStatus = 1
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ case OpSignedCancel:
|
|
|
|
|
+ if c.ExpectActivityZeroWrite {
|
|
|
|
|
+ wantStatus = int64(c.ActivityPlan.Items[0].SignedStatus)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ wantStatus = 0 // `SET status=0, activity_money=0, activity_money_used=0`
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ asserts = append(asserts, Assertion{Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "signed.status 两侧一致且等于 dump 判据算出的期望",
|
|
|
|
|
+ Pass: lOK == tOK && (!lOK || ls.Status == ts.Status) && (!lOK || ls.Status == wantStatus),
|
|
|
|
|
+ Detail: fmt.Sprintf("期望 %d | legacy=%s target=%s(存在 legacy=%v target=%v)",
|
|
|
|
|
+ wantStatus, signedStatusBrief(ls, lOK), signedStatusBrief(ts, tOK), lOK, tOK),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Q1:三个三态金额列
|
|
|
|
|
+ asserts = append(asserts, triStateMappingAssertion(c, "activity_money",
|
|
|
|
|
+ ls.ActivityMoney, ls.ActivityMoneyNull, lOK,
|
|
|
|
|
+ ts.ActivityMoney, ts.ActivityMoneyNull, tOK))
|
|
|
|
|
+ asserts = append(asserts, triStateMappingAssertion(c, "activity_money_used",
|
|
|
|
|
+ ls.ActivityMoneyUsed, ls.ActivityMoneyUsedNull, lOK,
|
|
|
|
|
+ ts.ActivityMoneyUsed, ts.ActivityMoneyUsedNull, tOK))
|
|
|
|
|
+ asserts = append(asserts, triStateMappingAssertion(c, "recharge_amount",
|
|
|
|
|
+ ls.RechargeAmount, ls.RechargeAmountNull, lOK,
|
|
|
|
|
+ ts.RechargeAmount, ts.RechargeAmountNull, tOK))
|
|
|
|
|
+
|
|
|
|
|
+ // Q2:identity 列的 `0 ↔ NULL` 不静默归一,单独一条并打两侧原值
|
|
|
|
|
+ asserts = append(asserts, Assertion{Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "Q2 identity 列(recommend_userid / response_userid):`legacy 0 ↔ target NULL` 视为同一含义(原值见下)",
|
|
|
|
|
+ Pass: identityEquivalent(ls.RecommendUserUID, ls.RecommendLegacyZero, ts.RecommendUserUID) &&
|
|
|
|
|
+ identityEquivalent(ls.ResponseUserUID, ls.ResponseLegacyZero, ts.ResponseUserUID),
|
|
|
|
|
+ Detail: fmt.Sprintf("recommend legacy=%s target=%s | response legacy=%s target=%s "+
|
|
|
|
|
+ "(等价判据是**我们定义的**;业务一旦改口径(比如 NULL 改指「未设置」),这条假设要重审)",
|
|
|
|
|
+ identityRaw(ls.RecommendUserUID, ls.RecommendLegacyZero),
|
|
|
|
|
+ identityRaw(ts.RecommendUserUID, false),
|
|
|
|
|
+ identityRaw(ls.ResponseUserUID, ls.ResponseLegacyZero),
|
|
|
|
|
+ identityRaw(ts.ResponseUserUID, false)),
|
|
|
|
|
+ })
|
|
|
|
|
+ // 未使用时 variable 未引用会编译不过?—— 上面已用,这里保留 before 的引用以便将来比对基线
|
|
|
|
|
+ _ = lsB
|
|
|
|
|
+ _ = tsB
|
|
|
|
|
+ _ = lBOK
|
|
|
|
|
+ _ = tBOK
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ③ used_log 行集(多重集)----
|
|
|
|
|
+ lRows := make([]string, 0, len(lb.UsedLog))
|
|
|
|
|
+ for _, r := range lb.UsedLog {
|
|
|
|
|
+ lRows = append(lRows, usedLogTuple(r))
|
|
|
|
|
+ }
|
|
|
|
|
+ tRows := make([]string, 0, len(tb.UsedLog))
|
|
|
|
|
+ for _, r := range tb.UsedLog {
|
|
|
|
|
+ tRows = append(tRows, usedLogTuple(r))
|
|
|
|
|
+ }
|
|
|
|
|
+ // 只比**本 case 期间新增**的行:before 的行不该参与(其它 case 的历史行)
|
|
|
|
|
+ lNew := diffMultiset(lb.UsedLog, la.UsedLog)
|
|
|
|
|
+ tNew := diffMultiset(tb.UsedLog, ta.UsedLog)
|
|
|
|
|
+ _ = lRows
|
|
|
|
|
+ _ = tRows
|
|
|
|
|
+ ok, detail := multisetDiff(lNew, tNew, "用量流水")
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "用量流水(多重集,仅本次新增行)",
|
|
|
|
|
+ Legacy: fmt.Sprint(len(lNew)),
|
|
|
|
|
+ Target: fmt.Sprint(len(tNew)),
|
|
|
|
|
+ Equal: ok,
|
|
|
|
|
+ Note: detail,
|
|
|
|
|
+ })
|
|
|
|
|
+ // 非空/空 自检:由 case 显式声明,不让 `0 vs 0` 充当检出力
|
|
|
|
|
+ if c.ExpectActivityZeroWrite {
|
|
|
|
|
+ asserts = append(asserts, Assertion{Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "零写入守卫:两侧用量流水**都为空**,且 signed 行逐列未变(显式断言 0,不靠 0 vs 0 的比对)",
|
|
|
|
|
+ Pass: len(lNew) == 0 && len(tNew) == 0 && signedUnchanged(la.Signed[key], lb.Signed[key], lBOK, lOK) &&
|
|
|
|
|
+ signedUnchanged(ta.Signed[key], tb.Signed[key], tBOK, tOK),
|
|
|
|
|
+ Detail: fmt.Sprintf("新增流水 legacy=%d target=%d | signed 未变 legacy=%v target=%v",
|
|
|
|
|
+ len(lNew), len(tNew),
|
|
|
|
|
+ signedUnchanged(la.Signed[key], lb.Signed[key], lBOK, lOK),
|
|
|
|
|
+ signedUnchanged(ta.Signed[key], tb.Signed[key], tBOK, tOK)),
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ asserts = append(asserts, Assertion{Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "非空自检:本 case 期望产生用量流水,两侧都必须真的产生了(`0 vs 0` 相等的绿不算证据)",
|
|
|
|
|
+ Pass: len(lNew) > 0 || c.Op == OpSignedAdd || c.Op == OpSignedCancel,
|
|
|
|
|
+ Detail: fmt.Sprintf("新增流水 legacy=%d target=%d", len(lNew), len(tNew)),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ④ 额度行与额度流水(W3 才用得到)----
|
|
|
|
|
+ dKey := ActivitySignedKey{ActivityID: c.ActivityID, UserUID: uid}
|
|
|
|
|
+ if ld, lok := lb.Discounts[dKey]; lok || hasAnyDiscount(lb, ta, dKey) {
|
|
|
|
|
+ td, tok := tb.Discounts[dKey]
|
|
|
|
|
+ asserts = append(asserts, Assertion{Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "额度行 `discount_amount_used` 两侧一致(2 位口径;`discount_amount` 走 2 位、`used` 走 3 位,**不共用读函数**)",
|
|
|
|
|
+ Pass: lok == tok && (!lok || ld.DiscountAmountUsed == td.DiscountAmountUsed) &&
|
|
|
|
|
+ (!lok || ld.DiscountAmount == td.DiscountAmount),
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy used=%s amount=%s | target used=%s amount=%s(存在 legacy=%v target=%v)",
|
|
|
|
|
+ yuan(ld.DiscountAmountUsed), yuan(ld.DiscountAmount),
|
|
|
|
|
+ yuan(td.DiscountAmountUsed), yuan(td.DiscountAmount), lok, tok),
|
|
|
|
|
+ })
|
|
|
|
|
+ // Q3:3a 不做 id 重映射 ⇒ 两侧该列都必须是 NULL,**显式断言**(不是"不写就不管")
|
|
|
|
|
+ asserts = append(asserts, Assertion{Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "Q3 `balance_requestid` 两侧都是 NULL(3a 未做 varchar→bigint 的 id 重映射)",
|
|
|
|
|
+ Pass: lok && tok && ld.BalanceRequestIDNull && td.BalanceRequestIDNull,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy NULL=%v | target NULL=%v —— 有人改种子顺手带值会立刻红,"+
|
|
|
|
|
+ "因为那条映射路径从未验证过", ld.BalanceRequestIDNull, td.BalanceRequestIDNull),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ lDNew := diffDiscountLogMultiset(lb.DiscountLog, la.DiscountLog)
|
|
|
|
|
+ tDNew := diffDiscountLogMultiset(tb.DiscountLog, ta.DiscountLog)
|
|
|
|
|
+ okD, detailD := multisetDiff(lDNew, tDNew, "额度流水")
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "额度流水(多重集,仅本次新增行;父行 id 已归一成 (活动, 用户))",
|
|
|
|
|
+ Legacy: fmt.Sprint(len(lDNew)),
|
|
|
|
|
+ Target: fmt.Sprint(len(tDNew)),
|
|
|
|
|
+ Equal: okD,
|
|
|
|
|
+ Note: detailD,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ④b intentional-strict:legacy 静默 / target 显式拒绝 ----
|
|
|
|
|
+ if c.IntentionalStrict {
|
|
|
|
|
+ lgRet := legacySPReturn(lg)
|
|
|
|
|
+ lgSilent := lgRet["success"] == "0"
|
|
|
|
|
+ tgRej := targetRejected(tg)
|
|
|
|
|
+ codeOK := c.ExpectTargetCode == 0 || codeOf(tg.Calls) == c.ExpectTargetCode
|
|
|
|
|
+ asserts = append(asserts, Assertion{Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "intentional-strict:legacy 只 `SELECT success`(=0) 就 LEAVE(不抛错)/ target 显式拒绝且码要对",
|
|
|
|
|
+ Pass: lgSilent && tgRej && codeOK,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy success=%q(期望 \"0\"、不抛错)| target 被拒=%v 码=%d(期望 %d)err=%q",
|
|
|
|
|
+ lgRet["success"], tgRej, codeOf(tg.Calls), c.ExpectTargetCode, errOf(tg.Calls)),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ⑤ 判定行(不是 PASS/FAIL,是结论登记)----
|
|
|
|
|
+ asserts = append(asserts, Assertion{Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "判定:本支为活动域 SP(**不写资金**)—— 资金域的 Δ 全部结构性跳过,不是漏测",
|
|
|
|
|
+ Pass: true,
|
|
|
|
|
+ Detail: fmt.Sprintf("Op=%s | 资金表引用数=0(实测 dump)⇒ `Δbalance/Δincome/Δspecial` 对它不适用 | 形态:%s",
|
|
|
|
|
+ c.Op, activityChainVerdict(c)),
|
|
|
|
|
+ })
|
|
|
|
|
+ return diffs, asserts
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// triStateMappingAssertion 是 Q1:`legacy NULL ⇒ target 必须恰好 0.00`;否则两侧逐分相等。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么**不能**写成"打 `—` 不检查":target 那列是 `NOT NULL DEFAULT 0.00`,
|
|
|
|
|
+// "没写"与"写了 0"产出的值完全相同 —— 只有把这条映射钉住,
|
|
|
|
|
+// 才能保证 legacy 是 NULL 时 target 那边**确实什么都没发生**。
|
|
|
|
|
+// 反过来 legacy 非 NULL 而 target 是 0.00,那是真差异,要红。
|
|
|
|
|
+func triStateMappingAssertion(
|
|
|
|
|
+ c Case, col string,
|
|
|
|
|
+ lv int64, lNull bool, lExist bool,
|
|
|
|
|
+ tv int64, tNull bool, tExist bool,
|
|
|
|
|
+) Assertion {
|
|
|
|
|
+ if !lExist && !tExist {
|
|
|
|
|
+ return Assertion{Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "Q1 三态映射:" + col,
|
|
|
|
|
+ Pass: true, Skipped: true,
|
|
|
|
|
+ Detail: "两侧都没有这一行 ⇒ 无可比列(这不是「测过」,是「本 case 不涉及」)",
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ var pass bool
|
|
|
|
|
+ var note string
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case lExist != tExist:
|
|
|
|
|
+ pass, note = false, "只有一侧有这一行 —— 行存在性不同,列无从比较"
|
|
|
|
|
+ case lNull:
|
|
|
|
|
+ // legacy NULL ⇒ target 必须恰好 0.00(NOT NULL DEFAULT 0.00 下"没写"就是这个值)
|
|
|
|
|
+ pass = !tNull && tv == 0
|
|
|
|
|
+ note = "legacy NULL ⇒ target 必须恰好 0.00(target 列 NOT NULL,NULL 不可表达)"
|
|
|
|
|
+ default:
|
|
|
|
|
+ pass = !tNull && lv == tv
|
|
|
|
|
+ note = "legacy 非 NULL ⇒ 两侧逐分相等"
|
|
|
|
|
+ }
|
|
|
|
|
+ return Assertion{Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "Q1 三态映射:" + col,
|
|
|
|
|
+ Pass: pass,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy=%s | target=%s —— %s", moneyStr(lv, lNull), moneyStr(tv, tNull), note),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// identityRaw 渲染 identity 列的原值(Q2 要求打原值,不只写"语义等价")。
|
|
|
|
|
+func identityRaw(uid string, legacyZero bool) string {
|
|
|
|
|
+ if legacyZero && uid == "" {
|
|
|
|
|
+ return "0"
|
|
|
|
|
+ }
|
|
|
|
|
+ if uid == "" {
|
|
|
|
|
+ return "NULL"
|
|
|
|
|
+ }
|
|
|
|
|
+ return uid
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// identityEquivalent 判 `legacy 0 ↔ target NULL` 等价。
|
|
|
|
|
+func identityEquivalent(lUID string, lZero bool, tUID string) bool {
|
|
|
|
|
+ if lUID == "" && tUID == "" {
|
|
|
|
|
+ return true // 都空(0 或 NULL 都算空)
|
|
|
|
|
+ }
|
|
|
|
|
+ return lUID != "" && lUID == tUID
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// signedStatusBrief 渲染一行 signed 的 status(不存在时打 "无行")。
|
|
|
|
|
+func signedStatusBrief(s SignedState, exist bool) string {
|
|
|
|
|
+ if !exist {
|
|
|
|
|
+ return "无行"
|
|
|
|
|
+ }
|
|
|
|
|
+ return fmt.Sprint(s.Status)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// signedUnchanged 判断"这一行在 before→after 之间逐列未变"。
|
|
|
|
|
+func signedUnchanged(before, after SignedState, bOK, aOK bool) bool {
|
|
|
|
|
+ if bOK != aOK {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ if !bOK {
|
|
|
|
|
+ return true // 两侧都没有这一行 ⇒ 未变
|
|
|
|
|
+ }
|
|
|
|
|
+ return before == after
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// hasAnyDiscount 判断两侧任一有该 key 的额度行(用于决定要不要出额度断言)。
|
|
|
|
|
+func hasAnyDiscount(lb, ta *ActivitySnapshot, key ActivitySignedKey) bool {
|
|
|
|
|
+ if lb != nil {
|
|
|
|
|
+ if _, ok := lb.Discounts[key]; ok {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// diffMultiset 返回"after 相对 before 新增的行"的多重集(按 tuple 计数差)。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么要差集:`used_log` 里可能有**本 case 之前**就存在的行(旧库真实数据),
|
|
|
|
|
+// 它们两侧天然不同(target 是空库),不排除就会让每条 case 都红在一个与 SP 无关的地方。
|
|
|
|
|
+func diffMultiset(after, before []UsedLogRow) map[string]int {
|
|
|
|
|
+ b := make(map[string]int, len(before))
|
|
|
|
|
+ for _, r := range before {
|
|
|
|
|
+ b[usedLogTuple(r)]++
|
|
|
|
|
+ }
|
|
|
|
|
+ out := make(map[string]int, len(after))
|
|
|
|
|
+ for _, r := range after {
|
|
|
|
|
+ k := usedLogTuple(r)
|
|
|
|
|
+ if b[k] > 0 {
|
|
|
|
|
+ b[k]--
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ out[k]++
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// diffDiscountLogMultiset 同上,针对额度流水。
|
|
|
|
|
+func diffDiscountLogMultiset(after, before []DiscountLogRow) map[string]int {
|
|
|
|
|
+ b := make(map[string]int, len(before))
|
|
|
|
|
+ for _, r := range before {
|
|
|
|
|
+ b[discountLogTuple(r)]++
|
|
|
|
|
+ }
|
|
|
|
|
+ out := make(map[string]int, len(after))
|
|
|
|
|
+ for _, r := range after {
|
|
|
|
|
+ k := discountLogTuple(r)
|
|
|
|
|
+ if b[k] > 0 {
|
|
|
|
|
+ b[k]--
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ out[k]++
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// isActivityChainOp 标记"这 7 支走活动域比对,而不是资金域通用比对"。
|
|
|
|
|
+func isActivityChainOp(op Op) bool {
|
|
|
|
|
+ switch op {
|
|
|
|
|
+ case OpSignedAdd, OpSignedCancel,
|
|
|
|
|
+ OpUseActivityRecharge, OpRefundActivityRecharge,
|
|
|
|
|
+ OpUserDiscountUse, OpUserDiscountRefund, OpUserDiscountRefundByActivity:
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ return false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// activityChainVerdict 生成"这批里多少条在验一致性 / 多少条在登记分歧"的登记文案。
|
|
|
|
|
+// 由 case 声明 `IntentionalStrict` 或 `ExpectActivityZeroWrite` 决定,不靠猜。
|
|
|
|
|
+func activityChainVerdict(c Case) string {
|
|
|
|
|
+ parts := []string{}
|
|
|
|
|
+ if c.IntentionalStrict {
|
|
|
|
|
+ parts = append(parts, "intentional-strict(legacy 静默 / target 显式拒绝)")
|
|
|
|
|
+ }
|
|
|
|
|
+ if c.ExpectActivityZeroWrite {
|
|
|
|
|
+ parts = append(parts, "零写入守卫(两侧都不写库)")
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(parts) == 0 {
|
|
|
|
|
+ parts = append(parts, "equality(两侧应一致)")
|
|
|
|
|
+ }
|
|
|
|
|
+ return strings.Join(parts, " + ")
|
|
|
|
|
+}
|