Files
ai-gateway-go/internal/workbench/inbox.go
T
LLMGuardX Dev d534865b33 0.11.4: 旗舰版第五轮完善(智能体节点任务下发/个人智能体安全策略)
- 节点任务:管理端向节点池下发(Prompt/HTTP/MCP/Skill/数字员工/自定义),
  指定节点或池路由,认领 SKIP LOCKED + 15 分钟租约,认领令牌防重放上报,
  失败 30s×次数退避重入队,达上限 failed,支持取消/重试,完成与失败站内信。
- 个人智能体安全策略:auto_approve_tools 跳过个人调用审批门;
  rate_limit_multiplier 按 (tool,user) 独立窗口放宽个人限流(全局额度不受影响)。
- 修复存量缺陷:/v1/agent/nodes/ 未挂 publicMux,节点心跳/认领端点在部署
  拓扑下不可达。
- 迁移 000046;任务全链路集成测试连真实库通过,HTTP 端到端验证
  (下发→认领→伪造令牌拒绝→上报→succeeded,列表不泄露认领令牌);
  25 包测试通过,前后端构建通过。
2026-08-13 14:30:37 +08:00

410 lines
17 KiB
Go

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)
NotifyPref bool // 收件人 = payload.portal_user_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")}}
case "security.login_detected":
ip := payloadValue(payload, "ip")
if ip == "" {
ip = "未知地址"
}
return []inboxDraft{{RecipientKind: "portal", Category: "security", Title: "新设备登录提醒", Body: "你的账号刚刚从 " + ip + " 登录,如非本人操作请立即修改密码", Link: "/portal/security", UserID: payloadValue(payload, "portal_user_id"), NotifyPref: true}}
case "resource_access.requested":
return []inboxDraft{{RecipientKind: "admin", Category: "approval", Title: "新的资源权限申请", Body: "用户申请访问 " + payloadValue(payload, "resource_type") + " " + payloadValue(payload, "resource_code"), Link: "/system/approvals", AllAdmins: true}}
case "resource_access.decided":
text := "已批准"
if payloadValue(payload, "status") == "rejected" {
text = "已驳回"
}
return []inboxDraft{{RecipientKind: "portal", Category: "approval", Title: "资源申请已处理", Body: "您的资源权限申请(" + payloadValue(payload, "resource_type") + " " + payloadValue(payload, "resource_code") + ")已被" + text, Link: "/portal/requests", UserID: payloadValue(payload, "portal_user_id")}}
case "tool_approval.requested":
return []inboxDraft{{RecipientKind: "admin", Category: "approval", Title: "工具使用待审批", Body: "工具 " + payloadValue(payload, "tool_code") + " 首次被调用,需审批后才能使用", Link: "/system/approvals", AllAdmins: true}}
case "agent_task.completed":
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "节点任务已完成", Body: "节点任务(" + payloadValue(payload, "task_id") + ")执行成功", Link: "/security/agent-tasks", AllAdmins: true}}
case "agent_task.failed":
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "节点任务执行失败", Body: "节点任务(" + payloadValue(payload, "task_id") + ")执行失败: " + payloadValue(payload, "error"), Link: "/security/agent-tasks", AllAdmins: true}}
}
return nil
}
func (s *InboxService) resolveRecipients(ctx context.Context, draft inboxDraft, payload json.RawMessage) ([]string, error) {
switch {
case draft.UserID != "" && draft.NotifyPref:
// 登录提醒:尊重账号的安全偏好(默认开启)。
var notify bool
if err := s.assets.pool.QueryRow(ctx, `SELECT COALESCE((SELECT login_notify FROM gateway.portal_security_prefs WHERE portal_user_id=$1),true)`, draft.UserID).Scan(&notify); err != nil {
return nil, err
}
if !notify {
return nil, nil
}
return []string{draft.UserID}, nil
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 != ""
}