package handler import ( "fmt" "log" "math/rand" "mime/multipart" "net/http" "os" "path/filepath" "strings" "time" "soul-api/internal/config" "soul-api/internal/oss" "github.com/gin-gonic/gin" ) const ( maxImageUploadBytes = 5 * 1024 * 1024 // 5MB maxVideoUploadBytes = 100 * 1024 * 1024 // 100MB(章节富文本视频) maxAttachmentUploadBytes = 30 * 1024 * 1024 // 30MB(章节附件) ) var allowedImageCT = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true} var allowedVideoCT = map[string]bool{ "video/mp4": true, "video/quicktime": true, "video/webm": true, "video/x-msvideo": true, } var allowedAttachmentCT = map[string]bool{ "application/pdf": true, "application/zip": true, "application/x-zip-compressed": 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, "text/plain": true, "application/json": true, } var allowedAttachmentExt = map[string]bool{ ".pdf": true, ".zip": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, ".txt": true, ".json": true, ".csv": true, ".md": true, } func uploadBookFolderCategory(folder string) string { switch folder { case "book-videos": return "video" case "book-attachments": return "attachment" default: return "image" } } func isAllowedVideo(ct string) bool { if strings.HasPrefix(ct, "video/") { return true } return allowedVideoCT[ct] } func isAllowedAttachment(file *multipart.FileHeader, ct string) bool { if allowedAttachmentCT[ct] { return true } ext := strings.ToLower(filepath.Ext(file.Filename)) return allowedAttachmentExt[ext] } // UploadPost POST /api/upload 通用上传(multipart:file + folder) // 同源挂载:POST /api/miniprogram/upload、POST /api/admin/upload(管理端须 Admin JWT) // 优先写入 OSS(system_config.oss_config);未配置或上传失败时自动落本地 uploads/ 并由 BaseURL 拼接可访问 URL(不再向前端返回 OSS 配置类错误)。 func UploadPost(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的文件"}) return } folder := c.PostForm("folder") if folder == "" { folder = "avatars" } ct := file.Header.Get("Content-Type") cat := uploadBookFolderCategory(folder) switch cat { case "video": if file.Size > maxVideoUploadBytes { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "视频大小不能超过100MB"}) return } if !isAllowedVideo(ct) { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持常见视频格式(如 mp4、mov、webm)"}) return } case "attachment": if file.Size > maxAttachmentUploadBytes { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "附件大小不能超过30MB"}) return } if !isAllowedAttachment(file, ct) { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不支持的附件类型(可用 pdf、zip、Office 文档、txt 等)"}) return } default: if file.Size > maxImageUploadBytes { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "图片大小不能超过5MB"}) return } if !allowedImageCT[ct] && !strings.HasPrefix(ct, "image/") { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持图片格式"}) return } } ext := filepath.Ext(file.Filename) if ext == "" { switch cat { case "video": ext = ".mp4" case "attachment": ext = ".bin" default: ext = ".jpg" } } name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext) objectKey := filepath.ToSlash(filepath.Join("uploads", folder, name)) if !oss.IsEnabled() { uploadPostSaveLocal(c, file, folder, name, ct) return } f, err := file.Open() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取文件失败"}) return } url, uploadErr := oss.Upload(objectKey, f) _ = f.Close() if uploadErr == nil && url != "" { c.JSON(http.StatusOK, gin.H{ "success": true, "url": url, "fullUrl": url, "data": gin.H{ "url": url, "fullUrl": url, "fileName": name, "size": file.Size, "type": ct, "storage": "oss", }, }) return } if uploadErr != nil { log.Printf("upload: OSS failed objectKey=%s (local fallback): %v", objectKey, uploadErr) } else { log.Printf("upload: OSS returned empty url objectKey=%s (local fallback)", objectKey) } uploadPostSaveLocal(c, file, folder, name, ct) } func uploadPostSaveLocal(c *gin.Context, file *multipart.FileHeader, folder, name, ct string) { uploadDir := config.Get().UploadDir if uploadDir == "" { uploadDir = "./uploads" } dir := filepath.Join(uploadDir, folder) _ = os.MkdirAll(dir, 0755) dst := filepath.Join(dir, name) if err := c.SaveUploadedFile(file, dst); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"}) return } relPath := "/uploads/" + filepath.ToSlash(filepath.Join(folder, name)) fullURL := relPath if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" { fullURL = cfg.BaseURLJoin(relPath) } c.JSON(http.StatusOK, gin.H{ "success": true, "url": fullURL, "fullUrl": fullURL, "data": gin.H{ "url": fullURL, "fullUrl": fullURL, "fileName": name, "size": file.Size, "type": ct, "storage": "local", }, }) } func randomStrUpload(n int) string { const letters = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, n) for i := range b { b[i] = letters[rand.Intn(len(letters))] } return string(b) } // UploadDelete DELETE /api/upload // path 支持:/uploads/xxx(本地)或 https://bucket.oss-xxx.aliyuncs.com/uploads/xxx(OSS) func UploadDelete(c *gin.Context) { path := c.Query("path") if path == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请指定 path"}) return } // OSS 公网 URL:从 OSS 删除 if oss.IsOSSURL(path) { objectKey := oss.ParseObjectKeyFromURL(path) if objectKey != "" { if err := oss.Delete(objectKey); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "OSS 删除失败"}) return } c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"}) return } } // 本地路径:支持 /uploads/xxx、uploads/xxx 或含 /uploads/ 的完整 URL if idx := strings.Index(path, "/uploads/"); idx >= 0 { path = path[idx+1:] // 从 uploads/ 开始 } rel := strings.TrimPrefix(path, "/uploads/") rel = strings.TrimPrefix(rel, "uploads/") if rel == "" || strings.Contains(rel, "..") { c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"}) return } uploadDir := config.Get().UploadDir if uploadDir == "" { uploadDir = "./uploads" } fullPath := filepath.Join(uploadDir, filepath.FromSlash(rel)) if err := os.Remove(fullPath); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "文件不存在或删除失败"}) return } c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"}) }