- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
224 lines
6.7 KiB
Go
224 lines
6.7 KiB
Go
// 一次性迁移工具:把旧论坛(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)
|
||
}
|
||
}
|
||
}
|