149 lines
5.5 KiB
Python
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])
|