☰
SpringBoot家具销售管理系统实战:从CRUD到库存并发设计
2026/10/16 3:53:09 网站建设 项目流程

花了整整两天时间把这类基于SpringBoot的家具销售管理系统从头到尾梳理了一遍。单看标题,很多人会觉得这又是一个普通的CRUD练习项目,但真正把业务模块拆开之后你会发现,家具销售里那些大件、低频、高客单价带来的逻辑细节,它其实都踩到了。这篇文章不打算照搬项目文档,而是从“如果一个新手拿到这套源码,该怎么读、怎么改、怎么避坑”的角度,把它当成一个完整案例来分析。

1. 项目拆解:这套系统到底在解决什么问题

1.1 先从业务说起:家具销售要管什么

家具销售和快消品的销售管理系统差别很大。快消品看重复购、看促销活动,而家具是典型的大件低频商品,用户决策周期长,下单之前反复逛、反复对比材质和价格;下单之后还有送货、安装、验收这些环节。所以这套系统表面上还是商品、购物车、订单这些通用模块,实际上要在细节里处理好“大件商品”带来的特殊逻辑。

具体到这个系统,核心业务闭环大致是这样:前台用户注册登录后浏览家具商品,可以按分类筛选、按关键词搜索,把喜欢的商品加入购物车,然后提交订单;后台管理员维护商品上下架、管理库存、处理订单发货,并通过统计报表看销售情况。订单本身要覆盖从待付款到已完成的全过程,中间还要处理取消订单这类反向流程。

我特别想提醒一句:拿到这类项目源码,很多人第一件事就是点运行,结果跑起来发现某个菜单没权限、某个数据对不上,才回头一点点看代码。我建议反过来,先花半小时把业务流程图和数据流向捋一遍。只要搞清楚“什么人操作什么功能、数据在哪些表之间流转”,这套系统在你手里就已经掌握了七成。

1.2 用户角色拆解:谁来用这套系统

系统里最核心的角色有两个:普通用户,也就是前台买家;管理员,也就是后台运营人员。

普通用户能做的事情看起来常规,但每个动作背后都有对应的数据表在支撑。比如注册后修改个人资料,对应的是用户表字段更新;收藏家具,对应收藏表插入记录;提交订单,则会同时影响订单表、订单明细表和库存表。所以看角色模块时,不能只看Controller里写了什么方法,要把用户操作、权限拦截和数据库变更动作放在一起联想记忆。

管理员角色相对更重。在简易版本里,管理员会把商品维护、上下架、库存调整、订单处理、销售统计全部集中到一个账号下,这在中小规模项目里是很常见的取舍。如果实际业务中多人分工明确,再往上加RBAC权限模型也不迟。

1.3 技术选型为什么是SpringBoot全家桶

这个项目的核心技术栈是SpringBoot,原因很现实:SpringBoot对中小型管理系统开发效率的优势太明显了。以前用Spring MVC拼SSH那套,光配置XML文件就吃掉不少精力;现在SpringBoot自动配置加内嵌Tomcat,一个Jar包就能跑起来,省去了大量环境配置的时间。

搭配SpringBoot的项目通常还会用到MyBatis或MyBatis-Plus做持久层,MySQL存数据,Thymeleaf做服务端页面模板。这套组合到今天依然是Java后台管理系统的主流搭配。它的好处在于:

  • 学习曲线平滑,网上资料多,遇到报错基本都能搜到对应解法;
  • 开发周期短,一个人也能快速完成全部模块;
  • 部署简单,打包成Jar扔到服务器上就能运行。

我见过有人一上来就纠结要不要改成Spring Cloud微服务。对一个家具销售管理系统来说,完全没必要。单体架构在业务规模没有到海量用户之前,就是最合适的复杂度,等真遇到性能瓶颈再拆分也来得及。

2. 数据库设计:从业务需求反推表结构

2.1 核心数据表的职责划分

我把这个系统的核心数据表梳理了一遍,真正发挥作用的大概有这些:

表名职责
用户表存登录账号、密码、联系电话、收货地址
家具分类表存分类名称、层级关系
家具商品表存商品名称、价格、库存、图片、描述、上下架状态
购物车表存用户临时选择的商品和数量
收藏表存用户收藏的商品记录
订单表存订单主信息、总金额、订单状态
订单明细表存订单里每个商品的快照信息
反馈表存用户咨询和售后反馈内容

商品为什么需要分类表?一是家具商品数量上来之后,分类能明显提升查找效率;二是前台筛选和后台统计都要按分类维度做。订单为什么和订单明细分开?因为一个订单里可能包含多件家具,如果把所有信息塞进订单表,订单表就变成一个又宽又难维护的大宽表,统计和状态流转会变得很别扭。

这个设计符合常规范式要求,同时也没有过度设计到让人看不下去。对学习案例来说,是很合适的起点。

2.2 订单表和订单明细表为什么必须分开

订单表和订单明细表分离,是这套系统里最值得学习的数据库设计点之一。

订单表记录订单级别信息:谁下的单、总金额、付款时间、订单状态、收货地址。订单明细表记录商品级别信息:这个订单里有哪些商品、单价多少、数量几件、小计多少钱。

这里的关键词是“快照”。很多新手在订单明细里只存商品ID,然后显示时再从商品表取价格。看起来没问题,但仔细想想:家具商品经常调价,如果用户下单之后管理员改了价格,订单明细里的价格也会跟着变,财务对账就会出乱子。合格的系统应该在订单生成那一刻,把商品名称、单价、数量这些信息以快照形式写进明细表,之后无论商品表怎么改,历史订单都不受影响。

订单总金额也应该来源于明细表,以各条明细的小计之和为准,而不是前端传什么就存什么。这一点我在后面下单事务部分会重点展开。

2.3 库存字段设计与并发安全考虑

库存字段看起来只是商品表里的一个整数列,但设计时需要提前想清楚几个问题。第一,库存放商品表还是单独建表?对这个体量的系统来说,直接放商品表就行。第二,库存绝对不能出现负数,否则就会发生“超卖”这种致命问题。

处理库存并发,常见做法是:下单前先查库存,库存够就扣减。这是最朴素的思路,但在并发场景下会出问题。两个用户同时下单,只剩一件库存,两个请求都查到库存为1,然后都执行扣减,库存就变成-1。

所以更稳妥的方案是使用数据库层面的原子操作,比如更新库存时加上条件判断:

UPDATE furniture SET stock = stock - #{quantity} WHERE id = #{commodityId} AND stock >= #{quantity}

这条SQL利用行锁把“检查库存”和“扣减库存”合并成一个原子操作,如果受影响行数为0,说明库存不足,下单直接失败。这个技巧在不涉及分布式锁的项目里非常实用,后面会再展开。

3. 核心功能实现思路:关键代码与设计细节

3.1 用户登录与权限拦截

系统的登录模块使用Session会话管理,登录成功后把用户信息保存到Session,后台管理页面通过拦截器做权限校验。拦截器的思路不复杂,就是定义一段逻辑,在请求进入Controller之前判断用户是否已登录。

登录拦截器核心写法大致是这样:

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("LOGIN_USER"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }

注册到配置类时,要指定拦截路径。我遇到过不少人问“为什么配置了拦截器,静态资源也被拦了”,答案很简单:在addInterceptors时,需要把静态资源路径通过excludePathPatterns排除掉,否则CSS、JS、图片全部无法加载。

3.2 家具商品列表与关键词搜索

商品列表是前台最大的流量入口。实现上没有太多玄学,就是查询商品表,支持按分类ID过滤、按商品名称模糊搜索。分页是必选项,否则商品数据一多,页面会直接卡到没法看。

使用MyBatis-Plus时,分页查询可以这样写:

Page<Furniture> page = new Page<>(current, size); LambdaQueryWrapper<Furniture> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Furniture::getName, keyword) .eq(categoryId != null, Furniture::getCategoryId, categoryId) .eq(Furniture::getStatus, 1); // 只查上架商品 furnitureService.page(page, wrapper);

这里有一个容易被忽略的点:商品列表只展示“上架”状态,下架商品只出现在后台管理里。如果漏了这个过滤条件,用户在前台看到下架商品还能加购,下单时又要再做一次校验,逻辑就变得混乱。商品图片字段存的是相对路径还是完整URL,也需要和前端约定好,这是这类项目里最常见的不一致问题。

3.3 购物车模块:临时数据到底放哪

购物车有三种常见实现:纯前端存储、后端数据库存储、Redis临时存储。这个系统采用的是数据库表方案,也就是用户点击加入购物车后,直接往购物车表插入记录。

数据库存储购物车的优点是完全不依赖浏览器端,用户换台电脑购物车数据依然存在;缺点是每次加购都产生一次数据库写入。对这个场景来说完全可接受,一个用户购物车里的商品数量非常有限,不会造成性能问题。

购物车表字段一般包括用户ID、商品ID、商品数量、加入时间。需要注意同一用户同一商品不能产生两条购物车记录。比较合理的做法是,加入购物车时先按用户ID和商品ID查询,如果已存在则数量累加,不存在才插入新记录。

3.4 下单流程与库存扣减的原子性处理

下单是整套系统里最有含金量的地方。表面上只是插入订单表和订单明细表两条记录,但里面藏着一大堆一致性问题。用户提交订单时,系统需要先校验库存,扣减库存,再写订单,这些操作必须在一个事务里完成,任何一个环节失败都要整体回滚,否则就会出现“订单没生成但库存扣没了”的烂账。

第一版写法很多人会这样做:

@Transactional(rollbackFor = Exception.class) public void createOrder(OrderCreateRequest request) { // 1. 组装订单实体并插入 // 2. 组装订单明细并批量插入 // 3. 扣减库存 }

这已经比不写事务靠谱,但并发场景下依然有超卖风险。假设库存只剩1件,两个请求同时读到库存为1,即使都用这段代码执行,也会都判断“库存够”,然后两个都扣减成功,库存变成-1。

解决办法就是前面提到的条件扣减:

int rows = furnitureMapper.reduceStock(commodityId, quantity); // reduceStock 对应SQL: // UPDATE furniture SET stock = stock - #{quantity} WHERE id = #{commodityId} AND stock >= #{quantity} if (rows == 0) { throw new RuntimeException("库存不足"); }

如果更新的行数是0,直接抛异常让事务回滚,订单不会生成。这比先select再update的方式可靠得多,这个思路可以迁移到所有涉及库存、余额、剩余次数的业务场景里。

3.5 订单状态机与管理端发货操作

订单状态是这个系统最容易被忽略却又非常重要的部分。如果不用状态机思路设计,直接靠一个status字段随手赋值,后面每加一个需求就要改一遍判断逻辑,代码会越改越乱。

订单状态基本可以归纳为这几个阶段:

状态值含义触发操作
0待付款用户提交订单
1待发货用户支付成功
2待收货管理员发货
3已完成用户确认收货
4已取消用户取消或超时关闭

状态应该是一个有限集合,流转方向要可控。比如待付款可以取消,但已完成不能再改成待付款。代码层面最简单的保护方式就是写一个状态流转判断方法:

public boolean canTransition(Integer currentStatus, Integer targetStatus) { Map<Integer, Set<Integer>> transitionMap = new HashMap<>(); transitionMap.put(0, new HashSet<>(Arrays.asList(1, 4))); transitionMap.put(1, new HashSet<>(Arrays.asList(2, 4))); transitionMap.put(2, new HashSet<>(Arrays.asList(3))); return transitionMap.getOrDefault(currentStatus, new HashSet<>()).contains(targetStatus); }

管理员发货,本质上就是把订单状态从1改到2,同时记录发货时间,最好把物流单号也存下来,方便用户在前台订单列表里查看。这些看似细节的东西,往往决定项目是否完整可用。

3.6 销售数据统计报表是怎么算的

销售统计是这套系统里容易被低估的模块。它会按日期或月份统计销售额、订单量、商品销量排行等指标。看起来复杂,其实就是对订单表、订单明细表做聚合查询。

一个典型的SQL是按商品统计销量:

SELECT item.commodity_id, SUM(item.quantity) AS total_sales, SUM(item.amount) AS total_amount FROM order_item item INNER JOIN orders o ON item.order_id = o.id WHERE o.status IN (1, 2, 3) GROUP BY item.commodity_id ORDER BY total_sales DESC

这里要注意一个统计口径问题:统计对象必须限定在“已支付”的订单,而不是所有单据,否则用户提交了但没付款的订单也会进入统计,数据就失真了。管理员取消的订单同样要排除。统计口径这件事,项目做得深不深,一眼就能看出来。

4. 实战避坑:事务、并发与数据一致性的教训

4.1 库存超卖问题:扣库存千万别只靠Java代码

前面已经强调过,最稳的库存扣减方式就是条件更新。但有一个很容易犯的错是:在Controller层做库存判断。有些人把业务逻辑直接写在Controller里,先查库存、再调用Mapper更新库存,中间一旦并发插入,就会出问题。更稳妥的做法是,把库存扣减封装到Service层事务方法里,利用数据库的原子操作兜底。

另外,给方法加synchronized关键字看似正确,实际并不稳。单机部署时synchronized确实能挡住并发,但服务一旦部署多个实例,锁就失效了。要真正处理分布式并发得上分布式锁。对这个项目来说,条件更新已经够用,而且不需要额外引入中间件。

4.2 事务失效的几个常见场景

@Transactional注解不是万能的。这几个场景我都见人踩过:

  • 类内部方法调用。同一个类里方法A调用方法B,B上有@Transactional,此时事务不生效,因为Spring事务基于AOP代理,内部调用不会经过代理。
  • 异常被吞掉。方法内部用try-catch把异常捕获了,事务感知不到异常,自然不回滚。
  • 抛出非运行时异常。事务默认只回滚RuntimeException和Error,如果方法抛出类型是检查异常,而事务又没有指定rollbackFor,就不会回滚。

解决办法也直接:要回滚就明确指定@Transactional(rollbackFor = Exception.class),内部不要随意吞异常,让异常正常抛出去。如果确实需要捕获异常再处理,可以手动标记事务回滚。

4.3 金额计算为什么用BigDecimal

家具这类高客单价商品,价格精确性一点都马虎不得。项目里所有金额字段都应该用BigDecimal,而不是double或float。double计算0.1加0.2会得到一串浮点误差,这在金额上是绝对不能接受的。

我见过代码里这样写:

double totalPrice = 1999.9 * 2;

看着结果是双位数好像没问题,但当数据量变大,浮点误差会在累加过程中被放大,最后报表对不上账。BigDecimal写起来稍微繁琐,但钱的事宁可代码丑一点,也不能让账出错。数据库金额字段用decimal(a,b)类型,Java类用BigDecimal,两边保持一致。

4.4 分页查询在关联表场景下的坑

家具商品列表如果需要连分类名称一起展示,很多人会直接写多表联查,再用分页插件。这里有个经典问题:当SQL里有多表连接、group by或order by时,分页插件生成count语句时可能出现count结果和实际记录数对不上的情况。

解决思路有两种。一种是关联表只查ID,查询出来后再在业务代码里批量补全分类名称;另一种是先对主表做分页,再查关联信息拼装。虽然麻烦一点,但能保证分页准确。使用MyBatis-Plus分页时,建议SQL尽量保持简单,少放复杂的子查询和group by,出错概率会低很多。

5. 性能优化与后续扩展建议

5.1 给热门家具商品加个Redis缓存

当前系统直接查询MySQL,数据量小的时候没问题。但家具商品首页、详情页这类访问集中的接口,完全可以引入Redis做缓存。做法不复杂:商品实体序列化后存进Redis,查询时先查缓存,缓存没有再去查数据库,查完写回缓存。商品更新时删除或重置对应缓存key。

引入缓存之后需要特别关注缓存与数据库的一致性。最简单有效的方式是旁路缓存:读操作先读缓存,写操作先更新数据库再删除缓存。千万不要只更新缓存不更新数据库,也不要只更新数据库却忘记清理缓存,这两个操作都会造成脏数据。

5.2 用消息队列应对高并发下单

如果家具商城某天做秒杀活动,瞬时下单量远大于处理能力,订单模块就会成为瓶颈。这时候可以引入消息队列,先把下单请求丢进队列,由消费者异步处理订单和库存,前端通过轮询查询订单状态。

这个方案对这个体量的系统来说属于超配,但值得理解原理。从单体项目往高并发架构演进时,削峰是最常见的思路之一,原理就是先把请求接收下来,再找机会慢慢消化,不让数据库被瞬时流量打崩。

5.3 这个项目还能怎么变体

如果想把这套系统改成其他行业的销售管理,路径非常清晰。家具、建材、灯具、家电这些大件低频商品的销售管理,业务结构高度相似:商品、分类、购物车、订单、库存、统计,这些模块完全可以复用。你只需要把商品表里的定制字段改成对应行业的参数,比如灯具加“瓦数”,建材加“规格型号”。

如果面向B2B场景,还要在订单里增加客户公司信息、折扣等级、合同管理,这些都可以在这个基础上扩展。做这类变体项目,关键是想清楚新增的业务量落在哪张表上,想清楚了,改动就等于做加减法。

6. 源码学习与个人建议

标题提到“关注可白嫖源码”,这是技术社区里常见的分发模式。想获取源码,通常就是关注发布者留下的指引,按提示领取。需要提醒的是,源码拿到手之后,个人学习和练习完全没问题,但不能直接打包售卖,也不应该去掉版权信息拿去商业使用。

我始终觉得,源码的价值不在于跑起来看一眼,而在于把它当成完整案例去读、去改、去验证。哪怕一个项目你早就会做了,看别人模块的划分方式、事务的处理方式、状态机的设计方式,也可能带来新的思路。

拿到项目代码之后,建议按这个顺序操作:先建好数据库并导入SQL脚本,核对表名和字段名;再修改数据库连接配置,特别注意端口和时区;然后全局搜索需要替换的常量;最后按“登录→添加商品→下单→发货”这条主流程走一遍,任何一步报错优先看日志。这条链路跑通,后面基本就顺了。

我自己复现这类项目的习惯是,先列一张“请求路径对应Controller方法”的清单。这一步做完,再看代码就有一种地图在手的感觉。很多人觉得读不懂别人的项目,问题往往不在于代码本身难,而在于找不到调用入口。入口找到了,一切都会清晰起来。

最后说一个实在的经验:别急着把全部源码看完再动手。边看边敲,把某一模块彻底看懂、改造、跑通,比把整个项目从头到尾翻阅一遍有用得多。这套家具销售管理系统把CRUD、权限拦截、事务处理、状态机、统计报表这些常规内容串在了一起,本身就是一份完整的后端练习素材,拿它练手,比对着零散教程学效果要好不少。就我个人体会,项目跑通之后最适合再做的一件事,是试着给自己加一个“库存预警”功能,商品库存低于某个阈值时在后台高亮提醒。改完之后你会发现,自己对整套系统的理解又深了一层。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询