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:
LLMGuardX Dev
2026-08-13 13:41:22 +08:00
parent e31cc54b8e
commit 87c2b04174
40 changed files with 2416 additions and 111 deletions
+1 -1
View File
@@ -171,7 +171,7 @@ func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Con
if err != nil {
return Channel{}, err
}
var encryptedKey []byte
var encryptedKey []byte = []byte{}
var keyVersion int
if apiKey != "" {
encryptedKey, keyVersion, err = s.cipher.Encrypt([]byte(apiKey))
+55 -25
View File
@@ -24,24 +24,28 @@ var (
)
type Department struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *string `json:"parent_id"`
ParentName string `json:"parent_name,omitempty"`
Active bool `json:"active"`
UserCount int `json:"user_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *string `json:"parent_id"`
ParentName string `json:"parent_name,omitempty"`
Active bool `json:"active"`
MaxAPIKeys int `json:"max_api_keys"`
MaxMonthlyTokens int64 `json:"max_monthly_tokens"`
UserCount int `json:"user_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type departmentInput struct {
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *string `json:"parent_id"`
Active *bool `json:"active"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *string `json:"parent_id"`
Active *bool `json:"active"`
MaxAPIKeys *int `json:"max_api_keys"`
MaxMonthlyTokens *int64 `json:"max_monthly_tokens"`
}
func (h *ManagementHTTPHandler) listDepartments(writer http.ResponseWriter, request *http.Request) {
@@ -94,6 +98,14 @@ func (h *ManagementHTTPHandler) updateDepartment(writer http.ResponseWriter, req
if input.Active == nil {
department.Active = current.Active
}
// 租户配额同样按部分更新语义处理:未提供时保留当前值,避免"只改名"
// 的 PUT 把配额清零。
if input.MaxAPIKeys == nil {
department.MaxAPIKeys = current.MaxAPIKeys
}
if input.MaxMonthlyTokens == nil {
department.MaxMonthlyTokens = current.MaxMonthlyTokens
}
updated, err := h.service.repository.UpdateDepartment(request.Context(), department, actor.ID)
if err != nil {
h.writeDepartmentError(writer, err)
@@ -126,7 +138,23 @@ func decodeDepartment(writer http.ResponseWriter, request *http.Request) (depart
if input.Active != nil {
active = *input.Active
}
return input, Department{Code: input.Code, Name: input.Name, Description: input.Description, ParentID: parentID, Active: active}, true
maxAPIKeys := 0
if input.MaxAPIKeys != nil {
if *input.MaxAPIKeys < 0 || *input.MaxAPIKeys > 1000000 {
apiresponse.Error(writer, http.StatusBadRequest, "Key 配额无效")
return input, Department{}, false
}
maxAPIKeys = *input.MaxAPIKeys
}
var maxMonthlyTokens int64
if input.MaxMonthlyTokens != nil {
if *input.MaxMonthlyTokens < 0 || *input.MaxMonthlyTokens > 1e15 {
apiresponse.Error(writer, http.StatusBadRequest, "月 Token 配额无效")
return input, Department{}, false
}
maxMonthlyTokens = *input.MaxMonthlyTokens
}
return input, Department{Code: input.Code, Name: input.Name, Description: input.Description, ParentID: parentID, Active: active, MaxAPIKeys: maxAPIKeys, MaxMonthlyTokens: maxMonthlyTokens}, true
}
func (h *ManagementHTTPHandler) writeDepartmentError(writer http.ResponseWriter, err error) {
@@ -152,7 +180,7 @@ func (r *Repository) ListDepartments(ctx context.Context) ([]Department, error)
}
rows, err := r.pool.Query(ctx, `
SELECT d.id::text, d.code, d.name, d.description, d.parent_id::text,
COALESCE(p.name, ''), d.active,
COALESCE(p.name, ''), d.active, d.max_api_keys, d.max_monthly_tokens,
count(u.id) FILTER (WHERE u.active), d.created_at, d.updated_at
FROM gateway.departments d
LEFT JOIN gateway.departments p ON p.id = d.parent_id
@@ -167,7 +195,8 @@ func (r *Repository) ListDepartments(ctx context.Context) ([]Department, error)
for rows.Next() {
var department Department
if err := rows.Scan(&department.ID, &department.Code, &department.Name, &department.Description,
&department.ParentID, &department.ParentName, &department.Active, &department.UserCount,
&department.ParentID, &department.ParentName, &department.Active, &department.MaxAPIKeys,
&department.MaxMonthlyTokens, &department.UserCount,
&department.CreatedAt, &department.UpdatedAt); err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
@@ -182,10 +211,11 @@ func (r *Repository) GetDepartment(ctx context.Context, id string) (Department,
}
var department Department
err := r.pool.QueryRow(ctx, `
SELECT id::text, code, name, description, parent_id::text, active, created_at, updated_at
SELECT id::text, code, name, description, parent_id::text, active, max_api_keys, max_monthly_tokens, created_at, updated_at
FROM gateway.departments WHERE id = $1`, id).Scan(
&department.ID, &department.Code, &department.Name, &department.Description,
&department.ParentID, &department.Active, &department.CreatedAt, &department.UpdatedAt,
&department.ParentID, &department.Active, &department.MaxAPIKeys, &department.MaxMonthlyTokens,
&department.CreatedAt, &department.UpdatedAt,
)
return department, mapRepositoryError(err)
}
@@ -250,18 +280,18 @@ func (r *Repository) storeDepartment(ctx context.Context, department Department,
}
if creating {
err = tx.QueryRow(ctx, `
INSERT INTO gateway.departments (id, code, name, description, parent_id, active)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO gateway.departments (id, code, name, description, parent_id, active, max_api_keys, max_monthly_tokens)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING created_at, updated_at`, department.ID, department.Code, department.Name,
department.Description, department.ParentID, department.Active).Scan(&department.CreatedAt, &department.UpdatedAt)
department.Description, department.ParentID, department.Active, department.MaxAPIKeys, department.MaxMonthlyTokens).Scan(&department.CreatedAt, &department.UpdatedAt)
} else {
err = tx.QueryRow(ctx, `
UPDATE gateway.departments
SET code = $2, name = $3, description = $4, parent_id = $5,
active = $6, updated_at = clock_timestamp()
active = $6, max_api_keys = $7, max_monthly_tokens = $8, updated_at = clock_timestamp()
WHERE id = $1
RETURNING created_at, updated_at`, department.ID, department.Code, department.Name,
department.Description, department.ParentID, department.Active).Scan(&department.CreatedAt, &department.UpdatedAt)
department.Description, department.ParentID, department.Active, department.MaxAPIKeys, department.MaxMonthlyTokens).Scan(&department.CreatedAt, &department.UpdatedAt)
}
if err != nil {
return Department{}, mapDepartmentError(err)
+5
View File
@@ -563,6 +563,8 @@ func adminMenus(account Account) []map[string]any {
if HasPermission(account, PermissionSystemManage) {
systemChildren = append(systemChildren, map[string]any{"name": "Assistant", "path": "assistant", "component": "/system/assistant", "meta": map[string]any{"title": "AI 助手"}})
systemChildren = append(systemChildren, map[string]any{"name": "License", "path": "license", "component": "/system/license", "meta": map[string]any{"title": "License 授权"}})
systemChildren = append(systemChildren, map[string]any{"name": "Approvals", "path": "approvals", "component": "/system/approvals", "meta": map[string]any{"title": "审批中心"}})
systemChildren = append(systemChildren, map[string]any{"name": "PlatformEnvVars", "path": "platform-env-vars", "component": "/system/platform-env-vars", "meta": map[string]any{"title": "平台环境变量"}})
}
if len(systemChildren) > 0 {
menus = append(menus, map[string]any{"name": "System", "path": "/system", "component": "/index/index", "meta": map[string]any{"title": "系统管理", "icon": "ri:user-3-line"}, "children": systemChildren})
@@ -577,8 +579,11 @@ func portalMenus() []map[string]any {
{"name": "PortalCatalog", "path": "catalog", "component": "/portal/catalog", "meta": map[string]any{"title": "资产目录"}},
{"name": "PortalMarketplace", "path": "marketplace", "component": "/portal/marketplace", "meta": map[string]any{"title": "资源市场"}},
{"name": "PortalPrompts", "path": "prompts", "component": "/portal/prompts", "meta": map[string]any{"title": "Prompt 广场"}},
{"name": "PortalDigitalEmployees", "path": "digital-employees", "component": "/portal/digital-employees", "meta": map[string]any{"title": "数字员工"}},
{"name": "PortalUsage", "path": "usage", "component": "/portal/usage", "meta": map[string]any{"title": "我的用量"}},
{"name": "PortalAccess", "path": "access", "component": "/portal/access", "meta": map[string]any{"title": "模型权限"}},
{"name": "PortalRequests", "path": "requests", "component": "/portal/requests", "meta": map[string]any{"title": "我的申请"}},
{"name": "PortalPersonalChannels", "path": "personal-channels", "component": "/portal/personal-channels", "meta": map[string]any{"title": "个人渠道"}},
{"name": "PortalFiles", "path": "files", "component": "/portal/files", "meta": map[string]any{"title": "文件仓库"}},
{"name": "PortalInbox", "path": "inbox", "component": "/portal/inbox", "meta": map[string]any{"title": "站内消息"}},
{"name": "PortalScheduledTasks", "path": "scheduled-tasks", "component": "/portal/scheduled-tasks", "meta": map[string]any{"title": "定时任务"}},
+140 -11
View File
@@ -25,6 +25,9 @@ func NewAdminHTTPHandler(pool *pgxpool.Pool, identityService *identity.Service,
h.mux.HandleFunc("GET /api/v1/admin/system-info", h.systemInfo)
h.mux.HandleFunc("GET /api/v1/admin/monitoring/overview", h.overview)
h.mux.HandleFunc("GET /api/v1/admin/tenants/overview", h.tenantsOverview)
h.mux.HandleFunc("GET /api/v1/admin/reports/tools", h.reportTools)
h.mux.HandleFunc("GET /api/v1/admin/reports/approvals", h.reportApprovals)
h.mux.HandleFunc("GET /api/v1/admin/reports/security", h.reportSecurity)
h.mux.HandleFunc("POST /api/v1/admin/reload", h.reloadSnapshots)
return h
}
@@ -84,17 +87,17 @@ func (h *AdminHTTPHandler) reloadSnapshots(w http.ResponseWriter, r *http.Reques
apiresponse.OK(w, map[string]bool{"reloaded": true})
}
// tenantsOverview 以部门为租户维度,汇总各租户的账号/Key/用量。
// tenantsOverview 以部门为租户维度,汇总各租户的账号/Key/用量与配额。
func (h *AdminHTTPHandler) tenantsOverview(w http.ResponseWriter, r *http.Request) {
if _, ok := h.account(w, r); !ok {
return
}
rows, err := h.pool.Query(r.Context(), `SELECT d.id::text,d.name,
rows, err := h.pool.Query(r.Context(), `SELECT d.id::text,d.name,d.max_api_keys,d.max_monthly_tokens,
(SELECT count(*) FROM gateway.portal_users u WHERE u.department_id=d.id),
(SELECT count(*) FROM gateway.api_keys k WHERE k.tenant_id=d.id AND k.enabled),
(SELECT count(*) FROM gateway.audit_events a WHERE a.tenant_id=d.id AND a.recorded_at>=date_trunc('day',now())),
(SELECT COALESCE(sum(a.prompt_tokens+a.completion_tokens),0) FROM gateway.audit_events a WHERE a.tenant_id=d.id AND a.recorded_at>=date_trunc('day',now()))
(SELECT COALESCE(sum(a.prompt_tokens+a.completion_tokens),0) FROM gateway.audit_events a WHERE a.tenant_id=d.id AND a.recorded_at>=date_trunc('day',now())),
(SELECT COALESCE(sum(a.prompt_tokens+a.completion_tokens),0) FROM gateway.audit_events a WHERE a.tenant_id=d.id AND a.recorded_at>=date_trunc('month',now()))
FROM gateway.departments d WHERE d.active ORDER BY d.name`)
if err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "租户概览查询失败")
@@ -102,17 +105,20 @@ func (h *AdminHTTPHandler) tenantsOverview(w http.ResponseWriter, r *http.Reques
}
defer rows.Close()
type tenantRow struct {
ID string `json:"id"`
Name string `json:"name"`
PortalUsers int64 `json:"portal_users"`
EnabledKeys int64 `json:"enabled_api_keys"`
TodayRequests int64 `json:"today_requests"`
TodayTokens int64 `json:"today_tokens"`
ID string `json:"id"`
Name string `json:"name"`
MaxAPIKeys int64 `json:"max_api_keys"`
MaxMonthlyTokens int64 `json:"max_monthly_tokens"`
PortalUsers int64 `json:"portal_users"`
EnabledKeys int64 `json:"enabled_api_keys"`
TodayRequests int64 `json:"today_requests"`
TodayTokens int64 `json:"today_tokens"`
MonthTokens int64 `json:"month_tokens"`
}
items := []tenantRow{}
for rows.Next() {
var item tenantRow
if err := rows.Scan(&item.ID, &item.Name, &item.PortalUsers, &item.EnabledKeys, &item.TodayRequests, &item.TodayTokens); err != nil {
if err := rows.Scan(&item.ID, &item.Name, &item.MaxAPIKeys, &item.MaxMonthlyTokens, &item.PortalUsers, &item.EnabledKeys, &item.TodayRequests, &item.TodayTokens, &item.MonthTokens); err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "租户概览查询失败")
return
}
@@ -124,3 +130,126 @@ func (h *AdminHTTPHandler) tenantsOverview(w http.ResponseWriter, r *http.Reques
}
apiresponse.OK(w, map[string]any{"tenants": items})
}
// reportRange 解析 from/to 日期(YYYY-MM-DD),返回起止时间。
func (h *AdminHTTPHandler) reportRange(r *http.Request) (from, to time.Time) {
now := time.Now().UTC()
to = now
from = now.AddDate(0, 0, -6)
if value := r.URL.Query().Get("from"); value != "" {
if parsed, err := time.Parse("2006-01-02", value); err == nil {
from = parsed
}
}
if value := r.URL.Query().Get("to"); value != "" {
if parsed, err := time.Parse("2006-01-02", value); err == nil {
to = parsed.AddDate(0, 0, 1)
}
}
return from, to
}
// reportTools 工具维度统计:调用数/成功率/平均延迟。
func (h *AdminHTTPHandler) reportTools(w http.ResponseWriter, r *http.Request) {
if _, ok := h.account(w, r); !ok {
return
}
from, to := h.reportRange(r)
rows, err := h.pool.Query(r.Context(), `SELECT t.code,t.name,
count(r.id),
count(r.id) FILTER (WHERE r.status='success'),
COALESCE(avg(r.latency_ms),0)::bigint
FROM gateway.tool_runs r JOIN gateway.tool_definitions t ON t.id=r.tool_id
WHERE r.created_at>=$1 AND r.created_at<$2
GROUP BY t.code,t.name ORDER BY count(r.id) DESC LIMIT 100`, from, to)
if err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "工具报表查询失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var code, name string
var total, success, avgLatency int64
if err := rows.Scan(&code, &name, &total, &success, &avgLatency); err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "工具报表查询失败")
return
}
items = append(items, map[string]any{"code": code, "name": name, "requests": total, "success": success, "failed": total - success, "avg_latency_ms": avgLatency})
}
apiresponse.OK(w, items)
}
// reportApprovals 审批维度统计:模型/资源/工具申请的发起与审批结果。
func (h *AdminHTTPHandler) reportApprovals(w http.ResponseWriter, r *http.Request) {
if _, ok := h.account(w, r); !ok {
return
}
from, to := h.reportRange(r)
rows, err := h.pool.Query(r.Context(), `SELECT 'model' AS kind,status,count(*) FROM gateway.model_access_requests WHERE created_at>=$1 AND created_at<$2 GROUP BY status
UNION ALL SELECT 'resource',status,count(*) FROM gateway.resource_access_requests WHERE created_at>=$1 AND created_at<$2 GROUP BY status
UNION ALL SELECT 'tool',status,count(*) FROM gateway.tool_approval_requests WHERE created_at>=$1 AND created_at<$2 GROUP BY status`, from, to)
if err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "审批报表查询失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var kind, status string
var count int64
if err := rows.Scan(&kind, &status, &count); err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "审批报表查询失败")
return
}
items = append(items, map[string]any{"kind": kind, "status": status, "count": count})
}
apiresponse.OK(w, items)
}
// reportSecurity 安全维度统计:登录成功/失败、锁定与来源 IP 分布。
func (h *AdminHTTPHandler) reportSecurity(w http.ResponseWriter, r *http.Request) {
if _, ok := h.account(w, r); !ok {
return
}
from, to := h.reportRange(r)
rows, err := h.pool.Query(r.Context(), `SELECT success,count(*),count(DISTINCT ip) FROM gateway.login_logs WHERE created_at>=$1 AND created_at<$2 GROUP BY success
UNION ALL SELECT NULL,0,0 WHERE false`, from, to)
if err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "安全报表查询失败")
return
}
defer rows.Close()
type loginStat struct {
Success bool `json:"success"`
Count int64 `json:"count"`
DistinctIPs int64 `json:"distinct_ips"`
}
stats := []loginStat{}
for rows.Next() {
var item loginStat
if err := rows.Scan(&item.Success, &item.Count, &item.DistinctIPs); err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "安全报表查询失败")
return
}
stats = append(stats, item)
}
rows.Close()
ips, err := h.pool.Query(r.Context(), `SELECT ip::text,count(*) FROM gateway.login_logs WHERE created_at>=$1 AND created_at<$2 GROUP BY ip ORDER BY count(*) DESC LIMIT 10`, from, to)
if err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "安全报表查询失败")
return
}
defer ips.Close()
topIPs := []map[string]any{}
for ips.Next() {
var ip string
var count int64
if err := ips.Scan(&ip, &count); err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "安全报表查询失败")
return
}
topIPs = append(topIPs, map[string]any{"ip": ip, "count": count})
}
apiresponse.OK(w, map[string]any{"login_stats": stats, "top_ips": topIPs})
}
+49
View File
@@ -22,6 +22,9 @@ func NewAdminHTTPHandler(service *Service, identityService *identity.Service) *A
h.mux.HandleFunc("GET /api/v1/admin/model-requests", h.requests)
h.mux.HandleFunc("POST /api/v1/admin/model-requests/{id}/approve", h.decide("approved"))
h.mux.HandleFunc("POST /api/v1/admin/model-requests/{id}/reject", h.decide("rejected"))
h.mux.HandleFunc("GET /api/v1/admin/resource-requests", h.resourceRequests)
h.mux.HandleFunc("POST /api/v1/admin/resource-requests/{id}/approve", h.decideResource("approved"))
h.mux.HandleFunc("POST /api/v1/admin/resource-requests/{id}/reject", h.decideResource("rejected"))
return h
}
func (h *AdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) }
@@ -90,3 +93,49 @@ func (h *AdminHTTPHandler) decide(status string) http.HandlerFunc {
apiresponse.OK(w, item)
}
}
// resourceRequests 资源/渠道权限申请列表(审批中心)。
func (h *AdminHTTPHandler) resourceRequests(w http.ResponseWriter, r *http.Request) {
if _, ok := h.admin(w, r); !ok {
return
}
status := strings.TrimSpace(r.URL.Query().Get("status"))
if status != "" && status != "pending" && status != "approved" && status != "rejected" && status != "cancelled" {
apiresponse.Error(w, 400, "状态无效")
return
}
items, err := h.service.AdminResourceRequests(r.Context(), status)
if err != nil {
portalError(w, err)
return
}
apiresponse.OK(w, items)
}
// decideResource 审批资源/渠道申请;通过时自动开通。
func (h *AdminHTTPHandler) decideResource(status string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
a, ok := h.admin(w, r)
if !ok {
return
}
if !identity.HasPermission(a, identity.PermissionProviderManage) {
apiresponse.Error(w, 403, "缺少资源审批权限")
return
}
var input struct {
Note string `json:"note"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
if err := decoder.Decode(&input); err != nil && !errors.Is(err, http.ErrBodyReadAfterClose) {
apiresponse.Error(w, 400, "请求格式无效")
return
}
item, err := h.service.DecideResourceRequest(r.Context(), r.PathValue("id"), status, input.Note, a.ID)
if err != nil {
portalError(w, err)
return
}
apiresponse.OK(w, item)
}
}
+7 -7
View File
@@ -80,14 +80,14 @@ func (s *Service) ensureChatCredential(ctx context.Context, account identity.Acc
// ChatSession 是一条通用聊天会话。
type ChatSession struct {
ID string `json:"id"`
Title string `json:"title"`
ProviderCode string `json:"provider_code"`
Model string `json:"model"`
Status string `json:"status"`
ID string `json:"id"`
Title string `json:"title"`
ProviderCode string `json:"provider_code"`
Model string `json:"model"`
Status string `json:"status"`
Messages []ConversationMessage `json:"messages,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const chatSessionSelect = `SELECT id::text,title,provider_code,model,status,created_at,updated_at FROM gateway.portal_chat_sessions`
+29
View File
@@ -36,6 +36,9 @@ func (s *RuntimeCredentials) Ensure(ctx context.Context, applicationID string, d
if !errors.Is(err, pgx.ErrNoRows) {
return "", "", err
}
if err := s.checkTenantKeyQuota(ctx, departmentID); err != nil {
return "", "", err
}
record, secret, err := s.repository.Create(ctx, "application-runtime", []string{"application:run"}, 120, 0, 0, nil, "")
if err != nil {
return "", "", err
@@ -72,6 +75,28 @@ func (s *RuntimeCredentials) Ensure(ctx context.Context, applicationID string, d
return secret, record.ID, nil
}
// checkTenantKeyQuota 校验租户(部门)Key 配额:max_api_keys>0 且已达上限时拒绝
// 新开通运行时凭据。tenant 为空(未分配部门)不限制。
func (s *RuntimeCredentials) checkTenantKeyQuota(ctx context.Context, tenantID *string) error {
if s == nil || s.pool == nil || tenantID == nil || *tenantID == "" {
return nil
}
var maxAPIKeys, used int
if err := s.pool.QueryRow(ctx, `SELECT COALESCE(max_api_keys,0) FROM gateway.departments WHERE id=$1`, *tenantID).Scan(&maxAPIKeys); err != nil {
return err
}
if maxAPIKeys <= 0 {
return nil
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway.api_keys WHERE tenant_id=$1 AND enabled`, *tenantID).Scan(&used); err != nil {
return err
}
if used >= maxAPIKeys {
return fmt.Errorf("租户 Key 配额已达上限(%d),请联系平台管理员提升配额", maxAPIKeys)
}
return nil
}
// EnsureUser returns the portal user's personal runtime credential used by the
// general chat. It is created lazily on first use with limits derived from the
// user's approved model requests, and bound to the user's department tenant so
@@ -92,6 +117,10 @@ func (s *RuntimeCredentials) EnsureUser(ctx context.Context, userID string, depa
if !errors.Is(err, pgx.ErrNoRows) {
return "", "", err
}
// 租户(部门)Key 配额:max_api_keys>0 时校验当前已绑定 Key 数。
if err := s.checkTenantKeyQuota(ctx, departmentID); err != nil {
return "", "", err
}
if rpm < 1 {
rpm = 120
}
+137
View File
@@ -0,0 +1,137 @@
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()
}
+196 -1
View File
@@ -33,6 +33,9 @@ func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHa
h.mux.HandleFunc("GET /api/v1/portal/model-requests/available", h.models)
h.mux.HandleFunc("GET /api/v1/portal/model-requests", h.modelRequests)
h.mux.HandleFunc("POST /api/v1/portal/model-requests", h.createModelRequest)
h.mux.HandleFunc("GET /api/v1/portal/resource-requests", h.resourceRequests)
h.mux.HandleFunc("POST /api/v1/portal/resource-requests", h.createResourceRequest)
h.mux.HandleFunc("DELETE /api/v1/portal/resource-requests/{id}", h.cancelResourceRequest)
h.mux.HandleFunc("GET /api/v1/portal/logs", h.logs)
h.mux.HandleFunc("GET /api/v1/portal/logs/{id}", h.logDetail)
h.mux.HandleFunc("GET /api/v1/portal/stats", h.stats)
@@ -54,6 +57,16 @@ func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHa
h.mux.HandleFunc("DELETE /api/v1/portal/chat/sessions/{id}", h.deleteChatSession)
h.mux.HandleFunc("GET /api/v1/portal/chat/sessions/{id}", h.getChatSession)
h.mux.HandleFunc("POST /api/v1/portal/chat/sessions/{id}/messages", h.appendChatMessage)
// 个人渠道:webhook 入站(公开,令牌鉴权) + 个人管理。
h.mux.HandleFunc("POST /v1/personal-channels/{code}/inbound", h.personalChannelInbound)
h.mux.HandleFunc("GET /api/v1/portal/personal-channels", h.personalChannels)
h.mux.HandleFunc("POST /api/v1/portal/personal-channels", h.createPersonalChannel)
h.mux.HandleFunc("POST /api/v1/portal/personal-channels/{id}/token", h.regeneratePersonalToken)
h.mux.HandleFunc("DELETE /api/v1/portal/personal-channels/{id}", h.deletePersonalChannel)
// 数字员工:会话入口 + 调用记录。
h.mux.HandleFunc("GET /api/v1/portal/digital-employees", h.digitalEmployees)
h.mux.HandleFunc("POST /api/v1/portal/digital-employees/{code}/chat", h.runDigitalEmployee)
h.mux.HandleFunc("GET /api/v1/portal/digital-employees/runs", h.myEmployeeRuns)
h.mux.HandleFunc("GET /api/v1/portal/marketplace", h.marketplace)
h.mux.HandleFunc("GET /api/v1/portal/marketplace/categories", h.marketplaceCategories)
h.mux.HandleFunc("GET /api/v1/portal/marketplace/installed", h.marketplaceInstalled)
@@ -359,6 +372,54 @@ func (h *HTTPHandler) createModelRequest(w http.ResponseWriter, r *http.Request)
apiresponse.OK(w, item)
}
// --- 资源/渠道权限申请 ---
func (h *HTTPHandler) resourceRequests(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
items, err := h.service.ResourceRequests(r.Context(), a)
if err != nil {
portalError(w, err)
return
}
apiresponse.OK(w, items)
}
func (h *HTTPHandler) createResourceRequest(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
var input struct {
ResourceType string `json:"resource_type"`
ResourceCode string `json:"resource_code"`
Reason string `json:"reason"`
}
if !decode(w, r, &input) {
return
}
item, err := h.service.CreateResourceRequest(r.Context(), a, input.ResourceType, input.ResourceCode, input.Reason)
if err != nil {
portalError(w, err)
return
}
apiresponse.OK(w, item)
}
func (h *HTTPHandler) cancelResourceRequest(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
if err := h.service.CancelResourceRequest(r.Context(), a, r.PathValue("id")); err != nil {
portalError(w, err)
return
}
apiresponse.OK(w, map[string]bool{"cancelled": true})
}
func limitParam(r *http.Request) int {
value, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if value < 1 {
@@ -600,7 +661,6 @@ func (h *HTTPHandler) appendConversationMessage(w http.ResponseWriter, r *http.R
}
// --- 通用聊天 ---
type chatCompletionsInput struct {
ProviderCode string `json:"provider_code"`
Model string `json:"model"`
@@ -730,3 +790,138 @@ func (h *HTTPHandler) appendChatMessage(w http.ResponseWriter, r *http.Request)
}
writeApplicationResponse(w, response)
}
// --- 个人渠道 ---
func (h *HTTPHandler) personalChannels(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
items, err := h.service.PersonalChannels(r.Context(), a)
if err != nil {
portalError(w, err)
return
}
apiresponse.OK(w, items)
}
func (h *HTTPHandler) createPersonalChannel(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
var input struct {
Code string `json:"code"`
Name string `json:"name"`
ProviderCode string `json:"provider_code"`
Model string `json:"model"`
}
if !decode(w, r, &input) {
return
}
item, token, err := h.service.CreatePersonalChannel(r.Context(), a, input.Code, input.Name, input.ProviderCode, input.Model)
if err != nil {
portalError(w, err)
return
}
apiresponse.OK(w, map[string]any{"channel": item, "inbound_token": token, "inbound_url": "/v1/personal-channels/" + item.Code + "/inbound"})
}
func (h *HTTPHandler) regeneratePersonalToken(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
token, err := h.service.RegenerateToken(r.Context(), a, r.PathValue("id"))
if err != nil {
portalError(w, err)
return
}
apiresponse.OK(w, map[string]string{"inbound_token": token})
}
func (h *HTTPHandler) deletePersonalChannel(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
if err := h.service.DeletePersonalChannel(r.Context(), a, r.PathValue("id")); err != nil {
portalError(w, err)
return
}
apiresponse.OK(w, map[string]bool{"deleted": true})
}
// personalChannelInbound 个人渠道入站(公开端点,令牌鉴权,同步返回文本)。
func (h *HTTPHandler) personalChannelInbound(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(r.Header.Get("X-Inbound-Token"))
if token == "" {
token = strings.TrimSpace(r.URL.Query().Get("token"))
}
var input struct {
Message string `json:"message"`
Content string `json:"content"`
}
if !decode(w, r, &input) {
return
}
message := strings.TrimSpace(input.Message)
if message == "" {
message = strings.TrimSpace(input.Content)
}
reply, err := h.service.HandlePersonalInbound(r.Context(), r.PathValue("code"), token, message)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, map[string]string{"reply": reply})
}
// --- 数字员工 ---
func (h *HTTPHandler) digitalEmployees(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
items, err := h.service.DigitalEmployees(r.Context(), a)
if err != nil {
portalError(w, err)
return
}
apiresponse.OK(w, items)
}
func (h *HTTPHandler) runDigitalEmployee(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
var input struct {
Message string `json:"message"`
}
if !decode(w, r, &input) {
return
}
response, err := h.service.RunDigitalEmployee(r.Context(), a, r.PathValue("code"), input.Message)
if err != nil {
portalError(w, err)
return
}
writeApplicationResponse(w, response)
}
func (h *HTTPHandler) myEmployeeRuns(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.service.MyEmployeeRuns(r.Context(), a, limit)
if err != nil {
portalError(w, err)
return
}
apiresponse.OK(w, items)
}
+206
View File
@@ -0,0 +1,206 @@
package portal
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"time"
"aigateway.local/core/internal/gateway"
"aigateway.local/core/internal/identity"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
)
func regexpMust(pattern string) *regexp.Regexp { return regexp.MustCompile(pattern) }
// PersonalChannel 是门户用户自建的 webhook 渠道,绑定已批准模型。
// 入站消息经用户运行时凭据应答,用量归属用户自己的 Key。
type PersonalChannel struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Kind string `json:"kind"`
ProviderCode string `json:"provider_code"`
Model string `json:"model"`
Enabled bool `json:"enabled"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const personalChannelSelect = `SELECT id::text,code,name,kind,provider_code,model,enabled,last_used_at,created_at,updated_at FROM gateway.personal_channels`
func scanPersonalChannel(row pgx.Row) (PersonalChannel, error) {
var c PersonalChannel
err := row.Scan(&c.ID, &c.Code, &c.Name, &c.Kind, &c.ProviderCode, &c.Model, &c.Enabled, &c.LastUsedAt, &c.CreatedAt, &c.UpdatedAt)
return c, err
}
// PersonalChannels 返回当前用户的个人渠道。
func (s *Service) PersonalChannels(ctx context.Context, account identity.Account) ([]PersonalChannel, error) {
rows, err := s.pool.Query(ctx, personalChannelSelect+` WHERE portal_user_id=$1 ORDER BY created_at DESC`, account.ID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PersonalChannel{}
for rows.Next() {
item, err := scanPersonalChannel(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// CreatePersonalChannel 创建 webhook 个人渠道并返回入站令牌(仅此一次显示)。
func (s *Service) CreatePersonalChannel(ctx context.Context, account identity.Account, code, name, providerCode, model string) (PersonalChannel, string, error) {
code = strings.ToLower(strings.TrimSpace(code))
name = strings.TrimSpace(name)
if !personalChannelCodePattern.MatchString(code) || name == "" || len(name) > 128 {
return PersonalChannel{}, "", errors.New("渠道代码或名称无效")
}
if _, err := s.approvedModel(ctx, account, providerCode, model); err != nil {
return PersonalChannel{}, "", errors.New("模型未批准或不可用,请先在「模型权限」申请")
}
// 聊天依赖用户运行时凭据,先确保开通。
if _, err := s.ensureChatCredential(ctx, account); err != nil {
return PersonalChannel{}, "", err
}
token, err := randomToken(32)
if err != nil {
return PersonalChannel{}, "", err
}
id, err := platformid.NewUUID()
if err != nil {
return PersonalChannel{}, "", err
}
hash := channelTokenHash(token)
_, err = s.pool.Exec(ctx, `INSERT INTO gateway.personal_channels(id,portal_user_id,code,name,kind,inbound_token_hash,provider_code,model) VALUES($1,$2,$3,$4,'webhook',$5,$6,$7)`, id, account.ID, code, name, hash, providerCode, model)
if err != nil {
if isUniqueViolation(err) {
return PersonalChannel{}, "", errors.New("渠道代码已存在")
}
return PersonalChannel{}, "", err
}
item, err := scanPersonalChannel(s.pool.QueryRow(ctx, personalChannelSelect+` WHERE id=$1`, id))
return item, token, err
}
// RegenerateToken 轮换入站令牌(旧令牌立即失效)。
func (s *Service) RegenerateToken(ctx context.Context, account identity.Account, id string) (string, error) {
token, err := randomToken(32)
if err != nil {
return "", err
}
tag, err := s.pool.Exec(ctx, `UPDATE gateway.personal_channels SET inbound_token_hash=$3,updated_at=clock_timestamp() WHERE id=$1 AND portal_user_id=$2`, id, account.ID, channelTokenHash(token))
if err != nil {
return "", err
}
if tag.RowsAffected() == 0 {
return "", ErrNotFound
}
return token, nil
}
// DeletePersonalChannel 删除个人渠道(仅本人)。
func (s *Service) DeletePersonalChannel(ctx context.Context, account identity.Account, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.personal_channels WHERE id=$1 AND portal_user_id=$2`, id, account.ID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// HandlePersonalInbound 处理个人渠道入站消息:令牌校验 → 用户运行时凭据应答。
func (s *Service) HandlePersonalInbound(ctx context.Context, code, presentedToken, text string) (string, error) {
code = strings.ToLower(strings.TrimSpace(code))
text = strings.TrimSpace(text)
if code == "" || presentedToken == "" {
return "", errors.New("渠道代码或令牌缺失")
}
if text == "" || len(text) > 100000 {
return "", errors.New("消息为空或过长")
}
var id, userID, providerCode, model, tokenHash string
err := s.pool.QueryRow(ctx, `SELECT id::text,portal_user_id::text,provider_code,model,inbound_token_hash FROM gateway.personal_channels WHERE code=$1 AND enabled`, code).Scan(&id, &userID, &providerCode, &model, &tokenHash)
if errors.Is(err, pgx.ErrNoRows) {
return "", errors.New("渠道不存在或未启用")
}
if err != nil {
return "", err
}
if subtle.ConstantTimeCompare([]byte(channelTokenHash(presentedToken)), []byte(tokenHash)) != 1 {
return "", errors.New("入站令牌无效")
}
if s.credentials == nil || s.gateway == nil {
return "", errors.New("渠道服务未配置")
}
secret, _, err := s.credentials.UserSecret(ctx, userID)
if err != nil {
return "", err
}
if secret == "" {
return "", errors.New("用户运行时凭据未开通")
}
payload, _ := json.Marshal(map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": text}}, "stream": false})
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(payload)).WithContext(gateway.WithRequestID(ctx, "personal-channel-"+time.Now().UTC().Format("20060102150405.000000000")))
request.Header.Set("Authorization", "Bearer "+secret)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
s.gateway.ServeHTTP(recorder, request)
var response map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
return "", errors.New("模型响应无法解析")
}
if recorder.Code < 200 || recorder.Code >= 300 {
message := "模型调用失败"
if value, ok := response["error"].(map[string]any); ok {
if text, ok := value["message"].(string); ok {
message = text
}
}
return "", errors.New(message)
}
choices, _ := response["choices"].([]any)
if len(choices) == 0 {
return "", errors.New("模型未返回回答")
}
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
answer, _ := message["content"].(string)
if strings.TrimSpace(answer) == "" {
return "", errors.New("模型未返回文本回答")
}
_, _ = s.pool.Exec(ctx, `UPDATE gateway.personal_channels SET last_used_at=clock_timestamp() WHERE id=$1`, id)
return answer, nil
}
func channelTokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func randomToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
var personalChannelCodePattern = regexpMust(`^[a-z][a-z0-9_-]{2,63}$`)
+197
View File
@@ -0,0 +1,197 @@
package portal
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"aigateway.local/core/internal/identity"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
func isUniqueViolation(err error) bool {
var pgError *pgconn.PgError
return errors.As(err, &pgError) && pgError.Code == "23505"
}
// ResourceRequest 是资源/渠道权限申请(mcp/skill/数字员工/渠道)。
// 审批通过后:市场资源自动安装(use 等级),渠道申请以批准记录本身作为授权凭据。
type ResourceRequest struct {
ID string `json:"id"`
PortalUserID string `json:"portal_user_id"`
UserLogin string `json:"user_login,omitempty"`
ResourceType string `json:"resource_type"`
ResourceCode string `json:"resource_code"`
Reason string `json:"reason"`
Status string `json:"status"`
DecisionNote string `json:"decision_note"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const resourceRequestSelect = `SELECT r.id::text,r.portal_user_id::text,u.account,r.resource_type,r.resource_code,r.reason,r.status,r.decision_note,r.decided_at,r.created_at,r.updated_at FROM gateway.resource_access_requests r JOIN gateway.portal_users u ON u.id=r.portal_user_id`
func scanResourceRequest(row pgx.Row) (ResourceRequest, error) {
var item ResourceRequest
err := row.Scan(&item.ID, &item.PortalUserID, &item.UserLogin, &item.ResourceType, &item.ResourceCode, &item.Reason, &item.Status, &item.DecisionNote, &item.DecidedAt, &item.CreatedAt, &item.UpdatedAt)
return item, err
}
// resourceTypeSupported 校验申请的资源类型。
func resourceTypeSupported(resourceType string) bool {
switch strings.ToLower(strings.TrimSpace(resourceType)) {
case "mcp_server", "skill", "digital_employee", "channel":
return true
}
return false
}
// ResourceRequests 返回当前用户的资源/渠道申请。
func (s *Service) ResourceRequests(ctx context.Context, account identity.Account) ([]ResourceRequest, error) {
rows, err := s.pool.Query(ctx, resourceRequestSelect+` WHERE r.portal_user_id=$1 ORDER BY r.created_at DESC LIMIT 200`, account.ID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ResourceRequest{}
for rows.Next() {
item, err := scanResourceRequest(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// CreateResourceRequest 发起资源/渠道权限申请(每用户每资源至多一个待审项)。
func (s *Service) CreateResourceRequest(ctx context.Context, account identity.Account, resourceType, code, reason string) (ResourceRequest, error) {
resourceType = strings.ToLower(strings.TrimSpace(resourceType))
code = strings.ToLower(strings.TrimSpace(code))
reason = strings.TrimSpace(reason)
if !resourceTypeSupported(resourceType) {
return ResourceRequest{}, errors.New("申请类型必须是 mcp_server/skill/digital_employee/channel")
}
if code == "" || len(code) > 128 || len(reason) > 4000 {
return ResourceRequest{}, errors.New("申请内容格式无效")
}
// 目标必须真实存在且启用:市场资源须已发布,渠道须已启用。
switch resourceType {
case "mcp_server", "skill", "digital_employee":
if s.market == nil {
return ResourceRequest{}, errors.New("资源市场服务未配置")
}
if _, _, err := s.market.Detail(ctx, resourceType, code); err != nil {
return ResourceRequest{}, errors.New("资源不存在或未发布")
}
case "channel":
var enabled bool
if err := s.pool.QueryRow(ctx, `SELECT enabled FROM gateway.channels WHERE code=$1`, code).Scan(&enabled); err != nil || !enabled {
return ResourceRequest{}, errors.New("渠道不存在或未启用")
}
}
item := ResourceRequest{PortalUserID: account.ID, ResourceType: resourceType, ResourceCode: code, Reason: reason}
item.ID, _ = platformid.NewUUID()
eventID, _ := platformid.NewUUID()
tx, err := s.pool.Begin(ctx)
if err != nil {
return ResourceRequest{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
_, err = tx.Exec(ctx, `INSERT INTO gateway.resource_access_requests(id,portal_user_id,resource_type,resource_code,reason) VALUES($1,$2,$3,$4,$5)`, item.ID, account.ID, resourceType, code, reason)
if err != nil {
if isUniqueViolation(err) {
return ResourceRequest{}, errors.New("同类申请已存在,等待管理员审批")
}
return ResourceRequest{}, err
}
payload, _ := json.Marshal(map[string]any{"request_id": item.ID, "portal_user_id": account.ID, "resource_type": resourceType, "resource_code": code})
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'resource_access.requested',1,'resource_access_request',$2,$3)`, eventID, item.ID, payload); err != nil {
return ResourceRequest{}, err
}
if err = tx.Commit(ctx); err != nil {
return ResourceRequest{}, err
}
return scanResourceRequest(s.pool.QueryRow(ctx, resourceRequestSelect+` WHERE r.id=$1`, item.ID))
}
// CancelResourceRequest 撤回本人待审申请。
func (s *Service) CancelResourceRequest(ctx context.Context, account identity.Account, id string) error {
tag, err := s.pool.Exec(ctx, `UPDATE gateway.resource_access_requests SET status='cancelled',updated_at=clock_timestamp() WHERE id=$1 AND portal_user_id=$2 AND status='pending'`, id, account.ID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AdminResourceRequests 返回全部资源/渠道申请(审批中心)。
func (s *Service) AdminResourceRequests(ctx context.Context, status string) ([]ResourceRequest, error) {
where, args := " WHERE true", []any{}
if status != "" {
args = append(args, status)
where += " AND r.status=$1"
}
rows, err := s.pool.Query(ctx, resourceRequestSelect+where+` ORDER BY r.created_at DESC LIMIT 500`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ResourceRequest{}
for rows.Next() {
item, err := scanResourceRequest(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// DecideResourceRequest 审批资源/渠道申请:通过时自动开通(marketplace 安装)。
func (s *Service) DecideResourceRequest(ctx context.Context, id, status, note, actorID string) (ResourceRequest, error) {
if status != "approved" && status != "rejected" {
return ResourceRequest{}, errors.New("审批状态无效")
}
if len(note) > 4000 {
return ResourceRequest{}, errors.New("审批备注过长")
}
eventID, _ := platformid.NewUUID()
tx, err := s.pool.Begin(ctx)
if err != nil {
return ResourceRequest{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, `UPDATE gateway.resource_access_requests SET status=$2,decision_note=$3,decided_by=$4,decided_at=clock_timestamp(),updated_at=clock_timestamp() WHERE id=$1 AND status='pending'`, id, status, strings.TrimSpace(note), actorID)
if err != nil {
return ResourceRequest{}, err
}
if tag.RowsAffected() == 0 {
return ResourceRequest{}, ErrNotFound
}
var userID, resourceType, resourceCode string
if err = tx.QueryRow(ctx, `SELECT portal_user_id::text,resource_type,resource_code FROM gateway.resource_access_requests WHERE id=$1`, id).Scan(&userID, &resourceType, &resourceCode); err != nil {
return ResourceRequest{}, err
}
if status == "approved" && resourceType != "channel" && s.market != nil {
// 自动安装到申请用户工作区(use 等级)。
if _, err = s.market.Install(ctx, resourceType, resourceCode, userID, "use"); err != nil {
return ResourceRequest{}, err
}
}
payload, _ := json.Marshal(map[string]any{"request_id": id, "portal_user_id": userID, "resource_type": resourceType, "resource_code": resourceCode, "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,'resource_access.decided',1,'resource_access_request',$2,$3)`, eventID, id, payload); err != nil {
return ResourceRequest{}, err
}
if err = tx.Commit(ctx); err != nil {
return ResourceRequest{}, err
}
return scanResourceRequest(s.pool.QueryRow(ctx, resourceRequestSelect+` WHERE r.id=$1`, id))
}
+29
View File
@@ -0,0 +1,29 @@
package portal
import "testing"
func TestResourceTypeSupported(t *testing.T) {
for _, kind := range []string{"mcp_server", "skill", "digital_employee", "channel", " MCP_SERVER "} {
if !resourceTypeSupported(kind) {
t.Errorf("kind %q should be supported", kind)
}
}
for _, kind := range []string{"model", "tool", "", "wecom"} {
if resourceTypeSupported(kind) {
t.Errorf("kind %q should not be supported", kind)
}
}
}
func TestPersonalChannelCodePattern(t *testing.T) {
for _, code := range []string{"my_bot", "report-bot", "ab1", "chat2"} {
if !personalChannelCodePattern.MatchString(code) {
t.Errorf("code %q should match", code)
}
}
for _, code := range []string{"Bot", "1bot", "b", "has space", "x!y"} {
if personalChannelCodePattern.MatchString(code) {
t.Errorf("code %q should not match", code)
}
}
}
+12 -12
View File
@@ -50,20 +50,20 @@ func ValidateBaseURL(ctx context.Context, raw string, allowPrivate bool) (string
// 前缀可以把 IPv6 地址桥接回内网 IPv4,因此必须一并拦截。
var specialPurposePrefixes = []netip.Prefix{
// IPv4 特殊用途网段(RFC 6890 及其更新)。
netip.MustParsePrefix("100.64.0.0/10"), // CGNAT 共享地址空间 RFC 6598
netip.MustParsePrefix("192.0.0.0/24"), // IETF 协议保留
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1 文档
netip.MustParsePrefix("192.88.99.0/24"), // 6to4 中继任播(已弃用)
netip.MustParsePrefix("198.18.0.0/15"), // 基准测试 RFC 2544
netip.MustParsePrefix("100.64.0.0/10"), // CGNAT 共享地址空间 RFC 6598
netip.MustParsePrefix("192.0.0.0/24"), // IETF 协议保留
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1 文档
netip.MustParsePrefix("192.88.99.0/24"), // 6to4 中继任播(已弃用)
netip.MustParsePrefix("198.18.0.0/15"), // 基准测试 RFC 2544
netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2 文档
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3 文档
netip.MustParsePrefix("240.0.0.0/4"), // 保留(含广播地址)
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3 文档
netip.MustParsePrefix("240.0.0.0/4"), // 保留(含广播地址)
// IPv6 特殊用途网段。
netip.MustParsePrefix("2001:db8::/32"), // 文档地址
netip.MustParsePrefix("2001:10::/28"), // ORCHID
netip.MustParsePrefix("2002::/16"), // 6to4:内嵌 IPv4,可桥接回内网
netip.MustParsePrefix("64:ff9b::/96"), // NAT64 知名前缀
netip.MustParsePrefix("64:ff9b:1::/48"), // NAT64 本地使用前缀
netip.MustParsePrefix("2001:db8::/32"), // 文档地址
netip.MustParsePrefix("2001:10::/28"), // ORCHID
netip.MustParsePrefix("2002::/16"), // 6to4:内嵌 IPv4,可桥接回内网
netip.MustParsePrefix("64:ff9b::/96"), // NAT64 知名前缀
netip.MustParsePrefix("64:ff9b:1::/48"), // NAT64 本地使用前缀
}
// IsPublicAddress 报告 ip 是否为可安全访问的公网单播地址。IPv4-mapped
+38 -1
View File
@@ -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
View File
@@ -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})
}
+10
View File
@@ -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
}
+3
View File
@@ -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
View File
@@ -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, &note, &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
View File
@@ -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
}