feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
223
backend/cmd/importusers/main.go
Normal file
223
backend/cmd/importusers/main.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// 一次性迁移工具:把旧论坛(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user