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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user