"""FastAPI 应用实例:lifespan / 异常处理 / 中间件 / 路由注册 / 状态初始化。 由 server.py(薄入口)导入 app 并交给 uvicorn.run。 """ from __future__ import annotations import argparse import asyncio import logging import os import sys import time from contextlib import asynccontextmanager from typing import AsyncIterator from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from woc_bridge.config import _state, BridgeConfig from woc_bridge.db.coordinator import _resolve_db_state_tuple, _auto_extract_with_lock from woc_bridge.messaging import MessageStreamer from woc_bridge.models import BridgeError, ErrorResponse from woc_bridge.version import BRIDGE_VERSION from woc_bridge.db import DbReader from woc_bridge.messaging import SendQueue from woc_bridge.ui import QrCapture, XdotoolDriver # 路由 from woc_bridge.routes import status as routes_status from woc_bridge.routes import db as routes_db from woc_bridge.routes import messages as routes_messages from woc_bridge.routes import send as routes_send from woc_bridge.routes import login as routes_login from woc_bridge.routes import contacts as routes_contacts from woc_bridge.routes import moments as routes_moments from woc_bridge.routes import media as routes_media from woc_bridge.routes import diagnostic as routes_diagnostic from woc_bridge.routes import screenshot as routes_screenshot # 模块级 logger logger = logging.getLogger("woc-bridge") # 为 woc-bridge logger 配置输出到 stderr 的 handler if not logger.handlers: _handler = logging.StreamHandler(sys.stderr) _handler.setFormatter( logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") ) logger.addHandler(_handler) logger.setLevel(logging.INFO) logger.propagate = False # --------------------------------------------------------------------------- # lifespan # --------------------------------------------------------------------------- @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """应用生命周期。 兼容两种启动方式: 1. `python3 server.py`:__main__ 已 _init_state,lifespan 直接用 2. `uvicorn server:app`:__main__ 未执行,lifespan 内兜底初始化 启动 send_queue,yield,停止 send_queue。 """ # 兜底初始化:兼容 uvicorn server:app 启动方式 if _state.xdotool is None: _init_state_from_env() _state.start_time = time.monotonic() if _state.send_queue is not None: await _state.send_queue.start() if _state.message_streamer is not None: await _state.message_streamer.start() try: yield finally: if _state.message_streamer is not None: await _state.message_streamer.stop() if _state.send_queue is not None: await _state.send_queue.stop() def _init_state_from_env() -> None: """从环境变量初始化全局状态(uvicorn 启动兜底路径)。 构造与 `python3 server.py` 等价的 BridgeConfig:命令行参数取默认值, 其余字段由 BridgeConfig.from_args_and_env 从环境变量读取。 """ args = argparse.Namespace( listen=os.environ.get("WOC_BRIDGE_LISTEN", "0.0.0.0:8088"), display=os.environ.get("DISPLAY", ":1") or ":1", wechat_db=os.environ.get("WOC_WECHAT_DB", "/config"), ) cfg = BridgeConfig.from_args_and_env(args) _init_state(cfg) app = FastAPI(title="woc-bridge", version=BRIDGE_VERSION, lifespan=lifespan) # --------------------------------------------------------------------------- # 全局异常处理 # --------------------------------------------------------------------------- @app.exception_handler(BridgeError) async def bridge_error_handler(request: Request, exc: BridgeError) -> JSONResponse: """捕获 BridgeError,返回统一错误结构。 对 RATE_LIMITED 错误额外设置 Retry-After 响应头,供客户端退避。 """ headers: dict[str, str] | None = None if exc.code == "RATE_LIMITED" and exc.details: retry_after = exc.details.get("retry_after") if isinstance(retry_after, int): headers = {"Retry-After": str(retry_after)} return JSONResponse( status_code=exc.http_status, content=ErrorResponse( success=False, error={ "code": exc.code, "message": exc.message, "details": exc.details, }, ).model_dump(), headers=headers, ) @app.exception_handler(Exception) async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: """捕获其他异常,返回 BRIDGE_INTERNAL_ERROR。 日志里记完整异常信息便于排障;响应只回通用 message,不泄露内部细节。 """ logger.exception("unhandled exception on %s %s", request.method, request.url.path) return JSONResponse( status_code=500, content=ErrorResponse( success=False, error={ "code": "BRIDGE_INTERNAL_ERROR", "message": "bridge internal error", "details": None, }, ).model_dump(), ) # --------------------------------------------------------------------------- # 请求/响应日志中间件 # --------------------------------------------------------------------------- _SLOW_REQUEST_MS = 2000 _QUIET_ENTRY_PATHS: set[str] = set() @app.middleware("http") async def request_logging_middleware(request: Request, call_next): """统一 HTTP 请求/响应日志。 入口:INFO 级别,打印 method + path + query(仅非 quiet 路径) 出口:INFO 级别,打印 method + path + status_code + 耗时ms - 4xx/5xx 升级为 WARNING/ERROR - 超过 _SLOW_REQUEST_MS 升级为 WARNING(带 ⚠️ 标记) - 响应体含 error.code 时额外打印错误码与 message """ method = request.method path = request.url.path query = str(request.url.query) if request.url.query else "" # 入口日志 if path not in _QUIET_ENTRY_PATHS: if query: logger.info("→ %s %s?%s", method, path, query) else: logger.info("→ %s %s", method, path) start = time.perf_counter() try: response = await call_next(request) except Exception as exc: # 未被异常处理器捕获的极端情况 elapsed_ms = (time.perf_counter() - start) * 1000 logger.error( "← %s %s → EXCEPTION %s %.1fms: %s", method, path, type(exc).__name__, elapsed_ms, exc, ) raise elapsed_ms = (time.perf_counter() - start) * 1000 # 出口日志级别按状态码分级 status = response.status_code if status >= 500: log_level = logging.ERROR elif status >= 400: log_level = logging.WARNING else: log_level = logging.INFO # 慢请求升级为 WARNING slow_marker = "" if elapsed_ms > _SLOW_REQUEST_MS and log_level == logging.INFO: log_level = logging.WARNING slow_marker = " ⚠️ slow" logger.log( log_level, "← %s %s → %d %.1fms%s", method, path, status, elapsed_ms, slow_marker, ) return response # --------------------------------------------------------------------------- # 路由注册 # --------------------------------------------------------------------------- app.include_router(routes_status.router) app.include_router(routes_db.router) app.include_router(routes_messages.router) app.include_router(routes_send.router) app.include_router(routes_login.router) app.include_router(routes_contacts.router) app.include_router(routes_moments.router) app.include_router(routes_media.router) app.include_router(routes_diagnostic.router) app.include_router(routes_screenshot.router) # --------------------------------------------------------------------------- # 状态初始化 # --------------------------------------------------------------------------- def _init_state(cfg: BridgeConfig) -> None: """根据 BridgeConfig 初始化全局状态(运行期实例)。""" _state.config = cfg _state.xdotool = XdotoolDriver(display=cfg.display) # DbReader 共享 AppState 中的 key_cache(单一密钥来源) _state.db_reader = DbReader(db_root=cfg.wechat_db, key_cache=_state.key_cache) _state.qr_capture = QrCapture(display=cfg.display) _state.send_queue = SendQueue( send_delay_ms=cfg.send_delay_ms, max_calls_per_sec=cfg.max_calls_per_sec, ) # 消息流推送器:内部轮询 DB + SSE 广播(lifespan 中 start/stop) _state.message_streamer = MessageStreamer( db_reader=_state.db_reader, resolve_db_state=_resolve_db_state_tuple, extract_key=lambda: _auto_extract_with_lock(force=True), ) # 读取 WOC_DB_KEY 环境变量,注入 DbReader # 用 set_default_key 而非 set_key:保留 woc-keys.json 已持久化的多 salt 映射, # env key 仅作为默认兜底,首次查询时按 salt 验证后入库 # db_reader.set_default_key 已转发到 key_cache.set_default_key,无需重复调用 if cfg.db_key: _state.db_reader.set_default_key(cfg.db_key) logger.info("从 WOC_DB_KEY 环境变量加载默认 DB 密钥(未验证,首次查询时验证)")