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:
openhands
2026-09-21 01:57:20 +00:00
parent 42c883fe7d
commit 4454047ce3
8 changed files with 389 additions and 18 deletions

View File

@ -16,6 +16,17 @@ class Settings(BaseSettings):
# ---- 调试 ----
DEBUG: bool = True
# ---- 应用元信息 ----
APP_VERSION: str = "1.0.0"
# ---- 日志 ----
LOG_LEVEL: str = "INFO"
LOG_JSON: bool = True # 生产保持 True便于采集本地调试可设 False 换可读格式
# ---- 错误追踪(可选,不装 sentry-sdk 则自动跳过)----
SENTRY_DSN: str | None = None
SENTRY_TRACES_SAMPLE_RATE: float = 0.0
# ---- CORS 跨域白名单JSON 数组字符串,直接从 .env 的 CORS_ORIGINS 读取) ----
CORS_ORIGINS: str = '["http://localhost:1420", "tauri://localhost"]'

View File

@ -0,0 +1,93 @@
"""健康检查 — 存活探针与就绪探针分离
为什么必须拆开:
- 存活探针liveness只回答「进程还活着吗」绝不能探测外部依赖。
否则数据库抖一下,编排系统会判定进程已死并反复重启容器,
把一次依赖故障放大成全站雪崩。
- 就绪探针readiness回答「现在能对外服务吗」。依赖不可用时返回 503
由负载均衡把该实例摘掉,依赖恢复后自动回来。
"""
from __future__ import annotations
import logging
import anyio
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.core.mom_database import mom_engine
logger = logging.getLogger("track.health")
router = APIRouter(tags=["健康检查"])
async def _probe_primary_db() -> bool:
"""主库探活 — 业务强依赖,失败即不就绪"""
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
return True
except Exception:
logger.exception("主库探活失败")
return False
def _probe_mom_db_sync() -> bool:
try:
with mom_engine.connect() as conn:
conn.execute(text("SELECT 1"))
return True
except Exception:
logger.exception("MOM 库探活失败")
return False
async def _probe_mom_db() -> bool:
# MOM 用的是同步引擎,放线程池执行,避免阻塞事件循环
return await anyio.to_thread.run_sync(_probe_mom_db_sync)
async def _collect() -> tuple[bool, dict[str, str]]:
primary_ok = await _probe_primary_db()
mom_ok = await _probe_mom_db()
checks = {
"database": "ok" if primary_ok else "fail",
# MOM 是外部只读依赖:挂掉时登录/选料降级,但扫码、流转、看板仍可用。
# 因此只标记 degraded、不摘流量 —— 否则 MOM 一抖就让在产车间全线停摆。
"mom_database": "ok" if mom_ok else "degraded",
}
return primary_ok, checks
@router.get("/health/live", include_in_schema=False)
async def liveness() -> dict:
"""存活探针:不触碰任何依赖,恒定快速返回"""
return {"status": "ok"}
@router.get("/health/ready", include_in_schema=False)
async def readiness() -> JSONResponse:
"""就绪探针:主库不可用时返回 503让负载均衡摘流量"""
ready, checks = await _collect()
return JSONResponse(
{"status": "ready" if ready else "not_ready", "checks": checks},
status_code=200 if ready else 503,
)
@router.get("/health", include_in_schema=False)
async def health() -> JSONResponse:
"""兼容旧监控脚本:语义等同就绪探针,并附带版本号"""
ready, checks = await _collect()
return JSONResponse(
{
"status": "ok" if ready else "unavailable",
"version": settings.APP_VERSION,
"checks": checks,
},
status_code=200 if ready else 503,
)

View File

@ -0,0 +1,89 @@
"""结构化日志 — 单行 JSON 输出 + 请求上下文注入
设计要点:
1. 零第三方依赖,只用 stdliblogging + json + contextvars
2. 业务代码通过 `extra={"extra_fields": {...}}` 附加结构化字段,
不要把可检索的字段拼进 msg 字符串 —— 拼进去就只能靠正则捞了。
3. request_id / user 走 contextvar。contextvar 在 asyncio 下按任务隔离,
并发请求之间不会串号;由 RequestContextMiddleware 与 get_current_user 写入。
"""
from __future__ import annotations
import json
import logging
import sys
from contextvars import ContextVar
from datetime import datetime, timezone
# 请求级上下文
request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
user_var: ContextVar[str | None] = ContextVar("user", default=None)
class _ContextFilter(logging.Filter):
"""把 contextvar 注入每条 record使 JSON 自带 request_id / user"""
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id_var.get()
record.user = user_var.get()
return True
class JsonFormatter(logging.Formatter):
"""单行 JSON — 便于 Loki / ELK / CloudWatch 直接解析,无需正则"""
def format(self, record: logging.LogRecord) -> str:
payload: dict = {
"ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if getattr(record, "request_id", None):
payload["request_id"] = record.request_id
if getattr(record, "user", None):
payload["user"] = record.user
payload.update(getattr(record, "extra_fields", None) or {})
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, default=str)
class TextFormatter(logging.Formatter):
"""本地开发可读格式LOG_JSON=false 时启用)"""
def format(self, record: logging.LogRecord) -> str:
line = (
f"{self.formatTime(record, '%H:%M:%S')} "
f"{record.levelname:<5} {record.name} - {record.getMessage()}"
)
extras = getattr(record, "extra_fields", None)
if extras:
line += " | " + " ".join(f"{k}={v}" for k, v in extras.items())
if record.exc_info:
line += "\n" + self.formatException(record.exc_info)
return line
def setup_logging(level: str = "INFO", json_output: bool = True) -> None:
"""配置根 logger。必须在应用启动前调用一次。"""
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter() if json_output else TextFormatter())
handler.addFilter(_ContextFilter())
root = logging.getLogger()
# 清空既有 handleruvicorn --reload / 多 worker 下模块可能被重复导入,
# 不清会看到每条日志打印 N 遍
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level.upper())
# uvicorn 自带 handler 会绕过上面的 formatter必须清掉并让它向根传播
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
lg = logging.getLogger(name)
lg.handlers.clear()
lg.propagate = True
# 访问日志统一由 RequestContextMiddleware 输出(含耗时 / 用户 / request_id
# 故关闭 uvicorn 自带的访问日志,避免重复
logging.getLogger("uvicorn.access").disabled = True

View File

@ -0,0 +1,83 @@
"""请求上下文中间件 — request_id 生成/透传 + 结构化访问日志"""
from __future__ import annotations
import logging
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from app.core.logging import request_id_var, user_var
access_log = logging.getLogger("track.access")
# 探针被高频轮询,降级为 DEBUG 避免把有价值的信息淹掉
_QUIET_PATHS = frozenset({"/health", "/health/live", "/health/ready"})
class RequestContextMiddleware(BaseHTTPMiddleware):
"""为每个请求建立可追踪上下文。
- request_id优先沿用上游网关传来的 X-Request-ID实现全链路追踪
没有就生成一个。响应头回写该 ID前端报错时可直接带上
运维拿 ID 就能在日志里精确定位到这一次请求。
- 访问日志method / path / status / duration_ms / client / user。
"""
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex
# 同时写入 request.state它由 ASGI scope 承载,作用域比 contextvar 更长。
# FastAPI 把 Exception 处理器交给 ServerErrorMiddleware位于本中间件外层
# 异常传播到那里时 contextvar 已在 finally 中被重置,只有 state 还留着 ID。
request.state.request_id = request_id
rid_token = request_id_var.set(request_id)
user_token = user_var.set(None)
started = time.perf_counter()
logged = False
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
response.headers["X-Request-ID"] = request_id
self._log_access(request, status_code, started)
logged = True
return response
finally:
# 异常路径也要留下访问记录,否则接口 500 时日志里反而没有痕迹
if not logged:
self._log_access(request, status_code, started)
request_id_var.reset(rid_token)
user_var.reset(user_token)
def _log_access(self, request: Request, status_code: int, started: float) -> None:
path = request.url.path
duration_ms = round((time.perf_counter() - started) * 1000, 1)
if status_code >= 500:
level = logging.ERROR
elif status_code >= 400:
level = logging.WARNING
elif path in _QUIET_PATHS:
level = logging.DEBUG
else:
level = logging.INFO
access_log.log(
level,
"%s %s -> %s (%.1fms)",
request.method,
path,
status_code,
duration_ms,
extra={
"extra_fields": {
"method": request.method,
"path": path,
"status": status_code,
"duration_ms": duration_ms,
"client": request.client.host if request.client else None,
"user": user_var.get(),
}
},
)