news 2026/10/31 2:51:33

电商后台商品维护:SPU/SKU、库存扣减与缓存一致性

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
电商后台商品维护:SPU/SKU、库存扣减与缓存一致性

简介:面向电子商务入门开发者与课程设计、毕业设计人群的网络商品销售管理系统源码包,围绕商品维护这一核心场景,覆盖商品查询录入、图片展示、库存预警、订单处理、配送与数据分析等业务环节,可用于理解中小型在线销售平台的功能划分与实现思路。压缩包共55个文件、约88KB,以15个dll运行库、13个cs业务与实体代码、3个aspx页面、3个csproj项目文件及sql脚本、配置文件等为主,呈现Models、BLL、DAL与Web层分离的典型三层结构,项目文件与解决方案配置齐全,便于按层阅读和二次改造。已有156人学习下载。读者可从中获得商品管理模块的查询录入逻辑、分层调用关系、数据库脚本与页面组织方式,也能参考库存提醒、订单整合和支付配送等模块的拆解方法,还可留意数据库访问封装、实体模型与业务层之间的依赖关系,据此完成商品维护等模块的扩展练习,适合用作小型电商系统原型或ASP.NET WebForms学习素材。

1. 改一个价格要动五张表:网络商品销售管理系统到底在管什么

运营在后台把一款商品的促销价从 99 改成 89,页面刷新了,列表也变了,可下单接口算出来的还是 99。查下去发现:商品主表改了、SKU 表没同步、Redis 里的详情缓存还挂着旧值、促销规则表里有一条优先级更高的记录、前端又把价格四舍五入成了整数。一个价格改出五个副本,这就是网络商品销售管理系统最典型的现场。这类系统的技术难点从来不在"增删改查",而在商品维护这条链路上——SPU 与 SKU 怎么拆、分类和规格怎么挂、上下架状态怎么流转、库存和价格以谁为准。

这套网络商品销售管理系统是一套前后端分离的电商后台,核心是商品维护域,外围挂订单、会员、库存和权限。适合三类人:想拿一套能跑通的后台做二次开发的开发者、需要交课程设计或毕业设计的同学、要给小商家搭一套自用管理后台的人。

2. 项目骨架与运行链路:从目录结构到第一屏商品列表

拿到一套后台源码,我一般不看 README 先看三样东西:pom 里的依赖版本、数据库脚本、前端路由表。这三样决定了它能不能在你机器上跑起来,以及你改的时候会不会牵一发动全身。这一章把骨架摊开,最后落到"怎么在自己电脑上跑出商品列表页"。

2.1 技术选型与模块划分

后端是 Spring Boot + MyBatis-Plus + MySQL 的组合,权限用 Spring Security + JWT,缓存用 Redis,前端 Vue 3 + Element Plus + Vite。这套组合在中小型后台里几乎是默认答案,好处是资料多、招人好招、二次开发时不用重新学一套心智模型。选 MyBatis-Plus 而不是 JPA,主要是商品列表那种"一堆可选筛选条件拼查询"的场景,用LambdaQueryWrapper拼起来比 Criteria API 直观,改起来也快。

后端按领域分模块,常见做法是 Maven 多模块:

goods-admin/ 前端工程 src/ api/ 按域拆的接口封装 views/goods/ 商品维护页面 router/ 路由 + 权限守卫 store/ pinia 状态 goods-server/ 后端父工程 goods-common/ 统一返回、异常、工具类 goods-framework/ 安全、Redis、MyBatis 配置 goods-system/ 用户、角色、菜单、字典 goods-goods/ 商品域:SPU、SKU、分类、规格、库存 goods-order/ 订单域 goods-admin-api/ 启动模块,聚合上面所有依赖

goods-goods是这套系统的重心,也是二次开发改得最多的地方。把商品域单独拆一个模块的价值在于:以后你接第三方 ERP 同步商品,或者把商品服务单独部署,边界是现成的,不用从一个大泥球里往外抠。

有一点要提前说清楚:模块拆得细,启动类的包扫描范围就得配对。@SpringBootApplication默认只扫自己所在的包和子包,跨模块的@Mapper、@Service必须靠@MapperScan和@ComponentScan显式指过去,这是新手第一次启动失败的高频原因,第 5 章会展开。

2.2 目录结构逐个看

后端goods-goods模块内部是标准的分层:

com.example.goods controller/ GoodsSpuController.java 商品主信息接口 GoodsSkuController.java SKU 接口 GoodsCategoryController.java 分类树 GoodsStockController.java 库存调整 service/ impl/GoodsSpuServiceImpl.java mapper/ GoodsSpuMapper.java GoodsSkuMapper.java domain/ GoodsSpu.java SPU 实体 GoodsSku.java SKU 实体 GoodsCategory.java dto/ GoodsSaveDTO.java 新增/编辑入参 GoodsQueryDTO.java 列表查询入参 vo/ GoodsListVO.java 列表出参 GoodsDetailVO.java 详情出参(SPU + SKU + 分类路径) convert/ GoodsConvert.java DTO/Entity/VO 转换

分层里最值得学的是 DTO / Entity / VO 三分。新人最容易图省事,Controller 直接收 Entity、直接返 Entity,跑得快,改起来要命——数据库加一个内部字段(比如del_flag、cost_price),接口返回里就跟着漏出去了。这套代码用GoodsConvert做转换,虽然多写几十行,但商品详情接口返回什么字段是显式声明的,前端联调时不会出现"这个字段哪来的"。

前端views/goods/下是spuList.vue、spuEdit.vue、categoryTree.vue、stockLog.vue四个页面。spuEdit.vue是全项目最长的单文件组件,因为它同时承载了基本信息、规格生成、SKU 表格、图片上传四块,后面第 3 章讲商品保存时会回到它。

2.3 跑起来:建库、改配置、双端启动

环境先对齐:JDK 17、MySQL 8.0、Redis 6 以上、Node 18。JDK 版本别乱升,Spring Boot 2.7 在 JDK 21 上能跑但个别反射相关的库会报InaccessibleObjectException,我第一次就栽在这。

建库脚本在goods-server/sql/下,一般拆成schema.sql和data.sql两个文件。最小验证只需要商品域这几张表:

CREATE DATABASE IF NOT EXISTS goods_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE goods_admin; -- 商品主表(SPU):一次商品创建对应一行 CREATE TABLE goods_spu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, spu_no VARCHAR(32) NOT NULL COMMENT '商品编码,业务唯一', spu_name VARCHAR(128) NOT NULL COMMENT '商品名称', category_id BIGINT NOT NULL COMMENT '末级分类ID', brand_id BIGINT DEFAULT NULL, main_pic VARCHAR(255) COMMENT '主图地址', price_low DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '最低价,列表展示用', price_high DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '最高价', total_stock INT NOT NULL DEFAULT 0 COMMENT 'SKU库存汇总,冗余字段', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1上架 2下架', del_flag TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_spu_no (spu_no), KEY idx_category (category_id), KEY idx_status (status) ) COMMENT '商品SPU表';

price_low/price_high/total_stock是三个典型的冗余字段。列表页一页 20 条,如果每行都去goods_sku里MIN(price)、SUM(stock),那就是 20 次子查询,商品多了直接拖垮列表接口。代价是 SKU 变更时必须同步回写 SPU,这段回写逻辑就落在GoodsSkuServiceImpl里,第 3 章会讲。

配置文件application-dev.yml里改三处就能起:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/goods_admin?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 redis: host: localhost port: 6379 database: 0 goods: upload: path: D:/goods-upload/ # 本地图片存储目录,部署时换成挂载盘 url-prefix: http://localhost:8080/upload/

serverTimezone不写或者写成UTC,商品创建时间会比实际早 8 小时,运营看"今天新增的商品"永远对不上。这个坑后面还会提一次。

启动顺序是 Redis → MySQL → 后端 → 前端:

# 1. 后端:在 goods-admin-api 模块下 cd goods-server/goods-admin-api mvn clean install -DskipTests mvn spring-boot:run -Dspring-boot.run.profiles=dev # 2. 前端:另开一个终端 cd goods-admin npm install --registry=https://registry.npmmirror.com npm run dev

前端vite.config.js里要确认代理指向 8080:

// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', // 后端地址,改端口时这里同步改 changeOrigin: true, }, }, }, })

后端所有接口都带/api前缀(server.servlet.context-path或者 Controller 上写死),代理的target后面不要再拼/api,否则路径会变成/api/api/goods/list直接 404。第一次跑通的标准不是"页面能打开",而是登录之后商品列表能查出数据、能点进编辑页、图片能正常显示——这三步过关,说明数据库、鉴权、静态资源映射三条链路都是通的。

3. 商品维护核心:SPU/SKU 模型与一条商品的完整生命周期

商品维护是整个系统的地基,订单、库存、促销全挂在它上面。这一章沿着"建表 → 保存 → 查询 → 上下架"走一遍,每一段都对应源码里一个具体方法。看懂了这条链路,后面的库存和订单就是自然延伸。

3.1 表结构:SPU 与 SKU 到底怎么拆

一句话区分:SPU 是"这个商品是什么"(iPhone 15),SKU 是"具体卖哪一个"(iPhone 15 256G 黑色)。用户下单、扣库存、算价格全部落在 SKU 上,SPU 只负责展示和聚合。

CREATE TABLE goods_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, spu_id BIGINT NOT NULL COMMENT '所属SPU', sku_no VARCHAR(32) NOT NULL COMMENT 'SKU编码,唯一', spec_json JSON DEFAULT NULL COMMENT '规格值,如 {"颜色":"黑色","内存":"256G"}', pic VARCHAR(255) COMMENT 'SKU主图,空则用SPU主图', price DECIMAL(10,2) NOT NULL COMMENT '售价', cost_price DECIMAL(10,2) DEFAULT NULL COMMENT '成本价,不对外返回', stock INT NOT NULL DEFAULT 0 COMMENT '可售库存', lock_stock INT NOT NULL DEFAULT 0 COMMENT '锁定库存(已下单未支付)', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', UNIQUE KEY uk_sku_no (sku_no), KEY idx_spu (spu_id) ) COMMENT '商品SKU表'; CREATE TABLE goods_category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父分类,0为根', name VARCHAR(64) NOT NULL, level TINYINT NOT NULL COMMENT '层级 1/2/3', sort INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 ) COMMENT '商品分类表';

spec_json用 JSON 类型而不是拆一张规格值关联表,是个务实的选择:好处是 SKU 列表直接就能渲染出规格字符串,不用三表 join;坏处是按规格筛选("查所有黑色的商品")得用 MySQL 的 JSON 函数,索引不好走。中小数据量下这个取舍是划算的,如果商品上了几十万,常见做法是再补一张goods_sku_spec关系表做检索,spec_json保留做展示。

stock和lock_stock分开,是为了下单未支付时先锁库存而不是直接扣。这两个字段的更新方式决定了会不会超卖,第 4 章细说。

3.2 商品新增与编辑:一个事务里干了四件事

GoodsSpuServiceImpl.saveGoods()是整个商品维护里最长的方法,一次保存要处理 SPU 主表、SKU 批量插入或更新、分类路径校验、冗余字段回写。

@Service public class GoodsSpuServiceImpl implements IGoodsSpuService { @Autowired private GoodsSpuMapper spuMapper; @Autowired private GoodsSkuMapper skuMapper; @Autowired private GoodsCategoryMapper categoryMapper; /** * 新增或编辑商品 * @param dto 前端提交的商品数据,id 为空表示新增 * @return 商品ID */ @Override @Transactional(rollbackFor = Exception.class) public Long saveGoods(GoodsSaveDTO dto) { // 1. 分类必须存在且是末级,否则前端筛选会出空节点 GoodsCategory category = categoryMapper.selectById(dto.getCategoryId()); if (category == null || category.getLevel() != 3) { throw new BizException("请选择三级分类"); } // 2. SKU 至少一条,且 skuNo 在本次提交内不能重复 List<GoodsSkuDTO> skuList = dto.getSkuList(); if (CollectionUtils.isEmpty(skuList)) { throw new BizException("至少添加一个规格"); } long distinctCount = skuList.stream() .map(GoodsSkuDTO::getSkuNo).distinct().count(); if (distinctCount != skuList.size()) { throw new BizException("SKU编码重复"); } GoodsSpu spu = GoodsConvert.INSTANCE.toSpu(dto); // 3. 冗余字段由 SKU 聚合得出,不接受前端传值 BigDecimal minPrice = skuList.stream().map(GoodsSkuDTO::getPrice) .min(BigDecimal::compareTo).orElse(BigDecimal.ZERO); BigDecimal maxPrice = skuList.stream().map(GoodsSkuDTO::getPrice) .max(BigDecimal::compareTo).orElse(BigDecimal.ZERO); int totalStock = skuList.stream() .mapToInt(s -> s.getStock() == null ? 0 : s.getStock()).sum(); spu.setPriceLow(minPrice); spu.setPriceHigh(maxPrice); spu.setTotalStock(totalStock); if (dto.getId() == null) { spu.setStatus(GoodsStatusEnum.DRAFT.getCode()); // 新增一律草稿 spuMapper.insert(spu); } else { spuMapper.updateById(spu); // 编辑时先逻辑删除被移除的SKU,避免历史订单关联不到规格 skuMapper.deleteBySpuIdExclude(spu.getId(), skuList.stream().map(GoodsSkuDTO::getId) .filter(Objects::nonNull).collect(Collectors.toList())); } // 4. 按 id 有无分别走插入和更新,保证幂等 for (GoodsSkuDTO skuDto : skuList) { GoodsSku sku = GoodsConvert.INSTANCE.toSku(skuDto); sku.setSpuId(spu.getId()); sku.setSpecJson(JSONUtil.toJsonStr(skuDto.getSpecMap())); if (sku.getId() == null) { skuMapper.insert(sku); } else { skuMapper.updateById(sku); } } return spu.getId(); } }

几个参数和逻辑点值得单独拎出来:

@Transactional(rollbackFor = Exception.class)里的rollbackFor不能省。Spring 默认只在RuntimeException和Error时回滚,如果代码里有受检异常被吞掉,SPU 插进去了 SKU 没插,商品就变成"点进去一片空白"的脏数据。

spu.setStatus(DRAFT)这条新增强制草稿,是我建议保留的。运营建商品经常是填一半保存一下,如果新增直接就是上架状态,前台立刻能搜到一个没图没价的商品,出了事还得手动下架一遍。

SKU 删除用逻辑删除而不是物理删。deleteBySpuIdExclude这条 SQL 的实际写法是:

UPDATE goods_sku SET del_flag = 1 WHERE spu_id = #{spuId} AND del_flag = 0 AND id NOT IN (<foreach 拼进来的 id 列表>)

如果列表为空会生成NOT IN ()直接语法错误,所以 Mapper 里必须用<if test="ids != null and ids.size() > 0">包一层,为空时走另一个不带NOT IN的分支。这种"列表为空导致的 SQL 拼接事故"在批量删除、批量改状态里到处都是。

3.3 分类树与规格模板:商品维护的两条辅路

分类树是典型的无限极结构,但商品只挂末级,所以这套代码直接定了三级。构建树的场景比想象中多:分类管理页要全树、商品编辑页要可选的末级、前台筛选要一级二级。每次都从数据库查全表再递归,量小的时候无所谓,量大了接口会明显变慢,常见做法是启动时或者定时刷进 Redis,改分类的时候主动失效。

public List<CategoryTreeVO> buildTree(Long parentId, List<GoodsCategory> all) { return all.stream() .filter(c -> Objects.equals(c.getParentId(), parentId)) .map(c -> { CategoryTreeVO vo = new CategoryTreeVO(); vo.setId(c.getId()); vo.setName(c.getName()); vo.setLeaf(c.getLevel() == 3); // 三级才算可选叶子 // 递归往下挂子节点,level 到 3 时天然为空 vo.setChildren(buildTree(c.getId(), all)); return vo; }) .sorted(Comparator.comparing(CategoryTreeVO::getId)) .collect(Collectors.toList()); }

递归里all是外部传进来的全量列表,不要在方法内部查库,否则 3 层树就是 1 + N + N*M 次查询。排序用id是为了稳定输出,实际项目里一般按sort字段排,这里保留了一个容易忽略的点:sort值重复时结果不稳定,两次刷新顺序不一样,前端会以为是 bug。

规格模板这块,代码里做的是goods_attr表存规格名(颜色、内存),goods_attr_value存可选值(黑、白、256G)。前端点"生成 SKU 表格"时,把选中的规格值做笛卡尔积,生成 N 行 SKU 让运营填价格和库存。这个笛卡尔积在前端做,后端只在保存时校验 SKU 数量和规格组合不重复。后端做也可以,但前端做能实时看到表格,体验好得多——这是个典型的"计算放前端、校验放后端"的分工。

3.4 上下架状态机与列表查询

商品状态只有三个:草稿、上架、下架。看起来简单,但上架前要校验的东西不少,这些校验如果散落在 Service 各处,后面加一条规则就得翻半天。

public enum GoodsStatusEnum { DRAFT(0, "草稿"), ON_SALE(1, "上架"), OFF_SALE(2, "下架"); private final int code; private final String desc; GoodsStatusEnum(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } /** 只有草稿和下架能上架 */ public static boolean canOnSale(int current) { return current == DRAFT.code || current == OFF_SALE.code; } private static Map<Integer, GoodsStatusEnum> map = new HashMap<>(); static { for (GoodsStatusEnum e : values()) { map.put(e.code, e); } } public static GoodsStatusEnum of(Integer code) { return map.get(code); } }

canOnSale写在枚举里而不是 Service 里,是因为"什么状态能上架"属于业务定义,不属于某个具体流程。列表页的批量上架、编辑页的保存并上架、定时任务自动上架,三处都要调这个判断,放枚举里只有一份。

上架接口本身很短:

@Transactional(rollbackFor = Exception.class) public void onSale(Long spuId) { GoodsSpu spu = spuMapper.selectById(spuId); if (spu == null || spu.getDelFlag() == 1) { throw new BizException("商品不存在"); } if (!GoodsStatusEnum.canOnSale(spu.getStatus())) { throw new BizException("当前状态不允许上架"); } // 上架前必须至少有一个启用的SKU且有库存 Long validSku = skuMapper.selectCount(new LambdaQueryWrapper<GoodsSku>() .eq(GoodsSku::getSpuId, spuId) .eq(GoodsSku::getStatus, 1) .gt(GoodsSku::getStock, 0)); if (validSku == null || validSku == 0) { throw new BizException("没有可售规格,无法上架"); } spuMapper.updateStatus(spuId, GoodsStatusEnum.ON_SALE.getCode()); }

列表查询用LambdaQueryWrapper拼接,商品维护页面通常有 5~6 个筛选条件(名称模糊、分类、状态、价格区间、时间范围),全部可选:

LambdaQueryWrapper<GoodsSpu> wrapper = new LambdaQueryWrapper<GoodsSpu>() .eq(GoodsSpu::getDelFlag, 0) .like(StringUtils.hasText(q.getKeyword()), GoodsSpu::getSpuName, q.getKeyword()) .eq(q.getStatus() != null, GoodsSpu::getStatus, q.getStatus()) .ge(q.getPriceMin() != null, GoodsSpu::getPriceLow, q.getPriceMin()) .le(q.getPriceMax() != null, GoodsSpu::getPriceHigh, q.getPriceMax()) .orderByDesc(GoodsSpu::getUpdateTime); // 分类筛选要连带子分类,先把分类子树 id 查出来再 in if (q.getCategoryId() != null) { wrapper.in(GoodsSpu::getCategoryId, categoryService.listLeafIdsUnder(q.getCategoryId())); } Page<GoodsSpu> page = spuMapper.selectPage(new Page<>(q.getPageNum(), q.getPageSize()), wrapper);

like条件第一个参数是布尔开关,这是 MyBatis-Plus 的惯用写法,条件不成立时整段忽略。前缀模糊('%关键字%')走不了索引,商品几十万条时列表搜索会明显变慢;真到那一步常见做法是接 Elasticsearch,把商品列表查询整体挪过去,但这属于明显超出中小后台量级的需求,先别过度设计。

4. 库存与价格:并发扣减、缓存一致性怎么处理

商品维护做完,紧接着就是"东西还没卖出去,库存先乱了"。这一章讲三个点:库存扣减怎么写才不超卖、商品详情缓存怎么不脏、价格计算怎么不丢精度。这三块是后台系统上线后最容易收到投诉的地方。

4.1 库存扣减的三种写法与取舍

先看反面教材,这是新人最常写的一版:

// 错误示范:先查后改,并发下必然超卖 GoodsSku sku = skuMapper.selectById(skuId); if (sku.getStock() >= num) { sku.setStock(sku.getStock() - num); skuMapper.updateById(sku); // 两个线程读到同一个旧值,都判断通过 }

select和update之间有窗口期,两个请求同时读到库存 1,都判断通过,最后都扣一遍,库存变成 -1。这就是超卖,血泪经验基本都是这么来的。

第一种改法,乐观锁版本号。实体加version字段,SQL 带where version = #{oldVersion},更新影响行数为 0 就重试。适合并发不高的后台调库存场景,重试逻辑得写,不然运营点两下就报错。

第二种改法,条件更新,也是这套系统实际用的:

/** * 扣减可售库存,条件更新保证并发安全 * @param skuId SKU主键 * @param num 扣减数量,必须大于0 * @return 影响行数,0 表示库存不足 */ @Update("UPDATE goods_sku SET stock = stock - #{num}, " + "lock_stock = lock_stock + #{num} " + "WHERE id = #{skuId} AND stock >= #{num} AND del_flag = 0") int deductStock(@Param("skuId") Long skuId, @Param("num") Integer num);

把判断条件塞进WHERE,交给数据库的行锁去保证原子性。一条 SQL 同时动两个字段:可售库存减、锁定库存加。影响行数为 0 就是库存不够,Service 直接抛"库存不足",不需要额外查一次。

第三种,高并发场景下的 Redis 预扣。活动秒杀那种量级,先在 Redis 里DECRBY扣减,扣到负数就回滚并返回失败,然后异步落库。这套后台没有走这条路,因为它的定位是小商家后台,库存变更主要由运营手动改和普通下单触发,QPS 撑死几十。要不要上 Redis 预扣,看的是你的峰值并发,不是看别人怎么写。

库存这块还有一条容易漏的:lock_stock加上去之后得有地方减回来。支付成功要把lock_stock减掉(货真的扣了),订单取消或超时未支付要把stock加回去、lock_stock减掉。这两个反向操作如果不写全,一段时间后就会发现"可售库存 + 锁定库存 ≠ 实际库存",对不上账。

4.2 商品详情缓存:Cache Aside 与删而不是改

商品详情是读多写少的典型,加一层 Redis 很划算。但缓存怎么更新,决定了你会不会遇到"价格改了页面没变"。

这套代码用的是 Cache Aside:

public GoodsDetailVO getDetail(Long spuId) { String key = "goods:detail:" + spuId; String cached = redisTemplate.opsForValue().get(key); if (StringUtils.hasText(cached)) { return JSONUtil.toBean(cached, GoodsDetailVO.class); } GoodsDetailVO vo = loadFromDb(spuId); // 查 SPU + SKU + 分类路径 if (vo != null) { // 加随机过期时间,避免同一批商品缓存同时失效造成击穿 long expire = 1800 + RandomUtil.randomLong(0, 300); redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(vo), expire, TimeUnit.SECONDS); } return vo; } /** 商品变更时只删除,不更新缓存 */ public void evictDetail(Long spuId) { redisTemplate.delete("goods:detail:" + spuId); }

为什么是删不是改?因为改缓存要先查库组装 VO,再写回去,两步之间如果有并发更新,写回去的那个可能是旧数据,缓存就长期脏了。删除的语义是"下次读的时候重新从库里加载",天然规避这个问题。代价是删除后第一次访问会打到库上,量不大的话完全能接受。

删除动作挂在所有会改变商品数据的地方:保存商品、上下架、改库存、批量导入。这里最容易翻车的是批量操作——批量上架 50 个商品,只删了第一个的缓存,剩下 49 个详情页还是旧状态。写批量接口的时候,缓存 key 的清理要放在 for 循环里,别放在循环外面。

4.3 价格计算:BigDecimal 的坑与"以谁为准"

价格用DECIMAL(10,2)存,Java 侧对应BigDecimal,这条没得商量。用double存价格,0.1 + 0.2 = 0.30000000000000004这种事迟早让你在对账时崩溃。

BigDecimal本身也有脾气:

BigDecimal a = new BigDecimal(0.1); // 0.1000000000000000055511151231257827 BigDecimal b = new BigDecimal("0.1"); // 0.1 BigDecimal result = b.multiply(new BigDecimal("3")) .setScale(2, RoundingMode.HALF_UP); // 0.30,必须显式指定舍入 // 比较大小用 compareTo,不要用 equals new BigDecimal("1.0").equals(new BigDecimal("1.00")); // false,scale 不同 new BigDecimal("1.0").compareTo(new BigDecimal("1.00")); // 0,相等

equals比的是值和精度,1.0和1.00判不等,商品价格校验里用equals做比较,会遇到"两个 99 块钱对不上"的玄学问题。setScale不指定舍入模式,遇到无限小数会直接抛ArithmeticException,折扣计算里1/3这种除不尽的情况一算就炸。

还有一个更隐蔽的问题:价格以谁为准。商品表里有price,SKU 表里有price,如果接了促销还有促销价。这套代码定的规则是:下单和展示一律以 SKU 的price为准,SPU 的price_low/price_high只用于列表展示,促销价在结算时单独计算,不写回商品表。这个约定必须写进接口文档,否则前端展示用 SPU 价、后端算钱用 SKU 价,两边对不上,用户就会截图来投诉。

5. 踩坑排查:从启动失败到商品改不动的现场记录

这套代码跑起来之后,真正耗时间的是各种"看着没问题但就是不生效"。下面这些是我在本地和测试环境反复遇到过的,按现象、原因、解决三段的格式记下来,新手可以直接对照。

5.1 环境与启动类问题

现象:后端起得来,访问接口全部 404,日志里看不到任何映射。原因:启动类在goods-admin-api模块,com.example.admin包下,而GoodsSpuController在com.example.goods.controller包下,Spring 默认扫描范围只到启动类所在包的子包,跨模块的 Bean 根本没注册。这种问题在拆多模块时几乎必踩,且报错信息很误导人——它不报"找不到 Bean",只报 404。 解决:启动类上补@ComponentScan(basePackages = "com.example"),或者把启动类挪到所有模块的公共父包下。同时确认各模块的@Mapper接口有@MapperScan("com.example.**.mapper")覆盖。

现象:npm run dev起来后登录能过,一调接口就报 CORS 跨域,控制台红一片。原因:本地开发是 5173 访问 8080,如果没走 Vite 代理而是直接写死了http://localhost:8080,浏览器同源策略就拦住了;另一种情况是走了代理但后端SecurityConfig里自己又配了一套 CORS,两套规则打架。 解决:前端统一用相对路径/api/xxx,靠vite.config.js的 proxy 转发,后端只保留一套 CORS 配置或者干脆关掉(同源之后不需要)。改完记得重启 Vite,代理配置热更新有时候不生效。

现象:商品创建时间是 8 小时前,运营反馈"今天新加的商品搜不到"。原因:spring.datasource.url里serverTimezone写成了UTC,或者干脆没写。MySQL 存的是DATETIME,JDBC 驱动按 UTC 解释,写入时把东八区时间减了 8 小时。 解决:URL 补&serverTimezone=Asia/Shanghai,同时确认 MySQL 服务端time_zone不是 UTC。两个都要对,只改一边还是会偏。

现象:前端npm install卡在某个包上,或者npm run dev报vite相关语法错误。原因:Node 版本不对。Vite 4 以上要求 Node 16+,Vite 5 要求 Node 18+,用 Node 14 跑直接报语法错误,但错误信息指向的是 vite 的配置文件,很容易误判成代码问题。 解决:node -v确认版本,建议 18 LTS。多项目共存的话用 nvm 切,别把全局 Node 升到最新版,老项目会集体罢工。

5.2 商品维护业务类问题

现象:商品保存成功,但编辑页打开后规格表格是空的,价格也没回填。原因:详情接口返回的 SKU 列表被 MyBatis-Plus 的逻辑删除插件过滤了;或者spec_json存的是 JSON 字符串,回显时没解析就直接塞给前端,前端拿到的是一串文本而不是对象。 解决:确认goods_sku的del_flag字段名和实体上的@TableLogic一致;详情 VO 里把specJson转成Map<String,String>再返回,前端spuEdit.vue的规格表格直接读这个 map。

现象:运营改完价格,列表页变了,商品详情页还是旧价格。原因:详情接口走了 Redis 缓存,保存商品时没有清对应 key,或者清的是goods:detail:{spuId}而实际 key 里多了个租户前缀或冒号,删了个不存在的 key,Redis 不报错,你以为删成功了。 解决:把缓存 key 的拼接收敛到一个工具方法里,读和删都调它,别手写字符串。另外可以在evictDetail里打印一下 key,第一次上线前核对一遍。

现象:批量删除分类时报"外键约束失败",或者分类删了但商品列表里出现空白分类。原因:分类表没有物理外键,全靠代码校验,删除时只判了parent_id下有没有子分类,没判该分类下有没有商品。 解决:删除前查goods_spu里category_id等于该分类且del_flag = 0的数量,大于 0 直接拦下并提示"该分类下还有 N 个商品"。分类是商品筛选的入口,允许删掉有商品的分类,后面一定会在列表页收到投诉。

现象:上下架按钮点击后提示成功,刷新页面状态没变。原因:updateStatus的 SQL 里写了where id = #{spuId} and status = #{oldStatus},前端传的还是上一次的状态,条件不匹配影响 0 行,但接口没检查返回值,默认返回成功。 解决:所有 update 类操作检查影响行数,为 0 时抛"操作失败,请刷新重试";或者服务端不信前端传的状态,自己查一次当前状态再判断流转是否合法。前端传状态只做展示,不做判断依据,这条在状态机相关的接口里都适用。

6. 二次开发:给商品维护加一套"扩展属性"的完整改法

真实项目里,商品字段永远加不完。今天要加"产地",明天要加"保质期",后天又说要按行业自定义。如果每加一个字段就改一次表、改一次实体、改一次前端表单,改到第十个字段人就麻了。这一节给一套可复用的改法:不动主表结构,用扩展属性表承载,商品维护页动态渲染表单。

思路是加一张键值表goods_ext_attr,spu_id关联商品,attr_key是属性编码,attr_value存值,attr_type区分文本、数字、日期、下拉。同时加一张模板表goods_attr_template,按分类配置"这个分类下有哪些扩展属性",商品编辑页拿到模板后动态渲染。

改造分四步:

第一步,加表并挂到商品保存事务里。表结构如下,主键、索引、字段语义一次定清楚:

字段类型说明
idBIGINT主键
spu_idBIGINT关联 goods_spu.id,有索引
attr_keyVARCHAR(64)属性编码,如 origin_place
attr_valueVARCHAR(512)属性值,统一按字符串存
attr_typeTINYINT1 文本 2 数字 3 日期 4 下拉
sortINT展示顺序

第二步,GoodsSaveDTO里加Map<String,String> extAttrs,saveGoods方法末尾追加一段:先按spuId删掉旧值,再批量插入新值。这里注意用物理删除,扩展属性不需要留历史,逻辑删会越积越多。

// 扩展属性整体覆盖,简单可靠,避免做 diff extAttrMapper.delete(new LambdaQueryWrapper<GoodsExtAttr>() .eq(GoodsExtAttr::getSpuId, spu.getId())); if (MapUtils.isNotEmpty(dto.getExtAttrs())) { List<GoodsExtAttr> attrs = dto.getExtAttrs().entrySet().stream().map(e -> { GoodsExtAttr attr = new GoodsExtAttr(); attr.setSpuId(spu.getId()); attr.setAttrKey(e.getKey()); attr.setAttrValue(e.getValue()); // 统一字符串,读取时按 attr_type 转换 attr.setAttrType(templateService.getType(e.getKey())); return attr; }).collect(Collectors.toList()); extAttrMapper.insertBatch(attrs); // 自己写 foreach 批量插入,别循环单插 }

第三步,详情接口把扩展属性一起返回,GoodsDetailVO里加extAttrs。不要让前端再发一个请求去查,两次请求之间商品被改了,表单就会串数据。

第四步,spuEdit.vue里根据模板渲染表单。核心是用一个v-for遍历模板,按attrType切换组件,而不是写十个v-if:

// 模板结构:[{ key: 'origin_place', label: '产地', type: 1 }, ...] const renderAttrs = computed(() => templateList.value.map(t => ({ ...t, component: { 1: 'el-input', 2: 'el-input-number', 3: 'el-date-picker', 4: 'el-select' }[t.type], })) )

验证方法要具体:新建一个商品填三个扩展属性保存,退出编辑页重新进入,确认三个值都回显;再改掉其中一个值保存,重新进入确认只变了那一个;最后到数据库select * from goods_ext_attr where spu_id = ?确认没有重复行。三步都过,这套改造才算稳。

有个细节不要省:attr_value统一存字符串,读取时按attr_type转换。我见过直接把数字和日期按原类型往VARCHAR里塞的,MySQL 会隐式转换,看着能用,一旦遇到日期格式不统一(2024-01-01和2024/01/01)就没法比较大小,后面做筛选功能还得回头清洗数据。

从那以后我每次加字段前,都强制走一遍"这个字段是不是所有商品都有、会不会超过三个、要不要按它筛选"这三问,答案是"否"就一律走扩展属性,主表只留高频且必填的列。这套习惯让我少改了很多次表结构,也少了很多夜里被叫起来加字段的时刻。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 2:50:38

SQL注入攻防实战:原理、工具与防御方案

1. 项目概述SQL注入作为Web安全领域的"元老级"漏洞&#xff0c;至今仍位列OWASP Top 10威胁榜首。去年某大型电商平台的数据泄露事件&#xff0c;根源就是一处未过滤的搜索框。我在企业红队评估中&#xff0c;90%的Web系统都能发现不同程度的注入问题。本文将带您从底…

作者头像 李华
网站建设 2026/10/31 2:50:36

远程控制排查与合规运维通道建设:驻留、连接、留痕实战

简介&#xff1a;Zero远控_04 是 Zero 远控系列教程第四份配套工程源码&#xff0c;面向已具备一定 C 与网络基础、希望动手实现远程控制的中级学习者。内容围绕客户端/服务器双端架构展开&#xff1a;服务端负责监听与身份校验&#xff0c;客户端完成连接建立与指令下发&#…

作者头像 李华
网站建设 2026/10/31 2:50:03

军工行业超大文件上传技术方案与WebUploader改造实践

1. 军工行业超大文件上传的技术挑战在军工行业的卫星视频处理场景中&#xff0c;我们经常需要传输单个超过10GB的高清监控视频或遥感影像。这类文件的上传与传统小文件有着本质区别&#xff1a;稳定性要求&#xff1a;军工网络环境复杂&#xff0c;可能跨越多个安全域&#xff…

作者头像 李华
网站建设 2026/10/31 2:50:02

人形机器人控制:模仿学习与强化学习结合实践

1. 人形机器人控制技术现状与挑战人形机器人作为机器人学皇冠上的明珠&#xff0c;其控制问题一直是学术界和工业界关注的焦点。传统基于模型的控制方法&#xff08;如PID控制、阻抗控制&#xff09;在面对复杂环境时往往表现不佳&#xff0c;而模仿学习&#xff08;Imitation …

作者头像 李华
网站建设 2026/10/31 2:49:20

AI工具如何导致职场过载及应对策略

1. 效率提升背后的悖论&#xff1a;当AI成为新负担去年我团队引入了一套号称能提升300%效率的AI协作系统&#xff0c;三个月后却发现&#xff1a;每天下班时间平均推迟了47分钟。这不是个例——Gartner最新调研显示&#xff0c;73%采用AI工具的中层管理者反馈"工作量不减反…

作者头像 李华
网站建设 2026/10/31 2:49:18

HTML入门指南:从基础语法到网页构建实践

1. 初识HTML&#xff1a;网页构建的基石HTML&#xff08;HyperText Markup Language&#xff09;是构成万维网的基础语言。想象一下&#xff0c;当你在浏览器中打开一个网页时&#xff0c;所有你看到的文字、图片、视频等内容&#xff0c;都是由HTML来定义和组织的。它就像是建…

作者头像 李华