package handler import ( "bytes" "fmt" "image/gif" "image/jpeg" "image/png" "io" "math/rand" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "soul-api/internal/database" "soul-api/internal/model" ) const ( uploadDirContent = "uploads" maxImageBytes = 5 * 1024 * 1024 // 5MB maxVideoBytes = 100 * 1024 * 1024 // 100MB defaultImageQuality = 85 ) var ( allowedImageTypes = map[string]bool{ "image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true, } allowedVideoTypes = map[string]bool{ "video/mp4": true, "video/quicktime": true, "video/webm": true, "video/x-msvideo": true, } ) // UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩),优先 OSS // 表单:file(必填), folder(可选,默认 images), quality(可选 1-100,默认 85) func UploadImagePost(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的图片"}) return } if file.Size > maxImageBytes { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "图片大小不能超过 5MB"}) return } ct := file.Header.Get("Content-Type") if !allowedImageTypes[ct] && !strings.HasPrefix(ct, "image/") { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持 jpg/png/gif/webp 格式"}) return } quality := defaultImageQuality if q := c.PostForm("quality"); q != "" { if qn, e := strconv.Atoi(q); e == nil && qn >= 1 && qn <= 100 { quality = qn } } folder := c.PostForm("folder") if folder == "" { folder = "images" } ext := filepath.Ext(file.Filename) if ext == "" { ext = ".jpg" } name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(6), ext) src, err := file.Open() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "打开文件失败"}) return } defer src.Close() data, err := io.ReadAll(src) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取文件失败"}) return } // JPEG 压缩 var finalData []byte finalCt := ct if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") { if img, err := jpeg.Decode(bytes.NewReader(data)); err == nil { var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err == nil { finalData = buf.Bytes() } } } else if strings.Contains(ct, "png") { if img, err := png.Decode(bytes.NewReader(data)); err == nil { var buf bytes.Buffer if err := png.Encode(&buf, img); err == nil { finalData = buf.Bytes() } } } else if strings.Contains(ct, "gif") { if img, err := gif.Decode(bytes.NewReader(data)); err == nil { var buf bytes.Buffer if err := gif.Encode(&buf, img, nil); err == nil { finalData = buf.Bytes() } } } if finalData == nil { finalData = data } // 优先 OSS 上传 if ossURL, err := ossUploadBytes(finalData, folder, name, finalCt); err == nil { c.JSON(http.StatusOK, gin.H{ "success": true, "url": ossURL, "data": gin.H{"url": ossURL, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "oss"}, }) return } // 回退本地存储 dir := filepath.Join(uploadDirContent, folder) _ = os.MkdirAll(dir, 0755) dst := filepath.Join(dir, name) if err := os.WriteFile(dst, finalData, 0644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"}) return } url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name)) c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "local"}}) } // UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传,优先 OSS // 表单:file(必填), folder(可选,默认 videos) func UploadVideoPost(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的视频"}) return } if file.Size > maxVideoBytes { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "视频大小不能超过 100MB"}) return } ct := file.Header.Get("Content-Type") if !allowedVideoTypes[ct] && !strings.HasPrefix(ct, "video/") { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持 mp4/mov/avi 等视频格式"}) return } folder := c.PostForm("folder") if folder == "" { folder = "videos" } ext := filepath.Ext(file.Filename) if ext == "" { ext = ".mp4" } name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(8), ext) // 优先 OSS 上传 if ossCfg := getOssConfig(); ossCfg != nil { src, err := file.Open() if err == nil { defer src.Close() if ossURL, err := ossUploadFile(src, folder, name); err == nil { c.JSON(http.StatusOK, gin.H{ "success": true, "url": ossURL, "data": gin.H{"url": ossURL, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "oss"}, }) return } } } // 回退本地存储 dir := filepath.Join(uploadDirContent, 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 } url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name)) c.JSON(http.StatusOK, gin.H{ "success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "local"}, }) } // AdminContentUpload POST /api/admin/content/upload 管理端-内容上传(通过 API 写入内容管理,不直接操作数据库) // 需 AdminAuth。Body: { "action": "import", "data": [ { "id","title","content","price","isFree","partId","partTitle","chapterId","chapterTitle" } ] } func AdminContentUpload(c *gin.Context) { var body struct { Action string `json:"action"` Data []importItem `json:"data"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) return } if body.Action != "import" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 import"}) return } if len(body.Data) == 0 { c.JSON(http.StatusOK, gin.H{"success": false, "error": "data 不能为空"}) return } db := database.DB() imported, failed := 0, 0 for _, item := range body.Data { if item.ID == "" || item.Title == "" { failed++ continue } price := 1.0 if item.Price != nil { price = *item.Price } isFree := false if item.IsFree != nil { isFree = *item.IsFree } wordCount := len(item.Content) status := "published" editionStandard, editionPremium := true, false ch := model.Chapter{ ID: item.ID, PartID: strPtrContent(item.PartID, "part-1"), PartTitle: strPtrContent(item.PartTitle, "未分类"), ChapterID: strPtrContent(item.ChapterID, "chapter-1"), ChapterTitle: strPtrContent(item.ChapterTitle, "未分类"), SectionTitle: item.Title, Content: item.Content, WordCount: &wordCount, IsFree: &isFree, Price: &price, Status: &status, EditionStandard: &editionStandard, EditionPremium: &editionPremium, } err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error if err == gorm.ErrRecordNotFound { err = db.Create(&ch).Error } else if err == nil { err = db.Model(&model.Chapter{}).Where("id = ?", item.ID).Updates(map[string]interface{}{ "section_title": ch.SectionTitle, "content": ch.Content, "word_count": ch.WordCount, "is_free": ch.IsFree, "price": ch.Price, }).Error } if err != nil { failed++ continue } imported++ } c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed}) } func randomStrContent(n int) string { const letters = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, n) for i := range b { b[i] = letters[rand.Intn(len(letters))] } return string(b) } func strPtrContent(s *string, def string) string { if s != nil && *s != "" { return *s } return def }