Files
kevin 067ade546f 后台管理支持上传与清空网站 Logo
- 新增 PUT /api/site/logo(multipart、仅图片,立即生效)与 DELETE /api/site/logo,均仅管理员可用
- Logo 文件引用计数自动管理:上传占用、替换/清空/切换外链释放旧文件;PUT /site 手填地址同样处理,挂载不存在的本地文件返回 400
- 抽出 file.IsImageUpload 供头像与 Logo 共用(按文件头探测图片)
- 后台管理 Logo 区保留外链输入,新增上传/清空按钮(无裁剪,校验类型与大小),三语文案补齐
- 补充引用计数、权限与校验测试并重新生成 Swagger 文档
2026-09-21 21:10:23 +08:00

238 lines
7.7 KiB
Go

// Package site 提供站点信息(站名、Logo、页脚文案)的读取与管理员更新接口。
package site
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"rill/internal/auth"
"rill/internal/config"
"rill/internal/file"
"rill/internal/httpx"
"rill/internal/model"
)
// UpdateRequest 更新站点信息请求,空 logo/footer 表示回退前端默认展示。
type UpdateRequest struct {
SiteName string `json:"site_name" binding:"required,max=100" example:"Rill"`
Logo string `json:"logo" binding:"omitempty,max=500" example:"https://example.com/logo.png"`
Footer string `json:"footer" binding:"omitempty,max=1000" example:"Copyright © Rill"`
}
// @Summary Get site settings
// @Description Public site settings: site name, logo URL, and footer text. Returns built-in defaults when the settings row is missing.
// @Tags public
// @Produce json
// @Success 200 {object} model.SiteSetting
// @Failure 500 {object} httpx.ErrorResponse
// @Router /site [get]
func Get(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
var setting model.SiteSetting
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, defaultSetting())
return
}
httpx.RespondDBError(c, err)
return
}
c.JSON(http.StatusOK, setting)
}
}
// @Summary Update site settings
// @Description Admin only. Update the site name, logo URL, and footer text; logo files hosted on this site have their reference count managed automatically. Returns the updated settings.
// @Tags admin
// @Accept json
// @Produce json
// @Param site body site.UpdateRequest true "Site settings"
// @Success 200 {object} model.SiteSetting
// @Failure 400 {object} httpx.ErrorResponse "invalid request or logo file not found"
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
// @Failure 500 {object} httpx.ErrorResponse
// @Router /site [put]
func Update(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
var req UpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()})
return
}
req.SiteName = strings.TrimSpace(req.SiteName)
if req.SiteName == "" {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: site_name is required"})
return
}
ctx := c.Request.Context()
var setting model.SiteSetting
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
httpx.RespondDBError(c, err)
return
}
setting = defaultSetting()
}
oldID, hasOld := file.ParseLocalURL(cfg.API.Prefix, setting.Logo)
newID, hasNew := file.ParseLocalURL(cfg.API.Prefix, req.Logo)
setting.SiteName = req.SiteName
setting.Logo = req.Logo
setting.Footer = req.Footer
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if hasNew && (!hasOld || newID != oldID) {
if err := file.Acquire(ctx, tx, newID); err != nil {
return err
}
}
if hasOld && (!hasNew || oldID != newID) {
if err := file.Release(ctx, tx, oldID); err != nil {
return err
}
}
return tx.Save(&setting).Error
})
if err != nil {
if errors.Is(err, file.ErrFileNotFound) {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: logo file not found"})
return
}
httpx.RespondDBError(c, err)
return
}
c.JSON(http.StatusOK, setting)
}
}
// @Summary Upload site logo
// @Description Admin only. Upload an image as the site logo (multipart field file, image only); the logo takes effect immediately and file references are managed automatically.
// @Tags admin
// @Accept mpfd
// @Produce json
// @Param file formData file true "Logo image"
// @Success 200 {object} model.SiteSetting
// @Failure 400 {object} httpx.ErrorResponse "invalid request, empty file, or not an image"
// @Failure 413 {object} httpx.ErrorResponse "file too large"
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
// @Failure 500 {object} httpx.ErrorResponse
// @Router /site/logo [put]
func UploadLogo(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
current, ok := auth.CurrentUser(c)
if !ok {
httpx.RespondUnauthorized(c)
return
}
header, ok := file.ReadUpload(c, cfg)
if !ok {
return
}
if isImage, err := file.IsImageUpload(header); err != nil {
httpx.RespondServerError(c, err, "读取上传图片失败")
return
} else if !isImage {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "logo must be an image"})
return
}
src, err := header.Open()
if err != nil {
httpx.RespondServerError(c, err, "打开上传图片失败")
return
}
defer src.Close()
ctx := c.Request.Context()
saved, err := file.Save(ctx, db, cfg, file.OperatorOf(c, current), header.Filename, src)
if err != nil {
file.RespondSaveError(c, err)
return
}
var setting model.SiteSetting
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
httpx.RespondDBError(c, err)
return
}
setting = defaultSetting()
}
oldID, hasOld := file.ParseLocalURL(cfg.API.Prefix, setting.Logo)
setting.Logo = file.URL(cfg.API.Prefix, saved.ID)
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if !hasOld || oldID != saved.ID {
if err := file.Acquire(ctx, tx, saved.ID); err != nil {
return err
}
}
if hasOld && oldID != saved.ID {
if err := file.Release(ctx, tx, oldID); err != nil {
return err
}
}
return tx.Save(&setting).Error
})
if err != nil {
httpx.RespondDBError(c, err)
return
}
c.JSON(http.StatusOK, setting)
}
}
// @Summary Delete site logo
// @Description Admin only. Clear the site logo and release the reference of the locally stored logo file.
// @Tags admin
// @Produce json
// @Success 200 {object} model.SiteSetting
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
// @Failure 500 {object} httpx.ErrorResponse
// @Router /site/logo [delete]
func DeleteLogo(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
var setting model.SiteSetting
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
httpx.RespondDBError(c, err)
return
}
setting = defaultSetting()
}
oldID, hasOld := file.ParseLocalURL(cfg.API.Prefix, setting.Logo)
setting.Logo = ""
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if hasOld {
if err := file.Release(ctx, tx, oldID); err != nil {
return err
}
}
return tx.Save(&setting).Error
})
if err != nil {
httpx.RespondDBError(c, err)
return
}
c.JSON(http.StatusOK, setting)
}
}
// defaultSetting 设置行缺失时的内置默认值。
func defaultSetting() model.SiteSetting {
return model.SiteSetting{ID: model.SiteSettingID, SiteName: "Rill"}
}