资讯详情 Spring Boot购物网站源码实战:下单事务、库存扣减与防超卖
📅 2026/10/23 6:34:45
简介这套购物网站项目源码与数据库面向JavaWeb初学者、课程设计及毕业设计参考者提供一套功能较完整的类淘宝电商实现适合用作电商项目入门拆解样本可帮助读者理解从商品浏览、搜索、购物车到下单、用户管理等业务流程如何落地。压缩包共269个文件约1.71MB其中35个jsp页面、30个java源文件与30个class文件构成前端展示和后端逻辑主体另有2个sql脚本及mdf、ldf数据库文件配合88张jpg、53张gif和19张png图片及样式脚本便于还原页面与搭建运行环境。已有631人学习下载。项目采用Servlet、JSP、JavaBean与MVC分层组织数据库涵盖商品、用户、订单等核心实体涉及用户登录注册、商品分类展示、购物车与订单处理等环节适合对照源码梳理请求处理、页面跳转、数据访问和表结构设计也可作为改造与调试的实战样例。1. 从一份带 SQL 的购物网站源码说起它能跑出什么前阵子有人丢给我一个压缩包文件名朴素得像是随手存的——购物网站 源代码 数据库。解压之后能完整跑出一条电商主流程注册登录、商品列表与搜索、商品详情、加入购物车、填收货地址、下单、模拟支付、后台增删改商品和查订单。对想搞明白电商主干链路的开发来说这种「三件套齐全」的包比零散的教学 demo 实在得多SQL 文件在表结构不用靠猜后端分层在接口怎么串一层层看得见前端页面也在字段怎么对上能直接对着调。它适合两类人。一类是刚学完框架、想做个像样的东西放进作品集的照着跑通再改两处业务比 CRUD 练习有说服力另一类是接了个小商城私活、想找套能改的底座先把架子支起来的重点不在从零写而在看懂别人的表怎么设计、事务边界划在哪儿。这篇笔记就按「它是什么 → 怎么跑起来 → 核心代码长什么样 → 哪儿容易翻车 → 怎么改成自己的」往下走。2. 技术栈与目录结构五个包、三张核心表先把家底摸清2.1 技术选型拆解一套典型的 Spring Boot MyBatis MySQL 组合这类带完整 SQL 的购物网站源码绝大多数走的是「后端 Spring Boot MyBatis或 MyBatis-Plus MySQL前端 Vue Element UI 前后端分离」这条路。也有相当一批是 Servlet/JSP 或者 Thymeleaf 服务端渲染的老式写法。我下面按 Spring Boot 这条主干讲如果你手上的包是 JSP 版本分层思路完全对得上只是注解要换成web.xml里的 Servlet 映射理解成本不高。选这套组合做电商入门是有道理的。商品、购物车、订单这类数据关系明确天生适合关系型数据库表结构能把一对多、多对多的关系讲透MyBatis 把 SQL 写在 XML 里对新手反而是好事——不用猜 ORM 生成了什么语句慢查询、字段漏查都能直接看见Spring 的声明式事务又刚好承接「下单要保证订单、明细、库存三处一致」这个最典型的场景。真正上生产当然还会加 Redis 缓存商品、消息队列削峰、分库分表但那是后话先把单体跑明白。层次常见技术在这个项目里负责什么视图层Vue 2 / Element UI或 Thymeleaf、JSP商品列表、详情、购物车、订单页渲染控制层Spring MVCRestController接收前端参数、做参数校验、组装返回体业务层ServiceTransactional下单、库存扣减、订单状态流转持久层MyBatis Mapper 接口 XML手写 SQL分页、条件查询、批量更新存储MySQL 5.7 / 8.0用户、商品、SKU、购物车、订单、地址需要提醒一句技术栈是手段不是目的。看这类项目最忌讳上来就纠结「为什么不用 Spring Cloud」一套单体能跑通下单全链路比一堆注册中心更有学习价值。2.2 目录结构对照先认清五个包拿到压缩包别急着双击启动类先花十分钟把目录过一遍后面改代码能省一倍时间。典型的后端结构长这样前后端分离的包里前端通常是同级的vue-admin或shop-web目录路径作用改代码时的关注度src/main/java/.../controller所有 HTTP 入口一个类对应一组接口高接口签名基本都在这改src/main/java/.../serviceimpl业务逻辑和事务边界最高下单、扣库存都在这里src/main/java/.../mapperMyBatis 接口方法名对应 XML 的 id中加查询的时候要动src/main/resources/mapper/*.xml真正的 SQL 语句高慢查询和字段问题都在这src/main/resources/application.yml数据库、端口、上传大小等配置高跑起来第一关sql/shop.sql建表 初始数据高先导它static/或upload/商品图片、静态资源中图片 404 全在这有个血泪经验不少包里的mapper目录和resources/mapper是两个不同的地方一个是 Java 接口一个是 XML名字一样但职责不同。mapper-locations配错路径启动不报错一调接口就抛Invalid bound statement新手最容易在这儿卡半天。2.3 数据库设计从 SQL 文件反推业务模型真正的业务逻辑藏在表结构里。与其一行行读 Service不如先把 SQL 文件里的建表语句过一遍商品和 SKU 怎么拆、订单和明细怎么关联、金额用什么类型看完心里就有谱了。核心三张表的简化形态大致如下-- 商品表只放公共信息价格和库存不在这里 CREATE TABLE t_product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL COMMENT 商品名, category_id BIGINT NOT NULL COMMENT 分类对应 t_category, main_image VARCHAR(255) DEFAULT NULL COMMENT 主图相对路径, detail TEXT COMMENT 富文本详情, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品; -- SKU 表真正被下单的是它价格和库存挂在这一层 CREATE TABLE t_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, spec VARCHAR(64) COMMENT 规格如 黑色/256G, price DECIMAL(10,2) NOT NULL COMMENT 单价必须用 DECIMAL, stock INT NOT NULL DEFAULT 0 COMMENT 可售库存, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号没有它并发下单会超卖, KEY idx_product (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品规格; -- 订单明细下单瞬间要把商品名、单价、图拍照存下来 CREATE TABLE t_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, product_name VARCHAR(128) NOT NULL COMMENT 快照商品改名不影响历史订单, price DECIMAL(10,2) NOT NULL COMMENT 快照价, num INT NOT NULL, KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细;三处细节值得琢磨。第一价格和库存放在 SKU 层而不是商品层因为同一件衣服的不同尺码价格和库存都可能不同拆开之后加购才能真正落到「买哪一件」上。第二订单明细里冗余存了product_name和price这不是设计失误而是刻意为之——商品改价、改名、下架之后历史订单必须还能原样展示这类快照字段在电商里叫「留痕」少了它以后对账要哭。第三金额一律DECIMAL(10,2)用FLOAT存钱的包我见过结算时 0.1 0.2 算出 0.30000000000000004对不上账纯属自找。提示导库前先确认 SQL 文件里的字符集是不是utf8mb4。是utf8的话商品名带个表情符号或者生僻字就会插入失败报错信息还特别含糊。3. 本地跑通从建库导 SQL 到后台登录成功的完整命令3.1 环境准备JDK、MySQL、Node 三件套的版本对齐跑不起来十次里有六次是版本问题。后端是 Spring Boot 2.x 的话JDK 用 8 或 11 最稳直接上 17 有概率撞上反射相关的启动异常MySQL 5.7 和 8.0 都能用但 8.0 的驱动类名是com.mysql.cj.jdbc.DriverURL 里还得带时区参数用老的com.mysql.jdbc.Driver会收到一行黄色警告虽然不致命但看着膈应。前端如果是 Vue 2Node 版本别超过 16上了 18 之后node-sass编译经常直接崩这个坑我踩过不止一次报错信息长到能刷满一屏本质就是二进制包和 Node ABI 版本对不上。依赖管理看有没有pom.xml。Maven 项目第一次mvn clean package会拉一堆包国内网络环境下建议先把镜像配好如果包里自带lib目录和.iml文件那可能是 IDE 直接导出的工程用 IDEA 打开按提示配 SDK 就行别硬套 Maven。3.2 导入 SQL建库、字符集、初始数据先看sql目录下有几个文件。有的包会拆成schema.sql建表和data.sql初始数据两个有的合成一个还有的会额外给一份init.sql建库语句。顺序千万别搞反先建库再导表最后灌数据。# 1. 建库字符集锁定 utf8mb4别用默认的 latin1 mysql -uroot -p -e CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 导 SQL--default-character-set 不加的话中文大概率变问号 mysql -uroot -p --default-character-setutf8mb4 shop sql/shop.sql # 3. 验一下表在不在、数据有没有进来 mysql -uroot -p -e use shop; show tables; select count(*) from t_product; select count(*) from t_sku;如果第三步查出来的商品数是 0说明 SQL 里只有建表语句没有INSERT得自己往后台加几条数据才能看到列表页。登录账号通常在t_user表里密码字段如果是 BCrypt 加密的明文可能是123456或者admin具体看包里的说明文档或者 README如果实在找不到就本地生成一个 BCrypt 串直接UPDATE覆盖掉比暴力猜快得多——这是最省事的后悔药。3.3 改配置与启动验证三个必改项配置文件是跑通的最后一道关。Spring Boot 项目找application.yml或application.properties改完再启动server: port: 8080 # 前端 proxy 里的 target 必须和这里一致 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 换成你自己的本地密码 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 5MB # 后台上传商品图默认 1MB 经常不够用 max-request-size: 10MB mybatis: mapper-locations: classpath:mapper/*.xml # 少这行XML 里的 SQL 全部找不到 configuration: map-underscore-to-camel-case: true # 数据库 user_name 自动映射成 userName三个必改项数据库密码、端口、mapper-locations。尤其是最后一个很多包里的 XML 放在resources/mapper下但配置写的是classpath*:com/xxx/mapper/*.xml路径对不上启动毫无异常一访问接口就Invalid bound statement (not found)。改完启动控制台看到 Tomcat 起在 8080 就算过了第一关。前端那边进目录先npm install然后看vue.config.js里的devServer.proxydevServer: { port: 8081, proxy: { /api: { target: http://127.0.0.1:8080, // 后端地址端口改了这里也得改 changeOrigin: true, pathRewrite: { ^/api: } // 后端接口没有 /api 前缀时必须重写 } } }pathRewrite这一行是跨域问题的分水岭。后端 Controller 上写的是RequestMapping(/product)前端请求/api/product/list不重写就会打到后端的/api/product/list上稳稳一个 404。3.4 冒烟测试按顺序点五步别一上来就点下单按依赖顺序走一遍最快定位问题注册一个新账号 → 登录看有没有拿到 token → 商品列表能不能出图 → 商品详情加购物车 → 结算填地址提交订单。这五步任何一步断掉问题范围立刻缩小到对应模块。后台管理端一般挂在/admin路径或者单独一个端口登录后能看到商品管理、订单管理、用户管理几个菜单。如果后台进不去但前台正常八成是角色字段role或is_admin没配对直接改数据库把当前用户的角色改成管理员最直接。4. 核心链路拆解加购、下单、鉴权三段代码的写法与坑4.1 购物车加购接口怎么处理重复商品购物车看着简单实际是整个项目里最容易被写歪的模块。最常见的错误是「每次加购都插一条新记录」结果用户连点三次同一件商品购物车里躺着三行一模一样的条目。合格的写法是先查后判Override public void addToCart(Long userId, Long skuId, Integer num) { if (num null || num 0 || num 99) { throw new BizException(购买数量不合法); } // 1. 查这个用户车里有没有同一个 SKU CartItem exist cartMapper.selectOne(new LambdaQueryWrapperCartItem() .eq(CartItem::getUserId, userId) .eq(CartItem::getSkuId, skuId)); if (exist ! null) { // 2. 有就累加数量不新增行 exist.setNum(exist.getNum() num); cartMapper.updateById(exist); return; } // 3. 没有才插入价格只做展示快照结算时必须重新查 Sku sku skuMapper.selectById(skuId); if (sku null || sku.getStock() 0) { throw new BizException(商品已下架); } CartItem item new CartItem(); item.setUserId(userId); item.setSkuId(skuId); item.setNum(num); item.setPriceSnapshot(sku.getPrice()); item.setCreateTime(LocalDateTime.now()); cartMapper.insert(item); }userId绝对不能从请求参数里取必须从登录态token 或 session里解出来否则改个 URL 就能往别人车里塞东西。priceSnapshot只是给购物车页面展示用的真正结算时要以数据库当前价重算不然用户半年前加购、商品涨过价你还按旧价卖亏的是自己。另外selectOneinsert这个组合在并发下依然可能插重稳妥做法是在(user_id, sku_id)上建唯一索引让数据库兜底。4.2 下单一个事务里做完价格重算、库存扣减、订单落库下单是全项目最该盯紧的一段。它要保证四件事同时成立订单主表落库、订单明细落库、库存扣减、购物车清空——任何一步失败都必须整体回滚不然就是「钱扣了没订单」或者「订单生了库存没减」。事务注解只是起点真正防超卖的是 SQL 里的条件更新Service public class OrderServiceImpl implements OrderService { Transactional(rollbackFor Exception.class) // 别用默认的 RuntimeException Override public Long createOrder(Long userId, ListLong cartIds, Long addressId) { // 1. 购物车项必须属于当前用户防止越权结算别人的车 ListCartItem items cartMapper.selectList(new LambdaQueryWrapperCartItem() .in(CartItem::getId, cartIds) .eq(CartItem::getUserId, userId)); if (items.isEmpty()) { throw new BizException(购物车为空); } BigDecimal total BigDecimal.ZERO; ListOrderItem details new ArrayList(); for (CartItem item : items) { // 2. 价格一律以数据库当前价为准前端传的金额一概不信 Sku sku skuMapper.selectById(item.getSkuId()); if (sku null) { throw new BizException(商品不存在); } // 3. 条件更新扣库存stock num 写进 where扣不动就是库存不足 int affected skuMapper.deductStock(sku.getId(), item.getNum()); if (affected 0) { throw new BizException(库存不足 sku.getSpec()); } total total.add(sku.getPrice().multiply(BigDecimal.valueOf(item.getNum()))); OrderItem d new OrderItem(); d.setSkuId(sku.getId()); d.setProductName(sku.getProductName()); // 快照 d.setPrice(sku.getPrice()); // 快照 d.setNum(item.getNum()); details.add(d); } // 4. 落订单主表状态 0 待支付 Order order new Order(); order.setOrderNo(generateOrderNo(userId)); order.setUserId(userId); order.setAddressId(addressId); order.setTotalAmount(total); order.setStatus(0); orderMapper.insert(order); // 5. 明细补 orderId 后批量插入再清购物车 details.forEach(d - d.setOrderId(order.getId())); orderItemMapper.insertBatch(details); cartMapper.deleteBatchIds(cartIds); return order.getId(); } }配套的 Mapper XML 才是防超卖的核心!-- 扣库存把够不够这个判断交给数据库天然带行锁 -- update iddeductStock UPDATE t_sku SET stock stock - #{num} WHERE id #{skuId} AND stock #{num} /update先select查库存、再判断、再update这套写法在并发下必超卖——两个请求同时查到库存 1都判断通过最后扣成 -1。改成WHERE stock #{num}之后第二条 update 影响行数为 0业务层抛异常回滚问题从根上解决。rollbackFor Exception.class也不能省Spring 默认只对RuntimeException回滚抛受检异常时事务悄悄提交这类问题排查起来像黑匣子,日志里一点痕迹都没有。4.3 鉴权拦截器 token 的两层校验前后端分离的项目基本都用 token 而不是 session。核心是一个HandlerInterceptor在进 Controller 之前把用户身份解出来塞进上下文public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { // 1. 放行非 Controller 请求静态资源、错误页 if (!(handler instanceof HandlerMethod)) { return true; } // 2. 白名单登录、注册、商品浏览接口不需要 token HandlerMethod hm (HandlerMethod) handler; if (hm.hasMethodAnnotation(NoAuth.class)) { return true; } String token req.getHeader(Authorization); if (token null || token.isEmpty()) { resp.setStatus(401); return false; } Long userId jwtUtil.parseUserId(token.replace(Bearer , )); if (userId null) { resp.setStatus(401); return false; } // 3. 塞进 ThreadLocalService 层随时可取不用层层传参 UserContext.set(userId); return true; } Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { UserContext.clear(); // 线程池复用不清会串号 } }两点说明。afterCompletion里的clear()千万别漏——Tomcat 线程是复用的不清 ThreadLocal下一个请求可能拿到上一个用户的 id这种 bug 排查难度极高表现是「偶尔能看到别人的订单」。第二商品列表、详情这类浏览接口应该标NoAuth放行让未登录用户也能逛只有加购、下单、订单列表才强制校验否则用户一进首页就被弹回登录页体验极差。5. 常见问题排查这套购物网站源码最容易翻车的五处5.1 启动与环境类连不上库、端口被占、静态资源 404现象一启动即报Communications link failure。原因通常有三种MySQL 服务没起来、URL 里的端口不是 3306、8.0 版本没配serverTimezone。解决顺序是先mysql -uroot -p手动连一下确认服务活着再核对application.yml里的端口和时区参数8.0 一律加serverTimezoneAsia/ShanghaiuseSSLfalse。现象二Port 8080 was already in use。上一个进程没退干净或者本机跑着别的服务。Windows 用netstat -ano | findstr 8080找到 PID 再taskkill /F /PIDMac/Linux 用lsof -i:8080后kill -9。图省事也可以直接改server.port但记得同步改前端proxy.target只改一头等于没改。现象三页面能打开但样式全丢、图片全是裂的。原因多半是静态资源路径写成了绝对路径/upload/xxx.jpg而项目实际部署在/shop这样的子路径下。解决要么在配置里加server.servlet.context-path要么把前端引用统一改成相对路径。这个坑在本地跑通常不出现一部署到测试环境就集体裂图。5.2 数据与事务类库存扣成负数、订单金额对不上现象四压测时库存出现 -3。原因就是 4.2 节说的「先查后改」select和update之间存在并发窗口。解决办法有两个方向轻量的用WHERE stock #{num}条件更新影响行数为 0 就抛异常要更严谨就加version字段做乐观锁UPDATE ... SET stock stock - #{num}, version version 1 WHERE id ? AND version ?失败重试两三次。别上synchronized单机有效多实例部署立刻失效。现象五订单总额和明细加起来对不上差几分钱。八成是某处用了double或者float做金额运算。把所有金额字段的类型统一成BigDecimal数据库对应DECIMAL(10,2)乘法用multiply、加法用add并且在最后一步统一setScale(2, RoundingMode.HALF_UP)收敛小数位。已经存进库的历史脏数据写个脚本按明细重算一遍总额刷回去。5.3 前后端联调类跨域、字段对不上、上传失败现象六浏览器控制台报 CORS 错误Postman 里却正常。这是跨域预检没过不是接口坏了。开发环境最省事的做法是走devServer.proxy代理让请求同源如果非要后端放行就在WebMvcConfigurer里加addCorsMappingsallowedOriginPatterns别图省事写*又同时开allowCredentials(true)这两个组合在浏览器里会被直接拒绝。现象七后端返回的字段是userName前端拿到的却是undefined。命名风格不一致数据库user_name、Java 属性userName、前端写username差一个大小写就拿不到值。统一约定后端开map-underscore-to-camel-case前端接口层做一次字段映射别在每个组件里各写各的。现象八后台上传商品图报Maximum upload size exceeded。Spring Boot 默认单文件 1MB现在的手机拍出来随便都三四兆。改spring.servlet.multipart.max-file-size和max-request-size即可Nginx 前面还有一层的话client_max_body_size也得跟着调只改一头照样 413。6. 二次开发把教学级购物网站改成能接真实订单的底座跑通只是开始真正值钱的是知道往哪儿改。我按投入产出比排了个序前两项做完这套源码的「可用度」能上一个台阶优先级改造点具体做法收益P0库存并发条件更新 version乐观锁 失败重试消除超卖业务可用P0登录态token 加过期时间、加刷新机制、密码 BCrypt安全底线P1订单状态机待支付/已支付/已发货/已完成/已取消用状态枚举约束流转避免状态乱跳P1支付回调幂等按order_no加唯一约束回调重复直接返回成功防重复发货P2商品缓存详情页加 Redis更新时删缓存首页响应从百毫秒降到十毫秒级订单状态机是最容易被忽略的一块。教学项目里状态就是0/1/2三个裸数字随手setStatus(2)满天飞结果出现「未支付却已发货」这种脏数据。我会把流转规则收进枚举public enum OrderStatus { UNPAID(0, 待支付), PAID(1, 已支付), SHIPPED(2, 已发货), FINISHED(3, 已完成), CANCELED(4, 已取消); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } // 只允许这几条合法路径其余一律拒绝 public boolean canTransitTo(OrderStatus target) { switch (this) { case UNPAID: return target PAID || target CANCELED; case PAID: return target SHIPPED; case SHIPPED: return target FINISHED; default: return false; // 终态不可再变 } } }Service 里所有改状态的地方统一走if (!current.canTransitTo(target)) throw new BizException(非法状态流转);一行判断挡掉大量脏数据。支付回调再加一层幂等回调进来先按order_no查订单已经是PAID就直接返回成功别重复扣库存、别重复发货——真实支付平台的回调是会重发的没做幂等的项目上线第一周必翻车。验证方法也要跟上。改完库存逻辑别只看页面点着正常写个简单的并发测试用 50 个线程同时下单同一件库存 10 的商品跑十轮看成功订单数是不是稳定在 10、库存是不是稳定在 0。这个测试跑通库存这块基本可以放心。改完鉴权则用两种角色交替登录确认普通用户访问不到/admin/**。从那以后我每次拿到一份带 SQL 的源码包第一件事都是先把表结构导出来画一遍关系图再动代码——因为业务逻辑写在 Service 里但业务规则藏在表结构里表看不懂改哪儿都是瞎猜。希望帮到你。本文还有配套的精品资源点击获取