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:
+37
@@ -48,3 +48,40 @@ copying the bundle to a deployment host.
|
||||
provider_uid 全局唯一,防止平台账号同时绑定多个本系统账号。
|
||||
- 会话索引只存令牌 SHA-256 摘要,列表时惰性清理过期项;当前会话不可吊销。
|
||||
- 登录提醒事件只落站内信,不包含凭据;开关按账号独立生效。
|
||||
|
||||
## 0.11.3 — 旗舰版第四轮完善
|
||||
|
||||
发布时间:2026-08-13
|
||||
|
||||
新增功能:
|
||||
|
||||
- **统一审批中心**:管理端「系统管理 → 审批中心」统一处理三类申请——
|
||||
模型访问、资源/渠道权限(新增 `resource_access_requests`,支持
|
||||
mcp_server/skill/digital_employee/channel)、工具使用(`tool_approval_requests`)。
|
||||
资源/渠道申请通过后自动开通(marketplace 安装 use 等级;渠道以批准记录为授权),
|
||||
门户「我的申请」可发起/撤回,审批结果经 outbox → 站内信通知双方。
|
||||
- **工具治理**:工具支持「限流(RPM)」与「需审批」标记;审批标记工具首次调用
|
||||
自动发起审批并拒绝执行,管理员通过后可用;限流用 PostgreSQL 固定窗口原子
|
||||
upsert 实现,多实例共享同一额度。管理端工具中心可配置两项治理参数。
|
||||
- **平台环境变量**:系统管理新增「平台环境变量」,平台级配置注入 skill/MCP
|
||||
运行时(加密存储),个人环境变量可覆盖平台默认值(优先级:请求 < 平台 < 个人)。
|
||||
- **数字员工会话入口**:门户「数字员工」页列出已授权员工、直接对话(复用用户
|
||||
运行时凭据)、查看调用记录(检索/工具/延迟/状态)。
|
||||
- **个人渠道**:门户「个人渠道」自建 Webhook 渠道绑定已批准模型;入站
|
||||
`POST /v1/personal-channels/{code}/inbound` 以 `X-Inbound-Token` 常量时间
|
||||
校验,经用户运行时凭据应答,用量归属用户 Key;支持令牌轮换与调用时间展示。
|
||||
- **报表多维统计**:企业报表新增「工具调用 / 审批授权 / 安全事件」三个维度
|
||||
(工具成功率与延迟、三类申请状态分布、登录成功/失败与来源 IP 分布)。
|
||||
- **租户(部门)配额**:部门支持 Key 数量上限与月 Token 上限(0=不限),门户
|
||||
运行时凭据开通时强制校验;租户概览展示配额用量与当月消耗。
|
||||
|
||||
迁移:000042_governance / 000043_platform_env_vars / 000044_personal_channels /
|
||||
000045_tenant_quotas(共 45 个迁移)。
|
||||
|
||||
安全要点:
|
||||
|
||||
- 工具审批:每工具至多一个待审项(部分唯一索引),审批通过前执行一律拒绝;
|
||||
限流窗口在提交侧原子递增,超限即拒,无竞态放大。
|
||||
- 个人渠道令牌只存 SHA-256 摘要、仅创建/轮换时显示一次,校验走
|
||||
constant-time 比较;渠道代码全局唯一。
|
||||
- 平台环境变量与个人变量同用 AES-256-GCM 加密,管理端仅系统管理员可写。
|
||||
|
||||
+17
-2
@@ -11,10 +11,10 @@ import (
|
||||
"time"
|
||||
|
||||
"aigateway.local/core/internal/agentnode"
|
||||
"aigateway.local/core/internal/channel"
|
||||
"aigateway.local/core/internal/assistant"
|
||||
"aigateway.local/core/internal/apikey"
|
||||
"aigateway.local/core/internal/assistant"
|
||||
"aigateway.local/core/internal/audit"
|
||||
"aigateway.local/core/internal/channel"
|
||||
"aigateway.local/core/internal/contentpolicy"
|
||||
"aigateway.local/core/internal/factcheck"
|
||||
"aigateway.local/core/internal/gateway"
|
||||
@@ -265,6 +265,7 @@ func main() {
|
||||
}
|
||||
envVarService := workbench.NewEnvVarService(db, envVarCipher)
|
||||
envVarHandler := workbench.NewEnvVarHTTPHandler(envVarService, identityService)
|
||||
adminEnvVarHandler := workbench.NewAdminEnvVarHTTPHandler(envVarService, identityService)
|
||||
toolService := workbench.NewToolService(workbenchService, toolCipher, cfg.Credentials.AllowPrivateToolURL)
|
||||
notificationService := workbench.NewNotificationService(workbenchService, notificationCipher, cfg.Credentials.AllowPrivateWebhookURL)
|
||||
workbenchHandler := workbench.NewAdminHTTPHandler(workbenchService, toolService, notificationService, identityService)
|
||||
@@ -400,6 +401,8 @@ func main() {
|
||||
controlMux.Handle("/api/v1/admin/knowledge-bases/", workbenchHandler)
|
||||
controlMux.Handle("/api/v1/admin/tools", workbenchHandler)
|
||||
controlMux.Handle("/api/v1/admin/tools/", workbenchHandler)
|
||||
controlMux.Handle("/api/v1/admin/tool-approvals", workbenchHandler)
|
||||
controlMux.Handle("/api/v1/admin/tool-approvals/", workbenchHandler)
|
||||
controlMux.Handle("/api/v1/admin/applications", workbenchHandler)
|
||||
controlMux.Handle("/api/v1/admin/applications/", workbenchHandler)
|
||||
controlMux.Handle("/api/v1/admin/marketplace-categories", marketplaceHandler)
|
||||
@@ -422,6 +425,7 @@ func main() {
|
||||
controlMux.Handle("/api/v1/admin/model-requests/", portalAdminHandler)
|
||||
controlMux.Handle("/api/v1/admin/system-info", operationsHandler)
|
||||
controlMux.Handle("/api/v1/admin/monitoring/overview", operationsHandler)
|
||||
controlMux.Handle("/api/v1/admin/reports/", operationsHandler)
|
||||
controlMux.Handle("/api/v1/admin/tenants/", operationsHandler)
|
||||
controlMux.Handle("/api/v1/admin/files", filesAdminHandler)
|
||||
controlMux.Handle("/api/v1/admin/files/", filesAdminHandler)
|
||||
@@ -479,6 +483,16 @@ func main() {
|
||||
controlMux.Handle("/api/v1/portal/stats", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/tools", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/chat/", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/personal-channels", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/personal-channels/", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/digital-employees", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/digital-employees/", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/resource-requests", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/resource-requests/", portalHandler)
|
||||
controlMux.Handle("/api/v1/admin/resource-requests", portalAdminHandler)
|
||||
controlMux.Handle("/api/v1/admin/resource-requests/", portalAdminHandler)
|
||||
controlMux.Handle("/api/v1/admin/env-vars", adminEnvVarHandler)
|
||||
controlMux.Handle("/api/v1/admin/env-vars/", adminEnvVarHandler)
|
||||
controlMux.Handle("/api/v1/", identityHandler)
|
||||
publicMux := http.NewServeMux()
|
||||
publicMux.Handle("/v1/prompts", workbenchRuntime)
|
||||
@@ -491,6 +505,7 @@ func main() {
|
||||
publicMux.Handle("/v1/mcp-servers", workbenchRuntime)
|
||||
publicMux.Handle("/v1/mcp-servers/", workbenchRuntime)
|
||||
publicMux.Handle("/v1/channels/", channelInboundHandler)
|
||||
publicMux.Handle("/v1/personal-channels/", portalHandler)
|
||||
publicMux.Handle("/v1/digital-employees/", workbenchRuntime)
|
||||
publicMux.Handle("/v1/", governedGateway)
|
||||
server := httpserver.New(httpserver.Dependencies{
|
||||
|
||||
@@ -454,3 +454,27 @@ PostgreSQL `text` 列拒绝写入 → 重试循环永远失败;inbox 的 Go `l
|
||||
SQL `$14`→`$13` 参数越界;update 返回视图未回读凭据导致 secret_configured
|
||||
显示失真;`/api/v1/portal/chat/` 与 `/api/v1/admin/social-providers` 挂载缺失;
|
||||
start/callback 用 provider code 而非 kind 查询。
|
||||
|
||||
# 追加:旗舰版功能完善第四轮(0.11.3,2026-08-13)
|
||||
|
||||
1. **统一审批中心**(迁移 000042):新增 `resource_access_requests`(资源/渠道申请)
|
||||
与 `tool_approval_requests`(工具审批);管理端审批中心聚合模型/资源/工具三类
|
||||
申请;通过后自动开通(marketplace 安装 / 渠道授权记录);outbox → 站内信
|
||||
通知申请人与管理员。
|
||||
2. **工具治理**:`tool_definitions` 增加 `rate_limit_rpm` 与 `approval_required`;
|
||||
限流用 `tool_rate_usage` 固定窗口原子 upsert(多实例共享额度,无竞态放大);
|
||||
审批标记工具在批准前执行一律拒绝并自动发起申请(部分唯一索引防重复),
|
||||
批准后立即可用。已端到端验证:拒绝→申请→审批→放行→RPM 超限。
|
||||
3. **平台环境变量**(迁移 000043):`platform_env_vars` 加密存储,运行时合并顺序
|
||||
平台→个人,个人可覆盖;管理端仅 `system:manage` 可写。
|
||||
4. **数字员工会话入口**:门户员工列表(部门可见或已安装)+ 对话 + 调用记录
|
||||
(经用户运行时 Key 归属)。
|
||||
5. **个人渠道**(迁移 000044):webhook 入站令牌 SHA-256 摘要落库、constant-time
|
||||
校验、仅创建/轮换时显示;入站经用户运行时凭据调受管网关,用量/审计归属
|
||||
用户 Key。已验证正确令牌放行、错误令牌拒绝。
|
||||
6. **报表多维**:工具调用/审批授权/安全事件三组端点
|
||||
(`/api/v1/admin/reports/{tools,approvals,security}`)。
|
||||
7. **租户配额**(迁移 000045):部门 Key/月 Token 上限,运行时凭据开通时强制
|
||||
校验;租户概览展示配额与当月用量。
|
||||
8. 修复:渠道 Save 空 API Key 时 `encrypted_api_key` NOT NULL 违约(空 bytea);
|
||||
安全报表 inet 列二进制扫描失败(`ip::text`)。
|
||||
|
||||
@@ -191,3 +191,24 @@ MinIO 对象存储与管理端/个人文件仓库;pgvector + Ollama(bge-m3)
|
||||
需企业开放平台应用凭据完成冒烟。
|
||||
- 多租户数据隔离重构:当前以部门(tenant_id)为租户维度,跨租户物理隔离(独立 schema/库)
|
||||
需明确部署形态后实施。
|
||||
|
||||
## 九、0.11.3 完成情况(2026-08-13 第四轮完善)
|
||||
|
||||
| 功能 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 统一审批中心 | ✅ | 模型/资源/渠道/工具四类申请统一审批,通过自动开通,双向站内信 |
|
||||
| 工具治理 | ✅ | 工具限流(RPM 固定窗口)+ 审批标记,端到端验证 |
|
||||
| 平台环境变量 | ✅ | 平台级注入,个人可覆盖 |
|
||||
| 数字员工会话入口 | ✅ | 门户列表/对话/调用记录 |
|
||||
| 个人渠道 | ✅ | 个人 Webhook 渠道绑定已批准模型,令牌轮换 |
|
||||
| 报表多维统计 | ✅ | 工具/审批/安全事件维度 |
|
||||
| 租户配额 | ✅ | 部门 Key/月 Token 上限 + 概览展示 |
|
||||
|
||||
剩余依赖外部条件项:
|
||||
|
||||
- 企微/钉钉/飞书真实平台联调(扫码登录与渠道):协议已实现且单测覆盖,
|
||||
需企业开放平台应用凭据完成冒烟。
|
||||
- 多租户物理隔离(独立 schema/库):当前以部门(tenant_id)为租户维度的
|
||||
逻辑隔离 + 配额管控已完成,物理隔离需明确部署形态后实施。
|
||||
- 智能体节点远程安装/任务下发:节点登记/心跳/路由预览已完成,远程安装
|
||||
与真实节点执行需部署 Agent 环境后验收。
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -31,6 +31,8 @@ type Department struct {
|
||||
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"`
|
||||
@@ -42,6 +44,8 @@ type departmentInput struct {
|
||||
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)
|
||||
|
||||
@@ -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": "定时任务"}},
|
||||
|
||||
@@ -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, "租户概览查询失败")
|
||||
@@ -104,15 +107,18 @@ func (h *AdminHTTPHandler) tenantsOverview(w http.ResponseWriter, r *http.Reques
|
||||
type tenantRow struct {
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}$`)
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+190
-12
@@ -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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
return nil, err
|
||||
}
|
||||
pairs = append(pairs, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
return pairs, rows.Err()
|
||||
}
|
||||
for _, p := range pairs {
|
||||
// 平台变量(全部,最多 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 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)
|
||||
}
|
||||
// 个人变量覆盖平台默认值。
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- 000042_governance.sql — 治理增强:工具限流与审批、资源/渠道权限申请(全类型审批流)。
|
||||
|
||||
-- 工具治理:调用频率上限(0 = 不限)与审批标记(approval_required 工具首次调用需管理员审批)。
|
||||
ALTER TABLE gateway.tool_definitions
|
||||
ADD COLUMN IF NOT EXISTS rate_limit_rpm integer NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS approval_required boolean NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN gateway.tool_definitions.rate_limit_rpm IS
|
||||
'Per-tool rate limit in calls per minute, 0 = unlimited. Enforced with an atomic fixed-window upsert so multiple gateway replicas share the same budget.';
|
||||
COMMENT ON COLUMN gateway.tool_definitions.approval_required IS
|
||||
'When true the tool cannot run until an admin approves it; the first invocation creates a tool_approval_requests entry.';
|
||||
|
||||
-- 工具限流固定窗口计数(PostgreSQL 原子 upsert,多实例共享)。
|
||||
CREATE TABLE IF NOT EXISTS gateway.tool_rate_usage (
|
||||
tool_id uuid NOT NULL REFERENCES gateway.tool_definitions(id) ON DELETE CASCADE,
|
||||
window_start timestamptz NOT NULL,
|
||||
call_count bigint NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (tool_id, window_start)
|
||||
);
|
||||
|
||||
-- 工具审批申请:每工具至多一个待审项;审批通过后该工具可被调用。
|
||||
CREATE TABLE IF NOT EXISTS gateway.tool_approval_requests (
|
||||
id uuid PRIMARY KEY,
|
||||
tool_id uuid NOT NULL REFERENCES gateway.tool_definitions(id) ON DELETE CASCADE,
|
||||
requester_kind varchar(16) NOT NULL DEFAULT 'system',
|
||||
requester_id text NOT NULL DEFAULT '',
|
||||
reason text NOT NULL DEFAULT '' CHECK (length(reason) <= 4000),
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
decided_by uuid REFERENCES gateway.admin_accounts(id) ON DELETE SET NULL,
|
||||
decision_note text NOT NULL DEFAULT '' CHECK (length(decision_note) <= 4000),
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
decided_at timestamptz
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tool_approval_requests_one_pending_idx
|
||||
ON gateway.tool_approval_requests (tool_id) WHERE status = 'pending';
|
||||
CREATE INDEX IF NOT EXISTS tool_approval_requests_status_time_idx
|
||||
ON gateway.tool_approval_requests (status, created_at DESC);
|
||||
|
||||
-- 资源/渠道权限申请:门户用户申请 mcp/skill/数字员工/渠道的使用权限,管理员审批后
|
||||
-- 自动开通(marketplace 安装或渠道部门授权)。
|
||||
CREATE TABLE IF NOT EXISTS gateway.resource_access_requests (
|
||||
id uuid PRIMARY KEY,
|
||||
portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE,
|
||||
resource_type varchar(24) NOT NULL CHECK (resource_type IN ('mcp_server', 'skill', 'digital_employee', 'channel')),
|
||||
resource_code text NOT NULL CHECK (length(resource_code) BETWEEN 1 AND 128),
|
||||
reason text NOT NULL DEFAULT '' CHECK (length(reason) <= 4000),
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'cancelled')),
|
||||
decision_note text NOT NULL DEFAULT '' CHECK (length(decision_note) <= 4000),
|
||||
decided_by uuid REFERENCES gateway.admin_accounts(id) ON DELETE SET NULL,
|
||||
decided_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS resource_access_requests_one_pending_idx
|
||||
ON gateway.resource_access_requests (portal_user_id, resource_type, resource_code)
|
||||
WHERE status = 'pending';
|
||||
CREATE INDEX IF NOT EXISTS resource_access_requests_status_time_idx
|
||||
ON gateway.resource_access_requests (status, created_at DESC);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 000043_platform_env_vars.sql — 平台级环境变量(skill/mcp 运行时注入)。
|
||||
-- 优先级:平台变量 < 个人变量(个人可覆盖平台默认值)。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway.platform_env_vars (
|
||||
key varchar(128) PRIMARY KEY CHECK (key ~ '^[A-Za-z_][A-Za-z0-9_]*$'),
|
||||
encrypted_value bytea NOT NULL,
|
||||
value_kek_version integer NOT NULL,
|
||||
description text NOT NULL DEFAULT '' CHECK (length(description) <= 512),
|
||||
updated_by uuid REFERENCES gateway.admin_accounts(id) ON DELETE SET NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 000044_personal_channels.sql — 个人渠道:门户用户自建 webhook 渠道,绑定已批准模型,
|
||||
-- 入站消息用用户运行时凭据应答,用量归属用户自己的 Key。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway.personal_channels (
|
||||
id uuid PRIMARY KEY,
|
||||
portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE,
|
||||
code varchar(64) NOT NULL,
|
||||
name varchar(128) NOT NULL,
|
||||
kind varchar(16) NOT NULL DEFAULT 'webhook' CHECK (kind IN ('webhook')),
|
||||
inbound_token_hash varchar(64) NOT NULL,
|
||||
provider_code text NOT NULL DEFAULT '',
|
||||
model text NOT NULL CHECK (length(model) BETWEEN 1 AND 512),
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
last_used_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
UNIQUE (code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS personal_channels_user_idx
|
||||
ON gateway.personal_channels (portal_user_id, created_at DESC);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 000045_tenant_quotas.sql — 租户(部门)级配额:Key 数量上限与月 Token 上限,
|
||||
-- 0 = 不限。平台管理员多租户管理的基础:每个部门即一个租户,配额在创建时强制。
|
||||
|
||||
ALTER TABLE gateway.departments
|
||||
ADD COLUMN IF NOT EXISTS max_api_keys integer NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS max_monthly_tokens bigint NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN gateway.departments.max_api_keys IS
|
||||
'Maximum gateway API keys bound to this tenant (department), 0 = unlimited.';
|
||||
COMMENT ON COLUMN gateway.departments.max_monthly_tokens IS
|
||||
'Maximum monthly token usage for this tenant, 0 = unlimited.';
|
||||
@@ -13,3 +13,30 @@ export const updateFactPolicy=(id:string,params:FactPolicy)=>request.put<FactPol
|
||||
export const deleteFactPolicy=(id:string)=>request.del({url:`/api/v1/admin/fact-check/policies/${id}`})
|
||||
export const fetchFactEvents=()=>request.get<{items:FactEvent[]}>({url:'/api/v1/admin/fact-check/events'})
|
||||
|
||||
|
||||
// --- 0.11.3 治理:资源/渠道申请、工具审批、报表多维、平台环境变量 ---
|
||||
export interface ResourceRequest {
|
||||
id: string; portal_user_id: string; user_login: string; resource_type: string
|
||||
resource_code: string; reason: string; status: string; decision_note: string
|
||||
decided_at?: string; created_at: string; updated_at: string
|
||||
}
|
||||
export interface ToolApproval {
|
||||
id: string; tool_code: string; tool_name: string; status: string
|
||||
reason: string; decision_note: string; created_at?: string; decided_at?: string; decided_by?: string
|
||||
}
|
||||
export const fetchResourceRequests=(status='')=>request.get<ResourceRequest[]>({url:'/api/v1/admin/resource-requests',params:{status}})
|
||||
export const decideResourceRequest=(id:string,status:'approve'|'reject',note:string)=>request.post<ResourceRequest>({url:`/api/v1/admin/resource-requests/${id}/${status}`,params:{note}})
|
||||
export const fetchToolApprovals=(status='')=>request.get<ToolApproval[]>({url:'/api/v1/admin/tool-approvals',params:{status}})
|
||||
export const decideToolApproval=(id:string,status:'approved'|'rejected',note:string)=>request.post<ToolApproval>({url:`/api/v1/admin/tool-approvals/${id}/decide`,params:{status,note}})
|
||||
|
||||
export interface ToolUsageRow { code:string;name:string;requests:number;success:number;failed:number;avg_latency_ms:number }
|
||||
export interface ApprovalStatRow { kind:string;status:string;count:number }
|
||||
export interface SecurityReport { login_stats:Array<{success:boolean;count:number;distinct_ips:number}>;top_ips:Array<{ip:string;count:number}> }
|
||||
export const fetchToolUsageReport=(params:{from:string;to:string})=>request.get<ToolUsageRow[]>({url:'/api/v1/admin/reports/tools',params})
|
||||
export const fetchApprovalReport=(params:{from:string;to:string})=>request.get<ApprovalStatRow[]>({url:'/api/v1/admin/reports/approvals',params})
|
||||
export const fetchSecurityReport=(params:{from:string;to:string})=>request.get<SecurityReport>({url:'/api/v1/admin/reports/security',params})
|
||||
|
||||
export interface PlatformEnvVar { key:string;configured:boolean;description:string;updated_at:string }
|
||||
export const fetchPlatformEnvVars=()=>request.get<PlatformEnvVar[]>({url:'/api/v1/admin/env-vars'})
|
||||
export const upsertPlatformEnvVar=(key:string,value:string,description:string)=>request.put<{saved:boolean}>({url:`/api/v1/admin/env-vars/${key}`,params:{value,description}})
|
||||
export const deletePlatformEnvVar=(key:string)=>request.del({url:`/api/v1/admin/env-vars/${key}`})
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface DepartmentRecord {
|
||||
parent_id?: string
|
||||
parent_name?: string
|
||||
active: boolean
|
||||
max_api_keys: number
|
||||
max_monthly_tokens: number
|
||||
user_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -49,6 +51,8 @@ export interface DepartmentInput {
|
||||
description: string
|
||||
parent_id?: string
|
||||
active: boolean
|
||||
max_api_keys?: number
|
||||
max_monthly_tokens?: number
|
||||
}
|
||||
|
||||
export interface IdentityProviderRecord {
|
||||
|
||||
@@ -29,8 +29,8 @@ export const addKnowledgeDocument=(id:string,params:{title:string;source_type:st
|
||||
export const deleteKnowledgeDocument=(kb:string,id:string)=>request.del({url:`/api/v1/admin/knowledge-bases/${kb}/documents/${id}`})
|
||||
export const searchKnowledge=(id:string,params:{query:string;top_k:number})=>request.post<SearchHit[]>({url:`/api/v1/admin/knowledge-bases/${id}/search`,params})
|
||||
|
||||
export interface ToolDefinition {id:string;code:string;name:string;description:string;endpoint_url:string;http_method:string;input_schema:Record<string,unknown>;timeout_seconds:number;department_ids:string[];enabled:boolean;has_secret_headers:boolean;revision:number}
|
||||
export interface ToolInput {code:string;name:string;description:string;endpoint_url:string;http_method:string;headers?:Record<string,string>;input_schema:Record<string,unknown>;timeout_seconds:number;department_ids:string[];enabled:boolean}
|
||||
export interface ToolDefinition {id:string;code:string;name:string;description:string;endpoint_url:string;http_method:string;input_schema:Record<string,unknown>;timeout_seconds:number;department_ids:string[];rate_limit_rpm:number;approval_required:boolean;enabled:boolean;has_secret_headers:boolean;revision:number}
|
||||
export interface ToolInput {code:string;name:string;description:string;endpoint_url:string;http_method:string;headers?:Record<string,string>;input_schema:Record<string,unknown>;timeout_seconds:number;department_ids:string[];rate_limit_rpm:number;approval_required:boolean;enabled:boolean}
|
||||
export const fetchTools=()=>request.get<ToolDefinition[]>({url:'/api/v1/admin/tools'})
|
||||
export const createTool=(params:ToolInput)=>request.post<ToolDefinition>({url:'/api/v1/admin/tools',params})
|
||||
export const updateTool=(id:string,params:ToolInput)=>request.put<ToolDefinition>({url:`/api/v1/admin/tools/${id}`,params})
|
||||
|
||||
@@ -59,12 +59,45 @@
|
||||
<ElTableColumn prop="cost" label="成本" width="140" />
|
||||
</ElTable>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="工具调用" name="tools">
|
||||
<ElTable v-loading="loading" :data="toolRows" row-key="code">
|
||||
<ElTableColumn prop="name" label="工具" min-width="160" />
|
||||
<ElTableColumn prop="code" label="编码" min-width="140" />
|
||||
<ElTableColumn prop="requests" label="调用数" width="110" />
|
||||
<ElTableColumn prop="success" label="成功" width="100" />
|
||||
<ElTableColumn prop="failed" label="失败" width="100" />
|
||||
<ElTableColumn prop="avg_latency_ms" label="平均延迟(ms)" width="130" />
|
||||
</ElTable>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="审批授权" name="approvals">
|
||||
<ElTable v-loading="loading" :data="approvalRows" row-key="key">
|
||||
<ElTableColumn label="申请类型" width="140">
|
||||
<template #default="{ row }">{{ ({ model: '模型', resource: '资源/渠道', tool: '工具' } as Record<string, string>)[row.kind as string] || row.kind }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="120">
|
||||
<template #default="{ row }">{{ ({ pending: '待审批', approved: '已通过', rejected: '已驳回', cancelled: '已取消' } as Record<string, string>)[row.status as string] || row.status }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="count" label="数量" width="110" />
|
||||
</ElTable>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="安全事件" name="security">
|
||||
<div class="mb-4 grid grid-cols-3 gap-4">
|
||||
<ElCard shadow="never"><div class="text-g-500 text-sm">登录成功</div><b class="mt-2 block text-2xl">{{ securityStats.success || 0 }}</b></ElCard>
|
||||
<ElCard shadow="never"><div class="text-g-500 text-sm">登录失败</div><b class="mt-2 block text-2xl">{{ securityStats.failed || 0 }}</b></ElCard>
|
||||
<ElCard shadow="never"><div class="text-g-500 text-sm">来源 IP 数</div><b class="mt-2 block text-2xl">{{ securityStats.ips || 0 }}</b></ElCard>
|
||||
</div>
|
||||
<ElTable v-loading="loading" :data="securityTopIPs" row-key="ip">
|
||||
<ElTableColumn prop="ip" label="来源 IP" min-width="220" />
|
||||
<ElTableColumn prop="count" label="登录次数" width="140" />
|
||||
</ElTable>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fetchDailyUsage } from '@/api/audit'
|
||||
import { fetchApprovalReport, fetchSecurityReport, fetchToolUsageReport } from '@/api/governance'
|
||||
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('provider')
|
||||
@@ -137,10 +170,27 @@
|
||||
return [...map.values()].sort((a, b) => b.date.localeCompare(a.date))
|
||||
})
|
||||
|
||||
const toolRows = ref<any[]>([])
|
||||
const approvalRows = ref<any[]>([])
|
||||
const securityTopIPs = ref<any[]>([])
|
||||
const securityStats = ref<{ success: number; failed: number; ips: number }>({ success: 0, failed: 0, ips: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
dailyUsage.value = await fetchDailyUsage({ from: range.value[0], to: range.value[1] })
|
||||
const params = { from: range.value[0], to: range.value[1] }
|
||||
dailyUsage.value = await fetchDailyUsage(params)
|
||||
toolRows.value = await fetchToolUsageReport(params)
|
||||
approvalRows.value = await fetchApprovalReport(params)
|
||||
const security = await fetchSecurityReport(params)
|
||||
securityTopIPs.value = security.top_ips || []
|
||||
const stats = { success: 0, failed: 0, ips: 0 }
|
||||
for (const item of security.login_stats || []) {
|
||||
if (item.success) stats.success += item.count
|
||||
else stats.failed += item.count
|
||||
stats.ips += item.distinct_ips
|
||||
}
|
||||
securityStats.value = stats
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -9,10 +9,19 @@
|
||||
</div>
|
||||
<ElTable v-loading="loading" :data="tenants" row-key="id">
|
||||
<ElTableColumn prop="name" label="租户(部门)" min-width="200" />
|
||||
<ElTableColumn prop="portal_users" label="门户账号" width="110" />
|
||||
<ElTableColumn prop="enabled_api_keys" label="启用 Key" width="110" />
|
||||
<ElTableColumn prop="today_requests" label="今日请求" width="120" />
|
||||
<ElTableColumn prop="today_tokens" label="今日 Tokens" width="140" />
|
||||
<ElTableColumn prop="portal_users" label="门户账号" width="100" />
|
||||
<ElTableColumn label="Key 配额" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.enabled_api_keys }} / {{ row.max_api_keys || '∞' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="月 Token 配额" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ (row.month_tokens || 0).toLocaleString() }} / {{ row.max_monthly_tokens ? row.max_monthly_tokens.toLocaleString() : '∞' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="today_requests" label="今日请求" width="110" />
|
||||
<ElTableColumn prop="today_tokens" label="今日 Tokens" width="130" />
|
||||
</ElTable>
|
||||
<div v-if="!loading && !tenants.length" class="py-10 text-center text-g-400">暂无租户数据</div>
|
||||
</div>
|
||||
@@ -24,10 +33,13 @@
|
||||
interface TenantRow {
|
||||
id: string
|
||||
name: string
|
||||
max_api_keys: number
|
||||
max_monthly_tokens: number
|
||||
portal_users: number
|
||||
enabled_api_keys: number
|
||||
today_requests: number
|
||||
today_tokens: number
|
||||
month_tokens: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template><div class="page-content"><div class="mb-5 flex items-start justify-between"><div><h2 class="text-xl font-semibold">工具中心</h2><p class="text-g-500 mt-1 text-sm">只注册受控 HTTP 工具,不允许任意命令或 SQL;运行时执行 DNS/SSRF 复核和响应大小限制</p></div><ElButton type="primary" @click="openCreate">注册工具</ElButton></div><ElTable v-loading="loading" :data="items" row-key="id"><ElTableColumn prop="code" label="编码" min-width="150"/><ElTableColumn prop="name" label="名称" min-width="130"/><ElTableColumn label="端点" min-width="240"><template #default="{row}"><ElTag>{{row.http_method}}</ElTag><span class="ml-2">{{row.endpoint_url}}</span></template></ElTableColumn><ElTableColumn label="凭据头" width="100"><template #default="{row}">{{row.has_secret_headers?'已加密':'无'}}</template></ElTableColumn><ElTableColumn label="范围" width="110"><template #default="{row}">{{row.department_ids.length?`${row.department_ids.length} 部门`:'未公开'}}</template></ElTableColumn><ElTableColumn label="状态" width="90"><template #default="{row}"><ElTag :type="row.enabled?'success':'info'">{{row.enabled?'启用':'停用'}}</ElTag></template></ElTableColumn><ElTableColumn label="操作" width="190" fixed="right"><template #default="{row}"><ElButton link type="primary" @click="openTest(row)">测试</ElButton><ElButton link type="primary" @click="openEdit(row)">编辑</ElButton><ElButton link type="danger" @click="remove(row)">删除</ElButton></template></ElTableColumn></ElTable>
|
||||
<ElDialog v-model="visible" :title="editing?'编辑工具':'注册工具'" width="760px"><ElForm label-width="110px"><div class="grid grid-cols-2 gap-x-4"><ElFormItem label="编码"><ElInput v-model="form.code" placeholder="weather_lookup"/></ElFormItem><ElFormItem label="名称"><ElInput v-model="form.name"/></ElFormItem></div><ElFormItem label="描述"><ElInput v-model="form.description" type="textarea"/></ElFormItem><div class="grid grid-cols-[120px_1fr] gap-x-4"><ElFormItem label="方法"><ElSelect v-model="form.http_method"><ElOption v-for="m in methods" :key="m" :label="m" :value="m"/></ElSelect></ElFormItem><ElFormItem label="端点"><ElInput v-model="form.endpoint_url"/></ElFormItem></div><ElFormItem label="请求头 JSON"><ElInput v-model="headersJSON" type="textarea" :rows="3" :placeholder="editing&¤tHasHeaders?'留空以保留现有加密请求头':'{"Authorization":"Bearer ..."}'"/></ElFormItem><ElFormItem label="输入 Schema"><ElInput v-model="schemaJSON" type="textarea" :rows="6"/></ElFormItem><ElFormItem label="部门范围"><ElSelect v-model="form.department_ids" multiple allow-create filterable class="w-full" placeholder="为安全起见,留空时不会向普通 API Key 公开"/></ElFormItem><div class="grid grid-cols-2"><ElFormItem label="超时秒数"><ElInputNumber v-model="form.timeout_seconds" :min="1" :max="120"/></ElFormItem><ElFormItem label="启用"><ElSwitch v-model="form.enabled"/></ElFormItem></div></ElForm><template #footer><ElButton @click="visible=false">取消</ElButton><ElButton type="primary" @click="save">保存</ElButton></template></ElDialog>
|
||||
<template><div class="page-content"><div class="mb-5 flex items-start justify-between"><div><h2 class="text-xl font-semibold">工具中心</h2><p class="text-g-500 mt-1 text-sm">只注册受控 HTTP 工具,不允许任意命令或 SQL;运行时执行 DNS/SSRF 复核和响应大小限制</p></div><ElButton type="primary" @click="openCreate">注册工具</ElButton></div><ElTable v-loading="loading" :data="items" row-key="id"><ElTableColumn prop="code" label="编码" min-width="150"/><ElTableColumn prop="name" label="名称" min-width="130"/><ElTableColumn label="端点" min-width="240"><template #default="{row}"><ElTag>{{row.http_method}}</ElTag><span class="ml-2">{{row.endpoint_url}}</span></template></ElTableColumn><ElTableColumn label="凭据头" width="100"><template #default="{row}">{{row.has_secret_headers?'已加密':'无'}}</template></ElTableColumn><ElTableColumn label="范围" width="110"><template #default="{row}">{{row.department_ids.length?`${row.department_ids.length} 部门`:'未公开'}}</template></ElTableColumn><ElTableColumn label="限流" width="90"><template #default="{row}">{{row.rate_limit_rpm?`${row.rate_limit_rpm} rpm`:'不限'}}</template></ElTableColumn><ElTableColumn label="审批" width="80"><template #default="{row}"><ElTag :type="row.approval_required?'warning':'info'" size="small">{{row.approval_required?'需审批':'无需'}}</ElTag></template></ElTableColumn><ElTableColumn label="状态" width="90"><template #default="{row}"><ElTag :type="row.enabled?'success':'info'">{{row.enabled?'启用':'停用'}}</ElTag></template></ElTableColumn><ElTableColumn label="操作" width="190" fixed="right"><template #default="{row}"><ElButton link type="primary" @click="openTest(row)">测试</ElButton><ElButton link type="primary" @click="openEdit(row)">编辑</ElButton><ElButton link type="danger" @click="remove(row)">删除</ElButton></template></ElTableColumn></ElTable>
|
||||
<ElDialog v-model="visible" :title="editing?'编辑工具':'注册工具'" width="760px"><ElForm label-width="110px"><div class="grid grid-cols-2 gap-x-4"><ElFormItem label="编码"><ElInput v-model="form.code" placeholder="weather_lookup"/></ElFormItem><ElFormItem label="名称"><ElInput v-model="form.name"/></ElFormItem></div><ElFormItem label="描述"><ElInput v-model="form.description" type="textarea"/></ElFormItem><div class="grid grid-cols-[120px_1fr] gap-x-4"><ElFormItem label="方法"><ElSelect v-model="form.http_method"><ElOption v-for="m in methods" :key="m" :label="m" :value="m"/></ElSelect></ElFormItem><ElFormItem label="端点"><ElInput v-model="form.endpoint_url"/></ElFormItem></div><ElFormItem label="请求头 JSON"><ElInput v-model="headersJSON" type="textarea" :rows="3" :placeholder="editing&¤tHasHeaders?'留空以保留现有加密请求头':'{"Authorization":"Bearer ..."}'"/></ElFormItem><ElFormItem label="输入 Schema"><ElInput v-model="schemaJSON" type="textarea" :rows="6"/></ElFormItem><ElFormItem label="部门范围"><ElSelect v-model="form.department_ids" multiple allow-create filterable class="w-full" placeholder="为安全起见,留空时不会向普通 API Key 公开"/></ElFormItem><div class="grid grid-cols-2"><ElFormItem label="超时秒数"><ElInputNumber v-model="form.timeout_seconds" :min="1" :max="120"/></ElFormItem><ElFormItem label="启用"><ElSwitch v-model="form.enabled"/></ElFormItem></div><div class="grid grid-cols-2"><ElFormItem label="限流 (RPM)"><ElInputNumber v-model="form.rate_limit_rpm" :min="0" :max="100000"/></ElFormItem><ElFormItem label="需审批"><ElSwitch v-model="form.approval_required"/></ElFormItem></div></ElForm><template #footer><ElButton @click="visible=false">取消</ElButton><ElButton type="primary" @click="save">保存</ElButton></template></ElDialog>
|
||||
<ElDialog v-model="testVisible" title="测试工具" width="650px"><ElInput v-model="testInput" type="textarea" :rows="8"/><div class="my-3"><ElButton type="primary" @click="runTest">执行</ElButton></div><pre class="max-h-80 overflow-auto rounded bg-black p-3 text-xs text-white">{{testResult}}</pre></ElDialog></div></template>
|
||||
<script setup lang="ts">import{ToolDefinition,ToolInput,createTool,deleteTool,fetchTools,testTool,updateTool}from'@/api/workbench';import{ElMessage,ElMessageBox}from'element-plus';const methods=['GET','POST','PUT','PATCH','DELETE'];const loading=ref(false),visible=ref(false),testVisible=ref(false),editing=ref(''),currentHasHeaders=ref(false);const items=ref<ToolDefinition[]>([]);const blank=():ToolInput=>({code:'',name:'',description:'',endpoint_url:'',http_method:'POST',headers:{},input_schema:{type:'object',properties:{}},timeout_seconds:15,department_ids:[],enabled:true});const form=reactive<ToolInput>(blank()),headersJSON=ref('{}'),schemaJSON=ref(JSON.stringify(blank().input_schema,null,2)),testInput=ref('{}'),testResult=ref(''),testing=ref<ToolDefinition>();async function load(){loading.value=true;try{items.value=await fetchTools()}finally{loading.value=false}}function openCreate(){editing.value='';currentHasHeaders.value=false;Object.assign(form,blank());headersJSON.value='{}';schemaJSON.value=JSON.stringify(form.input_schema,null,2);visible.value=true}function openEdit(row:ToolDefinition){editing.value=row.id;currentHasHeaders.value=row.has_secret_headers;Object.assign(form,{code:row.code,name:row.name,description:row.description,endpoint_url:row.endpoint_url,http_method:row.http_method,timeout_seconds:row.timeout_seconds,department_ids:[...row.department_ids],enabled:row.enabled});headersJSON.value='';schemaJSON.value=JSON.stringify(row.input_schema,null,2);visible.value=true}async function save(){try{form.input_schema=JSON.parse(schemaJSON.value);if(headersJSON.value.trim())form.headers=JSON.parse(headersJSON.value);else delete form.headers}catch{ElMessage.error('JSON 格式无效');return}editing.value?await updateTool(editing.value,form):await createTool(form);visible.value=false;await load()}async function remove(row:ToolDefinition){await ElMessageBox.confirm(`删除工具“${row.name}”?`,'确认',{type:'warning'});await deleteTool(row.id);await load()}function openTest(row:ToolDefinition){testing.value=row;testInput.value='{}';testResult.value='';testVisible.value=true}async function runTest(){if(!testing.value)return;try{const result=await testTool(testing.value.id,JSON.parse(testInput.value));testResult.value=JSON.stringify(result,null,2)}catch(error){testResult.value=String(error)}}onMounted(load)</script>
|
||||
<script setup lang="ts">import{ToolDefinition,ToolInput,createTool,deleteTool,fetchTools,testTool,updateTool}from'@/api/workbench';import{ElMessage,ElMessageBox}from'element-plus';const methods=['GET','POST','PUT','PATCH','DELETE'];const loading=ref(false),visible=ref(false),testVisible=ref(false),editing=ref(''),currentHasHeaders=ref(false);const items=ref<ToolDefinition[]>([]);const blank=():ToolInput=>({code:'',name:'',description:'',endpoint_url:'',http_method:'POST',headers:{},input_schema:{type:'object',properties:{}},timeout_seconds:15,department_ids:[],rate_limit_rpm:0,approval_required:false,enabled:true});const form=reactive<ToolInput>(blank()),headersJSON=ref('{}'),schemaJSON=ref(JSON.stringify(blank().input_schema,null,2)),testInput=ref('{}'),testResult=ref(''),testing=ref<ToolDefinition>();async function load(){loading.value=true;try{items.value=await fetchTools()}finally{loading.value=false}}function openCreate(){editing.value='';currentHasHeaders.value=false;Object.assign(form,blank());headersJSON.value='{}';schemaJSON.value=JSON.stringify(form.input_schema,null,2);visible.value=true}function openEdit(row:ToolDefinition){editing.value=row.id;currentHasHeaders.value=row.has_secret_headers;Object.assign(form,{code:row.code,name:row.name,description:row.description,endpoint_url:row.endpoint_url,http_method:row.http_method,timeout_seconds:row.timeout_seconds,department_ids:[...row.department_ids],rate_limit_rpm:row.rate_limit_rpm||0,approval_required:!!row.approval_required,enabled:row.enabled});headersJSON.value='';schemaJSON.value=JSON.stringify(row.input_schema,null,2);visible.value=true}async function save(){try{form.input_schema=JSON.parse(schemaJSON.value);if(headersJSON.value.trim())form.headers=JSON.parse(headersJSON.value);else delete form.headers}catch{ElMessage.error('JSON 格式无效');return}editing.value?await updateTool(editing.value,form):await createTool(form);visible.value=false;await load()}async function remove(row:ToolDefinition){await ElMessageBox.confirm(`删除工具“${row.name}”?`,'确认',{type:'warning'});await deleteTool(row.id);await load()}function openTest(row:ToolDefinition){testing.value=row;testInput.value='{}';testResult.value='';testVisible.value=true}async function runTest(){if(!testing.value)return;try{const result=await testTool(testing.value.id,JSON.parse(testInput.value));testResult.value=JSON.stringify(result,null,2)}catch(error){testResult.value=String(error)}}onMounted(load)</script>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-semibold">审批中心</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">统一处理模型访问、资源/渠道权限与工具使用申请,审批通过后自动开通</p>
|
||||
</div>
|
||||
|
||||
<ElTabs v-model="activeTab" @tab-change="load">
|
||||
<ElTabPane label="模型申请" name="model" />
|
||||
<ElTabPane label="资源 / 渠道申请" name="resource" />
|
||||
<ElTabPane label="工具审批" name="tool" />
|
||||
</ElTabs>
|
||||
|
||||
<ElTable v-if="activeTab === 'model'" v-loading="loading" :data="modelRequests" row-key="id">
|
||||
<ElTableColumn prop="user_login" label="申请人" width="140" />
|
||||
<ElTableColumn prop="model" label="模型" min-width="180" />
|
||||
<ElTableColumn prop="provider_code" label="供应商" width="120" />
|
||||
<ElTableColumn prop="reason" label="理由" min-width="200" show-overflow-tooltip />
|
||||
<ElTableColumn prop="requested_rpm" label="RPM" width="90" />
|
||||
<ElTableColumn prop="requested_monthly_tokens" label="月 Token" width="120" />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusText(row.status) }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<ElButton link type="success" @click="decide('model', row, 'approve')">通过</ElButton>
|
||||
<ElButton link type="danger" @click="decide('model', row, 'reject')">驳回</ElButton>
|
||||
</template>
|
||||
<span v-else class="text-g-400 text-xs">{{ row.decision_note || '—' }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElTable v-else-if="activeTab === 'resource'" v-loading="loading" :data="resourceRequests" row-key="id">
|
||||
<ElTableColumn prop="user_login" label="申请人" width="140" />
|
||||
<ElTableColumn label="类型" width="130">
|
||||
<template #default="{ row }">{{ typeName(row.resource_type) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="resource_code" label="资源" min-width="180" />
|
||||
<ElTableColumn prop="reason" label="理由" min-width="200" show-overflow-tooltip />
|
||||
<ElTableColumn prop="created_at" label="申请时间" width="180" />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusText(row.status) }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<ElButton link type="success" @click="decide('resource', row, 'approve')">通过并开通</ElButton>
|
||||
<ElButton link type="danger" @click="decide('resource', row, 'reject')">驳回</ElButton>
|
||||
</template>
|
||||
<span v-else class="text-g-400 text-xs">{{ row.decision_note || '—' }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElTable v-else v-loading="loading" :data="toolApprovals" row-key="id">
|
||||
<ElTableColumn prop="tool_name" label="工具" min-width="180" />
|
||||
<ElTableColumn prop="tool_code" label="编码" width="160" />
|
||||
<ElTableColumn prop="reason" label="原因" min-width="220" show-overflow-tooltip />
|
||||
<ElTableColumn prop="created_at" label="申请时间" width="180" />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusText(row.status) }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<ElButton link type="success" @click="decide('tool', row, 'approve')">通过</ElButton>
|
||||
<ElButton link type="danger" @click="decide('tool', row, 'reject')">驳回</ElButton>
|
||||
</template>
|
||||
<span v-else class="text-g-400 text-xs">{{ row.decision_note || '—' }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ModelRequest, ResourceRequest, ToolApproval,
|
||||
decideModelRequest, decideResourceRequest, decideToolApproval,
|
||||
fetchModelRequests, fetchResourceRequests, fetchToolApprovals
|
||||
} from '@/api/governance'
|
||||
|
||||
const activeTab = ref('model')
|
||||
const loading = ref(false)
|
||||
const modelRequests = ref<ModelRequest[]>([])
|
||||
const resourceRequests = ref<ResourceRequest[]>([])
|
||||
const toolApprovals = ref<ToolApproval[]>([])
|
||||
|
||||
const statusText = (v: string) => ({ pending: '待审批', approved: '已通过', rejected: '已驳回', cancelled: '已取消' }[v] || v)
|
||||
const statusType = (v: string) => (v === 'approved' ? 'success' : v === 'rejected' || v === 'cancelled' ? 'danger' : 'warning')
|
||||
const typeName = (v: string) => ({ mcp_server: 'MCP 服务器', skill: 'Skill', digital_employee: '数字员工', channel: '渠道' }[v] || v)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (activeTab.value === 'model') modelRequests.value = await fetchModelRequests('pending')
|
||||
if (activeTab.value === 'resource') resourceRequests.value = await fetchResourceRequests('pending')
|
||||
if (activeTab.value === 'tool') toolApprovals.value = await fetchToolApprovals('pending')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function decide(kind: 'model' | 'resource' | 'tool', row: any, action: 'approve' | 'reject') {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
action === 'approve' ? '通过后申请自动开通,可填写审批备注' : '填写驳回原因(将通知申请人)',
|
||||
action === 'approve' ? '通过申请' : '驳回申请',
|
||||
{ inputPlaceholder: '审批备注(可选)' }
|
||||
)
|
||||
const note = (value as string) || ''
|
||||
try {
|
||||
if (kind === 'model') await decideModelRequest(row.id, action, note)
|
||||
if (kind === 'resource') await decideResourceRequest(row.id, action, note)
|
||||
if (kind === 'tool') await decideToolApproval(row.id, action === 'approve' ? 'approved' : 'rejected', note)
|
||||
ElMessage.success(action === 'approve' ? '已通过' : '已驳回')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error((error as Error)?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">平台环境变量</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">平台级配置注入到 skill / MCP 运行时;个人环境变量可覆盖同名平台变量</p>
|
||||
</div>
|
||||
<ElButton type="primary" @click="openCreate">新增变量</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable v-loading="loading" :data="items" row-key="key">
|
||||
<ElTableColumn prop="key" label="变量名" min-width="200">
|
||||
<template #default="{ row }"><code class="text-xs">{{ row.key }}</code></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="description" label="说明" min-width="260" show-overflow-tooltip />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="row.configured ? 'success' : 'info'">{{ row.configured ? '已配置' : '无值' }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="updated_at" label="更新时间" width="180" />
|
||||
<ElTableColumn label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="primary" @click="openEdit(row)">编辑</ElButton>
|
||||
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElDialog v-model="visible" :title="editingKey ? `编辑 ${editingKey}` : '新增环境变量'" width="520px">
|
||||
<ElForm label-width="90px">
|
||||
<ElFormItem label="变量名" required>
|
||||
<ElInput v-model="form.key" :disabled="!!editingKey" placeholder="例如 LLM_API_BASE" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="值" required>
|
||||
<ElInput v-model="form.value" type="textarea" :rows="3" placeholder="变量值(加密存储)" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="说明">
|
||||
<ElInput v-model="form.description" maxlength="512" placeholder="用途说明" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="visible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="submit">保存</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { PlatformEnvVar, deletePlatformEnvVar, fetchPlatformEnvVars, upsertPlatformEnvVar } from '@/api/governance'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const visible = ref(false)
|
||||
const editingKey = ref('')
|
||||
const items = ref<PlatformEnvVar[]>([])
|
||||
const form = reactive({ key: '', value: '', description: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await fetchPlatformEnvVars()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingKey.value = ''
|
||||
Object.assign(form, { key: '', value: '', description: '' })
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: PlatformEnvVar) {
|
||||
editingKey.value = row.key
|
||||
Object.assign(form, { key: row.key, value: '', description: row.description || '' })
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.key.trim() || !form.value.trim()) {
|
||||
ElMessage.warning('变量名与值不能为空')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await upsertPlatformEnvVar(form.key.trim(), form.value, form.description)
|
||||
ElMessage.success('已保存')
|
||||
visible.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error((error as Error)?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: PlatformEnvVar) {
|
||||
await ElMessageBox.confirm(`删除后运行时将不再注入 ${row.key},确定删除?`, '删除变量', { type: 'warning' })
|
||||
await deletePlatformEnvVar(row.key)
|
||||
items.value = items.value.filter((item) => item.key !== row.key)
|
||||
ElMessage.success('已删除')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -223,6 +223,14 @@
|
||||
<ElFormItem label="描述">
|
||||
<ElInput v-model="departmentForm.description" type="textarea" maxlength="1024" show-word-limit />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="Key 配额">
|
||||
<ElInputNumber v-model="departmentForm.max_api_keys" :min="0" :max="1000000" class="w-full" />
|
||||
<div class="text-g-400 text-xs">该租户(部门)可绑定的网关 API Key 上限,0 = 不限</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="月 Token 配额">
|
||||
<ElInputNumber v-model="departmentForm.max_monthly_tokens" :min="0" :max="1000000000000000" class="w-full" />
|
||||
<div class="text-g-400 text-xs">该租户每月 Token 用量上限,0 = 不限</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="启用">
|
||||
<ElSwitch v-model="departmentForm.active" />
|
||||
</ElFormItem>
|
||||
@@ -395,7 +403,7 @@
|
||||
department_id: undefined
|
||||
})
|
||||
const departmentForm = reactive<DepartmentInput>({
|
||||
code: '', name: '', description: '', parent_id: undefined, active: true
|
||||
code: '', name: '', description: '', parent_id: undefined, active: true, max_api_keys: 0, max_monthly_tokens: 0
|
||||
})
|
||||
const idpForm = reactive<IdentityProviderInput>({
|
||||
code: '', display_name: '', issuer_url: '', client_id: '', client_secret: '',
|
||||
@@ -461,7 +469,7 @@
|
||||
function openCreate() {
|
||||
if (activeTab.value === 'department') {
|
||||
departmentEditingId.value = ''
|
||||
Object.assign(departmentForm, { code: '', name: '', description: '', parent_id: undefined, active: true })
|
||||
Object.assign(departmentForm, { code: '', name: '', description: '', parent_id: undefined, active: true, max_api_keys: 0, max_monthly_tokens: 0 })
|
||||
departmentDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
@@ -506,7 +514,7 @@
|
||||
departmentEditingId.value = record.id
|
||||
Object.assign(departmentForm, {
|
||||
code: record.code, name: record.name, description: record.description,
|
||||
parent_id: record.parent_id, active: record.active
|
||||
parent_id: record.parent_id, active: record.active, max_api_keys: record.max_api_keys || 0, max_monthly_tokens: record.max_monthly_tokens || 0
|
||||
})
|
||||
departmentDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -89,3 +89,23 @@ export const setSecurityPrefs=(login_notify:boolean)=>request.put<{login_notify:
|
||||
export const fetchProviderBindings=()=>request.get<ProviderBinding[]>({url:'/api/v1/portal/social/bindings'})
|
||||
export const startSocialBind=(kind:string)=>request.post<{redirect_url:string}>({url:`/api/v1/portal/social/${kind}/bind/start`})
|
||||
export const unbindSocial=(kind:string)=>request.del({url:`/api/v1/portal/social/${kind}/bind`})
|
||||
|
||||
// --- 资源/渠道权限申请 ---
|
||||
export interface ResourceRequest { id:string;user_login:string;resource_type:string;resource_code:string;reason:string;status:string;decision_note:string;created_at:string;updated_at:string }
|
||||
export const fetchMyResourceRequests=()=>request.get<ResourceRequest[]>({url:'/api/v1/portal/resource-requests'})
|
||||
export const createResourceRequest=(params:{resource_type:string;resource_code:string;reason:string})=>request.post<ResourceRequest>({url:'/api/v1/portal/resource-requests',params})
|
||||
export const cancelResourceRequest=(id:string)=>request.del({url:`/api/v1/portal/resource-requests/${id}`})
|
||||
|
||||
// --- 个人渠道 ---
|
||||
export interface PersonalChannel { id:string;code:string;name:string;kind:string;provider_code:string;model:string;enabled:boolean;last_used_at?:string;created_at:string;updated_at:string }
|
||||
export const fetchPersonalChannels=()=>request.get<PersonalChannel[]>({url:'/api/v1/portal/personal-channels'})
|
||||
export const createPersonalChannel=(params:{code:string;name:string;provider_code:string;model:string})=>request.post<{channel:PersonalChannel;inbound_token:string;inbound_url:string}>({url:'/api/v1/portal/personal-channels',params})
|
||||
export const regeneratePersonalToken=(id:string)=>request.post<{inbound_token:string}>({url:`/api/v1/portal/personal-channels/${id}/token`})
|
||||
export const deletePersonalChannel=(id:string)=>request.del({url:`/api/v1/portal/personal-channels/${id}`})
|
||||
|
||||
// --- 数字员工 ---
|
||||
export interface DigitalEmployee { code:string;name:string;description:string;installed:boolean }
|
||||
export interface EmployeeRun { employee_code:string;employee_name:string;status:string;latency_ms:number;retrieval_count:number;tool_count:number;error:string;created_at:string }
|
||||
export const fetchDigitalEmployees=()=>request.get<DigitalEmployee[]>({url:'/api/v1/portal/digital-employees'})
|
||||
export const runDigitalEmployee=(code:string,message:string)=>request.post<Record<string,unknown>>({url:`/api/v1/portal/digital-employees/${code}/chat`,params:{message}})
|
||||
export const fetchMyEmployeeRuns=(limit=20)=>request.get<EmployeeRun[]>({url:'/api/v1/portal/digital-employees/runs',params:{limit}})
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-semibold">数字员工</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">选择已授权数字员工直接对话;未安装的员工可在「资源市场」安装或「我的申请」申请权限</p>
|
||||
</div>
|
||||
|
||||
<ElTabs v-model="activeTab" @tab-change="load">
|
||||
<ElTabPane label="员工列表" name="list" />
|
||||
<ElTabPane label="调用记录" name="runs" />
|
||||
</ElTabs>
|
||||
|
||||
<div v-if="activeTab === 'list'" v-loading="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<ElCard v-for="item in employees" :key="item.code" shadow="hover">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<b>{{ item.name }}</b>
|
||||
<ElTag v-if="item.installed" type="success" size="small" class="ml-2">已安装</ElTag>
|
||||
</div>
|
||||
<code class="text-xs">{{ item.code }}</code>
|
||||
</div>
|
||||
<p class="text-g-500 mt-2 min-h-10 text-sm">{{ item.description || '暂无描述' }}</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<ElButton type="primary" size="small" :loading="chatting === item.code" @click="openChat(item)">开始对话</ElButton>
|
||||
<ElButton v-if="!item.installed" size="small" @click="$router.push('/portal/marketplace')">去市场安装</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
<ElEmpty v-if="!loading && !employees.length" description="暂无可用的数字员工" class="col-span-full" />
|
||||
</div>
|
||||
|
||||
<ElTable v-else v-loading="loading" :data="runs" row-key="created_at">
|
||||
<ElTableColumn prop="employee_name" label="数字员工" min-width="160" />
|
||||
<ElTableColumn prop="employee_code" label="编码" width="140" />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="row.status === 'success' ? 'success' : 'danger'">{{ row.status }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="latency_ms" label="耗时(ms)" width="100" />
|
||||
<ElTableColumn prop="retrieval_count" label="检索" width="80" />
|
||||
<ElTableColumn prop="tool_count" label="工具" width="80" />
|
||||
<ElTableColumn prop="error" label="错误" min-width="160" show-overflow-tooltip />
|
||||
<ElTableColumn prop="created_at" label="时间" width="180" />
|
||||
</ElTable>
|
||||
|
||||
<ElDialog v-model="chatVisible" :title="`对话 · ${chatTarget?.name || ''}`" width="640px">
|
||||
<div class="max-h-80 space-y-3 overflow-auto">
|
||||
<ElEmpty v-if="!chatMessages.length" description="发送第一条消息开始对话" :image-size="60" />
|
||||
<div v-for="(message, index) in chatMessages" :key="index" class="flex" :class="message.role === 'user' ? 'justify-end' : 'justify-start'">
|
||||
<div
|
||||
class="max-w-[85%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm"
|
||||
:class="message.role === 'user' ? 'bg-primary-600 text-white' : 'bg-g-100 text-g-800'"
|
||||
>{{ message.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<ElInput v-model="chatDraft" placeholder="输入消息" @keydown.enter.exact.prevent="sendChat" />
|
||||
<ElButton type="primary" :loading="chatting === chatTarget?.code" @click="sendChat">发送</ElButton>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
DigitalEmployee, EmployeeRun, fetchDigitalEmployees, fetchMyEmployeeRuns, runDigitalEmployee
|
||||
} from '@/api/portal'
|
||||
|
||||
const activeTab = ref('list')
|
||||
const loading = ref(false)
|
||||
const employees = ref<DigitalEmployee[]>([])
|
||||
const runs = ref<EmployeeRun[]>([])
|
||||
const chatVisible = ref(false)
|
||||
const chatTarget = ref<DigitalEmployee>()
|
||||
const chatMessages = ref<Array<{ role: string; content: string }>>([])
|
||||
const chatDraft = ref('')
|
||||
const chatting = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (activeTab.value === 'list') employees.value = await fetchDigitalEmployees()
|
||||
if (activeTab.value === 'runs') runs.value = await fetchMyEmployeeRuns()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openChat(item: DigitalEmployee) {
|
||||
chatTarget.value = item
|
||||
chatMessages.value = []
|
||||
chatDraft.value = ''
|
||||
chatVisible.value = true
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
const text = chatDraft.value.trim()
|
||||
if (!text || !chatTarget.value || chatting.value) return
|
||||
chatting.value = chatTarget.value.code
|
||||
chatMessages.value.push({ role: 'user', content: text })
|
||||
chatDraft.value = ''
|
||||
try {
|
||||
const response = await runDigitalEmployee(chatTarget.value.code, text)
|
||||
const choices = (response.choices as Array<{ message?: { content?: string } }>) || []
|
||||
const answer = choices[0]?.message?.content || '(无文本回答)'
|
||||
chatMessages.value.push({ role: 'assistant', content: answer })
|
||||
} catch (error) {
|
||||
chatMessages.value.push({ role: 'assistant', content: (error as Error)?.message || '调用失败' })
|
||||
} finally {
|
||||
chatting.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">个人渠道</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">自建 Webhook 渠道绑定已批准模型,外部系统可直接调用;用量归属你的账号</p>
|
||||
</div>
|
||||
<ElButton type="primary" @click="openCreate">新建渠道</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable v-loading="loading" :data="items" row-key="id">
|
||||
<ElTableColumn prop="name" label="名称" min-width="140" />
|
||||
<ElTableColumn prop="code" label="代码" width="150" />
|
||||
<ElTableColumn prop="model" label="绑定模型" min-width="160" />
|
||||
<ElTableColumn prop="provider_code" label="供应商" width="120" />
|
||||
<ElTableColumn prop="last_used_at" label="最近调用" width="180">
|
||||
<template #default="{ row }">{{ row.last_used_at || '—' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="90">
|
||||
<template #default="{ row }"><ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="primary" @click="showInbound(row)">调用地址</ElButton>
|
||||
<ElButton link type="warning" @click="regenerate(row)">重置令牌</ElButton>
|
||||
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElDialog v-model="visible" title="新建 Webhook 渠道" width="560px">
|
||||
<ElForm label-width="100px">
|
||||
<ElFormItem label="名称" required><ElInput v-model="form.name" maxlength="128" placeholder="例如 报表机器人" /></ElFormItem>
|
||||
<ElFormItem label="代码" required><ElInput v-model="form.code" placeholder="例如 my_bot(小写字母开头)" /></ElFormItem>
|
||||
<ElFormItem label="绑定模型" required>
|
||||
<ElSelect v-model="selectedModel" filterable class="w-full" placeholder="选择已批准的模型">
|
||||
<ElOptionGroup v-for="group in modelGroups" :key="group.provider" :label="group.provider">
|
||||
<ElOption v-for="m in group.models" :key="m.model" :label="m.model" :value="`${m.provider_code}\n${m.model}`" />
|
||||
</ElOptionGroup>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="visible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="submit">创建</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElDialog v-model="inboundVisible" :title="`调用地址 · ${current?.name || ''}`" width="620px">
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<div class="text-g-500 mb-1">入站 URL(POST JSON:{"message":"你好"})</div>
|
||||
<ElInput :model-value="inboundURL" readonly>
|
||||
<template #append><ElButton @click="copy(inboundURL)">复制</ElButton></template>
|
||||
</ElInput>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-g-500 mb-1">入站令牌(请求头 X-Inbound-Token 或 ?token=,只显示一次)</div>
|
||||
<ElInput :model-value="inboundToken" readonly>
|
||||
<template #append><ElButton @click="copy(inboundToken)">复制</ElButton></template>
|
||||
</ElInput>
|
||||
<p class="text-g-400 mt-1 text-xs">令牌仅创建/重置时显示;丢失请在列表中重置</p>
|
||||
</div>
|
||||
<ElAlert type="info" :closable="false" title="示例:curl -X POST https://你的域名/v1/personal-channels/CODE/inbound -H 'Content-Type: application/json' -H 'X-Inbound-Token: TOKEN' -d 「{"message":"你好"}」" />
|
||||
</div>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ChatModel, PersonalChannel,
|
||||
createPersonalChannel, deletePersonalChannel, fetchChatModels,
|
||||
fetchPersonalChannels, regeneratePersonalToken
|
||||
} from '@/api/portal'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const visible = ref(false)
|
||||
const inboundVisible = ref(false)
|
||||
const items = ref<PersonalChannel[]>([])
|
||||
const models = ref<ChatModel[]>([])
|
||||
const selectedModel = ref('')
|
||||
const current = ref<PersonalChannel>()
|
||||
const inboundURL = ref('')
|
||||
const inboundToken = ref('')
|
||||
const form = reactive({ name: '', code: '' })
|
||||
|
||||
const modelGroups = computed(() => {
|
||||
const groups: { provider: string; models: ChatModel[] }[] = []
|
||||
const index = new Map<string, ChatModel[]>()
|
||||
for (const m of models.value) {
|
||||
if (!index.has(m.provider_code)) index.set(m.provider_code, [])
|
||||
index.get(m.provider_code)!.push(m)
|
||||
}
|
||||
for (const [provider, list] of index) groups.push({ provider, models: list })
|
||||
return groups
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await fetchPersonalChannels()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openCreate() {
|
||||
models.value = await fetchChatModels()
|
||||
if (!models.value.length) {
|
||||
ElMessage.warning('暂无已批准的模型,请先在「模型权限」申请')
|
||||
return
|
||||
}
|
||||
form.name = ''
|
||||
form.code = ''
|
||||
selectedModel.value = ''
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.name.trim() || !form.code.trim() || !selectedModel.value) {
|
||||
ElMessage.warning('请填写名称、代码并选择模型')
|
||||
return
|
||||
}
|
||||
const [provider_code, model] = selectedModel.value.split('\n')
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await createPersonalChannel({ name: form.name.trim(), code: form.code.trim(), provider_code, model })
|
||||
visible.value = false
|
||||
await load()
|
||||
current.value = result.channel
|
||||
inboundURL.value = window.location.origin + result.inbound_url
|
||||
inboundToken.value = result.inbound_token
|
||||
inboundVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error((error as Error)?.message || '创建失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showInbound(row: PersonalChannel) {
|
||||
current.value = row
|
||||
inboundURL.value = `${window.location.origin}/v1/personal-channels/${row.code}/inbound`
|
||||
inboundToken.value = ''
|
||||
inboundVisible.value = true
|
||||
}
|
||||
|
||||
async function regenerate(row: PersonalChannel) {
|
||||
await ElMessageBox.confirm('重置后旧令牌立即失效,确定重置?', '重置入站令牌', { type: 'warning' })
|
||||
const result = await regeneratePersonalToken(row.id)
|
||||
current.value = row
|
||||
inboundURL.value = `${window.location.origin}/v1/personal-channels/${row.code}/inbound`
|
||||
inboundToken.value = result.inbound_token
|
||||
inboundVisible.value = true
|
||||
ElMessage.success('令牌已重置')
|
||||
}
|
||||
|
||||
async function remove(row: PersonalChannel) {
|
||||
await ElMessageBox.confirm('删除后该渠道立即停止响应,确定删除?', '删除渠道', { type: 'warning' })
|
||||
await deletePersonalChannel(row.id)
|
||||
items.value = items.value.filter((item) => item.id !== row.id)
|
||||
ElMessage.success('已删除')
|
||||
}
|
||||
|
||||
async function copy(value: string) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
ElMessage.success('已复制')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">我的申请</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">资源(MCP/Skill/数字员工)与渠道使用权限申请,审批通过后自动开通</p>
|
||||
</div>
|
||||
<ElButton type="primary" @click="visible = true">发起申请</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable v-loading="loading" :data="items" row-key="id">
|
||||
<ElTableColumn label="类型" width="130">
|
||||
<template #default="{ row }">{{ typeName(row.resource_type) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="resource_code" label="资源 / 渠道" min-width="180" />
|
||||
<ElTableColumn prop="reason" label="申请理由" min-width="220" show-overflow-tooltip />
|
||||
<ElTableColumn prop="created_at" label="申请时间" width="180" />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusText(row.status) }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="decision_note" label="审批意见" min-width="150" />
|
||||
<ElTableColumn label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton v-if="row.status === 'pending'" link type="danger" @click="cancel(row)">撤回</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElDialog v-model="visible" title="申请资源 / 渠道权限" width="560px">
|
||||
<ElForm label-width="110px">
|
||||
<ElFormItem label="类型" required>
|
||||
<ElSelect v-model="form.resource_type" class="w-full">
|
||||
<ElOption label="MCP 服务器" value="mcp_server" />
|
||||
<ElOption label="Skill" value="skill" />
|
||||
<ElOption label="数字员工" value="digital_employee" />
|
||||
<ElOption label="渠道" value="channel" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="资源代码" required>
|
||||
<ElInput v-model="form.resource_code" placeholder="例如 resource_code(需与管理员确认的唯一代码)" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="申请理由" required>
|
||||
<ElInput v-model="form.reason" type="textarea" :rows="4" maxlength="4000" show-word-limit />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="visible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="submit">提交</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ResourceRequest, cancelResourceRequest, createResourceRequest, fetchMyResourceRequests } from '@/api/portal'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const visible = ref(false)
|
||||
const items = ref<ResourceRequest[]>([])
|
||||
const form = reactive({ resource_type: 'mcp_server', resource_code: '', reason: '' })
|
||||
|
||||
const typeName = (v: string) => ({ mcp_server: 'MCP 服务器', skill: 'Skill', digital_employee: '数字员工', channel: '渠道' }[v] || v)
|
||||
const statusText = (v: string) => ({ pending: '待审批', approved: '已通过', rejected: '已驳回', cancelled: '已取消' }[v] || v)
|
||||
const statusType = (v: string) => (v === 'approved' ? 'success' : v === 'rejected' || v === 'cancelled' ? 'danger' : 'warning')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await fetchMyResourceRequests()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.resource_code.trim() || !form.reason.trim()) {
|
||||
ElMessage.warning('请填写资源代码与申请理由')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await createResourceRequest({ ...form, resource_code: form.resource_code.trim(), reason: form.reason.trim() })
|
||||
ElMessage.success('申请已提交,等待管理员审批')
|
||||
visible.value = false
|
||||
form.resource_code = ''
|
||||
form.reason = ''
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error((error as Error)?.message || '提交失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(row: ResourceRequest) {
|
||||
await ElMessageBox.confirm('撤回后需重新提交,确定撤回该申请?', '撤回申请', { type: 'warning' })
|
||||
await cancelResourceRequest(row.id)
|
||||
row.status = 'cancelled'
|
||||
ElMessage.success('已撤回')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user