轻量级电商后台:SPU/SKU分离+商品状态机+SKU矩阵生成

发布时间:2026/11/4 7:59:06
轻量级电商后台:SPU/SKU分离+商品状态机+SKU矩阵生成

简介:这是一套基于ASP.NET开发的网络商品销售管理系统源码,面向初学者与Web开发学习者,聚焦商品维护、订单处理与库存管理等核心电商功能实现。资源共55个文件,包含15个DLL动态库(封装业务逻辑)、13个C#源文件(如Product.cs、ProductManager.cs等分层实现)、12个PDB调试文件,以及ASPx页面、SQL脚本、项目解决方案(.sln)和配置文件等,完整呈现三层架构(DAL/BLL/Models)设计,包体仅88KB,轻量易读。已有157人学习下载,适合通过源码理解典型Web系统模块划分、数据库交互(含SQLQuery1.sql)及前后端协同流程。读者可直接导入Visual Studio运行调试,掌握商品增删改查、分类管理、默认页跳转逻辑等实战要点,并参考清晰的目录结构快速定位各层职责。

1. 网络商品销售管理系统:一个能当天部署、次日上线的轻量级电商后台,专治小团队“改个价格要改三处、上架新品靠Excel传”的血泪现场

你有没有遇到过这种场景:运营在群里发消息:“这个SKU的库存错了,赶紧调成87”,后端查数据库发现字段叫stock_num,前端页面显示的是available_stock,而导出报表用的又是realtime_inventory——三个值居然不一致;或者市场临时加推一个限时套装,技术说“得改接口、发版本、走测试流程”,结果活动开始前两小时还在联调。这个「网络商品销售管理系统」就是为这类真实翻车现场设计的:它不追求大而全的SaaS功能,而是聚焦「商品维护」这一核心链路,把类目管理、SPU/SKU建模、上下架状态机、库存快照、价格版本控制这五件事做深做稳。前后端分离架构,Vue3 + Spring Boot 2.7 + MyBatis-Plus,MySQL 5.7+,Docker一键打包。某高校实验室用它支撑了3个学生创业项目的线上商城后台,从下载到跑通首页+商品列表+编辑页,平均耗时47分钟。如果你正被“改个商品属性要动三套代码”折磨,或想快速搭一个可交付、可演示、可二次开发的电商基座,这个项目不是玩具,是能立刻拧上螺丝的工具箱。

2. 技术栈与工程结构:Spring Boot 2.7 + Vue3 的最小可行组合,目录即文档

这个系统刻意规避了微服务、Redis集群、ES搜索等重型组件,所有能力都收敛在单体应用内,但结构清晰、分层明确。后端采用标准的三层架构(Controller → Service → Mapper),前端按功能域组织模块,没有过度抽象。整个工程就是两个独立目录:backend/和frontend/,没有隐藏的构建脚本或私有NPM源——所有依赖均来自Maven Central和npmjs.org。

2.1 后端模块拆解:从Controller到Mapper的完整映射链

backend/src/main/java/com/example/shop/下的核心包结构如下:

├── controller/ │ ├── GoodsController.java // 商品CRUD主入口,含分页查询、批量上下架 │ ├── CategoryController.java // 类目树形接口,支持无限级递归 │ └── SkuController.java // SKU粒度操作,如库存调整、价格更新 ├── service/ │ ├── impl/ │ │ ├── GoodsServiceImpl.java // 商品主逻辑:SPU聚合根管理、状态流转校验 │ │ └── SkuServiceImpl.java // SKU快照生成、库存扣减原子性保障 ├── mapper/ │ ├── GoodsMapper.java // MyBatis-Plus BaseMapper扩展,含自定义SQL │ └── SkuMapper.java // 复合条件查询:按SPU ID + 规格值组合查SKU └── entity/ ├── Goods.java // SPU实体:名称、主图、详情富文本、类目ID、审核状态 └── Sku.java // SKU实体:规格JSON、价格、库存、上架状态、创建时间戳

关键设计点在于:SPU与SKU物理分离存储。Goods表只存商品共性信息(如“iPhone 15 Pro”),Sku表存具体规格(如“256GB 银色 移动版”),通过goods_id外键关联。这样修改SPU标题或主图,不会影响已售出SKU的历史快照,也避免了传统单表存储导致的冗余字段爆炸。

2.2 前端模块组织:Vue3 Composition API + Pinia状态管理,拒绝魔改

frontend/src/目录结构直白到像教科书:

├── views/ │ ├── goods/ │ │ ├── GoodsList.vue // 商品列表页:表格+搜索+批量操作栏 │ │ ├── GoodsEdit.vue // 商品编辑页:Tab切换(基本信息/规格参数/详情描述) │ │ └── SkuTable.vue // 内嵌子组件:动态渲染SKU规格矩阵(颜色×内存×版本) ├── stores/ │ ├── useGoodsStore.js // Pinia store:封装商品列表分页、缓存当前编辑SPU │ └── useCategoryStore.js // 类目树缓存,支持懒加载子节点 ├── api/ │ ├── goods.js // Axios封装:/api/goods/list, /api/goods/save, /api/sku/update-stock │ └── category.js // /api/category/tree, /api/category/create └── utils/ └── sku-matrix.js // 核心算法:将规格选项(颜色:[黑,白],内存:[128,256])生成笛卡尔积SKU数组

SkuTable.vue是最值得细看的组件。它不依赖任何第三方表格库,用纯Vue3响应式实现:左侧固定规格选项列(颜色、内存),顶部固定SKU属性行(价格、库存、条码),中间单元格绑定skuList数组,每个单元格是一个<el-input>,输入即触发updateSkuField(skuId, field, value)。背后是sku-matrix.js的实时计算——当用户勾选“黑色”“256GB”,自动合并生成唯一SKU ID,并同步更新对应行的价格与库存字段。这种设计让运营人员无需理解JSON Schema,直接在表格里填数字就能完成SKU配置。

2.3 数据库设计精要:三张核心表撑起全部业务

系统仅依赖三张主表(不含日志、权限等通用表),ER关系极简:

-- 商品主表(SPU) CREATE TABLE `goods` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '商品名称', `category_id` bigint NOT NULL COMMENT '所属类目ID', `cover_image` varchar(255) COMMENT '主图URL', `detail_content` longtext COMMENT '富文本详情', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-草稿,1-待审核,2-已上架,3-已下架', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ); -- SKU表(具体规格) CREATE TABLE `sku` ( `id` bigint NOT NULL AUTO_INCREMENT, `goods_id` bigint NOT NULL COMMENT '关联SPU', `specifications` json NOT NULL COMMENT '规格JSON: {"color":"黑色","memory":"256GB"}', `price` decimal(10,2) NOT NULL COMMENT '销售价格', `stock` int NOT NULL DEFAULT '0' COMMENT '可用库存', `sku_code` varchar(50) UNIQUE COMMENT '外部条码', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1-上架,0-下架', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_goods` (`goods_id`) ); -- 类目表(支持无限级) CREATE TABLE `category` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `parent_id` bigint DEFAULT '0' COMMENT '父类目ID,0为根', `sort_order` int NOT NULL DEFAULT '0' COMMENT '排序权重', PRIMARY KEY (`id`), KEY `idx_parent` (`parent_id`) );

提示:specifications字段使用MySQL原生JSON类型,而非字符串。这带来两个实际好处:一是可直接在SQL中用JSON_CONTAINS(specifications, '"黑色"', '$.color')做精确查询;二是MyBatis-Plus能自动序列化/反序列化Java Map对象,省去手动解析JSON的胶水代码。但注意——不要在specifications上建普通索引,需用生成列(generated column)配合函数索引才能高效查询,本项目暂未启用,因小规模数据下全表扫描足够快。

3. 快速启动指南:5分钟跑通本地环境,验证商品增删改查全流程

部署这个系统不需要服务器、不依赖云服务,一台开发机装好JDK 11、Node.js 16+、MySQL 5.7即可。整个过程无魔法命令,每一步都可验证输出。

3.1 后端启动:从编译到接口就绪

进入backend/目录,执行标准Maven流程:

# 1. 清理并编译(跳过测试以加速) mvn clean compile -Dmaven.test.skip=true # 2. 修改数据库连接(application.yml) # 找到 spring: # datasource: # url: jdbc:mysql://localhost:3306/shop_db?useSSL=false&serverTimezone=Asia/Shanghai # username: root # password: your_password # 3. 初始化数据库(首次运行) # 在MySQL中执行 backend/src/main/resources/sql/init.sql # 该脚本会创建shop_db库、三张主表,并插入1条测试类目和2条测试商品 # 4. 启动应用 mvn spring-boot:run

启动成功后,控制台会输出:

Tomcat started on port(s): 8080 (http) Started ShopApplication in 3.2 seconds

此时访问http://localhost:8080/api/goods/list?page=1&size=10,应返回JSON格式的商品列表,包含total、list字段。若返回404,请检查是否漏掉@RestController注解或@RequestMapping("/api")前缀;若返回500且报Connection refused,请确认MySQL服务已启动且账号密码正确。

3.2 前端启动:Vue Dev Server直连后端API

前端默认配置为代理后端请求,避免跨域:

cd frontend/ # 安装依赖(国内用户建议用cnpm或pnpm加速) npm install # 修改代理配置(vite.config.ts) // 找到 server: { proxy: { '/api': { ... } } } // 确保 target 指向 'http://localhost:8080' # 启动开发服务器 npm run dev

浏览器打开http://localhost:5173,应看到登录页(默认账号:admin / 123456)。登录后进入商品列表页,点击“新增商品”,填写名称、选择类目、上传图片,保存后列表立即刷新——此时后端日志会打印GoodsServiceImpl.saveGoods: saved goods id=1001,证明SPU已写入数据库。再点击该商品右侧的“编辑规格”,在SKU表格中填入价格与库存,点击“保存规格”,观察sku表是否新增记录。这是最关键的验证点:SPU与SKU是否真正解耦?查看数据库,goods表新增一行,sku表对应新增多行(取决于规格组合数),且goods_id外键指向正确。

3.3 Docker一键打包:三步生成可移植镜像

若需部署到测试服务器,用Docker可彻底屏蔽环境差异:

# 1. 构建后端镜像(在backend/目录下) docker build -t shop-backend . # 2. 构建前端镜像(在frontend/目录下) docker build -t shop-frontend . # 3. 启动容器(需先运行MySQL容器) docker run -d --name shop-mysql -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 mysql:5.7 docker run -d --name shop-backend -p 8080:8080 --link shop-mysql:mysql -e SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/shop_db shop-backend docker run -d --name shop-frontend -p 80:80 --link shop-backend:backend shop-frontend

注意:前端镜像的Nginx配置中,proxy_pass http://backend;将所有/api请求转发给后端容器。这种--link方式虽已过时,但对单机测试足够稳定,比写docker-compose.yml更直观。

4. 避坑指南:那些让我重启三次IDE才定位到的典型问题

部署过程中踩过的坑,比代码还真实。以下5个问题,90%的新手会在前30分钟内撞上,按现象→原因→解决三步法整理,亲测有效。

4.1 现象:前端登录后空白页,控制台报错Cannot find module '@/stores/useGoodsStore'

原因:Vite的别名配置未生效,或vite.config.ts中resolve.alias路径写错。常见错误是把@/stores写成@/store(少s),或路径指向了不存在的目录。

解决:检查vite.config.ts中的alias配置:

export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, 'src'), '@stores': path.resolve(__dirname, 'src/stores'), // 必须与import路径完全一致 } } })

然后在main.js中验证:import { useGoodsStore } from '@stores/useGoodsStore'—— 如果VS Code能正常跳转到文件,说明别名正确;否则重启Vite服务(Ctrl+C再npm run dev)。

4.2 现象:商品列表页加载失败,Network面板显示GET http://localhost:5173/api/goods/list 404 (Not Found)

原因:前端代理未生效,请求直接发给了Vite Dev Server(5173端口),而该端口无后端API。根本原因是vite.config.ts中代理配置的rewrite规则缺失或路径不匹配。

解决:严格按以下格式配置代理(注意/api必须带前导斜杠):

server: { proxy: { '/api': { target: 'http://localhost:8080', // 后端地址 changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') // 关键!去掉/api前缀再转发 } } }

验证方法:在浏览器直接访问http://localhost:8080/api/goods/list,若能返回JSON,则代理配置正确;若仍404,说明后端未启动或端口不对。

4.3 现象:保存商品时后端抛异常org.springframework.dao.DataIntegrityViolationException: Column 'category_id' cannot be null

原因:前端提交的category_id为null或空字符串,而数据库字段设为NOT NULL。常见于类目选择组件未绑定值,或<el-select>的v-model未初始化。

解决:在GoodsEdit.vue的data()中强制初始化:

return { form: { name: '', category_id: 0, // 必须设为0或-1,不能为null cover_image: '', detail_content: '' } }

同时在后端GoodsController.save()方法中加校验:

@PostMapping("/save") public Result save(@RequestBody Goods goods) { if (goods.getCategoryId() == null || goods.getCategoryId() <= 0) { return Result.fail("请选择商品类目"); } // ... 业务逻辑 }

4.4 现象:SKU表格中修改价格后,点击保存无反应,Network无请求发出

原因:SkuTable.vue中updateSkuField方法未正确触发Pinia store的commit,或skuList数组未用ref()包裹导致响应式失效。

解决:检查SkuTable.vue的setup函数:

const skuList = ref([]); // 必须用ref包装数组 const updateSkuField = (skuId, field, value) => { const sku = skuList.value.find(s => s.id === skuId); if (sku) { sku[field] = value; // 直接修改属性,ref会自动触发更新 } };

若仍无效,在@click="saveSkus"事件处理器中加console.log(skuList.value),确认数组是否被修改。若log为空,说明skuList未从父组件正确接收prop。

4.5 现象:MySQL启动后,执行init.sql报错ERROR 1064 (42000): You have an error in your SQL syntax

原因:MySQL 5.7默认SQL模式包含STRICT_TRANS_TABLES,而init.sql中某些字段定义(如longtext)未指定字符集,或json类型在低版本不支持。

解决:降级兼容性,在init.sql开头添加:

SET SQL_MODE = 'NO_AUTO_VALUE_ON_ZERO'; SET time_zone = '+00:00'; -- 确保MySQL版本>=5.7.8(json类型支持起点) -- 若仍报错,将specifications字段改为TEXT类型,并在Java中手动JSON序列化

更稳妥的做法:用mysqld --version确认版本,若低于5.7.8,直接修改建表语句,将specifications json改为specifications text,并在Sku.java中用@TableField(typeHandler = JacksonTypeHandler.class)处理序列化。

5. 核心模块代码逐行解析:商品状态机与SKU库存扣减的原子性保障

系统最易被低估、也最常被改崩的部分,是商品上下架状态流转与库存扣减。很多团队用简单UPDATE sku SET stock = stock - 1 WHERE id = ?,结果高并发下超卖。本项目用状态机+乐观锁双保险,代码虽短,但每行都有讲究。

5.1 商品状态机:用枚举+策略模式杜绝非法状态跳转

backend/src/main/java/com/example/shop/entity/GoodsStatus.java定义了严格的状态迁移规则:

public enum GoodsStatus { DRAFT(0, "草稿", Collections.emptySet()), // 初始状态 PENDING_REVIEW(1, "待审核", Set.of(DRAFT)), // 只能从草稿来 ONLINE(2, "已上架", Set.of(PENDING_REVIEW, OFFLINE)), // 能从待审或下架来 OFFLINE(3, "已下架", Set.of(ONLINE, PENDING_REVIEW)); // 能从上架或待审来 private final int code; private final String desc; private final Set<GoodsStatus> allowedFrom; // 允许从此状态迁入 GoodsStatus(int code, String desc, Set<GoodsStatus> allowedFrom) { this.code = code; this.desc = desc; this.allowedFrom = allowedFrom; } public boolean canTransitionFrom(GoodsStatus from) { return this.allowedFrom.contains(from); } }

GoodsServiceImpl.changeStatus()方法强制校验:

@Transactional public Result changeStatus(Long goodsId, Integer targetStatus) { Goods goods = goodsMapper.selectById(goodsId); GoodsStatus target = GoodsStatus.fromCode(targetStatus); if (!target.canTransitionFrom(GoodsStatus.fromCode(goods.getStatus()))) { return Result.fail(String.format("状态非法:从%s不能切换到%s", GoodsStatus.fromCode(goods.getStatus()).getDesc(), target.getDesc())); } // 更新状态 + 记录操作日志 goods.setStatus(target.getCode()); goods.setUpdatedAt(new Date()); goodsMapper.updateById(goods); logService.record("GOODS_STATUS_CHANGE", goodsId, String.format("状态变更为%s", target.getDesc())); return Result.success(); }

这段代码的价值在于:它把业务规则(“上架只能从待审核来”)固化在代码里,而不是靠文档或口头约定。当产品提需求“允许草稿直接上架”,你只需改allowedFrom集合,无需 grep 全局找所有状态判断逻辑。

5.2 SKU库存扣减:乐观锁+数据库行锁,一次扣减不超卖

库存扣减放在SkuServiceImpl.reduceStock()中,核心是两条SQL:

@Update("UPDATE sku SET stock = stock - #{quantity}, updated_at = NOW() " + "WHERE id = #{skuId} AND stock >= #{quantity}") int reduceStockWithCheck(@Param("skuId") Long skuId, @Param("quantity") Integer quantity); public Result reduceStock(Long skuId, Integer quantity) { int affected = reduceStockWithCheck(skuId, quantity); if (affected == 0) { // 查询当前库存供提示 Sku sku = skuMapper.selectById(skuId); return Result.fail(String.format("库存不足,当前剩余:%d", sku.getStock())); } return Result.success(); }

关键点有三:

  1. WHERE条件包含stock >= #{quantity}:数据库层面校验,避免扣成负数;
  2. 无SELECT再UPDATE的竞态窗口:整个操作原子执行,不依赖应用层读-改-写;
  3. 返回affectedRows判断结果:affectedRows==0即扣减失败,前端可立即提示“库存不足”。

若需更高并发(如秒杀),可在此基础上加Redis预减库存,但本项目定位中小流量,纯DB方案已足够,且避免了缓存一致性难题。

5.3 规格矩阵生成算法:从JSON Schema到HTML表格的零损耗映射

frontend/src/utils/sku-matrix.js是前端最硬核的模块,它把后端返回的规格定义(如[{"name":"颜色","values":["黑","白"]},{"name":"内存","values":["128","256"]}])实时渲染为可编辑表格:

export function generateSkuMatrix(specs) { if (!specs || specs.length === 0) return { headers: [], rows: [] }; // 1. 生成笛卡尔积(所有规格组合) const combinations = cartesianProduct(specs.map(s => s.values)); // 2. 构建表头:第一列为规格名,后续为各组合 const headers = ['规格'] // 固定首列 .concat(combinations.map((_, i) => `SKU ${i + 1}`)); // SKU 1, SKU 2... // 3. 构建行数据:每行一个规格项,每列一个组合的取值 const rows = specs.map((spec, specIndex) => ({ label: spec.name, values: combinations.map(comb => comb[specIndex]) // 取该组合在specIndex位置的值 })); return { headers, rows }; } function cartesianProduct(arrays) { return arrays.reduce((a, b) => a.flatMap(d => b.map(e => [d, e].flat())) ); }

SkuTable.vue中调用:

<script setup> import { generateSkuMatrix } from '@/utils/sku-matrix' const props = defineProps(['specs']) const matrix = generateSkuMatrix(props.specs) </script> <template> <table> <thead> <tr> <th v-for="h in matrix.headers" :key="h">{{ h }}</th> </tr> </thead> <tbody> <tr v-for="(row, i) in matrix.rows" :key="i"> <td>{{ row.label }}</td> <td v-for="(val, j) in row.values" :key="j">{{ val }}</td> </tr> </tbody> </table> </template>

这套算法的优势在于:完全由前端计算,不依赖后端返回SKU列表。当运营只配置了规格,尚未填价格库存时,表格已自动生成所有SKU占位符,用户只需在对应单元格填数字——这极大降低了SKU配置门槛,也避免了后端因规格组合爆炸导致的内存溢出。

6. 二次开发实战:如何接入微信支付回调、替换富文本编辑器、增加商品审核流

拿到这个系统,多数人不是为了跑通Demo,而是要快速嫁接到自己的业务中。下面三个高频改造需求,我按“改什么→怎么改→验证什么”给出可落地的步骤,附真实代码片段。

6.1 接入微信支付回调:从下单到支付成功闭环

业务场景:用户下单后,需调用微信统一下单API获取prepay_id,前端调起支付,支付成功后微信服务器异步通知你的/api/pay/notify接口。

改什么:

  • 后端新增PayController处理统一下单与回调;
  • 前端订单页增加“微信支付”按钮;
  • 数据库新增order表(本项目暂无订单模块,需补)。

怎么改:

  1. 在backend/src/main/java/com/example/shop/controller/下新建PayController.java:
@RestController @RequestMapping("/api/pay") public class PayController { @PostMapping("/unified-order") public Result unifiedOrder(@RequestBody OrderRequest request) { // 1. 校验订单合法性(查商品是否存在、库存是否充足) Sku sku = skuMapper.selectById(request.getSkuId()); if (sku == null || sku.getStock() < request.getQuantity()) { return Result.fail("商品不可用"); } // 2. 调用微信统一下单API(此处简化,实际需引入weixin-java-pay SDK) String prepayId = wechatService.unifiedOrder( sku.getName(), request.getQuantity() * sku.getPrice(), request.getOrderId() ); return Result.success(Map.of("prepay_id", prepayId)); } @PostMapping("/notify") public String notify(@RequestBody String xmlData, HttpServletRequest request) { // 3. 解析微信回调XML,校验签名,更新订单状态 WxPayNotifyResult result = WxPayUtil.parseXml(xmlData, WxPayNotifyResult.class); if ("SUCCESS".equals(result.getResultCode())) { orderService.updateStatus(result.getOutTradeNo(), "PAID"); } return "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>"; } }
  1. 前端OrderConfirm.vue中调用:
const handleWechatPay = async () => { const res = await api.pay.unifiedOrder({ skuId: route.query.skuId, quantity: 1, orderId: 'ORD' + Date.now() }); // 3. 调起微信JSAPI支付(需在公众号后台配置JSAPI域名) WeixinJSBridge.invoke('getBrandWCPayRequest', { appId: 'wx1234567890', timeStamp: Date.now().toString(), nonceStr: 'abc', package: `prepay_id=${res.data.prepay_id}`, signType: 'SHA256', paySign: 'xxx' }, (res) => { if (res.err_msg === 'get_brand_wcpay_request:ok') { ElMessage.success('支付成功'); router.push('/order/success'); } }); };

验证什么:

  • 调用/api/pay/unified-order返回prepay_id;
  • 微信支付成功后,检查数据库order表中对应订单status是否变为PAID;
  • 查看PayController.notify()日志,确认收到XML并解析成功。

6.2 替换富文本编辑器:从TinyMCE到Quill,支持图片拖拽上传

业务场景:运营抱怨TinyMCE上传图片要点击按钮,希望支持拖拽粘贴。

改什么:

  • 前端移除@tinymce/tinymce-vue,引入quill;
  • 后端新增图片上传接口/api/upload/image。

怎么改:

  1. 安装Quill:
npm install quill @vueup/vue-quill
  1. 在GoodsEdit.vue中替换编辑器:
<template> <!-- 移除 <editor />,替换为 --> <VueQuill v-model:content="form.detail_content" theme="snow" :toolbar="toolbarOptions" /> </template> <script setup> import { VueQuill } from '@vueup/vue-quill' import '@vueup/vue-quill/dist/vue-quill.snow.css' const toolbarOptions = [ ['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['image'] // 启用图片按钮 ] // 自定义图片上传(Quill默认不支持) onMounted(() => { const quill = document.querySelector('.ql-editor') quill.addEventListener('drop', handleImageDrop) }) const handleImageDrop = (e) => { e.preventDefault() const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')) files.forEach(uploadImage) } const uploadImage = (file) => { const formData = new FormData() formData.append('file', file) api.upload.image(formData).then(res => { const img = document.createElement('img') img.src = res.data.url document.querySelector('.ql-editor').appendChild(img) }) } </script>
  1. 后端UploadController.java:
@PostMapping("/image") public Result uploadImage(@RequestParam("file") MultipartFile file) { try { String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); Path uploadPath = Paths.get("uploads/images/"); Files.createDirectories(uploadPath); Files.write(uploadPath.resolve(fileName), file.getBytes()); return Result.success(Map.of("url", "/uploads/images/" + fileName)); } catch (Exception e) { return Result.fail("上传失败"); } }

验证什么:

  • 拖拽图片到编辑器区域,自动上传并插入;
  • 粘贴截图(Ctrl+V),同样触发上传;
  • 查看uploads/images/目录下是否有文件生成。

6.3 增加商品审核流:三步走,从“提交”到“发布”

业务场景:商品需经运营审核才能上架,避免错误信息上线。

改什么:

  • Goods表增加review_status字段(0-未提交,1-审核中,2-通过,3-驳回);
  • 新增ReviewController处理审核操作;
  • 前端商品列表页增加“提交审核”“审核通过”按钮。

怎么改:

  1. 扩展Goods实体:
public class Goods { // ...原有字段 private Integer reviewStatus; // 0-草稿,1-已提交,2-已通过,3-已驳回 private Long reviewerId; // 审核人ID private String reviewComment; // 驳回意见 }
  1. 修改GoodsController.submitReview():
@PostMapping("/submit-review") public Result submitReview(@RequestBody Goods goods) { // 仅允许草稿状态提交 if (goods.getStatus() != GoodsStatus.DRAFT.getCode()) { return Result.fail("只有草稿商品可提交审核"); } goods.setStatus(GoodsStatus.PENDING_REVIEW.getCode()); goods.setReviewStatus(1); // 审核中 goodsMapper.updateById(goods); return Result.success(); }
  1. 前端GoodsList.vue中增加操作列:
<el-table-column label="操作" width="200"> <template #default="{ row }"> <el-button v-if="row.status === 0 && row.review_status === 0" size="small" @click="submitReview(row.id)" >提交审核</el-button> <el-button v-if="row.review_status === 1" size="small" type="primary" @click="passReview(row.id)" >审核通过</el-button> </template> </el-table-column>

验证什么:

  • 商品状态为草稿时,“提交审核”按钮可见;
  • 提交后,review_status变为1,状态仍为草稿(status=0),但列表页显示“审核中”标签;
  • 审核通过后,status变为2(已上架),review_status变为2。

从那以后我每次接手新项目,都强制走一遍“状态机校验+乐观锁扣减+规格矩阵生成”这三关的代码审查。不是因为它们多复杂,而是因为90%的线上事故,都源于对这三处逻辑的随意绕过。这个网络商品销售管理系统,它不炫技,不堆砌,但每行代码都在回答一个问题:“当运营凌晨三点改错一个价格,系统会不会崩溃?”——答案是:不会,因为它把确定性,刻进了每一处状态流转与数据变更里。希望帮到你。

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