这一章把前面的设计落到代码。主线是 RuoYi-Vue 单体 + 独立 Collector 采集服务。如果你用 RuoYi-Cloud 或 RuoYi-Vue-Plus,目录和依赖会变,但表结构、采集流程、碳核算思路不变。
1. 环境准备
| 软件 | 版本 | 用途 |
|---|---|---|
| JDK | 8 或 17 | 后端 |
| Maven | 3.9+ | 构建 |
| MySQL | 8.0+ | 业务库 |
| Redis | 7.x | 缓存 |
| Node | 20 LTS | 前端 |
| Git | 最新 | 版本管理 |
| TDengine | 3.3.x | 时序库 |
| EMQX | 5.8.x | MQTT |
| Docker | 26+ | 本地中间件 |
拉取若依
git clone https://gitee.com/y_project/RuoYi-Vue.git git clone https://gitee.com/y_project/RuoYi-Vue3.git初始化数据库
在 MySQL 里创建
ry-vue库,执行若依sql目录下的ry_2025xxxx.sql和quartz.sql。具体文件名以你拉取的版本为准。启动 Redis 和 MySQL
本地可以用 Docker:
docker run -d --name ry-mysql -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=ry-vue mysql:8.0 docker run -d --name ry-redis -p 6379:6379 redis:7.2修改配置并启动
改
ruoyi-admin/src/main/resources/application-druid.yml里的数据库连接,改application.yml里的 Redis 连接。启动RuoYiApplication,浏览器打开前端,默认账号admin/admin123。
2. 工程改造
2.1 新增业务模块
在根 pom.xml 的 <modules> 里加:
<module>ruoyi-energy</module>新建 ruoyi-energy/pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi</artifactId>
<!-- 与根 pom 的 version 保持一致 -->
<version>3.8.9</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ruoyi-energy</artifactId>
<dependencies>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-framework</artifactId>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-system</artifactId>
</dependency>
<dependency>
<groupId>com.taosdata.jdbc</groupId>
<artifactId>taos-jdbcdriver</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>1.2.5</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.4</version>
</dependency>
</dependencies>
</project>在 ruoyi-admin/pom.xml 里依赖它:
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-energy</artifactId>
</dependency>2.2 包结构
com.ruoyi.energy
├── config
│ ├── TdengineConfig.java
│ ├── MqttConfig.java
│ └── ThreadPoolConfig.java
├── controller
│ ├── EnergyMeterController.java
│ ├── EnergyPointController.java
│ ├── EnergyStatController.java
│ └── CarbonAccountingController.java
├── service
│ ├── impl
│ ├── collect
│ ├── stat
│ ├── carbon
│ └── alarm
├── mapper
├── domain
├── enums
└── util2.3 Mapper 扫描
若依默认扫描 com.ruoyi.**.mapper,如果你的包在 com.ruoyi.energy.mapper 下,通常不用改。如果启动时报 Mapper 找不到,检查启动类上的 @MapperScan 或 application.yml 里的 mybatis.typeAliasesPackage、mapperLocations。
mybatis:
typeAliasesPackage: com.ruoyi.**.domain
mapperLocations: classpath*:mapper/**/*Mapper.xml3. 数据库设计
能碳系统的表可以分成五组:
| 分组 | 表 | 说明 |
|---|---|---|
| 基础数据 | energy_media、energy_meter、energy_point、energy_gateway |
介质、计量点、点位、网关 |
| 采集数据 | TDengine point_data |
原始值、质量码 |
| 统计数据 | energy_stat |
小时/日/月聚合 |
| 碳管理 | carbon_factor、carbon_accounting、carbon_emission |
因子、核算任务、核算明细 |
| 业务管理 | energy_price、energy_quota、energy_alarm_rule、energy_alarm |
单价、定额、报警 |
下面给出核心表的建表语句。字段按若依习惯保留 create_by、create_time、update_by、update_time、remark,方便代码生成和审计。
3.1 能源介质
CREATE TABLE energy_media (
media_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '介质ID',
media_code VARCHAR(32) NOT NULL COMMENT '介质编码',
media_name VARCHAR(64) NOT NULL COMMENT '介质名称',
category VARCHAR(16) NOT NULL COMMENT '分类:electricity/water/gas/steam/air/heat',
unit VARCHAR(16) NOT NULL COMMENT '计量单位',
standard_coal_factor DECIMAL(18,6) DEFAULT NULL COMMENT '折标煤系数 kgce/单位',
default_carbon_factor DECIMAL(18,6) DEFAULT NULL COMMENT '默认碳排放因子',
sort_order INT DEFAULT 0 COMMENT '排序',
status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
create_by VARCHAR(64) DEFAULT '',
create_time DATETIME DEFAULT NULL,
update_by VARCHAR(64) DEFAULT '',
update_time DATETIME DEFAULT NULL,
remark VARCHAR(500) DEFAULT NULL,
PRIMARY KEY (media_id),
UNIQUE KEY uk_media_code (media_code)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='能源介质';3.2 计量点
CREATE TABLE energy_meter (
meter_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '计量点ID',
meter_code VARCHAR(64) NOT NULL COMMENT '计量点编码',
meter_name VARCHAR(128) NOT NULL COMMENT '计量点名称',
media_id BIGINT NOT NULL COMMENT '能源介质ID',
dept_id BIGINT DEFAULT NULL COMMENT '所属部门/车间',
parent_id BIGINT DEFAULT 0 COMMENT '上级计量点ID',
meter_level TINYINT DEFAULT 2 COMMENT '层级:1总表 2分表 3设备表',
location VARCHAR(255) DEFAULT NULL COMMENT '安装位置',
brand VARCHAR(64) DEFAULT NULL COMMENT '品牌',
model VARCHAR(64) DEFAULT NULL COMMENT '型号',
serial_no VARCHAR(64) DEFAULT NULL COMMENT '出厂编号',
gateway_id BIGINT DEFAULT NULL COMMENT '所属网关ID',
protocol VARCHAR(32) DEFAULT NULL COMMENT '协议:MODBUS_RTU/DLT645/OPC_UA/MQTT',
comm_type VARCHAR(32) DEFAULT NULL COMMENT '通信方式:RS485/4-20mA/PULSE/ETHERNET',
slave_addr INT DEFAULT NULL COMMENT '从站地址',
baud_rate INT DEFAULT NULL COMMENT '波特率',
parity VARCHAR(8) DEFAULT 'N' COMMENT '校验位 N/E/O',
multiplier DECIMAL(18,6) DEFAULT 1 COMMENT '倍率(CT/PT变比)',
multiplier_applied CHAR(1) DEFAULT '0' COMMENT '表内是否已乘倍率(0否 1是)',
install_date DATE DEFAULT NULL COMMENT '安装日期',
last_online_time DATETIME DEFAULT NULL COMMENT '最后在线时间',
status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
create_by VARCHAR(64) DEFAULT '',
create_time DATETIME DEFAULT NULL,
update_by VARCHAR(64) DEFAULT '',
update_time DATETIME DEFAULT NULL,
remark VARCHAR(500) DEFAULT NULL,
PRIMARY KEY (meter_id),
UNIQUE KEY uk_meter_code (meter_code),
KEY idx_media_dept (media_id, dept_id),
KEY idx_gateway (gateway_id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='计量点';3.3 点位
CREATE TABLE energy_point (
point_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '点位ID',
point_code VARCHAR(96) NOT NULL COMMENT '点位编码',
point_name VARCHAR(128) NOT NULL COMMENT '点位名称',
meter_id BIGINT NOT NULL COMMENT '计量点ID',
point_type VARCHAR(16) DEFAULT 'INSTANT' COMMENT '类型:INSTANT瞬时/CUMULATIVE累计/STATUS状态',
data_type VARCHAR(16) NOT NULL COMMENT '数据类型:UINT16/INT16/UINT32/INT32/FLOAT32/BCD',
function_code INT DEFAULT 3 COMMENT 'Modbus功能码',
register_addr INT NOT NULL COMMENT '寄存器地址(十进制存)',
register_count INT DEFAULT 1 COMMENT '寄存器数量',
byte_order VARCHAR(8) DEFAULT 'AB' COMMENT '字节序:AB/ABCD/CDAB/BADC/DCBA',
scale DECIMAL(18,9) DEFAULT 1 COMMENT '缩放系数',
offset_value DECIMAL(18,6) DEFAULT 0 COMMENT '偏移量',
unit VARCHAR(16) DEFAULT NULL COMMENT '工程单位',
period_sec INT DEFAULT 60 COMMENT '采集周期(秒)',
deadband DECIMAL(18,6) DEFAULT NULL COMMENT '死区',
alarm_enabled CHAR(1) DEFAULT '0' COMMENT '是否报警(0否 1是)',
status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
create_by VARCHAR(64) DEFAULT '',
create_time DATETIME DEFAULT NULL,
update_by VARCHAR(64) DEFAULT '',
update_time DATETIME DEFAULT NULL,
remark VARCHAR(500) DEFAULT NULL,
PRIMARY KEY (point_id),
UNIQUE KEY uk_point_code (point_code),
KEY idx_meter (meter_id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='采集点位';3.4 统计结果
CREATE TABLE energy_stat (
stat_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '统计ID',
stat_type VARCHAR(8) NOT NULL COMMENT 'HOUR/DAY/MONTH/YEAR',
stat_time DATETIME NOT NULL COMMENT '统计时间(周期起点)',
point_id BIGINT NOT NULL COMMENT '点位ID',
meter_id BIGINT NOT NULL COMMENT '计量点ID',
media_id BIGINT NOT NULL COMMENT '介质ID',
dept_id BIGINT DEFAULT NULL COMMENT '部门ID',
value DECIMAL(20,6) NOT NULL COMMENT '统计值',
unit VARCHAR(16) DEFAULT NULL COMMENT '单位',
data_count INT DEFAULT 0 COMMENT '实际采集点数',
expected_count INT DEFAULT 0 COMMENT '应采集点数',
quality TINYINT DEFAULT 0 COMMENT '质量:0正常 1不完整 2异常',
create_time DATETIME DEFAULT NULL,
PRIMARY KEY (stat_id),
UNIQUE KEY uk_stat (stat_type, stat_time, point_id),
KEY idx_query (stat_type, stat_time, media_id, dept_id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='能耗统计';3.5 排放因子
CREATE TABLE carbon_factor (
factor_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '因子ID',
factor_code VARCHAR(64) NOT NULL COMMENT '因子编码',
factor_name VARCHAR(128) NOT NULL COMMENT '因子名称',
media_id BIGINT DEFAULT NULL COMMENT '关联介质',
source_type VARCHAR(32) NOT NULL COMMENT '排放源类型:ELECTRICITY/FUEL/STEAM/REFRIGERANT',
scope VARCHAR(8) NOT NULL COMMENT '范围:SCOPE_1/SCOPE_2/SCOPE_3',
region VARCHAR(32) DEFAULT 'NATIONAL' COMMENT '适用地区',
factor_year INT NOT NULL COMMENT '适用年份',
factor_value DECIMAL(18,9) NOT NULL COMMENT '因子值',
unit VARCHAR(32) NOT NULL COMMENT '单位',
source VARCHAR(255) DEFAULT NULL COMMENT '来源文件/公告',
version VARCHAR(32) NOT NULL COMMENT '版本号',
effective_from DATE NOT NULL COMMENT '生效日期',
effective_to DATE DEFAULT NULL COMMENT '失效日期',
status CHAR(1) DEFAULT '0' COMMENT '状态(0启用 1停用)',
create_by VARCHAR(64) DEFAULT '',
create_time DATETIME DEFAULT NULL,
update_by VARCHAR(64) DEFAULT '',
update_time DATETIME DEFAULT NULL,
remark VARCHAR(500) DEFAULT NULL,
PRIMARY KEY (factor_id),
UNIQUE KEY uk_factor (factor_code, version),
KEY idx_match (source_type, region, factor_year, status)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='碳排放因子库';3.6 核算任务与明细
CREATE TABLE carbon_accounting (
accounting_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '核算ID',
accounting_no VARCHAR(64) NOT NULL COMMENT '核算编号',
accounting_name VARCHAR(128) NOT NULL COMMENT '核算名称',
dept_id BIGINT DEFAULT NULL COMMENT '组织边界',
period_start DATE NOT NULL COMMENT '核算期间起',
period_end DATE NOT NULL COMMENT '核算期间止',
boundary_method VARCHAR(32) DEFAULT 'OPERATIONAL_CONTROL' COMMENT '合并方式',
standard VARCHAR(64) DEFAULT 'GB/T 32150' COMMENT '核算标准',
scope1 DECIMAL(20,6) DEFAULT 0 COMMENT '范围一排放',
scope2 DECIMAL(20,6) DEFAULT 0 COMMENT '范围二排放',
scope3 DECIMAL(20,6) DEFAULT 0 COMMENT '范围三排放',
total_emission DECIMAL(20,6) DEFAULT 0 COMMENT '总排放 tCO2e',
carbon_intensity DECIMAL(20,6) DEFAULT NULL COMMENT '碳强度',
intensity_unit VARCHAR(32) DEFAULT 'tCO2e/万元' COMMENT '碳强度单位',
status VARCHAR(16) DEFAULT 'DRAFT' COMMENT 'DRAFT/CALCULATING/REVIEWED/LOCKED',
calc_time DATETIME DEFAULT NULL COMMENT '计算时间',
lock_by VARCHAR(64) DEFAULT NULL COMMENT '锁定人',
lock_time DATETIME DEFAULT NULL COMMENT '锁定时间',
create_by VARCHAR(64) DEFAULT '',
create_time DATETIME DEFAULT NULL,
update_by VARCHAR(64) DEFAULT '',
update_time DATETIME DEFAULT NULL,
remark VARCHAR(500) DEFAULT NULL,
PRIMARY KEY (accounting_id),
UNIQUE KEY uk_accounting_no (accounting_no),
KEY idx_period (period_start, period_end)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='碳核算任务';
CREATE TABLE carbon_emission (
emission_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '明细ID',
accounting_id BIGINT NOT NULL COMMENT '核算ID',
source_type VARCHAR(32) NOT NULL COMMENT '排放源类型',
scope VARCHAR(8) NOT NULL COMMENT '范围',
activity_value DECIMAL(20,6) NOT NULL COMMENT '活动数据',
activity_unit VARCHAR(32) NOT NULL COMMENT '活动数据单位',
factor_id BIGINT DEFAULT NULL COMMENT '因子ID',
factor_value DECIMAL(18,9) DEFAULT NULL COMMENT '因子快照',
factor_unit VARCHAR(32) DEFAULT NULL COMMENT '因子单位',
factor_version VARCHAR(32) DEFAULT NULL COMMENT '因子版本快照',
emission DECIMAL(20,6) NOT NULL COMMENT '排放量 tCO2e',
data_source VARCHAR(255) DEFAULT NULL COMMENT '数据来源说明',
create_time DATETIME DEFAULT NULL,
PRIMARY KEY (emission_id),
KEY idx_accounting (accounting_id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='碳核算明细';4. 用代码生成器做 CRUD
导入表
启动若依,进入“系统工具 → 代码生成 → 导入”,勾选业务表。
配置生成信息
包名填
com.ruoyi.energy,模块名填energy,业务名填meter、point等。作者、菜单名称按项目填。生成并解压
下载生成的压缩包,把
main/java、main/resources、vue下的文件分别放到ruoyi-energy和前端项目对应目录。手工改造
生成的是标准单表 CRUD。你需要补:联表查询介质/部门名称、参数校验、数据权限、导入导出模板、点位与计量点的一致性校验。
生成后的 Controller 通常长这样,记得加权限注解:
@RestController
@RequestMapping("/energy/meter")
public class EnergyMeterController extends BaseController {
@Autowired
private IEnergyMeterService meterService;
@PreAuthorize("@ss.hasPermi('energy:meter:list')")
@GetMapping("/list")
public TableDataInfo list(EnergyMeter meter) {
startPage();
List<EnergyMeter> list = meterService.selectMeterList(meter);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('energy:meter:query')")
@GetMapping("/{meterId}")
public AjaxResult getInfo(@PathVariable Long meterId) {
return success(meterService.selectMeterById(meterId));
}
@PreAuthorize("@ss.hasPermi('energy:meter:add')")
@Log(title = "计量点", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody EnergyMeter meter) {
return toAjax(meterService.insertMeter(meter));
}
@PreAuthorize("@ss.hasPermi('energy:meter:edit')")
@Log(title = "计量点", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody EnergyMeter meter) {
return toAjax(meterService.updateMeter(meter));
}
@PreAuthorize("@ss.hasPermi('energy:meter:remove')")
@Log(title = "计量点", businessType = BusinessType.DELETE)
@DeleteMapping("/{meterIds}")
public AjaxResult remove(@PathVariable Long[] meterIds) {
return toAjax(meterService.deleteMeterByIds(meterIds));
}
}5. 采集服务
5.1 为什么独立部署
采集服务要长期运行、直接连现场网络、频繁重连,和 Web 应用的生命周期不同。独立部署后:
- Web 应用重启不影响采集。
- 采集服务可以部署在靠近网关的机器。
- 只给采集服务开放时序库写权限和 MQTT 订阅权限。
5.2 独立 Collector 工程
collector/
├── pom.xml
└── src/main
├── java/com/example/collector
│ ├── CollectorApplication.java
│ ├── mqtt/MqttIngestService.java
│ ├── modbus/ModbusPollingService.java
│ ├── store/TimeSeriesWriter.java
│ ├── store/LatestValueCache.java
│ ├── alarm/AlarmEvaluator.java
│ └── model/TelemetryMessage.java
└── resources/application.ymlpom.xml 关键依赖:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>1.2.5</version>
</dependency>
<dependency>
<groupId>com.taosdata.jdbc</groupId>
<artifactId>taos-jdbcdriver</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>com.intelligt.modbus</groupId>
<artifactId>jlibmodbus</artifactId>
<version>1.2.9.7</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>5.3 配置
server:
port: 9090
spring:
redis:
host: 127.0.0.1
port: 6379
password:
database: 2
mqtt:
broker: tcp://127.0.0.1:1883
client-id: collector-01
username: collector
password: change-me
topics:
- energy/+/+/telemetry
- energy/+/+/status
qos: 1
tdengine:
url: jdbc:TAOS://127.0.0.1:6030/energy?charset=utf8
username: root
password: taosdata
batch-size: 500
flush-interval-ms: 10005.4 MQTT 接入
@Service
public class MqttIngestService implements MqttCallbackExtended {
private static final Logger log = LoggerFactory.getLogger(MqttIngestService.class);
@Value("${mqtt.broker}")
private String broker;
@Value("${mqtt.client-id}")
private String clientId;
@Value("${mqtt.username}")
private String username;
@Value("${mqtt.password}")
private String password;
@Value("${mqtt.qos:1}")
private int qos;
private final ObjectMapper objectMapper = new ObjectMapper();
private final TimeSeriesWriter writer;
private final LatestValueCache cache;
private final AlarmEvaluator alarmEvaluator;
private MqttClient client;
public MqttIngestService(TimeSeriesWriter writer,
LatestValueCache cache,
AlarmEvaluator alarmEvaluator) {
this.writer = writer;
this.cache = cache;
this.alarmEvaluator = alarmEvaluator;
}
@PostConstruct
public void start() throws MqttException {
client = new MqttClient(broker, clientId, new MemoryPersistence());
MqttConnectOptions options = new MqttConnectOptions();
options.setUserName(username);
options.setPassword(password.toCharArray());
options.setAutomaticReconnect(true);
options.setCleanSession(false);
options.setKeepAliveInterval(30);
client.setCallback(this);
client.connect(options);
client.subscribe("energy/+/+/telemetry", qos);
client.subscribe("energy/+/+/status", qos);
log.info("MQTT connected: {}", broker);
}
@Override
public void connectComplete(boolean reconnect, String serverURI) {
try {
client.subscribe("energy/+/+/telemetry", qos);
client.subscribe("energy/+/+/status", qos);
log.info("MQTT reconnected, resubscribed: {}", serverURI);
} catch (MqttException e) {
log.error("resubscribe failed", e);
}
}
@Override
public void messageArrived(String topic, MqttMessage message) {
try {
String payload = new String(message.getPayload(), StandardCharsets.UTF_8);
if (topic.endsWith("/status")) {
handleStatus(topic, payload);
} else {
handleTelemetry(topic, payload);
}
} catch (Exception e) {
log.error("handle message failed, topic={}", topic, e);
}
}
private void handleTelemetry(String topic, String payload) throws Exception {
JsonNode root = objectMapper.readTree(payload);
String gatewayId = root.path("gatewayId").asText();
String meterCode = root.path("meterCode").asText();
String energyType = root.path("energyType").asText("");
long ts = root.path("ts").asLong(System.currentTimeMillis());
int quality = root.path("quality").asInt(0);
JsonNode points = root.path("points");
Iterator<Map.Entry<String, JsonNode>> it = points.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> entry = it.next();
String pointCode = meterCode + "." + entry.getKey();
double value = entry.getValue().asDouble();
writer.append(pointCode, meterCode, energyType, ts, value, quality);
cache.update(pointCode, value, ts, quality);
alarmEvaluator.evaluate(pointCode, value, ts);
}
}
private void handleStatus(String topic, String payload) {
// 解析网关心跳,更新在线状态和最后心跳时间
log.debug("gateway status: {} {}", topic, payload);
}
@Override
public void connectionLost(Throwable cause) {
log.error("MQTT connection lost", cause);
}
@Override
public void deliveryComplete(IMqttDeliveryToken token) {
// 采集服务只订阅,不发布,忽略
}
}5.5 批量写时序库
TDengine 的数据源要单独建,不要让 DataSource 注入到若依的 MySQL 主数据源:
@Configuration
public class TdengineConfig {
@Bean(name = "tdengineDataSource")
@ConfigurationProperties(prefix = "tdengine.datasource")
public DataSource tdengineDataSource() {
return DataSourceBuilder.create().build();
}
}tdengine:
datasource:
driver-class-name: com.taosdata.jdbc.TSDBDriver
url: jdbc:TAOS://127.0.0.1:6030/energy?charset=utf8
username: root
password: taosdata写入组件用 @Qualifier 指定数据源:
@Component
public class TimeSeriesWriter {
private static final Logger log = LoggerFactory.getLogger(TimeSeriesWriter.class);
private final BlockingQueue<PointSample> queue = new LinkedBlockingQueue<>(100000);
private final DataSource dataSource;
private final int batchSize;
private final long flushIntervalMs;
public TimeSeriesWriter(@Qualifier("tdengineDataSource") DataSource dataSource,
@Value("${tdengine.batch-size:500}") int batchSize,
@Value("${tdengine.flush-interval-ms:1000}") long flushIntervalMs) {
this.dataSource = dataSource;
this.batchSize = batchSize;
this.flushIntervalMs = flushIntervalMs;
}
public void append(String pointCode, String meterCode, String energyType,
long ts, double value, int quality) {
boolean offered = queue.offer(
new PointSample(pointCode, meterCode, energyType, ts, value, quality));
if (!offered) {
log.warn("collector queue full, dropping sample: {}", pointCode);
}
}
@Scheduled(fixedDelayString = "${tdengine.flush-interval-ms:1000}")
public void flush() {
List<PointSample> batch = new ArrayList<>(batchSize);
queue.drainTo(batch, batchSize);
if (batch.isEmpty()) {
return;
}
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (Statement stmt = conn.createStatement()) {
for (PointSample s : batch) {
stmt.addBatch(buildInsertSql(s));
}
stmt.executeBatch();
conn.commit();
}
} catch (Exception e) {
log.error("flush to TDengine failed, size={}", batch.size(), e);
// 失败时放回队列,等待下一次重试(生产环境要做幂等和限流)
queue.addAll(batch);
}
}
private String buildInsertSql(PointSample s) {
String table = "p_" + Math.abs(s.getPointCode().hashCode());
String sql = "INSERT INTO energy." + table
+ " USING energy.point_data TAGS ('" + esc(s.getPointCode()) + "','"
+ esc(s.getMeterCode()) + "','" + esc(s.getEnergyType()) + "',0)"
+ " VALUES (" + s.getTs() + "," + s.getValue() + "," + s.getQuality() + ")";
return sql;
}
private String esc(String value) {
return value == null ? "" : value.replace("'", "''");
}
}5.6 最新值缓存
@Component
public class LatestValueCache {
private final StringRedisTemplate redisTemplate;
public LatestValueCache(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void update(String pointCode, double value, long ts, int quality) {
String key = "energy:latest:" + pointCode;
Map<String, String> map = new HashMap<>();
map.put("value", String.valueOf(value));
map.put("ts", String.valueOf(ts));
map.put("quality", String.valueOf(quality));
redisTemplate.opsForHash().putAll(key, map);
redisTemplate.expire(key, 10, TimeUnit.MINUTES);
}
}监控页优先读 Redis;miss 时再查 TDengine 的最后一条记录。
5.7 Modbus 轮询
如果网关只做透明传输,采集服务也可以直接轮询 Modbus TCP:
@Service
public class ModbusPollingService {
private static final Logger log = LoggerFactory.getLogger(ModbusPollingService.class);
@Scheduled(fixedDelay = 60000)
public void poll() {
// 实际项目从数据库加载启用的网关和点位,这里用示例参数
ModbusMaster master = null;
try {
master = ModbusMasterFactory.createModbusMasterTCP("192.168.10.11", 502);
master.setResponseTimeout(3000);
master.connect();
// 从站地址 3,起始寄存器 0,读 10 个寄存器
ReadHoldingRegistersRequest request =
new ReadHoldingRegistersRequest(3, 0, 10);
ReadHoldingRegistersResponse response =
(ReadHoldingRegistersResponse) master.processRequest(request);
int[] regs = response.getRegisters();
double voltageA = regs[0] * 0.1;
long energyRaw = ((long) regs[8] << 16) | (regs[9] & 0xFFFFL);
double energy = energyRaw * 0.01;
log.info("UA={}V, EP={}kWh", voltageA, energy);
} catch (Exception e) {
log.error("modbus poll failed", e);
} finally {
if (master != null) {
try {
master.disconnect();
} catch (Exception ignored) {
}
}
}
}
}5.8 数据解析工具
public final class ModbusValueUtil {
private ModbusValueUtil() {
}
public static long toUInt32(int high, int low) {
return ((long) high << 16) | (low & 0xFFFFL);
}
public static int toInt16(int raw) {
return (short) raw;
}
public static float toFloatABCD(int r0, int r1) {
int bits = (r0 << 16) | (r1 & 0xFFFF);
return Float.intBitsToFloat(bits);
}
public static float toFloatCDAB(int r0, int r1) {
return toFloatABCD(r1, r0);
}
public static BigDecimal scale(long raw, BigDecimal scale, BigDecimal offset) {
return BigDecimal.valueOf(raw)
.multiply(scale)
.add(offset);
}
}6. 统计任务
统计任务放在若依的 Quartz 里,或者放在 Collector 里都可以。建议:
- 小时/日聚合放若依,方便在管理界面看执行记录。
- 秒级/分钟级实时判断放 Collector,低延迟。
6.1 任务类
@Component("energyStatTask")
public class EnergyStatTask {
@Autowired
private IEnergyStatService statService;
/**
* 聚合指定小时,参数格式 yyyy-MM-dd HH
* 例如:energyStatTask.hour('2026-10-01 10')
*/
public void hour(String hour) {
statService.aggregateHour(hour);
}
public void day(String day) {
statService.aggregateDay(day);
}
public void month(String month) {
statService.aggregateMonth(month);
}
}在若依“定时任务”里新增:
| 字段 | 值 |
|---|---|
| 任务名称 | 能耗小时聚合 |
| 调用目标字符串 | energyStatTask.hour('2026-10-01 10') |
| cron 表达式 | 0 5 * * * ? |
| 执行策略 | 立即执行 |
| 是否并发 | 禁止 |
6.2 聚合逻辑
累计量(电量、水量、气量)取周期内首尾差;瞬时量(功率、流量、温度)取平均/最大/最小。
@Service
public class EnergyStatServiceImpl implements IEnergyStatService {
@Autowired
private EnergyPointMapper pointMapper;
@Autowired
private TimeSeriesQueryDao timeSeriesQueryDao;
@Autowired
private EnergyStatMapper statMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public void aggregateHour(String hour) {
LocalDateTime start = LocalDateTime.parse(hour + ":00:00",
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
LocalDateTime end = start.plusHours(1);
// selectEnabledPoints 联表 energy_meter,带出 mediaId、deptId
List<EnergyPoint> points = pointMapper.selectEnabledPoints();
for (EnergyPoint point : points) {
TimeSeriesSummary summary = timeSeriesQueryDao.summarize(
point.getPointCode(), start, end);
BigDecimal value;
if ("CUMULATIVE".equals(point.getPointType())) {
value = summary.getLastValue().subtract(summary.getFirstValue());
} else {
value = summary.getAvgValue();
}
EnergyStat stat = new EnergyStat();
stat.setStatType("HOUR");
stat.setStatTime(Timestamp.valueOf(start));
stat.setPointId(point.getPointId());
stat.setMeterId(point.getMeterId());
stat.setMediaId(point.getMediaId());
stat.setDeptId(point.getDeptId());
stat.setValue(value);
stat.setUnit(point.getUnit());
stat.setDataCount(summary.getCount());
stat.setExpectedCount(60 * 60 / Math.max(point.getPeriodSec(), 1));
stat.setQuality(summary.getCount() >= stat.getExpectedCount() * 0.98 ? 0 : 1);
statMapper.upsert(stat);
}
}
}upsert 用唯一键 uk_stat 做幂等:
INSERT INTO energy_stat
(stat_type, stat_time, point_id, meter_id, media_id, dept_id,
value, unit, data_count, expected_count, quality, create_time)
VALUES
(#{statType}, #{statTime}, #{pointId}, #{meterId}, #{mediaId}, #{deptId},
#{value}, #{unit}, #{dataCount}, #{expectedCount}, #{quality}, NOW())
ON DUPLICATE KEY UPDATE
value = VALUES(value),
data_count = VALUES(data_count),
expected_count = VALUES(expected_count),
quality = VALUES(quality);6.3 跨零点与补数
- 日统计按
00:00:00到次日00:00:00,不要用23:59:59,否则会漏最后一秒。 - 累计量跨零点:取前一天最后一条和后一天第一条的差值,或直接用日末累计值减日初累计值。
- 缺数:先用前后值插值,标记质量码 1;无法插值的标记 2,报表里单独说明。
- 补跑:统计任务要支持传入日期重跑,且结果幂等。
7. 碳核算服务
7.1 计算器接口
public interface EmissionCalculator {
String sourceType();
BigDecimal calculate(EmissionContext context);
}@Component
public class ElectricityCalculator implements EmissionCalculator {
@Override
public String sourceType() {
return "ELECTRICITY";
}
@Override
public BigDecimal calculate(EmissionContext context) {
return context.getActivity()
.multiply(context.getFactorValue())
.setScale(6, RoundingMode.HALF_UP);
}
}@Component
public class FuelCalculator implements EmissionCalculator {
@Override
public String sourceType() {
return "FUEL";
}
@Override
public BigDecimal calculate(EmissionContext context) {
// 活动数据 = 消耗量 × 低位热值 × 含碳量 × 氧化率 × 44/12
// 实际项目把这些参数做成因子配置,不要写死
return context.getActivity()
.multiply(context.getNcv())
.multiply(context.getCarbonContent())
.multiply(context.getOxidationRate())
.multiply(new BigDecimal("3.666666667"))
.divide(new BigDecimal("1000"), 6, RoundingMode.HALF_UP);
}
}7.2 核算服务
@Service
public class CarbonAccountingServiceImpl implements ICarbonAccountingService {
@Autowired
private CarbonAccountingMapper accountingMapper;
@Autowired
private CarbonEmissionMapper emissionMapper;
@Autowired
private CarbonFactorMapper factorMapper;
@Autowired
private EnergyStatMapper statMapper;
private final Map<String, EmissionCalculator> calculatorMap;
public CarbonAccountingServiceImpl(List<EmissionCalculator> calculators) {
this.calculatorMap = calculators.stream()
.collect(Collectors.toMap(EmissionCalculator::sourceType, Function.identity()));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void calculate(Long accountingId) {
CarbonAccounting accounting = accountingMapper.selectById(accountingId);
if (accounting == null) {
throw new ServiceException("核算任务不存在");
}
if ("LOCKED".equals(accounting.getStatus())) {
throw new ServiceException("已锁定的核算任务不能重算");
}
List<CarbonEmission> items = emissionMapper.selectByAccountingId(accountingId);
BigDecimal scope1 = BigDecimal.ZERO;
BigDecimal scope2 = BigDecimal.ZERO;
BigDecimal scope3 = BigDecimal.ZERO;
for (CarbonEmission item : items) {
CarbonFactor factor = factorMapper.selectEffective(
item.getSourceType(),
"NATIONAL",
accounting.getPeriodEnd());
if (factor == null) {
throw new ServiceException("未找到有效排放因子:" + item.getSourceType());
}
EmissionContext context = new EmissionContext();
context.setActivity(item.getActivityValue());
context.setFactorValue(factor.getFactorValue());
context.setSourceType(item.getSourceType());
EmissionCalculator calculator = calculatorMap.get(item.getSourceType());
if (calculator == null) {
throw new ServiceException("不支持的排放源类型:" + item.getSourceType());
}
BigDecimal emission = calculator.calculate(context);
item.setFactorId(factor.getFactorId());
item.setFactorValue(factor.getFactorValue());
item.setFactorUnit(factor.getUnit());
item.setFactorVersion(factor.getVersion());
item.setEmission(emission);
emissionMapper.updateById(item);
if ("SCOPE_1".equals(item.getScope())) {
scope1 = scope1.add(emission);
} else if ("SCOPE_2".equals(item.getScope())) {
scope2 = scope2.add(emission);
} else {
scope3 = scope3.add(emission);
}
}
accounting.setScope1(scope1);
accounting.setScope2(scope2);
accounting.setScope3(scope3);
accounting.setTotalEmission(scope1.add(scope2).add(scope3));
accounting.setStatus("REVIEWED");
accounting.setCalcTime(new Date());
accountingMapper.updateById(accounting);
}
}7.3 活动数据从哪来
| 排放源 | 活动数据 | 来源 |
|---|---|---|
| 外购电 | kWh | energy_stat 月累计,介质 electricity |
| 天然气 | Nm³ | energy_stat 月累计,介质 gas |
| 蒸汽 | GJ/t | 流量计累计 + 焓值换算 |
| 制冷剂 | kg | 手工台账/采购记录 |
| 车辆燃油 | L | 加油卡/台账 |
建议做一个“活动数据汇总”中间表或视图,把不同来源统一成 source_type + activity_value + unit,再喂给核算引擎。不要让核算服务直接查十几张业务表。
8. 报警
8.1 规则表
CREATE TABLE energy_alarm_rule (
rule_id BIGINT NOT NULL AUTO_INCREMENT,
rule_name VARCHAR(128) NOT NULL,
point_id BIGINT DEFAULT NULL,
rule_type VARCHAR(16) NOT NULL COMMENT 'THRESHOLD/RATE/OFFLINE/BUSINESS',
operator VARCHAR(8) DEFAULT '>' COMMENT '> >= < <= =',
threshold DECIMAL(18,6) DEFAULT NULL,
duration_sec INT DEFAULT 0 COMMENT '持续多久才报警',
alarm_level VARCHAR(8) DEFAULT 'WARN' COMMENT 'INFO/WARN/CRITICAL',
notify_channel VARCHAR(64) DEFAULT NULL COMMENT 'SMS/EMAIL/WECHAT',
status CHAR(1) DEFAULT '0',
create_time DATETIME DEFAULT NULL,
PRIMARY KEY (rule_id),
KEY idx_point (point_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报警规则';8.2 报警去重
public void raise(String pointCode, BigDecimal value, String level) {
String key = "energy:alarm:active:" + pointCode;
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(key, value.toPlainString(), Duration.ofMinutes(30));
if (Boolean.TRUE.equals(first)) {
// 第一次触发,写报警表、发通知
alarmMapper.insert(...);
} else {
// 已有活动报警,只更新最后触发时间,不重复通知
alarmMapper.updateLastTime(pointCode, new Date());
}
}恢复时删除 Redis key,并把报警状态改为“已恢复”。确认和关闭由人工操作,保留操作日志。
9. 前端页面
9.1 API 封装
import request from '@/utils/request'
export function listMeter(query) {
return request({
url: '/energy/meter/list',
method: 'get',
params: query
})
}
export function getMeter(meterId) {
return request({
url: '/energy/meter/' + meterId,
method: 'get'
})
}
export function addMeter(data) {
return request({
url: '/energy/meter',
method: 'post',
data: data
})
}9.2 页面骨架
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryRef" :inline="true">
<el-form-item label="计量点名称" prop="meterName">
<el-input v-model="queryParams.meterName" placeholder="请输入" clearable />
</el-form-item>
<el-form-item label="能源介质" prop="mediaId">
<el-select v-model="queryParams.mediaId" placeholder="全部" clearable>
<el-option
v-for="item in mediaOptions"
:key="item.mediaId"
:label="item.mediaName"
:value="item.mediaId"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="meterList">
<el-table-column label="编码" prop="meterCode" width="180" />
<el-table-column label="名称" prop="meterName" />
<el-table-column label="介质" prop="mediaName" width="100" />
<el-table-column label="层级" prop="meterLevel" width="80" />
<el-table-column label="状态" prop="status" width="80">
<template #default="scope">
<el-tag :type="scope.row.status === '0' ? 'success' : 'info'">
{{ scope.row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script setup name="Meter">
import { ref, reactive, onMounted } from 'vue'
import { listMeter } from '@/api/energy/meter'
const loading = ref(false)
const meterList = ref([])
const total = ref(0)
const mediaOptions = ref([])
const queryParams = reactive({
pageNum: 1,
pageSize: 10,
meterName: undefined,
mediaId: undefined
})
function getList() {
loading.value = true
listMeter(queryParams).then(res => {
meterList.value = res.rows
total.value = res.total
}).finally(() => {
loading.value = false
})
}
function handleQuery() {
queryParams.pageNum = 1
getList()
}
function resetQuery() {
queryParams.meterName = undefined
queryParams.mediaId = undefined
handleQuery()
}
onMounted(() => {
getList()
})
</script>9.3 ECharts 趋势图
<template>
<div ref="chartRef" style="width: 100%; height: 360px"></div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as echarts from 'echarts'
const chartRef = ref(null)
let chart = null
function render(data) {
chart = echarts.init(chartRef.value)
chart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: data.times },
yAxis: { type: 'value', name: 'kWh' },
series: [{
name: '电量',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { opacity: 0.12 },
data: data.values
}]
})
}
function handleResize() {
chart && chart.resize()
}
onMounted(() => {
window.addEventListener('resize', handleResize)
// 实际项目调用接口获取数据后 render(data)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
chart && chart.dispose()
})
</script>10. 菜单与权限 SQL
在若依“系统管理 → 菜单管理”里手工加也可以。用 SQL 更方便批量初始化,注意 menu_id 用自增,下面的 @parentId 依赖 LAST_INSERT_ID():
-- 一级菜单:能源管理
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, remark)
VALUES
('能源管理', 0, 5, 'energy', NULL, 1, 0, 'M', '0', '0', '', 'energy', 'admin', SYSDATE(), '能源管理目录');
SET @energyId = LAST_INSERT_ID();
-- 二级菜单:计量点
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, remark)
VALUES
('计量点', @energyId, 1, 'meter', 'energy/meter/index', 1, 0, 'C', '0', '0', 'energy:meter:list', 'meter', 'admin', SYSDATE(), '计量点菜单');
SET @meterId = LAST_INSERT_ID();
-- 按钮权限
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, remark)
VALUES
('计量点查询', @meterId, 1, '', NULL, 1, 0, 'F', '0', '0', 'energy:meter:query', '#', 'admin', SYSDATE(), ''),
('计量点新增', @meterId, 2, '', NULL, 1, 0, 'F', '0', '0', 'energy:meter:add', '#', 'admin', SYSDATE(), ''),
('计量点修改', @meterId, 3, '', NULL, 1, 0, 'F', '0', '0', 'energy:meter:edit', '#', 'admin', SYSDATE(), ''),
('计量点删除', @meterId, 4, '', NULL, 1, 0, 'F', '0', '0', 'energy:meter:remove', '#', 'admin', SYSDATE(), ''),
('计量点导出', @meterId, 5, '', NULL, 1, 0, 'F', '0', '0', 'energy:meter:export', '#', 'admin', SYSDATE(), '');11. 联调与测试
11.1 没有硬件时怎么开发
| 工具 | 用途 |
|---|---|
| Modbus Slave / ModbusPal | 模拟 Modbus TCP/RTU 从站,造寄存器数据 |
| MQTTX | 模拟网关发布 telemetry 消息 |
| EMQX Dashboard | 看连接、订阅、消息速率 |
| TDengine CLI | 查原始数据、验证聚合 |
| Postman | 调若依接口 |
| JMeter | 压测采集写入和查询接口 |
用 MQTTX 发一条测试消息:
{
"gatewayId": "GW-TEST-01",
"meterCode": "SH01-C01-ELE-001",
"energyType": "electricity",
"ts": 1760000000000,
"quality": 0,
"points": {
"UA": 220.1,
"P": 123.4,
"EP": 45678.9
}
}Topic:
energy/SH01/GW-TEST-01/telemetry11.2 联调顺序
先通 MQTT
MQTTX 能连上 EMQX,Collector 能收到消息并打日志。
再通时序库
Collector 能把一条数据写进 TDengine,CLI 能查到。
再通 Redis
监控页能读到最新值,TTL 正常。
再通业务库
计量点、点位配置能从若依页面维护。
再通统计
跑一次小时聚合,
energy_stat有结果,重复跑不翻倍。最后通碳核算
用一条电费数据手工算一遍,和系统结果对比。
11.3 常见问题
| 问题 | 原因 | 处理 |
|---|---|---|
| 启动报 Mapper 找不到 | 包路径不在扫描范围 | 检查 @MapperScan 和 mapperLocations |
| 代码生成页面 404 | 前端路由/菜单没配 | 检查菜单 component 路径 |
| TDengine 连接超时 | 端口 6030 未开放/驱动版本不匹配 | 用 CLI 先验证,再查 JDBC URL |
| MQTT 一直重连 | ClientId 冲突/账号 ACL | 换 ClientId,查 EMQX 日志 |
| 数据重复 | QoS 1 重发/补传没去重 | 用 gatewayId + seq 或时间戳去重 |
| 统计值翻倍 | 任务重复执行/唯一键失效 | 检查 uk_stat 和 upsert |
| 碳核算结果与 Excel 不一致 | 单位/因子/口径不同 | 逐步对比活动数据和因子 |
| 页面图表空白 | 容器高度为 0/数据为空 | 给容器固定高度,检查接口返回 |
12. 开发顺序建议
| 顺序 | 任务 | 产出 |
|---|---|---|
| 1 | 若依跑通、建库、建模块 | 能登录、能访问业务菜单 |
| 2 | 建介质、计量点、点位表 | 基础数据可维护 |
| 3 | MQTTX → Collector → TDengine | 一条模拟数据入库 |
| 4 | Redis 最新值 + 监控页 | 页面能看到实时值 |
| 5 | 小时/日统计 | 趋势图有数据 |
| 6 | 报警规则 + 报警页 | 超限能触发 |
| 7 | 碳因子 + 核算任务 | 能算出总排放 |
| 8 | 报表导出 + 权限 | 能交付试用 |
| 9 | 接真实仪表 | 数据与现场一致 |
下一章讲部署:怎么把若依、Collector、MySQL、Redis、TDengine、EMQX 编排到一台或几台服务器上,以及上线前要检查什么。