谷粒商城基础(商品部分)

710 阅读21分钟

一、新建项目

结构.png

每个子模块用spring初始插件导入 web、和 openfeign 依赖 聚合pom

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.hong.gulimall</groupId>
    <artifactId>gmail</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>gmail</name>
    <description>聚合</description>
    <packaging>pom</packaging>

    <modules>
        <module>gulimall-product</module>
        <module>gulimall-order</module>
        <module>gulimall-member</module>
        <module>gulimall-ware</module>
        <module>gulimall-coupon</module>
        <module>renren-fast</module>
        <module>renren-generator</module>
    </modules>

</project>

二、导入人人开源脚手架

启动renren-fast后台模块

renren-fast是一个轻量级的,前后端分离的Java快速开发平台,能快速开发项目并交付

git.png

git2.png 修改yml配置文件,导入自带数据库文件,即可启动了

人人.png

启动renren-fast-vue前端管理界面

安装node.js环境,npm是随同Node.js一起安装的包管理工具,运行renren-fast-vue 只需要执行npm install下载项目,npm run dev执行

如果觉得下的慢的话,可以用国内镜像

npm config set registry http://registry.npm.taobao.org/

由于版本问题,这一步可能会报“Module build failed: Error: Cannot find module 'node-sass”的错误,这里我们就需要多执行一步

这个地方踩了无数的坑,一刷的时候就没有毛病,这里版本升级,我们需要降低sass版本

  • vscode 下在项目根目录打开终端,npm install node-sass@4.13.1,项目默认的 sass地址一直404的话可以考虑这样做
  • 安装完成后打开 package-lock.json ctrl+f 查找 sass,当version="4.13.1"时证明安装成功(当然前提是上一步没报错)
  • npm run dev
  • 补充一点 我事先将 npm版本切换到 6.9.0了

login.png

启动renren-generator模块

renren-generator是人人开源项目的代码生成器,可在线生成entity、xml、dao、service、html、js、sql代码,减少70%以上的开发任务

修改配置

我们只需要更改数据库地址和生成模板renren-generator/src/main/resources/generator.properties,启动项目。

gen.png 选择想生成相关的表格。

生.png

将生成代码导入相关模块。由于每个模块都需要很多相似的依赖和测试类,我们可以抽取出一个共同模块gulimall-common

common.png 修改pom文件依赖,并在每个模块导入该模块。

<parent>
    <artifactId>gmail</artifactId>
    <groupId>com.hong.gulimall</groupId>
    <version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>gulimall-common</artifactId>
<description>每一个微服务公共的依赖,工具类</description>
<dependency>
    <groupId>com.hong.gulimall</groupId>
    <artifactId>gulimall-common</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

给每个模块配置相关数据

  • 导入依赖
  • 配置数据源
  • 配置MyBatis-Plus
    • 配置@MapperScan配置扫描
    • 配置sql映射文件位置

三、微服务注册中心

Nacos是以服务为主要服务对象的中间件,Nacos支持所有主流的服务发现、配置和管理。

Nacos主要提供以下四大功能:

  • 服务发现和服务健康监测
  • 动态配置服务
  • 动态DNS服务
  • 服务及其元数据管理

导入spring-cloud-alibaba-dependencies依赖 只需在common模块中导入即可

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>2.1.0.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

修改配置

  application:
    name: gulimall-coupon
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848

加注解@EnableDiscoveryClient

启动nacos,然后分别启动各个微服务,将它们注册到Nacos中。

nacos.png

四、使用open-feign调用服务

比如说我们想获取每个会员的所有优惠卷信息。这里我们就需要用到couponmember两个模块,我们需要在coupon模块查询当前会员所有优惠卷返回给member模块

编写一个接口,告诉SpringCLoud这个接口需要调用远程服务

修改com.hong.gulimall.coupon.controller.CouponController,添加以下controller方法:

// 会员的所有优惠卷信息
@RequestMapping("/member/list")
public R membercoupons(){
    CouponEntity couponEntity = new CouponEntity();
    couponEntity.setCouponName("满100减10");
    return R.ok().put("coupons",Arrays.asList(couponEntity));
}

新建com.hong.gulimall.member.feign.CouponFeignService接口

// 告诉客户端要调用哪个远程服务(声明式远程调用)
@FeignClient("gulimall-coupon")
public interface CouponFeignService {

    // 获取会员的优惠卷
    @RequestMapping("coupon/coupon/member/list")
    public R membercoupons();
}

修改主配置类GulimallMemberApplication类,添加@EnableFeignClients注解,声明接口的每一个方法都是调用哪个远程服务的那个请求

@EnableFeignClients(basePackages = "com.hong.gulimall.member.feign")

测试远程调用功能,在MemberController中添加

@RequestMapping("/coupon")
public R test(){
    MemberEntity memberEntity = new MemberEntity();
    memberEntity.setNickname("张三");
    R membercoupons = couponFeignService.membercoupons();
    return R.ok().put("member",membercoupons).put("coupons",membercoupons.get("coupons"));
}

把两个模块都启动,访问地址http://localhost:8000/member/member/coupon

feign.png 停止gulimall-coupon服务,能够看到注册中心显示该服务的健康值为0

0.png 再次访问http://localhost:8000/member/member/coupon报错

error.png

五、Nacos作为配置中心

导入依赖

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

创建bootstrap.properties配置文件

spring.application.name=gulimall-coupon
# 兼顾了配置中心和注册中心地址
spring.cloud.nacos.config.name=127.0.0.1:8848

传统配置模式

创建“application.properties”配置文件

coupon.user.name="zhangsan"
coupon.user.age=30

CouponController中写测试接口

  @Value("${coupon.user.name}")
    private String name;
    @Value("${coupon.user.age}")
    private Integer age;

    @RequestMapping("/test")
    public R getConfigInfo(){
       return R.ok().put("name",name).put("age",age);
    }

访问http://localhost:7000/coupon/coupon/test test.png 这样做存在的一个问题,如果频繁的修改application.properties,在需要频繁重新打包部署。下面我们将采用Nacos的配置中心来解决这个问题。

Nacos配置方式

Nacos注册中心中,点击“配置列表”,添加配置规则: cof.png 配置格式:properties

文件的命名规则为:${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
${spring.application.name}:为微服务名
${spring.profiles.active}:指明是哪种环境下的配置,如devtestinfo
${spring.cloud.nacos.config.file-extension}:配置文件的扩展名,可以为propertiesyml

CouponController添加@RefreshScope注解 只要配置文件刷新,数据也跟着自动刷新,动态的从配置中心读取配置.

注意:同时存在properties配置文件和nacos配置中心时,优先加载nacos配置中心

Nacos支持三种配置加载方方案

Nacos支持“Namespace+group+data ID”的配置解决方案。

1、创建命名空间:

“命名空间”—>“创建命名空间”:

创建三个命名空间,分别为dev,test和prop

2、回到配置列表中,能够看到所创建的三个命名空间 2.png

下面我们需要在dev命名空间下,创建“gulimall-coupon.properties”配置规则: 3.png

3、访问:http://localhost:7000/coupon/coupon/test

4.png

并没有使用我们在dev命名空间下所配置的规则,而是使用的是public命名空间下所配置的规则,这是怎么回事呢?

查看“gulimall-coupon”服务的启动日志:

2020-04-24 16:37:24.158  WARN 32792 --- [           main] c.a.c.n.c.NacosPropertySourceBuilder     : Ignore the empty nacos configuration and get it based on dataId[gulimall-coupon] & group[DEFAULT_GROUP]
2020-04-24 16:37:24.163  INFO 32792 --- [           main] c.a.nacos.client.config.utils.JVMUtil    : isMultiInstance:false
2020-04-24 16:37:24.169  INFO 32792 --- [           main] b.c.PropertySourceBootstrapConfiguration : Located property source: [BootstrapPropertySource {name='bootstrapProperties-gulimall-coupon.properties,DEFAULT_GROUP'}, BootstrapPropertySource {name='bootstrapProperties-gulimall-coupon,DEFAULT_GROUP'}]

"gulimall-coupon.properties" ,默认就是public命名空间中的内容中所配置的规则。

4、指定命名空间

如果想要使得我们自定义的命名空间生效,需要在“bootstrap.properties”文件中,指定使用哪个命名空间:

spring.cloud.nacos.config.namespace=a2c83f0b-e0a8-40fb-9b26-1e9d61be7d6d

这个命名空间ID来源于我们在第一步所创建的命名空间

5、重启“gulimall-coupon”,再次访问:http://localhost:7000/coupon/coupon/test

5.png

但是这种命名空间的粒度还是不够细化,对此我们可以为项目的每个微服务module创建一个命名空间。

6、为所有微服务创建命名空间

6.png

7、回到配置列表选项卡,克隆pulic的配置规则到coupon命名空间下

7.png

切换到coupon命名空间下,查看所克隆的规则: 7.1.png

8、修改“gulimall-coupon”下的bootstrap.properties文件,添加如下配置信息

spring.cloud.nacos.config.namespace=7905c915-64ad-4066-8ea9-ef63918e5f79

这里指明的是,读取时使用coupon命名空间下的配置。

9、重启“gulimall-coupon”,访问:http://localhost:7000/coupon/coupon/test

9.png

DataID方案

通过指定spring.profile.active和配置文件的DataID,来使不同环境下读取不同的配置,读取配置时,使用的是默认命名空间public,默认分组(default_group)下的DataID

默认情况,Namespace=publicGroup=DEFAULT GROUP,默认ClusterDEFAULT

Group方案

通过Group实现环境区分

实例:通过使用不同的组,来读取不同的配置,还是以上面的gulimall-coupon微服务为例

1、新建“gulimall-coupon.properties”,将它置于“tmp”组下

2、修改“bootstrap.properties”配置,添加如下的配置

spring.cloud.nacos.config.group=tmp

3、重启“gulimall-coupon”,访问:http://localhost:7000/coupon/coupon/test

同时加载多个配置集

当微服务数量很庞大时,将所有配置都书写到一个配置文件中,显然不是太合适。对此我们可以将配置按照功能的不同,拆分为不同的配置文件。

数据源有关的配置写到一个配置文件中,和框架有关的写到另外一个配置文件中: 1、创建“datasource.yml”,用于存储和数据源有关的配置

spring:
  datasource:
    #MySQL配置
    url: jdbc:mysql://localhost:3306/gulimall_sms?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver

2、将和mybatis相关的配置,放置到“mybatis.yml”

mybatis-plus:
  mapper-locations: classpath:/mapper/**/*.xml
  #  自增主键
  global-config:
    db-config:
      id-type: auto

3、创建“other.yml”配置,保存其他的配置信息

server:
  port: 7000

spring:
  application:
    name: gulimall-coupon
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848

4、修改“gulimall-coupon”“bootstrap.properties”文件,加载“mybatis.yml”“datasource.yml”“other.yml”配置

spring.cloud.nacos.config.extension-configs[0].data-id=mybatis.yml
spring.cloud.nacos.config.extension-configs[0].group=dev
spring.cloud.nacos.config.extension-configs[0].refresh=true

spring.cloud.nacos.config.extension-configs[1].data-id=datasource.yml
spring.cloud.nacos.config.extension-configs[1].group=dev
spring.cloud.nacos.config.extension-configs[1].refresh=true


spring.cloud.nacos.config.extension-configs[2].data-id=other.yml
spring.cloud.nacos.config.extension-configs[2].group=dev
spring.cloud.nacos.config.extension-configs[2].refresh=true

5、注释“application.yml”文件中的所有配置

6、重启“gulimall-coupon”服务,然后访问http://localhost:7000/coupon/coupon/test

7、访问:http://localhost:7000/coupon/coupon/list,查看是否能够正常的访问数据库 list.png 小结:

  • 微服务任何配置信息,任何配置文件都可以放在配置中心;
  • 只需要在bootstrap.properties中,说明加载配置中心的哪些配置文件即可;
  • @Value, @ConfigurationProperties。都可以用来获取配置中心中所配置的信息;
  • 配置中心有的优先使用配置中心中的,没有则使用本地的配置。

六、写入网关模块

创建gulimall-gateway模块

导入依赖

<dependency>
    <groupId>com.hong.gulimall</groupId>
    <artifactId>gulimall-common</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

加入注册中心nacos
主启动类GulimallGatewayApplication加上@EnableDiscoveryClient注解

排除和数据源相关的配置 主启动类加、

@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})

启动网关

网关启动.png

七、编写商品分类业务

三级分类菜单

业务具体要求:我们想把分类写成三级菜单树形展示的模式。由于每个category有自己的分类id字段、父id字段parent_cid,这里我们可以用sql自查询语句,不过处理起来比较麻烦,所以我们只需拿出所有分类信息,在service层来处理

一个分类下可能又对应一堆分类,所以在gulimall-product模块下的分类实体类中添加一个List集合,在数据库中并没有该字段,这里我们用Mybatis-Plus的注解

/**
 * 商品三级分类
 * 
 * @author hong
 * @email 1365797108@qq.com
 * @date 2022-04-16 11:44:00
 */
@Data
@TableName("pms_category")
public class CategoryEntity implements Serializable {
   private static final long serialVersionUID = 1L;

   /**
    * 分类id
    */
   @TableId
   private Long catId;
   /**
    * 分类名称
    */
   private String name;
   /**
    * 父分类id
    */
   private Long parentCid;
   /**
    * 层级
    */
   private Integer catLevel;
   /**
    * 是否显示[0-不显示,1显示]
    */
   private Integer showStatus;
   /**
    * 排序
    */
   private Integer sort;
   /**
    * 图标地址
    */
   private String icon;
   /**
    * 计量单位
    */
   private String productUnit;
   /**
    * 商品数量
    */
   private Integer productCount;

   // 数据库不存在,封装在该类
   @TableField(exist = false)
   private List<CategoryEntity> children;
}

在控制层CategoryController添加方法

@Autowired
private CategoryService categoryService;

/**
 * 查出所有分类以及子分类,以树形结构组装起来
 */
@RequestMapping("/list/tree")
public R list(@RequestParam Map<String, Object> params){
    List<CategoryEntity> entities = categoryService.listWithTree();
    return R.ok().put("page", entities);
}

Service层:这里只需要拿到所有的分类,所以不需要编写dao层,直接用mpapi拿到所有数据交给服务层处理。

@Override
public List<CategoryEntity> listWithTree() {
    // 1、查出所有分类
    List<CategoryEntity> entities = baseMapper.selectList(null);
    // 2、组装成父子的树形结构
    // 2.1)、filter找到所有的一级分类
    // 2.2)、map把当前菜单子菜单封装进去
    // 2.3)、排序
    List<CategoryEntity> level1Menu = entities.stream().filter((categoryEntity -> {
        return categoryEntity.getParentCid() == 0;
    })).map((menu) -> {
        menu.setChildren(getChildren(menu,entities));
        return menu;
    }).sorted((menu1,menu2) -> {
        return (menu1.getSort() == null ? 0: menu1.getSort()) - (menu2.getSort() == null ? 0: menu2.getSort());
    }).collect(Collectors.toList());
    return level1Menu;
}

// 在all中找到当前菜单所有子菜单
private  List<CategoryEntity> getChildren(CategoryEntity root,List<CategoryEntity> all){
    List<CategoryEntity> children = all.stream().filter(categoryEntity -> {
        return categoryEntity.getParentCid() == root.getCatId();
    }).map(categoryEntity -> {
        // 1、找到子菜单
        categoryEntity.setChildren(getChildren(categoryEntity,all));
        return categoryEntity;
    }).sorted((menu1,menu2) -> {
        // 2、菜单的排序
        return (menu1.getSort() == null ? 0: menu1.getSort()) - (menu2.getSort() == null ? 0: menu2.getSort() );
    }).collect(Collectors.toList());

    return children;
}

然后启动后台管理,新增一个一级菜单和二级菜单,注意URL规则

菜单.png product/category对应http://localhost:8001/#/product-category地址,对应view/product/category.vue组件。

网关配置

编写前端业务,我们可以用如下代码测试

 //方法集合
    methods: {
      getMenus(){
         this.$http({
          url: this.$http.adornUrl('/product/category/list/tree'),
          method: 'get'
        }).then(data => {
          console.log("成功获取到菜单数据...",data)
        })
      }
    },
    //生命周期 - 创建完成(可以访问当前this实例)
    created() {
      this.getMenus();
    },

tree.png 这里我们发现404报错,后台product的业务端口是10000,renren-fast端口为8000,所以这里肯定是访问不到的。

查看static/config/index.js文件 js.png

这里我们需要改成网关地址,因为后台服务器的端口并不唯一,我们只需要用网关来接收后端服务,再转发到前端

  // api接口请求地址
  // 定义规则:前端项目, /api
  window.SITE_CONFIG['baseUrl'] = 'http://localhost:88/api';

由于更改了配置,后台的验证码也会失效,这时就需要我们去配置网关

验证码.png

renren-fast注册到服务中心 这里网关配置过程,前端请求网关地址为http://localhost:88/api/captcha.jpg?uuid=...
renren-fast服务实际地址http://localhost:8080/renren-fast/captcha.jpg?uuid=...
我们按照路径来断言,先将请求负载均衡到renren-fast,那么此时就会去注册中心找renren-fast服务,找到后拼接成地址为http://renren-fast/api/captcha.jpg?uuid=...,此时我们再用网关的过滤器网关重写功能去掉/api

gateway:
  routes:
    - id: admin_route
      # lb:负载均衡
      uri: lb://renren-fast
      # 按照路径来断言,任意请求先到renren-fast
      predicates:
        - Path=/api/**
      filters:
        - RewritePath=/api/(?<segment>.*),/renren-fast/${segment}

验证成功.png 访问成功.

解决跨域问题

跨域.png

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

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

跨域策略.png 跨域流程

跨域流程.png

两种解决办法
  • 使用nginx部署为同一域

nginx进行反向代理时,对外暴露的时同一个域,不会产生跨域问题.

nginx.png

  • 配置当次请求允许跨域

修改预先请求Option,让真正请求发过来

1、添加响应头

Access-Control-Allow-Origin:支持哪些来源的请求跨域

Access-Control-Allow-Methods:支持哪些方法跨域

Access-Control-Allow-Credentials:跨域请求默认不包含cookie,设置为true可以包含cookie

Access-Control-Expose-Headers:跨域请求暴露的字段

CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。

Access-Control-Max-Age:表明该响应的有效时间为多少秒。在有效时间内,浏览器无 须为同一请求再次发起预检请求。请注意,浏览器自身维护了一个最大有效时间,如果该首部字段的值超过了最大有效时间,将不会生效。

在网关里面统一解决跨域

在网关模块中编写配置类使用Spring自带的CorsWebFilter

package com.hong.gulimall.gateway.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;

@Configuration
public class GulimallCorsConfiguration {

    @Bean // 添加过滤器
    public CorsWebFilter corsWebFilter(){
        // 基于url跨域,选择reactive包下的
        UrlBasedCorsConfigurationSource source=new UrlBasedCorsConfigurationSource();
        // 跨域配置信息
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        // 允许跨域的头
        corsConfiguration.addAllowedHeader("*");
        // 允许跨域的请求方式
        corsConfiguration.addAllowedMethod("*");
        // 允许跨域的请求来源
        corsConfiguration.addAllowedOrigin("*");
//        corsConfiguration.addAllowedOriginPattern("*");
        // 是否允许携带cookie跨域
        corsConfiguration.setAllowCredentials(true);

        // 任意url都要进行跨域配置
        source.registerCorsConfiguration("/**",corsConfiguration);
        return new CorsWebFilter(source);
    }}

cors.png

若出现如上错误,只需要将renren-fast中自带的跨域配置文件注释即可。

编写product路由

路由顺序.png 防止出现如上错误,我们需要把精确的路由放在上面

gateway:
  routes:
    - id: product_route
      # lb:负载均衡
      uri: lb://gulimall-product
      # 按照路径来断言,任意请求先到renren-fast
      predicates:
        - Path=/api/product/**
      filters:
        - RewritePath=/api/(?<segment>.*),/${segment}

data.png 获取到数据,我们只用到data,所以解构出来{data}

逻辑删除

mp配置

mybatis-plus:
  global-config:
    db-config:
      id-type: auto
      logic-delete-value: 1
      logic-not-delete-value: 0

低版本可能要配置全局逻辑删除规则和组件Bean

加注解@TableLogic

/**
 * 是否显示[0-不显示,1显示]
 当前项目逻辑反了,加参数即可
 */
@TableLogic(value = "1",delval = "0")
private Integer showStatus;

分类增删改和拖曳

增删改后端逆向工程都已经自己生成,这里只需要处理前端了。使用element ui引入相关组件

 <div>
    <el-switch v-model="draggable" active-text="开启拖拽" inactive-text="关闭拖拽"></el-switch>
    <el-button v-if="draggable" @click="batchSave">批量保存</el-button>
    <el-button type="danger" @click="batchDelete">批量删除</el-button>
     <el-tree
      :data="menus"
      show-checkbox
      :props="defaultProps"
      node-key="id"
      :default-expanded-keys="expandedKey"
      :draggable="draggable"
      :allow-drop="allowDrop"
      @node-drop="handleDrop"
      :expand-on-click-node="false">
      <span class="custom-tree-node" slot-scope="{ node, data }">
        <span>{{ node.label }}</span>
        <span>
          <!--只有当前节点层级不为3显示-->
          <el-button v-if="data.catLevel <= 2" type="text" size="mini" @click="() => append(data)">
            Append
          </el-button>
          <el-button type="text" size="mini" @click="edit(data)">edit</el-button>
          <!--没有子节点显示-->
          <el-button v-if="data.children.length == 0" type="text" size="mini" @click="() => remove(node, data)">
            Delete
          </el-button>
        </span>
      </span>
    </el-tree>

     <el-dialog
      :title="title"
      :visible.sync="dialogVisible"
      width="30%"
      :close-on-click-modal="false"
    >
      <el-form :model="category">
        <el-form-item label="分类名称">
          <el-input v-model="category.name" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="图标">
          <el-input v-model="category.icon" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="计量单位">
          <el-input v-model="category.productUnit" autocomplete="off"></el-input>
        </el-form-item>
      </el-form>
      <span slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" @click="submitData">确 定</el-button>
      </span>
    </el-dialog>
</div>    

这里一个对话框既要处理编辑业务,又要处理添加业务,我们需要维护一个dialogType变量来判断是add还是edit,从而选取对应的处理。

data(){
    return {
        title: "",
        dialogType: "", //edit,add
        category: {
          name: "",
          parentCid: 0,
          catLevel: 0,
          showStatus: 1,
          sort: 0,
          productUnit: "",
          icon: "",
          // 必须置空
          catId: null
        },
        dialogVisible: false,
        expandedKey: [],
        menus: [],
        defaultProps: {
          children: 'children',
          label: 'name'
        }
    }
}
methods:{
 edit(data) {
        console.log("要修改的数据", data);
        this.dialogType = "edit";
        this.title = "修改分类";
        this.dialogVisible = true;

        //发送请求获取当前节点最新的数据
        this.$http({
          url: this.$http.adornUrl(`/product/category/info/${data.catId}`),
          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.category.catLevel = data.data.catLevel;
          this.category.sort = data.data.sort;
          this.category.showStatus = data.data.showStatus;
          /**
           *         parentCid: 0,
          catLevel: 0,
          showStatus: 1,
          sort: 0,
          */
        });
      },
      append(data) {
        console.log("append", data);
        this.dialogType = "add";
        this.title = "添加分类";
        this.dialogVisible = true;
        this.category.parentCid = data.catId;
        // 防止字符串
        this.category.catLevel = data.catLevel * 1 + 1;
        // id自增
        this.category.catId = null;
        this.category.name = "";
        this.category.icon = "";
        this.category.productUnit = "";
        this.category.sort = 0;
        this.category.showStatus = 1;
      }, 
      submitData() {
        if (this.dialogType == "add") {
          this.addCategory();
        }
        if (this.dialogType == "edit") {
          this.editCategory();
        }
      },
      //修改三级分类数据
      editCategory() {
        // 由于部分没有发出,没有回显,不发,动态更新
        var { catId, name, icon, productUnit } = this.category;
        this.$http({
          url: this.$http.adornUrl("/product/category/update"),
          method: "post",
          data: this.$http.adornData({ catId, name, icon, productUnit }, false)
        }).then(({ data }) => {
          this.$message({
            message: "菜单修改成功",
            type: "success"
          });
          //关闭对话框
          this.dialogVisible = false;
          //刷新出新的菜单
          this.getMenus();
          //设置需要默认展开的菜单
          this.expandedKey = [this.category.parentCid];
        });
      },
      //添加三级分类
      addCategory() {
        console.log("提交的三级分类数据", this.category);
        this.$http({
          url: this.$http.adornUrl("/product/category/save"),
          method: "post",
          data: this.$http.adornData(this.category, false)
        }).then(({ data }) => {
          this.$message({
            message: "菜单保存成功",
            type: "success"
          });
          //关闭对话框
          this.dialogVisible = false;
          //刷新出新的菜单
          this.getMenus();
          //设置需要默认展开的菜单
          this.expandedKey = [this.category.parentCid];
        });
      },
      remove(node, data) {
        console.log("node",node)
        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"
            });
            console.log("删除成功",data)
            // 刷新菜单
            this.getMenus();
            // 设置展开的位置
            this.expandedKey = [node.parent.data.catId]
          });
        }).catch(() => {

        });
      }
    }
  }

八、商品品牌管理实现

新建菜单,直接导入逆向工程生成的相关vue文件.

实现文件上传服务器

文件存储

文件的几种上传方式

oss.png 这里我们在项目中使用阿里云的云对象存储oss

阿里云对象存储-普通上传方式

oss1.png

阿里云对象存储-服务端签名后直传

Post Policy的使用规则在服务端通过各种语言代码完成签名,然后通过表单直传数据到OSS。由于服务端签名直传无需将AccessKey暴露在前端页面,相比JavaScript客户端签名直传具有更高的安全性

oss2.png

开通阿里云oss服务,下面我们来测试上传一个照片到oss服务器

导入依赖

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

编写测试代码。

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

    // 上传文件流。
    InputStream inputStream = new FileInputStream("C:\Users\鸿\Desktop\Guli Mall\分布式基础\资源\pics\2b1837c6c50add30.jpg");

    // hongmall 实例对象 ,上传的名字
    ossClient.putObject("hongmall", "1.png", inputStream);

    // 关闭OSSClient。
    ossClient.shutdown();

    System.out.println("上传成功...");
}

上传.png

SpringCloud整合oss

我们可以直接引入,阿里整合好的组件依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-alicloud-oss</artifactId>
    <version>0.2.2.RELEASE</version>
</dependency>

只需要在配置文件中配置相关配置并直接注入oss对象即可.

spring:
  cloud:
    alicloud:
      access-key: Jp2ZeSRhUxxCZBqHB
      secret-key: cZ2VmMmrR
      oss:
        endpoint: oss-cn-beijing.aliyuncs.com 
@Autowired
OSSClient ossClient;

编写第三方模块gulimall-third-party

我们可以专门编写一个模块把第三方依赖聚合起来。 pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.hong.gulimall</groupId>
    <artifactId>gulimall-third-party</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>gulimall-third-party</name>
    <description>第三方服务</description>

    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.1.8.RELEASE</spring-boot.version>
        <spring-cloud.version>Greenwich.SR3</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>com.hong.gulimall</groupId>
            <artifactId>gulimall-common</artifactId>
            <version>0.0.1-SNAPSHOT</version>
            <exclusions>
                <exclusion>
                    <groupId>com.baomidou</groupId>
                    <artifactId>mybatis-plus-boot-starter</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-alicloud-oss</artifactId>
            <version>0.2.2.RELEASE</version>
        </dependency>

<!--        <dependency>-->
<!--            <groupId>com.aliyun.oss</groupId>-->
<!--            <artifactId>aliyun-sdk-oss</artifactId>-->
<!--        </dependency>-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>2.1.0.RELEASE</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

编写controller层,服务端签名后直传业务代码

package com.hong.gulimall.thirdparty.controller;

import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.common.utils.BinaryUtil;
import com.aliyun.oss.model.MatchMode;
import com.aliyun.oss.model.PolicyConditions;
import com.hong.common.utils.R;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;

@RestController
public class OssController {

//    @Autowired
//    OSS ossClient;

    @RequestMapping("/oss/policy")
    public R policy() {
        // https://hongmall.oss-cn-beijing.aliyuncs.com/1.png
        // Endpoint以杭州为例,其它Region请按实际情况填写。
        String endpoint = "oss-cn-beijing.aliyuncs.com";
        // 安全问题,并不会账号密码,开通子用户
        // 云账号AccessKey有所有API访问权限,建议遵循阿里云安全最佳实践,创建并使用RAM子账号进行API访问或日常运维,请登录 https://ram.console.aliyun.com 创建。
        String accessKeyId = "LTAI5tBJp2ZeSRhUxxCZBqHB";
        String accessKeySecret = "C69ep2mbVIhKMDJT7bdrYcZ2VmMmrR";

        String bucket = "hongmall"; // 请填写您的 bucketname 。
        String host = "https://" + bucket + "." + endpoint; // host的格式为 bucketname.endpoint
        // callbackUrl为 上传回调服务器的URL,请将下面的IP和Port配置为您自己的真实信息。
        //String callbackUrl = "http://88.88.88.88:8888";

        String format = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
        String dir = format + "/"; // 用户上传文件时指定的前缀。

        // 创建OSSClient实例。
        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
        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", accessKeyId);
            respMap.put("policy", encodedPolicy);
            respMap.put("signature", postSignature);
            respMap.put("dir", dir);
            respMap.put("host", host);
            respMap.put("expire", String.valueOf(expireEndTime / 1000));

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

}

启动测试

oss30000.png 网关配置

- id: product_route
  # lb:负载均衡
  uri: lb://gulimall-third-party
  # 按照路径来断言,任意请求先到renren-fast
  predicates:
    - Path=/api/thirdparty/**
  filters:
    - RewritePath=/api/thirdparty/(?<segment>.*),/${segment}

编写品牌logo图片上传业务

将服务器文件上传方法封装到policy.js文件中
import http from '@/utils/httpRequest.js'
export function policy() {
   return  new Promise((resolve,reject)=>{
        http({
            url: http.adornUrl("/thirdparty/oss/policy"),
            method: "get",
            params: http.adornParams({})
        }).then(({ data }) => {
            resolve(data);
        })
    });
}
编写单文件上传和多文件上传组件

singeUpload.vue

<template> 
  <div>
    <el-upload
      action="https://hongmall.oss-cn-beijing.aliyuncs.com"
      :data="dataObj"
      list-type="picture"
      :multiple="false" :show-file-list="showFileList"
      :file-list="fileList"
      :before-upload="beforeUpload"
      :on-remove="handleRemove"
      :on-success="handleUploadSuccess"
      :on-preview="handlePreview">
      <el-button size="small" type="primary">点击上传</el-button>
      <div slot="tip" class="el-upload__tip">只能上传jpg/png文件,且不超过10MB</div>
    </el-upload>
    <el-dialog :visible.sync="dialogVisible">
      <img width="100%" :src="fileList[0].url" alt="">
    </el-dialog>
  </div>
</template>
<script>
   import {policy} from './policy'
   import { getUUID } from '@/utils'

  export default {
    name: 'singleUpload',
    props: {
      value: String
    },
    computed: {
      imageUrl() {
        return this.value;
      },
      imageName() {
        if (this.value != null && this.value !== '') {
          return this.value.substr(this.value.lastIndexOf("/") + 1);
        } else {
          return null;
        }
      },
      fileList() {
        return [{
          name: this.imageName,
          url: this.imageUrl
        }]
      },
      showFileList: {
        get: function () {
          return this.value !== null && this.value !== ''&& this.value!==undefined;
        },
        set: function (newValue) {
        }
      }
    },
    data() {
      return {
        dataObj: {
          policy: '',
          signature: '',
          key: '',
          ossaccessKeyId: '',
          dir: '',
          host: '',
          // callback:'',
        },
        dialogVisible: false
      };
    },
    methods: {
      emitInput(val) {
        this.$emit('input', val)
      },
      handleRemove(file, fileList) {
        this.emitInput('');
      },
      handlePreview(file) {
        this.dialogVisible = true;
      },
      beforeUpload(file) {
        let _self = this;
        return new Promise((resolve, reject) => {
          policy().then(response => {
            console.log("响应的数据",response);
            _self.dataObj.policy = response.data.policy;
            _self.dataObj.signature = response.data.signature;
            _self.dataObj.ossaccessKeyId = response.data.accessid;
            // uuid 防止路径重复
            _self.dataObj.key = response.data.dir +getUUID()+'_${filename}';
            _self.dataObj.dir = response.data.dir;
            _self.dataObj.host = response.data.host;
            console.log("响应的数据222",_self.dataObj);
            resolve(true)
          }).catch(err => {
            reject(false)
          })
        })
      },
      handleUploadSuccess(res, file) {
        console.log("上传成功...")
        this.showFileList = true;
        this.fileList.pop();
        this.fileList.push({name: file.name, url: this.dataObj.host + '/' + this.dataObj.key.replace("${filename}",file.name) });
        this.emitInput(this.fileList[0].url);
      }
    }
  }
</script>
<style>

</style>

上传文件测试 osscor.png 由于前端是拿到response响应信息直接向oss服务器发送post请求,这时候就会出现跨越问题。只需要修改一下即可

oss跨域.png

测试品牌上传

这里由于switch组件的开关是truefalse,数据库中为10,需要加上:active-value="1" :inactive-value="0"属性。

将表格图片内容进行自定义显示

  <el-table-column
        prop="logo"
        header-align="center"
        align="center"
        label="品牌logo地址">
        <template slot-scope="scope">
          <img :src="scope.row.logo" style="width:100px;height:8px">
        </template>
      </el-table-column>

校验功能

前端自定义校验器

   dataRule: {
          name: [
            { required: true, message: '品牌名不能为空', trigger: 'blur' }
          ],
          logo: [
            { required: true, message: '品牌logo地址不能为空', trigger: 'blur' }
          ],
          descript: [
            { required: true, message: '介绍不能为空', trigger: 'blur' }
          ],
          showStatus: [
            { required: true, message: '显示状态[0-不显示;1-显示]不能为空', trigger: 'blur' }
          ],
          firstLetter: [
          {
            validator: (rule, value, callback) => {
              if (value == "") {
                callback(new Error("首字母必须填写"));
              } else if (!/^[a-zA-Z]$/.test(value)) {
                callback(new Error("首字母必须a-z或者A-Z之间"));
              } else {
                callback();
              }
            },
            trigger: "blur"
          }
        ],
            sort: [
          {
            validator: (rule, value, callback) => {
              if (value == "") {
                callback(new Error("排序字段必须填写"));
              } else if (!Number.isInteger(value) || value<0) {
                callback(new Error("排序必须是一个大于等于0的整数"));
              } else {
                callback();
              }
            },
            trigger: "blur"
          }
        ]
        }
      }

后端使用JSR303校验

  • Bean添加校验注解
  • 开启校验功能@Valid,检验错误后有响应的响应,也可以自定义。
  • 校验Bean的后面紧跟一个BindingResult可以获取校验的结果。
/**
 * 保存
 */
@RequestMapping("/save")
// 告诉springmvc校验注解
public R save(@Valid @RequestBody BrandEntity brand, BindingResult result){
    if (result.hasErrors()){
        Map<String, String> map = new HashMap<>();
        // 获取校验结果
        result.getFieldErrors().forEach((fieldError) -> {
            String message = fieldError.getDefaultMessage();
            // 获取错误属性名字
            String field = fieldError.getField();
            map.put(field,message);
        });
        return R.error().put("400","提交数据不合法").put("data",map);
    }else {
        brandService.save(brand);
    }
    return R.ok();
}

测试

校验400.png

统一异常处理

可以使用springmvc@ControllerAdvice,这里只要发现异常就可以自动捕获,前面代码controller代码功能也将被取代。

package com.hong.gulimall.product.exception;

import com.hong.common.exception.BizCodeEnum;
import com.hong.common.utils.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

/**
 * 集中处理异常
 */
@Slf4j
@RestControllerAdvice(basePackages = "com.xunqi.gulimall.product.controller")
public class GulimallExceptionControllerAdvice {

    /**
     * 参数非法(效验参数)异常 MethodArgumentNotValidException
     * @param e
     * @return
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public R handleValidException(MethodArgumentNotValidException e) {
        log.error("数据效验出现问题{},异常类型{}",e.getMessage(),e.getClass());
        BindingResult bindingResult = e.getBindingResult();

        Map<String,String> errMap = new HashMap<>();
        bindingResult.getFieldErrors().forEach((fieldError) -> {
            errMap.put(fieldError.getField(),fieldError.getDefaultMessage());
        });
        return R.error(BizCodeEnum.VAILD_EXCEPTION.getCode(),BizCodeEnum.VAILD_EXCEPTION.getMessage())
                .put("data",errMap);
    }


    @ExceptionHandler(value = Throwable.class)
    public R handleException(Throwable throwable) {

        log.error("错误异常{}",throwable);

        return R.error(BizCodeEnum.UNKNOW_EXCEPTION.getCode(),BizCodeEnum.UNKNOW_EXCEPTION.getMessage());
    }

}

common模块新建异常枚举类

package com.hong.common.exception;

/**
 错误码和错误信息定义类
* 1. 错误码定义规则为5为数字
* 2. 前两位表示业务场景,最后三位表示错误码。例如:100001。10:通用 001:系统未知异常
* 3. 维护错误码后需要维护错误描述,将他们定义为枚举形式
* 错误码列表:
*  10: 通用
*      001:参数格式校验
*      002:短信验证码频率太高
*  11: 商品
*  12: 订单
*  13: 购物车
*  14: 物流
*  15:用户
**/

public enum BizCodeEnum {

    UNKNOW_EXCEPTION(10000,"系统未知异常"),
    VAILD_EXCEPTION(10001,"参数格式校验失败"),
    TO_MANY_REQUEST(10002,"请求流量过大,请稍后再试"),
    SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,请稍后再试"),
    PRODUCT_UP_EXCEPTION(11000,"商品上架异常"),
    USER_EXIST_EXCEPTION(15001,"存在相同的用户"),
    PHONE_EXIST_EXCEPTION(15002,"存在相同的手机号"),
    NO_STOCK_EXCEPTION(21000,"商品库存不足"),
    LOGINACCT_PASSWORD_EXCEPTION(15003,"账号或密码错误"),
    ;

    private Integer code;

    private String message;

    BizCodeEnum(Integer code, String message) {
        this.code = code;
        this.message = message;
    }

    public Integer getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}

JSR303分组校验

比如一些字段修改时并不用处理,增加时才需要全部处理,这里我们就可以用JSR303分组校验功能

package com.hong.gulimall.product.entity;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;

import java.io.Serializable;
import lombok.Data;
import org.hibernate.validator.constraints.URL;

import javax.validation.constraints.*;

/**
 * 品牌
 * 
 * @author hong
 * @email 1365797108@qq.com
 * @date 2022-04-16 11:44:00
 */
@Data
@TableName("pms_brand")
public class BrandEntity implements Serializable {
   private static final long serialVersionUID = 1L;

   /**
    * 品牌id
    */
   @NotNull(message = "修改必须指定品牌id",groups = {UpdateGroup.class})
   @Null(message = "新增不能指定id",groups = {AddGroup.class})
   @TableId
   private Long brandId;
   /**
    * 品牌名
    */
   @NotBlank(message = "品牌名必须提交",groups = {AddGroup.class,UpdateGroup.class})
   private String name;
   /**
    * 品牌logo地址
    */
   @NotBlank(groups = {AddGroup.class})
   @URL(message = "logo必须是一个合法的url地址",groups={AddGroup.class,UpdateGroup.class})
   private String logo;
   /**
    * 介绍
    */
   private String descript;
   /**
    * 显示状态[0-不显示;1-显示]
    */
// @Pattern()
   @NotNull(groups = {AddGroup.class, UpdateStatusGroup.class})
   @ListValue(vals={0,1},groups = {AddGroup.class, UpdateStatusGroup.class})
   private Integer showStatus;
   /**
    * 检索首字母
    */
   @NotEmpty(groups={AddGroup.class})
   @Pattern(regexp="^[a-zA-Z]$",message = "检索首字母必须是一个字母",groups={AddGroup.class,UpdateGroup.class})
   private String firstLetter;
   /**
    * 排序
    */
   @NotNull(groups={AddGroup.class})
   @Min(value = 0,message = "排序必须大于等于0",groups={AddGroup.class,UpdateGroup.class})
   private Integer sort;
}

给校验注解标注什么情况需要进行校验,默认没有指定分组的校验注解@NotBlank,在分组校验情况下不生效只会在@Valid生效。

自定义校验

  • 编写一个自定义的校验注解。
  • 编写一个自定义的校验器。
  • 关联自定义的校验器和自定义的校验注解。
package com.hong.common.valid;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

/**
 * @Description: 自定义注解规则
 **/

@Documented
@Constraint(validatedBy = { ListValueConstraintValidator.class })
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
public @interface ListValue {

    String message() default "{com.xunqi.common.valid.ListValue.message}";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };

    int[] vals() default { };

}
package com.hong.common.valid;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.HashSet;
import java.util.Set;

public class ListValueConstraintValidator implements ConstraintValidator<ListValue,Integer> {

    private Set<Integer> set = new HashSet<>();

    /**
     * 初始化方法
     * @param constraintAnnotation
     */
    @Override
    public void initialize(ListValue constraintAnnotation) {

        int[] vals = constraintAnnotation.vals();

        for (int val : vals) {
            set.add(val);
        }
    }

    /**
     * 判断是否效验成功
     * @param value 需要效验的值
     * @param context
     * @return
     */
    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext context) {
        //判断是否有包含的值
        boolean contains = set.contains(value);
        return contains;
    }
}

九、属性分组-规格参数-销售属性

属性分组-规格参数-销售属性-三级分类关联关系。

在写属性相关业务之前,我们先了解一下数据库设计。

  • 每个三级分类对应一个属性分组
  • 一个属性分组里又有多个属性
  • 分类既与属性分组有对应关系,又和属性有对应关系。

比如一个手机的分为主体和屏幕两个属性分组,而屏幕有分为内存、像素两个属性。 属性分组.png

SPU-SKU-属性

一、SPU全称Standard Product Unit (标准化产品单元)。译为:最小包装单元;

SPU可以直接认为是很多个产品打包组成的一个新物品,有更多的新特性和更多的形态。

二、SKU全称stock keeping unit(库存量单位)。译为:最小主要单元;

SKU不同于SPU,它可以认为就是一个很简单的物品。而这些个简单的物品打包组合就是SPU,比如,现在有5个iPhone(SKU),如果5个为一个生产最小单位,那么这5个iPhone就是组合打包产品(SPU)。又比如一款iPhone有很多种配置可选,每种配置就是SKU,但最终都还是一个iPhone(SPU).

我们看下该项目中有关SKUSPU相关表

sku.png 由图可看出一个spu可以对应多个sku,又对应多个属性。
比如:id为 1的spu的分别对应一个内存和容量6+128G4+64G这个两个sku.

分页条件检索查找每个分类下所有属性分组

/**
 * 查找每个分类下所有属性分组
 */
@RequestMapping("/list/{catelogId}")
// @RequestParam:将请求参数绑定到你控制器的方法参数上(是springmvc中接收普通参数的注解)
public R list(@RequestParam Map<String, Object> params,
              @PathVariable("catelogId") Long catelogId){

    PageUtils page = attrGroupService.queryPage(params,catelogId);

    return R.ok().put("page", page);
}

MyBatis-Plus已经将分页查询通过IPage接口封装起来,里面包含很多分页属性。

fenye.png MP自定义了一个查询参数Query类,将IPage的实现类Page封装在里面,同时初始化一些分页所需的变量。

package com.hong.common.utils;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hong.common.xss.SQLFilter;
import org.apache.commons.lang.StringUtils;

import java.util.Map;

/**
 * 查询参数
 */
public class Query<T> {

    public IPage<T> getPage(Map<String, Object> params) {
        return this.getPage(params, null, false);
    }

    public IPage<T> getPage(Map<String, Object> params, String defaultOrderField, boolean isAsc) {
        //分页参数
        long curPage = 1;
        long limit = 10;

        if(params.get(Constant.PAGE) != null){
            curPage = Long.parseLong((String)params.get(Constant.PAGE));
        }
        if(params.get(Constant.LIMIT) != null){
            limit = Long.parseLong((String)params.get(Constant.LIMIT));
        }

        //分页对象
        Page<T> page = new Page<>(curPage, limit);

        //分页参数
        params.put(Constant.PAGE, page);

        //排序字段
        //防止SQL注入(因为sidx、order是通过拼接SQL实现排序的,会有SQL注入风险)
        String orderField = SQLFilter.sqlInject((String)params.get(Constant.ORDER_FIELD));
        String order = (String)params.get(Constant.ORDER);


        //前端字段排序
        if(StringUtils.isNotEmpty(orderField) && StringUtils.isNotEmpty(order)){
            if(Constant.ASC.equalsIgnoreCase(order)) {
                return  page.addOrder(OrderItem.asc(orderField));
            }else {
                return page.addOrder(OrderItem.desc(orderField));
            }
        }

        //没有排序字段,则不排序
        if(StringUtils.isBlank(defaultOrderField)){
            return page;
        }

        //默认排序
        if(isAsc) {
            page.addOrder(OrderItem.asc(defaultOrderField));
        }else {
            page.addOrder(OrderItem.desc(defaultOrderField));
        }

        return page;
    }
}

同时自定义了封装分页数据返回类,聚合一个Page类。

package com.hong.common.utils;

import com.baomidou.mybatisplus.core.metadata.IPage;

import java.io.Serializable;
import java.util.List;

/**
 * 分页工具类
 *
 * @author Mark sunlightcs@gmail.com
 */
public class PageUtils implements Serializable {
   private static final long serialVersionUID = 1L;
   /**
    * 总记录数
    */
   private int totalCount;
   /**
    * 每页记录数
    */
   private int pageSize;
   /**
    * 总页数
    */
   private int totalPage;
   /**
    * 当前页数
    */
   private int currPage;
   /**
    * 列表数据
    */
   private List<?> list;
   
   /**
    * 分页
    * @param list        列表数据
    * @param totalCount  总记录数
    * @param pageSize    每页记录数
    * @param currPage    当前页数
    */
   public PageUtils(List<?> list, int totalCount, int pageSize, int currPage) {
      this.list = list;
      this.totalCount = totalCount;
      this.pageSize = pageSize;
      this.currPage = currPage;
      this.totalPage = (int)Math.ceil((double)totalCount/pageSize);
   }

   /**
    * 分页
    */
   public PageUtils(IPage<?> page) {
      this.list = page.getRecords();
      this.totalCount = (int)page.getTotal();
      this.pageSize = (int)page.getSize();
      this.currPage = (int)page.getCurrent();
      this.totalPage = (int)page.getPages();
   }

   public int getTotalCount() {
      return totalCount;
   }

   public void setTotalCount(int totalCount) {
      this.totalCount = totalCount;
   }

   public int getPageSize() {
      return pageSize;
   }

   public void setPageSize(int pageSize) {
      this.pageSize = pageSize;
   }

   public int getTotalPage() {
      return totalPage;
   }

   public void setTotalPage(int totalPage) {
      this.totalPage = totalPage;
   }

   public int getCurrPage() {
      return currPage;
   }

   public void setCurrPage(int currPage) {
      this.currPage = currPage;
   }

   public List<?> getList() {
      return list;
   }

   public void setList(List<?> list) {
      this.list = list;
   }
   
}

业务实现类AttrGroupServiceImpl

@Override
public PageUtils queryPage(Map<String, Object> params, Long catelogId) {
    // catelogId == 0查询所有
    if (catelogId == 0){
        IPage<AttrGroupEntity> page = this.page(new Query<AttrGroupEntity>().getPage(params),
                new QueryWrapper<AttrGroupEntity>());
        return new PageUtils(page);
    }else {
        String key = (String) params.get("key");
        // 按照三级分类
        // select * from pms_attr_group where catelog_id = ? and (sttr_group_id=key or attr_group_name like key)
        QueryWrapper<AttrGroupEntity> wrapper = new QueryWrapper<AttrGroupEntity>().eq("catelog_id", catelogId);
        if (!StringUtils.isEmpty(key)){
            // 条件检索
            wrapper.and((obj) -> {
                obj.eq("attr_group_id",key).or().like("attr_group_name",key);
            });
        }
        IPage<AttrGroupEntity> page = this.page(new Query<AttrGroupEntity>().getPage(params),
                wrapper);
        return new PageUtils(page);
    }
}

编写前端业务

抽取出category公共组件
<template>
  <div>
    <el-input placeholder="输入关键字进行过滤" v-model="filterText"></el-input>
    <el-tree
      :data="menus"
      :props="defaultProps"
      node-key="catId"
      ref="menuTree"
      @node-click="nodeclick"
      :filter-node-method="filterNode"
      :highlight-current = "true"
    ></el-tree>
  </div>
</template>

<script>
export default {
  //import引入的组件需要注入到对象中才能使用
  components: {},
  props: {},
  data() {
    //这里存放数据
    return {
      filterText: "",
      menus: [],
      expandedKey: [],
      defaultProps: {
        children: "children",
        label: "name"
      }
    };
  },
  //计算属性 类似于data概念
  computed: {},
  //监控data中的数据变化
  watch: {
    filterText(val) {
      this.$refs.menuTree.filter(val);
    }
  },
  //方法集合
  methods: {
    //树节点过滤
    filterNode(value, data) {
      if (!value) return true;
      return data.name.indexOf(value) !== -1;
    },
    getMenus() {
      this.$http({
        url: this.$http.adornUrl("/product/category/list/tree"),
        method: "get"
      }).then(({ data }) => {
        this.menus = data.page;
      });
    },
    nodeclick(data, node, component) {
      console.log("子组件category的节点被点击", data, node, component);
      //向父组件发送事件;
      this.$emit("tree-node-click", data, node, component);
    }
  },
  //生命周期 - 创建完成(可以访问当前this实例)
  created() {
    this.getMenus();
  }
</script>
父子组件通信

要求只有点击第三级分类时才查找数据并显示在右边表格,当子组件category.vue的三级分类菜单被点击时,在父组件sttrgroup.vue中感知子组件被点击同时触发相应方法。

  • 在父组件模板中,为子组件绑定自定义事件
 <category @tree-node-click="treenodeclick"></category>
  • 在子组件中 通过$emit发布消息,传递子组件中数据
 nodeclick(data, node, component) {
      console.log("子组件category的节点被点击", data, node, component);
      //向父组件发送事件;
      this.$emit("tree-node-click", data, node, component);
    }
  • 在父组件事件回调函数中接收数据并存储数据
/**
 * 父子组件传递数据
 * 1)、子组件给父组件传递数据,事件机制;
 *    子组件给父组件发送一个事件,携带上数据。
 * // this.$emit("事件名",携带的数据...)
 */

import Category from "../common/category";
import AddOrUpdate from "./attrgroup-add-or-update";
import RelationUpdate from "./attr-group-relation";
export default {
  //import引入的组件需要注入到对象中才能使用
  components: { Category, AddOrUpdate, RelationUpdate }
 //感知树节点被点击
    treenodeclick(data, node, component) {
      console.log("attrgroup感知到category的节点被点击",data,node,component);
      console.log("刚才被点击的菜单id:"+data.id);
      if (node.level == 3) {
        this.catId = data.catId;
        this.getDataList(); //重新查询
      }
    },
    getAllDataList(){
      this.catId = 0;
      this.getDataList();
    },
    // 获取数据列表
    getDataList() {
      this.dataListLoading = true;
      this.$http({
        url: this.$http.adornUrl(`/product/attrgroup/list/${this.catId}`),
        method: "get",
        params: this.$http.adornParams({
          page: this.pageIndex,
          limit: this.pageSize,
          key: this.dataForm.key
        })
      }).then(({ data }) => {
        if (data && data.code === 0) {
          this.dataList = data.page.list;
          this.totalPage = data.page.totalCount;
        } else {
          this.dataList = [];
          this.totalPage = 0;
        }
        this.dataListLoading = false;
      });
    }

前端

<template>
  <el-row :gutter="20">
    <el-col :span="6">
      <category @tree-node-click="treenodeclick"></category>
    </el-col>
    <el-col :span="18">
      <div class="mod-config">
        <el-form :inline="true" :model="dataForm" @keyup.enter.native="getDataList()">
          <el-form-item>
            <el-input v-model="dataForm.key" placeholder="参数名" clearable></el-input>
          </el-form-item>
          <el-form-item>
            <el-button @click="getDataList()">查询</el-button>
            <el-button type="success" @click="getAllDataList()">查询全部</el-button>
            <el-button
              v-if="isAuth('product:attrgroup:save')"
              type="primary"
              @click="addOrUpdateHandle()"
            >新增</el-button>
            <el-button
              v-if="isAuth('product:attrgroup:delete')"
              type="danger"
              @click="deleteHandle()"
              :disabled="dataListSelections.length <= 0"
            >批量删除</el-button>
          </el-form-item>
        </el-form>
        <el-table
          :data="dataList"
          border
          v-loading="dataListLoading"
          @selection-change="selectionChangeHandle"
          style="width: 100%;"
        >
          <el-table-column type="selection" header-align="center" align="center" width="50"></el-table-column>
          <el-table-column prop="attrGroupId" header-align="center" align="center" label="分组id"></el-table-column>
          <el-table-column prop="attrGroupName" header-align="center" align="center" label="组名"></el-table-column>
          <el-table-column prop="sort" header-align="center" align="center" label="排序"></el-table-column>
          <el-table-column prop="descript" header-align="center" align="center" label="描述"></el-table-column>
          <el-table-column prop="icon" header-align="center" align="center" label="组图标"></el-table-column>
          <el-table-column prop="catelogId" header-align="center" align="center" label="所属分类id"></el-table-column>
          <el-table-column
            fixed="right"
            header-align="center"
            align="center"
            width="150"
            label="操作"
          >
            <template slot-scope="scope">
              <el-button type="text" size="small" @click="relationHandle(scope.row.attrGroupId)">关联</el-button>
              <el-button
                type="text"
                size="small"
                @click="addOrUpdateHandle(scope.row.attrGroupId)"
              >修改</el-button>
              <el-button type="text" size="small" @click="deleteHandle(scope.row.attrGroupId)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>
        <el-pagination
          @size-change="sizeChangeHandle"
          @current-change="currentChangeHandle"
          :current-page="pageIndex"
          :page-sizes="[10, 20, 50, 100]"
          :page-size="pageSize"
          :total="totalPage"
          layout="total, sizes, prev, pager, next, jumper"
        ></el-pagination>
        <!-- 弹窗, 新增 / 修改 -->
        <add-or-update v-if="addOrUpdateVisible" ref="addOrUpdate" @refreshDataList="getDataList"></add-or-update>

        <!-- 修改关联关系 -->
        <relation-update v-if="relationVisible" ref="relationUpdate" @refreshData="getDataList"></relation-update>
      </div>
    </el-col>
  </el-row>
</template>

打开MP日志,测试结果

mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

测试2.png

添加属性分组时实现级联选择器显示分类

引入级联选择器组件

<el-dialog
    :title="!dataForm.id ? '新增' : '修改'"
    :close-on-click-modal="false"
    :visible.sync="visible"
    @closed="dialogClose"
  >
 <el-form-item label="所属分类" prop="catelogId">
        <!--filterable对话框可搜索-->
        <el-cascader filterable placeholder="试试搜索:手机" v-model="catelogPath" :options="categorys"  :props="props"></el-cascader>
      </el-form-item>
      
  props:{
        value:"catId",
        label:"name",
        children:"children"
  },
  categorys: [],
  catelogPath: []

@JsonInclude注解

json注解.png

json.png 由于查询数据时记录进了空数组,这里我们并不希望将空值也记录其中,只需要对该字段加上@JsonInclude注解。

// 指定不为空时返回才带上
@JsonInclude(JsonInclude.Include.NON_EMPTY)
// 数据库不存在,封装在该类
@TableField(exist = false)
private List<CategoryEntity> children;

修改属性分组

  <template slot-scope="scope">
              <el-button type="text" size="small" @click="relationHandle(scope.row.attrGroupId)">关联</el-button>
              <el-button
                type="text"
                size="small"
                @click="addOrUpdateHandle(scope.row.attrGroupId)"
              >修改</el-button>
              <el-button type="text" size="small" @click="deleteHandle(scope.row.attrGroupId)">删除</el-button>
            </template>

点击修改会弹出attrgroup-add-or-update组件,并调用子组件的init方法

<add-or-update v-if="addOrUpdateVisible" ref="addOrUpdate" @refreshDataList="getDataList"></add-or-update>
 // 新增 / 修改
    addOrUpdateHandle(id) {
      this.addOrUpdateVisible = true;
      this.$nextTick(() => {
        this.$refs.addOrUpdate.init(id);
      });
    }
 init(id) {
      this.dataForm.attrGroupId = id || 0;
      this.visible = true;
      this.$nextTick(() => {
        this.$refs["dataForm"].resetFields();
        // 如果有属性分组的值,那么就向服务器请求数据
        if (this.dataForm.attrGroupId) {
          this.$http({
              // 用id检索出详细信息
            url: this.$http.adornUrl(
              `/product/attrgroup/info/${this.dataForm.attrGroupId}`
            ),
            method: "get",
            params: this.$http.adornParams()
          }).then(({ data }) => {
              // 拿到服务端传来的所有数据来进行回显。
            if (data && data.code === 0) {
              this.dataForm.attrGroupName = data.attrGroup.attrGroupName;
              this.dataForm.sort = data.attrGroup.sort;
              this.dataForm.descript = data.attrGroup.descript;
              this.dataForm.icon = data.attrGroup.icon;
              this.dataForm.catelogId = data.attrGroup.catelogId;
              //查出catelogId的完整路径
              this.catelogPath =  data.attrGroup.catelogPath;
            }
          });
        }
      });
    }

这里发现分类并不会回显,分类应该为一整个三级菜单分类,而这里赋值的是一个三级菜单的分类id,我们还需要查出catelogId的完整路径,这里我们就希望后台传过来的数据能包含一个catelogPath完整路径,我们需要给后台AttrGroupEntity添加一个Long[]类型的完整路径catelogPath

package com.hong.gulimall.product.entity;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;

import java.io.Serializable;
import java.util.Date;
import lombok.Data;

/**
 * 属性分组
 * 
 * @author hong
 * @email 1365797108@qq.com
 * @date 2022-04-16 11:44:00
 */
@Data
@TableName("pms_attr_group")
public class AttrGroupEntity implements Serializable {
   private static final long serialVersionUID = 1L;

   /**
    * 分组id
    */
   @TableId
   private Long attrGroupId;
   /**
    * 组名
    */
   private String attrGroupName;
   /**
    * 排序
    */
   private Integer sort;
   /**
    * 描述
    */
   private String descript;
   /**
    * 组图标
    */
   private String icon;
   /**
    * 所属分类id
    */
   private Long catelogId;

   @TableField(exist = false)
   private Long[] catelogPath;
}

服务器业务

AttrGroupController

@Autowired
private CategoryService categoryService;
 /**
   * 查询属性信息
   */
  @RequestMapping("/info/{attrGroupId}")
  public R info(@PathVariable("attrGroupId") Long attrGroupId){
AttrGroupEntity attrGroup = attrGroupService.getById(attrGroupId);

      Long catelogId = attrGroup.getCatelogId();
      // 查找父类
      Long[] path = categoryService.findCatelogPath(catelogId);
      attrGroup.setCatelogPath(path);
      return R.ok().put("attrGroup", attrGroup);
  }

CategoryServiceImpl

 /**
     * 根据第三级分类查找完整路径
     * @param catelogId
     * @return
     */
    @Override
    public Long[] findCatelogPath(Long catelogId) {
        List<Long> paths = new ArrayList<>();
        List<Long> parentPath = findParentPath(catelogId, paths);
        return parentPath.toArray(new Long[parentPath.size()]);

    }

    private List<Long> findParentPath(Long catelogId, List<Long> paths) {
        // 1、收集当前节点
        paths.add(catelogId);
        CategoryEntity byId = this.getById(catelogId);
        if (byId.getParentCid() != 0){
            findParentPath(byId.getParentCid(),paths);
        }
        return paths;
    }

前端实现

这里子组件改变给父组件发事件刷新数值,

        <add-or-update v-if="addOrUpdateVisible" ref="addOrUpdate" @refreshDataList="getDataList"></add-or-update>

 // 表单提交
    dialogClose(){
    // 关闭清空
      this.catelogPath = [];
    },
    dataFormSubmit() {
      this.$refs["dataForm"].validate(valid => {
        if (valid) {
          this.$http({
            url: this.$http.adornUrl(
              `/product/attrgroup/${
                !this.dataForm.attrGroupId ? "save" : "update"
              }`
            ),
            method: "post",
            data: this.$http.adornData({
              attrGroupId: this.dataForm.attrGroupId || undefined,
              attrGroupName: this.dataForm.attrGroupName,
              sort: this.dataForm.sort,
              descript: this.dataForm.descript,
              icon: this.dataForm.icon,
              // 只要最后一个三级分类
              catelogId: this.catelogPath[this.catelogPath.length-1]
            })
          }).then(({ data }) => {
            if (data && data.code === 0) {
              this.$message({
                message: "操作成功",
                type: "success",
                duration: 1500,
                onClose: () => {
                  this.visible = false;
                  // 子组件给父组件发事件刷新数值
                  this.$emit("refreshDataList");
                }
              });
            } else {
              this.$message.error(data.msg);
            }
          });
        }
      });
    }
  },
  created(){
    this.getCategorys();
  }

修改关联关系

一个分类可以关联多个品牌。

获取品牌关联的分类

这里只需要传递一个brandId参数即可。 CategoryBrandRelationController

/**
 * 获取品牌关联的分类
 */
@GetMapping("/catelog/list")
public R cateloglist(@RequestParam("brandId") Long brandId){
    List<CategoryBrandRelationEntity> data = categoryBrandRelationService.list(
            new QueryWrapper<CategoryBrandRelationEntity>().eq("brand_id",brandId));
    return R.ok().put("data", data);
}

新增品牌和分类关联关系

关联.png 分别传入brandIdcatelogId两个参数。这里由于只需要回显分类名、品牌名,而如果每次都去数据库中查就会影响性能,电商项目一般不从数据库一个个查,所以我们建一个如下结构的数据库表。这样添加冗余字段只需要查询一次,但修改需要同步

关联表.png

  /**
   * 新增品牌和分类关联关系
   */
  @RequestMapping("/save")
  public R save(@RequestBody CategoryBrandRelationEntity categoryBrandRelation){
categoryBrandRelationService.saveDetail(categoryBrandRelation);

      return R.ok();
  }
@Override
public void saveDetail(CategoryBrandRelationEntity categoryBrandRelation) {
    Long brandId = categoryBrandRelation.getBrandId();
    Long catelogId = categoryBrandRelation.getCatelogId();
    // 1、查询详细名字
    BrandEntity brandEntity = brandDao.selectById(brandId);
    CategoryEntity categoryEntity = categoryDao.selectById(catelogId);

    categoryBrandRelation.setBrandName(brandEntity.getName());
    categoryBrandRelation.setCatelogName(categoryEntity.getName());

    this.save(categoryBrandRelation);
}

前端(新增品牌和分类关联关系)

这里我们把级联选择器抽取出来成一个组件。

<template>
<!-- 
使用说明:
1)、引入category-cascader.vue
2)、语法:<category-cascader :catelogPath.sync="catelogPath"></category-cascader>
    解释:
      catelogPath:指定的值是cascader初始化需要显示的值,应该和父组件的catelogPath绑定;
          由于有sync修饰符,所以cascader路径变化以后自动会修改父的catelogPath,这是结合子组件this.$emit("update:catelogPath",v);做的
      -->
  <div>
    <el-cascader
      filterable
      clearable 
      placeholder="试试搜索:手机"
      v-model="paths"
      :options="categorys"
      :props="setting"
    ></el-cascader>
  </div>
</template>

<script>
//这里可以导入其他文件(比如:组件,工具js,第三方插件js,json文件,图片文件等等)
//例如:import 《组件名称》 from '《组件路径》';

export default {
  //import引入的组件需要注入到对象中才能使用
  components: {},
  //接受父组件传来的值
  props: {
    catelogPath: {
      type: Array,
      default(){
        return [];
      }
    }
  },
  data() {
    //这里存放数据
    return {
      setting: {
        value: "catId",
        label: "name",
        children: "children"
      },
      categorys: [],
      paths: this.catelogPath
    };
  },
  watch:{
    catelogPath(v){
      this.paths = this.catelogPath;
    },
    paths(v){
      this.$emit("update:catelogPath",v);
      //还可以使用pubsub-js进行传值
      // this.PubSub.publish("catPath",v);
    }
  },
  //方法集合
  methods: {
    getCategorys() {
      this.$http({
        url: this.$http.adornUrl("/product/category/list/tree"),
        method: "get"
      }).then(({ data }) => {
        this.categorys = data.page;
      });
    }
  },
  //生命周期 - 创建完成(可以访问当前this实例)
  created() {
    this.getCategorys();
  }
};
</script>
<style scoped>
</style>

这样添加冗余字段只需要查询一次,但修改需要同步

修改更新同步业务

BrandController
/**
 * 更新(添加冗余字段只需要查询一次,但修改需要同步)
 */
@RequestMapping("/update")
public R update(@RequestBody BrandEntity brand){
    brandService.updateDetail(brand);
    return R.ok();
}
@Transactional(rollbackFor = Exception.class)
@Override
public void updateDetail(BrandEntity brand) {
    // 保证冗余字段的数据一致
    this.updateById(brand);
    if (!StringUtils.isEmpty(brand.getBrandId())){
        // 同步更新其他关联表中的数据
        categoryBrandRelationService.updateBrand(brand.getBrandId(),brand.getName());
        // TODO 更新其他关联
    }
}
CategoryBrandRelationServiceImpl

@Override
public void updateBrand(Long brandId, String name) {
    CategoryBrandRelationEntity categoryBrandRelationEntity = new CategoryBrandRelationEntity();
    categoryBrandRelationEntity.setBrandId(brandId);
    // 只带了哪个字段更新哪个字段
    categoryBrandRelationEntity.setBrandName(name);
    this.update(categoryBrandRelationEntity,
            new UpdateWrapper<CategoryBrandRelationEntity>().eq("brand_id",brandId));
}

修改更新同步业务级联分类

  /**
   * 修改(级联更新同步)
   */
  @RequestMapping("/update")
  public R update(@RequestBody CategoryEntity category){
categoryService.updateByCascade(category);
      return R.ok();
  }
/**
 * 级联更新所有关联的数据
 * @param category
 */
// 开启事务
@Transactional
@Override
public void updateByCascade(CategoryEntity category) {
    this.updateById(category);
    categoryBrandRelationService.updateCategory(category.getCatId(),category.getName());
}
void updateCategory(@Param("catId") Long catId,@Param("name") String name);
<update id="updateCategory" >
    update pms_category_brand_relation set catelog_name = {name} WHERE catelog_id = {catId}
</update>

获取分类规格参数和商品参数

这两个的业务代码非常相似,路径分别为product/attr/sale/list/0product/attr/base/list/0?,我们将路径改为/{attrType}/list/{catelogId},两个路径变量分别对应base规格参数,sale销售参数,将这两个参数抽取成常量;

/**
 * 查询规格参数信息
 */
//product/attr/sale/list/0?
@RequestMapping("/{attrType}/list/{catelogId}")
public R list(@RequestParam Map<String, Object> params,
              @PathVariable("catelogId") Long catelogId,
              @PathVariable("attrType")String type){

    PageUtils page = attrService.queryBaseAttrPage(params,catelogId,type);

    return R.ok().put("page", page);
}

获取分类.png 这里前端要的数据并不只实体里包含的这些,所以我们封装一个响应vo传给前台。

@Override
public PageUtils queryBaseAttrPage(Map<String, Object> params, Long catelogId, String attrType) {

    QueryWrapper<AttrEntity> queryWrapper = new QueryWrapper<AttrEntity>()
            .eq("attr_type","base".equalsIgnoreCase(attrType) ?
                    ProductConstant.AttrEnum.ATTR_TYPE_BASE.getCode() : ProductConstant.AttrEnum.ATTR_TYPE_SALE.getCode());

    //根据catelogId查询信息
    if (catelogId != 0) {
        queryWrapper.eq("catelog_id",catelogId);
    }

    String key = (String) params.get("key");
    if (!StringUtils.isEmpty(key)) {
        //attr_id attr_name
        queryWrapper.and((wrapper) -> {
            wrapper.eq("attr_id",key).or().like("attr_name",key);
        });
    }

    IPage<AttrEntity> page = this.page(
            new Query<AttrEntity>().getPage(params),
            queryWrapper
    );

    PageUtils pageUtils = new PageUtils(page);
    List<AttrEntity> records = page.getRecords();

    List<AttrRespVo> respVos = records.stream().map((attrEntity) -> {
        AttrRespVo attrRespVo = new AttrRespVo();
        BeanUtils.copyProperties(attrEntity, attrRespVo);

        //设置分类和分组的名字
        if ("base".equalsIgnoreCase(attrType)) {
            AttrAttrgroupRelationEntity relationEntity =
                    relationDao.selectOne(new QueryWrapper<AttrAttrgroupRelationEntity>().eq("attr_id",attrEntity.getAttrId()));
            if (relationEntity != null && relationEntity.getAttrGroupId() != null) {
                AttrGroupEntity attrGroupEntity = attrGroupDao.selectById(relationEntity.getAttrGroupId());
                attrRespVo.setGroupName(attrGroupEntity.getAttrGroupName());
            }

        }

        CategoryEntity categoryEntity = categoryDao.selectById(attrEntity.getCatelogId());
        if (categoryEntity != null) {
            attrRespVo.setCatelogName(categoryEntity.getName());

        }
        return attrRespVo;
    }).collect(Collectors.toList());

    pageUtils.setList(respVos);
    return pageUtils;

}

获取属性分组有关联的其他属性

/**
 * 获取属性分组有关联的其他属性
 * @param attrgroupId
 * @return
 */
//product/attrgroup/{attrgroupId}/attr/relation
@GetMapping(value = "/{attrgroupId}/attr/relation")
public R attrRelation(@PathVariable("attrgroupId") Long attrgroupId) {

    List<AttrEntity> entities = attrService.getRelationAttr(attrgroupId);

    return R.ok().put("data",entities);
}
/**
 * 根据分组id找到关联的所有属性
 * @param attrgroupId
 * @return
 */
@Override
public List<AttrEntity> getRelationAttr(Long attrgroupId) {

    List<AttrAttrgroupRelationEntity> entities = relationDao.selectList
            (new QueryWrapper<AttrAttrgroupRelationEntity>().eq("attr_group_id", attrgroupId));

    List<Long> attrIds = entities.stream().map((attr) -> {
        return attr.getAttrId();
    }).collect(Collectors.toList());

    //根据attrIds查找所有的属性信息
    //Collection<AttrEntity> attrEntities = this.listByIds(attrIds);

    //如果attrIds为空就直接返回一个null值出去
    if (attrIds == null || attrIds.size() == 0) {
        return null;
    }

    List<AttrEntity> attrEntityList = this.baseMapper.selectBatchIds(attrIds);

    return attrEntityList;
}

查询属性详细

回显1.png 这里的回显数据不仅仅包含实体的,我们还需要回显分组和分类。

/**
 * 信息
 */
@RequestMapping("/info/{attrId}")
public R info(@PathVariable("attrId") Long attrId){

    AttrRespVo respVo = attrService.getAttrInfo(attrId);

    return R.ok().put("attr", respVo);
}

这里要拿到分类信息,分组信息和路径。

@Override
public AttrRespVo getAttrInfo(Long attrId) {
    //查询详细信息
    AttrEntity attrEntity = this.getById(attrId);
    //查询分组信息
    AttrRespVo respVo = new AttrRespVo();
    BeanUtils.copyProperties(attrEntity,respVo);
    //判断是否是基本类型
    if (attrEntity.getAttrType() == ProductConstant.AttrEnum.ATTR_TYPE_BASE.getCode()) {
        //1、设置分组信息
        AttrAttrgroupRelationEntity relationEntity = relationDao.selectOne
                (new QueryWrapper<AttrAttrgroupRelationEntity>().eq("attr_id", attrId));
        if (relationEntity != null) {
            respVo.setAttrGroupId(relationEntity.getAttrGroupId());
            //获取分组名称
            AttrGroupEntity attrGroupEntity = attrGroupDao.selectById(relationEntity.getAttrGroupId());
            if (attrGroupEntity != null) {
                respVo.setGroupName(attrGroupEntity.getAttrGroupName());
            }
        }
    }
    //2、设置分类信息
    Long catelogId = attrEntity.getCatelogId();
    Long[] catelogPath = categoryService.findCatelogPath(catelogId);

    respVo.setCatelogPath(catelogPath);
    CategoryEntity categoryEntity = categoryDao.selectById(catelogId);
    if (categoryEntity != null) {
        respVo.setCatelogName(categoryEntity.getName());
    }
    return respVo;
}

修改和删除属性

/**
 * 修改
 */
@RequestMapping("/update")
public R update(@RequestBody AttrVo attr){
    attrService.updateAttrById(attr);
    return R.ok();
}

修改分组和分类,查询对应表时,结果大于0 即为修改,否则则为新增。

@Transactional(rollbackFor = Exception.class)
@Override
public void updateAttrById(AttrVo attr) {

    AttrEntity attrEntity = new AttrEntity();
    BeanUtils.copyProperties(attr,attrEntity);

    this.updateById(attrEntity);

    if (attrEntity.getAttrType() == ProductConstant.AttrEnum.ATTR_TYPE_BASE.getCode()) {
        //1、修改分组关联
        AttrAttrgroupRelationEntity relationEntity = new AttrAttrgroupRelationEntity();
        relationEntity.setAttrGroupId(attr.getAttrGroupId());
        relationEntity.setAttrId(attr.getAttrId());

        Integer count = relationDao.selectCount(new QueryWrapper<AttrAttrgroupRelationEntity>()
                .eq("attr_id", attr.getAttrId()));

        if (count > 0) {
            relationDao.update(relationEntity,
                    new UpdateWrapper<AttrAttrgroupRelationEntity>().eq("attr_id",attr.getAttrId()));
        } else {
            relationDao.insert(relationEntity);
        }
    }

}

属性组下关联新增新属性

我们想要新增没有被关联的属性,那么就应该先得到未被关联的属性。

获取属性分组没有关联的其他属性

/**
 * 获取属性分组没有关联的其他属性
 */
@GetMapping(value = "/{attrgroupId}/noattr/relation")
public R attrNoattrRelation(@RequestParam Map<String, Object> params,
                            @PathVariable("attrgroupId") Long attrgroupId) {
    PageUtils page = attrService.getNoRelationAttr(params,attrgroupId);
    return R.ok().put("page",page);
}

我们这里查询到的属性必须符合以下规则:

  • 当前分组只能关联自己所属的分类里面的所有属性

  • 当前分组只能关联别的分组没有引用的属性

/**
 * 获取当前分组没有被关联的所有属性
 * @param params
 * @param attrgroupId
 * @return
 */
@Override
public PageUtils getNoRelationAttr(Map<String, Object> params, Long attrgroupId) {

    //1、当前分组只能关联自己所属的分类里面的所有属性
    AttrGroupEntity attrGroupEntity = attrGroupDao.selectById(attrgroupId);
    //获取当前分类的id
    Long catelogId = attrGroupEntity.getCatelogId();

    //2、当前分组只能关联别的分组没有引用的属性
    //2.1)、当前分类下的其它分组
    List<AttrGroupEntity> groupEntities = attrGroupDao.selectList(new QueryWrapper<AttrGroupEntity>()
            .eq("catelog_id", catelogId));

    //获取到所有的attrGroupId
    List<Long> collect = groupEntities.stream().map((item) -> {
        return item.getAttrGroupId();
    }).collect(Collectors.toList());


    //2.2)、这些分组关联的属性
    List<AttrAttrgroupRelationEntity> groupId = relationDao.selectList
            (new QueryWrapper<AttrAttrgroupRelationEntity>().in("attr_group_id", collect));

    List<Long> attrIds = groupId.stream().map((item) -> {
        return item.getAttrId();
    }).collect(Collectors.toList());

    //2.3)、从当前分类的所有属性移除这些属性
    QueryWrapper<AttrEntity> queryWrapper = new QueryWrapper<AttrEntity>()
            .eq("catelog_id", catelogId).eq("attr_type",ProductConstant.AttrEnum.ATTR_TYPE_BASE.getCode());

    if (attrIds != null && attrIds.size() > 0) {
        queryWrapper.notIn("attr_id", attrIds);
    }

    //判断是否有参数进行模糊查询
    String key = (String) params.get("key");
    if (!StringUtils.isEmpty(key)) {
        queryWrapper.and((w) -> {
            w.eq("attr_id",key).or().like("attr_name",key);
        });
    }
    IPage<AttrEntity> page = this.page(new Query<AttrEntity>().getPage(params), queryWrapper);

    PageUtils pageUtils = new PageUtils(page);

    return pageUtils;
}

新增分组与属性关联

///product/attrgroup/attr/relation
@PostMapping(value = "/attr/relation")
public R addRelation(@RequestBody List<AttrGroupRelationVo> vos) {
    attrAttrgroupRelationService.saveBatch(vos);
    return R.ok();
}
/**
 * 批量添加属性与分组关联关系
 * @param vos
 */
@Override
public void saveBatch(List<AttrGroupRelationVo> vos) {

    List<AttrAttrgroupRelationEntity> collect = vos.stream().map((item) -> {
        AttrAttrgroupRelationEntity relationEntity = new AttrAttrgroupRelationEntity();
        BeanUtils.copyProperties(item, relationEntity);
        return relationEntity;
    }).collect(Collectors.toList());

    this.saveBatch(collect);
}

新增属性

attr.png AttrEntity实体类中并没有这个attrGroupId分组属性,我们可以像以前一样用下面这种注解来实现,但是这样实现并不优雅,我们采用另一种方法来实现,我们自己编写一个vo来收集前端传递过来的数据。

这样写并不规范
@TableField(exist = false)
private Long attrGroupId;

我们新增一个AttrVo

@Data
public class AttrVo {
    /**
     * 属性id
     */
    private Long attrId;
    /**
     * 属性名
     */
    private String attrName;
    /**
     * 是否需要检索[0-不需要,1-需要]
     */
    private Integer searchType;
    /**
     * 属性图标
     */
    private String icon;
    /**
     * 可选值列表[用逗号分隔]
     */
    private String valueSelect;
    /**
     * 属性类型[0-销售属性,1-基本属性,2-既是销售属性又是基本属性]
     */
    private Integer attrType;
    /**
     * 启用状态[0 - 禁用,1 - 启用]
     */
    private Long enable;
    /**
     * 所属分类
     */
    private Long catelogId;
    /**
     * 快速展示【是否展示在介绍上;0-否 1-是】,在sku中仍然可以调整
     */
    private Integer showDesc;

    private Long attrGroupId;

}
/**
 * 保存
 */
@RequestMapping("/save")
public R save(@RequestBody AttrVo attr){
    attrService.saveAttr(attr);
    return R.ok();
}

这里新增操作不仅仅新增一个Attr,还要维护属性分组

/**
 * 新增Attr
 * @param attr
 */
@Transactional(rollbackFor = Exception.class)
@Override
public void saveAttr(AttrVo attr) {
    AttrEntity attrEntity = new AttrEntity();
    BeanUtils.copyProperties(attr,attrEntity);
    //1、保存基本数据
    this.save(attrEntity);

    //2、保存关联关系
    //判断类型,如果是基本属性就设置分组id
    if (attr.getAttrType() == ProductConstant.AttrEnum.ATTR_TYPE_BASE.getCode() && attr.getAttrGroupId() != null) {
        AttrAttrgroupRelationEntity relationEntity = new AttrAttrgroupRelationEntity();
        relationEntity.setAttrGroupId(attr.getAttrGroupId());
        relationEntity.setAttrId(attrEntity.getAttrId());
        relationDao.insert(relationEntity);
    }
}
package com.hong.common.constant;

/**
 * @Description: 商品常量属性
 **/
public class ProductConstant {

    public enum AttrEnum {
        ATTR_TYPE_BASE(1,"基本属性"),
        ATTR_TYPE_SALE(0,"销售属性");

        private int code;

        private String msg;

        public int getCode() {
            return code;
        }

        public String getMsg() {
            return msg;
        }

        AttrEnum(int code, String msg) {
            this.code = code;
            this.msg = msg;
        }

    }


    public enum ProductStatusEnum {
        NEW_SPU(0,"新建"),
        SPU_UP(1,"商品上架"),
        SPU_DOWN(2,"商品下架"),
        ;

        private int code;

        private String msg;

        public int getCode() {
            return code;
        }

        public String getMsg() {
            return msg;
        }

        ProductStatusEnum(int code, String msg) {
            this.code = code;
            this.msg = msg;
        }

    }


}

Object 划分

1.PO(persistant object) 持久对象

PO 就是对应数据库中某个表中的一条记录,多个记录可以用 PO 的集合。 PO 中应该不包含任何对数据库的操作。

2.DO(Domain Object)领域对象

就是从现实世界中抽象出来的有形或无形的业务实体。

3.TO(Transfer Object) ,数据传输对象

不同的应用程序之间传输的对象

4.DTO(Data Transfer Object)数据传输对象

这个概念来源于 J2EE 的设计模式,原来的目的是为了 EJB 的分布式应用提供粗粒度的数据实体,以减少分布式调用的次数,从而提高分布式调用的性能和降低网络负载,但在这里,泛指用于展示层与服务层之间的数据传输对象。

5.VO(value object) 值对象

通常用于业务层之间的数据传递,和 PO 一样也是仅仅包含数据而已。但应是抽象出的业务对象 , 可以和表对应 , 也可以不 , 这根据业务的需要 。用 new 关键字创建,由GC 回收的。

View object:视图对象;接受页面传递来的数据,封装对象将业务处理完成的对象,封装成页面要用的数据

6.BO(business object) 业务对象

从业务模型的角度看 , 见 UML 元件领域模型中的领域对象。封装业务逻辑的 java 对象 , 通过调用 DAO 方法 , 结合 PO,VO 进行业务操作。business object: 业务对象 主要作用是把业务逻辑封装为一个对象。这个对象可以包括一个或多个其它的对象。 比如一个简历,有教育经历、工作经历、社会关系等等。 我们可以把教育经历对应一个 PO ,工作经历对应一个 PO ,社会关系对应一个 PO 。 建立一个对应简历的 BO 对象处理简历,每个 BO 包含这些 PO 。 这样处理业务逻辑时,我们就可以针对 BO 去处理。

7.POJO(plain ordinary java object) 简单无规则 java 对象

传统意义的 java 对象。就是说在一些 Object/Relation Mapping 工具中,能够做到维护数据库表记录的 persisent object 完全是一个符合 Java Bean 规范的纯 Java 对象,没有增加别的属性和方法。我的理解就是最基本的 java Bean ,只有属性字段及 settergetter方法!。

POJO 是 DO/DTO/BO/VO 的统称。

8.DAO(data access object) 数据访问对象

是一个 sun 的一个标准 j2ee 设计模式, 这个模式中有个接口就是 DAO ,它负持久层的操作。为业务层提供接口。此对象用于访问数据库。通常和 PO 结合使用, DAO 中包含了各种数据库的操作方法。通过它的方法 , 结合 PO 对数据库进行相关的操作。夹在业务逻辑与数据库资源中间。配合 VO, 提供数据库的 CRUD 操作.