《拼多多订单API:pdd.order.list.get 落地方案 —— 退款/售后状态机的幂等设计》(附 Python 源码)
先拍结论:
pdd.order.list.get不是实时增量主接口,它是“按成交时间捞历史/初始化”的接口(时间窗 ≤24h,历史 90 天)。实时同步要用pdd.order.number.list.increment.get,真相用pdd.order.information.get,售后用pdd.refund.list.increment.get+pdd.refund.information.get。拼多多订单最坑的是:订单发货态(order_status)和售后态(after_sales_status / refund_status)是两条独立轴,不能只存一个“状态”字段。
正确模型:
order_status : 1待发货 / 2已发货待签收 / 3已签收 refund_status: 1无售后或关闭 / 2售后处理中 / 3退款中 / 4退款成功 after_sales_status: 0/2/3/4/5/6/7/8/9/10/11/...(细粒度售后单状态) risk_control_status: 0正常 / 1审核中
生产里“订单状态机”必须是 (order_status, refund_status, after_sales_status, risk_control_status, version) 五维收敛,不是status一个字段来回覆盖。
一、pdd.order.list.get 的定位(别用错)
接口 | 时间维度 | 用途 | 生产角色 |
|---|---|---|---|
pdd.order.list.get | 成交时间,窗 ≤24h,历史 90 天 | 历史初始化 | 新店接入/补数据 |
pdd.order.number.list.increment.get | 更新时间,窗 ≤30min | 实时增量 | 主轮询 |
pdd.order.information.get | order_sn | 订单真相 | 拉子单/优惠/地址/售后态 |
pdd.refund.list.increment.get | 售后更新时间,窗 ≤30min | 售后增量 | 售后单触发器 |
pdd.refund.information.get | order_sn + after_sales_id | 售后真相 | 退款/退货/换货处理 |
官方口径很明确:
新用户用
order.list.get 同步历史,实时用 order.number.list.increment.get,消息/增量只当触发器,information 才是真相。二、拼多多订单/售后状态轴(必须分开建)
1. 订单发货轴 order_status
1 待发货 2 已发货待签收 3 已签收
(仅已成交订单:
confirm_status=1)2. 售后粗轴 refund_status(列表接口用)
1 无售后或售后关闭 2 售后处理中 3 退款中 4 退款成功 5 全部
3. 售后细轴 after_sales_status(详情/售后接口用)
0 无售后 2 买家申请退款,待商家处理 3 退货退款,待商家处理 4 商家同意退款,退款中 5 平台同意退款,退款中 6 驳回退款,待买家处理 7 已同意退货退款,待用户发货 8 平台处理中 9 平台拒绝退款,退款关闭 10 退款成功 11 买家撤销 12 买家逾期未处理,退款失败 13 买家逾期,超过有效期 14 换货补寄待商家处理 15 换货补寄待用户处理 16 换货补寄成功 17 换货补寄失败 18 换货补寄待用户确认完成 21 待商家同意维修 22 待用户确认发货 24 维修关闭 25 维修成功 27 待用户确认收货 31 已同意拒收退款,待用户拒收 32 补寄待商家发货 33 同意召回后退款,待商家召回
4. 风控轴
risk_control_status = 1 → 审核订单,不能发货
发货铁律(官方):order_status=1 AND refund_status=1 AND risk_control_status=0才能发。
三、幂等键设计(三层叠加)
L1 唯一键: (shop_id, order_sn) → 防重复插入 L2 去重表: (shop_id, order_sn, event_key) → 防重复消费 L3 状态机: UPDATE ... WHERE order_sn=? AND version < new_version AND status 合法
event_key 可以是:incr:{updated_at}(增量轮询)msg:{msg_id}(消息推送)refund:{after_sales_id}:{after_sales_status}:{updated_at}(售后事件)
四、统一订单快照模型
# pdd/order_model.py
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class PddOrderSnapshot:
shop_id: str
order_sn: str
order_status: int = 0 # 1/2/3
refund_status: int = 1 # 1/2/3/4
after_sales_status: int = 0 # 细粒度
risk_control_status: int = 0 # 0/1
confirm_status: int = 0 # 0未成交 1已成交 2取消
pay_amount: float = 0.0
updated_at: int = 0 # 平台更新时间(秒)
version: int = 0 # = updated_at,单调推进
after_sales_id: Optional[str] = None
refund_amount: float = 0.0
raw: dict = field(default_factory=dict)
def snapshot_from_list(o: dict, shop_id: str) -> PddOrderSnapshot:
updated = int(o.get("updated_at") or o.get("last_updated_time") or 0)
return PddOrderSnapshot(
shop_id=shop_id,
order_sn=o["order_sn"],
order_status=int(o.get("order_status", 0)),
refund_status=int(o.get("refund_status", 1)),
after_sales_status=int(o.get("after_sales_status", 0)),
risk_control_status=int(o.get("risk_control_status", 0)),
confirm_status=int(o.get("confirm_status", 0)),
pay_amount=float(o.get("pay_amount", 0) or 0),
updated_at=updated,
version=updated,
raw=o,
)五、状态机:订单轴 × 售后轴 分别收敛
# pdd/order_state_machine.py
from enum import IntEnum
class OrderShipStatus(IntEnum):
UNKNOWN = 0
WAIT_SHIP = 1
SHIPPED = 2
SIGNED = 3
class AfterSalesStatus(IntEnum):
NONE = 0
BUYER_APPLY_REFUND = 2
RETURN_REFUND_PENDING = 3
SELLER_AGREED_REFUNDING = 4
PLATFORM_AGREED_REFUNDING = 5
REJECTED_PENDING_BUYER = 6
AGREED_RETURN_WAIT_BUYER_SHIP = 7
PLATFORM_PROCESSING = 8
REFUND_CLOSED = 9
REFUND_SUCCESS = 10
BUYER_CANCELED = 11
EXCHANGE_PENDING_SELLER = 14
EXCHANGE_PENDING_BUYER = 15
EXCHANGE_SUCCESS = 16
REPAIR_PENDING_SELLER = 21
REPAIR_SUCCESS = 25
# 订单发货态:只允许前进,不允许被旧事件覆盖
ORDER_ALLOWED = {
OrderShipStatus.WAIT_SHIP: {OrderShipStatus.SHIPPED},
OrderShipStatus.SHIPPED: {OrderShipStatus.SIGNED},
OrderShipStatus.SIGNED: set(),
}
# 售后态:可以“升级/关闭/成功”,但不能从 退款成功 退回 待处理
AFTERSALES_BLOCKED_FROM = {
AfterSalesStatus.REFUND_SUCCESS: {
AfterSalesStatus.BUYER_APPLY_REFUND,
AfterSalesStatus.RETURN_REFUND_PENDING,
AfterSalesStatus.SELLER_AGREED_REFUNDING,
},
AfterSalesStatus.REFUND_CLOSED: {
AfterSalesStatus.SELLER_AGREED_REFUNDING,
AfterSalesStatus.PLATFORM_AGREED_REFUNDING,
},
}
class IllegalTransition(Exception):
pass
def check_order_transition(cur: int, new: int):
cur_s = OrderShipStatus(cur)
new_s = OrderShipStatus(new)
if new_s == cur_s:
return
if new_s not in ORDER_ALLOWED.get(cur_s, set()):
raise IllegalTransition(f"order {cur} -> {new} not allowed")
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
def check_aftersales_transition(cur: int, new: int):
new_s = AfterSalesStatus(new)
if new_s == AfterSalesStatus(cur):
return
blocked = AFTERSALES_BLOCKED_FROM.get(AfterSalesStatus(cur), set())
if new_s in blocked:
raise IllegalTransition(f"after_sales {cur} -> {new} rollback blocked")六、幂等落库(SQL 层兜底)
CREATE TABLE pdd_order ( shop_id VARCHAR(32) NOT NULL, order_sn VARCHAR(64) NOT NULL, order_status SMALLINT NOT NULL DEFAULT 0, refund_status SMALLINT NOT NULL DEFAULT 1, after_sales_status SMALLINT NOT NULL DEFAULT 0, risk_control_status SMALLINT NOT NULL DEFAULT 0, confirm_status SMALLINT NOT NULL DEFAULT 0, pay_amount DECIMAL(12,2) NOT NULL DEFAULT 0, version BIGINT NOT NULL DEFAULT 0, updated_at BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (shop_id, order_sn) ); CREATE TABLE pdd_order_event_dedup ( shop_id VARCHAR(32) NOT NULL, order_sn VARCHAR(64) NOT NULL, event_key VARCHAR(128) NOT NULL, created_at BIGINT NOT NULL, PRIMARY KEY (shop_id, order_sn, event_key) );
更新语句(防乱序核心):
UPDATE pdd_order
SET
order_status = #{new_order_status},
refund_status = #{new_refund_status},
after_sales_status = #{new_after_sales},
risk_control_status = #{new_risk},
pay_amount = #{pay_amount},
version = #{new_version},
updated_at = #{new_updated_at}
WHERE shop_id = #{shop_id}
AND order_sn = #{order_sn}
AND version < #{new_version};version < new_version一行搞定:
旧消息晚到 → 0 行
同版本重复 → 0 行
新状态推进 → 1 行
七、Python:增量同步 + 幂等消费
# pdd/order_sync.py
import time
class PddOrderSync:
def __init__(self, client, repo, dedup, bucket):
self.client = client
self.repo = repo
self.dedup = dedup
self.bucket = bucket
def poll_increment(self, start_sec: int, end_sec: int):
page = 1
while True:
self.bucket.acquire()
resp = self.client.execute("pdd.order.number.list.increment.get", {
"start_updated_at": start_sec,
"end_updated_at": end_sec,
"page": page,
"page_size": 100,
})
orders = self._extract(resp)
if not orders:
break
for o in orders:
self.ingest(o)
if len(orders) < 100:
break
page += 1
def ingest(self, o: dict):
shop_id = o["shop_id"]
order_sn = o["order_sn"]
updated = int(o.get("updated_at") or 0)
event_key = f"incr:{updated}"
# L2 去重
if not self.dedup.mark(shop_id, order_sn, event_key):
return
# 列表字段不全 → 拉详情
detail = self.fetch_detail(order_sn)
snap = snapshot_from_list(detail if detail else o, shop_id)
self.apply_snapshot(snap)
def fetch_detail(self, order_sn: str):
try:
resp = self.client.execute("pdd.order.information.get", {"order_sn": order_sn})
return (resp.get("order_info_get_response") or {}).get("order_info")
except RetriableError:
return None # 下次增量/兜底再补
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
def apply_snapshot(self, snap: PddOrderSnapshot):
cur = self.repo.get(shap=snap)
if cur is None:
self.repo.insert(snap)
self.emit_order_events(None, snap)
return
if snap.version < cur.version:
return # 旧事件
if snap.version == cur.version and snap.order_status == cur.order_status \
and snap.after_sales_status == cur.after_sales_status:
return # 完全幂等
# 状态机校验
try:
check_order_transition(cur.order_status, snap.order_status)
check_aftersales_transition(cur.after_sales_status, snap.after_sales_status)
except IllegalTransition as e:
self.repo.append_suspect(cur, snap, str(e))
return
rows = self.repo.update_if_newer(snap)
if rows == 1:
self.emit_order_events(cur, snap)八、售后单独立建模(别塞进订单表覆盖)
@dataclass class PddAfterSale: shop_id: str order_sn: str after_sales_id: str after_sales_status: int after_sales_type: int # 2仅退款 3退货退款 4换货 5补寄 6维修 refund_amount: float version: int raw: dict
售后增量:
def sync_refund(shop_id, start_sec, end_sec):
while True:
resp = client.execute("pdd.refund.list.increment.get", {
"start_updated_at": start_sec,
"end_updated_at": end_sec,
"after_sales_status": 0, # 0=全部
"page": page,
"page_size": 100,
})
for r in extract_refunds(resp):
if not dedup.mark(shop_id, r["order_sn"], f"rf:{r['after_sales_id']}:{r['after_sales_status']}:{r['updated_at']}"):
continue
detail = client.execute("pdd.refund.information.get", {
"order_sn": r["order_sn"],
"after_sales_id": r["after_sales_id"],
})
apply_aftersale(detail)订单上的
after_sales_status 只是“最新一条售后单的投影”,真实仲裁看
pdd_after_sale 表。九、业务动作守卫(最值钱的部分)
def can_ship(snap: PddOrderSnapshot) -> (bool, str): if snap.confirm_status != 1: return False, "not_confirmed" if snap.order_status != 1: return False, "not_wait_ship" if snap.risk_control_status == 1: return False, "risk_review" if snap.refund_status in (2, 3): return False, "refund_in_process" if snap.after_sales_status in ( 2, 3, 4, 5, 7, 8, 14, 15, 21, 31, 33 ): return False, "after_sale_open" return True, "ok"
调用发货前:
ok, reason = can_ship(snap) if not ok: freeze_and_alert(snap.order_sn, reason) else: do_ship(snap)
这就是前面说的:“货发出去了,用户申请退款”的坑。拼多多买家仅退款很早就能发起,ERP 不冻结 = 资损。
十、消息推送 + 轮询 双通道(和淘宝/京东同构)
消息推送 └─ 快返回 success(2~5s 内) └─ 入 MQ └─ pdd.order.information.get 拉真相 pdd.order.number.list.increment.get(30min 窗,每 60s) └─ 兜底漏推送/超时 pdd.refund.list.increment.get(30min 窗) └─ 售后单独立推进 三者都写: pdd_order(order_sn 主键) pdd_after_sale(after_sales_id 主键) pdd_order_event_dedup(event_key 主键)
十一、时间窗与限流
order.list.get:成交时间窗 ≤ 24h,历史 90 天,别做实时主源number.list.increment.get:更新时间窗 ≤ 30min,实时主源refund.list.increment.get:更新时间窗 ≤ 30min列表 QPS / 详情 QPS / 售后 QPS 分桶
预充值余额守卫(上篇)必须套在这里:欠费不是 429,是订单同步直接断气
十二、落地检查表
[ ]
order.list.get只用于初始化/补历史,不用于实时[ ] 实时用
order.number.list.increment.get[ ] 订单轴 / 售后轴 / 风控轴 三轴分开存
[ ]
(shop_id, order_sn)唯一键 +(order_sn, event_key)去重表 +version<new_version状态机[ ] 售后单独立表,订单表只存“最新售后投影”
[ ] 发货前
order_status=1 & refund_status=1 & risk_control_status=0 & 无未结售后[ ] 消息 2~5s 返回 success,业务异步跑
[ ] 旧事件晚到 → 0 行更新;同版本重发 → 0 行更新
[ ] 退款成功/售后关闭不允许回退到“待处理”
[ ] 余额守卫在 Client 层,不在业务层事后发现
十三、一句话收口
淘宝订单难在“消息+增量+详情”三角,京东难在“时间窗+鼎内+POP/FBP 分域”,拼多多订单难在:一个订单号下面,发货态、售后态、风控态、成交态是四根平行轴。你用status一个字段存拼多多订单,迟早会把“已发货 + 退款成功 + 换货中”存成“已发货”。正确做法:列表做触发器,详情做真相,售后单独立建模,version做时间仲裁,状态机做乱序防火墙。