后台商品服务 - 三级分类
1、查询
1)、接口编写
一级分类查出二级分类数据,二级分类中查询出三级分类数据
数据库表设计
DROP TABLE IF EXISTS `pms_category`;
CREATE TABLE `pms_category` (
`cat_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '分类id',
`name` char(50) DEFAULT NULL COMMENT '分类名称',
`parent_cid` bigint(20) DEFAULT NULL COMMENT '父分类id',
`cat_level` int(11) DEFAULT NULL COMMENT '层级',
`show_status` tinyint(4) DEFAULT NULL COMMENT '是否显示[0-不显示,1显示]',
`sort` int(11) DEFAULT NULL COMMENT '排序',
`icon` char(255) DEFAULT NULL COMMENT '图标地址',
`product_unit` char(50) DEFAULT NULL COMMENT '计量单位',
`product_count` int(11) DEFAULT NULL COMMENT '商品数量',
PRIMARY KEY (`cat_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1433 DEFAULT CHARSET=utf8mb4 COMMENT='商品三级分类';
三级分类接口编写
排序使用的是 Comparator 接口中的静态方法 comparing()
排序后,根据age排序 Collections.sort(models, Comparator.comparing(Model::getAge)); //Collections.sort(Comparator.comparing(Model::getAge)); 排序后,根据age排倒序 Collections.sort(models, >Comparator.comparing(Model::getAge).reversed());
先在实体类里面添加一个属性,用于收集子分类
@Data
@TableName("pms_category")
public class CategoryEntity implements Serializable {
private static final long serialVersionUID = 1L;
...
//因为此属性表中没有对应字段,需要使用注解 @TableField(exist = false) 指明
@TableField(exist = false)
private List<CategoryEntity> children;
}
接口 CategoryController
/**
* 查询出所有分类以及子分类,以树形结构组装起来
*/
@RequestMapping("/list/tree")
public R list(){
List<CategoryEntity> entities = categoryService.listWithTree();
return R.ok().put("data", entities);
}
业务类 CategoryService
// 返回查询所有分类以及子子分类,以树形结构组装起来
List<CategoryEntity> listWithTree();
实现类 CategoryServiceImpl
@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {
@Override
public List<CategoryEntity> listWithTree() {
// 1 查出所有分类(此处可以使用dao,也可以使用MyBatisPlus提供的BaseMapper,因为此处实现了ServiceImpl类)
List<CategoryEntity> entities = baseMapper.selectList(null);
// 2 组装成父子的树形结构
List<CategoryEntity> levelMenus = entities.stream()
// 2.1、找到所有一级分类(parent_cid为0的就是一级分类)
.filter(entity -> entity.getParentCid() == 0)
// 2.2、递归方法得到一级分类的二级、三级分类
.map(menu -> {
menu.setChildren(getMenuChildrens(menu,entities));
return menu;
})
// 2.3、根据sort属性排序(因为是包装类型,所以应该用equals方法比较)
.sorted(Comparator.comparing(entity -> Optional.ofNullable(entity.getSort()).orElse(0)))
//这里应该用equals方法比较,用==的话id超过127的话java会认为不相等
//.sorted((menu1, menu2) -> (menu1.getSort()==null ? 0:menu1.getSort()) - (menu2.getSort()==null ? 0:menu2.getSort()))
.collect(Collectors.toList());
return levelMenus;
}
/**
* 递归查询子分类
* @param root 当前category对象
* @param all 全部分类数据
* @return 返回当前传入category对象及其子二级、三级分类等等
*/
private List<CategoryEntity> getMenuChildrens(CategoryEntity root, List<CategoryEntity> all) {
List<CategoryEntity> childrens = all.stream()
// 遍历所有的category对象的父类id = 等于root的分类id 说明是他的子类
.filter(entity -> entity.getParentCid().equals(root.getCatId()))
// 对子类进行查找子子类
.peek(menu -> menu.setChildren(getMenuChildrens(menu,all)))
// 根据sort属性排序(因为是包装类型,所以应该用equals方法比较)
.sorted(Comparator.comparing(entity -> Optional.ofNullable(entity.getSort()).orElse(0)))
//这里应该用equals方法比较,用==的话id超过127的话java会认为不相等
//.sorted((menu1, menu2) -> (menu1.getSort()==null ? 0:menu1.getSort()) - (menu2.getSort()==null ? 0:menu2.getSort()))
.collect(Collectors.toList());
return childrens;
}
}
这里在改变流中元素内部结构时,使用的是 map() ,但是代码提示可以使用 peek() 替换
对此查阅了一下peek()的使用
Stream<T> peek(Consumer<? super T> action);
peek() vs map()
peek 方法接收一个Consumer的入参,是没有返回值的
而 map 方法的入参为 Function,有返回值
但是此处我使用 peek 代替 map 并没有影响到结果
peek() vs forEach()
forEach() 是一个最终操作。除此之外,peek() 和 forEach() 再无其他不同
IDEA 里面提示一般用于调试 Debug 阶段,用来打印出流经管道的元素Stream.of("one", "two", "three", "four") .filter(e -> e.length() > 3) //打印出流经管道的元素 .peek(e -> System.out.println("Filtered value: " + e)) // .map(String::toUpperCase) .peek(e -> System.out.println("Mapped value: " + e)) .collect(Collectors.toList());
执行结果
2)、树形展示三级分类数据
先启动 renren-fast、renren-vue (在项目路径执行cmd,然后执行 npm run dev),打开并登录人人快速开发平台页面
在菜单管理里面添加 “ 商品系统 ” 的一级目录,并在此下面添加 “ 分类维护 ” 的二级目录
可以看到添加的数据都会存储到 数据库 gulimall-admin 对应的表中
从目录栏里面点击 “ 分类维护 ” 或者其他菜单,发现访问的路径都是 xxx-xxx
可以在项目路径 renren-fast-vue\src\views\modules 里面找到对应的 vue 文件
比如系统管理下的管理员列表路径是 sys-user,可以在 renren-fast-vue\src\views\modules 里面找到 sys 目录下有 user.vue
所以对于 “ 分类维护 ” 菜单我们在 renren-fast-vue\src\views\modules 路径下创建目录 product,然后在目录里创建 category.vue 文件,进行代码前端编写
树形展示数据
,会用到的前端组件 Tree 树形控件。打开 ElementUI官网 ,参考里面的基础用法,在文件中写入以下内容
<template>
<el-tree :data="menus" :props="defaultProps" @node-click="handleNodeClick"></el-tree>
</template>
<script>
//这里可以导入其他文件(比如:组件,工具 js,第三方插件 js,json文件,图片文件等等)
//例如:import 《组件名称》 from '《组件路径》';
export default {
//import 引入的组件需要注入到对象中才能使用
components: {},
props: {},
//这里存放数据
data() {
return {
menus: [],
defaultProps: {
//子目录里显示是使用数据库中的 children 属性
children: 'children',
// 显示的标签内容,数据库中是name属性
label: 'name'
}
};
},
//方法集合
methods: {
handleNodeClick(data) {
console.log(data);
},
getMenus() {
this.$http(
// 请求接口见上
url: this.$http.adornUrl("/product/category/list/tree")
method: "get"
}).then(({ data }) =>
console.log("返回的菜单数据" + data.data)
this.menus = data.data;
});
}
},
//计算属性 类似于 data 概念
//computed: {},
//监控 data 中的数据变化
//watch: {},
//生命周期 - 创建完成(可以访问当前 this 实例)
created() {
this.getMenus();
},
//生命周期 - 挂载完成(可以访问 DOM 元素)
mounted() {},
beforeCreate() {}, //生命周期 - 创建之前
beforeMount() {}, //生命周期 - 挂载之前
beforeUpdate() {}, //生命周期 - 更新之前
updated() {}, //生命周期 - 更新之后
beforeDestroy() {}, //生命周期 - 销毁之前
destroyed() {}, //生命周期 - 销毁完成
activated() {}, //如果页面有 keep-alive 缓存功能,这个函数会触发
};
</script>
<style scoped>
</style>
内容分析:
tree树形结构标签
<el-tree :data="menus" :props="defaultProps" ></el-tree>
<!--
data 展示数据(因为下面定义了menus属性名,在此引用)
props 配置选项
children 指定子树为节点对象的某个属性值
label 指定节点标签为节点对象的某个属性值
disabled 节点选择框是否禁用为节点对象的某个属性值
@node-click 节点被点击时的回调
-->
编写方法获取全部菜单数据
参考其他vue文件里面的代码,发现其中的数据来源是在 method 属性里面声明方法,然后在生命周期函数里面进行调用
getMenus() {
this.$http({
// 请求接口见上面
url: this.$http.adornUrl("/product/category/list/tree"),
method: "get"
}).then(({ data }) => {
console.log("返回的菜单数据" + data.data);
this.menus = data.data;
});
}
3)、配置网关路由
但是此时运行会发现有问题:
点击 “分类维护” 菜单,发现访问的地址是 http://localhost:8080/renren-fast/product/category/list/tree
由此发现项目里面设置的 base路径 是 8080/renren-fast/
在 \renren-fast-vue\static\config 目录下的 index.js 文件中可以看到以下代码
// api接口请求地址
window.SITE_CONFIG['baseUrl'] = 'http://localhost:8080/renren-fast';
对此处理方法:
1 更改前端 base 路径
将 base 路径设置为网关模块地址,然后在网关模块的配置文件中配置断言路由,当访问哪些路径时会转向 renren-fast,某些路径会转向 product 模块
base路径更改如下:
// api接口请求地址
window.SITE_CONFIG['baseUrl'] = 'http://localhost:88/api';
2 将服务注册进nacos
需要将 renren-fast
、gulimall-product
、gulimall-gateway
注册进注册中心,所以来三件套:
-
加入 gulimall-common 模块的依赖(内含 nacos注册中心依赖)
-
配置文件中 指定服务名称、注册中心地址
gulimall-product 配置文件spring: # nacos application: name: gulimall-product cloud: nacos: discovery: server-addr: 127.0.0.1:8848
renren-fast 配置文件
server: servlet: context-path: /renren-fast spring: # nacos application: name: renren-fast cloud: nacos: discovery: server-addr: 127.0.0.1:8848
-
启动类上加上注册发现注解 @EnableDiscoveryClient
网关模块 gulimall-gateway 的配置文件如下:
注意:
因为 gulimall-gateway 里面引入了 gulimall-common,这里面有引入数据源的相关配置,但是 gateway 模块中没有使用到数据库,没有配置数据库相关信息,就会报错
解决:
在启动类中排除依赖@SpringBootApplication(exclude =DataSourceAutoConfiguration.class)
注意:
当使用网关路由的时候发现,uri 是具体地址时没问题,但结合nacos动态路由就会 404,
因为SpringCloud从2020版本开始就不再支持Ribbon了,所以当我们使用网关时需要在网关服务上加上Ribbon依赖
解决:<!-- 由于Nacos2020版之后不支持Ribbon所以通过服务名访问路由将会失败,需要引入以下依赖 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-loadbalancer</artifactId> </dependency>
(本项目中 renren-fast 里设置了 contextPath ,且与注册中心服务名一致,处理方式是将前端的 base 路径改为
88:/api
,后端路由过滤条件是将/api/**
跳转至/renren-fast/**
,那么访问 88:/api/test 就会路由至 8080:/renren-fast/test)
注意:当从 88 直接路由到 8080 没啥问题,但当 8080 服务设置了 contextPath ,就会出现问题
解决:
更改 contextPath 名称与注册中心里面的服务名不一致,如:服务名test-product,路径 /product
网关配置 :spring: cloud: nacos: discovery: server-addr: 127.0.0.1:8848 gateway: discovery: locator: # 开启从注册中心动态创建路由的功能,使用nacos中的服务名进行路由 enabled: true #表示将请求路径的服务名配置改成小写 ,因为服务注册的时候,向注册中心注册时将服务名转成大写的了 lowerCaseServiceId: true filters: - StripPrefix=0 routes: - id: admin_route # 使用了lb形式,从注册中心负载均衡的获取uri uri: lb://test-product predicates: # 此处原本的路径是 8080:/product/product/category/list/tree # 其中 /product 是 contextPath # 现在浏览器访问 88:/product/product/category/list/tree 即可跳转 - Path=/product/product/category/list/tree
3 网关模块配置路由
网关模块 gulimall-gateway 的配置如下:
pom.xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.8.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Greenwich.SR3</spring-cloud.version>
</properties>
<dependencies>
<!-- 于Nacos2020版之后不支持Ribbon所以通过服务名访问路由将会失败,需要引入以下依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
<version>2.2.6.RELEASE</version>
</dependency>
<dependency>
<groupId>afei.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
配置文件
server:
port: 88
spring:
application:
name: gulimall-gateway
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
gateway:
discovery:
locator:
# 开启从注册中心动态创建路由的功能,使用nacos中的服务名进行路由
enabled: true
routes:
- id: admin_route
# 使用了lb形式,从注册中心负载均衡的获取uri
uri: lb://renren-fast
predicates:
- Path=/api/**
filters:
# 将 /api/** 路由到 /renren-fast/** ,该人人服务设置了context-path: /renren-fast
- RewritePath=/api/(?<segment>.*),/renren-fast/$\{segment}
主启动类
@EnableDiscoveryClient
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class GulimallGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallGatewayApplication.class, args);
}
}
4 测试
现在启动 renren-fast 、renren-vue、gulimall-gateway
打开首页,发现发送验证码的请求路由成功
4)、解决跨域
虽然验证码发送成功,但是我们发现登录出错
当点击一次登录,但是后台却发送了两次登录请求,是因为跨域访问流程 就是浏览器发请求都要事先发送一个请求询问是否可以进行通信
跨域问题描述:
解决跨越( 一 ) 使用nginx部署为同一域
开发过于麻烦,上线再使用
解决跨域 ( 二 )配置当次请求允许跨域
在 gulimall-gateway 模块里面设置跨域问题
@Configuration
public class GulimallCorsConfiguration {
//配置跨域
@Bean
public CorsWebFilter corsWebFilter(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.setAllowCredentials(true);
source.registerCorsConfiguration("/**",corsConfiguration);
return new CorsWebFilter(source);
}
}
设置完之后启动项目,点击登录,发现问题,描述如下:
登录请求里面处理了两遍跨域问题
登录请求里面处理了两遍跨域问题,发现 renren-fast 模块里面也设置了跨域,因为请求先发送到网关,网关再转发给其他服务 ,所以注释掉 renren-fast 模块里面的跨域设置
成功登录进renren系统之后,开始配置三级分录的地址路由,如下
注意,当 gateway 网关配置
多个路由
,会有先后执行的顺序
,如果路由范围大的断言路径在前面,就会访问不到后面范围小的断言路径
routes:
- id: product_route
uri: lb://gulimall-product
# 此处断言路径范围较小,需要写在前面,否则报错404
predicates:
- Path=/api/product/**
filters:
# 将 /api/** 路由到 /**
- RewritePath=/api/(?<segment>.*),/$\{segment}
- id: admin_route
# 使用了lb形式,从注册中心负载均衡的获取uri
uri: lb://renren-fast
predicates:
- Path=/api/**
filters:
# 将 /api/** 路由到 /renren-fast/** ,该人人服务设置了context-path: /renren-fast
- RewritePath=/api/(?<segment>.*),/renren-fast/$\{segment}
2、删除
1)、接口编写逻辑删除
在 MyBatis-Plus 官网 里面有逻辑删除使用指南
修改配置文件:
-
配置全局的逻辑删除规则(默认1已删除,0未删除,可省略)
mybatis-plus: mapper-locations: classpath:/mapper/**/*.xml global-config: db-config: id-type: auto #\u8BBE\u7F6E\u4E3B\u952E\u81EA\u589E # 配置全局逻辑删除规则 logic-delete-value: 1 #默认为1 logic-not-delete-value: 0 #默认为0 # 打印 SQL 日志 logging: level: afei.product: debug
-
配置逻辑删除的组件Bean(3.1.1版本开始不需要这一步)
-
实体类加上逻辑删除注解@TableLogic (注解里可配置逻辑删除规则)
/** * 是否显示[0-不显示,1显示] * @TableLogic(value = "1",delval = "0") 逻辑删除注解 * value属性指明未删除要显示,delval表示已删除不显示 */ @TableLogic(value = "1",delval = "0") private Integer showStatus;
修改接口:
/**
* 逻辑删除
* @RequestBody:获取请求体,必须发送post请求才有 get请求没有
* SpringMvc 自动将请求体的数据 ( json ) 转为对应的对象
*/
@RequestMapping("/delete")
public R delete(@RequestBody Long[] catIds){
//categoryService.removeByIds(Arrays.asList(catIds));
categoryService.removeMenusByIds(Arrays.asList(catIds));
return R.ok();
}
// 逻辑批量删除分类
void removeMenusByIds(List<Long> asList);
@Override
public void removeMenusByIds(List<Long> asList) {
//TODO 1、检查当前删除的菜单,是否被别的地方应用
// 逻辑删除
baseMapper.deleteBatchIds(asList);
}
TODO 可以在这里查看
测试: 需要打开 postman
注意是 POST 请求,且请求参数需要是 json 格式
控制台打印的 sql 语句
2)、删除页面效果
此处还是使用 ElementUI官网 ,参考里面的自定义节点内容,使用 scoped slot
在 el-tree
标签里面添加以下内容
<template>
<el-tree :data="menus" :props="defaultProps"
:expand-on-click-node="false"
show-checkbox
node-key="catId"
:default-expanded-keys="expandedKey"
>
<span class="custom-tree-node" slot-scope="{ node, data }">
<span>{{ node.label }}</span>
<span>
<el-button v-if="node.level <=2" type="text" size="mini" @click="() => append(data)"> Append</el-button>
<el-button v-if="node.childNodes.length == 0" type="text" size="mini" @click="() => remove(node, data)"> Delete</el-button>
</span>
</span>
</el-tree>
</template>
说明:
1、 :expand-on-click-node="false"
前面的:是 v-bind: 缩写
expand-on-click-node 表示是否在点击节点的时候展开或者收缩节点, 默认值为 true
如果为 false,则只有点箭头图标的时候才会展开或者收缩节点
2、 show-checkbox
表示节点是否可被选择
3、 node-key="catId"
每个树节点用来作为唯一标识的属性,整棵树应该是唯一的
4、 :default-expanded-keys="expandedKey"
表示默认展开节点的 key 数组,这里使用的是自定义参数expandedKey
删除按钮:
<el-button
v-if="node.childNodes.length == 0"
type="text"
size="mini"
@click="() => remove(node, data)"
> Delete</el-button>
<!--
v-if="node.childNodes.length == 0" 没有子节点可以删除,子节点数组长度为0
type 对应类型
size 大小
@click 点击后出发的方法 此处使用了 箭头函数,会触发remove函数
-->
添加按钮:
<el-button
v-if="node.level <=2"
type="text" size="mini"
@click="() => append(data)"
> Append</el-button>
<!--
v-if="node.level <=2" 表示节点层级小于等于2
-->
在 data(){}
存放数据的位置添加以下内容:
//这里存放数据
data() {
return {
//接收页面显示的所有目录数据
menus: [],
//使用自定义参数expandedKey,来表示表示默认展开节点的key数组
expandedKey: [],
defaultProps: {
children: 'children',
label: 'name'
}
};
},
在方法集合 methods: {}
里面添加删除方法
取消不需要任何操作,所以里面没有代码,但是如果删除浏览器会报错
注意:
点击确定删除之后需要刷新菜单 this.getMenus();
实现删除菜单之后打开这个菜单的父菜单列表
//删除点击的时候就会触发这个函数
remove(node, data) {
//消息提示模板
//点击确定之后会调用 then() ,点击取消会调用 catch()
this.$confirm(`是否删除【${data.name}】菜单 ? `, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
})
.then(() => {
// 拿到当前节点的catId
var ids = [data.catId];
this.$http({
url: this.$http.adornUrl("/product/category/delete"),
method: "post",
data: this.$http.adornData(ids, false)
}).then(({ data }) => {
this.$message({
message: "菜单删除成功",
type: "success"
});
// 刷新菜单
this.getMenus();
// 设置默认需要展开的菜单,实现删除菜单之后打开这个菜单的父菜单列表
/**
default-expanded-keys 默认展开节点的 key 数组
*/
this.expandedKey = [node.parent.data.catId];
console.log(node.parent.data.catId);
});
})
.catch(() => {});
console.log("remove", node, data);
}
3、新增页面效果
此处用到的前端组件 Dialog 对话框。打开 ElementUI官网 ,参考里面的自定义对话框,在文件中写入以下内容
在 el-tree
标签里面添加以下内容
添加的按钮,层级小于2 才能新增
<el-button v-if="node.level <=2" type="text" size="mini" @click="() => append(data)"> Append</el-button>
在 template
标签内部, el-tree
标签同级下面添加以下内容
<el-dialog
:title="title"
:visible.sync="dialogVisible"
width="30%"
:before-close="handleClose"
:close-on-click-modal="false">
<!--
:title="title"
实现动态修改对话框标题
title为自定义参数
:visible.sync="dialogVisible"
设置visible属性,它接收Boolean,当为true时显示 Dialog
dialogVisible为自定义参数
-->
<el-form :model="category">
<el-form-item label="分类名称">
<el-input v-model="category.name" autocomplete="off"></el-input>
</el-form-item>
<el-form-item label="图标">
<el-input v-model="category.icon" autocomplete="off"></el-input>
</el-form-item>
<el-form-item label="计量单位">
<el-input
v-model="category.productUnit"
autocomplete="off"
></el-input>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="addCategory">确 定</el-button>
</span>
</el-dialog>
在 data(){}
存放数据的位置添加以下内容:
//动态修改对话框标题
title: "",
//初始化添加的分类数据
category: {
name: "",
parentCid: 0,
catLevel: 0,
showStatus: 1,
sort: 0,
productUnit: "",
icon: "",
catId: null
},
//对话框默认关闭,当点击按钮时打开对话框
dialogVisible: false,
在方法集合 methods: {}
里面添加方法
append(data) {
this.dialogVisible = true; //值为true,表示打开添加的对话框
this.title = "添加分类";
this.category.parentCid = data.catId;
this.category.catLevel = data.catLevel * 1 + 1;
console.log("append", data);
},
// 添加三级分类
addCategory() {
console.log("添加三级分类的数据", this.category);
this.$http({
url: this.$http.adornUrl("/product/category/save"),
method: "post",
data: this.$http.adornData(this.category, false)
}).then(({ data }) => {
this.$message({
message: "菜单保存成功",
type: "success"
});
// 关闭对话框
this.dialogVisible = false; //值为false,表示关闭添加的对话框
// 重新刷新数据
this.getMenus();
// 默认展开的菜单
this.expandedKey = [this.category.parentCid];
});
},
4、修改
1)、修改页面基本效果
添加修改按钮
<el-button type="text" size="mini" @click="edit(data)">edit</el-button>
在方法集合 methods: {}
里面添加方法
设置修改时回显的数据(直接发送查询请求来获取),初始化标题等
// 要修改的数据
edit(data) {
console.log("要修改的数据", data);
this.dialogType = "edit";
this.title = "修改分类";
this.dialogVisible = true;
// 发送查询请求获取最新的数据
this.$http({
url: this.$http.adornUrl(`/product/category/info/${data.catId}`), //注意这里时单引号,因为要动态获取id
method: "get"
}).then(({ data }) => {
// 请求成功
console.log("要回显的数据", data);
this.category.name = data.category.name;
this.category.catId = data.category.catId;
this.category.icon = data.category.icon;
this.category.productUnit = data.category.productUnit;
this.category.parentCid = data.category.parentCid;
});
},
因为添加和修改都是使用同一个对话框,所以提供自定义参数 dialogType
,来区分是添加操作还是修改操作
在 data(){}
存放数据的位置添加以下内容:
dialogType: "", //规定对话框的类型,修改还是添加
修改对话框里面的 确定 按钮
<el-button type="primary" @click="submitData">确 定</el-button>
在方法集合 methods: {}
里面添加方法
判断打开对话框之后是添加操作还是修改操作
submitData() {
if (this.dialogType == "add") {
// 进行新增
this.addCategory();
}
if (this.dialogType == "edit") {
// 进行修改
this.editCategory();
}
},
真实的修改操作
// 修改三级分类数据
editCategory() {
// 解构出单独的几个对象 用来提交,因为仅修改这些数据,其他属性不修改提交
var { catId, name, icon, productUnit } = this.category;
this.$http({
url: this.$http.adornUrl("/product/category/update"),
method: "post",
data: this.$http.adornData({ catId, name, icon, productUnit }, false) //如果第一个参数不是这个变量,那么sql语句也不一样,就会是全部修改
}).then(({ data }) => {
this.$message({
message: "菜单修改成功",
type: "success"
});
// 关闭对话框
this.dialogVisible = false;
// 重新刷新数据
this.getMenus();
// 默认展开的菜单
this.expandedKey = [this.category.parentCid];
});
},
注意解构出单独的几个对象 用来提交,因为仅修改这些数据,其他属性不修改提交
如果上面方法的参数直接写入 this.category ,那么 sql 语句里面修改的字段就是 data 里面初始化的所有字段
此时发现点击修改按钮,再点击添加会发现输入框仍然回显数据
需要修改 真实添加方法之前 里面的逻辑,进行清空输入框
// 添加准备
append(data) {
this.dialogVisible = true; //值为true,表示打开添加的对话框
console.log("append", data);
this.dialogType = "add";
this.title = "添加分类";
this.category.parentCid = data.catId;
this.category.catLevel = data.catLevel * 1 + 1;
//清空输入框
this.category.name = "";
this.category.catId = null;
this.category.icon = "";
this.category.productUnit = "";
},
注意:
- 在点击添加按钮时要把表单的数据进行清除,否则第二次打开任然会有上次表单提交剩下的数据
this.category.name = ""
; - 修改和新增用的是同一个表单,因此在方法对话框中 动态的绑定了
:title="title"
标题 用于显示是新增还是修改 - 一个表单都是一个提交方法 因此在提交方法的时候进行了判断,根据变量赋值决定调用那个方法 this.dialogType = “add”; this.dialogType = “edit”;
2)、拖拽功能
前端用的组件 Tree 树形控件 可拖拽节点,参考 ElementUI官网
需求:
- 拖拽的时候并不会更新数据库,只有点击批量保存的时候才会更新数据库
- 保存之后,需要更新的节点数组会清空,里面会存放新一批需要更新的节点数组
- 点击批量保存之后,会默认打开被修改过的父节点目录
el-tree
标签内容如下
<el-tree
:expand-on-click-node="false"
:data="menus"
:props="defaultProps"
show-checkbox
node-key="catId"
:default-expanded-keys="expandedKey"
:draggable="draggable"
:allow-drop="allowDrop"
@node-drop="handleDrop"
ref="menuTree"
>
<!--
:expand-on-click-node 是否在点击节点的时候展开或者收缩节点 默认 true
如果为 false,则只有点箭头图标的时候才会展开或者收缩节点。
show-checkbox 表示节点是否可被选择
node-key 每个树节点用来做唯一标识的属性
default-expanded-keys 表示默认展开节点的 key 数组,这里使用的是自定义参数
draggable 表示是否可以被拖拽 true&false,这里使用的是自定义参数
allow-drop 拖拽时判定目标节点能否被放置
node-drop 拖拽成功完成后触发的事件
ref 该组件tree的引用
-->
在 template
标签内部, el-tree
标签同级下面添加批量保存、开启拖拽
<el-switch v-model="draggable" active-text="开启拖拽" inactive-text="关闭拖拽"></el-switch>
<el-button v-if="draggable" @click="batchSave">批量保存</el-button>
在 data(){}
存放数据的位置添加以下内容:
ppCid: [], //当前拖拽节点的最新父节点,因为是批量保存,所以会有多个父节点
draggable: false, 使得开关拖拽按钮、保存按钮的显示、是否可以拖拽都在统一状态
updateNodes: [], //拖拽成功之后需要修改的节点
maxLevel: 1, //初始化最大层级,0或1都可以,只需要是小于等于最小层级数字即可
1 拖拽后能否被放置
:allow-drop="allowDrop"
表示拖拽时判定目标节点能否被放置
draggingNode:被拖拽节点对应的 Node
dropNode:结束拖拽时最后进入的节点
type :有三种值:‘prev’、‘inner’ 和 ‘next’,分别表示放置在目标节点前、插入至目标节点和放置在目标节点后
// 拖拽时判定目标节点能否被放置
// 需要判定被拖动的当前节点以及所在的父节点总层次不能大于3
allowDrop(draggingNode, dropNode, type) {
// 1、被拖动节点的最大层数(比如拖拽的是2级,且有3级子节点,则是3)
console.log("allowDrop", draggingNode, dropNode, type);
this.countNodeLevel(draggingNode.data);
console.log("被拖动节点的最大层数", this.maxLevel);
// 2、deep 是当前拖拽的节点所携带的深度
/*
当前拖拽的节点所携带的深度解释:
比如拖拽的是2级,且有3级子节点,则携带的深度是2;
比如拖拽的是 1 级,且有2级子节点,则携带的深度是2;
比如拖拽的是1级,且携带3级子节点,则携带的深度是3
*/
let deep = Math.abs(this.maxLevel - draggingNode.level) + 1;
console.log("深度", deep);
// 3、当前正在拖动的节点 + 父节点所在的深度不大于3即可
if (type == "inner") {
return deep + dropNode.level <= 3;
} else {
return deep + dropNode.parent.level <= 3;
}
},
//求当前点击拖拽的节点里面的最大层级数
/*
此处有疑问?
假如此处节点是1级, node.childNodes 数组里面的 catLevel 的排列是【2,3,3,2,2】
那么最后面查出来的 2 赋值给 this.maxLevel 时不会覆盖前面赋值的 3 吗?
*/
countNodeLevel(node) {
// 找到所有子节点,求出最大层数
if (node.childNodes != null && node.childNodes.length > 0) {
for (let i = 0; i < node.childNodes.length; i++) {
// data.catLevel 表示数据库中的层级数,这里catLevel和level值一致,都可用
if (node.childNodes[i].level > this.maxLevel) {
this.maxLevel = node.childNodes[i].level;
}
// 递归查找
this.countNodeLevel(node.childNodes[i]);
}
}
//如果没有子节点,则自身层数便是最大层数
this.maxLevel = node.level;
},
2 拖拽后收集需要修改的数据
@node-drop="handleDrop"
拽成功完成时触发的事件
draggingNode:被拖拽节点对应的 Node
dropNode:结束拖拽时最后进入的节点
dropType:被拖拽节点的放置位置(before、after、inner)
ev:event
//拖拽之后收集需要更新的数据
handleDrop(draggingNode, dropNode, dropType, ev) {
console.log("handleDrop: ", draggingNode, dropNode, dropType);
// 1、得到拖拽后最新父节点、兄弟节点数组
//当前节点拖拽之后最新的父节点
let pCid = 0;
// 表示拖拽之后当前节点的兄弟节点组成的数组
let siblings = null;
if (dropType == "before" || dropType == "after") {
/*
* 因为最前面的【图书、音像、电子书刊】这个 1 级节点的父节点不像其他一样是个对象或者 null,而是一个数组
* 所以通过【dropNode.parent.data.catId】获取父节点的时候得到的就是 undefined,所以这里需要进行判断
* pCid = dropNode.parent.data.catId == undefined? 0 : dropNode.parent.data.catId;
*/
//其实可以直接这么写,就不需要判断了
pCid = dropNode.data.parentCid;
siblings = dropNode.parent.childNodes;
} else {
pCid = dropNode.data.catId;
siblings = dropNode.childNodes;
}
//将最新父节点存放至数组中,方便后面将有改动的父节点目录展开
this.ppCid.push(pCid);
console.log("兄弟节点数组: ", siblings);
// 2、得到当前拖拽节点的最新排序、最新层级、以及兄弟节点的排序,并将这些数据全部放入数组中
for (let i = 0; i < siblings.length; i++) {
// 如果遍历到的正是当前正在拖拽的节点
if (siblings[i].data.catId == draggingNode.data.catId) {
//draggingNode.level表示原来的层级【比如2】
let catLevel = draggingNode.level;
//siblings[i].level表示拖拽后的层级【比如1】
if (siblings[i].level != draggingNode.level) {
// 3、当前拖拽节点的最新层级
// 当前结点的层级发生变化,将新的层级记录下来,需要修改
catLevel = siblings[i].level;
// 修改它子节点的层级
this.updateChildNodeLevel(siblings[i]);
}
// 对于当前正在拖拽的节点,需要修改排序、父节点、层级
this.updateNodes.push({
catId: siblings[i].data.catId,
sort: i,
parentCid: pCid,
catLevel: catLevel
});
//对于不是当前拖拽的节点,仅需要修改排序即可,将id和新的排序sort放入数组
} else {
this.updateNodes.push({
catId: siblings[i].data.catId,
sort: i
});
}
}
// 3、当前拖拽节点的最新层级
console.log("updateNodes", this.updateNodes);
},
// 修改当前正在拖拽节点的子节点的层级
updateChildNodeLevel(node) {
if (node.childNodes.length > 0) {
for (let i = 0; i < node.childNodes.length; i++) {
var cNode = node.childNodes[i].data;
this.updateNodes.push({
catId: cNode.catId,
catLevel: node.childNodes[i].level
});
this.updateChildNodeLevel(node.childNodes[i]);
}
}
},
3 批量保存
后端接口:
/**
* 页面拖拽,批量修改
*/
@RequestMapping("/update/sort")
public R updateSort(@RequestBody CategoryEntity[] category){
categoryService.updateBatchById(Arrays.asList(category));
return R.ok();
}
前端调用:
// 4、收集好要修改的数据之后, 发送修改请求到后端
batchSave(){
this.$http({
url: this.$http.adornUrl("/product/category/update/sort"),
method: "post",
data: this.$http.adornData(this.updateNodes, false)
})
.then(({ data }) => {
this.$message({
message: "菜单顺序等修改成功",
type: "success"
});
// 刷新菜单
this.getMenus();
// 默认展开的菜单
this.expandedKey = this.ppCid;
//注意修改之后要将数据清空
updateNodes = []; //拖拽成功之后需要修改的节点
maxLevel = 0;
});
},
3)、批量删除
在 template
标签内部, el-tree
标签同级下面添加批量删除按钮
<el-button type="danger" @click="batchDelete">批量删除</el-button>
因为需要找到菜单页面里面的被选中数据,首先要定位到菜单页面的所有数据,给el-tree
标签加一个唯一标识ref="menuTree"
,
<el-tree :data="menus" :props="defaultProps"
:expand-on-click-node="false"
show-checkbox
node-key="catId"
:default-expanded-keys="expandedKey"
:draggable="draggable"
:allow-drop="allowDrop"
@node-drop="handleDrop"
ref="menuTree"
>
此处使用 getCheckedNodes 方法
方法说明:
表示若节点可被选择(即 show-checkbox 为 true),则返回目前被选中的节点所组成的数组
参数说明:
(leafOnly, includeHalfChecked)接收两个 boolean 类型的参数
1.是否只是叶子节点,默认值为 false
2.是否包含半选节点,默认值为 false
batchDelete 方法:
batchDelete() {
let catIds = [];
let names = [];
// getCheckedNodes() 方法返回目前被选中的节点所组成的数组
let checkedNodes = this.$refs.menuTree.getCheckedNodes();
console.log("被选中的元素", checkedNodes);
for (let i = 0; i < checkedNodes.length; i++) {
// 遍历节点数组 拿到需要的值
catIds.push(checkedNodes[i].catId);
names.push(checkedNodes[i].name);
}
this.$confirm(`是否批量删除【${names}】菜单 ? `, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
})
.then(() => {
this.$http({
url: this.$http.adornUrl("/product/category/delete"),
method: "post",
data: this.$http.adornData(catIds, false)
}).then(({ data }) => {
this.$message({
message: "删除成功",
type: "success"
});
// 刷新菜单
this.getMenus();
});
})
.catch(()=>{});
},
后台商品服务 - 品牌管理
1、状态显示开关
在菜单管理里面 “ 商品系统 ” 下面添加 “ 品牌管理 ” 的二级目录
逆向生成前端代码:(就在之前生成的代码压缩包里面)
逆向工程生成的文件中有对应的 vue
没有删除按钮是因为里面判断了权限,进入 \renren-fast-vue\src\utils\index.js 将判断权限的方法内容清空,让其永远返回true/** * 是否有权限 * @param {*} key */ export function isAuth (key) { //return JSON.parse(sessionStorage.getItem('permissions') || '[]').indexOf(key) !== -1 || false return true; }
参考 Element UI 官网的 Table 表格,自定义表格
在 brand.js
文件中,找到 显示状态 所在的标签,在标签内添加以下内容
<el-switch
v-model="scope.row.showStatus"
active-color="#13ce66"
inactive-color="#ff4949"
:active-value="1"
:inactive-value="0"
@change="updateBrandStatus(scope.row)"
></el-switch>
<!--
scope.row 拿到整行的数据
active-color switch 打开时的背景色
inactive-color switch 关闭时的背景色
-->
<!--
switch 默认是通过 true、false 来改变开关的状态
但是此时我们数据库使用的是 1、0,所以需要修改switch打开时、关闭时的值
通过下面两个属性实现:
active-value switch 打开时的值
inactive-value switch 关闭时的值
-->
change函数:switch 状态发生变化时的回调函数,参数是新状态的值
在方法集合 methods: {}
里面添加方法,使得在数据展示页面即可通过开关修改数据的显示状态
//展示页面修改最新状态
updateBrandStatus(data) {
console.log("整行数据",data);
// 单独就封装两个字段
let {brandId,showStatus} = data
//发送请求修改状态,因为上面标签里面修改了switch开关的值,这里的 showStatus 是1或者0
this.$http({
url: this.$http.adornUrl('/product/brand/update'),
method: 'post',
data: this.$http.adornData({brandId,showStatus}, false)
}).then(({ data }) => {
this.$message({
type:"success",
message:"状态更新成功"
})
});
},
修改了展示页面,还要修改新增对话框里面
在 brand-add-or-update.vue
文件中,找到 显示状态 所在的标签,在标签内添加以下内容
<el-form-item label="显示状态" prop="showStatus">
<!-- 新增页面的显示状态修改为一个开关 -->
<el-switch
active-color="#13ce66"
inactive-color="#ff4949"
v-model="dataForm.showStatus"
:active-value="1"
:inactive-value="0"></el-switch>
</el-form-item>
2、云存储
此处品牌 logo 图片存储使用的是 OSS 对象存储方案
使用的是 服务端签名直传并设置上传回调 方案
新建 module 专门由于第三方服务
1)、pom.xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.8.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>afei</groupId>
<artifactId>gulimall-third-product</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>gulimall-third-product</name>
<description>第三方服务</description>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Greenwich.SR3</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>afei.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alicloud-oss</artifactId>
<version>2.2.0.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.1.2.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
</repository>
</repositories>
2)、配置文件
网关模块里面设置断言路由
routes:
- id: third_product_route
uri: lb://gulimall-third-product
predicates:
- Path=/api/thirdparty/**
filters:
# 将 /api/thirdparty/** 路由到 /**
- RewritePath=/api/thirdparty/(?<segment>.*),/$\{segment}
创建 Bucket,并且开启使用子账户 AccessKey
注意:
从人人开源前端 8001 访问OSS有跨域问题,需要在 对象存储Bucket 里面进行设置
在 nacos 配置中心里面配置 Bucket、endpoint、AccessKey ID、AccessKey Secret 等等,格式如下:
【注意,因为这里使用的阿里云版本是旧版,所以配置文件的格式也是旧版,如果引入的是新版的依赖,那么配置文件就不是下面的写法,具体参照 github官方文档 、 阿里云文档 说明】
bootstrap.properties
# 指明nacos配置中心地址、命名空间
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.cloud.nacos.config.namespace=580653b9-4c2d-4fb7-b682-0a0679875597
# 使用extension-configs[n]来引入多个配置文件
# 注意:若是与服务名(spring.application.name)一致的配置文件,不可使用extension-configs[n]引入,直接配置在spring.cloud.nacos.config.group=DEMO
spring.cloud.nacos.config.extension-configs[0].data-id=oss.yml
spring.cloud.nacos.config.extension-configs[0].group=DEFAULT_GROUP
spring.cloud.nacos.config.extension-configs[0].refresh=true
application.yml
server:
port: 30000
spring:
application:
name: gulimall-third-product
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
3)、业务类
OssController
@RestController
public class OssController {
@Value("${spring.cloud.alicloud.access-key}")
private String accessId;
@Value("${spring.cloud.alicloud.secret-key}")
private String accessKey;
@Value("${spring.cloud.alicloud.oss.endpoint}")
private String endpoint;
@Value("${spring.cloud.alicloud.oss.bucket}")
private String bucket;
@RequestMapping("/oss/policy")
public R policy(){
// 填写Host地址,格式为https://bucketname.endpoint。
String host = "https://gulimall-brandlogo.oss-cn-hangzhou.aliyuncs.com";
// 设置上传到OSS文件的前缀,可置空此项。置空后,文件将上传至Bucket的根目录下。
String farmat = LocalDate.now().toString();
String dir = farmat + "/";
OSSClient ossClient = new OSSClient(endpoint, accessId, accessKey);
Map<String, String> respMap = null;
try {
long expireTime = 600;
long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
Date expiration = new Date(expireEndTime);
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);
String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes("utf-8");
String encodedPolicy = BinaryUtil.toBase64String(binaryData);
String postSignature = ossClient.calculatePostSignature(postPolicy);
respMap = new LinkedHashMap<String, String>();
respMap.put("accessid", accessId);
respMap.put("policy", encodedPolicy);
respMap.put("signature", postSignature);
respMap.put("dir", dir);
respMap.put("host", host);
respMap.put("expire", String.valueOf(expireEndTime / 1000));
// respMap.put("expire", formatISO8601Date(expiration));
} catch (Exception e) {
// Assert.fail(e.getMessage());
System.out.println(e.getMessage());
}
return R.ok().put("data",respMap);
}
}
主启动类上开启注册发现
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallThirdProductApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallThirdProductApplication.class, args);
}
}
4)、测试
启动服务,并访问,得到结果如下
5)、前端文件上传逻辑
前端实现文件上传,使用的是 ElementUI 里面的 Upload
引入提供好的代码文件
其中 policy.js 文件是发送请求具体信息
export function policy() {
return new Promise((resolve,reject)=>{
http({
url: http.adornUrl("/thirdparty/oss/policy"),
method: "get",
params: http.adornParams({})
}).then(({ data }) => {
resolve(data);
})
});
}
singleUpload.vue 里面文件上传的逻辑
在文件上传之前进行数据处理
beforeUpload(file) {
let _self = this;
return new Promise((resolve, reject) => {
policy().then(response => {
console.log("后端响应的数据",response);
_self.dataObj.policy = response.data.policy;
_self.dataObj.signature = response.data.signature;
_self.dataObj.ossaccessKeyId = response.data.accessid;
_self.dataObj.key = response.data.dir +getUUID()+'_${filename}';
_self.dataObj.dir = response.data.dir;
_self.dataObj.host = response.data.host;
console.log("处理后的数据",_self.dataObj);
resolve(true)
}).catch(err => {
reject(false)
})
})
},
文件上传成功之后的逻辑
handleUploadSuccess(res, file) {
console.log("上传成功...")
this.showFileList = true;
this.fileList.pop();
this.fileList.push({name: file.name, url: this.dataObj.host + '/' + this.dataObj.key.replace("${filename}",file.name) });
this.emitInput(this.fileList[0].url);
}
6)、前端页面
在 brand-add-or-update.vue
引入上面文件上传组件
<script>
//引入 vue 文件
import SingleUpload from "@/components/upload/singleUpload";
export default {
//引入组件
components: { SingleUpload },
并使用组件
<el-form-item label="品牌logo地址" prop="logo">
<!-- 使用引入的标签进行文件上传 -->
<!-- <el-input v-model="dataForm.logo" placeholder="品牌logo地址"></el-input> -->
<single-upload v-model="dataForm.logo"></single-upload>
</el-form-item>
在 brand.vue
页面回显图片
<el-table-column prop="logo" header-align="center" align="center" label="品牌logo地址">
<template slot-scope="scope">
<img :src="scope.row.logo" style="width: 100px; height: 80px" />
</template>
</el-table-column>
3、前端表单效验规则
Form 组件提供了表单验证的功能,只需要通过 rules 属性传入约定的验证规则,并将 Form-Item 的 prop 属性设置为需校验的字段名即可
修改 brand-add-or-update.vue
</el-form>
标签里里面传入规则 :rules="dataRule"
dataRule: {
name: [{ required: true, message: "品牌名不能为空", trigger: "blur" }],
logo: [
{ required: true, message: "品牌logo地址不能为空", trigger: "blur" }
],
descript: [
{ required: true, message: "介绍不能为空", trigger: "blur" }
],
showStatus: [
{
required: true,
message: "显示状态[0-不显示;1-显示]不能为空",
trigger: "blur"
}
],
firstLetter: [
{
validator: (rule, value, callback) => {
if (value == "") {
callback(new Error("首字母必须填写"));
} else if (!/^[a-zA-Z]$/.test(value)) {
callback(new Error("首字母必须a-z或者A-Z之间"));
} else {
callback();
}
},
trigger: "blur"
}
],
sort: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("排序字段必须填写"));
} else if (!Number.isInteger(value) || value<0) {
callback(new Error("排序必须是一个大于等于0的整数"));
} else {
callback();
}
},
trigger: "blur"
}
]
}
4、后端 JSR303 数据效验
前端数据效验成功了,就会把json数据传递到后端,但是有人利用接口 比如 postman 乱发送请求 那会怎么办,于是后端也会利用 JSR303进行数据效验
1)、简单使用
1、给 Bean 添加校验注解
(javax.validation.constraints 包下),并可以定义 message
属性内容用以提示
/**
* 品牌名
*/
@NotBlank(message = "品牌名不可为空")
private String name;
若是没有自定义提示,默认的错误提示都是在配置文件 Validation Messages.properties
里面定义的
@NotBlank //一般用于字符串 @NotEmpty //一般用于集合数组等 @NotNull //可用于任何类型 @Null //表示必须为 null @URL //必须是合法的地址 @Pattern(regexp = "^[a-zA-Z]$") //正则表达式指定该属性必须是一个字母 @Min(value = 0) //必须大于等于0
2、开启校验功能,使用注解 @Valid
用在方法参数位置,效果:校验错误以后会有默认的响应;
Controller代码:
/**
* 保存
*/
@RequestMapping("/save")
public R save(@Valid @RequestBody BrandEntity brand){
brandService.save(brand);
return R.ok();
}
测试执行结果:
得到错误提示如下,会显示错误代码、出错的 bean、出错的规则 等信息
{
"timestamp": "2022-07-12T00:43:52.941+0000",
"status": 400,
"error": "Bad Request",
"errors": [
{
"codes": [
"NotBlank.brandEntity.name",
"NotBlank.name",
"NotBlank.java.lang.String",
"NotBlank"
],
"arguments": [
{
"codes": [
"brandEntity.name",
"name"
],
"arguments": null,
"defaultMessage": "name",
"code": "name"
}
],
"defaultMessage": "品牌名不可为空",
"objectName": "brandEntity",
"field": "name",
"rejectedValue": "",
"bindingFailure": false,
"code": "NotBlank"
}
],
"message": "Validation failed for object='brandEntity'. Error count: 1",
"path": "/product/brand/save"
}
3、给校验的 bean 后紧跟一个BindingResult
,就可以获取到校验的结果
public R save(@Valid @RequestBody BrandEntity brand, BindingResult result){
if (result.hasErrors()){
Map<String,String> map = new HashMap<>();
//1、getFieldErrors() 方法获取校验的错误结果
result.getFieldErrors().forEach((item)->{
//2、获取到错误提示
String message = item.getDefaultMessage();
//3、获取错误的属性的名字
String field = item.getField();
map.put(field,message);
});
return R.error(400,"提交的数据不合法").put("data",map);
}else {
brandService.save(brand);
return R.ok();
}
}
测试结果如下:
{
"msg": "提交的数据不合法",
"code": 400,
"data": {
"name": "品牌名不可为空"
}
}
2)、分组校验 (多场景复杂效验)
自定义空的接口,作为分组的各个组的标识
public interface AddGroup {
}
1、给校验注解标注什么情况需要进行校验 @NotBlank(message = “品牌名必须提交”,groups = {AddGroup.class,UpdateGroup.class})
@NotBlank(message = "品牌名必须提交",groups = {AddGroup.class}) //一般用于字符串
private String name;
2、开启校验时也指定对什么组进行校验 @Validated({AddGroup.class})
注意:这里是 @Validated ,前面是 @Valid
@RequestMapping("/save")
public R save(@Validated(AddGroup.class) @RequestBody BrandEntity brand){
}
3、默认没有指定分组的校验注解 @NotBlank,在分组校验情况@Validated({AddGroup.class}) 下不生效,只会在@Validated生效;
注意: 没有分组的情况也是一个组,若开启校验时指定了其他组,那么没有分组的这个组不会生效
实体类:
/**
* 品牌id
*/
@Null(message = "新增不能指定Id",groups = {AddGroup.class})
@NotNull(message = "修改必须指定品牌id",groups = {UpdateGroup.class})
@TableId
private Long brandId;
/**
* 品牌名
*/
@NotBlank(message = "品牌名不能为空",groups = {AddGroup.class,UpdateGroup.class})
private String name;
/**
* 品牌logo地址
*/
@NotEmpty(groups = {AddGroup.class})
@URL(message = "logo必须是一个合法的url地址",groups = {AddGroup.class,UpdateGroup.class})
private String logo;
/**
* 介绍
*/
private String descript;
/**
* 显示状态[0-不显示;1-显示]
*/
@NotNull(groups = {AddGroup.class, UpdateStatusGroup.class})
@ListValue(vals={0,1},groups = {AddGroup.class,UpdateStatusGroup.class})
private Integer showStatus;
/**
* 检索首字母
*/
@NotEmpty(groups = {AddGroup.class})
@Pattern(regexp = "^[a-zA-Z]$",message = "检索首字母必须是一个字母",groups = {AddGroup.class,UpdateGroup.class})
private String firstLetter;
/**
* 排序
*/
@NotNull(groups = {AddGroup.class})
@Min(value=0,message = "排序必须大于等于0",groups = {AddGroup.class,UpdateGroup.class})
private Integer sort;
3)、自定义校验
因为 common 模块里面没有 boot 依赖,所以需要单独引入 JSR303 校验的依赖
<dependency> <groupId>javax.validation</groupId> <artifactId>validation-api</artifactId> <version>2.0.1.Final</version> </dependency>
1、编写一个自定义的校验注解 @ListValue(vals={0,1})
@Documented
//关联自定义的校验器
@Constraint(validatedBy = { ListValueConstraintValidator.class })
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
public @interface ListValue {
//指向默认错误提示
String message() default "{com.atguigu.common.valid.ListValue.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
int[] vals() default { };
}
这里为了使用自定义默认错误提示,编写了配置文件,配置文件名就使用
ValidationMessages.properties
2、编写一个自定义的 校验器 ConstraintValidator
实现 ConstraintValidator<T,R>
接口,泛型 T 是自定义校验注解类型,泛型 R 是被校验属性类型
public class ListValueConstraintValidator implements ConstraintValidator<ListValue,Integer> {
private Set<Integer> set = new HashSet<>();
//初始化方法
@Override
public void initialize(ListValue constraintAnnotation) {
int[] vals = constraintAnnotation.vals(); //获取注解的val值@ListValue(vals={0,1})
for (int val : vals) {
set.add(val);
}
}
/**
* 判断是否校验成功
* @param value 需要校验的值,即传入的属性值
* @param context
* @return
*/
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
return set.contains(value);
}
}
3、关联
自定义的校验器和自定义的校验注解
自定义注解上面使用注解,注解内指定要关联绑定的自定义校验器
//关联自定义的校验器
@Constraint(validatedBy = { ListValueConstraintValidator.class })
4)、统一异常处理
这里异常处理类是在 gulimall-product 模块里面,模块 common 没有引入 boot 依赖
这里使用到了 SpringMVC 的注解 @ControllerAdvice
1、编写异常处理类使用SpringMvc的 @RestControllerAdvice
= @ControllerAdvice + @ResponseBody
2、使用 @ExceptionHandler
标记方法可以处理异常
3、通过getBindingResult()
获取到 BindingResult 对象
@Slf4j
@RestControllerAdvice(basePackages = "afei.product.controller")
public class ProductExceptionControllerAdvice {
/**
* 捕获定义的异常
*/
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public R handleVaildException(MethodArgumentNotValidException e) {
log.error("数据效验出现问题{},异常类型:{}",e.getMessage(),e.getClass());
Map<String,String> errorMap = new HashMap<>();
// getBindingResult()方法获取校验的错误结果对象 BindingResult
BindingResult bindingResult = e.getBindingResult();
bindingResult.getFieldErrors().forEach(fieldError -> {
errorMap.put(fieldError.getField(),fieldError.getDefaultMessage());
});
return R.error(BizCodeEnume.VAILD_EXCEPTION.getCode(),BizCodeEnume.VAILD_EXCEPTION.getMsg()).put("data",errorMap);
}
/**
* 兜底异常
*/
@ExceptionHandler(value = Throwable.class)
public R handleException(Throwable throwable) {
return R.error();
}
}
5)、异常错误码定义 (重点)
这里定义异常错误码的枚举类是定义在 common 模块里面
后端将定义的错误码写入到开发手册,前端出现对于的错误,就可以通过手册查询到对应的异常
/***
* 错误码和错误信息定义类
* 1. 错误码定义规则为5为数字
* 2. 前两位表示业务场景,最后三位表示错误码。例如:100001。10:通用 001:系统未知异常
* 3. 维护错误码后需要维护错误描述,将他们定义为枚举形式
*
* 错误码列表:
* 10: 通用
* 000:系统未知异常
* 001:参数格式校验
* 11: 商品
* 12: 订单
* 13: 购物车
* 14: 物流
*/
public enum BizCodeEnume {
UNKNOW_EXCEPTION(10000,"系统未知异常"),
VAILD_EXCEPTION(10001,"参数格式校验失败");
private int code;
private String msg;
BizCodeEnume(int code,String msg){
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
5、品牌分类关联
1)、品牌模糊查询
在品牌管理里面有关键字查询的输入框,那么就需要修改后端方法
修改 BrandServiceImpl
的 queryPage
@Override
public PageUtils queryPage(Map<String, Object> params) {
// 拿到参数中的 key
String key = (String) params.get("key");
QueryWrapper<BrandEntity> wrapper = new QueryWrapper<>();
if (!StringUtils.isEmpty(key)) {
wrapper.eq("brand_id",key).or().like("name",key)
.or().like("descript",key).or().like("first_letter",key);
}
// 组装条件进行查询
IPage<BrandEntity> page = this.page(
new Query<BrandEntity>().getPage(params),
wrapper
);
return new PageUtils(page);
}
2)、分页数据
各个地方的分页也没有显示正确数据,是因为缺少 MBP 的分页配置
添加配置类,配置分页 (新版3.2.4 BP 似乎需要分页配置类)
@Configuration
@EnableTransactionManagement //开启事务
@MapperScan("afei.product.dao")
public class MyBatisConfig {
//引入分页插件
@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
// 设置请求的页面大于最大页后操作, true调回到首页,false 继续请求 默认false
paginationInterceptor.setOverflow(true);
// 设置最大单页限制数量,默认 500 条,-1 不受限制
paginationInterceptor.setLimit(1000);
return paginationInterceptor;
}
}
3)、品牌分类关联
品牌管理里面,添加的品牌需要和商品分类 category 进行绑定,而绑定的数据也有单独的品牌分类关联表来存储 pms_category_brand_relation
1 展示关联分类
查看 接口文档(15、获取品牌关联的分类),当展示关联分类时,会以 GET 方式请求参数 brandId,而需要的响应数据 “catelogId”、“catelogName” 。修改业务类
/**
* 获取当前品牌关联的所有分类列表
*/
@GetMapping("/catelog/list")
public R list(@RequestParam Long brandId){
List<CategoryBrandRelationEntity> data = categoryBrandRelationService.list(
new QueryWrapper<CategoryBrandRelationEntity>().eq("brand_id",brandId)
);
return R.ok().put("data", data);
}
2 新增关联分类
当点击新增关联分类时,会以 POST 方式请求参数 {brandId: 1, catelogId: 225}
发现数据库仅仅存储了这两个id,没有存储name,所以需要修改业务类
controller
@RequestMapping("/save")
public R save(@RequestBody CategoryBrandRelationEntity categoryBrandRelation){
categoryBrandRelationService.saveDetail(categoryBrandRelation);
return R.ok();
}
service
//新增品牌分类关联
void saveDetail(CategoryBrandRelationEntity entity);
@Override
public void saveDetail(CategoryBrandRelationEntity entity) {
String brandName = brandDao.selectById(entity.getBrandId()).getName();
String categoryName = categoryDao.selectById(entity.getCatelogId()).getName();
entity.setBrandName(brandName);
entity.setCatelogName(categoryName);
this.save(entity);
}
同时注意,我们是直接把数据表存进了中间表,如果在真正的品牌名和分类名进行了修改,那么此时中间表的数据就是不对的,这时候数据就不是一致性,所以在进行修改的时候,也要把中间表的数据进行更改
效果如下 :
所以当商品分类的数据库表修改name时、品牌数据库表修改name时,这里关联分类表也要进行修改
Brand 品牌关联修改:
BrandController
@RequestMapping("/update")
public R update(@RequestBody BrandEntity brand){
brandService.updateDetail(brand);
return R.ok();
}
BrandService
@Override
public void updateDetail(BrandEntity brand) {
// 保证字段一致
// 根据id更改
this.updateById(brand);
if (!StringUtils.isEmpty(brand.getName())) {
// 同步更新其他关联表中的数据
categoryBrandRelationService.updateBrand(brand.getBrandId(),brand.getName());
// TODO 更新其他关联
}
}
Category 商品分类关联修改:
CategoryController
@RequestMapping("/update")
//@RequiresPermissions("product:category:update")
public R update(@RequestBody CategoryEntity category){
categoryService.updateDetail(category);
return R.ok();
}
CategoryService
@Override
public void updateDetail(CategoryEntity category) {
// 更新自己表对象
this.updateById(category);
// 同步更新其他关联表中的数据
categoryBrandRelationService.updateCategory(category.getCatId(),category.getName());
// TODO 更新其他关联
}
关联分类内部定义修改的方法:
CategoryBrandRelationService
//当品牌处的name更新时,同步更新此处的品牌数据
void updateBrand(Long brandId, String name);
//当商品分类处更新时,同步更新此处的商品分类
void updateCategory(Long catId, String name);
@Override
public void updateBrand(Long brandId, String name) {
//通过 UpdateWrapper 来进行字段更新
CategoryBrandRelationEntity relationEntity = new CategoryBrandRelationEntity();
relationEntity.setBrandName(name);
relationEntity.setBrandId(brandId);
this.update(relationEntity,new UpdateWrapper<CategoryBrandRelationEntity>().eq("brand_id",brandId));
}
//service
@Override
public void updateCategory(Long catId, String name) {
// 通过 XML 方式进行字段更新
baseMapper.updateCategory(catId, name);
}
//mapper
void updateCategory(@Param("catId") Long catId,
@Param("name") String name);
<update id="updateCategory">
UPDATE pms_category_brand_relation
SET catelog_name = #{name}
where catelog_id = #{catId}
</update>
后台商品服务 - 属性分组
1、基本概念
1)、SPU 与 SKU
SPU:Standard Product Unit(标准化产品单元)
是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合 描述了一个产品的特性
例如商品 iPhone13
SKU:Stock Keeping Unit(库存量单位)
即库存进出计量的基本单元,可以是以件,盒,托盘等为单位。SKU 这是对于大型连锁超市 DC(配送中心)物流管理的一个必要的方法。现在已经被引申为产品统一编号的简称,每种产品均对应有唯一的 SKU 号
同一类商品 SPU 拥有相同的基本属性值,但是各自又拥有不同的 SKU 属性值
(例如 iphone 13 【SPU】拥有同样的尺寸、重量等基本属性,但是拥有不同的颜色、内存【SKU】组合版本)
2)、基本属性【规格参数】与销售属性
SPU 来规定规格参数的值,SKU 来决定销售属性的值
每个分类下的商品共享规格参数,与销售属性。只是有些商品不一定要用这个分类下全部的属性;
- 属性是以三级分类组织起来的
- 规格参数中有些是可以提供检索的(例如可通过手机CPU来筛选检索)
- 规格参数也是基本属性,他们具有自己的分组(例如手机基本属性包括尺寸、屏幕、主芯片等)
- 属性的分组也是以三级分类组织起来的(例如屏幕里面也包括分辨率、屏幕尺寸等)
- 属性名确定的,但是值是每一个商品不同来决定的
3)、数据库表设计
【属性分组-规格参数-销售属性-三级分类】关联关系
SPU-SKU-属性表
- 属性表 pms_attr
- 属性分组表 pms_attr_group 主要是属性的组信息
- 属性与组关联关系 pms_attr_attrgroup_relation 上面两个信息表的关联表
- 商品属性值表 pms_product_attr_value
- sku信息表 pms_sku_info
- 商品销售属性表 pms_sku_sale_attr_value 将销售相关的几个属性值单独列出的表
属性分组效果图
2、基本查询实现
1)、前端 父子组件交互
左边这个树形空间我们已经写过了,在三级分类的时候编写过,为了实现复用,将其抽取出来
新建 category.vue 文件,放置在 common 目录下,里面编写 Tree 组件的基本框架
<template>
<div>
<el-input placeholder="输入关键字进行过滤" v-model="filterText"></el-input>
<el-tree
:data="menus"
:props="defaultProps"
node-key="catId"
ref="menuTree"
:highlight-current = "true"
></el-tree>
</div>
</template>
<script>
export default {
components: {},
props: {},
data() {
//这里存放数据
return {
menus: [],
expandedKey: [],
defaultProps: {
children: "children",
label: "name"
}
};
},
//计算属性 类似于data概念
computed: {},
//监控data中的数据变化
watch: { },
//方法集合
methods: {
//三级目录展示
getMenus() {
this.$http({
url: this.$http.adornUrl("/product/category/list/tree"),
method: "get"
}).then(({ data }) => {
this.menus = data.data;
});
},
},
//生命周期 - 创建完成(可以访问当前this实例)
created() {
this.getMenus();
}
};
</script>
<style scoped></style>
右边的表格有现成的,就是逆向工程里面生成的前端代码
逆向生成前端代码:(就在之前生成的代码压缩包里面)
逆向工程生成的文件中有对应的 vue
在此基础上稍作修改,因为要将页面一分为二,使用 ElementUI 里面的 Layout 布局,在 template
标签里面添加 el-row 标签,进行 24 分栏
<el-row :gutter="20">
<el-col :span="6">
<!-- 页面左边6列显示菜单,使用引入的组件,将组件名作为标签直接使用即可 -->
<category></category>
</el-col>
<el-col :span="18">
<!-- 页面右边18列显示表格,将生成的前端代码的表格部分放在这里 -->
</el-col>
</el-row>
左边的菜单需要引入其他文件,则在 attrgroup.vue
文件中写入以下代码
<script>
//导入其他文件(比如:组件,工具js,第三方插件js,json文件,图片文件等等)
//例如:import 《组件名称》 from '《组件路径》';
import Category from "../common/category";
import AddOrUpdate from "./attrgroup-add-or-update";
export default {
//import引入的组件需要注入到对象中才能使用
components: { Category, AddOrUpdate },
父子组件如何进行交互
父子组件传递数据,希望子组件 category 给父组件 attrgroup 传递数据,事件机制
只需要子组件给父组件发送一个事件,并携带上数据,然后父组件中直接用事件名接收即可
在category.vue
的 Tree 组件里声明函数 node-click
给 eltree 绑定单击事件,然后在单击触发的函数里写入此语句
this.$emit("事件名",携带的数据...)
node-click : 节点被点击时的回调
参数:传递给data属性的数组中该节点所对应的对象(数据库封装的信息)、节点对应的Node、节点组件本身
<el-tree
:data="menus"
:props="defaultProps"
node-key="catId"
ref="menuTree"
@node-click="nodeclick"
></el-tree>
nodeclick(data, node, component) {
//向父组件发送事件;$emit 表示触发事件,$off 删除事件,$on 绑定事件
this.$emit("tree-node-click", data, node, component);
}
父组件attrgroup.vue
需要定义相同方法接收
<category @tree-node-click="treenodeclick"></category>
//感知树节点被点击
treenodeclick(data, node, component) {
console.log("attrgroup感知到被点击:", data, node, component);
if (node.level == 3) {
this.catId = data.catId;
this.getDataList(); //在表格里面显示数据列表,携带了分类id
}
},
2)、接口 获取分类属性分组
因为需要实现点击页面左边的菜单,在右边表格显示该菜单的属性分组
所以查询的语句需要携带 目录 id
/**
* 列表
* product/attrgroup/list/165?t=1657611466891&page=1&limit=10&key=aa
*/
@RequestMapping("/list/{catelogId}")
public R list(@RequestParam Map<String, Object> params, @PathVariable("catelogId") Long catelogId){
PageUtils page = attrGroupService.queryPage(params,catelogId);
return R.ok().put("page", page);
}
/**
* 点击页面左边的菜单,在右边表格显示该菜单的属性分组
* @param params 分页请求相关参数
* @param catelogId 三级分类id
* @return
*/
PageUtils queryPage(Map<String, Object> params, Long catelogId);
实现类
使用了 MyBatisPlus 的 QueryWrapper
@Override
public PageUtils queryPage(Map<String, Object> params, Long catelogId) {
// 分类id 等于01 查询全部
if (catelogId == 0) {
//这里的Query、PageUtils是人人里面的工具类
IPage<AttrGroupEntity> page = this.page(new Query<AttrGroupEntity>().getPage(params),
new QueryWrapper<AttrGroupEntity>());
return new PageUtils(page);
} else {
// 拿到参数中的 key
String key = (String) params.get("key");
// 先根据分类id进行查询
QueryWrapper<AttrGroupEntity> wrapper = new QueryWrapper<AttrGroupEntity>()
.eq("catelog_id",catelogId);
// selecet * from attrgroup where catelog_id = ? and (attr_group_id = key or like attr_group_name = key)
// 有时候查询也不会带上key 所以得判断
if (!StringUtils.isEmpty(key)) {
// where条件后加上 and
wrapper.and((obj) -> {
obj.eq("attr_group_id",key).or().like("attr_group_name",key);
});
}
// 组装条件进行查询
IPage<AttrGroupEntity> page = this.page(new Query<AttrGroupEntity>().getPage(params),
wrapper);
return new PageUtils(page);
}
}
3、分类新增与修改
1)、新增
在新增和修改里面最主要的是 属性分组绑定的 商品,需要是下拉框选择的
那么这里就会使用到 Cascader 级联选择器
attrgroup-add-or-update.vue
中 加入该组件
<el-cascader
v-model="dataForm.catelogPath"
placeholder="试试搜索:手机"
:options="categorys"
:props="props"
filterable
></el-cascader>
<!--
placeholder="试试搜索:手机"默认的搜索提示
:options="categorys" 可选项数据源,键名可通过 Props 属性配置
:props="props" 配置选项
filterable 是否可搜索选项
-->
获取级联选择器里面的数据,初始化数据 data
data() {
return {
categorys: [],
methods
里面添加方法
methods: {
//新增级联选择器的内容显示
getCategorys(){
this.$http({
url: this.$http.adornUrl("/product/category/list/tree"),
method: "get"
}).then(({ data }) => {
this.categorys = data.data;
});
},
//生命周期 - 创建完成
created(){
this.getCategorys();
}
1 问题一
页面显示如下
没有显示文字是因为没有配置要使用的属性
data
里面配置选项
data() {
return {
props:{
value:"catId", //指定选项的值为选项对象的某个属性值
label:"name", //指定选项标签为选项对象的某个属性值
children:"children" //指定选项的子选项为选项对象的某个属性值
},
2 问题二
选择 children 来实现显示级联,但是当三级商品的 children 是空集合[ ] 的时候,新增页面也会出现选项的子选项栏
处理方法:
在后端实体类里面,将该字段设置为若值为空则不显示该字段,使用注解@JsonInclude(JsonInclude.Include.NON_EMPTY)
//表示该字段没有值时,不使用不显示该字段
@JsonInclude(JsonInclude.Include.NON_EMPTY)
//因为此属性表中没有对应字段,需要使用注解 @TableField(exist = false) 指明
@TableField(exist = false)
private List<CategoryEntity> children;
重启项目再次访问显示数据的接口,发现当 children 没有值时,返回的结果里直接没有该字段
3 问题三
当级联选项框选择好内容,点击提交的时候,会出现下面异常
这是因为下拉框传递的数据是数组,若是用单个数值属性接收就报错
处理方法:
修改template
标签里面的级联选项框,自定义属性来接收数组 id
<el-cascader
v-model="catelogPath"
placeholder="试试搜索:手机"
:options="categorys"
:props="props"
filterable
></el-cascader>
<!--
catelogPath=自定义属性进行接收
-->
初始化数组
data() {
return {
props:{
value:"catId",
label:"name",
children:"children"
},
categorys: [],
catelogPath: [],
那么在表单提交代码里面,也需要修改提交到后端的数据
因为新增、修改最终针对的还是所选的三级商品,所以传递的 id 应该是数组里面最后一位
catelogId: this.catelogPath[this.catelogPath.length-1]
2)、修改回显
修改和新增用的是一个添加组件 那么我们再点击修改后,如何把 级联显示的数据再次显示出来?
在 AttrGroup 点击修改后,会触发 addOrUpdateHandle 方法,他会通过引用 vue 文件里的 addOrUpdate 并调用他的 init 初始化方法。如下:
// 新增 / 修改
addOrUpdateHandle(id) {
this.addOrUpdateVisible = true;
this.$nextTick(() => {
//会调用addOrUpdate文件里面的init方法
this.$refs.addOrUpdate.init(id);
});
},
init 方法就是 修改表单时回显数据初始化操作
//修改表单时回显数据初始化
init(id) {
this.dataForm.attrGroupId = id || 0;
this.visible = true;
this.$nextTick(() => {
this.$refs["dataForm"].resetFields();
//如果传入的 id 有值,说明是修改,传到后端检索此商品的值,用来回显
if (this.dataForm.attrGroupId) {
this.$http({
url: this.$http.adornUrl(`/product/attrgroup/info/${this.dataForm.attrGroupId}`),
method: "get",
params: this.$http.adornParams()
//查到数据之后,来回显
}).then(({ data }) => {
if (data && data.code === 0) { //将查出的数据赋给 dataForm
this.dataForm.attrGroupName = data.attrGroup.attrGroupName;
this.dataForm.sort = data.attrGroup.sort;
this.dataForm.descript = data.attrGroup.descript;
this.dataForm.icon = data.attrGroup.icon;
this.dataForm.catelogId = data.attrGroup.catelogId;
//查出catelogId的完整路径,将后端对象的属性赋值给 catelogPath
this.catelogPath = data.attrGroup.catelogPath;
}
});
}
});
},
那么后端实体类就需要有能查询出级联路径的方法,并且需要有对应的属性来接收此数据
修改实体类 AttrGroupEntity
,添加一个新属性
/**
* 用于存放 级联显示的所属分类id 父子孙的地址
*/
@TableField(exist = false) // 标注为false 表是不是数据库字段
private Long[] catelogPath;
编写接口方法
@RequestMapping("/info/{attrGroupId}")
public R info(@PathVariable("attrGroupId") Long attrGroupId){
// 根据id查询出 分组group对象
AttrGroupEntity attrGroup = attrGroupService.getById(attrGroupId);
// 拿到所属分类id
Long catelogId = attrGroup.getCatelogId();
// 根据所属分类id查询出他的 父 子 孙 对应的数据
Long[] catelogPath = categoryService.findCatelogPath(catelogId);
//并注入group对象回传到前端进行回显
attrGroup.setCatelogPath(catelogPath);
return R.ok().put("attrGroup", attrGroup);
}
/**
* 找到catelogId的完整路径
* 【父/子/孙】
* @param catelogId
*/
Long[] findCatelogPath(Long catelogId);
@Override
public Long[] findCatelogPath(Long catelogId) {
List<Long> path = new ArrayList<>();
path = this.findParentCatelogPath(catelogId, path);
return path.toArray(new Long[0]);
}
/**
* 递归查找
* @param catelogId 三级分类的id
* @param paths 查找出的路径
* @return [1,22,165]父子孙
*/
private List<Long> findParentCatelogPath(Long catelogId,List<Long> paths) {
//添加当前节点 165
paths.add(catelogId);
CategoryEntity entity = this.getById(catelogId);
if (entity.getParentCid() != 0)
//传入父节点,并拼接 22,同时递归查出剩余的父节点 1
findParentCatelogPath(this.getById(entity.getParentCid()).getCatId(), paths);
// 此时得到的结果是 165 22 1,需要反转
Collections.reverse(paths);
return paths;
}
后台商品服务 - 平台属性
1、规格参数
1)、新增
注意数据库字段和实体类对不上,需要数据库新增一个字段
查看接口文档(06、保存属性【规格参数,销售属性】) ,发现当发起新增请求时,会传递以下参数:
{
"attrGroupId": 0, //属性分组id
"attrName": "string",//属性名
"attrType": 0, //属性类型
"catelogId": 0, //分类id
"enable": 0, //是否可用
"icon": "string", //图标
"searchType": 0, //是否检索
"showDesc": 0, //快速展示
"valueSelect": "string", //可选值列表
"valueType": 0 //可选值模式
}
但是后端实体类无法全部接收这些参数,缺少 attrGroupId
之前在商品分类 Category 的处理方法是 在实体类里面新增属性,并使用注解 @TableField(exist = false) ,但是这样不规范
这里创建 类 ,用于接收请求参数,其他属性与实体类一致,仅仅添加了 所属分组 id 属性 attrGroupId
@Data
public class AttrVo {
//属性id
private Long attrId;
//属性名
private String attrName;
//是否需要检索[0-不需要,1-需要]
private Integer searchType;
//值类型[0-为单个值,1-可以选择多个值]
private Integer valueType;
//属性图标
private String icon;
//可选值列表[用逗号分隔]
private String valueSelect;
//属性类型[0-销售属性,1-基本属性,2-既是销售属性又是基本属性]
private Integer attrType;
//启用状态[0 - 禁用,1 - 启用]
private Long enable;
//所属分类
private Long catelogId;
//快速展示【是否展示在介绍上;0-否 1-是】,在sku中仍然可以调整
private Integer showDesc;
//所属分组id
private Long attrGroupId;
}
修改新增业务类
@RequestMapping("/save")
public R save(@RequestBody AttrVo attrVo){
attrService.saveAttr(attrVo);
return R.ok();
}
//保存属性【规格参数,销售属性】
void saveAttr(AttrVo attrVo);
@Transactional //涉及多个表,开启事务
@Override
public void saveAttr(AttrVo attrVo) {
//1、保存实体类拥有的属性 pms_attr
AttrEntity entity = new AttrEntity();
BeanUtils.copyProperties(attrVo,entity); //将前者数据复制进后者,仅在属性名相同条件下生效 org.springframework.beans.BeanUtils
this.save(entity);
//2、保存关联表属性 pms_attr_attrgroup_relation
AttrAttrgroupRelationEntity relationEntity = new AttrAttrgroupRelationEntity();
// 注意此处有个错误,因为新增时没有 attrId,所以如果是下面写法会导致relationEntity没有attrId
//BeanUtils.copyProperties(attrVo,relationEntity);
relationEntity.setAttrGroupId(attrVo.getAttrGroupId());
relationEntity.setAttrId(entity.getAttrId()); //这里只能用entity,因为新增之后生成attrId
attrAttrgroupRelationService.save(relationEntity);
}
2)、列表
注意,页面需要响应的数据不仅仅有 pms_attr 库表的数据,还有其他表数据,所以仅仅用实体类是不够的,这里新建一个 VO 对象用于接收要响应的数据
@Data
public class AttrRespVo extends AttrVo {
//所属分类名字
private String catelogName;
//所属分组名字
private String groupName;
//用于回显
private Long[] catelogPath;
}
修改业务类
/**
* 获取分类属性列表
* @param catelogId 商品分类id
*/
@GetMapping("/base/list/{catelogId}")
public R list(@RequestParam Map<String, Object> params,
@PathVariable("catelogId") Long catelogId){
PageUtils page = attrService.queryBaseAttrPage(params,catelogId);
return R.ok().put("page", page);
}
//获取分类规格参数
PageUtils queryBaseAttrPage(Map<String, Object> params, Long catelogId);
这里有很多注意点:小心坑
@Override
public PageUtils queryBaseAttrPage(Map<String, Object> params, Long catelogId, String type) {
QueryWrapper<AttrEntity> wrapper = new QueryWrapper<>();
if (catelogId != 0){ //即选择了左侧商品分类情况
wrapper.eq("catelog_id",catelogId);
}
// 模糊查询:拿到参数中的 key
String key = (String) params.get("key");
if (!StringUtils.isEmpty(key)) {
wrapper.and((qw) -> {
qw.eq("attr_id",key).or().like("attr_name",key);
});
}
IPage<AttrEntity> page = this.page(new Query<AttrEntity>().getPage(params),wrapper);
PageUtils pageUtils = new PageUtils(page);
//注意页面还要显示 分类名字、分组名字,上面的 page 仅包括pms_attr的数据
//AttrRespVo attrRespVo = new AttrRespVo(); 若是在此处创建,则最后赋值之后得到的 respVos{屏幕尺寸,屏幕尺寸,屏幕尺寸} 是多个相同信息的对象
List<AttrEntity> records = page.getRecords();
List<AttrRespVo> respVos = records.stream().map((attrEntity) -> {
//注意,这里创建 AttrRespVo 只能在此处创建,不可再map方法外。即每次流收集之后都要清空对象
AttrRespVo attrRespVo = new AttrRespVo();
BeanUtils.copyProperties(attrEntity, attrRespVo);
//设置分类名
CategoryEntity categoryEntity = categoryService.getById(attrEntity.getCatelogId());
//ifPresent 如果对象非空则执行函数体
Optional.ofNullable(categoryEntity).ifPresent((entity)->attrRespVo.setCatelogName(entity.getName()));
//设置分组名
AttrAttrgroupRelationEntity relationEntity = relationDao.selectOne(new QueryWrapper<AttrAttrgroupRelationEntity>()
.eq("attr_id", attrEntity.getAttrId()));
AttrGroupEntity attrGroupEntity = attrGroupService.getById(relationEntity.getAttrGroupId());
if (attrGroupEntity != null)
attrRespVo.setGroupName(attrGroupEntity.getAttrGroupName());
return attrRespVo;
}).collect(Collectors.toList());
pageUtils.setList(respVos);
return pageUtils;
}
3)、修改回显
修改回显的问题,查看接口文档(07、查询属性详情)
发现请求参数还有"catelogPath": [2, 34, 225] //分类完整路径
,在 vo 类里新增该属性,并修改业务类
@RequestMapping("/info/{attrId}")
public R info(@PathVariable("attrId") Long attrId){
AttrRespVo attrRespVo = attrService.getAttrInfo(attrId);
return R.ok().put("attr", attrRespVo);
}
@Override
public AttrRespVo getAttrInfo(Long attrId) {
AttrRespVo attrRespVo = new AttrRespVo();
AttrEntity entity = this.getById(attrId);
BeanUtils.copyProperties(entity,attrRespVo);
//attrGroupId
AttrAttrgroupRelationEntity relationEntity = relationDao.selectOne(new QueryWrapper<AttrAttrgroupRelationEntity>().eq("attr_id", attrId));
attrRespVo.setAttrGroupId(relationEntity.getAttrGroupId());
//catelogPath
attrRespVo.setCatelogPath(categoryService.findCatelogPath(entity.getCatelogId()));
return attrRespVo;
}
4)、修改
修改需要改关联的表,所以重写业务类
@RequestMapping("/update")
public R update(@RequestBody AttrVo attrVo){
attrService.updateAttr(attrVo);
return R.ok();
}
@Transactional //修改、保存都需要开启事务
@Override
public void updateAttr(AttrVo attrVo) {
//普通修改
AttrEntity attrEntity = new AttrEntity();
BeanUtils.copyProperties(attrVo,attrEntity);
this.updateById(attrEntity);
//修改关联表:前端可能会传递属性分组id
AttrAttrgroupRelationEntity relationEntity = new AttrAttrgroupRelationEntity();
relationEntity.setAttrGroupId(attrVo.getAttrGroupId());
attrAttrgroupRelationService.update(relationEntity, new UpdateWrapper<AttrAttrgroupRelationEntity>().eq("attr_id",attrVo.getAttrId()));
}
2、销售属性
发现和前面获取分类规格参数是使用同一张数据库表,通过 attr_type
字段来区分是销售属性还是基本属性。所以直接修改前面的业务类方法
同时注意,这里销售属性的修改和新增都是访问上面 同样的路径,所以都需要修改
注意对于销售属性,是没有属性分组的
1)、枚举类
为了避免出现魔法值,这里销售属性、基本属性的判定定义枚举类
public class ProductConstant {
public enum AttrEnum{
ATTR_TYPE_BASE(1,"基本属性"),ATTR_TYPE_SALE(0,"销售属性");
private int code;
private String msg;
AttrEnum(int code,String msg){
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
}
2)、修改列表方法
/**
* 获取分类属性列表,都是查询同一张库表,所以可以写在一个方法里
* 基本属性:/product/attr/base/list/{catelogId}
* 销售属性:/product/attr/sale/list/{catelogId}
* @param catelogId 商品分类id
* @param type sale 销售属性 base 规格参数
*/
@GetMapping("/{type}/list/{catelogId}")
public R list(@RequestParam Map<String, Object> params
,@PathVariable("catelogId") Long catelogId
,@PathVariable("type") String type){
PageUtils page = attrService.queryBaseAttrPage(params,catelogId,type);
return R.ok().put("page", page);
}
//获取分类规格参数
//@param type:sale 销售属性 base 规格参数
PageUtils queryBaseAttrPage(Map<String, Object> params, Long catelogId, String type);
@Override
public PageUtils queryBaseAttrPage(Map<String, Object> params, Long catelogId, String type) {
QueryWrapper<AttrEntity> wrapper = new QueryWrapper<>();
//添加属性类型作为查询条件,对于不同的路径查询不同类型的属性
wrapper.eq("attr_type","base".equalsIgnoreCase(type)?
ProductConstant.AttrEnum.ATTR_TYPE_BASE.getCode():
ProductConstant.AttrEnum.ATTR_TYPE_SALE.getCode());
if (catelogId != 0){ //即选择了左侧商品分类情况
wrapper.eq("catelog_id",catelogId);
}
// 模糊查询:拿到参数中的 key
String key = (String) params.get("key");
if (!StringUtils.isEmpty(key)) {
wrapper.and((qw) -> {
qw.eq("attr_id",key).or().like("attr_name",key);
});
}
IPage<AttrEntity> page = this.page(new Query<AttrEntity>().getPage(params),wrapper);
PageUtils pageUtils = new PageUtils(page);
//注意页面还要显示 分类名字、分组名字,上面的 page 仅包括pms_attr的数据
//AttrRespVo attrRespVo = new AttrRespVo(); 若是在此处创建,则最后赋值之后得到的 respVos{屏幕尺寸,屏幕尺寸,屏幕尺寸} 是多个相同信息的对象
List<AttrEntity> records = page.getRecords();
List<AttrRespVo> respVos = records.stream().map((attrEntity) -> {
//注意,这里创建 AttrRespVo 只能在此处创建,不可再map方法外。即每次流收集之后都要清空对象
AttrRespVo attrRespVo = new AttrRespVo();
BeanUtils.copyProperties(attrEntity, attrRespVo);
//设置分类名
CategoryEntity categoryEntity = categoryService.getById(attrEntity.getCatelogId());
//ifPresent 如果对象非空则执行函数体
Optional.ofNullable(categoryEntity).ifPresent((entity)->attrRespVo.setCatelogName(entity.getName()));
//设置分组名
// 注意对于销售属性,是没有属性分组的,所以这里只有基本属性才设置
if ("base".equalsIgnoreCase(type)){
AttrAttrgroupRelationEntity relationEntity = relationDao.selectOne(new QueryWrapper<AttrAttrgroupRelationEntity>()
.eq("attr_id", attrEntity.getAttrId()));
AttrGroupEntity attrGroupEntity = attrGroupService.getById(relationEntity.getAttrGroupId());
if (attrGroupEntity != null)
attrRespVo.setGroupName(attrGroupEntity.getAttrGroupName());
}
return attrRespVo;
}).collect(Collectors.toList());
pageUtils.setList(respVos);
return pageUtils;
}
3)、修改新增、修改、回显
新增
@Transactional //涉及多个表,开启事务
@Override
public void saveAttr(AttrVo attrVo) {
//1、保存实体类拥有的属性 pms_attr
AttrEntity entity = new AttrEntity();
BeanUtils.copyProperties(attrVo,entity); //将前者数据复制进后者,仅在属性名相同条件下生效 org.springframework.beans.BeanUtils
this.save(entity);
// 销售属性是没有属性分组的
if (ProductConstant.AttrEnum.ATTR_TYPE_BASE.getCode() == entity.getAttrType()){
//2、保存关联表属性 pms_attr_attrgroup_relation
AttrAttrgroupRelationEntity relationEntity = new AttrAttrgroupRelationEntity();
// 注意此处有个错误,因为新增时没有 attrId,所以如果是下面写法会导致relationEntity没有attrId
//BeanUtils.copyProperties(attrVo,relationEntity);
relationEntity.setAttrGroupId(attrVo.getAttrGroupId());
relationEntity.setAttrId(entity.getAttrId()); //这里只能用entity,因为新增之后生成attrId
attrAttrgroupRelationService.save(relationEntity);
}
}
回显
@Override
public AttrRespVo getAttrInfo(Long attrId) {
AttrRespVo attrRespVo = new AttrRespVo();
AttrEntity entity = this.getById(attrId);
BeanUtils.copyProperties(entity,attrRespVo);
// 销售属性是没有属性分组的
if (ProductConstant.AttrEnum.ATTR_TYPE_BASE.getCode() == entity.getAttrType()){
//attrGroupId
AttrAttrgroupRelationEntity relationEntity = relationDao.selectOne(new QueryWrapper<AttrAttrgroupRelationEntity>().eq("attr_id", attrId));
attrRespVo.setAttrGroupId(relationEntity.getAttrGroupId());
}
//catelogPath
attrRespVo.setCatelogPath(categoryService.findCatelogPath(entity.getCatelogId()));
return attrRespVo;
}
修改
@Transactional //修改、保存都需要开启事务
@Override
public void updateAttr(AttrVo attrVo) {
//普通修改
AttrEntity attrEntity = new AttrEntity();
BeanUtils.copyProperties(attrVo,attrEntity);
this.updateById(attrEntity);
// 注意对于销售属性,是没有属性分组的,所以这里只有基本属性才设置
if (ProductConstant.AttrEnum.ATTR_TYPE_BASE.getCode() == attrVo.getAttrType()){
//修改关联表:前端可能会传递属性分组id
AttrAttrgroupRelationEntity relationEntity = new AttrAttrgroupRelationEntity();
relationEntity.setAttrGroupId(attrVo.getAttrGroupId());
attrAttrgroupRelationService.update(relationEntity, new UpdateWrapper<AttrAttrgroupRelationEntity>().eq("attr_id",attrVo.getAttrId()));
//注意:属性分组不是必填项,所以可能原来的数据没有属性分组,修改后才有,则此时这里的修改在属性分组表里面就是新增操作
}
}
3、属性分组关联属性
1)、列表
查看接口文档 10、获取属性分组的关联的所有属性
编写接口
AttrGroupController
/**
* 获取指定分组关联的所有属性
*/
@GetMapping("/{attrgroupId}/attr/relation")
public R attrRelation(@PathVariable("attrgroupId") Long attrgroupId){
List<AttrEntity> list= attrService.getRelationAttr(attrgroupId);
return R.ok().put("data", list);
}
//获取指定分组关联的所有属性
List<AttrEntity> getRelationAttr(Long attrgroupId);
AttrServiceImpl
/**
* 展示指定分组下关联的属性
* @param attrgroupId 组id
* @return 相关所有基本属性
*/
@Override
public List<AttrEntity> getRelationAttr(Long attrgroupId) {
List<AttrAttrgroupRelationEntity> relationEntities = relationDao.selectList(new QueryWrapper<AttrAttrgroupRelationEntity>().eq("attr_group_id", attrgroupId));
/*
ifPresent:如果不为空
orElse:如果为空
isPresent:返回的是 boolean 值,true存在
*/
if (Optional.of(relationEntities).isPresent()) {
List<Long> attrIds = relationEntities.stream().map(AttrAttrgroupRelationEntity::getAttrId).collect(Collectors.toList());
return (List<AttrEntity>) this.listByIds(attrIds); //此处attrIds如果为空就会报错
}
return null;
}
2)、删除
注意请求参数是对象数组形式 [{“attrId”:1,“attrGroupId”:2}]
请求方式是 POST,post 请求会携带json数据,要携带自定义参数对象,需要使用 requestBody 注解,
AttrGroupController
/**
* 删除属性与分组的关联关系
* 注意请求参数是对象数组形式 [{"attrId":1,"attrGroupId":2}]
* Post 请求会携带json数据,若携带自定义参数对象,需要使用 @RequestBody 注解
*/
@PostMapping("/attr/relation/delete")
public R deleteRelation(@RequestBody AttrGroupRelationVo[] relationVo){
attrService.deleteRelation(relationVo);
return R.ok();
}
//删除属性与分组的关联关系
void deleteRelation(AttrGroupRelationVo[] relationVo);
AttrServiceImpl
//删除关联
@Override
public void deleteRelation(AttrGroupRelationVo[] relationVo) {
// 转成 List<AttrAttrgroupRelationEntity>
List<AttrAttrgroupRelationEntity> entities = Arrays.asList(relationVo).stream().map((item) -> {
// 将 item对应属性拷贝到 relationEntity对象中
AttrAttrgroupRelationEntity relationEntity = new AttrAttrgroupRelationEntity();
BeanUtils.copyProperties(item, relationEntity);
return relationEntity;
}).collect(Collectors.toList());
relationDao.deleteBatchRelation(entities);
}
AttrAttrgroupRelationDao
//删除属性与分组的关联关系
void deleteBatchRelation(@Param("entities") List<AttrAttrgroupRelationEntity> entities);
<delete id="deleteBatchRelation">
DELETE FROM `pms_attr_attrgroup_relation` WHERE
<!-- 循环遍历进行删除 使用的是 or-->
<foreach collection="entities" item="item" separator=" OR ">
( attr_id=#{item.attrId} AND attr_group_id=#{item.attrGroupId} )
</foreach>
</delete>
3)、显示未关联属性
获取属性分组没有关联的其他属性
AttrGroupController
/**
* 获取属性分组没有关联的其他属性
* 获取属性分组里面还没有关联的本分类里面的其他基本属性,方便添加新的关联
*/
@GetMapping("/{attrgroupId}/noattr/relation")
public R attrNoRelation(@PathVariable("attrgroupId") Long attrgroupId,
@RequestParam Map<String, Object> params){
PageUtils page = attrService.getNoRelationAttr(params,attrgroupId);
return R.ok().put("page", page);
}
//获取属性分组里面还没有关联的本分类里面的其他基本属性,方便添加新的关联
PageUtils getNoRelationAttr(Map<String, Object> params, Long attrgroupId);
AttrServiceImpl
//新增关联时,可选的属性
@Override
public PageUtils getNoRelationAttr(Map<String, Object> params, Long attrgroupId) {
QueryWrapper<AttrEntity> wrapper = new QueryWrapper<>();
// 模糊查询:拿到参数中的 key
String key = (String) params.get("key");
if (!StringUtils.isEmpty(key)) {
wrapper.and((qw) -> {
qw.eq("attr_id", key).or().like("attr_name", key);
});
}
// 1、当前分组只能关联自己所在商品分类下的属性,且属性一定是基本属性,因为销售属性不可以绑定属性分组
Long catelogId = attrGroupService.getById(attrgroupId).getCatelogId();
wrapper.eq("catelog_id", catelogId)
.eq("attr_type", ProductConstant.AttrEnum.ATTR_TYPE_BASE.getCode());
// 2、属性未被其他分组关联,且未被自己关联(查询出已被关联的属性)
//拿到当前分类下的分组id
List<Long> groupIds = attrGroupDao.selectList(
new QueryWrapper<AttrGroupEntity>().eq("catelog_id", catelogId)
)
.stream()
.map((entity) -> entity.getAttrGroupId())
.collect(Collectors.toList());
//拿到分组关联的属性id
List<Long> attrIds = relationDao.selectList(
new QueryWrapper<AttrAttrgroupRelationEntity>().in("attr_group_id", groupIds)
)
.stream()
.map(entity -> entity.getAttrId())
.collect(Collectors.toList());
//查询出已被关联的属性
wrapper.notIn("attr_id",attrIds);
IPage<AttrEntity> page = this.page(new Query<AttrEntity>().getPage(params), wrapper);
PageUtils pageUtils = new PageUtils(page);
return pageUtils;
}
4)、添加
接口文档:添加属性与分组关联关系
AttrGroupController
/**
* 添加属性与分组关联关系
* 参数:[{"attrGroupId": 0, //分组id "attrId": 0, //属性id}]
*/
@PostMapping("/attr/relation")
public R addRelation(@RequestBody List<AttrAttrgroupRelationEntity> entityList){
relationService.saveBatch(entityList);
return R.ok();
}
后台商品服务 - 发布商品
整理会员模块
会员模块注册到 nacos,来个三件套:
-
加入 gulimall-common 模块的依赖(内含 nacos注册中心依赖)
-
配置文件中 指定服务名称、注册中心地址
# nacos application: name: gulimall-member cloud: nacos: discovery: server-addr: 127.0.0.1:8848
-
启动类上加上注册发现注解
@EnableDiscoveryClient
网关模块增加会员路由
spring:
cloud:
gateway:
routes:
- id: member_route
uri: lb://gulimall-member
# 此处断言路径范围较小,需要写在前面,否则报错404
predicates:
- Path=/api/member/**
filters:
# 将 /api/** 路由到 /**
- RewritePath=/api/(?<segment>.*),/$\{segment}
1、获取分类关联的品牌
在品牌管理里面,各个品牌都可以关联指定商品分类
在商品维护-发布商品里面,选择商品分类之后要反向显示关联的品牌
查看接口文档 14、获取分类关联的品牌
发现需要返回 “brandId”: 0、 “brandName”: “string” ,定义一个 VO 对象用于封装要返回数据
@Data
public class BrandVo {
/**
* "brandId": 0,
* "brandName": "string",
*/
private Long brandId;
private String brandName;
}
编写接口
CategoryBrandRelationController
/**
* 获取分类关联的品牌
*/
@GetMapping("/brands/list")
public R listBrands(@RequestParam(value = "catId",required = true) Long catId){
List<BrandEntity> entities = categoryBrandRelationService.getBrandCatId(catId);
List<BrandVo> voList = entities.stream().map(entity -> {
BrandVo vo = new BrandVo();
vo.setBrandId(entity.getBrandId());
vo.setBrandName(entity.getName());
return vo;
}).collect(Collectors.toList());
return R.ok().put("data", voList);
}
//根据商品分类查询品牌
List<BrandEntity> getBrandCatId(Long catId);
CategoryBrandRelationServiceImpl
//根据商品分类查询品牌
@Override
public List<BrandEntity> getBrandCatId(Long catId) {
List<CategoryBrandRelationEntity> relationList = categoryBrandRelationDao
.selectList(new QueryWrapper<CategoryBrandRelationEntity>().eq("catelog_id", catId));
List<BrandEntity> brandEntityList = relationList.stream()
.map(entity -> brandDao.selectById(entity.getBrandId())).collect(Collectors.toList());
return brandEntityList;
}
2、获取分类下所有分组&关联属性
基本信息输入成功后,就会跳转到规格参数,此页会根据所选分类 id 查询出对应数据
编写接口
AttrGroupController
/**
* 获取分类下所有分组&关联属性
*/
@GetMapping("/{catelogId}/withattr")
public R getAttrGroupWithAttrs(@PathVariable("catelogId") Long catelogId){
List<AttrGroupWithAttrsVo> vos= attrGroupService.getAttrGroupWithAttrsByCatelogId(catelogId);
return R.ok().put("data", vos);
}
//获取指定商品分类下所有属性分组,并获取分组下所有属性
List<AttrGroupWithAttrsVo> getAttrGroupWithAttrsByCatelogId(Long catelogId);
AttrGroupServiceImpl
@Override
public List<AttrGroupWithAttrsVo> getAttrGroupWithAttrsByCatelogId(Long catelogId) {
//查找属性分组
List<AttrGroupEntity> groupList = this.list(new QueryWrapper<AttrGroupEntity>().eq("catelog_id", catelogId));
List<AttrGroupWithAttrsVo> vos = groupList.stream().map(entity -> {
AttrGroupWithAttrsVo vo = new AttrGroupWithAttrsVo();
//查找属性分组下所有属性
List<AttrEntity> attrList = attrService.getRelationAttr(entity.getAttrGroupId());
BeanUtils.copyProperties(entity, vo);
vo.setAttrs(attrList);
return vo;
}).collect(Collectors.toList());
return vos;
}
3、商品新增业务
1)、封装 VO
新增商品信息填好之后,点击提交,会向后端传递一串 JSON,肯定要自定义 VO 对象来封装数据
我们将 json 放到 json解析网站上 并生成对应得实体类,注意有些参与计算的属性 如 int price ,需将类型更改为 BigDecimal
主类是 SpuSaveVo
@Data
public class SpuSaveVo {
private String spuName;
private String spuDescription;
private Long catalogId;
private Long brandId;
private BigDecimal weight;
private int publishStatus;
private List<String> decript;
private List<String> images;
private Bounds bounds;
private List<BaseAttrs> baseAttrs;
private List<Skus> skus;
}
其他类如下
//设置积分:购物积分、成长积分
@Data
public class Bounds {
private BigDecimal buyBounds;
private BigDecimal growBounds;
}
//填写的基本属性信息
@Data
public class BaseAttrs {
private Long attrId;
private String attrValues;
//快速展示【是否展示在介绍上;0-否 1-是】
private int showDesc;
}
//sku信息
@Data
public class Skus {
private List<Attr> attr;
private String skuName;
private BigDecimal price;
private String skuTitle;
private String skuSubtitle;
private List<Images> images;
private List<String> descar;
private int fullCount;
private BigDecimal discount;
private int countStatus;
private BigDecimal fullPrice;
private BigDecimal reducePrice;
private int priceStatus;
private List<MemberPrice> memberPrice;
}
@Data
public class Images {
private String imgUrl;
private int defaultImg;
}
//会员价
@Data
public class MemberPrice {
private Long id;
private String name;
private BigDecimal price;
}
2)、业务流程
主要流程如下:
- 保存spu基本信息 pms_spu_info
- 保存Spu的描述信息 pms_spu_info_desc(spu_id、decript)
- 保存Spu的图片集 pms_spu_images
- 保存spu的规格参数(基本属性) pms_product_attr_value
- 保存SPU的积分信息:需要跨服务远程调用
gulimall_sms sms =》 sms_spu_bounds - 保存当前Spu对应的所有SKU信息
- SKU的基本信息 pms_sku_info
- SKU的图片信息 pms_sku_images
- SKU的销售属性信息 pms_sku_sale_attr_value
- SKU的优惠、满减等信息:需要跨服务远程调用
gulimall_sms 库-》sms_sku_ladder \sms_sku_full_reduction\sms_member_price
因为第5步、第6.4步涉及到跨服务调用,可先编写其他接口,最后在编写这两个接口
3)、业务代码
编写接口 SpuInfoController
/**
* 保存
*/
@RequestMapping("/save")
public R save(@RequestBody SpuSaveVo vo){
spuInfoService.saveSpuInfo(vo);
return R.ok();
}
业务类 SpuInfoServiceImpl
@Autowired
SpuInfoDescService spuInfoDescService;
@Autowired
SpuImagesService spuImagesService;
@Autowired
AttrService attrService;
@Autowired
ProductAttrValueService productAttrValueService;
@Autowired
SkuInfoService skuInfoService;
@Autowired
SkuImagesService skuImagesService;
@Autowired
SkuSaleAttrValueService skuSaleAttrValueService;
@Autowired
CouponFeignService couponFeignService;
// TODO 高级部分完善
@Transactional
@Override
public void saveSpuInfo(SpuSaveVo vo) {
// 1、保存spu基本信息 pms_spu_info
SpuInfoEntity spuInfoEntity = new SpuInfoEntity();
BeanUtils.copyProperties(vo,spuInfoEntity);
spuInfoEntity.setCreateTime(new Date());
spuInfoEntity.setUpdateTime(new Date());
this.save(spuInfoEntity); //MP设置主键自增后,有主键回填
// 2、保存Spu的描述信息 pms_spu_info_desc(spu_id、decript)
// 注意此表的spu_id是非自增的,是设置进的值,需要在实体类上使用 @TableId(type = IdType.INPUT)
SpuInfoDescEntity spuInfoDescEntity = new SpuInfoDescEntity();
// SpuInfoEntity保存后 MP会回填主键,取得 spuId 设置到 Desc中
spuInfoDescEntity.setSpuId(spuInfoEntity.getId());
// 以逗号来拆分
List<String> decript = vo.getDecript();
spuInfoDescEntity.setDecript(String.join(",",decript));
spuInfoDescService.save(spuInfoDescEntity);
// 3、保存Spu的图片集 pms_spu_images
List<String> imageList = vo.getImages();
spuImagesService.saveImages(spuInfoEntity.getId(),imageList);
// 4、保存spu的规格参数(基本属性) pms_product_attr_value
List<BaseAttrs> baseAttrs = vo.getBaseAttrs();
List<ProductAttrValueEntity> list = baseAttrs.stream().map(baseAttr -> {
AttrEntity attrEntity = attrService.getById(baseAttr.getAttrId());
ProductAttrValueEntity valueEntity = new ProductAttrValueEntity();
BeanUtils.copyProperties(attrEntity, valueEntity);
valueEntity.setSpuId(spuInfoEntity.getId());
valueEntity.setAttrValue(baseAttr.getAttrValues());
valueEntity.setQuickShow(baseAttr.getShowDesc());
return valueEntity;
}).collect(Collectors.toList());
productAttrValueService.saveBatch(list);
// 5、保存SPU的积分信息:需要跨服务远程调用 gulimall_sms sms => sms_spu_bounds
Bounds bounds = vo.getBounds();
SpuBoundTo spuBoundTo = new SpuBoundTo(); //多个服务之间传递数据封装为TO
BeanUtils.copyProperties(bounds,spuBoundTo);
spuBoundTo.setSpuId(spuInfoEntity.getId());
// 远程服务调用
R r = couponFeignService.saveSpuBounds(spuBoundTo);
if (r.getCode() != 0) {
log.error("远程保存优惠信息失败");
}
// 6、保存当前Spu对应的所有SKU信息
List<Skus> skus = vo.getSkus();
if (skus != null && skus.size() > 0){
skus.forEach(sku -> {
//6.1、SKU的基本信息 pms_sku_info
SkuInfoEntity skuInfoEntity = new SkuInfoEntity();
BeanUtils.copyProperties(vo, skuInfoEntity); //catalogId、brandId
BeanUtils.copyProperties(sku, skuInfoEntity); //skuName、price、skuTitle、skuSubtitle;
skuInfoEntity.setSpuId(spuInfoEntity.getId());
skuInfoEntity.setSaleCount(0L);
//skuDefaultImg
List<Images> images = sku.getImages();
String defaultImage = "";
for (Images image : images) {
if (image.getDefaultImg() == 1) defaultImage = image.getImgUrl();
}
skuInfoEntity.setSkuDefaultImg(defaultImage);
skuInfoService.save(skuInfoEntity);
//6.2、SKU的图片信息 pms_sku_images
List<SkuImagesEntity> skuImagesList = images.stream().map(image -> {
SkuImagesEntity skuImagesEntity = new SkuImagesEntity();
BeanUtils.copyProperties(image, skuImagesEntity);
skuImagesEntity.setSkuId(skuInfoEntity.getSkuId());
return skuImagesEntity;
}).filter(skuImagesEntity -> {
// 没有图片路径的无需保存
//返回 true 就是需要, false 就是剔除:即对最后结果进行ImgUrl过滤空的处理
return !StringUtils.isEmpty(skuImagesEntity.getImgUrl());
}).collect(Collectors.toList());
skuImagesService.saveBatch(skuImagesList);
//6.3、SKU的销售属性信息 pms_sku_sale_attr_value
List<Attr> attrs = sku.getAttr();
// 保存 sku 销售属性
List<SkuSaleAttrValueEntity> skuSaleAttrValueEntities = attrs.stream().map(attr -> {
SkuSaleAttrValueEntity skuSaleAttrValueEntity = new SkuSaleAttrValueEntity();
BeanUtils.copyProperties(attr, skuSaleAttrValueEntity);
skuSaleAttrValueEntity.setSkuId(skuInfoEntity.getSkuId());
return skuSaleAttrValueEntity;
}).collect(Collectors.toList());
skuSaleAttrValueService.saveBatch(skuSaleAttrValueEntities);
//6.4、SKU的优惠、满减等信息:需要跨服务远程调用 gulimall_sms ->sms_sku_ladder \sms_sku_full_reduction\sms_member_price
SkuReductionTo skuReductionTo = new SkuReductionTo();
BeanUtils.copyProperties(sku,skuReductionTo);
skuReductionTo.setSkuId(skuInfoEntity.getSkuId());
if (skuReductionTo.getFullCount() > 0 || skuReductionTo.getFullPrice().compareTo(new BigDecimal("0")) == 1) {
R r1 = couponFeignService.saveSkuReduction(skuReductionTo);
if (r1.getCode() != 0) {
log.error("远程保存sku优惠信息失败");
}
}
});
}
}
其他业务类
SpuImagesService
@Override
public void saveImages(Long spuId, List<String> imageList) {
if (imageList == null || imageList.size() == 0){
log.error("图片为空!!!!!!");
}else {
List<SpuImagesEntity> entityList = imageList.stream().map(image -> {
SpuImagesEntity entity = new SpuImagesEntity();
entity.setSpuId(spuId);
entity.setImgUrl(image);
return entity;
}).collect(Collectors.toList());
this.saveBatch(entityList);
}
}
4)、远程调用
在第 5 步、第 6.4 步会调用 gulimall-coupon 服务的业务类
使用 feign 实现远程调用:
-
gulimall-coupon 配置注册进 nacos
启动类使用注解@EnableDiscoveryClient
spring: # nacos application: name: gulimall-coupon cloud: nacos: discovery: server-addr: 127.0.0.1:8848
-
gulimall-product 启动类上加注解
@EnableFeignClients(basePackages = "afei.product.feign")
-
gulimall-product 声明远程接口
接口上使用@FeignClient("gulimall-coupon")
注解,val 即为被调用的服务名@FeignClient("gulimall-coupon") public interface CouponFeignService
gulimall-product 服务编写远程接口
CouponFeignService
@FeignClient("gulimall-coupon")
public interface CouponFeignService {
/**
* 注意远程调用传递对象数据时,是转化为 JSON 格式传递,所以传送、接收双方都需要使用@RequestBody注解,且解析JSON的方式要一致
* 远程调用:是通过注册中心找到gulimall-coupon服务,再向指定路径发送请求,将 JSON 参数放在请求体位置
* 所以 这里的请求参数对象 和 接收方的请求参数对象 不需要是同一个类,只需要json数据模型兼容即可
* @param spuBoundTo
* @return
*/
@PostMapping("/coupon/spubounds/save")
R saveSpuBounds(@RequestBody SpuBoundTo spuBoundTo);
@PostMapping("/coupon/skufullreduction/saveinfo")
R saveSkuReduction(@RequestBody SkuReductionTo skuReductionTo);
}
此处传递参数使用了自定义 TO 对象,对象声明在 common 里面,如此就可以在两个服务使用 TO 对象进行数据传递
只不过此处接收参数的服务方并没有使用 TO,而是用自己的 entity ,如下
SpuBoundsController
@PostMapping("/save")
public R save(@RequestBody SpuBoundsEntity spuBounds){
spuBoundsService.save(spuBounds);
return R.ok();
}
SkuFullReductionController
@PostMapping("/saveinfo")
public R saveInfo(@RequestBody SkuReductionTo skuReductionTo){
skuFullReductionService.saveSkuReduction(skuReductionTo);
return R.ok();
}
//保存SKU的优惠、满减等信息 sms_sku_ladder \sms_sku_full_reduction\sms_member_price
@Override
public void saveSkuReduction(SkuReductionTo skuReductionTo) {
//满折信息 sms_sku_ladder
SkuLadderEntity skuLadderEntity = new SkuLadderEntity();
BeanUtils.copyProperties(skuReductionTo,skuLadderEntity); //skuId fullCount discount
skuLadderEntity.setAddOther(skuReductionTo.getCountStatus());
if (skuLadderEntity.getFullCount() > 0) {
skuLadderService.save(skuLadderEntity);
}
//满减信息 sms_sku_full_reduction
SkuFullReductionEntity skuFullReductionEntity = new SkuFullReductionEntity();
BeanUtils.copyProperties(skuReductionTo,skuFullReductionEntity);
// BigDecimal 用 compareTo来比较
if (skuFullReductionEntity.getFullPrice().compareTo(new BigDecimal("0")) == 1) {
this.save(skuFullReductionEntity);
}
//会员价信息 sms_member_price
List<MemberPrice> memberPriceList = skuReductionTo.getMemberPrice();
List<MemberPriceEntity> collect = memberPriceList.stream().map(memberPrice -> {
MemberPriceEntity priceEntity = new MemberPriceEntity();
priceEntity.setSkuId(skuReductionTo.getSkuId());
priceEntity.setMemberPrice(memberPrice.getPrice());
priceEntity.setMemberLevelName(memberPrice.getName());
priceEntity.setMemberLevelId(memberPrice.getId());
priceEntity.setAddOther(1);
return priceEntity;
}).filter(memberPriceEntity -> {
// 会员对应价格等于0 过滤掉
return memberPriceEntity.getMemberPrice().compareTo(new BigDecimal("0")) == 1;
}).collect(Collectors.toList());
memberPriceService.saveBatch(collect);
}
5)、问题
pms_spu_info_desc
表中没有自增id,仅有 spu_id 字段是需要手动插入的
解决思路:
在实体类上使用 MP 注解 @TableId(type = IdType.INPUT)
@Data
@TableName("pms_spu_info_desc")
public class SpuInfoDescEntity implements Serializable {
private static final long serialVersionUID = 1L;
//商品id
@TableId(type = IdType.INPUT)
private Long spuId;
//商品介绍
private String decript;
}
sku_images
表中 img_url
字段为空
sku_images 中有很多图片都是为空,因此我们需要在程序中处理这个数据,空数据不写入到数据库中
解决思路:
skuImages 保存部分代码、如果 ImgUrl 为空则进行过滤
}).filter(entity ->{
//返回 true 需要 false 过滤
return !StringUtils.isEmpty(entity.getImgUrl());
}).collect(Collectors.toList());
sku满减以及打折信息
数据出现错误
有部分数据 为0
解决思路:
在代码中过滤对应为0的数据
// 满几件 大于0 可以添加 满多少钱 至少要大于0
if (skuReductionTo.getFullCount() > 0 || skuReductionTo.getFullPrice().compareTo(new BigDecimal("0")) == 1) {
R r1 = couponFeignService.saveSkuReduction(skuReductionTo);
if (r1.getCode() != 0) {
log.error("远程保存sku优惠信息失败");
}
}
远程服务中也进行对应修改
/**
保存 商品阶梯价格
件数 大于0才能进行修改
**/
if (skuLadderEntity.getFullCount() > 0) {
skuLadderService.save(skuLadderEntity);
}
/**
保存商品满减信息
**/
// BigDecimal 用 compareTo来比较
if (skuFullReductionEntity.getFullPrice().compareTo(new BigDecimal("0")) == 1) {
this.save(skuFullReductionEntity);
}
/**
保存商品会员价格
也进行了过滤数据
**/
}).filter(item -> {
// 会员对应价格等于0 过滤掉
return item.getMemberPrice().compareTo(new BigDecimal("0")) == 1;
}).collect(Collectors.toList());
4、商品管理 SPU 检索
查询刚刚发布的商品,并能进行对应的条件查询
编写接口
SpuInfoController
@RequestMapping("/list")
public R list(@RequestParam Map<String, Object> params){
PageUtils page = spuInfoService.queryPageByCondition(params);
return R.ok().put("page", page);
}
SpuInfoServiceImpl
@Override
public PageUtils queryPageByCondition(Map<String, Object> params) {
QueryWrapper<SpuInfoEntity> wrapper = new QueryWrapper<>();
//关键字查询
String key = (String) params.get("key");
if (!StringUtils.isEmpty(key)) {
wrapper.and((w) ->{
w.eq("sku_id",key).or().like("sku_name",key);
});
}
//根据分类查询
String catelogId = (String) params.get("catelogId");
if (!StringUtils.isEmpty(catelogId) && !"0".equalsIgnoreCase(catelogId)) {
wrapper.eq("catalog_id",catelogId);
}
//根据品牌查询
String brandId = (String) params.get("brandId");
if (!StringUtils.isEmpty(brandId) && !"0".equalsIgnoreCase(brandId)) {
wrapper.eq("brand_id",brandId);
}
//根据状态查询
String status = (String) params.get("status");
if (!StringUtils.isEmpty(status)) {
wrapper.eq("publish_status",status);
}
IPage<SpuInfoEntity> page = this.page(
new Query<SpuInfoEntity>().getPage(params),
wrapper
);
return new PageUtils(page);
}
商品查询还会显示创建时间、更新时间,为了更好的在页面显示,在配置文件指定返回的日期格式
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
5、商品管理 SKU 检索
查询具体的商品管理 库存
编写接口
SkuInfoController
@RequestMapping("/list")
public R list(@RequestParam Map<String, Object> params){
PageUtils page = skuInfoService.queryPageByCondition(params);
return R.ok().put("page", page);
}
SkuInfoServiceImpl
注意处理关键字查询,还有根据价格区间查询,而价格查询的输入框有默认的数字0,所以对于查询语句需要判断一下
@Override
public PageUtils queryPageByCondition(Map<String, Object> params) {
QueryWrapper<SkuInfoEntity> wrapper = new QueryWrapper<>();
//关键字查询
String key = (String) params.get("key");
if (!StringUtils.isEmpty(key)) {
wrapper.and((w) ->{
w.eq("sku_id",key).or().like("sku_name",key);
});
}
//根据分类查询
String catelogId = (String) params.get("catelogId");
if (!StringUtils.isEmpty(catelogId) && !"0".equalsIgnoreCase(catelogId)) {
wrapper.eq("catalog_id",catelogId);
}
//根据品牌查询
String brandId = (String) params.get("brandId");
if (!StringUtils.isEmpty(brandId) && !"0".equalsIgnoreCase(brandId)) {
wrapper.eq("brand_id",brandId);
}
//价格区间查询
String min = (String) params.get("min");
if (!StringUtils.isEmpty(min)) {
wrapper.ge("price",min);
}
String max = (String) params.get("max");
if (!StringUtils.isEmpty(max) ) {
// 怕前端传递的数据是 abc 等等 所以要抛出异常
try {
BigDecimal bigDecimal = new BigDecimal(max);
if ( bigDecimal.compareTo(new BigDecimal("0")) == 1) {
wrapper.le("price",max);
}
} catch (Exception e) {
e.printStackTrace();
}
}
IPage<SkuInfoEntity> page = this.page(
new Query<SkuInfoEntity>().getPage(params),
wrapper
);
return new PageUtils(page);
}
6、Spu规格获取与更新
点击 商品系统-商品维护-spu管理 ,点击规格后查询出相关规格信息
修改后点击提交即可更新商品规格
AttrController
/**
* 获取spu规格
*/
@RequestMapping("/base/listforspu/{spuId}")
public R baseListforspu(@PathVariable("spuId") Long spuId){
List<ProductAttrValueEntity> list = productAttrValueService.listforspu(spuId);
return R.ok().put("data", list);
}
/**
* 修改商品规格
*/
@PostMapping("/update/{spuId}")
public R updateSpuAttr(@RequestBody List<ProductAttrValueEntity> list,@PathVariable("spuId") Long spuId){
productAttrValueService.updateSpuAttr(spuId,list);
return R.ok();
}
ProductAttrValueServiceImpl
//获取spu规格
@Override
public List<ProductAttrValueEntity> listforspu(Long spuId) {
List<ProductAttrValueEntity> list = this.baseMapper.selectList(new QueryWrapper<ProductAttrValueEntity>().eq("spu_id", spuId));
return list;
}
//修改商品规格
@Override
public void updateSpuAttr(Long spuId, List<ProductAttrValueEntity> list) {
// 1、根据spuid删除记录
this.baseMapper.delete(new QueryWrapper<ProductAttrValueEntity>().eq("spu_id",spuId));
// 2、遍历传递过来的记录 设置 spuId
List<ProductAttrValueEntity> collect = list.stream().map(productAttrValueEntity -> {
productAttrValueEntity.setSpuId(spuId);
return productAttrValueEntity;
}).collect(Collectors.toList());
// 3、批量保存
this.saveBatch(collect);
}
后台 - 仓储服务
整合服务
将 gulimall-ware 注册进 Nacos
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
application:
name: gulimall-ware
启动类使用注解,开启远程服务调用
@EnableFeignClients // 开启openfeign 远程服务调用
@EnableTransactionManagement //开启事务,加不加无所谓,boot底层自动配置
@MapperScan("afei.ware.dao")
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallWareApplication{
}
网关模块指定路由
spring:
cloud:
gateway:
routes:
- id: ware_route
uri: lb://gulimall-ware
# 此处断言路径范围较小,需要写在前面,否则报错404
predicates:
- Path=/api/ware/**
filters:
# 将 /api/** 路由到 /**
- RewritePath=/api/(?<segment>.*),/$\{segment}
1、获取仓库列表
点击库存系统-仓库维护 ,参考文档 01、仓库列表
WareInfoController
@RequestMapping("/list")
public R list(@RequestParam Map<String, Object> params){
PageUtils page = wareInfoService.queryPage(params);
return R.ok().put("page", page);
}
WareInfoServiceImpl
@Override
public PageUtils queryPage(Map<String, Object> params) {
QueryWrapper<WareInfoEntity> wareInfoEntityQueryWrapper = new QueryWrapper<>();
//关键字查询
String key = (String) params.get("key");
if(!StringUtils.isEmpty(key)){
wareInfoEntityQueryWrapper.eq("id",key)
.or().like("name",key)
.or().like("address",key)
.or().like("areacode",key);
}
IPage<WareInfoEntity> page = this.page(
new Query<WareInfoEntity>().getPage(params),
wareInfoEntityQueryWrapper
);
return new PageUtils(page);
}
2、查询商品库存
点击 库存系统-商品库存 ,02、查询商品库存
WareSkuController
//查询商品库存
@RequestMapping("/list")
public R list(@RequestParam Map<String, Object> params){
PageUtils page = wareSkuService.queryPage(params);
return R.ok().put("page", page);
}
WareSkuServiceImpl
@Override
public PageUtils queryPage(Map<String, Object> params) {
QueryWrapper<WareSkuEntity> queryWrapper = new QueryWrapper<>();
//仓库id查询
String skuId = (String) params.get("skuId");
if(!StringUtils.isEmpty(skuId)){
queryWrapper.eq("sku_id",skuId);
}
//商品id查询
String wareId = (String) params.get("wareId");
if(!StringUtils.isEmpty(wareId)){
queryWrapper.eq("ware_id",wareId);
}
IPage<WareSkuEntity> page = this.page(
new Query<WareSkuEntity>().getPage(params),
queryWrapper
);
return new PageUtils(page);
}
3、查询采购需求
库存数量需要和采购单相关,采购单维护-采购需求,选中采购需求,点击合并整单,就会生成采购单
采购需求页面展示数据,03、查询采购需求
PurchaseDetailController
/**
* 查看采购需求列表
*/
@RequestMapping("/list")
public R list(@RequestParam Map<String, Object> params){
PageUtils page = purchaseDetailService.queryPage(params);
return R.ok().put("page", page);
}
PurchaseDetailServiceImpl
//查看采购需求
@Override
public PageUtils queryPage(Map<String, Object> params) {
QueryWrapper<PurchaseDetailEntity> queryWrapper = new QueryWrapper<PurchaseDetailEntity>();
//关键字查询
String key = (String) params.get("key");
if(!StringUtils.isEmpty(key)){
queryWrapper.and(w->{
w.eq("purchase_id",key).or().eq("sku_id",key);
});
}
//根据状态查询
String status = (String) params.get("status");
if(!StringUtils.isEmpty(status)){
queryWrapper.eq("status",status);
}
//根据仓库查询
String wareId = (String) params.get("wareId");
if(!StringUtils.isEmpty(wareId)){
queryWrapper.eq("ware_id",wareId);
}
IPage<PurchaseDetailEntity> page = this.page(
new Query<PurchaseDetailEntity>().getPage(params),
queryWrapper
);
return new PageUtils(page);
}
4、查询未领取的采购单
对于采购单和采购需求都各自有5种不同的状态,所以使用枚举类来封装
public class WareConstant {
public enum PurchaseStatusEnum{
CREATED(0,"新建"),ASSIGNED(1,"已分配"),
RECEIVE(2,"已领取"),FINISH(3,"已完成"),
HASERROR(4,"有异常");
private int code;
private String msg;
PurchaseStatusEnum(int code,String msg){
this.code = code;
this.msg = msg;
}
public int getCode() {return code;}
public String getMsg() {return msg;}
}
public enum PurchaseDetailStatusEnum{
CREATED(0,"新建"),ASSIGNED(1,"已分配"),
BUYING(2,"正在采购"),FINISH(3,"已完成"),
HASERROR(4,"采购失败");
private int code;
private String msg;
PurchaseDetailStatusEnum(int code,String msg){
this.code = code;
this.msg = msg;
}
public int getCode() {return code;}
public String getMsg() {return msg;}
}
}
将采购需求合并为采购单,选中采购需求之后可以选择指定的采购单,将需求合并进去,若没有选择采购单,就会默认新增采购单,再合并进去
在合并页面需要显示未领取的采购单,查看接口文档 05、查询未领取的采购单
PurchaseController
//列表显示未领取的采购单
@RequestMapping("/unreceive/list")
public R unreceivelist(@RequestParam Map<String, Object> params){
PageUtils page = purchaseService.queryPageUnreceivePurchase(params);
return R.ok().put("page", page);
}
PurchaseServiceImpl
@Override
public PageUtils queryPageUnreceivePurchase(Map<String, Object> params) {
IPage<PurchaseEntity> page = this.page(
new Query<PurchaseEntity>().getPage(params),
new QueryWrapper<PurchaseEntity>().eq("status",WareConstant.PurchaseStatusEnum.CREATED.getCode())
.or().eq("status",WareConstant.PurchaseStatusEnum.ASSIGNED.getCode())
);
return new PageUtils(page);
}
5、合并采购需求
将采购需求合并为采购单,选中采购需求之后可以选择指定的采购单,将需求合并进去,若没有选择采购单,就会默认新增采购单,再合并进去
将请求参数封装为 VO 对象来接受
MergeVo
@Data
public class MergeVo {
private Long purchaseId; //整单id
private List<Long> items; //[1,2,3,4] 合并项集合
}
PurchaseController
//合并采购需求,成为采购单
@PostMapping("/merge")
public R merge(@RequestBody MergeVo mergeVo){
purchaseService.mergePurchase(mergeVo);
return R.ok();
}
PurchaseServiceImpl
@Transactional
@Override
public void mergePurchase(MergeVo mergeVo) {
//TODO 确认采购需求单的状态是0,1才可以进行合并整单
Long purchaseId = mergeVo.getPurchaseId();
// 1、如果合并采购需求时没有指定采购单,就新建采购单
if(purchaseId == null){
//新建一个采购单
PurchaseEntity purchaseEntity = new PurchaseEntity();
purchaseEntity.setStatus(WareConstant.PurchaseStatusEnum.CREATED.getCode());
purchaseEntity.setCreateTime(new Date());
purchaseEntity.setUpdateTime(new Date());
this.save(purchaseEntity);
purchaseId = purchaseEntity.getId();
}
// 2、如果合并采购需求时指定了采购单,获取采购需求id
// 更新采购需求单:采购单id、状态
List<Long> items = mergeVo.getItems();
Long finalPurchaseId = purchaseId;
List<PurchaseDetailEntity> collect = items.stream().map(detailId -> {
PurchaseDetailEntity detailEntity = new PurchaseDetailEntity();
detailEntity.setId(detailId);
detailEntity.setPurchaseId(finalPurchaseId);
detailEntity.setStatus(WareConstant.PurchaseDetailStatusEnum.ASSIGNED.getCode());
return detailEntity;
}).collect(Collectors.toList());
detailService.updateBatchById(collect);
// 更新已有采购单的更新时间
PurchaseEntity purchaseEntity = new PurchaseEntity();
purchaseEntity.setId(purchaseId);
purchaseEntity.setUpdateTime(new Date());
this.updateById(purchaseEntity);
}
6、领取采购单
采购人员点击采购,然后就去采购,这里就用接口来实现,通过 postman 输入 JSON 的参数 来请求
业务分析:
- 采购人员通过 APP 点击采购 完成对应的采购需求,这里使用的是 PostMan 来发送请求,发送请求 带的参数是什么? 参数就是采购Id
- 通过采购 Id 查询出采购相关信息,然后设置采购表的状态,设置成采购成功,同时通过这个 id 在 wms_purchase_detail 表中 对应的是 purchase_id 查询采购需求表的数据, 查询到后将他的状态设置成 “正在采购“
PurchaseController
/**
* 领取采购单
* @return
*/
@PostMapping("/received")
public R received(@RequestBody List<Long> ids){
purchaseService.received(ids);
return R.ok();
}
PurchaseServiceImpl
//领取采购单,可以领取多个采购单
@Override
public void received(List<Long> ids) {
//1、确认当前采购单是新建或者已分配状态
List<PurchaseEntity> purchaseCollect = ids.stream().map(id -> {
//查询采购单
PurchaseEntity purchaseEntity = this.getById(id);
return purchaseEntity;
}).filter(item -> {
//过滤采购单状态
if (item.getStatus() == WareConstant.PurchaseStatusEnum.CREATED.getCode() ||
item.getStatus() == WareConstant.PurchaseStatusEnum.ASSIGNED.getCode()) {
return true;
}
return false;
}).map(item->{
//2、改变更新采购单状态、更新时间
item.setStatus(WareConstant.PurchaseStatusEnum.RECEIVE.getCode());
item.setUpdateTime(new Date());
return item;
}).collect(Collectors.toList());
this.updateBatchById(purchaseCollect);
//3、改变采购需求的状态
purchaseCollect.forEach((item)->{
List<PurchaseDetailEntity> entities = detailService.listDetailByPurchaseId(item.getId());
List<PurchaseDetailEntity> detailEntities = entities.stream().map(entity -> {
PurchaseDetailEntity entity1 = new PurchaseDetailEntity();
entity1.setId(entity.getId());
entity1.setStatus(WareConstant.PurchaseDetailStatusEnum.BUYING.getCode());
return entity1;
}).collect(Collectors.toList());
detailService.updateBatchById(detailEntities);
});
}
7、完成采购
采购人员参与采购后,采购就会有他的结果,采购成功、采购失败
请求参数封装为 VO 对象
@Data
public class PurchaseDoneVo {
//采购单id
@NotNull
private Long id;
//采购单中多个采购需求的完成/失败状态详情
public List<PurchaseItemDoneVo> items;
}
//采购需求的完成/失败状态详情
@Data
public class PurchaseItemDoneVo {
private Long itemId;
//采购需求的状态
private Integer status;
//状态原因
private String reason;
}
编写接口
PurchaseController
//完成采购
@PostMapping("/done")
public R finish(@RequestBody PurchaseDoneVo doneVo){
purchaseService.done(doneVo);
return R.ok();
}
PurchaseServiceImpl
@Transactional
@Override
public void done(PurchaseDoneVo doneVo) {
Long id = doneVo.getId();
//1、改变采购需求的状态
Boolean flag = true;
List<PurchaseItemDoneVo> items = doneVo.getItems();
List<PurchaseDetailEntity> detailList = new ArrayList<>();
for (PurchaseItemDoneVo item : items) {
PurchaseDetailEntity detailEntity = new PurchaseDetailEntity();
// 对失败的采购需求更改状态:若有任一采购需求为失败,则采购单状态即为失败
if(item.getStatus() == WareConstant.PurchaseDetailStatusEnum.HASERROR.getCode()){
flag = false;
detailEntity.setStatus(item.getStatus());
}else{
detailEntity.setStatus(WareConstant.PurchaseDetailStatusEnum.FINISH.getCode());
// 将成功采购的进行入库
PurchaseDetailEntity entity = detailService.getById(item.getItemId());
wareSkuService.addStock(entity.getSkuId(),entity.getWareId(),entity.getSkuNum());
}
detailEntity.setId(item.getItemId());
detailList.add(detailEntity);
}
detailService.updateBatchById(detailList);
//2、改变采购单状态:采购需求的状态影响采购单状态
PurchaseEntity purchaseEntity = new PurchaseEntity();
purchaseEntity.setId(id);
purchaseEntity.setStatus(flag?WareConstant.PurchaseStatusEnum.FINISH.getCode():WareConstant.PurchaseStatusEnum.HASERROR.getCode());
purchaseEntity.setUpdateTime(new Date());
this.updateById(purchaseEntity);
}
将成功采购的进行入库
WareSkuServiceImpl
@Override
public void addStock(Long skuId, Long wareId, Integer skuNum) {
//1、判断如果还没有这个库存记录,就新增
List<WareSkuEntity> entities = wareSkuDao.selectList(new QueryWrapper<WareSkuEntity>().eq("sku_id", skuId).eq("ware_id", wareId));
if(entities == null || entities.size() == 0){
WareSkuEntity skuEntity = new WareSkuEntity();
skuEntity.setSkuId(skuId);
skuEntity.setStock(skuNum);
skuEntity.setWareId(wareId);
skuEntity.setStockLocked(0);
//TODO 远程查询sku的名字
/**
* 问题:如果失败,整个事务无需回滚
* 解决:
* 1、自己catch异常
* TODO 还可以用什么办法让异常出现以后不回滚?高级
*/
try {
R r = productFeignService.info(skuId);
Map<String,Object> skuInfo = (Map<String, Object>) r.get("skuInfo");
if(r.getCode() == 0){
skuEntity.setSkuName((String) skuInfo.get("skuName"));
}
}catch (Exception e){
}
wareSkuDao.insert(skuEntity);
}else{
//2、有该记录那就进行更新
wareSkuDao.addStock(skuId,wareId,skuNum);
}
}
WareSkuDao
@Mapper
public interface WareSkuDao extends BaseMapper<WareSkuEntity> {
void addStock(@Param("skuId") Long skuId
, @Param("wareId") Long wareId
, @Param("skuNum") Integer skuNum);
}
<update id="addStock">
UPDATE `wms_ware_sku` SET stock= stock+#{skuNum}
WHERE sku_id=#{skuId} AND ware_id=#{wareId}
</update>
远程查询sku的名字
启动类上使用注解 @EnableFeignClients
编写远程调用接口
ProductFeignService
@FeignClient("gulimall-product")
public interface ProductFeignService {
/**
* 注意:
* 若@FeignClient("gulimall-gateway"),则方法路径应该是("/api/product/skuinfo/info/{skuId}")
* 若@FeignClient("gulimall-product"),则路径应该是("/product/skuinfo/info/{skuId}")
*/
//根据skuid查询sku信息
@RequestMapping("/product/skuinfo/info/{skuId}")
public R info(@PathVariable("skuId") Long skuId);
}
SkuInfoController
@RequestMapping("/info/{skuId}")
public R info(@PathVariable("skuId") Long skuId){
SkuInfoEntity skuInfo = skuInfoService.getById(skuId);
return R.ok().put("skuInfo", skuInfo);
}
分布式基础篇总结
1、分布式基础概念
微服务、注册中心(Nacos)、配置中心(Nacos Cofig)、远程调用、Feign、网关
2、基础开发
SpringBoot2.0、SpringCloud、Mybatis-Plus、Vue组件化、阿里云对象存储
3、环境
Vagrant、Linux、Docker、MySQL、Redis、逆向工程&人人开源
4、开发规范
数据效验JSR303、全局异常处理、全局统一返回、全家跨越处理
枚举状态、业务状态、VO与TO与PO划分、逻辑删除
Lombok:@Data、@Slf4j