<?xml version="1.0" encoding="utf-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0"><channel><title>万邦api博客</title><link>https://www.alexob.com/</link><description>Good Luck To You!</description><item><title>做 ERP 的看过来：三平台的商品订单接口，不用再一家家啃文档（附 Python 源码）</title><link>https://www.alexob.com/?id=598</link><description>&lt;pre class=&quot;prism-highlight prism-language-python&quot;&gt;#&amp;nbsp;做&amp;nbsp;ERP&amp;nbsp;的看过来：三平台的商品订单接口，不用再一家家啃文档（附&amp;nbsp;Python&amp;nbsp;源码）

&amp;gt;&amp;nbsp;标题里「不用再一家家啃文档」要诚实地拆开说：**文档躲不掉，但可以从&amp;nbsp;300&amp;nbsp;页降到每平台&amp;nbsp;30&amp;nbsp;页**。
&amp;gt;
&amp;gt;&amp;nbsp;降法不是划重点，是换读法：ERP&amp;nbsp;只该面对一套「动作」，三个平台怎么实现这些动作，是适配器的事。你真正要啃的只有**差异**——而差异是可以列出来的，这篇就把它列全。
&amp;gt;
&amp;gt;&amp;nbsp;先划清和之前那篇&amp;nbsp;ERP&amp;nbsp;文（第&amp;nbsp;15&amp;nbsp;篇）的分工：那篇讲数据进了&amp;nbsp;ERP&amp;nbsp;之后怎么消化（金额拆分、账期、状态映射、超时窗口），全是账务语义；这篇只讲接口层——怎么把三个平台的东西拉进来、推出去，以及&amp;nbsp;ERP&amp;nbsp;场景特有的那道门槛：**多店铺**。

##&amp;nbsp;一、ERP&amp;nbsp;对接的真实工作量：不是学三个&amp;nbsp;SDK，是管理差异

先破一个误区。多数&amp;nbsp;ERP&amp;nbsp;团队评估对接工期时算的是「学三个平台的开发文档」，这不是主要成本。主要成本是**同一个业务动作在三个平台的实现差异**，而且这些差异文档里不会并排放着，你要自己发现。

按&amp;nbsp;ERP&amp;nbsp;最关心的五个域，把差异摊开：

|&amp;nbsp;域&amp;nbsp;&amp;nbsp;|&amp;nbsp;方向&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;增量参数差异&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;分页差异&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;最贵的坑&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;--&amp;nbsp;|&amp;nbsp;------&amp;nbsp;|&amp;nbsp;---------------------------------------------&amp;nbsp;|&amp;nbsp;----------------------&amp;nbsp;|&amp;nbsp;---------------------------------------&amp;nbsp;|
|&amp;nbsp;商品&amp;nbsp;|&amp;nbsp;下行为主&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;淘宝按&amp;nbsp;`modified`，1688&amp;nbsp;按更新时间，京东部分接口**只支持按天**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;三平台游标/页码不一，京东有**分页漂移**&amp;nbsp;|&amp;nbsp;只同步&amp;nbsp;SPU&amp;nbsp;不同步&amp;nbsp;SKU（第&amp;nbsp;1&amp;nbsp;篇的老坑，ERP&amp;nbsp;里表现为库存对不上）&amp;nbsp;|
|&amp;nbsp;订单&amp;nbsp;|&amp;nbsp;下行为主&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;淘宝按&amp;nbsp;`modified`，京东**按天且建议双时间字段**，1688&amp;nbsp;窗口重叠要自己做&amp;nbsp;|&amp;nbsp;页码式，深翻页丢数据&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;用创建时间不支付时间做增量&amp;nbsp;→&amp;nbsp;跨月订单归错账期（第&amp;nbsp;15&amp;nbsp;篇）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;库存&amp;nbsp;|&amp;nbsp;**双向**&amp;nbsp;|&amp;nbsp;无增量可言，靠推送&amp;nbsp;+&amp;nbsp;定时校准&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;—&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;上行回写是**写操作**：覆盖式同步会把&amp;nbsp;ERP&amp;nbsp;里的预占抹掉（第&amp;nbsp;3&amp;nbsp;篇）&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;物流&amp;nbsp;|&amp;nbsp;上行为主&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;—&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;—&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;发货回写错单号&amp;nbsp;=&amp;nbsp;**真发货事故**，这是&amp;nbsp;ERP&amp;nbsp;所有接口里责任最重的一个&amp;nbsp;|
|&amp;nbsp;售后&amp;nbsp;|&amp;nbsp;下行为主&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;三平台退款状态机完全不同（淘宝&amp;nbsp;7&amp;nbsp;态、京东&amp;nbsp;6&amp;nbsp;态、1688&amp;nbsp;退款分层级）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;页码式&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;仅退款&amp;nbsp;/&amp;nbsp;部分退款&amp;nbsp;/&amp;nbsp;退货入库成本重算，接口能拉到但语义要自己拼&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

方向那一列值得多看一眼：**ERP&amp;nbsp;不是「拉数据」，是双向同步**。下行占接口数量的&amp;nbsp;80%，但上行（库存回写、发货回写）占责任的&amp;nbsp;80%——下行拉错数据顶多报表难看，上行推错是真实的发货事故和库存事故。

##&amp;nbsp;二、统一动作层：ERP&amp;nbsp;只面对&amp;nbsp;12&amp;nbsp;个动作

解法就是第&amp;nbsp;8&amp;nbsp;篇那套适配器架构，但&amp;nbsp;ERP&amp;nbsp;版要按&amp;nbsp;ERP&amp;nbsp;的动作习惯重新切。我们把三平台的所有接口收敛成&amp;nbsp;12&amp;nbsp;个动作：

```
下行（拉取）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;上行（推送）
item.pull&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;商品资料&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;stock.push&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;库存回写
item_sku.pull&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SKU&amp;nbsp;明细&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ship.push&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;发货回写
buyer.pull&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;收货人信息&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;invoice.push&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;开票回写
order.pull&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;订单&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;refund.push&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;售后审核回写
stock.pull&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;库存快照&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;memo.push&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;卖家备注回写
refund.pull&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;售后单
logistics.pull&amp;nbsp;&amp;nbsp;&amp;nbsp;物流轨迹
```

两个纪律：

**①&amp;nbsp;动作是&amp;nbsp;ERP&amp;nbsp;定义的，不是平台定义的。**&amp;nbsp;平台的接口按它自己的业务切（淘宝一个「订单」接口里混着交易和履约信息），ERP&amp;nbsp;按自己的单据流切。适配器的活就是把平台切法翻译成&amp;nbsp;ERP&amp;nbsp;切法——**翻译层一旦出现&amp;nbsp;`if&amp;nbsp;订单金额&amp;nbsp;&amp;gt;&amp;nbsp;100`&amp;nbsp;这种业务判断，说明业务逻辑漏进来了**（第&amp;nbsp;8&amp;nbsp;篇的原话，ERP&amp;nbsp;场景下更常见，因为&amp;nbsp;ERP&amp;nbsp;的诱惑就是「顺手在适配层里算个账」）。

**②&amp;nbsp;能力声明必须显式。**&amp;nbsp;三个平台不是每个动作都支持。1688&amp;nbsp;没有按&amp;nbsp;SKU&amp;nbsp;的库存概念（按起订量阶梯），京东开票回写的权限包单独申请。**假装都支持是最贵的错误**：调用方传&amp;nbsp;sku_id，适配器默默返回了商品总库存，不报错，三个月后客户对不上账才被发现。

启动自检（第&amp;nbsp;8&amp;nbsp;篇的&amp;nbsp;`check_capabilities`，ERP&amp;nbsp;版）：

```python
REQUIRED&amp;nbsp;=&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;taobao&amp;quot;:&amp;nbsp;&amp;nbsp;[&amp;quot;item.pull&amp;quot;,&amp;nbsp;&amp;quot;order.pull&amp;quot;,&amp;nbsp;&amp;quot;stock.push&amp;quot;,&amp;nbsp;&amp;quot;ship.push&amp;quot;,&amp;nbsp;&amp;quot;refund.pull&amp;quot;],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;alibaba&amp;quot;:&amp;nbsp;[&amp;quot;item.pull&amp;quot;,&amp;nbsp;&amp;quot;order.pull&amp;quot;,&amp;nbsp;&amp;quot;stock.pull&amp;quot;,&amp;nbsp;&amp;quot;ship.push&amp;quot;],&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;没有&amp;nbsp;stock.push
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;jd&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[&amp;quot;item.pull&amp;quot;,&amp;nbsp;&amp;quot;order.pull&amp;quot;,&amp;nbsp;&amp;quot;stock.push&amp;quot;,&amp;nbsp;&amp;quot;ship.push&amp;quot;,&amp;nbsp;&amp;quot;refund.pull&amp;quot;],
}

def&amp;nbsp;check_capabilities(shops:&amp;nbsp;list[&amp;quot;Shop&amp;quot;])&amp;nbsp;-&amp;gt;&amp;nbsp;None:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;shop&amp;nbsp;in&amp;nbsp;shops:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;missing&amp;nbsp;=&amp;nbsp;[a&amp;nbsp;for&amp;nbsp;a&amp;nbsp;in&amp;nbsp;REQUIRED[shop.platform]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;a&amp;nbsp;not&amp;nbsp;in&amp;nbsp;CAPABILITIES[shop.platform]]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;missing:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;缺能力是配置错误，启动就该死，别等客户对账时发现
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;SystemExit(f&amp;quot;{shop.name}({shop.platform})&amp;nbsp;缺动作:&amp;nbsp;{missing}&amp;quot;)
```

注意&amp;nbsp;1688&amp;nbsp;那行的写法：`REQUIRED`&amp;nbsp;里**不包含**&amp;nbsp;`stock.push`，`CAPABILITIES`&amp;nbsp;里也没有——它不是缺失，是**不存在**。这两份清单的区别就是「没申请权限」和「平台没这个概念」，混着写会误导后来的人去申请一个不存在的权限包。

##&amp;nbsp;三、代码：动作路由&amp;nbsp;+&amp;nbsp;三平台订单增量&amp;nbsp;+&amp;nbsp;发货幂等

`pip&amp;nbsp;install&amp;nbsp;httpx`&amp;nbsp;即可。代码按&amp;nbsp;ERP&amp;nbsp;最疼的两个动作给全：`order.pull`（下行最复杂）和&amp;nbsp;`ship.push`（上行责任最重）。

###&amp;nbsp;3.1&amp;nbsp;统一模型与动作路由

```python
#&amp;nbsp;erp/models.py
from&amp;nbsp;__future__&amp;nbsp;import&amp;nbsp;annotations
from&amp;nbsp;dataclasses&amp;nbsp;import&amp;nbsp;dataclass,&amp;nbsp;field
from&amp;nbsp;decimal&amp;nbsp;import&amp;nbsp;Decimal


@dataclass
class&amp;nbsp;OrderLine:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sku_id:&amp;nbsp;str
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;title:&amp;nbsp;str
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;qty:&amp;nbsp;int
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;price:&amp;nbsp;Decimal&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;单价，永远&amp;nbsp;Decimal（第&amp;nbsp;4&amp;nbsp;篇：float&amp;nbsp;累加对不上账）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;pay_amount:&amp;nbsp;Decimal&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;实付，分摊后的行金额（分摊算法见第&amp;nbsp;15&amp;nbsp;篇）


@dataclass
class&amp;nbsp;Order:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;platform:&amp;nbsp;str
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;order_id:&amp;nbsp;str&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;平台单号
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;shop_id:&amp;nbsp;str&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;多店铺：这单属于哪家店（见第五节）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;status:&amp;nbsp;str&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;归一后的&amp;nbsp;ERP&amp;nbsp;状态，不是平台原始状态
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;paid_at:&amp;nbsp;str&amp;nbsp;|&amp;nbsp;None&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;支付时间。ERP&amp;nbsp;按这个记账期，不是&amp;nbsp;created_at
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines:&amp;nbsp;list[OrderLine]&amp;nbsp;=&amp;nbsp;field(default_factory=list)
#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex

class&amp;nbsp;NotSupported(Exception):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;显式报错&amp;nbsp;&amp;gt;&amp;nbsp;静默返回错数据。允许调用方&amp;nbsp;catch&amp;nbsp;后降级，不允许适配器假装成功&amp;quot;&amp;quot;&amp;quot;
```

```python
#&amp;nbsp;erp/base.py
from&amp;nbsp;abc&amp;nbsp;import&amp;nbsp;ABC,&amp;nbsp;abstractmethod
from&amp;nbsp;erp.models&amp;nbsp;import&amp;nbsp;Order,&amp;nbsp;NotSupported

CAPABILITIES&amp;nbsp;=&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;taobao&amp;quot;:&amp;nbsp;&amp;nbsp;{&amp;quot;item.pull&amp;quot;,&amp;nbsp;&amp;quot;item_sku.pull&amp;quot;,&amp;nbsp;&amp;quot;buyer.pull&amp;quot;,&amp;nbsp;&amp;quot;order.pull&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;stock.pull&amp;quot;,&amp;nbsp;&amp;quot;stock.push&amp;quot;,&amp;nbsp;&amp;quot;ship.push&amp;quot;,&amp;nbsp;&amp;quot;memo.push&amp;quot;,&amp;nbsp;&amp;quot;refund.pull&amp;quot;},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;alibaba&amp;quot;:&amp;nbsp;{&amp;quot;item.pull&amp;quot;,&amp;nbsp;&amp;quot;item_sku.pull&amp;quot;,&amp;nbsp;&amp;quot;order.pull&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;stock.pull&amp;quot;,&amp;nbsp;&amp;quot;ship.push&amp;quot;},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;jd&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;item.pull&amp;quot;,&amp;nbsp;&amp;quot;item_sku.pull&amp;quot;,&amp;nbsp;&amp;quot;buyer.pull&amp;quot;,&amp;nbsp;&amp;quot;order.pull&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;stock.pull&amp;quot;,&amp;nbsp;&amp;quot;stock.push&amp;quot;,&amp;nbsp;&amp;quot;ship.push&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;invoice.push&amp;quot;,&amp;nbsp;&amp;quot;refund.push&amp;quot;,&amp;nbsp;&amp;quot;refund.pull&amp;quot;},
}


class&amp;nbsp;Adapter(ABC):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;platform:&amp;nbsp;str

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;__init__(self,&amp;nbsp;shop):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.shop&amp;nbsp;=&amp;nbsp;shop

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;can(self,&amp;nbsp;action:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;bool:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;action&amp;nbsp;in&amp;nbsp;CAPABILITIES[self.platform]

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@abstractmethod
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;order_pull(self,&amp;nbsp;since:&amp;nbsp;int,&amp;nbsp;cursor:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;tuple[list[Order],&amp;nbsp;dict]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;返回&amp;nbsp;(订单列表,&amp;nbsp;游标)。游标是&amp;nbsp;opaque&amp;nbsp;的：调用方不解析，原样存取&amp;quot;&amp;quot;&amp;quot;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;query_order(self,&amp;nbsp;order_id:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;Order:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;ship.push&amp;nbsp;前的状态复核用，各平台自行实现&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;NotImplementedError

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;do_ship_push(self,&amp;nbsp;order_id:&amp;nbsp;str,&amp;nbsp;waybill:&amp;nbsp;str,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;company:&amp;nbsp;str,&amp;nbsp;idem_key:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;实际回写调用。idem_key&amp;nbsp;作为平台幂等键传入&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;NotImplementedError

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;ship_push(self,&amp;nbsp;order_id:&amp;nbsp;str,&amp;nbsp;waybill:&amp;nbsp;str,&amp;nbsp;company:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;NotSupported(f&amp;quot;{self.platform}&amp;nbsp;未开通&amp;nbsp;ship.push&amp;quot;)
```

游标设计成&amp;nbsp;opaque&amp;nbsp;的&amp;nbsp;dict&amp;nbsp;是刻意的：**调用方不许解析游标内容**。三个平台的游标内部结构完全不同（淘宝存&amp;nbsp;`modified`&amp;nbsp;时间戳，京东要存日期&amp;nbsp;+&amp;nbsp;页码，1688&amp;nbsp;存&amp;nbsp;`last_id`），如果调用方解析了游标，适配器内部就再也不能改增量策略了。

###&amp;nbsp;3.2&amp;nbsp;三平台&amp;nbsp;`order.pull`：差异全在这三个类里

```python
#&amp;nbsp;erp/adapters.py
import&amp;nbsp;asyncio
import&amp;nbsp;time
import&amp;nbsp;httpx

from&amp;nbsp;erp.base&amp;nbsp;import&amp;nbsp;Adapter
from&amp;nbsp;erp.models&amp;nbsp;import&amp;nbsp;Order,&amp;nbsp;OrderLine,&amp;nbsp;NotSupported
from&amp;nbsp;decimal&amp;nbsp;import&amp;nbsp;Decimal

STATUS_MAP&amp;nbsp;=&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;平台原始状态&amp;nbsp;→&amp;nbsp;ERP&amp;nbsp;归一状态。映射表放适配器里（是翻译，不是业务）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;taobao&amp;quot;:&amp;nbsp;&amp;nbsp;{&amp;quot;TRADE_FINISHED&amp;quot;:&amp;nbsp;&amp;quot;DONE&amp;quot;,&amp;nbsp;&amp;quot;WAIT_SELLER_SEND_GOODS&amp;quot;:&amp;nbsp;&amp;quot;TO_SHIP&amp;quot;},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;jd&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;FINISHED&amp;quot;:&amp;nbsp;&amp;quot;DONE&amp;quot;,&amp;nbsp;&amp;quot;WAIT_DELIVERY&amp;quot;:&amp;nbsp;&amp;quot;TO_SHIP&amp;quot;},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;alibaba&amp;quot;:&amp;nbsp;{&amp;quot;success&amp;quot;:&amp;nbsp;&amp;quot;DONE&amp;quot;,&amp;nbsp;&amp;quot;waitbuyerpay&amp;quot;:&amp;nbsp;&amp;quot;UNPAID&amp;quot;},
}


class&amp;nbsp;TaobaoAdapter(Adapter):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;platform&amp;nbsp;=&amp;nbsp;&amp;quot;taobao&amp;quot;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;order_pull(self,&amp;nbsp;since:&amp;nbsp;int,&amp;nbsp;cursor:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;tuple[list[Order],&amp;nbsp;dict]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;游标：modified&amp;nbsp;时间戳&amp;nbsp;+&amp;nbsp;last_id&amp;nbsp;双字段（秒级精度一秒多单会撞，第&amp;nbsp;3&amp;nbsp;篇）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;modified&amp;nbsp;=&amp;nbsp;cursor.get(&amp;quot;modified&amp;quot;,&amp;nbsp;since)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;last_id&amp;nbsp;=&amp;nbsp;cursor.get(&amp;quot;last_id&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;orders:&amp;nbsp;list[Order]&amp;nbsp;=&amp;nbsp;[]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;last_row&amp;nbsp;=&amp;nbsp;None

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;httpx.AsyncClient(timeout=10)&amp;nbsp;as&amp;nbsp;client:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;while&amp;nbsp;True:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r&amp;nbsp;=&amp;nbsp;await&amp;nbsp;client.get(&amp;quot;/v1/taobao/trades&amp;quot;,&amp;nbsp;params={
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;modified_start&amp;quot;:&amp;nbsp;modified,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;page_size&amp;quot;:&amp;nbsp;100,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;重叠加窗&amp;nbsp;60&amp;nbsp;秒：宁可重复不可遗漏（重复靠幂等吸收）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;})
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r.raise_for_status()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;body&amp;nbsp;=&amp;nbsp;r.json()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rows&amp;nbsp;=&amp;nbsp;body.get(&amp;quot;trades&amp;quot;,&amp;nbsp;[])
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;row&amp;nbsp;in&amp;nbsp;rows:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;key&amp;nbsp;=&amp;nbsp;f&amp;quot;{row[&amp;#39;modified&amp;#39;]}:{row[&amp;#39;tid&amp;#39;]}&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;key&amp;nbsp;&amp;lt;=&amp;nbsp;last_id:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;continue&amp;nbsp;&amp;nbsp;#&amp;nbsp;双键排序，跳过已处理的
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;orders.append(self._to_order(row))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;last_row&amp;nbsp;=&amp;nbsp;row
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;not&amp;nbsp;body.get(&amp;quot;has_next&amp;quot;):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;break

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;游标只推进到最后一条已处理的：结构对调用方&amp;nbsp;opaque
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;last_row:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cursor&amp;nbsp;=&amp;nbsp;{&amp;quot;modified&amp;quot;:&amp;nbsp;last_row[&amp;quot;modified&amp;quot;],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;last_id&amp;quot;:&amp;nbsp;f&amp;quot;{last_row[&amp;#39;modified&amp;#39;]}:{last_row[&amp;#39;tid&amp;#39;]}&amp;quot;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;orders,&amp;nbsp;cursor

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;_to_order(self,&amp;nbsp;row:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;Order:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;淘宝&amp;nbsp;HTTP&amp;nbsp;永远&amp;nbsp;200，错误藏在&amp;nbsp;error_response（第&amp;nbsp;11&amp;nbsp;篇）——网关已剥过一层
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Order(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;platform=self.platform,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;order_id=str(row[&amp;quot;tid&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;shop_id=self.shop.id,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;status=STATUS_MAP[self.platform].get(row[&amp;quot;status&amp;quot;],&amp;nbsp;&amp;quot;UNKNOWN&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;paid_at=row.get(&amp;quot;pay_time&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines=[OrderLine(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sku_id=str(s[&amp;quot;sku_id&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;title=s[&amp;quot;title&amp;quot;],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;qty=int(s[&amp;quot;num&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;price=Decimal(str(s[&amp;quot;price&amp;quot;])),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;pay_amount=Decimal(str(s[&amp;quot;payment&amp;quot;])),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;)&amp;nbsp;for&amp;nbsp;s&amp;nbsp;in&amp;nbsp;row.get(&amp;quot;orders&amp;quot;,&amp;nbsp;[])],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;)


class&amp;nbsp;JdAdapter(Adapter):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;platform&amp;nbsp;=&amp;nbsp;&amp;quot;jd&amp;quot;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;order_pull(self,&amp;nbsp;since:&amp;nbsp;int,&amp;nbsp;cursor:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;tuple[list[Order],&amp;nbsp;dict]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;京东部分订单接口只支持按天（第&amp;nbsp;3&amp;nbsp;篇的硬约束）：
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;增量单位从「小时」降级为「天」，用&amp;nbsp;last_modified&amp;nbsp;+&amp;nbsp;去重吸收重复
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;day&amp;nbsp;=&amp;nbsp;cursor.get(&amp;quot;day&amp;quot;)&amp;nbsp;or&amp;nbsp;time.strftime(&amp;quot;%Y-%m-%d&amp;quot;,&amp;nbsp;time.localtime(since&amp;nbsp;-&amp;nbsp;86400))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;orders:&amp;nbsp;list[Order]&amp;nbsp;=&amp;nbsp;[]

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;httpx.AsyncClient(timeout=10)&amp;nbsp;as&amp;nbsp;client:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r&amp;nbsp;=&amp;nbsp;await&amp;nbsp;client.post(&amp;quot;/v1/jd/order_search&amp;quot;,&amp;nbsp;content=_jd_param_json({
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;startDate&amp;quot;:&amp;nbsp;day,&amp;nbsp;&amp;quot;endDate&amp;quot;:&amp;nbsp;day,&amp;nbsp;&amp;quot;page&amp;quot;:&amp;nbsp;cursor.get(&amp;quot;page&amp;quot;,&amp;nbsp;1),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r.raise_for_status()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;body&amp;nbsp;=&amp;nbsp;jd_unwrap(r.json())&amp;nbsp;&amp;nbsp;#&amp;nbsp;按接口名剥壳，key&amp;nbsp;写法还不统一（第&amp;nbsp;11&amp;nbsp;篇）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;row&amp;nbsp;in&amp;nbsp;body.get(&amp;quot;orderList&amp;quot;,&amp;nbsp;[]):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;orders.append(self._to_order(row))

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;按天拉：游标只推进「日期」，重复行由调用方按&amp;nbsp;order_id&amp;nbsp;幂等吸收
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;orders,&amp;nbsp;{&amp;quot;day&amp;quot;:&amp;nbsp;day,&amp;nbsp;&amp;quot;page&amp;quot;:&amp;nbsp;cursor.get(&amp;quot;page&amp;quot;,&amp;nbsp;1)&amp;nbsp;+&amp;nbsp;1}


def&amp;nbsp;_jd_param_json(payload:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;京东的坑：360buy_param_json&amp;nbsp;里的中文不能被&amp;nbsp;\uXXXX&amp;nbsp;转义，
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;签名按原文算（ensure_ascii=False，第&amp;nbsp;11&amp;nbsp;篇那个卡一天半的坑）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;import&amp;nbsp;json
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;json.dumps(payload,&amp;nbsp;ensure_ascii=False,&amp;nbsp;separators=(&amp;quot;,&amp;quot;,&amp;nbsp;&amp;quot;:&amp;quot;))


def&amp;nbsp;jd_unwrap(body:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;dict:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;k,&amp;nbsp;v&amp;nbsp;in&amp;nbsp;body.items():
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;isinstance(v,&amp;nbsp;dict)&amp;nbsp;and&amp;nbsp;&amp;quot;result&amp;quot;&amp;nbsp;in&amp;nbsp;v:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;v[&amp;quot;result&amp;quot;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;body


class&amp;nbsp;AlibabaAdapter(Adapter):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;platform&amp;nbsp;=&amp;nbsp;&amp;quot;alibaba&amp;quot;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;order_pull(self,&amp;nbsp;since:&amp;nbsp;int,&amp;nbsp;cursor:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;tuple[list[Order],&amp;nbsp;dict]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;last_id&amp;nbsp;=&amp;nbsp;cursor.get(&amp;quot;last_id&amp;quot;,&amp;nbsp;0)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;orders:&amp;nbsp;list[Order]&amp;nbsp;=&amp;nbsp;[]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;httpx.AsyncClient(timeout=10)&amp;nbsp;as&amp;nbsp;client:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r&amp;nbsp;=&amp;nbsp;await&amp;nbsp;client.get(&amp;quot;/v1/alibaba/trades&amp;quot;,&amp;nbsp;params={
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;last_id&amp;quot;:&amp;nbsp;last_id,&amp;nbsp;&amp;quot;size&amp;quot;:&amp;nbsp;50})
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r.raise_for_status()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;row&amp;nbsp;in&amp;nbsp;r.json().get(&amp;quot;result&amp;quot;,&amp;nbsp;[]):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;orders.append(self._to_order(row))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;orders,&amp;nbsp;{&amp;quot;last_id&amp;quot;:&amp;nbsp;max((o.order_id&amp;nbsp;for&amp;nbsp;o&amp;nbsp;in&amp;nbsp;orders),&amp;nbsp;default=last_id)}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;_to_order(self,&amp;nbsp;row):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Order(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;platform=self.platform,&amp;nbsp;order_id=str(row[&amp;quot;id&amp;quot;]),&amp;nbsp;shop_id=self.shop.id,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;status=STATUS_MAP[self.platform].get(row[&amp;quot;status&amp;quot;],&amp;nbsp;&amp;quot;UNKNOWN&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;paid_at=row.get(&amp;quot;payTime&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines=[OrderLine(sku_id=str(s[&amp;quot;productID&amp;quot;]),&amp;nbsp;title=s[&amp;quot;name&amp;quot;],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;qty=int(s[&amp;quot;itemCount&amp;quot;]),&amp;nbsp;price=Decimal(str(s[&amp;quot;price&amp;quot;])),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;pay_amount=Decimal(str(s[&amp;quot;itemTotal&amp;quot;])))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;s&amp;nbsp;in&amp;nbsp;row.get(&amp;quot;orderEntrys&amp;quot;,&amp;nbsp;[])],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;)
```

这段代码里每个类的写法都不一样，**这是特性不是缺陷**：淘宝是「时间游标&amp;nbsp;+&amp;nbsp;双键排序」，京东是「按天&amp;nbsp;+&amp;nbsp;幂等吸收」，1688&amp;nbsp;是「ID&amp;nbsp;游标」。差异被关在三个类里，调用方永远只看到&amp;nbsp;`order_pull(since,&amp;nbsp;cursor)&amp;nbsp;-&amp;gt;&amp;nbsp;(orders,&amp;nbsp;cursor)`。

###&amp;nbsp;3.3&amp;nbsp;`ship.push`：责任最重的一个动作

```python
#&amp;nbsp;erp/ship.py
import&amp;nbsp;hashlib

from&amp;nbsp;erp.base&amp;nbsp;import&amp;nbsp;Adapter
from&amp;nbsp;erp.models&amp;nbsp;import&amp;nbsp;NotSupported

#&amp;nbsp;模块级连接，进程启动时初始化（结构见&amp;nbsp;store&amp;nbsp;的&amp;nbsp;SCHEMA&amp;nbsp;思路，第&amp;nbsp;20&amp;nbsp;篇）
db&amp;nbsp;=&amp;nbsp;None&amp;nbsp;&amp;nbsp;#&amp;nbsp;type:&amp;nbsp;sqlite3.Connection

SCHEMA_SHIP&amp;nbsp;=&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
create&amp;nbsp;table&amp;nbsp;if&amp;nbsp;not&amp;nbsp;exists&amp;nbsp;ship_push(
&amp;nbsp;&amp;nbsp;idem_key&amp;nbsp;text&amp;nbsp;primary&amp;nbsp;key,&amp;nbsp;platform&amp;nbsp;text,&amp;nbsp;order_id&amp;nbsp;text,
&amp;nbsp;&amp;nbsp;waybill&amp;nbsp;text,&amp;nbsp;result&amp;nbsp;text,&amp;nbsp;pushed_at&amp;nbsp;integer);
&amp;quot;&amp;quot;&amp;quot;

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
async&amp;nbsp;def&amp;nbsp;_already_pushed(idem:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;bool:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;db.execute(&amp;quot;select&amp;nbsp;1&amp;nbsp;from&amp;nbsp;ship_push&amp;nbsp;where&amp;nbsp;idem_key=?&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(idem,)).fetchone()&amp;nbsp;is&amp;nbsp;not&amp;nbsp;None


async&amp;nbsp;def&amp;nbsp;_mark_pushed(idem:&amp;nbsp;str,&amp;nbsp;adapter:&amp;nbsp;Adapter,&amp;nbsp;order_id:&amp;nbsp;str,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;waybill:&amp;nbsp;str,&amp;nbsp;result:&amp;nbsp;str):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;import&amp;nbsp;time
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;insert&amp;nbsp;or&amp;nbsp;ignore&amp;nbsp;into&amp;nbsp;ship_push&amp;nbsp;values(?,?,?,?,?,?)&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(idem,&amp;nbsp;adapter.platform,&amp;nbsp;order_id,&amp;nbsp;waybill,&amp;nbsp;result,&amp;nbsp;int(time.time())))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.commit()


async&amp;nbsp;def&amp;nbsp;ship_push(adapter:&amp;nbsp;Adapter,&amp;nbsp;order_id:&amp;nbsp;str,&amp;nbsp;waybill:&amp;nbsp;str,&amp;nbsp;company:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;发货回写。三重防呆，缺一个都可能造成真发货事故：

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;1.&amp;nbsp;本地幂等键：同一单号+运单号只发一次（重试安全）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;2.&amp;nbsp;先查后推：本地未确认发货的才允许推
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;3.&amp;nbsp;平台侧幂等：用&amp;nbsp;order_id&amp;nbsp;做平台幂等键，重复推送返回首次结果
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;idem&amp;nbsp;=&amp;nbsp;hashlib.sha1(f&amp;quot;{adapter.platform}:{order_id}:{waybill}&amp;quot;.encode()).hexdigest()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;await&amp;nbsp;_already_pushed(idem):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;&amp;quot;DUPLICATE&amp;quot;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;not&amp;nbsp;adapter.can(&amp;quot;ship.push&amp;quot;):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;NotSupported(f&amp;quot;{adapter.platform}&amp;nbsp;未开通&amp;nbsp;ship.push&amp;quot;)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;先查后推：ERP&amp;nbsp;状态推进和平台状态之间有时间差，推之前再确认一次
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;remote&amp;nbsp;=&amp;nbsp;await&amp;nbsp;adapter.query_order(order_id)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;remote.status&amp;nbsp;not&amp;nbsp;in&amp;nbsp;(&amp;quot;TO_SHIP&amp;quot;,):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;RuntimeError(f&amp;quot;平台状态&amp;nbsp;{remote.status}&amp;nbsp;不可发货，拒绝回写&amp;quot;)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;result&amp;nbsp;=&amp;nbsp;await&amp;nbsp;adapter.do_ship_push(order_id,&amp;nbsp;waybill,&amp;nbsp;company,&amp;nbsp;idem_key=idem)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;_mark_pushed(idem,&amp;nbsp;adapter,&amp;nbsp;order_id,&amp;nbsp;waybill,&amp;nbsp;result)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;result
```


为什么发货回写要三重防呆，库存回写只要两重？因为**发货回写触发的是物理世界**：平台收到回写就真的安排仓库发货了。重试导致的重复发货、错状态导致的提前发货，都是客户仓库里的实际损失——这类接口的容错原则和下行完全相反：**下行情愿重复，上行情愿少做**。

##&amp;nbsp;四、多店铺：ERP&amp;nbsp;的第一道门槛，别到最后才处理

这是&amp;nbsp;ERP&amp;nbsp;和自用脚本最本质的区别：**ERP&amp;nbsp;一个实例服务几十家客户店**。前十九篇里的「店铺」是单数，ERP&amp;nbsp;里必须是复数。这一节把多店铺的地基讲了，深度展开（存储加密、几十家店并发刷新的工程细节）留给下一篇。

三件事必须从第一天做对：

###&amp;nbsp;4.1&amp;nbsp;token&amp;nbsp;按店铺隔离，谁的&amp;nbsp;token&amp;nbsp;只能查谁的数据

第&amp;nbsp;14&amp;nbsp;篇讲过的最隐蔽的死法在多店铺下是**必死**：混淆&amp;nbsp;app&amp;nbsp;级与用户级凭证，接口全绿，拉到的是别人店铺的数据。ERP&amp;nbsp;里这不叫&amp;nbsp;bug，叫**事故**——客户&amp;nbsp;A&amp;nbsp;的订单进了客户&amp;nbsp;B&amp;nbsp;的库。

```python
#&amp;nbsp;erp/shops.py
import&amp;nbsp;asyncio
import&amp;nbsp;time
import&amp;nbsp;sqlite3

import&amp;nbsp;httpx


class&amp;nbsp;Shop:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;__init__(self,&amp;nbsp;shop_id:&amp;nbsp;str,&amp;nbsp;platform:&amp;nbsp;str,&amp;nbsp;name:&amp;nbsp;str):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.id&amp;nbsp;=&amp;nbsp;shop_id
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.platform&amp;nbsp;=&amp;nbsp;platform
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.name&amp;nbsp;=&amp;nbsp;name
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.user_key&amp;nbsp;=&amp;nbsp;&amp;quot;&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;店铺级授权凭证，绝不入库表名混用
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._tokens:&amp;nbsp;dict[str,&amp;nbsp;tuple[str,&amp;nbsp;float]]&amp;nbsp;=&amp;nbsp;{}&amp;nbsp;&amp;nbsp;#&amp;nbsp;platform&amp;nbsp;-&amp;gt;&amp;nbsp;(token,&amp;nbsp;expire)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._refresh_lock&amp;nbsp;=&amp;nbsp;asyncio.Lock()&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;单飞锁，per&amp;nbsp;shop
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.quarantined_until&amp;nbsp;=&amp;nbsp;0.0&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;过期隔离（见&amp;nbsp;4.3）

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;token(self,&amp;nbsp;app_key:&amp;nbsp;str,&amp;nbsp;secret:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;tok&amp;nbsp;=&amp;nbsp;self._tokens.get(self.platform)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;tok&amp;nbsp;and&amp;nbsp;tok[1]&amp;nbsp;&amp;gt;&amp;nbsp;time.time()&amp;nbsp;+&amp;nbsp;300:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;tok[0]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;self._refresh_lock:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;双检：拿到锁再查一次
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;tok&amp;nbsp;=&amp;nbsp;self._tokens.get(self.platform)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;tok&amp;nbsp;and&amp;nbsp;tok[1]&amp;nbsp;&amp;gt;&amp;nbsp;time.time()&amp;nbsp;+&amp;nbsp;300:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;tok[0]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;new_tok,&amp;nbsp;expires_in&amp;nbsp;=&amp;nbsp;await&amp;nbsp;self._do_refresh(app_key,&amp;nbsp;secret)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._tokens[self.platform]&amp;nbsp;=&amp;nbsp;(new_tok,&amp;nbsp;time.time()&amp;nbsp;+&amp;nbsp;expires_in)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;new_tok
```

`_refresh_lock`&amp;nbsp;是&amp;nbsp;**per&amp;nbsp;shop**&amp;nbsp;的，这是多店铺最容易犯的错：用一把全局锁，店铺&amp;nbsp;A&amp;nbsp;刷新时店铺&amp;nbsp;B&amp;nbsp;的正常调用也跟着排队；或者反过来，没有锁，同一家店两个协程同时刷新——京东和&amp;nbsp;1688&amp;nbsp;都是「后刷作废先刷」，两个协程会把自己刷死自己。

###&amp;nbsp;4.2&amp;nbsp;授权链路：ERP&amp;nbsp;走「他用型（ISV）」应用&amp;nbsp;+&amp;nbsp;商家授权

第&amp;nbsp;17&amp;nbsp;篇讲过应用类型决定权限天花板，ERP&amp;nbsp;场景再补三层：

-&amp;nbsp;**ERP&amp;nbsp;是&amp;nbsp;ISV&amp;nbsp;应用**，自用型&amp;nbsp;appkey&amp;nbsp;根本申请不到多店铺需要的权限包。类型选错要重建应用重审，**这个决定要在写第一行代码之前做**。
-&amp;nbsp;每家客户店要**店主自己扫码授权**（生成各自的&amp;nbsp;session/token），ERP&amp;nbsp;拿的是「店铺级」凭证，不是平台级。客户解绑通常无通知，token&amp;nbsp;会悄悄失效——靠每日摘要（第&amp;nbsp;20&amp;nbsp;篇那套）或每店心跳发现。
-&amp;nbsp;给客户的说辞要按第&amp;nbsp;17&amp;nbsp;篇的场景描述写法来：「系统为该商家管理其自有店铺的订单与库存」，别出现「聚合」「采集」。

###&amp;nbsp;4.3&amp;nbsp;单店过期隔离：一家店挂了，不能炸全部

```python
#&amp;nbsp;erp/runner.py
import&amp;nbsp;time


async&amp;nbsp;def&amp;nbsp;run_round(shops:&amp;nbsp;list,&amp;nbsp;adapters:&amp;nbsp;dict,&amp;nbsp;db):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;一轮同步。核心纪律：单店失败隔离，绝不&amp;nbsp;raise&amp;nbsp;到顶层&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;shop&amp;nbsp;in&amp;nbsp;shops:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;time.time()&amp;nbsp;&amp;lt;&amp;nbsp;shop.quarantined_until:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;continue&amp;nbsp;&amp;nbsp;#&amp;nbsp;隔离中，跳过，等摘要邮件喊人
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;adapter&amp;nbsp;=&amp;nbsp;adapters[shop.platform](shop)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cursor&amp;nbsp;=&amp;nbsp;load_cursor(db,&amp;nbsp;shop.id,&amp;nbsp;&amp;quot;order.pull&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;orders,&amp;nbsp;cursor&amp;nbsp;=&amp;nbsp;await&amp;nbsp;adapter.order_pull(since=last_round(db),&amp;nbsp;cursor=cursor)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;save_orders(db,&amp;nbsp;orders)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;按&amp;nbsp;order_id&amp;nbsp;幂等&amp;nbsp;upsert
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;save_cursor(db,&amp;nbsp;shop.id,&amp;nbsp;&amp;quot;order.pull&amp;quot;,&amp;nbsp;cursor)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;except&amp;nbsp;Exception&amp;nbsp;as&amp;nbsp;e:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;连续失败才隔离：单次失败可能只是网络抖动
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;shop.fail_count&amp;nbsp;=&amp;nbsp;getattr(shop,&amp;nbsp;&amp;quot;fail_count&amp;quot;,&amp;nbsp;0)&amp;nbsp;+&amp;nbsp;1
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;shop.fail_count&amp;nbsp;&amp;gt;=&amp;nbsp;3:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;401/403&amp;nbsp;大概率是客户解绑或&amp;nbsp;token&amp;nbsp;过期&amp;nbsp;→&amp;nbsp;隔离&amp;nbsp;1&amp;nbsp;小时
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;shop.quarantined_until&amp;nbsp;=&amp;nbsp;time.time()&amp;nbsp;+&amp;nbsp;3600
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;record_failure(db,&amp;nbsp;shop.id,&amp;nbsp;repr(e))&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;进每日摘要
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;循环正常走完：8&amp;nbsp;家店，1&amp;nbsp;家授权过期，其余&amp;nbsp;7&amp;nbsp;家照常出数据
```

隔离的判断要区分**错误类型**：401/403（授权失效）隔离一小时等客户重新授权；429（限流）降速不隔离；500&amp;nbsp;连续三次也隔离。**把「哪家店失败了、为什么、隔离到几点」写进每日摘要**，实施顾问早上看一眼就知道今天该找哪家客户重新扫码。

##&amp;nbsp;五、给&amp;nbsp;ERP&amp;nbsp;厂商的实施清单

最后是一份可以贴在项目文档里的清单，按实施顺序：

**售前（决定做不做）**

-&amp;nbsp;[&amp;nbsp;]&amp;nbsp;应用类型确认为&amp;nbsp;ISV/他用型（选错要重建重审）
-&amp;nbsp;[&amp;nbsp;]&amp;nbsp;五个域&amp;nbsp;×&amp;nbsp;三平台的能力矩阵过一遍，缺失动作（如&amp;nbsp;1688&amp;nbsp;无&amp;nbsp;stock.push）已与产品确认替代方案
-&amp;nbsp;[&amp;nbsp;]&amp;nbsp;客户的行业类目在三个平台都有对应权限包（特殊类目权限收紧，第&amp;nbsp;17&amp;nbsp;篇）

**开发（决定做多久）**

-&amp;nbsp;[&amp;nbsp;]&amp;nbsp;只啃差异：鉴权一节&amp;nbsp;+&amp;nbsp;增量参数一节&amp;nbsp;+&amp;nbsp;五个域的接口页，每平台约&amp;nbsp;30&amp;nbsp;页
-&amp;nbsp;[&amp;nbsp;]&amp;nbsp;游标&amp;nbsp;opaque，调用方不解析
-&amp;nbsp;[&amp;nbsp;]&amp;nbsp;上行三重防呆（幂等键&amp;nbsp;/&amp;nbsp;先查后推&amp;nbsp;/&amp;nbsp;平台幂等）
-&amp;nbsp;[&amp;nbsp;]&amp;nbsp;状态映射表放适配器，业务规则不进翻译层

**运维（决定活多久）**

-&amp;nbsp;[&amp;nbsp;]&amp;nbsp;token&amp;nbsp;per&amp;nbsp;shop&amp;nbsp;隔离&amp;nbsp;+&amp;nbsp;单飞锁
-&amp;nbsp;[&amp;nbsp;]&amp;nbsp;单店失败隔离&amp;nbsp;+&amp;nbsp;每日摘要含「失败店铺清单」
-&amp;nbsp;[&amp;nbsp;]&amp;nbsp;`solo.db`/token&amp;nbsp;库&amp;nbsp;`chmod&amp;nbsp;600`，绝不进&amp;nbsp;git

---

顺带说一句给&amp;nbsp;ERP&amp;nbsp;厂商的账：这套适配层写完是一次性资产，但**它的维护成本是按客户数摊的**——一家客户分摊到的维护费可能撑不起一个全职人力，这就是为什么不少&amp;nbsp;ERP&amp;nbsp;团队最后把「平台对接」这一层整体外置（买接口服务，自己只做动作层以上的业务）。算不算得过账，取决于你的客户数，公式和第&amp;nbsp;12&amp;nbsp;篇那套&amp;nbsp;TCO&amp;nbsp;一样，把「人力」换成「实施人力」就行。

下一篇写多店铺授权的工程细节：token&amp;nbsp;落库加密、几十家店并发刷新的撞车处理、批量重新授权的引导流程。那是&amp;nbsp;ERP&amp;nbsp;接入的最后一坑。&lt;/pre&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;</description><pubDate>Tue, 03 Nov 2026 11:38:29 +0800</pubDate></item><item><title>写给独立开发者：一个人怎么把三平台数据接口接完（附 Python 源码）</title><link>https://www.alexob.com/?id=597</link><description>&lt;pre class=&quot;prism-highlight prism-language-python&quot;&gt;#&amp;nbsp;写给独立开发者：一个人怎么把三平台数据接口接完（附&amp;nbsp;Python&amp;nbsp;源码）

&amp;gt;&amp;nbsp;先把边界划清：这篇和之前那篇「3&amp;nbsp;天接通三平台」不是一回事。
&amp;gt;
&amp;gt;&amp;nbsp;那篇默认你有一个小团队、有预算、有人分工。这篇的第一约束只有一条：**没有第二个人**。你病了没人值班，你忙别的项目时没人看日志，三个月后你自己都会忘记这个服务还活着。
&amp;gt;
&amp;gt;&amp;nbsp;所以单人版的做法和团队版大量相反。团队版里的最佳实践——事件驱动、统一抽象、熔断降级——对一个人来说，多数是负债：**它们都要花维护意愿，而你的维护意愿是最稀缺的资源**。
&amp;gt;
&amp;gt;&amp;nbsp;下面按「留什么、砍什么、作弊什么、什么时候放弃」四步讲，代码是一个完整可跑的单人版骨架。

##&amp;nbsp;一、先算一笔账：一个人接，到底要多少小时

团队版的账（第&amp;nbsp;6&amp;nbsp;篇算过）是&amp;nbsp;13&amp;nbsp;天。单人版不能按天算，要按小时算——因为你的时间是从晚上和周末里抠的。

诚实的拆法：

|&amp;nbsp;模块&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;小时&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;说明&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;--------------------&amp;nbsp;|&amp;nbsp;------&amp;nbsp;|&amp;nbsp;---------------------------------&amp;nbsp;|
|&amp;nbsp;签名&amp;nbsp;+&amp;nbsp;token&amp;nbsp;刷新&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;6&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;三平台三套签名，1688&amp;nbsp;的&amp;nbsp;urlPath&amp;nbsp;参与签名最容易卡半天&amp;nbsp;|
|&amp;nbsp;限流&amp;nbsp;+&amp;nbsp;HTTP&amp;nbsp;客户端&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;4&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;进程内令牌桶，见下文，不用&amp;nbsp;Redis&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;增量拉取&amp;nbsp;+&amp;nbsp;指纹比对&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;8&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;时间窗&amp;nbsp;+&amp;nbsp;指纹，SQLite&amp;nbsp;存&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;失败重放&amp;nbsp;+&amp;nbsp;每日摘要&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;6&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;单人版的命根子，后面细讲&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;第二个平台&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;8&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;第一个平台摸熟后，主要是字段差异&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;第三个平台&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;6&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;同上&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;自愈测试（拔网线、改错&amp;nbsp;key、杀进程）&amp;nbsp;|&amp;nbsp;2&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;这&amp;nbsp;2&amp;nbsp;小时不能省&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;**合计**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**40**&amp;nbsp;|&amp;nbsp;≈&amp;nbsp;5&amp;nbsp;个周末，或&amp;nbsp;2~3&amp;nbsp;周的晚上&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

注意这是**开发**。维护另算：每月&amp;nbsp;1~~2&amp;nbsp;小时（平台改字段、授权过期、配额规则变化），一年&amp;nbsp;12~~24&amp;nbsp;小时。这笔账第五节要用。

##&amp;nbsp;二、单人版架构：留什么、砍什么

对照团队版逐项过。原则只有一条：**单人无法持续维护的复杂度，一律砍掉**。

|&amp;nbsp;团队版做法&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;单人版&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;为什么&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;--------------------&amp;nbsp;|&amp;nbsp;---------------------------&amp;nbsp;|&amp;nbsp;-----------------------------------------------------&amp;nbsp;|
|&amp;nbsp;TMC&amp;nbsp;消息推送（事件驱动）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**轮询&amp;nbsp;5~10&amp;nbsp;分钟&amp;nbsp;+&amp;nbsp;时间窗**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;推送要维护长连接、断线重连、消息确认。轮询简单粗暴，对独立产品够用。规模上去再换，反着来一次的成本是可控的&amp;nbsp;|
|&amp;nbsp;三平台统一适配层（第&amp;nbsp;8&amp;nbsp;篇）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**先直连一个平台，第二个平台出现重复代码时再抽象**&amp;nbsp;|&amp;nbsp;统一层是给维护团队的投资；单人先抽象等于为还不存在的需求写代码。规矩是老的：事不过三&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;Redis&amp;nbsp;分布式令牌桶（第&amp;nbsp;10&amp;nbsp;篇）&amp;nbsp;|&amp;nbsp;**进程内令牌桶**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;你只有一个进程。少一个&amp;nbsp;Redis，少一类故障，少一笔服务器钱&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;舱壁隔离&amp;nbsp;+&amp;nbsp;熔断降级（第&amp;nbsp;9&amp;nbsp;篇）&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**超时&amp;nbsp;+&amp;nbsp;失败进重放表**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;熔断的价值是保护其他业务线；你只有一条业务线，它挂了你反正要修&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;监控告警系统&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**每天一封摘要邮件**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;告警系统要配、要调阈值、要有人响应。摘要邮件零维护，早上看一眼&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;微服务&amp;nbsp;/&amp;nbsp;消息队列&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**单进程&amp;nbsp;+&amp;nbsp;cron**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;一个进程，一个库，一个定时器。部署&amp;nbsp;=&amp;nbsp;一个&amp;nbsp;systemd&amp;nbsp;单元&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;MySQL&amp;nbsp;/&amp;nbsp;PG&amp;nbsp;+&amp;nbsp;运维&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**SQLite（WAL&amp;nbsp;模式）**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;零安装、零运维、备份就是拷一个文件。单人规模的量它完全扛得住&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

三条单人的铁律，比上面这张表更重要：

1.&amp;nbsp;**一切设计围绕「失联自愈」**。你一定会有一周忘了它的存在。挂掉的轮次要能自己补回来（重放表），而不是等你发现。
2.&amp;nbsp;**宁可重复，不可抽象**。两个平台的相似代码各写各的，等第三个出现再合并。单人项目里，错误的抽象比重复代码贵十倍——因为改错抽象的人只有你。
3.&amp;nbsp;**归一化模型只做你需要的字段**。第&amp;nbsp;2&amp;nbsp;篇那套统一&amp;nbsp;Item/Sku&amp;nbsp;模型是给多消费者系统用的。你的小工具只需要展示&amp;nbsp;6&amp;nbsp;个字段，那就只映射&amp;nbsp;6&amp;nbsp;个。

##&amp;nbsp;三、代码：一个进程、一个库、五个文件

`pip&amp;nbsp;install&amp;nbsp;httpx`&amp;nbsp;之外零依赖。目录结构：

```
solo/
├──&amp;nbsp;token_box.py&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;token&amp;nbsp;刷新，双检
├──&amp;nbsp;gate.py&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;进程内令牌桶
├──&amp;nbsp;store.py&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;SQLite：指纹表&amp;nbsp;+&amp;nbsp;重放表
├──&amp;nbsp;main.py&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;每小时一轮，cron&amp;nbsp;调
└──&amp;nbsp;digest.py&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;每日摘要邮件
```

###&amp;nbsp;3.1&amp;nbsp;token&amp;nbsp;刷新：双检&amp;nbsp;+&amp;nbsp;提前量

第&amp;nbsp;14&amp;nbsp;篇的&amp;nbsp;`TokenManager`&amp;nbsp;用的是分布式锁——那是多实例场景。单进程用线程锁&amp;nbsp;+&amp;nbsp;双检就够了，逻辑一个类讲完：

```python
#&amp;nbsp;solo/token_box.py
import&amp;nbsp;time
import&amp;nbsp;threading
import&amp;nbsp;sqlite3

import&amp;nbsp;httpx

REFRESH_AHEAD&amp;nbsp;=&amp;nbsp;300&amp;nbsp;&amp;nbsp;#&amp;nbsp;过期前&amp;nbsp;5&amp;nbsp;分钟就刷，别卡着点


class&amp;nbsp;TokenBox:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;单进程版&amp;nbsp;token&amp;nbsp;管理。SQLite&amp;nbsp;存&amp;nbsp;+&amp;nbsp;线程锁双检。
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;注意：多实例部署时这套不够，要用分布式锁（见第&amp;nbsp;14&amp;nbsp;篇）。&amp;quot;&amp;quot;&amp;quot;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;__init__(self,&amp;nbsp;db:&amp;nbsp;sqlite3.Connection,&amp;nbsp;platform:&amp;nbsp;str,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;app_key:&amp;nbsp;str,&amp;nbsp;secret:&amp;nbsp;str,&amp;nbsp;refresh_url:&amp;nbsp;str):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.db&amp;nbsp;=&amp;nbsp;db
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.platform&amp;nbsp;=&amp;nbsp;platform
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.app_key&amp;nbsp;=&amp;nbsp;app_key
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.secret&amp;nbsp;=&amp;nbsp;secret
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.refresh_url&amp;nbsp;=&amp;nbsp;refresh_url
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._lock&amp;nbsp;=&amp;nbsp;threading.Lock()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;create&amp;nbsp;table&amp;nbsp;if&amp;nbsp;not&amp;nbsp;exists&amp;nbsp;tokens(&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;platform&amp;nbsp;text&amp;nbsp;primary&amp;nbsp;key,&amp;nbsp;access_token&amp;nbsp;text,&amp;nbsp;expire_at&amp;nbsp;real)&amp;quot;)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;get(self)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;row&amp;nbsp;=&amp;nbsp;self.db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;select&amp;nbsp;access_token,&amp;nbsp;expire_at&amp;nbsp;from&amp;nbsp;tokens&amp;nbsp;where&amp;nbsp;platform=?&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(self.platform,)).fetchone()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;row&amp;nbsp;and&amp;nbsp;row[1]&amp;nbsp;&amp;gt;&amp;nbsp;time.time()&amp;nbsp;+&amp;nbsp;REFRESH_AHEAD:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;row[0]

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;with&amp;nbsp;self._lock:&amp;nbsp;&amp;nbsp;#&amp;nbsp;拿到锁后再查一次，防止两个协程轮流刷
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;row&amp;nbsp;=&amp;nbsp;self.db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;select&amp;nbsp;access_token,&amp;nbsp;expire_at&amp;nbsp;from&amp;nbsp;tokens&amp;nbsp;where&amp;nbsp;platform=?&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(self.platform,)).fetchone()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;row&amp;nbsp;and&amp;nbsp;row[1]&amp;nbsp;&amp;gt;&amp;nbsp;time.time()&amp;nbsp;+&amp;nbsp;REFRESH_AHEAD:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;row[0]

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;token,&amp;nbsp;expires_in&amp;nbsp;=&amp;nbsp;self._refresh()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;insert&amp;nbsp;into&amp;nbsp;tokens(platform,&amp;nbsp;access_token,&amp;nbsp;expire_at)&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;values(?,?,?)&amp;nbsp;on&amp;nbsp;conflict(platform)&amp;nbsp;do&amp;nbsp;update&amp;nbsp;set&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;access_token=excluded.access_token,&amp;nbsp;expire_at=excluded.expire_at&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(self.platform,&amp;nbsp;token,&amp;nbsp;time.time()&amp;nbsp;+&amp;nbsp;expires_in))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.db.commit()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;token

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;_refresh(self)&amp;nbsp;-&amp;gt;&amp;nbsp;tuple[str,&amp;nbsp;int]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r&amp;nbsp;=&amp;nbsp;httpx.post(self.refresh_url,&amp;nbsp;data={
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;app_key&amp;quot;:&amp;nbsp;self.app_key,&amp;nbsp;&amp;quot;secret&amp;quot;:&amp;nbsp;self.secret})
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r.raise_for_status()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;body&amp;nbsp;=&amp;nbsp;r.json()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;京东和&amp;nbsp;1688&amp;nbsp;都有「后刷作废先刷」的行为，
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;刷新失败时别把旧&amp;nbsp;token&amp;nbsp;删了——还能用就先用着
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;body[&amp;quot;access_token&amp;quot;],&amp;nbsp;int(body.get(&amp;quot;expires_in&amp;quot;,&amp;nbsp;86400))
```

最后那段注释是单人版特有的容忍：**刷新失败时保留旧&amp;nbsp;token&amp;nbsp;继续用**。团队版会立刻告警，你没空响应，那就让旧的多活一会儿，大部分情况它真的还能用。

###&amp;nbsp;3.2&amp;nbsp;限流：进程内令牌桶

第&amp;nbsp;10&amp;nbsp;篇的&amp;nbsp;Redis&amp;nbsp;版是给多实例共享额度用的。单进程，一个字典就够——**少一个依赖，少一类故障**：

```python
#&amp;nbsp;solo/gate.py
import&amp;nbsp;time
import&amp;nbsp;threading


class&amp;nbsp;Gate:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;进程内令牌桶，按&amp;nbsp;&amp;#39;平台:接口&amp;#39;&amp;nbsp;分桶（第&amp;nbsp;10&amp;nbsp;篇讲过为什么必须分桶：
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;平台的限流维度是&amp;nbsp;接口&amp;nbsp;×&amp;nbsp;应用，不是全局）&amp;quot;&amp;quot;&amp;quot;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;__init__(self):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._buckets:&amp;nbsp;dict[str,&amp;nbsp;tuple[float,&amp;nbsp;float]]&amp;nbsp;=&amp;nbsp;{}&amp;nbsp;&amp;nbsp;#&amp;nbsp;key&amp;nbsp;-&amp;gt;&amp;nbsp;(tokens,&amp;nbsp;last)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._lock&amp;nbsp;=&amp;nbsp;threading.Lock()

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;acquire(self,&amp;nbsp;key:&amp;nbsp;str,&amp;nbsp;rate:&amp;nbsp;float,&amp;nbsp;burst:&amp;nbsp;int&amp;nbsp;|&amp;nbsp;None&amp;nbsp;=&amp;nbsp;None):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;burst&amp;nbsp;=&amp;nbsp;burst&amp;nbsp;or&amp;nbsp;max(2,&amp;nbsp;int(rate&amp;nbsp;*&amp;nbsp;2))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;while&amp;nbsp;True:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;with&amp;nbsp;self._lock:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;now&amp;nbsp;=&amp;nbsp;time.monotonic()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;tokens,&amp;nbsp;last&amp;nbsp;=&amp;nbsp;self._buckets.get(key,&amp;nbsp;(float(burst),&amp;nbsp;now))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;tokens&amp;nbsp;=&amp;nbsp;min(float(burst),&amp;nbsp;tokens&amp;nbsp;+&amp;nbsp;(now&amp;nbsp;-&amp;nbsp;last)&amp;nbsp;*&amp;nbsp;rate)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;tokens&amp;nbsp;&amp;gt;=&amp;nbsp;1:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._buckets[key]&amp;nbsp;=&amp;nbsp;(tokens&amp;nbsp;-&amp;nbsp;1,&amp;nbsp;now)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;wait&amp;nbsp;=&amp;nbsp;(1&amp;nbsp;-&amp;nbsp;tokens)&amp;nbsp;/&amp;nbsp;rate
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;time.sleep(wait)
```

默认&amp;nbsp;`burst&amp;nbsp;=&amp;nbsp;rate&amp;nbsp;×&amp;nbsp;2`&amp;nbsp;是保守起点——第&amp;nbsp;10&amp;nbsp;篇里&amp;nbsp;`burst=500`&amp;nbsp;撞&amp;nbsp;429&amp;nbsp;的教训，单人版更要远离。你没有人手盯限流告警。

###&amp;nbsp;3.3&amp;nbsp;存储：SQLite&amp;nbsp;+&amp;nbsp;WAL&amp;nbsp;+&amp;nbsp;重放表

整个单人版的灵魂在&amp;nbsp;`replay`&amp;nbsp;表。**它替代的是团队版的值班制度**：

```python
#&amp;nbsp;solo/store.py
import&amp;nbsp;hashlib
import&amp;nbsp;json
import&amp;nbsp;time
import&amp;nbsp;sqlite3

SCHEMA&amp;nbsp;=&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
pragma&amp;nbsp;journal_mode&amp;nbsp;=&amp;nbsp;wal;&amp;nbsp;&amp;nbsp;--&amp;nbsp;读写不互斥，单人版最关键的一行配置

create&amp;nbsp;table&amp;nbsp;if&amp;nbsp;not&amp;nbsp;exists&amp;nbsp;items(
&amp;nbsp;&amp;nbsp;platform&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;text&amp;nbsp;not&amp;nbsp;null,
&amp;nbsp;&amp;nbsp;item_id&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;text&amp;nbsp;not&amp;nbsp;null,
&amp;nbsp;&amp;nbsp;fingerprint&amp;nbsp;text&amp;nbsp;not&amp;nbsp;null,
&amp;nbsp;&amp;nbsp;payload&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;text&amp;nbsp;not&amp;nbsp;null,
&amp;nbsp;&amp;nbsp;updated_at&amp;nbsp;&amp;nbsp;integer&amp;nbsp;not&amp;nbsp;null,
&amp;nbsp;&amp;nbsp;primary&amp;nbsp;key(platform,&amp;nbsp;item_id)
);

create&amp;nbsp;table&amp;nbsp;if&amp;nbsp;not&amp;nbsp;exists&amp;nbsp;replay(
&amp;nbsp;&amp;nbsp;id&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;integer&amp;nbsp;primary&amp;nbsp;key&amp;nbsp;autoincrement,
&amp;nbsp;&amp;nbsp;platform&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;text&amp;nbsp;not&amp;nbsp;null,
&amp;nbsp;&amp;nbsp;kind&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;text&amp;nbsp;not&amp;nbsp;null,
&amp;nbsp;&amp;nbsp;params&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;text&amp;nbsp;not&amp;nbsp;null,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;--&amp;nbsp;json
&amp;nbsp;&amp;nbsp;next_try_at&amp;nbsp;integer&amp;nbsp;not&amp;nbsp;null,
&amp;nbsp;&amp;nbsp;tries&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;integer&amp;nbsp;not&amp;nbsp;null&amp;nbsp;default&amp;nbsp;0,
&amp;nbsp;&amp;nbsp;last_error&amp;nbsp;&amp;nbsp;text
);
&amp;quot;&amp;quot;&amp;quot;


def&amp;nbsp;open_db(path:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;sqlite3.Connection:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db&amp;nbsp;=&amp;nbsp;sqlite3.connect(path)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.executescript(SCHEMA)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;db


def&amp;nbsp;fingerprint(payload:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;指纹比对（第&amp;nbsp;1&amp;nbsp;篇的思路）：变了才写库，省掉&amp;nbsp;90%&amp;nbsp;的无效写&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;keys&amp;nbsp;=&amp;nbsp;(&amp;quot;price&amp;quot;,&amp;nbsp;&amp;quot;stock&amp;quot;,&amp;nbsp;&amp;quot;title&amp;quot;,&amp;nbsp;&amp;quot;status&amp;quot;)&amp;nbsp;&amp;nbsp;#&amp;nbsp;只盯你关心的字段
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raw&amp;nbsp;=&amp;nbsp;json.dumps({k:&amp;nbsp;payload.get(k)&amp;nbsp;for&amp;nbsp;k&amp;nbsp;in&amp;nbsp;keys},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ensure_ascii=False,&amp;nbsp;sort_keys=True)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;hashlib.sha1(raw.encode()).hexdigest()

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
def&amp;nbsp;upsert_if_changed(db,&amp;nbsp;platform,&amp;nbsp;item_id,&amp;nbsp;payload)&amp;nbsp;-&amp;gt;&amp;nbsp;bool:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;fp&amp;nbsp;=&amp;nbsp;fingerprint(payload)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;row&amp;nbsp;=&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;select&amp;nbsp;fingerprint&amp;nbsp;from&amp;nbsp;items&amp;nbsp;where&amp;nbsp;platform=?&amp;nbsp;and&amp;nbsp;item_id=?&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(platform,&amp;nbsp;str(item_id))).fetchone()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;row&amp;nbsp;and&amp;nbsp;row[0]&amp;nbsp;==&amp;nbsp;fp:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;False
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;insert&amp;nbsp;into&amp;nbsp;items(platform,item_id,fingerprint,payload,updated_at)&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;values(?,?,?,?,?)&amp;nbsp;on&amp;nbsp;conflict(platform,item_id)&amp;nbsp;do&amp;nbsp;update&amp;nbsp;set&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;fingerprint=excluded.fingerprint,&amp;nbsp;payload=excluded.payload,&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;updated_at=excluded.updated_at&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(platform,&amp;nbsp;str(item_id),&amp;nbsp;fp,&amp;nbsp;json.dumps(payload,&amp;nbsp;ensure_ascii=False),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;int(time.time())))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;True


def&amp;nbsp;add_replay(db,&amp;nbsp;platform,&amp;nbsp;kind,&amp;nbsp;params,&amp;nbsp;last_error:&amp;nbsp;str):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;失败的拉取进重放表，指数退避：1h&amp;nbsp;→&amp;nbsp;2h&amp;nbsp;→&amp;nbsp;4h&amp;nbsp;→&amp;nbsp;封顶&amp;nbsp;12h。
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;已有同任务行则原地更新，别插入新行——重试路径会反复走到这里&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;p&amp;nbsp;=&amp;nbsp;json.dumps(params,&amp;nbsp;sort_keys=True)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;row&amp;nbsp;=&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;select&amp;nbsp;tries&amp;nbsp;from&amp;nbsp;replay&amp;nbsp;where&amp;nbsp;platform=?&amp;nbsp;and&amp;nbsp;kind=?&amp;nbsp;and&amp;nbsp;params=?&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(platform,&amp;nbsp;kind,&amp;nbsp;p)).fetchone()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;tries&amp;nbsp;=&amp;nbsp;(row[0]&amp;nbsp;if&amp;nbsp;row&amp;nbsp;else&amp;nbsp;0)&amp;nbsp;+&amp;nbsp;1
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;delay&amp;nbsp;=&amp;nbsp;min(3600&amp;nbsp;*&amp;nbsp;2&amp;nbsp;**&amp;nbsp;(tries&amp;nbsp;-&amp;nbsp;1),&amp;nbsp;3600&amp;nbsp;*&amp;nbsp;12)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;row:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;update&amp;nbsp;replay&amp;nbsp;set&amp;nbsp;next_try_at=?,&amp;nbsp;tries=?,&amp;nbsp;last_error=?&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;where&amp;nbsp;platform=?&amp;nbsp;and&amp;nbsp;kind=?&amp;nbsp;and&amp;nbsp;params=?&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(int(time.time())&amp;nbsp;+&amp;nbsp;delay,&amp;nbsp;tries,&amp;nbsp;last_error[:300],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;platform,&amp;nbsp;kind,&amp;nbsp;p))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;else:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;insert&amp;nbsp;into&amp;nbsp;replay(platform,kind,params,next_try_at,tries,last_error)&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;values(?,?,?,?,?,?)&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(platform,&amp;nbsp;kind,&amp;nbsp;p,&amp;nbsp;int(time.time())&amp;nbsp;+&amp;nbsp;delay,&amp;nbsp;1,&amp;nbsp;last_error[:300]))


def&amp;nbsp;due_replays(db,&amp;nbsp;limit=50):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;select&amp;nbsp;id,&amp;nbsp;platform,&amp;nbsp;kind,&amp;nbsp;params,&amp;nbsp;tries&amp;nbsp;from&amp;nbsp;replay&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;where&amp;nbsp;next_try_at&amp;nbsp;&amp;lt;=&amp;nbsp;?&amp;nbsp;order&amp;nbsp;by&amp;nbsp;next_try_at&amp;nbsp;limit&amp;nbsp;?&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(int(time.time()),&amp;nbsp;limit)).fetchall()


def&amp;nbsp;done_replay(db,&amp;nbsp;replay_id:&amp;nbsp;int):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.execute(&amp;quot;delete&amp;nbsp;from&amp;nbsp;replay&amp;nbsp;where&amp;nbsp;id=?&amp;quot;,&amp;nbsp;(replay_id,))
```

为什么要重放表，而不是「失败就重试三次」？因为**你要防的不是网络抖动，是你自己**。某天你改服务器配置把服务弄挂了，两天后才想起来——这两天拉的增量全丢了，重放表会把它们补回来，不需要你做任何事。团队版靠值班兜底，单人版靠表结构兜底。

另外，`updated_at`&amp;nbsp;加时间戳还有个隐藏用途：摘要邮件要报「最老的数据是多久之前的」，这个数字比「跑没跑成功」更能说明系统是不是悄悄停了。

###&amp;nbsp;3.4&amp;nbsp;主循环：每小时一轮

```python
#&amp;nbsp;solo/main.py
&amp;quot;&amp;quot;&amp;quot;cron:&amp;nbsp;0&amp;nbsp;*&amp;nbsp;*&amp;nbsp;*&amp;nbsp;*&amp;nbsp;cd&amp;nbsp;/opt/solo&amp;nbsp;&amp;amp;&amp;amp;&amp;nbsp;/usr/bin/python3&amp;nbsp;main.py&amp;nbsp;&amp;gt;&amp;gt;&amp;nbsp;run.log&amp;nbsp;2&amp;gt;&amp;amp;1&amp;quot;&amp;quot;&amp;quot;
import&amp;nbsp;asyncio
import&amp;nbsp;json
import&amp;nbsp;sys
import&amp;nbsp;time

import&amp;nbsp;httpx

from&amp;nbsp;gate&amp;nbsp;import&amp;nbsp;Gate
from&amp;nbsp;store&amp;nbsp;import&amp;nbsp;open_db,&amp;nbsp;upsert_if_changed,&amp;nbsp;add_replay,&amp;nbsp;due_replays,&amp;nbsp;done_replay

WINDOW_MIN&amp;nbsp;=&amp;nbsp;10&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;每轮往前多看&amp;nbsp;10&amp;nbsp;分钟，重叠窗口防边界漏单（第&amp;nbsp;3&amp;nbsp;篇的思路）
RATE&amp;nbsp;=&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;taobao&amp;quot;:&amp;nbsp;&amp;nbsp;{&amp;quot;item_list&amp;quot;:&amp;nbsp;5.0},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;alibaba&amp;quot;:&amp;nbsp;{&amp;quot;item_list&amp;quot;:&amp;nbsp;3.0},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;jd&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;item_list&amp;quot;:&amp;nbsp;2.0},
}


async&amp;nbsp;def&amp;nbsp;fetch_page(client,&amp;nbsp;platform,&amp;nbsp;updated_start)&amp;nbsp;-&amp;gt;&amp;nbsp;list[dict]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;各平台的增量列表接口。示例以淘宝为例，其余平台换参数即可&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r&amp;nbsp;=&amp;nbsp;await&amp;nbsp;client.get(f&amp;quot;/v1/{platform}/item_list&amp;quot;,&amp;nbsp;params={
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;updated_start&amp;quot;:&amp;nbsp;updated_start,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;page_size&amp;quot;:&amp;nbsp;100,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;})
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r.raise_for_status()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;r.json().get(&amp;quot;items&amp;quot;,&amp;nbsp;[])

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
async&amp;nbsp;def&amp;nbsp;sync_platform(platform:&amp;nbsp;str,&amp;nbsp;since:&amp;nbsp;int,&amp;nbsp;gate:&amp;nbsp;Gate,&amp;nbsp;db)&amp;nbsp;-&amp;gt;&amp;nbsp;tuple[int,&amp;nbsp;int]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ok,&amp;nbsp;fail&amp;nbsp;=&amp;nbsp;0,&amp;nbsp;0
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;updated_start&amp;nbsp;=&amp;nbsp;time.strftime(&amp;quot;%Y-%m-%d&amp;nbsp;%H:%M:%S&amp;quot;,&amp;nbsp;time.localtime(since))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;httpx.AsyncClient(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;base_url=&amp;quot;https://api.example.com&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;headers={&amp;quot;X-App-Key&amp;quot;:&amp;nbsp;platform},&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;按你的网关实际方式传
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;timeout=10)&amp;nbsp;as&amp;nbsp;client:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;items&amp;nbsp;=&amp;nbsp;await&amp;nbsp;fetch_page(client,&amp;nbsp;platform,&amp;nbsp;updated_start)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;it&amp;nbsp;in&amp;nbsp;items:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;gate.acquire(f&amp;quot;{platform}:item_list&amp;quot;,&amp;nbsp;RATE[platform][&amp;quot;item_list&amp;quot;])
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;upsert_if_changed(db,&amp;nbsp;platform,&amp;nbsp;it[&amp;quot;item_id&amp;quot;],&amp;nbsp;it):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ok&amp;nbsp;+=&amp;nbsp;1
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.commit()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;except&amp;nbsp;Exception&amp;nbsp;as&amp;nbsp;e:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;fail&amp;nbsp;+=&amp;nbsp;1
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;add_replay(db,&amp;nbsp;platform,&amp;nbsp;&amp;quot;item_list&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;updated_start&amp;quot;:&amp;nbsp;updated_start},&amp;nbsp;repr(e))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.commit()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;ok,&amp;nbsp;fail


async&amp;nbsp;def&amp;nbsp;replay_due(db):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;启动先还账：上次没跑完的先补&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;rid,&amp;nbsp;platform,&amp;nbsp;kind,&amp;nbsp;params,&amp;nbsp;tries&amp;nbsp;in&amp;nbsp;due_replays(db):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;p&amp;nbsp;=&amp;nbsp;json.loads(params)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;httpx.AsyncClient(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;base_url=&amp;quot;https://api.example.com&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;headers={&amp;quot;X-App-Key&amp;quot;:&amp;nbsp;platform},&amp;nbsp;timeout=10)&amp;nbsp;as&amp;nbsp;client:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;items&amp;nbsp;=&amp;nbsp;await&amp;nbsp;fetch_page(client,&amp;nbsp;platform,&amp;nbsp;p[&amp;quot;updated_start&amp;quot;])
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;it&amp;nbsp;in&amp;nbsp;items:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;upsert_if_changed(db,&amp;nbsp;platform,&amp;nbsp;it[&amp;quot;item_id&amp;quot;],&amp;nbsp;it)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;done_replay(db,&amp;nbsp;rid)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.commit()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;except&amp;nbsp;Exception&amp;nbsp;as&amp;nbsp;e:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;tries&amp;nbsp;会&amp;nbsp;+1，退避继续；退到&amp;nbsp;12h&amp;nbsp;还不行的，摘要邮件会报「卡住的重放」
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;add_replay(db,&amp;nbsp;platform,&amp;nbsp;kind,&amp;nbsp;p,&amp;nbsp;repr(e))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db.commit()


async&amp;nbsp;def&amp;nbsp;main():
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db&amp;nbsp;=&amp;nbsp;open_db(&amp;quot;solo.db&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;gate&amp;nbsp;=&amp;nbsp;Gate()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;replay_due(db)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;since&amp;nbsp;=&amp;nbsp;int(time.time())&amp;nbsp;-&amp;nbsp;3600&amp;nbsp;-&amp;nbsp;WINDOW_MIN&amp;nbsp;*&amp;nbsp;60
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;stats&amp;nbsp;=&amp;nbsp;{}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;platform&amp;nbsp;in&amp;nbsp;(&amp;quot;taobao&amp;quot;,&amp;nbsp;&amp;quot;alibaba&amp;quot;,&amp;nbsp;&amp;quot;jd&amp;quot;):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ok,&amp;nbsp;fail&amp;nbsp;=&amp;nbsp;await&amp;nbsp;sync_platform(platform,&amp;nbsp;since,&amp;nbsp;gate,&amp;nbsp;db)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;stats[platform]&amp;nbsp;=&amp;nbsp;(ok,&amp;nbsp;fail)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;结果写文件，digest.py&amp;nbsp;读它（比塞邮件逻辑进主循环干净）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;json.dump({&amp;quot;ts&amp;quot;:&amp;nbsp;int(time.time()),&amp;nbsp;&amp;quot;stats&amp;quot;:&amp;nbsp;stats},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;open(&amp;quot;last_run.json&amp;quot;,&amp;nbsp;&amp;quot;w&amp;quot;))


if&amp;nbsp;__name__&amp;nbsp;==&amp;nbsp;&amp;quot;__main__&amp;quot;:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;asyncio.run(main())
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;except&amp;nbsp;Exception:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;cron&amp;nbsp;环境里&amp;nbsp;print&amp;nbsp;没人看，异常落日志，明天摘要邮件会报「今天没跑」
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;import&amp;nbsp;traceback
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;traceback.print_exc(file=open(&amp;quot;run.log&amp;quot;,&amp;nbsp;&amp;quot;a&amp;quot;))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sys.exit(1)
```


几个刻意的简化，别当成偷懒：

-&amp;nbsp;**不做并发抓取**（第&amp;nbsp;7&amp;nbsp;篇那套&amp;nbsp;20&amp;nbsp;路并发）。单平台每小时一次增量，量级撑得起串行。等你真的慢了再优化，那时你已经知道瓶颈在哪了。
-&amp;nbsp;**不做配置文件**。三个平台的参数直接写在常量里。独立开发者的项目，YAML&amp;nbsp;配置层多数是自我安慰。
-&amp;nbsp;**cron&amp;nbsp;而不是常驻进程**。进程挂了&amp;nbsp;cron&amp;nbsp;下个小时自己会拉起来——这是免费的兜底，比&amp;nbsp;systemd&amp;nbsp;的&amp;nbsp;`Restart=always`&amp;nbsp;心智负担更小（两个都上也行）。

###&amp;nbsp;3.5&amp;nbsp;每日摘要：单人版的「监控」

```python
#&amp;nbsp;solo/digest.py
&amp;quot;&amp;quot;&amp;quot;cron:&amp;nbsp;0&amp;nbsp;21&amp;nbsp;*&amp;nbsp;*&amp;nbsp;*&amp;nbsp;python3&amp;nbsp;digest.py
每天一封邮件。内容只有四个数字，30&amp;nbsp;秒看完。&amp;quot;&amp;quot;&amp;quot;
import&amp;nbsp;json
import&amp;nbsp;sqlite3
import&amp;nbsp;smtplib
import&amp;nbsp;time
from&amp;nbsp;email.mime.text&amp;nbsp;import&amp;nbsp;MIMEText
from&amp;nbsp;datetime&amp;nbsp;import&amp;nbsp;datetime,&amp;nbsp;timedelta,&amp;nbsp;timezone

CN&amp;nbsp;=&amp;nbsp;timezone(timedelta(hours=8))


def&amp;nbsp;build(db)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;run&amp;nbsp;=&amp;nbsp;json.load(open(&amp;quot;last_run.json&amp;quot;))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;age&amp;nbsp;=&amp;nbsp;datetime.now(CN)&amp;nbsp;-&amp;nbsp;datetime.fromtimestamp(run[&amp;quot;ts&amp;quot;],&amp;nbsp;CN)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;oldest&amp;nbsp;=&amp;nbsp;db.execute(&amp;quot;select&amp;nbsp;min(updated_at)&amp;nbsp;from&amp;nbsp;items&amp;quot;).fetchone()[0]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;oldest_days&amp;nbsp;=&amp;nbsp;(time.time()&amp;nbsp;-&amp;nbsp;oldest)&amp;nbsp;/&amp;nbsp;86400&amp;nbsp;if&amp;nbsp;oldest&amp;nbsp;else&amp;nbsp;-1
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;stuck&amp;nbsp;=&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;select&amp;nbsp;count(*),&amp;nbsp;max(tries)&amp;nbsp;from&amp;nbsp;replay&amp;nbsp;where&amp;nbsp;tries&amp;nbsp;&amp;gt;=&amp;nbsp;4&amp;quot;).fetchone()

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines&amp;nbsp;=&amp;nbsp;[
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;昨日变更:&amp;nbsp;&amp;quot;&amp;nbsp;+&amp;nbsp;&amp;quot;,&amp;nbsp;&amp;quot;.join(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;{p}&amp;nbsp;{ok}条&amp;quot;&amp;nbsp;for&amp;nbsp;p,&amp;nbsp;(ok,&amp;nbsp;f)&amp;nbsp;in&amp;nbsp;run[&amp;quot;stats&amp;quot;].items()),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;最后成功时间:&amp;nbsp;{age.total_seconds()/3600:.1f}&amp;nbsp;小时前&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;+&amp;nbsp;(&amp;quot;&amp;nbsp;&amp;nbsp;←&amp;nbsp;超过&amp;nbsp;2&amp;nbsp;小时，检查服务&amp;quot;&amp;nbsp;if&amp;nbsp;age.total_seconds()&amp;nbsp;&amp;gt;&amp;nbsp;7200&amp;nbsp;else&amp;nbsp;&amp;quot;&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;最老数据:&amp;nbsp;{oldest_days:.1f}&amp;nbsp;天前&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;卡住的重放:&amp;nbsp;{stuck[0]}&amp;nbsp;条（最多重试&amp;nbsp;{stuck[1]&amp;nbsp;or&amp;nbsp;0}&amp;nbsp;次）&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;&amp;quot;\n&amp;quot;.join(lines)


if&amp;nbsp;__name__&amp;nbsp;==&amp;nbsp;&amp;quot;__main__&amp;quot;:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;db&amp;nbsp;=&amp;nbsp;sqlite3.connect(&amp;quot;solo.db&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;body&amp;nbsp;=&amp;nbsp;build(db)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;msg&amp;nbsp;=&amp;nbsp;MIMEText(body,&amp;nbsp;&amp;quot;plain&amp;quot;,&amp;nbsp;&amp;quot;utf-8&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;msg[&amp;quot;Subject&amp;quot;]&amp;nbsp;=&amp;nbsp;f&amp;quot;[solo-sync]&amp;nbsp;{datetime.now(CN):%m-%d}&amp;nbsp;摘要&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;msg[&amp;quot;From&amp;quot;]&amp;nbsp;=&amp;nbsp;&amp;quot;me@example.com&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;msg[&amp;quot;To&amp;quot;]&amp;nbsp;=&amp;nbsp;&amp;quot;me@example.com&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;with&amp;nbsp;smtplib.SMTP_SSL(&amp;quot;smtp.example.com&amp;quot;,&amp;nbsp;465)&amp;nbsp;as&amp;nbsp;s:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;s.login(&amp;quot;me@example.com&amp;quot;,&amp;nbsp;&amp;quot;your_password&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;s.send_message(msg)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(body)
```

四个数字各管一类静默故障：

-&amp;nbsp;**最后成功时间&amp;nbsp;&amp;gt;&amp;nbsp;2&amp;nbsp;小时**&amp;nbsp;→&amp;nbsp;服务挂了（cron&amp;nbsp;没跑、密钥过期、服务器欠费）
-&amp;nbsp;**最老数据&amp;nbsp;&amp;gt;&amp;nbsp;3&amp;nbsp;天**&amp;nbsp;→&amp;nbsp;某个平台的增量在悄悄失败，但整体流程还在「成功」
-&amp;nbsp;**卡住的重放&amp;nbsp;&amp;gt;&amp;nbsp;0**&amp;nbsp;→&amp;nbsp;有接口连续&amp;nbsp;4&amp;nbsp;次以上失败，需要人看一眼了
-&amp;nbsp;**昨日变更数**&amp;nbsp;→&amp;nbsp;和你对业务的预期对不上时（比如明明在搞活动，变更却很少），说明数据链路可能有洞

这就是单人版的监控哲学：**不做实时告警，做每天&amp;nbsp;30&amp;nbsp;秒的例行检查**。告警系统你配完头一周会认真看，第三周开始划掉通知，第三个月忘了密码。一封邮件你至少会扫一眼标题。

##&amp;nbsp;四、部署：一小时的收尾，别跳过

代码写完只算一半。独立开发者的项目死于「在我电脑上是好的」，这四步一小时搞定：

```bash
#&amp;nbsp;1.&amp;nbsp;systemd&amp;nbsp;用户级单元（比系统级安全，不用&amp;nbsp;root）
mkdir&amp;nbsp;-p&amp;nbsp;~/.config/systemd/user
cat&amp;nbsp;&amp;gt;&amp;nbsp;~/.config/systemd/user/solo-digest.timer&amp;nbsp;&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;
[Unit]
Description=solo&amp;nbsp;digest&amp;nbsp;daily
[Timer]
OnCalendar=*-*-*&amp;nbsp;21:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl&amp;nbsp;--user&amp;nbsp;enable&amp;nbsp;--now&amp;nbsp;solo-digest.timer

#&amp;nbsp;2.&amp;nbsp;数据库备份：一天一次，拷文件就行（SQLite&amp;nbsp;的好处）
echo&amp;nbsp;&amp;#39;0&amp;nbsp;22&amp;nbsp;*&amp;nbsp;*&amp;nbsp;*&amp;nbsp;cp&amp;nbsp;/opt/solo/solo.db&amp;nbsp;/opt/solo/backup/solo-$(date&amp;nbsp;+\%m\%d).db&amp;#39;&amp;nbsp;|&amp;nbsp;crontab&amp;nbsp;-

#&amp;nbsp;3.&amp;nbsp;数据库目录权限：600，里面有&amp;nbsp;token
chmod&amp;nbsp;600&amp;nbsp;/opt/solo/solo.db

#&amp;nbsp;4.&amp;nbsp;密钥不进代码：环境变量或独立文件，git&amp;nbsp;里只有占位符
```

第&amp;nbsp;3&amp;nbsp;步单独说一下：`solo.db`&amp;nbsp;里有&amp;nbsp;`tokens`&amp;nbsp;表，**泄露&amp;nbsp;=&amp;nbsp;三家店铺的授权全丢**。单人项目最容易死在「数据库和代码一起扔进&amp;nbsp;git&amp;nbsp;仓库」。

##&amp;nbsp;五、放弃线：什么时候该停手

这篇如果只写「怎么接」，不说「什么时候别接」，就不诚实了。三个信号，碰到任何一个就该停：

**①&amp;nbsp;收入覆盖不了维护时间。**&amp;nbsp;用第一节的账算：开发&amp;nbsp;40&amp;nbsp;小时&amp;nbsp;+&amp;nbsp;维护&amp;nbsp;12~24&amp;nbsp;小时/年。按独立开发者的时薪折成钱，再加服务器、再加你的注意力成本。**如果你的产品月收入低于这个数的一半，直接买现成的接口服务**——不是你不行，是这笔账本来就不成立。

**②&amp;nbsp;你开始需要第二个消费方。**&amp;nbsp;数据一旦要喂两个产品（比如一个网页&amp;nbsp;+&amp;nbsp;一个小程序），归一化模型、统一接口层这些东西就从负债变资产了——那是第&amp;nbsp;2&amp;nbsp;篇和第&amp;nbsp;8&amp;nbsp;篇的领域，说明你该按团队版重做了。

**③&amp;nbsp;权限天花板到了。**&amp;nbsp;个人主体能申请的权限包是有限的（第&amp;nbsp;17&amp;nbsp;篇讲过应用类型决定上限）。很多商品、订单类权限对企业主体都要审，个人主体经常直接不可见。**这一条不是技术问题，是资质问题，重写多少代码都解决不了**——也是独立开发者最终走向「买服务」或「注册公司」最常见的分叉点。

三平台接口的门槛里，最硬的一直不是代码。

---

二十篇到这里，正好把「一个人」这个视角补上了。前面十九篇默认读者背后有团队，这篇把约束换成「只有你」，砍法就完全不同了：**团队版优化的是吞吐和稳定，单人版优化的是维护意愿**。你接下来要维护的不是这套代码，是你自己看它的兴趣。&lt;/pre&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;</description><pubDate>Tue, 03 Nov 2026 11:05:47 +0800</pubDate></item><item><title> 免费试用 7 天：三平台接口文档 + 示例代码全给（附 Python 源码）</title><link>https://www.alexob.com/?id=596</link><description>&lt;pre class=&quot;prism-highlight prism-language-python&quot;&gt;#&amp;nbsp;免费试用&amp;nbsp;7&amp;nbsp;天：三平台接口文档&amp;nbsp;+&amp;nbsp;示例代码全给（附&amp;nbsp;Python&amp;nbsp;源码）

&amp;gt;&amp;nbsp;先说清楚一件事：**7&amp;nbsp;天试用最容易白费**。
&amp;gt;
&amp;gt;&amp;nbsp;我们后台看得到试用账号的调用曲线，一个很规律的现象是——第&amp;nbsp;1&amp;nbsp;天调几十次，把「能不能通」验证完了，然后就没有然后了；第&amp;nbsp;6&amp;nbsp;天想起来还剩一天，慌忙压一下并发，第&amp;nbsp;7&amp;nbsp;天到期，什么结论也没拿到。
&amp;gt;
&amp;gt;&amp;nbsp;所以这篇不讲「我们多好用」，讲**这&amp;nbsp;7&amp;nbsp;天该怎么用**：先验什么、后验什么、每一验用什么代码、什么数字算过、什么数字算不过。文末的四个脚本你可以直接拿去跑，跑完会生成一份决策报告——**那份报告才是试用的产出，不是「跑通了」这三个字**。
&amp;gt;
&amp;gt;&amp;nbsp;顺便说，这套脚本对任何一家接口服务商都适用。你要是正在对比几家，拿它挨个跑一遍，比听销售讲一小时有用。

##&amp;nbsp;一、先划清&amp;nbsp;7&amp;nbsp;天能做什么、不能做什么

试用不是「免费版产品」，它的边界必须提前讲明，否则第&amp;nbsp;3&amp;nbsp;天你就会撞上：

|&amp;nbsp;能做&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;做不到&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;-----------------&amp;nbsp;|&amp;nbsp;-------------------------&amp;nbsp;|
|&amp;nbsp;调通全部核心接口，验证字段是否够用&amp;nbsp;|&amp;nbsp;全量数据迁移（额度不够，也不该在试用期做）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;抽样验证数据准确性（对商家后台）&amp;nbsp;&amp;nbsp;|&amp;nbsp;长期稳定性验证（7&amp;nbsp;天看不出季度级故障）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;测出限流拐点和延迟分布&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;拿到和正式开通完全一致的权限（试用权限通常是子集）&amp;nbsp;|
|&amp;nbsp;走通一个真实业务场景的端到端流程&amp;nbsp;&amp;nbsp;|&amp;nbsp;把试用期的数据当生产依赖（到期即停，别埋雷）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

一条经验：**试用的目标是拿到「买不买」的决策依据，不是「先跑起来再说」**。这两件事的顺序反了，7&amp;nbsp;天一定白费。

##&amp;nbsp;二、文档：三平台接口文档，先看这四节

接口文档动辄几百页、上千个接口，全看完是浪费时间。真正决定你能不能落地的只有四节，按这个顺序看：

|&amp;nbsp;顺序&amp;nbsp;|&amp;nbsp;看什么&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;为什么先看这个&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;--&amp;nbsp;|&amp;nbsp;---------------&amp;nbsp;|&amp;nbsp;----------------------------------------------&amp;nbsp;|
|&amp;nbsp;①&amp;nbsp;&amp;nbsp;|&amp;nbsp;**接口列表&amp;nbsp;+&amp;nbsp;权限归属**&amp;nbsp;|&amp;nbsp;先确认「你要的接口存不存在、在哪个权限包里」。这一步能筛掉一半方案&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;②&amp;nbsp;&amp;nbsp;|&amp;nbsp;**请求参数与返回字段**&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;重点看返回字段有没有&amp;nbsp;`null`&amp;nbsp;之外的「缺失」——缺&amp;nbsp;key&amp;nbsp;通常是权限问题，不是没数据&amp;nbsp;|
|&amp;nbsp;③&amp;nbsp;&amp;nbsp;|&amp;nbsp;**错误码表**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;只看前&amp;nbsp;15&amp;nbsp;个高频码。剩下的你一辈子不会遇到&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;④&amp;nbsp;&amp;nbsp;|&amp;nbsp;**限流与配额说明**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;很多文档只写「有频率限制」，具体数值要问。这一条决定你的调度策略&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

三个平台的文档风格差异很大，顺着看会踩坑：

|&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;淘宝开放平台&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;1688&amp;nbsp;开放平台&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;京东宙斯&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;----&amp;nbsp;|&amp;nbsp;------------------&amp;nbsp;|&amp;nbsp;-----------------&amp;nbsp;|&amp;nbsp;----------------------------&amp;nbsp;|
|&amp;nbsp;文档组织&amp;nbsp;|&amp;nbsp;按**权限包**分组，接口散在各包下&amp;nbsp;|&amp;nbsp;按**业务域**分组，中文描述更细&amp;nbsp;|&amp;nbsp;按**接口名**平铺，中文文档偏薄&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;参数命名&amp;nbsp;|&amp;nbsp;下划线（`num_iid`）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;下划线，字段偏业务语义&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;驼峰&amp;nbsp;+&amp;nbsp;`360buy_param_json`&amp;nbsp;包一层&amp;nbsp;|
|&amp;nbsp;返回结构&amp;nbsp;|&amp;nbsp;顶层直接给业务字段&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;与淘宝接近&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**按接口名再套一层**，key&amp;nbsp;写法还不统一&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;最该注意&amp;nbsp;|&amp;nbsp;沙箱常与线上不同步&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;部分接口只有中文文档，字段含义要试&amp;nbsp;|&amp;nbsp;中文序列化转义问题（见第&amp;nbsp;11&amp;nbsp;篇）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

我们把这些整理成了一份**接口对照索引**：三个平台的核心接口（商品、SKU、价格、库存、订单、物流，各平台&amp;nbsp;12&amp;nbsp;个左右）按「同一业务动作」横向对齐，左边是动作，右边是三平台的接口名和关键差异。你按业务动作查，不用记三套命名。文档和示例代码一起给，不用申请就能看。

##&amp;nbsp;三、示例代码：四个脚本，覆盖&amp;nbsp;7&amp;nbsp;天全部验证

代码是一套的：`client.py`&amp;nbsp;打底，另外四个脚本各管一件事。全部用&amp;nbsp;`httpx`&amp;nbsp;+&amp;nbsp;`asyncio`，`pip&amp;nbsp;install&amp;nbsp;httpx`&amp;nbsp;就能跑。

###&amp;nbsp;3.1&amp;nbsp;统一的试用客户端

三个平台签名逻辑不同（淘宝和京东是&amp;nbsp;MD5&amp;nbsp;包夹，1688&amp;nbsp;是&amp;nbsp;HMAC-SHA1&amp;nbsp;且&amp;nbsp;urlPath&amp;nbsp;参与签名），但试用期你不需要关心这些——网关做了归一，你只面对一套参数和一套返回。

```python
#&amp;nbsp;trial/client.py
from&amp;nbsp;__future__&amp;nbsp;import&amp;nbsp;annotations

import&amp;nbsp;asyncio
import&amp;nbsp;time
from&amp;nbsp;dataclasses&amp;nbsp;import&amp;nbsp;dataclass,&amp;nbsp;field
from&amp;nbsp;typing&amp;nbsp;import&amp;nbsp;Any

import&amp;nbsp;httpx


@dataclass(frozen=True)
class&amp;nbsp;TrialConfig:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;base_url:&amp;nbsp;str&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;试用网关地址
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;app_key:&amp;nbsp;str
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;app_secret:&amp;nbsp;str
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;daily_quota:&amp;nbsp;int&amp;nbsp;=&amp;nbsp;5000&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;试用额度，每天重置
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;timeout:&amp;nbsp;float&amp;nbsp;=&amp;nbsp;8.0


@dataclass
class&amp;nbsp;Result:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ok:&amp;nbsp;bool
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;data:&amp;nbsp;Any&amp;nbsp;=&amp;nbsp;None
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;code:&amp;nbsp;str&amp;nbsp;=&amp;nbsp;&amp;quot;&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;归一后的错误码，见下表
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;message:&amp;nbsp;str&amp;nbsp;=&amp;nbsp;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cost_ms:&amp;nbsp;int&amp;nbsp;=&amp;nbsp;0
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;request_id:&amp;nbsp;str&amp;nbsp;=&amp;nbsp;&amp;quot;&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;出问题时报给服务商用
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raw:&amp;nbsp;dict&amp;nbsp;=&amp;nbsp;field(default_factory=dict)

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
class&amp;nbsp;TrialClient:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;试用期最小客户端：只管调用、计时、错误归一，不含业务逻辑。&amp;quot;&amp;quot;&amp;quot;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;__init__(self,&amp;nbsp;cfg:&amp;nbsp;TrialConfig):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.cfg&amp;nbsp;=&amp;nbsp;cfg
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._client&amp;nbsp;=&amp;nbsp;httpx.AsyncClient(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;base_url=cfg.base_url,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;timeout=httpx.Timeout(cfg.timeout),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;limits=httpx.Limits(max_connections=32,&amp;nbsp;max_keepalive_connections=16),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.calls&amp;nbsp;=&amp;nbsp;0

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;__aenter__(self):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;self

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;__aexit__(self,&amp;nbsp;*exc):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;self._client.aclose()

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;get(self,&amp;nbsp;path:&amp;nbsp;str,&amp;nbsp;**params)&amp;nbsp;-&amp;gt;&amp;nbsp;Result:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.calls&amp;nbsp;+=&amp;nbsp;1
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;t0&amp;nbsp;=&amp;nbsp;time.monotonic()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r&amp;nbsp;=&amp;nbsp;await&amp;nbsp;self._client.get(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;path,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;params={&amp;quot;app_key&amp;quot;:&amp;nbsp;self.cfg.app_key,&amp;nbsp;**params},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;headers={&amp;quot;X-Trial-Secret&amp;quot;:&amp;nbsp;self.cfg.app_secret},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;except&amp;nbsp;httpx.TimeoutException:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Result(False,&amp;nbsp;code=&amp;quot;TIMEOUT&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;message=f&amp;quot;&amp;gt;{self.cfg.timeout}s&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cost_ms=int((time.monotonic()&amp;nbsp;-&amp;nbsp;t0)&amp;nbsp;*&amp;nbsp;1000))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;except&amp;nbsp;httpx.HTTPError&amp;nbsp;as&amp;nbsp;e:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Result(False,&amp;nbsp;code=&amp;quot;NETWORK&amp;quot;,&amp;nbsp;message=str(e)[:120],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cost_ms=int((time.monotonic()&amp;nbsp;-&amp;nbsp;t0)&amp;nbsp;*&amp;nbsp;1000))

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cost&amp;nbsp;=&amp;nbsp;int((time.monotonic()&amp;nbsp;-&amp;nbsp;t0)&amp;nbsp;*&amp;nbsp;1000)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;body&amp;nbsp;=&amp;nbsp;r.json()&amp;nbsp;if&amp;nbsp;r.headers.get(&amp;quot;content-type&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;).startswith(&amp;quot;application/json&amp;quot;)&amp;nbsp;else&amp;nbsp;{}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;关键：HTTP&amp;nbsp;200&amp;nbsp;不等于成功。三个平台都有「200&amp;nbsp;+&amp;nbsp;body&amp;nbsp;里报错」的返回
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;code&amp;nbsp;=&amp;nbsp;str(body.get(&amp;quot;error_code&amp;quot;)&amp;nbsp;or&amp;nbsp;body.get(&amp;quot;code&amp;quot;)&amp;nbsp;or&amp;nbsp;&amp;quot;&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;r.status_code&amp;nbsp;&amp;gt;=&amp;nbsp;400&amp;nbsp;or&amp;nbsp;(code&amp;nbsp;and&amp;nbsp;code&amp;nbsp;not&amp;nbsp;in&amp;nbsp;(&amp;quot;0&amp;quot;,&amp;nbsp;&amp;quot;200&amp;quot;,&amp;nbsp;&amp;quot;success&amp;quot;)):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Result(False,&amp;nbsp;code=normalize(code,&amp;nbsp;r.status_code),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;message=str(body.get(&amp;quot;error_msg&amp;quot;)&amp;nbsp;or&amp;nbsp;body.get(&amp;quot;message&amp;quot;)&amp;nbsp;or&amp;nbsp;&amp;quot;&amp;quot;)[:200],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cost_ms=cost,&amp;nbsp;request_id=r.headers.get(&amp;quot;x-request-id&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raw=body)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Result(True,&amp;nbsp;data=body.get(&amp;quot;data&amp;quot;,&amp;nbsp;body),&amp;nbsp;cost_ms=cost,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;request_id=r.headers.get(&amp;quot;x-request-id&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;),&amp;nbsp;raw=body)


ERROR_KIND&amp;nbsp;=&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;AUTH&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;401&amp;quot;,&amp;nbsp;&amp;quot;403&amp;quot;,&amp;nbsp;&amp;quot;TOKEN_EXPIRED&amp;quot;,&amp;nbsp;&amp;quot;SIGN_ERROR&amp;quot;,&amp;nbsp;&amp;quot;INVALID_SIGN&amp;quot;},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;PARAM&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;400&amp;quot;,&amp;nbsp;&amp;quot;PARAM_MISSING&amp;quot;,&amp;nbsp;&amp;quot;PARAM_INVALID&amp;quot;},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;LIMIT&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;429&amp;quot;,&amp;nbsp;&amp;quot;THROTTLED&amp;quot;,&amp;nbsp;&amp;quot;QUOTA_EXCEEDED&amp;quot;},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;PLATFORM&amp;quot;:{&amp;quot;500&amp;quot;,&amp;nbsp;&amp;quot;502&amp;quot;,&amp;nbsp;&amp;quot;503&amp;quot;,&amp;nbsp;&amp;quot;504&amp;quot;,&amp;nbsp;&amp;quot;PLATFORM_ERROR&amp;quot;},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;NOT_SUPPORT&amp;quot;:&amp;nbsp;{&amp;quot;404&amp;quot;,&amp;nbsp;&amp;quot;API_NOT_FOUND&amp;quot;,&amp;nbsp;&amp;quot;CAPABILITY_MISSING&amp;quot;},
}


def&amp;nbsp;normalize(code:&amp;nbsp;str,&amp;nbsp;status:&amp;nbsp;int)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raw&amp;nbsp;=&amp;nbsp;code&amp;nbsp;or&amp;nbsp;str(status)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;kind,&amp;nbsp;codes&amp;nbsp;in&amp;nbsp;ERROR_KIND.items():
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;raw&amp;nbsp;in&amp;nbsp;codes:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;kind
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;&amp;quot;UNKNOWN&amp;quot;
```

`normalize`&amp;nbsp;这一层看着简单，但它是整个验证的前提——**如果错误码不归一，你没法统计「这&amp;nbsp;7&amp;nbsp;天里失败都是什么原因」**，最后只能说「偶尔报错」，等于没验。

###&amp;nbsp;3.2&amp;nbsp;`quickstart.py`：5&amp;nbsp;分钟跑通，输出一张对齐表

第一个脚本的目的不是「能调通」，而是**让你在&amp;nbsp;5&amp;nbsp;分钟内看到三个平台的数据长什么样、差在哪**。这比逐页读文档快得多。

```python
#&amp;nbsp;trial/quickstart.py
import&amp;nbsp;asyncio

from&amp;nbsp;client&amp;nbsp;import&amp;nbsp;TrialClient,&amp;nbsp;TrialConfig

TARGETS&amp;nbsp;=&amp;nbsp;[
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;(平台,&amp;nbsp;商品/订单标识,&amp;nbsp;说明)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;taobao&amp;quot;,&amp;nbsp;&amp;quot;654321098765&amp;quot;,&amp;nbsp;&amp;quot;自家店在卖的&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;alibaba&amp;quot;,&amp;nbsp;&amp;quot;678901234567&amp;quot;,&amp;nbsp;&amp;quot;同款或近似同款&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;jd&amp;quot;,&amp;nbsp;&amp;quot;100012043978&amp;quot;,&amp;nbsp;&amp;quot;在售竞品&amp;quot;),
]

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
async&amp;nbsp;def&amp;nbsp;main(cfg:&amp;nbsp;TrialConfig):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;TrialClient(cfg)&amp;nbsp;as&amp;nbsp;c:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rows&amp;nbsp;=&amp;nbsp;[]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;platform,&amp;nbsp;item_id,&amp;nbsp;note&amp;nbsp;in&amp;nbsp;TARGETS:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;item&amp;nbsp;=&amp;nbsp;await&amp;nbsp;c.get(f&amp;quot;/v1/{platform}/item&amp;quot;,&amp;nbsp;item_id=item_id)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;not&amp;nbsp;item.ok:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rows.append([platform,&amp;nbsp;item_id,&amp;nbsp;f&amp;quot;失败:{item.code}&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;])
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;continue

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;d&amp;nbsp;=&amp;nbsp;item.data
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;skus&amp;nbsp;=&amp;nbsp;d.get(&amp;quot;skus&amp;quot;)&amp;nbsp;or&amp;nbsp;[]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;prices&amp;nbsp;=&amp;nbsp;[s.get(&amp;quot;price&amp;quot;)&amp;nbsp;for&amp;nbsp;s&amp;nbsp;in&amp;nbsp;skus&amp;nbsp;if&amp;nbsp;s.get(&amp;quot;price&amp;quot;)&amp;nbsp;is&amp;nbsp;not&amp;nbsp;None]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;stocks&amp;nbsp;=&amp;nbsp;[s.get(&amp;quot;stock&amp;quot;)&amp;nbsp;for&amp;nbsp;s&amp;nbsp;in&amp;nbsp;skus&amp;nbsp;if&amp;nbsp;s.get(&amp;quot;stock&amp;quot;)&amp;nbsp;is&amp;nbsp;not&amp;nbsp;None]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rows.append([
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;platform,&amp;nbsp;item_id,&amp;nbsp;str(d.get(&amp;quot;title&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;))[:24],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;{min(prices)&amp;nbsp;if&amp;nbsp;prices&amp;nbsp;else&amp;nbsp;d.get(&amp;#39;price&amp;#39;,&amp;nbsp;&amp;#39;-&amp;#39;)}&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;{sum(stocks)&amp;nbsp;if&amp;nbsp;stocks&amp;nbsp;else&amp;nbsp;&amp;#39;无SKU库存&amp;#39;}&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;{len(skus)}个SKU&amp;nbsp;/&amp;nbsp;{item.cost_ms}ms&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;])

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;{&amp;#39;平台&amp;#39;:&amp;lt;9}{&amp;#39;商品ID&amp;#39;:&amp;lt;15}{&amp;#39;标题&amp;#39;:&amp;lt;26}{&amp;#39;最低价&amp;#39;:&amp;lt;10}{&amp;#39;总库存&amp;#39;:&amp;lt;12}{&amp;#39;结构&amp;#39;}&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(&amp;quot;-&amp;quot;&amp;nbsp;*&amp;nbsp;88)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;r&amp;nbsp;in&amp;nbsp;rows:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;{r[0]:&amp;lt;9}{r[1]:&amp;lt;15}{r[2]:&amp;lt;26}{r[3]:&amp;lt;10}{r[4]:&amp;lt;12}{r[5]}&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;\n本次消耗&amp;nbsp;{c.calls}&amp;nbsp;次额度&amp;quot;)


if&amp;nbsp;__name__&amp;nbsp;==&amp;nbsp;&amp;quot;__main__&amp;quot;:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;asyncio.run(main(TrialConfig(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;base_url=&amp;quot;https://api.example.com&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;app_key=&amp;quot;trial_xxxxxxxx&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;app_secret=&amp;quot;trial_secret_xxxxxxxx&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;)))
```

跑完你会立刻发现三件事：1688&amp;nbsp;那个商品「总库存」是空的、京东的&amp;nbsp;SKU&amp;nbsp;结构和淘宝不一样、同一个商品三个平台的标题几乎无法直接比对。这三件事正是后面几天要处理的。

###&amp;nbsp;3.3&amp;nbsp;`smoke.py`：9&amp;nbsp;项冒烟，出一份可粘贴的报告

冒烟的意义不在于「今天通了」，在于**以后每天跑一次，能提前发现三类变化**：授权过期、平台改字段、额度规则调整。所以它必须输出结构化结果，不能只&amp;nbsp;`print(&amp;quot;ok&amp;quot;)`。

```python
#&amp;nbsp;trial/smoke.py
import&amp;nbsp;asyncio
import&amp;nbsp;json
from&amp;nbsp;dataclasses&amp;nbsp;import&amp;nbsp;dataclass,&amp;nbsp;asdict

from&amp;nbsp;client&amp;nbsp;import&amp;nbsp;TrialClient,&amp;nbsp;TrialConfig

PROBES&amp;nbsp;=&amp;nbsp;[
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;taobao&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;quot;item&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;item_id&amp;quot;:&amp;nbsp;&amp;quot;654321098765&amp;quot;},&amp;nbsp;[&amp;quot;title&amp;quot;,&amp;nbsp;&amp;quot;skus&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;taobao&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;quot;orders&amp;quot;,&amp;nbsp;&amp;nbsp;{&amp;quot;start&amp;quot;:&amp;nbsp;&amp;quot;2026-10-01&amp;quot;,&amp;nbsp;&amp;quot;end&amp;quot;:&amp;nbsp;&amp;quot;2026-10-07&amp;quot;},&amp;nbsp;[&amp;quot;orders&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;taobao&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;quot;logistics&amp;quot;,&amp;nbsp;{&amp;quot;tid&amp;quot;:&amp;nbsp;&amp;quot;1234567890123&amp;quot;},&amp;nbsp;[&amp;quot;status&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;alibaba&amp;quot;,&amp;nbsp;&amp;quot;item&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;item_id&amp;quot;:&amp;nbsp;&amp;quot;678901234567&amp;quot;},&amp;nbsp;[&amp;quot;title&amp;quot;,&amp;nbsp;&amp;quot;price_ranges&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;alibaba&amp;quot;,&amp;nbsp;&amp;quot;orders&amp;quot;,&amp;nbsp;&amp;nbsp;{&amp;quot;start&amp;quot;:&amp;nbsp;&amp;quot;2026-10-01&amp;quot;,&amp;nbsp;&amp;quot;end&amp;quot;:&amp;nbsp;&amp;quot;2026-10-07&amp;quot;},&amp;nbsp;[&amp;quot;orders&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;alibaba&amp;quot;,&amp;nbsp;&amp;quot;logistics&amp;quot;,&amp;nbsp;{&amp;quot;tid&amp;quot;:&amp;nbsp;&amp;quot;1234567890&amp;quot;},&amp;nbsp;[&amp;quot;status&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;jd&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;item&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;sku_id&amp;quot;:&amp;nbsp;&amp;quot;100012043978&amp;quot;},&amp;nbsp;[&amp;quot;name&amp;quot;,&amp;nbsp;&amp;quot;price&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;jd&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;orders&amp;quot;,&amp;nbsp;&amp;nbsp;{&amp;quot;start&amp;quot;:&amp;nbsp;&amp;quot;2026-10-01&amp;quot;,&amp;nbsp;&amp;quot;end&amp;quot;:&amp;nbsp;&amp;quot;2026-10-07&amp;quot;},&amp;nbsp;[&amp;quot;orders&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;jd&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;logistics&amp;quot;,&amp;nbsp;{&amp;quot;order_id&amp;quot;:&amp;nbsp;&amp;quot;23456789012&amp;quot;},&amp;nbsp;[&amp;quot;status&amp;quot;]),
]


@dataclass
class&amp;nbsp;Probe:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;platform:&amp;nbsp;str
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;resource:&amp;nbsp;str
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;status:&amp;nbsp;str&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;PASS&amp;nbsp;/&amp;nbsp;FAIL&amp;nbsp;/&amp;nbsp;EMPTY&amp;nbsp;/&amp;nbsp;SKIP
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;detail:&amp;nbsp;str
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cost_ms:&amp;nbsp;int


async&amp;nbsp;def&amp;nbsp;run(cfg:&amp;nbsp;TrialConfig)&amp;nbsp;-&amp;gt;&amp;nbsp;list[Probe]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;out&amp;nbsp;=&amp;nbsp;[]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;TrialClient(cfg)&amp;nbsp;as&amp;nbsp;c:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;platform,&amp;nbsp;resource,&amp;nbsp;params,&amp;nbsp;required&amp;nbsp;in&amp;nbsp;PROBES:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;res&amp;nbsp;=&amp;nbsp;await&amp;nbsp;c.get(f&amp;quot;/v1/{platform}/{resource}&amp;quot;,&amp;nbsp;**params)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;not&amp;nbsp;res.ok:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;out.append(Probe(platform,&amp;nbsp;resource,&amp;nbsp;&amp;quot;FAIL&amp;quot;,&amp;nbsp;res.code,&amp;nbsp;res.cost_ms))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;continue

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;空数组不等于成功：无数据、无权限、参数错都长这样
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;payload&amp;nbsp;=&amp;nbsp;res.data&amp;nbsp;if&amp;nbsp;isinstance(res.data,&amp;nbsp;dict)&amp;nbsp;else&amp;nbsp;{}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;missing&amp;nbsp;=&amp;nbsp;[k&amp;nbsp;for&amp;nbsp;k&amp;nbsp;in&amp;nbsp;required&amp;nbsp;if&amp;nbsp;k&amp;nbsp;not&amp;nbsp;in&amp;nbsp;payload]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;missing:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;out.append(Probe(platform,&amp;nbsp;resource,&amp;nbsp;&amp;quot;EMPTY&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;缺字段&amp;nbsp;{&amp;#39;,&amp;#39;.join(missing)}&amp;quot;,&amp;nbsp;res.cost_ms))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;elif&amp;nbsp;not&amp;nbsp;any(payload.get(k)&amp;nbsp;for&amp;nbsp;k&amp;nbsp;in&amp;nbsp;required):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;out.append(Probe(platform,&amp;nbsp;resource,&amp;nbsp;&amp;quot;EMPTY&amp;quot;,&amp;nbsp;&amp;quot;返回空&amp;quot;,&amp;nbsp;res.cost_ms))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;else:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;out.append(Probe(platform,&amp;nbsp;resource,&amp;nbsp;&amp;quot;PASS&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;,&amp;nbsp;res.cost_ms))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;out


def&amp;nbsp;report(probes:&amp;nbsp;list[Probe])&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines&amp;nbsp;=&amp;nbsp;[&amp;quot;|&amp;nbsp;平台&amp;nbsp;|&amp;nbsp;资源&amp;nbsp;|&amp;nbsp;结果&amp;nbsp;|&amp;nbsp;说明&amp;nbsp;|&amp;nbsp;耗时&amp;nbsp;|&amp;quot;,&amp;nbsp;&amp;quot;|---|---|---|---|---|&amp;quot;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;p&amp;nbsp;in&amp;nbsp;probes:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;icon&amp;nbsp;=&amp;nbsp;{&amp;quot;PASS&amp;quot;:&amp;nbsp;&amp;quot;✅&amp;quot;,&amp;nbsp;&amp;quot;FAIL&amp;quot;:&amp;nbsp;&amp;quot;❌&amp;quot;,&amp;nbsp;&amp;quot;EMPTY&amp;quot;:&amp;nbsp;&amp;quot;⚠️&amp;quot;,&amp;nbsp;&amp;quot;SKIP&amp;quot;:&amp;nbsp;&amp;quot;—&amp;quot;}[p.status]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines.append(f&amp;quot;|&amp;nbsp;{p.platform}&amp;nbsp;|&amp;nbsp;{p.resource}&amp;nbsp;|&amp;nbsp;{icon}&amp;nbsp;{p.status}&amp;nbsp;|&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;{p.detail&amp;nbsp;or&amp;nbsp;&amp;#39;—&amp;#39;}&amp;nbsp;|&amp;nbsp;{p.cost_ms}ms&amp;nbsp;|&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;&amp;quot;\n&amp;quot;.join(lines)


if&amp;nbsp;__name__&amp;nbsp;==&amp;nbsp;&amp;quot;__main__&amp;quot;:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cfg&amp;nbsp;=&amp;nbsp;TrialConfig(base_url=&amp;quot;https://api.example.com&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;app_key=&amp;quot;trial_xxxxxxxx&amp;quot;,&amp;nbsp;app_secret=&amp;quot;trial_secret_xxxxxxxx&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;probes&amp;nbsp;=&amp;nbsp;asyncio.run(run(cfg))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(report(probes))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;json.dump([asdict(p)&amp;nbsp;for&amp;nbsp;p&amp;nbsp;in&amp;nbsp;probes],&amp;nbsp;open(&amp;quot;smoke_result.json&amp;quot;,&amp;nbsp;&amp;quot;w&amp;quot;),&amp;nbsp;ensure_ascii=False,&amp;nbsp;indent=2)
```

`EMPTY`&amp;nbsp;这个状态是刻意加的：**「调用成功但没数据」比「调用失败」危险得多**。失败你会去看，空数据你会以为这个商品就是没库存。淘宝的&amp;nbsp;`trade_list`&amp;nbsp;在时间参数超出保留期时就是这么返回的。

###&amp;nbsp;3.4&amp;nbsp;`bench.py`：找限流拐点，不是测「最高&amp;nbsp;QPS」

这是四个脚本里最有价值的一个，也是&amp;nbsp;90%&amp;nbsp;的试用会漏掉的一个。

多数人压测是「一路加压，直到报&amp;nbsp;429，那个数值就是上限」。这错了——**429&amp;nbsp;出现的时候，你已经有很多请求在超时了**。生产上的表现不是「报限流错误」，是「莫名其妙的超时和变慢」。

正确的做法是**阶梯加压，找延迟开始抬升的那个点**：

```python
#&amp;nbsp;trial/bench.py
import&amp;nbsp;asyncio
import&amp;nbsp;time
import&amp;nbsp;statistics

from&amp;nbsp;client&amp;nbsp;import&amp;nbsp;TrialClient,&amp;nbsp;TrialConfig


async&amp;nbsp;def&amp;nbsp;one_stage(cfg,&amp;nbsp;concurrency:&amp;nbsp;int,&amp;nbsp;seconds:&amp;nbsp;float,&amp;nbsp;path:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;dict:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;固定并发压一段时间，收集延迟样本&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;latencies,&amp;nbsp;errors,&amp;nbsp;wall_end&amp;nbsp;=&amp;nbsp;[],&amp;nbsp;0,&amp;nbsp;time.monotonic()&amp;nbsp;+&amp;nbsp;seconds
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;TrialClient(cfg)&amp;nbsp;as&amp;nbsp;c:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sem&amp;nbsp;=&amp;nbsp;asyncio.Semaphore(concurrency)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;worker():
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;nonlocal&amp;nbsp;errors
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;while&amp;nbsp;time.monotonic()&amp;nbsp;&amp;lt;&amp;nbsp;wall_end:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;sem:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;r&amp;nbsp;=&amp;nbsp;await&amp;nbsp;c.get(path,&amp;nbsp;item_id=&amp;quot;654321098765&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;latencies.append(r.cost_ms)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;not&amp;nbsp;r.ok:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;errors&amp;nbsp;+=&amp;nbsp;1

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;asyncio.gather(*[worker()&amp;nbsp;for&amp;nbsp;_&amp;nbsp;in&amp;nbsp;range(concurrency)])
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;total&amp;nbsp;=&amp;nbsp;c.calls

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lat&amp;nbsp;=&amp;nbsp;sorted(latencies)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;concurrency&amp;quot;:&amp;nbsp;concurrency,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;qps&amp;quot;:&amp;nbsp;round(total&amp;nbsp;/&amp;nbsp;seconds,&amp;nbsp;1),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;p50&amp;quot;:&amp;nbsp;lat[int(len(lat)&amp;nbsp;*&amp;nbsp;0.50)]&amp;nbsp;if&amp;nbsp;lat&amp;nbsp;else&amp;nbsp;0,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;p95&amp;quot;:&amp;nbsp;lat[int(len(lat)&amp;nbsp;*&amp;nbsp;0.95)]&amp;nbsp;if&amp;nbsp;lat&amp;nbsp;else&amp;nbsp;0,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;p99&amp;quot;:&amp;nbsp;lat[int(len(lat)&amp;nbsp;*&amp;nbsp;0.99)]&amp;nbsp;if&amp;nbsp;lat&amp;nbsp;else&amp;nbsp;0,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;err_rate&amp;quot;:&amp;nbsp;round(errors&amp;nbsp;/&amp;nbsp;total,&amp;nbsp;3)&amp;nbsp;if&amp;nbsp;total&amp;nbsp;else&amp;nbsp;0,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;samples&amp;quot;:&amp;nbsp;total,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}


async&amp;nbsp;def&amp;nbsp;find_cliff(cfg:&amp;nbsp;TrialConfig,&amp;nbsp;path=&amp;quot;/v1/taobao/item&amp;quot;):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;base&amp;nbsp;=&amp;nbsp;None
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rows&amp;nbsp;=&amp;nbsp;[]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;c&amp;nbsp;in&amp;nbsp;(1,&amp;nbsp;2,&amp;nbsp;4,&amp;nbsp;8,&amp;nbsp;16,&amp;nbsp;32):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;st&amp;nbsp;=&amp;nbsp;await&amp;nbsp;one_stage(cfg,&amp;nbsp;c,&amp;nbsp;seconds=20,&amp;nbsp;path=path)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rows.append(st)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;base&amp;nbsp;is&amp;nbsp;None:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;base&amp;nbsp;=&amp;nbsp;st[&amp;quot;p95&amp;quot;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;拐点判定：P95&amp;nbsp;涨到基线的&amp;nbsp;2&amp;nbsp;倍，或错误率超&amp;nbsp;1%
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;st[&amp;quot;p95&amp;quot;]&amp;nbsp;&amp;gt;&amp;nbsp;base&amp;nbsp;*&amp;nbsp;2&amp;nbsp;or&amp;nbsp;st[&amp;quot;err_rate&amp;quot;]&amp;nbsp;&amp;gt;&amp;nbsp;0.01:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;break

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;{&amp;#39;并发&amp;#39;:&amp;lt;6}{&amp;#39;QPS&amp;#39;:&amp;lt;8}{&amp;#39;P50&amp;#39;:&amp;lt;8}{&amp;#39;P95&amp;#39;:&amp;lt;8}{&amp;#39;P99&amp;#39;:&amp;lt;8}{&amp;#39;错误率&amp;#39;:&amp;lt;8}&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;r&amp;nbsp;in&amp;nbsp;rows:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;{r[&amp;#39;concurrency&amp;#39;]:&amp;lt;6}{r[&amp;#39;qps&amp;#39;]:&amp;lt;8}{r[&amp;#39;p50&amp;#39;]:&amp;lt;8}{r[&amp;#39;p95&amp;#39;]:&amp;lt;8}&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;{r[&amp;#39;p99&amp;#39;]:&amp;lt;8}{r[&amp;#39;err_rate&amp;#39;]:&amp;lt;8}&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;safe&amp;nbsp;=&amp;nbsp;rows[-2][&amp;quot;qps&amp;quot;]&amp;nbsp;if&amp;nbsp;len(rows)&amp;nbsp;&amp;gt;&amp;nbsp;1&amp;nbsp;else&amp;nbsp;rows[0][&amp;quot;qps&amp;quot;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;\n安全&amp;nbsp;QPS&amp;nbsp;建议：{safe}（拐点前一级，约占平台上限的&amp;nbsp;70%）&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;rows,&amp;nbsp;safe
```


跑出来的表长这样（我们内部跑的真实形态，数字随账号等级变）：

|&amp;nbsp;并发&amp;nbsp;|&amp;nbsp;QPS&amp;nbsp;|&amp;nbsp;P50&amp;nbsp;|&amp;nbsp;P95&amp;nbsp;|&amp;nbsp;P99&amp;nbsp;|&amp;nbsp;错误率&amp;nbsp;|
|---|---|---|---|---|---|
|&amp;nbsp;1&amp;nbsp;|&amp;nbsp;3.4&amp;nbsp;|&amp;nbsp;280&amp;nbsp;|&amp;nbsp;310&amp;nbsp;|&amp;nbsp;420&amp;nbsp;|&amp;nbsp;0%&amp;nbsp;|
|&amp;nbsp;2&amp;nbsp;|&amp;nbsp;6.7&amp;nbsp;|&amp;nbsp;290&amp;nbsp;|&amp;nbsp;330&amp;nbsp;|&amp;nbsp;460&amp;nbsp;|&amp;nbsp;0%&amp;nbsp;|
|&amp;nbsp;4&amp;nbsp;|&amp;nbsp;13.1&amp;nbsp;|&amp;nbsp;300&amp;nbsp;|&amp;nbsp;350&amp;nbsp;|&amp;nbsp;520&amp;nbsp;|&amp;nbsp;0%&amp;nbsp;|
|&amp;nbsp;8&amp;nbsp;|&amp;nbsp;25.6&amp;nbsp;|&amp;nbsp;320&amp;nbsp;|&amp;nbsp;**420**&amp;nbsp;|&amp;nbsp;980&amp;nbsp;|&amp;nbsp;0%&amp;nbsp;|
|&amp;nbsp;16&amp;nbsp;|&amp;nbsp;38.2&amp;nbsp;|&amp;nbsp;410&amp;nbsp;|&amp;nbsp;**1,850**&amp;nbsp;|&amp;nbsp;4,200&amp;nbsp;|&amp;nbsp;0.4%&amp;nbsp;|

注意&amp;nbsp;8&amp;nbsp;到&amp;nbsp;16&amp;nbsp;那一行：**错误率只有&amp;nbsp;0.4%，看着还行，但&amp;nbsp;P95&amp;nbsp;已经从&amp;nbsp;420ms&amp;nbsp;涨到&amp;nbsp;1,850ms**。如果你只看错误率，会以为&amp;nbsp;16&amp;nbsp;并发是安全的，上线之后就是一片超时告警。真正的安全值在&amp;nbsp;8&amp;nbsp;那级，甚至更低一点。

这也是我建议你**在试用期一定要跑压测**的原因：这个拐点每个账号都不一样，问销售也只能给个大概值，只有自己跑才知道。

###&amp;nbsp;3.5&amp;nbsp;`verify.py`：数据准不准，只能和商家后台对

前三步验的是「通不通、快不快」，这一步验的是**「对不对」**，也是最容易糊弄过去的一步——因为很多服务商的接口「返回&amp;nbsp;200、字段齐全、数值是错的」，你不做对账永远发现不了。

最硬的验证方式是：**让商家从自己后台导一份&amp;nbsp;CSV，跟接口返回逐条比**。抽样&amp;nbsp;50&amp;nbsp;个商品就够，覆盖有活动价、有多个&amp;nbsp;SKU、刚改过价的。

```python
#&amp;nbsp;trial/verify.py
import&amp;nbsp;csv
from&amp;nbsp;decimal&amp;nbsp;import&amp;nbsp;Decimal

from&amp;nbsp;client&amp;nbsp;import&amp;nbsp;TrialClient,&amp;nbsp;TrialConfig


def&amp;nbsp;load_truth(path:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;dict[str,&amp;nbsp;dict]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;商家后台导出的&amp;nbsp;CSV：item_id,title,price,stock&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;truth&amp;nbsp;=&amp;nbsp;{}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;with&amp;nbsp;open(path,&amp;nbsp;encoding=&amp;quot;utf-8-sig&amp;quot;)&amp;nbsp;as&amp;nbsp;f:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;row&amp;nbsp;in&amp;nbsp;csv.DictReader(f):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;truth[row[&amp;quot;item_id&amp;quot;].strip()]&amp;nbsp;=&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;title&amp;quot;:&amp;nbsp;row[&amp;quot;title&amp;quot;].strip(),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;price&amp;quot;:&amp;nbsp;Decimal(row[&amp;quot;price&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;stock&amp;quot;:&amp;nbsp;int(row[&amp;quot;stock&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;truth


async&amp;nbsp;def&amp;nbsp;verify(cfg:&amp;nbsp;TrialConfig,&amp;nbsp;truth:&amp;nbsp;dict[str,&amp;nbsp;dict],&amp;nbsp;platform=&amp;quot;taobao&amp;quot;):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ok,&amp;nbsp;diffs&amp;nbsp;=&amp;nbsp;0,&amp;nbsp;[]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;TrialClient(cfg)&amp;nbsp;as&amp;nbsp;c:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;item_id,&amp;nbsp;expect&amp;nbsp;in&amp;nbsp;truth.items():
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;res&amp;nbsp;=&amp;nbsp;await&amp;nbsp;c.get(f&amp;quot;/v1/{platform}/item&amp;quot;,&amp;nbsp;item_id=item_id)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;not&amp;nbsp;res.ok:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;diffs.append((item_id,&amp;nbsp;&amp;quot;调用失败&amp;quot;,&amp;nbsp;res.code,&amp;nbsp;&amp;quot;&amp;quot;))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;continue

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;got_price&amp;nbsp;=&amp;nbsp;Decimal(str(res.data.get(&amp;quot;price&amp;quot;,&amp;nbsp;&amp;quot;0&amp;quot;)))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;got_stock&amp;nbsp;=&amp;nbsp;int(res.data.get(&amp;quot;stock&amp;quot;,&amp;nbsp;0)&amp;nbsp;or&amp;nbsp;0)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;got_price&amp;nbsp;!=&amp;nbsp;expect[&amp;quot;price&amp;quot;]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;kind&amp;nbsp;=&amp;nbsp;&amp;quot;价格口径&amp;quot;&amp;nbsp;if&amp;nbsp;abs(got_price&amp;nbsp;-&amp;nbsp;expect[&amp;quot;price&amp;quot;])&amp;nbsp;&amp;lt;&amp;nbsp;expect[&amp;quot;price&amp;quot;]&amp;nbsp;*&amp;nbsp;Decimal(&amp;quot;0.2&amp;quot;)&amp;nbsp;else&amp;nbsp;&amp;quot;价格错误&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;diffs.append((item_id,&amp;nbsp;kind,&amp;nbsp;str(expect[&amp;quot;price&amp;quot;]),&amp;nbsp;str(got_price)))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;elif&amp;nbsp;got_stock&amp;nbsp;!=&amp;nbsp;expect[&amp;quot;stock&amp;quot;]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;库存差&amp;nbsp;5%&amp;nbsp;以内通常是时点差，不算错
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;abs(got_stock&amp;nbsp;-&amp;nbsp;expect[&amp;quot;stock&amp;quot;])&amp;nbsp;&amp;gt;&amp;nbsp;max(3,&amp;nbsp;expect[&amp;quot;stock&amp;quot;]&amp;nbsp;*&amp;nbsp;0.05):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;diffs.append((item_id,&amp;nbsp;&amp;quot;库存时点&amp;quot;,&amp;nbsp;str(expect[&amp;quot;stock&amp;quot;]),&amp;nbsp;str(got_stock)))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;else:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ok&amp;nbsp;+=&amp;nbsp;1
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;else:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ok&amp;nbsp;+=&amp;nbsp;1

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;total&amp;nbsp;=&amp;nbsp;len(truth)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;准确率&amp;nbsp;{(ok&amp;nbsp;/&amp;nbsp;total):.2%}&amp;nbsp;&amp;nbsp;({ok}/{total})&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;d&amp;nbsp;in&amp;nbsp;diffs[:15]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;&amp;nbsp;&amp;nbsp;{d[0]:&amp;lt;15}{d[1]:&amp;lt;8}预期{d[2]:&amp;lt;10}实际{d[3]}&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;ok&amp;nbsp;/&amp;nbsp;total,&amp;nbsp;diffs
```

差异分类比准确率本身更有用：

-&amp;nbsp;**价格口径**（差&amp;nbsp;20%&amp;nbsp;以内）——通常是券后价&amp;nbsp;/&amp;nbsp;活动价&amp;nbsp;/&amp;nbsp;含运费的差异。这不算错，是口径没对齐，谈的时候问清楚就行。
-&amp;nbsp;**价格错误**（差异很大）——真问题，直接排除。
-&amp;nbsp;**库存时点**——你导&amp;nbsp;CSV&amp;nbsp;和接口取值差了几分钟，正常。差&amp;nbsp;5%&amp;nbsp;以上才需要查。

我们内部的验收线是&amp;nbsp;**准确率&amp;nbsp;≥&amp;nbsp;99.5%，且「价格错误」为&amp;nbsp;0**。低于这个数，接入之后每天都会有人找你。

##&amp;nbsp;四、7&amp;nbsp;天怎么排

四个脚本写完了，剩下的就是排期。这张表可以直接照着做：

|&amp;nbsp;天&amp;nbsp;|&amp;nbsp;做什么&amp;nbsp;|&amp;nbsp;用哪个脚本&amp;nbsp;|&amp;nbsp;产出&amp;nbsp;|
|---|---|---|---|
|&amp;nbsp;D1&amp;nbsp;|&amp;nbsp;跑通&amp;nbsp;+&amp;nbsp;冒烟&amp;nbsp;|&amp;nbsp;`quickstart.py`&amp;nbsp;/&amp;nbsp;`smoke.py`&amp;nbsp;|&amp;nbsp;9&amp;nbsp;项探针结果，知道哪些接口能用&amp;nbsp;|
|&amp;nbsp;D2&amp;nbsp;|&amp;nbsp;文档对表&amp;nbsp;|&amp;nbsp;—&amp;nbsp;|&amp;nbsp;你要的&amp;nbsp;12&amp;nbsp;个接口，哪几个缺、缺在哪个权限包&amp;nbsp;|
|&amp;nbsp;D3&amp;nbsp;|&amp;nbsp;数据准确性&amp;nbsp;|&amp;nbsp;`verify.py`&amp;nbsp;|&amp;nbsp;准确率&amp;nbsp;+&amp;nbsp;差异清单（抽样&amp;nbsp;50&amp;nbsp;个）&amp;nbsp;|
|&amp;nbsp;D4&amp;nbsp;|&amp;nbsp;限流与延迟&amp;nbsp;|&amp;nbsp;`bench.py`&amp;nbsp;|&amp;nbsp;安全&amp;nbsp;QPS、P95&amp;nbsp;基线&amp;nbsp;|
|&amp;nbsp;D5&amp;nbsp;|&amp;nbsp;真实场景端到端&amp;nbsp;|&amp;nbsp;自己写&amp;nbsp;|&amp;nbsp;一次全量铺货，或一份日报，跑完&amp;nbsp;|
|&amp;nbsp;D6&amp;nbsp;|&amp;nbsp;异常与降级&amp;nbsp;|&amp;nbsp;手动&amp;nbsp;|&amp;nbsp;改错&amp;nbsp;key、断网、超配额，看返回能不能解释&amp;nbsp;|
|&amp;nbsp;D7&amp;nbsp;|&amp;nbsp;出报告、做决策&amp;nbsp;|&amp;nbsp;`trial_report.py`&amp;nbsp;|&amp;nbsp;一页纸：过&amp;nbsp;/&amp;nbsp;不过&amp;nbsp;/&amp;nbsp;有条件过&amp;nbsp;|

额度大概这么分（按&amp;nbsp;5,000&amp;nbsp;次/天算）：冒烟&amp;nbsp;60&amp;nbsp;次、验准&amp;nbsp;300&amp;nbsp;次、压测&amp;nbsp;2,000&amp;nbsp;次、真实场景&amp;nbsp;2,500&amp;nbsp;次。**第一天别把额度烧完**，这是最常见的错误。

D6&amp;nbsp;那天值得单独说：故意把&amp;nbsp;`app_secret`&amp;nbsp;改错一位，看返回是&amp;nbsp;`AUTH`&amp;nbsp;还是&amp;nbsp;`UNKNOWN`；故意把商品&amp;nbsp;ID&amp;nbsp;改成一个不存在的，看是&amp;nbsp;`PARAM`&amp;nbsp;还是空数据。**错误可解释性决定了你以后排障要花多少小时**——返回&amp;nbsp;`UNKNOWN`&amp;nbsp;的服务商，出问题只能找人问，一问就是半天。

##&amp;nbsp;五、`trial_report.py`：把结果汇总成一页决策报告

7&amp;nbsp;天结束，你不该拿着一堆&amp;nbsp;json&amp;nbsp;去汇报。这个脚本把前面所有结果拼成一份&amp;nbsp;markdown，带明确的判定：

```python
#&amp;nbsp;trial/trial_report.py
import&amp;nbsp;json
from&amp;nbsp;datetime&amp;nbsp;import&amp;nbsp;date

#&amp;nbsp;验收阈值（可按业务调，但建议不要放宽「价格错误为&amp;nbsp;0」）
THRESHOLDS&amp;nbsp;=&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;accuracy&amp;quot;:&amp;nbsp;0.995,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;数据准确率
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;p95_ms&amp;quot;:&amp;nbsp;800,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;常规接口&amp;nbsp;P95
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;safe_qps_ratio&amp;quot;:&amp;nbsp;1.5,&amp;nbsp;&amp;nbsp;#&amp;nbsp;安全&amp;nbsp;QPS&amp;nbsp;/&amp;nbsp;业务峰值
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;smoke_pass&amp;quot;:&amp;nbsp;9,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;冒烟通过项
}


def&amp;nbsp;build(smoke:&amp;nbsp;list[dict],&amp;nbsp;verify_acc:&amp;nbsp;float,&amp;nbsp;price_errors:&amp;nbsp;int,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;bench:&amp;nbsp;list[dict],&amp;nbsp;safe_qps:&amp;nbsp;float,&amp;nbsp;peak_qps:&amp;nbsp;float)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;passed&amp;nbsp;=&amp;nbsp;sum(1&amp;nbsp;for&amp;nbsp;p&amp;nbsp;in&amp;nbsp;smoke&amp;nbsp;if&amp;nbsp;p[&amp;quot;status&amp;quot;]&amp;nbsp;==&amp;nbsp;&amp;quot;PASS&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;p95&amp;nbsp;=&amp;nbsp;bench[-2][&amp;quot;p95&amp;quot;]&amp;nbsp;if&amp;nbsp;len(bench)&amp;nbsp;&amp;gt;&amp;nbsp;1&amp;nbsp;else&amp;nbsp;bench[-1][&amp;quot;p95&amp;quot;]

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;checks&amp;nbsp;=&amp;nbsp;[
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;冒烟通过&amp;quot;,&amp;nbsp;f&amp;quot;{passed}/9&amp;quot;,&amp;nbsp;passed&amp;nbsp;&amp;gt;=&amp;nbsp;THRESHOLDS[&amp;quot;smoke_pass&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;数据准确率&amp;quot;,&amp;nbsp;f&amp;quot;{verify_acc:.2%}&amp;quot;,&amp;nbsp;verify_acc&amp;nbsp;&amp;gt;=&amp;nbsp;THRESHOLDS[&amp;quot;accuracy&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;价格错误&amp;quot;,&amp;nbsp;str(price_errors),&amp;nbsp;price_errors&amp;nbsp;==&amp;nbsp;0),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;P95&amp;nbsp;延迟&amp;quot;,&amp;nbsp;f&amp;quot;{p95}ms&amp;quot;,&amp;nbsp;p95&amp;nbsp;&amp;lt;=&amp;nbsp;THRESHOLDS[&amp;quot;p95_ms&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;(&amp;quot;QPS&amp;nbsp;余量&amp;quot;,&amp;nbsp;f&amp;quot;{safe_qps&amp;nbsp;/&amp;nbsp;peak_qps:.1f}x&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;safe_qps&amp;nbsp;&amp;gt;=&amp;nbsp;peak_qps&amp;nbsp;*&amp;nbsp;THRESHOLDS[&amp;quot;safe_qps_ratio&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;verdict&amp;nbsp;=&amp;nbsp;&amp;quot;通过&amp;quot;&amp;nbsp;if&amp;nbsp;all(c[2]&amp;nbsp;for&amp;nbsp;c&amp;nbsp;in&amp;nbsp;checks)&amp;nbsp;else&amp;nbsp;(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;有条件通过&amp;quot;&amp;nbsp;if&amp;nbsp;sum(c[2]&amp;nbsp;for&amp;nbsp;c&amp;nbsp;in&amp;nbsp;checks)&amp;nbsp;&amp;gt;=&amp;nbsp;4&amp;nbsp;else&amp;nbsp;&amp;quot;不通过&amp;quot;)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines&amp;nbsp;=&amp;nbsp;[f&amp;quot;#&amp;nbsp;接口试用评估报告（{date.today()}）&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;f&amp;quot;**结论：{verdict}**&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;,&amp;nbsp;&amp;quot;|&amp;nbsp;检查项&amp;nbsp;|&amp;nbsp;实测&amp;nbsp;|&amp;nbsp;判定&amp;nbsp;|&amp;quot;,&amp;nbsp;&amp;quot;|---|---|---|&amp;quot;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;name,&amp;nbsp;val,&amp;nbsp;ok&amp;nbsp;in&amp;nbsp;checks:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines.append(f&amp;quot;|&amp;nbsp;{name}&amp;nbsp;|&amp;nbsp;{val}&amp;nbsp;|&amp;nbsp;{&amp;#39;✅&amp;#39;&amp;nbsp;if&amp;nbsp;ok&amp;nbsp;else&amp;nbsp;&amp;#39;❌&amp;#39;}&amp;nbsp;|&amp;quot;)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines&amp;nbsp;+=&amp;nbsp;[&amp;quot;&amp;quot;,&amp;nbsp;&amp;quot;##&amp;nbsp;未通过项的处理建议&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;name,&amp;nbsp;val,&amp;nbsp;ok&amp;nbsp;in&amp;nbsp;checks:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;not&amp;nbsp;ok:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines.append(f&amp;quot;-&amp;nbsp;**{name}**：实测&amp;nbsp;{val}。&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;若是口径差异请与服务商确认定义；若是真差异，换方案比接入后打补丁便宜。&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;&amp;quot;\n&amp;quot;.join(lines)
```

这份报告有个副作用：**它同时是给老板看的材料**。技术人做选型最难的不是判断，是说服——「我觉得这家还行」没有说服力，「准确率&amp;nbsp;99.8%、P95&amp;nbsp;420ms、QPS&amp;nbsp;余量&amp;nbsp;3.2&amp;nbsp;倍、五个检查项全过」有。

##&amp;nbsp;六、试用结束后最容易被忽略的两个坑

**①&amp;nbsp;试用期的数据不能当生产依赖。**&amp;nbsp;到期停服，你的任务表里那些外键、缓存里那些商品、报表里那些历史，全部失去来源。正确的做法是试用期只做「读&amp;nbsp;+&amp;nbsp;验证」，不做数据落地。要落，就落在你自己库里并标清来源和有效期（见第&amp;nbsp;18&amp;nbsp;篇那个&amp;nbsp;`provenance`&amp;nbsp;字段）。

**②&amp;nbsp;试用权限通常是正式权限的子集。**&amp;nbsp;你试用期能调的接口，正式开通不一定一次全给（要看资质和权限包）；反过来，试用期调不通的，也可能是权限没开而不是接口没有。**这一点必须在&amp;nbsp;D2&amp;nbsp;那天和商务确认清楚**，别等到接入时才发现「哦这个要另外申请」。

##&amp;nbsp;七、文中用到的东西

-&amp;nbsp;六个文件：`client.py`（打底客户端）、`quickstart.py`、`smoke.py`、`bench.py`、`verify.py`、`trial_report.py`（可直接跑，改配置即可）
-&amp;nbsp;三平台接口对照索引：核心业务动作&amp;nbsp;→&amp;nbsp;三平台接口名&amp;nbsp;+&amp;nbsp;关键差异，一页表
-&amp;nbsp;三平台错误码对照：把三套错误码归一成&amp;nbsp;5&amp;nbsp;类，判断「该重试还是该改代码」
-&amp;nbsp;试用账号：7&amp;nbsp;天，5,000&amp;nbsp;次/天，核心接口全开，不需要企业资质

我们把这些打包成了一份试用包，文档和代码不申请也能先看。**建议的顺序是：先看文档对表，确认接口够用，再领试用账号跑脚本**——反过来做，容易在第&amp;nbsp;2&amp;nbsp;天发现最关键的接口没有，白折腾。

---

前面十八篇讲的是「怎么把这件事做成」，这篇讲「怎么判断手上的工具行不行」。判断标准我写在代码里了，阈值你可以改成自己的——**但「价格错误为&amp;nbsp;0」这条建议别改**，它是唯一一个接入之后没法补救的指标。&lt;/pre&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;</description><pubDate>Mon, 02 Nov 2026 16:21:24 +0800</pubDate></item><item><title>官方授权通道和灰色爬虫，做数据服务该选哪条路（附 python 源码）</title><link>https://www.alexob.com/?id=595</link><description>&lt;pre class=&quot;prism-highlight prism-language-python&quot;&gt;#&amp;nbsp;官方授权通道和灰色爬虫，做数据服务该选哪条路（附&amp;nbsp;python&amp;nbsp;源码）

&amp;gt;&amp;nbsp;第&amp;nbsp;12&amp;nbsp;篇算过一笔自用的账：自建爬虫三年&amp;nbsp;37&amp;nbsp;万，官方&amp;nbsp;API&amp;nbsp;17.2&amp;nbsp;万。那篇的结论是「自用场景下，风险是一票否决项」。
&amp;gt;
&amp;gt;&amp;nbsp;但很多人问的不是自用——他们想做**数据服务**：把多平台的价格、商品数据聚合起来，卖给商家或开发者。这个问题必须单独回答，因为答案比自用场景更一边倒，而且原因完全不同。
&amp;gt;
&amp;gt;&amp;nbsp;一句话：**自用，灰色爬虫是成本问题；做成服务，是生存问题。**
&amp;gt;
&amp;gt;&amp;nbsp;自用时风险由你自己承担，封了就封了，损失可控。做成服务，你的每一个客户都跟你签了&amp;nbsp;SLA——你的上游哪天断了，你就是在对所有客户违约。这篇讲清楚为什么，以及走官方授权这条路要付的真实代价。

先划清边界：这篇不讨论怎么爬、怎么绕。爬虫的攻防不是技术选型，是法律风险——绕过技术措施可能触及刑法第&amp;nbsp;285&amp;nbsp;条，有真实获刑判例。这里只讲两条路的**商业与合规结构**，以及走合规路线需要的工程设施。

##&amp;nbsp;一、先精确定义「灰」在哪：四条边界

「灰色爬虫」不是一个笼统的概念，它压着四条线。做自用和做服务，压线的深度完全不同：

|&amp;nbsp;边界&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;说什么&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;自用&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;做服务&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;-----&amp;nbsp;|&amp;nbsp;----------------------&amp;nbsp;|&amp;nbsp;----------&amp;nbsp;|&amp;nbsp;-------------------&amp;nbsp;|
|&amp;nbsp;技术措施&amp;nbsp;&amp;nbsp;|&amp;nbsp;绕过反爬、破解接口&amp;nbsp;=&amp;nbsp;刑法&amp;nbsp;285&amp;nbsp;条风险&amp;nbsp;|&amp;nbsp;不碰就相对安全&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;同样不能碰&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;个人信息&amp;nbsp;&amp;nbsp;|&amp;nbsp;买家昵称、评价内容、地址都算&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;容易顺手采集&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**对外提供&amp;nbsp;=&amp;nbsp;违规放大**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;实质性替代&amp;nbsp;|&amp;nbsp;向第三方提供平台数据的替代视图&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;小规模通常够不上&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**必然够得上**——你就是在做这件事&amp;nbsp;|
|&amp;nbsp;平台协议&amp;nbsp;&amp;nbsp;|&amp;nbsp;robots、用户协议、接口条款&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;违约成本是封你一个号&amp;nbsp;|&amp;nbsp;封你，然后你违约于所有客户&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

关键差异在第三条。**做数据服务，天然就是「向不特定第三方提供平台数据的聚合视图」**——这正是反不正当竞争法里「实质性替代」的典型形态。爬公开数据自己看，和法律风险之间还有距离；聚合起来卖，距离就没有了。

所以不要问「爬公开数据违不违法」这种笼统问题，要问：**我提供的这个东西，是不是平台功能的替代品，我有没有权利向第三方提供它。**

##&amp;nbsp;二、做服务，风险是传导的

这是本篇的核心论证。画一条链：

```
上游数据源&amp;nbsp;→&amp;nbsp;你的服务&amp;nbsp;→&amp;nbsp;SLA&amp;nbsp;合同&amp;nbsp;→&amp;nbsp;客户
```

客户看到的、签合同的、付钱的，都是**你**。但你的服务质量，实际上由**上游**决定：

-&amp;nbsp;**官方授权上游断了**：有公告、有工单、有恢复预期，多数故障在小时级恢复，你甚至能提前收到字段变更通知。
-&amp;nbsp;**灰色上游断了**：一夜之间全挂——封号、IP&amp;nbsp;段拉黑、验证码升级，**没有公告、没有申诉渠道、往往不可恢复**。你连告诉客户「什么时候恢复」都做不到，因为没有「恢复」这回事。

而你和客户签的合同不会写「因为我的上游是灰色的」。SLA&amp;nbsp;写的是&amp;nbsp;99.9%、数据准确率、赔偿条款。上游断一天，就是全员违约：赔偿是月费的几倍，但真正的损失是客户流失和行业口碑——数据服务的圈子很小，一次大面积断供，名字就传开了。

**所以数据服务的第一性问题不是「哪条路便宜」，而是：你的上游有没有给你「对客户履约的能力」。**&amp;nbsp;灰色爬虫在这条上是结构性缺失，不是运营努力能补的。

顺带一个现实：灰色上游的故障是**突发的、全量的**——不存在「降级到&amp;nbsp;50%」，只有「可用」和「归零」两种状态。这让&amp;nbsp;SLA&amp;nbsp;的数学根本不成立。

##&amp;nbsp;三、官方授权通道的真实形态：不是一条路，是一组上游

「官方授权」也常被误解成「自己去申请开放平台」。做服务时，合法上游其实有四种，各有各的位置：

|&amp;nbsp;上游&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;拿到什么&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;适合&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;-------------------&amp;nbsp;|&amp;nbsp;------------&amp;nbsp;|&amp;nbsp;----------&amp;nbsp;|
|&amp;nbsp;**平台开放平台&amp;nbsp;/&amp;nbsp;ISV&amp;nbsp;授权**&amp;nbsp;|&amp;nbsp;你有权限范围内最全的字段&amp;nbsp;|&amp;nbsp;自家店铺数据、精修层&amp;nbsp;|
|&amp;nbsp;**联盟接口**（淘宝客、京东联盟）&amp;nbsp;&amp;nbsp;|&amp;nbsp;全站商品的价格、券、佣金&amp;nbsp;|&amp;nbsp;覆盖层，门槛低&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;**持牌数据商采购**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;归一化后的多平台数据&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;补充层，要尽调合同&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;**人工与授权补录**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;长尾与特殊字段&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;量小，但必须留痕&amp;nbsp;&amp;nbsp;&amp;nbsp;|

三点实操建议：

1.&amp;nbsp;**混合上游是常态，不是妥协。**&amp;nbsp;没有单一上游能同时满足覆盖、深度、时效。成熟的数据服务商都是「官方为主&amp;nbsp;+&amp;nbsp;采购补充&amp;nbsp;+&amp;nbsp;人工兜底」——重要的是每一条数据知道自己从哪来（下一节的代码就是干这个的）。
2.&amp;nbsp;**采购持牌数据商要做尽调。**&amp;nbsp;问清授权链路（第&amp;nbsp;17&amp;nbsp;篇那五问直接适用）：是直连官方授权还是二次倒卖？授权范围含不含「对外提供」？合同里把数据合规责任写进去。
3.&amp;nbsp;**「客户授权」也是授权。**&amp;nbsp;商家把店铺&amp;nbsp;session&amp;nbsp;授权给你（他用型&amp;nbsp;ISV&amp;nbsp;模式），你拿到的是**该店铺自己数据**的读取权——用它去查别的商家的商品，是越权。私域和行情的边界，第&amp;nbsp;17&amp;nbsp;篇讲过，这里只提醒一句：这个错误在自用时是隐患，在服务场景里是事故。

##&amp;nbsp;四、数据溯源：合规不是纪律，是代码

合规这件事，靠「团队自觉」一定会出事——半年后新人接手，从某个来源抓了批数据直接进了对外接口，没人知道。

所以正确做法是**把授权状态做进数据本身**：每条数据带溯源字段，未验证来源的数据在代码层面就出不了&amp;nbsp;API。

```python
#&amp;nbsp;provenance.py&amp;nbsp;——&amp;nbsp;数据溯源：让「灰数据进不了产品」成为系统属性
from&amp;nbsp;dataclasses&amp;nbsp;import&amp;nbsp;dataclass
from&amp;nbsp;enum&amp;nbsp;import&amp;nbsp;Enum


class&amp;nbsp;SourceType(str,&amp;nbsp;Enum):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;OFFICIAL&amp;nbsp;=&amp;nbsp;&amp;quot;official&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;平台开放平台&amp;nbsp;/&amp;nbsp;ISV&amp;nbsp;授权接口
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;AFFILIATE&amp;nbsp;=&amp;nbsp;&amp;quot;affiliate&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;联盟接口（淘宝客、京东联盟）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;PROCURED&amp;nbsp;=&amp;nbsp;&amp;quot;procured&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;向持牌数据商采购（有合同）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;MANUAL&amp;nbsp;=&amp;nbsp;&amp;quot;manual&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;人工录入&amp;nbsp;/&amp;nbsp;授权补录
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;UNVERIFIED&amp;nbsp;=&amp;nbsp;&amp;quot;unverified&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;来源未验证——禁止对外提供


@dataclass
class&amp;nbsp;Provenance:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;source_type:&amp;nbsp;SourceType
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;authorization_ref:&amp;nbsp;str&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;授权依据：ISV&amp;nbsp;合同号&amp;nbsp;/&amp;nbsp;采购合同编号&amp;nbsp;/&amp;nbsp;联盟备案号
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;fetched_at:&amp;nbsp;str&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;抓取时间（ISO&amp;nbsp;8601）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;license_scope:&amp;nbsp;str&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;允许用途：internal_only&amp;nbsp;/&amp;nbsp;client_delivery&amp;nbsp;/&amp;nbsp;redistribution
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;upstream_version:&amp;nbsp;str&amp;nbsp;=&amp;nbsp;&amp;quot;&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;上游接口版本——平台改字段时能圈定影响面


class&amp;nbsp;ComplianceError(Exception):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;pass


DELIVERABLE&amp;nbsp;=&amp;nbsp;{SourceType.OFFICIAL,&amp;nbsp;SourceType.AFFILIATE,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SourceType.PROCURED,&amp;nbsp;SourceType.MANUAL}

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
def&amp;nbsp;assert_deliverable(p:&amp;nbsp;Provenance)&amp;nbsp;-&amp;gt;&amp;nbsp;None:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;对外提供前强制检查。这个函数在&amp;nbsp;API&amp;nbsp;出口处调用，不是在文档里写一遍就完了&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;p.source_type&amp;nbsp;is&amp;nbsp;SourceType.UNVERIFIED:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;ComplianceError(&amp;quot;来源未验证的数据禁止对外提供（internal_only）&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;p.source_type&amp;nbsp;not&amp;nbsp;in&amp;nbsp;DELIVERABLE:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;ComplianceError(f&amp;quot;来源类型&amp;nbsp;{p.source_type}&amp;nbsp;不允许对外提供&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;&amp;quot;client_delivery&amp;quot;&amp;nbsp;not&amp;nbsp;in&amp;nbsp;p.license_scope:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;ComplianceError(f&amp;quot;来源&amp;nbsp;{p.source_type.value}&amp;nbsp;的授权范围不含对外提供&amp;quot;)
```

三个设计点，每一个都有真实用途：

1.&amp;nbsp;**`UNVERIFIED`&amp;nbsp;不是「不准用」，是「不准出」。**&amp;nbsp;临时来源的数据可以进内部分析库做研究，但&amp;nbsp;`assert_deliverable`&amp;nbsp;挂在&amp;nbsp;API&amp;nbsp;出口，它永远发不出去。研究自由和交付合规分开管。
2.&amp;nbsp;**`license_scope`&amp;nbsp;比来源类型更重要。**&amp;nbsp;同样是采购数据，合同里授权了&amp;nbsp;`internal_only`&amp;nbsp;和&amp;nbsp;`client_delivery`&amp;nbsp;是两个世界。签合同时看清楚，代码里对应上。
3.&amp;nbsp;**`upstream_version`&amp;nbsp;是救命字段。**&amp;nbsp;平台改字段（第&amp;nbsp;14&amp;nbsp;篇讲过字段映射的坑）时，你能精确回答「受影响的数据是从哪个上游、哪个版本抓的」，圈定影响面、定向修复。没有它，就是全量重刷。

##&amp;nbsp;五、合规自查：定期扫自己的系统

上游合规之外，还要防**自己人**越界。两类常见事故，都可以用脚本定期扫：

**事故一：越权调用。**&amp;nbsp;拿&amp;nbsp;A&amp;nbsp;店的授权去查&amp;nbsp;B&amp;nbsp;店的商品——接口返回&amp;nbsp;200，但这是第&amp;nbsp;17&amp;nbsp;篇说的越权，在服务场景里是事故。

**事故二：个人信息入库。**&amp;nbsp;抓商品数据时顺手把评价里的买家昵称、地址存下来了。单条看是脏数据，攒起来就是一个个人信息库——而你没有处理它的合法性基础。

```python
#&amp;nbsp;audit.py&amp;nbsp;——&amp;nbsp;两类自查：越权调用模式&amp;nbsp;+&amp;nbsp;疑似个人信息字段
import&amp;nbsp;re

#&amp;nbsp;----&amp;nbsp;自查一：session&amp;nbsp;与查询对象不匹配&amp;nbsp;=&amp;nbsp;越权&amp;nbsp;----
def&amp;nbsp;scan_unauthorized_calls(call_log:&amp;nbsp;list[dict],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;token_owner:&amp;nbsp;dict[str,&amp;nbsp;str])&amp;nbsp;-&amp;gt;&amp;nbsp;list[dict]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;call_log:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[{&amp;quot;token_id&amp;quot;:&amp;nbsp;&amp;quot;...&amp;quot;,&amp;nbsp;&amp;quot;seller_queried&amp;quot;:&amp;nbsp;&amp;quot;...&amp;quot;,&amp;nbsp;&amp;quot;api&amp;quot;:&amp;nbsp;&amp;quot;...&amp;quot;}]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;token_owner:&amp;nbsp;token_id&amp;nbsp;-&amp;gt;&amp;nbsp;该授权店铺的&amp;nbsp;seller_id
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;返回所有「用&amp;nbsp;A&amp;nbsp;店的&amp;nbsp;token&amp;nbsp;查&amp;nbsp;B&amp;nbsp;店数据」的调用
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;alerts&amp;nbsp;=&amp;nbsp;[]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;call&amp;nbsp;in&amp;nbsp;call_log:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;owner&amp;nbsp;=&amp;nbsp;token_owner.get(call[&amp;quot;token_id&amp;quot;])
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;owner&amp;nbsp;and&amp;nbsp;call.get(&amp;quot;seller_queried&amp;quot;)&amp;nbsp;not&amp;nbsp;in&amp;nbsp;(None,&amp;nbsp;&amp;quot;&amp;quot;,&amp;nbsp;owner):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;alerts.append({**call,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;reason&amp;quot;:&amp;nbsp;f&amp;quot;token&amp;nbsp;属于店铺&amp;nbsp;{owner}，却查询了&amp;nbsp;{call[&amp;#39;seller_queried&amp;#39;]}&amp;quot;})
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;alerts


#&amp;nbsp;----&amp;nbsp;自查二：疑似个人信息字段&amp;nbsp;----
PI_FIELDS&amp;nbsp;=&amp;nbsp;{&amp;quot;buyer_nick&amp;quot;,&amp;nbsp;&amp;quot;buyernick&amp;quot;,&amp;nbsp;&amp;quot;receiver_name&amp;quot;,&amp;nbsp;&amp;quot;receivername&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;receiver_mobile&amp;quot;,&amp;nbsp;&amp;quot;receivermobile&amp;quot;,&amp;nbsp;&amp;quot;mobile&amp;quot;,&amp;nbsp;&amp;quot;id_card&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;address&amp;quot;,&amp;nbsp;&amp;quot;detail_address&amp;quot;}

PI_PATTERNS&amp;nbsp;=&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;手机号&amp;quot;:&amp;nbsp;re.compile(r&amp;quot;\b1[3-9]\d{9}\b&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;身份证&amp;quot;:&amp;nbsp;re.compile(r&amp;quot;\b\d{17}[\dXx]\b&amp;quot;),
}

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
def&amp;nbsp;scan_pi_fields(schema:&amp;nbsp;dict[str,&amp;nbsp;str])&amp;nbsp;-&amp;gt;&amp;nbsp;list[dict]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;schema:&amp;nbsp;{&amp;quot;orders.receiver_name&amp;quot;:&amp;nbsp;&amp;quot;收货人姓名&amp;quot;,&amp;nbsp;...}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;返回疑似个人信息字段清单及命中原因，逐条评估去留
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;hits&amp;nbsp;=&amp;nbsp;[]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;key,&amp;nbsp;desc&amp;nbsp;in&amp;nbsp;(schema&amp;nbsp;or&amp;nbsp;{}).items():
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;name&amp;nbsp;=&amp;nbsp;key.split(&amp;quot;.&amp;quot;)[-1].lower()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;name&amp;nbsp;in&amp;nbsp;PI_FIELDS:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;hits.append({&amp;quot;field&amp;quot;:&amp;nbsp;key,&amp;nbsp;&amp;quot;why&amp;quot;:&amp;nbsp;&amp;quot;字段名命中&amp;nbsp;PI&amp;nbsp;清单&amp;quot;})
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;elif&amp;nbsp;desc&amp;nbsp;and&amp;nbsp;any(p.search(desc)&amp;nbsp;for&amp;nbsp;p&amp;nbsp;in&amp;nbsp;PI_PATTERNS.values()):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;hits.append({&amp;quot;field&amp;quot;:&amp;nbsp;key,&amp;nbsp;&amp;quot;why&amp;quot;:&amp;nbsp;&amp;quot;字段说明命中格式特征&amp;quot;})
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;hits


def&amp;nbsp;run_audit(call_log,&amp;nbsp;token_owner,&amp;nbsp;schema)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;pi&amp;nbsp;=&amp;nbsp;scan_pi_fields(schema)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cross&amp;nbsp;=&amp;nbsp;scan_unauthorized_calls(call_log,&amp;nbsp;token_owner)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;report&amp;nbsp;=&amp;nbsp;[f&amp;quot;[审计]&amp;nbsp;疑似&amp;nbsp;PI&amp;nbsp;字段&amp;nbsp;{len(pi)}&amp;nbsp;个，越权调用&amp;nbsp;{len(cross)}&amp;nbsp;笔&amp;quot;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;h&amp;nbsp;in&amp;nbsp;pi:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;report.append(f&amp;quot;&amp;nbsp;&amp;nbsp;PI&amp;nbsp;字段:&amp;nbsp;{h[&amp;#39;field&amp;#39;]}（{h[&amp;#39;why&amp;#39;]}）&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;c&amp;nbsp;in&amp;nbsp;cross[:20]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;report.append(f&amp;quot;&amp;nbsp;&amp;nbsp;越权:&amp;nbsp;{c[&amp;#39;api&amp;#39;]}&amp;nbsp;{c[&amp;#39;reason&amp;#39;]}&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;&amp;quot;\n&amp;quot;.join(report)
```

这个脚本建议**每月跑一次、接到&amp;nbsp;CI&amp;nbsp;里**，输出直接发到内部告警群。它防的不是黑客，是自己系统的熵增：新同事加了张表、新接了个来源、某个字段顺手存了明细——每一次单看都无害，攒起来就是合规事故。

##&amp;nbsp;六、决策框架：三个问题定路线

不绕弯子，三个问题问完，答案自己就出来了：

|&amp;nbsp;问题&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;答案是&amp;nbsp;A&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;答案是&amp;nbsp;B&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;-------&amp;nbsp;|&amp;nbsp;----------&amp;nbsp;|&amp;nbsp;-----------------&amp;nbsp;|
|&amp;nbsp;①&amp;nbsp;给谁用？&amp;nbsp;&amp;nbsp;|&amp;nbsp;自己/公司内部&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;对外客户（SaaS、API、报告）&amp;nbsp;|
|&amp;nbsp;②&amp;nbsp;数据去哪？&amp;nbsp;|&amp;nbsp;内部分析，不对外分发&amp;nbsp;|&amp;nbsp;聚合后对外提供&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;③&amp;nbsp;出事谁赔？&amp;nbsp;|&amp;nbsp;自己承担，损失可控&amp;nbsp;&amp;nbsp;|&amp;nbsp;合同违约，全员赔偿&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

**三问里只要有一个落在&amp;nbsp;B&amp;nbsp;列，灰色爬虫就不是选项**——不是「风险高低」的权衡，是商业模式结构上不成立：你无法对客户承诺一个随时可能归零的上游。

只有一个特殊情况值得说：**自用研究、一次性分析、无开放能力的长尾站点**（第&amp;nbsp;12&amp;nbsp;篇列过的爬虫合理场景），这些可以做，但数据必须标记为&amp;nbsp;`UNVERIFIED`——按第四节的机制，它们进得了你的分析库，出不了你的产品。

##&amp;nbsp;七、一句话收尾

两条路的差别，不在技术，在**商业模式的地基**：

&amp;gt;&amp;nbsp;灰色爬虫的商业模式建立在「平台没发现」上，每一天都在倒数；官方授权的建立在「平台允许」上，虽然慢，但没有倒计时。

做服务的人选哪条路，其实不是技术选型——是你打算把「客户信任」建在哪块地上。数据的生意是长生意，上游稳，才能谈&amp;nbsp;SLA；SLA&amp;nbsp;稳，才有复购。

---

*我们自己做三平台接口服务，走的就是全授权链路：开放平台&amp;nbsp;+&amp;nbsp;联盟&amp;nbsp;+&amp;nbsp;持牌合作，每个字段可溯源。如果你的选品、比价、监控系统不想在数据来源上冒风险，评论区或私信聊。*&lt;/pre&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;</description><pubDate>Mon, 02 Nov 2026 15:35:29 +0800</pubDate></item><item><title>淘宝商品 API 申请不下来？三个常见卡点和对策（附 python 源码）</title><link>https://www.alexob.com/?id=594</link><description>&lt;pre class=&quot;prism-highlight prism-language-python&quot;&gt;#&amp;nbsp;淘宝商品&amp;nbsp;API&amp;nbsp;申请不下来？三个常见卡点和对策（附&amp;nbsp;python&amp;nbsp;源码）

&amp;gt;&amp;nbsp;先说结论：申请不下来，绝大多数时候**不是你的技术方案有问题，是材料和场景没写对**。
&amp;gt;
&amp;gt;&amp;nbsp;淘宝开放平台的接口权限是「按权限包申请&amp;nbsp;+&amp;nbsp;人工审核」的机制，审核员看的是三件事：**你是谁（主体）、你要用哪种应用调（类型）、你拿数据干什么（场景）**。这三件事任何一件不对，材料写得再漂亮也会被驳回。
&amp;gt;
&amp;gt;&amp;nbsp;更麻烦的是，三种失败长得完全不一样：有的是提交就被拒，有的是审核过了但调用报「权限不足」，有的是调用能通但过几个月权限被回收。混在一起处理，只会反复提交、反复被拒。
&amp;gt;
&amp;gt;&amp;nbsp;这篇按三个卡点拆开讲，最后给一个**权限自检脚本**——申请下来之后第一件事应该是跑它，而不是直接写业务代码。

##&amp;nbsp;一、先把三种失败分清

|&amp;nbsp;表现&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;卡在哪&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;谁在拦你&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;--------------------------------------------&amp;nbsp;|&amp;nbsp;----------------&amp;nbsp;|&amp;nbsp;------------------&amp;nbsp;|
|&amp;nbsp;提交申请就被驳回，附一句「资质不符」&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;主体与资质&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;资质审核&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;应用创建了，但权限包里**找不到**你要的接口&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;应用类型&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;平台的产品设计（改不了，只能换类型）&amp;nbsp;|
|&amp;nbsp;权限申请通过了，调用还是报&amp;nbsp;`Insufficient&amp;nbsp;isv&amp;nbsp;permissions`&amp;nbsp;|&amp;nbsp;权限包&amp;nbsp;/&amp;nbsp;白名单&amp;nbsp;/&amp;nbsp;调用方式&amp;nbsp;|&amp;nbsp;运行时鉴权&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;用了几个月，突然全部报无权限&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;场景复核&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;风控（调用行为与申报不符）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

第二种最坑：你以为「没权限」，其实是「没资格」。后面细说。

##&amp;nbsp;二、卡点一：主体与资质——最容易被硬驳回的地方

三个必须过的关，缺一个都卡：

1.&amp;nbsp;**企业主体&amp;nbsp;+&amp;nbsp;实名**。个人开发者账号能拿到的商品类接口极少，做商用几乎必然要企业主体（营业执照、统一社会信用代码、法人信息）。
2.&amp;nbsp;**对公账户验证**。企业实名一般要走支付宝对公打款或授权验证，这一步卡住的人不少，因为财务流程慢。
3.&amp;nbsp;**经营范围**。这是最容易忽略、也最致命的一条：**营业执照的经营范围要能覆盖你的申请场景**。你去申请「数据服务」类接口，经营范围里只有「日用百货销售」，大概率直接驳回。

我们的建议很具体：

-&amp;nbsp;经营范围里补上**信息技术、数据服务、软件开发、计算机系统服务**这几项（工商变更一般&amp;nbsp;3~5&amp;nbsp;个工作日，比反复申诉快）。
-&amp;nbsp;联系人手机号必须是**法人或已备案的经办人**，填错会被系统直接驳回，而你往往收不到明确提示。
-&amp;nbsp;授权店铺绑定要店主扫码，别让客户等——这一条经常是「材料都齐了但卡了三天」的真实原因。

##&amp;nbsp;三、卡点二：应用类型选错，权限天花板就定了

淘宝的应用类型大方向分两类：**自用型**（只服务自己的店）和**他用型&amp;nbsp;/&amp;nbsp;ISV**（服务多个商家）。类型决定了你能申请哪些权限包——**而这是不可逆的**（要换只能新建应用、重新审核）。

所以最坑的一种情况是：你的&amp;nbsp;appkey&amp;nbsp;是自用型，去「功能场景」里找商品检索类接口的权限包，**根本找不到**。你会以为是自己没资格申请，实际上是应用类型不支持。

判断方法很简单，也有官方依据：接口文档最下方会列出「该接口所属权限包」和「可申请此权限包的应用类型」。对照一下就知道是哪种情况。

###&amp;nbsp;场景描述：审核员看的是业务，不是技术

场景描述被驳回，是最常见的软性失败。三条规则：

**①&amp;nbsp;不要出现技术词，更不要出现敏感词。**

-&amp;nbsp;「爬虫」「批量抓取」「爬取全网商品」——直接触发敏感词，这类表述会被判定为数据抓取用途。
-&amp;nbsp;「数据同步」「数据分析」——不是敏感词，但太笼统，审核员无法判断你要拿数据干什么，大概率打回。

**②&amp;nbsp;写「谁用、用来做什么、数据从哪来、不外传」四件事。**

对比一下：

```
❌&amp;nbsp;数据同步
❌&amp;nbsp;调用淘宝商品接口获取商品数据用于分析
✅&amp;nbsp;为已授权的商家提供店铺商品管理：商家在系统中维护自己的商品，
&amp;nbsp;&amp;nbsp;&amp;nbsp;系统通过商品接口读取本店商品的价格与库存，用于生成补货建议，
&amp;nbsp;&amp;nbsp;&amp;nbsp;数据仅在本系统内使用，不向第三方提供，不用于跨店铺采集合集。
```

**③&amp;nbsp;附证据。**&amp;nbsp;原型图、界面截图、数据流向图，比文字有用得多。审核员每天看成百份描述，有图的申请通过率明显高。

**④&amp;nbsp;如果是他用型（ISV），还要说清服务对象。**&amp;nbsp;「服务哪些商家、怎么签约、数据隔离怎么做」——这三问不答，他会怀疑你在做数据转售。

##&amp;nbsp;四、卡点三：权限包和接口是多对多，不是一个「商品权限」

这是技术人最容易误解的一层。**「我申请了商品权限」不等于「我能调所有商品接口」。**

`taobao.item.seller.get`（本店商品详情）和&amp;nbsp;`items.list.get`（商品列表）很可能在**不同的权限包**里。权限包是按业务场景切的，不是按资源类型切的。

所以正确的申请顺序是**从接口倒推权限包**，而不是从类目正推：

1.&amp;nbsp;列出你最终要调的接口（不是类目，是接口名）；
2.&amp;nbsp;每个接口文档拉到底，看它属于哪个权限包；
3.&amp;nbsp;去控制台「应用管理&amp;nbsp;→&amp;nbsp;应用证书&amp;nbsp;→&amp;nbsp;功能场景」里找这些包，能申请的一次申请（但别一次申请&amp;nbsp;20&amp;nbsp;个，首申通过率低）；
4.&amp;nbsp;找不到的包&amp;nbsp;→&amp;nbsp;回到卡点二，是应用类型问题。

###&amp;nbsp;私域接口和行情接口是隔离的

这条要单独强调，因为它是**封号级**的错误：

-&amp;nbsp;**私域接口**（需要&amp;nbsp;`session`）：只能读**该授权店铺自己**的数据。
-&amp;nbsp;拿店铺授权去批量查别的商家的商品——这是越权，不是「权限不够」。轻则权限回收，重则应用封禁、主体追责。

我们见过有人用客户店铺的&amp;nbsp;session&amp;nbsp;去扫竞品，理由是「接口返回成功了」。接口返回成功只代表你没超配额，不代表你合规。

###&amp;nbsp;五个子错误码，对应五种完全不同的动作

调用报&amp;nbsp;`Insufficient&amp;nbsp;isv&amp;nbsp;permissions`&amp;nbsp;时，真正的信息在**子错误码**里：

|&amp;nbsp;子错误码&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;含义&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;该做什么&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;----------------------------------------&amp;nbsp;|&amp;nbsp;-------------&amp;nbsp;|&amp;nbsp;---------------------------&amp;nbsp;|
|&amp;nbsp;`isv.permission-api-package-limit`&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;应用没有该接口权限&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;去功能场景申请对应权限包&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;`isv.permission-api-package-empty`&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;没关联任何访问包&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;按业务申请对应权限包&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;`isv.permission-api-package-not-allowed`&amp;nbsp;|&amp;nbsp;应用标签不允许这类&amp;nbsp;API&amp;nbsp;|&amp;nbsp;检查应用标签（如买家应用不能调部分订单接口）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;`isv.permission-ip-whitelist-limit`&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;IP&amp;nbsp;不在白名单&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;安全中心加白名单（生产机&amp;nbsp;IP&amp;nbsp;会变，别用动态&amp;nbsp;IP）&amp;nbsp;|
|&amp;nbsp;`isv.permission-api-widget-only-limit`&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;仅允许组件方式访问&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;换调用方式，或换接口&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

把这五个当成同一个「无权限」去调参数，能调一整天。**看到&amp;nbsp;`parameters`&amp;nbsp;类子错误反而是好消息——说明权限是有的，只是参数不对。**

顺便提醒：新创建的应用，默认调用额度通常很低（几十次/天这个量级，具体以后台显示为准），要靠真实使用记录逐步提升。所以别一上来就设计成每天几十万次的全量扫描，额度根本不够，而且申报口径和实际调用对不上，会触发复核。

##&amp;nbsp;五、被拒之后：别重复提交同样的材料

最常见的错误动作是「原样再交一次」。系统有记录，重复提交同样的内容只会延长时间。

正确顺序：

1.&amp;nbsp;**先问清理由**。工单或邮件问「具体是哪一项不满足」，绝大多数会给出方向（资质、场景、业绩证明之类）。
2.&amp;nbsp;**逐条对应补材料**，补的时候在描述里写「针对&amp;nbsp;X&amp;nbsp;项，已补充&amp;nbsp;Y」，让审核员不用重新读一遍。
3.&amp;nbsp;**申诉要证据化**：流程图&amp;nbsp;+&amp;nbsp;界面截图&amp;nbsp;+&amp;nbsp;数据用途说明&amp;nbsp;+&amp;nbsp;不外传承诺。空口说「我们只自己用」没有说服力。
4.&amp;nbsp;**别同时开多个应用刷**。同一主体短期内多个相似应用，会被判定为试探，反而更严。

##&amp;nbsp;六、实在申请不下来，三条可走的路

|&amp;nbsp;路径&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;适合&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;代价&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;---------------&amp;nbsp;|&amp;nbsp;-------------&amp;nbsp;|&amp;nbsp;------------------------------&amp;nbsp;|
|&amp;nbsp;**换平台先跑通**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;只是想验证业务模型&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;京东、1688&amp;nbsp;的资质门槛和权限结构不同，有时这边卡、那边通&amp;nbsp;|
|&amp;nbsp;**走联盟侧接口**（淘宝客）&amp;nbsp;|&amp;nbsp;做导购、比价、选品&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;需联盟备案；只能拿有推广计划的商品；SKU&amp;nbsp;明细常不全&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;**用第三方授权接口服务**&amp;nbsp;&amp;nbsp;|&amp;nbsp;要快速上线、不想碰资质流程&amp;nbsp;|&amp;nbsp;按量付费；关键要问清授权链路和口径&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

**明确不推荐的两条**：借用别人的&amp;nbsp;appkey（违规，出事责任在你）、自建爬虫补数据（法律风险，第&amp;nbsp;12&amp;nbsp;篇算过账）。

##&amp;nbsp;七、申请通过之后，第一件事是跑权限自检

这是全文最实用的部分。很多人拿到&amp;nbsp;appkey&amp;nbsp;就直接写业务代码，结果上线才发现某几个接口没权限，报错还都是一个&amp;nbsp;`Insufficient&amp;nbsp;isv&amp;nbsp;permissions`。

正确做法：**把要用的接口全部探测一遍，按子错误码分类，生成一张权限清单**。哪些能用、哪些要申请、哪些是应用类型不支持（申请也没用）——一次跑清楚。

```python
#&amp;nbsp;probe.py&amp;nbsp;——&amp;nbsp;权限自检：逐接口探测，按子错误码给出动作建议
import&amp;nbsp;asyncio
import&amp;nbsp;time

#&amp;nbsp;子错误码&amp;nbsp;-&amp;gt;&amp;nbsp;动作（顺序敏感：先匹配具体，再匹配泛化）
ACTIONS&amp;nbsp;=&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;isv.permission-api-package-limit&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;去「功能场景」申请该接口所属权限包&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;isv.permission-api-package-empty&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;未关联访问包，按业务申请对应权限包&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;isv.permission-api-package-not-allowed&amp;quot;:&amp;nbsp;&amp;quot;应用标签不允许，检查应用类型/标签&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;isv.permission-ip-whitelist-limit&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;quot;安全中心添加&amp;nbsp;IP&amp;nbsp;白名单&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;isv.permission-api-widget-only-limit&amp;quot;:&amp;nbsp;&amp;quot;仅支持组件方式，改用&amp;nbsp;widget&amp;nbsp;或换接口&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;isv.parameters-error&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;有权限！参数问题，检查必填与格式&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;isv.invalid-signature&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;签名问题，见第&amp;nbsp;14&amp;nbsp;篇的排查工具&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;isv.app-call-limited&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;超配额，降频或提额&amp;quot;,
}


def&amp;nbsp;classify(resp:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;tuple[str,&amp;nbsp;str]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;返回&amp;nbsp;(状态,&amp;nbsp;建议)。成功/无权限/参数错&amp;nbsp;三态要分清&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;&amp;quot;error_response&amp;quot;&amp;nbsp;not&amp;nbsp;in&amp;nbsp;resp:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;&amp;quot;OK&amp;quot;,&amp;nbsp;&amp;quot;可用&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;err&amp;nbsp;=&amp;nbsp;resp[&amp;quot;error_response&amp;quot;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sub&amp;nbsp;=&amp;nbsp;(err.get(&amp;quot;sub_code&amp;quot;)&amp;nbsp;or&amp;nbsp;&amp;quot;&amp;quot;).strip()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;msg&amp;nbsp;=&amp;nbsp;err.get(&amp;quot;sub_msg&amp;quot;)&amp;nbsp;or&amp;nbsp;err.get(&amp;quot;msg&amp;quot;)&amp;nbsp;or&amp;nbsp;&amp;quot;&amp;quot;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;code,&amp;nbsp;action&amp;nbsp;in&amp;nbsp;ACTIONS.items():
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;sub.startswith(code):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;sub,&amp;nbsp;action

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;sub:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;sub,&amp;nbsp;f&amp;quot;未收录子码，原文：{msg}&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;f&amp;quot;code={err.get(&amp;#39;code&amp;#39;)}&amp;quot;,&amp;nbsp;f&amp;quot;无子码，原文：{msg}&amp;quot;


async&amp;nbsp;def&amp;nbsp;probe_one(client,&amp;nbsp;name:&amp;nbsp;str,&amp;nbsp;method:&amp;nbsp;str,&amp;nbsp;params:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;dict:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;用最小合法参数探测一个接口。参数错也说明有权限，这本身就够用了&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;req&amp;nbsp;=&amp;nbsp;{&amp;quot;method&amp;quot;:&amp;nbsp;method,&amp;nbsp;&amp;quot;timestamp&amp;quot;:&amp;nbsp;str(int(time.time())),&amp;nbsp;**params}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;resp&amp;nbsp;=&amp;nbsp;await&amp;nbsp;client.post_taobao(req,&amp;nbsp;timeout=10)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;except&amp;nbsp;Exception&amp;nbsp;as&amp;nbsp;e:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;网络异常不算结论，标记重试
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;{&amp;quot;api&amp;quot;:&amp;nbsp;name,&amp;nbsp;&amp;quot;method&amp;quot;:&amp;nbsp;method,&amp;nbsp;&amp;quot;status&amp;quot;:&amp;nbsp;&amp;quot;NETWORK&amp;quot;,&amp;nbsp;&amp;quot;action&amp;quot;:&amp;nbsp;f&amp;quot;重试：{e}&amp;quot;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;status,&amp;nbsp;action&amp;nbsp;=&amp;nbsp;classify(resp)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;{&amp;quot;api&amp;quot;:&amp;nbsp;name,&amp;nbsp;&amp;quot;method&amp;quot;:&amp;nbsp;method,&amp;nbsp;&amp;quot;status&amp;quot;:&amp;nbsp;status,&amp;nbsp;&amp;quot;action&amp;quot;:&amp;nbsp;action}


async&amp;nbsp;def&amp;nbsp;probe_all(client,&amp;nbsp;targets:&amp;nbsp;list[dict])&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;并发探测并输出&amp;nbsp;markdown&amp;nbsp;表，直接贴进方案文档或工单&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rows&amp;nbsp;=&amp;nbsp;await&amp;nbsp;asyncio.gather(*(probe_one(client,&amp;nbsp;**t)&amp;nbsp;for&amp;nbsp;t&amp;nbsp;in&amp;nbsp;targets))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines&amp;nbsp;=&amp;nbsp;[&amp;quot;|&amp;nbsp;接口&amp;nbsp;|&amp;nbsp;method&amp;nbsp;|&amp;nbsp;状态&amp;nbsp;|&amp;nbsp;建议&amp;nbsp;|&amp;quot;,&amp;nbsp;&amp;quot;|---|---|---|---|&amp;quot;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;r&amp;nbsp;in&amp;nbsp;rows:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines.append(f&amp;quot;|&amp;nbsp;{r[&amp;#39;api&amp;#39;]}&amp;nbsp;|&amp;nbsp;{r[&amp;#39;method&amp;#39;]}&amp;nbsp;|&amp;nbsp;{r[&amp;#39;status&amp;#39;]}&amp;nbsp;|&amp;nbsp;{r[&amp;#39;action&amp;#39;]}&amp;nbsp;|&amp;quot;)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ok&amp;nbsp;=&amp;nbsp;sum(1&amp;nbsp;for&amp;nbsp;r&amp;nbsp;in&amp;nbsp;rows&amp;nbsp;if&amp;nbsp;r[&amp;quot;status&amp;quot;]&amp;nbsp;==&amp;nbsp;&amp;quot;OK&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;limited&amp;nbsp;=&amp;nbsp;sum(1&amp;nbsp;for&amp;nbsp;r&amp;nbsp;in&amp;nbsp;rows&amp;nbsp;if&amp;nbsp;r[&amp;quot;status&amp;quot;].startswith(&amp;quot;isv.permission&amp;quot;))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines.append(&amp;quot;&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines.append(f&amp;quot;结论：可用&amp;nbsp;{ok}&amp;nbsp;/&amp;nbsp;共&amp;nbsp;{len(rows)}；权限相关&amp;nbsp;{limited}&amp;nbsp;个待申请&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;&amp;quot;\n&amp;quot;.join(lines)
#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex

#&amp;nbsp;----&amp;nbsp;用法：列你要用的接口，参数给最小合法值&amp;nbsp;----
TARGETS&amp;nbsp;=&amp;nbsp;[
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;name&amp;quot;:&amp;nbsp;&amp;quot;本店商品详情&amp;quot;,&amp;nbsp;&amp;quot;method&amp;quot;:&amp;nbsp;&amp;quot;taobao.item.seller.get&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;params&amp;quot;:&amp;nbsp;{&amp;quot;fields&amp;quot;:&amp;nbsp;&amp;quot;num_iid,title,price&amp;quot;,&amp;nbsp;&amp;quot;num_iid&amp;quot;:&amp;nbsp;&amp;quot;683625971234&amp;quot;}},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;name&amp;quot;:&amp;nbsp;&amp;quot;商品列表&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;method&amp;quot;:&amp;nbsp;&amp;quot;taobao.items.onsale.get&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;params&amp;quot;:&amp;nbsp;{&amp;quot;fields&amp;quot;:&amp;nbsp;&amp;quot;num_iid,title&amp;quot;,&amp;nbsp;&amp;quot;page_size&amp;quot;:&amp;nbsp;&amp;quot;1&amp;quot;}},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;name&amp;quot;:&amp;nbsp;&amp;quot;库存查询&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;method&amp;quot;:&amp;nbsp;&amp;quot;taobao.item.quantity.get&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;params&amp;quot;:&amp;nbsp;{&amp;quot;fields&amp;quot;:&amp;nbsp;&amp;quot;num_iid,quantity&amp;quot;,&amp;nbsp;&amp;quot;num_iid&amp;quot;:&amp;nbsp;&amp;quot;683625971234&amp;quot;}},
]

if&amp;nbsp;__name__&amp;nbsp;==&amp;nbsp;&amp;quot;__main__&amp;quot;:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(asyncio.run(probe_all(make_client(),&amp;nbsp;TARGETS)))
```

跑完你会得到一张这样的表，直接贴进工单或方案：

```
|&amp;nbsp;接口&amp;nbsp;|&amp;nbsp;method&amp;nbsp;|&amp;nbsp;状态&amp;nbsp;|&amp;nbsp;建议&amp;nbsp;|
|&amp;nbsp;本店商品详情&amp;nbsp;|&amp;nbsp;taobao.item.seller.get&amp;nbsp;|&amp;nbsp;OK&amp;nbsp;|&amp;nbsp;可用&amp;nbsp;|
|&amp;nbsp;商品列表&amp;nbsp;|&amp;nbsp;taobao.items.onsale.get&amp;nbsp;|&amp;nbsp;isv.permission-api-package-limit&amp;nbsp;|&amp;nbsp;去「功能场景」申请该接口所属权限包&amp;nbsp;|
|&amp;nbsp;库存查询&amp;nbsp;|&amp;nbsp;taobao.item.quantity.get&amp;nbsp;|&amp;nbsp;isv.parameters-error&amp;nbsp;|&amp;nbsp;有权限！参数问题，检查必填与格式&amp;nbsp;|
```

三个设计要点：

1.&amp;nbsp;**用最小合法参数探测**。参数错没关系——`isv.parameters-error`&amp;nbsp;已经证明你有权限了，这比构造完美请求省事得多。
2.&amp;nbsp;**先匹配具体子码，再匹配泛化**。`api-package-*`&amp;nbsp;有一串，用&amp;nbsp;`startswith`&amp;nbsp;保留全貌，未收录的码原样打出来，方便你回头补映射表。
3.&amp;nbsp;**区分网络异常和权限结论**。超时不是「没权限」，别把它写进结论表误导自己。

##&amp;nbsp;八、一句话收尾

淘宝的接口审核，本质是**场景审核**，不是技术审核。你写的是给审核员看的业务流程，不是给机器看的调用链。

材料按「我是谁、我用哪种应用、我拿数据干什么、数据不出我系统」这四句写清楚，附上图和截图，通过率会明显不一样。

代码层面，申请下来之后**先跑权限自检，再写业务逻辑**——把「无权限」和「参数错」分开，能省掉至少半天。

---

*如果资质流程实在走不通，又急着上线，可以先用我们的三平台接口服务过渡：不需要你申请应用、不需要企业资质，按量付费，字段统一。等自建权限下来再切回去，代码改动很小。评论区或私信聊。*&lt;/pre&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;</description><pubDate>Sat, 31 Oct 2026 16:31:33 +0800</pubDate></item><item><title>想做多渠道比价系统，数据源到底该从哪来</title><link>https://www.alexob.com/?id=593</link><description>&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;这问题比“用哪个接口”更靠前一层。先给结论：**比价系统的数据源不该只选一个，而应按用途分三层**。只选一个，必然在覆盖和精度之间二选一。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;## 五类数据源的真实差异&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;| 数据源 | 接入门槛 | 覆盖范围 | 字段深度 | 合规 | 成本 |&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;|---|---|---|---|---|---|&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;| **平台开放平台 API** | 最高：企业资质、应用审核、类目权限 | 只到**你有权限的店铺、商品** | 最全：SKU、库存、活动价 | 干净 | 免费，但配额低 |&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;| **联盟、导购 API**（淘宝客、京东联盟、多多进宝） | 低：备案、推广位 | 全站，但**仅有联盟计划的商品** | 中等：标价、券、佣金，SKU 常不全 | 干净 | 免费 |&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;| **第三方聚合服务** | 无 | 广，取决于服务商 | 归一后的字段，看服务商能力 | 取决于其授权链路 | 按量付费 |&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;| **自建爬虫** | 低 | 最广：券后价、库存、评价都能拿到 | 最深 | **风险最高** | 维护成本最贵 |&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;| 人工、插件、众包 | 无 | 长尾 | 任意 | 灰色 | 人力 |&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;两个关键事实要强调。具体权限以平台当期文档为准，这里说明规律：&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;- **开放平台 API 做不了全站比价。** 商品接口基本只对你授权范围内的商品开放；想查全站，得走联盟侧接口，但联盟接口的 SKU 明细和库存往往拿不全。所以，“自接官方 API 就能比价”这条路，只对自家店铺成立。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;- **爬虫不是技术问题，是法律问题。** 第 285 条和反不正当竞争法都有真实判例，商用对外做 SaaS 时，这属于硬性排除条件，不能当作可权衡的风险。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;## 三种用途，三条路线&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;**① 内部选品、定价决策**（几十到几千个 SKU）&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;联盟 API 作为基础，拿不到的用第三方聚合补。这个规模完全不值得自建，一年几千块的调用量就够了。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;**② 对外提供比价服务**（多租户 SaaS，商用）&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;只能走联盟、聚合两条路，爬虫直接排除。这时你卖的不是数据，是 SLA 和授权链路，选型标准也随之改变。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;**③ 大规模全站比价**（十万级 SKU 以上）&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;三层混合：官方 API 精修重点商品，联盟、聚合覆盖全站候选，人工补长尾。理由很实际：全量高频扫会耗尽配额（我们算过，2 万商品按 30 分钟扫一遍是 96 万次/天），两级池收敛后能降到 6,900 次。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;## 选服务商要问的五件事&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;这五条也能直接拿去问任何一家数据商，包括我们：&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;1. **授权链路是什么？** 是直连官方授权，还是二次倒卖？后者说断就断。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;2. **到手价怎么定义？** 标价、券后价、满减后价、会员价、含不含运费，这是比价系统的关键问题，也是最容易被含糊过去的一条。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;3. **更新频率是多少？** 分钟级、小时级、天级，直接决定你的系统能不能叫“实时”。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;4. **限流按什么维度？** 接口×应用，还是全局共享？全局桶会让铺货任务把比价任务的额度耗光。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;5. **有没有历史数据？** 要做价格趋势和竞品动作分析，没有回溯能力的服务商直接排除。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;## 但数据源只解决 30%&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;剩下 70% 是这三件事，很多人拿到数据后才发现：&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;- **同款对齐**：同一个保温杯在三个平台叫三个名字，纯标题匹配误判率约 40%。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;- **价格口径归一**：页面价 99、券后 79、会员 75、加运费 85，你的系统报哪个？不统一口径的比价结果没人敢用。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;- **时效与配额**：全量高频会耗尽配额，低频又不叫实时。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;如果现在就要定方案，我按最常见的情况给个起步配置：&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;**先别急着接官方开放平台。** 资质周期常以周计（我们见过的案例里，两三个月很常见），而它只能覆盖你自己的店，做比价根本不够。起步阶段更实际的做法是：先用第三方聚合把覆盖层跑起来，重点商品再用官方 API 精修，长尾和特殊字段先人工。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;**然后立刻定口径。** 在写第一行代码之前，先把“到手价 = 标价 − 店铺券 − 平台券 + 运费（会员价单列）”写成一份书面定义。这件事比选数据源重要得多，因为它决定你的比价结果有没有人敢用。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;顺带一句：这个问题客户问得特别多，你手上这套“三层数据源 + 三个真难点 + 选服务商五问”，其实可以直接当销售话术用。尤其第三问（口径）和第五问（历史数据），很少有人在客户面前主动提，提了反而更容易建立信任。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;如果你愿意，我可以把它整理成两个东西：一篇发帖（“多渠道比价系统的数据源怎么选”，正好接着第 16 篇那条决策线），或者一页客户咨询话术。你说一声即可。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;&lt;br/&gt;&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span style=&quot;text-wrap-mode: nowrap;&quot;&gt;另外想确认一下你的场景，好给更准的建议：这套系统是**自己内部选品用**，还是要**对外卖给商家**？两者在合规边界和 SLA 要求上差得很远。&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;</description><pubDate>Sat, 31 Oct 2026 15:38:48 +0800</pubDate></item><item><title>1688 选品 + 淘宝比价 + 京东竞品监控，一条数据流跑起来（附 python 源码）</title><link>https://www.alexob.com/?id=592</link><description>&lt;pre class=&quot;prism-highlight prism-language-python&quot;&gt;#&amp;nbsp;1688&amp;nbsp;选品&amp;nbsp;+&amp;nbsp;淘宝比价&amp;nbsp;+&amp;nbsp;京东竞品监控，一条数据流跑起来（附&amp;nbsp;python&amp;nbsp;源码）

&amp;gt;&amp;nbsp;前面写了很多篇怎么把数据拿下来：签名、限流、稳定性、字段对齐。但拿下来之后呢？
&amp;gt;
&amp;gt;&amp;nbsp;数据本身不产生价值。**用三平台的数据回答三个问题**才产生价值：这个货最低多少钱能拿到（1688）、同款现在卖多少钱（淘宝）、对手在做什么动作（京东）。
&amp;gt;
&amp;gt;&amp;nbsp;这篇把三个问题串成一条流，从采集到输出一张「能不能做」的清单。中间最难的环节不是采集——是**同款识别**。三个平台对同一个商品的描述方式完全不同，认错了，后面全错。

##&amp;nbsp;一、先说清这条流要输出什么

技术方案要服务于一个具体产物。我们做这条流，最后只输出一张表，每天早上推到企业微信：

|&amp;nbsp;商品&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;1688&amp;nbsp;拿货价&amp;nbsp;|&amp;nbsp;起订量&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;淘宝价格带（P25/中位/P75）&amp;nbsp;|&amp;nbsp;京东在售竞品&amp;nbsp;|&amp;nbsp;预估净利率&amp;nbsp;|&amp;nbsp;退货率红线&amp;nbsp;|&amp;nbsp;建议&amp;nbsp;|
|&amp;nbsp;------------&amp;nbsp;|&amp;nbsp;--------&amp;nbsp;|&amp;nbsp;------&amp;nbsp;|&amp;nbsp;-----------------&amp;nbsp;|&amp;nbsp;------&amp;nbsp;|&amp;nbsp;-----&amp;nbsp;|&amp;nbsp;-----&amp;nbsp;|&amp;nbsp;--&amp;nbsp;|
|&amp;nbsp;不锈钢保温杯&amp;nbsp;500ml&amp;nbsp;|&amp;nbsp;28.0&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;2&amp;nbsp;件代发&amp;nbsp;&amp;nbsp;|&amp;nbsp;49&amp;nbsp;/&amp;nbsp;65&amp;nbsp;/&amp;nbsp;89&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;7&amp;nbsp;家&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;28.8%&amp;nbsp;|&amp;nbsp;125%&amp;nbsp;&amp;nbsp;|&amp;nbsp;可做&amp;nbsp;|
|&amp;nbsp;硅胶折叠水壶&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;41.5&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;50&amp;nbsp;件起批&amp;nbsp;|&amp;nbsp;39&amp;nbsp;/&amp;nbsp;45&amp;nbsp;/&amp;nbsp;52&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;12&amp;nbsp;家&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;-2.1%&amp;nbsp;|&amp;nbsp;—&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;放弃&amp;nbsp;|
|&amp;nbsp;玻璃冷水壶&amp;nbsp;1.2L&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;19.8&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;5&amp;nbsp;件代发&amp;nbsp;&amp;nbsp;|&amp;nbsp;45&amp;nbsp;/&amp;nbsp;58&amp;nbsp;/&amp;nbsp;79&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;3&amp;nbsp;家&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;31.5%&amp;nbsp;|&amp;nbsp;88%&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;重点&amp;nbsp;|

这张表的四列数据来源不同、更新频率不同，但**必须在同一行上对齐**——这是同款识别要解决的问题。

三段的职责：

-&amp;nbsp;**1688（货源）**：成本价、起订量、是否支持一件代发。日级更新就够，价格不常动。
-&amp;nbsp;**淘宝（比价）**：不是找最低价，是找**价格带**。最低价往往是不能做的货（亏本引流、规格不同、二手翻新）。
-&amp;nbsp;**京东（竞品）**：不是看有多少竞品，是看竞品**做了什么动作**——降价、上新、断货。

##&amp;nbsp;二、最难的环节：同款识别

同一个保温杯，三个平台可能这样写：

```
淘宝：&amp;nbsp;【爆款】316不锈钢保温杯男女士大容量水杯便携&amp;nbsp;500ml&amp;nbsp;送礼定制
京东：&amp;nbsp;富光保温杯&amp;nbsp;316不锈钢&amp;nbsp;500ml&amp;nbsp;白色&amp;nbsp;FGL-500
1688：&amp;nbsp;厂家直销&amp;nbsp;316不锈钢真空保温杯&amp;nbsp;500ml&amp;nbsp;定制LOGO&amp;nbsp;2件起批
```

纯标题匹配会把这当成三个完全不同的商品——关键词几乎没有一个完全重合。而实际上它们就是同一个货。

###&amp;nbsp;三级匹配：类目&amp;nbsp;→&amp;nbsp;标题&amp;nbsp;→&amp;nbsp;主图

我们的做法是三道筛子，逐级加严：

|&amp;nbsp;级别&amp;nbsp;|&amp;nbsp;手段&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;作用&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;成本&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;--&amp;nbsp;|&amp;nbsp;--------------------&amp;nbsp;|&amp;nbsp;------------&amp;nbsp;|&amp;nbsp;------&amp;nbsp;|
|&amp;nbsp;一级&amp;nbsp;|&amp;nbsp;叶子类目&amp;nbsp;+&amp;nbsp;价格量级&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;粗筛，砍掉&amp;nbsp;90%&amp;nbsp;噪音&amp;nbsp;|&amp;nbsp;极低&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;二级&amp;nbsp;|&amp;nbsp;标题归一&amp;nbsp;+&amp;nbsp;字符&amp;nbsp;2-gram&amp;nbsp;相似度&amp;nbsp;|&amp;nbsp;找候选&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;低&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;三级&amp;nbsp;|&amp;nbsp;主图&amp;nbsp;dHash&amp;nbsp;汉明距离&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;定案&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;需要下载图片&amp;nbsp;|

实测数据：**纯标题匹配的误判率约&amp;nbsp;40%，加上主图后降到&amp;nbsp;8%**。所以主图那一步不能省——图片是唯一跨平台稳定的特征，同一张图在三个平台都会出现（哪怕是供应商给的白底图）。

###&amp;nbsp;标题归一：去营销词，但必须保留规格词

这一步的规则是**反直觉的**：营销词要全部删掉（它们纯属噪音），但规格词必须留下并归一化——`500ml`、`0.5L`、`500毫升`&amp;nbsp;是同一个东西，而&amp;nbsp;`500ml`&amp;nbsp;和&amp;nbsp;`750ml`&amp;nbsp;是两个不同的货，价格能差&amp;nbsp;30%。

```python
#&amp;nbsp;matching.py&amp;nbsp;——&amp;nbsp;标题归一：去噪音，保留并统一规格
import&amp;nbsp;re

NOISE&amp;nbsp;=&amp;nbsp;[&amp;quot;包邮&amp;quot;,&amp;nbsp;&amp;quot;爆款&amp;quot;,&amp;nbsp;&amp;quot;新款&amp;quot;,&amp;nbsp;&amp;quot;正品&amp;quot;,&amp;nbsp;&amp;quot;厂家直销&amp;quot;,&amp;nbsp;&amp;quot;一件代发&amp;quot;,&amp;nbsp;&amp;quot;买一送一&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;限时&amp;quot;,&amp;nbsp;&amp;quot;特价&amp;quot;,&amp;nbsp;&amp;quot;热销&amp;quot;,&amp;nbsp;&amp;quot;网红&amp;quot;,&amp;nbsp;&amp;quot;同款&amp;quot;,&amp;nbsp;&amp;quot;批发&amp;quot;,&amp;nbsp;&amp;quot;定制&amp;quot;,&amp;nbsp;&amp;quot;送礼&amp;quot;,&amp;nbsp;&amp;quot;便携&amp;quot;]

UNIT&amp;nbsp;=&amp;nbsp;{&amp;quot;ml&amp;quot;:&amp;nbsp;(&amp;quot;ml&amp;quot;,&amp;nbsp;1),&amp;nbsp;&amp;quot;毫升&amp;quot;:&amp;nbsp;(&amp;quot;ml&amp;quot;,&amp;nbsp;1),&amp;nbsp;&amp;quot;l&amp;quot;:&amp;nbsp;(&amp;quot;ml&amp;quot;,&amp;nbsp;1000),&amp;nbsp;&amp;quot;升&amp;quot;:&amp;nbsp;(&amp;quot;ml&amp;quot;,&amp;nbsp;1000),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;g&amp;quot;:&amp;nbsp;(&amp;quot;g&amp;quot;,&amp;nbsp;1),&amp;nbsp;&amp;quot;克&amp;quot;:&amp;nbsp;(&amp;quot;g&amp;quot;,&amp;nbsp;1),&amp;nbsp;&amp;quot;kg&amp;quot;:&amp;nbsp;(&amp;quot;g&amp;quot;,&amp;nbsp;1000),&amp;nbsp;&amp;quot;千克&amp;quot;:&amp;nbsp;(&amp;quot;g&amp;quot;,&amp;nbsp;1000)}

SPEC_RE&amp;nbsp;=&amp;nbsp;re.compile(r&amp;quot;(\d+(?:\.\d+)?)\s*(ml|毫升|l|升|g|克|kg|千克)&amp;quot;)


def&amp;nbsp;normalize_title(title:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;t&amp;nbsp;=&amp;nbsp;title.lower()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;w&amp;nbsp;in&amp;nbsp;NOISE:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;t&amp;nbsp;=&amp;nbsp;t.replace(w,&amp;nbsp;&amp;quot;&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;t&amp;nbsp;=&amp;nbsp;re.sub(r&amp;quot;[【】\[\]（）()《》\s\-_/|,，、!！?？+*~]+&amp;quot;,&amp;nbsp;&amp;quot;&amp;nbsp;&amp;quot;,&amp;nbsp;t)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;repl(m):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;num&amp;nbsp;=&amp;nbsp;float(m.group(1))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;unit,&amp;nbsp;factor&amp;nbsp;=&amp;nbsp;UNIT[m.group(2)]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;f&amp;quot;&amp;nbsp;{int(num&amp;nbsp;*&amp;nbsp;factor)}{unit}&amp;nbsp;&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;0.5l&amp;nbsp;/&amp;nbsp;500毫升&amp;nbsp;-&amp;gt;&amp;nbsp;500ml

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;&amp;quot;&amp;nbsp;&amp;quot;.join(SPEC_RE.sub(repl,&amp;nbsp;t).split())
```

跑一下上面三个标题，归一后是这样：

```
不锈钢保温杯男女士大容量水杯&amp;nbsp;500ml
富光保温杯不锈钢&amp;nbsp;500ml白色&amp;nbsp;fgl-500
不锈钢真空保温杯&amp;nbsp;500ml&amp;nbsp;logo
```

现在「保温杯」和「500ml」都浮出来了，2-gram&amp;nbsp;相似度就能算得比较准。

###&amp;nbsp;相似度：中文用字符&amp;nbsp;2-gram，不要用空格分词

中文商品标题没有空格分词，用&amp;nbsp;`split()`&amp;nbsp;分出来的往往是一整个长串。字符&amp;nbsp;2-gram&amp;nbsp;简单粗暴但效果好，而且零依赖：

```python
def&amp;nbsp;bigrams(s:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;set[str]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;s&amp;nbsp;=&amp;nbsp;re.sub(r&amp;quot;\s+&amp;quot;,&amp;nbsp;&amp;quot;&amp;quot;,&amp;nbsp;s)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;{s[i:i&amp;nbsp;+&amp;nbsp;2]&amp;nbsp;for&amp;nbsp;i&amp;nbsp;in&amp;nbsp;range(len(s)&amp;nbsp;-&amp;nbsp;1)}&amp;nbsp;if&amp;nbsp;len(s)&amp;nbsp;&amp;gt;&amp;nbsp;1&amp;nbsp;else&amp;nbsp;{s}

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
def&amp;nbsp;jaccard(a:&amp;nbsp;str,&amp;nbsp;b:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;float:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;A,&amp;nbsp;B&amp;nbsp;=&amp;nbsp;bigrams(a),&amp;nbsp;bigrams(b)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;len(A&amp;nbsp;&amp;amp;&amp;nbsp;B)&amp;nbsp;/&amp;nbsp;len(A&amp;nbsp;|&amp;nbsp;B)&amp;nbsp;if&amp;nbsp;A&amp;nbsp;|&amp;nbsp;B&amp;nbsp;else&amp;nbsp;0.0
```

###&amp;nbsp;主图&amp;nbsp;dHash：64&amp;nbsp;位指纹，汉明距离&amp;nbsp;≤10&amp;nbsp;判相似

dHash&amp;nbsp;的思路是把图缩到&amp;nbsp;9×8&amp;nbsp;灰度，逐像素和右边邻居比较，得到一个&amp;nbsp;64&amp;nbsp;位整数。它只关心**明暗结构的梯度**，所以对缩放、轻微裁剪、压缩都不敏感——正好适合跨平台的主图。

```python
#&amp;nbsp;image_hash.py
from&amp;nbsp;PIL&amp;nbsp;import&amp;nbsp;Image


def&amp;nbsp;dhash(path:&amp;nbsp;str,&amp;nbsp;size:&amp;nbsp;int&amp;nbsp;=&amp;nbsp;8)&amp;nbsp;-&amp;gt;&amp;nbsp;int:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;img&amp;nbsp;=&amp;nbsp;Image.open(path).convert(&amp;quot;L&amp;quot;).resize((size&amp;nbsp;+&amp;nbsp;1,&amp;nbsp;size),&amp;nbsp;Image.LANCZOS)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;px&amp;nbsp;=&amp;nbsp;list(img.getdata())
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;bits&amp;nbsp;=&amp;nbsp;0
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;row&amp;nbsp;in&amp;nbsp;range(size):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;col&amp;nbsp;in&amp;nbsp;range(size):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;i&amp;nbsp;=&amp;nbsp;row&amp;nbsp;*&amp;nbsp;(size&amp;nbsp;+&amp;nbsp;1)&amp;nbsp;+&amp;nbsp;col
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;bits&amp;nbsp;=&amp;nbsp;(bits&amp;nbsp;&amp;lt;&amp;lt;&amp;nbsp;1)&amp;nbsp;|&amp;nbsp;(1&amp;nbsp;if&amp;nbsp;px[i]&amp;nbsp;&amp;gt;&amp;nbsp;px[i&amp;nbsp;+&amp;nbsp;1]&amp;nbsp;else&amp;nbsp;0)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;bits


def&amp;nbsp;hamming(a:&amp;nbsp;int,&amp;nbsp;b:&amp;nbsp;int)&amp;nbsp;-&amp;gt;&amp;nbsp;int:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;bin(a&amp;nbsp;^&amp;nbsp;b).count(&amp;quot;1&amp;quot;)
```

`hamming&amp;nbsp;≤&amp;nbsp;10`&amp;nbsp;判为相似（64&amp;nbsp;位里最多差&amp;nbsp;10&amp;nbsp;位）。这个阈值我们是拿&amp;nbsp;500&amp;nbsp;对人工标注样本调出来的，你可以按自己的品类重调——**服装类要放宽到&amp;nbsp;14**，因为模特姿势和背景差异更大。

###&amp;nbsp;组合打分：权重和阈值都要保守

```python
from&amp;nbsp;decimal&amp;nbsp;import&amp;nbsp;Decimal

W&amp;nbsp;=&amp;nbsp;{&amp;quot;title&amp;quot;:&amp;nbsp;0.55,&amp;nbsp;&amp;quot;image&amp;quot;:&amp;nbsp;0.35,&amp;nbsp;&amp;quot;price&amp;quot;:&amp;nbsp;0.10}


def&amp;nbsp;price_plausible(cost:&amp;nbsp;Decimal,&amp;nbsp;retail:&amp;nbsp;Decimal)&amp;nbsp;-&amp;gt;&amp;nbsp;float:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;成本价与售价的倍数关系：正常&amp;nbsp;1.5~5&amp;nbsp;倍；&amp;lt;1&amp;nbsp;或&amp;nbsp;&amp;gt;8&amp;nbsp;基本是不同货或不同规格&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;cost&amp;nbsp;&amp;lt;=&amp;nbsp;0&amp;nbsp;or&amp;nbsp;retail&amp;nbsp;&amp;lt;=&amp;nbsp;0:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;0.0
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ratio&amp;nbsp;=&amp;nbsp;float(retail&amp;nbsp;/&amp;nbsp;cost)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;ratio&amp;nbsp;&amp;lt;&amp;nbsp;1.0&amp;nbsp;or&amp;nbsp;ratio&amp;nbsp;&amp;gt;&amp;nbsp;8.0:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;0.0
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;1.0


def&amp;nbsp;match_score(title_sim:&amp;nbsp;float,&amp;nbsp;image_sim:&amp;nbsp;float,&amp;nbsp;price_score:&amp;nbsp;float)&amp;nbsp;-&amp;gt;&amp;nbsp;float:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;image_sim:&amp;nbsp;1&amp;nbsp;-&amp;nbsp;hamming/64；无图时传&amp;nbsp;None&amp;nbsp;走保守降权&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;image_sim&amp;nbsp;is&amp;nbsp;None:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;没图不能直接当&amp;nbsp;0&amp;nbsp;分，按标题相似度打折，且总分封顶&amp;nbsp;0.7（进人工复核）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;min(title_sim&amp;nbsp;*&amp;nbsp;0.9,&amp;nbsp;0.7)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;(W[&amp;quot;title&amp;quot;]&amp;nbsp;*&amp;nbsp;title_sim&amp;nbsp;+&amp;nbsp;W[&amp;quot;image&amp;quot;]&amp;nbsp;*&amp;nbsp;image_sim
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;+&amp;nbsp;W[&amp;quot;price&amp;quot;]&amp;nbsp;*&amp;nbsp;price_score)


def&amp;nbsp;is_same(score:&amp;nbsp;float,&amp;nbsp;threshold:&amp;nbsp;float&amp;nbsp;=&amp;nbsp;0.72)&amp;nbsp;-&amp;gt;&amp;nbsp;bool:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;score&amp;nbsp;&amp;gt;=&amp;nbsp;threshold
```

**阈值偏保守是有意为之。**&amp;nbsp;漏匹配的代价是少一个候选，人工还能补；误匹配的代价是按错的价格算利润，最后备货备错——后者贵得多。所以我们把&amp;nbsp;0.6~0.72&amp;nbsp;这段叫「灰区」，全部丢进人工复核队列，一天几十条，运营顺手就过了。

##&amp;nbsp;三、利润模型：把隐性成本算进去，否则算出来的都是幻觉

选品最容易犯的错是「售价减拿货价，毛利&amp;nbsp;40%，能做」。真实情况是：**毛利&amp;nbsp;36%&amp;nbsp;的大件，退货率&amp;nbsp;25%&amp;nbsp;的时候净利率只剩&amp;nbsp;10.7%**，再扣人工仓储就是负的。

四个必须进模型的隐性成本：

1.&amp;nbsp;**平台佣金**（约&amp;nbsp;5%，各平台各品类不同）
2.&amp;nbsp;**推广费**（新店起量期&amp;nbsp;10~20%，成熟期&amp;nbsp;5%）
3.&amp;nbsp;**退货成本**——不只是退钱，是「发货运费&amp;nbsp;+&amp;nbsp;回程运费&amp;nbsp;+&amp;nbsp;包装折损&amp;nbsp;+&amp;nbsp;商品可能无法二次销售」
4.&amp;nbsp;**资金占用**——批量拿货便宜&amp;nbsp;20%，但货压在仓里，两个月卖不掉就是负现金流

```python
#&amp;nbsp;profit.py
from&amp;nbsp;dataclasses&amp;nbsp;import&amp;nbsp;dataclass
from&amp;nbsp;decimal&amp;nbsp;import&amp;nbsp;Decimal


@dataclass
class&amp;nbsp;CostModel:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;price:&amp;nbsp;Decimal&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;售价
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;supply:&amp;nbsp;Decimal&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;1688&amp;nbsp;拿货价
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ship_out:&amp;nbsp;Decimal&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;发货运费
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ship_back:&amp;nbsp;Decimal&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;退货回程运费
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;pack_loss:&amp;nbsp;Decimal&amp;nbsp;=&amp;nbsp;Decimal(&amp;quot;3&amp;quot;)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;退货包装/折损
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;commission_rate:&amp;nbsp;Decimal&amp;nbsp;=&amp;nbsp;Decimal(&amp;quot;0.05&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ad_rate:&amp;nbsp;Decimal&amp;nbsp;=&amp;nbsp;Decimal(&amp;quot;0.10&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return_rate:&amp;nbsp;Decimal&amp;nbsp;=&amp;nbsp;Decimal(&amp;quot;0.15&amp;quot;)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;breakdown(self)&amp;nbsp;-&amp;gt;&amp;nbsp;dict:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;gross&amp;nbsp;=&amp;nbsp;self.price&amp;nbsp;-&amp;nbsp;self.supply&amp;nbsp;-&amp;nbsp;self.ship_out
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;commission&amp;nbsp;=&amp;nbsp;self.price&amp;nbsp;*&amp;nbsp;self.commission_rate
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ad&amp;nbsp;=&amp;nbsp;self.price&amp;nbsp;*&amp;nbsp;self.ad_rate
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;per_return&amp;nbsp;=&amp;nbsp;self.ship_out&amp;nbsp;+&amp;nbsp;self.ship_back&amp;nbsp;+&amp;nbsp;self.pack_loss
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return_loss&amp;nbsp;=&amp;nbsp;self.return_rate&amp;nbsp;*&amp;nbsp;per_return
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;net&amp;nbsp;=&amp;nbsp;gross&amp;nbsp;-&amp;nbsp;commission&amp;nbsp;-&amp;nbsp;ad&amp;nbsp;-&amp;nbsp;return_loss
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;毛利&amp;quot;:&amp;nbsp;gross,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;佣金&amp;quot;:&amp;nbsp;commission,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;推广&amp;quot;:&amp;nbsp;ad,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;退货损失&amp;quot;:&amp;nbsp;return_loss,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;净利&amp;quot;:&amp;nbsp;net,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;净利率&amp;quot;:&amp;nbsp;net&amp;nbsp;/&amp;nbsp;self.price&amp;nbsp;if&amp;nbsp;self.price&amp;nbsp;else&amp;nbsp;Decimal(&amp;quot;0&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;breakeven_return_rate(self)&amp;nbsp;-&amp;gt;&amp;nbsp;Decimal:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;退货率红线：净利归零时的退货率。
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;选品时看这个数比看净利率有用——它告诉你「这个货能承受多差的售后」
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;gross&amp;nbsp;=&amp;nbsp;self.price&amp;nbsp;-&amp;nbsp;self.supply&amp;nbsp;-&amp;nbsp;self.ship_out
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;after_fixed&amp;nbsp;=&amp;nbsp;gross&amp;nbsp;-&amp;nbsp;self.price&amp;nbsp;*&amp;nbsp;(self.commission_rate&amp;nbsp;+&amp;nbsp;self.ad_rate)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;per_return&amp;nbsp;=&amp;nbsp;self.ship_out&amp;nbsp;+&amp;nbsp;self.ship_back&amp;nbsp;+&amp;nbsp;self.pack_loss
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;per_return&amp;nbsp;&amp;lt;=&amp;nbsp;0:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Decimal(&amp;quot;1&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;max(Decimal(&amp;quot;0&amp;quot;),&amp;nbsp;after_fixed&amp;nbsp;/&amp;nbsp;per_return)
```

拿三个真实的品算一遍，都是我们做过或评估过的（费率统一按佣金&amp;nbsp;5%、推广&amp;nbsp;10%）：

```
【保温杯】售价&amp;nbsp;65，拿货&amp;nbsp;28，发&amp;nbsp;6，回&amp;nbsp;8，折损&amp;nbsp;3，退货率&amp;nbsp;15%
毛利&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;31.00&amp;nbsp;&amp;nbsp;&amp;nbsp;(47.7%)
佣金+推广&amp;nbsp;&amp;nbsp;9.75
退货损失&amp;nbsp;&amp;nbsp;&amp;nbsp;2.55&amp;nbsp;&amp;nbsp;&amp;nbsp;(15%&amp;nbsp;×&amp;nbsp;17)
净利&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;18.70&amp;nbsp;&amp;nbsp;&amp;nbsp;(28.8%)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;退货率红线&amp;nbsp;125%&amp;nbsp;→&amp;nbsp;健康

【服装】售价&amp;nbsp;129，拿货&amp;nbsp;45，发&amp;nbsp;5，回&amp;nbsp;9，折损&amp;nbsp;8，退货率&amp;nbsp;40%
毛利&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;79.00&amp;nbsp;&amp;nbsp;&amp;nbsp;(61.2%)
佣金+推广&amp;nbsp;19.35
退货损失&amp;nbsp;&amp;nbsp;&amp;nbsp;8.80&amp;nbsp;&amp;nbsp;&amp;nbsp;(40%&amp;nbsp;×&amp;nbsp;22)
净利&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;50.85&amp;nbsp;&amp;nbsp;&amp;nbsp;(39.4%)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;退货率红线&amp;nbsp;271%&amp;nbsp;→&amp;nbsp;扛得住

【大件家居】售价&amp;nbsp;599，拿货&amp;nbsp;320，发&amp;nbsp;60，回&amp;nbsp;120，折损&amp;nbsp;80，退货率&amp;nbsp;25%
毛利&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;219.00&amp;nbsp;&amp;nbsp;&amp;nbsp;(36.6%)
佣金+推广&amp;nbsp;89.85
退货损失&amp;nbsp;&amp;nbsp;65.00&amp;nbsp;&amp;nbsp;&amp;nbsp;(25%&amp;nbsp;×&amp;nbsp;260)
净利&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;64.15&amp;nbsp;&amp;nbsp;&amp;nbsp;(10.7%)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;退货率红线&amp;nbsp;49.7%&amp;nbsp;→&amp;nbsp;危险
```

第三个才是真正的杀手。**它单件退货成本&amp;nbsp;260&amp;nbsp;元，比毛利&amp;nbsp;219&amp;nbsp;元还高**——退一单等于白卖一单还倒贴。所以退货率红线只有&amp;nbsp;50%，而大件的行业退货率经常就在&amp;nbsp;30%&amp;nbsp;上下，稍有波动就到线。

注意这三个品的**毛利率排序和净利率排序是反的**：服装毛利&amp;nbsp;61%&amp;nbsp;但净利&amp;nbsp;39%，大件毛利&amp;nbsp;37%&amp;nbsp;净利只剩&amp;nbsp;11%。只看「毛利率」选品，会系统性地偏向高毛利品类，而高毛利品类往往正是退货成本最高的。

所以&amp;nbsp;`breakeven_return_rate()`&amp;nbsp;的真正价值不是「算红线」，而是**逼你把退货率填对**。我们内部的做法：新品按类目历史退货率的&amp;nbsp;1.2&amp;nbsp;倍填，换供应商时重算，大件类目额外单列「退货物流成本」字段——因为它的运费可能比货值还高。

##&amp;nbsp;四、竞品监控：监控「变化」，不是监控「数据」

这是另一个常见的浪费：写个脚本每天把竞品价格、销量、评价数存一遍，看板上堆满数字，然后没人看。

**人要的不是数据，是「发生了什么」。**&amp;nbsp;所以我们只输出五类事件：

|&amp;nbsp;事件&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;触发条件&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;业务含义&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;------&amp;nbsp;|&amp;nbsp;----------------------------&amp;nbsp;|&amp;nbsp;----------------&amp;nbsp;|
|&amp;nbsp;降价&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;幅度&amp;nbsp;≥3%&amp;nbsp;**且**&amp;nbsp;超过该商品&amp;nbsp;30&amp;nbsp;天波动&amp;nbsp;3σ&amp;nbsp;|&amp;nbsp;对手在抢量，可能要跟&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;涨价&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;同上（反向）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;对手在提利润，是机会窗口&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;SKU&amp;nbsp;减少&amp;nbsp;|&amp;nbsp;SKU&amp;nbsp;集合出现消失项&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;对手断货或砍款，可能是我们的机会&amp;nbsp;|
|&amp;nbsp;断货&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;`in_stock`&amp;nbsp;由&amp;nbsp;true&amp;nbsp;变&amp;nbsp;false&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;立刻可抢排名&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;评价增速异常&amp;nbsp;|&amp;nbsp;日增评价数的&amp;nbsp;z-score&amp;nbsp;&amp;gt;&amp;nbsp;3&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;对手在冲量（刷单或大促）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

关键在**阈值要用该商品自己的历史波动做基线**，不能拍脑袋定&amp;nbsp;3%：

```python
#&amp;nbsp;monitor.py
from&amp;nbsp;decimal&amp;nbsp;import&amp;nbsp;Decimal


def&amp;nbsp;detect_events(prev:&amp;nbsp;dict,&amp;nbsp;cur:&amp;nbsp;dict,&amp;nbsp;sigma:&amp;nbsp;Decimal)&amp;nbsp;-&amp;gt;&amp;nbsp;list[tuple[str,&amp;nbsp;dict]]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;prev/cur:&amp;nbsp;{&amp;quot;price&amp;quot;,&amp;nbsp;&amp;quot;in_stock&amp;quot;,&amp;nbsp;&amp;quot;sku_ids&amp;quot;,&amp;nbsp;&amp;quot;review_count&amp;quot;}&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;events&amp;nbsp;=&amp;nbsp;[]

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;价格：幅度门槛&amp;nbsp;+&amp;nbsp;基线门槛，双条件才报，避免&amp;nbsp;99&amp;nbsp;-&amp;gt;&amp;nbsp;98.9&amp;nbsp;这种噪音
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;prev[&amp;quot;price&amp;quot;]&amp;nbsp;and&amp;nbsp;cur[&amp;quot;price&amp;quot;]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;delta&amp;nbsp;=&amp;nbsp;(cur[&amp;quot;price&amp;quot;]&amp;nbsp;-&amp;nbsp;prev[&amp;quot;price&amp;quot;])&amp;nbsp;/&amp;nbsp;prev[&amp;quot;price&amp;quot;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;abs(delta)&amp;nbsp;&amp;gt;=&amp;nbsp;Decimal(&amp;quot;0.03&amp;quot;)&amp;nbsp;and&amp;nbsp;abs(delta)&amp;nbsp;&amp;gt;=&amp;nbsp;3&amp;nbsp;*&amp;nbsp;sigma:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;events.append((&amp;quot;PRICE_DROP&amp;quot;&amp;nbsp;if&amp;nbsp;delta&amp;nbsp;&amp;lt;&amp;nbsp;0&amp;nbsp;else&amp;nbsp;&amp;quot;PRICE_UP&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;delta&amp;quot;:&amp;nbsp;round(float(delta),&amp;nbsp;4),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;from&amp;quot;:&amp;nbsp;prev[&amp;quot;price&amp;quot;],&amp;nbsp;&amp;quot;to&amp;quot;:&amp;nbsp;cur[&amp;quot;price&amp;quot;]}))

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;prev[&amp;quot;in_stock&amp;quot;]&amp;nbsp;and&amp;nbsp;not&amp;nbsp;cur[&amp;quot;in_stock&amp;quot;]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;events.append((&amp;quot;OUT_OF_STOCK&amp;quot;,&amp;nbsp;{&amp;quot;price&amp;quot;:&amp;nbsp;cur[&amp;quot;price&amp;quot;]}))

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;gone&amp;nbsp;=&amp;nbsp;set(prev[&amp;quot;sku_ids&amp;quot;])&amp;nbsp;-&amp;nbsp;set(cur[&amp;quot;sku_ids&amp;quot;])
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;gone:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;events.append((&amp;quot;SKU_REMOVED&amp;quot;,&amp;nbsp;{&amp;quot;sku_ids&amp;quot;:&amp;nbsp;sorted(gone)}))

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;评价增速：用「日增评价数」的&amp;nbsp;z-score，不是评价总数
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;prev_v,&amp;nbsp;cur_v&amp;nbsp;=&amp;nbsp;prev.get(&amp;quot;review_velocity&amp;quot;),&amp;nbsp;cur.get(&amp;quot;review_velocity&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;review_sigma&amp;nbsp;=&amp;nbsp;prev.get(&amp;quot;review_sigma&amp;quot;)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;该商品历史日增评价数的标准差
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;prev_v&amp;nbsp;is&amp;nbsp;not&amp;nbsp;None&amp;nbsp;and&amp;nbsp;cur_v&amp;nbsp;is&amp;nbsp;not&amp;nbsp;None&amp;nbsp;and&amp;nbsp;review_sigma:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;z&amp;nbsp;=&amp;nbsp;(cur_v&amp;nbsp;-&amp;nbsp;prev_v)&amp;nbsp;/&amp;nbsp;review_sigma
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;z&amp;nbsp;&amp;gt;&amp;nbsp;3:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;events.append((&amp;quot;REVIEW_SURGE&amp;quot;,&amp;nbsp;{&amp;quot;z&amp;quot;:&amp;nbsp;round(float(z),&amp;nbsp;2),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;velocity&amp;quot;:&amp;nbsp;cur_v}))

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;events
```

这个设计救过我们一次：最初阈值定的是「价格变动&amp;nbsp;&amp;gt;1%」，结果一个竞品每天微调&amp;nbsp;1&amp;nbsp;块钱（99→98→97.5），一天推&amp;nbsp;300&amp;nbsp;条告警，运营把通知静音了，三周后对手直接降到&amp;nbsp;79&amp;nbsp;没人知道。加了「3σ&amp;nbsp;基线&amp;nbsp;+&amp;nbsp;3%&amp;nbsp;幅度」双条件之后，一天最多&amp;nbsp;5&amp;nbsp;条，每条都值得看。

**监控清单是人定的，不是全类目扫。**&amp;nbsp;一个类目全扫是几万次调用，其中&amp;nbsp;99%&amp;nbsp;你根本不关心。我们的做法是运营每周挑&amp;nbsp;20~60&amp;nbsp;个&amp;nbsp;SKU&amp;nbsp;进监控池，只盯这些。

##&amp;nbsp;五、把三段串起来：两级池&amp;nbsp;+&amp;nbsp;分级频率

调用量是这条流最大的成本，靠「两级池」收敛：

```
全类目候选&amp;nbsp;20,000&amp;nbsp;&amp;nbsp;──粗筛（日级，1&amp;nbsp;次/天）──▶&amp;nbsp;&amp;nbsp;候选池&amp;nbsp;800
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;│
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;┌─────────┴─────────┐
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;淘宝比价（4&amp;nbsp;次/天）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;京东竞品（48&amp;nbsp;次/天，仅精选&amp;nbsp;60）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;│&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;│
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;└────▶&amp;nbsp;决策表&amp;nbsp;+&amp;nbsp;事件推送
```

-&amp;nbsp;**候选池&amp;nbsp;800&amp;nbsp;个**：1688&amp;nbsp;日扫一次（800）+&amp;nbsp;淘宝&amp;nbsp;6&amp;nbsp;小时比一次（3,200）→&amp;nbsp;约&amp;nbsp;4,000&amp;nbsp;次/天
-&amp;nbsp;**精选池&amp;nbsp;60&amp;nbsp;个**：运营确认过的，京东&amp;nbsp;30&amp;nbsp;分钟一次&amp;nbsp;→&amp;nbsp;2,880&amp;nbsp;次/天
-&amp;nbsp;合计约&amp;nbsp;6,900&amp;nbsp;次/天；如果全量按&amp;nbsp;30&amp;nbsp;分钟扫&amp;nbsp;2&amp;nbsp;万个商品，是&amp;nbsp;96&amp;nbsp;万次/天

**调用量降到约&amp;nbsp;1/140，监控频率反而从&amp;nbsp;12&amp;nbsp;小时提升到&amp;nbsp;30&amp;nbsp;分钟。**&amp;nbsp;这就是第&amp;nbsp;13&amp;nbsp;篇那条铁律的又一次应用：按变更频率分级调度，只对值得高频的对象高频。

调度骨架可以直接复用第&amp;nbsp;13&amp;nbsp;篇的&amp;nbsp;`DueScheduler`，只需换一张频率表：

```python
LEVELS&amp;nbsp;=&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;supply&amp;quot;:&amp;nbsp;&amp;nbsp;{&amp;quot;interval_s&amp;quot;:&amp;nbsp;86400,&amp;nbsp;&amp;quot;pool&amp;quot;:&amp;nbsp;&amp;quot;candidate&amp;quot;},&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;1688&amp;nbsp;货源
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;retail&amp;quot;:&amp;nbsp;&amp;nbsp;{&amp;quot;interval_s&amp;quot;:&amp;nbsp;21600,&amp;nbsp;&amp;quot;pool&amp;quot;:&amp;nbsp;&amp;quot;candidate&amp;quot;},&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;淘宝比价
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;rival&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;interval_s&amp;quot;:&amp;nbsp;1800,&amp;nbsp;&amp;nbsp;&amp;quot;pool&amp;quot;:&amp;nbsp;&amp;quot;selected&amp;quot;},&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;京东竞品
}
```

##&amp;nbsp;六、三个结论

1.&amp;nbsp;**同款识别是地基。**&amp;nbsp;认错了，比价是错的、利润是错的、备货也是错的。宁可用保守阈值&amp;nbsp;+&amp;nbsp;人工复核，也不要让误匹配混进决策表。
2.&amp;nbsp;**利润要算到净利，退货率是隐藏杀手。**&amp;nbsp;填&amp;nbsp;5%&amp;nbsp;退货率的模型得出的结论，不如不填；`breakeven_return_rate()`&amp;nbsp;的用处是逼你把假设填对。
3.&amp;nbsp;**监控要监控事件，不要监控数字。**&amp;nbsp;每天的&amp;nbsp;300&amp;nbsp;条噪音会让运营关掉通知，那比不监控更糟。


最后一句合规提醒：这套流用的都是平台授权接口返回的公开商品数据，**不采集买家信息、不抓取评价内容原文**。竞品监控是看公开的挂牌价格和库存状态，这和抓取用户数据是两件事，别混在一起做。

---

*如果不想自己维护这套同款识别和分级调度，我们的三平台接口服务已经把同款聚合、价格带统计、竞品事件都做成了标准输出，接进来自带上面这张决策表。评论区或私信聊。*&lt;/pre&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;</description><pubDate>Fri, 30 Oct 2026 15:41:29 +0800</pubDate></item><item><title>电商 ERP 对接三平台，90% 的问题都出在字段和超时上（附 python 源码）</title><link>https://www.alexob.com/?id=591</link><description>&lt;pre class=&quot;prism-highlight prism-language-python&quot;&gt;#&amp;nbsp;电商&amp;nbsp;ERP&amp;nbsp;对接三平台，90%&amp;nbsp;的问题都出在字段和超时上（附&amp;nbsp;python&amp;nbsp;源码）

&amp;gt;&amp;nbsp;我们做过十几个&amp;nbsp;ERP&amp;nbsp;对接项目，复盘下来有个很稳定的分布：**字段问题&amp;nbsp;62%，超时问题&amp;nbsp;28%，剩下的&amp;nbsp;10%&amp;nbsp;才是签名、限流、权限这些**。
&amp;gt;
&amp;gt;&amp;nbsp;这个分布有点反直觉——工程师通常以为对接难在协议和加密，实际难的地方在**语义**和**时间窗口**。
&amp;gt;
&amp;gt;&amp;nbsp;原因也简单：普通的数据同步，是把平台的数据搬到自己库里；ERP&amp;nbsp;对接，是让**平台的交易记录**和**企业的会计账**在同一个时间窗口内闭合。前者错了解析字段重跑就行，后者错了要调账。
&amp;gt;
&amp;gt;&amp;nbsp;这篇讲两件事：为什么&amp;nbsp;ERP&amp;nbsp;的字段问题特别多，以及&amp;nbsp;ERP&amp;nbsp;说的「超时」到底指什么。

先说清楚边界：单位和时区那类错误（分/元、秒/毫秒）第&amp;nbsp;14&amp;nbsp;篇讲过，这里不再重复。这篇讲的是**更靠上的语义层**——字段含义对不上，以及业务时限兜不住。

##&amp;nbsp;一、ERP&amp;nbsp;与普通同步的根本差别：交易视图&amp;nbsp;vs&amp;nbsp;凭证视图

平台接口返回的是**交易视图**：这笔交易发生了什么，买家付了多少钱，货发到哪。

ERP&amp;nbsp;需要的是**凭证视图**：这笔业务记哪个科目、算哪个成本中心、归哪个账期、冲销哪张单。

两种视图之间不是字段改名，是**结构变形**。下面四个变形，是&amp;nbsp;62%&amp;nbsp;字段问题的来源（按出现频次排序）：

|&amp;nbsp;#&amp;nbsp;|&amp;nbsp;变形&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;平台给的&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;ERP&amp;nbsp;要的&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;错了的后果&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;-&amp;nbsp;|&amp;nbsp;-----------&amp;nbsp;|&amp;nbsp;----------------&amp;nbsp;|&amp;nbsp;------------------------&amp;nbsp;|&amp;nbsp;----------------&amp;nbsp;|
|&amp;nbsp;1&amp;nbsp;|&amp;nbsp;金额拆分&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;一个&amp;nbsp;`payment`（实付）&amp;nbsp;|&amp;nbsp;商品金额&amp;nbsp;+&amp;nbsp;运费&amp;nbsp;+&amp;nbsp;优惠&amp;nbsp;+&amp;nbsp;应收&amp;nbsp;+&amp;nbsp;佣金&amp;nbsp;|&amp;nbsp;毛利算不对，优惠成本全压在订单上&amp;nbsp;|
|&amp;nbsp;2&amp;nbsp;|&amp;nbsp;SKU&amp;nbsp;→&amp;nbsp;物料&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;一个销售&amp;nbsp;SKU&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;多个物料&amp;nbsp;+&amp;nbsp;数量（组合装）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;库存只扣一部分，静默虚高&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;3&amp;nbsp;|&amp;nbsp;时间&amp;nbsp;→&amp;nbsp;账期&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;创建时间、支付时间&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;业务日期（可配日结切点）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;跨月订单归错月，GMV&amp;nbsp;差几个点&amp;nbsp;|
|&amp;nbsp;4&amp;nbsp;|&amp;nbsp;订单状态&amp;nbsp;→&amp;nbsp;单据状态&amp;nbsp;|&amp;nbsp;5&amp;nbsp;个交易状态&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;审单&amp;nbsp;/&amp;nbsp;配货&amp;nbsp;/&amp;nbsp;出库&amp;nbsp;/&amp;nbsp;开票多张单据&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;状态机错乱，已出库单被重复发货&amp;nbsp;&amp;nbsp;|

###&amp;nbsp;变形一：金额是一个数，但账需要五个数

平台给&amp;nbsp;`payment`（买家实付），ERP&amp;nbsp;要做三件事：拆出商品金额与运费；把订单级优惠**分摊到行**（否则算不出单品行毛利）；算出应收（扣平台佣金后的金额）。

其中最容易出错、也最能体现专业度的是**优惠分摊**：

```python
#&amp;nbsp;erp/money.py
from&amp;nbsp;decimal&amp;nbsp;import&amp;nbsp;Decimal,&amp;nbsp;ROUND_HALF_UP

CENT&amp;nbsp;=&amp;nbsp;Decimal(&amp;quot;0.01&amp;quot;)


def&amp;nbsp;allocate_discount(total_discount:&amp;nbsp;Decimal,&amp;nbsp;lines:&amp;nbsp;list[dict])&amp;nbsp;-&amp;gt;&amp;nbsp;list[Decimal]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;把订单级优惠按行金额比例分摊到行。
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lines:&amp;nbsp;[{&amp;quot;line_id&amp;quot;:&amp;nbsp;...,&amp;nbsp;&amp;quot;amount&amp;quot;:&amp;nbsp;Decimal}]&amp;nbsp;&amp;nbsp;——&amp;nbsp;amount&amp;nbsp;是分摊前的行金额
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;返回:&amp;nbsp;与&amp;nbsp;lines&amp;nbsp;等长的分摊额列表（正数表示优惠额）

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;两个必须处理的边界：
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;1.&amp;nbsp;尾差：整除不了时，差额补在最后一行，否则各行相加&amp;nbsp;≠&amp;nbsp;订单优惠，对账天天差&amp;nbsp;0.01
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;2.&amp;nbsp;最后一行被补成负数：极端比例下尾差可能超过该行金额，要回退给金额最大的行
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;total_discount&amp;nbsp;==&amp;nbsp;0:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;[Decimal(&amp;quot;0&amp;quot;)]&amp;nbsp;*&amp;nbsp;len(lines)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;base&amp;nbsp;=&amp;nbsp;sum(l[&amp;quot;amount&amp;quot;]&amp;nbsp;for&amp;nbsp;l&amp;nbsp;in&amp;nbsp;lines)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;base&amp;nbsp;==&amp;nbsp;0:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;全额赠品单：无金额可分摊，全部记到首行
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;out&amp;nbsp;=&amp;nbsp;[Decimal(&amp;quot;0&amp;quot;)]&amp;nbsp;*&amp;nbsp;len(lines)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;out[0]&amp;nbsp;=&amp;nbsp;total_discount
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;out

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;shares,&amp;nbsp;allocated&amp;nbsp;=&amp;nbsp;[],&amp;nbsp;Decimal(&amp;quot;0&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;l&amp;nbsp;in&amp;nbsp;lines[:-1]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;share&amp;nbsp;=&amp;nbsp;(l[&amp;quot;amount&amp;quot;]&amp;nbsp;*&amp;nbsp;total_discount&amp;nbsp;/&amp;nbsp;base).quantize(CENT,&amp;nbsp;ROUND_HALF_UP)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;shares.append(share)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;allocated&amp;nbsp;+=&amp;nbsp;share
#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;tail&amp;nbsp;=&amp;nbsp;total_discount&amp;nbsp;-&amp;nbsp;allocated&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;尾差兜底
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;last&amp;nbsp;=&amp;nbsp;lines[-1][&amp;quot;amount&amp;quot;]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;shares&amp;nbsp;and&amp;nbsp;tail&amp;nbsp;&amp;gt;&amp;nbsp;last:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;最后一行承受不住，转给金额最大行
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;idx&amp;nbsp;=&amp;nbsp;max(range(len(lines)),&amp;nbsp;key=lambda&amp;nbsp;i:&amp;nbsp;lines[i][&amp;quot;amount&amp;quot;])
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;shares[idx]&amp;nbsp;+=&amp;nbsp;tail
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;shares.append(Decimal(&amp;quot;0&amp;quot;))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;else:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;shares.append(tail)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;shares
```

**那个尾差不是理论问题。**&amp;nbsp;我们遇到过一个客户，对账表每天差&amp;nbsp;0.01，财务查了两周，最后发现是三个行按比例分摊，四舍五入后多出&amp;nbsp;1&amp;nbsp;分钱没人认领。ERP&amp;nbsp;对账是**分毫必究**的，普通业务系统里可以忽略的四舍五入，在这里会每天生成一条差异记录。

还有个容易漏的：**赠品行的&amp;nbsp;`amount`&amp;nbsp;是&amp;nbsp;0，但必须保留**。很多人清洗数据时把&amp;nbsp;0&amp;nbsp;元行删了，看起来数据更干净，实际是把这个赠品的**成本丢出了订单**——月末算单均成本时偏低，越卖越亏还找不到原因。

###&amp;nbsp;变形二：一个销售&amp;nbsp;SKU&amp;nbsp;不等于一个物料

组合装是最典型的坑。平台上一个「三件套礼盒」是一个&amp;nbsp;SKU，ERP&amp;nbsp;里是三个物料各若干件：

```python
#&amp;nbsp;erp/bom.py
#&amp;nbsp;平台&amp;nbsp;SKU&amp;nbsp;=&amp;nbsp;销售单元；ERP&amp;nbsp;物料&amp;nbsp;=&amp;nbsp;库存单元。两者不是一一对应
BOM:&amp;nbsp;dict[str,&amp;nbsp;list[tuple[str,&amp;nbsp;int]]]&amp;nbsp;=&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;SKU-GIFT-3IN1&amp;quot;:&amp;nbsp;[(&amp;quot;MUG-001&amp;quot;,&amp;nbsp;2),&amp;nbsp;(&amp;quot;COASTER-002&amp;quot;,&amp;nbsp;4),&amp;nbsp;(&amp;quot;GIFTBOX-003&amp;quot;,&amp;nbsp;1)],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;SKU-CUP-PAIR&amp;quot;:&amp;nbsp;&amp;nbsp;[(&amp;quot;MUG-001&amp;quot;,&amp;nbsp;2)],
}


def&amp;nbsp;expand(sku_id:&amp;nbsp;str,&amp;nbsp;qty:&amp;nbsp;int)&amp;nbsp;-&amp;gt;&amp;nbsp;list[tuple[str,&amp;nbsp;int]]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;把销售数量展开成物料需求；非组合装原样返回&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;bom&amp;nbsp;=&amp;nbsp;BOM.get(sku_id)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;not&amp;nbsp;bom:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;[(sku_id,&amp;nbsp;qty)]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;[(mat,&amp;nbsp;n&amp;nbsp;*&amp;nbsp;qty)&amp;nbsp;for&amp;nbsp;mat,&amp;nbsp;n&amp;nbsp;in&amp;nbsp;bom]
```

不展开的后果是**静默的**：卖了&amp;nbsp;100&amp;nbsp;套，只扣了&amp;nbsp;100&amp;nbsp;个杯子的库存（如果&amp;nbsp;SKU&amp;nbsp;编码恰好和主物料同名），包装盒和杯垫根本没扣。系统不报错，账面看着正常，直到盘点发现包装盒少了&amp;nbsp;100&amp;nbsp;个——**而这时已经严重缺货，还在接单**。

比这更隐蔽的是反方向：**平台侧修改了组合装成分**（运营把三件套改成了两件套），你本地的&amp;nbsp;BOM&amp;nbsp;没更新。新订单按旧&amp;nbsp;BOM&amp;nbsp;扣料，多扣一个杯子，日积月累库存对不上，看谁都像偷了货。所以&amp;nbsp;BOM&amp;nbsp;要有版本和生效时间，且**跟着商品详情一起增量同步**（第&amp;nbsp;13&amp;nbsp;篇的详情同步应该带出组合装信息）。

###&amp;nbsp;变形三：ERP&amp;nbsp;要的不是「时间」，是「账期」

平台给你&amp;nbsp;`created`（下单）和&amp;nbsp;`pay_time`（支付），ERP&amp;nbsp;要的是**业务日期**——这笔钱算哪个月的。三条规矩：

1.&amp;nbsp;**用支付时间，不用创建时间。**&amp;nbsp;上月&amp;nbsp;30&amp;nbsp;号下单、本月&amp;nbsp;1&amp;nbsp;号付款的订单，按创建时间归上月，上月&amp;nbsp;GMV&amp;nbsp;虚高、本月偏低，两个月的报表都要解释。
2.&amp;nbsp;**日结切点可配。**&amp;nbsp;很多&amp;nbsp;ERP&amp;nbsp;的日结在凌晨&amp;nbsp;1~2&amp;nbsp;点（财务第二天早上结前一天的账），0&amp;nbsp;点到&amp;nbsp;2&amp;nbsp;点之间的订单应该算前一个业务日。
3.&amp;nbsp;**时区必须显式。**&amp;nbsp;这条第&amp;nbsp;14&amp;nbsp;篇讲过，但&amp;nbsp;ERP&amp;nbsp;里更要命：财务的月报是以北京时间的自然月为准的。

```python
#&amp;nbsp;erp/calendar.py
from&amp;nbsp;datetime&amp;nbsp;import&amp;nbsp;datetime,&amp;nbsp;timedelta
from&amp;nbsp;zoneinfo&amp;nbsp;import&amp;nbsp;ZoneInfo

CN&amp;nbsp;=&amp;nbsp;ZoneInfo(&amp;quot;Asia/Shanghai&amp;quot;)


def&amp;nbsp;business_date(paid_at:&amp;nbsp;datetime,&amp;nbsp;cutoff_hour:&amp;nbsp;int&amp;nbsp;=&amp;nbsp;0)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;业务日期：按支付时间归属账期，支持自定义日结切点&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;t&amp;nbsp;=&amp;nbsp;paid_at.astimezone(CN)&amp;nbsp;if&amp;nbsp;paid_at.tzinfo&amp;nbsp;else&amp;nbsp;paid_at.replace(tzinfo=CN)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;cutoff_hour&amp;nbsp;and&amp;nbsp;t.hour&amp;nbsp;&amp;lt;&amp;nbsp;cutoff_hour:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;t&amp;nbsp;-=&amp;nbsp;timedelta(days=1)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;凌晨单归前一业务日
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;t.strftime(&amp;quot;%Y-%m-%d&amp;quot;)
```

###&amp;nbsp;变形四：订单状态机不能直接搬

第&amp;nbsp;3&amp;nbsp;篇讲过订单状态机怎么设计。ERP&amp;nbsp;里还要多一层：一个平台订单在&amp;nbsp;ERP&amp;nbsp;里会**生成多张单据**（销售订单&amp;nbsp;→&amp;nbsp;出库单&amp;nbsp;→&amp;nbsp;应收单&amp;nbsp;→&amp;nbsp;发票），每张单据有自己的状态。把平台状态直接映射成&amp;nbsp;ERP&amp;nbsp;主单状态，会出现「已出库但未开票」这类中间态无处安放的问题。

**规矩是：平台状态映射到「事件」，事件驱动&amp;nbsp;ERP&amp;nbsp;单据流转。**&amp;nbsp;不要映射状态，映射事件。`WAIT_SELLER_SEND_GOODS`&amp;nbsp;不是一个状态，是「可以配货了」这个事件。

##&amp;nbsp;二、超时：ERP&amp;nbsp;说的超时，八成不是接口超时

这是最容易被技术人员误解的部分。工程师听到「超时」，第一反应是调&amp;nbsp;`timeout=`&amp;nbsp;参数；而在&amp;nbsp;ERP&amp;nbsp;场景里，真正的超时发生在三层，而且**接口层是最好解决的那层**：

|&amp;nbsp;层级&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;时限量级&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;超时的后果&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;谁来兜&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;-------&amp;nbsp;|&amp;nbsp;-----------&amp;nbsp;|&amp;nbsp;------------&amp;nbsp;|&amp;nbsp;-------&amp;nbsp;|
|&amp;nbsp;接口超时&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;秒&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;页面转圈、用户重试&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;工程师&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;审单/发货时限&amp;nbsp;|&amp;nbsp;小时~天&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;平台扣分、罚款、流量降权&amp;nbsp;|&amp;nbsp;运营&amp;nbsp;+&amp;nbsp;系统&amp;nbsp;|
|&amp;nbsp;对账窗口&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;天（T+1&amp;nbsp;/&amp;nbsp;月结）&amp;nbsp;|&amp;nbsp;账不平，财务停工&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;系统&amp;nbsp;+&amp;nbsp;财务&amp;nbsp;|

###&amp;nbsp;2.1&amp;nbsp;接口层：ERP&amp;nbsp;前台是同步阻塞的，所以不能长重试

普通后台同步可以慢慢重试（第&amp;nbsp;9&amp;nbsp;篇的预算式重试），但&amp;nbsp;ERP&amp;nbsp;不一样：**用户在页面上点「同步订单」，鼠标在那儿等着**。一次重试&amp;nbsp;30&amp;nbsp;秒，用户就以为卡死了，会连点五次——于是同一批订单被并发拉五遍。

解法不是把超时调长，而是**把长事务拆成「提交&amp;nbsp;+&amp;nbsp;查结果」**：

```python
#&amp;nbsp;erp/jobs.py&amp;nbsp;——&amp;nbsp;提交-轮询模式：前台&amp;nbsp;3&amp;nbsp;秒拿到&amp;nbsp;task_id，重活交后台
import&amp;nbsp;asyncio
import&amp;nbsp;uuid


class&amp;nbsp;JobService:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;__init__(self,&amp;nbsp;store,&amp;nbsp;worker):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.store&amp;nbsp;=&amp;nbsp;store&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;持久化任务表（对应第&amp;nbsp;2&amp;nbsp;篇的&amp;nbsp;SQLite&amp;nbsp;任务表思路）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.worker&amp;nbsp;=&amp;nbsp;worker

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;submit(self,&amp;nbsp;kind:&amp;nbsp;str,&amp;nbsp;idem_key:&amp;nbsp;str,&amp;nbsp;payload:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;幂等键：同一批订单连点五次，只产生一个任务&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;exist&amp;nbsp;=&amp;nbsp;await&amp;nbsp;self.store.find_by_idem(idem_key)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;exist:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;exist.task_id
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;task_id&amp;nbsp;=&amp;nbsp;uuid.uuid4().hex
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;self.store.create(task_id,&amp;nbsp;kind,&amp;nbsp;idem_key,&amp;nbsp;payload,&amp;nbsp;status=&amp;quot;pending&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.worker.wake()&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;不&amp;nbsp;await&amp;nbsp;执行，立即返回
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;task_id

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;status(self,&amp;nbsp;task_id:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;dict:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;task&amp;nbsp;=&amp;nbsp;await&amp;nbsp;self.store.get(task_id)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;{&amp;quot;task_id&amp;quot;:&amp;nbsp;task_id,&amp;nbsp;&amp;quot;status&amp;quot;:&amp;nbsp;task.status,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;done&amp;quot;:&amp;nbsp;task.done,&amp;nbsp;&amp;quot;failed&amp;quot;:&amp;nbsp;task.failed,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;errors&amp;quot;:&amp;nbsp;task.errors[:10]}&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;只回传前&amp;nbsp;10&amp;nbsp;条，前端可展开

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
#&amp;nbsp;后台&amp;nbsp;worker：每项任务有独立的业务截止时间（DDL），到点没做完就切下一批
async&amp;nbsp;def&amp;nbsp;run_batch(items,&amp;nbsp;handler,&amp;nbsp;ddl_seconds:&amp;nbsp;int&amp;nbsp;=&amp;nbsp;300):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;loop&amp;nbsp;=&amp;nbsp;asyncio.get_running_loop()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;deadline&amp;nbsp;=&amp;nbsp;loop.time()&amp;nbsp;+&amp;nbsp;ddl_seconds
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;skipped&amp;nbsp;=&amp;nbsp;[]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;it&amp;nbsp;in&amp;nbsp;items:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;loop.time()&amp;nbsp;&amp;gt;=&amp;nbsp;deadline:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;skipped.append(it)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;超预算的放回队列，下一轮继续，不算失败
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;continue
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;handler(it)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;skipped
```

三个设计点：

1.&amp;nbsp;**幂等键&amp;nbsp;=&amp;nbsp;业务主键**（比如&amp;nbsp;`sync_order:2026-10-29:shop_123`）。用户连点、两个运营同时点、定时任务和手动任务撞在一起，都只产生一个任务。
2.&amp;nbsp;**接口&amp;nbsp;3&amp;nbsp;秒内一定返回**。返回&amp;nbsp;`task_id`&amp;nbsp;而不是结果，前端轮询&amp;nbsp;`status`。这比把&amp;nbsp;`timeout`&amp;nbsp;调到&amp;nbsp;60&amp;nbsp;秒体验好得多，也避免了长连接占资源。
3.&amp;nbsp;**后台任务有&amp;nbsp;DDL，超了就把剩下的放回队列**，不要硬跑完——因为下一次调度很快就到，硬跑完只会让两轮任务叠在一起（这就是第&amp;nbsp;10&amp;nbsp;篇说的「任务堆积」）。

###&amp;nbsp;2.2&amp;nbsp;业务层：库存回写要两段式，不能「等接口成功」

超卖的根因在&amp;nbsp;ERP&amp;nbsp;里通常不是接口慢，而是**扣库存的时机错了**。

想一想&amp;nbsp;ERP&amp;nbsp;的出库流程：审单&amp;nbsp;→&amp;nbsp;生成出库单&amp;nbsp;→&amp;nbsp;拣货&amp;nbsp;→&amp;nbsp;出库&amp;nbsp;→&amp;nbsp;回写平台库存。如果只在「出库完成」时才扣库存，那么从审单到出库这段时间，库存是**虚高**的，平台还在卖，卖出去的单进来又排队等出库——超卖就是这么堆起来的。

正确做法是**预占&amp;nbsp;+&amp;nbsp;确认**（第&amp;nbsp;3&amp;nbsp;篇讲过&amp;nbsp;`total&amp;nbsp;-&amp;nbsp;reserved&amp;nbsp;-&amp;nbsp;safety`&amp;nbsp;的账本，这里给&amp;nbsp;ERP&amp;nbsp;的落法）：

```python
#&amp;nbsp;erp/stock.py&amp;nbsp;——&amp;nbsp;预占&amp;nbsp;+&amp;nbsp;确认，两段式
async&amp;nbsp;def&amp;nbsp;reserve(order_id:&amp;nbsp;str,&amp;nbsp;sku_id:&amp;nbsp;str,&amp;nbsp;qty:&amp;nbsp;int,&amp;nbsp;ttl_s:&amp;nbsp;int&amp;nbsp;=&amp;nbsp;3600)&amp;nbsp;-&amp;gt;&amp;nbsp;bool:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;审单通过就预占，不等到出库。返回&amp;nbsp;False&amp;nbsp;表示可用库存不足&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ok&amp;nbsp;=&amp;nbsp;await&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;UPDATE&amp;nbsp;stock&amp;nbsp;SET&amp;nbsp;reserved&amp;nbsp;=&amp;nbsp;reserved&amp;nbsp;+&amp;nbsp;:q&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;WHERE&amp;nbsp;sku_id&amp;nbsp;=&amp;nbsp;:s&amp;nbsp;AND&amp;nbsp;total&amp;nbsp;-&amp;nbsp;reserved&amp;nbsp;-&amp;nbsp;safety&amp;nbsp;&amp;gt;=&amp;nbsp;:q&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;q&amp;quot;:&amp;nbsp;qty,&amp;nbsp;&amp;quot;s&amp;quot;:&amp;nbsp;sku_id},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;ok:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;INSERT&amp;nbsp;INTO&amp;nbsp;reservation(order_id,&amp;nbsp;sku_id,&amp;nbsp;qty,&amp;nbsp;expires_at)&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;VALUES&amp;nbsp;(:o,&amp;nbsp;:s,&amp;nbsp;:q,&amp;nbsp;now()&amp;nbsp;+&amp;nbsp;:ttl)&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;o&amp;quot;:&amp;nbsp;order_id,&amp;nbsp;&amp;quot;s&amp;quot;:&amp;nbsp;sku_id,&amp;nbsp;&amp;quot;q&amp;quot;:&amp;nbsp;qty,&amp;nbsp;&amp;quot;ttl&amp;quot;:&amp;nbsp;ttl_s},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;bool(ok)


async&amp;nbsp;def&amp;nbsp;confirm(order_id:&amp;nbsp;str):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;出库成功：预占转实扣，同步回写平台&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;db.execute(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;UPDATE&amp;nbsp;stock&amp;nbsp;SET&amp;nbsp;total&amp;nbsp;=&amp;nbsp;total&amp;nbsp;-&amp;nbsp;r.qty,&amp;nbsp;reserved&amp;nbsp;=&amp;nbsp;reserved&amp;nbsp;-&amp;nbsp;r.qty&amp;nbsp;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;FROM&amp;nbsp;reservation&amp;nbsp;r&amp;nbsp;WHERE&amp;nbsp;r.order_id&amp;nbsp;=&amp;nbsp;:o&amp;nbsp;AND&amp;nbsp;r.sku_id&amp;nbsp;=&amp;nbsp;stock.sku_id&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;quot;o&amp;quot;:&amp;nbsp;order_id},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;db.execute(&amp;quot;DELETE&amp;nbsp;FROM&amp;nbsp;reservation&amp;nbsp;WHERE&amp;nbsp;order_id&amp;nbsp;=&amp;nbsp;:o&amp;quot;,&amp;nbsp;{&amp;quot;o&amp;quot;:&amp;nbsp;order_id})
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;push_stock_to_platform(order_id)


async&amp;nbsp;def&amp;nbsp;release_expired():
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;TTL&amp;nbsp;到期的预占自动释放——取消单、审核不通过、接口超时都靠这个兜底&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;db.execute(&amp;quot;DELETE&amp;nbsp;FROM&amp;nbsp;reservation&amp;nbsp;WHERE&amp;nbsp;expires_at&amp;nbsp;&amp;lt;&amp;nbsp;now()&amp;nbsp;RETURNING&amp;nbsp;sku_id,&amp;nbsp;qty&amp;quot;)
```

关键在&amp;nbsp;`release_expired`：**所有异常路径最终都收敛成「预占超时自动释放」**。审核卡住、出库单作废、平台接口&amp;nbsp;502，不需要每种情况都写补偿逻辑，等&amp;nbsp;TTL&amp;nbsp;到就行。这是把分布式一致性问题降级成一个定时任务。

###&amp;nbsp;2.3&amp;nbsp;对账层：日切和差异表

T+1&amp;nbsp;对账是&amp;nbsp;ERP&amp;nbsp;的硬需求，坑集中在两处：

-&amp;nbsp;**日切边界**：ERP&amp;nbsp;按业务日切（前面&amp;nbsp;`business_date`），平台按自然日，两边窗口不一样，边界上一定有差异。解法是**对账窗口取两边的交集并额外多取一天**，差异在窗口内消化。
-&amp;nbsp;**差异要有表，不能只有日志**。差异记录必须能落到&amp;nbsp;`recon_diff`&amp;nbsp;表里（订单号、金额差、状态差、处理结果），财务才能逐条勾对。只在日志里喊一句「对账不平」，等于没做对账。

##&amp;nbsp;三、把两类问题收成一份契约表

字段和超时，看起来是两个问题，本质是一个：**你和&amp;nbsp;ERP&amp;nbsp;之间对「这笔业务的含义」和「多久必须到达」没有书面约定**。

所以我们每个&amp;nbsp;ERP&amp;nbsp;项目都会产出一份&amp;nbsp;`contract.yaml`，既是配置，也是交接文档：

```yaml
#&amp;nbsp;contract.yaml&amp;nbsp;——&amp;nbsp;ERP&amp;nbsp;对接契约：含义&amp;nbsp;+&amp;nbsp;单位&amp;nbsp;+&amp;nbsp;缺失处理&amp;nbsp;+&amp;nbsp;超时语义
fields:
&amp;nbsp;&amp;nbsp;paid_amount:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;source:&amp;nbsp;{&amp;nbsp;taobao:&amp;nbsp;payment,&amp;nbsp;jd:&amp;nbsp;orderPrice,&amp;nbsp;alibaba:&amp;nbsp;totalAmount&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;semantics:&amp;nbsp;买家实付，含运费、已扣优惠
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;unit:&amp;nbsp;CNY&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;jd&amp;nbsp;原始单位为分，由适配器归一（见第&amp;nbsp;14&amp;nbsp;篇）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;missing:&amp;nbsp;error&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;缺失即报错，不允许落&amp;nbsp;0
&amp;nbsp;&amp;nbsp;discount:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;semantics:&amp;nbsp;订单级优惠总额
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;allocate:&amp;nbsp;by_line_amount&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;必须分摊到行，规则写进契约
&amp;nbsp;&amp;nbsp;sku_id:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;semantics:&amp;nbsp;销售单元，可能对应多个物料
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;expand:&amp;nbsp;bom&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;出库前必须展开，见&amp;nbsp;bom.py
&amp;nbsp;&amp;nbsp;pay_time:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;semantics:&amp;nbsp;业务日期基准（不是创建时间）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;business_day_cutoff:&amp;nbsp;2&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;凌晨&amp;nbsp;2&amp;nbsp;点日结

timeouts:
&amp;nbsp;&amp;nbsp;sync_order:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;deadline_ms:&amp;nbsp;3000&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;前台同步接口上限
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;on_timeout:&amp;nbsp;enqueue_job&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;超时不重试，转异步任务
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;idem_key:&amp;nbsp;&amp;quot;sync_order:{shop_id}:{date}&amp;quot;
&amp;nbsp;&amp;nbsp;push_stock:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;deadline_ms:&amp;nbsp;2000
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;on_timeout:&amp;nbsp;retry_queue&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;后台任务可重试，退避由链路层负责
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;consistency:&amp;nbsp;reserve_confirm&amp;nbsp;#&amp;nbsp;两段式，不允许直写
&amp;nbsp;&amp;nbsp;reconcile:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;window:&amp;nbsp;T+1
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;diff_table:&amp;nbsp;recon_diff
```

这张表的价值在项目交付后：**换人接手时，不用读代码猜「这个字段到底什么意思」，表里写着。**&amp;nbsp;半年后平台改字段，也是从这张表开始改，改完跑一遍启动自检确认没有漏配。

```python
#&amp;nbsp;erp/contract_check.py&amp;nbsp;——&amp;nbsp;启动自检：契约里声明的，代码里必须都实现
def&amp;nbsp;check(contract:&amp;nbsp;dict,&amp;nbsp;adapters:&amp;nbsp;dict):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;problems&amp;nbsp;=&amp;nbsp;[]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;name,&amp;nbsp;spec&amp;nbsp;in&amp;nbsp;contract[&amp;quot;fields&amp;quot;].items():
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;platform&amp;nbsp;in&amp;nbsp;spec[&amp;quot;source&amp;quot;]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;platform&amp;nbsp;not&amp;nbsp;in&amp;nbsp;adapters:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;problems.append(f&amp;quot;{name}:&amp;nbsp;缺&amp;nbsp;{platform}&amp;nbsp;适配器&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;elif&amp;nbsp;not&amp;nbsp;hasattr(adapters[platform],&amp;nbsp;f&amp;quot;map_{name}&amp;quot;):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;problems.append(f&amp;quot;{name}:&amp;nbsp;{platform}&amp;nbsp;未实现映射&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;spec.get(&amp;quot;allocate&amp;quot;)&amp;nbsp;and&amp;nbsp;not&amp;nbsp;spec.get(&amp;quot;unit&amp;quot;):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;problems.append(f&amp;quot;{name}:&amp;nbsp;声明了分摊但没声明单位&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;problems:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;SystemExit(&amp;quot;契约定稿失败:\n&amp;nbsp;&amp;nbsp;&amp;quot;&amp;nbsp;+&amp;nbsp;&amp;quot;\n&amp;nbsp;&amp;nbsp;&amp;quot;.join(problems))
```

启动就炸，比上线三天后发现某个平台少了个字段好得多。

##&amp;nbsp;四、一句话总结

ERP&amp;nbsp;对接难，不难在协议。**难在平台描述的是「交易」，ERP&amp;nbsp;记的是「账」；平台保证的是「最终一致」，ERP&amp;nbsp;需要的是「账期内闭合」。**

字段是语义翻译问题，超时是时间窗口问题。把这两件事都写进契约表，90%&amp;nbsp;的问题在发生之前就消掉了。

剩下&amp;nbsp;10%&amp;nbsp;是签名、限流、授权那些，前面几篇都写过：签名和鉴权看第&amp;nbsp;14&amp;nbsp;篇，限流看第&amp;nbsp;10&amp;nbsp;篇，链路层稳定性看第&amp;nbsp;9&amp;nbsp;篇。

---

*如果不想在这一层反复踩坑，我们提供的三平台数据接口已经把金额拆分、物料展开、业务日期这三件事做成了标准字段，直接对接&amp;nbsp;ERP&amp;nbsp;少写一半适配代码。评论区或私信聊。*&lt;/pre&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;</description><pubDate>Fri, 30 Oct 2026 15:19:32 +0800</pubDate></item><item><title>鉴权、签名、字段映射：三平台对接最容易翻车的三个环节（附 python 源码）</title><link>https://www.alexob.com/?id=590</link><description>&lt;pre class=&quot;prism-highlight prism-language-python&quot;&gt;#&amp;nbsp;鉴权、签名、字段映射：三平台对接最容易翻车的三个环节（附&amp;nbsp;python&amp;nbsp;源码）

&amp;gt;&amp;nbsp;接淘宝、1688、京东的开放平台接口，我们团队前后踩过的坑能写一本书。但真要按「浪费的人天」排个序，前三名毫无悬念：**鉴权、签名、字段映射**。
&amp;gt;
&amp;gt;&amp;nbsp;这三个环节有个共同的恶毒之处：**报错信息都不说人话**。淘宝返回&amp;nbsp;`Invalid&amp;nbsp;signature`，京东返回&amp;nbsp;`signature&amp;nbsp;error`，1688&amp;nbsp;返回&amp;nbsp;`error_code:&amp;nbsp;40`——没有一个告诉你错在哪一个参数、哪一步、哪个字符。很多人卡一天半，最后发现是时间戳用了秒而不是毫秒。
&amp;gt;
&amp;gt;&amp;nbsp;这篇不讲怎么接入（第&amp;nbsp;6、11&amp;nbsp;篇讲过），讲的是**翻车之后怎么定位**。建议先收藏，翻车的时候拿出来查。

##&amp;nbsp;一、为什么这三个环节最坑：错误都在「你自己的代码」和「平台的报错」之间

数据接口报错，通常是业务问题——库存不足、商品不存在，一眼能懂。而这三个环节的错误发生在**请求离开你的机器之前**，平台收到的东西和你以为发出去的东西**不是同一份**：

-&amp;nbsp;你以为发了&amp;nbsp;`timestamp=1730000000`，实际签名时用的是另一个值；
-&amp;nbsp;你以为参数按字典序排了，实际有一个&amp;nbsp;key&amp;nbsp;多了个空格；
-&amp;nbsp;你以为字段是整数，平台按字符串解析，静默截断。

**排查的核心方法论只有一句话：别猜，把「你实际发出去的东西」打出来，和「平台期望的东西」逐字符比对。**&amp;nbsp;后文所有代码都是围绕这句话展开的。

先给一张总览表，后面三节逐个展开：

|&amp;nbsp;环节&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;典型报错&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;报错的真实含义&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;平均排查耗时（我们的工单统计）&amp;nbsp;|
|&amp;nbsp;----&amp;nbsp;|&amp;nbsp;-------------------------------------------&amp;nbsp;|&amp;nbsp;------------------------------------------&amp;nbsp;|&amp;nbsp;---------------&amp;nbsp;|
|&amp;nbsp;鉴权&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;`session&amp;nbsp;expired`&amp;nbsp;/&amp;nbsp;`lack&amp;nbsp;of&amp;nbsp;authorization`&amp;nbsp;|&amp;nbsp;token&amp;nbsp;过期、session&amp;nbsp;与&amp;nbsp;appkey&amp;nbsp;不匹配、token&amp;nbsp;没跟着请求走&amp;nbsp;|&amp;nbsp;2&amp;nbsp;小时&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;签名&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;`Invalid&amp;nbsp;signature`&amp;nbsp;/&amp;nbsp;`error_code:&amp;nbsp;40`&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;参数没排对序、时间戳单位错、中文被转义、空值参与签名&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**1.5&amp;nbsp;天**&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;字段映射&amp;nbsp;|&amp;nbsp;无报错（最危险）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;价格少两位、状态映射错、时间差&amp;nbsp;8&amp;nbsp;小时&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;3&amp;nbsp;天起，且常由客户先发现&amp;nbsp;&amp;nbsp;&amp;nbsp;|

注意字段映射那一行——**它经常没有报错**。这是三个环节里唯一可能酿成资损的一类，放最后一节重点讲。

##&amp;nbsp;二、鉴权：三种&amp;nbsp;token，三种死法

三平台的授权模型长得像，细节全不一样。先列差异：

|&amp;nbsp;平台&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;凭证结构&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;有效期&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;过期前能刷新吗&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;----&amp;nbsp;|&amp;nbsp;----------------------------------&amp;nbsp;|&amp;nbsp;-----------------------&amp;nbsp;|&amp;nbsp;------------------------&amp;nbsp;|
|&amp;nbsp;淘宝&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;`appkey`&amp;nbsp;+&amp;nbsp;`sessionKey`（绑定商家）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;长期但可被商家主动失效&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;会话失效后需商家重新授权，**无法程序化刷新**&amp;nbsp;|
|&amp;nbsp;京东&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;`app_key`&amp;nbsp;+&amp;nbsp;`access_token`（OAuth2）&amp;nbsp;|&amp;nbsp;通常&amp;nbsp;1&amp;nbsp;年，refresh_token&amp;nbsp;更长&amp;nbsp;|&amp;nbsp;可以，用&amp;nbsp;refresh_token&amp;nbsp;换新&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;1688&amp;nbsp;|&amp;nbsp;`appkey`&amp;nbsp;+&amp;nbsp;`access_token`（OAuth2）&amp;nbsp;&amp;nbsp;|&amp;nbsp;约&amp;nbsp;10&amp;nbsp;小时（各应用不同）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;可以，refresh_token&amp;nbsp;换新&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

**死法一：混淆&amp;nbsp;app&amp;nbsp;级凭证和用户级凭证。**&amp;nbsp;&amp;nbsp;
`appkey/secret`&amp;nbsp;只代表「你的应用」，调用商品、订单接口还必须带上「商家授权」的&amp;nbsp;token。最经典的事故：测试环境用&amp;nbsp;A&amp;nbsp;商家的&amp;nbsp;session，生产环境忘了换，结果测试数据全对、上线后拉的全是另一个店铺的商品——**接口返回&amp;nbsp;200，数据是别人的**。所以凭证要「应用&amp;nbsp;×&amp;nbsp;商家」二元组建模，别把&amp;nbsp;sessionKey&amp;nbsp;存成全局配置。

**死法二：拿本地时钟判断&amp;nbsp;token&amp;nbsp;过期。**&amp;nbsp;&amp;nbsp;
token&amp;nbsp;的过期时间是平台返回的&amp;nbsp;`expires_in`（剩余秒数），如果你存的是&amp;nbsp;`本地&amp;nbsp;now&amp;nbsp;+&amp;nbsp;expires_in`，服务器时钟漂移&amp;nbsp;5&amp;nbsp;分钟，token&amp;nbsp;就会提前或延后&amp;nbsp;5&amp;nbsp;分钟失效。**必须存「到期时间点」，且以收到响应那一刻为基准**，再留安全余量。

**死法三：淘宝的「商家悄悄解绑」。**&amp;nbsp;&amp;nbsp;
淘宝&amp;nbsp;session&amp;nbsp;没有程序化刷新路径，商家在千牛后台解除授权后，你的&amp;nbsp;token&amp;nbsp;立刻全挂，且**没有提前通知**。这是淘宝接入最阴的一雷。解法是健康探测（第&amp;nbsp;9&amp;nbsp;篇的&amp;nbsp;PROBES&amp;nbsp;思路）+&amp;nbsp;授权状态监控，挂了第一时间找到商家重新授权。

代码上，一个最小可用的&amp;nbsp;`TokenManager`：

```python
#&amp;nbsp;auth/tokens.py
import&amp;nbsp;asyncio
import&amp;nbsp;time
from&amp;nbsp;dataclasses&amp;nbsp;import&amp;nbsp;dataclass,&amp;nbsp;field


@dataclass
class&amp;nbsp;Token:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;value:&amp;nbsp;str
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;refresh_value:&amp;nbsp;str&amp;nbsp;|&amp;nbsp;None
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;expires_at:&amp;nbsp;float&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;平台响应到达那一刻&amp;nbsp;+&amp;nbsp;expires_in，时间点而非时长
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;refresh_expires_at:&amp;nbsp;float&amp;nbsp;|&amp;nbsp;None&amp;nbsp;=&amp;nbsp;None

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;needs_refresh(self,&amp;nbsp;margin_s:&amp;nbsp;int&amp;nbsp;=&amp;nbsp;600)&amp;nbsp;-&amp;gt;&amp;nbsp;bool:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;提前&amp;nbsp;10&amp;nbsp;分钟就判为「该刷了」，余量吃掉网络延迟和时钟误差&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;time.time()&amp;nbsp;&amp;gt;=&amp;nbsp;self.expires_at&amp;nbsp;-&amp;nbsp;margin_s

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
class&amp;nbsp;TokenManager:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;按&amp;nbsp;(platform,&amp;nbsp;seller_id)&amp;nbsp;二元组管理，杜绝串店铺&amp;quot;&amp;quot;&amp;quot;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;__init__(self,&amp;nbsp;store):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.store&amp;nbsp;=&amp;nbsp;store&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;持久层：sqlite&amp;nbsp;/&amp;nbsp;redis，重启不丢
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._locks:&amp;nbsp;dict[str,&amp;nbsp;asyncio.Lock]&amp;nbsp;=&amp;nbsp;{}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._mem:&amp;nbsp;dict[str,&amp;nbsp;Token]&amp;nbsp;=&amp;nbsp;{}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;_lock(self,&amp;nbsp;key:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;asyncio.Lock:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;key&amp;nbsp;not&amp;nbsp;in&amp;nbsp;self._locks:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._locks[key]&amp;nbsp;=&amp;nbsp;asyncio.Lock()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;self._locks[key]

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;get(self,&amp;nbsp;platform:&amp;nbsp;str,&amp;nbsp;seller_id:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;key&amp;nbsp;=&amp;nbsp;f&amp;quot;{platform}:{seller_id}&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;tok&amp;nbsp;=&amp;nbsp;self._mem.get(key)&amp;nbsp;or&amp;nbsp;await&amp;nbsp;self.store.load(key)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;tok&amp;nbsp;is&amp;nbsp;None:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;AuthError(f&amp;quot;{key}&amp;nbsp;无凭证，需要商家重新授权&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;tok.needs_refresh():
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;with&amp;nbsp;self._lock(key):&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;单飞：并发请求只触发一次刷新
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;tok&amp;nbsp;=&amp;nbsp;self._mem.get(key)&amp;nbsp;or&amp;nbsp;tok
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;tok.needs_refresh():&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;双检：拿锁后再确认一次
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;tok&amp;nbsp;=&amp;nbsp;await&amp;nbsp;self._refresh(platform,&amp;nbsp;seller_id,&amp;nbsp;tok)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;except&amp;nbsp;RefreshFailed:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;refresh_token&amp;nbsp;也失效：只能走重新授权，主动告警而不是等调用报错
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;self.store.mark_broken(key)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;AuthError(f&amp;quot;{key}&amp;nbsp;refresh_token&amp;nbsp;已失效，需商家重新授权&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;tok.value

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;_refresh(self,&amp;nbsp;platform,&amp;nbsp;seller_id,&amp;nbsp;tok)&amp;nbsp;-&amp;gt;&amp;nbsp;Token:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;platform&amp;nbsp;==&amp;nbsp;&amp;quot;taobao&amp;quot;:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;RefreshFailed(&amp;quot;淘宝无程序化刷新，走重新授权流程&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;京东&amp;nbsp;/&amp;nbsp;1688：POST&amp;nbsp;token&amp;nbsp;端点，grant_type=refresh_token
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;resp&amp;nbsp;=&amp;nbsp;await&amp;nbsp;oauth_refresh(platform,&amp;nbsp;tok.refresh_value)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;new&amp;nbsp;=&amp;nbsp;Token(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;value=resp[&amp;quot;access_token&amp;quot;],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;refresh_value=resp.get(&amp;quot;refresh_token&amp;quot;,&amp;nbsp;tok.refresh_value),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;expires_at=time.time()&amp;nbsp;+&amp;nbsp;int(resp[&amp;quot;expires_in&amp;quot;]),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;refresh_expires_at=time.time()&amp;nbsp;+&amp;nbsp;int(resp.get(&amp;quot;refresh_token_valid_time&amp;quot;,&amp;nbsp;0)),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;key&amp;nbsp;=&amp;nbsp;f&amp;quot;{platform}:{seller_id}&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self._mem[key]&amp;nbsp;=&amp;nbsp;new
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;self.store.save(key,&amp;nbsp;new)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;new
```

三个细节值得注意：

1.&amp;nbsp;**`needs_refresh`&amp;nbsp;提前&amp;nbsp;10&amp;nbsp;分钟**——不是&amp;nbsp;token&amp;nbsp;到期才刷，余量吃掉时钟误差和刷新请求本身失败后的重试空间。
2.&amp;nbsp;**单飞锁&amp;nbsp;+&amp;nbsp;双检**——高并发下&amp;nbsp;100&amp;nbsp;个请求同时发现&amp;nbsp;token&amp;nbsp;过期，只允许&amp;nbsp;1&amp;nbsp;个真正去刷新，其余等结果。没有锁就是「并发刷新撞车」：后发的刷新会把先发的&amp;nbsp;token&amp;nbsp;作废（京东和&amp;nbsp;1688&amp;nbsp;都有这个行为），自己把自己刷死。多店铺规模上去之后这块还要加密存储和分店隔离，是下一篇的主题。
3.&amp;nbsp;**淘宝直接抛&amp;nbsp;`RefreshFailed`**——诚实地告诉调用方「这条路径不存在」，别写个永远成功的假刷新。

##&amp;nbsp;三、签名：报错只说错了，不说错在哪

签名错误是接入期**单点耗时最长**的环节。原因很简单：平台不会告诉你它算签名的输入是什么，你只能猜自己哪个参数「脏」了。

先给三平台签名算法速查（实现细节第&amp;nbsp;11&amp;nbsp;篇有完整代码，这里只列排障需要的差异点）：

|&amp;nbsp;平台&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;算法&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;关键差异&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;最高频错误&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;----&amp;nbsp;|&amp;nbsp;----------------------------&amp;nbsp;|&amp;nbsp;----------------------------&amp;nbsp;|&amp;nbsp;--------------------&amp;nbsp;|
|&amp;nbsp;淘宝&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;MD5（`secret&amp;nbsp;+&amp;nbsp;排序串&amp;nbsp;+&amp;nbsp;secret`）&amp;nbsp;|&amp;nbsp;GET/POST&amp;nbsp;均按参数排序拼&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;参数没按&amp;nbsp;ASCII&amp;nbsp;排序&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;京东&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;MD5（`secret&amp;nbsp;+&amp;nbsp;排序串&amp;nbsp;+&amp;nbsp;secret`）&amp;nbsp;|&amp;nbsp;业务参数整体塞进&amp;nbsp;`360buy_param_json`&amp;nbsp;|&amp;nbsp;JSON&amp;nbsp;中文被转义成&amp;nbsp;`\uXXXX`&amp;nbsp;|
|&amp;nbsp;1688&amp;nbsp;|&amp;nbsp;HMAC-SHA1&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;**urlPath&amp;nbsp;参与签名**&amp;nbsp;+&amp;nbsp;**毫秒时间戳**&amp;nbsp;|&amp;nbsp;时间戳用了秒&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

###&amp;nbsp;签名错误&amp;nbsp;Top&amp;nbsp;5（按我们工单频次排序）

**①&amp;nbsp;时间戳单位或时区错。**&amp;nbsp;1688&amp;nbsp;要毫秒，淘宝/京东用秒也能过但会有诡异边界；服务器是&amp;nbsp;UTC&amp;nbsp;时区的话，某些按「分钟窗口」校验时间戳的接口直接全挂。**解法：时间戳永远显式生成，别用框架默认值。**

**②&amp;nbsp;参数没按&amp;nbsp;ASCII&amp;nbsp;字典序排。**&amp;nbsp;Python&amp;nbsp;的&amp;nbsp;`sorted()`&amp;nbsp;恰好就是&amp;nbsp;ASCII&amp;nbsp;序，所以纯&amp;nbsp;Python&amp;nbsp;代码很少踩；踩坑的都是「从别的语言抄的签名代码」或者「自己手写了拼接顺序」。

**③&amp;nbsp;中文被转义。**&amp;nbsp;京东最典型：`360buy_param_json`&amp;nbsp;里的中文商品名，`json.dumps`&amp;nbsp;默认&amp;nbsp;`ensure_ascii=True`&amp;nbsp;会转成&amp;nbsp;`\uXXXX`——签名按转义后的算，服务端按原文验，**永远对不上**。一行&amp;nbsp;`ensure_ascii=False`&amp;nbsp;解决，但不知道的时候能卡一天半。

**④&amp;nbsp;空值参与了签名。**&amp;nbsp;某些平台规定空参数不参与签名，你把&amp;nbsp;`&amp;quot;&amp;quot;`&amp;nbsp;也拼进去了。规则各平台不同，**拿不准就试：先全拼，报错再去掉空值试一次**。

**⑤&amp;nbsp;secret&amp;nbsp;泄露后的轮换灾难。**&amp;nbsp;secret&amp;nbsp;提交到公开仓库后去平台后台重置，结果忘了旧&amp;nbsp;secret&amp;nbsp;还配在测试机&amp;nbsp;cron&amp;nbsp;里——旧任务报签名错，新任务正常，两边互相干扰极难定位。**secret&amp;nbsp;必须集中存放、一处引用**。

###&amp;nbsp;排查方法：把「实际发出去的」打出来比对

下面这个&amp;nbsp;`sign_debug.py`&amp;nbsp;是我们内部排查签名问题的工具，思路是：**本地复算签名，同时把参与签名的&amp;nbsp;canonical&amp;nbsp;string&amp;nbsp;完整打出来**。拿它和平台的报错对照，90%&amp;nbsp;的签名错误肉眼就能看出来：

```python
#&amp;nbsp;sign_debug.py&amp;nbsp;——&amp;nbsp;签名排障工具：复算&amp;nbsp;+&amp;nbsp;canonical&amp;nbsp;string&amp;nbsp;可视化
import&amp;nbsp;hashlib
import&amp;nbsp;hmac
import&amp;nbsp;json
import&amp;nbsp;time
from&amp;nbsp;urllib.parse&amp;nbsp;import&amp;nbsp;quote


def&amp;nbsp;canonical(params:&amp;nbsp;dict)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;淘宝/京东式&amp;nbsp;canonical&amp;nbsp;string：ASCII&amp;nbsp;序&amp;nbsp;k=v&amp;nbsp;直接相连，跳过空值和签名本身&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;pairs&amp;nbsp;=&amp;nbsp;sorted((k,&amp;nbsp;v)&amp;nbsp;for&amp;nbsp;k,&amp;nbsp;v&amp;nbsp;in&amp;nbsp;params.items()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;v&amp;nbsp;not&amp;nbsp;in&amp;nbsp;(&amp;quot;&amp;quot;,&amp;nbsp;None)&amp;nbsp;and&amp;nbsp;k&amp;nbsp;!=&amp;nbsp;&amp;quot;sign&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;&amp;quot;&amp;quot;.join(f&amp;quot;{k}{v}&amp;quot;&amp;nbsp;for&amp;nbsp;k,&amp;nbsp;v&amp;nbsp;in&amp;nbsp;pairs)


def&amp;nbsp;sign_taobao(params:&amp;nbsp;dict,&amp;nbsp;secret:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raw&amp;nbsp;=&amp;nbsp;f&amp;quot;{secret}{canonical(params)}{secret}&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;hashlib.md5(raw.encode(&amp;quot;utf-8&amp;quot;)).hexdigest().upper()


def&amp;nbsp;sign_jd(params:&amp;nbsp;dict,&amp;nbsp;secret:&amp;nbsp;str,&amp;nbsp;body:&amp;nbsp;dict&amp;nbsp;|&amp;nbsp;None&amp;nbsp;=&amp;nbsp;None)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;body&amp;nbsp;is&amp;nbsp;not&amp;nbsp;None:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;关键：ensure_ascii=False，中文按原文参与签名
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;params&amp;nbsp;=&amp;nbsp;{**params,&amp;nbsp;&amp;quot;360buy_param_json&amp;quot;:&amp;nbsp;json.dumps(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;body,&amp;nbsp;ensure_ascii=False,&amp;nbsp;separators=(&amp;quot;,&amp;quot;,&amp;nbsp;&amp;quot;:&amp;quot;))}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raw&amp;nbsp;=&amp;nbsp;f&amp;quot;{secret}{canonical(params)}{secret}&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;hashlib.md5(raw.encode(&amp;quot;utf-8&amp;quot;)).hexdigest().upper()


def&amp;nbsp;sign_alibaba(params:&amp;nbsp;dict,&amp;nbsp;secret:&amp;nbsp;str,&amp;nbsp;url_path:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;str:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;1688：urlPath&amp;nbsp;+&amp;nbsp;排序串，HMAC-SHA1，毫秒时间戳
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;params&amp;nbsp;=&amp;nbsp;{**params,&amp;nbsp;&amp;quot;_aop_timestamp&amp;quot;:&amp;nbsp;str(int(time.time()&amp;nbsp;*&amp;nbsp;1000))}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ordered&amp;nbsp;=&amp;nbsp;sorted((k,&amp;nbsp;v)&amp;nbsp;for&amp;nbsp;k,&amp;nbsp;v&amp;nbsp;in&amp;nbsp;params.items()&amp;nbsp;if&amp;nbsp;v&amp;nbsp;not&amp;nbsp;in&amp;nbsp;(&amp;quot;&amp;quot;,&amp;nbsp;None))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raw&amp;nbsp;=&amp;nbsp;url_path&amp;nbsp;+&amp;nbsp;&amp;quot;&amp;quot;.join(f&amp;quot;{k}{v}&amp;quot;&amp;nbsp;for&amp;nbsp;k,&amp;nbsp;v&amp;nbsp;in&amp;nbsp;ordered)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;digest&amp;nbsp;=&amp;nbsp;hmac.new(secret.encode(),&amp;nbsp;raw.encode(),&amp;nbsp;hashlib.sha1).hexdigest().upper()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;digest

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
def&amp;nbsp;debug_dump(name:&amp;nbsp;str,&amp;nbsp;params:&amp;nbsp;dict,&amp;nbsp;secret:&amp;nbsp;str,&amp;nbsp;url_path:&amp;nbsp;str&amp;nbsp;=&amp;nbsp;&amp;quot;&amp;quot;):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;把参与签名的每一步都打出来，和平台的报错对照&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cs&amp;nbsp;=&amp;nbsp;url_path&amp;nbsp;+&amp;nbsp;canonical(params)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;===&amp;nbsp;{name}&amp;nbsp;===&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;k,&amp;nbsp;v&amp;nbsp;in&amp;nbsp;sorted(params.items()):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;&amp;nbsp;&amp;nbsp;{k!r:28}&amp;nbsp;-&amp;gt;&amp;nbsp;{v!r}&amp;quot;)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;repr&amp;nbsp;能暴露首尾空格、全角字符
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;&amp;nbsp;&amp;nbsp;canonical&amp;nbsp;({len(cs)}&amp;nbsp;chars):&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{cs}&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;print(f&amp;quot;&amp;nbsp;&amp;nbsp;sign:&amp;nbsp;{sign_taobao(params,&amp;nbsp;secret)}&amp;quot;)


if&amp;nbsp;__name__&amp;nbsp;==&amp;nbsp;&amp;quot;__main__&amp;quot;:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;debug_dump(&amp;quot;taobao.item.get&amp;quot;,&amp;nbsp;{
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;method&amp;quot;:&amp;nbsp;&amp;quot;taobao.item.get&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;app_key&amp;quot;:&amp;nbsp;&amp;quot;your_appkey&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;timestamp&amp;quot;:&amp;nbsp;&amp;quot;2026-10-29&amp;nbsp;15:00:00&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;num_iid&amp;quot;:&amp;nbsp;&amp;quot;683625971234&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;fields&amp;quot;:&amp;nbsp;&amp;quot;num_iid,title,price&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;session&amp;quot;:&amp;nbsp;&amp;quot;your_session&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;note&amp;nbsp;&amp;quot;:&amp;nbsp;&amp;quot;value&amp;nbsp;ends&amp;nbsp;with&amp;nbsp;a&amp;nbsp;space&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;故意放一个带尾空格的参数做演示
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;},&amp;nbsp;&amp;quot;your_secret&amp;quot;)
```

用法：报签名错的时候，把**你实际发请求用的那份参数**（不是你以为的那份——从&amp;nbsp;httpx&amp;nbsp;的请求日志或抓包里拿）灌进&amp;nbsp;`debug_dump`，然后：

1.&amp;nbsp;看&amp;nbsp;`repr`&amp;nbsp;输出里有没有**首尾空格、全角字符、大小写不一致**的&amp;nbsp;key；
2.&amp;nbsp;看&amp;nbsp;canonical&amp;nbsp;string&amp;nbsp;的长度对不对——多一段少一段，二分着删参数试；
3.&amp;nbsp;时间戳单独确认单位（秒/毫秒）和格式。

**一个能省&amp;nbsp;80%&amp;nbsp;排查时间的习惯**：签名函数写好后立刻用官方文档里的「签名示例」跑一遍断言（三平台文档都附了带预期结果的示例参数），断言不过说明你的实现有问题，别等真请求了才发现。

##&amp;nbsp;四、字段映射：不报错，只出事

前两个环节的错误平台会告诉你（虽然说得含糊），字段映射的错误**平台根本不知道错了**——你拉回来的就是一份合法响应，只是你读它的方式不对。这类问题的共同特征：**上线数天后由运营或客户发现，排查时谁也不怀疑数据管道**。

###&amp;nbsp;三类错误&amp;nbsp;+&amp;nbsp;一个语义陷阱

**①&amp;nbsp;数值类型。**&amp;nbsp;三个平台的价格给法不一样：淘宝给字符串&amp;nbsp;`&amp;quot;59.90&amp;quot;`，京东给「分」为单位的整数&amp;nbsp;`5990`，1688&amp;nbsp;阶梯价给嵌套数组。最恶劣的是&amp;nbsp;`5990`——它既能当「分」也能当「元」，**错了不报错，只差&amp;nbsp;100&amp;nbsp;倍**。铁律：入库前统一转&amp;nbsp;`Decimal`，转换时必须显式指定来源单位。

**②&amp;nbsp;时间字段。**&amp;nbsp;秒还是毫秒、UTC&amp;nbsp;还是北京时间、字符串还是整数——三平台三样，甚至同平台不同接口都不一样。差&amp;nbsp;8&amp;nbsp;小时的时区错误会让「今日订单」报表永远差一半，而财务通常在月底才对出来。铁律：**收到即归一**，全部转成&amp;nbsp;UTC&amp;nbsp;毫秒时间戳存库，展示层再转本地。

**③&amp;nbsp;状态枚举。**&amp;nbsp;订单状态三平台完全不同：淘宝&amp;nbsp;`WAIT_SELLER_SEND_GOODS`，京东&amp;nbsp;`WAIT_SELLER_STOCK_OUT`，1688&amp;nbsp;用数字码。直接把平台状态存库，后面每写一个业务逻辑都要写一遍三平台的&amp;nbsp;if-else。铁律：**入库即映射到自有状态机**（第&amp;nbsp;3&amp;nbsp;篇的订单状态机就是那套字典）。

**④&amp;nbsp;空值语义陷阱（最隐蔽）。**&amp;nbsp;`&amp;quot;price&amp;quot;:&amp;nbsp;null`、`&amp;quot;price&amp;quot;:&amp;nbsp;&amp;quot;&amp;quot;`、key&amp;nbsp;整个不出现——三种「没有价格」的含义完全不同：null&amp;nbsp;通常是「该字段不适用」，空字符串可能是「平台接口有&amp;nbsp;bug」，key&amp;nbsp;缺失可能是「你没申请这个字段的权限」。**不区分这三种，价格同步会把一批「查询权限没申请」的商品当成「无货」处理**，这直接就是资损。

###&amp;nbsp;声明式映射：把「翻译规则」和数据代码分开

解决思路是老三样（第&amp;nbsp;2&amp;nbsp;篇的统一模型），这里给出映射层的可运行骨架，重点是**把单位、时区、枚举、空值四类规则显式声明出来**，而不是散落在业务代码里：

```python
#&amp;nbsp;mapping.py&amp;nbsp;——&amp;nbsp;声明式字段映射&amp;nbsp;+&amp;nbsp;空值语义
from&amp;nbsp;dataclasses&amp;nbsp;import&amp;nbsp;dataclass
from&amp;nbsp;decimal&amp;nbsp;import&amp;nbsp;Decimal


@dataclass
class&amp;nbsp;FieldSpec:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;path:&amp;nbsp;str&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;平台响应里的取值路径
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;to:&amp;nbsp;str&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;统一模型字段名
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;unit:&amp;nbsp;str&amp;nbsp;=&amp;nbsp;&amp;quot;raw&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;&amp;quot;raw&amp;quot;&amp;nbsp;|&amp;nbsp;&amp;quot;cent&amp;quot;&amp;nbsp;|&amp;nbsp;&amp;quot;second&amp;quot;&amp;nbsp;|&amp;nbsp;&amp;quot;millisecond&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;enum:&amp;nbsp;dict&amp;nbsp;|&amp;nbsp;None&amp;nbsp;=&amp;nbsp;None&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;平台状态&amp;nbsp;-&amp;gt;&amp;nbsp;自有状态
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;required:&amp;nbsp;bool&amp;nbsp;=&amp;nbsp;True


def&amp;nbsp;pick(data:&amp;nbsp;dict,&amp;nbsp;path:&amp;nbsp;str):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;按&amp;nbsp;a.b.c&amp;nbsp;取值；区分三种空：键缺失&amp;nbsp;/&amp;nbsp;null&amp;nbsp;/&amp;nbsp;空串&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cur&amp;nbsp;=&amp;nbsp;data
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;part&amp;nbsp;in&amp;nbsp;path.split(&amp;quot;.&amp;quot;):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;not&amp;nbsp;isinstance(cur,&amp;nbsp;dict)&amp;nbsp;or&amp;nbsp;part&amp;nbsp;not&amp;nbsp;in&amp;nbsp;cur:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Missing.ABSENT&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;key&amp;nbsp;缺失：多为权限未申请
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cur&amp;nbsp;=&amp;nbsp;cur[part]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;cur&amp;nbsp;is&amp;nbsp;None:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Missing.NULL&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;明确的&amp;nbsp;null：字段不适用
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;cur&amp;nbsp;==&amp;nbsp;&amp;quot;&amp;quot;:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Missing.EMPTY&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;空串：疑似平台侧异常，单独上报
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;cur


class&amp;nbsp;Missing:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ABSENT&amp;nbsp;=&amp;nbsp;object();&amp;nbsp;NULL&amp;nbsp;=&amp;nbsp;object();&amp;nbsp;EMPTY&amp;nbsp;=&amp;nbsp;object()


def&amp;nbsp;normalize(raw,&amp;nbsp;spec:&amp;nbsp;FieldSpec):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;v&amp;nbsp;=&amp;nbsp;pick(raw,&amp;nbsp;spec.path)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;isinstance(v,&amp;nbsp;type(Missing.ABSENT)):&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;用&amp;nbsp;is&amp;nbsp;判定哨兵对象
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;spec.required:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;MappingError(f&amp;quot;{spec.path}&amp;nbsp;缺失：检查字段权限是否已申请&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;None
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;v&amp;nbsp;is&amp;nbsp;Missing.NULL:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;None
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;v&amp;nbsp;is&amp;nbsp;Missing.EMPTY:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;MappingError(f&amp;quot;{spec.path}&amp;nbsp;为空串：疑似接口异常，人工确认&amp;quot;)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;spec.unit&amp;nbsp;==&amp;nbsp;&amp;quot;cent&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;京东价格：分&amp;nbsp;-&amp;gt;&amp;nbsp;元
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;v&amp;nbsp;=&amp;nbsp;Decimal(v)&amp;nbsp;/&amp;nbsp;100
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;elif&amp;nbsp;spec.unit&amp;nbsp;==&amp;nbsp;&amp;quot;second&amp;quot;:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;秒&amp;nbsp;-&amp;gt;&amp;nbsp;毫秒
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;v&amp;nbsp;=&amp;nbsp;int(v)&amp;nbsp;*&amp;nbsp;1000
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;spec.enum:&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;状态映射，未收录的显式报错
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;v&amp;nbsp;not&amp;nbsp;in&amp;nbsp;spec.enum:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raise&amp;nbsp;MappingError(f&amp;quot;未收录状态&amp;nbsp;{v!r}，先补映射表再上线&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;v&amp;nbsp;=&amp;nbsp;spec.enum[v]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;v


#&amp;nbsp;----&amp;nbsp;三平台的映射表：规则集中在这里，业务代码只见统一模型&amp;nbsp;----
TAOBAO_TRADE&amp;nbsp;=&amp;nbsp;[
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;FieldSpec(&amp;quot;tid&amp;quot;,&amp;nbsp;&amp;quot;order_no&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;FieldSpec(&amp;quot;created&amp;quot;,&amp;nbsp;&amp;quot;created_at&amp;quot;,&amp;nbsp;unit=&amp;quot;second&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;FieldSpec(&amp;quot;status&amp;quot;,&amp;nbsp;&amp;quot;status&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;enum={&amp;quot;WAIT_SELLER_SEND_GOODS&amp;quot;:&amp;nbsp;&amp;quot;TO_SHIP&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;TRADE_FINISHED&amp;quot;:&amp;nbsp;&amp;quot;DONE&amp;quot;}),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;FieldSpec(&amp;quot;payment&amp;quot;,&amp;nbsp;&amp;quot;paid_amount&amp;quot;),&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;淘宝字符串元
]

JD_TRADE&amp;nbsp;=&amp;nbsp;[
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;FieldSpec(&amp;quot;orderId&amp;quot;,&amp;nbsp;&amp;quot;order_no&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;FieldSpec(&amp;quot;orderStartTime&amp;quot;,&amp;nbsp;&amp;quot;created_at&amp;quot;,&amp;nbsp;unit=&amp;quot;millisecond&amp;quot;),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;FieldSpec(&amp;quot;orderState&amp;quot;,&amp;nbsp;&amp;quot;status&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;enum={&amp;quot;WAIT_SELLER_STOCK_OUT&amp;quot;:&amp;nbsp;&amp;quot;TO_SHIP&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;FINISHED&amp;quot;:&amp;nbsp;&amp;quot;DONE&amp;quot;}),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;FieldSpec(&amp;quot;orderPrice&amp;quot;,&amp;nbsp;&amp;quot;paid_amount&amp;quot;,&amp;nbsp;unit=&amp;quot;cent&amp;quot;),&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;京东分
]


def&amp;nbsp;map_trade(raw:&amp;nbsp;dict,&amp;nbsp;table:&amp;nbsp;list[FieldSpec])&amp;nbsp;-&amp;gt;&amp;nbsp;dict:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;{spec.to:&amp;nbsp;normalize(raw,&amp;nbsp;spec)&amp;nbsp;for&amp;nbsp;spec&amp;nbsp;in&amp;nbsp;table}
```


```

这套结构的价值在三个「显式」：

1.&amp;nbsp;**单位显式**——`unit=&amp;quot;cent&amp;quot;`&amp;nbsp;写在映射表里，永远不会有人把京东的价格直接当元用；
2.&amp;nbsp;**枚举显式**——平台新增一个状态码，映射层当场报错拦下，而不是静默落一个未知状态进库；
3.&amp;nbsp;**空值显式**——三种「没有」三种处理，权限没申请的问题在管道层就炸出来，不会变成「无货」。

再配一个&amp;nbsp;**round-trip&amp;nbsp;自检**，映射表改坏了&amp;nbsp;CI&amp;nbsp;直接红：

```python
def&amp;nbsp;test_roundtrip():
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sample&amp;nbsp;=&amp;nbsp;{&amp;quot;orderId&amp;quot;:&amp;nbsp;&amp;quot;JD123&amp;quot;,&amp;nbsp;&amp;quot;orderStartTime&amp;quot;:&amp;nbsp;1730000000000,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;orderState&amp;quot;:&amp;nbsp;&amp;quot;WAIT_SELLER_STOCK_OUT&amp;quot;,&amp;nbsp;&amp;quot;orderPrice&amp;quot;:&amp;nbsp;5990}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;out&amp;nbsp;=&amp;nbsp;map_trade(sample,&amp;nbsp;JD_TRADE)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;assert&amp;nbsp;out[&amp;quot;order_no&amp;quot;]&amp;nbsp;==&amp;nbsp;&amp;quot;JD123&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;assert&amp;nbsp;out[&amp;quot;status&amp;quot;]&amp;nbsp;==&amp;nbsp;&amp;quot;TO_SHIP&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;assert&amp;nbsp;out[&amp;quot;paid_amount&amp;quot;]&amp;nbsp;==&amp;nbsp;Decimal(&amp;quot;59.90&amp;quot;)&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;分&amp;nbsp;-&amp;gt;&amp;nbsp;元，别差&amp;nbsp;100&amp;nbsp;倍
```

##&amp;nbsp;五、翻车速查表：先定位环节，再查原因

最后把三个环节收成一张速查表。翻车的时候**先对症状定环节**，再去对应小节查原因，比盲目重试省一个工作日：

|&amp;nbsp;症状&amp;nbsp;|&amp;nbsp;环节&amp;nbsp;|&amp;nbsp;最可能的原因（按概率排序）&amp;nbsp;|
|---|---|---|
|&amp;nbsp;所有请求统一报鉴权/签名错，昨天还好好的&amp;nbsp;|&amp;nbsp;鉴权&amp;nbsp;|&amp;nbsp;token&amp;nbsp;过期&amp;nbsp;/&amp;nbsp;商家解绑授权&amp;nbsp;/&amp;nbsp;secret&amp;nbsp;被轮换&amp;nbsp;|
|&amp;nbsp;只有部分商家报鉴权错&amp;nbsp;|&amp;nbsp;鉴权&amp;nbsp;|&amp;nbsp;个别商家&amp;nbsp;session&amp;nbsp;失效，串店铺配置&amp;nbsp;|
|&amp;nbsp;签名错，参数肉眼检查全对&amp;nbsp;|&amp;nbsp;签名&amp;nbsp;|&amp;nbsp;中文被转义（京东）/&amp;nbsp;时间戳单位（1688&amp;nbsp;毫秒）|
|&amp;nbsp;签名错，加一个参数就开始报错&amp;nbsp;|&amp;nbsp;签名&amp;nbsp;|&amp;nbsp;新参数含特殊字符或空值参与规则不同&amp;nbsp;|
|&amp;nbsp;无报错，但数据「看起来不对」&amp;nbsp;|&amp;nbsp;字段映射&amp;nbsp;|&amp;nbsp;价格单位（分/元）、时间时区（8&amp;nbsp;小时）|
|&amp;nbsp;某类商品的数据总是缺字段&amp;nbsp;|&amp;nbsp;字段映射&amp;nbsp;|&amp;nbsp;字段权限未申请，pick&amp;nbsp;到&amp;nbsp;ABSENT&amp;nbsp;|
|&amp;nbsp;报表数字对不上，接口日志全绿&amp;nbsp;|&amp;nbsp;字段映射&amp;nbsp;|&amp;nbsp;状态映射漏了新枚举&amp;nbsp;/&amp;nbsp;空值被当&amp;nbsp;0&amp;nbsp;|

三个环节的共同预防措施其实就一条：**接入期写冒烟测试（第&amp;nbsp;6&amp;nbsp;篇的&amp;nbsp;9&amp;nbsp;项），把签名断言、token&amp;nbsp;刷新、字段映射&amp;nbsp;round-trip&amp;nbsp;都放进&amp;nbsp;CI**。翻车不可怕，可怕的是翻车发生在客户通知你之后。

---

这三件事做扎实，接入期&amp;nbsp;80%&amp;nbsp;的坑就避掉了。剩下的坑集中在**多店铺授权管理**——token&amp;nbsp;加密存储、几十家店并发刷新撞车、单店过期不拖累其他店——那是下一篇的主题，也是整个接入系列的收尾。

如果不想自己踩这些坑，也可以直接用我们封装好的接口服务，鉴权续期、签名托管、字段归一这些都是现成的，欢迎评论区或私信聊。

---

*系列文章：上一篇《商品详情、SKU、价格、库存，四个高频接口的正确调用姿势》讲了怎么调；这篇讲接入时怎么不翻车。*&lt;/pre&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;</description><pubDate>Thu, 29 Oct 2026 16:13:31 +0800</pubDate></item><item><title>商品详情、SKU、价格、库存，四个高频接口的正确调用姿势（附 Python 源码）</title><link>https://www.alexob.com/?id=589</link><description>&lt;pre class=&quot;prism-highlight prism-language-python&quot;&gt;#&amp;nbsp;商品详情、SKU、价格、库存，四个高频接口的正确调用姿势（附&amp;nbsp;Python&amp;nbsp;源码）

先给一个数字：这四个接口，占我们平台**总调用量的&amp;nbsp;85%**。

这个占比意味着两件事。第一，任何调用姿势上的坏习惯，都会被放大&amp;nbsp;85%——配额浪费、数据错位、深夜告警，基本都出在这四个接口上。第二，优化它们的收益也是&amp;nbsp;85%&amp;nbsp;的，比优化任何冷门接口都值。

这篇是手册型文章，和第&amp;nbsp;11&amp;nbsp;篇的对照表配套使用：那篇讲三个平台&amp;quot;有什么不同&amp;quot;，这篇讲&amp;quot;每个接口该怎么调&amp;quot;。四个接口各一份错误姿势清单，最后一个总纲。

---

##&amp;nbsp;〇、总纲：四个接口不是四件事，是一棵树

最常见的错误，是把它们当成四个平级接口、用同一个轮询周期去调。

但它们有严格的依赖关系：

```
商品详情（根）
&amp;nbsp;&amp;nbsp;&amp;nbsp;└──&amp;nbsp;SKU&amp;nbsp;集合（挂在商品上）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;├──&amp;nbsp;价格（挂在&amp;nbsp;SKU&amp;nbsp;上）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;└──&amp;nbsp;库存（挂在&amp;nbsp;SKU&amp;nbsp;上）
```

这个依赖关系推出两条铁律：

**铁律一：按变更频率分级调度。**&amp;nbsp;详情一天变不了几次，库存每秒都在变。捆在一个轮询周期里，要么详情白调（浪费配额），要么库存延迟（业务事故）。

**铁律二：能用一次详情带出的，不要拆四次请求。**&amp;nbsp;三个平台的详情接口几乎都会返回&amp;nbsp;SKU&amp;nbsp;列表和标价——一次请求拿三样东西，这是最大的配额节省。真正需要单独调的只有库存（它要实时）。

|&amp;nbsp;数据&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;变更频率&amp;nbsp;|&amp;nbsp;来源&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;同步方式&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;--------&amp;nbsp;|&amp;nbsp;----&amp;nbsp;|&amp;nbsp;-----------&amp;nbsp;|&amp;nbsp;-------------&amp;nbsp;|
|&amp;nbsp;标题、主图、类目&amp;nbsp;|&amp;nbsp;天级&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;详情接口&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;每日增量&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;SKU&amp;nbsp;集合&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;小时级&amp;nbsp;&amp;nbsp;|&amp;nbsp;详情接口内&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;集合&amp;nbsp;diff&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;价格&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;分钟级&amp;nbsp;&amp;nbsp;|&amp;nbsp;详情&amp;nbsp;/&amp;nbsp;价格接口&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;定时&amp;nbsp;+&amp;nbsp;改价回执&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;库存&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;秒级&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;库存接口&amp;nbsp;/&amp;nbsp;消息推送&amp;nbsp;|&amp;nbsp;**事件优先，轮询兜底**&amp;nbsp;|

下面四个接口的&amp;quot;正确姿势&amp;quot;，全部从这两条铁律推出来。

---

##&amp;nbsp;一、商品详情：它是根，不是流水账

详情接口的错误姿势，集中在&amp;quot;把它当唯一的真相来源&amp;quot;。

|&amp;nbsp;错误姿势&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;后果&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;正确姿势&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;----------------&amp;nbsp;|&amp;nbsp;----------------------&amp;nbsp;|&amp;nbsp;--------------------&amp;nbsp;|
|&amp;nbsp;全量轮询所有商品详情&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;配额爆炸，且&amp;nbsp;90%&amp;nbsp;请求拿回的是没变的数据&amp;nbsp;|&amp;nbsp;列表接口增量筛出变更&amp;nbsp;ID，再按需调详情&amp;nbsp;|
|&amp;nbsp;不传&amp;nbsp;`fields`，全字段拉&amp;nbsp;|&amp;nbsp;响应体积差&amp;nbsp;10&amp;nbsp;倍，解析和带宽都翻倍&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;按需声明字段，用不到的不拉&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;把详情里的价格/库存当业务数据源&amp;nbsp;|&amp;nbsp;数据滞后几分钟到几小时&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;详情只管&amp;quot;慢字段&amp;quot;，快字段走分级调度&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;下架商品按正常结构解析&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;静默拿到空数据或&amp;nbsp;KeyError&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;先判状态位，软删除单独处理&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

四条里最贵的是第二条。很多人不知道详情接口支持字段裁剪，把整个响应体解析一遍，结果：**每次多传&amp;nbsp;80%&amp;nbsp;用不到的字节，配额按调用次数算倒是不亏，但带宽、解析时间、落库量全都翻倍**。字段白名单就一行的事：

```python
DETAIL_FIELDS&amp;nbsp;=&amp;nbsp;[
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;num_iid&amp;quot;,&amp;nbsp;&amp;quot;title&amp;quot;,&amp;nbsp;&amp;quot;pic_url&amp;quot;,&amp;nbsp;&amp;quot;cid&amp;quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;skus&amp;quot;,&amp;nbsp;&amp;quot;price&amp;quot;,&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;SKU&amp;nbsp;集合和标价跟着详情一起拿（铁律二）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;list_time&amp;quot;,&amp;nbsp;&amp;quot;delist_time&amp;quot;,
]

params&amp;nbsp;=&amp;nbsp;{&amp;quot;num_iid&amp;quot;:&amp;nbsp;item_id,&amp;nbsp;&amp;quot;fields&amp;quot;:&amp;nbsp;&amp;quot;,&amp;quot;.join(DETAIL_FIELDS)}
```

第四条单独说一句：**下架商品在不同平台的表现不同**——有的返回错误码，有的返回成功但核心字段为空，有的直接不返回。用第&amp;nbsp;12&amp;nbsp;篇的&amp;nbsp;`integrity.validate`&amp;nbsp;兜底，加上一个状态位判断，别让下架商品在你的库里&amp;quot;看起来还活着&amp;quot;。

---

##&amp;nbsp;二、SKU：它是会消失的

SKU&amp;nbsp;是四个接口里坑最深的，因为它的坑不在&amp;quot;怎么调&amp;quot;，在&amp;quot;调回来之后怎么管&amp;quot;。

|&amp;nbsp;错误姿势&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;后果&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;正确姿势&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;-------------------------&amp;nbsp;|&amp;nbsp;-----------------&amp;nbsp;|&amp;nbsp;----------------------------------&amp;nbsp;|
|&amp;nbsp;只拿&amp;nbsp;`sku_id`&amp;nbsp;当主键&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;多店铺/多平台必然撞键&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;主键&amp;nbsp;=&amp;nbsp;`(platform,&amp;nbsp;item_id,&amp;nbsp;sku_id)`&amp;nbsp;|
|&amp;nbsp;覆盖式更新&amp;nbsp;SKU&amp;nbsp;表&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;商家删掉的&amp;nbsp;SKU&amp;nbsp;永远留在你库里&amp;nbsp;|&amp;nbsp;集合&amp;nbsp;diff，消失的标记&amp;nbsp;inactive&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;`props.split(&amp;quot;:&amp;quot;)`&amp;nbsp;硬拆销售属性&amp;nbsp;|&amp;nbsp;属性名里含冒号就炸&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;结构化解析（第&amp;nbsp;2&amp;nbsp;篇的&amp;nbsp;`_parse_props`）&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;假设每个商品都有&amp;nbsp;SKU&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;单规格商品返回空&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;先判商品规格类型&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

第二条是重灾区。商家改规格是日常操作：把&amp;quot;红色&amp;nbsp;L&amp;quot;删了换成&amp;quot;红色&amp;nbsp;XL&amp;quot;。如果你每次拉到&amp;nbsp;SKU&amp;nbsp;列表就整表覆盖，或者只往上插入新&amp;nbsp;SKU——**被删掉的那个&amp;nbsp;SKU&amp;nbsp;会永远留在你库里，价格库存还挂着旧数据**。客户拿它下单，就是一次客诉。

正确做法是集合&amp;nbsp;diff：

```python
#&amp;nbsp;sku_diff.py
def&amp;nbsp;sku_diff(old_ids:&amp;nbsp;set[str],&amp;nbsp;new_ids:&amp;nbsp;set[str])&amp;nbsp;-&amp;gt;&amp;nbsp;tuple[set[str],&amp;nbsp;set[str]]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;返回&amp;nbsp;(新增,&amp;nbsp;消失)。
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;消失的&amp;nbsp;SKU&amp;nbsp;千万别物理删除——历史订单还引用它。
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;标记&amp;nbsp;inactive，保留可查。
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;new_ids&amp;nbsp;-&amp;nbsp;old_ids,&amp;nbsp;old_ids&amp;nbsp;-&amp;nbsp;new_ids


async&amp;nbsp;def&amp;nbsp;sync_skus(store,&amp;nbsp;item_id:&amp;nbsp;str,&amp;nbsp;new_skus:&amp;nbsp;list[dict]):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;old_ids&amp;nbsp;=&amp;nbsp;set(store.sku_ids(item_id))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;new_ids&amp;nbsp;=&amp;nbsp;{s[&amp;quot;sku_id&amp;quot;]&amp;nbsp;for&amp;nbsp;s&amp;nbsp;in&amp;nbsp;new_skus}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;added,&amp;nbsp;removed&amp;nbsp;=&amp;nbsp;sku_diff(old_ids,&amp;nbsp;new_ids)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;s&amp;nbsp;in&amp;nbsp;new_skus:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;s[&amp;quot;sku_id&amp;quot;]&amp;nbsp;in&amp;nbsp;added:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;store.insert_sku(item_id,&amp;nbsp;s)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;else:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;store.update_sku(item_id,&amp;nbsp;s)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;属性或价格变了

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;sku_id&amp;nbsp;in&amp;nbsp;removed:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;store.mark_inactive(item_id,&amp;nbsp;sku_id)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;软删，订单引用不断
```

另外主键那条：`sku_id`&amp;nbsp;只在单个商品内唯一。做三平台、多店铺的库，主键从第一天就是三元组，**后期改主键的痛苦是接入期的十倍**（第&amp;nbsp;11&amp;nbsp;篇说&amp;nbsp;1688&amp;nbsp;连&amp;nbsp;SKU&amp;nbsp;都没有，那它的&amp;quot;规格&amp;quot;要单独建表，别硬凑）。

---

##&amp;nbsp;三、价格：它不是一个字段，是一组字段

价格的问题出在&amp;quot;你以为价格是一个数&amp;quot;。

|&amp;nbsp;错误姿势&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;后果&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;正确姿势&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;----------&amp;nbsp;|&amp;nbsp;-----------&amp;nbsp;|&amp;nbsp;----------------------&amp;nbsp;|
|&amp;nbsp;只取一个价格字段&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;拿到标价，不是成交价&amp;nbsp;&amp;nbsp;|&amp;nbsp;价格组归一，算&amp;quot;生效价&amp;quot;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;float&amp;nbsp;存价格&amp;nbsp;&amp;nbsp;|&amp;nbsp;账对不上，差几分钱&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;`Decimal`，最后一步才转&amp;nbsp;float&amp;nbsp;|
|&amp;nbsp;改价后立刻读回来验证&amp;nbsp;|&amp;nbsp;读到旧缓存，以为没改成&amp;nbsp;|&amp;nbsp;接受几分钟延迟，或以改价接口回执为准&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;1688&amp;nbsp;只取一个价&amp;nbsp;|&amp;nbsp;阶梯价拿错档位&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;按起订量取档&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;促销价无限期生效&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;过期促销价当现价&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;促销价必须带时效字段&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

第一和第五条组合起来，是最经典的线上事故：**拿到促销价&amp;nbsp;→&amp;nbsp;没记过期时间&amp;nbsp;→&amp;nbsp;促销结束后继续按促销价计算**。利润表和实际对不上，查一周。

正确建模是把价格当成一个带时效的小对象：

```python
#&amp;nbsp;price_model.py
from&amp;nbsp;dataclasses&amp;nbsp;import&amp;nbsp;dataclass
from&amp;nbsp;datetime&amp;nbsp;import&amp;nbsp;datetime
from&amp;nbsp;decimal&amp;nbsp;import&amp;nbsp;Decimal
from&amp;nbsp;zoneinfo&amp;nbsp;import&amp;nbsp;ZoneInfo

TZ&amp;nbsp;=&amp;nbsp;ZoneInfo(&amp;quot;Asia/Shanghai&amp;quot;)


@dataclass
class&amp;nbsp;Price:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sku_id:&amp;nbsp;str
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;list_price:&amp;nbsp;Decimal&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;标价&amp;nbsp;/&amp;nbsp;一口价
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;promo_price:&amp;nbsp;Decimal&amp;nbsp;|&amp;nbsp;None&amp;nbsp;=&amp;nbsp;None&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;促销价
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;promo_end:&amp;nbsp;datetime&amp;nbsp;|&amp;nbsp;None&amp;nbsp;=&amp;nbsp;None&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;促销截止时间

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;effective(self,&amp;nbsp;now:&amp;nbsp;datetime&amp;nbsp;|&amp;nbsp;None&amp;nbsp;=&amp;nbsp;None)&amp;nbsp;-&amp;gt;&amp;nbsp;Decimal:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;生效价：促销在有效期内才生效，过期自动回落标价&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;now&amp;nbsp;=&amp;nbsp;now&amp;nbsp;or&amp;nbsp;datetime.now(TZ)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;self.promo_price&amp;nbsp;is&amp;nbsp;not&amp;nbsp;None:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;self.promo_end&amp;nbsp;is&amp;nbsp;None&amp;nbsp;or&amp;nbsp;self.promo_end&amp;nbsp;&amp;gt;&amp;nbsp;now:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;self.promo_price
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;self.list_price
```

1688&amp;nbsp;的价格是另一个物种——阶梯价，按起订量分档：

```python
def&amp;nbsp;alibaba_price_tiers(raw:&amp;nbsp;list[dict])&amp;nbsp;-&amp;gt;&amp;nbsp;list[tuple[int,&amp;nbsp;Decimal]]:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;1688&amp;nbsp;priceRange：[{beginAmount:&amp;nbsp;2,&amp;nbsp;price:&amp;nbsp;&amp;quot;8.50&amp;quot;},&amp;nbsp;{beginAmount:&amp;nbsp;100,&amp;nbsp;price:&amp;nbsp;&amp;quot;7.20&amp;quot;}]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;按起订量升序排列，采购&amp;nbsp;N&amp;nbsp;件时取满足&amp;nbsp;N&amp;nbsp;的最后一档。
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;tiers&amp;nbsp;=&amp;nbsp;sorted(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;((int(x[&amp;quot;beginAmount&amp;quot;]),&amp;nbsp;Decimal(x[&amp;quot;price&amp;quot;]))&amp;nbsp;for&amp;nbsp;x&amp;nbsp;in&amp;nbsp;raw),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;key=lambda&amp;nbsp;t:&amp;nbsp;t[0],
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;tiers

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
def&amp;nbsp;tier_for(tiers:&amp;nbsp;list[tuple[int,&amp;nbsp;Decimal]],&amp;nbsp;qty:&amp;nbsp;int)&amp;nbsp;-&amp;gt;&amp;nbsp;Decimal:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;price&amp;nbsp;=&amp;nbsp;tiers[0][1]
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;begin,&amp;nbsp;p&amp;nbsp;in&amp;nbsp;tiers:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;qty&amp;nbsp;&amp;gt;=&amp;nbsp;begin:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;price&amp;nbsp;=&amp;nbsp;p
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;price
```

还有改价那条：**改价接口返回成功&amp;nbsp;≠&amp;nbsp;价格已生效**。平台侧有缓存延迟，短则几秒长则几分钟。以改价接口的响应做业务判断，别改完立刻读回来验证——你会拿到旧价格，然后误判&amp;quot;改价失败&amp;quot;再改一次。

---

##&amp;nbsp;四、库存：只有&amp;quot;可售库存&amp;quot;有意义

库存的问题在于：平台给你好几个数，你用了错的那个。

|&amp;nbsp;错误姿势&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;后果&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;正确姿势&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;------------&amp;nbsp;|&amp;nbsp;------------&amp;nbsp;|&amp;nbsp;----------------&amp;nbsp;|
|&amp;nbsp;用&amp;quot;总库存&amp;quot;当可卖数量&amp;nbsp;&amp;nbsp;|&amp;nbsp;超卖（锁定的库存又卖了）&amp;nbsp;|&amp;nbsp;用**可售库存**字段&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;平台库存直接覆盖本地库存&amp;nbsp;|&amp;nbsp;抹掉本地扣减，二次超卖&amp;nbsp;&amp;nbsp;|&amp;nbsp;本地账本&amp;nbsp;+&amp;nbsp;校准（第&amp;nbsp;3&amp;nbsp;篇）&amp;nbsp;|
|&amp;nbsp;和详情同频率轮询&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;延迟大还烧配额&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;事件推送优先，轮询兜底&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;库存为&amp;nbsp;0&amp;nbsp;就当售罄&amp;nbsp;&amp;nbsp;&amp;nbsp;|&amp;nbsp;可能是下架或区域性无货&amp;nbsp;&amp;nbsp;|&amp;nbsp;状态位&amp;nbsp;+&amp;nbsp;库存一起判断&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|
|&amp;nbsp;硬凑&amp;nbsp;1688&amp;nbsp;的库存&amp;nbsp;&amp;nbsp;|&amp;nbsp;它没有&amp;nbsp;SKU&amp;nbsp;库存概念&amp;nbsp;|&amp;nbsp;可售状态&amp;nbsp;+&amp;nbsp;阶梯价建模&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|

第三条是这篇的落地重点。库存是唯一值得上**事件推送**的字段——淘宝的&amp;nbsp;TMC&amp;nbsp;有库存变更消息，订阅它，库存事件毫秒级到达，配额零消耗。轮询只做兜底（比如每&amp;nbsp;5&amp;nbsp;分钟对账一次，防丢消息）：

```python
#&amp;nbsp;stock_sync.py&amp;nbsp;——&amp;nbsp;事件优先，轮询兜底
async&amp;nbsp;def&amp;nbsp;on_stock_event(msg:&amp;nbsp;dict):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;TMC&amp;nbsp;库存变更消息：毫秒级，零配额&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;apply_stock(msg[&amp;quot;item_id&amp;quot;],&amp;nbsp;msg[&amp;quot;sku_id&amp;quot;],&amp;nbsp;msg[&amp;quot;quantity&amp;quot;])

#&amp;nbsp;封装好API供应商demo&amp;nbsp;url=https://console.open.onebound.cn/console/?i=Lex
async&amp;nbsp;def&amp;nbsp;reconcile_loop(gw,&amp;nbsp;store,&amp;nbsp;item_ids:&amp;nbsp;list[str],&amp;nbsp;interval:&amp;nbsp;float&amp;nbsp;=&amp;nbsp;300):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;quot;&amp;quot;&amp;quot;兜底对账：防消息丢失。只查本地&amp;nbsp;5&amp;nbsp;分钟内有订单变动的商品，别全量&amp;quot;&amp;quot;&amp;quot;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;while&amp;nbsp;True:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;hot&amp;nbsp;=&amp;nbsp;store.items_with_recent_orders(minutes=5)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;iid&amp;nbsp;in&amp;nbsp;hot:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raw&amp;nbsp;=&amp;nbsp;await&amp;nbsp;gw.call(&amp;quot;stock.get&amp;quot;,&amp;nbsp;item_id=iid)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;local&amp;nbsp;=&amp;nbsp;store.local_stock(iid)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;校准取&amp;nbsp;min：平台还没反映本地刚扣的量（第&amp;nbsp;3&amp;nbsp;篇的规则）
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;safe&amp;nbsp;=&amp;nbsp;min(int(raw[&amp;quot;saleable&amp;quot;]),&amp;nbsp;local)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;store.remote_stock(iid)&amp;nbsp;!=&amp;nbsp;safe:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;store.update_remote_stock(iid,&amp;nbsp;safe)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;asyncio.sleep(interval)
```

注意兜底循环里那句&amp;nbsp;`hot&amp;nbsp;=&amp;nbsp;items_with_recent_orders(minutes=5)`：**只对账&amp;quot;最近有单&amp;quot;的商品**。全量对账是最容易写出来的版本，也是把库存轮询成本放大一百倍的版本——而真正需要对账的（刚发生扣减、可能不一致的）永远只占极小一部分。

---

##&amp;nbsp;五、把四个接口串起来：分级调度器

最后给一个可跑的调度骨架。它的职责只有一件事：**保证每个字段按自己的频率被同步，不多不少**。

```python
#&amp;nbsp;scheduler.py
import&amp;nbsp;asyncio
import&amp;nbsp;time

LEVELS&amp;nbsp;=&amp;nbsp;{&amp;quot;detail&amp;quot;:&amp;nbsp;3600,&amp;nbsp;&amp;quot;sku&amp;quot;:&amp;nbsp;3600,&amp;nbsp;&amp;quot;price&amp;quot;:&amp;nbsp;300,&amp;nbsp;&amp;quot;stock&amp;quot;:&amp;nbsp;30}


class&amp;nbsp;DueScheduler:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;def&amp;nbsp;__init__(self,&amp;nbsp;r):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;self.r&amp;nbsp;=&amp;nbsp;r

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;due(self,&amp;nbsp;item_id:&amp;nbsp;str,&amp;nbsp;field:&amp;nbsp;str)&amp;nbsp;-&amp;gt;&amp;nbsp;bool:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;last&amp;nbsp;=&amp;nbsp;await&amp;nbsp;self.r.get(f&amp;quot;due:{field}:{item_id}&amp;quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;last&amp;nbsp;is&amp;nbsp;None:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;True&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;从没同步过，先来一次
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;time.time()&amp;nbsp;-&amp;nbsp;float(last)&amp;nbsp;&amp;gt;=&amp;nbsp;LEVELS[field]

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;async&amp;nbsp;def&amp;nbsp;mark(self,&amp;nbsp;item_id:&amp;nbsp;str,&amp;nbsp;field:&amp;nbsp;str):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;self.r.set(f&amp;quot;due:{field}:{item_id}&amp;quot;,&amp;nbsp;time.time())


async&amp;nbsp;def&amp;nbsp;sync_item(gw,&amp;nbsp;sched:&amp;nbsp;DueScheduler,&amp;nbsp;store,&amp;nbsp;item_id:&amp;nbsp;str):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;----&amp;nbsp;1)&amp;nbsp;详情（小时级）：一次拉全&amp;nbsp;慢字段&amp;nbsp;+&amp;nbsp;SKU&amp;nbsp;集合&amp;nbsp;+&amp;nbsp;标价&amp;nbsp;----
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;await&amp;nbsp;sched.due(item_id,&amp;nbsp;&amp;quot;detail&amp;quot;):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;d&amp;nbsp;=&amp;nbsp;await&amp;nbsp;gw.call(&amp;quot;item.get&amp;quot;,&amp;nbsp;item_id=item_id,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;fields=&amp;quot;,&amp;quot;.join(DETAIL_FIELDS))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;store.upsert_item(item_id,&amp;nbsp;d)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;new_skus&amp;nbsp;=&amp;nbsp;d.get(&amp;quot;skus&amp;quot;,&amp;nbsp;[])
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;sync_skus(store,&amp;nbsp;item_id,&amp;nbsp;new_skus)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;内部做集合&amp;nbsp;diff
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;sched.mark(item_id,&amp;nbsp;&amp;quot;sku&amp;quot;)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;sched.mark(item_id,&amp;nbsp;&amp;quot;detail&amp;quot;)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;----&amp;nbsp;2)&amp;nbsp;价格（分钟级）：从详情缓存或价格接口取，归一成&amp;nbsp;Price&amp;nbsp;对象&amp;nbsp;----
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;await&amp;nbsp;sched.due(item_id,&amp;nbsp;&amp;quot;price&amp;quot;):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;raw&amp;nbsp;=&amp;nbsp;await&amp;nbsp;gw.call(&amp;quot;price.get&amp;quot;,&amp;nbsp;item_id=item_id)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;sku_price&amp;nbsp;in&amp;nbsp;normalize_prices(item_id,&amp;nbsp;raw):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;store.save_price(sku_price)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;effective()&amp;nbsp;按时效算生效价
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;sched.mark(item_id,&amp;nbsp;&amp;quot;price&amp;quot;)

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;----&amp;nbsp;3)&amp;nbsp;库存（秒级）：事件推送是主通道，这里只是兜底标记&amp;nbsp;----
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;真正的兜底对账在&amp;nbsp;reconcile_loop，只扫最近有单的商品


async&amp;nbsp;def&amp;nbsp;main(gw,&amp;nbsp;sched,&amp;nbsp;store,&amp;nbsp;item_ids):
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;详情和价格按各自的频率到期才调；库存靠事件
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;while&amp;nbsp;True:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;for&amp;nbsp;iid&amp;nbsp;in&amp;nbsp;item_ids:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;sync_item(gw,&amp;nbsp;sched,&amp;nbsp;store,&amp;nbsp;iid)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;await&amp;nbsp;asyncio.sleep(30)&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#&amp;nbsp;最细粒度的检查周期，但不是最细粒度的调用频率
```

跑起来的效果：同一个商品，详情接口一天调&amp;nbsp;24&amp;nbsp;次、价格&amp;nbsp;288&amp;nbsp;次、库存&amp;nbsp;0&amp;nbsp;次（走事件）——**对比&amp;quot;四个接口都&amp;nbsp;30&amp;nbsp;秒一轮&amp;quot;的写法，配额消耗约降到&amp;nbsp;1/300**，而库存的实时性反而更好（事件是毫秒级的）。

---

##&amp;nbsp;六、四个反直觉的结论

**1.&amp;nbsp;详情接口调得越勤，数据越不准。**&amp;nbsp;&amp;nbsp;
因为详情是慢字段的来源，高频调它不会让标题更新更快（它一天就变一次），只会让你的价格库存看起来&amp;quot;应该也是新的&amp;quot;——然后掩盖它们其实滞后的事实。分开调度，延迟才藏不住。

**2.&amp;nbsp;消失的&amp;nbsp;SKU&amp;nbsp;比新增的危险。**&amp;nbsp;&amp;nbsp;
新增最多是数据多一条；消失的&amp;nbsp;SKU&amp;nbsp;还挂着旧价格旧库存，客户能下单。覆盖式更新是这里的万恶之源。

**3.&amp;nbsp;促销价必须带时效，这是建模问题不是字段问题。**&amp;nbsp;&amp;nbsp;
没有&amp;nbsp;`promo_end`&amp;nbsp;字段的促销价，就是一颗定时炸弹——促销结束那天准时爆。

**4.&amp;nbsp;库存的正确姿势是&amp;quot;少调&amp;quot;，不是&amp;quot;勤调&amp;quot;。**&amp;nbsp;&amp;nbsp;
事件推送零配额、毫秒级；轮询全量、高延迟。轮询不是兜不了底，是兜底时只扫&amp;quot;最近有单&amp;quot;的商品——全量对账是库存成本失控的第一原因。

---

##&amp;nbsp;七、最后

这篇没有新架构，讲的全是**已有架构里每个接口的使用纪律**。

回看整个系列：第&amp;nbsp;8&amp;nbsp;篇给了骨架（统一接口层），第&amp;nbsp;9、10&amp;nbsp;篇给了运行时（稳定性、限流），第&amp;nbsp;12&amp;nbsp;篇给了选型（为什么用&amp;nbsp;API），第&amp;nbsp;11&amp;nbsp;篇给了差异（平台之间差在哪）。这篇补的是最后一层——**每个高频接口的调用纪律**。骨架、运行时、选型、差异、纪律，五层齐了。

下一篇写「多店铺授权管理」：token&amp;nbsp;的存储与加密、并发刷新撞车、单店授权过期的隔离。那是接入体系的最后一个坑区，写完这个系列就真正完整了。

`LEVELS`&amp;nbsp;里的数字是起点不是标准：详情&amp;nbsp;1&amp;nbsp;小时、价格&amp;nbsp;5&amp;nbsp;分钟、库存&amp;nbsp;30&amp;nbsp;秒，都是按&amp;quot;多数电商场景&amp;quot;取的。你们的业务如果价格敏感度高（比如比价类），价格调到&amp;nbsp;60&amp;nbsp;秒；如果纯铺货场景，详情调到&amp;nbsp;6&amp;nbsp;小时都够。**关键是分级这件事本身，不是每级的具体数字。**&lt;/pre&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;</description><pubDate>Thu, 29 Oct 2026 14:20:54 +0800</pubDate></item></channel></rss>