fix: 收口R6聚合与稳定观察
This commit is contained in:
261
sdk/app/main.py
261
sdk/app/main.py
@@ -20,6 +20,7 @@ import inspect
|
|||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import markdown
|
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.ws_hub import ws_hub
|
||||||
from services.device_manager import device_manager
|
from services.device_manager import device_manager
|
||||||
from services.security_modules import security_module_registry
|
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(
|
logging.basicConfig(
|
||||||
@@ -505,6 +509,7 @@ async def lifespan(app: FastAPI):
|
|||||||
from services.ai_heartbeat import ai_heartbeat
|
from services.ai_heartbeat import ai_heartbeat
|
||||||
from services.discovery_service import discovery_service
|
from services.discovery_service import discovery_service
|
||||||
|
|
||||||
|
_bind_shared_runtime_ws_hub(app)
|
||||||
logger.info("🚀 工作手机SDK v3.0 启动中...")
|
logger.info("🚀 工作手机SDK v3.0 启动中...")
|
||||||
from services.process_state import mark_process_started
|
from services.process_state import mark_process_started
|
||||||
mark_process_started()
|
mark_process_started()
|
||||||
@@ -625,6 +630,62 @@ Content-Type: application/json
|
|||||||
redoc_url="/redoc",
|
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配置
|
# CORS配置
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
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(fleet.router, prefix="/api/v3", tags=["多设备管理"])
|
||||||
app.include_router(kb.router, 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():
|
async def health_check():
|
||||||
"""健康检查"""
|
"""健康检查"""
|
||||||
from services.adb_device import adb_manager
|
from services.adb_device import adb_manager
|
||||||
adb_devices = await adb_manager.async_scan_devices()
|
adb_devices = []
|
||||||
return {
|
if bool(getattr(settings, "WORKPHONE_HOST_ADB_PROBE", False)):
|
||||||
|
adb_devices = await adb_manager.async_scan_devices()
|
||||||
|
data = {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"devices_online": len(ws_hub.connections),
|
"devices_online": len(ws_hub.connections),
|
||||||
"device_ids": list(ws_hub.connections.keys()),
|
"device_ids": list(ws_hub.connections.keys()),
|
||||||
"adb_devices": len(adb_devices),
|
"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")
|
@app.get("/ready")
|
||||||
@@ -1142,6 +1396,7 @@ def _custom_openapi():
|
|||||||
"verification_pending": "待真机验证",
|
"verification_pending": "待真机验证",
|
||||||
"placeholder": "未完成",
|
"placeholder": "未完成",
|
||||||
}.get(implementation_status, "未完成")
|
}.get(implementation_status, "未完成")
|
||||||
|
apply_global_openapi_contract(schema, _GLOBAL_WRITE_MANIFEST)
|
||||||
app.openapi_schema = schema
|
app.openapi_schema = schema
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from services.device_id_util import device_id_md5
|
from services.device_id_util import device_id_md5
|
||||||
from services.ws_hub import ws_hub
|
from services.ws_hub import ws_hub
|
||||||
|
from services.evidence_receipt import build_receipt
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -214,9 +215,7 @@ async def fleet_summary(project_id: str = ""):
|
|||||||
for device in devices:
|
for device in devices:
|
||||||
pid = str(device.get("project_id") or "")
|
pid = str(device.get("project_id") or "")
|
||||||
by_project[pid] = by_project.get(pid, 0) + 1
|
by_project[pid] = by_project.get(pid, 0) + 1
|
||||||
return {
|
data = {
|
||||||
"code": 200,
|
|
||||||
"data": {
|
|
||||||
"total": len(devices),
|
"total": len(devices),
|
||||||
"online": len(online),
|
"online": len(online),
|
||||||
"adb": 0,
|
"adb": 0,
|
||||||
@@ -224,8 +223,21 @@ async def fleet_summary(project_id: str = ""):
|
|||||||
"by_project": by_project,
|
"by_project": by_project,
|
||||||
"device_ids": [d.get("device_id") for d in devices],
|
"device_ids": [d.get("device_id") for d in devices],
|
||||||
"online_device_ids": [d.get("device_id") for d in online],
|
"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}})
|
@router.get("/fleet/devices", response_model=dict, responses={503: {"model": FleetOperationReceipt}})
|
||||||
@@ -242,7 +254,20 @@ async def fleet_devices(
|
|||||||
capability=capability,
|
capability=capability,
|
||||||
all_online=online_only,
|
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}})
|
@router.post("/fleet/execute", response_model=dict, responses={503: {"model": FleetOperationReceipt}})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, List, Any
|
from typing import Optional, List, Any
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -17,6 +18,8 @@ import json
|
|||||||
from services.ws_hub import ws_hub
|
from services.ws_hub import ws_hub
|
||||||
from services.adb_device import adb_manager
|
from services.adb_device import adb_manager
|
||||||
from services.device_manager import device_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()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -68,13 +71,35 @@ async def openai_compatible_chat(req: ChatRequest):
|
|||||||
if not user_msg:
|
if not user_msg:
|
||||||
raise HTTPException(400, "messages 中无 user 消息")
|
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
|
device_id = req.device_id
|
||||||
if not device_id:
|
if not device_id:
|
||||||
devices = list(ws_hub.connections.keys())
|
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
|
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 = ""
|
result_text = ""
|
||||||
executed_steps = []
|
executed_steps = []
|
||||||
@@ -510,11 +535,19 @@ async def fleet_broadcast(req: FleetCommandRequest):
|
|||||||
target_ids = req.device_ids
|
target_ids = req.device_ids
|
||||||
if not target_ids:
|
if not target_ids:
|
||||||
target_ids = list(ws_hub.connections.keys())
|
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:
|
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 = []
|
results = []
|
||||||
for did in target_ids:
|
for did in target_ids:
|
||||||
@@ -524,26 +557,23 @@ async def fleet_broadcast(req: FleetCommandRequest):
|
|||||||
"type": "execute",
|
"type": "execute",
|
||||||
"data": {"action": req.command, "params": req.params}
|
"data": {"action": req.command, "params": req.params}
|
||||||
}, timeout=15)
|
}, 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:
|
else:
|
||||||
adb_dev = adb_manager.get_device(did)
|
results.append({"device_id": did, "success": False, "error_code": "device_offline", "error": "设备不在线"})
|
||||||
if adb_dev:
|
|
||||||
results.append({"device_id": did, "success": True, "channel": "adb"})
|
|
||||||
else:
|
|
||||||
results.append({"device_id": did, "success": False, "error": "设备不在线"})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results.append({"device_id": did, "success": False, "error": str(e)})
|
results.append({"device_id": did, "success": False, "error": str(e)})
|
||||||
|
|
||||||
success_count = len([r for r in results if r["success"]])
|
aggregated = aggregate_node_results(results)
|
||||||
return {
|
return build_receipt(
|
||||||
"code": 200,
|
data={"results": results, **aggregated},
|
||||||
"data": {
|
success=aggregated["status"] != "failed",
|
||||||
"total": len(results),
|
code=200 if aggregated["status"] != "failed" else 503,
|
||||||
"success": success_count,
|
readback={"verified": aggregated["verified"], "write_performed": False, "source": "websocket_fleet"},
|
||||||
"failed": len(results) - success_count,
|
error_code="fleet_partial_failure" if aggregated["status"] == "partial_success" else None,
|
||||||
"results": results
|
error_message="部分设备完成,失败设备请按items逐台处理" if aggregated["status"] == "partial_success" else None,
|
||||||
}
|
retryable=aggregated["status"] in {"partial_success", "failed"},
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/gateway/fleet/group", tags=["外部对接网关"])
|
@router.post("/gateway/fleet/group", tags=["外部对接网关"])
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
@@ -27,36 +29,140 @@ from services.integration_manifest import (
|
|||||||
list_modules,
|
list_modules,
|
||||||
CONSUMER_LABELS,
|
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()
|
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=["对外接口统一清单"])
|
@router.get("/integration/manifest", tags=["对外接口统一清单"])
|
||||||
async def integration_manifest(request: Request) -> Dict[str, Any]:
|
async def integration_manifest(request: Request) -> Dict[str, Any]:
|
||||||
"""全量对外接口清单(按模块 + 消费方归类,机器可读)。"""
|
"""全量对外接口清单(按模块 + 消费方归类,机器可读)。"""
|
||||||
manifest = build_manifest(request.app)
|
manifest = build_manifest(request.app)
|
||||||
return {"code": 200, "data": manifest}
|
return _ok(manifest)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/integration/modules", tags=["对外接口统一清单"])
|
@router.get("/integration/modules", tags=["对外接口统一清单"])
|
||||||
async def integration_modules(request: Request) -> Dict[str, Any]:
|
async def integration_modules(request: Request) -> Dict[str, Any]:
|
||||||
"""模块目录(精简:模块/标签/消费方/端点数)。"""
|
"""模块目录(精简:模块/标签/消费方/端点数)。"""
|
||||||
manifest = build_manifest(request.app)
|
manifest = build_manifest(request.app)
|
||||||
return {"code": 200, "data": {
|
return _ok({
|
||||||
"module_count": manifest["module_count"],
|
"module_count": manifest["module_count"],
|
||||||
"total_endpoints": manifest["total_endpoints"],
|
"total_endpoints": manifest["total_endpoints"],
|
||||||
"modules": list_modules(manifest),
|
"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=["对外接口统一清单"])
|
@router.get("/integration/consumers", tags=["对外接口统一清单"])
|
||||||
async def integration_consumers() -> Dict[str, Any]:
|
async def integration_consumers() -> Dict[str, Any]:
|
||||||
"""消费方列表(存客宝/超管/AI数字员工/通用)。"""
|
"""消费方列表(存客宝/超管/AI数字员工/通用)。"""
|
||||||
return {"code": 200, "data": {
|
return _ok({
|
||||||
"consumers": [
|
"consumers": [
|
||||||
{"id": cid, "label": label} for cid, label in CONSUMER_LABELS.items()
|
{"id": cid, "label": label} for cid, label in CONSUMER_LABELS.items()
|
||||||
]
|
]
|
||||||
}}
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/integration/consumers/{consumer}", tags=["对外接口统一清单"])
|
@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())}",
|
detail=f"未知消费方: {consumer},可选: {list(CONSUMER_LABELS.keys())}",
|
||||||
)
|
)
|
||||||
manifest = build_manifest(request.app)
|
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=["对外接口统一清单"])
|
@router.get("/integration/capability/{device_id}", tags=["对外接口统一清单"])
|
||||||
@@ -98,7 +204,7 @@ async def integration_capability(
|
|||||||
# 1) 设备在线
|
# 1) 设备在线
|
||||||
online = False
|
online = False
|
||||||
try:
|
try:
|
||||||
from services.ws_hub import ws_hub
|
ws_hub = _runtime_ws_hub(request)
|
||||||
online = ws_hub.is_online(device_id)
|
online = ws_hub.is_online(device_id)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
online = False
|
online = False
|
||||||
@@ -124,7 +230,7 @@ async def integration_capability(
|
|||||||
# frida_available/frida_server 只能说明服务端口可用,不能证明微信已注入。
|
# frida_available/frida_server 只能说明服务端口可用,不能证明微信已注入。
|
||||||
if not supports_hook:
|
if not supports_hook:
|
||||||
try:
|
try:
|
||||||
from services.ws_hub import ws_hub
|
ws_hub = _runtime_ws_hub(request)
|
||||||
info = ws_hub.get_device_info(device_id) or {}
|
info = ws_hub.get_device_info(device_id) or {}
|
||||||
capabilities = set(info.get("capabilities") or [])
|
capabilities = set(info.get("capabilities") or [])
|
||||||
supports_hook = bool({"hook", "frida_rpc"} & capabilities)
|
supports_hook = bool({"hook", "frida_rpc"} & capabilities)
|
||||||
@@ -137,7 +243,7 @@ async def integration_capability(
|
|||||||
consumer=consumer or None,
|
consumer=consumer or None,
|
||||||
)
|
)
|
||||||
cap["probed"] = bool(probe and online)
|
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=["对外接口统一清单"])
|
@router.get("/integration/health", tags=["对外接口统一清单"])
|
||||||
@@ -154,7 +260,7 @@ async def integration_health(request: Request) -> Dict[str, Any]:
|
|||||||
|
|
||||||
# 1) WebSocket 主控
|
# 1) WebSocket 主控
|
||||||
try:
|
try:
|
||||||
from services.ws_hub import ws_hub
|
ws_hub = _runtime_ws_hub(request)
|
||||||
online = list(ws_hub.connections.keys())
|
online = list(ws_hub.connections.keys())
|
||||||
health["checks"]["websocket"] = {
|
health["checks"]["websocket"] = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -191,8 +297,12 @@ async def integration_health(request: Request) -> Dict[str, Any]:
|
|||||||
health["checks"]["cunkebao"] = {"ok": False, "error": str(e)}
|
health["checks"]["cunkebao"] = {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
# 4) AI 网关协议
|
# 4) AI 网关协议
|
||||||
|
ai = runtime_status()
|
||||||
health["checks"]["ai_gateway"] = {
|
health["checks"]["ai_gateway"] = {
|
||||||
"ok": True,
|
"ok": ai["available"],
|
||||||
|
"state": ai["state"],
|
||||||
|
"runtime": ai,
|
||||||
|
"brain": brain_runtime_status(),
|
||||||
"protocols": {
|
"protocols": {
|
||||||
"openai_compatible": "/api/v3/gateway/v1/chat/completions",
|
"openai_compatible": "/api/v3/gateway/v1/chat/completions",
|
||||||
"mcp_tools": "/api/v3/gateway/mcp/tools",
|
"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=["对外接口统一清单"])
|
@router.get("/integration/realtime/status", tags=["对外接口统一清单"])
|
||||||
@@ -224,7 +334,7 @@ async def integration_realtime_status(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from services.device_fleet import list_merged_local_devices
|
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.hook_module_service import hook_module_service
|
||||||
from services.wechat_device_map import normalize_wechat_device_fields
|
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
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
base_url = str(request.base_url).rstrip("/")
|
||||||
return {
|
return _ok({
|
||||||
"code": 200,
|
|
||||||
"data": {
|
|
||||||
"consumer": consumer or "common",
|
"consumer": consumer or "common",
|
||||||
"online_count": sum(1 for item in normalized if item.get("online")),
|
"online_count": sum(1 for item in normalized if item.get("online")),
|
||||||
"total_count": len(normalized),
|
"total_count": len(normalized),
|
||||||
@@ -276,8 +384,7 @@ async def integration_realtime_status(
|
|||||||
"url": f"{base_url}/api/v3/hook/events/stream",
|
"url": f"{base_url}/api/v3/hook/events/stream",
|
||||||
"event_source": "真实 Agent WS / Frida Hook / AI Agent 事件总线",
|
"event_source": "真实 Agent WS / Frida Hook / AI Agent 事件总线",
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/integration/realtime/events", tags=["对外接口统一清单"])
|
@router.get("/integration/realtime/events", tags=["对外接口统一清单"])
|
||||||
@@ -297,6 +404,6 @@ async def integration_realtime_events(
|
|||||||
platform=platform or None,
|
platform=platform or None,
|
||||||
limit=max(1, min(int(limit or 100), 500)),
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -3,10 +3,22 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from services.workbench import workbench_service
|
from services.workbench import workbench_service
|
||||||
|
from services.evidence_receipt import build_receipt
|
||||||
|
|
||||||
router = APIRouter(prefix="/workbench", tags=["总控平台总览"])
|
router = APIRouter(prefix="/workbench", tags=["总控平台总览"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview", response_model=dict)
|
@router.get("/overview", response_model=dict)
|
||||||
async def get_workbench_overview() -> 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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
640
sdk/scripts/full55_final_device_acceptance.py
Normal file
640
sdk/scripts/full55_final_device_acceptance.py
Normal 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())
|
||||||
122
sdk/tests/test_full55_r6_aggregate_stability.py
Normal file
122
sdk/tests/test_full55_r6_aggregate_stability.py
Normal 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", {})
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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允许范围。"
|
||||||
|
}
|
||||||
@@ -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 passed,11 deselected。
|
||||||
|
|
||||||
|
语法检查:通过。
|
||||||
|
|
||||||
|
改动范围检查:通过。
|
||||||
|
|
||||||
|
全量检查结果:235 passed,3项旧基线失败。3项分别是运行态OpenAPI快照未同步、模块06旧测试固定旧trace、模块11旧测试固定旧回读写法;不属于本次R6改动目标,也没有因此扩大修改范围。
|
||||||
|
|
||||||
|
## 当前完成口径
|
||||||
|
|
||||||
|
严格真机进度仍按原账本,不增加PASS:26/55,47.27%。
|
||||||
|
|
||||||
|
本次是离线修复完成,状态为“等真机窗口复验”。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
等允许操作手机的窗口后,只做只读复验:
|
||||||
|
|
||||||
|
1. 先确认手机在线和Hook在线。
|
||||||
|
2. 复验46客户画像聚合。
|
||||||
|
3. 复验47消息列表。
|
||||||
|
4. 复验54稳定观察,并分别保留Hook成功和失败结果。
|
||||||
|
5. 核对52验收入口的HTTP回执、trace、通道、原始回执和readback。
|
||||||
|
|
||||||
|
本次没有替用户操作设备,所以以上四项暂不宣称真机通过。
|
||||||
@@ -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聚合稳定修复"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user