From 4563979a155e966c71710a87ee164259222347f3 Mon Sep 17 00:00:00 2001 From: superidou Date: Thu, 13 Aug 2026 11:54:34 +0800 Subject: [PATCH] =?UTF-8?q?0.11.1:=20=E6=97=97=E8=88=B0=E7=89=88=E5=AE=8C?= =?UTF-8?q?=E5=96=84(=E8=B5=84=E6=BA=90=E6=9D=83=E9=99=90=E7=AD=89?= =?UTF-8?q?=E7=BA=A7/=E4=B8=AA=E4=BA=BA=E7=8E=AF=E5=A2=83=E5=8F=98?= =?UTF-8?q?=E9=87=8F/=E6=94=B6=E8=97=8F/=E4=BC=81=E4=B8=9A=E6=8A=A5?= =?UTF-8?q?=E8=A1=A8/=E7=A7=9F=E6=88=B7=E6=A6=82=E8=A7=88/ARM64=E5=8F=91?= =?UTF-8?q?=E5=B8=83/=E5=A4=9A=E6=B8=A0=E9=81=93=E6=8E=A5=E5=85=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 000035-000037(权限等级/环境变量/渠道) - 新增 internal/channel 渠道抽象层(webhook/企微/钉钉/飞书) - 全部功能端到端验证通过(25 包单测) --- cmd/gateway-api/main.go | 28 ++ deploy/PRODUCTION.md | 8 + docs/security-review-0.10.1.md | 22 + internal/channel/http.go | 214 +++++++++ internal/channel/service.go | 442 ++++++++++++++++++ internal/channel/util.go | 7 + internal/identity/http.go | 8 + internal/operations/admin_http.go | 42 ++ internal/portal/conversations.go | 7 + internal/portal/http.go | 3 +- internal/portal/service.go | 8 +- internal/workbench/envvars.go | 212 +++++++++ internal/workbench/marketplace.go | 14 +- .../workbench/marketplace_integration_test.go | 4 +- internal/workbench/runtime_http.go | 5 + migrations/000035_permission_level.sql | 5 + migrations/000036_user_env_vars.sql | 10 + migrations/000037_channels.sql | 17 + scripts/publish-images.sh | 18 + .../core/layouts/art-favorites/index.vue | 80 ++++ .../core/layouts/art-header-bar/index.vue | 3 + .../src/views/gateway/channels/index.vue | 203 ++++++++ .../admin/src/views/gateway/reports/index.vue | 150 ++++++ .../admin/src/views/gateway/tenants/index.vue | 47 ++ .../src/views/portal/env-vars/index.vue | 114 +++++ 25 files changed, 1664 insertions(+), 7 deletions(-) create mode 100644 internal/channel/http.go create mode 100644 internal/channel/service.go create mode 100644 internal/channel/util.go create mode 100644 internal/workbench/envvars.go create mode 100644 migrations/000035_permission_level.sql create mode 100644 migrations/000036_user_env_vars.sql create mode 100644 migrations/000037_channels.sql create mode 100755 scripts/publish-images.sh create mode 100644 web/apps/admin/src/components/core/layouts/art-favorites/index.vue create mode 100644 web/apps/admin/src/views/gateway/channels/index.vue create mode 100644 web/apps/admin/src/views/gateway/reports/index.vue create mode 100644 web/apps/admin/src/views/gateway/tenants/index.vue create mode 100644 web/apps/portal/src/views/portal/env-vars/index.vue diff --git a/cmd/gateway-api/main.go b/cmd/gateway-api/main.go index 42c4cbb..10ba47c 100644 --- a/cmd/gateway-api/main.go +++ b/cmd/gateway-api/main.go @@ -11,6 +11,7 @@ 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/audit" @@ -255,6 +256,15 @@ func main() { logger.Error("notification encryption initialization failed", "error", err) os.Exit(1) } + envVarCipher, err := cryptox.NewKeyring( + cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "user-env-var", + ) + if err != nil { + logger.Error("user env var encryption initialization failed", "error", err) + os.Exit(1) + } + envVarService := workbench.NewEnvVarService(db, envVarCipher) + envVarHandler := workbench.NewEnvVarHTTPHandler(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) @@ -319,6 +329,16 @@ func main() { logger.Error("application runtime credential initialization failed", "error", err) os.Exit(1) } + channelCipher, err := cryptox.NewKeyring( + cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "channel-config", + ) + if err != nil { + logger.Error("channel encryption initialization failed", "error", err) + os.Exit(1) + } + channelService := channel.NewService(db, cfg.Scheduler.GatewayBaseURL, channelCipher, logger) + channelHandler := channel.NewHTTPHandler(channelService, identityService) + channelInboundHandler := channel.NewInboundHTTPHandler(channelService) shadowMiddleware := shadow.New(cfg.Shadow, logger) governedGateway := shadowMiddleware.Wrap(proxy) workbenchRuntime := workbench.NewRuntimeHTTPHandler(workbenchService, toolService, workbench.NewRetriever(workbenchService, workbenchService.Embedder()), apiKeyAuthenticator, governedGateway, workbench.MarketplaceDeps{ @@ -330,11 +350,13 @@ func main() { }) workbenchRuntime.SetLogger(logger) workbenchRuntime.SetTraceStore(traceStore) + workbenchRuntime.SetEnvVarService(envVarService) // Wire the fact-check engine: the admin fact-check settings/policies UI now // actually governs application answers instead of being inert configuration. factCheckEngine := factcheck.NewEngine(db, workbench.NewFactCheckRetriever(workbench.NewRetriever(workbenchService, workbenchService.Embedder())), logger) workbenchRuntime.SetFactCheckEngine(factCheckEngine) portalService := portal.NewService(db, workbenchService, toolService, identityService) + portalService.SetEnvVarService(envVarService) portalService.SetApplicationRuntime(portal.NewRuntimeCredentials(db, apiKeyRepository, applicationKeyCipher), workbenchRuntime) portalService.SetMarketplace(marketplaceService) portalHandler := portal.NewHTTPHandler(portalService, identityService) @@ -399,6 +421,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/tenants/", operationsHandler) controlMux.Handle("/api/v1/admin/files", filesAdminHandler) controlMux.Handle("/api/v1/admin/files/", filesAdminHandler) controlMux.Handle("/api/v1/portal/files", filesPortalHandler) @@ -417,6 +440,8 @@ func main() { controlMux.Handle("/api/v1/admin/agent-nodes", agentNodeHandler) controlMux.Handle("/api/v1/admin/agent-nodes/", agentNodeHandler) controlMux.Handle("/api/v1/agent/nodes/", agentNodeHandler) + controlMux.Handle("/api/v1/admin/channels", channelHandler) + controlMux.Handle("/api/v1/admin/channels/", channelHandler) controlMux.Handle("/api/v1/admin/assistant", assistantHandler) controlMux.Handle("/api/v1/admin/assistant/", assistantHandler) controlMux.Handle("/api/v1/admin/license", licenseHandler) @@ -440,6 +465,8 @@ func main() { controlMux.Handle("/api/v1/portal/logs", portalHandler) controlMux.Handle("/api/v1/portal/logs/", portalHandler) controlMux.Handle("/api/v1/portal/model-requests", portalHandler) + controlMux.Handle("/api/v1/portal/env-vars", envVarHandler) + controlMux.Handle("/api/v1/portal/env-vars/", envVarHandler) controlMux.Handle("/api/v1/portal/memories", memoryHandler) controlMux.Handle("/api/v1/portal/memories/", memoryHandler) controlMux.Handle("/api/v1/portal/model-requests/", portalHandler) @@ -459,6 +486,7 @@ func main() { publicMux.Handle("/v1/skills/", workbenchRuntime) publicMux.Handle("/v1/mcp-servers", workbenchRuntime) publicMux.Handle("/v1/mcp-servers/", workbenchRuntime) + publicMux.Handle("/v1/channels/", channelInboundHandler) publicMux.Handle("/v1/digital-employees/", workbenchRuntime) publicMux.Handle("/v1/", governedGateway) server := httpserver.New(httpserver.Dependencies{ diff --git a/deploy/PRODUCTION.md b/deploy/PRODUCTION.md index de3df11..2d80797 100644 --- a/deploy/PRODUCTION.md +++ b/deploy/PRODUCTION.md @@ -116,3 +116,11 @@ network. When using an external PostgreSQL or Redis service, require TLS and use 并保留历史 keyring;不要直接更换 `CREDENTIAL_MASTER_KEY` 值。 - 若误换 key:管理端 Provider 列表会降级显示「凭据无法解密」警示(不会 让整个页面报错),需重新保存各 Provider 的 API Key 恢复。 + +### 多架构发布(ARM64) + +- 后端镜像支持 amd64 + arm64 交叉编译(CGO_ENABLED=0): + `./scripts/publish-images.sh registry.example.com/ai-gateway:0.11.0` + (需先 `docker buildx create --use`)。 +- 前端镜像(node/nginx)由 Docker Hub 提供多架构基础镜像,可直接 buildx 构建。 +- ARM64 节点部署:arm64 设备上 `docker compose up -d` 即可拉取 arm64 变体。 diff --git a/docs/security-review-0.10.1.md b/docs/security-review-0.10.1.md index 87cf595..565815e 100644 --- a/docs/security-review-0.10.1.md +++ b/docs/security-review-0.10.1.md @@ -404,3 +404,25 @@ PostgreSQL `text` 列拒绝写入 → 重试循环永远失败;inbox 的 Go `l 重构。 - ARM64 安装包:构建已可交叉编译(CGO_ENABLED=0),发布流程待配置 buildx。 - 个人环境变量注入、资源权限等级(查看/仅使用/管理)、收藏:前端增强。 + +--- + +# 追加:旗舰版功能完善第二轮(2026-08-13) + +1. **资源权限等级**(迁移 000035):marketplace 安装支持 + view/use/manage 三级权限,门户"我的资源"展示。 +2. **个人环境变量**(迁移 000036 + `internal/workbench/envvars.go`): + 门户配置 key-value(加密存储),对话/应用运行时自动合并进请求变量。 +3. **管理平台收藏**(`art-favorites` 组件):顶栏星标收藏当前页, + 弹窗快捷跳转(localStorage)。 +4. **企业报表**(`gateway/reports`):按供应商/模型/日三个维度汇总 + 用量与成本。 +5. **租户概览**(`/api/v1/admin/tenants/overview`):以部门为租户维度, + 统计账号/Key/今日用量,支持多租户管理视角。 +6. **ARM64 发布流程**:`scripts/publish-images.sh`(buildx 多架构推送) + + PRODUCTION.md 说明。 +7. **渠道接入**(迁移 000037 + `internal/channel`):统一渠道抽象, + 支持通用 Webhook/企业微信/钉钉/飞书;入站 + `POST /v1/channels/{code}/inbound`(企微签名校验/钉钉加签工具), + 绑定模型应答后按平台协议回复(企微应用消息/钉钉机器人/飞书应用), + 管理端渠道 CRUD + 连通性测试。 diff --git a/internal/channel/http.go b/internal/channel/http.go new file mode 100644 index 0000000..609edd6 --- /dev/null +++ b/internal/channel/http.go @@ -0,0 +1,214 @@ +package channel + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" +) + +// HTTPHandler 管理端渠道 CRUD。 +type HTTPHandler struct { + service *Service + identity *identity.Service + mux *http.ServeMux +} + +func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHandler { + h := &HTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/channels", h.list) + h.mux.HandleFunc("POST /api/v1/admin/channels", h.save) + h.mux.HandleFunc("PUT /api/v1/admin/channels/{id}", h.save) + h.mux.HandleFunc("DELETE /api/v1/admin/channels/{id}", h.delete) + h.mux.HandleFunc("POST /api/v1/admin/channels/{id}/test", h.test) + return h +} + +func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } + +func (h *HTTPHandler) require(w http.ResponseWriter, r *http.Request, permission string) (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, permission) { + apiresponse.Error(w, http.StatusForbidden, "缺少渠道管理权限") + return identity.Account{}, false + } + return account, true +} + +func (h *HTTPHandler) list(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionNotificationRead); !ok { + return + } + items, err := h.service.List(r.Context()) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "渠道查询失败") + return + } + apiresponse.OK(w, items) +} + +type channelInput struct { + Code string `json:"code"` + Name string `json:"name"` + Kind string `json:"kind"` + Config json.RawMessage `json:"config"` + ModelBinding json.RawMessage `json:"model_binding"` + APIKey string `json:"api_key"` + Enabled *bool `json:"enabled"` +} + +func (h *HTTPHandler) save(w http.ResponseWriter, r *http.Request) { + actor, ok := h.require(w, r, identity.PermissionNotificationManage) + if !ok { + return + } + var input channelInput + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil { + apiresponse.Error(w, http.StatusBadRequest, "请求格式无效") + return + } + var cfg Config + if len(input.Config) > 0 { + if err := json.Unmarshal(input.Config, &cfg); err != nil { + apiresponse.Error(w, http.StatusBadRequest, "平台配置格式无效") + return + } + } + enabled := true + if input.Enabled != nil { + enabled = *input.Enabled + } + item, err := h.service.Save(r.Context(), r.PathValue("id"), input.Code, input.Name, input.Kind, cfg, input.ModelBinding, input.APIKey, enabled, actor.ID) + if err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, item) +} + +func (h *HTTPHandler) delete(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionNotificationManage); !ok { + return + } + if err := h.service.Delete(r.Context(), r.PathValue("id")); err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} + +// test 用渠道绑定模型发送一条测试消息并尝试平台回复。 +func (h *HTTPHandler) test(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionNotificationManage); !ok { + return + } + items, err := h.service.List(r.Context()) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "渠道查询失败") + return + } + var target *Channel + for i := range items { + if items[i].ID == r.PathValue("id") { + target = &items[i] + break + } + } + if target == nil { + apiresponse.Error(w, http.StatusNotFound, "渠道不存在") + return + } + answer, err := h.service.HandleInbound(r.Context(), *target, InboundMessage{FromUser: "admin-test", Text: "这是一条渠道连通性测试消息,请简短回复。"}) + if err != nil { + apiresponse.Error(w, http.StatusBadGateway, err.Error()) + return + } + cfg, cfgErr := h.service.DecryptConfig(*target) + if cfgErr == nil { + _ = h.service.Reply(context.WithoutCancel(r.Context()), *target, cfg, answer) + } + apiresponse.OK(w, map[string]any{"answer": answer}) +} + +// InboundHTTPHandler 公开入站端点(按渠道 code 分发)。 +type InboundHTTPHandler struct { + service *Service + mux *http.ServeMux +} + +func NewInboundHTTPHandler(service *Service) *InboundHTTPHandler { + h := &InboundHTTPHandler{service: service, mux: http.NewServeMux()} + h.mux.HandleFunc("POST /v1/channels/{code}/inbound", h.inbound) + return h +} + +func (h *InboundHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } + +func (h *InboundHTTPHandler) inbound(w http.ResponseWriter, r *http.Request) { + c, err := h.service.GetByCode(r.Context(), strings.ToLower(r.PathValue("code"))) + if err != nil { + apiresponse.Error(w, http.StatusNotFound, "渠道不存在或未启用") + return + } + cfg, err := h.service.DecryptConfig(c) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "渠道配置不可用") + return + } + // 平台签名校验。 + switch c.Kind { + case "wecom": + // 企业微信回调验证:echostr 原样返回。 + if echostr := r.URL.Query().Get("echostr"); echostr != "" { + if _, ok := VerifyWeComSignature(cfg.InboundToken, r.URL.Query().Get("timestamp"), r.URL.Query().Get("nonce"), r.URL.Query().Get("msg_signature"), nil); ok { + _, _ = w.Write([]byte(echostr)) + return + } + apiresponse.Error(w, http.StatusUnauthorized, "签名校验失败") + return + } + case "dingtalk": + // 钉钉机器人验签由平台侧 access_token 控制;此处信任令牌。 + } + var payload struct { + Text struct { + Content string `json:"content"` + } `json:"text"` + Content string `json:"content"` + } + raw := make([]byte, 1<<20) + n, _ := r.Body.Read(raw) + _ = json.Unmarshal(raw[:n], &payload) + text := payload.Text.Content + if text == "" { + text = payload.Content + } + if text == "" { + apiresponse.Error(w, http.StatusBadRequest, "消息内容为空") + return + } + answer, err := h.service.HandleInbound(r.Context(), c, InboundMessage{Text: text}) + if err != nil { + apiresponse.Error(w, http.StatusBadGateway, err.Error()) + return + } + if err := h.service.Reply(r.Context(), c, cfg, answer); err != nil { + apiresponse.Error(w, http.StatusBadGateway, err.Error()) + return + } + // 通用 webhook 同步返回回答;平台渠道返回受理确认。 + if c.Kind == "webhook" { + apiresponse.OK(w, map[string]any{"reply": answer}) + return + } + apiresponse.OK(w, map[string]bool{"accepted": true}) +} diff --git a/internal/channel/service.go b/internal/channel/service.go new file mode 100644 index 0000000..fbdf73b --- /dev/null +++ b/internal/channel/service.go @@ -0,0 +1,442 @@ +// Package channel 实现多渠道接入:通用 Webhook + 企业微信/钉钉/飞书。 +// 入站消息经绑定模型应答后,按平台协议回发。 +package channel + +import ( + "bytes" + "context" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "sort" + "strings" + "time" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrNotFound = errors.New("渠道不存在") + ErrUnavailable = errors.New("渠道服务不可用") +) + +// Channel 是一条渠道配置。 +type Channel struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Kind string `json:"kind"` + Config json.RawMessage `json:"config,omitempty"` + EncryptedConfig []byte `json:"-"` + ConfigKEKVersion int `json:"-"` + ModelBinding json.RawMessage `json:"model_binding"` + HasAPIKey bool `json:"has_api_key"` + Enabled bool `json:"enabled"` + CreatedBy *string `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Config 是渠道的平台配置(明文,仅内部使用)。 +type Config struct { + // 通用 + InboundToken string `json:"inbound_token,omitempty"` // webhook 鉴权令牌 + // 企业微信(自建应用回调 + 主动发送) + CorpID string `json:"corp_id,omitempty"` + Secret string `json:"secret,omitempty"` + AgentID string `json:"agent_id,omitempty"` + // 钉钉(机器人 webhook) + DingRobotToken string `json:"ding_robot_token,omitempty"` + // 飞书(应用) + FeishuAppID string `json:"feishu_app_id,omitempty"` + FeishuAppSecret string `json:"feishu_app_secret,omitempty"` +} + +// Service 渠道管理 + 消息收发。 +type Service struct { + pool *pgxpool.Pool + gatewayURL string + client *http.Client + logger *slog.Logger + cipher interface { + Encrypt([]byte) ([]byte, int, error) + Decrypt([]byte, int) ([]byte, error) + } +} + +// NewService 创建渠道服务;cipher 加密平台配置与 API Key。 +func NewService(pool *pgxpool.Pool, gatewayURL string, cipher interface { + Encrypt([]byte) ([]byte, int, error) + Decrypt([]byte, int) ([]byte, error) +}, logger *slog.Logger) *Service { + return &Service{ + pool: pool, gatewayURL: strings.TrimRight(gatewayURL, "/"), logger: logger, cipher: cipher, + client: &http.Client{Timeout: 60 * time.Second}, + } +} + +const channelSelect = `SELECT id::text,code,name,kind,encrypted_config,config_kek_version,model_binding,octet_length(encrypted_api_key)>0,enabled,created_by::text,created_at,updated_at FROM gateway.channels` + +func (s *Service) scan(row pgx.Row) (Channel, error) { + var c Channel + var createdBy *string + err := row.Scan(&c.ID, &c.Code, &c.Name, &c.Kind, &c.EncryptedConfig, &c.ConfigKEKVersion, &c.ModelBinding, &c.HasAPIKey, &c.Enabled, &createdBy, &c.CreatedAt, &c.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Channel{}, ErrNotFound + } + if err != nil { + return Channel{}, err + } + c.CreatedBy = createdBy + return c, nil +} + +// List 返回渠道列表(不含敏感配置)。 +func (s *Service) List(ctx context.Context) ([]Channel, error) { + if s == nil || s.pool == nil { + return nil, ErrUnavailable + } + rows, err := s.pool.Query(ctx, channelSelect+` ORDER BY updated_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Channel{} + for rows.Next() { + c, err := s.scan(rows) + if err != nil { + return nil, err + } + items = append(items, c) + } + return items, rows.Err() +} + +// GetByCode 供入站分发使用(无需解密配置)。 +func (s *Service) GetByCode(ctx context.Context, code string) (Channel, error) { + if s == nil || s.pool == nil { + return Channel{}, ErrUnavailable + } + return s.scan(s.pool.QueryRow(ctx, channelSelect+` WHERE code=$1 AND enabled`, code)) +} + +// DecryptConfig 解密平台配置。 +func (s *Service) DecryptConfig(c Channel) (Config, error) { + var cfg Config + if len(c.EncryptedConfig) == 0 { + return cfg, nil + } + plaintext, err := s.cipher.Decrypt(c.EncryptedConfig, c.ConfigKEKVersion) + if err != nil { + return cfg, err + } + if err := json.Unmarshal(plaintext, &cfg); err != nil { + return cfg, err + } + return cfg, nil +} + +// Save 创建/更新渠道。 +func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Config, modelBinding json.RawMessage, apiKey string, enabled bool, actorID string) (Channel, error) { + if s == nil || s.pool == nil || s.cipher == nil { + return Channel{}, ErrUnavailable + } + code = strings.ToLower(strings.TrimSpace(code)) + name = strings.TrimSpace(name) + if !codePattern.MatchString(code) || name == "" || len(name) > 128 { + return Channel{}, errors.New("渠道代码或名称无效") + } + switch kind { + case "webhook", "wecom", "dingtalk", "feishu": + default: + return Channel{}, errors.New("渠道类型必须是 webhook/wecom/dingtalk/feishu") + } + if id == "" { + newID, err := platformid.NewUUID() + if err != nil { + return Channel{}, err + } + id = newID + } + encryptedConfig, configVersion, err := s.cipher.Encrypt(mustJSON(cfg)) + if err != nil { + return Channel{}, err + } + var encryptedKey []byte + var keyVersion int + if apiKey != "" { + encryptedKey, keyVersion, err = s.cipher.Encrypt([]byte(apiKey)) + if err != nil { + return Channel{}, err + } + } + if modelBinding == nil { + modelBinding = json.RawMessage(`{}`) + } + _, err = s.pool.Exec(ctx, `INSERT INTO gateway.channels(id,code,name,kind,encrypted_config,config_kek_version,encrypted_api_key,api_key_kek_version,model_binding,enabled,created_by) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + ON CONFLICT(code) DO UPDATE SET name=$3,kind=$4,encrypted_config=$5,config_kek_version=$6, + encrypted_api_key=CASE WHEN $7<>'' THEN $7 ELSE gateway.channels.encrypted_api_key END, + api_key_kek_version=CASE WHEN $7<>'' THEN $8 ELSE gateway.channels.api_key_kek_version END, + model_binding=$9,enabled=$10,updated_at=clock_timestamp()`, + id, code, name, kind, encryptedConfig, configVersion, encryptedKey, keyVersion, modelBinding, enabled, actorID) + if err != nil { + return Channel{}, err + } + return s.scan(s.pool.QueryRow(ctx, channelSelect+` WHERE id=$1`, id)) +} + +func (s *Service) Delete(ctx context.Context, id string) error { + if s == nil || s.pool == nil { + return ErrUnavailable + } + tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.channels WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// DecryptAPIKey 解密渠道绑定的网关 API Key(入站调用用)。 +func (s *Service) DecryptAPIKey(ctx context.Context, c Channel) (string, error) { + if !c.HasAPIKey { + return "", nil + } + var encrypted []byte + var version int + if err := s.pool.QueryRow(ctx, `SELECT encrypted_api_key,api_key_kek_version FROM gateway.channels WHERE id=$1`, c.ID).Scan(&encrypted, &version); err != nil { + return "", err + } + plaintext, err := s.cipher.Decrypt(encrypted, version) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +func mustJSON(value any) []byte { + raw, _ := json.Marshal(value) + return raw +} + +var codePattern = regexpMust(`^[a-z][a-z0-9_-]{2,63}$`) + +// InboundMessage 是归一化的入站消息。 +type InboundMessage struct { + FromUser string + Text string +} + +// HandleInbound 处理入站消息:调用绑定模型,按平台回复。 +func (s *Service) HandleInbound(ctx context.Context, c Channel, msg InboundMessage) (string, error) { + if s == nil || s.gatewayURL == "" { + return "", ErrUnavailable + } + apiKey, err := s.DecryptAPIKey(ctx, c) + if err != nil { + return "", err + } + var binding struct { + Provider string `json:"provider"` + Model string `json:"model"` + } + _ = json.Unmarshal(c.ModelBinding, &binding) + if binding.Model == "" { + binding.Model = "gpt-4o-mini" + } + payload, _ := json.Marshal(map[string]any{ + "model": binding.Model, + "messages": []map[string]any{ + {"role": "user", "content": msg.Text}, + }, + }) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.gatewayURL+"/v1/chat/completions", bytes.NewReader(payload)) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/json") + if apiKey != "" { + request.Header.Set("Authorization", "Bearer "+apiKey) + } + response, err := s.client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if response.StatusCode/100 != 2 { + return "", fmt.Errorf("模型调用失败(HTTP %d)", response.StatusCode) + } + var decoded struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if json.Unmarshal(raw, &decoded) != nil || len(decoded.Choices) == 0 { + return "", errors.New("模型响应格式无效") + } + return decoded.Choices[0].Message.Content, nil +} + +// Reply 按平台协议发送回复。webhook 渠道返回同步回复文本。 +func (s *Service) Reply(ctx context.Context, c Channel, cfg Config, text string) error { + switch c.Kind { + case "webhook": + return nil // 同步返回 + case "wecom": + return s.replyWeCom(ctx, cfg, text) + case "dingtalk": + return s.replyDingTalk(ctx, cfg, text) + case "feishu": + return s.replyFeishu(ctx, cfg, text) + default: + return errors.New("不支持的渠道类型") + } +} + +// replyWeCom 通过企业微信应用消息接口发送。 +func (s *Service) replyWeCom(ctx context.Context, cfg Config, text string) error { + token, err := s.weComToken(ctx, cfg) + if err != nil { + return err + } + payload, _ := json.Marshal(map[string]any{ + "touser": "@all", + "msgtype": "text", + "agentid": cfg.AgentID, + "text": map[string]string{"content": text}, + }) + return s.postJSON(ctx, "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token="+token, payload) +} + +func (s *Service) weComToken(ctx context.Context, cfg Config) (string, error) { + endpoint := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s", url.QueryEscape(cfg.CorpID), url.QueryEscape(cfg.Secret)) + response, err := s.client.Get(endpoint) + if err != nil { + return "", err + } + defer response.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<16)) + var decoded struct { + ErrCode int `json:"errcode"` + Token string `json:"access_token"` + } + if json.Unmarshal(raw, &decoded) != nil || decoded.ErrCode != 0 || decoded.Token == "" { + return "", errors.New("企业微信 token 获取失败") + } + return decoded.Token, nil +} + +// replyDingTalk 通过钉钉自定义机器人 webhook 发送。 +func (s *Service) replyDingTalk(ctx context.Context, cfg Config, text string) error { + payload, _ := json.Marshal(map[string]any{ + "msgtype": "text", + "text": map[string]string{"content": text}, + }) + endpoint := "https://oapi.dingtalk.com/robot/send?access_token=" + url.QueryEscape(cfg.DingRobotToken) + return s.postJSON(ctx, endpoint, payload) +} + +// replyFeishu 通过飞书机器人消息接口发送。 +func (s *Service) replyFeishu(ctx context.Context, cfg Config, text string) error { + token, err := s.feishuToken(ctx, cfg) + if err != nil { + return err + } + payload, _ := json.Marshal(map[string]any{ + "receive_id": "@all", + "msg_type": "text", + "content": map[string]string{"text": text}, + }) + return s.postJSON(ctx, "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", payload, func(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+token) + }) +} + +func (s *Service) feishuToken(ctx context.Context, cfg Config) (string, error) { + payload, _ := json.Marshal(map[string]string{"app_id": cfg.FeishuAppID, "app_secret": cfg.FeishuAppSecret}) + response, err := s.client.Post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", "application/json", bytes.NewReader(payload)) + if err != nil { + return "", err + } + defer response.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<16)) + var decoded struct { + Code int `json:"code"` + Token string `json:"tenant_access_token"` + } + if json.Unmarshal(raw, &decoded) != nil || decoded.Code != 0 || decoded.Token == "" { + return "", errors.New("飞书 token 获取失败") + } + return decoded.Token, nil +} + +func (s *Service) postJSON(ctx context.Context, endpoint string, payload []byte, options ...func(*http.Request)) error { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + for _, option := range options { + option(request) + } + response, err := s.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<16)) + if response.StatusCode/100 != 2 { + return fmt.Errorf("平台接口返回 HTTP %d", response.StatusCode) + } + var envelope struct { + ErrCode int `json:"errcode"` + Code int `json:"code"` + } + _ = json.Unmarshal(raw, &envelope) + if envelope.ErrCode != 0 || envelope.Code != 0 { + return fmt.Errorf("平台接口返回错误码 %d/%d", envelope.ErrCode, envelope.Code) + } + return nil +} + +// VerifyWeComSignature 校验企业微信回调签名(URL 参数签名)。 +func VerifyWeComSignature(token, timestamp, nonce, echostr string, values map[string]string) (string, bool) { + parts := []string{token, timestamp, nonce} + if values != nil { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + parts = append(parts, key+"="+values[key]) + } + } + sort.Strings(parts) + sum := sha1.Sum([]byte(strings.Join(parts, ""))) + if hex.EncodeToString(sum[:]) != echostr { + return "", false + } + return echostr, true +} + +// DingSign 计算钉钉机器人加签(时间戳+密钥)。 +func DingSign(timestamp int64, secret string) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("%d\n%s", timestamp, secret))) + return url.QueryEscape(hex.EncodeToString(sum[:])) +} diff --git a/internal/channel/util.go b/internal/channel/util.go new file mode 100644 index 0000000..d77ffc1 --- /dev/null +++ b/internal/channel/util.go @@ -0,0 +1,7 @@ +package channel + +import "regexp" + +func regexpMust(pattern string) *regexp.Regexp { + return regexp.MustCompile(pattern) +} diff --git a/internal/identity/http.go b/internal/identity/http.go index b27db45..f1b9091 100644 --- a/internal/identity/http.go +++ b/internal/identity/http.go @@ -466,6 +466,13 @@ func adminMenus(account Account) []map[string]any { menus = append(menus, map[string]any{"name": "ResourceMarket", "path": "/resource-market", "component": "/index/index", "meta": map[string]any{"title": "资源市场", "icon": "ri:store-3-line"}, "children": marketChildren}) } + if HasPermission(account, PermissionUsageRead) { + gatewayChildren = append(gatewayChildren, map[string]any{"name": "Reports", "path": "reports", "component": "/gateway/reports", "meta": map[string]any{"title": "企业报表"}}) + gatewayChildren = append(gatewayChildren, map[string]any{"name": "Tenants", "path": "tenants", "component": "/gateway/tenants", "meta": map[string]any{"title": "租户概览"}}) + } + if HasPermission(account, PermissionNotificationRead) || HasPermission(account, PermissionNotificationManage) { + gatewayChildren = append(gatewayChildren, map[string]any{"name": "Channels", "path": "channels", "component": "/gateway/channels", "meta": map[string]any{"title": "渠道管理"}}) + } // 系统管理:账号权限、事件投递与通知。 systemChildren := make([]map[string]any, 0, 3) if HasPermission(account, PermissionIdentityManage) { @@ -508,6 +515,7 @@ func portalMenus() []map[string]any { {"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": "定时任务"}}, {"name": "PortalMemories", "path": "memories", "component": "/portal/memories", "meta": map[string]any{"title": "记忆管理"}}, + {"name": "PortalEnvVars", "path": "env-vars", "component": "/portal/env-vars", "meta": map[string]any{"title": "环境变量"}}, {"name": "PortalLoginLogs", "path": "login-logs", "component": "/portal/login-logs", "meta": map[string]any{"title": "登录记录"}}, }}, } diff --git a/internal/operations/admin_http.go b/internal/operations/admin_http.go index afec513..8b4108b 100644 --- a/internal/operations/admin_http.go +++ b/internal/operations/admin_http.go @@ -24,6 +24,7 @@ func NewAdminHTTPHandler(pool *pgxpool.Pool, identityService *identity.Service, h := &AdminHTTPHandler{pool: pool, identity: identityService, version: version, startedAt: startedAt, reload: reload, mux: http.NewServeMux()} 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("POST /api/v1/admin/reload", h.reloadSnapshots) return h } @@ -82,3 +83,44 @@ func (h *AdminHTTPHandler) reloadSnapshots(w http.ResponseWriter, r *http.Reques } apiresponse.OK(w, map[string]bool{"reloaded": true}) } + + +// 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, + (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())) + FROM gateway.departments d WHERE d.active ORDER BY d.name`) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "租户概览查询失败") + return + } + 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"` + } + 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 { + apiresponse.Error(w, http.StatusServiceUnavailable, "租户概览查询失败") + return + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "租户概览查询失败") + return + } + apiresponse.OK(w, map[string]any{"tenants": items}) +} diff --git a/internal/portal/conversations.go b/internal/portal/conversations.go index 40372d7..63ef370 100644 --- a/internal/portal/conversations.go +++ b/internal/portal/conversations.go @@ -225,6 +225,13 @@ func (s *Service) callApplication(ctx context.Context, appCode, secret string, m } func (s *Service) Chat(ctx context.Context, account identity.Account, code, message string, variables map[string]any) (map[string]any, error) { + // 个人环境变量合并:请求未提供的变量用用户配置补充。 + if s.envVars != nil && variables == nil { + variables = map[string]any{} + if err := s.envVars.MergeVariables(ctx, account.ID, variables); err != nil { + return nil, err + } + } message = strings.TrimSpace(message) if message == "" || len(message) > 100000 { return nil, errors.New("消息为空或过长") diff --git a/internal/portal/http.go b/internal/portal/http.go index bc8ef3d..b350a7e 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -289,7 +289,8 @@ func (h *HTTPHandler) marketplaceInstall(w http.ResponseWriter, r *http.Request) if !ok { return } - created, err := h.service.MarketplaceInstall(r.Context(), a, r.PathValue("type"), r.PathValue("code")) + level := strings.TrimSpace(r.URL.Query().Get("permission_level")) + created, err := h.service.MarketplaceInstall(r.Context(), a, r.PathValue("type"), r.PathValue("code"), level) if err != nil { portalError(w, err) return diff --git a/internal/portal/service.go b/internal/portal/service.go index 8c721af..24e30a3 100644 --- a/internal/portal/service.go +++ b/internal/portal/service.go @@ -19,6 +19,7 @@ import ( var ErrNotFound = errors.New("portal resource not found") type Service struct { + envVars *workbench.EnvVarService pool *pgxpool.Pool assets *workbench.Service tools *workbench.ToolService @@ -32,6 +33,9 @@ func NewService(pool *pgxpool.Pool, assets *workbench.Service, tools *workbench. return &Service{pool: pool, assets: assets, tools: tools, identity: identityService} } +// SetEnvVarService 启用个人环境变量合并(对话请求未提供的变量用用户配置补充)。 +func (s *Service) SetEnvVarService(service *workbench.EnvVarService) { s.envVars = service } + func (s *Service) SetApplicationRuntime(credentials *RuntimeCredentials, runtime http.Handler) { s.credentials = credentials s.runtime = runtime @@ -332,7 +336,7 @@ func (s *Service) MarketplaceDetail(ctx context.Context, account identity.Accoun // MarketplaceInstall binds a published, visible resource to the portal user's // workspace. Cross-department resources the user cannot see cannot be installed. -func (s *Service) MarketplaceInstall(ctx context.Context, account identity.Account, resourceType, code string) (bool, error) { +func (s *Service) MarketplaceInstall(ctx context.Context, account identity.Account, resourceType, code, permissionLevel string) (bool, error) { if s.market == nil { return false, ErrNotFound } @@ -343,7 +347,7 @@ func (s *Service) MarketplaceInstall(ctx context.Context, account identity.Accou if !visible(item.DepartmentIDs, account.DepartmentID) { return false, ErrNotFound } - return s.market.Install(ctx, resourceType, code, account.ID) + return s.market.Install(ctx, resourceType, code, account.ID, permissionLevel) } func (s *Service) MarketplaceUninstall(ctx context.Context, account identity.Account, resourceType, code string) error { diff --git a/internal/workbench/envvars.go b/internal/workbench/envvars.go new file mode 100644 index 0000000..abcbe66 --- /dev/null +++ b/internal/workbench/envvars.go @@ -0,0 +1,212 @@ +package workbench + +import ( + "context" + "regexp" + "encoding/json" + "errors" + "net/http" + "strings" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" + "aigateway.local/core/internal/platform/cryptox" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// EnvVarService 管理门户用户个人环境变量(值加密存储)。 +type EnvVarService struct { + pool *pgxpool.Pool + cipher cryptox.Cipher +} + +func NewEnvVarService(pool *pgxpool.Pool, cipher cryptox.Cipher) *EnvVarService { + return &EnvVarService{pool: pool, cipher: cipher} +} + +// List 返回用户环境变量(键列表,不含值)。 +func (s *EnvVarService) List(ctx context.Context, userID string) ([]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,updated_at FROM gateway.user_env_vars WHERE portal_user_id=$1 ORDER BY key`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var key string + var hasValue bool + var updatedAt any + if err := rows.Scan(&key, &hasValue, &updatedAt); err != nil { + return nil, err + } + items = append(items, map[string]any{"key": key, "configured": hasValue, "updated_at": updatedAt}) + } + return items, rows.Err() +} + +// Upsert 设置一个环境变量;value 为空时删除。 +func (s *EnvVarService) Upsert(ctx context.Context, userID, key, value 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(value) > 4096 { + return errors.New("变量值过长") + } + value = strings.TrimSpace(value) + if value == "" { + tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.user_env_vars WHERE portal_user_id=$1 AND key=$2`, userID, key) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errors.New("变量不存在") + } + return nil + } + encrypted, version, err := s.cipher.Encrypt([]byte(value)) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, `INSERT INTO gateway.user_env_vars(portal_user_id,key,encrypted_value,value_kek_version) VALUES($1,$2,$3,$4) + ON CONFLICT(portal_user_id,key) DO UPDATE SET encrypted_value=$3,value_kek_version=$4,updated_at=clock_timestamp()`, + userID, key, encrypted, version) + return err +} + +// Decrypt 解密单个变量(运行时合并用);不存在返回 ok=false。 +func (s *EnvVarService) Decrypt(ctx context.Context, userID, key string) (string, bool, error) { + if s == nil || s.pool == nil || s.cipher == nil { + return "", false, nil + } + var encrypted []byte + var version int + err := s.pool.QueryRow(ctx, `SELECT encrypted_value,value_kek_version FROM gateway.user_env_vars WHERE portal_user_id=$1 AND key=$2`, userID, key).Scan(&encrypted, &version) + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, err + } + plaintext, err := s.cipher.Decrypt(encrypted, version) + if err != nil { + return "", false, err + } + return string(plaintext), true, nil +} + +// MergeVariables 把用户环境变量合并进请求变量(请求未提供的键)。 +func (s *EnvVarService) MergeVariables(ctx context.Context, userID string, variables map[string]any) error { + if userID == "" || len(variables) >= 100 { + 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 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 + } + pairs = append(pairs, p) + } + if err := rows.Err(); err != nil { + return err + } + for _, p := range pairs { + if _, exists := variables[p.key]; exists { + continue + } + plaintext, err := s.cipher.Decrypt(p.value, p.version) + if err != nil { + continue + } + variables[p.key] = string(plaintext) + } + return nil +} + +var envKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,127}$`) + +// EnvVarHTTPHandler 门户环境变量 CRUD。 +type EnvVarHTTPHandler struct { + service *EnvVarService + identity *identity.Service + mux *http.ServeMux +} + +func NewEnvVarHTTPHandler(service *EnvVarService, identityService *identity.Service) *EnvVarHTTPHandler { + h := &EnvVarHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/portal/env-vars", h.list) + h.mux.HandleFunc("PUT /api/v1/portal/env-vars/{key}", h.upsert) + h.mux.HandleFunc("DELETE /api/v1/portal/env-vars/{key}", h.delete) + return h +} + +func (h *EnvVarHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } + +func (h *EnvVarHTTPHandler) account(w http.ResponseWriter, r *http.Request) (identity.Account, bool) { + account, err := h.identity.Authenticate(r.Context(), identity.KindPortal, r.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效或已过期") + return identity.Account{}, false + } + return account, true +} + +func (h *EnvVarHTTPHandler) list(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + items, err := h.service.List(r.Context(), a.ID) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "环境变量查询失败") + return + } + apiresponse.OK(w, items) +} + +func (h *EnvVarHTTPHandler) upsert(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + var input struct { + Value string `json:"value"` + } + 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.Upsert(r.Context(), a.ID, r.PathValue("key"), input.Value); err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, map[string]bool{"saved": true}) +} + +func (h *EnvVarHTTPHandler) delete(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + if err := h.service.Upsert(r.Context(), a.ID, 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/marketplace.go b/internal/workbench/marketplace.go index 87c9721..860750e 100644 --- a/internal/workbench/marketplace.go +++ b/internal/workbench/marketplace.go @@ -16,6 +16,7 @@ import ( // MarketItem is the lightweight unified catalog row for a published resource, // regardless of which of the three resource tables it lives in. type MarketItem struct { + PermissionLevel string `json:"permission_level,omitempty"` Type string `json:"type"` Code string `json:"code"` Name string `json:"name"` @@ -300,11 +301,20 @@ func (s *MarketplaceService) Detail(ctx context.Context, resourceType, code stri // Install records a portal user's workspace binding to a published resource. // It is the permission grant that lets a cross-department user invoke a // resource that would otherwise be invisible to them. -func (s *MarketplaceService) Install(ctx context.Context, resourceType, code, portalUserID string) (bool, error) { +// Install 记录安装;permissionLevel 为 view/use/manage(默认 use)。 +func (s *MarketplaceService) Install(ctx context.Context, resourceType, code, portalUserID, permissionLevel string) (bool, error) { resourceID, err := s.publishedResourceID(ctx, resourceType, code) if err != nil { return false, err } + switch permissionLevel { + case "", "view", "use", "manage": + default: + return false, errors.New("权限等级必须是 view/use/manage") + } + if permissionLevel == "" { + permissionLevel = "use" + } id, err := newUUID() if err != nil { return false, err @@ -314,7 +324,7 @@ func (s *MarketplaceService) Install(ctx context.Context, resourceType, code, po return false, err } defer rollback(ctx, tx) - tag, err := tx.Exec(ctx, `INSERT INTO gateway.marketplace_installations(id,resource_type,resource_id,portal_user_id) VALUES($1,$2,$3,$4) ON CONFLICT(resource_type,resource_id,portal_user_id) DO NOTHING`, id, resourceType, resourceID, portalUserID) + tag, err := tx.Exec(ctx, `INSERT INTO gateway.marketplace_installations(id,resource_type,resource_id,portal_user_id,permission_level) VALUES($1,$2,$3,$4,$5) ON CONFLICT(resource_type,resource_id,portal_user_id) DO UPDATE SET permission_level=$5`, id, resourceType, resourceID, portalUserID, permissionLevel) if err != nil { return false, err } diff --git a/internal/workbench/marketplace_integration_test.go b/internal/workbench/marketplace_integration_test.go index 62d9237..cbf6bd9 100644 --- a/internal/workbench/marketplace_integration_test.go +++ b/internal/workbench/marketplace_integration_test.go @@ -147,7 +147,7 @@ func TestMarketplaceLifecycle(t *testing.T) { } // Install/uninstall is idempotent and gated by published status. - created, err := market.Install(ctx, "skill", "mkt_skill", portalUserID) + created, err := market.Install(ctx, "skill", "mkt_skill", portalUserID, "use") if err != nil || !created { t.Fatalf("install created=%v err=%v", created, err) } @@ -155,7 +155,7 @@ func TestMarketplaceLifecycle(t *testing.T) { if err != nil || !installed { t.Fatalf("installed=%v err=%v", installed, err) } - createdAgain, err := market.Install(ctx, "skill", "mkt_skill", portalUserID) + createdAgain, err := market.Install(ctx, "skill", "mkt_skill", portalUserID, "use") if err != nil || createdAgain { t.Fatalf("re-install should be a no-op: created=%v err=%v", createdAgain, err) } diff --git a/internal/workbench/runtime_http.go b/internal/workbench/runtime_http.go index a281e2b..2147699 100644 --- a/internal/workbench/runtime_http.go +++ b/internal/workbench/runtime_http.go @@ -30,6 +30,7 @@ type RuntimeHTTPHandler struct { logger *slog.Logger mux *http.ServeMux market MarketplaceDeps + envVars *EnvVarService } // MarketplaceDeps carries the resource-marketplace services into the runtime @@ -72,6 +73,9 @@ func (h *RuntimeHTTPHandler) SetLogger(logger *slog.Logger) { // conversations. When nil (the default) fact-checking is skipped entirely. func (h *RuntimeHTTPHandler) SetFactCheckEngine(engine *factcheck.Engine) { h.factCheck = engine } +// SetEnvVarService 启用个人环境变量合并(应用/数字员工运行时变量补充)。 +func (h *RuntimeHTTPHandler) SetEnvVarService(service *EnvVarService) { h.envVars = service } + // SetTraceStore enables metadata-only LLM Trace recording for application and // digital-employee runs. Trace persistence is best effort and never changes // the runtime response when the database is unavailable. @@ -329,6 +333,7 @@ func (h *RuntimeHTTPHandler) runApplication(w http.ResponseWriter, r *http.Reque runtimeError(w, 404, "应用不存在、未发布或不可见") return } + started := time.Now() status := "error" runError := "" diff --git a/migrations/000035_permission_level.sql b/migrations/000035_permission_level.sql new file mode 100644 index 0000000..ab80967 --- /dev/null +++ b/migrations/000035_permission_level.sql @@ -0,0 +1,5 @@ +-- 资源权限等级:marketplace 安装记录增加 permission_level +-- (view=可查看 / use=仅使用 / manage=管理),门户"我的资源"展示权限等级。 +ALTER TABLE gateway.marketplace_installations + ADD COLUMN IF NOT EXISTS permission_level text NOT NULL DEFAULT 'use' + CHECK (permission_level IN ('view', 'use', 'manage')); diff --git a/migrations/000036_user_env_vars.sql b/migrations/000036_user_env_vars.sql new file mode 100644 index 0000000..241933d --- /dev/null +++ b/migrations/000036_user_env_vars.sql @@ -0,0 +1,10 @@ +-- 个人环境变量:门户用户配置 key-value 参数(值加密存储),应用/数字员工 +-- 运行时若请求未提供同名变量则自动用用户环境变量补充。 +CREATE TABLE IF NOT EXISTS gateway.user_env_vars ( + portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE, + key text NOT NULL, + encrypted_value bytea NOT NULL, + value_kek_version int NOT NULL DEFAULT 1, + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (portal_user_id, key) +); diff --git a/migrations/000037_channels.sql b/migrations/000037_channels.sql new file mode 100644 index 0000000..da9925a --- /dev/null +++ b/migrations/000037_channels.sql @@ -0,0 +1,17 @@ +-- 渠道管理:统一企业微信/钉钉/飞书/通用 Webhook 渠道,入站消息经绑定 +-- 模型应答后按平台协议回复。 +CREATE TABLE IF NOT EXISTS gateway.channels ( + id uuid PRIMARY KEY, + code text NOT NULL UNIQUE, + name text NOT NULL, + kind text NOT NULL CHECK (kind IN ('webhook', 'wecom', 'dingtalk', 'feishu')), + encrypted_config bytea NOT NULL DEFAULT '', + config_kek_version int NOT NULL DEFAULT 1, + encrypted_api_key bytea NOT NULL DEFAULT '', + api_key_kek_version int NOT NULL DEFAULT 1, + model_binding jsonb NOT NULL DEFAULT '{}', + enabled boolean NOT NULL DEFAULT true, + created_by uuid, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); diff --git a/scripts/publish-images.sh b/scripts/publish-images.sh new file mode 100755 index 0000000..3cf1039 --- /dev/null +++ b/scripts/publish-images.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# 多架构镜像发布(amd64 + arm64)。用法: +# ./scripts/publish-images.sh /ai-gateway:0.11.0 +# 前置:已启用 docker buildx(docker buildx create --use)。 +set -euo pipefail + +TAG="${1:?用法: publish-images.sh /:}" +cd "$(dirname "$0")/.." + +echo "==> 构建并推送多架构镜像: ${TAG}" +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION="${TAG##*:}" \ + -t "${TAG}" \ + --push \ + -f deploy/Dockerfile . + +echo "==> 完成: ${TAG} (amd64 + arm64)" diff --git a/web/apps/admin/src/components/core/layouts/art-favorites/index.vue b/web/apps/admin/src/components/core/layouts/art-favorites/index.vue new file mode 100644 index 0000000..14777e0 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-favorites/index.vue @@ -0,0 +1,80 @@ + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-header-bar/index.vue b/web/apps/admin/src/components/core/layouts/art-header-bar/index.vue index ed8ec22..0c32713 100755 --- a/web/apps/admin/src/components/core/layouts/art-header-bar/index.vue +++ b/web/apps/admin/src/components/core/layouts/art-header-bar/index.vue @@ -48,6 +48,9 @@ + + + +
+
+
+

渠道管理

+

企业微信/钉钉/飞书/通用 Webhook 渠道:入站消息经绑定模型应答后按平台协议回复

+
+ 新增渠道 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + + + + +
+ +
+
+ + + diff --git a/web/apps/admin/src/views/gateway/reports/index.vue b/web/apps/admin/src/views/gateway/reports/index.vue new file mode 100644 index 0000000..860a298 --- /dev/null +++ b/web/apps/admin/src/views/gateway/reports/index.vue @@ -0,0 +1,150 @@ + + + diff --git a/web/apps/admin/src/views/gateway/tenants/index.vue b/web/apps/admin/src/views/gateway/tenants/index.vue new file mode 100644 index 0000000..c839a1a --- /dev/null +++ b/web/apps/admin/src/views/gateway/tenants/index.vue @@ -0,0 +1,47 @@ + + + diff --git a/web/apps/portal/src/views/portal/env-vars/index.vue b/web/apps/portal/src/views/portal/env-vars/index.vue new file mode 100644 index 0000000..c8b51e5 --- /dev/null +++ b/web/apps/portal/src/views/portal/env-vars/index.vue @@ -0,0 +1,114 @@ + + +