2026-07-08 23:25:58 +08:00
|
|
|
|
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,
|
2026-07-18 03:43:28 +08:00
|
|
|
|
_require_send_queue,
|
2026-07-08 23:25:58 +08:00
|
|
|
|
_state,
|
|
|
|
|
|
)
|
|
|
|
|
|
from woc_bridge.db.coordinator import (
|
|
|
|
|
|
_auto_extract_with_lock,
|
|
|
|
|
|
_resolve_db_state,
|
|
|
|
|
|
with_db_retry,
|
|
|
|
|
|
)
|
2026-07-18 03:43:28 +08:00
|
|
|
|
from woc_bridge.messaging.ui_action_scheduler import Priority
|
2026-07-08 23:25:58 +08:00
|
|
|
|
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()
|
2026-07-18 03:43:28 +08:00
|
|
|
|
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
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
2026-07-18 03:43:28 +08:00
|
|
|
|
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 桌面手动启动微信",
|
2026-07-08 23:25:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 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}",
|
|
|
|
|
|
)
|