deerflow-code/offline-backend-20260512/backend/app/gateway/routers/position_roles.py
2026-09-07 18:24:55 +08:00

149 lines
5.5 KiB
Python

"""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])