Spring Boot+MyBatis-Plus商品销售管理系统源码拆解
简介网络商品销售管理系统源码包面向学习电子商务系统开发的学生与初级开发者也可为需要搭建商品维护、库存预警、订单处理与配送管理流程的商家提供参考。整套代码采用ASP.NET三层结构组织表示层页面、业务逻辑层管理器、数据访问层服务与实体模型相互分离并附SQL建库脚本和数据库访问帮助类便于理解商品查询录入、图片管理、订单整合、退款退货及销售数据分析等模块的实现思路。压缩包共55个文件以15个dll动态库、13个cs源码、12个pdb调试文件、3个aspx页面、3个csproj项目文件为主另有txt说明、sql脚本与config配置整体约88KB体量轻便适合本地解压后对照项目结构阅读。已有156人学习可作为课程设计、毕业设计或小型电商后台原型的参考资料帮助读者梳理三层架构划分与业务模块之间的调用关系。1. 从运营那张改错的价目表说起网络商品销售管理系统要收敛的是什么运营在 Excel 里把一款鞋的价格从 299 改成 199只改了其中一个 SKU第二天客服接了三十个电话。这类事故多半不是人的问题是商品维护没有约束价格、库存、上下架状态散在表格和聊天记录里谁都能改改完没有留痕。网络商品销售管理系统要做的就是把商品、分类、SKU、库存、订单、客户收进一套 B/S 后台让商品维护带上校验、状态机和操作记录改错了还能倒回去查。这套源码适合两类人想照着跑通一套完整增删改查加权限加订单闭环的初学者和手里有个小商城、需要一份能直接改的底座的小团队。下面按技术骨架、商品维护链路、跑通步骤、踩坑点、二次开发的顺序拆开讲。2. 技术选型与工程骨架Spring Boot MyBatis-Plus 单模块怎么分层拿到一份销售管理系统的源码先别急着点启动类。花十分钟看清楚它用的是什么组合、包是怎么分的、表是怎么设计的后面改代码能省掉一半的返工。这类项目常见的组合是 Spring Boot MyBatis-Plus MySQL Vue也有不少是 SSM 老版本或者 Thymeleaf 服务端渲染映射关系基本一一对应看懂了分层换哪个版本都能对上号。2.1 技术栈组合与版本约束后端用 Spring Boot 2.7.x 打底JDK 8 或 11 都能跑别硬上 17很多老依赖在 17 上会因为反射限制报错。持久层选 MyBatis-Plus 3.5.x理由很实在商品维护模块九成是单表条件分页加批量更新用 JPA 要写一堆 Specification用原生 MyBatis 要手写几十个 XMLMyBatis-Plus 的LambdaQueryWrapper能把条件拼装压到几行还能顺手拿到逻辑删除和自动填充时间。MySQL 用 8.0字符集统一utf8mb4排序规则utf8mb4_general_ci。Redis 在这类项目里通常是可选项常见做法是只干两件事缓存商品分类树、存登录 token 或 token 黑名单。别一上来就给商品列表加缓存商品改一次要失效一堆 key很容易出现「后台改了价格前台还是老价格」的问题这是新手最容易踩的坑之一。前端是 Vue 2.6 Element UI axios用 vue-cli 起工程Node 建议 14 或 16。权限部分看源码包有的是 Spring Security JWT有的是自己写的拦截器 token 表后者的可读性对二次开发更友好改动权限规则不用去翻过滤器链。下面讲的结构按「拦截器 token」这套来如果你手上是 Security 版本把拦截器那部分换成配置类就等价。2.2 目录结构与分层职责单模块分包比多模块 Maven 更适合这种体量的项目改一个字段不用在四个 pom 之间来回跳。典型结构是这样mall-admin ├── src/main/java/com/example/mall │ ├── MallApplication.java # 启动类MapperScan 在这 │ ├── common/ # Result、PageQuery、BusinessException、全局异常 │ ├── config/ # MybatisPlusConfig、WebMvcConfig、RedisConfig │ ├── interceptor/ # AuthInterceptor登录态 接口权限校验 │ ├── module │ │ ├── product/ # controller / service / mapper / entity / dto / vo │ │ ├── category/ │ │ ├── order/ │ │ └── sys/ # 用户、角色、菜单 │ └── util/ # 上传、Excel、树形结构工具 ├── src/main/resources │ ├── mapper/ # 复杂查询的 XML 放这里 │ ├── application.yml │ └── application-dev.yml └── sql/ # schema.sql、data.sql按业务域分包而不是按 controller/service/mapper 三层平铺好处很直接要找商品维护的代码进module/product就够了不用在三个包之间来回切。每个域内部再分 controller、service、mapper各层职责别混Controller 只做参数接收和返回体包装业务规则全部压到 ServiceMapper 层最多写复杂 SQL不要在 XML 里塞if业务判断。DTO 和 VO 分开ProductSaveDTO收前端入参ProductVO给前端出参实体Product只跟表对齐三者混用是后面字段一多就失控的根源。2.3 核心表设计商品、SKU、分类、库存流水商品维护模块能不能扛住业务八成取决于表设计。这里最关键的一条是 SPU 和 SKU 拆开同一款鞋是一个 SPU它的 42 码黑、43 码白是两条 SKU价格和库存在 SKU 上标题和主图在 SPU 上。不拆的话运营加一个尺码就得复制一条完整的商品记录改标题要改 N 遍。CREATE TABLE pms_category ( id bigint NOT NULL AUTO_INCREMENT, parent_id bigint NOT NULL DEFAULT 0 COMMENT 0 表示一级分类, name varchar(64) NOT NULL, level tinyint NOT NULL DEFAULT 1 COMMENT 层级只允许末级挂商品, sort int NOT NULL DEFAULT 0, deleted bigint NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_parent (parent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品分类; CREATE TABLE pms_product ( id bigint NOT NULL AUTO_INCREMENT, category_id bigint NOT NULL COMMENT 末级分类 ID, name varchar(128) NOT NULL, sub_title varchar(255) DEFAULT NULL, main_pic varchar(255) DEFAULT NULL COMMENT 存相对路径别存绝对路径, price decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 展示价取 SKU 最低价, publish_status tinyint NOT NULL DEFAULT 0 COMMENT 0 下架 1 上架, deleted bigint NOT NULL DEFAULT 0 COMMENT 0 未删非 0 为删除时间戳, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品 SPU; CREATE TABLE pms_sku ( id bigint NOT NULL AUTO_INCREMENT, product_id bigint NOT NULL, sku_code varchar(64) NOT NULL COMMENT 全局唯一导入时用它去重, specs varchar(255) DEFAULT NULL COMMENT 颜色:黑;尺码:42, price decimal(10,2) NOT NULL, stock int NOT NULL DEFAULT 0, lock_stock int NOT NULL DEFAULT 0 COMMENT 下单未支付锁定量, version int NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_sku_code (sku_code), KEY idx_product (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品 SKU; CREATE TABLE pms_stock_record ( id bigint NOT NULL AUTO_INCREMENT, sku_id bigint NOT NULL, change_num int NOT NULL COMMENT 正数入库负数出库, biz_type varchar(32) NOT NULL COMMENT INIT/ORDER/DELIVER/REFUND, biz_no varchar(64) DEFAULT NULL COMMENT 业务单号便于对账, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_sku (sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水;几个字段值得单独说。价格一律decimal(10,2)别用double和图省事0.1 0.2这种精度问题在结算页会变成实打实的一分钱对不上。deleted用bigint存时间戳而不是tinyint存 0/1是为了绕开唯一索引冲突后面避坑那章会细说。库存流水表看着多余但真出问题时它是唯一能查的「后悔药」——某天库存对不上翻流水比翻日志快得多。3. 商品维护模块拆解一个 SPU 从新增到上架的完整链路商品维护是这套系统里改得最频繁的模块也是二次开发时第一个要动的地方。这一段按「实体映射 → 新增校验 → 分页查询 → 上下架状态机 → 批量导入」走一遍每段都给出可以直接抄的代码。3.1 实体映射与字段约定实体类和表对齐但注解要写对否则逻辑删除、自动填充、主键策略都会失效。Data TableName(pms_product) public class Product { TableId(type IdType.AUTO) // 自增导入历史数据时可保留原 ID private Long id; private Long categoryId; private String name; private String subTitle; private String mainPic; private BigDecimal price; private Integer publishStatus; TableLogic(value 0, delval UNIX_TIMESTAMP()) private Long deleted; // 删除时写入时间戳天然不冲突 TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }TableLogic的delval直接写UNIX_TIMESTAMP()MyBatis-Plus 会把它当原生 SQL 拼进 update 语句删一次写一个秒级时间戳同一个名字反复删也不会撞唯一索引。自动填充需要在config下注册一个MetaObjectHandler在insertFill和updateFill里分别塞LocalDateTime.now()忘了注册的话create_time永远是 null后面按时间排序全乱。3.2 新增与编辑的校验把脏数据挡在 Service 层DTO 上的NotBlank、Min只能拦住格式错误业务规则必须落在 Service因为唯一性、分类层级这些都要查库。Service RequiredArgsConstructor public class ProductServiceImpl extends ServiceImplProductMapper, Product implements ProductService { private final CategoryMapper categoryMapper; private final SkuMapper skuMapper; Override Transactional(rollbackFor Exception.class) public Long saveProduct(ProductSaveDTO dto) { // 1. 分类必须是末级避免商品挂在有子节点的分类上导致前台筛选查不到 Category category categoryMapper.selectById(dto.getCategoryId()); if (category null || category.getLevel() 2) { throw new BusinessException(请选择末级分类); } // 2. 同分类下名称不许重复并发靠唯一索引兜底见第 5 章 Long same lambdaQuery() .eq(Product::getCategoryId, dto.getCategoryId()) .eq(Product::getName, dto.getName()) .count(); if (same 0) { throw new BusinessException(该分类下已存在同名商品); } // 3. 至少要有一条 SKU且价格大于 0 if (CollectionUtils.isEmpty(dto.getSkuList())) { throw new BusinessException(请至少添加一条 SKU); } dto.getSkuList().forEach(sku - { if (sku.getPrice() null || sku.getPrice().compareTo(BigDecimal.ZERO) 0) { throw new BusinessException(SKU 价格必须大于 0); } }); Product product BeanUtil.copyProperties(dto, Product.class); product.setPublishStatus(0); // 新建一律下架人工上架 product.setPrice(dto.getSkuList().stream() .map(SkuSaveDTO::getPrice).min(BigDecimal::compareTo).orElse(BigDecimal.ZERO)); save(product); for (SkuSaveDTO s : dto.getSkuList()) { Sku sku BeanUtil.copyProperties(s, Sku.class); sku.setProductId(product.getId()); skuMapper.insert(sku); // 初始库存走流水保证任何库存变动都能追溯到来源 if (sku.getStock() ! null sku.getStock() 0) { stockService.record(sku.getId(), sku.getStock(), INIT, null); } } return product.getId(); } }rollbackFor Exception.class一定要写。默认 Spring 只在RuntimeException和Error上回滚自定义的受检异常抛出去事务不回滚商品插进去了 SKU 没插进去就成了脏数据。方法里三次抛异常的位置也值得注意分类校验必须在写库之前SKU 循环里的校验必须在一个事务内否则前两条 SKU 已经落库第三条报错就回滚不掉了。3.3 列表查询分页、多条件与分类树过滤后台列表的诉求通常是「按名字搜、按分类筛、按状态筛还要带分页」分类筛选得把子分类一起带上不然选一级分类查不到东西。Override public IPageProductVO pageQuery(ProductQuery query) { ListLong categoryIds categoryService.selfAndChildrenIds(query.getCategoryId()); LambdaQueryWrapperProduct wrapper Wrappers.ProductlambdaQuery() .like(StringUtils.hasText(query.getName()), Product::getName, query.getName()) .in(CollectionUtils.isNotEmpty(categoryIds), Product::getCategoryId, categoryIds) .eq(query.getPublishStatus() ! null, Product::getPublishStatus, query.getPublishStatus()) .orderByDesc(Product::getId); PageProduct page page(new Page(query.getPageNum(), query.getPageSize()), wrapper); return page.convert(p - { ProductVO vo BeanUtil.copyProperties(p, ProductVO.class); vo.setCategoryName(categoryService.nameOf(p.getCategoryId())); vo.setTotalStock(skuMapper.sumStockByProductId(p.getId())); return vo; }); }selfAndChildrenIds内部是先查全量分类再在内存里递归分类表通常几百条全量捞一次加 Redis 缓存比递归查库快得多。分页这里有个隐性依赖PaginationInnerInterceptor必须注册否则page()返回的total恒为 0前端翻页永远停在第 1 页——这个坑几乎每个用 MyBatis-Plus 的人都翻过。3.4 上下架状态机与库存扣减写法上下架不是简单改个字段上架前得确认主图有、有货、价格合法否则前台会出现「点进去 404」或者「下单提示无库存」。Override public void publish(Long id, Integer status) { Product product getById(id); if (product null) { throw new BusinessException(商品不存在); } if (Objects.equals(status, product.getPublishStatus())) { return; // 幂等重复点击直接返回 } if (status 1) { if (!StringUtils.hasText(product.getMainPic())) { throw new BusinessException(缺少主图不能上架); } Long sellable skuMapper.selectCount(Wrappers.SkulambdaQuery() .eq(Sku::getProductId, id).gt(Sku::getStock, 0)); if (sellable 0) { throw new BusinessException(没有可售库存的 SKU不能上架); } } // 带原状态做条件防止两个运营同时操作导致状态互相覆盖 boolean ok lambdaUpdate() .eq(Product::getId, id) .eq(Product::getPublishStatus, product.getPublishStatus()) .set(Product::getPublishStatus, status) .update(); if (!ok) { throw new BusinessException(状态已被他人修改请刷新后重试); } }扣库存的写法比select ... for update更常用直接靠 SQL 的条件更新做 CASUpdate(update pms_sku set stock stock - #{num}, version version 1 where id #{skuId} and stock #{num}) int deductStock(Param(skuId) Long skuId, Param(num) Integer num);返回 0 就说明库存不够或者被别人抢先扣了Service 里直接抛异常让事务回滚。这种写法不用持行锁等待并发下单时吞吐比悲观锁高一截代价是扣减失败要由上层重试或者提示用户。要改成本地消息表配合重试也容易deductStock的入参不变。3.5 批量导入按行独立事务运营最爱的功能是「给我一个 Excel 我一次传两千条」。整批一个事务的话第 1999 行报错前 1998 行全白干所以得按行提交。public ImportResult importProducts(MultipartFile file) throws IOException { ListProductImportRow rows EasyExcel.read(file.getInputStream()) .head(ProductImportRow.class).sheet().doReadSync(); ListString errors new ArrayList(); int success 0; for (int i 0; i rows.size(); i) { try { // 必须走代理对象this.saveProduct 会让 Transactional 失效 self.saveProduct(rows.get(i).toDto()); success; } catch (Exception e) { errors.add(第 (i 2) 行 e.getMessage()); // 2 是表头偏移 } } return new ImportResult(success, errors); }self是从容器里注入的自身代理Lazy注入或者AopContext.currentProxy()这点在批量导入里是硬性要求。用this调用的话被调用方法上的Transactional完全不生效等于整批共用一个外层事务一行失败全批回滚而且日志里看不出任何异常提示排查起来非常费劲。4. 本地跑通建库脚本、配置文件与启动后的冒烟验证代码看明白之后就该让它转起来。这段按顺序给命令照着敲能在一个新环境里从零跑到后台登录。4.1 建库与初始化数据# 1. 建库字符集必须 utf8mb4否则商品名里的生僻字和 emoji 会变成问号 mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS mall_admin \ DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 建表 灌基础数据分类、admin 账号、角色菜单 mysql -uroot -p mall_admin sql/schema.sql mysql -uroot -p mall_admin sql/data.sql导入前先看一眼schema.sql里有没有DROP TABLE有些源码包会在开头无条件删表本地跑第二次时把你自己造的数据一并清了。这一步做完用show tables数一下表数量跟sql/目录里对不上的话多半是脚本里有多条语句被客户端截断了。4.2 配置文件数据源、Redis、上传路径、跨域server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/mall_admin?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: ${DB_PASSWORD:123456} driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 database: 0 servlet: multipart: max-file-size: 5MB max-request-size: 20MB mall: upload-path: /data/mall/upload/ # 物理路径末尾必须带斜杠 upload-url-prefix: /files/ # 对外访问前缀 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-not-delete-value: 0 logic-delete-value: UNIX_TIMESTAMP()serverTimezoneAsia/Shanghai不能省不写的话 MySQL 8 驱动会按 UTC 解析后台显示的下单时间会比实际早 8 小时运营看报表时会以为数据丢了。upload-path用绝对路径别写./upload/打成 jar 之后工作目录会变图片能上传成功但刷新就 404。密码用${DB_PASSWORD:123456}这种占位符本地方便上服务器时用环境变量覆盖不用改代码。4.3 启动与接口冒烟mvn -q clean package -DskipTests java -jar target/mall-admin-1.0.0.jar --spring.profiles.activedev启动日志里看到 Tomcat 起在 8080 之后先别急着开前端。用两条命令验证后端是通的# 登录拿 tokenjq 只是为了截字段没有可以手动复制 TOKEN$(curl -s -X POST http://127.0.0.1:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:123456} | jq -r .data.token) # 带 token 查商品分页返回 code200 且 total 有值就算通 curl -s http://127.0.0.1:8080/api/product/page?pageNum1pageSize10 \ -H Authorization: $TOKENtotal是 0 而records是空数组先去看分页插件有没有注册接口直接返 401看拦截器里 token 是从 header 还是从 cookie 取的跟前端传的方式要对上。4.4 前端联调与静态资源映射前端装完依赖npm run serve改vue.config.js的代理别写死 IP不然换台机器就得改一遍module.exports { devServer: { port: 9528, proxy: { /api: { target: http://127.0.0.1:8080, changeOrigin: true } } } }图片访问要靠后端做静态资源映射否则上传成功了但img全是红叉Configuration public class WebMvcConfig implements WebMvcConfigurer { Value(${mall.upload-path}) private String uploadPath; Value(${mall.upload-url-prefix}) private String prefix; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(prefix **) .addResourceLocations(file: uploadPath); } }file:后面跟的必须是目录且以斜杠结尾写成file:/data/mall/upload少一个斜杠Spring 会把它当成文件路径结果就是所有图片 404。5. 避坑与排查商品维护链路上最容易翻车的 5 个点这些坑基本都出在「代码看着没问题但结果不对」的地方按现象、原因、解决三段记出问题时对着查。现象一分页查出来 total 永远是 0前端翻页一直停在第 1 页。原因是没有注册PaginationInnerInterceptorMyBatis-Plus 不会自动帮你拼count语句SQL 里没有 limit 也不会有总数。解决办法是在配置类里显式加上Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; }顺带提一句Page参数页码从 1 开始前端如果传 0 会查出空列表这种问题在联调时最容易被当成「接口没数据」。现象二同一个分类下删掉一个商品再新建同名商品报唯一索引冲突。原因是唯一索引建在了(category_id, name)上而逻辑删除只把deleted从 0 改成 1被删的那行还在表里索引照样算数。解决有两种一是把deleted改成bigint存时间戳并加进唯一索引(category_id, name, deleted)删两次时间戳不同就不冲突二是不建唯一索引靠 Service 的 count 校验兜底但要接受并发下可能出现的重复数据。这类项目里我一般选第一种改表比改并发逻辑便宜。现象三后台改完价格前台列表还是老价格要等好几分钟才变。原因是给商品列表加了 Redis 缓存但更新商品时只delete了详情 key忘了清列表 key或者列表 key 里带分页参数导致根本清不干净。解决办法是商品这类会频繁变更的数据干脆不加缓存或者按category_id维度打标签更新时按标签批量失效。缓存这东西在后台系统里收益远没有想象中大命中率低还带来一致性问题属于典型的「加了后悔不加也没事」。现象四批量导入两千条商品前面成功后面全部回滚日志里一条报错都没有。原因是循环里用this.saveProduct(...)调用Transactional走的是类内调用不经过代理事务注解直接失效所有行共用外层事务。解决是把自身代理注入进来Lazy private ProductService self;或者用AopContext.currentProxy()改完再跑一遍错误行会单独回滚并出现在errors列表里。这个问题不看源码几乎发现不了属于最难查的一类问题。现象五商品上架成功但前台下单提示「库存不足」后台显示还有货。原因是库存扣减只扣了stock没扣lock_stock或者下单锁库存和支付扣库存写了两套逻辑其中一套漏了。解决办法是把所有库存变动收敛到一个StockService任何地方改库存都走它同时写pms_stock_record流水。库存对不上时直接按sku_id把流水加起来和当前库存比差多少、哪一步少的一查就清楚比翻应用日志快得多。6. 二次开发把商品维护改成贴合自己业务的字段与规则真正落地时很少有人能原封不动用一套现成的商品维护。常见的改造是加字段、加规格维度、加上架审核。加字段这件事有个顺序别搞反先改表再改实体再改 DTO/VO最后改前端表单和列表列反着来会一路报错。改造点要动的文件容易忽略的地方加品牌、产地、保质期schema.sql、Product、ProductSaveDTO、ProductVO、前端表单VO 忘了加字段前端拿不到值以为没保存加规格维度颜色/尺码变三档pms_sku.specs改成 JSON或拆pms_sku_spec表老数据specs是分号串要做一次数据迁移上架改成先审核Product加audit_statuspublish()拆成提交和审核两个方法审核通过前前台不能查出来列表 SQL 要加条件接外部 ERP 同步商品新增ProductSyncService走定时任务或消息同步要按sku_code幂等重复推送不能插重加规格维度我踩过一次坑直接给specs字段塞 JSON前端渲染没问题但分类筛选那块的 SQL 里用了like %红%结果「红色」和「红棕色」互相污染。后来改成拆表pms_sku_spec(sku_id, spec_key, spec_value)筛选走exists子查询才干净。改造前评估一下查询场景纯展示的话 JSON 够用要做组合筛选就得拆表这个决定后面很难改。验证改造是否生效别只点页面。写完一个字段我一般走三步先用curl打一次新增接口看返回的 ID 和数据库里那行对不对再查一次分页接口确认 VO 里新字段有值最后才开前端看表单回显。页面能过但接口挂的情况太多了接口层过了前端基本不会出问题。从那以后我每次动商品表结构都强制先跑一遍「建库脚本 → 启动 → 新增一条 → 查列表 → 上架 → 下单」这条完整链路中间任何一步失败就停下来查不等攒到最后一起调。这个习惯帮我省掉了不少通宵。希望帮到你。本文还有配套的精品资源点击获取