资讯详情

Spring Boot + Vue 全栈开发:攀枝花水果电商系统实战

📅 2026/10/22 7:50:49 | 华诺云谱 👁 阅读
Spring Boot + Vue 全栈开发:攀枝花水果电商系统实战
最近帮一位做农产品电商的朋友搭建了一个攀枝花水果在线销售系统用的是 Spring Boot Vue 的前后端分离架构从需求梳理到部署上线差不多花了三周。这个项目不算大但“五脏俱全”——用户注册登录、水果分类展示、购物车、订单流转、后台商品管理、销量统计都有而且全部围绕攀枝花本地的芒果、枇杷、石榴、樱桃这些特色水果设计业务逻辑。整理这篇文章主要是想把整个项目从零到一的关键决策、核心代码思路、踩过的坑都记录下来。如果你是刚接触 Spring Boot 和 Vue 全栈开发的学生或者准备做类似电商系统的开发者这篇文章应该能帮你少走不少弯路。我会尽量把“为什么这么做”讲清楚而不只是贴代码。1. 项目背景与整体设计思路1.1 攀枝花水果电商的核心需求是什么攀枝花地处金沙江干热河谷光照充足芒果、枇杷、石榴都是国家地理标志产品水果品质有天然优势。但实际售卖中传统批发渠道利润薄本地果农又很难直接触达外地消费者。所以我们一开始就把系统定位成“产地直发”的水果商城解决三个核心问题让消费者能用小程序之外的方式浏览当季水果看到产地信息、成熟度说明、实拍图。让商家能自己管理商品上架、库存、价格不用依赖第三方平台的各种抽佣和规则。让订单和物流信息对用户透明从“下单”到“收货”的全流程状态可查。所以系统拆成了两个端用户端负责浏览、下单、支付、查询物流管理端负责商品管理、订单处理、数据统计。这也是绝大多数电商类毕业设计或小型商业项目的主流划分方式。1.2 为什么选 Spring Boot Vue 这套组合技术选型没有绝对最优只有最合适。当时对比过几个方案方案优点缺点结论Spring Boot Thymeleaf 服务端渲染上手快单体架构简单前后端耦合重交互体验一般后期维护成本高项目需要多端适配放弃Spring Boot 纯后端 Vue 3 前端前后端职责清晰可独立开发测试部署需要处理跨域、Token 认证等额外逻辑最终采用Node.js Express Vue前端技术栈统一后端生态和事务管理不如 Java 成熟团队后端经验以 Java 为主放弃选 Spring Boot 3 是因为它内置了 Spring Security、MyBatis-Plus 集成、Redis 支持等完整生态处理订单这种强事务场景比脚本语言更稳。Vue 3 用了 Composition API配合 Element Plus 组件库后台管理页面开发效率很高。注意如果你是完全零基础不建议直接照搬这个项目。先跑通 Spring Boot 的 Hello World再回来啃这个项目会顺畅很多。1.3 系统功能模块全景规划画不出漂亮的架构图没关系但脑子里要有清晰的模块地图。我们项目最终落地了这些功能用户端注册登录、首页水果推荐、商品列表按分类筛选、商品详情含多图轮播、加入购物车、提交订单、模拟支付、订单列表与状态跟踪、个人中心地址管理、资料修改。管理端管理员登录、商品分类管理、商品上下架与库存修改、订单列表发货、退款处理、用户列表、销量和销售额统计图表。公共能力图片上传、文件访问、统一异常处理、登录权限校验。一共设计了两张“用户视角”的流程图一张给消费者一张给管理员。消费者流程是注册登录→逛首页→选商品→加购物车→下单→模拟支付→等发货→确认收货。管理员流程是登录后台→管理分类→上架商品→看到新订单→发货→订单完成。这两条线就是整个开发的导航图。2. 核心数据模型与数据库设计数据库设计是这种业务系统的地基。地基歪了后面写代码全是补丁。我们用的是 MySQL 8.0设计了九张核心表。2.1 用户、地址与购物车用户表和大多数商城类似user表字段包括id、username、passwordBCrypt 加密存储、nickname、phone、avatar、create_time。这里强调一个细节密码绝对不存明文用 Spring Security 自带的 BCryptPasswordEncoder 加密。地址表address独立出来是因为一个用户可能有多个收货地址。字段id、user_id、consignee收货人、phone、province/city/district/detail再存一个is_default标记默认地址。购物车表cart是用户和商品的关联中间表id、user_id、product_id、quantity、checked是否选中用于批量结算。之所以单独留checked字段是因为很多购物车功能需要“勾选部分商品去结算”这个交互在前后端都要重点处理。2.2 商品、分类与轮播图分类表category很简单id、name、sort排序字段。攀枝花的商品分类就是芒果、枇杷、石榴、樱桃、火龙果、其他。商品表product是核心表之一字段较多字段类型说明idbigint主键category_idbigint分类外键namevarchar商品名称subtitlevarchar卖点副标题如“高山贵妃芒 香甜多汁”main_imagevarchar主图 URLsub_imagestext副图 URL 列表JSON 存储detailtext富文本详情pricedecimal销售单价original_pricedecimal市场价用于划线展示stockint总库存salesint销量下单成功自动增加statustinyint1 上架 0 下架这里我想特别说一下sub_images为什么用 JSON 存而不是建一张图片表。因为商品图片数量固定且很少我们控制在 5 张以内查询时一次性取出远比分表关联更高效。如果以后要做相册管理、图片标签再拆表也不迟。过度设计是学生项目最常见的问题。2.3 订单与订单项订单头表orders记录一次交易的汇总信息id、order_no唯一编号、user_id、address_id下单时把地址快照冗余存进receiver_info防止用户改地址后影响历史订单、total_amount、status、create_time、pay_time、ship_time、finish_time。订单状态用整数存0 待支付、1 已支付待发货、2 已发货待收货、3 已签收/完成、4 已取消、5 退款处理中。状态流转在服务层统一控制不允许前端随意传状态。订单项表order_item对应订单里的每一件商品order_id、product_id、product_name快照、product_image快照、price快照、quantity。这里反复出现“快照”是有重要原因的商品价格可能调整、商品可能下架但订单结算那一刻的价格和名称必须固定否则用户看到的历史订单会因为商品信息变动而变得不可信。2.4 后台管理员与评价表管理员表admin和用户表结构类似但不和用户表混在一起因为二者权限完全不同。评价表comment设计了id、order_id、user_id、product_id、content、rating1-5星、create_time。这里没有做“下单才能评价”的复杂校验而是在订单完成后前端判断当前用户是否已评价再显示表单入口。设计心得很多学校项目把评价做成商家手动在后台添加这是不符合真实场景的。评价必须是用户行为哪怕只是简单的按订单关联。3. 后端核心功能开发实录3.1 项目初始化与分层结构我用的是 Spring Initializr 生成基础工程Java 17 Spring Boot 3.1依赖选了 Spring Web、Spring Security、MyBatis-Plus3.5.3、MySQL Driver、Redis、Lombok、Validation。包结构按业务而不是按技术分层com.fruit.sales ├── common // 通用返回体、异常处理、常量 │ ├── Result.java │ ├── ResultCode.java │ └── GlobalExceptionHandler.java ├── config // 安全配置、跨域配置、MyBatis-Plus配置 ├── controller // 控制层 │ ├── user │ ├── product │ └── order ├── service // 服务层接口 │ └── impl // 服务实现 ├── mapper // MyBatis-Plus Mapper接口 ├── entity // 数据实体 ├── dto // 入参校验对象 └── vo // 返回视图对象为什么把ResultT放在 common因为所有接口都要返回统一定义的 JSON 结构{ code: 200, message: success, data: ... }。这样前端只需要处理一个标准格式不要每个接口写一套返回结构。3.2 登录认证与权限拦截登录接口大概长这样核心逻辑就是用户名密码校验 签发 Token。我们没有引入复杂的 OAuth2只用了 JWT Spring Security 过滤器链。Service public class UserAuthService { Autowired private UserMapper userMapper; Autowired private StringRedisTemplate redisTemplate; public ResultLoginVO login(LoginDTO dto) { User user userMapper.selectOne( new LambdaQueryWrapperUser() .eq(User::getUsername, dto.getUsername()) ); if (user null || !BCryptPasswordEncoder.matches(dto.getPassword(), user.getPassword())) { return Result.error(用户名或密码错误); } String token JwtUtil.createToken(user.getId(), user.getUsername()); return Result.success(new LoginVO(token, user.getNickname())); } }登录成功后生成 JWT后续请求在 Header 里带Authorization: Bearer token。我们用了一个拦截器JwtInterceptor统一解析 Token 并设置ThreadLocal中的用户上下文Controller 里直接通过UserContext.getUserId()拿到当前用户。实操提醒JWT 的 secret 一定要放到application.yml之外的独立配置或环境变量中别写死在代码里。之前见过有人把 secret 传上公开仓库整个系统等于裸奔。管理端的权限和用户端分开。管理端接口路径统一以/admin/**开头用另一个拦截器校验管理员 Token。这样用户和管理员两套身份互不干扰逻辑上更清晰。3.3 商品列表与条件分页查询商品列表是核心接口前端需要按分类、按价格区间、按搜索关键词、按销量排序来筛选。我们用了 MyBatis-Plus 的 Page 插件配合 LambdaQuery这里是代码片段public PageProductVO pageProducts(ProductQueryDTO query) { PageProduct page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.eq(Product::getStatus, 1); // 用户端只能看到上架商品 if (query.getCategoryId() ! null) { wrapper.eq(Product::getCategoryId, query.getCategoryId()); } if (StringUtils.hasText(query.getKeyword())) { wrapper.like(Product::getName, query.getKeyword()); } if (StringUtils.hasText(query.getOrderBy())) { if (sales.equals(query.getOrderBy())) { wrapper.orderByDesc(Product::getSales); } else if (price_asc.equals(query.getOrderBy())) { wrapper.orderByAsc(Product::getPrice); } } return productMapper.selectPage(page, wrapper); }这里有个小坑orderByDesc(Product::getSales)在 MyBatis-Plus 里没问题但你要是按price_desc排序注意字符串传参的校验避免出现 SQL 注入。所以orderBy字段我强制用白名单判断不允许直接拼接。3.4 订单状态机与库存扣减订单是电商系统最复杂的部分状态机是核心。我们抽了一个OrderService关键方法就是createOrder和handleStatus。创建订单的流程根据购物车选中的cartIds查出商品和数量。计算总金额同时做两件事检查库存是否充足stock quantity使用乐观锁或UPDATE product SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}来原子扣减。生成订单号。订单号生成规则时间戳 用户ID 随机数保证唯一别用数据库自增 ID。这里特别强调库存扣减的原子性。最开始我们写成先 select stock判断 0再 update stock stock - 1。这种“读改写”在高并发下会超卖。改成上面那条带条件的 UPDATE SQL 或者使用乐观锁版本号后实测并发下不会再出现负库存。支付这块我们没有对接真实支付网关。我的做法是提供一个模拟支付接口POST /order/pay/{orderNo}内部直接流转状态待支付→已支付。真实商业项目里你需要在这里调用支付平台的预下单接口并用回调通知更新订单状态。但作为学习和演示项目模拟支付完全够用。Transactional public void createOrder(OrderCreateDTO dto) { // 幂等校验防止前端重复提交同一批购物车 ListCartVO carts cartService.listCheckedCarts(dto.getUserId()); if (carts.isEmpty()) { throw new BusinessException(没有选中的商品); } // 计算金额生成订单头和订单项 // 扣库存、清空购物车 }Transactional一定要加在入口方法上这样createOrder中任何一个环节抛异常都会回滚不会出现“订单创建成功但库存没扣”的一致性撕裂问题。3.5 图片上传访问的坑商品图片上传用的是本地磁盘存储不是云存储。配置文件里加一个自定义路径file: upload-dir: /data/fruit/upload/上传接口用 MultipartFile 接收保存到指定目录文件名用 UUID 重命名防止冲突返回给前端的 URL 是/files/{filename}。然后写一个WebMvcConfigurer把/files/**映射到磁盘目录。Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceHandler(file: uploadDir /); }这个简单方案最大的坑是服务器重启后文件路径容易对不上或者前后端分离部署时图片域名不对。如果你是把 Vue 打包后放在同一个 Spring Boot 服务下跑相对路径没问题但如果你把前端部署到 Nginx图片请求要写绝对 URL 或让 Nginx 也代理/files路径。4. 前端 Vue 页面与核心交互实现4.1 工程结构与路由规划前端用 Vite 创建 Vue 3 工程UI 组件库选 Element Plus。目录src ├── api // 封装 axios 请求模块 │ ├── user.js │ ├── product.js │ └── order.js ├── router ├── store // Pinia 状态 ├── views │ ├── home // 首页 │ ├── product // 商品列表、详情 │ ├── cart // 购物车 │ ├── order // 订单确认、列表、详情 │ └── admin // 后台管理 ├── components // 公共组件轮播图、分页、商品卡片 └── utils // 请求拦截器、工具路由设计上用户端和管理端应该分开。我的做法是用户端路由不带前缀管理端路由统一挂在/admin下并加一个路由守卫判断管理员 Token 是否存在。4.2 商品列表页的筛选交互商品列表页是用户停留最多的页面。我们做了左侧分类树 顶部排序 Tab 中间商品卡片网格。排序 Tab 用el-radio-group绑定sortBy变量。点击排序时重新请求第一页数据。这里有一个是实际体验优化切换分类或排序时如果当前页码不是第一页应重置为第一页否则用户从第 3 页切到“销量排序”却还停在 3 页看到的是空列表体验很割裂。商品卡片跳转详情页用router.push({ path: /product/ id })。详情页需要单独请求一次商品详情接口而不是把列表页的商品数据通过 store 传递以免刷新丢数据。4.3 购物车与订单结算的前后端配合购物车页实现了“选中/取消选中”、“全选/全不选”、“数量增减”、“删除选中”。每次勾选状态变化前端都要更新本地状态但不需要立即把每次变化都发给后端。只有点击“去结算”时才把选中的cartId列表发给后端。后端接口根据这些cartId查出最新价格计算总额防止前端篡改价格。前端这里最容易出的 bug全选按钮的状态判断。如果购物车有 5 件商品其中 3 件勾选全选按钮应该是“半选”状态。Element Plus 的el-checkbox有indeterminate属性但必须手动设置不然 UI 上显示的是未勾选和真实状态不符。订单确认页从购物车页面跳过来携带cartIds参数页面通过 API 获取选中的商品和默认地址渲染。提交订单按钮点击后调submitOrder成功则跳转到“订单支付页”展示模拟支付二维码前端生成的假二维码或“模拟支付”按钮。4.4 管理后台的商品与订单管理管理后台整体布局用 Element Plus 的el-container左侧菜单右侧内容区。菜单项商品列表、商品编辑、分类管理、订单管理、数据统计。商品编辑页是表单 图片上传组合。图片上传组件我们用el-uploadel-upload action/api/file/upload :headersuploadHeaders :on-successhandleUploadSuccess list-typepicture-card 注意action必须写成实际后端上传接口地址因为我们开发时前端是localhost:5173后端是localhost:8080所以会先经过 Vite 的 proxy 代理。下面这段配置放在vite.config.js可以避免反复配跨域server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } }.env文件里的VITE_API_BASE_URL在本地设置为空字符串所有请求都走/api/**相对路径。生产环境如果前后端分离部署这个地址改成后端服务的域名即可。订单管理页的关键是状态流转按钮待支付订单显示“取消订单”已支付显示“发货”已发货没有操作按钮。这个动态按钮逻辑可以通过el-button的v-if绑定订单状态实现不用每个状态都写死。5. 部署上线与性能优化经验5.1 前后端分离部署的两种方式部署方案我们试过两种。方案一前端打包后整个放到 Spring Boot 的static目录下后端一个 jar 包跑所有。这个方案最省事不需要额外装 Nginx适合单机小项目或课程设计演示。缺点是以后前端想单独扩容做 CDN 就不方便了。方案二前端打包成dist放到 Nginx 的 html 目录Nginx 配置反向代理/api到后端的 8080 端口。这个方案更贴近企业实际。我们最后用的这个。关键 Nginx 配置不能直接抄要做路径适配server { listen 80; server_name your-domain; root /opt/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /files/ { alias /data/fruit/upload/; } }这里有两个容易踩的坑。第一个proxy_pass http://127.0.0.1:8080/结尾的斜杠很关键加上斜杠表示把/api前缀去掉再转发不加斜杠则原样转发。第二个Vue Router 是 history 模式时Nginx 必须配置try_files $uri $uri/ /index.html;否则刷新二级页面会 404。5.2 缓存与数据库优化商品列表接口扛着比较大的查询压力我们做了两个层级的优化第一层商品详情数据用 Redis 缓存。key 设计为product:detail:{id}value 是商品详情 JSON。当后台修改商品时删除对应缓存。// 伪代码 public ProductVO getProductDetail(Long id) { String cacheKey product:detail: id; String json redisTemplate.opsForValue().get(cacheKey); if (json ! null) return JSON.parseObject(json, ProductVO.class); ProductVO vo productMapper.selectDetail(id); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(vo), 30, TimeUnit.MINUTES); return vo; }第二层热点列表数据缓存一分销。首页推荐和热销榜的数据变更频率很低直接缓存 5 分钟这样即使用户刷新后端也扛得住。数据库层面我们只加了三个普通索引商品表的category_id和status联合索引订单表的user_id。不需要过度加索引因为数据量还没有大到需要复杂优化。把 SQL 打印出来看一眼确认没有全表扫描就够了。5.3 安全防护的几个必做项接口权限所有/admin/**接口必须校验管理员 Token否则任何人可以直接访问后台。参数校验前端传的数量quantity必须是 1-999 的整数如果不做校验有人传负数会导致库存越扣越多。统一异常处理用RestControllerAdvice捕获所有异常避免把堆栈信息直接返回给前端。像业务异常返回明确提示系统异常返回“服务器开小差了”详细日志只打印到控制台。我见过一个反面教材接口报错时直接把整个 SQL 异常信息返回给前端页面上一大串英文。用户看不懂语法还暴露了表名字段名非常不安全。6. 常见问题与排查技巧实录6.1 本地前后端联调时接口 404这个问题十个人有九个遇到。现象前端口令页面能打开但所有后端请求都 404 或者要求登录跳转到 401。排查思路先看浏览器 Network请求 URL 是什么。大概率是/api/xxx而后端接口是RequestMapping(/product/list)没有/api前缀。Vite proxy 做了 rewrite 还不能解决那就是 proxy 没有生效。检查vite.config.js是否写在了正确的层级要放在export default defineConfig({ server: ... })里。如果后端 8080 端口不能确认是否起来直接用 Postman 请求http://localhost:8080/接口路径能通就说明问题出在前端代理。6.2 并发下单出现库存超卖前几天在测试环境压了一下10 个线程同时买同一件库存只剩 5 件的水果结果有 6 个订单创建成功。原因就是我们最开始用了“先查再改”的方式两个线程同时查到了库存 5然后各自减 1最后库存变成 3但生成了两笔订单。解决办法就是前面说的把判断和扣减合并成一条 SQLUPDATE product SET stock stock - #{qty} WHERE id #{productId} AND stock #{qty}如果影响行数为 0说明库存不足或商品已下架直接抛出“库存不足”异常。事务回滚后订单也不会创建。这个方案虽然简单但在这个量级的商城系统里完全够用。如果你要做秒杀级的高并发那就要考虑 Redis 预扣库存 异步下单了但那是另一个复杂度的项目了。6.3 用户上传的水果图片显示不出来排查这类问题有个固定顺序先看数据库存的 URL 是什么再看前端请求的 URL 是什么最后看后端文件目录里有没有这个文件。常见原因之一本地开发时前端上传的图片 URL 是http://localhost:8080/files/xxx.jpg但前端页面是http://localhost:5173图片请求自动被浏览器按相对路径解析到 5173 端口结果 404。解决办法是图片 URL 必须是完整路径带上后端端口或者在后端返回图片 URL 时统一通过配置项拼接前缀。常见原因之二后端部署到服务器后文件是写到了本地磁盘的/data/upload但 Nginx 代理/files时 alias 路径写错了比如多了一层目录。这个靠浏览器 Network 看图片请求的状态码和实际返回路径很快能定位。6.4 时区导致订单时间相差 8 小时Spring Boot 连接 MySQL 时如果 JDBC URL 没有配置 serverTimezone取出来的create_time经常会比实际时间早 8 小时。最省事的解决办法是在连接串里明确指定jdbc:mysql://localhost:3306/fruit?serverTimezoneAsia/Shanghai同时 Jackson 序列化 LocalDateTime 时也容易变成默认格式yyyy-MM-ddTHH:mm:ss前端展示不友好。在application.yml里统一设置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai这个配置能解决 80% 的日期显示问题。剩下的 20% 是因为前端 new Date() 的浏览器时区不一致那个不要试图在前端转换统一由后端格式化后传字符串。7. 一些实在的个人体会这项目做完最大的感受倒不是技术多难而是业务细节决定成败。比如商品快照、状态机、库存原子扣减这些东西教科书上不会写得很细但实际做一遍踩一遍坑才真正理解为什么电商系统要这么设计。如果你是自己动手做类似的农产品电商项目我的建议是不要一上来就写代码。先花一天时间把数据表设计清楚用笔在纸上画一下每个状态怎么流转每个接口返回什么结构。表结构设计得好后端写起来非常顺表设计乱了后面全是改改改。另外学习和真实商业项目还是有距离的。如果是应付课程设计或毕设答辩把这个项目吃透把订单状态机、JWT 认证、文件上传这几个点讲明白基本就是优秀水平。如果是要真正上线做生意建议接入真实支付、增加物流信息对接、做二级缓存、加操作日志这些每一步都是“从能用到好用”的差距。最后分享一个小技巧开发联调阶段后端接口用CrossOrigin配合前端的 Vite proxy 双保险但生产环境一定要把前端的代理去掉改成 Nginx 反向代理。我就是当年没注意区分结果部署后图片和接口全部失效排查了整整一个下午。前后端分离不只是代码分离部署和服务治理同样分离这才是这套架构该有的姿态。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑