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,139 @@
package handler
import (
"errors"
"io"
"mime/multipart"
"net/http"
"os"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// ===== 数据导入(settingsAPI 组内,PermSettings 门控:仅超管/站长) =====
//
// POST /api/admin/import/:source multipart:
// - db_file 旧站 SQLite 库(必填)
// - avatars_zip 头像压缩包(可选)
// - with_content "true"/"false",是否同时导入板块/帖子/评论(默认 true)
// - dry_run "true"/"false",预检模式不写库(默认 false)
//
// source 走白名单(当前仅 jiang13),为 WordPress / Typecho 等来源预留扩展。
// AdminImportLegacy 导入外部站点数据,返回分类统计报告。幂等可重复导入。
func (h *Handlers) AdminImportLegacy(c *gin.Context) {
source := c.Param("source")
if !service.LegacyImportSource(source) {
c.JSON(http.StatusBadRequest, gin.H{"error": "暂不支持该数据源(当前支持:jiang13)"})
return
}
const overhead = 64 << 10 // multipart 边界与表单开销
limit := int64(service.LegacyDBMaxBytes) + int64(service.LegacyZipMaxBytes) + overhead
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
dbFH, err := c.FormFile("db_file")
if err != nil {
respondLegacyUploadError(c, err, "请上传旧站数据库文件(jiang13.db)")
return
}
if dbFH.Size > service.LegacyDBMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "数据库文件不能超过 64MB"})
return
}
zipPath := ""
if zipFH, ferr := c.FormFile("avatars_zip"); ferr == nil && zipFH != nil && zipFH.Size > 0 {
if zipFH.Size > service.LegacyZipMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "头像压缩包不能超过 128MB"})
return
}
zipPath, err = saveMultipartToTemp(zipFH, "legacy-avatars-*.zip")
if err != nil {
respondLegacyUploadError(c, err, "读取头像压缩包失败")
return
}
defer os.Remove(zipPath)
} else if ferr != nil {
// 表单整体解析失败(超限等);「字段不存在」直接忽略
var maxErr *http.MaxBytesError
if errors.As(ferr, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
return
}
}
dbPath, err := saveMultipartToTemp(dbFH, "legacy-*.db")
if err != nil {
respondLegacyUploadError(c, err, "读取数据库文件失败")
return
}
defer os.Remove(dbPath)
rep, err := h.LegacyImport.ImportFromFiles(dbPath, zipPath, service.LegacyImportOptions{
WithContent: formBool(c, "with_content", true),
DryRun: formBool(c, "dry_run", false),
}, claims.ID)
if err != nil {
if errors.Is(err, service.ErrLegacyInvalidSQLite) || errors.Is(err, service.ErrLegacyBadZip) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"report": rep})
}
func formBool(c *gin.Context, key string, def bool) bool {
v := c.PostForm(key)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
func respondLegacyUploadError(c *gin.Context, err error, fallback string) {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": fallback})
}
// saveMultipartToTemp 把上传文件落盘为临时文件(大文件不宜整读进内存,Docker 下 /tmp 可写)
func saveMultipartToTemp(fh *multipart.FileHeader, pattern string) (string, error) {
src, err := fh.Open()
if err != nil {
return "", err
}
defer src.Close()
tmp, err := os.CreateTemp("", pattern)
if err != nil {
return "", err
}
name := tmp.Name()
if _, err := io.Copy(tmp, src); err != nil {
tmp.Close()
os.Remove(name)
return "", err
}
if err := tmp.Close(); err != nil {
os.Remove(name)
return "", err
}
return name, nil
}