×

《拼多多订单API:pdd.order.list.get 落地方案 —— 退款/售后状态机的幂等设计》(附 Python 源码)

万邦科技Lex 万邦科技Lex 发表于2026-10-15 13:36:51 浏览33 评论0

抢沙发发表评论

《拼多多订单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 做时间仲裁,状态机做乱序防火墙。


群贤毕至

访客