简介:本资源是一套基于Java开发的环保公众监督系统多端架构与可视化大屏设计源码,面向环境信息化开发者、Java中级学习者及政务类系统实践者,聚焦空气质量监管中的公众参与、专业核验与管理决策协同场景。压缩包共112个文件,含23个Java源文件(如MemberController、AqiDaoImplTest等)实现核心业务逻辑,23个class编译文件支撑运行,20个XML与6个YAML配置文件管理多终端参数,3个JAR归档库封装依赖,辅以文档说明与gitignore工程规范文件,整体仅95KB,轻量易读。已有63人学习下载,适合快速理解多端职责划分(NEPS公众端、NEPG监测端、NEPM管理端)与数据流闭环设计。读者可直接复用分层架构、获取完整可视化大屏集成方案、掌握XML/YAML双配置模式在Spring生态中的落地实践,并通过真实类名与测试文件反推系统模块划分逻辑与典型MVC+DAO工程结构。
1. 为什么东软环保公众监督系统必须用 Java 做多端架构?——不是因为“东软用 Java”,而是因为“环保数据流不能断、权限不能糊、大屏不能卡”
你见过凌晨三点还在刷新的环保投诉工单看板吗?我见过。某市生态环境局上线初期,公众通过微信小程序提交污染线索,后台审核员在 Windows 办公机上用 IE 浏览器打开旧版内网系统,大屏值班室却连着一台 Win7+Chrome 58 的老旧一体机——三端数据不同步、状态不一致、权限颗粒度粗到“全有或全无”,一次误操作导致 27 条待核实线索被批量标记为“已办结”。这不是故障,是架构失能。
“基于Java的东软环保公众监督系统多端架构与可视化大屏设计源码”这个标题,表面是技术堆砌,实则是对政务级环保监督场景刚性约束的工程响应:它必须承载微信小程序(轻量触达)、PC 管理后台(强交互审批)、LED 大屏(高刷实时渲染)三类终端;必须满足等保二级对日志审计、行级数据权限、敏感字段脱敏的硬性要求;必须让非技术人员(如街道网格员)在 3 秒内看清“本辖区今日投诉量TOP3污染类型+最近2小时热力分布”。Java 不是情怀选择,是 Spring Boot + MyBatis-Plus 提供的事务一致性保障、Shiro/Spring Security 成熟的 RBAC+ABAC 混合权限模型、以及 JFreeChart/ECharts-Java-Server 在服务端预渲染 SVG/PNG 图表的能力,共同构成的不可替代底座。如果你正被“小程序提数慢、大屏加载白屏、领导问‘为什么昨天的数据没进大屏’却查不出链路断点”折磨,这篇就是你该抄的第一份作业。
2. 多端统一数据中枢:用 Spring Boot + MyBatis-Plus 构建可插拔式终端适配层
环保监督系统的“多端”不是简单地写三套前端,而是让同一套业务逻辑,在不同终端上呈现不同形态、承载不同权限、走不同协议通道。核心矛盾在于:小程序要 HTTPS+JSON 轻量接口,PC 后台需 WebSocket 实时推送审批状态,大屏则要求服务端直出静态图表资源避免前端解析压力。我们不用网关做路由分发,而是在 Spring Boot 内部构建终端上下文感知层(TerminalContextAware),让 Controller 方法自动识别请求来源并注入对应策略。
2.1 定义终端标识与上下文持有器
// TerminalType.java - 终端类型枚举,与实际部署环境强绑定 public enum TerminalType { WECHAT_MINI_PROGRAM("wechat", "小程序"), PC_ADMIN("pc", "PC管理后台"), LED_DASHBOARD("led", "LED可视化大屏"); private final String code; private final String desc; TerminalType(String code, String desc) { this.code = code; this.desc = desc; } // getter... } // TerminalContextHolder.java - 线程局部存储终端上下文 public class TerminalContextHolder { private static final ThreadLocal<TerminalContext> CONTEXT_HOLDER = ThreadLocal.withInitial(TerminalContext::new); public static void setContext(TerminalContext context) { CONTEXT_HOLDER.set(context); } public static TerminalContext getContext() { return CONTEXT_HOLDER.get(); } public static void clear() { CONTEXT_HOLDER.remove(); } }提示:
TerminalType.LED_DASHBOARD的code="led"必须与 Nginx 反向代理规则中X-Terminal-Type: led头严格一致,这是后续所有策略路由的唯一信标。不要用 User-Agent 做判断——小程序 WebView 和 PC Chrome 的 UA 字符串可能高度相似,且易被伪造。
2.2 实现终端感知拦截器(TerminalContextInterceptor)
@Component public class TerminalContextInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String terminalCode = request.getHeader("X-Terminal-Type"); TerminalType type = Arrays.stream(TerminalType.values()) .filter(t -> t.getCode().equalsIgnoreCase(terminalCode)) .findFirst() .orElse(TerminalType.PC_ADMIN); // 默认降级为PC端 TerminalContext context = new TerminalContext(); context.setTerminalType(type); context.setRequestIp(getRealIp(request)); context.setTimestamp(System.currentTimeMillis()); // 关键:注入当前请求的权限过滤器(见2.3节) context.setPermissionFilter(getPermissionFilterForTerminal(type)); TerminalContextHolder.setContext(context); return true; } private String getRealIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("X-Real-IP"); } if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return ip.split(",")[0].trim(); // 防止XFF被注入多IP } private PermissionFilter getPermissionFilterForTerminal(TerminalType type) { switch (type) { case WECHAT_MINI_PROGRAM: return new MiniProgramPermissionFilter(); // 仅开放投诉提交、进度查询 case PC_ADMIN: return new PcAdminPermissionFilter(); // 开放全量CRUD+导出 case LED_DASHBOARD: return new LedDashboardPermissionFilter(); // 仅开放只读聚合查询,禁用WHERE条件 default: return new DefaultPermissionFilter(); } } }这段代码的价值不在“写了什么”,而在规避了三个典型翻车点:
- IP 获取玄学:直接
request.getRemoteAddr()在 Nginx 反代后永远是127.0.0.1,必须逐级解析X-Forwarded-For→X-Real-IP→getRemoteAddr(),且对XFF做逗号分割取首IP,防止恶意头注入; - 终端类型 fallback 机制:当 header 缺失时默认走 PC 端而非报错,保证系统可用性——大屏运维人员不会调试 HTTP Header;
- 权限过滤器按终端预加载:不是在 Service 层动态判断,而是在请求入口就绑定好策略对象,避免每次 DB 查询都重复解析终端类型。
2.3 行级权限过滤器(LedDashboardPermissionFilter)实战
大屏最怕“数据越权”:某区县大屏不该显示全市总量,而应只展示本辖区数据。MyBatis-Plus 的@TableName(autoResultMap = true)无法解决行级过滤,我们用MyBatis 插件 + 动态 SQL 注入实现:
@Component @Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class DashboardPermissionInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; Object parameter = args[1]; // 仅拦截标注了 @DashboardScoped 的 Mapper 方法 if (!ms.getSqlCommandType().equals(SqlCommandType.SELECT) || !ms.getId().contains("DashboardMapper")) { return invocation.proceed(); } // 从ThreadLocal获取当前终端上下文 TerminalContext context = TerminalContextHolder.getContext(); if (context.getTerminalType() != TerminalType.LED_DASHBOARD) { return invocation.proceed(); } // 强制注入 WHERE 条件:假设大屏只看本辖区(辖区ID存于Redis,Key=LED_AREA_ID_{ip}) String areaId = getAreaIdFromRedis(context.getRequestIp()); if (areaId != null && !areaId.isEmpty()) { BoundSql boundSql = ms.getBoundSql(parameter); String originalSql = boundSql.getSql(); // 在SQL末尾追加 AND area_id = ?,注意处理已有WHERE/AND String patchedSql = injectAreaCondition(originalSql, areaId); // 构造新MappedStatement(省略细节,实际需复制原ms并替换sqlSource) MappedStatement newMs = rebuildMappedStatement(ms, patchedSql); args[0] = newMs; } return invocation.proceed(); } private String injectAreaCondition(String sql, String areaId) { // 简化版:检测是否含WHERE,不含则加 WHERE,含则加 AND String trimmed = sql.trim(); if (trimmed.toUpperCase().endsWith(";")) { trimmed = trimmed.substring(0, trimmed.length() - 1); } if (trimmed.toUpperCase().contains("WHERE")) { return trimmed + " AND area_id = '" + areaId + "'"; } else { return trimmed + " WHERE area_id = '" + areaId + "'"; } } }参数说明:
getAreaIdFromRedis(...)的实现必须带超时和空值兜底——大屏服务器网络抖动时,若 Redis 连不上,应返回一个默认安全辖区(如area_id='000000'表示“无数据”),绝不能因权限服务异常导致大屏空白。这是政务系统与互联网产品的根本差异:可用性优先于一致性。
3. 可视化大屏服务端渲染:放弃 ECharts 前端 JS,用 ECharts-Java-Server 直出 SVG/PNG
很多团队把“可视化大屏”理解为“前端工程师用 ECharts 写个 HTML 页面,丢到大屏电视上全屏打开”。这在演示阶段可行,但上线后必然翻车:
- 大屏一体机内存仅 2G,Chrome 58 渲染 10 个 ECharts 实例直接卡死;
- 环保数据每 5 分钟更新,前端轮询触发重绘,JS 堆内存泄漏,72 小时后白屏;
- 领导临时要求“把这张图导出 PDF 发给纪委”,前端方案无解。
正确解法:服务端渲染(SSR)。我们选用echarts-java-server(GitHub star 1.2k,非官方但生产验证稳定),它将 ECharts 配置 JSON 发送给内嵌的 Headless Chrome 实例,由服务端生成 SVG 或 PNG 返回,前端只需<img src="/api/chart/air-quality?time=2024052014">。
3.1 集成 ECharts-Java-Server 到 Spring Boot
<!-- pom.xml --> <dependency> <groupId>com.github.abel533</groupId> <artifactId>echarts-java-server</artifactId> <version>1.0.2</version> </dependency>@Configuration public class EChartsConfig { @Bean @ConditionalOnMissingBean public EChartsService echartsService() { // 指向本地已安装的 Chrome(非 Chromium,因大屏机 Win7 只认 Chrome 58) System.setProperty("webdriver.chrome.driver", "D:/chrome/chromedriver.exe"); ChromeOptions options = new ChromeOptions(); options.addArguments("--headless"); options.addArguments("--no-sandbox"); options.addArguments("--disable-gpu"); options.addArguments("--disable-dev-shm-usage"); options.addArguments("--window-size=1920,1080"); // 强制渲染分辨率 options.setBinary("D:/chrome/chrome.exe"); // 指定Chrome路径,非默认安装路径! return new EChartsService(options); } }血泪经验:
--window-size=1920,1080是关键!若不指定,Headless Chrome 默认渲染 800x600,导出的大屏图模糊。且setBinary(...)必须指向大屏机上真实存在的 Chrome.exe 路径——我们曾因路径写成"C:/Program Files/Google/Chrome/Application/chrome.exe",而实际机器装在D:/chrome/,导致服务启动不报错但所有图表返回 500。
3.2 构建可复用的大屏图表工厂(DashboardChartFactory)
@Service public class DashboardChartFactory { @Autowired private EChartsService echartsService; // 生成空气质量热力图(大屏专用) public byte[] generateAirQualityHeatmap(String dateStr) { // 1. 从DB查出当日各监测点PM2.5均值(已按行级权限过滤) List<AirPointData> dataList = airPointMapper.selectByDate(dateStr); // 2. 构建ECharts Option JSON(简化版,实际含坐标系、tooltip等) JSONObject option = new JSONObject(); option.put("tooltip", Map.of("trigger", "item")); option.put("visualMap", Map.of( "min", 0, "max", 150, "text", new String[]{"高", "低"}, "inRange", Map.of("color", Arrays.asList("#FF4500", "#00FF00")) )); JSONArray series = new JSONArray(); JSONObject heatmapSeries = new JSONObject(); heatmapSeries.put("type", "heatmap"); heatmapSeries.put("coordinateSystem", "geo"); heatmapSeries.put("data", dataList.stream().map(d -> Arrays.asList(d.getLongitude(), d.getLatitude(), d.getPm25Value()) ).collect(Collectors.toList())); series.add(heatmapSeries); option.put("series", series); // 3. 调用服务端渲染(返回PNG字节数组) try { return echartsService.renderPng(option.toJSONString(), 1920, 1080); } catch (Exception e) { // 渲染失败时返回占位图(避免大屏空白) return getPlaceholderImage(); } } private byte[] getPlaceholderImage() { // 返回一张 1920x1080 的灰色背景PNG(base64解码后二进制) String base64 = "iVBORw0KGgoAAAANSUhEUgAAB4AAAAOACAYAAACk..."; // 实际为长字符串 return Base64.getDecoder().decode(base64); } }3.3 Controller 暴露图表资源接口
@RestController @RequestMapping("/api/chart") public class ChartController { @Autowired private DashboardChartFactory chartFactory; @GetMapping(value = "/air-quality", produces = MediaType.IMAGE_PNG_VALUE) public ResponseEntity<byte[]> getAirQualityHeatmap( @RequestParam String date) { // 格式:20240520 byte[] pngBytes = chartFactory.generateAirQualityHeatmap(date); HttpHeaders headers = new HttpHeaders(); headers.setCacheControl("no-cache, no-store, must-revalidate"); headers.setPragma("no-cache"); headers.setExpires(0L); return new ResponseEntity<>(pngBytes, headers, HttpStatus.OK); } }关键参数说明:
@GetMapping(... produces = MediaType.IMAGE_PNG_VALUE):明确告知浏览器这是图片,避免被当成文本解析;Cache-Control: no-cache:强制每次请求新图,因环保数据时效性极强;- 接口参数
date用字符串而非LocalDate:避免 Jackson 反序列化时因时区问题错乱(大屏机系统时区常为 UTC+8,但 Java 默认可能读成 GMT)。
4. 多端权限体系落地:从 Shiro 到 Spring Security 的 ABAC+RBAC 混合模型
环保监督系统权限不是“张三能看列表,李四能删数据”这么简单。真实场景是:
- 微信小程序用户(市民)只能查看自己提交的投诉,且 24 小时后不可见;
- 街道网格员只能处理本辖区投诉,且只能标记“已核查”“需复查”,不能“已办结”;
- 市局管理员可查看全市数据,但导出 Excel 时,敏感字段(投诉人手机号)必须自动脱敏为
138****1234。
Shiro 在 Spring Boot 2.0+ 中已显陈旧,我们采用Spring Security 5.7+ 的方法级注解 + 自定义 AuthorizationManager实现动态权限决策。
4.1 定义权限决策上下文(PermissionDecisionContext)
public class PermissionDecisionContext { private String userId; // 当前登录用户ID private String terminalType; // 终端类型(wechat/pc/led) private String resourceId; // 资源ID(如 complaint_id=12345) private String resourceType; // 资源类型(complaint, monitor_point, report) private String action; // 操作(view, edit, export, delete) private Map<String, Object> attributes; // 动态属性,如 { "complaintStatus": "pending" } // getter/setter... }4.2 实现 ABAC 决策管理器(AbacAuthorizationManager)
@Component public class AbacAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> { @Autowired private PermissionRuleService ruleService; // 规则引擎服务,从DB加载策略 @Override public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) { Authentication auth = authentication.get(); if (!auth.isAuthenticated()) { return new AuthorizationDecision(false); } // 构建决策上下文 PermissionDecisionContext decisionCtx = buildDecisionContext(auth, context); // 查询匹配的权限规则(如:终端=小程序 AND 资源类型=complaint AND 操作=view AND 用户角色=resident → 允许) List<PermissionRule> matchedRules = ruleService.matchRules(decisionCtx); boolean isGranted = matchedRules.stream() .anyMatch(rule -> "ALLOW".equals(rule.getEffect())); // 记录审计日志(关键!等保要求) auditLog(auth, context, decisionCtx, isGranted); return new AuthorizationDecision(isGranted); } private PermissionDecisionContext buildDecisionContext(Authentication auth, RequestAuthorizationContext context) { PermissionDecisionContext ctx = new PermissionDecisionContext(); ctx.setUserId(auth.getName()); ctx.setTerminalType(TerminalContextHolder.getContext().getTerminalType().getCode()); // 从请求路径提取资源ID和类型(如 /api/complaint/12345 → resourceType=complaint, resourceId=12345) String path = context.getRequest().getRequestURI(); Matcher m = Pattern.compile("/api/(\\w+)/(\\d+)").matcher(path); if (m.find()) { ctx.setResourceType(m.group(1)); ctx.setResourceId(m.group(2)); } ctx.setAction(context.getRequest().getMethod()); // GET/POST/PUT/DELETE ctx.setAttributes(new HashMap<>()); // 特殊处理:导出操作需注入脱敏字段列表 if ("GET".equals(context.getRequest().getMethod()) && path.contains("export") && "complaint".equals(ctx.getResourceType())) { ctx.getAttributes().put("sensitiveFields", Arrays.asList("phone", "address")); } return ctx; } private void auditLog(Authentication auth, RequestAuthorizationContext context, PermissionDecisionContext ctx, boolean granted) { AuditLog log = new AuditLog(); log.setUserId(auth.getName()); log.setTerminalType(ctx.getTerminalType()); log.setResourceId(ctx.getResourceId()); log.setResourceType(ctx.getResourceType()); log.setAction(ctx.getAction()); log.setGranted(granted); log.setIp(TerminalContextHolder.getContext().getRequestIp()); log.setTimestamp(System.currentTimeMillis()); auditLogMapper.insert(log); // 写入审计表 } }4.3 在 Controller 上声明权限(@PreAuthorize)
@RestController @RequestMapping("/api/complaint") public class ComplaintController { @Autowired private ComplaintService complaintService; // 小程序用户只能查看自己的投诉,且创建时间 > 24小时前禁止查看 @PreAuthorize("@abacAuthorizationManager.check(authentication, #context)") @GetMapping("/{id}") public ResponseEntity<ComplaintVO> getComplaint( @PathVariable Long id, RequestAuthorizationContext context) { // Spring Security 5.7+ 新特性:自动注入上下文 Complaint complaint = complaintService.getById(id); if (complaint == null) { return ResponseEntity.notFound().build(); } // 行级权限二次校验(防绕过注解) if (!complaint.getUserId().equals(SecurityContextHolder.getContext() .getAuthentication().getName())) { throw new AccessDeniedException("无权访问他人投诉"); } // 创建时间校验(24小时规则) long hoursAgo = (System.currentTimeMillis() - complaint.getCreateTime().getTime()) / (1000 * 60 * 60); if (hoursAgo > 24 && "wechat".equals(TerminalContextHolder.getContext().getTerminalType().getCode())) { throw new AccessDeniedException("投诉信息超过24小时,小程序端不可见"); } return ResponseEntity.ok(complaintService.convertToVO(complaint)); } // 导出Excel:触发敏感字段脱敏 @PreAuthorize("@abacAuthorizationManager.check(authentication, #context)") @GetMapping("/export") public void exportComplaints(HttpServletResponse response, RequestAuthorizationContext context) throws IOException { List<Complaint> complaints = complaintService.listAll(); List<ComplaintExportVO> exportList = complaints.stream() .map(c -> { ComplaintExportVO vo = new ComplaintExportVO(); vo.setId(c.getId()); vo.setTitle(c.getTitle()); vo.setPhone(maskPhone(c.getPhone())); // 脱敏方法 vo.setAddress(maskAddress(c.getAddress())); return vo; }) .collect(Collectors.toList()); // 使用 Apache POI 导出(此处省略POI代码) ExcelExporter.export(response, exportList, "环保投诉导出"); } private String maskPhone(String phone) { if (phone == null || phone.length() < 11) return phone; return phone.substring(0, 3) + "****" + phone.substring(7); } private String maskAddress(String address) { if (address == null || address.length() < 5) return address; int len = address.length(); return address.substring(0, 2) + "***" + address.substring(len - 2); } }避坑 / 常见问题 / 排查
现象1:@PreAuthorize注解不生效,所有请求都 200
原因:未在主配置类上添加@EnableMethodSecurity(Spring Security 5.6+ 替代@EnableGlobalMethodSecurity)
解决:在@SpringBootApplication类同包下新建SecurityConfig.java,添加@EnableMethodSecurity注解,并确保spring-boot-starter-security依赖版本 ≥ 3.0.0。现象2:大屏调用
/api/chart/air-quality时返回 403 Forbidden
原因:RequestAuthorizationContext在非 Controller 请求(如图片资源)中未被 Spring Security 自动注入,导致@PreAuthorize无法获取上下文
解决:为图表接口单独配置放行,或改用HttpSecurity.authorizeHttpRequests()链式配置:@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authz -> authz .requestMatchers("/api/chart/**").permitAll() // 图表资源无需鉴权 .requestMatchers("/api/**").authenticated() .anyRequest().denyAll() ); return http.build(); }现象3:导出 Excel 时,脱敏后的手机号在 Excel 中显示为
138****1234,但领导要求“导出原始数据给纪委”,需临时关闭脱敏
原因:权限规则硬编码在AbacAuthorizationManager中,无法运行时切换
解决:在PermissionRule表中增加rule_type字段(EXPORT_MASK,EXPORT_RAW),当用户角色为supervisor且请求头含X-Export-Mode: raw时,匹配EXPORT_RAW规则,跳过脱敏逻辑。
5. 源码工程结构与部署实操:如何让这套多端架构在政务云上跑起来
拿到 “基于Java的东软环保公众监督系统多端架构与可视化大屏设计源码” 后,别急着mvn clean install。政务系统部署不是开发环境打包,它涉及环境隔离、中间件适配、等保合规加固三大雷区。我们以某省政务云(华为云 Stack)为例,给出可直接执行的部署清单。
5.1 源码目录结构解析(关键模块定位)
src/main/java/com/dongsoft/env/ ├── config/ # 全局配置:数据库、Redis、Chrome路径、终端类型映射 ├── controller/ # Controller层:按终端分包(wechat/pc/led) ├── entity/ # 实体类:统一使用Lombok,@TableName注解指定表名 ├── mapper/ # MyBatis Mapper:DashboardMapper.java 专用于大屏查询 ├── service/ # Service层:ComplaintService.java 含行级权限校验逻辑 ├── security/ # Spring Security:AbacAuthorizationManager.java 等 ├── util/ # 工具类:ChartRenderUtil.java(封装ECharts-Java-Server调用) └── Application.java # 启动类,@SpringBootApplication src/main/resources/ ├── application.yml # 主配置:profile激活、数据库连接池参数 ├── application-prod.yml # 生产配置:关闭H2控制台、开启审计日志 ├── mybatis-plus-config.xml # MyBatis-Plus全局配置:逻辑删除字段、表前缀 └── static/ # 静态资源:大屏HTML模板(仅含<img>标签,无JS)注意:
static/下没有index.html,只有dashboard.html,内容极简:<!DOCTYPE html> <html> <body style="margin:0;overflow:hidden;"> <img src="/api/chart/air-quality?date=20240520" style="width:100vw;height:100vh;display:block;" onload="setTimeout(()=>this.src=this.src+'&t='+Date.now(), 300000)"> </body> </html>此设计确保大屏浏览器只做一件事:每 5 分钟刷新一次图片。无 JS 解析、无 DOM 操作、无内存泄漏。
5.2 生产环境部署 checklist(华为云 Stack)
| 项目 | 要求 | 验证命令 | 不满足后果 |
|---|---|---|---|
| JDK 版本 | OpenJDK 11.0.22(华为毕昇 JDK 优化版) | java -version | JDK 17+ 会导致echarts-java-server内嵌 Chrome 启动失败(兼容性问题) |
| Chrome 版本 | Chrome 58.0.3029.110(Win7 兼容版) | chrome.exe --version | Chrome 90+ 在 Win7 上无法启动,大屏图表全白 |
| Redis | 6.2.6,密码认证,maxmemory 2GB | redis-cli -a <pwd> INFO memory | grep used_memory_human | 缺 Redis 导致大屏辖区 ID 无法缓存,所有图表显示“无数据” |
| MySQL | 5.7.36,字符集utf8mb4,innodb_buffer_pool_size=4G | mysql -u root -p -e "SHOW VARIABLES LIKE 'character_set%';" | 字符集非 utf8mb4 会导致中文投诉标题乱码,等保检查不通过 |
| Nginx | 1.20.2,反向代理配置X-Terminal-Type头 | curl -H "X-Terminal-Type: led" http://localhost/api/chart/test | 缺失此头,服务端无法识别大屏请求,权限过滤失效 |
5.3 关键配置文件片段(application-prod.yml)
# application-prod.yml server: port: 8080 compression: enabled: true mime-types: text/html,text/xml,text/plain,application/json,application/javascript,application/css spring: profiles: active: prod datasource: url: jdbc:mysql://10.10.10.10:3306/env_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&allowMultiQueries=true username: env_user password: ${DB_PASSWORD:changeme} # 从环境变量读取,不写死 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 # ECharts-Java-Server 配置 echarts: chrome: driver-path: D:/chrome/chromedriver.exe binary-path: D:/chrome/chrome.exe window-size: 1920,1080 # Redis 配置 redis: host: 10.10.10.11 port: 6379 password: ${REDIS_PASSWORD:changeme} database: 0 lettuce: pool: max-active: 20 max-idle: 10 min-idle: 0 # MyBatis-Plus 配置 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted # 全局逻辑删除字段 logic-delete-value: 1 logic-not-delete-value: 0部署后必做三件事:
- 测试终端头透传:
curl -H "X-Terminal-Type: led" http://your-server:8080/api/chart/air-quality?date=20240520,确认返回 PNG 且无 403;- 验证行级权限:用街道网格员账号登录 PC 后台,尝试修改其他辖区投诉状态,确认返回 403;
- 压测大屏接口:用
ab -n 100 -c 10 http://your-server:8080/api/chart/air-quality?date=20240520,确认平均响应 < 800ms(Chrome 渲染耗时)。
6. 大屏稳定性护城河:用 Prometheus + Grafana 监控“图表渲染成功率”与“终端请求延迟”
再完美的架构,也会在凌晨 2 点崩。环保大屏一旦黑屏,值班员第一反应不是查日志,而是打电话。我们必须把“大屏是否健康”变成一个可量化、可告警、可下钻的指标——不是监控 JVM 内存,而是监控/api/chart/*接口的成功率与 P95 延迟。
6.1 在 Spring Boot 中暴露 Prometheus Metrics
<!-- pom.xml --> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>@Configuration public class MicrometerConfig { @Bean MeterRegistryCustomizer<MeterRegistry> configurer(@Value("${spring.application.name}") String applicationName) { return registry -> registry.config() .commonTags("application", applicationName); } // 自定义图表渲染成功率指标 @Bean public Timer chartRenderTimer(MeterRegistry registry) { return Timer.builder("chart.render.time") .description("Time taken to render a chart") .register(registry); } @Bean public Counter chartRenderSuccessCounter(MeterRegistry registry) { return Counter.builder("chart.render.success") .description("Number of successful chart renders") .register(registry); } @Bean public Counter chartRenderFailureCounter(MeterRegistry registry) { return Counter.builder("chart.render.failure") .description("Number of failed chart renders") .register(registry); } }6.2 修改图表 Controller,埋点监控
@GetMapping(value = "/air-quality", produces = MediaType.IMAGE_PNG_VALUE) public ResponseEntity<byte[]> getAirQualityHeatmap( @RequestParam String date, HttpServletRequest request, HttpServletResponse response) { long start = System.currentTimeMillis(); try { byte[] pngBytes = chartFactory.generateAirQualityHeatmap(date); // 记录成功指标 chartRenderSuccessCounter.increment(); chartRenderTimer.record(System.currentTimeMillis() - start, TimeUnit.MILLISECONDS); HttpHeaders headers = new HttpHeaders(); headers.setCacheControl("no-cache"); return new ResponseEntity<>(pngBytes, headers, HttpStatus.OK); } catch (Exception e) { // 记录失败指标 chartRenderFailureCounter.increment(); log.error("Chart render failed for date: {}", date, e); throw new RuntimeException("图表渲染失败", e); } }6.3 Grafana 告警看板配置(关键面板)
| 面板名称 | PromQL 查询 | 告警阈值 | 告警消息 |
|---|---|---|---|
| 大屏图表渲染成功率 | rate(chart_render_success_total[1h]) / rate(chart_render_total[1h]) | < 0.95 | [紧急] 大屏图表渲染成功率低于95%,请检查Chrome进程或Redis连接 |
| P95 渲染延迟 | histogram_quantile(0.95, sum(rate(chart_render_time_seconds_bucket[1h])) by (le)) | > 2.0 | [警告] 大屏图表P95渲染延迟超2秒,可能影响大屏流畅度 |
| 终端请求分布 | sum by (terminal_type) (rate(http_server_requests_seconds_count{uri=~"/api/chart/.*"}[1h])) | led占比 < 30% | [注意] 大屏请求量异常偏低,检查Nginx终端头转发是否正常 |
**落地
本文还有配套的精品资源,点击获取