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(当前目录相对路径)、downloadable(true/false)、preserve_filename(true 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 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) }