From 4aff9937e55ce62fa82fe8fcafd65d75da7097d0 Mon Sep 17 00:00:00 2001 From: break Date: Tue, 28 Jul 2026 11:05:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=B4=E6=97=B6=E6=89=B9=E9=87=8F=E5=9B=9E?= =?UTF-8?q?=E6=94=B6=E8=AE=BE=E5=A4=87=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/批量回收设备脚本/功能总结.md | 21 + scripts/batch_device_recall/README.md | 44 ++ .../batch_device_recall.cpython-313.pyc | Bin 0 -> 31826 bytes .../test_batch_device_recall.cpython-313.pyc | Bin 0 -> 6509 bytes .../batch_device_recall.py | 574 ++++++++++++++++++ .../batch_device_recall/devices.example.csv | 61 ++ ...vices.example_回收结果_20260728_110203.csv | 61 ++ .../test_batch_device_recall.py | 127 ++++ 9 files changed, 889 insertions(+) create mode 100644 docs/批量回收设备脚本/功能总结.md create mode 100644 scripts/batch_device_recall/README.md create mode 100644 scripts/batch_device_recall/__pycache__/batch_device_recall.cpython-313.pyc create mode 100644 scripts/batch_device_recall/__pycache__/test_batch_device_recall.cpython-313.pyc create mode 100644 scripts/batch_device_recall/batch_device_recall.py create mode 100644 scripts/batch_device_recall/devices.example.csv create mode 100644 scripts/batch_device_recall/devices.example_回收结果_20260728_110203.csv create mode 100644 scripts/batch_device_recall/test_batch_device_recall.py diff --git a/README.md b/README.md index d7d908d..83cf9d4 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,7 @@ default: - **批量同步**:卡状态、实名状态、流量使用情况 - **批量购买套餐脚本**:支持从单列 CSV 读取 ICCID/虚拟号,逐资产调用后台订单接口购买统一套餐,提供预演、重复拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量购买套餐脚本/功能总结.md) - **批量换货脚本**:支持从双列 CSV 读取新旧资产映射,固定执行直接换货和全量数据迁移,提供预演、批次冲突拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_exchange/README.md) 与 [功能总结](docs/批量换货脚本/功能总结.md) +- **批量回收设备脚本**:支持从单列 CSV 读取 IMEI/虚拟号,复用已部署的设备查询与回收接口,提供整批预检查、默认预演和逐设备结果记录。详见 [使用说明](scripts/batch_device_recall/README.md) 与 [功能总结](docs/批量回收设备脚本/功能总结.md) - **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md) - **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/) - **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md) diff --git a/docs/批量回收设备脚本/功能总结.md b/docs/批量回收设备脚本/功能总结.md new file mode 100644 index 0000000..9c6137d --- /dev/null +++ b/docs/批量回收设备脚本/功能总结.md @@ -0,0 +1,21 @@ +# 批量回收设备脚本功能总结 + +## 用途 + +生产环境尚未部署设备异步 CSV 批量任务时,使用 `scripts/batch_device_recall/batch_device_recall.py` 读取单列 IMEI/虚拟号 CSV,并调用已经部署的后台设备接口完成回收。 + +## 安全边界 + +- 默认仅预演,显式传入 `--execute` 才会请求接口。 +- 真实执行前通过 `GET /api/admin/devices` 精确解析全部标识;任何设备未找到、匹配不唯一或重复引用都会整批停止。 +- 设备解析完成后按接口上限每批最多 100 台调用 `POST /api/admin/devices/recall`。 +- 不直接修改数据库,继续复用既有权限校验、设备与绑定卡归属同步、分配记录、审计日志和缓存失效逻辑。 +- 回收请求不自动重试,避免服务端成功但客户端未收到响应时产生误判。 + +## 验证 + +运行: + +```bash +python3 -m unittest scripts/batch_device_recall/test_batch_device_recall.py +``` diff --git a/scripts/batch_device_recall/README.md b/scripts/batch_device_recall/README.md new file mode 100644 index 0000000..7c95c8d --- /dev/null +++ b/scripts/batch_device_recall/README.md @@ -0,0 +1,44 @@ +# 批量回收设备脚本 + +该脚本读取单列 CSV,通过生产环境已有的设备列表和回收接口处理设备,不依赖尚未部署的异步 CSV 批量任务。 + +默认只预演,不发送 HTTP 请求。只有增加 `--execute` 才会先解析全部设备,再按每批最多 100 台调用 `POST /api/admin/devices/recall`。接口会同步设备及其绑定卡的归属,并保留分配记录和审计日志。 + +## CSV 格式 + +首行表头可选,每行填写一个设备 IMEI 或虚拟号: + +```csv +device_identifier +868120000000001 +VIRTUAL000001 +``` + +脚本会拦截多列、空值、重复标识、未找到设备、模糊匹配和同一设备被 IMEI、虚拟号重复引用的情况。全部设备预检查通过后才会发送回收请求。 + +回收目标沿用登录账号的既有接口权限:平台账号回收到平台库存,代理账号只能从直属下级回收到自己的店铺。 + +## 预演 + +```bash +python3 scripts/batch_device_recall/batch_device_recall.py \ + --base-url https://cmp-api.example.com \ + --csv scripts/batch_device_recall/devices.example.csv +``` + +## 真实执行 + +推荐通过环境变量传递 Token: + +```bash +JUNHONG_ADMIN_TOKEN='<后台Access Token>' \ +python3 scripts/batch_device_recall/batch_device_recall.py \ + --base-url https://cmp-api.example.com \ + --csv /path/to/devices.csv \ + --remark '生产环境人工批量回收' \ + --execute +``` + +未提供 Token 时,也可使用 `JUNHONG_ADMIN_USERNAME` 和 `JUNHONG_ADMIN_PASSWORD` 自动登录。 + +结果默认写入输入文件同目录的 `原文件名_回收结果_YYYYMMDD_HHMMSS.csv`。脚本不会自动重试回收请求;网络异常时应先核对设备归属,再决定是否重跑失败项。 diff --git a/scripts/batch_device_recall/__pycache__/batch_device_recall.cpython-313.pyc b/scripts/batch_device_recall/__pycache__/batch_device_recall.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..35995fc6ce0e5552074fa8a8ade673de434b0861 GIT binary patch literal 31826 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 bkXrZnrbjk)?R;|A@73vJDnXsg#E<_UNw1W` literal 0 HcmV?d00001 diff --git a/scripts/batch_device_recall/__pycache__/test_batch_device_recall.cpython-313.pyc b/scripts/batch_device_recall/__pycache__/test_batch_device_recall.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aff3962da03055036dc5866fbf7efc1dd5cc4d0b GIT binary patch literal 6509 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 literal 0 HcmV?d00001 diff --git a/scripts/batch_device_recall/batch_device_recall.py b/scripts/batch_device_recall/batch_device_recall.py new file mode 100644 index 0000000..697761e --- /dev/null +++ b/scripts/batch_device_recall/batch_device_recall.py @@ -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 必须大于 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()) diff --git a/scripts/batch_device_recall/devices.example.csv b/scripts/batch_device_recall/devices.example.csv new file mode 100644 index 0000000..a484d36 --- /dev/null +++ b/scripts/batch_device_recall/devices.example.csv @@ -0,0 +1,61 @@ +device_identifier +862639073065981 +862639073536379 +862639073876858 +862639071874939 +862639073762900 +862639071854352 +862639073461925 +862639073996755 +862639073027965 +862639073337596 +862639073775027 +862639071857918 +862639073005409 +862639075961740 +862639073877062 +862639073940258 +862639073863336 +862639071971818 +862639073329528 +862639073015960 +862639071942918 +862639073787832 +862639073918049 +862639075954604 +862639071986337 +862639073831101 +862639073397517 +862639075961781 +862639073934822 +862639073981799 +862639071857900 +862639073785372 +862639073017438 +862639073778799 +862639073369672 +862639071974481 +862639071920955 +862639073062491 +862639073867253 +862639071912374 +862639071854402 +862639073538466 +862639073885289 +862639073394191 +862639073903652 +862639073949309 +862639071932281 +862639075954364 +862639073955231 +862639073061121 +862639073003941 +862639073502140 +862639073966170 +862639073476576 +862639073775076 +862639073383434 +862639073488316 +862639073948756 +862639073368856 +862639073966527 diff --git a/scripts/batch_device_recall/devices.example_回收结果_20260728_110203.csv b/scripts/batch_device_recall/devices.example_回收结果_20260728_110203.csv new file mode 100644 index 0000000..b1e0c5d --- /dev/null +++ b/scripts/batch_device_recall/devices.example_回收结果_20260728_110203.csv @@ -0,0 +1,61 @@ +line_no,identifier,device_id,virtual_no,imei,status,http_status,code,msg +2,862639073065981,237,862639073065981,862639073065981,成功,200,0,success +3,862639073536379,219,862639073536379,862639073536379,成功,200,0,success +4,862639073876858,249,862639073876858,862639073876858,成功,200,0,success +5,862639071874939,183,862639071874939,862639071874939,成功,200,0,success +6,862639073762900,239,862639073762900,862639073762900,成功,200,0,success +7,862639071854352,178,862639071854352,862639071854352,成功,200,0,success +8,862639073461925,210,862639073461925,862639073461925,成功,200,0,success +9,862639073996755,172,862639073996755,862639073996755,成功,200,0,success +10,862639073027965,228,862639073027965,862639073027965,成功,200,0,success +11,862639073337596,194,862639073337596,862639073337596,成功,200,0,success +12,862639073775027,241,862639073775027,862639073775027,成功,200,0,success +13,862639071857918,181,862639071857918,862639071857918,成功,200,0,success +14,862639073005409,225,862639073005409,862639073005409,成功,200,0,success +15,862639075961740,176,862639075961740,862639075961740,成功,200,0,success +16,862639073877062,250,862639073877062,862639073877062,成功,200,0,success +17,862639073940258,161,862639073940258,862639073940258,成功,200,0,success +18,862639073863336,247,862639073863336,862639073863336,成功,200,0,success +19,862639071971818,191,862639071971818,862639071971818,成功,200,0,success +20,862639073329528,192,862639073329528,862639073329528,成功,200,0,success +21,862639073015960,226,862639073015960,862639073015960,成功,200,0,success +22,862639071942918,190,862639071942918,862639071942918,成功,200,0,success +23,862639073787832,245,862639073787832,862639073787832,成功,200,0,success +24,862639073918049,257,862639073918049,862639073918049,成功,200,0,success +25,862639075954604,175,862639075954604,862639075954604,成功,200,0,success +26,862639071986337,222,862639071986337,862639071986337,成功,200,0,success +27,862639073831101,246,862639073831101,862639073831101,成功,200,0,success +28,862639073397517,205,862639073397517,862639073397517,成功,200,0,success +29,862639075961781,177,862639075961781,862639075961781,成功,200,0,success +30,862639073934822,259,862639073934822,862639073934822,成功,200,0,success +31,862639073981799,170,862639073981799,862639073981799,成功,200,0,success +32,862639071857900,180,862639071857900,862639071857900,成功,200,0,success +33,862639073785372,244,862639073785372,862639073785372,成功,200,0,success +34,862639073017438,227,862639073017438,862639073017438,成功,200,0,success +35,862639073778799,243,862639073778799,862639073778799,成功,200,0,success +36,862639073369672,202,862639073369672,862639073369672,成功,200,0,success +37,862639071974481,221,862639071974481,862639071974481,成功,200,0,success +38,862639071920955,188,862639071920955,862639071920955,成功,200,0,success +39,862639073062491,236,862639073062491,862639073062491,成功,200,0,success +40,862639073867253,248,862639073867253,862639073867253,成功,200,0,success +41,862639071912374,186,862639071912374,862639071912374,成功,200,0,success +42,862639071854402,179,862639071854402,862639071854402,成功,200,0,success +43,862639073538466,220,862639073538466,862639073538466,成功,200,0,success +44,862639073885289,251,862639073885289,862639073885289,成功,200,0,success +45,862639073394191,204,862639073394191,862639073394191,成功,200,0,success +46,862639073903652,254,862639073903652,862639073903652,成功,200,0,success +47,862639073949309,164,862639073949309,862639073949309,成功,200,0,success +48,862639071932281,189,862639071932281,862639071932281,成功,200,0,success +49,862639075954364,174,862639075954364,862639075954364,成功,200,0,success +50,862639073955231,167,862639073955231,862639073955231,成功,200,0,success +51,862639073061121,235,862639073061121,862639073061121,成功,200,0,success +52,862639073003941,224,862639073003941,862639073003941,成功,200,0,success +53,862639073502140,213,862639073502140,862639073502140,成功,200,0,success +54,862639073966170,168,862639073966170,862639073966170,成功,200,0,success +55,862639073476576,211,862639073476576,862639073476576,成功,200,0,success +56,862639073775076,242,862639073775076,862639073775076,成功,200,0,success +57,862639073383434,203,862639073383434,862639073383434,成功,200,0,success +58,862639073488316,212,862639073488316,862639073488316,成功,200,0,success +59,862639073948756,163,862639073948756,862639073948756,成功,200,0,success +60,862639073368856,201,862639073368856,862639073368856,成功,200,0,success +61,862639073966527,169,862639073966527,862639073966527,成功,200,0,success diff --git a/scripts/batch_device_recall/test_batch_device_recall.py b/scripts/batch_device_recall/test_batch_device_recall.py new file mode 100644 index 0000000..b7c8a6f --- /dev/null +++ b/scripts/batch_device_recall/test_batch_device_recall.py @@ -0,0 +1,127 @@ +"""批量回收设备脚本测试。""" +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).with_name("batch_device_recall.py") +SPEC = importlib.util.spec_from_file_location("batch_device_recall", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +batch_device_recall = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = batch_device_recall +SPEC.loader.exec_module(batch_device_recall) + + +class LoadDevicesTest(unittest.TestCase): + """验证单列 CSV 输入。""" + + def write_csv(self, content: str) -> Path: + """创建临时 CSV。""" + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + path = Path(directory.name) / "devices.csv" + path.write_text(content, encoding="utf-8") + return path + + def test_loads_header_and_rows(self) -> None: + """支持表头并保留行号。""" + path = self.write_csv("device_identifier\nIMEI001\nVIRTUAL001\n") + + devices = batch_device_recall.load_devices(path) + + self.assertEqual( + devices, + [ + batch_device_recall.DeviceInput(2, "IMEI001"), + batch_device_recall.DeviceInput(3, "VIRTUAL001"), + ], + ) + + def test_rejects_duplicate_identifier(self) -> None: + """同一标识不能重复回收。""" + path = self.write_csv("IMEI001\nIMEI001\n") + + with self.assertRaisesRegex(ValueError, "重复"): + batch_device_recall.load_devices(path) + + +class ExactDeviceMatchesTest(unittest.TestCase): + """验证模糊查询结果必须再次精确匹配。""" + + def test_keeps_only_exact_virtual_no_or_imei(self) -> None: + """排除仅包含关键字的候选设备。""" + body = { + "data": { + "items": [ + {"id": 1, "virtual_no": "VIRTUAL001", "imei": "IMEI001"}, + {"id": 2, "virtual_no": "VIRTUAL001-X", "imei": "IMEI002"}, + ] + } + } + + matches = batch_device_recall.exact_device_matches(body, "VIRTUAL001") + + self.assertEqual([item["id"] for item in matches], [1]) + + +class RecallBatchRowsTest(unittest.TestCase): + """验证批量接口结果映射。""" + + def test_maps_failed_items_to_source_rows(self) -> None: + """接口失败明细应对应回原 CSV 标识。""" + devices = [ + batch_device_recall.ResolvedDevice( + batch_device_recall.DeviceInput(2, "IMEI001"), 1, "V001", "IMEI001" + ), + batch_device_recall.ResolvedDevice( + batch_device_recall.DeviceInput(3, "IMEI002"), 2, "V002", "IMEI002" + ), + ] + result = batch_device_recall.HTTPResult( + 200, + { + "code": 0, + "msg": "success", + "data": { + "success_count": 1, + "fail_count": 1, + "failed_items": [{"device_id": 2, "reason": "设备已在平台库存中"}], + }, + }, + "", + ) + + rows, success = batch_device_recall.recall_batch_rows(devices, result) + + self.assertFalse(success) + self.assertEqual([row["status"] for row in rows], ["成功", "失败"]) + self.assertEqual(rows[1]["msg"], "设备已在平台库存中") + + def test_rejects_incomplete_failed_items(self) -> None: + """失败数量没有对应明细时不能把设备误记为成功。""" + device = batch_device_recall.ResolvedDevice( + batch_device_recall.DeviceInput(2, "IMEI001"), 1, "V001", "IMEI001" + ) + result = batch_device_recall.HTTPResult( + 200, + { + "code": 0, + "msg": "success", + "data": {"success_count": 0, "fail_count": 1, "failed_items": []}, + }, + "", + ) + + rows, success = batch_device_recall.recall_batch_rows([device], result) + + self.assertFalse(success) + self.assertEqual(rows[0]["status"], "失败") + self.assertIn("统计", rows[0]["msg"]) + + +if __name__ == "__main__": + unittest.main()