
简介一份基于 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 MapString, String header new HashMap(); private final MapString, 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) { // 浓度折算、超标预判、数据归档 } }注册表用ConcurrentHashMapString, 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(ListMonitoringData 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 idpageSearch resultTypecom.example.vo.MonitorDataVO SELECT md.*, mp.point_name FROM monitoring_data md JOIN monitor_point mp ON md.point_id mp.id where if testpollutantCode ! null and pollutantCode ! AND md.pollutant_code #{pollutantCode} /if if teststartTime ! null AND md.monitor_time gt; #{startTime} /if if testendTime ! null AND md.monitor_time lt; #{endTime} /if if testoverStandard ! null and overStandard AND md.actual_value gt; mp.standard_limit /if /where ORDER BY md.monitor_time DESC LIMIT #{offset}, #{pageSize} /selectgt;是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) { ListMonitoringData page mapper.selectByCursor(lastId, pageSize); if (page.isEmpty()) { break; } exportService.append(page); lastId page.get(page.size() - 1).getId(); }这里的lastId是游标第一页传入nullMySQL走主键索引顺序扫描跳过已读数据的成本远低于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代码而没用连接池。本文还有配套的精品资源点击获取