feat: 快递100上门取件集成(SDS运单自动生成+面单回传)
- api/kuaidi100: 上门取件API客户端(bOrder/price/cancel/detail,MD5签名封装) - api/sds: 变体克重(children接口,24h缓存)/面单PDF上传/挂包裹/carriageNoStatus筛选 - flows/sds/waybill_autogen: 每10分钟遍历运单待生成订单(factoryIds+carriageNoStatus+orderStatusCode) 自动下极兔(returnType=20+tempid出面单),面单回调转PDF回传SDS; 幂等跳过已有运单;面单超时不自动取消(记人工跟进);schedule 默认 active:false - scripts: 手动E2E测试脚本 + 回调接收服务(验签MD5(param+salt)+JSONL落盘,配frpc 18090穿透) - 依赖 +pillow(面单PNG转PDF)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""快递100 上门取件API 回调接收服务(本机常驻,经 frpc 暴露公网)。
|
||||
|
||||
接收两类推送并验签落盘(MD5(param + salt)):
|
||||
- POST /kuaidi100/order 下单状态回调(callBackUrl)与面单短链/打印回调(printCallBackUrl)
|
||||
- POST /kuaidi100/poll 轨迹推送(pollCallBackUrl)
|
||||
|
||||
应答格式必须是 {"result":true,"returnCode":"200","message":"成功"},
|
||||
否则快递100会以 30 分钟间隔重推 3 次。
|
||||
|
||||
查看:
|
||||
- GET / 状态与最近回调摘要
|
||||
- GET /list?n=20 最近 n 条回调明细(JSON)
|
||||
|
||||
落盘: <repo>/data/kuaidi100/callbacks.jsonl,每行 {ts,path,verified,taskId,param}
|
||||
|
||||
用法: uv run python scripts/kuaidi100_callback_server.py [--port 9802] [--salt inkreach-test]
|
||||
salt 需与下单 param.salt 一致;也可用环境变量 KUAIDI100_CALLBACK_SALT 覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
LOG_FILE = REPO_ROOT / "data" / "kuaidi100" / "callbacks.jsonl"
|
||||
MAX_BODY = 1 << 20 # 1MB 上限,防异常大包
|
||||
|
||||
_state = {"started_at": time.time(), "received": 0, "verified_ok": 0}
|
||||
|
||||
|
||||
def _append(record: dict) -> None:
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with LOG_FILE.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def _recent(n: int = 20) -> list[dict]:
|
||||
if not LOG_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
lines = LOG_FILE.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
out = []
|
||||
for line in lines[-n:]:
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
salt: str = os.environ.get("KUAIDI100_CALLBACK_SALT", "inkreach-test")
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None: # 静默默认访问日志,落盘代替
|
||||
pass
|
||||
|
||||
# ----- POST 回调 -----
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if not path.startswith("/kuaidi100/"):
|
||||
self._json(404, {"result": False, "returnCode": "404", "message": "not found"})
|
||||
return
|
||||
length = min(int(self.headers.get("Content-Length") or 0), MAX_BODY)
|
||||
body = self.rfile.read(length).decode("utf-8", errors="replace")
|
||||
form = parse_qs(body, keep_blank_values=True)
|
||||
param_raw = (form.get("param") or [""])[0]
|
||||
sign = (form.get("sign") or [""])[0].upper()
|
||||
task_id = (form.get("taskId") or [""])[0]
|
||||
expect = hashlib.md5(f"{param_raw}{self.salt}".encode("utf-8")).hexdigest().upper()
|
||||
verified = bool(param_raw) and sign == expect
|
||||
|
||||
parsed: dict | None = None
|
||||
if param_raw:
|
||||
try:
|
||||
parsed = json.loads(param_raw)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
|
||||
_state["received"] += 1
|
||||
_state["verified_ok"] += int(verified)
|
||||
_append(
|
||||
{
|
||||
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"path": path,
|
||||
"verified": verified,
|
||||
"sign": sign,
|
||||
"taskId": task_id or None,
|
||||
"param": parsed if parsed is not None else param_raw[:2000],
|
||||
"client": self.client_address[0],
|
||||
}
|
||||
)
|
||||
# 验签失败也先按成功应答(已落盘标记 verified=false),避免测试环境反复重推干扰;
|
||||
# 生产可改为拒绝: {"result": false, ...}
|
||||
self._json(200, {"result": True, "returnCode": "200", "message": "成功"})
|
||||
|
||||
# ----- GET 查看 -----
|
||||
|
||||
def do_GET(self) -> None:
|
||||
url = urlparse(self.path)
|
||||
# 兼容两种路径:直连 /list 与经 frp/反代的 /kuaidi100/list(与 POST 路由前缀对齐)
|
||||
if url.path in ("/list", "/kuaidi100/list"):
|
||||
qs = parse_qs(url.query)
|
||||
n = min(int((qs.get("n") or ["20"])[0]), 200)
|
||||
self._json(200, {"count": n, "items": list(reversed(_recent(n)))})
|
||||
return
|
||||
recent = _recent(5)
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"service": "kuaidi100-callback",
|
||||
"listen": "127.0.0.1 (via frpc)",
|
||||
"started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(_state["started_at"])),
|
||||
"uptime_s": int(time.time() - _state["started_at"]),
|
||||
"received": _state["received"],
|
||||
"verified_ok": _state["verified_ok"],
|
||||
"log_file": str(LOG_FILE),
|
||||
"recent": [
|
||||
{"ts": r.get("ts"), "path": r.get("path"), "verified": r.get("verified"),
|
||||
"status": (r.get("param") or {}).get("data", {}).get("status")
|
||||
if isinstance(r.get("param"), dict) else None}
|
||||
for r in recent
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def _json(self, code: int, payload: dict) -> None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="快递100回调接收服务")
|
||||
parser.add_argument("--port", type=int, default=9802)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--salt", default=Handler.salt, help="验签 salt,须与下单 param.salt 一致")
|
||||
args = parser.parse_args()
|
||||
Handler.salt = args.salt
|
||||
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||
print(f"[kuaidi100-callback] listening on http://{args.host}:{args.port} (salt={args.salt})")
|
||||
print(f"[kuaidi100-callback] log -> {LOG_FILE}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,407 @@
|
||||
"""快递100「上门取件API(线上支付)」端到端测试脚本。
|
||||
|
||||
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
|
||||
|
||||
流程:
|
||||
1. 从 SDS 取一单国内工厂订单(factoryIds=1161),提取收/寄件人地址(收件人=订单 address,寄件人=发货仓 issuing_bay)
|
||||
2. 逐家查价(method=price),选最便宜的快递公司
|
||||
3. 下单(method=bOrder)拿 taskId/orderId/kuaidinum
|
||||
4. 取消订单(method=cancel)收尾,避免遗留测试单
|
||||
|
||||
鉴权: 公共参数 method/key/sign/t,param 为 JSON 字符串;
|
||||
sign = MD5(param + t + key + secret) 转 32 位大写。
|
||||
测试环境: http://e-test.kuaidilab.com/api/order/borderapi.do(测试 key/secret 需向快递100技术支持申请)
|
||||
|
||||
用法:
|
||||
uv run python scripts/kuaidi100_pickup_test.py --key KEY --secret SECRET [--customer CUSTOMER]
|
||||
# 或把 JSON {"key":"...","secret":"...","customer":"..."} 存进 Prefect Secret Block kuaidi100-test 后:
|
||||
uv run python scripts/kuaidi100_pickup_test.py --from-secret
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
TEST_BASE_URL = "http://e-test.kuaidilab.com/api/order/borderapi.do"
|
||||
PROD_BASE_URL = "https://poll.kuaidi100.com/order/borderapi.do"
|
||||
|
||||
# 文档列出的本 API 支持的快递公司编码(小写)
|
||||
SUPPORTED_COURIERS = [
|
||||
"jd",
|
||||
"debangkuaidi",
|
||||
"shunfeng",
|
||||
"jtexpress",
|
||||
"yuantong",
|
||||
"shentong",
|
||||
"zhongtong",
|
||||
"zhongtongkuaiyun",
|
||||
"yunda",
|
||||
"kuayue",
|
||||
"shunfengkuaiyun",
|
||||
"ems",
|
||||
"yimidida",
|
||||
"kuaidi100zhixuan",
|
||||
]
|
||||
|
||||
COURIER_NAMES = {
|
||||
"jd": "京东",
|
||||
"debangkuaidi": "德邦快递",
|
||||
"shunfeng": "顺丰速运",
|
||||
"jtexpress": "极兔速递",
|
||||
"yuantong": "圆通速递",
|
||||
"shentong": "申通快递",
|
||||
"zhongtong": "中通快递",
|
||||
"zhongtongkuaiyun": "中通快运",
|
||||
"yunda": "韵达快递",
|
||||
"kuayue": "跨越速运",
|
||||
"shunfengkuaiyun": "顺丰快运",
|
||||
"ems": "EMS",
|
||||
"yimidida": "壹米滴答",
|
||||
"kuaidi100zhixuan": "快递100智选",
|
||||
}
|
||||
|
||||
|
||||
# ---------- 快递100 公共调用 ----------
|
||||
|
||||
|
||||
def make_sign(param: str, t: str, key: str, secret: str) -> str:
|
||||
"""文档签名算法: MD5(param + t + key + secret) 转 32 位大写。"""
|
||||
raw = f"{param}{t}{key}{secret}"
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
def call_api(
|
||||
method: str,
|
||||
param: dict[str, Any],
|
||||
auth: dict[str, str],
|
||||
base_url: str = TEST_BASE_URL,
|
||||
timeout: float = 30.0,
|
||||
) -> dict[str, Any]:
|
||||
"""POST application/x-www-form-urlencoded,公共参数 method/key/sign/t/param。"""
|
||||
param_str = json.dumps(param, ensure_ascii=False, separators=(",", ":"))
|
||||
t = str(int(time.time() * 1000))
|
||||
body = {
|
||||
"method": method,
|
||||
"key": auth["key"],
|
||||
"sign": make_sign(param_str, t, auth["key"], auth["secret"]),
|
||||
"t": t,
|
||||
"param": param_str,
|
||||
}
|
||||
resp = httpx.post(base_url, data=body, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def check(resp: dict[str, Any], action: str) -> dict[str, Any]:
|
||||
if resp.get("result") is not True or str(resp.get("returnCode")) != "200":
|
||||
raise RuntimeError(f"[{action}] 失败: {json.dumps(resp, ensure_ascii=False)[:500]}")
|
||||
print(f"[{action}] 成功: {resp.get('message')}")
|
||||
return resp
|
||||
|
||||
|
||||
# ---------- SDS 取单 ----------
|
||||
|
||||
|
||||
def calc_order_weight_grams(client: Any, order: Any) -> tuple[float | None, list[str]]:
|
||||
"""委托 SdsMngClient.get_order_weight_grams(children 变体克重,24h 缓存)。"""
|
||||
return client.get_order_weight_grams(order)
|
||||
|
||||
|
||||
def fetch_sds_domestic_order(client: Any | None = None) -> dict[str, str]:
|
||||
"""取一单国内工厂(factoryIds=1161)订单,返回寄件/收件人字段+结构化克重。"""
|
||||
if client is None:
|
||||
from api import SdsMngClient
|
||||
from prefect.blocks.system import Secret
|
||||
|
||||
value = Secret.load("sds-credentials").get()
|
||||
cred = json.loads(value) if isinstance(value, str) else value
|
||||
client = SdsMngClient(cred["username"], cred["password"])
|
||||
from api.sds.models import OrderPageRequest
|
||||
|
||||
resp = client.get_orders(OrderPageRequest(factory_ids=[1161], size=1, page=1))
|
||||
if not resp.records:
|
||||
raise RuntimeError("SDS 未找到 factoryIds=1161 的订单")
|
||||
order = resp.records[0]
|
||||
addr = order.address
|
||||
bay = order.issuing_bay
|
||||
if not addr or not bay:
|
||||
raise RuntimeError("订单缺少收件地址或发货仓信息")
|
||||
|
||||
send_print_addr = _join_addr(bay.province, bay.city, bay.detail)
|
||||
rec_print_addr = _join_addr(addr.province, addr.city, addr.detail)
|
||||
cargo = order.items[0].product_name or "日用品" if order.items else "日用品"
|
||||
|
||||
info = {
|
||||
"order_no": order.no or order.id,
|
||||
"send_name": bay.name or "印美达",
|
||||
"send_addr": send_print_addr,
|
||||
"rec_name": addr.consignee or "收件人",
|
||||
"rec_mobile": addr.mobile_phone or "",
|
||||
"rec_addr": rec_print_addr,
|
||||
"cargo": cargo,
|
||||
}
|
||||
grams, missed = calc_order_weight_grams(client, order)
|
||||
if grams:
|
||||
info["weight_kg"] = f"{grams / 1000:.2f}"
|
||||
print(f"[weight] 结构化克重合计 {round(grams)}g = {info['weight_kg']}kg")
|
||||
else:
|
||||
print(f"[weight] 未能取到结构化克重({'; '.join(missed) or '无商品行'}),下单重量回退为默认 1kg")
|
||||
print(
|
||||
f"[SDS] 选中订单 {info['order_no']}\n"
|
||||
f" 寄件: {info['send_name']} / {info['send_addr']}\n"
|
||||
f" 收件: {info['rec_name']} {info['rec_mobile']} / {info['rec_addr']}\n"
|
||||
f" 物品: {info['cargo']}"
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
# ---------- 业务步骤 ----------
|
||||
|
||||
|
||||
def compare_prices(auth: dict[str, str], order_info: dict[str, str], base_url: str, weight: str) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""逐家查价,按 defPrice(默认总价)升序,返回 (最便宜编码, 全部结果)。"""
|
||||
results: list[dict[str, Any]] = []
|
||||
for com in SUPPORTED_COURIERS:
|
||||
param = {
|
||||
"kuaidiCom": com,
|
||||
"sendManPrintAddr": order_info["send_addr"],
|
||||
"recManPrintAddr": order_info["rec_addr"],
|
||||
"weight": weight,
|
||||
}
|
||||
try:
|
||||
resp = call_api("price", param, auth, base_url)
|
||||
if resp.get("result") is True:
|
||||
data = resp.get("data", {})
|
||||
results.append({"com": com, **data})
|
||||
print(
|
||||
f"[price] {COURIER_NAMES.get(com, com):8s} 首重={data.get('defFirstPrice')} "
|
||||
f"续重={data.get('defOverPrice')} 总价={data.get('defPrice')}"
|
||||
)
|
||||
else:
|
||||
print(f"[price] {COURIER_NAMES.get(com, com):8s} 不支持: {resp.get('message')}")
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"[price] {COURIER_NAMES.get(com, com):8s} 查询异常: {str(err)[:120]}")
|
||||
time.sleep(0.3)
|
||||
|
||||
priced = [r for r in results if str(r.get("defPrice", "") not in ("", None)) and _to_float(r.get("defPrice")) is not None]
|
||||
if not priced:
|
||||
raise RuntimeError("没有任何快递公司返回有效价格")
|
||||
priced.sort(key=lambda r: _to_float(r["defPrice"]))
|
||||
best = priced[0]
|
||||
print(
|
||||
f"[price] 共 {len(priced)} 家报价,最便宜: {COURIER_NAMES.get(best['com'], best['com'])} "
|
||||
f"({best['com']}) 总价 {best['defPrice']} 元"
|
||||
)
|
||||
return best["com"], results
|
||||
|
||||
|
||||
def _join_addr(province: str | None, city: str | None, detail: str | None) -> str:
|
||||
"""拼省+市+详细地址;detail 已含省/市名时跳过对应前缀,避免重复。"""
|
||||
detail = detail or ""
|
||||
prefix = ""
|
||||
for part in (province or "", city or ""):
|
||||
if part and part not in detail and part not in prefix:
|
||||
prefix += part
|
||||
return prefix + detail
|
||||
|
||||
|
||||
def _to_float(v: Any) -> float | None:
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def place_order(
|
||||
auth: dict[str, str], order_info: dict[str, str], base_url: str, kuaidicom: str, weight: str,
|
||||
sender_mobile: str, callback_url: str, dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
param = {
|
||||
"kuaidicom": kuaidicom,
|
||||
"recManName": order_info["rec_name"],
|
||||
"recManMobile": order_info["rec_mobile"],
|
||||
"recManPrintAddr": order_info["rec_addr"],
|
||||
"sendManName": order_info["send_name"],
|
||||
"sendManMobile": sender_mobile,
|
||||
"sendManPrintAddr": order_info["send_addr"],
|
||||
"callBackUrl": callback_url,
|
||||
"cargo": order_info["cargo"],
|
||||
"weight": weight,
|
||||
"payment": "SHIPPER",
|
||||
"remark": f"kuaidi100-e2e-test {order_info['order_no']}",
|
||||
"salt": "inkreach-test",
|
||||
"op": 0,
|
||||
"resultv2": "0",
|
||||
}
|
||||
if kuaidicom in ("shunfeng", "shunfengkuaiyun"): # 文档要求顺丰必填预约取件时间窗(间隔≥1小时)
|
||||
now = time.localtime()
|
||||
start_h = (now.tm_hour + 1) % 24
|
||||
param.update(
|
||||
dayType="1",
|
||||
pickupStartTime=f"{start_h:02d}:00",
|
||||
pickupEndTime=f"{(start_h + 1) % 24:02d}:00",
|
||||
)
|
||||
if dry_run:
|
||||
print("[bOrder dry-run] 以下为将提交的完整 param(未发送请求):")
|
||||
print(json.dumps(param, ensure_ascii=False, indent=2))
|
||||
return {}
|
||||
resp = check(call_api("bOrder", param, auth, base_url), "bOrder 下单")
|
||||
data = resp.get("data") or {}
|
||||
print(
|
||||
f"[bOrder] taskId={data.get('taskId')} orderId={data.get('orderId')} "
|
||||
f"kuaidinum={data.get('kuaidinum')} pollToken={str(data.get('pollToken'))[:20]}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def query_detail(auth: dict[str, str], base_url: str, task_id: Any) -> dict[str, Any] | None:
|
||||
"""订单详情查询(method=detail,只传 taskId),校验订单已正确落库。"""
|
||||
try:
|
||||
resp = check(call_api("detail", {"taskId": task_id}, auth, base_url), "detail 订单详情")
|
||||
data = resp.get("data") or {}
|
||||
print(
|
||||
f"[detail] status={data.get('status')} kuaidinum={data.get('kuaidiNum')} "
|
||||
f"serviceType={data.get('serviceType')} payStatus={data.get('payStatus')}"
|
||||
)
|
||||
return data
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"[detail] 查询失败(不中断测试): {str(err)[:160]}")
|
||||
return None
|
||||
|
||||
|
||||
def cancel_order(auth: dict[str, str], base_url: str, task_id: Any, order_id: Any) -> None:
|
||||
"""取消订单。taskId/orderId 原样回传;cancelMsg 用文档示例文案(实测含"测试"字样会被判订单信息错误)。"""
|
||||
param = {"taskId": task_id, "orderId": order_id, "cancelMsg": "暂时不寄件了"}
|
||||
check(call_api("cancel", param, auth, base_url), "cancel 取消")
|
||||
|
||||
|
||||
def track_query(auth: dict[str, str], com: str, num: str, phone: str = "") -> dict[str, Any] | None:
|
||||
"""实时轨迹查询(poll/query.do): sign=MD5(param+key+customer),需 customer 参数。"""
|
||||
if not auth.get("customer"):
|
||||
print("[track] 未提供 customer,跳过轨迹查询")
|
||||
return None
|
||||
url = base_query_url(auth)
|
||||
param = json.dumps({"com": com, "num": num, "phone": phone}, ensure_ascii=False, separators=(",", ":"))
|
||||
customer = auth["customer"]
|
||||
sign = hashlib.md5(f"{param}{auth['key']}{customer}".encode()).hexdigest().upper()
|
||||
resp = httpx.post(
|
||||
url,
|
||||
data={"customer": customer, "sign": sign, "param": param},
|
||||
timeout=30,
|
||||
).json()
|
||||
print(f"[track] {json.dumps(resp, ensure_ascii=False)[:300]}")
|
||||
return resp
|
||||
|
||||
|
||||
def base_query_url(auth: dict[str, str]) -> str:
|
||||
base = TEST_BASE_URL.replace("/order/borderapi.do", "")
|
||||
return f"{base}/poll/query.do"
|
||||
|
||||
|
||||
# ---------- 入口 ----------
|
||||
|
||||
|
||||
def load_auth(args: argparse.Namespace) -> dict[str, str]:
|
||||
if args.from_secret:
|
||||
from prefect.blocks.system import Secret
|
||||
|
||||
value = Secret.load(args.secret_block).get()
|
||||
auth = json.loads(value) if isinstance(value, str) else value
|
||||
else:
|
||||
auth = {"key": args.key or "", "secret": args.secret or "", "customer": args.customer or ""}
|
||||
missing = [k for k in ("key", "secret") if not auth.get(k)]
|
||||
if missing:
|
||||
raise SystemExit(f"缺少快递100鉴权参数: {missing}。用 --key/--secret 或 --from-secret 提供。")
|
||||
return auth
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="快递100上门取件API端到端测试")
|
||||
parser.add_argument("--key", help="企业版授权码 key")
|
||||
parser.add_argument("--secret", help="签名密钥 secret")
|
||||
parser.add_argument("--customer", default="", help="轨迹查询用 customer(可选)")
|
||||
parser.add_argument("--from-secret", action="store_true", help="从 Prefect Secret Block 读鉴权")
|
||||
parser.add_argument("--secret-block", default="kuaidi100-test", help="Secret Block 名(默认 kuaidi100-test)")
|
||||
parser.add_argument("--base-url", default=TEST_BASE_URL, help=f"API 地址(默认测试环境 {TEST_BASE_URL})")
|
||||
parser.add_argument("--courier", default="yuantong", help="快递编码(默认固定圆通 yuantong)")
|
||||
parser.add_argument("--auto-cheapest", action="store_true", help="按比价结果自动选最便宜(默认用 --courier 固定值)")
|
||||
parser.add_argument("--skip-price", action="store_true", help="跳过比价查询")
|
||||
parser.add_argument("--weight", default=None, help="重量 KG;默认用 SDS 变体克重自动汇总,取不到回退 1")
|
||||
parser.add_argument(
|
||||
"--sender-name", default="张文风",
|
||||
help="寄件人姓名(固定,覆盖 SDS 发货仓名)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sender-mobile", default="18320690872",
|
||||
help="寄件人手机号(固定)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sender-addr", default="广东省广州市番禺区南村镇新基村新盈科技园3栋2楼",
|
||||
help="寄件地址(固定,覆盖 SDS 发货仓地址)",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="只构造并打印下单参数,不实际下单")
|
||||
parser.add_argument(
|
||||
"--callback-url",
|
||||
default="http://8.138.172.239:18090/kuaidi100/order",
|
||||
help="订单回调地址(默认走专属 frpc 实例 kuaidi100 穿透到本机回调服务)",
|
||||
)
|
||||
parser.add_argument("--keep-order", action="store_true", help="下单后不取消")
|
||||
args = parser.parse_args()
|
||||
|
||||
auth = load_auth(args)
|
||||
order_info = fetch_sds_domestic_order()
|
||||
|
||||
# 寄件人固定为广州仓联系人(命令行可覆盖),收件人保持 SDS 订单动态获取
|
||||
order_info["send_name"] = args.sender_name
|
||||
order_info["send_addr"] = args.sender_addr
|
||||
print(f"[sender] 寄件人固定: {order_info['send_name']} {args.sender_mobile} / {order_info['send_addr']}")
|
||||
|
||||
weight = args.weight or order_info.get("weight_kg") or "1"
|
||||
print(f"[weight] 下单申报重量: {weight}kg")
|
||||
|
||||
kuaidicom = args.courier
|
||||
if not args.skip_price:
|
||||
cheapest, _ = compare_prices(auth, order_info, args.base_url, weight) # 比价仅作参考
|
||||
if args.auto_cheapest:
|
||||
kuaidicom = cheapest
|
||||
print(f"[price] --auto-cheapest 生效,切到最便宜: {COURIER_NAMES.get(cheapest, cheapest)} ({cheapest})")
|
||||
print(f"[courier] 固定使用: {COURIER_NAMES.get(kuaidicom, kuaidicom)} ({kuaidicom})")
|
||||
|
||||
data = place_order(
|
||||
auth, order_info, args.base_url, kuaidicom, weight,
|
||||
args.sender_mobile, args.callback_url, dry_run=args.dry_run,
|
||||
)
|
||||
if args.dry_run:
|
||||
print("[done] dry-run 结束,未下单 ✓")
|
||||
return
|
||||
task_id = data.get("taskId") # 保持原始类型回传,cancel 接口对类型敏感
|
||||
order_id = data.get("orderId")
|
||||
kuaidinum = str(data.get("kuaidinum") or "")
|
||||
|
||||
if kuaidinum:
|
||||
track_query(auth, kuaidicom, kuaidinum, order_info["rec_mobile"])
|
||||
|
||||
if task_id:
|
||||
query_detail(auth, args.base_url, task_id)
|
||||
|
||||
if not args.keep_order and task_id and order_id:
|
||||
cancel_order(auth, args.base_url, task_id, order_id)
|
||||
else:
|
||||
print(f"[done] 保留测试单: taskId={task_id} orderId={order_id}")
|
||||
|
||||
print("[done] 端到端测试完成 ✓")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user