分站可单独配置回收、合作反馈和客服二维码,未配置时继承主站;Logo、图标和轮播支持直接上传。 Co-authored-by: Cursor <cursoragent@cursor.com>
160 lines
4.6 KiB
Go
160 lines
4.6 KiB
Go
package file
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"hfb_sys/backend/internal/logging"
|
|
"hfb_sys/backend/pkg/response"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type Handler struct {
|
|
service *Service
|
|
storage *Storage
|
|
objectAuthorizer ObjectAuthorizer
|
|
uploadOwnerRecorder UploadOwnerRecorder
|
|
}
|
|
|
|
// ObjectAuthorizer 校验用户是否可读取指定私有对象。
|
|
type ObjectAuthorizer func(ctx context.Context, userID uint64, key string) (bool, error)
|
|
|
|
// UploadOwnerRecorder 保存用户上传私有文件的归属。
|
|
type UploadOwnerRecorder func(ctx context.Context, userID uint64, objectKeys []string) error
|
|
|
|
func NewHandler(service *Service, storage *Storage, objectAuthorizer ObjectAuthorizer, uploadOwnerRecorder UploadOwnerRecorder) *Handler {
|
|
return &Handler{
|
|
service: service,
|
|
storage: storage,
|
|
objectAuthorizer: objectAuthorizer,
|
|
uploadOwnerRecorder: uploadOwnerRecorder,
|
|
}
|
|
}
|
|
|
|
func (h *Handler) Upload(c *gin.Context) {
|
|
header, err := c.FormFile("file")
|
|
if err != nil {
|
|
response.BadRequest(c, "请选择要上传的文件")
|
|
return
|
|
}
|
|
reader, err := header.Open()
|
|
if err != nil {
|
|
response.BadRequest(c, "文件读取失败")
|
|
return
|
|
}
|
|
defer func() {
|
|
_ = reader.Close()
|
|
}()
|
|
item, err := h.service.Upload(uploadRequest{
|
|
Context: c.Request.Context(),
|
|
Scene: c.PostForm("scene"),
|
|
Header: header,
|
|
Reader: reader,
|
|
ContentType: header.Header.Get("Content-Type"),
|
|
})
|
|
if err != nil {
|
|
writeFileError(c, err)
|
|
return
|
|
}
|
|
if userID, ok := c.Get("user_id"); ok && h.uploadOwnerRecorder != nil {
|
|
id, valid := userID.(uint64)
|
|
if !valid || h.uploadOwnerRecorder(c.Request.Context(), id, item.ObjectKeys()) != nil {
|
|
// 归属记录失败时补偿删除刚写入的对象,避免用户重试产生孤儿文件。
|
|
if h.storage != nil {
|
|
if removeErr := h.storage.RemoveObjects(c.Request.Context(), item.ObjectKeys()); removeErr != nil {
|
|
logging.FromContext(c.Request.Context()).Warn("补偿删除上传对象失败", zap.Error(removeErr))
|
|
}
|
|
}
|
|
response.ServiceUnavailable(c, "文件归属记录失败")
|
|
return
|
|
}
|
|
}
|
|
response.Created(c, item)
|
|
}
|
|
|
|
func (h *Handler) Object(c *gin.Context) {
|
|
h.writeObject(c, false)
|
|
}
|
|
|
|
func (h *Handler) PublicObject(c *gin.Context) {
|
|
h.writeObject(c, true)
|
|
}
|
|
|
|
func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
|
if h.storage == nil {
|
|
response.ServiceUnavailable(c, "文件存储未连接")
|
|
return
|
|
}
|
|
key := strings.TrimSpace(c.Query("key"))
|
|
if key == "" || strings.Contains(key, "..") {
|
|
response.BadRequest(c, "文件 key 不正确")
|
|
return
|
|
}
|
|
if publicOnly &&
|
|
!strings.HasPrefix(key, "home-banner/") &&
|
|
!strings.HasPrefix(key, "avatar/") &&
|
|
!strings.HasPrefix(key, "announcement/") &&
|
|
!strings.HasPrefix(key, "mohong/") &&
|
|
!strings.HasPrefix(key, "crash/") &&
|
|
!strings.HasPrefix(key, "aw-recycle/") &&
|
|
!strings.HasPrefix(key, "cooperation-feedback/") &&
|
|
!strings.HasPrefix(key, "support-qrcode/") {
|
|
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
|
return
|
|
}
|
|
if !publicOnly {
|
|
if _, isAdmin := c.Get("admin_id"); !isAdmin {
|
|
userID, ok := c.Get("user_id")
|
|
if !ok {
|
|
response.Unauthorized(c, "缺少用户上下文")
|
|
return
|
|
}
|
|
id, ok := userID.(uint64)
|
|
if !ok || h.objectAuthorizer == nil {
|
|
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件")
|
|
return
|
|
}
|
|
allowed, err := h.objectAuthorizer(c.Request.Context(), id, key)
|
|
if err != nil {
|
|
response.ServiceUnavailable(c, "文件权限校验失败")
|
|
return
|
|
}
|
|
if !allowed {
|
|
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
object, err := h.storage.Get(c.Request.Context(), key)
|
|
if err != nil {
|
|
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
|
return
|
|
}
|
|
defer func() {
|
|
_ = object.Reader.Close()
|
|
}()
|
|
contentType := object.ContentType
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
c.Header("Content-Type", contentType)
|
|
c.Header("Cache-Control", "private, max-age=300")
|
|
c.DataFromReader(http.StatusOK, object.Size, contentType, object.Reader, nil)
|
|
}
|
|
|
|
func writeFileError(c *gin.Context, err error) {
|
|
response.RecordError(c, err)
|
|
switch {
|
|
case errors.Is(err, ErrDependencyUnavailable):
|
|
response.ServiceUnavailable(c, "文件存储未连接")
|
|
case errors.Is(err, ErrInvalidFile):
|
|
response.BadRequest(c, "文件不符合规则,仅支持 10MB 内的 JPG、PNG、WebP 或 PDF")
|
|
default:
|
|
response.Error(c, http.StatusInternalServerError, "internal_error", "文件服务暂时不可用")
|
|
}
|
|
}
|