ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于SpringBoot+Vue3+MyBatis的校园外卖系统实战解析

基于SpringBoot+Vue3+MyBatis的校园外卖系统实战解析

最近在整理手头的一套校园外卖服务系统,技术组合是 Java SpringBoot + Vue3 + MyBatis + MySQL,前后端完全分离。这套系统我前后用了差不多三周从零搭完,期间踩了不少坑也总结了一些经验,想着写出来分享给正在做课程设计、毕业设计,或者想完整走一遍前后端分离项目的朋友。校园外卖这个场景其实很有意思,它比普通电商项目更贴近生活,业务链路又足够完整——学生端点餐、商家端接单、骑手端配送、管理端监管,一整套角色权限的闭环都能覆盖到。如果你正在找项目练手,或者想看看一套典型的前后端分离系统是怎么设计落地的,这篇文章应该能给你不少参考。

1. 项目整体设计与技术选型思路

1.1 校园外卖场景的特征与需求拆解

做项目之前我习惯先把场景想透,校园外卖和市面上的外卖平台有本质区别。校园范围小,配送半径基本就是校内几栋宿舍楼加教学楼;用户群体单一,主要就是学生和教职工;订单高峰非常集中,中午十一点到一点、傍晚五点到七点这两个时段占了全天单量的大头;地址模型也简单,不用解析具体的街道门牌,只需要维护校内楼栋和房间号。

基于这些特征,我在设计功能时做了减法而不是加法。大平台那套复杂的地理围栏、智能调度、实时骑手轨迹,在校园场景里根本用不上。这套系统的核心需求可以拆成四个端口来看:

  • 用户端(学生/教职工):注册登录、浏览商家和菜品、购物车、下单支付、订单跟踪、历史订单、评价、收藏商家、地址管理。
  • 商家端(校内食堂档口/周边餐饮店):店铺信息维护、菜品发布和上下架、订单接单、出餐状态流转、营业状态切换、销量统计。
  • 骑手端(校内兼职配送员):注册入驻、查看待接单列表、抢单、取餐、确认送达。
  • 管理端(平台运营方):用户管理、商家入驻审核、骑手审核、订单全局监管、数据统计。

这四个端口并非简单堆功能,而是围绕"订单生命周期"这条主线展开的。我设计的原则是:每个端口只看到和自己职责相关的业务环节,避免信息过载。比如骑手端不需要看到菜品管理的任何信息,商家端也不需要关心用户的历史评价统计。

1.2 为什么选SpringBoot+Vue3+MyBatis这套组合

技术选型上我几乎没有犹豫。SpringBoot在Java后端领域已经是事实标准,内嵌Tomcat容器,一条命令就能启动项目,配合Maven管理依赖,开发效率很高。它自带的自动配置和约定优于配置的特性,让开发者能把主要精力放在业务逻辑而不是繁琐的XML配置上。

前端选Vue3是因为组合式API带来的代码组织能力。对比Vue2的Options API,Vue3用setup函数配合ref、reactive、computed等响应式API,逻辑复用和维护性都好很多。配合Vite构建工具,开发时热更新极快,体验比Webpack舒服不少。组件库我选的是Element Plus,它在Vue3社区最成熟,表格、表单、弹窗这些后台管理高频组件开箱即用。

MyBatis的选型值得多说两句。很多人觉得MyBatis写SQL麻烦,不如JPA省事,但论SQL的可控性和灵活度,MyBatis在中小型项目中优势非常明显。尤其订单列表这种需要多条件动态拼接查询的场景,MyBatis的<where>加<if>标签简直是为它量身定做的。再加上它轻量、易于学习,在学校项目里是非常稳妥的选择。

MySQL 5.7及以上版本作为存储层,理由更直接:关系型数据库对事务支持成熟,而订单和支付这类强一致性场景必须有事务兜底。校园项目也犯不着上PostgreSQL或者MongoDB这些相对陌生的东西,用MySQL能最大程度降低团队协作和后续维护的成本。

1.3 这套方案的优势与避开的坑

整套技术栈选型有一个核心考量:复杂度可控。我没有引入微服务、消息队列、分布式缓存这些偏重的中间件,原因很简单——一个校园级项目根本撑不起这些组件的运维成本,也没有必要。过度设计是学生项目最常犯的毛病,明明一台服务器能跑完的事,非要拆成好几个服务,最后光是联调就够折腾的。

但这不代表项目没有技术深度。订单超时处理、并发库存扣减、状态机流转、权限拦截、跨域处理,这些是真实业务系统都会遇到的问题。用轻量级的方案解决它们,比如用Spring定时任务处理超时订单、用乐观锁思想解决超卖,反而比堆一堆中间件更能体现对技术本质的理解。这个思路在后面的内容里我会逐一展开。

2. 核心模块拆解与数据库设计

2.1 角色权限模型的落地

校园外卖系统的角色权限模型,我按用户表的role字段做了一个简单的RBAC设计。在数据库层面,用户表设计成:

CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` VARCHAR(50) DEFAULT '' COMMENT '昵称', `role` TINYINT NOT NULL DEFAULT 1 COMMENT '角色:1用户 2商家 3骑手 4管理员', `student_no` VARCHAR(20) DEFAULT NULL COMMENT '学号,用户注册时选填', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL', `status` TINYINT DEFAULT 1 COMMENT '状态:1正常 0禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有个关键设计点:商家和骑手本质上也是用户,只是角色标识不同。商家注册时,用户表里插入一条role=2的记录,同时商家详情表里会有一条对应的店铺记录。这样设计的好处是登录认证逻辑高度统一,一个login接口就够用,后台再根据角色展示不同的首页。

学生认证这一块我做了一个可选的学号校验功能。注册时填入学号,后台做一个简单的学号格式校验和重复性检查。虽说不联网验证真伪,但在校园内网环境下至少能起到一定的过滤作用。这个功能在实际答辩时会被老师反复问,建议保留。

2.2 数据库核心表结构

除了用户表,整个系统核心的表还有这七张:

表名用途关键字段
shop商家店铺shop_name、category、min_price、delivery_fee、status
dish菜品shop_id、name、price、image、status、stock、sales
address收货地址user_id、building、detail、contact、phone、is_default
cart购物车user_id、shop_id、dish_id、quantity
orders主订单order_no、user_id、shop_id、rider_id、total_amount、status
order_detail订单明细order_id、dish_id、dish_name、price、quantity
review评价order_id、user_id、rating、content

菜品表值得注意的一个字段是sales(销量)。我之前见过不少项目用SELECT COUNT(*)实时统计销量,数据量一大查询就慢。这里我采取的策略是:菜品表维护一个冗余的销量字段,每次订单完成时累计更新。查询菜品列表直接按这个字段排序,性能好很多,代价是需要在事务里保证销量更新的一致性,这对MySQL来说毫无压力。

订单主表和明细表是典型的一对多关系,我特意在明细表里冗余了dish_name和price两个字段。可能有人会问:点菜的时候直接关联菜品表不就行了,为什么要存一份快照?原因是菜品名称和价格是会变的,商家可能明天就把宫保鸡丁从18块涨到20块,但已经下单的订单作为交易凭证,必须保留下单那一刻的信息。快照字段是这个项目里很容易被忽略、但真实业务中绝对不能少的细节。

2.3 订单状态机的设计思路

订单状态是整系统的灵魂,我设计的流转路径如下:

  • 0:等待支付
  • 1:已支付/待接单
  • 2:已接单/制作中
  • 3:配送中
  • 4:已完成
  • 5:已取消

再配合两个边界情况:等待支付超时自动取消,已支付订单商家端可以取消并退款。状态流转的方向是严格单向的,比如已取消的订单不能重新变成待接单,这需要在每次更新状态的SQL里加上WHERE status = 上一步状态的条件,防止并发请求把状态改乱了。

实际执行状态更新时,我用一个统一的方法处理:

public void updateOrderStatus(Long orderId, Integer expectedStatus, Integer targetStatus) { int rows = orderMapper.updateStatusByExpectedStatus(orderId, expectedStatus, targetStatus); if (rows == 0) { throw new BusinessException("订单状态已变化,请刷新后重试"); } }

这个写法的妙处在于利用数据库的行锁和影响行数来判断更新是否成功。如果两个请求同时想操作同一笔订单,只有一方能更新成功,另一方会因为影响行数为0而收到异常提示。这是并发控制里最简单也最实用的一招。

3. SpringBoot+MyBatis后端核心实现

3.1 项目分层与统一响应结构

后端项目的包结构我按技术职责分层,不按业务模块分。这样分层的好处是交流成本低,团队里任何人拿到项目都知道该往哪个包放代码:

com.example.campus ├── config # 配置类:CORS、拦截器、WebMvc配置 ├── controller # 接口层:接收参数、返回结果 ├── service # 业务层:事务边界就在这里 ├── mapper # MyBatis接口 ├── entity # 数据库实体 ├── dto # 入参对象 ├── vo # 出参对象 ├── common # 通用类:Result、异常、常量 └── util # 工具类:JWT工具、订单号生成

Controller层我要求只做参数接收和结果封装,不写任何业务逻辑;Service层是事务和业务规则的所在地;Mapper层单一职责,只负责SQL和数据库交互。所有接口返回统一使用一个Result<T>模型:

@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMsg("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String msg) { Result<T> result = new Result<>(); result.setCode(code); result.setMsg(msg); return result; } }

前端的响应拦截器只需要判断code是不是200,其他情况统一弹出msg提示。这样的约定让整个前后端联调变成了一个很机械的工作,几乎没有沟通成本。

3.2 订单提交的事务处理

下单这个接口是整个系统的核心方法。一次完整的下单行为涉及四件事:生成订单主记录、生成订单明细记录、扣减菜品库存、清空购物车。这四步要么全成功要么全失败,必须放在同一个事务里。

我是这样写的:

@Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateDTO dto) { // 校验商家营业状态 Shop shop = shopMapper.selectById(dto.getShopId()); if (shop == null || shop.getStatus() != 1) { throw new BusinessException("商家不存在或已打烊"); } // 校验购物车不为空 List<CartItemVO> cartItems = cartMapper.selectDetailedItems(dto.getUserId(), dto.getShopId()); if (cartItems.isEmpty()) { throw new BusinessException("购物车为空"); } // 计算订单金额 BigDecimal totalAmount = BigDecimal.ZERO; for (CartItemVO item : cartItems) { totalAmount = totalAmount.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } totalAmount = totalAmount.add(shop.getDeliveryFee()); if (totalAmount.compareTo(shop.getMinPrice()) < 0) { throw new BusinessException("未达到起送价"); } // 生成订单号 String orderNo = generateOrderNo(); // 插入主订单 Orders order = new Orders(); order.setOrderNo(orderNo); order.setUserId(dto.getUserId()); order.setShopId(dto.getShopId()); order.setTotalAmount(totalAmount); order.setDeliveryFee(shop.getDeliveryFee()); order.setStatus(0); order.setConsignee(dto.getConsignee()); order.setConsigneePhone(dto.getConsigneePhone()); order.setAddress(dto.getAddress()); order.setRemark(dto.getRemark()); orderMapper.insert(order); // 插入明细 for (CartItemVO item : cartItems) { OrderDetail detail = new OrderDetail(); detail.setOrderId(order.getId()); detail.setDishId(item.getDishId()); detail.setDishName(item.getDishName()); detail.setPrice(item.getPrice()); detail.setQuantity(item.getQuantity()); orderDetailMapper.insert(detail); // 扣减库存 int rows = dishMapper.decreaseStock(item.getDishId(), item.getQuantity()); if (rows == 0) { throw new BusinessException("菜品【" + item.getDishName() + "】库存不足"); } } // 清空购物车 cartMapper.clearByUserIdAndShopId(dto.getUserId(), dto.getShopId()); return order.getId(); }

写事务的时候有一个经验之谈:不要在事务里执行任何耗时的外部调用,比如发短信、调第三方支付备查接口、上传文件等等。事务期间这些连接会一直占着数据库资源,稍有不慎就会出现长事务拖垮数据库性能。我这个方法里所有操作都是纯数据库访问,耗时极短,非常安全。

库存扣减的SQL是防止超卖的关键。我用了条件更新而不是先查再更:

UPDATE dish SET stock = stock - #{quantity}, sales = sales + #{quantity} WHERE id = #{dishId} AND stock >= #{quantity}

stock >= #{quantity}这个条件在数据库层面就杜绝了扣成负数的情况。再配合Mapper返回的影响行数判断,如果返回0就说明库存不够。这就是乐观锁的思路,不用加悲观锁,也能保证并发安全。

3.3 MyBatis动态SQL的实用写法

订单列表是一个典型的多条件分页查询:按用户筛选、按状态筛选、按时间范围筛选、按订单号模糊匹配。用MyBatis的动态SQL,这个需求写起来非常清爽:

<select id="selectOrderPage" resultType="com.example.campus.vo.OrderVO"> SELECT o.*, s.shop_name FROM orders o LEFT JOIN shop s ON o.shop_id = s.id <where> <if test="userId != null"> AND o.user_id = #{userId} </if> <if test="status != null"> AND o.status = #{status} </if> <if test="startTime != null"> AND o.create_time &gt;= #{startTime} </if> <if test="endTime != null"> AND o.create_time &lt;= #{endTime} </if> <if test="orderNo != null and orderNo != ''"> AND o.order_no LIKE CONCAT('%', #{orderNo}, '%') </if> </where> ORDER BY o.create_time DESC LIMIT #{offset}, #{pageSize} </select>

<where>标签的好处是如果所有条件都为空,它会自动去掉WHERE关键字;如果第一个条件不满足,它会自动去掉前面的AND。写动态SQL的时候再也不用纠结条件拼接时的多余AND问题了。

这里要提一下分页的写法。我直接用LIMIT #{offset}, #{pageSize},没有引入PageHelper依赖。校园项目的数据量根本到不了需要复杂分页插件的地步,手写分页参数反而更直观。不过所有Mapper方法的第二个参数和第三个参数记得统一命名成offset和pageSize,这是约定。

3.4 订单超时取消的轻量级方案

等待支付的订单如果用户一直不付款,就会堆积在表里影响商家端接单列表。我调研过几种方案:用Redisson延迟队列最精准但引入Redis依赖;用RabbitMQ延迟消息最规范但要会装消息队列;用定时任务轮询最笨但最简单。

考虑到项目整体定位和部署成本,我选了Spring自带的@Scheduled定时任务配合数据库批量更新,代码简单到让人怀疑是不是太简陋了:

@Component public class OrderTimeoutTask { @Scheduled(cron = "0 */1 * * * ?") // 每1分钟执行一次 public void cancelTimeoutOrders() { // 找出所有超过15分钟仍处于待支付状态的订单 // 批量更新为已取消 int rows = orderMapper.cancelTimeoutOrders(LocalDateTime.now().minusMinutes(15)); if (rows > 0) { log.info("自动取消了 {} 笔超时订单", rows); } } }

对应的SQL也是批量操作:

UPDATE orders SET status = 5, cancel_time = NOW() WHERE status = 0 AND create_time &lt; #{timeoutTime}

为什么说这个方案够用?因为校园项目的订单量级在几百到几千这个范围,定时任务即便有一分钟延迟,用户体验也感知不到。而且这种轮询本身就是一种异步补偿机制,就算真的偶发卡顿,下一分钟也会补上。等以后订单量大了再迁移到延迟队列,业务代码完全不用改。

别忘了在SpringBoot启动类上加@EnableScheduling,这是新手最容易漏掉的一步,漏了定时任务会静默不生效。

3.5 JWT登录认证与接口鉴权

登录认证我用的是JWT自研方案,没有引入Spring Security。原因很朴素:项目要让大家看懂,Spring Security那一堆Filter链和配置项对新手很不友好。JWT的思路则非常直白:登录成功签发一个token,前端保存,后续每次请求在Header里带上,后端校验token解析出用户信息。

登录的核心逻辑:

@Service public class AuthServiceImpl implements AuthService { @Override public String login(String username, String password) { User user = userMapper.selectByUsername(username); if (user == null) { throw new BusinessException("用户不存在"); } if (!BCrypt.checkpw(password, user.getPassword())) { throw new BusinessException("密码错误"); } if (user.getStatus() == 0) { throw new BusinessException("账号已被禁用"); } // 生成JWT,有效期24小时 String token = JwtUtil.createToken(user.getId(), user.getUsername(), user.getRole()); return token; } }

密码存储必须用BCrypt,它自带随机盐,同一个密码每次加密结果都不同,比MD5和SHA安全得多。这里千万不能自己设计加密算法,业界成熟方案直接用就好。

接口鉴权我用了一个自定义拦截器。注册拦截器时指定哪些路径需要登录、哪些路径是公开的。同时把用户信息解析出来存进ThreadLocal,避免每个接口都从token里解析一遍。这算是一个常规做法,但能显著提升代码整洁度,Controller里直接UserContext.getCurrentUser()就能拿到当前登录用户了。

登录拦截器里有一个细节要注意:前端偶尔会把token放在Authorization头里,偶尔放在自定义头里。务必在拦截器里兼容这两种情况,否则联调时候会出现"明明登录了却说没权限"的诡异问题。

4. Vue3前端实现要点

4.1 项目初始化与页面架构

前端用Vite创建项目,命令就一行:

npm create vite@latest campus-front -- --template vue

依赖装完核心的几个包:

npm install vue-router@4 pinia axios element-plus

页面结构我按端口维度划分了四块。用户端是一套独立布局,有底部的TabBar切换首页、订单、我的;商家端和骑手端用的是侧边栏布局,类似管理后台;管理端就是纯粹的表格页面堆叠。路由设计如下:

/login # 登录页(四个角色共用) / # 用户端首页 /user/shop/[id] # 店铺详情 /user/cart # 购物车 /user/orders # 订单列表 /user/order/[id] # 订单详情 /shop/dashboard # 商家工作台 /shop/orders # 商家订单管理 /shop/dishes # 菜品管理 /rider/orders # 骑手接单大厅 /admin/users # 用户管理 /admin/shops # 商家审核 /admin/orders # 全局订单监管

文件目录也按这个思路组织,views下面分user、shop、rider、admin四个目录,页面对应关系一目了然。

4.2 Axios请求封装与跨域处理

前端所有接口请求都走一个统一的request.js模块。这个封装做了三件事:注入token、统一处理业务码、401跳登录。代码如下:

import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } else if (res.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject(new Error('登录已过期')) } else { ElMessage.error(res.msg || '请求出错') return Promise.reject(new Error(res.msg)) } }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } ) export default request

跨域处理是前后端分离项目里最常卡壳的环节。前端开发服务器在5173端口,后端接口在8080端口,直接用axios请求必然跨域。

解决思路有两条路。开发阶段我用Vite的proxy配置做代理转发:

export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } } })

生产环境我用Nginx做反代,配置也简单:

location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

这两种方案都比在SpringBoot里配置@CrossOrigin或CORS全局配置要更合理。后端配置跨域相当于开放了浏览器的跨域限制,而代理方案是在服务端转发,前端根本没有跨域行为,更安全也更贴近真实部署方式。我的做法是后端不配置任何CORS,把所有跨域处理都交给Nginx或者Vite proxy,接受同源约束,开发时也方便暴露问题。

4.3 购物车与订单提交流程的Vue实现

购物车模块我用Pinia做状态管理。cart.js这个store专门负责购物车逻辑:

export const useCartStore = defineStore('cart', { state: () => ({ items: [], currentShopId: null }), getters: { totalCount: state => state.items.reduce((sum, item) => sum + item.quantity, 0), totalPrice: state => state.items.reduce((sum, item) => sum + item.quantity * item.price, 0) }, actions: { addItem(dish) { const existing = this.items.find(item => item.dishId === dish.id) if (existing) { existing.quantity++ } else { this.items.push({ dishId: dish.id, name: dish.name, price: dish.price, quantity: 1 }) } }, removeItem(dishId) { this.items = this.items.filter(item => item.dishId !== dishId) }, clear() { this.items = [] this.currentShopId = null } } })

这里要注意一个业务细节:购物车只能同时存在一个商家的菜品。也就是说用户从店铺A加了两道菜,然后又去店铺B加菜,此时应该弹窗提示"切换店铺会清空购物车"。这个规则在addItem里需要额外判断,如果currentShopId不为空且不等于当前选中店铺,必须先clear再添加。

提交订单时,前端把购物车数据、收货人信息、备注一次性POST给后端,后端在事务里完成订单创建和库存扣减。提交成功后前端调用cartStore.clear()并跳转到订单详情页。整个流程不涉及货物金额在前端重新计算的问题,金额始终以服务端返回为准,前端展示的数据只做参考。

4.4 商品展示与订单倒计时的前端交互

用户端首页的商家列表和店铺详情页,我都用了Element Plus的卡片组件配合图片懒加载。菜品图片用el-image的lazy属性,虽然项目图片量不大,但这个习惯值得保持。

订单列表页有一个交互细节:等待支付的订单需要展示剩余支付时间倒计时。我用原生setInterval实现了一个每秒钟更新的计数组件。组件卸载时必须清理定时器,否则页面跳转后会内存泄漏。注意Vue3中使用onUnmounted钩子清理:

onUnmounted(() => { clearInterval(timer) })

这个看似微不足道的细节,是我在项目测试阶段发现的真实问题:倒计时组件被复用多次后,内存占用明显上涨,就是因为缺了清理步骤。

4.5 路由守卫与角色权限控制

前端有登录页、用户端页面、商家端页面、骑手端页面、管理端页面,路由权限必须和角色匹配。我用Vue Router的前置守卫来控制:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = Number(localStorage.getItem('role')) if (to.path === '/login') { next() return } if (!token) { next('/login') return } const roleMap = { 1: '/', 2: '/shop/dashboard', 3: '/rider/orders', 4: '/admin/users' } const baseRolePath = roleMap[role] // 如果访问的是其他角色的页面,重定向到自己的首页 if (to.path.startsWith('/shop') && role !== 2) { next(baseRolePath) return } if (to.path.startsWith('/rider') && role !== 3) { next(baseRolePath) return } if (to.path.startsWith('/admin') && role !== 4) { next(baseRolePath) return } next() })

很多项目只做了前端路由守卫,后端接口完全没有鉴权,这是非常大的安全隐患。做毕业设计的时候记得前后端都需要有权限控制,前端控制的是页面能不能访问,后端控制的是接口能不能调用。这套系统的安全底线是后端接口层的角色拦截,前端路由守卫只是用户体验层面的保护。

5. 常见问题与排查技巧实录

5.1 MyBatis查询结果映射不上的问题

联调的时候最容易出现的一个问题:SQL查询结果里明明有某个字段,但实体类里对应的属性却是null。排查思路主要看几个点:检查数据库字段和实体属性的映射关系是否正确,MyBatis默认把下划线字段映射成驼峰属性需要开启配置;检查resultType和resultMap的使用场景,多表查询涉及多个实体字段重叠时,resultMap比别名硬编靠谱得多。

我在项目里遇到过shop_name查出来是null,但shopName明明在VO里定义好的情况。最后定位到是MyBatis配置里map-underscore-to-camel-case没开启,加上之后问题就没再出现过。这个配置在application.yml里加上就好:

mybatis: configuration: map-underscore-to-camel-case: true

5.2 Long类型主键传到前端精度丢失

这是个大坑,我特意拿出来说。MySQL的BIGINT类型对应Java的Long,后端没问题,但JSON序列化传给前端,JavaScript的Number类型最多安全表示2^53-1,一旦订单号超过这个范围,前端拿到的数字就会变化精度,比如订单ID从1814815971541917696变成1814815971541917700。

最直接的解决办法是让订单ID以字符串形式传输。有两个方案:简单点的,把订单表的物理主键改成雪花算法生成的Long,但序列化时用@JsonSerialize(using = ToStringSerializer.class)强制转字符串;麻烦点的,彻底不用自增主键,直接用业务订单号作为主键。我选的是前者,改动最小。

5.3 订单状态被并发更新覆盖

如果一个订单同时被用户取消和商家接单,两个请求同时到达后端,就可能出现状态互相覆盖的问题。比如用户取消请求先到达,把状态更新成已取消,商家的接单请求又把这笔已取消的订单切到了已接单,数据就乱了。

解决思路前面提到过,就是在更新状态时带上原状态条件:

UPDATE orders SET status = #{targetStatus} WHERE id = #{orderId} AND status = #{expectedStatus}

更新后判断影响行数,如果是0就抛出异常。这里没有引入分布式锁或数据库悲观锁,靠的就是SQL的原子性和行级锁。这个方案足以应对校园项目量级,而且逻辑非常清晰,面试时也能讲出道理。

5.4 时间字段时区导致的偏移问题

java的LocalDateTime序列化到前端时,如果不配置格式化,会出现相差8小时或者格式变成"2024-06-01T12:00:00"这种带字母T的不友好格式。我在后端统一配置了Jackson:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

但要注意,这只是序列化格式层面的统一。真正的时间存储,MySQL的DATETIME是把字面时间原样存储,不包含时区信息;而Java侧的LocalDateTime也不带时区。这两个不带时区的东西凑在一起,反而不会出错,只要保证服务器时区和数据库连接串的时区参数一致,就没有那么多幺蛾子。连接串上我习惯加上serverTimezone=Asia/Shanghai:

jdbc:mysql://localhost:3306/campus_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

5.5 部署上线阶段的几个提醒

打包后端时记得把application.yml里的数据库连接信息改成线上环境,测试环境的密码千万不要带上线。然后Maven打包:

mvn clean package -DskipTests

前端打包生成dist目录:

npm run build

如果使用了Vue Router的history模式,Nginx配置里必须加上try_files规则,否则刷新页面会404:

location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }

这一步是很多人部署前端项目最容易踩的坑。不用history模式、用默认的hash模式可以规避,但URL会多个/#/,不美观,我建议直接用history模式并配好Nginx。

5.6 数据库索引设计的提升建议

订单表会频繁按用户、状态、时间三个维度查询,我在关键字段上建了联合索引:

ALTER TABLE orders ADD INDEX idx_user_status (user_id, status); ALTER TABLE orders ADD INDEX idx_create_time (create_time); ALTER TABLE orders ADD UNIQUE KEY uk_order_no (order_no);

查询订单列表的时候,WHERE user_id = ? AND status = ?可以完全命中联合索引,回表次数大大减少。个人经验是,学校项目虽然数据量不大,但面试官经常会问"你项目里有哪些索引、为什么这么建",提前把索引设计好,能回答得很从容。

写在最后的几点体会

做这个项目的过程里,我最大的感受是:校园外卖系统的技术难度不在于某个单独的技术点,而在于把业务链条完整串联起来。从购物车到下单、从接单到配送、从评价到售后,每一个环节的状态流转、数据一致性、权限边界,都需要设计者反复考量。技术上用再酷再新的框架,如果订单状态会错乱、库存会超卖、权限会绕过,这个系统就只能停留在"看起来能跑"的层面。

我给准备复现这个项目的朋友一句实在话:别急着写代码,先把订单状态机画出来、把核心表的字段理清楚、把四个角色的权限边界标明白,这三件事做完,你的项目就已经成功了一半。后半部分的bug排查和功能打磨,遇到什么问题就回来对照这个设计文档,思路会清晰很多。

如果你已经把这个完整链路跑通了,下一步可以考虑三个方向的扩展:一是引入WebSocket,商家端和骑手端就能实时收到新订单提醒,不用手动刷新页面;二是对接地图API,把配送员的位置轨迹展示出来,虽然校园场景规模小,但流程和技术的完整度立刻不一样了;三是在高峰期引入Redis缓存菜品和商家信息,降低数据库压力,这也是真实场景下最直接的优化点。每一个方向都能让这个项目从课设级别往生产级别迈一大步,值得好好折腾。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进