db.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. // Package db 数据库连接池装配 + AutoMigrate 收口(架构规格 §3.5/§3.6,GORM + Repository 分层)。
  2. //
  3. // 职责边界(依赖方向铁律:infra 不依赖任何业务域):
  4. // - 本包**只提供**"建连接池 + 执行 AutoMigrate"两个通用能力,**不**内置任何业务 model;
  5. // 具体要迁哪些表由**调用方**(cmd/api 装配层 / 集成测试)传入 model 列表——
  6. // 这样 infra 保持零业务域依赖(若在本包 import identity/model、org/model 会违反铁律)。
  7. // - 业务域 model 的"全表清单"由各域 model 包提供(identity/model.AllTables / org/model.AllTables /
  8. // academic/model.AllTables),装配层合并后传入 Migrate。
  9. // 注:academic 域 27 表由 AC-14 在 cmd/api/main.go 装配处接入(本包不感知业务域,保持零依赖);
  10. // 接入时的守卫语义不变:仅 `UNIFIED_AUTO_MIGRATE=1` 才执行 AutoMigrate。
  11. //
  12. // 与 DDL 的关系(unified/02-data-model/core-model-ddl.sql):
  13. // - 生产环境割接以 **DDL 文件为准**(PolarDB 建表,information_schema 核对);
  14. // - AutoMigrate 用于 **dev 环境快速建表 + e2e 测试隔离建表**(幂等:表存在则对齐,不存在则建);
  15. // - 两者必须逐列一致(T2 验收:GORM model tag 与 DDL 列名/类型/可空性对齐,单测对账)。
  16. //
  17. // 典型使用(cmd/api/main.go,T4 接线后):
  18. //
  19. // db, err := db.NewDB(cfg.DB) // 建连接池 + ping
  20. // all := append(append(identitymodel.AllTables(), orgmodel.AllTables()...), task.Task{})
  21. // if err := db.Migrate(db, all...); err != nil { log.Fatalf("migrate: %v", err) }
  22. // defer db.Close(db)
  23. //
  24. // 双实例(ADR-015):两个 Go 进程各调一次 NewDB(同一 DSN 连同一库),instanceId 只影响
  25. // 日志/临时目录,不影响连接池——DB 连接池天然共享同一个 PolarDB,无需"双 DSN"。
  26. package db
  27. import (
  28. "fmt"
  29. "time"
  30. "gorm.io/driver/mysql"
  31. "gorm.io/gorm"
  32. "gorm.io/gorm/logger"
  33. "yuansiwei/unified-backend/internal/infra/config"
  34. "yuansiwei/unified-backend/internal/infra/tz"
  35. )
  36. // NewDB 建立 MySQL 连接池并验证连通性(fail-fast:连不上库不允许启动,
  37. // 避免运行到首个查询才暴露"DSN 配错/库没起")。
  38. //
  39. // 入参:
  40. // - cfg config.DBCfg:PolarDB/MySQL 配置(DSN + 连接池参数,见 config.DBCfg)。
  41. //
  42. // 出参:
  43. // - *gorm.DB:就绪的 GORM 句柄(连接池已按 cfg 调参、已 ping 成功)。
  44. // - error:以下场景返回错误:
  45. // - DSN 为空(fail-fast,配置缺失);
  46. // - gorm.Open 失败(驱动初始化/连接失败);
  47. // - Ping 失败(网络不通/账密错/库不存在)。
  48. //
  49. // 连接池参数(§3.6 数据访问纪律):
  50. // - MaxOpenConns:限制并发连接,防双实例合计打满 PolarDB max_connections;
  51. // - MaxIdleConns:空闲连接复用,降低握手开销;
  52. // - ConnMaxLifetimeSec:连接最长存活,防连接池漂移(DB 侧 wait_timeout 与客户端对齐)。
  53. //
  54. // 外键约束:gorm.Config 显式 DisableForeignKeyConstraintWhenMigrating=true——
  55. // 本项目"外键不建物理 FK,应用层 Repository 强制作用域保证"(P0-11 收敛规则 7 / DDL 注释),
  56. // AutoMigrate 不得自动补 FK 约束(与 DDL 的 FOREIGN_KEY_CHECKS=0 语义一致)。
  57. func NewDB(cfg config.DBCfg) (*gorm.DB, error) {
  58. if cfg.DSN == "" {
  59. return nil, fmt.Errorf("db: DSN 为空(须经 config/dev.json 或环境变量 CONFIG_DB__DSN 提供)")
  60. }
  61. // 时区/解析收口(**唯一允许写 loc 的地方**):无论配置或环境变量给的是什么,
  62. // 一律归一为 parseTime=true + loc=Asia%2FShanghai,与进程时区(tz.Apply)保持一致。
  63. // 反面:`loc=Local` 会随机器时区漂移,开发机自测绿、UTC 容器整体错 8 小时。
  64. dsn := tz.NormalizeDSN(cfg.DSN)
  65. gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
  66. // 与 DDL"不建物理外键"对齐:迁移时不自动生成 FK 约束
  67. DisableForeignKeyConstraintWhenMigrating: true,
  68. // 生产/双实例默认静默 SQL 日志(避免高频只读接口刷日志);联调期可换 logger.Warn
  69. Logger: logger.Default.LogMode(logger.Silent),
  70. })
  71. if err != nil {
  72. return nil, fmt.Errorf("db: gorm.Open 失败: %w", err)
  73. }
  74. sqlDB, err := gdb.DB()
  75. if err != nil {
  76. return nil, fmt.Errorf("db: 取底层 *sql.DB 失败: %w", err)
  77. }
  78. // 连接池调参(0 值表示用 driver 默认;显式设置保证行为可预期)
  79. if cfg.MaxOpenConns > 0 {
  80. sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
  81. }
  82. if cfg.MaxIdleConns > 0 {
  83. sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
  84. }
  85. if cfg.ConnMaxLifetimeSec > 0 {
  86. sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSec) * time.Second)
  87. }
  88. // 连通性验证(fail-fast)
  89. if err := sqlDB.Ping(); err != nil {
  90. return nil, fmt.Errorf("db: Ping 失败(网络/账密/库名): %w", err)
  91. }
  92. return gdb, nil
  93. }
  94. // Migrate 对给定模型执行 AutoMigrate(幂等建表/对齐,dev + e2e 用)。
  95. //
  96. // 入参:
  97. // - gdb *gorm.DB:NewDB 返回的句柄。
  98. // - models ...any:要迁移的模型(*struct 指针,如 &model.User{};由调用方合并各域 AllTables 传入)。
  99. //
  100. // 出参:
  101. // - error:AutoMigrate 失败时返回(DDL 语法错/权限不足/表结构冲突)。
  102. //
  103. // 幂等语义:表不存在则按 model 建;已存在则逐列比对,缺列补列、类型不符告警
  104. // (GORM AutoMigrate 不删多余列、不强改已有列类型——避免误删生产数据,对齐"迁移最易失真处"纪律)。
  105. //
  106. // 说明:本函数**不硬编码**任何 model(保持 infra 零业务依赖);
  107. // 传入空 models 时为无操作(不报错,便于条件迁移)。
  108. func Migrate(gdb *gorm.DB, models ...any) error {
  109. if len(models) == 0 {
  110. return nil
  111. }
  112. if err := gdb.AutoMigrate(models...); err != nil {
  113. return fmt.Errorf("db: AutoMigrate 失败(%d 个模型): %w", len(models), err)
  114. }
  115. return nil
  116. }
  117. // Close 关闭连接池(优雅退出路径:cmd/api 收到 SIGTERM 后先排空在途任务再 Close,
  118. // 防连接泄漏——GORM 底层 *sql.DB 不显式 Close 会残留连接占用 PolarDB max_connections)。
  119. //
  120. // 入参:
  121. // - gdb *gorm.DB:NewDB 返回的句柄(nil 安全)。
  122. //
  123. // 出参:
  124. // - error:底层 sqlDB.Close 失败时返回(通常仅进程即将退出时调用,错误可忽略)。
  125. func Close(gdb *gorm.DB) error {
  126. if gdb == nil {
  127. return nil
  128. }
  129. sqlDB, err := gdb.DB()
  130. if err != nil {
  131. return fmt.Errorf("db: 取底层 *sql.DB 失败: %w", err)
  132. }
  133. return sqlDB.Close()
  134. }