乐优商城(四)商品规格管理

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> {
}

规格参数 SpecParamMapper

public interface SpecParamMapper extends Mapper<SpecParam> {
}

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 项目中添加 Controller

@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 测试

在这里插入图片描述

发布了80 篇原创文章 · 获赞 176 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/bm1998/article/details/104730903