工程手记

设备还没接上,后端先做什么?

设备接入系统如何确保数据有归属,为什么用户和厂商模块必须先建,MQTT 主题设计如何支撑权限隔离。

玩具联网项目需要同时支撑设备接入、厂商管理、订单与分销、小程序。搭建后端时,如果简单按"设备"→"产品"→"订单"的业务流程顺序实现,很快会碰到一个问题:设备连接上来上报数据,但不知道这个设备属于谁、属于哪个厂商,调试无从下手。

这不是代码问题,是架构依赖顺序的问题。

为什么顺序错了调试会卡住

设备上线后会通过 MQTT 上报心跳、状态、日志。这些数据要写入数据库,必须记录设备序列号和设备 ID。而设备 ID 是 MongoDB ObjectId,它存在于 Device 模型里。

Device 模型包含三个关键外键:

  • manufacturerId(该设备属于哪个厂商)
  • productId(该设备属于哪个商品型号)
  • userId(当前绑定的用户,null 表示未绑定)

当你没有先定义 Manufacturer 和 User 数据结构时,Device 记录就悬空了。设备连接时,你能看到日志来了,但无法回答"这是谁的设备"。更重要的是,Device 模型的权限检查逻辑会失效——后续要实现"用户只能查看自己的设备",需要依赖 userId 字段;要实现"厂商只能看自己的设备",需要 manufacturerId。没有这两个模型,权限层就没法建。

正确的搭建顺序

从下往上分三层:

第一层:所有制基础(必须最先)

  • User 模型(小程序用户)
  • Admin 模型(后台管理员)
  • Manufacturer 模型(厂商入驻)
  • Role 模型(角色权限)

这一层定义了"谁是谁"。后续所有数据都需要通过 userId、adminId 或 manufacturerId 来标记所有权和权限。

第二层:设备与商品(依赖第一层)

  • Product 模型(商品/型号定义)
  • Device 模型(具体设备实例,绑定到 User)
  • MQTT 服务(设备通信协议)

Device 需要 manufacturerId 来记录厂商关系,这样权限检查才有依据。Product 同样需要 manufacturerId。

第三层:业务流程(依赖前两层)

  • Order 模型(订单)
  • FinanceRecord 模型(财务结算)
  • Distribution 模型逻辑(二级分销)

订单需要引用 User、Product、Device,财务结算需要引用 Manufacturer。没有前两层,这一层无法运转。

为什么不能倒过来?如果先建 Order 和 Device,再补 User 和 Manufacturer,你会发现:

  1. Device 里的 manufacturerId 和 userId 无法被正确校验
  2. MQTT 设备上线后,无法判断数据属于哪个 User 或 Manufacturer
  3. 设备日志(DeviceLog)要记录归属关系,但缺少参考数据
  4. 后续添加用户和厂商时,已有的设备记录需要大规模回填或迁移

总结一句:数据的所有权明确之前,不要让设备开始说话

MQTT 主题设计:三层隔离

一旦用户、厂商、设备三个模型都存在了,MQTT 通信才真正能跑起来。

设计主题结构时,我采用三级层次:

toynet/device/{deviceSn}/status       # 设备上报状态
toynet/device/{deviceSn}/heartbeat    # 心跳
toynet/device/{deviceSn}/command      # 下发指令
toynet/device/{deviceSn}/response     # 指令响应
toynet/device/{deviceSn}/ai/audio     # 语音数据流

为什么是 deviceSn 而不是 deviceId?两个考虑:

  1. 硬件视角:SN(序列号)是设备的全球唯一标识,出厂时就确定。即使设备还没入库、还没绑定用户,SN 也是不变的。这样 MQTT broker 可以在设备连接时就验证 SN。
  2. 权限隔离:MQTT ACL(Access Control List)规则可以基于 SN 前缀做批量配置。比如"厂商 A 的所有设备 SN 以 TOY-A- 开头",就可以一条规则 toynet/device/TOY-A-+/+ 授予厂商 A 的连接账号。

再往上一层想,虽然主题里没有显式写 manufacturerId 或 userId,但通过 Device 模型的正向查询,MQTT 服务收到消息后可以:

  1. 解析 deviceSn
  2. 查库找到 Device 记录
  3. 获取 manufacturerId、userId、productId
  4. 校验消息来源(设备证书或 token)是否有权限修改这个设备

这样做的好处:

  • 设备独立性:设备只需要知道自己的 SN,不需要关心自己属于哪个用户
  • 厂商隔离:后台在 MQTT broker 层可以为每个厂商的账号设置主题权限
  • 灵活迁移:设备转移给另一个用户时,只需更新 Device.userId,MQTT 主题不变
  • 调试友好:指定 deviceSn 就能看完整的上报链路

指令下发与离线队列

设备接收指令通过 toynet/device/{deviceSn}/command 这个 topic。指令格式:

{
  "cmdId": "a1b2c3d4-e5f6-...",
  "cmd": "play_story",
  "params": { "storyId": "001" },
  "timestamp": 1712600000
}

每条指令都有唯一的 cmdId(UUID),用于追踪执行状态。指令类型包括播放故事、播放音乐、设置音量、运动控制等。

当设备离线时,server 端收不到 MQTT 连接,无法直接下发。但不能简单丢弃指令——应该先入库,标记为 pending,等设备上线后重新发送。这就需要一个 pending command queue,可以存在 Device.pendingCommands 字段或单独的 CommandQueue 表。

心跳机制是定位离线状态的基础:设备每 30 秒上报一次心跳,server 收到心跳就更新 Device.lastHeartbeat 和 status='online'。90 秒没收到心跳,自动标记 status='offline'。这样即使没有正式的连接状态通知,也能通过心跳超时推断设备状态。

从设计到运行

总结一下这个架构的关键约束:

  1. 所有权层必须先建:User、Manufacturer 决定后续数据的访问边界。
  2. 设备必须有归属:Device 必须绑定 manufacturerId(出厂时),后续可选绑定 userId(用户扫码时)。
  3. MQTT 主题用 deviceSn:保持设备的全局唯一性和权限隔离。
  4. 心跳定期刷新状态:不依赖复杂的连接管理,简单可靠。
  5. 指令带 ID 追踪:cmdId 让后续的 AI 对话模块(Plan 5)可以与指令执行绑定。

这个顺序不是教科书上的"标准",而是在这个具体项目里,设备数据要有明确的所有权和权限边界这个需求推导出来的。如果只有单一厂商、单一用户类型,顺序可以更灵活。但一旦要支撑多厂商入驻和用户私密性,底层模型的依赖关系就会很快锁死搭建顺序。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。