- 新增六层UI自动化架构:从Backend到Capabilities的完整分层实现 - 添加WeChat 4.0分辨率适配Profile与图像模板资源 - 实现幂等缓存、熔断器、重试策略、链路追踪与监控指标 - 新增头像下载安全校验、发布朋友圈路径白名单防护 - 优化密钥缓存、DB校验逻辑与初始化流程 - 补充完整错误码体系与启动清场机制
383 lines
15 KiB
Python
383 lines
15 KiB
Python
"""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] [%(trace_id)s] %(name)s: %(message)s"
|
||
)
|
||
)
|
||
logger.addHandler(_handler)
|
||
# P3:注入 TraceFilter 让日志带 trace_id
|
||
from woc_bridge.ui.trace import TraceFilter
|
||
_handler.addFilter(TraceFilter())
|
||
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()
|
||
# P3:启动 HA 模块(watchdog + resource_reaper)
|
||
if _state.watchdog is not None:
|
||
try:
|
||
await _state.watchdog.start()
|
||
except Exception as e:
|
||
logger.warning("[lifespan] watchdog start failed: %s", e)
|
||
if _state.resource_reaper is not None:
|
||
try:
|
||
await _state.resource_reaper.start()
|
||
except Exception as e:
|
||
logger.warning("[lifespan] resource_reaper start failed: %s", e)
|
||
# 启动时全量清场:上次崩溃可能残留脏状态(搜索框打开/输入框有内容)
|
||
# 失败仅告警不阻塞启动(spec Requirement: 启动全量清场)
|
||
if _state.xdotool is not None:
|
||
try:
|
||
cleanup_ok = await _state.xdotool._full_cleanup_on_startup()
|
||
if cleanup_ok:
|
||
logger.info("[lifespan] 启动清场完成")
|
||
else:
|
||
logger.warning("[lifespan] 启动清场 3 次失败,可能需要人工 VNC 接入")
|
||
except Exception as e:
|
||
logger.warning("[lifespan] 启动清场异常(不阻塞启动): %s", e)
|
||
try:
|
||
yield
|
||
finally:
|
||
# 停止消息流推送器(带超时,防止 worker 卡住阻塞关闭)
|
||
if _state.message_streamer is not None:
|
||
try:
|
||
await asyncio.wait_for(_state.message_streamer.stop(), timeout=5.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
logger.warning("[lifespan] message_streamer.stop() 超时或异常,强制继续")
|
||
# 停止发送队列(带超时)
|
||
if _state.send_queue is not None:
|
||
try:
|
||
await asyncio.wait_for(_state.send_queue.stop(), timeout=5.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
logger.warning("[lifespan] send_queue.stop() 超时或异常,强制继续")
|
||
# P3:停止 HA 模块
|
||
if _state.watchdog is not None:
|
||
try:
|
||
await asyncio.wait_for(_state.watchdog.stop(), timeout=5.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
logger.warning("[lifespan] watchdog.stop() 超时或异常,强制继续")
|
||
if _state.resource_reaper is not None:
|
||
try:
|
||
await asyncio.wait_for(_state.resource_reaper.stop(), timeout=5.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
logger.warning("[lifespan] resource_reaper.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)
|
||
|
||
# P3:Prometheus /metrics 端点
|
||
try:
|
||
from prometheus_client import make_asgi_app
|
||
app.mount("/metrics", make_asgi_app())
|
||
logger.info("[init] /metrics endpoint mounted")
|
||
except ImportError:
|
||
logger.warning("[init] prometheus_client not available, /metrics disabled")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 状态初始化
|
||
# ---------------------------------------------------------------------------
|
||
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 密钥(未验证,首次查询时验证)")
|
||
|
||
# P3:UI 自动化新架构组件(仅 ui_backend != "legacy" 时构造)
|
||
if cfg.ui_backend != "legacy":
|
||
try:
|
||
from woc_bridge.ui.backends import XdotoolBackend, OpenCVBackend
|
||
from woc_bridge.ui.locators.registry import LocatorRegistry
|
||
from woc_bridge.ui.actions import Actions
|
||
from woc_bridge.ui.idem_cache import IdemCache
|
||
from woc_bridge.ui.orchestrator import FlowOrchestrator
|
||
from woc_bridge.ui.capabilities import WeChatCapabilities
|
||
from woc_bridge.ui.watchdog import WeChatWatchdog
|
||
from woc_bridge.ui.resource_reaper import ResourceReaper
|
||
|
||
# 构造后端
|
||
_state.xdotool_backend = XdotoolBackend(display=cfg.display)
|
||
template_dir = os.path.join(
|
||
os.path.dirname(__file__), "ui", "profiles", "templates", "wechat_4.0", "light"
|
||
)
|
||
_state.opencv_backend = OpenCVBackend(template_dir=template_dir)
|
||
|
||
# 构造 Locator + Actions
|
||
profile_dir = os.path.join(os.path.dirname(__file__), "ui", "profiles")
|
||
_state.locator = LocatorRegistry(profile_dir=profile_dir, opencv=_state.opencv_backend)
|
||
_state.locator.load("4.0", (1920, 1080)) # 默认分辨率,实际可从 xdotool 获取
|
||
|
||
_state.actions = Actions(backend=_state.xdotool_backend, locator=_state.locator)
|
||
|
||
# 构造幂等缓存 + 编排器
|
||
_state.idem_cache = IdemCache(ttl=300, max_size=1000)
|
||
_state.orchestrator = FlowOrchestrator(
|
||
send_queue=_state.send_queue,
|
||
idem_cache=_state.idem_cache,
|
||
actions=_state.actions,
|
||
db_reader=_state.db_reader,
|
||
config=cfg,
|
||
)
|
||
|
||
# 构造 Capabilities
|
||
_state.capabilities = WeChatCapabilities(
|
||
orchestrator=_state.orchestrator,
|
||
xdotool_driver=_state.xdotool, # 保留旧驱动供 P1 兼容
|
||
)
|
||
|
||
# 构造 HA 模块
|
||
_state.watchdog = WeChatWatchdog(backend=_state.xdotool_backend, interval=10.0)
|
||
_state.resource_reaper = ResourceReaper(
|
||
debug_screenshot_dir="/tmp/woc_debug",
|
||
max_age_hours=24,
|
||
max_total_mb=100,
|
||
)
|
||
|
||
logger.info("[init] UI 新架构组件构造完成 (ui_backend=%s)", cfg.ui_backend)
|
||
except Exception as exc:
|
||
logger.error("[init] UI 新架构构造失败,回退 legacy: %s", exc)
|
||
# 构造失败不阻塞启动,回退到 legacy
|
||
# 清理已构造的部分组件,避免半初始化对象残留导致后续访问异常
|
||
_state.orchestrator = None
|
||
_state.capabilities = None
|
||
_state.actions = None
|
||
_state.locator = None
|
||
_state.idem_cache = None
|
||
_state.xdotool_backend = None
|
||
_state.opencv_backend = None
|
||
_state.watchdog = None
|
||
_state.resource_reaper = None
|