- 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.
252 lines
7.4 KiB
Go
252 lines
7.4 KiB
Go
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": "删除成功"})
|
||
}
|