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 }