Files
pj0235-eai_agentplatform/eai_agentplatform/backend-go/internal/api/netdisk.go
T
eaiadminandClaude Code c1af86c934 feat(asr): 本地语音转写接入为一级路由 + 并行工作流合并提交
按用户指示做**一包提交**,不按工作流拆分。本提交刻意混合了多条并行线:

  · 本地 ASR 接管:audio 成为与 chat/embed/image/video 同等的路由类别
    (IsLocalRoute 单一判据、audio 健康探测、default_audio_route、
    auto 占位、GET /api/ai/routes/audio、回退云端时界面明示「音频已出网」)
  · LLM 调用层:ctx 贯穿、ToolCall/ToolSchema、EmptyCompletionError /
    TransientUpstreamError(按错误类型而非文案判重试)
  · 编排 Agent:general_assistant orchestrate/persistence/spec_driver
  · 联网搜索:internal/search(playwright)
  · 网盘:backend + 前端
  · 前端 UI:导航/路由/工作台若干页
  · 交付文档:DELIVERY.md / AR04 / 部署文档的「无 Python」表述据实改写,
    新增 eai_agentplatform-asr.service、asr.env、clonezilla-cleanup 清 ~/asr-poc

不分拆的原因:dev 早期,粒度不该打断工作节奏。且实测过——这些改动
**在编译上是同一个单元**(llm.go 的 ctx 签名变更牵动 12 个调用点,
chat_message.go 的 ctx 改动又与编排重写同处一个 hunk),拆出来的中间态编不过。
详见 TOP_CODING_RULES.md G14.5 与 bugs_and_errors.md E09。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 22:21:39 +08:00

575 lines
16 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 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)
}