- 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)
160 lines
5.9 KiB
Python
160 lines
5.9 KiB
Python
"""快递100 上门取件API 回调接收服务(本机常驻,经 frpc 暴露公网)。
|
|
|
|
接收两类推送并验签落盘(MD5(param + salt)):
|
|
- POST /kuaidi100/order 下单状态回调(callBackUrl)与面单短链/打印回调(printCallBackUrl)
|
|
- POST /kuaidi100/poll 轨迹推送(pollCallBackUrl)
|
|
|
|
应答格式必须是 {"result":true,"returnCode":"200","message":"成功"},
|
|
否则快递100会以 30 分钟间隔重推 3 次。
|
|
|
|
查看:
|
|
- GET / 状态与最近回调摘要
|
|
- GET /list?n=20 最近 n 条回调明细(JSON)
|
|
|
|
落盘: <repo>/data/kuaidi100/callbacks.jsonl,每行 {ts,path,verified,taskId,param}
|
|
|
|
用法: uv run python scripts/kuaidi100_callback_server.py [--port 9802] [--salt inkreach-test]
|
|
salt 需与下单 param.salt 一致;也可用环境变量 KUAIDI100_CALLBACK_SALT 覆盖。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
LOG_FILE = REPO_ROOT / "data" / "kuaidi100" / "callbacks.jsonl"
|
|
MAX_BODY = 1 << 20 # 1MB 上限,防异常大包
|
|
|
|
_state = {"started_at": time.time(), "received": 0, "verified_ok": 0}
|
|
|
|
|
|
def _append(record: dict) -> None:
|
|
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
with LOG_FILE.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
|
|
|
|
def _recent(n: int = 20) -> list[dict]:
|
|
if not LOG_FILE.exists():
|
|
return []
|
|
try:
|
|
lines = LOG_FILE.read_text(encoding="utf-8").splitlines()
|
|
except OSError:
|
|
return []
|
|
out = []
|
|
for line in lines[-n:]:
|
|
try:
|
|
out.append(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
return out
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
salt: str = os.environ.get("KUAIDI100_CALLBACK_SALT", "inkreach-test")
|
|
|
|
def log_message(self, fmt: str, *args) -> None: # 静默默认访问日志,落盘代替
|
|
pass
|
|
|
|
# ----- POST 回调 -----
|
|
|
|
def do_POST(self) -> None:
|
|
path = urlparse(self.path).path
|
|
if not path.startswith("/kuaidi100/"):
|
|
self._json(404, {"result": False, "returnCode": "404", "message": "not found"})
|
|
return
|
|
length = min(int(self.headers.get("Content-Length") or 0), MAX_BODY)
|
|
body = self.rfile.read(length).decode("utf-8", errors="replace")
|
|
form = parse_qs(body, keep_blank_values=True)
|
|
param_raw = (form.get("param") or [""])[0]
|
|
sign = (form.get("sign") or [""])[0].upper()
|
|
task_id = (form.get("taskId") or [""])[0]
|
|
expect = hashlib.md5(f"{param_raw}{self.salt}".encode("utf-8")).hexdigest().upper()
|
|
verified = bool(param_raw) and sign == expect
|
|
|
|
parsed: dict | None = None
|
|
if param_raw:
|
|
try:
|
|
parsed = json.loads(param_raw)
|
|
except json.JSONDecodeError:
|
|
parsed = None
|
|
|
|
_state["received"] += 1
|
|
_state["verified_ok"] += int(verified)
|
|
_append(
|
|
{
|
|
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"path": path,
|
|
"verified": verified,
|
|
"sign": sign,
|
|
"taskId": task_id or None,
|
|
"param": parsed if parsed is not None else param_raw[:2000],
|
|
"client": self.client_address[0],
|
|
}
|
|
)
|
|
# 验签失败也先按成功应答(已落盘标记 verified=false),避免测试环境反复重推干扰;
|
|
# 生产可改为拒绝: {"result": false, ...}
|
|
self._json(200, {"result": True, "returnCode": "200", "message": "成功"})
|
|
|
|
# ----- GET 查看 -----
|
|
|
|
def do_GET(self) -> None:
|
|
url = urlparse(self.path)
|
|
# 兼容两种路径:直连 /list 与经 frp/反代的 /kuaidi100/list(与 POST 路由前缀对齐)
|
|
if url.path in ("/list", "/kuaidi100/list"):
|
|
qs = parse_qs(url.query)
|
|
n = min(int((qs.get("n") or ["20"])[0]), 200)
|
|
self._json(200, {"count": n, "items": list(reversed(_recent(n)))})
|
|
return
|
|
recent = _recent(5)
|
|
self._json(
|
|
200,
|
|
{
|
|
"service": "kuaidi100-callback",
|
|
"listen": "127.0.0.1 (via frpc)",
|
|
"started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(_state["started_at"])),
|
|
"uptime_s": int(time.time() - _state["started_at"]),
|
|
"received": _state["received"],
|
|
"verified_ok": _state["verified_ok"],
|
|
"log_file": str(LOG_FILE),
|
|
"recent": [
|
|
{"ts": r.get("ts"), "path": r.get("path"), "verified": r.get("verified"),
|
|
"status": (r.get("param") or {}).get("data", {}).get("status")
|
|
if isinstance(r.get("param"), dict) else None}
|
|
for r in recent
|
|
],
|
|
},
|
|
)
|
|
|
|
def _json(self, code: int, payload: dict) -> None:
|
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="快递100回调接收服务")
|
|
parser.add_argument("--port", type=int, default=9802)
|
|
parser.add_argument("--host", default="127.0.0.1")
|
|
parser.add_argument("--salt", default=Handler.salt, help="验签 salt,须与下单 param.salt 一致")
|
|
args = parser.parse_args()
|
|
Handler.salt = args.salt
|
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
print(f"[kuaidi100-callback] listening on http://{args.host}:{args.port} (salt={args.salt})")
|
|
print(f"[kuaidi100-callback] log -> {LOG_FILE}")
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|