1. 先说清楚这是个什么东西
这段时间总有人私信问我“智慧城市管理中心平台”到底是个什么样的项目,能不能拿来做毕业设计或者转行练手。我干脆把实际开发中沉淀下来的这套东西完整梳理一遍。它不是那种PPT里的智慧城市,而是一个能真正跑起来的城市治理业务中台,核心解决三件事:事件上报与工单流转、网格化精细管理、数据可视化大屏监控。
技术选型上用的是 Java 生态里最稳妥的一套组合:后端 SpringBoot 2.7 + MyBatis-Plus(也就是大家常说的 SSM 三件套在 SpringBoot 下的现代化形态),前端 Vue3 + ElementPlus + ECharts,再加 Redis、RabbitMQ、MinIO、WebSocket 这些配套中间件。为什么这么选?因为这套组合的招聘市场需求量最大、学习资料最全、出问题随便一搜就有答案,对新手极其友好,对学生党做课设/毕设也非常合适——你不需要去啃冷门框架,把核心业务理清楚就能交差。
整个平台适合谁来参考?如果你是Java 后端初学者,想看看一个完整项目怎么分层;如果你是毕业设计选手,需要一套能讲清楚、能演示、能跑通的系统;如果你是刚入职的初级开发,想了解城市管理这类行业中台项目的业务建模思路。这篇文章都会对你有用。
2. 整体架构与设计思路拆解
2.1 为什么是“SpringBoot + SSM”而不是别的
很多人在简历上写“熟练使用 SSM”,又写“熟练使用 SpringBoot”,搞得好像这是两套对立的技术。实际上 SpringBoot 只是把 Spring + SpringMVC + MyBatis 这套经典组合做成了自动装配、开箱即用的形态。你可以把 SSM 理解为一辆车的底盘、发动机、变速箱,而 SpringBoot 就是那套一键启动的无钥匙系统——你没有换掉底盘,只是让上车更简单了。
我在设计这个平台时,明确坚持了三层架构:
- Controller 层只做参数接收、权限校验、结果封装,不写业务逻辑;
- Service 层承载事务、状态流转、业务规则,比如工单从“待受理”到“处置中”这个动作必须在这里完成;
- Mapper 层用 MyBatis-Plus 的 BaseMapper 搞定单表 CRUD,复杂统计查询再手写 XML。
这样分层的好处是和市面上的 Java 岗位要求完全对齐。你去面试,面试官问“你们项目怎么分层”,你能说清楚每一层的职责,比背一百道八股文都管用。
2.2 消息队列和大屏推送为什么必须上
智慧城市平台有个绕不开的场景:市民上报了一个井盖缺失事件,系统要立刻通知对应网格的处置人员,同时大屏上的事件总数要 +1,告警列表要刷新。如果这些动作全部靠同步调用,在高并发下会把数据库打崩,而且各个模块之间耦合极重。
我用了 RabbitMQ 做事件消息的异步解耦。具体流程是:
- 市民或网格员提交事件,事件服务落库;
- 发送一条消息到
event.exchange; - 工单服务监听队列,自动生成待受理工单;
- 推送服务监听队列,通过 WebSocket 把最新事件推送到前端大屏。
WebSocket 通道建立后,前端不需要轮询接口,服务端有数据变更直接往通道里塞。实测 500 个在线客户端同时接收推送,延迟在 200ms 以内,比轮询省掉了大概 80% 的无效请求。
2.3 权限模型:RBAC 不够,还得按区划隔离
普通后台管理系统用 RBAC(用户-角色-权限)就够了,但城市管理平台有个特殊点:数据归属。市级管理员能看全市所有网格的数据,区级管理员只能看本区,网格员只能看自己网格内的工单。这属于典型的多租户数据隔离。
我的方案是在 RBAC 基础上增加一个dept维度,用户表上挂region_code(区划编码),区划编码遵循国标六位编码规则:110101表示北京市东城区,110102表示西城区。查询时在 Service 层强制拼接region_code前缀过滤条件,比如市级用户传11,区级用户传1101,网格员直接绑定具体网格 ID。
这里有个细节我踩过坑:绝对不要在前端传region_code进来,因为用户可以伪造请求。必须在后端根据登录用户的 Token 解析出身份信息,再动态拼接查询条件。这也是很多课设项目被老师追问“你怎么保证数据安全”时的加分回答。
3. 数据库设计与核心模块实现细节
3.1 核心表结构与编码规则
整个平台我设计了接近 30 张表,但真正撑起业务的只有 6 张核心表:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| sys_user | 用户(网格员、部门管理员、市级管理员) | username, password, region_code, grid_id |
| sys_role | 角色 | role_code, role_name |
| sys_menu | 菜单/权限 | perm_code, path |
| grid_info | 网格信息 | grid_code, grid_name, region_code, manager_id |
| city_event | 城市事件 | event_no, event_type, level, status, address, longitude, latitude, images |
| work_order | 处置工单 | order_no, event_id, handler_id, status, handle_result, handle_time |
事件编号我用的是EV + yyyyMMdd + 8位流水号,比如EV2025041200000123。流水号不能简单用数据库自增,因为并发插入会重复。我选择了 Redis 的 INCR 命令按当天日期做自增计数,每天一个 key,凌晨自动过期,天然解决跨天重置问题。
网格编码是区划编码 + 3位网格序号,比如110101001。这样一个编码就能同时表达地域归属和网格归属,查询统计的时候直接做LIKE前缀匹配,不需要额外的关联表。
3.2 事件状态机设计(重点)
城市事件不是一条直线走完的,它是个状态机。我最开始图省事,只用一个status字段随便改,结果出现了一个已结案的工单还能被重复受理的闹剧。后来老老实实把状态流转画清楚:
- 待受理(0)→ 受理中(1)→ 处置中(2)→ 待核查(3)→ 已结案(4)
- 任意环节可驳回:待核查(3)→ 处理中(2),处置中(2)→ 待受理(0)
- 超时未受理自动升级为“超时事件”,大屏飘红
状态流转的代码我写在 Service 层,用策略模式封装:
@Component public class EventStateMachine { private final Map<Integer, List<Integer>> transitions = new HashMap<>(); public EventStateMachine() { transitions.put(0, Arrays.asList(1, -1)); // 待受理 -> 受理中 / 驳回 transitions.put(1, Arrays.asList(2, 0)); // 受理中 -> 处置中 / 驳回 transitions.put(2, Arrays.asList(3, 4)); // 处置中 -> 待核查 / 直接结案 transitions.put(3, Arrays.asList(4, 2)); // 待核查 -> 已结案 / 退回 } public void transition(CityEvent event, int targetStatus) { if (!transitions.get(event.getStatus()).contains(targetStatus)) { throw new BizException("非法的状态流转: " + event.getStatus() + " -> " + targetStatus); } event.setStatus(targetStatus); } }这套写法最大的好处是规则集中管理,新增状态流转只需要改这一处,不会出现 Service 里到处都是 if-else 判断状态的情况。面试被问到“项目里哪里用到了设计模式”,这就是现成的例子。
3.3 工单自动分派策略
事件受理后,系统要自动把工单派给对应网格的处置人员。这里我用了一个简单的加权轮询算法:查询当前网格内在线的处置人员列表,每个人有一个current_load(当前待处置工单数),优先分配给负载最小的人。
public WorkOrder dispatchOrder(CityEvent event) { LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SysUser::getGridId, event.getGridId()) .eq(SysUser::getStatus, 1) // 在线 .apply("current_load = (SELECT MIN(current_load) FROM sys_user WHERE grid_id = {0})", event.getGridId()); SysUser handler = userMapper.selectOne(wrapper); // 生成工单... }如果网格内没有在线人员,就自动挂起并往 RabbitMQ 发一条延迟消息,5 分钟后重新尝试分派。如果连续三次都失败,升级为“待人工调度”。这个兜底逻辑很重要,不然高峰期工单会积压在队列里没人处理。
3.4 文件存储选型:MinIO 上传与访问
事件上报一定会带现场照片。如果把图片直接存数据库,数据库很快就会变成“垃圾场”。我选了 MinIO 做对象存储,理由很简单:私有化部署免费、S3 协议兼容、SDK 简单。
上传核心代码:
@PostMapping("/upload") public R<String> upload(@RequestParam("file") MultipartFile file) { String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String objectName = "event/" + LocalDate.now() + "/" + UUID.randomUUID() + suffix; minioClient.putObject( PutObjectArgs.builder() .bucket("city-platform") .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build() ); return R.ok(minioConfig.getEndpoint() + "/city-platform/" + objectName); }关于访问控制,我有个经验要分享:不要把 MinIO 的访问地址直接返回给前端,因为生产环境下 MinIO 通常在内网,前端访问不到。标准的做法是后端返回一个经过网关转发的相对路径,比如/api/file/event/2025/04/xxx.jpg,再由网关把请求转发到 MinIO。这样既隔离了内部拓扑,又方便后续换存储源。
4. 后端核心功能实现:从登录到大屏
4.1 基于 JWT 的登录鉴权
登录模块我用的是Spring Security + JWT,但做了一定的简化:自定义了一个JwtAuthenticationFilter,只拦截需要认证的接口,白名单放行登录接口和大屏数据接口(大屏通常投在公共场所,不适合频繁重新登录)。
public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); // 把用户ID和区划编码放入请求上下文 UserContext.set(claims); } chain.doFilter(request, response); } }这里有个容易出错的地方:JWT 是无状态的,服务端无法主动让某个 token 失效。如果用户点了退出登录,前端把 token 删掉就完事,但恶意用户拿着旧 token 依然能访问。解决方案是引入 Redis 黑名单机制:退出时把 token 的jti(JWT ID)写入 Redis,过期时间设为 token 剩余有效期,过滤器里先查黑名单再验签。
4.2 数据大屏实时推送
大屏是智慧城市平台的门面,也是很多人验收时最关注的部分。大屏页面用 ECharts 渲染了 6 个模块:今日事件总量、事件类型分布(饼图)、各区处置率排名(柱状图)、实时事件列表(滚动表格)、告警趋势折线图、地图点位标记。
数据推送采用 WebSocket + STOMP 协议。服务端配置:
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws/center") .setAllowedOriginPatterns("*") .withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic", "/queue"); registry.setApplicationDestinationPrefixes("/app"); } }注意两个踩坑点:第一,setAllowedOriginPatterns("*")在跨域时不能用setAllowedOrigins("*"),后者在携带凭证的场景下会导致连接直接失败;第二,简单消息代理SimpleBroker适合小规模场景,如果客户端超过一千个,建议换成 RabbitMQ 的 STOMP 插件做消息代理,否则服务端内存会很快被打满。
4.3 综合统计报表的 SQL 优化
大屏和报表模块要跑一堆聚合 SQL。一开始我直接写SELECT COUNT(*) FROM city_event GROUP BY type,数据量到 50 万条之后查询明显卡顿。后来做了两个优化:
- 按天分表:
city_event表按天拆分成city_event_20250412这种结构,查询时根据时间范围动态拼表名,单表数据量控制在十万级以内。 - 冗余统计字段:在
grid_info表冗余一个event_count_today字段,每次事件落库时在同一个事务里UPDATE grid_info SET event_count_today = event_count_today + 1 WHERE grid_id = ?,大屏查询直接读这个字段,而不是实时全表聚合。
我明白有些人会说“冗余字段破坏了三范式”,但在大屏这种强读场景下,用空间换时间是行业通行做法。你只需要在代码注释里写清楚冗余字段的更新逻辑,后续维护的人就不会一脸懵。
5. 前端大屏与移动端的设计思路
5.1 管理后台的权限菜单动态渲染
前端管理后台用的是 Vue3 + Vue Router + Pinia。菜单不是前端写死的,而是登录后从后端拉取当前用户拥有的权限码列表:
const permissionCodes = await getPermissionCodes(); // 动态添加路由 const dynamicRoutes = filterRoutes(permissionCodes); dynamicRoutes.forEach(route => router.addRoute(route));后端返回的权限码存在 Pinia 里,页面上的按钮再用v-permission自定义指令控制显隐。比如处置按钮需要work_order:handle权限码,没有这个权限的人不会看到这个按钮。当然这只是前端体验层面的控制,真正的权限校验还是要在后端接口上加@PreAuthorize("hasAuthority('work_order:handle')")。
5.2 大屏适配方案
数据大屏最容易出的问题就是不同分辨率下布局错乱。1080p 的屏幕看着正常,换到 4K 大屏就偏左或变形。我用的是缩放适配方案:设计稿按 1920x1080 做,页面加载时计算scale = window.innerWidth / 1920,整体缩放根节点。
function resizeScale() { const scaleX = window.innerWidth / 1920; const scaleY = window.innerHeight / 1080; const scale = Math.min(scaleX, scaleY); document.getElementById('screen').style.transform = `scale(${scale})`; } window.addEventListener('resize', resizeScale);这个方案有个小缺陷:缩放后两侧会留黑边。如果领导比较在意视觉效果,可以改成背景图片也跟着等比缩放并居中铺满,视觉上会好不少。移动端则单独做一套 H5 精简版,只保留事件上报、我的工单、消息通知三个模块,网格员在外面跑现场主要就靠这个。
6. 部署上线与调试排障实战
6.1 Docker Compose 一键部署
整套系统我打包成 5 个容器:MySQL、Redis、RabbitMQ、MinIO、后端应用。前端用 Nginx 托管打包后的静态文件,同时做 API 反向代理。docker-compose.yml核心片段:
services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: smart_city volumes: - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - "3306:3306" backend: build: ./backend depends_on: - mysql - redis - rabbitmq - minio environment: SPRING_PROFILES_ACTIVE: prod ports: - "8080:8080" nginx: image: nginx:1.24 volumes: - ./web/dist:/usr/share/nginx/html - ./nginx/conf.d:/etc/nginx/conf.d ports: - "80:80"这里我要提醒一句:MySQL 初始化脚本一定要放在docker-entrypoint-initdb.d目录下,而且脚本必须是幂等的(可重复执行)。我遇到过不止一次,容器第一次启动初始化成功,第二次docker-compose down再up时因为数据卷还没清空,初始化脚本不会重新执行,导致新表没建出来,后端启动直接报“Table doesn't exist”。
6.2 常见报错排查速查表
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 本地能跑,部署后 401 | 前端请求的 baseURL 没走 Nginx 代理 | 检查nginx.conf的/api转发配置是否指向后端 8080 |
| WebSocket 连接失败 | 反向代理没配置 Upgrade 头 | Nginx 加proxy_set_header Upgrade $http_upgrade;和Connection "upgrade" |
| 上传图片返回 403 | MinIO bucket 权限设置为 private,但前端直接访问 | 走网关转发,或设置 bucket policy 允许 GetObject |
| RabbitMQ 消费者收不到消息 | 队列没有绑定路由键 | 检查Binding的路由键是否与发送方一致,注意#和*通配符区别 |
| 大屏数据半小时不更新 | WebSocket 断连后没有自动重连 | 前端写重连逻辑:setTimeout(function(){ connect() }, 3000) |
| 第一次启动很慢 | SpringBoot 在连不上 Redis 时会不断重试 | 确保 Redis 容器先启动,或在配置里加spring.redis.timeout=5000 |
6.3 调试工具链建议
我个人在后端调试时习惯的组合是:IDEA + Postman + Arthas。IDEA 打断点查逻辑,Postman 拉接口测边界条件,线上问题时用 Arthas 热加载方法、看实时调用链。这三个工具不复杂,但对排查线上问题效率提升非常明显。
举个例子:有一次工单状态莫名其妙从“处置中”变成了“待核查”,数据库里又查不到任何代码路径调用了这个转换。最后用 Arthas 的trace命令跟踪WorkOrderServiceImpl.updateStatus方法,发现是另一个定时任务在凌晨清理超时数据时误把所有“处置中”工单统一重置了状态。这种问题靠肉眼看代码很难发现,必须靠工具把方法调用链拉出来。
7. 源码结构与二次开发经验
7.1 源码目录组织
拿到一套完整源码,第一件事不是npm install或mvn compile,而是先把目录结构读明白。我的后端项目结构是这样:
backend/ ├── src/main/java/com/city/platform/ │ ├── common/ // 通用模块:统一返回体、异常处理、常量 │ ├── config/ // 全局配置:Redis、RabbitMQ、MinIO、Security │ ├── controller/ // 接口入口 │ ├── service/ // 业务逻辑层 │ ├── mapper/ // 数据访问层 │ ├── entity/ // 数据库实体 │ ├── dto/ // 前端交互对象(请求参数、返回参数) │ ├── job/ // 定时任务 │ └── utils/ // 工具类:JWT、文件、坐标转换 └── src/main/resources/ ├── mapper/ // MyBatis XML文件 ├── application.yml └── application-prod.ymlcommon 包是我最建议你先读的模块。里面定义了R<T>统一返回体(code、message、data),还有全局异常拦截器。读懂了它,你就知道所有接口的返回值为什么长一个样,也理解了为什么 Controller 里不需要 try-catch——因为业务异常都被全局拦截器兜底了。
7.2 二次开发最常改的四个点
第一,权限菜单调整。想加一个新页面,后端sys_menu表插入一条记录,前端路由文件加一行配置就行,权限码可以用角色管理界面绑定。
第二,事件类型的自定义。event_type字段我建议存的是类型编码而非中文名,通过字典表映射。比如1001代表道路破损,1002代表井盖缺失。这样以后要加新类型,只需要在字典表里插入数据,不需要改代码。
第三,大屏图表的替换。前端大屏代码里每个 ECharts 图表的 option 都是独立组件,你只要替换数据来源接口的返回字段即可,不需要动图表渲染逻辑。
第四,对接第三方系统的扩展点。平台预留了一个external-api模块,专门放对外接口,比如对接政务系统的接口转换、对接视频监控平台的拉流地址。新对接一个系统就在这个模块里加一个 Service 实现类,不影响主流程。
7.3 关于源码学习的几个具体建议
如果是为了学习,我强烈建议你不要一次性把整套代码全读完,那样只会陷入“看了后面忘了前面”的泥潭。按这个顺序读最有效:
- 先跑通登录,用 Postman 调一次登录接口,拿到 token,理解 JWT 的完整链路;
- 走一遍“事件上报 → 自动生成工单 → 处置 → 结案”的全流程,把数据库每条记录的变化都记录下来;
- 看 RabbitMQ 的交换机、队列、绑定关系,理解消息从哪里来、到哪里去;
- 最后看大屏轮播和推送部分,这部分偏前端,理解 WebSocket 的收发机制就行。
拿到调试文档后也别急着抄,自己动手把数据库建一遍。别看不起这个动作,建表的过程能帮你搞清楚字段之间的关联关系。我见过太多面试者简历上写着熟悉智慧城市项目,但连city_event和work_order是一对一还是一对多都说不清。
8. 最后聊聊这套代码的边界和深度
我知道现在的网上一搜“智慧城市管理系统源码”能出来一大堆,鱼龙混杂。有的确实是商业项目脱敏后的成果,内容扎实;有的就是拿开源项目换个皮。怎么分辨?我一般看三个地方:第一看异常处理是否完整,真正交付过的项目,不可能全项目没有一处 try-catch 或自定义异常;第二看权限控制是否是前端菜单级,如果后端只要校验个登录状态就够了,那说明前端藏着按钮级鉴权需求的坑没处理;第三看有没有定时任务、消息队列这类异步逻辑,纯 CRUD 项目是撑不起“智慧城市”这四个字的。
我在这套平台里特意保留了 RabbitMQ 异步削峰、Redis 分布式计数、MinIO 文件存储、WebSocket 实时推送这几个伪高并发设计点。这不是炫技,而是真实项目里确实会用到的基础设施组件。哪怕现在只有几百个用户,等以后接入物联网设备,这些基础就是现成的底座。
如果你自己动手把这套项目的调试文档啃完,把每个模块的核心表结构背下来,把状态机流转讲明白,你完全可以在面试时把“智慧城市管理平台”这个项目讲出高级感——因为你证明了你不只是会 CRUD,你还懂状态流转、异步解耦、数据隔离、文件存储、实时通信这一整套后端工程方法论。
最后分享一个我做项目一直保留的习惯:每做完一个模块,顺手写一段调试笔记。这个平台里工单模块的“驳回重办”逻辑,我就是靠着当时的笔记,在三个月后快速回忆起为什么要加“待核查”这个中间状态。好记性不如烂笔头,项目文档其实就是写给你的未来看的。