三家门店的时候,打印不是问题。每家店一台电脑,装个小程序,收银软件调本地接口,纸就出来了。哪台机器坏了,店长打个电话,你远程连上去看一眼。
开到五十家,同一套做法会以一种很具体的方式崩掉:你不知道此刻有多少台打印机是离线的,不知道昨天有多少张小票根本没打出来,也不知道新开的那家店到底把机器装好了没有。运维成本不是随门店数线性增长的——它随门店数 × 每店机器数增长,而排查一次故障的时间基本是常数。
这篇讲怎么把它变回一个可管理的系统。
先把问题拆成四件事
连锁场景下的"打印做好了",其实是四个独立的问题,混在一起谈就会一直谈不清:
- 台账:全国有哪些门店、每家有哪些设备、此刻谁在线。
- 路由:一笔订单产生的内容,怎么准确地落到那家店的那台机器上——小票进小票机,后厨单进后厨机,面单进标签机。
- 可观测:打没打出来?没打出来是因为什么?谁该去处理?
- 隔离:门店只能看和用自己的设备,总部要能看全部;如果你是给连锁客户做系统的服务商,客户之间还得互相看不见。
本地部署的方案天然只能解决第 2 件,而且只在单店范围内。剩下三件必须在云端有一份统一的视图,否则就只能靠人打电话。
台账:让设备自己上报,别维护 Excel
手工维护的设备表格从写下第一行起就开始过期。正确的做法是设备自己注册、自己上报状态,你只维护"业务含义"那一层。
以 PrintBase 为例:每家门店的电脑装一个 Agent,本机装过的打印机自动注册上来,每台拿到一个数字 printer_code。你要做的只是把这个物理设备和业务角色对应起来:
const res = await fetch('https://api.printbase.cloud/v1/printers', {
headers: { Authorization: `Bearer ${process.env.PRINTBASE_API_KEY}` },
});
const printers = await res.json();
// [{ id, code, computer_id, name: "Gprinter GP-80250", status: "online",
// capabilities: ["pdf", "raw"] }, ...]你自己的库里只存一张很薄的映射表:
create table store_printers (
store_id text not null, -- SH-001
role text not null, -- receipt | kitchen | label
printer_code bigint not null, -- 1000123
fallback_code bigint, -- 同店备用机,可空
primary key (store_id, role)
);这张表就是整套方案的核心。它把"上海徐汇店的小票机"这个业务概念,和"编号 1000123 的物理设备"解耦开:机器坏了换一台,改一行数据,业务代码一个字都不用动。
路由:业务语义进,设备编号出
有了映射表,打印调用就不再出现设备编号,只出现业务语言:
async function printForStore(storeId, role, doc) {
const target = await db.getStorePrinter(storeId, role);
if (!target) throw new Error(`${storeId} 没有配置 ${role} 打印机`);
return sendJob(target.printer_code, doc, { storeId, role });
}
async function sendJob(printerCode, doc, meta) {
const res = await fetch('https://api.printbase.cloud/v1/print-jobs', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PRINTBASE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
printer_code: printerCode,
content_type: doc.type, // 'raw' 走 ESC/POS、ZPL;'pdf' 走文档
content: doc.bytes.toString('base64'),
copies: doc.copies ?? 1,
}),
});
const job = await res.json();
await db.recordPrintJob({ ...meta, jobId: job.id, printerCode });
return job;
}调用方看到的是这样:
await printForStore('SH-001', 'receipt', renderReceipt(order)); // 小票
await printForStore('SH-001', 'kitchen', renderKitchenTicket(order)); // 后厨单db.recordPrintJob 那一行不要省。它让你后面能回答"这张小票对应哪个订单、哪家店、哪台机器"——没有这层记录,云端任务 ID 和你的业务对象之间就是断的,排查时只能靠时间戳猜。
小票和后厨单用 raw 直通 ESC/POS,面单用 raw 走 ZPL,出入库单、日结报表这类用 pdf。具体指令怎么写,见 ESC/POS 小票打印完全指南 和 ZPL 标签打印实战。
可观测:门店不该靠打电话告诉你
接口返回 queued 只代表任务被接收了。真正的结果要靠回调拿。订阅 Webhook 之后,每个任务的终态会带着门店信息推回来:
// POST /hooks/printbase —— 验签细节见文档
async function handleEvent(event) {
const fresh = await db.markEventSeen(event.id); // 幂等,按事件 ID 去重
if (!fresh) return;
const { job_id, status, reason_code, printer_name, computer_name } = event.data;
const job = await db.findPrintJob(job_id); // 拿回 storeId / role
if (status === 'completed') {
return db.markPrinted(job_id);
}
if (reason_code === 'PRINTER_OFFLINE' || reason_code === 'PRINT_ERROR') {
const fallback = await db.getFallbackPrinter(job.storeId, job.role);
if (fallback) {
await sendJob(fallback, await db.reloadDoc(job_id), job); // 切备用机
return alertStore(job.storeId, `主${job.role}机异常,已切备用机`);
}
}
await alertStore(
job.storeId,
`${printer_name}(${computer_name})打印失败:${reason_code}`
);
}reason_code 的五个取值对应的现场动作完全不同,这是连锁场景里最值钱的一个字段:
reason_code | 现场实际发生了什么 | 该通知谁 |
|---|---|---|
AGENT_OFFLINE | 门店那台电脑关机了、睡眠了、或断网了 | 店长:开机、检查网络 |
PRINTER_OFFLINE | 电脑在线,打印机没开或线掉了 | 店员:开机、插线、检查是否缺纸 |
PRINT_ERROR | 本地打印系统报错 | 先自动重试一次,持续失败转 IT |
DOWNLOAD_FAILED | Agent 拉不到内容文件 | 你自己:多半是签名 URL 过期了 |
INVALID_CONTENT | 内容和声明的类型不符 | 你自己:生成端的 bug,别重试 |
把前两类推到门店群、后两类推到研发告警,运维体感会立刻不一样:门店收到的是"打印机没开"这种能立刻处理的话,而不是一串任务 ID。完整字段和验签方式见 Webhooks API 文档,为什么必须做幂等和兜底对账,见 打印任务状态回调。
每天早上一张健康表
回调告诉你"打印失败了",但有一类故障是无声的:门店九点开门,电脑没开机,而当天第一单还没来——没有任务,就没有失败事件。所以还需要一个主动巡检。
// 每天 08:30 跑一次,开门前
const printers = await listPrinters();
const expected = await db.allStorePrinters(); // 台账里应该在的
const offline = expected.filter((e) => {
const p = printers.find((x) => x.code === e.printer_code);
return !p || p.status !== 'online';
});
if (offline.length) {
await notifyOps(
offline.map((o) => `${o.store_id} / ${o.role}`).join('\n')
);
}这段代码不长,但它把"今天哪家店会出问题"从下午三点提前到了早上八点半。对连锁运营来说,这是这整套方案里投入产出比最高的一段。
隔离:门店、品牌与服务商
规模再大一点,隔离就成了硬需求。三种典型形态:
一个品牌,多家直营店。 一个组织装下所有门店,用一把服务端 API key,靠 store_printers 做路由。门店员工不接触 API,只用你的收银系统。这是最简单也最常见的形态。
多品牌,或加盟商。 每个品牌/加盟商一个组织,各自的打印机和任务互相不可见,总部通过 Enterprise 层看汇总。PrintBase 的 Enterprise API 提供 POST /v1/enterprise/orgs 建组织、POST /v1/enterprise/orgs/{id}/api-keys 给子组织发 key,GET /v1/enterprise/print-jobs 跨组织查打印记录,GET /v1/enterprise/usage 看各组织的设备与任务汇总。
你是给连锁做系统的服务商。 形态和上面一样,只是每个客户一个组织。这里要留意商业条款上的差别:多租户在 PrintBase 是基础能力,不需要单独的套餐;PrintNode 的对应能力是 Integrator 账户,起步 $60/月。具体对比见 PrintBase 与 PrintNode 的对比页。
无论哪种形态,有一条不要破例:API key 放在你的服务端,门店侧只跑 Agent。key 一旦下发到门店电脑,你就失去了对"谁能打到哪台机器"的控制。
新店上线清单
把开店流程标准化,比事后排查便宜得多。一家新店的打印部分应该是这样:
- 门店电脑装 Agent,用总部账号登录(不是店长的私人账号)。
- 打开控制台,确认这台电脑和它的打印机都已注册、状态
online。 - 按角色把
printer_code写进store_printers:receipt、kitchen、label。 - 每台机器打一张测试页,确认纸型、方向、切纸都正常。
- 有备用机的,把
fallback_code一并填上,并实际拔一次线验证切换生效。 - 把这家店加进每日巡检的名单里。
第 5 步经常被跳过,然后在真正需要它的那天才发现备用机配错了打印机编号。
那些反复出现的坑
- 门店电脑休眠。 这是
AGENT_OFFLINE的头号原因。把电源计划改成永不睡眠,并写进开店 SOP。 - 店员关掉 Agent。 任务栏里多出来一个不认识的图标,很容易被关掉。装成开机自启的后台服务,不要依赖人不去动它。
- 换了打印机没改台账。 所以映射表要薄——换机只改一行;也所以每日巡检要比对台账,而不是只看当前在线列表。
- 打印机名字全叫 "POS-58"。 五十家店同名设备,靠名字根本分不清。始终用
printer_code做主键,名字只用来给人看。 - 高峰期任务堆积。 云端 API 是异步的,先入队再派发,门店网络抖动不会丢单——但你的业务侧要有"这单还没打出来"的状态,别让收银员在不确定中重复点五次打印。
小结
连锁打印真正难的不是把纸打出来,而是在五十个地点同时知道纸有没有出来。做对这几件事就够了:设备自己注册、业务与设备用一张薄映射表解耦、回调按原因码分流到能处理的人、每天开门前主动巡检一次、API key 只留在服务端。
PrintBase 提供的正是这套底座:Agent 自动注册设备、printer_code 稳定寻址、终态 Webhook 带原因码、Enterprise 层做多组织隔离与汇总。免费套餐 每月 100 个任务、不用绑卡,先拿一家店把链路跑通,再复制到其余门店。按场景看的话,小票走 小票打印 API,面单走 面单打印 API。
