基于SSM+MySQL的道路养护管理系统设计与实现
2026/9/15 23:10:05 网站建设 项目流程

简介:集中面向道路养护信息化管理项目开发者的SSM+MySQL+HTML后台管理系统源码包,覆盖道路信息管理、损害类型管理、评定等级管理、日常巡查与定期检查等核心模块,适合高校毕业设计、课程设计或中小型市政养护系统二次开发参考。压缩包内共894个文件,大小30.45MB,主要包含Java业务代码、MyBatis映射XML、JSP/HTML页面、JavaScript/CSS前端资源及数据库SQL脚本,另有Maven工程配置与部署相关文件,便于直接导入开发环境查看项目结构与运行逻辑。目前已有357人学习,资源提供完整功能展示对应的可运行工程,读者可结合博文详情了解各模块设计思路,快速掌握SSM框架在道路养护场景下的分层实现方式,也可根据实际需求扩展巡检记录与等级评定流程。

1. 道路养护管理系统为什么还选 ssm+mysql+html

公路养护部门日常要做的事,说白了就是三件:巡检发现病害、派单维修、验收归档。这活儿看着简单,真干起来数据量不小——每天上百条巡查记录,每一条都要关联路段桩号、病害类型、严重程度、处理状态,最后还要生成月底统计报表。多数小型养护单位到现在还用 Excel 登记,查一条历史工单要翻半天文件,更别说按路段、按病害类型做统计了。这个标题给的就是一条比较务实的路:前端用 html 页面直接渲染和交互,后端用 ssm 框架(Spring + SpringMVC + MyBatis)组织业务逻辑,数据落在 mysql 里。它适合两类人:一是正在做毕设、需要找到一个完整可落地的 JavaWeb 项目骨架的学生,二是想用最低成本给班组搭一套内部工单系统的运维或养护管理人员。这套组合没有微服务、没有前后端分离,但恰恰因为它结构简单、依赖少、部署直观,反而能稳稳跑完“录入-流转-统计”这条主线。下面从数据库设计开始,把一套能跑的养护管理系统拆开讲。

2. 先把 mysql 表结构定好,再搭 ssm 工程骨架

2.1 道路养护核心表设计,字段和索引一次想清楚

道路养护管理系统里最核心的实体不是“用户”,而是“病害”和“工单”。我一般先画数据流:巡检员在某个路段发现坑槽或龟裂,上报一条病害记录;管理员看到记录后生成养护工单,派给施工队;施工队处理完回填结果,管理员验收关闭。围绕这条线,至少要建四张表:路段表、病害上报表、养护工单表、状态变更日志表。以下是建表 SQL 的核心部分。

CREATE TABLE t_road_section ( id INT PRIMARY KEY AUTO_INCREMENT, road_code VARCHAR(32) NOT NULL COMMENT '路段编码', road_name VARCHAR(64) NOT NULL COMMENT '路段名称', start_km DECIMAL(8,2) NOT NULL COMMENT '起点桩号', end_km DECIMAL(8,2) NOT NULL COMMENT '终点桩号', manager VARCHAR(32) COMMENT '路段负责人', remark VARCHAR(255) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_disease_report ( id INT PRIMARY KEY AUTO_INCREMENT, road_id INT NOT NULL COMMENT '关联路段', disease_type VARCHAR(16) NOT NULL COMMENT '病害类型:坑槽/龟裂/沉陷/车辙', disease_level TINYINT NOT NULL DEFAULT 0 COMMENT '0轻度 1中度 2重度', position_desc VARCHAR(128) COMMENT '具体位置描述', report_user VARCHAR(32) NOT NULL COMMENT '上报人', report_time DATETIME NOT NULL COMMENT '上报时间', photo_url VARCHAR(255) COMMENT '现场照片路径', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待派单 1已派单 2施工中 3待验收 4已完工 5已驳回', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', KEY idx_road_time (road_id, report_time), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_maintenance_order ( id INT PRIMARY KEY AUTO_INCREMENT, report_id INT NOT NULL COMMENT '关联病害上报', order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '工单号', assign_user VARCHAR(32) COMMENT '派单人', assign_time DATETIME COMMENT '派单时间', execute_team VARCHAR(64) COMMENT '施工班组', plan_finish_date DATE COMMENT '计划完成日期', actual_cost DECIMAL(10,2) DEFAULT 0 COMMENT '实际费用', finish_time DATETIME COMMENT '完工时间', accept_user VARCHAR(32) COMMENT '验收人', accept_time DATETIME COMMENT '验收时间', accept_result VARCHAR(255) COMMENT '验收意见', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待施工 1施工中 2待验收 3已完工 4已驳回', version INT NOT NULL DEFAULT 0, KEY idx_report (report_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字段类型上有一个容易被忽略的点:病害等级用 TINYINT 而不是 VARCHAR,因为后续要按等级做统计和排序,数字类型在范围查询和索引上比字符串更友好。金额类字段用 DECIMAL(10,2),避免 FLOAT 的精度问题。桩号字段用 DECIMAL(8,2),可以精确到厘米级。索引方面,idx_road_time (road_id, report_time)是典型的联合索引,直接服务“查某路段最近三个月的病害记录”这个高频查询;单独给 status 建索引是因为列表页默认会按状态筛选待办工单。

2.2 用 Maven 搭 ssm 骨架,依赖版本是个隐形坑

建完表后开始搭工程。这个标题组合最常见的工程结构是 Maven 的 war 包项目,目录上分成 controller、service、mapper、entity 四层,webapp 目录下放 HTML、CSS、JS 静态资源。pom.xml 里需要引入的核心依赖有五个:spring-webmvc、mybatis、mybatis-spring、mysql-connector-java、druid 连接池。版本上我一般固定用 Spring 5.3.x 搭配 MyBatis 3.5.x,不要追最新大版本,因为 Spring 6 和 jakarta 命名空间会让很多老教程失效。以下是一个可直接用的依赖清单片段。

<properties> <spring.version>5.3.30</spring.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> </dependencies>

版本搭配上有一个值得记住的组合:mysql-connector-java 8.x 的驱动类名是com.mysql.cj.jdbc.Driver,URL 必须带serverTimezone=Asia/Shanghai,否则本地测试时会报时区错误。jackson-databind 用来把 Controller 返回的对象序列化成 JSON 给前端 html 的 Ajax 调用,这个依赖经常被忘记加,导致页面能打开但数据始终加载不出来。

2.3 三层配置:Spring 管对象,SpringMVC 管请求,MyBatis 管 SQL

工程搭好后,配置是 ssm 项目最容易卡壳的地方。我习惯把配置拆成三个文件:spring-mvc.xml 负责扫描 controller 包和静态资源放行;spring-mybatis.xml 负责数据源、SqlSessionFactory、Mapper 扫描和事务管理器;web.xml 里配置 DispatcherServlet 和字符编码过滤器。下面给出 spring-mybatis.xml 的关键部分,因为它是整个数据链路的中枢。

<context:component-scan base-package="com.road.service" /> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver" /> <property name="url" value="jdbc:mysql://localhost:3306/road_maintain?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai" /> <property name="username" value="root" /> <property name="password" value="root" /> <property name="initialSize" value="5" /> <property name="maxActive" value="20" /> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource" /> <property name="mapperLocations" value="classpath:mapper/*.xml" /> <property name="typeAliasesPackage" value="com.road.entity" /> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true" /> </bean> </property> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.road.mapper" /> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource" /> </bean>

配置里有三个细节决定能不能跑通:mapUnderscoreToCamelCase设为 true 后,数据库的report_user字段能自动映射到实体类的reportUser属性,少写几十行 resultMap;mapperLocations里的classpath:mapper/*.xml是把 SQL 语句统一收进 resources 目录写 XML 文件,而不是写在注解里,这样复杂动态 SQL 更容易维护;连接池参数里initialSizemaxActive按照内部系统二十人同时在线的规模设置就够,不需要调大。事务管理器这一行很多人落下,落下之后@Transactional注解会静默失效,数据写到一半出错时不会回滚,工单状态就乱了。

3. Controller 到 Mapper 的查询链路,用动态 SQL 做病害列表筛选

3.1 三层代码怎么写,请求从哪里进、数据怎么出

查询链路按 ssm 的标准走法执行:浏览器发 HTTP 请求到 SpringMVC 的 DispatcherServlet,它根据 @RequestMapping 找到对应的 Controller 方法;Controller 调 Service 接口,Service 实现类里调 Mapper 接口;Mapper 接口绑定同名 XML 文件里的 SQL,把结果集映射成实体类,再逐层返回,最后由 Controller 用 @ResponseBody 转成 JSON 交给前端。以病害列表查询为例,Controller 层代码大致是这样。

@Controller @RequestMapping("/disease") public class DiseaseController { @Autowired private DiseaseService diseaseService; @RequestMapping("/list") @ResponseBody public Result list(Integer pageNum, Integer pageSize, String roadName, Integer diseaseLevel, String startTime, String endTime) { PageHelper.startPage(pageNum == null ? 1 : pageNum, pageSize == null ? 10 : pageSize); List<DiseaseVO> list = diseaseService.queryPage(roadName, diseaseLevel, startTime, endTime); PageInfo<DiseaseVO> pageInfo = new PageInfo<>(list); return Result.ok(pageInfo); } }

这段代码的逻辑分三段理解:PageHelper.startPage是分页插件的入口,它通过 MyBatis 拦截器把下一条查询 SQL 自动拼上LIMIT ?,pageNum 从 1 开始;DiseaseVO是视图对象,里面除了病害字段还加了roadName,这是通过查询时 JOIN 路段表带出来的,避免前端再单独发请求查路段名;Result.ok是一个统一返回体,包含 code、message、data 三个字段,前端 html 里的 Ajax 拿到后先判断 code 再做渲染。参数上有个约定:pageSize 上限在 Service 里要限制到 100,防止有人一次拉走全表数据。

3.2 Mapper 里的动态 SQL 是检索的核心,条件拼接按需生效

Service 层一般不写复杂逻辑,真正的筛选逻辑在 Mapper XML 里。下面的 SQL 是病害分页查询的核心,支持按路段名模糊匹配、按病害等级精确匹配、按上报时间范围筛选。

SELECT r.id, r.disease_type, r.disease_level, r.position_desc, r.report_time, r.report_user, r.status, s.road_name FROM t_disease_report r LEFT JOIN t_road_section s ON r.road_id = s.id WHERE 1 = 1 <if test="roadName != null and roadName != ''"> AND s.road_name LIKE CONCAT('%', #{roadName}, '%') </if> <if test="diseaseLevel != null"> AND r.disease_level = #{diseaseLevel} </if> <if test="startTime != null and startTime != ''"> AND r.report_time &gt;= #{startTime} </if> <if test="endTime != null and endTime != ''"> AND r.report_time &lt;= #{endTime} </if> ORDER BY r.report_time DESC

写这段 SQL 时有几个习惯值得保持:WHERE 1=1看着不优雅,但它是 MyBatis 动态 SQL 最稳妥的解法——如果去掉它,第一个<if>不成立而第二个成立,拼出来的 SQL 就是WHERE AND disease_level = ?,直接语法错误;时间字段用&gt;=&lt;=是因为 XML 里不能裸写大于号和小于号;CONCAT('%', #{roadName}, '%')比在 Java 代码里拼好%值%再传进来更安全,没有 SQL 注入的写法问题。排序固定用report_time DESC,保证新上报的病害排在前面,这个排序字段也是表里的索引列,数据量大时不会额外产生 filesort。

3.3 前端 html 页面如何消费 JSON,表格渲染和翻页怎么做

标题里特别提到了 html,说明前端不走 JSP 也不做前后端分离,而是直接用静态页面加 Ajax。我在 webapp 目录下建一个diseaseList.html,页面里面有筛选表单、表格容器和翻页按钮,数据通过fetch获取。一个通用的渲染函数骨架如下。

<script> async function loadPage(pageNum) { const params = new URLSearchParams({ pageNum: pageNum, pageSize: 10, roadName: document.getElementById('roadName').value, diseaseLevel: document.getElementById('diseaseLevel').value }); const resp = await fetch('/disease/list?' + params.toString()); const body = await resp.json(); if (body.code !== 200) { alert('加载失败: ' + body.message); return; } renderTable(body.data.list); renderPager(body.data.pageNum, body.data.pages); } </script>

fetch 的 GET 请求参数用 URLSearchParams 生成,比手工拼字符串更规范;返回体里body.data.list是当前页数据集合,body.data.pages是总页数,这两个字段来自 PageInfo 的序列化结果。要注意 PageHelper 返回的 PageInfo 里字段名是pageNumpageSizetotalpageslist,前端字段名要和它对齐,写错一个就渲染不出来。renderTable 函数内部用document.createElement('tr')动态建行,再通过textContent赋值,不用 innerHTML 拼数据,避免 XSS 注入风险——病害描述字段是用户输入,不能直接当 HTML 解析。

4. 养护工单的状态流转,事务和乐观锁是 ssm 落地成败的关键

4.1 用状态机表设计守住业务边界,六种状态不许乱跳

道路养护的工单流转不是随便改个状态值那么简单。一个病害从上报到归档,状态必须按固定路径走:待派单 → 已派单 → 施工中 → 待验收 → 已完工。驳回是一个分支,从待派单可以直接回到待派单并重置派单信息。如果代码里谁都能随便改状态,就会出现施工队还没干活工单就已经验收完的乱象。状态机定义用一张表约束,是 ssm 和 mysql 项目里最廉价可靠的方案。

status 值含义允许进入的操作前置状态
0待派单上报病害后默认状态
1已派单管理员派给施工队0
2施工中施工队确认开工1
3待验收施工队提交完工说明2
4已完工管理员验收通过3
5已驳回验收不合格 / 派单信息有误1, 3

判定状态是否允许跳转的逻辑写在 Service 层,用一个 Map 配置合法的状态转移路径,而不是散落在大段的 if-else 里。例如Map.of(0, new Integer[]{1}, 1, new Integer[]{2, 5}),这样新加一个状态只改 Map 不动业务代码,后续审计也一目了然。

4.2 @Transactional 在派单操作上的正确用法和常见失败场景

派单操作同时要更新病害表的 status、创建工单记录、写入一条状态变更日志,三个动作必须在一个事务里完成。以下这段代码是 Service 层实现派单的核心逻辑。

@Service public class MaintenanceServiceImpl implements MaintenanceService { @Autowired private DiseaseReportMapper diseaseReportMapper; @Autowired private MaintenanceOrderMapper orderMapper; @Autowired private StatusLogMapper statusLogMapper; @Override @Transactional(rollbackFor = Exception.class) public void assignOrder(AssignDTO dto) { DiseaseReport report = diseaseReportMapper.selectByIdForUpdate(dto.getReportId()); if (report == null) { throw new BusinessException("病害记录不存在"); } if (report.getStatus() != 0) { throw new BusinessException("当前状态不允许派单"); } int updated = diseaseReportMapper.compareAndSetStatus( dto.getReportId(), 0, 1, report.getVersion()); if (updated != 1) { throw new BusinessException("操作冲突,请刷新后重试"); } MaintenanceOrder order = new MaintenanceOrder(); order.setReportId(report.getId()); order.setOrderNo(generateOrderNo()); order.setExecuteTeam(dto.getExecuteTeam()); order.setPlanFinishDate(dto.getPlanFinishDate()); order.setStatus(0); orderMapper.insert(order); statusLogMapper.insert(StatusLog.of(report.getId(), report.getStatus(), 1, "管理员派单")); } }

这段代码有四个关键点。selectByIdForUpdate是加了FOR UPDATE的查询,它在 mysql 的 InnoDB 引擎下会对这一行加排他锁,第二个管理员同时点派单时会被阻塞,等锁超时后由compareAndSetStatus返回 0 触发“操作冲突”提示。compareAndSetStatus对应的 SQL 是UPDATE t_disease_report SET status = #{newStatus}, version = version + 1 WHERE id = #{id} AND status = #{oldStatus} AND version = #{oldVersion},用受影响行数判断是否更新成功。@Transactional(rollbackFor = Exception.class)里的 rollbackFor 必须写,因为 Spring 默认只回滚 RuntimeException,而自定义的 BusinessException 常常继承 Exception,不加这个参数事务死活不生效。generateOrderNo()生成工单号,我常用DateUtil.format(new Date(), "yyyyMMddHHmmss") + 四位随机数,保证并发情况下不撞号。

4.3 施工和验收的消息提醒,怎么用 html 页面定时轮询待办数量

养护人员打开系统首页时,最关心的是有多少病害等着派单、多少工单等着验收。这个需求在 ssm+html 架构下不用 WebSocket,直接前端定时轮询接口即可。html 页面里写一个setInterval,每 30 秒请求一次/dashboard/todoCount接口,返回待办数量后在导航栏的角标元素上更新数字。实现很简单,但有一个参数值得注意:轮询间隔要大于后端处理时间,还要考虑多人同时轮询的压力。内部系统几十人同时在线,30 秒一次完全够用;如果间隔小于 10 秒,mysql 的 QPS 会被这种无效查询白白消耗掉。

5. 给 mysql 做一次慢查询走查,参数和索引该调的地方就调

这套系统上线前我最常做的一件事,是把巡检数据灌到十万条以上,然后逐个页面点一遍,开着 mysql 的慢查询日志看哪些 SQL 拖后腿。慢查询日志的开关在 my.cnf 里,slow_query_log = ONlong_query_time = 1,等于说执行超过 1 秒的 SQL 全部记录到日志文件里。灌测试数据用 mysql 的递归 CTE 可以一次生成上万条巡检记录,例如设置一个WITH RECURSIVE seq AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM seq WHERE n < 10000),再配合DATE_ADD生成递增的时间,批量 INSERT 进巡检表,不用写存储过程就能快速制造数据。

实际走查中遇到最多的三类问题都在索引上。第一类是隐式类型转换,t_disease_report表的report_user是 VARCHAR 类型,页面筛选条件传过来的是数字 123,mysql 就会把字段值全部转成数字再比较,索引直接失效。排查方法很简单,看WHERE report_user = 123的执行计划,如果 type 是 ALL 就中招了。第二类是函数包裹索引列,比如按月份查巡检记录时写了WHERE DATE_FORMAT(report_time, '%Y-%m') = '2024-06',这会让idx_road_time用不上,应该改写为report_time >= '2024-06-01' AND report_time < '2024-07-01'。第三类是排序和查询条件没有复合索引,页面默认按时间倒序查某路段纪录,但 SQL 先按 road_id 过滤再排序,单独建 road_id 索引或者时间索引都不够,必须用(road_id, report_time)这样的联合索引才能兼顾过滤和排序。

关于连接池参数,还有一个容易忽略的指标:Druid 的maxWait默认是 -1,意思是拿不到连接时不等待直接抛异常。内部系统经常出现早晨上班那一刻 20 个人同时打开页面,连接池被占满后后面的请求全部报错。把maxWait设为 5000,配合testWhileIdle为 true,让线程排队等待 5 秒而不是立刻失败,运维压力会小很多。最后检查一遍 mysql 的innodb_buffer_pool_size,如果机器内存是 8G 就设到 2G,默认的 128M 缓存区在灌了测试数据后连索引都放不下,再好的 SQL 也跑不出性能。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询