feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
198
backend/handler/admin_board.go
Normal file
198
backend/handler/admin_board.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ===== 板块管理(仅站长;路由层 RequirePerm(PermBoards) 兜底) =====
|
||||
|
||||
// adminBoardBody 板块新建/更新请求体
|
||||
type adminBoardBody struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
ColorIndex *int `json:"color_index"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
PostPolicy string `json:"post_policy"`
|
||||
Visible *bool `json:"visible"`
|
||||
}
|
||||
|
||||
// toBoardInput 把请求体转 service 入参
|
||||
func toBoardInput(b adminBoardBody) service.BoardInput {
|
||||
return service.BoardInput{
|
||||
Name: b.Name,
|
||||
Description: b.Description,
|
||||
Icon: b.Icon,
|
||||
ColorIndex: b.ColorIndex,
|
||||
SortOrder: b.SortOrder,
|
||||
PostPolicy: b.PostPolicy,
|
||||
Visible: b.Visible,
|
||||
}
|
||||
}
|
||||
|
||||
// AdminListBoards 板块列表(管理端:含隐藏板块;附帖子计数)
|
||||
func (h *Handlers) AdminListBoards(c *gin.Context) {
|
||||
boards, err := h.Board.List()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取板块列表失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"boards": nonNilSlice(boards)})
|
||||
}
|
||||
|
||||
// AdminCreateBoard 创建板块
|
||||
func (h *Handlers) AdminCreateBoard(c *gin.Context) {
|
||||
var body adminBoardBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
board, err := h.Board.Create(toBoardInput(body))
|
||||
if err != nil {
|
||||
respondBoardError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"board": board})
|
||||
}
|
||||
|
||||
// AdminUpdateBoard 更新板块
|
||||
func (h *Handlers) AdminUpdateBoard(c *gin.Context) {
|
||||
id, ok := parseBoardID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body adminBoardBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
board, err := h.Board.Update(id, toBoardInput(body))
|
||||
if err != nil {
|
||||
respondBoardError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"board": board})
|
||||
}
|
||||
|
||||
// AdminDeleteBoard 软删板块(仍有帖子则拒绝)
|
||||
func (h *Handlers) AdminDeleteBoard(c *gin.Context) {
|
||||
id, ok := parseBoardID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Board.Delete(id); err != nil {
|
||||
respondBoardError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// adminReorderBody 排序请求体
|
||||
type adminReorderBody struct {
|
||||
IDs []uint `json:"ids"`
|
||||
}
|
||||
|
||||
// AdminReorderBoards 批量设置 sort_order
|
||||
func (h *Handlers) AdminReorderBoards(c *gin.Context) {
|
||||
var body adminReorderBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.Board.Reorder(body.IDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "排序失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminListBoardModerators 板块版主列表
|
||||
func (h *Handlers) AdminListBoardModerators(c *gin.Context) {
|
||||
id, ok := parseBoardID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mods, err := h.Board.ListModerators(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取版主失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"moderators": nonNilSlice(mods)})
|
||||
}
|
||||
|
||||
// adminBoardModBody 添加版主请求体(按用户名定位)
|
||||
type adminBoardModBody struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// AdminAddBoardModerator 按用户名添加板块版主
|
||||
func (h *Handlers) AdminAddBoardModerator(c *gin.Context) {
|
||||
boardID, ok := parseBoardID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body adminBoardModBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
userID := h.Auth.GetUserIDByUsername(body.Username)
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if err := h.Board.AddModerator(boardID, userID); err != nil {
|
||||
respondBoardError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminRemoveBoardModerator 移除版主
|
||||
func (h *Handlers) AdminRemoveBoardModerator(c *gin.Context) {
|
||||
boardID, ok := parseBoardID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
uid, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||
if err != nil || uid == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
|
||||
return
|
||||
}
|
||||
if err := h.Board.RemoveModerator(boardID, uint(uid)); err != nil {
|
||||
respondBoardError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// parseBoardID 解析路径中的板块 ID
|
||||
func parseBoardID(c *gin.Context) (uint, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的板块 ID"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// respondBoardError 板块业务错误映射
|
||||
func respondBoardError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "板块不存在"})
|
||||
case errors.Is(err, service.ErrBoardNameRequired),
|
||||
errors.Is(err, service.ErrProtectedOwner):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrBoardInUse):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
|
||||
}
|
||||
}
|
||||
139
backend/handler/admin_legacy_import.go
Normal file
139
backend/handler/admin_legacy_import.go
Normal 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
|
||||
}
|
||||
@@ -137,7 +137,13 @@ func (h *Handlers) Register(c *gin.Context) {
|
||||
}
|
||||
if security.VerifyEmail {
|
||||
if err = h.Ops.ConsumeCode(req.Email, "register", req.Code); err != nil {
|
||||
c.JSON(400, gin.H{"error": "请先完成邮箱验证"})
|
||||
var te *service.CodeThrottleError
|
||||
if errors.As(err, &te) {
|
||||
c.Header("Retry-After", strconv.Itoa(te.Wait))
|
||||
c.JSON(429, gin.H{"error": te.Error(), "retry_after": te.Wait})
|
||||
} else {
|
||||
c.JSON(400, gin.H{"error": "请先完成邮箱验证"})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type Handlers struct {
|
||||
Points *service.PointsService
|
||||
Setting *service.SettingService
|
||||
AdminUser *service.AdminUserService
|
||||
LegacyImport *service.LegacyImportService
|
||||
Moderation *service.ModerationService
|
||||
Chat *service.ChatService
|
||||
Telemetry *service.TelemetryService
|
||||
@@ -37,11 +38,14 @@ type Handlers struct {
|
||||
}
|
||||
|
||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||
// staff 直发;若开启「已过审免审」且用户已有 published 内容则直发;否则进 pending。
|
||||
// staff 直发;全站免审开启时所有人直发;否则若开启「已过审免审」且用户已有 published 内容则直发;否则进 pending。
|
||||
func (h *Handlers) resolvePublishStatus(userID uint, role string) string {
|
||||
if model.IsStaff(model.Role(role)) {
|
||||
return model.ContentStatusPublished
|
||||
}
|
||||
if withoutReview, err := h.Setting.PublishWithoutReview(); err == nil && withoutReview {
|
||||
return model.ContentStatusPublished
|
||||
}
|
||||
trust, err := h.Setting.TrustReviewedPublish()
|
||||
if err != nil || !trust {
|
||||
return model.ContentStatusPending
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
@@ -202,6 +203,10 @@ func (h *Handlers) BusinessQuota(c *gin.Context) {
|
||||
}
|
||||
p := c.FullPath()
|
||||
user := middleware.CurrentUser(c)
|
||||
if user != nil && model.IsStaff(model.Role(user.Role)) {
|
||||
c.Next() // 管理角色不受发帖/评论间隔与搜索频控限制
|
||||
return
|
||||
}
|
||||
identity := "ip:" + c.ClientIP()
|
||||
if user != nil {
|
||||
identity = "user:" + strconv.Itoa(int(user.ID))
|
||||
@@ -272,7 +277,13 @@ func (h *Handlers) ResetPassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if e := h.Ops.ResetPassword(req.Email, req.Code, req.Password); e != nil {
|
||||
c.JSON(400, gin.H{"error": "验证码无效、已过期或密码格式不符合要求"})
|
||||
var te *service.CodeThrottleError
|
||||
if errors.As(e, &te) {
|
||||
c.Header("Retry-After", strconv.Itoa(te.Wait))
|
||||
c.JSON(429, gin.H{"error": te.Error(), "retry_after": te.Wait})
|
||||
} else {
|
||||
c.JSON(400, gin.H{"error": "验证码无效、已过期或密码格式不符合要求"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "密码已更新,请重新登录"})
|
||||
|
||||
@@ -23,6 +23,7 @@ func (h *Handlers) PublicSettings(c *gin.Context) {
|
||||
type updateSettingsRequest struct {
|
||||
Accent *string `json:"accent"`
|
||||
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
|
||||
PublishWithoutReview *bool `json:"publish_without_review"`
|
||||
SiteName *string `json:"site_name"`
|
||||
SiteDescription *string `json:"site_description"`
|
||||
AllowRegister *bool `json:"allow_register"`
|
||||
@@ -62,6 +63,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
return gin.H{
|
||||
"accent": saved.Accent,
|
||||
"trust_reviewed_publish": saved.TrustReviewedPublish,
|
||||
"publish_without_review": saved.PublishWithoutReview,
|
||||
"site_name": saved.SiteName,
|
||||
"site_description": saved.SiteDescription,
|
||||
"allow_register": saved.AllowRegister,
|
||||
@@ -99,7 +101,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
}
|
||||
|
||||
func (req *updateSettingsRequest) hasAny() bool {
|
||||
return req.Accent != nil || req.TrustReviewedPublish != nil || req.SiteName != nil ||
|
||||
return req.Accent != nil || req.TrustReviewedPublish != nil || req.PublishWithoutReview != nil || req.SiteName != nil ||
|
||||
req.SiteDescription != nil || req.AllowRegister != nil || req.AllowComments != nil ||
|
||||
req.CommentsRequireLogin != nil || req.AllowMessages != nil || req.PostCooldownHours != nil || req.CodeBlockAutoFold != nil ||
|
||||
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
|
||||
@@ -156,6 +158,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.PublishWithoutReview != nil {
|
||||
if err := h.Setting.SetPublishWithoutReview(*req.PublishWithoutReview); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.SiteName != nil {
|
||||
if err := h.Setting.SetSiteName(*req.SiteName); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
|
||||
Reference in New Issue
Block a user