Spring Boot 农业物联网平台:从 0 到 1 搭建

Spring Boot 农业物联网平台:从 0 到 1 搭建

传感器数据上了 MQTT,摄像头跑起了 YOLO,现在需要一个「大脑」把它们管起来。这篇从零搭一套 Spring Boot 3 物联网中台:设备管理、TDengine 时序存储、告警规则引擎、ECharts 可视化。


项目初始化

bash 复制代码
# Spring Boot 3.2 + JDK 17
spring init \
  -d web,websocket,mybatis,mysql,lombok,validation \
  -g com.farmer \
  -a farm-iot-platform \
  farm-iot-platform

额外依赖:

xml 复制代码
<!-- TDengine 连接器 -->
<dependency>
    <groupId>com.taosdata.jdbc</groupId>
    <artifactId>taos-jdbcdriver</artifactId>
    <version>3.3.0</version>
</dependency>

<!-- EMQX MQTT 客户端 -->
<dependency>
    <groupId>org.eclipse.paho</groupId>
    <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
    <version>1.2.5</version>
</dependency>

<!-- Sa-Token 权限 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot3-starter</artifactId>
    <version>1.38.0</version>
</dependency>

目录结构:

复制代码
farm-iot-platform
├── controller/     → REST API 层
├── service/        → 业务逻辑
├── mapper/         → MyBatis Mapper
├── model/
│   ├── entity/     → MySQL 实体
│   └── dto/        → 数据传输对象
├── mqtt/           → MQTT 消息处理
├── alarm/          → 告警引擎
├── websocket/      → 实时推送
└── config/         → 配置类

设备管理:CRUD + 分组 + 标签

MySQL 表设计:

sql 复制代码
CREATE TABLE device (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    device_id   VARCHAR(64) NOT NULL UNIQUE COMMENT '设备唯一ID, esp32_a1b2c3',
    name        VARCHAR(128) COMMENT '设备名称, 大棚A东区传感器1',
    type        VARCHAR(32) NOT NULL COMMENT 'sensor/actuator/camera',
    location    VARCHAR(256) COMMENT '安装位置',
    group_id    BIGINT COMMENT '所属分组',
    tags        VARCHAR(512) DEFAULT '' COMMENT '标签,逗号分隔',
    mqtt_topic  VARCHAR(256) COMMENT 'MQTT topic 前缀',
    online      TINYINT DEFAULT 0 COMMENT '0离线 1在线',
    firmware    VARCHAR(32) COMMENT '固件版本',
    lat         DOUBLE COMMENT '纬度',
    lng         DOUBLE COMMENT '经度',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_device_id (device_id),
    INDEX idx_group_id (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE device_group (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    name        VARCHAR(64) NOT NULL COMMENT '分组名, 大棚A/果园B',
    parent_id   BIGINT DEFAULT 0 COMMENT '父分组ID',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

实体 + Mapper 用 MyBatis-Plus 一把梭:

java 复制代码
@TableName("device")
public class Device {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String deviceId;
    private String name;
    private String type;    // sensor / actuator / camera
    private String location;
    private Long groupId;
    private String tags;
    private String mqttTopic;
    private Boolean online;
    private String firmware;
}

public interface DeviceMapper extends BaseMapper<Device> {}

Service 层提供分页查询、按分组筛选、按标签搜索:

java 复制代码
@Service
public class DeviceService {
    
    public IPage<DeviceVO> pageDevices(DevicePageQuery query) {
        LambdaQueryWrapper<Device> wrapper = new LambdaQueryWrapper<>();
        
        // 关键搜索逻辑:模糊匹配名称或设备ID
        if (StrUtil.isNotBlank(query.getKeyword())) {
            wrapper.and(w -> w
                .like(Device::getName, query.getKeyword())
                .or()
                .eq(Device::getDeviceId, query.getKeyword()));
        }
        
        // 按分组筛选(含子分组)
        if (query.getGroupId() != null) {
            List<Long> groupIds = getGroupWithChildren(query.getGroupId());
            wrapper.in(Device::getGroupId, groupIds);
        }
        
        // 按标签筛选(FIND_IN_SET)
        if (StrUtil.isNotBlank(query.getTag())) {
            wrapper.apply("FIND_IN_SET({0}, tags)", query.getTag());
        }
        
        return deviceMapper.selectPage(query.getPage(), wrapper)
                .convert(this::toVO);
    }
}

TDengine 时序存储------写入快、查询快、压缩率高

TDengine 专门为物联网场景设计,写入性能是 InfluxDB 的 10 倍,数据压缩率 10:1 以上。

建超级表 + 自动子表

sql 复制代码
-- 超级表:定义数据结构和标签
CREATE STABLE IF NOT EXISTS farm.sensor_data (
    ts          TIMESTAMP,
    air_temp    FLOAT,
    air_humidity FLOAT,
    soil_temp   FLOAT,
    soil_moisture FLOAT,
    light       INT,
    battery     FLOAT,
    rssi        INT
) TAGS (
    device_id   BINARY(64),
    location    BINARY(256)
);

-- 插入时自动创建子表(子表名 = d_设备ID)
INSERT INTO farm.d_esp32_a1b2c3
USING farm.sensor_data TAGS ('esp32_a1b2c3', '大棚A-东区-1号位')
VALUES (NOW, 26.5, 68.2, 22.1, 35.0, 42000, 3.82, -65);

Spring Boot 连接 TDengine

java 复制代码
@Configuration
public class TDengineConfig {
    @Bean
    public JdbcTemplate tdengineJdbcTemplate(
            @Value("${tdengine.url}") String url,
            @Value("${tdengine.username}") String username,
            @Value("${tdengine.password}") String password) {
        return new JdbcTemplate(new DriverManagerDataSource(url, username, password));
    }
}

application.yml

yaml 复制代码
tdengine:
  url: jdbc:TAOS://localhost:6030/farm
  username: root
  password: taosdata

监听 MQTT 消息并写入 TDengine

java 复制代码
@Component
public class SensorDataListener {
    @Autowired
    private JdbcTemplate tdengine;
    
    @MqttListener(topic = "farm/+/sensor/+/data")
    public void onSensorData(String topic, String payload) {
        SensorData data = JSON.parseObject(payload, SensorData.class);
        
        // 提取 sensor 之后的设备ID(topic 第 3 段)
        String deviceId = topic.split("/")[3];
        String tableName = "farm.d_" + deviceId.replace("-", "_");
        
        tdengine.update("INSERT INTO " + tableName
            + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
            data.getTs(), data.getAirTemp(), data.getAirHumidity(),
            data.getSoilTemp(), data.getSoilMoisture(),
            data.getLight(), data.getBattery(), data.getRssi());
    }
}

查询------TDengine 标准 SQL

sql 复制代码
-- 最近 24 小时数据(每 5 分钟一个点降采样)
SELECT _wstart, AVG(air_temp), AVG(air_humidity), AVG(soil_moisture)
FROM farm.sensor_data
WHERE ts >= NOW - 1d
INTERVAL(5m);

-- 多设备对比:大棚 A 和 B 过去 1 小时温度
SELECT ts, air_temp FROM farm.d_esp32_a1b2c3 WHERE ts >= NOW - 1h
UNION ALL
SELECT ts, air_temp FROM farm.d_esp32_d4e5f6 WHERE ts >= NOW - 1h;

告警规则引擎------不要整 DSL,正则就够了

告警配置存 MySQL:

sql 复制代码
CREATE TABLE alarm_rule (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    name        VARCHAR(128),
    device_id   VARCHAR(64),         -- NULL = 所有设备
    field       VARCHAR(32),         -- air_temp / soil_moisture / battery / online
    operator    VARCHAR(8),          -- > / < / == / !=
    threshold   DOUBLE,
    duration    INT DEFAULT 0,       -- 持续秒数,0 = 立即触发
    level       VARCHAR(16) DEFAULT 'warn',  -- warn / error / critical
    enabled     TINYINT DEFAULT 1,
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);

告警引擎------每次收到传感器数据后触发评估:

java 复制代码
@Service
public class AlarmEngine {
    @Autowired
    private AlarmRuleMapper ruleMapper;
    @Autowired
    private AlarmRecordMapper recordMapper;
    @Autowired
    private WebSocketService wsService;
    
    private Map<String, LocalDateTime> conditionMetSince = new ConcurrentHashMap<>();
    
    public void evaluate(SensorData data) {
        List<AlarmRule> rules = ruleMapper.selectList(
            new LambdaQueryWrapper<AlarmRule>()
                .eq(AlarmRule::getEnabled, true)
                .and(w -> w.isNull(AlarmRule::getDeviceId)
                          .or().eq(AlarmRule::getDeviceId, data.getDev())));
        
        for (AlarmRule rule : rules) {
            double value = data.getValueByField(rule.getField());
            boolean triggered = evaluateRule(rule.getOperator(), value, rule.getThreshold());
            
            String ruleKey = rule.getId() + "_" + data.getDev();
            
            if (triggered) {
                LocalDateTime since = conditionMetSince.get(ruleKey);
                if (since == null) {
                    conditionMetSince.put(ruleKey, LocalDateTime.now());
                } else if (ChronoUnit.SECONDS.between(since, LocalDateTime.now()) >= rule.getDuration()) {
                    fireAlarm(rule, data, value);
                }
            } else {
                conditionMetSince.remove(ruleKey);
            }
        }
    }
    
    private void fireAlarm(AlarmRule rule, SensorData data, double value) {
        // 记录告警
        AlarmRecord record = new AlarmRecord();
        record.setRuleId(rule.getId());
        record.setRuleName(rule.getName());
        record.setDeviceId(data.getDev());
        record.setField(rule.getField());
        record.setValue(value);
        record.setThreshold(rule.getThreshold());
        record.setLevel(rule.getLevel());
        recordMapper.insert(record);
        
        // 推送告警到前端
        wsService.push(MessageType.ALARM, record);
    }
}

告警类型示例:

规则 条件 级别
土壤过干 soil_moisture < 20% 持续 10min warn
大棚高温 air_temp > 40℃ error
设备离线 online == false critical
电池低电 battery < 3.4V warn
CO₂ 不足 co2 < 300ppm warn

WebSocket 实时推送 + ECharts 可视化

后端 WebSocket 配置:

java 复制代码
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new FarmWebSocketHandler(), "/ws/farm")
                .setAllowedOrigins("*");
    }
}

前端用 ECharts 动态刷新(Vue 3 组件):

vue 复制代码
<script setup>
import * as echarts from 'echarts';
import { ref, onMounted, onUnmounted } from 'vue';

const chartRef = ref(null);
let chart = null;
let ws = null;

onMounted(() => {
  chart = echarts.init(chartRef.value);
  chart.setOption({
    title: { text: '大棚 A 实时温湿度' },
    xAxis: { type: 'time' },
    yAxis: [
      { type: 'value', name: '温度(℃)' },
      { type: 'value', name: '湿度(%)' }
    ],
    series: [
      { name: '温度', type: 'line', yAxisIndex: 0, data: [] },
      { name: '湿度', type: 'line', yAxisIndex: 1, data: [] }
    ]
  });
  
  // WebSocket 连接
  ws = new WebSocket('ws://your-server/ws/farm');
  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    const now = new Date(data.ts * 1000);
    
    // 向图表追加数据点
    chart.setOption({
      series: [
        { data: [...chart.getOption().series[0].data, [now, data.air_temp]] },
        { data: [...chart.getOption().series[1].data, [now, data.air_humidity]] }
      ]
    });
  };
});

onUnmounted(() => {
  chart?.dispose();
  ws?.close();
});
</script>

<template>
  <div ref="chartRef" style="width:100%;height:400px"></div>
</template>

部署清单

bash 复制代码
# 1. 阿里云 / 腾讯云 2C4G 服务器
# 2. 安装 Docker
# 3. 一条命令起三个容器
docker run -d --name emqx -p 1883:1883 -p 18083:18083 emqx/emqx:5.7.0
docker run -d --name tdengine -p 6030:6030 -p 6041:6041 tdengine/tdengine:3.3.0
docker run -d --name mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=xxx mysql:8.0

# 4. 打包 Spring Boot 应用
mvn clean package -DskipTests

# 5. 运行
java -jar farm-iot-platform.jar --spring.profiles.active=prod

下一篇:《微信小程序:农户手机上的「农场管家」》------UniApp 开发,一个码农扫二维码就能看到自家大棚实时数据,手指一点远程灌溉。