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
@@ -106,7 +106,7 @@ func ChatMessage(c *gin.Context) {
}
start := time.Now()
var replyBuilder strings.Builder
usedAiRoute, err := ai.GenerateStreamWithFallback(aiRoute, plan.LLMMessages, func(chunk string) {
usedAiRoute, err := ai.GenerateStreamWithFallback(c.Request.Context(), aiRoute, plan.LLMMessages, func(chunk string) {
replyBuilder.WriteString(chunk)
writeEvent(gin.H{"type": "text", "content": chunk})
})
@@ -281,7 +281,7 @@ func QuickAction(c *gin.Context) {
}
start := time.Now()
result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages)
result, usedAiRoute, err := ai.GenerateFullWithFallback(c.Request.Context(), aiRoute, aiMessages)
if err != nil {
ai.LogCall(ai.LogEntry{
UserID: user.ID, UsageKind: ai.UsageKindTextGen, Provider: aiRoute.Provider,
@@ -21,6 +21,7 @@ type routeItem struct {
Description string `json:"description"`
ShortRouteName string `json:"short_route_name,omitempty"`
ShortModelName string `json:"short_model_name,omitempty"`
IsLocal bool `json:"is_local"` // base_url 是回环即本地,见 config.IsLocalRoute
Healthy bool `json:"healthy"`
Checked bool `json:"checked"`
LatencyMs int64 `json:"latency_ms,omitempty"`
@@ -41,6 +42,7 @@ func toRouteItems(routes []*config.RouteConfig) []routeItem {
Description: r.Description,
ShortRouteName: r.ShortRouteName,
ShortModelName: r.ShortModelName,
IsLocal: config.IsLocalRoute(r),
Healthy: health.Healthy,
Checked: health.Checked,
LatencyMs: health.LatencyMs,
@@ -59,12 +61,14 @@ func autoRouteItem(routeID string, category string) routeItem {
ShortModelName: "检测中",
}
resolved, err := config.GetRoute(routeID)
// 按分类解析:audio 走 GetAudioRoute,见 config.GetRouteForCategory。
resolved, err := config.GetRouteForCategory(routeID, category)
if err == nil && resolved != nil {
health, _ := config.GetRouteHealth(resolved.RouteID)
item.Provider = resolved.Provider
item.Model = resolved.Model
item.BaseURL = resolved.BaseURL
item.IsLocal = config.IsLocalRoute(resolved)
item.ShortModelName = resolved.ShortModelName
if item.ShortModelName == "" {
item.ShortModelName = "优选"
@@ -85,6 +89,9 @@ func autoRouteItem(routeID string, category string) routeItem {
if category == "embed" {
item.ShortModelName = "Emb"
}
if category == "audio" {
item.ShortModelName = "ASR"
}
return item
}
@@ -118,3 +125,18 @@ func ListEmbedRoutes(c *gin.Context) {
items = append(items, toRouteItems(routes)...)
web.OK(c, gin.H{"routes": items})
}
// ListAudioRoutes 返回可用语音转写(ASR)路由列表。
//
// 与 chat/embed 两份是同一形状,多出来的是 base_url 里已经能看出本地/云端 ——
// 前端据 is_local 标注「音频不出网」,不必自己再去猜 provider 名。
func ListAudioRoutes(c *gin.Context) {
routes, err := config.GetRoutesByCategory("audio")
if err != nil {
web.Fail(c, web.NewLLMNotConfigured("路由加载失败: "+err.Error()))
return
}
items := []routeItem{autoRouteItem(config.AutoAudioRouteID, "audio")}
items = append(items, toRouteItems(routes)...)
web.OK(c, gin.H{"routes": items})
}
@@ -1,14 +1,15 @@
// ai_routetest.go —— 「AI 通路测试」的管理员接口。
//
// 用于开发/运维对某个 AI 模型路由/具体模型做一次完整业务验证:
// - 获取某路由的候选模型清单(配置 model ∪ 自动发现 /models)
// - 对单个模型 或 候选模型逐个,发起一次真实请求并记录原始报文 / 产物预览
// - 获取某路由的候选模型清单(配置 model ∪ 自动发现 /models)
// - 对单个模型 或 候选模型逐个,发起一次真实请求并记录原始报文 / 产物预览
//
// 设计约束:本接口的测试调用**不扣算力点、不写 ai_call_log**,纯后台诊断。
package api
import (
"encoding/json"
"fmt"
"io"
"github.com/gin-gonic/gin"
@@ -33,6 +34,10 @@ func AIRouteTestModels(c *gin.Context) {
web.Fail(c, web.NewBadRequest("路由不存在: "+req.RouteID))
return
}
if err := assertRouteResolvedAsRequested(route, req.RouteID); err != nil {
web.Fail(c, web.NewBadRequest(err.Error()))
return
}
candidates := []string{route.Model}
discovered, _ := routetest.DiscoverModels(route)
@@ -42,14 +47,14 @@ func AIRouteTestModels(c *gin.Context) {
}
web.OK(c, gin.H{
"route_id": route.RouteID,
"provider": route.Provider,
"category": route.Category,
"base_url": route.BaseURL,
"endpoint": route.Endpoint,
"route_id": route.RouteID,
"provider": route.Provider,
"category": route.Category,
"base_url": route.BaseURL,
"endpoint": route.Endpoint,
"config_model": route.Model,
"candidates": candidates,
"discovered": discovered,
"candidates": candidates,
"discovered": discovered,
})
}
@@ -77,8 +82,27 @@ func AIRouteTestRun(c *gin.Context) {
web.Fail(c, web.NewBadRequest("路由不存在: "+req.RouteID))
return
}
if err := assertRouteResolvedAsRequested(route, req.RouteID); err != nil {
web.Fail(c, web.NewBadRequest(err.Error()))
return
}
opts := &routetest.TestOptions{Prompt: req.Prompt, Models: req.Models, Size: req.Size}
result := routetest.Do(route, opts)
web.OK(c, gin.H{"result": result})
}
// assertRouteResolvedAsRequested 确认解析出来的就是请求的那条路由。
//
// GetRoute 找不到 route_id 时会静默回退 default_route(一条 chat 路由)。
// 那意味着页面拿着「路由 A」的标题去测路由 B,而结果看起来完全正常 ——
// 这类失败最难发现。auto 占位 id 例外:它本来就会被换成此刻健康的具体路由。
func assertRouteResolvedAsRequested(route *config.RouteConfig, requested string) error {
if route == nil {
return fmt.Errorf("路由不存在: %s", requested)
}
if config.IsAutoRouteID(requested) || route.RouteID == requested {
return nil
}
return fmt.Errorf("路由不存在: %s(解析成了 %s)", requested, route.RouteID)
}
@@ -0,0 +1,68 @@
package api
import (
"strings"
"testing"
"eai_agentplatform/backend/internal/config"
)
// 这一组钉的是「测试的路由就是你选的那条」。
//
// GetRoute 找不到 route_id 会静默回退 default_route。通路测试的入参来自前端
// 路由列表里的具体 id,一旦解析成了别的路由,页面会拿着「路由 A」的标题
// 去测路由 B,而结果看起来完全正常 —— 这类失败最难发现。
//
// 但「自动」占位 id 必须放行:它本来就该被换成此刻健康的那条具体路由,
// 拦下来等于把通路测试里「自动」这一项弄坏了。
func TestAssertRouteResolvedAsRequestedRejectsFallback(t *testing.T) {
route, err := config.GetRoute("chat_route_lmuai_deepseek_v4_flash")
if err != nil {
t.Fatalf("用例前提:该路由应能解析:%v", err)
}
// 一个不存在的 id:GetRoute 会回退到 default_route,解析结果不是它。
got, err := config.GetRoute("audio_route_nope_not_defined")
if err != nil {
t.Fatalf("用例前提:GetRoute 对未知 id 会回退默认路由,不该报错:%v", err)
}
if got.RouteID == "audio_route_nope_not_defined" {
t.Skip("前提不成立:该 id 竟然被解析成了自己")
}
if err := assertRouteResolvedAsRequested(got, "audio_route_nope_not_defined"); err == nil {
t.Fatalf("未知 id 解析成了 %s,守卫却放行了", got.RouteID)
} else if !strings.Contains(err.Error(), got.RouteID) {
// 报错要说明「解析成了谁」,否则排查时看不出是回退造成的。
t.Errorf("报错没带上实际解析到的路由 %s:%v", got.RouteID, err)
}
// 正常情况:解析结果就是请求的那条。
if err := assertRouteResolvedAsRequested(route, route.RouteID); err != nil {
t.Errorf("请求自己的 id 被拦了:%v", err)
}
}
// TestAssertRouteResolvedAsRequestedAllowsAutoIds 「自动」占位 id 必须放行。
//
// 拦下来会让通路测试里的「自动」选项直接报「路由不存在」——
// 而它恰恰是最该能测的一项(它就是线上真正在走的那条)。
//
// 这里用构造出来的路由,**不**去 config 里解析 auto id:解析会触发
// resolveAutoRoute → 健康巡检,而 chat 的巡检是真的发一次 /chat/completions,
// 单测不该去打云端的计费接口。守卫的契约只跟 id 的字面量有关。
func TestAssertRouteResolvedAsRequestedAllowsAutoIds(t *testing.T) {
resolved := &config.RouteConfig{RouteID: "chat_route_lmuai_deepseek_v4_flash", Category: "chat"}
for _, id := range []string{config.AutoChatRouteID, config.AutoEmbedRouteID, config.AutoAudioRouteID} {
if err := assertRouteResolvedAsRequested(resolved, id); err != nil {
t.Errorf("%s 被守卫拦下了:%v", id, err)
}
}
}
func TestAssertRouteResolvedAsRequestedRejectsNil(t *testing.T) {
if err := assertRouteResolvedAsRequested(nil, "whatever"); err == nil {
t.Error("nil 路由应当报错")
}
}
@@ -0,0 +1,67 @@
package api
import (
"encoding/json"
"github.com/gin-gonic/gin"
generalassistant "eai_agentplatform/backend/internal/specialists/packages/general_assistant"
"eai_agentplatform/backend/internal/web"
)
// OpeningCard 通用助手开场白身份(单一事实源在后端 InteractionCardJSON)。
type OpeningCard struct {
Label string `json:"label"`
Meta string `json:"meta"`
Greeting string `json:"greeting"`
Tagline string `json:"tagline"`
OpeningPrompt string `json:"opening_prompt"`
StarterPrompts []string `json:"starter_prompts"`
}
// GetAssistantOpening GET /api/assistant/opening —— 通用助手开场白身份。
//
// 为什么单独一个端点而不是走专员列表:通用助手(state=system)按设计不出现在
// 专员列表/市场上,但它的开场白是对话工作台的欢迎画面数据源。后端 InteractionCardJSON
// 是唯一事实源,前端不再硬编码开场白。
func GetAssistantOpening(c *gin.Context) {
// 从通用助手 manifest 读取开场白身份(单一事实源)。
card := parseOpeningCard(generalassistant.Manifest.InteractionCardJSON)
card.Label = generalassistant.Manifest.Label
web.OK(c, card)
}
// parseOpeningCard 解析 InteractionCardJSON 为 OpeningCard,缺字段给默认兜底。
func parseOpeningCard(raw string) OpeningCard {
card := OpeningCard{
Meta: "默认专员 · 通用协作",
Greeting: "博昇AI数字员工,您说,我做",
}
if raw == "" {
return card
}
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return card
}
if v, ok := m["greeting"].(string); ok && v != "" {
card.Greeting = v
}
if v, ok := m["tagline"].(string); ok && v != "" {
card.Tagline = v
}
if v, ok := m["opening_prompt"].(string); ok && v != "" {
card.OpeningPrompt = v
}
if v, ok := m["relationship_to_user"].(string); ok && v != "" {
card.Meta = v
}
if v, ok := m["starter_prompts"].([]any); ok {
for _, item := range v {
if s, ok := item.(string); ok {
card.StarterPrompts = append(card.StarterPrompts, s)
}
}
}
return card
}
@@ -0,0 +1,79 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// GET /api/ai/routes/audio 的真实请求验证。
//
// 走的是真路由 + 真中间件(复用 audioRouteFixture 起的 gin 引擎),不是直接调
// handler 函数 —— 「go test 全绿测不到 handler」在这里尤其成立:这个接口的意义
// 就是「语音路由能在后台像 chat/embed 一样被列出来」,只有真发一次请求才验得到
// 注册、鉴权、序列化这三段。
//
// 断言不依赖本机 8090 起没起:列出来的是**配置**里的路由,健康字段另算。
func TestListAudioRoutesEndpoint(t *testing.T) {
fx := newAudioRouteFixture(t)
req := httptest.NewRequest(http.MethodGet, "/api/ai/routes/audio", nil)
rec := httptest.NewRecorder()
fx.engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /api/ai/routes/audio = %d,期望 200;body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data struct {
Routes []struct {
AIRouteID string `json:"ai_route_id"`
Provider string `json:"provider"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
IsLocal bool `json:"is_local"`
ResolvedAIRouteID string `json:"resolved_ai_route_id"`
} `json:"routes"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("响应不是预期结构:%v;body=%s", err, rec.Body.String())
}
routes := body.Data.Routes
if len(routes) == 0 {
t.Fatal("routes 为空 —— 语音路由没有出现在后台列表里")
}
var sawAuto, sawLocal bool
for _, r := range routes {
// id 前缀是这条路由属于哪个分类的唯一外部可见证据(routeItem 不带 category)。
// 混进一条 chat 路由就会在这里红 —— 那正是 GetRoute 回退 default_route 的症状。
if !strings.HasPrefix(r.AIRouteID, "audio_route") {
t.Errorf("列表里出现了非语音路由 %q(provider=%s base_url=%s)", r.AIRouteID, r.Provider, r.BaseURL)
}
if r.Model == "" {
t.Errorf("%s 的 model 为空 —— 前台选它之后会拿空模型名去转写", r.AIRouteID)
}
if r.AIRouteID == "audio_route_auto" {
sawAuto = true
if !strings.HasPrefix(r.ResolvedAIRouteID, "audio_route") {
t.Errorf("「自动」解析到了 %q —— 不是语音路由", r.ResolvedAIRouteID)
}
}
if r.IsLocal {
sawLocal = true
if !strings.Contains(r.BaseURL, "127.0.0.1") && !strings.Contains(r.BaseURL, "localhost") {
t.Errorf("%s 被判为本地,base_url 却是 %q", r.AIRouteID, r.BaseURL)
}
}
}
if !sawAuto {
t.Error("列表里没有 audio_route_auto —— 后台就选不了「自动」")
}
if !sawLocal {
t.Error("列表里没有一条 is_local=true 的路由 —— 界面无法标出「本地」")
}
}
@@ -0,0 +1,516 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/auth"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/dal"
"eai_agentplatform/backend/internal/model"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
"eai_agentplatform/backend/internal/store"
)
// TestMain 定位 backend-go 并切换工作目录,让 config/ai_config.json 在测试进程内
// 可被找到(configDir() 的回退逻辑依赖 CWD/config)。
func TestMain(m *testing.M) {
if base := locateBackendGoForTest(); base != "" {
_ = os.Chdir(base)
}
os.Exit(m.Run())
}
// locateBackendGoForTest 从当前工作目录向上找含 config/ai_config.json 的目录。
func locateBackendGoForTest() string {
dir, err := os.Getwd()
if err != nil {
return ""
}
for {
if _, err := os.Stat(filepath.Join(dir, "config", "ai_config.json")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
// audioRouteFixture 一套跑真实路由的测试环境:临时库 + 临时 KB 目录 + 两个用户 + 任务 + 音频素材。
//
// 全部落在 t.TempDir() 下,绝不碰 data/ 里的真实库与素材。
type audioRouteFixture struct {
engine *gin.Engine
token string
other string // 另一个用户的令牌(用于越权用例)
taskID uint
media uint
rawURL string // 素材的落盘路径,负路径用例要用
}
func newAudioRouteFixture(t *testing.T) *audioRouteFixture {
t.Helper()
gin.SetMode(gin.TestMode)
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
prevDB := store.DB
prevDalDB := dal.DB
prevCfg := Cfg
db, err := store.Init(dbPath)
if err != nil {
t.Fatalf("初始化测试库失败: %v", err)
}
// dal.DB 是在 dal 包 init() 时从 store.DB 抄过去的,那时 store.DB 还是 nil。
// 测试里换了库必须同步,否则零值仓库(dal.MediaFileDAO{} 这类)会解引用 nil。
dal.SetDB(db)
t.Cleanup(func() {
store.DB = prevDB
dal.SetDB(prevDalDB)
Cfg = prevCfg
})
kbDir := filepath.Join(dir, "kb_data")
approved := filepath.Join(kbDir, "approved")
if err := os.MkdirAll(approved, 0o755); err != nil {
t.Fatalf("创建素材目录失败: %v", err)
}
// KB_DATA_DIR 必须**同时**指到临时目录,光在上面那个 cfg 里写不够。
//
// 技能包(skills/packages/audio_transcribe)读的是 config.Load().KBDataDir,
// 不是 RegisterRoutes 收到的那份 cfg —— 它跨了包,拿不到 api.Cfg,这是分层使然,
// 生产里两者同源于环境变量所以一致,测试里却必须自己对齐。
//
// 不对齐的后果有两个,第二个更要命:
// 1) config.Load() 会走 backendBaseDir(),测试二进制在 /tmp/go-buildXXXXXX/b001/
// 下,于是素材目录变成 go-build 的临时目录 —— 第 2 步会以一句
// 「读取音频文件失败:no such file or directory」失败,跟转写本身毫无关系;
// 2) 万一哪天推出来的正好是真实的 data/,这个用例就会去读**用户的素材**。
// 所以这里不只设置,还当场断言它真的生效了。
t.Setenv("KB_DATA_DIR", kbDir)
if resolved := config.Load().KBDataDir; resolved != kbDir {
t.Fatalf("KB_DATA_DIR 没有生效:config.Load() 得到 %q,期望 %q", resolved, kbDir)
}
// 先写一个假的 mp3:只有 scope 步骤读它(用 ffprobe 探时长,探不出就走「未知」分支)。
// 真正的转写用例会把 ASR_TEST_AUDIO 拷进来覆盖。
stored := "audio_route_test.mp3"
if err := os.WriteFile(filepath.Join(approved, stored), []byte("ID3-fake-audio"), 0o644); err != nil {
t.Fatalf("写入测试音频失败: %v", err)
}
owner := model.User{Username: "asr_owner", FullName: "转写测试员", Role: "employee", Status: "active"}
intruder := model.User{Username: "asr_intruder", FullName: "路人甲", Role: "employee", Status: "active"}
if err := store.DB.Create(&owner).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
if err := store.DB.Create(&intruder).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
task := model.TaskRecord{
SpecialistKey: "general-assistant",
Title: "转写",
Owner: owner.FullName,
Status: specialistruntime.TaskStatusDraft,
CreatedBy: &owner.ID,
}
if err := store.DB.Create(&task).Error; err != nil {
t.Fatalf("创建任务失败: %v", err)
}
media := model.MediaFile{
Filename: "20260925_094810.mp3",
StoredName: stored,
StoredPath: stored,
FileExt: "mp3",
FileSize: 15,
Status: "approved",
Source: "employee",
SubmitterID: owner.ID,
}
if err := store.DB.Create(&media).Error; err != nil {
t.Fatalf("创建素材失败: %v", err)
}
cfg := &config.Config{
DBPath: dbPath,
KBDataDir: kbDir,
NetdiskDataDir: filepath.Join(dir, "netdisk"),
JWTSecret: "audio-route-test-secret",
JWTExpireMin: 60,
}
engine := gin.New()
RegisterRoutes(engine, cfg)
ownerToken, err := auth.CreateToken(owner.Username, owner.Role, cfg.JWTSecret, cfg.JWTExpireMin)
if err != nil {
t.Fatalf("签发令牌失败: %v", err)
}
intruderToken, err := auth.CreateToken(intruder.Username, intruder.Role, cfg.JWTSecret, cfg.JWTExpireMin)
if err != nil {
t.Fatalf("签发令牌失败: %v", err)
}
fx := &audioRouteFixture{
engine: engine,
token: ownerToken,
other: intruderToken,
taskID: task.ID,
media: media.ID,
rawURL: filepath.Join(approved, stored),
}
return fx
}
// post 发一次真实 HTTP 请求(走 gin 引擎与真实鉴权中间件)。
func (fx *audioRouteFixture) post(t *testing.T, token, path string, body gin.H) (int, map[string]any) {
t.Helper()
payload, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
fx.engine.ServeHTTP(rec, req)
var decoded map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("%s 返回的不是 JSON:%s", path, rec.Body.String())
}
return rec.Code, decoded
}
// dataOf 取出响应信封里的 data 段。
func dataOf(t *testing.T, body map[string]any) map[string]any {
t.Helper()
data, ok := body["data"].(map[string]any)
if !ok {
t.Fatalf("响应没有 data 段:%v", body)
}
return data
}
// errorMessageOf 取出错误信封里的用户可见消息。
func errorMessageOf(t *testing.T, body map[string]any) string {
t.Helper()
msg, _ := body["message"].(string)
return msg
}
// TestAudioSkillScopeStepWritesRunAndArtifact 是「工作流 x/4 会不会动」的根:
// 第 1 步必须真的落一条 action_key = audio-transcribe:scope 的 task_run 与一条产物,
// 否则右栏的计数与产物区永远停在原地(这正是用户报的现象)。
func TestAudioSkillScopeStepWritesRunAndArtifact(t *testing.T) {
fx := newAudioRouteFixture(t)
code, body := fx.post(t, fx.token, "/api/skills/audio/scope", gin.H{
"task_id": fx.taskID,
"media_id": fx.media,
"language": "zh",
})
if code != http.StatusOK {
t.Fatalf("scope 步骤返回 %d:%s", code, errorMessageOf(t, body))
}
data := dataOf(t, body)
if got := data["action_key"]; got != "audio-transcribe:scope" {
t.Errorf("action_key = %v,期望 audio-transcribe:scope", got)
}
var runs []model.TaskRun
store.DB.Where("task_id = ?", fx.taskID).Find(&runs)
if len(runs) != 1 {
t.Fatalf("task_run 数 = %d,期望 1", len(runs))
}
if runs[0].ActionKey != "audio-transcribe:scope" || runs[0].Status != "done" {
t.Errorf("task_run 落库不对:action_key=%q status=%q", runs[0].ActionKey, runs[0].Status)
}
var artifacts []model.TaskArtifact
store.DB.Where("task_id = ?", fx.taskID).Find(&artifacts)
if len(artifacts) != 1 {
t.Fatalf("task_artifact 数 = %d,期望 1", len(artifacts))
}
if artifacts[0].ArtifactType != "text" || artifacts[0].Title != "转写要求" {
t.Errorf("产物不对:type=%q title=%q", artifacts[0].ArtifactType, artifacts[0].Title)
}
if strings.TrimSpace(artifacts[0].ContentText) == "" {
t.Error("产物正文为空 —— 右栏点开会是一片空白")
}
if artifacts[0].CreatedByRunID == nil || *artifacts[0].CreatedByRunID != runs[0].ID {
t.Error("产物的 created_by_run_id 没有指向本次 run —— 右栏按 run 取产物会取不到")
}
var task model.TaskRecord
store.DB.First(&task, fx.taskID)
if strings.TrimSpace(task.CurrentResult) == "" {
t.Error("task.current_result 为空")
}
if task.CurrentRunID == nil || *task.CurrentRunID != runs[0].ID {
t.Error("task.current_run_id 没有指向本次 run")
}
}
// TestAudioSkillKeepsStepsIndependent 断言第二步不会把第一步的 run 顶掉:
// 四步各写各的 run,前端才数得出 x/4。
func TestAudioSkillKeepsStepsIndependent(t *testing.T) {
fx := newAudioRouteFixture(t)
if code, body := fx.post(t, fx.token, "/api/skills/audio/scope", gin.H{
"task_id": fx.taskID, "media_id": fx.media,
}); code != http.StatusOK {
t.Fatalf("scope 返回 %d:%s", code, errorMessageOf(t, body))
}
// 第 3 步依赖第 2 步的产物,这里没有,必须报错且不留痕。
code, body := fx.post(t, fx.token, "/api/skills/audio/structure", gin.H{"task_id": fx.taskID})
if code != http.StatusBadRequest {
t.Fatalf("缺上一步产物时 structure 返回 %d,期望 400:%v", code, body)
}
if msg := errorMessageOf(t, body); !strings.Contains(msg, "逐字转写稿") {
t.Errorf("报错没有点明缺哪一份产物:%q", msg)
}
var count int64
store.DB.Model(&model.TaskRun{}).Where("task_id = ?", fx.taskID).Count(&count)
if count != 1 {
t.Errorf("失败的那一步也落了 run(task_run 数 = %d,期望仍是 1)—— 会出现假进度", count)
}
store.DB.Model(&model.TaskArtifact{}).Where("task_id = ?", fx.taskID).Count(&count)
if count != 1 {
t.Errorf("失败的那一步也落了产物(task_artifact 数 = %d,期望仍是 1)", count)
}
}
// TestAudioSkillRejectsForeignTaskAndMedia 是两条越权路径:
// 别人的任务不能借我的令牌跑,别人的录音也不能拿来转写。
func TestAudioSkillRejectsForeignTaskAndMedia(t *testing.T) {
fx := newAudioRouteFixture(t)
code, body := fx.post(t, fx.other, "/api/skills/audio/scope", gin.H{
"task_id": fx.taskID, "media_id": fx.media,
})
if code != http.StatusNotFound {
t.Errorf("拿别人的任务调接口返回 %d,期望 404:%v", code, body)
}
code, body = fx.post(t, fx.token, "/api/skills/audio/scope", gin.H{
"task_id": fx.taskID, "media_id": 999999,
})
if code != http.StatusBadRequest {
t.Errorf("不存在的 media_id 返回 %d,期望 400:%v", code, body)
}
if msg := errorMessageOf(t, body); !strings.Contains(msg, "不存在") {
t.Errorf("报错没有点明素材不存在:%q", msg)
}
}
// TestAudioSkillRejectsMissingParams 断言缺 task_id / media_id 时的报错是可读的,
// 而不是走到一半 panic 或静默返回空稿。
func TestAudioSkillRejectsMissingParams(t *testing.T) {
fx := newAudioRouteFixture(t)
code, body := fx.post(t, fx.token, "/api/skills/audio/scope", gin.H{"media_id": fx.media})
if code != http.StatusBadRequest || !strings.Contains(errorMessageOf(t, body), "task_id") {
t.Errorf("缺 task_id 的响应不对:%d %v", code, body)
}
code, body = fx.post(t, fx.token, "/api/skills/audio/scope", gin.H{"task_id": fx.taskID})
if code != http.StatusBadRequest || !strings.Contains(errorMessageOf(t, body), "media_id") {
t.Errorf("缺 media_id 的响应不对:%d %v", code, body)
}
}
// TestAudioSkillRequiresAuth 断言六个端点都挂了鉴权中间件,不是裸奔的。
//
// 逐个列出来而不是循环拼前缀:路由表是**写死**在 router.go 里的,
// 真要防的正是「新加了一个端点却忘了挂中间件」—— 用循环拼出来的话,
// 新端点只要不进这个列表就永远测不到。
func TestAudioSkillRequiresAuth(t *testing.T) {
fx := newAudioRouteFixture(t)
for _, path := range []string{
"/api/skills/audio/scope",
"/api/skills/audio/transcribe",
"/api/skills/audio/speakers",
"/api/skills/audio/speakers/confirm",
"/api/skills/audio/structure",
"/api/skills/audio/minutes",
} {
code, _ := fx.post(t, "", path, gin.H{"task_id": fx.taskID})
if code != http.StatusUnauthorized {
t.Errorf("%s 未带令牌返回 %d,期望 401", path, code)
}
}
}
// TestAudioSkillFourStepsEndToEnd 跑完整四步,断言「工作流 4/4」真的能走满。
//
// 默认跳过:第 2 步会把音频发到云端 ASR(当前路由是 SiliconFlow),且会真的消耗额度。
// 显式给音频路径才跑:
//
// ASR_TEST_AUDIO=/tmp/asr_two_speakers.mp3 go test ./internal/api/ -run TestAudioSkillFourStepsEndToEnd -v
func TestAudioSkillFourStepsEndToEnd(t *testing.T) {
audioPath := os.Getenv("ASR_TEST_AUDIO")
if audioPath == "" {
t.Skip("未设置 ASR_TEST_AUDIO,跳过真实转写的四步端到端测试")
}
source, err := os.ReadFile(audioPath)
if err != nil {
t.Fatalf("读取测试音频失败: %v", err)
}
fx := newAudioRouteFixture(t)
if err := os.WriteFile(fx.rawURL, source, 0o644); err != nil {
t.Fatalf("替换测试音频失败: %v", err)
}
steps := []string{"scope", "transcribe", "structure", "minutes"}
wantActions := []string{
"audio-transcribe:scope",
"audio-transcribe:transcribe",
"audio-transcribe:structure",
"audio-transcribe:minutes",
}
wantTypes := []string{"text", "transcript", "document", "checklist"}
for index, step := range steps {
body := gin.H{"task_id": fx.taskID}
if step == "scope" || step == "transcribe" {
body["media_id"] = fx.media
body["language"] = "zh"
} else {
// 空串 = 界面上没选模型,走技能自带那条大预算路由(不是 default_route)。
// 传空串而非写死某条路由 id,是为了让这条端到端验的正是线上默认走法。
body["ai_route_id"] = ""
}
code, resp := fx.post(t, fx.token, "/api/skills/audio/"+step, body)
if code != http.StatusOK {
t.Fatalf("第 %d 步 %s 返回 %d:%s", index+1, step, code, errorMessageOf(t, resp))
}
data := dataOf(t, resp)
if got := data["action_key"]; got != wantActions[index] {
t.Errorf("第 %d 步 action_key = %v,期望 %s", index+1, got, wantActions[index])
}
if content, _ := data["content"].(string); strings.TrimSpace(content) == "" {
t.Errorf("第 %d 步 %s 的内容为空", index+1, step)
}
t.Logf("第 %d 步 %s 完成:%v", index+1, step, data["summary"])
}
var runs []model.TaskRun
store.DB.Where("task_id = ?", fx.taskID).Order("id ASC").Find(&runs)
if len(runs) != 4 {
t.Fatalf("task_run 数 = %d,期望 4(右栏 x/4 走不满)", len(runs))
}
for index, run := range runs {
if run.ActionKey != wantActions[index] {
t.Errorf("第 %d 条 run 的 action_key = %q,期望 %q", index+1, run.ActionKey, wantActions[index])
}
}
var artifacts []model.TaskArtifact
store.DB.Where("task_id = ?", fx.taskID).Order("id ASC").Find(&artifacts)
if len(artifacts) != 4 {
t.Fatalf("task_artifact 数 = %d,期望 4", len(artifacts))
}
for index, artifact := range artifacts {
if artifact.ArtifactType != wantTypes[index] {
t.Errorf("第 %d 个产物 type = %q,期望 %q", index+1, artifact.ArtifactType, wantTypes[index])
}
if strings.TrimSpace(artifact.ContentText) == "" {
t.Errorf("第 %d 个产物(%s)正文为空", index+1, artifact.Title)
}
t.Logf("产物 %d:%s / %s(%d 字)", index+1, artifact.Title, artifact.ArtifactType,
len([]rune(artifact.ContentText)))
}
fmt.Printf("四步端到端完成:%d runs / %d artifacts\n", len(runs), len(artifacts))
}
// TestAudioSkillLLMStepsAcceptAutoRoute 验第 3、4 步在「界面上选的是自动路由」时也能跑通。
//
// 为什么单独一条:界面把 selectedAiChatRouteId 原样当 ai_route_id 传下来,而它的默认值
// 是 chat_route_auto —— 一个**虚拟路由 id**,在 ai_config.json 的 chat_routes 里查不到
// 同名条目(GetRoute 见到它才转去 resolveAutoRoute)。resolveAudioChatRoute 要是只做
// 「表里找不找得到」的检查,用户一进页面(没手动切过线路)就会撞上「分类不是 chat」。
// 这里用真实请求把这条路径钉死,而不是靠读代码推断。
//
// 第 2 步不在这里:ASR 要真实音频,见 TestAudioSkillFourStepsEndToEnd。
// 本用例直接往库里种一份逐字稿,把「取上一步产物 → 调 LLM → 落库」这条链路单独验掉。
//
// 默认跳过(会真的调 LLM,消耗额度):
//
// AUDIO_SKILL_LLM_TEST=1 go test ./internal/api/ -run TestAudioSkillLLMStepsAcceptAutoRoute -v
func TestAudioSkillLLMStepsAcceptAutoRoute(t *testing.T) {
if os.Getenv("AUDIO_SKILL_LLM_TEST") == "" {
t.Skip("未设置 AUDIO_SKILL_LLM_TEST,跳过真实调用 LLM 的第 3/4 步测试")
}
fx := newAudioRouteFixture(t)
// 种一份逐字稿,替代第 2 步。内容刻意带两个人的对话,好看出模型有没有真的读懂。
transcript := strings.Join([]string{
"【00:00 说话人1】我们先过一下这周的进度,主要是三件事:素材审批、知识库检索、还有转写。",
"【00:12 说话人2】素材审批这块我这边周四之前能提测,但还差一个驳回理由的必填校验。",
"【00:35 说话人1】那就周四提测,驳回理由必填这周必须加上,不然审批记录看不出来为什么退。",
"【01:02 说话人2】转写我建议先用云端跑通,本地 whisper 的部署下周再排。",
"【01:20 说话人1】同意,云端先跑通,但配置要能一键切回本地,不能写死。",
}, "\n")
seeded := model.TaskArtifact{
TaskID: fx.taskID,
Title: "逐字转写稿",
ArtifactType: "transcript",
ContentText: transcript,
Status: specialistruntime.ArtifactStatusDraft,
}
if err := store.DB.Create(&seeded).Error; err != nil {
t.Fatalf("种入逐字稿失败: %v", err)
}
wantActions := map[string]string{
"structure": "audio-transcribe:structure",
"minutes": "audio-transcribe:minutes",
}
for _, step := range []string{"structure", "minutes"} {
code, body := fx.post(t, fx.token, "/api/skills/audio/"+step, gin.H{
"task_id": fx.taskID,
"ai_route_id": "chat_route_auto",
})
if code != http.StatusOK {
t.Fatalf("%s 返回 %d:%s", step, code, errorMessageOf(t, body))
}
data := dataOf(t, body)
if got := data["action_key"]; got != wantActions[step] {
t.Errorf("%s 的 action_key = %v,期望 %s", step, got, wantActions[step])
}
content, _ := data["content"].(string)
if strings.TrimSpace(content) == "" {
t.Errorf("%s 的正文为空 —— 右栏产物点开会是空白", step)
}
t.Logf("%s 完成:%v(正文 %d 字)", step, data["summary"], len([]rune(content)))
}
var runs []model.TaskRun
store.DB.Where("task_id = ?", fx.taskID).Order("id ASC").Find(&runs)
if len(runs) != 2 {
t.Fatalf("task_run 数 = %d,期望 2(只跑了第 3、4 步)", len(runs))
}
for index, run := range runs {
want := []string{wantActions["structure"], wantActions["minutes"]}[index]
if run.ActionKey != want {
t.Errorf("第 %d 条 run 的 action_key = %q,期望 %q", index+1, run.ActionKey, want)
}
}
}
@@ -0,0 +1,309 @@
package api
import (
"fmt"
"net/http"
"strings"
"testing"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/model"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
"eai_agentplatform/backend/internal/store"
)
// 这一组用例走**真实路由 + 真实鉴权中间件**,钉住「核对说话人姓名」这条流程的接口面。
//
// 为什么不能只测函数:这道闸门与确认端点分处两层,中间还隔着 gin 的绑定与鉴权。
// 只测函数的话,路由少注册一行、或 handler 里少调一次闸门,函数级的用例全绿而
// 线上照样能越过确认 —— 仓库里已有的教训(go test 全绿测不到 handler)。
// seedTranscriptAndSpeakers 在任务下铺好「已经转写完、身份已推断待确认」的现场。
//
// 这个现场是这一步的**全部前提**:没有逐字稿,第 3 步根本跑不起来;
// 没有名单,就没什么可确认的。现场由用例自己铺,而不是靠先跑一遍真转写 ——
// 那会把云端 ASR 拉进单元测试。
func seedTranscriptAndSpeakers(t *testing.T, taskID uint, speakersJSON string) model.TaskArtifact {
t.Helper()
transcript := model.TaskArtifact{
TaskID: taskID,
SpecialistKey: "general-assistant",
Title: "逐字转写稿",
ArtifactType: "transcript",
Status: specialistruntime.ArtifactStatusDraft,
ContentText: "说话人 0:这个方案我同意。\n说话人 1:我也同意。",
ContentJSON: `{"segments":[{"speaker":"0","text":"这个方案我同意。"},` +
`{"speaker":"1","text":"我也同意。"}]}`,
}
if err := store.DB.Create(&transcript).Error; err != nil {
t.Fatalf("铺逐字稿失败:%v", err)
}
speakers := model.TaskArtifact{
TaskID: taskID,
SpecialistKey: "general-assistant",
Title: "说话人名单",
ArtifactType: "speakers",
Status: specialistruntime.ArtifactStatusReady,
ContentText: "## 说话人名单(AI 推断,待确认)\n",
ContentJSON: speakersJSON,
}
if err := store.DB.Create(&speakers).Error; err != nil {
t.Fatalf("铺说话人名单失败:%v", err)
}
return speakers
}
// 一份两人名单,形状与 runAudioSpeakersStep 写进 ContentJSON 的一致。
const twoSpeakersJSON = `{"source_type":"transcript","speakers":[` +
`{"key":"0","org":"某某局","title":"处长","name":"张三","evidence":"我是某某局的张三"},` +
`{"key":"1","title":"记录员","name":"","evidence":""}]}`
// TestAudioSpeakersGateBlocksStructureThroughRealRoute 是这道闸门的**真身**用例:
// 名单还是「待确认」时,直接把第 5 步的请求打进去,必须被拦住且不留痕。
//
// 拦住的是「绕过前端」这条路:前端把按钮灰掉只是 UX,请求照样能构造出来。
// 而这里要防的后果是「没人核对过,AI 猜的身份被当成事实写进正式纪要」。
func TestAudioSpeakersGateBlocksStructureThroughRealRoute(t *testing.T) {
fx := newAudioRouteFixture(t)
seedTranscriptAndSpeakers(t, fx.taskID, twoSpeakersJSON)
code, body := fx.post(t, fx.token, "/api/skills/audio/structure", gin.H{
"task_id": fx.taskID,
})
if code != http.StatusBadRequest {
t.Fatalf("名单未确认时 structure 返回 %d,期望 400:%v", code, body)
}
if msg := errorMessageOf(t, body); !strings.Contains(msg, "确认") {
t.Errorf("报错没有点明要先确认说话人身份:%q", msg)
}
// 被拦住的那一步不许留痕:落了 run 会让右栏的「工作流 x/6」凭空往前走一格,
// 显示成「已经整理过了」,而实际上一个字都没生成。
var runs int64
store.DB.Model(&model.TaskRun{}).Where("task_id = ?", fx.taskID).Count(&runs)
if runs != 0 {
t.Errorf("被闸门拦下的那一步落了 %d 条 task_run,期望 0 —— 会出现假进度", runs)
}
}
// TestAudioSpeakersConfirmUnlocksAndRecordsDecision 走一遍完整确认:
// 提交一份改过一处的名单 → 产物转 approved、写一条确认 run、任务不再是「待确认」,
// 且**每一位的 decided_by 由服务端算出来**(改过的那位 user_edited,没改的
// user_confirmed)。
//
// decided_by 是这条流程的留痕:事后要能回答「这份稿子里的身份,哪些是人拍板的、
// 哪些是机器猜的」。让客户端自报这个标志位是无法核验的,所以这里连
// 「客户端硬塞一个 decided_by」的情形一起钉住。
func TestAudioSpeakersConfirmUnlocksAndRecordsDecision(t *testing.T) {
fx := newAudioRouteFixture(t)
seedTranscriptAndSpeakers(t, fx.taskID, twoSpeakersJSON)
code, body := fx.post(t, fx.token, "/api/skills/audio/speakers/confirm", gin.H{
"task_id": fx.taskID,
"speakers": []gin.H{
// 一字未改,还带上一个客户端自报的 decided_by —— 必须被服务端覆盖掉。
{"key": "0", "org": "某某局", "title": "处长", "name": "张三",
"decided_by": "user_confirmed"},
// 用户把「记录员」改成了真名。
{"key": "1", "title": "记录员", "name": "李四"},
},
})
if code != http.StatusOK {
t.Fatalf("确认返回 %d:%s", code, errorMessageOf(t, body))
}
data := dataOf(t, body)
if got := data["action_key"]; got != "audio-transcribe:speakers-confirm" {
t.Errorf("action_key = %v,期望 audio-transcribe:speakers-confirm", got)
}
var run model.TaskRun
if err := store.DB.Where("task_id = ? AND action_key = ?", fx.taskID,
"audio-transcribe:speakers-confirm").First(&run).Error; err != nil {
t.Fatalf("没有落下确认这一步的 task_run:%v", err)
}
var artifact model.TaskArtifact
if err := store.DB.Where("task_id = ? AND artifact_type = ?", fx.taskID, "speakers").
Order("id DESC").First(&artifact).Error; err != nil {
t.Fatalf("读不到说话人名单产物:%v", err)
}
if artifact.Status != specialistruntime.ArtifactStatusApproved {
t.Errorf("确认后产物状态 = %q,期望 %q", artifact.Status, specialistruntime.ArtifactStatusApproved)
}
if !strings.Contains(artifact.ContentText, "已确认") {
t.Errorf("确认后的产物正文没有标记为已确认:\n%s", artifact.ContentText)
}
if artifact.CreatedByRunID != nil && *artifact.CreatedByRunID == run.ID {
// 产物是第 3 步写的,确认这一步只改状态、不新造产物。若指向了确认这条 run,
// 说明有人把它当新产物重建了 —— 右栏会出现两份同名产物。
t.Error("确认这一步重建了产物,而不是就地改状态")
}
var task model.TaskRecord
store.DB.First(&task, fx.taskID)
if task.Status == specialistruntime.TaskStatusPendingReview {
t.Error("确认之后任务还停在「待确认」")
}
if task.Status != specialistruntime.TaskStatusDraft {
t.Errorf("确认后任务状态 = %q,期望 %q(还有整理与纪要两步没跑)",
task.Status, specialistruntime.TaskStatusDraft)
}
// 服务端算出来的 decided_by:改过的 user_edited,没改的 user_confirmed。
speakers, _ := data["speakers"].([]any)
if len(speakers) != 2 {
t.Fatalf("返回的名单有 %d 位,期望 2", len(speakers))
}
byKey := map[string]map[string]any{}
for _, item := range speakers {
entry, _ := item.(map[string]any)
byKey[asText(entry["key"])] = entry
}
if got := asText(byKey["0"]["decided_by"]); got != "user_confirmed" {
t.Errorf("说话人 0 一字未改,decided_by = %q,期望 user_confirmed", got)
}
if got := asText(byKey["1"]["decided_by"]); got != "user_edited" {
t.Errorf("说话人 1 被改过,decided_by = %q,期望 user_edited", got)
}
// Evidence 是 AI 给的依据。客户端没提交它,服务端要从库里那份带回来 ——
// 它正是用户核对时唯一的凭据。
if got := asText(byKey["0"]["evidence"]); got != "我是某某局的张三" {
t.Errorf("依据没有从库里带回来:%q", got)
}
}
// TestAudioSpeakersConfirmRejectsMismatchedKeys 钉住「名单必须与稿里的人一一对应」。
//
// 多一个:凭空多出一位不存在的与会者,下游替换会把一个没人说过的名字写进稿子。
// 少一个:那位被静默漏掉,稿子里留着「说话人 1」而没人会注意到。
// 两种都不能靠「前端不会这么传」来防 —— 请求是可以构造的。
func TestAudioSpeakersConfirmRejectsMismatchedKeys(t *testing.T) {
fx := newAudioRouteFixture(t)
seedTranscriptAndSpeakers(t, fx.taskID, twoSpeakersJSON)
cases := []struct {
name string
speakers []gin.H
}{
{
name: "少一位",
speakers: []gin.H{
{"key": "0", "name": "张三"},
},
},
{
name: "多一位",
speakers: []gin.H{
{"key": "0", "name": "张三"},
{"key": "1", "name": "李四"},
{"key": "2", "name": "凭空多出来的王五"},
},
},
{
name: "换成稿里没有的标签",
speakers: []gin.H{
{"key": "0", "name": "张三"},
{"key": "SPEAKER_09", "name": "李四"},
},
},
{
name: "空名单",
speakers: []gin.H{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, body := fx.post(t, fx.token, "/api/skills/audio/speakers/confirm", gin.H{
"task_id": fx.taskID,
"speakers": tc.speakers,
})
if code != http.StatusBadRequest {
t.Fatalf("返回 %d,期望 400:%v", code, body)
}
// 名单必须原封不动:被拒的那次不许把状态改成已确认。
var artifact model.TaskArtifact
store.DB.Where("task_id = ? AND artifact_type = ?", fx.taskID, "speakers").
Order("id DESC").First(&artifact)
if artifact.Status != specialistruntime.ArtifactStatusReady {
t.Errorf("被拒的确认改动了产物状态:%q", artifact.Status)
}
})
}
}
// TestAudioSpeakersConfirmIsNotRepeatable 钉住「确认过就不能再确认一次」。
//
// 双击、或拿着一个过期的页面再点一次,都不该悄悄覆盖掉刚才那份确认 ——
// 要改就得重新跑一遍识别,让新的推断结果重新摆在人面前。
func TestAudioSpeakersConfirmIsNotRepeatable(t *testing.T) {
fx := newAudioRouteFixture(t)
seedTranscriptAndSpeakers(t, fx.taskID, twoSpeakersJSON)
payload := gin.H{
"task_id": fx.taskID,
"speakers": []gin.H{
{"key": "0", "name": "张三"},
{"key": "1", "name": "李四"},
},
}
if code, body := fx.post(t, fx.token, "/api/skills/audio/speakers/confirm", payload); code != http.StatusOK {
t.Fatalf("第一次确认返回 %d:%s", code, errorMessageOf(t, body))
}
code, body := fx.post(t, fx.token, "/api/skills/audio/speakers/confirm", payload)
if code != http.StatusConflict {
t.Fatalf("重复确认返回 %d,期望 409:%v", code, body)
}
var count int64
store.DB.Model(&model.TaskRun{}).
Where("task_id = ? AND action_key = ?", fx.taskID, "audio-transcribe:speakers-confirm").
Count(&count)
if count != 1 {
t.Errorf("重复确认落了 %d 条 task_run,期望 1 —— 右栏会数出一步没干过的活", count)
}
}
// TestAudioSpeakersConfirmRejectsMissingArtifactAndForeignTask 两条前提不满足的路径:
// 还没有名单就确认、拿别人的任务确认。两条都必须明确报错,不许静默成功。
func TestAudioSpeakersConfirmRejectsMissingArtifactAndForeignTask(t *testing.T) {
fx := newAudioRouteFixture(t)
// 任务里连逐字稿都没有,更没有名单。
code, body := fx.post(t, fx.token, "/api/skills/audio/speakers/confirm", gin.H{
"task_id": fx.taskID,
"speakers": []gin.H{{"key": "0", "name": "张三"}},
})
if code != http.StatusBadRequest || !strings.Contains(errorMessageOf(t, body), "说话人名单") {
t.Errorf("没有名单时的响应不对:%d %v", code, body)
}
seedTranscriptAndSpeakers(t, fx.taskID, twoSpeakersJSON)
code, body = fx.post(t, fx.other, "/api/skills/audio/speakers/confirm", gin.H{
"task_id": fx.taskID,
"speakers": []gin.H{{"key": "0", "name": "张三"}, {"key": "1", "name": "李四"}},
})
if code != http.StatusNotFound {
t.Errorf("拿别人的任务确认返回 %d,期望 404:%v", code, body)
}
}
// asText 把 JSON 解出来的任意值读成字符串。
//
// 不直接 `value.(string)`:JSON 里的数字在这一层是 float64,断言写死了类型的话,
// 一个本该比较的值会被静默读成空串,而空串恰好不等于期望值 —— 用例会红,
// 但报出来的是「值不对」而不是「类型不对」,排查要绕一圈。
func asText(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
default:
return fmt.Sprintf("%v", v)
}
}
@@ -1,32 +1,21 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
audiotranscribe "eai_agentplatform/backend/internal/skills/packages/audio_transcribe"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/web"
)
// AudioTranscribeResult 语音转录结果
type AudioTranscribeResult struct {
Text string `json:"text"` // 转录文字
Language string `json:"language"` // 语言
Duration float64 `json:"duration"` // 时长(秒)
CreatedAt string `json:"created_at"` // 时间
}
// TranscribeAudio POST /api/audio/transcribe —— 语音转文字
// TranscribeAudio POST /api/audio/transcribe —— 语音转文字(multipart 直传)。
//
// 这里是薄壳:转写内核在 skills/packages/audio_transcribe,因为技能工作流的
// 第 2 步(从 media_id 读盘转写)也要用它,而那个包拿不到本包的不导出函数。
// 本端点的价值只剩「浏览器直接传字节」这一条路径。
func TranscribeAudio(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
@@ -34,23 +23,18 @@ func TranscribeAudio(c *gin.Context) {
return
}
// 解析 multipart 表单
file, fileHeader, err := c.Request.FormFile("file")
if err != nil {
web.Fail(c, web.NewBadRequest("请上传音频文件"))
return
}
defer file.Close()
// 检查扩展名
ext := extractExt(fileHeader.Filename)
ext := audiotranscribe.ExtractExt(fileHeader.Filename)
if ext == "" {
ext = "mp3"
}
allowedExt := map[string]bool{
"mp3": true, "wav": true, "m4a": true,
"ogg": true, "flac": true, "aac": true, "wma": true,
}
if !allowedExt[ext] {
if !audiotranscribe.IsAudioExt(ext) {
web.Fail(c, web.NewBadRequest("不支持的音频格式,仅支持 MP3/WAV/M4A/OGG/FLAC"))
return
}
@@ -60,115 +44,17 @@ func TranscribeAudio(c *gin.Context) {
language = "zh" // 默认中文
}
// 读取文件内容
fileData, err := io.ReadAll(file)
if err != nil {
web.Fail(c, web.NewBadRequest("读取音频文件失败"))
return
}
file.Close()
result := transcribeAudio(fileData, ext, language)
logTranscription(user.ID)
result, err := audiotranscribe.TranscribeBytes(
user.ID, fileData, fileHeader.Filename, ext, language, "")
if err != nil {
web.Fail(c, web.NewBadRequest(err.Error()))
return
}
web.OK(c, result)
}
// extractExt 从文件名提取扩展名
func extractExt(filename string) string {
for i := len(filename) - 1; i >= 0; i-- {
if filename[i] == '.' {
return strings.ToLower(filename[i+1:])
}
}
return ""
}
// transcribeAudio 调用 Ollama whisper 进行语音转录
func transcribeAudio(fileData []byte, ext, language string) AudioTranscribeResult {
// 尝试从路由配置获取 LLM baseURL,降级到 Ollama 默认
baseURL := "http://127.0.0.1:11434"
if aiRoute, err := config.GetRoute("title_gen"); err == nil && aiRoute != nil && aiRoute.BaseURL != "" {
baseURL = aiRoute.BaseURL
// 去掉 /v1 后缀,确保路径正确
baseURL = strings.TrimSuffix(baseURL, "/v1")
}
// 调用 Ollama /v1/audio/transcriptions
url := baseURL + "/v1/audio/transcriptions"
// 构建 multipart body
buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf)
// model field
modelField, _ := w.CreateFormField("model")
modelField.Write([]byte("whisper"))
// language field
langField, _ := w.CreateFormField("language")
langField.Write([]byte(language))
// audio file
filePart, _ := w.CreateFormFile("file", "audio."+ext)
filePart.Write(fileData)
w.Close()
req, err := http.NewRequest("POST", url, buf)
if err != nil {
return AudioTranscribeResult{
Text: "请求构建失败",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer placeholder") // Ollama 通常不需要 key
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return AudioTranscribeResult{
Text: fmt.Sprintf("Ollama 服务不可达 (%s),请确认 whisper 模型已加载", baseURL),
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return AudioTranscribeResult{
Text: "读取响应失败",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
if resp.StatusCode != http.StatusOK {
return AudioTranscribeResult{
Text: fmt.Sprintf("转录失败 (HTTP %d): %s", resp.StatusCode, string(body)),
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
// 解析 {"text": "..."}
var out struct {
Text string `json:"text"`
}
if err := json.Unmarshal(body, &out); err != nil {
return AudioTranscribeResult{
Text: "响应解析失败",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
return AudioTranscribeResult{
Text: out.Text,
Language: language,
Duration: 0, // Ollama whisper 不返回时长
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
// logTranscription 记录语音转录日志
func logTranscription(userID uint) {
ai.LogCall(ai.LogEntry{UserID: userID, UsageKind: ai.UsageKindAudioTranscribe, Success: true})
}
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"strings"
"time"
@@ -57,7 +58,7 @@ func runBatchExtract(req BatchExtractRequest) BatchExtractResult {
fieldDesc := buildFieldDescription(req.Fields)
// 调用 AI 提取(此前传 nil 会让 AI 层空指针,见 helpers.generateWithDefaultRoute)
content, err := generateWithDefaultRoute([]ai.Message{
content, err := generateWithDefaultRoute(context.Background(), []ai.Message{
{Role: "system", Content: "你是一个字段提取专家。请从用户输入的内容中提取以下字段信息,以JSON格式返回:\n" + fieldDesc},
{Role: "user", Content: req.Content},
})
@@ -1,7 +1,9 @@
package api
import (
"context"
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -9,6 +11,7 @@ import (
"eai_agentplatform/backend/internal/ai"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/middleware"
generalassistant "eai_agentplatform/backend/internal/specialists/packages/general_assistant"
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
"eai_agentplatform/backend/internal/web"
)
@@ -153,6 +156,10 @@ func handleExpert(userID uint, req ChatMessageRequest, specialist *specialistmod
//
// system prompt 由 buildAssistantSystemPrompt 组装:基础角色 + 专员岗位说明书。
// 没解析到专员时(未指定 / key 失效)与改动前逐字一致。
//
// 普通对话现在也具备「联网搜索」能力:优先用带 web_search 工具的 Agent 生成,
// 让模型按需检索实时数据/政策/新闻,避免空写或编造;若 Agent 因模型/服务异常失败,
// 回退到原纯文本生成以保底。
func callChatModel(userID uint, req ChatMessageRequest, specialist *specialistmodel.Specialist, enableThinking bool) (string, ai.LogEntry) {
systemPrompt := buildAssistantSystemPrompt(specialist, enableThinking)
logEntry := ai.LogEntry{
@@ -177,7 +184,64 @@ func callChatModel(userID uint, req ChatMessageRequest, specialist *specialistmo
logEntry.Model = aiRoute.Model
start := time.Now()
result, usedRoute, err := ai.GenerateFullWithFallback(aiRoute, []ai.Message{
// 通用助手(未挂载具体专员,或挂的就是 general-assistant 专员)→ 走多 Agent 编排运行时。
// 通用助手的定位是「平台编排中枢」——相比公众号等专员的静态步骤,
// 它由 LLM 动态拆解子任务、并行执行 worker(可联网)、并发 Merge Gate 收敛。
// 注意:当用户把对话挂载到 general-assistant 的事由上时,specialist 此时并非 nil
// (resolveSpecialist 会命中它的记录),但语义上它仍是通用助手,必须走编排,
// 否则会退化成普通专员 agent(丢分 Agent 产物与落库)。
if specialist == nil || specialist.Key == generalassistant.Manifest.Key {
orchestrator := generalassistant.NewOrchestrator(aiRouteID)
finalContent, artifacts, orchErr := orchestrator.Run(context.Background(), req.Message, nil, req.TaskID)
logEntry.LatencyMs = int(time.Since(start).Milliseconds())
if orchErr == nil {
logEntry.Success = true
return attachOrchestration(finalContent, artifacts), logEntry
}
logEntry.ErrorMessage = orchErr.Error()
// 编排失败则回退到下方纯文本生成保底
start = time.Now()
result, usedRoute, ferr := ai.GenerateFullWithFallback(context.Background(), aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: req.Message},
})
logEntry.LatencyMs = int(time.Since(start).Milliseconds())
if ferr != nil {
logEntry.ErrorMessage = ferr.Error()
return "抱歉,AI 服务暂时不可用,请稍后重试。", logEntry
}
if usedRoute != nil {
logEntry.Provider = usedRoute.Provider
logEntry.AIRouteID = usedRoute.RouteID
logEntry.Model = usedRoute.Model
}
logEntry.TokensInput = result.Usage.PromptTokens
logEntry.TokensOutput = result.Usage.CompletionTokens
logEntry.Success = true
return result.Content, logEntry
}
// 优先走带搜索的 Agent:模型可按需调用 web_search 检索实时信息。
searchPrompt := systemPrompt + "\n\n如需获取最新数据、政策、新闻或事实细节,可调用 web_search 工具搜索后再作答,并标注信息出处。不要编造数字或事实。"
agent := ai.NewAgent(aiRoute)
agent.RegisterTool(ai.NewWebSearchTool(nil))
content, usage, _, agentErr := agent.RunWithUsage(context.Background(), []ai.Message{
{Role: "system", Content: searchPrompt},
{Role: "user", Content: req.Message},
})
logEntry.LatencyMs = int(time.Since(start).Milliseconds())
if agentErr == nil {
logEntry.TokensInput = usage.Usage.PromptTokens
logEntry.TokensOutput = usage.Usage.CompletionTokens
logEntry.Success = true
return content, logEntry
}
// Agent 失败(模型不支持 tools / 服务异常)→ 回退到原纯文本生成 + 回退链
start = time.Now()
result, usedRoute, err := ai.GenerateFullWithFallback(context.Background(), aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: req.Message},
})
@@ -197,6 +261,32 @@ func callChatModel(userID uint, req ChatMessageRequest, specialist *specialistmo
return result.Content, logEntry
}
// attachOrchestration 把多 Agent 编排的最终回复与子任务产物拼接到一起返回。
// 产物以折叠块追加在正文末尾,便于查验与追溯,不破坏现有消息 schema。
func attachOrchestration(finalContent string, artifacts []generalassistant.StepArtifact) string {
if len(artifacts) == 0 {
return finalContent
}
var b strings.Builder
b.WriteString(finalContent)
b.WriteString("\n\n<details><summary>📋 本次由通用编排拆分执行</summary>\n\n")
for _, a := range artifacts {
title := a.Title
if title == "" {
title = a.Key
}
if a.Content == "" {
b.WriteString("##### " + title + "\n\n(本子任务结果为空)\n\n")
continue
}
b.WriteString("##### " + title + "\n\n")
b.WriteString(a.Content)
b.WriteString("\n\n")
}
b.WriteString("</details>")
return b.String()
}
// generateTaskPlan 生成任务拆解
func generateTaskPlan(prompt string) *TaskPlan {
plan := &TaskPlan{
@@ -1,6 +1,7 @@
package api
import (
"context"
"strings"
"time"
@@ -356,7 +357,7 @@ func checkRisks(sections []ContractSection) []RiskItem {
func aiDeepReview(req ContractReviewRequest) []RiskItem {
// 此前传 nil 会让 AI 层空指针(见 helpers.generateWithDefaultRoute);
// 失败时返回 nil,由调用方只保留规则引擎结果。
content, err := generateWithDefaultRoute([]ai.Message{
content, err := generateWithDefaultRoute(context.Background(), []ai.Message{
{Role: "system", Content: "你是一位资深法务专家。请审查以下合同文本,识别法律风险。" +
"重点关注:条款完整性、权责对等性、违约责任、争议解决、不可抗力、保密条款、知识产权。"},
{Role: "user", Content: req.Content},
@@ -3,6 +3,7 @@ package api
import (
"archive/zip"
"bytes"
"context"
"fmt"
"strings"
"time"
@@ -119,7 +120,7 @@ func translateText(content, source, target string) string {
5. 直接输出翻译结果,不要包含解释说明`, srcName, tgtName)
aiRoute, _ := config.GetRoute("title_gen")
translatedContent, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
translatedContent, err := ai.GenerateWithFallback(context.Background(), aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: content},
})
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"fmt"
"strings"
@@ -27,7 +28,7 @@ func gradeEssay(userID uint, stem, rubric, userAnswer string) (float64, string,
}
start := time.Now()
result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages)
result, usedAiRoute, err := ai.GenerateFullWithFallback(context.Background(), aiRoute, aiMessages)
if err != nil {
ai.LogCall(ai.LogEntry{
UserID: userID, UsageKind: ai.UsageKindEssayGrade, Provider: aiRoute.Provider,
@@ -1,6 +1,7 @@
package api
import (
"context"
"fmt"
"strconv"
@@ -29,10 +30,10 @@ func parseID(c *gin.Context, name string) (uint, bool) {
//
// 存在的意义:取代此前 ai.GenerateWithFallback(nil, …) 的写法。传 nil 会让
// ai.buildRouteChain 直接报错——历史上前者会让调用方必 500。
func generateWithDefaultRoute(messages []ai.Message) (string, error) {
func generateWithDefaultRoute(ctx context.Context, messages []ai.Message) (string, error) {
aiRoute, err := config.GetRoute("title_gen")
if err != nil || aiRoute == nil {
return "", fmt.Errorf("通用文本生成路由不可用: %w", err)
}
return ai.GenerateWithFallback(aiRoute, messages)
return ai.GenerateWithFallback(ctx, aiRoute, messages)
}
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"fmt"
"math"
@@ -329,7 +330,7 @@ func vectorRetrieveCitations(aiRoute *config.RouteConfig, query string, candidat
for _, item := range candidates {
inputs = append(inputs, item.Chunk.Content)
}
vecs, err := client.Embed(inputs)
vecs, err := client.Embed(context.Background(), inputs)
if err != nil || len(vecs) != len(inputs) {
return nil, false
}
@@ -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)
}
@@ -0,0 +1,194 @@
package api
import (
"os"
"time"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/web"
)
// ────────────────────────────────────────────────────────────
// 回收站
// ────────────────────────────────────────────────────────────
// netdiskRecycleList GET /api/netdisk/recycle —— 回收站列表
func netdiskRecycleList(c *gin.Context) {
files := netdiskFileDAO.ListInRecycle(netdiskCompanyID)
web.OK(c, gin.H{"items": files, "total": len(files)})
}
// netdiskRecycleFile DELETE /api/netdisk/files/:id —— 移入回收站(软删除)
func netdiskRecycleFile(c *gin.Context) {
u := middleware.CurrentUser(c)
id, ok := parseID(c, "id")
if !ok {
return
}
f, found := netdiskFileDAO.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("文件不存在"))
return
}
if f.DeletedAt != nil {
web.Fail(c, web.NewBadRequest("文件已在回收站中"))
return
}
now := time.Now()
f.DeletedAt = &now
f.DeletedBy = &u.ID
if !netdiskFileDAO.Update(&f) {
web.Fail(c, web.NewBadRequest("移入回收站失败"))
return
}
web.OK(c, gin.H{"id": f.ID, "recycled": true})
}
// netdiskRestoreFile POST /api/netdisk/recycle/:id/restore —— 从回收站恢复
func netdiskRestoreFile(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.DeletedAt == nil {
web.Fail(c, web.NewBadRequest("文件不在回收站中"))
return
}
f.DeletedAt = nil
f.DeletedBy = nil
if !netdiskFileDAO.Update(&f) {
web.Fail(c, web.NewBadRequest("恢复失败"))
return
}
web.OK(c, f)
}
// netdiskPurgeFile DELETE /api/netdisk/recycle/:id —— 彻底删除(物理删文件+记录)
func netdiskPurgeFile(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.DeletedAt == nil {
web.Fail(c, web.NewBadRequest("仅回收站中的文件可彻底删除"))
return
}
// 物理删除
_ = os.Remove(netdiskFilePath(f.StoredName))
if !netdiskFileDAO.HardDelete(id) {
web.Fail(c, web.NewBadRequest("彻底删除失败"))
return
}
web.OK(c, gin.H{"purged": id})
}
// ────────────────────────────────────────────────────────────
// 审批(并行几秒的列表用 status 过滤)
// ────────────────────────────────────────────────────────────
// netdiskAuditList GET /api/netdisk/audit-list?status=&page=&size= —— 审批列表
func netdiskAuditList(c *gin.Context) {
page := atoiDefault(c.DefaultQuery("page", "1"), 1)
size := atoiDefault(c.DefaultQuery("size", "20"), 20)
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
total, items := netdiskFileDAO.ListForAudit(netdiskCompanyID, c.Query("status"), page, size)
web.OK(c, gin.H{"total": total, "items": items})
}
// netdiskAuditFile POST /api/netdisk/audit/:id —— 审批(approve / reject)
func netdiskAuditFile(c *gin.Context) {
u := middleware.CurrentUser(c)
id, ok := parseID(c, "id")
if !ok {
return
}
var req struct {
Action string `json:"action"`
RejectReason string `json:"reject_reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
f, found := netdiskFileDAO.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("文件不存在"))
return
}
if f.Status != "pending" {
web.Fail(c, web.NewConflictError("该文件已审批,不可重复操作"))
return
}
now := time.Now()
switch req.Action {
case "approve":
f.Status = "approved"
f.AuditBy = &u.ID
f.AuditAt = &now
f.RejectReason = ""
case "reject":
if spaceTrim(req.RejectReason) == "" {
web.Fail(c, web.NewBadRequest("驳回理由必填"))
return
}
f.Status = "rejected"
f.RejectReason = req.RejectReason
f.AuditBy = &u.ID
f.AuditAt = &now
default:
web.Fail(c, web.NewBadRequest("action 必须为 approve 或 reject"))
return
}
if !netdiskFileDAO.Update(&f) {
web.Fail(c, web.NewBadRequest("审批失败"))
return
}
web.OK(c, gin.H{"id": f.ID, "status": f.Status})
}
// ────────────────────────────────────────────────────────────
// 小工具
// ────────────────────────────────────────────────────────────
func atoiDefault(s string, def int) int {
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return def
}
n = n*10 + int(r-'0')
}
if s == "" {
return def
}
return n
}
func spaceTrim(s string) string {
start := 0
end := len(s)
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
end--
}
return s[start:end]
}
@@ -30,6 +30,11 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
if err := os.MkdirAll(filepath.Join(cfg.KBDataDir, "approved"), 0o755); err == nil {
r.Static("/media", filepath.Join(cfg.KBDataDir, "approved"))
}
// 网盘物理文件静态预览(只读):仅 approved 文件通过 Preview 接口取 URL,
// 未审批文件不对外暴露,落实「审批前置」。
if err := os.MkdirAll(cfg.NetdiskDataDir, 0o755); err == nil {
r.Static("/netdisk-file", cfg.NetdiskDataDir)
}
r.GET("/api/health", func(c *gin.Context) {
ver := "1.1.0"
@@ -117,6 +122,21 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.GET("/api/media/preview/:media_id", middleware.Auth(cfg), Preview)
r.GET("/api/media/status/:media_id", middleware.Auth(cfg), MediaStatus)
// ── 团队共享网盘(目录 / 文件 / 预览 / 下载)──
r.GET("/api/netdisk/folders", middleware.Auth(cfg), netdiskListFolders)
r.POST("/api/netdisk/folders", middleware.Auth(cfg), netdiskCreateFolder)
r.PATCH("/api/netdisk/folders/:id", middleware.Auth(cfg), netdiskPatchFolder)
r.GET("/api/netdisk/files", middleware.Auth(cfg), netdiskListFiles)
r.POST("/api/netdisk/upload", middleware.Auth(cfg), netdiskUpload)
r.GET("/api/netdisk/files/:id/download", middleware.Auth(cfg), netdiskDownload)
r.GET("/api/netdisk/files/:id/preview", middleware.Auth(cfg), netdiskPreview)
r.PUT("/api/netdisk/files/:id/rename", middleware.Auth(cfg), netdiskRenameFile)
r.PUT("/api/netdisk/files/:id/move", middleware.Auth(cfg), netdiskMoveFile)
r.DELETE("/api/netdisk/files/:id", middleware.Auth(cfg), netdiskRecycleFile)
// 回收站
r.GET("/api/netdisk/recycle", middleware.Auth(cfg), netdiskRecycleList)
r.POST("/api/netdisk/recycle/:id/restore", middleware.Auth(cfg), netdiskRestoreFile)
r.GET("/api/knowledge/spaces", middleware.Auth(cfg), ListKnowledgeSpaces)
r.GET("/api/knowledge/search", middleware.Auth(cfg), SearchKnowledge)
r.GET("/api/knowledge/status/:source_id", middleware.Auth(cfg), KnowledgeStatus)
@@ -130,6 +150,8 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.GET("/api/ai/usage", middleware.Auth(cfg), AIUsage)
r.GET("/api/ai/routes/chat", ListChatRoutes)
r.GET("/api/ai/routes/embed", ListEmbedRoutes)
r.GET("/api/ai/routes/audio", ListAudioRoutes)
r.GET("/api/assistant/opening", middleware.Auth(cfg), GetAssistantOpening)
// 学习进度(员工上报 + 本人查询)
r.POST("/api/learning/progress", middleware.Auth(cfg), RecordLearningProgress)
@@ -159,6 +181,19 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.POST("/api/skills/office/execute", middleware.Auth(cfg), skillapi.ExecuteOfficeSkill)
r.POST("/api/skills/office/ocr", middleware.Auth(cfg), skillapi.ExecuteOfficeSkillOCR)
r.POST("/api/skills/office/tts", middleware.Auth(cfg), skillapi.ExecuteOfficeSkillTTS)
// 语音转写技能的四步。**不复用 /api/skills/office/execute**:office 的运行时
// 是纯产物构造器(不发网络请求),转写必须真的调 ASR 与 LLM。
// 每步一条路由 = 每步一条 task_run,右栏「工作流 x/4」据此推进。
// 语音转写:一步一个端点、一步一条 task_run,右栏的「工作流 x/6」靠这个推进。
// speakers 与 speakers/confirm 是**两步**:前者只推断(产物落 ready、任务落待确认),
// 后者是用户拍板。中间停多久由用户决定,后端不设超时。
r.POST("/api/skills/audio/scope", middleware.Auth(cfg), skillapi.ExecuteAudioScope)
r.POST("/api/skills/audio/transcribe", middleware.Auth(cfg), skillapi.ExecuteAudioTranscribe)
r.POST("/api/skills/audio/speakers", middleware.Auth(cfg), skillapi.ExecuteAudioSpeakers)
r.POST("/api/skills/audio/speakers/confirm", middleware.Auth(cfg), skillapi.ExecuteAudioSpeakersConfirm)
r.POST("/api/skills/audio/structure", middleware.Auth(cfg), skillapi.ExecuteAudioStructure)
r.POST("/api/skills/audio/minutes", middleware.Auth(cfg), skillapi.ExecuteAudioMinutes)
r.GET("/api/tts/audio/:filename", middleware.Auth(cfg), skillapi.ServeTTSAudio)
r.POST("/api/chat/message", middleware.Auth(cfg), HandleChatMessage)
@@ -189,6 +224,12 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
admin.GET("/media/audit-list", AuditList)
admin.POST("/media/audit/:media_id", AuditMedia)
// 网盘管理:删目录、审批、回收站彻底删除
admin.DELETE("/netdisk/folders/:id", netdiskDeleteFolder)
admin.GET("/netdisk/audit-list", netdiskAuditList)
admin.POST("/netdisk/audit/:id", netdiskAuditFile)
admin.DELETE("/netdisk/recycle/:id", netdiskPurgeFile)
admin.POST("/knowledge/scan", KnowledgeScan)
admin.POST("/knowledge/index/rebuild", RebuildKnowledgeIndex)
admin.POST("/knowledge/spaces", CreateKnowledgeSpace)