#!/usr/bin/env python3 """批量直接换货脚本:读取双列 CSV,逐行创建必须迁移数据的直接换货单。 默认只执行预演。只有显式传入 --execute 时,才会调用 POST /api/admin/exchanges。脚本仅使用 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.request import Request, urlopen EXCHANGE_PATH = "/api/admin/exchanges" LOGIN_PATH = "/api/admin/login" AUTH_ERROR_CODES = {1002, 1003, 1004} ASSET_TYPES = {"iot_card", "device"} DEFAULT_EXCHANGE_REASON = "批量直接换货" OLD_HEADER_NAMES = { "old_identifier", "old_asset_identifier", "旧资产标识", "旧资产", } NEW_HEADER_NAMES = { "new_identifier", "new_asset_identifier", "新资产标识", "新资产", } @dataclass(frozen=True) class ExchangeInput: """保存 CSV 中的一组新旧资产标识及原始行号。""" line_no: int old_identifier: str new_identifier: str @dataclass(frozen=True) class HTTPResult: """保存一次 HTTP 请求的响应信息。""" status: int body: dict[str, Any] | None raw_body: str @dataclass(frozen=True) class ExchangeOutcome: """保存单组资产的换货结果及结果文件行。""" row: dict[str, object] success: bool http_status: int | None code: int | str | None message: str exchange_no: 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._post_json( 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 create_exchange( self, token: str, exchange: ExchangeInput, asset_type: str, exchange_reason: str, remark: str | None, ) -> HTTPResult: """创建并立即完成一组必须迁移数据的直接换货。""" payload = build_exchange_payload(exchange, asset_type, exchange_reason, remark) return self._post_json(EXCHANGE_PATH, payload, token=token) def _post_json(self, path: str, payload: dict[str, Any], token: str | None) -> HTTPResult: body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") headers = { "Accept": "application/json", "Content-Type": "application/json", "User-Agent": "junhong-batch-exchange/1.0", } if token: headers["Authorization"] = f"Bearer {token}" request = Request( url=self.base_url + path, data=body, headers=headers, method="POST", ) try: with urlopen(request, timeout=self.timeout) as response: raw_body = response.read().decode("utf-8", errors="replace") return HTTPResult( status=response.status, body=parse_json_object(raw_body), raw_body=raw_body, ) except HTTPError as exc: raw_body = exc.read().decode("utf-8", errors="replace") return HTTPResult( status=exc.code, body=parse_json_object(raw_body), 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/exchanges 执行直接换货和数据迁移", ) parser.add_argument( "--base-url", default=os.getenv("JUNHONG_ADMIN_BASE_URL", ""), help="接口 Base URL,例如 https://cmp-api.example.com;也可使用 JUNHONG_ADMIN_BASE_URL", ) parser.add_argument( "--csv", required=True, help="双列资产 CSV 文件路径,依次为旧资产标识、新资产标识,首行可有表头", ) parser.add_argument( "--asset-type", required=True, choices=sorted(ASSET_TYPES), help="本批资产类型:iot_card(物联网卡)或 device(设备)", ) parser.add_argument( "--exchange-reason", default=DEFAULT_EXCHANGE_REASON, help=f"本批换货原因(默认:{DEFAULT_EXCHANGE_REASON})", ) parser.add_argument("--remark", default="", 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 时用于自动登录;也可使用 JUNHONG_ADMIN_USERNAME", ) parser.add_argument( "--password", default=os.getenv("JUNHONG_ADMIN_PASSWORD", ""), help="后台登录密码;建议使用 JUNHONG_ADMIN_PASSWORD,避免进入命令历史", ) 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_exchanges(csv_path: Path) -> list[ExchangeInput]: """读取双列 CSV,并在执行前拦截可能破坏批次映射的数据。""" if not csv_path.exists(): raise ValueError(f"找不到 CSV 文件:{csv_path}") if not csv_path.is_file(): raise ValueError(f"CSV 路径不是文件:{csv_path}") exchanges: list[ExchangeInput] = [] old_line_by_identifier: dict[str, int] = {} new_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: reader = csv.reader(file) for line_no, row in enumerate(reader, start=1): if not any(value.strip() for value in row): continue if len(row) != 2: errors.append(f"第 {line_no} 行必须正好有两列,实际读取到 {len(row)} 列") continue old_identifier, new_identifier = (value.strip() for value in row) if not first_nonempty_seen: first_nonempty_seen = True if is_header_row(old_identifier, new_identifier): continue if not old_identifier or not new_identifier: errors.append(f"第 {line_no} 行的旧资产标识和新资产标识均不能为空") continue if len(old_identifier) > 100 or len(new_identifier) > 100: errors.append(f"第 {line_no} 行的资产标识不能超过 100 个字符") continue if old_identifier == new_identifier: errors.append(f"第 {line_no} 行的新旧资产标识相同:{old_identifier}") continue if old_identifier in old_line_by_identifier: errors.append( f"第 {line_no} 行旧资产与第 {old_line_by_identifier[old_identifier]} 行重复:" f"{old_identifier}" ) continue if new_identifier in new_line_by_identifier: errors.append( f"第 {line_no} 行新资产与第 {new_line_by_identifier[new_identifier]} 行重复:" f"{new_identifier}" ) continue old_line_by_identifier[old_identifier] = line_no new_line_by_identifier[new_identifier] = line_no exchanges.append( ExchangeInput( line_no=line_no, old_identifier=old_identifier, new_identifier=new_identifier, ) ) for identifier in old_line_by_identifier.keys() & new_line_by_identifier.keys(): errors.append( f"资产 {identifier} 同时作为第 {old_line_by_identifier[identifier]} 行旧资产和" f"第 {new_line_by_identifier[identifier]} 行新资产,批次执行顺序会改变其状态" ) if errors: preview = "\n".join(f" - {error}" for error in errors[:20]) if len(errors) > 20: preview += f"\n - 其余 {len(errors) - 20} 个错误已省略" raise ValueError(f"CSV 校验失败,请修正后重试:\n{preview}") if not exchanges: raise ValueError("CSV 中没有有效的换货资产映射") return exchanges def is_header_row(old_identifier: str, new_identifier: str) -> bool: """判断首个非空行是否为支持的双列表头。""" return old_identifier.lower() in OLD_HEADER_NAMES and new_identifier.lower() in NEW_HEADER_NAMES 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: """读取统一响应消息,非 JSON 响应则保留截断后的正文。""" 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 display_value(value: object) -> str: """把可能为空的字段转为适合日志和 CSV 的文本。""" return "" if value is None else str(value) 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,否则使用后台账号自动登录。""" token = args.token.strip() if token: return token username = args.username.strip() if not username: raise ValueError( "真实执行需要 --token,或同时提供 --username/--password;" "也可通过 JUNHONG_ADMIN_TOKEN 等环境变量配置" ) password = args.password if not password and sys.stdin.isatty(): password = getpass("请输入后台登录密码:") if not password: raise ValueError("使用账号登录时必须提供密码") print(f"正在使用后台账号 {username!r} 获取 Access Token...") return client.login(username, password) def build_exchange_payload( exchange: ExchangeInput, asset_type: str, exchange_reason: str, remark: str | None, ) -> dict[str, Any]: """构造预演和真实执行共同使用的固定直接换货请求。""" payload: dict[str, Any] = { "old_asset_type": asset_type, "old_identifier": exchange.old_identifier, "flow_type": "direct", "new_identifier": exchange.new_identifier, "migrate_data": True, "exchange_reason": exchange_reason, } if remark is not None: payload["remark"] = remark return payload def preview( exchanges: list[ExchangeInput], base_url: str, asset_type: str, exchange_reason: str, remark: str | None, ) -> int: """输出预演信息,不发送任何 HTTP 请求。""" print("预演完成:未发送任何 HTTP 请求。") print(f"接口地址:{base_url.rstrip('/')}{EXCHANGE_PATH}") print(f"换货数量:{len(exchanges)}") print(f"资产类型:{asset_type}") print("换货流程:direct(直接换货)") print("数据迁移:true(必须迁移)") print(f"换货原因:{exchange_reason}") print("请求示例:") for exchange in exchanges[:5]: payload = build_exchange_payload(exchange, asset_type, exchange_reason, remark) print(f" 第 {exchange.line_no} 行:{json.dumps(payload, ensure_ascii=False)}") if len(exchanges) > 5: print(f" 其余 {len(exchanges) - 5} 条已省略") print("确认资产映射无误后,增加 --execute 才会真实换货。") return 0 def exchange_asset( client: AdminAPIClient, token: str, exchange: ExchangeInput, asset_type: str, exchange_reason: str, remark: str | None, ) -> ExchangeOutcome: """调用一次换货接口,并转换为统一的结果记录。""" try: result = client.create_exchange(token, exchange, asset_type, exchange_reason, remark) code = response_code(result.body) message = response_message(result.body, result.raw_body) data = result.body.get("data") if result.body else None exchange_data = data if isinstance(data, dict) else {} request_succeeded = is_success(result.status, code) migration_completed = exchange_data.get("migration_completed") is True success = request_succeeded and migration_completed if request_succeeded and not migration_completed: message = "接口返回成功,但响应未确认数据迁移完成,请人工核对该换货单" exchange_no = display_value(exchange_data.get("exchange_no")) return ExchangeOutcome( row={ "line_no": exchange.line_no, "old_identifier": exchange.old_identifier, "new_identifier": exchange.new_identifier, "status": "成功" if success else "失败", "http_status": result.status, "code": display_value(code), "msg": message, "exchange_id": display_value(exchange_data.get("id")), "exchange_no": exchange_no, "migration_completed": display_value(exchange_data.get("migration_completed")), "migration_balance": display_value(exchange_data.get("migration_balance")), }, success=success, http_status=result.status, code=code, message=message, exchange_no=exchange_no, ) except RequestFailedError as exc: message = str(exc) return ExchangeOutcome( row={ "line_no": exchange.line_no, "old_identifier": exchange.old_identifier, "new_identifier": exchange.new_identifier, "status": "失败", "http_status": "", "code": "", "msg": message, "exchange_id": "", "exchange_no": "", "migration_completed": "", "migration_balance": "", }, success=False, http_status=None, code=None, message=message, exchange_no="", ) def execute( exchanges: list[ExchangeInput], client: AdminAPIClient, token: str, asset_type: str, exchange_reason: str, remark: str | None, output_path: Path, interval: float, ) -> int: """顺序执行换货,并把每条结果立即写入 CSV。""" output_path.parent.mkdir(parents=True, exist_ok=True) success_count = 0 failed_count = 0 total = len(exchanges) with output_path.open("w", encoding="utf-8-sig", newline="") as file: writer = csv.DictWriter( file, fieldnames=[ "line_no", "old_identifier", "new_identifier", "status", "http_status", "code", "msg", "exchange_id", "exchange_no", "migration_completed", "migration_balance", ], ) writer.writeheader() file.flush() for index, exchange in enumerate(exchanges, start=1): outcome = exchange_asset( client, token, exchange, asset_type, exchange_reason, remark, ) writer.writerow(outcome.row) if outcome.success: success_count += 1 print( f"[{index}/{total}] 成功:{exchange.old_identifier} -> " f"{exchange.new_identifier},换货单号={outcome.exchange_no}" ) else: failed_count += 1 print( f"[{index}/{total}] 失败:{exchange.old_identifier} -> " f"{exchange.new_identifier},HTTP={display_value(outcome.http_status)}," f"code={display_value(outcome.code)},msg={outcome.message}", file=sys.stderr, ) file.flush() if outcome.http_status == 401 or outcome.code in AUTH_ERROR_CODES: print("认证已失效,停止后续换货;已处理结果已保存。", file=sys.stderr) break if interval > 0 and index < total: time.sleep(interval) print() print(f"执行结束:成功 {success_count} 条,失败 {failed_count} 条。") print(f"结果文件:{output_path}") return 0 if failed_count == 0 and success_count == total else 2 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:// 开头") exchange_reason = args.exchange_reason.strip() if not exchange_reason: raise ValueError("exchange-reason 不能为空") if len(exchange_reason) > 100: raise ValueError("exchange-reason 不能超过 100 个字符") remark = args.remark.strip() or None if remark is not None and len(remark) > 500: raise ValueError("remark 不能超过 500 个字符") if args.timeout <= 0: raise ValueError("timeout 必须大于 0") if args.interval < 0: raise ValueError("interval 不能小于 0") input_path = Path(args.csv).expanduser().resolve() exchanges = load_exchanges(input_path) if not args.execute: return preview( exchanges, base_url, args.asset_type, exchange_reason, 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) print( f"即将真实换货:{len(exchanges)} 组,资产类型={args.asset_type}," "流程=direct,迁移数据=true" ) print(f"接口地址:{base_url.rstrip('/')}{EXCHANGE_PATH}") print(f"结果文件:{output_path}") return execute( exchanges=exchanges, client=client, token=token, asset_type=args.asset_type, exchange_reason=exchange_reason, remark=remark, output_path=output_path, interval=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())