feat: 管理端板块管理与旧版数据导入,补充部署运营文档

- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
2026-09-24 03:37:44 +08:00
parent c1f6a6636b
commit 3e55b5d230
53 changed files with 5078 additions and 409 deletions

View 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)
}
}
}