Files
soul-yongping/soul-api/internal/handler/upload.go
卡若 5724fba877 feat: 小程序超级个体/个人资料/CKB获客;VIP列表展示过滤;管理端与API联调
- 超级个体:去掉首位特例;列表仅展示有头像且非微信默认昵称(vip.go)
- 个人资料:居中头像、低调联系方式、点头像优先走存客宝 lead(ckbLeadToken)
- 阅读页分享朋友圈复制与 toast 去重
- soul-api: miniprogram users 带 ckbLeadToken;其它 handler 与路由调整
- 脚本:content_upload、miniprogram 上传辅助等

Made-with: Cursor
2026-03-22 08:34:28 +08:00

218 lines
6.8 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 (
"fmt"
"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 通用上传multipartfile + folder
// - 默认 folder如 avatars、book-images图片≤5MB
// - folder=book-videos视频≤100MB
// - folder=book-attachments常见文档/压缩包≤30MB
// 若管理端已配置 OSS优先上传到 OSSOSS 失败或未配置时回退本地磁盘(容灾)
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))
// 优先尝试 OSS已配置时
if oss.IsEnabled() {
f, err := file.Open()
if err == nil {
url, uploadErr := oss.Upload(objectKey, f)
_ = f.Close()
if uploadErr == nil && url != "" {
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
return
}
// OSS 失败,回退本地(容灾)
}
}
// 本地磁盘存储OSS 未配置或失败时)
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, "data": gin.H{"url": fullURL, "fileName": name, "size": file.Size, "type": ct}})
}
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/xxxOSS
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": "删除成功"})
}