WechatOnCloud/bridge/woc_bridge/routes/diagnostic.py

362 lines
14 KiB
Python
Raw Normal View History

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}",
)