
简介《JavaWeb综合项目——快递e栈》面向已掌握Java基础、希望进阶JavaWeb实战的学习者以快递业务为场景串联Servlet、Filter、Session与MVC分层开发可用于课程设计、毕业项目参考或综合练习。压缩包共831个文件、约17.23MB主要包含java源码与class文件、html/js/css前端资源、png/gif/jpg图片素材以及jar依赖、jsp、xml和properties配置覆盖页面展示、后端控制、静态资源与工程配置。已有890人学习下载。项目整合HTML、CSS、JavaScript、jQuery、Bootstrap、Layui、layer、Ajax及MySQL、Tomcat等技术读者可对照源码梳理请求流转、会话管理、过滤器拦截与MVC分层结构理解前后端交互和Web模块组织方式也可作为二次开发与功能扩展的基础工程。1. 快递 e 栈一个能跑通、也能改得动的 JavaWeb 练习项目前段时间帮人看一个课程设计用户输入取件码点“确认取件”页面转了两秒回到首页数据库里那条记录还是“待取件”日志干干净净。打开代码发现所有逻辑都堆在一个doPost里Session随手new异常被catch (Exception e) {}直接吃掉——这种“能跑但改不动”的状态是大部分 JavaWeb 学习者卡住的地方。快递 e 栈这个项目正好相反它把快递驿站每天真正会发生的几件事压进一个工程登录鉴权、取件码校验、快递状态流转、分页列表、快递员录入、后台统计。技术栈不花哨Servlet 当控制器、Filter 做统一拦截、Session 存登录态、JSP 渲染视图、JDBC 直连 MySQLMVC 分层落地。适合刚学完 Servlet 想找个完整项目练手的人也适合要交课程设计又不想只交一份 CRUD 的人。2. 工程结构与请求链路一个取件请求从 Servlet 走到 DAO这一章先把工程骨架铺开。拿到一个 JavaWeb 项目我习惯先看三样东西包名怎么分、web.xml或注解怎么配、有没有一层统一的基类。这三样决定了后面是“加个功能改三个文件”还是“加个功能翻十个文件”。快递 e 栈用的是标准的 Maven Web 工程Servlet 3.1 之后注解和web.xml可以混用但配置文件里的东西必须和注解对得上这是后面避坑章要展开的第一个雷。2.1 目录结构包名就是分层分层不是画在 PPT 上的是刻在包名里的。下面这个结构是我按项目实际组织方式整理出来的改哪儿、加哪儿看一眼包名就有数express-e-station/ ├── pom.xml ├── src/main/java/com/example/express/ │ ├── controller/ # Servlet 层收参数、校验、转发 │ │ ├── BaseServlet.java # 反射分发子类只写业务方法 │ │ ├── UserServlet.java # 登录/注册/退出 │ │ ├── ExpressServlet.java # 取件/查询/我的快递 │ │ └── AdminServlet.java # 录入/批量导入/统计 │ ├── service/ # 业务层事务边界在这里 │ │ ├── ExpressService.java │ │ └── UserService.java │ ├── dao/ # 数据访问层只写 SQL │ │ ├── ExpressDao.java │ │ └── UserDao.java │ ├── entity/ # POJO字段和表一一对应 │ ├── filter/ # 编码、登录、权限 │ ├── util/ # JdbcUtils / CodeUtil / Result │ └── listener/ # 启动时初始化连接池 ├── src/main/webapp/ │ ├── WEB-INF/views/ # JSP 放这里禁止浏览器直接访问 │ ├── static/ # css / js / img │ └── login.jsp └── src/main/resources/ ├── db.properties └── druid.propertiesWEB-INF/views这个位置有讲究。JSP 放在WEB-INF下面浏览器就拼不出地址直接访问所有页面都必须经过 Servlet 转发才渲染得出来登录态校验也就绕不过去。新手常见的做法是把 JSP 直接扔在webapp根目录结果就是用户不登录、手敲一个list.jsp也能看到全部快递单——这个漏洞在课程答辩现场被问出来是很难看的。分层之间的调用方向是单向的Controller 只能调 ServiceService 只能调 DaoDao 不认识 Servlet 的任何类。判断一个项目分层有没有做扎实我一般搜一下import javax.servlet出现在哪些包里只要 Service 和 Dao 里出现说明逻辑已经开始往上漏了。2.2 请求链路从/express/take到一条 UPDATE以取件为例完整链路是这样的前端表单 POST 到/express/takeEncodingFilter先把请求编码设成 UTF-8LoginFilter从 Session 里取loginUser判断是否放行放行后进ExpressServletBaseServlet从 URL 最后一段解析出方法名take反射调用take(req, resp)。take里只做两件事校验取件码格式、把参数交给ExpressService。Service 开事务、加行锁、判断状态、更新状态返回结果对象。Controller 拿到结果后决定是转发 JSP 还是输出 JSON。整条链路上每一层只干一件事出问题时可以精确地定位到“是拦截没放行”“是参数没进来”还是“是 SQL 没提交”。这是 MVC 在这个规模的项目里唯一的意义——不是为了显得专业是为了出 bug 的时候能少猜五次。2.3 BaseServlet用反射干掉 if-else 分发一个 Servlet 里塞五六个if (take.equals(action))是这类项目最常见的写法。项目里换成继承一个基类子类只写业务方法分发交给反射public class BaseServlet extends HttpServlet { Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 约定URL 最后一段就是方法名例如 /express/take - take(...) String uri req.getRequestURI(); // /express-e-station/express/take String methodName uri.substring(uri.lastIndexOf(/) 1); try { // 只找签名固定为 (req, resp) 的 public 方法 Method method this.getClass().getMethod( methodName, HttpServletRequest.class, HttpServletResponse.class); method.invoke(this, req, resp); } catch (NoSuchMethodException e) { resp.sendError(HttpServletResponse.SC_NOT_FOUND, 方法不存在: methodName); } catch (InvocationTargetException e) { // 把业务层抛出的真实异常剥出来否则日志里全是反射壳子 Throwable real e.getTargetException(); throw new ServletException(real); } catch (IllegalAccessException e) { throw new ServletException(e); } } }service是HttpServlet里被doGet/doPost共同调用的入口重写它等于同时接管了两种请求方法前端用 GET 查列表、POST 提交表单都不用再写两遍。getMethod的第二个、第三个参数是参数类型必须和子类方法签名严格一致HttpServletRequest写成ServletRequest就反射不到运行时抛NoSuchMethodException。InvocationTargetException这个包装必须剥掉不剥的话堆栈第一行永远是method.invoke真正的SQLException藏在 cause 里排查时会多绕一圈。子类写起来就干净了public class ExpressServlet extends BaseServlet { private final ExpressService expressService new ExpressService(); // 对应 POST /express/take public void take(HttpServletRequest req, HttpServletResponse resp) throws IOException { String code req.getParameter(code); User loginUser (User) req.getSession().getAttribute(loginUser); Result result expressService.take(code, loginUser.getId()); writeJson(resp, result); } }提示反射分发要求方法名在类内唯一且为 public子类里别写重载的同名方法否则getMethod只认签名完全匹配的那一个。2.4 数据层JdbcUtils 加一层薄模板Dao 层不引 ORM 也可以写得很干净关键是连接获取和释放必须收口到一个工具类里不能每个方法自己DriverManager.getConnection。项目里JdbcUtils从druid.properties读连接池配置对外只暴露三个方法public final class JdbcUtils { private static DataSource DS; static { try (InputStream in JdbcUtils.class.getClassLoader() .getResourceAsStream(druid.properties)) { Properties props new Properties(); props.load(in); DS DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(连接池初始化失败: e.getMessage()); } } public static Connection getConnection() throws SQLException { return DS.getConnection(); // 从池里借不是新建 } public static void rollback(Connection conn) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) { } } } public static void close(Connection conn) { if (conn ! null) { try { conn.close(); } catch (SQLException ignored) { } // 归还给池 } } }参数上唯一要盯的是druid.properties里的maxActive。课程演示环境并发很低设 10 就够设成 200 反而容易把 MySQL 的max_connections打满。initialSize建议 2 到 5启动时预建几条连接第一个请求不会卡在建连上。Dao 方法统一用 try-with-resources 管PreparedStatement和ResultSet连接由 Service 传进来或由 Dao 自己借自己还规则只能选一种混着写迟早出现“连接借了没还”。public ListExpress pageByStatus(int status, int page, int size) throws SQLException { String sql SELECT id, code, phone, status, shelf, in_time FROM express WHERE status ? ORDER BY in_time DESC LIMIT ?, ?; ListExpress list new ArrayList(); try (Connection conn JdbcUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, status); ps.setInt(2, (page - 1) * size); // LIMIT 偏移从 0 开始别直接传 page ps.setInt(3, size); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); } } } return list; }LIMIT ?, ?的第一个问号是偏移量不是页码这是分页翻车的经典位置第 2 页传page2会跳过 2 条而不是 10 条现象是“第二页和第一页数据重叠”。2.5 表结构三张表撑起 90% 的业务快递单表是核心用户表和驿站货架表是配角。字段设计上我建议一开始就把状态和索引定好后面加功能不用改表CREATE TABLE express ( id BIGINT NOT NULL AUTO_INCREMENT, code CHAR(6) NOT NULL COMMENT 取件码6 位数字, phone VARCHAR(11) NOT NULL COMMENT 收件人手机号, user_id BIGINT DEFAULT NULL COMMENT 关联用户散客为 NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待取 1已取 2退回, shelf VARCHAR(16) DEFAULT NULL COMMENT 货架位如 A-03-12, in_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, out_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_code (code), KEY idx_phone_status (phone, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_code这个唯一索引不是装饰它是取件码生成逻辑的兜底——并发插入两条相同取件码时数据库会直接拒绝程序捕获DuplicateKeyException重试即可比在应用层做“先查再插”靠谱得多。idx_phone_status是给“我的快递”列表用的用户端查询几乎都是“某手机号 某状态”联合索引顺序不能反。3. 登录态与拦截Filter 和 Session 怎么配合才不打架这套项目里最容易出玄学问题的地方就是登录态。现象往往是“我明明登录了点两下又跳回登录页”或者“退出登录后按浏览器后退还能看到订单”。根子不在 Session 本身在于 Session 里存了什么、Filter 什么时候读、页面有没有被缓存。这一章把这几个点拆开讲。3.1 Session 里到底该存什么我的习惯是只存一个精简对象不存整个User实体。实体里往往带密码字段、带手机号一旦某天 JSP 里手滑写了${loginUser.password}就是事故。public class LoginUser implements Serializable { private static final long serialVersionUID 1L; private Long id; private String nickname; private Integer role; // 1 普通用户 2 快递员 3 管理员 // 不含 password、不含手机号全量 }登录成功后LoginUser loginUser userService.login(phone, rawPassword); if (loginUser ! null) { HttpSession old req.getSession(false); if (old ! null) { old.invalidate(); // 防会话固定登录后换一个新 session } req.getSession(true).setAttribute(loginUser, loginUser); req.getSession().setMaxInactiveInterval(30 * 60); // 30 分钟 }invalidate()再新建这一步叫会话固定防护攻击者预先塞给你一个 sessionId你登录后继续用同一个 id对方就顺理成章拿到了登录态。教学项目里很少写这一步但它只多两行代码补上不亏。setMaxInactiveInterval单位是秒不设的话走容器默认Tomcat 默认 30 分钟web.xml里改session-config也能全局覆盖两处都写以代码为准。3.2 登录校验 Filter 的完整写法Filter 要做三件事放行白名单、识别未登录、区分页面请求和 Ajax 请求。第三种最容易漏漏了的后果是前端 ajax 拿到一整页登录页 HTMLJSON.parse直接抛错控制台一片红。WebFilter(urlPatterns {/express/*, /admin/*, /user/*}) public class LoginFilter implements Filter { // 白名单登录、注册、静态资源 private static final String[] WHITE { /user/login, /user/register, /login.jsp, /static/ }; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; // 去掉上下文路径得到 /express/take 这种干净路径 String path req.getRequestURI().substring(req.getContextPath().length()); for (String w : WHITE) { if (path.startsWith(w)) { chain.doFilter(request, response); return; } } // 关键传 false没有会话就不要新建一个空的 HttpSession session req.getSession(false); Object loginUser session null ? null : session.getAttribute(loginUser); if (loginUser null) { if (XMLHttpRequest.equals(req.getHeader(X-Requested-With))) { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write({\code\:401,\msg\:\登录已失效请重新登录\}); } else { resp.sendRedirect(req.getContextPath() /login.jsp); } return; // 这里必须 return不能继续 chain } chain.doFilter(request, response); // 放行 } }getSession(false)和getSession()的区别在于前者在无会话时返回null后者会顺手建一个。之前一次排查中发现访问日志里每个未登录请求都会建一个空 Session内存里堆了几万个就是这一处写成了无参版本。sendRedirect之后必须return否则请求会继续传给下一个 Filter 或 Servlet运气不好就在重定向响应里又写了一段 HTML浏览器报“响应头已提交”。3.3 静态资源和白名单别让 css 也被拦urlPatterns写成/*是最省事也最容易翻车的写法。css、js、图片全部经过 Filter白名单没覆盖到就一律被重定向成登录页现象是“登录页样式全丢了Network 里 css 返回的是 HTML”。两种解法一是像上面那样把/static/放进白名单精确匹配前缀二是把 Filter 映射收窄成/express/*、/admin/*、/user/*这种业务前缀静态资源根本不在射程内。我更倾向第二种配置少、漏洞面小。白名单的匹配方式也要注意path.startsWith(/user/)会把/user/login之外的所有/user/*也放过去想精确匹配就得用equals。项目里登录、注册这类接口老老实实写全路径比较稳。3.4 角色权限三档身份怎么判登录只解决“你是谁”不解决“你能不能干这个”。管理员能看全部快递单普通用户只能看自己的这层判断放在 Filter 里做最省事也可以放在 Service 里做。我一般两边都放Filter 拦掉明显越权的入口Service 里再校验一次数据归属防止有人绕过页面直接拼参数。/** 角色校验用在 /admin/* 上 */ public class RoleFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; LoginUser user (LoginUser) req.getSession().getAttribute(loginUser); // LoginFilter 先执行这里理论上不会为空兜一层保险 if (user null || user.getRole() null || user.getRole() 3) { ((HttpServletResponse) response).sendError(403, 无权限访问); return; } chain.doFilter(request, response); } }两个 Filter 的执行顺序由web.xml里filter-mapping的声明顺序决定用注解的话顺序不确定。如果项目里同时有编码 Filter 和登录 Filter编码必须在前否则登录 Filter 里读中文参数会读到乱码。这类顺序问题用注解配很容易踩稳妥的做法是把顺序敏感的两个 Filter 写进web.xml其余用注解。3.5 退出登录别只删一个属性退出登录有人写成session.removeAttribute(loginUser)看着能用实际上 Session 还在其他属性还在服务端资源也没释放。正确做法是invalidate()直接销毁public void logout(HttpServletRequest req, HttpServletResponse resp) throws IOException { HttpSession session req.getSession(false); if (session ! null) { session.invalidate(); } resp.sendRedirect(req.getContextPath() /login.jsp); }还有一个细节退出后按浏览器后退键页面可能还是从缓存里读出来的旧内容。给需要登录的页面加一句响应头就能解决放在LoginFilter的放行分支里统一设resp.setHeader(Cache-Control, no-store, no-cache, must-revalidate); resp.setHeader(Pragma, no-cache);4. 核心业务拆解取件码生成、行锁取件与分页列表前面两章搭的是骨架这一章讲真正有业务味道的地方。驿站项目和普通 CRUD 的区别就在取件这一步它天然带并发两个人同时输同一个取件码只能成功一次它还带状态约束已取件的单子不能被重复取。这两条如果只在页面上下工夫数据库里迟早出现两条status1的异常数据。4.1 取件码生成SecureRandom 加唯一索引兜底取件码一般是 6 位数字量级一百万。小项目用Random也够但我习惯上SecureRandom成本几乎为零还能避免同一毫秒内连续调用取到同一序列。public class CodeUtil { private static final SecureRandom RANDOM new SecureRandom(); /** * 生成 6 位数字取件码冲突则重试 * param dao 用来查重 * return 未被占用的取件码 */ public static String nextCode(ExpressDao dao) throws SQLException { for (int i 0; i 5; i) { String code String.format(%06d, RANDOM.nextInt(1_000_000)); if (!dao.existsByCode(code)) { return code; } } // 连试 5 次都撞说明号段接近饱和需要人工介入或扩位 throw new IllegalStateException(取件码分配失败请检查号段占用率); } }String.format(%06d, ...)保证7会被补成000007不然前端输入框限 6 位用户永远输不进去。存在性检查只是降低冲突概率真正的兜底是表上的uk_code唯一索引并发场景下两个线程可能同时查到“不存在”然后同时插入后插入的那个被数据库拒绝Service 捕获后重试一次就行。public Result save(Express express) { for (int i 0; i 3; i) { try { express.setCode(CodeUtil.nextCode(expressDao)); expressDao.insert(express); return Result.ok(express.getCode()); } catch (DuplicateKeyException e) { // 并发撞码重新生成再试 log.warn(取件码冲突重试第 {} 次, i 1); } catch (SQLException e) { return Result.fail(入库失败 e.getMessage()); } } return Result.fail(取件码分配失败请稍后再试); }4.2 取件动作行锁加状态判断防重复取件取件的核心是一段必须原子的“读状态—改状态”。只写UPDATE express SET status 1 WHERE code ?是危险的它不检查当前状态两个人同时提交两条 UPDATE 都成功业务上等于同一件快递被取走两次。public Result take(String code, Long userId) { if (code null || !code.matches(\\d{6})) { return Result.fail(取件码格式不正确); } Connection conn null; try { conn JdbcUtils.getConnection(); conn.setAutoCommit(false); // 手动事务 // SELECT ... FOR UPDATE 锁住这一行另一个事务会阻塞等待 Express e expressDao.selectByCodeForUpdate(conn, code); if (e null) { conn.rollback(); return Result.fail(取件码不存在); } if (e.getStatus() ! 0) { conn.rollback(); return Result.fail(e.getStatus() 1 ? 该件已被取走 : 该件已退回); } if (userId ! null e.getUserId() ! null !userId.equals(e.getUserId())) { conn.rollback(); return Result.fail(取件人与收件人不一致); } expressDao.updateStatus(conn, e.getId(), 1); // 0 - 1 conn.commit(); return Result.ok(取件成功货架位 e.getShelf()); } catch (SQLException ex) { JdbcUtils.rollback(conn); return Result.fail(系统繁忙请重试); } finally { JdbcUtils.close(conn); // 归还连接池 } }selectByCodeForUpdate里的 SQL 是SELECT ... FROM express WHERE code ? FOR UPDATE行锁生效的前提是code上有索引没索引会退化成表锁并发一高整个表都堵住。conn.setAutoCommit(false)之后所有分支都必须显式commit或rollback包括return之前。少写一个rollback的后果是连接带着未提交事务被归还给池下一个借到它的请求可能读到脏数据或者连接一直挂着锁不释放——这就是连接池耗尽类问题的常见起因之一。4.3 状态机三个状态值定死不要随便加状态字段用数字没问题但取值必须在文档和代码里锁死不然半年后没人记得2是退回还是异常。项目里就三个值值含义允许的下一步触发方0待取件1 已取件 / 2 已退回用户取件 / 管理员退回1已取件无终态用户2已退回无终态管理员out_time只在0 - 1或0 - 2时写入用NOW()而不是应用层取时间避免服务器和数据库时区不一致导致时间对不上。这个字段看起来不重要做“当日取件量”统计的时候就是唯一的依据。4.4 分页列表把总数和当页数据一起返回列表页最忌讳“只查当页、前端算总数”。正确做法是一次返回total和list前端按页渲染。Service 里两条 SQL一条COUNT(*)一条带LIMIT的查询public PageResultExpress page(int status, int page, int size) throws SQLException { if (page 1) page 1; if (size 1 || size 100) size 10; // 卡住 size 上限防止被拖库 int total expressDao.countByStatus(status); ListExpress list total 0 ? Collections.emptyList() : expressDao.pageByStatus(status, page, size); PageResultExpress result new PageResult(); result.setTotal(total); result.setList(list); result.setPage(page); result.setPages((total size - 1) / size); // 向上取整别用 total/size return result; }size上限这一行别省。有人手动改 URL 传size100000一条 SQL 把整张表捞进内存页面直接 502。total 0时直接返回空列表省一次无意义的数据库往返。对应的 JSP 渲染部分用 JSTL 遍历不要在里面写 Java 小脚本% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table classtable c:forEach items${page.list} vare tr td${e.code}/td td${e.phone}/td td${e.shelf}/td td c:choose c:when test${e.status 0}待取件/c:when c:when test${e.status 1}已取件/c:when c:otherwise已退回/c:otherwise /c:choose /td /tr /c:forEach /table${e.status 0}这种 EL 表达式依赖 JSP 2.0 以上和 JSTL 依赖pom.xml里必须引jstlweb.xml的版本不能写成 2.3否则 EL 不解析页面上直接打印${e.status}原文。这个坑在避坑章还会再提一次。5. 排查避坑五个让项目「能跑但不对劲」的坑下面这几条都是我在这类项目上真实遇到过的症状看起来五花八门原因都很朴素。每条按现象、原因、解决三段说遇到同类问题可以直接对照。5.1 中文乱码GET 和 POST 是两套解法现象表单提交的收件人姓名存进数据库变成????但同一个页面用 Chrome 开发者工具看请求体又是正常的汉字。原因POST 请求体的编码由request.setCharacterEncoding(UTF-8)控制必须在第一次读取参数之前设置GET 请求的参数在 URL 上编码由server.xml里 Connector 的URIEncoding决定Servlet 里设 request 编码对它无效。很多项目只在 Servlet 里设了一句POST 好了 GET 还是乱的。解决写一个EncodingFilter放在 Filter 链最前面同时覆盖请求和响应Tomcat 8 以后默认URIEncoding就是 UTF-8但 7 及更早版本要手工在 Connector 上加URIEncodingUTF-8。数据库连接串也别漏jdbc:mysql://...?useUnicodetruecharacterEncodingutf8表统一utf8mb4。WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); // 只对 POST 请求体生效 resp.setCharacterEncoding(UTF-8); chain.doFilter(req, resp); } }5.2 连接池耗尽连接借了没还现象本地跑得好好的演示时点几下就卡住日志里出现wait millis 60000, active 10, maxActive 10之后所有请求全部超时。原因某个 Dao 方法在异常分支里直接returnfinally里又只关了ResultSetConnection没还。也可能是setAutoCommit(false)之后没commit连接一直挂着一个未结束事务池子认为它还在使用。解决所有 Dao 方法统一用 try-with-resources 管Connection、PreparedStatement、ResultSet三个资源Service 里手动事务的连接必须在finally里close。验证方式是压测 200 次请求后看 Druid 监控页的active数是否回到 0回不去就说明有泄漏。5.3 Filter 被执行了两次现象在 Filter 里打日志发现同一个请求doFilter打印了两遍登录校验看着也没错但重定向逻辑触发了两次。原因WebFilter注解和web.xml里同时配了同一个 Filter 类容器把它当成两个 Filter 注册链上跑两遍。项目从教程里复制web.xml配置、又加了注解最容易撞上。解决二选一。用注解就把web.xml里的filter和filter-mapping删干净用web.xml就把注解去掉。需要控制执行顺序的场景比如编码 Filter 必须最先建议统一走web.xml注解的加载顺序不受控。5.4 Session 重启即失效登录态莫名丢失现象改完代码重启 Tomcat之前登录的账号全部变回未登录前端没有任何提示点按钮直接跳登录页。原因Tomcat 默认会在关闭时把 Session 序列化到work目录重启后恢复但前提是 Session 里存的对象实现了Serializable。LoginUser没实现序列化接口反序列化失败Session 直接被丢弃。另外项目重新部署redeploy时整个work目录被清空Session 一样没了。解决Session 里存的对象一律实现Serializable并显式声明serialVersionUID。真要解决“重启不掉线”得把登录态挪到外部存储教学项目里没必要接受重启掉线更省事前端加个统一的 401 拦截跳登录即可。5.5 JSP 直接返回源码或者报 404现象访问/admin/list.jsp浏览器下载了一个.jsp文件或者页面上的${page.total}原样打印出来。原因访问路径少了/WEB-INF前缀容器把这个请求当成静态资源处理EL 不解析多半是web.xml声明成了 Servlet 2.3那个版本默认关闭 EL。解决JSP 放在WEB-INF/views下只能通过req.getRequestDispatcher(/WEB-INF/views/list.jsp).forward(req, resp)访问web.xml头部声明升到 3.1pom.xml里补上jstl和taglibs-standard-impl依赖。转发用forward不用redirectredirect会让浏览器重新发一次请求request 域里的数据全丢列表页会变成空白。6. 二次开发给快递 e 栈加一个自提柜模块的改造路径项目跑通之后真正有意思的是加东西。自提柜是个合适的练手需求它和快递单是一对多又带一点状态约束柜门被占就不能再放改造路径能完整走一遍 MVC 的五层。我把当时加这个模块的顺序记一下照着做基本不会漏。先建表柜门和快递单用外键松耦合不建物理外键用字段关联方便后期分库CREATE TABLE locker ( id BIGINT NOT NULL AUTO_INCREMENT, station_id BIGINT NOT NULL COMMENT 所属驿站, door_no VARCHAR(8) NOT NULL COMMENT 柜门编号如 A-01, size TINYINT NOT NULL DEFAULT 1 COMMENT 1小 2中 3大, express_id BIGINT DEFAULT NULL COMMENT 占用它的快递单NULL 表示空闲, PRIMARY KEY (id), UNIQUE KEY uk_station_door (station_id, door_no), KEY idx_express (express_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;接着按依赖方向从下往上写LockerDao提供findFreeBySize、bind、unbind三个方法LockerService里做“找空柜—绑定—失败重试”的逻辑事务边界放在这里LockerServlet extends BaseServlet暴露/locker/list和/locker/bind最后在LoginFilter的urlPatterns里加上/locker/*在角色判断里限定只有快递员和管理员能调绑定接口。有个细节值得单独说绑定柜门本质是一次“抢占”必须防并发。// 用条件更新代替“先查后改”一条 SQL 完成抢占 String sql UPDATE locker SET express_id ? WHERE id ? AND express_id IS NULL; int rows ps.executeUpdate(); if (rows 0) { return Result.fail(该柜门已被占用请刷新后重试); }WHERE express_id IS NULL是这里的灵魂两个人同时抢同一个空柜只有一行会更新成功另一行rows为 0直接返回失败让用户刷新。这比加锁轻也不依赖事务隔离级别属于我加新功能时的默认写法。验证方式我一般分三步走先用curl直接打接口确认状态码和返回体正确再用两个终端并发跑同一个绑定请求看是不是只有一个 200最后在页面上手动点一遍确认柜门状态刷新正常。第三步最容易被跳过但很多问题是“接口对、页面没刷新”导致的。改造点需要改的文件容易漏的地方新增表locker.sql忘了给station_id door_no加唯一键Dao 层LockerDao.java抢占用条件更新别再写先查后改Service 层LockerService.java事务边界成功和失败都要显式提交Servlet 层LockerServlet.java继承BaseServlet方法名和 URL 尾段对上拦截配置LoginFilter新路径没加进urlPatterns接口裸奔页面WEB-INF/views/locker/list.jsp放WEB-INF下别放根目录这套改造路径其实可以套用到任何新模块上建表、Dao、Service、Servlet、Filter、JSP六步走完一个功能就闭环了。我现在拿到一个不熟的 JavaWeb 项目改功能前都会先按这个顺序把调用链在纸上画一遍画不通的地方就是后面要踩的坑。希望帮到你。本文还有配套的精品资源点击获取