乐优商城(四)商品规格管理
1. 商品规格
乐优商城是一个全品类的电商网站,因此商品的种类繁多,每一件商品,其属性又有差别。为了更准确描述商品及细分差别,抽象出两个概念:SPU 和 SKU
1.1 SPU 和 SKU
-
SPU:Standard Product Unit (标准产品单位) ,一组具有共同属性的商品集
-
SKU:Stock Keeping Unit(库存量单位),SPU 商品集因具体特性不同而细分的每个商品
上面的概念有些抽象,为便于理解下面有一张京东的 “小米 10” 商品页面图片:
-
在页面中的 “小米 10” 就是一个商品集,即 SPU
-
因为选择不同的颜色、版本而细分出不同的 “小米 10”,即 SKU。
比如:钛银色、8GB+256GB 是一个 SKU;冰蓝色、8GB+128GB 是一个 SKU
两者的作用:
- SPU 是一个抽象的商品集概念,是为了方便后台的管理。
- SKU 才是具体要销售的商品,每一个 SKU 的价格、库存可能会不一样,用户购买的是 SKU 而不是 SPU。
1.2 分析商品规格的关系
我们看看京东的 “小米 10” 商品的规格页面:
可以很容易分析出这里有两张表:规格组和规格参数。并且一个规格组对应着多个规格参数,一个规格参数对应着一个规格组。规格组和规格参数之间是一对多的关系。
并且一个分类对应着多个规格组,一个规格组对应着一个分类。分类和规格组之间是一对多的关系。
再来看看京东搜索 “手机” 后的过滤条件:
可以分析出:我们需要直接根据 “手机” 分类,得出 “品牌” 规格参数等。并且一个分类对应着多个规格参数,一个规格参数对应着一个分类。分类和规格参数之间是一对多的关系。
分类、规格组、规格参数之间的关系如下图所示
1.3 数据库设计
1.3.1 商品规格组表
规格组表 tb_spec_group
CREATE TABLE `tb_spec_group` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`cid` bigint(20) NOT NULL COMMENT '商品分类id,一个分类下有多个规格组',
`name` varchar(32) NOT NULL COMMENT '规格组的名称',
PRIMARY KEY (`id`),
KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=28 DEFAULT CHARSET=utf8 COMMENT='规格参数的分组表,每个商品分类下有多个规格参数组';
1.3.2 商品规格参数表
规格参数表 tb_spec_param
CREATE TABLE `tb_spec_param` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`cid` bigint(20) NOT NULL COMMENT '商品分类id',
`group_id` bigint(20) NOT NULL,
`name` varchar(256) NOT NULL COMMENT '参数名',
`numeric` tinyint(1) NOT NULL COMMENT '是否是数字类型参数,true或false',
`unit` varchar(256) DEFAULT '' COMMENT '数字类型参数的单位,非数字类型可以为空',
`generic` tinyint(1) NOT NULL COMMENT '是否是sku通用属性,true或false',
`searching` tinyint(1) NOT NULL COMMENT '是否用于搜索过滤,true或false',
`segments` varchar(1024) DEFAULT '' COMMENT '数值类型参数,如果需要搜索,则添加分段间隔值,如CPU频率间隔:0.5-1.0',
PRIMARY KEY (`id`),
KEY `key_group` (`group_id`),
KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=33 DEFAULT CHARSET=utf8 COMMENT='规格参数组下的参数名';
这里有几个字段比较特殊,下面给出具体的解释:
- numeric:用来判断规格参数是否是数字类型参数。如果是数字类型参数,还需要填写数字类型参数的单位。
- generic:用来判断规格参数是否是 SKU 通用属性。比如上面的 “小米 10” 的 “颜色” 和 “版本” 这两个规格参数就不是 SKU 通用属性,而是 SKU 特有属性,所以它们的值为 false。
- searching:用来判断规格参数是否用于搜索过滤。上面我们已经可以知道有些规格参数会作为搜索过滤的条件。
- segments:分段间隔值。如果一个字段既是数字类型参数,还能用于搜索过滤,那就可以给他分几个间隔值,比如电池容量:0-2000mAh、2000mAh-3000mAh、3000mAh-4000mAh
?
2. 商品规格组
2.1 商品规格组前端
我们打开规格参数的页面,可以看到左侧展示了商品的分类
点击一个分类的最终分类,可以看到右侧提示 “该分类下暂无规格组或尚未选择分类”,由此可以得知右侧是用来展示规格组数据的,只是现在暂时没有数据。
我们找到前端请求规格组数据的代码:
由此可以得知:
- 请求方式:GET
- 请求路径:/spec/groups
- 请求参数:分类 id,这里用的是 Rest 风格的占位符
- 返回参数:规格组的集合
2.2 实现商品规格组查询
2.2.1 实体类
在 leyou-item-interface 项目中添加两个实体类
规格组 SpecGroup
@Table(name = "tb_spec_group")
public class SpecGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long cid;
private String name;
@Transient
private List<SpecParam> params;
// getter、setter、toString 方法省略
}
注意:这里的 params 属性并不与数据库字段相对应,所以加上了 @Transient
规格参数 SpecParam
@Table(name = "tb_spec_param")
public class SpecParam {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long cid;
private Long groupId;
private String name;
@Column(name = "`numeric`")
private Boolean numeric;
private String unit;
private Boolean generic;
private Boolean searching;
private String segments;
// getter、setter、toString 方法省略
}
注意:这里的 numeric 属性在 MySQL 中是一个关键字,所以使用 @Column 表示它是一个字段
2.2.2 Mapper
在 leyou-item-service 项目中添加两个 Mapper
规格组 SpecGroupMapper
public interface SpecGroupMapper extends Mapper<SpecGroup> {
}
2.2.3 Service
在 leyou-item-service 项目中添加 Service
@Service
public class SpecificationService {
@Autowired
private SpecGroupMapper specGroupMapper;
@Autowired
private SpecParamMapper specParamMapper;
/**
* 根据分类 id 查询分组
*
* @param cid
* @return
*/
public List<SpecGroup> querySpecGroupsByCid(Long cid) {
SpecGroup specGroup = new SpecGroup();
specGroup.setCid(cid);
List<SpecGroup> specGroups = specGroupMapper.select(specGroup);
return specGroups;
}
}
2.2.4 Controller
在 leyou-item-service 项目中添加 Controlle
@RestController
@RequestMapping("/spec")
public class SpecificationController {
@Autowired
private SpecificationService specificationService;
/**
* 根据分类 id 查询分组
*
* @param cid
* @return
*/
@GetMapping("/groups/{cid}")
public ResponseEntity<List<SpecGroup>> querySpecGroupsByCid(@PathVariable("cid") Long cid) {
List<SpecGroup> specGroups = specificationService.querySpecGroupsByCid(cid);
if (CollectionUtils.isEmpty(specGroups)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(specGroups);
}
}
2.2.5 测试
成功加载规格组数据
3. 商品规格参数
3.1 商品规格参数前端
点击一个规格组 “主体”
可以看到规格组的表格切换到了规格参数的表格,只是暂时还没有数据
我们找到前端请求规格参数数据的代码:
由此可以得知:
- 请求方式:GET
- 请求路径:/spec/params
- 请求参数:规格组 id
- 返回参数:规格参数的集合
3.2 实现商品规格参数查询
3.2.1 Controller
在 SpecificationController 中添加方法
/**
* 根据条件查询规格参数
*
* @param gid
* @return
*/
@GetMapping("/params")
public ResponseEntity<List<SpecParam>> querySpecParams(@RequestParam("gid") Long gid) {
List<SpecParam> params = specificationService.querySpecParams(gid);
if (CollectionUtils.isEmpty(params)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(params);
}
3.2.2 Service
在 SpecificationService 中添加方法
/**
* 根据条件查询规格参数
* @param gid
* @return
*/
public List<SpecParam> querySpecParams(Long gid) {
SpecParam specParam = new SpecParam();
specParam.setGroupId(gid);
List<SpecParam> params = specParamMapper.select(specParam);
return params;
}
3.2.3 测试
成功加载规格参数数据
4. 商品
前面我们已经介绍了 SPU 和 SKU 的概念,了解了 SPU 是一个商品集,而 SKU 才是具体要销售的商品。所以商品必不可少的两张表就是 SPU 和 SKU,下面我们分析一下 SPU、SKU 和其他表之间的关系。
4.1 分析商品的关系
还是用上面举过的例子,“小米 10” 就是一个 SPU,它只对应 “小米” 这一个品牌,但小米品牌有多个 SPU,如:小米 9、小米 8 等。品牌和 SPU 之间是一对多的关系。
而 “小米 10” 是一部手机 ,它只对应手机这一个分类,而手机分类却可以对应多个 SPU。分类和 SPU 之间是一对多的关系。
前面已经讲过了,一个 SPU 可以有多个 SKU,而一个 SKU 只能有一个 SPU。SPU 和 SKU 之间是一对多的关系。
商品的关系如下图所示:
4.2 数据库设计
4.2.1 SPU 表
SPU 表 tb_spu
CREATE TABLE `tb_spu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'spu id',
`title` varchar(128) NOT NULL DEFAULT '' COMMENT '标题',
`sub_title` varchar(256) DEFAULT '' COMMENT '子标题',
`cid1` bigint(20) NOT NULL COMMENT '1级类目id',
`cid2` bigint(20) NOT NULL COMMENT '2级类目id',
`cid3` bigint(20) NOT NULL COMMENT '3级类目id',
`brand_id` bigint(20) NOT NULL COMMENT '商品所属品牌id',
`saleable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否上架,0下架,1上架',
`valid` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0已删除,1有效',
`create_time` datetime DEFAULT NULL COMMENT '添加时间',
`last_update_time` datetime DEFAULT NULL COMMENT '最后修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=195 DEFAULT CHARSET=utf8 COMMENT='spu表,该表描述的是一个抽象性的商品,比如 iphone8';
这张表似乎少了一些字段,比如商品描述,售后信息等,但这些数据都比较大,为了不影响查询效率我们做了表的垂直拆分,将 SPU 的详情放到了另一张表 tb_spu_detail
CREATE TABLE `tb_spu_detail` (
`spu_id` bigint(20) NOT NULL,
`description` text COMMENT '商品描述信息',
`generic_spec` varchar(2048) NOT NULL DEFAULT '' COMMENT '通用规格参数数据',
`special_spec` varchar(1024) NOT NULL COMMENT '特有规格参数及可选值信息,json格式',
`packing_list` varchar(1024) DEFAULT '' COMMENT '包装清单',
`after_service` varchar(1024) DEFAULT '' COMMENT '售后服务',
PRIMARY KEY (`spu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
这里有几个字段比较特殊,下面给出具体的解释:
generic_spec
用来保存通用规格参数信息的值,这里为了方便查询,使用了 JSON 格式。
其中都是键值对:
- key:对应的规格参数的 spec_param 的 id
- value:对应规格参数的值
special_spec
用来保存特有规格参数及可选值,也就是 SKU 的特有属性。
其中都是键值对:
- key:对应的规格参数的 spec_param 的 id
- value:对应规格参数的数组,因为 SKU 特有属性可能有多个
4.2.2 SKU 表
SKU 表 tb_sku
CREATE TABLE `tb_sku` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'sku id',
`spu_id` bigint(20) NOT NULL COMMENT 'spu id',
`title` varchar(256) NOT NULL COMMENT '商品标题',
`images` varchar(1024) DEFAULT '' COMMENT '商品的图片,多个图片以‘,’分割',
`price` bigint(15) NOT NULL DEFAULT '0' COMMENT '销售价格,单位为分',
`indexes` varchar(32) DEFAULT '' COMMENT '特有规格属性在spu属性模板中的对应下标组合',
`own_spec` varchar(1024) DEFAULT '' COMMENT 'sku的特有规格参数键值对,json格式,反序列化时请使用linkedHashMap,保证有序',
`enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0无效,1有效',
`create_time` datetime NOT NULL COMMENT '添加时间',
`last_update_time` datetime NOT NULL COMMENT '最后修改时间',
PRIMARY KEY (`id`),
KEY `key_spu_id` (`spu_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=27359021729 DEFAULT CHARSET=utf8 COMMENT='sku表,该表表示具体的商品实体,如黑色的 64g的iphone 8';
这里有几个字段比较特殊,下面给出具体的解释:
indexes
tb_spu_detail 表的 special_spec 字段是用来保存 SKU 特有属性的,而 indexes 字段就是这些特有属性的下标组合。这个设计在商品详情页会特别有用,当用户点击选中一个特有属性,你就能根据角标快速定位到 SKU。
比如 special_spec 字段如下:
indexes 字段:
- 0_0_0:表示白色、3GB、16GB
- 1_0_0:表示金色、3GB、16GB
- 2_0_0:表示玫瑰金、3GB、16GB
own_spec
用来保存 SKU 特有属性的键值对,使用了 JSON 格式,比如:
SKU 还应该有一个库存字段,但 SKU 表中的其他字段读的频率较高,而库存字段写的频率比较高,因此做了表的垂直拆分,使读写不会互相干扰。
库存表 tb_stock
CREATE TABLE `tb_stock` (
`sku_id` bigint(20) NOT NULL COMMENT '库存对应的商品sku id',
`seckill_stock` int(9) DEFAULT '0' COMMENT '可秒杀库存',
`seckill_total` int(9) DEFAULT '0' COMMENT '秒杀总数量',
`stock` int(9) NOT NULL COMMENT '库存数量',
PRIMARY KEY (`sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='库存表,代表库存,秒杀库存等信息';
4.3 商品前端
点击商品列表,可以看到这是一个可以实现分页、查询的表单,和之前做过的品牌的查询很相似。
我们找到前端请求商品数据的代码:
由此可以得知:
- 请求方式:GET
- 请求路径:spu/page
- 请求参数:
- key:搜索条件,String
- saleable:上下架,boolean(全部为 null,上架为 true,下架为 false)
- page:当前页,int
- rows:每页大小,int
- 返回参数:规格组的集合
- total:总条数
- items:当前页数据
4.4 实现商品查询
4.4.1 实体类
在 leyou-item-interface 中添加实体类
SPU
@Table(name = "tb_spu")
public class Spu {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long brandId;
private Long cid1;// 1级类目
private Long cid2;// 2级类目
private Long cid3;// 3级类目
private String title;// 标题
private String subTitle;// 子标题
private Boolean saleable;// 是否上架
private Boolean valid;// 是否有效,逻辑删除用
private Date createTime;// 创建时间
private Date lastUpdateTime;// 最后修改时间
// getter、setter、toString 方法省略
}
SPU 详情
@Table(name="tb_spu_detail")
public class SpuDetail {
@Id
private Long spuId;// 对应的SPU的id
private String description;// 商品描述
private String specialSpec;// 商品特殊规格的名称及可选值模板
private String genericSpec;// 商品的全局规格属性
private String packingList;// 包装清单
private String afterService;// 售后服务
// getter、setter、toString 方法省略
}
此时,我们发现一个问题,商品页面中的商品分类和品牌的应该是字符串
SPU 表的商品分类和品牌却只是 id,所以在实体类中还需要有两个属性,用来封装商品分类的 name 和品牌分类的 name。我们肯定不能直接修改 Spu 实体类,但可以拓展一个实体类 SpuBo,Bo 即 Business Object
public class SpuBo extends Spu{
private String cname;
private String bname;
// getter、setter、toString 方法省略
}
4.4.2 Mapper
在 leyou-item-service 项目中添加两个 Mapper
Spu
public interface SpuMapper extends Mapper<Spu> {
}
Spu 详情
public interface SpuDetail extends Mapper<SpuDetail> {
}
4.4.3 Controller
在 leyou-item-service 项目中添加 Controller
@RestController
@RequestMapping("/spu")
public class SpuController {
@Autowired
private SpuService spuService;
/**
* 根据查询条件分页查询商品信息
* @param key 搜索条件
* @param saleable 上下架
* @param page 当前页
* @param rows 每页大小
* @return
*/
@GetMapping("/page")
public ResponseEntity<PageResult<SpuBo>> querySpuByPage(
@RequestParam(name = "key", required = false) String key,
@RequestParam(name = "saleable", required = false) Boolean saleable,
@RequestParam(name = "page", defaultValue = "1") Integer page,
@RequestParam(name = "rows", defaultValue = "5") Integer rows
) {
PageResult<SpuBo> pageResult = spuService.querySpuByPage(key, saleable, page, rows);
if (CollectionUtils.isEmpty(pageResult.getItems())) {
ResponseEntity.notFound().build();
}
return ResponseEntity.ok(pageResult);
}
}
4.4.4 Service
在 leyou-item-service 项目中添加 Service
@Service
public class SpuService {
@Autowired
private SpuMapper spuMapper;
@Autowired
private BrandMapper brandMapper;
@Autowired
private CategoryService categoryService;
/**
* 根据查询条件分页查询商品信息
*
* @param key 搜索条件
* @param saleable 上下架
* @param page 当前页
* @param rows 每页大小
* @return
*/
public PageResult<SpuBo> querySpuByPage(String key, Boolean saleable, Integer page, Integer rows) {
// 初始化 example 对象
Example example = new Example(Spu.class);
Example.Criteria criteria = example.createCriteria();
// 添加搜索条件
if (StringUtils.isNotBlank(key)) {
criteria.andLike("title", "%" + key + "%");
}
// 添加上下架
if (saleable != null) {
criteria.andEqualTo("saleable", saleable);
}
// 添加分页
PageHelper.startPage(page, rows);
// 执行查询,获取 Spu 集合
List<Spu> spus = spuMapper.selectByExample(example);
// 将 Spu 集合包装成 pageInfo
PageInfo<Spu> spuPageInfo = new PageInfo<>(spus);
// 将 Spu 集合转化为 SpuBo 集合
ArrayList<SpuBo> spuBos = new ArrayList<>();
for (Spu spu : spus) {
SpuBo spuBo = new SpuBo();
// 复制共同的属性到 SpuBo 对象中
BeanUtils.copyProperties(spu, spuBo);
// 查询分类名称,并添加到 SpuBo 对象中
List<String> names = categoryService.queryNamesByIds(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()));
spuBo.setCname(StringUtils.join(names,"/"));
// 查询品牌名称,并添加到 SpuBo 对象中
Brand brand = brandMapper.selectByPrimaryKey(spu.getBrandId());
spuBo.setBname(brand.getName());
// 添加 SpuBo 到 SpuBo 集合
spuBos.add(spuBo);
}
// 返回 PageResult<SpuBo>
return new PageResult<SpuBo>(spuPageInfo.getTotal(), spuBos);
}
}
在 CategoryService 添加方法
/**
* 查询分类名称
* @param ids
* @return
*/
public List<String> queryNamesByIds(List<Long> ids) {
ArrayList<String> names = new ArrayList<>();
for (Long id : ids) {
Category category = categoryMapper.selectByPrimaryKey(id);
names.add(category.getName());
}
return names;
}
4.4.5 测试
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!