批量换货脚本
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s

This commit is contained in:
2026-07-22 19:09:28 +09:00
parent c58773e35b
commit c7f8b4c702
5 changed files with 986 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
# 批量换货脚本
该脚本读取两列 CSV逐行调用 `POST /api/admin/exchanges` 执行换货。脚本固定使用:
- `flow_type=direct`:只支持直接换货,接口创建换货单后会立即完成换货。
- `migrate_data=true`:必须执行全量数据迁移,不能通过参数关闭。
全量迁移由现有换货接口在同一事务中执行,包括钱包余额、套餐使用记录、累计充值字段和资产标签。脚本仅使用 Python 标准库,不需要安装依赖。
默认只预演,必须增加 `--execute` 才会真实换货。
## CSV 格式
首行表头可选,第一列填写旧资产标识,第二列填写新资产标识:
```csv
old_identifier,new_identifier
89860000000000000001,89860000000000000101
89860000000000000002,89860000000000000102
```
支持表头:
- 英文:`old_identifier,new_identifier``old_asset_identifier,new_asset_identifier`
- 中文:`旧资产标识,新资产标识``旧资产,新资产`
旧、新资产标识均使用换货接口已有的识别规则:物联网卡支持 ICCID、接入号、虚拟号设备支持虚拟号、IMEI、SN。
脚本会在请求前拦截:
- 列数不是两列,或任一列为空。
- 同一行新旧资产相同。
- 旧资产重复,或新资产重复。
- 同一资产在本批次中既作为旧资产又作为新资产。该情况会受到执行顺序影响,因此整批终止。
## 预演
物联网卡示例:
```bash
python3 scripts/batch_exchange/batch_exchange.py \
--base-url https://cmp-api.example.com \
--csv scripts/batch_exchange/exchanges.example.csv \
--asset-type iot_card
```
设备批次将 `--asset-type` 改为 `device`。同一个 CSV 批次只能使用一种资产类型;新资产必须与旧资产类型一致,否则接口会拒绝该行。
预演只校验 CSV 并展示最多五条请求示例,不需要 Token也不会调用接口。输出中会明确显示 `direct``migrate_data=true`
## 使用已有 Token 执行
推荐通过环境变量传递 Token避免进入命令历史
```bash
JUNHONG_ADMIN_TOKEN='<后台Access Token>' \
python3 scripts/batch_exchange/batch_exchange.py \
--base-url https://cmp-api.example.com \
--csv /path/to/exchanges.csv \
--asset-type iot_card \
--execute
```
## 使用账号自动登录后执行
未提供 Token 时,可以使用后台账号调用 `/api/admin/login` 自动获取 Token
```bash
JUNHONG_ADMIN_USERNAME='<后台账号>' \
JUNHONG_ADMIN_PASSWORD='<后台密码>' \
python3 scripts/batch_exchange/batch_exchange.py \
--base-url https://cmp-api.example.com \
--csv /path/to/exchanges.csv \
--asset-type iot_card \
--execute
```
也可以使用 `--token``--username``--password` 参数。密码优先通过环境变量或交互输入,避免保存在 Shell 历史中。
## 换货原因和备注
脚本默认使用 `批量直接换货` 作为换货原因。可按整批覆盖原因和备注:
```bash
python3 scripts/batch_exchange/batch_exchange.py \
--base-url https://cmp-api.example.com \
--csv /path/to/exchanges.csv \
--asset-type iot_card \
--exchange-reason '故障卡批量换货' \
--remark '2026年7月批次' \
--execute
```
## 结果文件
默认在输入 CSV 同目录生成:
```text
原文件名_换货结果_YYYYMMDD_HHMMSS.csv
```
结果包含新旧资产标识、成功或失败状态、HTTP 状态码、业务错误码、错误消息、换货单 ID、换货单号、迁移完成状态和迁移余额。每处理一条都会立即刷新文件中途中断时已完成的结果不会丢失。
为避免覆盖历史结果,`--output` 指定的文件已经存在时脚本会直接报错。
## 其他参数和执行约束
- `--timeout`:单次请求超时秒数,默认 `30`
- `--interval`:每次换货后的等待秒数,默认 `0.2`
- `--execute`:显式开启真实换货。
每组资产单独调用一次接口,脚本不会自动重试 POST 请求。接口可能已经成功提交事务但客户端没有收到响应,自动重试可能造成误判;失败项应先查询换货单或资产状态,再决定是否单独重跑。
脚本不会回滚前面已经成功的行。执行前应先预演并确认完整映射;执行后根据结果 CSV 逐条核对失败项。

View File

@@ -0,0 +1,613 @@
#!/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())

View File

@@ -0,0 +1,38 @@
old_identifier,new_identifier
99840868838,89861590172420400342
99840868841,89861590172420400343
99840868842,89861590172420400344
99840868843,89861590172420400345
99840868844,89861590172420400346
99840868845,89861590172420400347
99840868847,89861590172420400348
99840868848,89861590172420400349
99840868850,89861590172420400351
99840868851,89861590172420400352
99840868852,89861590172420400353
99840868854,89861590172420400354
99840868856,89861590172420400356
99840868863,89861590172420400363
99840868865,89861590172420400364
99820416468,89861590172420400365
99840868917,89861590172420400319
99840868918,89861590172420400320
99840868919,89861590172420400321
99840868920,89861590172420400322
99840868922,89861590172420400323
99840868923,89861590172420400324
99840868924,89861590172420400325
99840868925,89861590172420400326
99840868938,89861590172420400327
99840868939,89861590172420400328
99840868940,89861590172420400329
99840868941,89861590172420400330
99840868942,89861590172420400331
99840868943,89861590172420400332
99840868944,89861590172420400333
99840868945,89861590172420400334
99840868946,89861590172420400335
99840868948,89861590172420400336
99840868949,89861590172420400337
99840868951,89861590172420400338
99840868952,89861590172420400339
1 old_identifier new_identifier
2 99840868838 89861590172420400342
3 99840868841 89861590172420400343
4 99840868842 89861590172420400344
5 99840868843 89861590172420400345
6 99840868844 89861590172420400346
7 99840868845 89861590172420400347
8 99840868847 89861590172420400348
9 99840868848 89861590172420400349
10 99840868850 89861590172420400351
11 99840868851 89861590172420400352
12 99840868852 89861590172420400353
13 99840868854 89861590172420400354
14 99840868856 89861590172420400356
15 99840868863 89861590172420400363
16 99840868865 89861590172420400364
17 99820416468 89861590172420400365
18 99840868917 89861590172420400319
19 99840868918 89861590172420400320
20 99840868919 89861590172420400321
21 99840868920 89861590172420400322
22 99840868922 89861590172420400323
23 99840868923 89861590172420400324
24 99840868924 89861590172420400325
25 99840868925 89861590172420400326
26 99840868938 89861590172420400327
27 99840868939 89861590172420400328
28 99840868940 89861590172420400329
29 99840868941 89861590172420400330
30 99840868942 89861590172420400331
31 99840868943 89861590172420400332
32 99840868944 89861590172420400333
33 99840868945 89861590172420400334
34 99840868946 89861590172420400335
35 99840868948 89861590172420400336
36 99840868949 89861590172420400337
37 99840868951 89861590172420400338
38 99840868952 89861590172420400339

View File

@@ -0,0 +1,172 @@
"""批量换货脚本的输入校验测试。"""
from __future__ import annotations
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT_PATH = Path(__file__).with_name("batch_exchange.py")
SPEC = importlib.util.spec_from_file_location("batch_exchange", SCRIPT_PATH)
assert SPEC is not None and SPEC.loader is not None
batch_exchange = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = batch_exchange
SPEC.loader.exec_module(batch_exchange)
class LoadExchangesTest(unittest.TestCase):
"""验证 CSV 映射校验不会把危险批次交给接口执行。"""
def write_csv(self, content: str) -> Path:
"""在临时目录中创建待解析的 CSV。"""
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
path = Path(directory.name) / "exchanges.csv"
path.write_text(content, encoding="utf-8")
return path
def test_loads_supported_header_and_rows(self) -> None:
"""支持标准表头并保留原始行号。"""
path = self.write_csv("old_identifier,new_identifier\nold-1,new-1\nold-2,new-2\n")
exchanges = batch_exchange.load_exchanges(path)
self.assertEqual(
exchanges,
[
batch_exchange.ExchangeInput(2, "old-1", "new-1"),
batch_exchange.ExchangeInput(3, "old-2", "new-2"),
],
)
def test_rejects_duplicate_old_identifier(self) -> None:
"""同一旧资产不能在一批中换出两次。"""
path = self.write_csv("old-1,new-1\nold-1,new-2\n")
with self.assertRaisesRegex(ValueError, "旧资产.*重复"):
batch_exchange.load_exchanges(path)
def test_rejects_duplicate_new_identifier(self) -> None:
"""同一新资产不能接收两次换货。"""
path = self.write_csv("old-1,new-1\nold-2,new-1\n")
with self.assertRaisesRegex(ValueError, "新资产.*重复"):
batch_exchange.load_exchanges(path)
def test_rejects_same_identifier_in_one_row(self) -> None:
"""禁止资产换给自身。"""
path = self.write_csv("old-1,old-1\n")
with self.assertRaisesRegex(ValueError, "新旧资产标识相同"):
batch_exchange.load_exchanges(path)
def test_rejects_cross_row_asset_reuse(self) -> None:
"""禁止资产在同批中同时作为旧资产和新资产。"""
path = self.write_csv("old-1,new-1\nnew-1,new-2\n")
with self.assertRaisesRegex(ValueError, "同时作为.*旧资产.*新资产"):
batch_exchange.load_exchanges(path)
def test_rejects_missing_column_value(self) -> None:
"""双列中的空值必须在请求前被拦截。"""
path = self.write_csv("old_identifier,new_identifier\nold-1,\n")
with self.assertRaisesRegex(ValueError, "均不能为空"):
batch_exchange.load_exchanges(path)
class BuildExchangePayloadTest(unittest.TestCase):
"""验证脚本不能关闭直接换货或数据迁移。"""
def test_forces_direct_flow_and_migration(self) -> None:
"""请求固定为 direct 且 migrate_data 为 true。"""
exchange = batch_exchange.ExchangeInput(2, "old-1", "new-1")
payload = batch_exchange.build_exchange_payload(
exchange,
"iot_card",
"批量直接换货",
None,
)
self.assertEqual(payload["flow_type"], "direct")
self.assertIs(payload["migrate_data"], True)
self.assertEqual(payload["old_asset_type"], "iot_card")
class ExchangeAssetTest(unittest.TestCase):
"""验证结果必须同时满足接口成功和迁移完成。"""
class FakeClient:
"""返回指定响应的换货客户端替身。"""
def __init__(self, result: object) -> None:
self.result = result
def create_exchange(self, *args: object) -> object:
"""返回测试预设的接口响应。"""
return self.result
def test_marks_completed_migration_as_success(self) -> None:
"""业务成功且迁移完成时结果为成功。"""
result = batch_exchange.HTTPResult(
status=200,
body={
"code": 0,
"msg": "success",
"data": {
"id": 123,
"exchange_no": "EX123",
"migration_completed": True,
"migration_balance": 100,
},
},
raw_body="",
)
outcome = batch_exchange.exchange_asset(
self.FakeClient(result),
"token",
batch_exchange.ExchangeInput(2, "old-1", "new-1"),
"iot_card",
"批量直接换货",
None,
)
self.assertTrue(outcome.success)
self.assertEqual(outcome.exchange_no, "EX123")
def test_rejects_success_response_without_completed_migration(self) -> None:
"""业务成功但未确认迁移完成时结果仍为失败。"""
result = batch_exchange.HTTPResult(
status=200,
body={
"code": 0,
"msg": "success",
"data": {
"id": 123,
"exchange_no": "EX123",
"migration_completed": False,
"migration_balance": 0,
},
},
raw_body="",
)
outcome = batch_exchange.exchange_asset(
self.FakeClient(result),
"token",
batch_exchange.ExchangeInput(2, "old-1", "new-1"),
"iot_card",
"批量直接换货",
None,
)
self.assertFalse(outcome.success)
self.assertIn("未确认数据迁移完成", outcome.message)
if __name__ == "__main__":
unittest.main()