临时批量回收设备脚本
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 48s

This commit is contained in:
2026-07-28 11:05:05 +08:00
parent 178cc45bc2
commit 4aff9937e5
9 changed files with 889 additions and 0 deletions

View File

@@ -0,0 +1,574 @@
#!/usr/bin/env python3
"""批量回收设备脚本:读取单列 CSV通过现有后台接口回收设备。
默认只执行预演。只有显式传入 --execute 时,才会查询设备并调用
POST /api/admin/devices/recall。脚本仅使用 Python 标准库。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from getpass import getpass
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
DEVICE_LIST_PATH = "/api/admin/devices"
DEVICE_RECALL_PATH = "/api/admin/devices/recall"
LOGIN_PATH = "/api/admin/login"
AUTH_ERROR_CODES = {1002, 1003, 1004}
MAX_RECALL_BATCH_SIZE = 100
HEADER_NAMES = {
"device_identifier",
"identifier",
"virtual_no",
"imei",
"设备标识",
"虚拟号",
}
@dataclass(frozen=True)
class DeviceInput:
"""保存 CSV 中的设备标识及原始行号。"""
line_no: int
identifier: str
@dataclass(frozen=True)
class ResolvedDevice:
"""保存接口解析出的设备信息。"""
source: DeviceInput
device_id: int
virtual_no: str
imei: str
@dataclass(frozen=True)
class HTTPResult:
"""保存一次 HTTP 请求的响应信息。"""
status: int
body: dict[str, Any] | None
raw_body: str
class RequestFailedError(Exception):
"""表示请求尚未获得可解析的 HTTP 响应。"""
class AdminAPIClient:
"""调用后台认证、设备查询和设备回收接口的轻量客户端。"""
def __init__(self, base_url: str, timeout: float) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def login(self, username: str, password: str) -> str:
"""使用后台账号登录并返回 Access Token。"""
result = self._request_json(
"POST",
LOGIN_PATH,
{"username": username, "password": password, "device": "web"},
token=None,
)
code = response_code(result.body)
if not is_success(result.status, code):
raise RequestFailedError(
f"登录失败HTTP {result.status}code={display_value(code)}"
f"msg={response_message(result.body, result.raw_body)}"
)
data = result.body.get("data") if result.body else None
token = data.get("access_token") if isinstance(data, dict) else None
if not isinstance(token, str) or not token.strip():
raise RequestFailedError("登录响应中缺少 data.access_token")
return token.strip()
def find_devices(self, token: str, identifier: str) -> HTTPResult:
"""使用现有设备列表接口按关键字查询候选设备。"""
query = urlencode({"keyword": identifier, "page": 1, "page_size": 100})
return self._request_json("GET", f"{DEVICE_LIST_PATH}?{query}", None, token)
def recall_devices(
self,
token: str,
devices: list[ResolvedDevice],
remark: str,
) -> HTTPResult:
"""调用现有接口回收一批设备。"""
return self._request_json(
"POST",
DEVICE_RECALL_PATH,
{"device_ids": [device.device_id for device in devices], "remark": remark},
token,
)
def _request_json(
self,
method: str,
path: str,
payload: dict[str, Any] | None,
token: str | None,
) -> HTTPResult:
body = None
if payload is not None:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
headers = {"Accept": "application/json", "User-Agent": "junhong-batch-device-recall/1.0"}
if payload is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
request = Request(self.base_url + path, data=body, headers=headers, method=method)
try:
with urlopen(request, timeout=self.timeout) as response:
raw_body = response.read().decode("utf-8", errors="replace")
return HTTPResult(response.status, parse_json_object(raw_body), raw_body)
except HTTPError as exc:
raw_body = exc.read().decode("utf-8", errors="replace")
return HTTPResult(exc.code, parse_json_object(raw_body), raw_body)
except (URLError, TimeoutError, OSError) as exc:
raise RequestFailedError(f"请求失败:{exc}") from exc
def parse_args() -> argparse.Namespace:
"""解析命令行参数。"""
parser = argparse.ArgumentParser(
description="读取单列 CSV通过 /api/admin/devices/recall 批量回收设备",
)
parser.add_argument(
"--base-url",
default=os.getenv("JUNHONG_ADMIN_BASE_URL", ""),
help="接口 Base URL也可使用 JUNHONG_ADMIN_BASE_URL",
)
parser.add_argument("--csv", required=True, help="单列设备 CSV 路径,首行可有表头")
parser.add_argument(
"--remark",
default="生产环境 CSV 批量回收设备",
help="回收备注",
)
parser.add_argument(
"--token",
default=os.getenv("JUNHONG_ADMIN_TOKEN", ""),
help="后台 Access Token也可使用 JUNHONG_ADMIN_TOKEN",
)
parser.add_argument(
"--username",
default=os.getenv("JUNHONG_ADMIN_USERNAME", ""),
help="未提供 Token 时用于自动登录",
)
parser.add_argument(
"--password",
default=os.getenv("JUNHONG_ADMIN_PASSWORD", ""),
help="后台登录密码;建议使用环境变量",
)
parser.add_argument("--output", default="", help="结果 CSV 路径;默认输出到输入文件同目录")
parser.add_argument("--timeout", type=float, default=30.0, help="单次请求超时秒数(默认 30")
parser.add_argument("--interval", type=float, default=0.2, help="每批回收后的间隔秒数(默认 0.2")
parser.add_argument(
"--execute",
action="store_true",
help="真实查询并回收设备;不传时只校验 CSV 和预览",
)
return parser.parse_args()
def load_devices(csv_path: Path) -> list[DeviceInput]:
"""读取单列 CSV并在调用接口前拦截重复标识。"""
if not csv_path.exists():
raise ValueError(f"找不到 CSV 文件:{csv_path}")
if not csv_path.is_file():
raise ValueError(f"CSV 路径不是文件:{csv_path}")
devices: list[DeviceInput] = []
first_line_by_identifier: dict[str, int] = {}
errors: list[str] = []
first_nonempty_seen = False
with csv_path.open("r", encoding="utf-8-sig", newline="") as file:
for line_no, row in enumerate(csv.reader(file), start=1):
if not any(value.strip() for value in row):
continue
if len(row) != 1:
errors.append(f"{line_no} 行必须正好有一列,实际读取到 {len(row)}")
continue
identifier = row[0].strip()
if not first_nonempty_seen:
first_nonempty_seen = True
if identifier.lower() in HEADER_NAMES:
continue
if not identifier:
errors.append(f"{line_no} 行设备标识不能为空")
continue
if len(identifier) > 100:
errors.append(f"{line_no} 行设备标识不能超过 100 个字符")
continue
if identifier in first_line_by_identifier:
errors.append(
f"{line_no} 行与第 {first_line_by_identifier[identifier]} 行重复:{identifier}"
)
continue
first_line_by_identifier[identifier] = line_no
devices.append(DeviceInput(line_no, identifier))
if errors:
raise ValueError(format_errors("CSV 校验失败,请修正后重试", errors))
if not devices:
raise ValueError("CSV 中没有有效的 IMEI 或虚拟号")
return devices
def resolve_devices(
client: AdminAPIClient,
token: str,
inputs: list[DeviceInput],
) -> list[ResolvedDevice]:
"""在任何回收请求前精确解析全部标识,确保整批输入可用。"""
resolved: list[ResolvedDevice] = []
first_line_by_device_id: dict[int, int] = {}
errors: list[str] = []
for index, item in enumerate(inputs, start=1):
result = client.find_devices(token, item.identifier)
code = response_code(result.body)
if not is_success(result.status, code):
errors.append(
f"{item.line_no} 行查询失败HTTP {result.status}code={display_value(code)}"
f"msg={response_message(result.body, result.raw_body)}"
)
if result.status == 401 or code in AUTH_ERROR_CODES:
break
continue
matches = exact_device_matches(result.body, item.identifier)
if len(matches) != 1:
reason = "未找到设备" if not matches else "同时精确匹配多个设备"
errors.append(f"{item.line_no}{reason}{item.identifier}")
continue
device = matches[0]
device_id = parse_positive_int(device.get("id"))
if device_id is None:
errors.append(f"{item.line_no} 行设备响应缺少有效 ID{item.identifier}")
continue
if device_id in first_line_by_device_id:
errors.append(
f"{item.line_no} 行与第 {first_line_by_device_id[device_id]} 行指向同一设备:"
f"{item.identifier}"
)
continue
first_line_by_device_id[device_id] = item.line_no
resolved.append(
ResolvedDevice(
source=item,
device_id=device_id,
virtual_no=display_value(device.get("virtual_no")),
imei=display_value(device.get("imei")),
)
)
print(f"[{index}/{len(inputs)}] 已解析:{item.identifier} -> 设备ID {device_id}")
if errors:
raise ValueError(format_errors("设备预检查失败,未发送任何回收请求", errors))
return resolved
def exact_device_matches(body: dict[str, Any] | None, identifier: str) -> list[dict[str, Any]]:
"""从模糊查询结果中保留 IMEI 或虚拟号精确匹配项。"""
data = body.get("data") if body else None
items = data.get("items") if isinstance(data, dict) else None
if not isinstance(items, list):
return []
return [
item
for item in items
if isinstance(item, dict)
and (item.get("virtual_no") == identifier or item.get("imei") == identifier)
]
def recall_batch_rows(
batch: list[ResolvedDevice],
result: HTTPResult,
) -> tuple[list[dict[str, object]], bool]:
"""把一次批量回收响应转换为逐设备结果行。"""
code = response_code(result.body)
message = response_message(result.body, result.raw_body)
if not is_success(result.status, code):
return [result_row(device, False, result.status, code, message) for device in batch], False
data = result.body.get("data") if result.body else None
if not isinstance(data, dict):
message = "接口返回成功但缺少回收结果,请人工核对"
return [result_row(device, False, result.status, code, message) for device in batch], False
failed_items = data.get("failed_items")
failed_by_id: dict[int, str] = {}
if isinstance(failed_items, list):
for item in failed_items:
if not isinstance(item, dict):
continue
device_id = parse_positive_int(item.get("device_id"))
if device_id is not None:
failed_by_id[device_id] = display_value(item.get("reason")) or "回收失败"
success_count = parse_nonnegative_int(data.get("success_count"))
fail_count = parse_nonnegative_int(data.get("fail_count"))
if (
success_count is None
or fail_count is None
or success_count + fail_count != len(batch)
or fail_count != len(failed_by_id)
):
message = "接口回收统计与请求数量不一致,请人工核对"
return [result_row(device, False, result.status, code, message) for device in batch], False
rows = [
result_row(
device,
device.device_id not in failed_by_id,
result.status,
code,
failed_by_id.get(device.device_id, message),
)
for device in batch
]
return rows, not failed_by_id
def result_row(
device: ResolvedDevice,
success: bool,
http_status: int | str,
code: int | str | None,
message: str,
) -> dict[str, object]:
"""构造结果 CSV 的单行内容。"""
return {
"line_no": device.source.line_no,
"identifier": device.source.identifier,
"device_id": device.device_id,
"virtual_no": device.virtual_no,
"imei": device.imei,
"status": "成功" if success else "失败",
"http_status": http_status,
"code": display_value(code),
"msg": message,
}
def execute(
devices: list[ResolvedDevice],
client: AdminAPIClient,
token: str,
remark: str,
output_path: Path,
interval: float,
) -> int:
"""按接口上限分批回收,并把每批结果立即写入 CSV。"""
output_path.parent.mkdir(parents=True, exist_ok=True)
success_count = 0
failed_count = 0
with output_path.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(
file,
fieldnames=[
"line_no", "identifier", "device_id", "virtual_no", "imei",
"status", "http_status", "code", "msg",
],
)
writer.writeheader()
file.flush()
batches = list(chunks(devices, MAX_RECALL_BATCH_SIZE))
for batch_index, batch in enumerate(batches, start=1):
try:
result = client.recall_devices(token, batch, remark)
rows, batch_success = recall_batch_rows(batch, result)
except RequestFailedError as exc:
rows = [result_row(device, False, "", None, str(exc)) for device in batch]
batch_success = False
result = None
for row in rows:
writer.writerow(row)
if row["status"] == "成功":
success_count += 1
else:
failed_count += 1
file.flush()
print(
f"[{batch_index}/{len(batches)}] 本批 {len(batch)} 台:"
f"成功 {sum(row['status'] == '成功' for row in rows)}"
f"失败 {sum(row['status'] != '成功' for row in rows)}"
)
if result is not None:
code = response_code(result.body)
if result.status == 401 or code in AUTH_ERROR_CODES:
print("认证已失效,停止后续回收;已处理结果已保存。", file=sys.stderr)
break
if not batch_success:
print("本批存在失败,请根据结果文件人工核对。", file=sys.stderr)
if interval > 0 and batch_index < len(batches):
time.sleep(interval)
print(f"执行结束:成功 {success_count} 条,失败 {failed_count} 条。")
print(f"结果文件:{output_path}")
return 0 if success_count == len(devices) and failed_count == 0 else 2
def preview(devices: list[DeviceInput], base_url: str, remark: str) -> int:
"""输出预演信息,不发送 HTTP 请求。"""
print("预演完成:未发送任何 HTTP 请求。")
print(f"设备数量:{len(devices)}")
print(f"查询接口:{base_url.rstrip('/')}{DEVICE_LIST_PATH}?keyword=<设备标识>")
print(f"回收接口:{base_url.rstrip('/')}{DEVICE_RECALL_PATH}")
print(f"回收备注:{remark}")
print("标识示例:")
for device in devices[:5]:
print(f"{device.line_no} 行:{device.identifier}")
if len(devices) > 5:
print(f" 其余 {len(devices) - 5} 条已省略")
print("增加 --execute 后,脚本会先精确解析全部设备,再开始分批回收。")
return 0
def chunks(devices: list[ResolvedDevice], size: int):
"""按固定大小切分设备列表。"""
for start in range(0, len(devices), size):
yield devices[start:start + size]
def parse_json_object(raw_body: str) -> dict[str, Any] | None:
"""尝试把响应正文解析为 JSON 对象。"""
if not raw_body.strip():
return None
try:
value = json.loads(raw_body)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
def response_code(body: dict[str, Any] | None) -> int | str | None:
"""读取统一响应中的业务错误码。"""
if not body:
return None
code = body.get("code")
if isinstance(code, bool):
return int(code)
if isinstance(code, int):
return code
if isinstance(code, str):
stripped = code.strip()
return int(stripped) if stripped.isdigit() else stripped
return None
def response_message(body: dict[str, Any] | None, raw_body: str) -> str:
"""读取统一响应消息。"""
if body:
message = body.get("msg", body.get("message", ""))
if message is not None and str(message).strip():
return str(message).strip()
text = raw_body.strip().replace("\r", " ").replace("\n", " ")
return text[:500] if text else "接口未返回错误信息"
def is_success(http_status: int, code: int | str | None) -> bool:
"""同时校验 HTTP 状态码和业务响应码。"""
return 200 <= http_status < 300 and str(code) == "0"
def parse_positive_int(value: object) -> int | None:
"""解析正整数。"""
parsed = parse_nonnegative_int(value)
return parsed if parsed is not None and parsed > 0 else None
def parse_nonnegative_int(value: object) -> int | None:
"""解析非负整数,拒绝布尔值。"""
if isinstance(value, bool):
return None
if isinstance(value, int) and value >= 0:
return value
if isinstance(value, str) and value.strip().isdigit():
return int(value.strip())
return None
def display_value(value: object) -> str:
"""把可能为空的字段转为文本。"""
return "" if value is None else str(value)
def format_errors(title: str, errors: list[str]) -> str:
"""截断并格式化批量错误。"""
preview = "\n".join(f" - {error}" for error in errors[:20])
if len(errors) > 20:
preview += f"\n - 其余 {len(errors) - 20} 个错误已省略"
return f"{title}\n{preview}"
def resolve_output_path(input_path: Path, output_arg: str) -> Path:
"""生成结果文件路径。"""
if output_arg.strip():
return Path(output_arg).expanduser().resolve()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return input_path.with_name(f"{input_path.stem}_回收结果_{timestamp}.csv")
def resolve_token(args: argparse.Namespace, client: AdminAPIClient) -> str:
"""优先使用现有 Token否则使用后台账号自动登录。"""
if args.token.strip():
return args.token.strip()
if not args.username.strip():
raise ValueError("真实执行需要 --token或同时提供 --username/--password")
password = args.password
if not password and sys.stdin.isatty():
password = getpass("请输入后台登录密码:")
if not password:
raise ValueError("使用账号登录时必须提供密码")
print(f"正在使用后台账号 {args.username.strip()!r} 获取 Access Token...")
return client.login(args.username.strip(), password)
def main() -> int:
"""校验参数,执行预演或真实批量回收。"""
args = parse_args()
try:
base_url = args.base_url.strip()
if not base_url:
raise ValueError("必须通过 --base-url 或 JUNHONG_ADMIN_BASE_URL 配置接口地址")
if not base_url.startswith(("http://", "https://")):
raise ValueError("base-url 必须以 http:// 或 https:// 开头")
remark = args.remark.strip()
if not remark or len(remark) > 500:
raise ValueError("remark 必须为 1 至 500 个字符")
if args.timeout <= 0 or args.interval < 0:
raise ValueError("timeout 必须大于 0interval 不能小于 0")
input_path = Path(args.csv).expanduser().resolve()
inputs = load_devices(input_path)
if not args.execute:
return preview(inputs, base_url, remark)
output_path = resolve_output_path(input_path, args.output)
if output_path == input_path:
raise ValueError("结果文件不能与输入 CSV 使用同一路径")
if output_path.exists():
raise ValueError(f"结果文件已存在,请更换 --output 路径:{output_path}")
client = AdminAPIClient(base_url, args.timeout)
token = resolve_token(args, client)
resolved = resolve_devices(client, token, inputs)
print(f"预检查通过:{len(resolved)} 台设备;即将按每批最多 100 台真实回收。")
return execute(resolved, client, token, remark, output_path, args.interval)
except (ValueError, RequestFailedError) as exc:
print(f"错误:{exc}", file=sys.stderr)
return 1
except KeyboardInterrupt:
print("\n用户中断执行;已写入的结果会保留。", file=sys.stderr)
return 130
if __name__ == "__main__":
sys.exit(main())