feat(server): implement chat file TTL cleanup and enhance JPush vendor channel support
All checks were successful
Build Backend / build (push) Successful in 3m1s
Build Backend / build-docker (push) Successful in 2m10s

Add FileCleanupWorker for automatic expiration of chat files with configurable retention period and batch processing. Files uploaded via `/api/v1/uploads/files` are tracked in `uploaded_files` table with expiration timestamps, then deleted from S3 and database upon expiry. Expired file URLs are injected as `"expired": true` in message responses.

Extend JPush push notification configuration with vendor-specific channel_id support (xiaomi, huawei, oppo, vivo, meizu, honor, fcm) for differentiating system vs chat notifications. Add iOS APNs thread-id grouping configuration for notification categorization.
This commit is contained in:
lafay
2026-06-17 20:41:55 +08:00
parent d9aa4b46c3
commit a0e210feab
24 changed files with 1573 additions and 127 deletions

View File

@@ -18,6 +18,7 @@ import (
"with_you/internal/model"
"with_you/internal/pkg/s3"
"with_you/internal/repository"
"github.com/disintegration/imaging"
"go.uber.org/zap"
@@ -26,22 +27,34 @@ import (
_ "golang.org/x/image/tiff"
)
// UploadFileResult 通用文件上传结果
type UploadFileResult struct {
URL string `json:"url"`
Name string `json:"name"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
}
// UploadService 上传服务接口
type UploadService interface {
UploadImage(ctx context.Context, file *multipart.FileHeader) (string, string, string, error)
UploadImageBytes(ctx context.Context, raw []byte, filename string) (url, previewURL, previewURLLarge string, err error)
UploadAvatar(ctx context.Context, userID string, file *multipart.FileHeader) (string, error)
UploadCover(ctx context.Context, userID string, file *multipart.FileHeader) (string, error)
UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string) (*UploadFileResult, error)
GetURL(ctx context.Context, objectName string) (string, error)
Delete(ctx context.Context, objectName string) error
GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err error)
ValidateChatMessageImageSegments(segments model.MessageSegments) error
// GetExpiredURLSet 返回所有已从 S3 清理的文件 URL 集合,供 DTO 层标记失效
GetExpiredURLSet(ctx context.Context) (map[string]struct{}, error)
}
// uploadService 上传服务实现
type uploadService struct {
s3Client *s3.Client
userService UserService
s3Client *s3.Client
userService UserService
uploadedRepo repository.UploadedFileRepository
}
// 预览图配置
@@ -52,10 +65,11 @@ const (
)
// NewUploadService 创建上传服务
func NewUploadService(s3Client *s3.Client, userService UserService) UploadService {
func NewUploadService(s3Client *s3.Client, userService UserService, uploadedRepo repository.UploadedFileRepository) UploadService {
return &uploadService{
s3Client: s3Client,
userService: userService,
s3Client: s3Client,
userService: userService,
uploadedRepo: uploadedRepo,
}
}
@@ -216,6 +230,210 @@ func (s *uploadService) UploadCover(ctx context.Context, userID string, file *mu
return url, nil
}
// 聊天文件上传限制
const (
MaxChatFileSize = 50 << 20 // 50MB
chatFileFolderLimit = 32 // folder 名长度上限,避免路径注入
)
// chatFileAllowedMime 允许上传的文件 MIME 白名单(按类型前缀匹配)。
// 不在白名单内的类型一律拒绝,防止上传可执行脚本等高危文件。
var chatFileAllowedMime = map[string]bool{
// 文档
"application/pdf": true,
"application/msword": true,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
"application/vnd.ms-excel": true,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
"application/vnd.ms-powerpoint": true,
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
"application/rtf": true,
"text/plain": true,
"text/csv": true,
"text/markdown": true,
"application/json": true,
"application/xml": true,
"text/xml": true,
"application/zip": true,
"application/x-zip-compressed": true,
"application/x-rar-compressed": true,
"application/x-7z-compressed": true,
"application/gzip": true,
"application/x-tar": true,
// 音视频/图片(聊天文件入口)
"audio/mpeg": true,
"audio/mp3": true,
"audio/wav": true,
"audio/x-wav": true,
"audio/aac": true,
"audio/x-m4a": true,
"audio/flac": true,
"video/mp4": true,
"video/quicktime": true,
"video/x-msvideo": true,
"video/webm": true,
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"image/bmp": true,
// 其他常用类型
"application/octet-stream": true, // 部分客户端(如 Windows上传无明确类型需结合扩展名二次校验
}
// chatFileExtFallback 当 MIME 为 octet-stream 时,按扩展名推断的真实类型
var chatFileExtFallback = map[string]string{
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".zip": "application/zip",
".rar": "application/x-rar-compressed",
".7z": "application/x-7z-compressed",
".gz": "application/gzip",
".tar": "application/x-tar",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".json": "application/json",
".csv": "text/csv",
".md": "text/markdown",
".txt": "text/plain",
}
// normalizeChatFileMime 校正文件 MIME 类型:
// 1. 解析 multipart header 的 Content-Type
// 2. 若为通用 octet-stream依据文件名扩展名回退到更具体的类型
// 3. 全部兜底为 application/octet-stream。
func normalizeChatFileMime(headerType, filename string) string {
baseType, _, err := mime.ParseMediaType(strings.TrimSpace(headerType))
if err != nil || baseType == "" {
baseType = "application/octet-stream"
}
baseType = strings.ToLower(baseType)
if baseType == "application/octet-stream" {
ext := strings.ToLower(filepath.Ext(filename))
if real, ok := chatFileExtFallback[ext]; ok {
return real
}
}
return baseType
}
// validateChatFile 校验聊天文件大小与 MIME 白名单
func validateChatFile(size int64, mimeType string) error {
if size <= 0 {
return fmt.Errorf("文件为空")
}
if size > MaxChatFileSize {
return fmt.Errorf("文件大小超过限制(最大 %d MB", MaxChatFileSize>>20)
}
if !chatFileAllowedMime[mimeType] {
return fmt.Errorf("不支持的文件类型: %s", mimeType)
}
return nil
}
// UploadFile 上传聊天文件(非图片,按文件类型原样存储到 S3
// folder 用于隔离不同来源(如 chat/post仅允许字母数字与下划线。
// uploaderID 为上传者用户 ID用于审计。
func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string) (*UploadFileResult, error) {
if file == nil {
return nil, fmt.Errorf("文件为空")
}
if file.Size > MaxChatFileSize {
return nil, fmt.Errorf("文件大小超过限制(最大 %d MB", MaxChatFileSize>>20)
}
// 校验与规范化 folder避免路径穿越
folder = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
return r
}
return -1
}, folder)
if folder == "" || len(folder) > chatFileFolderLimit {
folder = "chat"
}
// 校验 MIME 类型
mimeType := normalizeChatFileMime(file.Header.Get("Content-Type"), file.Filename)
if err := validateChatFile(file.Size, mimeType); err != nil {
return nil, err
}
// 读取文件内容
f, err := file.Open()
if err != nil {
return nil, fmt.Errorf("打开文件失败: %w", err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("读取文件失败: %w", err)
}
if err := validateChatFile(int64(len(data)), mimeType); err != nil {
return nil, err
}
// 以内容哈希命名,避免重复存储;保留原始扩展名(便于浏览器识别)
hash := sha256.Sum256(data)
hashStr := fmt.Sprintf("%x", hash)
ext := strings.ToLower(filepath.Ext(file.Filename))
if ext == "" {
// 从 MIME 推断扩展名mime.ExtensionsByType 在新版本返回 (exts, err)
if es, _ := mime.ExtensionsByType(mimeType); len(es) > 0 {
ext = es[0]
} else {
ext = ".bin"
}
}
objectName := fmt.Sprintf("files/%s/%s%s", folder, hashStr, ext)
url, err := s.s3Client.UploadData(ctx, objectName, data, mimeType)
if err != nil {
return nil, fmt.Errorf("上传到 S3 失败: %w", err)
}
// 写入上传记录,用于 7 天 TTL 过期清理(失败仅记日志,不影响上传)
if s.uploadedRepo != nil {
record := &model.UploadedFile{
URL: url,
ObjectName: objectName,
Name: file.Filename,
Size: file.Size,
MimeType: mimeType,
Folder: folder,
UploaderID: uploaderID,
}
if rerr := s.uploadedRepo.Create(ctx, record); rerr != nil {
zap.L().Warn("写入文件上传记录失败", zap.String("url", url), zap.Error(rerr))
}
}
return &UploadFileResult{
URL: url,
Name: file.Filename,
Size: file.Size,
MimeType: mimeType,
}, nil
}
// GetExpiredURLSet 返回所有已从 S3 清理的文件 URL 集合,供 DTO 层标记失效
func (s *uploadService) GetExpiredURLSet(ctx context.Context) (map[string]struct{}, error) {
if s.uploadedRepo == nil {
return nil, nil
}
return s.uploadedRepo.GetExpiredURLSet(ctx)
}
// GetURL 获取文件URL
func (s *uploadService) GetURL(ctx context.Context, objectName string) (string, error) {
return s.s3Client.GetURL(ctx, objectName)