CI / backend (push) Canceled after 0s
跑通迁移与测试后修掉的真问题: - 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 全通。
238 lines
7.9 KiB
Python
238 lines
7.9 KiB
Python
"""工作区 / 成员 / 邀请。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)
|