《抖音订单API:/trade/order/detail 落地方案 —— 消息推送 + 补偿查询的架构设计》(附 Python 源码)
一、先拍结论
抖店订单同步的成熟架构只有一句话:“消息/数据推送做触发器,/order/searchList 做时间窗兜底,/order/orderDetail 拉真相,本地状态机做仲裁。”你写的/trade/order/detail在抖店开放平台里更接近“抖音开放平台担保支付/交易订单”语义;抖店(抖店开放平台)真实订单详情是/order/orderDetail,列表是/order/searchList。
订单详情接口存在部分数据返回延时,订单生成后延时 10s 再查;极端情况延迟 20 分钟,要用补偿机制再查一次
数据推送(抖店云 RDS)按 B 端事件触发,不是底层表每行变更都推
只靠消息推送 = 高峰丢包/重试失败/服务器慢响应 → 漏单
正确姿势:消息推送 + 接口轮询补偿
二、抖店订单接口边界(先纠名)
接口 | 角色 | 说明 |
|---|---|---|
消息推送 doudian_trade_TradeCreate/Paid/... | 触发器 | 只给 order_id / 事件类型 |
数据推送(抖店云 RDS) | 准实时落库 | 推密文字段到绑定库,订单/售后/商品可配 |
/order/searchList | 补偿/初始化 | 按创建时间或更新时间拉列表 |
/order/orderDetail | 真相源 | 子单、优惠、收货密文、履约、售后态 |
/afterSale/List + /afterSale/Detail | 售后真相 | 退款/退货/换货独立轴 |
规则:消息 ≠ 订单数据,列表 ≠ 订单详情,数据推送 ≠ 业务已处理。
三、整体架构
┌──────────────────────────────┐ │ 抖店平台 │ │ - 消息推送(TradeCreate等) │ │ - 数据推送(RDS 密文) │ └────────────┬─────────────────┘ │ ▼ ┌──────────────────────────────┐ │ Push Ingest(2~5s 返回 success)│ │ - msg_id 去重 │ │ - 只入队 (shop_id, order_id) │ └────────────┬─────────────────┘ ▼ ┌─────────────────┐ │ Order Detail │ │ Worker │ │ /order/orderDetail│ │ +10s 延迟 + 20min │ │ 补偿 │ └────────┬──────────┘ ▼ ┌─────────────────┐ │ 状态机 + 幂等写 │ │ version 仲裁 │ └────────┬──────────┘ ▼ ┌─────────────────┐ │ 补偿轮询 Cron │ │ /order/searchList│ │ 更新时间窗 滚动 │ └─────────────────┘
推模型:快、省调用、但会丢/重/乱序
拉模型:慢、烧调用、但能兜底
四、为什么不能直接“消息来了就发货”
新单 10s 内可能拿不到完整数据
达人信息要等联盟结算,最长延迟 1 小时
数据推送的
version不再是时间戳,不能拿来做时间排序翻页时订单状态变化 → 正向翻页跳单 → 必须倒序/时间窗重叠
order_id 进队,标记 need_detail=true
五、Python:抖店订单 Client
# douyin/order_client.py
import time
import hashlib
import hmac
import requests
class DouyinOrderClient:
GATEWAY = "https://openapi-fxg.jinritemai.com/"
def __init__(self, app_key, app_secret, access_token, sign_method="hmac-sha256"):
self.app_key = app_key
self.app_secret = app_secret
self.access_token = access_token
self.sign_method = sign_method
def _sign(self, params: dict) -> str:
qs = "&".join(f"{k}={params[k]}" for k in sorted(params))
if self.sign_method == "hmac-sha256":
return hmac.new(self.app_secret.encode(), qs.encode(), hashlib.sha256).hexdigest()
return hashlib.md5((self.app_secret + qs + self.app_secret).encode()).hexdigest().upper()
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
def _common(self, method: str):
return {
"method": method,
"app_key": self.app_key,
"access_token": self.access_token,
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"v": "2",
"sign_method": self.sign_method,
"format": "json",
}
def _post(self, method: str, biz: dict):
params = self._common(method)
params["param_json"] = str(biz).replace("'", '"')
params["sign"] = self._sign(params)
resp = requests.post(self.GATEWAY, data=params, timeout=5)
return resp.json()
def search_list(self, start_time, end_time, order_by=1, page=1, page_size=100, order_status=None):
biz = {
"start_time": start_time,
"end_time": end_time,
"order_by": order_by, # 1=按更新时间倒序更稳
"page": page,
"page_size": page_size,
"is_desc": 1,
}
if order_status is not None:
biz["order_status"] = order_status
return self._post("order.searchList", biz)
def order_detail(self, order_id: str):
return self._post("order.orderDetail", {"order_id": order_id})六、Detail Worker:10s 延迟 + 20min 补偿
# douyin/order_detail_worker.py
import time
class OrderDetailWorker:
def __init__(self, client, repo, dedup, bucket, queue):
self.client = client
self.repo = repo
self.dedup = dedup
self.bucket = bucket
self.queue = queue
def handle(self, shop_id: str, order_id: str, delay=10):
# 第一次:延 10s 再查
time.sleep(delay)
snap = self._fetch(order_id)
if not self._data_complete(snap):
# 极端延迟:20 分钟后补偿
self.queue.enqueue_delay(
{"shop_id": shop_id, "order_id": order_id, "phase": "compensate"},
delay=20 * 60,
)
return
self._apply(shop_id, snap)
def _fetch(self, order_id: str):
self.bucket.acquire()
resp = self.client.order_detail(order_id)
return (resp.get("data") or {}).get("order_info") or resp
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
def _data_complete(self, snap) -> bool:
if not snap:
return False
# 刚支付时可能缺收件/子单/优惠拆分
if snap.get("pay_status") in (1, 2) and not snap.get("sku_order_list"):
return False
return True
def _apply(self, shop_id, snap):
event_key = f"detail:{snap.get('order_id')}:{snap.get('update_time')}"
if not self.dedup.mark(shop_id, snap.get("order_id"), event_key):
return
self.repo.upsert_order(shop_id, snap)
self.repo.emit_business_events(snap)七、补偿轮询:时间窗前移 + 倒序 + 重叠
# douyin/order_compensator.py
from datetime import datetime, timedelta
class OrderCompensator:
def __init__(self, client, repo, bucket):
self.client = client
self.repo = repo
self.bucket = bucket
def run(self, shop_id, now: datetime):
# 结束时间前移 2~5 分钟,避开平台入库延迟
end = now - timedelta(minutes=3)
start = end - timedelta(minutes=30)
page = 1
min_update_seen = end
while True:
self.bucket.acquire()
resp = self.client.search_list(
start_time=int(start.timestamp()),
end_time=int(end.timestamp()),
order_by=1, # 更新时间倒序
page=page,
)
orders = self._extract(resp)
if not orders:
break
for o in orders:
self.repo.mark_need_detail(shop_id, o["order_id"], o.get("update_time"))
u = int(o.get("update_time", 0))
if u < min_update_seen.timestamp():
min_update_seen = datetime.fromtimestamp(u)
if len(orders) < 100 or page >= 50:
break
page += 1
# 下一轮从最小 update_time 往前,并留 1 分钟重叠防跳单
return min_update_seen - timedelta(minutes=1)结束时间 前移 2~5 分钟
按更新时间 倒序,从后往前翻
下一轮起点 = 本批最小 update_time - 重叠窗口
不深翻页,不靠
page=9999
八、幂等键与状态机
def order_version(snap) -> int:
# 抖店 version 不是时间戳了,用 update_time 做业务版本
return int(snap.get("update_time") or 0)
def apply_order(repo, shop_id, snap):
cur = repo.get_order(shop_id, snap["order_id"])
new_ver = order_version(snap)
if cur is None:
repo.insert_order(shop_id, snap)
fire_events(None, snap)
return
if new_ver < cur.version:
return # 旧消息晚到
if new_ver == cur.version and snap.get("order_status") == cur.order_status:
return # 完全幂等
check_order_transition(cur.order_status, snap["order_status"])
rows = repo.update_order_if_newer(shop_id, snap, cur.version)
if rows == 1:
fire_events(cur, snap)UPDATE douyin_order
SET order_status=#, pay_amount=#, post_addr=#,
version=#, update_time=#
WHERE shop_id=# AND order_id=#
AND version < #{new_version};九、消息推送接入(只做触发器)
# douyin/push_webhook.py
def on_push(body: dict):
# 2~5 秒内必须返回 success
msg_id = body["msg_id"]
if dedup.msg_seen(msg_id):
return "success"
tag = body["tag"] # TradeCreate / TradePaid ...
order_id = body["data"]["order_id"]
shop_id = body["shop_id"]
queue.publish({"shop_id": shop_id, "order_id": order_id, "trigger": tag})
dedup.mark_msg(msg_id)
return "success"doudian_trade_TradeCreate:下单doudian_trade_TradePaid:支付成功doudian_trade_TradePartlySellerShip/TradeSellerShip:发货doudian_trade_TradeSuccess:完成doudian_trade_TradeCanceled:取消doudian_trade_AuthorInfo:达人信息补推
消息只告诉你要“去看哪个 order_id”,别把消息体当订单真相。
十、数据推送(抖店云 RDS)怎么摆
推到绑定 RDS,不是推到你的业务库
订单/售后/商品可配
历史 60 天、保留 60 天可配
version不再当时间戳
抖店云 RDS(密文) └─ CDC / 定时读 └─ 业务库:order_raw(密文) └─ 解密服务(云内) └─ order_biz(明文只在云内)
明文收货信息/手机号解密服务必须抖店云内;云外只持有 order_id / 状态 / 金额 / 商品,不持有明文。
十一、售后轴独立(和拼多多同思路)
def sync_aftersale(shop_id, start, end):
while True:
resp = client._post("afterSale.List", {
"start_time": start, "end_time": end, "page": page, "page_size": 100
})
for a in extract(resp):
detail = client._post("afterSale.Detail", {
"order_id": a["order_id"], "after_sale_id": a["after_sale_id"]
})
apply_aftersale(shop_id, detail)after_sale_status = 最新一条售后单的投影
douyin_after_sale 表。十二、调用配额与降本
消息推送:不按 API 次数烧钱
数据推送:走抖店云 RDS,不是
/order/orderDetail轮询/order/searchList:补偿用,别当实时主源/order/orderDetail:按 order_id 触发,不批量盲拉新单:消息 → 10s 后 detail
老单/漏单:30min 窗补偿
大促:searchList 窗缩到 5~10min,detail 桶单独限流
十三、落地检查表
[ ] 消息 webhook 2~5s 返回 success,不写业务
[ ] msg_id 去重,order_id 进队
[ ] detail 延 10s 查,不完整就排 20min 补偿
[ ] searchList 结束时间前移 3min,倒序,重叠 1min
[ ] order_id 唯一键 + event_key 去重 + version(update_time) 仲裁
[ ] 订单轴 / 售后轴 / 达人信息轴分开
[ ] 达人信息用
doudian_trade_AuthorInfo覆盖,别信创建时刻[ ] 明文解密服务在抖店云内,云外只留密文/状态/金额
[ ] 数据推送 RDS 是“准实时源”,不是“业务已处理”
[ ] 大促补偿窗缩短、detail QPS 单独桶、余额守卫在 Client 层
十四、一句话收口
淘宝用 DSS 推、京东用 JMQ 推、拼多多靠增量轮询、抖店是“消息推送 + 抖店云数据推送 + searchList 补偿 + orderDetail 拉真相”四件套。抖店订单最阴的三件事是:新单详情延迟 10s~20min、version 不再是时间戳、达人信息要等联盟结算补推。所以别把消息当数据、别把列表当详情、别把创建时刻的达人信息当最终口径。消息负责“叫你起床”,searchList 负责“查谁没醒”,orderDetail 负责“看清楚这张脸”。