// Package db 数据库连接池装配 + AutoMigrate 收口(架构规格 §3.5/§3.6,GORM + Repository 分层)。 // // 职责边界(依赖方向铁律:infra 不依赖任何业务域): // - 本包**只提供**"建连接池 + 执行 AutoMigrate"两个通用能力,**不**内置任何业务 model; // 具体要迁哪些表由**调用方**(cmd/api 装配层 / 集成测试)传入 model 列表—— // 这样 infra 保持零业务域依赖(若在本包 import identity/model、org/model 会违反铁律)。 // - 业务域 model 的"全表清单"由各域 model 包提供(identity/model.AllTables / org/model.AllTables), // 装配层合并后传入 Migrate。 // // 与 DDL 的关系(unified/02-data-model/core-model-ddl.sql): // - 生产环境割接以 **DDL 文件为准**(PolarDB 建表,information_schema 核对); // - AutoMigrate 用于 **dev 环境快速建表 + e2e 测试隔离建表**(幂等:表存在则对齐,不存在则建); // - 两者必须逐列一致(T2 验收:GORM model tag 与 DDL 列名/类型/可空性对齐,单测对账)。 // // 典型使用(cmd/api/main.go,T4 接线后): // // db, err := db.NewDB(cfg.DB) // 建连接池 + ping // all := append(append(identitymodel.AllTables(), orgmodel.AllTables()...), task.Task{}) // if err := db.Migrate(db, all...); err != nil { log.Fatalf("migrate: %v", err) } // defer db.Close(db) // // 双实例(ADR-015):两个 Go 进程各调一次 NewDB(同一 DSN 连同一库),instanceId 只影响 // 日志/临时目录,不影响连接池——DB 连接池天然共享同一个 PolarDB,无需"双 DSN"。 package db import ( "fmt" "time" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" "yuansiwei/unified-backend/internal/infra/config" ) // NewDB 建立 MySQL 连接池并验证连通性(fail-fast:连不上库不允许启动, // 避免运行到首个查询才暴露"DSN 配错/库没起")。 // // 入参: // - cfg config.DBCfg:PolarDB/MySQL 配置(DSN + 连接池参数,见 config.DBCfg)。 // // 出参: // - *gorm.DB:就绪的 GORM 句柄(连接池已按 cfg 调参、已 ping 成功)。 // - error:以下场景返回错误: // - DSN 为空(fail-fast,配置缺失); // - gorm.Open 失败(驱动初始化/连接失败); // - Ping 失败(网络不通/账密错/库不存在)。 // // 连接池参数(§3.6 数据访问纪律): // - MaxOpenConns:限制并发连接,防双实例合计打满 PolarDB max_connections; // - MaxIdleConns:空闲连接复用,降低握手开销; // - ConnMaxLifetimeSec:连接最长存活,防连接池漂移(DB 侧 wait_timeout 与客户端对齐)。 // // 外键约束:gorm.Config 显式 DisableForeignKeyConstraintWhenMigrating=true—— // 本项目"外键不建物理 FK,应用层 Repository 强制作用域保证"(P0-11 收敛规则 7 / DDL 注释), // AutoMigrate 不得自动补 FK 约束(与 DDL 的 FOREIGN_KEY_CHECKS=0 语义一致)。 func NewDB(cfg config.DBCfg) (*gorm.DB, error) { if cfg.DSN == "" { return nil, fmt.Errorf("db: DSN 为空(须经 config/dev.json 或环境变量 CONFIG_DB__DSN 提供)") } gdb, err := gorm.Open(mysql.Open(cfg.DSN), &gorm.Config{ // 与 DDL"不建物理外键"对齐:迁移时不自动生成 FK 约束 DisableForeignKeyConstraintWhenMigrating: true, // 生产/双实例默认静默 SQL 日志(避免高频只读接口刷日志);联调期可换 logger.Warn Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { return nil, fmt.Errorf("db: gorm.Open 失败: %w", err) } sqlDB, err := gdb.DB() if err != nil { return nil, fmt.Errorf("db: 取底层 *sql.DB 失败: %w", err) } // 连接池调参(0 值表示用 driver 默认;显式设置保证行为可预期) if cfg.MaxOpenConns > 0 { sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) } if cfg.MaxIdleConns > 0 { sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) } if cfg.ConnMaxLifetimeSec > 0 { sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSec) * time.Second) } // 连通性验证(fail-fast) if err := sqlDB.Ping(); err != nil { return nil, fmt.Errorf("db: Ping 失败(网络/账密/库名): %w", err) } return gdb, nil } // Migrate 对给定模型执行 AutoMigrate(幂等建表/对齐,dev + e2e 用)。 // // 入参: // - gdb *gorm.DB:NewDB 返回的句柄。 // - models ...any:要迁移的模型(*struct 指针,如 &model.User{};由调用方合并各域 AllTables 传入)。 // // 出参: // - error:AutoMigrate 失败时返回(DDL 语法错/权限不足/表结构冲突)。 // // 幂等语义:表不存在则按 model 建;已存在则逐列比对,缺列补列、类型不符告警 // (GORM AutoMigrate 不删多余列、不强改已有列类型——避免误删生产数据,对齐"迁移最易失真处"纪律)。 // // 说明:本函数**不硬编码**任何 model(保持 infra 零业务依赖); // 传入空 models 时为无操作(不报错,便于条件迁移)。 func Migrate(gdb *gorm.DB, models ...any) error { if len(models) == 0 { return nil } if err := gdb.AutoMigrate(models...); err != nil { return fmt.Errorf("db: AutoMigrate 失败(%d 个模型): %w", len(models), err) } return nil } // Close 关闭连接池(优雅退出路径:cmd/api 收到 SIGTERM 后先排空在途任务再 Close, // 防连接泄漏——GORM 底层 *sql.DB 不显式 Close 会残留连接占用 PolarDB max_connections)。 // // 入参: // - gdb *gorm.DB:NewDB 返回的句柄(nil 安全)。 // // 出参: // - error:底层 sqlDB.Close 失败时返回(通常仅进程即将退出时调用,错误可忽略)。 func Close(gdb *gorm.DB) error { if gdb == nil { return nil } sqlDB, err := gdb.DB() if err != nil { return fmt.Errorf("db: 取底层 *sql.DB 失败: %w", err) } return sqlDB.Close() }