0.10.1: 安全与业务逻辑加固、新品牌与部署加固

三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
  与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
  撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
  全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
  >4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
  后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
  nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
This commit is contained in:
2026-08-13 10:50:51 +08:00
parent b536672000
commit 9501751792
136 changed files with 8024 additions and 1476 deletions
+7 -2
View File
@@ -56,8 +56,13 @@ func normalizeApplicationConfig(config *ApplicationConfig, requireModel bool) er
if config.Temperature < 0 || config.Temperature > 2 {
return errors.New("temperature 应在 0-2 之间")
}
if config.MaxToolRounds < 0 || config.MaxToolRounds > 8 {
return errors.New("max_tool_rounds 应在 0-8 之间")
// 缺省 max_tool_rounds(0)时按 5 轮处理,与数字员工一致;否则运行时
// round >= 0 在第一次工具调用前就判定"已达上限",应用永远无法完成工具调用。
if config.MaxToolRounds == 0 {
config.MaxToolRounds = 5
}
if config.MaxToolRounds < 1 || config.MaxToolRounds > 8 {
return errors.New("max_tool_rounds 应在 1-8 之间")
}
return nil
}
+6
View File
@@ -93,6 +93,10 @@ func (h *FilesAdminHTTPHandler) upload(w http.ResponseWriter, r *http.Request) {
contentType = ct
}
}
// 非 multipart 请求(body 为 nil 时)按原始请求体上传(?filename= 指定文件名)。
if body == nil {
body = r.Body
}
if originalName == "" {
apiresponse.Error(w, http.StatusBadRequest, "缺少文件名")
return
@@ -150,6 +154,8 @@ func serveFileContent(w http.ResponseWriter, r *http.Request, files *FileService
}
defer reader.Close()
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(obj.OriginalName))
// Content-Type 来自用户上传,回显前必须禁 MIME 嗅探,防止存储型 XSS。
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Type", obj.ContentType)
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
_, _ = io.Copy(w, reader)
+378
View File
@@ -0,0 +1,378 @@
package workbench
import (
"context"
"encoding/json"
"errors"
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/redis/go-redis/v9"
)
// InboxMessage 是站内消息的一行:一条消息对应一个收件人(admin 或 portal)。
// 管理员广播时按目标用户逐行落库,因此每行独立维护 read_at 已读回执。
type InboxMessage struct {
ID string `json:"id"`
RecipientKind string `json:"recipient_kind"`
RecipientUserID string `json:"recipient_user_id"`
SenderType string `json:"sender_type"`
Category string `json:"category"`
Title string `json:"title"`
Body string `json:"body"`
Link string `json:"link"`
Payload json.RawMessage `json:"payload,omitempty"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type InboxInput struct {
RecipientKind string `json:"recipient_kind"`
Category string `json:"category"`
Title string `json:"title"`
Body string `json:"body"`
Link string `json:"link"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// InboxService 负责将 outbox 事件物化为站内消息,并对外提供收件箱读写。
// 未读数以 PostgreSQL 为权威源(部分索引 COUNT 快速),Redis 仅作实时 PUBLISH
// 提示(为未来 SSE 预留);因此未读计数不会因 Redis 抖动或消息漂移而失真。
type InboxService struct {
assets *Service
redis *redis.Client
channel string
}
func NewInboxService(assets *Service, client *redis.Client, channel string) *InboxService {
return &InboxService{assets: assets, redis: client, channel: channel}
}
const inboxSelect = `SELECT id::text,recipient_kind,recipient_user_id::text,sender_type,category,title,body,link,payload,read_at,created_at FROM gateway.inbox_messages`
func scanInboxMessage(row pgx.Row) (InboxMessage, error) {
var m InboxMessage
err := row.Scan(&m.ID, &m.RecipientKind, &m.RecipientUserID, &m.SenderType, &m.Category, &m.Title, &m.Body, &m.Link, &m.Payload, &m.ReadAt, &m.CreatedAt)
return m, err
}
// inboxDraft 描述一个 outbox 事件要落成的一条站内消息(收件人解析方式不同)。
type inboxDraft struct {
RecipientKind string // admin | portal
Category string
Title string
Body string
Link string
UserID string // 直接收件人(从 payload 取),空串表示需额外解析
AllAdmins bool // 收件人 = 全部启用管理员
RequestUser bool // 收件人 = model_access_requests.portal_user_id(payload.request_id)
}
func payloadValue(payload json.RawMessage, key string) string {
var values map[string]any
if err := json.Unmarshal(payload, &values); err != nil {
return ""
}
value, ok := values[key]
if !ok {
return ""
}
switch v := value.(type) {
case string:
return v
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
}
encoded, _ := json.Marshal(value)
return string(encoded)
}
// inboxPlan 把事件类型映射成站内消息草稿(纯函数,便于单测)。未知事件返回 nil,
// 不构成错误——并非所有 outbox 事件都需要站内信。
func inboxPlan(eventType string, payload json.RawMessage) []inboxDraft {
switch eventType {
case "model_access.requested":
return []inboxDraft{{RecipientKind: "admin", Category: "approval", Title: "新的模型访问申请", Body: "用户申请访问模型 " + payloadValue(payload, "model"), Link: "/security/governance", AllAdmins: true}}
case "model_access.decided":
text := "已批准"
if payloadValue(payload, "status") == "rejected" {
text = "已驳回"
}
return []inboxDraft{{RecipientKind: "portal", Category: "approval", Title: "模型申请已处理", Body: "您的模型访问申请已被" + text, Link: "/portal/access", RequestUser: true}}
case "marketplace.installed":
return []inboxDraft{{RecipientKind: "portal", Category: "resource", Title: "资源已安装", Body: "资源 " + payloadValue(payload, "code") + " 已安装到您的工作区", Link: "/portal/marketplace", UserID: payloadValue(payload, "portal_user_id")}}
case "knowledge_document.ready":
return []inboxDraft{{RecipientKind: "admin", Category: "system", Title: "知识文档已入库", Body: "文档已分块入库(" + payloadValue(payload, "chunk_count") + " 切片)", Link: "/assets/knowledge", UserID: payloadValue(payload, "actor_id")}}
case "knowledge_document.reprocessed":
return []inboxDraft{{RecipientKind: "admin", Category: "system", Title: "知识文档已重新处理", Body: "文档已重新分块入库", Link: "/assets/knowledge", UserID: payloadValue(payload, "actor_id")}}
case "knowledge_document.embedding_failed":
return []inboxDraft{{RecipientKind: "admin", Category: "system", Title: "知识文档向量化失败", Body: "文档已入库但向量化失败,检索将回退全文检索;请检查 Ollama 后重新处理", Link: "/assets/knowledge", UserID: payloadValue(payload, "actor_id")}}
case "scheduled_task.completed":
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "定时任务已执行", Body: "定时任务 " + payloadValue(payload, "task_code") + " 已完成", Link: "/system/scheduled-tasks", UserID: payloadValue(payload, "actor_id")}}
case "scheduled_task.failed":
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "定时任务执行失败", Body: "定时任务 " + payloadValue(payload, "task_code") + " 执行失败: " + payloadValue(payload, "error"), Link: "/system/scheduled-tasks", UserID: payloadValue(payload, "actor_id")}}
}
return nil
}
func (s *InboxService) resolveRecipients(ctx context.Context, draft inboxDraft, payload json.RawMessage) ([]string, error) {
switch {
case draft.UserID != "":
return []string{draft.UserID}, nil
case draft.RequestUser:
var userID string
if err := s.assets.pool.QueryRow(ctx, `SELECT portal_user_id::text FROM gateway.model_access_requests WHERE id=$1`, payloadValue(payload, "request_id")).Scan(&userID); err != nil {
return nil, err
}
return []string{userID}, nil
case draft.AllAdmins:
rows, err := s.assets.pool.Query(ctx, `SELECT id::text FROM gateway.admin_accounts WHERE active`)
if err != nil {
return nil, err
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
return nil, nil
}
// Materialize 把一条 outbox 事件落成站内消息。以 (source_event_id, 收件人) 幂等:
// 事件重放时 ON CONFLICT DO NOTHING,不产生重复消息,也不报错。
func (s *InboxService) Materialize(ctx context.Context, eventID, eventType string, payload json.RawMessage) error {
for _, draft := range inboxPlan(eventType, payload) {
recipients, err := s.resolveRecipients(ctx, draft, payload)
if err != nil {
return err
}
for _, userID := range recipients {
if err := s.notify(ctx, eventID, draft, userID, payload); err != nil {
return err
}
}
}
return nil
}
func (s *InboxService) notify(ctx context.Context, sourceEventID string, draft inboxDraft, userID string, payload json.RawMessage) error {
id, err := newUUID()
if err != nil {
return err
}
// 数据库 CHECK 按字符数(length)校验,Go 的 len() 是字节数;多字节文本下
// 字节校验通过但字符数超限,INSERT 会失败并把事件永远卡在 pending。
// 落库前按 rune 数截断,保证任何语言正文都能入库。
title := runeTruncate(draft.Title, 256)
body := runeTruncate(draft.Body, 4000)
link := runeTruncate(draft.Link, 512)
tag, err := s.assets.pool.Exec(ctx, `INSERT INTO gateway.inbox_messages(id,source_event_id,recipient_kind,recipient_user_id,sender_type,category,title,body,link,payload)
VALUES($1,$2,$3,$4,'system',$5,$6,$7,$8,$9)
ON CONFLICT (source_event_id, recipient_kind, recipient_user_id) WHERE source_event_id IS NOT NULL DO NOTHING`,
id, sourceEventID, draft.RecipientKind, userID, draft.Category, title, body, link, payload)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return nil // 同一来源事件对同一收件人的重放,跳过
}
s.publish(draft.RecipientKind, userID)
return nil
}
// runeTruncate 按 rune(字符)数截断;超出 limit 个字符时截到第 limit 个
// 完整 rune,绝不产生无效 UTF-8。
func runeTruncate(value string, limit int) string {
if utf8.RuneCountInString(value) <= limit {
return value
}
runes := []rune(value)
return string(runes[:limit])
}
// publish 仅作实时提示(未来 SSE 可订阅);收件箱未读数以 DB 为准。
func (s *InboxService) publish(kind, userID string) {
if s.redis == nil {
return
}
_ = s.redis.Publish(context.WithoutCancel(context.Background()), s.channel, kind+":"+userID).Err()
}
// List 返回某个收件人的收件箱(倒序)。
func (s *InboxService) List(ctx context.Context, kind, userID string, limit int) ([]InboxMessage, error) {
if limit < 1 {
limit = 50
}
if limit > 200 {
limit = 200
}
rows, err := s.assets.pool.Query(ctx, inboxSelect+` WHERE recipient_kind=$1 AND recipient_user_id=$2 ORDER BY created_at DESC LIMIT $3`, kind, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []InboxMessage{}
for rows.Next() {
m, err := scanInboxMessage(rows)
if err != nil {
return nil, err
}
items = append(items, m)
}
return items, rows.Err()
}
// UnreadCount 以 PostgreSQL 为权威源统计未读消息(部分索引快速扫描)。
func (s *InboxService) UnreadCount(ctx context.Context, kind, userID string) (int, error) {
var count int
err := s.assets.pool.QueryRow(ctx, `SELECT count(*) FROM gateway.inbox_messages WHERE recipient_kind=$1 AND recipient_user_id=$2 AND read_at IS NULL`, kind, userID).Scan(&count)
return count, err
}
// MarkRead 把某条消息标记为已读;只允许收件人本人操作。
func (s *InboxService) MarkRead(ctx context.Context, id, kind, userID string) (bool, error) {
tag, err := s.assets.pool.Exec(ctx, `UPDATE gateway.inbox_messages SET read_at=coalesce(read_at,clock_timestamp()) WHERE id=$1 AND recipient_kind=$2 AND recipient_user_id=$3`, id, kind, userID)
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
// MarkAllRead 把某收件人的全部未读标记为已读,返回标记条数。
func (s *InboxService) MarkAllRead(ctx context.Context, kind, userID string) (int, error) {
tag, err := s.assets.pool.Exec(ctx, `UPDATE gateway.inbox_messages SET read_at=clock_timestamp() WHERE recipient_kind=$1 AND recipient_user_id=$2 AND read_at IS NULL`, kind, userID)
if err != nil {
return 0, err
}
return int(tag.RowsAffected()), nil
}
// AdminList 返回管理端消息中心列表。scope=mine 看发给自己(admin)的消息;
// scope=broadcasts 看管理员发起的广播(portal 收件)。其余 scope 全部返回。
func (s *InboxService) AdminList(ctx context.Context, adminID, scope string, limit int) ([]InboxMessage, error) {
if limit < 1 {
limit = 50
}
if limit > 500 {
limit = 500
}
query, args := inboxSelect+` WHERE`, []any{}
switch scope {
case "mine":
query += ` recipient_kind='admin' AND recipient_user_id=$1`
args = append(args, adminID)
case "broadcasts":
query += ` sender_type='admin' AND recipient_kind='portal'`
default:
query += ` recipient_kind='admin' AND recipient_user_id=$1`
args = append(args, adminID)
}
args = append(args, limit)
query += ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(len(args))
rows, err := s.assets.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := []InboxMessage{}
for rows.Next() {
m, err := scanInboxMessage(rows)
if err != nil {
return nil, err
}
items = append(items, m)
}
return items, rows.Err()
}
// Broadcast 由管理员向 portal(可按部门过滤)或全部 admin 发广播;逐收件人落库,
// 返回实际送达用户数。sender_type='admin',无 source_event_id(不与事件幂等键冲突)。
func (s *InboxService) Broadcast(ctx context.Context, input InboxInput, departmentIDs []string, actorID string) (int, error) {
input.RecipientKind = strings.TrimSpace(input.RecipientKind)
input.Category = strings.TrimSpace(input.Category)
input.Title = strings.TrimSpace(input.Title)
input.Body = strings.TrimSpace(input.Body)
input.Link = strings.TrimSpace(input.Link)
if input.RecipientKind != "portal" && input.RecipientKind != "admin" {
return 0, errors.New("广播对象必须是 admin 或 portal")
}
if input.Title == "" || utf8.RuneCountInString(input.Title) > 256 || utf8.RuneCountInString(input.Body) > 4000 || utf8.RuneCountInString(input.Link) > 512 {
return 0, errors.New("广播标题或正文格式无效")
}
if !validInboxLink(input.Link) {
return 0, errors.New("跳转链接仅支持站内绝对路径或 http(s) 地址")
}
if input.Category != "system" && input.Category != "approval" && input.Category != "task_result" && input.Category != "resource" {
input.Category = "system"
}
var query string
var args []any
if input.RecipientKind == "admin" {
query = `SELECT id::text FROM gateway.admin_accounts WHERE active`
} else {
query = `SELECT id::text FROM gateway.portal_users WHERE active AND (array_length($1::uuid[],1) IS NULL OR department_id = ANY($1::uuid[]))`
args = append(args, departmentIDs)
}
rows, err := s.assets.pool.Query(ctx, query, args...)
if err != nil {
return 0, err
}
defer rows.Close()
recipients := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return 0, err
}
recipients = append(recipients, id)
}
if err := rows.Err(); err != nil {
return 0, err
}
// 广播整体包在单个事务里:中途失败不留半套消息,收件人数目与落库一致。
tx, err := s.assets.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer rollback(ctx, tx)
count := 0
for _, userID := range recipients {
id, idErr := newUUID()
if idErr != nil {
return 0, idErr
}
tag, insertErr := tx.Exec(ctx, `INSERT INTO gateway.inbox_messages(id,recipient_kind,recipient_user_id,sender_type,category,title,body,link,payload) VALUES($1,$2,$3,'admin',$4,$5,$6,$7,$8)`, id, input.RecipientKind, userID, input.Category, input.Title, input.Body, input.Link, input.Payload)
if insertErr != nil {
return 0, insertErr
}
count += int(tag.RowsAffected())
s.publish(input.RecipientKind, userID)
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
return count, nil
}
// validInboxLink blocks executable and browser-special schemes. Empty links are allowed;
// internal links must be root-relative, while external links are limited to HTTP(S).
func validInboxLink(link string) bool {
if link == "" {
return true
}
if strings.HasPrefix(link, "/") && !strings.HasPrefix(link, "//") {
return true
}
parsed, err := url.ParseRequestURI(link)
return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
}
+120
View File
@@ -0,0 +1,120 @@
package workbench
import (
"encoding/json"
"net/http"
"aigateway.local/core/internal/identity"
"aigateway.local/core/internal/platform/apiresponse"
)
// InboxAdminHTTPHandler 向管理端暴露站内消息:消息中心、未读数、广播与读回执。
type InboxAdminHTTPHandler struct {
inbox *InboxService
identity *identity.Service
mux *http.ServeMux
}
func NewInboxAdminHTTPHandler(inbox *InboxService, identityService *identity.Service) *InboxAdminHTTPHandler {
h := &InboxAdminHTTPHandler{inbox: inbox, identity: identityService, mux: http.NewServeMux()}
h.mux.HandleFunc("GET /api/v1/admin/inbox", h.list)
h.mux.HandleFunc("GET /api/v1/admin/inbox/unread", h.unread)
h.mux.HandleFunc("POST /api/v1/admin/inbox/broadcast", h.broadcast)
h.mux.HandleFunc("POST /api/v1/admin/inbox/read-all", h.readAll)
h.mux.HandleFunc("POST /api/v1/admin/inbox/{id}/read", h.read)
return h
}
func (h *InboxAdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) }
func (h *InboxAdminHTTPHandler) require(w http.ResponseWriter, r *http.Request, permission string) (identity.Account, bool) {
account, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization"))
if err != nil {
apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效")
return identity.Account{}, false
}
if !identity.HasPermission(account, permission) {
apiresponse.Error(w, http.StatusForbidden, "缺少站内消息权限")
return identity.Account{}, false
}
return account, true
}
func (h *InboxAdminHTTPHandler) list(w http.ResponseWriter, r *http.Request) {
account, ok := h.require(w, r, identity.PermissionInboxRead)
if !ok {
return
}
items, err := h.inbox.AdminList(r.Context(), account.ID, r.URL.Query().Get("scope"), 100)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, items)
}
func (h *InboxAdminHTTPHandler) unread(w http.ResponseWriter, r *http.Request) {
account, ok := h.require(w, r, identity.PermissionInboxRead)
if !ok {
return
}
count, err := h.inbox.UnreadCount(r.Context(), "admin", account.ID)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, map[string]int{"unread": count})
}
func (h *InboxAdminHTTPHandler) broadcast(w http.ResponseWriter, r *http.Request) {
if _, ok := h.require(w, r, identity.PermissionInboxManage); !ok {
return
}
var input struct {
RecipientKind string `json:"recipient_kind"`
DepartmentIDs []string `json:"department_ids"`
Category string `json:"category"`
Title string `json:"title"`
Body string `json:"body"`
Link string `json:"link"`
Payload json.RawMessage `json:"payload,omitempty"`
}
if !decodeAsset(w, r, &input) {
return
}
count, err := h.inbox.Broadcast(r.Context(), InboxInput{
RecipientKind: input.RecipientKind, Category: input.Category,
Title: input.Title, Body: input.Body, Link: input.Link, Payload: input.Payload,
}, input.DepartmentIDs, "")
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, map[string]any{"sent": count, "ok": true})
}
func (h *InboxAdminHTTPHandler) read(w http.ResponseWriter, r *http.Request) {
account, ok := h.require(w, r, identity.PermissionInboxRead)
if !ok {
return
}
changed, err := h.inbox.MarkRead(r.Context(), r.PathValue("id"), "admin", account.ID)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, map[string]bool{"read": changed})
}
func (h *InboxAdminHTTPHandler) readAll(w http.ResponseWriter, r *http.Request) {
account, ok := h.require(w, r, identity.PermissionInboxRead)
if !ok {
return
}
count, err := h.inbox.MarkAllRead(r.Context(), "admin", account.ID)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, map[string]any{"read_all": count})
}
@@ -0,0 +1,124 @@
package workbench
import (
"context"
"encoding/json"
"os"
"testing"
"aigateway.local/core/internal/platform/config"
"aigateway.local/core/internal/platform/database"
)
// TestInboxMaterializeAndBroadcast 验证站内消息核心链路:事件物化 → 未读计数 →
// 重放幂等 → 已读回执 → 管理员广播。Redis 传 nil,走 DB 权威未读路径。
func TestInboxMaterializeAndBroadcast(t *testing.T) {
databaseURL := os.Getenv("WORKBENCH_TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("WORKBENCH_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8, MinConns: 0})
if err != nil {
t.Fatal(err)
}
defer pool.Close()
adminID := "44444444-4444-4444-4444-444444444444"
portalID := "55555555-5555-5555-5555-555555555555"
cleanup := func() {
// 消息按收件人删:materialize 落给 admin 收件人,广播落给 portal 收件人(payload 为空,
// 不能只按 payload 匹配,否则广播消息残留导致重跑未读数累加)。
// 注意:同一 $1 同时比较 uuid 列与 jsonb text 提取,须显式 ::uuid / ::text,
// 否则 PG 无法推断参数类型报 "text = uuid"(SQLSTATE 42883)。
if _, cErr := pool.Exec(ctx, `DELETE FROM gateway.inbox_messages WHERE recipient_user_id=$1::uuid OR recipient_user_id=$2::uuid OR payload->>'actor_id'=$1::text OR payload->>'portal_user_id'=$1::text`, adminID, portalID); cErr != nil {
t.Logf("cleanup inbox DELETE failed: %v", cErr)
}
_, _ = pool.Exec(ctx, `DELETE FROM gateway.admin_accounts WHERE id=$1::uuid`, adminID)
_, _ = pool.Exec(ctx, `DELETE FROM gateway.portal_users WHERE id=$1::uuid OR lower(account)='m8-inbox-portal'`, portalID)
}
cleanup()
defer cleanup()
_, err = pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role,active) VALUES($1,'m8-inbox-admin','test','superadmin',true) ON CONFLICT(id) DO NOTHING`, adminID)
if err != nil {
t.Fatal(err)
}
_, err = pool.Exec(ctx, `INSERT INTO gateway.portal_users(id,account,password_hash,active) VALUES($1,'m8-inbox-portal','test',true) ON CONFLICT(id) DO NOTHING`, portalID)
if err != nil {
t.Fatal(err)
}
svc := NewInboxService(NewService(pool), nil, "")
// 1) 事件物化:knowledge_document.ready → admin 收件人
eventID := "99999999-9999-4999-8999-999999999999"
payload, _ := json.Marshal(map[string]any{"chunk_count": "8", "actor_id": adminID})
if err := svc.Materialize(ctx, eventID, "knowledge_document.ready", payload); err != nil {
t.Fatalf("materialize failed: %v", err)
}
if count, err := svc.UnreadCount(ctx, "admin", adminID); err != nil || count != 1 {
t.Fatalf("unread after materialize = %d, err=%v; want 1", count, err)
}
// 2) 同事件重放幂等:不新增行、不报错
if err := svc.Materialize(ctx, eventID, "knowledge_document.ready", payload); err != nil {
t.Fatalf("replay failed: %v", err)
}
if count, _ := svc.UnreadCount(ctx, "admin", adminID); count != 1 {
t.Fatalf("unread after replay = %d, want 1 (idempotent)", count)
}
// 3) 收件箱列出 + 已读回执
items, err := svc.List(ctx, "admin", adminID, 10)
if err != nil || len(items) != 1 {
t.Fatalf("list = %d items, err=%v; want 1", len(items), err)
}
if items[0].SenderType != "system" || items[0].Category != "system" || items[0].Title != "知识文档已入库" {
t.Fatalf("unexpected message shape: %+v", items[0])
}
changed, err := svc.MarkRead(ctx, items[0].ID, "admin", adminID)
if err != nil || !changed {
t.Fatalf("mark read changed=%v err=%v; want true", changed, err)
}
if count, _ := svc.UnreadCount(ctx, "admin", adminID); count != 0 {
t.Fatalf("unread after mark-read = %d, want 0", count)
}
// 4) 管理员广播到全部 portal 用户(至少命中测试门户用户)
sent, err := svc.Broadcast(ctx, InboxInput{RecipientKind: "portal", Category: "system", Title: "m8 升级公告", Body: "新增站内消息功能", Link: "/portal/inbox"}, nil, adminID)
if err != nil {
t.Fatalf("broadcast failed: %v", err)
}
if sent < 1 {
t.Fatalf("broadcast sent = %d, want >=1", sent)
}
if count, _ := svc.UnreadCount(ctx, "portal", portalID); count != 1 {
t.Fatalf("portal unread after broadcast = %d, want 1", count)
}
// 5) AdminList scope=broadcasts 能看到这条管理员广播。broadcasts 是全局视角
// (所有 admin→portal 广播),不能假设列表恰好 1 条,改为在其中找到本测试广播。
broadcasts, err := svc.AdminList(ctx, adminID, "broadcasts", 100)
if err != nil {
t.Fatalf("broadcasts list failed: %v", err)
}
found := false
for _, b := range broadcasts {
if b.SenderType == "admin" && b.Body == "新增站内消息功能" {
found = true
break
}
}
if !found {
t.Fatalf("test broadcast not found in broadcasts list (%d items)", len(broadcasts))
}
// 6) MarkAllRead 清空门户未读
marked, err := svc.MarkAllRead(ctx, "portal", portalID)
if err != nil || marked != 1 {
t.Fatalf("mark-all-read = %d, err=%v; want 1", marked, err)
}
if count, _ := svc.UnreadCount(ctx, "portal", portalID); count != 0 {
t.Fatalf("portal unread after mark-all = %d, want 0", count)
}
}
+89
View File
@@ -0,0 +1,89 @@
package workbench
import (
"net/http"
"strconv"
"aigateway.local/core/internal/identity"
"aigateway.local/core/internal/platform/apiresponse"
)
// InboxPortalHTTPHandler 向门户端暴露个人收件箱与未读徽标。
type InboxPortalHTTPHandler struct {
inbox *InboxService
identity *identity.Service
mux *http.ServeMux
}
func NewInboxPortalHTTPHandler(inbox *InboxService, identityService *identity.Service) *InboxPortalHTTPHandler {
h := &InboxPortalHTTPHandler{inbox: inbox, identity: identityService, mux: http.NewServeMux()}
h.mux.HandleFunc("GET /api/v1/portal/inbox", h.list)
h.mux.HandleFunc("GET /api/v1/portal/inbox/unread", h.unread)
h.mux.HandleFunc("POST /api/v1/portal/inbox/read-all", h.readAll)
h.mux.HandleFunc("POST /api/v1/portal/inbox/{id}/read", h.read)
return h
}
func (h *InboxPortalHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) }
func (h *InboxPortalHTTPHandler) account(w http.ResponseWriter, r *http.Request) (identity.Account, bool) {
account, err := h.identity.Authenticate(r.Context(), identity.KindPortal, r.Header.Get("Authorization"))
if err != nil {
apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效或已过期")
return identity.Account{}, false
}
return account, true
}
func (h *InboxPortalHTTPHandler) list(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := h.inbox.List(r.Context(), "portal", a.ID, limit)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, items)
}
func (h *InboxPortalHTTPHandler) unread(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
count, err := h.inbox.UnreadCount(r.Context(), "portal", a.ID)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, map[string]int{"unread": count})
}
func (h *InboxPortalHTTPHandler) read(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
changed, err := h.inbox.MarkRead(r.Context(), r.PathValue("id"), "portal", a.ID)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, map[string]bool{"read": changed})
}
func (h *InboxPortalHTTPHandler) readAll(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
count, err := h.inbox.MarkAllRead(r.Context(), "portal", a.ID)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, map[string]any{"read_all": count})
}
+125
View File
@@ -0,0 +1,125 @@
package workbench
import (
"encoding/json"
"testing"
)
// TestInboxPlanMapsEvents 覆盖 inboxPlan 纯函数:每个支持的事件类型都要产出
// 预期类别 / 收件人类别 / 文案关键词,未知事件返回 nil。
func TestInboxPlanMapsEvents(t *testing.T) {
payload := func(values map[string]any) json.RawMessage {
encoded, err := json.Marshal(values)
if err != nil {
t.Fatal(err)
}
return encoded
}
cases := []struct {
name string
eventType string
values map[string]any
wantKind string // recipient_kind
wantCategory string
wantTitle string
wantUserID string
wantAll bool
wantRequest bool
}{
{name: "model_access.requested 通知全部管理员审批", eventType: "model_access.requested", values: map[string]any{"model": "gpt-5"}, wantKind: "admin", wantCategory: "approval", wantTitle: "新的模型访问申请", wantAll: true},
{name: "model_access.decided 已批准回执给申请用户", eventType: "model_access.decided", values: map[string]any{"status": "approved"}, wantKind: "portal", wantCategory: "approval", wantTitle: "模型申请已处理", wantRequest: true},
{name: "model_access.decided 已驳回文案", eventType: "model_access.decided", values: map[string]any{"status": "rejected"}, wantKind: "portal", wantCategory: "approval", wantTitle: "模型申请已处理", wantRequest: true},
{name: "marketplace.installed 发给安装用户", eventType: "marketplace.installed", values: map[string]any{"code": "report-bot", "portal_user_id": "11111111-1111-1111-1111-111111111111"}, wantKind: "portal", wantCategory: "resource", wantTitle: "资源已安装", wantUserID: "11111111-1111-1111-1111-111111111111"},
{name: "knowledge_document.ready 发给执行管理员", eventType: "knowledge_document.ready", values: map[string]any{"chunk_count": "12", "actor_id": "22222222-2222-2222-2222-222222222222"}, wantKind: "admin", wantCategory: "system", wantTitle: "知识文档已入库", wantUserID: "22222222-2222-2222-2222-222222222222"},
{name: "knowledge_document.reprocessed 发给执行管理员", eventType: "knowledge_document.reprocessed", values: map[string]any{"actor_id": "22222222-2222-2222-2222-222222222222"}, wantKind: "admin", wantCategory: "system", wantTitle: "知识文档已重新处理", wantUserID: "22222222-2222-2222-2222-222222222222"},
{name: "knowledge_document.embedding_failed 降级提示", eventType: "knowledge_document.embedding_failed", values: map[string]any{"actor_id": "22222222-2222-2222-2222-222222222222"}, wantKind: "admin", wantCategory: "system", wantTitle: "知识文档向量化失败", wantUserID: "22222222-2222-2222-2222-222222222222"},
{name: "scheduled_task.completed 发给创建者", eventType: "scheduled_task.completed", values: map[string]any{"task_code": "daily-report", "actor_id": "33333333-3333-3333-3333-333333333333"}, wantKind: "admin", wantCategory: "task_result", wantTitle: "定时任务已执行", wantUserID: "33333333-3333-3333-3333-333333333333"},
{name: "scheduled_task.failed 发给创建者", eventType: "scheduled_task.failed", values: map[string]any{"task_code": "daily-report", "error": "timeout", "actor_id": "33333333-3333-3333-3333-333333333333"}, wantKind: "admin", wantCategory: "task_result", wantTitle: "定时任务执行失败", wantUserID: "33333333-3333-3333-3333-333333333333"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
drafts := inboxPlan(tc.eventType, payload(tc.values))
if len(drafts) != 1 {
t.Fatalf("expected exactly one draft, got %d", len(drafts))
}
draft := drafts[0]
if draft.RecipientKind != tc.wantKind {
t.Errorf("recipient_kind = %q, want %q", draft.RecipientKind, tc.wantKind)
}
if draft.Category != tc.wantCategory {
t.Errorf("category = %q, want %q", draft.Category, tc.wantCategory)
}
if draft.Title != tc.wantTitle {
t.Errorf("title = %q, want %q", draft.Title, tc.wantTitle)
}
if draft.UserID != tc.wantUserID {
t.Errorf("user_id = %q, want %q", draft.UserID, tc.wantUserID)
}
if draft.AllAdmins != tc.wantAll {
t.Errorf("all_admins = %v, want %v", draft.AllAdmins, tc.wantAll)
}
if draft.RequestUser != tc.wantRequest {
t.Errorf("request_user = %v, want %v", draft.RequestUser, tc.wantRequest)
}
})
}
if drafts := inboxPlan("some.unknown.event", payload(map[string]any{})); drafts != nil {
t.Fatalf("unknown event should map to no drafts, got %+v", drafts)
}
}
// TestInboxPlanModelAccessRejectedBody 校验驳回与批准的不同正文文案。
func TestInboxPlanModelAccessRejectedBody(t *testing.T) {
values := func(status string) json.RawMessage {
encoded, _ := json.Marshal(map[string]any{"status": status})
return encoded
}
approved := inboxPlan("model_access.decided", values("approved"))
rejected := inboxPlan("model_access.decided", values("rejected"))
if !contains(approved[0].Body, "已批准") {
t.Errorf("approved body should mention 已批准, got %q", approved[0].Body)
}
if !contains(rejected[0].Body, "已驳回") {
t.Errorf("rejected body should mention 已驳回, got %q", rejected[0].Body)
}
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
// TestPayloadValue 校验 payloadValue 对字符串与数字两类取值的兼容(事件载荷
// 中数字可能被 JSON 解码为 float64)。
func TestPayloadValue(t *testing.T) {
payload := json.RawMessage(`{"model":"gpt-5","chunk_count":12,"active":true}`)
if got := payloadValue(payload, "model"); got != "gpt-5" {
t.Errorf("string key = %q, want gpt-5", got)
}
if got := payloadValue(payload, "chunk_count"); got != "12" {
t.Errorf("numeric key = %q, want 12", got)
}
if got := payloadValue(payload, "missing"); got != "" {
t.Errorf("missing key = %q, want empty", got)
}
}
func TestValidInboxLink(t *testing.T) {
cases := map[string]bool{
"": true, "/portal/inbox": true, "https://example.com/notice": true,
"http://example.com": true, "javascript:alert(1)": false,
"data:text/html,x": false, "//example.com/path": false, "portal/inbox": false,
}
for link, want := range cases {
if got := validInboxLink(link); got != want {
t.Errorf("validInboxLink(%q) = %v, want %v", link, got, want)
}
}
}
+33 -2
View File
@@ -258,7 +258,19 @@ func (s *MarketplaceService) Detail(ctx context.Context, resourceType, code stri
return item, nil, getErr
}
item = MarketItem{Type: "mcp_server", Code: server.Code, Name: server.Name, Description: server.Description, CategoryID: server.CategoryID, CategoryName: server.CategoryName, Tags: server.Tags, DepartmentIDs: server.DepartmentIDs, UpdatedAt: server.UpdatedAt}
detail = server
// 门户详情不得暴露 endpoint_url 与 has_secret_headers:内网服务拓扑和
// 密钥状态仅管理端可见(运行时列表同样省略该字段)。
raw, err := json.Marshal(server)
if err != nil {
return item, nil, err
}
var sanitized map[string]any
if err := json.Unmarshal(raw, &sanitized); err != nil {
return item, nil, err
}
delete(sanitized, "endpoint_url")
delete(sanitized, "has_secret_headers")
detail = sanitized
case "skill":
skill, getErr := s.skills.GetPublishedByCode(ctx, code)
if getErr != nil {
@@ -320,7 +332,9 @@ func (s *MarketplaceService) Install(ctx context.Context, resourceType, code, po
}
func (s *MarketplaceService) Uninstall(ctx context.Context, resourceType, code, portalUserID string) error {
resourceID, err := s.publishedResourceID(ctx, resourceType, code)
// 卸载不受 enabled/status 限制:管理员停用或归档资源后,用户仍能
// 移除自己的安装,否则安装行永久卡死、列表永远显示。
resourceID, err := s.resourceIDByCode(ctx, resourceType, code)
if err != nil {
return err
}
@@ -416,6 +430,23 @@ func (s *MarketplaceService) publishedResourceID(ctx context.Context, resourceTy
return id, mapNotFound(err)
}
// resourceIDByCode 按 code 解析资源 ID,不限制启用/发布状态(卸载场景使用)。
func (s *MarketplaceService) resourceIDByCode(ctx context.Context, resourceType, code string) (string, error) {
var id string
var err error
switch resourceType {
case "mcp_server":
err = s.assets.pool.QueryRow(ctx, `SELECT id::text FROM gateway.mcp_servers WHERE code=$1`, code).Scan(&id)
case "skill":
err = s.assets.pool.QueryRow(ctx, `SELECT id::text FROM gateway.skills WHERE code=$1`, code).Scan(&id)
case "digital_employee":
err = s.assets.pool.QueryRow(ctx, `SELECT id::text FROM gateway.digital_employees WHERE code=$1`, code).Scan(&id)
default:
return "", errors.New("未知的资源类型")
}
return id, mapNotFound(err)
}
func (s *MarketplaceService) resourceByID(ctx context.Context, resourceType, id string) (MarketItem, bool, error) {
var item MarketItem
switch resourceType {
+16 -4
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -84,9 +85,14 @@ func (c *MCPClient) DiscoverTools(ctx context.Context, server MCPServer, headers
if err != nil {
return nil, err
}
// 读缓存与写入共用同一把锁,避免 -race 下 tools/toolsAt 的无锁读。
c.mu.Lock()
if state.tools != nil && time.Since(state.toolsAt) < c.cacheTTL {
return state.tools, nil
snapshot := state.tools
c.mu.Unlock()
return snapshot, nil
}
c.mu.Unlock()
result, err := c.call(ctx, server, headers, "tools/list", map[string]any{})
if err != nil {
return nil, err
@@ -157,13 +163,19 @@ func (c *MCPClient) CallTool(ctx context.Context, server MCPServer, headers map[
return MCPToolResult{Content: text.String()}, nil
}
// cacheKey 以服务器 revision 参与缓存键:管理端编辑 endpoint/请求头后
// revision 递增,旧会话与旧工具列表立即失效,不会把过期会话发往新端点。
func cacheKey(server MCPServer) string {
return server.ID + ":" + strconv.FormatInt(server.Revision, 10)
}
// ensureInitialized performs the MCP initialize handshake for a server if its
// session has lapsed (or no cached tools exist yet), then acknowledges with
// notifications/initialized. The handshake is guarded by the per-server cache
// so a burst of calls does not re-initialize every request.
func (c *MCPClient) ensureInitialized(ctx context.Context, server MCPServer, headers map[string]string) (*mcpServerState, error) {
c.mu.Lock()
state, ok := c.states[server.ID]
state, ok := c.states[cacheKey(server)]
if ok && time.Since(state.initAt) < c.cacheTTL {
c.mu.Unlock()
return state, nil
@@ -182,7 +194,7 @@ func (c *MCPClient) ensureInitialized(ctx context.Context, server MCPServer, hea
c.mu.Lock()
state = &mcpServerState{initAt: time.Now(), sessionID: sessionID}
c.states[server.ID] = state
c.states[cacheKey(server)] = state
c.mu.Unlock()
// Best-effort acknowledgment; servers that require it will reject later
@@ -233,7 +245,7 @@ func (c *MCPClient) sendNotification(ctx context.Context, server MCPServer, head
func (c *MCPClient) call(ctx context.Context, server MCPServer, headers map[string]string, method string, params any) (json.RawMessage, error) {
c.mu.Lock()
sessionID := ""
if state, ok := c.states[server.ID]; ok {
if state, ok := c.states[cacheKey(server)]; ok {
sessionID = state.sessionID
}
c.mu.Unlock()
+43 -3
View File
@@ -14,6 +14,7 @@ import (
"net/http"
"strings"
"time"
"unicode/utf8"
"aigateway.local/core/internal/platform/cryptox"
"aigateway.local/core/internal/provider"
@@ -253,7 +254,13 @@ func (s *NotificationService) deliver(ctx context.Context, channel NotificationC
message = fmt.Sprintf("Webhook 返回 HTTP %d", status)
}
if len(message) > 1000 {
message = message[:1000]
// 按字节截断可能切半多字节 rune;无效 UTF-8 会被 PostgreSQL 拒绝,
// 使投递记录无法更新,事件永远重试。
cut := message[:1000]
for len(cut) > 0 && !utf8.RuneStart(cut[len(cut)-1]) {
cut = cut[:len(cut)-1]
}
message = cut
}
_, dbErr := s.assets.pool.Exec(context.WithoutCancel(ctx), `UPDATE gateway.notification_deliveries SET status=$2,attempts=attempts+1,response_status=nullif($3,0),last_error=$4,delivered_at=CASE WHEN $2='delivered' THEN clock_timestamp() ELSE delivered_at END,updated_at=clock_timestamp() WHERE id=$1`, delivery.ID, map[bool]string{true: "delivered", false: "failed"}[success], status, message)
if dbErr != nil {
@@ -280,6 +287,7 @@ func (s *NotificationService) RetryDelivery(ctx context.Context, id string) erro
type NotificationDispatcher struct {
service *NotificationService
inbox *InboxService
redis *redis.Client
stream, group, consumer string
logger *slog.Logger
@@ -288,6 +296,10 @@ type NotificationDispatcher struct {
func NewNotificationDispatcher(service *NotificationService, client *redis.Client, stream, consumer string, logger *slog.Logger) *NotificationDispatcher {
return &NotificationDispatcher{service: service, redis: client, stream: stream, group: "gateway-notifications-v1", consumer: consumer, logger: logger}
}
// SetInbox wires the in-app inbox materializer (M8 P4). 为 nil 时站内消息不落库,
// Webhook 投递不受影响。handle 内幂等:inbox 以 (source_event_id, 收件人) 去重。
func (d *NotificationDispatcher) SetInbox(inbox *InboxService) { d.inbox = inbox }
func (d *NotificationDispatcher) Run(ctx context.Context) error {
if err := d.redis.XGroupCreateMkStream(ctx, d.stream, d.group, "$").Err(); err != nil && !strings.Contains(err.Error(), "BUSYGROUP") {
return err
@@ -414,11 +426,25 @@ func (d *NotificationDispatcher) handle(ctx context.Context, message redis.XMess
eventID := fmt.Sprint(message.Values["event_id"])
eventType := fmt.Sprint(message.Values["event_type"])
payload := json.RawMessage(fmt.Sprint(message.Values["payload"]))
// M8 P4:物化站内消息。失败与 webhook 同语义——事件留在 pending,由 reclaim 重试;
// inbox 幂等(ON CONFLICT)保证重放不产生重复消息。
if d.inbox != nil {
if err := d.inbox.Materialize(ctx, eventID, eventType, payload); err != nil {
return err
}
}
channels, err := d.service.ListChannels(ctx)
if err != nil {
return err
}
selectedChannelID := payloadValue(payload, "notification_channel_id")
if selectedChannelID == "null" {
selectedChannelID = ""
}
for _, channel := range channels {
if selectedChannelID != "" && channel.ID != selectedChannelID {
continue
}
if !channel.Enabled || !matchesEvent(channel.EventPatterns, eventType) {
continue
}
@@ -429,9 +455,23 @@ func (d *NotificationDispatcher) handle(ctx context.Context, message redis.XMess
if delivery.Status == "delivered" {
continue
}
if deliveryErr = d.service.deliver(ctx, channel, delivery); deliveryErr != nil && d.logger != nil {
d.logger.Warn("webhook delivery failed", "channel", channel.Name, "event_id", eventID, "error", deliveryErr)
if deliveryErr = d.service.deliver(ctx, channel, delivery); deliveryErr != nil {
// 投递失败必须让事件留在 pending 列表由 reclaim 重试;但重试预算
// 耗尽后放弃自动重试(投递记录保留 failed 状态,管理端可人工重试),
// 否则永久失败的 Webhook 会让事件无限期卡在 pending,阻塞该事件
// 的其它通道投递与站内消息。
if delivery.Attempts >= webhookMaxAttempts {
if d.logger != nil {
d.logger.Error("webhook delivery exhausted retries; manual retry available in admin",
"channel", channel.Name, "event_id", eventID, "attempts", delivery.Attempts, "error", deliveryErr)
}
continue
}
return deliveryErr
}
}
return nil
}
// webhookMaxAttempts 是单条投递记录的自动重试上限;deliver 每次失败 attempts+1。
const webhookMaxAttempts = 10
+4
View File
@@ -63,6 +63,10 @@ func (h *FilesPortalHTTPHandler) upload(w http.ResponseWriter, r *http.Request)
contentType = ct
}
}
// 非 multipart 请求(body 为 nil 时)按原始请求体上传(?filename= 指定文件名)。
if body == nil {
body = r.Body
}
if originalName == "" {
apiresponse.Error(w, http.StatusBadRequest, "缺少文件名")
return
+41 -13
View File
@@ -16,6 +16,7 @@ import (
"aigateway.local/core/internal/factcheck"
"aigateway.local/core/internal/gateway"
"aigateway.local/core/internal/platform/apiresponse"
tracepkg "aigateway.local/core/internal/trace"
)
type RuntimeHTTPHandler struct {
@@ -25,6 +26,7 @@ type RuntimeHTTPHandler struct {
auth apikey.PrincipalAuthenticator
gateway http.Handler
factCheck *factcheck.Engine
traces *tracepkg.Store
logger *slog.Logger
mux *http.ServeMux
market MarketplaceDeps
@@ -70,6 +72,11 @@ func (h *RuntimeHTTPHandler) SetLogger(logger *slog.Logger) {
// conversations. When nil (the default) fact-checking is skipped entirely.
func (h *RuntimeHTTPHandler) SetFactCheckEngine(engine *factcheck.Engine) { h.factCheck = engine }
// SetTraceStore enables metadata-only LLM Trace recording for application and
// digital-employee runs. Trace persistence is best effort and never changes
// the runtime response when the database is unavailable.
func (h *RuntimeHTTPHandler) SetTraceStore(store *tracepkg.Store) { h.traces = store }
// factCheckRetriever adapts the workbench Retriever to the fact-check engine's
// EvidenceRetriever interface, reusing the same knowledge-base search path that
// application prompts already use.
@@ -155,11 +162,17 @@ func (h *RuntimeHTTPHandler) principal(w http.ResponseWriter, r *http.Request) (
return principal, true
}
func visible(departments []string, principal apikey.Principal, secure bool) bool {
// fail-closed:无 APIKeyID 的匿名主体不视为"可见一切"。
// 今天认证器总是返回 bootstrap 或真实 key ID,但任何未来认证路径的
// 变化都不应静默放开所有部门作用域资产。
if principal.APIKeyID == "" {
return true
return false
}
// 无部门限定的资源是全局资源:所有已认证主体可见。secure 只标记
// "执行敏感能力"类资源,不改变可见性规则——否则全局工具/MCP 对
// 所有人不可见,绑定它们的应用会在运行时失败。
if len(departments) == 0 {
return !secure
return true
}
if principal.TenantID == nil {
return false
@@ -321,13 +334,18 @@ func (h *RuntimeHTTPHandler) runApplication(w http.ResponseWriter, r *http.Reque
runError := ""
retrievalCount := 0
toolCount := 0
modelCallCount := 0
conversationID := strings.TrimSpace(r.Header.Get("X-Gateway-Conversation-ID"))
traceID := h.beginTrace(r.Context(), principal, "application", app.ID, app.Code, conversationID)
defer func() {
traceCtx := context.WithoutCancel(r.Context())
h.finishTrace(traceCtx, traceID, status, runError, retrievalCount, modelCallCount, toolCount)
runID, idErr := newUUID()
if idErr == nil {
_, _ = h.service.pool.Exec(context.WithoutCancel(r.Context()), `INSERT INTO gateway.application_runs(id,application_id,version,api_key_id,request_id,status,latency_ms,retrieval_count,tool_count,error) VALUES($1,$2,nullif($3,0),nullif($4,'')::uuid,$5,$6,$7,$8,$9,$10)`, runID, app.ID, valueOrZero(app.PublishedVersion), principal.APIKeyID, gateway.RequestID(r.Context()), status, time.Since(started).Milliseconds(), retrievalCount, toolCount, runError)
_, _ = h.service.pool.Exec(traceCtx, `INSERT INTO gateway.application_runs(id,application_id,version,api_key_id,trace_id,request_id,status,latency_ms,retrieval_count,tool_count,error) VALUES($1,$2,nullif($3,0),nullif($4,'')::uuid,nullif($5,'')::uuid,$6,$7,$8,$9,$10,$11)`, runID, app.ID, valueOrZero(app.PublishedVersion), principal.APIKeyID, traceID, gateway.RequestID(r.Context()), status, time.Since(started).Milliseconds(), retrievalCount, toolCount, runError)
}
}()
payload, toolsByCode, prepareErr := h.prepareApplication(r.Context(), app, input, principal, &retrievalCount)
payload, toolsByCode, prepareErr := h.prepareApplication(r.Context(), app, input, principal, &retrievalCount, traceID)
if prepareErr != nil {
runError = prepareErr.Error()
runtimeError(w, 400, runError)
@@ -338,7 +356,8 @@ func (h *RuntimeHTTPHandler) runApplication(w http.ResponseWriter, r *http.Reque
var responseHeaders http.Header
var statusCode int
for round := 0; ; round++ {
statusCode, responseHeaders, response, err = h.callGateway(r, payload)
modelCallCount++
statusCode, responseHeaders, response, err = h.callGatewayWithTrace(r, payload, traceID, round)
if err != nil {
runError = err.Error()
copyHeaders(w.Header(), responseHeaders)
@@ -367,7 +386,9 @@ func (h *RuntimeHTTPHandler) runApplication(w http.ResponseWriter, r *http.Reque
if json.Unmarshal([]byte(call.Arguments), &args) != nil {
args = map[string]any{}
}
result, executeErr := h.tools.Execute(r.Context(), tool, args, principal.APIKeyID, gateway.RequestID(r.Context()))
result, executeErr := h.executeToolWithTrace(r.Context(), traceID, call.Name, call.ID, round, func() (map[string]any, error) {
return h.tools.Execute(r.Context(), tool, args, principal.APIKeyID, gateway.RequestID(r.Context()))
})
if executeErr != nil {
runError = executeErr.Error()
runtimeError(w, 502, runError)
@@ -379,7 +400,7 @@ func (h *RuntimeHTTPHandler) runApplication(w http.ResponseWriter, r *http.Reque
}
}
if h.factCheck != nil {
h.applyFactCheck(r, input, response)
h.applyFactCheck(r, input, response, app.DepartmentIDs)
}
response["application"] = map[string]any{"code": app.Code, "name": app.Name, "version": valueOrZero(app.PublishedVersion), "retrieval_count": retrievalCount, "tool_calls": toolCount}
status = "success"
@@ -389,17 +410,22 @@ func (h *RuntimeHTTPHandler) runApplication(w http.ResponseWriter, r *http.Reque
// applyFactCheck verifies the assistant answer against configured knowledge
// bases and applies the policy action. It must never fail the chat: any error
// is logged and the answer is returned unchanged.
func (h *RuntimeHTTPHandler) applyFactCheck(r *http.Request, input applicationRequest, response map[string]any) {
// is logged and the answer is returned unchanged. departments 用于选择
// department:<uuid> 作用域的策略,空列表只应用 global 策略。
func (h *RuntimeHTTPHandler) applyFactCheck(r *http.Request, input applicationRequest, response map[string]any, departments []string) {
answer, _ := assistantAnswer(response)
lastQuestion := lastUserMessage(input.Messages)
if strings.TrimSpace(answer) == "" || strings.TrimSpace(lastQuestion) == "" {
return
}
scope := ""
if len(departments) > 0 {
scope = "department:" + departments[0]
}
verifier := func(ctx context.Context, model, system, user string, timeout time.Duration) (string, error) {
return h.VerifyFactCheck(ctx, r, model, system, user, timeout)
}
event, err := h.factCheck.Check(r.Context(), gateway.RequestID(r.Context()), lastQuestion, answer, factcheck.VerifierFunc(verifier))
event, err := h.factCheck.Check(r.Context(), gateway.RequestID(r.Context()), scope, lastQuestion, answer, factcheck.VerifierFunc(verifier))
if err != nil {
h.logger.Warn("fact-check skipped", "request_id", gateway.RequestID(r.Context()), "error", err)
return
@@ -446,7 +472,7 @@ func overrideAnswer(response map[string]any, content string) {
}
}
func (h *RuntimeHTTPHandler) prepareApplication(ctx context.Context, app Application, input applicationRequest, principal apikey.Principal, retrievalCount *int) (map[string]any, map[string]Tool, error) {
func (h *RuntimeHTTPHandler) prepareApplication(ctx context.Context, app Application, input applicationRequest, principal apikey.Principal, retrievalCount *int, traceID string) (map[string]any, map[string]Tool, error) {
config := *app.PublishedConfig
messages := make([]map[string]any, 0, len(input.Messages)+2)
total := 0
@@ -490,7 +516,7 @@ func (h *RuntimeHTTPHandler) prepareApplication(ctx context.Context, app Applica
if err != nil || !kb.Enabled || !visible(kb.DepartmentIDs, principal, false) {
return nil, nil, fmt.Errorf("应用绑定的知识库 %s 当前不可用", kbID)
}
hits, searchErr := h.retriever.Search(ctx, kbID, lastQuestion, config.RetrievalTopK)
hits, searchErr := h.searchWithTrace(ctx, traceID, kbID, lastQuestion, config.RetrievalTopK)
if searchErr != nil {
continue
}
@@ -588,7 +614,9 @@ type boundedRecorder struct {
}
func newBoundedRecorder() *boundedRecorder {
return &boundedRecorder{code: http.StatusOK, header: make(http.Header)}
// code 初始为 0:WriteHeader 只在首次调用时生效,若网关从未调用
// WriteHeader,则 Write 时默认回退 200。
return &boundedRecorder{header: make(http.Header)}
}
func (r *boundedRecorder) Header() http.Header { return r.header }
+57 -10
View File
@@ -131,8 +131,10 @@ func (h *RuntimeHTTPHandler) invokeMCPTool(w http.ResponseWriter, r *http.Reques
}
type digitalEmployeeRequest struct {
Messages []map[string]any `json:"messages"`
Variables map[string]any `json:"variables"`
Messages []map[string]any `json:"messages"`
Variables map[string]any `json:"variables"`
SkillIDs []string `json:"skill_ids"`
MCPServerIDs []string `json:"mcp_server_ids"`
}
func (h *RuntimeHTTPHandler) runDigitalEmployee(w http.ResponseWriter, r *http.Request) {
@@ -168,13 +170,18 @@ func (h *RuntimeHTTPHandler) runDigitalEmployee(w http.ResponseWriter, r *http.R
runError := ""
retrievalCount := 0
toolCount := 0
modelCallCount := 0
conversationID := strings.TrimSpace(r.Header.Get("X-Gateway-Conversation-ID"))
traceID := h.beginTrace(r.Context(), principal, "digital_employee", employee.ID, employee.Code, conversationID)
defer func() {
traceCtx := context.WithoutCancel(r.Context())
h.finishTrace(traceCtx, traceID, status, runError, retrievalCount, modelCallCount, toolCount)
runID, idErr := newUUID()
if idErr == nil {
_, _ = h.service.pool.Exec(context.WithoutCancel(r.Context()), `INSERT INTO gateway.digital_employee_runs(id,digital_employee_id,api_key_id,request_id,status,latency_ms,retrieval_count,tool_count,error) VALUES($1,$2,nullif($3,'')::uuid,$4,$5,$6,$7,$8,$9)`, runID, employee.ID, principal.APIKeyID, gateway.RequestID(r.Context()), status, time.Since(started).Milliseconds(), retrievalCount, toolCount, runError)
_, _ = h.service.pool.Exec(traceCtx, `INSERT INTO gateway.digital_employee_runs(id,digital_employee_id,api_key_id,trace_id,request_id,status,latency_ms,retrieval_count,tool_count,error) VALUES($1,$2,nullif($3,'')::uuid,nullif($4,'')::uuid,$5,$6,$7,$8,$9,$10)`, runID, employee.ID, principal.APIKeyID, traceID, gateway.RequestID(r.Context()), status, time.Since(started).Milliseconds(), retrievalCount, toolCount, runError)
}
}()
executors, payload, prepareErr := h.prepareDigitalEmployee(r.Context(), employee, input, principal, &retrievalCount, portalUserID)
executors, payload, prepareErr := h.prepareDigitalEmployee(r.Context(), employee, input, principal, &retrievalCount, portalUserID, traceID)
if prepareErr != nil {
runError = prepareErr.Error()
runtimeError(w, 400, runError)
@@ -184,7 +191,8 @@ func (h *RuntimeHTTPHandler) runDigitalEmployee(w http.ResponseWriter, r *http.R
var responseHeaders http.Header
var statusCode int
for round := 0; ; round++ {
statusCode, responseHeaders, response, err = h.callGateway(r, payload)
modelCallCount++
statusCode, responseHeaders, response, err = h.callGatewayWithTrace(r, payload, traceID, round)
if err != nil {
runError = err.Error()
copyHeaders(w.Header(), responseHeaders)
@@ -213,7 +221,9 @@ func (h *RuntimeHTTPHandler) runDigitalEmployee(w http.ResponseWriter, r *http.R
if json.Unmarshal([]byte(call.Arguments), &args) != nil {
args = map[string]any{}
}
result, executeErr := exec(r.Context(), args)
result, executeErr := h.executeToolWithTrace(r.Context(), traceID, call.Name, call.ID, round, func() (map[string]any, error) {
return exec(r.Context(), args)
})
if executeErr != nil {
runError = executeErr.Error()
runtimeError(w, 502, runError)
@@ -224,6 +234,10 @@ func (h *RuntimeHTTPHandler) runDigitalEmployee(w http.ResponseWriter, r *http.R
toolCount++
}
}
// 事实核验与普通应用一致:block 策略不能因走数字员工入口而被绕过。
if h.factCheck != nil {
h.applyFactCheck(r, applicationRequest{Messages: input.Messages, Variables: input.Variables}, response, employee.DepartmentIDs)
}
response["digital_employee"] = map[string]any{"code": employee.Code, "name": employee.Name, "persona": employee.Persona, "retrieval_count": retrievalCount, "tool_calls": toolCount}
status = "success"
copyHeaders(w.Header(), responseHeaders)
@@ -234,7 +248,7 @@ func (h *RuntimeHTTPHandler) runDigitalEmployee(w http.ResponseWriter, r *http.R
// persona + rendered skills as system context, knowledge RAG evidence, and the
// union of bound tools (regular + MCP) exposed to the model. It returns the
// tool executors keyed by the exact schema name the model may call.
func (h *RuntimeHTTPHandler) prepareDigitalEmployee(ctx context.Context, employee DigitalEmployee, input digitalEmployeeRequest, principal apikey.Principal, retrievalCount *int, portalUserID string) (map[string]toolExecutor, map[string]any, error) {
func (h *RuntimeHTTPHandler) prepareDigitalEmployee(ctx context.Context, employee DigitalEmployee, input digitalEmployeeRequest, principal apikey.Principal, retrievalCount *int, portalUserID, traceID string) (map[string]toolExecutor, map[string]any, error) {
messages := make([]map[string]any, 0, len(input.Messages)+3)
total := 0
lastQuestion := ""
@@ -260,8 +274,16 @@ func (h *RuntimeHTTPHandler) prepareDigitalEmployee(ctx context.Context, employe
if strings.TrimSpace(employee.Persona) != "" {
system = append(system, employee.Persona)
}
selectedSkills, err := selectedBindings(input.SkillIDs, employee.SkillIDs, "Skill")
if err != nil {
return nil, nil, err
}
selectedMCPServers, err := selectedBindings(input.MCPServerIDs, employee.MCPServerIDs, "MCP")
if err != nil {
return nil, nil, err
}
skills := map[string]Skill{}
for _, skillID := range employee.SkillIDs {
for _, skillID := range selectedSkills {
skill, err := h.market.Skills.Get(ctx, skillID)
if err != nil || !skill.Enabled || !visible(skill.DepartmentIDs, principal, false) {
return nil, nil, fmt.Errorf("绑定的 Skill %s 当前不可用", skillID)
@@ -286,7 +308,7 @@ func (h *RuntimeHTTPHandler) prepareDigitalEmployee(ctx context.Context, employe
if err != nil || !kb.Enabled || !visible(kb.DepartmentIDs, principal, false) {
return fmt.Errorf("绑定的知识库 %s 当前不可用", kbID)
}
hits, searchErr := h.retriever.Search(ctx, kbID, lastQuestion, employee.RetrievalTopK)
hits, searchErr := h.searchWithTrace(ctx, traceID, kbID, lastQuestion, employee.RetrievalTopK)
if searchErr != nil {
return nil
}
@@ -382,7 +404,7 @@ func (h *RuntimeHTTPHandler) prepareDigitalEmployee(ctx context.Context, employe
}
}
}
for _, serverID := range employee.MCPServerIDs {
for _, serverID := range selectedMCPServers {
if err := addMCP(serverID); err != nil {
return nil, nil, err
}
@@ -405,6 +427,31 @@ func (h *RuntimeHTTPHandler) prepareDigitalEmployee(ctx context.Context, employe
return executors, payload, nil
}
// selectedBindings lets scheduled tasks restrict a digital employee to a
// subset of its published Skill/MCP bindings. Empty means the employee's full
// published binding set; callers can never add resources it does not own.
func selectedBindings(selected, allowed []string, label string) ([]string, error) {
if len(selected) == 0 {
return allowed, nil
}
allowedSet := make(map[string]bool, len(allowed))
for _, id := range allowed {
allowedSet[id] = true
}
seen := map[string]bool{}
result := make([]string, 0, len(selected))
for _, id := range selected {
if !allowedSet[id] {
return nil, fmt.Errorf("请求的 %s %s 未绑定到数字员工", label, id)
}
if !seen[id] {
seen[id] = true
result = append(result, id)
}
}
return result, nil
}
// portalUserID resolves the portal user behind an API key, if any. Resource
// marketplace installs are scoped to portal users.
func (h *RuntimeHTTPHandler) portalUserID(ctx context.Context, principal apikey.Principal) (string, error) {
@@ -0,0 +1,18 @@
package workbench
import "testing"
func TestSelectedBindings(t *testing.T) {
allowed := []string{"a", "b", "c"}
all, err := selectedBindings(nil, allowed, "Skill")
if err != nil || len(all) != 3 {
t.Fatalf("empty selection should use all bindings: %#v err=%v", all, err)
}
selected, err := selectedBindings([]string{"b", "b"}, allowed, "Skill")
if err != nil || len(selected) != 1 || selected[0] != "b" {
t.Fatalf("selection should be deduplicated: %#v err=%v", selected, err)
}
if _, err := selectedBindings([]string{"outside"}, allowed, "Skill"); err == nil {
t.Fatal("unbound selection should be rejected")
}
}
+148
View File
@@ -0,0 +1,148 @@
package workbench
import (
"context"
"fmt"
"net/http"
"strings"
"aigateway.local/core/internal/apikey"
"aigateway.local/core/internal/gateway"
tracepkg "aigateway.local/core/internal/trace"
)
func (h *RuntimeHTTPHandler) beginTrace(ctx context.Context, principal apikey.Principal, traceType, targetID, targetCode, conversationID string) string {
if h.traces == nil {
return ""
}
conversationID = strings.TrimSpace(conversationID)
input := tracepkg.StartInput{RequestID: gateway.RequestID(ctx), APIKeyID: principal.APIKeyID, TenantID: principal.TenantID, TraceType: traceType, TargetID: targetID, TargetCode: targetCode, ConversationID: conversationID}
item, err := h.traces.Start(context.WithoutCancel(ctx), input)
if err != nil {
h.logger.Warn("llm trace start failed", "request_id", input.RequestID, "target", targetCode, "error", err)
return ""
}
return item.ID
}
func (h *RuntimeHTTPHandler) finishTrace(ctx context.Context, traceID, status, errorText string, retrievalCount, modelCallCount, toolCallCount int) {
if h.traces == nil || traceID == "" {
return
}
if err := h.traces.Finish(context.WithoutCancel(ctx), traceID, tracepkg.FinishInput{Status: status, Error: errorText, RetrievalCount: retrievalCount, ModelCallCount: modelCallCount, ToolCallCount: toolCallCount}); err != nil {
h.logger.Warn("llm trace finish failed", "trace_id", traceID, "error", err)
}
}
func (h *RuntimeHTTPHandler) callGatewayWithTrace(original *http.Request, payload map[string]any, traceID string, round int) (int, http.Header, map[string]any, error) {
spanID := ""
model, _ := payload["model"].(string)
if h.traces != nil && traceID != "" {
span, err := h.traces.StartSpan(context.WithoutCancel(original.Context()), tracepkg.SpanInput{TraceID: traceID, SpanType: "model", Name: "chat.completions", Round: round, Model: model, Metadata: map[string]any{"endpoint": "/v1/chat/completions"}})
if err != nil {
h.logger.Warn("llm model span start failed", "trace_id", traceID, "error", err)
} else {
spanID = span.ID
}
}
statusCode, headers, response, callErr := h.callGateway(original, payload)
if spanID != "" {
inputTokens, outputTokens := responseUsage(response)
spanStatus := "success"
if callErr != nil || statusCode < 200 || statusCode >= 300 {
spanStatus = "error"
}
metadata := map[string]any{"http_status": statusCode, "round": round}
providerCode := ""
spanModel := model
if provider := headers.Get("X-Gateway-Provider"); provider != "" {
providerCode = provider
metadata["provider"] = provider
}
if resolvedModel := strings.TrimSpace(headers.Get("X-Gateway-Model")); resolvedModel != "" {
spanModel = resolvedModel
}
if err := h.traces.FinishSpan(context.WithoutCancel(original.Context()), spanID, tracepkg.SpanFinishInput{Status: spanStatus, Error: errorString(callErr), InputTokens: inputTokens, OutputTokens: outputTokens, ProviderCode: providerCode, Model: spanModel, Metadata: metadata}); err != nil {
h.logger.Warn("llm model span finish failed", "span_id", spanID, "error", err)
}
}
return statusCode, headers, response, callErr
}
func (h *RuntimeHTTPHandler) executeToolWithTrace(ctx context.Context, traceID, name, callID string, round int, execute func() (map[string]any, error)) (map[string]any, error) {
spanID := ""
if h.traces != nil && traceID != "" {
span, err := h.traces.StartSpan(context.WithoutCancel(ctx), tracepkg.SpanInput{TraceID: traceID, SpanType: "tool", Name: name, Round: round, Metadata: map[string]any{"tool_call_id": callID}})
if err != nil {
h.logger.Warn("llm tool span start failed", "trace_id", traceID, "tool", name, "error", err)
} else {
spanID = span.ID
}
}
result, executeErr := execute()
if spanID != "" {
status := "success"
if executeErr != nil {
status = "error"
}
if err := h.traces.FinishSpan(context.WithoutCancel(ctx), spanID, tracepkg.SpanFinishInput{Status: status, Error: errorString(executeErr), Metadata: map[string]any{"tool_call_id": callID}}); err != nil {
h.logger.Warn("llm tool span finish failed", "span_id", spanID, "error", err)
}
}
return result, executeErr
}
func (h *RuntimeHTTPHandler) searchWithTrace(ctx context.Context, traceID, knowledgeBaseID, query string, topK int) ([]SearchHit, error) {
spanID := ""
if h.traces != nil && traceID != "" {
span, err := h.traces.StartSpan(context.WithoutCancel(ctx), tracepkg.SpanInput{TraceID: traceID, SpanType: "retrieval", Name: "knowledge.search", Metadata: map[string]any{"knowledge_base_id": knowledgeBaseID, "top_k": topK}})
if err != nil {
h.logger.Warn("llm retrieval span start failed", "trace_id", traceID, "error", err)
} else {
spanID = span.ID
}
}
hits, searchErr := h.retriever.Search(ctx, knowledgeBaseID, query, topK)
if spanID != "" {
status := "success"
if searchErr != nil {
status = "error"
}
metadata := map[string]any{"knowledge_base_id": knowledgeBaseID, "hit_count": len(hits)}
if err := h.traces.FinishSpan(context.WithoutCancel(ctx), spanID, tracepkg.SpanFinishInput{Status: status, Error: errorString(searchErr), Metadata: metadata}); err != nil {
h.logger.Warn("llm retrieval span finish failed", "span_id", spanID, "error", err)
}
}
return hits, searchErr
}
func responseUsage(response map[string]any) (int64, int64) {
if response == nil {
return 0, 0
}
usage, _ := response["usage"].(map[string]any)
return numberValue(usage["prompt_tokens"], usage["input_tokens"]), numberValue(usage["completion_tokens"], usage["output_tokens"])
}
func numberValue(values ...any) int64 {
for _, value := range values {
switch number := value.(type) {
case float64:
return int64(number)
case float32:
return int64(number)
case int:
return int64(number)
case int64:
return number
}
}
return 0
}
func errorString(err error) string {
if err == nil {
return ""
}
return fmt.Sprint(err)
}
@@ -10,12 +10,14 @@ import (
"os"
"strings"
"testing"
"time"
"aigateway.local/core/internal/apikey"
"aigateway.local/core/internal/gateway"
"aigateway.local/core/internal/platform/config"
"aigateway.local/core/internal/platform/cryptox"
"aigateway.local/core/internal/platform/database"
tracepkg "aigateway.local/core/internal/trace"
)
func TestWorkbenchPostgreSQLLifecycle(t *testing.T) {
@@ -35,7 +37,7 @@ func TestWorkbenchPostgreSQLLifecycle(t *testing.T) {
t.Fatal(err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM gateway.notification_channels WHERE name='m4-webhook'; DELETE FROM gateway.applications WHERE code='m4_app'; DELETE FROM gateway.tool_definitions WHERE code='m4_lookup'; DELETE FROM gateway.knowledge_bases WHERE name='m4-integration-kb'; DELETE FROM gateway.prompt_templates WHERE name='m4-integration-prompt'`)
_, _ = pool.Exec(ctx, `DELETE FROM gateway.agent_traces WHERE request_id='m4-runtime'; DELETE FROM gateway.notification_channels WHERE name='m4-webhook'; DELETE FROM gateway.applications WHERE code='m4_app'; DELETE FROM gateway.tool_definitions WHERE code='m4_lookup'; DELETE FROM gateway.knowledge_bases WHERE name='m4-integration-kb'; DELETE FROM gateway.prompt_templates WHERE name='m4-integration-prompt'`)
}
cleanup()
defer cleanup()
@@ -117,6 +119,8 @@ func TestWorkbenchPostgreSQLLifecycle(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "完成"}}}})
})
runtime := NewRuntimeHTTPHandler(assets, tools, NewRetriever(assets, nil), staticPrincipalAuthenticator{}, fakeGateway, MarketplaceDeps{})
traceStore := tracepkg.NewStore(pool)
runtime.SetTraceStore(traceStore)
runtimeRequest := httptest.NewRequest(http.MethodPost, "/v1/applications/m4_app/chat/completions", bytes.NewBufferString(`{"messages":[{"role":"user","content":"不可变运行时快照是什么?"}],"variables":{"question":"架构"}}`))
runtimeRequest.Header.Set("Authorization", "Bearer test")
runtimeRequest = runtimeRequest.WithContext(gateway.WithRequestID(runtimeRequest.Context(), "m4-runtime"))
@@ -125,6 +129,14 @@ func TestWorkbenchPostgreSQLLifecycle(t *testing.T) {
if runtimeResponse.Code != http.StatusOK || !governed || !strings.Contains(runtimeResponse.Body.String(), `"application"`) {
t.Fatalf("runtime status=%d governed=%v body=%s", runtimeResponse.Code, governed, runtimeResponse.Body.String())
}
traces, err := traceStore.List(ctx, tracepkg.Filter{From: time.Now().Add(-time.Minute), To: time.Now().Add(time.Minute), RequestID: "m4-runtime", Limit: 10})
if err != nil || len(traces) != 1 || traces[0].TraceType != "application" || traces[0].ModelCallCount != 1 {
t.Fatalf("runtime trace=%+v err=%v", traces, err)
}
detail, err := traceStore.Get(ctx, traces[0].ID)
if err != nil || len(detail.Spans) < 2 {
t.Fatalf("runtime trace detail=%+v err=%v", detail, err)
}
signed := ""
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {