- 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)
161 lines
5.7 KiB
Python
161 lines
5.7 KiB
Python
"""快递100「上门取件API(线上支付)」REST 客户端(httpx + pydantic 思路,纯客户端层,禁止 import prefect)。
|
|
|
|
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
|
|
|
|
鉴权与签名:
|
|
- 公共 body 参数 method/key/sign/t/param(Content-Type: application/x-www-form-urlencoded);
|
|
- sign = MD5(param + t + key + secret) 转 32 位大写,secret 在企业管理后台获取;
|
|
- 凭据(key/secret/customer/tempid)由调用方(flow 层)从 Secret Block 读取后注入,本层不落盘。
|
|
|
|
实测注意(勿踩):
|
|
- taskId/orderId 回传取消接口时必须原样保持类型(orderId 是字符串数字);
|
|
- cancelMsg 含"测试"字样会被后端判"订单信息错误",用固定文案"暂时不寄件了";
|
|
- returnType=20 出面单图片短链,经 callBackUrl 回调推送(status=200),tempid 必填。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
PROD_BASE_URL = "https://poll.kuaidi100.com/order/borderapi.do"
|
|
TEST_BASE_URL = "http://e-test.kuaidilab.com/api/order/borderapi.do"
|
|
|
|
CANCEL_MSG = "暂时不寄件了"
|
|
|
|
|
|
class Kuaidi100ApiError(RuntimeError):
|
|
"""快递100 业务失败(result != true 或 returnCode != 200)。"""
|
|
|
|
|
|
class Kuaidi100Client:
|
|
"""寄件下单(bOrder)/查价(price)/取消(cancel)/订单详情(detail)。"""
|
|
|
|
def __init__(
|
|
self,
|
|
key: str,
|
|
secret: str,
|
|
*,
|
|
base_url: str = PROD_BASE_URL,
|
|
customer: str | None = None,
|
|
timeout: float = 30.0,
|
|
) -> None:
|
|
self._key = key
|
|
self._secret = secret
|
|
self._customer = customer
|
|
self._base_url = base_url
|
|
self._http = httpx.Client(timeout=timeout)
|
|
|
|
# ----- 公共调用 -----
|
|
|
|
@staticmethod
|
|
def make_sign(param: str, t: str, key: str, secret: str) -> str:
|
|
return hashlib.md5(f"{param}{t}{key}{secret}".encode("utf-8")).hexdigest().upper()
|
|
|
|
def call(self, method: str, param: dict[str, Any]) -> dict[str, Any]:
|
|
"""POST 公共表单,业务失败抛 Kuaidi100ApiError,成功返回整个响应 JSON。"""
|
|
param_str = json.dumps(param, ensure_ascii=False, separators=(",", ":"))
|
|
t = str(int(time.time() * 1000))
|
|
body = {
|
|
"method": method,
|
|
"key": self._key,
|
|
"sign": self.make_sign(param_str, t, self._key, self._secret),
|
|
"t": t,
|
|
"param": param_str,
|
|
}
|
|
resp = self._http.post(self._base_url, data=body)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
if data.get("result") is not True or str(data.get("returnCode")) != "200":
|
|
raise Kuaidi100ApiError(f"[{method}] {json.dumps(data, ensure_ascii=False)[:500]}")
|
|
return data
|
|
|
|
# ----- 业务接口 -----
|
|
|
|
def price(
|
|
self,
|
|
kuaidi_com: str,
|
|
send_addr: str,
|
|
rec_addr: str,
|
|
weight: str = "1",
|
|
service_type: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""查价(只读)。返回 data:defPrice/defFirstPrice/price/overPricePerKg/serviceType 等。"""
|
|
param: dict[str, Any] = {
|
|
"kuaidiCom": kuaidi_com,
|
|
"sendManPrintAddr": send_addr,
|
|
"recManPrintAddr": rec_addr,
|
|
"weight": weight,
|
|
}
|
|
if service_type:
|
|
param["serviceType"] = service_type
|
|
return self.call("price", param).get("data") or {}
|
|
|
|
def border(
|
|
self,
|
|
*,
|
|
kuaidicom: str,
|
|
rec_man_name: str,
|
|
rec_man_mobile: str,
|
|
rec_man_print_addr: str,
|
|
send_man_name: str,
|
|
send_man_mobile: str,
|
|
send_man_print_addr: str,
|
|
callback_url: str,
|
|
cargo: str | None = None,
|
|
weight: str | None = None,
|
|
payment: str = "SHIPPER",
|
|
remark: str | None = None,
|
|
salt: str | None = None,
|
|
return_type: str | None = None,
|
|
tempid: str | None = None,
|
|
print_callback_url: str | None = None,
|
|
extra: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""寄件下单。returnType=20 + tempid 时面单短链经回调(status=200)推送。
|
|
|
|
返回 data:taskId/orderId/kuaidinum/pollToken/appointGotStartTime 等,
|
|
taskId/orderId 后续回传必须保持原类型。
|
|
"""
|
|
param: dict[str, Any] = {
|
|
"kuaidicom": kuaidicom,
|
|
"recManName": rec_man_name,
|
|
"recManMobile": rec_man_mobile,
|
|
"recManPrintAddr": rec_man_print_addr,
|
|
"sendManName": send_man_name,
|
|
"sendManMobile": send_man_mobile,
|
|
"sendManPrintAddr": send_man_print_addr,
|
|
"callBackUrl": callback_url,
|
|
"payment": payment,
|
|
}
|
|
for key, value in (
|
|
("cargo", cargo), ("weight", weight), ("remark", remark), ("salt", salt),
|
|
("returnType", return_type), ("tempid", tempid), ("printCallBackUrl", print_callback_url),
|
|
):
|
|
if value is not None:
|
|
param[key] = value
|
|
if extra:
|
|
param.update(extra)
|
|
return self.call("bOrder", param).get("data") or {}
|
|
|
|
def cancel(self, task_id: Any, order_id: Any, cancel_msg: str = CANCEL_MSG) -> None:
|
|
"""取消订单(取件前)。taskId/orderId 原样回传下单返回值。"""
|
|
self.call("cancel", {"taskId": task_id, "orderId": order_id, "cancelMsg": cancel_msg})
|
|
|
|
def detail(self, task_id: Any) -> dict[str, Any]:
|
|
"""订单详情(只读):status/kuaidiNum/payStatus/preWeight 等。"""
|
|
return self.call("detail", {"taskId": task_id}).get("data") or {}
|
|
|
|
def close(self) -> None:
|
|
self._http.close()
|
|
|
|
def __enter__(self) -> "Kuaidi100Client":
|
|
return self
|
|
|
|
def __exit__(self, *exc: Any) -> None:
|
|
self.close()
|