verify.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. package fixture
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "math"
  7. "sort"
  8. "strconv"
  9. "strings"
  10. "time"
  11. )
  12. // 本文件做**逐列核对**,关键设计是「期望值从哪来」:
  13. //
  14. // ⚠ 期望值**不由本包的映射代码产生**(否则映射写错时两边一起错,核对永远绿)。
  15. // 期望值一律由 **MySQL 服务端**算出:`CAST(x AS DECIMAL(12,2))` / `DATE_FORMAT(...)`。
  16. // 于是:
  17. // · Go 侧的取整/换算只要与 DECIMAL 语义不一致,核对立刻红;
  18. // · 变异实验(故意改错映射)必然红——这正是它的意义。
  19. // normalizeMoney 把服务端返回的十进制串规范成 2 位小数字符串,
  20. // 用于「期望 vs 实际」的字符串级比较("-0.00" → "0.00")。
  21. func normalizeMoney(s string) string {
  22. s = strings.TrimSpace(s)
  23. if s == "" {
  24. return ""
  25. }
  26. c, err := DecimalToCents(s)
  27. if err != nil {
  28. return "ERR:" + s
  29. }
  30. if c == 0 {
  31. return "0.00"
  32. }
  33. return CentsToDecimal(c)
  34. }
  35. // thousandthsOf 把十进制串精确转为「千分位」整数,用于 float 陷阱演示中的精确和。
  36. func thousandthsOf(s string) (int64, error) {
  37. raw := strings.TrimSpace(s)
  38. neg := false
  39. if strings.HasPrefix(raw, "-") {
  40. neg = true
  41. raw = raw[1:]
  42. }
  43. intPart, frac := raw, ""
  44. if i := strings.IndexByte(raw, '.'); i >= 0 {
  45. intPart, frac = raw[:i], raw[i+1:]
  46. }
  47. for len(frac) < 3 {
  48. frac += "0"
  49. }
  50. if len(frac) > 3 {
  51. frac = frac[:3]
  52. }
  53. ip := int64(0)
  54. if intPart != "" {
  55. v, err := strconv.ParseInt(intPart, 10, 64)
  56. if err != nil {
  57. return 0, err
  58. }
  59. ip = v
  60. }
  61. fp, err := strconv.ParseInt(frac, 10, 64)
  62. if err != nil {
  63. return 0, err
  64. }
  65. v := ip*1000 + fp
  66. if neg {
  67. v = -v
  68. }
  69. return v, nil
  70. }
  71. // ---------- 期望值读取(服务端口径) ----------
  72. type expExpense struct {
  73. UserID int64
  74. Balance, Spend, Income, Expenditure, Recommended, Special string
  75. }
  76. func readExpectedExpense(ctx context.Context, legacy *sql.DB, userID int64) (*expExpense, error) {
  77. const q = `
  78. SELECT userid,
  79. CAST(balance AS DECIMAL(12,2)),
  80. CAST(spend AS DECIMAL(12,2)),
  81. CAST(total_income AS DECIMAL(12,2)),
  82. CAST(total_expenditure AS DECIMAL(12,2)),
  83. CAST(ifnull(total_recommended_amount,0) AS DECIMAL(12,2)),
  84. CAST(ifnull(special_expenses,0) AS DECIMAL(12,2))
  85. FROM t_user_expense WHERE userid = ?`
  86. var e expExpense
  87. if err := legacy.QueryRowContext(ctx, q, userID).Scan(
  88. &e.UserID, &e.Balance, &e.Spend, &e.Income, &e.Expenditure, &e.Recommended, &e.Special); err != nil {
  89. return nil, err
  90. }
  91. return &e, nil
  92. }
  93. type expSchool struct {
  94. ID, SchoolID, CampusID int64
  95. Balance, Spend, Income, Expenditure, Recommended, Special string
  96. }
  97. func readExpectedSchools(ctx context.Context, legacy *sql.DB, userID int64) ([]expSchool, error) {
  98. const q = `
  99. SELECT id, schoolid, cast(ifnull(campusid,0) as signed),
  100. CAST(balance AS DECIMAL(12,2)),
  101. CAST(spend AS DECIMAL(12,2)),
  102. CAST(total_income AS DECIMAL(12,2)),
  103. CAST(total_expenditure AS DECIMAL(12,2)),
  104. CAST(ifnull(total_recommended_amount,0) AS DECIMAL(12,2)),
  105. CAST(ifnull(special_expenses,0) AS DECIMAL(12,2))
  106. FROM t_user_expense_school WHERE userid = ? ORDER BY id`
  107. rows, err := legacy.QueryContext(ctx, q, userID)
  108. if err != nil {
  109. return nil, err
  110. }
  111. defer rows.Close()
  112. var out []expSchool
  113. for rows.Next() {
  114. var r expSchool
  115. if err := rows.Scan(&r.ID, &r.SchoolID, &r.CampusID,
  116. &r.Balance, &r.Spend, &r.Income, &r.Expenditure, &r.Recommended, &r.Special); err != nil {
  117. return nil, err
  118. }
  119. out = append(out, r)
  120. }
  121. return out, rows.Err()
  122. }
  123. type expLog struct {
  124. ID int64
  125. Type int32
  126. LogID sql.NullString
  127. Money string
  128. CreateUserID sql.NullInt64
  129. CreateTimeStr string
  130. Remark sql.NullString
  131. }
  132. func readExpectedLogs(ctx context.Context, legacy *sql.DB, userID int64) ([]expLog, error) {
  133. const q = `
  134. SELECT id, cast(ifnull(type,0) as signed), logid,
  135. CAST(money AS DECIMAL(12,2)),
  136. create_userid,
  137. DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s.%f'),
  138. remark
  139. FROM t_user_expense_log WHERE userid = ? ORDER BY id`
  140. rows, err := legacy.QueryContext(ctx, q, userID)
  141. if err != nil {
  142. return nil, err
  143. }
  144. defer rows.Close()
  145. var out []expLog
  146. for rows.Next() {
  147. var r expLog
  148. if err := rows.Scan(&r.ID, &r.Type, &r.LogID, &r.Money, &r.CreateUserID, &r.CreateTimeStr, &r.Remark); err != nil {
  149. return nil, err
  150. }
  151. out = append(out, r)
  152. }
  153. return out, rows.Err()
  154. }
  155. // ---------- 目标值读取 ----------
  156. type actExpense struct {
  157. UserID string
  158. Balance, Spend, Income, Expenditure, Recommended, Special string
  159. }
  160. func readActualExpense(ctx context.Context, db *sql.DB, userID string) (*actExpense, error) {
  161. const q = `
  162. SELECT user_id, CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
  163. CAST(total_income AS DECIMAL(12,2)), CAST(total_expenditure AS DECIMAL(12,2)),
  164. CAST(total_recommended_amount AS DECIMAL(12,2)), CAST(special_expenses AS DECIMAL(12,2))
  165. FROM t_user_expense WHERE user_id = ?`
  166. var a actExpense
  167. err := db.QueryRowContext(ctx, q, userID).Scan(&a.UserID, &a.Balance, &a.Spend,
  168. &a.Income, &a.Expenditure, &a.Recommended, &a.Special)
  169. if err != nil {
  170. return nil, err
  171. }
  172. return &a, nil
  173. }
  174. type actSchool struct {
  175. ID, InstitutionID, CampusID int64
  176. Balance, Spend, Income, Expenditure, Recommended, Special string
  177. }
  178. func readActualSchools(ctx context.Context, db *sql.DB, userID string) ([]actSchool, error) {
  179. const q = `
  180. SELECT id, institution_id, campus_id,
  181. CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
  182. CAST(total_income AS DECIMAL(12,2)), CAST(total_expenditure AS DECIMAL(12,2)),
  183. CAST(total_recommended_amount AS DECIMAL(12,2)), CAST(special_expenses AS DECIMAL(12,2))
  184. FROM t_user_expense_school WHERE user_id = ? ORDER BY institution_id, campus_id`
  185. rows, err := db.QueryContext(ctx, q, userID)
  186. if err != nil {
  187. return nil, err
  188. }
  189. defer rows.Close()
  190. var out []actSchool
  191. for rows.Next() {
  192. var r actSchool
  193. if err := rows.Scan(&r.ID, &r.InstitutionID, &r.CampusID, &r.Balance, &r.Spend,
  194. &r.Income, &r.Expenditure, &r.Recommended, &r.Special); err != nil {
  195. return nil, err
  196. }
  197. out = append(out, r)
  198. }
  199. return out, rows.Err()
  200. }
  201. type actLog struct {
  202. ID int64
  203. UserID string
  204. Type int32
  205. LogID sql.NullString
  206. RequestID sql.NullString
  207. Money string
  208. BalanceAfter sql.NullString
  209. CreateUserID sql.NullString
  210. CreateTimeStr string
  211. Remark sql.NullString
  212. }
  213. func readActualLogs(ctx context.Context, db *sql.DB, userID string) ([]actLog, error) {
  214. const q = `
  215. SELECT id, user_id, type, log_id, request_id,
  216. CAST(money AS DECIMAL(12,2)),
  217. CAST(balance_after AS DECIMAL(12,2)),
  218. create_user_id,
  219. DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s.%f'),
  220. remark
  221. FROM t_user_expense_log WHERE user_id = ? ORDER BY id`
  222. rows, err := db.QueryContext(ctx, q, userID)
  223. if err != nil {
  224. return nil, err
  225. }
  226. defer rows.Close()
  227. var out []actLog
  228. for rows.Next() {
  229. var r actLog
  230. if err := rows.Scan(&r.ID, &r.UserID, &r.Type, &r.LogID, &r.RequestID, &r.Money,
  231. &r.BalanceAfter, &r.CreateUserID, &r.CreateTimeStr, &r.Remark); err != nil {
  232. return nil, err
  233. }
  234. out = append(out, r)
  235. }
  236. return out, rows.Err()
  237. }
  238. // ---------- 核对主体 ----------
  239. // Verify 执行一轮完整核对:4 张表逐列 + AUTO_INCREMENT + 空值语义 + float 陷阱。
  240. func Verify(ctx context.Context, cfg Config, legacyUserID int64) (*VerifyReport, error) {
  241. canonical := FixtureCanonicalUserID(legacyUserID)
  242. target, err := cfg.OpenTarget()
  243. if err != nil {
  244. return nil, err
  245. }
  246. defer target.Close()
  247. legacy, err := cfg.OpenLegacy()
  248. if err != nil {
  249. return nil, err
  250. }
  251. defer legacy.Close()
  252. rep := &VerifyReport{
  253. LoadedRows: map[string]int{},
  254. TargetRows: map[string]int{},
  255. }
  256. // ===== 1. t_user_expense(1 行 7 列)=====
  257. ee, err := readExpectedExpense(ctx, legacy, legacyUserID)
  258. if err != nil {
  259. return nil, fmt.Errorf("读期望 t_user_expense 失败:%w", err)
  260. }
  261. ae, err := readActualExpense(ctx, target, canonical)
  262. if err != nil {
  263. return nil, fmt.Errorf("读实际 t_user_expense 失败:%w", err)
  264. }
  265. key := "user_id=" + canonical
  266. rep.Checks = append(rep.Checks,
  267. check("t_user_expense", key, "user_id", fmt.Sprintf("%d", ee.UserID), ae.UserID,
  268. ae.UserID == canonical, "legacy int → canonical ULID(经 t_legacy_mapping)"),
  269. check("t_user_expense", key, "balance", ee.Balance, ae.Balance,
  270. normalizeMoney(ee.Balance) == normalizeMoney(ae.Balance), ""),
  271. check("t_user_expense", key, "spend", ee.Spend, ae.Spend,
  272. normalizeMoney(ee.Spend) == normalizeMoney(ae.Spend), ""),
  273. check("t_user_expense", key, "total_income", ee.Income, ae.Income,
  274. normalizeMoney(ee.Income) == normalizeMoney(ae.Income), ""),
  275. check("t_user_expense", key, "total_expenditure", ee.Expenditure, ae.Expenditure,
  276. normalizeMoney(ee.Expenditure) == normalizeMoney(ae.Expenditure), ""),
  277. check("t_user_expense", key, "total_recommended_amount", ee.Recommended, ae.Recommended,
  278. normalizeMoney(ee.Recommended) == normalizeMoney(ae.Recommended), ""),
  279. check("t_user_expense", key, "special_expenses", ee.Special, ae.Special,
  280. normalizeMoney(ee.Special) == normalizeMoney(ae.Special), ""),
  281. )
  282. rep.LoadedRows["t_user_expense"] = 1
  283. rep.TargetRows["t_user_expense"] = 1
  284. // ===== 2. t_user_expense_school(N 行 × 8 列)=====
  285. es, err := readExpectedSchools(ctx, legacy, legacyUserID)
  286. if err != nil {
  287. return nil, fmt.Errorf("读期望 t_user_expense_school 失败:%w", err)
  288. }
  289. as, err := readActualSchools(ctx, target, canonical)
  290. if err != nil {
  291. return nil, fmt.Errorf("读实际 t_user_expense_school 失败:%w", err)
  292. }
  293. rep.LoadedRows["t_user_expense_school"] = len(es)
  294. rep.TargetRows["t_user_expense_school"] = len(as)
  295. rep.Checks = append(rep.Checks, check("t_user_expense_school", "-", "行数",
  296. fmt.Sprintf("%d", len(es)), fmt.Sprintf("%d", len(as)), len(es) == len(as), "复合业务键 (user,institution,campus) 对齐"))
  297. // 按 (institution_id, campus_id) 建索引,避免依赖 legacy id(该表不保留 legacy id)。
  298. actualByKey := map[string]actSchool{}
  299. for _, r := range as {
  300. actualByKey[fmt.Sprintf("%d/%d", r.InstitutionID, r.CampusID)] = r
  301. }
  302. for _, e := range es {
  303. k := fmt.Sprintf("%d/%d", e.SchoolID, e.CampusID)
  304. a, ok := actualByKey[k]
  305. rowKey := fmt.Sprintf("inst=%d/campus=%d", e.SchoolID, e.CampusID)
  306. if !ok {
  307. rep.Checks = append(rep.Checks, ColumnCheck{
  308. Table: "t_user_expense_school", RowKey: rowKey, Column: "<整行>",
  309. Legacy: "存在", Target: "缺失", Equal: false, Note: "目标侧缺该复合键行",
  310. })
  311. continue
  312. }
  313. rep.Checks = append(rep.Checks,
  314. check("t_user_expense_school", rowKey, "institution_id", fmt.Sprintf("%d", e.SchoolID), fmt.Sprintf("%d", a.InstitutionID), e.SchoolID == a.InstitutionID, "schoolid→institution_id"),
  315. check("t_user_expense_school", rowKey, "campus_id", fmt.Sprintf("%d", e.CampusID), fmt.Sprintf("%d", a.CampusID), e.CampusID == a.CampusID, "campusid→campus_id"),
  316. check("t_user_expense_school", rowKey, "balance", e.Balance, a.Balance, normalizeMoney(e.Balance) == normalizeMoney(a.Balance), ""),
  317. check("t_user_expense_school", rowKey, "spend", e.Spend, a.Spend, normalizeMoney(e.Spend) == normalizeMoney(a.Spend), ""),
  318. check("t_user_expense_school", rowKey, "total_income", e.Income, a.Income, normalizeMoney(e.Income) == normalizeMoney(a.Income), ""),
  319. check("t_user_expense_school", rowKey, "total_expenditure", e.Expenditure, a.Expenditure, normalizeMoney(e.Expenditure) == normalizeMoney(a.Expenditure), ""),
  320. check("t_user_expense_school", rowKey, "total_recommended_amount", e.Recommended, a.Recommended, normalizeMoney(e.Recommended) == normalizeMoney(a.Recommended), ""),
  321. check("t_user_expense_school", rowKey, "special_expenses", e.Special, a.Special, normalizeMoney(e.Special) == normalizeMoney(a.Special), ""),
  322. )
  323. }
  324. // ===== 3. t_user_expense_log(M 行 × 10 列)=====
  325. el, err := readExpectedLogs(ctx, legacy, legacyUserID)
  326. if err != nil {
  327. return nil, fmt.Errorf("读期望 t_user_expense_log 失败:%w", err)
  328. }
  329. al, err := readActualLogs(ctx, target, canonical)
  330. if err != nil {
  331. return nil, fmt.Errorf("读实际 t_user_expense_log 失败:%w", err)
  332. }
  333. rep.LoadedRows["t_user_expense_log"] = len(el)
  334. rep.TargetRows["t_user_expense_log"] = len(al)
  335. rep.Checks = append(rep.Checks, check("t_user_expense_log", "-", "行数",
  336. fmt.Sprintf("%d", len(el)), fmt.Sprintf("%d", len(al)), len(el) == len(al), "按 id 对齐"))
  337. actualLogByID := map[int64]actLog{}
  338. for _, r := range al {
  339. actualLogByID[r.ID] = r
  340. }
  341. for _, e := range el {
  342. rowKey := fmt.Sprintf("id=%d", e.ID)
  343. a, ok := actualLogByID[e.ID]
  344. if !ok {
  345. rep.Checks = append(rep.Checks, ColumnCheck{
  346. Table: "t_user_expense_log", RowKey: rowKey, Column: "<整行>",
  347. Legacy: "存在", Target: "缺失", Equal: false, Note: "D1 显式插 id 未生效:目标侧无该 id",
  348. })
  349. continue
  350. }
  351. // create_user_id 期望:legacy 0/NULL → NULL;>0 → 必须是某个 ULID(由 t_legacy_mapping 解析)
  352. expCreateUser := "NULL"
  353. createUserOK := !a.CreateUserID.Valid
  354. if e.CreateUserID.Valid && e.CreateUserID.Int64 > 0 {
  355. expCreateUser = "ULID(legacy=" + strconv.FormatInt(e.CreateUserID.Int64, 10) + ")"
  356. createUserOK = a.CreateUserID.Valid && a.CreateUserID.String == FixtureCanonicalUserID(e.CreateUserID.Int64)
  357. }
  358. actCreateUser := "NULL"
  359. if a.CreateUserID.Valid {
  360. actCreateUser = a.CreateUserID.String
  361. }
  362. rep.Checks = append(rep.Checks,
  363. check("t_user_expense_log", rowKey, "id", strconv.FormatInt(e.ID, 10), strconv.FormatInt(a.ID, 10), e.ID == a.ID, "D1:与 legacy id 完全相同"),
  364. check("t_user_expense_log", rowKey, "user_id", strconv.FormatInt(legacyUserID, 10), a.UserID, a.UserID == canonical, "legacy userid → canonical ULID"),
  365. check("t_user_expense_log", rowKey, "type", strconv.FormatInt(int64(e.Type), 10), strconv.FormatInt(int64(a.Type), 10), e.Type == a.Type, ""),
  366. check("t_user_expense_log", rowKey, "log_id", nullStrView(e.LogID), nullStrView(a.LogID), nullStrView(e.LogID) == nullStrView(a.LogID), "logid→log_id"),
  367. check("t_user_expense_log", rowKey, "request_id", "NULL", nullStrView(a.RequestID), !a.RequestID.Valid, "legacy 无此列 → 必须 NULL"),
  368. check("t_user_expense_log", rowKey, "money", e.Money, a.Money, normalizeMoney(e.Money) == normalizeMoney(a.Money), "逐行 ROUND 到分"),
  369. check("t_user_expense_log", rowKey, "balance_after", "NULL", nullStrView(a.BalanceAfter), !a.BalanceAfter.Valid, "legacy 无快照 → 必须 NULL"),
  370. check("t_user_expense_log", rowKey, "create_user_id", expCreateUser, actCreateUser, createUserOK, "legacy 0/系统 → NULL"),
  371. check("t_user_expense_log", rowKey, "create_time", e.CreateTimeStr, a.CreateTimeStr, e.CreateTimeStr == a.CreateTimeStr, "秒级相同,毫秒补 .000"),
  372. check("t_user_expense_log", rowKey, "remark", maskRemark(e.Remark), maskRemark(a.Remark), sameRemark(e.Remark, a.Remark), "报告脱敏,比对用原文"),
  373. )
  374. }
  375. // ===== 4. t_user / t_legacy_mapping 链接 =====
  376. if err := verifyIdentity(ctx, target, legacyUserID, canonical, rep); err != nil {
  377. return nil, err
  378. }
  379. // ===== 4.5 create_time 未落入 DB 默认值(业务时间必须来自 legacy)=====
  380. rep.Assertions = append(rep.Assertions,
  381. createTimeAssertions(ctx, target, []string{"t_user_expense_log"})...)
  382. // ===== 5. 语义断言 =====
  383. rep.Assertions = append(rep.Assertions, Assertion{
  384. Name: "create_user_id=0 → NULL(本 fixture 数据无 0 行时按空集通过)",
  385. Pass: countZeroCreator(target, canonical),
  386. Detail: "SQL: SELECT COUNT(*) FROM t_user_expense_log WHERE user_id=? AND create_user_id='0' 必须为 0",
  387. })
  388. rep.Assertions = append(rep.Assertions, Assertion{
  389. Name: "balance_after 全为 NULL",
  390. Pass: countNonNullBalanceAfter(target, canonical),
  391. Detail: "SQL: ... WHERE user_id=? AND balance_after IS NOT NULL 必须为 0",
  392. })
  393. // ===== 6. AUTO_INCREMENT 不倒退 =====
  394. aiDemo, err := autoIncrementDemo(ctx, target, canonical)
  395. if err != nil {
  396. return nil, err
  397. }
  398. rep.AutoIncTest = aiDemo
  399. rep.Assertions = append(rep.Assertions, Assertion{
  400. Name: "显式插 id 后 AUTO_INCREMENT 不倒退(探针 id = 计数器,且 >= max+1;镜像表演示 max+1)",
  401. Pass: aiDemo.Pass && aiDemo.MirrorPass,
  402. Detail: fmt.Sprintf(
  403. "真实表:max(id)=%d, AUTO_INCREMENT=%d(=max+1? %v), 不带 id 插入得到 id=%d;"+
  404. "临时表镜像:显式插 id=%d → 不带 id 插入得到 id=%d(期望 %d)",
  405. aiDemo.MaxIDBefore, aiDemo.AutoIncrementVal, aiDemo.EqualsMaxPlusOne, aiDemo.ProbeID,
  406. aiDemo.MirrorExplicitID, aiDemo.MirrorProbeID, aiDemo.MirrorExpected),
  407. })
  408. // ===== 7. float 陷阱证据 =====
  409. trap, err := floatTrap(ctx, legacy, legacyUserID)
  410. if err != nil {
  411. return nil, err
  412. }
  413. rep.FloatTrap = trap
  414. return rep, nil
  415. }
  416. // check 构造一条核对行。
  417. func check(table, rowKey, column, legacyVal, targetVal string, equal bool, note string) ColumnCheck {
  418. return ColumnCheck{Table: table, RowKey: rowKey, Column: column,
  419. Legacy: legacyVal, Target: targetVal, Equal: equal, Note: note}
  420. }
  421. func nullStrView(v sql.NullString) string {
  422. if !v.Valid {
  423. return "NULL"
  424. }
  425. return v.String
  426. }
  427. // maskRemark 报告里脱敏:备注可能含真实姓名/手机号,只展示前 8 个字符 + 长度。
  428. func maskRemark(v sql.NullString) string {
  429. if !v.Valid {
  430. return "NULL"
  431. }
  432. r := []rune(v.String)
  433. head := r
  434. if len(head) > 8 {
  435. head = head[:8]
  436. }
  437. return fmt.Sprintf("%s…(len=%d)", string(head), len(r))
  438. }
  439. // sameRemark 用原文比对(脱敏只影响展示,不影响核对)。
  440. func sameRemark(a, b sql.NullString) bool {
  441. if a.Valid != b.Valid {
  442. return false
  443. }
  444. if !a.Valid {
  445. return true
  446. }
  447. return a.String == b.String
  448. }
  449. // verifyIdentity 核对 t_legacy_mapping 与 t_user 的链接是否正确。
  450. func verifyIdentity(ctx context.Context, target *sql.DB, legacyUserID int64, canonical string, rep *VerifyReport) error {
  451. var mapped string
  452. err := target.QueryRowContext(ctx,
  453. `SELECT canonical_user_id FROM t_legacy_mapping WHERE legacy_academic_user_id = ?`, legacyUserID).Scan(&mapped)
  454. switch {
  455. case err == sql.ErrNoRows:
  456. rep.Checks = append(rep.Checks, ColumnCheck{Table: "t_legacy_mapping",
  457. RowKey: fmt.Sprintf("legacy=%d", legacyUserID), Column: "canonical_user_id",
  458. Legacy: "存在", Target: "缺失", Equal: false, Note: "映射行不存在"})
  459. case err != nil:
  460. return err
  461. default:
  462. rep.Checks = append(rep.Checks, check("t_legacy_mapping", fmt.Sprintf("legacy=%d", legacyUserID),
  463. "canonical_user_id", canonical, mapped, mapped == canonical, "legacy int → ULID 映射正确"))
  464. }
  465. var displayName sql.NullString
  466. err = target.QueryRowContext(ctx,
  467. `SELECT display_name FROM t_user WHERE canonical_user_id = ?`, canonical).Scan(&displayName)
  468. switch {
  469. case err == sql.ErrNoRows:
  470. rep.Checks = append(rep.Checks, ColumnCheck{Table: "t_user",
  471. RowKey: canonical, Column: "<整行>", Legacy: "存在", Target: "缺失", Equal: false, Note: "t_user 行不存在"})
  472. case err != nil:
  473. return err
  474. default:
  475. rep.Checks = append(rep.Checks, check("t_user", canonical, "canonical_user_id",
  476. canonical, canonical, true, "主键存在即可(其余列为合法默认值)"))
  477. }
  478. return nil
  479. }
  480. // countZeroCreator 断言「没有一行 create_user_id 落成字符串 '0'」。
  481. func countZeroCreator(db *sql.DB, userID string) bool {
  482. var n int
  483. err := db.QueryRow(
  484. `SELECT COUNT(*) FROM t_user_expense_log WHERE user_id = ? AND create_user_id = '0'`, userID).Scan(&n)
  485. return err == nil && n == 0
  486. }
  487. // countNonNullBalanceAfter 断言 balance_after 全部为 NULL。
  488. func countNonNullBalanceAfter(db *sql.DB, userID string) bool {
  489. var n int
  490. err := db.QueryRow(
  491. `SELECT COUNT(*) FROM t_user_expense_log WHERE user_id = ? AND balance_after IS NOT NULL`, userID).Scan(&n)
  492. return err == nil && n == 0
  493. }
  494. // readAutoIncrement 读**即时**自增计数器。
  495. //
  496. // 两个坑都必须躲掉,否则会得到「AUTO_INCREMENT = 1」这种完全错误的结论:
  497. // 1. 不能读 information_schema.tables.AUTO_INCREMENT —— MySQL 8.0 默认
  498. // `information_schema_stats_expiry = 86400`,那是最多缓存一天的值(实测返回 1)。
  499. // 2. 必须先 `SET SESSION information_schema_stats_expiry = 0`,且 SET 与 SELECT
  500. // 必须在**同一条连接**上(连接池会换连接),所以这里用 db.Conn 独占一条。
  501. func readAutoIncrement(ctx context.Context, db *sql.DB, table string) (int64, error) {
  502. // SHOW 语句不支持占位符(Error 1064),因此表名内联;
  503. // 表名来自本包常量,且此处再做一次字符白名单校验,避免拼接风险。
  504. if table == "" {
  505. return 0, fmt.Errorf("readAutoIncrement: 表名为空")
  506. }
  507. for _, r := range table {
  508. if !(r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
  509. return 0, fmt.Errorf("readAutoIncrement: 非法表名 %q", table)
  510. }
  511. }
  512. conn, err := db.Conn(ctx)
  513. if err != nil {
  514. return 0, err
  515. }
  516. defer conn.Close()
  517. if _, err := conn.ExecContext(ctx, "SET SESSION information_schema_stats_expiry = 0"); err != nil {
  518. return 0, fmt.Errorf("关闭 information_schema 统计缓存失败:%w", err)
  519. }
  520. rows, err := conn.QueryContext(ctx, fmt.Sprintf("SHOW TABLE STATUS LIKE '%s'", table))
  521. if err != nil {
  522. return 0, err
  523. }
  524. defer rows.Close()
  525. cols, err := rows.Columns()
  526. if err != nil {
  527. return 0, err
  528. }
  529. if !rows.Next() {
  530. return 0, fmt.Errorf("SHOW TABLE STATUS 无 %s 行", table)
  531. }
  532. vals := make([]any, len(cols))
  533. ptrs := make([]any, len(cols))
  534. for i := range vals {
  535. ptrs[i] = &vals[i]
  536. }
  537. if err := rows.Scan(ptrs...); err != nil {
  538. return 0, err
  539. }
  540. for i, c := range cols {
  541. if !strings.EqualFold(c, "Auto_increment") {
  542. continue
  543. }
  544. switch v := vals[i].(type) {
  545. case nil:
  546. return 0, nil
  547. case []byte:
  548. return strconv.ParseInt(string(v), 10, 64)
  549. case int64:
  550. return v, nil
  551. case uint64:
  552. return int64(v), nil
  553. default:
  554. return 0, fmt.Errorf("Auto_increment 类型未知:%T", v)
  555. }
  556. }
  557. return 0, fmt.Errorf("SHOW TABLE STATUS 结果里没有 Auto_increment 列")
  558. }
  559. // autoIncrementDemo 验证「显式插 id 之后自增计数器不倒回」。
  560. //
  561. // A 段(真实表):读 max(id) 与即时 AUTO_INCREMENT → 插一条**不带 id** 的探针行 →
  562. // 探针 id 必须等于该计数器、且 >= max(id)+1(若被重置成 1,这里会直接撞主键或取到小数)。
  563. // B 段(临时表镜像):CREATE TEMPORARY TABLE ... LIKE → 显式插 id=100 →
  564. // 不带 id 插 → 必须 101。临时表随会话结束自动消失,不污染 schema、无需 DROP。
  565. func autoIncrementDemo(ctx context.Context, db *sql.DB, userID string) (AutoIncrementDemo, error) {
  566. var d AutoIncrementDemo
  567. if err := db.QueryRowContext(ctx,
  568. `SELECT IFNULL(MAX(id),0) FROM t_user_expense_log`).Scan(&d.MaxIDBefore); err != nil {
  569. return d, err
  570. }
  571. ai, err := readAutoIncrement(ctx, db, "t_user_expense_log")
  572. if err != nil {
  573. return d, err
  574. }
  575. d.AutoIncrementVal = ai
  576. d.ExpectedProbeID = ai
  577. d.EqualsMaxPlusOne = ai == d.MaxIDBefore+1
  578. res, err := db.ExecContext(ctx, `
  579. INSERT INTO t_user_expense_log
  580. (user_id, type, log_id, request_id, money, balance_after, create_user_id, create_time, remark)
  581. VALUES (?, 0, NULL, NULL, '0.00', NULL, NULL, '1970-01-01 00:00:00', 'ac15 fixture auto-inc probe')`, userID)
  582. if err != nil {
  583. return d, fmt.Errorf("AUTO_INCREMENT 探针插入失败(计数器疑似被重置):%w", err)
  584. }
  585. probeID, err := res.LastInsertId()
  586. if err != nil {
  587. return d, err
  588. }
  589. d.ProbeID = probeID
  590. // 判定:探针 id 必须等于读到的计数器(证明「不带 id 的插入」就是拿计数器发号),
  591. // 且计数器不得小于 max(id)+1(证明显式插 id 没有把计数器倒退)。
  592. d.Pass = probeID == d.ExpectedProbeID && d.AutoIncrementVal >= d.MaxIDBefore+1
  593. if _, err := db.ExecContext(ctx,
  594. `DELETE FROM t_user_expense_log WHERE remark = 'ac15 fixture auto-inc probe'`); err != nil {
  595. return d, fmt.Errorf("AUTO_INCREMENT 探针清理失败:%w", err)
  596. }
  597. // ---- B 段:临时表镜像,可重复地演示「显式插 id=100 → 下一条 = 101」----
  598. if err := mirrorAutoIncrement(ctx, db, userID, &d); err != nil {
  599. return d, err
  600. }
  601. return d, nil
  602. }
  603. // mirrorAutoIncrement 用 TEMPORARY TABLE 复现「max+1」。
  604. //
  605. // 独立连接执行:临时表是会话级的,必须确保 CREATE / INSERT / SELECT 走同一条连接。
  606. func mirrorAutoIncrement(ctx context.Context, db *sql.DB, userID string, d *AutoIncrementDemo) error {
  607. conn, err := db.Conn(ctx)
  608. if err != nil {
  609. return err
  610. }
  611. defer conn.Close()
  612. const tmp = "tmp_ac15_ai_probe"
  613. steps := []string{
  614. fmt.Sprintf("CREATE TEMPORARY TABLE `%s` LIKE t_user_expense_log", tmp),
  615. fmt.Sprintf("INSERT INTO `%s` (id, user_id, type, money, create_time, remark) VALUES (100, '%s', 0, '0.00', '1970-01-01 00:00:00', 'mirror explicit id')", tmp, userID),
  616. }
  617. for _, s := range steps {
  618. if _, err := conn.ExecContext(ctx, s); err != nil {
  619. return fmt.Errorf("临时表镜像步骤失败(%s):%w", s, err)
  620. }
  621. }
  622. d.MirrorExplicitID = 100
  623. res, err := conn.ExecContext(ctx, fmt.Sprintf(
  624. "INSERT INTO `%s` (user_id, type, money, create_time, remark) VALUES ('%s', 0, '0.00', '1970-01-01 00:00:00', 'mirror auto id')",
  625. tmp, userID))
  626. if err != nil {
  627. return fmt.Errorf("临时表镜像第二次插入失败:%w", err)
  628. }
  629. mirrorID, err := res.LastInsertId()
  630. if err != nil {
  631. return err
  632. }
  633. d.MirrorProbeID = mirrorID
  634. d.MirrorExpected = d.MirrorExplicitID + 1
  635. d.MirrorPass = mirrorID == d.MirrorExpected
  636. return nil
  637. }
  638. // floatTrap 采集「float 直接相加」与「逐行取整到分再相加」的差异证据。
  639. //
  640. // 三组数字:
  641. //
  642. // · Σ ROUND(x,2) —— 逐行取整后相加(迁移口径,也是双跑对账口径)
  643. // · ROUND(Σ x, 2) —— 先相加再取整(旧 SP 常见口径,与上面可能差 1 分)
  644. // · float64 直接累加 —— 展示二进制浮点的累加误差
  645. func floatTrap(ctx context.Context, legacy *sql.DB, userID int64) (FloatTrapDemo, error) {
  646. var d FloatTrapDemo
  647. // 逐条取值(3 位小数原文)
  648. rows, err := legacy.QueryContext(ctx, `
  649. SELECT CAST(money AS DECIMAL(20,3))
  650. FROM t_user_expense_log WHERE userid = ? ORDER BY id`, userID)
  651. if err != nil {
  652. return d, err
  653. }
  654. var raws []string
  655. for rows.Next() {
  656. var s string
  657. if err := rows.Scan(&s); err != nil {
  658. rows.Close()
  659. return d, err
  660. }
  661. raws = append(raws, s)
  662. }
  663. rows.Close()
  664. if err := rows.Err(); err != nil {
  665. return d, err
  666. }
  667. d.LegacyValues = raws
  668. var exactThousandths int64
  669. var floatSum float64
  670. for _, raw := range raws {
  671. th, err := thousandthsOf(raw)
  672. if err != nil {
  673. return d, err
  674. }
  675. exactThousandths += th
  676. floatSum += float64Of(raw)
  677. c, err := DecimalToCents(raw)
  678. if err != nil {
  679. return d, err
  680. }
  681. d.PerRowRounded = append(d.PerRowRounded, CentsToDecimal(c))
  682. d.SumOfRoundedCents += c
  683. }
  684. d.SumRoundedStr = CentsToDecimal(d.SumOfRoundedCents)
  685. d.ExactSumThousandths = exactThousandths
  686. d.FloatSumRaw = floatSum
  687. d.FloatAccumErr = floatSum - float64(exactThousandths)/1000
  688. // 服务端口径:ROUND(Σ x, 2) —— 与 Σ ROUND(x,2) 可能差 1 分(这就是「先加后取整」的坑)
  689. var roundOfSum string
  690. if err := legacy.QueryRowContext(ctx,
  691. `SELECT CAST(SUM(money) AS DECIMAL(12,2)) FROM t_user_expense_log WHERE userid = ?`,
  692. userID).Scan(&roundOfSum); err != nil {
  693. return d, err
  694. }
  695. d.RoundOfSumStr = roundOfSum
  696. rc, err := DecimalToCents(roundOfSum)
  697. if err != nil {
  698. return d, err
  699. }
  700. d.DeltaVsPerRow = rc - d.SumOfRoundedCents
  701. return d, nil
  702. }
  703. // SortedTableNames 返回核对表出现过的表名(去重后排序),供报告分节打印。
  704. func (r *VerifyReport) SortedTableNames() []string {
  705. seen := map[string]bool{}
  706. var out []string
  707. for _, c := range r.Checks {
  708. if !seen[c.Table] {
  709. seen[c.Table] = true
  710. out = append(out, c.Table)
  711. }
  712. }
  713. sort.Strings(out)
  714. return out
  715. }
  716. // FormatFloat 用 %.17g 展示 float64,暴露二进制尾差。
  717. func FormatFloat(v float64) string {
  718. if math.IsNaN(v) {
  719. return "NaN"
  720. }
  721. return strconv.FormatFloat(v, 'g', 17, 64)
  722. }
  723. // NowTruncSec 仅用于报告时间戳。
  724. func NowTruncSec() time.Time { return time.Now().Truncate(time.Second) }