玩具联网项目需要同时支撑设备接入、厂商管理、订单与分销、小程序。搭建后端时,如果简单按"设备"→"产品"→"订单"的业务流程顺序实现,很快会碰到一个问题:设备连接上来上报数据,但不知道这个设备属于谁、属于哪个厂商,调试无从下手。
这不是代码问题,是架构依赖顺序的问题。
为什么顺序错了调试会卡住
设备上线后会通过 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,你会发现:
- Device 里的 manufacturerId 和 userId 无法被正确校验
- MQTT 设备上线后,无法判断数据属于哪个 User 或 Manufacturer
- 设备日志(DeviceLog)要记录归属关系,但缺少参考数据
- 后续添加用户和厂商时,已有的设备记录需要大规模回填或迁移
总结一句:数据的所有权明确之前,不要让设备开始说话。
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?两个考虑:
- 硬件视角:SN(序列号)是设备的全球唯一标识,出厂时就确定。即使设备还没入库、还没绑定用户,SN 也是不变的。这样 MQTT broker 可以在设备连接时就验证 SN。
- 权限隔离:MQTT ACL(Access Control List)规则可以基于 SN 前缀做批量配置。比如"厂商 A 的所有设备 SN 以
TOY-A-开头",就可以一条规则toynet/device/TOY-A-+/+授予厂商 A 的连接账号。
再往上一层想,虽然主题里没有显式写 manufacturerId 或 userId,但通过 Device 模型的正向查询,MQTT 服务收到消息后可以:
- 解析 deviceSn
- 查库找到 Device 记录
- 获取 manufacturerId、userId、productId
- 校验消息来源(设备证书或 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'。这样即使没有正式的连接状态通知,也能通过心跳超时推断设备状态。
从设计到运行
总结一下这个架构的关键约束:
- 所有权层必须先建:User、Manufacturer 决定后续数据的访问边界。
- 设备必须有归属:Device 必须绑定 manufacturerId(出厂时),后续可选绑定 userId(用户扫码时)。
- MQTT 主题用 deviceSn:保持设备的全局唯一性和权限隔离。
- 心跳定期刷新状态:不依赖复杂的连接管理,简单可靠。
- 指令带 ID 追踪:cmdId 让后续的 AI 对话模块(Plan 5)可以与指令执行绑定。
这个顺序不是教科书上的"标准",而是在这个具体项目里,设备数据要有明确的所有权和权限边界这个需求推导出来的。如果只有单一厂商、单一用户类型,顺序可以更灵活。但一旦要支撑多厂商入驻和用户私密性,底层模型的依赖关系就会很快锁死搭建顺序。
■