接口返回了 201 Created,响应里写着 "status": "queued"。代码继续往下走,把订单标记为"已打印",告诉客户面单已经在路上了。
只不过——仓库那台打印机的标签纸早就用完了。
这是打印类集成里最常见的一个 bug:把"接口收下了任务"当成"文档已经到了人手里"。打印是物理动作,物理动作的失败发生在 HTTP 响应很久之后。想做对,先要理解任务的状态机,再决定怎么获知它的终点。
任务状态机
一个云打印任务走完四个状态才算结束:
queued -> dispatched -> printing -> completed
\
-> failed- queued — 接口收下并存储了任务,这就是你
POST拿到的状态。 - dispatched — 任务已经推送给打印机旁边那台机器上的 Agent。
- printing — Agent 把字节交给了本地打印系统。
- completed / failed — 终态。只有这两个才代表事情有了结论。
从 queued 到终态之间,才是现实发生的地方:Agent 所在的电脑可能睡眠了,打印机可能离线,content_url 指向的 PDF 可能 404,ZPL 可能写错了。这些都会产生一个带具体原因的 failed 任务,而它们在你早就拿到的那个响应里全都看不见。
方案一:轮询(能用,但有边界)
最直接的做法是反复问:
async function waitForJob(jobId, { timeoutMs = 60_000, intervalMs = 2000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`https://api.printbase.cloud/v1/print-jobs/${jobId}`, {
headers: { Authorization: `Bearer ${process.env.PRINTBASE_API_KEY}` },
});
const job = await res.json();
if (job.status === 'completed' || job.status === 'failed') return job;
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`任务 ${jobId} 在超时前没有出结果`);
}有一种场景轮询确实是正解:人就站在那儿等,而你本来就握着一个未返回的请求——自助终端、POS 收银台、页面上的"立即打印"按钮。循环活几秒钟就结束了。
把它当架构就会出问题。打一千张面单就是一千个轮询循环,其中绝大多数问到的答案跟上一次一样。你付出的是请求量、是最多一个 intervalMs 的延迟、还有在部署和进程重启之间维持这些循环的麻烦。更糟的是,一个 60 秒就放弃的循环,完全不知道第三分钟被人插回去的打印机后来怎么样了。
方案二:Webhook(后端真正该用的)
把方向反过来:注册一个 URL,让它来通知你。PrintBase Webhook 会在任务进入终态的那一刻,POST 一条带签名的 JSON 事件。
端点只需注册一次:
curl https://api.printbase.cloud/v1/webhooks \
-H "Authorization: Bearer $PRINTBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "发货服务",
"url": "https://example.com/hooks/printbase",
"events": ["print_job.completed", "print_job.failed"]
}'返回体里带一个 secret(whsec_...),按凭据的规格存好。之后每次投递都会带这些头:
X-PrintBase-Event: print_job.failed
X-PrintBase-Event-Id: evt_abc
X-PrintBase-Signature: t=1755852761,v1=9f2c...body 会告诉你是哪台打印机、在哪台电脑上、因为什么失败:
{
"id": "evt_abc",
"type": "print_job.failed",
"created_at": "2026-08-22T09:12:41Z",
"organization_id": "org_abc",
"data": {
"job_id": "job_abc",
"printer_id": "printer_123",
"printer_name": "仓库斑马机",
"computer_id": "cmp_456",
"computer_name": "wh-pc-02",
"status": "failed",
"reason_code": "PRINTER_OFFLINE",
"message": "printer is offline"
}
}printer_name 和 computer_name 比看上去重要:凌晨两点告警响起时,"wh-pc-02 上的仓库斑马机离线了"是能处理的,"job_abc 失败了"不是。
校验签名:用原始 body
签名是 HMAC-SHA256("{时间戳}.{原始 body}", secret)。所有人都会踩的坑是:拿重新序列化过的 JSON 去校验。JSON.parse 再 JSON.stringify,字节就变了——键顺序不同、空白不同——HMAC 永远对不上。把原始 body 留住。
import express from 'express';
import crypto from 'node:crypto';
const app = express();
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(
(header ?? '').split(',').map((kv) => kv.split('=').map((s) => s.trim()))
);
if (!t || !v1) return false;
// 拒绝重放很久以前那条合法签名
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/hooks/printbase', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body.toString('utf8');
if (!verify(raw, req.get('X-PrintBase-Signature'), process.env.PRINTBASE_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
res.sendStatus(200); // 先在超时内应答……
queue.push(JSON.parse(raw)); // ……再把耗时的活挪出请求
});这个顺序不是风格问题。PrintBase 给一次投递 5 秒,失败后重试两次(5 秒、30 秒后),然后放弃。如果你在应答之前先更新订单、写队列、发邮件,迟早会超时、被重试,然后把这些事全做两遍。
由此引出真正救命的一条规则:处理函数必须幂等。动手之前先按 X-PrintBase-Event-Id 去重。
async function handleEvent(event) {
const fresh = await db.markEventSeen(event.id); // 事件 id 上建唯一索引
if (!fresh) return; // 处理过了,丢弃
...
}按原因码分类处理,别看 message
失败任务会带五个 reason_code 之一,它们对应的处理方式完全不同:
reason_code | 含义 | 该做什么 |
|---|---|---|
AGENT_OFFLINE | 该站点没有 Agent 在线 | 通知现场。此刻重试打不出任何东西 |
PRINTER_OFFLINE | Agent 在线,打印机不在 | 告警,或切到同站点的另一台打印机 |
DOWNLOAD_FAILED | Agent 拉不到 content_url | 查 URL 有效期——签名 URL 在排队期间过期是典型原因 |
PRINT_ERROR | 本地打印系统拒绝了它 | 重试一次;持续失败就是驱动或硬件问题 |
INVALID_CONTENT | 内容和声明的类型对不上 | 你的生成端有 bug。不要重试,重试结果一模一样 |
真正有用的划分是可重试与不可重试。PRINT_ERROR 和偶发的 DOWNLOAD_FAILED 值得带退避地重试一次;INVALID_CONTENT 应该在错误监控里报出来;AGENT_OFFLINE 需要一个活人。
const RETRYABLE = new Set(['PRINT_ERROR', 'DOWNLOAD_FAILED']);
if (event.type === 'print_job.failed') {
const { job_id, reason_code, printer_name, computer_name } = event.data;
if (RETRYABLE.has(reason_code)) {
await scheduleReprint(job_id, { attempt: 1 });
} else {
await alertOps(`${printer_name}(${computer_name}):${reason_code}`);
}
}Webhook 不是数据库
这是大多数集成会跳过的部分。Webhook 只是一次投递尝试,而投递是可能彻底失败的:你的服务在那 5 秒 + 5 秒 + 30 秒的窗口里正好全程不可用;一次部署掐断了连接;有人轮换了 secret。第三次尝试之后事件就没了,标记为 failed,不会再补发。
所以要留一张兜底网:定期扫一遍数据库里仍未拿到结果的任务。
// 每 10 分钟一次
const stale = await db.jobsAwaitingResult({ olderThanMin: 10 });
for (const { jobId } of stale) {
const res = await fetch(`https://api.printbase.cloud/v1/print-jobs/${jobId}`, {
headers: { Authorization: `Bearer ${process.env.PRINTBASE_API_KEY}` },
});
const job = await res.json();
if (job.status === 'completed' || job.status === 'failed') {
await handleEvent({ id: `sweep_${jobId}`, type: `print_job.${job.status}`, data: job });
}
}Webhook 负责快,对账负责准。两条路都汇进同一个幂等处理函数,同时跑不会有额外成本。
另外,任何一次投递的状态、尝试次数、HTTP 码、下次重试时间,都可以用 GET /v1/webhooks/{id}/deliveries 查——这通常是回答"你们到底有没有调过我"最快的方式。
还没有打印机就能开发
这套东西不需要真打印机就能建起来。POST /v1/webhooks/{id}/test 会发一条真实的、带签名的 print_job.completed 事件,job_id 是 job_test:
curl -X POST https://api.printbase.cloud/v1/webhooks/wh_abc/test \
-H "Authorization: Bearer $PRINTBASE_API_KEY"开发期把端点指向一条隧道(cloudflared tunnel --url http://localhost:3000 或 ngrok),签名校验、去重、原因码分支就都能在打出第一张纸之前调通。
一句话总结
POST的响应只说明任务被接收了,仅此而已。- 只有人在等、请求还开着的时候用轮询;其余场景用 Webhook。
- 用原始 body 验签,5 秒内先应答,按事件 id 去重。
- 按
reason_code分支:能重试的重试,需要人的告警。 - 加一个对账扫描,因为没送达的 Webhook 从定义上就是无声的。
PrintBase 在每个终态都会发出这些事件,带签名,并写明是哪台打印机、哪台电脑。免费套餐 每月 100 个任务,够把集成写完;Webhook 在任意付费套餐开启。完整字段见 Webhooks API 文档。
