runner.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869
  1. package migrate
  2. import (
  3. "context"
  4. "fmt"
  5. "time"
  6. "gorm.io/driver/mysql"
  7. "gorm.io/gorm"
  8. identitymodel "yuansiwei/unified-backend/internal/identity/model"
  9. "yuansiwei/unified-backend/internal/identity/id"
  10. "yuansiwei/unified-backend/internal/migrate/norm"
  11. orgmodel "yuansiwei/unified-backend/internal/org/model"
  12. )
  13. // Options 迁移运行配置。
  14. type Options struct {
  15. // TeachingDSN 教学库 yuansiwei_i(只读源)。
  16. TeachingDSN string
  17. // FinanceDSN 教务库 finance_bk(只读源)。
  18. FinanceDSN string
  19. // TargetDSN 目标态 unified 库(读写,写入 5 表)。
  20. TargetDSN string
  21. // DryRun 仅预演:读库做存在性检查 + 产出报告,不执行任何写入。
  22. DryRun bool
  23. // Now 时间锚点(regTime 为空时用于 ULID 发放 / created_at)。可注入便于测试。
  24. Now time.Time
  25. }
  26. // Runner 迁移编排器(P1-04/T5 主体)。
  27. //
  28. // 职责:
  29. // 1. 读 legacy 双库(只读);
  30. // 2. 按 P0-06 归一 + P0-07 置信分级规划;
  31. // 3. 幂等 upsert 写目标态 5 表(t_institution/t_campus 维度 + t_user/t_identity/
  32. // t_wechat_union/t_membership/t_legacy_mapping);
  33. // 4. 产出 MigrateReport(含 P0-08 冲突/异常清单)。
  34. //
  35. // 事务边界:所有写入在单个 GORM 事务中(dry-run 不进入事务,仅读)。
  36. type Runner struct {
  37. opts Options
  38. src *Source
  39. target *gorm.DB
  40. // wxCache:legacy userid → 微信身份(Run 时填充,writeIdentities/writeWechatUnion 消费)。
  41. // 放在 Runner 上避免大参数透传;非并发(单事务串行处理)。
  42. wxCache map[int32]*LegacyWeixinUser
  43. }
  44. // NewRunner 构建迁移编排器(连接 legacy 双库 + 目标库,均 fail-fast)。
  45. func NewRunner(opts Options) (*Runner, error) {
  46. if opts.Now.IsZero() {
  47. opts.Now = time.Now()
  48. }
  49. src, err := NewSource(opts.TeachingDSN, opts.FinanceDSN)
  50. if err != nil {
  51. return nil, err
  52. }
  53. target, err := gorm.Open(gormMySQLTarget(opts.TargetDSN), gormMigrateCfg())
  54. if err != nil {
  55. _ = src.Close()
  56. return nil, fmt.Errorf("migrate: 目标库连接失败: %w", err)
  57. }
  58. if sqlDB, err := target.DB(); err == nil {
  59. if err := sqlDB.Ping(); err != nil {
  60. _ = src.Close()
  61. return nil, fmt.Errorf("migrate: 目标库 Ping 失败: %w", err)
  62. }
  63. }
  64. return &Runner{opts: opts, src: src, target: target}, nil
  65. }
  66. // Close 关闭所有连接。
  67. func (r *Runner) Close() error {
  68. var err error
  69. if r.src != nil {
  70. err = r.src.Close()
  71. }
  72. if r.target != nil {
  73. if sqlDB, derr := r.target.DB(); derr == nil {
  74. _ = sqlDB.Close()
  75. }
  76. }
  77. return err
  78. }
  79. // EnsureTables 确保目标态 12 表存在(dev 期 AutoMigrate;生产以 DDL 为准,幂等对齐)。
  80. // 迁移前调用,避免"写入时表不存在"。
  81. func (r *Runner) EnsureTables(ctx context.Context) error {
  82. tables := append(identitymodel.AllTables(), orgmodel.AllTables()...)
  83. return r.target.WithContext(ctx).AutoMigrate(tables...)
  84. }
  85. // Run 执行迁移(单事务)。
  86. //
  87. // 出参:
  88. // - *MigrateReport:完整报告(含冲突/异常清单,P0-08 输入)。
  89. // - error:仅当读取/事务提交失败时返回;单行写入失败记录进 report.Warnings 不中断。
  90. func (r *Runner) Run(ctx context.Context) (*MigrateReport, error) {
  91. rep := &MigrateReport{DryRun: r.opts.DryRun}
  92. // ---- 读取 legacy 全量(只读)----
  93. users, err := r.src.LoadAllUsers(ctx)
  94. if err != nil {
  95. return rep, fmt.Errorf("migrate: 读 t_user 失败: %w", err)
  96. }
  97. rep.UsersTotal = len(users)
  98. wx, err := r.src.LoadAllWeixinUsers(ctx)
  99. if err != nil {
  100. return rep, fmt.Errorf("migrate: 读 t_weixin_user 失败: %w", err)
  101. }
  102. // 填充微信缓存(userid → 身份,1:1)
  103. r.wxCache = make(map[int32]*LegacyWeixinUser, len(wx))
  104. for i := range wx {
  105. if wx[i].UserID > 0 {
  106. if _, exists := r.wxCache[wx[i].UserID]; !exists {
  107. r.wxCache[wx[i].UserID] = &wx[i]
  108. }
  109. }
  110. }
  111. assoc, err := r.src.LoadAllAssociations(ctx)
  112. if err != nil {
  113. return rep, fmt.Errorf("migrate: 读 t_user_account_association 失败: %w", err)
  114. }
  115. schoolIDs, err := r.src.LoadDistinctSchoolIDs(ctx)
  116. if err != nil {
  117. return rep, fmt.Errorf("migrate: 读 t_user_school_campus distinct schoolid 失败: %w", err)
  118. }
  119. schools, err := r.src.LoadSchools(ctx, schoolIDs)
  120. if err != nil {
  121. return rep, fmt.Errorf("migrate: 读 t_school 失败: %w", err)
  122. }
  123. rep.InstitutionsTotal = len(schools)
  124. campuses, err := r.src.LoadCampuses(ctx)
  125. if err != nil {
  126. return rep, fmt.Errorf("migrate: 读 t_school_campus 失败: %w", err)
  127. }
  128. rep.CampusesTotal = len(campuses)
  129. binds, err := r.src.LoadUserSchoolCampus(ctx)
  130. if err != nil {
  131. return rep, fmt.Errorf("migrate: 读 t_user_school_campus 失败: %w", err)
  132. }
  133. // ---- 规划(纯逻辑:ULID 发放 + 置信分级 + 冲突检测)----
  134. // 已有映射(重跑幂等:legacy id → canonical_user_id 复用)
  135. existingCanon, err := r.loadExistingMappings(ctx)
  136. if err != nil {
  137. return rep, fmt.Errorf("migrate: 读 t_legacy_mapping 失败: %w", err)
  138. }
  139. userSet := make(map[int32]struct{}, len(users))
  140. for i := range users {
  141. userSet[users[i].ID] = struct{}{}
  142. }
  143. // 微信孤儿(openid 的 userid 不在 t_user)
  144. for i := range wx {
  145. if _, ok := userSet[wx[i].UserID]; !ok {
  146. rep.WechatOrphans = append(rep.WechatOrphans, ConflictItem{
  147. LegacyID: wx[i].UserID,
  148. Detail: fmt.Sprintf("openid=%s 的 userid 不在 t_user,丢弃", wx[i].OpenID),
  149. })
  150. }
  151. }
  152. // 种子映射(userid → 阅卷/标记系统 id),供 t_legacy_mapping.legacy_teaching_user_id(P1-05 §2 拍定)。
  153. teachingByUserID := make(map[int32]int32, len(assoc))
  154. for i := range assoc {
  155. if assoc[i].UserID > 0 && assoc[i].MarkingSystemUserID != nil && *assoc[i].MarkingSystemUserID > 0 {
  156. if _, exists := teachingByUserID[assoc[i].UserID]; !exists {
  157. teachingByUserID[assoc[i].UserID] = *assoc[i].MarkingSystemUserID
  158. }
  159. }
  160. }
  161. planner := NewPlanner(users, assoc, wx)
  162. // ULID 发放:已映射复用,新 id 发放(时间有序:以 regTime 为锚)
  163. canonByID := make(map[int32]string, len(users))
  164. for i := range users {
  165. u := &users[i]
  166. if c, ok := existingCanon[u.ID]; ok && c != "" {
  167. canonByID[u.ID] = c
  168. continue
  169. }
  170. var t time.Time
  171. if u.RegTime != nil {
  172. t = *u.RegTime
  173. } else {
  174. t = r.opts.Now
  175. }
  176. c, err := id.NewCanonicalUserIDAt(t)
  177. if err != nil {
  178. return rep, fmt.Errorf("migrate: ULID 发放失败(legacy id=%d): %w", u.ID, err)
  179. }
  180. canonByID[u.ID] = c
  181. }
  182. // 决策表(每个 user 的匹配决策)
  183. decisionByID := make(map[int32]MatchDecision, len(users))
  184. for i := range users {
  185. u := &users[i]
  186. d := planner.Decide(u.ID, u.PhoneNumber)
  187. decisionByID[u.ID] = d
  188. // phone 撞号冲突清单(每组只记一次,避免重复)
  189. if d.PhoneConflict {
  190. if u.PhoneNumber != nil {
  191. rep.PhoneConflicts = appendUniqueConflict(rep.PhoneConflicts, ConflictItem{
  192. LegacyID: u.ID,
  193. Detail: fmt.Sprintf("phone=%s 撞号(同归一 phone 多账号,不自动合并)", normPhoneDisplay(*u.PhoneNumber)),
  194. })
  195. }
  196. }
  197. if d.PhoneInvalid && u.PhoneNumber != nil {
  198. rep.PhoneInvalid = append(rep.PhoneInvalid, ConflictItem{
  199. LegacyID: u.ID,
  200. Detail: fmt.Sprintf("phone 归一失败,原始值=%q", *u.PhoneNumber),
  201. })
  202. }
  203. }
  204. // ---- 写入(单事务;dry-run 仅读不写)----
  205. if r.opts.DryRun {
  206. // dry-run:只做存在性预检 + 计数(不实际写入)。
  207. // 机构/校区/用户/身份/成员 的新建量按"目标库已有则复用"估算。
  208. r.simulateCounts(ctx, rep, schools, campuses, users, canonByID, binds)
  209. return rep, nil
  210. }
  211. err = r.target.Transaction(func(tx *gorm.DB) error {
  212. // ① 机构(维度表,幂等:已存在则复用)
  213. instByID, err := r.writeInstitutions(ctx, tx, schools, rep)
  214. if err != nil {
  215. return err
  216. }
  217. // ② 校区(依赖机构)
  218. campusByID, err := r.writeCampuses(ctx, tx, campuses, instByID, rep)
  219. if err != nil {
  220. return err
  221. }
  222. // ③ 用户 + 身份 + 微信锚点 + 遗留映射(按 legacy id 序,幂等)
  223. //
  224. // 性能:一次性批量装载目标库现有唯一键到内存 map(O(1) 查表替代 O(N) 逐行 COUNT)。
  225. // 插入时把新键标记为已占用,维持"首个占用者胜出"语义与重跑幂等。
  226. usedUsername := map[string]int32{} // 归一 username → 首个占用者
  227. usedPhone := map[string]int32{} // 归一 phone → 首个占用者
  228. existUsername := r.existingUsernames(ctx, tx)
  229. existPhone := r.existingIdentities(ctx, tx, identitymodel.IdentityTypePhone, "")
  230. for k, v := range existUsername {
  231. usedUsername[k] = v
  232. }
  233. for k, v := range existPhone {
  234. usedPhone[k] = v
  235. }
  236. // 各表唯一键存量集合(批量装载,避免逐行 COUNT)
  237. existUserCanons := r.existingUserCanons(ctx, tx) // t_user.canonical_user_id
  238. existIdentityKeys := r.existingIdentityKeys(ctx, tx) // t_identity type|identifier|appid
  239. existUnionids := r.existingUnionids(ctx, tx) // t_wechat_union.unionid
  240. existMappingCanons := r.existingMappingCanons(ctx, tx) // t_legacy_mapping.canonical_user_id
  241. existMemberKeys := r.existingMemberKeys(ctx, tx) // t_membership canonical|campus|role
  242. for i := range users {
  243. u := &users[i]
  244. canon := canonByID[u.ID]
  245. d := decisionByID[u.ID]
  246. if err := r.writeUser(ctx, tx, u, canon, d, rep, usedUsername, existUserCanons); err != nil {
  247. rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_user legacy_id=%d 失败: %v", u.ID, err))
  248. continue
  249. }
  250. r.writeIdentities(ctx, tx, u, canon, d, rep, usedPhone, existIdentityKeys)
  251. r.writeWechatUnion(ctx, tx, u.ID, canon, existUnionids, rep)
  252. if err := r.writeLegacyMapping(ctx, tx, u, canon, d, teachingByUserID, existMappingCanons, rep); err != nil {
  253. rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_legacy_mapping legacy_id=%d 失败: %v", u.ID, err))
  254. }
  255. }
  256. // ④ 成员关系(依赖 canonical + 机构/校区)
  257. r.writeMemberships(ctx, tx, binds, canonByID, instByID, campusByID, users, existMemberKeys, rep)
  258. return nil
  259. })
  260. if err != nil {
  261. return rep, fmt.Errorf("migrate: 事务提交失败(已回滚): %w", err)
  262. }
  263. return rep, nil
  264. }
  265. // normPhoneDisplay 展示用(截断,防超长)。
  266. func normPhoneDisplay(raw string) string {
  267. c, ok := norm.Phone(raw)
  268. if ok {
  269. return c
  270. }
  271. return raw
  272. }
  273. // appendUniqueConflict 去重追加冲突项(同 LegacyID 只记一次)。
  274. func appendUniqueConflict(items []ConflictItem, item ConflictItem) []ConflictItem {
  275. for _, e := range items {
  276. if e.LegacyID == item.LegacyID {
  277. return items
  278. }
  279. }
  280. return append(items, item)
  281. }
  282. // ---- 幂等辅助(读目标库现有状态)----
  283. // loadExistingMappings 读 t_legacy_mapping,返回 legacy_i_user_id → canonical_user_id。
  284. //
  285. // dry-run 容错:目标表未建(MySQL 1146 表不存在)时按"空表"处理(全量首次迁移,ULID 全发放)。
  286. // 这保证 dry-run 纯只读、不依赖目标库已建表。
  287. func (r *Runner) loadExistingMappings(ctx context.Context) (map[int32]string, error) {
  288. m := make(map[int32]string)
  289. var rows []identitymodel.LegacyMapping
  290. if err := r.target.WithContext(ctx).Model(&identitymodel.LegacyMapping{}).Find(&rows).Error; err != nil {
  291. if isMissingTable(err) {
  292. return m, nil // 表不存在(dry-run 首次)→ 空映射
  293. }
  294. return nil, err
  295. }
  296. for i := range rows {
  297. if rows[i].LegacyIUserID != nil && *rows[i].LegacyIUserID > 0 {
  298. m[*rows[i].LegacyIUserID] = rows[i].CanonicalUserID
  299. }
  300. }
  301. return m, nil
  302. }
  303. // existingUsernames 读 t_user 现有 username(归一 username → canonical_user_id 占位,用于冲突检测)。
  304. func (r *Runner) existingUsernames(ctx context.Context, tx *gorm.DB) map[string]int32 {
  305. m := make(map[string]int32)
  306. var users []identitymodel.User
  307. if err := tx.WithContext(ctx).Model(&identitymodel.User{}).Find(&users).Error; err != nil {
  308. return m
  309. }
  310. for i := range users {
  311. if users[i].Username != nil && *users[i].Username != "" {
  312. canon := norm.Username(*users[i].Username)
  313. if _, ok := m[canon]; !ok {
  314. m[canon] = 0 // 占位(无 legacy id,仅标记"已占用")
  315. }
  316. }
  317. }
  318. return m
  319. }
  320. // existingIdentities 读 t_identity 现有指定 type 的 identifier(归一值 → 占位)。
  321. func (r *Runner) existingIdentities(ctx context.Context, tx *gorm.DB, typ, appid string) map[string]int32 {
  322. m := make(map[string]int32)
  323. var idents []identitymodel.Identity
  324. q := tx.WithContext(ctx).Model(&identitymodel.Identity{}).Where("type = ?", typ)
  325. if err := q.Find(&idents).Error; err != nil {
  326. return m
  327. }
  328. for i := range idents {
  329. m[idents[i].Identifier] = 0
  330. }
  331. return m
  332. }
  333. // identityKey 唯一键 (type, identifier, appid) 的内存 map key(与 DB 唯一键 uk_type_identifier_appid 对齐)。
  334. func identityKey(typ, identifier, appid string) string {
  335. return typ + "|" + identifier + "|" + appid
  336. }
  337. // memberKey 唯一键 (canonical_user_id, campus_id, role) 的内存 map key。
  338. func memberKey(canon string, campus int64, role string) string {
  339. return canon + "|" + fmt.Sprint(campus) + "|" + role
  340. }
  341. // existingUserCanons 批量读 t_user.canonical_user_id → set(重跑幂等:已映射 ULID 复用,跳过写入)。
  342. func (r *Runner) existingUserCanons(ctx context.Context, tx *gorm.DB) map[string]struct{} {
  343. m := make(map[string]struct{})
  344. var ids []string
  345. if err := tx.WithContext(ctx).Model(&identitymodel.User{}).
  346. Pluck("canonical_user_id", &ids).Error; err != nil {
  347. return m
  348. }
  349. for _, s := range ids {
  350. m[s] = struct{}{}
  351. }
  352. return m
  353. }
  354. // existingIdentityKeys 批量读 t_identity 全量唯一键 (type, identifier, appid) → set。
  355. func (r *Runner) existingIdentityKeys(ctx context.Context, tx *gorm.DB) map[string]struct{} {
  356. m := make(map[string]struct{})
  357. var idents []identitymodel.Identity
  358. if err := tx.WithContext(ctx).Model(&identitymodel.Identity{}).Find(&idents).Error; err != nil {
  359. return m
  360. }
  361. for i := range idents {
  362. m[identityKey(idents[i].Type, idents[i].Identifier, idents[i].AppID)] = struct{}{}
  363. }
  364. return m
  365. }
  366. // existingUnionids 批量读 t_wechat_union.unionid → set。
  367. func (r *Runner) existingUnionids(ctx context.Context, tx *gorm.DB) map[string]struct{} {
  368. m := make(map[string]struct{})
  369. var ids []string
  370. if err := tx.WithContext(ctx).Model(&identitymodel.WechatUnion{}).
  371. Pluck("unionid", &ids).Error; err != nil {
  372. return m
  373. }
  374. for _, s := range ids {
  375. m[s] = struct{}{}
  376. }
  377. return m
  378. }
  379. // existingMappingCanons 批量读 t_legacy_mapping.canonical_user_id → set。
  380. func (r *Runner) existingMappingCanons(ctx context.Context, tx *gorm.DB) map[string]struct{} {
  381. m := make(map[string]struct{})
  382. var ids []string
  383. if err := tx.WithContext(ctx).Model(&identitymodel.LegacyMapping{}).
  384. Pluck("canonical_user_id", &ids).Error; err != nil {
  385. return m
  386. }
  387. for _, s := range ids {
  388. m[s] = struct{}{}
  389. }
  390. return m
  391. }
  392. // existingMemberKeys 批量读 t_membership 唯一键 (canonical_user_id, campus_id, role) → set。
  393. func (r *Runner) existingMemberKeys(ctx context.Context, tx *gorm.DB) map[string]struct{} {
  394. m := make(map[string]struct{})
  395. var ms []orgmodel.Membership
  396. if err := tx.WithContext(ctx).Model(&orgmodel.Membership{}).
  397. Select("canonical_user_id", "campus_id", "role").Find(&ms).Error; err != nil {
  398. return m
  399. }
  400. for i := range ms {
  401. m[memberKey(ms[i].CanonicalUserID, ms[i].CampusID, ms[i].Role)] = struct{}{}
  402. }
  403. return m
  404. }
  405. // ---- 写入方法(每个方法在 dry-run 外执行;幂等:存在则跳过)----
  406. // writeInstitutions 写 t_institution(幂等:按 name 查现有复用)。
  407. func (r *Runner) writeInstitutions(ctx context.Context, tx *gorm.DB, schools []LegacySchool, rep *MigrateReport) (map[int32]int64, error) {
  408. instByID := make(map[int32]int64, len(schools))
  409. for i := range schools {
  410. s := &schools[i]
  411. var existing []orgmodel.Institution
  412. tx.WithContext(ctx).Model(&orgmodel.Institution{}).
  413. Where("institution_name = ?", s.Name).Limit(1).Find(&existing)
  414. if len(existing) > 0 {
  415. instByID[s.ID] = existing[0].InstitutionID
  416. continue
  417. }
  418. inst := &orgmodel.Institution{
  419. InstitutionName: s.Name,
  420. Status: mapOrgStatus(s.Status),
  421. CreatedAt: r.opts.Now,
  422. UpdatedAt: r.opts.Now,
  423. }
  424. if err := tx.WithContext(ctx).Create(inst).Error; err != nil {
  425. return instByID, err
  426. }
  427. instByID[s.ID] = inst.InstitutionID
  428. rep.InstitutionsCreated++
  429. }
  430. return instByID, nil
  431. }
  432. // writeCampuses 写 t_campus(幂等:按 campus_name + institution 复用)。
  433. func (r *Runner) writeCampuses(ctx context.Context, tx *gorm.DB, campuses []LegacySchoolCampus,
  434. instByID map[int32]int64, rep *MigrateReport) (map[int32]int64, error) {
  435. campusByID := make(map[int32]int64, len(campuses))
  436. for i := range campuses {
  437. c := &campuses[i]
  438. // 孤儿校区(school_id 不在被引用机构集):institution_id 置 0(机构级/平台级)
  439. instID, ok := instByID[c.SchoolID]
  440. if !ok {
  441. instID = 0
  442. }
  443. var existing []orgmodel.Campus
  444. tx.WithContext(ctx).Model(&orgmodel.Campus{}).
  445. Where("campus_name = ? AND institution_id = ?", c.Name, instID).Limit(1).Find(&existing)
  446. if len(existing) > 0 {
  447. campusByID[c.ID] = existing[0].CampusID
  448. continue
  449. }
  450. campus := &orgmodel.Campus{
  451. InstitutionID: instID,
  452. CampusName: c.Name,
  453. CampusCode: c.Code,
  454. Status: mapOrgStatus(c.Status),
  455. CreatedAt: r.opts.Now,
  456. UpdatedAt: r.opts.Now,
  457. }
  458. if err := tx.WithContext(ctx).Create(campus).Error; err != nil {
  459. return campusByID, err
  460. }
  461. campusByID[c.ID] = campus.CampusID
  462. rep.CampusesCreated++
  463. }
  464. return campusByID, nil
  465. }
  466. // writeUser 写 t_user(幂等:canonical_user_id 已存在则跳过;username 冲突置 NULL)。
  467. //
  468. // 性能:幂等判断用 existUserCanons 内存 set(事务内一次性批量装载),不再逐行 COUNT。
  469. func (r *Runner) writeUser(ctx context.Context, tx *gorm.DB, u *LegacyUser, canon string,
  470. d MatchDecision, rep *MigrateReport, usedUsername map[string]int32, existUserCanons map[string]struct{}) error {
  471. if _, ok := existUserCanons[canon]; ok {
  472. rep.UsersSkipped++
  473. return nil
  474. }
  475. // username:归一后若已占用(存量或本批首个外)→ 置 NULL + 记冲突
  476. var username *string
  477. if u.Username != "" {
  478. canonUsername := norm.Username(u.Username)
  479. if _, taken := usedUsername[canonUsername]; !taken {
  480. s := u.Username
  481. username = &s
  482. usedUsername[canonUsername] = u.ID
  483. } else {
  484. rep.UsernameConflicts = append(rep.UsernameConflicts, ConflictItem{
  485. LegacyID: u.ID,
  486. Detail: fmt.Sprintf("username=%q 归一后与已有用户重复,置 NULL", u.Username),
  487. })
  488. }
  489. }
  490. displayName := u.RealName
  491. user := &identitymodel.User{
  492. CanonicalUserID: canon,
  493. Username: username,
  494. DisplayName: displayName,
  495. AvatarURL: u.Avatar,
  496. PersonType: mapPersonType(u.Role),
  497. PasswordHash: &u.Password,
  498. PasswordSalt: &u.Salt,
  499. HashVersion: strPtr(identitymodel.HashVersionShaSalt), // I 库=教学侧,sha_salt(ADR-016 惰性升级)
  500. Status: mapUserStatus(u.Status),
  501. CreatedAt: r.opts.Now,
  502. UpdatedAt: r.opts.Now,
  503. }
  504. if u.RegTime != nil {
  505. user.CreatedAt = *u.RegTime
  506. }
  507. if err := tx.WithContext(ctx).Create(user).Error; err != nil {
  508. return err
  509. }
  510. existUserCanons[canon] = struct{}{} // 标记已占用(本批后续重复 ULID 幂等跳过)
  511. rep.UsersCreated++
  512. if u.Status == 0 || u.Status == 3 {
  513. rep.UsersDisabled++
  514. }
  515. return nil
  516. }
  517. // writeIdentities 写 t_identity(phone/email/username/wechat;幂等 + 全局唯一键冲突处理)。
  518. //
  519. // 性能:幂等判断用 existIdentityKeys 内存 set(事务内一次性批量装载),不再逐行 COUNT。
  520. func (r *Runner) writeIdentities(ctx context.Context, tx *gorm.DB, u *LegacyUser, canon string,
  521. d MatchDecision, rep *MigrateReport, usedPhone map[string]int32, existIdentityKeys map[string]struct{}) {
  522. // phone(强归一;撞号→本批首个占用,其余跳过+冲突已记)
  523. if u.PhoneNumber != nil && *u.PhoneNumber != "" {
  524. canonPhone, ok := norm.Phone(*u.PhoneNumber)
  525. if ok && !d.PhoneConflict {
  526. if _, taken := usedPhone[canonPhone]; !taken {
  527. if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypePhone, canonPhone, "", existIdentityKeys, rep); err == nil {
  528. usedPhone[canonPhone] = u.ID
  529. rep.IdentitiesPhone++
  530. }
  531. }
  532. }
  533. }
  534. // email(轻归一)
  535. if u.Email != nil && *u.Email != "" {
  536. canonEmail, ok := norm.Email(*u.Email)
  537. if ok {
  538. if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeEmail, canonEmail, "", existIdentityKeys, rep); err == nil {
  539. rep.IdentitiesEmail++
  540. }
  541. }
  542. }
  543. // username(轻归一;与 t_user.username 一致)
  544. if u.Username != "" {
  545. canonUsername := norm.Username(u.Username)
  546. if canonUsername != "" {
  547. if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeUsername, canonUsername, "", existIdentityKeys, rep); err == nil {
  548. rep.IdentitiesUsername++
  549. }
  550. }
  551. }
  552. // wechat(openid + appid,经 t_weixin_user)
  553. if w := r.wechatOf(u.ID); w != nil {
  554. openid, ok := norm.WechatOpenID(w.OpenID)
  555. if ok {
  556. appid := ""
  557. if w.AppID != nil {
  558. appid = *w.AppID
  559. }
  560. if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeWechat, openid, appid, existIdentityKeys, rep); err == nil {
  561. rep.IdentitiesWechat++
  562. }
  563. }
  564. }
  565. }
  566. // wechatOf 取某 legacy user 的微信身份(runner 持有 planner 不方便,直接查 wx 缓存)。
  567. // 注:实现上在 Run 里通过闭包注入更合适;此处用字段缓存。
  568. func (r *Runner) wechatOf(userID int32) *LegacyWeixinUser {
  569. return r.wxCache[userID]
  570. }
  571. // upsertIdentity 幂等写入单条 identity(唯一键 type+identifier+appid 已存在则跳过)。
  572. //
  573. // 性能:幂等判断用 existIdentityKeys 内存 set(事务内一次性批量装载),不再逐行 COUNT。
  574. func (r *Runner) upsertIdentity(ctx context.Context, tx *gorm.DB, canon, typ, identifier, appid string,
  575. existIdentityKeys map[string]struct{}, rep *MigrateReport) error {
  576. key := identityKey(typ, identifier, appid)
  577. if _, ok := existIdentityKeys[key]; ok {
  578. rep.IdentityDuplicates++
  579. return nil
  580. }
  581. it := &identitymodel.Identity{
  582. CanonicalUserID: canon,
  583. Type: typ,
  584. Identifier: identifier,
  585. AppID: appid,
  586. Verified: false,
  587. CreatedAt: r.opts.Now,
  588. }
  589. if err := tx.WithContext(ctx).Create(it).Error; err != nil {
  590. return err
  591. }
  592. existIdentityKeys[key] = struct{}{} // 标记已占用
  593. return nil
  594. }
  595. // writeWechatUnion 写 t_wechat_union(unionid 锚点;幂等:unionid 已存在则跳过)。
  596. //
  597. // 性能:幂等判断用 existUnionids 内存 set(事务内一次性批量装载),不再逐行 COUNT。
  598. func (r *Runner) writeWechatUnion(ctx context.Context, tx *gorm.DB, userID int32, canon string,
  599. existUnionids map[string]struct{}, rep *MigrateReport) {
  600. w := r.wxCache[userID]
  601. if w == nil || w.UnionID == nil || *w.UnionID == "" {
  602. return
  603. }
  604. // 同 unionid 可能挂多个 canonical(跨 appid 同人):已存在该 unionid → 跳过
  605. if _, ok := existUnionids[*w.UnionID]; ok {
  606. return
  607. }
  608. wu := &identitymodel.WechatUnion{
  609. CanonicalUserID: canon,
  610. UnionID: *w.UnionID,
  611. CreatedAt: r.opts.Now,
  612. UpdatedAt: r.opts.Now,
  613. }
  614. if err := tx.WithContext(ctx).Create(wu).Error; err == nil {
  615. existUnionids[*w.UnionID] = struct{}{} // 标记已占用
  616. rep.WechatUnionCreated++
  617. } else {
  618. rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_wechat_union unionid=%s 失败: %v", *w.UnionID, err))
  619. }
  620. }
  621. // writeLegacyMapping 写 t_legacy_mapping(canonical_user_id UNIQUE 幂等;重跑复用 ULID 时天然存在则跳过)。
  622. //
  623. // 三源 user_id(P1-05 §2 拍定):
  624. // - LegacyIUserID = 旧A yuansiwei_i.t_user.id(本地主源,恒有)
  625. // - LegacyTeachingUserID = 阅卷/标记系统 id(t_user_account_association.marking_system_userid,仅种子用户有)
  626. // - LegacyAcademicUserID = 教务 user id(本地恒 NULL,finance_bk 无 user 主表)
  627. func (r *Runner) writeLegacyMapping(ctx context.Context, tx *gorm.DB, u *LegacyUser, canon string,
  628. d MatchDecision, teachingByUserID map[int32]int32, existMappingCanons map[string]struct{}, rep *MigrateReport) error {
  629. if _, ok := existMappingCanons[canon]; ok {
  630. return nil // 已存在(重跑幂等 / 本批已写)
  631. }
  632. legacyID := u.ID
  633. role := mapRoleCode(u.Role)
  634. lm := &identitymodel.LegacyMapping{
  635. CanonicalUserID: canon,
  636. LegacyIUserID: &legacyID, // 旧A yuansiwei_i.t_user.id
  637. InstitutionID: 0, // 平台级(canonical 跨机构;代表机构非身份属性,P1-05 §6 拍定)
  638. CampusID: 0,
  639. Role: &role,
  640. MatchSource: strPtr(d.MatchSource),
  641. MatchConfidence: d.Confidence,
  642. AuditRef: strPtr(fmt.Sprintf("migrate/v1.2/%d", u.ID)),
  643. MergedAt: &r.opts.Now,
  644. CreatedAt: r.opts.Now,
  645. UpdatedAt: r.opts.Now,
  646. }
  647. // 阅卷/标记系统 id(种子用户才有;P1-05 §2:marking_system_userid → legacy_teaching_user_id)
  648. if markingID, ok := teachingByUserID[u.ID]; ok {
  649. lm.LegacyTeachingUserID = &markingID
  650. }
  651. if err := tx.WithContext(ctx).Create(lm).Error; err != nil {
  652. return err
  653. }
  654. existMappingCanons[canon] = struct{}{} // 标记已占用
  655. rep.LegacyMappingsCreated++
  656. return nil
  657. }
  658. // writeMemberships 写 t_membership(幂等:唯一键 canonical+campus+role)。
  659. //
  660. // 性能:幂等判断用 existMemberKeys 内存 set(事务内一次性批量装载),不再逐行 COUNT。
  661. func (r *Runner) writeMemberships(ctx context.Context, tx *gorm.DB, binds []LegacyUserSchoolCampus,
  662. canonByID map[int32]string, instByID map[int32]int64, campusByID map[int32]int64,
  663. users []LegacyUser, existMemberKeys map[string]struct{}, rep *MigrateReport) {
  664. userByID := make(map[int32]*LegacyUser, len(users))
  665. for i := range users {
  666. userByID[users[i].ID] = &users[i]
  667. }
  668. for i := range binds {
  669. b := &binds[i]
  670. u, ok := userByID[b.UserID]
  671. if !ok {
  672. // 绑定用户不在 t_user(孤儿)→ 跳过 + 警告
  673. rep.Warnings = append(rep.Warnings, fmt.Sprintf("t_membership 跳过:legacy 绑定 userid=%d 不在 t_user", b.UserID))
  674. continue
  675. }
  676. canon, ok := canonByID[b.UserID]
  677. if !ok {
  678. continue
  679. }
  680. instID, ok1 := instByID[b.SchoolID]
  681. campusID, ok2 := campusByID[b.CampusID]
  682. if !ok1 || !ok2 {
  683. rep.Warnings = append(rep.Warnings, fmt.Sprintf("t_membership 跳过:institution/campus 映射缺失 schoolid=%d campusid=%d", b.SchoolID, b.CampusID))
  684. continue
  685. }
  686. role := mapRoleCode(u.Role)
  687. // 幂等:唯一键 (canonical, campus, role)
  688. if _, ok := existMemberKeys[memberKey(canon, campusID, role)]; ok {
  689. continue
  690. }
  691. m := &orgmodel.Membership{
  692. CanonicalUserID: canon,
  693. InstitutionID: instID,
  694. CampusID: campusID,
  695. Role: role,
  696. Status: orgmodel.MembershipStatusActive,
  697. JoinedAt: &r.opts.Now,
  698. CreatedAt: r.opts.Now,
  699. }
  700. if err := tx.WithContext(ctx).Create(m).Error; err == nil {
  701. existMemberKeys[memberKey(canon, campusID, role)] = struct{}{} // 标记已占用
  702. rep.MembershipsCreated++
  703. } else {
  704. rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_membership legacy_userid=%d 失败: %v", b.UserID, err))
  705. }
  706. }
  707. }
  708. // simulateCounts dry-run 计数预检(只读,不写):估算各表新建量。
  709. func (r *Runner) simulateCounts(ctx context.Context, rep *MigrateReport, schools []LegacySchool,
  710. campuses []LegacySchoolCampus, users []LegacyUser, canonByID map[int32]string, binds []LegacyUserSchoolCampus) {
  711. existingUsers := r.countTable(ctx, "t_user")
  712. existingInst := r.countTable(ctx, "t_institution")
  713. existingCampus := r.countTable(ctx, "t_campus")
  714. existingMapping := r.countTable(ctx, "t_legacy_mapping")
  715. // 幂等估算:新建 = 目标量 - 已存在量(取下限 0)
  716. if len(users) > int(existingUsers) {
  717. rep.UsersCreated = len(users) - int(existingUsers)
  718. }
  719. if len(schools) > int(existingInst) {
  720. rep.InstitutionsCreated = len(schools) - int(existingInst)
  721. }
  722. if len(campuses) > int(existingCampus) {
  723. rep.CampusesCreated = len(campuses) - int(existingCampus)
  724. }
  725. if len(users) > int(existingMapping) {
  726. rep.LegacyMappingsCreated = len(users) - int(existingMapping)
  727. }
  728. // 身份/成员/锚点按"全量首次写入"估算(重跑时这些由唯一键去重,量趋 0)
  729. for i := range users {
  730. u := &users[i]
  731. _ = canonByID[u.ID]
  732. if u.PhoneNumber != nil && *u.PhoneNumber != "" {
  733. if _, ok := norm.Phone(*u.PhoneNumber); ok {
  734. rep.IdentitiesPhone++
  735. }
  736. }
  737. if u.Email != nil && *u.Email != "" {
  738. if _, ok := norm.Email(*u.Email); ok {
  739. rep.IdentitiesEmail++
  740. }
  741. }
  742. if u.Username != "" {
  743. rep.IdentitiesUsername++
  744. }
  745. }
  746. for i := range users {
  747. if w := r.wxCache[users[i].ID]; w != nil {
  748. rep.IdentitiesWechat++
  749. if w.UnionID != nil && *w.UnionID != "" {
  750. rep.WechatUnionCreated++
  751. }
  752. }
  753. }
  754. rep.MembershipsCreated = len(binds)
  755. }
  756. // countTable 统计表行数(dry-run 幂等估算用)。
  757. func (r *Runner) countTable(ctx context.Context, table string) int64 {
  758. var n int64
  759. _ = r.target.WithContext(ctx).Table(table).Count(&n).Error
  760. return n
  761. }
  762. // ---- 映射函数 ----
  763. // mapUserStatus legacy status int → 目标态 string。
  764. // 1=active,0/2/3=disabled(P0-02 §4 拍板:0/3 都迁 disabled 保留可追溯)。
  765. func mapUserStatus(legacy int32) string {
  766. if legacy == 1 {
  767. return identitymodel.UserStatusActive
  768. }
  769. return identitymodel.UserStatusDisabled
  770. }
  771. // mapOrgStatus 机构/校区 legacy status(tinyint)→ 目标态 string。
  772. func mapOrgStatus(legacy int8) string {
  773. if legacy == 1 {
  774. return orgmodel.StatusActive
  775. }
  776. return orgmodel.StatusDisabled
  777. }
  778. // mapPersonType legacy role 数字码 → 目标态 person_type(P0-02 §3.1)。
  779. func mapPersonType(legacy int32) string {
  780. switch legacy {
  781. case 2:
  782. return identitymodel.PersonTypeStudent
  783. case 4:
  784. return identitymodel.PersonTypeTeacher
  785. case 3:
  786. return identitymodel.PersonTypeParent
  787. default:
  788. return identitymodel.PersonTypeStaff
  789. }
  790. }
  791. // mapRoleCode legacy role 数字码 → 目标态 7 角色字符串码(P0-02 §2.5 拍板映射)。
  792. func mapRoleCode(legacy int32) string {
  793. switch legacy {
  794. case 1:
  795. return orgmodel.RoleSuperAdmin
  796. case 2:
  797. return orgmodel.RoleStudent
  798. case 3:
  799. return orgmodel.RoleParent
  800. case 4:
  801. return orgmodel.RoleTeacher
  802. case 5, 6:
  803. return orgmodel.RoleAcademic // admin/redactor → 教务
  804. case 8:
  805. return orgmodel.RoleFinance
  806. case 7, 9:
  807. return orgmodel.RoleMarket // prototype/marketer → 市场
  808. default:
  809. return orgmodel.RoleAcademic // 未知角色兜底为教务(运营角色)
  810. }
  811. }
  812. // strPtr 字符串指针辅助。
  813. func strPtr(s string) *string { return &s }
  814. // gormMySQLTarget 目标库 GORM 驱动(mysql.Open 返回 Dialector,与 infra/db 同款)。
  815. func gormMySQLTarget(dsn string) gorm.Dialector {
  816. return mysql.Open(dsn)
  817. }