简介:这是一套基于 Java 与 Spring Boot 框架开发的网上商城购物系统源码,适合正在学习 Spring Boot 实战、需要完成电商课程设计或毕业设计的开发者。系统覆盖商品管理、购物车、订单、用户地址与在线客服等常见电商业务,能帮助读者快速理解前后端分离商城项目的基本结构。资源包共 831 个文件,约 19.21MB,以 Java 后端源码、Vue 前端组件、JavaScript 脚本、HTML 页面与 CSS 样式为主,同时包含 SQL 数据库脚本和 Maven 构建配置,目录结构较完整,便于按模块查阅。目前已有 36 人在 CSDN 学习下载,可作为项目起步时的参考。项目在购物车、在线客服、地址管理三个模块中实现了增删改查、分页查询、条件筛选与排序,并提供按指定列和类型统计记录的提醒接口;同时附带 1-install.bat、2-run.bat、3-build.bat 等部署脚本,方便本地快速运行调试。源码中包含后台管理界面与常见工具配置,适合用来对照练习后端接口开发、Vue 页面联调及数据库设计。
1. 用Java和Spring Boot搭网上商城系统,源码里真正值钱的是模块拆分思路
拿到这个标题下的源码zip,很多人第一件事是解压、配个MySQL、跑起来看首页轮播图。但说实话,Spring Boot写的网上商城,仓库里一搜就是几十个版本,页面长得都差不多,真正拉开差距的是商品、购物车、订单这三条主线的边界划分,以及并发下单时怎么保库存不超卖。这套源码覆盖的人群很明确:刚啃完Java基础语法、想用Spring Boot做第一个完整项目的开发者,需要交课程设计或毕业设计的在校生,还有接小私活时想快速搭一套商城后端的人。跑通页面只是第一步,读懂模块拆法才能自己改需求。这篇就按我拿到这种项目时会做的事来讲:先立架构,再建表,然后走通下单链路,最后落到本地启动和排错。
2. 网上商城的Spring Boot四层架构:Controller、Service、Mapper与领域模型
2.1 Controller层只做参数接收,别把业务逻辑写进来
我见过不少Spring Boot项目,Controller里直接new ServiceImpl、直接写SQL查询,甚至有人把扣库存的UPDATE直接写在Controller方法里。这种写法在商城这种业务里撑不过第二个需求变更。常见做法是严格走四层:Controller接收HTTP请求、Service处理业务、Mapper访问数据库、领域对象承载数据。Controller只做三件事:解析参数、调用Service、包装返回值。看一段最小示例:
@RestController @RequestMapping("/api/goods") public class GoodsController { private final GoodsService goodsService; public GoodsController(GoodsService goodsService) { this.goodsService = goodsService; } @GetMapping("/page") public Result<PageResult<GoodsVO>> page(@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize) { return Result.ok(goodsService.pageQuery(pageNum, pageSize)); } }这段代码里没有出现任何SQL,没有出现if判断库存的语句。如果哪一天要把分页从PageHelper换成MyBatis-Plus,Controller一行都不用改。判断一个商城源码的Controller层写得好不好,就看方法里有没有超过三行以上的逻辑。参数校验可以交给JSR-303的@Valid注解,返回值统一用Result 包装,这样前端处理起来也一致。接口路径设计上,/api/goods/page这种按资源拆分的风格比/queryGoodsList更容易扩展。
2.2 Service层的事务边界:@Transactional放在哪个方法上
四层架构里Service层是业务规则的容器,也是事务的边界。商城项目里事务最密集的地方在订单创建:生成订单主表、生成订单明细、扣减库存、如果用了优惠券还要标记已使用,这些操作必须在一个事务里,任何一个失败都要回滚。很多新手犯的错是把@Transactional加在Controller方法上,或者加在Mapper接口上,前者事务范围太大,后者事务粒度太碎。正确位置是Service的实现类或接口方法上:
@Service public class OrderServiceImpl implements OrderService { private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final GoodsMapper goodsMapper; public OrderServiceImpl(OrderMapper orderMapper, OrderItemMapper orderItemMapper, GoodsMapper goodsMapper) { this.orderMapper = orderMapper; this.orderItemMapper = orderItemMapper; this.goodsMapper = goodsMapper; } @Transactional(rollbackFor = Exception.class) public Order createOrder(Long userId, List<OrderItemParam> items) { // 1. 校验商品状态与库存 // 2. 计算订单总金额 // 3. 插入订单主表 // 4. 批量插入订单明细 // 5. 扣减库存 return order; } }注意rollbackFor = Exception.class这个参数。Spring的@Transactional默认只在抛出RuntimeException时回滚,检查异常不会触发回滚。商城下单时如果抛出一个checked exception表示库存不足,事务不滚,订单主表已经插进去了,后面对账时会多出一堆孤儿订单。显式声明rollbackFor是最稳妥的写法。另外事务方法不能通过this调用,否则代理不生效,这个问题在Spring Boot里很隐蔽,排查时优先看调用链上有没有经过Spring容器代理。
2.3 MyBatis与Spring Boot的整合:Mapper接口和XML的对应关系
商城系统的查询条件通常很多:商品名称模糊查、价格区间、分类筛选、上下架状态,写死在Mapper接口的注解里会让SQL难以维护。源码里常见的做法是XML与接口分离。接口只声明方法,XML里写SQL,两者通过命名空间和方法名绑定。JPA系的@Query写不了复杂动态SQL,MyBatis的XML方案在商城场景下更顺手。看一个分页查询的声明:
@Mapper public interface GoodsMapper { List<Goods> selectPage(@Param("offset") int offset, @Param("limit") int limit, @Param("status") Integer status); }对应XML:
<select id="selectPage" resultType="com.example.mall.entity.Goods"> SELECT id, goods_name, price, stock, status FROM goods <where> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY sort_order DESC LIMIT #{offset}, #{limit} </select>这里有几个参数值得细看。@Param注解指定了SQL里引用的参数名,XML里用#{offset}和#{limit}做预编译占位,能防止SQL注入。resultType直接映射到实体类Goods,字段名和列名通过map-underscore-to-camel-case配置自动转驼峰。LIMIT #{offset}, #{limit}是MySQL的分页写法,offset表示跳过多少条,limit表示取多少条。如果前端传pageNum=2、pageSize=10,Service层要自己算offset=(pageNum-1)*pageSize。这种分页方式在数据量小于百万时没问题,数据量大了要改成基于游标的分页,这是另一个话题。
四层架构落实到商城项目,还有一个容易被忽略的角色:VO。Goods是数据库实体,GoodsVO是给前端看的视图对象。价格字段在数据库里是DECIMAL,Java对应BigDecimal,VO里可以再加工成字符串“¥99.00”,实体类不应该掺和展示逻辑。
3. 商品与购物车:核心数据表设计和接口实现
3.1 商品表、SKU表、购物车表的设计要点
商城的数据表设计,第一张表是商品表goods,第二张是库存表sku,第三张是购物车表cart。小项目里商品和SKU会合并成一张表,字段里加一个规格描述。但严格来说,一个商品有多个颜色、多个尺码,每个规格对应不同库存和价格时,必须拆出SKU表。看一套最简的三表结构:
CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_name VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', sort_order INT NOT NULL DEFAULT 0, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_status_sort (status, sort_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, sku_name VARCHAR(64) NOT NULL, sku_price DECIMAL(10,2) NOT NULL, sku_stock INT NOT NULL DEFAULT 0, UNIQUE KEY uk_goods_sku (goods_id, sku_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE cart ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, selected TINYINT NOT NULL DEFAULT 1 COMMENT '1选中 0未选中', UNIQUE KEY uk_user_sku (user_id, sku_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张购物车表的设计关键在uk_user_sku这个唯一键。它保证同一个用户加购同一个SKU时只有一条记录,不会出现重复行。DECIMAL(10,2)用来存价格,float和double绝不用于金额计算,这个不需要讨论。sku表里UNIQUE KEY uk_goods_sku(goods_id, sku_name)防止同一商品下出现同名规格。索引上,goods表的idx_status_sort(status, sort_order)是给商城首页的列表查询用的,WHERE status = 1 ORDER BY sort_order DESC,正好命中这个组合索引。
3.2 商品列表的分页查询:PageHelper还是手写LIMIT
商城首页的商品列表、搜索页的结果展示都需要分页。Spring Boot生态里两个常见方案:PageHelper和MyBatis-Plus的分页插件。PageHelper用起来最省事,调用PageHelper.startPage(pageNum, pageSize)后,紧接着那条查询SQL自动带上LIMIT,而且会执行一条COUNT查询拿到总数。
public PageResult<GoodsVO> pageQuery(int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); List<Goods> goodsList = goodsMapper.selectPage(pageNum, pageSize, 1); PageInfo<Goods> pageInfo = new PageInfo<>(goodsList); // 组装分页返回结构 }手写LIMIT的方案更可控,SQL直观,不依赖中间件。两者冲突时我一般这样做:简单查询用PageHelper,效率高;涉及多表JOIN或者查询很重的接口,用手写SQL配合二级缓存。PageHelper有个坑:startPage只会对紧接着的第一个查询生效,中间如果插入了别的Mapper查询,分页会作用到错误的SQL上。所以代码里startPage和查询语句之间不能隔业务逻辑。排查这类问题时看SQL日志,PageHelper会在日志里打印出带LIMIT的完整SQL,对照一下就知道有没有串页。
3.3 购物车加购的幂等处理:唯一索引与原子更新
加购接口是商城的高频操作,用户点一次加购,前端可能会因为网络超时重试两次。如果代码写成先查购物车有没有这条记录,没有就insert,有就update把数量加一,并发场景下两个请求同时查到null,会插入两条相同的记录。防止这个问题的第一道防线是表上的uk_user_sku唯一索引,第二道防线是业务代码里用INSERT或UPDATE的原子写法:
public void addToCart(Long userId, Long skuId, int quantity) { int updated = cartMapper.increaseQuantity(userId, skuId, quantity); if (updated == 0) { // 说明购物车里还没有这条SKU记录 Cart cart = new Cart(); cart.setUserId(userId); cart.setSkuId(skuId); cart.setQuantity(quantity); cart.setSelected(1); cartMapper.insert(cart); } }对应Mapper里的更新语句:
UPDATE cart SET quantity = quantity + #{quantity} WHERE user_id = #{userId} AND sku_id = #{skuId}increaseQuantity返回的int是受影响行数。如果等于0,说明where条件没命中,购物车里没有这条记录,才走insert分支。这个写法把“先查再写”的竞态窗口压缩到最小。UPDATE语句本身是行级原子操作,两个并发请求同时执行同一个UPDATE,MySQL的行锁会让它们串行执行,数量不会丢。insert那边如果并发撞了唯一索引,会抛DuplicateKeyException,业务层要把这个异常catch住,重新执行一次increaseQuantity,或者直接返回“已在购物车中”的提示。用ON DUPLICATE KEY UPDATE可以一条SQL解决这件事,但可读性稍差,我一般更倾向上面这种两步逻辑。
购物车还有一个常见的业务需求:勾选、取消勾选、批量删除、清空。这些操作全部围绕cart表的主键或user_id进行,不需要触碰goods和sku表。要注意的是查询购物车列表时,需要把goods_name、sku_name、商品图片、价格这些冗余信息JOIN出来,这个查询要控制在一次性查出所有字段,不要在循环里逐条查SKU,否则就是经典的N+1问题。
4. 订单流程与支付回调:状态机、库存扣减和幂等设计
4.1 订单状态机的流转设计:从待支付到已取消
订单是商城系统的核心,它连接了购物车、库存、支付和物流。状态机设计得好不好,直接决定后面写退款、售后、对账时痛不痛苦。网上商城的订单状态一般有五种:待支付、已支付、已发货、已完成、已取消。用枚举定义:
public enum OrderStatus { PENDING_PAYMENT(0, "待支付"), PAID(1, "已支付"), SHIPPED(2, "已发货"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public static boolean canChange(int from, int to) { // 只允许合法的状态迁移 return (from == PENDING_PAYMENT.code && to == PAID.code) || (from == PENDING_PAYMENT.code && to == CANCELLED.code) || (from == PAID.code && to == SHIPPED.code) || (from == SHIPPED.code && to == COMPLETED.code); } }状态不能随意跳转。待支付可以到已支付,也可以到已取消,但不能直接到已发货;已支付不能回到待支付。所有更新订单状态的SQL必须带状态条件,用乐观锁的思路防止状态错乱:
UPDATE orders SET status = #{newStatus}, pay_time = NOW() WHERE id = #{orderId} AND status = #{oldStatus}这条UPDATE是关键。如果返回0,说明订单状态已经变了,比如支付回调并发触发了两次,第二次执行时status已经不是待支付了,就不会重复更新。网上商城源码里最容易出问题的地方就在这里:直接UPDATE orders SET status = #{newStatus} WHERE id = #{orderId},不带旧状态条件,结果就是两个请求都成功,订单被覆盖成未知状态。
4.2 下单时扣库存:用UPDATE ... WHERE stock >= quantity
下单扣库存是并发问题的高发区。最简单的错误写法是先SELECT stock查出来,在Java里判断库存够不够,再UPDATE stock = stock - quantity。这个流程在并发下有明显的竞态窗口:两个请求同时查到stock=1,都判断库存充足,都执行扣减,库存变成-1。正确做法是把判断和扣减放到一条UPDATE里:
UPDATE goods SET stock = stock - #{quantity} WHERE id = #{goodsId} AND stock >= #{quantity}这条SQL的执行逻辑:MySQL对goods表的这一行加行锁,读取当前stock值,判断stock >= quantity是否成立。成立则减,不成立则不加,返回受影响行数0。Java侧的判断就简单了:
int updated = goodsMapper.deductStock(skuId, quantity); if (updated == 0) { throw new InsufficientStockException("库存不足"); }返回1表示扣减成功,返回0表示库存不足。这个方式比在Java里先查再判更可靠,因为判断和扣减在同一个数据库事务原子操作里完成。还有一个细节:goods表和sku表如果拆开了,扣减要针对sku_stock字段。另外定期对账时,要把订单明细里的sku_id和数量累加,跟实际扣减记录对比,防止程序bug导致库存漂移。
4.3 支付回调的验签与幂等:同一个通知必须只处理一次
支付回调是商城系统里最需要谨慎对待的接口。支付平台会多次发送异步通知,直到商户返回成功标识。回调接口的设计必须满足两个条件:验签和幂等。验签防止伪造回调,幂等保证同一个订单被通知多次时只处理一次。看一个回调接口的骨架:
@PostMapping("/pay/notify") public String payNotify(@RequestBody String notifyData) { // 1. 验签:用平台公钥或密钥对notifyData做签名验证 if (!payService.verifySign(notifyData)) { return "failure"; } // 2. 解析出订单号、支付金额、支付流水号 String orderNo = parseOrderNo(notifyData); String payStatus = parsePayStatus(notifyData); // 3. 幂等处理 Order order = orderMapper.selectByOrderNo(orderNo); if (order == null || order.getStatus() != OrderStatus.PENDING_PAYMENT.getCode()) { return "success"; } // 4. 校验金额是否一致 if (order.getTotalAmount().compareTo(parseAmount(notifyData)) != 0) { return "failure"; } // 5. 更新订单状态为已支付 orderMapper.updateStatus(order.getId(), OrderStatus.PAID.getCode()); return "success"; }第三步的if判断就是幂等关键。第一次回调进来,状态是待支付,更新成功。第二次、第三次回调进来,订单状态已经是已支付,直接返回success,不重复更新。这里不能反过来:先更新状态再返回,万一更新成功后返回failure,平台会一直重试,每次重试都会执行一次幂等判断,虽然不会出错但会有无谓的数据库压力。
金额校验也别省。平台回调里的支付金额要和本地订单金额严格比对,用BigDecimal的compareTo,不用equals,因为2.0和2.00在BigDecimal的equals比较下是false。回调处理完后可以考虑把完整的回调原始报文存一张日志表,方便后续排查对账问题。
5. Spring Boot商城源码的本地运行与排查技巧
5.1 用Actuator快速确认模块健康状态
拿到源码后第一件事是跑起来,跑起来之后确认各模块正常。Spring Boot Actuator是排查运行时问题最直接的工具。在pom.xml里加一个依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>然后配置暴露端点:
management: endpoints: web: exposure: include: health,info,metrics启动后访问/actuator/health,返回{"status":"UP"}就说明应用本身活着。如果数据源配置有问题,health端点会显示DOWN并附加数据库连接失败的明细。有一点要注意:生产环境不要把所有端点都暴露出去,include里只写需要的,或者直接用management.endpoints.web.exposure.include=health,info,避免把beans、env这些内部信息暴露到公网。
5.2 多环境配置:dev与prod的切换
商城项目至少要区分开发环境和生产环境。常见做法是用spring.profiles.active配合多个配置文件:
# application.yml spring: profiles: active: devapplication-dev.yml里放本地数据库连接,数据库地址是localhost,密码是本地密码,日志级别用DEBUG。application-prod.yml里放线上数据库连接,连接池参数调大,日志级别用INFO。打包发布时用命令行指定环境:
mvn clean package -DskipTests java -jar mall.jar --spring.profiles.active=prod这样切换环境不用改代码,启动参数指定即可。配置里还要注意MySQL连接串的时区参数,serverTimezone=Asia/Shanghai这个参数缺了会导致日期字段差8小时,商城订单时间如果从凌晨零点附近错到前一天,对账时核对半天。
5.3 端口占用、数据源连不上、静态资源404的排查顺序
本地启动商城源码最常见的三个报错:端口被占用、数据源连不上、静态资源404。端口占用看启动日志里的Web server failed to start. Port 8080 was already in use,解决方式是指定新端口:
java -jar mall.jar --server.port=8081数据源连不上时,看日志有没有Access denied for user或者Communications link failure。前者是用户名密码不对,后者是MySQL服务没启动或者端口不对。先用命令行测一下:mysql -u root -p,能连上再排查Java侧配置。
静态资源404要区分是Spring Boot没找到页面,还是接口路径不对。Spring Boot默认的静态资源根目录是classpath:/static/,前端页面、css、js都放这个目录。如果页面能打开但接口返回404,多半是@RestController和@Controller混用导致返回了视图名而不是JSON。
最后一招:本地复现问题时,把spring.datasource.hikari.connection-timeout调大一点,再把logging.level.com.example.mall.mapper调成DEBUG,就能看到每条SQL的完整参数。商城这种业务,日志里能看到SQL,问题就解决一半。
本文还有配套的精品资源,点击获取