简介:这是一套面向JavaWeb进阶学习者的综合实战项目源码,以“快递e栈”业务为主线,整合Servlet、Filter、Session与MVC分层思想,适合用于课程设计、毕业设计选题或框架入门后的练手。压缩包共831个文件、约17.23MB,涵盖Java源码、编译后的class、JSP页面、HTML/CSS/JavaScript与jQuery脚本,以及配套的图片、GIF、字体和jar依赖;前端采用Bootstrap、Layui、Layer与Ajax完成交互,后端涉及MySQL访问与Tomcat部署,并包含properties、xml等配置资源。目录覆盖用户管理、快递单录入与查询、状态跟踪等业务模块,代码分层清晰,便于按功能定位与调试。已有890人学习下载。读者可从中理解登录鉴权、过滤器拦截、会话管理、分页查询与前后端异步通信的完整链路,也可作为二次开发与排错参考。
1. 从一张取件码小票说起:快递 e 栈这套 JavaWeb 源码到底能跑出什么
用户拿着取件码到驿站,输入一串数字,系统核销、写日志、后台状态从“待取”翻成“已取”。快递 e 栈这套 JavaWeb 综合项目源码,核心就四件事:Servlet 接请求、Filter 做横切、Session 管登录态、MVC 把页面和业务拆开。它不是单点 Demo,而是把登录、快递入库、取件码生成、取件核销、后台列表串成一条完整请求链,数据库落 MySQL,视图落 JSP。适合刚学完 Servlet/JDBC、想把课程设计从“能跑”推到“能讲清楚”的人,也适合熟手拿它当骨架改造成自己的驿站管理项目。下面按环境、链路、分层、踩坑、验证的顺序拆,能抄的代码和参数直接给。
2. 环境与数据库落地:Tomcat、MySQL 和 JDBC 参数怎么配才不翻车
2.1 先卡住 javax.servlet 这条版本边界
这套源码的 Servlet、Filter 写法大概率是javax.servlet.*,不是jakarta.servlet.*。常见做法是 JDK 8 或 11 配 Tomcat 8.5/9,MySQL 5.7/8.0 都行,驱动用com.mysql.cj.jdbc.Driver。如果直接把项目丢进 Tomcat 10,启动时报java.lang.NoClassDefFoundError: javax/servlet/Filter或者注解全部不生效,不是代码写错,是包名换了。我的习惯是先在 IDE 的 Project Structure 里确认 SDK,再把 Tomcat 的 lib 目录和项目依赖对一遍,别急着改代码。
目录结构不用花哨,按 MVC 拆清楚就行。下面是一个能直接对照源码包的目录骨架,src/main/java下按层分包,webapp下放 JSP、静态资源和WEB-INF:
src/main/java/com/express ├── controller # LoginServlet、ExpressServlet、PickupServlet ├── service # ExpressService,事务边界放这里 ├── dao # UserDao、ExpressDao ├── model # User、Express ├── filter # EncodingFilter、AuthFilter └── util # JDBCUtils src/main/webapp ├── WEB-INF │ ├── views # login.jsp、pickup.jsp、admin/list.jsp │ └── web.xml ├── static # css、js、图片 └── index.jsp这段目录不是强制标准,但WEB-INF/views放 JSP 很关键:浏览器不能直接访问WEB-INF下的页面,只能由 Servlet 转发进去,MVC 的边界才守得住。static单独放,后面 Filter 放行静态资源时不用一个个列路径。源码包如果已经是 Maven 结构,就按src/main/java和src/main/webapp对照;如果是普通 Eclipse/IDEA Web 项目,就看src和WebContent或web目录,包名一致即可。
依赖这块,Servlet API 必须是provided,不能打进WEB-INF/lib,否则和 Tomcat 自带的类冲突。MySQL 驱动和 JSTL 要放进WEB-INF/lib。如果源码包里有pom.xml,检查javax.servlet-api的 scope;如果没有 Maven,就把对应 jar 复制到WEB-INF/lib,重启 Tomcat。下面这条命令只是示意复制动作,具体 jar 名以你本地仓库为准:
# 进入项目 webapp 目录,把运行时依赖放进 WEB-INF/lib mkdir -p WEB-INF/lib cp ~/.m2/repository/mysql/mysql-connector-java/*/mysql-connector-java-*.jar WEB-INF/lib/ cp ~/.m2/repository/javax/servlet/jstl/*/jstl-*.jar WEB-INF/lib/逻辑说明:WEB-INF/lib是 Web 应用类加载器能自动扫描的目录,驱动和 JSTL 放这里,Tomcat 启动后Class.forName和 JSP 标签才能找到类。参数说明:~/.m2是本地 Maven 仓库路径,Windows 下换成C:\Users\你的用户名\.m2;不要复制servlet-api.jar,它由容器提供。复制完不要只点“重新部署”,要彻底 restart,热部署有时候不认新 jar,这是血泪经验。
2.2 建库建表:三张表把取件链路撑起来
快递 e 栈最少需要用户表、快递表、取件日志表。用户表存登录账号和角色,快递表存取件码、状态、货架号,取件日志表存核销记录。取件码要唯一,状态用0/1表示待取/已取,时间字段用datetime。下面这份 DDL 可以直接在 MySQL 里执行,库名用express_e,字符集utf8mb4:
CREATE DATABASE IF NOT EXISTS express_e DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE express_e; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(50), phone VARCHAR(20), role TINYINT NOT NULL DEFAULT 0 COMMENT '0普通用户 1管理员', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE t_express ( id INT PRIMARY KEY AUTO_INCREMENT, pickup_code VARCHAR(16) NOT NULL UNIQUE, company VARCHAR(50) NOT NULL, receiver_phone VARCHAR(20) NOT NULL, shelf_no VARCHAR(20), status TINYINT NOT NULL DEFAULT 0 COMMENT '0待取 1已取', operator_id INT, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pickup_time DATETIME NULL, KEY idx_phone (receiver_phone), KEY idx_status (status) ) ENGINE=InnoDB; CREATE TABLE t_pickup_log ( id INT PRIMARY KEY AUTO_INCREMENT, express_id INT NOT NULL, user_id INT NOT NULL, pickup_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, result VARCHAR(20) NOT NULL, KEY idx_express (express_id) ) ENGINE=InnoDB; INSERT INTO t_user(username, password, real_name, phone, role) VALUES ('admin', '123456', '管理员', '13800000000', 1);逻辑说明:t_express.pickup_code加唯一索引,取件码生成时即使随机碰撞,数据库也会兜底报Duplicate entry,业务层捕获后重试即可。status加索引,后台按状态筛列表不会全表扫。t_pickup_log只记成功和失败结果,方便第 6 章用 SQL 对账。参数说明:ENGINE=InnoDB必须保留,第 3 章取件事务里的for update行锁只在 InnoDB 下有效;utf8mb4能存中文和特殊字符;管理员密码这里是明文,跑通后建议改成 SHA-256 加盐,别把明文直接交付。
提示:如果源码包自带
.sql文件,先对比字段名,不要直接覆盖。很多课程设计源码的实体字段和表字段有驼峰/下划线差异,DAO 里写的是realName还是real_name,以源码为准,表结构可以跟着改。
2.3 JDBC 参数:URL 里四个参数少一个都容易出玄学问题
数据库连接建议单独放db.properties,不要硬编码在 DAO 里。URL 里useUnicode=true&characterEncoding=utf8管中文,useSSL=false避免本地证书告警,serverTimezone管时间字段差 8 小时。MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver,MySQL 5 用com.mysql.jdbc.Driver,写错会在启动时直接ClassNotFoundException。
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/express_e?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456逻辑说明:db.properties放在src/main/resources或WEB-INF/classes下,类加载器用getResourceAsStream读取。参数说明:127.0.0.1:3306是本地 MySQL,端口改过就换;express_e和 DDL 的库名一致;serverTimezone不设置时,pickup_time可能插入成前一天,后台列表看起来像“时间穿越”。密码不要提交到公开仓库,课程设计交付时把password改成占位符,让使用者自己填。
工具类用DriverManager拿连接,课程设计够用。生产环境会换 HikariCP,但这里先把关闭连接做对,比上连接池更急。下面这段JDBCUtils用静态块加载驱动,提供getConnection和close:
package com.express.util; import java.io.InputStream; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.util.Properties; public class JDBCUtils { private static String url; private static String username; private static String password; static { try (InputStream in = JDBCUtils.class.getClassLoader() .getResourceAsStream("db.properties")) { Properties p = new Properties(); p.load(in); Class.forName(p.getProperty("jdbc.driver")); url = p.getProperty("jdbc.url"); username = p.getProperty("jdbc.username"); password = p.getProperty("jdbc.password"); } catch (Exception e) { throw new ExceptionInInitializerError("数据库配置加载失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(Connection c) { if (c != null) { try { c.close(); } catch (SQLException ignored) { } } } }逻辑说明:静态块只执行一次,驱动加载失败会抛ExceptionInInitializerError,启动阶段就能看到,不会等到第一次查询才报错。参数说明:getResourceAsStream("db.properties")默认从 classpath 根找,文件别放错到WEB-INF根目录;close只关连接,PreparedStatement和ResultSet建议在 DAO 里用 try-with-resources 自动关,避免异常分支漏关。常见翻车是只关ResultSet不关Connection,跑一天后 MySQL 报Too many connections。
2.4 部署检查:context path、web.xml 和驱动位置
部署前把 Tomcat 的 Application context 设成/express,后面所有重定向和静态资源路径都用request.getContextPath()拼,别写死/。web.xml的welcome-file可以指到index.jsp,但登录校验最终要靠 Filter。驱动 jar 必须在WEB-INF/lib,不是在 Tomcat 的lib,否则换个容器就丢。启动后先访问http://127.0.0.1:8080/express/,看到登录页再往下走;如果 404,先看 Tomcat 日志里应用有没有部署成功,再看web.xml的url-pattern和 Servlet 注解是否重复。
3. Servlet + Session 的取件链路:从登录、入库到取件核销的完整请求流
3.1 登录与 Session 写入:别把密码塞进会话
登录 Servlet 做三件事:接参数、查用户、写 Session。查用户走 DAO,不在这里写 SQL;Session 里只放User对象,不放密码。登录成功按角色跳:管理员去后台列表,普通用户去取件页。失败用 forward 回登录页并带msg,不要用 redirect,否则提示信息丢了。Session 超时设 30 分钟,课程设计够用,测试时可以改成 1 分钟。
package com.express.controller; import com.express.model.User; import com.express.service.UserService; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; @WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userService.login(username, password); if (user == null) { req.setAttribute("msg", "账号或密码错误"); req.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(true); session.setAttribute("loginUser", user); // 只存必要字段 session.setMaxInactiveInterval(30 * 60); // 30 分钟过期 if (user.getRole() == 1) { resp.sendRedirect(req.getContextPath() + "/admin/express/list"); } else { resp.sendRedirect(req.getContextPath() + "/pickup"); } } }逻辑说明:req.getSession(true)在无会话时新建,登录成功必须写;失败分支不创建 Session,避免匿名会话堆积。sendRedirect让浏览器重新发 GET,刷新后台列表不会重复提交登录表单。参数说明:maxInactiveInterval单位是秒,30*60 是 30 分钟;user.getRole()约定 1 为管理员,和建表注释一致;req.getContextPath()返回/express,重定向路径必须带它,否则部署到非根路径会 404。User建议实现Serializable,Tomcat 重启持久化会话时不至于报错。
3.2 入库与取件码生成:唯一索引是最后一道保险
快递入库由管理员在后台提交,Servlet 接公司、收件人手机号、货架号,生成取件码后插入t_express。取件码常见格式是“公司前缀 + 月日 + 4 位随机数”,例如SF01011234。随机数会碰撞,所以生成时先查重,数据库唯一索引再兜底。插入成功返回列表页,失败把错误信息带回表单。
package com.express.controller; import com.express.service.ExpressService; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; @WebServlet("/admin/express/add") public class ExpressAddServlet extends HttpServlet { private final ExpressService expressService = new ExpressService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String company = req.getParameter("company"); String phone = req.getParameter("receiverPhone"); String shelfNo = req.getParameter("shelfNo"); try { String code = expressService.addExpress(company, phone, shelfNo); req.getSession().setAttribute("lastPickupCode", code); resp.sendRedirect(req.getContextPath() + "/admin/express/list"); } catch (RuntimeException e) { req.setAttribute("msg", "入库失败:" + e.getMessage()); req.getRequestDispatcher("/WEB-INF/views/admin/add.jsp").forward(req, resp); } } }逻辑说明:Controller 只接参数和做跳转,取件码生成、查重、插入都在ExpressService里,MVC 的边界才不糊。lastPickupCode放 Session 是为了后台列表页提示“刚生成的取件码”,不是业务数据。参数说明:company对应快递公司简称,receiverPhone用来后续按手机号查件,shelfNo是货架号,可以为空;addExpress内部要循环生成取件码,捕获DuplicateKeyException后重试,最多 5 次,超过就抛业务异常,别让请求卡死。
3.3 取件核销事务:for update 和状态条件更新一起用
取件是整套项目最值得讲的链路。用户输入取件码,Service 开事务,先select ... for update锁住这行,判断状态是不是待取,再更新状态和取件时间,最后写日志。更新语句带status=0条件,防止两个请求同时取同一件。任何一步失败都回滚,不能出现“状态改了但日志没写”。
public boolean pickup(String pickupCode, int operatorId) { String selectSql = "select id, status from t_express where pickup_code=? for update"; String updateSql = "update t_express set status=1, pickup_time=now(), operator_id=? " + "where id=? and status=0"; String logSql = "insert into t_pickup_log(express_id, user_id, pickup_time, result) " + "values(?, ?, now(), ?)"; Connection c = null; try { c = JDBCUtils.getConnection(); c.setAutoCommit(false); int expressId; try (PreparedStatement ps = c.prepareStatement(selectSql)) { ps.setString(1, pickupCode); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { c.rollback(); return false; } expressId = rs.getInt("id"); if (rs.getInt("status") == 1) { c.rollback(); return false; } } } try (PreparedStatement ps = c.prepareStatement(updateSql)) { ps.setInt(1, operatorId); ps.setInt(2, expressId); if (ps.executeUpdate() != 1) { c.rollback(); return false; } } try (PreparedStatement ps = c.prepareStatement(logSql)) { ps.setInt(1, expressId); ps.setInt(2, operatorId); ps.setString(3, "SUCCESS"); ps.executeUpdate(); } c.commit(); return true; } catch (SQLException e) { if (c != null) { try { c.rollback(); } catch (SQLException ignored) { } } throw new RuntimeException("取件失败", e); } finally { if (c != null) { try { c.setAutoCommit(true); c.close(); } catch (SQLException ignored) { } } } }逻辑说明:for update在事务里锁行,第二个并发请求会等第一个提交后再读到status=1,直接返回 false;update ... where status=0是第二道保险,即使锁没生效,更新影响行数不等于 1 也会回滚。参数说明:pickupCode是用户输入,必须用PreparedStatement占位符,不能拼 SQL;operatorId是当前操作人,从 Session 的loginUser里取;result先写SUCCESS,失败分支可以不写日志,或者单独写FAIL,但不要在同一事务里回滚掉失败记录。MySQL 表必须是 InnoDB,否则for update退化成表锁甚至无效。
3.4 JSP 只做展示:request attribute 是 Controller 和 View 的接口
列表页和取件页都由 Servlet 查数据,req.setAttribute("expressList", list)后 forward 到WEB-INF/views下的 JSP。JSP 里用 JSTL 遍历,不写 JDBC,不写 Java 脚本段。状态显示用三元表达式,别在页面里查数据库。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table> <tr><th>取件码</th><th>公司</th><th>货架</th><th>状态</th></tr> <c:forEach items="${expressList}" var="e"> <tr> <td>${e.pickupCode}</td> <td>${e.company}</td> <td>${e.shelfNo}</td> <td>${e.status == 0 ? '待取' : '已取'}</td> </tr> </c:forEach> </table>逻辑说明:${expressList}来自request.setAttribute,JSTL 的forEach在服务端渲染,浏览器拿到的是纯 HTML。参数说明:e.status == 0和数据库注释一致;如果实体字段是pickupCode,JSP 就用驼峰,别写成pickup_code。JSP 放在WEB-INF/views下,直接访问会 404,这正是 MVC 想要的效果。
4. Filter 与 MVC 分层:把权限、编码、登录校验拆成可复用的横切逻辑
4.1 EncodingFilter:中文乱码先在这里堵住
编码 Filter 要放在最前面,负责request.setCharacterEncoding和response.setCharacterEncoding。POST 表单中文乱码,十有八九是这步没做或者 Filter 顺序排在 AuthFilter 后面。GET 请求在 Tomcat 8 以后默认 UTF-8,老版本要在server.xml的 Connector 上加URIEncoding="UTF-8"。下面这个 Filter 支持从web.xml读初始化参数,默认 UTF-8:
package com.express.filter; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class EncodingFilter implements Filter { private String encoding = "UTF-8"; @Override public void init(FilterConfig cfg) { String value = cfg.getInitParameter("encoding"); if (value != null && !value.trim().isEmpty()) { encoding = value.trim(); } } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; req.setCharacterEncoding(encoding); resp.setCharacterEncoding(encoding); resp.setContentType("text/html;charset=" + encoding); chain.doFilter(req, resp); } }逻辑说明:setCharacterEncoding只影响请求体,对 GET 的 query string 无效,所以老 Tomcat 还要改 Connector。setContentType管响应,JSP 里再写pageEncoding是第二道保险。参数说明:encoding默认 UTF-8,web.xml里可以覆盖;不要在这里判断 URL 放行,编码 Filter 应该对所有动态请求生效。如果项目里有文件上传,setCharacterEncoding要在解析 part 之前调用,否则文件名乱码。
4.2 AuthFilter:登录校验和角色校验分开写
登录校验 Filter 只拦需要登录的路径,比如/admin/*和/pickup/*,登录页、静态资源要放行。取 Session 用getSession(false),没有就重定向到登录页,不要新建空会话。管理员路径还要判断角色,普通用户访问/admin/*返回 403。下面这段逻辑把路径判断和角色判断放在一个 Filter 里,够课程设计用:
package com.express.filter; import com.express.model.User; import javax.servlet.*; import javax.servlet.http.*; import java.io.IOException; public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String path = req.getRequestURI().substring(req.getContextPath().length()); if (path.startsWith("/login") || path.startsWith("/static/")) { chain.doFilter(req, resp); return; } HttpSession session = req.getSession(false); User user = session == null ? null : (User) session.getAttribute("loginUser"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login"); return; } if (path.startsWith("/admin/") && user.getRole() != 1) { resp.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问后台"); return; } chain.doFilter(req, resp); } }逻辑说明:getRequestURI()包含 context path,先减掉再判断,部署到/express也不会误判。getSession(false)不创建新会话,匿名请求直接踢走。参数说明:/static/放行 CSS、JS、图片,如果静态资源放在/admin/static/下,要改成对应前缀;sendError(403)比重定向更直接,普通用户看到的是禁止访问,不会被绕回登录页。角色判断只拦/admin/,取件页普通用户也能进。
4.3 MVC 包结构:Controller 不写 SQL,DAO 不碰 Session
MVC 不是三个文件夹摆着看,边界要落到调用关系上。Controller 接参数、调 Service、设 attribute、做跳转;Service 管事务和业务规则;DAO 只做 SQL 和结果集映射;Model 是数据载体;View 只做展示。下面这张表是项目里最常见的职责划分,改代码时照着放,别把Connection传到 JSP 里:
| 层 | 典型类 | 该做的事 | 不该做的事 |
|---|---|---|---|
| Controller | LoginServlet、PickupServlet | 取参数、调 Service、转发/重定向 | 写 SQL、开事务 |
| Service | ExpressService | 事务边界、取件码生成、业务校验 | 碰HttpServletRequest |
| DAO | ExpressDao、UserDao | PreparedStatement、结果集映射 | 控制跳转、读 Session |
| Model | User、Express | 字段、getter/setter | 依赖 Servlet API |
| View | WEB-INF/views/*.jsp | EL/JSTL 展示 | 写 JDBC、写 Java 脚本段 |
这张表不是摆设。取件事务放在 Service,Controller 只调pickup(code, userId),DAO 只提供selectForUpdate和updateStatus,这样第 6 章用 curl 重复请求时,事务边界仍然在 Service,不会因为换了入口就失效。Session 只在 Controller 和 Filter 里出现,Service 不依赖HttpSession,单元测试才好写。
4.4 Filter 注册与顺序:web.xml 比注解更可控
@WebFilter写起来快,但顺序不可控,多个 Filter 同时用注解时,容器按类名或扫描顺序排,编码 Filter 可能排到 AuthFilter 后面。我的习惯是统一在web.xml里注册,encodingFilter放最前,authFilter放后面,url-pattern分开配。下面这段配置把编码和登录校验串起来:
<filter> <filter-name>encodingFilter</filter-name> <filter-class>com.express.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>authFilter</filter-name> <filter-class>com.express.filter.AuthFilter</filter-class> </filter> <filter-mapping> <filter-name>authFilter</filter-name> <url-pattern>/admin/*</url-pattern> </filter-mapping> <filter-mapping> <filter-name>authFilter</filter-name> <url-pattern>/pickup/*</url-pattern> </filter-mapping>逻辑说明:web.xml里<filter-mapping>的先后顺序就是执行顺序,encodingFilter先执行,请求体编码先设好,后面的 AuthFilter 和 Servlet 读中文参数才不会乱。参数说明:url-pattern用/admin/*和/pickup/*,不要图省事写/*再在代码里判断,登录页和静态资源会被误拦。如果项目里已经用了@WebFilter,不要再在web.xml配同一个类,否则同一个请求会过两次 Filter,Session 判断和重定向可能重复执行。
5. 避坑与排查:五个把课程设计拖到凌晨的常见问题
5.1 中文变问号:编码 Filter 顺序和数据库 URL 要一起看
现象:后台入库中文公司名变成???,JSP 页面标题正常,但数据库里是乱码。原因通常有两个:EncodingFilter 排在 AuthFilter 后面,登录失败 forward 时编码还没设;或者 JDBC URL 少了characterEncoding=utf8。解决:先把 EncodingFilter 的 mapping 挪到最前,再检查 URL 和表字符集,utf8mb4的表不要用latin1连接。GET 请求中文乱码还要看 Tomcat Connector 的URIEncoding,老版本默认 ISO-8859-1。改完重启,别只热部署。
5.2 登录页 CSS 丢失:AuthFilter 把静态资源也拦了
现象:登录页排版全乱,F12 看到app.css返回 302 到/login。原因:AuthFilter 的url-pattern写成/*,静态资源也被当受保护路径。解决:把静态资源统一放/static/,Filter 里path.startsWith("/static/")放行;或者 mapping 只配/admin/*和/pickup/*,登录页和静态资源根本不在拦截范围。不要用/*再在代码里列白名单,漏一个资源就多一次排查。
5.3 登录成功仍被踢回登录页:Session 取错或路径没带 context
现象:登录表单提交后浏览器跳到后台,立刻又回到登录页,循环。原因:AuthFilter 里用了req.getSession(true)新建空会话,或者重定向没带getContextPath(),部署在/express下跳到了根路径。解决:校验用getSession(false),登录成功写loginUser后再重定向;所有sendRedirect都拼req.getContextPath()。还要确认User放在 Session 里的 key 和 Filter 取的 key 完全一致,大小写都别错。
5.4 404 或 500:@WebServlet 和 web.xml 重复映射
现象:启动日志报The servlet named [xxx] is already mapped,或者访问某个 URL 一直 404。原因:同一个 Servlet 类既写了@WebServlet("/login"),又在web.xml里配了<servlet-mapping>,容器认为重复。解决:二选一。用注解就把web.xml里的 servlet 配置删掉;用web.xml就把类上的@WebServlet去掉。排查时在 Tomcat 日志里搜already mapped和ClassNotFoundException,比在浏览器里猜快。
5.5 取件码重复和连接泄漏:唯一索引与 try-with-resources 都要有
现象一:入库时报Duplicate entry 'SF01011234' for key 'pickup_code'。原因:随机数碰撞,生成时没查重。解决:Service 里循环生成并查重,最多重试 5 次,数据库唯一索引兜底。现象二:跑一段时间后 MySQL 报Too many connections。原因:DAO 里Connection在异常分支没关。解决:所有PreparedStatement、ResultSet用 try-with-resources,Connection在 finally 关;连接池不是课程设计必须,但关闭连接必须做对。取件事务里回滚后也要把autoCommit恢复再关连接。
6. 交付前自检:用一组接口断言和一条核对 SQL 把项目跑成自己的
项目能启动不等于链路通。我一般用 curl 把登录、入库、取件串一遍,再用 SQL 对账。先登录取 Cookie,再带 Cookie 调后台入库,最后用取件码核销。下面这组命令假设 context path 是/express,管理员账号是建表时插入的那个:
# 1. 登录,把 JSESSIONID 存到 cookie.txt curl -i -c cookie.txt -d "username=admin&password=123456" \ http://127.0.0.1:8080/express/login # 2. 管理员入库,注意带 Cookie curl -i -b cookie.txt -d "company=SF&receiverPhone=13800000000&shelfNo=A-01" \ http://127.0.0.1:8080/express/admin/express/add # 3. 用刚生成的取件码核销,取件码从后台列表或数据库里拿 curl -i -b cookie.txt -d "pickupCode=SF01011234" \ http://127.0.0.1:8080/express/pickup逻辑说明:-c把响应里的Set-Cookie写到文件,-b下次请求带上,模拟浏览器会话。参数说明:-d默认Content-Type: application/x-www-form-urlencoded,和表单提交一致;如果接口返回 302,看Location是不是带/express;如果返回登录页 HTML,说明 Cookie 没带上或 Session 过期。取件码不要写死,从数据库查一个待取状态的记录再替换。
SQL 对账看三件事:状态是否从 0 变 1、pickup_time是否非空、日志是否只有一条。下面这条查询把快递和日志左连接,重复取件时日志不应该增加:
SELECT e.pickup_code, e.status, e.pickup_time, l.result, l.pickup_time AS log_time FROM t_express e LEFT JOIN t_pickup_log l ON l.express_id = e.id WHERE e.pickup_code = 'SF01011234';逻辑说明:status=1且pickup_time非空说明核销成功;l.result='SUCCESS'且只有一行说明事务日志正常。参数说明:把取件码换成实际值;如果查出来两行SUCCESS,说明并发控制或重复提交没拦住,回第 3 章检查for update和status=0条件。再把 Session 超时临时改成 60 秒,等一分钟再调取件接口,应该被 AuthFilter 踢回登录页,这样登录态和业务链路都验证到了。
从那以后我每次拿到 JavaWeb 课程设计,都先看javax还是jakarta、Filter 顺序、Session key 这三个点,再谈功能。这套快递 e 栈源码包适合当骨架,先把第 2 章的 DDL 和第 4 章的 Filter 顺序抄下来,再对照目录改包名和数据库密码,比直接双击运行更快定位问题。希望帮到你。
本文还有配套的精品资源,点击获取