1. 项目整体拆解:为什么校园食堂需要一套订餐系统
1.1 毕设选题的价值与定位
校园食堂订餐系统,这个选题在Java方向的毕业设计里属于"标准但绝不落伍"的类型。你仔细看每年各大高校的毕设选题库,Java Web方向翻来覆去就是商城、博客、预约、订餐这几大类。但食堂订餐和普通商城有个关键区别——它有真实的场景痛点和明确的使用人群,这就让项目天然具备完整业务闭环:用户端点餐、食堂端接单、管理员统一管控。
从毕设答辩的角度来看,这个选题最大的优势是"麻雀虽小、五脏俱全"。一个完整的订餐系统需要覆盖前端展示、用户认证、订单管理、支付流程(或者替代方案)、后台管理等所有Web开发的经典环节。更难得的是,食堂订餐有真实的并发场景——中午11:30下课高峰期,数千名学生同时打开系统下单,这就逼着你去考虑并发、库存、超卖这些实际工程问题。
从就业角度来说,这个项目的技术栈(Spring Boot + MyBatis + MySQL + Redis)和绝大多数中小型公司的业务后端是直接对齐的。你在简历上写"独立完成校园食堂订餐系统",面试官能立刻在脑内勾勒出这个项目的技术画像,提问路径非常清晰:JWT认证怎么做、Redis缓存了什么、订单的分布式锁怎么实现。相比"校园二手交易平台""个人博客系统"这种已经被写滥了的项目,食堂订餐在业务复杂度上高半个台阶,又不会像秒杀系统那样让人一眼看出是培训班流水线产品。
1.2 核心角色与业务闭环
一个合格的食堂订餐系统,至少要包含三类角色,这三类角色对应的用户故事必须完整:
学生/普通用户:注册登录、浏览食堂和菜品、加入购物车、下单、取消订单、查看历史订单、评价菜品。这是系统的前台主体,用户量最大,日活最高,交互最频繁。
食堂商家/窗口:管理自家菜品(上架、下架、改价)、查看订单、接单/出餐、查看营业统计。这里的"食堂商家"可以按窗口划分,也可以按食堂楼层划分,具体取决于你数据库设计时的粒度。
系统管理员:用户管理(封禁/解封)、食堂审核、订单总览、数据统计(日订单量、销售额、热门菜品排行)、公告发布。
这三类角色的业务闭环很有意思,值得单独拿出来讲。学生的下单动作会触发一系列连锁反应:购物车创建订单 → 锁定菜品库存 → 生成待支付订单 → 支付成功后推送给对应食堂窗口 → 窗口接单后更新订单状态 → 用户端同步看到"制作中"→ 出餐后用户确认收货 → 订单完成。这一整条链路完整走下来,前端的交互体验和后端的状态机设计都得到了充分锻炼。
1.3 功能模块清单
按模块化思路拆解,这个系统的功能结构可以整理成一张总表:
| 模块 | 功能点 | 说明 |
|---|---|---|
| 用户模块 | 注册、登录、个人信息、地址管理 | 手机号+验证码或账号密码登录,JWT鉴权 |
| 食堂模块 | 食堂列表、食堂详情、窗口管理 | 管理员可增删改查食堂信息 |
| 菜品模块 | 菜品列表、分类筛选、关键词搜索、菜品详情 | 支持图片上传,库存字段控制 |
| 购物车模块 | 加入购物车、修改数量、删除、清空 | 可选存Redis或MySQL,数据一致性要注意 |
| 订单模块 | 创建订单、支付(模拟)、取消、确认收货 | 核心模块,状态机设计是关键 |
| 评价模块 | 评价菜品、评分、评价列表 | 关联订单,只能评价已完成的订单 |
| 管理后台 | 用户管理、食堂管理、订单管理、数据统计 | 角色权限控制,区分管理员与普通用户 |
| 公告模块 | 通知发布与展示 | 首页轮播或公告栏 |
这套功能列表如果全部做完,代码量大致在5000~8000行Java代码之间(不含前端),配合前端页面,总代码量基本满足毕设的体量要求。如果觉得工作量大,可以把评价模块砍掉,或者把购物车简化成"直接下单",都不会影响核心链路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与架构设计:Spring Boot为核心的原因
2.1 为什么选择Spring Boot,而不是Servlet或SSH
这里要先解决一个很多同学会问的问题:既然毕设是教学性质的,为什么不选最基础的Servlet + JSP,或者老牌的SSH(Struts2 + Spring + Hibernate)?
我的观点很直接:毕设既要兼顾教学完整性,也要考虑就业匹配度。 Spring Boot是当前Java后端开发的绝对主流,几乎任何一家中小型公司的Java岗位都要求掌握Spring Boot。如果你在2025年做Java毕设还在用Servlet手写接口,那就和用织布机证明自己懂纺织一样,方向没错,但明显偏离了实际生产环境。
Spring Boot本身解决的核心痛点是Spring早期版本繁重的XML配置。在没有Spring Boot的年代,你想启动一个Spring项目,要写一大堆applicationContext.xml、spring-mvc.xml,配置数据源、配置事务管理器、配置视图解析器。Spring Boot通过"约定优于配置"的理念,把这些全部自动化了,你启动一个Web项目只需要一个注解和几行配置。
从本项目角度来看,Spring Boot带来的实际收益有三点:第一,内嵌Tomcat,项目直接以jar包形式运行,部署极其简单,演示的时候一条java -jar命令搞定,不用在服务器上装Tomcat再丢war包;第二,Spring Boot的自动配置机制让MyBatis、Redis、JWT这些组件的集成只需要几个依赖和一小段配置,毕设阶段能把更多精力放在业务逻辑而非配置地狱里;第三,Spring Boot的生态非常成熟,遇到任何问题都能搜到解决方案,这对独立做毕设的同学们来说太太太重要了。
2.2 持久层选型:MyBatis还是MyBatis-Plus
持久层框架的选择上,我不推荐纯JPA/Hibernate,也不推荐原生JDBC,MyBatis是最合适的选择,而且建议直接用MyBatis-Plus。
MyBatis的核心优势是SQL自由控制。食堂订餐系统里有很多复杂的查询场景:多表关联查询(订单表JOIN订单明细表JOIN菜品表)、按时间范围分组统计、动态条件拼接(菜品名称模糊搜索 + 分类筛选 + 价格区间)。这些场景用MyBatis的XML映射文件写SQL非常直观,调试起来也方便,SQL写得对不对一看便知。
MyBatis-Plus则是在MyBatis之上做了一层增强,提供通用的CRUD方法,基础的增删改查连SQL都不用写,直接调用selectById()、insert()这些兰姆达表达式方法。比如菜品管理里的列表分页,用MyBatis-Plus可以这么写:
java复制IPage<Dish> page = new Page<>(current, size);
LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Dish::getCategoryId, categoryId)
.like(StringUtils.hasText(keyword), Dish::getName, keyword)
.orderByDesc(Dish::getSales);
dishMapper.selectPage(page, queryWrapper);
return page;
这段代码如果用原生MyBatis,你得写XML、写<where>动态标签、写<if>判断,还要手动拼分页SQL。MyBatis-Plus把这个工作量压缩到几行,这就是效率提升。
2.3 认证方案:JWT还是Session
登录认证是毕设答辩的高频问题,必须认真对待。传统的Session方案在单体架构下完全可用,但存在几个让人不舒服的点:Session是内存态,服务重启用户就掉线;SessionId存在Cookie里,需要处理跨域携带Cookie的繁琐配置;最关键的是,Session会话状态会让"无状态API"的优势消失。
本项目我推荐使用JWT(JSON Web Token)。JWT的核心原理是:用户登录成功后,服务器生成一个经过签名的Token字符串,客户端保存这个Token,每次请求时放在请求头里带给服务器,服务器验签通过后即认为请求合法。
具体逻辑是这样的:
java复制// 登录成功后生成Token
String token = Jwts.builder()
.setSubject(user.getId().toString())
.claim("username", user.getUsername())
.claim("role", user.getRole())
.setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
然后在拦截器里解析Token,把用户信息放入ThreadLocal,供后续业务使用。这里有个容易被忽略的细节:拦截器要在配置类里注册并且排除登录接口和静态资源的路径,不然会出现"明明登录了却还提示未登录"的诡异问题。
JWT方案的优点是无状态、跨域友好、天然适合前后端分离架构;缺点是Token无法在服务端强制失效(除非引入黑名单机制)。对毕设而言这个缺点完全可接受,答辩老师问到这个点,你如实说明并给出"引入Redis黑名单"的改进方案,反而能加分。
2.4 数据库设计思路
食堂订餐系统的核心表至少有这几张:
用户表(user):id、username、password(加密存储)、phone、avatar、role(0学生/1商家/2管理员)、status、create_time。
食堂表(canteen):id、name、location、image、description、status(营业/休业)、create_time。
菜品表(dish):id、canteen_id(关联食堂)、name、image、price、description、category_id(菜品分类)、stock(库存)、sales(销量)、status(上架/下架)。这里我把分类单独拆了一张category表,也可以在菜品表里直接存字符串分类名,看你的设计偏好。
订单表(orders):id、order_no(订单编号,必须唯一)、user_id、canteen_id、total_amount、status(0待支付/1待接单/2制作中/3待取餐/4已完成/5已取消)、remark、create_time、pay_time、finish_time。
订单明细表(order_detail):id、order_id、dish_id、dish_name(冗余字段,防止菜品被删后订单无记录)、price、quantity、subtotal。
购物车表(cart):id、user_id、dish_id、quantity、create_time。
评价表(review):id、user_id、order_id、dish_id(可选)、rating、content、create_time。
数据库设计时最核心的原则是订单明细必须冗余菜品快照字段。什么意思?用户在A时刻下单了一份"红烧肉盖饭"18元,到了B时刻食堂把这道菜改成了20元,甚至下架了。如果订单明细表里只存dish_id,用户查看历史订单时,菜品名和价格就可能会对不上。所以在order_detail表里存上dish_name和price这两个冗余字段,哪怕菜品后续变化,用户的订单记录依然准确。这个设计细节在答辩时主动讲出来,是明显的加分项。
3. 核心业务实现:从下单支付到出餐全流程
3.1 食堂与菜品信息管理
食堂管理这个模块相对简单,属于基础CRUD。管理员登录后可以新增食堂、上传食堂照片、设置营业状态。这里有个值得做的细节:食堂的"营业状态"字段应该和订单流程联动——如果食堂是休业状态,用户在前端就应该看不到这家食堂,或者提示"暂未营业"。
菜品管理需要重点处理的是图片上传。Spring Boot里处理图片上传有固定的套路:接收MultipartFile → 校验文件类型和大小 → 重命名文件防止路径穿越 → 存储到本地磁盘或OSS → 返回可访问的URL存入数据库。
java复制@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty() || file.getSize() > 5 * 1024 * 1024) {
return Result.error("文件为空或超过5MB");
}
String originalFilename = file.getOriginalFilename();
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
if (!Arrays.asList(".jpg", ".jpeg", ".png", ".webp").contains(suffix)) {
return Result.error("不支持的图片格式");
}
String fileName = UUID.randomUUID() + suffix;
// 按日期分目录存储,防止一个文件夹下文件过多
String datePath = LocalDate.now().toString().replace("-", "/");
File dest = new File(ROOT_PATH + datePath + "/" + fileName);
if (!dest.getParentFile().exists()) {
dest.getParentFile().mkdirs();
}
file.transferTo(dest);
return Result.success("/images/" + datePath + "/" + fileName);
}
这里有一个实际操作中很容易踩的坑:如果是前后端分离部署,前端页面和后端接口不在同一个域名下,图片URL就涉及跨域访问问题。最省事的方案是在后端写一个配置类,把本地图片目录映射为静态资源路径,或者直接使用Nginx反向代理图片目录。毕设阶段最简单的方式是用Spring Boot的WebMvcConfigurer接口:
java复制@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/images/**")
.addResourceHandler("file:" + ROOT_PATH);
}
这样前端直接访问http://localhost:8080/images/2025/06/01/uuid.jpg就能看到图片。
3.2 购物车与下单逻辑
购物车的设计有一个选型问题:存Redis还是存MySQL数据库表?
存Redis的优势是读写快、天然适合临时性数据、过期自动清除;缺点是需要维护Redis服务,且存在数据持久化风险。存MySQL的表方案简单可靠、方便查询,但每次加入购物车都要落库,性能上略逊。毕设项目选MySQL表方案完全够用,操作直观、答辩好讲。
加入购物车的核心逻辑比较简单:根据user_id和dish_id查是否已存在购物车记录,存在就累加数量,不存在就新增记录。需要注意的细节是:每次加入购物车时要校验菜品状态(不能加入已下架的菜品),并且要校验数量不能超过库存。
下单流程是整个系统的核心,我会用一张状态流转图来说明(文字描述形式):
- 用户从购物车勾选商品并提交订单
- 后端收到请求后校验用户登录状态、校验菜品库存是否充足
- 创建一个
orders记录,状态为"待支付",生成唯一订单号 - 批量查询购物车,创建对应的
order_detail明细记录 - 扣减库存(这一步要仔细处理并发问题)
- 清空购物车中已下单的商品
- 前端跳转到支付页面,点击"立即支付"后调用支付接口(本项目模拟支付)
- 支付成功后更新订单状态为"待接单",同时推送给对应食堂商家
订单号的生成方式也是一个可讲的亮点。不要用自增主键当订单号,外部用户看到订单号可能会猜测业务量。常用的方案:yyyyMMddHHmmss + 用户ID后四位 + 随机数,或使用UUID去掉横杠后截取一段。我用的是时间戳+随机数的方式:
java复制String orderNo = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
+ String.format("%04d", (int)(Math.random() * 10000));
3.3 库存扣减与超卖问题
这一步是答辩里最容易被追问的技术难点:高并发下如何防止商品超卖?
先解释什么是超卖:假设红烧肉盖浇饭库存只剩1份,同时来了10个用户发起下单请求,如果每个请求都先查询库存(发现还有1份),然后执行扣减(减少到0),最后创建订单,那这10个请求里可能有好几个都通过了库存校验,导致卖出了超过库存的订单。
我用过三种方案,从简单到复杂排列如下:
方案一:乐观锁(版本号/CAS方式)
sql复制UPDATE dish SET stock = stock - 1, version = version + 1
WHERE id = #{dishId} AND stock > 0 AND version = #{version}
如果更新影响行数为0,说明库存不足或者版本号不匹配,下单失败。这种方式实现简单、性能高,缺点是如果并发很大,会有不少请求因为版本号冲突而失败,用户体验略差。
方案二:悲观锁(SELECT FOR UPDATE)
java复制@Transactional
public void createOrder(...) {
Dish dish = dishMapper.selectByIdForUpdate(dishId); // SELECT * FROM dish WHERE id=? FOR UPDATE
if (dish.getStock() < quantity) {
throw new BusinessException("库存不足");
}
dishMapper.reduceStock(dishId, quantity);
// 创建订单...
}
FOR UPDATE会对这条记录加行级锁,其他事务必须等当前事务提交后才能操作同一行,因此保证不会超卖。缺点是锁会阻塞其他请求,吞吐量下降。但毕设场景完全够用,而且这个方案的逻辑最好理解,答辩时容易讲清楚。
方案三:Redis分布式锁
java复制String lockKey = "lock:dish:" + dishId;
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("系统繁忙,请稍后重试");
}
try {
// 查库存、扣库存、创建订单
} finally {
redisTemplate.delete(lockKey);
}
Redis分布式锁能跨实例保证互斥,适合微服务或多实例部署场景。毕设如果引入了Redis,用这个方案会显得技术含量更高。
实操层面我建议这样:代码里用方案二(悲观锁) + 直接在SQL里加stock > 0条件作为兜底。也就是UPDATE dish SET stock = stock - 1 WHERE id = ? AND stock > 0,这条SQL本身就能防止库存扣成负数。即使业务代码里查库存和扣库存之间发生了并发穿插,数据库层面也会拒绝超卖。两层保障,逻辑严密,答辩时讲这个组合拳会非常稳。
3.4 订单状态机的设计
订单状态管理如果不做约束,光靠各处散落的if-else判断,后期维护时一定会出现"状态混乱"的问题。正确做法是定义一个清晰的状态机模型,所有状态流转集中在service层处理。
我的订单状态定义如下:
| 状态值 | 状态含义 | 触发动作 | 允许流转到 |
|---|---|---|---|
| 0 | 待支付 | 用户提交订单 | 1(支付成功)/ 5(超时取消或用户取消) |
| 1 | 待接单 | 用户支付成功 | 2(商家接单)/ 5(商家拒单) |
| 2 | 制作中 | 商家接单 | 3(商家出餐) |
| 3 | 待取餐 | 商家出餐 | 4(用户确认取餐) |
| 4 | 已完成 | 用户取餐 | 无 |
| 5 | 已取消 | 用户取消或商家拒单 | 无 |
每个状态流转在Service层要有对应的状态校验。比如用户端取消订单时,只有状态为0(待支付)的订单才能允许取消;商家拒单时,只有状态为1(待接单)的订单才能拒。可以用一个统一的方法封装状态的变更:
java复制private void changeOrderStatus(String orderNo, Integer fromStatus, Integer toStatus) {
int rows = ordersMapper.updateStatus(orderNo, fromStatus, toStatus);
if (rows == 0) {
throw new BusinessException("订单状态已变更,请刷新页面");
}
}
updateStatus方法里的SQL是UPDATE orders SET status = #{toStatus} WHERE order_no = #{orderNo} AND status = #{fromStatus},利用数据库的行锁保证同一时刻只有一个请求能成功更新状态,这就是所谓的"状态更新的原子性"。这个方法在并发场景下极其有用,两个请求同时操作同一订单时,只有先到的那一个能成功。
3.5 前端页面方案
毕设的前端方案没有统一标准,结合你做这个系统的目的来选择:
如果目标是把前端做得美观好看、演示效果好,选Vue 3 + Element Plus,配合Vite构建,开发效率高,组件库现成。用户端和管理后台共用一套代码,通过路由和权限控制区分页面。
如果本身是纯后端方向、前端基础薄弱,直接用Thymeleaf模板引擎服务端渲染也是完全可行的。Spring Boot对Thymeleaf的支持很成熟,写页面虽然朴素些,但胜在简单直接,不用处理跨域问题,Session管理也更方便。
我在实际开发中用的是Vue 3 + Element Plus + Axios + Pinia的方案。目录结构大致如下:
code复制src/
├── api/ // 封装axios请求
├── router/ // 前端路由
├── store/ // Pinia状态管理
├── views/
│ ├── user/ // 用户端页面
│ │ ├── home.vue // 首页(食堂列表)
│ │ ├── canteen.vue // 食堂详情(菜品列表)
│ │ ├── cart.vue // 购物车
│ │ ├── order-confirm.vue // 订单确认页
│ │ ├── order-list.vue // 订单列表
│ │ └── login.vue // 登录注册页
│ └── admin/ // 管理后台页面
│ ├── dashboard.vue // 数据统计
│ ├── dish-manage.vue // 菜品管理
│ ├── order-manage.vue // 订单管理
│ └── user-manage.vue // 用户管理
└── main.js
Axios的请求拦截器和响应拦截器要优先写好,因为涉及JWT Token的携带和统一的错误处理:
javascript复制// 请求拦截器:携带Token
service.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = 'Bearer ' + token;
}
return config;
});
// 响应拦截器:统一处理错误
service.interceptors.response.use(
response => {
const res = response.data;
if (res.code !== 200) {
ElMessage.error(res.msg);
return Promise.reject(new Error(res.msg));
}
return res;
},
error => {
if (error.response?.status === 401) {
ElMessage.error('登录已过期,请重新登录');
localStorage.removeItem('token');
router.push('/login');
}
return Promise.reject(error);
}
);
这里有一个实操注意点:401拦截处理和登录页跳转之间要防止循环跳转。如果当前页面已经在登录页,再触发router.push('/login')会报"Duplicate navigation"警告。可以在跳转前判断router.currentRoute.value.path !== '/login'。
4. 实操踩坑记录与排查思路
4.1 JDK版本与Maven编译问题
我实操时遇到的最大坑就是JDK版本不匹配。本地用的是JDK 17,但IDEA里Maven配置的编译级别是JDK 8,编译时疯狂报错:
code复制java: 警告: 源发行版 17 需要目标发行版 17
这个问题的根源是IDEA的Java Compiler设置和Maven的pom.xml里java.version配置不一致。解决办法有两种:
第一,统一Maven配置。在pom.xml里明确指定:
xml复制<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
第二,检查IDEA的Settings → Build Tools → Maven → Importing → JDK for importer,确保和项目JDK一致。另外,IDEA的Preferences → Java Compiler → Per-module bytecode version也要改成和目标版本一致。
这个问题在答辩演示现场非常容易翻车,强烈建议在答辩前一天把项目用mvn clean package重新构建一次,确认能打出可运行的jar包。
4.2 图片上传后前端无法访问
图片上传成功后接口返回了URL,但前端用这个URL访问时页面返回404。排查后发现是Spring Boot没有把本地磁盘的图片目录映射成静态资源。
解决方案在3.1节已经提到过,用addResourceHandlers方法映射。这里补充一个细节:Windows和Linux系统的路径拼接逻辑不同。Windows下文件路径用file:D:/upload/images/,Linux下是file:/www/upload/images/,最好在application.yml里配置一个动态路径,不要写死。
yaml复制upload:
path: ${UPLOAD_PATH:./upload/}
4.3 事务失效问题
订单创建方法我加了@Transactional注解,但测试时发现第一个菜品扣减成功后,第二个菜品库存不足抛出异常,第一个菜品的库存回滚了,但订单记录却留在了数据库里。
排查后发现问题出在异常被方法内部捕获了。@Transactional默认只对RuntimeException回滚,如果业务异常被try-catch捕获后没有重新抛出,事务就无法感知异常,自然不会回滚。
正确做法是:事务方法内不做try-catch,或者catch后必须throw new RuntimeException(e)。更规范的做法是自定义业务异常,并设置事务的回滚策略:
java复制@Transactional(rollbackFor = Exception.class)
public void createOrder(...) {
// 业务代码
}
rollbackFor = Exception.class很重要,它能让事务对所有Exception类型回滚,而不是只回滚RuntimeException。这是面试中一个常见考点,恰好也是实操中容易踩的坑。
4.4 时间字段的时区问题
前端提交订单时,后端接收到的create_time字段少了8个小时。原因是Spring Boot默认使用UTC时区,而中国是UTC+8。
最简单的解法是在数据库连接地址上加上时区参数:
code复制jdbc:mysql://localhost:3306/canteen?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
同时在application.yml里设置:
yaml复制spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
这两个配置配合,就能保证数据库存储、接口返回给前端的时间都是北京时间。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
启动报Port 8080 was already in use |
端口被占用 | 换端口或者在application.yml设置server.port |
| 数据库连接失败 | MySQL服务未启动或账号密码错误 | 检查url/username/password,确认MySQL服务和数据库已创建 |
| 前端登录后刷新页面就失效 | JWT存在内存中 | Token存localStorage |
| 菜品的图片不显示 | 静态资源映射未配置 | 参考3.1节的addResourceHandlers配置 |
| 下单提示"库存不足"但数据库有货 | 并发扣减导致锁等待超时 | 检查事务时间,考虑乐观锁 |
Mapper接口报Invalid bound statement |
XML文件没扫描到 | 检查mybatis-plus.mapper-locations配置和XML路径 |
| 接口返回JSON但是中文乱码 | 响应编码不对 | 在application.yml里配置server.servlet.encoding.force=true |
5. 项目答辩准备与经验总结
5.1 答辩时要主动讲的三个技术亮点
毕设答辩的时间通常只有5~10分钟,要在这么短的时间展示项目的技术含量,必须有意识地突出核心亮点。根据我做这个项目的经验,下面三个点是最值得讲的:
第一个亮点:JWT无状态认证方案。 你可以画一条请求链路的图(用文字描述):用户登录成功后,后端签发含用户信息和角色标志的Token;前端请求时放在Authorization头;后端拦截器验签并提取用户信息。对比传统Session方案,强调三个优势:服务端不保存状态、天然支持跨域、适合前后端分离架构。
第二个亮点:订单状态机的设计。 这一点我特别推荐讲,因为大多数学生的项目都是散乱的if-else判断状态,而你的项目用了集中式的状态流转方法changeOrderStatus(orderNo, fromStatus, toStatus),配合数据库的行锁更新,能保证并发场景下订单状态的一致性。把这个设计讲出来,老师一听就知道你考虑过生产环境的问题。
第三个亮点:数据库的冗余字段设计。 订单明细表冗余存储菜品名称和价格的举动,向老师展示你理解了"数据冗余与查询效率的权衡"。再配上你采用了唯一索引约束order_no、常用查询字段添加了索引,这就是完整的数据库设计素养展示。
5.2 面试中如何把项目讲出深度
这个项目如果只是平平淡淡地做完,面试价值不大。但如果你在面试时能把"食堂订餐系统的并发设计"讲清楚,那项目的说服力会完全不同。这里我分享几个面试官大概率会追问的方向,你提前准备好回答思路:
追问一:如果学校有10个食堂,每个食堂有5个窗口,订单高峰期有成百上千的并发请求,你觉得系统瓶颈在哪里?
回答思路:瓶颈可能在几个位置——数据库连接池被占满、图片静态资源带宽、订单写入的锁竞争。改进方向可以是:引入Redis缓存菜品信息降低数据库压力、订单表按日期做分表、引入消息队列做削峰填谷。
追问二:JWT Token过期后用户正在下单怎么办?
回答思路:前端在响应拦截器检测到401时,尝试用refreshToken刷新Token;如果刷新失败则跳转登录页。这里还有一个细节:下单接口要做幂等性设计,防止刷新后重复提交订单——前端在下单前生成一个requestId,后端用Redis的setIfAbsent做幂等判断。
追问三:菜品库存和订单数据的一致性怎么保证?
回答思路:核心是用数据库事务保证一致性,下单和扣库存放在同一个事务里。为了提高并发,可以提前用Redis做库存预热,异步把扣减结果同步到数据库。但Redis和数据库的一致性需要引入补偿机制,比如定时对账任务来修正差异。
5.3 系统后续可以怎么扩展
如果你做完了这个项目,时间还有富余,我建议从下面几个方向中选择一个做扩展,这些扩展点可以写进论文的"展望"部分,也能在答辩时展示你的思考深度:
引入Redis缓存和分布式会话。 缓存菜品列表、食堂列表这些热点数据,降低数据库查询压力。这是一个技术含量高、实现成本也不高的改进,尤其适合已经有Redis基础的同学。
接入小程序端。 现在校园场景下,用小程序点餐远比浏览器访问符合使用习惯。把前端用uni-app重写一版适配小程序,后端接口几乎不用改,因为微信小程序的请求方式本质上也是HTTP + JSON。这项工作能体现你的跨端开发能力。
增加消息通知机制。 订单状态变化时,通过WebSocket或者SSE实时推送消息给用户和商家。Java领域推荐用WebSocket + Spring的TextWebSocketHandler,这个扩展点技术栈新,讲出来会有很好的效果。
完善数据统计模块。 管理员后台增加多维度的销售报表:按窗口统计菜品销量排行、按时间段统计订单量、按周统计营业趋势。这些可以用ECharts做可视化展示,既提升了系统的完整度,又增加了演示时的视觉亮点。
5.4 我做完这个项目后的真实体会
最后聊一点个人感受。做这个系统期间,我最大的收获不是Spring Boot的注解怎么用,也不是MyBatis的SQL怎么写,而是养成了"先想清楚再动手"的习惯。最开始我拿到需求就直接开始写代码,结果做到订单模块时发现数据库设计缺了状态字段,又回头改表结构,连带把前面前后端的代码都改了一遍。后来我放慢节奏,先用一个晚上把所有功能模块、数据表、状态流转图都画在纸上,再动手写代码,后面几乎没怎么返工。
还有一个心态上的心得:毕设系统不需要追求大而全,但一定要有一条能完整跑通的业务闭环。 食堂订餐系统从注册登录、浏览菜品、加入购物车、下单、支付、商家接单、出餐、确认收到,这条链路一定要从头到尾跑通,哪怕牺牲一些边角功能也在所不惜。演示的时候,一条流畅的核心链路,远比一堆半成品功能更能打动答辩老师。
另外,做系统的时候建议顺便写一份技术文档,把关键接口的请求参数和返回结果、数据库表结构、部署启动步骤记录下来。这份文档对你做论文、做答辩PPT都是直接素材,省得最后临阵磨枪。
如果这篇文章对你有帮助,后续我会把完整的源码结构和关键代码片段整理出来,也会聊聊如何在现有基础上做并发优化。有具体卡住的地方欢迎评论区留言,我看到了会回复,也欢迎私信交流。
