source.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. // Package migrate 数据迁移引擎(P1-04/T5,ADR-003 归一 pipeline 落地)。
  2. //
  3. // 职责:从 legacy 双库(yuansiwei_i 教学 + finance_bk 教务,只读)读取三源用户数据,
  4. // 按 P0-06 归一规则 + P0-07 置信分级执行匹配,幂等 upsert 写入目标态 5 表
  5. // (t_user/t_identity/t_wechat_union/t_membership/t_legacy_mapping)。
  6. //
  7. // 设计铁律:
  8. // - **幂等**:重跑不产生重复行(按 legacy id 顺序处理 + 存在性检查)
  9. // - **先归一后匹配**:归一失败绝不写脏 canonical(phone_invalid → 仅记录 raw)
  10. // - **置信分级**:高置信(种子 + phone 唯一)自动写;中/低/冲突 → 仅出报告
  11. // - **事务边界**:整批在单个 GORM 事务中(dry-run 不写)
  12. // - **ULID 发放**:id.NewCanonicalUserIDAt(regTime) 保证时间有序
  13. // - **legacy 只读**:Source 连接仅执行 SELECT,无任何 INSERT/UPDATE/DELETE
  14. package migrate
  15. import (
  16. "context"
  17. "fmt"
  18. "strings"
  19. "time"
  20. "gorm.io/driver/mysql"
  21. "gorm.io/gorm"
  22. "gorm.io/gorm/logger"
  23. "yuansiwei/unified-backend/internal/infra/tz"
  24. )
  25. // gormSilent 静默 logger(迁移批量读写高频 SQL,不刷日志;SLOW SQL 阈值噪音在此屏蔽)。
  26. var gormSilent = logger.Default.LogMode(logger.Silent)
  27. // gormMigrateCfg 迁移包共用的 GORM 配置(不建物理 FK + 静默日志)。
  28. func gormMigrateCfg() *gorm.Config {
  29. return &gorm.Config{
  30. DisableForeignKeyConstraintWhenMigrating: true,
  31. Logger: gormSilent,
  32. }
  33. }
  34. // isMissingTable 判断 GORM 错误是否为"表不存在"(MySQL 1146)。
  35. // dry-run / 幂等读路径据此容错:目标表未建时按"空表"处理(全量首次迁移)。
  36. func isMissingTable(err error) bool {
  37. if err == nil {
  38. return false
  39. }
  40. msg := err.Error()
  41. return strings.Contains(msg, "1146") || strings.Contains(msg, "doesn't exist")
  42. }
  43. // ---- legacy 数据源结构体(GORM 只读映射,不 AutoMigrate)----
  44. // LegacyUser 教学侧 yuansiwei_i.t_user(Canonical 主表,68107 行)。
  45. type LegacyUser struct {
  46. ID int32 `gorm:"column:id;primaryKey"`
  47. Username string `gorm:"column:username"`
  48. RealName *string `gorm:"column:real_name"`
  49. Password string `gorm:"column:password"`
  50. Salt string `gorm:"column:salt"`
  51. Role int32 `gorm:"column:role"`
  52. Email *string `gorm:"column:email"`
  53. PhoneNumber *string `gorm:"column:phone_number"`
  54. Avatar *string `gorm:"column:avatar"`
  55. RegTime *time.Time `gorm:"column:reg_time"`
  56. Status int32 `gorm:"column:status"`
  57. ParentID int32 `gorm:"column:parent_id"`
  58. GradeID int32 `gorm:"column:grade_id"`
  59. }
  60. // TableName 指定 legacy 表名。
  61. func (LegacyUser) TableName() string { return "t_user" }
  62. // LegacyWeixinUser 教学侧 yuansiwei_i.t_weixin_user(微信身份,3491 行)。
  63. type LegacyWeixinUser struct {
  64. OpenID string `gorm:"column:openid;primaryKey"`
  65. Nickname string `gorm:"column:nickname"`
  66. UserID int32 `gorm:"column:userid"`
  67. UnionID *string `gorm:"column:unionid"`
  68. AppID *string `gorm:"column:appid"`
  69. }
  70. // TableName 指定 legacy 表名。
  71. func (LegacyWeixinUser) TableName() string { return "t_weixin_user" }
  72. // LegacyAccountAssociation 教学侧 yuansiwei_i.t_user_account_association(现映射种子,16077 行)。
  73. type LegacyAccountAssociation struct {
  74. UserID int32 `gorm:"column:userid;primaryKey"`
  75. MarkingSystemUserID *int32 `gorm:"column:marking_system_userid"`
  76. }
  77. // TableName 指定 legacy 表名。
  78. func (LegacyAccountAssociation) TableName() string { return "t_user_account_association" }
  79. // LegacySchool 教学侧 yuansiwei_i.t_school(全国名录,仅取被引用的 23 所)。
  80. type LegacySchool struct {
  81. ID int32 `gorm:"column:id;primaryKey"`
  82. Name string `gorm:"column:name"`
  83. Status int8 `gorm:"column:status"` // tinyint
  84. }
  85. // TableName 指定 legacy 表名。
  86. func (LegacySchool) TableName() string { return "t_school" }
  87. // LegacySchoolCampus 教学侧 yuansiwei_i.t_school_campus(校区,63 行)。
  88. type LegacySchoolCampus struct {
  89. ID int32 `gorm:"column:id;primaryKey"`
  90. Name string `gorm:"column:name"`
  91. SchoolID int32 `gorm:"column:school_id"`
  92. Status int8 `gorm:"column:status"` // tinyint
  93. Code *string `gorm:"column:code"`
  94. }
  95. // TableName 指定 legacy 表名。
  96. func (LegacySchoolCampus) TableName() string { return "t_school_campus" }
  97. // LegacyUserSchoolCampus 教务侧 finance_bk.t_user_school_campus(用户-机构-校区绑定,26308 行)。
  98. type LegacyUserSchoolCampus struct {
  99. ID int32 `gorm:"column:id;primaryKey"`
  100. UserID int32 `gorm:"column:userid"`
  101. SchoolID int32 `gorm:"column:schoolid"`
  102. CampusID int32 `gorm:"column:campusid"`
  103. }
  104. // TableName 指定 legacy 表名(finance_bk 库)。
  105. func (LegacyUserSchoolCampus) TableName() string { return "t_user_school_campus" }
  106. // ---- 数据源接口 ----
  107. // Source 封装 legacy 双库的只读 GORM 连接(教学 + 教务)。
  108. //
  109. // 两个 *gorm.DB 分别指向 yuansiwei_i 和 finance_bk(同一 MySQL 实例,不同 database);
  110. // 若 DSN 未指定 database,用 UseDB 切换。
  111. type Source struct {
  112. teaching *gorm.DB // yuansiwei_i(t_user/t_weixin_user/t_user_account_association/t_school/t_school_campus)
  113. finance *gorm.DB // finance_bk(t_user_school_campus)
  114. }
  115. // NewSource 创建 legacy 数据源(两个 DSN 指向同一 MySQL 实例的不同库)。
  116. //
  117. // 入参:
  118. // - teachingDsn string:教学库 DSN(如 "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai")。
  119. // - financeDsn string:教务库 DSN(如 "dev:pass@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai")。
  120. //
  121. // 出参:
  122. // - *Source:就绪的只读数据源。
  123. // - error:连接失败时返回。
  124. func NewSource(teachingDsn, financeDsn string) (*Source, error) {
  125. var missing []string
  126. if teachingDsn == "" {
  127. missing = append(missing, "teachingDsn")
  128. }
  129. if financeDsn == "" {
  130. missing = append(missing, "financeDsn")
  131. }
  132. if len(missing) > 0 {
  133. return nil, fmt.Errorf("migrate: legacy DSN 不能为空: %v", missing)
  134. }
  135. // 时区收口(与 API 进程同一口径):历史 datetime 一律按 Asia/Shanghai 解释,
  136. // 避免"迁移按 UTC 读、接口按 +08:00 写"造成同一条记录差 8 小时。
  137. teachingDsn = tz.NormalizeDSN(teachingDsn)
  138. financeDsn = tz.NormalizeDSN(financeDsn)
  139. teaching, err := gorm.Open(mysql.Open(teachingDsn), gormMigrateCfg())
  140. if err != nil {
  141. return nil, fmt.Errorf("migrate: 教学库连接失败: %w", err)
  142. }
  143. if sqlDB, err := teaching.DB(); err == nil {
  144. if err := sqlDB.Ping(); err != nil {
  145. _ = sqlDB.Close()
  146. return nil, fmt.Errorf("migrate: 教学库 Ping 失败: %w", err)
  147. }
  148. }
  149. finance, err := gorm.Open(mysql.Open(financeDsn), gormMigrateCfg())
  150. if err != nil {
  151. return nil, fmt.Errorf("migrate: 教务库连接失败: %w", err)
  152. }
  153. if sqlDB, err := finance.DB(); err == nil {
  154. if err := sqlDB.Ping(); err != nil {
  155. _ = sqlDB.Close()
  156. return nil, fmt.Errorf("migrate: 教务库 Ping 失败: %w", err)
  157. }
  158. }
  159. return &Source{teaching: teaching, finance: finance}, nil
  160. }
  161. // Close 关闭两个连接池。
  162. func (s *Source) Close() error {
  163. var firstErr error
  164. if s.teaching != nil {
  165. if sqlDB, err := s.teaching.DB(); err == nil {
  166. _ = sqlDB.Close()
  167. }
  168. }
  169. if s.finance != nil {
  170. if sqlDB, err := s.finance.DB(); err == nil {
  171. _ = sqlDB.Close()
  172. } else {
  173. firstErr = err
  174. }
  175. }
  176. return firstErr
  177. }
  178. // ---- 读取方法(全部 SELECT,无写操作)----
  179. // LoadAllUsers 读取 t_user 全量(68107 行,按 id 排序保证幂等处理顺序)。
  180. func (s *Source) LoadAllUsers(ctx context.Context) ([]LegacyUser, error) {
  181. var users []LegacyUser
  182. err := s.teaching.WithContext(ctx).Model(&LegacyUser{}).Order("id").Find(&users).Error
  183. return users, err
  184. }
  185. // LoadAllWeixinUsers 读取 t_weixin_user 全量(3491 行)。
  186. func (s *Source) LoadAllWeixinUsers(ctx context.Context) ([]LegacyWeixinUser, error) {
  187. var rows []LegacyWeixinUser
  188. err := s.teaching.WithContext(ctx).Model(&LegacyWeixinUser{}).Order("openid").Find(&rows).Error
  189. return rows, err
  190. }
  191. // LoadAllAssociations 读取 t_user_account_association 全量(16077 行,现映射种子)。
  192. func (s *Source) LoadAllAssociations(ctx context.Context) ([]LegacyAccountAssociation, error) {
  193. var rows []LegacyAccountAssociation
  194. err := s.teaching.WithContext(ctx).Model(&LegacyAccountAssociation{}).Order("userid").Find(&rows).Error
  195. return rows, err
  196. }
  197. // LoadSchools 读取 t_school 中被 t_user_school_campus 引用的学校(23 所)。
  198. func (s *Source) LoadSchools(ctx context.Context, schoolIDs []int32) ([]LegacySchool, error) {
  199. if len(schoolIDs) == 0 {
  200. return nil, nil
  201. }
  202. var rows []LegacySchool
  203. err := s.teaching.WithContext(ctx).Model(&LegacySchool{}).Where("id IN ?", schoolIDs).Find(&rows).Error
  204. return rows, err
  205. }
  206. // LoadCampuses 读取 t_school_campus 全量(63 行)。
  207. func (s *Source) LoadCampuses(ctx context.Context) ([]LegacySchoolCampus, error) {
  208. var rows []LegacySchoolCampus
  209. err := s.teaching.WithContext(ctx).Model(&LegacySchoolCampus{}).Order("id").Find(&rows).Error
  210. return rows, err
  211. }
  212. // LoadUserSchoolCampus 读取 finance_bk.t_user_school_campus 全量(26308 行)。
  213. func (s *Source) LoadUserSchoolCampus(ctx context.Context) ([]LegacyUserSchoolCampus, error) {
  214. var rows []LegacyUserSchoolCampus
  215. err := s.finance.WithContext(ctx).Model(&LegacyUserSchoolCampus{}).Order("id").Find(&rows).Error
  216. return rows, err
  217. }
  218. // LoadDistinctSchoolIDs 从 t_user_school_campus 提取去重 schoolid(23 所)。
  219. func (s *Source) LoadDistinctSchoolIDs(ctx context.Context) ([]int32, error) {
  220. var ids []int32
  221. err := s.finance.WithContext(ctx).Model(&LegacyUserSchoolCampus{}).
  222. Distinct().Pluck("schoolid", &ids).Error
  223. return ids, err
  224. }