Files
DadaoTask/backend/app/middleware.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

145 lines
5.2 KiB
Python

"""中间件:请求上下文(request_id / 访问日志)与幂等键。
顺序:RequestContext → Idempotency → 路由。
request_id 必须最先建立,否则后面所有日志都没有 trace。
"""
from __future__ import annotations
import json
import time
import uuid
from typing import Any
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse
from .config import get_settings
from .errors import ErrorCode
from .logging_setup import get_logger, request_id_ctx
from .security import decode_token
from .stores import dumps, get_store, idempotency_key
settings = get_settings()
logger = get_logger("app.access")
_IDEMPOTENT_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
_MAX_CACHE_BYTES = 256 * 1024
class RequestContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
rid = request.headers.get("X-Request-Id") or uuid.uuid4().hex
token = request_id_ctx.set(rid)
started = time.perf_counter()
status = 500
try:
response = await call_next(request)
status = response.status_code
response.headers["X-Request-Id"] = rid
return response
finally:
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
logger.info(
"%s %s %s %sms",
request.method,
request.url.path,
status,
elapsed_ms,
extra={
"extra_fields": {
"method": request.method,
"path": request.url.path,
"status": status,
"elapsed_ms": elapsed_ms,
"client": request.client.host if request.client else "-",
}
},
)
request_id_ctx.reset(token)
class IdempotencyMiddleware(BaseHTTPMiddleware):
"""带 Idempotency-Key 的写请求,重复提交返回第一次的结果。
限制(M0 已知):同 key 并发请求存在竞态窗口,靠唯一键约束兜底留到业务层;
只缓存 JSON 且 < 256KB 的响应;5xx 不缓存。
"""
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if not settings.idempotency_enabled or request.method not in _IDEMPOTENT_METHODS:
return await call_next(request)
raw_key = request.headers.get("Idempotency-Key")
if not raw_key:
return await call_next(request)
subject = self._subject(request)
if subject is None:
# 未认证请求不做幂等(登录接口另有验证码限流)
return await call_next(request)
store = get_store()
cache_key = idempotency_key(subject, raw_key)
try:
cached = await store.get(cache_key)
except Exception: # noqa: BLE001 - 存储不可用不能拖垮业务
logger.warning("幂等存储不可用,降级为直接执行", exc_info=True)
return await call_next(request)
if cached:
payload = json.loads(cached)
return JSONResponse(
content=payload["body"],
status_code=payload["status"],
headers={"X-Idempotent-Replay": "1"},
)
response = await call_next(request)
# 只缓存成功响应:4xx 往往是"这次请求本身有问题"(校验失败、权限不足),
# 缓存住会让用户改对参数后仍然拿到同一个错误。
if response.status_code >= 400:
return response
body = b""
async for chunk in response.body_iterator: # type: ignore[attr-defined]
body += chunk.encode() if isinstance(chunk, str) else chunk
if len(body) <= _MAX_CACHE_BYTES and "application/json" in response.headers.get(
"content-type", ""
):
try:
await store.set(
cache_key,
dumps({"status": response.status_code, "body": json.loads(body)}),
settings.idempotency_ttl_seconds,
)
except Exception: # noqa: BLE001
logger.warning("幂等结果写入失败", exc_info=True)
return Response(
content=body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.headers.get("content-type"),
)
@staticmethod
def _subject(request: Request) -> str | None:
header = request.headers.get("Authorization", "")
if not header.lower().startswith("bearer "):
return None
try:
return str(decode_token(header.split(" ", 1)[1].strip(), "access"))
except Exception: # noqa: BLE001 - 令牌问题交给后面的依赖统一报错
return None
def error_body(request_id: str, code: ErrorCode, message: str, details: Any = None) -> dict:
return {
"code": int(code),
"message": message,
"data": details,
"request_id": request_id,
}