feat(media): 配置项与上传凭证尺寸软校验

补充 MediaConfig 默认值,并在签发上传凭证时按用途检查声明大小。
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-24 23:09:46 +08:00
parent e1b3a7ebd2
commit 6cfd6309d8
2 changed files with 95 additions and 0 deletions

View File

@ -25,6 +25,7 @@ type Config struct {
RateLimit RateLimitConfig `yaml:"rate_limit"` RateLimit RateLimitConfig `yaml:"rate_limit"`
Message MessageConfig `yaml:"message"` Message MessageConfig `yaml:"message"`
ObjectStorage ObjectStorageConfig `yaml:"object_storage"` ObjectStorage ObjectStorageConfig `yaml:"object_storage"`
Media MediaConfig `yaml:"media"`
SMS SMSConfig `yaml:"sms"` SMS SMSConfig `yaml:"sms"`
} }
@ -135,6 +136,23 @@ type ObjectStorageConfig struct {
ChatExpireDays int `yaml:"chat_expire_days"` ChatExpireDays int `yaml:"chat_expire_days"`
} }
// MediaConfig is server-side media detect (users) + compress (bots).
// Client App still compresses user uploads; see docs/media.md.
type MediaConfig struct {
Enabled bool `yaml:"enabled"`
UserReject bool `yaml:"user_reject"` // false=log only; true=return 40005
BotCompress bool `yaml:"bot_compress"`
ImageMaxEdge int `yaml:"image_max_edge"`
VideoMaxEdge int `yaml:"video_max_edge"`
AvatarMaxEdge int `yaml:"avatar_max_edge"`
ImageMaxBytes int64 `yaml:"image_max_bytes"`
VideoMaxBytes int64 `yaml:"video_max_bytes"`
AvatarMaxBytes int64 `yaml:"avatar_max_bytes"`
JPEGQuality int `yaml:"jpeg_quality"`
VideoCRF int `yaml:"video_crf"`
FFmpegPath string `yaml:"ffmpeg_path"`
}
type MessageConfig struct { type MessageConfig struct {
MaxContentSize int `yaml:"max_content_size"` MaxContentSize int `yaml:"max_content_size"`
SyncLimit int `yaml:"sync_limit"` SyncLimit int `yaml:"sync_limit"`
@ -314,6 +332,42 @@ func applyDefaults(cfg *Config) {
if cfg.RateLimit.UploadPerSecond < 1 { if cfg.RateLimit.UploadPerSecond < 1 {
cfg.RateLimit.UploadPerSecond = 10 cfg.RateLimit.UploadPerSecond = 10
} }
applyMediaDefaults(&cfg.Media)
}
func applyMediaDefaults(m *MediaConfig) {
if m == nil {
return
}
// Numeric/path defaults only. Toggle via YAML media.enabled / media.bot_compress
// (configs ship with enabled: true). Explicit enabled: false stays off.
if m.ImageMaxEdge <= 0 {
m.ImageMaxEdge = 1080
}
if m.VideoMaxEdge <= 0 {
m.VideoMaxEdge = 720
}
if m.AvatarMaxEdge <= 0 {
m.AvatarMaxEdge = 200
}
if m.ImageMaxBytes <= 0 {
m.ImageMaxBytes = 10 << 20
}
if m.VideoMaxBytes <= 0 {
m.VideoMaxBytes = 100 << 20
}
if m.AvatarMaxBytes <= 0 {
m.AvatarMaxBytes = 2 << 20
}
if m.JPEGQuality <= 0 {
m.JPEGQuality = 85
}
if m.VideoCRF <= 0 {
m.VideoCRF = 23
}
if strings.TrimSpace(m.FFmpegPath) == "" {
m.FFmpegPath = "ffmpeg"
}
} }
func parseEnv(raw string) string { func parseEnv(raw string) string {

View File

@ -7,8 +7,11 @@ import (
"spark-server/internal/module/upload/entity" "spark-server/internal/module/upload/entity"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n" "spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/media"
"spark-server/internal/pkg/reqctx" "spark-server/internal/pkg/reqctx"
"spark-server/internal/pkg/validate" "spark-server/internal/pkg/validate"
"go.uber.org/zap"
) )
// CreateInput requests a single-object Presigned PUT. The client does not choose the key. // CreateInput requests a single-object Presigned PUT. The client does not choose the key.
@ -43,6 +46,9 @@ func (s *Service) issuePut(ctx context.Context, userID int64, in *CreateInput) (
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := s.checkDeclaredSize(ctx, in.Purpose, in.Size); err != nil {
return nil, err
}
name := reqctx.NewObjectName() name := reqctx.NewObjectName()
ownerID := userID ownerID := userID
if in.Purpose == entity.PurposeGroupAvatar { if in.Purpose == entity.PurposeGroupAvatar {
@ -69,6 +75,41 @@ func (s *Service) issuePut(ctx context.Context, userID int64, in *CreateInput) (
}, nil }, nil
} }
// checkDeclaredSize applies soft media policy on credential size (no dimensions at this stage).
func (s *Service) checkDeclaredSize(ctx context.Context, purpose string, size int64) error {
if s == nil || !s.media.Enabled {
return nil
}
var v *media.Violation
switch purpose {
case entity.PurposeAvatar, entity.PurposeGroupAvatar, entity.PurposeMomentCover:
v = s.media.CheckAvatarMeta(0, 0, size)
case entity.PurposeChatVideo, entity.PurposeMomentVideo, entity.PurposeSquareVideo:
v = s.media.CheckVideoMeta(0, 0, size, 0)
case entity.PurposeChatImage, entity.PurposeMomentImage, entity.PurposeSquareImage, entity.PurposeChatGIF:
v = s.media.CheckImageMeta(0, 0, size)
default:
return nil
}
if v == nil {
return nil
}
log := s.log
if log == nil {
log = zap.NewNop()
}
log.Warn("upload media size soft-check",
zap.String("purpose", purpose),
zap.String("reason", v.Reason),
zap.Int64("size", v.Size),
zap.Int64("max_size", v.MaxSize),
)
if !s.media.UserReject {
return nil
}
return errs.ErrMediaOversized.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyMediaOversized, nil))
}
func matchSpec(ctx context.Context, in *CreateInput) (entity.Spec, string, error) { func matchSpec(ctx context.Context, in *CreateInput) (entity.Spec, string, error) {
lang := reqctx.Lang(ctx) lang := reqctx.Lang(ctx)
spec, ok := entity.LookupSpec(in.Purpose) spec, ok := entity.LookupSpec(in.Purpose)