Spring Boot+Vue海鲜电商系统实战:从商品建模到订单闭环
1. 从“菜市场”到“网络海鲜市场”项目定位与需求拆解1.1 这个项目到底解决了什么问题海鲜想做线上销售比普通标品麻烦得多。同一款带鱼按规格、产地、冰鲜还是冷冻价格能差出好几倍一个螃蟹订单冷链物流费用甚至能超过商品本身。普通电商系统直接套用“商品库存订单”的模型会在称重、保鲜、配送这些环节反复出问题。我当时做这个练习项目就是想把传统海鲜档口的业务流程完整搬到线上于是有了“003网络海鲜市场”这个编号。后端用 Spring Boot前端用 Vue前后端分离前台做商城浏览后台做商品与订单管理能跑通注册、登录、下单、支付回调、发货、评价这一整条链路。项目做完以后它不只是演示用的“玩具商城”而是把海鲜商品的特殊属性都考虑进去了。比如商品要支持按份、按盒、按斤计价库存要能拆成销售库存和实际库存订单创建后要能自动取消支付回调要做幂等处理。这些细节在普通图书、数码电商里经常被忽略但放到海鲜场景里全是刚需。对于正在学 Spring Boot 和 Vue 的同学来说与其做重复的管理系统不如做这种带行业属性的项目能把 Java 后端、关系型数据库、缓存、前端状态管理这些知识点全部串起来。1.2 功能边界不只是一个“商品列表加购物车”当时我没有一上来就堆功能而是先画角色。前台用户能浏览商品、按关键词搜索、加入购物车、提交订单、模拟支付、查看订单状态后台管理员能维护商品分类、上架商品、处理订单发货、调整库存如果有运营需求还能追加优惠券和满减活动。为了控制项目规模砍掉了复杂售后、退款、直播带货这些内容第一步先保证主流程闭环。权限方面技术方案比较常规单表用户 角色字段剩下靠接口层面控制。管理端接口统一加管理员校验普通会员只能操作自己的数据。这里给了一张模块清单模块前台功能后台功能用户注册、登录、收货地址管理管理员账号管理无法在前台注册商品商品列表、详情、搜索、分类筛选商品增删改、上下架、库存调整购物车加入购物车、修改数量、删除无订单创建订单、取消订单、模拟支付订单查询、发货、标记完成营销展示优惠价设置促销价、限购数量用这个表格把功能边界固定下来以后后续开发就很少出现“这里该不该做”的纠结。我个人比较推荐用小表先锁边界而不是做几十个页面再反过来梳理流程。这种方式在真实项目里也一样管用先定义清楚哪些是刚需哪些是锦上添花。1.3 技术组合为什么选 Spring Boot 加 Vue技术选型其实不需要追求新奇。Spring Boot 在国内后端生态里资料多、上手快内嵌 Tomcat 后打一个 JAR 就能跑很适合一个人维护。Vue 的组件化开发让页面复用变得非常直接配合 Element Plus 这类组件库后台管理界面写起来很快。前后端通过 JSON 交互开发时用 Vite 的代理转发请求部署时前端打包成静态文件后端只负责提供接口结构非常清晰。之所以坚持用分离架构而不是服务端渲染模板是因为我希望前端能独立部署也方便以后拆小程序或者 APP。如果一开始就把页面和后端堆在一个工程里后续加一个手机端界面就相当痛苦。当然分离也带来了跨域、登录态、打包部署这些额外问题这部分坑我在后面会专门展开讲。2. 数据库设计与认证方案先把地基打牢2.1 商品模型SPU、SKU 与售卖单位海鲜商城里“商品”这个概念如果只建一张表后期一定会后悔。我采用的是 SPU 和 SKU 两层结构SPU 指“冰鲜三文鱼中段”SKU 则具体到“冰鲜三文鱼中段 500g 装”和“冰鲜三文鱼中段 1kg 装”。商品主表存标题、主图、详情、品牌、分类SKU 表存规格名、售价、划线价、库存、SKU 图片。这样同一个商品不同规格可以共享详情但价格和库存相互独立。售卖单位这个字段容易被忽略。蛤蜊能按斤卖帝王蟹能按只卖刺身拼盘按份卖单位不同前端展示和后端计算逻辑就完全不同。我在商品表里增加了一个“售卖单位”字段同时保存“计价单位”和“每份净含量”再让价格字段统一按“展示价”存储。前端下单时传 SKU ID 和数量后端根据单位换算实际库存扣减量避免“按斤卖却按件扣库存”的问题。设计表结构时还要留好冗余字段。比如商品搜索会频繁按分类、关键词过滤如果每次查询都关联多张表一旦数据量大就会变慢。我在商品表里冗余了分类名称和状态列表页查询只查单品表只有在进入详情页时才按 ID 拉取完整规格数据。这种冗余让查询简单了很多付出的代价则是更新分类时需要同步更新商品表复杂度其实可以接受。2.2 JWT 登录认证无状态不等于没有状态登录方案没有用传统 Session而是选择 JWT。原因是前后端分离后接口可能被网页、小程序甚至移动端共用Session 依赖服务端存储和 Cookie 传输跨域场景下处理起来比较麻烦。JWT 把用户标识和过期时间放在令牌里后端解析签名后就能拿到用户信息天然适合接口鉴权。但“无状态”不代表万事大吉。令牌一旦签发在过期前是无法主动失效的如果用户退出或者被管理员禁号旧令牌仍然能用。所以我在设计上做了两个补偿一是令牌有效期设置成 2 小时刷新令牌有效期 7 天前端在请求发现 401 时自动调刷新接口换新令牌二是把用户状态字段塞进令牌里后端每次请求时顺手检查用户是否被禁用。这样既保留了无状态的优点又把风险控制住了。JWT 的密钥必须放在配置文件中而且不能提交到代码仓库。我当时踩过一个坑把密钥写死在代码里后来想把生产环境的密钥换成新的结果需要重新发版。正确的做法是把密钥写在环境变量或配置中心每次启动读取并支持定期更换。解析令牌时还要注意加一个 Base64 解码异常的处理不然前端传一个非法令牌过来后端直接 500接口的健壮性就没了。2.3 库存扣减用 Redis 锁和 Lua 挡住超卖“最后一件商品被两个人同时下单”是电商系统最经典的并发问题。海鲜商品在促销时很可能被瞬间抢空如果只是查库存、减库存、下单三步分开执行很容易超卖。我这里并没有直接把整个下单过程串行化而是把“扣减可用库存”和“生成订单”拆开处理。扣库存操作放到 Redis 里做用 Lua 脚本保证原子性。Lua 脚本的核心逻辑很简单先查出当前剩余库存如果大于等于购买数量就扣减同时把“已扣减数量”记录下来否则返回失败。Redis 单线程执行 Lua 脚本从读库存到写库存之间不会插入其他请求天然避免了超卖。数据库这边仍然保留一个库存字段下单成功后同步扣减库存不一致时以 Redis 为准做补偿。这个方案比单纯用数据库悲观锁性能好很多也比“直接锁表”优雅。不过 Redis 也不是零成本。如果用户下单后一直不支付Redis 里的库存会一直被占着。我做了超时释放订单创建后给 Redis 里的预占库存设置一个和订单超时时间一致的过期时间用户超时未支付订单取消的同时释放预占库存。如果用户支付成功则在确认支付回调时把预占库存转成已售库存数据库扣减真实库存。把库存拆成“预占”和“已售”两个概念后整个流程就清晰了。2.4 订单状态机与支付回调订单这部分的复杂程度远超预期。我最初只建了一个“状态”字段后来发现订单处理过程中既要判断“待支付”“已支付”“已发货”“已完成”还要处理“取消”“退款”“支付超时”这些分支状态之间不能乱跳。为保险起见我建立了一个简单的状态机当前状态允许发生的动作结果状态待支付用户支付已支付待支付超时未支付已取消已支付管理员发货已发货已发货用户确认收货已完成已支付用户申请退款简化已退款支付回调是这里最容易出错的地方。我并没有接真实第三方支付网关而是本地写了一个“模拟支付接口”发起支付后由模拟网关异步通知订单服务。回调可能重复、可能乱序因此处理回调时先检查订单当前状态如果已经是“已支付”就立刻返回成功不再重复处理。回调接口内部还要把“支付金额”和“订单金额”做对比不一致直接拒绝。幂等性不是可有可无的优化而是必须写在核心流程里。3. 核心功能实操从后端接口到前端页面3.1 项目目录结构如果目录乱后期写代码就是折磨。后端我按常见的分层结构组织seafood-server/ ├── src/main/java/ │ ├── controller/ # 接口层 │ ├── service/ # 业务层 │ ├── mapper/ # 数据库访问层 │ ├── entity/ # 实体类 │ ├── dto/ # 入参出参对象 │ ├── config/ # 跨域、拦截器、静态资源配置 │ ├── common/ # 统一返回结果、异常处理 │ └── SeafoodApplication.java前端按模块拆成视图、组件、状态、请求工具四个部分seafood-web/ ├── src/ │ ├── api/ # Axios 请求封装 │ ├── assets/ # 静态资源 │ ├── components/ # 通用组件 │ ├── router/ # 路由配置 │ ├── stores/ # Pinia 状态 │ ├── views/ # 页面 │ └── utils/ # 工具函数这样拆完之后每次新增功能我都能快速找到对应文件。一个好的目录结构不是给别人看的是给自己节省查代码时间的。3.2 后端商品查询与下单链路商品列表接口是最常用的接口我直接用了 MyBatis 动态 SQL支持按分类、关键词、价格区间筛选并做了分页。下面是一个简化版商品列表查询代码GetMapping(/products) public ResultPageResultProductVO list(ProductQuery query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); ListProduct products productMapper.selectByCondition(query); ListProductVO result products.stream() .map(product - { ProductVO vo new ProductVO(); BeanUtils.copyProperties(product, vo); vo.setLowestPrice(skuMapper.selectMinPrice(product.getId())); return vo; }) .toList(); return Result.success(new PageResult(result)); }列表页不需要展示每个规格的完整信息只需要该商品的最低价和销量所以我在 DTO 里加了一个lowestPrice字段用查询最低价 SKU 的方式填充。这里有个小技巧不要为了省事把 SKU 列表全部查出来再在 Java 里取最小值数据库一条MIN(price)语句就完成了减少很多数据传输。下单链路我建议按“校验地址 - 校验库存 - 扣减 Redis 库存 - 生成订单 - 生成订单明细”这几个步骤来做。生成订单必须使用事务而且要保证订单号和支付单号唯一。订单号生成我这里没有用 UUID而是采用“时间戳 用户ID后四位 随机数”的方式既保证唯一又能从订单号大概看出生成时间。用户 ID 后四位用于分表场景虽然现在用不到但以后订单量大了不需要重构主键规则。3.3 前端商品列表、购物车与登录态处理前端我没有用复杂的设计稿以实用为主。商品列表页调用/products接口把数据渲染成卡片点击商品进入详情页后选择规格、填写数量再加入购物车。购物车状态我放在了 Pinia 里而不是每次重新请求接口因为用户频繁改动数量时前端需要即时响应。代码大致如下export const useCartStore defineStore(cart, { state: () ({ items: [], }), getters: { totalCount: (state) state.items.reduce((sum, item) sum item.count, 0), totalPrice: (state) state.items.reduce((sum, item) sum item.price * item.count, 0), }, actions: { addItem(item) { const existing this.items.find((i) i.skuId item.skuId); if (existing) { existing.count item.count; } else { this.items.push(item); } }, }, });登录态处理是前端最容易出问题的一环。我在 Axios 封装里加请求拦截器每次请求自动带上Authorization: Bearer token响应拦截器收到 401 时先尝试调用刷新令牌接口刷新成功就重新发原请求刷新失败则跳转登录页。这里要注意防止多个请求同时刷新令牌我用一个isRefreshing标志位把并发的刷新请求合并避免把刷新接口调用好几遍。路由守卫负责页面访问权限。游客能访问商品列表和详情页进入购物车、结算页之前要检查是否登录。跳转策略是“先放行页面骨架页面加载时再请求用户信息如果未登录再跳转登录页”。这样能避免路由守卫里判断 token 过期导致的死循环。3.4 商品图片上传与静态资源映射商品图片上传是后台管理的基础功能。我在控制层写了一个上传接口接收 MultipartFile 后保存到本地磁盘同时生成随机文件名防止路径穿越和重名覆盖。保存路径没有放在默认的src/main/resources下因为那样打包进 JAR 后每次更新都需要重新发版。我把图片放在服务外部的一个目录然后在 application.yml 里配置静态资源映射spring: servlet: multipart: max-file-size: 10MB web: resources: static-locations: classpath:/static/, file:${upload.path}/配置完成后前端上传接口返回图片 URL比如/upload/20250101/xxx.jpg浏览器直接能访问。需要注意的一点是上传目录不能放在项目 src 下否则部署环境一变就丢图片。以后接对象存储也很方便只要把 upload 接口改成向云存储写入文件返回的 URL 换成云端地址即可。4. 实践中踩过的坑问题排查与解决方案4.1 上传图片能成功页面却显示 404这个问题的原因基本都在静态资源映射上。Spring Boot 直接访问本地磁盘路径时默认不会把外部目录映射成可访问 URL。我当时上传成功后在浏览器输入返回的地址一直 404排查了很久才发现是static-locations配置写错了。解决办法有三个检查点路径必须是文件系统绝对路径结尾要带/配置类里不能同时写多个WebMvcConfigurer导致覆盖默认配置上传目录要先手动创建好Spring Boot 不会自动帮你建目录。还有一个容易被忽略的坑前端如果通过 Nginx 部署图片的/upload/前缀请求也需要转发到后端服务否则打包上线后桃前能看图片部署后全部白屏。我在 Nginx 配置里加了一条 location 规则把/upload/代理到后端端口问题就解决了。4.2 登录拦截与跨域401 漫天飞前后端分离后跨域是必答题。Vite 开发模式下我用代理把/api转到后端生产环境用 Nginx 做同样的事。前端发出的请求始终是同源的所以后端其实可以不做跨域配置。但如果接入第三方调试工具或者后端接口需要直接暴露还是得配置跨域。真正麻烦的是登录拦截器。我一开始把拦截器配置放到了过滤器之后执行导致预检请求OPTIONS也被拦截前端大量出现 401。后来在拦截器里放行了所有 OPTIONS 请求并且设置Access-Control-Allow-Headers包含Authorization。这里要注意JWT 是从请求头拿的跨域预检如果不放行 Authorization 头浏览器会直接拦截真实请求页面表现和 401 很像但实际是 CORS 错误。排查时要先看浏览器控制台报错信息判断到底是 CORS 还是鉴权失败。4.3 定时任务取消超时订单误杀了刚支付的订单我做了个定时任务每分钟扫描创建超过 30 分钟且状态为“待支付”的订单直接改成“已取消”。上线测试时发现有用户刚支付成功订单就被取消了。原因是支付回调处理和定时任务并发运行两个请求同时读到订单状态定时任务先执行了取消回调再进来时发现订单已经是“已取消”导致状态错乱。解决办法是加数据库乐观锁。订单表增加version字段执行更新时带上条件status 待支付 AND version ?更新成功才说明这次修改有效。支付回调也做同样的条件更新两个操作同时执行时只有一方能成功。用状态加版本号的双重校验能避免大部分并发状态竞争问题。4.4 Vue 打包后刷新页面路由 404开发时路由一切正常打包部署到 Nginx 后从首页进去跳转没问题一刷新就 404。这是 Vue Router 开启 history 模式后的典型问题。服务端并没有对应的物理路径来响应/products/123所以返回了 Nginx 的默认 404。解决办法是在 Nginx 配置里增加 try_files 规则将所有非静态资源请求都转发到index.htmllocation / { try_files $uri $uri/ /index.html; }如果你用的是 hash 模式则没有这个问题但 URL 上会带#不够美观。生产环境推荐 history 模式加 Nginx 配置开发时用 Vite 代理即可。5. 复盘与后续扩展这个项目还能做成什么样5.1 如果重新做一遍我会调整这些设计最大的调整会放在数据库拆分上。现在商品、订单、用户、库存全在一个库里虽然开发方便但后续做报表统计时很容易影响主流程。如果是新建项目我会把订单和商品独立出来做读写分离至少预留一个订单分表的因子。另一个调整是文件存储本地磁盘方案在单机演示时足够但上线后要换成对象存储通过鉴权 URL 控制访问权限避免商品图被到处盗链。Redis 的使用也可以更进一步。目前只用了库存扣减和购物车缓存其实商品详情页的热点数据也可以缓存。对于访问量大的商品详情可以用 Redis 缓存商品基本信息设置 5 分钟过期并在后台修改商品时主动删除缓存。这样可以大幅减少数据库压力。做之前要处理好缓存穿透和雪崩至少给空值也设置短暂过期时间防止恶意请求打到数据库。5.2 进阶方向冷链配送、直播、分销体系海鲜电商的地域属性很强。后续可以增加“冷链配送模板”按城市设置是否配送、配送费、保温箱费用。下单时根据收货地址自动匹配配送模板不支持的地区直接提示无法配送。这个功能用一张配送模板表和一张运费明细表就能实现和商家后台的运费模板思路一致。营销方面可以增加直播预告和秒杀活动。秒杀功能需要把活动商品提前放入 Redis并在秒杀开始时用消息队列串行化订单请求。也可以在详情页加入“拼团”模式多人成团才能享受优惠价实际模拟团购状态变更玩法偏复杂但对理解分布式事务有帮助。建议先做限时秒杀再做拼团。最后还是想说一句电商项目不是功能越多越好而是把一条主流程打磨到足够流畅。代码写完以后我自己又重新走了一遍从注册、选购、支付、发货到确认收货的完整链路还特意模拟了支付超时和库存不足等异常场景。改掉几个状态判断问题后再把项目部署到服务器上才算真正做完。如果你也在做类似的 Spring Boot 加 Vue 电商项目遇到问题可以先从状态和并发这两个角度排查很多玄学 Bug 最后都出在这两个地方。这个项目做完以后我对前后端分离、接口设计、数据库事务和缓存一致性的理解明显上了一个台阶希望这篇记录也能给你提供一份有用的参考。