#!/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 必须大于 0,interval 不能小于 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())