上个月刚把一套水果在线销售系统从数据库建表一路做到服务器上线技术栈就是 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下单用户IDtotal_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 带 #不会出现刷新 404history 模式 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 生成一个 tokentoken 里带上 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 分页就是两三行代码PageProduct page new Page(pageNum, pageSize); LambdaQueryWrapperProduct 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也不能超过当前库存。整个系统最核心的逻辑是提交订单。一个前端点击“提交订单”后端要做的事情远不止插入一条订单记录。完整流程是根据当前登录用户查出购物车中勾选的购物车项遍历校验每个商品是否还存在且已上架库存是否充足计算总金额每一项单价乘以数量累加金额用 BigDecimal生成订单记录状态为“待支付”生成订单项记录保存商品快照扣减库存这一步必须用条件更新防止超卖删除购物车中已下单的商品模拟支付回调把订单状态改为“已支付”。第 1 到第 8 步必须包在同一个事务方法里任何一步失败都要全部回滚。尤其是扣库存这一步SQL 要写成这样Transactional(rollbackFor Exception.class) public Long submitOrder(SubmitOrderDTO dto) { ListCartItem 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如果后面做完我会再把小程序端的对接过程单独整理成一篇继续分享。
阅读完成 · 觉得有帮助?