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 包测试通过,前后端构建通过,端到端验证完成。
This commit is contained in:
@@ -47,6 +47,8 @@ func NewAdminHTTPHandler(service *Service, tools *ToolService, notifications *No
|
||||
h.mux.HandleFunc("PUT /api/v1/admin/tools/{id}", h.updateTool)
|
||||
h.mux.HandleFunc("DELETE /api/v1/admin/tools/{id}", h.deleteTool)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/tools/{id}/test", h.testTool)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/tool-approvals", h.listToolApprovals)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/tool-approvals/{id}/decide", h.decideToolApproval)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/applications", h.listApplications)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/applications", h.createApplication)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/applications/catalog", h.applicationCatalog)
|
||||
@@ -435,6 +437,8 @@ type toolPayload struct {
|
||||
InputSchema json.RawMessage `json:"input_schema"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
DepartmentIDs []string `json:"department_ids"`
|
||||
RateLimitRPM int `json:"rate_limit_rpm"`
|
||||
ApprovalReq bool `json:"approval_required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -445,7 +449,40 @@ func toolInput(p toolPayload, create bool) ToolInput {
|
||||
} else if create {
|
||||
headers = map[string]string{}
|
||||
}
|
||||
return ToolInput{Code: p.Code, Name: p.Name, Description: p.Description, EndpointURL: p.EndpointURL, HTTPMethod: p.HTTPMethod, Headers: headers, InputSchema: p.InputSchema, TimeoutSeconds: p.TimeoutSeconds, DepartmentIDs: p.DepartmentIDs, Enabled: p.Enabled}
|
||||
return ToolInput{Code: p.Code, Name: p.Name, Description: p.Description, EndpointURL: p.EndpointURL, HTTPMethod: p.HTTPMethod, Headers: headers, InputSchema: p.InputSchema, TimeoutSeconds: p.TimeoutSeconds, DepartmentIDs: p.DepartmentIDs, RateLimitRPM: p.RateLimitRPM, ApprovalRequired: p.ApprovalReq, Enabled: p.Enabled}
|
||||
}
|
||||
|
||||
// listToolApprovals 工具审批申请列表(治理中心)。
|
||||
func (h *AdminHTTPHandler) listToolApprovals(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionToolRead); !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.tools.ListApprovalRequests(r.Context(), r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusServiceUnavailable, "工具审批查询失败")
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
// decideToolApproval 审批工具申请(通过后工具可调用)。
|
||||
func (h *AdminHTTPHandler) decideToolApproval(w http.ResponseWriter, r *http.Request) {
|
||||
admin, ok := h.require(w, r, identity.PermissionToolManage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Status string `json:"status"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if !decodeAsset(w, r, &input) {
|
||||
return
|
||||
}
|
||||
if err := h.tools.DecideApprovalRequest(r.Context(), r.PathValue("id"), input.Status, input.Note, admin.ID); err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"decided": true})
|
||||
}
|
||||
func (h *AdminHTTPHandler) listTools(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionToolRead); !ok {
|
||||
|
||||
+197
-19
@@ -2,10 +2,10 @@ package workbench
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"aigateway.local/core/internal/identity"
|
||||
@@ -102,39 +102,140 @@ func (s *EnvVarService) Decrypt(ctx context.Context, userID, key string) (string
|
||||
return string(plaintext), true, nil
|
||||
}
|
||||
|
||||
// MergeVariables 把用户环境变量合并进请求变量(请求未提供的键)。
|
||||
// MergeVariables 把平台变量与个人变量合并进请求变量(请求已提供的键保持优先,
|
||||
// 个人变量覆盖平台默认值)。
|
||||
func (s *EnvVarService) MergeVariables(ctx context.Context, userID string, variables map[string]any) error {
|
||||
if userID == "" || len(variables) >= 100 {
|
||||
if s == nil || s.pool == nil || s.cipher == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT key,encrypted_value,value_kek_version FROM gateway.user_env_vars WHERE portal_user_id=$1 LIMIT 200`, userID)
|
||||
if len(variables) >= 100 {
|
||||
return nil
|
||||
}
|
||||
merged, err := s.mergeAll(ctx, userID, variables)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
type pair struct{ key string; value []byte; version int }
|
||||
pairs := []pair{}
|
||||
for rows.Next() {
|
||||
var p pair
|
||||
if err := rows.Scan(&p.key, &p.value, &p.version); err != nil {
|
||||
return err
|
||||
for key, value := range merged {
|
||||
variables[key] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EnvVarService) mergeAll(ctx context.Context, userID string, variables map[string]any) (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
type pair struct {
|
||||
key string
|
||||
value []byte
|
||||
version int
|
||||
}
|
||||
collect := func(query string, args ...any) ([]pair, error) {
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pairs = append(pairs, p)
|
||||
defer rows.Close()
|
||||
pairs := []pair{}
|
||||
for rows.Next() {
|
||||
var p pair
|
||||
if err := rows.Scan(&p.key, &p.value, &p.version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pairs = append(pairs, p)
|
||||
}
|
||||
return pairs, rows.Err()
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
// 平台变量(全部,最多 200)。
|
||||
platform, err := collect(`SELECT key,encrypted_value,value_kek_version FROM gateway.platform_env_vars ORDER BY key LIMIT 200`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range pairs {
|
||||
for _, p := range platform {
|
||||
if _, exists := variables[p.key]; exists {
|
||||
continue
|
||||
}
|
||||
plaintext, err := s.cipher.Decrypt(p.value, p.version)
|
||||
if err != nil {
|
||||
plaintext, decryptErr := s.cipher.Decrypt(p.value, p.version)
|
||||
if decryptErr != nil {
|
||||
continue
|
||||
}
|
||||
variables[p.key] = string(plaintext)
|
||||
out[p.key] = string(plaintext)
|
||||
}
|
||||
return nil
|
||||
// 个人变量覆盖平台默认值。
|
||||
if userID != "" {
|
||||
personal, err := collect(`SELECT key,encrypted_value,value_kek_version FROM gateway.user_env_vars WHERE portal_user_id=$1 ORDER BY key LIMIT 200`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range personal {
|
||||
if _, exists := variables[p.key]; exists {
|
||||
continue
|
||||
}
|
||||
plaintext, decryptErr := s.cipher.Decrypt(p.value, p.version)
|
||||
if decryptErr != nil {
|
||||
continue
|
||||
}
|
||||
out[p.key] = string(plaintext)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PlatformList 返回平台环境变量(不含值)。
|
||||
func (s *EnvVarService) PlatformList(ctx context.Context) ([]map[string]any, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, errors.New("环境变量服务不可用")
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT key,octet_length(encrypted_value)>0,description,updated_at FROM gateway.platform_env_vars ORDER BY key`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var key, description string
|
||||
var hasValue bool
|
||||
var updatedAt any
|
||||
if err := rows.Scan(&key, &hasValue, &description, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, map[string]any{"key": key, "configured": hasValue, "description": description, "updated_at": updatedAt})
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// PlatformUpsert 设置平台环境变量;value 为空时删除。
|
||||
func (s *EnvVarService) PlatformUpsert(ctx context.Context, actorID, key, value, description string) error {
|
||||
if s == nil || s.pool == nil || s.cipher == nil {
|
||||
return errors.New("环境变量服务不可用")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || len(key) > 128 || !envKeyPattern.MatchString(key) {
|
||||
return errors.New("变量名必须以字母开头,可含字母/数字/下划线,最长 128 字符")
|
||||
}
|
||||
if len(description) > 512 {
|
||||
return errors.New("描述过长")
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.platform_env_vars WHERE key=$1`, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("变量不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(value) > 4096 {
|
||||
return errors.New("变量值过长")
|
||||
}
|
||||
encrypted, version, err := s.cipher.Encrypt([]byte(value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `INSERT INTO gateway.platform_env_vars(key,encrypted_value,value_kek_version,description,updated_by) VALUES($1,$2,$3,$4,$5)
|
||||
ON CONFLICT(key) DO UPDATE SET encrypted_value=$2,value_kek_version=$3,description=$4,updated_by=$5,updated_at=clock_timestamp()`,
|
||||
key, encrypted, version, description, actorID)
|
||||
return err
|
||||
}
|
||||
|
||||
var envKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,127}$`)
|
||||
@@ -210,3 +311,80 @@ func (h *EnvVarHTTPHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// AdminEnvVarHTTPHandler 平台环境变量管理(系统管理员)。
|
||||
type AdminEnvVarHTTPHandler struct {
|
||||
service *EnvVarService
|
||||
identity *identity.Service
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewAdminEnvVarHTTPHandler(service *EnvVarService, identityService *identity.Service) *AdminEnvVarHTTPHandler {
|
||||
h := &AdminEnvVarHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()}
|
||||
h.mux.HandleFunc("GET /api/v1/admin/env-vars", h.list)
|
||||
h.mux.HandleFunc("PUT /api/v1/admin/env-vars/{key}", h.upsert)
|
||||
h.mux.HandleFunc("DELETE /api/v1/admin/env-vars/{key}", h.delete)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *AdminEnvVarHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *AdminEnvVarHTTPHandler) admin(w http.ResponseWriter, r *http.Request) (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, identity.PermissionSystemManage) {
|
||||
apiresponse.Error(w, http.StatusForbidden, "无系统管理权限")
|
||||
return identity.Account{}, false
|
||||
}
|
||||
return account, true
|
||||
}
|
||||
|
||||
func (h *AdminEnvVarHTTPHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.admin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.PlatformList(r.Context())
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusServiceUnavailable, "环境变量查询失败")
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
func (h *AdminEnvVarHTTPHandler) upsert(w http.ResponseWriter, r *http.Request) {
|
||||
admin, ok := h.admin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Value string `json:"value"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&input) != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if err := h.service.PlatformUpsert(r.Context(), admin.ID, r.PathValue("key"), input.Value, input.Description); err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"saved": true})
|
||||
}
|
||||
|
||||
func (h *AdminEnvVarHTTPHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.admin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.PlatformUpsert(r.Context(), "", r.PathValue("key"), "", ""); err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -122,6 +122,16 @@ func inboxPlan(eventType string, payload json.RawMessage) []inboxDraft {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ func TestInboxPlanMapsEvents(t *testing.T) {
|
||||
{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"},
|
||||
{name: "security.login_detected 发登录提醒且受偏好约束", eventType: "security.login_detected", values: map[string]any{"portal_user_id": "44444444-4444-4444-4444-444444444444", "ip": "203.0.113.7"}, wantKind: "portal", wantCategory: "security", wantTitle: "新设备登录提醒", wantUserID: "44444444-4444-4444-4444-444444444444", wantPref: true},
|
||||
{name: "resource_access.requested 通知全部管理员审批", eventType: "resource_access.requested", values: map[string]any{"resource_type": "channel", "resource_code": "corp_wecom"}, wantKind: "admin", wantCategory: "approval", wantTitle: "新的资源权限申请", wantAll: true},
|
||||
{name: "resource_access.decided 回执给申请用户", eventType: "resource_access.decided", values: map[string]any{"portal_user_id": "44444444-4444-4444-4444-444444444444", "resource_type": "skill", "resource_code": "sql-helper", "status": "approved"}, wantKind: "portal", wantCategory: "approval", wantTitle: "资源申请已处理", wantUserID: "44444444-4444-4444-4444-444444444444"},
|
||||
{name: "tool_approval.requested 通知管理员审批工具", eventType: "tool_approval.requested", values: map[string]any{"tool_code": "shell_exec", "tool_id": "55555555-5555-5555-5555-555555555555"}, wantKind: "admin", wantCategory: "approval", wantTitle: "工具使用待审批", wantAll: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
+131
-5
@@ -56,6 +56,9 @@ func (s *ToolService) validate(ctx context.Context, input *ToolInput, create boo
|
||||
if input.TimeoutSeconds < 1 || input.TimeoutSeconds > 120 {
|
||||
return errors.New("超时应在 1-120 秒之间")
|
||||
}
|
||||
if input.RateLimitRPM < 0 || input.RateLimitRPM > 100000 {
|
||||
return errors.New("工具限流应在 0-100000 RPM 之间")
|
||||
}
|
||||
input.DepartmentIDs, err = normalizeStrings(input.DepartmentIDs, 100)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -83,11 +86,11 @@ func (s *ToolService) validate(ctx context.Context, input *ToolInput, create boo
|
||||
return nil
|
||||
}
|
||||
|
||||
const toolSelect = `SELECT id::text,code,name,description,endpoint_url,http_method,input_schema,timeout_seconds,department_ids::text[],enabled,octet_length(encrypted_headers)>0,revision,created_at,updated_at,encrypted_headers,headers_kek_version FROM gateway.tool_definitions`
|
||||
const toolSelect = `SELECT id::text,code,name,description,endpoint_url,http_method,input_schema,timeout_seconds,department_ids::text[],rate_limit_rpm,approval_required,enabled,octet_length(encrypted_headers)>0,revision,created_at,updated_at,encrypted_headers,headers_kek_version FROM gateway.tool_definitions`
|
||||
|
||||
func scanTool(row pgx.Row) (Tool, error) {
|
||||
var t Tool
|
||||
err := row.Scan(&t.ID, &t.Code, &t.Name, &t.Description, &t.EndpointURL, &t.HTTPMethod, &t.InputSchema, &t.TimeoutSeconds, &t.DepartmentIDs, &t.Enabled, &t.HasSecretHeaders, &t.Revision, &t.CreatedAt, &t.UpdatedAt, &t.EncryptedHeaders, &t.HeadersKEKVersion)
|
||||
err := row.Scan(&t.ID, &t.Code, &t.Name, &t.Description, &t.EndpointURL, &t.HTTPMethod, &t.InputSchema, &t.TimeoutSeconds, &t.DepartmentIDs, &t.RateLimitRPM, &t.ApprovalRequired, &t.Enabled, &t.HasSecretHeaders, &t.Revision, &t.CreatedAt, &t.UpdatedAt, &t.EncryptedHeaders, &t.HeadersKEKVersion)
|
||||
return t, mapNotFound(err)
|
||||
}
|
||||
func (s *ToolService) List(ctx context.Context) ([]Tool, error) {
|
||||
@@ -136,16 +139,16 @@ func (s *ToolService) Save(ctx context.Context, id string, input ToolInput, acto
|
||||
if err != nil {
|
||||
return Tool{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO gateway.tool_definitions(id,code,name,description,endpoint_url,http_method,encrypted_headers,headers_kek_version,input_schema,timeout_seconds,department_ids,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, encrypted, version, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.Enabled, actorID)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO gateway.tool_definitions(id,code,name,description,endpoint_url,http_method,encrypted_headers,headers_kek_version,input_schema,timeout_seconds,department_ids,rate_limit_rpm,approval_required,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, encrypted, version, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.RateLimitRPM, input.ApprovalRequired, input.Enabled, actorID)
|
||||
} else {
|
||||
if input.Headers == nil {
|
||||
tag, updateErr := tx.Exec(ctx, `UPDATE gateway.tool_definitions SET code=$2,name=$3,description=$4,endpoint_url=$5,http_method=$6,input_schema=$7,timeout_seconds=$8,department_ids=$9,enabled=$10,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.Enabled)
|
||||
tag, updateErr := tx.Exec(ctx, `UPDATE gateway.tool_definitions SET code=$2,name=$3,description=$4,endpoint_url=$5,http_method=$6,input_schema=$7,timeout_seconds=$8,department_ids=$9,rate_limit_rpm=$10,approval_required=$11,enabled=$12,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.RateLimitRPM, input.ApprovalRequired, input.Enabled)
|
||||
err = updateErr
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
return Tool{}, ErrNotFound
|
||||
}
|
||||
} else {
|
||||
tag, updateErr := tx.Exec(ctx, `UPDATE gateway.tool_definitions SET code=$2,name=$3,description=$4,endpoint_url=$5,http_method=$6,encrypted_headers=$7,headers_kek_version=$8,input_schema=$9,timeout_seconds=$10,department_ids=$11,enabled=$12,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, encrypted, version, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.Enabled)
|
||||
tag, updateErr := tx.Exec(ctx, `UPDATE gateway.tool_definitions SET code=$2,name=$3,description=$4,endpoint_url=$5,http_method=$6,encrypted_headers=$7,headers_kek_version=$8,input_schema=$9,timeout_seconds=$10,department_ids=$11,rate_limit_rpm=$12,approval_required=$13,enabled=$14,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, encrypted, version, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.RateLimitRPM, input.ApprovalRequired, input.Enabled)
|
||||
err = updateErr
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
return Tool{}, ErrNotFound
|
||||
@@ -206,6 +209,125 @@ func (s *ToolService) headers(tool Tool) (map[string]string, error) {
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// ErrToolApprovalRequired 表示工具需管理员审批后才能调用。
|
||||
var ErrToolApprovalRequired = errors.New("工具需要管理员审批后才能调用")
|
||||
|
||||
// ErrToolRateLimited 表示工具调用频率超限。
|
||||
var ErrToolRateLimited = errors.New("工具调用频率超限,请稍后重试")
|
||||
|
||||
// enforceGovernance 在工具执行前做治理校验:审批标记 + 调用频率上限。
|
||||
// 审批缺失时自动发起一次申请(每工具至多一个待审项);限流用固定窗口原子
|
||||
// upsert,多实例共享同一额度。返回 (allowed, err)。
|
||||
func (s *ToolService) enforceGovernance(ctx context.Context, tool Tool) (bool, error) {
|
||||
if s == nil || s.assets == nil || s.assets.pool == nil {
|
||||
return false, errors.New("工具服务不可用")
|
||||
}
|
||||
if tool.ApprovalRequired {
|
||||
var approved bool
|
||||
if err := s.assets.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.tool_approval_requests WHERE tool_id=$1 AND status='approved')`, tool.ID).Scan(&approved); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !approved {
|
||||
// 自动发起待审申请(唯一部分索引防重复),通知管理员。
|
||||
requestID, err := newUUID()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
eventID, err := newUUID()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
tx, err := s.assets.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
tag, err := tx.Exec(ctx, `INSERT INTO gateway.tool_approval_requests(id,tool_id,reason) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, requestID, tool.ID, "工具首次调用,自动发起审批")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
payload, _ := json.Marshal(map[string]any{"tool_id": tool.ID, "tool_code": tool.Code, "request_id": requestID})
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'tool_approval.requested',1,'tool',$2,$3)`, eventID, tool.ID, payload); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, fmt.Errorf("%w: %s(已自动发起审批)", ErrToolApprovalRequired, tool.Name)
|
||||
}
|
||||
}
|
||||
if tool.RateLimitRPM > 0 {
|
||||
var count int64
|
||||
err := s.assets.pool.QueryRow(ctx, `INSERT INTO gateway.tool_rate_usage(tool_id,window_start,call_count)
|
||||
VALUES($1,date_trunc('minute',clock_timestamp()),1)
|
||||
ON CONFLICT (tool_id,window_start) DO UPDATE SET call_count=gateway.tool_rate_usage.call_count+1
|
||||
RETURNING call_count`, tool.ID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > int64(tool.RateLimitRPM) {
|
||||
return false, ErrToolRateLimited
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ListApprovalRequests 返回工具审批申请(含工具信息)。
|
||||
func (s *ToolService) ListApprovalRequests(ctx context.Context, status string) ([]map[string]any, error) {
|
||||
if s == nil || s.assets == nil || s.assets.pool == nil {
|
||||
return nil, errors.New("工具服务不可用")
|
||||
}
|
||||
where, args := " WHERE true", []any{}
|
||||
if status != "" {
|
||||
args = append(args, status)
|
||||
where += fmt.Sprintf(" AND r.status=$%d", len(args))
|
||||
}
|
||||
rows, err := s.assets.pool.Query(ctx, `SELECT r.id::text,t.code,t.name,r.status,r.reason,r.decision_note,r.created_at,r.decided_at,r.decided_by::text FROM gateway.tool_approval_requests r JOIN gateway.tool_definitions t ON t.id=r.tool_id`+where+` ORDER BY r.created_at DESC LIMIT 200`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, code, name, status, reason, note string
|
||||
var decidedAt, decidedBy any
|
||||
var createdAt any
|
||||
if err := rows.Scan(&id, &code, &name, &status, &reason, ¬e, &createdAt, &decidedAt, &decidedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, map[string]any{"id": id, "tool_code": code, "tool_name": name, "status": status, "reason": reason, "decision_note": note, "created_at": createdAt, "decided_at": decidedAt, "decided_by": decidedBy})
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// DecideApprovalRequest 审批工具申请;通过后工具立即可调用。
|
||||
func (s *ToolService) DecideApprovalRequest(ctx context.Context, id, status, note, actorID string) error {
|
||||
if status != "approved" && status != "rejected" {
|
||||
return errors.New("审批状态无效")
|
||||
}
|
||||
if len(note) > 4000 {
|
||||
return errors.New("审批备注过长")
|
||||
}
|
||||
tx, err := s.assets.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var toolID string
|
||||
err = tx.QueryRow(ctx, `UPDATE gateway.tool_approval_requests SET status=$2,decision_note=$3,decided_by=$4,decided_at=clock_timestamp() WHERE id=$1 AND status='pending' RETURNING tool_id::text`, id, status, note, actorID).Scan(&toolID)
|
||||
if err != nil {
|
||||
return mapNotFound(err)
|
||||
}
|
||||
eventID, _ := newUUID()
|
||||
payload, _ := json.Marshal(map[string]any{"request_id": id, "tool_id": toolID, "status": status, "actor_id": actorID})
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'tool_approval.decided',1,'tool',$2,$3)`, eventID, toolID, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *ToolService) Execute(ctx context.Context, tool Tool, input map[string]any, apiKeyID, requestID string) (result map[string]any, err error) {
|
||||
started := time.Now()
|
||||
status := "success"
|
||||
@@ -229,6 +351,10 @@ func (s *ToolService) Execute(ctx context.Context, tool Tool, input map[string]a
|
||||
if err = validateToolInput(tool.InputSchema, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 治理校验:审批标记 + 频率上限。被拒时不算一次成功调用(但会记 tool_runs 失败)。
|
||||
if allowed, governanceErr := s.enforceGovernance(ctx, tool); !allowed {
|
||||
return nil, governanceErr
|
||||
}
|
||||
headers, err := s.headers(tool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+18
-14
@@ -74,20 +74,20 @@ type PromptInput struct {
|
||||
}
|
||||
|
||||
type KnowledgeBase struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RetrievalMode string `json:"retrieval_mode"`
|
||||
ChunkSize int `json:"chunk_size"`
|
||||
ChunkOverlap int `json:"chunk_overlap"`
|
||||
DepartmentIDs []string `json:"department_ids"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Revision int64 `json:"revision"`
|
||||
DocumentCount int `json:"document_count"`
|
||||
ChunkCount int `json:"chunk_count"`
|
||||
VectorizedChunkCount int `json:"vectorized_chunk_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RetrievalMode string `json:"retrieval_mode"`
|
||||
ChunkSize int `json:"chunk_size"`
|
||||
ChunkOverlap int `json:"chunk_overlap"`
|
||||
DepartmentIDs []string `json:"department_ids"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Revision int64 `json:"revision"`
|
||||
DocumentCount int `json:"document_count"`
|
||||
ChunkCount int `json:"chunk_count"`
|
||||
VectorizedChunkCount int `json:"vectorized_chunk_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type KnowledgeDocument struct {
|
||||
@@ -124,6 +124,8 @@ type Tool struct {
|
||||
InputSchema json.RawMessage `json:"input_schema"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
DepartmentIDs []string `json:"department_ids"`
|
||||
RateLimitRPM int `json:"rate_limit_rpm"`
|
||||
ApprovalRequired bool `json:"approval_required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasSecretHeaders bool `json:"has_secret_headers"`
|
||||
Revision int64 `json:"revision"`
|
||||
@@ -139,6 +141,8 @@ type ToolInput struct {
|
||||
InputSchema json.RawMessage
|
||||
TimeoutSeconds int
|
||||
DepartmentIDs []string
|
||||
RateLimitRPM int
|
||||
ApprovalRequired bool
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user