M5(项目)-01-尚硅谷谷粒商城项目分布式基础篇开发文档

M5(项目)-01-尚硅谷谷粒商城项目分布式基础篇开发文档

分布式基础篇

一、环境搭建

  1. 各种开发软件的安装

虚拟机: docker,mysql,redis

主机: Maven, idea(后端),VsCode(前端),git,NodeJS

版本和安装过程略

  1. 快速搭建基本的前后端框架

​ (1). 前端(vscode):下载并导入人人开源后台管理系统vue端脚手架工程renren-fast-vue

坑点1:使用vscode导入renren-fast-vue工程时一定要注意自身NodeJS版本是否匹配package.json中node-sass的版本,如果不匹配则加载不出登录页面。我的NodeJS版本是v12.18.2,package.json中**“node-sass”: “^4.14.1”**

​ (2). 后端(idea):下载并导入人人开源后台管理系统renren-fast和逆向工程renren-generator快速生成dao,entity,service,controller基本CRUD代码以及mapper映射文件。

​ 在创建各个微服务模块前先创建一个公共模块gulimall-common,用以承载每一个微服务公共的依赖,bean,工具类等,各模块只需要在pom.xml文件中依赖这个公共模块就可以大大简化代码。由于要使用renren-fast,大部分依赖和工具类以及bean都要参考renren-fast,从renren-fast中抽取出需要用到的部分。

​ 搭建好公共模块后再创建各微服务模块,不要忘记在父项目gulimall的pom.xml中引用各module。不同的微服务模块使用逆向工程要更改renren-generator模块application.yml中url的表名以及generator.properties文件中的模块名和表前缀,最后分别启动模块测试接口是否可以正常访问。

bug和tip:

​ ①提示-在使用renren-generator快速生成时一定要看清下方共有几页,保证所有表都在一页里展示,这样全选后生成的代码才不会有缺失。

​ ②bug1-各模块配置yml文件时由于粗心使得datasource中的url缩进错误(删除了url:冒号后的空格)使得运行后抛出异常。总结:yml文件一定要注意格式问题

​ ③bug2-由于本人使用的VMware,在开关机后发现centos7的ip一直在变动,主机访问起来非常麻烦。就百度CentOS7设置固定IP地址将IP锁死。但是过了一会后发现xshell和sqlyog都不能正常连接虚拟机,打开VMware->编辑->虚拟网络编辑器中发现VMnet8中子网IP和NAT设置中网关IP都有问题(检查前三个数字和自己固定的Ip是否一致),修改后问题解决。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3HruWPLj-1646456434649)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644492852234.png)]

  1. 使用SpringCloud Alibaba以及SpringCloud搭建微服务组件

![KP%I78%XQ_D[KRJNY_]ZPK](D:\life\qq\2073412335\FileRecv\MobileFile\Image\KP%I78%XQ_D[KRJNY_]ZPK.png)

​ 3.1 使用SpringCloud Alibaba-Nacos配置注册中心(服务发现/注册)

​ ①服务发现是每个微服务都需要包含的功能,所以在common模块pom.xml中创建springcloud alibaba的依赖管理来控制版本并在dependency中依赖spring-cloud-starter-alibaba-nacos-discovery

​ ②其次,在各个微服务模块的yml文件中配置服务发现的服务端地址,默认是8848端口,spring.cloud.nacos.discovery.server-addr:127.0.0.1:8848和spring.application.name服务名。并在启动类上使用注解**@EnableDiscoveryClient标识。在下载(快速下载地址http://8090top.cn-sh2.ufileos.com/centos/nacos-server-1.3.1.zip)并双击nacos-server的bin目录下的startup.cmd启动nacos服务端后,再启动微服务,访问127.0.0.1:8848/nacos**用账密nacos登陆后查看微服务是否被注册到注册中心

​ 3.2 使用SpringCloud-Feign远程调用其它微服务-member会员模块调用coupon优惠券模块的demo

远程调用别的服务的步骤(此处测试调用coupon优惠券服务)
 * 1.pom.xml文件中引入spring-cloud-starter-openfeign,使该服务具有远程调用其它微服务的能力
 * 2.创建一个Feign包(盛放远程调用服务的接口)并在包下编写一个接口,使用注解@FeignClient("远程服务名")告诉springcloud该接口需要远程调用的服务,且接口的每个方法告知调用该服务的哪个请求即都是对应该服务的控制器方法签名,注意请求路径完整
 * 3.开启远程调用其它服务的功能,在启动类上使用注解@EnableFeignClients(basePackages = "feign包的全包名"),这样一旦启动该服务则会自动扫描feign包下使用@FeignClient注解的接口
 * 4.在自身控制层写一个远程调用其它服务的方法,注入feign包下需要调用其它服务的接口,在该控制器方法中可以使用注入的接口调用目标方法来获取远程调用其它服务中目标请求返回的数据。demo如下图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jk9eBANp-1646456434651)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644591989939.png)]

​ 3.3 使用SpringCloud Alibaba-Nacos实现动态配置管理

​ ①导入依赖。nacos-server兼顾了注册中心和配置中心,每个微服务都需要动态配置管理,则统一在common模块依赖spring-cloud-starter-alibaba-nacos-config

​ ②在服务的resources下创建bootstrap.properties文件,该文件执行优先级高于application.properties。并配置Nacos Config元数据:服务名和配置中心地址

spring.application.name=gulimall-coupon
spring.cloud.nacos.config.server-addr=127.0.0.1:8848

​ ③在nacos-server的配置列表中新建配置,Data ID是配置文件的名字(默认是服务名.properties),选好配置格式properties并填写需要动态管理的配置信息后发布

​ ④在用到配置文件的控制器(Controller层)中加上@RefreshScope注解,这样在nacos-server配置中心的配置列表里点编辑就可以动态修改配置文件后发布。注意:如果配置中心和applicaiton.properties都配置了相同项的配置,则优先配置中心的值。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iXabPHQA-1646456434652)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646384322154.png)]

​ 补充几个概念:

命名空间-隔离配置的作用。添加不同的命名空间,思路一–应用于不同的环境需求,如prop生产环境,test测试环境和dev开发环境,默认是public,且public下的配置默认优先读取。思路二–应用于不同微服务的配置隔离。如果需要声明优先读取的命名空间则需要在bootstrap.properties中定义spring.cloud.nacos.config.namespace=命名空间ID/命名空间名称(新版nacos填写名称即可,旧版需要填写命名空间的ID)

配置集-所有的配置的集合
配置集ID-Data ID,作用上类似于文件名,官方文档命名规范是服务名-环境名.yml/properties

配置分组-默认所有的配置集都属于DEFAULT_GROUP。根据需求可以新增不同的GROUP,如双11,618等分组。在特殊时期可以修改bootstrap.properties中spring.cloud.nacos.config.group的值来指定分组。

综上-命名空间和配置分组结合使用可以区分生产环境和微服务。官方建议使用命名空间区分生产环境,而通过配置分组来区分微服务。当然,也可以给每一个微服务都声明一个以微服务名为一个命名空间,并在配置分组下定义不同生产环境dev,prod等即使用命名空间区分微服务而配置分组区分生产环境。

​ 如果有配置文件内容过大需要拆分的需求,可以在bootstrap.properties中使用spring.cloud.nacos.config.ext-config[索引0开始].data-id=拆分配置文件名,spring.cloud.nacos.config.ext-config[索引0开始].group=拆分配置文件分组,spring.cloud.nacos.config.ext-config[索引0开始].refresh=true/false是否自动刷新

​ [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xp4N4sKG-1646456434653)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644648737804.png)]

​ 3.4 使用SpringCloud-GateWay作为API网关(Webflux编程模式)

​ ①先创建gulimall-gateway模块,添加gateway模块并在pom文件中引入gulimall-common公共依赖模块

注意–公共模块中有引入Mybatis-plus则配置文件中必须定义数据源,但网关模块不需要此处功能,可以通过给启动类注解添加exclude属性排除掉跟数据库有关的自动配置@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})。当然也可以不引入common模块,手动在pom文件中添加需要的依赖也行。

​ ②启动类上添加**@EnableDiscoveryClient注解用于开启发现服务的功能并在application.properties中定义服务注册中心地址spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848**,应用名以及端口号。

# 应用名称
spring.application.name=gulimall-gateway
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
server.port=88

​ 添加配置中心配置文件bootstrap.properties,定义应用名/服务名,配置中心地址和命名空间等信息。

spring.application.name=gulimall-gateway
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.cloud.nacos.config.namespace=dev
spring.cloud.nacos.config.group=DEFAULT_GROUP

二、基础篇数据库表设计及说明

1.gulimall_pms库-商品模块

属性表pms_attr

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6PhRQsqP-1646456434654)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646389682443.png)]

用于存放商品的规格参数|基本属性(attr_type=1)和销售属性(attr_type=0),兼有分类Id字段即catelog_id

属性分组表pms_attr_group

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nOMckmuD-1646456434654)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646389624761.png)]

用于存放属性的分组信息,兼有分类Id字段即catelog_id。属性分组和属性的关系是一对多,即一个属性分组可以有很多所属属性但是一个属性只有唯一的一个属性分组。

属性及属性分组关联表pms_attr_attrgroup_relation

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J7RuAeUl-1646456434655)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646389998312.png)]

用于存放属性和属性分组的关联关系,设计中间表的初衷是不使用外键。

分类表pms_category

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dX6fDcTO-1646456434656)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646390279742.png)]

用于存放所有的分类信息。设计的分类信息是以三层父子树形结构存放

品牌表pms_brand

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-V2GxDmOj-1646456434656)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646390543905.png)]

用于存放所有的品牌信息

分类及品牌关联表pms_category_brand_relation

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lJf8PL31-1646456434657)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646390633545.png)]

用于存放品牌和和分类的关联关系,分类和品牌是一对多的关系,即一个分类有多个所属品牌但一个品牌只有一个所属分类。此外还冗余设计了品牌名brand_name和分类名catelog_name,减少了查库的需求和压力但是有可能面对品牌名和分类名失真的情况需要在更新品牌名和分类名时级联更新中间表信息

标准化产品单元Spu主体信息表pms_spu_info

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-M2shhzqp-1646456434658)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646391410662.png)]

用于存放spu主体信息,兼有spu所属的分类Id和品牌Id。

Spu图集表pms_spu_images

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cR61j2lz-1646456434658)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646391687373.png)]

用于存放spu的商品图片

Spu简介图片表pms_spu_info_desc

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OjrummwX-1646456434659)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646391807415.png)]

用于存放spu的简介图片

Spu属性及属性值表pms_spu_attr_value

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XE9DdiHd-1646456434660)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646395030716.png)]

用于接收spu时实际设置的规格参数|基本属性的值,以及设置是否快速展示。

库存量单位Sku主体信息表pms_sku_info
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1GRRIbze-1646456434660)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646391960684.png)]

用于存放发行商品的主体信息,兼有了sku_id字段。标准化产品单元spu和库存量单位sku是一对多的关系,即一个spu可以有多个对应的sku,但一个sku只有唯一对应的一个spu。

Sku发行商品图集表pms_sku_images

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1meeS0bs-1646456434661)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646394151229.png)]

用于存放发行商品的图片集,img_url字段存放的图片链接,default_img字段1或0表示是否是默认展示图片

一个sku_id即一个sku商品可以对应多个图片但一个图片只能对应一个sku_id

Sku发行商品属性表pms_sku_sale_attr_value

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T1lvfrDY-1646456434661)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646395377653.png)]

用于接收设置发行商品时实际设置的销售属性的值

2.gulimall_sms库-优惠券模块

sku满减信息表sms_sku_full_reduciton

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1t5m0pCQ-1646456434662)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646397192476.png)]

用于存放sku的满减信息

sku折扣表sms_sku_ladder

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rd67gnn4-1646456434662)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646397310063.png)]

用于存放sku的打折信息

sku会员价表sms_member_price

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TFAo4eCI-1646456434663)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646397448583.png)]

用于存放会员的sku价格信息

spu积分表sms_spu_bounds

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vGCTX74O-1646456434663)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646397539290.png)]

用于存放spu的成长积分和金币获取量

3.gulimall_wms库-库存模块

仓库维护表wms_ware_info

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XHBzLb36-1646456434664)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1646455311978.png)]

三、业务实现

1. 商品模块-三级菜单 - pms_category表的CRUD

1.1 给pms_category商品分类表插入数据 略
1.2 修改gulimall-product模块CategoryController控制器方法list()

将原先请求路径/list改为/list/tree,将获取到的分类数据以父子的树形结构返回

/**
 * 查出所有分类以及子分类,以树形结构组装起来
 */
@RequestMapping("/list/tree")
//@RequiresPermissions("product:category:list")
public R list(){
    //list()方法能查到所有分类但是我们需要查到所有分类并用将父子分类以树形结构组装起来  categoryService.list();
    //所以要自定义一个方法listWithTree()
    List<CategoryEntity> entities = categoryService.listWithTree();
    return R.ok().put("data", entities);
}
1.3 创建CategoryService接口方法listWithTree()并在实现类CategoryServiceImpl中实现
@Override
public List<CategoryEntity> listWithTree() {
    //1.查出所有分类
    /*自定义方法listWithTree()需要用categoryDao查询所有分类,可以@Autowire手动注入,
    但由于该实现类CategoryServiceImpl继承了ServiceImpl,并将CategoryDao传入
    所以此时baseMapper指向的就是CategoryDao。使用baseMapper一样可以调用selectList()方法
    查询所有分类*/
    List<CategoryEntity> entities = baseMapper.selectList(null);
    //2.组装成父子树形结构
    //2.1 找到所有的一级分类 - 特点 父分类id为0即parent_cid=0
    //用流的方式根据需求过滤不满足需求的集合数据并收集到一个新的集合中
    /*未在CategoryEntity自定义children属性递归查询子分类数据
    List level1Menus = entities.stream().filter((categoryEntity -> {
        return categoryEntity.getParentCid() == 0;
    })).collect(Collectors.toList());*/

    //定义了children属性 有设置children属性和排序的需求 用到map()和sort()
    List<CategoryEntity> level1Menus = entities.stream().filter((categoryEntity -> {
        return categoryEntity.getParentCid() == 0;
    })).map(menu -> {
        //menu是遍历到的一级菜单项
        menu.setChildren(getChildrens(menu,entities));
        return menu;
    }).sorted((menu1,menu2)->{
        //这里sort是Interger类型 有可能为null出现空指针异常 使用三元表达式排除这种情况
        return (menu1.getSort()==null?0:menu1.getSort()) - (menu2.getSort()==null?0:menu2.getSort());
    }).collect(Collectors.toList());

    return level1Menus;
}

//递归查找所有菜单的子菜单 root是当前菜单 all是需要所有过滤的菜单集合-即查询到的所有分类标签数据
private List<CategoryEntity> getChildrens(CategoryEntity root, List<CategoryEntity> all) {
    List<CategoryEntity> children = all.stream().filter(categoryEntity -> {
        //过滤思路-查看当前菜单的分类id即cat_id是否等于待过滤元素的父菜单id即parent_cid
        //return categoryEntity.getParentCid() == root.getCatId();
        //不能使用 == 判断,因为id是Long类型-包装类,如果id超过127则结果错误(包装类缓存池)
        return categoryEntity.getParentCid().equals(root.getCatId());
    }).map(categoryEntity -> {
        //找到子菜单
        categoryEntity.setChildren(getChildrens(categoryEntity, all));
        return categoryEntity;
    }).sorted((menu1, menu2) -> {
        //这里sort是Interger类型 有可能为null出现空指针异常 使用三元表达式排除这种情况
        return (menu1.getSort()==null?0:menu1.getSort()) - (menu2.getSort()==null?0:menu2.getSort());
    }).collect(Collectors.toList());

    return children;
}

开启服务后测试接口返回数据是否正确。

1.4 在vscode的终端中使用npm run dev启动renren-fast-vue打开快速开发平台并创建商品系统目录以及分类维护菜单

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EUZ4eRps-1646456434664)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644764717248.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EqSFsoC8-1646456434665)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644764827247.png)]

参考系统管理目录下其它菜单的设置编写好菜单URL在对应的src/views/modules下创建product文件夹并在该文件下创建category.vue文件 由于路由配置都被平台快速配置好了 这样在点击商品系统的分类维护后就会自动渲染category.vue模块。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O0lUn0CX-1646456434665)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644765013224.png)]

1.5 参考其它模块如role.vue编写category.vue文件,添加getMenus()方法用以获取后台分类数据信息并在钩子函数created()中调用

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F9k3pQU7-1646456434666)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644813949127.png)]

点击开发平台的分类维护菜单发送请求,查看Network发现发送的请求地址有问题,应该发给的是localhost:10001/product/category/list/tree,实际却是如下

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yMo3LpSu-1646456434666)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644814101472.png)]

所以应该修改static\config\index.js文件中的基准路径baseUrl,(注意:经前端发送的请求都添加一个请求前缀api )将所有请求都发给网关,让网关去处理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-POWzDjsb-1646456434667)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644814471993.png)]

再重新刷新页面查看请求地址发现验证码请求直接给了网关,而验证码是renren-fast服务的功能,所以需要将renren-fast服务添加到服务注册中心

1.6 将renren-fast服务添加到注册中心

给renren-fast服务添加公共模块gulimall-common(服务注册到注册中心)并修改yml文件添加服务名和注册中心地址,再给启动类加上服务发现注解**@EnableDiscoveryClient**

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K9y2kKsV-1646456434667)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644814582667.png)]

1.7 修改gulimall-gateway的配置文件,将所有前端发送的请求暂时都负载均衡到renren-fast服务,具体模块发送的请求后期细化

再修改网关的application.yml文件,将所有前端发送的请求都负载均衡到renren-fast服务

断言定义的是所有的前端请求/api/**,过滤器是重写了路径 去掉了/api前端项目前缀并添加了/renren-fast的项目名地址

spring:
  cloud:
    gateway:
      routes:        
        - id: admin_route
          uri: lb://renren-fast
          predicates:
            - Path=/api/**
          filters:
            - RewritePath=/api/(?>.*),/renren-fast/$\{segment}
## 前端项目 请求前缀/api
## 发送的请求http://localhost:88/api/captcha.jpg 不加路径重写的实际请求是http://localhost:8080/api/captcha.jpg
## 需要转成http://localhost:8080/renren-fast/captcha.jpg  /renren-fast是renren-fast模块的应用名访问时需要添加的项目名地址
## server.servlet.context-path=/renren-fast

此处网关的路径重写注意网关版本

3.x的gateway路径重写- RewritePath=/api/?(?.*),/renren-fast\{segment}
2.x的gateway路径重写- RewritePath=/api/(?.*), /renren-fast/$\{segment}
1.8 解决跨域问题-通过添加配置类给容器返回CorsWebFilter从而给预检请求添加响应头实现请求跨域

填写完验证码点击登陆时出现了跨域问题

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JEDwFJpN-1646456434668)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644819190993.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AcvKdjXy-1646456434668)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644819858499.png)]

解决办法:①采用代理服务器nginx ②写跨域配置类,让服务器响应预检请求OPTIONS允许本次请求跨域。本项目采用②

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VoGZLbCA-1646456434669)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644819977068.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G2ZPTzPT-1646456434669)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644820154233.png)]

给gulimall-gateway服务下创建config包以及配置类,将springboot提供的CorsWebFilter配置(该过滤器配置类作用是配置预检请求的响应头)后存入容器

注意创建UrlBasedCorsConfigurationSource时使用reactive包,另外由于renren-fast项目也设置了跨域处理见renren-fast/src/main/java/io/renren/config/CorsConfig.java,将代码屏蔽后重新运行网关和renrenfast看跨域问题是否解决

@Configuration
public class GulimallCorsConfiguration {
    @Bean
    public CorsWebFilter corsWebFilter(){
        //创建CorsWebFilter对象需要传入CorsConfigurationSource配置源
        //而CorsConfigurationSource是一个接口,实现类是reactive包下的UrlBasedCorsConfigurationSource
        // org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource 响应式编程
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        //注册跨域配置 参1设置需要跨域的请求 参2传入跨域配置类
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedHeader("*");//允许哪些请求头跨域
        corsConfiguration.addAllowedMethod("*");//允许哪些请求方式跨域
        corsConfiguration.addAllowedOrigin("*");//允许哪些请求来源跨域
        corsConfiguration.setAllowCredentials(true);//允许携带cookie跨域
        source.registerCorsConfiguration("/**",corsConfiguration);
        return new CorsWebFilter(source);
    }
}
1.9 细化网关的routes配置,给product商品服务模块发送的请求提前(精确路由放在粗略路由之前)

跨域问题解决后,再在gulimall-gateway的applicaiton.yml里给网关添加前端发送/api/product/商品服务的请求时的路由配置 此处注意精确路由需要在粗略路由前面,否则/api/product/**的请求就会被/api/捕获,路由到错误服务中

spring:
  cloud:
    gateway:
      routes:
        - id: product_route
          uri: lb://gulimall-product
          predicates:
            - Path=/api/product/**
          filters:
            - RewritePath=/api/(?>.*),/$\{segment}

        - id: admin_route
          uri: lb://renren-fast
          predicates:
            - Path=/api/**
          filters:
            - RewritePath=/api/(?>.*),/renren-fast/$\{segment}


## 前端项目 请求前缀/api
## 发送的请求http://localhost:88/api/captcha.jpg 不加路径重写的实际请求是http://localhost:8080/api/captcha.jpg
## 需要转成http://localhost:8080/renren-fast/captcha.jpg  /renren-fast是renren-fast模块的应用名访问时需要添加的项目名地址
## server.servlet.context-path=/renren-fast

## 细化前端项目发的请求 如商品服务/api/product 另外精确路由需要在粗略路由前面
## 前端分类维护功能发送请求http://localhost:88/api/product/category/list/tree
## 后端需要接收的请求是http://localhost:10001/product/category/list/tree  去掉api前缀就行
1.10 校验前端是否正常获取到后端发送的分类数据,导入树形控件,绑定标签展示值和标签的子树

配置后重启项目检查是否可以获取到正确的三级分类数据,之后进入VScode,修改发送ajax请求响应成功后的函数,解构响应数据中的{data},这样data.data就是后端分类并组装好的树形分类数据。


再根据elementUI官方文档的树形控件使用说明在template中导入

  
     

并在data中修改defaultProps的label和children值,具体见上面

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aAYa4Q2j-1646456434670)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644847950427.png)]

1.11 给树形控件添加增删按钮

给树形控件加入append添加和delete删除选项 并给el-tree配置属性:expand-on-click-node="false"点击后不会自动展开折叠和可以选中的框show-checkbox(不用赋值true,不然报错)以及node-key="catId"每个树节点用来作为唯一标识的属性,并删除之前的点击事件,并在methods中定义append&delete方法

    <el-tree 
        :data="menus" 
        :props="defaultProps" 
        :expand-on-click-node="false" 
        show-checkbox
        node-key="catId"
    >
      <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>
    //树形控件追加标签 data是从数据库获取到节点的真正内容/数据
    //节点id 父节点cid 所有子节点children等
    append(data) {
        console.log("append:",data)

    },

    //树形空间移除标签 node是当前节点数据(是否被选中checked,是否展开enpended,所处层级level等),
    //data同append
    remove(node, data) {
        console.log("remove:",node,data)

    },

另外由于限定三级分类,所以只有在1,2级节点时才可以追加分类标签并且只有在没有子节点的时候可以删除当前节点,所以在append和delete按钮的属性中添加v-if控制展示的场景

1.12 在后端CategoryController类中修改逆向生成的delete方法,添加业务层接口方法以及业务层实现类方法实现,期间整合MP实现逻辑删除。

CategoryController类

/**
   * 删除-
   * @RequestBody获取请求体 必须发送post请求
   * SpringMVC 自动将请求体的数据(json形式存储)转化为对应的对象
   */
  @RequestMapping("/delete")
  //@RequiresPermissions("product:category:delete")
  public R delete(@RequestBody Long[] catIds){
      System.out.println("待删除菜单id:"+catIds);
	   //categoryService.removeByIds(Arrays.asList(catIds));
      //1.检查当前删除的菜单是否被别的地方引用
      categoryService.removeMenuByIds(Arrays.asList(catIds));
      return R.ok();
  }

CategoryService接口类

public interface CategoryService extends IService<CategoryEntity> {

    PageUtils queryPage(Map<String, Object> params);

    List<CategoryEntity> listWithTree();
	//自定义删除菜单方法
    void removeMenuByIds(List<Long> asList);
}

CategoryServiceImpl接口类

@Override
public void removeMenuByIds(List<Long> asList) {
    //TODO 调用批量删除之前检查菜单是否被其它地方引用
    //批量删除方法-使用了逻辑删除
    baseMapper.deleteBatchIds(asList);
}

由于后续引用地方不确定 使用TODO将此处加入待办事项可以在IDEA下方TODO栏查看待办事项。

此外SpringBoot整合MP实现逻辑删除需要如下步骤:

2、逻辑删除
 *   1)配置全局的逻辑删除规则(可以省略)
 * mybatis-plus:
 *   global-config:
 *     db-config:
 *       logic-delete-field: flag # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
 *       logic-delete-value: 1 # 逻辑已删除值(默认为 1)
 *       logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
 *   2)注册逻辑删除组件MP 3.1.1开始不需要此步骤--现在查看官网已经看不到这步要求(可以省略)
 *   3)实体类字段加上逻辑删除注解@TableLogic 且@TableLogic(value = "1",delval = "0")可以设置逻辑删除与不删除的值
 *   如果与默认全局配置冲突的话

有效配置就是给实体类字段加上逻辑删除注解@TableLogic

/**
 * 是否显示 表中定义[0-不显示,1显示]
 * 而MP 默认0显示1不显示 所以要该TableLogic的属性值
 */
@TableLogic(value = "1",delval = "0")
private Integer showStatus;
1.13 后端调整完毕后进入前端 修改remove()删除菜单的方法并加入ElmentUI的确认消息弹框和消息提示组件

由于renren-fast-vue对发送ajax请求做了封装 此处将发送httpget和httppost请求的模版写入全局代码片段方便后续快速使用。

	"http-get请求": {
		"prefix": "httpget",
		"body": [
			"this.\\$http({",
			"url:this.\\$http.adornUrl(''),",
			"method:'get',",
			"params:this.\\$http.adornParams({})",
			"}).then(({data})=>{",
			"})"
		],
		"description": "renren-fast-vue - httpGet请求"
	},
	"http-post请求": {
		"prefix": "httppost",
		"body": [
			"this.\\$http({",
			"url:this.\\$http.adornUrl(''),",
			"method:'post',",
			"data:this.\\$http.adornData(data,false)",
			"}).then(({data})=>{",
			"})"
		],
		"description": "renren-fast-vue - httpPost请求"
	},

remove()方法最终编写如下,需要留意的点如下:

①使用反引号包裹的文本内可以使用差值表达式${}获取值 不需要拼串 见确认删除弹框的消息提示部分回显菜单名

②确认删除后需要保持删除前的菜单结构则需要给el-tree控件动态绑定**:default-expanded-keys=“expandedKey”**,动态绑定的属性值expandedKey以数组形式存储,注意要在data()中定义且初值赋空。这样只需要在删除成功后刷新菜单时赋值需要删除菜单的父节点id就可以保持删除前的菜单结构

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tDH6pKqR-1646456434670)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1644920004890.png)]

//树形空间移除标签 node是当前节点数据(是否被选中checked,是否展开enpended,所处层级level等),
    //data同append
    remove(node, data) {
      console.log("remove:", node, data);
      //将当前节点数据data中的cartId拼成一个数组
      var ids = [data.catId];
      //使用elementUI messageBox消息弹框 确认是否删除 使用反引号可以在文本内使用差值表达式,不用拼串 `${data.name}`
      this.$confirm(
        `此操作将永久删除菜单【${data.name}】, 是否继续?`,
        "提示",
        {
          confirmButtonText: "确定",
          cancelButtonText: "取消",
          type: "warning",
        }
      )
        .then(() => {
          //确认删除,发送ajax请求
          this.$http({
            url: this.$http.adornUrl("/product/category/delete"),
            method: "post",
            data: this.$http.adornData(ids, false),
          }).then(({ data }) => {
            //使用elementUI的message消息提示组件
            this.$message({
              message: "成功删除菜单",
              type: "success",
            });
            //刷新菜单
            this.getMenus();
            //设置父节点id 保持删除前展开结构
            this.expandedKey = [node.parent.data.catId];
          });
        })
        .catch(() => {
          //取消删除不做任何处理,但必须保留否则会报错
        });
1.14 修改增加菜单 后端部分不需要修改 根据catId获取菜单信息的方法将存入自定义响应类R的键名改为"data" 主要是前端部分要添加模态框|对话框 以及事件处理函数等

template部分代码改造 菜单加edit按钮 添加修改新增菜单的对话框

注意点:①对话框弹出后 默认点击模态框外会关闭 需要给close-on-click-modal设为false 但这样设置close-on-click-modal="false"会报错传参错误 需要设置成动态绑定:close-on-click-modal=“false”

  
{{ node.label }} Append Edit Delete

script部分代码 编写和改造 注意点如下:

①对话框复用标记dialogType

字符串转数字 字符串*1 this.category.catLevel = data.catLevel * 1 + 1;

③对象解构的应用 - 将有用的对象属性结构出来 如解构category将需要发送的属性解构出来
var { catId, name, icon, productUnit } = this.category;

④在修改菜单回显数据时 重新发请求获取了一下最新的数据 稍微避免了修改过程时其它管理员修改完毕同条信息导致回显数据失真的情况 但是没有完全解决这个问题 可以考虑后续使用乐观锁解决这种情况

修改和新增复用同一对话框会出现先点击修改弹出对话框category赋值后再取消修改 随后点击新增会回显之前修改菜单获取到的信息这种情况 使用要在append(data)时将其它信息设为初始

export default {
  data() {
    return {
      menus: [],
      //expandedKey 菜单默认展开的结构状态 传入父节点id
      expandedKey: [],
      //dialogVisible控制对话框/模态框是否展示 默认不展示
      dialogVisible: false,
      //修改新增复用对话框的依据 edit|append
      dialogType: "",
      //对话框内表单绑定的数据对象 其中菜单ID-catId是对话框修改新增复用的依据
      category: {
        name: "",
        parentCid: 0,
        catLevel: 0,
        showStatus: 1,
        sort: 0,
        icon: "",
        productUnit: "",
        catId: null,
      },
      defaultProps: {
        //label:哪个属性是作为标签的值需要展示出来,children:哪个属性需要作为标签的子树
        children: "children",
        label: "name",
      },
    };
  },
  methods: {
    //获取分类数据
    getMenus() {
      this.$http({
        url: this.$http.adornUrl("/product/category/list/tree"),
        method: "get",
      }).then(({ data }) => {
        //{data}解构 将响应的对象中data解构出来 data.data就是查询到的数据集合
        console.log("成功获取到菜单数据。。", data.data);
        this.menus = data.data;
      });
    },

    //追加标签 点击append触发的事件 data是从数据库获取到节点的真正内容/数据
    //节点id 父节点cid 所有子节点children等
    append(data) {
      //console.log("append:", data);
      //设定对话框类型
      this.dialogType = "append";
      //点击添加标签后,对话框展示
      this.dialogVisible = true;
      //给追加标签绑定的数据对象赋值
      this.category.parentCid = data.catId; //父标签id
      this.category.catLevel = data.catLevel * 1 + 1; //分类级别 当前点击的节点级别 + 1 乘1是将字符串转换成数字
     //将其它属性设为初始 防止出现先点击修改弹出对话框category赋值后再取消修改 随后点击新增会回显之前修改菜单获取到的信息这种情况
      this.category.catId = null;
        this.category.name = "";
      this.category.icon = "";
      this.category.productUnit = "";
      this.category.sort = 0;
      this.category.showStatus = 1;
    },

    //移除标签 点击remove触发的事件 node是当前节点数据(是否被选中checked,是否展开enpended,所处层级level等),
    //data同append
    remove(node, data) {
      //console.log("remove:", node, data);
      //将当前节点数据data中的cartId拼成一个数组
      var ids = [data.catId];
      //使用elementUI messageBox消息弹框 确认是否删除 使用反引号可以在文本内使用差值表达式,不用拼串 `${data.name}`
      this.$confirm(`此操作将永久删除菜单【${data.name}】, 是否继续?`, "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning",
      })
        .then(() => {
          //确认删除,发送ajax请求
          this.$http({
            url: this.$http.adornUrl("/product/category/delete"),
            method: "post",
            data: this.$http.adornData(ids, false),
          }).then(({ data }) => {
            //使用elementUI的message消息提示组件
            this.$message({
              message: "成功删除菜单",
              type: "success",
            });
            //刷新菜单
            this.getMenus();
            //设置父节点id 保持删除前展开结构
            this.expandedKey = [node.parent.data.catId];
          });
        })
        .catch(() => {
          //取消删除不做任何处理,但必须保留否则会报错
        });
    },

    //修改菜单 点击edit按钮触发的事件
    edit(data) {
      //console.log("当前要修改的数据", data);
      //修改对话框类型
      this.dialogType = "edit";
      //打开对话框
      this.dialogVisible = true;
      //回显数据 发送请求重新获取最新数据 防止数据在更新期间被其它管理员更改
      //也没有完全避免这个问题 后续可以加乐观锁
      this.$http({
        url: this.$http.adornUrl(`/product/category/info/${data.catId}`),
        method: "get",
      }).then(({ data }) => {
        console.log("data.data:",data.data);//注意data.data下才是需要的菜单信息
        //请求成功 修改数据 层级level等属性不改 后期做拖拽功能直接改结构
        this.category.name = data.data.name;
        this.category.catId = data.data.catId;
        this.category.icon = data.data.icon;
        this.category.productUnit = data.data.productUnit;
        //为了修改后展示父菜单 所以有回显父菜单id的需求
        this.category.parentCid = data.data.parentCid;
      });
    },

    submitData() {
      if (this.dialogType == "append") {
        //调用添加标签的方法
        this.appendCategory();
      }
      if (this.dialogType == "edit") {
        //调用修改标签的方法
        this.editCategory();
      }
    },

    //对话框确认按钮点击-修改标签发送请求到后台
    editCategory() {
      //将前端收集到的category数据发送给后端,由于有些没有修改的内容不需要发给后端
      //所以要解构category将需要发送的解构出来
      var { catId, name, icon, productUnit } = this.category;
      //键值名相同可以省略
      //var data = {catId,name,icon,productUnit};//懒得多声明一个变量可以直接将结构的结果放进请求体里
      this.$http({
        url: this.$http.adornUrl("/product/category/update"),
        method: "post",
        data: this.$http.adornData({ catId, name, icon, productUnit }, false),
      }).then(({ data }) => {
        //后台响应成功
        //关闭对话框
        this.dialogVisible = false;
        //提示友好信息
        this.$message({
          message: "菜单修改成功",
          type: "success",
        });
        //刷新信息
        this.getMenus();
        //展示原先结构
        this.expandedKey = [this.category.parentCid];
      });
    },

    //对话框确认按钮点击-追加标签发送请求到后台
    appendCategory() {
      //console.log("提交的三级分类数据:", this.category);
      //将前端收集到的category数据发送给后端
      this.$http({
        url: this.$http.adornUrl("/product/category/save"),
        method: "post",
        data: this.$http.adornData(this.category, false),
      }).then(({ data }) => {
        //后台响应成功
        //关闭对话框
        this.dialogVisible = false;
        //提示友好信息
        this.$message({
          message: "菜单保存成功",
          type: "success",
        });
        //刷新信息
        this.getMenus();
        //展示原先结构
        this.expandedKey = [this.category.parentCid];
      });
    },
  },
  created() {
    this.getMenus();
  },
};
1.15 增加节点拖拽功能

调试成功后增加节点拖拽功能即实现通过拖拽节点从而改变父子关系和层级结构的功能。只需要给el-tree添加属性draggable就可以拖拽节点,但会出现超出设置的三层菜单的层级限制,需要额外添加属性**:allow-drap=“allowDrop”,并在method中定义方法allowDrop(draggingNode,dropNode,type),其中draggingNode表示当前节点,dropNode是目标节点,type有三种情况:‘prev’,'inner’和’next’分别表示放置在目标节点前,目标节点中和目标节点后,allowDrop返回的标记决定是否能够放置。另外额外提取了一个countNodeLevel(node)**方法用来递归调用计算当前节点的子节点最大层数。具体方法如下:

这个功能需要注意的是整个实现思想

①允许拖拽后放置的依据:当前被拖动的节点的深度+目标节点层级不能大于设置的菜单最大层级3

当前节点的深度通俗来说就是当前节点和子节点以及子节点的子节点全部加起来总共有几层菜单

当前节点的深度(待拖动节点加上子节点有几层) = 当前节点的子节点的最大层级maxLevel - 当前节点所处层级catlevel + 1

②计算当前节点的子节点最大层级的方法countNodeLevel(node)使用了递归

    //判断节点是否可以被拖拽 其中draggingNode表示当前节点,dropNode是目标节点,type有三种情况:
    //'prev','inner'和'next'分别表示放置在目标节点前,目标节点中和目标节点后
    allowDrop(draggingNode, dropNode, type) {
      //判断依据 当前被拖动的节点的深度+目标节点层级不能大于3
      //当前节点的深度(待拖动节点加上子节点有几层) = 当前节点的子节点的最大层级maxLevel - 当前节点所处层级catlevel + 1
      //console.log("allowDrop", draggingNode, dropNode, type);
      //1.计算当前节点的深度 即 当前节点的子节点的最大层级maxLevel - 当前节点所处层级catlevel + 1
      //1.1 求出当前节点的子节点最大层级 值更新在this.maxlevel中
      // draggingNode.data中是Node节点中从后台数据库获取到的静态信息,这里不使用 因为没有更新层级改变此处数据有可能失真
      this.countNodeLevel(draggingNode);
      //console.log("当前节点的子节点最大层级",this.maxLevel)
      //1.2 计算深度
      let deep = this.maxLevel - draggingNode.level + 1;
      //console.log("当前拖拽节点深度",deep)
      //2 判断是否可以拖动 拖动到目标节点内或者前后两种情况
      if (type == "inner") {
        //拖动到目标节点内 只需要当前节点深度+目标节点层级<=3即可
        let isDrag = deep + dropNode.level <= 3;
        console.log(`拖拽类型${type}: 当前节点的子节点最大层级:${this.maxLevel}--当前节点层级:${draggingNode.level}
        --当前节点深度:${deep}--目标节点层级:${dropNode.level}--是否允许拖拽:${isDrag}`);
        //判断完毕给data中的maxLevel赋初值
        this.maxLevel = 0;
        //this.updateNodes = [];
        return isDrag;
      } else {
        //拖动到目标节点前或后 只需要判断当前节点深度+目标节点父节点层级<=3即可
        let isDrag = deep + dropNode.parent.level <= 3;
        console.log(`拖拽类型${type}: 当前节点的子节点最大层级:${this.maxLevel}--当前节点层级:${draggingNode.level}
        --当前节点深度:${deep}--目标节点父节点层级:${dropNode.parent.level}--是否允许拖拽:${isDrag}`);
        //判断完毕给data中的maxLevels赋初值
        this.maxLevel = 0;
        //this.updateNodes = [];
        return isDrag;
      }
    },

    //计算当前节点的子节点最大层数
    countNodeLevel(node) {
      //console.log("当前节点信息",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]);
        }
      } else {
        //没有子节点 将maxLevel设置为当前节点层级 为了正确计算当前节点深度
        //console.log("无子节点的maxlevel设置",node.level)
        this.maxLevel = node.level;
      }
    },

以上方法实现后,在拖拽菜单后el-tree会自动计算出拖拽后应处于的层级level和子节点childNodes,但从数据库中获取到的静态数据data里的节点信息还没更新,先通过拖拽菜单成功后触发的事件函数**handleDrop(draggingNode, dropNode, dropType, ev)**来处理拖拽后的新节点数据并将拖拽后的新节点数据封装为节点对象数组updateNodes: []传入后端从而更新数据库中的节点数据。

注意**更新思想:**当前拖拽节点最新的父节点id|当前拖拽节点最新的顺序 - 遍历兄弟节点数组|当前拖拽节点最新的层级

    //拖拽菜单成功后触发的事件函数
    //draggingNode当前正拖拽的节点 dropNode目标节点|参考节点
    //dropType拖拽到参考节点的哪个位置 ev事件对象
    handleDrop(draggingNode, dropNode, dropType, ev) {
      //console.log("tree drop: ", draggingNode, dropNode, dropType);
      //1.当前拖拽节点最新的父节点id 根据方式判断
      let pCid = 0;
      let siblings = null;
      if (dropType == "before" || dropType == "after") {
        //父id应该是兄弟节点|目标节点的父id
        //pCid = dropNode.parent.data.catId;
        //这里避免一个小bug 如果移动到第一个一级菜单之前 由于之前一级菜单的父节点没有数据
        //所以移动后pCid会变成undefined 这里加个三元判断
        pCid =
          dropNode.parent.data.catId == undefined
            ? 0
            : dropNode.parent.data.catId;

        //当前拖拽节点的兄弟节点就是目标节点的父节点的子节点 - 注意childNodes是拖拽后自动改变后的新值
        //不同于data中后台获取到的children静态值
        siblings = dropNode.parent.childNodes;
      } else {
        //inner
        //父Id就是目标节点的id
        pCid = dropNode.data.catId;
        //当前拖拽节点的兄弟节点就是目标节点的子节点
        siblings = dropNode.childNodes;
      }
      //给全局pCid赋值
      this.pCid.push(pCid);

      //2.当前拖拽节点最新的顺序 - 遍历兄弟节点数组
      //3.当前拖拽节点最新的层级
      for (let i = 0; i < siblings.length; i++) {
        //遍历到当前拖拽节点
        if (siblings[i].data.catId == draggingNode.data.catId) {
          //将节点信息push到updateNodes中 除了排序改变还要将父id 以及层级(视情况而定)
          //判断层级是否发生变化 这里判断使用的siblings[i].level是会随拖拽后自动变化的 - 也就是目标值|正确值
          //而draggingNode.data.catLevel是数据库中存的静态数据 如果二者不相等则需要封装
          let catLevel = draggingNode.data.catLevel;
          if (siblings[i].level != catLevel) {
            //当前拖拽节点层级改变
            catLevel = siblings[i].level;
            //当前节点子节点层级改变 将当前遍历到的拖拽节点传入参数 其childNodes是子节点 抽成一个方法
            this.updateChildrenNodeLevel(siblings[i]);
          }
          this.updateNodes.push({
            catId: siblings[i].data.catId,
            sort: i,
            parentCid: pCid,
            catLevel: catLevel,
          });
        } else {
          //遍历到其它节点
          this.updateNodes.push({ catId: siblings[i].data.catId, sort: i });
        }
      }

      //打印最新整理好的updateNodes
      console.log("updateNodes:", this.updateNodes);
    },

    //拖拽后层级改变,当前拖拽节点的子节点层级改变
    updateChildrenNodeLevel(node) {
      //遍历
      for (let i = 0; i < node.childNodes.length; i++) {
        //let cNode = node.childNodes[i].data;//遍历到当前子节点存储的后端节点数据
        //cNode.catId = cNode.catId;//待更新的id
        //cNode.catLevel = node.childNodes[i].level//待更新的后端catLevel层级
        //console.log("待更新的子节点id",node.childNodes[i].data.catId)
        //console.log("待更新的子节点后端catLevel层级",node.childNodes[i].level)
        this.updateNodes.push({
          catId: node.childNodes[i].data.catId,
          catLevel: node.childNodes[i].level,
        });
        //递归调用
        this.updateChildrenNodeLevel(node.childNodes[i]);
      }
    },

为了多次拖拽后统和数据改动一次传入后端从而减少与数据库交互次数,定义一个保存批量拖拽的按钮点击后向后端发送数据更新请求,另外也定义一个清除节点更新数组的重置按钮

//批量拖拽后向后台提交最新节点信息
    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();

        //设置默认展开的菜单s
        this.expandedKey = this.pCid;
        //置为初值
        this.updateNodes = [];
        //this.pCid = [];
      });
    },

    //取消批量拖拽
    cancelBatchDrag() {
      //刷新菜单
      this.getMenus();

      //设置默认展开的菜单s
      this.expandedKey = this.pCid;
      //置为初值
      this.updateNodes = [];
      this.pCid = [];
    },
  },

也别忘了给后端增加批量更新节点的方法com.atguigu.gulimall.product.controller.CategoryController#updateSort

/**
 * 自定义批量修改方法 用于拖拽时的更新需求
 * category是前端收集到的待更新节点数组由SpringMVC自动映射为List
 */
@RequestMapping("/update/sort")
//@RequiresPermissions("product:category:update")
public R updateSort(@RequestBody List category){
    categoryService.updateBatchById(category);
    return R.ok();
}
1.16 批量删除

引入批量删除按钮el-button 点击后将选中的节点id组成的数组发送给后端批量删除

主要注意这里用到el-tree中Tree组件内部定义的方法,使用组件内定义的方法需要先给该组件定义一个引用标识ref属性,此外调用方式是:this.$refs.ref标识.组件内方法

    批量删除
   //批量删除菜单
    //这里用到el-tree中Tree组件内部定义的方法getCheckedMenus()该方法返回所有被选择的节点组成的数组
    //使用组件内定义的方法需要先给该组件定义一个引用标识ref属性,此外调用方式是:this.$refs.ref标识.组件内方法
    //其中this.$refs是拿到当前所有的组件 而.ref标识就是拿到树形控件
    batchDelete(){
      //let checkedNodes = this.$refs.treeMenu.getCheckedNodes();
      //console.log("所有被选中的节点:",checkedNodes)
      //这里使用map将this.$refs.treeMenu.getCheckedNodes()获取到的选中节点对象映射为只有其catId构成的数组
      let checkedNodesId = this.$refs.treeMenu.getCheckedNodes().map(node => node.catId);
      let checkedNodesName = this.$refs.treeMenu.getCheckedNodes().map(node => node.name);
      let checkedNodesNameSlice = [];
      if(checkedNodesName.length > 5){
        checkedNodesNameSlice = checkedNodesName.slice(0,5)
      }
      //console.log("截取名",checkedNodesNameSlice)
      //console.log("所有被选中的节点catId:",checkedNodesId)
      //使用elementUI messageBox消息弹框 确认是否删除 使用反引号可以在文本内使用差值表达式,不用拼串 `${data.name}`
      this.$confirm(`是否批量删除以下菜单【${checkedNodesName.length <= 5?checkedNodesName:checkedNodesNameSlice}】${checkedNodesNameSlice.length == 0?"":"等"}?`, "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning",
      })
        .then(() => {
          //确认删除,发送ajax请求
          this.$http({
            url: this.$http.adornUrl("/product/category/delete"),
            method: "post",
            data: this.$http.adornData(checkedNodesId, false),
          }).then(({ data }) => {
            //使用elementUI的message消息提示组件
            this.$message({
              message: "菜单批量删除成功",
              type: "success",
            });
            //刷新菜单
            this.getMenus();
            
          });
        })
        .catch(() => {
          //取消删除不做任何处理,但必须保留否则会报错
        });
      

    },

2.商品模块-品牌服务 - pms_brand表的CRUD

先导-基本的CRUD需要的vue文件renren-generator已经逆向生成好了,只需要先在快速开放平台创建好需要实现的菜单及服务 配置好菜单URL

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0ZEqK51A-1646456434671)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1645283243066.png)]

然后在前端项目文件对应的目录下复制粘贴生成好的vue文件即可,如商品模块的品牌服务

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EjxvbHsE-1646456434671)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1645283073314.png)]

导入后重启前端项目,打开新添加的菜单发现只有查询功能,没有新增功能,这是renren-fast-vue写好的权限校验,只有管理身份打开才能有新增删除等权限,这里开发阶段先将该权限校验的代码做一下修改,权限校验方法是在src\utils\index.js中导出

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mYTRy09c-1646456434672)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1645283540711.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OsyTfAmj-1646456434672)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1645283562486.png)]

以上修改完毕后就要按需对逆向生成的代码调整

2.1 修改品牌showStatus的存取类型input->switch并添加@change监听状态改变及时改变数据库数据

tips:

①由于showStatus品牌状态打算使用switch开关组件那么在el-table中就需要参考自定义列模版的使用方法。

在el-table的每一列el-table-column下自定义列模版需要使用template标签且必须定义属性slot-scope=“scope”,在template内就可以使用其它需要组合使用的组件,且在其它组件中可以通过scope.row获取当前行的数据。以下是showStatus列的改造

②后端数据库存放的show_status列用0|1绑定是否打开关闭,而switch默认true和fasle控制开关,所以手动设置el-switch的active-value和inactive-value值与数据库映射。切记此处1和0是数字,那么必须加上冒号:,否则会将1,0当字符串判定

      
        
      

以及状态改变后触发的事件处理函数-改变后台数据 ①结构行数据rowData ②后台showStatus用0和1存放,这里用三元将true|fasle 转化为1|0

    //品牌展示开关被改变后触发事件
    updateBrandStatus(rowData) {
      //console.log("status改变后最新rowData", rowData);
      //将brandId和showStatus解构出来
      let {brandId,showStatus} = rowData;
      this.$http({
        url: this.$http.adornUrl("/product/brand/update"),
        method: "post",
        //注意showStatus后台是0,1接收 这里使用三元转换数据
        data: this.$http.adornData({brandId:brandId,showStatus:showStatus?1:0}, false),
      }).then(({ data }) => {
        //响应成功
        this.$message({
          message:"状态改变成功",
          type:"success"
        })
      });
    },
2.2 品牌logo的文件上传功能

先导-对象存储服务OSS(object storage service)使用必要性

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IbfH5AIS-1646456434673)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1645368614646.png)]

阿里云对象存储服务的使用步骤 这里注意版本问题 新版本springboot有变化

使用aliyun对象存储OSS的步骤
 * 1)导入依赖
 *         
 *             com.alibaba.cloud
 *             spring-cloud-starter-alicloud-oss
 *             2.2.0.RELEASE
 *         
 * 2)在yml文件中配置阿里云openAPI的endpoint access-key secret-key
 * 3)注入OSSClient 通过OSSClient.putObject传入bucket名 自定义云端文件名含后缀以及本地文件流FileinputStream参数
 *    就可以将文件上传给阿里云的bucket列表

创建一个新模块gulimall-third-party用来盛放第三方的一些服务,将阿里云对象存储OSS放入其中

采用服务端签名直传的方式将图片存入阿里云OSS即在服务端通过Java代码完成签名(并且设置上传回调),然后通过表单直传数据到OSS。步骤如下

①pom.xml导入OSS依赖

②编辑application.yml文件

配置注册中心地址 应用名 端口名以及阿里云oss服务三属性access-key sercret-key及oss.endpoint 和自定义的云存储空间名bucket。

spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
    alicloud:
      access-key: LTAI5tPPeS9KtR539wrrL8WG
      secret-key: bFeVRHpecpHt1ASHG5blyrrjL3df20
      oss:
        endpoint: oss-cn-hangzhou.aliyuncs.com
        bucket: gulimall-chenk
  application:
    name: gulimall-third-party

server:
  port: 30000

③创建并编辑bootstrap.properties文件

设置配置中心地址 命名空间 并加载配置中心的配置

spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.cloud.nacos.config.namespace=0bcdfaec-accd-44e9-8d4f-fd92b49bcd10

#加载配置中心配置oss.yml oss.endpoint access-key sercret-key
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

④创建controller包并编写OSScontroller接口方法policy

注意点tips

a.注入使用**@Autowired和@Resource区别**

b.读取配置文件中属性**@Value("${属性名}")**

@RestController
public class OssController {

    //自动注入OssClient
    //方式一:@Autowired注解要注入OSS接口
    //如果注入OSSClient会报错Field ossClient in com.atguigu.gulimall.thirdparty.controller
    // .OssController required a bean of type 'com.aliyun.oss.OSSClient' that could not be found.
//    @Autowired
//    OSS ossClient;
    //方式二:@Resource注解 直接注入OSSClient即可 因为使用@Resource注解可以按名称找到
    @Resource
    OSSClient ossClient;

    //从配置文件中获取endpoint bucket等属性 其中bucket是自定义的一个属性
    @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;

//    @Value("${spring.cloud.alicloud.secret-key}")
//    private String accessKey;

    /**
     *
     * @return Map respMap包装为R 
     * accessid-
     * policy-策略
     * signature-签名 阿里云验证依据
     * dir-上传文件时的目录前缀
     * host-上传到的主机地址
     * expire-过期时间
     * respMap值为{"accessid":"LTAI5tPPeS9KtR539wrrL8WG","policy":"eyJleHBpcmF0aW9uIjoiMjAyMi0wMi0yMVQwMzoxNjozNy4wNjNaIiwi
     * Y29uZGl0aW9ucyI6W1siY29udGVudC1sZW5ndGgtcmFuZ2UiLDAsMTA0ODU3NjAwMF0sWyJzdGFydHMtd2l0aCIsIiRrZXkiLCIyMDIyL
     * TAyLTIxLyJdXX0=","signature":"+VkovFEcgQxoCkoOhzyBBlB6MG4=","dir":"2022-02-21/",
     * "host":"https://gulimall-chenk.oss-cn-hangzhou.aliyuncs.com","expire":"1645413397"}
     */
    @RequestMapping("/oss/policy")
    public R policy(){
        //对象存储服务最终访问路径应为 存储空间名.endpoint/自定义文件包含后缀
        // https://gulimall-chenk.oss-cn-hangzhou.aliyuncs.com/testup.jpg
        //从配置文件中获取endpoint bucket等属性
        //String accessId = ""; // 请填写您的AccessKeyId。
        //String accessKey = ""; // 请填写您的AccessKeySecret。
        //String endpoint = "oss-cn-hangzhou.aliyuncs.com"; // 请填写您的 endpoint。
        //String bucket = "bucket-name"; // 请填写您的 bucketname 。
        String host = "https://" + bucket + "." + endpoint; // host的格式为 bucketname.endpoint
        //上传回调暂时不用注释掉 callbackUrl为上传回调服务器的URL,请将下面的IP和Port配置为您自己的真实信息。
        //String callbackUrl = "http://88.88.88.88:8888";
        //文件的目录前缀这里设定每天的图片收集到一个文件夹中
        String currentDate = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
        String dir = currentDate; // 用户上传文件时指定的前缀。拼接与否看前端获取时是否要拼接

        Map<String, String> respMap = null;
        // 创建OSSClient实例。 这里自动注入省去
        //OSS ossClient = new OSSClientBuilder().build(endpoint, accessId, accessKey);
        try {
            long expireTime = 30;
            long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
            Date expiration = new Date(expireEndTime);
            // PostObject请求最大可支持的文件大小为5 GB,即CONTENT_LENGTH_RANGE为5*1024*1024*1024。
            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));
            /* 跨域部分网关统一解决 此部分删掉
            JSONObject jasonCallback = new JSONObject();
            jasonCallback.put("callbackUrl", callbackUrl);
            jasonCallback.put("callbackBody",
                    "filename=${object}&size=${size}&mimeType=${mimeType}&height=${imageInfo.height}&width=${imageInfo.width}");
            jasonCallback.put("callbackBodyType", "application/x-www-form-urlencoded");
            String base64CallbackBody = BinaryUtil.toBase64String(jasonCallback.toString().getBytes());
            respMap.put("callback", base64CallbackBody);

            JSONObject ja1 = JSONObject.fromObject(respMap);
            // System.out.println(ja1.toString());
            response.setHeader("Access-Control-Allow-Origin", "*");
            response.setHeader("Access-Control-Allow-Methods", "GET, POST");
            response(request, response, ja1.toString());*/

        } catch (Exception e) {
            // Assert.fail(e.getMessage());
            System.out.println(e.getMessage());
        } finally {
            ossClient.shutdown();
        }
        return R.ok().put("data",respMap);
    }
}

⑤给网关的配置文件添加新的路由地址-此处注意网关版本 不同版本路径重写方式不同

将前端发送的/api/thirdparty/的所有请求负载均衡到gulimall-third-party服务并重写路径将前端项目前缀/api/thirdparty去掉

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BJORMl9k-1646456434673)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1645415256045.png)]

⑥联调前端

前端使用upload组件来实现文件上传,将已经封装好的文件上传组件(单文件上传和多文件上传)以及抽取好的获取后端签名方法的policy.js放入src\components

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jiZUQdad-1646456434674)(C:\Users\ck\AppData\Roaming\Typora\typora-user-images\1645415839753.png)]

修改el-upload中action属性值为Bucket 域名 =“gulimall-chenk.oss-cn-hangzhou.aliyuncs.com”

然后遵循以下步骤正确导入组件

①在
                    
                    

你可能感兴趣的:(M5-项目,java,前端,开发语言)