☰
基于SSM的充电桩管理系统设计与报修工单闭环实现
2026/10/28 2:07:55 网站建设 项目流程

简介:面向新能源汽车充电桩管理系统的毕业设计与课程实践场景,这份文档围绕SSM与SpringBoot整合、B/S架构、MySQL数据库及Tomcat部署,梳理需求分析、数据库设计和功能模块实现思路,适合需要参考充电桩后台方案的学生与开发者。整包共1个docx文件,约4.62MB,以毕业论文为主体,含题目、摘要、目录和系统设计说明,未另附独立源码目录。内容覆盖首页、个人中心、维修员管理、用户管理、电桩类别管理、充电桩管理、报修与维修回复、系统管理等模块,并提及权限控制、安全性与扩展性,可用于功能拆解、表结构设计和论文写作参考。目前已有65人学习,适合对照充电桩运维流程完善毕业设计文档,并作为答辩前查漏补缺的资料。

1. 需求先立住:充电桩管理为什么值得做成一套 B/S 系统

去年有个做充电站运维的朋友给我看他们的台账:桩的信息在 Excel,报修靠电话,维修师傅回没回、什么时候回,全凭记忆。一台桩停摆三天,账面上一片正常。这种场景是新能源汽车充电桩管理系统最典型的起点——不是缺一个"能跑的网站",而是缺一条把桩台账、故障上报、维修派单、结果回复串起来的信息链路。这套基于 SSM 的新能源汽车充电桩系统,就是围绕这条链路做的毕设级方案:前台给车主和普通用户看桩、报修,后台给管理员和维修员管桩、接单、回复。它适合两类人:一类是要交一份完整充电桩系统毕设或课设、手里只有题目没有结构的学生;另一类是要给中小型充电站搭一套内部管理平台、预算和人力都不宽裕的从业者。

2. 技术选型与总体架构:SSM 三层结构与六个模块的职责边界

先把一件容易在答辩现场翻车的事说清楚:项目标题写的是 SSM,而很多同类说明文档里又出现 SpringBoot 字样。这两者不是二选一的对立关系。SSM 指的是 Spring + SpringMVC + MyBatis 这套骨架,描述的是"分层方式";SpringBoot 描述的是"装配方式",它用 starter 和自动配置替你把原来那堆 XML 换掉。真要落到毕设上,我一般建议:主体按 SSM 分层写,把 Spring 的 bean 装配、SpringMVC 的 DispatcherServlet 映射、MyBatis 的 SqlSessionFactory 三者讲透,至于用不用 SpringBoot 起脚手架,看你所在环境——用 MyEclipse + Tomcat 部署就老老实实写 XML 配置,用 IDEA + 内嵌容器就跑 SpringBoot,两种都不影响你对架构的表述。评审老师问的是"你的请求从浏览器到数据库经过了哪几层",不是"你用了哪个启动器"。

选型的另一半是数据库和服务器。MySQL 在这个体量上是合适的选择:表结构简单、关联不深、单表数据量在万级,用不上分库分表那套东西,反而它的开源属性、可视化工具生态和部署成本更贴合这类项目的实际。Tomcat 作为 Servlet 容器扛 JSP 渲染和静态资源,B/S 模式让用户端不用装任何客户端,浏览器打开就能用——这一点在验收演示时很占便宜,评委不用等你装环境。

2.1 分层不是形式:一次请求要经过哪四个落点

很多人写分层是"为了看起来像三层",结果 Controller 里塞业务判断、Service 里直接拼 SQL 字符串、Mapper 里写 if-else 分支,最后一层都守不住。这套系统里,我把四层的职责卡得比较死:

  • 视图层(JSP):只负责取值渲染和表单提交,不做任何业务判断。列表页的"是否可报修"这种按钮显隐,由后端传一个布尔标记过来,不在页面上写<% if(...) %>。
  • 控制层(Controller):接参数、做参数基本校验(非空、长度、格式)、调 Service、封装返回视图或 JSON。分页参数 pageNum/pageSize 在这一层归一化,避免往下传脏值。
  • 业务层(Service):事务边界、状态流转、跨表组合操作。比如"维修回复提交后要把报修单状态改为已处理",这个联动必须写在 Service 的一个方法里,靠@Transactional兜住。
  • 持久层(Mapper):只做单表或明确的多表查询,SQL 写在 XML 里,动态条件用<if>拼,不在这里写业务规则。

这样切的好处是排查路径清晰。用户说"报修提交后列表里看不到",我按 视图表单字段名 → Controller 接参 → Service 落库 → Mapper SQL 条件 的顺序走一遍,通常五分钟内能定位到是哪一层把数据吃了。分层真正的价值不是好看,是让故障可定位。

2.2 前后台两条链路:同一个工程里的两套入口

这套系统一个容易被忽略的设计点是:它不是单一后台,而是前台和后台两套访问链路共用一个工程。

前台面向普通用户,入口是系统首页,能看充电桩列表、充电常识、个人中心,登录后可以对自己的充电桩发起报修、查看维修回复。后台面向管理员和维修员,入口是后台登录页,登录成功后按角色进入不同的菜单树。两条链路共用同一套 Service 和 Mapper,但 Controller 是分开的包,拦截规则也不同——前台只拦"个人中心、报修提交"这类需要登录的路径,后台整体拦在/admin/**之下。

提示:前台和后台不要共用一个登录态字段名。我见过把前台用户 session 和后台管理员 session 都叫user的,结果在同一个浏览器里先后登录,前台用户把管理员挤掉,或者反过来,演示时现场翻车。

工程目录按这个思路切,包结构大致是:

src/main/java/com/charging/ ├── controller/ │ ├── front/ # 前台:首页、充电桩浏览、报修提交、个人中心 │ └── admin/ # 后台:用户/维修员/类别/充电桩/报修/回复/系统管理 ├── service/ │ └── impl/ # 业务实现,事务注解加在这一层 ├── mapper/ # MyBatis 接口 ├── entity/ # 与数据库表一一对应的实体 ├── interceptor/ # 登录拦截、角色拦截 └── util/ # 分页封装、文件上传、统一返回体 src/main/resources/ ├── mapper/ # 与 mapper 接口同名的 XML ├── spring-*.xml # 容器、MVC、事务、MyBatis 装配 └── jdbc.properties # 连接串、账号、驱动

controller分包这一刀很关键。它决定了后面拦截器配置能不能一句话覆盖整个后台,也决定了你答辩时画架构图能不能画出两条清晰箭头,而不是一团糊在一起的面条。

2.3 六个模块的职责划分与角色权限矩阵

功能清单原文列了九项:首页、个人中心、维修员管理、用户管理、电桩类别管理、充电桩管理、充电桩报修管理、维修回复管理、系统管理。这九项平铺着念很唬人,实际拆开看是三个角色、六个业务域。把角色和模块的对应关系做成矩阵,比文字描述管用:

模块管理员维修员普通用户主要操作
首页 / 充电常识维护查看查看轮播、公告、常识内容维护
个人中心改自己信息改自己信息改自己信息密码、联系方式
用户管理增删改查不可见仅自己账号、姓名、性别、年龄、电话
维修员管理增删改查仅自己不可见工号、姓名、身份证、住址
电桩类别管理增删改查查看查看类别名称、分类维度
充电桩管理增删改查查看查看编号、类别、图片、电压、功率、位置、单价
充电桩报修管理全量查看查看派给自己的提交 / 查看自己的故障描述、报修时间
维修回复管理全量查看提交回复查看自己的维修状态、维修登记
系统管理配置项维护不可见不可见参数名、参数值

这张表是权限设计的依据,也是验收时最容易被追问的地方。原始需求里没写细粒度的数据权限,但按这个场景的常规做法,维修员只能看到派给自己工号的报修单,普通用户只能看到自己账号下的报修记录——这个"行级过滤"不是在页面加个 if 判断,而是要在 Mapper 的查询条件里强制拼上当前登录账号,具体做法放到第 4 章讲。

充电桩管理是整个系统的核心域,它的字段设计决定了后面报修单能带出多少信息。桩表里除了编号、类别、图片、位置,还有输入电压、输出功率、使用方式和注意事项两个大文本字段,以及"每时单价"这个计费维度。这张表设计得饱满,报修单才能通过编号和类别把桩的信息冗余带过去,维修员到场前就知道是哪种桩、多少功率、在哪个位置——这就是把台账做厚的价值。

3. 数据库设计:从 E-R 图到七张核心表的字段落地

数据库这块是很多人答辩被问住的第二现场。表能建出来不代表设计过,老师随手问一句"报修单和充电桩之间是什么联系、为什么报修表里要存电桩编号而不是只存电桩主键",答不上来就露馅。这一章把实体、联系、字段、约束四个层面拆开讲。

3.1 实体与联系:四个主体、两条主链路

系统里的实体不复杂,但要想清楚谁是主体、谁依附谁。四个核心实体是用户、维修员、充电桩、电桩类别,两个过程实体是报修单和维修回复。

主干链路有两条。第一条是"归属链":类别 1:N 充电桩,充电桩是类别的实例化;管理员维护类别,充电桩挂在类别下,这也是为什么报修单里要同时冗余"电桩编号"和"电桩类别"——类别影响维修策略,编号定位具体设备。第二条是"工单链":用户 1:N 报修单,报修单 1:1 维修回复,维修员 1:N 维修回复。用户发起报修,管理员或系统把工单落到某个维修员工号上,维修员提交回复,回复里带维修状态和维修登记内容,用户在前台能看到这条回复。

这里有个设计取舍值得写进论文:报修单和维修回复为什么拆成两张表,而不是在报修单里加"维修状态、维修工号、维修内容"三个字段。拆表的理由是状态和人员会随流程追加——一张报修单如果第一次没修好要返修,一条回复记录装不下,拆开就天然支持一对多。实际交付版本里按 1:1 处理(一张报修单只对应一条回复),但表结构留了一对多的余地,这叫"逻辑上收紧、结构上留口"。

实体属性按 E-R 图落到字段上,用户注册实体带账号、密码、确认密码、姓名、年龄、联系电话;充电桩实体带编号、类别、图片、输入电压、输出功率、使用方式、注意事项、位置、每时单价。这两张图的属性项要和后面的表结构严格对齐,画图时多写一个字段、建表时少一个,评审对着看就发现了。

3.2 七张核心表与字段落地

按 E-R 图展开,实际落地七张表:配置表、充电桩表、充电桩报修表、维修回复表、用户表、维修员表、后台账号表。字段命名上原方案用了拼音(dianzhuangbianhao、weixiugonghao),这是国内毕设里很常见的做法,好处是表和论文里的中文说明一一对应,坏处是英文环境下可读性差。我的建议是保持拼音不动——因为改字段名要连带改实体、Mapper XML、JSP 取值,工程量不小且容易漏,性价比太低;真要优化,在论文里加一张"字段中文对照表"就够了。

关键表结构如下(截取主要字段):

表名字段类型说明
充电桩idbigint主键,自增
充电桩dianzhuangbianhaovarchar(200)电桩编号,业务唯一
充电桩dianzhuangleibievarchar(200)电桩类别,关联类别表
充电桩tupianvarchar(200)图片存储路径
充电桩shurudianyavarchar(200)输入电压
充电桩shuchugonglvvarchar(200)输出功率
充电桩shiyongfangshilongtext使用方式,大文本
充电桩zhuyishixianglongtext注意事项,大文本
充电桩suochuweizhivarchar(200)所处位置
充电桩meishidanjiafloat每时单价
充电桩报修dianzhuangbianhaovarchar(200)冗余电桩编号
充电桩报修guzhangmiaoshulongtext故障描述
充电桩报修yonghuzhanghaovarchar(200)提交人账号
充电桩报修baoxiushijiandatetime报修时间
维修回复weixiugonghaovarchar(200)受理维修员工号
维修回复weixiuzhuangtaivarchar(200)维修状态
维修回复weixiudengjilongtext维修登记内容
用户yonghuzhanghaovarchar(200)登录账号
用户mimavarchar(200)密码
维修员weixiugonghaovarchar(200)登录工号
配置name / valuevarchar(100)参数名 / 参数值

三处细节值得单独说。第一,meishidanjia用 float 而不是 decimal,这在计费场景里是有隐患的,浮点累加会出现 0.1+0.2 不等于 0.3 那类问题;如果只是展示单价、不做金额累加,float 够用,一旦要做账单就必须换 decimal(10,2)。第二,shiyongfangshi和guzhangmiaoshu用 longtext,MyBatis 里对应的 jdbcType 要写对,否则空值插入会报类型不匹配。第三,两张表都带addtime timestamp default CURRENT_TIMESTAMP,这是统一的创建时间戳,排序和"最新报修"都靠它,比用 id 倒序可靠。

3.3 建表 SQL 与索引、一致性的取舍

充电桩主表和一个业务表的建表语句大致如下:

CREATE TABLE `charging_pile` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `addtime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `dianzhuangbianhao` varchar(200) NOT NULL COMMENT '电桩编号', `dianzhuangleibie` varchar(200) DEFAULT NULL COMMENT '电桩类别', `tupian` varchar(200) DEFAULT NULL COMMENT '图片路径', `shurudianya` varchar(200) DEFAULT NULL COMMENT '输入电压', `shuchugonglv` varchar(200) DEFAULT NULL COMMENT '输出功率', `shiyongfangshi` longtext COMMENT '使用方式', `zhuyishixiang` longtext COMMENT '注意事项', `suochuweizhi` varchar(200) DEFAULT NULL COMMENT '所处位置', `meishidanjia` float DEFAULT NULL COMMENT '每时单价', PRIMARY KEY (`id`), UNIQUE KEY `uk_bianhao` (`dianzhuangbianhao`), -- 业务编号唯一,防重复录入 KEY `idx_leibie` (`dianzhuangleibie`) -- 按类别筛选的高频查询 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充电桩信息'; CREATE TABLE `pile_repair` ( `id` bigint NOT NULL AUTO_INCREMENT, `addtime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `dianzhuangbianhao` varchar(200) NOT NULL COMMENT '电桩编号', `dianzhuangleibie` varchar(200) DEFAULT NULL COMMENT '电桩类别', `suochuweizhi` varchar(200) DEFAULT NULL COMMENT '所处位置', `guzhangmiaoshu` longtext COMMENT '故障描述', `yonghuzhanghao` varchar(200) NOT NULL COMMENT '报修人账号', `yonghuxingming` varchar(200) DEFAULT NULL COMMENT '报修人姓名', `baoxiushijian` datetime DEFAULT NULL COMMENT '报修时间', PRIMARY KEY (`id`), KEY `idx_yonghu` (`yonghuzhanghao`), -- 前台"我的报修"必用 KEY `idx_bianhao` (`dianzhuangbianhao`) -- 按桩追溯历史故障 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充电桩报修';

设计上有三个明确的取舍。

其一,字符集统一用 utf8mb4 而不是 utf8。utf8 在老版本里只支持三字节,存不了 emoji 和部分生僻字,故障描述里用户随手打一个表情就报错,这种 bug 排查起来像玄学。建库、建表、连接串三处字符集必须一致,缺一处就漏。

其二,没有建物理外键。报修表里的电桩编号只是逻辑关联,不设FOREIGN KEY。原因很实际:物理外键会让充电桩删除操作变得棘手,一台桩报废要删,历史报修记录还在;有了外键就删不掉,得先删报修再删桩,一旦顺序写错就是一堆级联报错。逻辑关联 + 业务层做删除前校验(还有未完成工单就不允许删),灵活性和安全性更平衡。

其三,索引只建在真正高频的条件上——电桩编号、类别、用户账号。索引不是越多越好,每加一个索引就多一份写入开销,这个数据量下两个复合索引足够,别把每列都加上索引然后在论文里写"进行了充分的性能优化",一问执行计划就说不下去了。

注意:dianzhuangbianhao加了唯一约束,但前端录入时如果没有做重复校验,用户提交重复编号会直接抛数据库异常,页面弹一堆英文堆栈。正确做法是 Controller 先 count 一次,命中就返回友好提示,唯一索引只作为最后一道防线。

4. 核心模块实现:报修工单闭环与充电桩台账的落地细节

设计画完,能不能跑起来看这三块:台账的增删改查、报修到回复的工单闭环、登录与角色拦截。这三块写扎实了,其余模块基本是同一套模式的复制。

4.1 充电桩台账:条件查询与分页的正确写法

充电桩列表是使用频次最高的页面,它同时要支持按编号模糊查、按类别精确筛、按位置模糊查,还要分页。这类"多条件 + 分页"的查询,把条件判断写在 Java 里拼 SQL 是踩坑高发区,正确姿势是用 MyBatis 的动态 SQL:

<!-- ChargingPileMapper.xml:多条件分页查询 --> <select id="selectPage" resultType="com.charging.entity.ChargingPile"> SELECT * FROM charging_pile <where> <!-- 编号模糊匹配,前端传空串时不参与拼接 --> <if test="bianhao != null and bianhao != ''"> AND dianzhuangbianhao LIKE CONCAT('%', #{bianhao}, '%') </if> <!-- 类别为精确匹配,下拉框选中才生效 --> <if test="leibie != null and leibie != ''"> AND dianzhuangleibie = #{leibie} </if> <if test="weizhi != null and weizhi != ''"> AND suochuweizhi LIKE CONCAT('%', #{weizhi}, '%') </if> </where> ORDER BY addtime DESC LIMIT #{offset}, #{pageSize} </select> <!-- 与上一条共用同一套 where 条件,保证总数和列表口径一致 --> <select id="countByCondition" resultType="int"> SELECT COUNT(*) FROM charging_pile <where> <if test="bianhao != null and bianhao != ''"> AND dianzhuangbianhao LIKE CONCAT('%', #{bianhao}, '%') </if> <if test="leibie != null and leibie != ''"> AND dianzhuangleibie = #{leibie} </if> <if test="weizhi != null and weizhi != ''"> AND suochuweizhi LIKE CONCAT('%', #{weizhi}, '%') </if> </where> </select>

这段的关键不在 SQL 本身,在两个约定。

约定一:offset由 Service 层算,公式是(pageNum - 1) * pageSize,pageNum 从 1 开始。前端传 0 或负数要在 Controller 里归一到 1,否则LIMIT -10, 10直接报语法错误。分页起点这个问题看着小,是新手最容易翻车的地方之一。

约定二:列表 SQL 和总数 SQL 的 where 条件必须逐字一致。我见过只改了列表条件、忘了改 count 的,结果翻到第二页数据重复或者总数对不上,用户以为系统丢数据。稳妥的做法是把条件抽成<sql id="pageWhere">片段用<include>引,改一处两处都生效。

CONCAT('%', #{bianhao}, '%')用#{}而不是${},这是防 SQL 注入的硬要求。${}是字符串直接拼接,用户传个' OR 1=1 --就能把条件绕过;#{}走预编译占位符,参数永远当值处理。整个项目里除了排序字段这种无法用占位符的场景,一律用#{}。

4.2 报修到维修回复:一个事务里的状态流转

工单闭环是这套系统真正的业务核心。用户提交报修 → 管理员查看并把工单派给某个维修员 → 维修员提交回复(含维修状态和维修登记)→ 报修单状态更新 → 用户在前台看到回复。这条链路上有两处必须放在同一个事务里,否则会出现"回复写进去了但报修单状态还是待处理"这种脏数据。

@Service public class RepairServiceImpl implements RepairService { @Resource private RepairMapper repairMapper; @Resource private RepairReplyMapper replyMapper; /** * 维修员提交回复:写回复记录 + 回写报修单状态,必须同事务 * @param reply 回复实体,必填:dianzhuangbianhao、weixiugonghao、weixiuzhuangtai、weixiudengji * @return 影响行数,两条都成功才返回 2 */ @Override @Transactional(rollbackFor = Exception.class) public int submitReply(RepairReply reply) { // 1. 先校验工单是否存在,避免给不存在的单子写回复 Repair repair = repairMapper.selectByPileNo(reply.getDianzhuangbianhao()); if (repair == null) { throw new RuntimeException("报修单不存在,可能已被删除"); } // 2. 幂等保护:同一工号对同一单子只允许提交一次回复 int exists = replyMapper.countByPileNoAndWorker( reply.getDianzhuangbianhao(), reply.getWeixiugonghao()); if (exists > 0) { throw new RuntimeException("该工单已提交过维修回复"); } // 3. 写回复 reply.setDengjishijian(new Date()); int rows = replyMapper.insert(reply); // 4. 回写报修单状态,状态值与前端下拉框保持一致 repairMapper.updateStatus(reply.getDianzhuangbianhao(), reply.getWeixiuzhuangtai()); return rows + 1; } }

逻辑说明分四步。第 1 步做存在性校验,是因为报修单可能被管理员删掉而回复页面还开着,不校验就会写出一条孤儿回复。第 2 步做幂等,维修员手抖点两下提交按钮是很常见的,没有这层保护就会写出两条回复,前台展示时用户看到两条一模一样的维修记录。

第 3、4 步必须在事务里。@Transactional(rollbackFor = Exception.class)里的rollbackFor不能省——Spring 默认只对 RuntimeException 和 Error 回滚,抛出受检异常时不回滚,这个默认行为不知道坑过多少人。加上rollbackFor = Exception.class后,任何异常都会触发回滚,回复和状态要么都成、要么都不成。

参数上有一个隐含约束:weixiuzhuangtai的取值集合要和前端下拉框、后台统计口径三处对齐。我一般把它固定成三个值——待维修、维修中、已完成——并且写成常量类,不在各处硬编码中文。

对应地,报修单的状态回写 SQL 用一个按业务编号更新的语句:

<update id="updateStatus"> UPDATE pile_repair SET weixiuzhuangtai = #{status} WHERE dianzhuangbianhao = #{bianhao} </update>

提示:这里用业务编号做更新条件是原方案的表结构决定的(报修表没有单独的工单号)。如果一张桩历史上报修过多次,这条 update 会一次改掉所有历史记录的状态。要避免这个副作用,工单查询必须限定"未完成"的记录,或者在报修表里补一个自增主键回传前端,用它做更新条件。前者改动小,后者更规范,交付前二选一都要想清楚。

4.3 登录态与角色拦截:别把权限做成摆设

很多同类项目的权限只做到"没登录就跳登录页",登录之后所有菜单都能点——维修员能进用户管理删账号,普通用户能进后台。这种系统在演示时不会出问题,但评审只要切一个账号就能试出来。

正确做法是两道拦截:登录拦截负责"有没有登录",角色拦截负责"这个角色能不能进这个路径"。

public class RoleInterceptor implements HandlerInterceptor { // 路径前缀 -> 允许访问的角色,键为后台模块的一级路径 private static final Map<String, List<String>> RULES = new HashMap<>(); static { RULES.put("/admin/user", Arrays.asList("admin")); RULES.put("/admin/worker", Arrays.asList("admin")); RULES.put("/admin/category", Arrays.asList("admin")); RULES.put("/admin/pile", Arrays.asList("admin", "worker")); // 维修员可查看 RULES.put("/admin/repair", Arrays.asList("admin", "worker")); RULES.put("/admin/reply", Arrays.asList("admin", "worker")); RULES.put("/admin/config", Arrays.asList("admin")); } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); Object role = request.getSession().getAttribute("role"); if (role == null) { response.sendRedirect(request.getContextPath() + "/admin/login"); return false; } // 最长前缀匹配,找到当前请求归属的模块规则 for (Map.Entry<String, List<String>> e : RULES.entrySet()) { if (uri.startsWith(request.getContextPath() + e.getKey())) { if (!e.getValue().contains(role.toString())) { response.setStatus(403); response.getWriter().write("no permission"); return false; } } } // 未命中任何规则的后台路径,仅放行管理员 return "admin".equals(role.toString()) || !uri.contains("/admin/"); } }

RULES这张表的粒度选了"一级模块路径",不是每个接口单独配。理由是这套系统的权限维度很粗——就三个角色,模块内不存在更细的差异,配到接口级别维护成本高、收益低。preHandle返回 false 就中断请求,不再进 Controller。

最后一个 return 是兜底逻辑:没命中规则的/admin/路径默认只放管理员过。这段兜底不能省,否则以后加一个新模块忘了配规则,就等于给所有人开了口子。

数据层的行级过滤另做一层。用户查"我的报修",Mapper 里强制带上当前会话的账号,不从前端传这个参数:

// 从 session 取账号,不接受前端传值,防止改参数看别人的单子 String account = (String) request.getSession().getAttribute("yonghuzhanghao"); List<Repair> list = repairService.selectByUser(account);

权限这东西,做在页面上叫"隐藏",做在拦截器里才叫"控制"。两者都要有,但后者是底线。

5. 避坑与排查:这类项目最容易翻车的五个地方

下面五条都是同类项目里反复出现的问题,按"现象 → 原因 → 解决"写,遇到时对着查。

5.1 中文乱码:链路上一处字符集不一致就全废

现象:JSP 页面提交中文,数据库里存成问号或者一串中文;查询出来的列表也全是乱码。

原因:乱码不是单点问题,是一条链路上多处编码设置叠加的结果——JSP 的pageEncoding、响应头的Content-Type、SpringMVC 的字符编码过滤器、JDBC 连接串的characterEncoding、数据库和表的字符集。只要有一处是 ISO-8859-1 或者老 utf8,链路就断。

解决:按顺序查五处并统一成 UTF-8。JSP 页头写<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;web.xml里配CharacterEncodingFilter并把forceEncoding设为 true;连接串加?useUnicode=true&characterEncoding=utf8;建库建表用utf8mb4;开发工具的 workspace 编码设成 UTF-8。五处逐一对照,不要凭感觉改一处就重启试。

5.2 图片上传后 404:路径存了,文件没落地

现象:充电桩图片上传成功,数据库tupian字段也有值,但页面<img>显示裂图。

原因:上传时把文件写到了工程目录之外(比如D:/upload/),但src拼的是/upload/xxx.jpg,Tomcat 的静态资源根目录根本不指向那里;或者存的是绝对路径D:/upload/xxx.jpg,浏览器按相对路径解析成了http://host/项目名/D:/upload/xxx.jpg。

解决:两条路选一条。一是把上传目录配成 Tomcat 的虚拟路径,在 server.xml 里加<Context docBase="D:/upload" path="/upload"/>,数据库只存/upload/文件名,页面拼上项目上下文路径。二是直接上传到工程内的webapp/upload/,路径简单但重新部署会被清空,只适合演示。文件名统一用 UUID + 原扩展名重命名,避免同名覆盖和中文件名带中文导致的路径解析问题。

5.3 时间字段差八小时:datetime 和 timestamp 混着用

现象:前台提交报修,时间是下午三点,数据库里存的是早上七点,或者反过来;addtime靠数据库默认值生成,baoxiushijian靠 Java 的new Date()生成,两个字段显示的时间对不上。

原因:timestamp会做时区转换,datetime不会,两者混用时服务器时区和数据库时区不一致就出现偏差;另外 JDBC 连接串如果没指定serverTimezone,驱动会按 UTC 解析。

解决:一个系统里时间字段统一用一种类型,这套方案用datetime更稳,不受时区转换影响。连接串补上serverTimezone=Asia/Shanghai,服务器时区核对一遍。时间字段的赋值统一在 Service 层做,别一半靠数据库默认值一半靠 Java,出问题时根本没法判断是谁写的。

5.4 删除充电桩后报修记录变孤儿

现象:管理员删掉一台已经报废的充电桩,历史报修记录还在列表里,点进去详情页一片空白或者报 500。

原因:表之间是逻辑关联没有物理外键,删主表记录时子表数据没人管;详情页又依赖桩编号去查桩信息,查不到直接空指针。

解决:删除前做业务校验——查这台桩是否还有状态为"待维修 / 维修中"的工单,有就不允许删,提示"该充电桩存在未完成工单"。同时把详情页的桩信息查询改成"查不到就降级显示冗余字段",报修表里已经冗余了编号、类别、位置,缺桩的情况直接用冗余值渲染,不至于白屏。删除这块千万别图省事,一删全崩是这类系统最常见的验收事故。

5.5 分页参数错位:总数对、列表少一条

现象:列表每页显示 10 条,但总数显示 25,翻到第三页只有 5 条却说"共 3 页";或者翻页后数据重复出现。

原因:LIMIT的偏移量算错,公式写成pageNum * pageSize而不是(pageNum - 1) * pageSize,第一页直接跳过了前 10 条;或者 count 查询的条件和列表查询条件不一致;还有一种是把排序字段写成了非唯一列(比如按类别排序),同类别记录在分页边界上顺序不稳定。

解决:偏移量公式统一封装在工具类里,别每处手写。count 和 list 的 where 条件用<sql>片段复用。排序至少带一个唯一列兜底,写成ORDER BY addtime DESC, id DESC,保证分页结果稳定。

6. 验收与答辩:从功能点到测试记录的一条自查路径

交付前我会强制走一遍自查,不是看代码写得多漂亮,是看每个功能点能不能在陌生人手里跑通。这张表是我常用的验收清单,逐项打勾:

验收项检查方法合格标准
三角色登录用管理员、维修员、普通用户分别登录各自菜单树正确,无越权入口
越权访问普通用户手输/admin/user地址返回 403 或跳登录,不泄漏数据
台账增删改查新增一台桩、改单价、删一台无工单的桩列表实时刷新,图片正常显示
唯一约束提交重复的电桩编号页面给出中文提示,不抛堆栈
报修闭环用户报修 → 维修员回复 → 用户查看三端状态一致,时间字段对得上
重复提交维修员连点两次提交按钮只生成一条回复记录
分页造 25 条数据,翻到第三页总数、页数、条数三者一致
删除保护删除有未完成工单的充电桩拒绝删除并给出原因

答辩环节,老师的问题集中在三个方向,提前准备好答案。

一是为什么这么选型。答的时候别背概念,讲约束:数据量在万级、团队一个人、部署在单台 Tomcat,微服务和分库分表在这个场景下只会增加运维负担,SSM 三层能覆盖全部需求,MySQL 的关系模型和这类强关联的台账数据天然匹配。

二是数据库设计的依据。重点讲清楚报修表为什么冗余电桩编号和类别、为什么不用物理外键、索引为什么只建两个。能说清"不做什么"和"做什么"同样重要,反而更容易得分。

三是系统的不足。这个问题不要回避,主动说:单价用 float 不适合做账单累加、工单状态流转没有操作日志、充电桩的实时状态(在线/离线)没有接硬件数据,目前只能靠人工报修反映。承认边界比强行说"系统很完善"可信得多。

有个细节我吃过亏:演示前一定要清一遍浏览器缓存和 session。有次在同一个浏览器里先登了管理员,切到用户端演示报修,结果被后台 session 串了权限,页面直接跳后台首页,现场解释了半天。从那以后我每次演示前都强制走一遍"清缓存 → 关全部标签 → 用无痕窗口逐角色登录",一个角色一个窗口,绝不在同一个窗口里切账号。这套习惯后来救过我不止一次。希望帮到你。

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

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

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

立即咨询