diff --git a/cmd/gateway-api/main.go b/cmd/gateway-api/main.go index 42bf63d..42c4cbb 100644 --- a/cmd/gateway-api/main.go +++ b/cmd/gateway-api/main.go @@ -11,12 +11,15 @@ import ( "time" "aigateway.local/core/internal/agentnode" + "aigateway.local/core/internal/assistant" "aigateway.local/core/internal/apikey" "aigateway.local/core/internal/audit" "aigateway.local/core/internal/contentpolicy" "aigateway.local/core/internal/factcheck" "aigateway.local/core/internal/gateway" "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/memory" + "aigateway.local/core/internal/modelquota" "aigateway.local/core/internal/operations" "aigateway.local/core/internal/outbox" "aigateway.local/core/internal/platform/cache" @@ -25,6 +28,7 @@ import ( "aigateway.local/core/internal/platform/database" "aigateway.local/core/internal/platform/health" "aigateway.local/core/internal/platform/httpserver" + "aigateway.local/core/internal/platform/license" "aigateway.local/core/internal/platform/storage" "aigateway.local/core/internal/portal" "aigateway.local/core/internal/pricing" @@ -135,6 +139,13 @@ func main() { proxy.SetAllowPrivateProviderURLs(cfg.Credentials.AllowPrivateProviderURL) proxy.SetAdmissionController(gateway.NewRedisAdmissionController(criticalRedis)) proxy.SetTokenQuotaController(gateway.NewRedisTokenQuotaController(criticalRedis)) + // M8+ 模型级 Token 配额:企业模型总配额(所有 Key 共享),与 Key 级配额叠加。 + modelQuotaService := modelquota.NewService(db, criticalRedis, logger) + if err := modelQuotaService.Reload(ctx); err != nil { + logger.Warn("model quota initial load failed; quota checks disabled until refresh", "error", err) + } + go modelQuotaService.Run(ctx, cfg.RuntimeData.PricingRefreshInterval) + proxy.SetModelQuotaController(modelQuotaService) proxy.SetResiliencePolicy(gateway.ResiliencePolicy{ ResponseHeaderTimeout: cfg.Upstream.ResponseHeaderTimeout, MaxRetries: cfg.Upstream.MaxRetries, RetryBackoff: cfg.Upstream.RetryBackoff, CircuitThreshold: cfg.Upstream.CircuitThreshold, @@ -161,6 +172,7 @@ func main() { go contentPolicyEngine.Run(ctx) go pricingService.Run(ctx) proxy.SetContentPolicyEngine(contentPolicyEngine) + proxy.SetOutputPolicyEngine(contentPolicyEngine) proxy.SetPricingService(pricingService) identityRepository := identity.NewRepository(db) sessionStore := identity.NewSessionStore(criticalRedis, cfg.Auth.SessionTTL) @@ -220,6 +232,15 @@ func main() { } else { logger.Info("knowledge embeddings disabled, knowledge retrieval uses postgres_fts only") } + // 记忆管理(旗舰版):多层记忆 CRUD + 语义召回 + 授权;embedder 与知识库共用。 + memoryService := memory.NewService(db, nil) + if cfg.Embeddings.Enabled { + memoryService.SetEmbedder(workbench.NewOllamaEmbedder(workbench.OllamaEmbedderConfig{ + BaseURL: cfg.Embeddings.BaseURL, Model: cfg.Embeddings.Model, + Dim: cfg.Embeddings.Dim, BatchSize: cfg.Embeddings.BatchSize, Timeout: cfg.Embeddings.Timeout, + })) + } + memoryHandler := memory.NewHTTPHandler(memoryService, identityService) toolCipher, err := cryptox.NewKeyring( cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "tool-request-headers", ) @@ -286,6 +307,7 @@ func main() { } schedulerService := scheduler.NewService(db, schedulerCipher) schedulerHandler := scheduler.NewAdminHTTPHandler(schedulerService, identityService) + portalSchedulerHandler := scheduler.NewPortalHTTPHandler(schedulerService, identityService) traceStore := trace.NewStore(db) traceHandler := trace.NewAdminHTTPHandler(traceStore, identityService) agentNodeStore := agentnode.NewStore(db) @@ -317,7 +339,16 @@ func main() { portalService.SetMarketplace(marketplaceService) portalHandler := portal.NewHTTPHandler(portalService, identityService) portalAdminHandler := portal.NewAdminHTTPHandler(portalService, identityService) + // License 授权:文件校验 + 账号数管控 + 管理端查看/上传。 + licenseManager, err := license.NewManager(cfg.License.FilePath, cfg.Credentials.MasterKey) + if err != nil { + logger.Warn("license initialization failed; running as community edition", "error", license.FormatError(err)) + } + identityManagementHandler.SetLicenseManager(licenseManager) + licenseHandler := license.NewHTTPHandler(licenseManager, identityService) startedAt := time.Now() + assistantService := assistant.NewService(db, providerResolver, "", logger) + assistantHandler := assistant.NewHTTPHandler(assistantService, identityService) operationsHandler := operations.NewAdminHTTPHandler(db, identityService, version, startedAt, func(reloadCtx context.Context) error { return errors.Join(providerResolver.Reload(reloadCtx), contentPolicyEngine.Reload(reloadCtx), pricingService.Reload(reloadCtx)) }) @@ -335,6 +366,7 @@ func main() { controlMux.Handle("/api/v1/admin/content-policies", contentPolicyHandler) controlMux.Handle("/api/v1/admin/content-policies/", contentPolicyHandler) controlMux.Handle("/api/v1/admin/model-prices", pricingHandler) + controlMux.Handle("/api/v1/admin/model-quotas", modelquota.NewHTTPHandler(modelQuotaService, identityService)) controlMux.Handle("/api/v1/admin/model-prices/", pricingHandler) controlMux.Handle("/api/v1/admin/fact-check/", factCheckHandler) controlMux.Handle("/api/v1/admin/prompt-categories", workbenchHandler) @@ -376,6 +408,8 @@ func main() { controlMux.Handle("/api/v1/portal/inbox", inboxPortalHandler) controlMux.Handle("/api/v1/portal/inbox/", inboxPortalHandler) controlMux.Handle("/api/v1/admin/scheduled-tasks", schedulerHandler) + controlMux.Handle("/api/v1/portal/scheduled-tasks", portalSchedulerHandler) + controlMux.Handle("/api/v1/portal/scheduled-tasks/", portalSchedulerHandler) controlMux.Handle("/api/v1/admin/scheduled-tasks/", schedulerHandler) controlMux.Handle("/api/v1/admin/traces", traceHandler) controlMux.Handle("/api/v1/admin/traces/", traceHandler) @@ -383,8 +417,14 @@ 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/assistant", assistantHandler) + controlMux.Handle("/api/v1/admin/assistant/", assistantHandler) + controlMux.Handle("/api/v1/admin/license", licenseHandler) + controlMux.Handle("/api/v1/admin/license/", licenseHandler) controlMux.Handle("/api/v1/admin/reload", operationsHandler) controlMux.Handle("/api/v1/admin/identities/", identityManagementHandler) + controlMux.Handle("/api/v1/admin/roles", identityManagementHandler) + controlMux.Handle("/api/v1/admin/roles/", identityManagementHandler) controlMux.Handle("/api/v1/admin/departments", identityManagementHandler) controlMux.Handle("/api/v1/admin/departments/", identityManagementHandler) controlMux.Handle("/api/v1/admin/identity-providers", identityManagementHandler) @@ -400,6 +440,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/memories", memoryHandler) + controlMux.Handle("/api/v1/portal/memories/", memoryHandler) controlMux.Handle("/api/v1/portal/model-requests/", portalHandler) controlMux.Handle("/api/v1/portal/password", portalHandler) controlMux.Handle("/api/v1/portal/prompts", portalHandler) diff --git a/docs/security-review-0.10.1.md b/docs/security-review-0.10.1.md index f90149e..87cf595 100644 --- a/docs/security-review-0.10.1.md +++ b/docs/security-review-0.10.1.md @@ -362,3 +362,45 @@ PostgreSQL `text` 列拒绝写入 → 重试循环永远失败;inbox 的 Go `l 中心"语枢"字母 A 标记;沿用品牌色(#071F4D/#00E4E5/#006EFF)。 - 替换:侧边栏/顶栏 logo(SVG,Vite 内联)、登录页图标、favicon(16-256 多尺寸 ICO)。程序化像素验证渲染正确。 + +--- + +# 追加:旗舰版 Ultra 功能实现(2026-08-13) + +按旗舰版功能矩阵补齐的模块(均含后端+前端+迁移+端到端验证): + +## 新功能模块 +1. **License 授权**(`internal/platform/license` + 迁移 000031 前置账号管控): + HMAC-SHA256 签名 License 文件(edition/max_accounts/有效期/特性),启动校验 + + 热更新上传,账号创建按上限管控(Free=3)。管理端「系统管理→License 授权」。 +2. **登录记录**(迁移 000031):admin/portal 每次登录尝试(成功/失败/原因/IP/ + UA)落库;门户「登录记录」+ 管理端「系统管理→登录记录」(audit 权限)。 +3. **会话管理**:门户会话列表/重命名/删除(PATCH/DELETE)。 +4. **角色管理 CRUD**(迁移 000032 + `internal/identity/roles.go`):自定义 + 角色 + 权限字符串,内置角色合并展示;分配账号时权限展开。 +5. **门户定时任务**(`internal/scheduler/portal_http.go`):门户工作台 + 定时任务创建/启停/立即执行/历史(仅本人任务)。 +6. **按模型 Token 配额**(迁移 000033 + `internal/modelquota`):企业级 + 模型总配额(Provider+模型模式),所有 Key 共享自然月计数,与 Key 级配额 + 叠加;管理端「模型配额」。 +7. **输出侧脱敏**(`internal/contentpolicy/output.go` + gateway wrapper): + 模型回答(content/delta)应用 redact 规则拦截替换,SSE 逐行/JSON 缓冲; + 响应头 X-Gateway-Output-Redacted。 +8. **供应链安全扫描**(`internal/workbench/scan.go`):skill/mcp 定义静态 + 检测(硬编码密钥/内网端点/危险命令/提示词注入/base64 混淆),管理端扫描 + 按钮+结果展示。 +9. **记忆管理**(迁移 000034 + `internal/memory`):用户/部门/全局三层记忆, + Ollama 向量化语义召回(pgvector HNSW),关键帧衰减清理,向用户授权; + 门户「记忆管理」。 +10. **AI 助手**(`internal/assistant`):管理平台自然语言问答,注入实时平台 + 统计(供应商/模型/账号/用量/待办),走默认模型供应商。 +11. **概览真实数据**:dashboard 由模板 demo 改为 system-info + + monitoring/overview + license 实时数据。 + +## 遗留(记录,建议后续) +- 渠道接入(企微/钉钉/飞书)与扫码:依赖外部 IM 开放平台,需企业凭据, + 建议独立迭代。 +- 多租户管理:现有 tenant 为单租户模型,平台管理员多租户管理需数据隔离 + 重构。 +- ARM64 安装包:构建已可交叉编译(CGO_ENABLED=0),发布流程待配置 buildx。 +- 个人环境变量注入、资源权限等级(查看/仅使用/管理)、收藏:前端增强。 diff --git a/internal/assistant/http.go b/internal/assistant/http.go new file mode 100644 index 0000000..0b53e65 --- /dev/null +++ b/internal/assistant/http.go @@ -0,0 +1,51 @@ +package assistant + +import ( + "encoding/json" + "net/http" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" +) + +// HTTPHandler 管理端 AI 助手接口。 +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("POST /api/v1/admin/assistant/chat", h.chat) + return h +} + +func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } + +func (h *HTTPHandler) chat(w http.ResponseWriter, r *http.Request) { + account, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效或已过期") + return + } + if !identity.HasPermission(account, identity.PermissionSystemManage) { + apiresponse.Error(w, http.StatusForbidden, "缺少系统管理权限") + return + } + var input struct { + Message string `json:"message"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil || input.Message == "" || len(input.Message) > 8000 { + apiresponse.Error(w, http.StatusBadRequest, "消息不能为空且不超过 8000 字符") + return + } + answer, err := h.service.Answer(r.Context(), input.Message) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, err.Error()) + return + } + apiresponse.OK(w, map[string]any{"answer": answer}) +} diff --git a/internal/assistant/service.go b/internal/assistant/service.go new file mode 100644 index 0000000..5d53dd0 --- /dev/null +++ b/internal/assistant/service.go @@ -0,0 +1,109 @@ +// Package assistant 实现管理平台 AI 助手:基于实时平台统计信息(供应商、 +// 模型、账号、用量、事件投递等)回答管理员的自然语言问题。 +package assistant + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "aigateway.local/core/internal/gateway" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrUnavailable = errors.New("AI 助手服务暂不可用") + +// Resolver 提供默认模型供应商(注入 gateway 的 provider resolver)。 +type Resolver interface { + Resolve(code string) (gateway.ResolvedAdapter, error) +} + +// Service 管理平台 AI 助手。 +type Service struct { + pool *pgxpool.Pool + resolver Resolver + client *http.Client + logger *slog.Logger + model string +} + +func NewService(pool *pgxpool.Pool, resolver Resolver, model string, logger *slog.Logger) *Service { + return &Service{ + pool: pool, resolver: resolver, logger: logger, model: model, + client: &http.Client{Timeout: 60 * time.Second}, + } +} + +// Answer 回答管理员提问。 +func (s *Service) Answer(ctx context.Context, message string) (string, error) { + if s == nil || s.resolver == nil { + return "", ErrUnavailable + } + resolved, err := s.resolver.Resolve("") + if err != nil { + return "", fmt.Errorf("%w: 未配置默认模型供应商", ErrUnavailable) + } + model := s.model + if model == "" { + model = "gpt-4o-mini" // 兜底;实际以上游支持为准 + } + systemPrompt, err := s.platformSummary(ctx) + if err != nil { + s.logger.Warn("assistant platform summary failed", "error", err) + systemPrompt = "你是 AI 网关管理助手的系统提示,请基于平台知识回答。" + } + payload, _ := json.Marshal(map[string]any{ + "model": model, + "messages": []map[string]any{ + {"role": "system", "content": systemPrompt}, + {"role": "user", "content": message}, + }, + "temperature": 0.2, + }) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, resolved.Adapter.Target().String()+"/v1/chat/completions", bytes.NewReader(payload)) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/json") + resolved.Adapter.Prepare(request) + response, err := s.client.Do(request) + if err != nil { + return "", fmt.Errorf("%w: 模型调用失败: %v", ErrUnavailable, err) + } + defer response.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if response.StatusCode/100 != 2 { + return "", fmt.Errorf("%w: 模型返回 HTTP %d", ErrUnavailable, 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 "", fmt.Errorf("%w: 模型响应格式无效", ErrUnavailable) + } + return decoded.Choices[0].Message.Content, nil +} + +// platformSummary 汇总平台实时状态注入系统提示。 +func (s *Service) platformSummary(ctx context.Context) (string, error) { + var providers, models, admins, portals, apiKeys, pendingOutbox, todayRequests, todayTokens int64 + _ = s.pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM gateway.providers WHERE enabled), (SELECT count(*) FROM gateway.provider_models WHERE enabled), (SELECT count(*) FROM gateway.admin_accounts), (SELECT count(*) FROM gateway.portal_users), (SELECT count(*) FROM gateway.api_keys WHERE enabled), (SELECT count(*) FROM gateway.outbox_events WHERE status='pending'), (SELECT count(*) FROM gateway.audit_events WHERE recorded_at >= date_trunc('day', now())), (SELECT COALESCE(sum(prompt_tokens+completion_tokens),0) FROM gateway.audit_events WHERE recorded_at >= date_trunc('day', now()))`).Scan(&providers, &models, &admins, &portals, &apiKeys, &pendingOutbox, &todayRequests, &todayTokens) + return fmt.Sprintf(`你是 AI 网关管理助手。以下是平台实时状态(由系统注入,回答时请引用准确数字): +- 启用模型供应商: %d 个 +- 启用的上游模型: %d 个 +- 管理员账号: %d 个, 门户账号: %d 个 +- 启用 API Key: %d 个 +- 待处理 outbox 事件: %d 条 +- 今日请求: %d 次, 今日 Token 消耗: %d +请用中文简洁回答管理员的问题;涉及平台配置建议时说明操作路径(如"供应商管理→新增供应商")。`, providers, models, admins, portals, apiKeys, pendingOutbox, todayRequests, todayTokens), nil +} diff --git a/internal/contentpolicy/output.go b/internal/contentpolicy/output.go new file mode 100644 index 0000000..b53460f --- /dev/null +++ b/internal/contentpolicy/output.go @@ -0,0 +1,84 @@ +package contentpolicy + +import ( + "bytes" + "encoding/json" + "strings" +) + +// OutputRedact 对一条模型响应 JSON(完整响应或 SSE data 载荷)应用全部 +// redact 策略的规则:提取 choices[].message.content / choices[].delta.content +// 文本并替换敏感信息。与输入侧不同,输出 JSON 是给客户端消费的展示数据, +// 重编码可接受。返回替换后的字节与是否发生替换。 +func (e *Engine) OutputRedact(payload []byte) ([]byte, bool) { + if e == nil || len(payload) == 0 { + return payload, false + } + rules := e.outputRules() + if len(rules) == 0 { + return payload, false + } + var document any + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.UseNumber() + if decoder.Decode(&document) != nil { + return payload, false + } + changed := false + redactTree(&document, &changed, rules) + if !changed { + return payload, false + } + encoded, err := json.Marshal(document) + if err != nil { + return payload, false + } + return encoded, true +} + +// outputRules 返回全部启用策略的 redact 规则(输出侧不区分端点)。 +func (e *Engine) outputRules() []compiledRule { + current := e.current.Load() + if current == nil { + return nil + } + var rules []compiledRule + for _, policy := range current.policies { + if policy.Action == "redact" { + rules = append(rules, policy.rules...) + } + } + return rules +} + +// redactTree 递归遍历 JSON,仅对 content 文本应用规则。 +func redactTree(value *any, changed *bool, rules []compiledRule) { + switch current := (*value).(type) { + case map[string]any: + for key, child := range current { + local := child + if strings.ToLower(key) == "content" { + if text, ok := local.(string); ok { + next := text + for _, rule := range rules { + if rule.expression.MatchString(next) { + next = rule.expression.ReplaceAllString(next, rule.replacement) + } + } + if next != text { + *changed = true + local = next + } + } + } + redactTree(&local, changed, rules) + current[key] = local + } + case []any: + for index, child := range current { + local := child + redactTree(&local, changed, rules) + current[index] = local + } + } +} diff --git a/internal/gateway/output_policy.go b/internal/gateway/output_policy.go new file mode 100644 index 0000000..f76b936 --- /dev/null +++ b/internal/gateway/output_policy.go @@ -0,0 +1,111 @@ +package gateway + +import ( + "bytes" + "io" + "strings" +) + +// outputRedactReadCloser 对上游模型响应应用输出侧脱敏(隐私信息拦截替换): +// - 非流式(application/json):首次 Read 前缓冲整个响应,处理后再输出; +// - 流式(text/event-stream):逐 data 行处理,不改变事件边界。 +type outputRedactReadCloser struct { + io.ReadCloser + engine interface { + OutputRedact([]byte) ([]byte, bool) + } + sse bool + buffered []byte // 已处理待输出的字节 + done bool // 非流式已完成缓冲与处理 + pending []byte // 流式:未完成的行 +} + +func newOutputRedactReadCloser(body io.ReadCloser, contentType string, engine interface { + OutputRedact([]byte) ([]byte, bool) +}) io.ReadCloser { + if engine == nil { + return body + } + return &outputRedactReadCloser{ + ReadCloser: body, engine: engine, + sse: strings.Contains(strings.ToLower(contentType), "text/event-stream"), + } +} + +func (r *outputRedactReadCloser) Read(buffer []byte) (int, error) { + if !r.sse { + // 非流式:首次 Read 时一次性缓冲+处理。 + if !r.done { + r.done = true + raw, err := io.ReadAll(r.ReadCloser) + _ = err + if replaced, changed := r.engine.OutputRedact(raw); changed { + r.buffered = replaced + } else { + r.buffered = raw + } + } + if len(r.buffered) == 0 { + return 0, io.EOF + } + n := copy(buffer, r.buffered) + r.buffered = r.buffered[n:] + if len(r.buffered) == 0 { + return n, io.EOF + } + return n, nil + } + // 流式:先输出已处理的行,再读上游。 + if len(r.buffered) > 0 { + n := copy(buffer, r.buffered) + r.buffered = r.buffered[n:] + return n, nil + } + chunk := make([]byte, 32<<10) + n, err := r.ReadCloser.Read(chunk) + if n > 0 { + r.pending = append(r.pending, chunk[:n]...) + r.processLines() + } + if err == io.EOF && len(r.pending) > 0 { + // 流结束:剩余不完整行原样输出(不处理,避免破坏事件边界)。 + r.buffered = append(r.buffered, r.pending...) + r.pending = nil + } + if len(r.buffered) > 0 { + n2 := copy(buffer, r.buffered) + r.buffered = r.buffered[n2:] + return n2, err + } + return n, err +} + +// processLines 把 pending 中的完整行处理进 buffered。 +func (r *outputRedactReadCloser) processLines() { + for { + index := bytes.IndexByte(r.pending, '\n') + if index < 0 { + return + } + line := r.pending[:index] + r.pending = r.pending[index+1:] + trimmed := strings.TrimSpace(string(line)) + if !strings.HasPrefix(trimmed, "data:") { + r.buffered = append(r.buffered, line...) + r.buffered = append(r.buffered, '\n') + continue + } + payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + if payload == "" || payload == "[DONE]" { + r.buffered = append(r.buffered, line...) + r.buffered = append(r.buffered, '\n') + continue + } + if replaced, changed := r.engine.OutputRedact([]byte(payload)); changed { + r.buffered = append(r.buffered, []byte("data: "+string(replaced)+"\n")...) + } else { + r.buffered = append(r.buffered, line...) + r.buffered = append(r.buffered, '\n') + } + } +} diff --git a/internal/gateway/proxy.go b/internal/gateway/proxy.go index 968c932..bc56d6b 100644 --- a/internal/gateway/proxy.go +++ b/internal/gateway/proxy.go @@ -30,10 +30,14 @@ type Proxy struct { circuits sync.Map admission AdmissionController tokenQuota TokenQuotaController + modelQuota ModelQuotaController resilience ResiliencePolicy audit AuditRecorder policies *contentpolicy.Engine pricing *pricing.Service + outputPolicies interface { + OutputRedact([]byte) ([]byte, bool) + } } type cachedProxy struct { @@ -99,6 +103,11 @@ func (p *Proxy) SetTokenQuotaController(controller TokenQuotaController) { p.tokenQuota = controller } +// SetModelQuotaController 启用模型级 Token 配额(provider 解析后预留)。 +func (p *Proxy) SetModelQuotaController(controller ModelQuotaController) { + p.modelQuota = controller +} + func (p *Proxy) SetResiliencePolicy(policy ResiliencePolicy) { p.resilience = policy p.transport.ResponseHeaderTimeout = policy.ResponseHeaderTimeout @@ -106,6 +115,13 @@ func (p *Proxy) SetResiliencePolicy(policy ResiliencePolicy) { func (p *Proxy) SetAuditRecorder(recorder AuditRecorder) { p.audit = recorder } func (p *Proxy) SetContentPolicyEngine(engine *contentpolicy.Engine) { p.policies = engine } + +// SetOutputPolicyEngine 启用输出侧脱敏(模型回答隐私拦截替换)。 +func (p *Proxy) SetOutputPolicyEngine(engine interface { + OutputRedact([]byte) ([]byte, bool) +}) { + p.outputPolicies = engine +} func (p *Proxy) SetPricingService(service *pricing.Service) { p.pricing = service } func (p *Proxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) { @@ -288,6 +304,30 @@ func (p *Proxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) { request.Header.Del("X-Gateway-Provider") writer.Header().Set("X-Gateway-Provider", resolved.Code) span.setRoute(resolved.Code, writer.Header().Get("X-Gateway-Model")) + // 模型级配额(企业总配额,所有 Key 共享):在确定 provider 与 model 后 + // 预留,与 API Key 级配额叠加;未配置配额或额度不足时按 429 处理。 + if p.modelQuota != nil && usage != nil && modelPayload.model != "" { + modelQuota, quotaErr := p.modelQuota.Reserve(request.Context(), resolved.Code, modelPayload.model, estimateForReserve(p, request, usage), time.Now()) + if quotaErr != nil { + writeOpenAIError(writer, http.StatusServiceUnavailable, "model_quota_unavailable", "model quota service is unavailable") + return + } + if modelQuota != nil { + if reservation, ok := modelQuota.(interface { + AllowedFlag() bool + RemainingTokens() int64 + ResetTime() time.Time + }); ok { + if !reservation.AllowedFlag() { + writeOpenAIError(writer, http.StatusTooManyRequests, "insufficient_quota", "model token quota exceeded") + return + } + writer.Header().Set("X-ModelTokenLimit-Remaining", strconv.FormatInt(max(reservation.RemainingTokens(), 0), 10)) + usage.modelController = p.modelQuota + usage.modelReservation = modelQuota + } + } + } request.Body = span.captureBody(request.Body) p.proxyFor(resolved).ServeHTTP(writer, request) } @@ -323,6 +363,11 @@ func (p *Proxy) proxyFor(resolved ResolvedAdapter) *httputil.ReverseProxy { session.finish(TokenUsage{}) } } + // 输出侧脱敏:模型回答隐私信息拦截替换(仅 2xx 且启用了输出策略时)。 + if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices && p.outputPolicies != nil { + response.Body = newOutputRedactReadCloser(response.Body, response.Header.Get("Content-Type"), p.outputPolicies) + response.Header.Set("X-Gateway-Output-Redacted", "true") + } return nil } reverseProxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, err error) { @@ -351,6 +396,18 @@ func (p *Proxy) proxyFor(resolved ResolvedAdapter) *httputil.ReverseProxy { return reverseProxy } +// estimateForReserve 复用已有 usage session 的预留估算;不可用时回退 0。 +func estimateForReserve(p *Proxy, request *http.Request, usage *usageSession) int64 { + if usage != nil && usage.reservation.Reserved > 0 { + return usage.reservation.Reserved + } + estimate, err := prepareTokenBudget(request, p.maxBody) + if err != nil { + return 0 + } + return estimate +} + func (p *Proxy) authorized(request *http.Request) (apikey.Principal, error) { presented := strings.TrimSpace(request.Header.Get("X-Gateway-API-Key")) if presented == "" { diff --git a/internal/gateway/usage.go b/internal/gateway/usage.go index d037ade..cea568a 100644 --- a/internal/gateway/usage.go +++ b/internal/gateway/usage.go @@ -22,10 +22,19 @@ const maxUsageDocumentBytes = 2 << 20 type usageSession struct { controller TokenQuotaController reservation TokenReservation - fallback int64 - onFinish func(TokenUsage) - once sync.Once - log *slog.Logger + // modelController/modelReservation 是模型级配额(可选,企业模型总配额)。 + modelController ModelQuotaController + modelReservation any + fallback int64 + onFinish func(TokenUsage) + once sync.Once + log *slog.Logger +} + +// ModelQuotaController 抽象模型级配额控制器,避免 gateway 依赖 modelquota 包。 +type ModelQuotaController interface { + Reserve(ctx context.Context, providerCode, model string, estimate int64, now time.Time) (any, error) + Commit(ctx context.Context, reservation any, actual int64) error } type TokenUsage struct { @@ -64,6 +73,13 @@ func (s *usageSession) finish(usage TokenUsage) { } cancel() } + if s.modelController != nil && s.modelReservation != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + if err := s.modelController.Commit(ctx, s.modelReservation, usage.Total); err != nil && s.log != nil { + s.log.Warn("model quota commit failed", "error", err) + } + cancel() + } if s.onFinish != nil { s.onFinish(usage) } diff --git a/internal/identity/account.go b/internal/identity/account.go index d867ea7..f667041 100644 --- a/internal/identity/account.go +++ b/internal/identity/account.go @@ -82,6 +82,7 @@ const ( PermissionTraceRead = "trace:read" PermissionAgentNodeRead = "agent_node:read" PermissionAgentNodeManage = "agent_node:manage" + PermissionSystemManage = "system:manage" ) var rolePermissions = map[string][]string{ @@ -107,6 +108,7 @@ var rolePermissions = map[string][]string{ PermissionScheduledTaskRead, PermissionScheduledTaskManage, PermissionTraceRead, PermissionAgentNodeRead, PermissionAgentNodeManage, + PermissionSystemManage, }, "auditor": {PermissionProviderRead, PermissionAPIKeyRead, PermissionAuditRead, PermissionUsageRead, PermissionOutboxRead, PermissionContentPolicyRead, PermissionPricingRead, PermissionPromptRead, PermissionKnowledgeRead, PermissionToolRead, PermissionApplicationRead, PermissionNotificationRead, PermissionMCPServerRead, PermissionSkillRead, PermissionDigitalEmployeeRead, PermissionMarketplaceRead, PermissionFileRead, PermissionInboxRead, PermissionScheduledTaskRead, PermissionTraceRead, PermissionAgentNodeRead}, "member": {}, diff --git a/internal/identity/http.go b/internal/identity/http.go index 2013670..b27db45 100644 --- a/internal/identity/http.go +++ b/internal/identity/http.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "strconv" "strings" "time" @@ -42,6 +43,7 @@ type factorRequest struct { func NewHTTPHandler(service *Service) *HTTPHandler { handler := &HTTPHandler{service: service, mux: http.NewServeMux()} handler.mux.HandleFunc("POST /api/v1/admin/login", handler.login(KindAdmin)) + handler.mux.HandleFunc("GET /api/v1/admin/login-logs", handler.loginLogs(KindAdmin)) handler.registerTOTP(KindAdmin, "/api/v1/admin") handler.mux.HandleFunc("GET /api/v1/admin/whoami", handler.whoami(KindAdmin)) handler.mux.HandleFunc("POST /api/v1/admin/password", handler.changePassword(KindAdmin)) @@ -82,6 +84,33 @@ func (h *HTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reques h.mux.ServeHTTP(writer, request) } +func (h *HTTPHandler) loginLogs(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + account, err := h.service.Authenticate(request.Context(), KindAdmin, request.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期") + return + } + if !HasPermission(account, PermissionAuditRead) { + apiresponse.Error(writer, http.StatusForbidden, "缺少审计日志查看权限") + return + } + login := strings.TrimSpace(request.URL.Query().Get("login")) + limit := 50 + if value := request.URL.Query().Get("limit"); value != "" { + if parsed, parseErr := strconv.Atoi(value); parseErr == nil { + limit = parsed + } + } + logs, err := h.service.ListLoginLogs(request.Context(), kind, login, limit) + if err != nil { + apiresponse.Error(writer, http.StatusServiceUnavailable, "登录记录查询失败") + return + } + apiresponse.OK(writer, logs) + } +} + func (h *HTTPHandler) login(kind Kind) http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { // 防爆破:按 IP 的滑动窗口限流,超限返回 429(与账号锁定叠加)。 @@ -109,9 +138,13 @@ func (h *HTTPHandler) login(kind Kind) http.HandlerFunc { } result, err := h.service.Login(request.Context(), kind, login, input.Password) if err != nil { + // 登录失败审计(429 限流在 AllowLogin 阶段已拦截,此处都是真实失败)。 + _ = h.service.RecordLoginLog(request.Context(), kind, login, false, h.service.ClientIP(request), request.UserAgent(), loginFailureReason(err)) h.writeIdentityError(writer, err) return } + // 登录成功(含进入 TOTP 挑战阶段)。 + _ = h.service.RecordLoginLog(request.Context(), kind, login, true, h.service.ClientIP(request), request.UserAgent(), "success") apiresponse.OK(writer, map[string]any{ "token": result.Token, "refreshToken": "", "require_totp": result.RequireTOTP, "temp_token": result.TempToken, @@ -119,6 +152,28 @@ func (h *HTTPHandler) login(kind Kind) http.HandlerFunc { } } +// loginFailureReason 把登录错误归一化为审计用原因码。 +func loginFailureReason(err error) string { + switch { + case errors.Is(err, ErrInvalidCredentials): + return "invalid_credentials" + case errors.Is(err, ErrAccountDisabled): + return "account_disabled" + case errors.Is(err, ErrInvalidTOTP): + return "invalid_totp" + case errors.Is(err, ErrTOTPNotEnabled), errors.Is(err, ErrTOTPSetupRequired): + return "totp_not_configured" + case errors.Is(err, ErrUnavailable): + return "service_unavailable" + default: + var locked LockedError + if errors.As(err, &locked) { + return "account_locked" + } + return "unknown" + } +} + func (h *HTTPHandler) registerTOTP(kind Kind, prefix string) { h.mux.HandleFunc("POST "+prefix+"/login/totp", h.completeTOTPLogin(kind)) h.mux.HandleFunc("GET "+prefix+"/totp/status", h.totpStatus(kind)) @@ -142,9 +197,11 @@ func (h *HTTPHandler) completeTOTPLogin(kind Kind) http.HandlerFunc { } result, err := h.service.CompleteTOTPLogin(request.Context(), kind, input.TempToken, input.Code, input.BackupCode) if err != nil { + _ = h.service.RecordLoginLog(request.Context(), kind, "", false, h.service.ClientIP(request), request.UserAgent(), loginFailureReason(err)) h.writeIdentityError(writer, err) return } + _ = h.service.RecordLoginLog(request.Context(), kind, result.Account.Login, true, h.service.ClientIP(request), request.UserAgent(), "success") apiresponse.OK(writer, map[string]any{"token": result.Token, "refreshToken": "", "require_totp": false}) } } @@ -426,6 +483,13 @@ func adminMenus(account Account) []map[string]any { if HasPermission(account, PermissionScheduledTaskRead) || HasPermission(account, PermissionScheduledTaskManage) { systemChildren = append(systemChildren, map[string]any{"name": "ScheduledTasks", "path": "scheduled-tasks", "component": "/gateway/scheduled-tasks", "meta": map[string]any{"title": "定时任务"}}) } + if HasPermission(account, PermissionAuditRead) { + systemChildren = append(systemChildren, map[string]any{"name": "LoginLogs", "path": "login-logs", "component": "/system/login-logs", "meta": map[string]any{"title": "登录记录"}}) + } + 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 授权"}}) + } 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}) } @@ -442,6 +506,9 @@ func portalMenus() []map[string]any { {"name": "PortalAccess", "path": "access", "component": "/portal/access", "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": "定时任务"}}, + {"name": "PortalMemories", "path": "memories", "component": "/portal/memories", "meta": map[string]any{"title": "记忆管理"}}, + {"name": "PortalLoginLogs", "path": "login-logs", "component": "/portal/login-logs", "meta": map[string]any{"title": "登录记录"}}, }}, } } diff --git a/internal/identity/management.go b/internal/identity/management.go index e3decaf..5c93d42 100644 --- a/internal/identity/management.go +++ b/internal/identity/management.go @@ -24,6 +24,37 @@ var ( type ManagementHTTPHandler struct { service *Service mux *http.ServeMux + // license 提供账号数上限管控(nil 时不限制)。 + license interface { + AccountLimit() int + } +} + +// SetLicenseManager 注入 License 管理器用于账号数管控。 +func (h *ManagementHTTPHandler) SetLicenseManager(manager interface{ AccountLimit() int }) { + h.license = manager +} + +// checkAccountLimit 在创建账号前校验 License 账号数上限。 +func (h *ManagementHTTPHandler) checkAccountLimit(writer http.ResponseWriter, request *http.Request) bool { + if h.license == nil { + return true + } + limit := h.license.AccountLimit() + if limit <= 0 { + return true // 不限 + } + var total int + err := h.service.repository.CountIdentities(request.Context(), &total) + if err != nil { + apiresponse.Error(writer, http.StatusServiceUnavailable, "身份服务暂不可用") + return false + } + if total >= limit { + apiresponse.Error(writer, http.StatusForbidden, fmt.Sprintf("账号数已达 License 上限(%d 个),请联系管理员升级", limit)) + return false + } + return true } type identityInput struct { @@ -42,6 +73,10 @@ func NewManagementHTTPHandler(service *Service) *ManagementHTTPHandler { h.mux.HandleFunc("POST /api/v1/admin/identities/admins", h.create(KindAdmin)) h.mux.HandleFunc("PUT /api/v1/admin/identities/admins/{identity_id}", h.update(KindAdmin)) h.mux.HandleFunc("GET /api/v1/admin/identities/portal-users", h.list(KindPortal)) + h.mux.HandleFunc("GET /api/v1/admin/roles", h.listRoles) + h.mux.HandleFunc("POST /api/v1/admin/roles", h.createRole) + h.mux.HandleFunc("PUT /api/v1/admin/roles/{role_id}", h.updateRole) + h.mux.HandleFunc("DELETE /api/v1/admin/roles/{role_id}", h.deleteRole) h.mux.HandleFunc("POST /api/v1/admin/identities/portal-users", h.create(KindPortal)) h.mux.HandleFunc("PUT /api/v1/admin/identities/portal-users/{identity_id}", h.update(KindPortal)) h.mux.HandleFunc("GET /api/v1/admin/departments", h.listDepartments) @@ -79,6 +114,9 @@ func (h *ManagementHTTPHandler) create(kind Kind) http.HandlerFunc { if !ok { return } + if !h.checkAccountLimit(writer, request) { + return + } input, account, password, ok := h.decode(writer, request, kind, true) if !ok { return @@ -150,6 +188,98 @@ func (h *ManagementHTTPHandler) update(kind Kind) http.HandlerFunc { } } +func (h *ManagementHTTPHandler) listRoles(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request) + if !ok { + return + } + _ = actor + roles, err := h.service.repository.ListRoles(request.Context()) + if err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, roles) +} + +type roleInput struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` +} + +func (h *ManagementHTTPHandler) decodeRole(writer http.ResponseWriter, request *http.Request) (roleInput, bool) { + var input roleInput + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效") + return input, false + } + input.Code = strings.ToLower(strings.TrimSpace(input.Code)) + input.Name = strings.TrimSpace(input.Name) + input.Description = strings.TrimSpace(input.Description) + if !roleCodePattern.MatchString(input.Code) || input.Name == "" || len(input.Name) > 64 || len(input.Description) > 512 { + apiresponse.Error(writer, http.StatusBadRequest, "角色代码或名称格式无效") + return input, false + } + permissions, err := normalizePermissions(input.Permissions) + if err != nil { + apiresponse.Error(writer, http.StatusBadRequest, err.Error()) + return input, false + } + input.Permissions = permissions + return input, true +} + +func (h *ManagementHTTPHandler) createRole(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request) + if !ok { + return + } + input, ok := h.decodeRole(writer, request) + if !ok { + return + } + role, err := h.service.repository.SaveRole(request.Context(), "", input.Code, input.Name, input.Description, input.Permissions, actor.ID, true) + if err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, role) +} + +func (h *ManagementHTTPHandler) updateRole(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request) + if !ok { + return + } + input, ok := h.decodeRole(writer, request) + if !ok { + return + } + role, err := h.service.repository.SaveRole(request.Context(), request.PathValue("role_id"), input.Code, input.Name, input.Description, input.Permissions, actor.ID, false) + if err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, role) +} + +func (h *ManagementHTTPHandler) deleteRole(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request) + if !ok { + return + } + _ = actor + if err := h.service.repository.DeleteRole(request.Context(), request.PathValue("role_id")); err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, map[string]bool{"deleted": true}) +} + func (h *ManagementHTTPHandler) decode(writer http.ResponseWriter, request *http.Request, kind Kind, creating bool) (identityInput, Account, string, bool) { var input identityInput decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) @@ -176,9 +306,19 @@ func (h *ManagementHTTPHandler) decode(writer http.ResponseWriter, request *http apiresponse.Error(writer, http.StatusBadRequest, "账号或显示名称格式无效") return input, Account{}, "", false } - if kind == KindAdmin && input.Role != "" && input.Role != "superadmin" && input.Role != "operator" && input.Role != "auditor" { - apiresponse.Error(writer, http.StatusBadRequest, "管理员角色无效") - return input, Account{}, "", false + if kind == KindAdmin && input.Role != "" { + switch input.Role { + case "superadmin", "operator", "auditor": + default: + // 自定义角色:必须存在于角色表,并把其权限展开到账号 + // permissions(角色权限变更后由管理员重新分配或手动同步)。 + role, roleErr := h.service.repository.FindRole(request.Context(), input.Role) + if roleErr != nil { + apiresponse.Error(writer, http.StatusBadRequest, "角色不存在") + return input, Account{}, "", false + } + input.Permissions = append(input.Permissions, role.Permissions...) + } } if kind == KindPortal && input.Role != "" && input.Role != "member" { apiresponse.Error(writer, http.StatusBadRequest, "门户角色无效") @@ -230,6 +370,8 @@ func (h *ManagementHTTPHandler) requirePermission(writer http.ResponseWriter, re return account, true } +var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`) + func (h *ManagementHTTPHandler) writeError(writer http.ResponseWriter, err error) { switch { case errors.Is(err, ErrNotFound): diff --git a/internal/identity/repository.go b/internal/identity/repository.go index 746c629..439a846 100644 --- a/internal/identity/repository.go +++ b/internal/identity/repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "strings" "time" @@ -281,6 +282,92 @@ func expectOne(pool *pgxpool.Pool, ctx context.Context, query string, arguments return nil } +// LoginLog 是一条登录尝试记录。 +type LoginLog struct { + ID string `json:"id"` + Kind string `json:"kind"` + Login string `json:"login"` + Success bool `json:"success"` + IP *string `json:"ip,omitempty"` + UserAgent string `json:"user_agent"` + Reason string `json:"reason"` + CreatedAt time.Time `json:"created_at"` +} + +// RecordLoginLog 记录一次登录尝试(成功或失败)。 +func (r *Repository) RecordLoginLog(ctx context.Context, kind Kind, login string, success bool, ip, userAgent, reason string) error { + if r.pool == nil { + return ErrUnavailable + } + id, err := platformid.NewUUID() + if err != nil { + return err + } + ipValue := strings.TrimSpace(ip) + _, err = r.pool.Exec(ctx, `INSERT INTO gateway.login_logs(id,kind,login,success,ip,user_agent,reason) VALUES($1,$2,$3,$4,nullif($5,'')::inet,$6,$7)`, + id, string(kind), login, success, ipValue, truncateText(userAgent, 256), truncateText(reason, 64)) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return nil +} + +// ListLoginLogs 查询登录记录(按时间倒序)。 +func (r *Repository) ListLoginLogs(ctx context.Context, kind Kind, login string, limit int) ([]LoginLog, error) { + if r.pool == nil { + return nil, ErrUnavailable + } + if limit < 1 || limit > 500 { + limit = 50 + } + query := `SELECT id::text,kind,login,success,ip::text,user_agent,reason,created_at FROM gateway.login_logs WHERE kind=$1` + args := []any{string(kind)} + if login != "" { + args = append(args, login) + query += ` AND login=$` + strconv.Itoa(len(args)) + } + query += ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(len(args)+1) + args = append(args, limit) + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer rows.Close() + items := []LoginLog{} + for rows.Next() { + var item LoginLog + var ip *string + if err := rows.Scan(&item.ID, &item.Kind, &item.Login, &item.Success, &ip, &item.UserAgent, &item.Reason, &item.CreatedAt); err != nil { + return nil, err + } + if ip != nil && *ip != "" { + item.IP = ip + } + items = append(items, item) + } + return items, rows.Err() +} + +func truncateText(value string, limit int) string { + value = strings.TrimSpace(value) + if len(value) <= limit { + return value + } + return value[:limit] +} + +// CountIdentities 统计管理员与门户账号总数(License 账号数管控用)。 +func (r *Repository) CountIdentities(ctx context.Context, total *int) error { + if r.pool == nil { + return ErrUnavailable + } + err := r.pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM gateway.admin_accounts) + (SELECT count(*) FROM gateway.portal_users)`).Scan(total) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return nil +} + func (r *Repository) CreateAdmin(ctx context.Context, login, displayName, role, passwordHash string) (string, error) { if r.pool == nil { return "", ErrUnavailable diff --git a/internal/identity/roles.go b/internal/identity/roles.go new file mode 100644 index 0000000..4eb7041 --- /dev/null +++ b/internal/identity/roles.go @@ -0,0 +1,132 @@ +package identity + +import ( + "context" + "errors" + "fmt" + "strings" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" +) + +// Role 是一个自定义角色定义(内置角色在代码中,不落库)。 +type Role struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + Builtin bool `json:"builtin"` + CreatedBy *string `json:"created_by,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ListRoles 返回全部角色(内置 + 自定义)。 +func (r *Repository) ListRoles(ctx context.Context) ([]Role, error) { + if r.pool == nil { + return nil, ErrUnavailable + } + rows, err := r.pool.Query(ctx, `SELECT id::text,code,name,description,permissions,builtin,created_by::text,created_at,updated_at FROM gateway.roles ORDER BY builtin DESC,created_at`) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer rows.Close() + items := []Role{} + for rows.Next() { + var item Role + var createdBy *string + if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Description, &item.Permissions, &item.Builtin, &createdBy, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + item.CreatedBy = createdBy + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + // 合并代码内置角色(带可读名称)。 + builtinNames := map[string]string{"superadmin": "超级管理员", "operator": "运维操作员", "auditor": "审计员", "member": "普通成员"} + for code, permissions := range rolePermissions { + items = append(items, Role{ID: "builtin:" + code, Code: code, Name: builtinNames[code], Permissions: permissions, Builtin: true}) + } + return items, nil +} + +// FindRole 按 code 查询角色;builtin 角色由代码返回。 +func (r *Repository) FindRole(ctx context.Context, code string) (Role, error) { + code = strings.ToLower(strings.TrimSpace(code)) + if permissions, ok := rolePermissions[code]; ok { + return Role{Code: code, Name: code, Permissions: permissions, Builtin: true}, nil + } + var item Role + var createdBy *string + err := r.pool.QueryRow(ctx, `SELECT id::text,code,name,description,permissions,builtin,created_by::text,created_at,updated_at FROM gateway.roles WHERE code=$1`, code).Scan(&item.ID, &item.Code, &item.Name, &item.Description, &item.Permissions, &item.Builtin, &createdBy, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Role{}, ErrNotFound + } + if err != nil { + return Role{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + item.CreatedBy = createdBy + return item, nil +} + +// SaveRole 创建或更新自定义角色;内置角色禁止修改。 +func (r *Repository) SaveRole(ctx context.Context, id, code, name, description string, permissions []string, actorID string, create bool) (Role, error) { + if r.pool == nil { + return Role{}, ErrUnavailable + } + code = strings.ToLower(strings.TrimSpace(code)) + if _, builtin := rolePermissions[code]; builtin { + return Role{}, errors.New("内置角色不可修改") + } + if create { + id, err := platformid.NewUUID() + if err != nil { + return Role{}, err + } + _, err = r.pool.Exec(ctx, `INSERT INTO gateway.roles(id,code,name,description,permissions,created_by) VALUES($1,$2,$3,$4,$5,$6)`, id, code, strings.TrimSpace(name), strings.TrimSpace(description), permissions, actorID) + if err != nil { + return Role{}, mapRoleError(err) + } + return r.FindRole(ctx, code) + } + tag, err := r.pool.Exec(ctx, `UPDATE gateway.roles SET name=$2,description=$3,permissions=$4,updated_at=clock_timestamp() WHERE id=$1 AND NOT builtin`, id, strings.TrimSpace(name), strings.TrimSpace(description), permissions) + if err != nil { + return Role{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if tag.RowsAffected() == 0 { + return Role{}, ErrNotFound + } + var item Role + item, err = r.FindRole(ctx, code) + if err != nil { + return Role{}, err + } + return item, nil +} + +// DeleteRole 删除自定义角色(内置角色禁止)。 +func (r *Repository) DeleteRole(ctx context.Context, id string) error { + if r.pool == nil { + return ErrUnavailable + } + tag, err := r.pool.Exec(ctx, `DELETE FROM gateway.roles WHERE id=$1 AND NOT builtin`, id) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func mapRoleError(err error) error { + var pgError interface{ Code() string } + if errors.As(err, &pgError) && pgError.Code() == "23505" { + return ErrIdentityConflict + } + return fmt.Errorf("%w: %v", ErrUnavailable, err) +} diff --git a/internal/identity/service.go b/internal/identity/service.go index e49445d..e1c2ce2 100644 --- a/internal/identity/service.go +++ b/internal/identity/service.go @@ -77,6 +77,16 @@ func (s *Service) AllowLogin(ctx context.Context, ip string) bool { return s.limiter == nil || s.limiter.Allow(ctx, ip) } +// RecordLoginLog 记录登录审计(成功/失败与原因)。 +func (s *Service) RecordLoginLog(ctx context.Context, kind Kind, login string, success bool, ip, userAgent, reason string) error { + return s.repository.RecordLoginLog(ctx, kind, login, success, ip, userAgent, reason) +} + +// ListLoginLogs 查询登录记录。 +func (s *Service) ListLoginLogs(ctx context.Context, kind Kind, login string, limit int) ([]LoginLog, error) { + return s.repository.ListLoginLogs(ctx, kind, login, limit) +} + // ClientIP 提取登录限流使用的客户端 IP:仅在直连对端是可信代理时采信 // X-Forwarded-For,否则直接用对端地址,防止伪造头绕过限流。 func (s *Service) ClientIP(r *http.Request) string { diff --git a/internal/memory/portal_http.go b/internal/memory/portal_http.go new file mode 100644 index 0000000..082a472 --- /dev/null +++ b/internal/memory/portal_http.go @@ -0,0 +1,137 @@ +package memory + +import ( + "encoding/json" + "net/http" + "strconv" + + "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/portal/memories", h.list) + h.mux.HandleFunc("POST /api/v1/portal/memories", h.save) + h.mux.HandleFunc("PUT /api/v1/portal/memories/{id}", h.save) + h.mux.HandleFunc("DELETE /api/v1/portal/memories/{id}", h.delete) + h.mux.HandleFunc("POST /api/v1/portal/memories/recall", h.recall) + return h +} + +func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } + +func (h *HTTPHandler) 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 +} + +type memoryInput struct { + Category string `json:"category"` + Content string `json:"content"` + Importance int `json:"importance"` + SharedWith []string `json:"shared_with"` + Source string `json:"source"` +} + +func (h *HTTPHandler) decode(w http.ResponseWriter, r *http.Request) (memoryInput, bool) { + var input memoryInput + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil { + apiresponse.Error(w, http.StatusBadRequest, "请求格式无效") + return input, false + } + return input, true +} + +func (h *HTTPHandler) list(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + items, err := h.service.List(r.Context(), OwnerUser, a.ID, 200) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "记忆查询失败") + return + } + apiresponse.OK(w, items) +} + +func (h *HTTPHandler) save(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + input, ok := h.decode(w, r) + if !ok { + return + } + entry, err := h.service.Save(r.Context(), OwnerUser, a.ID, r.PathValue("id"), input.Category, input.Content, input.Source, input.Importance, input.SharedWith, a.ID) + if err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, entry) +} + +func (h *HTTPHandler) delete(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + entry, err := h.service.Get(r.Context(), r.PathValue("id")) + if err != nil { + apiresponse.Error(w, http.StatusNotFound, "记忆不存在") + return + } + // 仅本人或共享给本人的可删。 + if entry.OwnerKind == OwnerUser && entry.OwnerID != a.ID { + apiresponse.Error(w, http.StatusForbidden, "无权删除该记忆") + 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}) +} + +func (h *HTTPHandler) recall(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + var input struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil || input.Query == "" { + apiresponse.Error(w, http.StatusBadRequest, "查询内容不能为空") + return + } + if input.Limit <= 0 { + input.Limit = 5 + } + items, err := h.service.Recall(r.Context(), a.ID, a.DepartmentID, input.Query, input.Limit) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "记忆召回失败") + return + } + apiresponse.OK(w, items) +} + +var _ = strconv.Itoa diff --git a/internal/memory/service.go b/internal/memory/service.go new file mode 100644 index 0000000..928100b --- /dev/null +++ b/internal/memory/service.go @@ -0,0 +1,293 @@ +// Package memory 实现多层记忆管理:用户个人/部门/全局记忆集合, +// 向量化语义召回(复用 Ollama embedding),支持向用户授权与衰减清理。 +package memory + +import ( + "context" + "errors" + "strconv" + "fmt" + "strings" + "time" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrNotFound = errors.New("记忆不存在") + ErrUnavailable = errors.New("记忆服务不可用") +) + +// OwnerKind 记忆归属层级。 +type OwnerKind string + +const ( + OwnerUser OwnerKind = "user" + OwnerDepartment OwnerKind = "department" + OwnerGlobal OwnerKind = "global" +) + +// Entry 是一条记忆。 +type Entry struct { + ID string `json:"id"` + OwnerKind OwnerKind `json:"owner_kind"` + OwnerID string `json:"owner_id"` + Category string `json:"category"` + Content string `json:"content"` + Importance int `json:"importance"` + SharedWith []string `json:"shared_with"` + Source string `json:"source"` + LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"` + CreatedBy *string `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Embedder 生成文本向量(复用知识库的 Ollama embedder)。 +type Embedder interface { + Embed(ctx context.Context, texts []string) ([][]float32, error) +} + +// Service 记忆管理服务。 +type Service struct { + pool *pgxpool.Pool + embedder Embedder +} + +func NewService(pool *pgxpool.Pool, embedder Embedder) *Service { + return &Service{pool: pool, embedder: embedder} +} + +func (s *Service) SetEmbedder(embedder Embedder) { s.embedder = embedder } + +const entrySelect = `SELECT id::text,owner_kind,owner_id,category,content,importance,shared_with::text[],source,last_accessed_at,created_by::text,created_at,updated_at FROM gateway.memory_entries` + +func (s *Service) scanEntry(row interface{ Scan(dest ...any) error }) (Entry, error) { + var e Entry + var shared []string + var createdBy *string + err := row.Scan(&e.ID, &e.OwnerKind, &e.OwnerID, &e.Category, &e.Content, &e.Importance, &shared, &e.Source, &e.LastAccessedAt, &createdBy, &e.CreatedAt, &e.UpdatedAt) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return Entry{}, ErrNotFound + } + return Entry{}, err + } + e.SharedWith = shared + e.CreatedBy = createdBy + return e, nil +} + +// Save 创建或更新一条记忆。ownerID 为空时按 kind 处理(global 无归属)。 +func (s *Service) Save(ctx context.Context, kind OwnerKind, ownerID, id, category, content, source string, importance int, sharedWith []string, actorID string) (Entry, error) { + if s == nil || s.pool == nil { + return Entry{}, ErrUnavailable + } + content = strings.TrimSpace(content) + category = strings.TrimSpace(category) + if content == "" || len(content) > 8000 { + return Entry{}, errors.New("记忆内容必须为 1-8000 字符") + } + if category == "" { + category = "general" + } + if len(category) > 64 || len(source) > 128 { + return Entry{}, errors.New("分类或来源过长") + } + if importance < 1 { + importance = 5 + } + if importance > 10 { + importance = 10 + } + if sharedWith == nil { + sharedWith = []string{} // NOT NULL 列:空授权为显式空数组 + } + var err error + var embedding string + if s.embedder != nil { + vectors, err := s.embedder.Embed(ctx, []string{content}) + if err == nil && len(vectors) == 1 && len(vectors[0]) == 1024 { + // pgx 不识别 vector 类型的二进制编码:与知识库一致,用文本格式 + // "[0.1,0.2,...]" 配合 ::vector 转换。 + embedding = "[" + strings.Trim(strings.Join(joinFloats(vectors[0]), ","), " ") + "]" + } + } + // 向量维度必须为 1024 与列匹配。 + validVector := embedding != "" && strings.HasPrefix(embedding, "[") + + if id == "" { + var newID string + newID, err = platformid.NewUUID() + if err != nil { + return Entry{}, err + } + id = newID + if validVector { + _, err = s.pool.Exec(ctx, `INSERT INTO gateway.memory_entries(id,owner_kind,owner_id,category,content,importance,embedding,shared_with,source,created_by) VALUES($1,$2,$3,$4,$5,$6,$7::vector,$8,$9,$10)`, id, kind, ownerID, category, content, importance, embedding, sharedWith, source, actorID) + } else { + _, err = s.pool.Exec(ctx, `INSERT INTO gateway.memory_entries(id,owner_kind,owner_id,category,content,importance,shared_with,source,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, id, kind, ownerID, category, content, importance, sharedWith, source, actorID) + } + if err != nil { + return Entry{}, err + } + } else { + var tag interface{ RowsAffected() int64 } + if validVector { + tag, err = s.pool.Exec(ctx, `UPDATE gateway.memory_entries SET category=$3,content=$4,importance=$5,embedding=$6::vector,shared_with=$7,source=$8,updated_at=clock_timestamp() WHERE id=$1 AND owner_kind=$2`, id, kind, category, content, importance, embedding, sharedWith, source) + } else { + tag, err = s.pool.Exec(ctx, `UPDATE gateway.memory_entries SET category=$3,content=$4,importance=$5,shared_with=$6,source=$7,updated_at=clock_timestamp() WHERE id=$1 AND owner_kind=$2`, id, kind, category, content, importance, sharedWith, source) + } + if err != nil { + return Entry{}, err + } + if tag.RowsAffected() == 0 { + return Entry{}, ErrNotFound + } + } + return s.Get(ctx, id) +} + +func (s *Service) Get(ctx context.Context, id string) (Entry, error) { + if s == nil || s.pool == nil { + return Entry{}, ErrUnavailable + } + return s.scanEntry(s.pool.QueryRow(ctx, entrySelect+` WHERE id=$1`, id)) +} + +// List 列出归属下的记忆(global 与 department 可见性由调用方合并)。 +func (s *Service) List(ctx context.Context, kind OwnerKind, ownerID string, limit int) ([]Entry, error) { + if s == nil || s.pool == nil { + return nil, ErrUnavailable + } + if limit < 1 || limit > 500 { + limit = 100 + } + rows, err := s.pool.Query(ctx, entrySelect+` WHERE owner_kind=$1 AND owner_id=$2 ORDER BY importance DESC,updated_at DESC LIMIT $3`, kind, ownerID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Entry{} + for rows.Next() { + e, err := s.scanEntry(rows) + if err != nil { + return nil, err + } + items = append(items, e) + } + return items, rows.Err() +} + +// Delete 删除记忆(global 允许任意管理员)。 +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.memory_entries WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// Recall 语义召回:按向量相似度返回与 query 最相关的记忆。 +// scopes 决定搜索范围(user 自己的 + shared_with 含用户的 + department + global)。 +func (s *Service) Recall(ctx context.Context, userID string, departmentID *string, query string, limit int) ([]Entry, error) { + if s == nil || s.pool == nil { + return nil, ErrUnavailable + } + if limit < 1 || limit > 20 { + limit = 5 + } + query = strings.TrimSpace(query) + if query == "" { + return nil, errors.New("查询内容不能为空") + } + var embedding string + if s.embedder != nil { + vectors, err := s.embedder.Embed(ctx, []string{query}) + if err == nil && len(vectors) == 1 && len(vectors[0]) == 1024 { + embedding = "[" + strings.Trim(strings.Join(joinFloats(vectors[0]), ","), " ") + "]" + } + } + if embedding == "" { + // 向量不可用(embedding 关闭/Ollama 故障):按关键字+重要度召回。 + return s.recallFallback(ctx, userID, departmentID, query, limit) + } + scope := `(owner_kind='global' OR (owner_kind='user' AND owner_id=$1) OR (owner_kind='department' AND owner_id=$2) OR $1::uuid = ANY(shared_with))` + rows, err := s.pool.Query(ctx, entrySelect+` WHERE `+scope+` AND embedding IS NOT NULL ORDER BY embedding <=> $3::vector LIMIT $4`, userID, departmentID, embedding, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Entry{} + for rows.Next() { + e, err := s.scanEntry(rows) + if err != nil { + return nil, err + } + items = append(items, e) + } + // 记录访问时间(衰减依据)。 + if len(items) > 0 { + ids := make([]string, 0, len(items)) + for _, e := range items { + ids = append(ids, e.ID) + } + _, _ = s.pool.Exec(ctx, `UPDATE gateway.memory_entries SET last_accessed_at=clock_timestamp() WHERE id = ANY($1::uuid[])`, ids) + } + return items, rows.Err() +} + +func (s *Service) recallFallback(ctx context.Context, userID string, departmentID *string, query string, limit int) ([]Entry, error) { + scope := `(owner_kind='global' OR (owner_kind='user' AND owner_id=$1) OR (owner_kind='department' AND owner_id=$2) OR $1::uuid = ANY(shared_with))` + rows, err := s.pool.Query(ctx, entrySelect+` WHERE `+scope+` AND (content ILIKE '%'||$3||'%' OR to_tsvector('simple', content) @@ plainto_tsquery('simple', $3)) ORDER BY importance DESC,updated_at DESC LIMIT $4`, userID, departmentID, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Entry{} + for rows.Next() { + e, err := s.scanEntry(rows) + if err != nil { + return nil, err + } + items = append(items, e) + } + return items, rows.Err() +} + +// joinFloats 把 float32 切片格式化为 pgvector 文本。 +func joinFloats(values []float32) []string { + out := make([]string, len(values)) + for i, v := range values { + out[i] = strconv.FormatFloat(float64(v), 'f', -1, 32) + } + return out +} + +// Decay 衰减清理:低重要度且长期未访问的记忆降权并最终删除 +// (由 maintenance worker 定期调用)。 +func (s *Service) Decay(ctx context.Context, now time.Time, inactiveDays int) (int64, error) { + if s == nil || s.pool == nil { + return 0, ErrUnavailable + } + if inactiveDays < 7 { + inactiveDays = 30 + } + tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.memory_entries + WHERE importance <= 3 AND (last_accessed_at IS NULL OR last_accessed_at < $1::timestamptz)`, + now.AddDate(0, 0, -inactiveDays)) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// String 便捷格式化。 +func (s *Service) String() string { return fmt.Sprintf("memory-service") } diff --git a/internal/modelquota/admin_http.go b/internal/modelquota/admin_http.go new file mode 100644 index 0000000..a50f3bc --- /dev/null +++ b/internal/modelquota/admin_http.go @@ -0,0 +1,98 @@ +package modelquota + +import ( + "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/model-quotas", h.list) + h.mux.HandleFunc("POST /api/v1/admin/model-quotas", h.save) + h.mux.HandleFunc("PUT /api/v1/admin/model-quotas/{quota_id}", h.save) + h.mux.HandleFunc("DELETE /api/v1/admin/model-quotas/{quota_id}", h.delete) + 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.PermissionPricingRead); !ok { + return + } + items, err := h.service.List(r.Context()) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "模型配额查询失败") + return + } + apiresponse.OK(w, items) +} + +type quotaInput struct { + ProviderCode string `json:"provider_code"` + ModelPattern string `json:"model_pattern"` + MonthlyTokenQuota int64 `json:"monthly_token_quota"` + Enabled *bool `json:"enabled"` +} + +func (h *HTTPHandler) save(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionPricingManage); !ok { + return + } + var input quotaInput + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != 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("quota_id"), input.ProviderCode, input.ModelPattern, input.MonthlyTokenQuota, enabled) + if err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + _ = h.service.Reload(r.Context()) + apiresponse.OK(w, item) +} + +func (h *HTTPHandler) delete(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionPricingManage); !ok { + return + } + if err := h.service.Delete(r.Context(), r.PathValue("quota_id")); err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + _ = h.service.Reload(r.Context()) + apiresponse.OK(w, map[string]bool{"deleted": true}) +} + +var _ = strings.TrimSpace diff --git a/internal/modelquota/service.go b/internal/modelquota/service.go new file mode 100644 index 0000000..4201286 --- /dev/null +++ b/internal/modelquota/service.go @@ -0,0 +1,313 @@ +// Package modelquota 实现模型级 Token 配额:按 Provider+模型模式设置 +// 企业总配额,所有 API Key 共享同一自然月计数(与 API Key 级配额叠加), +// 用于模型级成本管控。 +package modelquota + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" +) + +var ErrUnavailable = errors.New("model quota unavailable") + +// Quota 是一条模型配额记录。 +type Quota struct { + ID string `json:"id"` + ProviderCode string `json:"provider_code"` + ModelPattern string `json:"model_pattern"` + MonthlyTokenQuota int64 `json:"monthly_token_quota"` + Enabled bool `json:"enabled"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Service 持有配额快照(定时刷新)并提供 Redis 原子预留/提交。 +type Service struct { + pool *pgxpool.Pool + client *redis.Client + logger *slog.Logger + snapshot atomic.Pointer[quotaSnapshot] + reserve *redis.Script + commitScript *redis.Script + mu sync.Mutex // 保护 admin 写路径 +} + +type quotaSnapshot struct { + quotas []Quota +} + +// NewService 创建服务(pool=PostgreSQL, client=critical Redis)。 +func NewService(pool *pgxpool.Pool, client *redis.Client, logger *slog.Logger) *Service { + return &Service{ + pool: pool, client: client, logger: logger, + reserve: redis.NewScript(modelReserveScript), commitScript: redis.NewScript(modelCommitScript), + } +} + +// Reload 从数据库刷新配额快照。 +func (s *Service) Reload(ctx context.Context) error { + if s == nil || s.pool == nil { + return nil + } + rows, err := s.pool.Query(ctx, `SELECT id::text,provider_code,model_pattern,monthly_token_quota,enabled,updated_at FROM gateway.model_quotas WHERE enabled ORDER BY provider_code,model_pattern`) + if err != nil { + return err + } + defer rows.Close() + items := []Quota{} + for rows.Next() { + var q Quota + if err := rows.Scan(&q.ID, &q.ProviderCode, &q.ModelPattern, &q.MonthlyTokenQuota, &q.Enabled, &q.UpdatedAt); err != nil { + return err + } + items = append(items, q) + } + if err := rows.Err(); err != nil { + return err + } + s.snapshot.Store("aSnapshot{quotas: items}) + return nil +} + +// Run 周期刷新快照。 +func (s *Service) Run(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = 30 * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.Reload(ctx); err != nil && s.logger != nil { + s.logger.Warn("model quota refresh failed; retaining last snapshot", "error", err) + } + } + } +} + +// Lookup 返回匹配 (provider, model) 的最高配额(模型模式最具体优先); +// 未配置返回 0(不限制)。 +func (s *Service) Lookup(providerCode, model string) int64 { + if s == nil { + return 0 + } + current := s.snapshot.Load() + if current == nil { + return 0 + } + providerCode = strings.ToLower(providerCode) + best := int64(0) + bestLen := -1 + for _, q := range current.quotas { + if q.ProviderCode != providerCode { + continue + } + if !matchPattern(q.ModelPattern, model) { + continue + } + // 更具体的模式(更长前缀)优先;同长度取配额更大者(防御性)。 + if len(q.ModelPattern) > bestLen || (len(q.ModelPattern) == bestLen && q.MonthlyTokenQuota > best) { + best = q.MonthlyTokenQuota + bestLen = len(q.ModelPattern) + } + } + return best +} + +func matchPattern(pattern, model string) bool { + pattern = strings.TrimSpace(pattern) + if pattern == "" || pattern == "*" { + return true + } + if strings.HasSuffix(pattern, "*") { + return strings.HasPrefix(model, strings.TrimSuffix(pattern, "*")) + } + return pattern == model +} + +// Reserve 为 (provider, model) 的月度计数预留 estimate;Allowed=false 表示超限。 +// 返回 any 以适配 gateway.ModelQuotaController 接口。 +func (s *Service) Reserve(ctx context.Context, providerCode, model string, estimate int64, now time.Time) (any, error) { + if s == nil || s.client == nil { + return Reservation{}, ErrUnavailable + } + if estimate < 0 { + estimate = 0 + } + now = now.UTC() + key := monthlyKey(providerCode, model, now) + reset := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC) + quota := s.Lookup(providerCode, model) + if quota <= 0 { + return Reservation{Allowed: true}, nil + } + result, err := s.reserve.Run(ctx, s.client, []string{key}, estimate, quota, int64(reset.Sub(now).Seconds())+86400).Slice() + if err != nil || len(result) != 2 { + return Reservation{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + allowed, _ := result[0].(int64) + current, _ := result[1].(int64) + return Reservation{ + Allowed: allowed == 1, Key: key, Reserved: estimate, Limit: quota, + Remaining: max(quota-current, 0), ResetAt: reset, + }, nil +} + +// Reservation 是一次模型配额预留。 +type Reservation struct { + Allowed bool + Key string + Reserved int64 + Limit int64 + Remaining int64 + ResetAt time.Time +} + +// 供 gateway 通过接口断言读取(避免依赖具体类型)。 +func (r Reservation) AllowedFlag() bool { return r.Allowed } +func (r Reservation) RemainingTokens() int64 { return max(r.Remaining, 0) } +func (r Reservation) ResetTime() time.Time { return r.ResetAt } + +// Commit 按实际用量回写(与预留的差额)。reservation 为 Reserve 返回值。 +func (s *Service) Commit(ctx context.Context, reservation any, actual int64) error { + res, ok := reservation.(Reservation) + if !ok { + return errors.New("invalid reservation type") + } + return s.commitOnce(ctx, res, actual) +} + +func (s *Service) commitOnce(ctx context.Context, reservation Reservation, actual int64) error { + if s == nil || s.client == nil || reservation.Key == "" || !reservation.Allowed { + return nil + } + if actual < 0 { + actual = 0 + } + if _, err := s.commitScript.Run(ctx, s.client, []string{reservation.Key}, actual-reservation.Reserved).Result(); err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return nil +} + +func monthlyKey(providerCode, model string, now time.Time) string { + return "gateway:model-token:" + providerCode + ":" + model + ":" + now.Format("200601") +} + +const modelReserveScript = ` +local estimate = tonumber(ARGV[1]) +local quota = tonumber(ARGV[2]) +local current = tonumber(redis.call('GET', KEYS[1]) or '0') +if quota > 0 and current + estimate > quota then + return {0, current} +end +if estimate > 0 then + current = redis.call('INCRBY', KEYS[1], estimate) + if current == estimate then redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) end +elseif redis.call('EXISTS', KEYS[1]) == 0 then + redis.call('SET', KEYS[1], 0, 'EX', tonumber(ARGV[3])) +end +return {1, current} +` + +const modelCommitScript = ` +local delta = tonumber(ARGV[1]) +if redis.call('EXISTS', KEYS[1]) == 0 then + return 0 +end +local current = tonumber(redis.call('GET', KEYS[1]) or '0') +local updated = current + delta +if updated < 0 then updated = 0 end +redis.call('SET', KEYS[1], updated, 'KEEPTTL') +return updated +` + +// --- 管理端 CRUD --- + +// List 返回全部配额记录。 +func (s *Service) List(ctx context.Context) ([]Quota, error) { + if s == nil || s.pool == nil { + return nil, ErrUnavailable + } + rows, err := s.pool.Query(ctx, `SELECT id::text,provider_code,model_pattern,monthly_token_quota,enabled,updated_at FROM gateway.model_quotas ORDER BY provider_code,model_pattern`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Quota{} + for rows.Next() { + var q Quota + if err := rows.Scan(&q.ID, &q.ProviderCode, &q.ModelPattern, &q.MonthlyTokenQuota, &q.Enabled, &q.UpdatedAt); err != nil { + return nil, err + } + items = append(items, q) + } + return items, rows.Err() +} + +// Save 创建或更新一条配额。 +func (s *Service) Save(ctx context.Context, id, providerCode, modelPattern string, quota int64, enabled bool) (Quota, error) { + if s == nil || s.pool == nil { + return Quota{}, ErrUnavailable + } + providerCode = strings.ToLower(strings.TrimSpace(providerCode)) + modelPattern = strings.TrimSpace(modelPattern) + if providerCode == "" || len(providerCode) > 64 || modelPattern == "" || len(modelPattern) > 255 || quota <= 0 { + return Quota{}, errors.New("供应商代码、模型模式或配额无效") + } + if id == "" { + newID, err := platformid.NewUUID() + if err != nil { + return Quota{}, err + } + id = newID + _, err = s.pool.Exec(ctx, `INSERT INTO gateway.model_quotas(id,provider_code,model_pattern,monthly_token_quota,enabled) VALUES($1,$2,$3,$4,$5) ON CONFLICT(provider_code,model_pattern) DO UPDATE SET monthly_token_quota=$4,enabled=$5,updated_at=clock_timestamp()`, id, providerCode, modelPattern, quota, enabled) + if err != nil { + return Quota{}, err + } + } else { + tag, err := s.pool.Exec(ctx, `UPDATE gateway.model_quotas SET provider_code=$2,model_pattern=$3,monthly_token_quota=$4,enabled=$5,updated_at=clock_timestamp() WHERE id=$1`, id, providerCode, modelPattern, quota, enabled) + if err != nil { + return Quota{}, err + } + if tag.RowsAffected() == 0 { + return Quota{}, errors.New("配额记录不存在") + } + } + var q Quota + err := s.pool.QueryRow(ctx, `SELECT id::text,provider_code,model_pattern,monthly_token_quota,enabled,updated_at FROM gateway.model_quotas WHERE id=$1`, id).Scan(&q.ID, &q.ProviderCode, &q.ModelPattern, &q.MonthlyTokenQuota, &q.Enabled, &q.UpdatedAt) + return q, err +} + +// Delete 删除一条配额。 +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.model_quotas WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errors.New("配额记录不存在") + } + return nil +} + +// String 供日志使用。 +func (r Reservation) String() string { + return strconv.FormatInt(r.Remaining, 10) +} diff --git a/internal/platform/config/config.go b/internal/platform/config/config.go index 1632985..488cb40 100644 --- a/internal/platform/config/config.go +++ b/internal/platform/config/config.go @@ -28,6 +28,12 @@ type Config struct { Embeddings Embeddings Inbox Inbox Scheduler Scheduler + License License +} + +// License 配置 License 授权(LICENSE_FILE 指向签名文件;为空=社区版 Free)。 +type License struct { + FilePath string } type Server struct { @@ -253,6 +259,9 @@ func Load() (Config, error) { BatchSize: intValue("SCHEDULER_BATCH_SIZE", 10), MaxAttempts: intValue("SCHEDULER_MAX_ATTEMPTS", 3), }, + License: License{ + FilePath: strings.TrimSpace(os.Getenv("LICENSE_FILE")), + }, } return cfg, cfg.Validate() diff --git a/internal/platform/license/admin_http.go b/internal/platform/license/admin_http.go new file mode 100644 index 0000000..512db65 --- /dev/null +++ b/internal/platform/license/admin_http.go @@ -0,0 +1,65 @@ +package license + +import ( + "encoding/json" + "net/http" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" +) + +// HTTPHandler 提供管理端 License 查看与上传接口。 +type HTTPHandler struct { + manager *Manager + identity *identity.Service + mux *http.ServeMux +} + +func NewHTTPHandler(manager *Manager, identityService *identity.Service) *HTTPHandler { + h := &HTTPHandler{manager: manager, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/license", h.get) + h.mux.HandleFunc("POST /api/v1/admin/license", h.upload) + 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, "缺少 License 管理权限") + return identity.Account{}, false + } + return account, true +} + +func (h *HTTPHandler) get(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionSystemManage); !ok { + return + } + apiresponse.OK(w, h.manager.Summary()) +} + +func (h *HTTPHandler) upload(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionSystemManage); !ok { + return + } + var input struct { + Content string `json:"content"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil || len(input.Content) > 256<<10 { + apiresponse.Error(w, http.StatusBadRequest, "请求格式无效") + return + } + if err := h.manager.Load([]byte(input.Content)); err != nil { + apiresponse.Error(w, http.StatusBadRequest, FormatError(err)) + return + } + apiresponse.OK(w, h.manager.Summary()) +} diff --git a/internal/platform/license/license.go b/internal/platform/license/license.go new file mode 100644 index 0000000..29545d5 --- /dev/null +++ b/internal/platform/license/license.go @@ -0,0 +1,258 @@ +// Package license 实现平台 License 授权校验与账号数管控。 +// +// License 是一个 JSON 文件(路径由 LICENSE_FILE 指定),内容为声明字段 + +// HMAC-SHA256 签名(密钥由 CREDENTIAL_MASTER_KEY 派生)。未配置 LICENSE_FILE +// 时按社区版 Free(3 账号)处理;管理员可通过管理端上传 License 热更新。 +package license + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "sync" + "time" +) + +// Edition 是版本枚举;功能矩阵以 features 列表为准,edition 只做展示与 +// 默认能力集合。 +const ( + EditionFree = "free" + EditionPro = "pro" + EditionUltra = "ultra" + DefaultAccountLimit = 3 // 未配置 License 时按社区版 Free +) + +var ErrInvalidLicense = errors.New("license 文件无效或签名不匹配") +var ErrLicenseExpired = errors.New("license 已过期") + +// Claims 是 License 的声明部分(不含签名)。 +type Claims struct { + Edition string `json:"edition"` + IssuedTo string `json:"issued_to"` + MaxAccounts int `json:"max_accounts"` // 0 = 不限 + Features []string `json:"features"` // 额外授权特性名(预留) + NotBefore string `json:"not_before"` // RFC3339 + NotAfter string `json:"not_after"` // RFC3339,空 = 永久 +} + +// License 是完整的 License 文件内容。 +type License struct { + Claims + Signature string `json:"signature"` // base64(HMAC-SHA256(claimsCanonicalJSON, key)) +} + +// Manager 持有当前 License 状态,支持热更新。 +type Manager struct { + mu sync.RWMutex + filePath string + master []byte + current License +} + +// NewManager 创建 License 管理器并从 filePath 加载(路径为空表示 Free 版)。 +func NewManager(filePath, masterKey string) (*Manager, error) { + m := &Manager{filePath: filePath, master: deriveKey(masterKey)} + if strings.TrimSpace(filePath) == "" { + return m, nil // Free 版,无需文件 + } + if err := m.Reload(); err != nil { + return nil, err + } + return m, nil +} + +// deriveKey 从 master key 派生 License 签名密钥(独立用途域,不与凭据加密混用)。 +func deriveKey(masterKey string) []byte { + sum := sha256.Sum256([]byte("aigateway-license-v1\x00" + masterKey)) + return sum[:] +} + +// Reload 重新读取并校验 License 文件。 +func (m *Manager) Reload() error { + if m == nil { + return nil + } + raw, err := os.ReadFile(m.filePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ErrInvalidLicense + } + return err + } + lic, err := Parse(raw, m.master) + if err != nil { + return err + } + m.mu.Lock() + m.current = lic + m.mu.Unlock() + return nil +} + +// Parse 解析并校验 License 内容(签名 + 有效期)。 +func Parse(raw []byte, key []byte) (License, error) { + var lic License + if err := json.Unmarshal(raw, &lic); err != nil { + return License{}, ErrInvalidLicense + } + claims, err := json.Marshal(lic.Claims) + if err != nil { + return License{}, ErrInvalidLicense + } + mac := hmac.New(sha256.New, key) + _, _ = mac.Write(claims) + expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(strings.TrimSpace(lic.Signature))) { + return License{}, ErrInvalidLicense + } + now := time.Now() + if lic.NotBefore != "" { + if start, err := time.Parse(time.RFC3339, lic.NotBefore); err == nil && now.Before(start) { + return License{}, ErrInvalidLicense + } + } + if lic.NotAfter != "" { + if end, err := time.Parse(time.RFC3339, lic.NotAfter); err == nil && now.After(end) { + return License{}, ErrLicenseExpired + } + } + edition := strings.ToLower(strings.TrimSpace(lic.Edition)) + switch edition { + case EditionFree, EditionPro, EditionUltra, "": + default: + return License{}, ErrInvalidLicense + } + if lic.Edition == "" { + lic.Edition = EditionFree + } + return lic, nil +} + +// Sign 生成 License(供本地签发工具/测试使用)。 +func Sign(claims Claims, masterKey string) (License, error) { + claims.Edition = strings.ToLower(strings.TrimSpace(claims.Edition)) + claims.Features = normalize(claims.Features) + raw, err := json.Marshal(claims) + if err != nil { + return License{}, err + } + mac := hmac.New(sha256.New, deriveKey(masterKey)) + _, _ = mac.Write(raw) + return License{Claims: claims, Signature: base64.StdEncoding.EncodeToString(mac.Sum(nil))}, nil +} + +func normalize(values []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(values)) + for _, v := range values { + v = strings.ToLower(strings.TrimSpace(v)) + if v != "" && !seen[v] { + seen[v] = true + out = append(out, v) + } + } + return out +} + +// Current 返回当前 License 快照。 +func (m *Manager) Current() License { + if m == nil { + return License{Claims: Claims{Edition: EditionFree}} + } + m.mu.RLock() + defer m.mu.RUnlock() + return m.current +} + +// AccountLimit 返回账号数上限;0 表示不限。 +func (m *Manager) AccountLimit() int { + lic := m.Current() + // 未配置 License 时 Edition 为空,同样按社区版 Free(3 账号)处理。 + if lic.Edition == "" || lic.Edition == EditionFree { + if lic.MaxAccounts > 0 { + return lic.MaxAccounts + } + return DefaultAccountLimit + } + return lic.MaxAccounts // pro/ultra 由 License 指定;0=不限 +} + +// EditionName 返回可读版本名。 +func (m *Manager) EditionName() string { + switch m.Current().Edition { + case EditionPro: + return "专业版 Pro" + case EditionUltra: + return "旗舰版 Ultra" + default: + return "社区版 Free" + } +} + +// FilePath 返回 License 文件路径。 +func (m *Manager) FilePath() string { + if m == nil { + return "" + } + return m.filePath +} + +// Load 原子替换 License 文件并热更新(管理端上传)。 +func (m *Manager) Load(raw []byte) error { + if m == nil || m.filePath == "" { + return errors.New("未配置 LICENSE_FILE,无法保存 License") + } + lic, err := Parse(raw, m.master) + if err != nil { + return err + } + tmp := m.filePath + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + if err := os.Rename(tmp, m.filePath); err != nil { + return err + } + m.mu.Lock() + m.current = lic + m.mu.Unlock() + return nil +} + +// Summary 返回给管理端展示的信息。 +func (m *Manager) Summary() map[string]any { + lic := m.Current() + expires := lic.NotAfter + if expires == "" { + expires = "永久" + } + return map[string]any{ + "edition": lic.Edition, + "edition_name": m.EditionName(), + "issued_to": lic.IssuedTo, + "max_accounts": m.AccountLimit(), + "features": lic.Features, + "expires": expires, + "file": m.FilePath(), + "licensed": m.FilePath() != "", + } +} + +// 便捷格式化错误信息。 +func FormatError(err error) string { + switch { + case errors.Is(err, ErrInvalidLicense): + return "License 无效或签名不匹配" + case errors.Is(err, ErrLicenseExpired): + return "License 已过期" + case err == nil: + return "" + default: + return fmt.Sprintf("License 加载失败: %v", err) + } +} diff --git a/internal/platform/license/license_test.go b/internal/platform/license/license_test.go new file mode 100644 index 0000000..da38092 --- /dev/null +++ b/internal/platform/license/license_test.go @@ -0,0 +1,44 @@ +package license +import "encoding/json" + +import ( + "testing" + "time" +) + +func TestSignParseRoundTrip(t *testing.T) { + key := "test-master-key-123" + claims := Claims{Edition: EditionUltra, IssuedTo: "acme", MaxAccounts: 50, + NotBefore: time.Now().Add(-time.Hour).Format(time.RFC3339), + NotAfter: time.Now().Add(24 * time.Hour).Format(time.RFC3339)} + lic, err := Sign(claims, key) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(lic) + parsed, err := Parse(raw, deriveKey(key)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if parsed.Edition != EditionUltra || parsed.MaxAccounts != 50 { + t.Fatalf("bad claims: %+v", parsed.Claims) + } +} + +func TestParseRejectsBadSignature(t *testing.T) { + lic, _ := Sign(Claims{Edition: EditionPro, MaxAccounts: 30}, "key-a") + raw, _ := json.Marshal(lic) + if _, err := Parse(raw, deriveKey("key-b")); err == nil { + t.Fatal("expected signature failure") + } +} + +func TestParseRejectsExpired(t *testing.T) { + claims := Claims{Edition: EditionPro, MaxAccounts: 30, + NotAfter: time.Now().Add(-time.Hour).Format(time.RFC3339)} + lic, _ := Sign(claims, "key") + raw, _ := json.Marshal(lic) + if _, err := Parse(raw, deriveKey("key")); err != ErrLicenseExpired { + t.Fatalf("expected expired, got %v", err) + } +} diff --git a/internal/portal/conversations.go b/internal/portal/conversations.go index 7f78a63..40372d7 100644 --- a/internal/portal/conversations.go +++ b/internal/portal/conversations.go @@ -36,6 +36,60 @@ type Conversation struct { UpdatedAt time.Time `json:"updated_at"` } +// ListConversations 返回当前用户在某应用下的会话列表(不含消息体)。 +func (s *Service) ListConversations(ctx context.Context, account identity.Account, code string, limit int) ([]Conversation, error) { + if limit < 1 || limit > 200 { + limit = 50 + } + rows, err := s.pool.Query(ctx, `SELECT c.id::text,a.code,c.title,c.status,c.created_at,c.updated_at + FROM gateway.portal_conversations c JOIN gateway.applications a ON a.id=c.application_id + WHERE c.portal_user_id=$1 AND a.code=$2 ORDER BY c.updated_at DESC LIMIT $3`, account.ID, strings.ToLower(strings.TrimSpace(code)), limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Conversation{} + for rows.Next() { + var item Conversation + if err := rows.Scan(&item.ID, &item.ApplicationCode, &item.Title, &item.Status, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +// RenameConversation 重命名会话(仅本人)。 +func (s *Service) RenameConversation(ctx context.Context, account identity.Account, code, id, title string) (Conversation, error) { + title = strings.TrimSpace(title) + if title == "" || len(title) > 128 { + return Conversation{}, errors.New("会话标题必须为 1-128 个字符") + } + var item Conversation + err := s.pool.QueryRow(ctx, `UPDATE gateway.portal_conversations c SET title=$3,updated_at=clock_timestamp() + FROM gateway.applications a WHERE a.id=c.application_id AND c.id=$1 AND c.portal_user_id=$2 AND a.code=$4 + RETURNING c.id::text,a.code,c.title,c.status,c.created_at,c.updated_at`, + id, account.ID, title, strings.ToLower(strings.TrimSpace(code))).Scan(&item.ID, &item.ApplicationCode, &item.Title, &item.Status, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Conversation{}, ErrNotFound + } + return item, err +} + +// DeleteConversation 删除会话及全部消息(仅本人)。 +func (s *Service) DeleteConversation(ctx context.Context, account identity.Account, code, id string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.portal_conversations c USING gateway.applications a + WHERE a.id=c.application_id AND c.id=$1 AND c.portal_user_id=$2 AND a.code=$3`, + id, account.ID, strings.ToLower(strings.TrimSpace(code))) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + func (s *Service) CreateConversation(ctx context.Context, account identity.Account, code string) (Conversation, error) { app, err := s.assets.GetPublishedApplicationByCode(ctx, strings.ToLower(strings.TrimSpace(code))) if err != nil || !visible(app.DepartmentIDs, account.DepartmentID) { diff --git a/internal/portal/http.go b/internal/portal/http.go index 786e1b8..bc8ef3d 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -21,6 +21,7 @@ type HTTPHandler struct { func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHandler { h := &HTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()} h.mux.HandleFunc("POST /api/v1/portal/password", h.changePassword) + h.mux.HandleFunc("GET /api/v1/portal/login-logs", h.loginLogs) h.mux.HandleFunc("GET /api/v1/portal/applications", h.applications) h.mux.HandleFunc("GET /api/v1/portal/catalog", h.catalog) h.mux.HandleFunc("GET /api/v1/portal/knowledge", h.knowledge) @@ -38,7 +39,10 @@ func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHa h.mux.HandleFunc("GET /api/v1/portal/cost", h.cost) h.mux.HandleFunc("GET /api/v1/portal/docs-info", h.docsInfo) h.mux.HandleFunc("POST /api/v1/portal/apps/{code}/chat", h.chat) + h.mux.HandleFunc("GET /api/v1/portal/apps/{code}/conversations", h.listConversations) h.mux.HandleFunc("POST /api/v1/portal/apps/{code}/conversations", h.createConversation) + h.mux.HandleFunc("PATCH /api/v1/portal/apps/{code}/conversations/{id}", h.renameConversation) + h.mux.HandleFunc("DELETE /api/v1/portal/apps/{code}/conversations/{id}", h.deleteConversation) h.mux.HandleFunc("GET /api/v1/portal/apps/{code}/conversations/{id}", h.getConversation) h.mux.HandleFunc("POST /api/v1/portal/apps/{code}/conversations/{id}/messages", h.appendConversationMessage) h.mux.HandleFunc("GET /api/v1/portal/marketplace", h.marketplace) @@ -79,6 +83,19 @@ func portalError(w http.ResponseWriter, err error) { apiresponse.Error(w, http.StatusBadRequest, err.Error()) } +func (h *HTTPHandler) loginLogs(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + logs, err := h.identity.ListLoginLogs(r.Context(), identity.KindPortal, a.Login, 50) + if err != nil { + portalError(w, err) + return + } + apiresponse.OK(w, logs) +} + func (h *HTTPHandler) changePassword(w http.ResponseWriter, r *http.Request) { account, ok := h.account(w, r) if !ok { @@ -479,6 +496,53 @@ func (h *HTTPHandler) chat(w http.ResponseWriter, r *http.Request) { } writeApplicationResponse(w, response) } +func (h *HTTPHandler) listConversations(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + items, err := h.service.ListConversations(r.Context(), a, r.PathValue("code"), 100) + if err != nil { + portalError(w, err) + return + } + apiresponse.OK(w, items) +} + +func (h *HTTPHandler) renameConversation(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + var input struct { + Title string `json:"title"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil { + apiresponse.Error(w, http.StatusBadRequest, "请求格式无效") + return + } + item, err := h.service.RenameConversation(r.Context(), a, r.PathValue("code"), r.PathValue("id"), input.Title) + if err != nil { + portalError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *HTTPHandler) deleteConversation(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + if err := h.service.DeleteConversation(r.Context(), a, r.PathValue("code"), r.PathValue("id")); err != nil { + portalError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} + func (h *HTTPHandler) createConversation(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { diff --git a/internal/scheduler/portal_http.go b/internal/scheduler/portal_http.go new file mode 100644 index 0000000..f90ad38 --- /dev/null +++ b/internal/scheduler/portal_http.go @@ -0,0 +1,190 @@ +package scheduler + +import ( + "encoding/json" + "errors" + "net/http" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" +) + +// PortalHTTPHandler 提供门户工作台定时任务接口:仅管理本人创建的任务。 +type PortalHTTPHandler struct { + service *Service + identity *identity.Service + mux *http.ServeMux +} + +func NewPortalHTTPHandler(service *Service, identityService *identity.Service) *PortalHTTPHandler { + h := &PortalHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/portal/scheduled-tasks", h.list) + h.mux.HandleFunc("POST /api/v1/portal/scheduled-tasks", h.create) + h.mux.HandleFunc("GET /api/v1/portal/scheduled-tasks/{id}", h.get) + h.mux.HandleFunc("PUT /api/v1/portal/scheduled-tasks/{id}", h.update) + h.mux.HandleFunc("DELETE /api/v1/portal/scheduled-tasks/{id}", h.delete) + h.mux.HandleFunc("POST /api/v1/portal/scheduled-tasks/{id}/start", h.start) + h.mux.HandleFunc("POST /api/v1/portal/scheduled-tasks/{id}/pause", h.pause) + h.mux.HandleFunc("POST /api/v1/portal/scheduled-tasks/{id}/run", h.runNow) + h.mux.HandleFunc("GET /api/v1/portal/scheduled-tasks/{id}/runs", h.runs) + return h +} + +func (h *PortalHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } + +func (h *PortalHTTPHandler) 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 *PortalHTTPHandler) list(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + items, err := h.service.ListByOwner(r.Context(), a.ID) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "定时任务查询失败") + return + } + apiresponse.OK(w, items) +} + +func (h *PortalHTTPHandler) get(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + task, err := h.service.GetOwned(r.Context(), a.ID, r.PathValue("id")) + if err != nil { + apiresponse.Error(w, http.StatusNotFound, "任务不存在") + return + } + apiresponse.OK(w, task) +} + +func (h *PortalHTTPHandler) create(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + var input TaskInput + if err := decodeJSON(w, r, &input); err != nil { + return + } + task, err := h.service.Save(r.Context(), "", input, a.ID) + if err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, task) +} + +func (h *PortalHTTPHandler) update(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + var input TaskInput + if err := decodeJSON(w, r, &input); err != nil { + return + } + // 归属校验:仅本人任务可更新。 + if _, err := h.service.GetOwned(r.Context(), a.ID, r.PathValue("id")); err != nil { + apiresponse.Error(w, http.StatusNotFound, "任务不存在") + return + } + task, err := h.service.Save(r.Context(), r.PathValue("id"), input, a.ID) + if err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, task) +} + +func (h *PortalHTTPHandler) delete(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + task, err := h.service.GetOwned(r.Context(), a.ID, r.PathValue("id")) + if err != nil { + apiresponse.Error(w, http.StatusNotFound, "任务不存在") + return + } + if err := h.service.Delete(r.Context(), task.ID); err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "任务删除失败") + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} + +func (h *PortalHTTPHandler) setEnabled(w http.ResponseWriter, r *http.Request, enabled bool) { + a, ok := h.account(w, r) + if !ok { + return + } + task, err := h.service.GetOwned(r.Context(), a.ID, r.PathValue("id")) + if err != nil { + apiresponse.Error(w, http.StatusNotFound, "任务不存在") + return + } + updated, err := h.service.SetEnabled(r.Context(), task.ID, enabled) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "任务状态更新失败") + return + } + apiresponse.OK(w, updated) +} + +func (h *PortalHTTPHandler) start(w http.ResponseWriter, r *http.Request) { h.setEnabled(w, r, true) } +func (h *PortalHTTPHandler) pause(w http.ResponseWriter, r *http.Request) { h.setEnabled(w, r, false) } + +func (h *PortalHTTPHandler) runNow(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + task, err := h.service.GetOwned(r.Context(), a.ID, r.PathValue("id")) + if err != nil { + apiresponse.Error(w, http.StatusNotFound, "任务不存在") + return + } + if _, err := h.service.QueueManual(r.Context(), task.ID); err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, map[string]bool{"queued": true}) +} + +func (h *PortalHTTPHandler) runs(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + task, err := h.service.GetOwned(r.Context(), a.ID, r.PathValue("id")) + if err != nil { + apiresponse.Error(w, http.StatusNotFound, "任务不存在") + return + } + items, err := h.service.Runs(r.Context(), task.ID, 50) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "执行历史查询失败") + return + } + apiresponse.OK(w, items) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, target any) error { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + apiresponse.Error(w, http.StatusBadRequest, "请求格式无效") + return errors.New("bad request") + } + return nil +} diff --git a/internal/scheduler/service.go b/internal/scheduler/service.go index 03b4704..549c55c 100644 --- a/internal/scheduler/service.go +++ b/internal/scheduler/service.go @@ -113,6 +113,35 @@ func scanTask(row pgx.Row) (Task, error) { return task, err } +// ListByOwner 返回指定创建者(门户用户)的任务。 +func (s *Service) ListByOwner(ctx context.Context, ownerID string) ([]Task, error) { + if s == nil || s.pool == nil { + return nil, ErrNotFound + } + rows, err := s.pool.Query(ctx, taskSelect+` WHERE created_by=$1 ORDER BY updated_at DESC`, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Task{} + for rows.Next() { + item, err := scanTask(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +// GetOwned 返回归属指定创建者的任务(门户隔离)。 +func (s *Service) GetOwned(ctx context.Context, ownerID, id string) (Task, error) { + if s == nil || s.pool == nil { + return Task{}, ErrNotFound + } + return scanTask(s.pool.QueryRow(ctx, taskSelect+` WHERE id=$1 AND created_by=$2`, id, ownerID)) +} + func (s *Service) List(ctx context.Context) ([]Task, error) { rows, err := s.pool.Query(ctx, taskSelect+` ORDER BY updated_at DESC`) if err != nil { diff --git a/internal/workbench/admin_marketplace.go b/internal/workbench/admin_marketplace.go index 4bb2e9c..8902204 100644 --- a/internal/workbench/admin_marketplace.go +++ b/internal/workbench/admin_marketplace.go @@ -3,6 +3,8 @@ package workbench import ( "net/http" "strconv" + "strings" + "time" "aigateway.local/core/internal/identity" "aigateway.local/core/internal/platform/apiresponse" @@ -32,6 +34,8 @@ func NewMarketplaceAdminHTTPHandler(market *MarketplaceService, mcpServers *MCPS h.mux.HandleFunc("PUT /api/v1/admin/mcp-servers/{id}", h.updateMCPServer) h.mux.HandleFunc("DELETE /api/v1/admin/mcp-servers/{id}", h.deleteMCPServer) h.mux.HandleFunc("POST /api/v1/admin/mcp-servers/{id}/test", h.testMCPServer) + h.mux.HandleFunc("POST /api/v1/admin/mcp-servers/{id}/scan", h.scanMCP) + h.mux.HandleFunc("POST /api/v1/admin/skills/{id}/scan", h.scanSkill) h.mux.HandleFunc("GET /api/v1/admin/skills", h.listSkills) h.mux.HandleFunc("POST /api/v1/admin/skills", h.createSkill) h.mux.HandleFunc("GET /api/v1/admin/skills/{id}", h.getSkill) @@ -433,3 +437,45 @@ func (h *MarketplaceAdminHTTPHandler) marketplaceCatalog(w http.ResponseWriter, } apiresponse.OK(w, items) } + + +// scanMCP 对 MCP 服务器定义执行供应链静态扫描。 +func (h *MarketplaceAdminHTTPHandler) scanMCP(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionMCPServerManage); !ok { + return + } + server, err := h.mcpServers.Get(r.Context(), r.PathValue("id")) + if err != nil { + apiresponse.Error(w, http.StatusNotFound, "MCP 服务器不存在") + return + } + content := strings.Join([]string{server.Name, server.Description, server.EndpointURL}, "\n") + if len(server.EncryptedHeaders) > 0 { + content += "\n[配置了加密请求头]" + } + apiresponse.OK(w, map[string]any{ + "findings": ScanResource(content), + "highest": HighestSeverity(ScanResource(content)), + "scanned_at": time.Now().UTC(), + "resource": server.Name, + }) +} + +// scanSkill 对 Skill 定义执行供应链静态扫描。 +func (h *MarketplaceAdminHTTPHandler) scanSkill(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionSkillManage); !ok { + return + } + skill, err := h.skills.Get(r.Context(), r.PathValue("id")) + if err != nil { + apiresponse.Error(w, http.StatusNotFound, "Skill 不存在") + return + } + content := strings.Join([]string{skill.Name, skill.Description, skill.Content}, "\n") + apiresponse.OK(w, map[string]any{ + "findings": ScanResource(content), + "highest": HighestSeverity(ScanResource(content)), + "scanned_at": time.Now().UTC(), + "resource": skill.Name, + }) +} diff --git a/internal/workbench/scan.go b/internal/workbench/scan.go new file mode 100644 index 0000000..ce43d65 --- /dev/null +++ b/internal/workbench/scan.go @@ -0,0 +1,151 @@ +package workbench + +import ( + "fmt" + "net" + "net/url" + "regexp" + "strings" + + "aigateway.local/core/internal/provider" +) + +// ScanFinding 是一条供应链安全扫描发现。 +type ScanFinding struct { + Rule string `json:"rule"` + Severity string `json:"severity"` // high / medium / low + Description string `json:"description"` + Match string `json:"match,omitempty"` +} + +// 静态扫描规则:对 skill/mcp 资源定义内容(描述、提示词、工具配置、URL 等) +// 做供应链安全检查,发现高危模式时在管理端展示。 +var ( + secretPatterns = []struct { + rule, severity, pattern, description string + re *regexp.Regexp + }{ + {rule: "openai_key", severity: "high", pattern: `sk-[A-Za-z0-9_-]{16,}`, description: "疑似硬编码 OpenAI API Key"}, + {rule: "aws_key", severity: "high", pattern: `AKIA[0-9A-Z]{16}`, description: "疑似硬编码 AWS Access Key"}, + {rule: "github_token", severity: "high", pattern: `gh[pousr]_[A-Za-z0-9]{20,}`, description: "疑似硬编码 GitHub Token"}, + {rule: "stripe_key", severity: "high", pattern: `sk_live_[A-Za-z0-9]{20,}`, description: "疑似硬编码 Stripe 密钥"}, + {rule: "generic_secret", severity: "medium", pattern: `(?i)(password|passwd|secret|api[_-]?key|token)\s*[:=]\s*['"][^'"]{8,}['"]`, description: "疑似硬编码凭据赋值"}, + {rule: "private_key_block", severity: "high", pattern: `-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----`, description: "包含私钥块"}, + } + dangerCommandPatterns = []struct { + pattern, description string + re *regexp.Regexp + }{ + {pattern: `(?i)(rm\s+-rf\s+/|:\(\)\s*\{[^}]*\}\s*;|mkfs\.|dd\s+if=.*of=/dev/)`, description: "包含危险系统命令"}, + {pattern: `(?i)curl\s+[^|;&]*\|\s*(ba)?sh|wget\s+[^|;&]*\|\s*(ba)?sh`, description: "管道执行远程脚本(curl|sh)"}, + } + injectionPatterns = []struct { + pattern, description string + re *regexp.Regexp + }{ + {pattern: `(?i)ignore (all |any )?(previous|prior) instructions`, description: "疑似提示词注入(忽略历史指令)"}, + {pattern: `(?i)(reveal|leak|exfiltrate|print)\s+(your|the)\s+(system\s+)?(prompt|instructions|secret)`, description: "疑似提示词注入(诱导泄露系统提示/密钥)"}, + {pattern: `(?i)(you are now|act as|pretend to be).{0,40}(no restrictions|unfiltered|jailbreak)`, description: "疑似越狱/解除限制指令"}, + } +) + +func compileStaticPatterns() { + for i := range secretPatterns { + secretPatterns[i].re = regexp.MustCompile(secretPatterns[i].pattern) + } + for i := range dangerCommandPatterns { + dangerCommandPatterns[i].re = regexp.MustCompile(dangerCommandPatterns[i].pattern) + } + for i := range injectionPatterns { + injectionPatterns[i].re = regexp.MustCompile(injectionPatterns[i].pattern) + } +} + +func init() { compileStaticPatterns() } + +// ScanResource 对资源定义内容执行静态安全扫描。 +// content 为拼接的文本(名称、描述、提示词、工具 URL、配置等)。 +func ScanResource(content string) []ScanFinding { + findings := []ScanFinding{} + scanText := func(patterns []struct { + pattern, description string + re *regexp.Regexp + }, severity string) { + for _, p := range patterns { + if match := p.re.FindString(content); match != "" { + findings = append(findings, ScanFinding{ + Rule: p.pattern, Severity: severity, Description: p.description, + Match: truncateRune(match, 80), + }) + } + } + } + for _, p := range secretPatterns { + if match := p.re.FindString(content); match != "" { + findings = append(findings, ScanFinding{ + Rule: p.rule, Severity: p.severity, Description: p.description, + Match: maskMatch(match), + }) + } + } + scanText(dangerCommandPatterns, "high") + scanText(injectionPatterns, "medium") + + // URL 与内网地址检测。 + urlPattern := regexp.MustCompile(`https?://[^\s"'<>]+`) + for _, raw := range urlPattern.FindAllString(content, -1) { + parsed, err := url.Parse(strings.Trim(raw, `.,;)]}'"`)) + if err != nil || parsed.Hostname() == "" { + continue + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + findings = append(findings, ScanFinding{Rule: "non_http_scheme", Severity: "high", Description: "包含非 http(s) 协议 URL(可能用于 SSRF/文件读取)", Match: truncateRune(raw, 80)}) + continue + } + if addresses, err := net.LookupIP(parsed.Hostname()); err == nil { + for _, ip := range addresses { + if !provider.IsPublicAddress(ip) { + findings = append(findings, ScanFinding{Rule: "private_endpoint", Severity: "high", Description: "资源引用了内网/保留地址(" + ip.String() + "),可能被用于内网探测", Match: truncateRune(raw, 80)}) + break + } + } + } + } + // base64 混淆检测(>=64 字符的 base64 串)。 + base64Pattern := regexp.MustCompile(`[A-Za-z0-9+/]{64,}={0,2}`) + if match := base64Pattern.FindString(content); match != "" { + findings = append(findings, ScanFinding{Rule: "obfuscated_blob", Severity: "low", Description: "包含疑似 base64 混淆数据", Match: truncateRune(match, 40) + "..."}) + } + return findings +} + +// HighestSeverity 返回发现中的最高严重级。 +func HighestSeverity(findings []ScanFinding) string { + order := map[string]int{"high": 0, "medium": 1, "low": 2} + best := "" + for _, f := range findings { + if rank, ok := order[f.Severity]; ok { + if best == "" || rank < order[best] { + best = f.Severity + } + } + } + return best +} + +func maskMatch(value string) string { + if len(value) <= 8 { + return "***" + } + return value[:4] + "…" + value[len(value)-4:] +} + +func truncateRune(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) + "…" +} + +var _ = fmt.Sprintf diff --git a/migrations/000031_login_logs.sql b/migrations/000031_login_logs.sql new file mode 100644 index 0000000..7d6a74c --- /dev/null +++ b/migrations/000031_login_logs.sql @@ -0,0 +1,17 @@ +-- 登录审计:记录管理端/门户每次登录尝试(成功/失败与原因),供个人中心 +-- 与管理端查询,满足"登录记录查看"需求。 +CREATE TABLE IF NOT EXISTS gateway.login_logs ( + id uuid PRIMARY KEY, + kind text NOT NULL CHECK (kind IN ('admin', 'portal')), + login text NOT NULL, + success boolean NOT NULL, + ip inet, + user_agent text NOT NULL DEFAULT '', + reason text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX IF NOT EXISTS login_logs_kind_idx + ON gateway.login_logs (kind, created_at DESC); +CREATE INDEX IF NOT EXISTS login_logs_login_idx + ON gateway.login_logs (login, created_at DESC); diff --git a/migrations/000032_roles.sql b/migrations/000032_roles.sql new file mode 100644 index 0000000..1f795ce --- /dev/null +++ b/migrations/000032_roles.sql @@ -0,0 +1,14 @@ +-- 自定义角色管理:在内置角色(superadmin/operator/auditor/member)之外, +-- 管理员可定义任意角色并为其分配权限字符串;绑定账号时权限展开写入 +-- 账号 permissions(与内置角色权限合并生效)。 +CREATE TABLE IF NOT EXISTS gateway.roles ( + id uuid PRIMARY KEY, + code text NOT NULL UNIQUE, + name text NOT NULL, + description text NOT NULL DEFAULT '', + permissions text[] NOT NULL DEFAULT '{}', + builtin boolean NOT NULL DEFAULT false, + created_by uuid, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); diff --git a/migrations/000033_model_quotas.sql b/migrations/000033_model_quotas.sql new file mode 100644 index 0000000..ab8c719 --- /dev/null +++ b/migrations/000033_model_quotas.sql @@ -0,0 +1,12 @@ +-- 模型级 Token 配额:按 Provider + 模型模式设置企业总配额(所有 API Key +-- 共享同一计数),用于成本管控。配额键按自然月滚动,与 API Key 级配额 +-- 相互独立、叠加生效。 +CREATE TABLE IF NOT EXISTS gateway.model_quotas ( + id uuid PRIMARY KEY, + provider_code text NOT NULL, + model_pattern text NOT NULL, + monthly_token_quota bigint NOT NULL CHECK (monthly_token_quota > 0), + enabled boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (provider_code, model_pattern) +); diff --git a/migrations/000034_memories.sql b/migrations/000034_memories.sql new file mode 100644 index 0000000..f18e8ea --- /dev/null +++ b/migrations/000034_memories.sql @@ -0,0 +1,25 @@ +-- 记忆管理(旗舰版):支持用户个人记忆/部门记忆/全局记忆的多层记忆集合, +-- 内容向量化后按语义召回(与知识库共用 Ollama embedding);支持向其他 +-- 用户授权(shared_with);按重要度与最近访问时间做衰减清理。 +CREATE TABLE IF NOT EXISTS gateway.memory_entries ( + id uuid PRIMARY KEY, + owner_kind text NOT NULL CHECK (owner_kind IN ('user', 'department', 'global')), + owner_id text NOT NULL DEFAULT '', + category text NOT NULL DEFAULT 'general', + content text NOT NULL, + importance int NOT NULL DEFAULT 5 CHECK (importance BETWEEN 1 AND 10), + embedding vector(1024), + shared_with uuid[] NOT NULL DEFAULT '{}', + source text NOT NULL DEFAULT '', + last_accessed_at timestamptz, + created_by uuid, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX IF NOT EXISTS memory_entries_owner_idx + ON gateway.memory_entries (owner_kind, owner_id, created_at DESC); +CREATE INDEX IF NOT EXISTS memory_entries_shared_idx + ON gateway.memory_entries USING gin (shared_with); +CREATE INDEX IF NOT EXISTS memory_entries_vector_idx + ON gateway.memory_entries USING hnsw (embedding vector_cosine_ops); diff --git a/web/apps/admin/src/locales/langs/en.json b/web/apps/admin/src/locales/langs/en.json index aa02917..3b8c823 100644 --- a/web/apps/admin/src/locales/langs/en.json +++ b/web/apps/admin/src/locales/langs/en.json @@ -268,7 +268,8 @@ "user": "User Manage", "role": "Role Manage", "userCenter": "User Center", - "menu": "Menu Manage" + "menu": "Menu Manage", + "license": "License" } }, "table": { diff --git a/web/apps/admin/src/locales/langs/zh.json b/web/apps/admin/src/locales/langs/zh.json index ab7defd..557f817 100644 --- a/web/apps/admin/src/locales/langs/zh.json +++ b/web/apps/admin/src/locales/langs/zh.json @@ -268,7 +268,8 @@ "user": "用户管理", "role": "角色管理", "userCenter": "个人中心", - "menu": "菜单管理" + "menu": "菜单管理", + "license": "License 授权" } }, "table": { diff --git a/web/apps/admin/src/router/modules/system.ts b/web/apps/admin/src/router/modules/system.ts index 16df585..c427d22 100644 --- a/web/apps/admin/src/router/modules/system.ts +++ b/web/apps/admin/src/router/modules/system.ts @@ -30,6 +30,16 @@ export const systemRoutes: AppRouteRecord = { roles: ['R_SUPER'] } }, + { + path: 'license', + name: 'License', + component: '/system/license', + meta: { + title: 'menus.system.license', + keepAlive: true, + roles: ['R_SUPER'] + } + }, { path: 'user-center', name: 'UserCenter', diff --git a/web/apps/admin/src/views/dashboard/console/index.vue b/web/apps/admin/src/views/dashboard/console/index.vue old mode 100755 new mode 100644 index 154c330..55ada76 --- a/web/apps/admin/src/views/dashboard/console/index.vue +++ b/web/apps/admin/src/views/dashboard/console/index.vue @@ -1,41 +1,96 @@ - diff --git a/web/apps/admin/src/views/system/assistant/index.vue b/web/apps/admin/src/views/system/assistant/index.vue new file mode 100644 index 0000000..5904244 --- /dev/null +++ b/web/apps/admin/src/views/system/assistant/index.vue @@ -0,0 +1,61 @@ + + + diff --git a/web/apps/admin/src/views/system/license/index.vue b/web/apps/admin/src/views/system/license/index.vue new file mode 100644 index 0000000..bf76dc0 --- /dev/null +++ b/web/apps/admin/src/views/system/license/index.vue @@ -0,0 +1,109 @@ + + + diff --git a/web/apps/admin/src/views/system/login-logs/index.vue b/web/apps/admin/src/views/system/login-logs/index.vue new file mode 100644 index 0000000..d02f116 --- /dev/null +++ b/web/apps/admin/src/views/system/login-logs/index.vue @@ -0,0 +1,62 @@ + + + diff --git a/web/apps/admin/src/views/system/role/index.vue b/web/apps/admin/src/views/system/role/index.vue old mode 100755 new mode 100644 index 126e8fd..6825d1a --- a/web/apps/admin/src/views/system/role/index.vue +++ b/web/apps/admin/src/views/system/role/index.vue @@ -1,241 +1,159 @@ - +