用接口接单:别再轮询那个不会把订单锁给你的端点
开两个进程去拉待处理订单,两个都会拿到同一张单,因为读取端点不改任何状态。真正把单据锁给你的是另一个端点,另外还有一把要你自己去上的锁,和两条没写在文档里的限流。
Sarah Johnson用接口接手工订单,第一个要定的是拿哪个端点去收活,而这一步恰好是多数对接写错的地方。GET /orders 是一次读取,谁问都返回符合条件的订单,不改任何状态。你跑两个 worker,或者一个 worker 加一次重试,两边会拿到同一张单,然后两边都去交付。
会把订单锁给你的是 POST /orders-pull。它先挑出你名下待处理的手工订单,再用一次比较并交换把每一张从 pending 翻成 processing,最后只把翻成功的那些返回给你。第二个 worker 哪怕晚一毫秒,比较失败,那张单就不在它的响应里。这个端点的源码注释里写着它兼容 PerfectPanel 的格式,如果你手上已经有一套面板对接,形状是对得上的。
一轮循环的正确顺序
- 抢单。
POST /orders-pull。请求体里可选limit,默认 100,上限硬顶在 500,先给最早的单。 - 上租约,如果你处理得慢。
PATCH /orders/{id}/last-process-time,不带请求体就是打上当前时间,这张单在你自己接下来的轮询里会消失三十分钟。 - 交付。
POST /orders-update带上账号行,量大的走POST /orders/{id}/accounts-bulk。 - 收尾。同一个
orders-update调用里带status: "completed",只交付了一部分就写"partial"。
鉴权是每次请求都要带的一个头,两种写法都收:
curl -X POST "https://api.hstockplus.com/api/admin/v2/orders-pull" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"limit": 50}'
# 等价写法
curl -X POST "https://api.hstockplus.com/api/admin/v2/orders-pull" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"limit": 50}'
密钥在后台的 API 设置页生成。完全没带头返回的是 API_KEY_REQUIRED,带了但是认不出来返回 INVALID_API_KEY。这是两个不同的码,对应两种完全不同的 bug,别在代码里合并处理。
那三十分钟是一把租约,而且要你自己去上
两个列表端点都会跳过 lastProcessTime 落在最近三十分钟内的订单。听上去像是系统自动帮你去重,其实不是,因为这两个端点谁都不写这个字段。全站只有一个地方写它,就是 PATCH /orders/{id}/last-process-time,也就是你自己。
所以这是一把协作式的锁。你把单据拿走的时候盖一下,它就在接下来半小时里从你的轮询结果中消失,这正是交付要花点时间而你的轮询每分钟跑一次时想要的效果。想设别的到期时间就传一个 ISO 时间戳,想立刻释放就传 null。完全不调这个端点的话,这个窗口对你就不存在。
两把锁挡的是两件事,别混:orders-pull 的状态翻转挡的是两个进程抢同一张单;这把租约挡的是同一个进程在还没做完的时候又把自己的单捞了一遍。
读取订单,以及两个会让人栽跟头的默认值
对账、补数据、做看板还是要用 GET /orders。在它上面写东西之前,有两个默认行为要先知道。
- 支付状态默认是两个值,不是一个。不传
paymentStatus的时候,你拿到的是completed或者partial的订单。partial 指的是部分退款,那些单子仍然是要干的活。这个参数本身接受逗号分隔的列表,可选值是 pending、completed、failed、refunded、processing、partial。 - 商品类型是选填的,而且可以多选。它不是拉手工订单的必要条件。可选值
manual、inventory、auto,逗号分隔,所以productType=manual,auto是合法的。
订单状态那个筛选器接受 pending、processing、completed、refunded、error。partial 不在里面,它是支付状态不是订单状态,这两个字段名字像但不是一回事。其余筛选项:entityType 取 product 或 smm_service,subCategory 按名称,createdFrom 和 createdTo 用 ISO 时间,limit 最大 500,再加 offset。
curl -X GET "https://api.hstockplus.com/api/admin/v2/orders\
?status=pending,processing&productType=manual&paymentStatus=completed,partial&limit=50" \
-H "X-Api-Key: YOUR_API_KEY"
你提交的账号行,在入库之前会被动三次
接口上的工单大部分出在这里,因为这三步都在存储之前跑完,而响应里一步都看不出来。
- 每个数组元素都按换行拆开。一个元素可以装下一整个文件。
["a:1\nb:2\nc:3"]和["a:1","b:2","c:3"]提交的是同一批东西。空行和首尾空白会被去掉。竖线不拆,它被当作凭据格式的一部分。 - 重复的会被丢掉,不区分大小写。批次内部去一遍,再和这张单上已经交付过的比一遍。所以超时之后原样重发是安全的,不会重复交付任何一行。
- 超出订单数量的部分被静默丢弃。系统按剩余的名额截断,不报错。所以数量要自己数准。
这里要专门说一句给习惯了自动发货那一套的人:平台不解析你这一行的内容。它就是一个字符串,原样存,原样交给买家。冒号分隔的两段、竖线分隔的三段带一个两步验证密钥、一个 token、一整段会话数据,在系统看来没有区别。也就是说,你商品描述里承诺的是什么格式,买家就会按什么格式去用,而这件事没有任何机制替你校验。格式写清楚,并且真的照着发,是你自己的责任。
正因为和历史交付比对之后重复项是丢弃而不是报错,分几次交付是被支持的做法:先发手上有的,标 partial,晚点补上剩下的,再标 completed。
两条限流,以及唯一正确的退避方式
orders-update 上有两道闸:同一个密钥的最小调用间隔,默认 200 毫秒;以及同一个用户同时在途的请求数上限,默认 2 个。撞上任何一道都是 HTTP 429,而且响应体里直接给了该等多久:
{ "error": "RATE_LIMIT", "message": "Too many order update requests",
"retry_after_ms": 137 }
{ "error": "CONCURRENCY_LIMIT", "message": "Too many concurrent sync requests",
"retry_after_ms": 5000 }
读 retry_after_ms 然后睡这么久。写死一个退避时间的话,对 200 毫秒那条太慢,对并发那条又太快,两边都不对。另外还有一把按订单的互斥锁:两次交付撞在同一张单上会返回 ORDER_DELIVERY_BUSY,同样是 429,正确反应是稍等重试同一个请求,而不是换一批凭据再发一次。
值得分开处理的错误码
API_KEY_REQUIRED和INVALID_API_KEY:前者是没带头,后者是带了但认不出来。ORDER_NOT_FOUND:缓存里没有这个订单号。注意刚创建的自动类订单对普通供应商有一小段可见性延迟,所以创建后几秒内的 404 未必是永久的。ACCESS_DENIED:这张单上没有一行是属于你的。PAYMENT_NOT_COMPLETED:未付款或者已全额退款。交付不进去,也不该重试。INVALID_ACCOUNTS,消息是 No valid accounts provided:你提交的数组拍平之后是空的,通常是传了空字符串或者非字符串元素。走 accounts-bulk 时另有一句 Provide accounts[] or accountsText,那是两个入参一个都没给。RATE_LIMIT、CONCURRENCY_LIMIT、ORDER_DELIVERY_BUSY:退避之后原样重发。
两个留给你自己记账的字段
orders-update 接受 external_id 存你自己那边的单号,如果你的面板本来就发 supplierOrderId,这个名字也收,是同一个东西的别名。还接受 external_price,一个数字,记这张单在上游花了多少。这个值只作参考,不进任何平台统计。传 null 或者空字符串会保留原值,所以反复更新不会把它抹掉。
手工不等于慢,数字在这里
商品类型里的手工,说的是货由供应商在订单来了之后上传,不是一个交付速度设置。这一点数据比解释更有说服力。
全站已完成的手工商品订单一共 6,649 笔,从付款到完成的中位耗时不到三十秒,百分之七十六在十分钟内结束,大约四十三笔里有一笔超过一天。库存型几乎是瞬时的,因为货本来就躺在那里;自动型的中位数在半分钟出头。
手工之所以看着这么快,原因恰恰是:在手工商品上跑量的那几家,都在用这套接口短间隔轮询,而不是盯着后台页面。所以如果你的对接把交付时间拖到了分钟级,你是那条长尾,不是常态。
还有一个和账号商品完全不同的时钟,如果你卖的是增长服务:那类订单从交付起算固定三天售后窗口,和挂单时写了什么无关。账号类商品是另一套,质保按件由你自己设,而且是从交付起算不是从付款起算,所以一张在处理中停了一天的订单,售后窗口一分钟都没少。
一个不会自己惹事的 worker
const BASE = 'https://api.hstockplus.com/api/admin/v2';
const HEAD = { 'X-Api-Key': process.env.API_KEY, 'Content-Type': 'application/json' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function call(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: HEAD,
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json().catch(() => ({}));
if (res.status === 429) {
await sleep(json.retry_after_ms ?? 1000); // 读服务端给的值,别写死
return call(method, path, body);
}
if (!res.ok) throw Object.assign(new Error(json.message || res.status), { code: json.error });
return json;
}
async function tick() {
const { orders = [] } = await call('POST', '/orders-pull', { limit: 50 });
for (const order of orders) {
// 到这里订单已经是 processing 并且归你了。处理慢就先上租约。
await call('PATCH', '/orders/' + order.id + '/last-process-time', {});
const accounts = await prepareCredentials(order); // 你自己那一套
await call('POST', '/orders-update', {
order: order.id,
status: accounts.length >= order.quantity ? 'completed' : 'partial',
accounts,
supplierOrderId: 'SUP-' + order.id,
});
}
}
这段循环比常见写法多做了三件事:它抢而不是读、它按服务端给的时长退避而不是自己猜、以及交付不全的时候它老实报 partial 而不是硬把单子关掉。
完整的端点清单,包括建商品、工单、退款和批量账号任务,在供应商接口页。还在纠结一个商品该挂成手工、库存还是自动的,那个选择写在商品管理那一篇里。



