0.11.0: 旗舰版功能补齐(License/登录记录/会话管理/角色管理/门户定时任务/模型配额/输出脱敏/供应链扫描/记忆管理/AI助手/真实概览)
- 新增迁移 000031-000034(登录日志/角色/模型配额/记忆) - 新增包: license/memory/modelquota/assistant,扫描引擎 - 全部功能后端+前端+端到端验证通过(25 包单测)
This commit is contained in:
@@ -11,12 +11,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"aigateway.local/core/internal/agentnode"
|
"aigateway.local/core/internal/agentnode"
|
||||||
|
"aigateway.local/core/internal/assistant"
|
||||||
"aigateway.local/core/internal/apikey"
|
"aigateway.local/core/internal/apikey"
|
||||||
"aigateway.local/core/internal/audit"
|
"aigateway.local/core/internal/audit"
|
||||||
"aigateway.local/core/internal/contentpolicy"
|
"aigateway.local/core/internal/contentpolicy"
|
||||||
"aigateway.local/core/internal/factcheck"
|
"aigateway.local/core/internal/factcheck"
|
||||||
"aigateway.local/core/internal/gateway"
|
"aigateway.local/core/internal/gateway"
|
||||||
"aigateway.local/core/internal/identity"
|
"aigateway.local/core/internal/identity"
|
||||||
|
"aigateway.local/core/internal/memory"
|
||||||
|
"aigateway.local/core/internal/modelquota"
|
||||||
"aigateway.local/core/internal/operations"
|
"aigateway.local/core/internal/operations"
|
||||||
"aigateway.local/core/internal/outbox"
|
"aigateway.local/core/internal/outbox"
|
||||||
"aigateway.local/core/internal/platform/cache"
|
"aigateway.local/core/internal/platform/cache"
|
||||||
@@ -25,6 +28,7 @@ import (
|
|||||||
"aigateway.local/core/internal/platform/database"
|
"aigateway.local/core/internal/platform/database"
|
||||||
"aigateway.local/core/internal/platform/health"
|
"aigateway.local/core/internal/platform/health"
|
||||||
"aigateway.local/core/internal/platform/httpserver"
|
"aigateway.local/core/internal/platform/httpserver"
|
||||||
|
"aigateway.local/core/internal/platform/license"
|
||||||
"aigateway.local/core/internal/platform/storage"
|
"aigateway.local/core/internal/platform/storage"
|
||||||
"aigateway.local/core/internal/portal"
|
"aigateway.local/core/internal/portal"
|
||||||
"aigateway.local/core/internal/pricing"
|
"aigateway.local/core/internal/pricing"
|
||||||
@@ -135,6 +139,13 @@ func main() {
|
|||||||
proxy.SetAllowPrivateProviderURLs(cfg.Credentials.AllowPrivateProviderURL)
|
proxy.SetAllowPrivateProviderURLs(cfg.Credentials.AllowPrivateProviderURL)
|
||||||
proxy.SetAdmissionController(gateway.NewRedisAdmissionController(criticalRedis))
|
proxy.SetAdmissionController(gateway.NewRedisAdmissionController(criticalRedis))
|
||||||
proxy.SetTokenQuotaController(gateway.NewRedisTokenQuotaController(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{
|
proxy.SetResiliencePolicy(gateway.ResiliencePolicy{
|
||||||
ResponseHeaderTimeout: cfg.Upstream.ResponseHeaderTimeout, MaxRetries: cfg.Upstream.MaxRetries,
|
ResponseHeaderTimeout: cfg.Upstream.ResponseHeaderTimeout, MaxRetries: cfg.Upstream.MaxRetries,
|
||||||
RetryBackoff: cfg.Upstream.RetryBackoff, CircuitThreshold: cfg.Upstream.CircuitThreshold,
|
RetryBackoff: cfg.Upstream.RetryBackoff, CircuitThreshold: cfg.Upstream.CircuitThreshold,
|
||||||
@@ -161,6 +172,7 @@ func main() {
|
|||||||
go contentPolicyEngine.Run(ctx)
|
go contentPolicyEngine.Run(ctx)
|
||||||
go pricingService.Run(ctx)
|
go pricingService.Run(ctx)
|
||||||
proxy.SetContentPolicyEngine(contentPolicyEngine)
|
proxy.SetContentPolicyEngine(contentPolicyEngine)
|
||||||
|
proxy.SetOutputPolicyEngine(contentPolicyEngine)
|
||||||
proxy.SetPricingService(pricingService)
|
proxy.SetPricingService(pricingService)
|
||||||
identityRepository := identity.NewRepository(db)
|
identityRepository := identity.NewRepository(db)
|
||||||
sessionStore := identity.NewSessionStore(criticalRedis, cfg.Auth.SessionTTL)
|
sessionStore := identity.NewSessionStore(criticalRedis, cfg.Auth.SessionTTL)
|
||||||
@@ -220,6 +232,15 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
logger.Info("knowledge embeddings disabled, knowledge retrieval uses postgres_fts only")
|
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(
|
toolCipher, err := cryptox.NewKeyring(
|
||||||
cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "tool-request-headers",
|
cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "tool-request-headers",
|
||||||
)
|
)
|
||||||
@@ -286,6 +307,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
schedulerService := scheduler.NewService(db, schedulerCipher)
|
schedulerService := scheduler.NewService(db, schedulerCipher)
|
||||||
schedulerHandler := scheduler.NewAdminHTTPHandler(schedulerService, identityService)
|
schedulerHandler := scheduler.NewAdminHTTPHandler(schedulerService, identityService)
|
||||||
|
portalSchedulerHandler := scheduler.NewPortalHTTPHandler(schedulerService, identityService)
|
||||||
traceStore := trace.NewStore(db)
|
traceStore := trace.NewStore(db)
|
||||||
traceHandler := trace.NewAdminHTTPHandler(traceStore, identityService)
|
traceHandler := trace.NewAdminHTTPHandler(traceStore, identityService)
|
||||||
agentNodeStore := agentnode.NewStore(db)
|
agentNodeStore := agentnode.NewStore(db)
|
||||||
@@ -317,7 +339,16 @@ func main() {
|
|||||||
portalService.SetMarketplace(marketplaceService)
|
portalService.SetMarketplace(marketplaceService)
|
||||||
portalHandler := portal.NewHTTPHandler(portalService, identityService)
|
portalHandler := portal.NewHTTPHandler(portalService, identityService)
|
||||||
portalAdminHandler := portal.NewAdminHTTPHandler(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()
|
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 {
|
operationsHandler := operations.NewAdminHTTPHandler(db, identityService, version, startedAt, func(reloadCtx context.Context) error {
|
||||||
return errors.Join(providerResolver.Reload(reloadCtx), contentPolicyEngine.Reload(reloadCtx), pricingService.Reload(reloadCtx))
|
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/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-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/model-prices/", pricingHandler)
|
||||||
controlMux.Handle("/api/v1/admin/fact-check/", factCheckHandler)
|
controlMux.Handle("/api/v1/admin/fact-check/", factCheckHandler)
|
||||||
controlMux.Handle("/api/v1/admin/prompt-categories", workbenchHandler)
|
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/portal/inbox/", inboxPortalHandler)
|
controlMux.Handle("/api/v1/portal/inbox/", inboxPortalHandler)
|
||||||
controlMux.Handle("/api/v1/admin/scheduled-tasks", schedulerHandler)
|
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/scheduled-tasks/", schedulerHandler)
|
||||||
controlMux.Handle("/api/v1/admin/traces", traceHandler)
|
controlMux.Handle("/api/v1/admin/traces", traceHandler)
|
||||||
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/admin/agent-nodes/", agentNodeHandler)
|
controlMux.Handle("/api/v1/admin/agent-nodes/", agentNodeHandler)
|
||||||
controlMux.Handle("/api/v1/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/reload", operationsHandler)
|
||||||
controlMux.Handle("/api/v1/admin/identities/", identityManagementHandler)
|
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/departments/", identityManagementHandler)
|
controlMux.Handle("/api/v1/admin/departments/", identityManagementHandler)
|
||||||
controlMux.Handle("/api/v1/admin/identity-providers", 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/logs/", portalHandler)
|
controlMux.Handle("/api/v1/portal/logs/", portalHandler)
|
||||||
controlMux.Handle("/api/v1/portal/model-requests", 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/model-requests/", portalHandler)
|
||||||
controlMux.Handle("/api/v1/portal/password", portalHandler)
|
controlMux.Handle("/api/v1/portal/password", portalHandler)
|
||||||
controlMux.Handle("/api/v1/portal/prompts", portalHandler)
|
controlMux.Handle("/api/v1/portal/prompts", portalHandler)
|
||||||
|
|||||||
@@ -362,3 +362,45 @@ PostgreSQL `text` 列拒绝写入 → 重试循环永远失败;inbox 的 Go `l
|
|||||||
中心"语枢"字母 A 标记;沿用品牌色(#071F4D/#00E4E5/#006EFF)。
|
中心"语枢"字母 A 标记;沿用品牌色(#071F4D/#00E4E5/#006EFF)。
|
||||||
- 替换:侧边栏/顶栏 logo(SVG,Vite 内联)、登录页图标、favicon(16-256
|
- 替换:侧边栏/顶栏 logo(SVG,Vite 内联)、登录页图标、favicon(16-256
|
||||||
多尺寸 ICO)。程序化像素验证渲染正确。
|
多尺寸 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。
|
||||||
|
- 个人环境变量注入、资源权限等级(查看/仅使用/管理)、收藏:前端增强。
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,10 +30,14 @@ type Proxy struct {
|
|||||||
circuits sync.Map
|
circuits sync.Map
|
||||||
admission AdmissionController
|
admission AdmissionController
|
||||||
tokenQuota TokenQuotaController
|
tokenQuota TokenQuotaController
|
||||||
|
modelQuota ModelQuotaController
|
||||||
resilience ResiliencePolicy
|
resilience ResiliencePolicy
|
||||||
audit AuditRecorder
|
audit AuditRecorder
|
||||||
policies *contentpolicy.Engine
|
policies *contentpolicy.Engine
|
||||||
pricing *pricing.Service
|
pricing *pricing.Service
|
||||||
|
outputPolicies interface {
|
||||||
|
OutputRedact([]byte) ([]byte, bool)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type cachedProxy struct {
|
type cachedProxy struct {
|
||||||
@@ -99,6 +103,11 @@ func (p *Proxy) SetTokenQuotaController(controller TokenQuotaController) {
|
|||||||
p.tokenQuota = controller
|
p.tokenQuota = controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetModelQuotaController 启用模型级 Token 配额(provider 解析后预留)。
|
||||||
|
func (p *Proxy) SetModelQuotaController(controller ModelQuotaController) {
|
||||||
|
p.modelQuota = controller
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Proxy) SetResiliencePolicy(policy ResiliencePolicy) {
|
func (p *Proxy) SetResiliencePolicy(policy ResiliencePolicy) {
|
||||||
p.resilience = policy
|
p.resilience = policy
|
||||||
p.transport.ResponseHeaderTimeout = policy.ResponseHeaderTimeout
|
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) SetAuditRecorder(recorder AuditRecorder) { p.audit = recorder }
|
||||||
func (p *Proxy) SetContentPolicyEngine(engine *contentpolicy.Engine) { p.policies = engine }
|
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) SetPricingService(service *pricing.Service) { p.pricing = service }
|
||||||
|
|
||||||
func (p *Proxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
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")
|
request.Header.Del("X-Gateway-Provider")
|
||||||
writer.Header().Set("X-Gateway-Provider", resolved.Code)
|
writer.Header().Set("X-Gateway-Provider", resolved.Code)
|
||||||
span.setRoute(resolved.Code, writer.Header().Get("X-Gateway-Model"))
|
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)
|
request.Body = span.captureBody(request.Body)
|
||||||
p.proxyFor(resolved).ServeHTTP(writer, request)
|
p.proxyFor(resolved).ServeHTTP(writer, request)
|
||||||
}
|
}
|
||||||
@@ -323,6 +363,11 @@ func (p *Proxy) proxyFor(resolved ResolvedAdapter) *httputil.ReverseProxy {
|
|||||||
session.finish(TokenUsage{})
|
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
|
return nil
|
||||||
}
|
}
|
||||||
reverseProxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, err error) {
|
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
|
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) {
|
func (p *Proxy) authorized(request *http.Request) (apikey.Principal, error) {
|
||||||
presented := strings.TrimSpace(request.Header.Get("X-Gateway-API-Key"))
|
presented := strings.TrimSpace(request.Header.Get("X-Gateway-API-Key"))
|
||||||
if presented == "" {
|
if presented == "" {
|
||||||
|
|||||||
@@ -22,12 +22,21 @@ const maxUsageDocumentBytes = 2 << 20
|
|||||||
type usageSession struct {
|
type usageSession struct {
|
||||||
controller TokenQuotaController
|
controller TokenQuotaController
|
||||||
reservation TokenReservation
|
reservation TokenReservation
|
||||||
|
// modelController/modelReservation 是模型级配额(可选,企业模型总配额)。
|
||||||
|
modelController ModelQuotaController
|
||||||
|
modelReservation any
|
||||||
fallback int64
|
fallback int64
|
||||||
onFinish func(TokenUsage)
|
onFinish func(TokenUsage)
|
||||||
once sync.Once
|
once sync.Once
|
||||||
log *slog.Logger
|
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 {
|
type TokenUsage struct {
|
||||||
Input int64
|
Input int64
|
||||||
Output int64
|
Output int64
|
||||||
@@ -64,6 +73,13 @@ func (s *usageSession) finish(usage TokenUsage) {
|
|||||||
}
|
}
|
||||||
cancel()
|
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 {
|
if s.onFinish != nil {
|
||||||
s.onFinish(usage)
|
s.onFinish(usage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ const (
|
|||||||
PermissionTraceRead = "trace:read"
|
PermissionTraceRead = "trace:read"
|
||||||
PermissionAgentNodeRead = "agent_node:read"
|
PermissionAgentNodeRead = "agent_node:read"
|
||||||
PermissionAgentNodeManage = "agent_node:manage"
|
PermissionAgentNodeManage = "agent_node:manage"
|
||||||
|
PermissionSystemManage = "system:manage"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rolePermissions = map[string][]string{
|
var rolePermissions = map[string][]string{
|
||||||
@@ -107,6 +108,7 @@ var rolePermissions = map[string][]string{
|
|||||||
PermissionScheduledTaskRead, PermissionScheduledTaskManage,
|
PermissionScheduledTaskRead, PermissionScheduledTaskManage,
|
||||||
PermissionTraceRead,
|
PermissionTraceRead,
|
||||||
PermissionAgentNodeRead, PermissionAgentNodeManage,
|
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},
|
"auditor": {PermissionProviderRead, PermissionAPIKeyRead, PermissionAuditRead, PermissionUsageRead, PermissionOutboxRead, PermissionContentPolicyRead, PermissionPricingRead, PermissionPromptRead, PermissionKnowledgeRead, PermissionToolRead, PermissionApplicationRead, PermissionNotificationRead, PermissionMCPServerRead, PermissionSkillRead, PermissionDigitalEmployeeRead, PermissionMarketplaceRead, PermissionFileRead, PermissionInboxRead, PermissionScheduledTaskRead, PermissionTraceRead, PermissionAgentNodeRead},
|
||||||
"member": {},
|
"member": {},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ type factorRequest struct {
|
|||||||
func NewHTTPHandler(service *Service) *HTTPHandler {
|
func NewHTTPHandler(service *Service) *HTTPHandler {
|
||||||
handler := &HTTPHandler{service: service, mux: http.NewServeMux()}
|
handler := &HTTPHandler{service: service, mux: http.NewServeMux()}
|
||||||
handler.mux.HandleFunc("POST /api/v1/admin/login", handler.login(KindAdmin))
|
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.registerTOTP(KindAdmin, "/api/v1/admin")
|
||||||
handler.mux.HandleFunc("GET /api/v1/admin/whoami", handler.whoami(KindAdmin))
|
handler.mux.HandleFunc("GET /api/v1/admin/whoami", handler.whoami(KindAdmin))
|
||||||
handler.mux.HandleFunc("POST /api/v1/admin/password", handler.changePassword(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)
|
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 {
|
func (h *HTTPHandler) login(kind Kind) http.HandlerFunc {
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
// 防爆破:按 IP 的滑动窗口限流,超限返回 429(与账号锁定叠加)。
|
// 防爆破:按 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)
|
result, err := h.service.Login(request.Context(), kind, login, input.Password)
|
||||||
if err != nil {
|
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)
|
h.writeIdentityError(writer, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 登录成功(含进入 TOTP 挑战阶段)。
|
||||||
|
_ = h.service.RecordLoginLog(request.Context(), kind, login, true, h.service.ClientIP(request), request.UserAgent(), "success")
|
||||||
apiresponse.OK(writer, map[string]any{
|
apiresponse.OK(writer, map[string]any{
|
||||||
"token": result.Token, "refreshToken": "", "require_totp": result.RequireTOTP,
|
"token": result.Token, "refreshToken": "", "require_totp": result.RequireTOTP,
|
||||||
"temp_token": result.TempToken,
|
"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) {
|
func (h *HTTPHandler) registerTOTP(kind Kind, prefix string) {
|
||||||
h.mux.HandleFunc("POST "+prefix+"/login/totp", h.completeTOTPLogin(kind))
|
h.mux.HandleFunc("POST "+prefix+"/login/totp", h.completeTOTPLogin(kind))
|
||||||
h.mux.HandleFunc("GET "+prefix+"/totp/status", h.totpStatus(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)
|
result, err := h.service.CompleteTOTPLogin(request.Context(), kind, input.TempToken, input.Code, input.BackupCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = h.service.RecordLoginLog(request.Context(), kind, "", false, h.service.ClientIP(request), request.UserAgent(), loginFailureReason(err))
|
||||||
h.writeIdentityError(writer, err)
|
h.writeIdentityError(writer, err)
|
||||||
return
|
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})
|
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) {
|
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": "定时任务"}})
|
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 {
|
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})
|
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": "PortalAccess", "path": "access", "component": "/portal/access", "meta": map[string]any{"title": "模型权限"}},
|
||||||
{"name": "PortalFiles", "path": "files", "component": "/portal/files", "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": "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": "登录记录"}},
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,37 @@ var (
|
|||||||
type ManagementHTTPHandler struct {
|
type ManagementHTTPHandler struct {
|
||||||
service *Service
|
service *Service
|
||||||
mux *http.ServeMux
|
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 {
|
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("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("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/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("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("PUT /api/v1/admin/identities/portal-users/{identity_id}", h.update(KindPortal))
|
||||||
h.mux.HandleFunc("GET /api/v1/admin/departments", h.listDepartments)
|
h.mux.HandleFunc("GET /api/v1/admin/departments", h.listDepartments)
|
||||||
@@ -79,6 +114,9 @@ func (h *ManagementHTTPHandler) create(kind Kind) http.HandlerFunc {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !h.checkAccountLimit(writer, request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
input, account, password, ok := h.decode(writer, request, kind, true)
|
input, account, password, ok := h.decode(writer, request, kind, true)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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) {
|
func (h *ManagementHTTPHandler) decode(writer http.ResponseWriter, request *http.Request, kind Kind, creating bool) (identityInput, Account, string, bool) {
|
||||||
var input identityInput
|
var input identityInput
|
||||||
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
|
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
|
||||||
@@ -176,10 +306,20 @@ func (h *ManagementHTTPHandler) decode(writer http.ResponseWriter, request *http
|
|||||||
apiresponse.Error(writer, http.StatusBadRequest, "账号或显示名称格式无效")
|
apiresponse.Error(writer, http.StatusBadRequest, "账号或显示名称格式无效")
|
||||||
return input, Account{}, "", false
|
return input, Account{}, "", false
|
||||||
}
|
}
|
||||||
if kind == KindAdmin && input.Role != "" && input.Role != "superadmin" && input.Role != "operator" && input.Role != "auditor" {
|
if kind == KindAdmin && input.Role != "" {
|
||||||
apiresponse.Error(writer, http.StatusBadRequest, "管理员角色无效")
|
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
|
return input, Account{}, "", false
|
||||||
}
|
}
|
||||||
|
input.Permissions = append(input.Permissions, role.Permissions...)
|
||||||
|
}
|
||||||
|
}
|
||||||
if kind == KindPortal && input.Role != "" && input.Role != "member" {
|
if kind == KindPortal && input.Role != "" && input.Role != "member" {
|
||||||
apiresponse.Error(writer, http.StatusBadRequest, "门户角色无效")
|
apiresponse.Error(writer, http.StatusBadRequest, "门户角色无效")
|
||||||
return input, Account{}, "", false
|
return input, Account{}, "", false
|
||||||
@@ -230,6 +370,8 @@ func (h *ManagementHTTPHandler) requirePermission(writer http.ResponseWriter, re
|
|||||||
return account, true
|
return account, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`)
|
||||||
|
|
||||||
func (h *ManagementHTTPHandler) writeError(writer http.ResponseWriter, err error) {
|
func (h *ManagementHTTPHandler) writeError(writer http.ResponseWriter, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrNotFound):
|
case errors.Is(err, ErrNotFound):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -281,6 +282,92 @@ func expectOne(pool *pgxpool.Pool, ctx context.Context, query string, arguments
|
|||||||
return nil
|
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) {
|
func (r *Repository) CreateAdmin(ctx context.Context, login, displayName, role, passwordHash string) (string, error) {
|
||||||
if r.pool == nil {
|
if r.pool == nil {
|
||||||
return "", ErrUnavailable
|
return "", ErrUnavailable
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -77,6 +77,16 @@ func (s *Service) AllowLogin(ctx context.Context, ip string) bool {
|
|||||||
return s.limiter == nil || s.limiter.Allow(ctx, ip)
|
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:仅在直连对端是可信代理时采信
|
// ClientIP 提取登录限流使用的客户端 IP:仅在直连对端是可信代理时采信
|
||||||
// X-Forwarded-For,否则直接用对端地址,防止伪造头绕过限流。
|
// X-Forwarded-For,否则直接用对端地址,防止伪造头绕过限流。
|
||||||
func (s *Service) ClientIP(r *http.Request) string {
|
func (s *Service) ClientIP(r *http.Request) string {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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") }
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -28,6 +28,12 @@ type Config struct {
|
|||||||
Embeddings Embeddings
|
Embeddings Embeddings
|
||||||
Inbox Inbox
|
Inbox Inbox
|
||||||
Scheduler Scheduler
|
Scheduler Scheduler
|
||||||
|
License License
|
||||||
|
}
|
||||||
|
|
||||||
|
// License 配置 License 授权(LICENSE_FILE 指向签名文件;为空=社区版 Free)。
|
||||||
|
type License struct {
|
||||||
|
FilePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -253,6 +259,9 @@ func Load() (Config, error) {
|
|||||||
BatchSize: intValue("SCHEDULER_BATCH_SIZE", 10),
|
BatchSize: intValue("SCHEDULER_BATCH_SIZE", 10),
|
||||||
MaxAttempts: intValue("SCHEDULER_MAX_ATTEMPTS", 3),
|
MaxAttempts: intValue("SCHEDULER_MAX_ATTEMPTS", 3),
|
||||||
},
|
},
|
||||||
|
License: License{
|
||||||
|
FilePath: strings.TrimSpace(os.Getenv("LICENSE_FILE")),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, cfg.Validate()
|
return cfg, cfg.Validate()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,60 @@ type Conversation struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
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) {
|
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)))
|
app, err := s.assets.GetPublishedApplicationByCode(ctx, strings.ToLower(strings.TrimSpace(code)))
|
||||||
if err != nil || !visible(app.DepartmentIDs, account.DepartmentID) {
|
if err != nil || !visible(app.DepartmentIDs, account.DepartmentID) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type HTTPHandler struct {
|
|||||||
func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHandler {
|
func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHandler {
|
||||||
h := &HTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()}
|
h := &HTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()}
|
||||||
h.mux.HandleFunc("POST /api/v1/portal/password", h.changePassword)
|
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/applications", h.applications)
|
||||||
h.mux.HandleFunc("GET /api/v1/portal/catalog", h.catalog)
|
h.mux.HandleFunc("GET /api/v1/portal/catalog", h.catalog)
|
||||||
h.mux.HandleFunc("GET /api/v1/portal/knowledge", h.knowledge)
|
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/cost", h.cost)
|
||||||
h.mux.HandleFunc("GET /api/v1/portal/docs-info", h.docsInfo)
|
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("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("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("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("POST /api/v1/portal/apps/{code}/conversations/{id}/messages", h.appendConversationMessage)
|
||||||
h.mux.HandleFunc("GET /api/v1/portal/marketplace", h.marketplace)
|
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())
|
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) {
|
func (h *HTTPHandler) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
account, ok := h.account(w, r)
|
account, ok := h.account(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -479,6 +496,53 @@ func (h *HTTPHandler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeApplicationResponse(w, response)
|
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) {
|
func (h *HTTPHandler) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := h.account(w, r)
|
a, ok := h.account(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -113,6 +113,35 @@ func scanTask(row pgx.Row) (Task, error) {
|
|||||||
return task, err
|
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) {
|
func (s *Service) List(ctx context.Context) ([]Task, error) {
|
||||||
rows, err := s.pool.Query(ctx, taskSelect+` ORDER BY updated_at DESC`)
|
rows, err := s.pool.Query(ctx, taskSelect+` ORDER BY updated_at DESC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package workbench
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"aigateway.local/core/internal/identity"
|
"aigateway.local/core/internal/identity"
|
||||||
"aigateway.local/core/internal/platform/apiresponse"
|
"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("PUT /api/v1/admin/mcp-servers/{id}", h.updateMCPServer)
|
||||||
h.mux.HandleFunc("DELETE /api/v1/admin/mcp-servers/{id}", h.deleteMCPServer)
|
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}/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("GET /api/v1/admin/skills", h.listSkills)
|
||||||
h.mux.HandleFunc("POST /api/v1/admin/skills", h.createSkill)
|
h.mux.HandleFunc("POST /api/v1/admin/skills", h.createSkill)
|
||||||
h.mux.HandleFunc("GET /api/v1/admin/skills/{id}", h.getSkill)
|
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)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
@@ -268,7 +268,8 @@
|
|||||||
"user": "User Manage",
|
"user": "User Manage",
|
||||||
"role": "Role Manage",
|
"role": "Role Manage",
|
||||||
"userCenter": "User Center",
|
"userCenter": "User Center",
|
||||||
"menu": "Menu Manage"
|
"menu": "Menu Manage",
|
||||||
|
"license": "License"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
|
|||||||
@@ -268,7 +268,8 @@
|
|||||||
"user": "用户管理",
|
"user": "用户管理",
|
||||||
"role": "角色管理",
|
"role": "角色管理",
|
||||||
"userCenter": "个人中心",
|
"userCenter": "个人中心",
|
||||||
"menu": "菜单管理"
|
"menu": "菜单管理",
|
||||||
|
"license": "License 授权"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ export const systemRoutes: AppRouteRecord = {
|
|||||||
roles: ['R_SUPER']
|
roles: ['R_SUPER']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'license',
|
||||||
|
name: 'License',
|
||||||
|
component: '/system/license',
|
||||||
|
meta: {
|
||||||
|
title: 'menus.system.license',
|
||||||
|
keepAlive: true,
|
||||||
|
roles: ['R_SUPER']
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'user-center',
|
path: 'user-center',
|
||||||
name: 'UserCenter',
|
name: 'UserCenter',
|
||||||
|
|||||||
Executable → Regular
+85
-30
@@ -1,41 +1,96 @@
|
|||||||
<!-- 工作台页面 -->
|
|
||||||
<template>
|
<template>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="mb-5 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<CardList></CardList>
|
<h2 class="text-xl font-semibold">运行概览</h2>
|
||||||
|
<p class="text-g-500 mt-1 text-sm">平台实时运行状态与近 24 小时用量</p>
|
||||||
|
</div>
|
||||||
|
<ElButton :loading="loading" @click="load">刷新</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ElRow :gutter="20">
|
<div class="mb-5 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<ElCol :sm="24" :md="12" :lg="10">
|
<ElCard shadow="never">
|
||||||
<ActiveUser />
|
<div class="text-g-500 text-sm">启用供应商</div>
|
||||||
</ElCol>
|
<b class="mt-2 block text-2xl">{{ info.enabled_providers ?? '—' }}</b>
|
||||||
<ElCol :sm="24" :md="12" :lg="14">
|
</ElCard>
|
||||||
<SalesOverview />
|
<ElCard shadow="never">
|
||||||
</ElCol>
|
<div class="text-g-500 text-sm">启用模型</div>
|
||||||
</ElRow>
|
<b class="mt-2 block text-2xl">{{ info.enabled_models ?? '—' }}</b>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard shadow="never">
|
||||||
|
<div class="text-g-500 text-sm">启用 API Key</div>
|
||||||
|
<b class="mt-2 block text-2xl">{{ info.enabled_api_keys ?? '—' }}</b>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard shadow="never">
|
||||||
|
<div class="text-g-500 text-sm">运行时长</div>
|
||||||
|
<b class="mt-2 block text-2xl">{{ uptimeText }}</b>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ElRow :gutter="20">
|
<div class="mb-5 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<ElCol :sm="24" :md="24" :lg="12">
|
<ElCard shadow="never">
|
||||||
<NewUser />
|
<div class="text-g-500 text-sm">24h 请求</div>
|
||||||
</ElCol>
|
<b class="mt-2 block text-2xl">{{ overview.requests ?? 0 }}</b>
|
||||||
<ElCol :sm="24" :md="12" :lg="6">
|
</ElCard>
|
||||||
<Dynamic />
|
<ElCard shadow="never">
|
||||||
</ElCol>
|
<div class="text-g-500 text-sm">24h 失败</div>
|
||||||
<ElCol :sm="24" :md="12" :lg="6">
|
<b class="mt-2 block text-2xl text-red-500">{{ overview.failed_requests ?? 0 }}</b>
|
||||||
<TodoList />
|
</ElCard>
|
||||||
</ElCol>
|
<ElCard shadow="never">
|
||||||
</ElRow>
|
<div class="text-g-500 text-sm">24h Tokens</div>
|
||||||
|
<b class="mt-2 block text-2xl">{{ ((overview.prompt_tokens ?? 0) + (overview.completion_tokens ?? 0)).toLocaleString() }}</b>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard shadow="never">
|
||||||
|
<div class="text-g-500 text-sm">平均延迟</div>
|
||||||
|
<b class="mt-2 block text-2xl">{{ overview.avg_latency_ms != null ? overview.avg_latency_ms.toFixed(1) + ' ms' : '—' }}</b>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AboutProject />
|
<ElDescriptions :column="3" border>
|
||||||
|
<ElDescriptionsItem label="版本">{{ info.version }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="Go">{{ info.go_version }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="License">{{ license.edition_name || '社区版 Free' }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="数据库">{{ info.database }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="对象存储">{{ info.object_storage ? '已启用' : '未启用' }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="统计窗口">{{ overview.window }}</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import CardList from './modules/card-list.vue'
|
import request from '@/utils/http'
|
||||||
import ActiveUser from './modules/active-user.vue'
|
|
||||||
import SalesOverview from './modules/sales-overview.vue'
|
|
||||||
import NewUser from './modules/new-user.vue'
|
|
||||||
import Dynamic from './modules/dynamic-stats.vue'
|
|
||||||
import TodoList from './modules/todo-list.vue'
|
|
||||||
import AboutProject from './modules/about-project.vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'Console' })
|
const loading = ref(false)
|
||||||
|
const info = ref<Record<string, any>>({})
|
||||||
|
const overview = ref<Record<string, any>>({})
|
||||||
|
const license = ref<Record<string, any>>({})
|
||||||
|
|
||||||
|
// license API 可能因权限不存在而失败,保持默认值
|
||||||
|
|
||||||
|
|
||||||
|
const uptimeText = computed(() => {
|
||||||
|
const seconds = info.value.uptime_seconds || 0
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
return days > 0 ? `${days} 天 ${hours} 小时` : `${hours} 小时 ${minutes} 分`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [system, monitor, lic] = await Promise.all([
|
||||||
|
request.get<any>({ url: '/api/v1/admin/system-info' }),
|
||||||
|
request.get<any>({ url: '/api/v1/admin/monitoring/overview' }),
|
||||||
|
request.get<any>({ url: '/api/v1/admin/license' }).catch(() => ({}))
|
||||||
|
])
|
||||||
|
info.value = system
|
||||||
|
overview.value = monitor
|
||||||
|
license.value = lic
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="mb-5">
|
||||||
|
<h2 class="text-xl font-semibold">AI 助手</h2>
|
||||||
|
<p class="text-g-500 mt-1 text-sm">基于平台实时状态(供应商/模型/账号/用量/事件投递)回答管理问题</p>
|
||||||
|
</div>
|
||||||
|
<ElCard shadow="never" class="mb-4">
|
||||||
|
<div class="max-h-96 space-y-3 overflow-y-auto">
|
||||||
|
<div v-for="(item, index) in messages" :key="index" class="flex" :class="item.role === 'user' ? 'justify-end' : 'justify-start'">
|
||||||
|
<div
|
||||||
|
class="max-w-[80%] whitespace-pre-wrap rounded-lg px-4 py-2 text-sm"
|
||||||
|
:class="item.role === 'user' ? 'bg-primary text-white' : 'bg-g-100 text-g-800'"
|
||||||
|
>{{ item.content }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="thinking" class="text-g-400 text-sm">助手思考中…</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ElInput v-model="input" placeholder="例如:平台目前有多少个供应商和 API Key?今日 Token 消耗多少?" @keyup.enter="send" />
|
||||||
|
<ElButton type="primary" :loading="thinking" @click="send">发送</ElButton>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<ElTag v-for="suggestion in suggestions" :key="suggestion" class="cursor-pointer" @click="ask(suggestion)">
|
||||||
|
{{ suggestion }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import request from '@/utils/http'
|
||||||
|
|
||||||
|
interface ChatItem {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = ref<ChatItem[]>([])
|
||||||
|
const input = ref('')
|
||||||
|
const thinking = ref(false)
|
||||||
|
const suggestions = ['平台概览', '今日用量与 Token 消耗', '有哪些待处理事件?', '如何新增模型供应商?']
|
||||||
|
|
||||||
|
function ask(text: string) {
|
||||||
|
input.value = text
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const message = input.value.trim()
|
||||||
|
if (!message || thinking.value) return
|
||||||
|
input.value = ''
|
||||||
|
messages.value.push({ role: 'user', content: message })
|
||||||
|
thinking.value = true
|
||||||
|
try {
|
||||||
|
const result = await request.post<{ answer: string }>({ url: '/api/v1/admin/assistant/chat', params: { message } })
|
||||||
|
messages.value.push({ role: 'assistant', content: result.answer })
|
||||||
|
} finally {
|
||||||
|
thinking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">License 授权</h2>
|
||||||
|
<p class="text-g-500 mt-1 text-sm">平台版本、账号数上限与授权有效期管控</p>
|
||||||
|
</div>
|
||||||
|
<ElButton type="primary" :loading="uploading" @click="openUpload">上传 License</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElAlert
|
||||||
|
v-if="!info.licensed"
|
||||||
|
class="mb-4"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
title="当前为社区版 Free(账号上限 3 个)。上传有效 License 后可启用专业版/旗舰版能力与更多账号。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElDescriptions v-loading="loading" :column="2" border class="mb-4">
|
||||||
|
<ElDescriptionsItem label="当前版本">{{ info.edition_name }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="版本标识">{{ info.edition }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="授权对象">{{ info.issued_to || '—' }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="账号数上限">
|
||||||
|
{{ info.max_accounts === 0 ? '不限' : info.max_accounts + ' 个' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="有效期至">{{ info.expires }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="License 文件">{{ info.file || '未配置' }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="授权特性" :span="2">
|
||||||
|
{{ info.features?.length ? info.features.join('、') : '—' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
|
||||||
|
<ElDialog v-model="dialogVisible" title="上传 License" width="560px">
|
||||||
|
<ElInput
|
||||||
|
v-model="content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="10"
|
||||||
|
placeholder="粘贴 License 文件完整内容(JSON,含 signature 字段)"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="uploading" @click="submit">保存并生效</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import request from '@/utils/http'
|
||||||
|
|
||||||
|
interface LicenseInfo {
|
||||||
|
edition: string
|
||||||
|
edition_name: string
|
||||||
|
issued_to?: string
|
||||||
|
max_accounts: number
|
||||||
|
features: string[]
|
||||||
|
expires: string
|
||||||
|
file: string
|
||||||
|
licensed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const content = ref('')
|
||||||
|
const info = ref<LicenseInfo>({
|
||||||
|
edition: 'free',
|
||||||
|
edition_name: '社区版 Free',
|
||||||
|
max_accounts: 3,
|
||||||
|
features: [],
|
||||||
|
expires: '永久',
|
||||||
|
file: '',
|
||||||
|
licensed: false
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
info.value = await request.get<LicenseInfo>({ url: '/api/v1/admin/license' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUpload() {
|
||||||
|
content.value = ''
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
ElMessage.warning('请粘贴 License 内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const result = await request.post<LicenseInfo>({ url: '/api/v1/admin/license', params: { content: content.value } })
|
||||||
|
info.value = result
|
||||||
|
dialogVisible.value = false
|
||||||
|
ElMessage.success('License 已生效')
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">登录记录</h2>
|
||||||
|
<p class="text-g-500 mt-1 text-sm">全量登录尝试审计(管理端/门户),可筛选账号</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ElInput v-model="loginFilter" placeholder="按登录名筛选" clearable class="w-52" @keyup.enter="load" />
|
||||||
|
<ElButton @click="load">查询</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElTable v-loading="loading" :data="logs" row-key="id">
|
||||||
|
<ElTableColumn prop="created_at" label="时间" width="190" />
|
||||||
|
<ElTableColumn prop="kind" label="端" width="80" />
|
||||||
|
<ElTableColumn prop="login" label="登录名" width="180" />
|
||||||
|
<ElTableColumn label="结果" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="row.success ? 'success' : 'danger'">{{ row.success ? '成功' : '失败' }}</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="ip" label="IP" width="150">
|
||||||
|
<template #default="{ row }">{{ row.ip || '—' }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="reason" label="原因" width="170" />
|
||||||
|
<ElTableColumn prop="user_agent" label="客户端" min-width="220" show-overflow-tooltip />
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import request from '@/utils/http'
|
||||||
|
|
||||||
|
interface LoginLog {
|
||||||
|
id: string
|
||||||
|
kind: string
|
||||||
|
login: string
|
||||||
|
success: boolean
|
||||||
|
ip?: string
|
||||||
|
reason: string
|
||||||
|
user_agent: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const loginFilter = ref('')
|
||||||
|
const logs = ref<LoginLog[]>([])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
logs.value = await request.get<LoginLog[]>({
|
||||||
|
url: '/api/v1/admin/login-logs',
|
||||||
|
params: loginFilter.value ? { login: loginFilter.value, limit: 100 } : { limit: 100 }
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
Executable → Regular
+133
-215
@@ -1,241 +1,159 @@
|
|||||||
<!-- 角色管理页面 -->
|
<!-- 角色管理:内置角色 + 自定义角色 CRUD,权限字符串编辑 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="art-full-height">
|
<div class="page-content">
|
||||||
<RoleSearch
|
<div class="mb-5 flex items-center justify-between gap-4">
|
||||||
v-show="showSearchBar"
|
<div>
|
||||||
v-model="searchForm"
|
<h2 class="text-xl font-semibold">角色管理</h2>
|
||||||
@search="handleSearch"
|
<p class="text-g-500 mt-1 text-sm">内置角色不可修改;自定义角色权限在分配账号时展开生效</p>
|
||||||
@reset="resetSearchParams"
|
</div>
|
||||||
></RoleSearch>
|
<ElButton type="primary" @click="openCreate">新增角色</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ElCard class="art-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
|
<ElTable v-loading="loading" :data="roles" row-key="id">
|
||||||
<ArtTableHeader
|
<ElTableColumn prop="code" label="代码" width="160" />
|
||||||
v-model:columns="columnChecks"
|
<ElTableColumn prop="name" label="名称" min-width="140" />
|
||||||
v-model:showSearchBar="showSearchBar"
|
<ElTableColumn prop="description" label="描述" min-width="180" show-overflow-tooltip />
|
||||||
:loading="loading"
|
<ElTableColumn label="类型" width="100">
|
||||||
@refresh="refreshData"
|
<template #default="{ row }">
|
||||||
>
|
<ElTag :type="row.builtin ? 'info' : 'success'">{{ row.builtin ? '内置' : '自定义' }}</ElTag>
|
||||||
<template #left>
|
|
||||||
<ElSpace wrap>
|
|
||||||
<ElButton @click="showDialog('add')" v-ripple>新增角色</ElButton>
|
|
||||||
</ElSpace>
|
|
||||||
</template>
|
</template>
|
||||||
</ArtTableHeader>
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="权限数" width="90">
|
||||||
|
<template #default="{ row }">{{ (row.permissions || []).length }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton link type="primary" :disabled="row.builtin" @click="openEdit(row)">编辑</ElButton>
|
||||||
|
<ElButton link type="danger" :disabled="row.builtin" @click="remove(row)">删除</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
|
||||||
<!-- 表格 -->
|
<ElDialog v-model="dialogVisible" :title="editingId ? '编辑角色' : '新增角色'" width="640px">
|
||||||
<ArtTable
|
<ElForm ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||||
:loading="loading"
|
<ElFormItem label="代码" prop="code">
|
||||||
:data="data"
|
<ElInput v-model="form.code" :disabled="!!editingId" placeholder="小写字母开头,如 ops-lead" />
|
||||||
:columns="columns"
|
</ElFormItem>
|
||||||
:pagination="pagination"
|
<ElFormItem label="名称" prop="name"><ElInput v-model="form.name" maxlength="64" /></ElFormItem>
|
||||||
@pagination:size-change="handleSizeChange"
|
<ElFormItem label="描述"><ElInput v-model="form.description" maxlength="512" /></ElFormItem>
|
||||||
@pagination:current-change="handleCurrentChange"
|
<ElFormItem label="权限">
|
||||||
>
|
<ElSelect v-model="form.permissions" multiple filterable collapse-tags class="w-full" placeholder="选择权限字符串">
|
||||||
</ArtTable>
|
<ElOption v-for="permission in permissionOptions" :key="permission.value" :label="permission.label" :value="permission.value" />
|
||||||
</ElCard>
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
<!-- 角色编辑弹窗 -->
|
</ElForm>
|
||||||
<RoleEditDialog
|
<template #footer>
|
||||||
v-model="dialogVisible"
|
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||||
:dialog-type="dialogType"
|
<ElButton type="primary" :loading="saving" @click="submit">保存</ElButton>
|
||||||
:role-data="currentRoleData"
|
</template>
|
||||||
@success="refreshData"
|
</ElDialog>
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 菜单权限弹窗 -->
|
|
||||||
<RolePermissionDialog
|
|
||||||
v-model="permissionDialog"
|
|
||||||
:role-data="currentRoleData"
|
|
||||||
@success="refreshData"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ButtonMoreItem } from '@/components/core/forms/art-button-more/index.vue'
|
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
||||||
import { useTable } from '@/hooks/core/useTable'
|
import request from '@/utils/http'
|
||||||
import { fetchGetRoleList } from '@/api/system-manage'
|
|
||||||
import ArtButtonMore from '@/components/core/forms/art-button-more/index.vue'
|
|
||||||
import RoleSearch from './modules/role-search.vue'
|
|
||||||
import RoleEditDialog from './modules/role-edit-dialog.vue'
|
|
||||||
import RolePermissionDialog from './modules/role-permission-dialog.vue'
|
|
||||||
import { ElTag, ElMessageBox } from 'element-plus'
|
|
||||||
|
|
||||||
defineOptions({ name: 'Role' })
|
interface Role {
|
||||||
|
id: string
|
||||||
type RoleListItem = Api.SystemManage.RoleListItem
|
code: string
|
||||||
type RoleSearchFormParams = Api.SystemManage.RoleSearchParams & {
|
name: string
|
||||||
daterange?: string[]
|
description: string
|
||||||
|
permissions: string[]
|
||||||
|
builtin: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索表单
|
const permissionOptions = [
|
||||||
const searchForm = ref<RoleSearchFormParams>({
|
{ label: '身份管理 identity:manage', value: 'identity:manage' },
|
||||||
roleName: undefined,
|
{ label: '供应商管理 provider:manage', value: 'provider:manage' },
|
||||||
roleCode: undefined,
|
{ label: '供应商查看 provider:read', value: 'provider:read' },
|
||||||
description: undefined,
|
{ label: 'API Key 管理 api_key:manage', value: 'api_key:manage' },
|
||||||
enabled: undefined,
|
{ label: 'API Key 查看 api_key:read', value: 'api_key:read' },
|
||||||
daterange: undefined
|
{ label: '审计查看 audit:read', value: 'audit:read' },
|
||||||
})
|
{ label: '用量查看 usage:read', value: 'usage:read' },
|
||||||
|
{ label: 'Outbox 管理 outbox:manage', value: 'outbox:manage' },
|
||||||
const showSearchBar = ref(false)
|
{ label: '内容策略管理 content_policy:manage', value: 'content_policy:manage' },
|
||||||
|
{ label: '定价管理 pricing:manage', value: 'pricing:manage' },
|
||||||
const dialogVisible = ref(false)
|
{ label: 'Prompt 管理 prompt:manage', value: 'prompt:manage' },
|
||||||
const permissionDialog = ref(false)
|
{ label: '知识库管理 knowledge:manage', value: 'knowledge:manage' },
|
||||||
const currentRoleData = ref<RoleListItem | undefined>(undefined)
|
{ label: '工具管理 tool:manage', value: 'tool:manage' },
|
||||||
|
{ label: '应用管理 application:manage', value: 'application:manage' },
|
||||||
const {
|
{ label: '通知管理 notification:manage', value: 'notification:manage' },
|
||||||
columns,
|
{ label: 'MCP 管理 mcp_server:manage', value: 'mcp_server:manage' },
|
||||||
columnChecks,
|
{ label: 'Skill 管理 skill:manage', value: 'skill:manage' },
|
||||||
data,
|
{ label: '数字员工管理 digital_employee:manage', value: 'digital_employee:manage' },
|
||||||
loading,
|
{ label: '市场管理 marketplace:manage', value: 'marketplace:manage' },
|
||||||
pagination,
|
{ label: '文件管理 file:manage', value: 'file:manage' },
|
||||||
getData,
|
{ label: '消息管理 inbox:manage', value: 'inbox:manage' },
|
||||||
searchParams,
|
{ label: '定时任务管理 scheduled_task:manage', value: 'scheduled_task:manage' },
|
||||||
resetSearchParams,
|
{ label: 'Trace 查看 trace:read', value: 'trace:read' },
|
||||||
handleSizeChange,
|
{ label: '智能体节点管理 agent_node:manage', value: 'agent_node:manage' },
|
||||||
handleCurrentChange,
|
{ label: '系统管理 system:manage', value: 'system:manage' }
|
||||||
refreshData
|
|
||||||
} = useTable({
|
|
||||||
// 核心配置
|
|
||||||
core: {
|
|
||||||
apiFn: fetchGetRoleList,
|
|
||||||
apiParams: {
|
|
||||||
current: 1,
|
|
||||||
size: 20
|
|
||||||
},
|
|
||||||
// 排除 apiParams 中的属性
|
|
||||||
excludeParams: ['daterange'],
|
|
||||||
columnsFactory: () => [
|
|
||||||
{
|
|
||||||
prop: 'roleId',
|
|
||||||
label: '角色ID',
|
|
||||||
width: 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'roleName',
|
|
||||||
label: '角色名称',
|
|
||||||
minWidth: 120
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'roleCode',
|
|
||||||
label: '角色编码',
|
|
||||||
minWidth: 120
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'description',
|
|
||||||
label: '角色描述',
|
|
||||||
minWidth: 150,
|
|
||||||
showOverflowTooltip: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'enabled',
|
|
||||||
label: '角色状态',
|
|
||||||
width: 100,
|
|
||||||
formatter: (row) => {
|
|
||||||
const statusConfig = row.enabled
|
|
||||||
? { type: 'success', text: '启用' }
|
|
||||||
: { type: 'warning', text: '禁用' }
|
|
||||||
return h(
|
|
||||||
ElTag,
|
|
||||||
{ type: statusConfig.type as 'success' | 'warning' },
|
|
||||||
() => statusConfig.text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'createTime',
|
|
||||||
label: '创建日期',
|
|
||||||
width: 180,
|
|
||||||
sortable: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'operation',
|
|
||||||
label: '操作',
|
|
||||||
width: 80,
|
|
||||||
fixed: 'right',
|
|
||||||
formatter: (row) =>
|
|
||||||
h('div', [
|
|
||||||
h(ArtButtonMore, {
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
key: 'permission',
|
|
||||||
label: '菜单权限',
|
|
||||||
icon: 'ri:user-3-line'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'edit',
|
|
||||||
label: '编辑角色',
|
|
||||||
icon: 'ri:edit-2-line'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'delete',
|
|
||||||
label: '删除角色',
|
|
||||||
icon: 'ri:delete-bin-4-line',
|
|
||||||
color: '#f56c6c'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row)
|
|
||||||
})
|
|
||||||
])
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const roles = ref<Role[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingId = ref('')
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const form = reactive({ code: '', name: '', description: '', permissions: [] as string[] })
|
||||||
|
const rules: FormRules = {
|
||||||
|
code: [{ required: true, message: '请输入角色代码', trigger: 'blur' }],
|
||||||
|
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const dialogType = ref<'add' | 'edit'>('add')
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
roles.value = await request.get<Role[]>({ url: '/api/v1/admin/roles' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showDialog = (type: 'add' | 'edit', row?: RoleListItem) => {
|
function openCreate() {
|
||||||
|
editingId.value = ''
|
||||||
|
Object.assign(form, { code: '', name: '', description: '', permissions: [] })
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
dialogType.value = type
|
|
||||||
currentRoleData.value = row
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function openEdit(row: Role) {
|
||||||
* 搜索处理
|
editingId.value = row.id
|
||||||
* @param params 搜索参数
|
Object.assign(form, { code: row.code, name: row.name, description: row.description, permissions: [...row.permissions] })
|
||||||
*/
|
dialogVisible.value = true
|
||||||
const handleSearch = (params: RoleSearchFormParams) => {
|
|
||||||
// 处理日期区间参数,把 daterange 转换为 startTime 和 endTime
|
|
||||||
const { daterange, ...filtersParams } = params
|
|
||||||
const [startTime, endTime] = Array.isArray(daterange) ? daterange : [null, null]
|
|
||||||
|
|
||||||
// 搜索参数赋值
|
|
||||||
Object.assign(searchParams, { ...filtersParams, startTime, endTime })
|
|
||||||
getData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonMoreClick = (item: ButtonMoreItem, row: RoleListItem) => {
|
async function submit() {
|
||||||
switch (item.key) {
|
if (!(await formRef.value?.validate())) return
|
||||||
case 'permission':
|
saving.value = true
|
||||||
showPermissionDialog(row)
|
try {
|
||||||
break
|
if (editingId.value) {
|
||||||
case 'edit':
|
await request.put({ url: `/api/v1/admin/roles/${editingId.value}`, params: { ...form } })
|
||||||
showDialog('edit', row)
|
ElMessage.success('角色已更新')
|
||||||
break
|
} else {
|
||||||
case 'delete':
|
await request.post({ url: '/api/v1/admin/roles', params: { ...form } })
|
||||||
deleteRole(row)
|
ElMessage.success('角色已创建')
|
||||||
break
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showPermissionDialog = (row?: RoleListItem) => {
|
async function remove(row: Role) {
|
||||||
permissionDialog.value = true
|
try {
|
||||||
currentRoleData.value = row
|
await ElMessageBox.confirm(`确认删除角色「${row.name}」?已分配该角色的账号权限不会自动回收。`, '删除角色', { type: 'warning' })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await request.del({ url: `/api/v1/admin/roles/${row.id}` })
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await load()
|
||||||
|
} catch { /* 全局错误提示 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteRole = (row: RoleListItem) => {
|
onMounted(load)
|
||||||
ElMessageBox.confirm(`确定删除角色"${row.roleName}"吗?此操作不可恢复!`, '删除确认', {
|
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning'
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// TODO: 调用删除接口
|
|
||||||
ElMessage.success('删除成功')
|
|
||||||
refreshData()
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
ElMessage.info('已取消删除')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="mb-5">
|
||||||
|
<h2 class="text-xl font-semibold">登录记录</h2>
|
||||||
|
<p class="text-g-500 mt-1 text-sm">最近 50 条本账号的登录尝试(含失败原因),如发现异常请立即修改密码</p>
|
||||||
|
</div>
|
||||||
|
<ElTable v-loading="loading" :data="logs" row-key="id">
|
||||||
|
<ElTableColumn prop="created_at" label="时间" width="190" />
|
||||||
|
<ElTableColumn label="结果" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="row.success ? 'success' : 'danger'">{{ row.success ? '成功' : '失败' }}</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="ip" label="IP" width="150">
|
||||||
|
<template #default="{ row }">{{ row.ip || '—' }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="user_agent" label="客户端" min-width="220" show-overflow-tooltip />
|
||||||
|
<ElTableColumn prop="reason" label="原因" width="160" />
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import request from '@/utils/http'
|
||||||
|
|
||||||
|
interface LoginLog {
|
||||||
|
id: string
|
||||||
|
success: boolean
|
||||||
|
ip?: string
|
||||||
|
user_agent: string
|
||||||
|
reason: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const logs = ref<LoginLog[]>([])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
logs.value = await request.get<LoginLog[]>({ url: '/api/v1/portal/login-logs' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">记忆管理</h2>
|
||||||
|
<p class="text-g-500 mt-1 text-sm">沉淀个人经验与偏好,对话时按语义自动召回;可授权给其他用户</p>
|
||||||
|
</div>
|
||||||
|
<ElButton type="primary" @click="openCreate">添加记忆</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElCard shadow="never" class="mb-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ElInput v-model="recallQuery" placeholder="语义检索我的记忆(如:我常用的代码风格)" @keyup.enter="recall" />
|
||||||
|
<ElButton type="primary" plain :loading="recalling" @click="recall">召回</ElButton>
|
||||||
|
</div>
|
||||||
|
<div v-if="recalled.length" class="mt-3 space-y-2">
|
||||||
|
<div v-for="item in recalled" :key="item.id" class="rounded bg-g-50 p-3 text-sm">
|
||||||
|
<div class="font-medium">{{ item.category }} · 重要度 {{ item.importance }}</div>
|
||||||
|
<div class="text-g-600 mt-1">{{ item.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<ElTable v-loading="loading" :data="entries" row-key="id">
|
||||||
|
<ElTableColumn prop="category" label="分类" width="120" />
|
||||||
|
<ElTableColumn prop="content" label="内容" min-width="300" show-overflow-tooltip />
|
||||||
|
<ElTableColumn prop="importance" label="重要度" width="90" />
|
||||||
|
<ElTableColumn label="授权给" width="160">
|
||||||
|
<template #default="{ row }">{{ (row.shared_with || []).length ? row.shared_with.length + ' 人' : '—' }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="updated_at" label="更新时间" width="180" />
|
||||||
|
<ElTableColumn label="操作" width="140" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton link type="primary" @click="openEdit(row)">编辑</ElButton>
|
||||||
|
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
|
||||||
|
<ElDialog v-model="dialogVisible" :title="editingId ? '编辑记忆' : '添加记忆'" width="600px">
|
||||||
|
<ElForm label-width="90px">
|
||||||
|
<ElFormItem label="分类"><ElInput v-model="form.category" placeholder="如 工作偏好 / 代码风格 / 常用工具" /></ElFormItem>
|
||||||
|
<ElFormItem label="内容" required>
|
||||||
|
<ElInput v-model="form.content" type="textarea" :rows="4" maxlength="8000" show-word-limit placeholder="记忆内容,对话时按语义召回" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="重要度">
|
||||||
|
<ElRate v-model="form.importance" :max="10" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="授权用户ID">
|
||||||
|
<ElInput v-model="sharedText" placeholder="逗号分隔的用户 ID(授权后对方可召回你的这条记忆)" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="saving" @click="submit">保存</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import request from '@/utils/http'
|
||||||
|
|
||||||
|
interface MemoryEntry {
|
||||||
|
id: string
|
||||||
|
category: string
|
||||||
|
content: string
|
||||||
|
importance: number
|
||||||
|
shared_with: string[]
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = ref<MemoryEntry[]>([])
|
||||||
|
const recalled = ref<MemoryEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const recalling = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingId = ref('')
|
||||||
|
const recallQuery = ref('')
|
||||||
|
const sharedText = ref('')
|
||||||
|
const form = reactive({ category: 'general', content: '', importance: 5 })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
entries.value = await request.get<MemoryEntry[]>({ url: '/api/v1/portal/memories' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recall() {
|
||||||
|
if (!recallQuery.value.trim()) return
|
||||||
|
recalling.value = true
|
||||||
|
try {
|
||||||
|
recalled.value = await request.post<MemoryEntry[]>({ url: '/api/v1/portal/memories/recall', params: { query: recallQuery.value.trim(), limit: 5 } })
|
||||||
|
} finally {
|
||||||
|
recalling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingId.value = ''
|
||||||
|
Object.assign(form, { category: 'general', content: '', importance: 5 })
|
||||||
|
sharedText.value = ''
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: MemoryEntry) {
|
||||||
|
editingId.value = row.id
|
||||||
|
Object.assign(form, { category: row.category, content: row.content, importance: row.importance })
|
||||||
|
sharedText.value = (row.shared_with || []).join(',')
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!form.content.trim()) {
|
||||||
|
ElMessage.warning('请输入记忆内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const shared = sharedText.value.split(',').map((v) => v.trim()).filter(Boolean)
|
||||||
|
const payload = { ...form, shared_with: shared }
|
||||||
|
if (editingId.value) {
|
||||||
|
await request.put({ url: `/api/v1/portal/memories/${editingId.value}`, params: payload })
|
||||||
|
} else {
|
||||||
|
await request.post({ url: '/api/v1/portal/memories', params: payload })
|
||||||
|
}
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
dialogVisible.value = false
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row: MemoryEntry) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认删除这条记忆?', '删除记忆', { type: 'warning' })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await request.del({ url: `/api/v1/portal/memories/${row.id}` })
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await load()
|
||||||
|
} catch { /* 全局错误提示 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">定时任务</h2>
|
||||||
|
<p class="text-g-500 mt-1 text-sm">创建自动化任务:按 Cron 计划执行应用/数字员工,支持查看执行历史</p>
|
||||||
|
</div>
|
||||||
|
<ElButton type="primary" @click="openCreate">新建任务</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElTable v-loading="loading" :data="tasks" row-key="id">
|
||||||
|
<ElTableColumn prop="name" label="名称" min-width="160" />
|
||||||
|
<ElTableColumn prop="target_type" label="目标类型" width="110">
|
||||||
|
<template #default="{ row }">{{ row.target_type === 'application' ? '应用' : '数字员工' }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="target_code" label="目标" min-width="150" />
|
||||||
|
<ElTableColumn prop="cron_expression" label="Cron" width="110" />
|
||||||
|
<ElTableColumn prop="timezone" label="时区" width="130" />
|
||||||
|
<ElTableColumn label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '暂停' }}</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="last_status" label="上次结果" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="row.last_status === 'success' ? 'success' : row.last_status ? 'danger' : 'info'">
|
||||||
|
{{ row.last_status || '—' }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="next_run_at" label="下次执行" width="180" />
|
||||||
|
<ElTableColumn label="操作" width="260" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton link type="success" :disabled="row.enabled" @click="setEnabled(row, true)">启动</ElButton>
|
||||||
|
<ElButton link type="warning" :disabled="!row.enabled" @click="setEnabled(row, false)">暂停</ElButton>
|
||||||
|
<ElButton link type="primary" @click="runNow(row)">立即执行</ElButton>
|
||||||
|
<ElButton link type="primary" @click="openRuns(row)">历史</ElButton>
|
||||||
|
<ElButton link type="primary" @click="openEdit(row)">编辑</ElButton>
|
||||||
|
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
|
||||||
|
<ElDialog v-model="dialogVisible" :title="editingId ? '编辑任务' : '新建任务'" width="640px">
|
||||||
|
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||||
|
<ElFormItem label="名称" prop="name"><ElInput v-model="form.name" maxlength="128" /></ElFormItem>
|
||||||
|
<ElFormItem label="目标类型" prop="target_type">
|
||||||
|
<ElSelect v-model="form.target_type" class="w-full">
|
||||||
|
<ElOption label="应用" value="application" />
|
||||||
|
<ElOption label="数字员工" value="digital_employee" />
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="目标代码" prop="target_code"><ElInput v-model="form.target_code" placeholder="如 my-app / my-employee" /></ElFormItem>
|
||||||
|
<ElFormItem label="Cron 表达式" prop="cron_expression"><ElInput v-model="form.cron_expression" placeholder="五字段,如 0 9 * * 1" /></ElFormItem>
|
||||||
|
<ElFormItem label="时区">
|
||||||
|
<ElInput v-model="form.timezone" placeholder="Asia/Shanghai" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="提示词">
|
||||||
|
<ElInput v-model="form.prompt" type="textarea" :rows="3" placeholder="可选,追加给目标的任务提示" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="执行 API Key">
|
||||||
|
<ElInput v-model="form.api_key" type="password" show-password :placeholder="editingId ? '留空表示不更换' : '必填'" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="saving" @click="submit">保存</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
|
||||||
|
<ElDialog v-model="runsVisible" title="执行历史" width="820px">
|
||||||
|
<ElTable v-loading="runsLoading" :data="runs" size="small">
|
||||||
|
<ElTableColumn prop="scheduled_for" label="计划时间" width="170" />
|
||||||
|
<ElTableColumn prop="started_at" label="开始时间" width="170" />
|
||||||
|
<ElTableColumn label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="row.status === 'success' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'">
|
||||||
|
{{ row.status }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="attempts" label="尝试" width="70" />
|
||||||
|
<ElTableColumn prop="error" label="错误" min-width="200" show-overflow-tooltip />
|
||||||
|
</ElTable>
|
||||||
|
</ElDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
||||||
|
import request from '@/utils/http'
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
target_type: string
|
||||||
|
target_code: string
|
||||||
|
cron_expression: string
|
||||||
|
timezone: string
|
||||||
|
prompt?: string
|
||||||
|
enabled: boolean
|
||||||
|
last_status?: string
|
||||||
|
next_run_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskInput {
|
||||||
|
name: string
|
||||||
|
target_type: string
|
||||||
|
target_code: string
|
||||||
|
cron_expression: string
|
||||||
|
timezone: string
|
||||||
|
prompt?: string
|
||||||
|
api_key?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = ref<Task[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingId = ref('')
|
||||||
|
const runsVisible = ref(false)
|
||||||
|
const runsLoading = ref(false)
|
||||||
|
const runs = ref<any[]>([])
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const form = reactive<TaskInput>({
|
||||||
|
name: '',
|
||||||
|
target_type: 'application',
|
||||||
|
target_code: '',
|
||||||
|
cron_expression: '0 9 * * 1',
|
||||||
|
timezone: 'Asia/Shanghai',
|
||||||
|
prompt: '',
|
||||||
|
api_key: ''
|
||||||
|
})
|
||||||
|
const rules: FormRules = {
|
||||||
|
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
|
||||||
|
target_type: [{ required: true, message: '请选择目标类型', trigger: 'change' }],
|
||||||
|
target_code: [{ required: true, message: '请输入目标代码', trigger: 'blur' }],
|
||||||
|
cron_expression: [{ required: true, message: '请输入 Cron 表达式', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
tasks.value = await request.get<Task[]>({ url: '/api/v1/portal/scheduled-tasks' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingId.value = ''
|
||||||
|
Object.assign(form, { name: '', target_type: 'application', target_code: '', cron_expression: '0 9 * * 1', timezone: 'Asia/Shanghai', prompt: '', api_key: '' })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: Task) {
|
||||||
|
editingId.value = row.id
|
||||||
|
Object.assign(form, {
|
||||||
|
name: row.name,
|
||||||
|
target_type: row.target_type,
|
||||||
|
target_code: row.target_code,
|
||||||
|
cron_expression: row.cron_expression,
|
||||||
|
timezone: row.timezone,
|
||||||
|
prompt: row.prompt || '',
|
||||||
|
api_key: ''
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!(await formRef.value?.validate())) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload: TaskInput = { ...form }
|
||||||
|
if (!payload.api_key) delete payload.api_key
|
||||||
|
if (editingId.value) {
|
||||||
|
await request.put({ url: `/api/v1/portal/scheduled-tasks/${editingId.value}`, params: payload })
|
||||||
|
ElMessage.success('任务已更新')
|
||||||
|
} else {
|
||||||
|
await request.post({ url: '/api/v1/portal/scheduled-tasks', params: payload })
|
||||||
|
ElMessage.success('任务已创建')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setEnabled(row: Task, enabled: boolean) {
|
||||||
|
try {
|
||||||
|
await request.post({ url: `/api/v1/portal/scheduled-tasks/${row.id}/${enabled ? 'start' : 'pause'}` })
|
||||||
|
await load()
|
||||||
|
} catch { /* 全局错误提示 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNow(row: Task) {
|
||||||
|
try {
|
||||||
|
await request.post({ url: `/api/v1/portal/scheduled-tasks/${row.id}/run` })
|
||||||
|
ElMessage.success('已加入执行队列')
|
||||||
|
} catch { /* 全局错误提示 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row: Task) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除任务「${row.name}」?`, '删除任务', { type: 'warning' })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await request.del({ url: `/api/v1/portal/scheduled-tasks/${row.id}` })
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await load()
|
||||||
|
} catch { /* 全局错误提示 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRuns(row: Task) {
|
||||||
|
runsVisible.value = true
|
||||||
|
runsLoading.value = true
|
||||||
|
try {
|
||||||
|
runs.value = await request.get<any[]>({ url: `/api/v1/portal/scheduled-tasks/${row.id}/runs` })
|
||||||
|
} finally {
|
||||||
|
runsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user