
先说结论一套能跑通的茶叶商城究竟要解决哪些问题如果你打开招聘软件搜一下全栈岗位会发现 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 ResultT { 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用户加购不经过后端登录之后前端把本地购物车合并到后端 RedisRedis 保存近 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 的组合式 APIComposition 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/后端返回给前端的是可以拼接的 URLhttp://你的域名/images/xxx.jpgNginx 再做一个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 打包成 jarJava 环境是 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你就可以底气很足地把这个项目往桌上一放然后不慌不忙地讲起你在库存扣减上踩过的那几个坑。