|
@@ -0,0 +1,778 @@
|
|
|
|
|
+package fixture
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "database/sql"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "math"
|
|
|
|
|
+ "sort"
|
|
|
|
|
+ "strconv"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "time"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// 本文件做**逐列核对**,关键设计是「期望值从哪来」:
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 期望值**不由本包的映射代码产生**(否则映射写错时两边一起错,核对永远绿)。
|
|
|
|
|
+// 期望值一律由 **MySQL 服务端**算出:`CAST(x AS DECIMAL(12,2))` / `DATE_FORMAT(...)`。
|
|
|
|
|
+// 于是:
|
|
|
|
|
+// · Go 侧的取整/换算只要与 DECIMAL 语义不一致,核对立刻红;
|
|
|
|
|
+// · 变异实验(故意改错映射)必然红——这正是它的意义。
|
|
|
|
|
+
|
|
|
|
|
+// normalizeMoney 把服务端返回的十进制串规范成 2 位小数字符串,
|
|
|
|
|
+// 用于「期望 vs 实际」的字符串级比较("-0.00" → "0.00")。
|
|
|
|
|
+func normalizeMoney(s string) string {
|
|
|
|
|
+ s = strings.TrimSpace(s)
|
|
|
|
|
+ if s == "" {
|
|
|
|
|
+ return ""
|
|
|
|
|
+ }
|
|
|
|
|
+ c, err := DecimalToCents(s)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return "ERR:" + s
|
|
|
|
|
+ }
|
|
|
|
|
+ if c == 0 {
|
|
|
|
|
+ return "0.00"
|
|
|
|
|
+ }
|
|
|
|
|
+ return CentsToDecimal(c)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// thousandthsOf 把十进制串精确转为「千分位」整数,用于 float 陷阱演示中的精确和。
|
|
|
|
|
+func thousandthsOf(s string) (int64, error) {
|
|
|
|
|
+ raw := strings.TrimSpace(s)
|
|
|
|
|
+ neg := false
|
|
|
|
|
+ if strings.HasPrefix(raw, "-") {
|
|
|
|
|
+ neg = true
|
|
|
|
|
+ raw = raw[1:]
|
|
|
|
|
+ }
|
|
|
|
|
+ intPart, frac := raw, ""
|
|
|
|
|
+ if i := strings.IndexByte(raw, '.'); i >= 0 {
|
|
|
|
|
+ intPart, frac = raw[:i], raw[i+1:]
|
|
|
|
|
+ }
|
|
|
|
|
+ for len(frac) < 3 {
|
|
|
|
|
+ frac += "0"
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(frac) > 3 {
|
|
|
|
|
+ frac = frac[:3]
|
|
|
|
|
+ }
|
|
|
|
|
+ ip := int64(0)
|
|
|
|
|
+ if intPart != "" {
|
|
|
|
|
+ v, err := strconv.ParseInt(intPart, 10, 64)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return 0, err
|
|
|
|
|
+ }
|
|
|
|
|
+ ip = v
|
|
|
|
|
+ }
|
|
|
|
|
+ fp, err := strconv.ParseInt(frac, 10, 64)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return 0, err
|
|
|
|
|
+ }
|
|
|
|
|
+ v := ip*1000 + fp
|
|
|
|
|
+ if neg {
|
|
|
|
|
+ v = -v
|
|
|
|
|
+ }
|
|
|
|
|
+ return v, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 期望值读取(服务端口径) ----------
|
|
|
|
|
+
|
|
|
|
|
+type expExpense struct {
|
|
|
|
|
+ UserID int64
|
|
|
|
|
+ Balance, Spend, Income, Expenditure, Recommended, Special string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func readExpectedExpense(ctx context.Context, legacy *sql.DB, userID int64) (*expExpense, error) {
|
|
|
|
|
+ const q = `
|
|
|
|
|
+SELECT userid,
|
|
|
|
|
+ CAST(balance AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(spend AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(total_income AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(total_expenditure AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ifnull(total_recommended_amount,0) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ifnull(special_expenses,0) AS DECIMAL(12,2))
|
|
|
|
|
+ FROM t_user_expense WHERE userid = ?`
|
|
|
|
|
+ var e expExpense
|
|
|
|
|
+ if err := legacy.QueryRowContext(ctx, q, userID).Scan(
|
|
|
|
|
+ &e.UserID, &e.Balance, &e.Spend, &e.Income, &e.Expenditure, &e.Recommended, &e.Special); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ return &e, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type expSchool struct {
|
|
|
|
|
+ ID, SchoolID, CampusID int64
|
|
|
|
|
+ Balance, Spend, Income, Expenditure, Recommended, Special string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func readExpectedSchools(ctx context.Context, legacy *sql.DB, userID int64) ([]expSchool, error) {
|
|
|
|
|
+ const q = `
|
|
|
|
|
+SELECT id, schoolid, cast(ifnull(campusid,0) as signed),
|
|
|
|
|
+ CAST(balance AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(spend AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(total_income AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(total_expenditure AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ifnull(total_recommended_amount,0) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ifnull(special_expenses,0) AS DECIMAL(12,2))
|
|
|
|
|
+ FROM t_user_expense_school WHERE userid = ? ORDER BY id`
|
|
|
|
|
+ rows, err := legacy.QueryContext(ctx, q, userID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer rows.Close()
|
|
|
|
|
+ var out []expSchool
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var r expSchool
|
|
|
|
|
+ if err := rows.Scan(&r.ID, &r.SchoolID, &r.CampusID,
|
|
|
|
|
+ &r.Balance, &r.Spend, &r.Income, &r.Expenditure, &r.Recommended, &r.Special); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ out = append(out, r)
|
|
|
|
|
+ }
|
|
|
|
|
+ return out, rows.Err()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type expLog struct {
|
|
|
|
|
+ ID int64
|
|
|
|
|
+ Type int32
|
|
|
|
|
+ LogID sql.NullString
|
|
|
|
|
+ Money string
|
|
|
|
|
+ CreateUserID sql.NullInt64
|
|
|
|
|
+ CreateTimeStr string
|
|
|
|
|
+ Remark sql.NullString
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func readExpectedLogs(ctx context.Context, legacy *sql.DB, userID int64) ([]expLog, error) {
|
|
|
|
|
+ const q = `
|
|
|
|
|
+SELECT id, cast(ifnull(type,0) as signed), logid,
|
|
|
|
|
+ CAST(money AS DECIMAL(12,2)),
|
|
|
|
|
+ create_userid,
|
|
|
|
|
+ DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s.%f'),
|
|
|
|
|
+ remark
|
|
|
|
|
+ FROM t_user_expense_log WHERE userid = ? ORDER BY id`
|
|
|
|
|
+ rows, err := legacy.QueryContext(ctx, q, userID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer rows.Close()
|
|
|
|
|
+ var out []expLog
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var r expLog
|
|
|
|
|
+ if err := rows.Scan(&r.ID, &r.Type, &r.LogID, &r.Money, &r.CreateUserID, &r.CreateTimeStr, &r.Remark); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ out = append(out, r)
|
|
|
|
|
+ }
|
|
|
|
|
+ return out, rows.Err()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 目标值读取 ----------
|
|
|
|
|
+
|
|
|
|
|
+type actExpense struct {
|
|
|
|
|
+ UserID string
|
|
|
|
|
+ Balance, Spend, Income, Expenditure, Recommended, Special string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func readActualExpense(ctx context.Context, db *sql.DB, userID string) (*actExpense, error) {
|
|
|
|
|
+ const q = `
|
|
|
|
|
+SELECT user_id, CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(total_income AS DECIMAL(12,2)), CAST(total_expenditure AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(total_recommended_amount AS DECIMAL(12,2)), CAST(special_expenses AS DECIMAL(12,2))
|
|
|
|
|
+ FROM t_user_expense WHERE user_id = ?`
|
|
|
|
|
+ var a actExpense
|
|
|
|
|
+ err := db.QueryRowContext(ctx, q, userID).Scan(&a.UserID, &a.Balance, &a.Spend,
|
|
|
|
|
+ &a.Income, &a.Expenditure, &a.Recommended, &a.Special)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ return &a, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type actSchool struct {
|
|
|
|
|
+ ID, InstitutionID, CampusID int64
|
|
|
|
|
+ Balance, Spend, Income, Expenditure, Recommended, Special string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func readActualSchools(ctx context.Context, db *sql.DB, userID string) ([]actSchool, error) {
|
|
|
|
|
+ const q = `
|
|
|
|
|
+SELECT id, institution_id, campus_id,
|
|
|
|
|
+ CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(total_income AS DECIMAL(12,2)), CAST(total_expenditure AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(total_recommended_amount AS DECIMAL(12,2)), CAST(special_expenses AS DECIMAL(12,2))
|
|
|
|
|
+ FROM t_user_expense_school WHERE user_id = ? ORDER BY institution_id, campus_id`
|
|
|
|
|
+ rows, err := db.QueryContext(ctx, q, userID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer rows.Close()
|
|
|
|
|
+ var out []actSchool
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var r actSchool
|
|
|
|
|
+ if err := rows.Scan(&r.ID, &r.InstitutionID, &r.CampusID, &r.Balance, &r.Spend,
|
|
|
|
|
+ &r.Income, &r.Expenditure, &r.Recommended, &r.Special); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ out = append(out, r)
|
|
|
|
|
+ }
|
|
|
|
|
+ return out, rows.Err()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type actLog struct {
|
|
|
|
|
+ ID int64
|
|
|
|
|
+ UserID string
|
|
|
|
|
+ Type int32
|
|
|
|
|
+ LogID sql.NullString
|
|
|
|
|
+ RequestID sql.NullString
|
|
|
|
|
+ Money string
|
|
|
|
|
+ BalanceAfter sql.NullString
|
|
|
|
|
+ CreateUserID sql.NullString
|
|
|
|
|
+ CreateTimeStr string
|
|
|
|
|
+ Remark sql.NullString
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func readActualLogs(ctx context.Context, db *sql.DB, userID string) ([]actLog, error) {
|
|
|
|
|
+ const q = `
|
|
|
|
|
+SELECT id, user_id, type, log_id, request_id,
|
|
|
|
|
+ CAST(money AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(balance_after AS DECIMAL(12,2)),
|
|
|
|
|
+ create_user_id,
|
|
|
|
|
+ DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s.%f'),
|
|
|
|
|
+ remark
|
|
|
|
|
+ FROM t_user_expense_log WHERE user_id = ? ORDER BY id`
|
|
|
|
|
+ rows, err := db.QueryContext(ctx, q, userID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer rows.Close()
|
|
|
|
|
+ var out []actLog
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var r actLog
|
|
|
|
|
+ if err := rows.Scan(&r.ID, &r.UserID, &r.Type, &r.LogID, &r.RequestID, &r.Money,
|
|
|
|
|
+ &r.BalanceAfter, &r.CreateUserID, &r.CreateTimeStr, &r.Remark); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ out = append(out, r)
|
|
|
|
|
+ }
|
|
|
|
|
+ return out, rows.Err()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 核对主体 ----------
|
|
|
|
|
+
|
|
|
|
|
+// Verify 执行一轮完整核对:4 张表逐列 + AUTO_INCREMENT + 空值语义 + float 陷阱。
|
|
|
|
|
+func Verify(ctx context.Context, cfg Config, legacyUserID int64) (*VerifyReport, error) {
|
|
|
|
|
+ canonical := FixtureCanonicalUserID(legacyUserID)
|
|
|
|
|
+
|
|
|
|
|
+ target, err := cfg.OpenTarget()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer target.Close()
|
|
|
|
|
+
|
|
|
|
|
+ legacy, err := cfg.OpenLegacy()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer legacy.Close()
|
|
|
|
|
+
|
|
|
|
|
+ rep := &VerifyReport{
|
|
|
|
|
+ LoadedRows: map[string]int{},
|
|
|
|
|
+ TargetRows: map[string]int{},
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 1. t_user_expense(1 行 7 列)=====
|
|
|
|
|
+ ee, err := readExpectedExpense(ctx, legacy, legacyUserID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("读期望 t_user_expense 失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ ae, err := readActualExpense(ctx, target, canonical)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("读实际 t_user_expense 失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ key := "user_id=" + canonical
|
|
|
|
|
+ rep.Checks = append(rep.Checks,
|
|
|
|
|
+ check("t_user_expense", key, "user_id", fmt.Sprintf("%d", ee.UserID), ae.UserID,
|
|
|
|
|
+ ae.UserID == canonical, "legacy int → canonical ULID(经 t_legacy_mapping)"),
|
|
|
|
|
+ check("t_user_expense", key, "balance", ee.Balance, ae.Balance,
|
|
|
|
|
+ normalizeMoney(ee.Balance) == normalizeMoney(ae.Balance), ""),
|
|
|
|
|
+ check("t_user_expense", key, "spend", ee.Spend, ae.Spend,
|
|
|
|
|
+ normalizeMoney(ee.Spend) == normalizeMoney(ae.Spend), ""),
|
|
|
|
|
+ check("t_user_expense", key, "total_income", ee.Income, ae.Income,
|
|
|
|
|
+ normalizeMoney(ee.Income) == normalizeMoney(ae.Income), ""),
|
|
|
|
|
+ check("t_user_expense", key, "total_expenditure", ee.Expenditure, ae.Expenditure,
|
|
|
|
|
+ normalizeMoney(ee.Expenditure) == normalizeMoney(ae.Expenditure), ""),
|
|
|
|
|
+ check("t_user_expense", key, "total_recommended_amount", ee.Recommended, ae.Recommended,
|
|
|
|
|
+ normalizeMoney(ee.Recommended) == normalizeMoney(ae.Recommended), ""),
|
|
|
|
|
+ check("t_user_expense", key, "special_expenses", ee.Special, ae.Special,
|
|
|
|
|
+ normalizeMoney(ee.Special) == normalizeMoney(ae.Special), ""),
|
|
|
|
|
+ )
|
|
|
|
|
+ rep.LoadedRows["t_user_expense"] = 1
|
|
|
|
|
+ rep.TargetRows["t_user_expense"] = 1
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 2. t_user_expense_school(N 行 × 8 列)=====
|
|
|
|
|
+ es, err := readExpectedSchools(ctx, legacy, legacyUserID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("读期望 t_user_expense_school 失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ as, err := readActualSchools(ctx, target, canonical)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("读实际 t_user_expense_school 失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ rep.LoadedRows["t_user_expense_school"] = len(es)
|
|
|
|
|
+ rep.TargetRows["t_user_expense_school"] = len(as)
|
|
|
|
|
+ rep.Checks = append(rep.Checks, check("t_user_expense_school", "-", "行数",
|
|
|
|
|
+ fmt.Sprintf("%d", len(es)), fmt.Sprintf("%d", len(as)), len(es) == len(as), "复合业务键 (user,institution,campus) 对齐"))
|
|
|
|
|
+
|
|
|
|
|
+ // 按 (institution_id, campus_id) 建索引,避免依赖 legacy id(该表不保留 legacy id)。
|
|
|
|
|
+ actualByKey := map[string]actSchool{}
|
|
|
|
|
+ for _, r := range as {
|
|
|
|
|
+ actualByKey[fmt.Sprintf("%d/%d", r.InstitutionID, r.CampusID)] = r
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, e := range es {
|
|
|
|
|
+ k := fmt.Sprintf("%d/%d", e.SchoolID, e.CampusID)
|
|
|
|
|
+ a, ok := actualByKey[k]
|
|
|
|
|
+ rowKey := fmt.Sprintf("inst=%d/campus=%d", e.SchoolID, e.CampusID)
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ rep.Checks = append(rep.Checks, ColumnCheck{
|
|
|
|
|
+ Table: "t_user_expense_school", RowKey: rowKey, Column: "<整行>",
|
|
|
|
|
+ Legacy: "存在", Target: "缺失", Equal: false, Note: "目标侧缺该复合键行",
|
|
|
|
|
+ })
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ rep.Checks = append(rep.Checks,
|
|
|
|
|
+ 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"),
|
|
|
|
|
+ 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"),
|
|
|
|
|
+ check("t_user_expense_school", rowKey, "balance", e.Balance, a.Balance, normalizeMoney(e.Balance) == normalizeMoney(a.Balance), ""),
|
|
|
|
|
+ check("t_user_expense_school", rowKey, "spend", e.Spend, a.Spend, normalizeMoney(e.Spend) == normalizeMoney(a.Spend), ""),
|
|
|
|
|
+ check("t_user_expense_school", rowKey, "total_income", e.Income, a.Income, normalizeMoney(e.Income) == normalizeMoney(a.Income), ""),
|
|
|
|
|
+ check("t_user_expense_school", rowKey, "total_expenditure", e.Expenditure, a.Expenditure, normalizeMoney(e.Expenditure) == normalizeMoney(a.Expenditure), ""),
|
|
|
|
|
+ check("t_user_expense_school", rowKey, "total_recommended_amount", e.Recommended, a.Recommended, normalizeMoney(e.Recommended) == normalizeMoney(a.Recommended), ""),
|
|
|
|
|
+ check("t_user_expense_school", rowKey, "special_expenses", e.Special, a.Special, normalizeMoney(e.Special) == normalizeMoney(a.Special), ""),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 3. t_user_expense_log(M 行 × 10 列)=====
|
|
|
|
|
+ el, err := readExpectedLogs(ctx, legacy, legacyUserID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("读期望 t_user_expense_log 失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ al, err := readActualLogs(ctx, target, canonical)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("读实际 t_user_expense_log 失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ rep.LoadedRows["t_user_expense_log"] = len(el)
|
|
|
|
|
+ rep.TargetRows["t_user_expense_log"] = len(al)
|
|
|
|
|
+ rep.Checks = append(rep.Checks, check("t_user_expense_log", "-", "行数",
|
|
|
|
|
+ fmt.Sprintf("%d", len(el)), fmt.Sprintf("%d", len(al)), len(el) == len(al), "按 id 对齐"))
|
|
|
|
|
+
|
|
|
|
|
+ actualLogByID := map[int64]actLog{}
|
|
|
|
|
+ for _, r := range al {
|
|
|
|
|
+ actualLogByID[r.ID] = r
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, e := range el {
|
|
|
|
|
+ rowKey := fmt.Sprintf("id=%d", e.ID)
|
|
|
|
|
+ a, ok := actualLogByID[e.ID]
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ rep.Checks = append(rep.Checks, ColumnCheck{
|
|
|
|
|
+ Table: "t_user_expense_log", RowKey: rowKey, Column: "<整行>",
|
|
|
|
|
+ Legacy: "存在", Target: "缺失", Equal: false, Note: "D1 显式插 id 未生效:目标侧无该 id",
|
|
|
|
|
+ })
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ // create_user_id 期望:legacy 0/NULL → NULL;>0 → 必须是某个 ULID(由 t_legacy_mapping 解析)
|
|
|
|
|
+ expCreateUser := "NULL"
|
|
|
|
|
+ createUserOK := !a.CreateUserID.Valid
|
|
|
|
|
+ if e.CreateUserID.Valid && e.CreateUserID.Int64 > 0 {
|
|
|
|
|
+ expCreateUser = "ULID(legacy=" + strconv.FormatInt(e.CreateUserID.Int64, 10) + ")"
|
|
|
|
|
+ createUserOK = a.CreateUserID.Valid && a.CreateUserID.String == FixtureCanonicalUserID(e.CreateUserID.Int64)
|
|
|
|
|
+ }
|
|
|
|
|
+ actCreateUser := "NULL"
|
|
|
|
|
+ if a.CreateUserID.Valid {
|
|
|
|
|
+ actCreateUser = a.CreateUserID.String
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ rep.Checks = append(rep.Checks,
|
|
|
|
|
+ check("t_user_expense_log", rowKey, "id", strconv.FormatInt(e.ID, 10), strconv.FormatInt(a.ID, 10), e.ID == a.ID, "D1:与 legacy id 完全相同"),
|
|
|
|
|
+ check("t_user_expense_log", rowKey, "user_id", strconv.FormatInt(legacyUserID, 10), a.UserID, a.UserID == canonical, "legacy userid → canonical ULID"),
|
|
|
|
|
+ check("t_user_expense_log", rowKey, "type", strconv.FormatInt(int64(e.Type), 10), strconv.FormatInt(int64(a.Type), 10), e.Type == a.Type, ""),
|
|
|
|
|
+ check("t_user_expense_log", rowKey, "log_id", nullStrView(e.LogID), nullStrView(a.LogID), nullStrView(e.LogID) == nullStrView(a.LogID), "logid→log_id"),
|
|
|
|
|
+ check("t_user_expense_log", rowKey, "request_id", "NULL", nullStrView(a.RequestID), !a.RequestID.Valid, "legacy 无此列 → 必须 NULL"),
|
|
|
|
|
+ check("t_user_expense_log", rowKey, "money", e.Money, a.Money, normalizeMoney(e.Money) == normalizeMoney(a.Money), "逐行 ROUND 到分"),
|
|
|
|
|
+ check("t_user_expense_log", rowKey, "balance_after", "NULL", nullStrView(a.BalanceAfter), !a.BalanceAfter.Valid, "legacy 无快照 → 必须 NULL"),
|
|
|
|
|
+ check("t_user_expense_log", rowKey, "create_user_id", expCreateUser, actCreateUser, createUserOK, "legacy 0/系统 → NULL"),
|
|
|
|
|
+ check("t_user_expense_log", rowKey, "create_time", e.CreateTimeStr, a.CreateTimeStr, e.CreateTimeStr == a.CreateTimeStr, "秒级相同,毫秒补 .000"),
|
|
|
|
|
+ check("t_user_expense_log", rowKey, "remark", maskRemark(e.Remark), maskRemark(a.Remark), sameRemark(e.Remark, a.Remark), "报告脱敏,比对用原文"),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 4. t_user / t_legacy_mapping 链接 =====
|
|
|
|
|
+ if err := verifyIdentity(ctx, target, legacyUserID, canonical, rep); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 5. 语义断言 =====
|
|
|
|
|
+ rep.Assertions = append(rep.Assertions, Assertion{
|
|
|
|
|
+ Name: "create_user_id=0 → NULL(本 fixture 数据无 0 行时按空集通过)",
|
|
|
|
|
+ Pass: countZeroCreator(target, canonical),
|
|
|
|
|
+ Detail: "SQL: SELECT COUNT(*) FROM t_user_expense_log WHERE user_id=? AND create_user_id='0' 必须为 0",
|
|
|
|
|
+ })
|
|
|
|
|
+ rep.Assertions = append(rep.Assertions, Assertion{
|
|
|
|
|
+ Name: "balance_after 全为 NULL",
|
|
|
|
|
+ Pass: countNonNullBalanceAfter(target, canonical),
|
|
|
|
|
+ Detail: "SQL: ... WHERE user_id=? AND balance_after IS NOT NULL 必须为 0",
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 6. AUTO_INCREMENT 不倒退 =====
|
|
|
|
|
+ aiDemo, err := autoIncrementDemo(ctx, target, canonical)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ rep.AutoIncTest = aiDemo
|
|
|
|
|
+ rep.Assertions = append(rep.Assertions, Assertion{
|
|
|
|
|
+ Name: "显式插 id 后 AUTO_INCREMENT 不倒退(探针 id = 计数器,且 >= max+1;镜像表演示 max+1)",
|
|
|
|
|
+ Pass: aiDemo.Pass && aiDemo.MirrorPass,
|
|
|
|
|
+ Detail: fmt.Sprintf(
|
|
|
|
|
+ "真实表:max(id)=%d, AUTO_INCREMENT=%d(=max+1? %v), 不带 id 插入得到 id=%d;"+
|
|
|
|
|
+ "临时表镜像:显式插 id=%d → 不带 id 插入得到 id=%d(期望 %d)",
|
|
|
|
|
+ aiDemo.MaxIDBefore, aiDemo.AutoIncrementVal, aiDemo.EqualsMaxPlusOne, aiDemo.ProbeID,
|
|
|
|
|
+ aiDemo.MirrorExplicitID, aiDemo.MirrorProbeID, aiDemo.MirrorExpected),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 7. float 陷阱证据 =====
|
|
|
|
|
+ trap, err := floatTrap(ctx, legacy, legacyUserID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ rep.FloatTrap = trap
|
|
|
|
|
+
|
|
|
|
|
+ return rep, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// check 构造一条核对行。
|
|
|
|
|
+func check(table, rowKey, column, legacyVal, targetVal string, equal bool, note string) ColumnCheck {
|
|
|
|
|
+ return ColumnCheck{Table: table, RowKey: rowKey, Column: column,
|
|
|
|
|
+ Legacy: legacyVal, Target: targetVal, Equal: equal, Note: note}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func nullStrView(v sql.NullString) string {
|
|
|
|
|
+ if !v.Valid {
|
|
|
|
|
+ return "NULL"
|
|
|
|
|
+ }
|
|
|
|
|
+ return v.String
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// maskRemark 报告里脱敏:备注可能含真实姓名/手机号,只展示前 8 个字符 + 长度。
|
|
|
|
|
+func maskRemark(v sql.NullString) string {
|
|
|
|
|
+ if !v.Valid {
|
|
|
|
|
+ return "NULL"
|
|
|
|
|
+ }
|
|
|
|
|
+ r := []rune(v.String)
|
|
|
|
|
+ head := r
|
|
|
|
|
+ if len(head) > 8 {
|
|
|
|
|
+ head = head[:8]
|
|
|
|
|
+ }
|
|
|
|
|
+ return fmt.Sprintf("%s…(len=%d)", string(head), len(r))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// sameRemark 用原文比对(脱敏只影响展示,不影响核对)。
|
|
|
|
|
+func sameRemark(a, b sql.NullString) bool {
|
|
|
|
|
+ if a.Valid != b.Valid {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ if !a.Valid {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ return a.String == b.String
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// verifyIdentity 核对 t_legacy_mapping 与 t_user 的链接是否正确。
|
|
|
|
|
+func verifyIdentity(ctx context.Context, target *sql.DB, legacyUserID int64, canonical string, rep *VerifyReport) error {
|
|
|
|
|
+ var mapped string
|
|
|
|
|
+ err := target.QueryRowContext(ctx,
|
|
|
|
|
+ `SELECT canonical_user_id FROM t_legacy_mapping WHERE legacy_academic_user_id = ?`, legacyUserID).Scan(&mapped)
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case err == sql.ErrNoRows:
|
|
|
|
|
+ rep.Checks = append(rep.Checks, ColumnCheck{Table: "t_legacy_mapping",
|
|
|
|
|
+ RowKey: fmt.Sprintf("legacy=%d", legacyUserID), Column: "canonical_user_id",
|
|
|
|
|
+ Legacy: "存在", Target: "缺失", Equal: false, Note: "映射行不存在"})
|
|
|
|
|
+ case err != nil:
|
|
|
|
|
+ return err
|
|
|
|
|
+ default:
|
|
|
|
|
+ rep.Checks = append(rep.Checks, check("t_legacy_mapping", fmt.Sprintf("legacy=%d", legacyUserID),
|
|
|
|
|
+ "canonical_user_id", canonical, mapped, mapped == canonical, "legacy int → ULID 映射正确"))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var displayName sql.NullString
|
|
|
|
|
+ err = target.QueryRowContext(ctx,
|
|
|
|
|
+ `SELECT display_name FROM t_user WHERE canonical_user_id = ?`, canonical).Scan(&displayName)
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case err == sql.ErrNoRows:
|
|
|
|
|
+ rep.Checks = append(rep.Checks, ColumnCheck{Table: "t_user",
|
|
|
|
|
+ RowKey: canonical, Column: "<整行>", Legacy: "存在", Target: "缺失", Equal: false, Note: "t_user 行不存在"})
|
|
|
|
|
+ case err != nil:
|
|
|
|
|
+ return err
|
|
|
|
|
+ default:
|
|
|
|
|
+ rep.Checks = append(rep.Checks, check("t_user", canonical, "canonical_user_id",
|
|
|
|
|
+ canonical, canonical, true, "主键存在即可(其余列为合法默认值)"))
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// countZeroCreator 断言「没有一行 create_user_id 落成字符串 '0'」。
|
|
|
|
|
+func countZeroCreator(db *sql.DB, userID string) bool {
|
|
|
|
|
+ var n int
|
|
|
|
|
+ err := db.QueryRow(
|
|
|
|
|
+ `SELECT COUNT(*) FROM t_user_expense_log WHERE user_id = ? AND create_user_id = '0'`, userID).Scan(&n)
|
|
|
|
|
+ return err == nil && n == 0
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// countNonNullBalanceAfter 断言 balance_after 全部为 NULL。
|
|
|
|
|
+func countNonNullBalanceAfter(db *sql.DB, userID string) bool {
|
|
|
|
|
+ var n int
|
|
|
|
|
+ err := db.QueryRow(
|
|
|
|
|
+ `SELECT COUNT(*) FROM t_user_expense_log WHERE user_id = ? AND balance_after IS NOT NULL`, userID).Scan(&n)
|
|
|
|
|
+ return err == nil && n == 0
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// readAutoIncrement 读**即时**自增计数器。
|
|
|
|
|
+//
|
|
|
|
|
+// 两个坑都必须躲掉,否则会得到「AUTO_INCREMENT = 1」这种完全错误的结论:
|
|
|
|
|
+// 1. 不能读 information_schema.tables.AUTO_INCREMENT —— MySQL 8.0 默认
|
|
|
|
|
+// `information_schema_stats_expiry = 86400`,那是最多缓存一天的值(实测返回 1)。
|
|
|
|
|
+// 2. 必须先 `SET SESSION information_schema_stats_expiry = 0`,且 SET 与 SELECT
|
|
|
|
|
+// 必须在**同一条连接**上(连接池会换连接),所以这里用 db.Conn 独占一条。
|
|
|
|
|
+func readAutoIncrement(ctx context.Context, db *sql.DB, table string) (int64, error) {
|
|
|
|
|
+ // SHOW 语句不支持占位符(Error 1064),因此表名内联;
|
|
|
|
|
+ // 表名来自本包常量,且此处再做一次字符白名单校验,避免拼接风险。
|
|
|
|
|
+ if table == "" {
|
|
|
|
|
+ return 0, fmt.Errorf("readAutoIncrement: 表名为空")
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, r := range table {
|
|
|
|
|
+ if !(r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
|
|
|
|
+ return 0, fmt.Errorf("readAutoIncrement: 非法表名 %q", table)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ conn, err := db.Conn(ctx)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return 0, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer conn.Close()
|
|
|
|
|
+ if _, err := conn.ExecContext(ctx, "SET SESSION information_schema_stats_expiry = 0"); err != nil {
|
|
|
|
|
+ return 0, fmt.Errorf("关闭 information_schema 统计缓存失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ rows, err := conn.QueryContext(ctx, fmt.Sprintf("SHOW TABLE STATUS LIKE '%s'", table))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return 0, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer rows.Close()
|
|
|
|
|
+ cols, err := rows.Columns()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return 0, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if !rows.Next() {
|
|
|
|
|
+ return 0, fmt.Errorf("SHOW TABLE STATUS 无 %s 行", table)
|
|
|
|
|
+ }
|
|
|
|
|
+ vals := make([]any, len(cols))
|
|
|
|
|
+ ptrs := make([]any, len(cols))
|
|
|
|
|
+ for i := range vals {
|
|
|
|
|
+ ptrs[i] = &vals[i]
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := rows.Scan(ptrs...); err != nil {
|
|
|
|
|
+ return 0, err
|
|
|
|
|
+ }
|
|
|
|
|
+ for i, c := range cols {
|
|
|
|
|
+ if !strings.EqualFold(c, "Auto_increment") {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ switch v := vals[i].(type) {
|
|
|
|
|
+ case nil:
|
|
|
|
|
+ return 0, nil
|
|
|
|
|
+ case []byte:
|
|
|
|
|
+ return strconv.ParseInt(string(v), 10, 64)
|
|
|
|
|
+ case int64:
|
|
|
|
|
+ return v, nil
|
|
|
|
|
+ case uint64:
|
|
|
|
|
+ return int64(v), nil
|
|
|
|
|
+ default:
|
|
|
|
|
+ return 0, fmt.Errorf("Auto_increment 类型未知:%T", v)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return 0, fmt.Errorf("SHOW TABLE STATUS 结果里没有 Auto_increment 列")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// autoIncrementDemo 验证「显式插 id 之后自增计数器不倒回」。
|
|
|
|
|
+//
|
|
|
|
|
+// A 段(真实表):读 max(id) 与即时 AUTO_INCREMENT → 插一条**不带 id** 的探针行 →
|
|
|
|
|
+// 探针 id 必须等于该计数器、且 >= max(id)+1(若被重置成 1,这里会直接撞主键或取到小数)。
|
|
|
|
|
+// B 段(临时表镜像):CREATE TEMPORARY TABLE ... LIKE → 显式插 id=100 →
|
|
|
|
|
+// 不带 id 插 → 必须 101。临时表随会话结束自动消失,不污染 schema、无需 DROP。
|
|
|
|
|
+func autoIncrementDemo(ctx context.Context, db *sql.DB, userID string) (AutoIncrementDemo, error) {
|
|
|
|
|
+ var d AutoIncrementDemo
|
|
|
|
|
+ if err := db.QueryRowContext(ctx,
|
|
|
|
|
+ `SELECT IFNULL(MAX(id),0) FROM t_user_expense_log`).Scan(&d.MaxIDBefore); err != nil {
|
|
|
|
|
+ return d, err
|
|
|
|
|
+ }
|
|
|
|
|
+ ai, err := readAutoIncrement(ctx, db, "t_user_expense_log")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return d, err
|
|
|
|
|
+ }
|
|
|
|
|
+ d.AutoIncrementVal = ai
|
|
|
|
|
+ d.ExpectedProbeID = ai
|
|
|
|
|
+ d.EqualsMaxPlusOne = ai == d.MaxIDBefore+1
|
|
|
|
|
+
|
|
|
|
|
+ res, err := db.ExecContext(ctx, `
|
|
|
|
|
+INSERT INTO t_user_expense_log
|
|
|
|
|
+ (user_id, type, log_id, request_id, money, balance_after, create_user_id, create_time, remark)
|
|
|
|
|
+VALUES (?, 0, NULL, NULL, '0.00', NULL, NULL, '1970-01-01 00:00:00', 'ac15 fixture auto-inc probe')`, userID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return d, fmt.Errorf("AUTO_INCREMENT 探针插入失败(计数器疑似被重置):%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ probeID, err := res.LastInsertId()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return d, err
|
|
|
|
|
+ }
|
|
|
|
|
+ d.ProbeID = probeID
|
|
|
|
|
+ // 判定:探针 id 必须等于读到的计数器(证明「不带 id 的插入」就是拿计数器发号),
|
|
|
|
|
+ // 且计数器不得小于 max(id)+1(证明显式插 id 没有把计数器倒退)。
|
|
|
|
|
+ d.Pass = probeID == d.ExpectedProbeID && d.AutoIncrementVal >= d.MaxIDBefore+1
|
|
|
|
|
+ if _, err := db.ExecContext(ctx,
|
|
|
|
|
+ `DELETE FROM t_user_expense_log WHERE remark = 'ac15 fixture auto-inc probe'`); err != nil {
|
|
|
|
|
+ return d, fmt.Errorf("AUTO_INCREMENT 探针清理失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- B 段:临时表镜像,可重复地演示「显式插 id=100 → 下一条 = 101」----
|
|
|
|
|
+ if err := mirrorAutoIncrement(ctx, db, userID, &d); err != nil {
|
|
|
|
|
+ return d, err
|
|
|
|
|
+ }
|
|
|
|
|
+ return d, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// mirrorAutoIncrement 用 TEMPORARY TABLE 复现「max+1」。
|
|
|
|
|
+//
|
|
|
|
|
+// 独立连接执行:临时表是会话级的,必须确保 CREATE / INSERT / SELECT 走同一条连接。
|
|
|
|
|
+func mirrorAutoIncrement(ctx context.Context, db *sql.DB, userID string, d *AutoIncrementDemo) error {
|
|
|
|
|
+ conn, err := db.Conn(ctx)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer conn.Close()
|
|
|
|
|
+
|
|
|
|
|
+ const tmp = "tmp_ac15_ai_probe"
|
|
|
|
|
+ steps := []string{
|
|
|
|
|
+ fmt.Sprintf("CREATE TEMPORARY TABLE `%s` LIKE t_user_expense_log", tmp),
|
|
|
|
|
+ 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),
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, s := range steps {
|
|
|
|
|
+ if _, err := conn.ExecContext(ctx, s); err != nil {
|
|
|
|
|
+ return fmt.Errorf("临时表镜像步骤失败(%s):%w", s, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ d.MirrorExplicitID = 100
|
|
|
|
|
+
|
|
|
|
|
+ res, err := conn.ExecContext(ctx, fmt.Sprintf(
|
|
|
|
|
+ "INSERT INTO `%s` (user_id, type, money, create_time, remark) VALUES ('%s', 0, '0.00', '1970-01-01 00:00:00', 'mirror auto id')",
|
|
|
|
|
+ tmp, userID))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return fmt.Errorf("临时表镜像第二次插入失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ mirrorID, err := res.LastInsertId()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ d.MirrorProbeID = mirrorID
|
|
|
|
|
+ d.MirrorExpected = d.MirrorExplicitID + 1
|
|
|
|
|
+ d.MirrorPass = mirrorID == d.MirrorExpected
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// floatTrap 采集「float 直接相加」与「逐行取整到分再相加」的差异证据。
|
|
|
|
|
+//
|
|
|
|
|
+// 三组数字:
|
|
|
|
|
+//
|
|
|
|
|
+// · Σ ROUND(x,2) —— 逐行取整后相加(迁移口径,也是双跑对账口径)
|
|
|
|
|
+// · ROUND(Σ x, 2) —— 先相加再取整(旧 SP 常见口径,与上面可能差 1 分)
|
|
|
|
|
+// · float64 直接累加 —— 展示二进制浮点的累加误差
|
|
|
|
|
+func floatTrap(ctx context.Context, legacy *sql.DB, userID int64) (FloatTrapDemo, error) {
|
|
|
|
|
+ var d FloatTrapDemo
|
|
|
|
|
+
|
|
|
|
|
+ // 逐条取值(3 位小数原文)
|
|
|
|
|
+ rows, err := legacy.QueryContext(ctx, `
|
|
|
|
|
+SELECT CAST(money AS DECIMAL(20,3))
|
|
|
|
|
+ FROM t_user_expense_log WHERE userid = ? ORDER BY id`, userID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return d, err
|
|
|
|
|
+ }
|
|
|
|
|
+ var raws []string
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var s string
|
|
|
|
|
+ if err := rows.Scan(&s); err != nil {
|
|
|
|
|
+ rows.Close()
|
|
|
|
|
+ return d, err
|
|
|
|
|
+ }
|
|
|
|
|
+ raws = append(raws, s)
|
|
|
|
|
+ }
|
|
|
|
|
+ rows.Close()
|
|
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
|
|
+ return d, err
|
|
|
|
|
+ }
|
|
|
|
|
+ d.LegacyValues = raws
|
|
|
|
|
+
|
|
|
|
|
+ var exactThousandths int64
|
|
|
|
|
+ var floatSum float64
|
|
|
|
|
+ for _, raw := range raws {
|
|
|
|
|
+ th, err := thousandthsOf(raw)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return d, err
|
|
|
|
|
+ }
|
|
|
|
|
+ exactThousandths += th
|
|
|
|
|
+ floatSum += float64Of(raw)
|
|
|
|
|
+
|
|
|
|
|
+ c, err := DecimalToCents(raw)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return d, err
|
|
|
|
|
+ }
|
|
|
|
|
+ d.PerRowRounded = append(d.PerRowRounded, CentsToDecimal(c))
|
|
|
|
|
+ d.SumOfRoundedCents += c
|
|
|
|
|
+ }
|
|
|
|
|
+ d.SumRoundedStr = CentsToDecimal(d.SumOfRoundedCents)
|
|
|
|
|
+ d.ExactSumThousandths = exactThousandths
|
|
|
|
|
+ d.FloatSumRaw = floatSum
|
|
|
|
|
+ d.FloatAccumErr = floatSum - float64(exactThousandths)/1000
|
|
|
|
|
+
|
|
|
|
|
+ // 服务端口径:ROUND(Σ x, 2) —— 与 Σ ROUND(x,2) 可能差 1 分(这就是「先加后取整」的坑)
|
|
|
|
|
+ var roundOfSum string
|
|
|
|
|
+ if err := legacy.QueryRowContext(ctx,
|
|
|
|
|
+ `SELECT CAST(SUM(money) AS DECIMAL(12,2)) FROM t_user_expense_log WHERE userid = ?`,
|
|
|
|
|
+ userID).Scan(&roundOfSum); err != nil {
|
|
|
|
|
+ return d, err
|
|
|
|
|
+ }
|
|
|
|
|
+ d.RoundOfSumStr = roundOfSum
|
|
|
|
|
+ rc, err := DecimalToCents(roundOfSum)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return d, err
|
|
|
|
|
+ }
|
|
|
|
|
+ d.DeltaVsPerRow = rc - d.SumOfRoundedCents
|
|
|
|
|
+ return d, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// SortedTableNames 返回核对表出现过的表名(去重后排序),供报告分节打印。
|
|
|
|
|
+func (r *VerifyReport) SortedTableNames() []string {
|
|
|
|
|
+ seen := map[string]bool{}
|
|
|
|
|
+ var out []string
|
|
|
|
|
+ for _, c := range r.Checks {
|
|
|
|
|
+ if !seen[c.Table] {
|
|
|
|
|
+ seen[c.Table] = true
|
|
|
|
|
+ out = append(out, c.Table)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ sort.Strings(out)
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// FormatFloat 用 %.17g 展示 float64,暴露二进制尾差。
|
|
|
|
|
+func FormatFloat(v float64) string {
|
|
|
|
|
+ if math.IsNaN(v) {
|
|
|
|
|
+ return "NaN"
|
|
|
|
|
+ }
|
|
|
|
|
+ return strconv.FormatFloat(v, 'g', 17, 64)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// NowTruncSec 仅用于报告时间戳。
|
|
|
|
|
+func NowTruncSec() time.Time { return time.Now().Truncate(time.Second) }
|