Files
DadaoTask/backend/app/api/workspaces.py
T
Steven 2b493bd9f8
CI / backend (push) Canceled after 0s
fix(m0): 迁移可执行 + 测试全绿(33 条)
跑通迁移与测试后修掉的真问题:

- alembic 0001:asyncpg 走预备语句,一次 execute 只能一条 SQL。
  CREATE FUNCTION / CREATE POLICY / GRANT 全部拆成单语句逐条执行,
  否则报 "cannot insert multiple commands into a prepared statement"。
- 创建工作区:ORM flush 只按 relationship 排序,裸外键不算数,
  member 可能先于 workspace 落地撞 FK。改为先 flush workspace 再插 member。
- datetime.UTC 是 3.11+ 才有的,本机 3.10 直接 ImportError。改用 timezone.utc。
- 幂等中间件:原来会缓存 4xx。校验失败被缓存后,用户改对参数仍拿到同一个 422。
  改为只缓存 <400。
- 发码节流窗口改为可配置 SMS_THROTTLE_SECONDS(测试置 0),
  节流用例自己显式打开,避免"整库关掉节流"导致节流从未被验证。
- 租户隔离用例:pytest.raises(Exception) 改为精确的 ProgrammingError,
  否则任何异常都会让"应用角色不能建表"这条断言假通过。

验收:pytest 33 passed;ruff check / format 通过;真机跑 uvicorn,
/health、/version、登录、建工作区、列表、/auth/me 全通。
2026-10-28 03:22:59 +08:00

238 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""工作区 / 成员 / 邀请。M0 的验收核心:两个租户互相看不见。"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
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 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)
# 先刷 workspace 再插 member:ORM 的 flush 顺序只按 relationship 排序,
# 没有 relationship 的裸外键不会被考虑,member 可能先于 workspace 落地而撞 FK。
await session.flush()
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(timezone.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(timezone.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(timezone.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)