|
|
@@ -23,6 +23,9 @@ func c1Assertions(
|
|
|
// ① 两侧看到的校区元数据必须与 case 声明一致(自营/加盟走不同分支的根源)
|
|
|
out = append(out, campusMetaAssertion(c, lg, tg))
|
|
|
out = append(out, campusAccountAssertion(c, lg, tg))
|
|
|
+ if c.NoCampusAccount {
|
|
|
+ out = append(out, campusAccountCreatedAssertion(c, lg, tg))
|
|
|
+ }
|
|
|
|
|
|
// ② 活动失效范围(清零)或报名状态归 0(取消活动)
|
|
|
if c.ActivityPlan != nil {
|
|
|
@@ -132,21 +135,46 @@ func campusMetaAssertion(c Case, lg, tg *SideResult) Assertion {
|
|
|
//
|
|
|
// 没有这条,"读不到账户"会被读成 0 余额,两侧都是 0 → 基线相等"空过",
|
|
|
// 整个 case 看起来在跑、其实什么都没测。
|
|
|
+//
|
|
|
+// 期望值由 case **显式声明**(CampusMissing / NoCampusAccount),不是"看实测是什么就认什么"。
|
|
|
func campusAccountAssertion(c Case, lg, tg *SideResult) Assertion {
|
|
|
- want := !c.CampusMissing
|
|
|
+ want := !c.CampusMissing && !c.NoCampusAccount
|
|
|
ok := lg.Before.HasCampusAccount == want && tg.Before.HasCampusAccount == want
|
|
|
word := "存在"
|
|
|
+ reason := ""
|
|
|
if !want {
|
|
|
word = "不存在"
|
|
|
+ if c.CampusMissing {
|
|
|
+ reason = "(CampusMissing:校区本身就不存在)"
|
|
|
+ } else {
|
|
|
+ reason = "(NoCampusAccount:校区存在但该用户没有账户行)"
|
|
|
+ }
|
|
|
}
|
|
|
return Assertion{
|
|
|
- Name: "校区账户行" + word + "(CampusMissing 时故意不存在)",
|
|
|
+ Name: "调用前:校区账户行" + word + reason,
|
|
|
Pass: ok,
|
|
|
Detail: fmt.Sprintf("legacy 存在=%v | target 存在=%v(期望都为 %v)",
|
|
|
lg.Before.HasCampusAccount, tg.Before.HasCampusAccount, want),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// campusAccountCreatedAssertion 断言"调用后校区账户行确实被建出来了"。
|
|
|
+//
|
|
|
+// 只在 NoCampusAccount 用例上有意义:SP8 的 `IF logCount=0 THEN INSERT INTO t_user_expense_school`
|
|
|
+// 这段的全部作用就是"先建行再扣"。如果 Go 侧只做 UPDATE 不 UPSERT,
|
|
|
+// 那条 UPDATE 会静默影响 0 行 —— 余额、流水全都对不上,但**错误会被误读成"金额算错"**。
|
|
|
+// 单独把"行被建出来了"钉成一条,红的时候一眼知道是建户分支没走到。
|
|
|
+func campusAccountCreatedAssertion(c Case, lg, tg *SideResult) Assertion {
|
|
|
+ ok := lg.After.HasCampusAccount && tg.After.HasCampusAccount
|
|
|
+ return Assertion{
|
|
|
+ Name: "调用后:校区账户行被建出来(dump 的 `IF logCount=0 THEN INSERT` 分支)",
|
|
|
+ Pass: ok,
|
|
|
+ Detail: fmt.Sprintf("legacy 存在=%v 余额=%s | target 存在=%v 余额=%s(都应为 true)",
|
|
|
+ lg.After.HasCampusAccount, yuan(lg.After.Campus.Balance),
|
|
|
+ tg.After.HasCampusAccount, yuan(tg.After.Campus.Balance)),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// signedAssertion 断言"调用后的报名状态 = 按 dump 判据算出的期望"(整张 map 全等)。
|
|
|
func signedAssertion(c Case, lg, tg *SideResult) Assertion {
|
|
|
var want map[int64]int32
|
|
|
@@ -185,6 +213,196 @@ func spReturnAssertion(c Case, lg *SideResult) Assertion {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// ---------- 请求号 33 字符(INTENTIONAL-WIDEN) ----------
|
|
|
+
|
|
|
+// requestIDOverflowCompare 是 `SpecialRequestIDOverflow` 的专用比对。
|
|
|
+//
|
|
|
+// 为什么不能走通用比对:这条 case 的结论**本来就是"两侧不相等"**——
|
|
|
+// legacy 在 `INSERT t_user_expense_log` 那一步被 varchar(32) 挡住(1406),
|
|
|
+// 而它前面的两条 UPDATE 已经执行过(MySQL 只回滚**失败的那条语句**,不回滚同一次 CALL 里的前置语句),
|
|
|
+// 所以 legacy 的终态是"钱扣了、流水没有";target 则正常落库。
|
|
|
+//
|
|
|
+// 这条 case 要钉死四件事:
|
|
|
+// 1. legacy **确实**是 1406(而不是别的错),且点到了 logid 列 —— 证明是列宽,不是逻辑
|
|
|
+// 2. legacy 钱**已经扣了**、但流水 0 条 —— 把"半途失败留下无凭证扣款"这个真实缺陷摆出来
|
|
|
+// 3. target 成功,且 33 字符请求号**完整落库**(没被静默截断)—— 这是 WIDEN 的直接证据
|
|
|
+// 4. 两侧 Δbalance 相同 —— 钱的结果一致,差异只在"有没有凭证"
|
|
|
+func requestIDOverflowCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
+ var diffs []Diff
|
|
|
+ var asserts []Assertion
|
|
|
+
|
|
|
+ lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
|
|
|
+ tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
|
|
|
+ lgErr := errOf(lg.Calls)
|
|
|
+ dGlobal := sub(lg.After.Global, lg.Before.Global)
|
|
|
+ tGlobal := sub(tg.After.Global, tg.Before.Global)
|
|
|
+ dCampus := sub(lg.After.Campus, lg.Before.Campus)
|
|
|
+ tCampus := sub(tg.After.Campus, tg.Before.Campus)
|
|
|
+ want := -c.AmountCents
|
|
|
+
|
|
|
+ diffs = append(diffs,
|
|
|
+ Diff{Item: "请求号长度", Legacy: fmt.Sprint(len(c.RequestID)) + " 字符 → logid varchar(32)",
|
|
|
+ Target: fmt.Sprint(len(c.RequestID)) + " 字符 → request_id varchar(64)",
|
|
|
+ Equal: true, Note: "列宽不同是本条的**结论**,不是被测行为"},
|
|
|
+ Diff{Item: "legacy 是否成功", Legacy: fmt.Sprint(lgOK), Target: "N/A",
|
|
|
+ Equal: !lgOK, Note: "期望 false:1406(logid 放不下)"},
|
|
|
+ Diff{Item: "target 是否成功", Legacy: "N/A", Target: fmt.Sprint(tgOK),
|
|
|
+ Equal: tgOK, Note: "期望 true:varchar(64) 放得下"},
|
|
|
+ Diff{Item: "Δbalance(两侧都应为 −金额)",
|
|
|
+ Legacy: yuan(dGlobal.Balance), Target: yuan(tGlobal.Balance),
|
|
|
+ Equal: dGlobal.Balance == want && tGlobal.Balance == want,
|
|
|
+ Note: "legacy 的两条 UPDATE 在失败的 INSERT **之前**,所以钱照扣"},
|
|
|
+ Diff{Item: "流水条数", Legacy: fmt.Sprint(len(lg.After.Logs)), Target: fmt.Sprint(len(tg.After.Logs)),
|
|
|
+ Equal: true, Note: "**本来就不同**:legacy 0 条(写 log 失败)/ target 1 条 —— 这正是要记录的差异"},
|
|
|
+ )
|
|
|
+
|
|
|
+ asserts = append(asserts,
|
|
|
+ Assertion{
|
|
|
+ Name: "前提:入参请求号 **确实 33 字符**(> legacy 的 32,否则本条退化成普通 case)",
|
|
|
+ Pass: len(c.RequestID) == 33,
|
|
|
+ Detail: fmt.Sprintf("len(requestId)=%d(期望 33)", len(c.RequestID)),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: "legacy 报的是 **1406(Data too long)且点名字段 logid** —— 证明是列宽不是逻辑",
|
|
|
+ Pass: !lgOK && strings.Contains(lgErr, "1406") && strings.Contains(strings.ToLower(lgErr), "logid"),
|
|
|
+ Detail: fmt.Sprintf("legacy success=%v err=%q", lgOK, lgErr),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: "legacy **钱已扣、流水 0 条**(同一次 CALL 里前置 UPDATE 不回滚)—— 缺陷在 legacy 一侧",
|
|
|
+ Pass: dGlobal.Balance == want && dCampus.Balance == want && len(lg.After.Logs) == 0,
|
|
|
+ Detail: fmt.Sprintf("Δ全局=%s Δ校区=%s 流水=%d 条(期望各 %s、0 条)",
|
|
|
+ yuan(dGlobal.Balance), yuan(dCampus.Balance), len(lg.After.Logs), yuan(want)),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: "target 成功且 **33 字符请求号完整落库**(没被静默截断 / 没报错)",
|
|
|
+ Pass: tgOK && len(tg.After.Logs) == 1 &&
|
|
|
+ tg.After.Logs[0].RequestID == c.RequestID && tg.After.Logs[0].LogID == c.RequestID &&
|
|
|
+ len(tg.After.Logs[0].RequestID) == len(c.RequestID),
|
|
|
+ Detail: fmt.Sprintf("target success=%v 流水=%d 条 request_id 长度=%d log_id 长度=%d(期望 %v/1/%d/%d)",
|
|
|
+ tgOK, len(tg.After.Logs), len(reqIDOf(tg.After.Logs)), len(logIDOf(tg.After.Logs)),
|
|
|
+ true, len(c.RequestID), len(c.RequestID)),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: "两侧 Δbalance 相同(钱的结果一致,差异只在**有没有凭证**)",
|
|
|
+ Pass: dGlobal.Balance == tGlobal.Balance && dCampus.Balance == tCampus.Balance,
|
|
|
+ Detail: fmt.Sprintf("全局 legacy=%s target=%s | 校区 legacy=%s target=%s",
|
|
|
+ yuan(dGlobal.Balance), yuan(tGlobal.Balance),
|
|
|
+ yuan(dCampus.Balance), yuan(tCampus.Balance)),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return diffs, asserts
|
|
|
+}
|
|
|
+
|
|
|
+// reqIDOf / logIDOf 取第一条流水的请求号(没有流水时返回空串,长度 0)。
|
|
|
+func reqIDOf(logs []LogRow) string {
|
|
|
+ if len(logs) == 0 {
|
|
|
+ return ""
|
|
|
+ }
|
|
|
+ return logs[0].RequestID
|
|
|
+}
|
|
|
+
|
|
|
+func logIDOf(logs []LogRow) string {
|
|
|
+ if len(logs) == 0 {
|
|
|
+ return ""
|
|
|
+ }
|
|
|
+ return logs[0].LogID
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 校区账户不存在 → 先建行 → 金额列 NULL(LEGACY-DEFECT) ----------
|
|
|
+
|
|
|
+// campusRowNullCompare 是 `SpecialCampusRowNullBalance` 的专用比对。
|
|
|
+//
|
|
|
+// 背景(全部来自 `information_schema` + SP8 正文,不是猜的):
|
|
|
+//
|
|
|
+// legacy `t_user_expense_school`.{balance,spend,total_income,…} = decimal(10,3) **NULL DEFAULT NULL**
|
|
|
+// legacy `t_user_expense`.{balance,total_income} = float(10,3) **DEFAULT 0.000**
|
|
|
+// SP8:`IF logCount=0 THEN INSERT INTO t_user_expense_school(userid,schoolid,campusid)` —— 只写三列
|
|
|
+//
|
|
|
+// 于是:新行金额全 NULL → `SET balance=balance-inputMoney` 仍是 NULL(NULL 传染)→
|
|
|
+// **校区那一分钱都没扣**,但 `INSERT t_user_expense_log(type=209, money=-inputMoney)` 照样写。
|
|
|
+// 全局表因为 DEFAULT 0.000 反而真的扣成 −inputMoney ⇒ **同一笔业务,两张表自相矛盾**。
|
|
|
+//
|
|
|
+// target 侧同名列是 `NOT NULL DEFAULT 0.00` → 建行即 0 → 真扣成 −inputMoney,两表一致。
|
|
|
+//
|
|
|
+// 结论:这是 **legacy 的数据缺陷**,target 侧的行为是正确的那一个,**不跟**。
|
|
|
+// 所以这条 case 不断言"两侧相等",只断言"两侧各自确实是这样"。
|
|
|
+func campusRowNullCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
+ var diffs []Diff
|
|
|
+ var asserts []Assertion
|
|
|
+
|
|
|
+ want := -c.AmountCents
|
|
|
+ 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)
|
|
|
+
|
|
|
+ diffs = append(diffs,
|
|
|
+ Diff{Item: "调用前校区账户行", Legacy: fmt.Sprint(lg.Before.HasCampusAccount),
|
|
|
+ Target: fmt.Sprint(tg.Before.HasCampusAccount), Equal: true,
|
|
|
+ Note: "都应为 false(本条的**前提**,由 case 显式声明 NoCampusAccount)"},
|
|
|
+ Diff{Item: "调用后校区行 · balance",
|
|
|
+ Legacy: nullWord(lg.After), Target: yuan(tg.After.Campus.Balance), Equal: true,
|
|
|
+ Note: "**本来就不同**:legacy 建成 NULL 且扣不动;target 真扣成 −金额"},
|
|
|
+ Diff{Item: "调用后全局 · Δbalance", Legacy: yuan(dGlobal.Balance), Target: yuan(tGlobal.Balance),
|
|
|
+ Equal: true, Note: "两侧全局都扣了(全局列有 DEFAULT 0.000)"},
|
|
|
+ Diff{Item: "流水条数", Legacy: fmt.Sprint(len(lg.After.Logs)), Target: fmt.Sprint(len(tg.After.Logs)),
|
|
|
+ Equal: true, Note: "都是 1 条 type=209 —— legacy 是「没扣钱却写了凭证」"},
|
|
|
+ )
|
|
|
+
|
|
|
+ asserts = append(asserts,
|
|
|
+ Assertion{
|
|
|
+ Name: "前提:两侧调用前**都**没有该校区账户行(否则本条测不到建行分支)",
|
|
|
+ Pass: !lg.Before.HasCampusAccount && !tg.Before.HasCampusAccount,
|
|
|
+ Detail: fmt.Sprintf("legacy 存在=%v | target 存在=%v(都应为 false)",
|
|
|
+ lg.Before.HasCampusAccount, tg.Before.HasCampusAccount),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: "legacy:校区行**被建出来了**,但金额列仍是 **NULL**(`SET balance=balance-m` 在 NULL 上仍是 NULL)",
|
|
|
+ Pass: lg.After.HasCampusAccount && lg.After.CampusNullBalance,
|
|
|
+ Detail: fmt.Sprintf("legacy 行存在=%v 金额列为 NULL=%v(都应为 true)",
|
|
|
+ lg.After.HasCampusAccount, lg.After.CampusNullBalance),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: "legacy:**全局**被扣了 −金额(`t_user_expense` 的列有 DEFAULT 0.000)—— 同一笔业务两表矛盾",
|
|
|
+ Pass: dGlobal.Balance == want && dGlobal.Income == want,
|
|
|
+ Detail: fmt.Sprintf("Δ全局 balance=%s total_income=%s(期望各 %s)",
|
|
|
+ yuan(dGlobal.Balance), yuan(dGlobal.Income), yuan(want)),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: "legacy:流水照写 1 条 type=209 / money=−金额(**钱没扣,凭证有了**)",
|
|
|
+ Pass: len(lg.After.Logs) == 1 && lg.After.Logs[0].Type == 209 &&
|
|
|
+ lg.After.Logs[0].Money == want,
|
|
|
+ Detail: fmt.Sprintf("legacy %s(期望 type=209 money=%s ×1)", fmtLogs(lg.After.Logs), yuan(want)),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: "target:校区行被建出来且**真扣成 −金额**(列是 NOT NULL DEFAULT 0.00),两表一致",
|
|
|
+ Pass: tg.After.HasCampusAccount && !tg.After.CampusNullBalance &&
|
|
|
+ tg.After.Campus.Balance == want && tCampus.Balance == want && tCampus.Income == want,
|
|
|
+ Detail: fmt.Sprintf("target 行存在=%v 余额=%s Δbalance=%s Δtotal_income=%s(期望 true / %s / %s / %s)",
|
|
|
+ tg.After.HasCampusAccount, yuan(tg.After.Campus.Balance),
|
|
|
+ yuan(tCampus.Balance), yuan(tCampus.Income),
|
|
|
+ yuan(want), yuan(want), yuan(want)),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: "判定:**LEGACY-DEFECT** —— legacy 两表自相矛盾(校区 NULL / 全局 −金额),target 侧正确,不跟",
|
|
|
+ Pass: lg.After.CampusNullBalance && !tg.After.CampusNullBalance &&
|
|
|
+ dGlobal.Balance == tGlobal.Balance && dCampus.Balance != tCampus.Balance,
|
|
|
+ Detail: fmt.Sprintf("legacy 校区 NULL=%v Δ校区=%s | target Δ校区=%s | 两侧全局 Δ 相同=%v",
|
|
|
+ lg.After.CampusNullBalance, yuan(dCampus.Balance), yuan(tCampus.Balance),
|
|
|
+ dGlobal.Balance == tGlobal.Balance),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return diffs, asserts
|
|
|
+}
|
|
|
+
|
|
|
+// nullWord 把"NULL / 具体值"渲染成可读串(报告里 NULL 必须显形,不能显示成 0.00)。
|
|
|
+func nullWord(s SideSnapshot) string {
|
|
|
+ if s.CampusNullBalance {
|
|
|
+ return "NULL(未扣款)"
|
|
|
+ }
|
|
|
+ return yuan(s.Campus.Balance)
|
|
|
+}
|
|
|
+
|
|
|
// ---------- 小工具 ----------
|
|
|
|
|
|
func errSuffix(e string) string {
|