5759c1862e
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
298 lines
10 KiB
Go
298 lines
10 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
|
|
}
|
|
_, department, ok := decodeDepartment(writer, request)
|
|
if !ok {
|
|
return
|
|
}
|
|
department.ID = request.PathValue("department_id")
|
|
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
|
|
}
|