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>
This commit is contained in:
eaiadmin
2026-09-26 22:21:39 +08:00
co-authored by Claude Code
parent e73169df50
commit c1af86c934
192 changed files with 18046 additions and 392 deletions
@@ -0,0 +1,575 @@
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)
}