
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_idsys_role角色role_code, role_namesys_menu菜单/权限perm_code, pathgrid_info网格信息grid_code, grid_name, region_code, manager_idcity_event城市事件event_no, event_type, level, status, address, longitude, latitude, imageswork_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 MapInteger, ListInteger 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) { LambdaQueryWrapperSysUser 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 RString 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 的jtiJWT 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 doesnt exist”。6.2 常见报错排查速查表现象根本原因解决方案本地能跑部署后 401前端请求的 baseURL 没走 Nginx 代理检查nginx.conf的/api转发配置是否指向后端 8080WebSocket 连接失败反向代理没配置 Upgrade 头Nginx 加proxy_set_header Upgrade $http_upgrade;和Connection upgrade上传图片返回 403MinIO bucket 权限设置为 private但前端直接访问走网关转发或设置 bucket policy 允许 GetObjectRabbitMQ 消费者收不到消息队列没有绑定路由键检查Binding的路由键是否与发送方一致注意#和*通配符区别大屏数据半小时不更新WebSocket 断连后没有自动重连前端写重连逻辑setTimeout(function(){ connect() }, 3000)第一次启动很慢SpringBoot 在连不上 Redis 时会不断重试确保 Redis 容器先启动或在配置里加spring.redis.timeout50006.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 包是我最建议你先读的模块。里面定义了RT统一返回体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你还懂状态流转、异步解耦、数据隔离、文件存储、实时通信这一整套后端工程方法论。最后分享一个我做项目一直保留的习惯每做完一个模块顺手写一段调试笔记。这个平台里工单模块的“驳回重办”逻辑我就是靠着当时的笔记在三个月后快速回忆起为什么要加“待核查”这个中间状态。好记性不如烂笔头项目文档其实就是写给你的未来看的。