PMFlow/backend/app/services/project_member/service.py

237 lines
7.0 KiB
Python

"""项目成员分配服务。"""
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 = {
"OWNER": {"OWNER"},
"REVIEWER": {"REVIEWER"},
"OPERATOR": {"OPERATOR"},
"BUILDER": {"BUILDER"},
}
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,
)