目录
1.商品规格数据结构
1.1.SPU和SKU
1.2.数据库设计分析
1.2.1.思考并发现问题
1.2.2.分析规格参数
1.2.3.SKU的特有属性
1.3.规格参数表
1.3.1.表结构
1.3.2.json结构分析
2.商品规格参数管理
2.1.页面实现
2.1.1.整体布局
2.2.2.后端代码
3.3.导入图片信息
4.商品查询
4.1.效果预览
4.2.后台提供接口
4.4.1.实体类
4.4.2.controller
4.4.3.service
4.4.4.mapper
4.4.5.Category中拓展查询名称的功能
4.5.测试
乐优商城是一个全品类的电商网站,因此商品的种类繁多,每一件商品,其属性又有差别。为了更准确描述商品及细分差别,抽象出两个概念:SPU和SKU,了解一下:
SPU:Standard Product Unit (标准产品单位) ,一组具有共同属性的商品集
SKU:Stock Keeping Unit(库存量单位),SPU商品集因具体特性不同而细分的每个商品
以图为例来看:
本页的 华为Mate10 就是一个商品集(SPU)
因为颜色、内存等不同,而细分出不同的Mate10,如亮黑色128G版。(SKU)
可以看出:
SPU是一个抽象的商品集概念,为了方便后台的管理。
SKU才是具体要销售的商品,每一个SKU的价格、库存可能会不一样,用户购买的是SKU而不是SPU
弄清楚了SPU和SKU的概念区分,接下来我们一起思考一下该如何设计数据库表。
首先来看SPU,大家一起思考下SPU应该有哪些字段来描述?
id:主键
title:标题
description:描述
specification:规格
packaging_list:包装
after_service:售后服务
comment:评价
category_id:商品分类
brand_id:品牌
似乎并不复杂,但是大家仔细思考一下,商品的规格字段你如何填写?
不同商品的规格不一定相同,数据库中要如何保存?
再看下SKU,大家觉得应该有什么字段?
id:主键
spu_id:关联的spu
price:价格
images:图片
stock:库存
颜色?
内存?
硬盘?
碰到难题了,不同的商品分类,可能属性是不一样的,比如手机有内存,衣服有尺码,我们是全品类的电商网站,这些不同的商品的不同属性,如何设计到一张表中?
仔细查看每一种商品的规格你会发现:
虽然商品规格千变万化,但是同一类商品(如手机)的规格是统一的,有图为证:
华为的规格:
三星的规格:
也就是说,商品的规格参数应该是与分类绑定的。每一个分类都有统一的规格参数模板,但不同商品其参数值可能不同。
如下图所示:
SPU中会有一些特殊属性,用来区分不同的SKU,我们称为SKU特有属性。如华为META10的颜色、内存属性。
不同种类的商品,一个手机,一个衣服,其SKU属性不相同。
同一种类的商品,比如都是衣服,SKU属性基本是一样的,都是颜色、尺码等。
这样说起来,似乎SKU的特有属性也是与分类相关的?事实上,仔细观察你会发现,SKU的特有属性是商品规格参数的一部分:
也就是说,我们没必要单独对SKU的特有属性进行设计,它可以看做是规格参数中的一部分。这样规格参数中的属性可以标记成两部分:
所有sku共享的规格属性(称为全局属性)
每个sku不同的规格属性(称为特有属性)
打开一个搜索页,我们来看看过滤的条件:
你会发现,过滤条件中的屏幕尺寸、运行内存、网路、机身内存、电池容量、CPU核数等,在规格参数中都能找到:
也就是说,规格参数中的数据,将来会有一部分作为搜索条件来使用。我们可以在设计时,将这部分属性标记出来,将来做搜索的时候,作为过滤条件。要注意的是,无论是SPU的全局属性,还是SKU的特有属性,都有可能作为搜索过滤条件的,并不冲突,而是有一个交集:
先看下规格参数表:
CREATE TABLE `tb_specification` (
`category_id` bigint(20) NOT NULL COMMENT '规格模板所属商品分类id',
`specifications` varchar(3000) NOT NULL DEFAULT '' COMMENT '规格参数模板,json格式',
PRIMARY KEY (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品规格参数模板,json格式。';
很奇怪是吧,只有两个字段。特别需要注意的是第二个字段:
specificatons:规格参数模板,json格式
为什么是一个json?我们看下规格参数的格式:
如果按照传统数据库设计,这里至少需要3张表:
group:代表组,与商品分类关联
param_key:属性名,与组关联,一对多
param_value:属性备选值,与属性名关联,一对多
这样程序的复杂度大大增加,但是提高了数据的复用性。
我们的解决方案是,采用json来保存整个规格参数模板,不需要额外的表,一个字符串就够了。
先整体看一下:
因为规格参数分为很多组,所以json最外层是一个数组。
数组中是对象类型,每个对象代表一个组的数据,对象的属性包括:
group:组的名称
params:该组的所有属性
接下来是params:
以主芯片
这一组为例:
group:注明,这里是主芯片
params:该组的所有规格属性,因为不止一个,所以是一个数组。这里包含四个规格属性:CPU品牌,CPU型号,CPU频率,CPU核数。每个规格属性都是一个对象,包含以下信息:
k:属性名称
searchable:是否作为搜索字段,将来在搜索页面使用,boolean类型
global:是否是SPU全局属性,boolean类型。true为全局属性,false为SKU的特有属性
options:属性值的可选项,数组结构。起约束作用,不允许填写可选项以外的值,比如CPU核数,有人添10000核岂不是很扯淡
numerical:是否为数值,boolean类型,true则为数值,false则不是。为空也代表非数值
unit:单位,如:克,毫米。如果是数值类型,那么就需要有单位,否则可以不填。
上面的截图中所有属性都是全局属性,我们来看看内存,应该是特有属性:
总结下:
规格参数分组,每组有多个参数
参数的 k
代表属性名称,没有值,具体的SPU才能确定值
参数会有不同的属性:是否可搜索,是否是全局、是否是数值,这些都用boolean值进行标记:
SPU下的多个SKU共享的参数称为全局属性,用global
标记
SPU下的多个SKU特有的参数称为特有属性
如果参数是数值类型,用numerical
标记,并且指定单位unit
如果参数可搜索,用searchable
标记
页面比较复杂,这里就不带着大家去实现完整页面效果了,我们一起分析一下即可。
打开规格参数页面,看到如下内容:
因为规格是跟商品分类绑定的,因此首先会展现商品分类树,并且提示你要选择商品分类,才能看到规格参数的模板。一起了解下页面的实现:
可以看出页面分成3个部分:
v-card-title
:标题部分,这里是提示信息,告诉用户要先选择分类,才能看到模板
v-tree
:这里用到的是我们之前讲过的树组件,展示商品分类树,不过现在是假数据,我们只要把treeData
属性删除,它就会走url
属性指定的路径去查询真实的商品分类树了。
v-dialog
:Vuetify提供的对话框组件,v-model绑定的dialog属性是boolean类型:
true则显示弹窗
false则隐藏弹窗
实体类
@Table(name = "tb_specification")
public class Specification {
@Id
private Long categoryId;
private String specifications;
public Long getCategoryId() {
return categoryId;
}
public void setCategoryId(Long categoryId) {
this.categoryId = categoryId;
}
public String getSpecifications() {
return specifications;
}
public void setSpecifications(String specifications) {
this.specifications = specifications;
}
}
mapper
public interface SpecificationMapper extends Mapper {
}
controller
先分析下需要的东西,在页面的ajax请求中可以看出:
请求方式:查询,肯定是get
请求路径:/spec/{cid} ,这里通过路径占位符传递商品分类的id
请求参数:商品分类id
返回结果:页面是直接把resp.data
赋值给了specifications:
代码:
@RestController
@RequestMapping("spec")
public class SpecificationController {
@Autowired
private SpecificationService specificationService;
@GetMapping("{id}")
public ResponseEntity querySpecificationByCategoryId(@PathVariable("id") Long id){
Specification spec = this.specificationService.queryById(id);
if (spec == null) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(spec.getSpecifications());
}
}
service:
@Service
public class SpecificationService {
@Autowired
private SpecificationMapper specificationMapper;
public Specification queryById(Long id) {
return this.specificationMapper.selectByPrimaryKey(id);
}
}
页面访问测试:
目前,我们数据库只提供了3条规格参数信息:
我们访问:http://api.leyou.com/api/item/spec/76
然后在后台系统中测试:
现在商品表中虽然有数据,但是所有的图片信息都是无法访问的,我们需要把图片导入到虚拟机:
首先,把资料提供的数据上传到虚拟机下:/leyou/static
目录:
然后,使用命令解压缩:
unzip images.zip
修改Nginx配置,使nginx反向代理这些图片地址:
vim /opt/nginx/config/nginx.conf
修改成如下配置:
server {
listen 80;
server_name image.leyou.com;
# 监听域名中带有group的,交给FastDFS模块处理
location ~/group([0-9])/ {
ngx_fastdfs_module;
}
# 将其它图片代理指向本地的/leyou/static目录
location / {
root /leyou/static/;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
接下来,我们实现商品管理的页面,先看下我们要实现的效果:
可以看出整体是一个table,然后有新增按钮。是不是跟昨天写品牌管理很像?
模板代码在分别在Goods.vue
页面已经准备好,接下来在后台提供分页查询SPU的功能:
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
}
SPU详情
@Table(name="tb_spu_detail")
public class SpuDetail {
@Id
private Long spuId;// 对应的SPU的id
private String description;// 商品描述
private String specTemplate;// 商品特殊规格的名称及可选值模板
private String specifications;// 商品的全局规格属性
private String packingList;// 包装清单
private String afterService;// 售后服务
// 省略getter和setter
}
先分析:
请求方式:GET
请求路径:/spu/page
请求参数:
page:当前页
rows:每页大小
key:过滤条件
saleable:上架或下架
返回结果:商品SPU的分页信息。
要注意,页面展示的是商品分类和品牌名称,而数据库中保存的是id,怎么办?
我们可以新建一个类,继承SPU,并且拓展cname和bname属性,写到ly-item-interface
public class SpuBo extends Spu {
String cname;// 商品分类名称
String bname;// 品牌名称
// 略 。。
}
编写controller代码:
我们把与商品相关的一切业务接口都放到一起,起名为GoodsController,业务层也是这样
@RestController
public class GoodsController {
@Autowired
private GoodsService goodsService;
/**
* 分页查询SPU
* @param page
* @param rows
* @param key
* @return
*/
@GetMapping("/spu/page")
public ResponseEntity> querySpuByPage(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "rows", defaultValue = "5") Integer rows,
@RequestParam(value = "key", required = false) String key) {
// 分页查询spu信息
PageResult result = this.goodsService.querySpuByPageAndSort(page, rows, key);
if (result == null || result.getItems().size() == 0) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(result);
}
}
所有商品相关的业务(包括SPU和SKU)放到一个业务下:GoodsService。
@Service
public class GoodsService {
@Autowired
private SpuMapper spuMapper;
@Autowired
private CategoryService categoryService;
@Autowired
private BrandMapper brandMapper;
public PageResult querySpuByPageAndSort(Integer page, Integer rows, Boolean saleable, String key) {
// 1、查询SPU
// 分页,最多允许查100条
PageHelper.startPage(page, Math.min(rows, 100));
// 创建查询条件
Example example = new Example(Spu.class);
Example.Criteria criteria = example.createCriteria();
// 是否过滤上下架
if (saleable != null) {
criteria.orEqualTo("saleable", saleable);
}
// 是否模糊查询
if (StringUtils.isNotBlank(key)) {
criteria.andLike("title", "%" + key + "%");
}
Page pageInfo = (Page) this.spuMapper.selectByExample(example);
List list = pageInfo.getResult().stream().map(spu -> {
// 2、把spu变为 spuBo
SpuBo spuBo = new SpuBo();
// 属性拷贝
BeanUtils.copyProperties(spu, spuBo);
// 3、查询spu的商品分类名称,要查三级分类
List names = this.categoryService.queryNameByIds(
Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()));
// 将分类名称拼接后存入
spuBo.setCname(StringUtils.join(names, "/"));
// 4、查询spu的品牌名称
Brand brand = this.brandMapper.selectByPrimaryKey(spu.getBrandId());
spuBo.setBname(brand.getName());
return spuBo;
}).collect(Collectors.toList());
return new PageResult<>(pageInfo.getTotal(), list);
}
}
public interface SpuMapper extends Mapper {
}
页面需要商品的分类名称需要在这里查询,因此要额外提供查询分类名称的功能,
在CategoryService中添加功能:
public List queryNameByIds(List ids) {
return this.categoryMapper.selectByIdList(ids).stream().map(Category::getName).collect(Collectors.toList());
}
mapper的selectByIDList方法是来自于通用mapper。不过需要我们在mapper上继承一个通用mapper接口:
public interface CategoryMapper extends Mapper, SelectByIdListMapper {
// ...coding
}
刷新页面,查看效果: