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
+8
View File
@@ -0,0 +1,8 @@
"""快递100 上门取件API(线上支付) 客户端。
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
"""
from api.kuaidi100.client import Kuaidi100ApiError, Kuaidi100Client
__all__ = ["Kuaidi100Client", "Kuaidi100ApiError"]
+160
View File
@@ -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()
+97
View File
@@ -23,6 +23,7 @@ from api.sds.models import (
OrderQueryParams,
OrderStatistics,
OrderStatus,
ProductChildrenResponse,
ProductListRequest,
ProductListResponse,
)
@@ -30,6 +31,7 @@ from api.sds.models import (
BASE_URL = "https://pod-api.sdspod.com"
COMPANY_NO = "印美达"
KEYWORDS_BATCH_SIZE = 200
PRODUCT_CACHE_TTL_SECONDS = 24 * 3600 # 产品变体(克重等)是主数据,长缓存
FILTERS_FIELDS = (
"factoryList,merchantList,logisticsList,earlyWarningList,"
"issuingBayAreaList,logisticsSourceList,extendStatusList"
@@ -64,6 +66,7 @@ class SdsMngClient(TokenHttpClient):
self._username = username
self._password = password
self._cache: TTLCache[Any] = TTLCache(cache_ttl_seconds)
self._product_cache: TTLCache[Any] = TTLCache(PRODUCT_CACHE_TTL_SECONDS)
self._concurrency = concurrency
# ----- 认证 -----
@@ -129,6 +132,8 @@ class SdsMngClient(TokenHttpClient):
query["earlyWarningType"] = params.early_warning_type
if params.item_num_type is not None:
query["itemNumType"] = params.item_num_type
if params.carriage_no_status is not None:
query["carriageNoStatus"] = params.carriage_no_status
return query
@staticmethod
@@ -297,6 +302,98 @@ class SdsMngClient(TokenHttpClient):
merged.extend(r.records)
return ProductListResponse(total_count=first.total_count, records=merged)
# ----- 产品变体/克重(主数据,24h 缓存) -----
def get_product_children(self, product_id: int) -> ProductChildrenResponse:
"""产品变体列表(含每尺码克重 weight 克/件、包装盒尺寸)。
GET /pod/product/{id}/children;变体克重是主数据,缓存 PRODUCT_CACHE_TTL_SECONDS。
"""
key = f"children:{product_id}"
cached = self._product_cache.get(key)
if cached is not None:
return cached
data = self.get(f"/pod/product/{product_id}/children", params={"t": int(time.time() * 1000)})
result = ProductChildrenResponse.model_validate(data)
self._product_cache.set(key, result)
return result
def get_variant_weight_map(self, product_id: int) -> dict[int, float]:
"""{变体 id: 克重}。订单行的 product_id 即变体 id,可直接查。"""
return {
c.id: c.weight
for c in self.get_product_children(product_id).records
if c.weight is not None
}
def resolve_product_id(self, code: str) -> int | None:
"""产品编码(父 sku,如 RB9327005) -> 产品 id(keyword 精确搜索,长缓存)。"""
key = f"pid:{code}"
cached = self._product_cache.get(key)
if cached is not None:
return cached or None
resp = self.get_products(ProductListRequest(keyword=code, size=1))
pid = resp.records[0].id if resp.records else 0
self._product_cache.set(key, pid)
return pid or None
def get_order_weight_grams(self, order: Any) -> tuple[float | None, list[str]]:
"""按变体克重汇总订单克重。返回 (总克重, 未匹配行描述);None 时调用方需回退。"""
total, missed, maps = 0.0, [], {}
for it in order.items or []:
supply = getattr(it, "productSupply", None) or {}
parent_id = supply.get("productParentId")
if parent_id is None or it.product_id is None:
missed.append(f"行{it.no or '?'} 缺 productParentId/product_id")
continue
if parent_id not in maps:
maps[parent_id] = self.get_variant_weight_map(parent_id)
weight = maps[parent_id].get(it.product_id)
if weight is None:
missed.append(f"行{it.no or it.product_id} 变体无克重字段")
continue
total += weight * (it.num or 1)
return (total or None), missed
# ----- 面单文件/包裹(运单回传) -----
def _send_with_token(
self,
method: str,
url: str,
*,
files: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
"""基类 _send 未覆盖的请求形态(multipart 上传 / PUT),带 token + 401 重登一次。"""
for attempt in (1, 2):
headers = {self._token_header: self._ensure_token()}
resp = self._http.request(method, url, files=files, json=json_body, params=params, headers=headers)
if resp.status_code == 401 and attempt == 1:
self._clear_token()
continue
resp.raise_for_status()
return resp.json() if resp.content else {}
return {}
def upload_waybill_pdf(self, filename: str, pdf_bytes: bytes) -> dict[str, Any]:
"""上传面单 PDF,返回含 CDN url 的文件信息。POST /files/pdf/logisticsWaybill。"""
files = {"file": (filename, pdf_bytes, "application/pdf")}
return self._send_with_token("POST", "/files/pdf/logisticsWaybill", files=files)
def attach_parcel_label(self, order_id: Any, parcel_id: Any, order_no: str, label_pdf_url: str) -> None:
"""把面单挂到订单包裹(覆盖语义)。PUT /orders/{orderId}/logistic/parcel/{parcelId}。
orderNo 传该包裹运单号(原样保留或回写新单号);labelPdf 为 upload_waybill_pdf 的 url。
"""
self._send_with_token(
"PUT",
f"/orders/{order_id}/logistic/parcel/{parcel_id}",
json_body={"orderNo": order_no, "labelPdf": label_pdf_url},
params={"t": int(time.time() * 1000)},
)
# ----- 物流 -----
def get_logistics_areas(self) -> IssuingBayAreaResponse:
+21
View File
@@ -219,6 +219,26 @@ class ProductListResponse(_SdsModel):
records: list[Product] = Field(default=[], alias="list")
class ProductVariant(_SdsModel):
"""产品变体(/pod/product/{id}/children):颜色/尺码/克重/包装盒尺寸。"""
id: int
parent_id: int | None = Field(default=None, alias="parentId")
sku: str | None = None
size: str | None = None
color_name: str | None = Field(default=None, alias="colorName")
color_code: str | None = Field(default=None, alias="colorCode")
status: int | None = None
weight: float | None = None # 克/件
box_length: float | None = Field(default=None, alias="boxLength")
box_width: float | None = Field(default=None, alias="boxWidth")
box_height: float | None = Field(default=None, alias="boxHeight")
class ProductChildrenResponse(_SdsModel):
records: list[ProductVariant] = Field(default=[], alias="list")
# ----- 物流域(混用 snake_case,显式 alias)-----
@@ -318,6 +338,7 @@ class OrderPageRequest(BaseModel):
extend_status: str | None = None
early_warning_type: str | None = None
item_num_type: str | None = None
carriage_no_status: str | None = None # 运单号状态筛选,如 generate=运单待生成
limit: int | None = None
all: bool | None = None