1. 商品服务

1.1 三级分类

三级分类指的是商品导航栏中的商品分类

4. 业务功能[45-101] - 图1

在gulimall_pms数据库的pms_category表中导入分类数据:
pms_catelog.sql

1.1.1 查询

先把数据按照树形结构查出来,再在前台展示

  1. @Service("categoryService")
  2. public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {
  3. @Override
  4. public List<CategoryEntity> listWithTree() {
  5. // 1. 查出所有分类
  6. /**
  7. * 这里为什么可以直接用baseMapper查询?
  8. * @Autowired
  9. * CategoryDao categoryDao;
  10. * categoryDao.selectList(null);
  11. *
  12. * 是因为CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity>
  13. * 所以在ServiceImpl<T,T>中注入的baseMapper当前代表CategoryDao,操作的是CategoryEntity
  14. */
  15. // 1 查出所有分类
  16. List<CategoryEntity> entities = baseMapper.selectList(null);
  17. // 2 组装成父子的树形结构
  18. // 2.1 找到所有的一级分类
  19. List<CategoryEntity> level1Menus = entities.stream().filter(categoryEntity ->
  20. categoryEntity.getParentCid() == 0
  21. ).map((menu)->{
  22. menu.setChildren(getChildrens(menu,entities));
  23. return menu;
  24. }).sorted((menu1,menu2)->{
  25. return (menu1.getSort()==null?0:menu1.getSort()) - (menu2.getSort()==null?0:menu2.getSort());
  26. }).collect(Collectors.toList());
  27. return level1Menus;
  28. }
  29. private List<CategoryEntity> getChildrens(CategoryEntity root,List<CategoryEntity> all){
  30. List<CategoryEntity> children = all.stream().filter(categoryEntity ->
  31. categoryEntity.getParentCid() == root.getCatId()
  32. ).map(menu -> {
  33. // 找到子菜单
  34. menu.setChildren(getChildrens(menu, all));
  35. return menu;
  36. }).sorted((menu1, menu2) ->
  37. // 菜单排序
  38. (menu1.getSort() == null ? 0 : menu1.getSort()) - (menu2.getSort() == null ? 0 : menu2.getSort())
  39. ).collect(Collectors.toList());
  40. return children;
  41. }
  42. }

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

启动我们后台管理项目的前端项目和后台项目,在系统管理中新增一个商品管理的目录

4. 业务功能[45-101] - 图2

在商品管理下新增一个分类维护的菜单

4. 业务功能[45-101] - 图3

维护成功之后,新增的菜单目录数据保存在gulimall_admin数据库中的sys_menu表中,前台如下图所示,我们要在区域2中展示我们三级分类的数据,并且要对数据进行增删改查操作。新增菜单时维护的菜单路由是product/category,但点击分类维护菜单时,发送的请求是product-category。

4. 业务功能[45-101] - 图4

分析项目可知,请求中的product-category这个路径与项目文件的映射规则是:项目中\src\views\modules\文件夹\文件名.vue转换为请求路径:文件夹-文件名。所以我们应该在\src\views\modules\文件夹下创建一个product文件夹,并在product文件夹下创建category.vue

4. 业务功能[45-101] - 图5

在category.vue中发送查询三级分类数据的请求:

getMenus() {
  this.$http({
    url: this.$http.adornUrl("/product/category/list/tree"),
    method: "get"
  }).then(({ data }) => {
    console.log("成功获取到菜单数据...", data.data);
    this.menus = data.data;
    });
}

请求发送之后,出现404异常。分析可知是因为这个请求的url是http://localhost:8080/renren-fast/product/category/list/tree,但是查询三级分类数据在gulimall_product服务中,实际的请求应该是:http://localhost:10000/gulimall-product/product/category/list/tree,所以我们应该把renren-fast这个服务也加入到注册中心,之后通过网关的方式访问

4. 业务功能[45-101] - 图6

将renren-fast加入到注册中心,在application.yml配置服务名与nacos注册中心的地址,并开启服务的注册与发现功能

  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
  application:
    name: renren-fast

注册中心配置好之后,在网关服务的application.yml中配置路由规则

/* id 路由规则的id
 * uri lb(Load Balance)指负载均衡://要路由到的服务(注册中心的服务名)
 * predicates 断言:前台什么样的请求要做路由,我们把前台请求的基础路径都改为/api
 * filters 路径重新
*/
- id: admin_route
    uri: lb://renren-fast
    predicates:
        - Path=/api/**
/* 前端发送请求http://localhost:88/api/captcha.jpg到网关,网关发现满足路由要求,就会到注册中心找到renren-fast
 * 的IP地址,把这个请求转到http://renren-fast:8080/api/captcha.jpg(把前端请求端口号后的内容拼到后面),因为
 * 实际地址是http://renren-fast:8080/captcha.jpg没有api一层的,所以需要用filters 
 */
    filters:
        - RewritePath=/api/(?<segment>.*),/renren-fast/$\{segment}

/* 以上是请求renren-fast获取验证码(http://localhost:88/api/captcha.jpg),
 * 验证码获取之后,我们还要到商品服务中获取分类数据(http://localhost:10000/gulimall-product/product/category/list/tree)
 * 所以还应该再配置一个路由规则,是路由到商品服务的
 * 商品服务,前台发送http://localhost:88/api/product/category/list/tree,所以我们将/api/product路由到商品服务
 */
# 这里需要注意:admin_route和product_route都是路由/api,要把product_route放在admin_route的前面,
# 否则/api/product/的请求优先适配到admin_route的断言,那么转发的地址就不对
- id: product_route
    uri: lb://gulimall-product
    predicates:
        - Path=/api/product/**
     filters:
        - RewritePath=/api/(?<segment>.*),/gulimall-product/$\{segment}

修改前台基础路径:在\static\config\index.js中修改

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

当index.js修改好之后,重新登录后台管理系统,发现控制台报错:这个报错的意思是跨域不能访问

4. 业务功能[45-101] - 图7

1.1.3 网关统一配置跨域

  1. 什么是跨域?

跨域:指的是浏览器不能执行其他网站的脚本。它是由浏览器的同源策略造成的,是浏览器对javascript施加的安全限制。
同源策略:是指协议,域名,端口都要相同,其中有一个不同都会产生跨域。

请求的链接端口之前的内容只要不一致,就认为是跨域了,会被同源策略限制

4. 业务功能[45-101] - 图8

  1. 跨域的流程是什么?

非简单请求浏览器会先发送预检请求,如果响应可以跨域,才会发送真实的请求。【什么是非简单请求】

4. 业务功能[45-101] - 图9

  1. 怎么解决跨域的问题?

a> 使用nginx部署为统一域

假设有一台nginx服务器,我们将前端项目和后台项目都部署在nginx服务器中,浏览器访问前端项目,我们就访问nginx的地址,只要是静态请求,nginx默认代理给前端项目,动态请求nginx反向代理给网关,网关再转到其他服务

4. 业务功能[45-101] - 图10

b> 添加响应头

跨域请求会发一个预检请求询问服务器能不能跨域,只要服务器返回可以跨域就可以了。所以可以给预检请求一个响应头,允许跨域访问。在响应头加上以下内容:

  • Access-Control-Allow-Origin:支持哪些来源的请求跨域
  • Access-Control-Allow-Methods:支持哪些方法跨域
  • Access-Control-Allow-Credentials:跨域请求默认不包含cookie,设置为true可以包含cookie
  • Access-Control-Expose Headers:跨域请求暴露的字段

    • CORS请求时, XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-L anguage、Content-Type、 Expires、 Last-Modified、 Pragma。 如果想拿到其他字段,就必须在Access-Control- Expose- Headers里面指定。
  • Access-Control-Max _Age:表明该响应的有效时间为多少秒。在有效时间内,浏览器无须为同一请求再次发起预检请求。请注意,浏览器自身维护了一个最大有效时间,如果该首部字段的值超过了最大有效时间,将不会生效。

  1. 在网关中处理跨域

每一个项目都可能被远程访问,所以我们可以在网关中处理跨域。

@Configuration
public class GuliMallCrosConfig {

    @Bean
    public CorsWebFilter corsWebFilter(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

        CorsConfiguration corsConfiguration = new CorsConfiguration();

        //1、配置跨域
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.setAllowCredentials(true);

        source.registerCorsConfiguration("/**",corsConfiguration);
        return new CorsWebFilter(source);
    }

}

在处理跨域之前,我们发送请求时,请求头是下图这样的

4. 业务功能[45-101] - 图11

处理跨域之后,我们的请求头中多了允许跨域的内容,OPTIONS只是打头兵,真正的请求在OPTIONS请求成功之后发送的

4. 业务功能[45-101] - 图12

注意:renren-fast服务中也处理了跨域,要把renren-fast服务中的代码注释掉,否则处理两次跨域,内容重复,会报错

1.1.4 树形展示三级分类数据

引入elementUI的树形组件,展示三级分类数据【elementUI官网】

<template>
  // 引入elementui的树形组件
  <el-tree :data="menus" :props="defaultProps" @node-click="handleNodeClick" />
</template>

data() {
  return {
    menus: [],
    defaultProps: {
      children: "children",
      // 哪一个字段作为展示的名称
      label: "name"
    }
  };
},

methods: {
    getMenus() {
      this.$http({
        url: this.$http.adornUrl("/product/category/list/tree"),
        method: "get"
      }).then(({ data }) => {
        console.log("成功获取到菜单数据...", data.data);
        // 将后台查到的数据放在menus中,并在elementui的树形组件展示
        this.menus = data.data;
      });
    }
},

1.1.5 增删改查功能完善

具体功能实现看代码吧,学一下ElementUI和Vue


MyBatisPlus的逻辑删除【MyBatisPlus官网】

  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. 配置逻辑删除的组件(高版本可省略)

  3. 给Bean加上逻辑删除注解

    /**
    * 是否显示[0-不显示,1显示]
    * value:默认逻辑未删除值(该值可无、会自动获取全局配置)
    * delval:默认逻辑删除值(该值可无、会自动获取全局配置)
    */
    @TableLogic(value = "1",delval = "0")
    private Integer showStatus;
    

1.2 品牌管理

1.2.1 使用逆向工程的前后端代码

在商品系统菜单下新建品牌管理目录,使用逆向工程中品牌管理的Vue代码,并对细节部分做出修改与调整,具体调整看源码。

4. 业务功能[45-101] - 图13

新增品牌时,需要上传品牌的logo,上传的是一张图片,所以需要考虑上传的图片如何存储的问题。

如果是单体服务,浏览器上传文件之后,将文件存储在部署当前服务的服务器的某个位置,下次需要这个文件从服务器读取出来就行了。分布式情况下,如果也把文件存储在部署当前服务的服务器上,假设第一次上传文件把文件存储在了B服务器,下次请求时请求的是C服务器,但是C服务器上没有之前上传的文件,就会有问题。

可以专门设置一台文件存储的服务器。有两种方式:自建服务器和云存储,云存储显然比自建服务器更合适一些,我们当前使用阿里云对象存储。

4. 业务功能[45-101] - 图14

1.2.2 云存储OSS开通与使用

要使用阿里云对象存储,首先需要先去阿里云中开通对象存储服务(阿里云官网,搜索对象存储,按照提示开通就行)。

将文件上传到阿里云对象存储有三种方式:

  1. 普通上传方式:浏览器上传文件到我们的应用服务,应用服务将用户上传的文件再提交到阿里云对象存储。没有必要让文件经过应用服务器,多余

4. 业务功能[45-101] - 图15

  1. 用户直接上传到OSS:用户直接上传给OSS意味着我们要将OSS的用户名密码暴露出来,不安全

  2. 签名后直传:如下图,这种方式比较安全,不用暴露OSS的用户名密码,用户向服务器要到签名数据,之后直接把文件上传到OSS。推荐这种方式

4. 业务功能[45-101] - 图16

签名后直传要怎么实现?

Spring Cloud Alibaba中包含了如何使用阿里云对象存储的组件,可以看看熟悉一下。【Spring Cloud Alibaba官方文档】

4. 业务功能[45-101] - 图17

在阿里云官网,也有签名后直传的详细实例:【签名后直传】

4. 业务功能[45-101] - 图18

因为我们后续还有很多使用到第三方服务的地方,所以新建一个专门用来调用第三方服务的工程gulimall_thirdpart,将这个工程加入注册中心,之后新建一个Controller,处理获取签名的请求(代码复制阿里云官网中关于签名后直传的实例,稍作修改)

@RestController
public class OssController {

    @Autowired
    OSS ossClient;

    @Value("${alibaba.cloud.oss.bucket}")
    private String bucket;
    @Value("${alibaba.cloud.oss.endpoint}")
    private String endpoint;
    @Value("${alibaba.cloud.access-key}")
    private String accessKeyId;

    @RequestMapping("/oss/policy")
    public R getPolicy(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        //https://gulimall-hello.oss-cn-beijing.aliyuncs.com/hahaha.jpg
        String host = "https://" + bucket + "." + endpoint;

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

        Map<String, String> respMap = null;
        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", 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);
    }
}

因为我们所有的请求都是经过网关,所以在网关中配置gulimall_thirdpart服务的路由规则

- id: third_route
    uri: lb://gulimall-thirdpart
    predicates:
        - Path=/api/third/**
    filters:
        - RewritePath=/api/(?<segment>.*),/$\{segment}

1.2.3 前后端联调测试文件上传

在使用前端组件进行文件上传时,会产生如下跨域的错误

4. 业务功能[45-101] - 图19

需要去阿里云的对象存储中设置跨域,设置成功之后再上传

4. 业务功能[45-101] - 图20

1.2.4 JSR303

JSR-303 是 JAVA EE 6 中的一项子规范,叫做 Bean Validation。它提供了对 Java EE 和 Java SE 中的 Java Bean 进行验证的方式。

1.2.4.1 数据校验

数据不仅需要前端校验,后端也必须校验,因为可能会绕过前端,直接向后台发送请求,比如接口过来的数据。后台校验我们使用JSR303。

要使用校验注解,我们首先得在gulimall_common中引入依赖

<dependency>
      <groupId>javax.validation</groupId>
      <artifactId>validation-api</artifactId>
      <version>2.0.1.Final</version>
</dependency>
  1. 给Bean添加校验注解,并定义自己的message提示,要给哪个Entity的字段添加校验,就在字段上添加注解

    不定义的话就会取默认的提示信息,默认提示信息是配置在org\hibernate\validator\ValidationMessages.properties中的

public class BrandEntity implements Serializable {
    private static final long serialVersionUID = 1L;

    /**
     * 品牌名
     */
    @NotBlank(message = "品牌名必须提交")
    private String name;
    /**
     * 品牌logo地址
     */
    @NotBlank
    @URL(message = "logo必须是一个合法的url地址")
    private String logo;

}
  1. 只给Bean添加校验注解是没有效果的,还需要开启校验功能@Valid; 校验错误以后会有默认的响应;

    @RequestMapping("/save")
    public R save(@Valid @RequestBody CategoryEntity category){
     categoryService.save(category);
    
     return R.ok();
    }
    

    ```json // 入参数据 { “name”:””, “logo”:”11” }

// 校验之后默认的响应 { “timestamp”: “2022-01-06T12:16:18.079+0000”, “status”: 400, “error”: “Bad Request”, “errors”: [ { “codes”: [ “NotBlank.brandEntity.name”, “NotBlank.name”, “NotBlank.java.lang.String”, “NotBlank” ], “arguments”: [ { “codes”: [ “brandEntity.name”, “name” ], “arguments”: null, “defaultMessage”: “name”, “code”: “name” } ], “defaultMessage”: “不能为空”, “objectName”: “brandEntity”, “field”: “name”, “rejectedValue”: “”, “bindingFailure”: false, “code”: “NotBlank” }, { “codes”: [ “URL.brandEntity.logo”, “URL.logo”, “URL.java.lang.String”, “URL” ], “arguments”: [ { “codes”: [ “brandEntity.logo”, “logo” ], “arguments”: null, “defaultMessage”: “logo”, “code”: “logo” }, [], { “defaultMessage”: “”, “arguments”: null, “codes”: [ “” ] }, -1, { “defaultMessage”: “”, “arguments”: null, “codes”: [ “” ] }, { “defaultMessage”: “.“, “arguments”: null, “codes”: [ “.“ ] } ], “defaultMessage”: “需要是一个合法的URL”, “objectName”: “brandEntity”, “field”: “logo”, “rejectedValue”: “11”, “bindingFailure”: false, “code”: “URL” } ], “message”: “Validation failed for object=’brandEntity’. Error count: 2”, “path”: “/product/brand/save” }


3. 给校验的bean后紧跟一个BindingResult,就可以获取到校验的结果

虽然校验之后会有默认的响应数据,但是这个响应不符合我们的业务规范,所以还需要对校验的响应进行处理
```java
@RequestMapping("/save")
public R save(@Valid @RequestBody BrandEntity brand, BindingResult result) {

    if (result.hasErrors()) {
        Map<String, String> map = new HashMap<>();
        //1、获取校验的错误结果
        result.getFieldErrors().forEach((item) -> {
            //FieldError 获取到错误提示
            String message = item.getDefaultMessage();
            //获取错误的属性的名字
            String field = item.getField();
            map.put(field, message);
        });

        return R.error(400, "提交的数据不合法").put("data", map);
    } else {
        brandService.save(brand);
    }

    return R.ok();
}

1.2.4.2 统一异常处理

我们不可能在每一个方法里面都用BindingResult去处理校验返回的结果。所以我们可以定义统一处理异常的类。

/**
 * 集中处理所有异常
 * @RestControllerAdvice:表明这是一个异常处理类
 */
@Slf4j
//@ResponseBody
//@ControllerAdvice(basePackages = "com.yaosy.gulimall.product.controller")
@RestControllerAdvice(basePackages = "com.yaosy.gulimall.product.controller")
public class GulimallExceptionControllerAdvice {

    //  @ExceptionHandler 标注当前方法可以处理的异常
    @ExceptionHandler(value= MethodArgumentNotValidException.class)
    public R handleVaildException(MethodArgumentNotValidException e){
        log.error("数据校验出现问题{},异常类型:{}",e.getMessage(),e.getClass());
        BindingResult bindingResult = e.getBindingResult();

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

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

        log.error("错误:",throwable);
        return R.error(BizCodeEnume.UNKNOW_EXCEPTION.getCode(),BizCodeEnume.UNKNOW_EXCEPTION.getMsg());
    }

}

1.2.4.3 分组校验(多场景的复杂校验)

假设新增时不需要校验品牌ID是否为空,但是修改时需要校验品牌ID是否为空,就可以做分组校验。groups需要定义一个类型值,所以我们可以在common中定义一个接口,不需要有做其他处理

public interface UpdateGroup {}
@NotBlank(groups = UpdateGroup.class)
private Long brandId;

在Controller的方法中给校验注解标注什么情况需要进行校验

@RequestMapping("/save")
public R save(@Validated(UpdateGroup.class) @RequestBody BrandEntity brand){
    brandService.save(brand);

    return R.ok();
}

需要注意:默认没有指定分组的校验注解@NotBlank,在分组校验情况@Validated({AddGroup.class})下不生效,只会在@Validated生效;

1.2.4.4 自定义校验注解

  1. 编写一个自定义的校验注解

    @Documented
    @Constraint(validatedBy = { ListValueConstraintValidator.class })
    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
    @Retention(RUNTIME)
    public @interface ListValue {
     String message() default "{com.atguigu.common.valid.ListValue.message}";
    
     Class<?>[] groups() default { };
    
     Class<? extends Payload>[] payload() default { };
    
     int[] vals() default { };
    }
    
  2. 编写一个自定义的校验器 ConstraintValidator

    public class ListValueConstraintValidator implements ConstraintValidator<ListValue,Integer> {
    
     private Set<Integer> set = new HashSet<>();
     //初始化方法
     @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) {
    
         return set.contains(value);
     }
    }
    
  3. 关联自定义的校验器和自定义的校验注解:用自定义注解上的4个注解进行关联,关联之后我们就可以在需要的地方使用我们自定义的注解

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

1.3 SPU & SKU & 规格参数 & 销售属性

SPU(Standard Product Unit):标准化产品单元,是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。

SKU(Stock Keepilg Unit):库存量单位,即库存进出计量的基本单元,可以是以件,盒,托盘等为单位。SKU这是对于大型连锁超市DC(配送中心)物流管理的一个必要的方法。现在已经被引申为产品统一编号的简称,每种产品均对应有唯一的SKU号。

规格参数(基本属性) & 销售属性:每个分类下的商品共享规格参数,与销售属性。只是有些商品不一定要用这个分类下全部的属性:

  1. 属性是以三级分类组织起来的
  2. 规格参数中有些是可以提供检索的
  3. 规格参数也是基本属性,他们具有自己的分组
  4. 属性的分组也是以三级分类组织起来的
  5. 属性名确定的,但是值是每一个商品不同来决定的