"""快递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()