WechatOnCloud/bridge/woc_bridge/app.py
Kris 15fc62d478 feat: 完成微信UI自动化新架构全量开发与集成
- 新增六层UI自动化架构:从Backend到Capabilities的完整分层实现
- 添加WeChat 4.0分辨率适配Profile与图像模板资源
- 实现幂等缓存、熔断器、重试策略、链路追踪与监控指标
- 新增头像下载安全校验、发布朋友圈路径白名单防护
- 优化密钥缓存、DB校验逻辑与初始化流程
- 补充完整错误码体系与启动清场机制
2026-07-16 01:19:47 +08:00

383 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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_statelifespan 直接用
2. `uvicorn server:app`__main__ 未执行lifespan 内兜底初始化
启动 send_queueyield停止 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)
# P3Prometheus /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 密钥(未验证,首次查询时验证)")
# P3UI 自动化新架构组件(仅 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