订单接口最小化与私有文件访问加固
- 订单列表使用独立最小 DTO 并分页,号主待办提供独立接口与统计 - 用户 token 增加版本控制,冻结/改密/退出即时撤销会话 - 移除 URL token 传参,SSE 与接口统一使用 HttpOnly Cookie - 私有文件按上传归属与业务关联授权,收款凭证转私有访问并校验归属 - 公开商品接口返回最小字段,隐藏号主身份与内部状态 - 每日清理超过 30 天未关联业务的上传归属,上传归属失败时补偿删除对象
This commit is contained in:
@@ -8,4 +8,13 @@ type UploadDTO struct {
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
Size int64 `json:"size"`
|
||||
objectKeys []string
|
||||
}
|
||||
|
||||
// ObjectKeys 返回上传产生的全部对象键,包含图片缩略图与中图变体。
|
||||
func (d *UploadDTO) ObjectKeys() []string {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), d.objectKeys...)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
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
|
||||
service *Service
|
||||
storage *Storage
|
||||
objectAuthorizer ObjectAuthorizer
|
||||
uploadOwnerRecorder UploadOwnerRecorder
|
||||
}
|
||||
|
||||
func NewHandler(service *Service, storage *Storage) *Handler {
|
||||
return &Handler{service: service, storage: storage}
|
||||
// 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) {
|
||||
@@ -44,6 +60,19 @@ func (h *Handler) Upload(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -68,7 +97,6 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
||||
if publicOnly &&
|
||||
!strings.HasPrefix(key, "home-banner/") &&
|
||||
!strings.HasPrefix(key, "avatar/") &&
|
||||
!strings.HasPrefix(key, "payment-cert/") &&
|
||||
!strings.HasPrefix(key, "announcement/") &&
|
||||
!strings.HasPrefix(key, "mohong/") &&
|
||||
!strings.HasPrefix(key, "crash/") &&
|
||||
@@ -77,6 +105,29 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
||||
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", "文件不存在或暂不可访问")
|
||||
|
||||
@@ -61,6 +61,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
||||
}
|
||||
var thumbnailURL string
|
||||
var mediumURL string
|
||||
objectKeys := []string{key}
|
||||
for _, variant := range generateImageVariants(key, data, contentType) {
|
||||
err := s.storage.PutObject(req.Context, variant.Key, bytes.NewReader(variant.Content), int64(len(variant.Content)), variant.ContentType, map[string]string{
|
||||
"source-object": key,
|
||||
@@ -68,6 +69,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
objectKeys = append(objectKeys, variant.Key)
|
||||
if strings.Contains(variant.Key, "."+ImageVariantThumb+".") {
|
||||
thumbnailURL = fileURLForScene(scene, variant.Key)
|
||||
}
|
||||
@@ -84,6 +86,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
||||
Filename: req.Header.Filename,
|
||||
ContentType: contentType,
|
||||
Size: int64(len(data)),
|
||||
objectKeys: objectKeys,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -106,7 +109,7 @@ func normalizeContentType(contentType string, data []byte) string {
|
||||
|
||||
func fileURLForScene(scene string, key string) string {
|
||||
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
|
||||
if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
|
||||
if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
|
||||
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
|
||||
}
|
||||
return fileURL
|
||||
|
||||
@@ -157,6 +157,36 @@ func (s *Storage) Get(ctx context.Context, key string) (*Object, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RemoveObjects 删除主存储与镜像中的对象,返回第一个错误;对象不存在视为成功。
|
||||
func (s *Storage) RemoveObjects(ctx context.Context, keys []string) error {
|
||||
var firstErr error
|
||||
for _, key := range keys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.removeObject(ctx, key); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if s.mirror != nil {
|
||||
if err := s.mirror.removeObject(ctx, key); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *Storage) removeObject(ctx context.Context, key string) error {
|
||||
if err := s.ensureBucketReady(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{})
|
||||
if minio.ToErrorResponse(err).Code == "NoSuchKey" {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Storage) ensureBucket(ctx context.Context) error {
|
||||
exists, err := s.client.BucketExists(ctx, s.bucket)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user