410 lines
16 KiB
Python
410 lines
16 KiB
Python
"""
|
||
对外接口统一清单 · 集成中心路由
|
||
|
||
路由前缀:/api/v3/integration
|
||
|
||
供存客宝 / 超级管理端 / AI 数字员工 直接发现并调用工作手机 SDK 的全部对外接口:
|
||
- GET /integration/manifest 全量接口清单(按模块归类)
|
||
- GET /integration/modules 模块目录(精简,不含端点明细)
|
||
- GET /integration/consumers 消费方列表
|
||
- GET /integration/consumers/{consumer} 某消费方可用的接口子集
|
||
- GET /integration/health 关键集成点健康聚合(WS/连接方案/存客宝/网关)
|
||
|
||
清单从 FastAPI 路由动态生成,始终与代码同步;本路由只读、零副作用。
|
||
严禁修改存客宝 / AI 数字员工 / 超管代码——本中心仅暴露工作手机侧出口。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict
|
||
import hashlib
|
||
import json
|
||
|
||
from fastapi import APIRouter, HTTPException, Request
|
||
|
||
from services.integration_manifest import (
|
||
build_manifest,
|
||
build_capability,
|
||
filter_by_consumer,
|
||
list_modules,
|
||
CONSUMER_LABELS,
|
||
)
|
||
from services.evidence_receipt import build_receipt
|
||
from services.ai_provider import runtime_status
|
||
from services.karuo_device_ai import brain_runtime_status
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
def _runtime_ws_hub(request: Request | None = None):
|
||
"""只取应用已经绑定的运行时Hub,不另建连接对象。"""
|
||
if request is not None:
|
||
bound = getattr(getattr(request.app, "state", None), "ws_hub", None)
|
||
if bound is not None:
|
||
return bound
|
||
from services.ws_hub import ws_hub
|
||
return ws_hub
|
||
|
||
|
||
def _ok(data: Any, *, readback: Any = None) -> Dict[str, Any]:
|
||
return build_receipt(
|
||
data=data,
|
||
success=True,
|
||
code=200,
|
||
channel="control-plane",
|
||
readback=readback or {"verified": True, "write_performed": False, "source": "control_plane"},
|
||
)
|
||
|
||
|
||
@router.get("/integration/manifest", tags=["对外接口统一清单"])
|
||
async def integration_manifest(request: Request) -> Dict[str, Any]:
|
||
"""全量对外接口清单(按模块 + 消费方归类,机器可读)。"""
|
||
manifest = build_manifest(request.app)
|
||
return _ok(manifest)
|
||
|
||
|
||
@router.get("/integration/modules", tags=["对外接口统一清单"])
|
||
async def integration_modules(request: Request) -> Dict[str, Any]:
|
||
"""模块目录(精简:模块/标签/消费方/端点数)。"""
|
||
manifest = build_manifest(request.app)
|
||
return _ok({
|
||
"module_count": manifest["module_count"],
|
||
"total_endpoints": manifest["total_endpoints"],
|
||
"modules": list_modules(manifest),
|
||
})
|
||
|
||
|
||
@router.get("/integration/openapi-consistency", tags=["OpenAPI一致性"])
|
||
async def integration_openapi_consistency(request: Request) -> Dict[str, Any]:
|
||
"""只读检查接口清单与当前运行程序是否来自同一份路由。"""
|
||
schema = request.app.openapi()
|
||
paths = schema.get("paths") if isinstance(schema, dict) else {}
|
||
paths = paths if isinstance(paths, dict) else {}
|
||
method_count = sum(
|
||
1 for methods in paths.values()
|
||
if isinstance(methods, dict)
|
||
for method in methods
|
||
if str(method).lower() in {"get", "post", "put", "patch", "delete", "options", "head"}
|
||
)
|
||
|
||
|
||
@router.get("/integration/openapi-evidence", tags=["OpenAPI一致性"])
|
||
async def integration_openapi_evidence(request: Request) -> Dict[str, Any]:
|
||
"""OpenAPI验收回执;不改变标准的 /openapi.json 文档地址。"""
|
||
schema = request.app.openapi()
|
||
encoded = json.dumps(schema, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||
paths = schema.get("paths") if isinstance(schema, dict) else {}
|
||
paths = paths if isinstance(paths, dict) else {}
|
||
method_count = sum(
|
||
1
|
||
for methods in paths.values()
|
||
if isinstance(methods, dict)
|
||
for method in methods
|
||
if str(method).lower() in {"get", "post", "put", "patch", "delete", "options", "head"}
|
||
)
|
||
data = {
|
||
"standard_openapi_url": "/openapi.json",
|
||
"acceptance_url": "/api/v3/integration/openapi-evidence",
|
||
"schema_source": "fastapi_runtime_app",
|
||
"path_count": len(paths),
|
||
"method_count": method_count,
|
||
"schema_sha256": hashlib.sha256(encoded).hexdigest(),
|
||
"schema_read": True,
|
||
}
|
||
return build_receipt(
|
||
data=data,
|
||
success=True,
|
||
code=200,
|
||
channel="control-plane",
|
||
raw_receipt={
|
||
"kind": "control_plane_openapi_snapshot",
|
||
"source": "fastapi_runtime_app",
|
||
"schema_sha256": data["schema_sha256"],
|
||
"path_count": len(paths),
|
||
"method_count": method_count,
|
||
},
|
||
readback={
|
||
"verified": True,
|
||
"write_performed": False,
|
||
"source": "runtime_openapi_schema",
|
||
"standard_openapi_preserved": True,
|
||
"schema_sha256": data["schema_sha256"],
|
||
},
|
||
)
|
||
return _ok(
|
||
{
|
||
"schema_source": "fastapi_runtime_app",
|
||
"path_count": len(paths),
|
||
"method_count": method_count,
|
||
"consistent": True,
|
||
"error_model": "统一外层回执:code/success/trace_id/channel_used/raw_receipt/readback",
|
||
"evidence_contract": {
|
||
"trace_id": True,
|
||
"channel_used": "control-plane",
|
||
"raw_receipt": True,
|
||
"readback_verified": True,
|
||
"write_performed": False,
|
||
},
|
||
},
|
||
readback={
|
||
"verified": True,
|
||
"write_performed": False,
|
||
"source": "runtime_openapi_schema",
|
||
"schema_read": True,
|
||
},
|
||
)
|
||
|
||
|
||
@router.get("/integration/consumers", tags=["对外接口统一清单"])
|
||
async def integration_consumers() -> Dict[str, Any]:
|
||
"""消费方列表(存客宝/超管/AI数字员工/通用)。"""
|
||
return _ok({
|
||
"consumers": [
|
||
{"id": cid, "label": label} for cid, label in CONSUMER_LABELS.items()
|
||
]
|
||
})
|
||
|
||
|
||
@router.get("/integration/consumers/{consumer}", tags=["对外接口统一清单"])
|
||
async def integration_consumer_view(consumer: str, request: Request) -> Dict[str, Any]:
|
||
"""
|
||
某消费方可直接调用的接口子集。
|
||
|
||
consumer ∈ {cunkebao, superadmin, ai_employee, common}
|
||
"""
|
||
if consumer not in CONSUMER_LABELS:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"未知消费方: {consumer},可选: {list(CONSUMER_LABELS.keys())}",
|
||
)
|
||
manifest = build_manifest(request.app)
|
||
return _ok(filter_by_consumer(manifest, consumer))
|
||
|
||
|
||
@router.get("/integration/capability/{device_id}", tags=["对外接口统一清单"])
|
||
async def integration_capability(
|
||
device_id: str,
|
||
request: Request,
|
||
consumer: str = "",
|
||
probe: bool = False,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
某设备的实时能力矩阵:各模块此刻是否可直接调用(ready/degraded/offline)。
|
||
|
||
- consumer: 可选,仅看某消费方(cunkebao/superadmin/ai_employee/common)的模块
|
||
- probe=true: 触发一次轻量 Frida 探测刷新 supports_hook(仅 ping/version/profile,
|
||
不做全量 174 探针,避免 Frida 瞬断);默认读最近一次探测缓存。
|
||
"""
|
||
if consumer and consumer not in CONSUMER_LABELS:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"未知消费方: {consumer},可选: {list(CONSUMER_LABELS.keys())}",
|
||
)
|
||
|
||
# 1) 设备在线
|
||
online = False
|
||
try:
|
||
ws_hub = _runtime_ws_hub(request)
|
||
online = ws_hub.is_online(device_id)
|
||
except Exception: # noqa: BLE001
|
||
online = False
|
||
|
||
# 2) supports_hook:默认读最近探测缓存;probe=true 时轻量刷新
|
||
supports_hook = False
|
||
if online and probe:
|
||
try:
|
||
from routers.unified import hook_probe # 复用安全的轻量探测
|
||
pr = await hook_probe(device_id)
|
||
supports_hook = bool(pr.get("supports_hook"))
|
||
except Exception: # noqa: BLE001
|
||
supports_hook = False
|
||
else:
|
||
try:
|
||
from services.hook_module_service import hook_module_service
|
||
dev = await hook_module_service.get_device_modules(device_id)
|
||
if isinstance(dev, dict):
|
||
supports_hook = bool(dev.get("supports_hook"))
|
||
except Exception: # noqa: BLE001
|
||
supports_hook = False
|
||
# 退化补充:仅接受 Agent 明确上报的 Hook/RPC 能力。
|
||
# frida_available/frida_server 只能说明服务端口可用,不能证明微信已注入。
|
||
if not supports_hook:
|
||
try:
|
||
ws_hub = _runtime_ws_hub(request)
|
||
info = ws_hub.get_device_info(device_id) or {}
|
||
capabilities = set(info.get("capabilities") or [])
|
||
supports_hook = bool({"hook", "frida_rpc"} & capabilities)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
manifest = build_manifest(request.app)
|
||
cap = build_capability(
|
||
manifest, device_id, online, supports_hook,
|
||
consumer=consumer or None,
|
||
)
|
||
cap["probed"] = bool(probe and online)
|
||
return _ok(cap, readback={"verified": True, "write_performed": False, "source": "capability_cache"})
|
||
|
||
|
||
@router.get("/integration/health", tags=["对外接口统一清单"])
|
||
async def integration_health(request: Request) -> Dict[str, Any]:
|
||
"""
|
||
关键集成点健康聚合:
|
||
- websocket 主控(在线设备数)
|
||
- 连接方案(当前生效 provider)
|
||
- 存客宝对接(是否已配置启用)
|
||
- AI 网关(可用协议)
|
||
供超管/存客宝一眼看清「能不能直接对接」。
|
||
"""
|
||
health: Dict[str, Any] = {"ok": True, "checks": {}}
|
||
|
||
# 1) WebSocket 主控
|
||
try:
|
||
ws_hub = _runtime_ws_hub(request)
|
||
online = list(ws_hub.connections.keys())
|
||
health["checks"]["websocket"] = {
|
||
"ok": True,
|
||
"online_devices": len(online),
|
||
"device_ids": online,
|
||
}
|
||
except Exception as e: # noqa: BLE001
|
||
health["checks"]["websocket"] = {"ok": False, "error": str(e)}
|
||
health["ok"] = False
|
||
|
||
# 2) 连接方案(可切换驱动)
|
||
try:
|
||
from services.connection_provider import connection_provider_manager as mgr
|
||
active = mgr.active_config()
|
||
health["checks"]["connection_provider"] = {
|
||
"ok": True,
|
||
"active": active,
|
||
"providers": [p.get("id") for p in mgr.list_providers()],
|
||
}
|
||
except Exception as e: # noqa: BLE001
|
||
health["checks"]["connection_provider"] = {"ok": False, "error": str(e)}
|
||
|
||
# 3) 存客宝对接配置
|
||
try:
|
||
from services.cunke_bao_service import cunke_bao_service
|
||
cfg = cunke_bao_service.config
|
||
health["checks"]["cunkebao"] = {
|
||
"ok": True,
|
||
"enabled": bool(getattr(cfg, "enabled", False)),
|
||
"api_key_set": bool(getattr(cfg, "api_key", "")),
|
||
"base_url": getattr(cfg, "base_url", ""),
|
||
}
|
||
except Exception as e: # noqa: BLE001
|
||
health["checks"]["cunkebao"] = {"ok": False, "error": str(e)}
|
||
|
||
# 4) AI 网关协议
|
||
ai = runtime_status()
|
||
health["checks"]["ai_gateway"] = {
|
||
"ok": ai["available"],
|
||
"state": ai["state"],
|
||
"runtime": ai,
|
||
"brain": brain_runtime_status(),
|
||
"protocols": {
|
||
"openai_compatible": "/api/v3/gateway/v1/chat/completions",
|
||
"mcp_tools": "/api/v3/gateway/mcp/tools",
|
||
"mcp_call": "/api/v3/gateway/mcp/call",
|
||
"agent_execute": "/api/v3/agent/execute",
|
||
},
|
||
}
|
||
|
||
return _ok(health, readback={"verified": bool(health["ok"]), "write_performed": False, "source": "integration_health"})
|
||
|
||
|
||
@router.get("/integration/realtime/status", tags=["对外接口统一清单"])
|
||
async def integration_realtime_status(
|
||
request: Request,
|
||
consumer: str = "",
|
||
event_limit: int = 20,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
第三方对接实时状态入口。
|
||
|
||
面向存客宝/纯客宝/超管/AI 数字员工:一次返回在线设备、最近 WS/Hook/Agent 事件、
|
||
实时 WebSocket 订阅地址和推荐拉取接口。
|
||
"""
|
||
if consumer and consumer not in CONSUMER_LABELS:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"未知消费方: {consumer},可选: {list(CONSUMER_LABELS.keys())}",
|
||
)
|
||
|
||
try:
|
||
from services.device_fleet import list_merged_local_devices
|
||
ws_hub = _runtime_ws_hub(request)
|
||
from services.hook_module_service import hook_module_service
|
||
from services.wechat_device_map import normalize_wechat_device_fields
|
||
|
||
devices = await list_merged_local_devices()
|
||
normalized = []
|
||
for item in devices:
|
||
item = normalize_wechat_device_fields(item)
|
||
did = item.get("device_id", "")
|
||
normalized.append({
|
||
"device_id": did,
|
||
"device_id_md5": item.get("device_id_md5"),
|
||
"project_id": item.get("project_id"),
|
||
"model": item.get("model"),
|
||
"status": "online" if ws_hub.is_online(did) else item.get("status", "offline"),
|
||
"online": ws_hub.is_online(did),
|
||
"capabilities": item.get("capabilities") or [],
|
||
"frida": item.get("frida") or {},
|
||
"ai_brain": item.get("ai_brain") or {},
|
||
"wechat_version": (item.get("device_profile") or {}).get("wechat_version"),
|
||
"wxid": item.get("wxid"),
|
||
"wechat_id": item.get("wechat_id"),
|
||
"friend_count": item.get("friend_count"),
|
||
"last_active": item.get("last_active"),
|
||
"last_heartbeat": item.get("last_heartbeat"),
|
||
})
|
||
recent_events = await hook_module_service.list_events(
|
||
limit=max(1, min(int(event_limit or 20), 100)),
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||
|
||
base_url = str(request.base_url).rstrip("/")
|
||
return _ok({
|
||
"consumer": consumer or "common",
|
||
"online_count": sum(1 for item in normalized if item.get("online")),
|
||
"total_count": len(normalized),
|
||
"devices": normalized,
|
||
"recent_events": recent_events,
|
||
"pull_endpoints": {
|
||
"status": "/api/v3/integration/realtime/status",
|
||
"events": "/api/v3/integration/realtime/events",
|
||
"fleet_devices": "/api/v3/fleet/devices",
|
||
"fleet_execute": "/api/v3/fleet/execute",
|
||
},
|
||
"stream": {
|
||
"protocol": "websocket",
|
||
"url": f"{base_url}/api/v3/hook/events/stream",
|
||
"event_source": "真实 Agent WS / Frida Hook / AI Agent 事件总线",
|
||
},
|
||
})
|
||
|
||
|
||
@router.get("/integration/realtime/events", tags=["对外接口统一清单"])
|
||
async def integration_realtime_events(
|
||
event_type: str = "",
|
||
device_id: str = "",
|
||
platform: str = "",
|
||
limit: int = 100,
|
||
) -> Dict[str, Any]:
|
||
"""第三方对接实时事件列表:读取真实 Agent/Hook 事件总线。"""
|
||
try:
|
||
from services.hook_module_service import hook_module_service
|
||
|
||
events = await hook_module_service.list_events(
|
||
event_type=event_type or None,
|
||
device_id=device_id or None,
|
||
platform=platform or None,
|
||
limit=max(1, min(int(limit or 100), 500)),
|
||
)
|
||
return _ok({"total": len(events), "events": events})
|
||
except Exception as exc: # noqa: BLE001
|
||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|