Files
LLMGuardX Dev 87c2b04174 0.11.3: 旗舰版第四轮完善(统一审批中心/工具治理/平台环境变量/数字员工入口/个人渠道/报表多维/租户配额)
- 统一审批中心:模型/资源/渠道/工具四类申请聚合审批,通过自动开通
  (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 包测试通过,前后端构建通过,端到端验证完成。
2026-08-13 13:41:22 +08:00

138 lines
4.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package portal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"time"
"aigateway.local/core/internal/gateway"
"aigateway.local/core/internal/identity"
"github.com/jackc/pgx/v5"
)
// DigitalEmployeeView 是门户可见的数字员工(部门可见或已安装)。
type DigitalEmployeeView struct {
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
Installed bool `json:"installed"`
}
// DigitalEmployeeRun 是用户的数字员工调用记录。
type DigitalEmployeeRun struct {
EmployeeCode string `json:"employee_code"`
EmployeeName string `json:"employee_name"`
Status string `json:"status"`
LatencyMS int64 `json:"latency_ms"`
Retrieval int `json:"retrieval_count"`
ToolCalls int `json:"tool_count"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// DigitalEmployees 返回当前用户可用的数字员工。
func (s *Service) DigitalEmployees(ctx context.Context, account identity.Account) ([]DigitalEmployeeView, error) {
if s.market == nil {
return []DigitalEmployeeView{}, nil
}
items, err := s.market.Catalog(ctx, "digital_employee", "", "", "", 200)
if err != nil {
return nil, err
}
installedItems, err := s.market.ListInstalled(ctx, account.ID)
if err != nil {
return nil, err
}
installed := map[string]bool{}
for _, item := range installedItems {
if item.Type == "digital_employee" {
installed[item.Code] = true
}
}
result := []DigitalEmployeeView{}
for _, item := range items {
if !visible(item.DepartmentIDs, account.DepartmentID) {
continue
}
result = append(result, DigitalEmployeeView{Code: item.Code, Name: item.Name, Description: item.Description, Installed: installed[item.Code]})
}
return result, nil
}
// RunDigitalEmployee 运行一次数字员工对话(复用用户运行时凭据)。
func (s *Service) RunDigitalEmployee(ctx context.Context, account identity.Account, code, message string) (map[string]any, error) {
code = strings.ToLower(strings.TrimSpace(code))
message = strings.TrimSpace(message)
if message == "" || len(message) > 100000 {
return nil, errors.New("消息为空或过长")
}
if s.credentials == nil || s.runtime == nil {
return nil, errors.New("数字员工服务未配置")
}
secret, _, err := s.credentials.EnsureUser(ctx, account.ID, account.DepartmentID, 120, 0)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(map[string]any{
"messages": []map[string]any{{"role": "user", "content": message}},
"variables": map[string]any{},
})
request := httptest.NewRequest(http.MethodPost, "/v1/digital-employees/"+code+"/chat/completions", bytes.NewReader(payload)).WithContext(gateway.WithRequestID(ctx, "portal-employee-"+time.Now().UTC().Format("20060102150405.000000000")))
request.Header.Set("Authorization", "Bearer "+secret)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
s.runtime.ServeHTTP(recorder, request)
var response map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
return nil, errors.New("数字员工响应无法解析")
}
if recorder.Code < 200 || recorder.Code >= 300 {
message := fmt.Sprintf("数字员工调用失败(HTTP %d", recorder.Code)
if value, ok := response["error"].(map[string]any); ok {
if text, ok := value["message"].(string); ok {
message = text
}
}
return response, errors.New(message)
}
return response, nil
}
// MyEmployeeRuns 返回当前用户的数字员工调用记录(经用户运行时 Key 归属)。
func (s *Service) MyEmployeeRuns(ctx context.Context, account identity.Account, limit int) ([]DigitalEmployeeRun, error) {
if limit < 1 || limit > 100 {
limit = 20
}
// 运行时 Key 可能尚未开通:此时无记录,直接返回空。
var apiKeyID string
err := s.pool.QueryRow(ctx, `SELECT api_key_id::text FROM gateway.portal_user_runtime_credentials WHERE portal_user_id=$1`, account.ID).Scan(&apiKeyID)
if errors.Is(err, pgx.ErrNoRows) {
return []DigitalEmployeeRun{}, nil
}
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `SELECT e.code,e.name,r.status,r.latency_ms,r.retrieval_count,r.tool_count,r.error,r.created_at
FROM gateway.digital_employee_runs r JOIN gateway.digital_employees e ON e.id=r.digital_employee_id
WHERE r.api_key_id=$1 ORDER BY r.created_at DESC LIMIT $2`, apiKeyID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []DigitalEmployeeRun{}
for rows.Next() {
var item DigitalEmployeeRun
if err := rows.Scan(&item.EmployeeCode, &item.EmployeeName, &item.Status, &item.LatencyMS, &item.Retrieval, &item.ToolCalls, &item.Error, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}