Files
mailgo/internal/db/db.go
T
dsh 034901a467 fix(db): AutoMigrate 前清理旧模型遗留的同名非唯一索引,修复 MySQL 启动失败
旧模型 BanEntry.IPAddress 为普通 index,由 AutoMigrate 建成非唯一索引
idx_ban_entries_ip_address;升级到 uniqueIndex 后,gorm mysql 驱动
MigrateColumnUnique 对非唯一列无条件 CREATE UNIQUE INDEX(不检查
HasIndex),与同名旧索引冲突报 Error 1061 Duplicate key name,服务
启动即失败(AutoMigrate 末尾的 HasIndex 保护执行不到)。

dropLegacyBanIndex 在 dedupeBanEntries 之后、AutoMigrate 之前,检测
information_schema.statistics 中同名 non_unique=1 的索引并删除,由
AutoMigrate 重建为唯一索引。仅 MySQL 生效,表不存在/失败仅告警。
2026-08-19 23:36:21 -04:00

127 lines
4.9 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 db
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"mail_go/config"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// InitDB initializes the database connection and performs auto-migration.
// It selects the appropriate driver based on cfg.Driver and resolves
// the DSN path for SQLite relative to the storage base directory.
func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.DB, error) {
var dialector gorm.Dialector
switch cfg.Driver {
case "sqlite":
dsn := cfg.DSN
// If the DSN is the default relative path, prepend the storage base directory
if dsn == config.DefaultDSNWin || dsn == config.DefaultDSNLinux {
dsn = filepath.Join(storageCfg.BaseDir, "mail.db")
}
// Ensure the parent directory exists for SQLite
dir := filepath.Dir(dsn)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败 %s: %w", dir, err)
}
// 多连接并发(SMTP/IMAP/POP3/Web/外发 worker/推送)下:
// - WAL 模式:读不阻塞写,消除瞬时 SQLITE_BUSY 导致写失败被吞
// - busy_timeout=5000ms:写竞争时等待而非立刻失败
// - synchronous=NORMALWAL 下安全且写入更快
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
dsn = dsn + sep + "_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL"
dialector = sqlite.Open(dsn)
case "mysql":
dialector = mysql.Open(cfg.DSN)
default:
return nil, fmt.Errorf("不支持的数据库驱动: %s", cfg.Driver)
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
// AutoMigrate 前清理 ban_entries 的历史重复行(保留每 IP 最大 id):
// ip_address 将升级为唯一索引,重复行会使索引创建失败。
// 首次安装表不存在时忽略错误(AutoMigrate 会建新表)。
dedupeBanEntries(db)
// 再清理旧模型遗留的同名非唯一索引(见 dropLegacyBanIndex)。
dropLegacyBanIndex(db)
// Auto-migrate all models
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}, &Mailbox{}); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
return db, nil
}
// dedupeBanEntries 删除 ban_entries 中同一 ip_address 的重复行(保留
// 每组 id 最大的一条),为 ip_address 唯一索引的 AutoMigrate 扫清障碍。
// 表不存在(首次安装)时静默跳过;清理失败仅告警,不阻断启动
// (索引创建失败会在 AutoMigrate 中显式报错)。
func dedupeBanEntries(db *gorm.DB) {
// SQLite 与 MySQL 均支持;MySQL 不允许 DELETE 子查询直接引用同表
// (1093),因此用派生表包一层。
sql := "DELETE FROM ban_entries WHERE id NOT IN (" +
"SELECT mid FROM (SELECT MAX(id) AS mid FROM ban_entries GROUP BY ip_address) AS t)"
if err := db.Exec(sql).Error; err != nil {
// 表不存在(首次安装)为预期情况
msg := err.Error()
if strings.Contains(msg, "no such table") || strings.Contains(msg, "doesn't exist") {
return
}
log.Printf("清理 ban_entries 重复行失败(唯一索引可能无法创建): %v", err)
}
}
// dropLegacyBanIndex 删除 ban_entries 上历史遗留的同名非唯一索引
// (旧模型 IPAddress 为普通 `index` 时由 AutoMigrate 创建)。
// IPAddress 升级为 uniqueIndex 后,MySQL 的 MigrateColumnUnique 会对
// 非唯一列无条件执行 CREATE UNIQUE INDEX(该路径不检查 HasIndex),
// 与同名旧索引冲突时报 Error 1061 Duplicate key name,导致启动即失败
// AutoMigrate 末尾按名查 HasIndex 的保护根本执行不到)。
// 仅 MySQL 存在此问题;SQLite 迁移路径不受影响。
// 表不存在(首次安装)时静默跳过;失败仅告警,不阻断启动
// (索引创建失败会在 AutoMigrate 中显式报错)。
func dropLegacyBanIndex(db *gorm.DB) {
if db.Dialector.Name() != "mysql" {
return
}
var cnt int64
err := db.Raw(
"SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'ban_entries' AND index_name = 'idx_ban_entries_ip_address' AND non_unique = 1",
).Scan(&cnt).Error
if err != nil {
// 表不存在(首次安装)为预期情况
if strings.Contains(err.Error(), "doesn't exist") {
return
}
log.Printf("检查 ban_entries 遗留索引失败(唯一索引可能无法创建): %v", err)
return
}
if cnt == 0 {
return
}
if err := db.Exec("ALTER TABLE `ban_entries` DROP INDEX `idx_ban_entries_ip_address`").Error; err != nil {
log.Printf("删除 ban_entries 遗留非唯一索引失败(唯一索引可能无法创建): %v", err)
return
}
log.Printf("已删除 ban_entries 遗留非唯一索引 idx_ban_entries_ip_addressAutoMigrate 将重建为唯一索引)")
}