
写完了《糖球系列》第一篇之后后台这个“基石”在半中间其实已经立起来了。当时一堆硬件、网页、通知终端各写各的想想都觉得头大——既然是做“糖球”这套通知系统与其每个端维护一套逻辑不如直接抽出一套 API同时养活网页、ESP32、寻呼机三个完全不同的客户端。今天这篇就是记录我第二期的核心思路和踩坑过程一个后台 API怎么同时服务“看得见的网页”“看不见的硬件”和“只负责叫人的寻呼机”。这套东西非常适合个人全栈项目、硬核 DIY 爱好者以及想理解“前后端分离 多端复用”到底怎么落地的人。1. 为什么先搭后台三端共存的架构思路1.1 三端之间到底要共享什么做这套系统之前我先把需求拆了一遍。网页端要看历史消息、能管理设备、能手动推送一条通知ESP32 那边要上报温湿度、接收指令、按指令执行动作寻呼机更简单只要“收到一条消息然后响铃/亮屏”。三个端看起来差异很大但绕来绕去都在同一批数据上打转用户、设备、消息、任务。我之前见过不少项目网页端写自己的数据表ESP32 固件里也塞一套逻辑寻呼机又单独搞一套协议最后数据对不上调试靠猜。所以这一期我一开始就用了一个笨但稳的办法所有状态统一放后台所有端只做两件事——读接口、写接口。网页是这么干活ESP32 也是这么干活寻呼机本质上还是一个“特别轻量的客户端”。这样设计有一个明显的好处后面要加新端比如微信小程序、语音助手不用再动数据模型只要照着 API 文档写一个客户端就行。这也就是“一套 API 养活多个端”的核心含义。1.2 一个后台的划分API 层、数据层、消息层后台听着复杂实际拆开就是三块接口层、存储层、消息层。接口层对外暴露 RESTful API负责登录、查询、下发指令存储层我直接用 SQLite 起步一张用户表、一张设备表、一张消息表、一张命令表够用且方便备份消息层单独拎出来是因为 ESP32 和寻呼机并不总是适合频繁轮询它们更适合订阅消息通道。我见过有人把 MQTT broker 也叫做“API”其实概念不完全一样。API 是同步的请求响应消息层是异步的推送。我最后的设计是同步接口处理管理操作异步消息通道处理设备通知。网页端调POST /api/v1/messages发送消息后台先写库再往消息队列丢一条任务寻呼机订阅的主题收到内容ESP32 也从自己的主题里拿到指令。这样即使某一台设备离线消息也还是落库了等它上线再补发。1.3 技术选型FastAPI 是我这几期下来的一个顺手选择接口层我一开始考虑过 Express 和 Spring Boot最后选了 FastAPI。原因很个人自动生成 OpenAPI 文档太香了ESP32 和网页端两个小团队其实就我自己对着 Swagger 页面就能把字段抠明白不用额外维护文档。异步支持也顺手后台要往消息队列推东西时async def写起来很自然。不过如果你想用 Node.js也完全没问题。核心不在框架在于接口设计规范。我在设计 API 时定的原则是路径必须有/api/v1前缀所有返回统一包一层{ code: 0, data: ..., message: ok }错误码有明确编号。这样的好处是不管是网页的 Axios、ESP32 的 HTTPClient还是寻呼机里极简的请求代码解析逻辑都能写得特别简单。2. 核心 API 设计与鉴权让三个端都认得同一个 Token2.1 数据模型设计用户、设备、消息、命令这一期我先画了几张表做系统最忌讳上来就写接口表结构稳了接口就是增删改查。用户表存登录账号和密码哈希设备表存设备标识、设备类型web、esp32、pager、在线状态、最后心跳时间消息表存发送方、接收方、内容、状态命令表存需要 ESP32 执行的指令比如开灯、重启、查询传感器。设备表里我比较得意的一个字段是last_seen_at。ESP32 和寻呼机每隔一段时间上报心跳网页端通过这个字段就能知道设备是在线还是离线。判断机制其实很简单如果last_seen_at距今超过 5 分钟就认为离线。这个“软状态”不依赖 TCP 长连接在设备网络不稳定的环境下反而更可靠。消息表也做了一个状态机pending-delivered-read。网页发消息后状态是pending寻呼机收到并确认后变delivered用户按键查看后变成read。这一步看着细但后面排查“寻呼机明明收到了怎么没响”时直接看消息状态就能定位。2.2 接口划分与统一返回结构我实际提供的接口不算多但每个都有明确分工模块接口用途认证POST /api/v1/auth/login登录获取 Token认证POST /api/v1/auth/refresh刷新 Token设备GET /api/v1/devices设备列表/状态设备POST /api/v1/devices/{id}/heartbeat设备心跳上报消息GET /api/v1/messages拉取历史消息消息POST /api/v1/messages给指定设备发消息命令GET /api/v1/devices/{id}/commandsESP32 拉取待执行命令命令POST /api/v1/commands/{id}/ack命令执行结果回执所有接口都走 JSON时间统一用 ISO 8601 字符串比如2025-01-20T10:30:00Z。ESP32 端用time_t和 NTP 同步时间后可以直接转成这种格式网页端new Date()一解析就完事避免在不同端之间处理时间格式差异。统一返回结构是这么约定的{ code: 0, data: {}, message: ok }code非 0 表示业务失败。比如密码错误返回code: 1001token 过期返回code: 1002。HTTP 状态码我也会配合用但真正做业务判断以返回体里的code为准因为有些 HTTP 客户端比如部分嵌入式 HTTP 库处理非 200 状态码时行为不一致统一 200 业务码能减少兼容性问题。2.3 Token 鉴权和常见的“失效”问题三端共用一个鉴权体系我选的是 JWT。为什么不是传统的 Session因为 ESP32 和寻呼机上不想存一堆会话数据JWT 是自包含的后台重启后设备也不用重新登录。实际方案是登录成功后返回access_token有效期 2 小时和refresh_token有效期 30 天硬件端用access_token放在请求头里Authorization: Bearer token。不过这里有个血的教训网页端和 ESP32 端拿到 token 后必须把“token 过期”当成正常流程处理不能当成系统故障。我第一次调试时ESP32 连续跑了六小时突然开始报401我还以为是网络问题后来看日志才发现是 token 过期了。后来我在硬件端写了一个规则遇到401自动用refresh_token换新 token换完重试一次原请求。网页端则是用 Axios 拦截器干这事。类似地我在排查一些第三方系统时会看到login failed. check api token or gitlab version这类报错其实就是 token 对不上或者后端版本不匹配导致的鉴权失败。自己写接口时养成规范返回code: 1002的习惯别人接的时候就不会对着日志猜谜。2.4 错误码规范与限流细节错误码如果没规范三端排障就得翻代码。我定了一套code含义处理建议0成功正常流程1001用户名或密码错误提示用户检查凭据1002Token 无效或过期自动刷新或重新登录1003没有权限操作该设备检查设备归属2001设备不存在或已下线刷新设备列表2002消息发送失败查看消息状态和重试策略4000参数错误检查请求体字段429请求过于频繁按Retry-After头等待限流也必须做尤其是 ESP32 或寻呼机如果出现 bug可能会疯狂请求接口。我在后台加了一个简单的滑动窗口限流每个设备每分钟最多 60 次请求超过就返回 429。实际运行中某台 ESP32 的 Wi-Fi 重连逻辑写崩了每 3 秒重启一次网络栈每次都触发请求直接触发限流。没有这层保护时后台日志会被刷爆。3. ESP32 接入让硬件设备“听后台的话”3.1 准备阶段固件、开发环境和烧录ESP32 接入我用的 Arduino 框架因为生态成熟库好找。在 Arduino IDE 里安装 esp32 开发板包时我踩过一个经典坑FQBN 参数不对导致烧录失败提示什么using board esp32s3 from platform in folder然后卡住。后来换成 PlatformIO板子型号在platformio.ini里写清楚就没再折腾过。如果你用的 Arduino IDE记得在“开发板管理器”里装好 esp32 包后按钮选择板型时要选对具体模组比如ESP32S3 Dev Module不要选成ESP32 Dev Module。网络连接部分最简单的就是WiFi.begin(ssid, password)但有个细节容易被忽视WiFi 连接成功后要等WiFi.status()稳定。我一开始不等待就发 HTTP 请求前几次总是超时后来改成循环检查WL_CONNECTED并且加上 20 秒超时问题立刻消失。3.2 你的第一个 HTTP 请求轮询还是长连接ESP32 拉取指令我第一版做的是最简单的轮询每 30 秒请求一次GET /api/v1/devices/{id}/commands。后台有命令就返回没有就返回空数组。这样做优点是真简单HTTPClient 发一条请求、ArduinoJson 解析响应就完事不需要引入额外的协议库。但轮询有个问题一条指令从网页端点击到 ESP32 执行最长可能要等 30 秒。如果设备是控制灯、控制风扇这种延迟还能接受但如果是紧急开关体验就很差。所以第二版我引入了 MQTT后台命令下发时同时往 MQTT 主题推一条消息ESP32 订阅对应主题能做到秒级响应。HTTP 轮询作为保底方案继续保留防止 MQTT 断开后设备“失聪”。硬件端代码骨架大致是这样#include WiFi.h #include HTTPClient.h #include ArduinoJson.h const char* ssid your-ssid; const char* password your-pass; const char* token your-access-token; const char* deviceId esp32-01; void fetchCommands() { HTTPClient http; http.begin(http://your-server/api/v1/devices/ String(deviceId) /commands); http.addHeader(Authorization, Bearer String(token)); int httpCode http.GET(); if (httpCode 200) { JsonDocument doc; deserializeJson(doc, http.getString()); // 遍历 data 数组执行命令回传 ack } else if (httpCode 401) { // token 过期走刷新逻辑 } http.end(); }注意JsonDocument在 ArduinoJson 最新版里的用法固定容量文档StaticJsonDocument容易溢出导致解析失败。我的消息体不算大但如果 JSON 里包含较长的设备和时间字符串建议用JsonDocument并且引入ArduinoJson7.x 版本它会自动管理内存减少踩坑。3.3 断线重连与低功耗ESP32 在家里用还好一旦放到信号弱的位置断线是家常便饭。重连逻辑我写了三档退避第一次 5 秒重试第二次 15 秒第三次以后固定 60 秒。这样既不会把后台频率打爆也不会在信号恢复时等待太久。重启多了你会发现 ESP32 的 flash 写入次数也是有限的频繁ESP.restart()不是好方案尽量先尝试重连 WiFi、重连 MQTT最后才重启。低功耗方面当 ESP32 作为传感器节点没必要一直全速跑。我用esp_sleep_enable_timer_wakeup(60 * 1000000)让设备每 60 秒醒来一次上报温湿度后继续睡。这里有个容易踩的坑开启深度睡眠后Wi-Fi 连接会断开每次醒来重新连接大概要花 3~5 秒费电反而不少。后来我改用 modem sleep 模式Wi-Fi 保持连接但射频按需工作实测电流比一直满速跑省了大约 40%。OTA 升级也是这期顺手加的。如果你给设备部署了一个新功能总不能每回都拔 USB 线刷。ESP32 支持 HTTP OTA后台可以给设备下发“新固件地址”设备下载后写到 OTA 分区重启完成更新。不过 OTA 最怕下载到一半断网我建议固件里做版本校验和回滚标记新固件启动 30 秒内主动上报在线状态如果后台没收到说明升级失败回滚到旧分区。4. 网页端接入Vue3 后台管理系统的前后端协作4.1 请求封装、登录态和路由守卫网页端我用的 Vue3 Vite Pinia这组合是目前搭后台管理系统最顺手的之一。所有请求都通过 Axios 实例发出去baseURL指向后台地址请求拦截器里统一带上Authorization头。响应拦截器里判断code如果等于 1002就调 refresh 接口换 token换完自动把原请求重发一次。登录页调POST /api/v1/auth/login成功后把 access_token 和 refresh_token 存到 localStorage。路由守卫在跳转到需要登录的页面之前检查 token 是否存在如果不存在就跳回登录页。到这里都属于套路真正要注意的是不要只靠“token 存在”判断登录态因为 token 可能已经过期。我一般会在 Pinia 里存一个用户信息页面加载时调GET /api/v1/auth/me校验 token 是否有效无效就清理本地状态并跳转登录。4.2 实时数据展示轮询 / SSE / WebSocket 的选择网页端要显示设备实时状态和消息记录我第一版用的是定时轮询5 秒拉一次设备列表和消息列表。数据量小的时候完全够用代码也简单。但后来设备多了5 秒一次的请求会让后台压力变大而且页面切换时如果没清理定时器还会出现内存泄漏。后面我把设备状态改成了 SSEServer-Sent Events。SSE 本质是后台往浏览器单向推流网页端只需要一个EventSource实例不需要像 WebSocket 那样处理握手、心跳、二进制帧。设备心跳更新时后台通过 SSE 推一条device_online事件前端收到后更新页面数据体验非常顺滑。如果下一个版本要做双向交互网页直接操控 ESP32 执行命令我再考虑升级成 WebSocket。但从“后台管理系统”这个定位看SSE 的性价比已经很高了。4.3 前端请求常见报错跨域和请求体超限前后端分离开发时跨域是绕不开的坎。后台需要配置 CORS 允许前端域名重点不是只加Access-Control-Allow-Origin还要允许Authorization头。我排查过一个诡异现象GET 请求一切正常POST 请求却报跨域错误。原因就是浏览器会先发一个 OPTIONS 预检请求后台没处理 OPTIONS 返回 200。另外一类问题是请求体过大导致后端直接返回 400就像一些大模型 API 会报this models maximum context length is 1048576 tokens一样超出上限就拒绝。我在后台也设了请求体大小限制前端上传大 JSON 时要分段否则就会看到莫名其妙的 400。5. 给寻呼机发一条消息一套 API 如何触达低功耗终端5.1 寻呼机是啥我这里指的是一种接收通知的迷你终端严格来说这套系统里的“寻呼机”并不是传统无线电 POCSAG 寻呼机而是一块只有巴掌大小、专门用来“收消息并提示”的低功耗设备。你可以叫它通知终端、呼叫器但我还是喜欢叫寻呼机因为它干的事和老 BP 机一模一样有人给你发一条消息它响铃震动亮屏提醒你“有事”。我做的寻呼机硬件是 ESP32-C3 一块墨水屏 一个无源蜂鸣器 一个按键。小、省电、安静适合摆在桌面上当第二屏幕。收到消息后在墨水屏上显示文字蜂鸣器响两声用户按一下按钮确认已读。整套设备不需要持续高功耗显示只在消息到达时刷新墨水屏所以电池续航可以做到两三个星期。5.2 接入方案消息表 队列 推送网页端发送消息的流程是这样的网页调用POST /api/v1/messages参数是target_device_id和content。后台校验参数写入消息表状态为pending。后台往消息队列我用的是一套 Redis Stream其实也可以用 RabbitMQ发一条 event。寻呼机通过 MQTT 订阅pager/{device_id}/notify主题收到消息后显示并发出已读回执。寻呼机收到消息后会调用POST /api/v1/messages/{id}/ack把状态改成delivered用户按键确认查看前端页面再调POST /api/v1/messages/{id}/read状态变成read。通过消息状态机就能清楚看到一条消息到底是被推送到了设备还是已经被用户看到。这对排查“寻呼机没响”特别有用——如果状态一直停在pending说明 MQTT 推送失败如果停在delivered说明设备收到了但用户还没确认。5.3 寻呼机低功耗策略和“收到就刷屏”的实现寻呼机平时能睡就睡但它又要随时等待消息这里我用了一个折中方案ESP32 保持 MQTT 连接但开启 modem sleep有消息进来时从浅睡眠中唤醒刷新墨水屏然后继续睡。实测待机电流降到 40mA 左右比一直满功率跑省了一半以上。墨水屏刷一次大概要 2 秒期间如果又收到新消息我会合并显示而不是每条都刷一次屏幕。这个细节一开始没做有次连续来三条消息寻呼机屏幕刷了三遍蜂鸣器也响了三遍看着非常蠢。改成 5 秒内的消息合并展示后体验一下子正常了。5.4 消息重复问题和去重MQTT 的 QoS 1 能保证消息至少到达一次但可能重复。寻呼机收到同一消息 ID 多次时不能每次都刷新屏幕和响铃。我在设备端维护了一个最近 20 条已处理消息 ID 的环形缓冲区收到消息先判断 ID 是否已存在存在就直接忽略。这个机制对 ESP32 处理 MQTT 重复投递特别关键不然一个消息被后台重试两次用户就会听到三遍铃声。6. 实操中的常见问题与排查笔记6.1 API 层Token 失效、CORS 和长请求超时这一周调试下来API 层最常遇到的就是 Token 失效。网页端还好有拦截器自动刷新ESP32 端经常因为长期不重启导致 access_token 过期我后来在固件里写了一个变量记录获取 token 的时间距离现在超过 90 分钟就主动刷新一次而不是等到 401 再处理。CORS 的问题上面提了重点说一下后台对 OPTIONS 请求的处理。我在 FastAPI 里加了 CORSMiddleware但要注意allow_headers里必须包含Authorization只默认那几条会拦截到自定义头。长请求超时也曾坑过我一次。某个接口需要查一个月内所有消息并按设备分组统计数据量一上来处理时间超过 5 秒前端 Axios 默认超时时间直接断掉。后来我加了两个优化一个是为慢接口增加异步任务另一个是调整前端超时到 30 秒。核心原则是浏览器可以等但接口得明确告诉前端自己在干活。对可能超过 10 秒的操作最好改成先提交任务再轮询任务状态。6.2 ESP32 层连不上网、内存不足、时间不对ESP32 连不上 Wi-Fi 大多数时候不是代码问题而是 Wi-Fi 信号功率或频段问题。我遇到过 5GHz 路由器只开了 5GHz 频段ESP32 只支持 2.4GHz折腾半天搜不到网络。所以如果你用的路由器支持双频合一建议给物联网设备单独开一个 2.4GHz 的 SSID。内存不足也是一个高频坑。Arduino 环境里String用多了堆碎片会导致莫名其妙的 crash。我后来把所有的 HTTP 响应解析改用固定长度的char数组加ArduinoJson的流式解析内存占用稳定了很多。另外deserializeJson失败时不要直接忽略打印一下错误类型你会惊讶地发现很多问题其实是后台返回的 JSON 结构跟预期不一致。时间不对会影响消息排序和日志排查。ESP32 启动后先configTime(0, 0, pool.ntp.org)同步 NTP 时间我发现在国内网络环境某些默认 NTP 服务器可能响应慢可以用多家 NTP 地址轮询。时间同步成功之前设备上报的数据在后台展示会有 8 小时左右的偏差一开始我还以为是数据库时区问题查了半天才发现是 ESP32 时区没设对。6.3 消息可靠性丢消息、重复消息、乱序这套系统里消息链路较长网页 - API - 消息队列 - MQTT - 寻呼机。任何一环断了都会丢消息。我的排查方法是在后台给每个消息生成一个全局唯一 ID并且在整个链路日志里带上这个 ID。寻呼机收到消息后上报的 ack 也带这个 ID这样用grep一条链路就能看出到底断在哪儿。乱序问题主要出在重试上。MQTT 在弱网环境下客户端可能先收到后发的消息再收到先发的旧消息重试。我在寻呼机端加了一个简单的时间戳比较如果新消息的时间戳小于当前已显示消息的时间戳就当成过期消息忽略。除非场景需要严格顺序比如多步指令否则这招够用。6.4 一个让我印象深刻的排查案例有次用户反馈网页上显示 ESP32 离线但设备明明在运行。查了设备心跳记录发现最后一次心跳是 3 分钟前离我的 5 分钟离线阈值还差一点。又过两分钟再看页面变成离线。结果再仔细一查不是设备挂了是设备在深度睡眠根本不会上报心跳只是偶尔醒一次采集数据。所以离线状态对“间断休眠设备”天然不友好。后来我给设备表加了sleep_enabled字段如果设备说明自己支持休眠后台就把离线判断阈值从 5 分钟放宽到 15 分钟并在网页上显示“睡眠中”而不是“离线”。这个案例让我意识到状态判断必须跟着设备类型走不能一套规则套所有设备。网页端永远在线ESP32 传感器可能 60 秒醒一次寻呼机两天醒一次它们的last_seen_at阈值应该完全不同。一些个人小感受整套做下来最深的体会是后台 API 不是一个“写完了就没事”的东西而是要反复根据三端的实际反馈去调整。最早我设计消息接口时只考虑网页端字段比较随意等寻呼机接入时发现它需要额外的message_id和timestamp字段又回来改接口。早知如此最开始就应该把接口当“公共契约”来设计一次性考虑所有客户端的差异。如果让我重新来一次我会先花一小时把接口文档和字段约束写清楚再动手写代码。虽然感觉像是慢了但后面几个端联调时基本没有因为字段打架返工反而赚回来了。“糖球系列”下一期我打算把 MQTT 和 HTTP 之间的线程模型、消息去重和回调机制再往深挖一挖。如果你也在做类似的多端通知系统建议先把 API 立稳再往外面长网页、长硬件后台果然是基石。