本次提交将woc-bridge项目重构为模块化包结构,按职责拆分多个子域: 1. 新增models层定义所有Pydantic数据模型与统一错误体系 2. 拆分db/ui/messaging/routes等业务域模块 3. 实现基础API路由:状态查询、截图、登录、媒体获取等 4. 重构tools脚本的模块导入路径 5. 补充版本号与能力清单定义 6. 完善全局配置与依赖管理 整体完成项目从单文件脚本到可维护的包结构迁移,为后续功能开发打下基础。
327 lines
13 KiB
Python
327 lines
13 KiB
Python
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,
|
||
_state,
|
||
)
|
||
from woc_bridge.db.coordinator import (
|
||
_auto_extract_with_lock,
|
||
_resolve_db_state,
|
||
with_db_retry,
|
||
)
|
||
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()
|
||
|
||
# 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)
|
||
|
||
# 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}",
|
||
)
|