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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user