简介:智慧农场小程序完整版面向农业信息化开发者、小程序学习者及有二次开发需求的农业企业,提供一套可直接定制改造的微信小程序源码,覆盖种植计划、环境监测、远程控制与数据查看等典型场景。压缩包共1646个文件,整体约28.41MB,以php、dat、png、html、js为主,辅以wxss、wxml、json等小程序结构文件及config配置、functions函数、ttf字体等资源,兼顾后端接口、页面样式与静态素材,目录分层便于按模块查找。已有4732人学习,说明其在农业小程序实战中具备一定参考价值。读者可从中了解微信小程序技术栈与页面组织方式,并借助源码中的物联网数据交互、GIS可视化、数据库连接、云服务接口、实时通讯和权限控制等实现思路,完成功能梳理、界面调整或二次开发,也可作为课程设计与农业信息化项目的参考底稿。
1. 智慧农场小程序完整版到底能跑出什么
智慧农场小程序完整版这套代码,我第一次跑的时候踩了个不大不小的坑——页面全渲染出来了,温湿度曲线也漂亮,但底下挂的是写死的假数据,接上真实数据库后折线直接拉平。后来才想明白,这类资源的价值不在界面做得多好看,而在地块、种植批次、环境采集记录这三层数据怎么串成一条线。它解决的是中小农场“有传感器没台账、有台账没看板”的老问题:棚里的空气温湿度、土壤墒情按十分钟一条落库,小程序端按地块看实时值和 24 小时曲线,超阈值标红,种植批次和溯源信息挂在同一个地块 ID 上。手里已经有硬件、或者打算接硬件的开发者,做农业物联网方向的课程设计,想快速搭一套演示环境去对需求的团队,都能直接从这套结构上改。
2. 三层结构拆解:小程序端、服务端、数据库各自管什么
一套农场系统最容易散架的地方,是职责没划清:小程序端直接拼 SQL 思路、服务端只做转发、数据库里塞了一堆冗余字段。完整版把边界切得比较干脆,小程序端只负责展示和交互,所有判定逻辑放服务端,数据库只保证写入幂等和查询索引。
2.1 客户端只做三件事
小程序端在整套系统里的定位是“轻客户端”,它不该知道阈值怎么算、不该知道一条记录该不该入库。拆开看,它只干三件事:
- 拿地块列表和地块详情,渲染成卡片和图表;
- 把用户的操作(新增种植批次、手动补录一条环境数据、切换时间范围)打成请求发出去;
- 对服务端返回的标准结构做展示层加工,比如把
soil_hum按阈值染成红绿。
这个边界一旦模糊,代码就会迅速腐化。我见过不少改过的版本,在页面里写if (soil_hum < 35)这种判断,后来农场换了作物、阈值跟着变,得挨个页面改。正确做法是阈值跟着“作物批次”走,服务端在返回数据时顺带把threshold一起下发,页面只负责比对和上色。
请求层我一般会抽一个统一封装,把 baseURL、超时、错误提示、登录态注入都收在一处,页面里只写路径和参数。这样后面换域名、加签名、上重试,都只改一个文件。
// utils/http.js // BASE 必须是你自己已备案的 HTTPS 域名,小程序的合法域名校验绕不过去 const BASE = 'https://farm.example.com'; const TOKEN_KEY = 'farm_token'; function request(path, params = {}, method = 'GET') { return new Promise((resolve, reject) => { wx.request({ url: BASE + path, data: params, method, timeout: 8000, // 农场现场网络差,超时别设太短,8s 是个折中 header: { 'content-type': 'application/json', // 登录后写入 storage,这里每次读取,避免缓存在内存里过期 Authorization: wx.getStorageSync(TOKEN_KEY) || '' }, success(res) { // 约定:HTTP 200 且 code === 0 才算业务成功 if (res.statusCode === 200 && res.data && res.data.code === 0) { resolve(res.data); } else if (res.statusCode === 401) { wx.removeStorageSync(TOKEN_KEY); wx.navigateTo({ url: '/pages/login/login' }); reject(new Error('登录已失效')); } else { reject(new Error((res.data && res.data.msg) || '接口异常')); } }, fail: reject }); }); } module.exports = { request };这里三个参数值得说一下。timeout设 8000ms,是因为农场大棚里的 4G 信号普遍不稳,设 3s 会出现大量假失败;Authorization每次从 storage 读而不是缓存在模块变量里,是为了让退出登录能立刻生效;code约定成 0 表示成功,比用 HTTP 状态码区分业务错误更省事,因为小程序端拿不到那么多语义。
2.2 服务端承担判定与聚合
服务端的关键词是“判定”和“聚合”。判定指阈值比较、设备在线状态、批次是否超期;聚合指把高频的原始采集数据按时间粒度压成图表能直接用的数组。原始表十分钟一条,一天就是 144 条,一个月 4320 条,直接全量丢给小程序端画图,网络和渲染都吃不消。
常见做法是在查询时按FLOOR(UNIX_TIMESTAMP(collected_at) / 600)分组取平均,接口层支持hours和step两个参数,前端传什么粒度就返回什么粒度。这样不用额外建汇总表,代价是数据量大以后查询变慢,超过百万行再考虑加物化表或者按天分区。
另一个职责是写入幂等。设备上报最怕重复:断网重连补传、网关重试、调试时手工重发,都会产生重复记录。解决办法是把(device_sn, collected_at)做成唯一索引,写入用ON DUPLICATE KEY UPDATE,重复上报只更新数值不新增行。这条经验是踩过坑换来的,早期没加唯一键,一个月的曲线图上出现了一串垂直的尖刺,排查半天才发现是同一时刻写了两条。
2.3 数据库只留六张核心表
完整版的表结构不算多,去掉日志和配置,业务核心就是六张:
| 表名 | 作用 | 关键字段 | 索引策略 |
|---|---|---|---|
farm_plot | 地块主数据 | plot_no、area_mu、soil_type | plot_no唯一 |
crop_batch | 种植批次 | plot_id、crop_name、plant_date、status | plot_id普通索引 |
device | 设备档案 | sn、plot_id、type、last_online | sn唯一 |
env_record | 环境采集明细 | device_sn、plot_id、各项指标、collected_at | (plot_id, collected_at)联合索引 |
alarm_log | 告警记录 | plot_id、metric、value、level | (plot_id, created_at) |
trace_record | 溯源节点 | batch_id、node_type、content | batch_id普通索引 |
这里有个取舍要讲清楚:env_record里同时冗余了device_sn和plot_id。按范式说,plot_id能从device表关联出来,不该冗余。但实际查询几乎全是“按地块查最近 24 小时”,每次 join 一次设备表,在千万行级别就是明显的开销,而且设备换地块后历史数据仍应归属原地块。冗余这一个字段,换来的是查询路径短一半,值得。
soil_type、status这类枚举用TINYINT存,不用字符串。原因很实际:字符串在索引里占空间,而且拼错一个字母就要全表扫。枚举含义写在字段注释里,前端维护一份常量表映射成中文。
3. 本地跑通:从建库到真机预览的完整六步
拿到代码先别急着改业务,按顺序把“能登录、能看列表、能看到一条曲线”跑通,比一上来就读源码高效得多。下面这套顺序是我反复验证过的,中间任何一步跳过,后面都会以更隐蔽的方式报错。
3.1 服务端依赖与配置项
服务端是 Node.js 那一套,进目录先装依赖。注意 Node 版本别太低,mysql2的 Promise 接口和可选链语法在 14 以下会出问题,建议 18 LTS 起。
# 进入服务端目录 cd server # 安装依赖,用 lock 文件保证版本一致 npm ci # 复制一份本地配置,不要直接改 .env.example cp .env.example .env # 启动开发模式,改代码自动重启 npm run dev配置项集中在.env里,几个必须改的:
# server/.env PORT=3000 DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=farm DB_PASS=farm_dev_2024 DB_NAME=smart_farm JWT_SECRET=change_this_to_a_long_random_string UPLOAD_DIR=./public/uploadsJWT_SECRET千万别留默认值,这就是个后悔药——上线后忘了改,任何人都能自己签一个管理员 token 出来。UPLOAD_DIR用相对路径,部署到不同机器时不用跟着改。
数据库连接池的配置也在这个环节定下来,写死在db.js里:
// server/db.js const mysql = require('mysql2/promise'); const pool = mysql.createPool({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT || 3306), user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 20, // 小程序并发不高,20 足够;再多要和 MySQL 的 max_connections 对账 queueLimit: 0, // 0 表示不限制排队,超时交给上层处理 timezone: '+08:00', // 写库读库都按东八区,避免 DATETIME 偏移 decimalNumbers: true // DECIMAL 直接返回 number,前端比较不用 Number() 转 }); module.exports = pool;decimalNumbers: true这个参数容易被忽略。MySQL 的DECIMAL默认返回字符串,前端拿到"35.20"去和35比较,"35.20" < 35结果是 false,阈值判定直接失效,而且不会报错,属于典型的黑匣子问题。
3.2 建库、导表、灌种子数据
数据库这步有三条命令,顺序不能反。
# 1. 建库并指定字符集,别用默认的 latin1 mysql -u root -p -e "CREATE DATABASE smart_farm DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导入表结构,脚本里已经带 DROP TABLE IF EXISTS,可重复执行 mysql -u root -p smart_farm < sql/schema.sql # 3. 灌入演示数据:3 个地块、5 个批次、近 24 小时的模拟采集记录 mysql -u root -p smart_farm < sql/seed.sql建表脚本里最容易出问题的是env_record,贴一下核心部分:
CREATE TABLE `env_record` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `device_sn` VARCHAR(32) NOT NULL COMMENT '设备序列号', `plot_id` INT UNSIGNED NOT NULL COMMENT '冗余地块ID,避免查历史时 join', `air_temp` DECIMAL(5,2) DEFAULT NULL COMMENT '空气温度 ℃', `air_hum` DECIMAL(5,2) DEFAULT NULL COMMENT '空气湿度 %RH', `soil_temp` DECIMAL(5,2) DEFAULT NULL COMMENT '土壤温度 ℃', `soil_hum` DECIMAL(5,2) DEFAULT NULL COMMENT '土壤含水率 %', `lux` INT DEFAULT NULL COMMENT '光照 lx', `collected_at` DATETIME NOT NULL COMMENT '采集时刻,设备侧时间', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入库时刻', PRIMARY KEY (`id`), UNIQUE KEY `uk_sn_time` (`device_sn`, `collected_at`), KEY `idx_plot_time` (`plot_id`, `collected_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='环境采集明细';三个设计点:uk_sn_time保证同一设备同一时刻只有一条,配合后面的ON DUPLICATE KEY UPDATE实现幂等;idx_plot_time是为“按地块按时间范围”这个最高频查询准备的,联合索引的顺序不能反,把plot_id放前面才能命中;created_at和collected_at分开,是为了排查“设备时钟漂移”这类问题时有据可依。
种子数据里我一般会把时间造得分散一点,跨过整点和零点,这样曲线不会是一条直线,也方便验证时区问题有没有踩到。
3.3 小程序端配置与真机预览
小程序端进目录后先改三处配置,都在config.js和project.config.json里。
// miniprogram/config.js module.exports = { // 开发者工具里可以填 127.0.0.1:3000,但真机预览必须是 HTTPS 域名 baseUrl: 'https://farm.example.com', // 图表默认展示时长,单位小时 defaultHours: 24, // 页面一次拉取的原始点上限,超过会让后端降采样 maxRawPoints: 500 };真机预览这一步,血泪经验是:开发者工具里勾了“不校验合法域名”,http://192.168.x.x:3000能跑通,但手机上一扫码就白屏。原因是真机走的是正式校验链路,必须是备案过的 HTTPS 域名,局域网 IP 一概不认。调试期常见做法是在服务器上挂个证书、把服务端部署上去,或者用一个临时的 HTTPS 转发地址,但别想着绕过校验上线。
配置改完,扫码进小程序,第一次会跳登录页。演示账号在seed.sql里有,角色分普通农户和管理员两种,权限校验在服务端中间件里做,小程序端只根据返回的role决定菜单显示,不做真正的拦截——前端隐藏按钮只是体验,不构成安全边界。
3.4 用 Postman 先验证接口,再回来看页面
跑通页面后,如果数据不对,先别在页面上打断点,直接用接口工具打一遍。
# 登录拿 token curl -X POST https://farm.example.com/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"demo","password":"demo123"}' # 用返回的 token 查最近 24 小时数据,step=10 表示 10 分钟一个点 curl "https://farm.example.com/api/env/history?plotId=1&hours=24&step=10" \ -H "Authorization: Bearer <上一步返回的 token>"返回结构里如果data是空数组,优先查三件事:地块 ID 对不对、collected_at是否落在时间窗口内、数据库连的是不是同一个库。这三个占了我遇到过的空数据问题的八成以上。
4. 核心链路:地块、批次、环境数据怎么串起来
这套代码真正值得看的是数据怎么流动,界面反而是最容易替换的部分。一条完整链路是:设备把采集报文发到上报接口,接口校验后幂等写库,同时比对该批次的阈值配置,超限写告警;小程序端按地块拉最新值和历史曲线,把告警字段渲染成红色角标。
4.1 三类实体的关系与查询路径
关系很直白:一个地块上可以有多茬种植批次,一个地块可以挂多台设备,一台设备产生多条采集记录。查询路径有两条主干:
- 实时看板:
plot_id→ 最新一条env_record+ 当日告警数 + 当前批次信息; - 历史曲线:
plot_id+ 时间范围 → 按粒度聚合的env_record数组。
第一条走ORDER BY collected_at DESC LIMIT 1,在idx_plot_time上是一次索引反向扫描,很快。第二条如果直接GROUP BY,在数据量上来后要扫很多行,所以接口层加了个step参数,用时间戳整除做分桶,把聚合压力下推到数据库。
4.2 上报接口:从原始报文到入库
设备侧发来的报文格式各家不同,服务端要做的是把它规整成统一结构再落库。完整版的上报接口支持单条和批量,批量上限 200 条,是为了防住网关断网后一次性补传几千条把连接池打满。
// server/routes/env.js const express = require('express'); const router = express.Router(); const pool = require('../db'); const { checkThreshold } = require('../service/alarm'); // POST /api/env/report 设备上报,走设备密钥校验,不走用户 token router.post('/report', async (req, res) => { const { sn, plotId, list } = req.body; if (!sn || !plotId || !Array.isArray(list) || list.length === 0) { return res.status(400).json({ code: 400, msg: 'sn / plotId / list 不能为空' }); } if (list.length > 200) { return res.status(413).json({ code: 413, msg: '单次最多 200 条' }); } // 统一转成二维数组,mysql2 的批量插入需要这个形状 const rows = list.map(it => [ sn, Number(plotId), it.airTemp ?? null, it.airHum ?? null, it.soilTemp ?? null, it.soilHum ?? null, it.lux ?? null, new Date(it.collectedAt) // 约定客户端传 ISO8601 字符串,如 2025-03-01T08:10:00+08:00 ]); const sql = ` INSERT INTO env_record (device_sn, plot_id, air_temp, air_hum, soil_temp, soil_hum, lux, collected_at) VALUES ? ON DUPLICATE KEY UPDATE air_temp = VALUES(air_temp), air_hum = VALUES(air_hum), soil_temp = VALUES(soil_temp), soil_hum = VALUES(soil_hum), lux = VALUES(lux)`; try { const [result] = await pool.query(sql, [rows]); // 落库成功后异步做阈值判定,失败不影响上报返回 checkThreshold(plotId, list).catch(e => console.error('[alarm]', e)); res.json({ code: 0, data: { received: list.length, affected: result.affectedRows } }); } catch (e) { console.error('[env.report]', e); res.status(500).json({ code: 500, msg: '写入失败' }); } }); module.exports = router;几个参数得说清楚。sn是设备序列号,服务端会拿它去device表校验归属,不在册的设备直接拒绝,这段代码略去了校验中间件。collectedAt强制要求带时区的 ISO8601 字符串,不许传2025-03-01 08:10:00这种裸时间,因为裸时间在不同运行环境下会被解析成不同时区,这个坑后面单独讲。affectedRows在ON DUPLICATE KEY UPDATE下返回的是“插入数 + 更新数 × 2”,所以看到 200 条数据返回 400 不要慌,这是 MySQL 的既定行为。
checkThreshold放到响应之后异步执行,是权衡的结果:告警判定要查批次配置、要写告警表、要发订阅消息,链路比写库长得多;设备端等不起,超时重传又会带来新的重复。
历史查询接口的降采样部分:
// GET /api/env/history?plotId=1&hours=24&step=10 router.get('/history', async (req, res) => { const plotId = Number(req.query.plotId); const hours = Math.min(Number(req.query.hours) || 24, 24 * 30); // 最多 30 天 const step = Math.max(Number(req.query.step) || 10, 1); // 最小 1 分钟 const sql = ` SELECT FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(collected_at) / (? * 60)) * (? * 60)) AS bucket, ROUND(AVG(soil_hum), 2) AS soil_hum, ROUND(AVG(air_temp), 2) AS air_temp, ROUND(AVG(air_hum), 2) AS air_hum FROM env_record WHERE plot_id = ? AND collected_at >= DATE_SUB(NOW(), INTERVAL ? HOUR) GROUP BY bucket ORDER BY bucket ASC`; const [rows] = await pool.query(sql, [step, step, plotId, hours]); // 统一输出毫秒时间戳,前端不用再猜时区 res.json({ code: 0, data: rows.map(r => ({ t: new Date(r.bucket).getTime(), soilHum: r.soil_hum, airTemp: r.air_temp, airHum: r.air_hum })) }); });hours上限压到 720 小时,step下限压到 1 分钟,是为了防止有人传hours=99999把数据库拖垮。分桶算法用FLOOR(UNIX_TIMESTAMP / (step*60)) * (step*60),本质是把时间戳按粒度向下取整,同一桶内的数据做平均。输出统一转成毫秒时间戳是刻意为之——只要接口层把时区问题终结掉,前端就永远不会遇到。
4.3 小程序端的图表数据整形与阈值着色
页面拿到数据后不是直接丢给图表组件,中间要做一层整形:按当前批次下发的阈值,给每个点算一个颜色,同时把超限点单独挑出来做标记。
// pages/plot/detail.js const { request } = require('../../utils/http'); Page({ data: { plotId: 0, latest: null, series: [], // [{ t, v, color }] threshold: { soilHumMin: 35, soilHumMax: 80, airTempMax: 38 }, loading: true }, onLoad(query) { this.setData({ plotId: Number(query.id || 0) }); this.refresh(); }, async refresh() { const id = this.data.plotId; try { // 两个请求并发,共用一个 loading,减少页面闪烁 const [latestRes, historyRes] = await Promise.all([ request('/api/env/latest', { plotId: id }), request('/api/env/history', { plotId: id, hours: 24, step: 10 }) ]); // 阈值跟着批次走,服务端下发,前端只做比对 const th = latestRes.data.threshold || this.data.threshold; const series = historyRes.data.map(p => ({ t: p.t, v: p.soilHum, // 低于下限或高于上限都标红,中间区间标绿 color: (p.soilHum < th.soilHumMin || p.soilHum > th.soilHumMax) ? '#e5484d' : '#2f9e44' })); this.setData({ latest: latestRes.data, threshold: th, series, loading: false, // 超限点数量,用来决定是否在页面顶部挂提示条 abnormalCount: series.filter(p => p.color === '#e5484d').length }); } catch (e) { this.setData({ loading: false }); wx.showToast({ title: e.message || '加载失败', icon: 'none' }); } } });整形这层做了三件事:把服务端字段名转成页面用的短名,避免模板里写一长串;把颜色预先算好存进数据,WXML 里就不用写复杂表达式;顺手统计超限点数量,供顶部提示条使用。series里的t是毫秒时间戳,图表 x 轴直接用它算偏移,不用再做字符串解析。
有一点要提醒:Promise.all并发的请求数不宜超过四个。小程序对同域名并发有上限,超了会排队,表现出来就是加载慢。如果页面还要拉告警列表和批次信息,建议合成一个聚合接口,而不是继续加并发。
5. 避坑排查:五个真实翻车点逐条拆
下面这五条都是我或身边人在实际部署里撞过的,按“现象 → 原因 → 解决”写,遇到类似症状可以直接对号入座。
5.1 曲线整体偏移八小时
现象:数据库里看到的collected_at是对的,但小程序上的曲线整体左移或右移八小时,零点附近的数据尤其明显。
原因:三段时区没对齐。设备侧发的是 UTC 时间但没带时区标识;Node 服务端new Date('2025-03-01 08:10:00')在部分运行环境按 UTC 解析;连接池没设timezone,MySQL 驱动又按本地时区转换了一次。任何一段错位,都会叠加成八小时。
解决:全链路统一成“带时区的 ISO8601 字符串进,毫秒时间戳出”。设备侧强制带+08:00,连接池设timezone: '+08:00',接口返回前统一getTime()转毫秒数。改完之后曲线立刻对齐,原来的数据不用迁移,因为存的是DATETIME,语义没变,只是读取时的解释方式统一了。
5.2 真机白屏、开发者工具正常
现象:开发者工具里一切正常,手机扫码进去所有请求都失败,页面空白,控制台提示域名不在白名单。
原因:工具里勾选了跳过域名校验,真机不走这个开关。另外,即便是 HTTPS,证书链不完整、用了自签证书、域名没备案,都会被拒。
解决:真机调试必须用备案域名加完整证书链,用在线工具查一下证书链是否完整。局域网 IP 这条路走不通,别在这上面耗时间。临时方案是把服务端部署到一台有证书的机器上,长期方案是提前把域名和证书准备好,这属于项目启动阶段就该确认的事,拖到联调才想起来会很被动。
5.3 连接池被打满,接口随机超时
现象:单点测试都正常,一到大屏和手机同时开着,接口开始零星超时,重启服务后恢复,过一会儿又出现。
原因:连接池connectionLimit是 20,但有几个接口做了嵌套查询——先查地块,再循环查每个地块的最新记录。十个地块就是十一次查询,五个用户同时打开看板,连接瞬间被占满,后面的请求全在排队。
解决:把循环查询改成一条带子查询的 SQL,或者用IN批量查回来在内存里分组。同时对env_record的(plot_id, collected_at)索引做一次EXPLAIN确认命中。改完连接数从峰值 20 降到 3 左右。另外给连接池加了个慢查询日志,超过 500ms 的 SQL 打出来,这个习惯后来帮我提前发现了好几次全表扫描。
5.4 图片上传成功但显示不出来
现象:后台上传地块照片提示成功,数据库里也有路径,但小程序端图片是裂的。
原因:存的是服务器绝对路径/var/www/farm/public/uploads/xxx.jpg,而小程序只能访问 HTTPS 下的静态资源 URL;另外上传时字段名和后端接收字段名不一致,部分文件其实压根没存进去,只是接口没报错。
解决:数据库只存相对路径uploads/2025/03/xxx.jpg,拼接前缀的逻辑放在接口层,域名换了不用改数据。上传字段名两侧对齐,后端加一层文件类型和大小校验,超过 5MB 或不是图片格式直接拒绝。小程序端<image>的src用完整 HTTPS 地址,注意别漏了协议头。
5.5 定时任务重复执行
现象:告警通知偶尔会连发两三条,日志里同一个时刻的定时任务出现了多次。
原因:服务端开了多进程(PM2 的 cluster 模式或容器多副本),每个进程都注册了同一个 cron 任务,到点一起触发。
解决:定时任务要么单独拆成一个进程跑,要么在任务入口加分布式锁,抢到锁的才执行。小项目最省事的做法是定时任务进程和环境变量绑定,只在WORKER=cron的那个实例上注册。另外 cron 表达式里的时区也要显式指定,否则容器默认 UTC,任务会在早上八点而不是零点跑。
6. 进阶:接真实传感器做阈值告警
演示数据跑顺之后,下一步就是把真实设备接进来。这一步的难点不在协议,在“数据怎么持续、稳定、不重复地进来”,以及“告警不要变成骚扰”。
6.1 MQTT 桥接与入库
多数农业传感器网关支持 MQTT 上报,主题里带设备编号,载荷是 JSON。常见做法是起一个独立的桥接进程,订阅通配主题,解析后复用上报接口的写库逻辑。
// server/mqtt-bridge.js const mqtt = require('mqtt'); const pool = require('./db'); const client = mqtt.connect(process.env.MQTT_URL, { username: process.env.MQTT_USER, password: process.env.MQTT_PASS, clientId: `farm-bridge-${process.pid}`, // 多实例时 pid 保证 clientId 不冲突 clean: false, // 保留会话,断线重连后能收到 QoS1 的离线消息 reconnectPeriod: 3000 }); client.on('connect', () => { // 通配订阅:farm/{地块编号}/env client.subscribe('farm/+/env', { qos: 1 }, err => { if (err) console.error('[mqtt] 订阅失败', err); }); }); client.on('message', async (topic, buf) => { const plotNo = topic.split('/')[1]; let payload; try { payload = JSON.parse(buf.toString()); } catch { return; // 非法 JSON 直接丢弃,不要让一条坏数据拖垮整个桥接进程 } const collectedAt = new Date(payload.ts); // 网关时间,毫秒 if (Number.isNaN(collectedAt.getTime())) return; try { await pool.query( `INSERT INTO env_record (device_sn, plot_id, air_temp, air_hum, soil_temp, soil_hum, lux, collected_at) VALUES (?, (SELECT id FROM farm_plot WHERE plot_no = ? LIMIT 1), ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE soil_hum = VALUES(soil_hum), air_temp = VALUES(air_temp)`, [payload.sn, plotNo, payload.airTemp, payload.airHum, payload.soilTemp, payload.soilHum, payload.lux, collectedAt] ); } catch (e) { console.error('[mqtt] 入库失败', e.message); } });clean: false加 QoS1 的组合,是为了网关短暂断网时不丢数据;代价是 broker 侧要保留会话状态,内存占用会涨。clientId拼上process.pid,避免多副本部署时互相顶掉连接——这个坑很隐蔽,表现是设备数据时有时无。
6.2 告警判定要加去抖
最容易被吐槽的功能是“告警太吵”。土壤含水率在阈值边缘来回抖动,十分钟一条,一小时能发六条通知。我一般的做法是加两级去抖:
| 参数 | 建议值 | 作用 |
|---|---|---|
duration | 连续 3 个点超限 | 过滤单点毛刺 |
cooldown | 同一指标 30 分钟 | 避免重复通知 |
level | 轻微/严重两档 | 严重档才发订阅消息 |
判定逻辑放在checkThreshold里,维护一张内存态的lastAlarmAt映射,进程重启后从alarm_log里回填最近一次时间。订阅消息只推严重档,轻微档落库,在小程序端的告警页里看。这样既不漏掉真问题,也不会让人把通知关掉。
这套改造我在一块试验田上跑了一个种植季,最大的收获不是技术上的,而是习惯上的:从那以后我每次接新设备,都强制先跑一遍“时间戳对齐 + 幂等写入 + 去抖告警”这三项检查,任何一项没过就不往下走。前面省下的十分钟,后面往往要花一整天去查。希望帮到你。
本文还有配套的精品资源,点击获取