
简介快递e栈是一套面向JavaWeb初学者的综合性实战项目资源整合Servlet、Filter、Listener、Session、MVC分层等核心知识覆盖从HTML/CSS/JS前端页面到Ajax交互、MySQL数据存储以及Tomcat部署的完整链路。资源共831个文件压缩包仅17.23MB包含52个Java源码、109个class编译文件、36个jar依赖包以及大量png/gif图片素材和html/js/css/bootstrap/layui等前端资源其中png/gif视觉素材占比较高便于还原页面与交互效果另有jsp、xml、properties等配置文件目录结构清晰便于按模块学习或直接导入IDE运行。目前已有886人浏览学习。从压缩包中可获得完整项目源码、页面素材、配置与部署文件既能用于课程设计或毕业设计参考也可帮助理解会话跟踪、过滤器链、监听器及MVC分层在实际业务中的协作方式适合Servlet/JSP阶段的学生巩固实战能力。1. 为什么快递e栈比想象中的更适合做第一个完整JavaWeb项目快递e栈这个项目我第一次跑起来的时候第一反应是「就这」——无非是登录、加个Filter、往Session里塞个对象好像两小时就能讲完。但真正动手之后才发现Servlet、Filter、Session、MVC这四个词背后几乎覆盖了一个JavaWeb从业者入行要踩的所有核心问题请求从哪里进、登录态存在哪里、页面和数据层之间到底隔了几层、为什么同样的代码换个路径就404。项目形态看起来是个快递代收系统本质上却是一台把「请求→逻辑→数据→响应」整条链路完整跑通的训练机而且场景足够具体快递员要入库、用户要取件、管理员要管格口每一件事都能对应到一张表、一个Servlet、一组状态流转。这套资源适合正在学JavaWeb、想找完整案例练手的人也适合那些已经写过几个小Demo、但没跑通过「登录拦截增删改查状态切换」的开发者。快递e栈解决的并不是某个高深问题而是帮你把零散的知识点收拢成一个能部署、能演示、能继续加功能的完整系统——它是我见过的把Servlet、Filter、Session、MVC整合得最规整的练手项目之一。2. 业务建模先行快递e栈的表结构、角色与状态流转2.1 角色与业务流用户、快递员、管理员三条线快递e栈这个系统从标题就能看出来是围绕「快递柜」展开的。它不是一个电商系统也不是一个物流后台而是一个代收代发的中间场景快递员把包裹投递到柜子系统生成取件码用户凭码取件管理员维护柜子和用户数据。想清楚这一点再上手才不会把业务逻辑做歪。核心角色只有三个每个角色对应一套操作界面和一套Servlet用户登录后查快递、取件核心操作是「凭取件码取件」快递员登录后录入快递信息、分配格口、生成取件码核心操作是「入库」管理员维护用户、查看所有快递、管理格口状态核心操作是「改状态、清滞留」。三个角色的业务线交织在同几张表上这是快递e栈最有价值的地方。很多JavaWeb练习项目是「一个表一个CRUD」快递e栈则要求你同时处理「用户表」「快递单表」「柜子表」三张主表而且快递单表要同时关联用户和格口。这比单表增删改查高一个台阶但又不至于复杂到需要框架支撑——用Servlet手写刚好。业务流我一般建议按「快递员入库→用户取件→格口释放」这条主线来走先跑通这个闭环再补管理端。入库包含快递单号和手机号的校验取件包含取件码的比对和格口状态的切换每一步都同时改动快递单表和柜子表这才是有价值的业务闭环。2.2 三张核心表让人、柜、快递单分开建模快递e栈的表结构常见的建法是三张核心表加一张关联账号表。我拆这套资源时最看重的就是它的表关系是否经得起推敲——很多新手写的项目把手机号、取件码、格口号全塞进一张表里短期内跑得通但一到「管理员按柜子查滞留件」就傻眼了。推荐表设计如下CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, phone VARCHAR(11) NOT NULL, role INT DEFAULT 0, -- 0用户 1快递员 2管理员 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE cabinet_cell ( id INT PRIMARY KEY AUTO_INCREMENT, cell_no VARCHAR(10) NOT NULL UNIQUE, cell_type INT DEFAULT 1, -- 1小柜 2中柜 3大柜 status INT DEFAULT 0, -- 0空闲 1占用 2锁定 3维修 current_express_id INT DEFAULT NULL ); CREATE TABLE express_order ( id INT PRIMARY KEY AUTO_INCREMENT, express_no VARCHAR(30) NOT NULL UNIQUE, user_phone VARCHAR(11) NOT NULL, pickup_code VARCHAR(6) NOT NULL, cell_id INT NOT NULL, status INT DEFAULT 0, -- 0已入库 1已取件 2滞留 3异常 in_time DATETIME, out_time DATETIME NULL );说几个拆包时的关键判断。第一express_order表里冗余了cell_id而不是去cabinet_cell里反查原因是取件时要同时知道「东西在哪个柜子」和「柜子当前状态」两次查询不如一次冗余省事而且格口与快递单是一对一关系不会产生数据不一致。第二user表单独存phone字段不依赖快递单里的user_phone这样管理员端要做用户维度的统计时不需要扫描express表——但快递员入库时只输入手机号产生一条新的快递单并不要求该手机号必须是系统用户这里要区分开否则会挡住真实业务。如果你自己改这个项目做主键策略建议保持INT AUTO_INCREMENT不要换成UUID字符串做主键。快递e栈的表量级在练习场景下根本到不了需要分布式ID的程度字符串主键只会让外键关联和索引查询变慢属于给自己找不必要的麻烦。2.3 格口状态机四态切换是整套业务的核心柜子格口的状态流转是这个项目最容易写乱的地方。表面上只是status字段从0变1、从1变0但实际上它牵涉一个最基本的状态机设计问题状态之间哪些转换是合法的哪些是不应该出现的。我一般把格口定义为四态空闲0→ 占用1→ 空闲0中间插入**锁定2和维修3**作为管理员介入的旁路状态。快递员入库时系统只允许在「空闲」的格口上写入快递单并将状态置为占用用户取件时系统先校验取件码和手机号再判定该格口当前必须是「占用」状态否则直接抛出异常提示。锁定和维修状态的格口任何入库操作都不可选。这里很容易翻车的地方在于新手习惯用if-else层层判断写到后面四个状态互相之间都能跳最后数据乱成一锅粥。我拿到这套资源后的第一件事就是把状态流转抽出来用一个独立的CellState常量类管理public class CellState { public static final int FREE 0; public static final int OCCUPIED 1; public static final int LOCKED 2; public static final int MAINTENANCE 3; public static boolean canInsert(int currentState) { return currentState FREE; } public static boolean canPickup(int currentState) { return currentState OCCUPIED; } public static boolean canLock(int currentState) { return currentState FREE || currentState OCCUPIED; } }这个类没有业务逻辑但它的存在把状态判断收拢到了一处。后续写InsertExpressServlet和PickupExpressServlet时不再到处散落魔法数字也不容易出现「把维修中的柜子分配给了新快递」这种事故。这套写法也算是我多年看项目源码时检验一个JavaWeb项目是否规整的第一道关卡状态字段有没有被集中管理还是散落在各个Servlet里各写各的。3. 打通MVC主链路Servlet分发、Filter拦截与Session登录态3.1 一个功能一个Servlet前端控制器的取舍快递e栈采用的是经典的JSPServlet架构没有引入Spring MVC之类的框架。这意味着所有请求分发逻辑都用Servlet处理每个功能点对应一个Servlet类。这里有一个设计取向的问题是学Spring用一个DispatcherServlet统一分发还是老老实实一个功能一个Servlet从资源本身的角度看一个功能一个Servlet更容易理解和调试。UserLoginServlet只管登录ExpressInServlet只管入库PickupServlet只管取件责任边界非常清楚。出问题时你打开Tomcat日志就能立刻定位到是哪个Servlet抛了异常不用先走一遍路由配置去猜谁在处理这个请求。我拆这套资源时特意数了下Servlet的数量大概八九个对应登录、注册、快递列表、入库、取件、格口管理、用户管理等几个核心操作控制在了一个不用过度设计就能维护的规模。核心请求处理代码形如WebServlet(/login) public class UserLoginServlet extends HttpServlet { private UserService userService new UserService(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); User user userService.login(username, password); if (user null) { req.setAttribute(errorMsg, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } HttpSession session req.getSession(); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(30 * 60); resp.sendRedirect(req.getContextPath() /index); } }这里有三处是经常出问题的。第一req.setCharacterEncoding(UTF-8)不是随便写写POST请求下表单中文是否乱码全靠这一行但它只对POST请求的请求体生效GET请求的参数编码由Tomcat的URIEncoding决定这点后面避坑章节会细说。第二sendRedirect用的是req.getContextPath() /index这个相对路径写法经过了encodeURL处理能保证Session ID在重定向时被正确携带——如果你偷懒写resp.sendRedirect(/index)部署在带context-path的路径下就是404。第三session.setMaxInactiveInterval(30 * 60)是30分钟超时单位是秒新手经常在这写30导致Session十秒就过期。3.2 Filter统一拦截白名单之外的请求一律要登录快递e栈的登录控制显然不是靠每个Servlet里重复写if (session.getAttribute(loginUser) null)来完成的那样写出来的代码只适合教学Demo根本撑不起完整的业务系统。这里采取的是一套白名单Filter方案约定哪些路径放行其余路径全部要求登录。用WebFilter(/*)注册的过滤器会在所有Servlet和JSP之前执行核心逻辑如下WebFilter(/*) public class LoginFilter implements Filter { // 白名单不需要登录就能访问的路径 private static final ListString WHITE_LIST Arrays.asList( /login.jsp, /login, /register.jsp, /register, /css, /js, /images, /error.jsp ); 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(); String contextPath request.getContextPath(); String path uri.substring(contextPath.length()); // 白名单或静态资源直接放行 for (String prefix : WHITE_LIST) { if (path.startsWith(prefix)) { chain.doFilter(req, resp); return; } } // 已登录的用户直接放行 HttpSession session request.getSession(false); if (session ! null session.getAttribute(loginUser) ! null) { chain.doFilter(req, resp); return; } // 未登录跳回登录页并记录来源页 String from request.getRequestURI(); response.sendRedirect(request.getContextPath() /login.jsp?from from); } }逻辑说明Filter先从URI中截掉context-path得到真正的请求路径然后先比对白名单凡是登录页、注册页、静态资源直接放行再检查Session中有没有loginUser有说明已登录两者都不满足就重定向到登录页并带上from参数。参数说明request.getSession(false)这个false参数很关键它表示「如果当前没有Session就返回null不要新建」避免给每个未登录的请求都硬塞一个没用的Session。但这里也有个坑就算是不需要登录的请求如果它在JSP里用了session内置对象Tomcat也会自动创建Session所以在Filter里判断登录态时要用false而后面你需要在JSP里展示用户名时可以直接声明% page sessiontrue %这两者有区别别搞混。3.3 Session的存活边界登录态为什么会丢Session是快递e栈登录态的核心载体但很多人并不清楚Session的边界在哪。一个常见现象是用户在快递e栈登录成功后逛了一会儿页面再点取件就跳到登录页了。这个问题通常不是Filter写错了而是Session的存活边界被无意间缩短了。三种最常见的原因。第一setMaxInactiveInterval设置过短默认值在Tomcat的web.xml里是30分钟如果你在代码里覆盖成了小于实际业务操作间隔的值比如10分钟用户花了12分钟浏览快递列表后去取件登录态就没了。第二浏览器Cookie过期JSESSIONID是存在Cookie里的如果你在代码某处新建Cookie覆盖了路径或者设置了极短的Max-AgeSession ID就丢了。第三调用request.getSession()和getSession(true)在每次请求时都创建一个新的Session对象然后把数据存到了新Session里旧数据就找不到了。我建议拿到这套资源后先做一个「是否同一个Session」的排查在LoginFilter里加一行System.out.println(session.getId())然后连续点击几个页面观察JSESSIONID是否变化。如果每次刷新都变基本可以确定是Cookie路径或Tomcat配置问题如果隔一段时间才变说明是超时设置问题。变一次就查一次定位路径就缩短一半。4. 快递入柜与取件格口分配、取件码生成与状态机实现4.1 格口分配策略从顺序分配到断号补位快递员录入快递信息后系统要做的第一件事是选一个空闲格口把包裹放进去。最简单的做法是排序查第一个空闲格口但真实场景里这会导致柜子前部的格口永远满载、后部格口常年闲置。快递e栈合理的做法是优先复用历史使用频率低的格口或者采用「断号补位」策略即优先查找那些曾经被使用过、但当前空闲的格口其次才是从未使用过的新格口。我一般采用如下的分配策略核心是先把「曾经占用过但现在空闲」的格口找出来-- 优先分配曾经使用过、当前空闲的格口 SELECT * FROM cabinet_cell WHERE status 0 AND current_express_id IS NULL ORDER BY last_used_time ASC LIMIT 1;但这里有个sql查询盲区如果你的cabinet_cell表里没有last_used_time字段这个策略就执行不了。所以拿到这套资源时我建议给cabinet_cell加一个last_used_time DATETIME DEFAULT NULL字段在格口释放时更新它。这个字段有个额外的好处管理端做「格口利用率分析」时直接按时间排序就能看出哪些格口长期闲置。分配成功后的Java逻辑是事务性的Transactional public ExpressOrder insertExpress(String expressNo, String userPhone) { // 1. 查询可用格口 CabinetCell cell cellDao.findAvailableCell(); if (cell null) { throw new BusinessException(暂无可用的空闲格口请等待用户取件); } // 2. 生成唯一取件码 String pickupCode generatePickupCode(); // 3. 占用格口 cellDao.lockCell(cell.getId(), 1); // 4. 创建快递单 ExpressOrder order new ExpressOrder(); order.setExpressNo(expressNo); order.setUserPhone(userPhone); order.setPickupCode(pickupCode); order.setCellId(cell.getId()); order.setStatus(0); order.setInTime(new Date()); orderDao.insert(order); // 5. 回填格口当前快递单号 cellDao.updateCurrentExpress(cell.getId(), order.getId()); return order; }逻辑说明先查空闲格口再生成取件码然后同时更新格口状态和插入快递单。这里必须保证第3步和第4步在同一个事务里否则可能出现「格口锁了但快递单创建失败」或者反过来「快递单建了但格口还是空闲」的中间状态。参数说明cellDao.lockCell(cell.getId(), 1)把格口从空闲变成占用第二个参数对应状态机里的占用态cellDao.updateCurrentExpress回填当前快递单ID是为了取件后能快速定位格口。整套逻辑跑完格口和快递单是严格一对一的不会出现一个格口同时塞两单的鬼故事。4.2 取件码生成6位随机码的唯一性校验取件码的生成规则看似随意其实决定了系统是否会被用户抱怨。纯随机6位数字意味着在柜子规模超过一万时碰撞概率会显著上升顺序生成数字又会被老用户摸清规律、不一定取自己的件。我一般建议取件码采用「时间戳低位随机数」的组合方式既能保证不可预测又能通过数据库唯一索引兜底大数碰撞。生成算法如下public String generatePickupCode() { String code null; for (int i 0; i 5; i) { // 最多重试5次 // 取当前毫秒时间戳后4位 3位随机数拼成6位 long millis System.currentTimeMillis(); String timePart String.valueOf(millis % 10000); String randomPart String.format(%03d, new Random().nextInt(1000)); code timePart randomPart; code code.substring(code.length() - 6); // 查重 if (orderDao.findByPickupCode(code) null) { return code; } } return String.valueOf(System.nanoTime()).substring(0, 6); }逻辑说明第一行循环内拼码timePart randomPart是个7位字符串截取后6位保证长度。这里重试上限设为5次因为理论上每次碰撞概率是万分之一量级5次几乎不可能连续碰撞。最后兜底用nanoTime的前6位。但注意兜底方案并不严谨nanoTime在高并发下也可能重复所以快递单表里应对pickup_code建立唯一索引——这是最后一道防线数据库约束才是真正兜底的。参数说明真正生产环境我不会只用6位纯数字通常会在6位数字前面加一位序号字母但快递e栈的场景是单体练习项目6位纯数字足够。如果你把它部署到真实小区场景把Random.nextInt(1000)调整到10000让它变成8位就行表结构不需要变。4.3 取件闭环验证、改状态、释放格口用户取件是整个项目中校验逻辑最密集的地方。取件页面接受两个输入取件码、手机尾号后四位。后端需要同时校验这两个信息防止有人拿别人的取件码来取件。取件核心流程Transactional public PickupResult pickup(String pickupCode, String phoneTail) { ExpressOrder order orderDao.findByPickupCode(pickupCode); if (order null) { return PickupResult.fail(取件码不存在); } if (order.getStatus() ! 0) { return PickupResult.fail(该快递已被取走或状态异常); } // 校验手机尾号 String orderPhoneTail order.getUserPhone().substring(order.getUserPhone().length() - 4); if (!orderPhoneTail.equals(phoneTail)) { return PickupResult.fail(手机尾号不匹配请确认是否为本人快递); } // 更新快递单 orderDao.updateStatus(order.getId(), 1, new Date()); // 释放格口 cellDao.releaseCell(order.getCellId()); return PickupResult.success(order); }逻辑说明先用取件码查出快递单然后比对手机尾号最后同时更新快递单状态和释放格口。cellDao.releaseCell会把格口状态改回空闲、清空current_express_id、更新last_used_time。到这里快递e栈最核心的业务闭环已经跑通入库占用格口、生成取件码取件释放格口、归档快递单。剩下的管理端功能本质上都是在这三张表上做查询和状态管理核心难度已经不大了。5. 常见问题与避坑路径、编码、连接池与Filter白名单5.1 重定向路径写成绝对路径部署后全是404现象本地IDE里跑得好好的部署到Tomcat的webapps下通过http://localhost:8080/express-stack/login访问点登录后跳转到一个404页面地址栏里变成http://localhost:8080/login。原因resp.sendRedirect(/index)这种写法是指相对于服务器根路径。如果项目部署在/express-stack这个context-path下浏览器会去向http://localhost:8080/index发起请求而Tomcat在根路径下并没有这个应用自然返回404。解决所有重定向都改用resp.sendRedirect(req.getContextPath() /index)。同理JSP页面里的超链接、表单action也全部要带上${pageContext.request.contextPath}前缀。这是我见过最频繁的JavaWeb翻车点几乎没有之一。5.2 数据库连接没关闭MySQL连接池被耗尽现象系统运行一段时间后突然所有需要查数据库的操作都报Connection is not available, request timed out after 30000ms重启Tomcat又正常了然后过一段时间再次崩溃。原因某些Servlet或DAO里用了conn DriverManager.getConnection()后没有在finally块里关闭连接。Tomcat默认的连接池连接数是100个每个没有归还的连接一直占着名额跑着跑着连接池就满了。解决在DAO层强制使用try-with-resources或finally块统一关闭Connection、Statement、ResultSet。另一个办法是配置连接池的removeAbandonedTimeout但那是治标不治本真正要治的是代码里每一个连接都被正常release。我在这套资源里看到不少DAO层代码没有统一关闭连接拿到手后第一件事就是检查这处。5.3 Filter白名单漏了静态资源CSS和JS全部被拦截现象登录之后跳转到首页页面看起来纯文本没有任何样式打开浏览器控制台全是404错误请求路径都是/css/style.css。原因LoginFilter拦截了/*你的白名单里只写了/login.jsp和/login没有包含/css、/js、/images等静态资源目录导致这些资源请求也被拦截去重定向到登录页。重定向的结果是一个HTML页面浏览器把它当CSS解析自然失败。解决把静态资源目录加进白名单是最直接的办法。但要注意path.startsWith(/css)这种方式有个漏洞如果有人构造/css/../login.jsp这种路径可能绕过Filter。更稳妥的方案是给静态资源单独配置一个Filter映射或者在WebFilter中排除文件扩展名if (path.endsWith(.css) || path.endsWith(.js) || path.endsWith(.png)) { chain.doFilter(req, resp); return; }5.4 中文乱码请求和响应的编码不一致现象注册新用户时用户名是中文写入数据库后变成???或者数据库里正常页面上显示乱码。原因三层编码不一致。第一层POST请求体默认用ISO-8859-1解码需要setCharacterEncoding(UTF-8)第二层Tomcat 8之前GET请求参数由URIEncoding决定默认也是ISO-8859-1第三层JSP页面本身的pageEncoding和数据库连接串里的characterEncoding可能不一致。解决一套组合拳收工。所有Servlet开头加req.setCharacterEncoding(UTF-8)和resp.setCharacterEncoding(UTF-8)JSP页面统一声明% page contentTypetext/html;charsetUTF-8 languagejava %JDBC连接串加?useUnicodetruecharacterEncodingUTF-8。这是老生常谈但每次拆到完整JavaWeb项目我都会重点核查一遍这三处。5.5 ID配置的查询要单独跑一次Filter里取Cookie会踩到空指针现象用户未登录访问首页Filter正常放行后页面报错偶尔抛出NullPointerException位置在request.getCookies()这行。原因Filter里判断登录态时写了Cookie[] cookies request.getCookies()但用户第一次访问且本地无Cookie时这个方法返回null不是空数组。有些新手直接在cookies上做遍历直接NPE。解决拿到数组后先判空再遍历或者干脆直接用request.getSession(false)判断登录态不要在Filter里手动解析JSESSIONID——那是自己给自己找麻烦Session机制本身已经处理了Cookie的读写。6. 进阶一点把登录态与取件码串成一条可信的整链路整套快递e栈跑通后如果还想往上走一步我的建议是不要马上转去学Spring Boot而是在这个项目里把「登录态验证」和「取件验证」这两个原本独立的环节串起来形成一条带上下文的可信链路。什么意思呢现在的实现里取件只要取件码和手机尾号匹配就行理论上任何人都可以拿别人的取件码去取件——只要他知道了取件码。真实场景里快递员会通过短信把取件码发给收件人如果取件码被其他人看了比如短信被家人看到、取件码写在了快递外包装上那任何人都能取走快件。所以合理的设计应该是取件时不仅校验取件码和手机尾号还要校验当前登录用户且当前登录用户必须与快递单关联。实现方式Transactional public PickupResult pickupWithLoginCheck(String pickupCode, User loginUser) { ExpressOrder order orderDao.findByPickupCode(pickupCode); if (order null) { return PickupResult.fail(取件码不存在); } if (order.getStatus() ! 0) { return PickupResult.fail(该快递已被取走或状态异常); } // 双重校验手机尾号 当前登录用户的手机号 if (!order.getUserPhone().equals(loginUser.getPhone())) { return PickupResult.fail(该快递不属于当前登录用户); } // 更新快递单与释放格口 orderDao.updateStatus(order.getId(), 1, new Date()); cellDao.releaseCell(order.getCellId()); return PickupResult.success(order); }这个版本的pickupWithLoginCheck把验证逻辑从「知道取件码就能取」变成了「取件码登录用户的手机号必须匹配」系统安全性上了一个台阶。从教学角度它也顺便演示了一个重要的架构习惯每个请求的入口方法都应该显式地知道自己当前的操作用户是谁而不是靠内部逻辑去猜。从那以后我带过的每个JavaWeb练习项目不管规模多小都强制走一遍这个流程先检查Session里存的是谁、再校验参数里的是不是这个人、最后才执行状态变更。这套习惯让我的代码在后来转Spring Security、Shiro的时候基本零成本迁移因为你已经习惯了安全校验是业务逻辑的一部分而不是事后补的补丁。快递e栈适合作为这个动作的试验田一是它的业务场景足够具体二是它不依赖框架安全逻辑全都要自己手写一遍写一遍才会懂。希望这个拆解能帮你把项目的每一层都摸透动手时少走弯路。本文还有配套的精品资源点击获取