"""项目成员分配服务。""" from __future__ import annotations import re from typing import Iterable, Sequence from sqlalchemy import and_, delete, select from sqlalchemy.ext.asyncio import AsyncSession from ...models import User, UserProject from ..notification_service import NotificationService _ROLE_SPLIT_RE = re.compile(r"[,/|\s]+") _ROLE_ALIAS_MAP = { "AI": {"AI"}, "AI_ENGINEER": {"AI"}, "FRONTEND": {"FE"}, "FE": {"FE"}, "BACKEND": {"BE"}, "BE": {"BE"}, "BOSS": {"BOSS"}, "ADMIN": {"ADMIN"}, } class ProjectMemberAssignmentError(ValueError): """项目成员分配失败。""" def __init__(self, message: str, *, invalid_user_ids: Sequence[int] | None = None) -> None: super().__init__(message) self.invalid_user_ids = [int(uid) for uid in (invalid_user_ids or [])] def _normalize_user_ids(user_ids: Iterable[int] | None) -> list[int]: normalized: set[int] = set() for raw in user_ids or []: if raw is None: continue try: normalized.add(int(raw)) except (TypeError, ValueError): continue return sorted(normalized) def _parse_role_tokens(raw: str | None) -> set[str]: tokens = { token.strip().upper() for token in _ROLE_SPLIT_RE.split(str(raw or "").strip()) if token.strip() } normalized: set[str] = set() for token in tokens: normalized.update(_ROLE_ALIAS_MAP.get(token, {token})) return normalized def _user_matches_project_role(user_role: str | None, expected_roles: set[str]) -> bool: if not expected_roles: return False return bool(_parse_role_tokens(user_role).intersection(expected_roles)) async def _load_active_user_ids( db: AsyncSession, *, tenant_id: int, candidate_user_ids: Sequence[int] | None = None, ) -> set[int]: query = select(User.id).where( and_( User.tenant_id == int(tenant_id), User.status == "active", User.is_active.is_(True), ) ) normalized_ids = _normalize_user_ids(candidate_user_ids) if normalized_ids: query = query.where(User.id.in_(normalized_ids)) rows = await db.execute(query) return {int(uid) for uid in rows.scalars().all() if uid is not None} async def _load_role_member_ids( db: AsyncSession, *, tenant_id: int, assigned_role: str | None, ) -> set[int]: expected_roles = _parse_role_tokens(assigned_role) if not expected_roles: return set() rows = await db.execute( select(User.id, User.role).where( and_( User.tenant_id == int(tenant_id), User.status == "active", User.is_active.is_(True), ) ) ) role_member_ids: set[int] = set() for user_id, user_role in rows.all(): if _user_matches_project_role(user_role, expected_roles): role_member_ids.add(int(user_id)) return role_member_ids async def resolve_project_member_ids( db: AsyncSession, *, tenant_id: int, owner_id: int | None, assigned_role: str | None, requested_member_user_ids: Sequence[int] | None = None, require_requested_members: bool = False, include_owner: bool = True, ) -> list[int]: """解析项目成员列表,支持显式成员、角色兜底与 owner 兜底。""" member_ids: set[int] = set() requested_ids = _normalize_user_ids(requested_member_user_ids) if requested_ids: active_requested = await _load_active_user_ids( db, tenant_id=tenant_id, candidate_user_ids=requested_ids, ) invalid_user_ids = sorted(set(requested_ids) - active_requested) if invalid_user_ids: raise ProjectMemberAssignmentError( "member_user_ids 包含不存在或非激活用户", invalid_user_ids=invalid_user_ids, ) member_ids.update(active_requested) elif require_requested_members: raise ProjectMemberAssignmentError("至少分配一名项目成员") if include_owner and owner_id is not None: owner_ids = await _load_active_user_ids( db, tenant_id=tenant_id, candidate_user_ids=[int(owner_id)], ) member_ids.update(owner_ids) if not member_ids and not require_requested_members: member_ids.update( await _load_role_member_ids( db, tenant_id=tenant_id, assigned_role=assigned_role, ) ) if not member_ids and not require_requested_members: active_user_ids = await _load_active_user_ids(db, tenant_id=tenant_id) if active_user_ids: member_ids.add(min(active_user_ids)) if not member_ids: raise ProjectMemberAssignmentError("当前租户没有可分配成员,已拒绝写入") return sorted(member_ids) async def replace_project_members( db: AsyncSession, *, tenant_id: int, project_id: int, member_user_ids: Sequence[int], ) -> list[int]: """全量替换项目成员。""" normalized_ids = _normalize_user_ids(member_user_ids) if not normalized_ids: raise ProjectMemberAssignmentError("至少分配一名项目成员") await db.execute( delete(UserProject).where( and_( UserProject.tenant_id == int(tenant_id), UserProject.project_id == int(project_id), ) ) ) for user_id in normalized_ids: db.add( UserProject( tenant_id=int(tenant_id), user_id=int(user_id), project_id=int(project_id), ) ) return normalized_ids async def notify_member_assignment_failure( db: AsyncSession, *, tenant_id: int, actor_user_id: int | None, reason: str, interface_path: str, project_id: int | None = None, project_title: str | None = None, commit: bool = False, ) -> None: """将成员分配失败事件写入消息台。""" if actor_user_id is None: return project_label = f"项目#{int(project_id)}" if project_id is not None else (project_title or "新项目") content = ( f"接口:{interface_path}\n" f"项目:{project_label}\n" f"原因:{reason}" ) await NotificationService(db).create_notification( tenant_id=int(tenant_id), user_id=int(actor_user_id), category="system", title="项目成员分配失败", content=content, source="project_member_service", entity_type="project", entity_id=int(project_id) if project_id is not None else None, target_url=f"/projects/{int(project_id)}/board" if project_id is not None else "/projects", kind="error", priority="high", status="failed", blocking_reason=reason[:500], recommended_action="retry", dedupe_key=( f"project-member-failed:{int(tenant_id)}:" f"{int(actor_user_id)}:{interface_path}:{int(project_id or 0)}" )[:255], commit=commit, )