// 一次性迁移工具:把旧论坛(jiang13-forum,SQLite)的用户导入本站(PostgreSQL)。 // // 迁移范围:用户名 / bcrypt 密码哈希 / 昵称 / 签名 / 邮箱 / 头像(含文件复制)。 // 旧站 role=admin 的账号不导入;积分、经验、徽章等一律不迁。 // 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。 // // 幂等:已存在(含软删)同名用户自动跳过,可安全重复运行。 // // 用法示例: // // go run ./cmd/importusers --dry-run # 只统计打印,不写库不复制文件 // go run ./cmd/importusers # 实际导入 package main import ( "flag" "fmt" "io" "log" "os" "path/filepath" "time" "github.com/glebarez/sqlite" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/freefire/jiang13-bbs/model" ) const signatureMaxRunes = 255 // 新站 Signature 列上限 // oldUser 只映射迁移所需列,显式指向旧库 users 表 type oldUser struct { ID uint `gorm:"primaryKey"` Username string `gorm:"column:username"` Email string `gorm:"column:email"` Password string `gorm:"column:password"` Nickname string `gorm:"column:nickname"` Signature string `gorm:"column:signature"` Avatar string `gorm:"column:avatar"` Role string `gorm:"column:role"` Banned bool `gorm:"column:banned"` CreatedAt time.Time `gorm:"column:created_at"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` // 软删行由 GORM 自动过滤 } func (oldUser) TableName() string { return "users" } type report struct { avatarMissing []string conflicts []string failed []string } func main() { var ( sqlitePath = flag.String("sqlite", `C:\Users\freefire\Documents\jiang13-forum\data\jiang13.db`, "旧站 SQLite 库路径") avatarsSrc = flag.String("avatars-src", `C:\Users\freefire\Documents\jiang13-forum\data\uploads\avatars`, "旧站头像文件目录") avatarsDst = flag.String("avatars-dst", `./data/uploads/avatars`, "新站头像文件目录({DataDir}/uploads/avatars)") dsn = flag.String("dsn", "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable", "新站 PostgreSQL DSN") dryRun = flag.Bool("dry-run", false, "只统计打印,不写库不复制文件") ) flag.Parse() // ---- 打开旧库(只读) ---- oldDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(*sqlitePath)+"?mode=ro"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { log.Fatalf("打开旧库失败 %s: %v", *sqlitePath, err) } var users []oldUser if err := oldDB.Where("role <> ?", "admin").Order("id ASC").Find(&users).Error; err != nil { log.Fatalf("读取旧库用户失败: %v", err) } log.Printf("旧库可迁移用户(role != admin): %d 人", len(users)) // ---- 打开新库 ---- newDB, err := gorm.Open(postgres.Open(*dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { log.Fatalf("连接新库失败: %v", err) } if !*dryRun { if err := os.MkdirAll(*avatarsDst, 0o755); err != nil { log.Fatalf("创建头像目标目录失败 %s: %v", *avatarsDst, err) } } rep := &report{} var imported int for _, u := range users { // 用户名查重(含软删行) var n int64 if err := newDB.Unscoped().Model(&model.User{}).Where("username = ?", u.Username).Count(&n).Error; err != nil { rep.failed = append(rep.failed, fmt.Sprintf("%s (查重失败: %v)", u.Username, err)) continue } if n > 0 { rep.conflicts = append(rep.conflicts, u.Username) continue } avatarURL, err := resolveAvatar(u.Avatar, *avatarsSrc, *avatarsDst, *dryRun) if err != nil { rep.failed = append(rep.failed, fmt.Sprintf("%s (头像处理失败: %v)", u.Username, err)) continue } if u.Avatar != "" && avatarURL == "" { rep.avatarMissing = append(rep.avatarMissing, fmt.Sprintf("%s: %s", u.Username, u.Avatar)) } user := model.User{ Username: u.Username, Email: u.Email, Password: u.Password, // bcrypt 哈希原样复制 Nickname: u.Nickname, Signature: truncateRunes(u.Signature, signatureMaxRunes), Avatar: avatarURL, Role: model.RoleUser, Banned: u.Banned, CreatedAt: u.CreatedAt, // 保留原注册时间 } if *dryRun { imported++ continue } if err := newDB.Create(&user).Error; err != nil { rep.failed = append(rep.failed, fmt.Sprintf("%s (写入失败: %v)", u.Username, err)) continue } imported++ } printSummary(len(users), imported, rep, *dryRun) } // resolveAvatar 把旧头像相对路径落成新站文件 + URL。 // 返回新 Avatar 字段值;空串表示无法迁移(源缺失或格式不支持)。 func resolveAvatar(oldAvatar, srcDir, dstDir string, dryRun bool) (string, error) { if oldAvatar == "" { return "", nil } const prefix = "/uploads/avatars/" if len(oldAvatar) <= len(prefix) || filepath.Base(oldAvatar) != oldAvatar[len(prefix):] { // 不符合旧站 "/uploads/avatars/<文件名>" 约定(如外部 URL),无法本地迁移 return "", nil } filename := filepath.Base(oldAvatar) src := filepath.Join(srcDir, filename) if _, err := os.Stat(src); err != nil { return "", nil // 源文件缺失 } dst := filepath.Join(dstDir, filename) if !dryRun { if _, err := os.Stat(dst); err != nil { if err := copyFile(src, dst); err != nil { return "", err } } // dst 已存在则跳过复制,URL 仍然有效 } return prefix + filename, nil } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() if _, err := io.Copy(out, in); err != nil { return err } return out.Close() } func truncateRunes(s string, max int) string { r := []rune(s) if len(r) <= max { return s } return string(r[:max]) } func printSummary(total, imported int, rep *report, dryRun bool) { mode := "导入完成" if dryRun { mode = "dry-run 统计" } fmt.Println("==========") fmt.Printf("[%s] 旧库用户 %d | 成功 %d | 用户名冲突跳过 %d | 头像缺失 %d | 失败 %d\n", mode, total, imported, len(rep.conflicts), len(rep.avatarMissing), len(rep.failed)) if len(rep.conflicts) > 0 { fmt.Println("-- 用户名冲突(新库已存在,跳过):") for _, c := range rep.conflicts { fmt.Println(" ", c) } } if len(rep.avatarMissing) > 0 { fmt.Println("-- 头像源文件缺失(Avatar 置空):") for _, c := range rep.avatarMissing { fmt.Println(" ", c) } } if len(rep.failed) > 0 { fmt.Println("-- 失败明细:") for _, c := range rep.failed { fmt.Println(" ", c) } } }