跑通迁移与测试后修掉的真问题: - 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 全通。
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -14,7 +14,7 @@ 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 ..schemas import decode_cursor, encode_cursor, ok
|
||||
from ..security import invite_code_matches, new_invite_code
|
||||
|
||||
router = APIRouter(prefix="/workspaces", tags=["工作区"])
|
||||
@@ -66,9 +66,10 @@ async def create_workspace(
|
||||
|
||||
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")
|
||||
)
|
||||
# 先刷 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,
|
||||
@@ -156,7 +157,7 @@ async def create_invitation(
|
||||
code=new_invite_code(),
|
||||
role=body.role,
|
||||
invited_by=ctx.user.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=body.expires_hours),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=body.expires_hours),
|
||||
)
|
||||
session.add(invitation)
|
||||
await session.flush()
|
||||
@@ -192,19 +193,15 @@ async def join_workspace(
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
matched = next(
|
||||
(inv for inv in invitation if invite_code_matches(body.code, inv.code)), None
|
||||
)
|
||||
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):
|
||||
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
|
||||
)
|
||||
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":
|
||||
@@ -223,7 +220,7 @@ async def join_workspace(
|
||||
existing.role = matched.role
|
||||
existing.status = "active"
|
||||
|
||||
matched.accepted_at = datetime.now(UTC)
|
||||
matched.accepted_at = datetime.now(timezone.utc)
|
||||
matched.accepted_by = ctx.user.id
|
||||
await session.flush()
|
||||
await audit(
|
||||
|
||||
Reference in New Issue
Block a user