简介:这份基于JavaWeb的苍穹外卖项目资源提供完整的在线订餐系统源码,同时附带作者历时一个月整理的开发笔记,适合正在学习Servlet、JSP、Spring、MyBatis等后端技术,或希望积累完整项目经验的开发者。压缩包共2000个文件、约13.61MB,核心文件类型包括388个xml配置、146个java源码、144个class编译产物,以及yml、iml、xlsx等工程配置与表格文档,其中Markdown笔记可直接用于梳理业务和部署流程;数量庞大的hash文件多为Git对象,可忽略。项目围绕用户登录鉴权、菜品与订单管理、SQL查询、文件上传等模块展开,覆盖MVC分层、依赖注入、数据库表设计、Session/Cookie会话控制、SQL注入防御、基于Tomcat的部署运行等完整知识点,并给出单元测试与整合测试的参考思路。已有16397人浏览学习,适合作为从课内理论过渡到完整项目开发、用于复盘和二次练习的高价值参考资料。
1. 从开始到跑通:苍穹外卖这个 JavaWeb 项目到底能帮我解决什么
我花了整整一个月,把苍穹外卖从空目录写到了能下单、能支付、能接单。这个项目不是一个简单的 CRUD 练手,它把 JavaWeb 开发里最常见的几块硬骨头——登录鉴权、缓存、事务、定时任务、跨域联调——全都串在了同一条业务链上:用户登录 → 逛菜单 → 加购物车 → 下单 → 支付 → 商家接单。你把它跑起来,等于把 JavaWeb 从语法到工程化的路走了一遍。
它适合两类人。第一类是刚学完 Spring Boot 和 MyBatis、但手里没有完整项目的人,把它跑通能补上「从 demo 到系统」的断层;第二类是准备投实习或工作的同学,需要一份代码结构规范、能讲清楚业务链路的项目用来复盘。下面我不讲虚的,直接从技术选型、目录结构、核心代码、运行步骤到改造思路,把这份源码拆给你看。
2. 技术选型与工程结构:JavaWeb 项目是怎么一层层组织起来的
2.1 技术栈全景:Spring Boot、MyBatis、Redis 各管哪一段
先看整体选型,苍穹外卖是一个典型的前后端分离单体项目。后端基座是 Spring Boot,ORM 层用 MyBatis 加 XML 写 SQL,缓存用 Redis,鉴权用 JWT,前端管理端是 Vue 生态,用户端是小程序或 H5。这个组合在 JavaWeb 项目里非常常见,没有引入消息队列、搜索引擎这类重量级组件,原因是业务复杂度还没到那个程度,强行上微服务和 MQ 反而会稀释核心链路的学习价值。
Spring Boot 在这套架构里的角色是「装配者」:把 HTTP 入口、拦截器、事务管理、定时任务调度这些基础设施通过注解和自动配置串起来,让我不用手写一堆 XML 配置。MyBatis 负责数据库访问,它和 JPA 最大的区别是 SQL 由我掌控,下单时多表联查、按状态统计这些复杂查询写起来更直接。Redis 承担了三件事:缓存菜品列表、保存购物车临时数据、记录订单提交幂等标记。JWT 则负责用户登录后的身份传递,服务端不存 session,每次请求靠令牌头里的签名信息识别身份。
这套选型最直观的好处是数据结构清晰。每个模块的边界一眼能看穿:Controller 只接收参数和返回结果,Service 里写业务规则,Mapper 只碰 SQL。就算以后要把某一个模块拆出去做微服务,按这个分层切一刀就能拆,不会出现代码缠在一起撕不开的情况。
2.2 目录结构拆解:后端分层和前端页面是怎么对应的
拿到源码后先别急着跑,把目录结构过一遍,后续改代码会顺手很多。后端是标准的四层结构,前端管理端按业务模块划分页面,用户端按使用流程组织功能。
sky-take-out/ ├── sky-server/ # 后端主服务 │ ├── src/main/java/ │ │ └── com/sky/ │ │ ├── controller/ # HTTP 入口,只做参数接收和结果封装 │ │ ├── service/ # 业务逻辑层,事务和业务规则都在这层 │ │ ├── mapper/ # MyBatis 数据访问接口 │ │ ├── entity/ # 数据库表对应的实体类 │ │ ├── dto/ # 前端传参的对象模型 │ │ ├── vo/ # 返回给前端的数据对象 │ │ ├── config/ # 配置类,包含拦截器、跨域、Redis 配置 │ │ ├── interceptor/ # JWT 登录拦截器 │ │ ├── handler/ # 全局异常处理 │ │ └── task/ # 定时任务 │ └── src/main/resources/ │ ├── mapper/ # MyBatis XML 文件 │ └── application.yml # 核心配置 ├── sky-common/ # 公共模块:工具类、统一返回结果、异常定义 └── sky-pojo/ # 实体模块:entity、dto、vo 集中管理后端把sky-server、sky-common、sky-pojo拆成三个子模块,这个设计我看到源码第一眼就觉得舒服。sky-common里放 JwtUtil、MD5Util、RedisKey 常量这些没有业务属性的工具;sky-pojo里全是数据模型,跨模块引用时不会出现循环依赖;sky-server只是业务实现本身。前端页面则分成用户端和管理端两块,管理端有菜品分类维护、订单状态看板、员工管理;用户端是点餐、购物车、订单状态查询流。
我在看这类分层项目时有一个习惯:先找一个完整的业务流程从头到尾跟一遍。比如管理端新增菜品,路径就是controller → service → mapper → XML,再通过前端「菜品管理」页面对应的接口调用到上面这条链。把一条链走通之后,其他模块基本都是它的变体,读代码的速度会快很多。
2.3 数据库设计:订单、菜品、购物车这些表是怎么关联的
数据库是这个项目最容易被忽略但最值得细看的部分。外卖业务的核心表我梳理下来一共十张左右,主要分三块:基础数据(员工、用户)、商品数据(分类、菜品、口味)、交易数据(购物车、订单、订单明细、地址、支付记录)。
菜品和口味是一对多关系,一张菜品表加一张口味表,用dish_id关联。购物车表比较特殊,它其实是一个临时表,用户没下单之前数据都存在 Redis 里,只有提交订单时才把购物车内容落库成订单明细,同时清掉 Redis 缓存。订单表和订单明细表是典型的主从结构:订单表一行代表一单,记录总金额、状态、下单时间、用户 ID;订单明细表一行代表订单里的一个菜品,记录菜品名、价格、数量。查询订单详情时用order_id把两张表连起来。
-- 订单主表 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '用户ID', order_number VARCHAR(32) NOT NULL COMMENT '订单号', status TINYINT NOT NULL COMMENT '1待付款 2已接单 3配送中 4已完成 5已取消', amount DECIMAL(10,2) NOT NULL COMMENT '订单金额', create_time DATETIME NOT NULL, pay_time DATETIME ); -- 订单明细表 CREATE TABLE order_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL COMMENT '所属订单ID', dish_name VARCHAR(64) NOT NULL, dish_flavor VARCHAR(128), price DECIMAL(10,2) NOT NULL, number INT NOT NULL );设计上有一点值得说道:订单金额没有设计成明细金额的冗余列,而是单独存了amount字段。一开始我觉得冗余,后来发现这样做有两个好处:一是查询订单列表不用每次 join 明细算总额,性能更好;二是订单生成后商品价格如果调整,明细表也跟着变,但订单表的金额是下单那一刻的快照,不会因为菜品改价而影响历史订单。做交易类系统时,这种「快照金额」的思路可以直接抄。
3. 核心模块拆解:一个订单从加购到支付的完整链路
3.1 用户登录与 JWT 鉴权
整个系统的入口是登录。苍穹外卖的管理端和用户端都走 JWT 鉴权,流程不复杂:用户提交账号密码,服务端校验通过后签发一个带签名和过期时间的令牌返回给前端;前端后续请求在 Header 里带上这个令牌,后端写一个拦截器统一解析,解析通过就放行,解析失败直接返回 401。
// AuthController.java @PostMapping("/login") public Result<LoginVO> login(@RequestBody LoginDTO loginDTO) { // 查询用户,校验状态 Employee employee = employeeService.getByUsername(loginDTO.getUsername()); if (employee == null) { return Result.error("账号不存在"); } if (!MD5Util.matches(loginDTO.getPassword(), employee.getPassword())) { return Result.error("密码错误"); } if (employee.getStatus() == 0) { return Result.error("账号已被禁用"); } // 生成JWT令牌,有效期2小时 String token = JwtUtil.createToken( employee.getId(), employee.getName(), 2 * 60 * 60 * 1000L ); return Result.success(new LoginVO(employee.getId(), employee.getName(), token)); }这段代码有几个细节需要说明。密码不是明文比对,数据库里存的是 MD5 加盐后的值,MD5Util.matches方法负责把前端传来的明文密码做同样处理再对比。账号禁用状态在登录时就拦住了,而不是等鉴权通过后才提示,这样被禁用的用户根本拿不到令牌。令牌有效期设了 2 小时,过期后前端需要重新登录,这是单体 web 项目里比较常见的体验权衡。
对应的拦截器在spring-mvc层面做统一处理:
// JwtTokenInterceptor.java public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("token"); if (StrUtil.isEmpty(token)) { response.setStatus(401); return false; } try { // 解析成功后把用户ID放进ThreadLocal,方便后续业务直接取 Claims claims = JwtUtil.parseToken(token); BaseContext.setCurrentEmployeeId(claims.get("empId", Long.class)); return true; } catch (Exception e) { response.setStatus(401); return false; } }拦截器的好处是不用在每个 Controller 里重复写解析代码。这里我踩过一个坑:拦截器注册时必须调用excludePathPatterns把登录接口和静态资源路径排除掉,否则登录接口自己都被拦截了,会变成「还没登录就让你登录」的死循环。这段逻辑在项目里放在了WebMvcConfig中,你跑起来如果发现登录接口直接 401,第一反应就去看拦截器注册那里的排除名单。
3.2 菜品查询与 Redis 缓存策略
用户端逛菜单是最频繁的操作,如果每次都去 MySQL 查菜品列表,数据库连接会被拖垮。苍穹外卖的做法是把菜品按分类维度缓存到 Redis,缓存的 key 设计成dish_加分类 ID,value 是 JSON 序列化之后的菜品列表。
// DishServiceImpl.java @Override public List<DishVO> listByCategoryId(Long categoryId) { String key = RedisKey.DISH_PREFIX + categoryId; // 例如 dish_1001 String cached = redisTemplate.opsForValue().get(key); if (StrUtil.isNotEmpty(cached)) { return JSONUtil.toList(cached, DishVO.class); } // 缓存未命中,查数据库 List<DishVO> dishVOList = dishMapper.selectByCategoryId(categoryId); // 回填缓存,过期时间30分钟 redisTemplate.opsForValue().set( key, JSONUtil.toJsonStr(dishVOList), 30, TimeUnit.MINUTES ); return dishVOList; }这里有一个使用 Redis 的经典细节:回填缓存时一定要设置过期时间,不能把缓存当成永久的存储。菜品名称、价格、口味这些数据不是一成不变的,管理员改价之后如果缓存不过期,用户端看到的还是旧数据。30 分钟是性能和一致性的折中值,如果对数据一致性要求更高,可以在管理端修改菜品后主动调用redisTemplate.delete(key),下次查询自然回填。
另一个细节是缓存空值处理。如果某个分类下面一道菜都没有,查出来的列表是空的,这时如果只缓存非空结果,空数据每次都会穿透到数据库。正确处理是即使查到空列表也缓存,只是把过期时间设短一些,比如 5 分钟。这样频繁请求的「空的分类」也不会打爆数据库,这个技巧在《避坑章节》里我还会结合失效场景再讲一次。
3.3 购物车与下单流程
购物车模块在代码上没什么难度,就是增删改查,但它背后有一个设计选择:购物车数据存在哪。苍穹外卖选的是 Redis,因为购物车是强临时性的数据,用户清空或下单后就不再有意义,放在 Redis 里天然带过期机制,而且不需要建表去维护。
// ShoppingCartServiceImpl.java 核心片段 public void add(ShoppingCartDTO cartDTO) { // 构造购物车对象,只存用户ID、菜品ID、数量 ShoppingCart cart = new ShoppingCart(); BeanUtils.copyProperties(cartDTO, cart); cart.setUserId(BaseContext.getCurrentUserId()); cart.setNumber(1); // 判断这个用户是否已加过同一菜品 ShoppingCart exist = shoppingCartMapper.selectOne(cart); if (exist != null) { exist.setNumber(exist.getNumber() + 1); shoppingCartMapper.updateNumber(exist); } else { shoppingCartMapper.insert(cart); } }加购的逻辑很简单:查一下购物车有没有同款菜,有就把数量加一,没有就插入一行新记录。这里要注意shoppingCartMapper.selectOne(cart)的参数设计,必须把用户 ID、菜品 ID 都传进去,否则会出现「用户 A 加了宫保鸡丁,用户 B 再加时看到 A 的记录」这种串数据事故。
下单是整个项目最考验事务控制的一段。订单主表插入、订单明细批量插入、清空购物车、扣减菜品库存这四步操作必须保证要么全部成功,要么全部失败。实现上在 Service 方法上加了@Transactional注解,任何一步抛出运行时异常都会触发回滚。
@Transactional public OrderVO submit(OrderSubmitDTO submitDTO) { // 幂等检查:防止前端重复点击时创建两笔订单 String idempotentKey = "order_pre_" + submitDTO.getUserId(); if (redisTemplate.hasKey(idempotentKey)) { throw new RuntimeException("订单提交中,请勿重复操作"); } // 1. 构造订单主表数据,状态置为待付款 Orders order = buildOrder(submitDTO); orderMapper.insert(order); // 2. 从购物车查出该用户所有商品,逐条转成订单明细 List<ShoppingCart> cartList = shoppingCartMapper.selectByUserId(submitDTO.getUserId()); List<OrderDetail> detailList = cartList.stream().map(this::toOrderDetail).toList(); detailList.forEach(orderDetailMapper::insert); // 3. 清空购物车 shoppingCartMapper.deleteByUserId(submitDTO.getUserId()); return OrderVO.builder().orderId(order.getId()).amount(order.getAmount()).build(); }下单这段代码里有一个容易忽略的坑:@Transactional默认只对运行时异常回滚,如果代码里 try-catch 把异常吞掉了,事务不会生效。比如我在这里插订单时可能抛DuplicateKeyException,如果我在 catch 里记录日志后正常返回,订单没插进去但购物车被清了,用户会看到「购物车空了但订单不存在」。所以写事务方法时有一个原则:宁可让异常往上抛,也不要自己吞掉业务中断类异常。
幂等检查是这段代码里最关键的设计。order_pre_这个 key 在用户点击下单按钮时写入 Redis,订单创建成功后删除。如果用户快速点了两次提交,第二个请求进来会直接抛异常,不会重复建单。这个逻辑在没加的时候我真实翻车过——联调测试时我点了两下下单,订单表里出现了两笔一模一样的订单,后来才意识到不能只靠前端按钮禁用,后端必须做幂等。
3.4 订单状态流转与定时任务
订单创建后并不是一条直线走到完成,它有一组状态:待付款 → 已接单 → 配送中 → 已完成,中间还有已取消和退款。管理端和用户端的很多操作,本质都是对这个状态字段的流转。
// OrderServiceImpl.java 状态变更核心方法 public void confirm(Long orderId) { Orders order = orderMapper.getById(orderId); if (order.getStatus() != OrderStatus.PENDING_PAYMENT) { throw new RuntimeException("当前订单状态不允许接单"); } // 设置已接单状态,记录接单时间 order.setStatus(OrderStatus.CONFIRMED); order.setConfirmTime(LocalDateTime.now()); orderMapper.update(order); // 推送通知到用户端 WebSocketServer.sendToUser(order.getUserId(), "您的订单已接单"); }外卖项目里有几个场景需要定时任务来处理:超过 15 分钟未支付的订单自动取消,配送中超过一定时间未送达的订单自动标记为异常。Spring 自带的@Scheduled注解就够用,好处是不用额外引入 XXL-Job 这类重组件。
// OrderTimeoutTask.java @Component public class OrderTimeoutTask { @Autowired private OrderService orderService; // 每分钟执行一次,扫描超过15分钟未支付的订单 @Scheduled(cron = "0 */1 * * * ?") public void processTimeoutOrders() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(15); List<Orders> timeoutOrders = orderMapper.selectByStatusAndTime( OrderStatus.PENDING_PAYMENT, deadline ); timeoutOrders.forEach(order -> orderService.cancelOrder(order.getId())); } }cron表达式0 */1 * * * ?表示每一分钟的第 0 秒触发一次。扫描条件是状态等于待付款且创建时间早于当前时间 15 分钟。这里用数据库查询来捞超时订单是单体项目的常见做法,但如果未来量大了,每扫一次就全表查一次会变成性能瓶颈,升级方向是把「订单超时」做成延迟队列或使用 Redis 过期事件。
4. 本地运行:把苍穹外卖从源码跑起来的完整步骤
4.1 环境准备与版本搭配
动手跑之前先把环境装齐,缺一个后面都会报错。我建议按这个列表核对:JDK 1.8 或 11,Maven 3.6 以上,MySQL 5.7 或 8.0,Redis 5 以上,Node.js 14 以上。前端是一个独立的 Vue 工程,需要通过 npm 安装依赖再启动开发服务器,所以 Node 环境不能省。
关于版本搭配,我在第一次跑的时候吃过亏:JDK 版本和 Spring Boot 版本不匹配会导致项目启动直接失败。比如你用 JDK 17 跑一个基于 Spring Boot 2.3 构建的项目,可能会遇到反射调用的兼容报错。稳妥做法是先用项目pom.xml里声明的 Spring Boot 版本反推 JDK 版本,Spring Boot 2.x 就用 JDK 8 或 11。
MySQL 和 Redis 都建议用本地安装而不是 Docker,原因是在学习阶段你很可能需要打开命令行看具体日志和键值。比如订单状态没更新,你怀疑是缓存问题,可以直接敲redis-cli查看dish_1001这个 key 是否存在、过期时间还剩多少。Docker 不是不能用,只是排查问题时多了一层抽象,对于新手而言本地安装更直观。
4.2 配置文件修改:这是最容易被卡住的步骤
项目拿到手不要急着点启动,先改配置文件。后端配置在sky-server/src/main/resources/application.yml,核心要改三处:数据源地址、Redis 地址、文件上传路径。
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/sky_take_out?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8 username: root password: 123456 redis: host: localhost port: 6379 # 如果 Redis 设置了密码,这里填 password: mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.sky.entity sky: jwt: # 生产环境必须改成随机字符串 secret-key: your-secret-key-please-change timeout: 7200000spring.datasource.url里的serverTimezone=Asia/Shanghai必须保留,否则 MySQL 8 的驱动会报时区错误。sky.jwt.secret-key是一个自定义配置项,用来给 JWT 签名,默认值如果被其他人知道了,他们就能伪造令牌。你本地玩无所谓,但如果要部署到测试环境,第一件事就是把它换成一段长随机字符串。修改配置后建议重启应用而不是依赖热加载,热加载有时不会重新读取 yml 文件。
前端配置也有一个坑:管理端工程的请求地址写死在了vite.config.js里,代理目标是http://localhost:8080。如果你的后端改了端口,这里记得同步改。不然前端页面可以打开,但所有接口请求全部走 404 或 502,让你误以为后端代码有问题。
4.3 启动数据库、Redis 和前后端
启动顺序建议固定成一套:先启动 MySQL 和 Redis,再启动后端,最后启动前端。MySQL 启动后需要手动创建数据库并导入项目附带的db/sky_take_out.sql脚本,用source命令导入即可。导入后可以执行一条SELECT * FROM employee;确认表和初始数据存在。
后端项目是用 Maven 构建的多模块工程,启动前先在根目录执行:
mvn clean install -DskipTests这条命令会把sky-common和sky-pojo安装到本地仓库,否则单独启动sky-server时会报「找不到 sky-common 依赖」的错误。多模块项目最容易在这个环节翻车,一定要先全量构建一次再启动。
之后运行启动类SkyApplication.java。看到类似Tomcat started on port(s): 8080的日志就说明后端起来了。验证方式很简单,浏览器直接访问http://localhost:8080/employee/login会返回 405 或 401,这是正常的,因为 POST 接口不支持 GET 访问,但至少说明端口和容器没问题。
前端工程启动相对省事:
npm install npm run dev管理端页面通常会跑在 5173 端口,用默认账号admin和初始密码123456登录。用户端如果工程里包含的是一个 H5 页面,同样通过 npm 启起来后用浏览器模拟器访问。
4.4 跑通一条完整业务链来验证
项目启动成功不等于代码能跑通业务,我的建议是从用户端走完一条「下单 → 支付 → 接单 → 送达」的完整链路,任何一环报错都能帮你快速定位问题。
登录管理端进入菜品管理页,确认能看到已导入的分类和菜品;然后切换到用户端,把一道菜加入购物车,进入结算页提交订单。此时回管理端订单页面,能看到新订单出现在「待付款」列表里。点开订单详情,再模拟支付成功,刷新后状态会从待付款变成已接单。如果手动点击接单和送达按钮,整条链路就走完了。
验证过程中如果页面报「网络异常」,优先看浏览器开发者工具里的 Network 面板:如果请求都没发出去,基本是前端代理没生效;如果请求发出了但被拦截器挡了,去排查 JWT 令牌是否有值、拦截器的排除路径是否配置正确;如果接口报 500,去后端控制台看具体异常堆栈。这套排查顺序能覆盖 90% 的联调问题。
5. 避坑实操:一个月开发里最容易翻车的四个点
5.1 静态资源 404:拦截器把图片和页面一起拦了
现象:页面能打开,但菜品图片全部裂开,直接访问图片地址返回 401。
原因:JWT 拦截器把所有路径都拦截了,包括/images/**和/static/**这些静态资源路径。图片请求没有带令牌,自然被判定为未登录而拦截。
解决:在WebMvcConfig注册拦截器时,把静态资源路径加入排除名单:
registry.addInterceptor(jwtTokenInterceptor) .addPathPatterns("/**") .excludePathPatterns( "/employee/login", "/user/**/login", "/images/**" );注意扫码:如果你后面自己扩展了文件上传目录,比如新加了一个/files/**路径,记得同步加进排除名单。否则就会出现「本地上传成功但页面加载不出来」的翻车现场。
5.2 缓存穿透:空数据把数据库打挂了
现象:接口偶发超时,数据库 CPU 飙升,打开慢日志发现高频重复查询同一个不存在的数据。
原因:用户端频繁访问一个不存在的菜品分类时,缓存里永远没有值,每次请求都穿透到数据库。正常菜品有缓存挡着,但「无数据」的情况没有缓存覆盖。
解决:缓存空值,并且给空值设置较短过期时间。修改查询逻辑:
if (StrUtil.isEmpty(cached)) { List<DishVO> list = dishMapper.selectByCategoryId(categoryId); if (CollUtil.isEmpty(list)) { // 缓存空值,5分钟过期防止穿透 redisTemplate.opsForValue().set(key, "[]", 5, TimeUnit.MINUTES); return new ArrayList<>(); } redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(list), 30, TimeUnit.MINUTES); return list; }5.3 事务失效:本地调用导致 @Transactional 不生效
现象:下单接口执行到一半抛异常,但数据库里已经有一半的修改被提交了。订单主表没插入成功,购物车却被清空了。
原因:事务方法在同一个类里被另一个方法直接调用,绕过了 Spring 的代理对象。常见写法是把submitOrder和cancelOrder放在同一个 Service 类中,然后在submitOrder内部直接调cancelOrder,后者即使标注了@Transactional也不起作用。另一个常见原因是代码里捕获了异常没有重新抛出,事务感知不到失败。
解决:第一,避免同类内部调用事务方法,必要时注入自己的代理对象;第二,事务方法里遇到业务异常一律抛出 RuntimeException,不要 catch 后 return。
// 错误写法 public void submitOrder(OrderSubmitDTO dto) { try { orderMapper.insert(order); } catch (Exception e) { log.error("下单失败", e); // 异常被吞掉,事务不会回滚 } } // 正确写法:让异常抛给事务管理器 public void submitOrder(OrderSubmitDTO dto) { if (dto.getAmount() <= 0) { throw new RuntimeException("订单金额非法"); } orderMapper.insert(order); }5.4 跨域问题:前端调接口 No 'Access-Control-Allow-Origin'
现象:前端页面能打开,但控制台报跨域错误,请求 Response Header 里没有Access-Control-Allow-Origin。
原因:后端运行在 8080 端口,前端开发服务器运行在 5173 端口,浏览器把不同端口视为跨域。前端代理配置没生效,或后端没有开启 CORS。
解决:最简单的方式是后端配置一个全局跨域过滤器:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }如果后端加了拦截器,要注意 CORS 预检请求(OPTIONS)也要被放行,否则跨域配置写了等于白写。这里我血泪经验是:拦截器排除路径里把**改成/**是不对的,OPTIONS请求会被拦,需要显式判断请求方法为OPTIONS时直接放行。
6. 二次开发动手改:加一个优惠券模块的思路
苍穹外卖现在的订单计算逻辑是「菜品总价」直接落库,如果想引入优惠券,改动会牵一发动全身。我建议按这个顺序分三步改:先加数据表,再改订单计算的 Service 层,最后在用户端结算页接入接口。
先设计两张表:一张coupon存优惠券模板,一张user_coupon存用户领取记录。模板表主要字段是面额、满减门槛、有效期;领取记录表主要字段是用户 ID、券 ID、状态(未用、已用、过期)。代码上新增一个CouponService,核心方法是计算订单优惠后的实际金额:
public BigDecimal calcDiscountAmount(Long userId, Long couponId, BigDecimal orderAmount) { UserCoupon userCoupon = userCouponMapper.selectById(couponId); if (userCoupon == null || userCoupon.getUserId() != userId) { throw new RuntimeException("优惠券不存在"); } if (!OrderStatus.PENDING_PAYMENT.equals(userCoupon.getStatus())) { throw new RuntimeException("优惠券已使用或已过期"); } BigDecimal threshold = userCoupon.getThreshold(); if (orderAmount.compareTo(threshold) < 0) { throw new RuntimeException("未满足满减金额"); } // 返回优惠金额,这一步在订单金额扣减前调用 return userCoupon.getAmount(); }改动下单逻辑时,要在订单主表金额计算的地方插入优惠扣减,同时把优惠券标记为已用。需要注意这两步必须在同一个事务里完成,否则可能出现「钱扣了但券没核销」的脏数据。我给这个模块加一个幂等判断:用 Redis 存一个coupon_used_加用户 ID 的 key,核销前检查是否存在,避免并发环境下同一张券被用两次。
这个改造做完之后,最好验证两条路径:一是领券后下单金额变正确,二是券用完之后同一张券再次使用报「已使用」异常。尝试在已完成的订单上再做一次退款,这时候你会感受到快照金额带来的好处——订单表里存的是原金额和实付金额两个字段,退款时退回实付金额,整体逻辑非常顺。当初我写完这个项目之后又花了两天时间把优惠券功能加上了,虽然代码量不大,但对「订单金额从哪来、怎么算、存哪份」这些问题的理解彻底清晰了。从那以后我每次拿到新项目,都会把「金额是否做了快照」「核心状态流转有没有幂等保护」「缓存有没有设置过期时间」这三个点当默认检查项,全部过一遍才敢往上叠新功能。希望帮到你。
本文还有配套的精品资源,点击获取