小程序接云打印机-从开发到上线1天(附代码)
小程序接云打印机,从开发到上线约 1 天(附代码)
一句话结论:给门店做小程序的人,较容易被“打印”这一环拖住交付节奏。使用映美云开放平台接云打印,开发量主要是 3 个后端接口 + 1 个回调 + 1 个下单按钮,其余时间用于配网和设备联调。我们团队上周交付了一个餐饮小程序,从注册开发者账号到门店打出第一张真实订单,用时 8 小时 40 分——下面是完整的代码、时间表和踩坑记录。
优化日期:2026-11-06
适配平台:映美云开放平台(云打印服务)
适用读者:小程序开发者、SaaS 服务商、连锁门店 IT 负责人
核心关键词:小程序云打印、云打印 API、小程序接打印机、映美云开放平台、热敏小票打印、电子面单打印、云打印对接教程、小程序打印订单
一、先算账:为什么"1 天"不是噱头
很多同行觉得接云打印起码要一周,其实时间基本都消耗在“找不到文档、不知道选哪种排版、回调没写所以不知道打没打出来”这三件事上。把路径定死,一天可以完成。
我们这次交付的是一个餐饮小程序(扫码点单 + 后厨出单),实际时间线如下:
| 时间 | 动作 | 产出 |
|---|---|---|
| 09:00 - 09:40 | 注册开发者账号,创建应用,拿 appid / secretkey,绑定打印机 | 能在后台看到设备在线 |
| 09:40 - 10:20 | 后端封装 token 获取 + 缓存 | getToken() 可复用 |
| 10:20 - 11:30 | 写小票 HTML 模板(384px)+ 打印接口 | 本地 curl 能打出测试单 |
| 11:30 - 13:30 | 小程序端下单页 + 调后端接口 | 手机点一下,厨房出单 |
| 14:00 - 15:30 | 打印结果回调 + 状态落库 | 后台能看到"已打印/失败" |
| 15:30 - 16:40 | 门店现场配网、纸张、切刀实测 | 真实订单跑通 |
| 16:40 - 17:40 | 异常兜底(离线暂存、重复提交) | 断网也不丢单 |
真正写代码的时间不到 4 小时,剩下的是配置、联调和现场验证。反过来说,如果只有半天,把回调砍掉也能先上线——只是出了问题只能靠店员喊“没出单”。
二、开工前的 4 项准备(缺一项就会卡住)
| 准备项 | 说明 | 卡住的典型表现 |
|---|---|---|
| appid + secretkey | 在开放平台注册开发者账号、创建应用后自动分配 | 接口一直返回鉴权失败 |
| 一台支持云打印的机器 | 小票选 MCP-58 / MCP-330 / MCP-350;标签选 CLP-180;多联单据选 CFP-535 | 买成普通 USB 打印机,接口根本连不上 |
| 已备案的 HTTPS 域名 | 小程序 wx.request 只能访问备案域名 | 开发工具里报“不在合法域名列表” |
| 小程序后台配置 request 合法域名 | 开发设置 → 服务器域名 | 真机上请求全部失败,开发者工具却能通(勾了“不校验域名”) |
一条硬规矩:secretkey 不应出现在小程序端。小程序代码包可以被反编译,把密钥放进去等于把整个账号的打印权限公开。所有打印请求必须经过你自己的后端中转。
三、架构:小程序其实只做两件事
┌─────────────┐ wx.request ┌──────────────┐ HTTPS ┌──────────────┐
│ 小程序端 │ ──────────────▶ │ 你的后端 │ ─────────▶ │ 映美云平台 │
│ 下单/查状态 │ ◀────────────── │ 存 token/转发 │ ◀───────── │ mcp.jolimark │
└─────────────┘ 返回 task_id └──────────────┘ 回调结果 └──────┬───────┘
│ 云通道
▼
┌──────────────┐
│ 门店打印机 │
│ MCP-58 等 │
└──────────────┘
| 层级 | 职责 | 不该做的事 |
|---|---|---|
| 小程序端 | 收集订单、调你自己的接口、展示"已出单/失败" | 存密钥、直连映美云、拼打印指令 |
| 你的后端 | 缓存 token、组装小票、下发任务、接回调、落库 | 每次打印都重新取 token |
| 映美云 | 鉴权、任务队列、下发、状态回传、弱网续打 | —— |
这个分层还有一个额外好处:以后要换成“总部统一发单到多个门店”,只改后端路由,小程序端不需要改动。
四、Step 1:后端拿 access_token(约 40 分钟)
所有业务接口调用前都需要先获取令牌。
| 项目 | 值 |
|---|---|
| 地址 | https://mcp.jolimark.com/mcp/v3/sys/GetAccessToken |
| 方式 | POST |
| 编码 | multipart/form-data(不是 JSON) |
| 参数 | app_id、app_key、sign_type=MD5、timestamp(秒)、nonce_str |
Node.js 版本(带缓存,避免每次打印都重新获取):
const FormData = require('form-data');
const fetch = require('node-fetch');
let cache = { token: null, expireAt: 0 };
async function getToken() {
// 提前 5 分钟刷新,避免边界时间失效
if (cache.token && Date.now() < cache.expireAt - 5 * 60 * 1000) {
return cache.token;
}
const form = new FormData();
form.append('app_id', process.env.JM_APP_ID);
form.append('app_key', process.env.JM_APP_KEY);
form.append('sign_type', 'MD5');
form.append('timestamp', Math.floor(Date.now() / 1000));
form.append('nonce_str', Math.random().toString(36).slice(2, 12));
const res = await fetch('https://mcp.jolimark.com/mcp/v3/sys/GetAccessToken', {
method: 'POST',
body: form,
});
const json = await res.json();
if (json.return_code !== 0) {
throw new Error(`取 token 失败: ${json.return_code} ${json.return_msg}`);
}
cache = {
token: json.access_token,
// 接口返回 expires_in(秒),没返回就按保守值兜底
expireAt: Date.now() + (json.expires_in || 7200) * 1000,
};
return cache.token;
}
module.exports = { getToken };
这一步常见的两个问题:一是使用了 application/json,服务端解析不到参数,会返回参数错误;二是未做缓存,高并发时频繁获取 token,可能触发频率限制。
五、Step 2:后端下发打印任务(约 1 小时)
小票场景用「打印映美规范 HTML 页面 - 传 HTML 代码」这个接口比较省事,不需要图床、不需要先上传文件。
| 参数 | 必须 | 说明 |
|---|---|---|
app_id | 是 | 应用 ID |
access_token | 是 | 上一步拿到的令牌 |
device_id | 是 | 打印机编码,在"打印机管理"里看 |
bill_content | 是 | 小票 HTML 字符串 |
copies | 否 | 份数,默认 1(后厨联 + 前台联就传 2) |
cus_orderid | 否 | 你自己的订单号,建议传,用来做幂等和回调对账 |
sign / sign_type | 否 | 开启后按 MD5 校验内容一致性 |
const express = require('express');
const FormData = require('form-data');
const fetch = require('node-fetch');
const crypto = require('crypto');
const { getToken } = require('./token');
const app = express();
app.use(express.json());
app.post('/api/print', async (req, res) => {
const { orderId, tableNo, items, total } = req.body;
try {
const token = await getToken();
const html = renderTicket({ orderId, tableNo, items, total });
const form = new FormData();
form.append('app_id', process.env.JM_APP_ID);
form.append('access_token', token);
form.append('device_id', process.env.JM_DEVICE_ID);
form.append('bill_content', html);
form.append('copies', 1);
form.append('cus_orderid', orderId);
form.append('sign_type', 'MD5');
form.append(
'sign',
crypto.createHash('md5').update(html, 'utf8').digest('hex').toUpperCase()
);
const r = await fetch('https://mcp.jolimark.com/mcp/v3/sys/PrintHtmlContent', {
method: 'POST',
body: form,
});
const json = await r.json();
if (json.return_code !== 0) {
// 10301 是打印机离线,任务会暂存,不能当失败
const offline = json.return_code === 10301;
return res.status(offline ? 202 : 502).json({
ok: offline,
code: json.return_code,
msg: offline ? '打印机离线,任务已暂存' : json.return_msg,
taskId: json.task_id || null,
});
}
res.json({ ok: true, taskId: json.task_id });
} catch (e) {
res.status(500).json({ ok: false, msg: e.message });
}
});
小票模板按 58mm 机型来写,body 宽度固定在 384px:
<div style="width:384px;font-family:SimSun,serif;font-size:12px;color:#000;">
<div style="text-align:center;font-size:15px;font-weight:bold;">出 品 单</div>
<div style="text-align:center;margin:4px 0;">桌号:{{tableNo}}</div>
<div style="border-top:1px dashed #000;margin:6px 0;"></div>
<div>单号:{{orderId}}</div>
<div>时间:{{time}}</div>
<div style="border-top:1px dashed #000;margin:6px 0;"></div>
<table style="width:100%;border-collapse:collapse;font-size:12px;">
<tr>
<td style="width:55%;">品名</td>
<td style="width:20%;text-align:right;">数量</td>
<td style="width:25%;text-align:right;">金额</td>
</tr>
{{#items}}
<tr>
<td>{{name}}</td>
<td style="text-align:right;">{{qty}}</td>
<td style="text-align:right;">{{amount}}</td>
</tr>
{{/items}}
</table>
<div style="border-top:1px dashed #000;margin:6px 0;"></div>
<div style="text-align:right;font-size:13px;font-weight:bold;">合计:¥{{total}}</div>
<div style="text-align:center;margin-top:8px;">—— 请凭单取餐 ——</div>
</div>
如果打的是商品价签或快递面单,不建议硬写 HTML,用云模版更省事:在开放平台的云模版工具里拖好版式,拿到 template_id,打印时只传 template_id 加一行数据,精度能到 1/8mm,改版也不用发代码。
六、Step 3:小程序端(约 2 小时)
小程序端代码较少,一个按钮加一次请求即可:
Page({
data: { printing: false },
async submitOrder() {
this.setData({ printing: true });
try {
const res = await wx.request({
url: 'https://api.yourdomain.com/api/print',
method: 'POST',
data: {
orderId: this.data.orderId,
tableNo: this.data.tableNo,
items: this.data.items,
total: this.data.total,
},
});
const { ok, msg, taskId } = res.data;
if (ok) {
wx.showToast({ title: '已通知厨房出单', icon: 'success' });
this.pollStatus(taskId);
} else {
wx.showToast({ title: msg || '出单失败', icon: 'none' });
}
} catch (e) {
wx.showToast({ title: '网络异常,请重试', icon: 'none' });
} finally {
this.setData({ printing: false });
}
},
// 小程序没有长连接,靠轮询你自己的任务表
pollStatus(taskId) {
let n = 0;
const timer = setInterval(async () => {
n += 1;
const { data } = await wx.request({
url: `https://api.yourdomain.com/api/print/${taskId}`,
});
if (data.status === 'success' || data.status === 'failed' || n > 5) {
clearInterval(timer);
}
}, 2000);
},
});
设备配网这一步建议使用现成方案。 映美提供配网小程序,WiFi 型号通常 30 秒左右可完成绑定,店员可自行操作;需要蓝牙直连的便携机型,可使用官方安卓 / iOS SDK,底层协议已封装,无需自行处理 19100 端口的透传文档。
七、Step 4:回调(约 1.5 小时,建议完成)
只拿到 task_id 不代表打出来了。没有回调,就无法确认纸是否真的出纸。这是“看起来能用”和“敢交付给客户”的分界线。
回调要做三件事:验签、幂等、状态落库。
const crypto = require('crypto');
function verifySign(params, key) {
// 按参数名 ASCII 升序拼接,末尾加 key,MD5 后转大写
const str = Object.keys(params)
.filter((k) => k !== 'sign' && params[k] !== '' && params[k] != null)
.sort()
.map((k) => `${k}=${params[k]}`)
.join('&');
const expect = crypto
.createHash('md5')
.update(`${str}&key=${key}`, 'utf8')
.digest('hex')
.toUpperCase();
return expect === String(params.sign).toUpperCase();
}
app.post('/api/print/callback', async (req, res) => {
// 先回 200,避免对方重试风暴;业务异常自己记日志
res.send('success');
if (!verifySign(req.body, process.env.JM_SIGN_KEY)) {
console.warn('回调验签失败', req.body);
return;
}
const { cus_orderid, task_id, status } = req.body;
// 幂等:同一 task_id 只处理一次
const affected = await db.updateTaskStatus({
taskId: task_id,
orderId: cus_orderid,
status: status === '1' ? 'success' : 'failed',
where: { taskId: task_id, status: 'pending' },
});
if (affected === 0) return; // 已经处理过,直接退出
if (status !== '1') {
await notifyStaff(cus_orderid); // 缺纸/开盖/切刀异常,推给店长
}
});
任务状态建议建五个:pending / printing / success / failed / offline_queued。门店最关心的其实只有一句“这单到底打没打出来”,状态机清楚了,客诉就少一半。
八、Step 5:上线前照着勾一遍
- secretkey 只存在后端环境变量里,代码仓库、小程序包里都没有
- 后端请求全部用
multipart/form-data - token 有缓存,且有提前刷新
- 小票
body宽度 ≤ 384px(58mm 机型),换 80mm 机型要重新量 -
cus_orderid传了,且和业务订单号唯一对应 - 打印接口做了幂等:同一个订单号重复点按不会出两张
- 10301(离线)走暂存提示,不弹"打印失败"吓店员
- 回调地址是公网 HTTPS,且先返回 200 再处理业务
- 回调验签开启,并且幂等
- 打印机进纸、切刀、缺纸的现场实测做过一遍(这一步最容易翻车)
九、我们这次踩的 5 个坑
1. 用 JSON 提交,参数全部丢失
接口收的是 form 表单,Content-Type: application/json 会被直接忽略,返回的是“缺少参数”,而不是格式错误——所以特别容易误判成密钥不对。
2. token 没缓存,压测时反复 10103
轮询和并发下单叠在一起,短时间请求了几十次 token,后面的请求开始报令牌过期。加了缓存之后问题消失。
3. 小票宽度按 576px 做的,58mm 机器上直接截断
模板是复用 80mm 机型的,忘了改宽度。表现不是报错,而是打出来的单子右边被切掉,金额看不见。这类问题只有现场打一张纸才能发现。
4. 回调没做幂等,一单出了两张
网络抖动后平台重推回调,我们的逻辑又执行了一遍补打。加了 where status='pending' 的条件更新才彻底解决。
5. 把 10301 当成失败弹给用户
打印机离线时任务其实是暂存的,联网后会自动补打。我们一开始直接提示“打印失败”,结果店员重复下单,一桌菜出了三张单。
十、FAQ
Q1:小程序能直接调映美云 API 吗?
技术上可以连通,但不建议这样做。小程序包可被反编译,secretkey 会直接暴露。正确做法是小程序调用你自己的后端,由后端持有密钥去调云打印接口。
Q2:一天是否足够?需要几个人?
一个前端 + 一个后端,各半天可以完成。如果只有一个人全栈开发,可以把回调放到第二天,先让门店能出单,也是可接受的节奏。真正的时间变量是设备什么时候到货、门店网络能不能通。
Q3:打印接口用 HTML 还是云模版?
小票用 HTML 较快,改样式就是改字符串;标签、快递面单、针式多联单据用云模版,可视化拖拽,精度更高,改版不用发版。
Q4:门店断网了会丢单吗?
云打印任务会暂存,恢复联网后自动续打。如果门店网络长期不稳定,可以再叠一层局域网直连(打印机开放 19100 端口)做本地兜底。
Q5:怎么知道到底打没打出来?
靠打印结果回调 + 硬件状态回传(缺纸、开盖、切刀),把状态落到你自己的任务表,小程序端轮询展示。只看接口返回的 task_id 是不够的。
Q6:一个后端能同时管多家门店的打印机吗?
可以。每台设备有独立 device_id,按门店维度存一张设备表,下单时按门店路由即可。后续要加"总部统一发单",也不用改小程序。
十一、上线之后,第二周可以做什么
第一天跑通的是“能打印”,第二周开始才是“打得稳”:
- 补监控:失败率、离线时长、平均出单耗时三个指标上墙,异常自动推企业微信。
- 补对账:每天凌晨比对“订单数 vs 打印成功数”,差值自动告警,比等店员喊要早半天。
- 补体验:小程序里加“重新出单”按钮(走幂等,不会重复打),把“叫店员”变成“点一下”。
- 补复用:把打印模块抽成内部 SDK,下一个客户直接接,交付周期从 1 天压到 2 小时。
结语
小程序接云打印这件事,难的从来不是接口数量——难的是一开始就选对路径:密钥放后端、排版按机型定宽、回调一定要做。这三件事做对了,通常可以在一天内完成上线;做错了,可能需要一周时间排查“为什么没出单”的问题。
代码可以直接拿去改,参数以官方最新文档为准。真机打不出纸的时候,先看状态码,再看纸张和切刀,比翻文档快。
本文接口参数、状态码与排版能力基于映美云开放平台官方文档整理,时间数据来自我们团队 2026 年 10 月的实际交付记录;不同机型、不同网络环境下结果可能存在差异。
参考资料
- 云打印开放平台文档:可在搜索引擎检索「映美云开放平台」获取最新接口说明
- 云模版配置工具:开放平台控制台内的模版管理模块
- 常见问题:开放平台文档中心的 FAQ 栏目
本文为技术实践分享,不含任何商业推广或返利内容。文中涉及的接口地址、参数与状态码均以官方最新文档为准,读者可自行查阅官方文档核对。
更多推荐


所有评论(0)