目录
商品管理
一、SPU和SKU数据结构
1.1 SPU表
1.1.1 表结构
tb_spu表
CREATE TABLE `tb_spu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'spu id',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
`sub_title` varchar(255) 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=208 DEFAULT CHARSET=utf8 COMMENT='spu表,该表描述的是一个抽象的商品,比如 iphone8';
但是这个表中没有商品的描述字段等附加信息
进行垂直拆分,将SPU表中的详情放在另一张表中:tb_spu_detail
CREATE TABLE `tb_spu_detail` (
`spu_id` bigint(20) NOT NULL,
`description` text COMMENT '商品描述信息',
`specifications` varchar(3000) NOT NULL DEFAULT '' COMMENT '全部规格参数数据',
`spec_template` varchar(1000) NOT NULL COMMENT '特有规格参数及可选值信息,json格式',
`packing_list` varchar(1000) DEFAULT '' COMMENT '包装清单',
`after_service` varchar(1000) DEFAULT '' COMMENT '售后服务',
PRIMARY KEY (`spu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
因为这个表中的数据都比较大,所以为了不影响主表的查询效率,拆分出这张表。但是在这里需要详细介绍一下这两个字段:specifications和spec_template。
1.1.2 SPU中的规格参数
在上一篇博客中规格参数与商品分类绑定,一个分类下的所有SPU具有类似的规格参数。SPU下的SKU可能会有不同的规格参数,因此:
-
SPU中保存全局的规格参数信息。
-
SKU中保存特有规格参数。
以手机为例,品牌、操作系统等肯定是全局属性,内存、颜色等肯定是特有属性。
当确定了一个SPU,比如vivo的:x23
全局属性举例:
品牌:vivo
型号:x21
特有属性举例:
颜色:[冰钻黑,宝石红,极光白,黑金,魅夜紫]
内存:[6G]
机身存储:[64G,128G]
1.1.2.1. specifications字段
此字段保存全部规格参数信息,所以是一个json格式:
整体来看
整体看上去与规格参数表中的数据一样,也是一个数组,并且分组,每组下有多个参数 。
展开来看
可以看到,与规格参数表中的模板相比,最大的区别就是,这里指定了具体的值,因为商品确定了,其参数值肯定也确定了。
特有属性
刚才看到的是全局属性,那么特有属性在这个字段中如何存储呢?
特有属性也是有的,但是,注意看这里是不确定具体值的,因为特有属性只有在SKU中才能确定。这里只是保存了options,所有SKU属性的可选项。
在哪里可以使用specifications字段呢?商品详情页的规格参数信息中:
1.1.2.2. spec_template字段
既然specifications已经包含了所有的规格参数,那么为什么又多出了一个spec_template呢?里面又有哪些内容呢?
来看数据格式:
可以看出,里面只保存了规格参数中的特有属性,而且格式进行了大大的简化,只有属性的key,和待选项。
为什么要冗余保存一份?
因为很多场景下我们只需要查询特有规格属性,如果放在一起,每次查询再去分离比较麻烦。
比如,商品详情页展示可选的规格参数时:
1.2 SKU表
1.2.1 表结构
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(255) NOT NULL COMMENT '商品标题',
`images` varchar(1000) DEFAULT '' COMMENT '商品的图片,多个图片以‘,’分割',
`price` bigint(15) NOT NULL DEFAULT '0' COMMENT '销售价格,单位为分',
`indexes` varchar(100) COMMENT '特有规格属性在spu属性模板中的对应下标组合',
`own_spec` varchar(1000) 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 DEFAULT CHARSET=utf8 COMMENT='sku表,该表表示具体的商品实体,如黑色的64GB的iphone 8';
还有一张表代表库存:
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='库存表,代表库存,秒杀库存等信息';
问题:为什么要将库存独立一张表?
因为库存字段写频率较高,而SKU的其它字段以读为主,因此将两张表分离,读写不会干扰。
特别需要注意的是sku表中的indexes
字段和own_spec
字段。sku中应该保存特有规格参数的值,就在这两个字段中。
1.2.2 KU中的特有规格参数
1.2.2.1 indexes字段
在SPU表中,已经对特有规格参数及可选项进行了保存,结构如下(样例):
{
"机身颜色": [
"香槟金",
"樱花粉",
"磨砂黑"
],
"内存": [
"2GB",
"3GB"
],
"机身存储": [
"16GB",
"32GB"
]
}
这些特有属性如果排列组合,会产生12个不同的SKU,而不同的SKU,其属性就是上面备选项中的一个。
比如:
-
红米4X,香槟金,2GB内存,16GB存储
-
红米4X,磨砂黑,2GB内存,32GB存储
你会发现,每一个属性值,对应于SPU中options数组的一个选项,如果我们记录下角标,就是这样:
-
红米4X,0,0,0
-
红米4X,2,0,1
既然如此,那么就可以将不同角标串联起来,作为SPU下不同SKU的标示。这就是indexes字段。
这个设计在商品详情页会特别有用
当用户点击选中一个特有属性,你就能根据 角标快速定位到sku。
1.2.2.2 own_spec字段
结构:
{"机身颜色":"香槟金","内存":"2GB","机身存储":"16GB"}
保存的是特有属性的键值对。
SPU中保存的是可选项,但不确定具体的值,而SKU中的保存的就是具体的键值对了。
这样,在页面展示规格参数信息时,就可以根据key来获取值,用于显示。
1.3 图片信息
将图片信息上传到虚拟机中/leyou/static
目录下,然后使用nginx反向代理这些图片:
二、商品查询
2.1 效果预览
页面结构与前面品牌管理相类似,也是一个data-table。
2.2 页面实现
2.2.1 页面代码
<template>
<v-card>
<v-card-title>
<v-btn color="primary" @click="addGoods">新增商品</v-btn>
<v-btn color="error" @click="deleteAllGoods">删除商品</v-btn>
<v-spacer></v-spacer>
<v-flex xs3>
状态:
<v-btn-toggle v-model="filter.saleable">
<!--<v-btn flat>-->
<!--全部-->
<!--</v-btn>-->
<v-btn flat :value="true">
上架
</v-btn>
<v-btn flat :value="false">
下架
</v-btn>
</v-btn-toggle>
</v-flex>
<v-text-field label="输入关键字搜索" class="flex sm3" append-icon="search" v-model.lazy="filter.search"></v-text-field>
</v-card-title>
<v-divider></v-divider>
<v-data-table
:headers="headers"
:items="goodsList"
:pagination.sync="pagination"
:total-items="totalGoods"
:loading="loading"
class="elevation-10"
select-all
v-model="selected"
>
<template slot="items" slot-scope="props">
<td class="text-xs-center">
<v-checkbox v-model="props.selected" primary hide-details>
</v-checkbox>
</td>
<td class="text-xs-center">{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.title }}</td>
<td class="text-xs-center">{{ props.item.cname}}</td>
<td class="text-xs-center">{{ props.item.bname }}</td>
<td class="justify-center layout">
<v-btn icon small @click="editGoods(props.item)">
<i class="el-icon-edit"/>
</v-btn>
<v-btn icon small @click="deleteItem(props.item.id)">
<i class="el-icon-delete"/>
</v-btn>
<v-btn icon small v-if="props.item.saleable" @click="soldOutPut(props.item.id)">下架</v-btn>
<v-btn icon small v-else @click="soldOutPut(props.item.id)">上架</v-btn>
</td>
</template>
<template slot="no-data">
<v-alert :value="true" color="error" icon="warning">
对不起,没有查询到任何数据 :(
</v-alert>
</template>
<template slot="pageText" slot-scope="props">
共{{props.itemsLength}}条,当前:{{ props.pageStart }} - {{ props.pageStop }}
</template>
</v-data-table>
<v-dialog max-width="900" v-model="show" persistent scrollable>
<v-card>
<!--对话框的标题-->
<v-toolbar dense dark color="primary">
<v-toolbar-title>{{isEdit ? '修改' : '新增'}}商品</v-toolbar-title>
<v-spacer/>
<!--关闭窗口的按钮-->
<v-btn icon @click="closeWindow"><v-icon>close</v-icon></v-btn>
</v-toolbar>
<!--对话框的内容,表单-->
<v-card-text class="px-5" style="height: 600px">
<my-goods-form @initStep="initStep" @close="close" :oldGoods="oldGoods" :step="step" :show="show" ref="goodsForm"/>
</v-card-text>
<v-card-actions>
<v-layout row justify-center>
<v-flex xs2>
<v-btn :disabled="step === 1" color="primary" @click="lastStep">上一步</v-btn>
</v-flex>
<v-flex xs2>
<v-btn :disabled="step === 4" color="primary" @click="nextStep">下一步</v-btn>
</v-flex>
</v-layout>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
// 导入自定义的表单组件
import MyGoodsForm from './MyGoodsForm'
export default {
name: "MyGoods",
data(){
return{
totalGoods:0, //总条数
goodsList: [], //当前页品牌数据
loading: true, //是否在加载中
headers: [// 表头
{text: 'id', align: 'center', value: 'id'},
{text: '标题', align: 'center', sortable: false, value: 'name'},
{text: '商品分类', align: 'center', sortable: false, value: 'image'},
{text: '品牌', align: 'center', value: 'letter', sortable: true,},
{text: '操作', align: 'center', value: 'id', sortable: false}
],
show: false, //控制对话框的显示
oldGoods : {}, //即将编辑的商品信息
isEdit : false, //是否被编辑
selected:[], //选择的条目
pagination:{}, //分页信息
filter :{
saleable: true, //上架还是下架
search: '', //搜索过滤字段
},
step:1, //子组件中的步骤索引,初始为1
}
},
watch:{
pagination:{
deep:true,
handler(){
this.getDataFromServer();
}
},
filter:{
deep:true,
handler(){
this.getDataFromServer();
}
}
},
created(){
this.getDataFromServer();
},
methods:{
close(){
this.show = false;
//重新获取数据
this.getDataFromServer();
//初始化弹窗
this.step = 1;
},
initStep(){
this.step = 1;
},
getDataFromServer(){ //从服务器加载数据
// 开启进度条
this.loading = true;
//发起ajax请求
// 分页查询page,rows,key,sortBy,desc
this.$http.get("/item/goods/spu/page",{
params:{
page:this.pagination.page, //当前页
rows:this.pagination.rowsPerPage, //每页大小
sortBy:this.pagination.sortBy, //排序字段
desc:this.pagination.descending, //是否降序
key:this.filter.search, //搜索条件
saleable:this.filter.saleable ===0 ? true: this.filter.saleable//上下架
}
}).then(resp =>{
this.goodsList=resp.data.items;
this.totalGoods = resp.data.total;
//关闭进度条
this.loading = false;
})
},
addGoods(){
//修改标记
this.isEdit = false;
//控制弹窗可见
this.show = true;
//把oldGoods变为null
this.oldGoods = null;
},
deleteAllGoods(){
const deleteGoodsId = this.selected.map(s => {
return s.id;
});
if (deleteGoodsId.length > 0){
this.$message.confirm("全部删除,不可恢复!").then(() => {
this.$http.delete("/item/goods/spu/"+deleteGoodsId.join("-")).then(() => {
this.getDataFromServer();
}).catch(() => {
this.$message.error("删除失败!");
})
}).catch(() => {
this.$message.info("删除取消!");
})
}
},
closeWindow(){
this.oldGoods = null;
//重新加载数据
this.getDataFromServer();
//关闭窗口
this.show = false;
},
lastStep(){
if (this.step > 1){
this.step--;
}
},
nextStep(){
if (this.$refs.goodsForm.$refs.basic.validate() && this.step < 4)
{
this.step++;
}
},
deleteItem(id){
const selectId = this.selected.map( s => {
return s.id;
});
if (selectId.length === 1 && selectId[0] === id) {
this.$message.confirm("删除后,不可恢复!").then(() => {
this.$http.delete("/item/goods/spu/"+id).then(() => {
this.getDataFromServer();
}).catch(() => {
this.$message.error("删除失败!");
})
}).catch(() => {
this.$message.info("删除取消!");
});
}
},
soldOutPut(id){
//修改商品的saleable = false
console.log(id);
this.$http.put("/item/goods/spu/out/"+id).then(() => {
this.$message.success("操作成功!");
this.getDataFromServer();
}).catch(() => {
this.$message.error("操作失败!");
});
},
editGoods(oldGoods){
this.oldGoods = oldGoods;
//构造商品分类
const cname=oldGoods.cname.split("/");
const categories=[];
categories.push({id:oldGoods.cid1,name:cname[0]});
categories.push({id:oldGoods.cid2,name:cname[1]});
categories.push({id:oldGoods.cid3,name:cname[2]});
this.oldGoods.categories = categories;
this.$http.get("/item/goods/spu/"+oldGoods.id).then(({data}) => {
this.isEdit = true;
this.oldGoods.skusList = data.skus;
this.oldGoods.spuDetail = data.spuDetail;
this.oldGoods.spuDetail.specialSpecs = JSON.parse(data.spuDetail.specTemplate);
this.oldGoods.spuDetail.specifications = JSON.parse(data.spuDetail.specifications);
//显示弹窗
this.show = true;
}).catch();
}
},
components:{
MyGoodsForm
}
}
</script>
<style scoped>
</style>
2.2.2 页面分析
data属性
-
goodsList:当前页商品数据
-
totalGoods:商品总数
-
headers:头信息,需要修改头显示名称(id、标题、商品分类、品牌、操作)
-
oldGoods:准备要修改的商品,用于数据回显
-
pagination:分页信息
-
filter:过滤条件,页面上包含两个过滤条件搜索和上下架按钮,把这两个过滤条件都放在一个对象中进行监听。所以搜索框绑定的字段是filter.search,按钮组绑定的字段是filter.saleable。
getDataFromServer
从服务器请求数据,请求路径为:/item/goods/spu/page,参数有6个:当前页、每页大小、排序字段(id)、是否降序、搜索条件、上下架。同品牌管理一样,这6个参数在后台也是封装起来的。
addGoods
新增商品
deleteAllGoods
删除选中的全部数据条目
soldOutPut
根据id修改商品的上下架状态
editGoods
商品修改,通过传入的oldGoods数据,将数据回显,然后再进行修改保存。
deleteItem
根据传入的条目id,删除对应的单条数据。
2.2.3 总结
因为本模块只是商品查询,所以其他相关函数在下一篇介绍,本篇主要介绍商品数据查询以及显示,所以涉及到的函数只有:getDataFromServer。现在前端请求已经就绪,就差后台接口了。
2.3 后台接口
2.3.1 POJO
SPU
package com.leyou.item.pojo;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.util.Date;
/**
* @author li
*/
@Table(name = "tb_spu")
public class Spu {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long brandId;
/**
* 1级类目
*/
private Long cid1;
/**
* 2级类目
*/
private Long cid2;
/**
* 3级类目
*/
private Long cid3;
/**
* 标题
*/
private String title;
/**
* 子标题
*/
private String subTitle;
/**
* 是否上架
*/
private Boolean saleable;
/**
* 是否有效,逻辑删除使用
*/
private Boolean valid;
/**
* 创建时间
*/
private Date createTime;
/**
* 最后修改时间
*/
private Date lastUpdateTime;
//省略get和set方法
}
SPU详情
package com.leyou.item.pojo;
import javax.persistence.Id;
import javax.persistence.Table;
/**
* @author li
*/
@Table(name="tb_spu_detail")
public class SpuDetail {
@Id
/**
* 对应的SPU的id
*/
private Long spuId;
/**
* 商品描述
*/
private String description;
/**
* 商品特殊规格的名称及可选值模板
*/
private String specTemplate;
/**
* 商品的全局规格属性
*/
private String specifications;
/**
* 包装清单
*/
private String packingList;
/**
* 售后服务
*/
private String afterService;
//省略get和set方法
}
2.3.2 Controller
-
请求方式:GET
-
请求路径:/spu/page
-
请求参数:
-
page:当前页
-
rows:每页大小
-
sortBy:排序字段
-
desc:是否降序
-
key:过滤条件
-
saleable:上架或下架
-
-
返回结果:商品SPU的分页信息。
需要注意的一点是,在返回结果中,商品的分类和商品的品牌都是以id的形式存在的,但是前端页面要显示具体的信息。所以在这里需要新建一个类,继承SPU,并且在原有基础上进行属性拓展cname和bname,即具体的商品分类名称和品牌名称。
public class SpuBo extends Spu {
String cname;// 商品分类名称
String bname;// 品牌名称
// 略 。。
}
/**
* 分页查询
* @param page
* @param rows
* @param sortBy
* @param desc
* @param key
* @param saleable
* @return
*/
@GetMapping("/spu/page")
public ResponseEntity<PageResult<SpuBo>> querySpuByPage(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "rows", defaultValue = "5") Integer rows,
@RequestParam(value = "sortBy", required = false) String sortBy,
@RequestParam(value = "desc", defaultValue = "false") Boolean desc,
@RequestParam(value = "key", required = false) String key,
@RequestParam(value = "saleable",defaultValue = "true") Boolean saleable){
SpuQueryByPageParameter spuQueryByPageParameter = new SpuQueryByPageParameter(page,rows,sortBy,desc,key,saleable);
//分页查询spu信息
PageResult<SpuBo> result = this.goodsService.querySpuByPageAndSort(spuQueryByPageParameter);
if (result == null){
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
}
return ResponseEntity.ok(result);
}
2.3.3 Service
接口
/**
* 分页查询
* @param spuQueryByPageParameter
* @return
*/
PageResult<SpuBo> querySpuByPageAndSort(SpuQueryByPageParameter spuQueryByPageParameter);
实现类
/**
* 分页查询
* @param spuQueryByPageParameter
* @return
*/
@Override
public PageResult<SpuBo> querySpuByPageAndSort(SpuQueryByPageParameter spuQueryByPageParameter) {
//1.查询spu,分页查询,最多查询100条
PageHelper.startPage(spuQueryByPageParameter.getPage(),Math.min(spuQueryByPageParameter.getRows(),100));
//2.创建查询条件
Example example = new Example(Spu.class);
Example.Criteria criteria = example.createCriteria();
//3.条件过滤
//3.1 是否过滤上下架
if (spuQueryByPageParameter.getSaleable() != null){
System.out.println(spuQueryByPageParameter.getSaleable());
criteria.orEqualTo("saleable",spuQueryByPageParameter.getSaleable());
}
//3.2 是否模糊查询
if (StringUtils.isNotBlank(spuQueryByPageParameter.getKey())){
criteria.andLike("title","%"+spuQueryByPageParameter.getKey()+"%");
}
//3.3 是否排序
if (StringUtils.isNotBlank(spuQueryByPageParameter.getSortBy())){
System.out.println(spuQueryByPageParameter.getSortBy());
example.setOrderByClause(spuQueryByPageParameter.getSortBy()+(spuQueryByPageParameter.getDesc()? " DESC":" ASC"));
}
Page<Spu> pageInfo = (Page<Spu>) this.spuMapper.selectByExample(example);
//将spu变为spubo
List<SpuBo> list = pageInfo.getResult().stream().map(spu -> {
SpuBo spuBo = new SpuBo();
//1.属性拷贝
BeanUtils.copyProperties(spu,spuBo);
//2.查询spu的商品分类名称,各级分类
List<String> nameList = this.categoryService.queryNameByIds(Arrays.asList(spu.getCid1(),spu.getCid2(),spu.getCid3()));
//3.拼接名字,并存入
spuBo.setCname(StringUtils.join(nameList,"/"));
//4.查询品牌名称
Brand brand = this.brandMapper.selectByPrimaryKey(spu.getBrandId());
spuBo.setBname(brand.getName());
return spuBo;
}).collect(Collectors.toList());
return new PageResult<>(pageInfo.getTotal(),list);
}
2.3.4 Mapper
package com.leyou.item.mapper;
import com.leyou.item.pojo.Spu;
import tk.mybatis.mapper.common.Mapper;
/**
* @Author: 98050
* Time: 2018-08-14 22:14
* Feature:
*/
@org.apache.ibatis.annotations.Mapper
public interface SpuMapper extends Mapper<Spu> {
}
2.3.5 Category中拓展查询名称的功能
页面需要商品的分类名称需要在这里查询,因此要额外提供查询分类名称的功能,
在CategoryService中添加功能:
接口
/**
* 根据ids查询名字
* @param asList
* @return
*/
List<String> queryNameByIds(List<Long> asList);
实现类
/**
* 根据ids查询名字
* @param asList
* @return
*/
@Override
public List<String> queryNameByIds(List<Long> asList) {
List<String> names = new ArrayList<>();
if (asList != null && asList.size() !=0){
for (Long id : asList) {
names.add(this.categoryMapper.queryNameById(id));
}
}
return names;
//使用通用mapper接口中的SelectByIdListMapper接口查询
//return this.categoryMapper.selectByIdList(asList).stream().map(Category::getName).collect(Collectors.toList());
}
因为是把cid1、cid2、cid3放入list中作为参数传入,即根据id数组来查询,所以有两种解决方法:
1. 遍历id数组,每次查询一条记录,将每次得到的结果都存入list中最后返回即可。
上边的代码就是采用这种方法,所以需要自己在categoryMapper中新增方法queryByNameById:
/**
* 根据id查名字
* @param id
* @return
*/
@Select("SELECT name FROM tb_category WHERE id = #{id}")
String queryNameById(Long id);
2. 继承通用mapper的SelectByIdListMapper方法,一次性查出返回。
CategoryService中queryNameByIds方法新的实现类
public List<String> queryNameByIds(List<Long> ids) {
return this.categoryMapper.selectByIdList(ids).stream().map(Category::getName).collect(Collectors.toList());
}
mapper的selectByIDList方法是来自于通用mapper。不过需要我们在mapper上继承一个通用mapper接口:
public interface CategoryMapper extends Mapper<Category>, SelectByIdListMapper<Category, Long> {
// ...coding
}