ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电商手机销售系统毕业设计:SPU/SKU拆分与库存并发实战

电商手机销售系统毕业设计:SPU/SKU拆分与库存并发实战

简介:这是一套面向计算机专业学生与Java Web开发者的电子商务实战资料,围绕网上手机销售系统的设计与实现展开,适合课程设计、毕业设计及想掌握B/S架构电商平台开发的技术人员。资源包共2000个文件,约202.7MB,涵盖jsp页面、java源码、js脚本、html与css样式、jar依赖包、xml配置及class编译文件等,完整呈现前端展示、后台管理与数据库设计的技术脉络。系统功能包括用户注册登录、商品浏览检索、购物车与订单处理、公告留言管理,后台则覆盖用户、商品、订单、公告等信息维护,采用HTML、JSP、CSS、SSH框架与SQL Server 2008实现。随包附有项目源码、辅助视频、毕业论文、答辩PPT与任务书,可帮助读者从需求分析、可行性研究到系统测试完整走通开发流程,理解各模块设计思路与实现细节。目前已有60人学习,适合需要完整项目案例与文档支撑的读者参考。

1. 从一份毕业设计看电商手机销售系统的真实骨架

很多同学第一次拿到“电子商务领域网上手机销售系统的设计与实现”这类题目时,第一反应是去搜一套现成源码,把论文里的功能模块图对着改一改就交差。但真正做过一遍的人都知道,这套系统里最值钱的部分不是页面长什么样,而是订单、库存、支付回调这三条链路怎么在并发下不打架。手机销售这个场景比通用商城更“毒”:SKU 属性多(颜色、内存、版本)、价格波动频繁、秒杀式抢购集中,任何一个环节没处理好,超卖和脏数据就会在答辩现场被老师一句话问穿。这篇文章面向的是正在做这类课程设计或毕业设计的同学,也适合想用一套完整项目练手全栈开发的新人。我会按“先讲清系统边界,再拆库表和接口,最后落到部署和排错”的顺序,把一套可复现的手机销售系统从零讲透,源码结构、论文要点、答辩会被追问的地方都会覆盖到。

2. 需求拆解与技术选型:为什么手机销售比通用商城更难做

2.1 手机品类的三个特殊业务约束

通用商城的商品模型通常是“一个商品一个价格一份库存”,但手机不是。同一款机型会有 8GB+128GB、12GB+256GB 等多个规格,每个规格是独立库存、独立价格,甚至独立参与活动。这意味着你的商品表不能只有一张product,必须拆成 SPU(标准产品单位,代表“某款手机”)和 SKU(库存量单位,代表“某颜色某内存的具体可售单元”)。这是第一个约束。

第二个约束是价格时效性。手机首发价、活动价、以旧换新补贴价经常并存,用户下单那一刻锁定的价格必须和支付时一致,否则会出现“下单 3999、付款变 4099”的投诉。常见做法是在订单表里冗余一份下单时的价格快照,而不是每次去关联查商品现价。

第三个约束是库存并发。手机秒杀场景下,几百个请求同时扣同一 SKU 的库存,如果用“先查再减”的写法,必然超卖。正确做法是用数据库行锁或原子更新,把判断和扣减合并成一条 SQL。

提示:答辩时老师最爱问的就是“你怎么防止超卖”,这一条答不上来,前面做得再漂亮也会被扣分。

2.2 技术栈选型与理由

一套能跑通、能讲清、能在普通笔记本上演示的系统,选型不宜过重。我一般推荐这样的组合:

层次选型选择理由
后端框架Spring Boot生态成熟,答辩老师熟悉,遇到问题资料多
数据库MySQL 8支持行锁和事务,够用且免费
缓存Redis处理库存预热和会话,减轻数据库压力
前端Vue + Element UI组件齐全,管理后台和商城前台都能快速搭
持久层MyBatis-Plus单表 CRUD 不用写 SQL,复杂查询仍可手写

如果时间紧,前端也可以换成 Thymeleaf 服务端渲染,少写一套接口联调。但如果论文里想体现“前后端分离”这个加分点,Vue 方案更合适。选型没有绝对对错,关键是你能说清“为什么不用另一个”。

2.3 功能模块的边界划分

一套完整的手机销售系统,按角色拆成三块:用户端(浏览、搜索、加购、下单、支付、查订单)、管理端(商品管理、订单管理、库存管理、用户管理)、系统层(登录鉴权、支付回调、定时任务)。论文里的功能结构图通常画成树状,但真正写代码时,我建议按“领域”而不是按“页面”来分包:

com.shop ├── controller // 接口层,只做参数校验和转发 ├── service // 业务逻辑,事务在这里控制 │ ├── order // 订单领域 │ ├── product // 商品领域 │ └── user // 用户领域 ├── mapper // 数据访问 ├── entity // 数据库实体 ├── dto / vo // 传输对象和视图对象 └── config // 全局配置

这样分包的好处是,订单相关的逻辑全部收在service/order下,答辩时翻代码一目了然,也方便你讲“高内聚低耦合”。

3. 数据库设计与核心表落地:从 SPU/SKU 到订单快照

3.1 商品域建表:SPU 与 SKU 的拆分

先看商品域的核心两张表。SPU 表存公共信息(品牌、型号、描述),SKU 表存具体规格和库存。

-- SPU 表:一款手机的基本信息 CREATE TABLE `spu` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(128) NOT NULL COMMENT '商品名称,如 某品牌旗舰机', `brand` VARCHAR(64) NOT NULL COMMENT '品牌', `category_id` BIGINT NOT NULL COMMENT '分类ID', `description` TEXT COMMENT '商品描述', `status` TINYINT DEFAULT 1 COMMENT '1上架 0下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_category (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- SKU 表:具体可售规格,库存和价格挂在这里 CREATE TABLE `sku` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `spu_id` BIGINT NOT NULL COMMENT '所属SPU', `specs` VARCHAR(255) NOT NULL COMMENT '规格JSON,如 {"颜色":"黑","内存":"256G"}', `price` DECIMAL(10,2) NOT NULL COMMENT '售价', `stock` INT NOT NULL DEFAULT 0 COMMENT '可售库存', `version` INT DEFAULT 0 COMMENT '乐观锁版本号', `image` VARCHAR(255) COMMENT '规格图', INDEX idx_spu (`spu_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

specs字段用 JSON 字符串存规格,好处是加规格不用改表结构,坏处是不能直接按规格筛选。如果论文里需要“按内存筛选手机”这种功能,可以再拆一张spec_value表做关联,但课程设计阶段用 JSON 足够。

3.2 订单域建表:价格快照与状态机

订单表最关键的两个设计:价格快照和状态字段。

CREATE TABLE `order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号', `user_id` BIGINT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消', `receiver` VARCHAR(64) COMMENT '收货人', `phone` VARCHAR(20) COMMENT '联系电话', `address` VARCHAR(255) COMMENT '收货地址', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME COMMENT '支付时间', INDEX idx_user (`user_id`), INDEX idx_status (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `sku_id` BIGINT NOT NULL, `sku_name` VARCHAR(128) NOT NULL COMMENT '下单时商品名快照', `specs` VARCHAR(255) COMMENT '下单时规格快照', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', `quantity` INT NOT NULL, INDEX idx_order (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意order_item里冗余了sku_name、specs、price。这不是冗余设计失误,而是刻意为之:商品改价改名后,历史订单必须显示下单当时的信息。答辩时如果老师问“为什么不直接关联 sku 表查”,这就是标准答案。

3.3 库存扣减的原子写法

超卖的根源是“读-判断-写”三步不原子。正确写法是把判断塞进 WHERE 条件:

-- 原子扣减:库存不足时 affected rows = 0,业务层据此判断失败 UPDATE sku SET stock = stock - #{quantity} WHERE id = #{skuId} AND stock >= #{quantity};

在 Service 层这样调用:

// 返回影响行数,0 表示库存不足 int affected = skuMapper.deductStock(skuId, quantity); if (affected == 0) { throw new BizException("库存不足,请刷新后重试"); }

这条 SQL 依赖 InnoDB 的行锁,WHERE id = ?命中主键,锁粒度最小。如果并发量再高,可以叠加 Redis 预扣减,但课程设计阶段这一条 SQL 已经能扛住答辩演示。

4. 核心链路实现:下单、支付回调与订单状态流转

4.1 下单接口的完整事务流程

下单是整个系统最复杂的一段,涉及库存、订单、订单项三张表的写入,必须在一个事务里。我一般按这个顺序写:

@Service public class OrderServiceImpl implements OrderService { @Transactional(rollbackFor = Exception.class) public String createOrder(Long userId, Long skuId, Integer quantity, AddressDTO addr) { // 1. 查 SKU,校验上架状态 Sku sku = skuMapper.selectById(skuId); if (sku == null || sku.getStock() < quantity) { throw new BizException("商品不存在或库存不足"); } // 2. 原子扣减库存,失败直接抛异常触发回滚 int affected = skuMapper.deductStock(skuId, quantity); if (affected == 0) { throw new BizException("库存扣减失败"); } // 3. 写订单主表 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalAmount(sku.getPrice().multiply(new BigDecimal(quantity))); order.setStatus(0); order.setReceiver(addr.getName()); order.setPhone(addr.getPhone()); order.setAddress(addr.getDetail()); orderMapper.insert(order); // 4. 写订单项,冗余价格和规格快照 OrderItem item = new OrderItem(); item.setOrderId(order.getId()); item.setSkuId(skuId); item.setSkuName(sku.getName()); item.setSpecs(sku.getSpecs()); item.setPrice(sku.getPrice()); item.setQuantity(quantity); orderItemMapper.insert(item); return order.getOrderNo(); } }

@Transactional保证四步要么全成功要么全回滚。rollbackFor = Exception.class是为了让受检异常也触发回滚,默认只回滚运行时异常,这一点很多人踩过坑。

4.2 支付回调的幂等处理

支付回调是外部系统调你的接口,同一个订单可能被回调多次。如果不做幂等,用户付一次钱可能生成两笔已支付记录。处理思路是:回调进来先查订单状态,已支付就直接返回成功,不再重复处理。

public String handlePayCallback(String orderNo, String tradeNo) { Order order = orderMapper.selectByOrderNo(orderNo); if (order == null) { return "FAIL"; } // 幂等:已支付或已取消的订单不再处理 if (order.getStatus() != 0) { return "SUCCESS"; } // 更新状态,WHERE 带 status=0 双重保险 int affected = orderMapper.updateStatus(orderNo, 0, 1, tradeNo); if (affected == 0) { // 并发下被其他线程抢先处理,同样视为成功 return "SUCCESS"; } return "SUCCESS"; }

对应的 SQL 也要带状态条件:

UPDATE `order` SET status = 1, pay_time = NOW() WHERE order_no = #{orderNo} AND status = 0;

这样即使两个回调同时到达,数据库行锁也会让其中一个affected = 0,不会重复更新。

4.3 订单状态机与超时取消

订单状态不能随便改,必须按状态机走:0待支付 → 1已支付 → 2已发货 → 3已完成,任意状态可转 4已取消(已发货除外)。建议在 Service 层写一个校验方法,所有状态变更前先调用:

private static final Map<Integer, List<Integer>> TRANSITIONS = Map.of( 0, List.of(1, 4), // 待支付 -> 已支付 / 已取消 1, List.of(2, 4), // 已支付 -> 已发货 / 已取消(退款) 2, List.of(3), // 已发货 -> 已完成 3, List.of(), 4, List.of() ); public void checkTransition(int from, int to) { if (!TRANSITIONS.getOrDefault(from, List.of()).contains(to)) { throw new BizException("非法状态流转"); } }

超时取消用定时任务扫status = 0 AND create_time < NOW() - INTERVAL 30 MINUTE的订单,逐条取消并回补库存。回补库存同样用原子 SQL:UPDATE sku SET stock = stock + ? WHERE id = ?。

5. 避坑与排查:那些答辩前夜才暴露的问题

5.1 现象:本地跑通,换台电脑就报数据库连接失败

原因通常是配置文件里写死了localhost:3306和本机密码,换环境后 MySQL 端口或密码不同。解决方式是把数据库配置抽到application-dev.yml,并在 README 里写清初始化步骤:先执行schema.sql建表,再执行data.sql灌测试数据,最后改配置里的账号密码。论文里如果附了部署章节,这一段要写具体,别只写“配置数据库连接”。

5.2 现象:下单接口压测时出现超卖

原因就是前面说的“先查再减”。排查方法是看日志里同一 SKU 的扣减记录,如果出现负库存,基本可以确认。解决方式是把扣减改成WHERE stock >= ?的原子更新,并在 Service 层判断影响行数。改完后可以用 JMeter 开 50 个线程并发下单验证,库存为 0 时不再有成功订单。

5.3 现象:支付回调后订单状态没变

先看回调接口有没有被正确路由到,再看订单号是否匹配。常见原因是回调参数里的订单号和系统内订单号字段名不一致,比如对方传out_trade_no,你取的是orderNo。排查时把回调原始报文打进日志,逐字段比对。另外注意回调接口不能加登录鉴权拦截,否则外部系统调不进来,这个坑很隐蔽。

5.4 现象:订单列表页加载越来越慢

数据量到几千条后,如果分页查询没走索引,或者每查一条订单又去查一次订单项(N+1 查询),页面就会卡。解决方式是给order.user_id和order.status建索引,订单项用IN批量查再在内存里按 orderId 分组,避免循环查库。MyBatis-Plus 的分页插件记得配置,否则Page对象不会真正 limit。

5.5 现象:答辩时被问“你的系统安全性怎么保证”

这个问题范围很大,但落到手机销售系统上,至少要能答出三点:密码用 BCrypt 加盐存储而不是明文或 MD5;接口做登录态校验,管理端接口做角色鉴权;下单金额在服务端重新计算,不信任前端传来的价格。如果还能补一句“支付回调做了签名校验和幂等”,基本就稳了。

6. 论文、答辩与源码组织的进阶技巧

先说论文。很多同学把源码写完再补论文,结果发现论文里的功能模块图和代码对不上。我的习惯是先把数据库表设计定稿,因为表结构是论文“系统设计”章节的核心,也是代码的源头。表定了,E-R 图、功能结构图、接口清单都能顺着推出来。论文里的“核心代码”章节不要整页贴代码,挑三段讲:库存原子扣减、订单事务、支付回调幂等,每段配一段文字说明“为什么这么写”,这比贴一百行 CRUD 有说服力。

再说答辩 PPT。老师提问集中在三个方向:数据一致性怎么保证、并发怎么处理、你和别人的系统有什么区别。PPT 里最好有一页专门画下单时序,把“校验库存 → 扣减 → 写订单 → 返回”四步画清楚,旁边标注事务边界。被问到不会的,别硬编,就说“这部分我目前用 XX 方案实现,理论上还可以用 XX 优化,但课程设计阶段没展开”,诚实比瞎答得分高。

源码组织上,建议在项目根目录放一个README.md,写清环境要求(JDK 版本、MySQL 版本、Node 版本)、初始化步骤、默认账号密码、启动命令。辅助视频如果要做,按“环境搭建 → 数据库初始化 → 后端启动 → 前端启动 → 功能演示”五段录,每段不超过十分钟,别从头到尾录一遍敲代码,没人看得下去。任务书和开题报告里的进度安排,实际做的时候大概率会拖,所以排期时把联调和答辩准备各留出三天缓冲,这是血泪经验。

最后说一个我自己的习惯:每次改完核心逻辑,比如库存扣减或订单状态,我都会手动构造一次“库存刚好为 1 时两个人同时下单”的场景跑一遍,确认只有一个成功。这个动作花不了五分钟,但能挡住答辩现场最致命的一击。系统做完不是终点,能把它讲清楚、能证明它在边界情况下不翻车,才是这套毕业设计真正要交付的东西。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进