Files
LLMGuardX Dev 87c2b04174 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 包测试通过,前后端构建通过,端到端验证完成。
2026-08-13 13:41:22 +08:00

338 lines
12 KiB
Go

package identity
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"aigateway.local/core/internal/platform/apiresponse"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
var (
ErrDepartmentConflict = errors.New("department already exists")
ErrDepartmentInUse = errors.New("department is in use")
ErrDepartmentCycle = errors.New("department hierarchy cycle")
departmentCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`)
)
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"`
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"`
MaxAPIKeys *int `json:"max_api_keys"`
MaxMonthlyTokens *int64 `json:"max_monthly_tokens"`
}
func (h *ManagementHTTPHandler) listDepartments(writer http.ResponseWriter, request *http.Request) {
if _, ok := h.requirePermission(writer, request); !ok {
return
}
departments, err := h.service.repository.ListDepartments(request.Context())
if err != nil {
h.writeDepartmentError(writer, err)
return
}
apiresponse.OK(writer, departments)
}
func (h *ManagementHTTPHandler) createDepartment(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
input, department, ok := decodeDepartment(writer, request)
if !ok {
return
}
_ = input
created, err := h.service.repository.CreateDepartment(request.Context(), department, actor.ID)
if err != nil {
h.writeDepartmentError(writer, err)
return
}
apiresponse.OK(writer, created)
}
func (h *ManagementHTTPHandler) updateDepartment(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
input, department, ok := decodeDepartment(writer, request)
if !ok {
return
}
department.ID = request.PathValue("department_id")
current, err := h.service.repository.GetDepartment(request.Context(), department.ID)
if err != nil {
h.writeDepartmentError(writer, err)
return
}
// 部分更新语义:省略 active 时保留当前状态,避免"只改名称"的 PUT
// 绕过停用保护把部门静默重新激活。
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)
return
}
apiresponse.OK(writer, updated)
}
func decodeDepartment(writer http.ResponseWriter, request *http.Request) (departmentInput, Department, bool) {
var input departmentInput
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, Department{}, false
}
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
input.Name = strings.TrimSpace(input.Name)
input.Description = strings.TrimSpace(input.Description)
if !departmentCodePattern.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 1024 {
apiresponse.Error(writer, http.StatusBadRequest, "部门代码、名称或描述格式无效")
return input, Department{}, false
}
var parentID *string
if input.ParentID != nil && strings.TrimSpace(*input.ParentID) != "" {
value := strings.TrimSpace(*input.ParentID)
parentID = &value
}
active := true
if input.Active != nil {
active = *input.Active
}
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) {
switch {
case errors.Is(err, ErrNotFound):
apiresponse.Error(writer, http.StatusNotFound, "部门不存在")
case errors.Is(err, ErrDepartmentConflict):
apiresponse.Error(writer, http.StatusConflict, "部门代码已存在")
case errors.Is(err, ErrDepartmentCycle):
apiresponse.Error(writer, http.StatusConflict, "部门层级不能形成循环")
case errors.Is(err, ErrDepartmentInUse):
apiresponse.Error(writer, http.StatusConflict, "部门仍包含启用用户或启用子部门,不能停用")
case errors.Is(err, ErrUnavailable):
apiresponse.Error(writer, http.StatusServiceUnavailable, "部门服务暂不可用")
default:
apiresponse.Error(writer, http.StatusBadRequest, "部门操作失败")
}
}
func (r *Repository) ListDepartments(ctx context.Context) ([]Department, error) {
if r.pool == nil {
return nil, ErrUnavailable
}
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, 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
LEFT JOIN gateway.portal_users u ON u.department_id = d.id
GROUP BY d.id, p.name
ORDER BY d.code`)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer rows.Close()
departments := make([]Department, 0)
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.MaxAPIKeys,
&department.MaxMonthlyTokens, &department.UserCount,
&department.CreatedAt, &department.UpdatedAt); err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
departments = append(departments, department)
}
return departments, mapRepositoryError(rows.Err())
}
func (r *Repository) GetDepartment(ctx context.Context, id string) (Department, error) {
if r.pool == nil {
return Department{}, ErrUnavailable
}
var department Department
err := r.pool.QueryRow(ctx, `
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.MaxAPIKeys, &department.MaxMonthlyTokens,
&department.CreatedAt, &department.UpdatedAt,
)
return department, mapRepositoryError(err)
}
func (r *Repository) CreateDepartment(ctx context.Context, department Department, actorID string) (Department, error) {
id, err := platformid.NewUUID()
if err != nil {
return Department{}, err
}
department.ID = id
return r.storeDepartment(ctx, department, actorID, true)
}
func (r *Repository) UpdateDepartment(ctx context.Context, department Department, actorID string) (Department, error) {
return r.storeDepartment(ctx, department, actorID, false)
}
func (r *Repository) storeDepartment(ctx context.Context, department Department, actorID string, creating bool) (Department, error) {
if r.pool == nil {
return Department{}, ErrUnavailable
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer func() { _ = tx.Rollback(ctx) }()
if department.ParentID != nil {
var parentActive bool
if err := tx.QueryRow(ctx, `SELECT active FROM gateway.departments WHERE id = $1`, *department.ParentID).Scan(&parentActive); err != nil {
return Department{}, mapRepositoryError(err)
}
if !parentActive {
return Department{}, ErrDepartmentInUse
}
}
if !creating && department.ParentID != nil {
var cycle bool
if err := tx.QueryRow(ctx, `
WITH RECURSIVE descendants AS (
SELECT id FROM gateway.departments WHERE parent_id = $1
UNION ALL
SELECT d.id FROM gateway.departments d JOIN descendants x ON d.parent_id = x.id
)
SELECT $2::uuid = $1::uuid OR EXISTS (SELECT 1 FROM descendants WHERE id = $2)`,
department.ID, *department.ParentID).Scan(&cycle); err != nil {
return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if cycle {
return Department{}, ErrDepartmentCycle
}
}
if !creating && !department.Active {
var inUse bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM gateway.portal_users WHERE department_id = $1 AND active)
OR EXISTS (SELECT 1 FROM gateway.departments WHERE parent_id = $1 AND active)`, department.ID).Scan(&inUse); err != nil {
return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if inUse {
return Department{}, ErrDepartmentInUse
}
}
if creating {
err = tx.QueryRow(ctx, `
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, 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, 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, department.MaxAPIKeys, department.MaxMonthlyTokens).Scan(&department.CreatedAt, &department.UpdatedAt)
}
if err != nil {
return Department{}, mapDepartmentError(err)
}
eventID, err := platformid.NewUUID()
if err != nil {
return Department{}, err
}
eventType := "department.updated"
if creating {
eventType = "department.created"
}
payload, _ := json.Marshal(map[string]any{"department_id": department.ID, "code": department.Code, "actor_id": actorID})
if _, err := tx.Exec(ctx, `
INSERT INTO gateway.outbox_events
(event_id, event_type, event_version, aggregate_type, aggregate_id, payload)
VALUES ($1, $2, 1, 'department', $3, $4)`, eventID, eventType, department.ID, payload); err != nil {
return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if err := tx.Commit(ctx); err != nil {
return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return department, nil
}
func mapDepartmentError(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
var pgError *pgconn.PgError
if errors.As(err, &pgError) {
switch pgError.Code {
case "23505":
return ErrDepartmentConflict
case "23503", "23514":
return ErrDepartmentCycle
}
}
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return nil
}