Files
web/server/handlers/module_upload.go
2026-03-18 18:43:34 +08:00

324 lines
9.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 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
}
// UploadSiteAsset 上传功能模块/文件form 可选folder当前目录相对路径、downloadabletrue/false
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 := c.PostForm("folder")
downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1"
baseDir := filepath.Join(getUploadDir(), "sites", siteID, filepath.Clean(folder))
if err := os.MkdirAll(baseDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
return
}
name := file.Filename
ext := filepath.Ext(name)
nameNoExt := name[:len(name)-len(ext)]
saveName := nameNoExt + "_" + time.Now().Format("20060102150405") + ext
relPath := filepath.Join("sites", siteID, filepath.Clean(folder), saveName)
relPath = filepath.ToSlash(relPath)
destPath := filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
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": "上传成功"})
}
// 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)
}