run.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  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. expense, err := extractExpense(ctx, legacy, legacyUserID)
  55. if err != nil {
  56. return nil, err
  57. }
  58. schools, err := extractExpenseSchools(ctx, legacy, legacyUserID)
  59. if err != nil {
  60. return nil, err
  61. }
  62. logs, err := extractExpenseLogs(ctx, legacy, legacyUserID)
  63. if err != nil {
  64. return nil, err
  65. }
  66. if len(schools) == 0 {
  67. return nil, fmt.Errorf("legacy 用户 %d 无 t_user_expense_school 行,不满足 fixture 前置条件", legacyUserID)
  68. }
  69. if len(logs) < 3 {
  70. return nil, fmt.Errorf("legacy 用户 %d 只有 %d 条流水(要求 >=3)", legacyUserID, len(logs))
  71. }
  72. rep.LegacyCounts, err = countLegacyRows(ctx, legacy, legacyUserID)
  73. if err != nil {
  74. return nil, err
  75. }
  76. rep.LegacyRows = rep.LegacyCounts["t_user_expense"] + rep.LegacyCounts["t_user_expense_school"] + rep.LegacyCounts["t_user_expense_log"]
  77. canonical := FixtureCanonicalUserID(legacyUserID)
  78. rep.CanonicalUserID = canonical
  79. // ---- 创建人集合(去重、升序)----
  80. creatorSet := map[int64]bool{}
  81. for _, l := range logs {
  82. if l.CreateUserID.Valid && l.CreateUserID.Int64 > 0 {
  83. creatorSet[l.CreateUserID.Int64] = true
  84. }
  85. }
  86. for id := range creatorSet {
  87. rep.CreatorIDs = append(rep.CreatorIDs, id)
  88. }
  89. sort.Slice(rep.CreatorIDs, func(i, j int) bool { return rep.CreatorIDs[i] < rep.CreatorIDs[j] })
  90. // ---- 1) t_legacy_mapping + t_user 行 ----
  91. if err := upsertMapping(ctx, target, MappingEntry{
  92. CanonicalUserID: canonical,
  93. LegacyAcademicUserID: legacyUserID,
  94. InstitutionID: schools[0].SchoolID,
  95. CampusID: schools[0].CampusID,
  96. Role: "student",
  97. MatchSource: "seed",
  98. MatchConfidence: 100,
  99. }); err != nil {
  100. return nil, err
  101. }
  102. rep.MappingsWritten++
  103. if err := upsertUser(ctx, target, canonical, "AC15 fixture 账户(legacy="+itoa(legacyUserID)+")"); err != nil {
  104. return nil, err
  105. }
  106. rep.UsersWritten++
  107. for _, cid := range rep.CreatorIDs {
  108. uid := FixtureCanonicalUserID(cid)
  109. if err := upsertMapping(ctx, target, MappingEntry{
  110. CanonicalUserID: uid,
  111. LegacyAcademicUserID: cid,
  112. InstitutionID: schools[0].SchoolID,
  113. CampusID: schools[0].CampusID,
  114. Role: "staff",
  115. MatchSource: "seed",
  116. MatchConfidence: 100,
  117. }); err != nil {
  118. return nil, err
  119. }
  120. rep.MappingsWritten++
  121. if err := upsertUser(ctx, target, uid, "AC15 fixture 创建人(legacy="+itoa(cid)+")"); err != nil {
  122. return nil, err
  123. }
  124. rep.UsersWritten++
  125. }
  126. resolver := func(id int64) (string, bool) {
  127. if id <= 0 {
  128. return "", false
  129. }
  130. return FixtureCanonicalUserID(id), true
  131. }
  132. // ---- 2) 全局账户 ----
  133. te, warns, err := MapExpense(*expense, canonical)
  134. if err != nil {
  135. return nil, err
  136. }
  137. rep.Warnings = append(rep.Warnings, warns...)
  138. if err := upsertExpense(ctx, target, te); err != nil {
  139. return nil, err
  140. }
  141. // ---- 3) 校区账户 ----
  142. for _, s := range schools {
  143. ts, w, err := MapExpenseSchool(s, canonical)
  144. if err != nil {
  145. return nil, err
  146. }
  147. rep.Warnings = append(rep.Warnings, w...)
  148. if err := upsertExpenseSchool(ctx, target, ts); err != nil {
  149. return nil, err
  150. }
  151. }
  152. // ---- 4) 流水(D1 显式 id)----
  153. for _, l := range logs {
  154. tl, w, err := MapExpenseLog(l, canonical, resolver)
  155. if err != nil {
  156. return nil, err
  157. }
  158. rep.Warnings = append(rep.Warnings, w...)
  159. if err := insertExpenseLog(ctx, target, tl); err != nil {
  160. return nil, err
  161. }
  162. rep.LogIDs = append(rep.LogIDs, tl.ID)
  163. }
  164. // ---- 5) 样本(前 3 条,remark 脱敏)----
  165. for i, l := range logs {
  166. if i >= 3 {
  167. break
  168. }
  169. rep.Samples = append(rep.Samples, LogSample{
  170. ID: l.ID,
  171. Type: l.Type,
  172. Money: l.Money.String,
  173. CreateTime: l.CreateTime.Time.Format("2006-01-02 15:04:05"),
  174. RemarkMask: maskRemark(l.Remark),
  175. })
  176. }
  177. return rep, nil
  178. }
  179. // CleanReport 是清理结果。
  180. type CleanReport struct {
  181. Deleted map[string]int64
  182. Remaining map[string]int64
  183. CanonicalIDs []string
  184. }
  185. // Cleanup 删除本 fixture 造的全部数据,并回报 4 张表的剩余行数。
  186. //
  187. // 删除范围严格限定在「由 FixtureCanonicalUserID 派生出来的 ULID」上,
  188. // 不会误删任何非 fixture 行。
  189. func Cleanup(ctx context.Context, cfg Config, legacyUserID int64) (*CleanReport, error) {
  190. target, err := cfg.OpenTarget()
  191. if err != nil {
  192. return nil, err
  193. }
  194. defer target.Close()
  195. legacy, err := cfg.OpenLegacy()
  196. if err != nil {
  197. return nil, err
  198. }
  199. defer legacy.Close()
  200. logs, err := extractExpenseLogs(ctx, legacy, legacyUserID)
  201. if err != nil {
  202. return nil, err
  203. }
  204. creatorSet := map[int64]bool{legacyUserID: true}
  205. for _, l := range logs {
  206. if l.CreateUserID.Valid && l.CreateUserID.Int64 > 0 {
  207. creatorSet[l.CreateUserID.Int64] = true
  208. }
  209. }
  210. var ids []int64
  211. for id := range creatorSet {
  212. ids = append(ids, id)
  213. }
  214. sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
  215. var canonicals []string
  216. for _, id := range ids {
  217. canonicals = append(canonicals, FixtureCanonicalUserID(id))
  218. }
  219. rep := &CleanReport{Deleted: map[string]int64{}, Remaining: map[string]int64{}, CanonicalIDs: canonicals}
  220. inList := strings.TrimSuffix(strings.Repeat("?,", len(canonicals)), ",")
  221. args := make([]any, len(canonicals))
  222. for i, c := range canonicals {
  223. args[i] = c
  224. }
  225. // 1) 探针行(变异/异常中断时可能残留)
  226. if _, err := target.ExecContext(ctx,
  227. `DELETE FROM t_user_expense_log WHERE remark = 'ac15 fixture auto-inc probe'`); err != nil {
  228. return nil, err
  229. }
  230. for _, t := range []string{"t_user_expense_log", "t_user_expense_school", "t_user_expense"} {
  231. res, err := target.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE user_id IN (%s)", t, inList), args...)
  232. if err != nil {
  233. return nil, fmt.Errorf("清理 %s 失败:%w", t, err)
  234. }
  235. n, _ := res.RowsAffected()
  236. rep.Deleted[t] = n
  237. }
  238. // 映射与用户行
  239. res, err := target.ExecContext(ctx,
  240. fmt.Sprintf("DELETE FROM t_legacy_mapping WHERE canonical_user_id IN (%s)", inList), args...)
  241. if err != nil {
  242. return nil, err
  243. }
  244. n, _ := res.RowsAffected()
  245. rep.Deleted["t_legacy_mapping"] = n
  246. res, err = target.ExecContext(ctx,
  247. fmt.Sprintf("DELETE FROM t_user WHERE canonical_user_id IN (%s)", inList), args...)
  248. if err != nil {
  249. return nil, err
  250. }
  251. n, _ = res.RowsAffected()
  252. rep.Deleted["t_user"] = n
  253. // 复核 4 张表的**全表**行数(不按 user 过滤:要证明回到 0 行)
  254. for _, t := range []string{"t_user_expense", "t_user_expense_school", "t_user_expense_log"} {
  255. var cnt int64
  256. if err := target.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&cnt); err != nil {
  257. return nil, err
  258. }
  259. rep.Remaining[t] = cnt
  260. }
  261. for _, t := range []string{"t_legacy_mapping", "t_user"} {
  262. var cnt int64
  263. if err := target.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&cnt); err != nil {
  264. return nil, err
  265. }
  266. rep.Remaining[t] = cnt
  267. }
  268. return rep, nil
  269. }
  270. // CountTargetTables 统计目标 4 张表当前行数(清理前后对照用)。
  271. func CountTargetTables(ctx context.Context, cfg Config) (map[string]int64, error) {
  272. db, err := cfg.OpenTarget()
  273. if err != nil {
  274. return nil, err
  275. }
  276. defer db.Close()
  277. return countTargetTablesWith(ctx, db)
  278. }
  279. func countTargetTablesWith(ctx context.Context, db *sql.DB) (map[string]int64, error) {
  280. out := map[string]int64{}
  281. for _, t := range []string{"t_user_expense", "t_user_expense_school", "t_user_expense_log", "t_legacy_mapping", "t_user"} {
  282. var n int64
  283. if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+t).Scan(&n); err != nil {
  284. return nil, err
  285. }
  286. out[t] = n
  287. }
  288. return out, nil
  289. }
  290. func itoa(v int64) string { return fmt.Sprintf("%d", v) }