Files
jiang13-bbs/backend/cmd/importusers/main.go
freefire 3e55b5d230 feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
2026-09-24 03:37:44 +08:00

224 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 一次性迁移工具:把旧论坛(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)
}
}
}