batch2_verify.go 39 KB

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