简介这是一套面向电子商务方向学习者与小型网店开发者的网络商品销售管理系统源码包围绕商品维护这一核心场景覆盖商品查询、录入、图片管理、库存预警、订单处理与配送跟踪等业务环节可用于课程设计、毕业设计参考或二次开发练手。压缩包共55个文件约88KB以C#源文件与编译产物为主体13个.cs实现商品、分类的模型与业务逻辑15个.dll和12个.pdb为分层程序集及其调试符号另有3个.aspx页面、3个.csproj工程文件、1个.sln解决方案与1个SQL脚本构成典型三层架构示例配合txt说明与config配置可辅助部署。已有156人学习关注。通过这套分层代码读者可以看清Models、BLL、DAL各层职责划分与DBHelper的封装方式理解商品与分类管理接口的调用链路并借助SQL脚本搭建数据库后运行调试为后续扩展订单、库存模块提供可复用的骨架。1. 商品维护没做好网络商品销售管理系统就很难真正上线去年帮一个做日用品的团队看后台运营在商品编辑页改完售价点保存页面提示成功前台商城挂的还是三天前的价格。翻了半小时才发现编辑接口只更新了商品主表真实成交价存在 SKU 表里两边各改各的。这类「网络商品销售管理系统 商品维护」的坑几乎每个自研后台都要踩一遍。商品维护看着只是增删改查实际牵着分类树、规格库存、上下架状态、前台缓存、订单快照五条线。下面按一套 Spring Boot MyBatis Vue 的前后端分离骨架把商品维护从目录结构、表设计、核心代码一直拆到怎么跑起来、怎么改新手能照抄熟手能直接看状态机和并发那块。2. 工程骨架拆解一个商品列表请求要穿过哪几层2.1 目录结构后端分四层前端按页面切这类系统常见做法是前后端分仓后端一个 Spring Boot 单体不拆微服务——商品维护的数据量撑死几十万行拆了反而给自己找麻烦。我一般把后端按controller / service / mapper / entity四层摆前端按views页面切接口统一收在api目录里。目录长这样mall-admin ├── src/main/java/com/example/mall │ ├── common # 统一返回体 R、全局异常、分页参数 │ ├── controller # 只做参数接收和结果包装不写业务 │ ├── service # 业务规则状态流转、重复校验、缓存失效 │ │ └── impl │ ├── mapper # 接口 resources/mapper/*.xml │ ├── entity # 与表一一对应的 DO │ ├── dto # 入参对象带 Valid 注解 │ └── vo # 出参对象给前端表格用 └── src/main/resources ├── mapper/productMapper.xml └── application.yml mall-admin-web ├── src/api/product.js # 把 /api/product/** 全部封在这里 ├── src/views/product │ ├── list.vue # 列表 搜索 分页 │ └── edit.vue # 新增/编辑弹窗 └── src/router约定三条Controller 里不许出现if (status 1)这种业务判断Service 层不许 importHttpServletRequestMapper XML 里不许出现${}字符串拼接。这三条守住后面加需求才不至于改一处崩三处。DTO 和 VO 分开看着啰嗦但商品表有deleted、version这类字段直接透给前端等于把内部实现暴露了。2.2 表设计商品主表、分类表、SKU 表的关系商品维护出问题八成先出在表结构上。核心就三张表分类表管展示归类商品主表管标题、主图、状态SKU 表管真实的价格和库存。没有多规格的商品SKU 表里也给它塞一条默认记录这样价格只有一处来源后面就不会出现「主表改了价前台没变」的玄学。-- 商品分类两级足够parent_id 0 表示顶级 CREATE TABLE t_category ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 分类ID, parent_id BIGINT NOT NULL DEFAULT 0 COMMENT 父分类ID0为顶级, name VARCHAR(50) NOT NULL COMMENT 分类名称, sort INT NOT NULL DEFAULT 0 COMMENT 排序值越小越靠前, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, PRIMARY KEY (id), KEY idx_parent (parent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品分类; -- 商品主表 CREATE TABLE t_product ( id BIGINT NOT NULL AUTO_INCREMENT, product_no VARCHAR(32) NOT NULL COMMENT 商品编码业务唯一, name VARCHAR(120) NOT NULL COMMENT 商品名称, category_id BIGINT NOT NULL COMMENT 所属分类, main_image VARCHAR(255) DEFAULT NULL COMMENT 主图相对路径不存完整URL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1上架 2下架 3售罄, detail TEXT COMMENT 详情富文本, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1已删除, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_product_no (product_no), KEY idx_cate_status (category_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品主表; -- 规格表单规格商品也插一条 spec默认 CREATE TABLE t_product_sku ( id BIGINT NOT NULL AUTO_INCREMENT, product_id BIGINT NOT NULL, spec VARCHAR(100) NOT NULL DEFAULT 默认 COMMENT 规格描述如 500ml/红色, price DECIMAL(10,2) NOT NULL COMMENT 成交价唯一价格来源, stock INT NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_product (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品SKU;几个字段值得单独说。status用TINYINT而不是boolean因为真实业务里商品不可能只有「上架/下架」两态草稿、售罄、待审核都得占位用 boolean 迟早要改表。version是给乐观锁留的后悔药两个运营同时点上下架时不至于后提交的直接覆盖前面的。main_image只存相对路径域名换的时候不用批量刷数据。提示utf8mb4不是可选项。商品名里一个 emoji 就能让utf8三字节的列插入报错而这个错在测试环境往往复现不了。2.3 请求链路一次分页查询经过了谁前端点「搜索」走的是GET /api/product/page?keyword保温杯status1pageNum1pageSize10。链路是axios 拦截器带上 token → 后端ProductController接收查询对象 →ProductServiceImpl补默认值页码为空给 1→ProductMapper.selectPage走 XML 动态 SQL → MySQL 用idx_cate_status索引 → 结果封装成PageResultProductVO返回。统一返回体建议长这样前端只判断一个codeData public class RT { private int code; // 0 成功非 0 为业务错误码 private String msg; private T data; public static T RT ok(T data) { RT r new R(); r.code 0; r.data data; return r; } public static T RT fail(int code, String msg) { RT r new R(); r.code code; r.msg msg; return r; } }code用 0 表示成功而不是 200是为了和 HTTP 状态码解耦参数校验失败返回 HTTP 200 code1001前端好统一弹提示真正的 401、500 交给拦截器处理。分页对象PageResult里固定三个字段list / total / pageNum前端表格组件直接吃这个结构换组件也不用改后端。3. 商品维护核心实现新增、编辑、上下架、批量操作3.1 新增商品参数校验和分类兜底校验分两层注解管格式Service 管业务。DTO 上把能靠注解解决的都解决掉别在 Service 里写一堆if (name null)Data public class ProductSaveDTO { private Long id; // 编辑时传新增为空 NotBlank(message 商品名称不能为空) Size(max 120, message 商品名称不能超过120字) private String name; NotNull(message 请选择商品分类) private Long categoryId; NotNull(message 请填写售价) DecimalMin(value 0.01, message 售价必须大于0) private BigDecimal price; Min(value 0, message 库存不能为负数) private Integer stock; private String mainImage; private String detail; private ListSkuDTO skuList; // 多规格时传单规格为空 }Controller 只加一个Validated异常交给全局处理器转成R.fail。真正值钱的是 Service 里那几行兜底Override Transactional(rollbackFor Exception.class) public Long save(ProductSaveDTO dto) { // 1. 分类必须存在且处于启用状态否则前台无处归类 Category cate categoryMapper.selectById(dto.getCategoryId()); if (cate null || cate.getStatus() ! 1) { throw new BizException(分类不存在或已停用请重新选择); } // 2. 同分类下名称不许重复编辑时排除自己 int dup productMapper.countByName(dto.getName(), dto.getCategoryId(), dto.getId()); if (dup 0) { throw new BizException(该分类下已存在同名商品); } Product p new Product(); BeanUtils.copyProperties(dto, p); if (dto.getId() null) { p.setProductNo(genProductNo()); // 形如 P202406120001 p.setStatus(0); // 新增一律进草稿不直接上架 productMapper.insert(p); } else { // 编辑只允许改草稿和下架状态上架中的商品要先进下架 productMapper.updateById(p); } skuService.saveOrUpdate(p.getId(), dto.getSkuList()); return p.getId(); }genProductNo()我一般用「前缀 日期 当日序列」序列靠 Redis 自增或者select max(product_no)加一别用 UUID——运营要在后台按编码搜东西UUID 那种一长串没人记得住。Transactional(rollbackFor Exception.class)必须显式写Spring 默认只在RuntimeException回滚抛 checked 异常时主表插进去了 SKU 没插数据就脏了。3.2 上下架把状态当状态机写别当开关写新手最容易把上下架写成update t_product set status ? where id ?。单机测试永远没问题一上线两个运营同时操作就出事A 点下架、B 点上架谁后提交算谁的前台可能挂着一个本该下架的商品。我的写法是「先校验、再带旧版本号更新」Override Transactional(rollbackFor Exception.class) public void changeStatus(Long id, Integer target) { if (!Arrays.asList(1, 2).contains(target)) { throw new BizException(非法的目标状态); } Product p productMapper.selectById(id); if (p null || p.getDeleted() 1) { throw new BizException(商品不存在); } if (p.getStatus().equals(target)) { return; // 幂等重复点击直接返回 } if (target 1) { // 上架前的硬性检查 if (StringUtils.isBlank(p.getMainImage())) { throw new BizException(商品主图为空不能上架); } int totalStock skuService.sumStock(id); if (totalStock 0) { throw new BizException(库存为0请先补货); } } // 关键where 里带上旧状态和版本号更新不到就说明被人抢先改了 int rows productMapper.updateStatus(id, target, p.getStatus(), p.getVersion()); if (rows 0) { throw new BizException(商品已被他人修改请刷新后重试); } productCache.evict(id); // 前台缓存同步失效 }对应 XML 是update idupdateStatus UPDATE t_product SET status #{target}, version version 1 WHERE id #{id} AND status #{oldStatus} AND version #{version} AND deleted 0 /update三个参数缺一不可oldStatus防状态跳跃version防并发覆盖deleted 0防把已删商品改活。rows 0时抛业务异常让前端刷新别自作聪明重试——重试会把用户的意图改掉。3.3 分页搜索动态 SQL 里三个必踩的点列表页的搜索条件通常是「关键词 分类 状态」随意组合用 MyBatis 的if拼最省事但三个细节不注意就翻车select idselectPage resultTypecom.example.mall.vo.ProductVO SELECT p.id, p.product_no, p.name, p.status, c.name AS categoryName, (SELECT MIN(s.price) FROM t_product_sku s WHERE s.product_id p.id) AS minPrice, (SELECT SUM(s.stock) FROM t_product_sku s WHERE s.product_id p.id) AS totalStock FROM t_product p LEFT JOIN t_category c ON c.id p.category_id where p.deleted 0 if testkeyword ! null and keyword ! AND (p.name LIKE CONCAT(%, #{keyword}, %) OR p.product_no LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND p.status #{status} /if if testcategoryId ! null AND p.category_id #{categoryId} /if /where ORDER BY p.update_time DESC, p.id DESC /select第一个点LIKE必须用CONCAT拼#{}写成%${keyword}%就是标准注入入口而且#{}会自动加引号前后加%也不会报错。第二个点排序一定要带第二排序键p.id DESC只按update_time排遇到批量导入时几十条记录时间戳一样翻页会出现同一条数据在两页都出现、另一条永远翻不到。第三个点价格和库存用子查询从 SKU 汇总别在主表再冗余一份——冗余字段的同步迟早会漏。where标签会自动处理开头的AND但p.deleted 0最好写在where里而不是外面否则后面加OR条件时优先级会错。3.4 前端编辑弹窗Element UI 两个老坑前端这块新增和编辑通常共用一个el-dialog坑也集中在复用上// 打开弹窗时先重置否则第二次打开还是上一次的数据 openDialog(row) { this.$nextTick(() { this.$refs.productForm.resetFields() // 注意每个 el-form-item 必须有 prop if (row) { this.form { ...row } // 浅拷贝避免直接改表格行数据 } else { this.form this.emptyForm() } }) } // 提交前把空串转 null否则后端 BigDecimal 字段收到 直接 400 buildPayload() { const p { ...this.form } if (p.price ) p.price null if (p.stock ) p.stock null return p }resetFields()只对绑定了prop的el-form-item生效少写一个prop就会出现「其他字段都清了就这个还留着」。另一个是el-input双向绑定默认给字符串空输入框提交的是而不是null后端NotNull拦不住JSON 反序列化BigDecimal时直接抛 400前端只看到一句「请求失败」。加:destroy-on-closetrue也能解决复用问题代价是每次打开重建组件弹窗多的时候会卡。4. 跑起来与排错从建库到登录后台的高频翻车点4.1 环境与配置版本对齐比代码重要这套骨架对版本不算挑剔但要对齐JDK 8 或 11、Maven 3.6、MySQL 5.7 或 8.0、Node 14 或 16。MySQL 8 要注意驱动类名换成com.mysql.cj.jdbc.Driver连接串必须带时区否则启动时报The server time zone value is unrecognized。server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/mall_admin?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 5MB # 商品主图上限别用默认的 1MB mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 下划线转驼峰省一堆 resultMap log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期把SQL打出来启动就两步# 后端先建库 mall_admin导入 schema.sql 和 data.sql再启动 mvn clean package -DskipTests java -jar target/mall-admin-1.0.0.jar --spring.profiles.activedev # 前端Node 版本不对就先 nvm use 16 cd mall-admin-web npm install npm run serve # 默认 8081通过 vue.config.js 的 proxy 转发到 8080log-impl那行开发期很有用MyBatis 会把最终执行的 SQL 和参数全打出来selectPage查不出数据时直接复制 SQL 去 Navicat 跑一遍比断点快。上线前记得关掉日志量会翻好几倍。4.2 初始化数据管理员账号和分类种子data.sql里至少要放三样东西一个管理员、两三条顶级分类、一条演示商品。密码别存明文BCrypt 或 MD5salt 都行BCrypt 更省心INSERT INTO t_admin (username, password, real_name, status) VALUES (admin, $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy, 管理员, 1); -- 上面的密文对应明文 123456登录后第一件事是改密码分类种子按两级插parent_id 0是顶级子分类指向上级 id。演示商品的status给 1上架保证第一次进前台能看到东西不然还得自己手建容易在第一步就放弃。注意schema.sql里别写DROP DATABASE脚本一旦在别人的机器上重跑数据全没。要重建就单独放reset.sql靠人工执行。4.3 五个高频问题排查清单现象一保存提示成功前台价格没变。原因是价格存了两处编辑接口只更新了主表的展示价。解决把 SKU 当唯一价格源主表不再保留价格字段保存成功后发一条商品变更事件把详情页缓存evict掉。现象二翻到第二页出现重复数据还有商品永远翻不到。原因是ORDER BY update_time存在大量相同值MySQL 分页顺序不稳定。解决排序补上唯一键ORDER BY p.update_time DESC, p.id DESC代价是update_time上要有索引。现象三编辑弹窗第二次打开还是上一条商品的内容。原因是el-dialog复用了组件实例表单没重置或者某个el-form-item漏了prop导致resetFields跳过它。解决逐个检查prop并在openDialog里$nextTick后调resetFields()。现象四中文商品名搜不出来英文能搜到。原因是连接串缺characterEncodingutf8或者库表建成了utf8三字节。解决库、表、连接串全部统一utf8mb4建表后执行SHOW CREATE TABLE确认一遍。现象五上传主图成功刷新页面 404。原因是图片写进了 jar 包内目录或系统临时目录重启就没了。解决配一个外部目录file.upload-path/data/upload/再用WebMvcConfigurer把/upload/**映射过去数据库只存相对路径。现象六并发点上下架商品状态和操作记录对不上。原因是update ... set status没有条件保护。解决按 3.2 的写法带oldStatus和version更新行数为 0 就抛异常让用户刷新同时把操作日志写进同一个事务。5. 二次开发从单规格到多规格的三步改造接手这类系统第一件事通常是加 SKU。改造顺序建议是先在t_product_sku上加spec_json存规格组合如{容量:500ml,颜色:白}再在商品编辑页加一个规格矩阵表格最后改前台详情页按规格切换价格和库存。别一上来就动主表结构商品主表加字段会牵连列表、搜索、导出所有查询。第二个常见需求是操作日志。用 AOP 切Log注解把操作人、商品 id、变更前后的状态记到t_product_log出了问题能查是谁在什么时候把商品下架的——没有这张表线上纠纷只能靠猜。第三个是订单解耦。商品名和价格在下单那一刻就要快照进订单明细别在订单表里只存product_id然后实时 join。商品改名、改价、下架之后历史订单还得显示当时的样子这是电商后台的底线。验证方法我一般固定跑三条改完状态机先跑单元测试覆盖「草稿→上架」「上架→下架」「下架→上架」和「非法状态跳转」四种再用两个浏览器窗口同时点同一个商品的上下架看是否有一个被拦下来最后把商品下架后刷前台详情页确认缓存真的失效了而不是等十分钟自己过期。血泪经验是我最早做这类系统时图省事把status直接做成is_on_sale布尔字段后来业务要加「售罄」和「待审核」整个链路从 SQL 到前端枚举全改了一遍改了两天。从那以后我每接一个商品模块动第一行代码前都强制把状态流转画在纸上标清楚谁能从哪个状态跳到哪个状态、跳之前要校验什么再动手建表。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?