拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpringBoot实战:雪具销售系统从数据库设计到库存并发扣减

聊到SpringBoot相关的实战类选题很多人第一反应就是又是XX管理系统。但说实话把一套带完整前后端和数据库设计的销售系统做扎实对理解Java Web开发的核心链路非常有帮助。我在做这个基于SpringBoot的雪具销售系统对应kaic这份文档源码结构时最大的感触是它并不是单纯堆CRUD从商品模型设计到订单状态流转再到库存扣减的并发处理几乎把SSM时代遗留的经典问题全部覆盖了一遍。这篇文章会围绕整个项目的选型理由、模块拆分、数据库设计、核心代码实现和踩坑过程展开重点写给两类人一是正在做SpringBoot课设/毕设的同学想找一个逻辑完整、能自圆其说的参照系二是刚接触企业级Java开发、想通过完整项目快速上手SpringBoot的初学者。1. 开始动工之前为什么这类管理系统选题过了这么多年仍然能打1.1 先搞清楚雪具销售系统到底要解决什么问题接手这个需求时我做的第一件事不是建工程而是把雪具销售四个字拆开。雪具本身是典型的季节性、高客单价、规格复杂的商品——同款雪板有长度145/155/165、硬度、左右脚固定器尺寸之分雪服有大小码和颜色组合。这种多重规格组合的商品结构远比普通一本书、一件T恤的简单商品要复杂。因此整个系统的核心矛盾在于如何用一个可扩展的数据模型去承载多规格商品并且让下单、库存、订单状态这一整条链路逻辑不自洽。基于这个定位项目被拆成用户端和管理端两条业务线。用户端围绕浏览→加购→下单→支付模拟→查看订单展开管理端围绕商品维护→库存管理→订单处理→用户管理展开。除此之外为了降低开发成本没有引入支付网关真实对接而是采用模拟支付。我在文档里明确写了这个取舍否则一个毕设项目根本不可能申请到真实的商户号。1.2 技术选型时的三个决定性因素这套系统的技术栈最终确定为SpringBoot MyBatis-Plus MySQL Redis可选 Thymeleaf/Vue选型和当时的场景高度绑定。SpringBoot负责整个后端的自动配置和启动管理避免手工搭建SSM框架时的XML配置地狱。这个项目里SpringBoot 2.7.x是我最终的选择因为它同时兼容JDK8和JDK11部署环境要求低。如果是SpringBoot 3.x就强制要求JDK17很多实验室机器未必具备条件。MyBatis-Plus解决单表CRUD的效率问题。销售系统里大量的单表操作商品查询、分类列表、用户信息如果全部手写SQL工作量大且容易出错。MyBatis-Plus的BaseMapper直接继承了现成的增删改查方法稍微复杂一点的动态查询就交给QueryWrapper处理大幅缩短开发时间。Thymeleaf还是Vue的权衡。我见过的很多SpringBoot项目实际上是前端用Vue、后端只做接口的前后端分离项目。这套系统最初为了降低答辩时的演示复杂度和部署成本选择了服务端渲染的Thymeleaf方案——直接打包成一个Jar跑起来就能看效果无需额外启动Node环境。不过项目文档里也附加了一个Vue版本的前端工程方便偏向前后端分离方向的同学参考。如果你的目标是尽可能少被问倒选择技术栈越少、链路越短越容易完整掌控全局。2. 系统整体拆解用户端、管理端与各功能模块的边界2.1 用户端的核心操作闭环打开前台首页用户看到的不是一堆功能按钮而是一条引导路径注册登录→按分类浏览雪具→进入商品详情查看多规格价格和剩余库存→点击加入购物车→提交订单→模拟支付→订单列表查看物流状态。这条路径上的每一个节点就是一个独立的功能模块用户模块注册时做用户名唯一性校验和MD5加盐加密存储登录成功后生成Token写入Session并用拦截器校验需要登录才能访问的接口比如购物车、下单、订单列表。商品模块支持按分类筛选、按关键字搜索、按销量和价格排序列表页采用分页展示商品详情页展示多规格SKU、库存数量和轮播图。购物车模块用户可以把不同的雪具SKU加入到购物车调整数量时会实时校验库存上限前端数量框做了防抖。订单模块提交订单时后端统一计算金额用户只能传SKU和数量禁止前端传金额生成订单主表和订单明细表同时扣减库存、清空对应购物车条目。支付模块模拟支付页面点确认支付后订单状态由待付款改为待发货并记录支付时间。从业务闭环的角度来说这段设计需要确保任何一个环节断掉时都有提示反馈。比如商品下架后不能被加购、库存不足时不能提交订单、用户未登录跳转到登录页且登录后可以回跳原页面等。这些细节往往是指导老师和高年级同学重点看的地方比单纯的功能堆砌要加分很多。2.2 管理端的内容和权限控制管理端和使用者的角色对应默认内置一个管理员账号。管理端的模块包括商品管理新增、编辑、上下架商品维护规格、库存、价格、图片地址、详情描述。分类管理对雪板、雪服、雪鞋、护具、配件等分类做调整。订单管理查看所有订单列表按订单号或用户名搜索对待发货订单执行发货操作订单状态变为已发货并记录发货时间。用户管理查询用户列表禁用/启用账号。数据概览首页展示商品总数、用户总数、订单总数和销售额合计用ECharts柱状图展示最近7天订单量走势。权限控制上我没有引入Spring Security而是在后端写了一个AdminInterceptor对/admin/**路径做管理员角色校验。这样做的原因有两个一是避免Spring Security复杂的配置流程把核心业务逻辑淹没二是文档和答辩时更容易讲清怎么做的。实际生产项目当然需要更完善的权限框架但对这套项目来说够用比复杂更重要。你可以在README里明确标注这是简化方案反而体现你做了技术权衡。2.3 模块边界的设计要点整个系统里用户端和管理端在代码层面共用同一套Service和Mapper只是Controller层做了拆分。前端的页面资源按照/user和/admin两个目录分开管理后端接口按照/api/user/**和/api/admin/**两个前缀区分。这样做的好处是权限拦截可以按路径前缀一刀切不用在业务代码里到处判断当前登录人是否是管理员同时数据库的访问逻辑复用了同一套避免用户端和管理端对同一张表各自写一遍操作导致逻辑不一致。比如商品表用户端读取的是上架状态且库存大于0的记录管理端需要看到所有商品那么Service层就提供两个不同条件的方法而不是在同一方法里做双端兼容。3. 从数据库设计看业务逻辑雪具库存和订单状态怎么设计才不打架3.1 核心表的字段规划和关系梳理我设计了9张核心表用户表、分类表、商品表、SKU表、购物车表、订单主表、订单明细表、收货地址表、管理员表。其中商品表和SKU表的分离是整个数据模型的亮点。表名关键字段说明userid, username, password, nickname, phone, statusstatus为1正常0禁用categoryid, name, sort分类排序值数字越小越靠前goodsid, category_id, name, main_image, detail, status对应SPU一个商品下有多种规格skuid, goods_id, spec_info, price, stock, image多个SKUspec_info存储155cm/单板这类组合cartid, user_id, sku_id, quantity, checkedchecked为勾选状态默认1ordersid, order_no, user_id, total_amount, status, create_time, pay_time, delivery_time订单主表order_no唯一索引order_itemid, order_id, goods_id, sku_id, goods_name, sku_spec, price, quantity冗余商品快照信息addressid, user_id, receiver, phone, province, city, district, detail简化版地址表adminid, username, password管理员账号表商品详情页读的是goods表而规格、价格、库存读的是sku表。这样做最大的好处是新增一个155cm黑色雪板规格只需要往sku表插一行不需要动商品表结构。订单明细表里的goods_name和sku_spec是冗余字段目的是防止商品或SKU后续被删除导致历史订单无法展示当时的购买信息。3.2 订单状态机的定义订单状态是整个系统状态最复杂的地方我定义了一个状态常量类用整数表示不同状态0待付款下单成功但未支付1待发货已支付成功等待管理员发货2已发货管理员填写物流单号并确认发货3已完成用户确认收货或后台强制完成-1已取消用户主动取消或超时未支付自动取消状态流转只允许沿合法方向迁移待付款→待发货支付、待付款→已取消用户取消、待发货→已发货管理员发货、已发货→已完成确认收货。我在OrderServiceImpl里写了一个changeOrderStatus(orderId, expectStatus, newStatus)方法用UPDATE语句加上WHERE status expectStatus来做状态机更新这样即使两个请求同时操作同一订单也不会出现状态紊乱。提示项目文档里我专门画了一张状态流转图PlantUML源码答辩时可以直接用。图片不需要多精美但状态的合法迁移路径必须讲清楚。3.3 库存扣减策略下单即锁还是支付再扣这个决策直接决定了系统的并发安全级别。我采用的是下单时预扣库存支付失败或取消时回补库存策略。用户提交订单的瞬间程序执行int count skuMapper.deductStock(skuId, quantity); if (count 0) { throw new ServiceException(库存不足或商品已下架); }这里的deductStock使用的是MyBatis的原子更新UPDATE sku SET stock stock - #{quantity} WHERE id #{skuId} AND stock #{quantity}stock quantity这个条件和受影响行数count配合天然避免了商品超卖问题——即使两个用户同时发起购买数据库行锁也会保证只有一个更新成功。如果把扣减放到支付成功时执行就会出现下单锁住了库存但用户迟迟不付钱库存被无效占用的问题如果支付时直接扣且下单时不扣又会高频出现下单成功但支付时发现没货的尴尬场景。两害相权预扣加回补更符合一般电商系统的设计直觉也是对演示最友好的一种方案。4. 从登录鉴权到下单闭环核心模块的SpringBoot落地细节4.1 登录鉴权和用户身份贯穿项目的登录功能没有引入Spring Security而是自己写了一个LoginInterceptor配合Session完成身份贯穿。用户登录成功后把用户对象放进Session同时把/cart/**、/order/**、/user/**的请求拦截下来判断Session里有没有用户信息。没有则跳转到登录页并携带redirect参数登录成功后再跳回来。管理员的鉴权是独立的一套。先通过AdminInterceptor拦截/admin/**请求然后从Session中取出管理员对象。这个逻辑放到一个类里配置WebMvcConfigurer时注册registry.addInterceptor(loginInterceptor) .addPathPatterns(/cart/**, /order/**, /user/**) .excludePathPatterns(/user/login, /user/register); registry.addInterceptor(adminInterceptor) .addPathPatterns(/admin/**) .excludePathPatterns(/admin/login);这样配置让代码里不再散落判断是否登录的逻辑新增一个需要登录的路径只需在配置里加一行。文档里我把拦截器注册顺序和常见的拦截器失效问题也做了记录——比如静态资源被拦截导致CSS加载不出来这类问题排查起来非常隐蔽但实际中经常出现。4.2 商品分页查询与条件的组合使用商品列表页用到最多的查询是按分类按关键字按价格排序。这个场景我直接用了MyBatis-Plus的分页插件和LambdaQueryWrapper// 配置分页插件 Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }查询时LambdaQueryWrapperGoods wrapper new LambdaQueryWrapper(); wrapper.eq(Goods::getStatus, 1) // 只查上架商品 .eq(categoryId ! null, Goods::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), Goods::getName, keyword) .orderByDesc(Goods::getSales); PageGoods page goodsMapper.selectPage(new Page(pageNum, pageSize), wrapper);这里的要点有两个。第一eq方法里第一个参数是布尔值条件为false时MyBatis-Plus会自动忽略这一条件这样就不用手动拼SQL判断参数是否为空。第二分页插件本质上是拦截了SQL语句并自动拼了LIMIT所以Maven依赖中必须额外引入mybatis-plus-extension单单引入mybatis-plus-boot-starter在部分版本里分页插件会不生效。这一点很多人遇到但没意识到根因。4.3 购物车模块的存储选型购物车实现有两种方案一种是存Redis一种是存MySQL我选择的是MySQL。考虑到项目的复杂度不是所有部署环境都一定配好了Redis如果加了Redis会引入缓存过期时间、Redis和数据库一致性、Redis服务不可用时的降级等问题这些对毕设而言会喧宾夺主。购物车表结构很简单一个用户ID加一个SKU ID唯一确定一行查询时JOIN出商品主图和标题方便展示。加购时如果购物车里已经有同一个SKU就做数量累加而不是插入新行否则会出现相同的商品出现在购物车多行里。调整数量和删除条目也都围绕这张表做。前端页面的操作都走后端接口刷新重查而不是前端本地维护购物车数组——这样刷新页面数据也不会丢。4.4 下单事务的边界和细节创建订单这个方法逻辑上涉及生成订单主表、生成订单明细、扣库存、清购物车四个操作必须放在同一个事务里否则中途任何一步报错都会造成数据不一致。Transactional(rollbackFor Exception.class) public OrderVO createOrder(OrderCreateDTO dto, Long userId) { // 1. 计算总金额遍历dto中的skuId和quantity // 2. 验证库存并预扣库存 // 3. 插入orders和order_item // 4. 批量删除购物车条目 }这里我用的是rollbackFor Exception.class而不是默认的RuntimeException。Spring的Transactional默认只在运行时异常时回滚如果业务代码抛出一个检查异常比如IOException事务不会回滚。封装业务异常时建议统一继承RuntimeException比如ServiceException这样事务的触发条件就非常明确。还有一个细节在订单号生成上。单据号如果直接用数据库自增ID用户肉眼可见地猜测订单量不适合做商业系统展示。我在工具类里写了一个generateOrderNo()格式是yyyyMMddHHmmss 6位随机数加上数据库层面的唯一约束基本可以满足演示需求。这个方法还顺便避免了下单接口被并发调用时订单号撞车造成插入失败的问题。5. 实测中容易踩的坑分页、金额精度和库存并发这三件事5.1 分页插件不生效的排查链路MyBatis-Plus分页插件查出来永远只有全部数据这个问题我印象很深。当时现象是前台商品列表页无论传pageNum和pageSize是多少返回的都是全部商品日志里看到SQL语句也没有LIMIT关键字。排查步骤是这样的先确认MybatisPlusInterceptor是否被Spring容器加载检查配置类是否被扫描。再确认PaginationInnerInterceptor的DbType是否传对了传MYSQL还是OTHER会有差异。最后打开控制台SQL日志发现执行器类型是SimpleExecutor这说明分页插件根本没起作用。最终找到原因项目里自建了一个SqlSessionFactory的配置类里面手动new了MybatisSqlSessionFactoryBean覆盖了MyBatis-Plus的自动配置导致插件未注入。解决方案就是在那个自定义配置里手动添加插件MybatisSqlSessionFactoryBean factory new MybatisSqlSessionFactoryBean(); factory.setPlugins(new Interceptor[]{new MybatisPlusInterceptor() {{ addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); }}});这个问题有点小众但能暴露出自动配置和手动配置冲突这个SpringBoot的经典坑。文档里把这个排查过程单列了一章包括如何开SQL日志、如何确认拦截器加载这些信息在答辩时非常有用。5.2 金额计算为什么不能用double订单金额计算在最初版本用的是double类型本地测试一切正常。某次验证时发现订单总金额出现了104.999999999这样的值虽然页面显示时做了保留两位小数的格式化但数据库里存的值就是这个脏数字。这是Java浮点运算的经典精度问题。0.10.2不等于0.3而是0.30000000000000004。销售系统的金额字段从Java实体到数据库字段全程都应该用BigDecimal。数据库decimal(10,2)Java实体BigDecimal前端传参时用字符串不要用浮点数。BigDecimal total BigDecimal.ZERO; for (CartVO item : cartList) { BigDecimal price item.getSkuPrice(); BigDecimal quantity BigDecimal.valueOf(item.getQuantity()); total total.add(price.multiply(quantity)); }BigDecimal的另一个坑是构造方法。new BigDecimal(0.1)仍然会产生精度问题必须用new BigDecimal(0.1)或者BigDecimal.valueOf(0.1)。我在代码里统一加了个MoneyUtil工具类所有金额构造都走工具方法避免后续新增功能时再次踩雷。5.3 并发场景下的超卖和订单状态错乱模拟并发测试时我用JMeter同时对同一个SKU发起50个下单请求库存设为10。预扣库存的stock quantity条件将超卖数量降为0但出现了另一个形态的问题10个请求成功另外40个请求并不是库存不足而是订单号重复。原因是我用了yyyyMMddHHmmss 6位随机数作为订单号同一秒内大量并发请求发生了随机数碰撞。解决方法是把订单号生成改成分布式ID方案。我选择了最简单的方案用时间戳加上一个AtomicInteger自增值private static final AtomicInteger SEQ new AtomicInteger(100000); public static String generateOrderNo() { LocalDateTime now LocalDateTime.now(); String time DateTimeFormatter.ofPattern(yyyyMMddHHmmssSSS).format(now); return time 0 SEQ.getAndIncrement(); }这里的时间戳精确到毫秒加上AtomicInteger的自增在单机部署下基本不会重复。如果再严谨一点可以在订单表给order_no加唯一索引数据库层面兜底。这个案例我给文档里专门留了一页因为并发问题在实际面试和答辩中几乎必被问到。6. 文档与源码的配合如何让项目在展示和评审中更稳6.1 代码结构如何体现可读性和可维护性整个工程的包结构如下com.snowshop ├── common // 统一返回结果、异常处理、常量类 ├── config // 拦截器配置、MyBatis-Plus配置、WebMvc配置 ├── controller // 控制层按业务模块拆分 ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体类 ├── dto // 前端传入参数对象 ├── vo // 后端返回给前端的视图对象 └── service // 业务层接口和实现这个结构看起来很常规但它解决了几个核心问题DTO和Entity不混用。前端传来的数据可能包含密码、金额等敏感字段如果直接把前端参数绑定到User实体上会导致黑客通过构造请求修改额外字段。比如User实体里有adminFlag字段用户注册时候如果传了这个字段就可能被直接写入为管理员。虽然真实项目中会有更多防护但DTO层阻断是最基本的防线也是我在文档里重点强调的一点。Service接口和实现类分开方便在答辩时说这里是面向接口编程方便未来替换实现。虽然实际开发中有些项目直接写实现类不写接口但标准的Spring分层规范下分开写更符合预期。所有Controller方法只依赖Service接口业务逻辑全部收敛在实现类中。6.2 异常处理和统一返回值的设计我在文档里特别强调了一个问题不能等报错了浏览器直接显示错误堆栈这非常扣分。项目里我定义了一个RResult统一返回体包含三个字段code、msg、data。正常的业务接口成功后都返回R.success(data)失败返回R.error(库存不足)。配合RestControllerAdvice全局异常处理器ServiceException- HTTP 200code为业务错误码msg为具体提示MethodArgumentNotValidException- HTTP 400返回参数校验失败的具体字段提示Exception- HTTP 500msg统一为系统繁忙同时打印完整堆栈到日志全局异常的好处是业务代码里只需要throw new ServiceException(...)不需要每个Controller方法都写try-catch。前端统一处理响应体判断code是否为200再决定渲染逻辑。整个异常链路相当于给系统加了安全网演示的时候一般不太会出尴尬状况。6.3 演示数据的准备和演示路径的设计一套源码如果导入数据库后发现空空如也会导致展示体验很差。我在数据库初始化脚本里预置了完整数据20多件雪具商品雪板、雪服、雪鞋、护具、手套每件商品配了2到4个SKU规格和不同的库存数量一个管理员账号、三个测试用户账号以及几条覆盖不同状态的订单记录。演示路径建议按以下顺序走用管理员账号登录后台打开商品管理页展示商品列表和编辑功能。切到用户端注册新账号或者直接使用预置用户登录。浏览雪板分类进入详情页查看不同规格的价格和库存。加入购物车调整数量后提交订单。在订单页确认支付订单状态变更为待发货。切到管理端找到该订单点击发货。切回用户端确认收货订单变为已完成。最后展示后台数据概览页说明销售额和订单量来源于真实数据库统计。这条路径完整走通一遍覆盖了用户端、管理端、订单状态机的前后交互比单纯介绍某个功能点要立体得多。文档里我还把每一步的预期页面效果截图插入形成一份演示稿临场不慌。6.4 文档部分最值得花时间的几个章节文档源码里的文档常见的内容包含需求说明、数据库设计、接口文档、部署说明这几个部分写多少都不嫌多。但我复盘下来最容易被忽略、也最影响评委观感的是部署说明和环境要求。文档开头一定要注明JDK版本和SpringBoot版本。比如项目基于JDK8 SpringBoot 2.7.18如果使用JDK17运行SpringBoot 2.x可能会遇到反射相关的兼容警告如果用SpringBoot 3.x则要求Maven版本和依赖坐标都不同。我专门写了一个常见运行问题小节包括端口被占用怎么改application.yml里server.portMySQL时区问题导致数据库连接失败的报错和解决方式数据库初始化脚本如何导入账号密码如何修改如果导入Eclipse/IDEA后出现Lombok找不到符号的解决办法这些看起来琐碎的问题实际上占掉了我在前期整套环境搭建时间的大头。把它们整理成FAQ放在文档末尾既方便自己回顾也能让拿到源码的人少走弯路。另外接口文档部分如果觉得手写麻烦可以用Swagger/springdoc自动生成但我在文档里选择保留一份手动整理的接口清单表格包含请求路径、请求方法、请求参数、返回结果四项。这样即使没启动项目光看文档也能理解接口设计对评分和阅读体验都有正向帮助。6.5 从能跑到讲得清的关键一步代码写完、功能跑通之后我还做了一件很重要的事顺着核心业务流程把所有方法调用的链路捋了一遍随手画了一张调用时序草图文字版。从用户点击提交订单开始到Controller接收参数、DTO校验、Service开始事务、Mapper扣库存、生成订单号、更新购物车、事务提交、返回结果给前端每一步对应的类和文件我都能说出来。这个梳理过程看似简单却是把代码能跑变成能跟别人讲清楚的关键一步。因为多数时候代码是试出来的而不是设计出来的如果不主动做链路梳理答辩或者分享时一旦被问到你这个方法底层怎么执行的很容易卡壳。做完这一步后我把这条链路画成了一张标题为订单创建时序图的图附在文档的第四章。其中用箭头标注了前端→Controller→Service→Mapper→数据库的调用关系以及各层之间传递的数据对象。这张图在后来的展示环节帮了大忙——至少证明这个项目不是东拼西凑的代码而是从整体出发设计出来的。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门