如果你正在准备计算机毕业设计,或者想用一套经典技术栈完整走一遍前后端开发,那“Spring Boot 早餐预订平台 App”这个题目值得认真做。它看起来只是手机端点早餐的应用,实际覆盖了用户认证、商户管理、商品库存、订单状态流转、支付回调、消息通知等后端核心模块,业务链路完整,非常适合拿来当毕业设计项目,也可以作为面试项目写在简历上。
这类项目的定位很明确:后端以 Spring Boot 框架为核心,前端以一个移动端 App 为载体,实现从“用户选早餐”到“商家出餐”的完整闭环。难度不高不低,刚好够得着本科毕设要求,又不至于像纯管理系统那样没有区分度。这篇文章我按实际开发顺序来拆:先讲需求和建模,再讲数据库设计,然后带出核心代码,最后把我见过的高频问题一起列出来。尽量让照着做的人少走弯路。
1. 项目概述与核心需求拆解
1.1 早餐预订这个场景,核心痛点到底是什么
早餐和平时的外卖不太一样,它的高峰期非常集中,基本就是早上七点到九点。这个时间段里,用户赶着上班上学,商家后厨同时面对到店顾客和外卖订单,很容易出现三种情况:门店排队太长、热门早餐售罄、出餐后放凉没人取。
早餐预订平台解决的就是“预订”两个字:用户在前一天晚上或者当天早上下单,商家根据订单提前备餐,用户到店即取或者等配送。也就是说,它不是一个即时外卖平台,而是一个预约制点餐系统。很多同学做这个题目时,直接把普通外卖系统的逻辑搬过来,忽略了“预订”属性,答辩时被问“营业时间怎么处理”“超时订单怎么取消”就容易卡壳。
从产品角色来看,系统至少要有三类使用方:
- 普通用户:注册登录、浏览店铺、加入购物车、下单支付、查看订单、评价。
- 商家:维护店铺信息、管理商品、接单、出餐、查看订单统计。
- 平台管理员:审核店铺、管理用户、管理商品分类、查看全部订单。
如果把配送员也算进去,还要再加一个配送端。但毕设项目建议把“配送”简化掉,只做“到店自取”和“商家自行配送”两种方式,否则角色一多,代码量和文档量都会翻倍。
1.2 功能模块与角色权限怎么划分才清晰
我建议把功能划分成四个端来看:用户端、商家端、管理端、后端支撑模块。前端 App 主要面向用户端,商家和管理员可以用同一个 App 的不同角色入口,也可以用 Web 管理后台。这里有一个很常见的做法:一份后端代码,同时支撑 App 和 Web 管理端,只需要在接口层面做角色权限控制。
| 模块 | 功能点 | 主要角色 |
|---|---|---|
| 账户模块 | 手机号注册、验证码登录、密码登录、Token 管理 | 用户/商家/管理员 |
| 店铺模块 | 店铺列表、店铺详情、营业时间、起送/自取规则 | 用户、商家 |
| 商品模块 | 商品分类、商品列表、商品上下架、库存管理 | 用户、商家 |
| 购物车模块 | 加入购物车、修改数量、清空购物车 | 用户 |
| 订单模块 | 下单、取消、支付、接单、出餐、完成、评价 | 全部 |
| 支付模块 | 模拟支付、支付回调、超时关闭 | 用户、系统 |
| 消息模块 | 订单状态变更通知、取餐提醒 | 系统 |
权限控制不需要做得太重,一张用户表里加一个role字段,用户是 0,商家是 1,管理员是 2,后端用拦截器校验登录态,再用注解或者方法判断角色就够了。过度设计 RBAC 权限模型放在这个项目里性价比不高。
1.3 技术选型:为什么建议用 Spring Boot + Redis 这套组合
技术选型是毕业设计文档里必定会写到的一节,也是答辩老师最喜欢问的地方。这里的组合建议是:
- 后端:Spring Boot 2.7.x + MyBatis-Plus 3.5.x + MySQL 8.0
- 缓存与验证码:Redis
- 认证:JWT
- App 端:Android 原生(Java/Kotlin)或 uni-app
- 构建工具:Maven
选择 Spring Boot 的理由很直接:自动配置能省掉大量 XML,内置 Tomcat 打 jar 包就能跑,Spring 生态太成熟了,遇到问题几乎都能搜到解决方案。MyBatis-Plus 能帮你把单表 CRUD 的 SQL 省掉,手写 SQL 只留给复杂查询。MySQL 做持久化,Redis 承担热点商品缓存、验证码存储、token 黑名单、购物车临时存储这些事。
App 端我多说一句。如果你 Android 基础好,原生开发更稳,毕竟有些学校要求“App”必须能装上 Android 手机跑。如果时间紧,uni-app 是更现实的选择,一套代码同时出 Android 和 iOS 包,而且和后端接口对接方式与普通 web 请求没有本质区别。不管是哪种,后端接口设计才是重头戏,App 只是壳。
2. 系统设计与数据库建模:把业务变成表结构
2.1 核心表字段设计
数据库是这一类业务系统的地基。我的建议是不要建太多表,但关键表一定要完整。通常需要这几张核心表:用户表、店铺表、商品表、购物车表、订单表、订单明细表、收货地址表、支付记录表、评价表。
我只挑几个容易遗漏的地方详细说。
用户表注意字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| phone | varchar(20) | 手机号,唯一索引 |
| password | varchar(100) | BCrypt 加密后的密码 |
| nickname | varchar(50) | 昵称 |
| avatar | varchar(255) | 头像地址 |
| role | tinyint | 0用户 1商家 2管理员 |
| status | tinyint | 0禁用 1正常 |
商品表注意字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| shop_id | bigint | 所属店铺 |
| category_id | bigint | 分类 |
| name | varchar(100) | 商品名 |
| image | varchar(255) | 商品图 |
| price | decimal(10,2) | 单价 |
| stock | int | 库存 |
| sales | int | 销量 |
| status | tinyint | 0下架 1上架 |
订单表不要直接用order做表名,因为order在 MySQL 里是保留关键字,很容易在写 SQL 时踩坑。最稳妥的做法是叫orders:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(32) | 业务订单号,唯一 |
| user_id | bigint | 下单用户 |
| shop_id | bigint | 店铺 |
| total_amount | decimal(10,2) | 订单总金额 |
| pay_amount | decimal(10,2) | 实付金额 |
| status | tinyint | 订单状态 |
| take_type | tinyint | 0自取 1配送 |
| pickup_time | datetime | 期望取餐时间 |
| address_id | bigint | 配送地址ID |
| remark | varchar(255) | 备注 |
| pay_time | datetime | 支付时间 |
| cancel_time | datetime | 取消时间 |
| finish_time | datetime | 完成时间 |
订单明细表要特别重视“快照”字段。用户下单后商品名称、价格可能被商家修改,历史订单不能跟着变,所以在order_item表里要存product_name、product_image、price,而不是只存product_id。这是很多毕设容易忽略的点,答辩时提出来反而是加分项。
2.2 订单状态机与业务流程设计
订单状态是整个项目里最核心的业务线。我见过不少同学只用一个“状态”字段,代码里到处判断数字,最后自己都分不清。正确的做法是先画出状态机,再写代码。
这里给出一个实用状态定义:
| 状态码 | 状态含义 | 可操作动作 |
|---|---|---|
| 0 | 待支付 | 取消订单、支付 |
| 1 | 已支付/待接单 | 商家接单 |
| 2 | 备餐中 | 商家出餐 |
| 3 | 待取餐/配送中 | 用户确认取餐/收货 |
| 4 | 已完成 | 用户评价 |
| 5 | 已取消 | 无 |
| 6 | 退款中 | 商家/管理员处理 |
流程是一条很清晰的链路:
用户创建订单(待支付) → 支付成功(已支付/待接单) → 商家接单(备餐中) → 出餐(待取餐/配送中) → 用户取餐或收货(已完成) → 评价。
两个容易被问的细节是“超时取消”和“库存释放”。待支付订单如果 15 分钟未支付,系统要自动取消并恢复商品库存。实现方案有三种:定时任务扫表、RabbitMQ 延迟队列、Redisson 延迟队列。毕设阶段用定时任务扫表最简单,虽然不够优雅,但你完全能在答辩时讲清楚原理,再用消息队列优化作为未来展望。
2.3 接口设计规范与 App 端的对接约定
后端接口直接决定 App 端开发是否顺畅。建议所有接口都采用 RESTful 风格,统一返回结构,统一错误码。
统一返回结构可以先写一个Result<T>:
@Data public class Result<T> { private Integer code; private String message; private T data; public Result(Integer code, String message, T data) { this.code = code; this.message = message; this.data = data; } public static <T> Result<T> ok(T data) { return new Result<>(200, "success", data); } public static <T> Result<T> error(Integer code, String message) { return new Result<>(code, message, null); } }错误码规则保持简单:200 成功,400 参数错误,401 未登录,403 无权限,500 服务器异常。不要搞太复杂的错误码体系,毕设阶段够用就行。
核心接口列表建议如下:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/user/register | 注册 |
| POST | /api/user/login | 登录,返回 token |
| GET | /api/shop/list | 店铺列表 |
| GET | /api/product/list | 某店铺商品列表 |
| POST | /api/cart/add | 加入购物车 |
| POST | /api/order/create | 创建订单 |
| POST | /api/order/pay | 订单支付(模拟) |
| POST | /api/pay/notify | 支付回调 |
| GET | /api/order/detail | 订单详情 |
| POST | /api/order/cancel | 取消订单 |
| POST | /api/shop/order/accept | 商家接单 |
| POST | /api/shop/order/ready | 商家出餐 |
App 端在对接时只需要记住两件事:登录后把 token 保存起来,每次请求放到 Authorization 请求头里;后端返回的 code 不等于 200 时,统一弹出错误提示。接口设计阶段越规范,前后端联调就越省心。
3. 核心模块实现:从登录到下单
3.1 用户认证与 JWT Token 接入
Spring Boot 里做登录认证,最常用的方案是 JWT + 拦截器。用户登录成功后,后端生成一个 token 返回给 App,App 后续请求带上这个 token,后端通过拦截器解析 token 拿到用户身份。
先引入 JWT 依赖:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>JWT 工具类可以这样写:
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private Long expire; public String createToken(Long userId, String phone) { return Jwts.builder() .claim("userId", userId) .claim("phone", phone) .setExpiration(new Date(System.currentTimeMillis() + expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }然后写一个拦截器,统一处理登录状态:
@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = jwtUtil.parseToken(token); UserContext.set(claims.get("userId", Long.class)); return true; } catch (Exception e) { // 解析失败统一按未登录处理 } } response.setStatus(401); return false; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }这里用UserContext做 ThreadLocal 保存当前登录用户,后面所有 Service 里直接UserContext.get()就能拿到用户 ID,非常方便。
有一个细节要提醒:注册接口和登录接口不能走拦截器,否则会变成死循环。在拦截器注册时用excludePathPatterns排除即可。
密码存储不要用明文。Spring Security 里抽出一个BCryptPasswordEncoder,注册时加密,登录时校验。在 pom 里引入spring-security-crypto即可,不需要引入完整 Spring Security,避免带来一堆默认过滤器干扰接口。
3.2 商品浏览、缓存与购物车
商品列表是 App 打开后最先看到的页面,要做好分页。用 MyBatis-Plus 的Page对象非常方便:
Page<Product> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getShopId, shopId) .eq(Product::getStatus, 1) .orderByDesc(Product::getSales); productMapper.selectPage(page, wrapper);早餐商品的特点是“热销商品稳定且访问集中”。如果高峰期所有请求都打数据库,MySQL 压力会很大。这里可以把热销商品列表缓存到 Redis。我在实际开发中用的是一个简单方案:系统启动后,把每个店铺销量前十的商品统一放到 Redis 的 List 里,App 打开店铺时先查缓存,缓存没有再看数据库。
注意缓存穿透。如果有人反复查询一个不存在的店铺或商品,每次都打到数据库,可以考虑对空结果做短时间缓存,比如shop:info:{id}不存在时缓存一个空值,过期时间设 60 秒。这个点写到论文里也很有亮点。
购物车我建议不要做成纯 App 本地存储。原因很简单:后端需要在下单时统一校验商品状态和库存,如果购物车数据只存在手机本地,用户看到的加购数量和实时库存可能不一致。更稳妥的做法是后端维护一张cart表,App 端只是展示购物车数据。加购接口接收到请求后,后端执行:
- 查询商品是否存在、是否上架
- 查询商品库存
- 如果购物车已有该商品,数量累加
- 否则插入一条购物车记录
用户真正下单时,后端再重新遍历购物车数据,逐项校验。所有判断以后端为准,App 端只是展示。
3.3 下单、库存控制与支付回调
下单是整个系统最考验代码质量的地方,核心难点有两个:事务一致性和库存不能超卖。
先说一下正常流程:
校验店铺营业状态 查询购物车项 校验商品上架状态和库存 扣减库存 创建订单(状态:待支付) 创建订单明细 清空购物车 返回订单号在 Spring Boot 中,用@Transactional把整个方法包起来。库存扣减不能用“先查库存再 update”的方式,那会出现并发超卖问题。最简单的正确写法是条件更新:
@Update("UPDATE product SET stock = stock - #{count} " + "WHERE id = #{productId} AND stock >= #{count}") int deductStock(@Param("productId") Long productId, @Param("count") Integer count);stock >= #{count}放在 where 条件里,数据库行锁会保证并发情况下只有一个请求能扣成功,返回影响行数为 0 就说明库存不足。这段逻辑可以说是整个订单功能的核心,答辩时老师问“并发下库存怎么控制”,能答出这一句就够了。
下单主流程的简化代码:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(CreateOrderDTO dto, Long userId) { List<CartItemDTO> items = dto.getItems(); BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> orderItems = new ArrayList<>(); for (CartItemDTO item : items) { int rows = productMapper.deductStock(item.getProductId(), item.getQuantity()); if (rows == 0) { throw new BizException("商品库存不足"); } Product product = productMapper.selectById(item.getProductId()); OrderItem orderItem = new OrderItem(); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getName()); orderItem.setProductImage(product.getImage()); orderItem.setPrice(product.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItem.setSubtotal(product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); totalAmount = totalAmount.add(orderItem.getSubtotal()); orderItems.add(orderItem); } String orderNo = generateOrderNo(); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setShopId(dto.getShopId()); order.setTotalAmount(totalAmount); order.setPayAmount(totalAmount); order.setStatus(0); order.setTakeType(dto.getTakeType()); // ... 省略其他字段 orderMapper.insert(order); for (OrderItem orderItem : orderItems) { orderItem.setOrderId(order.getId()); orderItemMapper.insert(orderItem); } cartMapper.deleteByUserIdAndShopId(userId, dto.getShopId()); return OrderVO.from(order); }支付回调这里再说一句。毕设阶段不需要真的接入微信支付,因为申请商户号和开发文档流程复杂,演示环境也不一定具备。一般做法是自己写一个“模拟支付接口”,App 端点击支付,后端直接修改订单状态为已支付,同时保留一个payment表记录。想做得更真实,可以模拟一个/api/pay/notify回调入口,由模拟支付接口触发。
回调处理必须做到幂等:同样的回调通知可能来多次,不能把已支付订单重复改成已支付。最简单的写法是带状态条件的更新:
@Transactional(rollbackFor = Exception.class) public boolean handlePayNotify(String orderNo, BigDecimal amount) { int rows = orderMapper.updateStatusByCondition(orderNo, 0, 1); return rows == 1; }只有当前状态是 0(待支付)时,才能更新成 1(已支付)。如果更新行数为 0,说明订单已经被处理过,直接返回成功,不重复处理。这个判断看似简单,但能避免一大类脏数据问题。
3.4 订单状态变更的消息推送
用户下单后,最关心的是“商家接单没有”“早餐好了没有”。App 端要实时拿到订单状态变化,可以用两种方式:
- WebSocket:后端主动推送状态到 App。
- 轮询:App 每隔几秒调一次查询接口。
毕设演示时,我建议做 WebSocket 推送作为亮点,同时保留轮询兜底。因为 WebSocket 放在项目里既体现技术深度,又能解释“实时性”需求。
后端可以用 Spring 自带的 WebSocket 支持,定义一个/ws/order/{userId}端点。当商家执行“接单”“出餐”操作时,订单状态变更后发布一个 Spring 事件:
public class OrderStatusEvent extends ApplicationEvent { private Long userId; private String message; // 构造方法、getter/setter }Service 里发布事件:
applicationEventPublisher.publishEvent(new OrderStatusEvent(order.getUserId(), "您的订单已开始备餐"));监听器拿到事件后,通过SimpMessagingTemplate把消息推送给对应用户。App 端订阅自己的用户 ID 通道,就能直接弹出一条“取餐提醒”。
WebSocket 如果调试起来麻烦,可以先做轮询。轮询接口本身已经写好,只是多了几行定时器代码。在论文里把 WebSocket 方案写清楚,演示时轮询只要能正常展示状态流转,问题不大。
4. 开发部署与联调实践
4.1 后端工程结构与关键配置
良好的工程结构能省掉很多后期麻烦。我建议按这种方式组织包结构:
src/main/java/com/example/breakfast ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // MyBatis-Plus Mapper ├── entity // 数据库实体 ├── dto // 入参对象 ├── vo // 返回对象 ├── config // 配置类 ├── common // 统一返回、异常、常量 └── utils // 工具类对应的application.yml核心配置:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/breakfast?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true jwt: secret: your-secret-key-change-in-production expire: 604800000 # 7天,单位毫秒有两个配置细节容易踩坑。第一个是 MySQL 8 以上必须加serverTimezone=Asia/Shanghai,否则启动时报时区错误。第二个是 JWT 密钥在真实环境要足够长,不能随便写一个短字符串,否则容易被猜解,毕设里能解释清楚就行。
异常处理也要提前做好。Controller 层不要到处 try-catch,用一个全局异常处理器统一拦截业务异常和系统异常,返回统一格式的Result。这样做的好处是 App 端接到的任何错误响应都是同一个结构,不会出现“有的接口返回 JSON,有的接口返回 HTML”的尴尬情况。
4.2 App 端对接后端的工程实践
App 开发过程中,前端同学和后端同学最容易在联调阶段吵架。核心问题通常不是接口逻辑,而是网络地址、超时设置、Token 传递、图片加载这几件事。
Android 原生如果使用 Retrofit,可以这样统一处理 token:
OkHttpClient client = new OkHttpClient.Builder() .addInterceptor(chain -> { Request original = chain.request(); Request.Builder requestBuilder = original.newBuilder(); String token = TokenManager.getToken(); if (token != null) { requestBuilder.header("Authorization", "Bearer " + token); } return chain.proceed(requestBuilder.build()); }) .connectTimeout(10, TimeUnit.SECONDS) .build(); Retrofit retrofit = new Retrofit.Builder() .baseUrl(BuildConfig.BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .client(client) .build();注意baseUrl不要写死成http://localhost:8080/。Android 模拟器里访问宿主机要用http://10.0.2.2:8080/,真机调试要用电脑的局域网 IP。很多同学卡在联调这一步,其实就是地址没配对。
如果用的是 uni-app,封装一个带 token 的请求方法:
const request = (url, method = 'GET', data = {}) => { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + url, method, data, header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') }, success: (res) => resolve(res.data), fail: reject }) }) }还有一个经常被忽略的问题:跨域。App 端原生请求没有跨域概念,但如果你在 App 里内嵌了 Web 页面,或者调试时用浏览器访问后端接口,就会碰到 CORS。解决方法是后端写一个 WebMvcConfigurer,在addCorsMappings里允许所有来源和方法。毕设阶段可以把 allowedOrigins 放开,生产环境再收紧。
图片上传建议不要直接传 Base64。手机端选完图片后,后端提供一个上传接口,把图片存到本地静态目录或对象存储,返回一个可访问的 URL。存本地时要注意配置静态资源映射,否则上传成功但访问不到,App 端显示裂图。
4.3 部署演示与验收检查清单
项目快完成时,先把演示流程定下来。我见过太多同学开发时一切正常,一到答辩现场,数据库没启动、Redis 没启动、端口被占用,整个演示翻车。提前把流程走三遍,比临时加班修 bug 有用得多。
部署环节其实比较简单:
mvn clean package -DskipTests java -jar target/breakfast-0.0.1-SNAPSHOT.jar如果要用 Nginx 部署,把后端的 8080 端口反向代理到域名或局域网地址,前端打包后的静态文件放到 Nginx 的 html 目录里,访问一个路径就能同时代理前后端。
演示前的检查清单我建议包含这些:
| 检查项 | 预期结果 |
|---|---|
| MySQL 和 Redis 服务已启动 | 后端启动日志无连接报错 |
| 注册新用户 | 能收到验证码并成功入库 |
| 登录 | 返回 token,未登录请求返回 401 |
| 浏览商品列表 | 列表有数据且图片正常加载 |
| 加入购物车 | 商品数量累计正确 |
| 下单 | 库存扣减,生成订单号 |
| 支付 | 订单状态从待支付变成已支付 |
| 商家接单 | 用户 App 收到状态变更通知 |
| 出餐 | 订单状态变为待取餐 |
| 超时订单取消 | 超过 15 分钟自动关闭且库存恢复 |
准备两份测试数据。一份是白天演示用的,包含三到五家店铺、每个店铺十来个商品,库存调大,避免演示到一半某个商品没库存。另一份是答辩前重置数据库用的,保证任何时候重新运行都能恢复到初始状态。
5. 常见问题与避坑指南
5.1 高频问题排查速查表
把这些年帮人调试同类项目时遇到的典型问题整理一下,遇到相似报错可以照着查。
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 后端启动报端口被占用 | 8080 被其他进程占用 | 改server.port,或查杀占用进程 |
| 连接数据库报时区错误 | MySQL 连接串缺少时区参数 | 加serverTimezone=Asia/Shanghai |
| 注册登录接口返回 401 | 拦截器没有排除公开接口 | 拦截器注册时排除/api/user/login等路径 |
| 登录成功后请求还是 401 | token 没放在请求头,或 token 过期 | 检查 App 端 Authorization 头和过期时间 |
| 商品图片加载失败 | 图片 URL 不可访问 / 静态资源未映射 | 确认映射路径,用完整 URL 测试 |
| 库存偶尔变成负数 | 扣减时没有加stock >= count条件 | 使用条件更新 SQL |
| 支付回调处理完又重复修改订单 | 回调幂等没做 | 用UPDATE ... WHERE status = 0 |
| Redis 缓存数据丢失 | 没配置持久化 / 键过期 | 明确键过期策略,热数据过期时间设长一点 |
| App 模拟器请求不到后端 | 模拟器与宿主机地址原因 | 模拟器用10.0.2.2,真机用局域网 IP |
| 下单成功后购物车没有清空 | 事务中清空购物车代码未执行 | 检查事务边界,确认@Transactional生效 |
还有一个非常隐蔽的问题:MyBatis-Plus 的实体字段用了is开头,比如isDefault,生成 SQL 时可能映射成default导致语法错误。解决方法是字段改名,或者在注解里指定列名。
5.2 老开发者的几点真心建议
如果让我给准备做这个题目的同学总结一句,我会说:把“下单到出餐”这条链路做扎实,比堆一堆花哨页面有用得多。很多同类项目最后看起来页面不少,但订单流程断在“支付回调”或“库存恢复”上,反而暴露基本功不扎实。
具体建议有几点:
第一,先写接口文档再动手写代码。不需要用太重的工具,直接 Markdown 表格也行。把每个接口的入参、出参、状态码固定下来,前后端并行开发时效率能提高不少,论文里的接口设计章节也有了现成素材。
第二,尽量让项目有一点“记忆点”。Spring Boot 的 CRUD 本身并不难,难的是你如何解释为什么用 Redis 缓存、为什么用条件更新防超卖、为什么用状态机约束订单流转。这三个点只要能自圆其说,答辩基本稳了。
第三,不要急着上微服务。有同学觉得注册中心、网关、熔断这些名词很高级,就往项目里塞。但以早餐预订平台的业务体量,单体架构完全足够,微服务只会增加部署和演示的不确定性。毕设评分更看重业务完整度,而不是技术名词数量。
最后再分享一个小技巧:答辩前的演示环境,可以用脚本一键初始化数据库和缓存,比如把 SQL 文件放在项目里,启动时自动执行,或者写一个init.sql手动导入。这样无论现场数据被改成什么样,都能快速恢复到可演示状态。这类细节平时不起眼,真正到关键场合能救命。