- 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
143 lines
5.0 KiB
Python
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,
|
|
}
|