Merge pull request 'feat(db): 支持 MySQL 迁移(修复模型外键冲突 + 大文本类型 + 迁移工具)' (#13) from dsh/mailgo:fix/mysql-migration into main
Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
@@ -40,3 +40,4 @@ win/srv/
|
||||
|
||||
# 临时测试文件
|
||||
login_test.html
|
||||
migrate
|
||||
@@ -0,0 +1,260 @@
|
||||
// migrate 一次性工具:把 SQLite 数据迁移到 MySQL(mailgo 库)。
|
||||
// 用法:go run ./cmd/migrate -from /srv/mail_go/mail.db -dsn "mailgo:密码@tcp(127.0.0.1:3306)/mailgo?charset=utf8mb4&parseTime=True&loc=UTC"
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
fromDSN = flag.String("from", "/srv/mail_go/mail.db", "SQLite 数据库路径")
|
||||
mysqlDSN = flag.String("dsn", "", "MySQL DSN(目标库,需已创建 mailgo 库与用户)")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *mysqlDSN == "" {
|
||||
log.Fatal("缺少 -dsn")
|
||||
}
|
||||
|
||||
// 目标:MySQL(InitDB 内含 AutoMigrate,按当前模型建表)
|
||||
mdb, err := db.InitDB(config.DatabaseConfig{Driver: "mysql", DSN: *mysqlDSN}, config.StorageConfig{BaseDir: "/srv/mail_go/"})
|
||||
if err != nil {
|
||||
log.Fatalf("连接 MySQL 失败: %v", err)
|
||||
}
|
||||
log.Println("MySQL 建表完成(AutoMigrate)")
|
||||
|
||||
// 源:SQLite(只读)
|
||||
sdb, err := db.InitDB(config.DatabaseConfig{Driver: "sqlite", DSN: *fromDSN}, config.StorageConfig{BaseDir: "/srv/mail_go/"})
|
||||
if err != nil {
|
||||
log.Fatalf("连接 SQLite 失败: %v", err)
|
||||
}
|
||||
sdb.Logger = logger.Default.LogMode(logger.Silent)
|
||||
|
||||
// 关闭 GORM 自动时间戳(保留原始 CreatedAt/UpdatedAt)
|
||||
mw := mdb.Session(&gorm.Session{SkipHooks: true})
|
||||
|
||||
stateWant := int64(-1) // mailbox_states 期望行数;-1 = 源库无此表不校验
|
||||
|
||||
// 按外键依赖顺序复制:domains → users → messages → attachments → 其余
|
||||
// 所有时间统一 UTC(MySQL DATETIME 无时区)。
|
||||
utc := func(t time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
// MySQL DATETIME 最小年份 1000;零值由调用方转 NULL
|
||||
return t
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
_ = utc
|
||||
|
||||
// ---- domains ----
|
||||
var domains []db.Domain
|
||||
if err := sdb.Order("id").Find(&domains).Error; err != nil {
|
||||
log.Fatalf("读 domains: %v", err)
|
||||
}
|
||||
for i := range domains {
|
||||
domains[i].CreatedAt = domains[i].CreatedAt.UTC()
|
||||
domains[i].UpdatedAt = domains[i].UpdatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&domains).Error; err != nil {
|
||||
log.Fatalf("写 domains: %v", err)
|
||||
}
|
||||
log.Printf("domains: %d", len(domains))
|
||||
|
||||
// ---- users ----
|
||||
var users []db.User
|
||||
if err := sdb.Order("id").Find(&users).Error; err != nil {
|
||||
log.Fatalf("读 users: %v", err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].CreatedAt = users[i].CreatedAt.UTC()
|
||||
users[i].UpdatedAt = users[i].UpdatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&users).Error; err != nil {
|
||||
log.Fatalf("写 users: %v", err)
|
||||
}
|
||||
log.Printf("users: %d", len(users))
|
||||
|
||||
// ---- messages ----
|
||||
var msgs []db.Message
|
||||
if err := sdb.Order("id").Find(&msgs).Error; err != nil {
|
||||
log.Fatalf("读 messages: %v", err)
|
||||
}
|
||||
for i := range msgs {
|
||||
msgs[i].Date = msgs[i].Date.UTC()
|
||||
msgs[i].CreatedAt = msgs[i].CreatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&msgs).Error; err != nil {
|
||||
log.Fatalf("写 messages: %v", err)
|
||||
}
|
||||
log.Printf("messages: %d", len(msgs))
|
||||
|
||||
// ---- attachments ----
|
||||
var atts []db.Attachment
|
||||
if err := sdb.Order("id").Find(&atts).Error; err != nil {
|
||||
log.Fatalf("读 attachments: %v", err)
|
||||
}
|
||||
for i := range atts {
|
||||
atts[i].CreatedAt = atts[i].CreatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&atts).Error; err != nil {
|
||||
log.Fatalf("写 attachments: %v", err)
|
||||
}
|
||||
log.Printf("attachments: %d", len(atts))
|
||||
|
||||
// ---- outbound_messages(原样,含时间转 UTC)----
|
||||
var outs []db.OutboundMessage
|
||||
if err := sdb.Order("id").Find(&outs).Error; err != nil {
|
||||
log.Fatalf("读 outbound_messages: %v", err)
|
||||
}
|
||||
for i := range outs {
|
||||
outs[i].NextAttemptAt = outs[i].NextAttemptAt.UTC()
|
||||
if outs[i].CompletedAt != nil && !outs[i].CompletedAt.IsZero() {
|
||||
u := outs[i].CompletedAt.UTC()
|
||||
outs[i].CompletedAt = &u
|
||||
}
|
||||
outs[i].CreatedAt = outs[i].CreatedAt.UTC()
|
||||
outs[i].UpdatedAt = outs[i].UpdatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&outs).Error; err != nil {
|
||||
log.Fatalf("写 outbound_messages: %v", err)
|
||||
}
|
||||
log.Printf("outbound_messages: %d", len(outs))
|
||||
|
||||
// ---- ban_entries(expires_at 零值 → NULL)----
|
||||
rows, err := sdb.Raw("SELECT id, ip_address, reason, fail_count, ban_count, expires_at, created_at, updated_at FROM ban_entries ORDER BY id").Rows()
|
||||
if err != nil {
|
||||
log.Fatalf("读 ban_entries: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
bans := 0
|
||||
for rows.Next() {
|
||||
var (
|
||||
id uint
|
||||
ip string
|
||||
reason *string
|
||||
failCount int
|
||||
banCount int
|
||||
expires *time.Time
|
||||
created *time.Time
|
||||
updated *time.Time
|
||||
)
|
||||
if err := rows.Scan(&id, &ip, &reason, &failCount, &banCount, &expires, &created, &updated); err != nil {
|
||||
log.Fatalf("扫 ban_entries: %v", err)
|
||||
}
|
||||
norm := func(t *time.Time) *time.Time {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
if err := mdb.Exec("INSERT INTO ban_entries (id, ip_address, reason, fail_count, ban_count, expires_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
id, ip, reason, failCount, banCount, norm(expires), norm(created), norm(updated)).Error; err != nil {
|
||||
log.Fatalf("写 ban_entries id=%d: %v", id, err)
|
||||
}
|
||||
bans++
|
||||
}
|
||||
log.Printf("ban_entries: %d", bans)
|
||||
|
||||
// ---- protocol_logs ----
|
||||
var logs []db.ProtocolLog
|
||||
if err := sdb.Order("id").Find(&logs).Error; err != nil {
|
||||
log.Fatalf("读 protocol_logs: %v", err)
|
||||
}
|
||||
for i := range logs {
|
||||
logs[i].CreatedAt = logs[i].CreatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&logs).Error; err != nil {
|
||||
log.Fatalf("写 protocol_logs: %v", err)
|
||||
}
|
||||
log.Printf("protocol_logs: %d", len(logs))
|
||||
|
||||
// ---- mailbox_states(原生 SQL:该表随 UIDVALIDITY 特性存在,旧版本源库可能没有)----
|
||||
var stateCount int64
|
||||
sdb.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='mailbox_states'").Scan(&stateCount)
|
||||
if stateCount > 0 {
|
||||
// 目标库若没有该表(上游模型未含 MailboxState 时 AutoMigrate 不会建),先建表
|
||||
var tcnt int64
|
||||
mdb.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'mailbox_states'").Scan(&tcnt)
|
||||
if tcnt == 0 {
|
||||
if err := mdb.Exec(`CREATE TABLE mailbox_states (
|
||||
user_id bigint unsigned NOT NULL,
|
||||
folder varchar(64) NOT NULL,
|
||||
uid_validity bigint unsigned NOT NULL,
|
||||
created_at datetime(3) NULL,
|
||||
updated_at datetime(3) NULL,
|
||||
PRIMARY KEY (user_id, folder))`).Error; err != nil {
|
||||
log.Fatalf("建 mailbox_states 表: %v", err)
|
||||
}
|
||||
log.Println("mailbox_states: 目标库已建表")
|
||||
}
|
||||
srows, err := sdb.Raw("SELECT user_id, folder, uid_validity, created_at, updated_at FROM mailbox_states ORDER BY user_id, folder").Rows()
|
||||
if err != nil {
|
||||
log.Fatalf("读 mailbox_states: %v", err)
|
||||
}
|
||||
defer srows.Close()
|
||||
states := 0
|
||||
for srows.Next() {
|
||||
var (
|
||||
userID uint
|
||||
folder string
|
||||
validity uint32
|
||||
created *time.Time
|
||||
updated *time.Time
|
||||
)
|
||||
if err := srows.Scan(&userID, &folder, &validity, &created, &updated); err != nil {
|
||||
log.Fatalf("扫 mailbox_states: %v", err)
|
||||
}
|
||||
norm := func(t *time.Time) *time.Time {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
if err := mdb.Exec("INSERT INTO mailbox_states (user_id, folder, uid_validity, created_at, updated_at) VALUES (?,?,?,?,?)",
|
||||
userID, folder, validity, norm(created), norm(updated)).Error; err != nil {
|
||||
log.Fatalf("写 mailbox_states: %v", err)
|
||||
}
|
||||
states++
|
||||
}
|
||||
log.Printf("mailbox_states: %d", states)
|
||||
stateWant = int64(states)
|
||||
} else {
|
||||
log.Println("mailbox_states: 源库无此表,跳过")
|
||||
}
|
||||
|
||||
// ---- 校验 ----
|
||||
check := func(table string, want int64) {
|
||||
var got int64
|
||||
if err := mdb.Table(table).Count(&got).Error; err != nil {
|
||||
log.Fatalf("校验 %s: %v", table, err)
|
||||
}
|
||||
if got != want {
|
||||
log.Fatalf("校验 %s 失败: got %d want %d", table, got, want)
|
||||
}
|
||||
fmt.Printf("校验 %s: %d/%d ✓\n", table, got, want)
|
||||
}
|
||||
check("domains", int64(len(domains)))
|
||||
check("users", int64(len(users)))
|
||||
check("messages", int64(len(msgs)))
|
||||
check("attachments", int64(len(atts)))
|
||||
check("outbound_messages", int64(len(outs)))
|
||||
check("ban_entries", int64(bans))
|
||||
check("protocol_logs", int64(len(logs)))
|
||||
if stateWant >= 0 {
|
||||
check("mailbox_states", stateWant)
|
||||
}
|
||||
|
||||
log.Println("迁移完成 ✅")
|
||||
}
|
||||
@@ -60,8 +60,8 @@ type Message struct {
|
||||
ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
|
||||
CcAddr string `gorm:"size:2048" json:"cc_addr"`
|
||||
Subject string `gorm:"size:1024" json:"subject"`
|
||||
TextBody string `gorm:"type:text" json:"text_body"`
|
||||
HtmlBody string `gorm:"type:text" json:"html_body"`
|
||||
TextBody string `gorm:"type:mediumtext" json:"text_body"`
|
||||
HtmlBody string `gorm:"type:mediumtext" json:"html_body"`
|
||||
RawData string `gorm:"type:mediumtext" json:"raw_data"`
|
||||
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
|
||||
@@ -155,10 +155,11 @@ func (ProtocolLog) TableName() string {
|
||||
}
|
||||
|
||||
// Attachment represents a file attached to an email message.
|
||||
// 注意:不声明 Message 关联(避免 GORM 外键名 MessageID 与
|
||||
// Message.MessageID 字符串字段冲突,导致 AutoMigrate 生成错误外键)。
|
||||
type Attachment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
MessageID uint `gorm:"index;not null" json:"message_id"`
|
||||
Message Message `gorm:"foreignKey:MessageID" json:"message"`
|
||||
FileName string `gorm:"size:255;not null" json:"file_name"`
|
||||
FilePath string `gorm:"size:512;not null" json:"file_path"`
|
||||
ContentType string `gorm:"size:128" json:"content_type"`
|
||||
|
||||
Reference in New Issue
Block a user