ForcePilot/backend/package/yuxi/channel/extensions/pinduoduo/client.py
Kris 1476e82ef0 feat(channel): 添加拼多多渠道扩展
新增拼多多(Pinduoduo)渠道扩展,支持在 Yuxi 平台中集成拼多多电商客服渠道。

包含以下功能模块:
- client: 拼多多 API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- pairing: 用户配对与绑定
- security: 安全校验
- signature: 请求签名验证
- token: Token 管理
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- tools: Agent 工具集成
- window: 窗口管理
- types: 类型定义
2026-05-21 11:33:31 +08:00

301 lines
9.8 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any
import httpx
from yuxi.channel.extensions.pinduoduo.signature import generate_sign
from yuxi.channel.extensions.pinduoduo.token import PddTokenManager
logger = logging.getLogger(__name__)
# Alternative gateway: https://gw-api.pinduoduo.com/api/router
PDD_API_URL = "https://open-api.pinduoduo.com/api/router"
MAX_RETRY_COUNT = 3
RETRY_BACKOFF_BASE = 2.0
TOKEN_EXPIRED_ERROR_CODES = {10005, 10006, 10007}
RATE_LIMIT_ERROR_CODE = 70001
class PddApiError(Exception):
def __init__(self, error_code: int, error_msg: str, retryable: bool = False):
self.error_code = error_code
self.error_msg = error_msg
self.retryable = retryable
super().__init__(f"[{error_code}] {error_msg}")
class PddApiClient:
def __init__(self, token_manager: PddTokenManager, sign_method: str = "md5"):
self._token_manager = token_manager
self._sign_method = sign_method
self._http_client: httpx.AsyncClient | None = None
def _get_client(self) -> httpx.AsyncClient:
if self._http_client is None:
self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
return self._http_client
async def call(self, api_type: str, params: dict | None = None) -> dict[str, Any]:
token = await self._token_manager.get_token()
client = self._get_client()
base_params: dict[str, Any] = {
"type": api_type,
"client_id": self._token_manager.client_id,
"access_token": token,
"timestamp": int(time.time()),
"data_type": "JSON",
}
if params:
base_params.update(params)
sign = generate_sign(base_params, self._token_manager.client_secret, self._sign_method)
base_params["sign"] = sign
last_error = None
for attempt in range(MAX_RETRY_COUNT):
try:
resp = await client.post(PDD_API_URL, json=base_params)
resp.raise_for_status()
data = resp.json()
if "error_response" in data:
err = data["error_response"]
err_code = err.get("error_code", -1)
err_msg = err.get("error_msg", "unknown error")
if err_code in TOKEN_EXPIRED_ERROR_CODES:
logger.info("Token expired, refreshing...")
await self._token_manager.invalidate_token()
token = await self._token_manager.get_token()
base_params["access_token"] = token
base_params["sign"] = generate_sign(base_params, self._token_manager.client_secret, self._sign_method)
continue
if err_code == RATE_LIMIT_ERROR_CODE:
wait_time = RETRY_BACKOFF_BASE**attempt
logger.warning(
"Rate limited, waiting %.1fs (attempt %d)",
wait_time,
attempt + 1,
)
await asyncio.sleep(wait_time)
last_error = PddApiError(err_code, err_msg, retryable=True)
continue
raise PddApiError(err_code, err_msg)
return data
except httpx.HTTPStatusError as e:
raise PddApiError(
e.response.status_code,
f"HTTP {e.response.status_code}",
retryable=e.response.status_code >= 500,
) from e
except httpx.RequestError as e:
last_error = PddApiError(-1, str(e), retryable=True)
if attempt < MAX_RETRY_COUNT - 1:
wait_time = RETRY_BACKOFF_BASE**attempt
await asyncio.sleep(wait_time)
continue
raise last_error or PddApiError(-1, "Max retries exceeded")
async def close(self) -> None:
if self._http_client:
await self._http_client.aclose()
self._http_client = None
class PddCSClient:
def __init__(self, api_client: PddApiClient):
self._api = api_client
async def send_text(self, session_id: str, from_user: str, to_user: str, mall_id: str, content: str) -> dict:
return await self._api.call(
"pdd.logistics.cs.session.send",
{
"session_id": session_id,
"from_user": from_user,
"to_user": to_user,
"mall_id": mall_id,
"message_type": 0,
"message_content": content,
},
)
async def send_image(self, session_id: str, from_user: str, to_user: str, mall_id: str, image_url: str) -> dict:
return await self._api.call(
"pdd.logistics.cs.session.send",
{
"session_id": session_id,
"from_user": from_user,
"to_user": to_user,
"mall_id": mall_id,
"message_type": 1,
"message_content": json.dumps({"url": image_url}),
},
)
async def send_order_card(self, session_id: str, from_user: str, to_user: str, mall_id: str, order_sn: str) -> dict:
return await self._api.call(
"pdd.logistics.cs.session.send",
{
"session_id": session_id,
"from_user": from_user,
"to_user": to_user,
"mall_id": mall_id,
"message_type": 3,
"message_content": json.dumps({"order_sn": order_sn}),
},
)
async def poll_messages(
self,
mall_id: str,
start_modified: str,
end_modified: str,
page_num: int = 1,
page_size: int = 100,
) -> dict:
return await self._api.call(
"pdd.logistics.cs.message.get",
{
"mall_id": mall_id,
"start_modified": start_modified,
"end_modified": end_modified,
"page_num": page_num,
"page_size": page_size,
},
)
class PddOrderClient:
def __init__(self, api_client: PddApiClient):
self._api = api_client
async def get_order_detail(self, order_sn: str) -> dict:
return await self._api.call(
"pdd.order.information.get",
{"order_sn": order_sn},
)
async def get_recent_orders(
self, start_updated_at: str, end_updated_at: str, page: int = 1, page_size: int = 20
) -> dict:
return await self._api.call(
"pdd.order.list.get",
{
"start_updated_at": start_updated_at,
"end_updated_at": end_updated_at,
"page": page,
"page_size": page_size,
},
)
class PddLogisticsClient:
def __init__(self, api_client: PddApiClient):
self._api = api_client
async def get_trace(self, order_sn: str) -> dict:
return await self._api.call(
"pdd.logistics.trace.get",
{"order_sn": order_sn},
)
class PddRefundClient:
def __init__(self, api_client: PddApiClient):
self._api = api_client
async def get_refund_info(self, order_sn: str) -> dict:
return await self._api.call(
"pdd.refund.information.get",
{"order_sn": order_sn},
)
async def get_refund_list(
self, start_updated_at: str, end_updated_at: str, page: int = 1, page_size: int = 20
) -> dict:
return await self._api.call(
"pdd.refund.list.get",
{
"start_updated_at": start_updated_at,
"end_updated_at": end_updated_at,
"page": page,
"page_size": page_size,
},
)
async def agree_refund(self, order_sn: str, remark: str = "") -> dict:
return await self._api.call(
"pdd.refund.status.update",
{
"order_sn": order_sn,
"operate_type": 1,
"remark": remark,
},
)
async def reject_refund(self, order_sn: str, reject_reason: str, reject_remark: str = "") -> dict:
return await self._api.call(
"pdd.refund.status.update",
{
"order_sn": order_sn,
"operate_type": 2,
"reject_reason": reject_reason,
"reject_remark": reject_remark,
},
)
class PddSessionClient:
def __init__(self, api_client: PddApiClient):
self._api = api_client
async def transfer_human(self, session_id: str, mall_id: str, buyer_id: str, reason: str = "") -> dict:
params: dict = {
"session_id": session_id,
"mall_id": mall_id,
"to_user": buyer_id,
}
if reason:
params["transfer_reason"] = reason
return await self._api.call(
"pdd.logistics.cs.session.transfer.human",
params,
)
async def close_session(self, session_id: str, mall_id: str) -> dict:
return await self._api.call(
"pdd.logistics.cs.session.close",
{
"session_id": session_id,
"mall_id": mall_id,
},
)
class PddGoodsClient:
def __init__(self, api_client: PddApiClient):
self._api = api_client
async def get_goods_detail(self, goods_id: int) -> dict:
return await self._api.call(
"pdd.goods.detail.get",
{"goods_id": goods_id},
)
async def get_goods_list(self, page: int = 1, page_size: int = 20) -> dict:
return await self._api.call(
"pdd.goods.list.get",
{"page": page, "page_size": page_size},
)