runner.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  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. usedUsername := map[string]int32{} // 归一 username → 首个占用者
  224. usedPhone := map[string]int32{} // 归一 phone → 首个占用者
  225. existUsername := r.existingUsernames(ctx, tx)
  226. existPhone := r.existingIdentities(ctx, tx, identitymodel.IdentityTypePhone, "")
  227. for k, v := range existUsername {
  228. usedUsername[k] = v
  229. }
  230. for k, v := range existPhone {
  231. usedPhone[k] = v
  232. }
  233. for i := range users {
  234. u := &users[i]
  235. canon := canonByID[u.ID]
  236. d := decisionByID[u.ID]
  237. if err := r.writeUser(ctx, tx, u, canon, d, rep, usedUsername, existUsername); err != nil {
  238. rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_user legacy_id=%d 失败: %v", u.ID, err))
  239. continue
  240. }
  241. r.writeIdentities(ctx, tx, u, canon, d, rep, usedPhone, existPhone)
  242. r.writeWechatUnion(ctx, tx, u.ID, canon, planner, rep)
  243. if err := r.writeLegacyMapping(ctx, tx, u, canon, d, teachingByUserID, rep); err != nil {
  244. rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_legacy_mapping legacy_id=%d 失败: %v", u.ID, err))
  245. }
  246. }
  247. // ④ 成员关系(依赖 canonical + 机构/校区)
  248. r.writeMemberships(ctx, tx, binds, canonByID, instByID, campusByID, users, rep)
  249. return nil
  250. })
  251. if err != nil {
  252. return rep, fmt.Errorf("migrate: 事务提交失败(已回滚): %w", err)
  253. }
  254. return rep, nil
  255. }
  256. // normPhoneDisplay 展示用(截断,防超长)。
  257. func normPhoneDisplay(raw string) string {
  258. c, ok := norm.Phone(raw)
  259. if ok {
  260. return c
  261. }
  262. return raw
  263. }
  264. // appendUniqueConflict 去重追加冲突项(同 LegacyID 只记一次)。
  265. func appendUniqueConflict(items []ConflictItem, item ConflictItem) []ConflictItem {
  266. for _, e := range items {
  267. if e.LegacyID == item.LegacyID {
  268. return items
  269. }
  270. }
  271. return append(items, item)
  272. }
  273. // ---- 幂等辅助(读目标库现有状态)----
  274. // loadExistingMappings 读 t_legacy_mapping,返回 legacy_i_user_id → canonical_user_id。
  275. //
  276. // dry-run 容错:目标表未建(MySQL 1146 表不存在)时按"空表"处理(全量首次迁移,ULID 全发放)。
  277. // 这保证 dry-run 纯只读、不依赖目标库已建表。
  278. func (r *Runner) loadExistingMappings(ctx context.Context) (map[int32]string, error) {
  279. m := make(map[int32]string)
  280. var rows []identitymodel.LegacyMapping
  281. if err := r.target.WithContext(ctx).Model(&identitymodel.LegacyMapping{}).Find(&rows).Error; err != nil {
  282. if isMissingTable(err) {
  283. return m, nil // 表不存在(dry-run 首次)→ 空映射
  284. }
  285. return nil, err
  286. }
  287. for i := range rows {
  288. if rows[i].LegacyIUserID != nil && *rows[i].LegacyIUserID > 0 {
  289. m[*rows[i].LegacyIUserID] = rows[i].CanonicalUserID
  290. }
  291. }
  292. return m, nil
  293. }
  294. // existingUsernames 读 t_user 现有 username(归一 username → canonical_user_id 占位,用于冲突检测)。
  295. func (r *Runner) existingUsernames(ctx context.Context, tx *gorm.DB) map[string]int32 {
  296. m := make(map[string]int32)
  297. var users []identitymodel.User
  298. if err := tx.WithContext(ctx).Model(&identitymodel.User{}).Find(&users).Error; err != nil {
  299. return m
  300. }
  301. for i := range users {
  302. if users[i].Username != nil && *users[i].Username != "" {
  303. canon := norm.Username(*users[i].Username)
  304. if _, ok := m[canon]; !ok {
  305. m[canon] = 0 // 占位(无 legacy id,仅标记"已占用")
  306. }
  307. }
  308. }
  309. return m
  310. }
  311. // existingIdentities 读 t_identity 现有指定 type 的 identifier(归一值 → 占位)。
  312. func (r *Runner) existingIdentities(ctx context.Context, tx *gorm.DB, typ, appid string) map[string]int32 {
  313. m := make(map[string]int32)
  314. var idents []identitymodel.Identity
  315. q := tx.WithContext(ctx).Model(&identitymodel.Identity{}).Where("type = ?", typ)
  316. if err := q.Find(&idents).Error; err != nil {
  317. return m
  318. }
  319. for i := range idents {
  320. m[idents[i].Identifier] = 0
  321. }
  322. return m
  323. }
  324. // ---- 写入方法(每个方法在 dry-run 外执行;幂等:存在则跳过)----
  325. // writeInstitutions 写 t_institution(幂等:按 name 查现有复用)。
  326. func (r *Runner) writeInstitutions(ctx context.Context, tx *gorm.DB, schools []LegacySchool, rep *MigrateReport) (map[int32]int64, error) {
  327. instByID := make(map[int32]int64, len(schools))
  328. for i := range schools {
  329. s := &schools[i]
  330. var existing []orgmodel.Institution
  331. tx.WithContext(ctx).Model(&orgmodel.Institution{}).
  332. Where("institution_name = ?", s.Name).Limit(1).Find(&existing)
  333. if len(existing) > 0 {
  334. instByID[s.ID] = existing[0].InstitutionID
  335. continue
  336. }
  337. inst := &orgmodel.Institution{
  338. InstitutionName: s.Name,
  339. Status: mapOrgStatus(s.Status),
  340. CreatedAt: r.opts.Now,
  341. UpdatedAt: r.opts.Now,
  342. }
  343. if err := tx.WithContext(ctx).Create(inst).Error; err != nil {
  344. return instByID, err
  345. }
  346. instByID[s.ID] = inst.InstitutionID
  347. rep.InstitutionsCreated++
  348. }
  349. return instByID, nil
  350. }
  351. // writeCampuses 写 t_campus(幂等:按 campus_name + institution 复用)。
  352. func (r *Runner) writeCampuses(ctx context.Context, tx *gorm.DB, campuses []LegacySchoolCampus,
  353. instByID map[int32]int64, rep *MigrateReport) (map[int32]int64, error) {
  354. campusByID := make(map[int32]int64, len(campuses))
  355. for i := range campuses {
  356. c := &campuses[i]
  357. // 孤儿校区(school_id 不在被引用机构集):institution_id 置 0(机构级/平台级)
  358. instID, ok := instByID[c.SchoolID]
  359. if !ok {
  360. instID = 0
  361. }
  362. var existing []orgmodel.Campus
  363. tx.WithContext(ctx).Model(&orgmodel.Campus{}).
  364. Where("campus_name = ? AND institution_id = ?", c.Name, instID).Limit(1).Find(&existing)
  365. if len(existing) > 0 {
  366. campusByID[c.ID] = existing[0].CampusID
  367. continue
  368. }
  369. campus := &orgmodel.Campus{
  370. InstitutionID: instID,
  371. CampusName: c.Name,
  372. CampusCode: c.Code,
  373. Status: mapOrgStatus(c.Status),
  374. CreatedAt: r.opts.Now,
  375. UpdatedAt: r.opts.Now,
  376. }
  377. if err := tx.WithContext(ctx).Create(campus).Error; err != nil {
  378. return campusByID, err
  379. }
  380. campusByID[c.ID] = campus.CampusID
  381. rep.CampusesCreated++
  382. }
  383. return campusByID, nil
  384. }
  385. // writeUser 写 t_user(幂等:canonical_user_id 存在则跳过;username 冲突置 NULL)。
  386. func (r *Runner) writeUser(ctx context.Context, tx *gorm.DB, u *LegacyUser, canon string,
  387. d MatchDecision, rep *MigrateReport, usedUsername map[string]int32, existUsername map[string]int32) error {
  388. var n int64
  389. tx.WithContext(ctx).Model(&identitymodel.User{}).Where("canonical_user_id = ?", canon).Count(&n)
  390. if n > 0 {
  391. rep.UsersSkipped++
  392. return nil
  393. }
  394. // username:归一后若已占用(存量或本批首个外)→ 置 NULL + 记冲突
  395. var username *string
  396. if u.Username != "" {
  397. canonUsername := norm.Username(u.Username)
  398. if _, taken := usedUsername[canonUsername]; !taken {
  399. s := u.Username
  400. username = &s
  401. usedUsername[canonUsername] = u.ID
  402. } else {
  403. rep.UsernameConflicts = append(rep.UsernameConflicts, ConflictItem{
  404. LegacyID: u.ID,
  405. Detail: fmt.Sprintf("username=%q 归一后与已有用户重复,置 NULL", u.Username),
  406. })
  407. }
  408. }
  409. _ = existUsername // 存量 username 冲突已在 usedUsername 合并
  410. displayName := u.RealName
  411. user := &identitymodel.User{
  412. CanonicalUserID: canon,
  413. Username: username,
  414. DisplayName: displayName,
  415. AvatarURL: u.Avatar,
  416. PersonType: mapPersonType(u.Role),
  417. PasswordHash: &u.Password,
  418. PasswordSalt: &u.Salt,
  419. HashVersion: strPtr(identitymodel.HashVersionShaSalt), // I 库=教学侧,sha_salt(ADR-016 惰性升级)
  420. Status: mapUserStatus(u.Status),
  421. CreatedAt: r.opts.Now,
  422. UpdatedAt: r.opts.Now,
  423. }
  424. if u.RegTime != nil {
  425. user.CreatedAt = *u.RegTime
  426. }
  427. if err := tx.WithContext(ctx).Create(user).Error; err != nil {
  428. return err
  429. }
  430. rep.UsersCreated++
  431. if u.Status == 0 || u.Status == 3 {
  432. rep.UsersDisabled++
  433. }
  434. return nil
  435. }
  436. // writeIdentities 写 t_identity(phone/email/username/wechat;幂等 + 全局唯一键冲突处理)。
  437. func (r *Runner) writeIdentities(ctx context.Context, tx *gorm.DB, u *LegacyUser, canon string,
  438. d MatchDecision, rep *MigrateReport, usedPhone map[string]int32, existPhone map[string]int32) {
  439. // phone(强归一;撞号→本批首个占用,其余跳过+冲突已记)
  440. if u.PhoneNumber != nil && *u.PhoneNumber != "" {
  441. canonPhone, ok := norm.Phone(*u.PhoneNumber)
  442. if ok && !d.PhoneConflict {
  443. if _, taken := usedPhone[canonPhone]; !taken {
  444. if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypePhone, canonPhone, "", rep); err == nil {
  445. usedPhone[canonPhone] = u.ID
  446. rep.IdentitiesPhone++
  447. }
  448. }
  449. }
  450. }
  451. // email(轻归一)
  452. if u.Email != nil && *u.Email != "" {
  453. canonEmail, ok := norm.Email(*u.Email)
  454. if ok {
  455. if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeEmail, canonEmail, "", rep); err == nil {
  456. rep.IdentitiesEmail++
  457. }
  458. }
  459. }
  460. // username(轻归一;与 t_user.username 一致)
  461. if u.Username != "" {
  462. canonUsername := norm.Username(u.Username)
  463. if canonUsername != "" {
  464. if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeUsername, canonUsername, "", rep); err == nil {
  465. rep.IdentitiesUsername++
  466. }
  467. }
  468. }
  469. // wechat(openid + appid,经 t_weixin_user)
  470. if w := r.wechatOf(u.ID); w != nil {
  471. openid, ok := norm.WechatOpenID(w.OpenID)
  472. if ok {
  473. appid := ""
  474. if w.AppID != nil {
  475. appid = *w.AppID
  476. }
  477. if err := r.upsertIdentity(ctx, tx, canon, identitymodel.IdentityTypeWechat, openid, appid, rep); err == nil {
  478. rep.IdentitiesWechat++
  479. }
  480. }
  481. }
  482. _ = existPhone
  483. }
  484. // wechatOf 取某 legacy user 的微信身份(runner 持有 planner 不方便,直接查 wx 缓存)。
  485. // 注:实现上在 Run 里通过闭包注入更合适;此处用字段缓存。
  486. func (r *Runner) wechatOf(userID int32) *LegacyWeixinUser {
  487. return r.wxCache[userID]
  488. }
  489. // upsertIdentity 幂等写入单条 identity(唯一键 type+identifier+appid 已存在则跳过)。
  490. func (r *Runner) upsertIdentity(ctx context.Context, tx *gorm.DB, canon, typ, identifier, appid string, rep *MigrateReport) error {
  491. var n int64
  492. tx.WithContext(ctx).Model(&identitymodel.Identity{}).
  493. Where("type = ? AND identifier = ? AND appid = ?", typ, identifier, appid).Count(&n)
  494. if n > 0 {
  495. rep.IdentityDuplicates++
  496. return nil
  497. }
  498. it := &identitymodel.Identity{
  499. CanonicalUserID: canon,
  500. Type: typ,
  501. Identifier: identifier,
  502. AppID: appid,
  503. Verified: false,
  504. CreatedAt: r.opts.Now,
  505. }
  506. return tx.WithContext(ctx).Create(it).Error
  507. }
  508. // writeWechatUnion 写 t_wechat_union(unionid 锚点;幂等:canonical/unionid 已存在则跳过)。
  509. func (r *Runner) writeWechatUnion(ctx context.Context, tx *gorm.DB, userID int32, canon string,
  510. planner *Planner, rep *MigrateReport) {
  511. w := r.wxCache[userID]
  512. if w == nil || w.UnionID == nil || *w.UnionID == "" {
  513. return
  514. }
  515. // 同 unionid 可能挂多个 canonical(跨 appid 同人):已存在任一 canonical 的该 unionid → 跳过
  516. var n int64
  517. tx.WithContext(ctx).Model(&identitymodel.WechatUnion{}).Where("unionid = ?", *w.UnionID).Count(&n)
  518. if n > 0 {
  519. return
  520. }
  521. wu := &identitymodel.WechatUnion{
  522. CanonicalUserID: canon,
  523. UnionID: *w.UnionID,
  524. CreatedAt: r.opts.Now,
  525. UpdatedAt: r.opts.Now,
  526. }
  527. if err := tx.WithContext(ctx).Create(wu).Error; err == nil {
  528. rep.WechatUnionCreated++
  529. } else {
  530. rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_wechat_union unionid=%s 失败: %v", *w.UnionID, err))
  531. }
  532. }
  533. // writeLegacyMapping 写 t_legacy_mapping(canonical_user_id UNIQUE 幂等;重跑复用 ULID 时天然存在则跳过)。
  534. //
  535. // 三源 user_id(P1-05 §2 拍定):
  536. // - LegacyIUserID = 旧A yuansiwei_i.t_user.id(本地主源,恒有)
  537. // - LegacyTeachingUserID = 阅卷/标记系统 id(t_user_account_association.marking_system_userid,仅种子用户有)
  538. // - LegacyAcademicUserID = 教务 user id(本地恒 NULL,finance_bk 无 user 主表)
  539. func (r *Runner) writeLegacyMapping(ctx context.Context, tx *gorm.DB, u *LegacyUser, canon string,
  540. d MatchDecision, teachingByUserID map[int32]int32, rep *MigrateReport) error {
  541. var n int64
  542. tx.WithContext(ctx).Model(&identitymodel.LegacyMapping{}).Where("canonical_user_id = ?", canon).Count(&n)
  543. if n > 0 {
  544. return nil // 已存在(重跑幂等)
  545. }
  546. legacyID := u.ID
  547. role := mapRoleCode(u.Role)
  548. lm := &identitymodel.LegacyMapping{
  549. CanonicalUserID: canon,
  550. LegacyIUserID: &legacyID, // 旧A yuansiwei_i.t_user.id
  551. InstitutionID: 0, // 平台级(canonical 跨机构;代表机构非身份属性,P1-05 §6 拍定)
  552. CampusID: 0,
  553. Role: &role,
  554. MatchSource: strPtr(d.MatchSource),
  555. MatchConfidence: d.Confidence,
  556. AuditRef: strPtr(fmt.Sprintf("migrate/v1.2/%d", u.ID)),
  557. MergedAt: &r.opts.Now,
  558. CreatedAt: r.opts.Now,
  559. UpdatedAt: r.opts.Now,
  560. }
  561. // 阅卷/标记系统 id(种子用户才有;P1-05 §2:marking_system_userid → legacy_teaching_user_id)
  562. if markingID, ok := teachingByUserID[u.ID]; ok {
  563. lm.LegacyTeachingUserID = &markingID
  564. }
  565. if err := tx.WithContext(ctx).Create(lm).Error; err != nil {
  566. return err
  567. }
  568. rep.LegacyMappingsCreated++
  569. return nil
  570. }
  571. // writeMemberships 写 t_membership(幂等:唯一键 canonical+campus+role)。
  572. func (r *Runner) writeMemberships(ctx context.Context, tx *gorm.DB, binds []LegacyUserSchoolCampus,
  573. canonByID map[int32]string, instByID map[int32]int64, campusByID map[int32]int64,
  574. users []LegacyUser, rep *MigrateReport) {
  575. userByID := make(map[int32]*LegacyUser, len(users))
  576. for i := range users {
  577. userByID[users[i].ID] = &users[i]
  578. }
  579. for i := range binds {
  580. b := &binds[i]
  581. u, ok := userByID[b.UserID]
  582. if !ok {
  583. // 绑定用户不在 t_user(孤儿)→ 跳过 + 警告
  584. rep.Warnings = append(rep.Warnings, fmt.Sprintf("t_membership 跳过:legacy 绑定 userid=%d 不在 t_user", b.UserID))
  585. continue
  586. }
  587. canon, ok := canonByID[b.UserID]
  588. if !ok {
  589. continue
  590. }
  591. instID, ok1 := instByID[b.SchoolID]
  592. campusID, ok2 := campusByID[b.CampusID]
  593. if !ok1 || !ok2 {
  594. rep.Warnings = append(rep.Warnings, fmt.Sprintf("t_membership 跳过:institution/campus 映射缺失 schoolid=%d campusid=%d", b.SchoolID, b.CampusID))
  595. continue
  596. }
  597. role := mapRoleCode(u.Role)
  598. // 幂等:唯一键 (canonical, campus, role)
  599. var n int64
  600. tx.WithContext(ctx).Model(&orgmodel.Membership{}).
  601. Where("canonical_user_id = ? AND campus_id = ? AND role = ?", canon, campusID, role).Count(&n)
  602. if n > 0 {
  603. continue
  604. }
  605. m := &orgmodel.Membership{
  606. CanonicalUserID: canon,
  607. InstitutionID: instID,
  608. CampusID: campusID,
  609. Role: role,
  610. Status: orgmodel.MembershipStatusActive,
  611. JoinedAt: &r.opts.Now,
  612. CreatedAt: r.opts.Now,
  613. }
  614. if err := tx.WithContext(ctx).Create(m).Error; err == nil {
  615. rep.MembershipsCreated++
  616. } else {
  617. rep.Warnings = append(rep.Warnings, fmt.Sprintf("写 t_membership legacy_userid=%d 失败: %v", b.UserID, err))
  618. }
  619. }
  620. }
  621. // simulateCounts dry-run 计数预检(只读,不写):估算各表新建量。
  622. func (r *Runner) simulateCounts(ctx context.Context, rep *MigrateReport, schools []LegacySchool,
  623. campuses []LegacySchoolCampus, users []LegacyUser, canonByID map[int32]string, binds []LegacyUserSchoolCampus) {
  624. existingUsers := r.countTable(ctx, "t_user")
  625. existingInst := r.countTable(ctx, "t_institution")
  626. existingCampus := r.countTable(ctx, "t_campus")
  627. existingMapping := r.countTable(ctx, "t_legacy_mapping")
  628. // 幂等估算:新建 = 目标量 - 已存在量(取下限 0)
  629. if len(users) > int(existingUsers) {
  630. rep.UsersCreated = len(users) - int(existingUsers)
  631. }
  632. if len(schools) > int(existingInst) {
  633. rep.InstitutionsCreated = len(schools) - int(existingInst)
  634. }
  635. if len(campuses) > int(existingCampus) {
  636. rep.CampusesCreated = len(campuses) - int(existingCampus)
  637. }
  638. if len(users) > int(existingMapping) {
  639. rep.LegacyMappingsCreated = len(users) - int(existingMapping)
  640. }
  641. // 身份/成员/锚点按"全量首次写入"估算(重跑时这些由唯一键去重,量趋 0)
  642. for i := range users {
  643. u := &users[i]
  644. _ = canonByID[u.ID]
  645. if u.PhoneNumber != nil && *u.PhoneNumber != "" {
  646. if _, ok := norm.Phone(*u.PhoneNumber); ok {
  647. rep.IdentitiesPhone++
  648. }
  649. }
  650. if u.Email != nil && *u.Email != "" {
  651. if _, ok := norm.Email(*u.Email); ok {
  652. rep.IdentitiesEmail++
  653. }
  654. }
  655. if u.Username != "" {
  656. rep.IdentitiesUsername++
  657. }
  658. }
  659. for i := range users {
  660. if w := r.wxCache[users[i].ID]; w != nil {
  661. rep.IdentitiesWechat++
  662. if w.UnionID != nil && *w.UnionID != "" {
  663. rep.WechatUnionCreated++
  664. }
  665. }
  666. }
  667. rep.MembershipsCreated = len(binds)
  668. }
  669. // countTable 统计表行数(dry-run 幂等估算用)。
  670. func (r *Runner) countTable(ctx context.Context, table string) int64 {
  671. var n int64
  672. _ = r.target.WithContext(ctx).Table(table).Count(&n).Error
  673. return n
  674. }
  675. // ---- 映射函数 ----
  676. // mapUserStatus legacy status int → 目标态 string。
  677. // 1=active,0/2/3=disabled(P0-02 §4 拍板:0/3 都迁 disabled 保留可追溯)。
  678. func mapUserStatus(legacy int32) string {
  679. if legacy == 1 {
  680. return identitymodel.UserStatusActive
  681. }
  682. return identitymodel.UserStatusDisabled
  683. }
  684. // mapOrgStatus 机构/校区 legacy status(tinyint)→ 目标态 string。
  685. func mapOrgStatus(legacy int8) string {
  686. if legacy == 1 {
  687. return orgmodel.StatusActive
  688. }
  689. return orgmodel.StatusDisabled
  690. }
  691. // mapPersonType legacy role 数字码 → 目标态 person_type(P0-02 §3.1)。
  692. func mapPersonType(legacy int32) string {
  693. switch legacy {
  694. case 2:
  695. return identitymodel.PersonTypeStudent
  696. case 4:
  697. return identitymodel.PersonTypeTeacher
  698. case 3:
  699. return identitymodel.PersonTypeParent
  700. default:
  701. return identitymodel.PersonTypeStaff
  702. }
  703. }
  704. // mapRoleCode legacy role 数字码 → 目标态 7 角色字符串码(P0-02 §2.5 拍板映射)。
  705. func mapRoleCode(legacy int32) string {
  706. switch legacy {
  707. case 1:
  708. return orgmodel.RoleSuperAdmin
  709. case 2:
  710. return orgmodel.RoleStudent
  711. case 3:
  712. return orgmodel.RoleParent
  713. case 4:
  714. return orgmodel.RoleTeacher
  715. case 5, 6:
  716. return orgmodel.RoleAcademic // admin/redactor → 教务
  717. case 8:
  718. return orgmodel.RoleFinance
  719. case 7, 9:
  720. return orgmodel.RoleMarket // prototype/marketer → 市场
  721. default:
  722. return orgmodel.RoleAcademic // 未知角色兜底为教务(运营角色)
  723. }
  724. }
  725. // strPtr 字符串指针辅助。
  726. func strPtr(s string) *string { return &s }
  727. // gormMySQLTarget 目标库 GORM 驱动(mysql.Open 返回 Dialector,与 infra/db 同款)。
  728. func gormMySQLTarget(dsn string) gorm.Dialector {
  729. return mysql.Open(dsn)
  730. }