Files
ai-gateway-go/internal/identity/departments.go
T
superidou 9501751792 0.10.1: 安全与业务逻辑加固、新品牌与部署加固
三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
  与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
  撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
  全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
  >4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
  后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
  nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
2026-08-13 10:50:51 +08:00

308 lines
11 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"`
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"`
}
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
}
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
}
return input, Department{Code: input.Code, Name: input.Name, Description: input.Description, ParentID: parentID, Active: active}, 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,
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.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, 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,
)
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)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING created_at, updated_at`, department.ID, department.Code, department.Name,
department.Description, department.ParentID, department.Active).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()
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)
}
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
}