做茶叶生意的人想上一套系统最常问的一句话是市面上现成的商城模板一抓一大把但茶叶这行特有的山头批次规格单位年份等级这些玩法普通模板根本认不出来。我前前后后做过一套基于Spring Boot的茶叶商城系统从项目源码、数据库脚本到部署文档整个交付过不止一次期间踩过的坑、推翻重来的设计也不算少。这篇就把这套茶叶商城系统的完整设计思路和落地过程写透包括为什么这么选型、核心模块怎么拆、SKU与库存怎么处理、订单状态怎么流转、表结构怎么设计以及最终交付时源码和文档要整理到什么程度才算是真正能用。不管你是打算拿它做课程设计、毕业设计还是想给中小茶商做二次开发的底子这套思路都能直接参考。1. 茶叶商城为什么选Spring BootMySQL这套组合而不是更轻的方案1.1 茶叶生意的特殊性决定了普通商城模板不够用先说业务场景。茶叶不像普通标品它的商品维度极其繁杂同一个品种可能分不同产区、不同年份、不同等级、不同包装规格而每一组维度组合都会直接影响价格和库存。比如同样是红茶同一个茶园里春季采的和秋季采的价格可能差出一倍同款茶叶按饼卖、按盒卖、按斤卖库存逻辑也不同。普通商城模板里的规格通常只支持颜色、尺寸这类简单维度面对茶叶这种多维度SKU直接套用会非常痛苦。还有一层是库存管理的颗粒度。茶叶的库存很多时候不是简单的剩多少件而是要同时管理可售库存锁定库存已售库存。用户下单后如果一直不付款库存要不要被占住用户取消订单了库存怎么回补这些都是实际经营里天天遇到的问题代码层面的处理稍有不慎就会出现超卖或者库存虚高。还有一个容易被忽略的点是季节性与活动节奏。春茶上市、中秋礼盒、年底囤货茶叶商城的促销高峰非常集中下单支付的动作会在短时间内爆发。这就要求系统在架构上必须扛得住瞬时流量同时业务逻辑不能因为并发问题而出错。1.2 技术选型对比为什么最终落在Spring Boot上项目早期我其实对比过好几条路这里直接把对比结论列出来方便后来人参考方案优势劣势适不适合茶叶商城Spring Boot MyBatis MySQL生态成熟、资料极多、事务控制方便、团队招人容易需要自己写的东西较多上手有门槛非常适合尤其是需要完整业务闭环的项目SSMSpring MVC Spring MyBatis经典框架教学场景常见配置繁琐Bootstrap类配置要写一大堆能做但开发效率低没必要Node.js/Express前端语言统一、开发快事务与复杂业务编排相对薄弱强类型约束弱适合小工具不适合这种业务密集型的商城低代码平台上线快、界面现成业务逻辑一旦绕起来就寸步难行数据模型难迁移只能做展示站做不了正经交易系统我最终选择Spring Boot的核心理由有三个。第一事务管理成熟可靠Spring声明式事务用起来非常顺手订单状态和库存扣减这种需要强一致性的场景靠Transactional可以很优雅地搞定。第二生态整合成本低安全认证、参数校验、接口文档生成、缓存接入、定时任务Spring Boot都有相对统一的接入方式不用东拼西凑。第三招人、找人问问题都方便社区里关于Spring Boot商城项目的帖子多到看不完遇到问题基本都能搜到答案对于需要长期维护迭代的项目来说这一点非常宝贵。1.3 这套系统的技术栈清单最终落地的技术栈不算花哨但胜在稳定后端框架Spring Boot 2.x稳定版本即可不必追新ORM框架MyBatis-Plus逆向工程生成单表CRUD非常省事数据库MySQL 5.7或8.0存储引擎用InnoDB缓存Redis用来做商品缓存、购物车缓存、库存预扣权限认证Spring Security JWT前后端分离接口鉴权前端Vue 2/Vue 3 Element UI不做服务端渲染纯接口交互支付本地模拟支付服务方便演示业务流程不影响上线换真实网关这套组合最大的好处是每一层都有明确的替代方案即使以后觉得MyBatis不够爽想换JPA或者想把Vue换掉边界也都是清晰的不会牵一发动全身。2. 功能模块拆解从用户浏览到商家发货的完整业务链路2.1 前台用户端的核心链路茶叶商城的用户端链路本质上就是一条浏览→选品→加购→下单→支付→收货的主流程。我设计系统时没有过度堆砌功能而是把每个环节的关键动作都落成了具体模块。浏览环节包括首页轮播图、商品分类导航、商品列表页和搜索功能。这里有一个茶叶项目特有的小细节列表页必须支持多条件筛选产季、年份、价格区间、规格而不是只支持关键词搜索。因为茶叶用户往往是带着明确意图来的比如想找今年的春茶、预算在三百以内、要饼茶搜茶叶两个字是搜不出东西的。选品环节对应的是商品详情页和SKU选择器。用户进入详情页后需要看到当前规格的价格、库存、图片切换规格时价格和库存要即时变化。这个交互背后的数据支撑就是一个设计良好的SKU表后面我会详细展开。下单环节包含购物车结算和直接购买两条路径。购物车我采用Redis缓存数据库落地的双层结构用户加购时先写缓存结算时再落库生成订单这样既能保证响应速度又不会因为用户一直不加购就丢数据。支付环节是整条链路里最敏感的部分。因为是教学案例我没有直接接真实支付网关而是做了一个本地模拟支付收银台用户下单后跳转到收银台页面选择模拟支付成功或模拟支付失败系统根据结果走不同的订单流转逻辑。将来要替换成真实支付只需要在PayService这个接口里换一个实现类业务层完全不用动。2.2 后台管理端的模块划分后台管理端是茶叶商城项目里最容易被人忽视、但实际工作量一点都不小的部分。我大致划分了这几个模块商品管理分类管理、商品上架、SKU与库存维护、上下架状态切换订单管理订单列表、订单详情、发货操作、订单备注用户管理会员列表、收货地址管理、账号状态管理轮播图管理首页广告图配置系统管理管理员账号、角色权限简化版这里我想提醒一句后台管理模块不需要做得很花哨但商品上下架发货操作这两个功能必须做好权限控制因为它们在真实经营中是由不同角色操作的。后台用户表我单独设计和前台会员表完全分开避免权限边界模糊。2.3 模块拆分背后的设计原则功能拆完之后我当时给自己定了几条规矩后来证明非常有用第一前台接口和后台接口严格分开URL前缀不同、鉴权方式不同、参数校验级别也不同。第二所有业务操作都经过Service层Controller只做参数接收和结果封装不写业务逻辑。第三涉及金额、库存、订单状态变更的操作必须在Service层加事务并且记录操作日志。第四商品详情、首页推荐这类读多写少的数据优先走Redis缓存数据库只做最终数据源。这四条规矩看起来简单但很多商城项目的烂就烂在Controller里写了一堆业务代码改一个需求要翻十几个文件。边界清晰的项目后面加需求、修bug都会省很多事。3. 商品与库存茶叶交易里最核心的一物多规格设计3.1 SKU与SPU的建模思路茶叶商城的商品建模我强烈建议采用标准的SPUSKU双层结构。SPUStandard Product Unit是商品代表一款茶叶比如某品牌·高山红茶500g礼盒装SKUStock Keeping Unit是库存量单位代表这款茶下具体的规格组合比如500g盒装版250g袋装版。每个SKU都关联自己的价格、库存和图片。用户在前台看到的规格切换本质上就是切换不同的SKU。数据库层面SPU表存储商品的基础信息标题、描述、主图、分类SKU表存储各规格的独立信息两张表通过goods_id关联。这里容易犯的错是把规格信息直接拼在商品表里比如加一个spec字段存JSON串。这样做的直接后果是用户想筛价格低于三百的饼茶时SQL根本没法高效过滤只能把JSON全部查出来再在内存里算数据量一旦上来接口就卡死。正经做法是把价格、库存相关的字段全部设计成独立列让数据库索引发挥应有的作用。3.2 库存扣减方案我最终选择的是预扣库存支付确认库存扣减是商城类项目面试和答辩环节的高频问题也是实际开发中最容易出bug的地方。我前后对比了三种方案方案一用户下单时直接扣减库存取消订单或超时未支付时再回补。好处是简单直观坏处是恶意刷单会把库存全部占住真实买家反而买不到。方案二用户支付成功后才扣减库存。好处是不会误扣坏处是下单阶段完全不管库存的话高并发下可能出现几百人同时下单同一款茶支付时发现库存不够引发大量退款投诉。方案三下单时预扣库存支付成功后把预扣转为实扣超时未支付则自动释放预扣库存。这是真实电商系统里用得最多的方案兼顾了体验和库存准确性。我最终选择了方案三。具体实现是下单时先检查Redis中的剩余可售库存若大于0则用Redis的原子操作扣减可售库存并增加锁定库存同时生成一个待支付订单设置支付超时时间。用户支付成功后把锁定库存转为已售超时未支付则释放锁定库存。这套逻辑用Redis的decr/incr原子命令来支撑避免并发场景下数据不一致。3.3 防超卖的具体代码实践下单扣库存时的数据库操作我用了条件更新而不是先查后更从根源上避免超卖。核心SQL和Java写法大概是这样Transactional public boolean preDeductStock(Long skuId, Integer count) { // 条件更新只有当前库存充足时才扣减 int rows skuMapper.preDeductStock(skuId, count); return rows 0; }对应的SQLUPDATE goods_sku SET available_stock available_stock - #{count}, locked_stock locked_stock #{count} WHERE id #{skuId} AND available_stock #{count}别看这段代码简单里面的逻辑很关键。WHERE available_stock #{count}保证了任何一个时刻扣减操作只会在库存充足时成功而UPDATE本身是数据库层面的行级锁操作天然是原子的。两个用户同时下单时只有一个用户的更新能命中行另一个会返回0行受影响从而判定库存不足直接提示用户。这套方案的另一个好处是不需要额外引入分布式锁。比起先用SELECT查库存再判断、再UPDATE的两步走方案条件更新的写法既少了一次无谓的查询也彻底规避了并发下查到的库存和实际扣减时的库存不一致的问题。Redis虽然也参与了预扣逻辑但它管的是请求入口的快速拦截数据库管的是最终的一致性兜底两者职责清晰不会互相干扰。这里有一个我在真实项目里吃过亏的细节Redis预扣库存时如果用户下单后一直不支付Redis里的锁定库存会一直占用着。所以必须配合一个定时任务比如每5分钟扫描一次超时未支付的订单执行关单并回补库存。我这套系统里用的是Spring自带的Scheduled注解简单可靠不额外引入分布式任务调度框架。4. 订单状态流转从待付款到已完成的一套可跑通方案4.1 订单状态机的定义与流转规则订单模块是整个商城系统的交通枢纽所有模块最终都要跟订单挂钩。我在设计订单状态时没有用那种漫无边际的状态列表而是严格定义了五个核心状态和它们的流转方向状态含义可流转到的状态触发条件待付款下单成功等待支付待发货、已取消支付成功或超时/主动取消待发货支付成功等待商家发货待收货、已取消商家发货或商家主动取消售后场景待收货已发货等待确认收货已完成用户确认收货或系统自动确认已完成交易结束不可流转终态已取消订单关闭不可流转终态状态流转我统一收敛在OrderService里不允许在Controller或者其他Service里直接改订单状态。每个状态变更都对应一个明确的业务方法比如payOrder()、cancelOrder()、deliverOrder()、confirmReceipt()方法内部先做状态校验、再更新状态、同时写一条订单日志保证状态的每一次变化都有迹可循。有的同学可能会想用状态机的框架来管理这套流转但对于这种状态数量在10个以内的场景我认为完全没必要引入额外的复杂度。一个updateOrderStatus方法配合每个方法里的前置校验就能把状态流转控制得很清晰。引入重框架反而会让项目显得臃肿答辩时还要额外解释设计动机。4.2 支付回调的处理幂等是底线模拟支付的实现思路很简单用户下单后调用本地收银台接口收银台根据用户点击的结果调用PayService的handlePayCallback()方法。但这个简单的流程背后有一个必须严肃对待的问题支付回调必须是幂等的。也就是说同一个订单的支付成功通知无论被调用一次、两次还是五次系统最终的状态都应该是一样的。为什么会出现重复回调因为真实支付网关为了保证通知到达率会以一定的策略多次发起回调。在我的模拟支付里虽然不太会出现这种情况但代码必须按真实场景来写。具体做法是在处理支付回调时先去数据库查当前订单状态如果订单已经是待发货或更后面的状态说明支付已经被处理过直接返回成功不做任何重复操作。Transactional public void handlePayCallback(String orderNo) { OrderEntity order orderMapper.selectByOrderNo(orderNo); // 幂等校验已经处理过支付的订单直接返回 if (order.getStatus() ! OrderStatus.PENDING_PAY) { return; } order.setStatus(OrderStatus.PENDING_SHIP); order.setPayTime(new Date()); orderMapper.updateById(order); // 锁定库存转为已售 orderItemMapper.confirmSell(orderNo); }这里还要注意一个顺序问题先更新订单状态再执行库存的锁定转实销两者必须放在同一个事务里。否则万一更新订单成功但库存转换失败就会出现钱收了但库存对不上的严重数据问题。4.3 取消订单与库存回补用户主动取消或者超时关单时逻辑同样是两件事把订单置为已取消同时释放锁定库存。释放库存的SQL依然采用条件更新UPDATE goods_sku SET available_stock available_stock #{count}, locked_stock locked_stock - #{count} WHERE id #{skuId} AND locked_stock #{count}注意这里的locked_stock #{count}同样重要它防止了并发场景下把锁定库存扣成负数。这个更新即使发生了取消操作和支付操作同时到达这种极端竞争也能靠数据库的行锁保证最终只有一边生效不会出现库存数据错乱。我自己实测下来的经验是订单闭环的代码在功能开发阶段看起来没什么量但一旦进入多用户并发测试各种订单被同时支付和取消库存预扣后没回补的问题就会冒出来。所以建议大家把状态机逻辑写完以后一定用并发脚本跑几轮不要只做单接口的手工测试。5. 数据库设计实战一套可以跑通业务闭环的表结构5.1 核心表清单与职责划分整套系统的数据表不算多但每张表都有明确分工。列出核心表清单如下表名作用核心字段说明goods商品基础信息表商品名、描述、主图、分类ID、上下架状态goods_category商品分类表分类名、父级ID、排序goods_sku商品规格/库存表SKU名、价格、可售库存、锁定库存、已售库存、图片member前台用户表昵称、头像、手机号、状态member_address收货地址表收件人、手机号、省市区、详细地址、默认地址标记cart_item购物车表会员ID、SKU ID、数量、选中状态order订单主表订单号、会员ID、总金额、支付金额、状态、收货信息快照order_item订单明细表订单ID、SKU ID、商品快照、规格快照、单价、数量pay_record支付流水表支付单号、订单号、金额、支付时间、支付状态admin_user后台管理员表用户名、加密密码、角色这里我想特别强调一下订单表里收货信息快照这个概念。很多初学商城项目的人会把收货地址直接用关联表用户下单后只存一个address_id。这个设计在家用项目里可能没问题但在真实场景中是个隐患因为用户地址改了之后历史订单里的收货信息也会跟着变但订单实际发出时的地址应该是不可变的。正确做法是在下单时把收货人姓名、手机、地址这段信息整体复制到订单表里作为一次性的业务快照。我的订单表里直接冗余了这几个字段从源头避免了这个问题。5.2 关键DDL示例商品SKU表和订单表的建表语句我直接贴出来感兴趣的同学可以自己跑一遍CREATE TABLE goods_sku ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, goods_id bigint(20) NOT NULL COMMENT 关联商品ID, sku_name varchar(128) NOT NULL COMMENT 规格名称, price decimal(10,2) NOT NULL COMMENT 销售价格, available_stock int(11) NOT NULL COMMENT 可售库存, locked_stock int(11) NOT NULL DEFAULT 0 COMMENT 锁定库存, sold_stock int(11) NOT NULL DEFAULT 0 COMMENT 已售数量, image varchar(255) DEFAULT NULL COMMENT 规格图片, create_time datetime DEFAULT NULL COMMENT 创建时间, update_time datetime DEFAULT NULL COMMENT 更新时间, PRIMARY KEY (id), KEY idx_goods_id (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品规格/库存表;CREATE TABLE order ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, order_no varchar(64) NOT NULL COMMENT 订单编号, member_id bigint(20) NOT NULL COMMENT 会员ID, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, status tinyint(4) NOT NULL COMMENT 订单状态, receiver_name varchar(32) NOT NULL COMMENT 收货人姓名, receiver_phone varchar(20) NOT NULL COMMENT 收货人手机号, receiver_address varchar(255) NOT NULL COMMENT 收货地址快照, pay_time datetime DEFAULT NULL COMMENT 支付时间, create_time datetime DEFAULT NULL COMMENT 创建时间, update_time datetime DEFAULT NULL COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_member_id (member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表;几个细节值得留意。第一order_no必须建唯一索引因为它是业务上对外暴露的单号不能重复。第二订单表的金额用decimal而不是float或double这一点极其重要浮点数做金额计算会在精度上坑死人decimal才是正道。第三所有表统一用utf8mb4字符集因为商品名和地址里可能会存生僻字和特殊符号。5.3 事务、索引与数据一致性数据一致性方面我的原则是写操作全部在事务内完成查询热点全部走索引页面级展示数据优先考虑缓存。事务边界上一次完整的下单锁库存是一个事务支付回调转状态也是一个事务取消订单回补库存同样是一个事务。每个事务只做一件事不要贪多。尤其要避免在一个事务里既做订单操作又去大量查询统计事务持有数据库连接的时间越短越好。索引设计上核心就是让每个高频查询都能走索引。订单表按member_id建索引支撑我的订单查询商品表按category_id建索引支撑分类列表SKU表按goods_id建索引支撑商品详情页查规格。这些索引数量不多但每一个都命中了真实查询场景不会出现索引建了一堆却用不上、反而拖累写入性能的情况。6. 项目交付源码、数据库脚本、文档怎么整理才算真正合格6.1 一套干净交付包的目录结构标题里写了源码数据库文档这三个东西凑齐其实不难但凑齐和合格是两码事。我见过太多交付包源码能跑起来的没几个数据库脚本缺少初始化数据文档写到一半就断掉的比比皆是。到我这边整理交付物时我给自己定了硬性标准拿到交付包的人按照文档从头执行必须能在30分钟内把系统跑起来。一个合格的交付包目录大致是这样的project-root/ ├── backend/ # Spring Boot后端源码 │ ├── src/main/java/... │ ├── src/main/resources/ │ │ ├── application.yml │ │ ├── db/ │ │ │ └── init.sql # 数据库初始化脚本 │ │ └── mapper/... │ └── pom.xml ├── frontend/ # 前端源码 │ ├── src/ │ └── package.json ├── docs/ │ ├── 环境要求.md │ ├── 快速部署.md │ ├── 项目结构说明.md │ └── 接口文档.md ├── sql/ │ ├── 01_create_tables.sql │ ├── 02_insert_data.sql │ └── 03_update.sql └── README.md数据库脚本我会拆成多个文件01_create_tables.sql只建表02_insert_data.sql只插入演示数据03_update.sql放后续的变更脚本。这样做的最大好处是使用者如果想看纯建表逻辑不会需要在一堆测试数据里翻来翻去如果后续版本迭代03_update.sql又能完整记录改动轨迹。6.2 数据库脚本怎么给才能到手即用写数据库初始化脚本时我特别强调两条经验。第一脚本里必须包含演示数据。一个茶叶商城如果数据库里只有空表前端页面展示出来全是空白跑通了也不知道做得好不好。我会插入8-10款不同分类的茶叶商品每款带2-3个SKU让首页和详情页看起来是丰满的。同时把管理员的初始账号密码也写进脚本并在文档里特意说明避免使用者对着登录页面干瞪眼。第二脚本开头要有一行注释写清楚适用于MySQL 5.7及以上版本并附上导入命令。因为不同的人数据库版本不一致直接在低版本上跑高版本的语法会报错。我在脚本里尽量避免使用MySQL 8.0才支持的新特性让兼容面尽可能大。6.3 文档的坑最容易让二次开发者卡住的细节文档这块老实说它比源码更容易被低估。我写文档时有一个习惯强制自己在第一次拿到这份文档的心理状态下从头到尾走一遍流程凡是走到一半发现缺了信息的立刻补进去。这个习惯帮我发现并解决了很多坑。最典型的是环境变量和端口配置。后端application.yml里连接数据库的密码、Redis的地址端口如果文档不写清楚使用者十有八九会卡在启动报错上。我会在快速部署文档的开头放一个环境要求表格列出JDK版本、Maven版本、MySQL版本、Redis版本以及对应的安装说明然后把配置文件中需要修改的地方用文字加粗标出来。另一件容易被忽视的事是前端项目的构建步骤比如Node版本要求、npm install之后还需要什么额外配置这些都要写到。接口文档我也不含糊。每个接口至少包含请求方式、URL、请求参数、返回示例和必要的注意事项。对于订单相关的接口我还会专门标注该操作会改变订单状态请勿重复调用之类的提醒因为这类接口出了问题最难排查。6.4 一些真实的实测心得最后分享几条实际做这套系统的过程中换来的经验不一定写在任何书本里但都很实在。第一记得给管理端预留一个订单备注字段。做开发的初期可能觉得这个字段没用但真实卖茶的人经常需要在订单上标注客户要求发顺丰这单是熟客抹零这类信息。这个字段加上去之后后台系统在真实使用中的友好度会提升一大截。第二把商品上下架做成一个显眼的功能不要跟删除混为一谈。茶叶的上下架是常态操作某款茶售罄了下架、补货后再上架都是家常便饭。删除功能其实用得极少甚至可以不做物理删除只做逻辑删除不然数据一删后面统计报表就全乱了。第三模拟支付的收银台一定要做失败这个分支。很多教学项目只做支付成功这一条快乐路径导致整个流程里支付失败超时未付这些分支完全没被覆盖。但恰恰是这些分支最容易藏bug。把模拟失败的分支做完整个系统的健壮性是截然不同的。第四写代码阶段就顺手把数据库的注释写全。MySQL建表语句里的COMMENT写清楚后面生成接口文档或者给项目写说明时能省无数时间。说句实话很多所谓的项目难交付不是难在功能多而是难在别人看不懂。这套茶叶商城系统做到后面我自己最大的体会是一个项目是否能做出来和是否能交付好用完全不是一个难度等级。源码、数据库、文档三件套凑齐只是起点真正让人放心的是整套设计里每一处业务逻辑都经得起追问每一个表字段都说得清来龙去脉。如果按这个标准去打磨无论你最终面对的是答辩、考核还是真实客户都不会慌。
阅读完成 · 觉得有帮助?