跑通迁移与测试后修掉的真问题: - 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:
@@ -13,7 +13,7 @@ from ..config import get_settings
|
||||
from ..deps import CurrentUser, SessionDep
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import AppUser, Member, Workspace
|
||||
from ..schemas import Envelope, ok
|
||||
from ..schemas import ok
|
||||
from ..security import create_token_pair, decode_token
|
||||
from ..stores import get_store, sms_code_key, sms_throttle_key
|
||||
|
||||
@@ -43,9 +43,13 @@ class RefreshIn(BaseModel):
|
||||
@router.post("/sms/send", summary="发送登录验证码")
|
||||
async def send_code(body: SendCodeIn) -> dict:
|
||||
store = get_store()
|
||||
hits = await store.incr(sms_throttle_key(body.phone), 60)
|
||||
if hits > 1:
|
||||
raise AppError(ErrorCode.SMS_TOO_FREQUENT, "发送过于频繁,请 60 秒后重试")
|
||||
if settings.sms_throttle_seconds > 0:
|
||||
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
|
||||
if hits > 1:
|
||||
raise AppError(
|
||||
ErrorCode.SMS_TOO_FREQUENT,
|
||||
f"发送过于频繁,请 {settings.sms_throttle_seconds} 秒后重试",
|
||||
)
|
||||
|
||||
if settings.sms_provider == "dev":
|
||||
code = settings.sms_dev_code
|
||||
|
||||
@@ -20,7 +20,7 @@ class UserOut(BaseModel):
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
token_type: str = "bearer" # noqa: S105 - 不是密码,是 OAuth2 的固定字段值
|
||||
expires_in: int
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Literal
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
DEV_JWT_SECRET = "dev-only-change-me"
|
||||
DEV_JWT_SECRET = "dev-only-change-me" # noqa: S105 - 仅开发默认值,生产启动时会被校验拦下
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -27,9 +27,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# ── 数据库 ────────────────────────────────────────────
|
||||
# 应用角色:受 RLS 约束,非 owner、非 superuser
|
||||
database_url: str = (
|
||||
"postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
||||
)
|
||||
database_url: str = "postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
||||
# 迁移角色:owner,可绕过 RLS,仅 alembic / 运维脚本使用
|
||||
migration_database_url: str = (
|
||||
"postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
||||
@@ -46,6 +44,8 @@ class Settings(BaseSettings):
|
||||
access_token_minutes: int = 30
|
||||
refresh_token_days: int = 14
|
||||
sms_code_ttl_seconds: int = 300
|
||||
# 同一手机号两次发码的最小间隔;测试里设为 0 可关闭节流
|
||||
sms_throttle_seconds: int = 60
|
||||
sms_dev_code: str = "123456"
|
||||
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
|
||||
|
||||
|
||||
+14
-13
@@ -39,16 +39,13 @@ SessionFactory = async_sessionmaker(engine, expire_on_commit=False, autoflush=Fa
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
"""一个请求 = 一个事务。异常即整体回滚。"""
|
||||
async with SessionFactory() as session:
|
||||
async with session.begin():
|
||||
yield session
|
||||
async with SessionFactory() as session, session.begin():
|
||||
yield session
|
||||
|
||||
|
||||
async def bind_tenant(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID | None) -> None:
|
||||
"""把租户上下文写进当前事务。必须在任何业务查询之前调用。"""
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.ws_id', :ws, true)"), {"ws": str(ws_id)}
|
||||
)
|
||||
await session.execute(text("SELECT set_config('app.ws_id', :ws, true)"), {"ws": str(ws_id)})
|
||||
if user_id is not None:
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)}
|
||||
@@ -65,15 +62,19 @@ async def bind_user(session: AsyncSession, user_id: uuid.UUID) -> None:
|
||||
async def current_app_role(session: AsyncSession) -> dict[str, Any]:
|
||||
"""自检用:确认应用连接的不是特权角色。"""
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT current_user AS role, "
|
||||
"pg_has_role(current_user, 'pg_read_all_data', 'member') AS read_all, "
|
||||
"(SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS is_super, "
|
||||
"(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypass_rls"
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT current_user AS role, "
|
||||
"pg_has_role(current_user, 'pg_read_all_data', 'member') AS read_all, "
|
||||
"(SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS is_super, "
|
||||
"(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypass_rls"
|
||||
)
|
||||
)
|
||||
)
|
||||
).mappings().one()
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import json
|
||||
import logging
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
@@ -37,10 +37,14 @@ def _mask(value: Any, depth: int = 0) -> Any:
|
||||
return "<deep>"
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: (MASK if any(h in str(key).lower() for h in _SENSITIVE_HINTS) else _mask(val, depth + 1))
|
||||
key: (
|
||||
MASK
|
||||
if any(hint in str(key).lower() for hint in _SENSITIVE_HINTS)
|
||||
else _mask(val, depth + 1)
|
||||
)
|
||||
for key, val in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
if isinstance(value, list | tuple):
|
||||
return [_mask(item, depth + 1) for item in value]
|
||||
return value
|
||||
|
||||
@@ -48,7 +52,7 @@ def _mask(value: Any, depth: int = 0) -> Any:
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
|
||||
"ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
|
||||
+4
-2
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -28,7 +27,10 @@ logger = get_logger("app")
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings.validate_runtime()
|
||||
logger.info("服务启动", extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}})
|
||||
logger.info(
|
||||
"服务启动",
|
||||
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
|
||||
)
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -97,7 +97,9 @@ class IdempotencyMiddleware(BaseHTTPMiddleware):
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
if response.status_code >= 500:
|
||||
# 只缓存成功响应:4xx 往往是"这次请求本身有问题"(校验失败、权限不足),
|
||||
# 缓存住会让用户改对参数后仍然拿到同一个错误。
|
||||
if response.status_code >= 400:
|
||||
return response
|
||||
|
||||
body = b""
|
||||
|
||||
Reference in New Issue
Block a user