ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

网络商品销售管理系统源码拆解:商品维护到订单闭环

网络商品销售管理系统源码拆解:商品维护到订单闭环

简介:这是一套面向电子商务开发者与计算机专业学生的网络商品销售管理系统源码,基于C#与ASP.NET技术栈构建,采用分层架构设计,适合用于课程设计、毕业设计或中小型电商项目的二次开发参考。压缩包共55个文件,约88KB,包含15个dll动态链接库、13个cs源码文件、12个pdb调试符号文件,以及aspx页面、csproj项目文件、sql脚本和config配置等,覆盖数据访问层、业务逻辑层与模型层的完整结构。系统围绕商品维护、库存控制、订单处理、配送管理与数据分析等模块展开,商品查询与录入、图片上传优化、库存阈值提醒、多支付方式集成等设计均有对应实现。已有156人学习下载,读者可借此理解分层架构的代码组织方式,掌握商品与分类管理、数据库操作封装等核心逻辑,并在此基础上扩展自己的电商功能模块。

1. 网络商品销售管理系统:从商品维护到订单闭环,一份能跑通的源码拆解

很多做后台管理系统的开发者都有过这种体验:需求文档里写着“商品维护”四个字,真动手时才发现要处理分类层级、SKU 组合、库存扣减、上下架状态机,还要跟订单模块对账。这套网络商品销售管理系统源码,核心解决的就是从商品录入到订单生成这条主链路的完整闭环。它适合正在做课程设计的学生、需要快速搭后台原型的独立开发者,以及想参考一套完整 CRUD + 业务状态流转实现的后端新手。我拿到包之后先跑了一遍主流程,又翻了几个关键模块的代码,下面把能复现的部分和踩过的坑都摊开讲。

2. 环境搭建与数据库初始化:把项目跑起来的第一公里

2.1 技术栈确认与依赖安装

这套系统常见的技术组合是后端 Spring Boot + MyBatis-Plus,前端 Vue + Element UI,数据库 MySQL 8.0。拿到源码包后别急着改代码,先把依赖版本对齐。我一般会先看pom.xml和package.json里的版本号,确认 JDK 用 1.8 还是 11,Node 用 14 还是 16。版本不对齐是新手翻车最多的地方,比如 MyBatis-Plus 3.4 和 3.5 在分页插件配置上就有差异。

后端依赖安装:

# 进入后端目录,清理并下载依赖 cd backend mvn clean install -DskipTests # 如果下载慢,在 settings.xml 里配国内镜像源 # 注意:不要用公司内网仓库地址,用公共镜像即可

前端依赖安装:

# 进入前端目录 cd frontend # 安装依赖,建议用 npm ci 而不是 npm install # npm ci 严格按照 package-lock.json 安装,避免版本漂移 npm ci # 启动开发服务器 npm run serve

这里解释一下为什么推荐npm ci:npm install会尝试更新 lock 文件里的版本,多人协作时容易出现“我这里能跑你那里报错”的玄学问题。npm ci直接按锁定版本安装,适合首次复现。

2.2 数据库建表与初始数据导入

数据库初始化是第二个容易卡住的点。源码包里一般会带一个sql目录,里面通常有两个文件:schema.sql建表语句和data.sql初始数据。常见做法是先建库再导表:

-- 创建数据库,字符集用 utf8mb4 CREATE DATABASE mall_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 切换到该库 USE mall_db; -- 导入建表脚本(在命令行执行) -- mysql -u root -p mall_db < sql/schema.sql -- mysql -u root -p mall_db < sql/data.sql

导入完成后,重点检查三张表:product(商品主表)、product_category(分类表)、product_sku(规格表)。这三张表的关联字段是category_id和product_id,如果初始数据里分类 ID 对不上,商品列表页会显示空白。我遇到过data.sql里分类数据插入了但商品数据的外键指向了不存在的分类,页面不报错但查不出数据,排查了半天。

提示:导入前先确认 MySQL 的sql_mode是否包含STRICT_TRANS_TABLES,严格模式下日期字段为0000-00-00会直接报错,而有些旧脚本里确实写了这种默认值。

2.3 配置文件修改与启动验证

后端配置文件通常是application.yml或application-dev.yml,需要改的地方就三处:数据库连接、端口、文件上传路径。

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/mall_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver # 文件上传目录,商品图片会存到这里 file: upload-path: /data/upload/

serverTimezone必须配,不配的话 MySQL 8 驱动会报时区错误。upload-path指向的目录要提前建好并给写权限,否则上传商品图时会抛FileNotFoundException,但前端可能只提示“上传失败”,看不到具体原因。

启动顺序:先起后端mvn spring-boot:run,看到Started Application in x seconds再起前端。前端vue.config.js里一般配了代理,把/api转发到localhost:8080,如果后端没起就访问前端,接口全部 502。

3. 商品维护模块拆解:分类、SKU 与上下架状态机

3.1 商品分类的树形结构与递归查询

商品分类是典型的树形结构,表里用parent_id自关联。查全量分类树时,常见做法有两种:递归查数据库,或者一次查出所有节点在内存里组装。这套源码用的是后者,因为分类数据量小,一次查完更省连接。

// 查询所有分类,在内存中组装成树 public List<CategoryVO> listCategoryTree() { // 一次查出所有分类,避免递归查库 List<Category> all = categoryMapper.selectList(null); // 按 parentId 分组 Map<Long, List<CategoryVO>> childrenMap = all.stream() .map(this::convertToVO) .collect(Collectors.groupingBy(CategoryVO::getParentId)); // 从根节点开始递归挂载子节点 List<CategoryVO> roots = childrenMap.getOrDefault(0L, new ArrayList<>()); roots.forEach(root -> buildChildren(root, childrenMap)); return roots; } private void buildChildren(CategoryVO node, Map<Long, List<CategoryVO>> childrenMap) { List<CategoryVO> children = childrenMap.getOrDefault(node.getId(), new ArrayList<>()); node.setChildren(children); // 递归处理每一层 children.forEach(child -> buildChildren(child, childrenMap)); }

参数说明:parentId为 0 表示根节点,这是约定俗成的写法。如果初始数据里根节点的parent_id写成了 null 而不是 0,getOrDefault(0L, ...)会返回空列表,整棵树就没了。这是我在一个模拟项目里真实踩过的坑,查了半天 SQL 没问题,最后发现是数据里根节点 parent_id 为 null。

3.2 SKU 规格组合的生成与库存维护

商品有多个规格维度时,比如颜色和尺寸,SKU 是这些维度的笛卡尔积。前端传过来的是规格值数组,后端要生成所有组合。常见做法是用递归或迭代做笛卡尔积:

// 根据规格值生成 SKU 组合 public List<Sku> generateSkus(List<List<String>> specValues) { List<List<String>> result = new ArrayList<>(); // 从第一个规格开始,逐步做笛卡尔积 result.add(new ArrayList<>()); for (List<String> values : specValues) { List<List<String>> temp = new ArrayList<>(); for (List<String> existing : result) { for (String value : values) { List<String> newCombination = new ArrayList<>(existing); newCombination.add(value); temp.add(newCombination); } } result = temp; } // 转成 Sku 对象,库存默认给 0 return result.stream().map(combo -> { Sku sku = new Sku(); sku.setSpecJson(JSON.toJSONString(combo)); sku.setStock(0); return sku; }).collect(Collectors.toList()); }

逻辑说明:假设颜色有 2 个值、尺寸有 3 个值,最终生成 6 个 SKU。specJson字段存的是规格组合的 JSON 字符串,方便前端回显。库存默认给 0 而不是随便给个数字,是为了强制运营人员手动填,避免超卖。这里有个边界:如果某个规格维度为空数组,笛卡尔积结果会是空列表,前端要加校验拦截。

3.3 商品上下架的状态流转与库存扣减时机

商品状态一般有:草稿、待审核、已上架、已下架。状态流转不是随便改的,要有前置校验。比如已上架的商品不能直接删除,必须先下架。这套源码里用了一个简单的状态机:

// 商品状态枚举 public enum ProductStatus { DRAFT(0, "草稿"), PENDING(1, "待审核"), ON_SALE(2, "已上架"), OFF_SALE(3, "已下架"); private final int code; private final String desc; // 构造方法和 getter 省略 } // 上架操作,只有待审核或已下架状态才能上架 public void onSale(Long productId) { Product product = productMapper.selectById(productId); if (product == null) { throw new BizException("商品不存在"); } // 状态校验 if (product.getStatus() != ProductStatus.PENDING.getCode() && product.getStatus() != ProductStatus.OFF_SALE.getCode()) { throw new BizException("当前状态不允许上架"); } // 校验库存,所有 SKU 库存不能全为 0 Long totalStock = skuMapper.sumStockByProductId(productId); if (totalStock == null || totalStock <= 0) { throw new BizException("库存为 0,无法上架"); } product.setStatus(ProductStatus.ON_SALE.getCode()); productMapper.updateById(product); }

库存扣减的时机是下单时而不是加购时,这点很关键。加购只锁库存不扣减,下单才真正扣。如果加购就扣库存,用户放弃支付后库存要回滚,逻辑复杂度翻倍。这套源码用的是下单扣减 + 超时回滚的方案,订单 30 分钟未支付自动取消并恢复库存。

注意:库存扣减要用update product_sku set stock = stock - #{num} where id = #{id} and stock >= #{num}这种带条件的更新,靠数据库行锁保证不超卖。先查再改在并发下一定出问题。

4. 订单模块与商品模块的联动:下单、拆单与对账

4.1 下单主流程与商品快照

订单模块和商品模块的联动点在于:下单时要生成商品快照。为什么不能直接关联商品表?因为商品信息会变,价格、名称改了之后,历史订单显示的就是新数据,对不上账。快照表order_item里冗余存商品名称、价格、规格、图片。

// 创建订单项,写入商品快照 public OrderItem createOrderItem(Sku sku, Integer quantity) { Product product = productMapper.selectById(sku.getProductId()); OrderItem item = new OrderItem(); item.setOrderId(null); // 后续回填 item.setProductId(product.getId()); item.setSkuId(sku.getId()); // 快照字段:下单那一刻的名称和价格 item.setProductName(product.getName()); item.setProductImage(product.getMainImage()); item.setSpecJson(sku.getSpecJson()); item.setPrice(sku.getPrice()); item.setQuantity(quantity); // 小计金额 item.setTotalPrice(sku.getPrice().multiply(new BigDecimal(quantity))); return item; }

参数说明:price用BigDecimal而不是double,金额计算不能用浮点数,这是血泪经验。specJson直接从 SKU 复制,保证订单里显示的规格和下单时一致。

4.2 订单拆单逻辑与多商品处理

一个订单包含多个商品时,如果这些商品属于不同店铺或不同仓库,需要拆单。这套源码是单店铺模型,拆单逻辑简化了,但保留了按商品维度拆分的接口。常见做法是:购物车结算时按店铺分组,每个店铺生成一个子订单。

// 按店铺分组拆单 public List<Order> splitOrder(List<CartItem> cartItems, Long userId) { // 按 shopId 分组 Map<Long, List<CartItem>> grouped = cartItems.stream() .collect(Collectors.groupingBy(CartItem::getShopId)); List<Order> orders = new ArrayList<>(); for (Map.Entry<Long, List<CartItem>> entry : grouped.entrySet()) { Order order = new Order(); order.setUserId(userId); order.setShopId(entry.getKey()); order.setOrderNo(generateOrderNo()); // 计算总金额 BigDecimal total = entry.getValue().stream() .map(CartItem::getTotalPrice) .reduce(BigDecimal.ZERO, BigDecimal::add); order.setTotalAmount(total); orders.add(order); } return orders; }

逻辑说明:generateOrderNo()一般用时间戳 + 用户 ID 后四位 + 随机数,保证唯一且可读。拆单后每个子订单独立支付、独立发货,但对外展示时可以聚合。如果项目不需要拆单,把shopId固定成同一个值就行。

4.3 库存回滚与订单取消的补偿机制

订单超时未支付要取消并回滚库存。这里有个坑:回滚库存时如果商品已经下架或删除,SKU 可能查不到。所以回滚逻辑要容错,查不到 SKU 就跳过,记录日志人工处理。

// 取消订单并回滚库存 @Transactional(rollbackFor = Exception.class) public void cancelOrder(Long orderId) { Order order = orderMapper.selectById(orderId); if (order == null || order.getStatus() != OrderStatus.UNPAID.getCode()) { return; } // 更新订单状态为已取消 order.setStatus(OrderStatus.CANCELLED.getCode()); orderMapper.updateById(order); // 回滚每个订单项的库存 List<OrderItem> items = orderItemMapper.selectByOrderId(orderId); for (OrderItem item : items) { Sku sku = skuMapper.selectById(item.getSkuId()); if (sku == null) { // SKU 已被删除,记录日志,不阻断流程 log.warn("回滚库存失败,SKU 不存在: {}", item.getSkuId()); continue; } // 加回库存 skuMapper.increaseStock(item.getSkuId(), item.getQuantity()); } }

@Transactional保证订单状态和库存回滚在同一个事务里,要么都成功要么都失败。increaseStock对应的 SQL 是update product_sku set stock = stock + #{num} where id = #{id},直接加不校验上限,因为回滚的是之前扣掉的量。

5. 避坑与排查:商品维护和订单联动中的五个真实问题

5.1 商品列表查不出数据但数据库有记录

现象:前端商品管理页显示空白,分页组件显示总数为 0,但直接查数据库product表有数据。

原因:MyBatis-Plus 的逻辑删除字段配置问题。表里通常有deleted字段,实体类上加了@TableLogic,如果初始数据里deleted默认值是 1 而不是 0,所有数据都被当成已删除过滤掉了。

解决:检查data.sql里deleted字段的默认值,统一改成 0。或者临时把实体类上的@TableLogic注掉验证一下,确认是这个问题再改数据。

5.2 SKU 库存扣成负数

现象:并发下单后,某个 SKU 的库存变成负数,超卖了。

原因:扣减库存的 SQL 写成了update product_sku set stock = stock - #{num} where id = #{id},没有加and stock >= #{num}条件。并发时两个线程同时查到库存为 1,都执行扣减,结果变成 -1。

解决:SQL 必须带条件更新,靠数据库行锁保证原子性。更新后判断影响行数,如果为 0 说明库存不足,抛异常回滚事务。

-- 正确的扣减写法 UPDATE product_sku SET stock = stock - #{num} WHERE id = #{id} AND stock >= #{num};

5.3 商品图片上传后访问 404

现象:上传商品主图成功,数据库里也存了路径,但前端<img>标签加载图片时 404。

原因:上传路径是服务器本地磁盘路径,比如/data/upload/xxx.jpg,但前端访问的是http://localhost:8080/upload/xxx.jpg,后端没有配静态资源映射。

解决:在后端加一个配置类,把上传目录映射到 URL 路径。

@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 把 /upload/** 映射到本地磁盘目录 registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } }

5.4 订单金额计算出现 0.01 元误差

现象:购物车多个商品总价和订单实付金额差几分钱。

原因:金额字段用了double或float,浮点数累加有精度损失。

解决:所有金额字段用BigDecimal,数据库用decimal(10,2)。Java 里BigDecimal的加减乘除要用对应方法,不要用+-*/。

5.5 商品下架后购物车仍能结算

现象:商品已下架,但用户购物车里还有,结算时居然能生成订单。

原因:下单接口没有校验商品状态,只校验了库存。

解决:创建订单项时加状态校验,商品状态不是“已上架”就抛异常,提示用户该商品已下架。

if (product.getStatus() != ProductStatus.ON_SALE.getCode()) { throw new BizException("商品 [" + product.getName() + "] 已下架"); }

6. 进阶技巧:用定时任务做库存预警与订单超时处理

跑通主流程之后,有两个进阶功能值得加上:库存预警和订单超时自动取消。这两个都用定时任务实现,Spring Boot 里加@EnableScheduling和@Scheduled就行。

库存预警的逻辑是每天凌晨扫一遍 SKU 表,把库存低于阈值的商品查出来,生成预警记录或发通知。阈值可以配在application.yml里,比如默认 10。

@Component public class StockWarnTask { @Value("${stock.warn-threshold:10}") private Integer warnThreshold; // 每天凌晨 2 点执行 @Scheduled(cron = "0 0 2 * * ?") public void checkStock() { // 查库存低于阈值的 SKU List<Sku> lowStockSkus = skuMapper.selectLowStock(warnThreshold); if (lowStockSkus.isEmpty()) { return; } // 按商品分组,生成预警信息 Map<Long, List<Sku>> grouped = lowStockSkus.stream() .collect(Collectors.groupingBy(Sku::getProductId)); grouped.forEach((productId, skus) -> { log.warn("商品 {} 有 {} 个 SKU 库存不足", productId, skus.size()); // 实际项目里这里可以发邮件或站内信 }); } }

订单超时处理是每 5 分钟扫一次未支付订单,创建时间超过 30 分钟的就取消并回滚库存。这里要注意别用select *全表扫,加时间范围条件走索引。

@Scheduled(cron = "0 */5 * * * ?") public void cancelTimeoutOrders() { // 30 分钟前创建的未支付订单 LocalDateTime deadline = LocalDateTime.now().minusMinutes(30); List<Order> timeoutOrders = orderMapper.selectTimeoutUnpaid(deadline); for (Order order : timeoutOrders) { try { orderService.cancelOrder(order.getId()); } catch (Exception e) { // 单个订单取消失败不影响其他订单 log.error("取消超时订单失败: {}", order.getId(), e); } } }

参数说明:cron = "0 */5 * * * ?"表示每 5 分钟执行一次。deadline用LocalDateTime而不是Date,避免时区问题。cancelOrder里已经有事务和库存回滚逻辑,直接复用。

验证定时任务是否生效,我一般会临时把 cron 改成每分钟执行一次,观察日志输出,确认没问题再改回去。这个习惯是从一次线上事故留下的:当时定时任务配了但没生效,原因是启动类上忘了加@EnableScheduling,订单超时取消功能形同虚设,库存一直被占用。从那以后我每次加定时任务都强制走一遍“改短周期 → 看日志 → 恢复配置”的流程。希望帮到你。

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

RELATED READING

延伸阅读

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