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,8 @@
|
||||
"""快递100 上门取件API(线上支付) 客户端。
|
||||
|
||||
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
|
||||
"""
|
||||
|
||||
from api.kuaidi100.client import Kuaidi100ApiError, Kuaidi100Client
|
||||
|
||||
__all__ = ["Kuaidi100Client", "Kuaidi100ApiError"]
|
||||
@@ -0,0 +1,160 @@
|
||||
"""快递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()
|
||||
Reference in New Issue
Block a user