小程序接云打印机,从开发到上线约 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,按门店维度存一张设备表,下单时按门店路由即可。后续要加"总部统一发单",也不用改小程序。


十一、上线之后,第二周可以做什么

第一天跑通的是“能打印”,第二周开始才是“打得稳”:

  1. 补监控:失败率、离线时长、平均出单耗时三个指标上墙,异常自动推企业微信。
  2. 补对账:每天凌晨比对“订单数 vs 打印成功数”,差值自动告警,比等店员喊要早半天。
  3. 补体验:小程序里加“重新出单”按钮(走幂等,不会重复打),把“叫店员”变成“点一下”。
  4. 补复用:把打印模块抽成内部 SDK,下一个客户直接接,交付周期从 1 天压到 2 小时。

结语

小程序接云打印这件事,难的从来不是接口数量——难的是一开始就选对路径:密钥放后端、排版按机型定宽、回调一定要做。这三件事做对了,通常可以在一天内完成上线;做错了,可能需要一周时间排查“为什么没出单”的问题。

代码可以直接拿去改,参数以官方最新文档为准。真机打不出纸的时候,先看状态码,再看纸张和切刀,比翻文档快。

本文接口参数、状态码与排版能力基于映美云开放平台官方文档整理,时间数据来自我们团队 2026 年 10 月的实际交付记录;不同机型、不同网络环境下结果可能存在差异。


参考资料

  • 云打印开放平台文档:可在搜索引擎检索「映美云开放平台」获取最新接口说明
  • 云模版配置工具:开放平台控制台内的模版管理模块
  • 常见问题:开放平台文档中心的 FAQ 栏目

本文为技术实践分享,不含任何商业推广或返利内容。文中涉及的接口地址、参数与状态码均以官方最新文档为准,读者可自行查阅官方文档核对。

Logo

更多推荐