"""工作区 / 成员 / 邀请。M0 的验收核心:两个租户互相看不见。""" from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Query, Request from pydantic import BaseModel, Field from sqlalchemy import select, tuple_ from ..audit import record as audit from ..db import bind_tenant from ..deps import AdminDep, CurrentUser, MemberDep, SessionDep, TenantDep from ..errors import AppError, ErrorCode from ..models import AppUser, Invitation, Member, Workspace from ..schemas import Page, decode_cursor, encode_cursor, ok from ..security import invite_code_matches, new_invite_code router = APIRouter(prefix="/workspaces", tags=["工作区"]) ROLE_CHOICES = ("admin", "member", "guest") class CreateWorkspaceIn(BaseModel): name: str = Field(min_length=1, max_length=64) slug: str | None = Field(default=None, max_length=64) class CreateInvitationIn(BaseModel): role: str = Field(default="member") expires_hours: int = Field(default=72, ge=1, le=24 * 30) class JoinIn(BaseModel): code: str = Field(min_length=4, max_length=32) def _ws_out(ws: Workspace, role: str) -> dict: return { "id": str(ws.id), "name": ws.name, "slug": ws.slug, "plan": ws.plan, "role": role, "created_at": ws.created_at, } @router.post("", summary="创建工作区(创建者成为 owner)") async def create_workspace( body: CreateWorkspaceIn, user: CurrentUser, session: SessionDep, request: Request ) -> dict: ws_id = uuid.uuid4() slug = body.slug or f"ws-{ws_id.hex[:8]}" exists = ( await session.execute(select(Workspace.id).where(Workspace.slug == slug)) ).scalar_one_or_none() if exists is not None: raise AppError(ErrorCode.CONFLICT, "该 slug 已被占用") # 先绑租户再插数据:workspace 的 RLS 策略是 id = app.ws_id, # 插入时 Postgres 用 WITH CHECK 校验,不绑就会直接被拒。 await bind_tenant(session, ws_id, user.id) ws = Workspace(id=ws_id, name=body.name, slug=slug, owner_id=user.id, plan="free") session.add(ws) session.add( Member(workspace_id=ws_id, user_id=user.id, role="owner", status="active") ) await session.flush() await audit( session, workspace_id=ws_id, actor_user_id=user.id, action="workspace.create", target_type="workspace", target_id=str(ws_id), payload={"name": body.name}, ip=request.client.host if request.client else "", ) return ok(_ws_out(ws, "owner")) @router.get("", summary="我的工作区列表") async def list_workspaces(user: CurrentUser, session: SessionDep) -> dict: rows = ( await session.execute( select(Workspace, Member.role) .join(Member, Member.workspace_id == Workspace.id) .where(Member.user_id == user.id, Member.status == "active") .order_by(Workspace.created_at.desc()) ) ).all() return ok({"items": [_ws_out(ws, role) for ws, role in rows]}) @router.get("/{ws_id}", summary="工作区详情") async def get_workspace(ctx: MemberDep, session: SessionDep) -> dict: ws = await session.get(Workspace, ctx.ws_id) if ws is None: raise AppError(ErrorCode.WORKSPACE_NOT_FOUND, "工作区不存在") return ok(_ws_out(ws, ctx.role)) @router.get("/{ws_id}/members", summary="成员列表(游标分页)") async def list_members( ctx: MemberDep, session: SessionDep, limit: int = Query(default=20, ge=1, le=100), cursor: str | None = Query(default=None), ) -> dict: stmt = ( select(Member, AppUser) .join(AppUser, AppUser.id == Member.user_id) .where(Member.workspace_id == ctx.ws_id) ) if cursor: cur = decode_cursor(cursor) stmt = stmt.where(tuple_(Member.created_at, Member.id) < (cur.created_at, cur.id)) stmt = stmt.order_by(Member.created_at.desc(), Member.id.desc()).limit(limit + 1) rows = (await session.execute(stmt)).all() has_more = len(rows) > limit rows = rows[:limit] items = [ { "id": str(m.id), "user_id": str(m.user_id), "role": m.role, "status": m.status, "nickname": u.nickname, "phone": f"{u.phone[:3]}****{u.phone[-4:]}", "joined_at": m.joined_at, } for m, u in rows ] next_cursor = ( encode_cursor(rows[-1][0].created_at, rows[-1][0].id) if has_more and rows else None ) return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more}) @router.post("/{ws_id}/invitations", summary="生成邀请码(admin 及以上)") async def create_invitation( body: CreateInvitationIn, ctx: AdminDep, session: SessionDep, request: Request ) -> dict: if body.role not in ROLE_CHOICES: raise AppError(ErrorCode.BAD_REQUEST, f"角色只能是 {', '.join(ROLE_CHOICES)}") if body.role == "admin" and ctx.role != "owner": raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有 owner 能邀请 admin") invitation = Invitation( workspace_id=ctx.ws_id, code=new_invite_code(), role=body.role, invited_by=ctx.user.id, expires_at=datetime.now(UTC) + timedelta(hours=body.expires_hours), ) session.add(invitation) await session.flush() await audit( session, workspace_id=ctx.ws_id, actor_user_id=ctx.user.id, action="invitation.create", target_type="invitation", target_id=str(invitation.id), payload={"role": body.role}, ip=request.client.host if request.client else "", ) return ok( { "id": str(invitation.id), "code": invitation.code, "role": invitation.role, "expires_at": invitation.expires_at, } ) @router.post("/{ws_id}/join", summary="凭邀请码加入工作区") async def join_workspace( body: JoinIn, ctx: TenantDep, session: SessionDep, request: Request ) -> dict: invitation = ( await session.execute( select(Invitation).where( Invitation.workspace_id == ctx.ws_id, Invitation.accepted_at.is_(None), ) ) ).scalars() matched = next( (inv for inv in invitation if invite_code_matches(body.code, inv.code)), None ) if matched is None: raise AppError(ErrorCode.INVITATION_INVALID, "邀请码无效或已使用") if matched.expires_at < datetime.now(UTC): raise AppError(ErrorCode.INVITATION_INVALID, "邀请码已过期") existing = ( await session.execute( select(Member).where( Member.workspace_id == ctx.ws_id, Member.user_id == ctx.user.id ) ) ).scalar_one_or_none() if existing is not None and existing.status == "active": raise AppError(ErrorCode.ALREADY_A_MEMBER, "你已经是该工作区成员") if existing is None: session.add( Member( workspace_id=ctx.ws_id, user_id=ctx.user.id, role=matched.role, status="active", ) ) else: existing.role = matched.role existing.status = "active" matched.accepted_at = datetime.now(UTC) matched.accepted_by = ctx.user.id await session.flush() await audit( session, workspace_id=ctx.ws_id, actor_user_id=ctx.user.id, action="member.join", target_type="member", target_id=str(ctx.user.id), payload={"role": matched.role}, ip=request.client.host if request.client else "", ) ws = await session.get(Workspace, ctx.ws_id) return ok(_ws_out(ws, matched.role) if ws else None)