run.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. package fixture
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "sort"
  7. "strings"
  8. )
  9. // 本文件是流程编排:Pick → Extract → Map → Load →(Verify)→ Clean。
  10. //
  11. // 每一步都可单独从 CLI 调用,因此**没有任何跨进程的内存状态**:
  12. // canonical_user_id 由 FixtureCanonicalUserID 确定性派生,重复执行天然幂等。
  13. // MapReport 是一次「搬数据」的结果。
  14. type MapReport struct {
  15. LegacyUserID int64
  16. CanonicalUserID string
  17. LegacyCounts map[string]int
  18. LegacyRows int
  19. Warnings []string
  20. CreatorIDs []int64
  21. UsersWritten int
  22. MappingsWritten int
  23. LogIDs []int64
  24. // 样本:3 条流水(id/type/money/时间),供报告引用(remark 已脱敏)。
  25. Samples []LogSample
  26. }
  27. // LogSample 是报告里引用的流水样本。
  28. type LogSample struct {
  29. ID int64
  30. Type int32
  31. Money string
  32. CreateTime string
  33. RemarkMask string
  34. }
  35. // PrepareAndLoad 把选中用户的一条链路搬到目标库。
  36. //
  37. // 步骤:
  38. // 1. legacy 侧只读取出 1 账户 + N 校区账户 + M 条流水;
  39. // 2. 为「本人 + 流水里出现的创建人」各插 1 行 t_legacy_mapping 与 t_user;
  40. // —— 创建人是 create_user_id 的引用对象,不插就会退化成 NULL,核对就看不到「解析成功」这一半;
  41. // 3. 逐行映射(mapping.go 纯函数)→ 幂等写入(load.go)。
  42. func PrepareAndLoad(ctx context.Context, cfg Config, legacyUserID int64) (*MapReport, error) {
  43. legacy, err := cfg.OpenLegacy()
  44. if err != nil {
  45. return nil, err
  46. }
  47. defer legacy.Close()
  48. target, err := cfg.OpenTarget()
  49. if err != nil {
  50. return nil, err
  51. }
  52. defer target.Close()
  53. rep := &MapReport{LegacyUserID: legacyUserID}
  54. // 落运行标记:create_time 补了 DEFAULT 之后,漏传业务时间会被 DB 静默填成插入时刻,
  55. // 只有 verify 侧「create_time >= 迁移执行时刻 的行数 == 0」这条断言能抓住它。
  56. if _, err := WriteRunMarker(ctx, target, "batch1"); err != nil {
  57. return nil, err
  58. }
  59. expense, err := extractExpense(ctx, legacy, legacyUserID)
  60. if err != nil {
  61. return nil, err
  62. }
  63. schools, err := extractExpenseSchools(ctx, legacy, legacyUserID)
  64. if err != nil {
  65. return nil, err
  66. }
  67. logs, err := extractExpenseLogs(ctx, legacy, legacyUserID)
  68. if err != nil {
  69. return nil, err
  70. }
  71. if len(schools) == 0 {
  72. return nil, fmt.Errorf("legacy 用户 %d 无 t_user_expense_school 行,不满足 fixture 前置条件", legacyUserID)
  73. }
  74. if len(logs) < 3 {
  75. return nil, fmt.Errorf("legacy 用户 %d 只有 %d 条流水(要求 >=3)", legacyUserID, len(logs))
  76. }
  77. rep.LegacyCounts, err = countLegacyRows(ctx, legacy, legacyUserID)
  78. if err != nil {
  79. return nil, err
  80. }
  81. rep.LegacyRows = rep.LegacyCounts["t_user_expense"] + rep.LegacyCounts["t_user_expense_school"] + rep.LegacyCounts["t_user_expense_log"]
  82. canonical := FixtureCanonicalUserID(legacyUserID)
  83. rep.CanonicalUserID = canonical
  84. // ---- 创建人集合(去重、升序)----
  85. creatorSet := map[int64]bool{}
  86. for _, l := range logs {
  87. if l.CreateUserID.Valid && l.CreateUserID.Int64 > 0 {
  88. creatorSet[l.CreateUserID.Int64] = true
  89. }
  90. }
  91. for id := range creatorSet {
  92. rep.CreatorIDs = append(rep.CreatorIDs, id)
  93. }
  94. sort.Slice(rep.CreatorIDs, func(i, j int) bool { return rep.CreatorIDs[i] < rep.CreatorIDs[j] })
  95. // ---- 1) t_legacy_mapping + t_user 行 ----
  96. if err := upsertMapping(ctx, target, MappingEntry{
  97. CanonicalUserID: canonical,
  98. LegacyAcademicUserID: legacyUserID,
  99. InstitutionID: schools[0].SchoolID,
  100. CampusID: schools[0].CampusID,
  101. Role: "student",
  102. MatchSource: "seed",
  103. MatchConfidence: 100,
  104. }); err != nil {
  105. return nil, err
  106. }
  107. rep.MappingsWritten++
  108. if err := upsertUser(ctx, target, canonical, "AC15 fixture 账户(legacy="+itoa(legacyUserID)+")"); err != nil {
  109. return nil, err
  110. }
  111. rep.UsersWritten++
  112. for _, cid := range rep.CreatorIDs {
  113. uid := FixtureCanonicalUserID(cid)
  114. if err := upsertMapping(ctx, target, MappingEntry{
  115. CanonicalUserID: uid,
  116. LegacyAcademicUserID: cid,
  117. InstitutionID: schools[0].SchoolID,
  118. CampusID: schools[0].CampusID,
  119. Role: "staff",
  120. MatchSource: "seed",
  121. MatchConfidence: 100,
  122. }); err != nil {
  123. return nil, err
  124. }
  125. rep.MappingsWritten++
  126. if err := upsertUser(ctx, target, uid, "AC15 fixture 创建人(legacy="+itoa(cid)+")"); err != nil {
  127. return nil, err
  128. }
  129. rep.UsersWritten++
  130. }
  131. resolver := func(id int64) (string, bool) {
  132. if id <= 0 {
  133. return "", false
  134. }
  135. return FixtureCanonicalUserID(id), true
  136. }
  137. // ---- 2) 全局账户 ----
  138. te, warns, err := MapExpense(*expense, canonical)
  139. if err != nil {
  140. return nil, err
  141. }
  142. rep.Warnings = append(rep.Warnings, warns...)
  143. if err := upsertExpense(ctx, target, te); err != nil {
  144. return nil, err
  145. }
  146. // ---- 3) 校区账户 ----
  147. for _, s := range schools {
  148. ts, w, err := MapExpenseSchool(s, canonical)
  149. if err != nil {
  150. return nil, err
  151. }
  152. rep.Warnings = append(rep.Warnings, w...)
  153. if err := upsertExpenseSchool(ctx, target, ts); err != nil {
  154. return nil, err
  155. }
  156. }
  157. // ---- 4) 流水(D1 显式 id)----
  158. for _, l := range logs {
  159. tl, w, err := MapExpenseLog(l, canonical, resolver)
  160. if err != nil {
  161. return nil, err
  162. }
  163. rep.Warnings = append(rep.Warnings, w...)
  164. if err := insertExpenseLog(ctx, target, tl); err != nil {
  165. return nil, err
  166. }
  167. rep.LogIDs = append(rep.LogIDs, tl.ID)
  168. }
  169. // ---- 5) 样本(前 3 条,remark 脱敏)----
  170. for i, l := range logs {
  171. if i >= 3 {
  172. break
  173. }
  174. rep.Samples = append(rep.Samples, LogSample{
  175. ID: l.ID,
  176. Type: l.Type,
  177. Money: l.Money.String,
  178. CreateTime: l.CreateTime.Time.Format("2006-01-02 15:04:05"),
  179. RemarkMask: maskRemark(l.Remark),
  180. })
  181. }
  182. return rep, nil
  183. }
  184. // CleanReport 是清理结果。
  185. type CleanReport struct {
  186. Deleted map[string]int64
  187. Remaining map[string]int64
  188. CanonicalIDs []string
  189. }
  190. // Cleanup 删除本 fixture 造的全部数据,并回报 4 张表的剩余行数。
  191. //
  192. // 删除范围严格限定在「由 FixtureCanonicalUserID 派生出来的 ULID」上,
  193. // 不会误删任何非 fixture 行。
  194. func Cleanup(ctx context.Context, cfg Config, legacyUserID int64) (*CleanReport, error) {
  195. target, err := cfg.OpenTarget()
  196. if err != nil {
  197. return nil, err
  198. }
  199. defer target.Close()
  200. legacy, err := cfg.OpenLegacy()
  201. if err != nil {
  202. return nil, err
  203. }
  204. defer legacy.Close()
  205. logs, err := extractExpenseLogs(ctx, legacy, legacyUserID)
  206. if err != nil {
  207. return nil, err
  208. }
  209. creatorSet := map[int64]bool{legacyUserID: true}
  210. for _, l := range logs {
  211. if l.CreateUserID.Valid && l.CreateUserID.Int64 > 0 {
  212. creatorSet[l.CreateUserID.Int64] = true
  213. }
  214. }
  215. var ids []int64
  216. for id := range creatorSet {
  217. ids = append(ids, id)
  218. }
  219. sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
  220. var canonicals []string
  221. for _, id := range ids {
  222. canonicals = append(canonicals, FixtureCanonicalUserID(id))
  223. }
  224. rep := &CleanReport{Deleted: map[string]int64{}, Remaining: map[string]int64{}, CanonicalIDs: canonicals}
  225. inList := strings.TrimSuffix(strings.Repeat("?,", len(canonicals)), ",")
  226. args := make([]any, len(canonicals))
  227. for i, c := range canonicals {
  228. args[i] = c
  229. }
  230. // 1) 探针行(变异/异常中断时可能残留)
  231. if _, err := target.ExecContext(ctx,
  232. `DELETE FROM t_user_expense_log WHERE remark = 'ac15 fixture auto-inc probe'`); err != nil {
  233. return nil, err
  234. }
  235. for _, t := range []string{"t_user_expense_log", "t_user_expense_school", "t_user_expense"} {
  236. res, err := target.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE user_id IN (%s)", t, inList), args...)
  237. if err != nil {
  238. return nil, fmt.Errorf("清理 %s 失败:%w", t, err)
  239. }
  240. n, _ := res.RowsAffected()
  241. rep.Deleted[t] = n
  242. }
  243. // 映射与用户行
  244. res, err := target.ExecContext(ctx,
  245. fmt.Sprintf("DELETE FROM t_legacy_mapping WHERE canonical_user_id IN (%s)", inList), args...)
  246. if err != nil {
  247. return nil, err
  248. }
  249. n, _ := res.RowsAffected()
  250. rep.Deleted["t_legacy_mapping"] = n
  251. res, err = target.ExecContext(ctx,
  252. fmt.Sprintf("DELETE FROM t_user WHERE canonical_user_id IN (%s)", inList), args...)
  253. if err != nil {
  254. return nil, err
  255. }
  256. n, _ = res.RowsAffected()
  257. rep.Deleted["t_user"] = n
  258. // 运行标记也清掉(t_audit_log 应回到 0 行)
  259. if _, err := ClearRunMarkers(ctx, target); err != nil {
  260. return nil, err
  261. }
  262. // 复核 4 张表的**全表**行数(不按 user 过滤:要证明回到 0 行)
  263. for _, t := range []string{"t_user_expense", "t_user_expense_school", "t_user_expense_log"} {
  264. var cnt int64
  265. if err := target.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&cnt); err != nil {
  266. return nil, err
  267. }
  268. rep.Remaining[t] = cnt
  269. }
  270. for _, t := range []string{"t_legacy_mapping", "t_user"} {
  271. var cnt int64
  272. if err := target.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&cnt); err != nil {
  273. return nil, err
  274. }
  275. rep.Remaining[t] = cnt
  276. }
  277. return rep, nil
  278. }
  279. // CountTargetTables 统计目标 4 张表当前行数(清理前后对照用)。
  280. func CountTargetTables(ctx context.Context, cfg Config) (map[string]int64, error) {
  281. db, err := cfg.OpenTarget()
  282. if err != nil {
  283. return nil, err
  284. }
  285. defer db.Close()
  286. return countTargetTablesWith(ctx, db)
  287. }
  288. func countTargetTablesWith(ctx context.Context, db *sql.DB) (map[string]int64, error) {
  289. out := map[string]int64{}
  290. for _, t := range []string{"t_user_expense", "t_user_expense_school", "t_user_expense_log", "t_legacy_mapping", "t_user"} {
  291. var n int64
  292. if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&n); err != nil {
  293. return nil, err
  294. }
  295. out[t] = n
  296. }
  297. return out, nil
  298. }
  299. func itoa(v int64) string { return fmt.Sprintf("%d", v) }