359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""电商全渠道适配器:
|
||
- 统一抽象接口 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()
|