Files
web/server/handlers/module_upload.go
whm 0800982224 feat: 分片上传断点续传、临时目录后台配置与清扫、宇恒云账号管理
- 管理端大文件分片上传与 sessionStorage 续传;Nginx 大请求体/超时
- .chunk-uploads 定期清扫;system_config 后台配置保留时长与扫描间隔
- 宇恒云 POST /register 对接与 yuheng_cloud_register_records 留痕;yuheng_cloud:manage 权限

Made-with: Cursor
2026-04-13 14:50:27 +08:00

424 lines
13 KiB
Go
Raw Permalink 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 handlers
import (
"context"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"yh_web/server/models"
"github.com/gin-gonic/gin"
)
// getUploadDir 上传根目录:容器内通过 UPLOAD_DIR 挂载到独立可写路径(如 /uploads避免 /app 只读
func getUploadDir() string {
if d := os.Getenv("UPLOAD_DIR"); d != "" {
return d
}
return "uploads"
}
// pathPrefix 站点下相对路径前缀,用于多级目录
func pathPrefix(siteID string) string {
return "sites/" + siteID + "/"
}
// ListSiteAssets 站点功能模块/上传文件列表query path 为当前目录相对路径空为根downloadable=1 时返回该站点下所有可下载文件(供首页编辑选择)
func ListSiteAssets(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
onlyDownloadable := c.Query("downloadable") == "1" || c.Query("downloadable") == "true"
if onlyDownloadable {
listDownloadableAssets(c, siteID)
return
}
path := c.Query("path")
prefix := pathPrefix(siteID)
if path != "" {
prefix = prefix + path
if prefix[len(prefix)-1] != '/' {
prefix += "/"
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
filter := bson.M{"site_id": siteID, "file_path": bson.M{"$regex": "^" + regexp.QuoteMeta(prefix) + "[^/]+$"}}
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
cursor, err := coll.Find(ctx, filter, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cursor.Close(ctx)
var list []models.SiteAsset
if err = cursor.All(ctx, &list); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, _ := coll.CountDocuments(ctx, filter)
subDirs := listSubDirs(c, siteID, path)
c.JSON(http.StatusOK, gin.H{"list": list, "total": total, "sub_dirs": subDirs})
}
func listDownloadableAssets(c *gin.Context, siteID string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
filter := bson.M{"site_id": siteID, "downloadable": true}
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
cursor, err := coll.Find(ctx, filter, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cursor.Close(ctx)
var list []models.SiteAsset
if err = cursor.All(ctx, &list); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"list": list, "total": len(list)})
}
// ListDownloadableAssets 仅返回可下载文件列表(供首页编辑选择,仅需 homepage:edit 权限)
func ListDownloadableAssets(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
listDownloadableAssets(c, siteID)
}
func listSubDirs(c *gin.Context, siteID, currentPath string) []string {
prefix := pathPrefix(siteID)
if currentPath != "" {
prefix = prefix + currentPath
if prefix[len(prefix)-1] != '/' {
prefix += "/"
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID, "file_path": bson.M{"$regex": "^" + regexp.QuoteMeta(prefix)}})
if err != nil {
return nil
}
defer cursor.Close(ctx)
var docs []struct {
FilePath string `bson:"file_path"`
}
_ = cursor.All(ctx, &docs)
seen := make(map[string]bool)
for _, d := range docs {
rel := strings.TrimPrefix(d.FilePath, prefix)
if rel == "" || rel == d.FilePath {
continue
}
parts := strings.SplitN(rel, "/", 2)
if len(parts) > 0 && parts[0] != "" {
seen[parts[0]] = true
}
}
// 再扫描物理目录
baseDir := filepath.Join(getUploadDir(), filepath.FromSlash(prefix))
entries, _ := os.ReadDir(baseDir)
for _, e := range entries {
if e.IsDir() {
seen[e.Name()] = true
}
}
names := make([]string, 0, len(seen))
for n := range seen {
names = append(names, n)
}
sort.Strings(names)
return names
}
func promotionMimeType(ext string) string {
switch strings.ToLower(ext) {
case ".mov":
return "video/quicktime"
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
default:
return ""
}
}
// ServePromotionMedia 公开读取站点 uploads/sites/{site_id}/promotion/ 下文件(与后台上传到 promotion/… 目录对应)
func ServePromotionMedia(c *gin.Context) {
siteID := c.Param("site_id")
raw := strings.TrimPrefix(c.Param("filepath"), "/")
if siteID == "" || raw == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
rel := filepath.ToSlash(filepath.Clean(raw))
if rel == "." || strings.HasPrefix(rel, "../") || strings.Contains(rel, "/../") {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效路径"})
return
}
baseDir := filepath.Join(getUploadDir(), "sites", siteID, "promotion")
fullPath := filepath.Join(baseDir, filepath.FromSlash(rel))
relBack, err := filepath.Rel(baseDir, fullPath)
if err != nil || strings.HasPrefix(relBack, "..") {
c.JSON(http.StatusForbidden, gin.H{"error": "禁止访问"})
return
}
fi, err := os.Stat(fullPath)
if err != nil || fi.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
ext := filepath.Ext(fullPath)
ct := promotionMimeType(ext)
if ct == "" {
ct = "application/octet-stream"
}
c.Header("Content-Type", ct)
c.Header("Cache-Control", "public, max-age=86400")
c.File(fullPath)
}
// computeSiteUploadDest 与整文件上传、分片合并完成时一致的目标路径(非 preserve 时 saveName 含当前时间戳)
func computeSiteUploadDest(siteID, folder, originalFilename string, preserve bool) (relPath, destPath string, errMsg string) {
name := originalFilename
ext := filepath.Ext(name)
nameNoExt := strings.TrimSuffix(name, ext)
var saveName string
if preserve {
saveName = filepath.Base(name)
if saveName == "." || saveName == string(filepath.Separator) || saveName == "" {
return "", "", "无效的文件名"
}
} else {
if len(ext) == 0 {
saveName = nameNoExt + "_" + time.Now().Format("20060102150405")
} else {
saveName = nameNoExt + "_" + time.Now().Format("20060102150405") + ext
}
}
folderClean := ""
if folder != "" {
folderClean = filepath.ToSlash(filepath.Clean(folder))
if strings.HasPrefix(folderClean, "../") || strings.Contains(folderClean, "/../") {
return "", "", "无效的目录路径"
}
}
if folderClean != "" {
relPath = filepath.ToSlash(filepath.Join("sites", siteID, folderClean, saveName))
} else {
relPath = filepath.ToSlash(filepath.Join("sites", siteID, saveName))
}
destPath = filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
return relPath, destPath, ""
}
// UploadSiteAsset 上传功能模块/文件form 可选folder当前目录相对路径、downloadabletrue/false、preserve_filenametrue 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 URL
func UploadSiteAsset(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"})
return
}
folder := strings.TrimSpace(c.PostForm("folder"))
downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1"
preserve := c.PostForm("preserve_filename") == "true" || c.PostForm("preserve_filename") == "1"
relPath, destPath, errMsg := computeSiteUploadDest(siteID, folder, file.Filename, preserve)
if errMsg != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": errMsg})
return
}
if preserve {
ctxDel, cancelDel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancelDel()
coll := config.GetDB(config.DBName).Collection("site_assets")
_, _ = coll.DeleteMany(ctxDel, bson.M{"site_id": siteID, "file_path": relPath})
_ = os.Remove(destPath)
}
baseDir := filepath.Dir(destPath)
if err := os.MkdirAll(baseDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
return
}
if err := c.SaveUploadedFile(file, destPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
doc := models.SiteAsset{
SiteID: siteID,
Name: file.Filename,
FilePath: relPath,
Size: file.Size,
ContentType: file.Header.Get("Content-Type"),
Downloadable: downloadable,
CreatedAt: time.Now().Format(time.RFC3339),
}
res, err := config.GetDB(config.DBName).Collection("site_assets").InsertOne(ctx, bson.M{
"site_id": doc.SiteID,
"name": doc.Name,
"file_path": doc.FilePath,
"size": doc.Size,
"content_type": doc.ContentType,
"downloadable": doc.Downloadable,
"created_at": doc.CreatedAt,
})
if err != nil {
os.Remove(destPath)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"})
// promotion 目录下 .mov 由服务端异步转 .mp4 并更新本条资源(需容器/主机安装 ffmpeg
ScheduleTranscodeAfterUpload(siteID, relPath, destPath, res.InsertedID)
}
// DeleteSiteAsset 删除站点资源
func DeleteSiteAsset(c *gin.Context) {
siteID := c.Param("site_id")
idStr := c.Param("asset_id")
if siteID == "" || idStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
oid, err := bson.ObjectIDFromHex(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
var asset models.SiteAsset
err = coll.FindOne(ctx, bson.M{"_id": oid, "site_id": siteID}).Decode(&asset)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
return
}
fullPath := filepath.Join(getUploadDir(), filepath.FromSlash(asset.FilePath))
os.Remove(fullPath)
_, err = coll.DeleteOne(ctx, bson.M{"_id": oid})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
// CreateSiteFolderInput 创建目录
type CreateSiteFolderInput struct {
Path string `json:"path" binding:"required"`
}
// CreateSiteFolder 在站点下创建多级目录
func CreateSiteFolder(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
var input CreateSiteFolderInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写目录路径"})
return
}
clean := filepath.Clean(input.Path)
if clean == "." || clean == ".." || strings.HasPrefix(clean, "..") {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
return
}
baseDir := filepath.Join(getUploadDir(), "sites", siteID, clean)
if err := os.MkdirAll(baseDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "创建成功", "path": filepath.ToSlash(clean)})
}
// DownloadSiteAsset 前台公开下载:仅当资源标记为可下载时返回文件(供首页等使用)
func DownloadSiteAsset(c *gin.Context) {
siteID := c.Param("site_id")
assetIDStr := c.Param("asset_id")
if siteID == "" || assetIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
oid, err := bson.ObjectIDFromHex(assetIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
var asset models.SiteAsset
err = coll.FindOne(ctx, bson.M{"_id": oid, "site_id": siteID}).Decode(&asset)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
return
}
if !asset.Downloadable {
c.JSON(http.StatusForbidden, gin.H{"error": "该资源不可下载"})
return
}
fullPath := filepath.Join(getUploadDir(), filepath.FromSlash(asset.FilePath))
if _, err := os.Stat(fullPath); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
c.Header("Content-Disposition", "attachment; filename=\""+asset.Name+"\"")
if asset.ContentType != "" {
c.Header("Content-Type", asset.ContentType)
}
c.File(fullPath)
}