简介:HttpPrinter4.zip 是一份面向 Web 前端与 Java 后端开发者的 HTML 打印插件集合,围绕 HTTP 协议传输打印任务,解决网页打印、远程打印及跨设备输出等需求。压缩包共 549 个文件、约 107.32MB,包含 27 个 js 脚本、21 个 dll 库、19 个 dcu 编译单元、14 个 exe 程序,以及 pas、dfm、dpr 等工程源码和 docx、chm、pdf 说明文档,另有 gif、jpg 等界面素材与 ini、cfg 配置文件,便于按模块定位示例、库文件和帮助资料。已有 645 人学习下载。插件通过 JS 方法触发打印,并借助 HTTP 请求把指令交给服务端解析执行,覆盖打印预览、分页、格式化与异常处理等环节;包内示例和文档可帮助读者理解 HTML 内容组织、JavaScript 调用方式以及 Java 端接收打印逻辑,适合需要快速集成网络打印能力或研究打印插件实现的中高级开发者参考。
1. 为什么浏览器打印总要弹窗:HttpPrinter4 解决的到底是什么问题
做过收银、票据、面单这类系统的人,大多撞上过同一个场面:页面数据全对,一点打印就弹系统对话框,用户还得挑打印机、调份数、点确定,批量出二十张就要点二十次。HttpPrinter4.zip 这套 html js httpPrinter 打印插件配 Java 本地服务的组合,就是冲这个场面来的——把打印从浏览器里挪出去,交给一个跑在本机的小服务,前端只管把内容用 HTTP 递过去,排版、选打印机、走纸、切纸由 Java 侧接住。
它的定位很明确:需要静默打印、需要指定某台打印机、需要连续打小票和标签的 Web 系统。反过来,纯公网、客户端完全不受控、连本地服务都装不上的场景,这套东西帮不上忙,硬上只会多一层运维负担。
2. 拆开通信链路:一次打印请求从 JS 到 Java 的四段结构
在动手部署之前,先把这条链路想清楚,后面调参数、查故障才不会瞎猜。HttpPrinter 这类组件的本质是一个本机 HTTP 服务加一个打印调度器,前端和它之间只有 HTTP 一层关系,没有插件、没有控件、没有浏览器扩展。理解这一点,很多“为什么没反应”的问题就能自己定位。
2.1 为什么选 HTTP 本地服务,而不是控件或浏览器原生打印
浏览器原生打印的边界很清楚:window.print()只能走系统对话框,样式受@media print支配,分页、页边距、纸张类型基本由用户在对话框里决定,代码几乎无法干预。早期不少项目用浏览器插件或 ActiveX 控件绕开这个限制,代价是绑定特定内核、特定系统,升级一次浏览器就全线返工,这类血泪经验在行业里不算少。
本地 HTTP 服务的思路是把“打印”变成一次普通网络请求。服务跑在127.0.0.1上,跟着客户端机器一起启动,前端用fetch或XMLHttpRequest发一个 JSON 过去,服务收到后调用系统打印接口。它带来的直接好处有三点:第一,打印过程完全静默,不经过任何用户交互;第二,可以按名字指定打印机,多台设备各司其职;第三,打印内容和打印动作解耦,模板改版不用重装客户端。
代价也要说清楚。客户端必须装这个服务,装机、开机自启、端口占用、杀毒软件拦截,都是你要负责的范围。另外本地服务是 HTTP,而业务页面往往是 HTTPS,混合内容会被浏览器拦下来,这一点在第 5 章会专门拆。选型时我一般这么判断:如果是内部系统、门店终端、仓库工位这类可控环境,本地服务方案成熟且省事;如果是面向陌生用户的公网产品,就要慎重,宁可退回到浏览器打印加提示引导。
2.2 请求体长什么样:任务号、打印机名、模板与数据字段
不同实现的字段命名会有差异,但结构大同小异:一个任务标识、一个目标打印机、一段内容或模板加数据、若干控制参数。下面这份 JSON 是最常见的形态,实际字段以包里的接口说明为准,我这里用能跑通的最小集合举例。
{ "taskId": "pos-20240612-0001", "printer": "收银小票打印机", "template": "receipt_58mm", "copies": 1, "silent": true, "data": { "shopName": "样例门店", "orderNo": "A0001", "items": [ { "name": "商品甲", "qty": 2, "price": "12.00" }, { "name": "商品乙", "qty": 1, "price": "8.50" } ], "total": "32.50", "payTime": "2024-06-12 10:21:33" } }taskId是幂等键,重复提交同一个 ID 时服务端可以选择丢弃或覆盖,连续打印场景里这个字段能救你一次。printer建议填打印机在系统里的完整名称,而不是“默认打印机”,否则多设备环境下会打到隔壁工位去。template指向服务端预置的模板文件,data是纯业务数据,模板负责排版,两者分离的好处是改版只动模板不动前端。copies和silent属于控制位,前者控制份数,后者控制是否强制静默。
请求体的大小要留意。把整页 HTML 塞进 JSON 在数据量小时没问题,但订单明细上百条、带 Base64 图片时,报文会膨胀到几 MB,本地服务解析慢,前端还会以为超时。常见做法是模板加数据,图片走 URL 或本地路径,服务端自己去取。
2.3 Java 侧的四段处理:接收、渲染、派发、回执
服务端的骨架并不复杂,一段接收、一段渲染、一段派发、一段回执。下面这段代码用 JDK 自带的HttpServer和javax.print写了最小可用版本,重点是展示处理顺序和异常兜底,生产环境还要补线程池、日志和权限校验。
import com.sun.net.httpserver.HttpServer; import javax.print.*; import java.io.*; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; public class PrintAgent { public static void main(String[] args) throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 18080), 0); server.createContext("/print", ctx -> { String body = new String(ctx.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); String resp; try { // 1. 解析请求,取出 printer 与渲染后的文本或文档流 PrintJob job = PrintJob.parse(body); // 2. 按名字定位打印服务,找不到直接失败,不要回落到默认打印机 PrintService svc = findService(job.getPrinter()); if (svc == null) throw new IllegalStateException("printer not found: " + job.getPrinter()); // 3. 组装 Doc 并提交,DocFlavor 决定数据被当作文本还是字节流 Doc doc = new SimpleDoc(job.render(), DocFlavor.BYTE_ARRAY.AUTOSENSE, null); svc.createPrintJob().print(doc, null); resp = "{\"code\":0,\"taskId\":\"" + job.getTaskId() + "\"}"; } catch (Exception e) { // 4. 失败也要回 200 之外的明确状态,前端才能重试 ctx.sendResponseHeaders(500, 0); resp = "{\"code\":500,\"msg\":\"" + e.getMessage() + "\"}"; } ctx.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); ctx.getResponseBody().write(resp.getBytes(StandardCharsets.UTF_8)); ctx.close(); }); server.start(); } private static PrintService findService(String name) { for (PrintService s : PrintServiceLookup.lookupPrintServices(null, null)) { if (s.getName().equals(name)) return s; } return null; } }几个点值得单独说。第一,监听地址写死127.0.0.1而不是0.0.0.0,前者只有本机能访问,后者等于把打印能力开放到局域网,谁扫到都能打,这是实打实的安全事故。第二,findService找不到时抛异常而不是回落到默认打印机,因为错误打印比不打印更难排查——单据悄悄从另一台机器吐出来,现场没人知道发生了什么。第三,异常分支必须返回非 2xx 状态码,否则前端拿到 200 会以为成功,用户点十次你打十张白纸。第四,DocFlavor的选择决定数据怎么解释,文本用AUTOSENSE让它自己嗅探编码,PDF 或图片用对应的字节流类型。
2.4 回执与幂等:别让重试变成重复打印
前端调用本地服务,一定会遇到超时。可能是服务正在处理上一单,也可能是打印机在等纸。这时候前端如果无脑重试,就会出现同一张票打两次的情况。解决办法不复杂:把taskId当作幂等键,服务端维护一个短期缓存(比如最近十分钟的 ID 集合),重复请求直接返回上次的结果,不再真正提交打印。
回执内容也建议多带一点信息,除了code和taskId,再给一个printer和timestamp。出问题时把这三样一贴,能快速判断是没提交、提交错打印机,还是打印机侧卡住了。我一般还会让服务端在本地落一行文本日志,记录任务号、打印机名、数据长度和耗时,这个日志在排查“到底打没打”的时候比什么都管用。
3. 把第一张票据打出来:环境准备与端到端联调
链路清楚了就动手。这一章按“先让服务活着,再让 curl 打通,最后接前端”的顺序走,每一步都有明确的成功标志,卡在哪一步一目了然。新手最容易犯的错是跳过中间步骤,直接写前端代码,结果页面报错、服务没起、打印机没装,三个问题叠在一起,根本分不清是谁的锅。
3.1 部署与端口确认:先让服务稳定跑起来
拿到压缩包后先解压,看目录结构:一般会有启动脚本、配置文件、模板目录和依赖的 jar。Java 服务需要本机有运行时环境,版本按包里的说明来,别自己升到最新,兼容性问题往往就出在这里。
# 解压并进入目录 unzip HttpPrinter4.zip -d http-printer cd http-printer # 前台启动,先看控制台输出,不要一上来就后台跑 java -jar print-agent.jar --port=18080 --template-dir=./templates # 另开一个终端确认端口在监听 # Linux / macOS lsof -iTCP:18080 -sTCP:LISTEN # 通用替代方式 curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:18080/health--port建议避开 80、8080、3306 这类常被占用的端口,18080 是个不容易撞车的选择。--template-dir指向模板目录,服务启动时会加载,模板改了要重启或者调热加载接口。健康检查接口返回 200 就说明服务活着,返回 000 基本是没起来或者端口不对。
确认服务活着之后,还要单独确认打印机在系统里可见。做法是进系统打印设置,看设备名称,把名字原样复制下来,后面请求里的printer字段必须和它一字不差,多一个空格都匹配不上。
提示:服务装好后一定要配开机自启。收银机断电重启是常态,靠人工点一下启动脚本,早晚会有人在早高峰发现打不出小票。
3.2 用 curl 先把服务端打通,别急着写前端
这一步是整个联调里性价比最高的。前端出问题时变量太多:跨域、混合内容、请求体格式、字段拼写,全混在一起。先用 curl 把服务端单独验证一遍,成功之后前端只要照着同样的报文发就行。
curl -X POST http://127.0.0.1:18080/print \ -H "Content-Type: application/json; charset=utf-8" \ -d '{ "taskId": "test-0001", "printer": "收银小票打印机", "template": "receipt_58mm", "copies": 1, "silent": true, "data": { "shopName": "样例门店", "orderNo": "A0001", "total": "32.50" } }'返回{"code":0,"taskId":"test-0001"}且打印机真的吐纸,说明服务端链路完全通了,剩下的都是前端问题。如果返回 500 且提示 printer not found,回去核对打印机名字;如果返回连接被拒,服务没起或者端口不对;如果返回成功但没出纸,看打印机是否离线、缺纸、盖子没合上——这类物理问题在门店现场占比不低,别一上来就怀疑代码。
再补一个查询接口的验证,很多实现会提供打印机列表:
curl http://127.0.0.1:18080/printer/list拿到列表之后,前端就不该再硬编码打印机名,而是让用户在设置页里选一次,存到本地配置里。门店换机器、加打印机的时候,这一个小设计能省掉一次发版。
3.3 前端封装:把 fetch 包成一个可复用的 printClient
前端代码不要在每个业务页面里复制粘贴 fetch,包一层,统一处理超时、重试和错误提示。下面这个封装是最小可用版本,重点在超时控制和错误分类。
// printClient.js const BASE = 'http://127.0.0.1:18080'; export async function print(payload, { timeout = 8000, retry = 1 } = {}) { const body = JSON.stringify({ taskId: payload.taskId || `t-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, silent: true, copies: 1, ...payload }); for (let i = 0; i <= retry; i++) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeout); try { const res = await fetch(`${BASE}/print`, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body, signal: ctrl.signal }); clearTimeout(timer); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); if (json.code !== 0) throw new Error(json.msg || 'print failed'); return json; // 成功,带 taskId 返回给调用方留痕 } catch (e) { clearTimeout(timer); if (i === retry) throw e; // 最后一次仍失败,抛给上层提示 await new Promise(r => setTimeout(r, 600)); // 等打印机缓一下再重试 } } }AbortController解决的是“请求发出去石沉大海”的问题,没有超时控制,前端会一直挂着转圈。retry默认给 1 次而不是 3 次,因为打印失败多半是物理原因,重试太多次只会把队列堆满。taskId自动生成兜底,但业务侧最好传入自己的订单号,这样重试时服务端能靠幂等键去重,不会打出两张一样的票。返回的taskId建议顺手记到业务日志里,事后对账用得上。
调用侧就一行:
import { print } from './printClient'; await print({ taskId: order.no, printer: cfg.printer, template: 'receipt_58mm', data: order });3.4 模板占位符约定:让改版不动前端代码
模板是这套方案里最容易被低估的部分。如果模板写死在 Java 代码里,每次门店改个抬头都要重新打包,运维成本直接翻倍。常见做法是模板独立成文件,用占位符替换,服务启动时加载、运行时可重载。
占位符语法各实现不同,${field}、{{field}}、#field#都见过,选一种并全项目统一。数据里嵌套数组时,模板引擎要支持循环,否则订单明细只能拼成一大段文本,改个列宽都费劲。下面是一个 58mm 小票模板的片段,展示字段替换和循环的写法:
{{shopName}} 订单号:{{orderNo}} ------------------------ {{#each items}} {{name}} x{{qty}} {{price}} {{/each}} ------------------------ 合计:{{total}} {{payTime}}设计模板时有两条经验。一是宽度按字符数算,58mm 纸一般一行 32 个半角字符,中文占两个,字段超长要截断,不然会折行把版式冲乱。二是不要依赖浏览器渲染,模板最终由服务端转成打印指令,CSS 里的flex、float在这条链路上不生效,用空格和对齐来排版反而稳。改完模板记得走一遍重载接口或者重启服务,再打一张测试单确认,别等用户下单了才发现占位符名字拼错。
4. 参数怎么调:纸张、偏移、编码与多打印机路由
服务能跑通只是及格线,真正决定这套东西能不能在门店活下来的,是参数调得对不对。这一章挑四类最常出问题的参数展开:纸张与偏移、编码、多打印机路由、队列节流。每类都给判断方法和调整手段,照着改基本能覆盖八成现场问题。
4.1 纸张尺寸与打印偏移:从毫米级校准开始
纸张参数通常有三项:宽度、高度、边距。热敏小票纸常见 58mm 和 80mm 两种,对应每行字符数不同,标签纸则要精确到毫米,差 1mm 就会打到隔壁标签上。下面的参数表是调参时的对照参考,具体字段名以配置文件为准。
| 参数 | 典型取值 | 作用 | 调错的后果 |
|---|---|---|---|
| paperWidth | 58 / 80(mm) | 决定可打印宽度与每行字符数 | 内容折行、右侧被裁 |
| paperHeight | 0(连续纸)/ 40(标签) | 连续纸填 0,标签填实际高度 | 标签多走一张或定位偏 |
| marginTop | 0~3(mm) | 顶部留白 | 首行被切或上方空白过多 |
| marginLeft | 0~2(mm) | 左侧留白 | 整体右移、右边溢出 |
| offsetY | -2~2(mm) | 垂直微调,补偿机械误差 | 每张位置漂移 |
偏移这件事有点玄学,同一型号的机器,用久了齿轮磨损,打印位置也会漂。我一般的做法是打一张带刻度线的校准页,量出实际偏移量,再回填到offsetY和marginLeft,而不是靠肉眼反复试。校准页上画好 10mm 网格,打出来往纸上一比,偏了多少一目了然。
连续纸场景要特别注意:纸张高度设得比实际内容大,会多走一段空白;设得小,内容会被截断,而且截断往往发生在表格中间,看起来像数据丢了,实际是纸张高度不够。遇到“明明数据完整但打印不全”,先查纸张高度,再查内容长度,顺序别反。
4.2 中文乱码与字符编码:一条链路上有三处可能出错
中文打出来是问号或者方块,是这套方案最高频的投诉。原因不唯一,得按链路一段段排:前端 JSON 编码、HTTP 传输编码、服务端解析编码、打印指令编码,四处任何一处不一致都会出问题。
排查顺序建议从后往前。先确认打印机本身支持中文,部分老机型只支持 ASCII,需要先转成点阵图再打,这种情况换编码没用,得换输出方式。再确认服务端解析请求时用的字符集,new String(bytes, StandardCharsets.UTF_8)这一句如果写成平台默认编码,在中文系统上可能碰巧能用,换台机器就翻车,属于典型的“能跑但不可靠”。最后看打印指令阶段,DocFlavor选AUTOSENSE时服务会嗅探,但嗅探不是万能的,明确指定text/plain; charset=utf-8更稳。
// 解析请求体时显式指定 UTF-8,不要依赖平台默认编码 String body = new String(ctx.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); // 构造打印文档时给出明确的字符集,避免服务端再猜一次 DocFlavor flavor = new DocFlavor.BYTE_ARRAY(DocFlavor.BYTE_ARRAY.AUTOSENSE.getMimeType()); byte[] content = rendered.getBytes(StandardCharsets.UTF_8); Doc doc = new SimpleDoc(content, flavor, null);还有一处容易漏:模板文件本身的保存编码。用编辑器存成 GBK,服务按 UTF-8 读,模板里的中文抬头就变成乱码,而数据部分正常,看起来像是“模板有问题”。统一全链路 UTF-8,包括文件保存格式,这个约定写进项目说明里,别靠口口相传。
4.3 多打印机路由:把打印机名做成配置,而不是写死在代码里
一个门店可能同时有小票机、标签机、后厨机,仓库还有面单机。前端硬编码打印机名,换机器就得改代码发版。常见做法是维护一张路由表,按业务类型映射到打印机名,配置放在服务端或前端的本地设置里,改配置不发版。
// printerRoute.js —— 业务类型到打印机的映射,存本地配置,设置页可改 export const route = { receipt: '收银小票打印机', kitchen: '后厨打印机', label: '标签打印机', waybill: '面单打印机' }; export function resolvePrinter(type) { const saved = JSON.parse(localStorage.getItem('printerRoute') || '{}'); const name = saved[type] || route[type]; if (!name) throw new Error(`未配置打印机:${type}`); return name; }设置页里做一个下拉,数据源来自/printer/list,让用户自己选。这样换机器、加设备都不用开发介入。路由表还要考虑降级:后厨机离线时,是继续失败还是回落到小票机打一张?我的建议是明确失败并提示,不要悄悄回落,因为后厨单打到收银台,厨房根本看不到,问题会拖到出餐超时才暴露。
4.4 队列节流与超时:连续打印时最容易被忽略的一环
一个订单可能同时触发小票、后厨单、标签三张,如果三个请求并发发出,老打印机的缓冲区扛不住,会出现丢单、半张、指令错乱。表现是打印内容缺一段或者打出乱码字符,看起来像编码问题,实际是并发导致的数据截断。
处理办法是串行化。前端用一个简单的任务队列,同一台打印机的任务排队执行,中间留几十毫秒间隔。
// 按打印机串行排队,避免并发把缓冲区打爆 const queues = new Map(); export function enqueue(printer, task) { const prev = queues.get(printer) || Promise.resolve(); const next = prev .catch(() => {}) // 前一个失败不阻塞后续 .then(() => task()) .then(r => new Promise(res => setTimeout(() => res(r), 80))); // 间隔 80ms queues.set(printer, next); return next; }间隔 80ms 是个经验值,热敏机一般够用,慢速标签机可能要 150ms 以上,按实际设备调。超时时间也要分开设:小票 5 秒足够,标签和面单涉及走纸定位,给到 10 秒更稳。超时之后不要立刻重试,先查打印队列里是否已经堆积,堆了就先清空再重发,否则越重试越乱。
5. 避坑现场:五类打印失败的现象、根因与处置
前面几章讲的是正常路径,这一章讲翻车。下面五类问题是我在实际项目里反复见到的,每条都按“现象 → 原因 → 解决”写,遇到时对着症状找,比从头排查快得多。
5.1 服务明明起了,前端就是连不上
现象:浏览器控制台报ERR_CONNECTION_REFUSED或请求一直 pending,但本地用 curl 访问127.0.0.1:18080是通的。
原因通常有三个。一是服务绑定的地址是127.0.0.1,而页面里写的是localhost,在某些系统上localhost会先解析到 IPv6 的::1,服务没监听 IPv6 就拒绝连接。二是端口被别的程序占了,服务启动时没报错但实际没绑上,或者绑上之后被安全软件拦截。三是系统防火墙对 Java 进程弹了拦截提示,用户随手点了拒绝,之后所有请求都被挡在外面。
解决:地址统一写127.0.0.1,别用localhost;启动后用lsof -iTCP:18080 -sTCP:LISTEN确认监听的是哪个地址;安全软件里给 Java 进程加白名单,这一步在装机脚本里就该做掉,别指望门店人员会操作。
5.2 HTTPS 页面调本地 HTTP 服务,请求被浏览器静默拦掉
现象:本地调试一切正常,一部署到线上就打印失败,控制台出现混合内容警告,请求根本没发出去。
原因很直接:页面是 HTTPS,请求目标是 HTTP,浏览器按混合内容策略拦截。本地服务装在用户机器上,拿不到公网可信证书,这条路天然走不通。
解决:常见做法是让本地服务也提供 HTTPS,用自签证书,首次访问时引导用户信任一次;或者把打印请求改由同源的后端转发,前端只调自己的 HTTPS 接口,后端再和内网打印服务通信,但这要求打印服务不在用户本机。两条路各有代价,前者要处理证书信任,后者要改架构。选之前先确认部署形态,别做到一半才发现方向不对。
5.3 打印内容被截断,或者图片打成空白
现象:小票打到一半停了,后面的合计金额没了;或者模板里的 Logo 位置是一块空白。
原因:截断多半是纸张高度设小了,服务按设定高度裁剪内容;也可能是单次请求数据量太大,服务端缓冲区不够。图片空白一般是路径问题——模板里写的是相对路径,服务的工作目录和模板目录不一致,取不到文件,静默失败了;也可能是图片格式打印机不支持,比如带透明通道的 PNG。
解决:纸张高度按实际内容动态算,或者连续纸直接设 0;大数据量拆成多次请求;图片统一转成不透明的 JPG 或单色位图,路径改成绝对路径,并且在服务端加一条“图片加载失败要报错”的逻辑,别让它悄悄变空白。
5.4 连续打多张时丢单,或者打出乱码字符
现象:一次提交五张标签,实际只出三张;或者某张票上出现一串看不懂的符号。
原因:并发请求同时写入打印机缓冲区,指令流交错,导致部分指令被吞或者被错误解释。乱码字符往往是控制指令被截断后,剩余字节被当成文本打了出来。
解决:按打印机维度串行排队,参考 4.4 的队列写法;服务端对同一台打印机的任务加锁;如果打印机支持,开启流控或者加大缓冲区。这个问题在小票机上不明显,在标签机和针式打印机上特别突出,别拿小票机的经验去套所有设备。
5.5 服务运行一段时间后自己消失,重启电脑也不会自启
现象:早上第一单打不出来,去任务管理器一看,打印服务进程没了;或者电脑重启后必须手动点一下启动脚本。
原因:进程被系统的省电策略或安全软件回收;启动脚本是前台运行的,关掉命令行窗口进程就结束;开机自启没配,或者配了但因为依赖的服务没起来而启动失败。
解决:把服务注册成系统服务或计划任务,配置失败自动重启;启动脚本改成后台运行并重定向日志;开机自启加一个延迟,等网络和打印后台服务就绪再拉起。这一条属于运维基本功,但漏掉它,前面所有调试都白做。
6. 把打印纳入发布流程:任务日志、灰度与验收清单
打印这套东西最容易出问题的时刻,不是开发阶段,是改完模板、换完打印机、升级完服务之后的那次上线。因为它依赖客户端环境,你在自己机器上测通了,不代表门店那台用了三年的机器也能通。所以最后一章不讲新功能,讲怎么让每次变更可控。
第一件事是任务日志。服务端每处理一个任务,落一行结构化日志:时间、taskId、打印机名、模板名、数据字节数、耗时、结果码。这行日志不要只写文件,最好再提供一个/task/recent接口,前端设置页里能直接看到最近二十条。曾经有一次门店反馈“偶尔少打一张”,就是靠这个列表对比业务订单号,发现是前端在超时后重试、而服务端没做幂等导致的重复提交被打印机吞掉。没有日志,这个问题只能靠猜。
第二件事是灰度。模板改版不要一次性全量,先在一台测试机上把新模板打十张,覆盖最长订单、最多明细、带图片三种情况,确认版式和走纸都正常再推。打印机型号不同的门店分批推,先推一家,观察一天。下面这张验收清单可以直接抄进发布流程:
| 检查项 | 通过标准 | 不通过的常见原因 |
|---|---|---|
| 服务存活 | /health返回 200 | 端口占用、进程未起 |
| 打印机可见 | /printer/list含目标设备 | 驱动未装、名称不一致 |
| 中文正常 | 测试单无问号方块 | 编码链路不统一 |
| 版式对齐 | 无折行、无右侧裁切 | 纸张宽度或边距设错 |
| 连续打印 | 连打 10 张不丢单 | 未串行排队 |
| 断电恢复 | 重启后服务自动拉起 | 未配自启或自启失败 |
第三件事是留一条手工兜底通道。再稳的自动打印也会遇到极端情况,设置页里放一个“打印测试页”按钮,再放一个“重新加载模板”按钮,让现场人员在不找开发的情况下能自救一次。这两个按钮救急的次数,比很多花哨功能都多。
我自己的习惯是:任何跟打印相关的改动,上线前必须在一台真实设备上连打二十张,中间穿插一次服务重启和一次打印机断电重连。这个流程听起来笨,但它替我挡掉过至少三次会在早高峰炸掉的问题。打印这条链路离用户太近,出错就是当场出丑,宁可上线前多花二十分钟,也别等门店打电话过来。希望帮到你。
本文还有配套的精品资源,点击获取