简介:一份基于 Java 语言的东软环保监督系统设计源码,面向 Java 后端开发者与环境信息化项目人员,用于学习企业级业务系统的完整搭建思路。资源共 129 个文件,压缩包约 223KB,其中以 108 个 Java 源文件为核心,覆盖 JWT 认证、反馈处理、巡检管理、管理员后台等典型模块;另有 14 个 XML 配置与 2 个 YML 文件用于 Spring/Spring Boot 组件整合和环境配置,SQL 文件提供数据库表结构与初始化数据,properties 文件辅助多环境参数调整,并附 PDF 说明文档,便于快速上手。已有 469 人学习下载。从内容预览可看到 JwtTokenUtil、AqiFeedbackController、RedisServiceImpl、InspectorServiceImpl 等关键类,能帮助理解接口鉴权、缓存服务、AQI 反馈流转和巡查员业务的具体实现;结合项目的分层设计与 Git 忽略文件,也可学习代码组织、版本管理及企业级项目文档配套的实践方法。
1. 环保监督系统在监督什么,为什么Java能撑住整套骨架
一块大屏上滚动着辖区内各排污口的实时监测数据,每五分钟上来一条浓度值,偶尔跳出红色告警。真正干过这类项目的人都知道,它不是一个“能查数据的网站”,而是把数采仪接入、数据清洗、超标判定、告警推送、执法任务流转串成一条长链的复杂业务系统。东软环保监督系统的设计源码,正是围绕这条链路展开的。选Java做主语言,不是因为“企业级”这个标签,而是因为Spring Boot、MyBatis、Quartz这些组件能直接把数采解析、持久化、定时汇总、任务流这些固定动作复用起来,人才池也大。新手接手这类源码,最该先看的是数据模型和状态流转,而不是界面;Java八股里常问的工厂模式、状态机、动态SQL,这套系统基本全占了。本文按我从零搭这类系统的思路,把模块、表结构、核心代码、查询优化和排错经验一次说透。
2. 环保监督系统的模块划分与核心数据模型
2.1 从数采到执法,系统先拆出四条主线
这类系统最常见的拆分方式是按业务域分模块,而不是按技术层分。第一块是档案管理,管污染源基本信息、排污许可证、行业类别、行政区域,这是所有业务的地基。第二块是在线监测,管监测点、排放口、污染物编码、阈值设置,以及每天几百万行的监测数据。第三块是告警与调度,负责超标判定、异常离线检测、告警合并与推送。第四块是执法闭环,从告警生成任务、指派到人、整改上报,再到复查销号。
模块划分的关键在于“污染源—监测点—排放口”这条主链不能被拆散。比如监测数据表要能直接关联到排放口,排放口再归到污染源,否则做区域统计时要跨三张表反复join。我见过不少源码后期改出问题,都是因为在监测点表里塞了太多冗余字段,导致新增一个监测指标就要改表结构。
2.2 用ER关系定下污染源、监测点、排放口的边界
在写建表语句之前,先把实体关系理清楚。一个污染源可以对应多个监测点,一个监测点对应一个排放口,一个排放口下可以配置多个监测指标,比如化学需氧量、氨氮、总磷。监测数据表落在最底层,每个指标一行,而不是把多个指标揉成一行的宽表。宽表查询时确实少join,但新增指标要加列,历史数据要回填,后期非常痛苦。
| 实体 | 关键字段 | 与上级实体关系 |
|---|---|---|
| 污染源 | id, 名称, 行业代码, 区域编码, 许可证号 | 无 |
| 监测点 | id, 污染源id, 排放口名称, 经度, 纬度 | N:1 归属污染源 |
| 监测项目 | id, 监测点id, 污染物编码, 单位, 标准限值 | N:1 归属监测点 |
| 监测数据 | id, 监测项目id, 实测值, 流量, 采集时间 | N:1 关联监测项目 |
这张ER模型跑通之后,像“某区域所有超标点位”这种查询,就是从污染源表过滤区域,再关联监测点、监测项目,最后落到监测数据的标准限值比较。MySQL里老老实实走外键逻辑而非物理外键,线上写入性能损失更小。
2.3 监测数据表的分区与索引设计
监测数据是整张系统里膨胀最快的表,一天几百万行很常见。如果不提前分区,三个月后按时间范围查询就会全表扫描。常见做法是把主键改成复合主键,用采集时间做RANGE分区,按月或按季度切分。
CREATE TABLE monitoring_data ( id BIGINT NOT NULL AUTO_INCREMENT, point_id BIGINT NOT NULL COMMENT '监测点ID', pollutant_code VARCHAR(16) NOT NULL COMMENT '污染物编码', actual_value DECIMAL(12, 3) NOT NULL COMMENT '实测值', flow_rate DECIMAL(12, 3) DEFAULT NULL COMMENT '流量', monitor_time DATETIME NOT NULL COMMENT '采集时间', PRIMARY KEY (id, monitor_time) ) PARTITION BY RANGE (TO_DAYS(monitor_time)) ( PARTITION p202501 VALUES LESS THAN (TO_DAYS('2025-02-01')), PARTITION p202502 VALUES LESS THAN (TO_DAYS('2025-03-01')), PARTITION p202503 VALUES LESS THAN (TO_DAYS('2025-04-01')) );这里有两个点需要说明。一是主键必须带上分区键monitor_time,否则MySQL会拒绝创建分区表。二是分区不是建完就完事,需要定期用ALTER TABLE monitoring_data ADD PARTITION提前创建下个月的分区,否则数据写入时会直接报错。索引方面,查询条件是“监测点+时间范围”最常见,所以联合索引(point_id, monitor_time)是首选,报表按污染物聚合时再补一个(pollutant_code, monitor_time),不要一上来就把所有字段都加索引。
3. 监测接入、超标判定与告警闭环的Java实现
3.1 用工厂模式把污染物解析从if-else里解放出来
数采仪上报的数据帧通常是分号分隔的字符串,里面既包含请求编号、系统编码这类头信息,也包含CP=&&数据区里的污染物浓度。解析的第一步是把字符串拆成结构化的帧对象,而不是直接在业务代码里到处split。
public class ProtocolFrame { private final Map<String, String> header = new HashMap<>(); private final Map<String, String> cp = new HashMap<>(); public static ProtocolFrame parse(String raw) { ProtocolFrame frame = new ProtocolFrame(); String[] parts = raw.split(";"); for (String part : parts) { if (part.startsWith("CP=&&")) { String cpBody = part.substring(5, part.length() - 2); for (String kv : cpBody.split(",")) { String[] entry = kv.split("=", 2); frame.cp.put(entry[0], entry[1]); } } else if (part.contains("=")) { String[] entry = part.split("=", 2); frame.header.put(entry[0], entry[1]); } } return frame; } }解析时用split("=", 2)而不是split("="),是因为污染物名称本身可能携带等号场景,即使实际没遇到,这个习惯也能避免数组越界。帧解析完并不代表数据合法,还要对必填字段做空值校验、对数值做范围校验,异常帧单独落到错误表而不是直接丢弃,排查数采仪问题时全靠这张表。
污染物类型会持续增加,如果再写一层层if ("COD".equals(code)),每加一种指标就要改入口方法。这时候用工厂模式更干净:定义一个统一的处理接口,每个污染物一个实现类,再用一个Map按编码注册。
public interface PollutantHandler { boolean supports(String pollutantCode); void handle(MonitorPoint point, BigDecimal value, LocalDateTime time); } public class CodHandler implements PollutantHandler { @Override public boolean supports(String pollutantCode) { return "COD".equals(pollutantCode); } @Override public void handle(MonitorPoint point, BigDecimal value, LocalDateTime time) { // 浓度折算、超标预判、数据归档 } }注册表用ConcurrentHashMap<String, PollutantHandler>,在Spring启动时把实现类按supports()的返回值放入Map。这样新增污染物时只写新Handler,不动调用方。对熟手来说,这里还能看出一个隐含约束:Handler不能持有可变的成员变量,必须是无状态的,否则并发环境下状态会串。
3.2 超标状态机:判定与恢复不能写在同一个if里
很多初级实现是每次来一条数据都和阈值比一下,超过就发告警,没超过就不发。这样做最直接的后果是毛刺数据导致告警风暴,比如数采仪瞬间抖动一下,就会产生一条虚假告警。成熟的方案是引入状态机,每一个监测项目在内存或Redis里维护一个当前状态,只有状态发生跳转时才产生事件。
public enum ExceedStatus { NORMAL, EXCEEDING, RECOVERING }状态跳转规则如下:NORMAL状态下连续三次监测值超限,才转入EXCEEDING并生成告警;EXCEEDING状态下连续三次监测值回落到限值以内,先进入RECOVERING,再等两个周期确认无反弹后回到NORMAL。这样就把“瞬时超标”和“持续超标”区分开了。计数器可以用Redis的INCR加过期时间实现,也可以用Caffeine做本地缓存,单机部署时本地缓存更省事。
告警合并也值得注意。同一污染源的多个监测点在同一小时同时超标,应该合并成一条告警事件,而不是生成一堆碎片提醒,否则执法人员的手机消息会被刷爆。实现上可以对告警事件按“污染源+小时”做分组键,在内存里做滑窗聚合,窗口结束再统一推送。
3.3 批量入库与定时任务,把压力留在夜里
监测数据入库不能一条一条insert,否则数据库连接会成为瓶颈。常见做法是攒够500条或者每隔两秒批量刷一次。MyBatis批量写用<foreach>拼values,这里要控制单批大小,超过MySQL的max_allowed_packet会报错,实际经验是500到1000条一批比较合适,每批结束后清空列表。
@Transactional public void batchSave(List<MonitoringData> batch) { if (batch.isEmpty()) { return; } monitoringDataMapper.batchInsert(batch); }定时任务方面,每日排放量汇总、月度报表、告警统计这类任务都放在凌晨执行。用Spring自带的@Scheduled时,一旦部署多个实例,任务会重复执行。常见做法是引入分布式锁,用Redis的SETNX加过期时间,拿到锁的实例才执行任务。
String lockKey = "job:dailyReport:lock"; String token = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, token, Duration.ofMinutes(10)); if (Boolean.TRUE.equals(locked)) { try { dailyReportService.execute(); } finally { // 用Lua脚本比对token再删除,防止误删他人锁 releaseLock(lockKey, token); } }释放锁必须比对token,直接delete(lockKey)会在锁过期时误删其他实例刚抢到的锁。这里Duration.ofMinutes(10)是锁的持有时间,任务跑不完就续期,或者选择Redisson的看门狗机制,道理一样。
4. 查询、报表与执法文书的工程化落地
4.1 动态SQL扛住几十个筛选条件,MyBatis源码教会我们的事
监督系统里最常见的页面是“监测数据查询”,筛选项通常包括行政区域、行业类别、监测点名称、污染物编码、是否超标、时间范围,加起来十几个条件。一次性写死SQL不现实,MyBatis的动态SQL就是为了这个场景存在的。
<select id="pageSearch" resultType="com.example.vo.MonitorDataVO"> SELECT md.*, mp.point_name FROM monitoring_data md JOIN monitor_point mp ON md.point_id = mp.id <where> <if test="pollutantCode != null and pollutantCode != ''"> AND md.pollutant_code = #{pollutantCode} </if> <if test="startTime != null"> AND md.monitor_time >= #{startTime} </if> <if test="endTime != null"> AND md.monitor_time <= #{endTime} </if> <if test="overStandard != null and overStandard"> AND md.actual_value > mp.standard_limit </if> </where> ORDER BY md.monitor_time DESC LIMIT #{offset}, #{pageSize} </select>>=是XML里对>=的转义,直接写>=会把XML解析器搞懵。上面这个查询能正常工作,但还有一个隐性问题:overStandard条件里把actual_value和standard_limit做比较,这会导致MySQL无法用联合索引过滤,数据量大时性能会明显下降。这类“是否超标”的高频筛选,更可靠的做法是在写入时冗余一个exceed_flag字段,查询时直接查标记位。
另外,时间范围是这类查询最重要的条件,MyBatis最后生成的SQL要尽量让monitor_time直接参与索引范围扫描,不要在SQL里写DATE_FORMAT(monitor_time, '%Y-%m-%d') = ...这种对列做函数运算的写法,函数包裹列会让索引失效。这是读MyBatis源码时最值得记住的一点:框架只负责生成SQL,索引能不能用上还得看生成出来的条件长什么样。
4.2 按日、月、季度聚合排放量,SQL怎么写才不吃性能
排放量报表的统计口径是“实测浓度×流量×时间”,SQL里做简化时容易漏掉时间折算因子。下面是一般的日统计写法。
SELECT DATE_FORMAT(monitor_time, '%Y-%m-%d') AS stat_date, pollutant_code, ROUND(AVG(actual_value), 2) AS avg_value, ROUND(MAX(actual_value), 2) AS max_value, ROUND(SUM(actual_value * flow_rate), 2) AS emission_load FROM monitoring_data WHERE monitor_time >= #{startTime} AND monitor_time < #{endTime} GROUP BY stat_date, pollutant_code ORDER BY stat_date;注意几个参数细节。SUM(actual_value * flow_rate)算出来的是段时间内的累计负荷,不是严格意义上的排放总量,实际项目中还要乘以单条数据的代表时长,比如五分钟一条数据要乘5/60。AVG(actual_value)也有讲究,很多监测点夜间停产时上报0值,如果直接平均,会把日均浓度拉低,统计前要决定是否过滤停机时段。
月报表和季度报表不建议直接用sale一个SQL跑全部,而是先在每日汇总表里算好日结果,月报表再聚合日表,这样三层报表的查询时间基本恒定。
4.3 执法任务流转与文书生成串起闭环
告警确认之后要生成执法任务,执法任务表至少要包含任务编号、来源告警ID、污染源ID、任务类型、指派人、处理状态、办结时限这些字段。状态的推进用一张状态流转表维护,每个状态之间的合法动作要固定,避免直接改状态字段。
| 状态 | 允许动作 | 下一状态 |
|---|---|---|
| 待分派 | 指派处理人 | 处理中 |
| 处理中 | 提交整改报告 | 待复查 |
| 待复查 | 复查通过 | 已办结 |
| 待复查 | 复查不通过 | 处理中 |
执法文书用FreeMarker这类模板引擎生成Word,数据模型里放污染源名称、超标倍数、监测时间、标准限值,模板里做变量替换。生成后的文件建议落到对象存储,数据库只存路径,不要把二进制写在MySQL里。
5. 针对东软环保监督系统的调优与排错技巧
5.1 时间格式化与数值类型,是两个最容易埋雷的地方
告警汇总和报表任务都是多线程执行的,如果代码里用了共享的SimpleDateFormat,会出现时间错乱、解析异常,甚至在日志里看到完全不可能出现的日期。正确做法是用DateTimeFormatter,它是线程安全的。
// 错误示范:多线程下共享SimpleDateFormat // private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); // 正确示范 private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); String timeText = DTF.format(LocalDateTime.now());数值字段统一用DECIMAL(12, 3)存浓度值,不要图省事用DOUBLE,累计求和时浮点误差会被放大,月度排放量算到第三位小数就偏了。
5.2 导出大范围数据时OOM,先查是不是一次性全load了
后台导出三个月监测数据时,如果代码里SELECT *然后循环写入Excel,内存必然扛不住。常见做法是分页拉取,但深分页时LIMIT 100000, 2000的偏移量扫描很慢,更推荐用keyset方式,每次拿上一页最后一条记录的ID。
Long lastId = null; int pageSize = 2000; while (true) { List<MonitoringData> page = mapper.selectByCursor(lastId, pageSize); if (page.isEmpty()) { break; } exportService.append(page); lastId = page.get(page.size() - 1).getId(); }这里的lastId是游标,第一页传入null,MySQL走主键索引顺序扫描,跳过已读数据的成本远低于LIMIT深分页。
5.3 生产排错先看三个地方,顺序不要乱
出现告警延迟、报表没跑出来,我的排查顺序是固定的。第一步看MySQL慢查询日志,把long_query_time设置成1秒,超过1秒的SQL都要捞出来看执行计划。第二步看JVM GC日志,JDK 9以上用-Xlog:gc*,重点看Full GC频率和耗时,如果每分钟都有Full GC,先检查是不是某个查询没加时间范围条件扫了全分区。第三步看调度线程池状态,用jcmd <pid> Thread.print看任务线程是阻塞在数据库连接池还是Redis上。
Fortify静态源码扫描操作也可以接入Jenkins,构建后自动跑一次,重点检查SQL注入、资源未释放、硬编码凭据这三类高危问题。扫描报告里出现数据库连接未关闭时,优先看是不是写了自定义JDBC代码而没用连接池。
本文还有配套的精品资源,点击获取