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