|
|
@@ -0,0 +1,1044 @@
|
|
|
+package fixture
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "database/sql"
|
|
|
+ "fmt"
|
|
|
+ "sort"
|
|
|
+ "strconv"
|
|
|
+ "strings"
|
|
|
+)
|
|
|
+
|
|
|
+// 本文件是第二批的**逐列核对**。两条与上一批相同的铁律:
|
|
|
+// 1. 期望值**不由本包映射代码产生**:金额/时间一律由 MySQL 服务端
|
|
|
+// `CAST(... AS DECIMAL(12,2))` / `DATE_FORMAT(...)` 算出;
|
|
|
+// 2. 用户引用(ULID)的期望值来自**目标库 t_legacy_mapping 的实际数据**
|
|
|
+// (反向 JOIN 查出来),而不是调用 FixtureCanonicalUserID —— 否则映射写错时两边一起错。
|
|
|
+//
|
|
|
+// 另外本批专门验证 biz_no 四项(原样保留 / 幂等 / id 解耦 / 反查可用)。
|
|
|
+
|
|
|
+// ColumnAgg 是「一列 × N 行」的聚合核对结果(避免 600+ 行逐格输出)。
|
|
|
+type ColumnAgg struct {
|
|
|
+ Table string
|
|
|
+ Column string
|
|
|
+ Total int
|
|
|
+ Equal int
|
|
|
+ Samples []string // 最多 3 条不一致样例
|
|
|
+ Note string
|
|
|
+}
|
|
|
+
|
|
|
+// OK 表示该列所有行都相等。
|
|
|
+func (c ColumnAgg) OK() bool { return c.Total > 0 && c.Equal == c.Total }
|
|
|
+
|
|
|
+// colAcc 是聚合累加器。
|
|
|
+type colAcc struct {
|
|
|
+ table, column, note string
|
|
|
+ total, equal int
|
|
|
+ samples []string
|
|
|
+}
|
|
|
+
|
|
|
+func newCol(table, column, note string) *colAcc {
|
|
|
+ return &colAcc{table: table, column: column, note: note}
|
|
|
+}
|
|
|
+
|
|
|
+// cmp 比一行;不一致时留样例(最多 3 条)。
|
|
|
+func (a *colAcc) cmp(rowKey, legacy, target string, ok bool) {
|
|
|
+ a.total++
|
|
|
+ if ok {
|
|
|
+ a.equal++
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if len(a.samples) < 3 {
|
|
|
+ a.samples = append(a.samples, fmt.Sprintf("%s: legacy=%q target=%q", rowKey, truncate(legacy, 28), truncate(target, 28)))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func (a *colAcc) done() ColumnAgg {
|
|
|
+ return ColumnAgg{Table: a.table, Column: a.column, Total: a.total, Equal: a.equal,
|
|
|
+ Samples: a.samples, Note: a.note}
|
|
|
+}
|
|
|
+
|
|
|
+// BizNoVerify 是 biz_no 四项验证的结果。
|
|
|
+type BizNoVerify struct {
|
|
|
+ Table string
|
|
|
+ Rows int
|
|
|
+ CharEqual int // 逐字符相等的行数
|
|
|
+ Len32 int // 长度为 32 的行数
|
|
|
+ HexOK int // 32 位小写 hex 的行数
|
|
|
+ UniqueBizNo int // DISTINCT biz_no 数
|
|
|
+ IDEqualsPrefix bool // id 是否等于 biz_no 派生的数字(**必须为 false**,证明未做反算)
|
|
|
+ ReverseJoin int // 用 biz_no JOIN 回 legacy 的行数
|
|
|
+ LegacyRows int
|
|
|
+ MirrorOK bool // 反序插入镜像:id 跟插入顺序、不跟 biz_no
|
|
|
+ MirrorSample []string // 镜像里 (id, biz_no) 前 5 行
|
|
|
+ AutoIncrementVal int64
|
|
|
+ MaxID int64
|
|
|
+}
|
|
|
+
|
|
|
+// D1Verify 是 D1 口径的 id 一致性结果。
|
|
|
+type D1Verify struct {
|
|
|
+ Table string
|
|
|
+ LegacyIDs []int64
|
|
|
+ TargetIDs []int64
|
|
|
+ Pass bool
|
|
|
+}
|
|
|
+
|
|
|
+// Batch2VerifyReport 是整轮核对结果。
|
|
|
+type Batch2VerifyReport struct {
|
|
|
+ Options Batch2Options
|
|
|
+ Cols []ColumnAgg
|
|
|
+ Assertions []Assertion
|
|
|
+ BizNo []BizNoVerify
|
|
|
+ D1 []D1Verify
|
|
|
+ TargetRows map[string]int64
|
|
|
+ Warnings []string
|
|
|
+}
|
|
|
+
|
|
|
+// FailCount 统计不一致列数 + 失败断言数。
|
|
|
+func (r *Batch2VerifyReport) FailCount() int {
|
|
|
+ n := 0
|
|
|
+ for _, c := range r.Cols {
|
|
|
+ if !c.OK() {
|
|
|
+ n++
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for _, a := range r.Assertions {
|
|
|
+ if !a.Pass {
|
|
|
+ n++
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return n
|
|
|
+}
|
|
|
+
|
|
|
+// OK 表示整轮通过。
|
|
|
+func (r *Batch2VerifyReport) OK() bool { return r.FailCount() == 0 }
|
|
|
+
|
|
|
+// ulidIndex 从目标库 t_legacy_mapping 反查 legacy int → ULID(数据驱动的期望值来源)。
|
|
|
+func ulidIndex(ctx context.Context, db *sql.DB) (map[int64]string, error) {
|
|
|
+ rows, err := db.QueryContext(ctx,
|
|
|
+ `SELECT legacy_academic_user_id, canonical_user_id FROM t_legacy_mapping
|
|
|
+ WHERE legacy_academic_user_id IS NOT NULL`)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ defer rows.Close()
|
|
|
+ out := map[int64]string{}
|
|
|
+ for rows.Next() {
|
|
|
+ var id int64
|
|
|
+ var uid string
|
|
|
+ if err := rows.Scan(&id, &uid); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ out[id] = uid
|
|
|
+ }
|
|
|
+ return out, rows.Err()
|
|
|
+}
|
|
|
+
|
|
|
+// expectedULID 用映射表数据解析期望 ULID;0/NULL/无映射 → "NULL"。
|
|
|
+func expectedULID(idx map[int64]string, v sql.NullInt64) string {
|
|
|
+ if !v.Valid || v.Int64 <= 0 {
|
|
|
+ return "NULL"
|
|
|
+ }
|
|
|
+ if uid, ok := idx[v.Int64]; ok {
|
|
|
+ return uid
|
|
|
+ }
|
|
|
+ return "UNMAPPED(" + strconv.FormatInt(v.Int64, 10) + ")"
|
|
|
+}
|
|
|
+
|
|
|
+func nullStrOrNull(v sql.NullString) string {
|
|
|
+ if !v.Valid {
|
|
|
+ return "NULL"
|
|
|
+ }
|
|
|
+ return v.String
|
|
|
+}
|
|
|
+
|
|
|
+// VerifyBatch2 执行第二批的完整核对。
|
|
|
+func VerifyBatch2(ctx context.Context, cfg Config, opts Batch2Options) (*Batch2VerifyReport, error) {
|
|
|
+ legacy, err := cfg.OpenLegacy()
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ defer legacy.Close()
|
|
|
+ target, err := cfg.OpenTarget()
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ defer target.Close()
|
|
|
+
|
|
|
+ idx, err := ulidIndex(ctx, target)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ rep := &Batch2VerifyReport{Options: opts, TargetRows: map[string]int64{}}
|
|
|
+ for _, t := range []string{"t_expenditure", "t_user_divide_into", "t_user_divide_into_log",
|
|
|
+ "t_user_balance_request", "t_expenditure_log"} {
|
|
|
+ var n int64
|
|
|
+ if err := target.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&n); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ rep.TargetRows[t] = n
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := verifyDivideInto(ctx, legacy, target, opts, idx, rep); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ if err := verifyDivideIntoLog(ctx, legacy, target, opts, idx, rep); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ if err := verifyBalanceRequest(ctx, legacy, target, opts, idx, rep); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ if err := verifyExpenditureLog(ctx, legacy, target, opts, idx, rep); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ if opts.WithExpenditureDict {
|
|
|
+ if err := verifyExpenditureDict(ctx, legacy, target, rep); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if err := verifyBizNoFour(ctx, legacy, target, opts, rep); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ return rep, nil
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- t_user_divide_into ----------
|
|
|
+
|
|
|
+func verifyDivideInto(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
|
|
|
+ idx map[int64]string, rep *Batch2VerifyReport) error {
|
|
|
+ type row struct {
|
|
|
+ UserID int64
|
|
|
+ Type int32
|
|
|
+ Quota, Income, Payed string
|
|
|
+ Root, Parent sql.NullInt64
|
|
|
+ Left, Right, Layer int32
|
|
|
+ }
|
|
|
+ const q = `
|
|
|
+SELECT userid, cast(ifnull(type,1) as signed),
|
|
|
+ CAST(ROUND(CAST(ifnull(quota,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
+ CAST(ROUND(CAST(ifnull(total_income,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
+ CAST(ROUND(CAST(ifnull(total_income_payed,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
+ root_userid, parent_userid,
|
|
|
+ cast(ifnull(left_val,1) as signed), cast(ifnull(right_val,2) as signed),
|
|
|
+ cast(ifnull(layer,1) as signed)
|
|
|
+ FROM t_user_divide_into WHERE root_userid = ? ORDER BY left_val, userid`
|
|
|
+ rows, err := legacy.QueryContext(ctx, q, opts.DivideRootUserID)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ var exp []row
|
|
|
+ for rows.Next() {
|
|
|
+ var r row
|
|
|
+ if err := rows.Scan(&r.UserID, &r.Type, &r.Quota, &r.Income, &r.Payed,
|
|
|
+ &r.Root, &r.Parent, &r.Left, &r.Right, &r.Layer); err != nil {
|
|
|
+ rows.Close()
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ exp = append(exp, r)
|
|
|
+ }
|
|
|
+ rows.Close()
|
|
|
+
|
|
|
+ cUser := newCol("t_user_divide_into", "user_id", "legacy userid → ULID")
|
|
|
+ cType := newCol("t_user_divide_into", "type", "")
|
|
|
+ cQuota := newCol("t_user_divide_into", "quota", "float(10,2) → DECIMAL(12,2)")
|
|
|
+ cIncome := newCol("t_user_divide_into", "total_income", "")
|
|
|
+ cPayed := newCol("t_user_divide_into", "total_income_payed", "")
|
|
|
+ cRoot := newCol("t_user_divide_into", "root_user_id", "根节点=自身")
|
|
|
+ cParent := newCol("t_user_divide_into", "parent_user_id", "legacy parent_userid=0(根)→ NULL")
|
|
|
+ cLeft := newCol("t_user_divide_into", "left_val", "Nested Set")
|
|
|
+ cRight := newCol("t_user_divide_into", "right_val", "Nested Set")
|
|
|
+ cLayer := newCol("t_user_divide_into", "layer", "")
|
|
|
+
|
|
|
+ for _, e := range exp {
|
|
|
+ rowKey := strconv.FormatInt(e.UserID, 10)
|
|
|
+ wantUID := expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true})
|
|
|
+ var got struct {
|
|
|
+ UserID string
|
|
|
+ Type int8
|
|
|
+ Quota, Income, Payed string
|
|
|
+ Root, Parent sql.NullString
|
|
|
+ Left, Right, Layer int32
|
|
|
+ }
|
|
|
+ err := target.QueryRowContext(ctx, `
|
|
|
+SELECT user_id, type, CAST(quota AS DECIMAL(12,2)), CAST(total_income AS DECIMAL(12,2)),
|
|
|
+ CAST(total_income_payed AS DECIMAL(12,2)), root_user_id, parent_user_id,
|
|
|
+ left_val, right_val, layer
|
|
|
+ FROM t_user_divide_into WHERE user_id = ?`, wantUID).Scan(
|
|
|
+ &got.UserID, &got.Type, &got.Quota, &got.Income, &got.Payed,
|
|
|
+ &got.Root, &got.Parent, &got.Left, &got.Right, &got.Layer)
|
|
|
+ if err == sql.ErrNoRows {
|
|
|
+ cUser.cmp(rowKey, wantUID, "<缺失>", false)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ cUser.cmp(rowKey, wantUID, got.UserID, got.UserID == wantUID)
|
|
|
+ cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(got.Type), int32(got.Type) == e.Type)
|
|
|
+ cQuota.cmp(rowKey, e.Quota, got.Quota, normalizeMoney(e.Quota) == normalizeMoney(got.Quota))
|
|
|
+ cIncome.cmp(rowKey, e.Income, got.Income, normalizeMoney(e.Income) == normalizeMoney(got.Income))
|
|
|
+ cPayed.cmp(rowKey, e.Payed, got.Payed, normalizeMoney(e.Payed) == normalizeMoney(got.Payed))
|
|
|
+ cRoot.cmp(rowKey, expectedULID(idx, e.Root), nullStrOrNull(got.Root),
|
|
|
+ nullStrOrNull(got.Root) == expectedULID(idx, e.Root))
|
|
|
+ cParent.cmp(rowKey, expectedULID(idx, e.Parent), nullStrOrNull(got.Parent),
|
|
|
+ nullStrOrNull(got.Parent) == expectedULID(idx, e.Parent))
|
|
|
+ cLeft.cmp(rowKey, fmt.Sprint(e.Left), fmt.Sprint(got.Left), got.Left == e.Left)
|
|
|
+ cRight.cmp(rowKey, fmt.Sprint(e.Right), fmt.Sprint(got.Right), got.Right == e.Right)
|
|
|
+ cLayer.cmp(rowKey, fmt.Sprint(e.Layer), fmt.Sprint(got.Layer), got.Layer == e.Layer)
|
|
|
+ }
|
|
|
+ if len(exp) == 0 {
|
|
|
+ cUser.cmp("-", "0 行", "0 行", false)
|
|
|
+ }
|
|
|
+ rep.Cols = append(rep.Cols, cUser.done(), cType.done(), cQuota.done(), cIncome.done(),
|
|
|
+ cPayed.done(), cRoot.done(), cParent.done(), cLeft.done(), cRight.done(), cLayer.done())
|
|
|
+
|
|
|
+ // 断言:树上**恰好 1 个根**(parent_user_id IS NULL),且该根的 root_user_id = user_id
|
|
|
+ var total, nullParent, selfRoot int
|
|
|
+ if err := target.QueryRowContext(ctx, `
|
|
|
+SELECT COUNT(*),
|
|
|
+ SUM(parent_user_id IS NULL),
|
|
|
+ SUM(parent_user_id IS NULL AND root_user_id = user_id)
|
|
|
+ FROM t_user_divide_into`).Scan(&total, &nullParent, &selfRoot); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ rep.Assertions = append(rep.Assertions, Assertion{
|
|
|
+ Name: "分账树根节点 parent_user_id 为 NULL,且根节点 root_user_id = user_id(legacy parent_userid=0 / root_userid=自身)",
|
|
|
+ Pass: nullParent == 1 && selfRoot == 1,
|
|
|
+ Detail: fmt.Sprintf("t_user_divide_into 共 %d 行:parent_user_id IS NULL 的 %d 行(应=1),"+
|
|
|
+ "其中 root_user_id=user_id 的 %d 行(应=1)", total, nullParent, selfRoot),
|
|
|
+ })
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- t_user_divide_into_log ----------
|
|
|
+
|
|
|
+func verifyDivideIntoLog(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
|
|
|
+ idx map[int64]string, rep *Batch2VerifyReport) error {
|
|
|
+ type row struct {
|
|
|
+ ID int64
|
|
|
+ Type int32
|
|
|
+ UserID int64
|
|
|
+ Student int64
|
|
|
+ OrderNo sql.NullString
|
|
|
+ Money string
|
|
|
+ TimeStr sql.NullString
|
|
|
+ Remark sql.NullString
|
|
|
+ }
|
|
|
+ const q = `
|
|
|
+SELECT id, cast(type as signed), userid, studentid, orderid,
|
|
|
+ CAST(ROUND(CAST(ifnull(money,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
+ DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'), remark
|
|
|
+ FROM t_user_divide_into_log WHERE userid = ? ORDER BY id`
|
|
|
+ rows, err := legacy.QueryContext(ctx, q, opts.DivideRootUserID)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ var exp []row
|
|
|
+ for rows.Next() {
|
|
|
+ var r row
|
|
|
+ if err := rows.Scan(&r.ID, &r.Type, &r.UserID, &r.Student, &r.OrderNo, &r.Money, &r.TimeStr, &r.Remark); err != nil {
|
|
|
+ rows.Close()
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ exp = append(exp, r)
|
|
|
+ }
|
|
|
+ rows.Close()
|
|
|
+
|
|
|
+ cID := newCol("t_user_divide_into_log", "id", "D1:与 legacy id 完全相同")
|
|
|
+ cType := newCol("t_user_divide_into_log", "type", "tinyint zerofill → TINYINT")
|
|
|
+ cUser := newCol("t_user_divide_into_log", "user_id", "userid → ULID")
|
|
|
+ cStudent := newCol("t_user_divide_into_log", "student_id", "studentid → ULID")
|
|
|
+ cOrder := newCol("t_user_divide_into_log", "order_no", "orderid → order_no")
|
|
|
+ cMoney := newCol("t_user_divide_into_log", "money", "float(10,2) → DECIMAL(12,2)")
|
|
|
+ cTime := newCol("t_user_divide_into_log", "create_time", "秒级相同,毫秒 .000")
|
|
|
+ cRemark := newCol("t_user_divide_into_log", "remark", "text → LONGTEXT,报告脱敏")
|
|
|
+
|
|
|
+ var legacyIDs, targetIDs []int64
|
|
|
+ for _, e := range exp {
|
|
|
+ rowKey := "id=" + strconv.FormatInt(e.ID, 10)
|
|
|
+ legacyIDs = append(legacyIDs, e.ID)
|
|
|
+ var g struct {
|
|
|
+ ID int64
|
|
|
+ Type int8
|
|
|
+ UserID string
|
|
|
+ Student string
|
|
|
+ OrderNo sql.NullString
|
|
|
+ Money string
|
|
|
+ TimeStr sql.NullString
|
|
|
+ Remark sql.NullString
|
|
|
+ }
|
|
|
+ err := target.QueryRowContext(ctx, `
|
|
|
+SELECT id, type, user_id, student_id, order_no,
|
|
|
+ CAST(money AS DECIMAL(12,2)),
|
|
|
+ DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'), remark
|
|
|
+ FROM t_user_divide_into_log WHERE id = ?`, e.ID).Scan(
|
|
|
+ &g.ID, &g.Type, &g.UserID, &g.Student, &g.OrderNo, &g.Money, &g.TimeStr, &g.Remark)
|
|
|
+ if err == sql.ErrNoRows {
|
|
|
+ cID.cmp(rowKey, strconv.FormatInt(e.ID, 10), "<缺失>", false)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ targetIDs = append(targetIDs, g.ID)
|
|
|
+ cID.cmp(rowKey, strconv.FormatInt(e.ID, 10), strconv.FormatInt(g.ID, 10), g.ID == e.ID)
|
|
|
+ cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(g.Type), int32(g.Type) == e.Type)
|
|
|
+ cUser.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}), g.UserID,
|
|
|
+ g.UserID == expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}))
|
|
|
+ cStudent.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.Student, Valid: true}), g.Student,
|
|
|
+ g.Student == expectedULID(idx, sql.NullInt64{Int64: e.Student, Valid: true}))
|
|
|
+ cOrder.cmp(rowKey, nullStrOrNull(e.OrderNo), nullStrOrNull(g.OrderNo), nullStrOrNull(e.OrderNo) == nullStrOrNull(g.OrderNo))
|
|
|
+ cMoney.cmp(rowKey, e.Money, g.Money, normalizeMoney(e.Money) == normalizeMoney(g.Money))
|
|
|
+ cTime.cmp(rowKey, nullStrOrNull(e.TimeStr), nullStrOrNull(g.TimeStr), nullStrOrNull(e.TimeStr) == nullStrOrNull(g.TimeStr))
|
|
|
+ cRemark.cmp(rowKey, maskRemark(e.Remark), maskRemark(g.Remark), sameRemark(e.Remark, g.Remark))
|
|
|
+ }
|
|
|
+ rep.Cols = append(rep.Cols, cID.done(), cType.done(), cUser.done(), cStudent.done(),
|
|
|
+ cOrder.done(), cMoney.done(), cTime.done(), cRemark.done())
|
|
|
+
|
|
|
+ rep.D1 = append(rep.D1, D1Verify{
|
|
|
+ Table: "t_user_divide_into_log",
|
|
|
+ LegacyIDs: legacyIDs,
|
|
|
+ TargetIDs: targetIDs,
|
|
|
+ Pass: int64SliceEqual(legacyIDs, targetIDs),
|
|
|
+ })
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func int64SliceEqual(a, b []int64) bool {
|
|
|
+ if len(a) != len(b) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ x := append([]int64(nil), a...)
|
|
|
+ y := append([]int64(nil), b...)
|
|
|
+ sort.Slice(x, func(i, j int) bool { return x[i] < x[j] })
|
|
|
+ sort.Slice(y, func(i, j int) bool { return y[i] < y[j] })
|
|
|
+ for i := range x {
|
|
|
+ if x[i] != y[i] {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- t_user_balance_request ----------
|
|
|
+
|
|
|
+func verifyBalanceRequest(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
|
|
|
+ idx map[int64]string, rep *Batch2VerifyReport) error {
|
|
|
+ type row struct {
|
|
|
+ BizNo string
|
|
|
+ Type int32
|
|
|
+ UserID int64
|
|
|
+ SchoolID, CampusID int64
|
|
|
+ Money sql.NullString
|
|
|
+ TransferType int8
|
|
|
+ TransferTime sql.NullString
|
|
|
+ OrderNo sql.NullString
|
|
|
+ ReqUser int64
|
|
|
+ ReqTime sql.NullString
|
|
|
+ RespUser sql.NullInt64
|
|
|
+ RespTime sql.NullString
|
|
|
+ RespMsg, Item, Imgs, Remark sql.NullString
|
|
|
+ Aproved int8
|
|
|
+ }
|
|
|
+ const q = `
|
|
|
+SELECT id, type, userid, schoolid, campusid,
|
|
|
+ CAST(ROUND(CAST(money AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
+ transfer_accounts_type, DATE_FORMAT(transfer_accounts_time,'%Y-%m-%d %H:%i:%s.%f'),
|
|
|
+ orderid, request_userid, DATE_FORMAT(request_time,'%Y-%m-%d %H:%i:%s.%f'),
|
|
|
+ response_userid, DATE_FORMAT(response_time,'%Y-%m-%d %H:%i:%s.%f'),
|
|
|
+ response_msg, item, imgs, remark, aproved
|
|
|
+ FROM t_user_balance_request WHERE userid = ? ORDER BY request_time, id`
|
|
|
+ rows, err := legacy.QueryContext(ctx, q, opts.BalanceRequestUserID)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ var exp []row
|
|
|
+ for rows.Next() {
|
|
|
+ var r row
|
|
|
+ if err := rows.Scan(&r.BizNo, &r.Type, &r.UserID, &r.SchoolID, &r.CampusID, &r.Money,
|
|
|
+ &r.TransferType, &r.TransferTime, &r.OrderNo, &r.ReqUser, &r.ReqTime,
|
|
|
+ &r.RespUser, &r.RespTime, &r.RespMsg, &r.Item, &r.Imgs, &r.Remark, &r.Aproved); err != nil {
|
|
|
+ rows.Close()
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ exp = append(exp, r)
|
|
|
+ }
|
|
|
+ rows.Close()
|
|
|
+
|
|
|
+ const t = "t_user_balance_request"
|
|
|
+ cBiz := newCol(t, "biz_no", "旧 UUID 主键原样保留(逐字符)")
|
|
|
+ cType := newCol(t, "type", "")
|
|
|
+ cUser := newCol(t, "user_id", "userid → ULID")
|
|
|
+ cInst := newCol(t, "institution_id", "schoolid → institution_id(org 域未映射,直迁)")
|
|
|
+ cCampus := newCol(t, "campus_id", "campusid → campus_id(同上)")
|
|
|
+ cToInst := newCol(t, "to_institution_id", "legacy 无此列 → 必须 NULL")
|
|
|
+ cToCampus := newCol(t, "to_campus_id", "legacy 无此列 → 必须 NULL")
|
|
|
+ cMoney := newCol(t, "money", "decimal(10,3) → DECIMAL(12,2)")
|
|
|
+ cTT := newCol(t, "transfer_accounts_type", "")
|
|
|
+ cTTime := newCol(t, "transfer_accounts_time", "NULL → NULL")
|
|
|
+ cOrder := newCol(t, "order_no", "orderid → order_no")
|
|
|
+ cReqUser := newCol(t, "request_user_id", "request_userid → ULID")
|
|
|
+ cReqTime := newCol(t, "request_time", "秒级相同")
|
|
|
+ cRespUser := newCol(t, "response_user_id", "0 → NULL")
|
|
|
+ cRespTime := newCol(t, "response_time", "NULL → NULL")
|
|
|
+ cRespMsg := newCol(t, "response_msg", "空串保留空串")
|
|
|
+ cApproved := newCol(t, "approved", "aproved 错拼修正;-2/1 原样")
|
|
|
+ cItem := newCol(t, "item", "text → varchar(1000),报告脱敏")
|
|
|
+ cImgs := newCol(t, "imgs", "text → LONGTEXT,比对原文")
|
|
|
+ cRemark := newCol(t, "remark", "空串保留空串,报告脱敏")
|
|
|
+
|
|
|
+ var zeroResp int
|
|
|
+ for _, e := range exp {
|
|
|
+ rowKey := "biz=" + shortBizNo(e.BizNo)
|
|
|
+ if e.RespUser.Valid && e.RespUser.Int64 == 0 {
|
|
|
+ zeroResp++
|
|
|
+ }
|
|
|
+ var g struct {
|
|
|
+ ID int64
|
|
|
+ BizNo string
|
|
|
+ Type int32
|
|
|
+ UserID string
|
|
|
+ Inst, Campus int64
|
|
|
+ ToInst, ToCampus sql.NullInt64
|
|
|
+ Money string
|
|
|
+ TT int8
|
|
|
+ TTime, OrderNo, ReqTime sql.NullString
|
|
|
+ ReqUser string
|
|
|
+ RespUser sql.NullString
|
|
|
+ RespTime, RespMsg, Item, Imgs, Remark sql.NullString
|
|
|
+ Approved int8
|
|
|
+ }
|
|
|
+ err := target.QueryRowContext(ctx, `
|
|
|
+SELECT id, biz_no, type, user_id, institution_id, campus_id, to_institution_id, to_campus_id,
|
|
|
+ CAST(money AS DECIMAL(12,2)), transfer_accounts_type,
|
|
|
+ DATE_FORMAT(transfer_accounts_time,'%Y-%m-%d %H:%i:%s.%f'), order_no,
|
|
|
+ request_user_id, DATE_FORMAT(request_time,'%Y-%m-%d %H:%i:%s.%f'),
|
|
|
+ response_user_id, DATE_FORMAT(response_time,'%Y-%m-%d %H:%i:%s.%f'),
|
|
|
+ response_msg, approved, item, imgs, remark
|
|
|
+ FROM t_user_balance_request WHERE biz_no = ?`, e.BizNo).Scan(
|
|
|
+ &g.ID, &g.BizNo, &g.Type, &g.UserID, &g.Inst, &g.Campus, &g.ToInst, &g.ToCampus,
|
|
|
+ &g.Money, &g.TT, &g.TTime, &g.OrderNo, &g.ReqUser, &g.ReqTime, &g.RespUser,
|
|
|
+ &g.RespTime, &g.RespMsg, &g.Approved, &g.Item, &g.Imgs, &g.Remark)
|
|
|
+ if err == sql.ErrNoRows {
|
|
|
+ cBiz.cmp(rowKey, e.BizNo, "<缺失>", false)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ cBiz.cmp(rowKey, e.BizNo, g.BizNo, g.BizNo == e.BizNo)
|
|
|
+ cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(g.Type), g.Type == e.Type)
|
|
|
+ cUser.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}), g.UserID,
|
|
|
+ g.UserID == expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}))
|
|
|
+ cInst.cmp(rowKey, fmt.Sprint(e.SchoolID), fmt.Sprint(g.Inst), g.Inst == e.SchoolID)
|
|
|
+ cCampus.cmp(rowKey, fmt.Sprint(e.CampusID), fmt.Sprint(g.Campus), g.Campus == e.CampusID)
|
|
|
+ cToInst.cmp(rowKey, "NULL", fmt.Sprint(g.ToInst.Int64), !g.ToInst.Valid)
|
|
|
+ cToCampus.cmp(rowKey, "NULL", fmt.Sprint(g.ToCampus.Int64), !g.ToCampus.Valid)
|
|
|
+ cMoney.cmp(rowKey, nullStrOrNull(e.Money), g.Money,
|
|
|
+ normalizeMoney(orZero(e.Money)) == normalizeMoney(g.Money))
|
|
|
+ cTT.cmp(rowKey, fmt.Sprint(e.TransferType), fmt.Sprint(g.TT), g.TT == e.TransferType)
|
|
|
+ cTTime.cmp(rowKey, nullStrOrNull(e.TransferTime), nullStrOrNull(g.TTime),
|
|
|
+ nullStrOrNull(e.TransferTime) == nullStrOrNull(g.TTime))
|
|
|
+ cOrder.cmp(rowKey, nullStrOrNull(e.OrderNo), nullStrOrNull(g.OrderNo),
|
|
|
+ nullStrOrNull(e.OrderNo) == nullStrOrNull(g.OrderNo))
|
|
|
+ cReqUser.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.ReqUser, Valid: true}), g.ReqUser,
|
|
|
+ g.ReqUser == expectedULID(idx, sql.NullInt64{Int64: e.ReqUser, Valid: true}))
|
|
|
+ cReqTime.cmp(rowKey, nullStrOrNull(e.ReqTime), nullStrOrNull(g.ReqTime),
|
|
|
+ nullStrOrNull(e.ReqTime) == nullStrOrNull(g.ReqTime))
|
|
|
+ cRespUser.cmp(rowKey, expectedULID(idx, e.RespUser), nullStrOrNull(g.RespUser),
|
|
|
+ nullStrOrNull(g.RespUser) == expectedULID(idx, e.RespUser))
|
|
|
+ cRespTime.cmp(rowKey, nullStrOrNull(e.RespTime), nullStrOrNull(g.RespTime),
|
|
|
+ nullStrOrNull(e.RespTime) == nullStrOrNull(g.RespTime))
|
|
|
+ cRespMsg.cmp(rowKey, maskText(e.RespMsg, 10), maskText(fromStr(g.RespMsg), 10), sameNullString(e.RespMsg, g.RespMsg))
|
|
|
+ cApproved.cmp(rowKey, fmt.Sprint(e.Aproved), fmt.Sprint(g.Approved), g.Approved == e.Aproved)
|
|
|
+ cItem.cmp(rowKey, maskText(e.Item, 16), maskText(fromStr(g.Item), 16), sameNullString(e.Item, g.Item))
|
|
|
+ cImgs.cmp(rowKey, maskText(e.Imgs, 8), maskText(fromStr(g.Imgs), 8), sameNullString(e.Imgs, g.Imgs))
|
|
|
+ cRemark.cmp(rowKey, maskText(e.Remark, 10), maskText(fromStr(g.Remark), 10), sameNullString(e.Remark, g.Remark))
|
|
|
+ }
|
|
|
+ rep.Cols = append(rep.Cols, cBiz.done(), cType.done(), cUser.done(), cInst.done(), cCampus.done(),
|
|
|
+ cToInst.done(), cToCampus.done(), cMoney.done(), cTT.done(), cTTime.done(), cOrder.done(),
|
|
|
+ cReqUser.done(), cReqTime.done(), cRespUser.done(), cRespTime.done(), cRespMsg.done(),
|
|
|
+ cApproved.done(), cItem.done(), cImgs.done(), cRemark.done())
|
|
|
+
|
|
|
+ rep.Assertions = append(rep.Assertions, Assertion{
|
|
|
+ Name: "response_userid=0 → response_user_id 为 NULL(不是 0/空串)",
|
|
|
+ Pass: countNonNullRespUser(ctx, target, opts.BalanceRequestUserID) == 0,
|
|
|
+ Detail: fmt.Sprintf("legacy 有 %d 行 response_userid=0;target 侧该用户 response_user_id 非 NULL 且为空串的行数必须为 0",
|
|
|
+ zeroResp),
|
|
|
+ })
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// countNonNullRespUser 统计 fixture 用户下 response_user_id 落成 0/空串的行数(必须为 0)。
|
|
|
+func countNonNullRespUser(ctx context.Context, db *sql.DB, legacyUserID int64) int {
|
|
|
+ uid := FixtureCanonicalUserID(legacyUserID)
|
|
|
+ var n int
|
|
|
+ if err := db.QueryRowContext(ctx,
|
|
|
+ `SELECT COUNT(*) FROM t_user_balance_request
|
|
|
+ WHERE user_id = ? AND (response_user_id = '0' OR response_user_id = '')`, uid).Scan(&n); err != nil {
|
|
|
+ return -1
|
|
|
+ }
|
|
|
+ return n
|
|
|
+}
|
|
|
+
|
|
|
+func orZero(v sql.NullString) string {
|
|
|
+ if !v.Valid || v.String == "" {
|
|
|
+ return "0.000"
|
|
|
+ }
|
|
|
+ return v.String
|
|
|
+}
|
|
|
+
|
|
|
+func fromStr(v sql.NullString) sql.NullString { return v }
|
|
|
+
|
|
|
+func sameNullString(a, b sql.NullString) bool {
|
|
|
+ if a.Valid != b.Valid {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if !a.Valid {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ return a.String == b.String
|
|
|
+}
|
|
|
+
|
|
|
+// maskText 报告脱敏:只展示前 n 个字符 + 总长度。比对用原文(sameNullString)。
|
|
|
+func maskText(v sql.NullString, n int) string {
|
|
|
+ if !v.Valid {
|
|
|
+ return "NULL"
|
|
|
+ }
|
|
|
+ r := []rune(v.String)
|
|
|
+ head := r
|
|
|
+ if len(head) > n {
|
|
|
+ head = head[:n]
|
|
|
+ }
|
|
|
+ return fmt.Sprintf("%s…(len=%d)", string(head), len(r))
|
|
|
+}
|
|
|
+
|
|
|
+func shortBizNo(s string) string {
|
|
|
+ if len(s) > 8 {
|
|
|
+ return s[:8] + "…"
|
|
|
+ }
|
|
|
+ return s
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- t_expenditure_log ----------
|
|
|
+
|
|
|
+func verifyExpenditureLog(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
|
|
|
+ idx map[int64]string, rep *Batch2VerifyReport) error {
|
|
|
+ type row struct {
|
|
|
+ BizNo string
|
|
|
+ SchoolID, CampusID int64
|
|
|
+ ExpenditureID int64
|
|
|
+ Cost string
|
|
|
+ CreateUser sql.NullInt64
|
|
|
+ TimeStr sql.NullString
|
|
|
+ Year, Month sql.NullInt64
|
|
|
+ Remark, Imgs sql.NullString
|
|
|
+ }
|
|
|
+ const q = `
|
|
|
+SELECT id, schoolid, campusid, expenditureid,
|
|
|
+ CAST(ROUND(CAST(cost AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
+ create_userid, DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'),
|
|
|
+ year, month, remark, imgs
|
|
|
+ FROM t_expenditure_log WHERE create_userid = ? ORDER BY id`
|
|
|
+ rows, err := legacy.QueryContext(ctx, q, opts.ExpenditureLogUserID)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ var exp []row
|
|
|
+ for rows.Next() {
|
|
|
+ var r row
|
|
|
+ if err := rows.Scan(&r.BizNo, &r.SchoolID, &r.CampusID, &r.ExpenditureID, &r.Cost,
|
|
|
+ &r.CreateUser, &r.TimeStr, &r.Year, &r.Month, &r.Remark, &r.Imgs); err != nil {
|
|
|
+ rows.Close()
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ exp = append(exp, r)
|
|
|
+ }
|
|
|
+ rows.Close()
|
|
|
+
|
|
|
+ const t = "t_expenditure_log"
|
|
|
+ cBiz := newCol(t, "biz_no", "旧 UUID 主键原样保留(逐字符)")
|
|
|
+ cInst := newCol(t, "institution_id", "schoolid → institution_id(org 域未映射,直迁)")
|
|
|
+ cCampus := newCol(t, "campus_id", "campusid → campus_id(同上)")
|
|
|
+ cExp := newCol(t, "expenditure_id", "expenditureid → t_expenditure.id(字典 D1,恒等)")
|
|
|
+ cCost := newCol(t, "cost", "float(10,3) → DECIMAL(12,2)")
|
|
|
+ cUser := newCol(t, "create_user_id", "create_userid → ULID;0 → NULL")
|
|
|
+ cTime := newCol(t, "create_time", "秒级相同")
|
|
|
+ cYear := newCol(t, "year", "MySQL YEAR → SMALLINT")
|
|
|
+ cMonth := newCol(t, "month", "")
|
|
|
+ cRemark := newCol(t, "remark", "text → LONGTEXT,报告脱敏")
|
|
|
+ cImgs := newCol(t, "imgs", "text → LONGTEXT")
|
|
|
+
|
|
|
+ for _, e := range exp {
|
|
|
+ rowKey := "biz=" + shortBizNo(e.BizNo)
|
|
|
+ var g struct {
|
|
|
+ ID int64
|
|
|
+ BizNo string
|
|
|
+ Inst, Campus int64
|
|
|
+ ExpID int64
|
|
|
+ Cost string
|
|
|
+ CreateUser sql.NullString
|
|
|
+ TimeStr sql.NullString
|
|
|
+ Year, Month sql.NullInt64
|
|
|
+ Remark, Imgs sql.NullString
|
|
|
+ }
|
|
|
+ err := target.QueryRowContext(ctx, `
|
|
|
+SELECT id, biz_no, institution_id, campus_id, expenditure_id, CAST(cost AS DECIMAL(12,2)),
|
|
|
+ create_user_id, DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'),
|
|
|
+ year, month, remark, imgs
|
|
|
+ FROM t_expenditure_log WHERE biz_no = ?`, e.BizNo).Scan(
|
|
|
+ &g.ID, &g.BizNo, &g.Inst, &g.Campus, &g.ExpID, &g.Cost, &g.CreateUser,
|
|
|
+ &g.TimeStr, &g.Year, &g.Month, &g.Remark, &g.Imgs)
|
|
|
+ if err == sql.ErrNoRows {
|
|
|
+ cBiz.cmp(rowKey, e.BizNo, "<缺失>", false)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ cBiz.cmp(rowKey, e.BizNo, g.BizNo, g.BizNo == e.BizNo)
|
|
|
+ cInst.cmp(rowKey, fmt.Sprint(e.SchoolID), fmt.Sprint(g.Inst), g.Inst == e.SchoolID)
|
|
|
+ cCampus.cmp(rowKey, fmt.Sprint(e.CampusID), fmt.Sprint(g.Campus), g.Campus == e.CampusID)
|
|
|
+ cExp.cmp(rowKey, fmt.Sprint(e.ExpenditureID), fmt.Sprint(g.ExpID), g.ExpID == e.ExpenditureID)
|
|
|
+ cCost.cmp(rowKey, e.Cost, g.Cost, normalizeMoney(e.Cost) == normalizeMoney(g.Cost))
|
|
|
+ cUser.cmp(rowKey, expectedULID(idx, e.CreateUser), nullStrOrNull(g.CreateUser),
|
|
|
+ nullStrOrNull(g.CreateUser) == expectedULID(idx, e.CreateUser))
|
|
|
+ cTime.cmp(rowKey, nullStrOrNull(e.TimeStr), nullStrOrNull(g.TimeStr),
|
|
|
+ nullStrOrNull(e.TimeStr) == nullStrOrNull(g.TimeStr))
|
|
|
+ cYear.cmp(rowKey, nullIntStr(e.Year), nullIntStr(g.Year), nullIntStr(e.Year) == nullIntStr(g.Year))
|
|
|
+ cMonth.cmp(rowKey, nullIntStr(e.Month), nullIntStr(g.Month), nullIntStr(e.Month) == nullIntStr(g.Month))
|
|
|
+ cRemark.cmp(rowKey, maskText(e.Remark, 10), maskText(g.Remark, 10), sameNullString(e.Remark, g.Remark))
|
|
|
+ cImgs.cmp(rowKey, maskText(e.Imgs, 8), maskText(g.Imgs, 8), sameNullString(e.Imgs, g.Imgs))
|
|
|
+ }
|
|
|
+ rep.Cols = append(rep.Cols, cBiz.done(), cInst.done(), cCampus.done(), cExp.done(), cCost.done(),
|
|
|
+ cUser.done(), cTime.done(), cYear.done(), cMonth.done(), cRemark.done(), cImgs.done())
|
|
|
+
|
|
|
+ // 断言:expenditure_id 必须全部能在 t_expenditure 字典里找到
|
|
|
+ var orphans int
|
|
|
+ if err := target.QueryRowContext(ctx, `
|
|
|
+SELECT COUNT(*) FROM t_expenditure_log l
|
|
|
+ LEFT JOIN t_expenditure d ON d.id = l.expenditure_id
|
|
|
+ WHERE d.id IS NULL`).Scan(&orphans); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ rep.Assertions = append(rep.Assertions, Assertion{
|
|
|
+ Name: "expenditure_id 全部可解析到 t_expenditure(无孤儿引用)",
|
|
|
+ Pass: orphans == 0,
|
|
|
+ Detail: fmt.Sprintf("LEFT JOIN t_expenditure 后孤儿行数 = %d(字典 45 行走 D1,id 原样)", orphans),
|
|
|
+ })
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func nullIntStr(v sql.NullInt64) string {
|
|
|
+ if !v.Valid {
|
|
|
+ return "NULL"
|
|
|
+ }
|
|
|
+ return strconv.FormatInt(v.Int64, 10)
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- t_expenditure 字典 ----------
|
|
|
+
|
|
|
+func verifyExpenditureDict(ctx context.Context, legacy, target *sql.DB, rep *Batch2VerifyReport) error {
|
|
|
+ type row struct {
|
|
|
+ ID int64
|
|
|
+ Type int32
|
|
|
+ ParentID int64
|
|
|
+ Name string
|
|
|
+ Remark sql.NullString
|
|
|
+ }
|
|
|
+ rows, err := legacy.QueryContext(ctx,
|
|
|
+ `SELECT id, type, parentid, name, remark FROM t_expenditure ORDER BY id`)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ var exp []row
|
|
|
+ for rows.Next() {
|
|
|
+ var r row
|
|
|
+ if err := rows.Scan(&r.ID, &r.Type, &r.ParentID, &r.Name, &r.Remark); err != nil {
|
|
|
+ rows.Close()
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ exp = append(exp, r)
|
|
|
+ }
|
|
|
+ rows.Close()
|
|
|
+
|
|
|
+ const t = "t_expenditure"
|
|
|
+ cID := newCol(t, "id", "D1:与 legacy id 完全相同")
|
|
|
+ cType := newCol(t, "type", "")
|
|
|
+ cParent := newCol(t, "parent_id", "自引用树,0=顶级")
|
|
|
+ cName := newCol(t, "name", "字典名称原样")
|
|
|
+ cRemark := newCol(t, "remark", "text → LONGTEXT")
|
|
|
+ cInst := newCol(t, "institution_id", "legacy 无作用域列 → 0")
|
|
|
+
|
|
|
+ for _, e := range exp {
|
|
|
+ rowKey := "id=" + strconv.FormatInt(e.ID, 10)
|
|
|
+ var g struct {
|
|
|
+ ID, ParentID int64
|
|
|
+ Type int32
|
|
|
+ Name string
|
|
|
+ Remark sql.NullString
|
|
|
+ Inst int64
|
|
|
+ }
|
|
|
+ err := target.QueryRowContext(ctx,
|
|
|
+ `SELECT id, type, parent_id, name, remark, institution_id FROM t_expenditure WHERE id = ?`,
|
|
|
+ e.ID).Scan(&g.ID, &g.Type, &g.ParentID, &g.Name, &g.Remark, &g.Inst)
|
|
|
+ if err == sql.ErrNoRows {
|
|
|
+ cID.cmp(rowKey, fmt.Sprint(e.ID), "<缺失>", false)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ cID.cmp(rowKey, fmt.Sprint(e.ID), fmt.Sprint(g.ID), g.ID == e.ID)
|
|
|
+ cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(g.Type), g.Type == e.Type)
|
|
|
+ cParent.cmp(rowKey, fmt.Sprint(e.ParentID), fmt.Sprint(g.ParentID), g.ParentID == e.ParentID)
|
|
|
+ cName.cmp(rowKey, e.Name, g.Name, g.Name == e.Name)
|
|
|
+ cRemark.cmp(rowKey, maskText(e.Remark, 10), maskText(g.Remark, 10), sameNullString(e.Remark, g.Remark))
|
|
|
+ cInst.cmp(rowKey, "0", fmt.Sprint(g.Inst), g.Inst == 0)
|
|
|
+ }
|
|
|
+ rep.Cols = append(rep.Cols, cID.done(), cType.done(), cParent.done(), cName.done(),
|
|
|
+ cRemark.done(), cInst.done())
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- biz_no 四项验证 ----------
|
|
|
+
|
|
|
+// verifyBizNoFour 逐条验证 biz_no 口径的四件事。
|
|
|
+func verifyBizNoFour(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
|
|
|
+ rep *Batch2VerifyReport) error {
|
|
|
+ cases := []struct {
|
|
|
+ table string
|
|
|
+ legacyQ string
|
|
|
+ arg int64
|
|
|
+ mirrorID func(bizNo string) string
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ table: "t_user_balance_request",
|
|
|
+ legacyQ: `SELECT id FROM t_user_balance_request WHERE userid = ? ORDER BY request_time, id`,
|
|
|
+ arg: opts.BalanceRequestUserID,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ table: "t_expenditure_log",
|
|
|
+ legacyQ: `SELECT id FROM t_expenditure_log WHERE create_userid = ? ORDER BY id`,
|
|
|
+ arg: opts.ExpenditureLogUserID,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ for _, c := range cases {
|
|
|
+ v := BizNoVerify{Table: c.table}
|
|
|
+
|
|
|
+ rows, err := legacy.QueryContext(ctx, c.legacyQ, c.arg)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ var legacyBizNos []string
|
|
|
+ for rows.Next() {
|
|
|
+ var s string
|
|
|
+ if err := rows.Scan(&s); err != nil {
|
|
|
+ rows.Close()
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ legacyBizNos = append(legacyBizNos, s)
|
|
|
+ }
|
|
|
+ rows.Close()
|
|
|
+ v.LegacyRows = len(legacyBizNos)
|
|
|
+
|
|
|
+ // ① 逐字符相等 + ② 长度/hex + 唯一性
|
|
|
+ trows, err := target.QueryContext(ctx, "SELECT biz_no, id FROM "+c.table)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ targetByBiz := map[string]int64{}
|
|
|
+ for trows.Next() {
|
|
|
+ var b string
|
|
|
+ var id int64
|
|
|
+ if err := trows.Scan(&b, &id); err != nil {
|
|
|
+ trows.Close()
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ targetByBiz[b] = id
|
|
|
+ }
|
|
|
+ trows.Close()
|
|
|
+ v.Rows = len(targetByBiz)
|
|
|
+ v.UniqueBizNo = len(targetByBiz)
|
|
|
+ for _, b := range legacyBizNos {
|
|
|
+ if _, ok := targetByBiz[b]; ok {
|
|
|
+ v.CharEqual++
|
|
|
+ }
|
|
|
+ if len(b) == 32 {
|
|
|
+ v.Len32++
|
|
|
+ }
|
|
|
+ if hex32.MatchString(b) {
|
|
|
+ v.HexOK++
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ③ id 与 biz_no 解耦:目标 id 是 1..N 的连续小整数,而 biz_no 是 32 位 hex;
|
|
|
+ // 断言「id != biz_no 的任一数字派生」——这里用「id 必然 <= N」这一硬事实
|
|
|
+ // (若做过任何 hash/截断反算,id 会是大整数)。
|
|
|
+ maxAllowed := int64(len(targetByBiz)) + 100
|
|
|
+ derived := false
|
|
|
+ for _, id := range targetByBiz {
|
|
|
+ if id > maxAllowed {
|
|
|
+ derived = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ v.IDEqualsPrefix = derived // true = 疑似由 biz_no 反算,必须为 false
|
|
|
+
|
|
|
+ // ④ 反查可用:用 biz_no JOIN 回 legacy
|
|
|
+ var joined int
|
|
|
+ if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
|
|
|
+ `SELECT COUNT(*) FROM %s r JOIN %s.%s t ON t.biz_no = r.id WHERE r.%s = ?`,
|
|
|
+ c.table, "yuansiwei_2026", c.table, joinUserCol(c.table)), c.arg).Scan(&joined); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ v.ReverseJoin = joined
|
|
|
+
|
|
|
+ // ⑤ id 解耦的**反序插入实验**(临时表,会话结束自动消失):
|
|
|
+ // 把同一批 biz_no **倒序**插进镜像表,若 id 跟着插入顺序涨、biz_no 保持原值,
|
|
|
+ // 就说明 id 是纯插入计数器,与 biz_no 无关。
|
|
|
+ mirrorOK, sample, err := mirrorBizNoOrder(ctx, target, c.table, legacyBizNos)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ v.MirrorOK, v.MirrorSample = mirrorOK, sample
|
|
|
+
|
|
|
+ // ⑥ AUTO_INCREMENT 未倒退
|
|
|
+ ai, err := readAutoIncrement(ctx, target, c.table)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ v.AutoIncrementVal = ai
|
|
|
+ if err := target.QueryRowContext(ctx, "SELECT IFNULL(MAX(id),0) FROM "+c.table).Scan(&v.MaxID); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ rep.BizNo = append(rep.BizNo, v)
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, v := range rep.BizNo {
|
|
|
+ rep.Assertions = append(rep.Assertions,
|
|
|
+ Assertion{
|
|
|
+ Name: v.Table + ".biz_no 与 legacy id 逐字符相等",
|
|
|
+ Pass: v.LegacyRows > 0 && v.CharEqual == v.LegacyRows && v.Rows == v.LegacyRows,
|
|
|
+ Detail: fmt.Sprintf("legacy %d 行 / target %d 行 / 逐字符相等 %d 行;长度=32 的 %d 行,hex 形态 %d 行",
|
|
|
+ v.LegacyRows, v.Rows, v.CharEqual, v.Len32, v.HexOK),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: v.Table + ".biz_no 唯一(uk_biz_no 生效,无重复)",
|
|
|
+ Pass: v.UniqueBizNo == v.Rows,
|
|
|
+ Detail: fmt.Sprintf("DISTINCT biz_no = %d,总行数 = %d", v.UniqueBizNo, v.Rows),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: v.Table + ".id 与 biz_no 解耦(id 是自增,不由 biz_no 派生)",
|
|
|
+ Pass: !v.IDEqualsPrefix && v.MirrorOK,
|
|
|
+ Detail: fmt.Sprintf("max(id)=%d,AUTO_INCREMENT=%d;反序插入镜像:%v",
|
|
|
+ v.MaxID, v.AutoIncrementVal, v.MirrorSample),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: v.Table + ".biz_no 反查可用(JOIN 回 legacy 命中全部行)",
|
|
|
+ Pass: v.ReverseJoin == v.LegacyRows,
|
|
|
+ Detail: fmt.Sprintf("JOIN finance_bk 命中 %d 行 / legacy %d 行", v.ReverseJoin, v.LegacyRows),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: v.Table + ".AUTO_INCREMENT >= max(id)+1(biz_no 口径下计数器正常递增)",
|
|
|
+ Pass: v.AutoIncrementVal >= v.MaxID+1,
|
|
|
+ Detail: fmt.Sprintf("AUTO_INCREMENT=%d, max(id)=%d", v.AutoIncrementVal, v.MaxID),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// joinUserCol 返回各 biz_no 表在 JOIN 时用于筛选的列名。
|
|
|
+func joinUserCol(table string) string {
|
|
|
+ if table == "t_expenditure_log" {
|
|
|
+ return "create_userid"
|
|
|
+ }
|
|
|
+ return "userid"
|
|
|
+}
|
|
|
+
|
|
|
+// mirrorRequiredCols 返回各 biz_no 表「NOT NULL 且无默认值」的列与哨兵值。
|
|
|
+func mirrorRequiredCols(table string) ([]string, []any) {
|
|
|
+ switch table {
|
|
|
+ case "t_user_balance_request":
|
|
|
+ // created_at / updated_at 在真实库里没有 DEFAULT(DDL 文件写了,实际库没生效)
|
|
|
+ return []string{"created_at", "updated_at"}, []any{LegacyMissingTime, LegacyMissingTime}
|
|
|
+ case "t_expenditure_log":
|
|
|
+ return []string{"create_time"}, []any{LegacyMissingTime}
|
|
|
+ default:
|
|
|
+ return nil, nil
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// mirrorBizNoOrder 在临时表里**倒序**插入这批 biz_no,验证 id 只跟插入顺序、与 biz_no 无关。
|
|
|
+func mirrorBizNoOrder(ctx context.Context, db *sql.DB, table string, bizNos []string) (bool, []string, error) {
|
|
|
+ conn, err := db.Conn(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return false, nil, err
|
|
|
+ }
|
|
|
+ defer conn.Close()
|
|
|
+
|
|
|
+ tmp := "tmp_b2_bizno_" + table // 表名进临时表名:连接复用时避免残留旧结构
|
|
|
+ // TEMPORARY TABLE 是**会话级**的:连接被复用时它还在,所以用 IF NOT EXISTS + 清空,
|
|
|
+ // 全程不 DROP 任何真实表(DROP TEMPORARY 也只影响会话临时表,这里索性不用)。
|
|
|
+ if _, err := conn.ExecContext(ctx,
|
|
|
+ fmt.Sprintf("CREATE TEMPORARY TABLE IF NOT EXISTS `%s` LIKE %s", tmp, table)); err != nil {
|
|
|
+ return false, nil, fmt.Errorf("建临时镜像表失败:%w", err)
|
|
|
+ }
|
|
|
+ if _, err := conn.ExecContext(ctx, fmt.Sprintf("DELETE FROM `%s`", tmp)); err != nil {
|
|
|
+ return false, nil, fmt.Errorf("清空临时镜像表失败:%w", err)
|
|
|
+ }
|
|
|
+ if _, err := conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE `%s` AUTO_INCREMENT = 1", tmp)); err != nil {
|
|
|
+ // 临时镜像表只用于观察发号起点;ALTER 只作用于会话临时表,不碰任何真实表。
|
|
|
+ return false, nil, fmt.Errorf("重置临时镜像表自增失败:%w", err)
|
|
|
+ }
|
|
|
+ // 目标表的 NOT NULL 且无默认值的列必须显式给值(见报告「结构漂移」一节)。
|
|
|
+ // 镜像表只用来观察 id 的发号规律,这些列填哨兵即可。
|
|
|
+ extraCols, extraVals := mirrorRequiredCols(table)
|
|
|
+
|
|
|
+ rev := append([]string(nil), bizNos...)
|
|
|
+ sort.Sort(sort.Reverse(sort.StringSlice(rev)))
|
|
|
+ for _, b := range rev {
|
|
|
+ cols := "biz_no"
|
|
|
+ holders := "?"
|
|
|
+ args := []any{b}
|
|
|
+ for i, c := range extraCols {
|
|
|
+ cols += ", " + c
|
|
|
+ holders += ", ?"
|
|
|
+ args = append(args, extraVals[i])
|
|
|
+ }
|
|
|
+ if _, err := conn.ExecContext(ctx,
|
|
|
+ fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)", tmp, cols, holders), args...); err != nil {
|
|
|
+ return false, nil, fmt.Errorf("镜像插入失败:%w", err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ rows, err := conn.QueryContext(ctx, fmt.Sprintf("SELECT id, biz_no FROM `%s` ORDER BY id", tmp))
|
|
|
+ if err != nil {
|
|
|
+ return false, nil, err
|
|
|
+ }
|
|
|
+ defer rows.Close()
|
|
|
+ var sample []string
|
|
|
+ var prevBiz string
|
|
|
+ ok := true
|
|
|
+ i := 0
|
|
|
+ for rows.Next() {
|
|
|
+ var id int64
|
|
|
+ var b string
|
|
|
+ if err := rows.Scan(&id, &b); err != nil {
|
|
|
+ return false, nil, err
|
|
|
+ }
|
|
|
+ if len(sample) < 4 {
|
|
|
+ sample = append(sample, fmt.Sprintf("id=%d←biz=%s", id, shortBizNo(b)))
|
|
|
+ }
|
|
|
+ // id 必须严格从 1 递增(插入顺序),而 biz_no 必须是**倒序**(与 id 顺序相反)
|
|
|
+ if id != int64(i+1) {
|
|
|
+ ok = false
|
|
|
+ }
|
|
|
+ if i > 0 && strings.Compare(b, prevBiz) >= 0 {
|
|
|
+ ok = false
|
|
|
+ }
|
|
|
+ prevBiz = b
|
|
|
+ i++
|
|
|
+ }
|
|
|
+ return ok && i == len(bizNos), sample, rows.Err()
|
|
|
+}
|