☰
Spring Boot+Vue水果商城实战:前后端分离与订单事务全解析
2026/10/22 2:45:18 网站建设 项目流程

上个月刚把一套水果在线销售系统从数据库建表一路做到服务器上线,技术栈就是 Spring Boot + Vue 前后端分离。做这个项目的原因很直接:产地水果要走线上,缺一个能把商品、购物车、订单、后台管理串起来的完整闭环;而作为练手项目,它的业务复杂度又刚好卡在一个非常舒服的位置——比单纯的学生管理系统多了订单事务和库存校验,又比大型电商平台简单得多,特别适合用来理解前后端分离开发的全过程。文章里我会把整体设计、技术选型、核心模块的实现细节,以及实际运行中踩过的坑都整理出来。如果你正在准备毕业设计,或者想帮本地果农搭一套可用的商城,这篇应该能帮你省不少时间。

1. 项目整体设计与需求拆解

1.1 这个项目到底在解决什么问题

做这个项目之前,先得想清楚“水果在线销售”和普通电商有什么区别。水果是生鲜,讲究应季、产地、新鲜度,价格波动快,库存管理比标准工业品麻烦得多。所以系统的核心不是做一个花哨的商城页面,而是把“选品→上架→下单→发货”这条线用数据完整接起来。

整个系统涉及三类角色,业务边界非常清晰:

角色能做什么不能做什么
游客浏览商品列表、查看详情、看公告不能加购物车、不能下单
注册用户登录后加购物车、提交订单、查看个人订单、维护收货地址不能管理商品和订单
管理员商品上下架、编辑库存、处理订单、查看销售统计不需要参与前台购物流程

为什么必须做前后端分离而不是传统服务端渲染?原因有两个。第一,Spring Boot 只负责输出 JSON 接口,Vue 只负责页面渲染,以后要加小程序端或者移动端,后端接口基本可以复用,不需要重写一套。第二,前后端分离是目前团队协作的标准姿势,前端和后端可以并行开发,只要提前约定好接口文档就行。纯 JSP 或模板引擎那种方式虽然开发简单,但工程化程度和交互体验都差一大截。

1.2 功能模块与页面结构

很多同学做这类项目一上来就建表写代码,结果做着做着发现功能对不上。我习惯先把页面和接口列成一张对应表,再开始动工。这张表既是开发清单,也是后面写文档的基础。

模块前端页面核心接口
用户登录页、注册页、个人中心注册、登录、获取用户信息
商品首页、商品列表页、商品详情页分页查询、详情、分类列表
购物车购物车页面加入购物车、更新数量、删除、购物车列表
订单确认订单页、订单列表页、订单详情页提交订单、订单列表、订单详情、取消订单、模拟支付
管理端商品管理、分类管理、订单管理、统计看板商品CRUD、订单发货、销售统计
公告首页公告展示、管理端公告管理公告列表、公告增删改

这里有个容易被忽略的设计点:用户端和管理端应该做成两个不同的路由模块,而不是混在同一个页面里切换。原因很简单,后端接口权限不同,前端路由也需要拦截。用户端的订单接口和管理端的订单接口,查询范围、返回字段完全不一样,强行共用一个页面只会把自己绕晕。

1.3 数据库设计最关键的那几张表

这个项目的表不算多,真正核心的其实就六张:用户表、分类表、商品表、购物车表、订单表、订单项表,再加一张公告表。我重点说三个设计原则,这三个原则直接决定了后面订单逻辑好不好写。

第一个原则是订单表必须做商品快照。订单里保存的不只是商品ID,还要把下单那一刻的商品名称、单价、图片冗余存下来。为什么?因为商品价格会变,商品名称也可能调整,如果订单只关联商品ID,三个月后查历史订单,显示的价格可能已经是改过之后的,这是绝对不允许的。电商系统里订单属于财务数据,必须保留“当时成交时”的完整快照。

第二个原则是订单和订单项分开建表。一个订单可能包含多种水果,芒果、石榴、枇杷一起结算,所以在订单表下面还要有订单项表,每条订单项记录一个商品的下单快照。订单表存总金额、收货信息、订单状态,订单项表存每个商品的单价、数量、小计金额。

第三个原则是金额必须用定点数,不能用浮点数。订单金额、单价在数据库里用 decimal(10,2),在 Java 实体类里用 BigDecimal。如果图省事用 double,就会出现 0.1 + 0.2 = 0.30000000000000004 这种经典问题,算总价的时候直接翻车。

订单表的核心字段大概是这个结构:

字段名类型说明
idbigint主键
order_novarchar订单号,唯一,全局流水号
user_idbigint下单用户ID
total_amountdecimal(10,2)订单总金额
statustinyint订单状态:0待支付、1已支付、2待发货、3已发货、4已完成、5已取消
receiver_namevarchar收货人姓名
receiver_phonevarchar收货人手机号
receiver_addressvarchar完整收货地址
create_timedatetime下单时间
pay_timedatetime支付时间
ship_timedatetime发货时间

订单项表主要字段:id、order_id、product_id、product_name、product_image、price、count、total_price。

2. 技术选型与工具链配置

2.1 后端这一堆依赖到底怎么选

Spring Boot 版本我选的是 2.7.x,搭配 JDK8。为什么不直接上 Spring Boot 3?因为很多教程、插件、网上资料还是以 2.x 为主,对新手更友好。JDK8 也够用,升级到 SPDK17 反而会碰到一些兼容性小问题。

ORM 用 MyBatis-Plus 而不是原生 MyBatis 或 JPA,理由很直接:单表 CRUD 和分页查询是这类系统最常见的操作,MyBatis-Plus 可以把这部分代码省掉,同时保留手写 SQL 的能力。订单扣库存那种带条件更新的 SQL,还是手写更可控。JPA 虽然也能做,但懒加载、级联、嵌套查询这些概念对刚入门的人不太友好,出了问题排查成本高。

数据库用 MySQL 5.7 或 8.0 都可以。如果是本地学习开发 5.7 足够了;如果要用窗口函数做统计图,MySQL 8.0 更方便。Redis 在这个项目里主要用来存验证码、存 token 黑名单,也可以顺手做首页热销商品的缓存。不过如果只是交作业或跑通流程,Redis 可以先不加,不影响主体功能。我建议还是加上,哪怕只用来存验证码,这也是面试时能讲的亮点。

JWT 用 jjwt 库实现,搭配 Spring Boot 拦截器做接口鉴权。Hutool 工具库建议引入,生成订单号、日期处理、加密签名这些都有现成方法。Lombok 也加上,实体类能少写一大半 getter/setter。

2.2 前端工程化与组件方案

前端技术栈取决于个人熟悉程度。如果刚开始学,用 Vue2 + Element UI,资料最多、踩坑答案最好搜;如果是从零开始的新项目,可以一步到位用 Vue3 + Vite + Element Plus,工程化体验更好,构建也快。我个人这个项目用的 Vue3 + Element Plus,但下面讲的原理在 Vue2 里同样适用。

Vue Router 必须用 history 模式而不是 hash 模式吗?其实看需求。hash 模式部署简单,URL 带 #,不会出现刷新 404;history 模式 URL 干净,但需要后端 Nginx 配合做 try_files 配置。项目上线建议用 history 模式,本地联调时先配好 Vite 代理。

Axios 一定要做统一封装。baseURL 指向/api,请求拦截器里从 localStorage 拿 token 放到请求头,响应拦截器里统一处理错误码和 401 跳转。不做这一步的话,每个页面都要重复写 token 逻辑,代码会非常难看。

统计看板我用了 ECharts,水果销售 Top5、近七天订单量、交易金额走势这些图表,对管理端来说是很直观的功能点,也适合在答辩时展示。

2.3 从开发到部署的环境搭配

本地开发环境建议这样配:后端用 IntelliJ IDEA,前端用 VS Code,数据库工具用 Navicat 或 DBeaver。前后端用两个端口分开跑,前端开发服务器 8080 端口,后端 Spring Boot 8081 端口,数据库 3306。前端所有请求通过 Vite 代理转发到http://localhost:8081,这样就避免了开发阶段的跨域问题。

部署阶段,后端打成 jar 包放到服务器,前端执行构建命令生成 dist 静态目录,用 Nginx 托管。Nginx 里配置/api反向代理到后端服务,同时解决 history 模式路由刷新 404 的问题。

这里有个小建议:项目从第一天开始,接口路径就统一带/api前缀,比如/api/user/login、/api/product/list。这样开发和生产环境只需要改一层代理配置,不用动代码。

3. 核心模块实现与实操过程

3.1 登录注册与JWT鉴权的完整流程

注册登录是第一个要做的模块。密码必须加密存储,我用的 BCryptPasswordEncoder,同一个密码每次加密结果都不同,安全性比 MD5 加盐高一个量级。注册接口的逻辑很简单:校验用户名是否已存在,存在就返回提示;不存在就把密码加密后插入用户表。

登录流程是这样走的:前端把用户名和密码提交到/api/user/login,后端校验成功后,用 jjwt 生成一个 token,token 里带上 userId、userName、role 和过期时间,返回给前端。前端把 token 存在 localStorage,之后每一个请求都在请求头里带上Authorization: Bearer <token>。

后端用一个拦截器做全局鉴权,放行登录、注册、商品查询、公告查询这些不需要登录的接口,其余接口统一校验 token。核心逻辑类似这样:

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) { // 解析出 userId 和 role,放入 request attribute Integer userId = jwtUtil.getUserId(token); request.setAttribute("userId", userId); request.setAttribute("userRole", jwtUtil.getRole(token)); return true; } // 返回 401,前端 Axios 拦截后跳登录页 response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; }

拦截器里解析出来的 userId 一定要存到 request 上下文里,后面查询购物车、提交订单都要用它,不能再让前端传用户ID过来。让前端传用户ID等于任何人都能操作别人的购物车,这是一个新手最容易犯的安全漏洞。

3.2 水果商品模块:库存、上下架、条件筛选

商品模块看起来是简单的增删改查,但有几个细节必须处理到位。商品表除了常规的名称、描述、价格、封面图、轮播图、分类ID之外,还要有库存、销量、上架状态、应季标签。

用户端查询商品列表时分页接口加上筛选条件:按分类筛选、按价格区间筛选、按上架状态过滤、按销量排序、按新品排序。管理端查询商品则要能查所有状态,包括下架商品。所以这两个接口不能共用同一个查询逻辑,管理端的接口要多传一个status参数,用户端接口永远强制只查status = 1。

用 MyBatis-Plus 分页就是两三行代码:

Page<Product> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getStatus, 1) .eq(StringUtils.hasText(categoryId), Product::getCategoryId, categoryId) .between(priceMin != null && priceMax != null, Product::getPrice, priceMin, priceMax) .orderByDesc(Product::getSales); productMapper.selectPage(page, wrapper);

商品详情页要做一件事:把对应的轮播图数组解析出来返回给前端。我的做法是数据库里存 JSON 字符串,后端查出来后用 Hutool 的 JSONUtil 转成数组放进返回对象里。这种简单场景没必要单独建一张图片表,除非图片数量不可控。

还有一个容易被忽略的点:商品上架的时候要考虑库存是否足够,下架的时候要考虑有没有未处理的订单。这个业务约束不复杂,但必须在后端接口里做判断,不能依赖前端按钮隐藏。

3.3 购物车与订单:最核心的事务

购物车表的设计很简单,用户ID + 商品ID + 数量,加一个唯一约束保证同一个商品不会出现两条记录。加入购物车时先查询是否已存在,存在就数量加一,不存在就新增一条。更新数量时校验数量不能小于 1,也不能超过当前库存。

整个系统最核心的逻辑是提交订单。一个前端点击“提交订单”,后端要做的事情远不止插入一条订单记录。完整流程是:

  1. 根据当前登录用户查出购物车中勾选的购物车项;
  2. 遍历校验每个商品是否还存在且已上架,库存是否充足;
  3. 计算总金额:每一项单价乘以数量累加,金额用 BigDecimal;
  4. 生成订单记录,状态为“待支付”;
  5. 生成订单项记录,保存商品快照;
  6. 扣减库存,这一步必须用条件更新防止超卖;
  7. 删除购物车中已下单的商品;
  8. 模拟支付回调,把订单状态改为“已支付”。

第 1 到第 8 步必须包在同一个事务方法里,任何一步失败都要全部回滚。尤其是扣库存这一步,SQL 要写成这样:

@Transactional(rollbackFor = Exception.class) public Long submitOrder(SubmitOrderDTO dto) { List<CartItem> cartItems = cartMapper.selectList(...); BigDecimal total = BigDecimal.ZERO; for (CartItem item : cartItems) { Product product = productMapper.selectById(item.getProductId()); if (product == null || product.getStatus() != 1) { throw new RuntimeException("商品已下架"); } if (product.getStock() < item.getCount()) { throw new RuntimeException("库存不足"); } total = total.add(product.getPrice().multiply(BigDecimal.valueOf(item.getCount()))); } // 生成订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setTotalAmount(total); order.setStatus(0); orderMapper.insert(order); // 批量插入订单项 // 扣库存:条件更新防止超卖 int rows = productMapper.deductStock(productId, count); if (rows == 0) { throw new RuntimeException("库存扣减失败"); } // 清购物车 // 模拟支付成功 return order.getId(); }

扣库存的 SQL 是UPDATE product SET stock = stock - #{count} WHERE id = #{productId} AND stock >= #{count},返回值是 0 说明库存不够,直接抛异常让事务回滚。这种写法利用数据库行锁天然地解决了并发超卖问题,比先查再更新安全得多。

订单状态不要用魔法数字到处写。我习惯定义一个枚举类,把 0 到 5 的状态用枚举命名,前端展示状态文本,后端处理状态流转,都基于枚举,避免代码里到处出现if (status == 2)这种不可读的写法。

3.4 管理后台:商品上下架与订单处理

管理后台的权限逻辑要跟用户端分开。后端拦截器里除了校验 token,还要校验 role,只有管理员的 token 才能访问/api/admin/**下面的接口。前端路由守卫里也做一次角色判断,用户端页面和管理端页面互不可见。

商品管理最主要的功能就是上下架和库存编辑。编辑库存有一个隐藏风险:如果把库存改小了,要确认当前没有未发货订单用了这些库存。最稳妥的做法是编辑库存接口只允许在商品下架状态下调用,然后修改完再上架。这个约束虽然多一步操作,但能避免数据不一致。

订单管理方面,管理员要做的是查看订单列表、订单详情、发货操作。发货操作就是把订单状态从“已支付”改成“已发货”,并填入物流单号和发货时间。这个操作同样涉及状态校验:发货必须发生在已支付状态,已完成和已取消的订单不能发货。

统计看板的数据来源主要是订单表和订单项表。近七日销售额可以用订单表按日期 group by 聚合,水果销量 Top5 要用订单项表按商品ID group by 求和。这种聚合查询在 MySQL 里跑几百条数据毫无压力,直接用 MyBatis-Plus 的 selectMaps 写 SQL,然后返回给前端 ECharts 渲染。

4. 实战中的坑与排查清单实录

4.1 跨域配置与前后端联调

前后端分离开发时,前端在 8080 端口,后端在 8081 端口,浏览器默认会拦截两个不同源之间的 AJAX 请求。开发阶段最简单的解法是配置 Vite 代理,把/api开头的请求转发到http://localhost:8081。但这个方法只解决本地开发,部署以后前后端是同一个域名,通过 Nginx 路径区分,所以联调阶段真正的跨域问题还得出后端兜底。

我推荐在后端做一个全局 CORS 配置,而不是在每个 Controller 上加@CrossOrigin。配置类里声明允许的来源、方法、请求头,并开启 allowCredentials。如果前端要带 cookie 跨域,allowCredentials和允许来源必须同时设置,不能设置成星号。不过我这个项目用的是 token 鉴权,跨域场景其实不需要 cookie,直接允许指定来源就行。

一次诡异的跨域报错排查了很久,最后发现是拦截器里没有放行 OPTIONS 预检请求,导致前端请求没到 Controller 就被拦了。记住:只要做了自定义拦截器,第一行必须先判断if ("OPTIONS".equals(request.getMethod())) return true;。

4.2 金额和时间类型最容易翻车

金额问题前面已经说了,必须用 BigDecimal。实际操作中还有一个坑是 MyBatis-Plus 查询结果映射:如果数据库字段是 decimal,实体类用 BigDecimal,不要手动转成 double。计算总金额时要留意小数位溢出,数据库 decimal(10,2) 最大支持到 99999999.99,一般水果订单不会超,但仍要做好金额上限校验。

时间类型推荐全部用 LocalDateTime,数据库字段用 datetime。默认情况下 Jackson 会把 LocalDateTime 序列化成一大串数组,前端根本没法直接用。需要加一段全局配置,定义日期格式:

@Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> builder .serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) .deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }

如果不做这个配置,你会发现订单列表页的时间列显示成一串数字字符串,看起来莫名其妙。这类问题在联调中非常常见,提前配置好能省不少沟通成本。

4.3 部署阶段的Nginx与路径问题

部署阶段最容易出的坑是前端路由刷新 404。Vue Router 用 history 模式时,用户停留在订单详情页,按 F5 刷新,浏览器会向服务器请求/order/123这个路径,但服务器上根本没有这个静态文件,于是返回 404。解决办法是 Nginx 里做 try_files 回退:

location / { root /home/app/dist; index index.html; try_files $uri $uri/ /index.html; }

后端接口通过反向代理转发:

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

注意proxy_pass后面是否带斜杠是有区别的。带斜杠会把/api前缀去掉再转发,如果后端接口路径也带/api,这里就不能去掉。我踩过一次这个坑:前端请求/api/user/login,后端要求也是/api/user/login,但代理把/api吞掉了,结果一直 404。整理部署文档时一定要把这个细节写清楚。

文件上传功能也有一个经典坑:上传的商品图片不能存到项目内部的src/main/resources/static目录。因为后端是打成 jar 包运行的,jar 包内部的静态资源没法在运行期修改,重启之后上传的图片也会丢失。正确做法是配置一个外部目录,比如/home/app/upload,然后通过自定义静态资源映射让/upload/**指向这个目录。

4.4 接口联调时的高频小问题

分页参数最容易不一致。前端传pageNum和pageSize,后端接收时参数名一致,但封装返回结果时Page对象里默认字段是current和size,不是pageNum和pageSize。要么构造一个统一返回结构,把分页字段重新命名;要么前端在请求层做转换,统一用current/size传参。两边口径不一致,列表页和搜索功能就会时好时坏。

对象拷贝也是一个隐患。数据库实体类 Entity 不能直接返回给前端,尤其是用户表里有密码字段。如果直接返回实体,前端在控制台里都能看到用户密码的加密串,虽然 BCrypt 一时解不开,但这属于最低级的接口安全问题。正解是定义 VO 对象,只暴露需要的字段。

Lombok 也不是没有坑。实体类只要加了@Data,再手动写了一个构造函数,就容易和全参构造函数冲突。最好固定习惯:有业务需要就写@AllArgsConstructor和@NoArgsConstructor显式声明,不要在@Data之外自己加非标准构造器,否则一编译就是一排红色报错。

MyBatis-Plus 的逻辑删除也要注意。商品实体类加了@TableLogic字段后,所有查询都会被自动加上deleted = 0条件。有次我在后台管理列表里死活查不到一个刚删除的商品,检查半天才发现逻辑删除生效了。这个功能本身没问题,但你要清楚它会在所有查询上生效,避免在联调时产生“数据丢了”的错觉。

最后再用一个实际操作中的经验收尾。这套系统第一版跑通时,我把提交订单、扣库存、清购物车拆在三个 Service 里分别调用,结果前端连续点了两次提交按钮,库存直接被扣成负数。后来把所有操作收进一个事务方法,扣库存改成条件更新,再次并发测试才稳定下来。电商项目的核心从来不是会写多少 CRUD,而是如何在并发和异常情况下保证订单、库存、金额这三条数据不出错。这套系统我后续准备加一个小程序端,核心接口完全复用现在的 REST API,如果后面做完,我会再把小程序端的对接过程单独整理成一篇继续分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询