最近在整理手头的一套校园外卖服务系统技术组合是 Java SpringBoot Vue3 MyBatis MySQL前后端完全分离。这套系统我前后用了差不多三周从零搭完期间踩了不少坑也总结了一些经验想着写出来分享给正在做课程设计、毕业设计或者想完整走一遍前后端分离项目的朋友。校园外卖这个场景其实很有意思它比普通电商项目更贴近生活业务链路又足够完整——学生端点餐、商家端接单、骑手端配送、管理端监管一整套角色权限的闭环都能覆盖到。如果你正在找项目练手或者想看看一套典型的前后端分离系统是怎么设计落地的这篇文章应该能给你不少参考。1. 项目整体设计与技术选型思路1.1 校园外卖场景的特征与需求拆解做项目之前我习惯先把场景想透校园外卖和市面上的外卖平台有本质区别。校园范围小配送半径基本就是校内几栋宿舍楼加教学楼用户群体单一主要就是学生和教职工订单高峰非常集中中午十一点到一点、傍晚五点到七点这两个时段占了全天单量的大头地址模型也简单不用解析具体的街道门牌只需要维护校内楼栋和房间号。基于这些特征我在设计功能时做了减法而不是加法。大平台那套复杂的地理围栏、智能调度、实时骑手轨迹在校园场景里根本用不上。这套系统的核心需求可以拆成四个端口来看用户端学生/教职工注册登录、浏览商家和菜品、购物车、下单支付、订单跟踪、历史订单、评价、收藏商家、地址管理。商家端校内食堂档口/周边餐饮店店铺信息维护、菜品发布和上下架、订单接单、出餐状态流转、营业状态切换、销量统计。骑手端校内兼职配送员注册入驻、查看待接单列表、抢单、取餐、确认送达。管理端平台运营方用户管理、商家入驻审核、骑手审核、订单全局监管、数据统计。这四个端口并非简单堆功能而是围绕订单生命周期这条主线展开的。我设计的原则是每个端口只看到和自己职责相关的业务环节避免信息过载。比如骑手端不需要看到菜品管理的任何信息商家端也不需要关心用户的历史评价统计。1.2 为什么选SpringBootVue3MyBatis这套组合技术选型上我几乎没有犹豫。SpringBoot在Java后端领域已经是事实标准内嵌Tomcat容器一条命令就能启动项目配合Maven管理依赖开发效率很高。它自带的自动配置和约定优于配置的特性让开发者能把主要精力放在业务逻辑而不是繁琐的XML配置上。前端选Vue3是因为组合式API带来的代码组织能力。对比Vue2的Options APIVue3用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 注册时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个关键设计点商家和骑手本质上也是用户只是角色标识不同。商家注册时用户表里插入一条role2的记录同时商家详情表里会有一条对应的店铺记录。这样设计的好处是登录认证逻辑高度统一一个login接口就够用后台再根据角色展示不同的首页。学生认证这一块我做了一个可选的学号校验功能。注册时填入学号后台做一个简单的学号格式校验和重复性检查。虽说不联网验证真伪但在校园内网环境下至少能起到一定的过滤作用。这个功能在实际答辩时会被老师反复问建议保留。2.2 数据库核心表结构除了用户表整个系统核心的表还有这七张表名用途关键字段shop商家店铺shop_name、category、min_price、delivery_fee、statusdish菜品shop_id、name、price、image、status、stock、salesaddress收货地址user_id、building、detail、contact、phone、is_defaultcart购物车user_id、shop_id、dish_id、quantityorders主订单order_no、user_id、shop_id、rider_id、total_amount、statusorder_detail订单明细order_id、dish_id、dish_name、price、quantityreview评价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. SpringBootMyBatis后端核心实现3.1 项目分层与统一响应结构后端项目的包结构我按技术职责分层不按业务模块分。这样分层的好处是交流成本低团队里任何人拿到项目都知道该往哪个包放代码com.example.campus ├── config # 配置类CORS、拦截器、WebMvc配置 ├── controller # 接口层接收参数、返回结果 ├── service # 业务层事务边界就在这里 ├── mapper # MyBatis接口 ├── entity # 数据库实体 ├── dto # 入参对象 ├── vo # 出参对象 ├── common # 通用类Result、异常、常量 └── util # 工具类JWT工具、订单号生成Controller层我要求只做参数接收和结果封装不写任何业务逻辑Service层是事务和业务规则的所在地Mapper层单一职责只负责SQL和数据库交互。所有接口返回统一使用一个ResultT模型Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMsg(操作成功); result.setData(data); return result; } public static T ResultT error(Integer code, String msg) { ResultT 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(商家不存在或已打烊); } // 校验购物车不为空 ListCartItemVO 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 idselectOrderPage resultTypecom.example.campus.vo.OrderVO SELECT o.*, s.shop_name FROM orders o LEFT JOIN shop s ON o.shop_id s.id where if testuserId ! null AND o.user_id #{userId} /if if teststatus ! null AND o.status #{status} /if if teststartTime ! null AND o.create_time gt; #{startTime} /if if testendTime ! null AND o.create_time lt; #{endTime} /if if testorderNo ! null and orderNo ! AND o.order_no LIKE CONCAT(%, #{orderNo}, %) /if /where ORDER BY o.create_time DESC LIMIT #{offset}, #{pageSize} /selectwhere标签的好处是如果所有条件都为空它会自动去掉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 vitelatest campus-front -- --template vue依赖装完核心的几个包npm install vue-router4 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: true5.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的不友好格式。我在后端统一配置了Jacksonspring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8但要注意这只是序列化格式层面的统一。真正的时间存储MySQL的DATETIME是把字面时间原样存储不包含时区信息而Java侧的LocalDateTime也不带时区。这两个不带时区的东西凑在一起反而不会出错只要保证服务器时区和数据库连接串的时区参数一致就没有那么多幺蛾子。连接串上我习惯加上serverTimezoneAsia/Shanghaijdbc:mysql://localhost:3306/campus_order?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai5.5 部署上线阶段的几个提醒打包后端时记得把application.yml里的数据库连接信息改成线上环境测试环境的密码千万不要带上线。然后Maven打包mvn clean package -DskipTests前端打包生成dist目录npm run build如果使用了Vue Router的history模式Nginx配置里必须加上try_files规则否则刷新页面会404location / { 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缓存菜品和商家信息降低数据库压力这也是真实场景下最直接的优化点。每一个方向都能让这个项目从课设级别往生产级别迈一大步值得好好折腾。