0.11.3: 旗舰版第四轮完善(统一审批中心/工具治理/平台环境变量/数字员工入口/个人渠道/报表多维/租户配额)

- 统一审批中心:模型/资源/渠道/工具四类申请聚合审批,通过自动开通
  (marketplace 安装/渠道授权),outbox 双向站内信;门户可发起/撤回。
- 工具治理:rate_limit_rpm(固定窗口原子 upsert,多实例共享)+ approval_required
  (首次调用自动发起审批,批准前一律拒绝)。
- 平台环境变量:平台级注入 skill/MCP 运行时,个人可覆盖;系统管理员可写。
- 数字员工会话入口:门户列表/对话/调用记录,复用用户运行时凭据。
- 个人渠道:webhook 入站令牌 SHA-256 摘要 + constant-time 校验,绑定已批准
  模型,用量归属用户 Key。
- 报表多维:工具调用/审批授权/安全事件三组统计端点与页面。
- 租户配额:部门 Key/月 Token 上限,运行时凭据开通强制校验,概览展示用量。
- 迁移 000042-000045;修复渠道空 API Key NOT NULL 违约与 inet 扫描;
  25 包测试通过,前后端构建通过,端到端验证完成。
This commit is contained in:
LLMGuardX Dev
2026-08-13 13:41:22 +08:00
parent e31cc54b8e
commit 87c2b04174
40 changed files with 2416 additions and 111 deletions
+55 -25
View File
@@ -24,24 +24,28 @@ var (
)
type Department struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *string `json:"parent_id"`
ParentName string `json:"parent_name,omitempty"`
Active bool `json:"active"`
UserCount int `json:"user_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *string `json:"parent_id"`
ParentName string `json:"parent_name,omitempty"`
Active bool `json:"active"`
MaxAPIKeys int `json:"max_api_keys"`
MaxMonthlyTokens int64 `json:"max_monthly_tokens"`
UserCount int `json:"user_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type departmentInput struct {
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *string `json:"parent_id"`
Active *bool `json:"active"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *string `json:"parent_id"`
Active *bool `json:"active"`
MaxAPIKeys *int `json:"max_api_keys"`
MaxMonthlyTokens *int64 `json:"max_monthly_tokens"`
}
func (h *ManagementHTTPHandler) listDepartments(writer http.ResponseWriter, request *http.Request) {
@@ -94,6 +98,14 @@ func (h *ManagementHTTPHandler) updateDepartment(writer http.ResponseWriter, req
if input.Active == nil {
department.Active = current.Active
}
// 租户配额同样按部分更新语义处理:未提供时保留当前值,避免"只改名"
// 的 PUT 把配额清零。
if input.MaxAPIKeys == nil {
department.MaxAPIKeys = current.MaxAPIKeys
}
if input.MaxMonthlyTokens == nil {
department.MaxMonthlyTokens = current.MaxMonthlyTokens
}
updated, err := h.service.repository.UpdateDepartment(request.Context(), department, actor.ID)
if err != nil {
h.writeDepartmentError(writer, err)
@@ -126,7 +138,23 @@ func decodeDepartment(writer http.ResponseWriter, request *http.Request) (depart
if input.Active != nil {
active = *input.Active
}
return input, Department{Code: input.Code, Name: input.Name, Description: input.Description, ParentID: parentID, Active: active}, true
maxAPIKeys := 0
if input.MaxAPIKeys != nil {
if *input.MaxAPIKeys < 0 || *input.MaxAPIKeys > 1000000 {
apiresponse.Error(writer, http.StatusBadRequest, "Key 配额无效")
return input, Department{}, false
}
maxAPIKeys = *input.MaxAPIKeys
}
var maxMonthlyTokens int64
if input.MaxMonthlyTokens != nil {
if *input.MaxMonthlyTokens < 0 || *input.MaxMonthlyTokens > 1e15 {
apiresponse.Error(writer, http.StatusBadRequest, "月 Token 配额无效")
return input, Department{}, false
}
maxMonthlyTokens = *input.MaxMonthlyTokens
}
return input, Department{Code: input.Code, Name: input.Name, Description: input.Description, ParentID: parentID, Active: active, MaxAPIKeys: maxAPIKeys, MaxMonthlyTokens: maxMonthlyTokens}, true
}
func (h *ManagementHTTPHandler) writeDepartmentError(writer http.ResponseWriter, err error) {
@@ -152,7 +180,7 @@ func (r *Repository) ListDepartments(ctx context.Context) ([]Department, error)
}
rows, err := r.pool.Query(ctx, `
SELECT d.id::text, d.code, d.name, d.description, d.parent_id::text,
COALESCE(p.name, ''), d.active,
COALESCE(p.name, ''), d.active, d.max_api_keys, d.max_monthly_tokens,
count(u.id) FILTER (WHERE u.active), d.created_at, d.updated_at
FROM gateway.departments d
LEFT JOIN gateway.departments p ON p.id = d.parent_id
@@ -167,7 +195,8 @@ func (r *Repository) ListDepartments(ctx context.Context) ([]Department, error)
for rows.Next() {
var department Department
if err := rows.Scan(&department.ID, &department.Code, &department.Name, &department.Description,
&department.ParentID, &department.ParentName, &department.Active, &department.UserCount,
&department.ParentID, &department.ParentName, &department.Active, &department.MaxAPIKeys,
&department.MaxMonthlyTokens, &department.UserCount,
&department.CreatedAt, &department.UpdatedAt); err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
@@ -182,10 +211,11 @@ func (r *Repository) GetDepartment(ctx context.Context, id string) (Department,
}
var department Department
err := r.pool.QueryRow(ctx, `
SELECT id::text, code, name, description, parent_id::text, active, created_at, updated_at
SELECT id::text, code, name, description, parent_id::text, active, max_api_keys, max_monthly_tokens, created_at, updated_at
FROM gateway.departments WHERE id = $1`, id).Scan(
&department.ID, &department.Code, &department.Name, &department.Description,
&department.ParentID, &department.Active, &department.CreatedAt, &department.UpdatedAt,
&department.ParentID, &department.Active, &department.MaxAPIKeys, &department.MaxMonthlyTokens,
&department.CreatedAt, &department.UpdatedAt,
)
return department, mapRepositoryError(err)
}
@@ -250,18 +280,18 @@ func (r *Repository) storeDepartment(ctx context.Context, department Department,
}
if creating {
err = tx.QueryRow(ctx, `
INSERT INTO gateway.departments (id, code, name, description, parent_id, active)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO gateway.departments (id, code, name, description, parent_id, active, max_api_keys, max_monthly_tokens)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING created_at, updated_at`, department.ID, department.Code, department.Name,
department.Description, department.ParentID, department.Active).Scan(&department.CreatedAt, &department.UpdatedAt)
department.Description, department.ParentID, department.Active, department.MaxAPIKeys, department.MaxMonthlyTokens).Scan(&department.CreatedAt, &department.UpdatedAt)
} else {
err = tx.QueryRow(ctx, `
UPDATE gateway.departments
SET code = $2, name = $3, description = $4, parent_id = $5,
active = $6, updated_at = clock_timestamp()
active = $6, max_api_keys = $7, max_monthly_tokens = $8, updated_at = clock_timestamp()
WHERE id = $1
RETURNING created_at, updated_at`, department.ID, department.Code, department.Name,
department.Description, department.ParentID, department.Active).Scan(&department.CreatedAt, &department.UpdatedAt)
department.Description, department.ParentID, department.Active, department.MaxAPIKeys, department.MaxMonthlyTokens).Scan(&department.CreatedAt, &department.UpdatedAt)
}
if err != nil {
return Department{}, mapDepartmentError(err)
+5
View File
@@ -563,6 +563,8 @@ func adminMenus(account Account) []map[string]any {
if HasPermission(account, PermissionSystemManage) {
systemChildren = append(systemChildren, map[string]any{"name": "Assistant", "path": "assistant", "component": "/system/assistant", "meta": map[string]any{"title": "AI 助手"}})
systemChildren = append(systemChildren, map[string]any{"name": "License", "path": "license", "component": "/system/license", "meta": map[string]any{"title": "License 授权"}})
systemChildren = append(systemChildren, map[string]any{"name": "Approvals", "path": "approvals", "component": "/system/approvals", "meta": map[string]any{"title": "审批中心"}})
systemChildren = append(systemChildren, map[string]any{"name": "PlatformEnvVars", "path": "platform-env-vars", "component": "/system/platform-env-vars", "meta": map[string]any{"title": "平台环境变量"}})
}
if len(systemChildren) > 0 {
menus = append(menus, map[string]any{"name": "System", "path": "/system", "component": "/index/index", "meta": map[string]any{"title": "系统管理", "icon": "ri:user-3-line"}, "children": systemChildren})
@@ -577,8 +579,11 @@ func portalMenus() []map[string]any {
{"name": "PortalCatalog", "path": "catalog", "component": "/portal/catalog", "meta": map[string]any{"title": "资产目录"}},
{"name": "PortalMarketplace", "path": "marketplace", "component": "/portal/marketplace", "meta": map[string]any{"title": "资源市场"}},
{"name": "PortalPrompts", "path": "prompts", "component": "/portal/prompts", "meta": map[string]any{"title": "Prompt 广场"}},
{"name": "PortalDigitalEmployees", "path": "digital-employees", "component": "/portal/digital-employees", "meta": map[string]any{"title": "数字员工"}},
{"name": "PortalUsage", "path": "usage", "component": "/portal/usage", "meta": map[string]any{"title": "我的用量"}},
{"name": "PortalAccess", "path": "access", "component": "/portal/access", "meta": map[string]any{"title": "模型权限"}},
{"name": "PortalRequests", "path": "requests", "component": "/portal/requests", "meta": map[string]any{"title": "我的申请"}},
{"name": "PortalPersonalChannels", "path": "personal-channels", "component": "/portal/personal-channels", "meta": map[string]any{"title": "个人渠道"}},
{"name": "PortalFiles", "path": "files", "component": "/portal/files", "meta": map[string]any{"title": "文件仓库"}},
{"name": "PortalInbox", "path": "inbox", "component": "/portal/inbox", "meta": map[string]any{"title": "站内消息"}},
{"name": "PortalScheduledTasks", "path": "scheduled-tasks", "component": "/portal/scheduled-tasks", "meta": map[string]any{"title": "定时任务"}},