fix: derive device online state from live wss
This commit is contained in:
409
sdk/app/routers/fleet.py
Normal file
409
sdk/app/routers/fleet.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
多手机设备管理接口。
|
||||
|
||||
面向存客宝/触客宝/超管等外部系统:把单机 SDK 能力包装成可筛选、
|
||||
可批量执行、可审计的 Fleet API。单机能力仍由 devices/unified 路由负责。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.device_id_util import device_id_md5
|
||||
from services.ws_hub import ws_hub
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
WRITE_ACTIONS = {
|
||||
"send_message",
|
||||
"batch_send",
|
||||
"mass_send",
|
||||
"add_friend",
|
||||
"batch_add_friend",
|
||||
"post_moments",
|
||||
"like_moments",
|
||||
"comment_moments",
|
||||
"create_group",
|
||||
"invite_to_group",
|
||||
"remove_from_group",
|
||||
"set_group_notice",
|
||||
"set_group_name",
|
||||
"set_group_welcome",
|
||||
"delete_tag",
|
||||
"create_tag",
|
||||
"tag_add",
|
||||
"tag_remove",
|
||||
}
|
||||
|
||||
|
||||
def _result_success(result: Dict[str, Any]) -> bool:
|
||||
"""只接受执行端明确 success=true,禁止以 HTTP/code 单独判定成功。"""
|
||||
if not isinstance(result, dict):
|
||||
return False
|
||||
payload = result.get("data") if isinstance(result.get("data"), dict) else result
|
||||
return payload.get("success") is True
|
||||
|
||||
|
||||
def _normalize_agent_result(action: str, result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""将 Agent 原始动作回执归一化为 Fleet 的标准可验收结构。"""
|
||||
normalized = dict(result or {})
|
||||
data = dict(normalized.get("data") or {})
|
||||
raw = normalized.get("raw_rpc_receipt") or dict(normalized)
|
||||
trace_id = (normalized.get("trace_id") or normalized.get("command_id")
|
||||
or raw.get("trace_id") or raw.get("command_id"))
|
||||
|
||||
# Android Agent 的截图字段是 image_base64;Fleet 标准字段为 base64。
|
||||
# 有有效图片即是该只读采集动作的业务成功,不依赖 HTTP 200 单独判定。
|
||||
if action == "screenshot" and int(normalized.get("code", 500)) == 200 and data.get("image_base64"):
|
||||
data.setdefault("base64", data["image_base64"])
|
||||
data["success"] = True
|
||||
normalized["readback"] = {
|
||||
"kind": "screenshot",
|
||||
"image_bytes_base64": len(str(data["image_base64"])),
|
||||
"channel": data.get("channel") or "agent_internal_screenshot",
|
||||
}
|
||||
|
||||
normalized["data"] = data
|
||||
normalized["trace_id"] = trace_id
|
||||
normalized["raw_rpc_receipt"] = raw
|
||||
return normalized
|
||||
|
||||
|
||||
def _timeout_result(device_id: str, timeout: int) -> Dict[str, Any]:
|
||||
"""统一 Fleet 超时回执,保留可重试元数据。"""
|
||||
trace_id = uuid.uuid4().hex
|
||||
receipt = {
|
||||
"operation": "fleet_execute",
|
||||
"device_id": device_id,
|
||||
"channel": "websocket/timeout",
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"trace_id": trace_id,
|
||||
"channel_used": "websocket/timeout",
|
||||
"raw_rpc_receipt": receipt,
|
||||
"readback": None,
|
||||
"success": False,
|
||||
"result": {
|
||||
"code": 504,
|
||||
"success": False,
|
||||
"error_code": "timeout",
|
||||
"error_message": f"设备执行超过 {timeout}s 超时",
|
||||
"retryable": True,
|
||||
"retry_after_seconds": 1,
|
||||
"channel_used": "websocket/timeout",
|
||||
"trace_id": trace_id,
|
||||
"raw_rpc_receipt": receipt,
|
||||
"readback": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FleetOperationReceipt(BaseModel):
|
||||
"""Fleet 统一离线/失败回执。"""
|
||||
code: int
|
||||
success: bool
|
||||
data: dict = Field(default_factory=dict)
|
||||
error_code: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
retryable: bool = False
|
||||
trace_id: Optional[str] = None
|
||||
channel_used: str
|
||||
raw_rpc_receipt: Optional[dict] = None
|
||||
readback: Optional[dict] = None
|
||||
|
||||
|
||||
def _fleet_offline_receipt(req: "FleetExecuteRequest") -> JSONResponse:
|
||||
"""Fleet 无 WSS 目标时返回结构化 503,不转 ADB 或本机通道。"""
|
||||
trace_id = req.trace_id or uuid.uuid4().hex
|
||||
receipt = {
|
||||
"operation": "fleet_execute",
|
||||
"device_ids": req.device_ids or [],
|
||||
"project_id": req.project_id,
|
||||
"channel": "websocket/offline",
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"code": 503,
|
||||
"success": False,
|
||||
"data": {"action": req.action, "target_count": 0, "results": []},
|
||||
"error_code": "device_offline",
|
||||
"error_message": "没有匹配的 WSS 在线设备",
|
||||
"retryable": True,
|
||||
"trace_id": trace_id,
|
||||
"channel_used": "websocket/offline",
|
||||
"raw_rpc_receipt": receipt,
|
||||
"readback": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FleetExecuteRequest(BaseModel):
|
||||
"""批量执行请求。"""
|
||||
|
||||
device_ids: Optional[List[str]] = None
|
||||
project_id: Optional[str] = None
|
||||
all_online: bool = False
|
||||
platform: str = "wechat"
|
||||
action: str
|
||||
params: Dict[str, Any] = Field(default_factory=dict)
|
||||
hook_only: bool = False
|
||||
timeout: int = 60
|
||||
max_concurrency: int = 3
|
||||
dry_run: bool = False
|
||||
confirm: bool = False
|
||||
trace_id: Optional[str] = None
|
||||
|
||||
|
||||
def _normalize_status(device: dict) -> str:
|
||||
"""在线态只信任当前进程中的 WSS 连接,历史登记不能冒充在线。"""
|
||||
return "online" if ws_hub.is_online(device.get("device_id", "")) else "offline"
|
||||
|
||||
|
||||
async def _select_devices(
|
||||
*,
|
||||
device_ids: Optional[List[str]] = None,
|
||||
project_id: Optional[str] = None,
|
||||
all_online: bool = False,
|
||||
status: str = "",
|
||||
capability: str = "",
|
||||
) -> List[dict]:
|
||||
from routers.devices import list_ws_managed_devices
|
||||
|
||||
devices = await list_ws_managed_devices()
|
||||
selected = []
|
||||
wanted = set(device_ids or [])
|
||||
for device in devices:
|
||||
did = device.get("device_id", "")
|
||||
if wanted and did not in wanted:
|
||||
continue
|
||||
if project_id and str(device.get("project_id") or "") != str(project_id):
|
||||
continue
|
||||
if all_online and not ws_hub.is_online(did):
|
||||
continue
|
||||
if status and _normalize_status(device) != status:
|
||||
continue
|
||||
if capability and capability not in (device.get("capabilities") or []):
|
||||
continue
|
||||
normalized = dict(device)
|
||||
normalized["status"] = _normalize_status(normalized)
|
||||
normalized["online"] = ws_hub.is_online(did)
|
||||
normalized["device_id_md5"] = normalized.get("device_id_md5") or device_id_md5(did)
|
||||
selected.append(normalized)
|
||||
return selected
|
||||
|
||||
|
||||
@router.get("/fleet/summary", response_model=dict, responses={503: {"model": FleetOperationReceipt}})
|
||||
async def fleet_summary(project_id: str = ""):
|
||||
"""所有手机/项目手机总览。"""
|
||||
devices = await _select_devices(project_id=project_id or None)
|
||||
online = [d for d in devices if d.get("online")]
|
||||
offline = [d for d in devices if not d.get("online")]
|
||||
by_project: Dict[str, int] = {}
|
||||
for device in devices:
|
||||
pid = str(device.get("project_id") or "")
|
||||
by_project[pid] = by_project.get(pid, 0) + 1
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"total": len(devices),
|
||||
"online": len(online),
|
||||
"adb": 0,
|
||||
"offline": len(offline),
|
||||
"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],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/fleet/devices", response_model=dict, responses={503: {"model": FleetOperationReceipt}})
|
||||
async def fleet_devices(
|
||||
project_id: str = "",
|
||||
status: str = "",
|
||||
capability: str = "",
|
||||
online_only: bool = False,
|
||||
):
|
||||
"""按项目、状态、能力筛选设备列表。"""
|
||||
devices = await _select_devices(
|
||||
project_id=project_id or None,
|
||||
status=status,
|
||||
capability=capability,
|
||||
all_online=online_only,
|
||||
)
|
||||
return {"code": 200, "data": {"devices": devices, "count": len(devices)}}
|
||||
|
||||
|
||||
@router.post("/fleet/execute", response_model=dict, responses={503: {"model": FleetOperationReceipt}})
|
||||
async def fleet_execute(req: FleetExecuteRequest):
|
||||
"""在多台在线手机上批量执行同一个设备动作。"""
|
||||
batch_trace_id = req.trace_id or uuid.uuid4().hex
|
||||
devices = await _select_devices(
|
||||
device_ids=req.device_ids,
|
||||
project_id=req.project_id,
|
||||
all_online=req.all_online or not req.device_ids,
|
||||
status="online",
|
||||
)
|
||||
if not devices:
|
||||
return _fleet_offline_receipt(req)
|
||||
|
||||
is_write = req.action in WRITE_ACTIONS
|
||||
if is_write and not (req.dry_run or req.confirm):
|
||||
return {
|
||||
"code": 200,
|
||||
"success": False,
|
||||
"trace_id": batch_trace_id,
|
||||
"channel_used": "fleet/confirm_required",
|
||||
"raw_rpc_receipt": {
|
||||
"operation": "fleet_execute",
|
||||
"action": req.action,
|
||||
"confirm_required": True,
|
||||
"trace_id": batch_trace_id,
|
||||
},
|
||||
"readback": None,
|
||||
"data": {
|
||||
"success": False,
|
||||
"confirm_required": True,
|
||||
"reason": "批量写类动作需要 confirm=true;可先 dry_run=true 查看目标设备",
|
||||
"action": req.action,
|
||||
"target_count": len(devices),
|
||||
"targets": [d.get("device_id") for d in devices],
|
||||
},
|
||||
}
|
||||
if req.dry_run:
|
||||
return {
|
||||
"code": 200,
|
||||
"success": True,
|
||||
"trace_id": batch_trace_id,
|
||||
"channel_used": "fleet/dry_run",
|
||||
"raw_rpc_receipt": {
|
||||
"operation": "fleet_execute",
|
||||
"action": req.action,
|
||||
"dry_run": True,
|
||||
"executed": False,
|
||||
"trace_id": batch_trace_id,
|
||||
},
|
||||
"readback": None,
|
||||
"data": {
|
||||
"success": True,
|
||||
"dry_run": True,
|
||||
"executed": False,
|
||||
"action": req.action,
|
||||
"target_count": len(devices),
|
||||
"targets": [d.get("device_id") for d in devices],
|
||||
},
|
||||
}
|
||||
|
||||
sem = asyncio.Semaphore(max(1, min(int(req.max_concurrency or 1), 10)))
|
||||
timeout = max(5, min(int(req.timeout or 60), 300))
|
||||
|
||||
async def run_one(device: dict) -> dict:
|
||||
did = device.get("device_id", "")
|
||||
async with sem:
|
||||
try:
|
||||
# Fleet 与单设备微信路由共用 unified + device_transport,
|
||||
# 确保 WS/Hook 选路、hook_only、错误码和真实回执口径一致。
|
||||
from routers.unified import _execute_skill
|
||||
|
||||
result = await _execute_skill(
|
||||
did,
|
||||
req.platform,
|
||||
req.action,
|
||||
req.params or {},
|
||||
timeout=timeout,
|
||||
# 微信默认固定走 WSS Agent → 手机本机 Frida RPC;调用方
|
||||
# 仅在非微信平台时可维持原有 generic Agent 语义。
|
||||
hook_only=req.hook_only or req.platform == "wechat",
|
||||
)
|
||||
result = _normalize_agent_result(req.action, result)
|
||||
return {
|
||||
"device_id": did,
|
||||
"device_id_md5": device_id_md5(did),
|
||||
"success": _result_success(result),
|
||||
"trace_id": result.get("trace_id"),
|
||||
"channel_used": result.get("channel_used") or result.get("_channel_used") or "websocket/agent",
|
||||
"raw_rpc_receipt": result.get("raw_rpc_receipt"),
|
||||
"readback": result.get("readback") or result.get("db_readback"),
|
||||
"result": result,
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
return _timeout_result(did, timeout)
|
||||
except Exception as exc:
|
||||
trace_id = uuid.uuid4().hex
|
||||
receipt = {
|
||||
"operation": "fleet_execute",
|
||||
"device_id": did,
|
||||
"channel": "websocket/error",
|
||||
"exception": type(exc).__name__,
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
return {
|
||||
"device_id": did,
|
||||
"device_id_md5": device_id_md5(did),
|
||||
"success": False,
|
||||
"trace_id": trace_id,
|
||||
"channel_used": "websocket/error",
|
||||
"raw_rpc_receipt": receipt,
|
||||
"readback": None,
|
||||
"result": {
|
||||
"code": 503,
|
||||
"success": False,
|
||||
"error_code": "execution_failed",
|
||||
"error_message": str(exc),
|
||||
"retryable": True,
|
||||
"retry_after_seconds": 1,
|
||||
"channel_used": "websocket/error",
|
||||
"trace_id": trace_id,
|
||||
"raw_rpc_receipt": receipt,
|
||||
"readback": None,
|
||||
},
|
||||
}
|
||||
|
||||
results = await asyncio.gather(*(run_one(device) for device in devices))
|
||||
ok = sum(1 for item in results if item.get("success"))
|
||||
body = {
|
||||
"code": 200,
|
||||
"success": ok == len(results),
|
||||
"trace_id": batch_trace_id,
|
||||
"channel_used": "frida_rpc" if req.platform == "wechat" else "websocket/agent",
|
||||
"raw_rpc_receipt": {
|
||||
"operation": "fleet_execute",
|
||||
"action": req.action,
|
||||
"trace_id": batch_trace_id,
|
||||
"results": [item.get("raw_rpc_receipt") for item in results],
|
||||
},
|
||||
"readback": [item.get("readback") for item in results],
|
||||
"data": {
|
||||
"success": ok == len(results),
|
||||
"action": req.action,
|
||||
"target_count": len(results),
|
||||
"success_count": ok,
|
||||
"failed_count": len(results) - ok,
|
||||
"results": results,
|
||||
},
|
||||
}
|
||||
if ok == 0 and results and all(int(item.get("result", {}).get("code", 200)) >= 500 for item in results):
|
||||
trace_id = batch_trace_id
|
||||
body.update({
|
||||
"code": 503,
|
||||
"error_code": "fleet_execution_failed",
|
||||
"error_message": "所有 WSS Agent 执行目标失败",
|
||||
"retryable": True,
|
||||
"trace_id": trace_id,
|
||||
"channel_used": "websocket/agent",
|
||||
"raw_rpc_receipt": {"operation": "fleet_execute", "trace_id": trace_id, "results": results},
|
||||
"readback": None,
|
||||
})
|
||||
return JSONResponse(status_code=503, content=body)
|
||||
return body
|
||||
260
sdk/app/services/device_console.py
Normal file
260
sdk/app/services/device_console.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""设备详情、微信摘要、删除和单机命令的专用服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
import uuid
|
||||
|
||||
from services.device_manager import device_manager
|
||||
from services.device_transport import device_transport
|
||||
from services.device_id_util import sanitize_sensitive_fields
|
||||
from services.ws_hub import ws_hub
|
||||
from services.workbench import (
|
||||
ControlPlaneStorageUnavailable,
|
||||
IdempotencyConflict,
|
||||
control_plane_store,
|
||||
now_iso,
|
||||
record_audit,
|
||||
reserve_idempotency,
|
||||
save_idempotency_result,
|
||||
)
|
||||
|
||||
|
||||
class DeviceOnlineError(RuntimeError):
|
||||
def __init__(self, result: dict):
|
||||
self.result = result
|
||||
|
||||
|
||||
class DeviceDeleteConfirmationRequired(RuntimeError):
|
||||
def __init__(self, result: dict):
|
||||
self.result = result
|
||||
|
||||
|
||||
class DeviceOfflineError(RuntimeError):
|
||||
def __init__(self, result: dict):
|
||||
self.result = result
|
||||
|
||||
|
||||
class DeviceCommandVerificationError(RuntimeError):
|
||||
def __init__(self, result: dict):
|
||||
self.result = result
|
||||
|
||||
|
||||
class DeviceConsoleService:
|
||||
def __init__(self, store: Any = control_plane_store) -> None:
|
||||
self.store = store
|
||||
|
||||
async def _log_command_history(self, *, device_id: str, action: str, payload: dict, result: dict) -> None:
|
||||
"""保存命令历史;只留下脱敏后的请求和结果必要字段。"""
|
||||
history_result = sanitize_sensitive_fields({
|
||||
"command_id": result.get("command_id"),
|
||||
"status": result.get("status"),
|
||||
"trace_id": result.get("trace_id"),
|
||||
"readback": result.get("readback"),
|
||||
"raw_receipt": result.get("raw_receipt"),
|
||||
"verified": result.get("status") == "succeeded",
|
||||
})
|
||||
await device_manager.log_command(
|
||||
device_id,
|
||||
action,
|
||||
sanitize_sensitive_fields(payload),
|
||||
history_result,
|
||||
)
|
||||
|
||||
async def _lookup(self, device_id: str) -> Optional[dict]:
|
||||
online = ws_hub.get_device_info(device_id)
|
||||
stored = None
|
||||
try:
|
||||
stored = await device_manager.get_device(device_id)
|
||||
except Exception:
|
||||
stored = None
|
||||
if not online and not stored:
|
||||
return None
|
||||
merged = {**(stored or {}), **(online or {})}
|
||||
merged["device_id"] = device_id
|
||||
# Mongo 仅保存历史登记;控制台“在线”只由本进程 WSS 会话判定。
|
||||
is_online = ws_hub.is_online(device_id)
|
||||
merged["status"] = "online" if is_online else "offline"
|
||||
merged["connection_type"] = "websocket" if is_online else "offline"
|
||||
return merged
|
||||
|
||||
async def detail(self, device_id: str) -> Optional[dict]:
|
||||
item = await self._lookup(device_id)
|
||||
if item is None:
|
||||
return None
|
||||
return sanitize_sensitive_fields({
|
||||
"device_id": device_id,
|
||||
"name": item.get("name") or item.get("model") or device_id,
|
||||
"status": item.get("status", "offline"),
|
||||
"project_id": item.get("project_id"),
|
||||
"groups": await self._groups_for(device_id),
|
||||
"capabilities": item.get("capabilities") or [],
|
||||
"connection": {
|
||||
"type": item.get("connection_type", "offline"),
|
||||
"online": item.get("status") == "online",
|
||||
"last_heartbeat": item.get("last_heartbeat"),
|
||||
"source": "wss_heartbeat" if item.get("status") == "online" else "mongo.devices",
|
||||
},
|
||||
"version": {
|
||||
"agent_version": item.get("agent_version"),
|
||||
"wechat_version": self._wechat_value(item, "wechat_version"),
|
||||
"android_version": item.get("android_version"),
|
||||
"source_at": self._source_at(item),
|
||||
},
|
||||
"wechat": await self.wechat_summary(device_id, item=item),
|
||||
"health": {
|
||||
"battery_level": item.get("battery_level"),
|
||||
"memory_usage_pct": item.get("memory_usage_pct"),
|
||||
"storage_free_mb": item.get("storage_free_mb"),
|
||||
"last_heartbeat": item.get("last_heartbeat"),
|
||||
"source_at": self._source_at(item),
|
||||
},
|
||||
"source_at": self._source_at(item),
|
||||
})
|
||||
|
||||
async def wechat_summary(self, device_id: str, *, item: Optional[dict] = None) -> dict:
|
||||
item = item or await self._lookup(device_id) or {}
|
||||
quick = item.get("quick_status") or item.get("last_status") or {}
|
||||
logged_in = item.get("wechat_logged_in")
|
||||
if logged_in is None:
|
||||
logged_in = quick.get("logged_in", quick.get("wechat_logged_in"))
|
||||
wechat_id = item.get("wechat_id") or item.get("wxid") or quick.get("wechat_id") or quick.get("wxid")
|
||||
friend_count = item.get("friend_count")
|
||||
if friend_count is None:
|
||||
friend_count = quick.get("friend_count")
|
||||
version = item.get("wechat_version") or quick.get("wechat_version")
|
||||
source_at = item.get("wechat_source_at") or quick.get("source_at") or self._source_at(item)
|
||||
return sanitize_sensitive_fields({
|
||||
"device_id": device_id,
|
||||
"logged_in": logged_in if isinstance(logged_in, bool) else None,
|
||||
"wechat_id": wechat_id,
|
||||
"friend_count": friend_count if isinstance(friend_count, int) else None,
|
||||
"wechat_version": version,
|
||||
"source_at": source_at,
|
||||
"state": "ready" if logged_in is True and wechat_id else ("not_bound" if logged_in is False else "not_collected"),
|
||||
})
|
||||
|
||||
async def delete_device(self, *, device_id: str, actor: str, key: str, trace_id: str, confirm: bool = False) -> dict:
|
||||
payload = {"device_id": device_id, "confirm": bool(confirm)}
|
||||
item = await self._lookup(device_id)
|
||||
|
||||
# 在线设备必须先拦截,不能因为控制面存储暂不可用而落入删除分支。
|
||||
if ws_hub.is_online(device_id):
|
||||
result = {
|
||||
"deleted": False,
|
||||
"device_id": device_id,
|
||||
"trace_id": trace_id,
|
||||
"protection": "online_device_delete_blocked",
|
||||
"phone_command_increment": 0,
|
||||
}
|
||||
try:
|
||||
idem = await reserve_idempotency(self.store, scope="device-delete", actor=actor, key=key, payload=payload)
|
||||
if idem.replay:
|
||||
return idem.result or {}
|
||||
audit = await record_audit(self.store, actor=actor, action="device.delete", resource={"device_id": device_id}, request_payload=payload, result_code="device_online", trace_id=trace_id)
|
||||
result["audit_id"] = audit["audit_id"]
|
||||
await save_idempotency_result(self.store, scope="device-delete", actor=actor, key=key, result=result)
|
||||
except ControlPlaneStorageUnavailable:
|
||||
result["audit_id"] = None
|
||||
result["audit_persisted"] = False
|
||||
result["audit_error"] = "control_plane_storage_unavailable"
|
||||
raise DeviceOnlineError(result)
|
||||
|
||||
if item is not None and not confirm:
|
||||
result = {
|
||||
"deleted": False,
|
||||
"device_id": device_id,
|
||||
"trace_id": trace_id,
|
||||
"precheck": True,
|
||||
"confirmation_required": True,
|
||||
"protection": "offline_device_delete_confirmation_required",
|
||||
"phone_command_increment": 0,
|
||||
}
|
||||
audit = await record_audit(
|
||||
self.store,
|
||||
actor=actor,
|
||||
action="device.delete.precheck",
|
||||
resource={"device_id": device_id},
|
||||
request_payload=payload,
|
||||
result_code="confirmation_required",
|
||||
trace_id=trace_id,
|
||||
risk_level="high",
|
||||
)
|
||||
result["audit_id"] = audit["audit_id"]
|
||||
raise DeviceDeleteConfirmationRequired(result)
|
||||
|
||||
idem = await reserve_idempotency(self.store, scope="device-delete", actor=actor, key=key, payload=payload)
|
||||
if idem.replay:
|
||||
return idem.result or {}
|
||||
if item is None:
|
||||
result = {"deleted": False, "device_id": device_id, "trace_id": trace_id}
|
||||
await save_idempotency_result(self.store, scope="device-delete", actor=actor, key=key, result=result)
|
||||
return result
|
||||
deletion = await device_manager.delete_device(device_id)
|
||||
if not deletion.get("deleted"):
|
||||
raise ControlPlaneStorageUnavailable("设备目录删除未产生持久化变更")
|
||||
audit = await record_audit(self.store, actor=actor, action="device.delete", resource={"device_id": device_id}, request_payload=payload, result_code="succeeded", trace_id=trace_id)
|
||||
result = {
|
||||
"deleted": True,
|
||||
"device_id": device_id,
|
||||
"audit_id": audit["audit_id"],
|
||||
"trace_id": trace_id,
|
||||
"confirmed": True,
|
||||
"precheck": False,
|
||||
}
|
||||
await save_idempotency_result(self.store, scope="device-delete", actor=actor, key=key, result=result)
|
||||
return result
|
||||
|
||||
async def command(self, *, device_id: str, action: str, parameters: dict, timeout_ms: int, confirm: bool, actor: str, key: str, trace_id: str) -> dict:
|
||||
payload = {"device_id": device_id, "action": action, "parameters": parameters, "timeout_ms": timeout_ms, "confirm": confirm}
|
||||
idem = await reserve_idempotency(self.store, scope="device-command", actor=actor, key=key, payload=payload)
|
||||
if idem.replay:
|
||||
return idem.result or {}
|
||||
if not ws_hub.is_online(device_id):
|
||||
raw = {"operation": action, "device_id": device_id, "channel": "websocket/offline", "trace_id": trace_id}
|
||||
audit = await record_audit(self.store, actor=actor, action="device.command", resource={"device_id": device_id}, request_payload=payload, result_code="device_offline", trace_id=trace_id, channel_used="websocket/offline", raw_receipt=raw, risk_level="high" if not confirm else "medium")
|
||||
result = {"command_id": None, "status": "device_offline", "device_id": device_id, "raw_receipt": raw, "readback": None, "audit_id": audit["audit_id"], "trace_id": trace_id}
|
||||
await self._log_command_history(device_id=device_id, action=action, payload=payload, result=result)
|
||||
await save_idempotency_result(self.store, scope="device-command", actor=actor, key=key, result=result)
|
||||
raise DeviceOfflineError(result)
|
||||
raw = await device_transport.execute_via_ws(device_id, "system", action, {**parameters, "trace_id": trace_id}, timeout=max(1, int(timeout_ms / 1000)))
|
||||
raw = dict(raw or {})
|
||||
data = raw.get("data") if isinstance(raw.get("data"), dict) else {}
|
||||
readback = data.get("readback") or data.get("db_readback") or raw.get("readback")
|
||||
command_id = raw.get("command_id") or data.get("command_id")
|
||||
rpc_success = raw.get("code") == 200 and data.get("success") is True
|
||||
verified = rpc_success and bool(command_id) and isinstance(readback, dict) and bool(readback.get("verified", True))
|
||||
result = {
|
||||
"command_id": command_id,
|
||||
"device_id": device_id,
|
||||
"status": "succeeded" if verified else ("rpc_succeeded" if rpc_success else "failed"),
|
||||
"raw_receipt": raw,
|
||||
"readback": readback,
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
await self._log_command_history(device_id=device_id, action=action, payload=payload, result=result)
|
||||
audit = await record_audit(self.store, actor=actor, action="device.command", resource={"device_id": device_id, "command_id": command_id}, request_payload=payload, result_code="succeeded" if verified else "business_verification_failed", trace_id=trace_id, readback=readback, channel_used="websocket/agent", raw_receipt=raw, risk_level="high" if not confirm else "medium")
|
||||
result["audit_id"] = audit["audit_id"]
|
||||
await save_idempotency_result(self.store, scope="device-command", actor=actor, key=key, result=result)
|
||||
if not verified:
|
||||
raise DeviceCommandVerificationError(result)
|
||||
return result
|
||||
|
||||
async def _groups_for(self, device_id: str) -> list[dict]:
|
||||
try:
|
||||
groups = await self.store.list("device_groups")
|
||||
except ControlPlaneStorageUnavailable:
|
||||
return []
|
||||
return [{"group_id": item.get("group_id"), "name": item.get("name"), "version": item.get("version", 1)} for item in groups if device_id in (item.get("device_ids") or [])]
|
||||
|
||||
@staticmethod
|
||||
def _wechat_value(item: dict, key: str) -> Any:
|
||||
quick = item.get("quick_status") or item.get("last_status") or {}
|
||||
return item.get(key) or quick.get(key)
|
||||
|
||||
@staticmethod
|
||||
def _source_at(item: dict) -> Optional[str]:
|
||||
return item.get("source_at") or item.get("updated_at") or item.get("last_heartbeat")
|
||||
|
||||
|
||||
device_console_service = DeviceConsoleService()
|
||||
Reference in New Issue
Block a user