为什么需要独立后端

PipeMonitor 的 STM32 设备通过 DTU 透传上云,MQTT 消息需要被解析、存储和对外暴露。Flutter 应用需要 REST API 查询历史数据和下发命令。这套逻辑需要一个后端服务来承载。

三容器架构

# docker-compose.yml 核心服务
services:
  pipe-monitor-mysql:    # 数据持久化
  pipe-monitor-mqtt:     # 设备消息 Broker
  pipe-monitor-api:      # HTTP/WS 服务

MySQL 容器

低频遥测写入,默认按小内存实例运行:

pipe-monitor-mysql:
  image: mysql:8.4
  mem_limit: "384m"
  command:
    - --performance-schema=OFF
    - --innodb-buffer-pool-size=64M
    - --max-connections=40

PipeMonitor 的数据写入频率很低(10 秒一帧,每帧 ~500 字节),不需要高性能 MySQL 配置。关闭 Performance Schema、限制 buffer pool 和连接数,384MB 内存足够稳定运行。

Mosquitto MQTT Broker

pipe-monitor-mqtt:
  image: eclipse-mosquitto:2
  ports:
    - "1883:1883"
  volumes:
    - ./pipe-monitor-mqtt/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
    - ./pipe-monitor-mqtt/aclfile:/mosquitto/config/aclfile:ro

设备通过公网 1883 端口连接,强制账号密码和 ACL 控制。

API 服务

pipe-monitor-api:
  build:
    context: ./services/pipe-monitor-api
  environment:
    MQTT_UP_TOPIC: "device/FM001/up"
    MQTT_DOWN_TOPIC_TEMPLATE: "device/{dev}/down"
    DB_HOST: "pipe-monitor-mysql"

API 容器同时连接 MQTT Broker 和 MySQL,作为设备数据和用户请求之间的桥梁。

Node.js API 服务

JWT 鉴权

// 登录接口:用户不存在与密码错误返回同一种响应,避免账号枚举攻击
const matched = user ? await verifyPassword(password, user.passwordHash) : false;
if (!user || !matched) {
  res.status(401).json({ error: "invalid_credentials" });
  return;
}

JWT 密钥必须通过环境变量注入一个 32 位以上的随机字符串,如果缺失或长度不足,服务启动时直接退出:

if (jwtSecretIsFallback || jwtSecret.length < 32) {
  console.error("JWT_SECRET 未配置或长度不足 32 字符,启动中止");
  process.exit(1);
}

REST API 路由

路由方法用途
/healthGET健康检查(公开)
/api/auth/loginPOST登录(公开)
/api/latestGET最新一帧遥测
/api/historyGET历史曲线数据(游标分页)
/api/alarmsGET告警列表
/api/statusGET链路状态
/api/commands/upload-periodPOST下发上报周期

所有 /api/* 路由(除 login 外)都经过 JWT 鉴权中间件。

命令下发:seq 整型溢出修复

服务端通过 MQTT 下发命令,命令序号初始用 Date.now() 生成 13 位时间戳,但 STM32 固件用 int32 解析 JSON 整型——1.7e12 远超 2^31-1,会被截断到错误值,设备回复的 cmd_seq 与 App 等待的 seq 对不上。

修复方案:改用进程内单调递增计数器,配合 mod 限幅:

let cmdSeqCounter = 0;
function nextCmdSeq() {
  cmdSeqCounter = (cmdSeqCounter + 1) % 2_000_000_000;
  return cmdSeqCounter;
}

WebSocket 实时推送

/ws/live 端点提供实时数据推送,客户端通过 ?token=<JWT> 携带鉴权令牌。WS 连接上可以收到:

  • hello 帧:连接建立时推送当前最新数据快照
  • message 帧:每次 MQTT 收到新遥测帧时实时推送

WS 和 REST 共享同一份运行时状态 state,保证实时流和请求口径一致。

优雅关闭

function shutdown(signal) {
  live.close();                    // 先停实时推送
  mqttClient.end(false, () => {    // 再停 MQTT
    server.close(() => {           // 再停 HTTP
      dbStore.close().finally(() => process.exit(0));
    });
  });
  // 15s 超时强退,避免容器编排被迫 SIGKILL
  setTimeout(() => process.exit(1), 15000).unref();
}

MQTT Topic 设计

device/<deviceId>/up     # 设备上行:tele / alarm / ack
device/<deviceId>/down   # 云端下行:cmd JSON
  • up:设备通过 DTU 透传上来的 JSON 帧,每行以 \n 结尾
  • down:云端下发的一行 cmd JSON,设备收到后执行并回复 ack

安全加固

  • helmet 中间件提供 X-Frame-Options、HSTS、默认 CSP 等响应头加固
  • app.disable("x-powered-by") 隐藏 Express 指纹
  • 请求体限制 128KB
  • MQTT 强制账号密码 + ACL 白名单

小结

  • 三容器架构:MySQL + Mosquitto + Node.js API,各司其职
  • JWT 鉴权 + 防枚举登录 + 密钥强制注入
  • 游标分页的 history 接口,避免深度分页性能问题
  • WebSocket 实时推送,与 REST 共享同一运行时状态
  • 命令序号使用进程内单调计数器,修复 32 位截断问题
  • 优雅关闭保证容器编排不会被迫 SIGKILL

后续阅读