from __future__ import annotations import asyncio import logging import shutil from fastapi import APIRouter from woc_bridge.config import ( DIAGNOSTIC_ITEMS, _DIAGNOSTIC_ITEM_BY_ID, _require_db_reader, _require_xdotool, _require_send_queue, _state, ) from woc_bridge.db.coordinator import ( _auto_extract_with_lock, _resolve_db_state, with_db_retry, ) from woc_bridge.messaging.ui_action_scheduler import Priority from woc_bridge.models import ( BridgeError, ConnectivityResponse, DiagnosticItem, DiagnosticRunResult, ) logger = logging.getLogger("woc-bridge") router = APIRouter() # --------------------------------------------------------------------------- # 路由:GET /api/diagnostic/connectivity # --------------------------------------------------------------------------- @router.get("/api/diagnostic/connectivity", response_model=ConnectivityResponse) async def diagnostic_connectivity() -> ConnectivityResponse: """bridge 连通性检查(channels ProbeableAdapter 调用)。 MVP 阶段恒返回 reachable=true / latency_ms=0 / error=null。 真实实现可加:内部 health ping / 依赖项检查 / 平均响应时延。 Returns: ConnectivityResponse:reachable / latency_ms / error """ return ConnectivityResponse(reachable=True, latency_ms=0, error=None) # --------------------------------------------------------------------------- # 路由:GET /api/diagnostic/items # --------------------------------------------------------------------------- @router.get("/api/diagnostic/items") async def diagnostic_items() -> dict: """返回诊断项定义列表。 前端「诊断」页面用本接口渲染可执行的检查项卡片,每张卡片含: - check_id(POST /api/diagnostic/run/{check_id} 的路径参数) - name(卡片标题)/ description(卡片副标题) - severity(critical/error/warn/info,决定卡片颜色与是否阻断) - auto_repairable(true 时显示「自动修复」按钮,调 autofix 接口) Returns: {"items": [DiagnosticItem, ...]}(共 4 项:wechat_running / login_state / db_accessible / xdotool_available) """ return { "items": [DiagnosticItem(**item) for item in DIAGNOSTIC_ITEMS], } # --------------------------------------------------------------------------- # 路由:POST /api/diagnostic/run/{check_id} # --------------------------------------------------------------------------- @router.post("/api/diagnostic/run/{check_id}", response_model=DiagnosticRunResult) @with_db_retry async def diagnostic_run(check_id: str) -> DiagnosticRunResult: """执行单个诊断项检查(channels DoctorAdapter 调用)。 支持的 check_id: - wechat_running:pgrep -x wechat 检测进程,auto_repairable=true - login_state:detect_login_state(),区分 not_running/not_logged_in/logged_in - db_accessible:check_db_status() 区分 ok/not_found/encrypted/unreadable, encrypted 时返回 repair_plan 提示独立专项解决 - xdotool_available:shutil.which('xdotool') 检测可执行文件 Args: check_id: 路径参数,诊断项 ID(见 /api/diagnostic/items 返回) Returns: DiagnosticRunResult:含 passed / severity / message / auto_repairable / repair_plan(仅 db_accessible=encrypted/unreadable 时非空) Raises: BridgeError(INVALID_PARAMS): 未知 check_id(HTTP 400) Notes: - 不抛业务错误的诊断项:即使 passed=false 也返回 200 + 详细 message, 让调用方按字段判定后续动作(如显示修复建议、调 autofix) - repair_plan 字段仅在 db_accessible 失败时给出建议,其他项为 None """ item = _DIAGNOSTIC_ITEM_BY_ID.get(check_id) if item is None: raise BridgeError( code="INVALID_PARAMS", message=f"未知诊断项: {check_id}", ) xdotool = _require_xdotool() db_reader = _require_db_reader() if check_id == "wechat_running": passed = await xdotool.is_wechat_running() message = "微信进程运行中" if passed else "微信进程未运行" elif check_id == "login_state": state = await xdotool.detect_login_state() passed = state == "logged_in" message = f"登录态: {state}" elif check_id == "db_accessible": # 统一走 _resolve_db_state(含 salt/key/pid/验证逻辑) db_state = await _resolve_db_state() if db_state.db_accessible: source = _state.key_cache.source or "unknown" msg = "微信 DB 可读" if db_state.status == "ok" \ else f"DB 加密但已具备解密能力(key 来源: {source})" return DiagnosticRunResult( check_id=check_id, passed=True, severity=item["severity"], message=msg, auto_repairable=item["auto_repairable"], repair_plan=None, ) if db_state.status == "not_found": return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="未在 /config 下找到微信消息 DB", auto_repairable=item["auto_repairable"], repair_plan=None, ) if db_state.status == "unreadable": return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="DB 文件存在但不可读(权限问题)", auto_repairable=item["auto_repairable"], repair_plan="检查 /config/xwechat_files 下 DB 文件权限", ) # need_init / key_invalid 且无有效 key return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="DB 已加密(SQLCipher),需密钥才能读取", auto_repairable=item["auto_repairable"], repair_plan="通过 POST /api/db/decrypt 注入 64 位 hex 密钥,或设置 WOC_DB_KEY 环境变量,或启用 WOC_DB_KEY_AUTO_EXTRACT=true 自动提取", ) elif check_id == "xdotool_available": path = shutil.which("xdotool") passed = path is not None message = f"xdotool 路径: {path}" if passed else "xdotool 不可用" else: # 理论不可达 raise BridgeError( code="INVALID_PARAMS", message=f"未知诊断项: {check_id}", ) return DiagnosticRunResult( check_id=check_id, passed=passed, severity=item["severity"], message=message, auto_repairable=item["auto_repairable"], repair_plan=None, ) # --------------------------------------------------------------------------- # 路由:POST /api/diagnostic/autofix/{check_id} # --------------------------------------------------------------------------- @router.post("/api/diagnostic/autofix/{check_id}", response_model=DiagnosticRunResult) @with_db_retry async def diagnostic_autofix(check_id: str) -> DiagnosticRunResult: """尝试自动修复诊断项(channels DoctorAdapter 调用)。 仅 auto_repairable=true 的项可调,否则抛 INVALID_PARAMS。 当前可修复项: - wechat_running:pkill -x wechat 杀进程 → 等 2s 检查 autostart 是否拉起 → 未拉起则 bridge 显式启动(start_wechat)作为兜底 - xdotool_available:不可修复,返回 passed=false + "请重建容器" (xdotool 缺失说明容器构建异常,bridge 内无法修复,只能重建) - db_accessible:加密 DB 时尝试自动提取 key(P1 能力),失败返回 repair_plan Args: check_id: 路径参数,诊断项 ID Returns: DiagnosticRunResult:含 passed / message(描述修复结果或失败原因) Raises: BridgeError(INVALID_PARAMS): 未知 check_id 或该项不可自动修复(HTTP 400) Notes: - wechat_running 修复流程: 1. pkill 杀旧进程 2. 等 2s 让 autostart watchdog 拉起 3. 若 autostart 已拉起 → 返回 passed=true + 新 PID 4. 若 autostart 未拉起 → bridge 显式启动(start_wechat) 5. 显式启动成功 → passed=true + 新 PID 6. 显式启动失败 → passed=false + repair_plan 提示 - 调用方收到 passed=true 后应间隔 3-5s 调 /api/diagnostic/run/wechat_running 确认实际状态(进程可能在初始化中,窗口尚未出现) """ item = _DIAGNOSTIC_ITEM_BY_ID.get(check_id) if item is None: raise BridgeError( code="INVALID_PARAMS", message=f"未知诊断项: {check_id}", ) if not item["auto_repairable"]: raise BridgeError( code="INVALID_PARAMS", message="该项不可自动修复", ) if check_id == "wechat_running": xdotool = _require_xdotool() scheduler = _require_send_queue() # 启动 fast-fail 窗口:覆盖 autostart 拉起新进程的 ~5-10s 期间, # 避免队列内积压任务对新窗口执行 xdotool 失败形成风暴 if hasattr(scheduler, "mark_wechat_dead"): try: scheduler.mark_wechat_dead(15.0) except Exception as exc: logger.warning("diagnostic/autofix: mark_wechat_dead failed: %s", exc) async def _autofix_flow(): # 1. pkill 旧进程(不强制 SIGKILL,给微信优雅退出机会) try: proc = await asyncio.create_subprocess_exec( "pkill", "-x", "wechat", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() except Exception: pass # 2. 等 2s 让 autostart watchdog 拉起 await asyncio.sleep(2) # 3. 检查 autostart 是否已拉起 new_pid = await xdotool.check_wechat_pid() # 4. autostart 未拉起,bridge 显式启动兜底 if new_pid is None: new_pid = await xdotool.start_wechat(timeout_sec=10) return new_pid try: if hasattr(scheduler, "execute"): new_pid = await scheduler.execute( _autofix_flow, priority=Priority.CRITICAL, wait_timeout_ms=30000, trace_id="autofix_wechat_running", ) else: new_pid = await _autofix_flow() except Exception as exc: logger.warning("diagnostic/autofix: wechat_running autofix failed: %s", exc) return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message=f"autofix 执行失败: {exc}", auto_repairable=True, repair_plan="检查 X 会话是否正常(Xvnc/openbox),或通过 VNC 桌面手动启动微信", ) # 5. 返回结果 if new_pid is not None: return DiagnosticRunResult( check_id=check_id, passed=True, severity=item["severity"], message=f"微信进程已恢复,PID={new_pid}", auto_repairable=True, repair_plan=None, ) return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="autostart 未拉起且 bridge 显式启动失败", auto_repairable=True, repair_plan="检查 X 会话是否正常(Xvnc/openbox),或通过 VNC 桌面手动启动微信", ) elif check_id == "db_accessible": db_reader = _require_db_reader() # 检查 DB 状态 status = await asyncio.to_thread(db_reader.check_db_status) if status == "ok": return DiagnosticRunResult( check_id=check_id, passed=True, severity=item["severity"], message="DB 可读,无需修复", auto_repairable=item["auto_repairable"], repair_plan=None, ) if status in ("need_init", "key_invalid"): # 尝试自动提取 key(阻塞等待结果,复用公共加锁路径) extracted = await _auto_extract_with_lock() if extracted: return DiagnosticRunResult( check_id=check_id, passed=True, severity=item["severity"], message="自动提取 DB 密钥成功,DB 已可解密读取", auto_repairable=item["auto_repairable"], repair_plan=None, ) else: return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="自动提取 DB 密钥失败", auto_repairable=item["auto_repairable"], repair_plan="通过 POST /api/db/decrypt 手动注入 64 位 hex 密钥,或设置 WOC_DB_KEY 环境变量", ) # not_found / unreadable 不可自动修复 return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message=f"DB 状态: {status},无法自动修复", auto_repairable=item["auto_repairable"], repair_plan="检查 /config/xwechat_files 下 DB 文件是否存在且权限正确", ) elif check_id == "xdotool_available": return DiagnosticRunResult( check_id=check_id, passed=False, severity=item["severity"], message="xdotool 不可用,请重建容器", auto_repairable=True, repair_plan=None, ) # 理论不可达 raise BridgeError( code="INVALID_PARAMS", message=f"未知诊断项: {check_id}", )