WechatOnCloud/bridge/woc_bridge/routes/diagnostic.py
Kris ed2ee086de refactor: 重构UI操作调度,引入优先级调度与监控
主要变更:
1. 新增UIActionScheduler实现优先级UI任务调度,支持CRITICAL/HIGH/NORMAL/LOW四级优先级
2. 替换原有SendQueue为UIActionScheduler,统一所有UI操作的调度逻辑
3. 为截图、登录、重启等API添加调度器封装,支持超时、限流与fast-fail机制
4. 新增Prometheus监控指标,统计UI任务执行、等待耗时、超时、限流与快速失败情况
5. 为QrCapture添加命令执行超时保护,避免X11操作卡死
6. 扩展StatusResponse与状态接口,暴露调度器指标
7. 新增完整的单元测试覆盖调度器功能
8. 为看门狗注入调度器,实现kill前队列清空与快速失败窗口
2026-07-18 03:43:28 +08:00

362 lines
14 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.

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:
ConnectivityResponsereachable / 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_idPOST /api/diagnostic/run/{check_id} 的路径参数)
- name卡片标题/ description卡片副标题
- severitycritical/error/warn/info决定卡片颜色与是否阻断
- auto_repairabletrue 时显示「自动修复」按钮,调 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_runningpgrep -x wechat 检测进程auto_repairable=true
- login_statedetect_login_state(),区分 not_running/not_logged_in/logged_in
- db_accessiblecheck_db_status() 区分 ok/not_found/encrypted/unreadable
encrypted 时返回 repair_plan 提示独立专项解决
- xdotool_availableshutil.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_idHTTP 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_runningpkill -x wechat 杀进程 → 等 2s 检查 autostart 是否拉起
→ 未拉起则 bridge 显式启动start_wechat作为兜底
- xdotool_available不可修复返回 passed=false + "请重建容器"
xdotool 缺失说明容器构建异常bridge 内无法修复,只能重建)
- db_accessible加密 DB 时尝试自动提取 keyP1 能力),失败返回 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}",
)