batch2_verify.go 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044
  1. package fixture
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "sort"
  7. "strconv"
  8. "strings"
  9. )
  10. // 本文件是第二批的**逐列核对**。两条与上一批相同的铁律:
  11. // 1. 期望值**不由本包映射代码产生**:金额/时间一律由 MySQL 服务端
  12. // `CAST(... AS DECIMAL(12,2))` / `DATE_FORMAT(...)` 算出;
  13. // 2. 用户引用(ULID)的期望值来自**目标库 t_legacy_mapping 的实际数据**
  14. // (反向 JOIN 查出来),而不是调用 FixtureCanonicalUserID —— 否则映射写错时两边一起错。
  15. //
  16. // 另外本批专门验证 biz_no 四项(原样保留 / 幂等 / id 解耦 / 反查可用)。
  17. // ColumnAgg 是「一列 × N 行」的聚合核对结果(避免 600+ 行逐格输出)。
  18. type ColumnAgg struct {
  19. Table string
  20. Column string
  21. Total int
  22. Equal int
  23. Samples []string // 最多 3 条不一致样例
  24. Note string
  25. }
  26. // OK 表示该列所有行都相等。
  27. func (c ColumnAgg) OK() bool { return c.Total > 0 && c.Equal == c.Total }
  28. // colAcc 是聚合累加器。
  29. type colAcc struct {
  30. table, column, note string
  31. total, equal int
  32. samples []string
  33. }
  34. func newCol(table, column, note string) *colAcc {
  35. return &colAcc{table: table, column: column, note: note}
  36. }
  37. // cmp 比一行;不一致时留样例(最多 3 条)。
  38. func (a *colAcc) cmp(rowKey, legacy, target string, ok bool) {
  39. a.total++
  40. if ok {
  41. a.equal++
  42. return
  43. }
  44. if len(a.samples) < 3 {
  45. a.samples = append(a.samples, fmt.Sprintf("%s: legacy=%q target=%q", rowKey, truncate(legacy, 28), truncate(target, 28)))
  46. }
  47. }
  48. func (a *colAcc) done() ColumnAgg {
  49. return ColumnAgg{Table: a.table, Column: a.column, Total: a.total, Equal: a.equal,
  50. Samples: a.samples, Note: a.note}
  51. }
  52. // BizNoVerify 是 biz_no 四项验证的结果。
  53. type BizNoVerify struct {
  54. Table string
  55. Rows int
  56. CharEqual int // 逐字符相等的行数
  57. Len32 int // 长度为 32 的行数
  58. HexOK int // 32 位小写 hex 的行数
  59. UniqueBizNo int // DISTINCT biz_no 数
  60. IDEqualsPrefix bool // id 是否等于 biz_no 派生的数字(**必须为 false**,证明未做反算)
  61. ReverseJoin int // 用 biz_no JOIN 回 legacy 的行数
  62. LegacyRows int
  63. MirrorOK bool // 反序插入镜像:id 跟插入顺序、不跟 biz_no
  64. MirrorSample []string // 镜像里 (id, biz_no) 前 5 行
  65. AutoIncrementVal int64
  66. MaxID int64
  67. }
  68. // D1Verify 是 D1 口径的 id 一致性结果。
  69. type D1Verify struct {
  70. Table string
  71. LegacyIDs []int64
  72. TargetIDs []int64
  73. Pass bool
  74. }
  75. // Batch2VerifyReport 是整轮核对结果。
  76. type Batch2VerifyReport struct {
  77. Options Batch2Options
  78. Cols []ColumnAgg
  79. Assertions []Assertion
  80. BizNo []BizNoVerify
  81. D1 []D1Verify
  82. TargetRows map[string]int64
  83. Warnings []string
  84. }
  85. // FailCount 统计不一致列数 + 失败断言数。
  86. func (r *Batch2VerifyReport) FailCount() int {
  87. n := 0
  88. for _, c := range r.Cols {
  89. if !c.OK() {
  90. n++
  91. }
  92. }
  93. for _, a := range r.Assertions {
  94. if !a.Pass {
  95. n++
  96. }
  97. }
  98. return n
  99. }
  100. // OK 表示整轮通过。
  101. func (r *Batch2VerifyReport) OK() bool { return r.FailCount() == 0 }
  102. // ulidIndex 从目标库 t_legacy_mapping 反查 legacy int → ULID(数据驱动的期望值来源)。
  103. func ulidIndex(ctx context.Context, db *sql.DB) (map[int64]string, error) {
  104. rows, err := db.QueryContext(ctx,
  105. `SELECT legacy_academic_user_id, canonical_user_id FROM t_legacy_mapping
  106. WHERE legacy_academic_user_id IS NOT NULL`)
  107. if err != nil {
  108. return nil, err
  109. }
  110. defer rows.Close()
  111. out := map[int64]string{}
  112. for rows.Next() {
  113. var id int64
  114. var uid string
  115. if err := rows.Scan(&id, &uid); err != nil {
  116. return nil, err
  117. }
  118. out[id] = uid
  119. }
  120. return out, rows.Err()
  121. }
  122. // expectedULID 用映射表数据解析期望 ULID;0/NULL/无映射 → "NULL"。
  123. func expectedULID(idx map[int64]string, v sql.NullInt64) string {
  124. if !v.Valid || v.Int64 <= 0 {
  125. return "NULL"
  126. }
  127. if uid, ok := idx[v.Int64]; ok {
  128. return uid
  129. }
  130. return "UNMAPPED(" + strconv.FormatInt(v.Int64, 10) + ")"
  131. }
  132. func nullStrOrNull(v sql.NullString) string {
  133. if !v.Valid {
  134. return "NULL"
  135. }
  136. return v.String
  137. }
  138. // VerifyBatch2 执行第二批的完整核对。
  139. func VerifyBatch2(ctx context.Context, cfg Config, opts Batch2Options) (*Batch2VerifyReport, error) {
  140. legacy, err := cfg.OpenLegacy()
  141. if err != nil {
  142. return nil, err
  143. }
  144. defer legacy.Close()
  145. target, err := cfg.OpenTarget()
  146. if err != nil {
  147. return nil, err
  148. }
  149. defer target.Close()
  150. idx, err := ulidIndex(ctx, target)
  151. if err != nil {
  152. return nil, err
  153. }
  154. rep := &Batch2VerifyReport{Options: opts, TargetRows: map[string]int64{}}
  155. for _, t := range []string{"t_expenditure", "t_user_divide_into", "t_user_divide_into_log",
  156. "t_user_balance_request", "t_expenditure_log"} {
  157. var n int64
  158. if err := target.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&n); err != nil {
  159. return nil, err
  160. }
  161. rep.TargetRows[t] = n
  162. }
  163. if err := verifyDivideInto(ctx, legacy, target, opts, idx, rep); err != nil {
  164. return nil, err
  165. }
  166. if err := verifyDivideIntoLog(ctx, legacy, target, opts, idx, rep); err != nil {
  167. return nil, err
  168. }
  169. if err := verifyBalanceRequest(ctx, legacy, target, opts, idx, rep); err != nil {
  170. return nil, err
  171. }
  172. if err := verifyExpenditureLog(ctx, legacy, target, opts, idx, rep); err != nil {
  173. return nil, err
  174. }
  175. if opts.WithExpenditureDict {
  176. if err := verifyExpenditureDict(ctx, legacy, target, rep); err != nil {
  177. return nil, err
  178. }
  179. }
  180. if err := verifyBizNoFour(ctx, legacy, target, opts, rep); err != nil {
  181. return nil, err
  182. }
  183. return rep, nil
  184. }
  185. // ---------- t_user_divide_into ----------
  186. func verifyDivideInto(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
  187. idx map[int64]string, rep *Batch2VerifyReport) error {
  188. type row struct {
  189. UserID int64
  190. Type int32
  191. Quota, Income, Payed string
  192. Root, Parent sql.NullInt64
  193. Left, Right, Layer int32
  194. }
  195. const q = `
  196. SELECT userid, cast(ifnull(type,1) as signed),
  197. CAST(ROUND(CAST(ifnull(quota,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
  198. CAST(ROUND(CAST(ifnull(total_income,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
  199. CAST(ROUND(CAST(ifnull(total_income_payed,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
  200. root_userid, parent_userid,
  201. cast(ifnull(left_val,1) as signed), cast(ifnull(right_val,2) as signed),
  202. cast(ifnull(layer,1) as signed)
  203. FROM t_user_divide_into WHERE root_userid = ? ORDER BY left_val, userid`
  204. rows, err := legacy.QueryContext(ctx, q, opts.DivideRootUserID)
  205. if err != nil {
  206. return err
  207. }
  208. var exp []row
  209. for rows.Next() {
  210. var r row
  211. if err := rows.Scan(&r.UserID, &r.Type, &r.Quota, &r.Income, &r.Payed,
  212. &r.Root, &r.Parent, &r.Left, &r.Right, &r.Layer); err != nil {
  213. rows.Close()
  214. return err
  215. }
  216. exp = append(exp, r)
  217. }
  218. rows.Close()
  219. cUser := newCol("t_user_divide_into", "user_id", "legacy userid → ULID")
  220. cType := newCol("t_user_divide_into", "type", "")
  221. cQuota := newCol("t_user_divide_into", "quota", "float(10,2) → DECIMAL(12,2)")
  222. cIncome := newCol("t_user_divide_into", "total_income", "")
  223. cPayed := newCol("t_user_divide_into", "total_income_payed", "")
  224. cRoot := newCol("t_user_divide_into", "root_user_id", "根节点=自身")
  225. cParent := newCol("t_user_divide_into", "parent_user_id", "legacy parent_userid=0(根)→ NULL")
  226. cLeft := newCol("t_user_divide_into", "left_val", "Nested Set")
  227. cRight := newCol("t_user_divide_into", "right_val", "Nested Set")
  228. cLayer := newCol("t_user_divide_into", "layer", "")
  229. for _, e := range exp {
  230. rowKey := strconv.FormatInt(e.UserID, 10)
  231. wantUID := expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true})
  232. var got struct {
  233. UserID string
  234. Type int8
  235. Quota, Income, Payed string
  236. Root, Parent sql.NullString
  237. Left, Right, Layer int32
  238. }
  239. err := target.QueryRowContext(ctx, `
  240. SELECT user_id, type, CAST(quota AS DECIMAL(12,2)), CAST(total_income AS DECIMAL(12,2)),
  241. CAST(total_income_payed AS DECIMAL(12,2)), root_user_id, parent_user_id,
  242. left_val, right_val, layer
  243. FROM t_user_divide_into WHERE user_id = ?`, wantUID).Scan(
  244. &got.UserID, &got.Type, &got.Quota, &got.Income, &got.Payed,
  245. &got.Root, &got.Parent, &got.Left, &got.Right, &got.Layer)
  246. if err == sql.ErrNoRows {
  247. cUser.cmp(rowKey, wantUID, "<缺失>", false)
  248. continue
  249. }
  250. if err != nil {
  251. return err
  252. }
  253. cUser.cmp(rowKey, wantUID, got.UserID, got.UserID == wantUID)
  254. cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(got.Type), int32(got.Type) == e.Type)
  255. cQuota.cmp(rowKey, e.Quota, got.Quota, normalizeMoney(e.Quota) == normalizeMoney(got.Quota))
  256. cIncome.cmp(rowKey, e.Income, got.Income, normalizeMoney(e.Income) == normalizeMoney(got.Income))
  257. cPayed.cmp(rowKey, e.Payed, got.Payed, normalizeMoney(e.Payed) == normalizeMoney(got.Payed))
  258. cRoot.cmp(rowKey, expectedULID(idx, e.Root), nullStrOrNull(got.Root),
  259. nullStrOrNull(got.Root) == expectedULID(idx, e.Root))
  260. cParent.cmp(rowKey, expectedULID(idx, e.Parent), nullStrOrNull(got.Parent),
  261. nullStrOrNull(got.Parent) == expectedULID(idx, e.Parent))
  262. cLeft.cmp(rowKey, fmt.Sprint(e.Left), fmt.Sprint(got.Left), got.Left == e.Left)
  263. cRight.cmp(rowKey, fmt.Sprint(e.Right), fmt.Sprint(got.Right), got.Right == e.Right)
  264. cLayer.cmp(rowKey, fmt.Sprint(e.Layer), fmt.Sprint(got.Layer), got.Layer == e.Layer)
  265. }
  266. if len(exp) == 0 {
  267. cUser.cmp("-", "0 行", "0 行", false)
  268. }
  269. rep.Cols = append(rep.Cols, cUser.done(), cType.done(), cQuota.done(), cIncome.done(),
  270. cPayed.done(), cRoot.done(), cParent.done(), cLeft.done(), cRight.done(), cLayer.done())
  271. // 断言:树上**恰好 1 个根**(parent_user_id IS NULL),且该根的 root_user_id = user_id
  272. var total, nullParent, selfRoot int
  273. if err := target.QueryRowContext(ctx, `
  274. SELECT COUNT(*),
  275. SUM(parent_user_id IS NULL),
  276. SUM(parent_user_id IS NULL AND root_user_id = user_id)
  277. FROM t_user_divide_into`).Scan(&total, &nullParent, &selfRoot); err != nil {
  278. return err
  279. }
  280. rep.Assertions = append(rep.Assertions, Assertion{
  281. Name: "分账树根节点 parent_user_id 为 NULL,且根节点 root_user_id = user_id(legacy parent_userid=0 / root_userid=自身)",
  282. Pass: nullParent == 1 && selfRoot == 1,
  283. Detail: fmt.Sprintf("t_user_divide_into 共 %d 行:parent_user_id IS NULL 的 %d 行(应=1),"+
  284. "其中 root_user_id=user_id 的 %d 行(应=1)", total, nullParent, selfRoot),
  285. })
  286. return nil
  287. }
  288. // ---------- t_user_divide_into_log ----------
  289. func verifyDivideIntoLog(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
  290. idx map[int64]string, rep *Batch2VerifyReport) error {
  291. type row struct {
  292. ID int64
  293. Type int32
  294. UserID int64
  295. Student int64
  296. OrderNo sql.NullString
  297. Money string
  298. TimeStr sql.NullString
  299. Remark sql.NullString
  300. }
  301. const q = `
  302. SELECT id, cast(type as signed), userid, studentid, orderid,
  303. CAST(ROUND(CAST(ifnull(money,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
  304. DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'), remark
  305. FROM t_user_divide_into_log WHERE userid = ? ORDER BY id`
  306. rows, err := legacy.QueryContext(ctx, q, opts.DivideRootUserID)
  307. if err != nil {
  308. return err
  309. }
  310. var exp []row
  311. for rows.Next() {
  312. var r row
  313. if err := rows.Scan(&r.ID, &r.Type, &r.UserID, &r.Student, &r.OrderNo, &r.Money, &r.TimeStr, &r.Remark); err != nil {
  314. rows.Close()
  315. return err
  316. }
  317. exp = append(exp, r)
  318. }
  319. rows.Close()
  320. cID := newCol("t_user_divide_into_log", "id", "D1:与 legacy id 完全相同")
  321. cType := newCol("t_user_divide_into_log", "type", "tinyint zerofill → TINYINT")
  322. cUser := newCol("t_user_divide_into_log", "user_id", "userid → ULID")
  323. cStudent := newCol("t_user_divide_into_log", "student_id", "studentid → ULID")
  324. cOrder := newCol("t_user_divide_into_log", "order_no", "orderid → order_no")
  325. cMoney := newCol("t_user_divide_into_log", "money", "float(10,2) → DECIMAL(12,2)")
  326. cTime := newCol("t_user_divide_into_log", "create_time", "秒级相同,毫秒 .000")
  327. cRemark := newCol("t_user_divide_into_log", "remark", "text → LONGTEXT,报告脱敏")
  328. var legacyIDs, targetIDs []int64
  329. for _, e := range exp {
  330. rowKey := "id=" + strconv.FormatInt(e.ID, 10)
  331. legacyIDs = append(legacyIDs, e.ID)
  332. var g struct {
  333. ID int64
  334. Type int8
  335. UserID string
  336. Student string
  337. OrderNo sql.NullString
  338. Money string
  339. TimeStr sql.NullString
  340. Remark sql.NullString
  341. }
  342. err := target.QueryRowContext(ctx, `
  343. SELECT id, type, user_id, student_id, order_no,
  344. CAST(money AS DECIMAL(12,2)),
  345. DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'), remark
  346. FROM t_user_divide_into_log WHERE id = ?`, e.ID).Scan(
  347. &g.ID, &g.Type, &g.UserID, &g.Student, &g.OrderNo, &g.Money, &g.TimeStr, &g.Remark)
  348. if err == sql.ErrNoRows {
  349. cID.cmp(rowKey, strconv.FormatInt(e.ID, 10), "<缺失>", false)
  350. continue
  351. }
  352. if err != nil {
  353. return err
  354. }
  355. targetIDs = append(targetIDs, g.ID)
  356. cID.cmp(rowKey, strconv.FormatInt(e.ID, 10), strconv.FormatInt(g.ID, 10), g.ID == e.ID)
  357. cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(g.Type), int32(g.Type) == e.Type)
  358. cUser.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}), g.UserID,
  359. g.UserID == expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}))
  360. cStudent.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.Student, Valid: true}), g.Student,
  361. g.Student == expectedULID(idx, sql.NullInt64{Int64: e.Student, Valid: true}))
  362. cOrder.cmp(rowKey, nullStrOrNull(e.OrderNo), nullStrOrNull(g.OrderNo), nullStrOrNull(e.OrderNo) == nullStrOrNull(g.OrderNo))
  363. cMoney.cmp(rowKey, e.Money, g.Money, normalizeMoney(e.Money) == normalizeMoney(g.Money))
  364. cTime.cmp(rowKey, nullStrOrNull(e.TimeStr), nullStrOrNull(g.TimeStr), nullStrOrNull(e.TimeStr) == nullStrOrNull(g.TimeStr))
  365. cRemark.cmp(rowKey, maskRemark(e.Remark), maskRemark(g.Remark), sameRemark(e.Remark, g.Remark))
  366. }
  367. rep.Cols = append(rep.Cols, cID.done(), cType.done(), cUser.done(), cStudent.done(),
  368. cOrder.done(), cMoney.done(), cTime.done(), cRemark.done())
  369. rep.D1 = append(rep.D1, D1Verify{
  370. Table: "t_user_divide_into_log",
  371. LegacyIDs: legacyIDs,
  372. TargetIDs: targetIDs,
  373. Pass: int64SliceEqual(legacyIDs, targetIDs),
  374. })
  375. return nil
  376. }
  377. func int64SliceEqual(a, b []int64) bool {
  378. if len(a) != len(b) {
  379. return false
  380. }
  381. x := append([]int64(nil), a...)
  382. y := append([]int64(nil), b...)
  383. sort.Slice(x, func(i, j int) bool { return x[i] < x[j] })
  384. sort.Slice(y, func(i, j int) bool { return y[i] < y[j] })
  385. for i := range x {
  386. if x[i] != y[i] {
  387. return false
  388. }
  389. }
  390. return true
  391. }
  392. // ---------- t_user_balance_request ----------
  393. func verifyBalanceRequest(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
  394. idx map[int64]string, rep *Batch2VerifyReport) error {
  395. type row struct {
  396. BizNo string
  397. Type int32
  398. UserID int64
  399. SchoolID, CampusID int64
  400. Money sql.NullString
  401. TransferType int8
  402. TransferTime sql.NullString
  403. OrderNo sql.NullString
  404. ReqUser int64
  405. ReqTime sql.NullString
  406. RespUser sql.NullInt64
  407. RespTime sql.NullString
  408. RespMsg, Item, Imgs, Remark sql.NullString
  409. Aproved int8
  410. }
  411. const q = `
  412. SELECT id, type, userid, schoolid, campusid,
  413. CAST(ROUND(CAST(money AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
  414. transfer_accounts_type, DATE_FORMAT(transfer_accounts_time,'%Y-%m-%d %H:%i:%s.%f'),
  415. orderid, request_userid, DATE_FORMAT(request_time,'%Y-%m-%d %H:%i:%s.%f'),
  416. response_userid, DATE_FORMAT(response_time,'%Y-%m-%d %H:%i:%s.%f'),
  417. response_msg, item, imgs, remark, aproved
  418. FROM t_user_balance_request WHERE userid = ? ORDER BY request_time, id`
  419. rows, err := legacy.QueryContext(ctx, q, opts.BalanceRequestUserID)
  420. if err != nil {
  421. return err
  422. }
  423. var exp []row
  424. for rows.Next() {
  425. var r row
  426. if err := rows.Scan(&r.BizNo, &r.Type, &r.UserID, &r.SchoolID, &r.CampusID, &r.Money,
  427. &r.TransferType, &r.TransferTime, &r.OrderNo, &r.ReqUser, &r.ReqTime,
  428. &r.RespUser, &r.RespTime, &r.RespMsg, &r.Item, &r.Imgs, &r.Remark, &r.Aproved); err != nil {
  429. rows.Close()
  430. return err
  431. }
  432. exp = append(exp, r)
  433. }
  434. rows.Close()
  435. const t = "t_user_balance_request"
  436. cBiz := newCol(t, "biz_no", "旧 UUID 主键原样保留(逐字符)")
  437. cType := newCol(t, "type", "")
  438. cUser := newCol(t, "user_id", "userid → ULID")
  439. cInst := newCol(t, "institution_id", "schoolid → institution_id(org 域未映射,直迁)")
  440. cCampus := newCol(t, "campus_id", "campusid → campus_id(同上)")
  441. cToInst := newCol(t, "to_institution_id", "legacy 无此列 → 必须 NULL")
  442. cToCampus := newCol(t, "to_campus_id", "legacy 无此列 → 必须 NULL")
  443. cMoney := newCol(t, "money", "decimal(10,3) → DECIMAL(12,2)")
  444. cTT := newCol(t, "transfer_accounts_type", "")
  445. cTTime := newCol(t, "transfer_accounts_time", "NULL → NULL")
  446. cOrder := newCol(t, "order_no", "orderid → order_no")
  447. cReqUser := newCol(t, "request_user_id", "request_userid → ULID")
  448. cReqTime := newCol(t, "request_time", "秒级相同")
  449. cRespUser := newCol(t, "response_user_id", "0 → NULL")
  450. cRespTime := newCol(t, "response_time", "NULL → NULL")
  451. cRespMsg := newCol(t, "response_msg", "空串保留空串")
  452. cApproved := newCol(t, "approved", "aproved 错拼修正;-2/1 原样")
  453. cItem := newCol(t, "item", "text → varchar(1000),报告脱敏")
  454. cImgs := newCol(t, "imgs", "text → LONGTEXT,比对原文")
  455. cRemark := newCol(t, "remark", "空串保留空串,报告脱敏")
  456. var zeroResp int
  457. for _, e := range exp {
  458. rowKey := "biz=" + shortBizNo(e.BizNo)
  459. if e.RespUser.Valid && e.RespUser.Int64 == 0 {
  460. zeroResp++
  461. }
  462. var g struct {
  463. ID int64
  464. BizNo string
  465. Type int32
  466. UserID string
  467. Inst, Campus int64
  468. ToInst, ToCampus sql.NullInt64
  469. Money string
  470. TT int8
  471. TTime, OrderNo, ReqTime sql.NullString
  472. ReqUser string
  473. RespUser sql.NullString
  474. RespTime, RespMsg, Item, Imgs, Remark sql.NullString
  475. Approved int8
  476. }
  477. err := target.QueryRowContext(ctx, `
  478. SELECT id, biz_no, type, user_id, institution_id, campus_id, to_institution_id, to_campus_id,
  479. CAST(money AS DECIMAL(12,2)), transfer_accounts_type,
  480. DATE_FORMAT(transfer_accounts_time,'%Y-%m-%d %H:%i:%s.%f'), order_no,
  481. request_user_id, DATE_FORMAT(request_time,'%Y-%m-%d %H:%i:%s.%f'),
  482. response_user_id, DATE_FORMAT(response_time,'%Y-%m-%d %H:%i:%s.%f'),
  483. response_msg, approved, item, imgs, remark
  484. FROM t_user_balance_request WHERE biz_no = ?`, e.BizNo).Scan(
  485. &g.ID, &g.BizNo, &g.Type, &g.UserID, &g.Inst, &g.Campus, &g.ToInst, &g.ToCampus,
  486. &g.Money, &g.TT, &g.TTime, &g.OrderNo, &g.ReqUser, &g.ReqTime, &g.RespUser,
  487. &g.RespTime, &g.RespMsg, &g.Approved, &g.Item, &g.Imgs, &g.Remark)
  488. if err == sql.ErrNoRows {
  489. cBiz.cmp(rowKey, e.BizNo, "<缺失>", false)
  490. continue
  491. }
  492. if err != nil {
  493. return err
  494. }
  495. cBiz.cmp(rowKey, e.BizNo, g.BizNo, g.BizNo == e.BizNo)
  496. cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(g.Type), g.Type == e.Type)
  497. cUser.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}), g.UserID,
  498. g.UserID == expectedULID(idx, sql.NullInt64{Int64: e.UserID, Valid: true}))
  499. cInst.cmp(rowKey, fmt.Sprint(e.SchoolID), fmt.Sprint(g.Inst), g.Inst == e.SchoolID)
  500. cCampus.cmp(rowKey, fmt.Sprint(e.CampusID), fmt.Sprint(g.Campus), g.Campus == e.CampusID)
  501. cToInst.cmp(rowKey, "NULL", fmt.Sprint(g.ToInst.Int64), !g.ToInst.Valid)
  502. cToCampus.cmp(rowKey, "NULL", fmt.Sprint(g.ToCampus.Int64), !g.ToCampus.Valid)
  503. cMoney.cmp(rowKey, nullStrOrNull(e.Money), g.Money,
  504. normalizeMoney(orZero(e.Money)) == normalizeMoney(g.Money))
  505. cTT.cmp(rowKey, fmt.Sprint(e.TransferType), fmt.Sprint(g.TT), g.TT == e.TransferType)
  506. cTTime.cmp(rowKey, nullStrOrNull(e.TransferTime), nullStrOrNull(g.TTime),
  507. nullStrOrNull(e.TransferTime) == nullStrOrNull(g.TTime))
  508. cOrder.cmp(rowKey, nullStrOrNull(e.OrderNo), nullStrOrNull(g.OrderNo),
  509. nullStrOrNull(e.OrderNo) == nullStrOrNull(g.OrderNo))
  510. cReqUser.cmp(rowKey, expectedULID(idx, sql.NullInt64{Int64: e.ReqUser, Valid: true}), g.ReqUser,
  511. g.ReqUser == expectedULID(idx, sql.NullInt64{Int64: e.ReqUser, Valid: true}))
  512. cReqTime.cmp(rowKey, nullStrOrNull(e.ReqTime), nullStrOrNull(g.ReqTime),
  513. nullStrOrNull(e.ReqTime) == nullStrOrNull(g.ReqTime))
  514. cRespUser.cmp(rowKey, expectedULID(idx, e.RespUser), nullStrOrNull(g.RespUser),
  515. nullStrOrNull(g.RespUser) == expectedULID(idx, e.RespUser))
  516. cRespTime.cmp(rowKey, nullStrOrNull(e.RespTime), nullStrOrNull(g.RespTime),
  517. nullStrOrNull(e.RespTime) == nullStrOrNull(g.RespTime))
  518. cRespMsg.cmp(rowKey, maskText(e.RespMsg, 10), maskText(fromStr(g.RespMsg), 10), sameNullString(e.RespMsg, g.RespMsg))
  519. cApproved.cmp(rowKey, fmt.Sprint(e.Aproved), fmt.Sprint(g.Approved), g.Approved == e.Aproved)
  520. cItem.cmp(rowKey, maskText(e.Item, 16), maskText(fromStr(g.Item), 16), sameNullString(e.Item, g.Item))
  521. cImgs.cmp(rowKey, maskText(e.Imgs, 8), maskText(fromStr(g.Imgs), 8), sameNullString(e.Imgs, g.Imgs))
  522. cRemark.cmp(rowKey, maskText(e.Remark, 10), maskText(fromStr(g.Remark), 10), sameNullString(e.Remark, g.Remark))
  523. }
  524. rep.Cols = append(rep.Cols, cBiz.done(), cType.done(), cUser.done(), cInst.done(), cCampus.done(),
  525. cToInst.done(), cToCampus.done(), cMoney.done(), cTT.done(), cTTime.done(), cOrder.done(),
  526. cReqUser.done(), cReqTime.done(), cRespUser.done(), cRespTime.done(), cRespMsg.done(),
  527. cApproved.done(), cItem.done(), cImgs.done(), cRemark.done())
  528. rep.Assertions = append(rep.Assertions, Assertion{
  529. Name: "response_userid=0 → response_user_id 为 NULL(不是 0/空串)",
  530. Pass: countNonNullRespUser(ctx, target, opts.BalanceRequestUserID) == 0,
  531. Detail: fmt.Sprintf("legacy 有 %d 行 response_userid=0;target 侧该用户 response_user_id 非 NULL 且为空串的行数必须为 0",
  532. zeroResp),
  533. })
  534. return nil
  535. }
  536. // countNonNullRespUser 统计 fixture 用户下 response_user_id 落成 0/空串的行数(必须为 0)。
  537. func countNonNullRespUser(ctx context.Context, db *sql.DB, legacyUserID int64) int {
  538. uid := FixtureCanonicalUserID(legacyUserID)
  539. var n int
  540. if err := db.QueryRowContext(ctx,
  541. `SELECT COUNT(*) FROM t_user_balance_request
  542. WHERE user_id = ? AND (response_user_id = '0' OR response_user_id = '')`, uid).Scan(&n); err != nil {
  543. return -1
  544. }
  545. return n
  546. }
  547. func orZero(v sql.NullString) string {
  548. if !v.Valid || v.String == "" {
  549. return "0.000"
  550. }
  551. return v.String
  552. }
  553. func fromStr(v sql.NullString) sql.NullString { return v }
  554. func sameNullString(a, b sql.NullString) bool {
  555. if a.Valid != b.Valid {
  556. return false
  557. }
  558. if !a.Valid {
  559. return true
  560. }
  561. return a.String == b.String
  562. }
  563. // maskText 报告脱敏:只展示前 n 个字符 + 总长度。比对用原文(sameNullString)。
  564. func maskText(v sql.NullString, n int) string {
  565. if !v.Valid {
  566. return "NULL"
  567. }
  568. r := []rune(v.String)
  569. head := r
  570. if len(head) > n {
  571. head = head[:n]
  572. }
  573. return fmt.Sprintf("%s…(len=%d)", string(head), len(r))
  574. }
  575. func shortBizNo(s string) string {
  576. if len(s) > 8 {
  577. return s[:8] + "…"
  578. }
  579. return s
  580. }
  581. // ---------- t_expenditure_log ----------
  582. func verifyExpenditureLog(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
  583. idx map[int64]string, rep *Batch2VerifyReport) error {
  584. type row struct {
  585. BizNo string
  586. SchoolID, CampusID int64
  587. ExpenditureID int64
  588. Cost string
  589. CreateUser sql.NullInt64
  590. TimeStr sql.NullString
  591. Year, Month sql.NullInt64
  592. Remark, Imgs sql.NullString
  593. }
  594. const q = `
  595. SELECT id, schoolid, campusid, expenditureid,
  596. CAST(ROUND(CAST(cost AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
  597. create_userid, DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'),
  598. year, month, remark, imgs
  599. FROM t_expenditure_log WHERE create_userid = ? ORDER BY id`
  600. rows, err := legacy.QueryContext(ctx, q, opts.ExpenditureLogUserID)
  601. if err != nil {
  602. return err
  603. }
  604. var exp []row
  605. for rows.Next() {
  606. var r row
  607. if err := rows.Scan(&r.BizNo, &r.SchoolID, &r.CampusID, &r.ExpenditureID, &r.Cost,
  608. &r.CreateUser, &r.TimeStr, &r.Year, &r.Month, &r.Remark, &r.Imgs); err != nil {
  609. rows.Close()
  610. return err
  611. }
  612. exp = append(exp, r)
  613. }
  614. rows.Close()
  615. const t = "t_expenditure_log"
  616. cBiz := newCol(t, "biz_no", "旧 UUID 主键原样保留(逐字符)")
  617. cInst := newCol(t, "institution_id", "schoolid → institution_id(org 域未映射,直迁)")
  618. cCampus := newCol(t, "campus_id", "campusid → campus_id(同上)")
  619. cExp := newCol(t, "expenditure_id", "expenditureid → t_expenditure.id(字典 D1,恒等)")
  620. cCost := newCol(t, "cost", "float(10,3) → DECIMAL(12,2)")
  621. cUser := newCol(t, "create_user_id", "create_userid → ULID;0 → NULL")
  622. cTime := newCol(t, "create_time", "秒级相同")
  623. cYear := newCol(t, "year", "MySQL YEAR → SMALLINT")
  624. cMonth := newCol(t, "month", "")
  625. cRemark := newCol(t, "remark", "text → LONGTEXT,报告脱敏")
  626. cImgs := newCol(t, "imgs", "text → LONGTEXT")
  627. for _, e := range exp {
  628. rowKey := "biz=" + shortBizNo(e.BizNo)
  629. var g struct {
  630. ID int64
  631. BizNo string
  632. Inst, Campus int64
  633. ExpID int64
  634. Cost string
  635. CreateUser sql.NullString
  636. TimeStr sql.NullString
  637. Year, Month sql.NullInt64
  638. Remark, Imgs sql.NullString
  639. }
  640. err := target.QueryRowContext(ctx, `
  641. SELECT id, biz_no, institution_id, campus_id, expenditure_id, CAST(cost AS DECIMAL(12,2)),
  642. create_user_id, DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s.%f'),
  643. year, month, remark, imgs
  644. FROM t_expenditure_log WHERE biz_no = ?`, e.BizNo).Scan(
  645. &g.ID, &g.BizNo, &g.Inst, &g.Campus, &g.ExpID, &g.Cost, &g.CreateUser,
  646. &g.TimeStr, &g.Year, &g.Month, &g.Remark, &g.Imgs)
  647. if err == sql.ErrNoRows {
  648. cBiz.cmp(rowKey, e.BizNo, "<缺失>", false)
  649. continue
  650. }
  651. if err != nil {
  652. return err
  653. }
  654. cBiz.cmp(rowKey, e.BizNo, g.BizNo, g.BizNo == e.BizNo)
  655. cInst.cmp(rowKey, fmt.Sprint(e.SchoolID), fmt.Sprint(g.Inst), g.Inst == e.SchoolID)
  656. cCampus.cmp(rowKey, fmt.Sprint(e.CampusID), fmt.Sprint(g.Campus), g.Campus == e.CampusID)
  657. cExp.cmp(rowKey, fmt.Sprint(e.ExpenditureID), fmt.Sprint(g.ExpID), g.ExpID == e.ExpenditureID)
  658. cCost.cmp(rowKey, e.Cost, g.Cost, normalizeMoney(e.Cost) == normalizeMoney(g.Cost))
  659. cUser.cmp(rowKey, expectedULID(idx, e.CreateUser), nullStrOrNull(g.CreateUser),
  660. nullStrOrNull(g.CreateUser) == expectedULID(idx, e.CreateUser))
  661. cTime.cmp(rowKey, nullStrOrNull(e.TimeStr), nullStrOrNull(g.TimeStr),
  662. nullStrOrNull(e.TimeStr) == nullStrOrNull(g.TimeStr))
  663. cYear.cmp(rowKey, nullIntStr(e.Year), nullIntStr(g.Year), nullIntStr(e.Year) == nullIntStr(g.Year))
  664. cMonth.cmp(rowKey, nullIntStr(e.Month), nullIntStr(g.Month), nullIntStr(e.Month) == nullIntStr(g.Month))
  665. cRemark.cmp(rowKey, maskText(e.Remark, 10), maskText(g.Remark, 10), sameNullString(e.Remark, g.Remark))
  666. cImgs.cmp(rowKey, maskText(e.Imgs, 8), maskText(g.Imgs, 8), sameNullString(e.Imgs, g.Imgs))
  667. }
  668. rep.Cols = append(rep.Cols, cBiz.done(), cInst.done(), cCampus.done(), cExp.done(), cCost.done(),
  669. cUser.done(), cTime.done(), cYear.done(), cMonth.done(), cRemark.done(), cImgs.done())
  670. // 断言:expenditure_id 必须全部能在 t_expenditure 字典里找到
  671. var orphans int
  672. if err := target.QueryRowContext(ctx, `
  673. SELECT COUNT(*) FROM t_expenditure_log l
  674. LEFT JOIN t_expenditure d ON d.id = l.expenditure_id
  675. WHERE d.id IS NULL`).Scan(&orphans); err != nil {
  676. return err
  677. }
  678. rep.Assertions = append(rep.Assertions, Assertion{
  679. Name: "expenditure_id 全部可解析到 t_expenditure(无孤儿引用)",
  680. Pass: orphans == 0,
  681. Detail: fmt.Sprintf("LEFT JOIN t_expenditure 后孤儿行数 = %d(字典 45 行走 D1,id 原样)", orphans),
  682. })
  683. return nil
  684. }
  685. func nullIntStr(v sql.NullInt64) string {
  686. if !v.Valid {
  687. return "NULL"
  688. }
  689. return strconv.FormatInt(v.Int64, 10)
  690. }
  691. // ---------- t_expenditure 字典 ----------
  692. func verifyExpenditureDict(ctx context.Context, legacy, target *sql.DB, rep *Batch2VerifyReport) error {
  693. type row struct {
  694. ID int64
  695. Type int32
  696. ParentID int64
  697. Name string
  698. Remark sql.NullString
  699. }
  700. rows, err := legacy.QueryContext(ctx,
  701. `SELECT id, type, parentid, name, remark FROM t_expenditure ORDER BY id`)
  702. if err != nil {
  703. return err
  704. }
  705. var exp []row
  706. for rows.Next() {
  707. var r row
  708. if err := rows.Scan(&r.ID, &r.Type, &r.ParentID, &r.Name, &r.Remark); err != nil {
  709. rows.Close()
  710. return err
  711. }
  712. exp = append(exp, r)
  713. }
  714. rows.Close()
  715. const t = "t_expenditure"
  716. cID := newCol(t, "id", "D1:与 legacy id 完全相同")
  717. cType := newCol(t, "type", "")
  718. cParent := newCol(t, "parent_id", "自引用树,0=顶级")
  719. cName := newCol(t, "name", "字典名称原样")
  720. cRemark := newCol(t, "remark", "text → LONGTEXT")
  721. cInst := newCol(t, "institution_id", "legacy 无作用域列 → 0")
  722. for _, e := range exp {
  723. rowKey := "id=" + strconv.FormatInt(e.ID, 10)
  724. var g struct {
  725. ID, ParentID int64
  726. Type int32
  727. Name string
  728. Remark sql.NullString
  729. Inst int64
  730. }
  731. err := target.QueryRowContext(ctx,
  732. `SELECT id, type, parent_id, name, remark, institution_id FROM t_expenditure WHERE id = ?`,
  733. e.ID).Scan(&g.ID, &g.Type, &g.ParentID, &g.Name, &g.Remark, &g.Inst)
  734. if err == sql.ErrNoRows {
  735. cID.cmp(rowKey, fmt.Sprint(e.ID), "<缺失>", false)
  736. continue
  737. }
  738. if err != nil {
  739. return err
  740. }
  741. cID.cmp(rowKey, fmt.Sprint(e.ID), fmt.Sprint(g.ID), g.ID == e.ID)
  742. cType.cmp(rowKey, fmt.Sprint(e.Type), fmt.Sprint(g.Type), g.Type == e.Type)
  743. cParent.cmp(rowKey, fmt.Sprint(e.ParentID), fmt.Sprint(g.ParentID), g.ParentID == e.ParentID)
  744. cName.cmp(rowKey, e.Name, g.Name, g.Name == e.Name)
  745. cRemark.cmp(rowKey, maskText(e.Remark, 10), maskText(g.Remark, 10), sameNullString(e.Remark, g.Remark))
  746. cInst.cmp(rowKey, "0", fmt.Sprint(g.Inst), g.Inst == 0)
  747. }
  748. rep.Cols = append(rep.Cols, cID.done(), cType.done(), cParent.done(), cName.done(),
  749. cRemark.done(), cInst.done())
  750. return nil
  751. }
  752. // ---------- biz_no 四项验证 ----------
  753. // verifyBizNoFour 逐条验证 biz_no 口径的四件事。
  754. func verifyBizNoFour(ctx context.Context, legacy, target *sql.DB, opts Batch2Options,
  755. rep *Batch2VerifyReport) error {
  756. cases := []struct {
  757. table string
  758. legacyQ string
  759. arg int64
  760. mirrorID func(bizNo string) string
  761. }{
  762. {
  763. table: "t_user_balance_request",
  764. legacyQ: `SELECT id FROM t_user_balance_request WHERE userid = ? ORDER BY request_time, id`,
  765. arg: opts.BalanceRequestUserID,
  766. },
  767. {
  768. table: "t_expenditure_log",
  769. legacyQ: `SELECT id FROM t_expenditure_log WHERE create_userid = ? ORDER BY id`,
  770. arg: opts.ExpenditureLogUserID,
  771. },
  772. }
  773. for _, c := range cases {
  774. v := BizNoVerify{Table: c.table}
  775. rows, err := legacy.QueryContext(ctx, c.legacyQ, c.arg)
  776. if err != nil {
  777. return err
  778. }
  779. var legacyBizNos []string
  780. for rows.Next() {
  781. var s string
  782. if err := rows.Scan(&s); err != nil {
  783. rows.Close()
  784. return err
  785. }
  786. legacyBizNos = append(legacyBizNos, s)
  787. }
  788. rows.Close()
  789. v.LegacyRows = len(legacyBizNos)
  790. // ① 逐字符相等 + ② 长度/hex + 唯一性
  791. trows, err := target.QueryContext(ctx, "SELECT biz_no, id FROM "+c.table)
  792. if err != nil {
  793. return err
  794. }
  795. targetByBiz := map[string]int64{}
  796. for trows.Next() {
  797. var b string
  798. var id int64
  799. if err := trows.Scan(&b, &id); err != nil {
  800. trows.Close()
  801. return err
  802. }
  803. targetByBiz[b] = id
  804. }
  805. trows.Close()
  806. v.Rows = len(targetByBiz)
  807. v.UniqueBizNo = len(targetByBiz)
  808. for _, b := range legacyBizNos {
  809. if _, ok := targetByBiz[b]; ok {
  810. v.CharEqual++
  811. }
  812. if len(b) == 32 {
  813. v.Len32++
  814. }
  815. if hex32.MatchString(b) {
  816. v.HexOK++
  817. }
  818. }
  819. // ③ id 与 biz_no 解耦:目标 id 是 1..N 的连续小整数,而 biz_no 是 32 位 hex;
  820. // 断言「id != biz_no 的任一数字派生」——这里用「id 必然 <= N」这一硬事实
  821. // (若做过任何 hash/截断反算,id 会是大整数)。
  822. maxAllowed := int64(len(targetByBiz)) + 100
  823. derived := false
  824. for _, id := range targetByBiz {
  825. if id > maxAllowed {
  826. derived = true
  827. }
  828. }
  829. v.IDEqualsPrefix = derived // true = 疑似由 biz_no 反算,必须为 false
  830. // ④ 反查可用:用 biz_no JOIN 回 legacy
  831. var joined int
  832. if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
  833. `SELECT COUNT(*) FROM %s r JOIN %s.%s t ON t.biz_no = r.id WHERE r.%s = ?`,
  834. c.table, "yuansiwei_2026", c.table, joinUserCol(c.table)), c.arg).Scan(&joined); err != nil {
  835. return err
  836. }
  837. v.ReverseJoin = joined
  838. // ⑤ id 解耦的**反序插入实验**(临时表,会话结束自动消失):
  839. // 把同一批 biz_no **倒序**插进镜像表,若 id 跟着插入顺序涨、biz_no 保持原值,
  840. // 就说明 id 是纯插入计数器,与 biz_no 无关。
  841. mirrorOK, sample, err := mirrorBizNoOrder(ctx, target, c.table, legacyBizNos)
  842. if err != nil {
  843. return err
  844. }
  845. v.MirrorOK, v.MirrorSample = mirrorOK, sample
  846. // ⑥ AUTO_INCREMENT 未倒退
  847. ai, err := readAutoIncrement(ctx, target, c.table)
  848. if err != nil {
  849. return err
  850. }
  851. v.AutoIncrementVal = ai
  852. if err := target.QueryRowContext(ctx, "SELECT IFNULL(MAX(id),0) FROM "+c.table).Scan(&v.MaxID); err != nil {
  853. return err
  854. }
  855. rep.BizNo = append(rep.BizNo, v)
  856. }
  857. for _, v := range rep.BizNo {
  858. rep.Assertions = append(rep.Assertions,
  859. Assertion{
  860. Name: v.Table + ".biz_no 与 legacy id 逐字符相等",
  861. Pass: v.LegacyRows > 0 && v.CharEqual == v.LegacyRows && v.Rows == v.LegacyRows,
  862. Detail: fmt.Sprintf("legacy %d 行 / target %d 行 / 逐字符相等 %d 行;长度=32 的 %d 行,hex 形态 %d 行",
  863. v.LegacyRows, v.Rows, v.CharEqual, v.Len32, v.HexOK),
  864. },
  865. Assertion{
  866. Name: v.Table + ".biz_no 唯一(uk_biz_no 生效,无重复)",
  867. Pass: v.UniqueBizNo == v.Rows,
  868. Detail: fmt.Sprintf("DISTINCT biz_no = %d,总行数 = %d", v.UniqueBizNo, v.Rows),
  869. },
  870. Assertion{
  871. Name: v.Table + ".id 与 biz_no 解耦(id 是自增,不由 biz_no 派生)",
  872. Pass: !v.IDEqualsPrefix && v.MirrorOK,
  873. Detail: fmt.Sprintf("max(id)=%d,AUTO_INCREMENT=%d;反序插入镜像:%v",
  874. v.MaxID, v.AutoIncrementVal, v.MirrorSample),
  875. },
  876. Assertion{
  877. Name: v.Table + ".biz_no 反查可用(JOIN 回 legacy 命中全部行)",
  878. Pass: v.ReverseJoin == v.LegacyRows,
  879. Detail: fmt.Sprintf("JOIN finance_bk 命中 %d 行 / legacy %d 行", v.ReverseJoin, v.LegacyRows),
  880. },
  881. Assertion{
  882. Name: v.Table + ".AUTO_INCREMENT >= max(id)+1(biz_no 口径下计数器正常递增)",
  883. Pass: v.AutoIncrementVal >= v.MaxID+1,
  884. Detail: fmt.Sprintf("AUTO_INCREMENT=%d, max(id)=%d", v.AutoIncrementVal, v.MaxID),
  885. },
  886. )
  887. }
  888. return nil
  889. }
  890. // joinUserCol 返回各 biz_no 表在 JOIN 时用于筛选的列名。
  891. func joinUserCol(table string) string {
  892. if table == "t_expenditure_log" {
  893. return "create_userid"
  894. }
  895. return "userid"
  896. }
  897. // mirrorRequiredCols 返回各 biz_no 表「NOT NULL 且无默认值」的列与哨兵值。
  898. func mirrorRequiredCols(table string) ([]string, []any) {
  899. switch table {
  900. case "t_user_balance_request":
  901. // created_at / updated_at 在真实库里没有 DEFAULT(DDL 文件写了,实际库没生效)
  902. return []string{"created_at", "updated_at"}, []any{LegacyMissingTime, LegacyMissingTime}
  903. case "t_expenditure_log":
  904. return []string{"create_time"}, []any{LegacyMissingTime}
  905. default:
  906. return nil, nil
  907. }
  908. }
  909. // mirrorBizNoOrder 在临时表里**倒序**插入这批 biz_no,验证 id 只跟插入顺序、与 biz_no 无关。
  910. func mirrorBizNoOrder(ctx context.Context, db *sql.DB, table string, bizNos []string) (bool, []string, error) {
  911. conn, err := db.Conn(ctx)
  912. if err != nil {
  913. return false, nil, err
  914. }
  915. defer conn.Close()
  916. tmp := "tmp_b2_bizno_" + table // 表名进临时表名:连接复用时避免残留旧结构
  917. // TEMPORARY TABLE 是**会话级**的:连接被复用时它还在,所以用 IF NOT EXISTS + 清空,
  918. // 全程不 DROP 任何真实表(DROP TEMPORARY 也只影响会话临时表,这里索性不用)。
  919. if _, err := conn.ExecContext(ctx,
  920. fmt.Sprintf("CREATE TEMPORARY TABLE IF NOT EXISTS `%s` LIKE %s", tmp, table)); err != nil {
  921. return false, nil, fmt.Errorf("建临时镜像表失败:%w", err)
  922. }
  923. if _, err := conn.ExecContext(ctx, fmt.Sprintf("DELETE FROM `%s`", tmp)); err != nil {
  924. return false, nil, fmt.Errorf("清空临时镜像表失败:%w", err)
  925. }
  926. if _, err := conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE `%s` AUTO_INCREMENT = 1", tmp)); err != nil {
  927. // 临时镜像表只用于观察发号起点;ALTER 只作用于会话临时表,不碰任何真实表。
  928. return false, nil, fmt.Errorf("重置临时镜像表自增失败:%w", err)
  929. }
  930. // 目标表的 NOT NULL 且无默认值的列必须显式给值(见报告「结构漂移」一节)。
  931. // 镜像表只用来观察 id 的发号规律,这些列填哨兵即可。
  932. extraCols, extraVals := mirrorRequiredCols(table)
  933. rev := append([]string(nil), bizNos...)
  934. sort.Sort(sort.Reverse(sort.StringSlice(rev)))
  935. for _, b := range rev {
  936. cols := "biz_no"
  937. holders := "?"
  938. args := []any{b}
  939. for i, c := range extraCols {
  940. cols += ", " + c
  941. holders += ", ?"
  942. args = append(args, extraVals[i])
  943. }
  944. if _, err := conn.ExecContext(ctx,
  945. fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)", tmp, cols, holders), args...); err != nil {
  946. return false, nil, fmt.Errorf("镜像插入失败:%w", err)
  947. }
  948. }
  949. rows, err := conn.QueryContext(ctx, fmt.Sprintf("SELECT id, biz_no FROM `%s` ORDER BY id", tmp))
  950. if err != nil {
  951. return false, nil, err
  952. }
  953. defer rows.Close()
  954. var sample []string
  955. var prevBiz string
  956. ok := true
  957. i := 0
  958. for rows.Next() {
  959. var id int64
  960. var b string
  961. if err := rows.Scan(&id, &b); err != nil {
  962. return false, nil, err
  963. }
  964. if len(sample) < 4 {
  965. sample = append(sample, fmt.Sprintf("id=%d←biz=%s", id, shortBizNo(b)))
  966. }
  967. // id 必须严格从 1 递增(插入顺序),而 biz_no 必须是**倒序**(与 id 顺序相反)
  968. if id != int64(i+1) {
  969. ok = false
  970. }
  971. if i > 0 && strings.Compare(b, prevBiz) >= 0 {
  972. ok = false
  973. }
  974. prevBiz = b
  975. i++
  976. }
  977. return ok && i == len(bizNos), sample, rows.Err()
  978. }