87c2b04174
- 统一审批中心:模型/资源/渠道/工具四类申请聚合审批,通过自动开通 (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 包测试通过,前后端构建通过,端到端验证完成。
338 lines
12 KiB
Go
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
|
|
}
|