网络商品销售管理系统商品维护实战:表设计、状态机与并发控制

发布时间:2026/10/27 2:08:07
网络商品销售管理系统商品维护实战:表设计、状态机与并发控制

简介:这是一套面向电子商务方向学习者与小型网店开发者的网络商品销售管理系统源码包,围绕商品维护这一核心场景,覆盖商品查询、录入、图片管理、库存预警、订单处理与配送跟踪等业务环节,可用于课程设计、毕业设计参考或二次开发练手。压缩包共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 层不许 importHttpServletRequest;Mapper 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 '父分类ID,0为顶级', 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU';

几个字段值得单独说。status用TINYINT而不是boolean,因为真实业务里商品不可能只有「上架/下架」两态,草稿、售罄、待审核都得占位,用 boolean 迟早要改表。version是给乐观锁留的后悔药,两个运营同时点上下架时不至于后提交的直接覆盖前面的。main_image只存相对路径,域名换的时候不用批量刷数据。

提示:utf8mb4不是可选项。商品名里一个 emoji 就能让utf8三字节的列插入报错,而这个错在测试环境往往复现不了。

2.3 请求链路:一次分页查询经过了谁

前端点「搜索」,走的是GET /api/product/page?keyword=保温杯&status=1&pageNum=1&pageSize=10。链路是:axios 拦截器带上 token → 后端ProductController接收查询对象 →ProductServiceImpl补默认值(页码为空给 1)→ProductMapper.selectPage走 XML 动态 SQL → MySQL 用idx_cate_status索引 → 结果封装成PageResult<ProductVO>返回。统一返回体建议长这样,前端只判断一个code:

@Data public class R<T> { private int code; // 0 成功,非 0 为业务错误码 private String msg; private T data; public static <T> R<T> ok(T data) { R<T> r = new R<>(); r.code = 0; r.data = data; return r; } public static <T> R<T> fail(int code, String msg) { R<T> r = new R<>(); r.code = code; r.msg = msg; return r; } }

code用 0 表示成功而不是 200,是为了和 HTTP 状态码解耦:参数校验失败返回 HTTP 200 +code=1001,前端好统一弹提示;真正的 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 List<SkuDTO> 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 id="updateStatus"> 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 id="selectPage" resultType="com.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 test="keyword != null and keyword != ''"> AND (p.name LIKE CONCAT('%', #{keyword}, '%') OR p.product_no LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND p.status = #{status} </if> <if test="categoryId != 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-close="true"也能解决复用问题,代价是每次打开重建组件,弹窗多的时候会卡。

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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true 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.active=dev # 前端:Node 版本不对就先 nvm use 16 cd mall-admin-web npm install npm run serve # 默认 8081,通过 vue.config.js 的 proxy 转发到 8080

log-impl那行开发期很有用,MyBatis 会把最终执行的 SQL 和参数全打出来,selectPage查不出数据时直接复制 SQL 去 Navicat 跑一遍,比断点快。上线前记得关掉,日志量会翻好几倍。

4.2 初始化数据:管理员账号和分类种子

data.sql里至少要放三样东西:一个管理员、两三条顶级分类、一条演示商品。密码别存明文,BCrypt 或 MD5+salt 都行,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()。

现象四:中文商品名搜不出来,英文能搜到。原因是连接串缺characterEncoding=utf8,或者库表建成了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 到前端枚举全改了一遍,改了两天。从那以后我每接一个商品模块,动第一行代码前都强制把状态流转画在纸上,标清楚谁能从哪个状态跳到哪个状态、跳之前要校验什么,再动手建表。希望帮到你。

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