ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

扫码点餐系统源码解析:Servlet+JWT+Redis实现全流程

扫码点餐系统源码解析:Servlet+JWT+Redis实现全流程 简介基于JavaWeb的点餐系统后端源码采用原生Servlet编写面向正在学习Java服务端开发、准备课程设计或毕业设计的学生及开发者。项目定位为前后端分离架构中的服务端部分当前压缩包仅包含后端代码前端Vue2代码仍在整理中后端覆盖订单、账单、支付、用户、认证、购物车等核心业务模块集成Redis与JWT实现认证授权和单点登录配合MySQL8数据库使用Mybatis-Flex简化数据访问并借助ZXing生成二维码整体技术栈较为完整。压缩包共157个文件主体为137个Java源码文件另有12个XML配置、2个SQL数据库脚本、2个properties配置文件、2个http接口测试文件、1个Markdown说明及1个JSON文件整体仅168KB结构清晰便于快速检阅。已有449人学习参考可用作点餐系统开发的后端实现模板也是学习原生Servlet、JWT鉴权、Mybatis-Flex、二维码生成等技能的实战素材尤其适合毕业设计项目二次开发。1. 一个Servlet工程如何撑起扫码点餐全流程扫码点餐在餐饮行业已经算不上新鲜事但真正把这套流程从零到一实现过的团队都清楚它不只是“前端点菜、后端存单”这么简单桌面码、购物车、订单快照、支付回调、对账、token过期续期任何一个环节处理不到位堂食高峰期就会暴露成事故。这个基于 javaWeb 的点餐系统源码走的是一条在 Spring Boot 大行其道时很少被拆开讲的技术路线后端用原生 Servlet Filter 处理 HTTP 请求认证授权交给 JWT Redis数据库访问用 Mybatis-Flex 精简二维码用 ZXing 动态生成前端则是 Vue2 Element-UI 管理后台 Vant-UI 移动端。它不像脚手架工程那样替你隐藏细节每个请求从进入 Filter 到返回 JSON都保留着 JavaWeb 最原始的路径。适合三种人正在学 javaWeb 想做完整项目案例的学生想摆脱 Spring 全家桶理解 Servlet 生命周期的一线开发以及需要在一套小系统里同时落地认证、支付、订单状态机、二维码生成的从业者。下面按请求链路顺序把这个源码里最有复现价值的部分拆开讲。2. JWT与Redis的登录授权原生Servlet过滤器链如何接住请求2.1 为什么要用 JWT 还要搭配 Redis很多第一次做 JavaWeb 项目的人会问一个问题JWT 本身是无状态的点餐系统里为什么还要在 Redis 里再存一份答案是无状态带来的是扩容方便但带不走“主动下线、多点互踢、token 续期”这三个餐饮场景的硬需求。服务员离职、收银端换班、同一账号在不同窗口登录服务端需要能够随时把一个 token 作废。JWT 一旦签发在过期前无法主动失效而 Redis 里存一份 token 与服务端持有会话类似注销就是从 Redis 删除 Key比黑名单机制更干净。这个项目的代码里能看到 AuthService.java 和 UserService.java 分开写前者只管签发和校验 token后者只做账号查询说明作者有意把“认证”和“用户业务”拆开。认证链路的完整走向是用户调/api/auth/login后端校验账号密码后生成 JWT同时把 token 写入 RedisKey 形如login:token:{userId}之后前端每次请求在 Header 里带Authorization: Bearer {token}由自定义 AuthFilter 统一拦截校验。2.2 生成与校验的两段关键代码JWT 的生成和解析在项目里被封装成一个工具类业务模块不直接接触 JWT 细节。以替代 jjwt 0.11.x 版本的写法为例核心方法如下public class JwtUtil { private static final String SECRET restaurant-app-secret; private static final long EXPIRE_MILLIS 60 * 60 * 1000L; public static String createToken(User user) { return Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(role, user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_MILLIS)) .signWith(Keys.hmacShaKeyFor(SECRET.getBytes()), SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes())) .build() .parseClaimsJws(token) .getBody(); } }这段代码里有两个参数需要根据项目实际情况调整。SECRET 是签名密钥生产环境不能硬编码在源码里也不要直接用默认值否则任何人可以伪造 tokenEXPIRE_MILLIS 控制 JWT 本身的过期时间点餐场景一般建议设置 2 到 8 小时太短会导致用户吃饭吃到一半要重新登录太长则丢失安全边界。setSubject 放 userIdclaim 里放 role是为了让后续 Filter 校验时可以快速判断用户角色不需要再去查数据库。生成 token 之后AuthService 要做的不只是返回 token还要把会话关系写入 Redis并设置与 JWT 匹配或略长的过期时间String redisKey login:token: user.getId(); redisTemplate.opsForValue().set(redisKey, token, 8, TimeUnit.HOURS);如果 Redis 的 TTL 比 JWT 短用户就会出现在 JWT 有效但 Redis 查不到的情况Filter 会判定未登录如果比 JWT 长token 过期后 Redis 里的脏数据还会残留一段时间。常见做法是两者设置相同的过期时长并在登出接口中主动删除 Redis Key。2.3 AuthFilter 拦截与白名单设计有了 token 生成和校验工具接下来把校验逻辑挂到 Servlet 过滤链上。原生 Servlet 项目里Filter 可以统一拦截前端所有请求做到登录校验、跨域处理、字符集处理三合一。下面是 AuthFilter 的核心骨架WebFilter(urlPatterns /api/*, filterName authFilter) public class AuthFilter implements Filter { private static final ListString WHITE_LIST Arrays.asList( /api/auth/login, /api/public/tableQr, /api/public/menu ); Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; resp.setHeader(Access-Control-Allow-Origin, req.getHeader(Origin)); resp.setHeader(Access-Control-Allow-Methods, GET,POST,PUT,DELETE,OPTIONS); resp.setHeader(Access-Control-Allow-Headers, Authorization,Content-Type); if (OPTIONS.equalsIgnoreCase(req.getMethod())) { resp.setStatus(HttpServletResponse.SC_OK); return; } String requestUri req.getRequestURI(); if (WHITE_LIST.contains(requestUri)) { chain.doFilter(request, response); return; } String token req.getHeader(Authorization); if (StringUtils.hasText(token) AuthService.validateToken(token)) { chain.doFilter(request, response); } else { ServletUtils.writeJson(resp, Result.fail(401, 未登录或登录已过期)); } } }过滤器的白名单列表是这套认证体系里最值得花时间设计的地方。点餐系统里餐桌扫码进入菜单页、获取桌台二维码这两个接口必须放行否则顾客扫完码直接被打回登录页而提交订单、查看账单、支付这类接口则必须校验登录态。项目中 auth-api.http 和 cuisine-api.http 两个文件里其实已经把这些请求路径整理成了可执行清单后面第 5 章会专门说怎么利用它做接口验收。注意这里的跨域处理前后端分离后Vue2 开发服务器默认运行在 8080 或 5173 端口后端的 Tomcat 占用 8080两者端口不同必然产生跨域。在 Filter 里每次响应都加上Access-Control-Allow-Origin并直接 return OPTIONS 预检请求是最轻量的做法不需要额外引入 CORS 框架。生产环境如果前后端通过 Nginx 同源代理这段跨域头可以去掉避免暴露接口给非授权域名调用。3. 点餐、结算与支付的业务闭环OrderService与BillService的设计3.1 从 CartService 到订单快照购物车在点餐系统里不是必须落库的临时数据但它承担着“菜单选品到订单生成”之间的缓冲作用。OrderService.java 创建订单时不是把购物车里的菜品价格实时查一遍而是做一个快照。为什么强调快照两个原因一是套餐或单品在运营调价后历史订单仍然要保留下单时的价格和菜品名否则对账和售后会对不上二是查询实时价格需要多次访问数据库而快照只需要在加购时核算一次金额。CartService.java 在这个项目里更像一个聚合服务它把购物车内每个菜品处理成 CartItem 对象包含 dishId、dishName、price、quantity 四个字段。提交订单时OrderService 需要遍历购物车计算总价并同时写订单主表和订单明细表。简化后的创建订单逻辑如下public Order createOrder(ListCartItem cartItems, Long tableId, Long userId) { Order order new Order(); order.setOrderNo(generateOrderNo()); order.setTableId(tableId); order.setUserId(userId); order.setStatus(OrderStatus.UNPAID.getCode()); OrderStatusBean totalAmount new BigDecimal(0); ListOrderItem items new ArrayList(); for (CartItem item : cartItems) { OrderItem detail new OrderItem(); detail.setDishId(item.getDishId()); detail.setDishName(item.getDishName()); detail.setPrice(item.getPrice()); detail.setQuantity(item.getQuantity()); detail.setSubtotal(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); totalAmount totalAmount.add(detail.getSubtotal()); items.add(detail); } order.setTotalAmount(totalAmount); // 保存订单主表、明细表清空购物车 return order; }这里有一个新手很容易踩的坑金额计算用小数的 double 或 float。菜单上常见 19.9 元、3.5 元这种计价单位经过多次加法和乘法后浮点数误差会累积到分位对账时差几分钱很难查。项目里用 BigDecimal 并通过字符串构造是正确做法。如果你在这个项目源码里看到金额字段仍然用 double那它不是生产级设计只能用于快速演示学习。3.2 订单状态机与流转约束订单状态是点餐系统里最容易失控的部分。从用户下单到完成支付再到后厨出餐前端页面和后端接口都需要根据当前状态决定下一步动作。这个项目里订单状态可以从源码的 OrderStatus 常量中看到映射关系大致是待支付、已支付、制作中、已上菜、已完成、已取消六种状态。合理的状态流转约束如下状态触发条件对前端的作用0 待支付购物车提交成功展示支付二维码和倒计时1 已支付支付回调成功推送后厨进入制作队列2 制作中后厨接单用户端显示制作进度3 已上菜服务员标记上菜账单页展示待评价入口4 已完成用户确认或结账订单归档参与门店统计5 已取消未支付超时或用户主动取消释放桌台购物车清空状态流转不能允许跳变例如待支付状态不能直接跳到已完成必须在支付回调中先更新为已支付再由后厨流程推进到制作中。这些约束写在 OrderService 的 updateStatus 方法里。好一点的写法是用状态枚举代替魔法数字项目里如果看到 switch 判断 status建议自己重构时引入枚举。3.3 BillService 与 PayService 如何分工这个源码里有 BillService.java、PayService.java 和一个 BillService_bak.java。从命名可以推断作者最初在账单和支付的处理上经历过一次重构BillService_bak 是改造前的备份。这给后来读源码的人提供了一个很好的对比视角支付服务不应该直接操作订单和账单数据。PayService 的职责是面向支付渠道的生成支付二维码、接收第三方回调、校验回调签名、查询支付结果。它不应该有修改订单状态的权限因为外部渠道返回的数据不可完全信任必须由 BillService 根据回调结果完成对账并确认支付金额与订单金额一致后再更新订单状态。这样拆分的意义在于如果支付渠道出现重复回调PayService 只需要做幂等判断而真正的状态变更由内部服务控制。Mybatis-Flex 在 Dao 层的写法也体现出这种职责分离。以订单查询为例Mybatis-Flex 的 QueryWrapper 可以直接用 Lambda 方式拼接条件Order order Db.selectOneByQuery( QueryWrapper.create() .where(Order::getOrderNo).eq(orderNo) );Mybatis-Flex 相比原生 MyBatis 的 XML 映射省去了繁琐的 SQL 标签又比 MyBatis-Plus 更轻。原生 Servlet 工程里集成它不需要引入 Spring 插件初始化好 SqlSessionFactory 后直接通过 Db 静态方法操作数据这很符合“小项目不想堆依赖”的定位。4. 二维码、购物车与前后端联调ZXing与Vue2的配合4.1 两种二维码桌台码与支付码二维码在这个项目里有两个出现位置餐桌码和支付码。餐桌码在顾客落座时扫内容是前端点餐页的 URL 加上桌号参数形如http://ip:port/#/menu?tableId12支付码在提交订单后生成内容是收银台地址或支付链接。二者用同一套 ZXing 工具类生成区别只在于 content 字段不同。使用 ZXing 生成二维码的代码很固定以下几点需要注意。字符编码上内容中有中文参数时比如桌台名称、菜品标签一定要先 URL 编码否则二维码能被识别但扫描后解析出的 URL 乱码。生成逻辑通常放在 Servlet 或 Controller 中直接输出图片流private void writeQrCode(String content, HttpServletResponse response) throws Exception { int width 300; int height 300; QRCodeWriter writer new QRCodeWriter(); BitMatrix matrix writer.encode(content, BarcodeFormat.QR_CODE, width, height); BufferedImage image MatrixToImageWriter.toBufferedImage(matrix); response.setContentType(image/png); response.setHeader(Cache-Control, no-cache); ImageIO.write(image, png, response.getOutputStream()); }width 和 height 决定了二维码的像素尺寸300 是移动端扫码比较稳妥的尺寸。位数不是越多越好点餐码内容较短无需设置过高的容错级别默认纠错级别 L 或 M 就够。餐桌上打印的码可以适当调大到 400 左右避免距离过远扫不出来。4.2 购物车用 Redis Hash 还是字符串购物车数据在前端如果不持久化用户刷新页面就会丢失这在堂食点餐中完全不能接受。项目采用 Redis 存储购物车具体数据结构是 HashKey 为cart:user:{userId}Field 为 dishIdValue 为菜品和数量的 JSON 字符串。这样设计的直接好处是修改某个菜的数量不需要读取整个购物车HINCRBY 或 HSET 一条命令就能完成实时计算购物车总价时HGETALL 一次性取出所有菜品再遍历即可。以某个菜加购两次为例CartService 的写入逻辑可以简化为public void addItem(Long userId, CartItem item) { String cartKey cart:user: userId; String field String.valueOf(item.getDishId()); String json JSON.toJSONString(item); redisTemplate.opsForHash().put(cartKey, field, json); redisTemplate.expire(cartKey, 30, TimeUnit.MINUTES); }过期时间设置为 30 分钟是为了防止用户在看菜单的过程中弄脏 Redis 内存。这里要注意一点每次加购都重置 TTL 会让购物车一直存活适合店内堂食场景但外卖场景下用户可能隔很久再下单需要把 TTL 调长或者干脆独立维护会话时间。4.3 Vue2 中 Axios 拦截器与后端 JSON 约定前端 Vue2 项目通常分两个入口顾客移动端点餐页和管理员 PC 后台。移动端用 Vant-UI后台用 Element-UI。两个入口共用一套后端接口但拥有不同角色因此前端在 Axios 请求拦截器里做 token 注入响应拦截器里处理 401 跳转登录页service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) service.interceptors.response.use( res { if (res.data.code 200) { return res.data.data } return Promise.reject(res.data) }, err { if (err.response err.response.status 401) { router.push(/login) return } return Promise.reject(err) } )这段代码对应的后端约定是所有接口返回统一结构{code, msg, data}code 为 200 表示成功401 表示认证失效。拦截器在 data 层把包装拆掉业务代码里只需要关心 data 字段不用每个页面都判断 error code。这个约定不是项目特有的但在这个源码里可以明显看到 ServletUtils.java 承担了统一 JSON 响应的职责说明作者从一开始就意识到格式一致对前后端联调的重要性。5. 部署Tomcat时的SQL、跨域与token排错清单5.1 原生 Servlet 工程部署配置这个项目不依赖 Spring Boot 内嵌服务器打 war 包部署到 Tomcat 是主流方式。部署前需要检查 web.xml 和注解配置是否匹配当前的 Servlet 版本。Tomcat 8 和 9 对应 Servlet 3.1/4.0支持WebServlet和WebFilter注解风格的类不需要在 web.xml 中逐个声明如果仍在使用 Tomcat 7 或更老版本则需要把 Filter 的映射写回 web.xmlfilter filter-nameauthFilter/filter-name filter-classcom.restaurant.filter.AuthFilter/filter-class /filter filter-mapping filter-nameauthFilter/filter-name url-pattern/api/*/url-pattern /filter-mappingwar 包发布后在webapps目录下会生成同名目录接口访问路径带上下文前缀。例如前端请求/api/auth/login后端实际路径是/{contextPath}/api/auth/login。跨域配置需要同时允许真实路径和上下文前缀不然前端会出现 404 和 CORS 报错同时存在的迷惑现象。5.2 MySQL8 与 Redis 连接常见问题数据库配置是本项目最容易在启动阶段卡住的环节。MySQL8 的驱动类从com.mysql.jdbc.Driver改为com.mysql.cj.jdbc.DriverJDBC URL 中如果不带时区参数会直接报错。以下是建议的最小配置项报错现象原因解决办法Public Key Retrieval is not allowedMySQL8 默认认证插件缓存 sha2 密码JDBC URL 加 allowPublicKeyRetrievaltrueThe server time zone value is unrecognized服务器时区没有显式指定JDBC URL 加 serverTimezoneAsia/ShanghaiUnknown database数据库名不匹配核对 mybatis-flex 配置中的 url 与数据库名Access denied for user账号权限或 host 限制给应用账号开放远程 host 或使用 localhost连接 Redis 时如果项目配置了密码而 Redis 服务端没有设置 requirepass启动时不会立刻报错但登录接口写 token 时会一直超时。建议启动后端前先执行redis-cli ping确认连通性再执行redis-cli auth {password}验证密码。5.3 用 auth-api.http 直接验收认证链路项目里自带的 auth-api.http 和 cuisine-api.http 是 IDEA HTTP Client 的请求文件比 Postman 更适合做接口回归测试。文件内可以定义变量把登录拿到的 token 自动传给后续请求POST http://localhost:8080/api/auth/login Content-Type: application/json { phone: 13800000000, password: 123456 } {% client.global.set(token, response.body.data.token); %} ### GET http://localhost:8080/api/order/list Authorization: Bearer {{token}}这种写法在排查 401 问题时特别有效只有登录请求不经过 token 注入而后续订单接口全部依赖{{token}}。如果你把auth-api.http里的登录账号改为一个不存在的手机号返回 401 说明 UserService 层账号校验生效如果登录成功但订单接口仍然 401问题大概率出在 Redis 里没查到 token而不是 JWT 校验失败。先确认 Redis 的 Key 是否存在再判断是过滤器白名单配置错了还是 TTL 过短。这套排查顺序直接覆盖了原生化 Servlet 项目中七成以上的认证故障场景。本文还有配套的精品资源点击获取
返回列表