1445 lines
60 KiB
Python
1445 lines
60 KiB
Python
"""
|
||
工作手机SDK v3.0 - 主入口
|
||
存客宝的AI手机控制引擎
|
||
"""
|
||
|
||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||
from fastapi.openapi.docs import get_swagger_ui_html
|
||
from fastapi.openapi.utils import get_openapi
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
||
import hmac
|
||
import base64
|
||
import hashlib
|
||
import time
|
||
from contextlib import asynccontextmanager
|
||
import logging
|
||
import os
|
||
import inspect
|
||
import json
|
||
import subprocess
|
||
import asyncio
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import markdown
|
||
|
||
from config import settings
|
||
from routers import devices, unified, agent, ai_tasks, adb, experience, projects, qrcode, voice, capture, hook_modules, connection, gateway, registry_cluster, discovery, cunke_bao, frida_wireless, wechat_full, connection_provider, integration, process, fleet, device_groups, integrations_admin, releases, workbench, kb, security_modules
|
||
from services.ws_hub import ws_hub
|
||
from services.device_manager import device_manager
|
||
from services.security_modules import security_module_registry
|
||
from services.write_gate import apply_global_openapi_contract, install_global_write_gate
|
||
from services.device_read_evidence import build_device_read_receipt, adapt_hook_probe_payload
|
||
from services.evidence_receipt import normalize_endpoint_evidence
|
||
|
||
# 配置日志
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_APP_DIR = os.path.dirname(__file__)
|
||
_STATIC_DIR = os.path.join(_APP_DIR, "static")
|
||
_CONSOLE_UI_DIR = os.path.join(_STATIC_DIR, "console")
|
||
_CONSOLE_UI_INDEX = os.path.join(_CONSOLE_UI_DIR, "index.html")
|
||
_DOCS_ROOT = os.path.normpath(os.path.join(_APP_DIR, "..", "..", "开发文档"))
|
||
_DOC_PAGE_MAP = {
|
||
"api-architecture": os.path.join(_DOCS_ROOT, "5、接口", "05-交互图", "API架构与交互流程图.html"),
|
||
"anti-ban-architecture": os.path.join(_DOCS_ROOT, "2、架构", "04-交互图", "防封模块架构图.html"),
|
||
"wechat-server-flow": os.path.join(_DOCS_ROOT, "2、架构", "04-交互图", "微信控制-设备服务器交互图.html"),
|
||
"dual-channel-device-map": os.path.join(_DOCS_ROOT, "2、架构", "04-交互图", "四端双通道绑定与device-map.html"),
|
||
}
|
||
_AGENT_ROOT_CANDIDATES = [
|
||
os.path.join(_APP_DIR, "agent"),
|
||
os.path.normpath(os.path.join(_APP_DIR, "..", "agent")),
|
||
]
|
||
_AGENT_ROOT = next((p for p in _AGENT_ROOT_CANDIDATES if os.path.exists(p)), _AGENT_ROOT_CANDIDATES[0])
|
||
_AGENT_DOCS = [
|
||
{
|
||
"id": "agent-readme",
|
||
"title": "Agent 架构与部署",
|
||
"summary": "设备端 Agent 总览:WebSocket 连接、技能引擎、目录结构、Termux 一键安装。",
|
||
"category": "agent",
|
||
"section": "Agent 集群",
|
||
"folder": "agent",
|
||
"file": "README.md",
|
||
"keywords": ["agent", "termux", "websocket", "部署"],
|
||
},
|
||
{
|
||
"id": "agent-skills",
|
||
"title": "技能引擎 Skills",
|
||
"summary": "6 大 Skill(微信/抖音/小红书/闲鱼/语音/搜索)· SkillExecutor 调度 · 复合命令。",
|
||
"category": "agent",
|
||
"section": "Agent 集群",
|
||
"folder": "agent/skills",
|
||
"file": "README.md",
|
||
"keywords": ["skill", "微信", "抖音", "技能", "executor"],
|
||
},
|
||
{
|
||
"id": "agent-hawk",
|
||
"title": "Hawk 网络恢复模块",
|
||
"summary": "Agent 与 Hawk 边界协同:断网自动恢复、WiFi 开关、合法操作边界。",
|
||
"category": "agent",
|
||
"section": "Agent 集群",
|
||
"folder": "agent/docs",
|
||
"file": "AGENT_HAWK.md",
|
||
"keywords": ["hawk", "网络", "wifi", "恢复", "断网"],
|
||
},
|
||
{
|
||
"id": "agent-hook",
|
||
"title": "Hook 脚本系统",
|
||
"summary": "微信 Hook 初版脚本(Frida):RPC 骨架、消息监听、脚本部署流程。",
|
||
"category": "agent",
|
||
"section": "Agent 集群",
|
||
"folder": "agent/hook",
|
||
"file": "README.md",
|
||
"keywords": ["hook", "frida", "rpc", "消息监听"],
|
||
},
|
||
]
|
||
_WORKBENCH_DOCS = [
|
||
{
|
||
"id": "sdk-manual",
|
||
"title": "SDK操作手册",
|
||
"summary": "工作手机 SDK 的整体使用说明、核心能力和调用入口。",
|
||
"category": "manual",
|
||
"section": "说明文档",
|
||
"folder": "9、手册/02-操作指南",
|
||
"file": "SDK操作手册.md",
|
||
"keywords": ["SDK", "手册", "调用", "接入"],
|
||
},
|
||
{
|
||
"id": "usage-solution",
|
||
"title": "使用与落地方案",
|
||
"summary": "从部署到业务落地的整体使用路径与建议方案。",
|
||
"category": "manual",
|
||
"section": "说明文档",
|
||
"folder": "9、手册/02-操作指南",
|
||
"file": "使用与落地方案.md",
|
||
"keywords": ["方案", "落地", "部署", "使用"],
|
||
},
|
||
{
|
||
"id": "wechat-e2e",
|
||
"title": "微信消息E2E验证指南",
|
||
"summary": "微信消息链路的端到端验证步骤和排查指引。",
|
||
"category": "manual",
|
||
"section": "说明文档",
|
||
"folder": "9、手册/03-验证",
|
||
"file": "微信消息E2E验证指南.md",
|
||
"keywords": ["微信", "E2E", "验证", "排查"],
|
||
},
|
||
{
|
||
"id": "agent-install",
|
||
"title": "设备端Agent安装与公司设备说明",
|
||
"summary": "设备端 Agent 的安装方式、公司设备规范和运行说明。",
|
||
"category": "manual",
|
||
"section": "说明文档",
|
||
"folder": "9、手册/04-专项",
|
||
"file": "设备端Agent安装与公司设备说明.md",
|
||
"keywords": ["Agent", "安装", "设备", "说明"],
|
||
},
|
||
{
|
||
"id": "install-checklist",
|
||
"title": "安装前配置检查规范",
|
||
"summary": "安装前需要确认的配置、环境与风险检查项。",
|
||
"category": "manual",
|
||
"section": "说明文档",
|
||
"folder": "9、手册/04-专项",
|
||
"file": "安装前配置检查规范.md",
|
||
"keywords": ["安装", "检查", "规范", "配置"],
|
||
},
|
||
{
|
||
"id": "system-architecture",
|
||
"title": "系统架构",
|
||
"summary": "工作手机 SDK 的整体架构、模块分层与链路设计。",
|
||
"category": "architecture",
|
||
"section": "架构与经验",
|
||
"folder": "2、架构",
|
||
"file": "01-总览/系统架构.md",
|
||
"keywords": ["架构", "模块", "分层", "链路"],
|
||
},
|
||
{
|
||
"id": "hook-architecture",
|
||
"title": "Hook通道与多设备多服务器架构",
|
||
"summary": "Hook 通道、Frida 集成、多设备与多服务器的设计说明。",
|
||
"category": "architecture",
|
||
"section": "架构与经验",
|
||
"folder": "2、架构",
|
||
"file": "03-通道与设备/Hook通道与多设备多服务器架构.md",
|
||
"keywords": ["Hook", "Frida", "多设备", "多服务器"],
|
||
},
|
||
{
|
||
"id": "api-spec",
|
||
"title": "接口规范",
|
||
"summary": "统一接口的字段约定、协议与调用方式。",
|
||
"category": "api",
|
||
"section": "接口与协议",
|
||
"folder": "5、接口",
|
||
"file": "01-规范与统一层/接口规范.md",
|
||
"keywords": ["接口", "协议", "字段", "规范"],
|
||
},
|
||
{
|
||
"id": "cunkebao-spec",
|
||
"title": "存客宝对接规范",
|
||
"summary": "存客宝与工作手机 SDK 的对接方式和聚合规则。",
|
||
"category": "api",
|
||
"section": "接口与协议",
|
||
"folder": "5、接口",
|
||
"file": "02-业务对接/存客宝对接规范.md",
|
||
"keywords": ["存客宝", "对接", "SDK", "聚合"],
|
||
},
|
||
{
|
||
"id": "progress-overview",
|
||
"title": "开发进度总表",
|
||
"summary": "当前阶段、完成率、里程碑与下一步开发重点。",
|
||
"category": "project",
|
||
"section": "项目管理",
|
||
"folder": "10、项目管理",
|
||
"file": "开发进度总表.md",
|
||
"keywords": ["进度", "里程碑", "阶段", "总表"],
|
||
},
|
||
]
|
||
def _scan_arch_assets():
|
||
import urllib.parse
|
||
arch_dir = Path(_DOCS_ROOT) / "2、架构"
|
||
known = [
|
||
("arch-project-tree", "项目目录总览", "仓库顶层 sdk / 开发文档 / 机擎 结构一览。"),
|
||
("arch-sdk", "工作手机SDK架构图", "系统全景四层架构,适合做整体汇报入口。"),
|
||
("arch-flow", "设备与服务器交互流程(微信控制)", "设备、服务器与微信控制的交互过程。"),
|
||
("arch-device", "工作手机设备端运转逻辑", "设备端从启动到心跳到 Skill 执行的完整运转。"),
|
||
("arch-agent-total", "Agent与服务器工作流总图", "业务入口、服务器 Agent、设备 Agent 和消息闭环。"),
|
||
("arch-hook", "Hook与服务器交互流程图", "Hook 通道的完整交互链路与功能细节。"),
|
||
("arch-ban", "防封模块架构图", "防封中心、设备侧、行为层与服务端策略。"),
|
||
("arch-ban-detail", "防封服务端核心能力拆细图", "防封服务端 7 大模块逐一拆细。"),
|
||
("mode-hook", "本地控制模式一:Hook/Frida", "Frida 深度控制微信的模式示意。"),
|
||
("mode-adb", "本地控制模式二:ADB静默控制", "无需 Root 的 ADB 静默控制模式。"),
|
||
("mode-agent", "本地控制模式三:Agent WebSocket远控", "远程统一控制多设备。"),
|
||
("mode-ai", "本地控制模式四:AI Agent自然语言", "自然语言编排复杂任务。"),
|
||
("mode-scrcpy", "本地控制模式五:scrcpy可视化控制", "投屏与人工接管。"),
|
||
]
|
||
fname_map = {
|
||
"arch-project-tree": "images/01-整体总览/工作手机项目目录总览.png",
|
||
"arch-sdk": "images/01-整体总览/工作手机SDK架构图.png",
|
||
"arch-flow": "images/03-微信与Hook/设备与服务器交互流程_微信控制.png",
|
||
"arch-device": "images/01-整体总览/工作手机设备端运转逻辑.png",
|
||
"arch-agent-total": "images/01-整体总览/卡若AI手机_Agent与服务器工作流总图.png",
|
||
"arch-hook": "images/03-微信与Hook/Hook与服务器交互流程图.png",
|
||
"arch-ban": "images/04-防封/防封模块架构图.png",
|
||
"arch-ban-detail": "images/04-防封/防封服务端核心能力拆细图.png",
|
||
"mode-hook": "images/02-控制通道/本地控制模式_1_Hook_Frida模式.png",
|
||
"mode-adb": "images/02-控制通道/本地控制模式_2_ADB静默控制模式.png",
|
||
"mode-agent": "images/02-控制通道/本地控制模式_3_Agent_WebSocket远控模式.png",
|
||
"mode-ai": "images/02-控制通道/本地控制模式_4_AI_Agent自然语言模式.png",
|
||
"mode-scrcpy": "images/02-控制通道/本地控制模式_5_scrcpy可视化控制模式.png",
|
||
}
|
||
result = []
|
||
for aid, title, summary in known:
|
||
fname = fname_map.get(aid, "")
|
||
fpath = arch_dir / fname
|
||
if fpath.exists():
|
||
encoded = urllib.parse.quote(f"2、架构/{fname}")
|
||
result.append({
|
||
"id": aid,
|
||
"title": title,
|
||
"summary": summary,
|
||
"category": "architecture",
|
||
"url": f"/workbench-docs/{encoded}",
|
||
})
|
||
return result
|
||
|
||
_WORKBENCH_ARCH_ASSETS = _scan_arch_assets()
|
||
|
||
|
||
def _doc_path(meta: dict) -> Path:
|
||
if meta.get("category") == "agent":
|
||
return Path(_AGENT_ROOT) / meta["file"] if meta["folder"] == "agent" else Path(_AGENT_ROOT) / meta["folder"].replace("agent/", "", 1) / meta["file"]
|
||
return Path(_DOCS_ROOT) / meta["folder"] / meta["file"]
|
||
|
||
|
||
def _doc_meta_with_state(meta: dict) -> dict:
|
||
path = _doc_path(meta)
|
||
exists = path.exists()
|
||
return {
|
||
**meta,
|
||
"exists": exists,
|
||
"path": str(path),
|
||
"updated_at": path.stat().st_mtime if exists else None,
|
||
"raw_url": f"/workbench-docs/{meta['folder']}/{meta['file']}" if exists else None,
|
||
}
|
||
|
||
|
||
def _render_markdown_html(content: str) -> str:
|
||
return markdown.markdown(
|
||
content,
|
||
extensions=["extra", "tables", "fenced_code", "toc", "sane_lists"],
|
||
output_format="html5",
|
||
)
|
||
|
||
|
||
import time as _time
|
||
_overview_cache = {"data": None, "ts": 0}
|
||
|
||
async def _build_workbench_overview() -> dict:
|
||
now = _time.time()
|
||
if _overview_cache["data"] and (now - _overview_cache["ts"]) < 10:
|
||
return _overview_cache["data"]
|
||
|
||
from services.adb_device import adb_manager
|
||
from services.ai_command import ai_engine
|
||
from services.connection_priority import connection_priority
|
||
|
||
# ADB 扫描加超时,避免 adb devices 卡住导致 overview 整体超时
|
||
try:
|
||
adb_authorized = await asyncio.wait_for(adb_manager.async_scan_devices(), timeout=5.0)
|
||
adb_all = await asyncio.wait_for(adb_manager.async_scan_all_devices(), timeout=5.0)
|
||
except asyncio.TimeoutError:
|
||
adb_authorized, adb_all = [], []
|
||
|
||
db_devices = []
|
||
try:
|
||
db_devices = await asyncio.wait_for(device_manager.get_all_devices(limit=200), timeout=3.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
db_devices = []
|
||
|
||
online_devices = {d["device_id"]: d for d in ws_hub.get_online_devices()}
|
||
|
||
# device_id → serial 映射,用于去重
|
||
id_serial_map = dict(adb_manager._deviceid_to_serial)
|
||
merged = {}
|
||
|
||
# ADB 设备优先(用 serial 做 key)
|
||
for serial in adb_authorized:
|
||
adb_dev = adb_manager.get_device(serial)
|
||
info = adb_dev.get_info() if adb_dev else {}
|
||
merged[serial] = {
|
||
"device_id": serial,
|
||
"serial": serial,
|
||
"model": info.get("model", "Unknown"),
|
||
"brand": info.get("brand", "Unknown"),
|
||
"android_version": info.get("android_version", "Unknown"),
|
||
"status": "adb",
|
||
"connection_type": "adb",
|
||
"controllable": True,
|
||
"display": info.get("display", {}),
|
||
}
|
||
|
||
# DB 设备(跳过已通过 serial 匹配到的)
|
||
existing_device_ids = {m.get("device_id", "") for m in merged.values()}
|
||
for device in db_devices:
|
||
device_id = device["device_id"]
|
||
serial = (
|
||
id_serial_map.get(device_id)
|
||
or device.get("serial")
|
||
or device.get("adb_serial")
|
||
or device.get("wifi_serial")
|
||
or ""
|
||
)
|
||
if serial and serial in merged:
|
||
merged[serial].update({k: v for k, v in device.items() if v and k not in ("status", "device_id")})
|
||
continue
|
||
if device_id in existing_device_ids:
|
||
continue
|
||
merged[device_id] = {**device, "serial": serial}
|
||
|
||
# WebSocket 在线设备
|
||
for device_id, info in online_devices.items():
|
||
serial = id_serial_map.get(device_id, "")
|
||
if serial and serial in merged:
|
||
merged[serial].update({**info, "status": "online"})
|
||
elif device_id in merged:
|
||
merged[device_id].update({**info, "status": "online"})
|
||
else:
|
||
merged[device_id] = {**info, "status": "online", "serial": serial}
|
||
|
||
# adb_all 中未授权/离线设备
|
||
known_serials = {m.get("serial", "") for m in merged.values()} | set(merged.keys())
|
||
for adb_entry in adb_all:
|
||
serial = adb_entry["serial"]
|
||
if serial in known_serials:
|
||
continue
|
||
if adb_entry["adb_status"] != "device":
|
||
merged[serial] = {
|
||
"device_id": serial,
|
||
"serial": serial,
|
||
"model": adb_entry.get("model") or "未知设备",
|
||
"status": adb_entry["adb_status"],
|
||
"connection_type": "usb",
|
||
"controllable": False,
|
||
"adb_status": adb_entry["adb_status"],
|
||
"usb": adb_entry.get("usb", ""),
|
||
"hint": "请在手机上点击「允许 USB 调试」" if adb_entry["adb_status"] == "unauthorized" else "",
|
||
}
|
||
|
||
# 最终去重:同一物理设备可能有 serial key 和 device_id key
|
||
seen_serials = set()
|
||
deduped = {}
|
||
for key, dev in merged.items():
|
||
serial = dev.get("serial", "")
|
||
if serial and serial in seen_serials:
|
||
for k2, d2 in deduped.items():
|
||
if d2.get("serial") == serial:
|
||
# 同一物理设备优先保留 WS 在线态,其次保留 controllable=True。
|
||
if dev.get("status") == "online" and d2.get("status") != "online":
|
||
d2.update(dev)
|
||
elif dev.get("controllable") and not d2.get("controllable"):
|
||
d2.update(dev)
|
||
else:
|
||
for fk, fv in dev.items():
|
||
if fv and not d2.get(fk):
|
||
d2[fk] = fv
|
||
break
|
||
continue
|
||
if serial:
|
||
seen_serials.add(serial)
|
||
deduped[key] = dev
|
||
|
||
from services.device_id_util import enrich_device_id_fields
|
||
from services.wechat_device_map import normalize_wechat_device_fields
|
||
|
||
physical_serial_models = {
|
||
device.get("model")
|
||
for device in deduped.values()
|
||
if device.get("serial") and ":" not in str(device.get("serial")) and device.get("model")
|
||
}
|
||
devices = []
|
||
for device in deduped.values():
|
||
device_id = str(device.get("device_id") or "")
|
||
serial = str(device.get("serial") or "")
|
||
model = device.get("model")
|
||
is_hex_alias = len(device_id) == 32 and all(char in "0123456789abcdef" for char in device_id.lower())
|
||
if model in physical_serial_models and ((is_hex_alias and not serial) or (serial and ":" in serial)):
|
||
continue
|
||
if serial and len(device_id) == 32 and all(char in "0123456789abcdef" for char in device_id.lower()):
|
||
device["device_id"] = serial
|
||
devices.append(normalize_wechat_device_fields(enrich_device_id_fields(device)))
|
||
online_count = len([d for d in devices if d.get("status") in ("online", "adb")])
|
||
adb_count = len([d for d in devices if d.get("status") == "adb"])
|
||
unauthorized_count = len([d for d in devices if d.get("status") == "unauthorized"])
|
||
offline_count = len([d for d in devices if d.get("status") == "offline"])
|
||
|
||
try:
|
||
ai_status = await asyncio.wait_for(ai_engine.get_status(), timeout=3.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
ai_status = {"available": False, "backend": "", "model": ""}
|
||
|
||
docs = [_doc_meta_with_state(meta) for meta in _WORKBENCH_DOCS + _AGENT_DOCS]
|
||
|
||
# 为每台设备附加连接模式信息(并行+单设备超时,避免 overview 整体超时)
|
||
_MODE_EVAL_TIMEOUT = 5.0 # 单设备评估超时秒数
|
||
|
||
async def _eval_modes(did: str):
|
||
try:
|
||
return await asyncio.wait_for(connection_priority.async_evaluate(did), timeout=_MODE_EVAL_TIMEOUT)
|
||
except (asyncio.TimeoutError, Exception):
|
||
return []
|
||
|
||
device_modes = {}
|
||
tasks = [_eval_modes(did) for dev in devices if (did := dev.get("device_id", ""))]
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
idx = 0
|
||
for dev in devices:
|
||
did = dev.get("device_id", "")
|
||
if not did:
|
||
continue
|
||
modes = results[idx] if idx < len(results) and not isinstance(results[idx], Exception) else []
|
||
idx += 1
|
||
try:
|
||
best = next((m for m in modes if m.available), None)
|
||
dev["connection_modes"] = [m.to_dict() for m in modes]
|
||
dev["best_mode"] = best.to_dict() if best else None
|
||
device_modes[did] = dev
|
||
except Exception:
|
||
pass
|
||
|
||
result = {
|
||
"sdk_online": True,
|
||
"sdk_version": "3.0.0",
|
||
"ws_online": len(ws_hub.connections),
|
||
"adb_count": len(adb_authorized),
|
||
"adb_all": len(adb_all),
|
||
"device_total": len(devices),
|
||
"device_online": online_count,
|
||
"device_offline": offline_count + adb_count,
|
||
"unauthorized_count": unauthorized_count,
|
||
"devices": devices,
|
||
"summary": {
|
||
"sdk_online": True,
|
||
"sdk_version": "3.0.0",
|
||
"ws_online": len(ws_hub.connections),
|
||
"adb_count": len(adb_authorized),
|
||
"adb_all": len(adb_all),
|
||
"unauthorized_count": unauthorized_count,
|
||
"device_total": len(devices),
|
||
"device_online": online_count,
|
||
"device_offline": offline_count,
|
||
"manual_docs": len([d for d in docs if d["category"] == "manual" and d["exists"]]),
|
||
"architecture_assets": len(_WORKBENCH_ARCH_ASSETS),
|
||
},
|
||
"agent": ai_status,
|
||
"docs": docs,
|
||
"architecture_assets": _WORKBENCH_ARCH_ASSETS,
|
||
"sections": [
|
||
{"id": "overview", "title": "首页总览"},
|
||
{"id": "docs", "title": "说明文档"},
|
||
{"id": "devices", "title": "设备控制"},
|
||
{"id": "hook", "title": "Hook 控制"},
|
||
{"id": "agent", "title": "AI Agent"},
|
||
{"id": "connection", "title": "连接与协议"},
|
||
{"id": "architecture", "title": "架构与经验"},
|
||
{"id": "project", "title": "项目管理"},
|
||
],
|
||
"control_modes": [
|
||
{"id": "hook", "name": "Hook / Frida", "desc": "读写内部能力强,适合微信深度控制", "priority": 1},
|
||
{"id": "agent", "name": "Agent WebSocket", "desc": "实时双向,设备端App常驻", "priority": 2},
|
||
{"id": "adb", "name": "ADB 静默控制", "desc": "无需 Root,部署简单,适合稳定兜底", "priority": 3},
|
||
{"id": "ai", "name": "AI Agent", "desc": "自然语言编排复杂任务", "priority": 4},
|
||
{"id": "scrcpy", "name": "scrcpy 可视化", "desc": "投屏与人工接管", "priority": 5},
|
||
],
|
||
}
|
||
_overview_cache["data"] = result
|
||
_overview_cache["ts"] = _time.time()
|
||
return result
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
"""应用生命周期管理"""
|
||
from services.ai_heartbeat import ai_heartbeat
|
||
from services.discovery_service import discovery_service
|
||
|
||
_bind_shared_runtime_ws_hub(app)
|
||
logger.info("🚀 工作手机SDK v3.0 启动中...")
|
||
from services.process_state import mark_process_started
|
||
mark_process_started()
|
||
await device_manager.init()
|
||
if device_manager.db is not None:
|
||
logger.info("✅ MongoDB 连接成功")
|
||
else:
|
||
logger.warning("⚠️ MongoDB 不可用,SDK 以无 DB 降级模式运行")
|
||
await ai_heartbeat.start()
|
||
logger.info("✅ AI 心跳监控已启动")
|
||
await discovery_service.start_beacon(port=8899)
|
||
logger.info("✅ 设备发现 beacon 已启动")
|
||
heartbeat_task = asyncio.create_task(_heartbeat_sweeper())
|
||
ai_hb_task = asyncio.create_task(_ai_heartbeat_loop())
|
||
yield
|
||
logger.info("🛑 工作手机SDK 关闭中...")
|
||
heartbeat_task.cancel()
|
||
ai_hb_task.cancel()
|
||
for t in (heartbeat_task, ai_hb_task):
|
||
try:
|
||
await t
|
||
except asyncio.CancelledError:
|
||
pass
|
||
await discovery_service.stop_beacon()
|
||
await ai_heartbeat.stop()
|
||
await device_manager.close()
|
||
|
||
|
||
async def _heartbeat_sweeper():
|
||
"""后台心跳巡检:清理长时间未上报心跳的设备"""
|
||
while True:
|
||
await asyncio.sleep(max(5, settings.WS_HEARTBEAT_INTERVAL))
|
||
await ws_hub.sweep_stale_devices(timeout_seconds=max(settings.WS_TIMEOUT, settings.WS_HEARTBEAT_INTERVAL * 3))
|
||
|
||
|
||
async def _ai_heartbeat_loop():
|
||
"""AI 心跳监控循环:健康评分 + 异常检测 + 任务下发"""
|
||
from services.ai_heartbeat import ai_heartbeat
|
||
while True:
|
||
try:
|
||
await ai_heartbeat.tick()
|
||
except Exception as e:
|
||
logger.error(f"AI 心跳监控异常: {e}")
|
||
await asyncio.sleep(ai_heartbeat.HEALTH_INTERVAL_SEC)
|
||
|
||
|
||
TAGS_METADATA = [
|
||
{"name": "消息管理", "description": "发送/获取/同步/批量/评论/转发/撤回/名片/语音 — 9个端点"},
|
||
{"name": "好友管理", "description": "添加/通过/备注/删除/搜索/批量 — 8个端点"},
|
||
{"name": "群聊管理", "description": "创建/邀请/移除/公告/群名/群消息/欢迎语/退群 — 10个端点"},
|
||
{"name": "标签管理", "description": "创建/删除/打标/取标/查人 — 6个端点"},
|
||
{"name": "朋友圈管理", "description": "发布/点赞/评论/删除/封面/可见天数/分享链接 — 8个端点"},
|
||
{"name": "个人设置", "description": "获取资料/昵称/签名/头像/性别/地区 — 6个端点"},
|
||
{"name": "账号安全", "description": "状态/解封/安全中心/改密码/申诉/限制检查 — 9个端点"},
|
||
{"name": "收藏管理", "description": "收藏/收藏列表 — 2个端点"},
|
||
{"name": "支付", "description": "红包/转账/付款码/收款/钱包/账单/领红包 — 7个端点"},
|
||
{"name": "聊天设置", "description": "置顶/免打扰/清空记录 — 3个端点"},
|
||
{"name": "小程序", "description": "打开小程序 — 1个端点"},
|
||
{"name": "公众号", "description": "关注公众号 — 1个端点"},
|
||
{"name": "视频号", "description": "列表/点赞/评论/关注/分享 — 5个端点"},
|
||
{"name": "扫一扫", "description": "扫码/加好友/我的二维码/图片识别 — 4个端点"},
|
||
{"name": "通话", "description": "语音通话/视频通话 — 2个端点"},
|
||
{"name": "群发助手", "description": "群发消息 — 1个端点"},
|
||
{"name": "搜一搜", "description": "微信搜一搜 — 1个端点"},
|
||
{"name": "看一看", "description": "微信看一看 — 1个端点"},
|
||
{"name": "微信运动", "description": "步数/点赞 — 2个端点"},
|
||
{"name": "位置", "description": "发送位置/共享实时位置 — 2个端点"},
|
||
{"name": "表情", "description": "发送表情/表情包列表 — 2个端点"},
|
||
{"name": "文件管理", "description": "发送文件/下载文件 — 2个端点"},
|
||
{"name": "设置", "description": "勿扰/清缓存/检查更新/退出/切换账号 — 5个端点"},
|
||
{"name": "设备管理", "description": "设备列表/状态/截图/ADB控制"},
|
||
{"name": "AI Agent", "description": "自然语言控制手机"},
|
||
{"name": "防封监控", "description": "操作频率/内容/账号生命周期 — 2个端点"},
|
||
]
|
||
|
||
app = FastAPI(
|
||
title="机擎 v3.0 · 工作手机SDK API",
|
||
description="""
|
||
## 工作手机SDK — 微信全功能控制接口
|
||
|
||
通过 ADB UI 自动化后端静默控制 Android 手机上的微信,**无需 Root**。
|
||
|
||
### 核心能力
|
||
- **98 个 API 端点**,覆盖微信全部功能
|
||
- **96 个引擎方法**(WeChatADBEngine)
|
||
- **23 个功能模块**(消息/好友/群聊/支付/解封/视频号/扫一扫...)
|
||
- **导航缓存系统**:首次录制坐标,后续免 UI dump
|
||
- **操作日志**:每次操作自动记录步骤/耗时/结果
|
||
- **容错机制**:自动重试 3 次 + 状态恢复
|
||
|
||
### 支持设备
|
||
- Redmi Note 13 5G (Android 13, 1080×2400)
|
||
- 微信 8.0.56
|
||
|
||
### 调用方式
|
||
```
|
||
POST http://{服务器IP}:8899/api/v3/message/send
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"device_id": "xgfe65eimrrofyws",
|
||
"platform": "wechat",
|
||
"to_id": "好友昵称",
|
||
"content": "你好"
|
||
}
|
||
```
|
||
|
||
### 公共参数
|
||
| 参数 | 类型 | 必填 | 说明 |
|
||
|------|------|------|------|
|
||
| device_id | string | ✅ | ADB 设备序列号 |
|
||
| platform | string | ✅ | 固定 `wechat` |
|
||
""",
|
||
version="3.0.0",
|
||
lifespan=lifespan,
|
||
openapi_tags=TAGS_METADATA,
|
||
docs_url=None,
|
||
redoc_url="/redoc",
|
||
)
|
||
|
||
|
||
# R6:所有路由必须共用当前进程的同一个 WebSocketHub。
|
||
# 某些启动方式会同时留下 services.* 与 app.services.* 两套模块名;
|
||
# 这里不新建连接,只从已经存在的对象里选当前仍有实时连接的那个,并统一绑定。
|
||
_WS_HUB_MODULES = (
|
||
"services.ws_hub",
|
||
"app.services.ws_hub",
|
||
)
|
||
_WS_HUB_ROUTE_MODULES = (
|
||
"routers.unified",
|
||
"app.routers.unified",
|
||
"routers.integration",
|
||
"app.routers.integration",
|
||
"routers.gateway",
|
||
"app.routers.gateway",
|
||
"routers.workbench",
|
||
"app.routers.workbench",
|
||
"routers.fleet",
|
||
"app.routers.fleet",
|
||
"services.workbench",
|
||
"app.services.workbench",
|
||
"services.device_fleet",
|
||
"app.services.device_fleet",
|
||
"services.device_transport",
|
||
"app.services.device_transport",
|
||
)
|
||
|
||
|
||
def _bind_shared_runtime_ws_hub(target_app: FastAPI | None = None):
|
||
"""把已加载的聚合路由绑定到同一个运行时 ws_hub,不创建新连接。"""
|
||
global ws_hub
|
||
candidates = [ws_hub]
|
||
for module_name in _WS_HUB_MODULES:
|
||
module = sys.modules.get(module_name)
|
||
candidate = getattr(module, "ws_hub", None) if module else None
|
||
if candidate is not None and not any(candidate is item for item in candidates):
|
||
candidates.append(candidate)
|
||
|
||
# 优先使用已有实时连接最多的实例;数量相同则保留主入口导入的实例。
|
||
canonical = max(
|
||
candidates,
|
||
key=lambda item: len(getattr(item, "connections", {}) or {}),
|
||
)
|
||
ws_hub = canonical
|
||
for module_name in _WS_HUB_MODULES:
|
||
module = sys.modules.get(module_name)
|
||
if module is not None:
|
||
module.ws_hub = canonical
|
||
for module_name in _WS_HUB_ROUTE_MODULES:
|
||
module = sys.modules.get(module_name)
|
||
if module is not None and hasattr(module, "ws_hub"):
|
||
module.ws_hub = canonical
|
||
if target_app is not None:
|
||
target_app.state.ws_hub = canonical
|
||
return canonical
|
||
|
||
# CORS配置
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
_PUBLIC_HTTP_PATHS = {
|
||
"/", "/health", "/ready", "/openapi.json", "/docs", "/redoc",
|
||
"/docs/oauth2-redirect", "/llms.txt", "/favicon.ico", "/api/v3/console/login",
|
||
}
|
||
|
||
_CONSOLE_COOKIE = "workphone_console_session"
|
||
_CONSOLE_LOGIN_FAILURES = {}
|
||
|
||
|
||
@app.get("/docs", include_in_schema=False)
|
||
async def swagger_docs():
|
||
"""带接口实现状态标识的 Swagger 文档。"""
|
||
response = get_swagger_ui_html(
|
||
openapi_url=app.openapi_url,
|
||
title=f"{app.title} - 接口文档",
|
||
swagger_ui_parameters={"displayOperationId": False, "filter": True},
|
||
)
|
||
html = response.body.decode("utf-8").replace(
|
||
"</head>",
|
||
'<link rel="stylesheet" href="/static/swagger-status.css?v=20260724"></head>',
|
||
).replace(
|
||
"</body>",
|
||
'<script src="/static/swagger-status.js?v=20260724"></script></body>',
|
||
)
|
||
return Response(content=html, media_type="text/html; charset=utf-8")
|
||
|
||
|
||
def _create_console_session(username: str) -> str:
|
||
expires = int(time.time()) + max(300, settings.CONSOLE_SESSION_TTL)
|
||
payload = f"{username}:{expires}"
|
||
signature = hmac.new(settings.API_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||
return base64.urlsafe_b64encode(f"{payload}:{signature}".encode()).decode()
|
||
|
||
|
||
def _has_valid_console_session(request: Request) -> bool:
|
||
token = request.cookies.get(_CONSOLE_COOKIE, "")
|
||
try:
|
||
decoded = base64.urlsafe_b64decode(token.encode()).decode()
|
||
username, expires, signature = decoded.rsplit(":", 2)
|
||
payload = f"{username}:{expires}"
|
||
expected = hmac.new(settings.API_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||
return (
|
||
username == settings.CONSOLE_USERNAME
|
||
and int(expires) >= int(time.time())
|
||
and hmac.compare_digest(signature, expected)
|
||
)
|
||
except (ValueError, TypeError):
|
||
return False
|
||
|
||
|
||
def _console_cookie_secure(request: Request) -> bool:
|
||
"""根据浏览器访问协议设置 Secure,避免 HTTP 页面丢弃登录 Cookie。"""
|
||
forwarded = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
|
||
return settings.CONSOLE_COOKIE_SECURE or forwarded == "https" or request.url.scheme == "https"
|
||
|
||
|
||
@app.post("/api/v3/console/login")
|
||
async def console_login(request: Request):
|
||
data = await request.json()
|
||
client_ip = request.client.host if request.client else "unknown"
|
||
now = int(time.time())
|
||
failures = [ts for ts in _CONSOLE_LOGIN_FAILURES.get(client_ip, []) if now - ts < 300]
|
||
if len(failures) >= 5:
|
||
return JSONResponse(status_code=429, content={"code": 429, "message": "登录尝试过多,请5分钟后再试"})
|
||
username = str(data.get("username", "")).strip()
|
||
password = str(data.get("password", ""))
|
||
configured = bool(settings.CONSOLE_PASSWORD)
|
||
valid = configured and hmac.compare_digest(username, settings.CONSOLE_USERNAME)
|
||
valid = valid and hmac.compare_digest(password, settings.CONSOLE_PASSWORD)
|
||
if not valid:
|
||
failures.append(now)
|
||
_CONSOLE_LOGIN_FAILURES[client_ip] = failures
|
||
return JSONResponse(status_code=401, content={"code": 401, "message": "账号或密码错误"})
|
||
_CONSOLE_LOGIN_FAILURES.pop(client_ip, None)
|
||
response = JSONResponse(content={"code": 200, "message": "登录成功", "username": username})
|
||
response.set_cookie(
|
||
_CONSOLE_COOKIE,
|
||
_create_console_session(username),
|
||
max_age=settings.CONSOLE_SESSION_TTL,
|
||
httponly=True,
|
||
secure=_console_cookie_secure(request),
|
||
samesite="strict",
|
||
path="/",
|
||
)
|
||
return response
|
||
|
||
|
||
@app.post("/api/v3/console/logout")
|
||
async def console_logout():
|
||
response = JSONResponse(content={"code": 200, "message": "已退出"})
|
||
response.delete_cookie(_CONSOLE_COOKIE, path="/")
|
||
return response
|
||
|
||
|
||
@app.get("/api/v3/console/session")
|
||
async def console_session(request: Request):
|
||
return {"code": 200, "authenticated": _has_valid_console_session(request)}
|
||
|
||
|
||
@app.middleware("http")
|
||
async def require_production_api_key(request: Request, call_next):
|
||
"""生产环境保护控制接口;健康探针和文档保持可读。"""
|
||
path = request.url.path.rstrip("/") or "/"
|
||
# 浏览器跨域调用会先发送不携带业务密钥的 OPTIONS 预检;应交给
|
||
# CORSMiddleware 返回允许头,真实 GET/POST 请求仍必须通过 API Key。
|
||
public = request.method == "OPTIONS" or path in _PUBLIC_HTTP_PATHS or path.startswith("/static/")
|
||
# 设备首装只允许携带既有配对 token 的 Bootstrap/安装包请求;绝不开放匿名下载。
|
||
device_bootstrap_paths = {
|
||
"/install.sh",
|
||
"/api/v3/agent/download",
|
||
}
|
||
is_device_bootstrap = path in device_bootstrap_paths or path.startswith("/api/v3/connection/bootstrap/")
|
||
supplied_device_token = request.headers.get("X-Device-Token", "").strip()
|
||
expected_device_token = settings.DEVICE_PAIRING_TOKEN.strip()
|
||
device_authorized = bool(
|
||
is_device_bootstrap
|
||
and expected_device_token
|
||
and hmac.compare_digest(supplied_device_token, expected_device_token)
|
||
)
|
||
security_control_path = path.startswith("/api/v3/security/modules")
|
||
api_auth_enabled = security_module_registry.is_enabled("api_auth")
|
||
if (security_control_path or api_auth_enabled) and not public and not device_authorized:
|
||
supplied = request.headers.get("X-API-Key", "").strip()
|
||
auth = request.headers.get("Authorization", "").strip()
|
||
if not supplied and auth.lower().startswith("bearer "):
|
||
supplied = auth[7:].strip()
|
||
expected = settings.API_KEY.strip()
|
||
if (not expected or not hmac.compare_digest(supplied, expected)) and not _has_valid_console_session(request):
|
||
return JSONResponse(status_code=401, content={"code": 401, "message": "API Key 无效"})
|
||
return await call_next(request)
|
||
|
||
# 注册路由
|
||
app.include_router(devices.router, prefix="/api/v3", tags=["设备管理"])
|
||
app.include_router(device_groups.router, prefix="/api/v3", tags=["设备分组"])
|
||
app.include_router(integrations_admin.router, prefix="/api/v3", tags=["第三方接入管理"])
|
||
app.include_router(releases.router, prefix="/api/v3", tags=["APK发布"])
|
||
app.include_router(workbench.router, prefix="/api/v3", tags=["总控平台总览"])
|
||
app.include_router(security_modules.router, prefix="/api/v3", tags=["安全模块"])
|
||
app.include_router(unified.router, prefix="/api/v3", tags=["统一接口"])
|
||
app.include_router(agent.router, prefix="/api/v3", tags=["AI Agent"])
|
||
app.include_router(ai_tasks.router, prefix="/api/v3", tags=["AI任务"])
|
||
app.include_router(adb.router, tags=["ADB设备控制"])
|
||
app.include_router(experience.router, tags=["经验库"])
|
||
app.include_router(projects.router, prefix="/api/v3", tags=["项目管理"])
|
||
app.include_router(qrcode.router, prefix="/api/v3", tags=["二维码"])
|
||
app.include_router(voice.router, prefix="/api/v3", tags=["语音控制"])
|
||
app.include_router(capture.router, tags=["抓包"])
|
||
app.include_router(hook_modules.router, prefix="/api/v3", tags=["Hook模块管理"])
|
||
app.include_router(connection.router, prefix="/api/v3", tags=["连接协议"])
|
||
app.include_router(connection_provider.router, prefix="/api/v3", tags=["连接方案可切换驱动"])
|
||
app.include_router(gateway.router, prefix="/api/v3", tags=["外部对接网关"])
|
||
app.include_router(registry_cluster.router, tags=["多服务器注册中心"])
|
||
app.include_router(discovery.router, tags=["设备发现"])
|
||
app.include_router(cunke_bao.router, prefix="/api/v3", tags=["存客宝对接"])
|
||
app.include_router(frida_wireless.router, prefix="/api/v3", tags=["Frida无线管理"])
|
||
app.include_router(wechat_full.router, prefix="/api/v3", tags=["微信全量操作"])
|
||
app.include_router(integration.router, prefix="/api/v3", tags=["对外接口统一清单"])
|
||
app.include_router(process.router, prefix="/api/v3", tags=["进程状态"])
|
||
app.include_router(fleet.router, prefix="/api/v3", tags=["多设备管理"])
|
||
app.include_router(kb.router, tags=["内部知识库"])
|
||
|
||
# 所有业务写接口在进入原业务路由前,先经过统一的干跑、确认和幂等门禁。
|
||
# 这只加公共外壳,不改各业务路由的具体实现。
|
||
_GLOBAL_WRITE_MANIFEST = install_global_write_gate(app)
|
||
|
||
|
||
@app.middleware("http")
|
||
async def hook_probe_evidence_envelope(request: Request, call_next):
|
||
"""只给旧Hook探针加证据外壳,不改统一路由和Hook脚本。"""
|
||
response = await call_next(request)
|
||
path = request.url.path
|
||
if request.method != "GET" or not path.startswith("/api/v3/hook/probe/"):
|
||
return response
|
||
try:
|
||
body = b"".join([chunk async for chunk in response.body_iterator])
|
||
payload = json.loads(body.decode("utf-8"))
|
||
if not isinstance(payload, dict):
|
||
return response
|
||
device_id = path.rsplit("/", 1)[-1]
|
||
adapted = adapt_hook_probe_payload(payload, device_id)
|
||
headers = {k: v for k, v in response.headers.items() if k.lower() not in {"content-length", "content-encoding"}}
|
||
return JSONResponse(status_code=response.status_code, content=adapted, headers=headers)
|
||
except Exception:
|
||
# 原始响应无法解析时原样返回,不伪造成功。
|
||
return response
|
||
|
||
|
||
_PLATFORM_EVIDENCE_PATHS = {
|
||
"/api/v3/wechat/account/profile", # 模块6:微信账号资料
|
||
"/api/v3/tag/list", # 模块11:标签列表与映射
|
||
"/api/v3/customer/profile-bundle", # 模块46:客户画像聚合
|
||
"/api/v3/message/list", # 模块47:客户会话聚合
|
||
"/api/v3/stability/watch", # 模块54:限流重试超时观察
|
||
}
|
||
|
||
|
||
async def _refresh_stability_hook_probe(payload: dict, device_id: str) -> dict:
|
||
"""稳定观察需要时复用正式 Hook 探针,并保留真实失败。"""
|
||
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
||
samples = data.get("samples") if isinstance(data.get("samples"), list) else []
|
||
summary = data.get("summary") if isinstance(data.get("summary"), dict) else {}
|
||
if not samples or not bool(summary.get("hook_required")):
|
||
return payload
|
||
|
||
# 原稳定采样已经拿到明确成功时不重复探针;只有 hook_ok=false 时才补用
|
||
# 正式 /hook/probe 的同一条函数链,避免把“在线但Hook失败”改成成功。
|
||
if all(item.get("hook_ok") is True for item in samples if isinstance(item, dict)):
|
||
return payload
|
||
|
||
try:
|
||
unified_module = (
|
||
sys.modules.get("routers.unified")
|
||
or sys.modules.get("app.routers.unified")
|
||
)
|
||
if unified_module is None:
|
||
from routers import unified as unified_module # type: ignore
|
||
probe_payload = await unified_module.hook_probe(device_id)
|
||
probe_receipt = adapt_hook_probe_payload(probe_payload, device_id)
|
||
except Exception as exc: # 探针失败必须保留为失败,不伪造成功
|
||
probe_receipt = {
|
||
"code": 503,
|
||
"success": False,
|
||
"error_code": "hook_probe_exception",
|
||
"error_message": str(exc)[:200],
|
||
"readback": {"verified": False, "write_performed": False},
|
||
}
|
||
|
||
probe_readback = probe_receipt.get("readback") if isinstance(probe_receipt.get("readback"), dict) else {}
|
||
probe_verified = probe_readback.get("verified") is True
|
||
for item in samples:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
item["hook_ok"] = probe_verified
|
||
item["hook_probe"] = probe_receipt
|
||
if not probe_verified:
|
||
item["error"] = item.get("error") or probe_receipt.get("error_code") or "hook_probe_failed"
|
||
|
||
total = len(samples)
|
||
ok_count = sum(
|
||
1
|
||
for item in samples
|
||
if isinstance(item, dict)
|
||
and item.get("ws_online") is True
|
||
and item.get("heartbeat_stale") is False
|
||
and (not summary.get("hook_required") or item.get("hook_ok") is True)
|
||
)
|
||
summary.update({
|
||
"total": total,
|
||
"ok": ok_count,
|
||
"success_rate": round(ok_count / total, 4) if total else 0,
|
||
})
|
||
data["samples"] = samples
|
||
data["summary"] = summary
|
||
data["hook_probe"] = probe_receipt
|
||
payload["data"] = data
|
||
payload["raw_rpc_receipt"] = {
|
||
"kind": "stability_observation_with_hook_probe",
|
||
"source": "ws_hub+hook_probe",
|
||
"device_id": device_id,
|
||
"samples": samples,
|
||
"hook_probe": probe_receipt.get("raw_rpc_receipt") or probe_receipt,
|
||
}
|
||
payload["readback"] = {
|
||
"verified": bool(total and ok_count == total),
|
||
"write_performed": False,
|
||
"source": "stability_watch+hook_probe",
|
||
"sample_count": total,
|
||
"ok_count": ok_count,
|
||
"hook_probe_verified": probe_verified,
|
||
}
|
||
payload["success"] = payload["readback"]["verified"] is True
|
||
if not payload["success"]:
|
||
payload["error_code"] = "hook_probe_failed"
|
||
payload["error_message"] = "设备在线,但现有Hook探针没有通过"
|
||
else:
|
||
payload.pop("error_code", None)
|
||
payload.pop("error_message", None)
|
||
return payload
|
||
|
||
|
||
@app.middleware("http")
|
||
async def platform_evidence_envelope(request: Request, call_next):
|
||
"""把旧平台接口的组件回执提升到最外层,保留真实失败状态。"""
|
||
_bind_shared_runtime_ws_hub(app)
|
||
response = await call_next(request)
|
||
if request.url.path not in _PLATFORM_EVIDENCE_PATHS:
|
||
return response
|
||
try:
|
||
body = b"".join([chunk async for chunk in response.body_iterator])
|
||
payload = json.loads(body.decode("utf-8"))
|
||
if not isinstance(payload, dict):
|
||
return response
|
||
if request.url.path == "/api/v3/stability/watch":
|
||
device_id = request.query_params.get("device_id", "")
|
||
if device_id and request.query_params.get("include_hook", "").lower() == "true":
|
||
payload = await _refresh_stability_hook_probe(payload, device_id)
|
||
operation = (
|
||
"stability_watch"
|
||
if request.url.path == "/api/v3/stability/watch"
|
||
else request.url.path.rsplit("/", 1)[-1].replace("-", "_")
|
||
)
|
||
adapted = normalize_endpoint_evidence(
|
||
payload,
|
||
operation=operation,
|
||
trace_id=request.headers.get("X-FULL55-Trace") or request.query_params.get("trace_id"),
|
||
fallback_channel=(
|
||
"websocket/frida_rpc"
|
||
if (
|
||
"message" in request.url.path
|
||
or "tag" in request.url.path
|
||
or "profile" in request.url.path
|
||
or request.query_params.get("include_hook", "").lower() == "true"
|
||
)
|
||
else "websocket/agent"
|
||
),
|
||
)
|
||
# 外层统一口径:底层没有提供任何业务读回时,用统一的
|
||
# readback_unverified;已有具体业务失败原因则原样保留。
|
||
readback = adapted.get("readback") if isinstance(adapted.get("readback"), dict) else {}
|
||
if (
|
||
adapted.get("success") is False
|
||
and adapted.get("error_code") == "readback_failed"
|
||
and readback.get("verified") is False
|
||
and request.url.path != "/api/v3/stability/watch"
|
||
):
|
||
adapted["error_code"] = "readback_unverified"
|
||
headers = {
|
||
key: value
|
||
for key, value in response.headers.items()
|
||
if key.lower() not in {"content-length", "content-encoding"}
|
||
}
|
||
return JSONResponse(status_code=response.status_code, content=adapted, headers=headers)
|
||
except Exception:
|
||
# 解析失败时保留原响应,不用包装层掩盖真实错误。
|
||
return response
|
||
|
||
|
||
# ========== 健康检查 ==========
|
||
|
||
@app.get("/health")
|
||
async def health_check():
|
||
"""健康检查"""
|
||
from services.adb_device import adb_manager
|
||
adb_devices = []
|
||
if bool(getattr(settings, "WORKPHONE_HOST_ADB_PROBE", False)):
|
||
adb_devices = await adb_manager.async_scan_devices()
|
||
data = {
|
||
"status": "healthy",
|
||
"version": "3.0.0",
|
||
"devices_online": len(ws_hub.connections),
|
||
"device_ids": list(ws_hub.connections.keys()),
|
||
"adb_devices": len(adb_devices),
|
||
"adb_serials": adb_devices,
|
||
"evidence_source": "sdk_process+ws_hub",
|
||
}
|
||
verified = bool(ws_hub.connections)
|
||
result = build_device_read_receipt(
|
||
data=data,
|
||
action="health",
|
||
channel="websocket/agent" if verified else "sdk_local/offline",
|
||
raw_rpc_receipt={"source": "sdk_health", "devices_online": len(ws_hub.connections), "adb_probe_used": bool(getattr(settings, "WORKPHONE_HOST_ADB_PROBE", False))},
|
||
readback={"verified": verified, "source": "ws_hub_live_connections", "online_ws_count": len(ws_hub.connections)},
|
||
trace_id=f"health-{int(time.time() * 1000)}",
|
||
error_code="no_live_device" if not verified else None,
|
||
error_message="没有真实在线设备,健康检查不报成功" if not verified else None,
|
||
success=verified,
|
||
)
|
||
# 保留旧健康检查调用方直接读取的字段。
|
||
result.update({"status": data["status"], "version": data["version"]})
|
||
return result
|
||
|
||
|
||
@app.get("/ready")
|
||
async def ready():
|
||
"""就绪探针(部署/负载均衡用):进程已启动且可接收流量"""
|
||
return {"ready": True, "version": "3.0.0"}
|
||
|
||
|
||
@app.get("/llms.txt")
|
||
async def llms_txt():
|
||
"""AI/LLM 可读入口:声明接口网站、OpenAPI、MCP 与对接手册。"""
|
||
static_path = os.path.join(_STATIC_DIR, "llms.txt")
|
||
if os.path.exists(static_path):
|
||
return FileResponse(static_path, media_type="text/plain; charset=utf-8")
|
||
raise HTTPException(status_code=404, detail="llms.txt 不存在")
|
||
|
||
|
||
@app.get("/")
|
||
async def root():
|
||
"""根路由 → 聚合总控台"""
|
||
static_path = _CONSOLE_UI_INDEX
|
||
if os.path.exists(static_path):
|
||
return FileResponse(static_path)
|
||
return {
|
||
"name": "工作手机SDK v3.0",
|
||
"description": "统一聚合总控台",
|
||
"docs": "/docs",
|
||
"health": "/health",
|
||
"ready": "/ready",
|
||
"voice_control": "/voice",
|
||
"control_center": "/control",
|
||
}
|
||
|
||
|
||
@app.get("/hub")
|
||
async def hub_page():
|
||
"""统一聚合总控台"""
|
||
static_path = _CONSOLE_UI_INDEX
|
||
if os.path.exists(static_path):
|
||
return FileResponse(static_path)
|
||
raise HTTPException(status_code=404, detail="总控平台前端产物不存在,请先执行 sdk/android-ui 的 build:runtime")
|
||
|
||
|
||
@app.get("/control")
|
||
async def control_page():
|
||
"""兼容旧入口,永久归一到 Docker Hub。"""
|
||
return RedirectResponse(url="/?tab=devices", status_code=308)
|
||
|
||
|
||
@app.get("/voice")
|
||
async def voice_control_page():
|
||
"""旧语音页已融合进工作台命令区。"""
|
||
return RedirectResponse(url="/?tab=overview", status_code=308)
|
||
|
||
|
||
@app.get("/wechat")
|
||
async def wechat_control_page():
|
||
"""旧微信页已融合进设备管理与 Hook 面板。"""
|
||
return RedirectResponse(url="/?tab=devices", status_code=308)
|
||
|
||
|
||
@app.get("/static/index.html", include_in_schema=False)
|
||
async def legacy_static_console():
|
||
"""旧静态控制台只保留兼容跳转,不再维护第二套页面。"""
|
||
return RedirectResponse(url="/?tab=devices", status_code=308)
|
||
|
||
|
||
@app.get("/static/voice_control.html", include_in_schema=False)
|
||
async def legacy_static_voice_console():
|
||
"""旧语音控制页兼容跳转。"""
|
||
return RedirectResponse(url="/?tab=overview", status_code=308)
|
||
|
||
|
||
@app.get("/static/wechat_control.html", include_in_schema=False)
|
||
async def legacy_static_wechat_console():
|
||
"""旧微信控制页兼容跳转。"""
|
||
return RedirectResponse(url="/?tab=devices", status_code=308)
|
||
|
||
|
||
@app.get("/pages/{page_key}")
|
||
async def docs_page(page_key: str):
|
||
"""聚合页内嵌的项目文档页面"""
|
||
doc_path = _DOC_PAGE_MAP.get(page_key)
|
||
if not doc_path or not os.path.exists(doc_path):
|
||
raise HTTPException(status_code=404, detail="页面不存在")
|
||
return FileResponse(doc_path)
|
||
|
||
|
||
_WORKBENCH_OVERVIEW_TIMEOUT = 15.0 # 整体超时秒数,避免 ADB/设备端操作阻塞
|
||
|
||
|
||
def _overview_fallback() -> dict:
|
||
"""overview 超时或异常时的降级响应"""
|
||
return {
|
||
"sdk_online": True,
|
||
"sdk_version": "3.0.0",
|
||
"ws_online": len(ws_hub.connections),
|
||
"adb_count": 0,
|
||
"adb_all": 0,
|
||
"device_total": 0,
|
||
"device_online": 0,
|
||
"device_offline": 0,
|
||
"unauthorized_count": 0,
|
||
"devices": [],
|
||
"summary": {"sdk_online": True, "sdk_version": "3.0.0", "manual_docs": 0, "architecture_assets": 0},
|
||
"agent": {"available": False, "backend": "", "model": ""},
|
||
"docs": [],
|
||
"architecture_assets": [],
|
||
"sections": [],
|
||
"control_modes": [],
|
||
"timeout_fallback": True,
|
||
}
|
||
|
||
|
||
@app.get("/api/v3/workbench/docs", tags=["工作台概览"])
|
||
async def workbench_docs():
|
||
"""工作台文档清单。"""
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"docs": [_doc_meta_with_state(meta) for meta in _WORKBENCH_DOCS + _AGENT_DOCS],
|
||
"architecture_assets": _WORKBENCH_ARCH_ASSETS,
|
||
},
|
||
}
|
||
|
||
|
||
@app.get("/api/v3/workbench/docs/{doc_id}", tags=["工作台概览"])
|
||
async def workbench_doc_detail(doc_id: str):
|
||
"""读取工作台文档正文并返回渲染后的 HTML。"""
|
||
meta = next((item for item in _WORKBENCH_DOCS + _AGENT_DOCS if item["id"] == doc_id), None)
|
||
if not meta:
|
||
raise HTTPException(status_code=404, detail="文档不存在")
|
||
|
||
path = _doc_path(meta)
|
||
if not path.exists():
|
||
raise HTTPException(status_code=404, detail="文档文件不存在")
|
||
|
||
raw = path.read_text(encoding="utf-8")
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
**_doc_meta_with_state(meta),
|
||
"content_markdown": raw,
|
||
"content_html": _render_markdown_html(raw),
|
||
},
|
||
}
|
||
|
||
|
||
# 挂载静态文件目录
|
||
if os.path.exists(_STATIC_DIR):
|
||
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
|
||
|
||
if os.path.exists(_DOCS_ROOT):
|
||
app.mount("/workbench-docs", StaticFiles(directory=_DOCS_ROOT), name="workbench-docs")
|
||
|
||
|
||
# ========== Agent 分发接口 ==========
|
||
|
||
# Agent 代码根目录
|
||
_AGENT_DIR = os.path.normpath(os.path.join(_APP_DIR, "..", "agent"))
|
||
_AGENT_DIST = os.path.join(_AGENT_DIR, "dist", "agent.tar.gz")
|
||
|
||
|
||
@app.get("/api/v3/agent/download", tags=["设备管理"])
|
||
async def download_agent():
|
||
"""
|
||
下载设备端 Agent 打包文件(agent.tar.gz)
|
||
|
||
设备端 install.sh 会调用此接口自动下载代码。
|
||
如果 dist/agent.tar.gz 不存在,自动运行 package.sh 打包。
|
||
"""
|
||
# 自动打包(如果 dist 不存在或过期)
|
||
if not os.path.exists(_AGENT_DIST):
|
||
package_sh = os.path.join(_AGENT_DIR, "package.sh")
|
||
if os.path.exists(package_sh):
|
||
try:
|
||
subprocess.run(["bash", package_sh], cwd=_AGENT_DIR, check=True, timeout=30)
|
||
logger.info("Agent 代码已自动打包")
|
||
except Exception as e:
|
||
logger.error(f"自动打包失败: {e}")
|
||
return {"error": "Agent 打包失败,请在服务器手动执行 agent/package.sh"}
|
||
|
||
if os.path.exists(_AGENT_DIST):
|
||
return FileResponse(
|
||
_AGENT_DIST,
|
||
media_type="application/gzip",
|
||
filename="agent.tar.gz",
|
||
)
|
||
return {"error": "agent.tar.gz 不存在"}
|
||
|
||
|
||
@app.get("/install.sh")
|
||
async def serve_install_script():
|
||
"""
|
||
提供 Termux 一键安装脚本
|
||
|
||
用法: curl -sL http://服务器IP:8899/install.sh | bash -s -- --server ws://服务器IP:8899/ws/device
|
||
"""
|
||
install_sh = os.path.join(_AGENT_DIR, "install.sh")
|
||
if os.path.exists(install_sh):
|
||
return FileResponse(install_sh, media_type="text/plain", filename="install.sh")
|
||
return Response(content="echo '❌ install.sh 不存在'\n", media_type="text/plain")
|
||
|
||
|
||
# ========== WebSocket 设备连接 ==========
|
||
|
||
@app.websocket("/ws/device/{device_id}")
|
||
async def device_websocket(websocket: WebSocket, device_id: str):
|
||
"""设备WebSocket连接入口"""
|
||
expected_token = settings.DEVICE_PAIRING_TOKEN.strip()
|
||
supplied_token = websocket.query_params.get("token", "").strip()
|
||
if expected_token and not hmac.compare_digest(supplied_token, expected_token):
|
||
await websocket.close(code=4401, reason="invalid pairing token")
|
||
return
|
||
connected = await ws_hub.connect(websocket, device_id)
|
||
if not connected:
|
||
return
|
||
try:
|
||
while True:
|
||
data = await websocket.receive_json()
|
||
await ws_hub.handle_message(device_id, data)
|
||
except WebSocketDisconnect:
|
||
await ws_hub.disconnect(device_id, websocket=websocket)
|
||
except Exception as e:
|
||
logger.error(f"WebSocket错误 [{device_id}]: {e}")
|
||
await ws_hub.disconnect(device_id, websocket=websocket)
|
||
|
||
|
||
_OPENAPI_SUMMARY_ZH = {
|
||
"console_login": "控制台登录",
|
||
"console_logout": "控制台退出",
|
||
"console_session": "查询控制台会话",
|
||
"api_start_capture": "开始抓包",
|
||
"api_stop_capture": "停止抓包",
|
||
"api_capture_status": "查询抓包状态",
|
||
"api_capture_data": "获取抓包数据",
|
||
"list_modules": "查询模块列表",
|
||
"create_module": "创建模块",
|
||
"get_module": "获取模块详情",
|
||
"update_module": "更新模块",
|
||
"delete_module": "删除模块",
|
||
"set_module_scope": "设置模块作用范围",
|
||
"enable_module": "启用模块",
|
||
"disable_module": "停用模块",
|
||
"get_device_modules": "查询设备模块",
|
||
"reload_device_modules": "重新加载设备模块",
|
||
"get_device_module_logs": "查询设备模块日志",
|
||
"list_scripts": "查询脚本列表",
|
||
"upload_script": "上传脚本",
|
||
"download_script": "下载脚本",
|
||
"deploy_script": "部署脚本",
|
||
"list_hook_events": "查询 Hook 事件",
|
||
"ingest_hook_event": "接收 Hook 事件",
|
||
"register_node": "注册服务节点",
|
||
"node_heartbeat": "上报节点心跳",
|
||
"list_nodes": "查询服务节点",
|
||
"remove_node": "移除服务节点",
|
||
"registry_info": "查询注册中心信息",
|
||
"ping": "检测微信连接",
|
||
"get_safety_center": "获取微信安全中心",
|
||
"check_restrictions": "检查微信账号限制",
|
||
"get_top_stories": "获取微信看一看",
|
||
"get_wechat_steps": "获取微信运动步数",
|
||
"get_sticker_list": "获取微信表情列表",
|
||
"show_payment_code": "显示付款码",
|
||
"send_red_packet": "发送红包",
|
||
"send_transfer": "发起转账",
|
||
"confirm_prepared_transfer": "确认已准备转账",
|
||
"inspect_payment_classes": "支付类探针",
|
||
"inspect_transfer_readback": "转账回读探针",
|
||
"receive_transfer_payment": "确认收取转账",
|
||
"reject_transfer_payment": "退还转账",
|
||
"receive_payment": "确认收款",
|
||
"view_wallet": "查看钱包",
|
||
"view_transactions": "查看账单",
|
||
"receive_red_packet": "领取红包",
|
||
"receive_incoming_payment_batch": "批量领取待收款项",
|
||
"like_wechat_steps": "点赞微信运动",
|
||
"clear_cache": "清理微信缓存",
|
||
"check_for_update": "检查微信更新",
|
||
"voice_call": "发起微信语音通话",
|
||
"video_call": "发起微信视频通话",
|
||
"share_real_time_location": "共享实时位置",
|
||
"download_file": "下载微信文件",
|
||
"mass_send": "群发微信消息",
|
||
"batch_send": "批量发送微信消息",
|
||
"reply_comment": "回复朋友圈评论",
|
||
}
|
||
|
||
|
||
def _custom_openapi():
|
||
"""生成中文接口摘要,避免 FastAPI 根据函数名自动生成英文说明。"""
|
||
if app.openapi_schema:
|
||
return app.openapi_schema
|
||
schema = get_openapi(
|
||
title=app.title,
|
||
version=app.version,
|
||
description=app.description,
|
||
routes=app.routes,
|
||
tags=app.openapi_tags,
|
||
)
|
||
routes = {
|
||
(route.path, method.lower()): route
|
||
for route in app.routes
|
||
for method in (getattr(route, "methods", None) or [])
|
||
}
|
||
verification_status = _load_openapi_verification_status()
|
||
for path, path_item in schema.get("paths", {}).items():
|
||
for method, operation in path_item.items():
|
||
route = routes.get((path, method.lower()))
|
||
if route is None:
|
||
continue
|
||
doc = (getattr(route.endpoint, "__doc__", "") or "").strip()
|
||
first_line = doc.splitlines()[0].strip() if doc else ""
|
||
if route.name in _OPENAPI_SUMMARY_ZH:
|
||
operation["summary"] = _OPENAPI_SUMMARY_ZH[route.name]
|
||
elif any("\u4e00" <= char <= "\u9fff" for char in first_line):
|
||
operation["summary"] = first_line.rstrip("。;")
|
||
else:
|
||
operation["summary"] = _OPENAPI_SUMMARY_ZH.get(route.name, "接口说明")
|
||
key = f"{method.upper()} {path}"
|
||
source_status = _route_implementation_status(route)
|
||
implementation_status = verification_status.get(key, {}).get("status")
|
||
if not implementation_status:
|
||
implementation_status = "placeholder" if source_status == "placeholder" else "verification_pending"
|
||
operation["x-implementation-status"] = implementation_status
|
||
operation["x-implementation-label"] = {
|
||
"implemented": "已实现",
|
||
"verification_pending": "待真机验证",
|
||
"placeholder": "未完成",
|
||
}.get(implementation_status, "未完成")
|
||
apply_global_openapi_contract(schema, _GLOBAL_WRITE_MANIFEST)
|
||
app.openapi_schema = schema
|
||
return schema
|
||
|
||
|
||
def _route_implementation_status(route) -> str:
|
||
"""按接口源码自动识别实现状态;显式标记优先于静态判断。"""
|
||
explicit = getattr(route.endpoint, "__implementation_status__", "")
|
||
if explicit in {"implemented", "placeholder"}:
|
||
return explicit
|
||
try:
|
||
source = inspect.getsource(route.endpoint)
|
||
except (OSError, TypeError):
|
||
return "implemented"
|
||
compact = " ".join(source.lower().split())
|
||
doc = (getattr(route.endpoint, "__doc__", "") or "").lower()
|
||
body = compact.split("):", 1)[-1]
|
||
if body.strip() in {"pass", "..."}:
|
||
return "placeholder"
|
||
if ("接口占位" in doc or "空占位" in doc) and "禁止空占位" not in doc:
|
||
return "placeholder"
|
||
fixed_unavailable = (
|
||
"capability_unavailable" in source
|
||
and ('"code": 503' in source or "'code': 503" in source)
|
||
and "await " not in source
|
||
)
|
||
return "placeholder" if fixed_unavailable else "implemented"
|
||
|
||
|
||
def _load_openapi_verification_status() -> dict:
|
||
"""读取真机验收状态;接口只有取得真实设备回读后才允许亮显。"""
|
||
path = os.path.join(_APP_DIR, "data", "openapi_verification_status.json")
|
||
try:
|
||
with open(path, encoding="utf-8") as file:
|
||
data = json.load(file)
|
||
return data.get("operations", {})
|
||
except (OSError, ValueError, TypeError):
|
||
return {}
|
||
|
||
|
||
app.openapi = _custom_openapi
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(app, host="0.0.0.0", port=8899)
|