fix: expose device pairing QR and guarded cleanup

This commit is contained in:
Manus AI
2026-08-08 21:48:34 +08:00
parent c372cbc57b
commit e36b05159b
6 changed files with 2071 additions and 397 deletions

View File

@@ -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 心跳监控仪表盘 ==========

View File

@@ -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硬件字段缺失时绝不能让所有设备
都得到空串 MD5d41d8cd98f00b204e9800998ecf8427e—— 那会令全部设备
互相"指纹碰撞",使碰撞检测失效。无硬件数据时回退到 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

View 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

View File

@@ -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》与 CP1CP4 需求台账。
4. 定义边界:项目对话仅做资料与索引,设备动作仍经现有 API → Agent → 回读 → 审计闭环。
**进度**:方案制作 100%P0 建立 ChatGPT 项目待执行;代码改造 0%。
### 2026-08-08 21:47扫码绑定入口与离线设备删除
- 已把设备页 `扫描` 拆分为 `扫码绑定``扫描 ADB`;扫码绑定会自动生成当前服务器二维码。
- 已新增离线设备删除接口和页面按钮,在线设备有 409 保护,保留命令审计。
- 验收:本地定向测试 3/3 通过;宝塔容器 healthy、二维码接口 HTTP 200 且返回 PNG、页面标识回读通过、删除不存在设备返回 404。当前 WSS 在线设备 0 台,等待手机扫码注册。

View File

@@ -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 注册。