首页 / 资讯中心 / 文章详情

JSP+Servlet外卖系统:四角色权限与订单状态机实战

JSP+Servlet外卖系统:四角色权限与订单状态机实战 ★ FEATURED ARTICLE
简介本资源是一套基于JSPServlet开发的完整外卖订餐系统实战项目面向Java Web初学者与课程设计者解决多角色协同业务建模与MVC分层实现的学习痛点。压缩包共93.63MB含源码、MySQL数据库脚本online.sql、系统部署说明、角色功能截图及配套教学视频.wmv其中JSP页面负责动态视图渲染Servlet承担请求调度与业务流转MySQL支撑会员、商家、骑手、管理员四类角色的数据交互与订单闭环。已有660人学习下载项目结构清晰、模块职责分明覆盖用户注册登录、菜单浏览、下单支付、骑手接单配送、商家订单管理及后台系统监控等全流程功能可直接导入Eclipse运行是掌握Java Web基础技术栈与企业级Web开发规范的典型实践范例。1. 这不是又一个“学生课设Demo”JSPServlet外卖系统真能跑通四角色协同闭环你搜“JSPServlet外卖订餐系统”十有八九点开是压缩包里几个.jsp文件、web.xml配得像考古文物、数据库脚本缺字段、登录后跳转404——更别说“会员/骑手/商家/管理员”四类用户状态隔离、订单流转、实时状态同步这些硬骨头。但这个标题里的.zip不是摆设它代表一套可本地部署、角色权限分明、订单状态机完整、且不依赖Spring Boot或Vue等现代框架的纯Java EE落地方案。它解决的不是“怎么写HelloWorld”而是“如何用原生Servlet容器如Tomcat 8.5支撑真实业务中四个角色在同套数据模型下各司其职——会员下单不卡顿、骑手接单有推送感、商家改状态即时可见、管理员查数据不翻页崩溃”。适合两类人一是想补足Java Web底层链路HTTP请求→Servlet生命周期→JSP渲染→Session管理→事务边界的进阶学习者二是需要快速验证业务逻辑、或为老旧政务/校园系统做轻量级扩展的工程师。别被“JSP过时”带偏——很多存量系统至今靠它稳跑十年关键不在技术新旧而在状态怎么管、并发怎么控、页面怎么不裸奔。2. 搭建环境从Tomcat到数据库避开JDK和编码的双重玄学2.1 JDK与Tomcat版本必须咬死为什么JDK 8u202 Tomcat 8.5.99是黄金组合这套系统不是用JDK 17写的。解压.zip后第一眼要看WEB-INF/web.xml里的web-app声明如果看到version3.0或3.1说明它基于Servlet 3.0规范强依赖JDK 8及以上但绝不能用JDK 11的默认模块化机制。我踩过最深的坑是用JDK 17启动Tomcat报java.lang.NoClassDefFoundError: javax/servlet/Servlet——不是缺jar是JDK 17移除了java.se.ee模块。解决方案只有两个①降级JDK装JDK 8u202非最新u301因u202对Tomcat 8.5兼容性最稳②不换JDK就换容器改用Tomcat 9.0.85支持Servlet 4.0但需手动修改web.xml头并重写部分WebServlet注解——成本远高于换JDK。提示Tomcat选8.5.99而非8.5.0因为99版修复了8.5.0里AsyncContext在高并发下单次请求重复触发onComplete()的致命bug而外卖系统里骑手抢单、会员刷新订单列表都依赖异步回调。安装后验证# 确认JDK版本必须输出1.8.0_202 java -version # 启动Tomcat前先设JAVA_HOMEWindows用setLinux用export export JAVA_HOME/path/to/jdk1.8.0_202 export PATH$JAVA_HOME/bin:$PATH2.2 MySQL建库脚本的三个隐藏雷区字符集、时间戳、外键约束解压后的SQL文件通常是db_init.sql或schema.sql常被直接source执行结果登录页面报Unknown column user_type in where clause。原因有三雷区现象原因解决方案字符集错配中文显示乱码、插入时报Incorrect string valueSQL文件用UTF-8-BOM保存MySQL默认latin1创建库时显式指定CREATE DATABASE takeout DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;TIMESTAMP默认值order_status字段插入时报Invalid default value for update_timeMySQL 5.6严格模式禁用CURRENT_TIMESTAMP作多个字段默认值将update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP改为update_time DATETIME DEFAULT NOW() ON UPDATE NOW()外键未启用InnoDBALTER TABLE order_info ADD CONSTRAINT fk_rider_id FOREIGN KEY (rider_id) REFERENCES rider(id)执行失败表引擎是MyISAM不支持外键在建表语句末尾加ENGINEInnoDB例如CREATE TABLE rider (...) ENGINEInnoDB;执行建库后务必用以下命令验证-- 查看库字符集 SHOW CREATE DATABASE takeout; -- 查看所有表引擎 SELECT table_name, engine FROM information_schema.tables WHERE table_schematakeout; -- 查看外键是否生效返回空集说明没建成功 SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMAtakeout AND REFERENCED_TABLE_NAME IS NOT NULL;2.3 项目导入Eclipse/IDEA别让“Dynamic Web Module Version”毁掉整个部署在IDE中右键项目→Properties→Project Facets常见错误是把Dynamic Web Module Version设成4.0——这会导致web.xml里servlet-mapping被忽略所有Servlet 404。必须设为3.1对应Servlet 3.1规范。操作路径EclipseProperties → Project Facets → 勾选Dynamic Web Module → Version选3.1 → ApplyIDEAFile → Project Structure → Modules → 右侧Facets → Web → Version选3.1注意若IDE提示“Cannot change version because web.xml declares version 3.0”说明web.xml头是web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_0.xsd version3.0此时需手动将version3.0改为3.1并更新xsd地址为web-app_3_1.xsd。3. 四角色权限体系不是if-else堆砌而是Filter链Session属性的精准拦截3.1 用户登录态如何穿透四层角色Session.setAttribute(userRole, rider)只是开始系统里每个角色登录后LoginServlet会将用户信息存入Session但仅存role字符串远远不够。真实场景中骑手需看到“待接单”“进行中”订单商家只能改自己店的订单状态管理员要查全量数据——这些差异不能靠JSP里c:if test${sessionScope.userRole admin}硬判断因为① JSP渲染时Session可能已失效② 恶意用户篡改Cookie中的JSESSIONID可伪造身份③ 多个角色共用同一URL如/order/list.jsp后端不校验直接渲染会泄露数据。正确做法是用Filter做前置校验且校验逻辑绑定到具体URL路径。查看src/filter/RoleFilter.java或类似命名核心逻辑如下public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; // 1. 获取当前请求URI如 /rider/order_list.jsp String uri req.getRequestURI(); // 2. 从Session取用户角色注意必须是登录后set的且不能只取字符串 User user (User) req.getSession().getAttribute(currentUser); if (user null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } // 3. 路径白名单静态资源放行 if (uri.endsWith(.css) || uri.endsWith(.js) || uri.endsWith(.png)) { chain.doFilter(request, response); return; } // 4. 角色路由映射关键 if (uri.contains(/rider/) !rider.equals(user.getRole())) { resp.sendError(HttpServletResponse.SC_FORBIDDEN, Access denied: rider only); return; } if (uri.contains(/merchant/) !merchant.equals(user.getRole())) { resp.sendError(HttpServletResponse.SC_FORBIDDEN, Access denied: merchant only); return; } // ... 其他角色校验 chain.doFilter(request, response); }逻辑说明Filter不依赖userRole字符串而是从Session取完整的User对象含id、role、storeId等再结合URI路径做精确拦截。参数说明req.getContextPath()确保重定向路径带项目名如/takeout/login.jsp避免跨应用跳转失败SC_FORBIDDEN比sendRedirect更安全——不暴露内部路径。3.2 商家专属接口如何让一个Servlet同时服务N家店铺而不串数据商家登录后所有订单查询、菜品管理必须限定在store_id范围内。但若在每个DAO方法里都加WHERE store_id ?代码冗余且易漏。标准解法是在Filter中将store_id注入ThreadLocalDAO层自动读取。查看src/filter/StoreFilter.javapublic class StoreFilter implements Filter { private static final ThreadLocalInteger STORE_ID_HOLDER new ThreadLocal(); public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; User user (User) req.getSession().getAttribute(currentUser); // 仅商家才设置store_id if (merchant.equals(user.getRole()) user.getStoreId() ! null) { STORE_ID_HOLDER.set(user.getStoreId()); // 绑定到当前线程 } try { chain.doFilter(request, response); } finally { STORE_ID_HOLDER.remove(); // 必须清理否则线程复用时污染 } } public static Integer getCurrentStoreId() { return STORE_ID_HOLDER.get(); } }在OrderDAO.java中调用public ListOrder findOrdersByStatus(String status) { String sql SELECT * FROM order_info WHERE status ? ; if (StoreFilter.getCurrentStoreId() ! null) { sql AND store_id ?; // 动态拼接 return query(sql, status, StoreFilter.getCurrentStoreId()); } return query(sql, status); }参数说明ThreadLocal保证每个请求线程独享store_id避免Tomcat线程池复用导致的数据错乱finally块中的remove()是血泪经验——漏掉会导致后续请求拿到上一个商家的store_id。3.3 骑手抢单的原子性保障为什么synchronized(this)在分布式环境下彻底失效骑手点击“抢单”按钮后端GrabOrderServlet需完成①查订单状态是否为“待接单”②更新订单rider_id和status③扣减骑手可接单数。若用synchronized(this)锁住Servlet实例在多台Tomcat集群下完全无效——每台机器的Servlet是独立实例。真正解法是数据库行级锁 乐观锁版本号。查看OrderDAO.grabOrder()方法public boolean grabOrder(int orderId, int riderId) { String sql UPDATE order_info SET rider_id ?, status riding, version version 1 WHERE id ? AND status waiting AND version ?; // 先查出当前version Order order findById(orderId); if (order null || !waiting.equals(order.getStatus())) { return false; } // 执行带version校验的UPDATE int updated update(sql, riderId, orderId, order.getVersion()); return updated 1; // 影响行数为1才表示抢成功 }关键点version字段在建表时必须存在INT DEFAULT 0且每次更新都1WHERE条件中version ?确保只有未被其他骑手修改过的订单才能被抢。失败时前端应提示“订单已被抢走”而非报错。4. 订单状态机从“待支付”到“已完成”状态流转不是if-else能兜住的4.1 状态变更的唯一入口为什么所有状态更新必须走OrderService.updateStatus()系统里订单状态有6种waiting(待接单)、riding(配送中)、delivered(已送达)、cancelled(已取消)、paid(已支付)、completed(已完成)。若在RiderServlet里直接UPDATE order_info SET statusriding WHERE id?在MerchantServlet里又UPDATE ... SET statuscompleted很快就会出现状态错乱——比如骑手刚接单商家误点“已完成”订单直接跳过riding态。正确设计是定义状态流转图所有变更走统一方法且校验前置状态合法性。查看OrderService.javapublic boolean updateStatus(int orderId, String targetStatus, String operatorRole) { Order order orderDAO.findById(orderId); if (order null) return false; // 状态机校验不同角色能触发的状态迁移不同 switch (operatorRole) { case rider: if (!waiting.equals(order.getStatus()) !riding.equals(order.getStatus())) { return false; // 骑手只能从waiting→riding或riding→delivered } if (riding.equals(targetStatus) !waiting.equals(order.getStatus())) return false; if (delivered.equals(targetStatus) !riding.equals(order.getStatus())) return false; break; case merchant: if (!riding.equals(order.getStatus()) !delivered.equals(order.getStatus())) { return false; // 商家只能从riding→delivered或delivered→completed } if (completed.equals(targetStatus) !delivered.equals(order.getStatus())) return false; break; case member: if (!waiting.equals(order.getStatus())) return false; // 会员只能取消待接单订单 if (cancelled.equals(targetStatus)) break; return false; } // 执行更新含version乐观锁 return orderDAO.updateStatusWithVersion(orderId, targetStatus, order.getVersion()); }逻辑说明operatorRole由调用方传入如RiderServlet传rider避免角色伪造targetStatus必须是预设枚举值防止SQL注入校验通过后才调用DAO——这是状态机的“守门员”。4.2 页面实时刷新JSP如何感知订单状态变化而不靠F5会员下单后页面显示“等待骑手接单”但骑手一抢单会员页面应立刻变“骑手已接单”。纯JSP无法主动推送常规做法是轮询setInterval但本系统用了更轻量的方案利用Servlet 3.0的AsyncContext做长连接模拟。查看OrderStatusListener.jsp通常在会员订单详情页底部script function checkOrderStatus() { const xhr new XMLHttpRequest(); xhr.open(GET, %request.getContextPath()%/async/status?orderId%request.getParameter(id)%, true); xhr.onreadystatechange function() { if (xhr.readyState 4) { if (xhr.status 200) { const data JSON.parse(xhr.responseText); if (data.status ! %order.getStatus()%) { location.reload(); // 状态变了就整页刷新简单有效 } } } }; xhr.send(); } // 每5秒检查一次 setInterval(checkOrderStatus, 5000); /script后端AsyncStatusServletWebServlet(urlPatterns /async/status, asyncSupported true) public class AsyncStatusServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { AsyncContext asyncCtx req.startAsync(); asyncCtx.setTimeout(30000); // 30秒超时 // 开新线程等待状态变化实际项目中应监听MQ或DB变更通知 new Thread(() - { int orderId Integer.parseInt(req.getParameter(orderId)); String oldStatus getOrderStatus(orderId); long start System.currentTimeMillis(); while (System.currentTimeMillis() - start 30000) { String newStatus getOrderStatus(orderId); if (!newStatus.equals(oldStatus)) { try { resp.setContentType(application/json); resp.getWriter().write({\status\:\ newStatus \}); asyncCtx.complete(); return; } catch (IOException e) { e.printStackTrace(); } } try { Thread.sleep(1000); } catch (InterruptedException e) {} } // 超时返回空 try { resp.getWriter().write({}); asyncCtx.complete(); } catch (IOException e) { e.printStackTrace(); } }).start(); } }注意此方案是简化版长轮询生产环境应替换为WebSocket或Server-Sent EventsSSE。但对教学系统它比meta http-equivrefresh content5更优雅——页面不闪且只在状态变时刷新。5. 避坑指南那些让开发者凌晨三点还在查日志的典型问题5.1 现象登录成功后跳转到首页但Header显示“请登录”Session里user为空原因web.xml中session-config未配置cookie-http-onlytrue/cookie-http-only导致Chrome 80默认阻止第三方Cookie跨域请求如从localhost:8080访问localhost:8081的API时Session丢失。解决在web.xml中添加session-config cookie-http-onlytrue/cookie-http-only cookie-securefalse/cookie-secure !-- 本地开发用false生产环境HTTPS设true -- /session-config5.2 现象商家上传菜品图片后JSP页面显示img srcupload/xxx.jpg但404原因upload/目录不在Web应用根路径下而是在Tomcat的webapps/ROOT/upload/或项目外独立路径JSP无法直接访问。解决① 方案一推荐在web.xml中配置虚拟路径映射servlet-mapping servlet-namedefault/servlet-name url-pattern/upload/*/url-pattern /servlet-mapping并将图片存到$CATALINA_HOME/webapps/ROOT/upload/② 方案二用Servlet动态读取文件流WebServlet(/showImage) public class ImageServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String filename req.getParameter(f); File file new File(D:/upload/ filename); resp.setContentType(image/jpeg); Files.copy(file.toPath(), resp.getOutputStream()); } }JSP中写img srcshowImage?fxxx.jpg。5.3 现象骑手接单后订单状态变为riding但管理后台订单列表仍显示waiting原因MySQL事务隔离级别为REPEATABLE READ默认管理员查询时读到的是事务开始时的快照未看到骑手事务提交后的变更。解决① 在OrderDAO.findOrders()方法开头加connection.setTransactionIsolation(Connection.TRANSACTION_ISOLATION_READ_COMMITTED);② 或在web.xml中配置全局事务级别Tomcat 8.5resource-env-ref resource-env-ref-namejdbc/takeout/resource-env-ref-name resource-env-ref-typejavax.sql.DataSource/resource-env-ref-type /resource-env-ref并在context.xml中设置defaultTransactionIsolationTRANSACTION_READ_COMMITTED。5.4 现象JSP页面中文乱码但数据库和控制台日志都是UTF-8原因JSP文件本身保存为GBK编码而pageEncoding声明为UTF-8导致JSP引擎解析时字节错乱。解决① 用Notepad或VS Code打开所有.jsp文件右下角确认编码为UTF-8 without BOM② 在每个JSP顶部加三重保险% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8% % request.setCharacterEncoding(UTF-8); % !DOCTYPE html html langzh-CN head meta charsetUTF-85.5 现象部署到Tomcat后访问/takeout/login.jsp报404但/takeout/能显示Tomcat欢迎页原因项目名称与WAR包名不一致。例如WAR包名为takeout.war但web.xml中display-name写成了food-order导致Tomcat部署时上下文路径为/food-order而非/takeout。解决① 删除webapps下所有takeout*文件夹② 将WAR包重命名为ROOT.war覆盖默认ROOT应用③ 或在conf/server.xml中手动指定Context path/takeout docBaseD:/projects/takeout reloadabletrue/6. 进阶技巧用JSP Fragment EL表达式重构会员中心页面告别硬编码6.1 为什么“jsp个人信息展示页面”总显得简陋因为没用Fragment分离关注点会员中心页member/profile.jsp常把头像、昵称、余额、收货地址全写在一个JSP里导致① 修改头像上传逻辑要动整个页面② 不同角色会员/骑手/商家的个人信息结构不同却共用一套HTML。解法是用JSP Fragment.jspf拆分可复用区块。创建WEB-INF/jspf/header.jspf% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8% div classuser-header img src${user.avatarUrl ! null ? user.avatarUrl : /images/default-avatar.png} width80 height80 alt头像 h2${user.nickname}/h2 p会员等级span classlevel${user.levelName}/span/p /div在profile.jsp中引入% include file/WEB-INF/jspf/header.jspf % !-- 其他内容 --关键点Fragment里用EL表达式${user.xxx}直接读取request或session中的对象无需jsp:useBean/WEB-INF/下文件不会被直接访问安全性更高。6.2 “jsp图片如何对坐标定位”用CSS Grid替代绝对定位的血泪经验早期JSP页面常用div styleposition:absolute;top:100px;left:200px定位头像结果一换屏幕尺寸就错位。现代解法是用CSS Grid定义会员信息网格图片作为Grid Item自动居中。profile.jsp中style .profile-grid { display: grid; grid-template-columns: 1fr 2fr; gap: 20px; } .avatar-cell { grid-column: 1; grid-row: 1 / -1; display: flex; flex-direction: column; align-items: center; } .info-cell { grid-column: 2; } /style div classprofile-grid div classavatar-cell img src${user.avatarUrl} width120 height120 button onclickuploadAvatar()上传新头像/button /div div classinfo-cell h3基本信息/h3 p手机号${user.phone}/p p注册时间${user.registerTime}/p /div /div效果无论屏幕宽窄头像始终居左信息文本自适应右侧空间grid-row: 1 / -1让头像区域跨所有行避免高度不匹配。6.3 最后一条硬核建议别碰“突破网站会员付费限制”这类黑产思路看到热搜词里有“突破网站会员付费限制”我必须强调这个JSP外卖系统的所有会员功能如“试用超级会员”都是前端展示后端校验双保险。例如MemberService.getVipFeatures()方法会查数据库user.vip_level 0且所有VIP专享接口如优先派单都经过VipFilter拦截。任何试图用浏览器插件修改HTML或绕过JS校验的行为在后端Filter里都会被SC_FORBIDDEN拦截。真正的会员体系价值不在“去广告”而在用vip_level字段驱动业务规则——比如vip_level2的用户订单自动分配给评分4.9的骑手。与其研究怎么破解不如把vip_level字段接入短信营销系统做精准权益发放。我带过三届学生做这个项目最常翻车的不是技术而是心态有人花三天调通登录却用一周纠结“饿了么Element图标怎么在JSP里用”。后来我定了条铁律所有UI组件先用原生HTML/CSS实现功能再考虑美化所有业务逻辑先跑通状态流转再加缓存和监控。这套JSPServlet系统不是古董它是让你看清HTTP本质的黑匣子——当request.getParameter(status)变成数据库里一行真实的statuscompleted那种掌控感是任何框架封装都给不了的后悔药。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站