出色的 在线导览, 保持 继续下去。感谢! 達勒姆大教堂 令人惊叹的 旅行者博客...
注册账号免费测试南网商城API数据接口南网商城(隶属于南方电网旗下,专注电力行业物资采购)是电力系统内重要的 B2B 电商平台,聚焦电力设备、电工材料、安防用品等工业品类。其商品详情数据(如型号规格、技术参数、供应商资质、交...
期待你的精彩评论 加入1058人围观注册账号免费测试南网商城API数据接口南网商城(隶属于南方电网旗下,专注电力行业物资采购)是电力系统内重要的 B2B 电商平台,聚焦电力设备、电工材料、安防用品等工业品类。其商品详情数据(如型号规格、技术参数、供应商资质、交...
期待你的精彩评论 加入1058人围观# 三平台 API 限流怎么绕开瓶颈?三个参数调完就顺了(附 Python 源码)
先把话说清楚:**平台的限流绕不过去**。
改 IP、换 AppKey、注册多个应用分摊额度——这些做法短期有效,但只要平台做应用维度关联,一封就是全封,而且是永久性的。我们做这行两年,见过的封号案例里,一半是这么来的。
所以这篇文章讲的不是"绕开限制",是**"别再浪费额度"**。
因为真实情况是:撞限流的团队里,**九成的瓶颈不是额度不够,是额度被烧在了没用的地方**。我们自己的数据:调参前一个月撞限流 3400 次,调参后 12 次——平台的额度一次没变,变的只是我们自己怎么用。
三个参数,加一套配额账本。下面是完整的东西。
---
## 一、先搞清楚你在跟什么限流打交道
三个平台的限流模型**长得完全不一样**。用一套逻辑套三个平台,是这个话题里最常见的错误。
| | 淘宝 | 京东 | 1688 |
| ------- | -------------- | ----------- | ---------- |
| 限流维度 | appkey × 接口 | 应用 × 接口 | appkey 流量包 |
| 形态 | 日调用量 + QPS 双限 | 日调用量 + QPS | 总量包 + QPS |
| 典型报错 | `code: 7`、`26` | 业务码 + `429` | `429`、业务码 |
| 失败调用算不算 | **算** | **算** | 部分算 |
| 恢复粒度 | 秒级 | 分钟级 | 秒级 |
| 是否可提额 | 可申请 | 走商务 | 可购买 |
这张表里有两行直接决定了架构,但大多数实现都踩了:
### 关键结论 1:限流是「接口 × 应用」维度的,不是全局的
淘宝的调用量配额是**按接口分别计**的。`item_get` 打满了,`trade_list` 一点不受影响。
所以全局令牌桶是错的。
我见过不止一个项目这么写:
```python
# ❌ 错的
limiter = TokenBucket(rate=50) # 全局 50 QPS
async def call(platform, api, **kw):
await limiter.acquire()
...
```
后果是:铺货任务在猛拉详情接口,把全局令牌吃光,这时候一个物流查询请求进来,明明平台侧完全不限它,却要排队等 3 秒。
**正确做法**:桶的 key 是 `(platform, api)`。粒度对齐平台的限流维度,不多不少。粒度再细(比如按 item_id)没意义,因为平台不按这个限;粒度再粗(按平台)就浪费了额度。
### 关键结论 2:失败的调用也占配额
这条最反直觉,也最贵。
淘宝的调用量是按**调用次数**算的,不看成功失败。所以你签名错了、参数漏了,重试三次——**四次调用全部计入配额,全部是浪费**。
这就是为什么上一篇讲的重试分级在这里格外重要:`fatal` 类错误一次重试都不该有。它不只是"没用",它是在**偷你还剩的额度**。
> 顺带一个实现细节:不要只靠 `code` 判断错误类型。平台会新增错误码,你的映射表永远追不上。要加一层 message 关键词兜底:
>
> ```python
> def is_rate_limited(code: str, msg: str) -> bool:
> if code in ("7", "26", "429"):
> return True
> kw = ("limit", "too many", "频繁", "超限", "exceed", "throttl")
> return any(k in msg.lower() for k in kw)
> ```
---
## 二、瓶颈在哪:先算三笔账,再动参数
不先算账就调参,等于闭着眼睛拧螺丝。这三笔账都是 SQL 或日志就能算出来的,半小时的事。
### 账 1:配额花在哪个接口上了
```
接口 调用次数 占比 带来有效数据
item_get 412,000 68.3% 41,200 条
trade_list 88,000 14.6% 88,000 条
logistics_trace 71,000 11.8% 9,400 条 ← 注意这里
shop_info 32,000 5.3% 32,000 条
```
看到问题了吗:`logistics_trace` 占了 11.8% 的配额,只换来 9,400 条有效数据。因为大部分查询打在了**已签收**的订单上——早就没有新轨迹了。
(这就是第三篇讲的:用 `INSERT OR IGNORE` + `rowcount == 1` 去重,加上 `last_status != 'SIGNED'` 过滤,这个接口的调用量能降 80%。**降调用量比调参数有效一百倍**,因为调参只是在分配额度,降量是在释放额度。)
### 账 2:有效请求率
```
有效请求率 = 真正拿到新数据的调用 / 总调用
```
低于 30% 说明你在"重复问同样的问题",该加缓存或做增量了。这个数字跟限流无关,但它决定了你需要多少额度。
### 账 3:配额燃烧曲线
这是最容易被忽略的一笔。日配额 10 万,如果前 6 小时用掉 8 万,剩下 18 小时就只能干等着——而客户投诉通常发生在下午。
```python
# throttle/burn.py
from datetime import datetime
from zoneinfo import ZoneInfo
TZ = ZoneInfo("Asia/Shanghai")
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def consume_quota(r, platform: str, api: str, n: int = 1) -> None:
"""每次调用都记账,按自然日归零"""
day = datetime.now(TZ).strftime("%Y%m%d")
key = f"quota:{platform}:{api}:{day}"
await r.incrby(key, n)
await r.expire(key, 90000) # 25 小时,跨天自动过期
async def burn_ratio(r, platform: str, api: str, daily_quota: int) -> float:
"""
燃烧比 = 已用比例 / 时间进度比例
> 1.3 报警:按这个速度,今天不够用
< 0.5 提示:额度有富余,可以提高并发
"""
day = datetime.now(TZ).strftime("%Y%m%d")
used = int(await r.get(f"quota:{platform}:{api}:{day}") or 0)
now = datetime.now(TZ)
elapsed = (now.hour * 3600 + now.minute * 60 + now.second) / 86400
progress = max(elapsed, 1 / 1440) # 防止零点除零
return (used / daily_quota) / progress
```
这个 `burn_ratio` 是我们监控里最有用的一个指标。**它能提前 4 小时预测"今天配额会不够"**,而不是等到撞墙了才知道。
而且它是双向的:比值 0.5 说明额度有富余,那段时间可以把并发调高一点,把活干完。
---
## 三、参数一:`concurrency`——它不是一个可以随便拍的数
标题说三个参数,第一个就打破你的直觉:**并发不是自变量,它是因变量。**
恒等式:
```
在途请求数 = QPS × 平均耗时
```
也就是说:
```python
concurrency ≈ target_qps × p95_latency_seconds
```
举个具体的:目标 100 QPS,p95 延迟 300ms → `100 × 0.3 = 30`。
**为什么用 p95 不用平均值**:因为撞墙往往发生在长尾。平均 200ms、p95 800ms 的接口,你按平均值算出来 20,实际会遇到"20 个位置里 5 个卡了 8 秒",然后队列瞬间堆起来,后面全在等。按 p95 算,留的余量正好覆盖长尾。
两个常见的错法:
| 错法 | 现象 | 后果 |
| ---------- | ---------- | ---------------------- |
| 并发给太大(200) | 90% 的请求在排队 | 内存和 fd 被占满,P99 恶化,还撞限流 |
| 并发给太小(10) | 打不满目标 QPS | 吞吐上不去,任务跑不完 |
第二种更容易被忽略。`concurrency=10`、延迟 300ms → 实际吞吐上限 33 QPS。你想跑 100 QPS,永远跑不到,而且看日志全是"正常返回",没有报错。
```python
# throttle/gate.py
import asyncio
class GateBusy(Exception):
pass
class Gate:
"""
每个 (platform, api) 一个 Gate。
并发 + 令牌桶 + AIMD,三件东西绑在一起,因为它们必须同步调整。
"""
def __init__(self, name: str, bucket, adaptive, concurrency: int):
self.name = name
self.bucket = bucket
self.adaptive = adaptive
self.concurrency = concurrency
self._sem = asyncio.Semaphore(concurrency)
async def __aenter__(self):
# 先占并发槽,再等令牌。
# 反过来的话,等令牌的请求会把槽全占住,
# 明明桶里还有令牌也没人能拿到(真实的坑,别问怎么知道的)
try:
await asyncio.wait_for(self._sem.acquire(), timeout=1.0)
except asyncio.TimeoutError:
raise GateBusy(f"{self.name} concurrency={self.concurrency} 已满")
try:
await self.bucket.wait(1)
except Exception:
self._sem.release()
raise
return self
async def __aexit__(self, exc_type, exc, tb):
self._sem.release()
if exc is None:
await self.adaptive.on_success()
elif getattr(exc, "rate_limited", False):
await self.adaptive.on_limited()
return False
```
注意 `__aenter__` 里那个顺序,以及那个 1 秒超时。并发满了就**立刻告诉调用方"忙"**,别让它排队——排队会掩盖真实压力,等服务崩了你才发现队列已经三千长了。
---
## 四、参数二:令牌桶——`rate` 管长期,`burst` 管瞬时
这两个是文章标题里的第二个参数,但其实是两个数:**速率**决定长期,**桶深**决定瞬时。
### 为什么必须有 burst
电商接口调用是**脉冲式**的,不是均匀分布的。一次铺货 500 个商品,就是突然一批并发请求涌进来。你的令牌桶如果 `burst = rate`(每秒回一个就只能存一个),那这 500 个请求会被均匀摊到 500/rate 秒里——本来 10 秒能干完的活,变成 50 秒。
### burst 该多大
两个约束同时要满足:
```
① burst ≥ 平台单秒允许的突发量 × 0.5 (别浪费突发能力)
② burst + rate × 1s ≤ 平台单秒硬上限 (别打穿)
```
经验值 `burst = rate × 2 ~ 5`。太小失去突发能力,太大直接撞墙。
举个反例:我们最早给淘宝 `item_get` 设 `rate=80, burst=500`。因为那个接口日配额够大,想着"一次多放点没关系"。结果每 6 秒就有一次 500 连发的脉冲,平台侧按秒窗口算,直接 429。**rate 明明只有 80,但瞬时到了 500。**
### 分布式令牌桶(Lua + Redis)
单机令牌桶在多实例部署下是灾难:4 个 worker 各算各的,实际 QPS 是配置值的 4 倍。所以必须集中式。
```lua
-- throttle/bucket.lua
-- KEYS[1] 桶 key
-- ARGV[1] rate(每秒回多少)ARGV[2] burst(最大存量)
-- ARGV[3] now_ms ARGV[4] want(这次要几个)
local rate = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local want = tonumber(ARGV[4])
local d = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(d[1])
local ts = tonumber(d[2])
if tokens == nil then
tokens = burst
ts = now
end
local delta = math.max(0, now - ts) / 1000
tokens = math.min(burst, tokens + delta * rate)
if tokens < want then
local need_ms = math.ceil((want - tokens) / rate * 1000)
return {0, need_ms}
end
tokens = tokens - want
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(burst / rate * 1000) + 5000)
return {1, 0}
```
```python
# throttle/bucket.py
import asyncio
import time
from pathlib import Path
class RateLimitTimeout(Exception):
def __init__(self, key: str, wait: float):
super().__init__(f"{key} 排队超时,还需 {wait:.2f}s")
self.rate_limited = True
class TokenBucket:
def __init__(self, redis, key: str, rate: float, burst: int):
self.r = redis
self.key = f"rl:{key}"
self.rate = rate
self.burst = burst
self._lua = Path("throttle/bucket.lua").read_text(encoding="utf-8")
self._sha_id: str | None = None
async def _script_sha(self) -> str:
if self._sha_id is None:
self._sha_id = await self.r.script_load(self._lua)
return self._sha_id
async def acquire(self, want: int = 1) -> float:
"""返回 0 表示拿到;> 0 表示还差多少秒"""
now_ms = int(time.time() * 1000)
ok, wait_ms = await self.r.evalsha(
await self._script_sha(), 1, self.key,
self.rate, self.burst, now_ms, want,
)
return 0.0 if ok else wait_ms / 1000
async def wait(self, want: int = 1, timeout: float = 60.0) -> None:
deadline = time.monotonic() + timeout
while True:
delay = await self.acquire(want)
if delay == 0:
return
if time.monotonic() + delay > deadline:
raise RateLimitTimeout(self.key, delay)
await asyncio.sleep(delay)
```
一个容易忽略的点:**lua 脚本用 `evalsha` 而不是 `eval`**。`eval` 每次都传脚本全文,一个大脚本几百字节,高频调用时这部分网络开销不小;`evalsha` 只传 40 字节的 sha1。用 `script_load` 缓存,节点重启后 `evalsha` 会报 `NOSCRIPT`,需要捕获后重新 load(上面为了简洁省略了,生产环境要补)。
---
## 五、参数三:退避曲线——退多快,恢复多慢
前两个参数决定"会不会撞",这一个决定"撞了之后多久恢复"。它是一对系数(降幅 + 恢复步长),合成一条曲线。
三种错法,按危害排序:
| 错法 | 代码 | 为什么错 |
| -------- | ------------------------ | ----------------------------------- |
| 固定 sleep | `await asyncio.sleep(1)` | 撞墙期间每秒都撞一次,1 秒变 1 小时;而且**每次失败都烧配额** |
| 直接放弃 | `raise` | 大批任务失败,客户重跑,额度烧得更多 |
| 无脑重试 | `for _ in range(5)` | 雪崩,还可能触发平台的"恶意调用"风控 |
正确做法是 AIMD(加法增、乘法减),但标准 AIMD 有个缺陷:**它不记得上次撞在哪**。
所以每次恢复都要重新"试探"过去,第二次撞在同一个位置。加一个临界点记忆就能解决:
```python
# throttle/adaptive.py
import time
class AdaptiveRate:
"""
AIMD + 临界点记忆
撞限流 → rate *= 0.5 (乘法降,退得快)
连续成功 N → rate += step (加法升,进得慢)
记忆 → 记下撞墙时的 rate,恢复不超过它的 90%
最后这条是关键:没有它,每次恢复都会重新撞在同一个地方。
"""
def __init__(self, rate: float, min_rate: float = 1.0,
max_rate: float = 500.0, recover_after: int = 50,
step: float = 1.0, memory_ratio: float = 0.9):
self.rate = rate
self.min_rate = min_rate
self.max_rate = max_rate
self.recover_after = recover_after
self.step = step
self.memory_ratio = memory_ratio
self.ceiling: float | None = None # 上次撞墙时的速率
self._ok_streak = 0
self._last_hit = 0.0
async def on_success(self):
self._ok_streak += 1
if self._ok_streak < self.recover_after:
return
self._ok_streak = 0
new_rate = self.rate + self.step
if self.ceiling is not None:
new_rate = min(new_rate, self.ceiling * self.memory_ratio)
self.rate = min(self.max_rate, new_rate)
async def on_limited(self):
now = time.monotonic()
# 5 秒内的第二次撞击才更新 ceiling,
# 避免一次偶发毛刺把 ceiling 永久压低
if now - self._last_hit > 5.0 or self.ceiling is None:
self.ceiling = max(self.ceiling or 0, self.rate)
self._last_hit = now
self._ok_streak = 0
self.rate = max(self.min_rate, self.rate * 0.5)
```
`memory_ratio=0.9` 这个值有讲究:太低(0.5)恢复太保守,明明还有余量却上不去;太高(0.99)会频繁在边界试探。0.9 是实测下来最平衡的——撞墙一次后,稳定在临界值的 90%,之后很少再撞。
还有一个**必须做但很少有文章提**的点:`on_limited` 之后,那个请求**不能再立刻重试**。它要退回去排队,等新速率生效。否则你降了速率,但手上这批已经在途的请求还在猛冲,等于没降。
---
## 六、把三个参数装到一起
```python
# throttle/registry.py
import asyncio
class Throttle:
"""
按 (platform, api) 生成 Gate。
参数不是写死的常量,而是从配置里读——因为三个平台的三个参数值完全不同。
"""
def __init__(self, redis, config: dict):
self.r = redis
self.cfg = config
self._gates: dict[tuple[str, str], Gate] = {}
self._lock = asyncio.Lock()
async def get(self, platform: str, api: str) -> Gate:
key = (platform, api)
gate = self._gates.get(key)
if gate is not None:
return gate
async with self._lock: # 双检锁,避免并发建重复桶
gate = self._gates.get(key)
if gate is not None:
return gate
c = self.cfg.get(platform, {}).get(api, self.cfg["default"])
bucket = TokenBucket(self.r, f"{platform}:{api}",
rate=c["rate"], burst=c["burst"])
gate = Gate(
name=f"{platform}/{api}",
bucket=bucket,
adaptive=AdaptiveRate(rate=c["rate"]),
concurrency=c["concurrency"],
)
self._gates[key] = gate
return gate
async def call(self, platform: str, api: str, fn, **kwargs):
gate = await self.get(platform, api)
# 速率被 AIMD 调过之后,令牌桶要跟上
gate.bucket.rate = gate.adaptive.rate
async with gate:
try:
return await fn(**kwargs)
except Exception as e:
if getattr(e, "rate_limited", False):
# 这里退避一下,让降速生效再回来
await asyncio.sleep(1.0)
raise
```
配置长这样(三个平台的值完全不一样,这就是为什么不能写死):
```yaml
# throttle.yaml
default:
rate: 20
burst: 60
concurrency: 8
taobao:
item_get: { rate: 80, burst: 300, concurrency: 24 } # 日配额大,可冲
trade_list: { rate: 30, burst: 90, concurrency: 9 } # 敏感接口,保守
logistics_trace: { rate: 10, burst: 20, concurrency: 3 } # 限流最严
jd:
item_get: { rate: 40, burst: 120, concurrency: 12 }
order_list: { rate: 15, burst: 45, concurrency: 5 }
alibaba:
item_get: { rate: 25, burst: 75, concurrency: 8 }
```
---
## 七、调完之后的数字
同一批任务(10 万商品铺货 + 3 万订单同步),平台的额度一次没改:
| 指标 | 调参前 | 调参后 | 靠什么 |
| --------- | ------------- | --------------- | ------------------ |
| 撞限流次数 / 天 | 3,400 | **12** | burst 调小 + AIMD 记忆 |
| 平均 QPS | 38(峰值乱冲到 200) | **稳定 92** | 桶速率 + 并发按 p95 算 |
| 任务耗时 | 5h20m(一半在退避) | **1h05m** | 三件叠加 |
| 配额利用率 | 61%(失败也烧) | **97%** | 重试分级 + 分接口桶 |
| 配额燃烧比 | 下午 3 点就 > 1.5 | 全天稳定在 0.95~1.05 | burn_ratio 监控 |
最值得看的是第三行。**耗时从 5 小时 20 分降到 1 小时 05 分,但 QPS 只从 38 涨到 92**——按比例算应该只快 2.4 倍,实际快了 4.9 倍。
多出来的那部分是"不再退避"。调参前那 5 小时里,有一半时间是在 sleep 等重试。**限流最大的成本不是吞吐低,是时间被 sleep 吃掉了。**
---
## 八、四个反直觉的结论
**1. 并发调大不会更快,只会让长尾更慢。**
并发超过 `QPS × p95延迟` 之后,多出来的请求只是在队列里等着,同时占用内存和文件句柄。真正的吞吐由令牌桶的 rate 决定,不由并发决定。
**2. burst 是三个参数里最危险的一个。**
rate 决定长期,出不了大事;burst 决定瞬时,设大了直接打穿平台窗口。而且它出问题的表现很迷惑——平均 QPS 明明没超,但就是天天撞 429。
**3. 撞限流后立刻降速,恢复反而更快。**
因为撞墙期间每次失败都在烧配额,而且失败请求也要等超时。降速看着"退了一步",实际上是止损。
**4. 减少请求数比调任何参数都有效。**
调参最多把效率从 61% 提到 97%,这是 1.6 倍。而把物流查询从 71,000 次降到 12,000 次,是 6 倍的额度释放。**调参是分配问题,降量是供给问题。** 先做降量。
---
## 九、最后
三个参数,说到底是在回答三个问题:
- `concurrency`:**同时在途多少个**(由 QPS 和延迟推出来,不是拍的)
- `rate / burst`:**每秒放多少个,能突然放多少个**(长期与瞬时的分离)
- `backoff`:**撞了之后退多快、恢复多慢**(决定故障时长,不是决定吞吐)
再加一个不在标题里但最重要的:**先算账**。三笔账算完,你大概会发现真正的瓶颈不在限流上。
最后再说一次开头那句话:这套东西的目标是**把额度用满**,不是**突破额度**。前者是工程,后者是违规。我们做接口服务这些年,见过太多为了省点调用量去搞多 AppKey 分摊的,最后账号全封、客户数据全断——那个损失比省下来的钱大得多。
代码里 `bucket.lua` 和 `adaptive.py` 是不依赖业务的,可以单独抽出来用;`registry.py` 的配置结构按你们自己的接口清单改。所有阈值都建议先跑一周观察日志再定,别照抄我这几个数——平台额度、你的客户结构、调用模式都不一样。
---
*附:文中淘宝 `item_get` 的 `rate=80` 是我们账号的实际额度推导出来的,不同账号、不同应用等级差别很大。接入前先用第七篇那套 `StageTimer` 跑一天,看真实 QPS 曲线和平台报错分布,再定这三个数。*# 接口稳定率 99.9% 背后,我们在链路层做了这 5 件事(附 Python 源码)
先说个数字:99.9% 的可用性,摊到 30 天里是 **43 分钟**。
43 分钟,一个月。听起来还有余量对吧?但这是**全部**——三个平台加在一起,所有接口、所有客户、所有时段,一个月累计只能有 43 分钟不达标。
我们做电商数据接口(淘宝 / 1688 / 京东),上游是别人家的系统。上游什么时候抖动、什么时候限流、什么时候半夜改字段,你控制不了。所以这个数字不可能靠"不出事"达成,只能靠**出事的时候影响面被限制住**。
这篇文章讲的就是"限制影响面"这件事:在业务代码和平台 API 之间加的那一层,我们叫**链路层**,里面做了五件事。
代码是 Python + asyncio + httpx,可以直接拿去用。
---
## 一、先把 99.9% 的口径定下来,否则没法谈
这是最多人跳过的一步,也是后面所有优化没意义的原因。
同一个系统,三种算法能算出三个数:
| 口径 | 怎么算 | 数字好看程度 | 有没有意义 |
| --------- | ----------------- | ------ | ------------------ |
| 进程存活率 | 服务没挂就算成功 | 99.99% | 基本没意义,挂了才叫故障 |
| HTTP 成功率 | 返回 200 就算成功 | 99.5% | 有点意义,但 200 里可能有错误体 |
| **业务成功率** | 拿到**正确的业务数据**才算成功 | 99.9% | 有意义,但最难做 |
我们说的是第三种。定义写清楚:
```
可用性 = 成功请求数 / 应当成功的请求数
```
两个关键词要解释:
**"成功"** —— 不是 HTTP 200,是拿到结构完整、字段非空、语义正确的业务数据。淘宝的接口 HTTP 永远 200,错误信息藏在 body 的 `error_response` 里。只统计状态码,你的监控永远是绿的。
**"应当成功"** —— 这个限定词救了我们。客户传错参数、AppKey 拼错、没授权就调接口,这些请求怎么算都是失败,而且**再怎么优化链路也不会成功**。
真实情况是:这类"调用方错误"占我们总请求量的 **6% 左右**。如果把它们算进分母,可用性的理论上限就是 94%,永远上不了 99.9%。
所以口径是:调用方错误**排除出分母**,但**单独监控**。一旦这个比例突然从 6% 涨到 15%,说明文档有问题,或者某个客户在乱调,得去查(通常是我们自己的锅:接口文档写得不清楚)。
> 这一步的价值不在于算出什么数字,而在于**逼你想清楚"失败"到底指什么**。想清楚了,后面五件事才知道该往哪使劲。
---
## 二、链路层在哪,它管什么
```
业务代码(下单、铺货、同步)
│ 它只知道:调 get_item(id),要么拿到数据,要么拿到异常
▼
┌──────────────────────────────────────────┐
│ 链路层(本文主角) │
│ ① 舱壁隔离 ② 超时预算 ③ 重试分级 │
│ ④ 熔断降级 ⑤ 探测与归一 │
└──────────────────────────────────────────┘
│ 三平台各自 client,各自限流,各自熔断
▼
淘宝开放平台 / 京东宙斯 / 1688 网关
```
三条设计原则,后面每个模块都在守:
1. **业务代码看不见重试、限流、超时**——它只描述"我要什么",不描述"失败了怎么办"。
2. **故障隔离在平台粒度**——淘宝挂了不能拖累京东,京东限流不能让淘宝的请求排队。
3. **每一层都必须能"快速失败"**——宁可 100ms 返回一个明确的"现在不行",也不要 30s 后返回一个超时。两者对业务的意义完全不同。
---
## 三、第 1 件事:舱壁隔离,让一个平台的病不传染
最经典的翻车场景:三个平台共用一个 `httpx.AsyncClient`,淘宝那侧开始超时,连接全部卡在淘宝上,连接池占满,京东的请求连连接都拿不到——**京东明明好好的,也跟着挂**。
解法是造船上的"舱壁":每个平台一套独立的资源,进水只淹一个舱。
```python
# link/bulkhead.py
import asyncio
import httpx
class BulkheadFull(Exception):
def __init__(self, name: str, concurrency: int):
super().__init__(f"bulkhead {name} full (concurrency={concurrency})")
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class Bulkhead:
"""舱壁:每个平台一套并发额度,互不挤占"""
def __init__(self, name: str, concurrency: int = 16):
self.name = name
self.concurrency = concurrency
self._sem = asyncio.Semaphore(concurrency)
async def __aenter__(self):
try:
# 注意这里是 0.5s,不是无限等
# 排队本质上也是一种失败,只是它看起来像"在正常工作"
await asyncio.wait_for(self._sem.acquire(), timeout=0.5)
except asyncio.TimeoutError:
raise BulkheadFull(self.name, self.concurrency)
return self
async def __aexit__(self, *exc):
self._sem.release()
return False
def make_client(platform: str) -> httpx.AsyncClient:
"""每个平台一个 client,参数不是随便填的"""
return httpx.AsyncClient(
http2=True,
limits=httpx.Limits(
max_connections=32,
max_keepalive_connections=16,
# ↓ 这一行是本篇最值钱的一行,下面有血案
keepalive_expiry=25.0,
),
timeout=httpx.Timeout(connect=2.0, read=8.0, write=2.0, pool=1.0),
follow_redirects=False,
)
```
三个参数值得单独说:
**`concurrency` 怎么定**:`QPS × 平均耗时`。假设某平台限流 100 QPS,平均响应 300ms,那理论并发是 30。取 16 是留了余量——并发给多了不会更快,因为**限流才是物理下限**,多出来的只是在网关排队,还白占内存。
**`pool` 超时 1 秒**:这是"拿不到连接"的等待上限。默认不设或者设很长,意味着连接池被占满时新请求会一直等,等到天荒地老,然后一起超时。1 秒拿不到就直接认输,快得多。
**`keepalive_expiry=25`**:这一行有故事,我单独讲。
### 血案复盘:一次因为 35 秒导致的成功率暴跌
某个凌晨,京东侧的成功率从 99.95% 掉到 92%,持续了两个小时。监控上没有任何"错误",延迟 P99 从 400ms 涨到 8s。
查出来的原因很蠢:
- httpx 默认 `keepalive_expiry=5.0`,我们当时手写成了 `60.0`,觉得"连接复用久一点更好"
- 京东网关侧的空闲连接回收是 **30 秒**
- 于是 30~60 秒这个区间里的连接,是**对端已经关掉、我们以为还活着**的死连接
- 请求发出去,一直等 `read` 超时(8 秒),然后重试,重试又可能拿到另一个死连接
一个死连接 = 8 秒浪费。那晚 60% 的请求撞上了。
修法就是 `keepalive_expiry` 必须**小于**对端的空闲回收时间。拿不到对端确切值时,取 20~30 秒是最安全的区间。另外加一条:`RemoteProtocolError` 必须**不消耗重试次数**——那是连接级别的偶发问题,换条连接就好,不该算成"这次调用失败了一次"。
> 这个坑的可怕之处:**它不报错,只是慢**。你的错误率监控完全正常,只有延迟曲线在涨。如果你只看错误率不看延迟分布,这类问题能藏几个月。
---
## 四、第 2 件事:超时三级拆分 + 预算传递
"超时设 30 秒"是另一个常见错误。它有个隐藏假设:**一次业务调用只有一个请求**。
实际上一次铺货可能串 20 个请求。每个都 30 秒,最坏情况 10 分钟——用户早就关了页面,你的线程还在跑。
两个动作:
**(1)超时按接口类型拆开,不是一刀切**
| 接口类型 | connect | read | 理由 |
| --------- | ------- | ---- | -------------- |
| 列表 / 增量查询 | 2s | 3s | 数据量小,慢了就是有问题 |
| 商品详情 | 2s | 6s | 详情字段多,正常要 1~2s |
| 批量提交 / 导出 | 2s | 15s | 平台侧真的在跑任务 |
| 物流轨迹 | 2s | 4s | 单条查询,快 |
**(2)总预算往下传,剩余不够就立刻放弃**
```python
# link/budget.py
import time
class Budget:
"""
一次业务调用分给链路的剩余时间。
核心思想:超时不是「每个请求各自计时」,是「一共就这么久,你自己分」。
"""
__slots__ = ("_deadline", "_total")
def __init__(self, total_ms: int):
self._total = total_ms / 1000
self._deadline = time.monotonic() + self._total
def left(self) -> float:
return self._deadline - time.monotonic()
def slice(self, want: float) -> float:
"""子请求可用超时:不超过剩余预算,也不低于 200ms"""
return max(0.2, min(want, self.left()))
def enough(self, need: float) -> bool:
"""还剩的时间够不够再试一次?不够就别试了"""
return self.left() >= need
```
用起来是这样:
```python
budget = Budget(total_ms=8000) # 这个业务动作总共 8 秒
for item_id in item_ids:
if not budget.enough(1.0): # 连 1 秒都不剩了
raise BudgetExhausted(f"剩 {budget.left():.2f}s,还有 {len(rest)} 个没处理")
await gw.call("item_get", timeout=budget.slice(6.0), ...)
```
**为什么要这么麻烦**:因为"超时"和"重试"必须联动。预算只剩 1 秒,你还按 6 秒超时去发请求、失败了再重试三次——这 1 秒变成了 20 秒,而且必然失败。有了预算,链路层就能回答一个业务层答不出的问题:**现在还有没有必要试**。
> 一个实现细节:`asyncio.wait_for` 超时之后,底层请求**不一定**立刻取消干净(尤其是 HTTP/2 多路复用下)。所以 httpx 自己的 `timeout` 必须比 `wait_for` 小一点,让 httpx 先超时、自己清理连接;`wait_for` 只做最后兜底。反过来用,会漏连接。
---
## 五、第 3 件事:重试分级——别把所有错误都当"再试一次"
先说最反直觉的一点:**大部分重试是无效的,而且有害**。
签名错了,重试一百次还是签名错,只是把配额烧光。参数少了,重试一百次还是少。权限没开,重试一百次还是没开。这些错误重试的唯一效果是:**把一次明确的失败,变成一次很慢的失败**。
所以第一件事是把三平台几百个错误码压成几类:
```python
# link/errors.py
from enum import Enum
import httpx
class Kind(str, Enum):
OK = "ok"
RETRY = "retry" # 瞬时故障:网络抖动、5xx、网关超时 → 可立刻重试
SLOW = "slow" # 限流、配额耗尽 → 退避久一点再试
FATAL = "fatal" # 参数 / 签名 / 权限 → 重试无意义,直接抛
UNKNOWN = "unknown" # 没见过 → 不重试,立即告警,人工看
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
# ---- 平台业务码 → 归一 ----
_TAOBAO = {
"7": Kind.SLOW, # app call limited(限流)
"15": Kind.RETRY, # remote service error
"21": Kind.FATAL, # missing params
"22": Kind.FATAL, # invalid params
"26": Kind.SLOW, # 调用量超限
"27": Kind.FATAL, # 权限不足
"28": Kind.FATAL, # 应用被禁用
"33": Kind.FATAL, # 签名错误
"40": Kind.FATAL, # 会话过期
}
_JD = {
"1001": Kind.FATAL,
"1002": Kind.FATAL,
"2001": Kind.RETRY,
"3001": Kind.SLOW,
}
_ALIBABA = {
"400": Kind.FATAL,
"401": Kind.FATAL,
"429": Kind.SLOW,
"500": Kind.RETRY,
}
_BY_PLATFORM = {"taobao": _TAOBAO, "jd": _JD, "alibaba": _ALIBABA}
def classify(platform: str, exc: Exception | None = None,
code: str | None = None) -> Kind:
if exc is not None:
if isinstance(exc, (httpx.ConnectTimeout, httpx.ReadTimeout,
httpx.WriteTimeout, httpx.PoolTimeout,
httpx.ConnectError)):
return Kind.RETRY
# 连接被对端关掉:换条连接就好,不是业务失败
if isinstance(exc, httpx.RemoteProtocolError):
return Kind.RETRY
if isinstance(exc, httpx.HTTPStatusError):
s = exc.response.status_code
if s == 429:
return Kind.SLOW
if 500 <= s < 600:
return Kind.RETRY
return Kind.FATAL
return Kind.UNKNOWN
return _BY_PLATFORM.get(platform, {}).get(str(code), Kind.UNKNOWN)
```
第二件事是给重试加**全局预算**。这一条很多人没做,但它是防止"重试风暴"的唯一手段。
原理解释一下:上游开始抖动 → 10% 请求失败 → 重试 → 变成 30% 请求量 → 上游更扛不住 → 失败率涨到 30% → 重试 → 请求量翻倍……**重试本身成了压垮上游的那根稻草**。这就是雪崩。
```python
# link/retry.py
import random
import time
class RetryBudget:
"""
全局重试预算:按速率发令牌,用完就不许重试。
正常时几乎无感;上游抖动时它会自动把重试量摁住。
"""
def __init__(self, capacity: int = 20, window: float = 10.0):
self.capacity = capacity # 窗口内最多允许多少次重试
self.window = window
self._tokens = float(capacity)
self._last = time.monotonic()
def _refill(self):
now = time.monotonic()
rate = self.capacity / self.window
self._tokens = min(self.capacity, self._tokens + (now - self._last) * rate)
self._last = now
def allow(self) -> bool:
self._refill()
if self._tokens < 1:
return False
self._tokens -= 1
return True
def backoff(attempt: int, base: float = 0.2, cap: float = 4.0) -> float:
"""
指数退避 + full jitter
full jitter 很重要:不加随机的话,一批同时失败的请求会同时重试,
形成周期性的尖峰,把上游二次打挂
"""
return random.uniform(0, min(cap, base * 2 ** attempt))
```
第三件事,也是最容易被忽略的:**写接口不能盲目重试**。
`item_update` 这种接口,超时了不代表没执行成功。可能平台已经改完价了,只是响应没回来。你重试一次,就改了两次——如果平台侧按"每次调用扣一次额度"计费,或者更新操作会触发审核流程,这就是实打实的损失。
规则:
- **读接口**:可以重试,最多 3 次
- **写接口**:必须带业务幂等键(`out_request_id`,用你自己的订单号/任务号),并且只在**确认没送达**的情况下重试(`ConnectError` 可以,`ReadTimeout` 不行)
- **没有幂等键的写接口**:一次都不重试,失败直接抛给业务层,让业务层用业务状态去判断
> 还有一个结构性问题:**重试必须收口在一层**。如果适配器里重试 3 次、网关层再重试 3 次、业务层还重试 3 次,一次调用放大成 27 次。这个问题在代码 review 时看不出来,只有在大促当天能看出来——那时候你已经没时间改了。
---
## 六、第 4 件事:熔断,加上"带标记的降级"
重试解决"偶发失败",熔断解决"持续失败"。
区别在于:上游只是抖一下,重试有用;上游已经挂了,重试只是在浪费自己的时间。熔断器就是那个替你判断"它是不是已经挂了"的东西。
```python
# link/breaker.py
import time
from collections import deque
class Breaker:
"""
closed → 正常放行,滑窗内失败率超阈值 → open
open → 直接快速失败(不请求上游),冷却 30s → half_open
half_open → 只放 3 个探测,2 个成功 → closed,任一失败 → open
注意 half_open 放 3 个而不是 1 个:
只放 1 个的话,那个请求恰好赶上抖动,就要再等一个完整冷却期,
恢复会变得非常慢。这是实测出来的参数,不是拍脑袋。
"""
def __init__(self, window=10.0, min_samples=20, fail_ratio=0.5,
cooldown=30.0, probe_quota=3, probe_need=2):
self.window = window
self.min_samples = min_samples # 样本太少不算,避免启动就被熔断
self.fail_ratio = fail_ratio
self.cooldown = cooldown
self.probe_quota = probe_quota
self.probe_need = probe_need
self.state = "closed"
self._events: deque[tuple[float, bool]] = deque()
self._opened_at = 0.0
self._probes_left = 0
self._probe_ok = 0
def allow(self) -> bool:
if self.state == "closed":
return True
if self.state == "open":
if time.monotonic() - self._opened_at >= self.cooldown:
self.state = "half_open"
self._probes_left = self.probe_quota
self._probe_ok = 0
return True
return False
if self._probes_left > 0:
self._probes_left -= 1
return True
return False
def record(self, ok: bool):
now = time.monotonic()
if self.state == "half_open":
if ok:
self._probe_ok += 1
if self._probe_ok >= self.probe_need:
self.state = "closed"
self._events.clear()
else:
self._trip(now)
return
self._events.append((now, ok))
cutoff = now - self.window
while self._events and self._events[0][0] < cutoff:
self._events.popleft()
if len(self._events) >= self.min_samples:
fails = sum(1 for _, ok_ in self._events if not ok_)
if fails / len(self._events) > self.fail_ratio:
self._trip(now)
def _trip(self, now: float):
self.state = "open"
self._opened_at = now
self._events.clear()
```
然后是全篇我最想讲的部分:**降级不是返回错误,是返回"能用的旧数据 + 一个标记"**。
```python
# link/degrade.py
import json
import time
from dataclasses import dataclass
@dataclass
class Stale:
value: dict
age: float # 数据多少秒前抓的
reason: str
def as_payload(self) -> dict:
return {
"data": self.value,
"stale": True,
"age": round(self.age, 1),
"reason": self.reason,
}
class StaleCache:
"""
「最后一份好数据」,和业务缓存分开存。
区别:业务缓存可以被主动失效(因为有人改了东西);
这份不会——它就是降级时唯一的兜底。
"""
def __init__(self, redis):
self.r = redis
async def put(self, key: str, value: dict):
await self.r.set(f"stale:{key}",
json.dumps({"v": value, "ts": time.time()}),
ex=7 * 86400) # 留 7 天,够久了
async def get(self, key: str, max_age: float = 3600) -> Stale | None:
raw = await self.r.get(f"stale:{key}")
if not raw:
return None
d = json.loads(raw)
age = time.time() - d["ts"]
if age > max_age:
return None # 太旧了,宁可不给
return Stale(value=d["v"], age=age, reason="upstream_unavailable")
```
**为什么这个设计比"返回 503"好**,用铺货场景举例:
- 客户要铺 500 个商品,要读每个商品的价格
- 上游挂了,但 3 分钟前的价格明明还有
- 返回 503 → 客户整个任务失败,重跑一遍
- 返回 `{stale: true, age: 180}` → 客户照常铺货,价格是 3 分钟前的,几乎没影响
而且 `stale: true` 和 `age` 这两个字段是**诚实的**。客户自己决定要不要接受——如果他做的是财务对账,他会拒绝;如果是铺货,他会接受。这个决定权应该在他手里,不该由你替他做。
> 一句话总结这个模块:**99.9% 的含义不是"永远返回正确数据",是"永远返回可解释的结果"**。抛异常是"我不知道发生了什么",返回 stale 数据是"我知道发生了什么,这是我能给的最好的"。
---
## 七、第 5 件事:主动探测 + 错误码归一
前四件事都在"出事之后"起作用。这一件是**在客户发现之前发现**。
### (1)探测:不要把客户的第一次请求当成探测
```python
# link/probe.py
import asyncio
import time
# 三平台 × 三类资源,每类一个固定样本
# 这三个样本必须提前准备好,且长期存在(找一个自己店铺的老商品)
PROBES = [
("taobao", "item_get", {"num_iid": "654320987654"}),
("taobao", "trade_list", {"start": "2026-10-01 00:00:00"}),
("jd", "item_get", {"skuId": "100012043978"}),
("jd", "order_list", {"startTime": "2026-10-01 00:00:00"}),
("alibaba", "item_get", {"num_iid": "610947572360"}),
("alibaba", "order_list", {"startTime": "2026-10-01 00:00:00"}),
]
async def probe_once(gateways, metrics):
for platform, api, params in PROBES:
t0 = time.monotonic()
ok, detail = True, "ok"
try:
await gateways[platform].call(
api, params, budget_ms=8000, timeout=5.0,
)
except Exception as e:
ok, detail = False, f"{type(e).__name__}: {e}"
cost = (time.monotonic() - t0) * 1000
metrics.observe(platform=platform, api=f"probe.{api}",
ok=ok, cost_ms=cost)
if not ok:
metrics.alert(f"[探测失败] {platform}/{api} | {detail} | {cost:.0f}ms")
async def probe_loop(gateways, metrics, interval: float = 30.0):
while True:
try:
await probe_once(gateways, metrics)
except Exception as e: # 探测器自己不能挂
metrics.alert(f"[探测器异常] {e}")
await asyncio.sleep(interval)
```
30 秒一次,三平台三类资源,一天 8640 次请求——很小,但它能提前发现三件**不主动查就永远发现不了**的事:
1. **授权过期**。token 到期的那一刻不会有人告诉你,客户第二天来投诉你才知道。
2. **平台改字段**。京东某次把 `wareQd` 改名了,字段还是返回、只是空了。如果你的解析不校验必填,数据就静默变空。探测里做一次字段非空校验就能抓到。
3. **限流额度变了**。平台调整配额从 100 QPS 降到 50 QPS,你不探测就只能等客户撞上去。
### (2)错误码归一:一行结构化日志
```python
# link/metrics.py
import json
import time
class Metrics:
def __init__(self, sink):
self.sink = sink
def observe(self, *, platform: str, api: str, ok: bool,
kind: str = "ok", cost_ms: float = 0.0,
trace_id: str = "", retries: int = 0, stale: bool = False):
# 一行 JSON,不要多行日志
# 多行日志在并发下会串行错乱,grep 的时候想死
self.sink.write(json.dumps({
"ts": round(time.time(), 3),
"trace": trace_id,
"platform": platform,
"api": api,
"ok": ok,
"kind": kind, # ok / retry / slow / fatal / unknown
"cost_ms": round(cost_ms, 1),
"retries": retries,
"stale": stale,
}, ensure_ascii=False) + "\n")
def alert(self, msg: str):
self.sink.write(json.dumps(
{"ts": round(time.time(), 3), "level": "alert", "msg": msg},
ensure_ascii=False) + "\n")
```
`kind` 这个字段是全部监控的基石。有了它,你能直接算出:
- `fatal` 占比 → 文档和参数校验有没有问题
- `slow` 占比 → 配额够不够,是不是该提额了
- `retry` 占比 → **上游健康度**(这个指标比错误率更早预警)
- `stale` 占比 → 降级在多大范围内发生
> 一个经验值:**`retry` 占比超过 5% 就该警惕,超过 10% 说明上游已经不正常了**,即便此时业务成功率还是 100%。这个指标比可用性提前 10~30 分钟反应。
---
## 八、串起来:一个请求要过五道关
```python
# link/gateway.py
import asyncio
import time
from .budget import Budget
from .errors import Kind, classify
from .retry import backoff
class Gateway:
"""链路层门面。业务层只调这一个方法。"""
def __init__(self, platform, client, bulkhead, breaker,
retry_budget, stale_cache, metrics):
self.platform = platform
self.client = client
self.bulkhead = bulkhead
self.breaker = breaker
self.retry_budget = retry_budget
self.stale = stale_cache
self.metrics = metrics
async def call(self, api: str, fn, *, budget_ms: int = 8000,
timeout: float = 6.0, cache_key: str | None = None,
idempotent: bool = True, trace_id: str = "", **kwargs):
"""
fn: 适配器函数,签名 fn(timeout=..., **kwargs) -> dict
"""
budget = Budget(budget_ms)
t0 = time.monotonic()
# 第 4 关:熔断(最先,最便宜)
if not self.breaker.allow():
self.metrics.observe(platform=self.platform, api=api, ok=False,
kind="breaker_open", trace_id=trace_id)
return await self._degrade(cache_key, "breaker_open")
last_exc: Exception | None = None
retries = 0
for attempt in range(3):
# 第 2 关:预算不够就别试了
if not budget.enough(0.3):
last_exc = last_exc or TimeoutError("budget exhausted")
break
# 第 3 关:全局重试预算
if attempt > 0:
if not self.retry_budget.allow():
break
await asyncio.sleep(backoff(attempt - 1))
retries += 1
try:
# 第 1 关:舱壁
async with self.bulkhead:
result = await asyncio.wait_for(
fn(timeout=budget.slice(timeout), **kwargs),
timeout=budget.left(),
)
self.breaker.record(True)
self.metrics.observe(
platform=self.platform, api=api, ok=True,
cost_ms=(time.monotonic() - t0) * 1000,
trace_id=trace_id, retries=retries,
)
if cache_key:
await self.stale.put(cache_key, result)
return result
except Exception as e:
kind = classify(self.platform, exc=e)
self.breaker.record(False)
last_exc = e
self.metrics.observe(
platform=self.platform, api=api, ok=False,
kind=kind.value,
cost_ms=(time.monotonic() - t0) * 1000,
trace_id=trace_id, retries=retries,
)
# 参数错 / 签名错 / 没见过:重试是烧配额
if kind in (Kind.FATAL, Kind.UNKNOWN):
break
# 限流:多等一会儿,别急着再来一次
if kind is Kind.SLOW:
await asyncio.sleep(1.0 + backoff(attempt))
# 写接口:除非在适配器里带了幂等键,否则一次都不重试
if not idempotent:
break
# 第 5 关:降级
stale = await self._degrade(cache_key, str(last_exc))
if stale is not None:
return stale
raise last_exc
async def _degrade(self, cache_key, reason):
if not cache_key:
return None
s = await self.stale.get(cache_key)
if s is None:
return None
self.metrics.observe(platform=self.platform, api="degrade",
ok=True, kind="stale", stale=True)
return s.as_payload()
```
注意最后的 `_degrade`:**降级放在抛出异常之前**。这个顺序是有意的——只有"真的没招了"才降级,而不是一失败就降级(那样会把偶发抖动放大成大面积 stale 数据)。
---
## 九、五件事做完,数字变成了什么样
| 指标 | 做之前 | 做之后 | 主要来自哪件事 |
| ------------ | ---------- | -------------- | ----------- |
| 业务成功率 | 99.21% | **99.94%** | 五件叠加 |
| 单平台故障影响面 | 全站(三平台一起挂) | **该平台降级,其余正常** | ① 舱壁 |
| 故障时 P99 延迟 | 31s(重试到超时) | **1.2s**(快速失败) | ② 预算 + ④ 熔断 |
| 重试放大倍数 | 3.7× | 1.12× | ③ 重试预算 |
| 上游异常发现时间 | 客户投诉后(小时级) | **30 秒内** | ⑤ 探测 |
| 深夜 P0 次数(半年) | 7 | 1 | ④ 熔断 + ⑤ 探测 |
最值得看的是第二行:**故障影响面从"全站"变成"一个平台"**。这件事比把 99.21 提到 99.94 更有价值——因为前者意味着"客户换个平台还能干活",后者只是"数字好看一点"。
---
## 十、五个反直觉的结论
做完这一套,有几个发现跟直觉不太一样,可能比代码更有用:
**1. 加超时不等于更快,超时必须和重试联动。**
单独设超时,只是把"永久卡住"变成"卡一会儿再卡一会儿"。真正起作用的是总预算——它管的不是单个请求,是整个业务动作的时间上限。
**2. 重试率超过 5%,重试就在让系统变慢。**
因为重试是有成本的:占连接、占并发、占配额,还挤压正常请求。要监控**重试率**而不是重试次数——次数看着不多,比率可能已经失控了。
**3. 熔断打开容易,恢复难。**
`half_open` 只放 1 个探测请求的话,那个请求只要恰好失败,就得再等一个完整冷却期。实测放 3 个、成功 2 个才恢复,恢复速度差好几倍。
**4. 连接池不是越大越好,超过限流额度就是纯浪费。**
`max_connections` 给到 200,但平台限流 50 QPS,多出来的 150 个连接只是在网关排队,同时占着你的内存和文件句柄。连接数应该 ≈ `QPS × 平均耗时`,再留 20%。
**5. 最难的不是技术,是口径。**
把"调用方参数错误"算进分母,可用性上限就是 94%,你把代码优化到极致也上不了 99.9%。先想清楚分母,再谈数字。
---
## 十一、最后
这五件事,本质上是同一件事的五个层面:**承认上游一定会出问题,然后把"出问题"的代价控制住**。
- 舱壁控制**范围**——只影响一个平台
- 预算控制**时间**——1 秒认输,不拖 30 秒
- 重试分级控制**放大**——不让一次失败变成十次请求
- 熔断降级控制**形态**——从"报错"变成"给可解释的旧数据"
- 探测归一控制**发现速度**——客户投诉之前就知道
单看每一件事都不复杂,加起来大概一千多行代码。但它们决定了你半夜能不能睡着。
我们把这套东西在三个平台上跑了两年,中间踩过的坑基本都在上面了(`keepalive_expiry` 那条尤其贵)。如果你正在自己接这三个平台,这套代码可以直接拿去改;如果你觉得这套活不值得自己养,那也正常——我们做的就是这件事。
代码里所有类都是独立可测的,`Breaker`、`RetryBudget`、`Budget` 这三个不依赖网络,建议先写单元测试再接入,别拿线上流量试。
---
*附:文中 `keepalive_expiry` 的推荐值取决于各平台网关的实际配置,25 秒是我们在淘宝/京东/1688 上的经验值,接入前建议先用 `curl -v` 观察 `Connection: close` 的时机再定。*# 一套接口打三平台,省下 80% 的对接工时(附 Python 源码)
> 80% 不是省在第一次对接,是省在之后每一次改动。这笔账要算清楚,否则你会以为统一接口层是过度设计。
## 一、三套代码的日常
先看一个很常见的项目结构:
```
project/
├── taobao/
│ ├── client.py # 签名 + 请求
│ ├── product.py # 商品:字段映射、SKU 处理
│ ├── order.py # 订单:分页、状态转换
│ └── logistics.py
├── alibaba/
│ ├── client.py # 又是一份签名
│ ├── product.py # 又是一份字段映射
│ ├── order.py
│ └── logistics.py
├── jd/
│ ├── client.py
│ ├── product.py
│ ├── order.py
│ └── logistics.py
└── service/
├── price_sync.py # ← 业务逻辑,里面全是 if platform == "taobao"
├── stock_sync.py # ← 又一次 if platform == ...
└── order_sync.py # ← 第三次
```
问题不在文件多,在于**每个业务需求都要改三处**。
产品说了句「库存要精确到 SKU」,你得改三个 `stock` 模块。发现淘宝有个坑要修,另外两个平台得确认一遍是不是也有同样的问题。加个新字段,三个平台各写一遍映射。
代码量翻三倍,bug 面积也翻三倍——因为三份实现里总有一份写得跟另外两份不一样。
## 二、先算一笔诚实的账
「省 80% 工时」这种话,如果只算首次对接,是站不住的。算细一点:
### 首次对接
| 项目 | 三套独立实现 | 统一接口层 |
| --------- | ------------- | ----------------- |
| 签名/请求基础设施 | 3 × 1 天 = 3 天 | 1 天(抽象基类) |
| 商品资源适配 | 3 × 2 天 = 6 天 | 3 × 1.5 天 = 4.5 天 |
| 订单资源适配 | 3 × 2 天 = 6 天 | 3 × 1.5 天 = 4.5 天 |
| 物流资源适配 | 3 × 1 天 = 3 天 | 3 × 0.5 天 = 1.5 天 |
| 统一层设计 | 0 | 1.5 天 |
| **合计** | **18 天** | **13 天** |
首次对接只省 **28%**。因为适配器还是得一个一个写,平台差异不会因为抽象就消失。
### 后续每一次改动
真正拉开差距的是这里。假设一个持续一年的项目:
| 变更类型 | 频率 | 三套实现 | 统一层 |
| --------- | ------- | ------------------------ | ----------- |
| 加一个业务字段 | 每月 1 次 | 3 处 × 0.5 天 = 1.5 天 | 1 处 × 0.5 天 |
| 修一个平台 bug | 每月 2 次 | 2 × 0.5 天(有时要排查是否影响其他两份) | 0.5 天 |
| 调整业务规则 | 每季度 1 次 | 3 处 × 1 天 | 1 处 × 1 天 |
| 接第四个平台 | 每年 1 次 | 5 天 | 1.5 天 |
| 新人接手理解成本 | 每年 1 次 | 3 天 | 1 天 |
一年下来:三套实现约 **43 天**,统一层约 **16 天**。
**维护期省 63%。** 加上首次的 28%,以及「bug 只修一处就不会漏」带来的隐性收益,综合到 80% 是合理的。
关键结论:**统一接口层的价值随时间增长,项目越久省得越多。** 如果只做一个短期的数据导出脚本,确实没必要。但如果是长期跑的业务系统,这个投入一年内就回本。
## 三、架构:门面 + 适配器 + 能力声明
```
业务代码(只写一次)
↓
CommerceAPI(门面)
├─ 统一重试 / 缓存 / 限流
├─ 能力检查(不支持就明确报错)
└─ 结果归一化
↓
┌─────────┼─────────┐
TaobaoAdapter AlibabaAdapter JdAdapter
↓ ↓ ↓
各自签名、字段、分页、错误码
```
三个角色的职责必须分清:
- **门面**:业务逻辑唯一入口,负责跨平台共性(重试、缓存、能力检查)
- **适配器**:只做「平台方言 → 统一语言」的翻译,不含业务判断
- **能力声明**:显式声明每个平台支持什么,不支持的操作直接抛错
第三条是最容易被忽略、也最容易埋雷的。下面单独讲。
## 四、代码
### 4.1 统一模型和能力声明
```python
# core/models.py
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass
class Product:
platform: str
item_id: str
title: str
price: Decimal
stock: int | None = None # None 表示该平台不提供
images: list[str] = field(default_factory=list)
skus: list["Sku"] = field(default_factory=list)
@dataclass
class Sku:
sku_id: str
props: dict[str, str]
price: Decimal
stock: int | None = None
@dataclass
class Order:
platform: str
order_id: str
status: str # 统一状态,不是平台原始值
pay_amount: Decimal
created_at: str
modified_at: str
buyer: str = ""
@dataclass
class Capability:
"""
每个平台支持什么,显式声明。
这是统一接口里最关键的一层——三平台能力根本不对等,
不声明清楚,业务代码就会在某个平台上拿到空数据还以为正常。
"""
sku_level_stock: bool = False # 库存是否精确到 SKU
tiered_price: bool = False # 是否有阶梯价(1688 特有)
order_increment: bool = False # 订单是否支持按修改时间增量拉
logistics_subscribe: bool = False # 物流是否支持订阅推送
batch_product_query: bool = False # 商品是否支持批量查询
```
为什么不用 `hasattr` 或者 try/except 判断?因为**能力应该是显式契约,不是运行时探针**。业务代码在启动时就能知道「京东这个接口不支持 SKU 级库存」,从而决定降级策略,而不是等跑到一半发现返回全是 `None`。
### 4.2 适配器基类
```python
# core/adapter.py
from abc import ABC, abstractmethod
from decimal import Decimal
from .models import Product, Order, Capability
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class NotSupported(Exception):
"""平台明确不支持该操作。不要用假数据糊弄调用方。"""
def __init__(self, platform: str, operation: str):
super().__init__(f"[{platform}] 不支持操作:{operation}")
self.platform = platform
self.operation = operation
class BaseAdapter(ABC):
platform: str = ""
capability: Capability = Capability()
# --- 子类必须实现 ---
@abstractmethod
async def get_product(self, item_id: str) -> Product:
...
@abstractmethod
async def list_orders(self, start: str, end: str, page: int = 1) -> list[Order]:
...
# --- 子类按能力选择性实现 ---
async def get_stock(self, item_id: str, sku_id: str | None = None) -> int:
if not self.capability.sku_level_stock and sku_id:
raise NotSupported(self.platform, "按 SKU 查询库存")
return await self._get_stock_impl(item_id, sku_id)
async def _get_stock_impl(self, item_id: str, sku_id: str | None) -> int:
raise NotSupported(self.platform, "库存查询")
# --- 状态归一:每个平台状态码不同,统一成五个 ---
STATUS_MAP: dict[str, str] = {}
def normalize_status(self, raw: str) -> str:
return self.STATUS_MAP.get(str(raw), "UNKNOWN")
```
统一状态只定五个:`PENDING`(待付款)、`PAID`(待发货)、`SHIPPED`(已发货)、`DONE`(已完成)、`CLOSED`(已关闭)。多了没用,少了不够用。
### 4.3 三个适配器(只展示关键差异)
```python
# adapters/taobao.py
from decimal import Decimal
from core.adapter import BaseAdapter
from core.models import Product, Order, Capability, Sku
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class TaobaoAdapter(BaseAdapter):
platform = "taobao"
capability = Capability(
sku_level_stock=True,
order_increment=True,
logistics_subscribe=True,
batch_product_query=False,
)
STATUS_MAP = {
"WAIT_BUYER_PAY": "PENDING",
"WAIT_SELLER_SEND_GOODS": "PAID",
"WAIT_BUYER_CONFIRM_GOODS": "SHIPPED",
"TRADE_FINISHED": "DONE",
"TRADE_CLOSED": "CLOSED",
}
def __init__(self, client):
self.c = client
async def get_product(self, item_id: str) -> Product:
d = await self.c.call("taobao.item.seller.get", {
"fields": "num_iid,title,price,num,quantity,pic_url,sku",
"num_iid": item_id,
})
it = d["item_get_response"]["item"]
skus = []
for s in (it.get("skus") or {}).get("sku") or []:
skus.append(Sku(
sku_id=str(s["sku_id"]),
props=_parse_props(s.get("properties_name", "")),
price=Decimal(str(s.get("price", "0"))),
stock=int(s.get("quantity", 0)),
))
return Product(
platform=self.platform,
item_id=str(it["num_iid"]),
title=it.get("title", ""),
price=Decimal(str(it.get("price", "0"))),
stock=int(it.get("quantity", 0)),
images=[it["pic_url"]] if it.get("pic_url") else [],
skus=skus,
)
async def list_orders(self, start, end, page=1) -> list[Order]:
d = await self.c.call("taobao.trades.sold.get", {
"fields": "tid,status,payment,created,modified,buyer_nick",
"start_modified": start, "end_modified": end,
"page_no": page, "page_size": 100,
})
rows = ((d.get("trades_sold_get_response") or {})
.get("trades") or {}).get("trade") or []
return [Order(
platform=self.platform,
order_id=str(t["tid"]),
status=self.normalize_status(t.get("status", "")),
pay_amount=Decimal(str(t.get("payment", "0"))),
created_at=t.get("created", ""),
modified_at=t.get("modified", ""),
buyer=t.get("buyer_nick", ""),
) for t in rows]
def _parse_props(s: str) -> dict[str, str]:
out = {}
for seg in filter(None, s.split(";")):
p = seg.split(":")
if len(p) >= 4:
out[p[2]] = p[3]
return out
```
```python
# adapters/alibaba.py
from decimal import Decimal
from core.adapter import BaseAdapter
from core.models import Product, Order, Capability
from core.adapter import NotSupported
class AlibabaAdapter(BaseAdapter):
platform = "alibaba"
capability = Capability(
sku_level_stock=False, # 1688 按起订量阶梯,没有 SKU 概念
tiered_price=True,
order_increment=True,
logistics_subscribe=False, # 需要单独申请
)
STATUS_MAP = {
"waitbuyerpay": "PENDING",
"waitsellersend": "PAID",
"waitbuyerreceive": "SHIPPED",
"success": "DONE",
"cancel": "CLOSED",
}
def __init__(self, client):
self.c = client
async def get_product(self, item_id: str) -> Product:
d = await self.c.call("com.alibaba.product.get", {
"productID": item_id, "webSite": "1688",
})
r = d.get("result") or {}
ranges = (r.get("saleInfo") or {}).get("priceRanges") or []
price = Decimal(str(ranges[0]["price"])) if ranges else Decimal("0")
return Product(
platform=self.platform,
item_id=str(r.get("productID", item_id)),
title=r.get("subject", ""),
price=price,
stock=int((r.get("saleInfo") or {}).get("amountOnSale", 0) or 0),
images=[i["fullPathImageURI"]
for i in (r.get("image") or {}).get("images", [])],
)
async def get_stock(self, item_id: str, sku_id: str | None = None) -> int:
# 显式拒绝,而不是忽略 sku_id 返回总量
if sku_id:
raise NotSupported(self.platform, "按 SKU 查询库存(1688 无此概念)")
return await self._get_stock_impl(item_id, None)
async def list_orders(self, start, end, page=1) -> list[Order]:
d = await self.c.call("com.alibaba.trade/get.soldList", {
"modifyStartTime": start, "modifyEndTime": end,
"pageNo": page, "pageSize": 50,
})
rows = (d.get("result") or {}).get("orderList") or []
return [Order(
platform=self.platform,
order_id=str(o.get("orderId")),
status=self.normalize_status(o.get("orderStatus", "")),
pay_amount=Decimal(str(o.get("sumPayment") or "0")),
created_at=o.get("createTime", ""),
modified_at=o.get("modifyTime", ""),
buyer=o.get("toLoginId", ""),
) for o in rows]
```
```python
# adapters/jd.py
from decimal import Decimal
from core.adapter import BaseAdapter
from core.models import Product, Order, Capability
class JdAdapter(BaseAdapter):
platform = "jd"
capability = Capability(
sku_level_stock=True,
order_increment=True,
logistics_subscribe=True,
)
STATUS_MAP = {
"1": "PENDING", # 等待付款
"2": "PAID", # 等待发货
"3": "SHIPPED", # 已发货
"4": "DONE", # 已完成
"6": "CLOSED", # 已取消
}
def __init__(self, client):
self.c = client
async def get_product(self, item_id: str) -> Product:
d = await self.c.call("jd.union.open.goods.query", {
"360buy_param_json": '{"skuIds":[%s]}' % item_id,
})
rows = ((d.get("jd_union_open_goods_query_responce") or {})
.get("result") or {}).get("data") or []
if not rows:
return Product(platform=self.platform, item_id=item_id,
title="", price=Decimal("0"))
g = rows[0]
return Product(
platform=self.platform,
item_id=str(g.get("skuId", item_id)),
title=g.get("skuName", ""),
price=Decimal(str(g.get("price", "0"))),
stock=None, # 该接口不返回库存
images=[g["imageUrl"]] if g.get("imageUrl") else [],
)
async def list_orders(self, start, end, page=1) -> list[Order]:
d = await self.c.call("jingdong.pop.order.search", {
"360buy_param_json": (
'{"page":"%d","pageSize":"100",'
'"startDate":"%s","endDate":"%s"}' % (page, start, end)),
})
rows = ((d.get("jingdong_pop_order_search_responce") or {})
.get("searchorderinfo_result") or {}).get("orderInfoList") or []
return [Order(
platform=self.platform,
order_id=str(o.get("orderId")),
status=self.normalize_status(o.get("orderState", "")),
pay_amount=Decimal(str(o.get("orderPayment") or "0")),
created_at=o.get("orderStartTime", ""),
modified_at=o.get("orderModified", ""),
buyer=o.get("pin", ""),
) for o in rows]
```
三个适配器放在一起看,共性一目了然:**都要签名、都要分页、都要状态映射、都要字段归一**。这些「一样的东西」全部沉到基类,适配器只保留「不一样的东西」。
这就是统一接口层真正省事的地方——不是省打字,是**让「不一样」变得可枚举**。
### 4.4 门面
```python
# core/api.py
import asyncio
from .adapter import BaseAdapter, NotSupported
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class CommerceAPI:
"""
业务代码唯一入口。三平台一个门面,业务层不再出现 platform 判断。
"""
def __init__(self, adapters: dict[str, BaseAdapter]):
self.adapters = adapters
def _get(self, platform: str) -> BaseAdapter:
if platform not in self.adapters:
raise KeyError(f"未注册平台:{platform}")
return self.adapters[platform]
# --- 单平台操作 ---
async def product(self, platform: str, item_id: str):
return await self._get(platform).get_product(item_id)
async def orders(self, platform: str, start: str, end: str,
max_pages: int = 50) -> list:
ad = self._get(platform)
if not ad.capability.order_increment:
raise NotSupported(platform, "订单增量拉取")
out, page = [], 1
while page <= max_pages:
batch = await ad.list_orders(start, end, page)
out.extend(batch)
if len(batch) < 100:
break
page += 1
return out
# --- 跨平台操作:这才是统一接口真正的价值 ---
async def products_all(self, item_ids_map: dict[str, list[str]],
concurrency: int = 10) -> dict[str, list]:
"""
一次拿三个平台的商品。业务层不用写循环、不用管并发。
item_ids_map = {"taobao": [...], "alibaba": [...], "jd": [...]}
"""
sem = asyncio.Semaphore(concurrency)
result: dict[str, list] = {}
async def one(platform: str, iid: str):
async with sem:
try:
return platform, await self.product(platform, iid)
except Exception as e:
print(f"[{platform}] {iid} 失败: {e}")
return platform, None
tasks = [one(p, i) for p, ids in item_ids_map.items() for i in ids]
for platform, prod in await asyncio.gather(*tasks):
if prod is not None:
result.setdefault(platform, []).append(prod)
return result
async def order_summary(self, start: str, end: str,
platforms: list[str] | None = None) -> dict:
"""
跨平台订单汇总。业务层写一行,三平台自动并行。
"""
targets = platforms or list(self.adapters.keys())
async def one(p):
try:
orders = await self.orders(p, start, end)
return p, {
"count": len(orders),
"amount": sum(o.pay_amount for o in orders),
"by_status": _group_status(orders),
}
except Exception as e:
return p, {"error": str(e)}
pairs = await asyncio.gather(*[one(p) for p in targets])
return dict(pairs)
def _group_status(orders) -> dict[str, int]:
out: dict[str, int] = {}
for o in orders:
out[o.status] = out.get(o.status, 0) + 1
return out
```
`order_summary` 这个方法值得多看一眼。在没统一接口的项目里,这段逻辑要写三遍,每遍处理不同的分页、不同的状态码、不同的金额字段。统一之后业务层只写一行。
## 五、同一个需求,两种写法
需求:「拉三个平台昨天的订单,算出各平台待发货数量和金额。」
**没有统一接口:**
```python
# 30 多行,而且每加一个平台就再加 10 行
result = {}
# 淘宝
t_orders, page = [], 1
while True:
d = taobao.call("taobao.trades.sold.get", {...})
rows = d["trades_sold_get_response"]["trades"]["trade"]
t_orders += rows
if len(rows) < 100:
break
page += 1
result["taobao"] = {
"pending": sum(1 for o in t_orders if o["status"] == "WAIT_SELLER_SEND_GOODS"),
"amount": sum(float(o["payment"]) for o in t_orders
if o["status"] == "WAIT_SELLER_SEND_GOODS"),
}
# 1688(字段名完全不同)
a_orders, page = [], 1
while True:
d = alibaba.call("com.alibaba.trade/get.soldList", {...})
rows = d["result"]["orderList"]
a_orders += rows
if len(rows) < 50:
break
page += 1
result["alibaba"] = {
"pending": sum(1 for o in a_orders if o["orderStatus"] == "waitsellersend"),
"amount": sum(float(o["sumPayment"] or 0) for o in a_orders
if o["orderStatus"] == "waitsellersend"),
}
# 京东(又是另一套)
...
```
**有统一接口:**
```python
summary = await api.order_summary("2026-10-22 00:00:00", "2026-10-23 00:00:00")
pending = {p: v["by_status"].get("PAID", 0) for p, v in summary.items()}
```
从 30 行到一个方法调用。而且**加第四个平台,业务代码一行都不用改。**
## 六、能力不对等:统一接口最硬的部分
这是统一接口真正难的地方,也是最容易埋雷的地方。
三个平台的能力根本不对等:
| 能力 | 淘宝 | 1688 | 京东 |
| --------- | --- | ----------- | --------- |
| SKU 级库存 | 支持 | 不支持(按起订量阶梯) | 部分接口支持 |
| 阶梯价 | 不支持 | 支持 | 不支持 |
| 订单按修改时间增量 | 支持 | 支持 | 部分接口只支持按天 |
| 物流订阅推送 | 支持 | 需单独申请 | 支持 |
| 商品批量查询 | 不支持 | 支持 | 支持 |
**最坑的做法是「假装都支持」**:调用方传 `sku_id`,1688 适配器忽略这个参数返回商品总量。业务代码以为拿到了 SKU 库存,实际上是错的,而且不报错。
所以 `get_stock` 里那句显式 `raise NotSupported` 才是正确做法:
```python
if sku_id:
raise NotSupported(self.platform, "按 SKU 查询库存(1688 无此概念)")
```
配套的用法是**启动时检查能力**:
```python
def check_capabilities(api: CommerceAPI, required: dict[str, bool]):
"""
启动时校验:业务需要的能力,各平台是否都支持。
不支持就提前暴露,而不是跑到线上才发现。
"""
problems = []
for platform, ad in api.adapters.items():
for cap_name, needed in required.items():
if needed and not getattr(ad.capability, cap_name, False):
problems.append(f"{platform} 不支持 {cap_name}")
if problems:
raise RuntimeError("能力校验失败:\n" + "\n".join(problems))
# 启动时调用
check_capabilities(api, {
"sku_level_stock": True, # 业务强依赖 SKU 库存
"order_increment": True,
})
```
这段会在启动时告诉你:「1688 不支持 sku_level_stock」。你可以立刻决定是降级、还是排除 1688,而不是等三天后客户投诉库存不准。
## 七、一套测试跑三个平台
统一接口的「统一」不能靠嘴说,得靠测试证明。用 pytest 参数化,同一套契约测试跑三个适配器:
```python
# tests/test_contract.py
import pytest
from decimal import Decimal
from core.adapter import NotSupported
@pytest.fixture
def adapters():
"""三个适配器实例,用测试账号"""
return {"taobao": ..., "alibaba": ..., "jd": ...}
@pytest.mark.parametrize("platform", ["taobao", "alibaba", "jd"])
@pytest.mark.asyncio
async def test_product_shape(adapters, platform, sample_ids):
"""契约一:商品必须返回统一结构"""
p = await adapters[platform].get_product(sample_ids[platform])
assert p.platform == platform
assert isinstance(p.title, str)
assert isinstance(p.price, Decimal)
assert p.price >= 0
assert isinstance(p.images, list)
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
@pytest.mark.parametrize("platform", ["taobao", "alibaba", "jd"])
@pytest.mark.asyncio
async def test_order_status_normalized(adapters, platform):
"""契约二:状态必须是五个统一值之一"""
orders = await adapters[platform].list_orders(
"2026-10-15 00:00:00", "2026-10-23 00:00:00")
for o in orders:
assert o.status in {"PENDING", "PAID", "SHIPPED", "DONE", "CLOSED", "UNKNOWN"}
assert isinstance(o.pay_amount, Decimal)
@pytest.mark.asyncio
async def test_unsupported_explicit(adapters, sample_ids):
"""契约三:不支持的能力必须明确报错,不能静默降级"""
with pytest.raises(NotSupported):
await adapters["alibaba"].get_stock(
sample_ids["alibaba"], sku_id="any_sku")
```
第三条测试是整套设计里最重要的一条。它把「不能静默降级」这个约定固化成了测试,防止后来的人图省事改成返回 `0`。
## 八、收益复盘
同一套业务代码,服务三个平台:
| 指标 | 三套实现 | 统一接口层 |
| ------ | ----------- | -------------- |
| 首次对接 | 18 天 | 13 天(省 28%) |
| 一年维护 | 约 43 天 | 约 16 天(省 63%) |
| 业务层代码量 | 30 行 / 每个需求 | 3 行 / 每个需求 |
| 加第四个平台 | 约 5 天 | 约 1.5 天(省 70%) |
| 新人上手 | 读三套代码 | 读一个门面 + 三个薄适配器 |
综合下来,**一年周期省 80% 左右是站得住的**。但前提是:真的长期跑,且平台数量 ≥ 2。
只接一个平台,或者只做一次性的数据导出,这个抽象就是过度设计。工程决策永远看场景。
## 九、四个坑
1. **别在适配器里写业务判断。** 适配器只做翻译。一旦出现 `if 订单金额 > 100` 这种代码,说明业务逻辑漏进了适配器,将来三个平台会各写一份不一样的规则。
2. **能力声明要跟着平台文档更新。** 京东的接口能力经常变(同一个能力,不同接口支持度不同)。建议每个季度对照文档核一次 `Capability`,把它当配置维护,不是当常量。
3. **统一状态不要贪多。** 五个状态够用。给三个平台各定十个细分状态,最后业务代码里全是 `if status in [...]`,反而更乱。
4. **门面不要变成上帝类。** `CommerceAPI` 只放跨平台共性(重试、缓存、并发、能力检查)。具体业务(比如「降价 5% 就告警」)放在 service 层,别塞进门面。
## 十、最后
「一套接口打三平台」的价值,不在少写几行代码,在**让「平台差异」变成一个有边界、可枚举、可测试的东西**。
差异集中在适配器里,业务代码里就再也不该出现 `if platform == "taobao"`。一旦出现了,说明抽象漏了。
代码在上面,`BaseAdapter` + `Capability` + 契约测试这三件套可以直接搬到你的项目里。
如果你连适配器都不想写——签名、字段映射、状态归一、能力差异这些事,现成的三平台 API 接口服务已经做完了,输出就是统一格式,接进来只需要写业务层。省下的正是前面算的那 13 天加 16 天。
有在做的可以聊聊,你们的统一层是怎么处理能力不对等的,是抛错还是降级?
---
**标签**:`Python` `架构设计` `电商` `适配器模式` `API`# 从 3 小时到 3 分钟:三平台商品同步是怎么提速的(附 Python 源码)
> 60 倍提速不是靠某一个技巧,是四个优化叠加。每个贡献多少,本文算给你看。
## 一、一个真实的慢
某个多平台商家,3000 个商品,每天要同步一次三平台的商品数据(标题、价格、库存、状态)。
原来的脚本:串行逐个拉,每个商品调一次详情接口,拉完逐条写库。跑一次 3 小时。
运营的抱怨很直接:「我早上想看昨天的库存,脚本要到中午才跑完,看的是上午的数据。」
更糟的是,**没人说得清 3 小时都花在哪**。有人说接口慢,有人说服务器不行,有人说数据库写不动。都在猜。
## 二、第一步:先测量,别猜
性能优化的第一原则:**先测量,再动手。** 我在这上面吃过亏——曾经花一天优化数据库索引,后来 profile 一跑发现数据库只占 2% 的时间。
给脚本加了个简单的计时器,跑一次全量,结果如下:
| 环节 | 耗时 | 占比 |
| ---------------- | ------ | ---- |
| 调接口等响应(3000 次串行) | 172 分钟 | 96% |
| JSON 解析 + 字段映射 | 4 分钟 | 2% |
| 逐条写数据库 | 3 分钟 | 2% |
| 其他(重试、日志) | 1 分钟 | 0.5% |
结论非常清晰:**96% 的时间在等网络**。
这不是「接口慢」,是**串行等 3000 次**。每个请求平均 3.4 秒,串行乘以 3000,就是 172 分钟。
这个测量直接决定了优化顺序:先解决等待(并发),再减少请求数(增量),写库那 3 分钟排最后。
测量代码很简单,就几十行,但它决定了后面所有力气往哪使:
```python
# timer.py
import time
from collections import defaultdict
class StageTimer:
"""分阶段计时,跑完输出每阶段耗时和占比"""
def __init__(self):
self.spent = defaultdict(float)
self.counts = defaultdict(int)
self._t0 = None
self._current = None
def start(self, stage: str):
self._current = stage
self._t0 = time.perf_counter()
def stop(self):
if self._current and self._t0 is not None:
self.spent[self._current] += time.perf_counter() - self._t0
self.counts[self._current] += 1
self._current = None
def report(self) -> list[tuple[str, float, float]]:
total = sum(self.spent.values()) or 1.0
rows = sorted(self.spent.items(), key=lambda x: -x[1])
return [(name, sec, sec / total) for name, sec in rows]
def print(self):
print(f"\n{'环节':<24}{'次数':>8}{'耗时':>12}{'占比':>8}")
for name, sec, pct in self.report():
print(f"{name:<24}{self.counts[name]:>8}{sec:>10.1f}s{pct:>8.1%}")
```
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
## 三、优化一:并发——最直觉,但有天花板
96% 在等网络,那并发就是第一刀。把 3000 个串行请求改成 20 路并发:
```python
# concurrent.py
import asyncio
import time
class RateGate:
"""
匀速限流闸门:保证全进程 QPS 不超过上限。
比每个任务自己 sleep 更准,因为它是全局视角。
"""
def __init__(self, qps: float):
self.interval = 1.0 / qps
self._next_slot = time.monotonic()
self._lock = asyncio.Lock()
async def wait(self):
async with self._lock:
now = time.monotonic()
wait = self._next_slot - now
if wait > 0:
await asyncio.sleep(wait)
self._next_slot += self.interval
else:
self._next_slot = now + self.interval
async def fetch_all(ids: list[str], fetch_one, concurrency: int = 20,
qps: float = 10.0) -> list:
"""
并发拉取。两个旋钮:
- concurrency:同时在飞的请求数(受限于连接和服务端)
- qps:每秒发起多少个(受限于平台限流)
两个都要设,只设 concurrency 会被限流打回来
"""
gate = RateGate(qps)
sem = asyncio.Semaphore(concurrency)
results = [None] * len(ids)
async def one(idx: int, item_id: str):
async with sem:
await gate.wait()
results[idx] = await fetch_one(item_id)
await asyncio.gather(*[one(i, x) for i, x in enumerate(ids)])
return results
```
注意这段代码里有两个旋钮:`concurrency`(同时在飞的请求数)和 `qps`(每秒发起多少)。
**只设并发不设 QPS,是新手最常见的翻车点。** 20 路并发、每秒发出 20 个请求,直接撞平台限流,然后全部进入重试,反而更慢。
实测:3 小时 → 9 分钟。20 倍。
但天花板立刻出现了:平台限流 QPS 10,3000 个商品理论下限就是 300 秒 = 5 分钟。**并发再高也没用,限流是硬约束。**
所以第二刀必须换思路:不是「把请求发得更快」,而是「发更少的请求」。
## 四、优化二:少发请求——本文最值钱的洞察
重新看需求:3000 个商品每天同步。真的 3000 个都变了吗?
统计了一下:**每天真正变化的商品不到 10%**(改价、改库存、上下架)。90% 的请求在拉「没变的数据」。
那能不能先知道哪些变了,再只拉那些?
能。**商品列表接口一次能返回几十条记录的 `modified` 时间戳**——用 100 次列表请求(每次 30 条)就能拿到全部 3000 个商品的更新时间,替代 3000 次详情请求。
```python
# incremental.py
import json
from datetime import datetime
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
def list_all_modified(client, shop_id: str) -> dict[str, str]:
"""
分页拉全量商品列表,只取 item_id -> modified 映射。
3000 个商品 = 100 次轻量请求,代替 3000 次重量请求
"""
mapping = {}
page = 1
while True:
rows = client.list_products(shop_id, page=page, page_size=30)
if not rows:
break
for r in rows:
mapping[str(r["item_id"])] = r["modified"]
if len(rows) < 30:
break
page += 1
return mapping
def pick_changed(current: dict[str, str],
last_sync: dict[str, str] | None) -> list[str]:
"""
和上次同步记录对比,只返回有变化的 item_id
新商品 + 修改过的商品 + 已删除的商品(单独处理)
"""
last = last_sync or {}
changed = [iid for iid, m in current.items() if last.get(iid) != m]
removed = [iid for iid in last if iid not in current]
if removed:
print(f"[sync] {len(removed)} 个商品已下架,需清理")
return changed
def save_sync_state(state_path: str, current: dict[str, str]):
with open(state_path, "w", encoding="utf-8") as f:
json.dump(current, f, ensure_ascii=False)
def load_sync_state(state_path: str) -> dict[str, str] | None:
try:
with open(state_path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return None
```
账算一下:
- 原来:3000 次详情请求(每次约 3.4 秒等效负载)
- 现在:100 次列表请求 + 约 300 次详情请求 = 400 次请求
**请求数降到原来的 13%。** 这比任何并发技巧的收益都大。
这个思路的本质:**用便宜的请求替代昂贵的请求。** 列表接口一次返回 30 条的元数据,单价是详情接口的三十分之一。先便宜地筛一遍,再对筛出来的做贵的操作。
## 五、优化三 + 四:写库和连接
到这一步,网络等待只剩 40 秒左右,剩下的小头也该收一收。
**逐条写库 → 批量写。** SQLite 用 `executemany`,MySQL 用多值 INSERT:
```python
# bulk_write.py
import sqlite3
def bulk_upsert(conn: sqlite3.Connection, rows: list[tuple]):
"""
批量 upsert。3000 条逐条写 = 3000 次事务;
executemany 一批 = 1 次事务,差两个数量级
"""
conn.executemany("""
INSERT INTO products(platform, item_id, title, price, stock, synced_at)
VALUES(?,?,?,?,?,datetime('now'))
ON CONFLICT(platform, item_id) DO UPDATE SET
title=excluded.title, price=excluded.price,
stock=excluded.stock, synced_at=excluded.synced_at
""", rows)
conn.commit()
```
**每次新建连接 → 复用连接。** 这个最隐蔽:每次请求新建 TCP + TLS 握手要 200~~400ms,3000 次就是 10~~20 分钟。`httpx.AsyncClient` 复用连接后,握手只发生一次。
```python
# 复用连接:整个进程共享一个 client
# 错误:每次 fetch_one 里 with httpx.AsyncClient() as c: ...
# 正确:模块级或应用级创建一次
import httpx
client = httpx.AsyncClient(
timeout=10.0,
limits=httpx.Limits(max_connections=20, max_keepalive_connections=20),
)
```
顺带把限流做成自适应(AIMD),限流报错就减半 QPS,平稳运行则缓慢恢复:
```python
# adaptive.py
import random
class AdaptiveRate:
"""
自适应限速(AIMD):
- 撞限流:QPS 减半(Multiplicative Decrease)
- 连续 50 次成功:QPS +10%(Additive Increase)
比写死一个保守值快得多,也比写死激进值安全
"""
def __init__(self, qps: float = 5.0, min_qps: float = 0.5, max_qps: float = 20.0):
self.qps = qps
self.min_qps = min_qps
self.max_qps = max_qps
self._ok_streak = 0
self._next_slot = 0.0
def on_success(self):
self._ok_streak += 1
if self._ok_streak >= 50:
self.qps = min(self.max_qps, self.qps * 1.1)
self._ok_streak = 0
def on_rate_limited(self):
self.qps = max(self.min_qps, self.qps / 2)
self._ok_streak = 0
@property
def interval(self) -> float:
return 1.0 / self.qps
async def wait(self):
"""带抖动的等待,避免多任务同刻发车"""
import asyncio, time
now = time.monotonic()
wait = max(0.0, self._next_slot - now)
if wait:
await asyncio.sleep(wait)
self._next_slot = max(now, self._next_slot) + self.interval * random.uniform(0.8, 1.2)
```
## 六、结果:60 倍是怎么来的
四个优化叠加,每一步的贡献都有数:
| 阶段 | 耗时 | 相比上一步 | 手段 |
| ------------------ | ---------- | -------- | ------------------------------- |
| 原始 | 180 分钟 | — | 串行 + 全量 + 逐条写 |
| + 并发 20 + QPS 限流 | 9 分钟 | **20×** | asyncio 并发,全局 QPS 闸门 |
| + 列表预筛增量 | 约 1.2 分钟 | **7.5×** | 100 次列表替代 2820 次详情 |
| + 批量写 + 连接复用 + 自适应 | **约 1 分钟** | 1.3× | executemany + keep-alive + AIMD |
两个观察值得记住:
1. **最大的收益来自「少做事」(7.5×)而不是「做得快」(20× 有硬顶)。** 并发受限于平台限流,少发请求不受任何限制。
2. **最后一个优化只贡献 1.3×,但它是「从 1 分钟到 40 秒」和「偶尔翻车」的区别。** 连接复用和自适应限速不影响平均值,影响的是稳定性。
## 七、完整主流程
把四个优化串起来:
```python
# sync_main.py
import asyncio
import sqlite3
from timer import StageTimer
from concurrent import fetch_all
from incremental import (list_all_modified, pick_changed,
load_sync_state, save_sync_state)
from bulk_write import bulk_upsert
from adaptive import AdaptiveRate
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def run_sync(clients: dict, shop_id: str,
state_path: str = "sync_state.json"):
t = StageTimer()
# 1. 增量预筛:100 次轻量请求拿全量 modified
t.start("列表预筛(3平台)")
current = {}
for platform, client in clients.items():
current[platform] = await list_all_modified(client, shop_id)
t.stop()
# 2. 对比上次状态,只保留有变化的
last = load_sync_state(state_path)
t.start("增量对比")
changed_map = {p: pick_changed(m, last and last.get(p))
for p, m in current.items()}
t.stop()
# 3. 并发拉详情(自适应限速)
t.start("详情并发拉取")
rows = []
for platform, client in clients.items():
ids = changed_map[platform]
if not ids:
continue
rate = AdaptiveRate(qps=5.0)
async def fetch_one(iid):
await rate.wait()
try:
r = await client.get_product_detail(iid)
rate.on_success()
return (platform, iid, r["title"], float(r["price"]),
r["stock"])
except Exception as e:
if "limit" in str(e).lower():
rate.on_rate_limited()
raise
results = await fetch_all(ids, fetch_one,
concurrency=20, qps=10.0)
rows.extend(r for r in results if r)
t.stop()
# 4. 批量写库
t.start("批量写库")
conn = sqlite3.connect("products.db")
bulk_upsert(conn, rows)
conn.close()
t.stop()
# 5. 保存同步状态,供下次增量对比
save_sync_state(state_path, current)
t.print()
return len(rows)
if __name__ == "__main__":
from client import PlatformClient
clients = {
"taobao": PlatformClient("taobao", "key", "secret"),
"alibaba": PlatformClient("alibaba", "key", "secret"),
"jd": PlatformClient("jd", "key", "secret"),
}
asyncio.run(run_sync(clients, "your_shop_id"))
```
## 八、四个坑
1. **并发数和 QPS 是两个旋钮,必须分开调。** 只调并发会被限流打回来进入重试风暴,比串行还慢。
2. **增量依赖 `modified` 字段,但不是所有平台都可靠。** 有的平台批量改价不更新 `modified`。建议每周跑一次全量对账,增量为主、全量兜底——和价格同步那篇的思路一致。
1. **`fetch_one` 里闭包引用循环变量是经典 bug。** 上面代码里 `for platform, client in clients.items()` 里面定义 `fetch_one`,每次循环重新定义,没有引用问题;但如果把函数定义提到循环外再绑定变量,就会全部引用最后一次的 `client`。测试时三个平台的数据混在一起,多半是这个问题。
2. **首次运行没有同步状态,会走全量。** 这是正确行为(第一次本来就该全量),但要提醒使用者:第一次跑慢是正常的,从第二次开始才是 3 分钟。
## 九、最后
回头看这 60 倍:
- 并发(20×):把等待重叠起来,但受限于平台限流
- 增量(7.5×):**用便宜的请求替代昂贵的请求**,收益最大
- 批量写 + 连接复用(1.3×):平均值贡献小,稳定性贡献大
顺序也重要:**先测量再动手**。如果先花一天优化数据库(只占 2%),一天就白费了。
代码在上面,`RateGate`、`AdaptiveRate`、增量预筛这三个组件是通用的,可以拆到任何同步场景里用。
有在做的可以聊聊,你们的同步脚本跑多久,profile 过吗?
---
**标签**:`Python` `性能优化` `电商` `异步编程` `数据同步`# 3 天接通淘宝 + 1688 + 京东,商品、订单、物流全打通(附 Python 源码)
> 三天不是靠加班堆出来的。是靠「先申请权限、砍掉非核心、把基础设施一次做对」。
## 一、先把话说清楚:3 天能做什么
很多人看到「3 天接通」第一反应是吹牛。所以先把边界划清。
**3 天能做到的:**
- 三个平台的签名、鉴权、限流、重试全部跑通
- 商品(列表 / 详情 / 库存)、订单(增量拉取 / 详情)、物流(运单 / 轨迹)三大资源能拿到数据
- 一个统一的客户端,业务层不需要知道平台差异
- 冒烟测试 + 错误码映射 + 日志,能支撑后续迭代
**3 天做不到的:**
- 完整的商品发布(类目映射、属性对齐要单独做,那是另一个工程)
- 高并发场景(需要压测、连接池调优、缓存设计)
- 全部接口(三个平台加起来几百个接口,只接核心的)
**关键前提:权限必须提前申请。** 这是 3 天能不能成立的最大变量,下一节单独说。
## 二、Day 0:最不可控的是权限,不是代码
这是接过的都懂、没接过的都会踩的坑。
代码写三天能写完,但**应用审核可能要三周**。
| 平台 | 需要什么 | 现实周期 |
| ---- | ---------------------- | ------------------- |
| 淘宝 | 企业开发者认证 + 应用审核 + 接口权限包 | 3~10 个工作日 |
| 1688 | 企业营业执照 + 诚信通账号 | 5~15 个工作日 |
| 京东 | 商家签约 + 应用授权 | 3~10 个工作日,部分类目要额外资质 |
所以真实项目里,**第 0 天该做的是提交申请,不是写代码。**
我的做法是:申请提交后,立刻用沙箱环境或测试账号把代码写完。淘宝有沙箱,1688 和京东部分接口支持测试 appkey,够你把 90% 的逻辑验完。
如果等到权限下来才动手,那就是三周不是三天。
**接入前必须准备好这一份东西:**
```
□ 三个平台的 app_key / app_secret
□ 一个能正常访问的服务器(固定 IP,有些平台要加白名单)
□ 已授权的一个测试店铺(每平台至少一个)
□ 该店铺下 3~5 个真实商品 ID(用于调试)
□ 近 7 天至少 10 笔订单(用于调订单接口)
□ 一个已发货的运单号(用于调物流接口)
```
最后三项最容易被忽略。很多人代码写完了,发现测试店铺是个空店,没商品没订单,接口返回空数组,根本没法验证解析逻辑对不对。
## 三、三天怎么排
### Day 1:签名 + 通道打通(不碰业务)
这一天的目标只有一个:**三个平台各自能成功调用一个最简单的接口。**
不要一上来写业务。第一天的所有时间都花在「让请求能通」上,因为签名这块的坑最深,而且报错信息最不友好。
```python
# signers.py
import hashlib
import hmac
def sign_taobao(params: dict, secret: str) -> str:
"""TOP:MD5(secret + 排序 k+v + secret),大写"""
keys = sorted(params)
raw = secret + "".join(f"{k}{params[k]}" for k in keys) + secret
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
def sign_alibaba(params: dict, secret: str) -> str:
"""1688:HMAC-SHA1,注意是 HMAC 不是 MD5"""
keys = sorted(params)
raw = "".join(f"{k}{params[k]}" for k in keys)
return hmac.new(secret.encode("utf-8"), raw.encode("utf-8"),
hashlib.sha1).hexdigest().upper()
def sign_jd(params: dict, secret: str) -> str:
"""宙斯:和淘宝一样是 MD5 包夹,但参数名和拼接细节有差异"""
keys = sorted(params)
raw = secret + "".join(f"{k}{params[k]}" for k in keys) + secret
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
```
三个签名里,1688 最容易错。淘宝和京东都是 MD5 包夹,长得几乎一样,很多人以为 1688 也是,结果一直报签名错误。
**签名调试的三个技巧:**
1. 先拿官方 SDK 算一次,把自己的结果和它对比。别对着文档猜。
2. 注意编码。参数里有中文时,UTF-8 编码后再签名,不是先 URL 编码。
3. 注意大小写。三个平台都要求大写 HEX,`hexdigest()` 出来是小写,必须 `.upper()`。
Day 1 的验收标准:跑一个 `get_time` 或者最轻量的查询接口,三个平台都返回成功。
### Day 2:数据模型 + 三大资源
这一天做两件事:定模型,接资源。
```python
# models.py
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass
class Product:
platform: str
item_id: str
title: str
price: Decimal
stock: int
images: list[str] = field(default_factory=list)
@dataclass
class Order:
platform: str
order_id: str
status: str
pay_amount: Decimal
created_at: str
buyer: str
items: list[dict] = field(default_factory=list)
@dataclass
class Shipment:
platform: str
order_id: str
waybill: str
company: str
status: str = ""
traces: list[dict] = field(default_factory=list)
```
模型只定这三个,不要贪多。字段以「业务真正会用到的」为准,多余的一律不加——加一个字段,三个平台都要写一遍映射,成本是乘三的。
然后按资源依次接:
**商品**:列表(分页)→ 详情 → 库存。顺序不能反,列表能拿到 ID 才能调详情。
**订单**:增量列表 → 详情。增量参数一定要用「修改时间」不是「创建时间」,原因后面踩坑那节说。
**物流**:运单查询 → 轨迹查询。轨迹要么订阅推送,要么低频轮询,别高频拉。
### Day 3:基础设施 + 冒烟测试
第三天不写业务,写「让业务能长期跑」的东西:
- 统一错误码映射
- 重试策略
- 限流
- 日志
- 冒烟测试
这部分是接入期最容易被跳过、但后期最费时间的。跳过它们,你会在上线第一周花掉两倍时间排查「为什么这个接口偶尔失败」。
## 四、核心代码:一个客户端打通三个平台
这套设计的思路:**业务层只调 `client.call(resource, action, params)`,平台差异全在客户端里消化。**
```python
# client.py
import asyncio
import hashlib
import hmac
import time
import random
from dataclasses import dataclass
import httpx
from signers import sign_taobao, sign_alibaba, sign_jd
# ---------- 统一错误类型 ----------
class ErrorKind:
AUTH = "auth" # 签名/授权问题,重试无用
RATE_LIMIT = "rate_limit" # 限流,退避可恢复
SERVER = "server" # 平台 5xx,重试可恢复
PARAM = "param" # 参数错,必须先修代码
BUSINESS = "business" # 业务拒绝(如库存不足),看具体语义
@dataclass
class PlatformError(Exception):
kind: str
code: str
message: str
platform: str
def __str__(self):
return f"[{self.platform}] {self.kind}({self.code}): {self.message}"
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
# 错误码映射:接入期最值钱的表,把各平台的模糊报错统一成五类
ERROR_MAP = {
"taobao": {
"isv.invalid-signature": ErrorKind.AUTH,
"isv.missing-signature": ErrorKind.AUTH,
"isv.user-not-exist": ErrorKind.AUTH,
"isp.top-remote-connection-timeout": ErrorKind.SERVER,
"isp.top.throttled-by-user-defined-limit": ErrorKind.RATE_LIMIT,
"isv.parameters-mismatch": ErrorKind.PARAM,
},
"alibaba": {
"401": ErrorKind.AUTH,
"403": ErrorKind.AUTH,
"429": ErrorKind.RATE_LIMIT,
"500": ErrorKind.SERVER,
"502": ErrorKind.SERVER,
},
"jd": {
"1001": ErrorKind.AUTH,
"1002": ErrorKind.AUTH,
"1099": ErrorKind.RATE_LIMIT,
"1005": ErrorKind.SERVER,
},
}
ENDPOINTS = {
"taobao": "https://eco.taobao.com/router/rest",
"alibaba": "https://gw.open.1688.com/openapi/param2/1",
"jd": "https://api.jd.com/routerjson",
}
class PlatformClient:
"""
三平台统一客户端。业务层不需要知道任何平台差异。
"""
def __init__(self, platform: str, app_key: str, secret: str,
qps: int = 5, timeout: float = 15.0):
self.platform = platform
self.app_key = app_key
self.secret = secret
self.qps = qps
self.sem = asyncio.Semaphore(qps)
self.http = httpx.AsyncClient(timeout=timeout)
self._last_call = 0.0
async def _throttle(self):
"""简单匀速限流:保证调用间隔 >= 1/qps"""
interval = 1.0 / self.qps
wait = self._last_call + interval - time.time()
if wait > 0:
await asyncio.sleep(wait)
self._last_call = time.time()
def _sign(self, params: dict) -> str:
fn = {"taobao": sign_taobao,
"alibaba": sign_alibaba,
"jd": sign_jd}[self.platform]
return fn(params, self.secret)
def _build(self, method: str, params: dict) -> tuple[str, dict]:
if self.platform == "taobao":
body = {
"method": method, "app_key": self.app_key,
"timestamp": str(int(time.time())),
"format": "json", "v": "2.0", "sign_method": "md5",
**params,
}
body["sign"] = self._sign(body)
return ENDPOINTS["taobao"], body
if self.platform == "alibaba":
body = {"access_token": self.app_key, **params}
body["_aop_signature"] = self._sign(body)
api = method.replace(".", "/")
return f"{ENDPOINTS['alibaba']}/{api}/{self.app_key}", body
# jd
body = {
"method": method, "app_key": self.app_key,
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"format": "json", "v": "2.0",
**params,
}
body["sign"] = self._sign(body)
return ENDPOINTS["jd"], body
async def call(self, method: str, params: dict | None = None,
retries: int = 3) -> dict:
"""
统一调用入口。可重试错误自动退避,不可重试的立刻抛。
"""
params = params or {}
url, body = self._build(method, params)
last_err = None
for attempt in range(retries):
try:
async with self.sem:
await self._throttle()
t0 = time.time()
r = await self.http.post(url, data=body)
cost = (time.time() - t0) * 1000
data = r.json()
self._log(method, cost, r.status_code)
err = self._extract_error(data)
if err is None:
return data
if err.kind in (ErrorKind.AUTH, ErrorKind.PARAM, ErrorKind.BUSINESS):
raise err # 重试无意义
last_err = err
except PlatformError:
raise
except Exception as e:
last_err = PlatformError(
ErrorKind.SERVER, "NETWORK", str(e), self.platform)
# 指数退避 + 抖动,避免同时重试打爆平台
backoff = (2 ** attempt) + random.uniform(0, 0.5)
await asyncio.sleep(backoff)
raise last_err or PlatformError(
ErrorKind.SERVER, "UNKNOWN", "重试耗尽", self.platform)
def _extract_error(self, data: dict) -> PlatformError | None:
"""把三种不同的错误结构统一成 PlatformError"""
if self.platform == "taobao":
e = data.get("error_response")
if e:
code = e.get("sub_code") or e.get("code", "")
return PlatformError(
ERROR_MAP["taobao"].get(code, ErrorKind.BUSINESS),
code, e.get("sub_msg") or e.get("msg", ""), "taobao")
if self.platform == "alibaba":
if "error_code" in data:
code = str(data["error_code"])
return PlatformError(
ERROR_MAP["alibaba"].get(code, ErrorKind.BUSINESS),
code, data.get("error_message", ""), "alibaba")
if self.platform == "jd":
if "error_response" in data:
e = data["error_response"]
code = str(e.get("code", ""))
return PlatformError(
ERROR_MAP["jd"].get(code, ErrorKind.BUSINESS),
code, e.get("zh_desc") or e.get("en_desc", ""), "jd")
return None
def _log(self, method: str, cost_ms: float, status: int):
# 接你现有的日志系统;接入期建议先打到 stdout
print(f"[{self.platform}] {method} {status} {cost_ms:.0f}ms")
async def close(self):
await self.http.aclose()
```
这个客户端里,**最值钱的是 `ERROR_MAP` 和错误分类**。
接入期你一定会遇到「接口偶尔失败」,如果不做分类,你只能无脑重试。而重试签名错误是纯浪费——签名错了重试一百次还是错,只是把配额烧光。
分成五类之后,策略就清楚了:`AUTH` 和 `PARAM` 立刻抛、让人看;`RATE_LIMIT` 退避;`SERVER` 重试;`BUSINESS` 交给业务层判断。
## 五、三大资源的具体封装
```python
# resources.py
from decimal import Decimal
from client import PlatformClient
class ProductAPI:
def __init__(self, client: PlatformClient):
self.c = client
async def get(self, item_id: str) -> dict:
"""商品详情,三平台归一"""
p = self.c.platform
if p == "taobao":
d = await self.c.call("taobao.item.seller.get", {
"fields": "num_iid,title,price,num,quantity,pic_url",
"num_iid": item_id})
it = d["item_get_response"]["item"]
return {"item_id": str(it["num_iid"]), "title": it.get("title", ""),
"price": Decimal(str(it.get("price", "0"))),
"stock": int(it.get("quantity", 0))}
if p == "alibaba":
d = await self.c.call("com.alibaba.product.get", {
"productID": item_id, "webSite": "1688"})
r = d.get("result") or {}
ranges = (r.get("saleInfo") or {}).get("priceRanges") or []
return {"item_id": str(r.get("productID", item_id)),
"title": r.get("subject", ""),
"price": Decimal(str(ranges[0]["price"])) if ranges else Decimal("0"),
"stock": int((r.get("saleInfo") or {}).get("amountOnSale", 0))}
d = await self.c.call("jd.union.open.goods.query", {
"360buy_param_json": '{"skuIds":[%s]}' % item_id})
rows = ((d.get("jd_union_open_goods_query_responce") or {})
.get("result") or {}).get("data") or []
if not rows:
return {}
g = rows[0]
return {"item_id": str(g.get("skuId")), "title": g.get("skuName", ""),
"price": Decimal(str(g.get("price", "0"))), "stock": 0}
class OrderAPI:
def __init__(self, client: PlatformClient):
self.c = client
async def list_increment(self, start: str, end: str, page: int = 1) -> list[dict]:
"""
按修改时间增量拉。start/end 格式 YYYY-MM-DD HH:MM:SS
注意:用修改时间,不是创建时间
"""
p = self.c.platform
if p == "taobao":
d = await self.c.call("taobao.trades.sold.get", {
"fields": "tid,status,payment,created,modified,buyer_nick",
"start_modified": start, "end_modified": end,
"page_no": page, "page_size": 100})
rows = ((d.get("trades_sold_get_response") or {})
.get("trades") or {}).get("trade") or []
return [{"order_id": str(t["tid"]), "status": t.get("status", ""),
"pay_amount": Decimal(str(t.get("payment", "0"))),
"created_at": t.get("created"),
"buyer": t.get("buyer_nick", "")} for t in rows]
if p == "alibaba":
d = await self.c.call("com.alibaba.trade/get.soldList", {
"modifyStartTime": start, "modifyEndTime": end,
"pageNo": page, "pageSize": 50})
rows = (d.get("result") or {}).get("orderList") or []
return [{"order_id": str(o.get("orderId")),
"status": o.get("orderStatus", ""),
"pay_amount": Decimal(str(o.get("sumPayment") or "0")),
"created_at": o.get("createTime"),
"buyer": o.get("toLoginId", "")} for o in rows]
d = await self.c.call("jingdong.pop.order.search", {
"360buy_param_json": (
'{"page":"%d","pageSize":"100",'
'"startDate":"%s","endDate":"%s"}' % (page, start, end))})
rows = ((d.get("jingdong_pop_order_search_responce") or {})
.get("searchorderinfo_result") or {}).get("orderInfoList") or []
return [{"order_id": str(o.get("orderId")),
"status": str(o.get("orderState", "")),
"pay_amount": Decimal(str(o.get("orderPayment") or "0")),
"created_at": o.get("orderStartTime"),
"buyer": o.get("pin", "")} for o in rows]
class LogisticsAPI:
def __init__(self, client: PlatformClient):
self.c = client
async def trace(self, waybill: str, company: str) -> list[dict]:
"""物流轨迹。接入期先用轮询验证通路,生产环境要改订阅"""
p = self.c.platform
if p == "taobao":
d = await self.c.call("taobao.logistics.trace.search", {
"tid": waybill})
elif p == "alibaba":
d = await self.c.call("com.alibaba.logistics/getLogisticsTraceInfo", {
"logisticsId": waybill})
else:
d = await self.c.call("jingdong.etms.trace.get", {
"360buy_param_json": '{"waybillCode":"%s"}' % waybill})
# 三个平台返回结构不同,只取「时间 + 描述」两个字段
nodes = (d.get("traces") or d.get("traceList")
or d.get("data") or [])
return [{"time": n.get("time") or n.get("acceptTime"),
"desc": n.get("desc") or n.get("standerDesc") or n.get("content")}
for n in nodes]
```
注意 `LogisticsAPI.trace` 里那段节点提取——用 `or` 链式兜底。这种写法不好看,但接入期很实用:你在没有真实返回样本的情况下,先把几种可能的字段名都兜住,跑通之后再根据实际返回精简。
## 六、Day 3 的验收:冒烟测试
这是第三天必须写的东西。没有它,你永远不知道「接通了」是接通了几个接口。
```python
# smoke_test.py
import asyncio
import sys
from client import PlatformClient
from resources import ProductAPI, OrderAPI, LogisticsAPI
CHECKS = [
# (平台, 资源, 说明)
("taobao", "product", "商品详情"),
("taobao", "order", "订单增量"),
("taobao", "logistics", "物流轨迹"),
("alibaba", "product", "商品详情"),
("alibaba", "order", "订单增量"),
("alibaba", "logistics", "物流轨迹"),
("jd", "product", "商品详情"),
("jd", "order", "订单增量"),
("jd", "logistics", "物流轨迹"),
]
CONFIG = {
"taobao": {"key": "your_key", "secret": "your_secret",
"item": "654321098765", "waybill": "SF1234567890"},
"alibaba": {"key": "your_key", "secret": "your_secret",
"item": "612345678901", "waybill": "YT9876543210"},
"jd": {"key": "your_key", "secret": "your_secret",
"item": "100012043978", "waybill": "JD0001234567"},
}
async def check_one(platform: str, resource: str) -> tuple[bool, str]:
cfg = CONFIG[platform]
client = PlatformClient(platform, cfg["key"], cfg["secret"])
try:
if resource == "product":
r = await ProductAPI(client).get(cfg["item"])
ok = bool(r.get("item_id"))
return ok, f"标题={r.get('title', '')[:20]} 价格={r.get('price')}"
if resource == "order":
r = await OrderAPI(client).list_increment(
"2026-10-15 00:00:00", "2026-10-22 00:00:00")
return True, f"拉到 {len(r)} 单"
r = await LogisticsAPI(client).trace(cfg["waybill"], "SF")
return True, f"{len(r)} 个轨迹节点"
except Exception as e:
return False, str(e)[:80]
finally:
await client.close()
async def main():
print(f"{'平台':<10}{'资源':<12}{'结果':<6}详情")
print("-" * 70)
failed = 0
for platform, resource, _ in CHECKS:
ok, detail = await check_one(platform, resource)
mark = "PASS" if ok else "FAIL"
if not ok:
failed += 1
print(f"{platform:<10}{resource:<12}{mark:<6}{detail}")
print("-" * 70)
print(f"通过 {len(CHECKS) - failed}/{len(CHECKS)}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
```
这个脚本每天跑一次,接到监控里。**它能提前发现三类问题**:授权过期、平台改字段、限流额度变了。这三件事都是「不主动检查就不知道,等业务报错才发现」的。
## 七、真实时间分配
回头看这三天,时间实际花在哪(一个做过三次接入的人的经验值):
| 环节 | 预计 | 实际 | 差异原因 |
| --------- | ----- | ----- | ------------------------- |
| 签名调试 | 0.5 天 | 1 天 | 1688 的 HMAC 和文档示例不一致,调了半天 |
| 接口联调 | 1 天 | 1.5 天 | 没有真实测试数据,返回空数组无从验证 |
| 字段映射 | 0.5 天 | 0.5 天 | 符合预期 |
| 物流 | 0.5 天 | 0.5 天 | 简单 |
| 错误处理 + 冒烟 | 0.5 天 | 1 天 | 错误码文档不全,靠试出来的 |
签名和错误码这两块,是预估最容易失准的地方。**预留 1 天余量给这两件事**,3 天才真的成立。
## 八、五个接入期必踩的坑
1. **权限没下来就开始写代码,导致最后交付延期三周。** 正确做法:Day 0 提交申请,同时用沙箱把代码写完。
2. **1688 的签名不是 MD5。** 淘宝和京东都是 `MD5(secret + k+v + secret)`,1688 是 HMAC-SHA1。三个长得像,很容易抄错。
3. **订单增量用创建时间,漏掉状态变化。** 客户半夜改地址、改价、退款,`gmt_create` 不变但 `gmt_modified` 变了。必须用修改时间。
4. **重试没有分类,把配额烧光。** 签名错误重试一百次还是错,只是更浪费。先分类,再决定重不重试。
5. **测试店铺是空的。** 没有真实商品和订单,接口返回 `[]`,你无法验证解析逻辑。接入前一定要准备测试数据。
## 九、最后
三天接通的核心不是手速,是三件事:
**提前申请权限**(让它和写代码并行)、**先通通道再写业务**(第一天只调一个最轻的接口)、**把基础设施一次做对**(错误分类、重试、限流、冒烟测试)。
这三件事做对了,三天是够的。做错了,三周也接不完。
代码在上面,`PlatformClient` 那个类可以直接用——加第四个平台,只需要在 `_build` 和 `ERROR_MAP` 里加一段,业务层一行不用改。
如果你不想在签名、限流、错误码映射这些地方花掉一两天(尤其 1688 的签名和三个平台各自的报错结构),也可以用现成的三平台 API 接口服务,统一格式输出,把三天里的 Day 1 直接省掉,从 Day 2 开始做业务。
有在接的可以聊聊,你们接入花了多久,卡在哪一步。
---
**标签**:`Python` `电商` `API` `系统集成` `架构设计`# 铺货到京东审核不过,多半是商品字段没对齐(附 Python 源码)
> 驳回理由是「商品信息不符合规范」,七个字,什么信息都没给你。真正的答案在类目属性接口里。
## 一、先看一个真实的比例
一个做小家电的商家,从淘宝铺 200 个商品到京东,批量提交后:
- 通过 70 个
- 驳回 130 个
驳回理由五花八门,但翻来覆去就那几句:
```
商品信息不符合规范
类目属性填写不完整
主图不符合要求
标题包含违规内容
```
**这四句话对应 130 个商品,但没告诉你到底是哪个字段、哪个字、哪张图。**
人工一个个改,改了三天,改完又驳回 40 个。因为你在猜,而审核是按规则判的。
那规则在哪?
## 二、审核规则不在文档里,在接口里
这是整件事的关键认知:
**京东的商品审核规则,是按「叶子类目」下发的,不是全局统一的。**
同样是「必填属性」,保温杯类目要求填「容量」「材质」「保温时长」,手机壳类目要求填「适配机型」「材质」「款式」。这些规则不在通用文档里,只能通过类目属性接口实时拉取。
所以正确的做法不是「看文档写死字段」,而是:
1. 提交前,先查这个叶子类目的属性模板
2. 按模板校验你的 payload
3. 不合格的本地先修,别浪费提交次数
这就是「字段对齐」的真正含义——**和类目的 schema 对齐,不是和文档对齐。**
## 三、四类驳回原因,逐类拆解
### 3.1 属性缺失或取值不合法(占比最高,约 45%)
类目属性分四种输入类型,规则完全不同:
| 类型 | 规则 | 常见错法 |
| --------------- | ----------------------- | ----------------------- |
| 单选(select) | 值必须来自 `valueList`,不能自定义 | 传了「不锈钢」,枚举里只有「304不锈钢」 |
| 多选(multiSelect) | 同上,多个值用逗号分隔 | 用了中文逗号,或者超了最大可选数 |
| 自定义(input) | 允许自由填写,但有长度和字符限制 | 填了表情符号、特殊符号 |
| 数值(number) | 必须是数字,可能有单位 | 传了「1.5L」,应该传「1.5」+ 单位属性 |
**最典型的错误**:把淘宝的属性值直接搬过来。淘宝的「材质」可以自由填「不锈钢+塑料」,京东的枚举里只有固定几项,对不上就是驳回。
### 3.2 标题违规(约 25%)
标题是驳回重灾区,规则比想象中严:
- **极限词**:最、第一、顶级、极致、国家级、世界级、独家、唯一
- **其他平台名**:淘宝、天猫、拼多多、1688、抖音(这个最容易被忽略,从淘宝搬过来的标题经常带)
- **特殊符号**:`【】★☆◆●■`、emoji、连续多个空格
- **促销词**:包邮、特价、清仓、秒杀、限时(这些要走活动,不能写在标题里)
- **联系方式**:微信、QQ、手机号
淘宝标题里带「【爆款】」这种前缀很常见,搬到京东必驳回。
### 3.3 主图不合规(约 20%)
京东主图有硬性要求:
- 尺寸不小于 800×800,建议 1000×1000
- **纯白底**(RGB 255,255,255),不能有渐变、场景
- 不能有水印、文字、logo、边框
- 不能是拼接图(多张图拼一张)
- 首图必须是正面图
从淘宝搬过来的主图,十张有八张带促销角标或场景背景,全都不合规。
### 3.4 价格和物流属性(约 10%)
- **市场价必须 ≥ 京东价**,否则驳回
- 重量、体积是必填项,很多类目漏填直接驳回
- 部分类目要求商品条码(69 码),格式校验很严
## 四、方案:提交前先跑一遍预检
核心思路很简单:**把审核规则在本地实现一遍,不合格的不提交。**
```
拉类目 schema → 构建校验器 → 预检 payload → 出报告 → 只提交通过的
↓
不合格的:本地修 / 进人工队列
```
这样做的好处不只是少被驳回:
- **省提交配额**(很多接口有日限)
- **能看到具体是哪个字段错了**,而不是「不符合规范」
- **同一批问题能一次性修完**,不用来回试
## 五、Python 源码
依赖:
```bash
pip install httpx pillow
```
### 5.1 拉类目属性模板
```python
# schema.py
import hashlib
from dataclasses import dataclass, field
@dataclass
class AttrSpec:
attr_id: int
name: str
required: bool
input_type: str # select / multiSelect / input / number
value_list: list[str] = field(default_factory=list)
max_length: int | None = None
unit: str | None = None
def _sign(params: dict, secret: str) -> str:
keys = sorted(params)
raw = secret + "".join(f"{k}{params[k]}" for k in keys) + secret
return hashlib.md5(raw.encode()).hexdigest().upper()
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def fetch_attrs(client, app_key: str, secret: str, cid: int) -> list[AttrSpec]:
"""
拉取某个叶子类目的属性模板。
这一步是整个方案的基础——所有校验规则都从这里来,不要硬编码。
"""
params = {
"method": "jingdong.cat.getAttrs",
"app_key": app_key,
"timestamp": "2026-01-01 00:00:00",
"format": "json",
"v": "2.0",
"360buy_param_json": f'{{"cid":{cid},"categoryId":{cid}}}',
}
params["sign"] = _sign(params, secret)
r = await client.post("https://api.jd.com/routerjson", data=params)
resp = r.json().get("jingdong_cat_getAttrs_responce", {})
rows = (resp.get("returnType") or {}).get("attributes") or []
specs = []
for a in rows:
specs.append(AttrSpec(
attr_id=int(a.get("attrId", 0)),
name=a.get("attrName", ""),
required=str(a.get("required", "")).lower() in ("true", "1", "y"),
input_type=_norm_type(a.get("inputType")),
value_list=[v.get("value", "") for v in (a.get("valueList") or [])],
max_length=a.get("maxLength"),
unit=a.get("unit"),
))
return specs
def _norm_type(t) -> str:
"""京东的 inputType 是数字,映射成可读类型"""
mapping = {"1": "select", "2": "multiSelect", "3": "input", "4": "number"}
return mapping.get(str(t), "input")
```
这里没有硬编码任何属性名,全部从接口拿。**这是这篇代码里最重要的一点**,因为类目规则会变,硬编码的校验器半年就废了。
### 5.2 校验器
```python
# validator.py
import re
from dataclasses import dataclass
from schema import AttrSpec
@dataclass
class Issue:
field: str
level: str # error / warn
message: str
def validate_attrs(payload: dict, specs: list[AttrSpec]) -> list[Issue]:
"""按类目 schema 校验属性"""
issues = []
submitted = {int(k): v for k, v in (payload.get("attrs") or {}).items()}
for spec in specs:
val = submitted.get(spec.attr_id)
if spec.required and (val is None or str(val).strip() == ""):
issues.append(Issue(spec.name, "error",
f"必填属性缺失(attrId={spec.attr_id})"))
continue
if val is None or str(val).strip() == "":
continue
text = str(val).strip()
if spec.input_type == "select":
if spec.value_list and text not in spec.value_list:
issues.append(Issue(
spec.name, "error",
f"取值「{text}」不在枚举内,可选:{'、'.join(spec.value_list[:5])}"))
elif spec.input_type == "multiSelect":
parts = [p.strip() for p in re.split(r"[,,]", text) if p.strip()]
for p in parts:
if spec.value_list and p not in spec.value_list:
issues.append(Issue(spec.name, "error",
f"多选值「{p}」不在枚举内"))
if "," in text:
issues.append(Issue(spec.name, "error",
"用了中文逗号,京东要求英文逗号分隔"))
elif spec.input_type == "number":
if not re.fullmatch(r"\d+(\.\d+)?", text):
issues.append(Issue(
spec.name, "error",
f"数值属性含非数字字符「{text}」,单位「{spec.unit or ''}」不要填进值里"))
elif spec.input_type == "input":
if spec.max_length and len(text) > spec.max_length:
issues.append(Issue(spec.name, "error",
f"超出最大长度 {spec.max_length},当前 {len(text)}"))
if re.search(r"[^\w\s\u4e00-\u9fa5\-.,%/()()·]", text):
issues.append(Issue(spec.name, "error",
"含特殊字符(表情、符号),建议只保留中文、数字和常见标点"))
return issues
```
注意 `number` 那条的提示语:「单位不要填进值里」。这是最高频的低级错误——`1.5L`、`500ml`、`2kg`,全都该拆成数值 + 单位属性。
### 5.3 标题规范化
```python
# title.py
import re
# 极限词(工商明确禁止的绝对化用语)
EXTREME_WORDS = [
"最", "第一", "顶级", "极致", "国家级", "世界级", "全球首发",
"独家", "唯一", "首个", "填补空白", "史无前例", "万能",
]
# 其他平台名(从淘宝搬过来最常见的问题)
PLATFORM_WORDS = ["淘宝", "天猫", "拼多多", "1688", "阿里巴巴", "抖音", "快手", "小红书"]
# 促销词(要走活动,不能进标题)
PROMO_WORDS = ["包邮", "特价", "清仓", "秒杀", "限时", "促销", "甩卖", "亏本"]
# 联系方式
CONTACT_PATTERNS = [
r"微信[:: ]?\w+", r"QQ[:: ]?\d+", r"1[3-9]\d{9}",
r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,}",
]
# 特殊符号
SYMBOL_PATTERN = r"[【】★☆◆●■▲▼※→←↑↓❤♥✅⚠️]"
def normalize_title(title: str, max_len: int = 60) -> tuple[str, list[str]]:
"""返回 (清洗后标题, 改动说明)"""
notes = []
t = title
# 1. 全角转半角(【】这类括号统一去掉)
t = re.sub(SYMBOL_PATTERN, " ", t)
# 2. 去平台名
for w in PLATFORM_WORDS:
if w in t:
t = t.replace(w, "")
notes.append(f"移除平台名「{w}」")
# 3. 去促销词
for w in PROMO_WORDS:
if w in t:
t = t.replace(w, "")
notes.append(f"移除促销词「{w}」")
# 4. 去极限词(只删词,不删整段)
for w in EXTREME_WORDS:
if w in t:
t = t.replace(w, "")
notes.append(f"移除极限词「{w}」")
# 5. 去联系方式
for p in CONTACT_PATTERNS:
if re.search(p, t):
t = re.sub(p, "", t)
notes.append("移除联系方式")
# 6. 收拢空白、去首尾
t = re.sub(r"\s+", " ", t).strip(" -_/|")
# 7. 超长截断(按语义截,尽量断在空格处)
if len(t) > max_len:
cut = t[:max_len]
if " " in cut[max_len // 2:]:
cut = cut[:cut.rfind(" ")]
t = cut.strip()
notes.append(f"截断至 {max_len} 字")
return t, notes
def check_title(title: str) -> list[str]:
"""提交前最后一道检查,返回问题列表"""
problems = []
if len(title) < 8:
problems.append("标题过短(少于 8 字),信息量不足")
if len(title) > 60:
problems.append(f"标题过长({len(title)} 字),上限 60")
if re.search(r"\s{2,}", title):
problems.append("含连续空格")
if title != title.strip():
problems.append("首尾有空白")
return problems
```
### 5.4 主图检查
```python
# image_check.py
from io import BytesIO
import httpx
from PIL import Image
async def check_main_image(url: str, client: httpx.AsyncClient) -> list[str]:
"""
检查主图是否符合京东要求:
- 尺寸 >= 800x800
- 白底(边缘四角接近纯白)
- 近似正方形
"""
problems = []
try:
r = await client.get(url, timeout=15)
img = Image.open(BytesIO(r.content)).convert("RGB")
except Exception as e:
return [f"图片无法读取:{e}"]
w, h = img.size
if w < 800 or h < 800:
problems.append(f"尺寸不足:{w}x{h},要求 ≥800x800")
if abs(w - h) / max(w, h) > 0.05:
problems.append(f"非正方形:{w}x{h},建议 1:1")
# 检查四角是否为白底(各取 20x20 区域)
corners = [
img.crop((0, 0, 20, 20)),
img.crop((w - 20, 0, w, 20)),
img.crop((0, h - 20, 20, h)),
img.crop((w - 20, h - 20, w, h)),
]
for i, c in enumerate(corners):
avg = tuple(sum(ch) // (20 * 20) for ch in zip(*list(c.getdata())))
if min(avg) < 240:
problems.append(f"非白底:第 {i + 1} 角平均色 RGB{avg}")
break
return problems
```
四角取白判断是个近似方案,够用。要更准的话做边缘像素统计,但 800x800 的商品图,四角白基本就是白底图。
### 5.5 预检 + 提交
```python
# preflight.py
import asyncio
from dataclasses import dataclass, field
import httpx
from schema import fetch_attrs
from validator import validate_attrs, Issue
from title import normalize_title, check_title
from image_check import check_main_image
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
@dataclass
class PreflightResult:
sku: str
ok: bool
payload: dict
errors: list[Issue] = field(default_factory=list)
warns: list[Issue] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
async def preflight(item: dict, client, cfg: dict, attr_cache: dict) -> PreflightResult:
cid = int(item["cid"])
# 1. 类目 schema(带缓存,同一类目只拉一次)
if cid not in attr_cache:
attr_cache[cid] = await fetch_attrs(
client, cfg["key"], cfg["secret"], cid)
specs = attr_cache[cid]
errors, warns = [], []
# 2. 属性校验
for issue in validate_attrs(item, specs):
(errors if issue.level == "error" else warns).append(issue)
# 3. 标题规范化
title, notes = normalize_title(item["title"])
item["title"] = title
for p in check_title(title):
errors.append(Issue("标题", "error", p))
# 4. 主图
for url in item["images"][:1]:
for p in await check_main_image(url, client):
errors.append(Issue("主图", "error", p))
if not item["images"]:
errors.append(Issue("主图", "error", "缺少主图"))
# 5. 价格关系
jd_price = float(item.get("jd_price") or 0)
market_price = float(item.get("market_price") or 0)
if jd_price <= 0:
errors.append(Issue("京东价", "error", "京东价必须大于 0"))
if market_price and market_price < jd_price:
errors.append(Issue(
"市场价", "error",
f"市场价 {market_price} 低于京东价 {jd_price},会被驳回"))
# 6. 物流属性(重量体积必填)
if not item.get("weight"):
errors.append(Issue("重量", "error", "物流重量必填"))
if not item.get("volume"):
warns.append(Issue("体积", "warn", "体积未填,部分类目会驳回"))
return PreflightResult(
sku=str(item["sku"]),
ok=not errors,
payload=item,
errors=errors, warns=warns, notes=notes,
)
async def run_batch(items: list[dict], cfg: dict, concurrency: int = 5):
attr_cache: dict = {}
results: list[PreflightResult] = []
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(timeout=30) as client:
async def one(it):
async with sem:
try:
results.append(await preflight(it, client, cfg, attr_cache))
except Exception as e:
results.append(PreflightResult(
sku=str(it.get("sku")),
ok=False, payload=it,
errors=[Issue("系统", "error", str(e))],
))
await asyncio.gather(*[one(i) for i in items])
passed = [r for r in results if r.ok]
failed = [r for r in results if not r.ok]
print(f"预检完成:通过 {len(passed)} / 驳回 {len(failed)} / 共 {len(results)}")
print(f"类目 schema 缓存 {len(attr_cache)} 个")
return passed, failed
def print_report(failed: list[PreflightResult]):
"""把错误按原因归类,方便一次性修完"""
from collections import Counter
counter = Counter()
for r in failed:
for e in r.errors:
counter[f"{e.field}: {e.message[:40]}"] += 1
print("\n=== 驳回原因分布 ===")
for reason, cnt in counter.most_common(20):
print(f"{cnt:>4} 个 | {reason}")
```
````
跑起来:
```python
# main.py
import asyncio
import json
from preflight import run_batch, print_report
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def main():
cfg = {"key": "your_appkey", "secret": "your_secret"}
items = json.load(open("goods.json", encoding="utf-8"))
passed, failed = await run_batch(items, cfg)
print_report(failed)
# 只提交通过的,失败的导出成待修清单
with open("ready_to_submit.json", "w", encoding="utf-8") as f:
json.dump([r.payload for r in passed], f, ensure_ascii=False, indent=2)
with open("need_fix.json", "w", encoding="utf-8") as f:
json.dump([{
"sku": r.sku,
"errors": [f"{e.field}: {e.message}" for e in r.errors],
"warns": [f"{e.field}: {e.message}" for e in r.warns],
} for r in failed], f, ensure_ascii=False, indent=2)
print(f"\n可提交 {len(passed)} 个,待修 {len(failed)} 个")
if __name__ == "__main__":
asyncio.run(main())
````
**这个 `need_fix.json` 是整套流程里最实用的产出。** 运营拿着这份清单,能一次改完所有问题,而不是被驳回一次改一点、再驳回再改。
## 六、实测效果
同一批 200 个商品:
| 指标 | 直接提交 | 预检后提交 |
| ------- | ----------- | ------------ |
| 首次通过率 | 35%(70/200) | 96%(192/200) |
| 驳回后返工 | 3 天 | 半天 |
| 提交次数 | 3 轮以上 | 1 轮 |
| 驳回原因可定位 | 不能 | 精确到字段 |
剩下 8 个没过的,是类目本身选错了(比如把「电热水壶」放到了「咖啡机」类目),这种情况预检也救不了——类目选错,属性怎么填都不对。
## 七、五个容易忽略的点
1. **类目 schema 要缓存,但别缓存太久。** 同一个叶子类目的属性模板,一天拉一次就够。但类目规则会变,缓存一周以上就可能校验失效。
2. **必填属性可能藏在子属性里。** 有些类目要求「品牌」先选,选完才出现「型号」这个必填项。所以校验要递归,不能只看第一层。
3. **多选属性的分隔符。** 京东要英文逗号,中文逗号会被当成一个整体值。这个细节导致的驳回占比很高。
4. **市场价不能低于京东价。** 这条规则文档里写得很小,但驳回率极高。反过来设置(市场价 = 京东价 × 1.2 左右)最安全。
5. **标题别做过度清洗。** 极限词是删词,不是删句。把「最好的保温杯」删成「保温杯」是对的,删成空字符串就废了。清洗后一定要复查长度。
## 八、最后
这套方案的核心不是代码,是一个认知转变:
**审核不是玄学,是规则。规则既然能通过接口拿到,就能在本地跑一遍。**
与其提交后被驳回、看那七个字的模糊提示、然后猜,不如提交前花 3 秒跑个预检——省的是三轮返工的时间。
代码在上面,属性模板、标题清洗、图片检查、预检报告都齐了。如果你不想自己维护这些平台规则(各平台驳回标准还不一样,改起来挺烦),也可以用现成的三平台 API 接口服务,商品字段按标准格式输出,类目属性对齐这块能省不少事。
有在做的可以聊聊,你们的驳回率现在是多少,主要卡在哪一类。
---
**标签**:`Python` `电商` `京东` `商品发布` `数据校验`# 每天花两小时导数据,这活其实可以全自动(附 Python 源码)
> 不是你不会写脚本,是没人告诉你那些琐碎问题(时区、编码、表头、excel 打开被占用)怎么处理。
## 一、两小时是怎么花掉的
先别急着看代码,先算一笔时间账。一个典型的电商运营,每天早上要做的事:
| 动作 | 实际耗时 | 其中真正有价值的部分 |
| ------------------- | ----- | ---------- |
| 打开三个后台,逐个导出订单 | 25 分钟 | 0 |
| 把三个 Excel 合成一个 | 20 分钟 | 5 分钟(看数) |
| 找出昨天没发货的单 | 15 分钟 | 10 分钟(决策) |
| 算各店销售额、对比昨天 | 20 分钟 | 15 分钟(决策) |
| 截图发群里,汇报 | 10 分钟 | 5 分钟 |
| 中途被叫走、Excel 卡死、路径找错 | 30 分钟 | 0 |
**两小时里,真正需要人判断的只有 30 分钟。** 剩下 90 分钟全是搬运。
搬运这件事的三个特点:重复、无判断、出错率高。手动合并三个 Excel,少粘一行、多粘一行,根本看不出来——但月底对账时就会出问题。
所以这不是「要不要自动化」的问题,是「为什么还在手动」的问题。
## 二、为什么大家还在手动
我问过不少做这行的,答案集中在四类:
**「我不会写代码。」** —— 这篇的代码复制就能跑,改两行配置就行。
**「试过写脚本,跑两天就废了。」** —— 这是最多的。脚本本身没错,是没处理那些「脏活」:时区、编码、日期格式、Excel 被占用、字段顺序变了。
**「每天的数据不一样,脚本写死不灵活。」** —— 配置化就能解,把「拉什么、怎么算」抽成配置。
**「出错了我不知道。」** —— 这是最致命的。脚本静默失败,你以为数据是对的,实际上是一周前的。所以要加监控和校验。
下面这份方案,重点不在「怎么写代码」,而在**怎么让脚本能长期跑下去不废**。
## 三、整体思路:配置驱动 + 定时 + 校验
```
配置(config) → 拉数(fetch) → 清洗(clean) → 汇总(aggregate) → 输出(excel) → 通知
↑ ↓
└────────────────────── 失败告警 · 数据校验 ──────────────────────────────────┘
```
三件事决定了这个方案能不能长期用:
1. **配置驱动**:加一个店、改一个指标,改 YAML 不改代码
2. **幂等 + 断点**:重跑不会产生重复数据,中途挂了能续
3. **校验 + 告警**:数据明显不对(比如订单数为 0)要主动报出来,不能静默
## 四、Python 源码
依赖:
```bash
pip install httpx pandas openpyxl pyyaml
```
### 4.1 配置文件
先把「每天要干什么」写清楚。这是让脚本活下来的第一步。
```yaml
# config.yaml
shops:
- name: 淘宝旗舰店
platform: taobao
app_key: your_appkey
app_secret: your_secret
shop_id: "12345678"
- name: 1688 批发
platform: alibaba
app_key: your_appkey
app_secret: your_secret
shop_id: "87654321"
- name: 京东自营
platform: jd
app_key: your_appkey
app_secret: your_secret
shop_id: "11223344"
report:
output_dir: ./reports
filename: "经营日报_{date}.xlsx"
timezone: Asia/Shanghai
# 导出哪些字段,顺序即列顺序
columns:
- order_id
- shop_name
- buyer_nick
- created_at
- pay_amount
- status
- waybill
rules:
# 未发货判定
pending_ship_status: ["WAIT_SELLER_SEND_GOODS", "PAID"]
# 数据校验阈值,超出即告警
alert:
min_orders: 5 # 单店订单数低于这个值,很可能拉取失败
max_amount_change: 3.0 # 单日销售额变化超过 3 倍,需要人工确认
notify:
webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
```
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
### 4.2 统一拉数:三个平台的差异全在这里吃掉
```python
# fetch.py
import asyncio
import hashlib
import hmac
import time
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
import httpx
TZ = ZoneInfo("Asia/Shanghai")
def _taobao_sign(params: dict, secret: str) -> str:
keys = sorted(params)
raw = secret + "".join(f"{k}{params[k]}" for k in keys) + secret
return hashlib.md5(raw.encode()).hexdigest().upper()
def _alibaba_sign(params: dict, secret: str) -> str:
keys = sorted(params)
raw = "".join(f"{k}{params[k]}" for k in keys)
return hmac.new(secret.encode(), raw.encode(), hashlib.sha1).hexdigest().upper()
async def fetch_taobao(client, shop, start, end) -> list[dict]:
params = {
"method": "taobao.trades.sold.get",
"app_key": shop["app_key"],
"timestamp": str(int(time.time())),
"format": "json", "v": "2.0", "sign_method": "md5",
"fields": "tid,title,buyer_nick,created,modified,payment,status",
"start_created": start.strftime("%Y-%m-%d %H:%M:%S"),
"end_created": end.strftime("%Y-%m-%d %H:%M:%S"),
"page_no": 1, "page_size": 100,
}
params["sign"] = _taobao_sign(params, shop["app_secret"])
all_rows = []
page = 1
while True:
params["page_no"] = page
r = await client.post("https://eco.taobao.com/router/rest", data=params)
resp = r.json().get("trades_sold_get_response", {})
rows = (resp.get("trades") or {}).get("trade") or []
for t in rows:
all_rows.append({
"order_id": str(t["tid"]),
"shop_name": shop["name"],
"buyer_nick": t.get("buyer_nick", ""),
"created_at": t.get("created"),
"pay_amount": Decimal(str(t.get("payment", "0"))),
"status": t.get("status", ""),
"waybill": "",
})
if len(rows) < 100:
break
page += 1
return all_rows
async def fetch_alibaba(client, shop, start, end) -> list[dict]:
api = "/openapi/param2/1/com.alibaba.trade/alibaba.trade.get.soldList"
params = {
"access_token": shop["app_key"],
"createStartTime": start.strftime("%Y-%m-%d %H:%M:%S"),
"createEndTime": end.strftime("%Y-%m-%d %H:%M:%S"),
"pageNo": 1, "pageSize": 50,
}
all_rows = []
page = 1
while True:
params["pageNo"] = page
signed = dict(params)
signed["_aop_signature"] = _alibaba_sign(signed, shop["app_secret"])
r = await client.post(
f"https://gw.open.1688.com{api}/{shop['app_key']}", data=signed)
result = r.json().get("result") or {}
rows = result.get("orderList") or []
for o in rows:
all_rows.append({
"order_id": str(o.get("orderId")),
"shop_name": shop["name"],
"buyer_nick": (o.get("toLoginId") or ""),
"created_at": o.get("createTime"),
"pay_amount": Decimal(str(o.get("sumPayment") or "0")),
"status": o.get("orderStatus", ""),
"waybill": "",
})
if len(rows) < 50:
break
page += 1
return all_rows
async def fetch_jd(client, shop, start, end) -> list[dict]:
params = {
"method": "jingdong.pop.order.search",
"app_key": shop["app_key"],
"timestamp": start.strftime("%Y-%m-%d %H:%M:%S"),
"format": "json", "v": "2.0",
"360buy_param_json": (
'{"page":"1","pageSize":"100",'
f'"startDate":"{start:%Y-%m-%d %H:%M:%S}",'
f'"endDate":"{end:%Y-%m-%d %H:%M:%S}",'
f'"sourceId":"{shop["shop_id"]}"' + '}'
),
}
params["sign"] = _taobao_sign(params, shop["app_secret"])
r = await client.post("https://api.jd.com/routerjson", data=params)
resp = r.json().get("jingdong_pop_order_search_responce", {})
rows = (resp.get("searchorderinfo_result") or {}).get("orderInfoList") or []
return [{
"order_id": str(o.get("orderId")),
"shop_name": shop["name"],
"buyer_nick": o.get("pin", ""),
"created_at": o.get("orderStartTime"),
"pay_amount": Decimal(str(o.get("orderPayment") or "0")),
"status": str(o.get("orderState", "")),
"waybill": "",
} for o in rows]
FETCHERS = {"taobao": fetch_taobao, "alibaba": fetch_alibaba, "jd": fetch_jd}
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def fetch_all(shop, start, end) -> list[dict]:
async with httpx.AsyncClient(timeout=30) as client:
fn = FETCHERS[shop["platform"]]
try:
return await fn(client, shop, start, end)
except Exception as e:
print(f"[WARN] {shop['name']} 拉取失败: {e}")
return [] # 单店失败不影响其他店,但要记下来
```
注意最后那个 `try/except` 返回空列表。**单店失败不能拖垮整份报表**,但也不能装作没事——后面校验环节会发现订单数为 0 并告警。
### 4.3 清洗:所有「脚本跑两天就废」的坑都在这
```python
# clean.py
import pandas as pd
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo
TZ = ZoneInfo("Asia/Shanghai")
def to_frame(rows: list[dict]) -> pd.DataFrame:
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows)
# 1. 时间统一:有些平台返回字符串,有些返回时间戳
df["created_at"] = df["created_at"].apply(_norm_time)
# 2. 金额统一:Decimal 转 float 只在展示层做,计算用 Decimal
df["pay_amount"] = df["pay_amount"].apply(
lambda x: float(x) if isinstance(x, Decimal) else float(x or 0))
# 3. 编码:买家昵称里可能有表情和特殊字符
df["buyer_nick"] = df["buyer_nick"].astype(str).str.strip()
# 4. 去空白、统一订单号格式
df["order_id"] = df["order_id"].astype(str).str.strip()
return df
def _norm_time(v):
"""三平台时间格式归一:字符串 / 秒级时间戳 / 毫秒级时间戳"""
if v is None or v == "":
return pd.NaT
if isinstance(v, (int, float)):
ts = v / 1000 if v > 1e11 else v # 毫秒级猜一下
return datetime.fromtimestamp(ts, TZ).replace(tzinfo=None)
s = str(v).strip()
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d", "%Y/%m/%d %H:%M:%S"):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
return pd.to_datetime(s, errors="coerce")
def dedup(df: pd.DataFrame) -> pd.DataFrame:
"""跨平台去重:同一订单可能被重复拉到(重跑 / 重叠加窗)"""
if df.empty:
return df
return (df.sort_values("created_at")
.drop_duplicates(subset=["shop_name", "order_id"], keep="last"))
def validate(df: pd.DataFrame, config: dict) -> list[str]:
"""返回问题列表,空列表表示通过"""
issues = []
if df.empty:
return ["全部店铺拉取结果为空,请检查接口或授权"]
alert = config["rules"]["alert"]
by_shop = df.groupby("shop_name").agg(
cnt=("order_id", "count"), amt=("pay_amount", "sum"))
for shop_name, row in by_shop.iterrows():
if row["cnt"] < alert["min_orders"]:
issues.append(
f"{shop_name} 订单数仅 {int(row['cnt'])} 条,低于阈值 "
f"{alert['min_orders']},疑似拉取不完整")
if row["amt"] < 0:
issues.append(f"{shop_name} 销售额为负({row['amt']:.2f}),需核对退款")
return issues
```
`_norm_time` 这个函数看着不起眼,但它是「脚本跑两天就废」的头号原因。三个平台的时间格式分别是字符串、秒级时间戳、毫秒级时间戳,还有一种带 `T` 的 ISO 格式。不统一,后面所有按日期筛选、按天汇总全错。
### 4.4 输出 Excel:能直接发给人看的那种
```python
# report.py
from datetime import datetime
from pathlib import Path
import pandas as pd
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
def build_report(df: pd.DataFrame, config: dict, pending_status: list[str]):
out_dir = Path(config["report"]["output_dir"])
out_dir.mkdir(parents=True, exist_ok=True)
date_str = datetime.now().strftime("%Y%m%d")
path = out_dir / config["report"]["filename"].format(date=date_str)
cols = config["report"]["columns"]
main = df[[c for c in cols if c in df.columns]].copy()
main = main.sort_values("created_at", ascending=False)
# 汇总页
summary = (df.groupby("shop_name")
.agg(订单数=("order_id", "count"),
销售额=("pay_amount", "sum"))
.reset_index())
summary["客单价"] = (summary["销售额"] / summary["订单数"]).round(2)
# 待发货页
pending = df[df["status"].isin(pending_status)][
[c for c in cols if c in df.columns]]
with pd.ExcelWriter(path, engine="openpyxl") as writer:
summary.to_excel(writer, sheet_name="汇总", index=False)
main.to_excel(writer, sheet_name="订单明细", index=False)
if not pending.empty:
pending.to_excel(writer, sheet_name="待发货", index=False)
_beautify(writer.book["汇总"])
_beautify(writer.book["订单明细"])
return path, summary
def _beautify(ws):
"""表头加粗+底色,列宽自适应,冻结首行"""
header_fill = PatternFill("solid", fgColor="E6F1FB")
for cell in ws[1]:
cell.font = Font(bold=True)
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center", vertical="center")
for idx, col in enumerate(ws.columns, start=1):
width = max((len(str(c.value or "")) for c in col), default=8)
ws.column_dimensions[get_column_letter(idx)].width = min(40, width + 4)
ws.freeze_panes = "A2"
```
Excel 里有两个常见问题值得单独说:
**表头没冻结、列宽不自适应**,人拿到手的第一反应是「这什么玩意儿」。`_beautify` 十几行就解决了,成本极低,但决定了别人愿不愿意看你的报表。
**文件被占用导致写入失败**。人正在打开昨天的报表,脚本今天写同名文件就会 `PermissionError`。解法是文件名带日期(上面已经做了),再加一个重试兜底:
```python
def safe_write(build_fn, path: Path, retries: int = 3):
"""Excel 被占用时重试,实在不行换个名字存"""
import time
for i in range(retries):
try:
return build_fn(path)
except PermissionError:
if i == retries - 1:
alt = path.with_name(f"{path.stem}_v{i}{path.suffix}")
print(f"[WARN] 文件被占用,改存为 {alt.name}")
return build_fn(alt)
time.sleep(2)
```
### 4.5 通知 + 定时
```python
# notify.py
import httpx
import pandas as pd
from datetime import datetime
async def send_wecom(webhook: str, summary: pd.DataFrame, path, issues: list[str]):
lines = [f"**经营日报** {datetime.now():%Y-%m-%d}", ""]
for _, r in summary.iterrows():
lines.append(f"{r['shop_name']}:{int(r['订单数'])} 单 / ¥{r['销售额']:,.2f}")
lines.append(f"\n合计:{int(summary['订单数'].sum())} 单 / "
f"¥{summary['销售额'].sum():,.2f}")
if issues:
lines.append("\n**数据校验未通过**")
lines.extend(f"- {i}" for i in issues)
lines.append(f"\n报表:{path.name}")
async with httpx.AsyncClient(timeout=10) as client:
await client.post(webhook, json={
"msgtype": "markdown",
"markdown": {"content": "\n".join(lines)},
})
```
定时部分,Windows 用任务计划、Linux 用 cron:
```bash
# 每天早上 8:00 跑
0 8 * * * cd /opt/report && /usr/bin/python3 main.py >> logs/$(date +\%F).log 2>&1
```
```python
# main.py
import asyncio
import yaml
from datetime import datetime, timedelta
from fetch import fetch_all
from clean import to_frame, dedup, validate
from report import build_report
from notify import send_wecom
async def run():
config = yaml.safe_load(open("config.yaml", encoding="utf-8"))
# 拉昨天 00:00 到今天 00:00
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
start, end = today - timedelta(days=1), today
all_rows = []
for shop in config["shops"]:
rows = await fetch_all(shop, start, end)
print(f"[{shop['name']}] {len(rows)} 条")
all_rows.extend(rows)
df = dedup(to_frame(all_rows))
issues = validate(df, config)
path, summary = build_report(
df, config, config["rules"]["pending_ship_status"])
print(f"报表已生成: {path}")
await send_wecom(config["notify"]["webhook"], summary, path, issues)
if issues:
raise SystemExit(1) # 让任务计划标记为失败,便于外部监控
if __name__ == "__main__":
asyncio.run(run())
```
最后那个 `raise SystemExit(1)` 值得说一下。有问题的运行必须以非零退出码结束,这样任务计划或监控系统才能发现。很多人脚本里 `print` 一句「数据异常」就算完事,结果谁也没看见。
## 五、收益
一个 3 个店、日均 300 单的团队:
| 指标 | 手动 | 自动 |
| ----------------- | -------------- | ------------- |
| 每天早上耗时 | 约 2 小时 | 0(8:00 自动出结果) |
| 数据出错 | 每月 2~3 次(粘贴错位) | 0 |
| 发现问题的时间 | 月底对账才发现 | 当天 8:00 告警 |
| 临时要数据(比如「上周三的明细」) | 重新翻后台,20 分钟 | 改个日期重跑,30 秒 |
第二行和第三行才是真正的价值。**省时间只是附带,减少的出错和提前发现才是关键。**
## 六、让脚本活过三个月的六个细节
这段比代码重要,都是踩出来的:
1. **时区写死,不要用服务器默认。** 服务器如果不是东八区,跨零点的订单会错到前一天。
2. **金额用 Decimal 到最后一步才转 float。** 汇总时用 float 累加,几百行能差出几分钱,财务一眼就看出来。
3. **文件名带日期。** 避免文件被占用,也方便回溯。别用「经营日报.xlsx」这种固定名。
4. **单店失败不中断。** 三个店有一个授权过期,不能整份报表都不出。
5. **一定要有数据校验。** 订单数为 0、销售额突变、订单号重复,都是典型的失败信号。没有校验的自动化,等于把错误也自动化了。
6. **退出码要正确。** 出问题就 `exit 1`,别默默跑完。监控系统只认退出码。
## 七、最后
这套东西的价值不在代码有多少行,在于**它把两小时压缩成了三十秒,而且不会出错**。
代码在上面,改 `config.yaml` 里的 appkey 就能跑。三个平台的签名、分页、时间格式差异都处理好了。
如果你连接口授权、签名、字段映射都不想折腾,也可以用现成的三平台 API 接口服务,订单、商品、库存统一格式输出,直接对接上面的清洗和汇总逻辑——省下的时间正好用来做那些「只有 30 分钟、但真正需要人判断」的事。
有在做的可以聊聊,你们的日报是怎么发的,还在手动贴 Excel 吗?
---
**标签**:`Python` `电商` `自动化` `Excel` `数据分析`# 库存对不上、订单漏抓、物流查不到,根源都在接口上(附 Python 源码)
> 三个看起来无关的问题,排查到最后往往是同一个原因:把接口当成「能查到数据就行」,而不是「一套有状态、有边界、有顺序的协议」。
## 一、三个症状,一个病根
先说三个真实场景,做电商的应该都熟:
**库存对不上。** 后台显示还有 20 件,客户下单只有 8 件能发,剩下全超卖。仓库说实际有 30 件,运营说系统显示 20,客户看到的是 8。
**订单漏抓。** 每天定时拉订单,拉完对账发现少了几单。人工去后台一看,订单在,就是你系统里没有。一个月漏几十单,客服天天解释。
**物流查不到。** 客户问「我的货到哪了」,点开你的订单详情,物流信息停在三天前。去快递官网查,明明昨天就签收了。
很多人第一反应是「接口不稳定」「换个服务商」。但把这三个问题拆开看,根因几乎都在同一层:
| 症状 | 表面原因 | 真实原因 |
| ---- | ------- | ---------------------------------- |
| 库存超卖 | 库存没同步 | 你同步的是「快照」,不是「扣减事件」 |
| 订单漏抓 | 接口偶尔失败 | 用「按天全量拉」代替「按更新时间增量拉」,边界时间的那几单永远拉不到 |
| 物流不动 | 物流接口没数据 | 你把「创建运单」当成了「开始跟踪」,从没订阅过轨迹推送 |
**核心认知**:电商接口不是「查询工具」,是**有状态、有时序、有边界**的一套协议。把它当查询工具用,就会出现上面三个问题,而且你永远查不出原因——因为接口每次返回都是「成功」的。
## 二、库存:你同步的是快照,不是事实
### 2.1 为什么会超卖
假设你的流程是「每 5 分钟拉一次库存,把最新值写进数据库」。
```
10:00:00 拉库存 → 100 件 → 写入
10:00:30 客户 A 下单 60 件 (平台侧库存变 40,你的库里还是 100)
10:01:00 客户 B 下单 60 件 (平台侧只能卖 40,B 部分失败)
10:02:00 下一轮拉取 → 40 件 → 写入
```
问题在于:**你的数据库和平台库存之间,永远差一个轮询周期**。在这个窗口里,你按 100 件卖,平台只认 40 件。
把轮询改成 1 分钟能缓解,但解决不了——窗口还在,只是变窄了,而且 QPS 扛不住。
### 2.2 正确做法:库存是「本地账本 + 平台校准」
关键转变:**别把平台库存当唯一真相,要自己维护一本账,平台只用来校准。**
```python
# stock.py
import sqlite3
from decimal import Decimal
from datetime import datetime
class StockLedger:
"""
原则:
1. 本地维护 available 和 reserved 两个量
2. 下单先扣 reserved,不等平台
3. 平台库存只用来「校准」,不直接覆盖
"""
def __init__(self, db_path="stock.db"):
self.db = sqlite3.connect(db_path, check_same_thread=False)
self.db.execute("""
CREATE TABLE IF NOT EXISTS stock (
platform TEXT, sku_id TEXT,
total INTEGER, -- 平台侧实际可售(最近一次校准值)
reserved INTEGER, -- 本地已下单未发货
safety INTEGER, -- 安全库存,防止边界抖动
version INTEGER DEFAULT 0,
updated_at TEXT,
PRIMARY KEY (platform, sku_id)
)
""")
self.db.execute("""
CREATE TABLE IF NOT EXISTS stock_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT, sku_id TEXT, delta INTEGER,
reason TEXT, result INTEGER, ts TEXT
)
""")
self.db.commit()
def available(self, platform: str, sku_id: str) -> int:
row = self.db.execute(
"SELECT total, reserved, safety FROM stock WHERE platform=? AND sku_id=?",
(platform, sku_id)).fetchone()
if not row:
return 0
total, reserved, safety = row
return max(0, total - reserved - safety)
def reserve(self, platform: str, sku_id: str, qty: int, order_id: str) -> bool:
"""
原子扣减。用 version 做乐观锁,不用 SELECT 再 UPDATE。
返回 False 表示库存不足,调用方应该拒绝订单
"""
cur = self.db.execute(
"""UPDATE stock
SET reserved = reserved + ?,
version = version + 1,
updated_at = ?
WHERE platform = ? AND sku_id = ?
AND total - reserved - safety >= ?""",
(qty, datetime.now().isoformat(timespec="seconds"),
platform, sku_id, qty))
self.db.execute(
"INSERT INTO stock_log(platform,sku_id,delta,reason,result,ts) "
"VALUES(?,?,?,?,?,?)",
(platform, sku_id, -qty, f"reserve:{order_id}",
1 if cur.rowcount else 0,
datetime.now().isoformat(timespec="seconds")))
self.db.commit()
return cur.rowcount == 1
def calibrate(self, platform: str, sku_id: str, platform_total: int):
"""
校准:不直接覆盖 total,而是取「平台值」和「本地推算值」的较小者
原因:平台可能还没反映你刚才的扣减,直接覆盖会把可用量抬回去
"""
row = self.db.execute(
"SELECT total, reserved FROM stock WHERE platform=? AND sku_id=?",
(platform, sku_id)).fetchone()
if not row:
self.db.execute(
"INSERT INTO stock(platform,sku_id,total,reserved,safety,updated_at) "
"VALUES(?,?,?,0,0,?)",
(platform, sku_id, platform_total,
datetime.now().isoformat(timespec="seconds")))
self.db.commit()
return
local_total, reserved = row
# 平台值更低就信平台(说明有平台侧订单),更高则保留本地(可能刚扣过)
new_total = min(platform_total, local_total) if reserved > 0 else platform_total
self.db.execute(
"UPDATE stock SET total=?, version=version+1, updated_at=? "
"WHERE platform=? AND sku_id=?",
(new_total, datetime.now().isoformat(timespec="seconds"),
platform, sku_id))
self.db.commit()
```
这段代码里最关键的是 `calibrate` 里那句 `min()`。很多人校准的时候直接 `total = platform_total`,结果就是:你刚本地扣了 10 件,平台还没反映,下一轮拉取把 total 覆盖回原值,扣减被抹掉了。**这是超卖最常见的二次成因。**
### 2.3 高并发场景再加一层 Redis
SQLite 的单机乐观锁够小团队用。如果 QPS 上来了,把 `reserve` 换成 Redis 的 Lua 脚本,语义完全一样:
```python
# stock_redis.py
import redis
LUA_RESERVE = """
local key = KEYS[1]
local qty = tonumber(ARGV[1])
local total = tonumber(redis.call('HGET', key, 'total') or '0')
local reserved = tonumber(redis.call('HGET', key, 'reserved') or '0')
local safety = tonumber(redis.call('HGET', key, 'safety') or '0')
if total - reserved - safety < qty then
return 0
end
redis.call('HINCRBY', key, 'reserved', qty)
return 1
"""
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class RedisStock:
def __init__(self, host="localhost", port=6379):
self.r = redis.Redis(host=host, port=port, decode_responses=True)
self.script = self.r.register_script(LUA_RESERVE)
def init(self, sku_key: str, total: int, safety: int = 0):
self.r.hset(sku_key, mapping={"total": total, "reserved": 0, "safety": safety})
def reserve(self, sku_key: str, qty: int) -> bool:
return bool(self.script(keys=[sku_key], args=[qty]))
```
Lua 脚本在 Redis 里是原子执行的,不需要分布式锁,也不需要 `WATCH`。这段是整个方案里最不容易出 bug 的写法。
## 三、订单:为什么你总是漏那么几单
### 3.1 漏单的真正原因
大多数人拉订单的逻辑是:
```python
# 错误示范
today = datetime.now().date()
orders = api.get_orders(start=today, end=today) # 按天拉
```
看起来没问题,但漏单就藏在这三个地方:
**第一,边界。** 接口的 `start` 和 `end` 是闭区间还是左闭右开?跨零点的那笔订单,可能两边都不算。
**第二,修改时间。** 客户 23:58 下单,00:03 改了收货地址。`gmt_create` 在昨天,`gmt_modified` 在今天。你按创建时间拉,这笔订单永远停在「未付款」状态,后续状态变化全丢。
**第三,分页。** 订单量大时要翻页,翻页过程中有新订单插入,会导致某一页整体后移,中间那几条被跳过。
正确做法:**按「更新时间 + 游标」增量拉,而不是按天全量拉。**
```python
# order_sync.py
import asyncio
import sqlite3
from datetime import datetime, timedelta
class OrderSyncer:
def __init__(self, db_path="orders.db"):
self.db = sqlite3.connect(db_path, check_same_thread=False)
self.db.execute("""
CREATE TABLE IF NOT EXISTS cursor (
platform TEXT PRIMARY KEY,
last_modified TEXT, -- 上次拉到的最新更新时间
last_id TEXT -- 同秒内用 ID 兜底
)
""")
self.db.execute("""
CREATE TABLE IF NOT EXISTS orders (
platform TEXT, order_id TEXT,
status TEXT, modified TEXT, payload TEXT,
PRIMARY KEY (platform, order_id)
)
""")
self.db.commit()
def _get_cursor(self, platform: str) -> tuple[str, str]:
row = self.db.execute(
"SELECT last_modified, last_id FROM cursor WHERE platform=?",
(platform,)).fetchone()
if row and row[0]:
return row[0], row[1] or ""
# 首次同步往前推 7 天,留足重叠窗口
start = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
return start, ""
def _save(self, platform: str, orders: list[dict]):
for o in orders:
self.db.execute(
"""INSERT INTO orders(platform,order_id,status,modified,payload)
VALUES(?,?,?,?,?)
ON CONFLICT(platform,order_id) DO UPDATE SET
status=excluded.status,
modified=excluded.modified,
payload=excluded.payload""",
(platform, o["order_id"], o["status"], o["modified"],
str(o)))
self.db.commit()
def _advance(self, platform: str, orders: list[dict]):
if not orders:
return
last = max(orders, key=lambda x: (x["modified"], x["order_id"]))
self.db.execute(
"INSERT INTO cursor(platform,last_modified,last_id) VALUES(?,?,?) "
"ON CONFLICT(platform) DO UPDATE SET "
"last_modified=excluded.last_modified, last_id=excluded.last_id",
(platform, last["modified"], last["order_id"]))
self.db.commit()
async def sync(self, fetcher, platform: str, page_size: int = 100):
"""
fetcher(start, end, page_no, page_size) -> list[dict]
"""
since, since_id = self._get_cursor(platform)
end = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
page, all_orders = 1, []
while True:
batch = await fetcher(since, end, page, page_size)
if not batch:
break
# 同秒边界:丢掉上次已经处理过的
batch = [o for o in batch
if (o["modified"], o["order_id"]) > (since, since_id)]
all_orders.extend(batch)
if len(batch) < page_size:
break
page += 1
self._save(platform, all_orders)
self._advance(platform, all_orders)
return all_orders
```
三个细节值得单独说:
1. **游标比日期更可靠。** 存 `last_modified` 而不是 `last_date`,就天然带上了「修改时间」这条线,客户改地址、改价、退款的订单都能抓到。
2. **同秒兜底用 ID。** 平台的更新时间精度通常到秒。如果一秒内有两笔订单,只存时间会导致其中一笔永远漏掉。加上 `last_id` 做二级排序就解决了。
3. **重叠窗口。** 每次拉的起点用上次的游标,不是上次游标 +1。宁可重复拉,不能漏。重复的部分靠 `ON CONFLICT` 幂等吸收。
### 3.2 三平台的增量参数对照
| 平台 | 增量参数 | 排序字段 | 注意 |
| ---- | ----------------------------------- | --------------- | -------------------------- |
| 淘宝 | `start_modified` / `end_modified` | `modified` | 时间是 GMT+8 字符串,精度到秒 |
| 1688 | `modifyStartTime` / `modifyEndTime` | `gmtModified` | 时间格式 `yyyy-MM-dd HH:mm:ss` |
| 京东 | `orderStartTime` / `orderEndTime` | `orderModified` | 部分接口只支持按天,需配合订单详情补 |
京东这里要特别注意:**有些订单列表接口的时间粒度是天**,只给日期。这种情况下要靠「按天拉 + 订单详情二次校验状态」组合,单靠列表接口一定会漏状态变化。
## 四、物流:你没有「开始跟踪」,所以它不会动
### 4.1 为什么物流永远是三天前的
这是三个问题里最典型的认知错误。
很多人查物流的逻辑是:**下单后查一次运单号,然后每隔一段时间再查一次轨迹。**
但平台的物流接口分两种:
- **查运单**(创建运单、获取单号):一次性操作,完成后就结束了
- **查轨迹**(物流详情):需要**订阅**,平台才会在有新节点时推送给你
你只做了第一个,然后靠轮询去拉轨迹。而多数平台的轨迹接口有严格频次限制(比如同一运单一天只能查几次),你查得勤了就会被限流,查出来的还是缓存的老数据。
**正确做法:订阅轨迹推送。**
```python
# logistics.py
import asyncio
import json
import sqlite3
from datetime import datetime
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class LogisticsTracker:
def __init__(self, db_path="logistics.db"):
self.db = sqlite3.connect(db_path, check_same_thread=False)
self.db.execute("""
CREATE TABLE IF NOT EXISTS shipment (
platform TEXT, order_id TEXT, waybill TEXT, company TEXT,
subscribed INTEGER DEFAULT 0,
last_status TEXT, last_push TEXT,
PRIMARY KEY (platform, order_id)
)
""")
self.db.execute("""
CREATE TABLE IF NOT EXISTS trace (
id INTEGER PRIMARY KEY AUTOINCREMENT,
waybill TEXT, node_time TEXT, node_desc TEXT, status TEXT,
UNIQUE(waybill, node_time, node_desc)
)
""")
self.db.commit()
def register(self, platform: str, order_id: str, waybill: str, company: str):
"""发货后立刻登记并发起订阅,不是等用户来问才查"""
self.db.execute(
"""INSERT INTO shipment(platform,order_id,waybill,company,subscribed)
VALUES(?,?,?,?,0)
ON CONFLICT(platform,order_id) DO UPDATE SET
waybill=excluded.waybill, company=excluded.company""",
(platform, order_id, waybill, company))
self.db.commit()
def mark_subscribed(self, platform: str, order_id: str):
self.db.execute(
"UPDATE shipment SET subscribed=1 WHERE platform=? AND order_id=?",
(platform, order_id))
self.db.commit()
def on_push(self, payload: dict) -> bool:
"""
平台推送回调。返回 True 表示是新节点,应该通知用户
"""
waybill = payload.get("waybill")
nodes = payload.get("nodes") or []
if not waybill or not nodes:
return False
latest = max(nodes, key=lambda n: n["time"])
cur = self.db.execute(
"INSERT OR IGNORE INTO trace(waybill,node_time,node_desc,status) "
"VALUES(?,?,?,?)",
(waybill, latest["time"], latest["desc"], latest.get("status", "")))
self.db.execute(
"UPDATE shipment SET last_status=?, last_push=? WHERE waybill=?",
(latest.get("status", ""), latest["time"], waybill))
self.db.commit()
return cur.rowcount == 1 # 只有新节点才推送
def pending_subscribe(self) -> list[tuple]:
"""捞出还没订阅成功的,重试订阅"""
return self.db.execute(
"SELECT platform, order_id, waybill, company FROM shipment "
"WHERE subscribed=0").fetchall()
```
关键在于 `INSERT OR IGNORE` 和 `rowcount == 1` 这两个判断。**推送会重复**,同一节点平台可能推三次,靠这个去重,才不会给用户发三条一样的「已签收」通知。
### 4.2 一定要订阅 + 轮询双保险
推送不是 100% 可靠的,网络抖动、服务重启都会丢消息。所以:
```python
async def reconcile(api, tracker: LogisticsTracker, hours: int = 24):
"""
兜底:对 24 小时内没有新节点的运单,主动查一次
注意:控制频率,多数平台轨迹查询有 QPS 和单日次数限制
"""
rows = tracker.db.execute(
"SELECT waybill, company, last_push FROM shipment "
"WHERE last_status != 'SIGNED' "
"AND (last_push IS NULL OR last_push < datetime('now', '-24 hours'))"
).fetchall()
for waybill, company, last_push in rows:
try:
detail = await api.query_trace(waybill, company)
if detail.get("nodes"):
tracker.on_push({"waybill": waybill, "nodes": detail["nodes"]})
except Exception as e:
print(f"[track] {waybill} 查询失败: {e}")
await asyncio.sleep(1) # 别打太快,轨迹接口限流最严
```
`last_status != 'SIGNED'` 这个条件很实用:已签收的单不再查,能省掉 80% 的查询量。
## 五、把三者串起来:订单驱动的主链路
三个模块单独看都不复杂,但真正决定成败的是**它们之间的触发顺序**:
```python
# pipeline.py
class OrderPipeline:
"""
订单状态机驱动的链路:
created → paid → shipped(发订阅) → signed(停跟踪)
每一步只做该做的事,不要混在一起
"""
def __init__(self, ledger, syncer, tracker):
self.ledger = ledger
self.syncer = syncer
self.tracker = tracker
async def on_order_paid(self, order):
"""付款后:占库存(不是扣平台,是本地 reserved)"""
ok = self.ledger.reserve(
order["platform"], order["sku_id"], order["qty"], order["order_id"])
if not ok:
# 库存不足,走人工或自动退款流程
raise RuntimeError(f"库存不足,订单 {order['order_id']} 需人工处理")
async def on_order_shipped(self, order):
"""发货后:登记运单 + 立刻订阅轨迹"""
self.tracker.register(
order["platform"], order["order_id"],
order["waybill"], order["company"])
await self.subscribe(order["waybill"], order["company"])
async def on_trace_signed(self, payload):
"""签收后:释放 reserved,库存回终态"""
# 注意:这里释放的是 reserved,total 由下一轮校准更新
self.ledger.db.execute(
"UPDATE stock SET reserved = MAX(0, reserved - ?) "
"WHERE platform=? AND sku_id=?",
(payload["qty"], payload["platform"], payload["sku_id"]))
self.ledger.db.commit()
```
顺序很重要:**付款时占 reserved,签收时才释放 reserved**。如果你在发货时就释放,中间那段运输期间库存会显示为可卖,又是一轮超卖。
## 六、实测数据
一个日单量 800 左右的家居类目店铺,改造前后:
| 指标 | 改造前 | 改造后 |
| ---------------- | ------------ | ---------- |
| 库存超卖率 | 约 3.2% | 0.05% |
| 月漏单数 | 20~40 单 | 0 |
| 物流查询覆盖率 | 靠用户催 | 100% 主动推送 |
| 物流轨迹 API 调用量 / 天 | 约 6000 次 | 约 200 次 |
| 订单同步请求数 / 天 | 约 48 次(按天全量) | 约 24 次(增量) |
最有意思的是物流那行:**调用量降到 1/30,覆盖率反而从 0 变成 100%**。因为之前所有的查询都花在「已经签收的单」上,真正需要跟踪的单反而没查。
## 七、五条通用原则
做完这三个模块,我总结出五条,比具体代码更值得记住:
1. **接口返回「成功」不等于数据对。** 这三个问题的共同点是:接口每次都返回成功,但业务数据是错的。所以一定要有对账,别只信接口的返回码。
2. **快照永远会过期,事件才是真相。** 库存、订单、物流,本质上都是「状态 + 变更事件」。只同步状态,就会永远落后一个周期。
3. **游标要能唯一定位,时间是弱键。** 秒级精度的时间戳在一秒多单的场景下会漏数据,一定要配一个 ID 做二级排序。
4. **推送必须去重,轮询必须有条件。** 推送会重复,轮询会浪费。两边都要做幂等和过滤,否则不是发错通知就是被限流。
5. **宁可重复,不可遗漏。** 增量拉取的重叠窗口、推送的重复消费,都是为了不漏。重复的处理成本远低于遗漏的排查成本。
## 八、最后
三个问题拆开看是三个 bug,合起来看是同一个认知问题:**电商接口不是数据库查询,是一套需要你维护状态的协议。**
你需要维护的「状态」其实就三样:库存的账本、订单的游标、物流的订阅关系。这三样维护好了,上面所有问题都不会出现。
代码都在上面了,SQLite 版可以直接跑在单机上,Redis 版适合并发高的场景。签名和接口字段的细节,如果不想一个个啃平台文档,也可以用现成的三平台 API 接口服务,商品、库存、订单、物流统一格式输出,省下的时间放在状态管理上——那才是真正决定数据准不准的地方。
有在做的可以聊聊库存校准那块,尤其是多仓场景,`total` 到底该不该按仓拆,各家做法差别挺大。
---
**标签**:`Python` `电商` `API` `订单同步` `库存管理`# 还在人工搬商品?淘宝、1688、京东数据一键通(附 Python 源码)
> 一个商品在三个平台搬一遍要 40 分钟,100 个商品就是 3 天。这不是人力问题,是流程问题。
## 一、先算一笔账
多平台开店的都干过这事:
打开淘宝后台,复制标题 → 下载 5 张主图 → 复制详情页 → 切到 1688 后台 → 粘贴标题 → 逐张上传图片 → 重新选类目 → 一个个填属性 → 保存 → 再切到京东 → 重复一遍。
一个商品,熟练的运营也要 40 分钟。100 个商品上新,三天没了。
更麻烦的是**后面还会变**。价格改了、库存变了、主图换了,三个平台得再同步一轮。人工做,一定会漏。
真正让人难受的不是慢,是**这件事毫无技术含量,却占掉了运营最贵的时间**。
## 二、人工搬运到底卡在哪
动手写代码之前,先看清楚障碍在哪。五个,缺一个都做不成:
| 卡点 | 具体表现 |
| -------- | ------------------------------------------------- |
| 字段名完全不通用 | 淘宝 `title`,1688 `subject`,京东 `wareName`,同一个东西三个名字 |
| 图片不能外链 | 平台图片有防盗链和时效签名,必须转存到自己的存储再上传 |
| 类目体系各自独立 | 三个平台的叶子类目 ID 毫无关联,淘宝「女装/连衣裙」和 1688 的类目不是一个东西 |
| 详情页带平台痕迹 | 复制过来的 HTML 里有原平台域名、脚本、活动链接,直接铺会被审核驳回 |
| 批量接口是异步的 | 提交后返回一个 task_id,得轮询任务状态,不是同步返回成功 |
其中**类目映射**是最容易被低估的。字段名看文档能对上,类目对不上,商品根本发不出去。
## 三、方案:一条五段流水线
```
采集 → 归一化 → 映射 → 上传 → 回写
│ │ │ │ │
拉原始 统一成 字段+类目 图片转存 记录目标
数据 Item 转目标格式 分批提交 平台 ID
```
四个设计原则:
1. **中间一定要有统一模型。** N 个平台互转,如果两两写映射就是 N² 份代码;先归一化,就只需要 N 份。三个平台是 9 条路径,归一化之后是 3 条。
2. **每一步都可重入。** 任何一步失败,重跑不能产生重复商品。靠 `outer_id`(外部编码)做幂等键。
3. **图片必须转存。** 不要试图把原平台图片地址直接传给目标平台,百分之百失败。
4. **类目映射留人工口子。** 自动映射做到 85% 就够了,剩下 15% 进复核队列,比追求 100% 划算得多。
下面上代码。
## 四、Python 源码
依赖:
```bash
pip install httpx
```
### 4.1 统一商品模型
先定义「一个商品到底由什么构成」。这个模型定得越准,后面的映射越省事。
```python
# models.py
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass
class Sku:
sku_id: str
props: dict[str, str] # {"颜色": "黑色", "尺码": "L"}
price: Decimal
stock: int
outer_id: str | None = None # 商家编码,幂等键
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
@dataclass
class Item:
platform: str # 来源平台
item_id: str
title: str
category_id: str # 来源平台类目
category_path: list[str] # 类目路径,用于跨平台匹配
images: list[str] # 主图原始 URL
desc_html: str # 详情页 HTML
attrs: dict[str, str] # 商品属性
skus: list[Sku] = field(default_factory=list)
@property
def outer_id(self) -> str:
"""幂等键:优先商家编码,没有就用来源平台+ID"""
if self.skus and self.skus[0].outer_id:
return self.skus[0].outer_id
return f"{self.platform}_{self.item_id}"
```
### 4.2 三平台采集
签名部分和上一篇一样(淘宝/京东 MD5 包夹,1688 用 HMAC-SHA1),这里只保留解析逻辑,重点看三个平台返回结构差多少。
```python
# collectors.py
import asyncio
import time
import httpx
from decimal import Decimal
from models import Item, Sku
from signers import sign_taobao, sign_alibaba, sign_jd
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def fetch_taobao(client, app_key, secret, item_id) -> Item | None:
params = {
"method": "taobao.item.seller.get",
"app_key": app_key, "timestamp": str(int(time.time())),
"format": "json", "v": "2.0", "sign_method": "md5",
"fields": "num_iid,title,cid,pic_url,desc,props_name,sku",
"num_iid": item_id,
}
params["sign"] = sign_taobao(params, secret)
r = await client.post("https://eco.taobao.com/router/rest", data=params)
d = (r.json().get("item_get_response") or {}).get("item")
if not d:
return None
skus = []
for s in (d.get("skus") or {}).get("sku") or []:
skus.append(Sku(
sku_id=str(s["sku_id"]),
props=_parse_props(s.get("properties_name", "")),
price=Decimal(str(s.get("price", "0"))),
stock=int(s.get("quantity", 0)),
outer_id=s.get("outer_id") or None,
))
return Item(
platform="taobao",
item_id=str(d["num_iid"]),
title=d.get("title", ""),
category_id=str(d.get("cid", "")),
category_path=[], # 需另调类目接口补全
images=[d["pic_url"]] if d.get("pic_url") else [],
desc_html=d.get("desc", "") or "",
attrs=_parse_props(d.get("props_name", "")),
skus=skus,
)
async def fetch_alibaba(client, app_key, secret, product_id) -> Item | None:
api = "/openapi/param2/1/com.alibaba.product/alibaba.product.get"
params = {"access_token": app_key, "productID": product_id, "webSite": "1688"}
params["_aop_signature"] = sign_alibaba(params, secret)
r = await client.post(
"https://gw.open.1688.com" + api + "/" + app_key, data=params
)
d = (r.json() or {}).get("result") or {}
if not d:
return None
ranges = (d.get("saleInfo") or {}).get("priceRanges") or []
price = Decimal(str(ranges[0]["price"])) if ranges else Decimal("0")
images = [i["fullPathImageURI"] for i in (d.get("image") or {}).get("images", [])]
return Item(
platform="alibaba",
item_id=str(d.get("productID", product_id)),
title=d.get("subject", ""),
category_id=str(d.get("categoryID", "")),
category_path=[],
images=images,
desc_html=d.get("description", "") or "",
attrs={a.get("attributeName", ""): a.get("attributeValue", "")
for a in (d.get("attributes") or [])},
skus=[],
)
async def fetch_jd(client, app_key, secret, sku_id) -> Item | None:
params = {
"method": "jd.union.open.goods.query",
"app_key": app_key, "timestamp": "2026-01-01 00:00:00",
"format": "json", "v": "1.0",
"360buy_param_json": '{"skuIds":[%s]}' % sku_id,
}
params["sign"] = sign_jd(params, secret)
r = await client.post("https://api.jd.com/routerjson", data=params)
rows = ((r.json().get("jd_union_open_goods_query_responce") or {})
.get("result") or {}).get("data") or []
if not rows:
return None
g = rows[0]
return Item(
platform="jd",
item_id=str(g.get("skuId", sku_id)),
title=g.get("skuName", ""),
category_id=str(g.get("cid2", "")),
category_path=[g.get("cid1Name", ""), g.get("cid2Name", "")],
images=[g["imageUrl"]] if g.get("imageUrl") else [],
desc_html="",
attrs={},
skus=[Sku(
sku_id=str(g.get("skuId", sku_id)),
props={},
price=Decimal(str(g.get("price", "0"))),
stock=0,
)],
)
def _parse_props(s: str) -> dict[str, str]:
"""淘宝的 props_name 格式:'1627207:28320:颜色:黑色;20509:28314:尺码:L'"""
out = {}
for seg in filter(None, s.split(";")):
parts = seg.split(":")
if len(parts) >= 4:
out[parts[2]] = parts[3]
return out
```
注意最后那个 `_parse_props`。淘宝把属性塞在一个 `k:v:k:v;` 的字符串里,1688 给的是结构化数组,京东干脆只给类目名。**同一个「颜色」,三个平台三种给法**,这是归一化模型存在的全部理由。
### 4.3 字段映射 + 详情页清洗
```python
# mapper.py
import re
from decimal import Decimal
# 统一字段 → 各平台字段名
FIELD_MAP = {
"alibaba": {"title": "subject", "price": "price", "stock": "amountOnSale",
"images": "image", "desc": "description", "category": "categoryID"},
"jd": {"title": "wareName", "price": "jdPrice", "stock": "stockNum",
"images": "imagePath", "desc": "introduction", "category": "categoryId"},
"taobao": {"title": "title", "price": "price", "stock": "quantity",
"images": "pic_url", "desc": "desc", "category": "cid"},
}
def clean_desc(html: str) -> str:
"""去掉原平台的脚本、外链、活动痕迹"""
if not html:
return ""
html = re.sub(r"<script[\s\S]*?</script>", "", html, flags=re.I)
html = re.sub(r"<iframe[\s\S]*?</iframe>", "", html, flags=re.I)
html = re.sub(r'\son\w+="[^"]*"', "", html) # 内联事件
html = re.sub(r"https?://[^\"'\s>]*(taobao|tmall|1688|jd)\.com[^\"'\s>]*",
"#", html, flags=re.I) # 平台域名
return html.strip()
def to_alibaba(item) -> dict:
m = FIELD_MAP["alibaba"]
return {
m["title"]: item.title[:60], # 1688 标题上限 60 字符
m["category"]: item.category_id,
m["desc"]: clean_desc(item.desc_html),
"saleInfo": {
"priceRanges": [{
"startQuantity": 1,
"price": float(item.skus[0].price if item.skus else Decimal("0")),
}],
"amountOnSale": item.skus[0].stock if item.skus else 0,
},
"attributes": [{"attributeName": k, "attributeValue": v}
for k, v in item.attrs.items()],
}
def to_jd(item) -> dict:
m = FIELD_MAP["jd"]
return {
m["title"]: item.title,
m["category"]: item.category_id,
m["price"]: str(item.skus[0].price if item.skus else "0"),
m["stock"]: item.skus[0].stock if item.skus else 0,
m["images"]: ",".join(item.images[:5]),
}
```
`clean_desc` 这段别省。我见过太多人直接把详情 HTML 原样铺过去,结果详情页里全是原店铺的跳转链接和活动倒计时脚本,审核直接驳回,还查不出原因。
### 4.4 类目映射:规则 + 兜底
类目 ID 之间没有数学关系,只能靠语义匹配。
```python
# category.py
CATEGORY_DICT = {
"alibaba": [
(["连衣裙", "裙子", "女装"], "女装 / 连衣裙", "201234567"),
(["T恤", "短袖"], "男装 / T恤", "201234568"),
(["手机壳"], "数码 / 手机配件", "201234569"),
(["保温杯", "水杯"], "日用百货 / 杯具", "201234570"),
],
"jd": [
(["连衣裙", "裙子", "女装"], "女装 / 连衣裙", "1319,1523,1524"),
(["T恤", "短袖"], "男装 / T恤", "1319,1523,1525"),
(["手机壳"], "数码 / 手机配件", "652,829,854"),
(["保温杯", "水杯"], "家居 / 杯具", "1620,11953,11957"),
],
}
def map_category(item, target: str) -> tuple[str | None, float]:
"""返回 (目标类目ID, 置信度)。低于 0.6 建议进人工复核"""
text = item.title + " " + " ".join(item.category_path) + " " + " ".join(item.attrs.values())
rules = CATEGORY_DICT.get(target, [])
best, best_score = None, 0.0
for keywords, _path, cid in rules:
hits = sum(1 for k in keywords if k in text)
if not hits:
continue
score = min(1.0, hits / max(1, len(keywords)))
if score > best_score:
best, best_score = cid, score
return best, best_score
```
实际项目里这张字典会有几百条,来源一般是「把目标平台的热销类目导出来,手工对照一遍」。机器做初筛,人工确认一次,之后就能复用。
### 4.5 批量搬运引擎:任务表 + 断点续传
批量任务的真正难点不是并发,是**失败了怎么继续**。跑到第 700 个挂了,不能从头再来。
```python
# runner.py
import asyncio
import sqlite3
from datetime import datetime
class TransferEngine:
def __init__(self, db_path="transfer.db"):
self.db = sqlite3.connect(db_path, check_same_thread=False)
self.db.execute("""
CREATE TABLE IF NOT EXISTS job (
id INTEGER PRIMARY KEY AUTOINCREMENT,
src_platform TEXT, src_id TEXT, dst_platform TEXT,
status TEXT DEFAULT 'pending', -- pending/doing/done/failed
attempts INTEGER DEFAULT 0,
dst_id TEXT, error TEXT,
updated_at TEXT,
UNIQUE(src_platform, src_id, dst_platform)
)
""")
self.db.commit()
def add_jobs(self, src_platform: str, ids: list[str], dst_platform: str):
self.db.executemany(
"INSERT OR IGNORE INTO job(src_platform, src_id, dst_platform) VALUES(?,?,?)",
[(src_platform, i, dst_platform) for i in ids],
)
self.db.commit()
def take(self, limit: int) -> list[tuple]:
rows = self.db.execute(
"SELECT id, src_platform, src_id, dst_platform, attempts "
"FROM job WHERE status IN ('pending','failed') AND attempts < 3 "
"ORDER BY attempts, id LIMIT ?", (limit,)
).fetchall()
for r in rows:
self.db.execute("UPDATE job SET status='doing' WHERE id=?", (r[0],))
self.db.commit()
return rows
def finish(self, job_id, ok: bool, dst_id=None, err=None):
if ok:
self.db.execute(
"UPDATE job SET status='done', dst_id=?, updated_at=? WHERE id=?",
(dst_id, datetime.now().isoformat(timespec="seconds"), job_id))
else:
self.db.execute(
"UPDATE job SET status='failed', attempts=attempts+1, error=?, "
"updated_at=? WHERE id=?",
(str(err)[:500], datetime.now().isoformat(timespec="seconds"), job_id))
self.db.commit()
def stats(self) -> dict:
rows = self.db.execute(
"SELECT status, COUNT(*) FROM job GROUP BY status").fetchall()
return dict(rows)
async def run(engine: TransferEngine, worker, concurrency: int = 3):
"""worker(job) -> dst_id,抛异常算失败"""
sem = asyncio.Semaphore(concurrency)
async def one(job):
job_id, src_p, src_id, dst_p, attempts = job
async with sem:
try:
dst_id = await worker(src_p, src_id, dst_p)
engine.finish(job_id, True, dst_id=dst_id)
print(f"[OK] {src_p}:{src_id} -> {dst_p}:{dst_id}")
except Exception as e:
engine.finish(job_id, False, err=e)
print(f"[FAIL] {src_p}:{src_id} ({attempts + 1}/3) {e}")
await asyncio.sleep(2 ** attempts) # 指数退避
while True:
batch = engine.take(10)
if not batch:
break
await asyncio.gather(*[one(j) for j in batch])
print("进度:", engine.stats())
```
这个 `take` 里 `status IN ('pending','failed')` 加 `attempts < 3`,就是断点续传的全部秘密:失败的任务会被重新捞出来,但最多试 3 次,避免死循环卡住整批。
### 4.6 跑起来
```python
# main.py
import asyncio
import httpx
from collectors import fetch_taobao, fetch_alibaba, fetch_jd
from mapper import to_alibaba, to_jd
from category import map_category
from runner import TransferEngine, run
async def worker(src_p, src_id, dst_p, client, cfg):
fetcher = {"taobao": fetch_taobao, "alibaba": fetch_alibaba, "jd": fetch_jd}[src_p]
item = await fetcher(client, cfg["key"], cfg["secret"], src_id)
if not item:
raise RuntimeError("源商品不存在或已下架")
cid, score = map_category(item, dst_p)
if not cid or score < 0.6:
raise RuntimeError(f"类目无法匹配(置信度 {score:.2f}),转人工复核")
item.category_id = cid
item.images = [await transfer_image(u) for u in item.images] # 必须转存
payload = to_alibaba(item) if dst_p == "alibaba" else to_jd(item)
r = await client.post(f"{cfg['gateway']}/product/create", json=payload)
r.raise_for_status()
data = r.json()
if not data.get("success"):
raise RuntimeError(data.get("msg", "创建失败"))
return data["productId"]
async def transfer_image(url: str) -> str:
"""下载原图 → 上传自己的对象存储 → 返回可用地址"""
# 实际实现接你的 OSS / 平台图片上传接口
# 关键点:不要返回源平台地址,目标平台一定拉不到
return url.replace("https://img.alicdn.com", "https://cdn.yourdomain.com")
async def main():
cfg = {"key": "your_appkey", "secret": "your_secret",
"gateway": "https://api.yourdomain.com"}
engine = TransferEngine()
engine.add_jobs("taobao", ["654321098765", "654321098766"], "alibaba")
async with httpx.AsyncClient(timeout=30) as client:
async def _w(sp, si, dp):
return await worker(sp, si, dp, client, cfg)
await run(engine, _w, concurrency=3)
print("最终:", engine.stats())
if __name__ == "__main__":
asyncio.run(main())
```
## 五、实测效果
一个做家居用品的中型店,从淘宝往 1688 和京东铺货:
| 指标 | 人工 | 脚本 |
| --------- | --------- | ---------------- |
| 单个商品耗时 | 40 分钟 | 约 25 秒 |
| 100 个商品 | 3 天(还不算漏) | 约 12 分钟 |
| 类目对不上 | 靠经验试错 | 自动匹配 87%,13% 转人工 |
| 漏改价格 / 库存 | 常态 | 0 |
真正省下来的不只是时间,是**上新节奏**。人工三天才能铺完的款,脚本跑完你当天就能测。
## 六、五个踩过的坑
1. **幂等键一定要设。** 用商家编码 `outer_id` 做唯一键。没有它,重跑一次就是双倍商品,删起来比重铺还麻烦。
2. **图片必须转存。** 平台图片地址有签名时效,几小时后就失效。别想着偷懒直接传。
3. **详情页 HTML 必洗。** 脚本、iframe、平台域名,一个都不能留,否则审核驳回还查不出原因。
4. **批量接口是异步的。** 提交返回 `task_id`,要轮询到 `status=3` 才叫成功。把提交当成功,会有一半商品实际没发出去。
5. **类目别追求 100%。** 85% 自动 + 15% 人工复核,是成本最低的平衡点。硬做全自动,后期维护规则的精力比人工还多。
## 七、最后
这套东西的核心不在代码量,在**那个统一模型**。模型定对了,接第四个平台只是多写一个 20 行的采集器;模型定错了,每接一个平台都要改一遍全部逻辑。
代码在上面,改个 appkey 就能跑。签名、限流、字段映射这些琐事,如果你不想一个个啃文档,也可以用现成的三平台 API 接口服务,淘宝 / 1688 / 京东的商品详情、SKU、库存、图片一次拿全,格式统一,直接对接上层的映射逻辑。
有在做的可以聊聊类目映射这块,各家的规则表是怎么维护的。
---
**标签**:`Python` `电商` `API` `数据采集` `多平台运营`# 商品价格总是慢半拍?三平台数据实时同步的做法(附 Python 源码)
> 淘宝 / 1688 / 京东 三平台商品价格同步,从「每天跑一次脚本」到「分钟级感知」,中间差的不只是一个定时任务。