"""快递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) 落盘: /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()