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:
yeuimu
2026-11-03 08:02:39 +00:00
parent d60a99ab4e
commit 5f4e1ffa1f
10 changed files with 1184 additions and 4 deletions
+407
View File
@@ -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()