- 订单列表使用独立最小 DTO 并分页,号主待办提供独立接口与统计 - 用户 token 增加版本控制,冻结/改密/退出即时撤销会话 - 移除 URL token 传参,SSE 与接口统一使用 HttpOnly Cookie - 私有文件按上传归属与业务关联授权,收款凭证转私有访问并校验归属 - 公开商品接口返回最小字段,隐藏号主身份与内部状态 - 每日清理超过 30 天未关联业务的上传归属,上传归属失败时补偿删除对象
135 lines
3.8 KiB
Go
135 lines
3.8 KiB
Go
package file
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
|
ErrInvalidFile = errors.New("invalid file")
|
|
)
|
|
|
|
const maxUploadSize = 10 * 1024 * 1024
|
|
|
|
var allowedContentTypes = map[string]bool{
|
|
"image/jpeg": true,
|
|
"image/png": true,
|
|
"image/webp": true,
|
|
"application/pdf": true,
|
|
}
|
|
|
|
type Service struct {
|
|
storage *Storage
|
|
}
|
|
|
|
func NewService(storage *Storage) *Service {
|
|
return &Service{storage: storage}
|
|
}
|
|
|
|
func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
|
if s.storage == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
if req.Header == nil || req.Reader == nil || req.Header.Size <= 0 {
|
|
return nil, ErrInvalidFile
|
|
}
|
|
data, err := readUploadData(req.Reader)
|
|
if err != nil || len(data) == 0 || len(data) > maxUploadSize {
|
|
return nil, ErrInvalidFile
|
|
}
|
|
contentType := normalizeContentType(req.ContentType, data)
|
|
scene := normalizeScene(req.Scene)
|
|
if !allowedContentTypes[contentType] || (scene == "chat" && !strings.HasPrefix(contentType, "image/")) {
|
|
return nil, ErrInvalidFile
|
|
}
|
|
key, err := newObjectKey(scene, req.Header.Filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.storage.PutObject(req.Context, key, bytes.NewReader(data), int64(len(data)), contentType, map[string]string{
|
|
"original-filename": req.Header.Filename,
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrDependencyUnavailable, err)
|
|
}
|
|
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,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
objectKeys = append(objectKeys, variant.Key)
|
|
if strings.Contains(variant.Key, "."+ImageVariantThumb+".") {
|
|
thumbnailURL = fileURLForScene(scene, variant.Key)
|
|
}
|
|
if strings.Contains(variant.Key, "."+ImageVariantMedium+".") {
|
|
mediumURL = fileURLForScene(scene, variant.Key)
|
|
}
|
|
}
|
|
fileURL := fileURLForScene(scene, key)
|
|
return &UploadDTO{
|
|
ObjectKey: key,
|
|
URL: fileURL,
|
|
ThumbnailURL: thumbnailURL,
|
|
MediumURL: mediumURL,
|
|
Filename: req.Header.Filename,
|
|
ContentType: contentType,
|
|
Size: int64(len(data)),
|
|
objectKeys: objectKeys,
|
|
}, nil
|
|
}
|
|
|
|
func readUploadData(reader multipart.File) ([]byte, error) {
|
|
limited := io.LimitReader(reader, maxUploadSize+1)
|
|
return io.ReadAll(limited)
|
|
}
|
|
|
|
func normalizeContentType(contentType string, data []byte) string {
|
|
contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
|
|
if allowedContentTypes[contentType] {
|
|
return contentType
|
|
}
|
|
detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]))
|
|
if allowedContentTypes[detected] {
|
|
return detected
|
|
}
|
|
return contentType
|
|
}
|
|
|
|
func fileURLForScene(scene string, key string) string {
|
|
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
|
|
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
|
|
}
|
|
|
|
func normalizeScene(scene string) string {
|
|
scene = strings.TrimSpace(strings.ToLower(scene))
|
|
switch scene {
|
|
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement":
|
|
return scene
|
|
default:
|
|
return "misc"
|
|
}
|
|
}
|
|
|
|
type uploadRequest struct {
|
|
Context context.Context
|
|
Scene string
|
|
Header *multipart.FileHeader
|
|
Reader multipart.File
|
|
ContentType string
|
|
}
|