""" 对外接口统一清单 · 集成中心路由 路由前缀:/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