PMFlow/backend/app/services/workspace_service.py

612 lines
22 KiB
Python

"""Workspace lifecycle and membership service.
This module keeps the current single-tenant implementation compatible while
introducing the product model: every account owns a personal workspace and may
join team workspaces through memberships.
"""
from __future__ import annotations
from datetime import datetime, timedelta
import hashlib
import secrets
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import and_, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Project, Tenant, TenantInvitation, TenantMembership, User, UserProject, UserStatus
from ..services.auth import AuthService, has_any_role
from ..utils.timezone import now_cn_naive
INVITATION_TOKEN_BYTES = 32
INVITATION_EXPIRE_DAYS = 7
ACTIVE_MEMBERSHIP_STATUS = "active"
PERSONAL_WORKSPACE_TYPE = "personal"
TEAM_WORKSPACE_TYPE = "team"
VALID_WORKSPACE_ROLES = {"OWNER", "REVIEWER", "BUILDER", "OPERATOR"}
TEAM_INVITABLE_ROLES = {"REVIEWER", "BUILDER", "OPERATOR"}
def hash_invitation_token(token: str) -> str:
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
def _normalize_email(email: str) -> str:
normalized = str(email or "").strip().lower()
if not normalized or "@" not in normalized:
raise HTTPException(status_code=400, detail="有效邮箱不能为空")
return normalized
def _phone_invitation_email(phone_e164: str) -> str:
safe = "".join(ch for ch in str(phone_e164 or "") if ch.isdigit())
return f"{safe}@phone.pmflow.local"
def _normalize_role(role: str) -> str:
normalized = str(role or "").strip().upper()
if normalized not in VALID_WORKSPACE_ROLES:
raise HTTPException(status_code=400, detail=f"无效空间角色:{role}")
return normalized
def _workspace_payload(tenant: Tenant, membership: TenantMembership | None = None) -> dict[str, Any]:
return {
"id": int(tenant.id),
"name": tenant.name,
"domain": tenant.domain,
"workspace_type": tenant.workspace_type,
"slug": tenant.slug,
"owner_user_id": int(tenant.owner_user_id) if tenant.owner_user_id is not None else None,
"status": tenant.status,
"role": membership.role if membership else None,
"membership_status": membership.status if membership else None,
"is_personal_owner": bool(membership.is_personal_owner) if membership else False,
}
async def ensure_personal_workspace(
db: AsyncSession,
*,
user: User,
commit: bool = False,
) -> Tenant:
"""Ensure a user has a personal workspace and membership."""
row = await db.execute(
select(Tenant)
.join(TenantMembership, TenantMembership.tenant_id == Tenant.id)
.where(
and_(
TenantMembership.user_id == int(user.id),
TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS,
TenantMembership.is_personal_owner.is_(True),
Tenant.workspace_type == PERSONAL_WORKSPACE_TYPE,
)
)
.order_by(Tenant.id.asc())
)
existing = row.scalars().first()
if existing:
return existing
display_name = str(user.name or user.username or "我的").strip()
tenant = Tenant(
name=f"{display_name} 的个人空间",
domain=None,
workspace_type=PERSONAL_WORKSPACE_TYPE,
slug=f"personal-{int(user.id)}",
owner_user_id=int(user.id),
status="active",
)
db.add(tenant)
await db.flush()
db.add(
TenantMembership(
tenant_id=int(tenant.id),
user_id=int(user.id),
role="OWNER",
status=ACTIVE_MEMBERSHIP_STATUS,
is_personal_owner=True,
)
)
if commit:
await db.commit()
await db.refresh(tenant)
return tenant
async def ensure_active_workspace_membership(
db: AsyncSession,
*,
user: User,
commit: bool = False,
) -> TenantMembership:
"""Backfill and return membership for the user's current compatibility tenant."""
tenant_id = int(getattr(user, "tenant_id", 0) or 0)
if tenant_id <= 0:
personal = await ensure_personal_workspace(db, user=user, commit=False)
user.tenant_id = int(personal.id)
user.role = "OWNER"
tenant_id = int(personal.id)
row = await db.execute(
select(TenantMembership).where(
and_(
TenantMembership.tenant_id == tenant_id,
TenantMembership.user_id == int(user.id),
)
)
)
membership = row.scalar_one_or_none()
if not membership:
membership = TenantMembership(
tenant_id=tenant_id,
user_id=int(user.id),
role=str(user.role or "BUILDER").upper(),
status=ACTIVE_MEMBERSHIP_STATUS,
is_personal_owner=False,
)
db.add(membership)
await db.flush()
if commit:
await db.commit()
await db.refresh(membership)
return membership
async def register_user_with_personal_workspace(
db: AsyncSession,
*,
username: str,
password: str,
name: str,
email: str,
) -> User:
normalized_email = _normalize_email(email)
username_value = str(username or "").strip()
if not username_value:
raise HTTPException(status_code=400, detail="用户名不能为空")
existing = (
await db.execute(
select(User).where((func.lower(User.username) == username_value.lower()) | (func.lower(User.email) == normalized_email))
)
).scalar_one_or_none()
if existing:
raise HTTPException(status_code=409, detail="用户名或邮箱已存在")
tenant = Tenant(
name=f"{(name or username_value).strip()} 的个人空间",
workspace_type=PERSONAL_WORKSPACE_TYPE,
slug=None,
owner_user_id=None,
status="active",
)
db.add(tenant)
await db.flush()
user = User(
tenant_id=int(tenant.id),
username=username_value,
password_hash=AuthService.hash_password(password),
name=(name or username_value).strip(),
email=normalized_email,
role="OWNER",
assigned_domains=[],
workload=0,
status=UserStatus.ACTIVE.value,
is_active=True,
)
db.add(user)
await db.flush()
tenant.owner_user_id = int(user.id)
tenant.slug = f"personal-{int(user.id)}"
db.add(
TenantMembership(
tenant_id=int(tenant.id),
user_id=int(user.id),
role="OWNER",
status=ACTIVE_MEMBERSHIP_STATUS,
is_personal_owner=True,
)
)
await db.commit()
await db.refresh(user)
return user
async def list_user_workspaces(db: AsyncSession, *, user: User) -> dict[str, Any]:
await ensure_personal_workspace(db, user=user, commit=False)
await ensure_active_workspace_membership(db, user=user, commit=False)
await db.commit()
rows = (
await db.execute(
select(Tenant, TenantMembership)
.join(TenantMembership, TenantMembership.tenant_id == Tenant.id)
.where(
and_(
TenantMembership.user_id == int(user.id),
TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS,
Tenant.status == "active",
)
)
.order_by(Tenant.workspace_type.desc(), Tenant.id.asc())
)
).all()
workspaces = [_workspace_payload(tenant, membership) for tenant, membership in rows]
active_workspace = next((item for item in workspaces if int(item["id"]) == int(user.tenant_id)), None)
return {"active_workspace": active_workspace, "workspaces": workspaces}
async def create_team_workspace(db: AsyncSession, *, current_user: User, name: str) -> dict[str, Any]:
team_name = str(name or "").strip()
if not team_name:
raise HTTPException(status_code=400, detail="团队空间名称不能为空")
tenant = Tenant(
name=team_name,
workspace_type=TEAM_WORKSPACE_TYPE,
owner_user_id=int(current_user.id),
status="active",
)
db.add(tenant)
await db.flush()
membership = TenantMembership(
tenant_id=int(tenant.id),
user_id=int(current_user.id),
role="OWNER",
status=ACTIVE_MEMBERSHIP_STATUS,
is_personal_owner=False,
)
db.add(membership)
current_user.tenant_id = int(tenant.id)
current_user.role = "OWNER"
await db.commit()
await db.refresh(tenant)
return _workspace_payload(tenant, membership)
async def switch_workspace(db: AsyncSession, *, current_user: User, tenant_id: int) -> dict[str, Any]:
row = (
await db.execute(
select(Tenant, TenantMembership)
.join(TenantMembership, TenantMembership.tenant_id == Tenant.id)
.where(
and_(
Tenant.id == int(tenant_id),
Tenant.status == "active",
TenantMembership.user_id == int(current_user.id),
TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS,
)
)
)
).one_or_none()
if not row:
raise HTTPException(status_code=403, detail="你不是该空间成员")
tenant, membership = row
current_user.tenant_id = int(tenant.id)
current_user.role = str(membership.role or current_user.role)
await db.commit()
await db.refresh(current_user)
return _workspace_payload(tenant, membership)
async def create_invitation(
db: AsyncSession,
*,
current_user: User,
email: str | None = None,
phone_country_code: str | None = None,
phone_number: str | None = None,
role: str,
project_ids: list[int] | None = None,
) -> dict[str, Any]:
workspace_role = str(current_user.role or "").upper()
invite_role = _normalize_role(role)
if invite_role not in TEAM_INVITABLE_ROLES:
raise HTTPException(status_code=400, detail="邀请成员角色只能是 REVIEWER、BUILDER 或 OPERATOR")
if not has_any_role(workspace_role, ["OWNER", "REVIEWER"]):
raise HTTPException(status_code=403, detail="只有空间负责人可以邀请成员")
if invite_role == "REVIEWER" and not has_any_role(workspace_role, ["OWNER"]):
raise HTTPException(status_code=403, detail="只有 OWNER 可以邀请 REVIEWER")
from .phone_auth_service import mask_phone, normalize_phone
normalized_phone = normalize_phone(phone_country_code, phone_number)
normalized_email = _normalize_email(email or _phone_invitation_email(normalized_phone["phone_e164"]))
tenant_id = int(current_user.tenant_id)
project_id_list = sorted({int(pid) for pid in (project_ids or []) if int(pid) > 0})
if project_id_list:
existing_projects = (
await db.execute(
select(Project.id).where(and_(Project.tenant_id == tenant_id, Project.id.in_(project_id_list)))
)
).scalars().all()
if len(set(existing_projects)) != len(project_id_list):
raise HTTPException(status_code=400, detail="项目不属于当前空间")
existing_invitation = (
await db.execute(
select(TenantInvitation).where(
and_(
TenantInvitation.tenant_id == tenant_id,
TenantInvitation.phone_e164 == normalized_phone["phone_e164"],
TenantInvitation.status == "pending",
)
)
)
).scalar_one_or_none()
token = secrets.token_urlsafe(INVITATION_TOKEN_BYTES)
if existing_invitation:
invitation = existing_invitation
invitation.email = normalized_email
invitation.phone_country_code = normalized_phone["phone_country_code"]
invitation.phone_number = normalized_phone["phone_number"]
invitation.phone_e164 = normalized_phone["phone_e164"]
invitation.role = invite_role
invitation.project_ids = project_id_list
invitation.token_hash = hash_invitation_token(token)
invitation.expires_at = now_cn_naive() + timedelta(days=INVITATION_EXPIRE_DAYS)
invitation.invited_by_user_id = int(current_user.id)
else:
invitation = TenantInvitation(
tenant_id=tenant_id,
email=normalized_email,
phone_country_code=normalized_phone["phone_country_code"],
phone_number=normalized_phone["phone_number"],
phone_e164=normalized_phone["phone_e164"],
role=invite_role,
project_ids=project_id_list,
token_hash=hash_invitation_token(token),
status="pending",
expires_at=now_cn_naive() + timedelta(days=INVITATION_EXPIRE_DAYS),
invited_by_user_id=int(current_user.id),
)
db.add(invitation)
try:
await db.commit()
await db.refresh(invitation)
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="邀请链接生成冲突,请重试") from exc
return {
"id": int(invitation.id),
"email": invitation.email,
"phone_country_code": invitation.phone_country_code,
"phone_number": invitation.phone_number,
"phone_e164": invitation.phone_e164,
"masked_phone": mask_phone(invitation.phone_e164 or ""),
"role": invitation.role,
"project_ids": invitation.project_ids or [],
"status": invitation.status,
"expires_at": invitation.expires_at.isoformat(),
"token": token,
}
async def _apply_invitation_to_user(
db: AsyncSession,
*,
invitation: TenantInvitation,
user: User,
switch_to_workspace: bool,
) -> dict[str, Any]:
membership = (
await db.execute(
select(TenantMembership).where(
and_(
TenantMembership.tenant_id == int(invitation.tenant_id),
TenantMembership.user_id == int(user.id),
)
)
)
).scalar_one_or_none()
if not membership:
membership = TenantMembership(
tenant_id=int(invitation.tenant_id),
user_id=int(user.id),
role=invitation.role,
status=ACTIVE_MEMBERSHIP_STATUS,
is_personal_owner=False,
)
db.add(membership)
else:
membership.role = invitation.role
membership.status = ACTIVE_MEMBERSHIP_STATUS
for project_id in sorted({int(pid) for pid in (invitation.project_ids or [])}):
exists = (
await db.execute(
select(UserProject.id).where(
and_(
UserProject.tenant_id == int(invitation.tenant_id),
UserProject.user_id == int(user.id),
UserProject.project_id == project_id,
)
)
)
).scalar_one_or_none()
if exists is None:
db.add(UserProject(tenant_id=int(invitation.tenant_id), user_id=int(user.id), project_id=project_id))
invitation.status = "accepted"
invitation.accepted_by_user_id = int(user.id)
if switch_to_workspace:
user.tenant_id = int(invitation.tenant_id)
user.role = invitation.role
tenant = (await db.execute(select(Tenant).where(Tenant.id == int(invitation.tenant_id)))).scalar_one_or_none()
return {
"id": int(invitation.id),
"workspace_id": int(invitation.tenant_id),
"workspace_name": tenant.name if tenant else None,
"role": invitation.role,
"project_ids": invitation.project_ids or [],
"status": invitation.status,
}
async def accept_invitation_by_verified_phone(
db: AsyncSession,
*,
token: str,
user: User,
phone_e164: str,
switch_to_workspace: bool = True,
) -> dict[str, Any]:
invitation = (
await db.execute(
select(TenantInvitation).where(TenantInvitation.token_hash == hash_invitation_token(token))
)
).scalar_one_or_none()
if not invitation or invitation.status != "pending":
raise HTTPException(status_code=404, detail="邀请不存在或已失效")
if invitation.expires_at < now_cn_naive():
invitation.status = "expired"
await db.flush()
raise HTTPException(status_code=410, detail="邀请已过期")
if str(invitation.phone_e164 or "") != str(phone_e164 or ""):
raise HTTPException(status_code=403, detail="当前验证手机号与邀请手机号不一致")
return await _apply_invitation_to_user(db, invitation=invitation, user=user, switch_to_workspace=switch_to_workspace)
async def accept_pending_invitations_for_phone(
db: AsyncSession,
*,
user: User,
phone_e164: str,
auto_accept: bool = False,
) -> list[dict[str, Any]]:
rows = (
await db.execute(
select(TenantInvitation)
.where(
and_(
TenantInvitation.phone_e164 == phone_e164,
TenantInvitation.status == "pending",
TenantInvitation.expires_at >= now_cn_naive(),
)
)
.order_by(TenantInvitation.created_at.asc(), TenantInvitation.id.asc())
)
).scalars().all()
if not auto_accept:
payload: list[dict[str, Any]] = []
for invitation in rows:
tenant = (await db.execute(select(Tenant).where(Tenant.id == int(invitation.tenant_id)))).scalar_one_or_none()
payload.append(
{
"id": int(invitation.id),
"workspace_id": int(invitation.tenant_id),
"workspace_name": tenant.name if tenant else None,
"role": invitation.role,
"project_ids": invitation.project_ids or [],
"expires_at": invitation.expires_at.isoformat(),
}
)
return payload
accepted: list[dict[str, Any]] = []
for invitation in rows:
accepted.append(await _apply_invitation_to_user(db, invitation=invitation, user=user, switch_to_workspace=False))
return accepted
async def accept_invitation(
db: AsyncSession,
*,
token: str,
current_user: User | None = None,
new_user: Any | None = None,
) -> dict[str, Any]:
invitation = (
await db.execute(
select(TenantInvitation).where(TenantInvitation.token_hash == hash_invitation_token(token))
)
).scalar_one_or_none()
if not invitation or invitation.status != "pending":
raise HTTPException(status_code=404, detail="邀请不存在或已失效")
if invitation.expires_at < now_cn_naive():
invitation.status = "expired"
await db.commit()
raise HTTPException(status_code=410, detail="邀请已过期")
user = current_user
if user is None:
if new_user is None:
raise HTTPException(status_code=401, detail="接受邀请前需要登录或创建账号")
if _normalize_email(new_user.email or "") != _normalize_email(invitation.email):
raise HTTPException(status_code=400, detail="注册邮箱必须与邀请邮箱一致")
user = await register_user_with_personal_workspace(
db,
username=new_user.username,
password=new_user.password,
name=new_user.name,
email=new_user.email or invitation.email,
)
else:
if invitation.phone_e164:
if str(getattr(user, "phone_e164", "") or "") != str(invitation.phone_e164):
raise HTTPException(status_code=403, detail="当前账号手机号与邀请手机号不一致")
elif _normalize_email(user.email) != _normalize_email(invitation.email):
raise HTTPException(status_code=403, detail="当前账号邮箱与邀请邮箱不一致")
await ensure_personal_workspace(db, user=user, commit=False)
await _apply_invitation_to_user(db, invitation=invitation, user=user, switch_to_workspace=True)
await db.commit()
await db.refresh(user)
return {"user": user.to_dict(), "workspace_id": int(invitation.tenant_id), "role": invitation.role}
async def leave_workspace(db: AsyncSession, *, current_user: User, tenant_id: int) -> dict[str, Any]:
membership = (
await db.execute(
select(TenantMembership).where(
and_(
TenantMembership.tenant_id == int(tenant_id),
TenantMembership.user_id == int(current_user.id),
TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS,
)
)
)
).scalar_one_or_none()
if not membership:
raise HTTPException(status_code=404, detail="你不是该空间成员")
tenant = (await db.execute(select(Tenant).where(Tenant.id == int(tenant_id)))).scalar_one_or_none()
if tenant and tenant.workspace_type == PERSONAL_WORKSPACE_TYPE:
raise HTTPException(status_code=400, detail="个人空间不能退出")
if membership.role == "OWNER":
owner_count = int(
(
await db.execute(
select(func.count(TenantMembership.id)).where(
and_(
TenantMembership.tenant_id == int(tenant_id),
TenantMembership.status == ACTIVE_MEMBERSHIP_STATUS,
TenantMembership.role == "OWNER",
)
)
)
).scalar()
or 0
)
if owner_count <= 1:
raise HTTPException(status_code=409, detail="退出前需要先转让 OWNER")
membership.status = "removed"
await db.execute(
UserProject.__table__.delete().where(
and_(
UserProject.tenant_id == int(tenant_id),
UserProject.user_id == int(current_user.id),
)
)
)
if int(current_user.tenant_id) == int(tenant_id):
personal = await ensure_personal_workspace(db, user=current_user, commit=False)
current_user.tenant_id = int(personal.id)
current_user.role = "OWNER"
await db.commit()
return {"left_workspace_id": int(tenant_id), "active_workspace_id": int(current_user.tenant_id)}