feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS

站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 05:27:33 +08:00
parent 5f7193042f
commit d784b0ea7a
68 changed files with 2588 additions and 768 deletions

View File

@@ -26,6 +26,8 @@ func (h *Handlers) AdminListUsers(c *gin.Context) {
Keyword: strings.TrimSpace(c.Query("q")),
Role: c.Query("role"),
Status: c.Query("status"),
Sort: c.Query("sort"),
Dir: c.Query("order"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取用户列表失败"})

View File

@@ -4,14 +4,23 @@ import (
"net/http"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/version"
"github.com/gin-gonic/gin"
)
// Health 健康检查
// Health 健康检查(官方运行时 Docker HEALTHCHECK 与反代 /health)
func (h *Handlers) Health(c *gin.Context) {
if err := model.PingDB(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "error", "error": err.Error()})
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "error",
"error": err.Error(),
"version": version.Version,
})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"version": version.Version,
"commit": version.Commit,
})
}

View File

@@ -41,8 +41,10 @@ type updateSettingsRequest struct {
AttachmentMaxMB *int `json:"attachment_max_mb"`
AttachmentMaxCount *int `json:"attachment_max_count"`
ImageMaxMB *int `json:"image_max_mb"`
CustomCSS *string `json:"custom_css"`
CustomJS *string `json:"custom_js"`
BgSiteURL *string `json:"bg_site_url"`
BgSiteMode *string `json:"bg_site_mode"`
BgAdminURL *string `json:"bg_admin_url"`
BgAdminMode *string `json:"bg_admin_mode"`
TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播
}
@@ -68,8 +70,10 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"attachment_max_mb": saved.AttachmentMaxMB,
"attachment_max_count": saved.AttachmentMaxCount,
"image_max_mb": saved.ImageMaxMB,
"custom_css": saved.CustomCSS,
"custom_js": saved.CustomJS,
"bg_site_url": saved.BgSiteURL,
"bg_site_mode": saved.BgSiteMode,
"bg_admin_url": saved.BgAdminURL,
"bg_admin_mode": saved.BgAdminMode,
}
}
@@ -80,8 +84,9 @@ func (req *updateSettingsRequest) hasAny() bool {
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.CustomCSS != nil ||
req.CustomJS != nil || req.TimelineGitImport != nil
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil ||
req.BgSiteURL != nil || req.BgSiteMode != nil ||
req.BgAdminURL != nil || req.BgAdminMode != nil || req.TimelineGitImport != nil
}
// AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings)
@@ -269,20 +274,32 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
if req.CustomCSS != nil {
if err := h.Setting.SetCustomCSS(*req.CustomCSS); err != nil {
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": "自定义 CSS 过长或含非法闭合标签"})
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的背景铺放模式"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.CustomJS != nil {
if err := h.Setting.SetCustomJS(*req.CustomJS); err != nil {
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": "自定义 JS 过长或含非法闭合标签"})
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的背景铺放模式"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
@@ -318,3 +335,46 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
}
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
}

View File

@@ -7,6 +7,7 @@ import (
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/realtime"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
@@ -165,3 +166,67 @@ func (h *Handlers) DeleteAttachment(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// UploadBackground 超管上传站点背景图(JPEG/PNG/WebP),并立即绑定前台或后台
func (h *Handlers) UploadBackground(c *gin.Context) {
surface, ok := service.NormalizeBgSurface(c.PostForm("surface"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定前台或后台(surface=site|admin)"})
return
}
limit := int64(service.BackgroundMaxBytes + 4096)
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
fh, err := c.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大或格式不正确"})
return
}
if fh.Size > service.BackgroundMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"})
return
}
f, err := fh.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
return
}
defer f.Close()
url, err := h.Upload.SaveBackground(f)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.applyBgURL(surface, url); err != nil {
writeBgSettingError(c, err)
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
out["url"] = url
c.JSON(http.StatusOK, out)
}