Files
workphone-sdk/sdk/app/routers/fleet.py

225 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
多手机设备管理接口。
面向存客宝/触客宝/超管等外部系统:把单机 SDK 能力包装成可筛选、
可批量执行、可审计的 Fleet API。单机能力仍由 devices/unified 路由负责。
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from services.device_fleet import list_merged_local_devices
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",
}
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
def _normalize_status(device: dict) -> str:
if ws_hub.is_online(device.get("device_id", "")):
return "online"
return str(device.get("status") or "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]:
devices = await list_merged_local_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)
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")]
adb = [d for d in devices if d.get("status") == "adb"]
offline = [d for d in devices if not d.get("online") and d.get("status") != "adb"]
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": len(adb),
"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)
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)
async def fleet_execute(req: FleetExecuteRequest):
"""在多台在线手机上批量执行同一个设备动作。"""
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:
raise HTTPException(status_code=404, detail="没有匹配的在线设备")
is_write = req.action in WRITE_ACTIONS
if is_write and len(devices) > 1 and not (req.dry_run or req.confirm):
return {
"code": 200,
"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,
"data": {
"success": True,
"dry_run": True,
"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:
result = await ws_hub.send_command(
did,
{
"type": "execute",
"data": {
"script": req.platform,
"action": req.action,
"params": req.params or {},
"hook_only": req.hook_only,
},
},
timeout=timeout,
)
return {
"device_id": did,
"device_id_md5": device_id_md5(did),
"success": result.get("code") == 200,
"result": result,
}
except Exception as exc:
return {
"device_id": did,
"device_id_md5": device_id_md5(did),
"success": False,
"error": str(exc),
}
results = await asyncio.gather(*(run_one(device) for device in devices))
ok = sum(1 for item in results if item.get("success"))
return {
"code": 200,
"data": {
"success": ok == len(results),
"action": req.action,
"target_count": len(results),
"success_count": ok,
"failed_count": len(results) - ok,
"results": results,
},
}