// migrate 将旧版 JSON 文件数据迁移到 MySQL 数据库。 // // 用法: // // go run ./cmd/migrate [--data-dir ./data] // // 环境变量(与主程序相同): // // APP_ENV=production 使用生产数据库(默认使用开发测试库) // DB_DSN 自定义 DSN(优先级最高) package main import ( "encoding/base64" "encoding/json" "flag" "log" "os" "path/filepath" "strings" "gorm.io/gorm" "gorm.io/gorm/clause" "sproutgate-backend/internal/database" "sproutgate-backend/internal/models" "sproutgate-backend/internal/storage" ) func main() { dataDir := flag.String("data-dir", "./data", "旧版 JSON 数据目录路径") flag.Parse() absDir, err := filepath.Abs(*dataDir) if err != nil { log.Fatalf("路径解析失败: %v", err) } log.Printf("数据目录: %s", absDir) db, err := database.Open() if err != nil { log.Fatalf("连接数据库失败: %v", err) } // 自动迁移表结构 if err := db.AutoMigrate( &storage.DBUser{}, &storage.DBPendingUser{}, &storage.DBResetPassword{}, &storage.DBSecondaryEmailVerification{}, &storage.DBAppConfig{}, &storage.DBInviteCode{}, ); err != nil { log.Fatalf("表结构同步失败: %v", err) } log.Println("✓ 表结构已同步") migrateAdminConfig(db, absDir) migrateAuthConfig(db, absDir) migrateEmailConfig(db, absDir) migrateCheckinConfig(db, absDir) migrateRegistrationConfig(db, absDir) migrateUsers(db, absDir) log.Println("\n✅ 数据迁移完成!") } // ── 辅助函数 ────────────────────────────────────────────────────────────────── func readJSONFile(path string, target any) error { raw, err := os.ReadFile(path) if err != nil { return err } return json.Unmarshal(raw, target) } // putConfig 将任意值序列化为 JSON 后写入 app_configs(冲突时整体覆盖)。 func putConfig(db *gorm.DB, key string, value any) { raw, err := json.Marshal(value) if err != nil { log.Printf(" ✗ 序列化 [%s] 失败: %v", key, err) return } row := storage.DBAppConfig{ConfigKey: key, ConfigValue: string(raw)} if err := db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error; err != nil { log.Printf(" ✗ app_configs[%s] 失败: %v", key, err) } else { log.Printf(" ✓ app_configs[%s]", key) } } // ── 各节配置迁移 ────────────────────────────────────────────────────────────── func migrateAdminConfig(db *gorm.DB, dataDir string) { path := filepath.Join(dataDir, "config", "admin.json") var cfg storage.AdminConfig if err := readJSONFile(path, &cfg); err != nil { log.Printf("[admin] 跳过(%v)", err) return } putConfig(db, "admin", cfg) } func migrateAuthConfig(db *gorm.DB, dataDir string) { path := filepath.Join(dataDir, "config", "auth.json") var cfg storage.AuthConfig if err := readJSONFile(path, &cfg); err != nil { log.Printf("[auth] 跳过(%v)", err) return } putConfig(db, "auth", cfg) } func migrateEmailConfig(db *gorm.DB, dataDir string) { path := filepath.Join(dataDir, "config", "email.json") var cfg storage.EmailConfig if err := readJSONFile(path, &cfg); err != nil { log.Printf("[email] 跳过(%v)", err) return } putConfig(db, "email", cfg) } func migrateCheckinConfig(db *gorm.DB, dataDir string) { path := filepath.Join(dataDir, "config", "checkin.json") var cfg storage.CheckInConfig if err := readJSONFile(path, &cfg); err != nil { log.Printf("[checkin] 跳过(%v)", err) return } putConfig(db, "checkin", cfg) } type oldRegistrationConfig struct { RequireInviteCode bool `json:"requireInviteCode"` Invites []storage.InviteEntry `json:"invites"` } type registrationPolicy struct { RequireInviteCode bool `json:"requireInviteCode"` ForbiddenAccounts string `json:"forbiddenAccounts"` InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins,omitempty"` } func migrateRegistrationConfig(db *gorm.DB, dataDir string) { path := filepath.Join(dataDir, "config", "registration.json") var old oldRegistrationConfig if err := readJSONFile(path, &old); err != nil { log.Printf("[registration] 跳过(%v)", err) return } putConfig(db, "registration", registrationPolicy{RequireInviteCode: old.RequireInviteCode}) for _, entry := range old.Invites { row := storage.DBInviteCode{ Code: entry.Code, Note: entry.Note, MaxUses: entry.MaxUses, Uses: entry.Uses, ExpiresAt: entry.ExpiresAt, CreatedAt: entry.CreatedAt, } if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil { log.Printf(" ✗ 邀请码 %s 失败: %v", entry.Code, err) } else { log.Printf(" ✓ 邀请码 %s", entry.Code) } } } func migrateUsers(db *gorm.DB, dataDir string) { usersDir := filepath.Join(dataDir, "users") entries, err := os.ReadDir(usersDir) if err != nil { log.Printf("[users] 读取目录失败: %v", err) return } total, ok, skipped := 0, 0, 0 for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } total++ path := filepath.Join(usersDir, entry.Name()) var record models.UserRecord if err := readJSONFile(path, &record); err != nil { log.Printf(" ✗ 读取 %s 失败: %v", entry.Name(), err) skipped++ continue } // 优先用 JSON 中的 account 字段,没有则从文件名(base64)解码 if strings.TrimSpace(record.Account) == "" { name := strings.TrimSuffix(entry.Name(), ".json") decoded, decErr := base64.RawURLEncoding.DecodeString(name) if decErr != nil || len(decoded) == 0 { log.Printf(" ✗ 无法确定账号,跳过 %s", entry.Name()) skipped++ continue } record.Account = string(decoded) } row := storage.DBUserFromRecord(record) if err := db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error; err != nil { log.Printf(" ✗ 用户 %s 失败: %v", record.Account, err) skipped++ } else { log.Printf(" ✓ 用户 %s", record.Account) ok++ } } log.Printf("[users] 合计 %d 个,成功 %d,跳过 %d", total, ok, skipped) }