谷粒商城(二)

谷粒商城(二)

后台商品服务 - 三级分类

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-fastgulimall-productgulimall-gateway 注册进注册中心,所以来三件套:

  1. 加入 gulimall-common 模块的依赖(内含 nacos注册中心依赖

  2. 配置文件中 指定服务名称、注册中心地址
    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
    
  3. 启动类上加上注册发现注解 @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. 配置全局的逻辑删除规则(默认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
    
  2. 配置逻辑删除的组件Bean(3.1.1版本开始不需要这一步)

  3. 实体类加上逻辑删除注解@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 = "";
},

注意:

  1. 在点击添加按钮时要把表单的数据进行清除,否则第二次打开任然会有上次表单提交剩下的数据 this.category.name = "";
  2. 修改和新增用的是同一个表单,因此在方法对话框中 动态的绑定了 :title="title" 标题 用于显示是新增还是修改
  3. 一个表单都是一个提交方法 因此在提交方法的时候进行了判断,根据变量赋值决定调用那个方法 this.dialogType = “add”; this.dialogType = “edit”;

2)、拖拽功能

前端用的组件 Tree 树形控件 可拖拽节点,参考 ElementUI官网

需求:

  1. 拖拽的时候并不会更新数据库,只有点击批量保存的时候才会更新数据库
  2. 保存之后,需要更新的节点数组会清空,里面会存放新一批需要更新的节点数组
  3. 点击批量保存之后,会默认打开被修改过的父节点目录

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)、品牌模糊查询

在品牌管理里面有关键字查询的输入框,那么就需要修改后端方法

修改 BrandServiceImplqueryPage

@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)、列表

查看接口文档(05、获取分类规格参数)

注意,页面需要响应的数据不仅仅有 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、销售属性

在这里插入图片描述

查看接口文档(09、获取分类销售属性)

发现和前面获取分类规格参数是使用同一张数据库表,通过 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)、删除

接口文档 12、删除属性与分组的关联关系

注意请求参数是对象数组形式 [{“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,来个三件套:

  1. 加入 gulimall-common 模块的依赖(内含 nacos注册中心依赖)

  2. 配置文件中 指定服务名称、注册中心地址

    # nacos
    application:
      name: gulimall-member
    cloud:
      nacos:
        discovery:
          server-addr: 127.0.0.1:8848
    
  3. 启动类上加上注册发现注解 @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 查询出对应数据

查看接口文档17、获取分类下所有分组&关联属性

编写接口

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)、业务流程

主要流程如下:

  1. 保存spu基本信息 pms_spu_info
  2. 保存Spu的描述信息 pms_spu_info_desc(spu_id、decript)
  3. 保存Spu的图片集 pms_spu_images
  4. 保存spu的规格参数(基本属性) pms_product_attr_value
  5. 保存SPU的积分信息:需要跨服务远程调用
    gulimall_sms sms =》 sms_spu_bounds
  6. 保存当前Spu对应的所有SKU信息
    1. SKU的基本信息 pms_sku_info
    2. SKU的图片信息 pms_sku_images
    3. SKU的销售属性信息 pms_sku_sale_attr_value
    4. SKU的优惠、满减等信息:需要跨服务远程调用
      gulimall_sms 库-》sms_sku_ladder \sms_sku_full_reduction\sms_member_price

因为第5步、第6.4步涉及到跨服务调用,可先编写其他接口,最后在编写这两个接口

3)、业务代码

查看接口文档 19、新增商品

编写接口 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 实现远程调用:

  1. gulimall-coupon 配置注册进 nacos
    启动类使用注解 @EnableDiscoveryClient

    spring:
      # nacos
      application:
        name: gulimall-coupon
      cloud:
        nacos:
          discovery:
            server-addr: 127.0.0.1:8848
    
  2. gulimall-product 启动类上加注解
    @EnableFeignClients(basePackages = "afei.product.feign")

  3. 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 检索

在这里插入图片描述
查询刚刚发布的商品,并能进行对应的条件查询

查看接口文档 18、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 检索

查询具体的商品管理 库存

查看接口文档 21、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、合并采购需求

将采购需求合并为采购单,选中采购需求之后可以选择指定的采购单,将需求合并进去,若没有选择采购单,就会默认新增采购单,再合并进去

查看文档,04、合并采购需求

将请求参数封装为 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

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值