Scert/backend/internal/database/database.go

230 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package database
import (
"context"
"database/sql"
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"github.com/jackc/pgx/v5"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
type Database struct {
DB *sql.DB
}
func Open(ctx context.Context, cfg config.DatabaseConfig) (*Database, error) {
if err := ValidateConfigForOpen(cfg); err != nil {
return nil, err
}
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
if driver != "sqlite" && driver != "mysql" && driver != "postgres" {
return nil, fmt.Errorf("数据库驱动无效:%s", cfg.Driver)
}
dsn := strings.TrimSpace(cfg.DSN)
if driver == "mysql" {
var err error
dsn, err = normalizeMySQLDSN(dsn)
if err != nil {
return nil, err
}
}
if driver == "postgres" {
var err error
dsn, err = normalizePostgresDSN(dsn)
if err != nil {
return nil, err
}
}
if driver == "sqlite" {
if cfg.DSN != ":memory:" && len(cfg.DSN) > 0 && cfg.DSN != "file::memory:?cache=shared" {
if err := ensureSQLiteDir(cfg.DSN); err != nil {
return nil, err
}
}
}
sqlDriver := driver
if driver == "postgres" {
sqlDriver = "pgx"
}
db, err := sql.Open(sqlDriver, dsn)
if err != nil {
return nil, fmt.Errorf("打开数据库失败:%w", err)
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeMins) * time.Minute)
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("连接数据库失败:%w", err)
}
if driver == "sqlite" {
if err := configureSQLite(pingCtx, db); err != nil {
_ = db.Close()
return nil, err
}
}
return &Database{DB: db}, nil
}
// ValidateConfigForOpen 接受已在内存中补全凭据的外部数据库配置。
// 持久化配置必须使用 ValidateConfig;运行时配置由 ResolveConfig 生成。
func ValidateConfigForOpen(cfg config.DatabaseConfig) error {
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
if driver != "sqlite" && driver != "mysql" && driver != "postgres" {
return fmt.Errorf("数据库驱动无效:%s", cfg.Driver)
}
if strings.TrimSpace(cfg.DSN) == "" {
return fmt.Errorf("数据库连接信息不能为空")
}
if driver == "sqlite" && strings.TrimSpace(cfg.CredentialRef) != "" {
return fmt.Errorf("SQLite 不允许配置数据库凭据引用")
}
return nil
}
func configureSQLite(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, "PRAGMA busy_timeout = 5000"); err != nil {
return fmt.Errorf("配置 SQLite 忙等待失败:%w", err)
}
var mode string
if _, err := db.ExecContext(ctx, "PRAGMA journal_mode = WAL"); err != nil {
return fmt.Errorf("配置 SQLite WAL 失败:%w", err)
}
if err := db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil {
return fmt.Errorf("读取 SQLite 日志模式失败:%w", err)
}
if strings.EqualFold(strings.TrimSpace(mode), "memory") || strings.EqualFold(strings.TrimSpace(mode), "wal") {
return nil
}
return fmt.Errorf("SQLite 日志模式无效:%s", mode)
}
func normalizeMySQLDSN(dsn string) (string, error) {
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
return "", fmt.Errorf("解析 MySQL 连接信息失败:%w", err)
}
cfg.ParseTime = true
cfg.Loc = time.UTC
return cfg.FormatDSN(), nil
}
func normalizePostgresDSN(dsn string) (string, error) {
parsed, err := pgx.ParseConfig(dsn)
if err != nil {
return "", fmt.Errorf("解析 PostgreSQL 连接信息失败:%w", err)
}
if strings.TrimSpace(parsed.Host) == "" || strings.TrimSpace(parsed.Database) == "" || strings.TrimSpace(parsed.User) == "" {
return "", fmt.Errorf("PostgreSQL 连接信息必须包含主机、数据库和用户名")
}
sslMode := postgresSSLMode(dsn)
if sslMode != "verify-full" {
return "", fmt.Errorf("PostgreSQL 连接必须使用 sslmode=verify-full")
}
if parsed.TLSConfig == nil || parsed.TLSConfig.InsecureSkipVerify {
return "", fmt.Errorf("PostgreSQL TLS 校验配置无效")
}
return dsn, nil
}
func postgresSSLMode(dsn string) string {
dsn = strings.TrimSpace(dsn)
if parsed, err := url.Parse(dsn); err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") {
return strings.ToLower(strings.TrimSpace(parsed.Query().Get("sslmode")))
}
for _, field := range strings.Fields(dsn) {
key, value, found := strings.Cut(field, "=")
if found && strings.EqualFold(strings.TrimSpace(key), "sslmode") {
return strings.ToLower(strings.Trim(strings.TrimSpace(value), "'\""))
}
}
return ""
}
// SanitizeDSN 返回可写入日志的数据库连接信息,不包含 MySQL 密码。
func SanitizeDSN(driver, dsn string) string {
switch strings.ToLower(strings.TrimSpace(driver)) {
case "mysql":
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
return "mysql://[连接信息无效]"
}
if cfg.Passwd != "" {
cfg.Passwd = "********"
}
return cfg.FormatDSN()
case "postgres":
parsed, err := url.Parse(dsn)
if err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") {
if parsed.User != nil {
username := parsed.User.Username()
if _, hasPassword := parsed.User.Password(); hasPassword {
parsed.User = url.UserPassword(username, "********")
}
}
return parsed.String()
}
return sanitizePostgresKeywordDSN(dsn)
}
return dsn
}
func sanitizePostgresKeywordDSN(dsn string) string {
fields := strings.Fields(dsn)
for index, field := range fields {
key, _, found := strings.Cut(field, "=")
if found && strings.EqualFold(strings.TrimSpace(key), "password") {
fields[index] = key + "=********"
}
}
return strings.Join(fields, " ")
}
func ensureSQLiteDir(dsn string) error {
path := dsn
if strings.HasPrefix(path, "file:") {
path = strings.TrimPrefix(strings.SplitN(path, "?", 2)[0], "file:")
}
return osMkdirAll(filepath.Dir(path))
}
var osMkdirAll = func(path string) error {
if path == "." || path == "" {
return nil
}
return mkdirAll(path)
}
var mkdirAll = func(path string) error {
return makeDirectory(path)
}
func (d *Database) Close() error {
if d == nil || d.DB == nil {
return nil
}
return d.DB.Close()
}
// Ping 检查数据库连接是否可用。
func (d *Database) Ping(ctx context.Context) error {
if d == nil || d.DB == nil {
return fmt.Errorf("数据库连接不能为空")
}
if err := d.DB.PingContext(ctx); err != nil {
return fmt.Errorf("数据库健康检查失败:%w", err)
}
return nil
}