a3a14b484a
改了什么:新增京东联盟订单查询客户端、cps_order 京东字段迁移、CPS 对账接口 platform=all/jd、数据大盘京东订单与佣金聚合。 为什么改:数据大盘需要展示京东 CPS 真实订单佣金,不再保持占位。 验证方式:python -m pytest tests\\test_admin_read.py::test_dashboard_overview tests\\test_cps_admin.py::test_jd_reconcile_updates_dashboard -q;本地真实拉取 2026-06-27 京东订单 3 行,页面显示京东有效 1 单/佣金 ¥0.13。 --------- Co-authored-by: lowmaster-chen <1119780489@qq.com> Reviewed-on: #90 Co-authored-by: chenshuobo <chenshuobo@wonderable.ai> Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
164 lines
5.5 KiB
Python
164 lines
5.5 KiB
Python
"""京东联盟 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}
|