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

550 lines
20 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.
package handler
import (
"errors"
"net/http"
"github.com/freefire/jiang13-bbs/realtime"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// GET /api/settings
func (h *Handlers) PublicSettings(c *gin.Context) {
s, err := h.Setting.Public()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
return
}
c.JSON(http.StatusOK, s)
}
// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖其它设置
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"`
AllowComments *bool `json:"allow_comments"`
CommentsRequireLogin *bool `json:"comments_require_login"`
AllowMessages *bool `json:"allow_messages"`
PostCooldownHours *int `json:"post_cooldown_hours"`
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
CodeBlockFoldLines *int `json:"code_block_fold_lines"`
UIAnimations *bool `json:"ui_animations"`
AnimCodeFold *bool `json:"anim_code_fold"`
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
AnimChrome *bool `json:"anim_chrome"`
PostLinkNewTab *bool `json:"post_link_new_tab"`
AttachmentExtLimit *bool `json:"attachment_ext_limit"`
AttachmentExts *[]string `json:"attachment_exts"`
AttachmentMaxMB *int `json:"attachment_max_mb"`
AttachmentMaxCount *int `json:"attachment_max_count"`
ImageMaxMB *int `json:"image_max_mb"`
BgSiteURL *string `json:"bg_site_url"`
BgSiteMode *string `json:"bg_site_mode"`
BgAdminURL *string `json:"bg_admin_url"`
BgAdminMode *string `json:"bg_admin_mode"`
SiteWordmark *string `json:"site_wordmark"`
SiteSlogan *string `json:"site_slogan"`
SiteKeywords *[]string `json:"site_keywords"`
LogoLightURL *string `json:"logo_light_url"`
LogoDarkURL *string `json:"logo_dark_url"`
FaviconURL *string `json:"favicon_url"`
BrandMark *string `json:"brand_mark"`
BrandLogoSize *string `json:"brand_logo_size"`
BrandLogoFit *string `json:"brand_logo_fit"`
FooterLinks *[]service.FooterLink `json:"footer_links"`
}
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,
"allow_comments": saved.AllowComments,
"comments_require_login": saved.CommentsRequireLogin,
"allow_messages": saved.AllowMessages,
"post_cooldown_hours": saved.PostCooldownHours,
"code_block_auto_fold": saved.CodeBlockAutoFold,
"code_block_fold_lines": saved.CodeBlockFoldLines,
"ui_animations": saved.UIAnimations,
"anim_code_fold": saved.AnimCodeFold,
"anim_smooth_scroll": saved.AnimSmoothScroll,
"anim_chrome": saved.AnimChrome,
"post_link_new_tab": saved.PostLinkNewTab,
"attachment_ext_limit": saved.AttachmentExtLimit,
"attachment_exts": saved.AttachmentExts,
"attachment_max_mb": saved.AttachmentMaxMB,
"attachment_max_count": saved.AttachmentMaxCount,
"image_max_mb": saved.ImageMaxMB,
"bg_site_url": saved.BgSiteURL,
"bg_site_mode": saved.BgSiteMode,
"bg_admin_url": saved.BgAdminURL,
"bg_admin_mode": saved.BgAdminMode,
"site_wordmark": saved.SiteWordmark,
"site_slogan": saved.SiteSlogan,
"site_keywords": saved.SiteKeywords,
"logo_light_url": saved.LogoLightURL,
"logo_dark_url": saved.LogoDarkURL,
"favicon_url": saved.FaviconURL,
"brand_mark": saved.BrandMark,
"brand_logo_size": saved.BrandLogoSize,
"brand_logo_fit": saved.BrandLogoFit,
"footer_links": saved.FooterLinks,
}
}
func (req *updateSettingsRequest) hasAny() bool {
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 ||
req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil ||
req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil ||
req.BgSiteURL != nil || req.BgSiteMode != nil ||
req.BgAdminURL != nil || req.BgAdminMode != nil ||
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
req.FooterLinks != nil
}
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
func (h *Handlers) AdminGetSettings(c *gin.Context) {
saved, err := h.Setting.Public()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
return
}
c.JSON(http.StatusOK, settingsPayload(saved))
}
// PUT /api/admin/settings
func (h *Handlers) UpdateSettings(c *gin.Context) {
var req updateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if !req.hasAny() {
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"})
return
}
if req.AllowRegister != nil || req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil || req.AttachmentMaxCount != nil || req.ImageMaxMB != nil {
c.JSON(400, gin.H{"error": "请在访问与安全或文件与存储页通过版本校验保存这些设置"})
return
}
if req.Accent != nil {
if err := h.Setting.SetAccent(*req.Accent); err != nil {
if errors.Is(err, service.ErrInvalidAccent) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.TrustReviewedPublish != nil {
if err := h.Setting.SetTrustReviewedPublish(*req.TrustReviewedPublish); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
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) {
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称过长或无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.SiteDescription != nil {
if err := h.Setting.SetSiteDescription(*req.SiteDescription); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "站点简介过长"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.AllowRegister != nil {
if err := h.Setting.SetAllowRegister(*req.AllowRegister); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.AllowComments != nil {
if err := h.Setting.SetAllowComments(*req.AllowComments); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.CommentsRequireLogin != nil {
if err := h.Setting.SetCommentsRequireLogin(*req.CommentsRequireLogin); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.AllowMessages != nil {
if err := h.Setting.SetAllowMessages(*req.AllowMessages); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.PostCooldownHours != nil {
if err := h.Setting.SetPostCooldownHours(*req.PostCooldownHours); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "冷静期须为 0–168 小时"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.CodeBlockAutoFold != nil {
if err := h.Setting.SetCodeBlockAutoFold(*req.CodeBlockAutoFold); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.CodeBlockFoldLines != nil {
if err := h.Setting.SetCodeBlockFoldLines(*req.CodeBlockFoldLines); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "折叠行数须为 0–100"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.UIAnimations != nil {
if err := h.Setting.SetUIAnimations(*req.UIAnimations); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.AnimCodeFold != nil {
if err := h.Setting.SetAnimCodeFold(*req.AnimCodeFold); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.AnimSmoothScroll != nil {
if err := h.Setting.SetAnimSmoothScroll(*req.AnimSmoothScroll); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.AnimChrome != nil {
if err := h.Setting.SetAnimChrome(*req.AnimChrome); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.PostLinkNewTab != nil {
if err := h.Setting.SetPostLinkNewTab(*req.PostLinkNewTab); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.AttachmentExtLimit != nil {
if err := h.Setting.SetAttachmentExtLimit(*req.AttachmentExtLimit); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.AttachmentExts != nil {
if err := h.Setting.SetAttachmentExts(*req.AttachmentExts); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "扩展名无效或数量超限(最多 80 个,仅字母数字)"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.AttachmentMaxMB != nil {
if err := h.Setting.SetAttachmentMaxMB(*req.AttachmentMaxMB); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "附件上限须至少 1MB"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.AttachmentMaxCount != nil {
if err := h.Setting.SetAttachmentMaxCount(*req.AttachmentMaxCount); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "每帖附件数须为 1–20"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.ImageMaxMB != nil {
if err := h.Setting.SetImageMaxMB(*req.ImageMaxMB); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "插图上限须至少 1MB"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.BgSiteURL != nil {
if err := h.applyBgURL(service.BgSurfaceSite, *req.BgSiteURL); err != nil {
writeBgSettingError(c, err)
return
}
}
if req.BgAdminURL != nil {
if err := h.applyBgURL(service.BgSurfaceAdmin, *req.BgAdminURL); err != nil {
writeBgSettingError(c, err)
return
}
}
if req.BgSiteMode != nil {
if err := h.Setting.SetBgSiteMode(*req.BgSiteMode); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的背景铺放模式"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.BgAdminMode != nil {
if err := h.Setting.SetBgAdminMode(*req.BgAdminMode); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的背景铺放模式"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.SiteWordmark != nil {
if err := h.Setting.SetSiteWordmark(*req.SiteWordmark); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "字标过长(最多 16 字)"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.SiteSlogan != nil {
if err := h.Setting.SetSiteSlogan(*req.SiteSlogan); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "标语过长(最多 32 字)"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.SiteKeywords != nil {
if err := h.Setting.SetSiteKeywords(*req.SiteKeywords); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "关键词过多或单个过长(最多 16 个,每个 20 字)"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.LogoLightURL != nil {
if err := h.applyBrandURL(service.BrandSlotLight, *req.LogoLightURL); err != nil {
writeBrandSettingError(c, err)
return
}
}
if req.LogoDarkURL != nil {
if err := h.applyBrandURL(service.BrandSlotDark, *req.LogoDarkURL); err != nil {
writeBrandSettingError(c, err)
return
}
}
if req.FaviconURL != nil {
if err := h.applyBrandURL(service.BrandSlotFavicon, *req.FaviconURL); err != nil {
writeBrandSettingError(c, err)
return
}
}
if req.BrandMark != nil {
if err := h.Setting.SetBrandMark(*req.BrandMark); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "页眉形式无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.BrandLogoFit != nil {
if err := h.Setting.SetBrandLogoFit(*req.BrandLogoFit); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "铺法无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.BrandLogoSize != nil {
if err := h.Setting.SetBrandLogoSize(*req.BrandLogoSize); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "画幅尺寸无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.FooterLinks != nil {
if err := h.Setting.SetFooterLinks(*req.FooterLinks); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "页脚链接无效(最多 8 条,文案 32 字,地址须为站内路径或 http(s))"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
saved, err := h.Setting.Public()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
return
}
payload := settingsPayload(saved)
h.Hub.BroadcastAll(realtime.Envelope{
Type: realtime.EventSettingsChanged,
Data: payload,
})
out := settingsPayload(saved)
out["ok"] = true
c.JSON(http.StatusOK, out)
}
func writeBgSettingError(c *gin.Context, err error) {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "背景图地址无效"})
return
}
if errors.Is(err, service.ErrBgFileMissing) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
}
// applyBgURL 写入对应轨的背景 URL,并删除不再被引用的旧文件
func (h *Handlers) applyBgURL(surface, url string) error {
u, ok := service.NormalizeBgURL(url)
if !ok {
return service.ErrInvalidSiteSetting
}
if u != "" && !h.Upload.BackgroundFileExists(u) {
return service.ErrBgFileMissing
}
oldSite, err := h.Setting.BgSiteURL()
if err != nil {
return err
}
oldAdmin, err := h.Setting.BgAdminURL()
if err != nil {
return err
}
if surface == service.BgSurfaceAdmin {
if err := h.Setting.SetBgAdminURL(u); err != nil {
return err
}
h.Upload.RemoveBackgroundIfUnused(oldAdmin, oldSite, u)
return nil
}
if err := h.Setting.SetBgSiteURL(u); err != nil {
return err
}
h.Upload.RemoveBackgroundIfUnused(oldSite, u, oldAdmin)
return nil
}
func writeBrandSettingError(c *gin.Context, err error) {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "品牌图地址无效"})
return
}
if errors.Is(err, service.ErrBrandFileMissing) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
}
// applyBrandURL 写入对应槽位,并删除不再被引用的旧文件
func (h *Handlers) applyBrandURL(slot, url string) error {
u, ok := service.NormalizeBrandURL(slot, url)
if !ok {
return service.ErrInvalidSiteSetting
}
if u != "" && !h.Upload.BrandFileExists(u) {
return service.ErrBrandFileMissing
}
oldLight, err := h.Setting.LogoLightURL()
if err != nil {
return err
}
oldDark, err := h.Setting.LogoDarkURL()
if err != nil {
return err
}
oldFav, err := h.Setting.FaviconURL()
if err != nil {
return err
}
var old string
switch slot {
case service.BrandSlotDark:
old = oldDark
if err := h.Setting.SetLogoDarkURL(u); err != nil {
return err
}
oldDark = u
case service.BrandSlotFavicon:
old = oldFav
if err := h.Setting.SetFaviconURL(u); err != nil {
return err
}
oldFav = u
default:
old = oldLight
if err := h.Setting.SetLogoLightURL(u); err != nil {
return err
}
oldLight = u
}
h.Upload.RemoveBrandIfUnused(old, oldLight, oldDark, oldFav)
return nil
}