"""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)}