Files
DadaoTask/backend/app/middleware.py
T
Steven 0c53aa7062 feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档
- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件
- alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT
- tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行)
- web: React18+Vite 骨架(登录、工作区、邀请落地页)
- android: Compose 骨架(登录、工作区列表)
- 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
2026-10-28 02:38:53 +08:00

143 lines
5.0 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)
if response.status_code >= 500:
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,
}