JSP教学设备报修系统实战:状态机与Servlet闭环设计 简介这套毕业设计资源围绕高校教学设备报修场景基于JSP与MySQL实现B/S架构的管理平台面向计算机相关专业学生、毕业设计开发者及需要快速搭建报修系统的技术人员。资源共662个文件压缩包约15.94MB其中包含87个JSP页面、29个JS脚本、39个CSS样式以及419个GIF演示图与32张JPG图片等覆盖前端页面、后端逻辑与界面素材另含SQL数据库脚本、Eclipse工程配置文件和论文文档便于导入开发环境后直接运行或二次修改。同时资料内配有演示视频可辅助理解系统操作与设计说明适合学习JSP项目开发、Servlet/JSP整合与数据库设计的入门及进阶人群。目前已有200人学习下载可作为课程设计或毕业设计的完整参考。整体架构包含系统首页、用户注册、设备报修、后台管理等功能模块从需求分析、数据库设计到详细实现均有说明能帮助读者节省搭建时间快速掌握报修系统从零到一的开发流程。1. 教学设备报修系统一个 JSP 课设能不能写出闭环业务设备报修系统是 JSP 技术栈里被选次数最多的课设题目之一原因很直白业务边界清楚、角色不超过四种、核心对象只有“报修单”这一张表。但“能跑”和“能答辩”之间差着的恰恰是一条业务闭环——提交报修之后由谁受理、怎么派单、维修进度去哪里看、结束后如何归档这四步只要断一环演示现场就会被问住。这篇博客不写空框架直接按一个可运行的 JSP 教学设备报修系统来拆从数据模型和状态机开始到 JSP Servlet DAO 的实现链路再到 Tomcat 部署时最让人头疼的 JSP 编译与 class 定位最后补三个能在答辩里加分的小改造。正在做课设或开题的在校生可以照路径走想对比 JSP 与 Spring Boot 差异的开发者可以直接看第 5 部分。2. JSP教学设备报修系统的角色权限与四态数据模型2.1 角色只要三种状态却要四段先决定权限模型再写页面这是做 JSP 项目最不会返工的顺序。教学设备报修系统最常见的角色划分是三种报修人学生或任课教师、维修员后勤或实验室管理员、系统管理员教务处。也有人把“审核人”单独拆出来但那样就要多写一套审核页面、多一组动态 SQL工作量上并不划算。三种角色的页面复用度很高列表页和表单页是同一套 JSP只是查询条件不同。状态流转比角色更容易被忽略。“待受理 → 维修中 → 已完成”看起来够了实际演示时你会发现少了两个口子报修人提交后想撤销怎么办维修完成后要不要留痕存档所以完整一点的状态机应该是四个节点状态值状态名可执行动作操作角色1待受理派单、撤销管理员 / 报修人2维修中提交维修结果维修员3已完成评价、归档报修人 / 管理员4已归档仅浏览无这个设计在答辩里非常占分。导师问“为什么不能从待受理直接跳已完成”你可以说派单动作把报修单绑定到具体维修员是责任闭环的第一步已完成状态需要维修员先填写维修结果和耗材管理员确认后才能归档。把状态值设计成 int再配一个常量类或 Java 枚举比用字符串“pending / doing”省空间也比散落的魔法数字更安全。2.2 三张核心表字段怎么定不引入复杂外键也不把权限表单独拆出来三张表就能支撑整个业务sys_user、equipment、repair_order。如果教室和实验室需要单独统计可以在设备表上加一个location字段不必额外建房间表。核心建表 SQL 如下CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 加盐后的密码, role TINYINT NOT NULL DEFAULT 1 COMMENT 1报修人 2维修员 3管理员, real_name VARCHAR(32) COMMENT 真实姓名, phone VARCHAR(20) COMMENT 联系电话, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE equipment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_no VARCHAR(32) NOT NULL COMMENT 设备编号如 DM-2024-001, name VARCHAR(64) NOT NULL COMMENT 设备名称, location VARCHAR(128) COMMENT 存放位置如 教学楼A301, status TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1报修中 2报废, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT设备表; CREATE TABLE repair_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 报修单号, device_id BIGINT NOT NULL COMMENT 设备ID, reporter_id BIGINT NOT NULL COMMENT 报修人ID, assignee_id BIGINT DEFAULT NULL COMMENT 维修员ID, description VARCHAR(500) NOT NULL COMMENT 故障描述, priority TINYINT DEFAULT 2 COMMENT 1低 2中 3高, status TINYINT NOT NULL DEFAULT 1 COMMENT 1待受理 2维修中 3已完成 4已归档, repair_result VARCHAR(500) COMMENT 维修结果, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME DEFAULT NULL COMMENT 完成时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT报修单表;这里有几个容易在后期翻车的决策。order_no不要用自增 ID 替代报修单号要展示给用户看也经常被打印出来贴在设备上格式建议是“BX 年月日 三位流水号”生成逻辑放在 Servlet 或独立工具类里。equipment.status和repair_order.status存在语义重叠约定为“设备状态由最近一条未完结的报修单决定”不需要写触发器去同步。物理外键可以不加学校机房的老 MySQL 版本对级联限制较多逻辑外键配合索引是更省心的做法。2.3 初始数据与演示数据的准备答辩演示最怕打开页面上全是空表。初始化脚本里要把三种角色各建一个账号再插入几台设备和两条不同状态的报修单前台页面一打开就能看到列表、状态标签和操作按钮的效果。密码字段要统一加密不要明文入库课设里用 MD5 加固定盐足够说明安全意识。INSERT INTO sys_user (username, password, role, real_name, phone) VALUES (admin, MD5(admin123), 3, 教务处管理员, 13800001111), (repair01, MD5(123456), 2, 张师傅, 13800002222), (student01, MD5(123456), 1, 李同学, 13800003333); INSERT INTO equipment (device_no, name, location, status) VALUES (DM-2024-001, 多媒体投影仪, 教学楼A301, 1), (DM-2024-002, 智慧黑板, 实验楼B201, 0), (DM-2024-003, 功放音响, 报告厅, 0); INSERT INTO repair_order (order_no, device_id, reporter_id, description, priority, status) VALUES (BX20241215001, 1, 3, 投影仪开机无画面电源指示灯亮, 3, 2), (BX20241216002, 2, 3, 黑板触摸失效部分区域无响应, 2, 1);MD5()函数是 MySQL 内置的这样写能让初始化脚本独立运行不需要先启动 Java 应用再执行加密逻辑。演示数据要覆盖“不同优先级、不同状态”的组合这样后面做状态筛选和统计图表时才有内容可以展示。3. JSP 实现报修主链路页面、Servlet、DAO 的三层写法3.1 JSP 列表页用 JSTL 遍历状态码转换成标签JSP 页面里最忌讳大段 Java 脚本片段混着 HTML即使用%! %把方法都写进页面后期也基本没法维护。常见做法是 JSTL EL 表达式页面只负责展示和条件判断。报修列表页的核心片段如下% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % ... table tr th单号/thth设备/thth故障描述/th th优先级/thth状态/thth提交时间/th /tr c:forEach items${orderList} varo tr td${o.orderNo}/td td${o.deviceName}/td td title${o.description}${o.description}/td td c:choose c:when test${o.priority 1}低/c:when c:when test${o.priority 2}中/c:when c:when test${o.priority 3}高/c:when /c:choose /td td c:if test${o.status 1}span classbadge warn待受理/span/c:if c:if test${o.status 2}span classbadge info维修中/span/c:if c:if test${o.status 3}span classbadge ok已完成/span/c:if /td tdfmt:formatDate value${o.createTime} patternyyyy-MM-dd HH:mm//td /tr /c:forEach /table${o.deviceName}是联表查询后放进RepairOrder对象的冗余字段只读展示时不需要每次都去查设备表。fmt:formatDate负责时间格式化Java 里的Date直接输出会带毫秒数和时区后缀通过标签统一格式是 JSP 里的标准习惯。状态和优先级各自用c:if或c:choose渲染CSS 上再配不同颜色比直接输出数字或中文字符串要直观得多。3.2 Servlet action 参数分发与 JSON 数据返回Servlet 是整个请求链路的入口。常见的做法是一个业务模块对应一个 ServletURL 的action参数决定调哪个方法避免每个功能都新建一个 Servlet 类web.xml也不会膨胀。报修单模块的分发逻辑可以写成WebServlet(/repair) public class RepairServlet extends HttpServlet { protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String action req.getParameter(action); if (action null || action.isEmpty()) { action list; } switch (action) { case addPage: addPage(req, resp); break; case add: add(req, resp); break; case assign: assign(req, resp); break; case finish: finish(req, resp); break; case list: list(req, resp); break; case my: myList(req, resp); break; default: resp.sendError(HttpServletResponse.SC_NOT_FOUND); } } // add / assign / finish / list 等方法省略 }WebServlet注解从 Servlet 3.0 开始支持Tomcat 7 以上都不用写web.xml映射。action list作为默认分支是因为列表页入口最稳定即使有人忘了带参数也不会直接 404。每个 case 分支只负责参数解析和调用 Service最后统一转发到 JSP 或重定向不要在 Servlet 里写 SQL。还有一个实际会踩的坑报修单的“设备选择”下拉框里需要选中设备后异步带出位置这时候接口要返回 JSON 数组。老教程里常见的写法是import net.sf.json.JSONArray这个 JSON-lib 包早就停更了而且和较新 JDK 的反射机制不兼容。更稳的方案是引入 Jackson 的ObjectMapper或手动拼接[{...},{...}]字符串返回。手动拼字符串虽然不优雅但在只返回几个字段的课设场景里最可控还不容易引入依赖冲突。3.3 DAO 的 PreparedStatement 参数与事务边界DAO 层是整个系统里测试价值最高的部分。设备报修系统的写入链路涉及三处更新插入报修单、修改设备状态、冲洗当前维护状态。用PreparedStatement是标准做法既防 SQL 注入又让参数类型的设置更明确。下面是插入报修单的 DAO 方法public int createOrder(RepairOrder order) throws SQLException { String sql INSERT INTO repair_order (order_no, device_id, reporter_id, description, priority, status) VALUES(?,?,?,?,?,?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, order.getOrderNo()); ps.setLong(2, order.getDeviceId()); ps.setLong(3, order.getReporterId()); ps.setString(4, order.getDescription()); ps.setInt(5, order.getPriority()); ps.setInt(6, order.getStatus()); int rows ps.executeUpdate(); try (ResultSet rs ps.getGeneratedKeys()) { if (rs.next()) { order.setId(rs.getLong(1)); } } return rows; } }Statement.RETURN_GENERATED_KEYS让 MySQL 返回自增主键这样后续把“ID 写入日志表”和“修改设备状态”放在同一个业务方法里时报修单 ID 才能被复用。try-with-resources保证Connection和PreparedStatement在异常时自动关闭老项目里常见的finally { conn.close(); }写法可以简化掉。事务边界放在 Service 层而不是 DAO 层。比如新增报修操作要做两件事插入repair_order、把equipment.status改成 1。这两个操作必须同生共死所以 Service 方法里先conn.setAutoCommit(false)两个 DAO 都成功后再commit()任何一个抛异常就rollback()。如果事务放在 DAO 里两个 DAO 各用各的连接事务就失去了意义。3.4 状态流转用乐观锁避免重复派单从待受理变成维修中本质是一个状态机的跃迁。多人同时操作时可能出现两个管理员同时给同一张报修单派单的情况最后结果变成被后来者覆盖。解决的办法不需要上分布式锁一行 UPDATE 配合状态条件就够UPDATE repair_order SET status 2, assignee_id ? WHERE id ? AND status 1接口收到WHERE status 1这个条件影响行数如果返回 0说明这张单子已经被别人操作过前端提示“该报修单已受理请刷新后重试”。同理从维修中到已完成可以用WHERE status 2来约束。这套思路和乐观锁是一致的把“前一个状态”作为更新条件业务上不会有重复派单代码里也不需要加synchronized。答辩的时候把这个点讲出来非常加分。4. Tomcat 部署与 JSP 编译排错怎么定位 class 文件4.1 部署前需要确定的项目结构与 lib 依赖JSP 项目的部署结构比 Spring Boot 的重。用 IDEA 或 Eclipse 导出 WAR 包时要确保这几个目录是完整的WEB-INF/web.xml、WEB-INF/lib、WEB-INF/classes、页面文件直接放在根目录或WEB-INF/pages下。不少报错“404 页面找不到”其实是目录层级不对JSP 文件放进了WEB-INF里同时又没有在 Servlet 里转发。MySQL 驱动 jar 要放到WEB-INF/lib下不是放在 IDEA 的外部 Library 里就行。老生常谈但每次都会遇到直接把mysql-connector-java-8.0.x.jar加入构建路径应用启动正常一执行查询就报ClassNotFoundException: com.mysql.jdbc.Driver。原因是 Tomcat 运行时的类加载器不会去找冷启动时构建工具提供的库WEB-INF/lib才是它固定的扫描位置。把 jar 放进去之后去部署结构页面确认没有勾选“lib 目录排除”。4.2 JSP 编译后的 class 文件保存在哪里work 目录定位“JSP 编译 class 文件保存在哪里”是排查页面白屏和代码不生效问题的关键。JSP 第一次被请求时Tomcat 会用 Jasper 引擎把它转成 Java 源文件再编译成 class 文件这些文件统一存放在 Tomcat 的work目录下路径规则是${CATALINA_HOME}/work/Catalina/localhost/${contextPath}/org/apache/jsp/比如通过http://localhost:8080/repair/login.jsp访问对应文件就在find ${CATALINA_HOME:-/opt/tomcat}/work \ -type f -name login_jsp.class 2/dev/nullWindows 环境默认路径是C:\Program Files\Apache Software Foundation\Tomcat 9.0\work\Catalina\localhost\repair\org\apache\jsp。文件命名规则是把 JSP 文件名中的点和斜杠全部替换成下划线repair/order/list.jsp生成list_jsp.java和list_jsp.class。排查代码不生效时直接打开这个.java源文件就能看到 JSP 里的 HTML 标签和 Java 代码具体被翻译成了什么。很多诡异问题的根源都在这里${pageContext.request.contextPath}在 JSP 里正常但翻译成 Java 后拼接的字符串路径不对导致静态资源全部 404。work目录里还能看到每次修改 JSP 后的重新编译时间如果时间没有变化说明浏览器可能缓存了页面和服务器无关。4.3 毕业设计现场最常见的六个报错报错关键字常见原因快速定位方法UnsupportedClassVersionError编译用的 JDK 比 Tomcat 运行的 JDK 版本高java -version和 Tomcat 启动日志版本对比中文乱码POST 请求没设置request.setCharacterEncoding(UTF-8)或 JSP 的pageEncoding不一致统一在过滤器里设置 UTF-8web.xml里映射/*404 找不到 ServletWebServlet的urlPatterns与页面action不一致打开 JSP 查看表单action属性值再对比注解ClassNotFoundException: com.mysql.jdbc.DriverMySQL 驱动 jar 不在WEB-INF/lib解压 WAR 包看 jar 是否真实存在于 lib 目录JSP 页面白屏无报错RuntimeException 被 JSP 的isErrorPage吞掉或导入类不存在去 Tomcat 的logs/localhost.yyyy-MM-dd.log翻异常堆栈The method getXxx() is undefinedEL 表达式里属性名拼写和实体类 getter 不一致ELo.deviceName对应的必须存在getDeviceName()最后一类报错在课设里出现频率极高。JSP 页面编译成普通 Java 类之后EL 表达式对属性名的解析完全依赖 getter 方法IDE 里不报错是因为没把它当 Java 源码编译启动后一访问就是 500。改实体类方法名比改 JSP 更麻烦因为关联页面多所以建议属性一开始就按“驼峰命名”定好不要出现device_no和deviceNo混用。5. JSP 报修系统的答辩加分改造状态机、统计页、Spring Boot 迁移5.1 用 Java 枚举拆掉魔法数字四态状态机刚开始是int 注释代码量变大后order.getStatus() 2这种判断散落在 Servlet、DAO、JSP 三处改一个状态含义要全文替换。抽出枚举类其实不复杂但价值非常明显public enum RepairStatus { PENDING(1, 待受理), PROCESSING(2, 维修中), FINISHED(3, 已完成), ARCHIVED(4, 已归档); private final int value; private final String label; RepairStatus(int value, String label) { this.value value; this.label label; } public int value() { return value; } public String label() { return label; } public static RepairStatus from(int value) { for (RepairStatus s : values()) { if (s.value value) return s; } throw new IllegalArgumentException(未知状态: value); } }配合from(int)静态工厂DAO 里查询结果转实体时调用RepairStatus.from(rs.getInt(status)).value()JSP 里也能直接通过from拿到标签文本。这比在页面上写三次c:if更干净也是“设计模式 Java 实现”这个热词背后最基本的思想——用类型和常量约束住状态空间。答辩时可以说“状态从值对象变成了领域对象”这句话比“我会 CRUD”有说服力得多。5.2 报修历史页加筛选和统计默认的列表页做了分页后再加两个筛选条件优先级和状态SQL 在 Service 层拼好之后传给 DAO。这样页面直接变成一个迷你报表入口不需要额外开发。如果报修单数据超过几百行给查询条件加复合索引也就是device_id status create_time的联合索引查询响应从 200 毫秒降到 30 毫秒是很正常的提升。这比“优化 JVM 参数”更实在也更容易在答辩时演示效果。5.3 迁移到 Spring Boot 的最小代价做完整套 JSP 系统之后会有不少同学被问“你了解 Spring Boot 吗”。迁移并不需要重写所有代码DAO 层的 SQL 基本不动改成 Spring JdbcTemplate 或 MyBatis 的 MapperServlet 变成Controllerservice方法拆成GetMapping/PostMappingJSP 页面替换成 Thymeleaf但 EL 表达式的写法和th:text非常接近学习成本不高。关键是数据库表和状态机设计不重构意味着整条业务逻辑可以直接平移。把这样的迁移思路列成表格作为结课报告最后一张 PPT比自己写“项目展望”更有实际信息量。建议再给前端页面补一个小功能当前用户的历史报修记录里增加一个“催办”按钮待受理状态超过 24 小时才能点击点击后给管理员页面的列表多一个置顶标记。这个功能改动范围小但能把报修系统的完整度从“课设水平”拉到“可用工具”水平整个过程只需要在repair_order表加一个urgent_flag字段并在列表查询 ORDER BY 里加上这个字段的排序规则。验证方式也很简单造一条 25 小时前创建且状态为 1 的报修单用报修人账号点击催办再切到管理员账号查看该单是否排到了列表顶部。本文还有配套的精品资源点击获取