diff --git a/RELEASE.md b/RELEASE.md index e1de728..b9b98bc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 加密,管理端仅系统管理员可写。 diff --git a/cmd/gateway-api/main.go b/cmd/gateway-api/main.go index 9d0ff7c..b03c4be 100644 --- a/cmd/gateway-api/main.go +++ b/cmd/gateway-api/main.go @@ -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{ diff --git a/docs/security-review-0.10.1.md b/docs/security-review-0.10.1.md index cfb9aff..b79f162 100644 --- a/docs/security-review-0.10.1.md +++ b/docs/security-review-0.10.1.md @@ -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`)。 diff --git a/docs/旗舰版需求规划与完成情况.md b/docs/旗舰版需求规划与完成情况.md index bccae02..7712424 100644 --- a/docs/旗舰版需求规划与完成情况.md +++ b/docs/旗舰版需求规划与完成情况.md @@ -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 环境后验收。 diff --git a/internal/channel/service.go b/internal/channel/service.go index 04a9f55..3a3da2e 100644 --- a/internal/channel/service.go +++ b/internal/channel/service.go @@ -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)) diff --git a/internal/identity/departments.go b/internal/identity/departments.go index 17c0adc..91b262b 100644 --- a/internal/identity/departments.go +++ b/internal/identity/departments.go @@ -24,24 +24,28 @@ var ( ) type Department struct { - ID string `json:"id"` - Code string `json:"code"` - Name string `json:"name"` - Description string `json:"description"` - ParentID *string `json:"parent_id"` - ParentName string `json:"parent_name,omitempty"` - Active bool `json:"active"` - UserCount int `json:"user_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + ParentID *string `json:"parent_id"` + ParentName string `json:"parent_name,omitempty"` + Active bool `json:"active"` + MaxAPIKeys int `json:"max_api_keys"` + MaxMonthlyTokens int64 `json:"max_monthly_tokens"` + UserCount int `json:"user_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type departmentInput struct { - Code string `json:"code"` - Name string `json:"name"` - Description string `json:"description"` - ParentID *string `json:"parent_id"` - Active *bool `json:"active"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + ParentID *string `json:"parent_id"` + Active *bool `json:"active"` + MaxAPIKeys *int `json:"max_api_keys"` + MaxMonthlyTokens *int64 `json:"max_monthly_tokens"` } func (h *ManagementHTTPHandler) listDepartments(writer http.ResponseWriter, request *http.Request) { @@ -94,6 +98,14 @@ func (h *ManagementHTTPHandler) updateDepartment(writer http.ResponseWriter, req if input.Active == nil { department.Active = current.Active } + // 租户配额同样按部分更新语义处理:未提供时保留当前值,避免"只改名" + // 的 PUT 把配额清零。 + if input.MaxAPIKeys == nil { + department.MaxAPIKeys = current.MaxAPIKeys + } + if input.MaxMonthlyTokens == nil { + department.MaxMonthlyTokens = current.MaxMonthlyTokens + } updated, err := h.service.repository.UpdateDepartment(request.Context(), department, actor.ID) if err != nil { h.writeDepartmentError(writer, err) @@ -126,7 +138,23 @@ func decodeDepartment(writer http.ResponseWriter, request *http.Request) (depart if input.Active != nil { active = *input.Active } - return input, Department{Code: input.Code, Name: input.Name, Description: input.Description, ParentID: parentID, Active: active}, true + maxAPIKeys := 0 + if input.MaxAPIKeys != nil { + if *input.MaxAPIKeys < 0 || *input.MaxAPIKeys > 1000000 { + apiresponse.Error(writer, http.StatusBadRequest, "Key 配额无效") + return input, Department{}, false + } + maxAPIKeys = *input.MaxAPIKeys + } + var maxMonthlyTokens int64 + if input.MaxMonthlyTokens != nil { + if *input.MaxMonthlyTokens < 0 || *input.MaxMonthlyTokens > 1e15 { + apiresponse.Error(writer, http.StatusBadRequest, "月 Token 配额无效") + return input, Department{}, false + } + maxMonthlyTokens = *input.MaxMonthlyTokens + } + return input, Department{Code: input.Code, Name: input.Name, Description: input.Description, ParentID: parentID, Active: active, MaxAPIKeys: maxAPIKeys, MaxMonthlyTokens: maxMonthlyTokens}, true } func (h *ManagementHTTPHandler) writeDepartmentError(writer http.ResponseWriter, err error) { @@ -152,7 +180,7 @@ func (r *Repository) ListDepartments(ctx context.Context) ([]Department, error) } rows, err := r.pool.Query(ctx, ` SELECT d.id::text, d.code, d.name, d.description, d.parent_id::text, - COALESCE(p.name, ''), d.active, + COALESCE(p.name, ''), d.active, d.max_api_keys, d.max_monthly_tokens, count(u.id) FILTER (WHERE u.active), d.created_at, d.updated_at FROM gateway.departments d LEFT JOIN gateway.departments p ON p.id = d.parent_id @@ -167,7 +195,8 @@ func (r *Repository) ListDepartments(ctx context.Context) ([]Department, error) for rows.Next() { var department Department if err := rows.Scan(&department.ID, &department.Code, &department.Name, &department.Description, - &department.ParentID, &department.ParentName, &department.Active, &department.UserCount, + &department.ParentID, &department.ParentName, &department.Active, &department.MaxAPIKeys, + &department.MaxMonthlyTokens, &department.UserCount, &department.CreatedAt, &department.UpdatedAt); err != nil { return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) } @@ -182,10 +211,11 @@ func (r *Repository) GetDepartment(ctx context.Context, id string) (Department, } var department Department err := r.pool.QueryRow(ctx, ` - SELECT id::text, code, name, description, parent_id::text, active, created_at, updated_at + SELECT id::text, code, name, description, parent_id::text, active, max_api_keys, max_monthly_tokens, created_at, updated_at FROM gateway.departments WHERE id = $1`, id).Scan( &department.ID, &department.Code, &department.Name, &department.Description, - &department.ParentID, &department.Active, &department.CreatedAt, &department.UpdatedAt, + &department.ParentID, &department.Active, &department.MaxAPIKeys, &department.MaxMonthlyTokens, + &department.CreatedAt, &department.UpdatedAt, ) return department, mapRepositoryError(err) } @@ -250,18 +280,18 @@ func (r *Repository) storeDepartment(ctx context.Context, department Department, } if creating { err = tx.QueryRow(ctx, ` - INSERT INTO gateway.departments (id, code, name, description, parent_id, active) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO gateway.departments (id, code, name, description, parent_id, active, max_api_keys, max_monthly_tokens) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING created_at, updated_at`, department.ID, department.Code, department.Name, - department.Description, department.ParentID, department.Active).Scan(&department.CreatedAt, &department.UpdatedAt) + department.Description, department.ParentID, department.Active, department.MaxAPIKeys, department.MaxMonthlyTokens).Scan(&department.CreatedAt, &department.UpdatedAt) } else { err = tx.QueryRow(ctx, ` UPDATE gateway.departments SET code = $2, name = $3, description = $4, parent_id = $5, - active = $6, updated_at = clock_timestamp() + active = $6, max_api_keys = $7, max_monthly_tokens = $8, updated_at = clock_timestamp() WHERE id = $1 RETURNING created_at, updated_at`, department.ID, department.Code, department.Name, - department.Description, department.ParentID, department.Active).Scan(&department.CreatedAt, &department.UpdatedAt) + department.Description, department.ParentID, department.Active, department.MaxAPIKeys, department.MaxMonthlyTokens).Scan(&department.CreatedAt, &department.UpdatedAt) } if err != nil { return Department{}, mapDepartmentError(err) diff --git a/internal/identity/http.go b/internal/identity/http.go index eaee7dd..02a8229 100644 --- a/internal/identity/http.go +++ b/internal/identity/http.go @@ -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": "定时任务"}}, diff --git a/internal/operations/admin_http.go b/internal/operations/admin_http.go index 8b4108b..6f7c49f 100644 --- a/internal/operations/admin_http.go +++ b/internal/operations/admin_http.go @@ -25,6 +25,9 @@ func NewAdminHTTPHandler(pool *pgxpool.Pool, identityService *identity.Service, h.mux.HandleFunc("GET /api/v1/admin/system-info", h.systemInfo) h.mux.HandleFunc("GET /api/v1/admin/monitoring/overview", h.overview) h.mux.HandleFunc("GET /api/v1/admin/tenants/overview", h.tenantsOverview) + h.mux.HandleFunc("GET /api/v1/admin/reports/tools", h.reportTools) + h.mux.HandleFunc("GET /api/v1/admin/reports/approvals", h.reportApprovals) + h.mux.HandleFunc("GET /api/v1/admin/reports/security", h.reportSecurity) h.mux.HandleFunc("POST /api/v1/admin/reload", h.reloadSnapshots) return h } @@ -84,17 +87,17 @@ func (h *AdminHTTPHandler) reloadSnapshots(w http.ResponseWriter, r *http.Reques apiresponse.OK(w, map[string]bool{"reloaded": true}) } - -// tenantsOverview 以部门为租户维度,汇总各租户的账号/Key/用量。 +// tenantsOverview 以部门为租户维度,汇总各租户的账号/Key/用量与配额。 func (h *AdminHTTPHandler) tenantsOverview(w http.ResponseWriter, r *http.Request) { if _, ok := h.account(w, r); !ok { return } - rows, err := h.pool.Query(r.Context(), `SELECT d.id::text,d.name, + rows, err := h.pool.Query(r.Context(), `SELECT d.id::text,d.name,d.max_api_keys,d.max_monthly_tokens, (SELECT count(*) FROM gateway.portal_users u WHERE u.department_id=d.id), (SELECT count(*) FROM gateway.api_keys k WHERE k.tenant_id=d.id AND k.enabled), (SELECT count(*) FROM gateway.audit_events a WHERE a.tenant_id=d.id AND a.recorded_at>=date_trunc('day',now())), - (SELECT COALESCE(sum(a.prompt_tokens+a.completion_tokens),0) FROM gateway.audit_events a WHERE a.tenant_id=d.id AND a.recorded_at>=date_trunc('day',now())) + (SELECT COALESCE(sum(a.prompt_tokens+a.completion_tokens),0) FROM gateway.audit_events a WHERE a.tenant_id=d.id AND a.recorded_at>=date_trunc('day',now())), + (SELECT COALESCE(sum(a.prompt_tokens+a.completion_tokens),0) FROM gateway.audit_events a WHERE a.tenant_id=d.id AND a.recorded_at>=date_trunc('month',now())) FROM gateway.departments d WHERE d.active ORDER BY d.name`) if err != nil { apiresponse.Error(w, http.StatusServiceUnavailable, "租户概览查询失败") @@ -102,17 +105,20 @@ func (h *AdminHTTPHandler) tenantsOverview(w http.ResponseWriter, r *http.Reques } defer rows.Close() type tenantRow struct { - ID string `json:"id"` - Name string `json:"name"` - PortalUsers int64 `json:"portal_users"` - EnabledKeys int64 `json:"enabled_api_keys"` - TodayRequests int64 `json:"today_requests"` - TodayTokens int64 `json:"today_tokens"` + ID string `json:"id"` + Name string `json:"name"` + MaxAPIKeys int64 `json:"max_api_keys"` + MaxMonthlyTokens int64 `json:"max_monthly_tokens"` + PortalUsers int64 `json:"portal_users"` + EnabledKeys int64 `json:"enabled_api_keys"` + TodayRequests int64 `json:"today_requests"` + TodayTokens int64 `json:"today_tokens"` + MonthTokens int64 `json:"month_tokens"` } items := []tenantRow{} for rows.Next() { var item tenantRow - if err := rows.Scan(&item.ID, &item.Name, &item.PortalUsers, &item.EnabledKeys, &item.TodayRequests, &item.TodayTokens); err != nil { + if err := rows.Scan(&item.ID, &item.Name, &item.MaxAPIKeys, &item.MaxMonthlyTokens, &item.PortalUsers, &item.EnabledKeys, &item.TodayRequests, &item.TodayTokens, &item.MonthTokens); err != nil { apiresponse.Error(w, http.StatusServiceUnavailable, "租户概览查询失败") return } @@ -124,3 +130,126 @@ func (h *AdminHTTPHandler) tenantsOverview(w http.ResponseWriter, r *http.Reques } apiresponse.OK(w, map[string]any{"tenants": items}) } + +// reportRange 解析 from/to 日期(YYYY-MM-DD),返回起止时间。 +func (h *AdminHTTPHandler) reportRange(r *http.Request) (from, to time.Time) { + now := time.Now().UTC() + to = now + from = now.AddDate(0, 0, -6) + if value := r.URL.Query().Get("from"); value != "" { + if parsed, err := time.Parse("2006-01-02", value); err == nil { + from = parsed + } + } + if value := r.URL.Query().Get("to"); value != "" { + if parsed, err := time.Parse("2006-01-02", value); err == nil { + to = parsed.AddDate(0, 0, 1) + } + } + return from, to +} + +// reportTools 工具维度统计:调用数/成功率/平均延迟。 +func (h *AdminHTTPHandler) reportTools(w http.ResponseWriter, r *http.Request) { + if _, ok := h.account(w, r); !ok { + return + } + from, to := h.reportRange(r) + rows, err := h.pool.Query(r.Context(), `SELECT t.code,t.name, + count(r.id), + count(r.id) FILTER (WHERE r.status='success'), + COALESCE(avg(r.latency_ms),0)::bigint + FROM gateway.tool_runs r JOIN gateway.tool_definitions t ON t.id=r.tool_id + WHERE r.created_at>=$1 AND r.created_at<$2 + GROUP BY t.code,t.name ORDER BY count(r.id) DESC LIMIT 100`, from, to) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "工具报表查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var code, name string + var total, success, avgLatency int64 + if err := rows.Scan(&code, &name, &total, &success, &avgLatency); err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "工具报表查询失败") + return + } + items = append(items, map[string]any{"code": code, "name": name, "requests": total, "success": success, "failed": total - success, "avg_latency_ms": avgLatency}) + } + apiresponse.OK(w, items) +} + +// reportApprovals 审批维度统计:模型/资源/工具申请的发起与审批结果。 +func (h *AdminHTTPHandler) reportApprovals(w http.ResponseWriter, r *http.Request) { + if _, ok := h.account(w, r); !ok { + return + } + from, to := h.reportRange(r) + rows, err := h.pool.Query(r.Context(), `SELECT 'model' AS kind,status,count(*) FROM gateway.model_access_requests WHERE created_at>=$1 AND created_at<$2 GROUP BY status + UNION ALL SELECT 'resource',status,count(*) FROM gateway.resource_access_requests WHERE created_at>=$1 AND created_at<$2 GROUP BY status + UNION ALL SELECT 'tool',status,count(*) FROM gateway.tool_approval_requests WHERE created_at>=$1 AND created_at<$2 GROUP BY status`, from, to) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "审批报表查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var kind, status string + var count int64 + if err := rows.Scan(&kind, &status, &count); err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "审批报表查询失败") + return + } + items = append(items, map[string]any{"kind": kind, "status": status, "count": count}) + } + apiresponse.OK(w, items) +} + +// reportSecurity 安全维度统计:登录成功/失败、锁定与来源 IP 分布。 +func (h *AdminHTTPHandler) reportSecurity(w http.ResponseWriter, r *http.Request) { + if _, ok := h.account(w, r); !ok { + return + } + from, to := h.reportRange(r) + rows, err := h.pool.Query(r.Context(), `SELECT success,count(*),count(DISTINCT ip) FROM gateway.login_logs WHERE created_at>=$1 AND created_at<$2 GROUP BY success + UNION ALL SELECT NULL,0,0 WHERE false`, from, to) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "安全报表查询失败") + return + } + defer rows.Close() + type loginStat struct { + Success bool `json:"success"` + Count int64 `json:"count"` + DistinctIPs int64 `json:"distinct_ips"` + } + stats := []loginStat{} + for rows.Next() { + var item loginStat + if err := rows.Scan(&item.Success, &item.Count, &item.DistinctIPs); err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "安全报表查询失败") + return + } + stats = append(stats, item) + } + rows.Close() + ips, err := h.pool.Query(r.Context(), `SELECT ip::text,count(*) FROM gateway.login_logs WHERE created_at>=$1 AND created_at<$2 GROUP BY ip ORDER BY count(*) DESC LIMIT 10`, from, to) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "安全报表查询失败") + return + } + defer ips.Close() + topIPs := []map[string]any{} + for ips.Next() { + var ip string + var count int64 + if err := ips.Scan(&ip, &count); err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "安全报表查询失败") + return + } + topIPs = append(topIPs, map[string]any{"ip": ip, "count": count}) + } + apiresponse.OK(w, map[string]any{"login_stats": stats, "top_ips": topIPs}) +} diff --git a/internal/portal/admin_http.go b/internal/portal/admin_http.go index 26ff272..3bc00b3 100644 --- a/internal/portal/admin_http.go +++ b/internal/portal/admin_http.go @@ -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) + } +} diff --git a/internal/portal/chat.go b/internal/portal/chat.go index fa6343f..2e58e34 100644 --- a/internal/portal/chat.go +++ b/internal/portal/chat.go @@ -80,14 +80,14 @@ func (s *Service) ensureChatCredential(ctx context.Context, account identity.Acc // ChatSession 是一条通用聊天会话。 type ChatSession struct { - ID string `json:"id"` - Title string `json:"title"` - ProviderCode string `json:"provider_code"` - Model string `json:"model"` - Status string `json:"status"` + ID string `json:"id"` + Title string `json:"title"` + ProviderCode string `json:"provider_code"` + Model string `json:"model"` + Status string `json:"status"` Messages []ConversationMessage `json:"messages,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } const chatSessionSelect = `SELECT id::text,title,provider_code,model,status,created_at,updated_at FROM gateway.portal_chat_sessions` diff --git a/internal/portal/credentials.go b/internal/portal/credentials.go index dfa837d..6f1b244 100644 --- a/internal/portal/credentials.go +++ b/internal/portal/credentials.go @@ -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 } diff --git a/internal/portal/employees.go b/internal/portal/employees.go new file mode 100644 index 0000000..ca4162e --- /dev/null +++ b/internal/portal/employees.go @@ -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() +} diff --git a/internal/portal/http.go b/internal/portal/http.go index 53fdb8f..b82b123 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -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) +} diff --git a/internal/portal/personal_channels.go b/internal/portal/personal_channels.go new file mode 100644 index 0000000..2b5e28f --- /dev/null +++ b/internal/portal/personal_channels.go @@ -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}$`) diff --git a/internal/portal/requests.go b/internal/portal/requests.go new file mode 100644 index 0000000..74a751c --- /dev/null +++ b/internal/portal/requests.go @@ -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)) +} diff --git a/internal/portal/requests_test.go b/internal/portal/requests_test.go new file mode 100644 index 0000000..443720d --- /dev/null +++ b/internal/portal/requests_test.go @@ -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) + } + } +} diff --git a/internal/provider/url_validator.go b/internal/provider/url_validator.go index aa73f39..078b1b0 100644 --- a/internal/provider/url_validator.go +++ b/internal/provider/url_validator.go @@ -50,20 +50,20 @@ func ValidateBaseURL(ctx context.Context, raw string, allowPrivate bool) (string // 前缀可以把 IPv6 地址桥接回内网 IPv4,因此必须一并拦截。 var specialPurposePrefixes = []netip.Prefix{ // IPv4 特殊用途网段(RFC 6890 及其更新)。 - netip.MustParsePrefix("100.64.0.0/10"), // CGNAT 共享地址空间 RFC 6598 - netip.MustParsePrefix("192.0.0.0/24"), // IETF 协议保留 - netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1 文档 - netip.MustParsePrefix("192.88.99.0/24"), // 6to4 中继任播(已弃用) - netip.MustParsePrefix("198.18.0.0/15"), // 基准测试 RFC 2544 + netip.MustParsePrefix("100.64.0.0/10"), // CGNAT 共享地址空间 RFC 6598 + netip.MustParsePrefix("192.0.0.0/24"), // IETF 协议保留 + netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1 文档 + netip.MustParsePrefix("192.88.99.0/24"), // 6to4 中继任播(已弃用) + netip.MustParsePrefix("198.18.0.0/15"), // 基准测试 RFC 2544 netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2 文档 - netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3 文档 - netip.MustParsePrefix("240.0.0.0/4"), // 保留(含广播地址) + netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3 文档 + netip.MustParsePrefix("240.0.0.0/4"), // 保留(含广播地址) // IPv6 特殊用途网段。 - netip.MustParsePrefix("2001:db8::/32"), // 文档地址 - netip.MustParsePrefix("2001:10::/28"), // ORCHID - netip.MustParsePrefix("2002::/16"), // 6to4:内嵌 IPv4,可桥接回内网 - netip.MustParsePrefix("64:ff9b::/96"), // NAT64 知名前缀 - netip.MustParsePrefix("64:ff9b:1::/48"), // NAT64 本地使用前缀 + netip.MustParsePrefix("2001:db8::/32"), // 文档地址 + netip.MustParsePrefix("2001:10::/28"), // ORCHID + netip.MustParsePrefix("2002::/16"), // 6to4:内嵌 IPv4,可桥接回内网 + netip.MustParsePrefix("64:ff9b::/96"), // NAT64 知名前缀 + netip.MustParsePrefix("64:ff9b:1::/48"), // NAT64 本地使用前缀 } // IsPublicAddress 报告 ip 是否为可安全访问的公网单播地址。IPv4-mapped diff --git a/internal/workbench/admin_http.go b/internal/workbench/admin_http.go index bb17267..b5428fc 100644 --- a/internal/workbench/admin_http.go +++ b/internal/workbench/admin_http.go @@ -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 { diff --git a/internal/workbench/envvars.go b/internal/workbench/envvars.go index abcbe66..8c59ded 100644 --- a/internal/workbench/envvars.go +++ b/internal/workbench/envvars.go @@ -2,10 +2,10 @@ package workbench import ( "context" - "regexp" "encoding/json" "errors" "net/http" + "regexp" "strings" "aigateway.local/core/internal/identity" @@ -102,39 +102,140 @@ func (s *EnvVarService) Decrypt(ctx context.Context, userID, key string) (string return string(plaintext), true, nil } -// MergeVariables 把用户环境变量合并进请求变量(请求未提供的键)。 +// MergeVariables 把平台变量与个人变量合并进请求变量(请求已提供的键保持优先, +// 个人变量覆盖平台默认值)。 func (s *EnvVarService) MergeVariables(ctx context.Context, userID string, variables map[string]any) error { - if userID == "" || len(variables) >= 100 { + if s == nil || s.pool == nil || s.cipher == nil { return nil } - rows, err := s.pool.Query(ctx, `SELECT key,encrypted_value,value_kek_version FROM gateway.user_env_vars WHERE portal_user_id=$1 LIMIT 200`, userID) + if len(variables) >= 100 { + return nil + } + merged, err := s.mergeAll(ctx, userID, variables) if err != nil { return err } - defer rows.Close() - type pair struct{ key string; value []byte; version int } - pairs := []pair{} - for rows.Next() { - var p pair - if err := rows.Scan(&p.key, &p.value, &p.version); err != nil { - return err + for key, value := range merged { + variables[key] = value + } + return nil +} + +func (s *EnvVarService) mergeAll(ctx context.Context, userID string, variables map[string]any) (map[string]any, error) { + out := map[string]any{} + type pair struct { + key string + value []byte + version int + } + collect := func(query string, args ...any) ([]pair, error) { + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err } - pairs = append(pairs, p) + defer rows.Close() + pairs := []pair{} + for rows.Next() { + var p pair + if err := rows.Scan(&p.key, &p.value, &p.version); err != nil { + return nil, err + } + pairs = append(pairs, p) + } + return pairs, rows.Err() } - if err := rows.Err(); err != nil { - return err + // 平台变量(全部,最多 200)。 + platform, err := collect(`SELECT key,encrypted_value,value_kek_version FROM gateway.platform_env_vars ORDER BY key LIMIT 200`) + if err != nil { + return nil, err } - for _, p := range pairs { + for _, p := range platform { if _, exists := variables[p.key]; exists { continue } - plaintext, err := s.cipher.Decrypt(p.value, p.version) - if err != nil { + plaintext, decryptErr := s.cipher.Decrypt(p.value, p.version) + if decryptErr != nil { continue } - variables[p.key] = string(plaintext) + out[p.key] = string(plaintext) } - return nil + // 个人变量覆盖平台默认值。 + if userID != "" { + personal, err := collect(`SELECT key,encrypted_value,value_kek_version FROM gateway.user_env_vars WHERE portal_user_id=$1 ORDER BY key LIMIT 200`, userID) + if err != nil { + return nil, err + } + for _, p := range personal { + if _, exists := variables[p.key]; exists { + continue + } + plaintext, decryptErr := s.cipher.Decrypt(p.value, p.version) + if decryptErr != nil { + continue + } + out[p.key] = string(plaintext) + } + } + return out, nil +} + +// PlatformList 返回平台环境变量(不含值)。 +func (s *EnvVarService) PlatformList(ctx context.Context) ([]map[string]any, error) { + if s == nil || s.pool == nil { + return nil, errors.New("环境变量服务不可用") + } + rows, err := s.pool.Query(ctx, `SELECT key,octet_length(encrypted_value)>0,description,updated_at FROM gateway.platform_env_vars ORDER BY key`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var key, description string + var hasValue bool + var updatedAt any + if err := rows.Scan(&key, &hasValue, &description, &updatedAt); err != nil { + return nil, err + } + items = append(items, map[string]any{"key": key, "configured": hasValue, "description": description, "updated_at": updatedAt}) + } + return items, rows.Err() +} + +// PlatformUpsert 设置平台环境变量;value 为空时删除。 +func (s *EnvVarService) PlatformUpsert(ctx context.Context, actorID, key, value, description string) error { + if s == nil || s.pool == nil || s.cipher == nil { + return errors.New("环境变量服务不可用") + } + key = strings.TrimSpace(key) + if key == "" || len(key) > 128 || !envKeyPattern.MatchString(key) { + return errors.New("变量名必须以字母开头,可含字母/数字/下划线,最长 128 字符") + } + if len(description) > 512 { + return errors.New("描述过长") + } + value = strings.TrimSpace(value) + if value == "" { + tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.platform_env_vars WHERE key=$1`, key) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errors.New("变量不存在") + } + return nil + } + if len(value) > 4096 { + return errors.New("变量值过长") + } + encrypted, version, err := s.cipher.Encrypt([]byte(value)) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, `INSERT INTO gateway.platform_env_vars(key,encrypted_value,value_kek_version,description,updated_by) VALUES($1,$2,$3,$4,$5) + ON CONFLICT(key) DO UPDATE SET encrypted_value=$2,value_kek_version=$3,description=$4,updated_by=$5,updated_at=clock_timestamp()`, + key, encrypted, version, description, actorID) + return err } var envKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,127}$`) @@ -210,3 +311,80 @@ func (h *EnvVarHTTPHandler) delete(w http.ResponseWriter, r *http.Request) { } apiresponse.OK(w, map[string]bool{"deleted": true}) } + +// AdminEnvVarHTTPHandler 平台环境变量管理(系统管理员)。 +type AdminEnvVarHTTPHandler struct { + service *EnvVarService + identity *identity.Service + mux *http.ServeMux +} + +func NewAdminEnvVarHTTPHandler(service *EnvVarService, identityService *identity.Service) *AdminEnvVarHTTPHandler { + h := &AdminEnvVarHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/env-vars", h.list) + h.mux.HandleFunc("PUT /api/v1/admin/env-vars/{key}", h.upsert) + h.mux.HandleFunc("DELETE /api/v1/admin/env-vars/{key}", h.delete) + return h +} + +func (h *AdminEnvVarHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.mux.ServeHTTP(w, r) +} + +func (h *AdminEnvVarHTTPHandler) admin(w http.ResponseWriter, r *http.Request) (identity.Account, bool) { + account, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效或已过期") + return identity.Account{}, false + } + if !identity.HasPermission(account, identity.PermissionSystemManage) { + apiresponse.Error(w, http.StatusForbidden, "无系统管理权限") + return identity.Account{}, false + } + return account, true +} + +func (h *AdminEnvVarHTTPHandler) list(w http.ResponseWriter, r *http.Request) { + if _, ok := h.admin(w, r); !ok { + return + } + items, err := h.service.PlatformList(r.Context()) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "环境变量查询失败") + return + } + apiresponse.OK(w, items) +} + +func (h *AdminEnvVarHTTPHandler) upsert(w http.ResponseWriter, r *http.Request) { + admin, ok := h.admin(w, r) + if !ok { + return + } + var input struct { + Value string `json:"value"` + Description string `json:"description"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil { + apiresponse.Error(w, http.StatusBadRequest, "请求格式无效") + return + } + if err := h.service.PlatformUpsert(r.Context(), admin.ID, r.PathValue("key"), input.Value, input.Description); err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, map[string]bool{"saved": true}) +} + +func (h *AdminEnvVarHTTPHandler) delete(w http.ResponseWriter, r *http.Request) { + if _, ok := h.admin(w, r); !ok { + return + } + if err := h.service.PlatformUpsert(r.Context(), "", r.PathValue("key"), "", ""); err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} diff --git a/internal/workbench/inbox.go b/internal/workbench/inbox.go index 7d14489..6c23d71 100644 --- a/internal/workbench/inbox.go +++ b/internal/workbench/inbox.go @@ -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 } diff --git a/internal/workbench/inbox_test.go b/internal/workbench/inbox_test.go index 1806716..f5fdbbe 100644 --- a/internal/workbench/inbox_test.go +++ b/internal/workbench/inbox_test.go @@ -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 { diff --git a/internal/workbench/tools.go b/internal/workbench/tools.go index b9dc479..5c6b534 100644 --- a/internal/workbench/tools.go +++ b/internal/workbench/tools.go @@ -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 diff --git a/internal/workbench/types.go b/internal/workbench/types.go index 820f479..5267e45 100644 --- a/internal/workbench/types.go +++ b/internal/workbench/types.go @@ -74,20 +74,20 @@ type PromptInput struct { } type KnowledgeBase struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - RetrievalMode string `json:"retrieval_mode"` - ChunkSize int `json:"chunk_size"` - ChunkOverlap int `json:"chunk_overlap"` - DepartmentIDs []string `json:"department_ids"` - Enabled bool `json:"enabled"` - Revision int64 `json:"revision"` - DocumentCount int `json:"document_count"` - ChunkCount int `json:"chunk_count"` - VectorizedChunkCount int `json:"vectorized_chunk_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + RetrievalMode string `json:"retrieval_mode"` + ChunkSize int `json:"chunk_size"` + ChunkOverlap int `json:"chunk_overlap"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` + Revision int64 `json:"revision"` + DocumentCount int `json:"document_count"` + ChunkCount int `json:"chunk_count"` + VectorizedChunkCount int `json:"vectorized_chunk_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type KnowledgeDocument struct { @@ -124,6 +124,8 @@ type Tool struct { InputSchema json.RawMessage `json:"input_schema"` TimeoutSeconds int `json:"timeout_seconds"` DepartmentIDs []string `json:"department_ids"` + RateLimitRPM int `json:"rate_limit_rpm"` + ApprovalRequired bool `json:"approval_required"` Enabled bool `json:"enabled"` HasSecretHeaders bool `json:"has_secret_headers"` Revision int64 `json:"revision"` @@ -139,6 +141,8 @@ type ToolInput struct { InputSchema json.RawMessage TimeoutSeconds int DepartmentIDs []string + RateLimitRPM int + ApprovalRequired bool Enabled bool } diff --git a/migrations/000042_governance.sql b/migrations/000042_governance.sql new file mode 100644 index 0000000..2ed1e1a --- /dev/null +++ b/migrations/000042_governance.sql @@ -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); diff --git a/migrations/000043_platform_env_vars.sql b/migrations/000043_platform_env_vars.sql new file mode 100644 index 0000000..bfc7c6d --- /dev/null +++ b/migrations/000043_platform_env_vars.sql @@ -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() +); diff --git a/migrations/000044_personal_channels.sql b/migrations/000044_personal_channels.sql new file mode 100644 index 0000000..823d114 --- /dev/null +++ b/migrations/000044_personal_channels.sql @@ -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); diff --git a/migrations/000045_tenant_quotas.sql b/migrations/000045_tenant_quotas.sql new file mode 100644 index 0000000..926113b --- /dev/null +++ b/migrations/000045_tenant_quotas.sql @@ -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.'; diff --git a/web/apps/admin/src/api/governance.ts b/web/apps/admin/src/api/governance.ts index d8e8315..8ff69c0 100644 --- a/web/apps/admin/src/api/governance.ts +++ b/web/apps/admin/src/api/governance.ts @@ -13,3 +13,30 @@ export const updateFactPolicy=(id:string,params:FactPolicy)=>request.putrequest.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({url:'/api/v1/admin/resource-requests',params:{status}}) +export const decideResourceRequest=(id:string,status:'approve'|'reject',note:string)=>request.post({url:`/api/v1/admin/resource-requests/${id}/${status}`,params:{note}}) +export const fetchToolApprovals=(status='')=>request.get({url:'/api/v1/admin/tool-approvals',params:{status}}) +export const decideToolApproval=(id:string,status:'approved'|'rejected',note:string)=>request.post({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({url:'/api/v1/admin/reports/tools',params}) +export const fetchApprovalReport=(params:{from:string;to:string})=>request.get({url:'/api/v1/admin/reports/approvals',params}) +export const fetchSecurityReport=(params:{from:string;to:string})=>request.get({url:'/api/v1/admin/reports/security',params}) + +export interface PlatformEnvVar { key:string;configured:boolean;description:string;updated_at:string } +export const fetchPlatformEnvVars=()=>request.get({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}`}) diff --git a/web/apps/admin/src/api/identities.ts b/web/apps/admin/src/api/identities.ts index 805371d..9786e24 100644 --- a/web/apps/admin/src/api/identities.ts +++ b/web/apps/admin/src/api/identities.ts @@ -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 { diff --git a/web/apps/admin/src/api/workbench.ts b/web/apps/admin/src/api/workbench.ts index d81fb4f..b84fdff 100644 --- a/web/apps/admin/src/api/workbench.ts +++ b/web/apps/admin/src/api/workbench.ts @@ -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({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;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;input_schema:Record;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;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;input_schema:Record;timeout_seconds:number;department_ids:string[];rate_limit_rpm:number;approval_required:boolean;enabled:boolean} export const fetchTools=()=>request.get({url:'/api/v1/admin/tools'}) export const createTool=(params:ToolInput)=>request.post({url:'/api/v1/admin/tools',params}) export const updateTool=(id:string,params:ToolInput)=>request.put({url:`/api/v1/admin/tools/${id}`,params}) diff --git a/web/apps/admin/src/views/gateway/reports/index.vue b/web/apps/admin/src/views/gateway/reports/index.vue index 860a298..2624aef 100644 --- a/web/apps/admin/src/views/gateway/reports/index.vue +++ b/web/apps/admin/src/views/gateway/reports/index.vue @@ -59,12 +59,45 @@ + + + + + + + + + + + + + + + + + + + + + + +
+
登录成功
{{ securityStats.success || 0 }}
+
登录失败
{{ securityStats.failed || 0 }}
+
来源 IP 数
{{ securityStats.ips || 0 }}
+
+ + + + +
+ diff --git a/web/apps/admin/src/views/system/approvals/index.vue b/web/apps/admin/src/views/system/approvals/index.vue new file mode 100644 index 0000000..ac88493 --- /dev/null +++ b/web/apps/admin/src/views/system/approvals/index.vue @@ -0,0 +1,125 @@ + + diff --git a/web/apps/admin/src/views/system/platform-env-vars/index.vue b/web/apps/admin/src/views/system/platform-env-vars/index.vue new file mode 100644 index 0000000..887e7ab --- /dev/null +++ b/web/apps/admin/src/views/system/platform-env-vars/index.vue @@ -0,0 +1,105 @@ + + diff --git a/web/apps/admin/src/views/system/user/index.vue b/web/apps/admin/src/views/system/user/index.vue index 9b91a4b..c743caa 100644 --- a/web/apps/admin/src/views/system/user/index.vue +++ b/web/apps/admin/src/views/system/user/index.vue @@ -223,6 +223,14 @@ + + +
该租户(部门)可绑定的网关 API Key 上限,0 = 不限
+
+ + +
该租户每月 Token 用量上限,0 = 不限
+
@@ -395,7 +403,7 @@ department_id: undefined }) const departmentForm = reactive({ - 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({ 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 } diff --git a/web/apps/portal/src/api/portal.ts b/web/apps/portal/src/api/portal.ts index 8448e62..d81c1c1 100644 --- a/web/apps/portal/src/api/portal.ts +++ b/web/apps/portal/src/api/portal.ts @@ -89,3 +89,23 @@ export const setSecurityPrefs=(login_notify:boolean)=>request.put<{login_notify: export const fetchProviderBindings=()=>request.get({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({url:'/api/v1/portal/resource-requests'}) +export const createResourceRequest=(params:{resource_type:string;resource_code:string;reason:string})=>request.post({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({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({url:'/api/v1/portal/digital-employees'}) +export const runDigitalEmployee=(code:string,message:string)=>request.post>({url:`/api/v1/portal/digital-employees/${code}/chat`,params:{message}}) +export const fetchMyEmployeeRuns=(limit=20)=>request.get({url:'/api/v1/portal/digital-employees/runs',params:{limit}}) diff --git a/web/apps/portal/src/views/portal/digital-employees/index.vue b/web/apps/portal/src/views/portal/digital-employees/index.vue new file mode 100644 index 0000000..6268bbc --- /dev/null +++ b/web/apps/portal/src/views/portal/digital-employees/index.vue @@ -0,0 +1,113 @@ + + diff --git a/web/apps/portal/src/views/portal/personal-channels/index.vue b/web/apps/portal/src/views/portal/personal-channels/index.vue new file mode 100644 index 0000000..6207b48 --- /dev/null +++ b/web/apps/portal/src/views/portal/personal-channels/index.vue @@ -0,0 +1,173 @@ + + diff --git a/web/apps/portal/src/views/portal/requests/index.vue b/web/apps/portal/src/views/portal/requests/index.vue new file mode 100644 index 0000000..08a76b4 --- /dev/null +++ b/web/apps/portal/src/views/portal/requests/index.vue @@ -0,0 +1,104 @@ + +