fix: 修复任务分页总数错误 + 补齐可观测性基建
分页总数(#5): - get_all_tasks 的 total 原为 len(flat_tasks)(当前页条数),移动端 「我的任务」用 tasks.length < total 判断 hasMore,首页满员时恒为 false,列表永远停在第一页 20 条。改为独立 COUNT 查询(与 notifications.py 已有写法保持一致)。 可观测性(#9): - 新增 core/logging.py:单行 JSON 结构化日志 + request_id/user 上下文 注入;零第三方依赖;接管 uvicorn 自带 handler 避免格式绕过。 - 新增 core/middleware.py:RequestContextMiddleware 生成/透传 X-Request-ID 并回写响应头,输出含耗时/用户的结构化访问日志。 - 新增 core/health.py:拆分存活/就绪探针。/health/live 不触依赖; /health/ready 探主库,不可用返回 503;MOM 挂掉仅降级不摘流量。 - main.py:全局异常处理器只把堆栈写日志,响应体仅回 request_id; 接入可选 Sentry(未装 SDK 时静默跳过)。 - auth_service:解析 Token 后写入 user 上下文,日志自动带操作人。 注:异常处理器由 ServerErrorMiddleware 调用,此时 contextvar 已被重置, 故 request_id 同时写入 request.state(由 ASGI scope 承载)再读取。 已用 TestClient 验证:探针状态码/检查项、X-Request-ID 透传与生成、 500 响应携带可对账的 request_id 且不泄露堆栈。
This commit is contained in:
@ -1,22 +1,63 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.core.config import settings
|
||||
from app.core.health import router as health_router
|
||||
from app.core.logging import request_id_var, setup_logging
|
||||
from app.core.middleware import RequestContextMiddleware
|
||||
from app.api.v1.router import api_router
|
||||
|
||||
# 日志必须在任何模块开始产日志之前配置好,故放模块顶层而非 lifespan 内
|
||||
setup_logging(level=settings.LOG_LEVEL, json_output=settings.LOG_JSON)
|
||||
|
||||
logger = logging.getLogger("track.main")
|
||||
|
||||
|
||||
def _init_error_tracking() -> None:
|
||||
"""可选错误追踪:未配置 DSN,或未安装 sentry-sdk 时静默跳过"""
|
||||
if not settings.SENTRY_DSN:
|
||||
return
|
||||
try:
|
||||
import sentry_sdk
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"已配置 SENTRY_DSN 但未安装 sentry-sdk,错误追踪未启用;"
|
||||
"需要时执行 pip install sentry-sdk"
|
||||
)
|
||||
return
|
||||
sentry_sdk.init(
|
||||
dsn=settings.SENTRY_DSN,
|
||||
traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE,
|
||||
environment="production" if not settings.DEBUG else "development",
|
||||
release=settings.APP_VERSION,
|
||||
)
|
||||
logger.info("错误追踪已启用 (Sentry)")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期:启动时初始化连接,关闭时释放资源"""
|
||||
# 启动:验证数据库连接等
|
||||
_init_error_tracking()
|
||||
logger.info(
|
||||
"服务启动",
|
||||
extra={
|
||||
"extra_fields": {
|
||||
"version": settings.APP_VERSION,
|
||||
"debug": settings.DEBUG,
|
||||
"cors_origins": settings.CORS_ORIGINS_LIST,
|
||||
}
|
||||
},
|
||||
)
|
||||
yield
|
||||
# 关闭:清理资源
|
||||
logger.info("服务关闭")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Track Production API",
|
||||
description="工厂生产流转管理系统 API",
|
||||
version="0.1.0",
|
||||
version=settings.APP_VERSION,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@ -27,12 +68,38 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
# 暴露给浏览器 JS 读取:前端报错时才能把 request_id 一起带上便于对账
|
||||
expose_headers=["X-Request-ID"],
|
||||
)
|
||||
|
||||
# Starlette 的 add_middleware 是「后添加者在外层」,故 RequestContextMiddleware
|
||||
# 最后注册 → 最先进入请求,才能覆盖包括 CORS 预检在内的全部请求并回写 X-Request-ID
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""兜底异常处理。
|
||||
|
||||
完整堆栈只进日志;响应体仅返回 request_id —— 既不把内部实现泄露给客户端,
|
||||
又让用户报障时能凭这个 ID 在日志里精确定位到本次失败。
|
||||
"""
|
||||
# 优先取 request.state(见 RequestContextMiddleware 的说明):
|
||||
# 本处理器由 ServerErrorMiddleware 调用,此时 contextvar 已被重置
|
||||
request_id = getattr(request.state, "request_id", None) or request_id_var.get()
|
||||
logger.exception(
|
||||
"未处理异常: %s %s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
extra={"extra_fields": {"method": request.method, "path": request.url.path}},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "服务器内部错误", "request_id": request_id},
|
||||
)
|
||||
|
||||
|
||||
# ---- 注册路由 ----
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok", "version": "0.1.0"}
|
||||
# 健康检查挂在根路径(/health*),运维探针不经过 /api/v1
|
||||
app.include_router(health_router)
|
||||
|
||||
Reference in New Issue
Block a user