baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
+358
View File
@@ -0,0 +1,358 @@
"""电商全渠道适配器:
- 统一抽象接口 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()