- 超级个体:去掉首位特例;列表仅展示有头像且非微信默认昵称(vip.go) - 个人资料:居中头像、低调联系方式、点头像优先走存客宝 lead(ckbLeadToken) - 阅读页分享朋友圈复制与 toast 去重 - soul-api: miniprogram users 带 ckbLeadToken;其它 handler 与路由调整 - 脚本:content_upload、miniprogram 上传辅助等 Made-with: Cursor
218 lines
6.8 KiB
Go
218 lines
6.8 KiB
Go
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 通用上传(multipart:file + folder)
|
||
// - 默认 folder(如 avatars、book-images):图片,≤5MB
|
||
// - folder=book-videos:视频,≤100MB
|
||
// - folder=book-attachments:常见文档/压缩包,≤30MB
|
||
// 若管理端已配置 OSS,优先上传到 OSS;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))
|
||
|
||
// 优先尝试 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/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": "删除成功"})
|
||
}
|