225 lines
7.0 KiB
Python
225 lines
7.0 KiB
Python
"""
|
||
多手机设备管理接口。
|
||
|
||
面向存客宝/触客宝/超管等外部系统:把单机 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,
|
||
},
|
||
}
|