fix: 收口R6聚合与稳定观察

This commit is contained in:
Manus AI
2026-08-10 22:18:38 +08:00
parent 2fdaccac45
commit 52a5aa8f0f
12 changed files with 1399 additions and 50 deletions

View File

@@ -20,6 +20,7 @@ import inspect
import json
import subprocess
import asyncio
import sys
from pathlib import Path
import markdown
@@ -29,6 +30,9 @@ from routers import devices, unified, agent, ai_tasks, adb, experience, projects
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(
@@ -505,6 +509,7 @@ 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()
@@ -625,6 +630,62 @@ Content-Type: application/json
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,
@@ -795,6 +856,181 @@ 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
# ========== 健康检查 ==========
@@ -802,15 +1038,33 @@ app.include_router(kb.router, tags=["内部知识库"])
async def health_check():
"""健康检查"""
from services.adb_device import adb_manager
adb_devices = await adb_manager.async_scan_devices()
return {
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
"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")
@@ -1142,6 +1396,7 @@ def _custom_openapi():
"verification_pending": "待真机验证",
"placeholder": "未完成",
}.get(implementation_status, "未完成")
apply_global_openapi_contract(schema, _GLOBAL_WRITE_MANIFEST)
app.openapi_schema = schema
return schema

View File

@@ -17,6 +17,7 @@ from pydantic import BaseModel, Field
from services.device_id_util import device_id_md5
from services.ws_hub import ws_hub
from services.evidence_receipt import build_receipt
router = APIRouter()
@@ -214,9 +215,7 @@ async def fleet_summary(project_id: str = ""):
for device in devices:
pid = str(device.get("project_id") or "")
by_project[pid] = by_project.get(pid, 0) + 1
return {
"code": 200,
"data": {
data = {
"total": len(devices),
"online": len(online),
"adb": 0,
@@ -224,8 +223,21 @@ async def fleet_summary(project_id: str = ""):
"by_project": by_project,
"device_ids": [d.get("device_id") for d in devices],
"online_device_ids": [d.get("device_id") for d in online],
},
}
return build_receipt(
data=data,
success=True,
code=200,
channel="control-plane",
readback={
"verified": True,
"write_performed": False,
"source": "fleet_summary_control_plane",
"snapshot_mode": "control_plane_snapshot",
"partial": False,
"node_count": len(devices),
},
)
@router.get("/fleet/devices", response_model=dict, responses={503: {"model": FleetOperationReceipt}})
@@ -242,7 +254,20 @@ async def fleet_devices(
capability=capability,
all_online=online_only,
)
return {"code": 200, "data": {"devices": devices, "count": len(devices)}}
return build_receipt(
data={"devices": devices, "count": len(devices)},
success=True,
code=200,
channel="control-plane",
readback={
"verified": True,
"write_performed": False,
"source": "fleet_devices_control_plane",
"snapshot_mode": "control_plane_snapshot",
"partial": False,
"node_count": len(devices),
},
)
@router.post("/fleet/execute", response_model=dict, responses={503: {"model": FleetOperationReceipt}})

View File

@@ -7,6 +7,7 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from typing import Optional, List, Any
import asyncio
@@ -17,6 +18,8 @@ import json
from services.ws_hub import ws_hub
from services.adb_device import adb_manager
from services.device_manager import device_manager
from services.ai_provider import runtime_status
from services.evidence_receipt import build_receipt, aggregate_node_results
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -68,13 +71,35 @@ async def openai_compatible_chat(req: ChatRequest):
if not user_msg:
raise HTTPException(400, "messages 中无 user 消息")
ai_status = runtime_status()
if not ai_status["available"]:
return JSONResponse(
status_code=503,
content=build_receipt(
data={"ai": ai_status, "suggested_action": "先配置AI服务或把任务交给AI任务队列"},
success=False,
code=503,
error_code="ai_backend_unavailable",
error_message=ai_status["fallback"]["message"],
),
)
device_id = req.device_id
if not device_id:
devices = list(ws_hub.connections.keys())
if not devices:
adb_devs = await adb_manager.async_scan_devices()
devices = adb_devs
device_id = devices[0] if devices else None
if not device_id:
return JSONResponse(
status_code=503,
content=build_receipt(
data={"suggested_action": "请明确指定在线设备ID"},
success=False,
code=503,
error_code="device_offline",
error_message="没有可用的在线设备",
retryable=True,
),
)
result_text = ""
executed_steps = []
@@ -510,11 +535,19 @@ async def fleet_broadcast(req: FleetCommandRequest):
target_ids = req.device_ids
if not target_ids:
target_ids = list(ws_hub.connections.keys())
adb_devs = await adb_manager.async_scan_devices()
target_ids += [s for s in adb_devs if s not in target_ids]
if not target_ids:
return {"code": 200, "data": {"results": [], "message": "无在线设备"}}
return JSONResponse(
status_code=503,
content=build_receipt(
data={"results": [], "message": "无在线设备"},
success=False,
code=503,
error_code="device_offline",
error_message="没有在线设备可执行这次批量任务",
retryable=True,
),
)
results = []
for did in target_ids:
@@ -524,26 +557,23 @@ async def fleet_broadcast(req: FleetCommandRequest):
"type": "execute",
"data": {"action": req.command, "params": req.params}
}, timeout=15)
results.append({"device_id": did, "success": True, "result": resp})
result_ok = isinstance(resp, dict) and resp.get("success", True) is True
results.append({"device_id": did, "success": result_ok, "result": resp, "readback": (resp or {}).get("readback") if isinstance(resp, dict) else None})
else:
adb_dev = adb_manager.get_device(did)
if adb_dev:
results.append({"device_id": did, "success": True, "channel": "adb"})
else:
results.append({"device_id": did, "success": False, "error": "设备不在线"})
results.append({"device_id": did, "success": False, "error_code": "device_offline", "error": "设备不在线"})
except Exception as e:
results.append({"device_id": did, "success": False, "error": str(e)})
success_count = len([r for r in results if r["success"]])
return {
"code": 200,
"data": {
"total": len(results),
"success": success_count,
"failed": len(results) - success_count,
"results": results
}
}
aggregated = aggregate_node_results(results)
return build_receipt(
data={"results": results, **aggregated},
success=aggregated["status"] != "failed",
code=200 if aggregated["status"] != "failed" else 503,
readback={"verified": aggregated["verified"], "write_performed": False, "source": "websocket_fleet"},
error_code="fleet_partial_failure" if aggregated["status"] == "partial_success" else None,
error_message="部分设备完成失败设备请按items逐台处理" if aggregated["status"] == "partial_success" else None,
retryable=aggregated["status"] in {"partial_success", "failed"},
)
@router.post("/gateway/fleet/group", tags=["外部对接网关"])

View File

@@ -17,6 +17,8 @@
from __future__ import annotations
from typing import Any, Dict
import hashlib
import json
from fastapi import APIRouter, HTTPException, Request
@@ -27,36 +29,140 @@ from services.integration_manifest import (
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 {"code": 200, "data": manifest}
return _ok(manifest)
@router.get("/integration/modules", tags=["对外接口统一清单"])
async def integration_modules(request: Request) -> Dict[str, Any]:
"""模块目录(精简:模块/标签/消费方/端点数)。"""
manifest = build_manifest(request.app)
return {"code": 200, "data": {
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 {"code": 200, "data": {
return _ok({
"consumers": [
{"id": cid, "label": label} for cid, label in CONSUMER_LABELS.items()
]
}}
})
@router.get("/integration/consumers/{consumer}", tags=["对外接口统一清单"])
@@ -72,7 +178,7 @@ async def integration_consumer_view(consumer: str, request: Request) -> Dict[str
detail=f"未知消费方: {consumer},可选: {list(CONSUMER_LABELS.keys())}",
)
manifest = build_manifest(request.app)
return {"code": 200, "data": filter_by_consumer(manifest, consumer)}
return _ok(filter_by_consumer(manifest, consumer))
@router.get("/integration/capability/{device_id}", tags=["对外接口统一清单"])
@@ -98,7 +204,7 @@ async def integration_capability(
# 1) 设备在线
online = False
try:
from services.ws_hub import ws_hub
ws_hub = _runtime_ws_hub(request)
online = ws_hub.is_online(device_id)
except Exception: # noqa: BLE001
online = False
@@ -124,7 +230,7 @@ async def integration_capability(
# frida_available/frida_server 只能说明服务端口可用,不能证明微信已注入。
if not supports_hook:
try:
from services.ws_hub import ws_hub
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)
@@ -137,7 +243,7 @@ async def integration_capability(
consumer=consumer or None,
)
cap["probed"] = bool(probe and online)
return {"code": 200, "data": cap}
return _ok(cap, readback={"verified": True, "write_performed": False, "source": "capability_cache"})
@router.get("/integration/health", tags=["对外接口统一清单"])
@@ -154,7 +260,7 @@ async def integration_health(request: Request) -> Dict[str, Any]:
# 1) WebSocket 主控
try:
from services.ws_hub import ws_hub
ws_hub = _runtime_ws_hub(request)
online = list(ws_hub.connections.keys())
health["checks"]["websocket"] = {
"ok": True,
@@ -191,8 +297,12 @@ async def integration_health(request: Request) -> Dict[str, Any]:
health["checks"]["cunkebao"] = {"ok": False, "error": str(e)}
# 4) AI 网关协议
ai = runtime_status()
health["checks"]["ai_gateway"] = {
"ok": True,
"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",
@@ -201,7 +311,7 @@ async def integration_health(request: Request) -> Dict[str, Any]:
},
}
return {"code": 200, "data": health}
return _ok(health, readback={"verified": bool(health["ok"]), "write_performed": False, "source": "integration_health"})
@router.get("/integration/realtime/status", tags=["对外接口统一清单"])
@@ -224,7 +334,7 @@ async def integration_realtime_status(
try:
from services.device_fleet import list_merged_local_devices
from services.ws_hub import ws_hub
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
@@ -257,9 +367,7 @@ async def integration_realtime_status(
raise HTTPException(status_code=500, detail=str(exc)) from exc
base_url = str(request.base_url).rstrip("/")
return {
"code": 200,
"data": {
return _ok({
"consumer": consumer or "common",
"online_count": sum(1 for item in normalized if item.get("online")),
"total_count": len(normalized),
@@ -276,8 +384,7 @@ async def integration_realtime_status(
"url": f"{base_url}/api/v3/hook/events/stream",
"event_source": "真实 Agent WS / Frida Hook / AI Agent 事件总线",
},
},
}
})
@router.get("/integration/realtime/events", tags=["对外接口统一清单"])
@@ -297,6 +404,6 @@ async def integration_realtime_events(
platform=platform or None,
limit=max(1, min(int(limit or 100), 500)),
)
return {"code": 200, "data": {"total": len(events), "events": events}}
return _ok({"total": len(events), "events": events})
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(exc)) from exc

View File

@@ -3,10 +3,22 @@
from fastapi import APIRouter
from services.workbench import workbench_service
from services.evidence_receipt import build_receipt
router = APIRouter(prefix="/workbench", tags=["总控平台总览"])
@router.get("/overview", response_model=dict)
async def get_workbench_overview() -> dict:
return {"code": 200, "success": True, "data": await workbench_service.overview()}
data = await workbench_service.overview()
return build_receipt(
data=data,
success=True,
code=200,
readback={
"verified": data.get("data_state") == "sampled",
"write_performed": False,
"source": "workbench_aggregation",
"partial": data.get("data_state") != "sampled",
},
)

View File

@@ -0,0 +1,640 @@
#!/usr/bin/env python3
"""FULL55 最终真机验收脚本。
这个脚本只负责按最终证据规则分批验收不修改路由、Hook 或 Agent。
安全默认值:
* 默认只读模式;只读模块每个连续调用两次。
* dry-run 模式只带 dry_run=true、confirm=false、retry=0。
* write 模式必须同时提供 --confirm-writes 和独立白名单夹具。
* 所有请求都不自动重试,所有回执都保存 trace、通道、原始回执和业务回读摘要。
真实设备调用由本脚本的 HTTP 传输层承担;本文件本身不在导入或测试时发起请求。
"""
from __future__ import annotations
import argparse
import base64
import copy
import csv
import hashlib
import html
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Any, Callable, Iterable, Mapping, Protocol
ROOT = Path(__file__).resolve().parents[2]
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
DEFAULT_BASE_URL = "http://open.quwanzhi.com:8899"
DEFAULT_DEVICE_ID = "3c2d803e58f2c30a744234484c4e393e"
DEFAULT_AUDIT_CSV = (
ROOT
/ "开发文档/10、项目管理/02-测试报告/20260810_FULL55_REPAIR/23_最终证据审计/证据缺口清单.csv"
)
DEFAULT_EVIDENCE_DIR = (
ROOT / "开发文档/10、项目管理/02-测试报告/20260810_FULL55_REPAIR/26_最终验收脚本"
)
READONLY_CONCLUSION = "需补只读"
DRY_RUN_CONCLUSION = "需补干跑"
WRITE_CONCLUSION = "需真实写验收"
VALID_CONCLUSIONS = {READONLY_CONCLUSION, DRY_RUN_CONCLUSION, WRITE_CONCLUSION}
class AcceptanceConfigError(ValueError):
"""验收参数或夹具不符合安全门禁。"""
class Transport(Protocol):
def request(
self,
method: str,
path: str,
payload: Mapping[str, Any] | None,
*,
trace_id: str,
) -> dict[str, Any]:
"""返回 status、body、elapsed_ms不得在此层自动重试。"""
def _load_module_specs() -> dict[int, dict[str, Any]]:
"""复用已有只读/干跑调用清单,避免复制另一套接口路径。"""
try:
from full55_device_qa import MODULES # type: ignore
except ImportError as exc: # pragma: no cover - 仅用于损坏安装的清晰报错
raise AcceptanceConfigError(f"找不到现有FULL55调用清单: {exc}") from exc
# 第52项验收不能把标准文档页当作带证据回执的业务接口
# 标准 /openapi.json 保持原样,验收脚本改走专用只读回执入口。
specs = copy.deepcopy(MODULES)
specs[52] = {
**specs[52],
"calls": [
("GET", "/api/v3/integration/openapi-evidence", "readonly", {}),
],
}
return specs
def load_audit_rows(path: Path = DEFAULT_AUDIT_CSV) -> list[dict[str, str]]:
"""读取审计清单并检查编号必须连续覆盖1到55。"""
with path.open(encoding="utf-8-sig", newline="") as handle:
rows = list(csv.DictReader(handle))
if len(rows) != 55:
raise AcceptanceConfigError(f"审计清单应有55项实际{len(rows)}")
numbers = [int(row["编号"]) for row in rows]
if numbers != list(range(1, 56)):
raise AcceptanceConfigError("审计清单编号不是连续的1到55")
unknown = {row["审计结论"] for row in rows} - VALID_CONCLUSIONS
if unknown:
raise AcceptanceConfigError(f"审计清单出现未知结论: {sorted(unknown)}")
return rows
def audit_index(rows: Iterable[Mapping[str, str]]) -> dict[int, dict[str, str]]:
return {int(row["编号"]): dict(row) for row in rows}
def selected_ids(value: str | None, *, allowed: set[int]) -> list[int]:
"""解析 1,2,5-8不传时返回允许的全部编号。"""
if not value or value.strip().lower() == "all":
return sorted(allowed)
result: set[int] = set()
for part in value.split(","):
token = part.strip()
if not token:
continue
if "-" in token:
start_text, end_text = token.split("-", 1)
start, end = int(start_text), int(end_text)
if start > end:
raise AcceptanceConfigError(f"模块范围无效: {token}")
result.update(range(start, end + 1))
else:
result.add(int(token))
unknown = result - allowed
if unknown:
raise AcceptanceConfigError(f"所选模块不属于当前模式: {sorted(unknown)}")
return sorted(result)
def redact(value: Any) -> Any:
"""保存证据前脱敏,避免把手机号、令牌和密钥写入报告。"""
if isinstance(value, dict):
result: dict[str, Any] = {}
for key, item in value.items():
if re.search(r"password|token|secret|api[_-]?key|phone|mobile|authorization|base64|image_data|screenshot_data", str(key), re.I):
result[key] = "[已脱敏]"
else:
result[key] = redact(item)
return result
if isinstance(value, list):
return [redact(item) for item in value]
return value
def _nested_dict(value: Any, *keys: str) -> dict[str, Any]:
for key in keys:
if isinstance(value, dict) and isinstance(value.get(key), dict):
return value[key]
return {}
def extract_evidence(body: Any) -> dict[str, Any]:
"""从不同层级回执中统一提取最终验收所需的七类证据。"""
if not isinstance(body, dict):
return {
"trace_id": None,
"channel_used": None,
"raw_rpc_receipt": None,
"readback": {},
"readback_verified": False,
"write_performed": None,
}
data = body.get("data") if isinstance(body.get("data"), dict) else {}
raw = (
body.get("raw_rpc_receipt")
or body.get("raw_receipt")
or body.get("raw")
or data.get("raw_rpc_receipt")
or data.get("raw_receipt")
or data.get("raw")
)
readback = (
body.get("readback")
if isinstance(body.get("readback"), dict)
else data.get("readback")
if isinstance(data.get("readback"), dict)
else {}
)
trace = body.get("trace_id") or body.get("trace") or data.get("trace_id") or data.get("trace")
channel = (
body.get("channel_used")
or body.get("channel")
or data.get("channel_used")
or data.get("channel")
)
verified = (
readback.get("verified") is True
or readback.get("verified_no_write") is True
or body.get("readback_verified") is True
or body.get("verified") is True
or data.get("verified") is True
)
write_performed = body.get("write_performed", data.get("write_performed"))
return {
"trace_id": trace,
"channel_used": channel,
"raw_rpc_receipt": raw,
"readback": readback,
"readback_verified": verified,
"write_performed": write_performed,
}
def evidence_complete(result: Mapping[str, Any], *, require_write: bool = False) -> bool:
"""判断单次结果是否闭合HTTP200之外不提升为成功。"""
ev = extract_evidence(result.get("body", result))
if result.get("http_status") != 200:
return False
if not ev["trace_id"] or not ev["channel_used"] or not ev["raw_rpc_receipt"]:
return False
if not ev["readback"] or not ev["readback_verified"]:
return False
if require_write and ev["write_performed"] is not True:
return False
return True
def _make_controls(payload: Mapping[str, Any], *, mode: str, trace_id: str, key: str) -> dict[str, Any]:
body = dict(payload)
if mode == "dry-run":
body.update({"dry_run": True, "confirm": False, "retry": 0, "idempotency_key": key, "trace_id": trace_id})
elif mode == "write":
body.update({"dry_run": False, "confirm": True, "retry": 0, "idempotency_key": key, "trace_id": trace_id})
elif mode == "confirm-false":
body.update({"dry_run": False, "confirm": False, "retry": 0, "idempotency_key": key, "trace_id": trace_id})
else:
body.setdefault("trace_id", trace_id)
return body
class HttpTransport:
"""直连HTTP传输关闭代理、关闭自动重试方便追溯每一次请求。"""
def __init__(self, base_url: str, *, timeout: float = 25.0):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def request(
self,
method: str,
path: str,
payload: Mapping[str, Any] | None,
*,
trace_id: str,
) -> dict[str, Any]:
method = method.upper()
query: dict[str, Any] = {}
body: bytes | None = None
if method == "GET":
query = dict(payload or {})
else:
body = json.dumps(payload or {}, ensure_ascii=False).encode("utf-8")
url = self.base_url + path
if query:
url += "?" + urllib.parse.urlencode(query, doseq=True)
request = urllib.request.Request(url, method=method, data=body, headers={"Accept": "application/json", "X-FULL55-Trace": trace_id})
if body is not None:
request.add_header("Content-Type", "application/json")
started = time.monotonic()
try:
with self.opener.open(request, timeout=self.timeout) as response:
raw = response.read()
status = response.status
except urllib.error.HTTPError as exc:
raw = exc.read()
status = exc.code
except Exception as exc: # 网络错误也保存为可追溯结果
return {"http_status": None, "body": {"error": f"{type(exc).__name__}: {exc}"}, "elapsed_ms": round((time.monotonic() - started) * 1000, 1)}
try:
parsed: Any = json.loads(raw.decode("utf-8", "replace"))
except Exception:
parsed = {"_text": raw.decode("utf-8", "replace")[:4000]}
return {"http_status": status, "body": parsed, "elapsed_ms": round((time.monotonic() - started) * 1000, 1)}
def _path_for(spec: Any, device_id: str, group_id: str | None = None) -> str:
return str(spec).replace("{device_id}", device_id).replace("{group_id}", group_id or "FULL55_NO_GROUP_FIXTURE")
def _resolve_placeholders(value: Any, device_id: str, group_id: str | None = None) -> Any:
"""递归替换调用清单里的设备/群占位符,避免把花括号原样发到正式环境。"""
if isinstance(value, dict):
return {key: _resolve_placeholders(item, device_id, group_id) for key, item in value.items()}
if isinstance(value, list):
return [_resolve_placeholders(item, device_id, group_id) for item in value]
if isinstance(value, tuple):
return tuple(_resolve_placeholders(item, device_id, group_id) for item in value)
if isinstance(value, str):
return _path_for(value, device_id, group_id)
return value
def _write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(redact(value), ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
fields = [
"module_id", "module", "mode", "round", "stage", "method", "path", "http_status",
"trace_id", "channel_used", "raw_present", "readback_verified", "write_performed", "passed", "reason",
]
with path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fields)
writer.writeheader()
for row in rows:
writer.writerow({field: row.get(field, "") for field in fields})
def _write_html(path: Path, summary: Mapping[str, Any], rows: list[dict[str, Any]]) -> None:
table = []
for row in rows:
table.append(
"<tr>"
+ "".join(f"<td>{html.escape(str(row.get(key, '')))}</td>" for key in ("module_id", "module", "mode", "round", "stage", "http_status", "passed", "reason"))
+ "</tr>"
)
headings = "".join(f"<th>{html.escape(k)}</th>" for k in ("module_id", "module", "mode", "round", "stage", "http_status", "passed", "reason"))
body = "\n".join(table)
path.write_text(
"<!doctype html><meta charset='utf-8'><title>FULL55最终验收</title>"
"<style>body{font-family:system-ui, sans-serif;margin:24px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:6px;text-align:left}th{background:#f4f4f4}.ok{color:green}</style>"
f"<h1>FULL55最终验收汇总</h1><pre>{html.escape(json.dumps(summary, ensure_ascii=False, indent=2))}</pre>"
f"<table><thead><tr>{headings}</tr></thead><tbody>{body}</tbody></table>",
encoding="utf-8",
)
def _find_image_bytes(value: Any) -> bytes | None:
"""从截图回执中找PNG/JPEG内容找不到时保留结构化等价证据。"""
if isinstance(value, dict):
for key, item in value.items():
if isinstance(item, str) and re.search(r"png|image|screenshot|base64", str(key), re.I):
text = item.split(",", 1)[1] if item.startswith("data:image/") and "," in item else item
try:
decoded = base64.b64decode(text, validate=False)
except Exception:
decoded = b""
if decoded.startswith(b"\x89PNG\r\n\x1a\n") or decoded.startswith(b"\xff\xd8\xff"):
return decoded
found = _find_image_bytes(item)
if found:
return found
elif isinstance(value, list):
for item in value:
found = _find_image_bytes(item)
if found:
return found
return None
def _save_screenshot(evidence_dir: Path, module_id: int, body: Any) -> dict[str, Any]:
image = _find_image_bytes(body)
if not image:
return {"screenshot_present": False, "screenshot_equivalent": evidence_complete({"http_status": 200, "body": body})}
path = evidence_dir / f"screenshot_{module_id:02d}.png"
path.write_bytes(image)
return {"screenshot_present": True, "screenshot_path": str(path), "bytes": len(image), "sha256": hashlib.sha256(image).hexdigest()}
def _parse_fixture(path: Path, module_ids: list[int]) -> dict[int, dict[str, Any]]:
try:
fixture = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
raise AcceptanceConfigError(f"夹具不是有效JSON: {exc}") from exc
if fixture.get("independent_allowlist") is not True:
raise AcceptanceConfigError("夹具必须明确 independent_allowlist=true")
if not fixture.get("fixture_id"):
raise AcceptanceConfigError("夹具缺少fixture_id")
modules = fixture.get("modules")
if not isinstance(modules, dict):
raise AcceptanceConfigError("夹具缺少modules对象")
selected: dict[int, dict[str, Any]] = {}
keys: list[str] = []
for module_id in module_ids:
item = modules.get(str(module_id))
if not isinstance(item, dict):
raise AcceptanceConfigError(f"夹具没有模块{module_id}的独立配置")
if not item.get("operations"):
raise AcceptanceConfigError(f"模块{module_id}没有operations")
if not isinstance(item.get("targets"), list) or not item.get("targets"):
raise AcceptanceConfigError(f"模块{module_id}没有targets白名单")
if not item.get("readback"):
raise AcceptanceConfigError(f"模块{module_id}没有业务回读定义")
for operation in item["operations"]:
key = operation.get("idempotency_key")
if not key:
raise AcceptanceConfigError(f"模块{module_id}存在空幂等键")
keys.append(str(key))
selected_item = dict(item)
selected_item["_fixture_id"] = fixture["fixture_id"]
selected[module_id] = selected_item
if len(keys) != len(set(keys)):
raise AcceptanceConfigError("夹具内幂等键重复")
return selected
def _module_calls(specs: Mapping[int, Mapping[str, Any]], module_id: int, mode: str) -> list[tuple[str, str, str, dict[str, Any]]]:
calls = list(specs[module_id].get("calls", []))
if mode == "readonly":
calls = [call for call in calls if call[2] == "readonly"]
elif mode == "dry-run":
calls = [call for call in calls if call[2] == "dry_run"]
if not calls:
raise AcceptanceConfigError(f"模块{module_id}{mode}模式没有可执行调用")
return calls
class AcceptanceRunner:
def __init__(
self,
*,
transport: Transport,
audit_rows: list[dict[str, str]],
specs: Mapping[int, Mapping[str, Any]],
device_id: str,
evidence_dir: Path,
mode: str,
module_ids: list[int],
confirm_writes: bool = False,
fixture_modules: dict[int, dict[str, Any]] | None = None,
):
self.transport = transport
self.audit = audit_index(audit_rows)
self.specs = specs
self.device_id = device_id
self.evidence_dir = evidence_dir
self.mode = mode
self.module_ids = module_ids
self.confirm_writes = confirm_writes
self.fixture_modules = fixture_modules or {}
self.rows: list[dict[str, Any]] = []
self.last_body: Any = None
self.started_at = time.time()
def _call(
self,
module_id: int,
module_name: str,
stage: str,
round_number: int,
method: str,
path: str,
payload: Mapping[str, Any] | None,
*,
request_mode: str,
key: str | None = None,
require_write: bool = False,
) -> dict[str, Any]:
trace = f"full55-final-{module_id:02d}-{stage}-{round_number}-{uuid.uuid4().hex[:12]}"
body = _resolve_placeholders(dict(payload or {}), self.device_id)
if request_mode in {"dry-run", "write", "confirm-false"}:
body = _make_controls(body, mode=request_mode, trace_id=trace, key=key or f"full55-{uuid.uuid4().hex}")
elif request_mode == "readonly":
body.setdefault("device_id", self.device_id)
body.setdefault("trace_id", trace)
resolved_path = _path_for(path, self.device_id)
result = self.transport.request(method, resolved_path, body, trace_id=trace)
self.last_body = result.get("body")
ev = extract_evidence(result.get("body"))
passed = evidence_complete(result, require_write=require_write)
# confirm=false 的拦截本来就应返回4xx没有发生写入即可视为门禁通过。
if stage.startswith("confirm_gate"):
passed = result.get("http_status") in {400, 409, 422} and ev.get("write_performed") is not True
row = {
"module_id": module_id,
"module": module_name,
"mode": self.mode,
"round": round_number,
"stage": stage,
"method": method,
"path": resolved_path,
"http_status": result.get("http_status"),
"trace_id": ev.get("trace_id") or trace,
"channel_used": ev.get("channel_used"),
"raw_present": bool(ev.get("raw_rpc_receipt")),
"readback_verified": bool(ev.get("readback_verified")),
"write_performed": ev.get("write_performed"),
"passed": passed,
"reason": "证据齐全" if passed else "缺HTTP/trace/channel/raw/readback/verified中的一项或多项",
"elapsed_ms": result.get("elapsed_ms"),
"response": redact(result.get("body")),
}
self.rows.append(row)
_write_json(self.evidence_dir / f"module_{module_id:02d}_{stage}_{round_number}.json", row)
return row
def _run_readonly_or_dry_run(self, module_id: int) -> None:
item = self.audit[module_id]
module_name = item["模块"]
request_mode = "readonly" if self.mode == "readonly" else "dry-run"
for round_number in (1, 2):
for call_index, (method, path, _call_mode, payload) in enumerate(_module_calls(self.specs, module_id, self.mode), start=1):
self._call(
module_id,
module_name,
f"{request_mode}_{call_index}",
round_number,
method,
path,
payload,
request_mode=request_mode,
key=f"full55-{request_mode}-{module_id}-{call_index}-{round_number}-{uuid.uuid4().hex}",
require_write=False,
)
def _run_write_module(self, module_id: int) -> None:
item = self.audit[module_id]
module_name = item["模块"]
fixture = self.fixture_modules[module_id]
for op_index, operation in enumerate(fixture["operations"], start=1):
method = str(operation.get("method", "POST")).upper()
path = str(operation["path"])
payload = dict(operation.get("payload", {}))
key = str(operation.get("idempotency_key") or f"full55-write-{module_id}-{op_index}-{uuid.uuid4().hex}")
self._call(module_id, module_name, f"dry_run_{op_index}", op_index, method, path, payload, request_mode="dry-run", key=key, require_write=False)
self._call(module_id, module_name, f"confirm_gate_{op_index}", op_index, method, path, payload, request_mode="confirm-false", key=f"{key}:gate", require_write=False)
self._call(module_id, module_name, f"confirm_once_{op_index}", op_index, method, path, payload, request_mode="write", key=key, require_write=True)
replay = self._call(module_id, module_name, f"idempotency_replay_{op_index}", op_index, method, path, payload, request_mode="write", key=key, require_write=True)
replay["replay_required"] = True
replay["replay_semantics"] = "必须是同键同参复用,不得产生第二次业务写"
readback = fixture["readback"]
self._call(
module_id,
module_name,
f"business_readback_{op_index}",
op_index,
str(readback.get("method", "GET")).upper(),
str(readback["path"]),
dict(readback.get("payload", {})),
request_mode="readonly",
require_write=False,
)
# 一个写模块一张截图;截图失败会被记录,不会自动重试。
screenshot = fixture.get("screenshot", {"method": "POST", "path": f"/api/v3/devices/{self.device_id}/screenshot", "payload": {}})
shot = self._call(module_id, module_name, "screenshot", 1, str(screenshot.get("method", "POST")), str(screenshot["path"]), dict(screenshot.get("payload", {})), request_mode="readonly")
shot.update(_save_screenshot(self.evidence_dir, module_id, self.last_body))
shot["screenshot_required"] = True
_write_json(self.evidence_dir / f"module_{module_id:02d}_screenshot_1.json", shot)
def run(self) -> dict[str, Any]:
self.evidence_dir.mkdir(parents=True, exist_ok=True)
for module_id in self.module_ids:
if self.mode == "write":
self._run_write_module(module_id)
else:
self._run_readonly_or_dry_run(module_id)
summary = {
"script": "full55_final_device_acceptance",
"mode": self.mode,
"device_calls_allowed_by_script": True,
"real_write_allowed": self.mode == "write" and self.confirm_writes,
"retry": 0,
"selected_modules": self.module_ids,
"selected_count": len(self.module_ids),
"evidence_rule": {"readonly_rounds": 2, "dry_run_confirm": False, "write_confirm_count": 1, "idempotency_replay_count": 1},
"fixture_id": self.fixture_modules and sorted({str(v.get("_fixture_id", "")) for v in self.fixture_modules.values()}) or None,
"started_at": self.started_at,
"finished_at": time.time(),
"record_count": len(self.rows),
"passed_records": sum(bool(row["passed"]) for row in self.rows),
"failed_records": sum(not bool(row["passed"]) for row in self.rows),
}
_write_json(self.evidence_dir / "full55_final_acceptance.json", {"summary": summary, "records": self.rows})
_write_csv(self.evidence_dir / "full55_final_acceptance.csv", self.rows)
_write_html(self.evidence_dir / "full55_final_acceptance.html", summary, self.rows)
return {"summary": summary, "records": self.rows}
def _validate_selection(mode: str, module_ids: list[int], rows: list[dict[str, str]], confirm_writes: bool, fixture_file: Path | None) -> dict[int, dict[str, Any]] | None:
index = audit_index(rows)
expected = {
"readonly": {n for n, row in index.items() if row["审计结论"] == READONLY_CONCLUSION},
"dry-run": {n for n, row in index.items() if row["审计结论"] in {DRY_RUN_CONCLUSION, WRITE_CONCLUSION}},
"write": {n for n, row in index.items() if row["审计结论"] == WRITE_CONCLUSION},
}[mode]
if not set(module_ids) <= expected:
raise AcceptanceConfigError(f"{mode}模式允许模块{sorted(expected)},实际选择{module_ids}")
if mode != "write" and confirm_writes:
raise AcceptanceConfigError("只有write模式可以使用--confirm-writes")
if mode == "write":
if not confirm_writes:
raise AcceptanceConfigError("write模式必须显式提供--confirm-writes")
if fixture_file is None:
raise AcceptanceConfigError("write模式必须提供--fixture-file")
return _parse_fixture(fixture_file, module_ids)
return None
def run_acceptance(
*,
mode: str = "readonly",
modules: str | None = None,
evidence_dir: Path = DEFAULT_EVIDENCE_DIR,
base_url: str = DEFAULT_BASE_URL,
device_id: str = DEFAULT_DEVICE_ID,
audit_csv: Path = DEFAULT_AUDIT_CSV,
confirm_writes: bool = False,
fixture_file: Path | None = None,
transport: Transport | None = None,
) -> dict[str, Any]:
if mode not in {"readonly", "dry-run", "write"}:
raise AcceptanceConfigError(f"不支持模式: {mode}")
rows = load_audit_rows(audit_csv)
index = audit_index(rows)
allowed = {n for n, row in index.items() if row["审计结论"] == READONLY_CONCLUSION} if mode == "readonly" else ({n for n, row in index.items() if row["审计结论"] in {DRY_RUN_CONCLUSION, WRITE_CONCLUSION}} if mode == "dry-run" else {n for n, row in index.items() if row["审计结论"] == WRITE_CONCLUSION})
module_ids = selected_ids(modules, allowed=allowed)
fixture_modules = _validate_selection(mode, module_ids, rows, confirm_writes, fixture_file)
runner = AcceptanceRunner(transport=transport or HttpTransport(base_url), audit_rows=rows, specs=_load_module_specs(), device_id=device_id, evidence_dir=evidence_dir, mode=mode, module_ids=module_ids, confirm_writes=confirm_writes, fixture_modules=fixture_modules)
return runner.run()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="FULL55最终真机验收默认只读写入需要双重开关")
parser.add_argument("--mode", choices=("readonly", "dry-run", "write"), default="readonly", help="readonly默认连续两次dry-run只零写write受夹具和显式开关保护")
parser.add_argument("--modules", help="模块编号如1,2,5-8不填则选择当前模式全部模块")
parser.add_argument("--evidence-dir", type=Path, default=DEFAULT_EVIDENCE_DIR, help="证据输出目录")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="SDK地址")
parser.add_argument("--device-id", default=DEFAULT_DEVICE_ID, help="设备编号")
parser.add_argument("--audit-csv", type=Path, default=DEFAULT_AUDIT_CSV, help="最终证据缺口清单")
parser.add_argument("--confirm-writes", action="store_true", help="write模式的第二道开关")
parser.add_argument("--fixture-file", type=Path, help="write模式的独立白名单夹具JSON")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = run_acceptance(mode=args.mode, modules=args.modules, evidence_dir=args.evidence_dir, base_url=args.base_url, device_id=args.device_id, audit_csv=args.audit_csv, confirm_writes=args.confirm_writes, fixture_file=args.fixture_file)
except AcceptanceConfigError as exc:
print(f"参数/夹具门禁失败:{exc}", file=sys.stderr)
return 2
print(json.dumps(result["summary"], ensure_ascii=False, indent=2))
return 0 if result["summary"]["failed_records"] == 0 else 1
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())

View File

@@ -0,0 +1,122 @@
"""R6聚合接口共享运行时 ws_hub稳定观察复用正式 Hook 探针。"""
from __future__ import annotations
import asyncio
import sys
import types
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
APP_ROOT = ROOT / "app"
SCRIPT_ROOT = ROOT / "scripts"
for path in (APP_ROOT, SCRIPT_ROOT):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
import main # noqa: E402
from full55_final_device_acceptance import _load_module_specs # noqa: E402
def test_runtime_hub_binds_route_modules_to_live_instance(monkeypatch):
original = main.ws_hub
original_modules = {}
for name in (
"services.ws_hub",
"app.services.ws_hub",
"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",
):
module = sys.modules.get(name)
if module is not None and hasattr(module, "ws_hub"):
original_modules[name] = getattr(module, "ws_hub")
live = types.SimpleNamespace(connections={"DEVICE": object()})
duplicate_module = types.SimpleNamespace(ws_hub=live)
monkeypatch.setitem(sys.modules, "app.services.ws_hub", duplicate_module)
try:
selected = main._bind_shared_runtime_ws_hub()
assert selected is live
assert main.ws_hub is live
assert duplicate_module.ws_hub is live
unified = sys.modules.get("routers.unified")
if unified is not None and hasattr(unified, "ws_hub"):
assert unified.ws_hub is live
finally:
main.ws_hub = original
for name, value in original_modules.items():
module = sys.modules.get(name)
if module is not None:
module.ws_hub = value
def _stability_payload():
return {
"code": 200,
"data": {
"samples": [{
"ws_online": True,
"heartbeat_stale": False,
"hook_ok": False,
}],
"summary": {"total": 1, "ok": 0, "hook_required": True},
},
}
def test_stability_uses_formal_hook_probe_and_promotes_verified(monkeypatch):
async def fake_probe(device_id):
return {
"code": 200,
"trace_id": "probe-trace",
"supports_hook": True,
"frida_available_reported": True,
"hook_tests": {"connect": "ok"},
"profile": {"success": True, "wxid": "WXID"},
"raw_rpc_receipt": {"rpc": "probe", "device_id": device_id},
}
unified = sys.modules.get("routers.unified")
assert unified is not None
monkeypatch.setattr(unified, "hook_probe", fake_probe)
result = asyncio.run(main._refresh_stability_hook_probe(_stability_payload(), "DEVICE"))
sample = result["data"]["samples"][0]
assert sample["hook_ok"] is True
assert result["readback"]["verified"] is True
assert result["success"] is True
assert result["data"]["hook_probe"]["readback"]["verified"] is True
def test_stability_keeps_hook_failure_as_failure(monkeypatch):
async def fake_probe(_device_id):
return {
"code": 503,
"supports_hook": False,
"frida_available_reported": False,
"hook_tests": {"connect": "failed"},
"profile": {},
}
unified = sys.modules.get("routers.unified")
assert unified is not None
monkeypatch.setattr(unified, "hook_probe", fake_probe)
result = asyncio.run(main._refresh_stability_hook_probe(_stability_payload(), "DEVICE"))
sample = result["data"]["samples"][0]
assert sample["hook_ok"] is False
assert result["readback"]["verified"] is False
assert result["success"] is False
def test_module_52_uses_evidence_endpoint_without_changing_standard_openapi():
specs = _load_module_specs()
assert specs[52]["calls"] == [
("GET", "/api/v3/integration/openapi-evidence", "readonly", {})
]

View File

@@ -0,0 +1,9 @@
{
"sdk/app/routers/integration.py": "3cb5f5ba13cd2c1a8637c90a024ee923e1f6c82303402428ad893bd1ecb02700",
"sdk/app/routers/gateway.py": "eb35dc96d3c43149c2b416d7b0168b6a83d77d20d9fedc345adfe73bd9fee644",
"sdk/app/routers/workbench.py": "80339ca247980461c6947c85621fbce3032ed008f119cb8e7b11ccc52b8ee440",
"sdk/app/routers/fleet.py": "ef8b121cb3ed320a4b18a59d7034f1b19d140205cc8fbd4d6859f63dbd65885e",
"sdk/app/main.py": "3ea856a88dfb78ff01d5fb5b73581aeb0d55498835e736e916941b0f489d8f5a",
"sdk/scripts/full55_final_device_acceptance.py": "541b9c8576ec67fb7eb0b07f1d71577e1c5c195830576b7e1f275aedf2819220",
"sdk/tests/test_full55_r6_aggregate_stability.py": "79b660fd1f32b030ddc75be767383d1fcd4fcad32619bb53b80330f47de55fc1"
}

View File

@@ -0,0 +1,14 @@
{
"allowed_scope": [
"sdk/app/routers/integration.py",
"sdk/app/routers/gateway.py",
"sdk/app/routers/workbench.py",
"sdk/app/routers/fleet.py",
"sdk/app/main.py",
"sdk/scripts/full55_final_device_acceptance.py",
"sdk/tests/test_full55_r6_aggregate_stability.py"
],
"device_or_deploy_performed": false,
"outside_scope_modified_by_this_task": false,
"note": "工作树另有其他任务的未提交改动本报告只记录本次R6允许范围。"
}

View File

@@ -0,0 +1,85 @@
# R6 聚合稳定修复
## 一句话结论
这次只做了电脑里的代码和离线测试,没有操作手机、没有部署、没有重启,也没有发起任何业务动作。
专项测试已经通过46、47、54、52具备下一步真机复验条件但还不能把离线通过当成真机通过。
## 这次解决了什么
### 46 客户画像聚合、47 消息列表
以前外层接口明明看到手机在线却误报“设备WSS没连上”。
现在聚合接口会直接使用当前运行进程已经在用的那条连接,不再临时新建连接,也不再拿另一份连接对象。
这样做是为了让health、connection和聚合接口看到的是同一台在线手机。
### 54 稳定观察
现在会正式调用已有的Hook探针再判断结果。
手机在线但Hook探针失败时结果继续保留失败不会假装成功。
Hook探针成功并且回读确认后才会标记verified=true。
### 52 验收入口
标准OpenAPI地址保持不变没有改公共接口说明。
验收脚本改用带完整回执的验收地址:
`GET /api/v3/integration/openapi-evidence`
标准说明地址仍然是:
`GET /openapi.json`
## 修改范围
只涉及任务允许的文件:
- `sdk/app/main.py`
- `sdk/app/routers/integration.py`
- `sdk/app/routers/gateway.py`
- `sdk/app/routers/workbench.py`
- `sdk/app/routers/fleet.py`
- `sdk/scripts/full55_final_device_acceptance.py`
- `sdk/tests/test_full55_r6_aggregate_stability.py`
没有改 unified、wechat_full、device_transport、Agent、Android、Hook。
## 离线测试结果
专项命令:
```bash
PYTHONPATH=sdk python3 -m pytest -q sdk/tests/test_full55_r6_aggregate_stability.py sdk/tests/test_full55_r6_agent_strict_frida.py sdk/tests/test_full55_r5_capability_routes.py sdk/tests/test_full55_r5_platform_evidence.py -k 'r6 or module_46 or module_47 or module_52 or module_54 or missing_readback'
```
结果20 passed11 deselected。
语法检查:通过。
改动范围检查:通过。
全量检查结果235 passed3项旧基线失败。3项分别是运行态OpenAPI快照未同步、模块06旧测试固定旧trace、模块11旧测试固定旧回读写法不属于本次R6改动目标也没有因此扩大修改范围。
## 当前完成口径
严格真机进度仍按原账本不增加PASS26/5547.27%。
本次是离线修复完成,状态为“等真机窗口复验”。
## 下一步
等允许操作手机的窗口后,只做只读复验:
1. 先确认手机在线和Hook在线。
2. 复验46客户画像聚合。
3. 复验47消息列表。
4. 复验54稳定观察并分别保留Hook成功和失败结果。
5. 核对52验收入口的HTTP回执、trace、通道、原始回执和readback。
本次没有替用户操作设备,所以以上四项暂不宣称真机通过。

View File

@@ -0,0 +1,40 @@
{
"task_id": "WP-CORE71-R6-AGGREGATE-STABILITY",
"status": "OFFLINE_PATCH_READY_WAIT_DEVICE",
"created_at": "2026-08-10T22:18:01.103684+08:00",
"device_calls": 0,
"deployment": 0,
"install": 0,
"sdk_reload": 0,
"business_calls": 0,
"strict_pass_before": "26/55",
"strict_pass_after": "26/55",
"scope_files": [
"sdk/app/routers/integration.py",
"sdk/app/routers/gateway.py",
"sdk/app/routers/workbench.py",
"sdk/app/routers/fleet.py",
"sdk/app/main.py",
"sdk/scripts/full55_final_device_acceptance.py",
"sdk/tests/test_full55_r6_aggregate_stability.py"
],
"module_status": {
"46": "OFFLINE_PATCH_READY_WAIT_DEVICE",
"47": "OFFLINE_PATCH_READY_WAIT_DEVICE",
"52": "OFFLINE_SCRIPT_ADDRESS_FIXED_WAIT_DEVICE",
"54": "OFFLINE_PATCH_READY_WAIT_DEVICE"
},
"tests": {
"targeted": "20 passed, 11 deselected",
"py_compile": "PASS",
"diff_check": "PASS",
"full_suite": "235 passed, 3 baseline failures"
},
"openapi": {
"standard_path": "/openapi.json",
"acceptance_path": "/api/v3/integration/openapi-evidence",
"standard_unchanged": true
},
"evidence_source": "/Users/karuo/Documents/开发/2、私域银行/工作手机/开发文档/10、项目管理/02-测试报告/20260810_FULL55_REPAIR/67_R5合并更新后14项只读复验",
"report_dir": "/Users/karuo/Documents/开发/2、私域银行/工作手机/开发文档/10、项目管理/02、测试报告/20260810_FULL55_REPAIR/70_R6聚合稳定修复"
}

View File

@@ -0,0 +1,10 @@
专项测试20 passed, 11 deselected in 1.69s
语法检查PY_COMPILE=PASS
改动范围检查DIFF_CHECK=PASS
全量测试235 passed, 3 baseline failures in 21.86s
全量失败项OpenAPI快照未同步模块06旧trace断言模块11旧readback断言。
设备调用0
部署0
安装0
SDK重载0
业务调用0