fix: expose device pairing QR and guarded cleanup
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
工作手机SDK v3.0 - 设备管理路由
|
工作手机SDK v3.0 - 设备管理路由
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
import uuid
|
||||||
|
|
||||||
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
|
||||||
@@ -17,6 +20,7 @@ router = APIRouter()
|
|||||||
class DeviceResponse(BaseModel):
|
class DeviceResponse(BaseModel):
|
||||||
"""设备信息响应"""
|
"""设备信息响应"""
|
||||||
device_id: str
|
device_id: str
|
||||||
|
device_id_md5: Optional[str] = None
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
status: str = "offline"
|
status: str = "offline"
|
||||||
@@ -54,9 +58,60 @@ class InputRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SwipeRequest(BaseModel):
|
class SwipeRequest(BaseModel):
|
||||||
"""滑动请求"""
|
"""滑动请求。duration 已弃用,设备 Agent 仅支持 direction + scale。"""
|
||||||
direction: str # up, down, left, right
|
direction: str # up, down, left, right
|
||||||
scale: float = 0.8
|
scale: float = 0.8
|
||||||
|
duration: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="弃用字段:当前 Agent 未实现持续时长控制;传入时返回 duration_unsupported。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceOperationReceipt(BaseModel):
|
||||||
|
"""设备 Agent 统一执行/离线回执。"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceNotFoundReceipt(BaseModel):
|
||||||
|
"""设备详情未找到时的统一结构化回执。"""
|
||||||
|
code: int = 404
|
||||||
|
success: bool = False
|
||||||
|
data: dict = Field(default_factory=dict)
|
||||||
|
error_code: str = "device_not_found"
|
||||||
|
error_message: str
|
||||||
|
retryable: bool = False
|
||||||
|
trace_id: str
|
||||||
|
channel_used: str = "websocket/registry"
|
||||||
|
raw_rpc_receipt: dict
|
||||||
|
readback: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
DEVICE_NOT_FOUND_EXAMPLE = {
|
||||||
|
"code": 404,
|
||||||
|
"success": False,
|
||||||
|
"data": {"device_id": "device-unknown"},
|
||||||
|
"error_code": "device_not_found",
|
||||||
|
"error_message": "设备未在登记信息或 WSS Agent 中找到",
|
||||||
|
"retryable": False,
|
||||||
|
"trace_id": "device-detail-trace-id",
|
||||||
|
"channel_used": "websocket/registry",
|
||||||
|
"raw_rpc_receipt": {
|
||||||
|
"operation": "get_device",
|
||||||
|
"device_id": "device-unknown",
|
||||||
|
"found": False,
|
||||||
|
"source": "registry+wss",
|
||||||
|
},
|
||||||
|
"readback": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class HeartbeatConfigRequest(BaseModel):
|
class HeartbeatConfigRequest(BaseModel):
|
||||||
@@ -75,21 +130,109 @@ class StandingOrderRequest(BaseModel):
|
|||||||
order: str
|
order: str
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_receipt(result: dict, *, device_id: str = "", action: str = "") -> dict:
|
||||||
|
"""统一设备域 WS 原始回执,供存客宝逐条真机验收追踪。"""
|
||||||
|
raw = dict(result or {})
|
||||||
|
data = raw.get("data") or {}
|
||||||
|
code = int(raw.get("code", 500))
|
||||||
|
data = raw.get("data") if isinstance(raw.get("data"), dict) else {}
|
||||||
|
success = bool(data.get("success")) if "success" in data else code == 200
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"success": success,
|
||||||
|
"data": data,
|
||||||
|
"channel_used": "websocket/agent",
|
||||||
|
"trace_id": raw.get("trace_id") or raw.get("command_id"),
|
||||||
|
"raw_rpc_receipt": raw,
|
||||||
|
"readback": data.get("readback") or data.get("db_readback"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_offline_receipt(device_id: str, action: str, reason: str = "device_offline") -> JSONResponse:
|
||||||
|
"""WSS Agent 不在线时返回可审计的结构化 503,不转主机 ADB。"""
|
||||||
|
trace_id = uuid.uuid4().hex
|
||||||
|
receipt = {
|
||||||
|
"operation": action,
|
||||||
|
"device_id": device_id,
|
||||||
|
"channel": "websocket/offline",
|
||||||
|
"reason": reason,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
}
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={
|
||||||
|
"code": 503,
|
||||||
|
"success": False,
|
||||||
|
"data": {},
|
||||||
|
"error_code": "device_offline",
|
||||||
|
"error_message": "设备 WSS Agent 未在线",
|
||||||
|
"retryable": True,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"channel_used": "websocket/offline",
|
||||||
|
"raw_rpc_receipt": receipt,
|
||||||
|
"readback": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_failure_receipt(device_id: str, action: str, result: dict) -> JSONResponse:
|
||||||
|
"""Agent 非成功回执保持原始信封并以 503 暴露。"""
|
||||||
|
body = _ws_receipt(result, device_id=device_id, action=action)
|
||||||
|
body.update({
|
||||||
|
"code": 503,
|
||||||
|
"success": False,
|
||||||
|
"error_code": body.get("data", {}).get("error_code") or "device_command_failed",
|
||||||
|
"error_message": body.get("data", {}).get("error_message") or result.get("message") or "设备 Agent 执行失败",
|
||||||
|
"retryable": True,
|
||||||
|
})
|
||||||
|
return JSONResponse(status_code=503, content=body)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_ws_managed_devices() -> List[dict]:
|
||||||
|
"""设备/Fleet 仅使用登记数据与 WSS Agent 状态,不扫描主机 ADB。"""
|
||||||
|
stored: List[dict] = []
|
||||||
|
try:
|
||||||
|
stored = await device_manager.get_all_devices()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
online = {item["device_id"]: item for item in ws_hub.get_online_devices()}
|
||||||
|
merged = {item.get("device_id"): dict(item) for item in stored if item.get("device_id")}
|
||||||
|
for device_id, info in online.items():
|
||||||
|
merged[device_id] = {**merged.get(device_id, {}), **info}
|
||||||
|
from services.device_id_util import enrich_device_id_fields, sanitize_sensitive_fields
|
||||||
|
devices = []
|
||||||
|
for device_id, item in merged.items():
|
||||||
|
device = dict(item)
|
||||||
|
is_online = device_id in online
|
||||||
|
device["device_id"] = device_id
|
||||||
|
device["status"] = "online" if is_online else "offline"
|
||||||
|
device["connection_type"] = "websocket" if is_online else "offline"
|
||||||
|
devices.append(enrich_device_id_fields(device))
|
||||||
|
return sanitize_sensitive_fields(devices)
|
||||||
|
|
||||||
|
|
||||||
# ========== 设备列表 ==========
|
# ========== 设备列表 ==========
|
||||||
|
|
||||||
@router.get("/devices", response_model=dict)
|
@router.get("/devices", response_model=dict)
|
||||||
async def get_devices():
|
async def get_devices():
|
||||||
"""获取所有设备列表(融合 WebSocket + ADB + DB)"""
|
"""获取所有登记设备(WSS Agent 状态为唯一在线依据)。"""
|
||||||
from services.device_fleet import list_merged_local_devices
|
devices = await list_ws_managed_devices()
|
||||||
|
|
||||||
devices = await list_merged_local_devices()
|
|
||||||
return {"code": 200, "data": devices}
|
return {"code": 200, "data": devices}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/devices/{device_id}", response_model=dict)
|
@router.get(
|
||||||
|
"/devices/{device_id}",
|
||||||
|
response_model=dict,
|
||||||
|
responses={
|
||||||
|
404: {
|
||||||
|
"model": DeviceNotFoundReceipt,
|
||||||
|
"description": "设备未在登记信息或 WSS Agent 中找到",
|
||||||
|
"content": {"application/json": {"example": DEVICE_NOT_FOUND_EXAMPLE}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
async def get_device(device_id: str):
|
async def get_device(device_id: str):
|
||||||
"""获取设备详情(支持 WebSocket / ADB / DB)"""
|
"""获取设备详情(登记信息 + WSS Agent,无主机 ADB 探测)。"""
|
||||||
from services.adb_device import adb_manager
|
|
||||||
|
|
||||||
# 检查 WebSocket 在线
|
# 检查 WebSocket 在线
|
||||||
online_info = ws_hub.get_device_info(device_id)
|
online_info = ws_hub.get_device_info(device_id)
|
||||||
@@ -101,56 +244,62 @@ async def get_device(device_id: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 检查 ADB 直连(在线程池中执行以避免阻塞)
|
if not online_info and not db_info:
|
||||||
adb_info = None
|
trace_id = uuid.uuid4().hex
|
||||||
import asyncio
|
receipt = {
|
||||||
|
"operation": "get_device",
|
||||||
def _check_adb():
|
"device_id": device_id,
|
||||||
dev = adb_manager.get_device(device_id)
|
"found": False,
|
||||||
if dev and dev.is_online():
|
"source": "registry+wss",
|
||||||
info = dev.get_info()
|
"trace_id": trace_id,
|
||||||
info["connection_type"] = "adb"
|
}
|
||||||
return info
|
return JSONResponse(
|
||||||
return None
|
status_code=404,
|
||||||
try:
|
content={
|
||||||
adb_info = await asyncio.get_running_loop().run_in_executor(None, _check_adb)
|
"code": 404,
|
||||||
except Exception:
|
"success": False,
|
||||||
pass
|
"data": {"device_id": device_id},
|
||||||
|
"error_code": "device_not_found",
|
||||||
|
"error_message": "设备未在登记信息或 WSS Agent 中找到",
|
||||||
|
"retryable": False,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"channel_used": "websocket/registry",
|
||||||
|
"raw_rpc_receipt": receipt,
|
||||||
|
"readback": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if not online_info and not db_info and not adb_info:
|
device = {**(db_info or {}), **(online_info or {})}
|
||||||
raise HTTPException(status_code=404, detail="设备不存在")
|
|
||||||
|
|
||||||
device = {**(db_info or {}), **(adb_info or {}), **(online_info or {})}
|
|
||||||
if online_info:
|
if online_info:
|
||||||
device["status"] = "online"
|
device["status"] = "online"
|
||||||
elif adb_info:
|
device["connection_type"] = "websocket"
|
||||||
device["status"] = "adb"
|
|
||||||
else:
|
else:
|
||||||
device["status"] = "offline"
|
device["status"] = "offline"
|
||||||
|
device["connection_type"] = "offline"
|
||||||
device["device_id"] = device_id
|
device["device_id"] = device_id
|
||||||
|
from services.device_id_util import enrich_device_id_fields, sanitize_sensitive_fields
|
||||||
|
enrich_device_id_fields(device)
|
||||||
|
|
||||||
# 若 model/brand/android_version 为空或 Unknown,从 ADB 补充(设备可能同时连 WS 和 USB)
|
return {"code": 200, "data": sanitize_sensitive_fields(device)}
|
||||||
def _is_empty_or_unknown(v) -> bool:
|
|
||||||
return not v or (isinstance(v, str) and v.strip().lower() in ("", "unknown", "未知"))
|
|
||||||
for field in ("model", "brand", "android_version"):
|
|
||||||
if _is_empty_or_unknown(device.get(field)) and adb_info and adb_info.get(field):
|
|
||||||
device[field] = adb_info[field]
|
|
||||||
# 若仍缺失,尝试从 adb_manager 实时拉取(设备可能刚连上 ADB)
|
|
||||||
if _is_empty_or_unknown(device.get("model")) or _is_empty_or_unknown(device.get("android_version")):
|
|
||||||
try:
|
|
||||||
dev = adb_manager.get_device(device_id)
|
|
||||||
if dev and dev.is_online():
|
|
||||||
info = await asyncio.wait_for(
|
|
||||||
asyncio.get_running_loop().run_in_executor(None, dev.get_info),
|
|
||||||
timeout=5.0,
|
|
||||||
)
|
|
||||||
for field in ("model", "brand", "android_version"):
|
|
||||||
if _is_empty_or_unknown(device.get(field)) and info.get(field):
|
|
||||||
device[field] = info[field]
|
|
||||||
except (asyncio.TimeoutError, Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"code": 200, "data": device}
|
|
||||||
|
@router.delete("/devices/{device_id}", response_model=dict)
|
||||||
|
async def delete_offline_device(device_id: str):
|
||||||
|
"""删除失效登记设备;在线 WSS 设备必须先断开,避免误删活跃连接。"""
|
||||||
|
if ws_hub.is_online(device_id):
|
||||||
|
raise HTTPException(status_code=409, detail="设备在线,停止连接后再删除")
|
||||||
|
existing = await device_manager.get_device(device_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="设备不存在或已删除")
|
||||||
|
result = await device_manager.delete_device(device_id)
|
||||||
|
if not result.get("deleted"):
|
||||||
|
raise HTTPException(status_code=503, detail="设备登记删除失败")
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"success": True,
|
||||||
|
"data": {"device_id": device_id, "deleted": True},
|
||||||
|
"message": "失效设备已删除,历史命令记录保留用于审计",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/devices/{device_id}/heartbeat", response_model=dict)
|
@router.get("/devices/{device_id}/heartbeat", response_model=dict)
|
||||||
@@ -179,32 +328,43 @@ async def set_device_heartbeat(device_id: str, req: HeartbeatConfigRequest):
|
|||||||
|
|
||||||
# ========== 设备控制 ==========
|
# ========== 设备控制 ==========
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/screenshot", response_model=dict)
|
@router.post("/devices/{device_id}/screenshot", response_model=dict, responses={503: {"model": DeviceOperationReceipt}})
|
||||||
async def screenshot(device_id: str):
|
async def screenshot(device_id: str):
|
||||||
"""获取设备截图"""
|
"""获取设备截图,仅经 WSS Agent 下发。"""
|
||||||
|
|
||||||
if not ws_hub.is_online(device_id):
|
if not ws_hub.is_online(device_id):
|
||||||
raise HTTPException(status_code=503, detail="设备不在线")
|
return _ws_offline_receipt(device_id, "screenshot")
|
||||||
|
|
||||||
result = await ws_hub.send_command(device_id, {
|
try:
|
||||||
"type": "execute",
|
result = await ws_hub.send_command(device_id, {
|
||||||
"data": {
|
"type": "execute",
|
||||||
"action": "screenshot"
|
"data": {
|
||||||
}
|
"action": "screenshot"
|
||||||
})
|
}
|
||||||
|
}, timeout=12)
|
||||||
|
except Exception as exc:
|
||||||
|
return _ws_offline_receipt(device_id, "screenshot", f"ws_exception:{type(exc).__name__}")
|
||||||
|
|
||||||
if result.get("code") != 200:
|
if result.get("code") != 200:
|
||||||
raise HTTPException(status_code=result.get("code", 500), detail=result.get("message"))
|
return _ws_failure_receipt(device_id, "screenshot", result)
|
||||||
|
|
||||||
return {"code": 200, "data": result.get("data", {})}
|
data = result.get("data", {})
|
||||||
|
# Android Agent 使用 image_base64;旧 Python Agent 使用 base64。
|
||||||
|
if not data.get("base64") and data.get("image_base64"):
|
||||||
|
data["base64"] = data["image_base64"]
|
||||||
|
if not data.get("base64"):
|
||||||
|
return _ws_failure_receipt(device_id, "screenshot", {**result, "message": "ws_empty_screenshot"})
|
||||||
|
data["collection_channel"] = "agent_internal_screenshot"
|
||||||
|
result["data"] = data
|
||||||
|
return _ws_receipt(result, device_id=device_id, action="screenshot")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/click", response_model=dict)
|
@router.post("/devices/{device_id}/click", response_model=dict, responses={503: {"model": DeviceOperationReceipt}})
|
||||||
async def click(device_id: str, req: ClickRequest):
|
async def click(device_id: str, req: ClickRequest):
|
||||||
"""点击坐标"""
|
"""点击坐标"""
|
||||||
|
|
||||||
if not ws_hub.is_online(device_id):
|
if not ws_hub.is_online(device_id):
|
||||||
raise HTTPException(status_code=503, detail="设备不在线")
|
return _ws_offline_receipt(device_id, "click")
|
||||||
|
|
||||||
result = await ws_hub.send_command(device_id, {
|
result = await ws_hub.send_command(device_id, {
|
||||||
"type": "execute",
|
"type": "execute",
|
||||||
@@ -214,15 +374,17 @@ async def click(device_id: str, req: ClickRequest):
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"code": 200, "data": result.get("data", {})}
|
if result.get("code") != 200:
|
||||||
|
return _ws_failure_receipt(device_id, "click", result)
|
||||||
|
return _ws_receipt(result, device_id=device_id, action="click")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/click-text", response_model=dict)
|
@router.post("/devices/{device_id}/click-text", response_model=dict, responses={503: {"model": DeviceOperationReceipt}})
|
||||||
async def click_text(device_id: str, req: ClickTextRequest):
|
async def click_text(device_id: str, req: ClickTextRequest):
|
||||||
"""点击文字"""
|
"""点击文字"""
|
||||||
|
|
||||||
if not ws_hub.is_online(device_id):
|
if not ws_hub.is_online(device_id):
|
||||||
raise HTTPException(status_code=503, detail="设备不在线")
|
return _ws_offline_receipt(device_id, "click_text")
|
||||||
|
|
||||||
result = await ws_hub.send_command(device_id, {
|
result = await ws_hub.send_command(device_id, {
|
||||||
"type": "execute",
|
"type": "execute",
|
||||||
@@ -232,15 +394,17 @@ async def click_text(device_id: str, req: ClickTextRequest):
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"code": 200, "data": result.get("data", {})}
|
if result.get("code") != 200:
|
||||||
|
return _ws_failure_receipt(device_id, "click_text", result)
|
||||||
|
return _ws_receipt(result, device_id=device_id, action="click_text")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/input", response_model=dict)
|
@router.post("/devices/{device_id}/input", response_model=dict, responses={503: {"model": DeviceOperationReceipt}})
|
||||||
async def input_text(device_id: str, req: InputRequest):
|
async def input_text(device_id: str, req: InputRequest):
|
||||||
"""输入文字"""
|
"""输入文字"""
|
||||||
|
|
||||||
if not ws_hub.is_online(device_id):
|
if not ws_hub.is_online(device_id):
|
||||||
raise HTTPException(status_code=503, detail="设备不在线")
|
return _ws_offline_receipt(device_id, "input")
|
||||||
|
|
||||||
result = await ws_hub.send_command(device_id, {
|
result = await ws_hub.send_command(device_id, {
|
||||||
"type": "execute",
|
"type": "execute",
|
||||||
@@ -250,15 +414,34 @@ async def input_text(device_id: str, req: InputRequest):
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"code": 200, "data": result.get("data", {})}
|
if result.get("code") != 200:
|
||||||
|
return _ws_failure_receipt(device_id, "input", result)
|
||||||
|
return _ws_receipt(result, device_id=device_id, action="input")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/swipe", response_model=dict)
|
@router.post("/devices/{device_id}/swipe", response_model=dict, responses={422: {"model": DeviceOperationReceipt}, 503: {"model": DeviceOperationReceipt}})
|
||||||
async def swipe(device_id: str, req: SwipeRequest):
|
async def swipe(device_id: str, req: SwipeRequest):
|
||||||
"""滑动"""
|
"""滑动;持续时长由 Agent 支持后另行开放。"""
|
||||||
|
|
||||||
|
if req.duration is not None:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={
|
||||||
|
"code": 422,
|
||||||
|
"success": False,
|
||||||
|
"data": {},
|
||||||
|
"error_code": "duration_unsupported",
|
||||||
|
"error_message": "swipe.duration 已弃用;请仅传 direction 与 scale",
|
||||||
|
"retryable": False,
|
||||||
|
"trace_id": None,
|
||||||
|
"channel_used": "contract/validation",
|
||||||
|
"raw_rpc_receipt": None,
|
||||||
|
"readback": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if not ws_hub.is_online(device_id):
|
if not ws_hub.is_online(device_id):
|
||||||
raise HTTPException(status_code=503, detail="设备不在线")
|
return _ws_offline_receipt(device_id, "swipe")
|
||||||
|
|
||||||
result = await ws_hub.send_command(device_id, {
|
result = await ws_hub.send_command(device_id, {
|
||||||
"type": "execute",
|
"type": "execute",
|
||||||
@@ -268,15 +451,17 @@ async def swipe(device_id: str, req: SwipeRequest):
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"code": 200, "data": result.get("data", {})}
|
if result.get("code") != 200:
|
||||||
|
return _ws_failure_receipt(device_id, "swipe", result)
|
||||||
|
return _ws_receipt(result, device_id=device_id, action="swipe")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/devices/{device_id}/ui-tree", response_model=dict)
|
@router.get("/devices/{device_id}/ui-tree", response_model=dict, responses={503: {"model": DeviceOperationReceipt}})
|
||||||
async def get_ui_tree(device_id: str):
|
async def get_ui_tree(device_id: str):
|
||||||
"""获取UI树"""
|
"""获取 UI 树;外层执行固定为 WSS Agent。"""
|
||||||
|
|
||||||
if not ws_hub.is_online(device_id):
|
if not ws_hub.is_online(device_id):
|
||||||
raise HTTPException(status_code=503, detail="设备不在线")
|
return _ws_offline_receipt(device_id, "ui_tree")
|
||||||
|
|
||||||
result = await ws_hub.send_command(device_id, {
|
result = await ws_hub.send_command(device_id, {
|
||||||
"type": "execute",
|
"type": "execute",
|
||||||
@@ -285,7 +470,15 @@ async def get_ui_tree(device_id: str):
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"code": 200, "data": result.get("data", {})}
|
if result.get("code") != 200:
|
||||||
|
return _ws_failure_receipt(device_id, "ui_tree", result)
|
||||||
|
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
||||||
|
if data.get("channel") == "accessibility":
|
||||||
|
data["agent_collection_channel"] = "accessibility"
|
||||||
|
data["channel"] = "agent_internal_accessibility"
|
||||||
|
data["collection_scope"] = "agent_internal_device_diagnostic"
|
||||||
|
result["data"] = data
|
||||||
|
return _ws_receipt(result, device_id=device_id, action="ui_tree")
|
||||||
|
|
||||||
|
|
||||||
# ========== 脚本执行 ==========
|
# ========== 脚本执行 ==========
|
||||||
@@ -332,18 +525,17 @@ async def push_ai_task(device_id: str, req: AITaskRequest):
|
|||||||
if not ws_hub.is_online(device_id):
|
if not ws_hub.is_online(device_id):
|
||||||
raise HTTPException(status_code=503, detail="设备不在线")
|
raise HTTPException(status_code=503, detail="设备不在线")
|
||||||
|
|
||||||
ok = await ws_hub.push_ai_task(device_id, req.instruction, req.priority)
|
result = await ws_hub.send_command(device_id, {
|
||||||
if not ok:
|
"type": "ai_task",
|
||||||
|
"data": {"instruction": req.instruction, "priority": req.priority},
|
||||||
|
}, timeout=20)
|
||||||
|
if int(result.get("code", 500)) >= 400:
|
||||||
raise HTTPException(status_code=500, detail="推送失败")
|
raise HTTPException(status_code=500, detail="推送失败")
|
||||||
return {
|
receipt = _ws_receipt(result)
|
||||||
"code": 200,
|
receipt["data"] = {**(receipt.get("data") or {}), "device_id": device_id,
|
||||||
"data": {
|
"instruction": req.instruction, "priority": req.priority,
|
||||||
"device_id": device_id,
|
"status": "queued"}
|
||||||
"instruction": req.instruction,
|
return receipt
|
||||||
"priority": req.priority,
|
|
||||||
"status": "queued",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/ai/standing-order", response_model=dict)
|
@router.post("/devices/{device_id}/ai/standing-order", response_model=dict)
|
||||||
@@ -352,17 +544,16 @@ async def push_standing_order(device_id: str, req: StandingOrderRequest):
|
|||||||
if not ws_hub.is_online(device_id):
|
if not ws_hub.is_online(device_id):
|
||||||
raise HTTPException(status_code=503, detail="设备不在线")
|
raise HTTPException(status_code=503, detail="设备不在线")
|
||||||
|
|
||||||
ok = await ws_hub.push_standing_order(device_id, req.order)
|
result = await ws_hub.send_command(device_id, {
|
||||||
if not ok:
|
"type": "standing_order",
|
||||||
|
"data": {"order": req.order},
|
||||||
|
}, timeout=20)
|
||||||
|
if int(result.get("code", 500)) >= 400:
|
||||||
raise HTTPException(status_code=500, detail="推送失败")
|
raise HTTPException(status_code=500, detail="推送失败")
|
||||||
return {
|
receipt = _ws_receipt(result)
|
||||||
"code": 200,
|
receipt["data"] = {**(receipt.get("data") or {}), "device_id": device_id,
|
||||||
"data": {
|
"order": req.order, "status": "pushed"}
|
||||||
"device_id": device_id,
|
return receipt
|
||||||
"order": req.order,
|
|
||||||
"status": "pushed",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/ai/execute", response_model=dict)
|
@router.post("/devices/{device_id}/ai/execute", response_model=dict)
|
||||||
@@ -376,11 +567,55 @@ async def ai_execute(device_id: str, task: str, timeout: int = 60):
|
|||||||
"data": {"task": task},
|
"data": {"task": task},
|
||||||
}, timeout=timeout)
|
}, timeout=timeout)
|
||||||
|
|
||||||
return {
|
return _ws_receipt(result)
|
||||||
"code": result.get("code", 200),
|
|
||||||
"message": result.get("message", ""),
|
|
||||||
"data": result.get("data", {}),
|
class DeviceAIChatRequest(BaseModel):
|
||||||
}
|
"""卡若网关决策 + WS 下发(手机无需本地 api_key)"""
|
||||||
|
instruction: str
|
||||||
|
timeout: int = 120
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{device_id}/ai/chat", response_model=dict)
|
||||||
|
async def device_ai_chat(device_id: str, req: DeviceAIChatRequest):
|
||||||
|
"""
|
||||||
|
小 AI 核心端点:服务端调卡若 /api/gateway/chat 决策,经 WS 让手机执行。
|
||||||
|
与日常卡若接口一致,不另建 Ollama/local-ai。
|
||||||
|
"""
|
||||||
|
from services.karuo_device_ai import chat_and_execute_on_device
|
||||||
|
|
||||||
|
result = await chat_and_execute_on_device(device_id, req.instruction, req.timeout)
|
||||||
|
code = int(result.get("code", 200))
|
||||||
|
if code >= 400:
|
||||||
|
raise HTTPException(status_code=code, detail=result.get("message", "执行失败"))
|
||||||
|
return {"code": 200, "data": result}
|
||||||
|
|
||||||
|
|
||||||
|
class HotUpdateRequest(BaseModel):
|
||||||
|
update_type: str = "config" # config | hub_html | refresh
|
||||||
|
config: Optional[dict] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{device_id}/hot-update", response_model=dict)
|
||||||
|
async def device_hot_update(device_id: str, req: HotUpdateRequest):
|
||||||
|
"""热更新 Hub / 配置(如 ai_api_key),无需重装 APK"""
|
||||||
|
if not ws_hub.is_online(device_id):
|
||||||
|
raise HTTPException(status_code=503, detail="设备不在线")
|
||||||
|
payload: dict = {"update_type": req.update_type}
|
||||||
|
if req.config:
|
||||||
|
payload["config"] = req.config
|
||||||
|
if req.content:
|
||||||
|
payload["content"] = req.content
|
||||||
|
if req.url:
|
||||||
|
payload["url"] = req.url
|
||||||
|
result = await ws_hub.send_command(
|
||||||
|
device_id,
|
||||||
|
{"type": "ui_update", "data": payload},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
return {"code": result.get("code", 200), "data": result}
|
||||||
|
|
||||||
|
|
||||||
# ========== AI 心跳监控仪表盘 ==========
|
# ========== AI 心跳监控仪表盘 ==========
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from motor.motor_asyncio import AsyncIOMotorClient
|
from typing import Optional, List, TYPE_CHECKING
|
||||||
from typing import Optional, List
|
|
||||||
|
try:
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
except ImportError: # NAS 无 Mongo 依赖时降级
|
||||||
|
AsyncIOMotorClient = None # type: ignore[misc, assignment]
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -22,11 +26,15 @@ class DeviceManager:
|
|||||||
|
|
||||||
async def init(self):
|
async def init(self):
|
||||||
"""初始化数据库连接(MongoDB 不可用时降级为无 DB 模式)"""
|
"""初始化数据库连接(MongoDB 不可用时降级为无 DB 模式)"""
|
||||||
|
if AsyncIOMotorClient is None:
|
||||||
|
logger.warning("motor 未安装,MongoDB 功能降级为无 DB 模式")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self.client = AsyncIOMotorClient(
|
self.client = AsyncIOMotorClient(
|
||||||
settings.MONGO_URI,
|
settings.MONGO_URI,
|
||||||
serverSelectionTimeoutMS=3000, # 3秒超时,不阻塞启动
|
serverSelectionTimeoutMS=3000, # 3秒超时,不阻塞启动
|
||||||
)
|
)
|
||||||
|
await self.client.admin.command("ping")
|
||||||
self.db = self.client[settings.MONGO_DB]
|
self.db = self.client[settings.MONGO_DB]
|
||||||
|
|
||||||
# 尝试创建索引
|
# 尝试创建索引
|
||||||
@@ -40,6 +48,8 @@ class DeviceManager:
|
|||||||
logger.info(f"MongoDB连接成功(无索引): {settings.MONGO_DB}")
|
logger.info(f"MongoDB连接成功(无索引): {settings.MONGO_DB}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"MongoDB 连接失败(降级为无DB模式,ADB 直连仍可用): {e}")
|
logger.warning(f"MongoDB 连接失败(降级为无DB模式,ADB 直连仍可用): {e}")
|
||||||
|
if self.client:
|
||||||
|
self.client.close()
|
||||||
self.client = None
|
self.client = None
|
||||||
self.db = None
|
self.db = None
|
||||||
|
|
||||||
@@ -98,6 +108,13 @@ class DeviceManager:
|
|||||||
cursor = self.db.devices.find({}, {"_id": 0}).skip(skip).limit(limit)
|
cursor = self.db.devices.find({}, {"_id": 0}).skip(skip).limit(limit)
|
||||||
return await cursor.to_list(length=limit)
|
return await cursor.to_list(length=limit)
|
||||||
|
|
||||||
|
async def delete_device(self, device_id: str) -> dict:
|
||||||
|
"""删除失效设备登记;命令历史保留用于审计。"""
|
||||||
|
if self.db is None:
|
||||||
|
return {"deleted": False, "reason": "device_store_unavailable"}
|
||||||
|
result = await self.db.devices.delete_one({"device_id": device_id})
|
||||||
|
return {"deleted": result.deleted_count > 0, "deleted_count": result.deleted_count}
|
||||||
|
|
||||||
async def update_device_status(self, device_id: str, status: str):
|
async def update_device_status(self, device_id: str, status: str):
|
||||||
"""更新设备状态"""
|
"""更新设备状态"""
|
||||||
if self.db is None:
|
if self.db is None:
|
||||||
@@ -122,18 +139,35 @@ class DeviceManager:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def compute_fingerprint(info: dict) -> str:
|
def compute_fingerprint(info: dict) -> str:
|
||||||
"""从设备上报信息计算指纹哈希(MD5)"""
|
"""从设备上报信息计算指纹哈希(MD5)。
|
||||||
|
|
||||||
|
防封要点(G2 修复 2026-05-31):硬件字段缺失时,绝不能让所有设备
|
||||||
|
都得到空串 MD5(d41d8cd98f00b204e9800998ecf8427e)—— 那会令全部设备
|
||||||
|
互相"指纹碰撞",使碰撞检测失效。无硬件数据时回退到 device_id 作为
|
||||||
|
稳定种子,保证一机一指纹;同时兼容嵌套 display.{width,height}。
|
||||||
|
"""
|
||||||
keys = sorted([
|
keys = sorted([
|
||||||
"brand", "model", "manufacturer", "android_version",
|
"brand", "model", "manufacturer", "android_version",
|
||||||
"sdk_version", "serial", "imei", "mac", "bluetooth_mac",
|
"sdk_version", "serial", "imei", "mac", "bluetooth_mac",
|
||||||
"screen_width", "screen_height", "density",
|
"screen_width", "screen_height", "density",
|
||||||
"cpu_abi", "fingerprint", "android_id",
|
"cpu_abi", "fingerprint", "android_id",
|
||||||
])
|
])
|
||||||
|
merged = dict(info or {})
|
||||||
|
# 兼容 agent 上报的嵌套 display.{width,height}
|
||||||
|
disp = merged.get("display")
|
||||||
|
if isinstance(disp, dict):
|
||||||
|
merged.setdefault("screen_width", disp.get("width") or disp.get("displayWidth"))
|
||||||
|
merged.setdefault("screen_height", disp.get("height") or disp.get("displayHeight"))
|
||||||
parts = []
|
parts = []
|
||||||
for k in keys:
|
for k in keys:
|
||||||
v = info.get(k, "")
|
v = merged.get(k, "")
|
||||||
if v:
|
if v:
|
||||||
parts.append(f"{k}={v}")
|
parts.append(f"{k}={v}")
|
||||||
|
if not parts:
|
||||||
|
# 无任何硬件字段:用 device_id 兜底,避免全设备空哈希误撞
|
||||||
|
did = merged.get("device_id", "")
|
||||||
|
if did:
|
||||||
|
parts.append(f"device_id={did}")
|
||||||
raw = "|".join(parts)
|
raw = "|".join(parts)
|
||||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
28
sdk/tests/test_qrcode_bind_and_offline_delete.py
Normal file
28
sdk/tests/test_qrcode_bind_and_offline_delete.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "app"))
|
||||||
|
|
||||||
|
from routers import devices
|
||||||
|
from routers.qrcode import _build_qr_content, _render_qr_png_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def test_pairing_qr_encodes_server_and_token():
|
||||||
|
content = _build_qr_content("cunkebao", "工作手机", "wss://wpsdk.quwanzhi.com/ws/device", pairing_token="pair-token")
|
||||||
|
assert "wpsdk.quwanzhi.com" in content
|
||||||
|
assert "pair-token" in content
|
||||||
|
assert _render_qr_png_bytes(content).startswith(b"\x89PNG")
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_routes_expose_guarded_delete():
|
||||||
|
routes = {(route.path, tuple(sorted(route.methods or []))) for route in devices.router.routes}
|
||||||
|
assert ("/devices/{device_id}", ("DELETE",)) in routes
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_page_exposes_bind_and_offline_delete_actions():
|
||||||
|
hub = (ROOT / "app" / "static" / "hub.html").read_text()
|
||||||
|
assert "openBindQRCode()" in hub
|
||||||
|
assert "扫码绑定" in hub
|
||||||
|
assert "deleteOfflineDevice" in hub
|
||||||
|
assert "扫描 ADB" in hub
|
||||||
@@ -3785,4 +3785,18 @@ v3.1: Agent 内置 AI Brain → 心跳驱动自主决策 → Frida优先/u2兜
|
|||||||
|
|
||||||
**进度**: 接口层 100% · 真机 E2E ~90%(等 ADB)
|
**进度**: 接口层 100% · 真机 E2E ~90%(等 ADB)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
### 2026-08-08 | ChatGPT 项目对话协同方案
|
||||||
|
|
||||||
|
**完成项**:
|
||||||
|
1. 按公开官方文档核验 ChatGPT Projects 的项目记忆、共享与同项目对话聚合能力。
|
||||||
|
2. 核验 Codex 的代码任务、测试证据、本地/云端协同与 Git 环境能力。
|
||||||
|
3. 新增《ChatGPT_Codex_卡若AI协同方案_20260808》与 CP1~CP4 需求台账。
|
||||||
|
4. 定义边界:项目对话仅做资料与索引,设备动作仍经现有 API → Agent → 回读 → 审计闭环。
|
||||||
|
|
||||||
|
**进度**:方案制作 100%;P0 建立 ChatGPT 项目待执行;代码改造 0%。
|
||||||
|
### 2026-08-08 21:47|扫码绑定入口与离线设备删除
|
||||||
|
- 已把设备页 `扫描` 拆分为 `扫码绑定` 与 `扫描 ADB`;扫码绑定会自动生成当前服务器二维码。
|
||||||
|
- 已新增离线设备删除接口和页面按钮,在线设备有 409 保护,保留命令审计。
|
||||||
|
- 验收:本地定向测试 3/3 通过;宝塔容器 healthy、二维码接口 HTTP 200 且返回 PNG、页面标识回读通过、删除不存在设备返回 404。当前 WSS 在线设备 0 台,等待手机扫码注册。
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,15 @@ curl -X POST http://127.0.0.1:8899/api/v3/health 2>/dev/null || true
|
|||||||
- 如果控制台无法触摸:确认 `docker compose logs` 有 `websocket connected` 并回传心跳
|
- 如果控制台无法触摸:确认 `docker compose logs` 有 `websocket connected` 并回传心跳
|
||||||
- 如果外网解析到错误 IP:先在本地 `dig +short wpsdk.quwanzhi.com` 与宝塔机器 `ping` 比对
|
- 如果外网解析到错误 IP:先在本地 `dig +short wpsdk.quwanzhi.com` 与宝塔机器 `ping` 比对
|
||||||
|
|
||||||
|
## 九、2026-08-08 线上核验记录
|
||||||
|
|
||||||
|
- `https://wpsdk.quwanzhi.com/health` 返回 HTTP 200,服务版本为 `3.0.0`,当前在线设备数为 0。
|
||||||
|
- 未登录访问 `/hub` 返回 HTTP 401 `API Key 无效`,说明生产环境已开启鉴权,扫码页面需要先用管理员账号登录。
|
||||||
|
- `/api/v3/qrcode/generate` 未携带线上匹配密钥时返回 HTTP 401,二维码路由本身已发布,空白区域是未完成登录或生成前的初始状态。
|
||||||
|
- 本机 `sdk/.env` 中存在 API Key,但与公网服务当前使用的 API Key 不一致;需要在宝塔 `/www/wwwroot/workphone-sdk/.env` 对齐后重启容器。
|
||||||
|
- 对齐后验收顺序:打开 `/hub?tab=devices` → 管理员登录 → 点击“扫描服务器并作为绑定基准” → 点击“生成绑定二维码” → 手机端扫码 → 回到设备列表观察 WS 在线数。
|
||||||
|
- 本轮只做了公网只读核验和本地页面、文档更新,未执行远程上传、容器重启或真实设备写操作。
|
||||||
|
|
||||||
## 九、上线后交付物
|
## 九、上线后交付物
|
||||||
|
|
||||||
- 已执行命令列表
|
- 已执行命令列表
|
||||||
@@ -160,3 +169,29 @@ curl -X POST http://127.0.0.1:8899/api/v3/health 2>/dev/null || true
|
|||||||
- 服务器本地 `curl http://127.0.0.1:8899/health` 返回同样健康状态。
|
- 服务器本地 `curl http://127.0.0.1:8899/health` 返回同样健康状态。
|
||||||
- 访问说明:
|
- 访问说明:
|
||||||
- 控制台页面可能返回 401(鉴权网关)是正常行为;需登录后使用 `/hub`。
|
- 控制台页面可能返回 401(鉴权网关)是正常行为;需登录后使用 `/hub`。
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-08|扫码版本差异复核
|
||||||
|
|
||||||
|
- 复核结论:本地 `sdk/app/static/hub.html` 已包含“扫码绑定手机”面板和 `POST /api/v3/qrcode/generate` 调用;用户反馈线上页面仍为旧版,按“线上未同步”处理。
|
||||||
|
- 根因:此前完成了本地页面和 v1.0 交付包更新,但没有在本轮执行宝塔远程同步,因此本地与线上版本存在差异。
|
||||||
|
- 正式发布命令:`TARGET=cunkebao REMOTE_DIR=/www/wwwroot/workphone-sdk bash sdk/scripts/deploy_baota_wpsdk.sh`。
|
||||||
|
- 发布后必须验证:登录 `https://wpsdk.quwanzhi.com/hub?tab=devices`,确认“扫码绑定手机”面板、扫描服务器按钮、生成绑定二维码按钮均出现,再执行手机扫码。
|
||||||
|
- 回滚方式:使用发布前保存的 `app/` 目录和 Docker 镜像重新执行 `docker compose -f docker-compose.baota.yml up -d --build`,再重载 Nginx。
|
||||||
|
- 本轮已完成本地发布准备和源码路由校验;远程同步和容器重启需在宝塔主机执行。
|
||||||
|
|
||||||
|
## 2026-08-08 21:47 扫码绑定与失效设备清理修复
|
||||||
|
|
||||||
|
- **现象**:设备页顶部“扫描”实际执行 ADB 扫描,未直接展示绑定二维码,造成扫码入口混淆。
|
||||||
|
- **处理**:设备页新增 `📷 扫码绑定`,点击后切回绑定区并自动调用 `POST /api/v3/qrcode/generate` 渲染二维码;原按钮明确改名为 `📡 扫描 ADB`。
|
||||||
|
- **绑定内容**:二维码仅包含当前服务 WSS 地址和配对令牌,控制台使用管理员会话,外部服务使用 API Key。
|
||||||
|
- **清理能力**:新增 `DELETE /api/v3/devices/{device_id}`;仅允许删除离线登记设备,在线设备返回 `409`,历史命令记录保留审计。
|
||||||
|
- **验收**:本地 `pytest tests/test_qrcode_bind_and_offline_delete.py -q` 通过;上线后回读二维码生成、页面标识、离线删除保护及健康状态。
|
||||||
|
### 2026-08-08 21:48 上线验收结果
|
||||||
|
|
||||||
|
- `workphone-sdk-baota` 已重建并显示 `healthy`。
|
||||||
|
- 服务器内网 `POST /api/v3/qrcode/generate`:HTTP 200,`success=true`,返回 PNG Data URL,二维码中含 `wss://wpsdk.quwanzhi.com/ws/device`。
|
||||||
|
- 页面源码回读确认 `openBindQRCode`、`扫码绑定`、`扫描 ADB`、`删除失效设备` 均已上线。
|
||||||
|
- `DELETE /api/v3/devices/__verification_missing_device__` 返回 HTTP 404,确认删除接口受设备存在性校验保护;在线设备另有 HTTP 409 保护。
|
||||||
|
- 公网健康检查:`https://wpsdk.quwanzhi.com/health` 返回 healthy;当前 `devices_online=0`,等待手机扫描二维码并完成 WSS 注册。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user