237 lines
7.0 KiB
Python
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,
|
|
)
|