261 lines
13 KiB
Python
261 lines
13 KiB
Python
"""设备详情、微信摘要、删除和单机命令的专用服务。"""
|
|
|
|
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()
|