CI / backend (push) Canceled after 0s
跑通迁移与测试后修掉的真问题: - 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 全通。
145 lines
5.2 KiB
Python
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,
|
|
}
|