Files
Mycontent/soul-api/internal/handler/upload_content.go
乘风 b1db1efeb8 feat: enhance profile editing and image upload functionality
- Introduced a utility function to ensure absolute media URLs for user profile images.
- Updated the user cache synchronization method to handle profile data more effectively, including avatar, nickname, phone, and MBTI.
- Refactored the avatar upload process to utilize the new URL handling function, improving reliability.
- Modified the super article editor to focus on image uploads instead of file uploads, enhancing user experience with clearer labeling and prompts.

This update aims to streamline user profile management and improve the image upload experience in the application.
2026-05-09 16:23:59 +08:00

321 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"bytes"
"fmt"
"image/gif"
"image/jpeg"
"image/png"
"io"
"log"
"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"
"soul-api/internal/oss"
)
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未配置或失败时自动落本地 uploads/。
// 表单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
}
var ossURL string
var ossErr error
if oss.IsEnabled() {
ossURL, ossErr = ossUploadBytes(finalData, folder, name, finalCt)
} else {
ossErr = fmt.Errorf("OSS 未配置")
}
if ossErr == nil && ossURL != "" {
c.JSON(http.StatusOK, gin.H{
"success": true,
"url": ossURL,
"fullUrl": ossURL,
"data": gin.H{
"url": ossURL, "fullUrl": ossURL, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "oss",
},
})
return
}
if ossErr != nil {
log.Printf("upload/image: OSS failed folder=%s name=%s (local fallback): %v", folder, name, ossErr)
} else {
log.Printf("upload/image: OSS empty url folder=%s name=%s (local fallback)", folder, name)
}
// 回退本地存储
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, "fullUrl": url,
"data": gin.H{"url": url, "fullUrl": 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)
var ossURL string
var ossErr error
if oss.IsEnabled() {
src, openErr := file.Open()
if openErr != nil {
ossErr = openErr
} else {
ossURL, ossErr = ossUploadFile(src, folder, name)
_ = src.Close()
}
} else {
ossErr = fmt.Errorf("OSS 未配置")
}
if ossErr == nil && ossURL != "" {
c.JSON(http.StatusOK, gin.H{
"success": true,
"url": ossURL,
"fullUrl": ossURL,
"data": gin.H{
"url": ossURL, "fullUrl": ossURL, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "oss",
},
})
return
}
if ossErr != nil {
log.Printf("upload/video: OSS skipped or failed (local fallback) folder=%s name=%s: %v", folder, name, ossErr)
}
// 回退本地存储
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,
"fullUrl": url,
"data": gin.H{"url": url, "fullUrl": 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
}