"""Global position-roundtable role configuration API. This directory is intentionally different from ``/api/positions``: the latter is the organisation/RBAC position model, whereas this router controls the work views that appear in the 岗位多智能体会商 page. """ from __future__ import annotations import re from datetime import datetime from typing import Literal from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field, field_validator from app.gateway.deps import get_current_user, get_optional_user_from_request from app.gateway.position_role_defaults import POSITION_ROLE_SEED from deerflow.persistence.position_roles import PositionRoleStore router = APIRouter(prefix="/api/position-roles", tags=["position-roles"]) ROLE_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,127}$") class PositionRole(BaseModel): id: str = Field(..., min_length=1, max_length=128) name: str = Field(..., min_length=1, max_length=128) description: str = Field(default="", max_length=512) role_type: Literal["standard", "intent"] = "standard" enabled: bool = True # Response values are preserved by the repository; clients must not use # this flag to promote a custom role into a protected built-in role. is_builtin: bool = False sort_order: int = Field(default=0, ge=0) @field_validator("id") @classmethod def validate_id(cls, value: str) -> str: value = value.strip() if not ROLE_ID_PATTERN.fullmatch(value): raise ValueError("岗位标识须以字母开头,只能包含字母、数字、- 或 _") return value @field_validator("name") @classmethod def normalize_name(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("岗位名称不能为空") return value class PositionRoleResponse(PositionRole): updated_by: str | None = None updated_at: datetime | str | None = None class PositionRoleListResponse(BaseModel): roles: list[PositionRoleResponse] class PositionRoleReplaceRequest(BaseModel): roles: list[PositionRole] = Field(default_factory=list) def _get_store(request: Request) -> PositionRoleStore: store = getattr(request.app.state, "position_role_store", None) if store is None: raise HTTPException(status_code=503, detail="Position role store not available") return store async def _require_admin(request: Request) -> None: """Reject non-admins but retain the local auth-disabled developer mode.""" user = await get_optional_user_from_request(request) if user is None: return if getattr(user, "system_role", None) != "admin": raise HTTPException(status_code=403, detail="岗位角色配置仅限管理员") def _validate_payload( rows: list[PositionRole], existing: list[dict[str, object]], ) -> list[dict[str, object]]: """Apply directory invariants before the atomic replacement. Keeping built-ins prevents old business-chain sessions from becoming unreadable. They remain fully editable (including disable/retype); custom roles can be removed freely. """ if not rows: raise HTTPException(status_code=422, detail="至少保留一个启用的岗位") ids = [row.id for row in rows] if len(ids) != len(set(ids)): raise HTTPException(status_code=422, detail="岗位标识不能重复") names = [row.name.casefold() for row in rows] if len(names) != len(set(names)): raise HTTPException(status_code=422, detail="岗位名称不能重复") enabled = [row for row in rows if row.enabled] intent_rows = [row for row in enabled if row.role_type == "intent"] if len(intent_rows) != 1: raise HTTPException( status_code=422, detail="请保留且仅保留一个启用的意图识别岗(它承载会商入口和收口智能体)", ) builtin_ids = {str(item["id"]) for item in existing if item.get("is_builtin")} missing_builtin = builtin_ids.difference(ids) if missing_builtin: raise HTTPException( status_code=422, detail="内置岗位不能删除;如暂不使用,请将它停用", ) existing_builtin = {str(item["id"]): bool(item.get("is_builtin")) for item in existing} return [ { **row.model_dump(), "is_builtin": existing_builtin.get(row.id, False), "sort_order": index, } for index, row in enumerate(rows) ] @router.get("", response_model=PositionRoleListResponse) async def list_position_roles(request: Request) -> PositionRoleListResponse: # Startup performs the same idempotent seed. Repeating it here makes an # upgraded running instance recover gracefully when the table was created # after startup or a deployment starts with an empty database. rows = await _get_store(request).ensure_seed(POSITION_ROLE_SEED) return PositionRoleListResponse(roles=[PositionRoleResponse(**row) for row in rows]) @router.put("", response_model=PositionRoleListResponse) async def replace_position_roles( request: Request, body: PositionRoleReplaceRequest, ) -> PositionRoleListResponse: await _require_admin(request) store = _get_store(request) normalized = _validate_payload(body.roles, await store.list_roles()) user_id = await get_current_user(request) rows = await store.replace_all(normalized, updated_by=user_id) return PositionRoleListResponse(roles=[PositionRoleResponse(**row) for row in rows])