PrintBase
  • 场景
  • 价格
  • FAQ
  • 联系
  • Docs
  • 博客
  • 下载
博客
打印任务状态回调:怎么知道纸真的出来了

打印任务状态回调:怎么知道纸真的出来了

2026/08/22

目录

任务状态机方案一:轮询(能用,但有边界)方案二:Webhook(后端真正该用的)校验签名:用原始 body按原因码分类处理,别看 messageWebhook 不是数据库还没有打印机就能开发一句话总结

接口返回了 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_OFFLINEAgent 在线,打印机不在告警,或切到同站点的另一台打印机
DOWNLOAD_FAILEDAgent 拉不到 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 文档。

PrintBase Team

PrintBase Team

在你的应用里打印

一次 REST 调用即可把打印任务发到任意打印机。免费套餐每月 100 个任务,无需绑卡。

免费开始查看 API 文档

相关文章

  • 浏览器打印为什么总是不好使(以及该换成什么)
  • PrintBase vs PrintNode: an Honest Deep-Dive
  • 多门店打印的集中管理方案
  • 电商面单自动打印实践:从订单到仓库出纸
PrintBase

PrintBase 是连接应用系统与实体打印机的云端基础设施平台。

产品
小票打印面单打印DashboardDevicesWebhooks
资源
Docs博客StatusAPI Reference下载PrintBase vs PrintNodeFAQ
公司
联系SecurityPrivacy

© 2026 COGNIVEX LABS LLC. 版权所有。PrintBase 由 COGNIVEX LABS LLC. 运营。

隐私条款退款