先把我做这个项目的真实感受放在最前面:没有任何一个技术项目能像果蔬生鲜电商这样,把SpringBoot和Vue3的实战价值体现得如此充分。前后端分离、JWT鉴权、商品与订单流转、后台管理……这些看上去很“教科书”的名词,落在一个卖菜平台上,突然就变得非常具体。你不再是为了学框架而写代码,而是真的要去解决“水果怎么上架”“购物车怎么算钱”“订单怎么扣库存”这些实际问题。
这个项目适合谁?一句话:适合正在准备毕业设计、想找一份前后端分离实战项目经验、或者打算从躺平阶段重新捡起Java+Vue技术栈的人。果蔬生鲜电商的切入角度比普通商城更有辨识度,业务上多出了“新鲜度”“重量计价”“冷链配送”这些真实痛点,能写进文档和简历里的东西非常多。我接下来说的每一部分,都会围绕“如何从一个空目录把整个系统落地”来展开,附带我在编码、联调、部署过程中踩过的坑和找到的最优解。
1. 项目整体分析与架构设计思路
1.1 为什么选果蔬生鲜电商这个方向
我在帮人选题时最常被问一句话:“商城系统不是烂大街了吗?”没错,普通的图书商城、数码商城确实没有新鲜感,但果蔬生鲜是一个很微妙的赛道。它的商品天然具备分类属性(叶菜、根茎、水果、肉禽蛋),价格体系里有“份”和“斤”两种计量方式,库存会因为生鲜损耗频繁变动,订单还牵扯到配送时段、冷链要求、退换货策略。这些业务特征落到技术上,意味着数据库表要设计得比普通商城更细致,接口要处理更复杂的条件查询,前端页面也要照顾“快速浏览、快速加购”的移动端习惯。
从技术训练的角度看,果蔬生鲜电商几乎是“前后端分离标准范式”的浓缩体。它涵盖了用户注册登录、商品展示、购物车、订单创建、库存扣减、后台管理、文件上传、数据统计,这些模块单独拆开都是面试里必问的东西,组合在一起就是一个完整闭环。更关键的是,你在做这个项目的过程中能自然理解“为什么需要前后端分离”:前端管理交互状态,后端管理数据安全,两者只通过API通信,互不干扰。
1.2 前后端分离架构的角色划分
前后端分离这四个字听起来简单,真正落地时有几条明确的边界要守住。SpringBoot后端的职责是:提供RESTful接口、校验参数、执行事务、操作数据库、生成JWT令牌、处理文件存储。Vue3前端的职责是:渲染页面、管理路由、保存用户登录状态、调用后端接口、处理交互反馈。两者之间只通过JSON格式的数据交互,不共享模板引擎,不直接读取对方文件。
我选的方案是:后端用SpringBoot 2.7.9 + MyBatis-Plus + MySQL 8.0 + Redis,前端用Vue3 + Vite + Pinia + Vue Router + Element Plus。这里必须多说一句版本选择的问题。SpringBoot 3.x虽然已经发布很久,但如果目标是稳妥落地一个电商系统,2.7.x反而是更理性的选择。原因很实际:3.x把javax迁移到了jakarta命名空间,很多老牌工具包的兼容还在过渡期;2.7.x则处于“功能够用、生态成熟、网上资料最多”的黄金阶段。我身边不止一个人卡在SpringBoot 3.0的版本兼容上浪费了两三天,而2.7.x几乎不会遇到这种坑。JDK我选的8,不是因为不会17,而是因为部署环境兼容性最好。
1.3 业务模块与核心流程拆解
整个系统划分成前台商城和后台管理两大部分。前台用户操作的是:浏览商品、查看分类、搜索、加入购物车、确认下单、查看订单、管理收货地址。后台管理员操作的是:商品上架下架、库存调整、订单发货、用户管理、销售统计。
业务核心链路是所有模块里最需要理清楚的部分:用户登录后浏览商品,将商品加入购物车,购物车勾选后生成订单,下单时校验并锁定库存,支付成功(这里可以接真实支付,也可以做成模拟支付)后库存正式扣减,然后订单进入待发货状态。这条链路里最容易出问题的环节就是库存:高并发场景下如果两条订单同时请求同一个商品,数据库层面的库存判断和扣减必须保证原子性,否则就会出现“超卖”。我在这个项目里用的办法是数据库行锁配合事务,先把商品行的库存读出来,如果剩余大于0才更新,更新语句本身再加剩余数条件,双保险。
2. 后端核心模块:从工程搭建到业务落地
2.1 工程结构设计与数据库建模
工程结构上我建议按“模块分包”而不是“按层分包”。简单说就是:先按业务域划分包,商品相关的Controller、Service、Mapper放在product包里,订单相关的放在order包里,用户相关的放在user包里。这种结构的好处是当代码量增长到几百个文件时,你不会迷失在controller层、service层各自的庞大列表里,而是能按业务入口快速定位。我见过太多按层分包的毕设项目,最后改一个商品功能要在三层目录之间来回跳,那体验真的太痛苦了。
数据库表上,果蔬生鲜系统我会设计这些核心表:用户表,字段包含用户名、密码(BCrypt加密存储)、昵称、头像、手机号、状态;商品分类表,支持两级分类;商品表,包含商品名称、主图、轮播图、详情描述、价格、原价、库存、销量、上下架状态、单位(斤/份)、新鲜度标签;购物车表,包含用户ID、商品ID、数量、选中状态;订单表,包含订单编号、用户ID、总金额、支付状态、配送地址、配送时段、订单状态;订单明细表,包含所属订单ID、商品ID、商品快照信息(名称、图片、单价)、数量、小计。
这里有个值得注意的设计细节:订单明细中必须保存“下单那一刻的商品名称和价格快照”,而不是只存商品ID。原因很现实,后续商品改名或调价,不能影响历史订单的展示。我在开发文档中把这点单独标注为“数据冗余是故意的”,避免被误判为不规范设计。
2.2 统一返回结构与异常处理
前后端对接最怕出现一种情况:不同的接口返回的JSON结构完全不一样,前端每个请求都要单独判断。解决方式是后端定义一个统一返回值。我将其命名为Result,核心字段包括code、message、data。code为200表示成功,500表示系统异常,401表示未登录或令牌失效,404表示资源不存在。所有Controller的方法都返回这个结构,前端axios拦截器只需要统一处理一次即可。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }异常处理上,我写了一个全局异常处理器,拦截所有Controller层抛出的异常。业务异常(比如库存不足、商品已下架)统一返回提示给前端;系统异常则记录日志,并返回“系统繁忙,请稍后重试”这样安全的提示,避免把数据库SQL错误直接暴露出去。这个习惯是在真实项目里养成的,因为接口的错误信息一旦包含敏感SQL片段,被有心人看到就是安全隐患。
2.3 登录鉴权与权限控制
登录鉴权我用的JWT方案。用户在登录成功后,后端生成一个有效期2小时的令牌返回给前端。前端把令牌存储在本地,并在后续每次请求的请求头中携带。后端有一个拦截器,在访问需要登录的接口时先校验令牌,无效则直接返回401状态码。
JWT本身由三部分组成:Header、Payload、Signature。Header里声明加密算法,Payload里放用户ID、用户名、过期时间,Signature用密钥对前面两部分进行签名。这样做的好处是服务端无状态,不需要把会话信息存储在内存或数据库中,扩展性好。
@Component public class JwtInterceptor implements HandlerInterceptor { @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 { Long userId = JwtUtil.parseToken(token); request.setAttribute("userId", userId); return true; } catch (Exception e) { // 令牌过期或非法 } } response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } }权限区分上,管理员和普通用户我用角色字段区分。管理员接口加了一个自定义注解,拦截器里判断当前用户的角色是admin才放行。这个方案比较轻量,不需要引入Spring Security全家桶,对于这个体量的系统已经足够。
2.4 核心业务接口实现要点
商品模块的最核心接口是分页查询,果蔬生鲜的场景下支持按关键字搜索、按分类筛选、按价格排序、按销量排序。使用MyBatis-Plus的LambdaQueryWrapper可以很优雅地完成动态查询拼接,但需要注意一个细节:当排序字段来自前端时,绝不能直接拼接列名,而是要用白名单映射,防止SQL注入。
订单模块是最考验事务能力的地方。我在下单接口上加了Transactional注解,方法内依次执行:校验购物车商品、校验库存、扣减库存、生成订单主表、生成订单明细、清空对应购物车项。中间任何一步失败,整个事务回滚,保证数据一致。扣减库存的SQL语句这样写:
UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}这个UPDATE语句自带条件判断,MySQL会在执行时锁住这一行记录,多个并发请求同时进来时,后进来的只能等待前一个事务提交。这才是真正意义上的防超卖,仅仅在Java代码里先select再update是不够配合的,因为那两条SQL之间有空隙。
3. 前端核心页面与交互实现
3.1 Vue3工程搭建与关键依赖
前端工程我用Vite创建,相比Webpack,开发服务器的启动速度和热更新时间明显提升,对Vue3的默认支持也是Vite原生就有的。创建方式一句话带过:npm create vite@latest,选择vue模板,然后安装vue-router、pinia、axios、element-plus。Element Plus按需导入可以减小打包体积,但为了省事,我直接全量引入,毕竟本地开发和毕设演示的体量完全感知不到差异。
Vue3项目中我推荐全程使用组合式API,也就是setup语法糖。同样是实现一个计数器的逻辑,选项式API要把数据和方法分散在data、methods两个区域,而组合式API允许你按功能把相关代码聚在一起,可读性高很多。更重要的是,现在大部分企业新项目都在用组合式API,面试时也会被问到区别,提前适应不吃亏。
3.2 路由与全局状态管理
路由我分成两类:普通页面路由和需要登录才能访问的路由。判断是否登录的方法,是在路由守卫里检查轻量级本地存储里有没有token。如果没有token且访问的是受保护页面,直接重定向到登录页。这种前端路由守卫解决的是体验问题,真正的安全校验后端拦截器兜底执行,两层缺一不可。
全局状态管理用Pinia。很多人问,为什么不用Vuex?Vue3的官方推荐已经变了,Pinia更轻量、TS支持更自然、没有mutations的繁琐概念。我在项目里主要用Pinia管理两个东西:用户信息(用户ID、昵称、头像、角色)和购物车数量角标。购物车数量角标是一个非常经典的全局状态场景,因为首页、商品详情页、购物车页面都会改变或显示它,如果不用全局状态,组件之间传值会传到你崩溃。
export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('token') || '') const userInfo = ref({}) function setToken(value) { token.value = value localStorage.setItem('token', value) } function setUserInfo(value) { userInfo.value = value } function logout() { token.value = '' userInfo.value = {} localStorage.removeItem('token') } return { token, userInfo, setToken, setUserInfo, logout } })3.3 页面组件拆解:首页、商品详情、购物车
首页的布局核心是顶部搜索栏、左侧分类导航、中间商品瀑布信息流。分类数据在页面加载时调用后端接口获取,点击左侧分类时,右侧商品列表根据分类ID重新请求。商品卡片需要展示图片、名称、价格、单位、销量,以及生鲜特有的“新鲜”“精选”标签。这里有一个实用技巧:图片地址由后端返回相对路径,前端在axios拦截器或公共方法里统一拼接完整的图片地址,这样部署时只需要改一个地方就能切换整个环境的图片域名。
商品详情页主要展示大图、价格、选择数量、加入购物车、立即购买。购物车页面则是全选、单选、修改数量、删除、计算合计金额。计算合计金额我是在前端实时算的,每次勾选或修改数量,总价立即变化。下单时再把购物车中选中项传给后端,后端重新计算金额并返回实际支付金额,防止前端篡改价格。
购物车数量修改这里有一个常见交互坑:用户连续点击加减按钮时,如果每次都请求后端接口,会产生大量并发请求。我的做法是做一个300毫秒的防抖,用户停止操作后才发送请求,体验正常且后端压力小很多。另一个坑是修改数量接口应该传商品ID和新数量,后端根据商品ID找到对应购物车记录再更新,而不是把整条购物车记录传回去,因为前端数据可能已经过期。
3.4 后台管理页面与图表统计
后台管理页面我单独做了一套布局,左侧菜单包含商品管理、订单管理、用户管理、分类管理、数据统计。商品管理页面有一个商品表格,支持分页、搜索、上下架切换、编辑弹窗、新增商品弹窗。新增和编辑商品时需要上传图片,我用的是Element Plus的Upload组件配合后端文件上传接口,上传成功后后端返回图片URL,前端把URL回填给表单。
订单管理页面的核心是状态流转。订单状态有:待付款、待发货、已发货、已完成、已取消。后台的操作为:发货按钮(将状态从待发货改为已发货),查看明细(弹出抽屉展示订单中的商品快照列表)。数据统计页面我用了ECharts,展示近7天销售额曲线、分类销售占比饼图。这个模块不需要后端提供复杂报表,只需两个统计SQL:一个是按日期分组求和,一个是按分类分组求和。
3.5 接口封装与交互细节
axios封装是整个前端工程里价值最高的基础设施。我在封装文件中统一做了四件事:设置基础URL、请求拦截器里添加token、响应拦截器里统一处理code、401时强制跳转登录页。前端所有请求都通过这个封装实例发出,业务代码里只需要关心成功后的data数据。
const service = axios.create({ baseURL: '/api', timeout: 10000 }) 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) { return res } if (res.code === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(new Error(res.message)) }, error => { if (error.response && error.response.status === 401) { window.location.href = '/login' } return Promise.reject(error) } )这个封装解决了我在联调阶段遇到的最烦人问题:接口一多,每个页面都要重复写loading、错误提示、登录失效跳转,代码越来越冗余。做完这次封装之后,前端页面里的请求代码极短,一眼就能看出业务逻辑。
4. 开发文档与部署:从本地联调到上线
4.1 完整开发文档都应包含哪些内容
很多人把开发文档理解成“代码注释汇总”,这其实是误区。真正有价值的开发文档,首先要写清楚项目是怎么跑起来的:环境要求、数据库初始化脚本、后端启动步骤、前端启动步骤。其次要写清楚项目的架构设计,包括技术选型理由、模块划分、核心业务流程时序说明。最后要写的是接口文档。
接口文档我强烈建议用在线方式生成,后端集成Knife4j后,启动项目自动生成Swagger文档页面,所有接口的路径、参数、返回示例一目了然。比起手动维护Word文档,这种自动文档永远不会过期。当然,为了让开发文档更有“项目答辩味”,我额外补充了数据库设计文档,里面每张表都要写清楚字段的作用、类型、索引设计原因,这部分内容是文档加分的关键。
4.2 本地联调的三个关键配置
本地联调最大的坎就是跨域。前端跑在5173端口,后端跑在8080端口,浏览器会拦截跨域请求。解决方案有两个:一是后端配置跨域过滤器,允许来自前端地址的请求;二是前端在vite.config.js中配置代理。我更推荐第二种,因为生产环境部署时前端和API走同一个域名,不需要跨域,代理方案只在开发环境生效,架构上更干净。
// vite.config.js server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }第二个关键配置是后端的前缀统一。所有Controller的请求路径都以/api开头,前端代理中把/api转发到后端,这样部署时可以灵活调整后端地址而不影响前端代码。第三个关键配置是数据库连接的时区参数。连接MySQL时URL里必须加上serverTimezone=Asia/Shanghai,否则日期时间字段会相差8小时,这个问题出现时特别隐蔽。
4.3 打包部署:jar加Nginx组合
部署方案我选的是:后端打成jar包由Java直接运行,前端build构建后放入Nginx的静态目录。相比把前端也塞进SpringBoot的resources目录,Nginx方案的好处是静态资源响应更快,且未来如果前端要升级,可以独立替换,不用重启后端。
前端打包执行npm run build,产物在dist目录。把dist目录里的文件传到服务器的Nginx html目录。同时需要配置Nginx把/api路径的请求代理到本机的8080端口:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里面的location /配置非常重要,它可以解决Vue Router历史模式下刷新页面404的问题。原因在于前端路由是前端自己管理的,后端服务器没有对应的物理文件,try_files把不存在的路径全部重写到index.html,让前端路由接管。
4.4 图片存储方案的选型
果蔬生鲜系统里商品图片数量不少,图片存储我优先推荐本地存储加静态资源映射。后端配置一个上传目录,把上传的图片保存到磁盘,同时映射为可访问的URL。这个方案在没有对象存储服务的情况下最简单实用,零成本、部署简单。
upload: path: /data/upload/ spring: web: resources: static-locations: file:${upload.path}如果后续部署到云服务器并配置了CDN,可以把代码里的上传实现替换成MinIO或阿里云OSS。项目文档中我把这个替换点明确标注出来,作为一个可扩展的架构设计很方便说明。
5. 常见问题与避坑实录
5.1 SpringBoot版本过高导致的兼容问题
我亲眼见过一个同学把项目搭在SpringBoot 3.2上,然后折腾了一整天才发现是MyBatis-Plus依赖版本不兼容,启动直接报错。这类问题的根源是3.x从javax迁移到jakarta命名空间,老版本第三方库找不到对应的javax包。如果你不是刻意要学新特性,我建议直接用2.7.9。如果已经用了3.x,排查思路是升级所有第三方依赖到适配3.x的版本,注意mybatis-plus-spring-boot3-starter这样的专用依赖名。
5.2 Vue3的日期校验规则问题
Element Plus表单校验中,日期选择器使用type: 'date'时,默认的校验规则并不会自动生效,需要在rules里指定validator或者用type: 'date'带正确格式。常见的报错是输入框选了日期但提示“请输入日期”,原因是绑定的值还是字符串而不是Date对象。解决方式是在选择器上设置format和value-format,且校验规则里写成type: 'date'。这个小问题在热搜里被我多次看到,说明确实困扰了很多人。
5.3 上传组件的on-success监听不到
Element Plus的Upload组件在Vue3里有一个经典误区:事件回调参数顺序。on-success回调的参数不是简单的response,第一个参数是response,第二个是uploadFile,第三个是uploadFiles。如果你只写一个箭头函数还想直接拿到后端返回的URL,就漏掉了回调的完整签名。另一个坑是组件挂载后如果upload的action属性为空,上传不会触发,所以action必须在模板中提前写成'/api/file/upload'。
5.4 图片上传后商品列表不显示
这个问题的排查思路我整理成了经验:先看后端接口返回的URL是否完整,再看浏览器直接访问该URL是否200,最后检查前端拼接图片地址的逻辑。绝大多数情况不是代码写错,而是图片URL没有走统一前缀拼接方法。我在项目里给所有图片地址暴露了一个全局过滤器,任何模板里出现的imgSrc都会被自动补全域名前缀,这个方案在一开始就解决了后续几十个页面的重复劳动。
5.5 修改Tabs标签页样式的深度选择器问题
Element Plus在Vue3中组件内部样式使用scoped时,直接在样式表里写.el-tabs__item会不生效,因为scoped会为选择器自动添加组件的data属性,而Element Plus组件内部的DOM不会被添加。解决方案是用深度选择器:deep(),比如:deep(.el-tabs__item) { color: #16a085; }。这是Vue3 CSS作用域机制和组件库样式冲突的典型场景,多遇到几次就形成肌肉记忆了。
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 后端启动报ClassNotFoundException | SpringBoot版本与依赖版本不匹配 | 统一版本或替换为适配版本 |
| 前端刷新页面404 | 路由模式与服务器未配合 | Nginx配置try_files |
| 登录成功但接口401 | 前端未携带token或token过期 | 检查请求拦截器与JWT有效期 |
| 后端返回图片URL访问404 | 静态资源映射未配置 | 配置resources.static-locations |
| 日期选择器校验失败 | value-format与校验类型不匹配 | 设置value-format并调整rule |
收尾:做完这个项目后我对全栈开发的重新理解
这里我没有用固定的第九章总结,因为做这个项目最值得留下的恰恰是一些散装心得。第一,前后端分离项目最耗时间的往往不是写单点功能,而是“联调”。你在后端把返回结构设计好、在前端把axios封装好,是在为自己省下后面几十个小时的对接口时间。第二,电商系统的真正难点不在增删改查,而在数据一致性和边界情况,比如库存超卖、订单金额被篡改、图片URL在全环境可用。把这些问题提前想明白,比多写十个接口更有价值。第三,千万别小看开发文档的撰写过程。我写完这个果蔬电商系统的完整文档后,最大的感受是“原来我做的项目比我自己以为的复杂得多”,那些深度拆解的接口说明、数据库设计说明、部署文档,在答辩和求职时都会变成你最有说服力的作品证据。
如果你正准备动手复刻这个果蔬电商系统,我的建议是:不要急着抄代码,先用一天把数据库表和核心接口设计好,再花半天把前端工程框架跑起来,最后按模块逐个实现。中间遇到问题回到这篇文章的避坑清单里查一查,你会发现大部分坑我都提前替你们踩过了。