
简介这是一套基于Java与Spring Boot框架开发的网上商城购物系统完整源码面向计算机专业学生、Java后端学习者以及需要快速搭建电商基础模块的开发者。系统覆盖商品管理、购物车管理、订单管理、用户地址管理和在线客服等电商核心业务购物车、客服与地址模块均支持增删改查、分页查询、条件筛选和排序并提供按指定列与时间范围统计记录数量的提醒接口便于后续二次扩展。资源包为zip压缩格式共831个文件约19.21MB代码以java后端逻辑、vue前端组件、js脚本为主辅以svg、gif、jpg等视觉素材同时包含sql数据库脚本、html页面、Maven构建配置及一键安装运行命令目录结构清晰适合本地部署和按模块研读。已有36人浏览学习可帮助理解Spring Boot配合Vue的前后端分离项目分层设计以及网上商城系统中购物车、订单、地址管理等模块的常见实现思路。1. 网上商城购物系统到底在做什么Spring Boot 在这里解决什么问题拿到一个基于Java和Spring Boot框架的网上商城购物系统源码包时先别急着解压跑起来。这类系统看起来是商品展示加下单真正考核工程能力的部分其实在订单链路库存怎么扣、订单状态怎么转、支付回调怎么对账。页面只是壳壳后面的数据一致性才是这套代码值得看的地方。对刚入行的 Java 开发者来说网上商城购物系统是典型的全流程业务练习用户注册登录、商品分类浏览、购物车合并、提交订单、模拟支付、后台发货。它比纯 CRUD 多了一点业务规则又比分布式电商少了很多中间件复杂度适合作为 Spring Boot 入门到进阶的过渡项目。对 5 年以上的熟手来说这份代码的看点在于事务边界、锁的使用、状态机的设计是否经得起并发推敲。我习惯把这类系统拆成用户端交易链路和管理端数据维护两条线来看。前者关心性能和一致性后者关心操作的便捷和数据的正确。下面按这条思路把常见落地路径讲清楚所有代码都是这个场景下最常用的写法可以直接对照你自己的工程改造。2. 拆开源码包网上商城购物系统的工程目录与数据模型设计2.1 先确认 Spring Boot 四层架构是否完整典型的商城源码包解压后Maven 工程下src/main/java里会按controller / service / mapper / entity四层组织对应 Spring Boot 四层架构的标准分工。entity放数据库表映射对象mapper只负责 SQL 和数据访问service层处理业务规则和事务controller层只做参数接收和结果封装。src/main/java ├── controller # 接收 HTTP 请求返回 Result ├── service # 业务逻辑事务边界在这里 │ └── impl ├── mapper # MyBatis 接口XML 或注解 SQL ├── entity # 数据库表映射对象 ├── config # 拦截器、Redis、MyBatis 配置 ├── common # 统一返回体、异常处理、工具类 └── ShopApplication.java # Spring Boot 启动类拿到源码后先确认一件事service层是否真的存在还是把业务逻辑全部写在了controller里。后者在课程设计和初期练手代码里很常见能跑但后续加需求很痛苦。面试聊到Spring Boot 四层架构时能说清楚为什么业务不能放 controller 的人不多这点比背概念更能看出工程理解。2.2 建这 7 张表网上商城购物系统的数据库设计商城系统的表结构各源码差异较大但核心不会跳过下面这些表。我建议拿到包先看 SQL 脚本里有没有这些表没有就说明业务链是断的。表名作用关键字段sys_user买家和管理员账号user_id,username,password_hash,rolecategory商品分类树形结构category_id,parent_id,nameproduct商品 SPU即商品主体product_id,category_id,title,statusproduct_sku商品 SKU即具体规格和库存sku_id,product_id,spec,price,stockcart_item购物车条目cart_id,user_id,sku_id,quantityorders订单主表order_id,order_no,user_id,total_amount,statusorder_item订单明细快照商品信息item_id,order_id,sku_id,price,quantityorders和order_item分开是必须的主表管状态和金额明细表管当时买了什么。下单后商品改价、改名都不影响订单里的快照数据这是订单表设计的基本规则。2.3 商品与 SKU 拆分的 SQL 写法多规格商品比如颜色加尺码必须拆成product和product_sku两张表。简单源码里常见一张product表塞全部字段的做法那只适合没有规格的虚拟商品。CREATE TABLE product ( product_id BIGINT PRIMARY KEY AUTO_INCREMENT, category_id BIGINT NOT NULL COMMENT 分类ID, title VARCHAR(200) NOT NULL COMMENT 商品标题, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架 ) ENGINE InnoDB COMMENT 商品SPU表; CREATE TABLE product_sku ( sku_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL COMMENT 所属SPU, spec VARCHAR(100) NOT NULL COMMENT 规格描述如 黑色/M, price DECIMAL(10,2) NOT NULL COMMENT 售价, stock INT NOT NULL DEFAULT 0 COMMENT 库存, KEY idx_product (product_id) ) ENGINE InnoDB COMMENT 商品SKU表;这里的spec字段用字符串存规格描述简单直接适合大多数课程设计和内部系统。如果要支持规格筛选就要拆出spec_name和spec_value两张关联表复杂度会上一个台阶。拿源码做二次开发时先想清楚你的商品有没有规格搜索需求再决定要不要改表结构。3. 用 Spring Boot 把用户登录、购物车、下单链路跑通3.1 登录态怎么存JWT 拦截器的 Spring Boot 实现商城系统的用户登录最常用的方案是 JWT 配合拦截器。用户登录成功后签发一个 token后续请求在请求头带Authorization拦截器解析出用户 ID 放到请求属性里供后续业务使用。用 Redis 存 session 也能做但 JWT 无状态在前后端分离的场景下更常见。public class LoginInterceptor implements HandlerInterceptor { private final TokenService tokenService; public LoginInterceptor(TokenService tokenService) { this.tokenService tokenService; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; // 跨域预检请求直接放行 } String token request.getHeader(Authorization); Long userId tokenService.parseToken(token); // 解析失败返回 null if (userId null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } request.setAttribute(currentUserId, userId); return true; } }拦截器通过WebMvcConfigurer注册并指定拦截路径。常见的坑是addInterceptor时把放开路径写多或写漏导致登录页和商品列表也要求 token。我一般这样配置Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/products/**); } }TokenService里做两件事用用户的userId和过期时间生成 JWT解析时校验签名和有效期。密钥不要硬编码在代码里放到application.yml的配置项中二次开发时至少能改。3.2 购物车合并CartService 的接口设计与实现购物车的常见实现有两种存 Redis 和存数据库。存 Redis 读写快但要做持久化策略存数据库实现直观数据不丢适合这份源码的定位。我倾向直接建cart_item表理由是对小规模系统来说少引入一层缓存一致性问题。public interface CartService { void addItem(Long userId, Long skuId, Integer quantity); ListCartItemVO listCart(Long userId); void removeItem(Long userId, Long cartId); } Service public class CartServiceImpl implements CartService { private final CartItemMapper cartItemMapper; private final ProductSkuMapper productSkuMapper; public CartServiceImpl(CartItemMapper cartItemMapper, ProductSkuMapper productSkuMapper) { this.cartItemMapper cartItemMapper; this.productSkuMapper productSkuMapper; } Override public void addItem(Long userId, Long skuId, Integer quantity) { CartItem existing cartItemMapper.selectByUserAndSku(userId, skuId); if (existing ! null) { cartItemMapper.updateQuantity(existing.getCartId(), existing.getQuantity() quantity); } else { CartItem item new CartItem(); item.setUserId(userId); item.setSkuId(skuId); item.setQuantity(quantity); cartItemMapper.insert(item); } } }核心逻辑在selectByUserAndSku这一步同一个用户加同一个 SKU 时做数量累加而不是新增一条脏数据。很多源码在这里写了个全表查询再在 Java 里循环判断数据量小没问题但写法上不如在 SQL 里直接按user_id sku_id查。3.3 下单是事务的边界订单创建与库存扣减的代码顺序下单是这套系统里最容易写错的地方。正确的顺序是校验 SKU 可买数量生成订单号插入订单主表和明细表扣减库存清空购物车。这些操作必须在一个事务里完成任何一个失败都要全部回滚。Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, ListCartItemDTO items) { // 1. 幂等检查相同用户和相同商品组合的订单已存在则直接返回 String idempotentKey buildKey(userId, items); Order exists orderMapper.selectByIdempotentKey(idempotentKey); if (exists ! null) { return exists; } // 2. 计算订单总金额金额必须用数据库价格不能信前端传值 BigDecimal totalAmount BigDecimal.ZERO; ListOrderItem orderItems new ArrayList(); for (CartItemDTO item : items) { ProductSku sku productSkuMapper.selectById(item.getSkuId()); if (sku null || sku.getStock() item.getQuantity()) { throw new BusinessException(商品库存不足: item.getSkuId()); } totalAmount totalAmount.add(sku.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); OrderItem oi new OrderItem(); oi.setSkuId(sku.getSkuId()); oi.setPrice(sku.getPrice()); oi.setQuantity(item.getQuantity()); orderItems.add(oi); } // 3. 插入订单主表和明细表 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setStatus(OrderStatus.CREATED.getCode()); orderMapper.insert(order); for (OrderItem oi : orderItems) { oi.setOrderId(order.getOrderId()); orderItemMapper.insert(oi); } // 4. 扣库存、清购物车 for (CartItemDTO item : items) { productSkuMapper.deductStock(item.getSkuId(), item.getQuantity()); cartItemMapper.deleteByUserAndSku(userId, item.getSkuId()); } return order; }这里有三个容易踩的细节。第一Transactional默认只回滚RuntimeExceptionrollbackFor Exception.class要把受检异常也加进去。第二金额一定以数据库价格为准前端传价格过来直接忽略。第三订单号不要在 Java 里用UUID.randomUUID()生成长串可读性差排序也乱常见做法是yyyyMMddHHmmss 用户ID后四位 随机四位。单机部署下这个写法够用但并发抢购时sku.getStock()的判断和后面的deductStock不是原子的会出现两个请求都读到库存剩余 1 件然后同时下单成功的情况。防超卖的解法在下一章讲。4. 管理后台落地商品维护、库存扣减与订单状态机的 Spring Boot 实现4.1 管理后台的商品 SPU 与 SKU 维护管理端页面一般只做三件事商品上下架、SKU 价格库存维护、订单发货。商品上下架改的是product.statusSKU 维护改的是product_sku表。列表页用分页查询搜索条件通常是标题模糊匹配和分类筛选。RestController RequestMapping(/api/admin/products) public class AdminProductController { private final ProductService productService; public AdminProductController(ProductService productService) { this.productService productService; } GetMapping public PageResultProductVO page(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer size, RequestParam(required false) String keyword, RequestParam(required false) Long categoryId) { return productService.pageProducts(page, size, keyword, categoryId); } PutMapping(/{productId}/status) public ResultVoid updateStatus(PathVariable Long productId, RequestParam Integer status) { productService.updateStatus(productId, status); return Result.ok(); } }分页参数里page和size要限制最大值size超过 100 直接截断防止有人一次拉全表。keyword拼接时用 MyBatis 的bind或直接参数传入%keyword%不要用字符串拼接 SQL。操作接口路径影响表商品列表GET /api/admin/productsproductproduct_sku商品上下架PUT /api/admin/products/{id}/statusproduct.status设置库存PUT /api/admin/skus/{id}/stockproduct_sku.stock修改价格PUT /api/admin/skus/{id}/priceproduct_sku.price4.2 用乐观锁扣减库存避免网上商城购物系统超卖超卖是商城系统绕不开的问题。前面那种先查库存再判断的方式在高并发下必然出错因为两个请求可能同时读到同一个库存值。修正方式是在扣减 SQL 里加条件让数据库来保证原子性。UPDATE product_sku SET stock stock - #{count} WHERE sku_id #{skuId} AND stock #{count}SQL 执行后返回影响行数为 0 表示库存不足或 SKU 不存在。Java 侧只需要判断这个返回值public void deductStockIfAvailable(Long skuId, Integer count) { int affected productSkuMapper.deductStock(skuId, count); if (affected 0) { throw new BusinessException(库存不足); } }把stock #{count}写进 SQL配合 InnoDB 的行锁UPDATE语句本身就会对命中行加锁直到事务提交所以两个并发事务不会同时扣到同一份库存。这个写法比在 Java 层用synchronized可靠也比悲观锁SELECT ... FOR UPDATE少一次查询。需要注意的重试场景如果后面紧跟的插入订单明细失败导致事务回滚deductStock的更新也会回滚库存会恢复不会出现扣了库存没订单的脏数据。4.3 订单状态机用枚举和迁移表管住订单流转订单状态比字段本身更需要管理。常见状态有已创建、已支付、已发货、已完成、已取消、已关闭。没有状态机约束的源码通常是到处直接setStatus(3)改多了状态就乱了。合理做法是建一个枚举定义合法状态再用一张迁移表描述哪些状态能转到哪些状态。public enum OrderStatus { CREATED(1, 已创建), PAYED(2, 已支付), SHIPPED(3, 已发货), FINISHED(4, 已完成), CANCELED(-1, 已取消), CLOSED(-2, 已关闭); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } }状态迁移规则用一张表固化在代码里每次转状态前查这张表当前状态允许动作目标状态CREATED支付PAYEDCREATED取消CANCELEDPAYED发货SHIPPEDSHIPPED确认收货FINISHEDCANCELED无无private static final MapInteger, SetInteger ALLOWED_TRANSITIONS new HashMap(); static { ALLOWED_TRANSITIONS.put(OrderStatus.CREATED.getCode(), Set.of(OrderStatus.PAYED.getCode(), OrderStatus.CANCELED.getCode())); ALLOWED_TRANSITIONS.put(OrderStatus.PAYED.getCode(), Set.of(OrderStatus.SHIPPED.getCode())); ALLOWED_TRANSITIONS.put(OrderStatus.SHIPPED.getCode(), Set.of(OrderStatus.FINISHED.getCode())); } public void transition(Order order, OrderStatus target) { SetInteger allowed ALLOWED_TRANSITIONS.get(order.getStatus()); if (allowed null || !allowed.contains(target.getCode())) { throw new BusinessException(非法订单状态迁移); } orderMapper.updateStatus(order.getOrderId(), target.getCode()); }把状态的合法性约束放在 Java 层而不是数据库层是这类系统的常见取舍。完整方案还可以在数据库加CHECK约束但迁移时改约束成本高一般项目不这么做。状态修改必须走transition方法不在 Service 外面直接调updateStatus这是源码质量的分水岭。5. 本地跑通这套网上商城购物系统的最低配置与验证技巧5.1 启动前必改的三个配置解压源码后先不要急着点启动把application.yml里三项参数确认好数据库连接、Redis 连接、MyBatis 的 mapper 扫描路径。spring: datasource: url: jdbc:mysql://localhost:3306/shop_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 redis: host: localhost port: 6379 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.shop.entity management: endpoints: web: exposure: include: health,infoserverTimezone不设会导致日期字段差 8 小时。mapper-locations指向的路径要和工程里 XML 实际位置一致漏配会报Invalid bound statement。management.endpoints.web.exposure.include这里只暴露health和info不要把*直接漏出去避免 Actuator 未授权访问这类问题出现在这个项目里。5.2 按顺序启动并用 curl 验证订单链路依赖环境准备好后按数据库建库、导入 SQL、启动 Redis、启动 Spring Boot 的顺序操作。Java 环境配置好之后用 Maven 直接跑mvn spring-boot:run启动日志出现Started ShopApplication后用 curl 验证登录和商品列表跳过前端页面直接测接口定位问题更快。# 登录拿 token curl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:test,password:123456} # 带 token 查购物车 curl -H Authorization: Bearer token \ http://localhost:8080/api/cart/list接口通了再开着前端页面点能省掉一大半前后端分不清谁错的排查时间。5.3 验证超卖修复是否生效的压测方法改完库存扣减逻辑后可以用最原始的方式验证用一个 SKU 库存设为 1用ab或 JMeter 同时发 20 个下单请求看最终订单明细只有一条成功、库存为 0。没有压测工具时直接开两个终端同时 curl 下单接口也能复现问题。打开 MyBatis 的 SQL 日志确认扣减 SQL 的stock #{count}条件真的拼进去了logging: level: com.example.shop.mapper: debug日志里能看到交易事务提交前实际执行的 SQL以及每个UPDATE返回的影响行数。影响行数为 0 的请求会被拦截到业务异常里下单接口返回库存不足这就说明防超卖逻辑生效了。这一步验证完整套网上商城购物系统从数据模型到订单链路才算真正闭环。本文还有配套的精品资源点击获取