简介:面向高校计算机相关专业毕业设计选题与课程项目参考,这份文档围绕基于SSM的新能源汽车充电桩系统展开,重点解决充电桩信息、用户、维修与报修流程的系统化管理问题,适合需要撰写论文或梳理SSM项目设计思路的读者。压缩包内共1个docx文件,约4.62MB,以毕业论文说明书为主体,包含题目、诚信承诺书、中英文摘要、目录及后续系统分析、设计与实现章节,可据此了解B/S架构、MySQL数据库与Tomcat部署下的功能划分。文档覆盖首页、个人中心、维修员管理、用户管理、电桩类别管理、充电桩管理、充电桩报修与维修回复、系统管理等模块,对权限控制、可扩展性和日常运维流程也有讨论。已有65人学习,可作为毕业设计论文结构、功能模块设计和充电桩管理系统方案撰写的参考材料。
1. 从一张对不上的报修单说起:这套系统到底要解决什么
做新能源汽车充电桩管理系统的毕设或课设,最常见的画面是:桩的台账躺在一张表格里,报修记录散在聊天群里,维修进度靠打电话追问。等到要写设计说明、要答辩演示,三份数据对不上,谁也说不清某台桩到底修没修好、是谁修的、修了几次。这套基于 SSM 框架的新能源汽车充电桩管理系统,就是把台账、报修、派单、回复四件事收进同一个 B/S 后台:管理员管桩、管人、管类别,维修员只领单和回单,普通用户查桩、报修、看进度。适合正在做充电桩方向毕设或课程设计、需要一套结构完整且能被追问的系统方案的人。
2. 需求分析与角色划分:三类用户、九个模块的职责边界
需求这一章最容易被写成空话,写满两页“提高效率、方便管理”,答辩老师一问“那具体谁在什么情况下点哪个按钮”,就答不上来。我习惯的做法是先把线下的人工流程摊开,找出它在哪几个位置断掉,断点就是功能点。这一章按这个思路把九个模块的来历讲清楚,再给出角色权限矩阵和可验收的需求句式,后面第 3、4 章的设计与实现都从这里往下接。
2.1 从人工台账的三个断点倒推功能清单
线下管桩的流程其实很简单:桩建好,登记编号、类别、位置、输入电压、输出功率、单价;用户发现充不上电,找管理方报修;管理方指派维修工;维修工到现场处理完回一句“修好了”。流程本身不复杂,断在三个地方。
第一个断点是“找不到”。桩的编号、类别、位置、单价写在纸上或表格里,用户报修时说不清是哪台桩,管理员翻半天才能定位,位置描述还会出现“三号门左边那排”这种只有本地人听得懂的说法。对应的功能就是充电桩管理、电桩类别管理,桩必须有唯一编号,类别要能维护成字典,位置要规范化录入。
第二个断点是“跟不住”。报修之后没有单据,谁受理的、派给谁了、修到哪一步,全靠口头传递,用户催一次问一次。对应的功能是充电桩报修管理和维修回复管理,报修要落成一张可查询的单据,回复要落成一条带维修工号、维修姓名、维修状态、维修登记内容的记录,用户能在自己的个人中心里看到进度。
第三个断点是“留不下”。修完之后记录就散了,同型号的桩反复出同类故障,没人沉淀得下来。对应的功能是个人中心和系统管理,前者让用户看到自己的历史报修,后者给管理员留配置项和公告位。九个模块——首页、个人中心、维修员管理、用户管理、电桩类别管理、充电桩管理、充电桩报修管理、维修回复管理、系统管理——基本都是这三个断点的延伸,不是拍脑袋凑出来的。
把断点和模块的映射关系列成一张表,写设计说明的时候会非常好用,评审看到的是推导过程,不是功能罗列:
| 业务断点 | 典型现象 | 对应模块 | 落到数据上的要求 |
|---|---|---|---|
| 找不到 | 用户报不出桩编号,位置描述含糊 | 充电桩管理、电桩类别管理 | 桩编号唯一,类别可配置,位置必填 |
| 跟不住 | 报修后无单据,进度靠追问 | 充电桩报修管理、维修回复管理 | 单据有状态,回复有关联的单据号 |
| 留不下 | 维修记录散失,故障无法复盘 | 个人中心、系统管理 | 历史可查,配置项可维护 |
2.2 管理员、维修员、普通用户各看到什么
角色划分不是把菜单藏一藏就完事,关键是把“谁能改哪张表”定死。这套系统里只有三类角色,边界其实很清楚。
管理员是唯一的全权限角色,九个模块都能进。但真正需要他动手的是四件事:维护维修员账号(维修工号、姓名、身份证、家庭住址、联系电话)、维护普通用户账号、维护电桩类别字典、维护充电桩档案;此外还要处理报修单据的受理与派单,以及系统管理里的配置参数和公告。普通用户这边,前台能看首页、充电常识、充电桩列表,登录后进个人中心改自己的姓名、性别、年龄、联系电话,提交报修并查看回复。维修员权限最窄,只应该看到派给自己或待接的报修单,以及自己提交过的维修回复,用户管理和类别管理这两个入口对他应该是不可见的。
这里有个细节值得写进方案:维修员表里存了身份证和家庭住址,属于敏感字段,前台任何页面都不应该回显,后台列表页也应该做脱敏或者只对管理员可见。很多同学做权限就是前端 v-if 一删了事,接口照样能查出来,这是个实实在在的坑,第 5 章会专门讲。
| 模块 | 管理员 | 维修员 | 普通用户 |
|---|---|---|---|
| 维修员管理 | 增删改查 | 仅本人信息只读 | 不可见 |
| 用户管理 | 增删改查 | 不可见 | 仅本人信息可改 |
| 电桩类别管理 | 增删改查 | 只读 | 只读 |
| 充电桩管理 | 增删改查 | 只读 | 只读 |
| 充电桩报修管理 | 查看全部、派单 | 查看待接与本人单据 | 提交、查看本人单据 |
| 维修回复管理 | 查看全部 | 提交、查看本人回复 | 查看本人单据的回复 |
| 系统管理 | 增删改查 | 不可见 | 不可见 |
2.3 把需求写成能验收的句子
需求条目建议统一按“角色 + 前置条件 + 动作 + 数据结果”来写,一条一句话,这样测试的时候可以直接照着点。举几条这套系统里我实际会写的:
- 管理员在已登录状态下新增充电桩,填写电桩编号、电桩类别、所处位置、输入电压、输出功率、每时单价并上传图片,提交后列表首行出现该记录,重复的电桩编号被拒绝并提示。
- 普通用户在已登录状态下对某台桩提交报修,填写故障描述,提交后单据状态为“待受理”,报修时间取提交时刻,电桩编号、类别、所处位置、图片由档案自动带出。
- 管理员在报修列表选择一条“待受理”单据,指定维修工号完成派单,单据状态变为“已派单”,维修员登录后能看到这条记录。
- 维修员对“维修中”的单据提交维修回复,填写维修登记内容,提交后生成维修回复记录,主单状态变为“已完成”,普通用户在个人中心能看到这条回复。
- 管理员删除某个电桩类别时,若仍有充电桩引用该类别,操作被拒绝并提示先迁移。
非功能需求不用写太多,但要有量化口径:单表数据量按千级设计,列表查询走分页,单页 10 条;常规查询响应控制在 1 秒以内;密码不得明文入库;未登录访问后台地址必须跳转登录页而不是返回数据。这几条写出来,第 6 章的验收环节就有依据了。
3. 总体架构与数据库设计:B/S 三层结构下的框架组合与七张表
架构这一章要回答两个问题:为什么是 B/S 而不是 C/S,以及设计说明里写“SSM”而代码里跑的是 SpringBoot,这个口径怎么统一。数据库部分要回答第三个问题:七张表怎么划分职责,哪些字段是冗余快照,哪些字段是真正的关联键。这三件事说清楚了,第 4 章的代码才有落脚点。
3.1 B/S 三层结构与框架组合的选型
选 B/S 的理由很实在:用户端和管理端都只需要浏览器,不用给每台机器装客户端,改一版页面刷新就生效;部署上只需要一台装好 MySQL 和 Tomcat 的机器,对学生来说一台普通笔记本就能跑完整套。三层结构是浏览器层负责展示和交互,Tomcat 上的应用层负责业务和权限,MySQL 负责持久化,中间通过 HTTP 和 JDBC 连接,各层职责不串。
框架组合这部分要特别小心,因为输入材料里标题写的是 SSM,正文里又写“以 SpringBoot 作为框架”,这两句话放在同一份设计说明里会被人抓。正确的说法是:SSM 指 Spring + SpringMVC + MyBatis 这套技术组合,SpringBoot 是这套组合的工程化封装,它把原来需要手写的一堆 XML 配置换成了起步依赖和自动装配,底层的 IoC 容器、DispatcherServlet、MyBatis 的 SqlSessionFactory 一个没少。换句话说,写 SSM 说的是用了哪三样技术,写 SpringBoot 说的是用什么方式把它们装起来,两者不冲突。
依赖引入大致是这样,版本统一交给父工程管理,不在每个依赖上单独写版本号,省得后面版本打架:
<!-- Web 层:内含 SpringMVC 的 DispatcherServlet 与内嵌 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 持久层:MyBatis 与 Spring 的整合装配 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> </dependency> <!-- 数据库驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 热部署,改完页面不用重启 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <optional>true</optional> </dependency>还有一处容易翻车:SpringBoot 默认打 jar 包、用内嵌 Tomcat 启动,但设计说明里如果写的是“部署到 Tomcat 服务器”,那就得改成 war 包外置部署。改法不复杂,启动类继承SpringBootServletInitializer并重写configure,打包方式改成 war,然后丢进 Tomcat 的 webapps 目录。两种方式都能跑,但演示前一定要确认自己用的是哪一种,别在答辩现场发现 war 包少了个配置起不来。
@SpringBootApplication public class PileApplication extends SpringBootServletInitializer { public static void main(String[] args) { SpringApplication.run(PileApplication.class, args); } // 外置 Tomcat 部署时,容器通过这个方法加载应用上下文 @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(PileApplication.class); } }3.2 包结构与一次查询请求的完整链路
包结构按职责分,不按页面分。页面会变,职责不会。我一般用这样的结构:
com.example.pile ├── controller 接收请求、参数校验、组装返回 ├── service 业务规则,事务边界在这一层 │ └── impl ├── mapper MyBatis 接口,SQL 写在 resources/mapper 下 ├── entity 与表对应的实体,字段用驼峰 ├── common Result、PageResult、MD5Util 等公共类 ├── config WebMvcConfig、跨域与静态资源映射 └── interceptor 登录与角色拦截以“用户在充电桩列表页按类别筛选并翻页”这条链路为例,走一遍能看清各层在干什么。浏览器发起GET /pile/list?type=快充&page=1,DispatcherServlet 按路径找到ChargePileController的list方法;Controller 只做两件事,把page换算成offset,把参数原样传给 Service;Service 里通常没有复杂逻辑,直接调 Mapper;Mapper 对应的 XML 里拼条件、拼limit,交给 MySQL 执行;结果集按resultType映射成实体列表,包装成PageResult返回 JSON;前端拿到total和rows渲染表格和分页条。
这条链路里有两个地方值得在设计说明里点一句:一是分页参数不要在 Controller 里直接用,统一在PageResult里封装page、size、total、rows四个字段,前端好接;二是返回结构统一用Result,成功失败都用同一个外壳,前端判断code就行,不用每个接口写一套解析。
// 统一返回外壳:code=0 成功,非 0 为业务失败,msg 直接可回显 public class Result<T> { private int code; private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 0; r.msg = "操作成功"; r.data = data; return r; } public static <T> Result<T> fail(String msg) { Result<T> r = new Result<>(); r.code = 500; r.msg = msg; return r; } // getter / setter 省略 }3.3 E-R 设计与七张表的职责划分
实体关系其实不复杂,难点在“哪些字段该冗余”。先说清实体:用户(普通用户)、维修员、充电桩、电桩类别、报修单、维修回复、系统配置,一共七个。关系上,一个用户可以提交多张报修单,一台充电桩可以被多次报修,一张报修单对应零到一条维修回复(没修完就没有回复),一个维修员可以处理多张单据,充电桩属于某个类别。
七张表的分工如下,注意这里没有把“电桩类别”单独做成一张表,而是用配置表 + 类别字段的方式维护,这是很多同类系统的做法,代价是类别改名时要批量更新业务表,好处是表数量少、上手快。如果你的设计说明里想显得更规范,把类别拆成独立的字典表也完全可以,只是多一张表和多一次关联查询。
| 表名 | 中文名 | 核心字段 | 与其他表的关系 |
|---|---|---|---|
app_user | 普通用户表 | 用户账号、用户姓名、密码、性别、年龄、联系电话 | 账号被报修单引用 |
maintainer | 维修员表 | 维修工号、维修姓名、密码、性别、联系电话、身份证、家庭住址 | 工号被维修回复引用 |
charge_pile | 充电桩表 | 电桩编号、电桩类别、图片、输入电压、输出功率、使用方式、注意事项、所处位置、每时单价 | 编号被报修单引用 |
repair_order | 充电桩报修表 | 电桩编号、类别、图片、位置、故障描述、用户账号、用户姓名、报修时间、状态 | 关联用户与充电桩 |
repair_reply | 维修回复表 | 电桩编号、类别、图片、位置、用户账号、用户姓名、登记时间、维修工号、维修姓名、维修状态、维修登记 | 关联报修单与维修员 |
sys_config | 配置表 | 配置参数名称、配置参数值 | 独立字典 |
sys_user | 后台账号表 | 用户名、密码、角色 | 管理员与维修员登录用 |
这里要提前说一个设计判断:报修表和维修回复表里都存了电桩编号、类别、图片、位置、用户账号、用户姓名这六组字段,看起来是重复,实际上是快照。理由很直接——桩的位置可能改,用户可能改名,但历史单据必须保持提交当时的样子,否则三个月后回看记录会对不上。真正用来关联的是报修单主键,不是这些快照字段。
3.4 关键建表语句与字段口径
字段命名上,输入材料里用的是拼音(dianzhuangbianhao、suochuweizhi这类),这在同类毕设里很常见,好处是跟中文含义一一对应,坏处是每次写 SQL 都要想一下拼写。如果沿用它,就全线统一,不要一半拼音一半英文;如果改成英文短名,那就在设计说明里给一张字段对照表,评审看了反而觉得规范。下面按英文短名写核心三张表。
-- 充电桩档案表:编号唯一,是整条链路的锚点 CREATE TABLE charge_pile ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', pile_no VARCHAR(64) NOT NULL COMMENT '电桩编号,业务唯一', pile_type VARCHAR(64) NOT NULL COMMENT '电桩类别,取自类别字典', image VARCHAR(255) DEFAULT NULL COMMENT '桩体照片相对路径', input_voltage VARCHAR(32) DEFAULT NULL COMMENT '输入电压', output_power VARCHAR(32) DEFAULT NULL COMMENT '输出功率', usage_desc TEXT COMMENT '使用方式', notice TEXT COMMENT '注意事项', location VARCHAR(255) NOT NULL COMMENT '所处位置,规范到楼栋或车位号', price_per_hour DECIMAL(8,2) NOT NULL DEFAULT 0.00 COMMENT '每时单价,金额用 decimal 不用 float', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建档时间', PRIMARY KEY (id), UNIQUE KEY uk_pile_no (pile_no), KEY idx_type (pile_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充电桩档案';-- 报修单表:状态字段是流程的骨架,必须有 CREATE TABLE repair_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', pile_no VARCHAR(64) NOT NULL COMMENT '电桩编号,提交时从档案快照', pile_type VARCHAR(64) DEFAULT NULL COMMENT '电桩类别快照', image VARCHAR(255) DEFAULT NULL COMMENT '图片快照', location VARCHAR(255) DEFAULT NULL COMMENT '位置快照', user_account VARCHAR(64) NOT NULL COMMENT '报修人账号', user_name VARCHAR(64) DEFAULT NULL COMMENT '报修人姓名快照', fault_desc TEXT NOT NULL COMMENT '故障描述', status VARCHAR(16) NOT NULL DEFAULT '待受理' COMMENT '待受理/已派单/维修中/已完成/已关闭', repair_no VARCHAR(64) DEFAULT NULL COMMENT '派单后的维修工号', report_time DATETIME NOT NULL COMMENT '报修时间', PRIMARY KEY (id), KEY idx_user (user_account), KEY idx_status (status), KEY idx_pile (pile_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充电桩报修单';-- 维修回复表:一条回复对应一张报修单,靠 order_id 关联 CREATE TABLE repair_reply ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', order_id BIGINT NOT NULL COMMENT '对应报修单主键', pile_no VARCHAR(64) DEFAULT NULL COMMENT '电桩编号快照', user_account VARCHAR(64) DEFAULT NULL COMMENT '报修人账号快照', repair_no VARCHAR(64) NOT NULL COMMENT '维修工号', repair_name VARCHAR(64) DEFAULT NULL COMMENT '维修姓名快照', repair_status VARCHAR(16) NOT NULL DEFAULT '已完成' COMMENT '本次处理结果', repair_desc TEXT COMMENT '维修登记内容', register_time DATETIME NOT NULL COMMENT '登记时间', PRIMARY KEY (id), KEY idx_order (order_id), KEY idx_repair (repair_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维修回复';三个字段口径说明一下。price_per_hour用DECIMAL(8,2)而不是输入材料里的float,金额字段用浮点会出现0.1+0.2这类精度问题,计费场景下迟早被追问。status用中文枚举值,好处是页面上直接回显,坏处是以后要加国际化得改数据,学生项目里这个取舍可以接受,但要在设计说明里写明白。create_time用DATETIME而不是TIMESTAMP,后者在某些 MySQL 配置下会受时区影响,跨机器演示容易出现时间差八小时的情况。
4. 关键模块实现:充电桩建档、报修流转与越权拦截
需求定了、表定了,剩下的就是三条主线:桩怎么建起来并查得出来,报修怎么从提交走到关闭,权限怎么在接口层兜住。这三块写扎实,其余模块基本都是同一套模板的变体,复制改改就行。
4.1 充电桩建档与类别管理:条件检索加分页
充电桩列表是这个系统里被点开次数最多的页面,写法上有两个要求:条件要能组合,翻页不能全表查。下面的 Mapper XML 是核心查询,<where>标签会自动处理第一个条件前的and,避免拼出语法错误;模糊查询用concat拼接而不是直接字符串相加,能防住一部分注入问题。
<!-- ChargePileMapper.xml:支持编号/位置模糊、类别精确、电压精确三种条件的组合查询 --> <select id="selectPage" resultType="com.example.pile.entity.ChargePile"> SELECT id, pile_no, pile_type, image, input_voltage, output_power, location, price_per_hour, create_time FROM charge_pile <where> <if test="keyword != null and keyword != ''"> AND (pile_no LIKE CONCAT('%', #{keyword}, '%') OR location LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="pileType != null and pileType != ''"> AND pile_type = #{pileType} </if> <if test="voltage != null and voltage != ''"> AND input_voltage = #{voltage} </if> </where> ORDER BY id DESC LIMIT #{offset}, #{size} </select> <!-- 与列表同条件的计数,分页条要靠它算总页数 --> <select id="countByCondition" resultType="int"> SELECT COUNT(*) FROM charge_pile <where> <if test="keyword != null and keyword != ''"> AND (pile_no LIKE CONCAT('%', #{keyword}, '%') OR location LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="pileType != null and pileType != ''"> AND pile_type = #{pileType} </if> </where> </select>参数上,offset由(page - 1) * size算出,size默认给 10,别让它由前端随便传,否则有人传size=100000一次把库拖垮。keyword走的是编号和位置两个字段的联合模糊,因为用户报修时说得出的往往是“哪栋楼附近”,而不是编号。
新增和修改要注意唯一性校验。pile_no建了唯一索引,插入重复时数据库会抛异常,但直接把这个异常抛给前端体验很差,所以在 Service 层先查一次,存在就返回“电桩编号已存在”,同时保留数据库唯一索引作为最后一道保险。类别下拉框不要在前端写死,从类别字典或配置表里读,否则加一个“超充”类别就要改代码重新发版。
4.2 报修到维修回复:一条状态流转的闭环
这条链路是整个系统最值得写的部分,因为它有状态、有角色切换、有数据快照。状态我定成五档:待受理、已派单、维修中、已完成、已关闭。每档谁能动、动完变成什么,写清楚才不会出现“用户在已完成单据上又点了一次回复”这种脏数据。
| 当前状态 | 触发动作 | 操作角色 | 流转后状态 | 附带写入 |
|---|---|---|---|---|
| 无 | 提交报修 | 普通用户 | 待受理 | 报修时间、故障描述、桩快照 |
| 待受理 | 指定维修工号 | 管理员 | 已派单 | 维修工号 |
| 已派单 | 维修员接单 | 维修员 | 维修中 | 接单时间 |
| 维修中 | 填写维修登记并提交 | 维修员 | 已完成 | 维修回复记录 |
| 已完成 | 用户确认或超时关闭 | 系统/管理员 | 已关闭 | 关闭时间 |
提交报修的接口要做的关键动作是“从档案带快照”,而不是让用户手填编号和位置:
// 用户提交报修:编号、类别、图片、位置全部从桩档案取,用户只填故障描述 @PostMapping("/order/submit") @ResponseBody public Result<String> submit(@RequestBody RepairForm form, HttpSession session) { AppUser user = (AppUser) session.getAttribute("frontUser"); if (user == null) { return Result.fail("请先登录再提交报修"); } ChargePile pile = pileService.getByPileNo(form.getPileNo()); if (pile == null) { return Result.fail("电桩编号不存在,请核对后重试"); } if (form.getFaultDesc() == null || form.getFaultDesc().trim().length() < 5) { return Result.fail("故障描述至少填写五个字"); } RepairOrder order = new RepairOrder(); // 快照字段:提交那一刻的样子,后续桩信息变更不影响历史单据 order.setPileNo(pile.getPileNo()); order.setPileType(pile.getPileType()); order.setImage(pile.getImage()); order.setLocation(pile.getLocation()); order.setUserAccount(user.getAccount()); order.setUserName(user.getName()); order.setFaultDesc(form.getFaultDesc().trim()); order.setStatus("待受理"); order.setReportTime(new Date()); repairOrderService.save(order); return Result.ok("报修已提交,可在个人中心查看进度"); }维修员提交回复时,同样从主单取快照,并且在同一个事务里更新主单状态。这两步必须原子,否则会出现“回复记录写了但主单还是维修中”的情况,用户端看到进度不动,就会反复催。
// 维修员提交维修登记:写回复 + 改主单状态,放在同一事务里 @Transactional(rollbackFor = Exception.class) public void reply(Long orderId, String repairDesc, Maintainer m) { RepairOrder order = repairOrderMapper.selectById(orderId); if (order == null) { throw new BizException("报修单不存在"); } if (!"维修中".equals(order.getStatus())) { throw new BizException("当前单据状态不允许提交维修登记"); } RepairReply reply = new RepairReply(); reply.setOrderId(order.getId()); reply.setPileNo(order.getPileNo()); reply.setUserAccount(order.getUserAccount()); reply.setRepairNo(m.getNo()); reply.setRepairName(m.getName()); reply.setRepairStatus("已完成"); reply.setRepairDesc(repairDesc); reply.setRegisterTime(new Date()); repairReplyMapper.insert(reply); // 主单状态同步推进,条件里带上原状态,避免并发下重复推进 int rows = repairOrderMapper.updateStatus(orderId, "维修中", "已完成"); if (rows == 0) { throw new BizException("单据已被处理,请刷新后重试"); } }updateStatus里带上原状态当条件(WHERE id = ? AND status = '维修中'),是一种轻量的乐观锁,两个人同时点提交只有一个成功。这个细节写进设计说明,答辩被问到“并发怎么办”时有东西可说。
4.3 登录会话与角色拦截:别只把菜单藏起来
权限这件事,前端藏菜单只是体验优化,真正的边界在接口。做法是一个拦截器加一张路径规则表:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); HttpSession session = request.getSession(false); if (session == null) { response.sendRedirect("/login"); return false; } // 后台管理段:管理员会话 if (uri.startsWith("/admin/")) { if (session.getAttribute("adminUser") == null) { response.sendRedirect("/login"); return false; } } // 维修段:维修员会话,且必须是维修员角色 if (uri.startsWith("/repair/")) { Maintainer m = (Maintainer) session.getAttribute("repairUser"); if (m == null) { response.setStatus(403); return false; } } // 前台用户段:普通用户会话 if (uri.startsWith("/user/")) { if (session.getAttribute("frontUser") == null) { response.sendRedirect("/login"); return false; } } return true; } }注册规则时把静态资源和登录注册接口排除掉,其余按前缀收口:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/admin/**", "/repair/**", "/user/**") .excludePathPatterns("/login", "/register", "/css/**", "/js/**", "/upload/**"); } // 上传目录映射成静态资源,图片路径存相对路径即可 @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:./upload/"); } }密码存储不要明文。学生项目里最低成本的做法是 MD5 加固定盐,虽然强度一般,但比明文强得多,而且实现只有几行;如果愿意多写两行,用BCryptPasswordEncoder更合适。登录成功后把用户对象放进 Session,只放账号、姓名、角色这类必要字段,别把整条用户记录塞进去,尤其别把密码字段带进 Session。
5. 避坑与排查:这类系统最容易翻车的五个位置
下面五个问题,基本每一届做同类系统的同学都会碰到至少两个。按“现象 → 原因 → 解决”写,出问题时可以当排查清单用。
5.1 两张用户表混用,登录串号
现象:管理员用普通用户的账号密码登进了后台,或者普通用户登录后能看到维修员的报修列表。
原因:表设计里同时存在app_user(普通用户)和sys_user(后台账号),但登录接口图省事只查了一张表,或者查了两张但没判断角色,Session 里存的键还都是同一个user。
解决:两条独立的登录入口,前台登录只查app_user,后台登录只查sys_user并校验角色是否为管理员或维修员;Session 里用不同的键(frontUser、adminUser、repairUser)区分;拦截器按路径前缀取对应的键。这套做法写死在代码里,别指望前端菜单能兜住。
5.2 报修表与回复表双写,状态对不上
现象:维修员提交了回复,用户端看到的进度还停在“维修中”;或者一张单据出现两条回复。
原因:先写回复表、再更新主单状态,两步不在同一个事务里,中间出错就断在半路;或者提交按钮没有防重复点击,接口被连点两次。
解决:reply方法加@Transactional(rollbackFor = Exception.class);更新主单时用WHERE id = ? AND status = '维修中'做条件更新,返回影响行数为 0 就抛业务异常回滚;前端按钮点击后立即置灰,后端在 Service 层再查一次是否已存在回复记录。三层里任意一层做了都只能是缓解,两层一起做才稳。
5.3 图片路径写死绝对路径,换台机器全 404
现象:本地演示图片正常显示,拷到同学电脑上或者部署到服务器后,所有桩的照片都是裂图。
原因:上传时把D:\workspace\upload\xxx.jpg这种绝对路径存进了数据库,换机器后这个路径根本不存在。
解决:数据库只存相对路径(如/upload/2024/11/xxx.jpg),上传目录通过addResourceHandlers映射成静态资源;上传根目录写成配置项,不同环境改配置不改代码。已经存了绝对路径的老数据,写个更新脚本把前缀截掉。
5.4 下划线字段映射不到驼峰属性,查询结果全是 null
现象:SQL 在客户端跑得好好的,有数据;接口返回的列表里每个对象的属性都是null,页面上空白一片。
原因:表字段是pile_no这种下划线风格,实体属性是pileNo驼峰风格,MyBatis 默认不会自动转换,需要开配置或者写resultMap。
解决:在配置文件里打开map-underscore-to-camel-case: true,这是最省事的做法;如果有个别字段名对不上(比如拼音字段dianzhuangbianhao对应pileNo),单独写resultMap显式映射,别指望自动转换能猜出来。排查时先把 Mapper 返回的 List 大小打出来,能排除是查询条件的问题还是映射的问题。
mybatis: configuration: map-underscore-to-camel-case: true mapper-locations: classpath:mapper/*.xml5.5 菜单藏了但接口没拦,越权一测就露馅
现象:用普通用户的账号,手动在地址栏敲/admin/maintainer/list,维修员的身份证和家庭住址全出来了。
原因:权限只做在前端,后端接口裸奔,任何人拿到地址就能调。
解决:所有以/admin/、/repair/、/user/开头的接口全部纳入拦截器管辖,登录接口和静态资源显式排除;对涉及敏感字段的列表接口,在返回前做字段裁剪,维修员的身份证只回显前六位和后四位;测试时固定用三个不同角色的账号各敲一遍越权地址,形成习惯。
6. 验收与答辩:一套可照着走一遍的检查清单
验收这件事,别等到答辩前一天才做。我一般会在系统跑通后按模块走一遍清单,走完这一遍,基本上答辩现场被点哪里都不慌。
6.1 按模块走的验收清单
| 模块 | 验收动作 | 通过标准 |
|---|---|---|
| 登录 | 三个角色分别登录,再用错密码各试一次 | 各进各的首页,错误提示明确,不串号 |
| 充电桩管理 | 新增一台桩,编号重复再提交一次 | 首次成功,第二次被拒并提示编号重复 |
| 充电桩列表 | 按类别筛选、按位置关键词搜索、翻到第二页 | 结果正确,分页条总数与筛选结果一致 |
| 报修提交 | 用户对一台桩提交报修,故障描述只填两个字 | 正常提交生成待受理单;描述过短被拒 |
| 派单 | 管理员把待受理单派给某维修工号 | 状态变已派单,该维修员登录可见 |
| 维修回复 | 维修员提交维修登记 | 生成回复记录,主单变已完成,用户端可见 |
| 越权 | 用普通用户账号访问后台地址 | 跳登录页或 403,不返回任何业务数据 |
| 删除保护 | 删除仍被引用的电桩类别 | 操作被拒并提示先迁移 |
表格之外,还有两个容易被忽略的点。一是空数据状态:列表没有数据时页面不能报错,分页条不能崩,这是演示最容易翻车的地方,因为现场往往用的是干净数据库。二是时间格式:报修时间、登记时间在页面上统一yyyy-MM-dd HH:mm:ss,别一边显示时间戳一边显示日期字符串。
6.2 五分钟演示顺序与三个高频问题
演示不要按菜单顺序点,按故事线走:先以管理员身份登录,展示充电桩列表和一台桩的详情(体现建档规范);切到普通用户,对这台桩提交一次报修,回到个人中心看到“待受理”(体现提交与快照);切回管理员派单给维修员;切到维修员账号提交维修登记;最后回到用户端看到“已完成”和维修内容。一条线走完,九个模块里最核心的六个都被覆盖了,比逐个菜单点一遍有说服力得多。
答辩常问的三个问题,提前把答案写在一页纸上:为什么用 SSM 又提 SpringBoot——按第 3.1 节的口径答,说的是技术组合与工程化封装的关系;数据一致性怎么保证——答事务加条件更新,具体到updateStatus那个WHERE status = '维修中';安全性做了什么——答密码不明文、拦截器按路径分角色、敏感字段脱敏回显,三点足够。
我第一次带这类系统做演示,就是在干净数据库上翻车的:列表页空数据时模板里取了个不存在的字段,整页白屏,现场只能重启。从那以后我每次演示前都强制走一遍“清库 → 造三条数据 → 删到只剩一条 → 再删空”的流程,空态、单条、多条三种情况都过一遍才敢上台。这套清单你可以直接拿去改成自己的版本,希望帮到你。
本文还有配套的精品资源,点击获取