"""京东联盟 OpenAPI 客户端。 当前只接数据大盘需要的订单明细接口: `jd.union.open.order.row.query`。京东要求订单查询时间窗最长 1 小时, 调用方负责切窗分页。 """ from __future__ import annotations import hashlib import json import logging from datetime import datetime, timedelta, timezone from typing import Any import httpx from app.core.config import settings logger = logging.getLogger(__name__) _BEIJING = timezone(timedelta(hours=8)) class JdUnionError(RuntimeError): """京东联盟 API 调用失败。""" def _parse_json_maybe(value: Any) -> Any: if not isinstance(value, str): return value text = value.strip() if not text: return value try: return json.loads(text) except json.JSONDecodeError: return value def _sign(params: dict[str, Any], secret: str) -> str: pieces = [secret] for key in sorted(k for k in params if k != "sign"): value = params[key] if value is None: continue pieces.append(f"{key}{value}") pieces.append(secret) raw = "".join(pieces) return hashlib.md5(raw.encode("utf-8")).hexdigest().upper() def _unwrap_response(data: dict[str, Any]) -> dict[str, Any]: if "error_response" in data: err = data["error_response"] or {} msg = err.get("zh_desc") or err.get("en_desc") or err.get("msg") or err raise JdUnionError(f"京东 API 错误: {msg}") body: Any = data for key, value in data.items(): if key.endswith("_responce") or key.endswith("_response"): body = value break body = _parse_json_maybe(body) if not isinstance(body, dict): raise JdUnionError("京东 API 返回格式异常") result = body.get("queryResult", body.get("result", body)) result = _parse_json_maybe(result) if not isinstance(result, dict): raise JdUnionError("京东 API 业务结果格式异常") code = str(result.get("code", result.get("resultCode", "200"))) if code not in {"0", "200"}: msg = result.get("message") or result.get("msg") or result.get("resultMsg") or result raise JdUnionError(f"京东 API 业务错误: {msg}") return result def call(method: str, payload: dict[str, Any], *, version: str = "1.0") -> dict[str, Any]: if not settings.jd_union_configured: raise JdUnionError("京东联盟凭证未配置") biz_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) params: dict[str, Any] = { "method": method, "app_key": settings.JD_UNION_APP_KEY, "timestamp": datetime.now(_BEIJING).strftime("%Y-%m-%d %H:%M:%S"), "format": "json", "v": version, "sign_method": "md5", "360buy_param_json": biz_json, } params["sign"] = _sign(params, settings.JD_UNION_APP_SECRET) try: with httpx.Client(timeout=settings.JD_UNION_TIMEOUT_SEC, trust_env=False) as client: resp = client.post(settings.JD_UNION_GATEWAY, data=params) resp.raise_for_status() data = resp.json() except httpx.HTTPError as e: raise JdUnionError(f"京东 API 网络错误: {e}") from e except json.JSONDecodeError as e: raise JdUnionError("京东 API 返回非 JSON") from e return _unwrap_response(data) def _extract_rows(result: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]: payload = _parse_json_maybe(result.get("data", result.get("result", result))) has_more = bool(result.get("hasMore") or result.get("has_more")) if isinstance(payload, dict): for key in ("orderRowResp", "orderRows", "orderList", "orders", "list", "rows"): rows = _parse_json_maybe(payload.get(key)) if isinstance(rows, list): return [r for r in rows if isinstance(r, dict)], bool( payload.get("hasMore") or payload.get("has_more") or has_more ) return [], bool(payload.get("hasMore") or payload.get("has_more") or has_more) if isinstance(payload, list): return [r for r in payload if isinstance(r, dict)], has_more return [], has_more def query_order_rows( *, start_time: datetime, end_time: datetime, query_time_type: int = 3, page_index: int = 1, page_size: int = 200, ) -> dict[str, Any]: """查询京东 CPS 订单行。 query_time_type: 1 下单时间, 2 完成时间, 3 更新时间。 start_time/end_time 用北京时间展示给京东;调用方需保证窗口不超过 1 小时。 """ start_bj = start_time.astimezone(_BEIJING) end_bj = end_time.astimezone(_BEIJING) if end_bj <= start_bj: return {"rows": [], "has_more": False} if end_bj - start_bj > timedelta(hours=1): raise JdUnionError("京东订单查询单次时间窗不能超过 1 小时") order_req: dict[str, Any] = { "pageIndex": page_index, "pageSize": min(max(page_size, 1), 200), "type": query_time_type, "startTime": start_bj.strftime("%Y-%m-%d %H:%M:%S"), "endTime": end_bj.strftime("%Y-%m-%d %H:%M:%S"), } if settings.JD_UNION_AUTH_KEY: order_req["key"] = settings.JD_UNION_AUTH_KEY result = call("jd.union.open.order.row.query", {"orderReq": order_req}) rows, has_more = _extract_rows(result) logger.info( "jd.union.open.order.row.query fetched rows=%s page=%s has_more=%s", len(rows), page_index, has_more, ) return {"rows": rows, "has_more": has_more, "raw": result}