做校园设备报修系统这个选题,大概率是两类人:一类是计算机相关专业的毕业生,拿它当毕业设计;另一类是学校信息化部门的老师或外包开发者,想找一个能落地的后勤报修方案。不管你是哪一类,SpringBoot + 校园设备报修这两个关键词组合在一起,本身就是一套非常典型的"管理信息系统"练手项目——它涵盖了用户角色划分、流程状态流转、文件上传、数据统计这些高频需求,同时又比电商、社交这类项目简单不少,非常适合用来快速上手SpringBoot的完整开发链路。
我今年上半年刚帮一所职业院校完整落地过一套类似的校园设备报修系统,从需求梳理到部署上线前后花了三周左右,中间踩了不少坑,也攒了一些实战经验。这篇文章我就用这套系统的真实开发过程作为主线,把SpringBoot在校园报修场景下的技术选型、核心模块设计、流程实现和部署运维整个链路完整拆开来讲,希望能给准备做类似系统的同学一个可以直接参考的路线图。
1. 系统整体设计与技术选型思路
1.1 核心需求解析:校园报修系统到底在管什么
校园设备的报修维修和普通企业的工单系统有一个本质区别:它的服务对象是流动性极强的师生群体,报修设备种类杂(多媒体教室的投影仪、实验室的电脑、宿舍的空调、食堂的设备),而且维修响应速度直接影响到教学活动能不能正常进行。
所以这套系统的核心需求可以拆成四句话:
- 师生能快速提交报修单,不需要装任何客户端,浏览器打开就能用。
- 维修工能收到派单提醒,处理完要回填维修结果和耗材使用情况。
- 管理员能掌握全局数据,比如哪个区域报修最多、哪个维修工处理最慢、哪些设备故障率最高。
- 整个报修流程要有状态可查,师生能随时看到自己的单子处理到哪一步了。
这四句话翻译成技术语言,就是三个核心模块:角色权限管理模块、报修流程状态机模块、数据统计与可视化模块。配合上通知提醒(一般是短信或微信模板消息,也可以用站内信),整个系统的骨架就出来了。
1.2 为什么选SpringBoot + MyBatis-Plus这套组合
关于技术选型,我看过不少同学的毕设代码,有人用SpringBoot + JPA,有人用SpringBoot + MyBatis原生,还有人非要上微服务那套,把简单的报修系统拆成五六个服务。我的建议非常明确:单体应用 + SpringBoot + MyBatis-Plus,这是最稳的组合,没有之一。
理由也很实在。校园报修系统的并发量撑死了也就几百人同时在线,单体应用完全扛得住。SpringBoot最大的价值在于它把Spring繁琐的XML配置全部干掉,你只需要在application.yml里写几行配置,就能把一个可运行的Web服务跑起来——这对新手极其友好。而MyBatis-Plus则解决了MyBatis最让人头疼的问题:CRUD还要自己写SQL。它内置的BaseMapper接口让你连selectById()这种方法都不用写,直接调用就行。
我举个实际例子,你要实现"分页查询所有待派单的报修记录,按提交时间倒序",用MyBatis-Plus只需要这样写:
// ServiceImpl中的代码 @Override public IPage<RepairRecord> getPendingPage(int current, int size) { LambdaQueryWrapper<RepairRecord> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(RepairRecord::getStatus, "PENDING") .orderByDesc(RepairRecord::getCreateTime); return this.page(new Page<>(current, size), wrapper); }如果换成原生MyBatis,你要写XML映射文件、写SQL语句、写PageHelper的分页插件配置,工作量至少多出三倍。对于校园报修这种CRUD占大头、业务逻辑并不复杂的项目,MyBatis-Plus是性价比最高的选择。
1.3 角色权限模型:三种身份的边界划分
整个系统的权限模型我设计为三角色:学生(报修人)、维修工、管理员。这里有一个很关键的设计决策:不要自己硬编码角色判断,而是用SpringSecurity + JWT来做统一的认证和授权。
曾有同学图省事,直接在每个Controller里加if(user.getRole() == 1)这样的判断,后患无穷。一旦你要加一个新角色(比如"楼栋管理员"),你得把所有if全翻一遍。我采用的是基于注解的权限控制:
@RestController @RequestMapping("/api/repair") public class RepairController { @PreAuthorize("hasRole('STUDENT')") @PostMapping("/submit") public Result submit(@RequestBody RepairSubmitDTO dto) { // 学生提交报修单 } @PreAuthorize("hasRole('WORKER')") @PostMapping("/handle/{id}") public Result handle(@PathVariable Long id, @RequestBody HandleDTO dto) { // 维修工处理报修单 } @PreAuthorize("hasRole('ADMIN')") @GetMapping("/stats/category") public Result categoryStats() { // 管理员查看分类统计 } }这样做的最大好处是权限规则集中在注解上,一眼就能看出接口谁能访问。而且JWT无状态认证天然适合前后端分离的场景——Vue前端拿到token存到localStorage里,每次请求通过Authorization头带过来,后端通过过滤器校验并解析出当前用户信息,整个过程不需要Session。
需要注意的是hasRole('STUDENT')和hasAuthority('student')的细微区别。Spring Security的hasRole会自动加上ROLE_前缀,所以你在代码里写hasRole('STUDENT'),那么在授权表或角色表里存的角色标识就是ROLE_STUDENT。这个细节搞混了,权限怎么调都调不通。
2. 核心流程与数据库设计实战
2.1 报修流程状态机:从提交到完成的完整链路
校园报修最核心的业务逻辑是流程状态流转。我在设计时画了一条明确的状态链:
待派单(PENDING) -> 已派单(DISPATCHED) -> 维修中(PROCESSING) -> 已完成(COMPLETED) -> 已驳回(REJECTED)为什么要有"待派单"和"维修中"这两个状态分开管理?因为校园场景下,报修单往往会先经过管理员统一分派,再通知具体维修工。如果让学生直接选维修工,很容易出现某个维修工忙死、另一个闲死的情况。所以我在表设计里让assignee_id和status联动,只有管理员或者系统自动分派完成,状态才从PENDING变成DISPATCHED,维修工点"开始处理"后变成PROCESSING,处理完回填结果后变COMPLETED。
在实际代码里,我封装了一个状态流转的核心方法,避免各业务层随意修改状态值:
public boolean transition(Long recordId, String expectedFrom, String targetTo) { LambdaUpdateWrapper<RepairRecord> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(RepairRecord::getId, recordId) .eq(RepairRecord::getStatus, expectedFrom); // 乐观锁思想 RepairRecord update = new RepairRecord(); update.setStatus(targetTo); return this.update(update, wrapper); }注意这句.eq(RepairRecord::getStatus, expectedFrom)——它利用数据库层面的行锁效果,确保状态只能从指定的前置状态流转过来。如果两个人同时操作同一张单,只有一个能成功,另一个更新行数为0,直接被接口层拦截并提示"状态已变更,请刷新"。这就是最朴素的乐观锁思路,对付报修单这种低频操作绰绰有余,没必要引入专门的分布式锁。
2.2 数据库表设计:九张表的完整拆解
这套系统的数据库我刚建的时候只设计了三张表——用户表、报修表、评论表,结果做到权限模块就发现不对劲,角色和菜单没法关联,维修工和维修区域没法绑定。后来我完整敲定了九张表,这里列出来给大家做个参考:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| user | 所有用户(学生/维修工/管理员) | id, username, password, role, real_name, phone |
| repair_record | 报修单主表 | id, user_id, device_type, location, description, photo_url, status, assignee_id, create_time |
| repair_assign_log | 派单日志 | id, record_id, assigner_id, assignee_id, assign_time, note |
| repair_handle_detail | 维修处理明细 | id, record_id, handler_id, handle_result, part_used, cost, handle_time |
| repair_evaluation | 师生评价 | id, record_id, user_id, rating, content, create_time |
| device_category | 设备分类 | id, name, parent_id, sort_order |
| building_info | 楼栋教室信息 | id, building_name, room_name, area_tag |
| notification | 通知消息 | id, user_id, title, content, is_read, create_time |
| sys_menu / user_role | 权限辅助表 | 视权限方案而定 |
核心表之间的关联逻辑:repair_record表的user_id关联提交人,assignee_id关联维修工,device_type关联设备分类,location可以直接存楼栋加教室的文本(也可以关联building_info表做规范化)。repair_handle_detail和repair_record是一对一关系,但单独拆表会让日志记录更清晰,以后要查"某个维修工总共换了多少耗材"直接对repair_handle_detail做聚合就行,完全不用碰主表。
有个细节特别容易被忽略:图片存储字段。报修时让学生拍照上传,这个photo_url字段不要直接存磁盘绝对路径,因为部署后路径会变。我建议统一存相对路径,配合配置项在读取时拼接完整访问地址,这样系统迁移时不会有历史数据全部失效的问题。
2.3 自动派单策略:一个极其实用的算法
校园报修系统比企业工单系统多一个"闲时没人管"的问题——维修工不可能24小时候着,学生晚上报修的单子经常积压到第二天早上。我实际做的时候给系统加了一个简单的自动派单算法:
public Long autoAssign(RepairRecord record) { // 找到该设备类型擅长且当前待处理单量最少的维修工 List<WorkerLoadDTO> candidates = workerMapper.findAvailableWorkers(); // 按待处理单量升序排序,返回第一个 return candidates.stream() .filter(w -> w.getSkillTags().contains(record.getDeviceType())) .sorted(Comparator.comparingInt(WorkerLoadDTO::getPendingCount)) .map(WorkerLoadDTO::getWorkerId) .findFirst() .orElse(null); }逻辑很简单,就是"擅长优先 + 负载均衡"。先筛出懂这个设备类型的维修工,再按他们当前待处理单量排序,把单子给最闲的人。如果所有人都没有空或者是下班时间,就退回给管理员手动派单。这个小功能实测下来非常有用,能减少管理员80%的派单工作量。
3. 核心功能模块实现与实操要点
3.1 报修单提交:表单设计、图片上传与防重复提交
报修单提交是整个系统用户体验的起点,也是信息质量的关键所在。我见过很多系统的报修表单,就几个字段:姓名、电话、问题描述。这样的表单交上来之后,维修工还得打电话问到底哪个教室哪个设备坏了,沟通成本极高。
我落地的表单是这样设计的:楼栋(下拉选择)、教室号(下拉或手动输入)、设备类型(级联选择)、故障描述(必填,不少于10个字)、故障图片(可选拍照)、预约时间(可选)。关键点在设备类型,我用了两级级联,比如"多媒体设备 -> 投影仪"、"空调 -> 挂机"、"网络 -> 有线网络不通",这样维修工接到单子后基本不用再确认"哪坏了"。
图片上传我用的是本地存储 + Nginx静态映射方案,没有引入OSS。对于学校内部系统,几百GB的图片存储需求本地磁盘完全扛得住。上传接口的核心代码:
@PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("请选择文件"); } // 校验文件类型,只允许jpg/png,限制大小2MB String originalName = file.getOriginalFilename(); String ext = originalName.substring(originalName.lastIndexOf(".")).toLowerCase(); if (!Arrays.asList(".jpg", ".jpeg", ".png").contains(ext)) { return Result.error("仅支持jpg/png格式"); } // 用UUID重命名,避免文件名冲突 String newName = UUID.randomUUID().toString().replace("-", "") + ext; String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); File dir = new File(uploadPath + "/" + datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, newName)); return Result.success("/upload/" + datePath + "/" + newName); }这里注意两点:第一,文件名必须重命名,否则班级群里大家传的都是IMG_20240601_123456.jpg,第二次上传直接覆盖同名文件。第二,按日期建子目录能避免单个目录文件过多导致访问变慢。
防重复提交是我踩过的一个坑。有个学生手快连点了三下提交按钮,生成了三个一模一样的报修单。解决办法有两种,最简单的是前端在点击后把按钮置灰、加loading状态;更稳妥的是后端对这同一个用户在短时间内(比如10秒内)的重复提交做拦截。我用了后者的思路,基于Redis的简单计数:
// 伪代码 String redisKey = "repair_submit_lock:" + userId; Boolean isFirst = redisTemplate.opsForValue() .setIfAbsent(redisKey, "1", Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(isFirst)) { return Result.error("请不要重复提交"); }3.2 维修工工作台:待办列表、抢单与处理回填
维修工角色的核心页面是工作台——一个待办列表加一个处理详情弹窗。这个模块实现起来不难,但有几个交互细节处理不好就很别扭。
一是待办列表的刷新问题。维修工不可能一直刷新页面看新单子,我这里用了最轻量的轮询方案,前端每30秒调一次接口拿未处理的数量,有变化就提示"您有新的维修任务"。当时我也考虑过WebSocket实时推送,但对这个场景来说属于过度设计——报修单不是实时聊天,30秒的延迟完全在可接受范围内。
二是处理结果回填的字段设计。维修工完成维修后,需要填写:维修方式(现场维修/带回维修/更换配件)、处理结果说明、使用的耗材及数量、是否产生费用。耗材字段要设计成动态添加行,一个维修工可能同时换了电容和电源线,不能只给一个文本输入框。
这里有实操心得:维修工的文化水平差异很大,有些人打字很慢。处理结果说明最好给几个常用模板选项,比如"已恢复正常使用""配件已更换,目前无异常""设备需报废处理,建议采购新机",然后再挂一个可选的补充说明输入框。这样既能保证回复速度,又能兼顾个性化信息。
3.3 管理员后台:审核、派单、数据统计与人员管理
管理员的职责相比前两个角色要重得多,我把后台功能分成四块:报修单管理、维修工管理、设备与位置管理、数据看板。
报修单管理的关键是列表支持多条件组合筛选。我做了这样一组筛选条件:状态(全部/待派单/已派单/维修中/已完成/已驳回)、设备类型、楼栋、提交日期范围、是否紧急。筛选条件在MyBatis-Plus里用LambdaQueryWrapper动态拼接,非常方便:
public IPage<RepairRecord> pageWithCondition(RepairQueryDTO query, int current, int size) { LambdaQueryWrapper<RepairRecord> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.hasText(query.getStatus()), RepairRecord::getStatus, query.getStatus()) .eq(query.getDeviceTypeId() != null, RepairRecord::getDeviceType, query.getDeviceTypeId()) .eq(StringUtils.hasText(query.getBuilding()), RepairRecord::getLocation, query.getBuilding()) .ge(query.getStartDate() != null, RepairRecord::getCreateTime, query.getStartDate()) .le(query.getEndDate() != null, RepairRecord::getCreateTime, query.getEndDate()); return this.page(new Page<>(current, size), wrapper); }注意eq方法的第一个参数是boolean类型,当条件为false时,这个条件会被自动忽略。这个写法比我之前习惯的if判断+wrapper.eq拼接干净得多,强烈推荐。
数据看板部分,我用ECharts做了三个图:近7日报修量趋势柱状图、设备类型占比饼图、维修工工作量排行横向条形图。统计数据直接写SQL做聚合,比在Java里循环统计快得多。这里贴一条一周趋势的统计SQL:
SELECT DATE(create_time) as day, COUNT(*) as cnt FROM repair_record WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE(create_time) ORDER BY day;对应ServiceImpl里直接用Mapper注解方法执行:
@Select("SELECT DATE(create_time) as day, COUNT(*) as cnt " + "FROM repair_record " + "WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) " + "GROUP BY DATE(create_time) ORDER BY day") List<DailyCountVO> selectLastSevenDaysCount();注意COUNT(*)查询返回的字段别名必须和VO里的属性名对应,否则MyBatis映射会有问题。
3.4 通知模块:站内信与微信模板消息
报修进度发生变化时,怎么通知到学生?最直接的方式是站内信,就是notification表存记录,学生登录系统后在右上角小红点看到未读消息。这个实现很简单,但有个问题——学生在教室打开电脑才看得到,手机上根本不会主动打开系统。
我在实际部署时接入了学校已有的微信公众号平台服务,通过模板消息推送进度通知。学生提交报修单时如果愿意绑定微信,那么每个状态变化都会推送一条类似这样的消息:
【校园报修进度提醒】 您申报的[多媒体教室A101投影仪]维修单,当前状态:维修中 维修工:张师傅 预计完成时间:2024-06-01 18:00模板消息的接入在服务端其实就是调微信接口加上access_token,SpringBoot里的调用方式是用RestTemplate或OkHttp发一个POST请求。不过这里有个大坑:微信公众号的模板消息有严格的行业模板审核,报修这种内容大概率审核不通过。更省事的方式是让学校提供微信企业号的"应用消息"接口,或者直接用校园通的站内推送,这个要跟学校信息中心确认他们有什么渠道。技术本身不难,难的是确定哪种渠道可用——这块一定要第一时间跟校方沟通,我当初因为这个接口确定晚了,项目延期了三天。
4. 开发部署全流程与常见问题排查
4.1 从Maven工程到可运行Jar包
开发环境我建议统一用JDK 8 + SpringBoot 2.7.x这套组合,除非学校服务器明确要求更高的版本。JDK 8的兼容性最稳,SpringBoot 2.7.x也是2.x系列的最后一个稳定分支,各种第三方库的适配都很成熟。我见过有人直接上SpringBoot 3.0 + JDK 17,结果javax.*包名全变成了jakarta.*,老教程里的代码大面积报错,白白浪费好几天去适配——完全没有必要。
工程结构方面,我采用的是标准的Maven多模块划分(也可以做单模块,但多模块结构更清晰,适合毕设展示和后续扩展):
campus-repair-system/ ├── pom.xml ├── campus-common/ -- 通用工具类、统一结果封装、异常处理 ├── campus-framework/ -- SpringSecurity、JWT、配置类 ├── campus-system/ -- 用户、角色、菜单、日志模块 ├── campus-repair/ -- 报修单、派单、处理、评价模块 └── campus-admin/ -- 启动入口、Controller层、定时任务模块间的依赖关系是campus-repair依赖campus-system,campus-admin依赖所有模块,最终打包时只需要在campus-admin的pom.xml里配置SpringBoot打包插件:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>2.7.18</version> </plugin> </plugins> </build>打包命令就一行:mvn clean package -DskipTests。成功后会在campus-admin/target目录生成campus-admin.jar。注意,用这个插件打出来的Jar是可执行的胖Jar包,里面包含了Tomcat内嵌服务器,直接java -jar campus-admin.jar就能跑起来,这是SpringBoot最爽的地方。
4.2 生产环境部署:Java环境、外部配置与开机自启
部署服务器我推荐2核4G的Linux云主机,装好JDK 8和MySQL 5.7或8.0就够用了。部署前最需要提前定好的事情是:外部配置分离。把application.yml里所有可能随环境变化的配置——数据库地址、Redis地址、JWT密钥、文件上传路径——放到启动参数上,例如:
java -jar campus-admin.jar \ --spring.datasource.url=jdbc:mysql://localhost:3306/campus_repair?useUnicode=true&characterEncoding=utf8 \ --spring.datasource.username=repair_user \ --spring.datasource.password=YourPass123 \ --custom.upload-path=/data/campus-repair/upload这样就不用每次环境变化都重新打包Jar,只要写好启动脚本,切换测试环境或者迁移生产环境只需要改参数,非常省事。
为了让服务在断电重启后能自动恢复,我用Linux的systemd写了一个服务文件:
[Unit] Description=Campus Repair System After=network.target mysql.service [Service] Type=simple User=deploy ExecStart=/usr/bin/java -jar /opt/campus-repair/campus-admin.jar --spring.profiles.active=prod Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target配置完成后,systemctl daemon-reload && systemctl enable campus-repair就能实现开机自启、崩溃自动重启。这一步虽然不属于"写代码"的部分,但运维稳定性在真实环境里没它不行。
4.3 高频Bug实录:JWT过期、中文乱码与文件路径丢失
整个开发部署过程中,我整理了三个高频问题,这里单独拎出来说,比你自己瞎琢磨快得多。
问题一:JWT Token过期后前端不跳转登录页。
表现是用户操作着突然接口报401,但页面还停留在当前页,过一会儿才弹出登录过期。排查思路要先明确:JWT无状态,后端只能返回401状态码,跳转逻辑必须由前端全局拦截器处理。我后来在Vue项目的axios拦截器里做了统一处理,response.status === 401时清空localStorage并window.location.href = '/login'。看起来是个纯前端问题,但后端接口的返回结构需要统一成{ code: 401, msg: "登录已过期" },否则前端没法辨别。
问题二:MySQL插入中文数据变问号。
这个基本可以锁定是连接字符串缺少characterEncoding=utf8参数。我在application.yml里配置如下之后问题解决:
spring: datasource: url: jdbc:mysql://localhost:3306/campus_repair?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai同时需要确认两件事:数据库表本身的CHARACTER SET是utf8mb4(utf8mb4才是完整的UTF-8,utf8在MySQL里实际是utf8mb3,连emoji和生僻字都存不了),以及Linux服务器的LANG环境变量不为空。这三层任何一层掉链子,中文都会乱码。
问题三:上传的图片访问404。
我排查过自己遇到过的情况:上传成功后photo_url存了/upload/2024/06/01/uuid.jpg,浏览器访问却返回404。原因是我前面的Nginx只配置了location /api的反向代理,/upload路径没有映射到磁盘目录。正确的Nginx配置应该是:
server { listen 80; server_name campus.example.edu.cn; location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /upload/ { alias /data/campus-repair/upload/; } }alias后面最后一级目录名要和URL路径对应,写错了同样404。调试时可以先在服务器上curl http://127.0.0.1:8080/upload/xxx.jpg确认后端能不能访问到文件,如果能,问题就出在Nginx配置上,逐步缩小排查范围,比盲目改配置高效得多。
4.4 核心接口压测与性能优化记录
很多人做完系统就交付了,从没打过压力测试。我这次在部署前用JMeter做了两轮简单的接口压测,发现一个问题:报修单列表页的接口在300并发下平均响应时间从80ms飙升到1200ms,基本处于不可用状态。
查了一下SQL日志,发现列表接口默认JOIN了用户表、设备分类表、楼栋信息表,而且没有加索引。repair_record表在status和create_time字段上建立联合索引之后,响应时间降到了200ms以下。
如果你的项目数据量不大(几十万条以内),这个级别的优化就足够了。如果数据量再大,可以加一层Redis做热点数据的缓存,比如楼栋列表、设备分类这类极少变化的字典数据,第一次查询后写入Redis,后续直接走缓存。注意只缓存读多写少的数据,报修单本身别用Redis缓存,因为有状态流转,缓存一致性处理不好反而出bug。
从我做校园报修系统踩过的坑来看,整个项目最难的根本不是某个技术难点,而是把用户角色、流程状态、通知反馈这些业务细节想清楚、做完整。SpringBoot在这类管理系统中确实省力,它让开发者把精力放在业务逻辑而不是配置和框架本身。你按我上面这个路线把九张表、四个模块、两条状态链路(报修流转和用户权限)落地,整个系统就已经很完备了。
做这套系统后期,我额外做的一个小扩展是导出了维修工月度工作量Excel报表,用EasyExcel实现,管理员后台点一下"导出本月汇总"就能生成一张带合并单元格的统计表。后续如果你想让它更亮眼,也可以在这个方向上加把劲——从报修数据分析出设备老化趋势,反推下一年的采购预算建议,这个功能的展示效果比单纯做增删改查好得多。