Files
dealerhub/backend/apps/channel/adapters.py
T

359 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""电商全渠道适配器:
- 统一抽象接口 ChannelAdapter(拉单 / 推库存)
- 抖店开放平台真实实现(MD5 签名 + access_token + 分→元换算)
- 1688 开放平台真实实现(HMAC-SHA256 签名 + _aop_timestamp/_aop_signature)
- MockChannelAdapter 本地/测试模拟
- 未配置 app_key 的店铺自动回退 Mock,保证开发环境零依赖可用
"""
import json
import time
from abc import ABC, abstractmethod
from datetime import datetime, timedelta, timezone as dt_timezone
from decimal import Decimal
from typing import Optional, List, Dict, Any
import httpx
from .signing import douyin_sign, alibaba1688_sign
DOUYIN_API_BASE = "https://openapi-fxg.jinritemai.com"
ALI1688_API_BASE = "https://gw.open.1688.com"
class ChannelAdapter(ABC):
"""电商渠道适配器基类。"""
@abstractmethod
def fetch_orders(self, account, since: Optional[datetime] = None) -> List[Dict[str, Any]]:
"""从电商平台拉取最新支付或待发货订单(统一归一化结构)。"""
pass
@abstractmethod
def push_stock(self, account, product_code: str, quantity: Decimal) -> bool:
"""向电商平台同步库存数量。"""
pass
# ============================================================
# 通用 HTTP 缝隙(便于单测 monkeypatch)
# ============================================================
DEFAULT_TIMEOUT = 15.0
def http_post_json(url: str, params: dict) -> Dict[str, Any]:
"""POST 表单参数,返回 JSON 响应。"""
with httpx.Client(timeout=DEFAULT_TIMEOUT) as client:
resp = client.post(url, data=params)
resp.raise_for_status()
return resp.json()
def http_get_json(url: str, params: dict) -> Dict[str, Any]:
"""GET 请求,返回 JSON 响应。"""
with httpx.Client(timeout=DEFAULT_TIMEOUT) as client:
resp = client.get(url, params=params)
resp.raise_for_status()
return resp.json()
def _as_utc(dt):
"""把 DB 里的 naive datetime 视为 UTC。"""
if dt and dt.tzinfo is None:
return dt.replace(tzinfo=dt_timezone.utc)
return dt
# ============================================================
# 抖店开放平台(Douyin / Jinritemai)
# ============================================================
def douyin_build_signed_params(account, method: str, param_json: dict) -> dict:
"""构造抖店平台标准签名请求参数。"""
ts = str(int(time.time()))
params = {
"method": method,
"app_key": account.app_key,
"access_token": account.access_token,
"param_json": json.dumps(param_json, ensure_ascii=False, separators=(",", ":")),
"timestamp": ts,
"v": "2",
"sign_method": "md5",
}
params["sign"] = douyin_sign(params, account.app_secret)
return params
def normalize_douyin_orders(api_data: dict) -> List[Dict[str, Any]]:
"""将抖店 order/searchList 响应归一化为内部订单结构。
金额字段单位为"分",统一换算为元(Decimal)。
"""
data = api_data.get("data") or {}
order_list = (
data.get("shop_order_list")
or data.get("order_list")
or data.get("list")
or []
)
results = []
for od in order_list:
receiver = od.get("post_receiver") or {}
pay_amount_cents = od.get("pay_amount") or 0
items = []
for sku in od.get("sku_order_list") or []:
qty = str(sku.get("sku_num") or sku.get("item_num") or "1")
price_cents = sku.get("sku_order_item_price") or sku.get("sku_price") or 0
items.append({
"product_code": str(
sku.get("external_sku_id")
or sku.get("ext_product_code")
or sku.get("sku_id")
or ""
),
"quantity": qty,
"unit_price": str(Decimal(str(price_cents)) / 100),
})
results.append({
"external_order_id": str(od.get("order_id_str") or od.get("order_id") or od.get("id") or ""),
"order_status": str(od.get("order_status") or od.get("status") or ""),
"order_amount": str(Decimal(str(pay_amount_cents)) / 100),
"buyer_name": str(od.get("buyer_words") or receiver.get("user_name", "") or ""),
"buyer_phone": str(receiver.get("mobile", "") or ""),
"receiver_address": " ".join(filter(None, [
receiver.get("province", ""),
receiver.get("city", ""),
receiver.get("town", ""),
receiver.get("detail", ""),
])),
"items": items,
})
return results
class DouyinChannelAdapter(ChannelAdapter):
"""抖店开放平台适配器(MD5 签名)。"""
def fetch_orders(self, account, since: Optional[datetime] = None) -> List[Dict[str, Any]]:
# 未配置真实凭证 → 回退 Mock,方便本地联调
if not account.app_key or not account.access_token:
return MockChannelAdapter().fetch_orders(account, since)
self.ensure_fresh_token(account)
param_json = {"order_status": 100, "page": 0, "size": 50} # 100 = 已支付待发货
if since:
param_json["start_time"] = int(since.timestamp())
params = douyin_build_signed_params(account, "order.searchList", param_json)
api_data = self._http_post(f"{DOUYIN_API_BASE}/order/searchList", params)
return normalize_douyin_orders(api_data)
def push_stock(self, account, product_code: str, quantity: Decimal) -> bool:
if not account.app_key or not account.access_token:
return MockChannelAdapter().push_stock(account, product_code, quantity)
self.ensure_fresh_token(account)
param_json = {"product_id": str(product_code), "stock_num": int(quantity)}
params = douyin_build_signed_params(account, "product.stockNum.update", param_json)
data = self._http_post(f"{DOUYIN_API_BASE}/product/stockNum/update", params)
return data.get("code") == 10000
def ensure_fresh_token(self, account) -> None:
"""access_token 临期(< 1 天)时自动刷新。"""
expires_at = _as_utc(account.token_expires_at)
now = datetime.now(dt_timezone.utc)
if expires_at and expires_at > now + timedelta(days=1):
return
params = {
"app_key": account.app_key,
"grant_type": "refresh_token",
"refresh_token": account.refresh_token,
}
params["sign"] = douyin_sign(params, account.app_secret)
data = self._http_post(f"{DOUYIN_API_BASE}/oauth2/refresh_token", params)
if data.get("code") == 10000 and data.get("data"):
token_data = data["data"]
account.access_token = token_data.get("access_token", account.access_token)
account.refresh_token = token_data.get("refresh_token", account.refresh_token)
expires_in = int(token_data.get("expires_in", 604800))
account.token_expires_at = now + timedelta(seconds=expires_in)
account.save(update_fields=["access_token", "refresh_token", "token_expires_at", "updated_at"])
def _http_post(self, url: str, params: dict) -> Dict[str, Any]:
return http_post_json(url, params)
# ============================================================
# 1688 开放平台(Ocean 协议)
# ============================================================
def hmac_sha256_upper(secret: str, raw: str) -> str:
import hashlib
import hmac as _hmac
return _hmac.new(
secret.encode("utf-8"), raw.encode("utf-8"), hashlib.sha256
).hexdigest().upper()
def alibaba1688_build_url_params(account, api_path: str, extra_params: dict) -> dict:
"""构造 1688 Ocean 协议签名请求参数(GET/POST 通用)。
Ocean 协议签名串 = URL 路径部分(/param2/1/{api}/{appKey}) + 排序后的 k+v 拼接
"""
ts = str(int(time.time() * 1000))
sign_params = dict(extra_params)
sign_params["access_token"] = account.access_token
sign_params["_aop_timestamp"] = ts
sign_params["_aop_key"] = account.app_key
path_part = f"/param2/1/{api_path}/{account.app_key}"
sorted_pairs = "".join(f"{k}{sign_params[k]}" for k in sorted(sign_params.keys()))
sign_raw = f"{path_part}{sorted_pairs}"
request_params = dict(sign_params)
request_params["_aop_signature"] = hmac_sha256_upper(account.app_secret, sign_raw)
return request_params
def normalize_1688_orders(api_data: dict) -> List[Dict[str, Any]]:
"""将 1688 alibaba.trade.getBuyerOrderList 响应归一化。
金额单位为"分"。结构:result.result[] -> baseInfo + productItems[]
"""
result = api_data.get("result") or {}
order_list = result.get("result") or result.get("resultList") or []
results = []
for od in order_list:
base = od.get("baseInfo") or {}
items = []
for pi in od.get("productItems") or []:
qty = str(pi.get("quantity") or pi.get("itemQty") or "1")
amount_cents = pi.get("itemAmount") or 0
qty_dec = Decimal(qty) if Decimal(qty) > 0 else Decimal("1")
unit_price = ((Decimal(str(amount_cents)) / 100) / qty_dec).quantize(Decimal("0.0001"))
items.append({
"product_code": str(pi.get("productID") or pi.get("extProductCode") or pi.get("productCode") or ""),
"quantity": qty,
"unit_price": str(unit_price),
})
results.append({
"external_order_id": str(base.get("idOfStr") or base.get("id") or ""),
"order_status": str(base.get("status") or ""),
"order_amount": str(Decimal(str(base.get("totalAmount") or 0)) / 100),
"buyer_name": str(base.get("buyerLoginID") or ""),
"buyer_phone": "",
"receiver_address": " ".join(filter(None, [
base.get("receiverProvince", ""),
base.get("receiverCity", ""),
base.get("receiverAddress", ""),
])),
"items": items,
})
return results
class Alibaba1688Adapter(ChannelAdapter):
"""1688 开放平台适配器(HMAC-SHA256 签名,Ocean 协议)。"""
TRADE_API = "com.alibaba.trade/alibaba.trade.getBuyerOrderList"
def fetch_orders(self, account, since: Optional[datetime] = None) -> List[Dict[str, Any]]:
if not account.app_key or not account.access_token:
return MockChannelAdapter().fetch_orders(account, since)
self.ensure_fresh_token(account)
extra = {"status": "waitsellerreceive"} # 已付款待收货
params = alibaba1688_build_url_params(account, self.TRADE_API, extra)
url = f"{ALI1688_API_BASE}/openapi/param2/1/{self.TRADE_API}/{account.app_key}"
api_data = self._http_get(url, params)
return normalize_1688_orders(api_data)
def push_stock(self, account, product_code: str, quantity: Decimal) -> bool:
# 1688 商品库存需走 offer 编辑接口(未接入),如实返回 False 交由调用方记录
if not account.app_key:
return MockChannelAdapter().push_stock(account, product_code, quantity)
return False
def ensure_fresh_token(self, account) -> None:
"""1688 refreshToken 刷新( /system/oauth2/refreshToken )。"""
expires_at = _as_utc(account.token_expires_at)
now = datetime.now(dt_timezone.utc)
if expires_at and expires_at > now + timedelta(days=1):
return
params = {
"appKey": account.app_key,
"refreshToken": account.refresh_token,
}
params["sign"] = alibaba1688_sign(params, account.app_secret)
url = f"{ALI1688_API_BASE}/openapi/http1/system/oauth2/refreshToken/{account.app_key}"
data = self._http_post(url, params)
token = data.get("access_token") or (data.get("result") or {}).get("access_token")
if token:
account.access_token = token
expires_in_raw = data.get("expires_in") or (data.get("result") or {}).get("expires_in") or 86400
account.token_expires_at = now + timedelta(seconds=int(expires_in_raw))
account.save(update_fields=["access_token", "token_expires_at", "updated_at"])
def _http_get(self, url: str, params: dict) -> Dict[str, Any]:
return http_get_json(url, params)
def _http_post(self, url: str, params: dict) -> Dict[str, Any]:
return http_post_json(url, params)
class MockChannelAdapter(ChannelAdapter):
"""模拟测试适配器:用于本地环境与单测,生成结构规范的测试订单。"""
def fetch_orders(self, account, since: Optional[datetime] = None) -> List[Dict[str, Any]]:
from apps.catalog.models import Product
p = Product.objects.filter(tenant=account.tenant, status="active").first()
if not p:
p = Product.objects.filter(tenant=account.tenant).first()
prod_code = p.code if p else "P001"
prod_price = p.sale_price if p else Decimal("50.00")
return [
{
"external_order_id": f"{account.platform.upper()}-{datetime.now().strftime('%Y%m%d%H%M%S')}-001",
"order_status": "PAID",
"order_amount": str(prod_price * 2),
"buyer_name": "张三(电商买家)",
"buyer_phone": "13800138000",
"receiver_address": "四川省成都市高新区天府软件园",
"items": [
{
"product_code": prod_code,
"quantity": "2",
"unit_price": str(prod_price),
}
],
}
]
def push_stock(self, account, product_code: str, quantity: Decimal) -> bool:
return True
def get_adapter_for_account(account) -> ChannelAdapter:
"""工厂方法:根据店铺平台获取对应适配器。"""
if account.platform == "douyin":
return DouyinChannelAdapter()
elif account.platform == "1688":
return Alibaba1688Adapter()
else:
return MockChannelAdapter()