简介:本资源是一套基于JSP+Servlet开发的完整外卖订餐系统实战项目,面向Java Web初学者与课程设计学生,解决多角色协同业务建模与MVC架构落地实践问题。压缩包为ZIP格式,大小93.63MB,包含源代码、MySQL数据库脚本(online.sql)、系统部署说明、角色功能截图及配套视频教程(.wmv),覆盖会员下单、商家接单、骑手配送、管理员监控等全流程模块。已有660人学习下载,项目采用Eclipse开发环境,以JSP实现动态页面展示,Servlet处理请求逻辑,MySQL支撑用户、订单、商品等核心数据管理,结构清晰、职责分明,便于理解MVC分层思想与Web应用开发规范。读者可直接导入运行,快速掌握Java Web基础技术栈集成、多角色权限划分及真实业务场景下的数据库设计与交互逻辑。
1. 这不是又一个“学生作业式”外卖系统:它用 JSP+Servlet 跑通了四角色协同闭环,真能接单、派单、结算、查轨迹
你搜“JSP 外卖系统”,十有八九点开是单页静态菜单+模拟下单的 demo,连登录都只校验用户名密码,更别说骑手接单后状态怎么同步、商家确认出餐后怎么触发配送、会员积分怎么实时扣减——这些不是锦上添花,而是业务流卡点。这个.zip包里的系统,从数据库表设计到 Servlet 分发逻辑,完整覆盖「会员下单 → 商家接单/出餐 → 骑手接单/送达 → 管理员监控」全链路,所有角色共用同一套 Session 管理和权限路由,不是靠 if-else 切页面,而是用role_id+filter动态拦截请求路径。它不依赖 Spring Boot 或 Vue 前端框架,纯 JSP 渲染 + Servlet 控制 + JDBC 直连 MySQL,部署在 Tomcat 8.5+ 上就能跑,适合教学演示、课程设计答辩、或作为中小本地餐饮 SaaS 的最小可行原型(MVP)。如果你正被“如何让 Servlet 真正管住四类用户权限”“JSP 怎么安全传参又不暴露订单 ID”“骑手端地图坐标怎么和后台订单绑定”这类问题卡住,这个包不是玩具,是能照着改、能上线试跑、能让你讲清楚“为什么这里必须用HttpSessionListener而不是Filter”的实操基线。
2. 四角色权限体系:从数据库建模到 Servlet Filter 的硬核落地
2.1 数据库设计:一张 user 表撑起四角色,靠 role_id 和 status 字段做分流
系统没搞四张用户表(user_member、user_rider、user_merchant、user_admin),而是在t_user表里用role_id TINYINT区分角色(1=会员,2=骑手,3=商家,4=管理员),再用status TINYINT控制生命周期(0=禁用,1=启用,2=冻结)。关键字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
id | BIGINT PK | 主键,自增 |
username | VARCHAR(32) NOT NULL UNIQUE | 登录账号,全局唯一 |
password | VARCHAR(64) NOT NULL | SHA-256 加密存储(非明文!) |
role_id | TINYINT NOT NULL DEFAULT 1 | 角色标识,决定首页跳转和菜单可见性 |
status | TINYINT NOT NULL DEFAULT 1 | 账号状态,登录时强制校验 |
real_name | VARCHAR(20) | 会员/骑手/商家真实姓名 |
phone | VARCHAR(15) | 绑定手机号,用于短信通知(预留字段) |
balance | DECIMAL(10,2) DEFAULT 0.00 | 会员余额,单位:元 |
rider_license | VARCHAR(64) | 骑手身份证号(仅骑手角色填充) |
merchant_address | TEXT | 商家详细地址(仅商家角色填充) |
提示:
role_id不是外键关联角色表,而是硬编码值。好处是查询快(避免 JOIN),坏处是扩展新角色需改代码。教学场景下这是合理取舍——你真要在生产环境加“供应商”角色?先重构权限模块,而不是改个数字。
2.2 登录认证:Servlet 层统一拦截,Session 存对象而非字符串
登录入口是LoginServlet.java,核心逻辑不是简单request.getParameter("username"),而是:
// LoginServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username").trim(); String password = request.getParameter("password"); // 1. 密码加盐校验(使用 SHA-256 + 随机 salt,salt 存在数据库 salt 字段) User user = userService.findByUsername(username); if (user == null || !PasswordUtil.verify(password, user.getPassword(), user.getSalt())) { request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 2. 检查账号状态 if (user.getStatus() != 1) { request.setAttribute("error", "账号已被禁用,请联系管理员"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 3. 将完整 User 对象存入 Session(不是只存 username!) HttpSession session = request.getSession(true); session.setAttribute("currentUser", user); // 关键:存对象,后续所有 Servlet 可直接 getAttribute session.setMaxInactiveInterval(1800); // 30 分钟无操作自动失效 // 4. 根据 role_id 重定向首页 String redirectPath = getHomePathByRole(user.getRoleId()); response.sendRedirect(redirectPath); }getHomePathByRole()方法返回:
role_id=1→/member/index.jsprole_id=2→/rider/order_list.jsp(骑手首页是待接单列表)role_id=3→/merchant/order_manage.jsp(商家首页是待处理订单)role_id=4→/admin/dashboard.jsp(管理员首页是数据看板)
注意:这里没用
web.xml的<security-constraint>做声明式安全,因为四角色页面路径混杂(如/order/detail.jsp会员、骑手、商家都能访问,但展示字段不同),必须靠代码级控制。Session 存User对象而非username,是为了在后续OrderServlet中能直接调用user.getRoleId()判断权限,避免反复查库。
2.3 权限过滤器:用 Filter 拦截非法路径,比 JSP 里写<c:if>更可靠
系统在src/filter/RoleFilter.java实现了基于路径前缀的角色白名单:
// RoleFilter.java public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String path = request.getServletPath(); // 允许静态资源和登录页直接访问 if (path.startsWith("/css/") || path.startsWith("/js/") || path.startsWith("/images/") || path.equals("/login.jsp") || path.equals("/LoginServlet")) { chain.doFilter(req, resp); return; } // 必须已登录 HttpSession session = request.getSession(false); if (session == null || session.getAttribute("currentUser") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } User user = (User) session.getAttribute("currentUser"); int roleId = user.getRoleId(); // 按路径前缀定义角色权限(硬编码,教学项目可接受) if (path.startsWith("/member/")) { if (roleId != 1) { unauthorized(response); return; } } else if (path.startsWith("/rider/")) { if (roleId != 2) { unauthorized(response); return; } } else if (path.startsWith("/merchant/")) { if (roleId != 3) { unauthorized(response); return; } } else if (path.startsWith("/admin/")) { if (roleId != 4) { unauthorized(response); return; } } chain.doFilter(req, resp); } private void unauthorized(HttpServletResponse response) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.getWriter().write("无权限访问此页面"); }血泪经验:很多初学者在 JSP 页面里用
<c:if test="${sessionScope.currentUser.roleId == 1}">控制按钮显示,这完全无效——URL 可以直接输入/admin/user_list.jsp绕过前端判断。Filter 是 Servlet 容器层拦截,只要请求进来了,就必须过这一关。这个 Filter 在web.xml中配置为<url-pattern>/*</url-pattern>,确保所有请求必经之路。
3. 订单状态机:从会员下单到骑手送达的七步流转与 Servlet 协同
3.1 状态定义与数据库字段:用 status_code + status_desc 双保险
订单主表t_order的状态字段设计为:
| status_code | status_desc | 说明 | 可触发角色 |
|---|---|---|---|
| 10 | 待支付 | 会员下单后,未付款 | 会员(支付)、管理员(人工代付) |
| 20 | 已支付 | 支付成功,进入配单池 | 系统(自动)、管理员(手动) |
| 30 | 配单中 | 商家未接单,系统正在匹配骑手 | 系统(定时任务) |
| 40 | 商家已接单 | 商家点击“确认接单” | 商家 |
| 50 | 骑手已接单 | 骑手点击“我要接单” | 骑手 |
| 60 | 骑手中途 | 骑手已取餐,正在配送 | 骑手(更新位置)、系统(超时预警) |
| 70 | 已完成 | 骑手点击“确认送达” | 骑手 |
| 80 | 已取消 | 会员/商家/管理员任一角色发起取消 | 多角色 |
提示:
status_code用两位数而非字符串,方便 SQL 排序和范围查询(如WHERE status_code BETWEEN 40 AND 60查配送中订单);status_desc用于 JSP 页面直接显示,避免硬编码中文。
3.2 关键 Servlet 协同:OrderServlet 承担状态变更中枢
所有订单状态变更都走OrderServlet,通过action参数区分操作类型:
// OrderServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); HttpSession session = request.getSession(); User currentUser = (User) session.getAttribute("currentUser"); switch (action) { case "create": handleCreateOrder(request, response, currentUser); break; case "pay": handlePayOrder(request, response, currentUser); break; case "accept": handleAcceptOrder(request, response, currentUser); // 商家接单 break; case "take": handleTakeOrder(request, response, currentUser); // 骑手接单 break; case "deliver": handleDeliverOrder(request, response, currentUser); // 骑手送达 break; case "cancel": handleCancelOrder(request, response, currentUser); // 取消订单 break; default: response.sendError(HttpServletResponse.SC_BAD_REQUEST); } }以handleAcceptOrder为例,它不只是更新数据库:
private void handleAcceptOrder(HttpServletRequest request, HttpServletResponse response, User user) throws IOException { long orderId = Long.parseLong(request.getParameter("orderId")); // 1. 校验:只能是当前商家自己的订单,且状态为 20(已支付)或 30(配单中) Order order = orderService.findById(orderId); if (order == null || order.getMerchantId() != user.getId() || !(order.getStatusCode() == 20 || order.getStatusCode() == 30)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权操作此订单"); return; } // 2. 更新订单状态 + 设置商家ID + 记录操作时间 order.setStatusCode(40); order.setMerchantId(user.getId()); order.setAcceptTime(new Date()); orderService.update(order); // 3. 发送站内信给骑手(模拟,实际可集成短信/推送) messageService.sendToRiders("新订单:" + order.getOrderNo() + ",请尽快接单", order.getId()); // 4. 重定向回商家订单管理页 response.sendRedirect(request.getContextPath() + "/merchant/order_manage.jsp?tab=accepted"); }注意:这里没有用事务注解(因为没 Spring),而是手动用
Connection.setAutoCommit(false)+try-catch-finally包裹 DB 操作。教学项目中,明确写出事务边界比依赖框架更利于理解一致性保障。
3.3 骑手端实时定位:JSP 页面用 JS 获取坐标,Servlet 接收并存入 t_rider_location
骑手在/rider/order_detail.jsp页面点击“开始配送”后,触发 JS 获取 GPS:
<!-- rider/order_detail.jsp --> <script> function startDelivery() { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( function(position) { const lat = position.coords.latitude; const lng = position.coords.longitude; // 发送坐标到 Servlet fetch('<%=request.getContextPath()%>/RiderLocationServlet', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'orderId=<%=order.getId()%>&lat=' + lat + '&lng=' + lng }).then(r => r.text()).then(text => alert(text)); }, function(error) { console.error('获取位置失败:', error); } ); } } </script> <button onclick="startDelivery()">开始配送</button>对应RiderLocationServlet.java:
// RiderLocationServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { long orderId = Long.parseLong(request.getParameter("orderId")); double lat = Double.parseDouble(request.getParameter("lat")); double lng = Double.parseDouble(request.getParameter("lng")); HttpSession session = request.getSession(); User rider = (User) session.getAttribute("currentUser"); // 插入或更新骑手位置(按 orderId 关联) RiderLocation location = new RiderLocation(); location.setOrderId(orderId); location.setRiderId(rider.getId()); location.setLatitude(lat); location.setLongitude(lng); location.setUpdateTime(new Date()); riderLocationService.saveOrUpdate(location); // 使用 REPLACE INTO 或 ON DUPLICATE KEY UPDATE response.getWriter().write("位置已更新"); }t_rider_location表结构:
id BIGINT PKorder_id BIGINT INDEXrider_id BIGINTlatitude DECIMAL(10,8)longitude DECIMAL(10,8)update_time DATETIME
玄学提醒:Android WebView 或 iOS Safari 在某些版本下
getCurrentPosition可能静默失败。生产环境必须加超时和错误 fallback(如手动输入地址),教学项目至少在 JS 里补error回调打印 console,否则调试时会以为“功能没反应”。
4. 避坑:JSP+Servlet 外卖系统里最常翻车的 4 个硬伤
4.1 现象:会员登录后,刷新页面就登出;骑手接单后,订单状态不更新
原因:HttpSession默认使用 Cookie 存储 Session ID,但开发时用localhost:8080访问,而图片/JS 引用用了http://127.0.0.1:8080,浏览器视为不同域,Cookie 不共享。
解决:统一所有 URL 前缀为${pageContext.request.contextPath},JSP 中写<img src="${pageContext.request.contextPath}/images/logo.png">;Servlet 重定向用response.sendRedirect(request.getContextPath() + "/xxx");检查浏览器开发者工具 Application → Cookies,确认JSESSIONID是否出现在所有请求中。
4.2 现象:商家修改菜品价格,会员端购物车里的价格还是旧的
原因:购物车数据存在HttpSession中,但菜品价格变更后,Session 里的CartItem对象没同步更新,导致“内存价格”和“数据库价格”不一致。
解决:购物车渲染时,JSP 不直接输出item.getPrice(),而是调用productService.findById(item.getProductId()).getPrice()实时查库;或在商家修改价格后,用ServletContext广播事件,遍历所有 Session 清空购物车缓存(教学项目推荐前者,简单可控)。
4.3 现象:骑手端地图坐标定位偏差 500 米,订单配送超时误判
原因:navigator.geolocation返回的是 WGS-84 坐标(GPS 标准),但国内地图 SDK(如高德、百度)使用 GCJ-02 坐标系,直接画点会偏移。
解决:服务端不做转换(JSP+Servlet 项目无地图 SDK),而是在 JSP 页面引入高德 JS API,用AMap.convertFrom([lng, lat], 'gps')转换后再marker.setPosition();或约定前端获取坐标后,调用高德 Web Service API 转换(需申请 Key,教学项目可 mock 偏移量)。
4.4 现象:管理员删除会员,该会员的订单记录级联删除失败,数据库报外键约束异常
原因:t_order表的member_id字段设置了FOREIGN KEY,但删除语句没加ON DELETE CASCADE,或 MySQL 存储引擎不是 InnoDB(MyISAM 不支持外键)。
解决:建表时显式声明:
CREATE TABLE t_order ( id BIGINT PRIMARY KEY, member_id BIGINT NOT NULL, FOREIGN KEY (member_id) REFERENCES t_user(id) ON DELETE CASCADE ) ENGINE=InnoDB;并确认 MySQL 配置show engines;输出中InnoDB为YES;若已建表,用ALTER TABLE t_order DROP FOREIGN KEY xxx;先删旧约束,再重建。
5. JSP 页面性能与安全加固:三招让老技术栈扛住真实流量
5.1 JSP 缓存策略:用<%@ page isELIgnored="false" %>+ 自定义 Taglib 减少重复逻辑
系统里大量订单列表页(如/member/order_list.jsp)需要格式化金额、状态文案、时间戳。别在每个<c:forEach>里写fmt:formatNumber和c:choose,而是封装成自定义 Tag:
// src/tag/OrderStatusTag.java @Tag(name="orderStatus", tagClassName="com.example.tag.OrderStatusTag") public class OrderStatusTag extends SimpleTagSupport { private int statusCode; @Override public void doTag() throws JspException, IOException { JspWriter out = getJspContext().getOut(); String text = getStatusText(statusCode); out.write(text); } private String getStatusText(int code) { switch (code) { case 10: return "待支付"; case 20: return "已支付"; case 40: return "商家已接单"; case 50: return "骑手已接单"; case 70: return "已完成"; default: return "未知状态"; } } // getter/setter... }在 JSP 中使用:
<%@ taglib prefix="my" uri="/WEB-INF/tags/my.tld" %> <c:forEach items="${orderList}" var="order"> <tr> <td>${order.orderNo}</td> <td><my:orderStatus statusCode="${order.statusCode}"/></td> <td><fmt:formatNumber value="${order.totalAmount}" pattern="¥#,##0.00"/></td> </tr> </c:forEach>这样做的好处:状态文案集中维护,改一处全站生效;避免 EL 表达式嵌套过深导致 JSP 编译失败;Tag 执行比 JSTL
<c:choose>更快(无 XML 解析开销)。
5.2 防 XSS 与 SQL 注入:JSP 输出一律用<c:out value="${xxx}" />,Servlet 参数校验前置
所有用户输入(用户名、地址、评论)存库前必须过滤:
- 使用 Apache Commons Text 的
StringEscapeUtils.escapeHtml4(input)处理富文本; - 对数字型参数(如
orderId)用Long.parseLong()包裹,捕获NumberFormatException; - 对字符串参数(如
searchKey)用正则校验^[a-zA-Z0-9\u4e00-\u9fa5\\s]{1,50}$(允许中文、字母、数字、空格,长度 1~50)。
JSP 页面输出时,永远不用${param.xxx}或${user.name}直接插值,必须:
<!-- 正确 --> <td><c:out value="${order.merchantName}" /></td> <!-- 错误(XSS 风险) --> <td>${order.merchantName}</td>后悔药:如果已上线发现 XSS,紧急补救是在
web.xml中配置<context-param>开启 EL 忽略,但这会破坏所有功能。最好的后悔药是——从第一行 JSP 就养成c:out习惯。
5.3 Tomcat 调优:把maxThreads和connectionTimeout写进server.xml
默认 Tomcat 配置(conf/server.xml)对并发支撑不足。针对外卖系统高频订单查询,修改Connector:
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" maxThreads="200" <!-- 默认 200,够教学用 --> minSpareThreads="25" maxSpareThreads="75" acceptCount="100" <!-- 请求队列长度 --> compression="on" <!-- 开启 GZIP 压缩 --> compressionMinSize="2048" noCompressionUserAgents="gozilla, traviata" />同时,在conf/web.xml中开启 Session 持久化(防重启丢失):
<manager className="org.apache.catalina.session.PersistentManager" saveOnRestart="true"> <store className="org.apache.catalina.session.FileStore" directory="sessionStore" /> </manager>我一般会:在
src/main/webapp/WEB-INF/web.xml里加<session-config>显式声明超时:
<session-config> <session-timeout>30</session-timeout> <!-- 单位:分钟 --> <cookie-http-only>true</cookie-http-only> <!-- 防 XSS 窃取 Session --> <tracking-mode>COOKIE</tracking-mode> </session-config>这样比依赖 Tomcat 默认值更可控,也符合 OWASP 安全基线。
6. 从 ZIP 包到可交付系统:五个必须亲手验证的验收动作
6.1 验证点一:数据库初始化脚本能否一键建库建表(含测试数据)
解压 ZIP 后,找到db/init.sql(或类似命名),用 MySQL 客户端执行:
mysql -u root -p < init.sql执行后检查:
- 库是否存在:
SHOW DATABASES LIKE 'food_delivery'; - 表是否齐全:
USE food_delivery; SHOW TABLES;应包含t_user,t_merchant,t_food,t_order,t_order_item,t_rider_location等 10+ 张表; - 测试账号是否插入:
SELECT username, role_id, status FROM t_user WHERE username IN ('member1', 'rider1', 'merchant1', 'admin1');应返回四条记录,role_id分别为 1/2/3/4,status=1。
如果
init.sql缺失或执行报错,立刻停下手——这不是可运行系统,是半成品。我遇到过三次,都是ENGINE=InnoDB被写成ENGINE=MyISAM,或AUTO_INCREMENT字段漏写PRIMARY KEY。宁可花 20 分钟修 SQL,别指望“运行时自动建表”。
6.2 验证点二:Tomcat 启动后,四角色首页能否正确跳转且菜单可见
部署 WAR 包(或直接放webapps/ROOT)后,启动 Tomcat,依次访问:
http://localhost:8080/login.jsp→ 输入member1/123456→ 应跳转到/member/index.jsp,页面顶部显示“欢迎,会员1”,左侧菜单有“我的订单”“购物车”“个人中心”;http://localhost:8080/login.jsp→ 输入rider1/123456→ 应跳转到/rider/order_list.jsp,页面有“待接单”“进行中”“已完成”Tab;http://localhost:8080/login.jsp→ 输入merchant1/123456→ 应跳转到/merchant/order_manage.jsp,表格列出测试订单;http://localhost:8080/login.jsp→ 输入admin1/123456→ 应跳转到/admin/dashboard.jsp,有订单统计图表。
关键检查:打开浏览器开发者工具 Network 标签,看跳转后的 HTML 是否加载成功(Status 200),而非 302 循环或 404。如果卡在登录页,一定是
RoleFilter拦截了,检查web.xml中 Filter 的<url-pattern>是否配置为/*。
6.3 验证点三:订单创建→支付→商家接单→骑手接单→送达,全流程状态是否连贯
手动走一遍闭环:
- 会员登录,加购商品,提交订单 →
t_order.status_code应为10(待支付); - 点击“去支付” →
status_code变为20(已支付); - 商家登录,进入“待处理订单”,点击“确认接单” →
status_code变为40,accept_time有值; - 骑手登录,进入“待接单”,点击“我要接单” →
status_code变为50,rider_id被填充; - 骑手进入订单详情页,点击“确认送达” →
status_code变为70,deliver_time有值。
每一步后,用 MySQL 客户端查t_order表验证字段变更。不要只信页面文字——页面可能缓存旧状态,数据库才是真相。
6.4 验证点四:骑手定位坐标能否存入数据库并被查询
骑手登录后,打开/rider/order_detail.jsp(选一个status_code=50的订单),点击“发送位置”按钮(或页面自带自动上报),然后执行:
SELECT * FROM t_rider_location WHERE order_id = ? ORDER BY update_time DESC LIMIT 1;应返回一条记录,latitude和longitude是合理数值(如39.9042,116.4074北京坐标),update_time是当前时间。
如果查不到,检查
RiderLocationServlet是否被正确映射(web.xml中<servlet-mapping>的url-pattern是否为/RiderLocationServlet),以及t_rider_location表是否有UNIQUE KEY (order_id)约束(避免重复插入)。
6.5 验证点五:管理员能否禁用会员账号,且该会员立即无法登录
管理员登录 → 进入/admin/user_list.jsp→ 找到member1→ 点击“禁用” → 数据库中t_user.status变为0。此时新开浏览器窗口,访问http://localhost:8080/login.jsp,用member1/123456登录,应提示“账号已被禁用,请联系管理员”,且 Session 不创建。
这是权限体系的终极考验。如果禁用后还能登录,说明
LoginServlet没校验status字段,或RoleFilter没拦截/member/**路径。必须追到代码里,一行行确认校验逻辑。
我带学生做这个项目时,总强调:ZIP 包不是终点,是起点。它给你骨架,但血肉要你亲手填——改一个status_code常量,调一次Connection.commit(),修一行web.xml的 Filter 配置,这些动作比背概念重要十倍。当你亲手让骑手的坐标点在地图上跳动起来,那刻你就真正懂了什么叫“四角色协同”。希望帮到你。
本文还有配套的精品资源,点击获取