87c2b04174
- 统一审批中心:模型/资源/渠道/工具四类申请聚合审批,通过自动开通 (marketplace 安装/渠道授权),outbox 双向站内信;门户可发起/撤回。 - 工具治理:rate_limit_rpm(固定窗口原子 upsert,多实例共享)+ approval_required (首次调用自动发起审批,批准前一律拒绝)。 - 平台环境变量:平台级注入 skill/MCP 运行时,个人可覆盖;系统管理员可写。 - 数字员工会话入口:门户列表/对话/调用记录,复用用户运行时凭据。 - 个人渠道:webhook 入站令牌 SHA-256 摘要 + constant-time 校验,绑定已批准 模型,用量归属用户 Key。 - 报表多维:工具调用/审批授权/安全事件三组统计端点与页面。 - 租户配额:部门 Key/月 Token 上限,运行时凭据开通强制校验,概览展示用量。 - 迁移 000042-000045;修复渠道空 API Key NOT NULL 违约与 inet 扫描; 25 包测试通过,前后端构建通过,端到端验证完成。
406 lines
17 KiB
Go
406 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}}
|
|
}
|
|
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(¬ify); 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 != ""
|
|
}
|