ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

设备维修管理系统源码解析:原生Servlet+JDBC实战与避坑指南

设备维修管理系统源码解析:原生Servlet+JDBC实战与避坑指南 简介设备维修管理系统是一套基于JavaWeb的完整源码工程采用原生Servlet与JDBC技术栈面向计算机、数学、电子信息等专业学生适用于课程设计、期末大作业或毕业设计参考。压缩包共包含248个文件其中54个Java源文件与对应class文件构成核心业务逻辑50个JavaScript脚本和17个CSS样式负责前端交互与布局另有若干JPG/PNG图片、SQL数据库脚本及配置文件整体仅2.84MB轻量易部署。项目内置设备报修、维修记录、配件管理、客户信息等模块从数据表设计到DAO层实现均有清晰层次方便读者理解传统JavaWeb开发流程。目前已有346人学习下载适合希望快速上手ServletJDBC开发、并能在此基础上自行拓展功能的开发者。1. 设备维修管理系统源码值不值得看先弄清楚原生 servlet JDBC 替你挡掉了什么设备维修管理系统是那种看起来普通、但拆开全是核心点的 JavaWeb 项目。这套源码用原生 servlet 加 JDBC 直接连 MySQL没有 Spring 那层包装对刚结束基础语法、想搞懂 HTTP 请求怎么从按钮一路走到数据库的人来说比一上来就糊 Spring Boot 更适合当作第一套完整工程。它能报修登记、维修单派单、状态流转、备件库存这些真实场景串起来适合正在做 JavaWeb 课程设计的学生也适合刚入职需要维护老系统的初级开发。看完这套源码你除了能把它跑起来更重要的是能把 servlet 生命周期、JDBC 事务边界这些平时背八股背不牢的东西动手验证一遍。2. 先把维修业务的表结构立住四张核心表与 JDBC 连接 MySQL 的最小配置2.1 维修业务的角色与工单状态流转单据到哪一步该由谁改项目里常见角色有三种报修人、维修工、管理员。报修人创建工单维修工接单和填写处理记录管理员负责派单和验收。对应到数据上普通用户只能新增工单、看自己的工单维修工看分给自己的工单管理员能看全部。工单状态我建议用数字存别用中文字符常见定义是 1-待受理、2-维修中、3-待验收、4-已完成、5-已驳回。这一串数字对应一个比较自然的流转管理员把报修单派给维修工状态变成 2维修工修完提交为 3管理员现场验收通过改成 4如果描述不清楚或维修方案不合理就驳回成 5。我第一次按这种结构整理表时参考了黑马JavaWeb笔记里那套订单思路但实际跑业务后发现还要补一层东西每个状态变更到底是谁操作的。如果表里只有update_time最多知道“什么时候变的”不知道“谁变的”。真上线用至少给t_repair_order加一个operator_id或者在工单里冗余操作人姓名。这不是数据库设计炫技是工单审计的基本底线后面你接其他订单类系统这套逻辑照样能用。2.2 表结构设计user、device、repair_order、spare_part 的字段与关系下面这段 SQL 是按这类项目最常用的口径整理出来的。写的时候有几个关键点外键要建但不要太密t_repair_order只对设备、报修人建外键assignee_id也指向t_user但允许为 NULL因为派单之前没值备件和工单之间是多对多所以拆一张t_order_part关联表之后统计备件消耗就不用扫描工单表里的字符串拼接字段。CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, role TINYINT NOT NULL DEFAULT 1 COMMENT 1-普通用户 2-维修工 3-管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_device ( id INT PRIMARY KEY AUTO_INCREMENT, device_code VARCHAR(50) NOT NULL UNIQUE, device_name VARCHAR(100) NOT NULL, location VARCHAR(100), status TINYINT NOT NULL DEFAULT 1 COMMENT 1-正常 0-故障, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_repair_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, device_id INT NOT NULL, reporter_id INT NOT NULL, assignee_id INT DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-待受理 2-维修中 3-待验收 4-已完成 5-已驳回, fault_desc VARCHAR(500), handle_note VARCHAR(500), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_order_device FOREIGN KEY (device_id) REFERENCES t_device(id), CONSTRAINT fk_order_reporter FOREIGN KEY (reporter_id) REFERENCES t_user(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_spare_part ( id INT PRIMARY KEY AUTO_INCREMENT, part_code VARCHAR(50) NOT NULL UNIQUE, part_name VARCHAR(100) NOT NULL, stock_count INT NOT NULL DEFAULT 0, warn_count INT NOT NULL DEFAULT 10, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_order_part ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, part_id INT NOT NULL, part_count INT NOT NULL DEFAULT 1, CONSTRAINT fk_op_order FOREIGN KEY (order_id) REFERENCES t_repair_order(id), CONSTRAINT fk_op_part FOREIGN KEY (part_id) REFERENCES t_spare_part(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;t_device里的status是设备自身状态和工单状态是两码事别混。设备为 0 时才允许发起新的报修工单这个校验要在插入工单前做一次也可以在t_repair_order加一个触发器但小项目里用代码判断更直观。t_order_part那张表是后面做库存扣减的核心维修工选择备件、填数量单据提交后库存从这张关联表反查回去事务范围就在“更新工单状态 更新备件库存”这两步上。2.3 JDBC 连接 MySQL 的最小配置URL 参数逐段说明纯 JDBC 项目的连接参数有四个常见坑。第一个是驱动类名MySQL 5.x 旧驱动是com.mysql.jdbc.DriverMySQL 6.0 之后官方改成了com.mysql.cj.jdbc.Driver连接 8.x 数据库时类名没换直接报ClassNotFoundException。第二个是 URL 里的serverTimezoneAsia/Shanghai不写就报时区不认识。第三个是useSSLfalse本地开发环境用 SSL 除了慢没有意义。第四个是characterEncodingutf8必须显式声明否则和项目里的 UTF-8 字符集对不上。package util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public class JdbcUtil { private static final String URL jdbc:mysql://localhost:3306/repair_system ?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD root; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void closeAll(Connection conn, PreparedStatement ps, ResultSet rs) { try { if (rs ! null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (ps ! null) ps.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn ! null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }关闭顺序是ResultSet到PreparedStatement再到Connection反着来偶尔会出现“连接已关闭但 ResultSet 还想读取”的奇怪异常。这套JdbcUtil用DriverManager.getConnection每次新建连接项目上线前建议换成连接池常见做法是接 HikariCP 或 DBCP。我一般让新手先不急着上连接池纯 JDBC 的断点跟进去能看清每一步理解了连接怎么来、怎么还再换池子就明白池的本质是复用连接而不是重复建连。3. 把工单增删改查跑通Servlet 路由 DAO 封装 JSP 列表的最小闭环3.1 用 Maven 还是直接把 jar 丢进 lib两种目录结构都列给你拿到源码后第一步是让工程能在 IDEA 里跑。如果源码自带 Maven打开pom.xml等依赖下载完配 Tomcat 就行。如果源码是传统结构src下直接放包web/WEB-INF/lib里丢了一堆 jarIDEA 里也能通过Project Structure Modules Dependencies把 lib 目录加进来。我更倾向把它改造成 Maven 工程目录清爽依赖版本一眼能看全后续加 JSON 库也不用满网找 jar。project modelVersion4.0.0/modelVersion groupIdcom.company/groupId artifactIdrepair-system/artifactId packagingwar/packaging version1.0.0/version properties maven.compiler.source8/maven.compiler.source maven.compiler.target8/maven.compiler.target /properties dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version3.1.0/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.30/version /dependency /dependencies /projectjavax.servlet-api的 scope 必须写provided因为 Tomcat 自带 servlet 实现如果把 jar 打包进 war部署时容易出现类冲突。源码里如果用的是javax.servlet包名Tomcat 别选 10 以上版本Tomcat 10 把包名换成了jakarta.servlet老项目直接跑不起来。这个坑我后面在避坑章节还会细讲这里先记得JDK 8 配 Tomcat 9 是原生 JavaWeb 最稳的组合。包结构按职责分成四块entity放RepairOrder、Device、SparePart等实体类dao放所有 JDBC 操作servlet只接收请求和转发页面filter放编码和登录拦截。实体类就是纯字段加 getter/setter对应t_repair_order表一行数据。这种分层被说土但它把一个请求从 Tomcat 到 JSP 的路径画得清清楚楚。3.2 RepairOrderServlet用 action 参数在一个 Servlet 里处理多个操作原生 Servlet 项目不流行每个功能建一个 Servlet太多类反而难找。常见做法是WebServlet(/order/*)映射一个RepairOrderServlet根据req.getPathInfo()拿到的子路径分派到不同方法。doGet处理查询类请求doPost处理修改类请求这是 JavaWeb 的老规约也让新手明白 HTTP 方法不是摆样子。package servlet; import dao.RepairOrderDao; import entity.RepairOrder; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; WebServlet(/order/*) public class RepairOrderServlet extends HttpServlet { private final RepairOrderDao orderDao new RepairOrderDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action parseAction(req); if (list.equals(action)) { req.setAttribute(orderList, orderDao.listAll()); req.getRequestDispatcher(/WEB-INF/jsp/order_list.jsp).forward(req, resp); } else { resp.sendError(HttpServletResponse.SC_NOT_FOUND); } } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String action parseAction(req); if (create.equals(action)) { RepairOrder order new RepairOrder(); order.setOrderNo(req.getParameter(orderNo)); order.setDeviceId(Integer.parseInt(req.getParameter(deviceId))); order.setReporterId(Integer.parseInt(req.getParameter(reporterId))); order.setFaultDesc(req.getParameter(faultDesc)); int newId orderDao.insert(order); req.setAttribute(newOrderId, newId); resp.sendRedirect(req.getContextPath() /order/list); } } private String parseAction(HttpServletRequest req) { String pathInfo req.getPathInfo(); if (pathInfo null || /.equals(pathInfo)) { return ; } return pathInfo.substring(1); } }注意parseAction拿到的是/order/list里的list因为WebServlet(/order/*)会把/order/后面的部分放进getPathInfo()。sendRedirect必须带req.getContextPath()部署上下文名不是根路径时漏掉它跳转地址就会 404。这里故意用了sendRedirect因为创建完工单再刷新页面不应该重复提交重定向以后浏览器地址变成/order/list再刷新也只是重新查询这条习惯比转发更适合修改类操作。3.3 DAO 层 PreparedStatement 习惯参数绑定与主键回填DAO 层每个方法对应一条 SQL。列表查询拼 SQL 要小心宁可多写几行绑定参数也不要靠字符串拼接用户输入这是 JDBC 编程的底线。PreparedStatement 能在数据库端做参数转义和预编译既防 SQL 注入也减少重复解析 SQL 的开销。package dao; import entity.RepairOrder; import util.JdbcUtil; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; import java.util.List; public class RepairOrderDao { public ListRepairOrder listAll() { String sql SELECT id, order_no, device_id, fault_desc, status FROM t_repair_order ORDER BY id DESC; ListRepairOrder list new ArrayList(); try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { RepairOrder o new RepairOrder(); o.setId(rs.getInt(id)); o.setOrderNo(rs.getString(order_no)); o.setDeviceId(rs.getInt(device_id)); o.setFaultDesc(rs.getString(fault_desc)); o.setStatus(rs.getInt(status)); list.add(o); } } catch (SQLException e) { e.printStackTrace(); } return list; } public int insert(RepairOrder order) { String sql INSERT INTO t_repair_order(order_no, device_id, reporter_id, fault_desc, status) VALUES (?, ?, ?, ?, 1); try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, order.getOrderNo()); ps.setInt(2, order.getDeviceId()); ps.setInt(3, order.getReporterId()); ps.setString(4, order.getFaultDesc()); ps.executeUpdate(); try (ResultSet keys ps.getGeneratedKeys()) { if (keys.next()) { return keys.getInt(1); } } } catch (SQLException e) { e.printStackTrace(); } return -1; } }Statement.RETURN_GENERATED_KEYS是这里容易被忽略的点。INSERT后如果不主动声明要刚生成的自增主键getGeneratedKeys()返回的结果集里可能拿不到值后面要把工单 ID 写进备件关联表时就抓瞎。把insert返回值拿回 Servlet再用来做页面跳转或记录日志整个闭环才算完整。3.4 JSP 列表页用 JSTL 遍历工单并正确拼出操作链接DAO 查出来的 List 被放在request里JSP 通过 JSTL 的c:forEach遍历。这里也有个经典坑页面上的链接如果不带上下文路径部署文件名一换所有按钮全 404。最省事的办法是在 JSP 头尾定义一个basePath再给base标签设值让页面上所有相对地址以项目根路径为基准。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % String basePath request.getScheme() :// request.getServerName() : request.getServerPort() request.getContextPath() /; % !DOCTYPE html html head base href% basePath % meta charsetUTF-8 title维修工单列表/title /head body table border1 tr th工单号/th th设备ID/th th故障描述/th th当前状态/th th操作/th /tr c:forEach varorder items${orderList} tr td${order.orderNo}/td td${order.deviceId}/td td${order.faultDesc}/td td${order.status}/td td a hreforder/detail/${order.id}查看/a a hreforder/finish?orderId${order.id}完成/a /td /tr /c:forEach /table /body /html有了basehreforder/detail/${order.id}会基于项目根路径解析成/repair_system/order/detail/1不再受当前/repair_system/order/list页面路径的干扰。除了base方案Spring 生态里更喜欢用c:url但原生 Servlet 项目里base更直白。${order.status}显示的是数字看起来不友好可以再放一个 JSP 页面里的c:choose把 1 到 5 翻译成文字这部分留在你把基本列表跑通后再加。4. 维修完成那一刻必须交给数据库事务两条 update 为什么不能拆开提交4.1 什么时候需要事务工单状态和备件库存必须同时改设备维修里最典型的事务场景是“维修完成”这个动作。如果只改工单状态为待验收不涉及备件一条 update 加事务纯粹多余。但实际维修过程往往用了备件那一次点击“完成”需要做两件事把t_repair_order的 status 改成 3同时把t_spare_part的库存扣掉还要在t_order_part记一笔消耗明细。三条 SQL 对应三次数据库操作。如果没有事务第一条成功、第二条失败备件账面没扣工单状态却显示完成了。月底盘库少几个轴承不细心查永远对不上账。我在把系统正式给同事试用一周后就踩出了这个问题后来回头看这就是典型的 JDBC 事务边界没控制住。想让这几条 SQL 要么全成、要么全不成首先必须保证用的是同一个Connection而不是每个 DAO 方法各自拿连接。4.2 setAutoCommit(false)、commit、rollback 的事务模板JDBC 默认 autocommit 是 true每条 SQL 执行完立刻提交。要开启手动事务第一步就是conn.setAutoCommit(false)然后所有 SQL 共享同一个Connection全部成功后调commit()任何一步异常调rollback()最后在finally里关闭连接。Connection conn null; try { conn JdbcUtil.getConnection(); conn.setAutoCommit(false); // 第一步更新工单状态 String sqlOrder UPDATE t_repair_order SET status 3 WHERE id ?; try (PreparedStatement ps conn.prepareStatement(sqlOrder)) { ps.setInt(1, orderId); ps.executeUpdate(); } // 第二步校验备件库存 String sqlCheck SELECT stock_count FROM t_spare_part WHERE id ? FOR UPDATE; int stock 0; try (PreparedStatement ps conn.prepareStatement(sqlCheck)) { ps.setInt(1, partId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { stock rs.getInt(stock_count); } } } if (stock usedCount) { throw new IllegalStateException(备件库存不足); } // 第三步扣减备件库存 String sqlStock UPDATE t_spare_part SET stock_count stock_count - ? WHERE id ?; try (PreparedStatement ps conn.prepareStatement(sqlStock)) { ps.setInt(1, usedCount); ps.setInt(2, partId); ps.executeUpdate(); } conn.commit(); } catch (Exception e) { if (conn ! null) { conn.rollback(); } throw e; } finally { if (conn ! null) { conn.close(); } }SELECT ... FOR UPDATE是给备件库存这一行加悲观锁。两个维修工同时完成工单、都要扣同一个备件时不加锁就会出现库存超扣先读到库存 5另一个事务也读到 5两边都减 3结果库存变成 2可实际已经发出去了 6 件。加FOR UPDATE后第二个事务会等第一个提交完再继续读虽然牺牲一点并发但小系统里正确性优先。注意这段代码只演示了事务结构真正落地时应该把这三步挪到一个 Service 方法里Servlet 只负责接收参数和调用 Service。4.3 事务失效的三种写法连接顺序、异常吞掉、连接分散第一种是连接关闭早于提交。有人把conn.close()写在conn.commit()前面以为反正要关闭先关后提交没区别。实际上Connection被close()时如果还有未提交事务JDBC 驱动会回滚掉结果就是代码不报错但数据没变。解决很简单commit()永远在close()之前finally里只做关闭。第二种是catch里只e.printStackTrace()没有rollback。异常发生时程序继续往下走连接看似还活着但事务状态已经不可靠尤其配了连接池时连接会回到池里脏状态污染下一个请求。解决方式不是只打日志而是先rollback()再把异常抛出让上层 Servlet 能提示用户“操作失败”。第三种更隐蔽两个 DAO 方法各自调JdbcUtil.getConnection()。Servlet 先调orderDao.updateStatus()这个连接提交了再调stockDao.deduct()这个连接回滚了效果就是工单改好了库存没扣。事务必须建立在同一个连接上常见做法是把Connection作为 Service 层方法的参数传给两个 DAO或者用一个ThreadLocalConnection绑定当前线程的连接DAO 里优先取线程变量里的连接。源码如果直接在 Servlet 里零散写 JDBC这里大概率会踩第二种或第三种坑。5. 避坑指南IDEA 跑 JavaWeb 项目、MySQL 连接与中文乱码的 10 个排查点5.1 IDEA 运行 JavaWeb 项目配置Tomcat 版本和热部署idea运行javaweb项目配置是很多新手第一次卡住的地方。IDEA 里配 Tomcat 的正确顺序是先确认项目 Artifact 是Web Application Exploded爆炸包就是直接把解压后的目录交给 Tomcat改完代码重启最快然后在 Run Configuration 里新建 Tomcat Server LocalDeployment 面板把项目加进去Application context 固定写成/repair_system别用 IDEA 默认生成的一长串带_war_exploded的名字。第一个常见问题是 Tomcat 版本选错。代码里 import 的是javax.servlet如果装了 Tomcat 10启动直接报NoClassDefFoundError: javax/servlet/...。原因不是代码坏了是 Tomcat 10 把包名整体改成jakarta.servlet。解决有两条路要么装回 Tomcat 9要么把源码里的javax.servlet全部改成jakarta.servlet后者等于把类库换一套没必要。原生 JavaWeb 老源码统一用 Tomcat 9 处理。第二个是热部署不生效。改 JSP 刷新浏览器就变了改 Java 代码后经常要手动Build Rebuild Project才能看到效果。IDEA 里 Tomcat Run Configuration 的 On frame deactivation 默认是Update resources只更新静态文件。要让它连带重新编译 class得在 Deploy 面板里把构建方式调成Update classes and resources不然每次改逻辑代码都像没保存一样容易被自己气到。5.2 JDBC 连接 MySQL时区、SSL、认证插件三个老问题这三个问题在jdbc连接mysql的场景里出现频率极高。排第一位的是时区启动时抛The server time zone value йʱ is unrecognized。MySQL 8 连接会严格对比时区服务器默认值解析不了中文操作系统里的时区表述。在 URL 末尾加serverTimezoneAsia/Shanghai立刻解决顺带把这个参数和useSSLfalse一起写进常量省得每次建项目都踩一次。第二个是 MySQL 8 的认证插件。报错内容一般是Public Key Retrieval is not allowed。MySQL 8 默认新用户的认证插件是caching_sha2_password老版本驱动建立连接时需要先拿公钥出于安全默认不自动获取。开发环境直接在 URL 加allowPublicKeyRetrievaltrue最快不愿意加这个参数就登录 MySQL 把用户认证改回mysql_native_password两条路都能通。第三个容易被忽略的是驱动本身太老。lib 目录里如果是mysql-connector-java-5.1.x连 MySQL 8 数据库会出现“Unknown database”一类说不清的怪问题有时甚至只报连接超时。排查时先看驱动 jar 的版本8.x 数据库至少用 Connector/J 8.0 系列别在 5.x 上折腾。5.3 中文乱码请求、响应、数据库三处编码要一致中文乱码很少只有一个原因通常是整条链路都没统一。先放一张全局过滤器拦截所有请求把请求和响应的字符集都设成 UTF-8package filter; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.annotation.WebFilter; import java.io.IOException; WebFilter(/*) public class CharacterEncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); } }写完过滤器还乱码按三个方向排查JSP 第一行是否写了pageEncodingUTF-8没写的话 JSP 文件在编译阶段就可能错表单提交页面的accept-charset是否被浏览器覆盖成其他编码最后查数据库连接 URL 有没有characterEncodingutf8。utf8在 MySQL Connector/J 里实际会映射成utf8mb4建表时也用utf8mb4别再纠结utf8和utf8mb4二选一统一成utf8mb4最省事。如果过滤器、JSP、URL 全是对的剩下就看数据库SHOW CREATE TABLE t_repair_order确认表默认字符集不是 latin1。老项目从别处导入脚本时经常建表语句里带了DEFAULT CHARSETlatin1这种表怎么改 Java 代码都白搭。5.4 页面 404 与修改不生效上下文路径、Servlet 映射和缓存页面 404 最常见的是 CDN 路径写错。页面地址是/repair_system/order/list你在 JSP 里写hrefcss/style.css浏览器会拼成/repair_system/order/css/style.css服务端当然找不到。这个和 Spring MVC 里/${pageContext.request.contextPath}是同一个问题原生 Servlet 项目里用base href%basePath%或者每个地址前手动拼ctx都是常规解法。Servlet 映射也容易出 404。WebServlet(/order/*)能匹配/order/list但不能严格匹配/order路径信息是 null 时parseAction返回空串Servlet 里没处理就走向sendError。反过来如果 web.xml 里只声明了/order/list访问/order/list/带尾斜杠又可能不匹配。拿到项目后先把几个核心 URL 用浏览器访问一遍确认映射配的不是“看起来能通”。改代码不生效的问题一半是 IDEA 没触发编译。Tomcat 运行面板下方会显示当前 Build 状态如果显示Build completed但页面还是旧逻辑打开Settings Build Tools Maven把Always update snapshots选中再删掉 Tomcat 的 work 目录重启。这条“先清缓存再重启”的顺序已经被我写给身边同事无数次了大多数时候比改代码本身更让人头疼。6. 从能跑到能用权限拦截、状态机与备件预警的进阶路径6.1 用 Filter 统一做登录与角色判断能跑通增删改查只是第一步。真正交给业务方用之前至少把登录拦截补上。很多源码里是在每个 Servlet 判断session.getAttribute(loginUser)是不是 null重复代码多漏一个位置就裸奔。用一个LoginFilter统一拦截放行登录页、登录接口和静态资源其他所有请求先验身份再放行。package filter; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI().substring(request.getContextPath().length()); if (/login.jsp.equals(uri) || /user/login.equals(uri) || uri.startsWith(/css/) || uri.startsWith(/js/)) { chain.doFilter(req, resp); return; } Object loginUser request.getSession().getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /login.jsp); } else { chain.doFilter(req, resp); } } }角色判断同样可以放在 Filter 里比如/order/finish只允许维修工和管理员访问拿到loginUser后判断 role 字段再放行。加这个 Filter 之前记得先确认loginUser这类属性名字在 Servlet 和 JSP 里是一致的不然会出现“明明登录了跳转页面却一直把你踢回登录页”的怪现象。6.2 用枚举管理工单状态结束硬编码数字的隐患业务跑一个月后你会发现代码里到处都是order.setStatus(3)、if (status 4)这样的魔法数字。想改状态含义要全局替换好几处漏一个就是线上乌龙。常见做法是抽出常量类或枚举。枚举比常量更好的一点是能把状态和显示文案放到一起JSP 也能通过枚举对象拿到对应文字。public enum RepairOrderStatus { PENDING(1, 待受理), REPAIRING(2, 维修中), TO_ACCEPT(3, 待验收), DONE(4, 已完成), REJECTED(5, 已驳回); private final int code; private final String label; RepairOrderStatus(int code, String label) { this.code code; this.label label; } public int getCode() { return code; } public String getLabel() { return label; } }再进一步把状态流转规则收拢到一个方法里待受理只能推进到维修中或已驳回维修中只能推进到待验收待验收只能推进到已完成或驳回。非法跳转直接在 Service 层挡住数据不会因为前端的下拉框被绕过而出现乱状态。这套思路和流程引擎是同一套逻辑只不过在 JDBC 项目里用几十行代码实现而已。6.3 备件库存预警先做查询实时判断再考虑定时任务备件扣完库存后列表页想标识哪些备件需要采购最简单的不是写定时任务而是查询时做实时判断SELECT part_code, part_name, stock_count, warn_count, IF(stock_count warn_count, 预警中, 充足) AS stock_level FROM t_spare_part WHERE stock_count warn_count;把这条 SQL 的结果放在备件管理页顶部低于预警线的用不同颜色高亮。小系统数据量不大单表几十上百行每次查询实时算一次根本感知不到性能差异。等库存记录到几十万条、需要每天汇总时再考虑用定时任务生成预警快照表也不迟现在用不上为未来过度设计。当初我自己就是觉得原生 Servlet 项目“太老”一开始直接把设备维修管理系统改写成 Spring Boot结果面试被问到 servlet 生命周期和 JDBC 事务边界时答得很虚。回头把这套原生代码一行行读了一遍才把请求从 Tomcat 到 JSP 再到数据库的整条路径串起来。原生 servlet 和 JDBC 不是要你生产环境一直用而是让你排查问题时心里有底框架帮你省掉的也是框架替你藏起来的。设备维修管理系统这个方向很适合做这件事代码边界清晰业务又足够真实。希望这些踩坑经验能让你少走一段弯路希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进