先说结论:一套能跑通的茶叶商城,究竟要解决哪些问题
如果你打开招聘软件搜一下全栈岗位,会发现 Spring Boot + Vue 的组合几乎成了中小型电商项目的标配。这个茶叶商城系统就是这么来的:一个刚起步的茶叶品牌方,要做一套既能展示商品、又能在线下单的后台管理系统,要求前台用户操作要顺,后台管理员要看得到库存、订单和流水。技术栈直接定了 Spring Boot + Vue,原因很简单:团队熟悉、招聘成本低、上手快,而且社区资料足够多,真到踩坑的时候不至于求助无门。
这个项目我自己从头到尾搭过一遍,前后端分离,包含了商品管理、分类管理、购物车、订单、支付模拟、用户登录注册、后台数据统计等模块。用到的核心组件大致如下:
- 后端:Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis + JWT
- 前端:Vue 3 + Vite + Element Plus + Pinia + Axios
- 部署:前端 Nginx 静态托管,后端打成 jar 包用 systemd 守护
如果你正准备自己做一套电商系统练手,或者想在企业项目里快速落地类似的商城模块,这篇文章会把我的设计思路、核心代码逻辑、联调过程中踩过的坑,以及我认为最重要的经验教训,一次说清楚。
需求拆解:茶叶这种品类,和普通电商有什么不一样
很多人一听到"商城系统",第一反应是抄一套通用的电商模板,商品表、订单表、购物车表一建就完事。但真去接触了茶叶项目,你会发现这个品类有几个特点,直接影响表结构设计和业务代码逻辑。
1.1 茶叶商品的核心属性:规格、等级、年份、产地
茶叶和标准化的电子产品不一样,它的 SKU(库存单位)极其琐碎。比如同一款"高山乌龙茶",可以按等级分为一级、特级,按包装分为礼盒装和散装,按年份分为当年新茶和陈茶。商品属性如果不提前规划好,后期改表结构会非常痛苦。
我当时的设计思路是:商品表(goods)只存公共信息,比如商品名、主图、详情描述、上架状态。规格属性拆到单独的 sku 表,每个 SKU 拥有自己的价格、库存、规格描述、图片和货号。这样一个商品可以对应 6 个、8 个甚至更多 SKU,前端商品详情页切换规格时直接请求 SKU 维度数据,价格和库存即时联动。
注意:茶叶的"货号"很重要。因为线下批发和线上零售经常混着来,货号打错了,仓库发货时候对不上,买家投诉的沟通成本极高。
1.2 购物车和订单:需要考虑预售、多仓发货和限购
茶叶商城还有一个特点:部分高端茶是预订制或限量发售的,比如头春茶、明前茶。这意味着库存字段不能简单用一个整数表示,还需要考虑状态——可下单、预售中、已售罄。我在 goods 表里加了一个 sale_type 字段,普通商品走正常库存扣减,预售商品在订单创建时不立即扣减库存,而是等支付后再锁定库存。
订单表的设计也比较讲究。一个订单必须有主订单表(order)和订单明细表(order_item)两张表。主订单存总金额、收货信息、订单状态、支付流水号;明细表存每个 SKU 的单价、数量、快照信息。为什么强调"快照"?因为商品价格和名称随时可能被后台修改,如果订单明细里不存一份当时的商品快照,以后查历史订单就会出现"价格对不上"的尴尬情况。
1.3 后台管理端:权限和可视化统计是重点
这个项目不是只有 C 端展示,更核心的是给运营人员用的后台管理端。后台需要支持多个角色:超级管理员可以管理一切,运营人员只能改商品和分类,客服人员只能查订单、改订单状态。权限模型用的是 RBAC(基于角色的访问控制),用户-角色-菜单三级关联。
另外茶企老板最关心的数据不是抽象的流量指标,而是:哪款茶卖得最好、哪个地区的订单最多、本月毛利大概多少。我当时给后台做了一个简单的统计面板,按日展示订单量、销售额、客单价;按商品维度展示 Top 10 榜单。这些统计用 SQL 聚合就能做,没必要一上来就接大数据组件。
后端实现要点:Spring Boot 这边我重点打磨的地方
后端是整个商城系统的核心,我在这里踩过不少坑,也沉淀了一些个人认为很实用的实现方案。
2.1 统一返回结果与全局异常,不然前端会被你逼疯
开始写接口的时候,最忌讳每个接口返回格式都不一样。有人返回 Map,有人直接返回一个字符串,前端同学接数据还得逐个看接口文档。我从一开始就要求所有接口返回统一的结构:
public class Result<T> { private Integer code; private String message; private T data; }code 约定 200 表示成功,400 表示参数错误,401 表示未登录,500 表示服务器异常。前端在 Axios 拦截器里统一处理这些 code,判断是否弹出错误提示、是否跳转登录页。这个规范一旦定下来,前后端联调会省下大量扯皮时间。
全局异常处理用@RestControllerAdvice实现。业务异常专门封装了一个 BizException,比如库存不足、商品已下架、订单状态不允许操作,这些都要抛 BizException,然后统一转换错误码和提示信息。系统的 RuntimeException 也要兜底处理,避免直接把堆栈信息抛给前端。
2.2 JWT 登录认证与权限控制的实践
用户认证我选了 JWT,而不是传统的 Session。前后端分离项目里,Session 需要处理跨域携带 Cookie,比较麻烦,JWT 把用户信息加密放在 Token 里,前端请求时在请求头带上Authorization: Bearer token,后端在拦截器中解析。
Token 里我存的是用户 ID、角色编码、过期时间。之所以不存太多额外信息,是因为 Token 是 Base64 编码的,虽然不加密,但谁都能解码。如果你把用户手机号、住址放进去,等于变相泄露隐私。还有一点,JWT 是无状态的,想要强制让用户下线很困难,所以在后台管理端,我额外用 Redis 存了一份"当前有效 Token"的白名单。如果用户修改密码或被管理员禁用账号,直接从 Redis 删掉对应记录,强制重新登录。
2.3 购物车用 Redis 还是数据库
购物车这一块我犹豫过一段时间。用 Redis 的 Hash 结构存购物车,性能极好,用户每次加购、勾选、改数量都很流畅;但有个问题——用户换设备、清浏览器缓存,Redis 数据可能丢。用数据库存购物车,逻辑简单可靠,但频繁读写数据库,对数据库压力不小。
我最后采用了折中方案:未登录状态下,购物车数据存前端本地(localStorage),用户加购不经过后端;登录之后,前端把本地购物车合并到后端 Redis,Redis 保存近 30 天。用户换设备只要重新登录就能恢复购物车。这种方式下,数据库无关购物车,Redis 挂了也不至于影响核心下单链路,等用户真正下单时直接读 Redis 购物车生成订单。
2.4 订单流程:库存扣减的核心逻辑
订单模块是整个系统里我最谨慎的地方。第一次做的时候,我直接用"先查库存再扣减"的方式,结果高并发下一测就发现超卖。后来改成在 SQL 语句里直接做条件更新:
UPDATE sku SET stock = stock - #{count} WHERE sku_id = #{skuId} AND stock >= #{count}这条 SQL 等于把判断库存和扣减库存合并成了原子操作。受影响行数等于 1,说明扣减成功;等于 0,说明库存不够,直接返回"库存不足"。这样不用锁表,也不用引入分布式锁,就能解决绝大部分超卖问题。当然,如果未来并发量真的大到需要消息队列削峰,那是另一个话题了。
订单创建还是应该放进事务里。创建主订单、插入订单明细、扣减 SKU 库存、清空购物车中相关商品,这四步必须全部成功,任何一个失败都要回滚。
2.5 支付模块:模拟支付不等于随便糊弄
这个项目没有真正对接微信支付或支付宝,而是做了一套模拟支付的流程:用户点击"去支付",后端生成支付二维码(接入一些开源的二维码生成库),前端轮询支付状态。模拟支付的核心在于订单状态机要清晰。
我定义的订单状态是这样的:
| 状态值 | 含义 | 可执行操作 |
|---|---|---|
| 0 | 待支付 | 取消订单、去支付 |
| 1 | 已支付/待发货 | 申请退款 |
| 2 | 已发货 | 确认收货 |
| 3 | 已完成 | 申请售后 |
| 4 | 已关闭(超时或用户取消) | 无 |
超时未支付的订单,我用一个定时任务每分钟扫描一次,超过 30 分钟未支付的订单自动关闭并释放库存。这个定时任务最开始用的是@Scheduled注解,跑起来没问题,但要注意分布式部署下多实例会重复执行,需要在任务上加分布式锁。设计简单一点,其实可以交给 Redis 的过期回调或者消息队列延迟任务来做,但是初期项目用定时轮询完全够。
前端实现要点:Vue 页面和交互的落地方案
前端用了 Vue 3 的组合式 API(Composition API),配合 Vite 构建。之所以不用 Vue 2,一是新项目没必要再用老技术,二是 Element Plus 只支持 Vue 3。这里记录几个我花了比较多心思的模块。
3.1 前端路由与权限控制
路由分成两块:商城端的用户页面和后台管理端的页面。后台管理端是一个独立布局,顶部侧边导航菜单栏根据用户权限动态渲染。后端接口会返回当前用户拥有哪些菜单编码,前端拿到菜单编码后动态注册路由,并且用 Vue Router 的全局前置守卫做拦截。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (token && to.path === '/login') { next('/') } else { next() } })不过有一点需要注意:前端守卫只是体验上的跳转,真正的权限校验必须在后端接口做。因为前端所有代码用户都能看到,只靠隐藏按钮和路由守卫不能防住恶意请求。
3.2 商品列表的筛选与分页
商品列表页看起来简单,但体验好坏差别很大。茶叶商城的用户通常有三个维度的筛选需求:按茶叶类别(绿茶、红茶、乌龙茶、白茶、黑茶)、按价格区间、按产地或者品牌。我在前端用了一组筛选条件组件,每次筛选变化就重新请求商品列表接口。
后端接口的参数是 pageNum、pageSize、categoryId、minPrice、maxPrice、keyword、sortField。排序这里有个小坑:数据库字段 sort_order 不能直接让用户传字符串,因为用户传任何字段名都可能引发 SQL 注入。我的做法是后端维护一个允许排序的字段映射表,前端只传 sortField 的枚举值,比如 "default"、"price_asc"、"price_desc"、"sales_desc",后端映射到具体的数据库字段。
3.3 购物车页面与本地合并
前端购物车页面的数量加减和勾选,我全部用 Pinia 来做状态管理。选择哪些商品去结算,这些数据放在 store 里,点击"去结算"时把选中的 SKU 列表和数量传给后端创建订单接口。
本地购物车合并到登录状态的逻辑,需要特别注意操作时机。用户在未登录状态下往购物车加了很多商品,然后点击登录,登录成功后前端把本地购物车一并传入后端,后端按用户维度做合并。同一样商品累加数量,不同商品直接追加。合完以后清空 localStorage 中的购物车数据。
3.4 商品详情的规格切换
商品详情页的规格选择是另一个容易写乱的地方。用户点击不同的规格选项,页面要立即展示对应的 SKU 价格、库存、图片。我用一个 computed 属性监听用户选中的规格组合,再根据组合去 skuList 里查找匹配的 SKU。
规格选择和商品图片联动也要考虑。比如某个 SKU 单独上传了自己的图片,切换规格时应该更新图片轮播组件。没有单独图片的 SKU 就用商品主图。这种细节做得好,用户会觉得这个商城"挺正规"。
3.5 后台管理端的表单校验和交互
后台管理端的核心是商品的增删改查,尤其是商品表单,字段特别多:商品名称、分类、主图、详情图、描述、是否上架、销售模式、SKU 列表。SKU 列表是一个动态表格,运营人员可以自己添加一行或删除一行,每一行包含规格名、价格、库存、货号、图片。
动态表单用 Element Plus 的 el-table 配合 v-for 循环渲染;表单提交前要校验:商品名不能为空、价格不能为负、每个 SKU 的库存不能小于 0。校验规则如果散落在各个提交方法里很容易漏,我把所有校验汇总到一个 validateGoodsForm 方法,return 一个布尔值,不通过就提示具体哪一项有问题。
联调、测试与部署:上线前最容易翻车的几个环节
代码写得再漂亮,联调一乱、部署一崩,前面的功劳全白费。这里整理我在这个茶叶商城项目里实际遇到的几个问题以及排查过程。
4.1 跨域问题:Nginx 配置反向代理
前端项目开发模式下用 Vite 代理解决跨域,生产环境部署时用 Nginx 反向代理。刚开始部署时我踩了一个坑:Nginx 只配置了前端静态文件的 location /,但没有把 /api 开头的请求反向代理到后端服务,结果上线后所有页面能打开,但一登录就报 404。
正确配置如下:
server { listen 80; server_name yourdomain.com; location / { root /var/www/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files 那行必须写,否则前端路由刷新会变成 404。很多人在部署历史路由模式的 Vue 应用时都会遇到"刷新后白屏"的问题,就是少了这行配置。
4.2 图片上传与访问路径的坑
商品图片上传在一个文件存储服务器上,后端负责接收 MultipartFile 并写入磁盘,返回图片相对路径。开发环境一切正常,但部署到服务器后我发现图片上传成功了,页面却加载不出来。查了半天才发现是文件写入路径的问题。
我当时犯的错误是用了相对路径images/upload/,而服务运行目录每次启动都可能变化。正确的做法是配置一个绝对路径,比如/data/tea-shop/images/,后端返回给前端的是可以拼接的 URL:http://你的域名/images/xxx.jpg,Nginx 再做一个location /images/的映射到磁盘目录。
这种做法虽然简单,但注意要定期备份图片目录,硬盘坏了图全没了,比数据库丢了还心疼。
4.3 联调时最常见的 3 类问题
- 请求头不带 Token。很多前端新手会把 Token 放在请求体或者 query 参数里,后端拦截器从 Header 取,结果拿不到。统一在 Axios 请求拦截器里添加 Token,别在每一个接口单独处理。
- 时间格式不一致。后端返回的日期是 "2025-05-06 12:30:00",前端直接显示没问题,但如果要做倒计时或者比较大小,最好统一处理后端时间格式为时间戳或标准 ISO 格式。
- 金额精度丢失。后端计算金额时使用 BigDecimal,前端 JavaScript 的 Number 类型在处理小数时会有精度问题。我的做法是:后端以小数为单位返回金额,前端展示时再转换成元。比如 12500 分 = 125.00 元。
4.4 部署步骤记录
我用的部署方式比较朴素,但很稳:
- 后端项目用 Maven 打包成 jar,Java 环境是 OpenJDK 8。
- jar 包通过 systemd 配置开机自启,日志用 journalctl 查看。
- 前端 npm run build 构建出 dist 目录,上传到服务器 /var/www/html。
- Nginx 配置完成后,systemctl reload nginx。
- MySQL 数据库提前建好,用 Flyway 或者直接导入 SQL 初始化脚本。
如果后端需要修改配置,我习惯把 application.yml 里的数据库密码、Redis 地址都抽成环境变量,这样换环境部署不需要改代码重新打包,只改环境变量就行。
复盘:几个让我印象深刻的经验教训
项目做完以后回看,有几件事如果早点明白,能省下很多加班时间。
5.1 先画好表结构,比先写接口更重要
这个项目第一步我不是写 Controller,而是花了一整天设计数据库表。商品表、SKU 表、分类表、购物车表、订单表、订单明细表、用户表、角色表、菜单表、支付流水表,每张表的主键、外键、唯一约束、索引都列清楚,然后才开始写代码。
一开始我嫌麻烦,觉得表可以边写边改,但后来越改越痛苦——订单表加了字段,商品表被迫跟着改,关联查询也乱了。后来我学乖了,表结构设计阶段多花的时间,在开发阶段一定会加倍还回来,而且是正利息。
5.2 命名规范统一,团队协作才不遭罪
这个项目虽然核心是一个人写,但代码的命名仍然尽量统一。比如所有接口统一以 /api 前缀开头,管理端接口再细分 /api/admin,用户端的接口是 /api/user。所有 Controller 类名后缀都是 Controller,所有 Service 接口名后缀是 Service,实现类后缀是 ServiceImpl。
统一命名的最大好处是,项目停下来几个月后再回来看,不需要重新回忆当初的命名思路。
5.3 页面可以简单,但状态流转不能乱
有些同学做商城页面,觉得界面美观比状态流转重要。但作为后端联调的人,我最怕的就是前端自己造出一些"不存在"的状态。比如订单已经发货,用户端的按钮却显示"去支付";订单已经取消,后台还能看到"发货"按钮。
前后端应该明确共用一份状态枚举。我在后端把订单状态枚举值返回给前端,前端每个操作按钮的显示条件都绑定到当前状态值,宁可多写几个 v-if,也不要靠"根据字符串判断"这种宽松写法。
5.4 留好日志,排查问题才能不下跪
上线初期遇到最多的问题就是:"用户说下单失败了,但没报错,不知道怎么回事"。这种问题只能靠日志。我排查了几个问题后,强制自己在关键节点加了日志:
- 订单创建前打印用户 ID、商品 ID、数量
- 库存扣减后打印剩余库存
- 支付回调打印原始报文
- Token 解析失败时打印异常原因
日志级别在开发环境是 DEBUG,生产环境是 INFO,但业务关键节点用log.info固定输出。再加上日志文件按天切割保留 30 天,出了问题能往回查一段时间的数据。
5.5 这个项目后续还能怎么扩展
如果你也想拿这套东西继续玩,我建议几个方向:
- 接入真实的微信支付/支付宝,替换掉模拟支付模块
- 引入消息队列,把订单创建、库存扣减、短信通知异步化
- 增加秒杀/限时购活动,考验高并发处理能力
- 做一个小程序端或者移动端 H5,复用大部分 API
- 给后台管理端加上更丰富的数据看板,比如销售趋势图、区域热力图、库存预警
我个人在实际操作中的体会是,商城类项目最值钱的部分不是页面多好看,而是订单、库存、支付这三个核心链路不能乱。任何一环出了问题,影响都是直接的经济损失。你把这个茶叶商城系统完整走一遍,对电商业务的理解会比单纯刷十个实战教程都深。下次有人问你"你会不会 Spring Boot + Vue",你就可以底气很足地把这个项目往桌上一放,然后不慌不忙地讲起你在库存扣减上踩过的那几个坑。