package api import ( "crypto/rand" "encoding/hex" "errors" "io" "mime" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "eai_agentplatform/backend/internal/dal" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" "eai_agentplatform/backend/internal/web" ) // 团队共享网盘:全公司共享一套目录与文件,company_id 固定为 1(单公司平台)。 const netdiskCompanyID = 1 // 目录 path 合法性相关常量(对齐 pj0034 asset_folder 红线)。 // 不可删除/改名的 system folder:根目录 `/`。 const netdiskRootPath = "/" func init() { netdiskFolderDAO = dal.NetdiskFolderDAO{} netdiskFileDAO = dal.NetdiskFileDAO{} } var ( netdiskFolderDAO dal.NetdiskFolderDAO netdiskFileDAO dal.NetdiskFileDAO ) // netdiskRoot 返回网盘物理存储根目录(不存在则创建)。 func netdiskRoot() string { dir := Cfg.NetdiskDataDir if dir == "" { dir = filepath.Join("data", "netdisk") } _ = os.MkdirAll(dir, 0o755) return dir } // netdiskFilePath 由 StoredName 得到物理绝对路径。 func netdiskFilePath(storedName string) string { return filepath.Join(netdiskRoot(), filepath.Base(storedName)) } // ──────────────────────────────────────────────────────────── // 目录 path 工具(参考 pj0034 app/services/dam/folder.py) // ──────────────────────────────────────────────────────────── var netdiskInvalidSegChars = func() func(r rune) bool { invalid := "\\:*?\"<>|" return func(r rune) bool { return strings.ContainsRune(invalid, r) } }() // netdiskNormalizePath 规范化:去尾 `/`、压缩多 `/`、去段内空白;返 `/` 起的绝对路径。 func netdiskNormalizePath(raw string) string { s := strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/")) if s == "" { return "/" } if !strings.HasPrefix(s, "/") { s = "/" + s } for strings.Contains(s, "//") { s = strings.ReplaceAll(s, "//", "/") } if len(s) > 1 { s = strings.TrimRight(s, "/") } return s } // netdiskValidatePath 校验并返回规范化 path;不合法返回错误。 func netdiskValidatePath(raw string) (string, error) { if strings.TrimSpace(raw) == "" { return "", errors.New("目录路径不能为空") } p := netdiskNormalizePath(raw) for _, seg := range strings.Split(p, "/") { if seg == "" { continue } if seg == "." || seg == ".." { return "", errors.New("目录路径不允许 '.' 或 '..'") } if strings.ContainsFunc(seg, netdiskInvalidSegChars) { return "", errors.New("目录名含非法字符 \\ : * ? \" < > |") } if len(seg) > 100 { return "", errors.New("目录名「" + seg + "」超 100 字符") } } if len(p) > 500 { return "", errors.New("目录路径总长超 500 字符") } return p, nil } // netdiskIsRoot 是否根目录(system folder,不可删/改名)。 func netdiskIsRoot(path string) bool { return netdiskNormalizePath(path) == netdiskRootPath } // netdiskDeriveDisplayName 由 path 末段派生显示名。 func netdiskDeriveDisplayName(path string) string { p := netdiskNormalizePath(path) if p == "/" { return "/" } segs := strings.Split(p, "/") return segs[len(segs)-1] } // netdiskReplacePrefix 把老前缀整体替换为新前缀(用于目录改名时联动后代)。 func netdiskReplacePrefix(path, oldPrefix, newPrefix string) string { if path == oldPrefix { return newPrefix } if strings.HasPrefix(path, oldPrefix+"/") { return newPrefix + path[len(oldPrefix):] } return path } // netdiskParentPath 返回父目录路径(根为 "/")。 func netdiskParentPath(path string) string { p := netdiskNormalizePath(path) if p == "/" { return "/" } if idx := strings.LastIndex(p, "/"); idx > 0 { return p[:idx] } return "/" } // ──────────────────────────────────────────────────────────── // 目录 CRUD // ──────────────────────────────────────────────────────────── // netdiskListFolders GET /api/netdisk/folders —— 列全部目录 func netdiskListFolders(c *gin.Context) { folders := netdiskFolderDAO.ListDescendants(netdiskCompanyID, "") web.OK(c, gin.H{"items": folders, "total": len(folders)}) } // netdiskCreateFolder POST /api/netdisk/folders —— 新建目录 (mkdir) func netdiskCreateFolder(c *gin.Context) { u := middleware.CurrentUser(c) var req struct { Path string `json:"path"` DisplayName string `json:"display_name"` } if err := c.ShouldBindJSON(&req); err != nil { web.Fail(c, web.NewBadRequest("请求参数错误")) return } path, err := netdiskValidatePath(req.Path) if err != nil { web.Fail(c, web.NewBadRequest(err.Error())) return } if netdiskIsRoot(path) { web.Fail(c, web.NewBadRequest("根目录已存在,无需创建")) return } if _, found := netdiskFolderDAO.GetByPath(netdiskCompanyID, path); found { web.Fail(c, web.NewBadRequest("目录「" + path + "」已存在")) return } name := req.DisplayName if name == "" { name = netdiskDeriveDisplayName(path) } folder := model.NetdiskFolder{ CompanyID: netdiskCompanyID, Path: path, DisplayName: name, CreatedByID: u.ID, CreatedAt: time.Now(), } if !netdiskFolderDAO.Insert(&folder) { web.Fail(c, web.NewBadRequest("创建目录失败")) return } web.OK(c, folder) } // netdiskPatchFolder PATCH /api/netdisk/folders/:id —— 重命名 / 移动(联动后代与文件) func netdiskPatchFolder(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } var req struct { Path *string `json:"path"` DisplayName *string `json:"display_name"` } if err := c.ShouldBindJSON(&req); err != nil { web.Fail(c, web.NewBadRequest("请求参数错误")) return } folder, found := netdiskFolderDAO.GetByID(id) if !found { web.Fail(c, web.NewNotFoundError("目录不存在")) return } if netdiskIsRoot(folder.Path) { web.Fail(c, web.NewBadRequest("根目录不可改名或移动")) return } if req.Path != nil { newPath, err := netdiskValidatePath(*req.Path) if err != nil { web.Fail(c, web.NewBadRequest(err.Error())) return } if netdiskNormalizePath(newPath) == netdiskNormalizePath(folder.Path) { // 同名,跳过移动 } else { if strings.HasPrefix(newPath+"/", folder.Path+"/") { web.Fail(c, web.NewBadRequest("不能把目录移动到自己的子目录下")) return } if _, dup := netdiskFolderDAO.GetByPath(netdiskCompanyID, newPath); dup { web.Fail(c, web.NewBadRequest("目标目录「" + newPath + "」已存在")) return } // 联动后代目录 desc := netdiskFolderDAO.ListDescendants(netdiskCompanyID, folder.Path) for i := range desc { if desc[i].ID == folder.ID { continue } next := netdiskReplacePrefix(desc[i].Path, folder.Path, newPath) if next != desc[i].Path { desc[i].Path = next netdiskFolderDAO.Update(&desc[i]) } } // 联动该目录下(含后代)的文件 folder_path netdiskMigrateFilesOnFolderMove(folder.Path, newPath) folder.Path = newPath } } if req.DisplayName != nil && strings.TrimSpace(*req.DisplayName) != "" { folder.DisplayName = strings.TrimSpace(*req.DisplayName) } if !netdiskFolderDAO.Update(&folder) { web.Fail(c, web.NewBadRequest("更新目录失败")) return } web.OK(c, folder) } // netdiskMigrateFilesOnFolderMove 目录改名/移动后,把落在该目录及其后代的文件 folder_path 同步改前缀。 func netdiskMigrateFilesOnFolderMove(oldPrefix, newPrefix string) { // 取所有未被回收站软删的文件,逐个修正前缀。 folders := netdiskFolderDAO.ListDescendants(netdiskCompanyID, "") affected := map[string]bool{} for _, f := range folders { if f.Path == oldPrefix || strings.HasPrefix(f.Path, oldPrefix+"/") { affected[f.Path] = true } } if len(affected) == 0 { return } var files []model.NetdiskFile var all []model.NetdiskFile dal.New(&all).Find(&all) for i := range all { nf := all[i] if nf.CompanyID != netdiskCompanyID || nf.DeletedAt != nil { continue } next := netdiskReplacePrefix(nf.FolderPath, oldPrefix, newPrefix) if next != nf.FolderPath { nf.FolderPath = next files = append(files, nf) } } for i := range files { netdiskFileDAO.Update(&files[i]) } } // netdiskDeleteFolder DELETE /api/netdisk/folders/:id —— 删除空目录(非空 409) func netdiskDeleteFolder(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } folder, found := netdiskFolderDAO.GetByID(id) if !found { web.Fail(c, web.NewNotFoundError("目录不存在")) return } if netdiskIsRoot(folder.Path) { web.Fail(c, web.NewBadRequest("根目录不可删除")) return } if children := netdiskFolderDAO.CountChildren(netdiskCompanyID, folder.Path); children > 0 { web.Fail(c, web.NewBadRequest("该目录下还有子目录,请先清空再删除")) return } // 统计该目录下非回收站文件数 cnt := netdiskCountLiveFilesUnder(folder.Path) if cnt > 0 { web.Fail(c, web.NewBadRequest("该目录下还有 "+strconv.FormatInt(cnt, 10)+" 个文件,请先移走或删除")) return } if !netdiskFolderDAO.Delete(id) { web.Fail(c, web.NewBadRequest("删除失败")) return } web.OK(c, gin.H{"deleted": id}) } // netdiskCountLiveFilesUnder 统计某目录(含后代路径)下未软删除的文件数。 func netdiskCountLiveFilesUnder(folderPath string) int64 { var all []model.NetdiskFile dal.New(&all).Find(&all) var n int64 for _, f := range all { if f.CompanyID != netdiskCompanyID || f.DeletedAt != nil { continue } if f.FolderPath == folderPath { n++ } } return n } // ──────────────────────────────────────────────────────────── // 文件:列表 / 上传 // ──────────────────────────────────────────────────────────── // netdiskListFiles GET /api/netdisk/files?path=&status= —— 列某目录文件(默认只看 approved) func netdiskListFiles(c *gin.Context) { path := c.DefaultQuery("path", "/") norm := netdiskNormalizePath(path) status := c.DefaultQuery("status", "approved") files := netdiskFileDAO.ListByFolder(netdiskCompanyID, norm, status) web.OK(c, gin.H{"items": files, "total": len(files), "path": norm}) } var blockedNetdiskExt = map[string]bool{ "exe": true, "bin": true, "dll": true, "so": true, "dylib": true, "msi": true, "apk": true, "ipa": true, "deb": true, "rpm": true, "pkg": true, "appimage": true, "bat": true, "cmd": true, "com": true, "scr": true, "sys": true, "drv": true, "ps1": true, "psm1": true, "vbs": true, "vbe": true, "js": true, "jse": true, "wsf": true, "wsh": true, "reg": true, "lnk": true, "iso": true, "img": true, "dmg": true, } func netdiskIsUploadable(ext string) bool { return !blockedNetdiskExt[strings.ToLower(strings.TrimPrefix(ext, "."))] } func netdiskSizeLimit(ext string) int64 { ext = strings.ToLower(strings.TrimPrefix(ext, ".")) switch ext { case "mp4", "mov", "avi", "mkv", "webm", "m4v", "wmv", "flv": return Cfg.FileMaxVideo default: return Cfg.FileMaxDoc } } func netdiskRandomID() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return strconv.FormatInt(time.Now().UnixNano(), 36) } return hex.EncodeToString(b) } // netdiskUpload POST /api/netdisk/upload —— 直传 func netdiskUpload(c *gin.Context) { u := middleware.CurrentUser(c) folderPath := netdiskNormalizePath(c.PostForm("folder_path")) if _, err := netdiskValidatePath(folderPath); err != nil { folderPath = "/" } // 若指定了不存在的目录,回退根目录 if folderPath != "/" { if _, ok := netdiskFolderDAO.GetByPath(netdiskCompanyID, folderPath); !ok { folderPath = "/" } } file, header, err := c.Request.FormFile("file") if err != nil { web.Fail(c, web.NewBadRequest("缺少文件字段 file")) return } defer file.Close() ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(header.Filename), ".")) if !netdiskIsUploadable(ext) { web.Fail(c, web.NewBadRequest("不支持的文件类型")) return } if header.Size > netdiskSizeLimit(ext) { web.Fail(c, web.NewBadRequest("文件超过大小限制")) return } source := "employee" status := "pending" if u.Role == "admin" { source = "admin" status = "approved" // 管理员上传自动通过 } storedName := netdiskRandomID() + "." + ext dst := filepath.Join(netdiskRoot(), storedName) out, err := os.Create(dst) if err != nil { web.Fail(c, web.NewBadRequest("保存文件失败")) return } if _, err := io.Copy(out, file); err != nil { out.Close() os.Remove(dst) web.Fail(c, web.NewBadRequest("写入文件失败")) return } out.Close() m := model.NetdiskFile{ CompanyID: netdiskCompanyID, FolderPath: folderPath, Filename: header.Filename, StoredName: storedName, FileExt: ext, MimeType: mime.TypeByExtension("." + ext), FileSize: header.Size, Status: status, Source: source, SubmitterID: u.ID, CreatedAt: time.Now(), } if !netdiskFileDAO.Insert(&m) { os.Remove(dst) web.Fail(c, web.NewBadRequest("创建文件记录失败")) return } web.OK(c, gin.H{"file_id": m.ID, "status": m.Status, "filename": m.Filename}) } // netdiskDownload GET /api/netdisk/files/:id/download —— 下载(仅 approved) func netdiskDownload(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } f, found := netdiskFileDAO.GetByID(id) if !found { web.Fail(c, web.NewNotFoundError("文件不存在")) return } if f.Status != "approved" { web.Fail(c, web.NewForbiddenError("文件未通过审批,不可下载")) return } c.FileAttachment(netdiskFilePath(f.StoredName), f.Filename) } // netdiskPreview GET /api/netdisk/files/:id/preview —— 在线预览(仅 approved) func netdiskPreview(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } f, found := netdiskFileDAO.GetByID(id) if !found { web.Fail(c, web.NewNotFoundError("文件不存在")) return } if f.Status != "approved" { web.Fail(c, web.NewForbiddenError("文件未通过审批,不可预览")) return } web.OK(c, gin.H{ "preview_url": "/netdisk-file/" + f.StoredName, "file_ext": f.FileExt, "mime_type": f.MimeType, }) } // netdiskRenameFile PUT /api/netdisk/files/:id/rename —— 重命名(仅改名,不移动) func netdiskRenameFile(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } var req struct { Filename string `json:"filename"` } if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Filename) == "" { web.Fail(c, web.NewBadRequest("文件名不能为空")) return } f, found := netdiskFileDAO.GetByID(id) if !found { web.Fail(c, web.NewNotFoundError("文件不存在")) return } if f.DeletedAt != nil { web.Fail(c, web.NewBadRequest("文件在回收站中")) return } name := strings.TrimSpace(req.Filename) if netdiskNormalizePath(name) == "/" || strings.Contains(name, "/") || strings.Contains(name, "\\") { web.Fail(c, web.NewBadRequest("文件名不合法")) return } f.Filename = name if !netdiskFileDAO.Update(&f) { web.Fail(c, web.NewBadRequest("重命名失败")) return } web.OK(c, f) } // netdiskMoveFile PUT /api/netdisk/files/:id/move —— 移动到目标目录 func netdiskMoveFile(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } var req struct { FolderPath string `json:"folder_path"` } if err := c.ShouldBindJSON(&req); err != nil { web.Fail(c, web.NewBadRequest("请求参数错误")) return } target, err := netdiskValidatePath(req.FolderPath) if err != nil { web.Fail(c, web.NewBadRequest(err.Error())) return } if _, found := netdiskFolderDAO.GetByPath(netdiskCompanyID, target); !found && target != "/" { web.Fail(c, web.NewNotFoundError("目标目录不存在")) return } f, found := netdiskFileDAO.GetByID(id) if !found { web.Fail(c, web.NewNotFoundError("文件不存在")) return } if f.DeletedAt != nil { web.Fail(c, web.NewBadRequest("文件在回收站中")) return } f.FolderPath = target if !netdiskFileDAO.Update(&f) { web.Fail(c, web.NewBadRequest("移动失败")) return } web.OK(c, f) }