谷粒商城之分布式基础(二)

6 商品服务

6.1 三级分类

谷粒商城之分布式基础(二)_第1张图片

商城的商品页面展示是一个三级分类的。有一级分类、二级分类、三级分类。这就是我们接下来要进行的操作。

6.1.1 数据库

  • 首先我们在gulimall_pms这个数据库中的pms_category这个表下插入数据
    商品三级分类SQL代码

6.1.2 查出所有分类及其子分类

1、CategoryController

gulimall-product中的controller包下的CategoryController

  • 在类中对原来逆向生成的代码进行修改,
@RestController
@RequestMapping("product/category")
public class CategoryController {
    @Autowired
    private CategoryService categoryService;
    /**
     * 查出所有分类以及子分类,以树形结构组装起来
     */
    @RequestMapping("/list/tree")
    public R list(){
        List<CategoryEntity> entities =  categoryService.listWithTree();

        return R.ok().put("data", entities);
    }
 }

2、CategoryService

接着我们使用idea自带的工具帮助我们生成相应的方法。

/**
 * 商品三级分类
 */
public interface CategoryService extends IService<CategoryEntity> {

    List<CategoryEntity> listWithTree();
}

3、CategoryServiceImpl

@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {

	// @Autowired
    // CategoryDao  categoryDao; //其实这里因为继承了ServiceImpl,且其泛型就是 CategoryDao,
    // 所以我们可以直接使用 ServiceImpl里面的 baseMapper来直接注入

	.......
        
    /**
     * 1、Lambda表达式
     * 1、举例:(o1, o2)->Integer.compare(o1, o2)
     *
     * 2、格式:
     *
     * -> :lambda操作符 或 箭头操作符
     * -> 左边: lambda形参列表(其实就是接口中的抽象方法的形参)
     * -> 右边: lambda体(其实就是重写的抽象方法的方法体)
     * 3、总结:
     *
     * -> 左边: lambda形参列表的参数类型可以省略(类型推断),如果形参列表只有一个参数,其一对()也可以省略
     *
     * -> 右边: lambda体应该使用一对{}包裹;如果lambda体只执行一条语句(可能是return语句),可以省略这一对{}和return关键字
     *右边
     */
    @Override
    public List<CategoryEntity> listWithTree() {

        //1.查出所有分类
        //没有查询条件,就是代表查询所有
        List<CategoryEntity> entities = baseMapper.selectList(null);

        //2.组装成父子的树形结构
        //2.1 找到所有的一级分类  (categoryEntity) -> {} lambda 表达式
        List<CategoryEntity> level1Menus = entities.stream()
                // .filter((categoryEntity) -> { return categoryEntity.getParentCid() == 0}) 下面的lambda表达式省略了return及{}及()
                .filter(categoryEntity -> categoryEntity.getParentCid() == 0)   //过滤出一级分类,因为其父类id是0
                .map((menu) -> {   //在菜单收集成list之前先通过递归找到菜单的所有子分类,放在map中,然后排序,即将当前菜单改了之后重新返回, 然后再收集菜单。
                    //设置一级分类的子分类
                    menu.setChildren(getChildren(menu, entities));
                    return menu;
                }).sorted((menu1, menu2) -> {
                    //排序,menu1:之前的菜单     menu2:之后的菜单
                    return (menu1.getSort() == null ? 0 : menu1.getSort()) - (menu2.getSort() == null ? 0 : menu2.getSort());//子菜单肯定有有前一个和后一个之分
                })
                .collect(Collectors.toList());


        return level1Menus;
    }

    //递归查找所有菜单的子菜单
    // root 当前菜单   all 所有菜单
    private List<CategoryEntity> getChildren(CategoryEntity root, List<CategoryEntity> all) {

        List<CategoryEntity> children = all.stream().filter(categoryEntity -> {
            return categoryEntity.getParentCid() == root.getCatId();   //二级菜单的父分类id == 一级分类的catid
        }).map(categoryEntity -> {
            //1.找到子菜单
            //递归查找
            categoryEntity.setChildren(getChildren(categoryEntity, all));//二级菜单下还有三级菜单,继续查找
            return categoryEntity;

            //2.菜单的排序
        }).sorted((menu1, menu2) -> {   //sorted() 定制排序
            return (menu1.getSort() == null ? 0 : menu1.getSort() - (menu2.getSort() == null ? 0 : menu2.getSort()));
        }).collect(Collectors.toList());

        return children;
    }

这里使用的是流式编程,对于这方面我们可去参考java8新特性的StreamAPI来进行相应的学习。

谷粒商城之分布式基础(二)_第2张图片

在学习的过程中,看到老师使用TODO才知道IDEA有一个类似备忘录的功能。

4、启动测试

我们启动gulimall-product微服务进行测试查询。

  • 我们接着进行测试,浏览器发送http://localhost:10000/product/category/list/tree,测试结果如下图,显示正确。这里我们推荐浏览器装一个Json格式的处理的插件可以很好的帮助我们查看Json数据。

  • 谷粒商城之分布式基础(二)_第3张图片


6.1.3 配置网关路由与路径重写

前后端联调:

启动后台:renren-fast微服务(idea);

启动前端:renren-fast-vue(vscode);

接着我们来到后台系统进行菜单模块的添加。

1、 后台添加目录和菜单

注意:避坑指南

如果系统登录不上,可能是 跨域配置默认不开启

谷粒商城之分布式基础(二)_第4张图片

登录成功之后,我们就可以开始进行后台系统的编辑和完善了。

  1. 在菜单管理中添加一个商品系统的目录。如下图。谷粒商城之分布式基础(二)_第5张图片

  2. 在商品系统中新增一个分类维护的菜单。菜单的路由其实就是我们商品微服务中的访问路径。

    希望的效果:在左侧点击【分类维护】,希望在此展示3级分类
    注意地址栏http://localhost:8001/#/product-category 可以注意到product-category我们的/被替换为了-

谷粒商城之分布式基础(二)_第6张图片

我们在后台系统中修改的,在数据库的gulimall-admin中也会同步进行修改。

谷粒商城之分布式基础(二)_第7张图片

  • 我们可以看到如果我们点击角色管理的话,地址栏是/sys-role,但是我们实际发送的请求应该是/sys/role,

    sys-role 具体的视图在 renren-fast-vue/views/modules/sys/role.vue

    所以由此可以知道后台会将 /自动转换为 - ,同理我们去访问/product/category也会自动被转换为/product-category

    具体地址栏如下所示:

    1667742855965

    1667742879863

  • 我们在renren-fast-vue中可以看到有一个文件,对应的其实就是/sys-role对应的页面视图,,即sys文件夹下的role.vue对应的就是角色管理这个页面的展示。所以对于商品分类/product/category,我们接下来要做的就是在renren-fast-vue下创建一个product文件夹,文件夹中创建一个category.vue来进行页面展示。

谷粒商城之分布式基础(二)_第8张图片

谷粒商城之分布式基础(二)_第9张图片

2、编写树形结构

  1. 对于这一段前端开发的代码,我们可以借鉴element.eleme.cn中的快速开发指南进行编写。





  1. 进行测试

测试中发现检查网页源代码发现,本来应该是给商品微服务10000端口发送的查询的,但是发送到了renren-fast 8080端口去了。

谷粒商城之分布式基础(二)_第10张图片

image-20210927115040661

我们以后还会同时发向更多的端口,所以需要配置网关,前端只向网关发送请求,然后由网关自己路由到相应服务器端口。

renren-fast-vue中有一个 Index.js是管理 api 接口请求地址的,如下图。如果我们本次只是简单的将8080改为10000端口,那么当下次如果是10001呢?难道每次都要改吗?所以我们的下一步做法是使用网关进行路由。通过网关映射到具体的请求地址。

ps:此处也可以参考其他人的理解:

借鉴:他要给8080发请求读取数据,但是数据是在10000端口上,如果找到了这个请求改端口那改起来很麻烦。方法1是改vue项目里的全局配置,方法2是搭建个网关,让网关路由到10000。

谷粒商城之分布式基础(二)_第11张图片

ps: 上面这个图明显有错误,vscode 已经报错,这里我没有注意到,以致 后面处理 跨域问题的时候 白白浪费了我 9个半 小时的时间啊!!!!1

前端项目报错也会影响!!!

切记!!!!!!!!!!!!!!!!

在这里,对于微服务,后面我们统一改为加 api 前缀能路由过去。

  // api接口请求地址
  window.SITE_CONFIG['baseUrl'] = 'http://localhost:88/api'

接下来进行测试访问

image-20210927115040661

我们发现 验证码 一直加载不出来。检查网页源代码发现是因为我们直接给网关发送验证码请求了。但是真实的应该是给 renren-fast 发送请求。

分析原因:前端给网关发验证码请求,但是验证码请求在 renren-fast服务里,所以要想使验证码好使,需要把 renren-fast服务注册到服务中心,并且由网关进行路由

谷粒商城之分布式基础(二)_第12张图片

3、将renren-fast注册进 nacos ,使用网关进行统一管理

问题引入:他要去 nacos 中查找api服务,但是nacos里是fast服务,就通过把api改成fast服务,所以让fast注册到服务注册中心,这样请求88网关转发到8080fast。
让fast里加入注册中心的依赖,所以引入common

  • 引入gulimall-common

谷粒商城之分布式基础(二)_第13张图片

  • 在renren-fast的 application.yml文件中配置nacos注册中心地址

    spring:
     application:
       name: renren-fast    //给 renren-fast  起一个名字,方便nacos服务注册发现
     cloud:
       nacos:
         discovery:
           server-addr: 127.0.0.1:8848   //注册进nacos
    
  • 在renren-fast的主启动类上加入@EnableDiscoveryClient注解,使得该微服务会被注册中心发现谷粒商城之分布式基础(二)_第14张图片

  • 注册成功

谷粒商城之分布式基础(二)_第15张图片

4、启动测试

  1. 最开始进行启动,在renren-fast的CorsConfig跨域配置中,allowedOriginPatterns报错。出现原因是因为:我们使用的springboot版本是2.1.8.RELEASE。所以将这个.allowedOriginPatterns换成.allowedOrigins即可。

谷粒商城之分布式基础(二)_第16张图片

谷粒商城之分布式基础(二)_第17张图片

  1. 最开始报错,在b站看了评论和弹幕之后将gulimall-common这个依赖给取消了,因为启动报依赖循环报错。后面我将所有的依赖都换成老师的同样的版本之后就没有了。

    启动报错:

    java: Annotation processing is not supported for module cycles. Please ensure that all modules from cycle [gulimall-common,renren-fast] are excluded from annotation processing

    指的是 循环依赖的问题

    1667747244272>解决办法:不要引入公共依赖,直接引入 nacos的服务注册发现的依赖

    		
            <dependency>
                <groupId>com.alibaba.cloudgroupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
                <version>2.1.0.RELEASEversion>
            dependency>
    		
            
            
            
            
    

    启动成功

谷粒商城之分布式基础(二)_第18张图片

鉴于上面出现很多错误,但是老师视频中没有出现这些错误,大概率是因为依赖的原因,所以对于gulimall中所有的依赖进行统一,按照老师的依赖进行配置。以防止后面出现很多突发的错误。

  • 根据老师的依赖进行重新设置,然后重新运行网关。

启动报错:Caused by: org.yaml.snakeyaml.scanner.ScannerException: mapping values are not allowed here

这个地方报错的原因大概率是yml文件语法错误:注意这个坑找了好久,id uri predicates filters都要对齐,同一层级。

谷粒商城之分布式基础(二)_第19张图片

完整代码示例如下:

# 在 yml  配置文件中配置,可以很方便的让我们在 项目上线后将配置直接转移到配置中心
spring:
  cloud:
    gateway:
      routes:
        - id: admin_route
          uri: lb://renren-fast    # 路由给renren-fast,lb代表负载均衡
          predicates:            # 什么情况下路由给它
            - Path=/api/**     # 把所有api开头的请求都转发给renren-fast:因为默认前端项目都带上api前缀,
          filters:
            - RewritePath=/api/(?>/?.*), /renren-fast/$\{segment}
            # 默认规则, 请求过来:http://localhost:88/api/captcha.jpg   转发-->  http://renren-fast:8080/api/captcha.jpg
            # 但是真正的路径是http://renren-fast:8080/renren-fast/captcha.jpg
            # 所以使用路径重写把/api/* 改变成 /renren-fast/*

修改后运行成功,验证码出现。

5、浏览器跨域问题

上面我们验证码出现了,但是我们登录却报错,原因在于浏览器的跨域问题。

从 8001访问88,引发 CORS 跨域请求,浏览器会拒绝跨域请求

1667827145407

谷粒商城之分布式基础(二)_第20张图片

跨域
问题描述:已拦截跨源请求:同源策略禁止读取位于 http://localhost:88/api/sys/login 的远程资源。(原因:CORS 头缺少 ‘Access-Control-Allow-Origin’)。

问题分析:这是一种跨域问题。访问的域名和端口和原来的请求不同,请求就会被限制

跨域:指的是浏览器不能执行其他网站的脚本。它是由浏览器的同源策略造成的,是浏览器对js施加的安全限制。(ajax可以)

同源策略:是指协议,域名,端囗都要相同,其中有一个不同都会产生跨域;

  1. 引入浏览器跨域知识

    谷粒商城之分布式基础(二)_第21张图片

    跨域流程:

    这个跨域请求的实现是通过预检请求实现的,先发送一个OPSTIONS探路,收到响应允许跨域后再发送真实请求

    谷粒商城之分布式基础(二)_第22张图片

    谷粒商城之分布式基础(二)_第23张图片

    谷粒商城之分布式基础(二)_第24张图片

    前面跨域的解决方案:

    方法1:设置nginx包含admin和gateway
    方法2:让服务器告诉预检请求能跨域

    1. 这里我们采用的解决办法:在gulimall-gateway中配置跨域配置列GulimallCorsConfiguration解决跨域问题------配置filter,每个请求来了以后,返回给浏览器之前都添加上那些字段

    我们在gulimall-gateway中创建一个config来存放GulimallCorsConfiguration。注意这个包一定是要在gateway这个包下,否则启动报错(坑)。

    @Configuration
    public class GulimallCorsConfiguration {
    
        @Bean   // 添加过滤器,当请求一过来走完 corsWebFilter 就给他们添加上跨域的相应配置
        public CorsWebFilter corsWebFilter(){
    
            // 基于url路径跨域,选择reactive包下的
            UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
            // 跨域配置信息
            CorsConfiguration corsConfiguration = new CorsConfiguration();
    
            // 允许跨域的头
            corsConfiguration.addAllowedHeader("*");
            // 允许跨域的请求方式
            corsConfiguration.addAllowedMethod("*");
            // 允许跨域的请求来源
            corsConfiguration.addAllowedOrigin("*");
            // 是否允许携带cookie跨域
            corsConfiguration.setAllowCredentials(true);
            // 任意url都要进行跨域配置
            //对接口进行配置,“/*”代表所有,“/**”代表适配的所有接口
            source.registerCorsConfiguration("/**",corsConfiguration);
            //CorsWebFilter的构造器需要传递一个
            //org.springframework.web.cors.reactive.CorsConfigurationSource的接口作为参数
            //接口不能实例化,所以选择CorsConfigurationSource的实现类
            //UrlBasedCorsConfigurationSource作为参数
            return new CorsWebFilter(source);
        }
    }
    
    1. 再次启动测试

    浏览器检查报错,报错的原因是:renren-fast 中也配置了跨域,但是我们只需要一个,所以要给注释掉。

    http://localhost:8001/renren已拦截跨源请求:同源策略禁止读取位于 http://localhost:88/api/sys/login 的远程资源。(原因:不允许有多个 ‘Access-Control-Allow-Origin’ CORS 头)n-fast/captcha.jpg?uuid=69c79f02-d15b-478a-8465-a07fd09001e6

    出现了多个请求,并且也存在多个跨源请求。

    为了解决这个问题,需要修改renren-fast项目,注释掉“io.renren.config.CorsConfig”类。然后再次进行访问。

    image-20221028163003311

    谷粒商城之分布式基础(二)_第25张图片

    1. 跨域问题困扰了我 9个半小时的时间,最后发现 竟然是 renren-fast-vue 前端代码 格式问题,真是崩溃了。

      这里也给了我一个 提醒,有时候需要从多方面进行问题的查找!!!!

      前端 有时候也会报错,一定要注意。 其实只要依赖版本和老师的一样,有很多坑是可以避免的。

6.1.4 树形展示三级分类数据

谷粒商城之分布式基础(二)_第26张图片

image-20221029170058736

在显示商品系统/分类信息的时候,出现了404异常,请求的http://localhost:88/api/product/category/list/tree不存在
只有通过http://localhost:10000/product/category/list/tree路径才能够正常访问,所以会报404异常。这是路径映射错误。我们需要在网关中进行路径重写,让网关帮我们转到正确的地址。

1667830493930

1、 商品微服务注册进nacos

首先我们需要将 gulimall-product 服务 注册进 nacos,方便网关进行路由。

我们在nacos中新建一个 product 命名空间,以后关于 product商品微服务下的配置就放在该命名空间下,目前我们注册微服务的话,都默认放在 public 命名空间下就行,配置文件放在各自微服务的命名空间下即可。

谷粒商城之分布式基础(二)_第27张图片

首先这里我们先回顾一下 nacos的配置步骤:

  1. 微服务注册进nacos:
    • 首先 需要在 application.yml / application.properties 文件中配置nacos的服务注册地址,并且最好每一个微服务都有属于自己的一个 应用名字
  spring:
    cloud:
      nacos:
        discovery:
          server-addr: 127.0.0.1:8848
  1. 微服务 配置 进 nacos
    • 如果想要 用nacos作为配置中心 ,需要 新建 bootstrap.properties 文件,然后在里面配置nacos 配置中心的地址; 此外,我们规定每一个微服务都有属于自己的命名空间,以后隶属于该微服务下的配置文件都配置在 该命名空间中。
  spring.application.name=gulimall-product
  # 配置nacos 配置中心地址
  spring.cloud.nacos.config.server-addr=127.0.0.1:8848
  spring.cloud.nacos.config.namespace=832f36b7-7878-47b7-8968-408f7b98b1e6
  1. 在启动类 上 添加注解 @EnableDiscoveryClient : 为了发现服务注册和配置

1667832554205

注册和配置成功。

2、在网关配置文件中配置路由规则,进行路径重写

在 gulimall-gateway 下的 application.yml中进行配置

      - id: product_route
          uri: lb://gulimall_product
          predicates:
            - Path=/api/product/**
          filters:
            - RewritePath=/api/(?>/?.*), /$\{segment}
        #          http://localhost:88/api/product/category/list/tree  http://localhost:10000/product/category/list/tree

注意:

如果直接访问 localhost:88/api/product/category/list/tree invalid token这个url地址的话,会提示非法令牌,后台管理系统中没有登录,所以没有带令牌

1667833363607

原因:先匹配的先路由,renren-fast 和 product 路由重叠,fast 要求登录

修正:在路由规则的顺序上,将精确的路由规则放置到模糊的路由规则的前面,否则的话,精确的路由规则将不会被匹配到,类似于异常体系中try catch子句中异常的处理顺序。

http://localhost:88/api/product/category/list/tree 正常

访问http://localhost:8001/#/product-category,正常

原因是:先访问网关88,网关路径重写后访问nacos8848,nacos找到服务

谷粒商城之分布式基础(二)_第28张图片

谷粒商城之分布式基础(二)_第29张图片

谷粒商城之分布式基础(二)_第30张图片

成功访问。

3、前端代码修改

谷粒商城之分布式基础(二)_第31张图片

因为我们 对 整个对象 中的 data 数据感兴趣 ,所以我们 将 对象中的 data 解构出来。

我们使用{}将data的数据进行解构:data.data是我们需要的数组内容

谷粒商城之分布式基础(二)_第32张图片

 //获取菜单集合
    methods: {
        handleNodeClick(data) {
            console.log(data);
        },
        //获取后台数据
        getMenus() {
            this.$http({
                url: this.$http.adornUrl('/product/category/list/tree'),
                method: 'get'
            }).then(({data}) => {  //将整个对象中的data数据结构出来,因为只有data才是我们需要的
                console.log("成功了获取到菜单数据....", data.data)
                this.menus = data.data; // 数组内容,把数据给menus,就是给了vue实例,最后绑定到视图上
            })
        }
    },

谷粒商城之分布式基础(二)_第33张图片

谷粒商城之分布式基础(二)_第34张图片

此时有了3级结构,但是没有数据,在category.vue的模板中,数据是menus,而还有一个props。这是element-ui的规则


export default {
    //import引入的组件需要注入到对象中才能使用
    components: {},
    data() {
        return {
            menus: [],  //真正的数据需要发送请求从数据库中进行查找
            defaultProps: {
                children: 'children', //子节点
                label: 'name'  //name属性作为标签的值,展示出来
            }
        };
    },

修改完毕后,测试:

谷粒商城之分布式基础(二)_第35张图片

6.1.5 删除数据----逻辑删除

1、前端代码

node 与 data
在element-ui的tree中,有2个非常重要的属性

node代表当前节点对象(是否展开等信息,element-ui自带属性)
data是节点数据,是自己的数据。
data从哪里来:前面ajax发送请求,拿到data,赋值给menus属性,而menus属性绑定到标签的data属性。而node是 ui 的默认规则

删除效果预想:

  • 在每一个菜单后面添加 append, delete
  • 点击按钮时,不进行菜单的打开合并:expand-on-click-node=“false”
  • 当没有子菜单或者没有引用(后台数据库判断是否有被引用,这里暂时不考虑)的时候,才可以显示delete按钮。当为一级、二级菜单时,才显示append按钮
    • 利用 v-if 进行判断是否显示 按钮:
      1. 如果 当前节点 node 的等级 ≤ 2,表示是一级菜单或二级菜单,不显示删除按钮------- v-if=“node.level <= 2”, level表示当前 是几级节点;
      2. 如果 当前节点 的子节点的 数组长度为0,表示 没有子菜单----v-if=“node.childNodes.length == 0”
  • 添加多选框 show-checkbox ,可以多选
  • 设置 node-key=""标识每一个节点的不同






效果展示:

谷粒商城之分布式基础(二)_第36张图片

2、逻辑删除

  1. 首先我们先测试一下 gulimall-product中的 CategoryController删除功能。

测试删除数据,打开postman(APIfox也可以)输入“ http://localhost:88/api/product/category/delete ”,请求方式设置为POST,请求体body选 json 数组

谷粒商城之分布式基础(二)_第37张图片

可以看到删除成功,而且数据库中也没有该数据了。

ps:这里将限制行数给取消勾选,不然默认是只显示 1000行。

谷粒商城之分布式基础(二)_第38张图片

这是一种 物理删除(不推荐),数据库中也同样被修改了。

接下来我们正式编写删除逻辑。

  1. 在真正的删除之前,我们要先检查该菜单是否被引用了。
  • 修改gulimall-product 中的CategoryController类
 @RequestMapping("/delete")
    public R delete(@RequestBody Long[] catIds){
        //删除之前需要判断待删除的菜单那是否被别的地方所引用。
//		categoryService.removeByIds(Arrays.asList(catIds));

        categoryService.removeMenuByIds(Arrays.asList(catIds));
        return R.ok();
    }
  • CategoryServiceImpl类
@Override
    public void removeMenuByIds(List<Long> asList) {
        //TODO 1.检查当前删除的菜单,是否被别的地方引用
        //其实开发中使用的都是逻辑删除,并不是真正物理意义上的删除
        baseMapper.deleteBatchIds(asList);
    }

这里我们还不清楚后面有哪些服务需要用到product,所以我们建一个备忘录,以后再来补充。

谷粒商城之分布式基础(二)_第39张图片

在学习的过程中,看到老师使用TODO才知道IDEA有一个类似备忘录的功能。

  1. 对于开发中,我们常常采用的是逻辑删除(我们并不希望删除数据,而是标记它被删除了,这就是逻辑删除),即在数据库表设计时设计一个表示逻辑删除状态的字段,在pms_category我们选择 show_status 字段,当它为0,表示被删除。

    逻辑删除是mybatis-plus 的内容,会在项目中配置一些内容,告诉此项目执行delete语句时并不删除,只是标志位。

    我们使用mybatis-plus中的逻辑删除语法:

谷粒商城之分布式基础(二)_第40张图片

1)、配置全局逻辑删除规则

application.yml中

mybatis-plus:
  mapper-locations: classpath*:/mapper/**/*.xml
  global-config:
    db-config:
      id-type: auto             #主键自增
      logic-delete-value: 1     #1表示删除
      logic-not-delete-value: 0   #0表示未删除

注意:这里有一个坑,数据库中我们最开始设置的是1:未删除,0:删除。这个坑马上解决。

/**
	 * 是否显示[0-不显示,1显示]
	 */
	@TableLogic(value = "1",delval = "0")//因为application.yml和数据库中的设置刚好相反,所以我们这里按数据库中的效果单独设置 
	private Integer showStatus;

配置之后,我们可以继续使用APIFox进行测试,实际测试成功。为了验证,我们也可以在application.yml设置一个全局打印日志,将sql语句打印出来。

1667879871854

logging:
  level:
    com.atguigu.gulimall: debug  #设置日志打印级别

3、测试删除

测试删除数据,打开postman或者是APIFox都可以(推荐使用APIFox)

输入“ http://localhost:88/api/product/category/delete ”,请求方式设置为POST,为了比对效果,可以在删除之前查询数据库的pms_category表:

delete请求传入的是数组,所以我们使用json数据。

1667880187492

谷粒商城之分布式基础(二)_第41张图片

删除1433,之后从 数据库中 show_status 1—>0,即逻辑删除正确。

1667880208982

控制台打印的SQL语句:

Preparing: UPDATE pms_category SET show_status=0 WHERE cat_id IN ( ? ) AND show_status=1
Parameters: 1433(Long)
Updates: 1

由此可见,逻辑删除成功,SQL语句为 更新字段。

4、前端代码编写

发送的请求:delete

发送的数据:this.$http.adornData(ids, false)

util/httpRequest.js中,封装了一些拦截器

http.adornParams是封装get请求的数据

http.adornData封装post请求的数据

ajax 的 get 请求第一次向服务器请求数据之后,后续的请求可能会被缓存,就不会请求服务器要新的数据了。

所以为了不缓存,我们在url后面拼接个 date时间戳 或者一个随机数,让他每次都请求服务器获取实时的数据了。

  • 编写前端 remove 方法,实现向后端发送请求
  • 点击delete弹出提示框,是否删除这个节点: elementui 中 MessageBox 弹框中的确认消息添加到删除之前
  • 删除成功后有消息提示: elementui 中 Message 消息提示
  • 原来展开状态的菜单栏,在删除之后也应该展开: el-tree组件的 default-expanded-keys 属性,默认展开。 每次删除之后,把删除菜单的父菜单的id值赋给默认展开值即可。

注意:

前端向后端发送post请求和get请求。对于这个我们可以设置一个自定义的代码块。文件->首选项->用户片段,以后我们就可以通过快捷键直接进行输出了。

"http-get请求": {
        "prefix": "httpget",
        "body":[
            "this.\\$http({",
            "url: this.\\$http.adornUrl(''),",
            "method:'get',",
            "params:this.\\$http.adornParams({})",
            "}).then(({data})=>{",
            "})"
        ],
        "description":"httpGET请求"
    },

    "http-post请求":{
        "prefix":"httppost",
        "body":[
            "this.\\$http({",
            "url:this.\\$http.adornUrl(''),",
            "method:'post',",
            "data: this.\\$http.adornData(data, false)",
            "}).then(({data})=>{ })"
        ],
        "description":"httpPOST请求"
    }

要求:删除之后,显示弹窗,而且展开的菜单仍然展开。

1667901001847

//在el-tree中设置默认展开属性,绑定给expandedKey
:default-expanded-keys="expandedKey"

//data中添加属性
expandedKey: [],

//完整的remove方法
    remove(node, data) {
      var ids = [data.catId];
      this.$confirm(`是否删除【${data.name}】菜单?`, "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning",
      })
        .then(() => {
          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();
            //设置需要默认展开的菜单
            this.expandedKey = [node.parent.data.catId]
          });
        })
        .catch(() => {});
    },

6.1.6 新增分类

1、elementui中 Dialog 对话框

  • 一个会话的属性为:visible.sync=“dialogVisible”
  • 导出的data中"dialogVisible = false"
  • 点击确认或者取消后的逻辑都是@click=“dialogVisible = false” 关闭会话即关闭弹框

2、点击 append,弹出对话框,输入分类名称

3、点击确定,添加到数据库: 新建方法addCategory发送post请求到后端; 因为要把数据添加到数据库,所以在前端数据中按照数据库的格式声明一个category。点击append时,计算category属性(比如 父id,以及当前层级等),点击确定时发送 post 请求(后台代码使用的是 @RequestBody 注解,需要发送 post请求)。

4、点击确定后,需要刷新菜单,显示出新的菜单;此外还需要展开菜单方便查看。


	
      
        
          
        
      
      
        取 消
        确 定
      
    

//data中新增数据
//按照数据库格式声明的数据
      categroy: { name: "", parentCid: 0, catLevel: 0, showStatus: 1, sort: 0 },
//判断是否显示对话框
      dialogVisible: false,

          
//修改append方法,新增addCategory方法
//点击append后,计算category属性,显示对话框
    append(data) {
      console.log("append", data);
      this.dialogVisible = true;
      this.categroy.parentCid = data.catId;
      this.categroy.catLevel = data.catLevel * 1 + 1;
    },

//点击确定后,发送post请求
//成功后显示添加成功,展开刚才的菜单
    addCategory() {
      console.log("提交的数据", this.categroy);
      this.$http({
        url: this.$http.adornUrl("/product/category/save"),
        method: "post",
        data: this.$http.adornData(this.categroy, false),
      }).then(({ data }) => {
          this.$message({
              message: "添加成功",
              type: "success",
            });
            //刷新出新的菜单
            this.getMenus();
            //设置需要默认展开的菜单
            this.expandedKey = [this.categroy.parentCid];
            this.dialogVisible = false;
      });

6.1.7 修改分类

  1. gulimall-product中的 CategoryController
/**
     * 信息
     */
    @RequestMapping("/info/{catId}")
    //@RequiresPermissions("product:category:info")
    public R info(@PathVariable("catId") Long catId){
		CategoryEntity category = categoryService.getById(catId);

        return R.ok().put("data", category); //我们统一 为 data
    }

2.前端代码

实现修改名称,图标,计量单位。

1、新增Edit按钮:复制之前的append

2、查看controller,发现updata方法是由id进行更新的,所以data中的category中新增catId

3、增加、修改的时候也修改图标和计量单位,所以data的category新增inco,productUnit

4、新建edit方法,用来绑定Edit按钮。新建editCategory方法,用来绑定对话框的确定按钮。

5、复用对话框:

data数据中新增dialogType,用来标记此时对话框是由 edit打开的,还是由 append打开的。
新建方法 submitData,与对话框的确定按钮进行绑定,在方法中判断,如果 dialogTypeadd调用addCategory(),如果 dialogTypeedit调用editCategory()
data数据中新增 title,绑定对话框的title,用来做提示信息。判断dialogType的值,来选择提示信息。
6、防止多个人同时操作,对话框中的回显的信息应该是由数据库中读出来的:点击Edit按钮,发送httpget请求。(看好返回的数据)

7、编辑editCategory方法:

controller之中的更新是动态更新,根据id,发回去什么值修改什么值,所以把要修改的数据发回后端就好。
成功之后发送提示消息,展开刚才的菜单。
8、编辑之后,再点击添加,发现会回显刚才编辑的信息。所以在append方法中重置回显的信息。

9、这里给 对话框 添加一个 close-on-click-modal = false:这样我们点对话框之外的空白处就不会直接不显示对话框了。

1667923063606


		  
            Edit
          


	 
            
                
                    
                
                
                    
                
                
                    
                
            
            
                取 消
                确 定
            
        


//data, 新增了title、dialogType。 categroy中新增了inco、productUnit、catId
  data() {
        return {
            title: "",
            dialogType: "", //edit,add
            dialogVisible: false,
            menus: [],
            expandedKey: [],
            category: {
                name: "",
                parentCid: 0,
                catLevel: 0,
                showStatus: 1,
                sort: 0,
                icon: "",
                productUnit: "",
                catId: null,
            },
            defaultProps: {
                children: "children",  //子节点
                label: "name",  //name属性作为标签的值,展示出来
            },
        };
    },

//方法
     //绑定对话框的确定按钮,根据dialogType判断调用哪个函数
    submitData() {
            if (this.dialogType == "add") {
                this.addCategory();
            }
            if (this.dialogType == "edit") {
                this.editCategory();
            }
        },
        
        //绑定Edit按钮,设置dialogType、title,从后台读取数据,展示到对话框内
    edit(data) {
            console.log("要修改的数据", data);
            this.dialogType = "edit";
            this.title = "修改分类";
            // 发送请求获取节点最新的数据
            this.$http({
                url: this.$http.adornUrl(`/product/category/info/${data.catId}`),
                method: "get",
            }).then(({ data }) => {
                // 请求成功
                console.log("要回显的数据", data);
                this.category.name = data.data.name;
                this.category.catId = data.data.catId;
                this.category.icon = data.data.icon;
                this.category.productUnit = data.data.productUnit;
                this.category.parentCid = data.data.parentCid;
                this.dialogVisible = true;
            });
        },
        
       //修改三级分类数据
        //绑定对话框的确定按钮,向后台发送更新请求,传过去想要修改的字段
        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),
            })
                .then(({ data }) => {
                    this.$message({
                        type: "success",
                        message: "菜单修改成功!",
                    });
                    // 关闭对话框
                    this.dialogVisible = false;
                    // 刷新出新的菜单
                    this.getMenus();
                    // 设置需要默认展开的菜单
                    this.expandedKey = [this.category.parentCid];
                })
                .catch(() => { });
        },
    
    //点击append按钮,清空编辑之后的回显数据
        append(data) {
            console.log("append----", data);
            this.dialogType = "add";
            this.title = "添加分类";
            this.category.parentCid = data.catId;
            this.category.catLevel = data.catLevel * 1 + 1;
            this.category.catId = null;
            this.category.name = null;
            this.category.icon = "";
            this.category.productUnit = "";
            this.category.sort = 0;
            this.category.showStatus = 1;
            this.dialogVisible = true;
        },

6.1.8 拖曳效果

1、前端代码

1、拖拽功能的前端实现:ementui树型控件->可拖拽节点

  • 在中加入属性 draggable表示节点可拖拽。
  • 在中加入属性 :allow-drop=“allowDrop”,拖拽时判定目标节点能否被放置。
  • allowDrop有三个参数: draggingNode表示拖拽的节点, dropNode表示拖拽到哪个节点,type表示拖拽的类型 ’prev’、‘inner’ 和 ‘next’,表示拖拽到目标节点之前、里面、之后。
  • allowDrop函数实现判断,拖拽后必须保持树形的三层结构。
    • 节点的深度 = 最深深度 - 当前深度 + 1
    • 当拖拽节点拖拽到目标节点的内部,要满足: 拖拽节点的深度 + 目标节点的深度 <= 3
    • 当拖拽节点拖拽的目标节点的两侧,要满足: 拖拽节点的深度 + 目标节点的父节点的深度 <= 3

	   draggable
      :allow-drop="allowDrop"
// data中新增属性,用来记录当前节点的最大深度
maxLevel: 0,
    
    
//新增方法
    allowDrop(draggingNode, dropNode, type) {
            //1、被拖动的当前节点以及所在的父节点总层数不能>3

            //1)、被拖动的当前节点总层数
            console.log("allowDrop", draggingNode, dropNode, type);
            this.countNodeLevel(draggingNode.data);
            //当前正在拖动的节点+父节点所在的深度不大于3即可
            let deep = this.maxLevel - draggingNode.data.catLevel + 1;
            console.log("深度:", deep);

            //this.maxLevel
            if (type == "inner") {
                return (deep + dropNode.level) <= 3;
            } else {
                return (deep + dropNode.parent.level) <= 3;
            }
        },

   //计算当前节点的最大深度
        countNodeLevel(node) {
            //找到所有子节点,求出最大深度
            if (node.children != null && node.children.length > 0) {
                for (let i = 0; i < node.children.length; i++) {
                    if (node.children[i].catLevel > this.maxLevel) {
                        this.maxLevel = node.children[i].catLevel;
                    }
                    this.countNodeLevel(node.children[i]);
                }
            }
        },
  1. 拖拽功能的数据收集
  • 在中加入属性@node-drop=“handleDrop”, 表示拖拽事件结束后触发事件handleDrop,handleDrop共四个参数:
    • draggingNode:被拖拽节点对应的 Node;
    • dropNode:结束拖拽时最后进入的节点;
    • dropType:被拖拽节点的放置位置(before、after、inner);
    • ev:event
  • 拖拽可能影响的节点的数据:parentCid、catLevel、sort
    • data中新增updateNodes ,把所有要修改的节点都传进来。
    • 要修改的数据:拖拽节点的parentCid、catLevel、sort
    • 要修改的数据:新的兄弟节点的sort (把新的节点收集起来,然后重新排序)
    • 要修改的数据:子节点的catLeve
//el-tree中新增属性,绑定handleDrop,表示拖拽完触发
@node-drop="handleDrop"

//data 中新增数据,用来记录需要更新的节点(拖拽的节点(parentCid、catLevel、sort),拖拽后的兄弟节点(sort),拖拽节点的子节点(catLevel))
updateNodes: [],
    

//新增方法
    handleDrop(draggingNode, dropNode, dropType, ev) {
      console.log("handleDrop: ", draggingNode, dropNode, dropType);
      //1、当前节点最新父节点的id
      let pCid = 0;
      //拖拽后的兄弟节点,分两种情况,一种是拖拽到两侧,一种是拖拽到内部
      let sibings = null;
      if (dropType == "before" || dropType == "after") {
        pCid = dropNode.parent.data.catId == undefined ? 0: dropNode.parent.data.catId;
        sibings = dropNode.parent.childNodes;
      } else {
        pCid = dropNode.data.catId;
        sibings = dropNode.childNodes;
      }

      //2、当前拖拽节点的最新顺序
      //遍历所有的兄弟节点,如果是拖拽节点,传入(catId,sort,parentCid,catLevel),如果是兄弟节点传入(catId,sort)
      for (let i = 0; i < sibings.length; i++) {
          if (sibings[i].data.catId == draggingNode.data.catId){
              //如果遍历的是当前正在拖拽的节点
              let catLevel = draggingNode.level;
              if (sibings[i].level != draggingNode.level){
                  //当前节点的层级发生变化
                  catLevel = sibings[i].level;
                  //修改他子节点的层级
                  this.updateChildNodeLevel(sibings[i]);
              }
              this.updateNodes.push({catId:sibings[i].data.catId, sort: i, parentCid: pCid, catLevel:catLevel});
          }else{
              this.updateNodes.push({catId:sibings[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++){
                //遍历子节点,传入(catId,catLevel)
                var cNode = node.childNodes[i].data;
                this.updateNodes.push({catId:cNode.catId,catLevel:node.childNodes[i].level});
                //处理子节点的子节点
                this.updateChildNodeLevel(node.childNodes[i]);
            }
        }
    },

  1. 拖拽功能实现
  • 在后端编写批量修改的方法update/sort
  • 前端发送post请求,把要修改的数据发送过来
  • 提示信息,展开拖拽节点的父节点

CategoryController修改方法

@RestController
@RequestMapping("product/category")
public class CategoryController {
    @Autowired
    private CategoryService categoryService;


    /**
     * 批量修改分类
     */
    @RequestMapping("/update/sort")
    // @RequiresPermissions("product:category:update")
    public R update(@RequestBody CategoryEntity[] category){

        categoryService.updateBatchById(Arrays.asList(category));
        return R.ok();
    }

}

利用 APIfox 测试 批量修改效果

谷粒商城之分布式基础(二)_第42张图片

测试成功。接下来我们完善下 前端的代码。

前端发送请求:

//3 当前拖拽节点的最新层级
            console.log("updateNodes", this.updateNodes);
            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 = [pCid];
                //每次拖拽后把数据清空,否则要修改的节点将会越拖越多
                this.updateNodes = [],
                this.maxLevel = 0
            });

  1. 批量拖拽功能
  • 添加开关,控制拖拽功能是否开启
  • 每次拖拽都要和数据库交互,不合理。批量拖拽过后,一次性保存。

	
    
    批量保存
   
    
//data中新增数据
 pCid:[], //批量保存过后要展开的菜单id
 draggable: false, //绑定拖拽开关是否打开
  

//修改了一些方法,修复bug,修改过的方法都贴在下面了

//点击批量保存按钮,发送请求
        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.pCid;
                //每次拖拽后把数据清空,否则要修改的节点将会越拖越多
                this.updateNodes = [],
                this.maxLevel = 0;
                // this.pCid = 0;
            })
                .catch(() => { });
        },
  
        
    handleDrop(draggingNode, dropNode, dropType, ev) {
            console.log("handleDrop: ", draggingNode, dropNode, dropType);

            //1、当前节点最新父节点的id
            let pCid = 0;
            //拖拽后的兄弟节点,分两种情况,一种是拖拽到两侧,一种是拖拽到内部
            let sibings = null;
            if (dropType == "before" || dropType == "after") {
                pCid = dropNode.parent.data.catId == undefined ? 0 : dropNode.parent.data.catId;
                sibings = dropNode.parent.childNodes;
            } else {
                pCid = dropNode.data.catId;
                sibings = dropNode.childNodes;
            }
            this.pCid.push(pCid);

            //2、当前拖拽节点的最新顺序
            //遍历所有的兄弟节点,如果是拖拽节点,传入(catId,sort,parentCid,catLevel),如果是兄弟节点传入(catId,sort)
            for (let i = 0; i < sibings.length; i++) {
                if (sibings[i].data.catId == draggingNode.data.catId) {
                    //如果遍历的是当前正在拖拽的节点
                    let catLevel = draggingNode.level;
                    if (sibings[i].level != draggingNode.level) {
                        //当前节点的层级发生变化
                        catLevel = sibings[i].level;
                        //修改他子节点的层级
                        this.updateChildNodeLevel(sibings[i]);
                    }
                    this.updateNodes.push({ catId: sibings[i].data.catId, sort: i, parentCid: pCid, catLevel: catLevel });
                } else {
                    this.updateNodes.push({ catId: sibings[i].data.catId, sort: i });
                }

            }
            //3 当前拖拽节点的最新层级
            console.log("updateNodes", this.updateNodes);
        },
  
                                               
// 修改拖拽判断逻辑
    allowDrop(draggingNode, dropNode, type) {
            //1 被拖动的当前节点以及所在的父节点总层数不能大于3

            //1 被拖动的当前节点总层数
            console.log("allowDrop:", draggingNode, dropNode, type);

            var level = this.countNodeLevel(draggingNode);

            // 当前正在拖动的节点+父节点所在的深度不大于3即可
            let deep = Math.abs(this.maxLevel - draggingNode.level) + 1;
            console.log("深度:", deep);

            // this.maxLevel
            if (type == "innner") {
                return deep + dropNode.level <= 3;
            } else {
                return deep + dropNode.parent.level <= 3;
            }
        },
                                                        
//计算当前节点的最大深度
        countNodeLevel(node) {
            // 找到所有子节点,求出最大深度
            if (node.childNodes != null && node.childNodes.length > 0) {
                for (let i = 0; i < node.childNodes.length; i++) {
                    if (node.childNodes[i].level > this.maxLevel) {
                        this.maxLevel = node.childNodes[i].level;
                    }
                     this.countNodeLevel(node.childNodes[i]);
                }
            }
        },

6.1.9 批量删除

前端代码

  • 新增删除按钮
批量删除


ref="menuTree"

  • 批量删除方法
//批量删除
        batchDelete() {
            let catIds = [];
            let catNames = [];
            let checkedNodes = this.$refs.menuTree.getCheckedNodes();
            console.log("被选中的元素", checkedNodes);
            for (let i = 0; i < checkedNodes.length; i++) {
                catIds.push(checkedNodes[i].catId);
                catNames.push(checkedNodes[i].name);
            }

            this.$confirm(`是否删除【${catNames}】菜单?`, "提示", {
                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(() => { });
                })
                .catch(() => { });
        },

6.1.10 前端代码(总)







至此三级分类告一段落。


6.2 品牌管理

这次要用到的代码是通过renren-generator代码生成器中生成的前端代码。在前面中如果我们不小心进行删除了,可以通过idea自带的恢复功能进行恢复。

步骤:

  1. 右键点击resources->Local History->Show History

谷粒商城之分布式基础(二)_第43张图片

  1. 找到删除前端的记录
  2. 右键->Revert。 找回成功!

谷粒商城之分布式基础(二)_第44张图片

6.2.1 使用逆向工程前端代码

  1. 菜单管理—新增菜单

谷粒商城之分布式基础(二)_第45张图片

  1. 将gulimall-product中的前端代码复制到前端工程product下。

谷粒商城之分布式基础(二)_第46张图片

  1. 没有新增删除按钮: 修改权限,Ctrl+Shift+F查找isAuth,全部返回为true

谷粒商城之分布式基础(二)_第47张图片

谷粒商城之分布式基础(二)_第48张图片

  1. 查看效果

image-20210927135815283

这里提一嘴,我们可以将es6语法检查关闭。

谷粒商城之分布式基础(二)_第49张图片

6.2.2 效果优化-快速显示开关

  1. 在列表中添加自定义列:中间加标签。可以通过 Scoped slot 可以获取到 row, column, $index 和 store(table 内部的状态管理)的数据
  2. 修改开关状态,发送修改请求
  3. 数据库中showStatus是0和1,开关默认值是true/false。 所以在开关中设置:active-value=“1” 、:inactive-value="0"属性,与数据库同步

谷粒商城之分布式基础(二)_第50张图片

谷粒商城之分布式基础(二)_第51张图片


      
        
      


      
        
        
      

1668095460218

效果如下:品牌logo地址显示在一栏了。

谷粒商城之分布式基础(二)_第52张图片

//brand.vue中新增方法,用来修改状态
updateBrandStatus(data) {
      console.log("最新信息",data);
      let { brandId, showStatus } = data;
      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:"状态更新成功"
        })
      });
    },

6.2.3 文件上传功能

  1. 知识补充

谷粒商城之分布式基础(二)_第53张图片

谷粒商城之分布式基础(二)_第54张图片

谷粒商城之分布式基础(二)_第55张图片

这里我们选用服务端签名后直传进行文件上传功能,好处是:

上传的账号信息存储在应用服务器
上传先找应用服务器要一个policy上传策略,生成防伪签名

  1. 开通阿里云OSS对象存储服务,并创建新的Bucket

谷粒商城之分布式基础(二)_第56张图片

https://help.aliyun.com/document_detail/32007.html sdk–java版本

谷粒商城之分布式基础(二)_第57张图片

  1. 如何使用

阿里云关于文件上传的帮助文档

根据官网的文档,我们可以直接在项目中引入依赖进行安装

这个依赖是最原始的。配置什么要写一大堆。

<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.15.0</version>
</dependency>

文件上传的具体配置,我们在 gulimall-product 的 test 包下的 GulimallProductApplicationTests类中进行测试,代码如下:

 @Test
    public void testUpload() throws FileNotFoundException {
        // Endpoint以杭州为例,其它Region请按实际情况填写。
        String endpoint = "oss-cn-hangzhou.aliyuncs.com";
        // 云账号AccessKey有所有API访问权限,建议遵循阿里云安全最佳实践,创建并使用RAM子账号进行API访问或日常运维,请登录 https://ram.console.aliyun.com 创建。
        String accessKeyId = "LTAI5tABh1pjUprZGrKi92w1";
        String accessKeySecret = "enVYmXd9p1sHvVub5gBf21E3tjuIFJ";

        // // 创建OSSClient实例。
        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);

        // 上传文件流。
        InputStream inputStream = new FileInputStream("F:\\JAVA listen\\尚硅谷Java学科全套教程(总207.77GB)\\谷粒商城\\课件\\课件和文档\\基础篇\\资料\\pics\\0d40c24b264aa511.jpg");
        ossClient.putObject("gulimall-wystart", "0d40c24b264aa511.jpg", inputStream);

        // 关闭OSSClient。
        ossClient.shutdown();
        System.out.println("上传成功");
    }
endpoint的取值:点击概览就可以看到你的endpoint信息,endpoint在
这里就是上海等地区,如 oss-cn-qingdao.aliyuncs.com
bucket域名:就是签名加上bucket,如gulimall-fermhan.oss-cn-qingdao.aliyuncs.com
accessKeyId和accessKeySecret需要创建一个RAM账号:

接下来就是具体如何获取的示例:

  • 获取EndpointAccessKey IDAccessKey Secret

    • Endpoint

      谷粒商城之分布式基础(二)_第58张图片

    • AccessKey IDAccessKey Secret

      ​ 注意,这里我们需要创建阿里云的子账户,这样可以避免我们主账号直接在网络上进行暴露。

谷粒商城之分布式基础(二)_第59张图片

谷粒商城之分布式基础(二)_第60张图片

谷粒商城之分布式基础(二)_第61张图片

对子账户分配权限,管理OSS对象存储服务。这里我们允许读和写,方便我们实现上传功能。

谷粒商城之分布式基础(二)_第62张图片

测试:谷粒商城之分布式基础(二)_第63张图片

谷粒商城之分布式基础(二)_第64张图片

可以看到上传到云服务成功。

  1. 直接使用SpringCloud Alibaba已经封装好的 oss

    谷粒商城之分布式基础(二)_第65张图片

谷粒商城之分布式基础(二)_第66张图片

  • 引入依赖(和老师版本一致)

    <dependency>
                <groupId>com.alibaba.cloudgroupId>
                <artifactId>spring-cloud-starter-alicloud-ossartifactId>
                <version>2.1.0.RELEASEversion>
            dependency>
    
  • 在 gulimall-product 的 application.yml文件中配置

    1 创建“AccessKey ID”和“AccessKeySecret”
    
    2 配置key,secret和endpoint相关信息
        alicloud:
          access-key: LTAI5tABh1pjUprZGrKi92w1
          secret-key: enVYmXd9p1sHvVub5gBf21E3tjuIFJ
          oss:
            endpoint: oss-cn-hangzhou.aliyuncs.com
    

    测试:

      @Autowired
        OSSClient ossClient;
    
        @Test
        public void testUpload() throws FileNotFoundException {
            // // Endpoint以杭州为例,其它Region请按实际情况填写。
            // String endpoint = "oss-cn-hangzhou.aliyuncs.com";
            // // 云账号AccessKey有所有API访问权限,建议遵循阿里云安全最佳实践,创建并使用RAM子账号进行API访问或日常运维,请登录 https://ram.console.aliyun.com 创建。
            // String accessKeyId = "LTAI5tABh1pjUprZGrKi92w1";
            // String accessKeySecret = "enVYmXd9p1sHvVub5gBf21E3tjuIFJ";
            //
            // // // 创建OSSClient实例。
            // OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
    
            // 上传文件流。
            InputStream inputStream = new FileInputStream("F:\\JAVA listen\\尚硅谷Java学科全套教程(总207.77GB)\\谷粒商城\\课件\\课件和文档\\基础篇\\资料\\pics\\0d40c24b264aa511.jpg");
            ossClient.putObject("gulimall-wystart", "0d40c24b264aa511.jpg", inputStream);
    
            // 关闭OSSClient。
            ossClient.shutdown();
            System.out.println("上传成功");
        }
    
    

    测试,同样可以成功上传。

谷粒商城之分布式基础(二)_第67张图片

注意:

视频中将阿里巴巴oss存储服务依赖加到gulimall-common中,但是这个时候如果启动product是会报错的,原因是其他微服务都依赖了gulimall-common服务,如果其他微服务没有进行相关配置,会报依赖循环的错误,导致启动失败。但是后面我们创建一个专属于第三方服务的微服务,所以如果你要在这里跟着老师的步骤,进行测试的话,最好的建议就是将阿里云服务的oss进行单独引入到product服务,并将common中的注释掉。

6.2.4 新建第三方服务微服务工程并完成文件上传功能

我们将文件上传或者以后的短信验证这些第三方服务抽取出来放到一个专门的第三方微服务的工程项目中。gulimall-third-party

谷粒商城之分布式基础(二)_第68张图片

  • 照例引入springweb和openfeign

谷粒商城之分布式基础(二)_第69张图片

  • 完善 gulimall-third-party 的 pom 文件
oss依赖
添加依赖,将原来 gulimall-common 中的“spring-cloud-starter-alicloud-oss”依赖移动到该项目中,让该微服务专门管理第三方服务
        
        <dependency>
            <groupId>com.alibaba.cloudgroupId>
            <artifactId>spring-cloud-starter-alicloud-ossartifactId>
            <version>2.1.0.RELEASEversion>
        dependency>

引入gulimall-common,注意在其中排除mybatisplus依赖。如果不排除,启动会报错。

         <dependency>
            <groupId>com.atguigu.gulimallgroupId>
            <artifactId>gulimall-commonartifactId>
            <version>0.0.1-SNAPSHOTversion>
            <exclusions>
                <exclusion>
                    <groupId>com.baomidougroupId>
                    <artifactId>mybatis-plus-boot-starterartifactId>
                exclusion>
            exclusions>
        dependency>

另外也需要在“pom.xml”文件中,添加如下的依赖管理
<dependencyManagement>

        <dependencies>
            <dependency>
                <groupId>org.springframework.cloudgroupId>
                <artifactId>spring-cloud-dependenciesartifactId>
                <version>${spring-cloud.version}version>
                <type>pomtype>
                <scope>importscope>
            dependency>
            <dependency>
                <groupId>com.alibaba.cloudgroupId>
                <artifactId>spring-cloud-alibaba-dependenciesartifactId>
                <version>2.1.0.RELEASEversion>
                <type>pomtype>
                <scope>importscope>
            dependency>
        dependencies>
    dependencyManagement>

  • 将服务注册和配置到nacos中

    • 新建 第三方服务的命名空间 ,以后相关配置我们就放在该命名空间下。

      谷粒商城之分布式基础(二)_第70张图片

    • 创建 oss.yml配置文件,以后线上生产时文件上传配置就放在此配置文件中

      谷粒商城之分布式基础(二)_第71张图片

    • 创建 bootstrap.properties文件,进行nacos的配置,此外每一个微服务都需要有对应的微服务名字

      spring.application.name=gulimall-third-party
      # nacos配置中心配置
      spring.cloud.nacos.config.server-addr=127.0.0.1:8848
      spring.cloud.nacos.config.namespace=844086b8-9b51-4e08-a69d-1e76cfbf4485
      
      #以后我们就将文件上传的相关配置放在oss.yml下
      spring.cloud.nacos.config.ext-config[0].data-id=oss.yml
      spring.cloud.nacos.config.ext-config[0].group=DEFAULT_GROUP
      spring.cloud
      
    • 在 application.yml 文件中将服务注册进nacos:这里我们将 oss相关配置也先配置进来,以后线上生产的时候再放到 nacos 上。

      spring:
        cloud:
          nacos:
            discovery:
              server-addr: 127.0.0.1:8848
          alicloud:
            access-key: LTAI5tABh1pjUprZGrKi92w1
            secret-key: enVYmXd9p1sHvVub5gBf21E3tjuIFJ
            oss:
               endpoint: oss-cn-hangzhou.aliyuncs.com
               bucket: gulimall-wystart
      
        application:
          name: gulimall-third-party
      
      server:
        port: 30000
      
      
    • 在 主启动类中添加服务发现注解

      @EnableDiscoveryClient  //服务发现
      @SpringBootApplication
      public class GulimallThirdPartyApplication {
      

1、单元测试

@Autowired
    OSSClient ossClient;

    //测试文件上传到云服务器
    @Test
    public void testUpload() throws FileNotFoundException {
        // // Endpoint以杭州为例,其它Region请按实际情况填写。
        // String endpoint = "oss-cn-hangzhou.aliyuncs.com";
        // // 云账号AccessKey有所有API访问权限,建议遵循阿里云安全最佳实践,创建并使用RAM子账号进行API访问或日常运维,请登录 https://ram.console.aliyun.com 创建。
        // String accessKeyId = "LTAI5tABh1pjUprZGrKi92w1";
        // String accessKeySecret = "enVYmXd9p1sHvVub5gBf21E3tjuIFJ";
        //
        // // // 创建OSSClient实例。
        // OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);

        // 上传文件流。
        InputStream inputStream = new FileInputStream("F:\\JAVA listen\\尚硅谷Java学科全套教程(总207.77GB)\\谷粒商城\\课件\\课件和文档\\基础篇\\资料\\pics\\0d40c24b264aa511.jpg");
        ossClient.putObject("gulimall-wystart", "hahaha.jpg", inputStream);

        // 关闭OSSClient。
        ossClient.shutdown();
        System.out.println("上传成功");
    }

成功上传。

1668233190999

2、服务端签名直传并设置上传回调

接下来我们仔细讲解一下 利用 服务端签名后直传的原理

背景

采用JavaScript客户端直接签名(参见JavaScript客户端签名直传)时,AccessKeyID和AcessKeySecret会暴露在前端页面,因此存在严重的安全隐患。因此,OSS提供了服务端签名后直传的方案。

谷粒商城之分布式基础(二)_第72张图片

服务端签名后直传的原理如下:

用户发送上传Policy请求到应用服务器。
应用服务器返回上传Policy和签名给用户。
用户直接上传数据到OSS。

谷粒商城之分布式基础(二)_第73张图片

  1. 参考官网进行相关配置

谷粒商城之分布式基础(二)_第74张图片

阿里云OSS存储服务中对于服务器签名直传这部分的文档。链接在下面:

https://help.aliyun.com/document_detail/91868.htm?spm=a2c4g.11186623.0.0.1607566a7iSEvF#concept-ahk-rfz-2fb

我们参考这个文档创建属于我们自己的配置。

  • 编写 com.atguigu.gulimall.thirdparty.controller.OssController

    @RestController
    public class OssController {
    
    
        @Autowired
        OSS ossClient;
    
    
        @Value("${spring.cloud.alicloud.oss.endpoint}")//从配置文件动态读取,不写死
        private String endpoint;
        @Value("${spring.cloud.alicloud.oss.bucket}")
        private String bucket;
        @Value("${spring.cloud.alicloud.access-key}")
        private String accessId;
    
        @RequestMapping("/oss/policy")
        public Map<String,String> policy() {
    
            // 填写Host地址,格式为https://bucketname.endpoint。
            String host = "https://" + bucket + "." + endpoint;
    
            //自定义日期格式文件夹,以后上传的文件统一放在当天的文件夹中
            String format = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
            // 设置上传到OSS文件的前缀,可置空此项。置空后,文件将上传至Bucket的根目录下。
            String dir = format + "/";//用户上传时指定的前缀
    
            Map<String, String> respMap = null;
    
            try {
                long expireTime = 30;
                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 respMap;
        }
    }
    
    
    

    测试 http://localhost:30000/oss/policy

    1668235090813

  1. 以后在上传文件时的统一访问路径为“ http://localhost:88/api/thirdparty/oss/policy”,即利用 网关统一路由,由网关来进行转发。

在“gulimall-gateway”中配置路由规则:

        - id: third_party_route
          uri: lb://gulimall-third-party
          predicates:
            - Path=/api/thirdparty/**
          filters:
            - RewritePath=/api/thirdparty(?>/?.*), /$\{segment}
        #http://localhost:88/api/thirdparty/oss/policy  http://localhost:30000/oss/policy

测试是否能够正常跳转: http://localhost:88/api/thirdparty/oss/policy

1668242176551

成功。

  1. 前后端联调,实现文件上传。

    • 将课件中的有关文件上传的资源复制。

      1668418591015

      谷粒商城之分布式基础(二)_第75张图片

    • 文件上传组件在/renren-fast-vue/src/components中

    • 修改组件中el-upload中的action属性,替换成自己的Bucket域名

    谷粒商城之分布式基础(二)_第76张图片

    singleUpload.vue是单文件上传,multiUploca.vue是多文件上传。

    • 把单个文件上传组件应用到brand-add-or-update.vue
    //在
    
    
    1. 将逆向生成的前端代码复制到product下面。

      1668523185366

    2. 在modules/product/下创建attgroup.vue组件

      • 左侧6 用来显示菜单,右侧18用来显示表格
      • 谷粒商城之分布式基础(二)_第99张图片
      • 引入公共组件Category, AddOrUpdate
      • 剩下的复制生成的attrgroup.vue
    
    
    
    
    
    
    

    踩坑:

    Can't resolve './attrgroup-add-or-update' in 'C:\Users\hxld\Desktop\renren-fast-vue\src\views\modules\product'

    解决办法:

    原来是绝对路径,后面改为相对路径即可。错误原因是因为版本问题可能。

    2、父子组件传递数据

    我们要实现的功能是点击左侧,右侧表格对应显示。

    父子组件传递数据:category.vue点击时,引用它的attgroup.vue能感知到, 然后通知到add-or-update。

    谷粒商城之分布式基础(二)_第100张图片

    1. 子组件发送事件
    • 在category.vue中的树形控件绑定点击事件@node-click=“nodeclick”
    • node-click方法中有三个参数(data, node, component),data表示当前数据,node为elementui封装的数据
    • 点击之后向父组件发送事件:this.$emit(“tree-node-click”,…) …为参数
    //组件绑定事件
    
    
        
    //methods中新增方法
     nodeclick(data,node,component){
                console.log("子组件category的节点被点击",data,node,component);
                //向父组件发送事件
                this.$emit("tree-node-click",data,node,component);
            }
    
    
    1. 父组件接收事件
    //引用的组件,可能会发散tree-node-click事件,当接收到时,触发父组件的treenodeclick方法
    
    
    
    //methods中新增treenodeclick方法,验证父组件是否接收到
     //感知树节点被点击
            treenodeclick(data,node,component){
                console.log("attrgroup感知到category的节点被点击:",data,node,component);
                console.log("刚才被点击的菜单id:",data.catId);
            },
    
    

    3、启动测试

    谷粒商城之分布式基础(二)_第101张图片

    ps:这里可以参考其他网友的课件

    根据请求地址http://localhost:8001/#/product-attrgroup
    
    所以应该有product/attrgroup.vue。我们之前写过product/cateory.vue,现在我们
    要抽象到common//cateory.vue
    
    
    1 左侧内容:
    
    要在左面显示菜单,右面显示表格复制,放到attrgroup.vue的