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

338 lines
13 KiB
Python

"""岗位 (position) management API — admin only.
A position aggregates, per resource type, the tags whose resources are granted
to its members. Assigning a user to a position (1:1) materializes those grants
into the user's "我的" tables via the sync engine; recommended-question and
skill visibility are filtered live elsewhere.
All endpoints require ``system_role == "admin"`` (auth-disabled mode lets calls
through, matching the other admin routers).
"""
from __future__ import annotations
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request, get_position_store, get_position_sync, get_tag_store
from deerflow.persistence.positions import POSITION_RESOURCE_TYPES, PositionStore
from deerflow.persistence.tags import TagStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/positions", tags=["positions"])
async def _require_admin(request: Request) -> None:
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_bindings(bindings: dict[str, list[str]]) -> dict[str, list[str]]:
out: dict[str, list[str]] = {}
for resource_type, tag_ids in bindings.items():
if resource_type not in POSITION_RESOURCE_TYPES:
raise HTTPException(status_code=422, detail=f"Invalid resource type '{resource_type}'. Allowed: {', '.join(POSITION_RESOURCE_TYPES)}")
out[resource_type] = list(tag_ids or [])
return out
# ------------------------------------------------------------------ schemas
class PositionResponse(BaseModel):
id: str
name: str
description: str = ""
is_default: bool = False
member_count: int = 0
created_at: str | None = None
updated_at: str | None = None
class PositionListResponse(BaseModel):
positions: list[PositionResponse]
resource_types: list[str] = Field(default_factory=lambda: list(POSITION_RESOURCE_TYPES))
class PositionCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=191)
description: str = Field(default="", max_length=1024)
is_default: bool = False
class PositionUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=191)
description: str | None = Field(default=None, max_length=1024)
is_default: bool | None = None
class TagBindingsResponse(BaseModel):
position_id: str
bindings: dict[str, list[str]]
class TagBindingsRequest(BaseModel):
bindings: dict[str, list[str]] = Field(default_factory=dict, description="resource_type -> [tag_id]; omitted/empty type = default set")
class MemberResponse(BaseModel):
id: str
email: str = ""
class MembersResponse(BaseModel):
members: list[MemberResponse]
class MembersMutationRequest(BaseModel):
user_ids: list[str] = Field(..., min_length=1)
class UserPositionRequest(BaseModel):
position_id: str | None = Field(default=None, description="null clears the user's position")
class PositionUserResponse(BaseModel):
id: str
email: str = ""
system_role: str = "user"
position_id: str | None = None
class PositionUsersResponse(BaseModel):
users: list[PositionUserResponse]
class MyPositionResponse(BaseModel):
position: PositionResponse | None = None
# ----------------------------------------------------------------- position CRUD
@router.get("", response_model=PositionListResponse, summary="List Positions")
async def list_positions(request: Request, store: PositionStore = Depends(get_position_store)) -> PositionListResponse:
await _require_admin(request)
records = await store.list_positions()
return PositionListResponse(positions=[PositionResponse(**r) for r in records])
@router.get("/users", response_model=PositionUsersResponse, summary="List All Users + Their Position")
async def list_position_users(request: Request, store: PositionStore = Depends(get_position_store)) -> PositionUsersResponse:
await _require_admin(request)
users = await store.list_all_users()
return PositionUsersResponse(users=[PositionUserResponse(**u) for u in users])
@router.get("/me", response_model=MyPositionResponse, summary="Get My Effective Position")
async def get_my_position(request: Request, store: PositionStore = Depends(get_position_store)) -> MyPositionResponse:
"""The caller's own position — open to any authenticated user (not admin).
Returns the explicitly-assigned position, falling back to the default
position (which unassigned users inherit). ``null`` when neither exists or
the caller is anonymous. Powers the top-bar 岗位 label.
"""
user = await get_optional_user_from_request(request)
if user is None:
return MyPositionResponse(position=None)
record = None
pid = await store.get_user_position_id(str(user.id))
if pid:
record = await store.get_position(pid)
if record is None:
default_id = await store.get_default_position_id()
if default_id:
record = await store.get_position(default_id)
return MyPositionResponse(position=PositionResponse(**record) if record else None)
@router.post("", response_model=PositionResponse, status_code=201, summary="Create Position")
async def create_position(request: Request, body: PositionCreateRequest, store: PositionStore = Depends(get_position_store)) -> PositionResponse:
await _require_admin(request)
try:
record = await store.create_position({"id": uuid.uuid4().hex, "name": body.name.strip(), "description": body.description, "is_default": body.is_default})
return PositionResponse(**record)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
@router.put("/{position_id}", response_model=PositionResponse, summary="Update Position")
async def update_position(
request: Request,
position_id: str,
body: PositionUpdateRequest,
store: PositionStore = Depends(get_position_store),
sync=Depends(get_position_sync),
) -> PositionResponse:
await _require_admin(request)
update_data = body.model_dump(exclude_unset=True)
if update_data.get("name"):
update_data["name"] = update_data["name"].strip()
try:
record = await store.update_position(position_id, update_data)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
if record is None:
raise HTTPException(status_code=404, detail="Position not found")
# Becoming the default changes what unassigned users inherit.
if update_data.get("is_default"):
await sync.sync_default_members()
return PositionResponse(**record)
@router.delete("/{position_id}", status_code=204, summary="Delete Position")
async def delete_position(
request: Request,
position_id: str,
store: PositionStore = Depends(get_position_store),
sync=Depends(get_position_sync),
) -> None:
await _require_admin(request)
member_ids = await store.list_member_ids(position_id)
deleted = await store.delete_position(position_id)
if not deleted:
raise HTTPException(status_code=404, detail="Position not found")
# Former members now fall back to the default position; re-sync them.
for uid in member_ids:
await sync.sync_user(uid)
# ----------------------------------------------------------------- tag bindings
@router.get("/{position_id}/tag-bindings", response_model=TagBindingsResponse, summary="Get Tag Bindings")
async def get_tag_bindings(request: Request, position_id: str, store: PositionStore = Depends(get_position_store)) -> TagBindingsResponse:
await _require_admin(request)
if await store.get_position(position_id) is None:
raise HTTPException(status_code=404, detail="Position not found")
bindings = await store.get_tag_bindings(position_id)
return TagBindingsResponse(position_id=position_id, bindings=bindings)
@router.put("/{position_id}/tag-bindings", response_model=TagBindingsResponse, summary="Set Tag Bindings")
async def set_tag_bindings(
request: Request,
position_id: str,
body: TagBindingsRequest,
store: PositionStore = Depends(get_position_store),
tag_store: TagStore = Depends(get_tag_store),
sync=Depends(get_position_sync),
) -> TagBindingsResponse:
await _require_admin(request)
if await store.get_position(position_id) is None:
raise HTTPException(status_code=404, detail="Position not found")
cleaned = _validate_bindings(body.bindings)
# Validate every tag id exists and matches its resource type.
for resource_type, tag_ids in cleaned.items():
if not tag_ids:
continue
known = {t["id"] for t in await tag_store.list_tags(resource_type)}
for tid in tag_ids:
if tid not in known:
raise HTTPException(status_code=422, detail=f"Tag '{tid}' is not a '{resource_type}' tag")
bindings = await store.set_tag_bindings(position_id, cleaned)
await sync.sync_position(position_id)
return TagBindingsResponse(position_id=position_id, bindings=bindings)
# --------------------------------------------------------------------- members
@router.get("/{position_id}/members", response_model=MembersResponse, summary="List Position Members")
async def list_members(request: Request, position_id: str, store: PositionStore = Depends(get_position_store)) -> MembersResponse:
await _require_admin(request)
if await store.get_position(position_id) is None:
raise HTTPException(status_code=404, detail="Position not found")
members = await store.list_members(position_id)
return MembersResponse(members=[MemberResponse(**m) for m in members])
@router.post("/{position_id}/members", response_model=MembersResponse, summary="Add Position Members")
async def add_members(
request: Request,
position_id: str,
body: MembersMutationRequest,
store: PositionStore = Depends(get_position_store),
sync=Depends(get_position_sync),
) -> MembersResponse:
await _require_admin(request)
if await store.get_position(position_id) is None:
raise HTTPException(status_code=404, detail="Position not found")
# Re-sync users leaving their previous position too, so its grants are dropped.
prev_positions = {uid: await store.get_user_position_id(uid) for uid in body.user_ids}
await store.assign_users(position_id, body.user_ids)
for uid in body.user_ids:
await sync.sync_user(uid)
prev = prev_positions.get(uid)
if prev and prev != position_id:
logger.debug("user %s moved from position %s to %s", uid, prev, position_id)
members = await store.list_members(position_id)
return MembersResponse(members=[MemberResponse(**m) for m in members])
@router.delete("/{position_id}/members", response_model=MembersResponse, summary="Remove Position Members")
async def remove_members(
request: Request,
position_id: str,
body: MembersMutationRequest,
store: PositionStore = Depends(get_position_store),
sync=Depends(get_position_sync),
) -> MembersResponse:
await _require_admin(request)
if await store.get_position(position_id) is None:
raise HTTPException(status_code=404, detail="Position not found")
# Only unassign users that are actually in this position.
member_ids = set(await store.list_member_ids(position_id))
victims = [uid for uid in body.user_ids if uid in member_ids]
await store.unassign_users(victims)
for uid in victims:
await sync.sync_user(uid)
members = await store.list_members(position_id)
return MembersResponse(members=[MemberResponse(**m) for m in members])
@router.post("/{position_id}/resync", summary="Re-sync Position Members")
async def resync_position(
request: Request,
position_id: str,
store: PositionStore = Depends(get_position_store),
sync=Depends(get_position_sync),
) -> dict:
await _require_admin(request)
if await store.get_position(position_id) is None:
raise HTTPException(status_code=404, detail="Position not found")
count = await sync.sync_position(position_id)
return {"synced": count}
# --------------------------------------------------- per-user position (1:1)
@router.put("/users/{user_id}", response_model=PositionResponse | None, summary="Set User Position")
async def set_user_position(
request: Request,
user_id: str,
body: UserPositionRequest,
store: PositionStore = Depends(get_position_store),
sync=Depends(get_position_sync),
) -> PositionResponse | None:
await _require_admin(request)
if body.position_id:
if await store.get_position(body.position_id) is None:
raise HTTPException(status_code=404, detail="Position not found")
await store.assign_users(body.position_id, [user_id])
else:
await store.unassign_users([user_id])
await sync.sync_user(user_id)
if not body.position_id:
return None
record = await store.get_position(body.position_id)
return PositionResponse(**record) if record else None