package upload import ( "bytes" "context" "fmt" "io" "mime/multipart" "net/http" "path" "strings" "time" "unicode" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/platform/idgen" "hyapp-admin-server/internal/response" "github.com/gin-gonic/gin" ) const ( uploadSniffBytes = 512 ) var ( imageUploadPolicy = uploadPolicy{ kind: "images", requireImage: true, allowedExtensions: map[string]struct{}{ ".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {}, ".svga": {}, ".pag": {}, }, } fileUploadPolicy = uploadPolicy{ kind: "files", } ) type ObjectUploader interface { PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error) } type Handler struct { audit shared.OperationLogger objectPrefix string uploader ObjectUploader } func New(uploader ObjectUploader, objectPrefix string, audit shared.OperationLogger) *Handler { objectPrefix = strings.Trim(strings.TrimSpace(objectPrefix), "/") if objectPrefix == "" { objectPrefix = "admin" } return &Handler{audit: audit, objectPrefix: objectPrefix, uploader: uploader} } func (h *Handler) UploadFile(c *gin.Context) { h.uploadObject(c, fileUploadPolicy) } func (h *Handler) UploadImage(c *gin.Context) { h.uploadObject(c, imageUploadPolicy) } func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) { if h.uploader == nil { response.ServerError(c, "文件上传未配置") return } file, header, ok := parseUploadFile(c) if !ok { return } defer file.Close() defer func() { if c.Request.MultipartForm != nil { _ = c.Request.MultipartForm.RemoveAll() } }() if header.Size <= 0 { response.BadRequest(c, "文件大小不正确") return } head, contentType, detectedContentType, ok := sniffUploadContent(c, file, header) if !ok { return } if policy.requireImage { contentType = resolveImageUploadContentType(header.Filename, contentType, detectedContentType) } if !validateUploadContent(policy, header.Filename, contentType) { response.BadRequest(c, "文件类型不支持") return } actor := shared.ActorFromContext(c) objectKey := buildUploadObjectKey(h.objectPrefix, policy, actor.UserID, header.Filename, contentType, time.Now()) objectURL, err := h.uploader.PutObject(c.Request.Context(), objectKey, io.MultiReader(bytes.NewReader(head), file), header.Size, contentType) if err != nil { response.ServerError(c, "文件上传失败") return } shared.OperationLogWithResourceID(c, h.audit, "upload-file", "cos_objects", objectKey, "success", fmt.Sprintf("kind=%s size=%d content_type=%s", policy.kind, header.Size, contentType)) response.OK(c, uploadResult{ URL: objectURL, ObjectKey: objectKey, ContentType: contentType, SizeBytes: header.Size, }) } func parseUploadFile(c *gin.Context) (multipart.File, *multipart.FileHeader, bool) { if err := c.Request.ParseMultipartForm(1 << 20); err != nil { response.BadRequest(c, "文件参数不正确") return nil, nil, false } for _, field := range []string{"file", "image", "avatar"} { file, header, err := c.Request.FormFile(field) if err == nil { return file, header, true } } response.BadRequest(c, "文件参数不正确") return nil, nil, false } func sniffUploadContent(c *gin.Context, file multipart.File, header *multipart.FileHeader) ([]byte, string, string, bool) { head := make([]byte, uploadSniffBytes) n, err := io.ReadFull(file, head) if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { response.BadRequest(c, "文件参数不正确") return nil, "", "", false } head = head[:n] detectedContentType := http.DetectContentType(head) contentType := strings.TrimSpace(header.Header.Get("Content-Type")) if contentType == "" || contentType == "application/octet-stream" { contentType = detectedContentType } return head, contentType, detectedContentType, true } func validateUploadContent(policy uploadPolicy, filename string, contentType string) bool { contentType = strings.ToLower(strings.TrimSpace(contentType)) if policy.requireImage && !isImageContentType(contentType) { return false } if len(policy.allowedExtensions) == 0 { return true } extension := normalizedUploadExtension(filename, contentType) _, ok := policy.allowedExtensions[extension] return ok } func resolveImageUploadContentType(filename string, contentType string, detectedContentType string) string { extension := cleanUploadExtension(path.Ext(filename)) switch extension { case ".svga": return "application/x-svga" case ".pag": return "application/vnd.tencent.pag" } contentType = strings.ToLower(strings.TrimSpace(contentType)) if contentType == "application/x-svga" || contentType == "application/vnd.tencent.pag" { return contentType } if isRasterImageContentType(detectedContentType) { return strings.ToLower(strings.TrimSpace(detectedContentType)) } return strings.ToLower(strings.TrimSpace(detectedContentType)) } func isImageContentType(contentType string) bool { if isRasterImageContentType(contentType) { return true } switch strings.ToLower(strings.TrimSpace(contentType)) { case "application/x-svga", "application/vnd.tencent.pag": return true default: return false } } func isRasterImageContentType(contentType string) bool { switch strings.ToLower(strings.TrimSpace(contentType)) { case "image/jpeg", "image/png", "image/webp": return true default: return false } } func buildUploadObjectKey(objectPrefix string, policy uploadPolicy, actorID uint, filename string, contentType string, now time.Time) string { date := now.UTC().Format("20060102") extension := normalizedUploadExtension(filename, contentType) if extension == "" && policy.requireImage { extension = ".jpg" } idPrefix := strings.TrimSuffix(policy.kind, "s") return fmt.Sprintf("%s/%s/%d/%s/%s%s", objectPrefix, policy.kind, actorID, date, idgen.New(idPrefix), extension) } func normalizedUploadExtension(filename string, contentType string) string { if extension := cleanUploadExtension(path.Ext(filename)); extension != "" { return extension } switch strings.ToLower(strings.TrimSpace(contentType)) { case "image/jpeg": return ".jpg" case "image/png": return ".png" case "image/webp": return ".webp" case "application/x-svga": return ".svga" case "application/vnd.tencent.pag": return ".pag" case "application/pdf": return ".pdf" default: return "" } } func cleanUploadExtension(extension string) string { extension = strings.ToLower(strings.TrimSpace(extension)) if len(extension) < 2 || len(extension) > 10 || extension[0] != '.' { return "" } for _, char := range extension[1:] { if !unicode.IsDigit(char) && (char < 'a' || char > 'z') { return "" } } return extension }