// 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("迁移完成 ✅") }