谷粒商城笔记 + 前后端完整代码 + 报错问题汇总(基础篇)

谷粒商城基础篇

  • 1. Preparation In Advance
    • 1.1 开发环境
    • 1.2 项目初始化
    • 1.3 分布式开发配置
      • 1.3.1 Nacos 注册中心
      • 1.3.2 OpenFeign 远程调用
      • 1.3.3 Nacos 配置中心
      • 1.4.4 Gateway 网关
      • 1.4.5 网关统一配置跨域
  • 2. 三级分类
    • 2.1 树形展示
      • 2.1.1 后端实现代码
      • 2.1.2 renren-fast 网关配置
      • 2.1.3 mall-product 网关配置
      • 2.1.4 前端展示查询结果
    • 2.2 删除
    • 2.3 新增
    • 2.4 修改
    • 2.5 拖拽修改功能
      • 2.5.1 实现拖拽效果
      • 2.5.2 收集数据
      • 2.5.3 批量拖拽
    • 2.6 批量删除
  • 3. 品牌管理
    • 3.1 逆向生成前端代码并优化
    • 3.2 OSS 文件上传
      • 3.2.1 简介
      • 3.2.2 普通上传
      • 3.2.3 服务端签名后直传
      • 3.2.4 前端联调
    • 3.3 数据校验
      • 3.3.1 前端表单校验
      • 3.3.2 JSR303
      • 3.3.3 统一异常处理
      • 3.3.4 分组校验
      • 3.3.5 自定义校验
  • 4. 属性分组
    • 4.1 属性相关概念
    • 4.2 抽取公共组件 & 属性分组页面
    • 4.3 根据 分类ID 获取 属性分组
    • 4.4 新增分组 & 级联选择器
    • 4.5 修改分组 & 级联选择器回显
    • 4.6 品牌管理分页功能
  • 5.关联服务
  • 6. 平台属性
    • 6.1 新增基本属性
    • 6.2 获取分类的基本属性
    • 6.3 修改基本属性
    • 6.4 获取属性分组关联的销售属性
    • 6.5 删除销售属性与分组的关联
    • 6.6 获取分组未关联的属性
    • 6.7 添加属性和分组的关联关系
  • 7. 新增商品
    • 7.1 调试会员等级相关接口
    • 7.2 获取分类关联的品牌
    • 7.3 获取分类下所有分组及属性
    • 7.4 新增商品
  • 8. 商品管理
    • 8.1 SPU 检索
    • 8.2 SKU 检索
    • 8.3 获取 SPU 规格参数
    • 8.4 修改 SPU 规格参数
  • 9. 仓库管理
    • 9.1 查询仓库信息
    • 9.2 查询商品库存信息
    • 9.3 查询采购需求
    • 9.4 合并采购需求到采购单
    • 9.5 领取采购单
    • 9.6 完成采购
  • Gateway
  • 问题汇总
    • node-sass 报错问题
    • 'parent.relativePath' of POM
    • renren-fast 注册到 Nacos
    • P75 publish 报错

很久以前写的,很多地方写的应该不大行,随便看看,有时间重写一下。(笔记里面的大部分图片被我删了,OSS 访问要钱…)


有一些地方和老师写的不一样(主要是类、数据库表的命名有些不同)

1. Preparation In Advance

1.1 开发环境

Docker,虚拟化容器技术,基于镜像。pull 的服务为 Imagesrun 的服务为 Containers

Linux 安装 Docker

# Uninstall Old Versions
sudo yum remove docker \
                docker-client \
                docker-client-latest \
                docker-common \
                docker-latest \
                docker-latest-logrotate \
                docker-logrotate \
                docker-engine

# Install Required Packages
sudo yum install -y yum-utils

sudo yum-config-manager \
    --add-repo \
    https://download.docker.com/linux/centos/docker-ce.repo

# Install Docker
sudo yum install docker-ce docker-ce-cli containerd.io docker-compose-plugin

# Start / Restart / Stop / Status
sudo systemctl start docker
sudo systemctl restart docker
sudo systemctl stop docker
sudo systemctl status docker

# Set Docker Daemon
sudo systemctl enable docker

# Images Accelerator
sudo mkdir -p /etc/docker

sudo tee /etc/docker/daemon.json <<-'EOF'
{
  "registry-mirrors": ["https://xzstr9ov.mirror.aliyuncs.com"]
}
EOF

sudo systemctl daemon-reload

sudo systemctl restart docker

# Uninstall Docker Engine  & Delete All Images
sudo yum remove docker-ce docker-ce-cli containerd.io docker-compose-plugin
 
sudo rm -rf /var/lib/docker
sudo rm -rf /var/lib/containerd

Docker 安装 MySQL

# 拉取 MySQL 镜像
docker pull mysql

# 启动 MySQL 服务,并运行容器
docker run -p 3306:3306 --name mysql \
-v /devTools/mysql/log:/var/log/mysql \
-v /devTools/mysql/data:/var/lib/mysql \
-v /devTools/mysql/conf:/etc/mysql \
-e MYSQL_ROOT_PASSWORD=root \
-d mysql

说明:

-p 3306:3306																# MySQL 容器的 3306 端口映射到 Linux 的 3306 端口
-v /devTools/mysql/log:/var/log/mysql \			# MySQL 容器中的配置文件夹挂载到 Linux 中的 /devTools/mysql/log
-v /devTools/mysql/data:/var/lib/mysql \		# MySQL 容器中的将日志文件夹挂载到 Linux 中的 /devTools/mysql/data
-v /devTools/mysql/conf:/etc/mysql \				# MySQL 容器中的将配置文件夹挂载到 Linux 中的 /devTools/mysql/conf
-e MYSQL_ROOT_PASSWORD=root \								# 初始化 root 用户的密码
-d mysql:																		# Daemon

# 直接在 Linux 即可访问 /devTools/mysql 中的文件
[root@VM-8-5-centos ~]# ls /devTools/mysql/
conf  data  log

MySQL 配置:

vi /devTools/mysql/conf/my.cnf

# 添加以下信息到 /devTools/mysql/conf/my.cnf 中
[client]
default-character-set=utf8

[mysql]
default-character-set=utf8

[mysqld]
init_connect='SET collation_connection = utf8_unicode_ci'
init_connect='SET NAMES utf8'
character-set-server=utf8
collation-server=utf8_unicode_ci
skip-character-set-client-handshake
skip-name-resolve

# 配置后需要重启 MySQL
docker restart mysql

Docker 安装 Redis

# 拉取 Redis 镜像
docker pull redis

# 启动 Redis 服务,并运行容器
docker run -p 6379:6379 --name redis \
-v /devTools/redis/data:/data \
-v /devTools/redis/conf/redis.conf:/etc/redis/redis.conf \
-d redis redis-server /etc/redis/redis.conf

# Redis 配置
vi /devTools/redis/conf/redis.conf
# AOF 持久化
appendonly yes
# 配置后需要重启
docker restart redis

Maven 配置(settings.xml

<mirrors>
	<mirror>
		<id>nexus-aliyunid>
		<mirrorOf>centralmirrorOf>
		<name>Nexus aliyunname>
		<url>http://maven.aliyun.com/nexus/content/groups/publicurl>
	mirror>
mirrors>

<profiles>
	<profile> 
		<id>jdk-1.8id> 
		<activation> 
			<activeByDefault>trueactiveByDefault> 
			<jdk>1.8jdk> 
		activation> 
		<properties>
		 	<maven.compiler.source>1.8maven.compiler.source> 
		 	<maven.compiler.target>1.8maven.compiler.target> 
		 	<maven.compiler.compilerVersion>1.8maven.compiler.compilerVersion> 
		properties>
	profile>
profiles>

Git

# 配置用户名
git config --global user.name iTsawaysu

# 配置邮箱
git config --global user.email [email protected]

# 配置 SSH 免密登录
# 进入 Git Bash,输入以下命令,连续三次回车。
ssh-keygen -t rsa -C "[email protected]"

cat ~/.ssh/id_rsa.pub

# 将 id_rsa.pub 中的 SSH 公钥添加到 Gitee or GitHub 中
# 测试
ssh -T [email protected]
ssh -T [email protected]

1.2 项目初始化

创建仓库 Gitee or GitHub 仓库。

创建 Spring Boot 微服务

商品服务 mall-product
仓储服务 mall-ware
订单服务 mall-order
优惠服务 mall-coupon
用户服务 mall-member

# 共同点
依赖:spring-web、openfeign
包名:com.sun.mall.xxx
Spring Boot 版本:2.1.8.RELEASE
Spring Cloud 版本:Greenwich.SR3

mall 父项目:


<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.0modelVersion>
    <groupId>com.sun.mallgroupId>
    <artifactId>mallartifactId>
    <version>0.0.1-SNAPSHOTversion>
    <name>mallname>
    <description>聚合服务description>
    <packaging>pompackaging>

    <modules>
        <module>mall-couponmodule>
        <module>mall-membermodule>
        <module>mall-ordermodule>
        <module>mall-productmodule>
        <module>mall-waremodule>
    modules>
project>

创建数据库

Database Name:
	mall_oms
	mall_pms
	mall_sms
	mall_ums
	mall_wms
	mall_admin

Character Set:
	utf8mb4
Collation:
	utf8mb4_general_ci
	
The Last One Thing: Execute SQL File.

导入 人人开源 & 逆向工程 & 公共模块

  1. renren-fast 导入到 mall 项目中,在 mall-admin 数据库中运行 SQL 文件,修改配置,启动。
  2. renren-fast-vuenpm install & npm run dev 即可访问后台管理系统。
  3. 导入 renren-generator ,修改配置(数据库连接信息)和 generator.properties 中的信息;再将 /resources/template/Controller.java.vm 中的 @RequiresPermissions 注解先注释掉。
  4. 运行代码生成器,生成对应的代码,将生成的代码中的 main 目录导入到 对应的微服务中。
  5. 创建 mall-common 模块,引入公共依赖、公共类,该模块作为公共模块。

各个微服务依次整合 MyBatis Plus

  1. mall-cmmon 中导入依赖;

    <dependency>
        <groupId>com.baomidougroupId>
        <artifactId>mybatis-plus-boot-starterartifactId>
        <version>3.2.0version>
    dependency>
    
  2. 配置数据源;

    spring:
      datasource:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/mall_pms?useSSL=false
        username: root
        password: root
    
  3. 在主启动类上添加 @MapperScan("com.sun.mall.product.dao") 注解扫描并注册 Mapper 接口;

  4. 设置 Mapper 映射文件位置 & 全局设置 主键ID 为自增类型。

    mybatis-plus:
      mapper-locations: classpath:/mapper/**/*.xml
      global-config:
        db-config:
          id-type: auto
    

1.3 分布式开发配置

mall-cmmon 模块中导入 spring-cloud-alibaba指定版本。

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.alibaba.cloudgroupId>
          	<artifactId>spring-cloud-alibaba-dependenciesartifactId>
          	<version>2.1.0.REALEASEversion>
          	<type>pomtype>
          	<scope>importscope>
        dependency>
  	dependencies>
dependencyManagement>

1.3.1 Nacos 注册中心

  1. 引入 spring-cloud-starter-alibaba-discovery 依赖
  2. 在配置文件中 配置 Nacos Server 的地址:spring.cloud.nacos.discovery.server-addr
  3. 使用 @EnableDiscoveryClient 注解开启 服务的注册和发现功能
  4. 下载 Nacos 并且解压;启动 Naocs,再启动微服务;在 localhost:8848/nacos 中即可查看服务注册情况。
  1. 各个微服务都需要注册到 注册中心,因此可以将 Nacos 的依赖放到 mall-common 中。

    <dependency>
        <groupId>com.alibaba.cloudgroupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
    dependency>
    
  2. 配置文件:

    spring:
    	cloud:
      	nacos:
        	discovery:
          	server-addr: localhost:8848
    
  3. 主启动类上添加 @EnableDiscoveryClient 注解,开启服务注册与发现功能;

  4. 启动 Nacos;启动 Nacos 后再启动微服务,将微服务注册到 Nacos 注册中心。

1.3.2 OpenFeign 远程调用

实现步骤:

  1. 引入 spring-cloud-starter-openfeign 依赖
  2. 开启远程调用功能:@EnableFeignClients(basePackages = "com.sun.mall.member.feign")
  3. 声明远程接口(FeignService)并标注 @FeignClient("服务名字") 注解
  4. 在 FeignController 中调用 FeignService 中声明的方法

实现过程:

  1. 服务启动,自动扫描 @EnableFeignClients 注解所指定的包;
  2. 通过 @FeignClient("服务名") 注解,在 注册中心 中找到对应的服务;
  3. 最后,调用请求路径所对应的 Controller 方法。

mall-coupon 中准备一个 Controller 方法;

@GetMapping("/test")
public R test(){
		return R.ok().put("data", "openFeign OK~~~");
}
  1. 引入 OpenFeign 的依赖;

  2. mall-member 的主启动类上标注 @EnableFeignClients 注解,开启远程调用功能;

    @EnableFeignClients(basePackages = "com.sun.mall.member.feign")
    
  3. mall-member 中声明远程接口 CouponFeignService

    @Component
    @FeignClient("mall-coupon")
    public interface CouponFeignService {
        /**
         *  1. 服务启动,会自动扫描 @EnableFeignClients 注解指定的包;
         *  2. 通过 @FeignClient("服务名") 注解,在 注册中心 中找到对应的服务;
         *  3. 最后再调用 该请求路径所对应的 Controller 方法。
         */
        @GetMapping("/coupon/coupon/test")
        public R test();
    }
    
  4. mall-member 中声明 CouponFeignController

    @RestController
    @RequestMapping("/member/coupon")
    public class CouponFeignController {
    
        @Resource
        private CouponFeignService couponFeignService;
    
        @GetMapping("/test")
        public R test() {
            return couponFeignService.test();
        }
    }
    
  5. 最后,启动 mall-coupon & mall-member,访问 localhost:8000/member/coupon/test

    {
    		"msg": "success",
    		"code": 0,
    		"data": "openFeign OK~~~"
    }
    

1.3.3 Nacos 配置中心

  1. 引入 spring-cloud-starter-alibaba-nacos-config 依赖;
  2. bootstrap.yml 中配置 Nacos Config;
  3. 启动 Nacos,新建一个配置 —— Data ID 为 mall-coupon-dev.yml
    • Data ID 的完整格式:${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}

bootstrap.yml

spring:
  application:
    name: mall-coupon
  cloud:
    nacos:
      config:
        server-addr: localhost:8848
        file-extension: yml
	profiles:
    active: dev

CouponController:测试从外部获取配置信息是否成功。

注意: 在 Controller 上标注 @RefreshScope 注解,动态获取并刷新配置

@RefreshScope
@RestController
@RequestMapping("coupon/coupon")
public class CouponController {
    @Value("${config.info}")
    private String configInfo;

    @GetMapping("/configInfo")
    public R configInfo(){
        return R.ok().put("configInfo", configInfo);
    }
}

访问 localhost:7000/coupon/coupon/configInfo 即可获取到 Nacos 配置中心的配置信息。

Namespace & Group

Namespace 命名空间 —— 隔离配置

  • public 为保留空间,默认新增的配置都在 public 空间;
  • 新建 devtestprod 命名空间,默认访问的是 public,可以在 bootstrap.yml 中配置,访问指定的命名空间。
spring:
  cloud:
    nacos:
      config:
        server-addr: localhost:8848
        file-extension: yml
        namespace: 52f9c44d-bb3f-4ff3-9ec8-9c7e3d8e11bf

Group 配置分组

  • 默认所有的配置都属于 DEFAULT_GROUP
  • 新建两个 Data ID 同名的配置,Group 分别为 DEV_GROUPTEST_GROUP
  • 修改 bootstrap.ymlspring.cloud.nacos.config.group 配置项的值,即可指定的获取 同名不同组 的配置文件。
spring:
  cloud:
    nacos:
      config:
        server-addr: localhost:8848
        file-extension: yml
        namespace: 52f9c44d-bb3f-4ff3-9ec8-9c7e3d8e11bf
        group: DEV_GROUP

最终配置

mall-ware 为例,其他微服务同理。

  1. Nacos 中创建 命名空间 dev

  2. Nacos 中创建 mall-ware-dev.yml,属于 dev 空间;

    server:
      port: 11000
    
    spring:
      application:
        name: mall-ware
      datasource:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/mall_wms?useSSL=false
        username: root
        password: root
    
  3. Nacos 中创建 mybatis.yml,属于 public 空间;

    mybatis-plus:
      mapper-locations: classpath:/mapper/**/*.xml
      global-config:
        db-config:
          id-type: auto
    
  4. bootstrap.yml

    spring:
      application:
        name: mall-ware
      cloud:
        nacos:
          discovery:
            server-addr: localhost:8848
          config:
            server-addr: localhost:8848
            file-extension: yml
            namespace: 9214d082-ba56-4d03-b137-9c1a2a4b0e59
            ext-config:
              - data-id: mybatis.yml
                refresh: true
      profiles:
        active: dev
    

1.4.4 Gateway 网关

网关微服务最边缘的服务,直接暴露给用户,作为用户和微服务间的桥梁

  • 加入网关后,使用路由机制,只需要访问网关,网关按照一定的规则将请求路由转发到对应的微服务即可;转发给多个相同的实例时,能够实现负载均衡。
  • 网关可以和注册中心整合,只需要使用服务名称即可访问微服务,通过服务名称找到目标的 ip:port,可以实现负载均衡、Token 拦截、权限验证、限流等操作。

Nginx 和 Gateway 的区别

  1. Nginx 是服务器级别的,Gateway 是项目级别的;
  2. Nginx 性能更好一些;
  3. 服务器接收请求,Nginx 负载均衡转发到 Gateway,Gateway 再次负载均衡转发到各个微服务;
  4. 如果项目中没有 Gateway 集群,则可以省略 Nginx。

Gateway 工作原理

  1. 客户端向 Gateway 发送请求;
  2. 在 Gateway Handler Mapping 中找到与请求相匹配的路由;(Predicate
  3. 转发给 Gateway Web Hanlder 处理,Gateway Web Handler 通过指定的过滤器过滤(Filter)后,再将请求转发给实际的服务的业务逻辑进行处理;
  4. 处理完后返回结果。

路由(Route):构建网关的基本模块,由 ID、目标 URI、一系列断言 和 过滤器 组成;

断言(Predicate):可以匹配 HTTP 请求中的任何信息,比如 请求头、请求参数等;

过滤(Filter):可以在请求被路由前或后,对请求进行修改。

网关测试

  1. 创建 mall-gateway 模块,引入 spring-cloud-starter-gatewaymall-common(去除公共模块中的 MP) 依赖;

    <dependency>
        <groupId>com.sun.mallgroupId>
        <artifactId>mall-commonartifactId>
        <version>0.0.1-SNAPSHOTversion>
        <exclusions>
            <exclusion>
                <groupId>com.baomidougroupId>
                <artifactId>mybatis-plus-boot-starterartifactId>
            exclusion>
        exclusions>
    dependency>
    
    <dependency>
        <groupId>org.springframework.cloudgroupId>
        <artifactId>spring-cloud-starter-gatewayartifactId>
    dependency>
    
  2. bootstrap.yml

    spring:
      application:
        name: mall-gateway
      cloud:
        nacos:
          discovery:
            server-addr: localhost:8848
          config:
            server-addr: localhost:8848
            file-extension: yml
    
  3. 在 Nacos 中创建 mall-gateway.yml

    server:
      port: 88
    
    spring:
      application:
        name: mall-gateway
    
      cloud:
        gateway:
          routes:
            - id: baidu_route
              uri: https://www.baidu.com
              predicates:
                - Query=where, bd
            - id: qq_route
              uri: https://www.bilibili.com/
              predicates:
                - Query=where, bz
    
  4. 访问 localhost:88?where=bd 跳转到 百度,访问 localhost:88?where=bz 跳转到 B站。

1.4.5 网关统一配置跨域

浏览器出于安全考虑,使用 XMLHttpRequest 对象发起 HTTP 请求时必须遵守 同源策略,否则就是跨域的 HTTP 请求,默认情况下是禁止的。

  • 同源策略:如果两个 URL 满足 三个相同协议(HTTP)、域名(localhost)、端口(8080)相同,则这两个 URL 同源。
  • 跨域:跨浏览器同源策略。一个浏览器从一个域名的网页请求另一个域名的资源时,协议、域名 和 端口,只要有一个不同,就是跨域。
  • CORS:Corss-Origin Resources Sharing 跨域资源共享。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import org.springframework.web.cors.reactive.CorsWebFilter;

@Configuration
public class MallCorsConfiguration {
    @Bean
    public CorsWebFilter corsWebFilter(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", corsConfiguration);
        return new CorsWebFilter(source);
    }
}

最后:记得将 renren-fast 中所配置的 CorsConfig 注释掉。

2. 三级分类

mall_pmspms_category 表的设计。(运行 pms_catelog.sql 对该表进行数据填充)

字段 类型 长度 注释
cat_id bigint 20 分类 ID
name char 50 分类名称
parent_cid bigint 20 父分类 ID
cat_level int 11 层级
show_status tinyint 4 是否显示 [0 不显示,1 显示]
sort int 11 排序
icon char 255 图标地址
product_unit char 50 计量单位
product_count int 11 商品数量

2.1 树形展示

2.1.1 后端实现代码

查询所有分类及其子分类,以树形结构展示。

CategoryEntity 增加一个属性:

/**
 * 子分类(在数据库表中不存在的字段)
 */
@TableField(exist = false)
private List<CategoryEntity> children;

注意:Long 类型比较需要先使用 longValue() 将包装类转换为基本类型 long

/**
 * 查询所有分类及其子分类,以树形结构展示
 */
@RequestMapping("/list/tree")
public R list(){
    List<CategoryEntity> categoryEntityList = categoryService.displayWithTreeStructure();
    return R.ok().put("data", categoryEntityList);
}
/**
 * 查询所有分类及其子分类,以树形结构展示
 */
@Override
public List<CategoryEntity> displayWithTreeStructure() {
    // 1. 查出所有分类
    List<CategoryEntity> categoryEntityList = query().list();

    // 2. 组装为父子的树形结构
    List<CategoryEntity> categoriesWithTreeStructure = categoryEntityList.stream()
            // 2.1 找到所有的一级分类(父分类ID 为 0)
            .filter(categoryEntity -> categoryEntity.getParentCid().longValue() == 0)
            .map(levelOneCategory -> {
                // 2.2 为所有的一级分类设置 children
                levelOneCategory.setChildren(setChildrenCategory(levelOneCategory, categoryEntityList));
                return levelOneCategory;
            })
            // 2.3 排序
            .sorted((o1, o2) -> {
                return (o1.getSort() == null ? 0 : o1.getSort()) - (o2.getSort() == null ? 0 : o2.getSort());
            })
            .collect(Collectors.toList());
    return categoriesWithTreeStructure;
}

/**
 * 递归查找所有子分类
 * @param rootCategory 当前分类
 * @param allCategories 所有分类
 * @return 子分类
 */
private List<CategoryEntity> setChildrenCategory(CategoryEntity rootCategory, List<CategoryEntity> allCategories) {
    List<CategoryEntity> subcategoryList = allCategories.stream()
            // 找到子菜单
            .filter(categoryEntity -> categoryEntity.getParentCid().longValue() == rootCategory.getCatId().longValue())
            .map(childrenCategory -> {
                childrenCategory.setChildren(setChildrenCategory(childrenCategory, allCategories));
                return childrenCategory;
            })
            .sorted((o1, o2) -> {
                return (o1.getSort() == null ? 0 : o1.getSort()) - (o2.getSort() == null ? 0 : o2.getSort());
            })
            .collect(Collectors.toList());
    return subcategoryList;
}

2.1.2 renren-fast 网关配置

在前端页面的 系统管理 - 菜单管理 中,新增 商品系统 目录,在该目录下新增 分类维护 菜单,菜单路由为 product/category。(新增的菜单对应增加到了 mall-admin 中的 sys_menu 表中)

点击菜单管理,URL 为 http://localhost:8001/#/sys-menu;点击分类维护,URL 为 http://localhost:8001/#/product-category

可以发现 product/category 中的 / 被替换为 - 了;通过查看前端代码能够找到 sys-role 具体的视图为 src/views/modules/sys/role.vue;因此创建 product-category 视图的位置应该为 src/views/modules/product/category.vue




访问分类维护菜单无法获取到数据,因为发送的请求是 http://localhost:8080/renren-fast/product/category/list/tree,提供服务的接口是 10000 端口的 mall-product 微服务。

renren-fast-vue/static/config/index.js 中定义了 API 接口的基准路径地址,为 localhost:8080/renren-fast;将其修改为给 Gateway 发送请求,由 Gateway 路由到指定的微服务。(注意,这里可为前端项目统一添加一个请求路径的前缀 api

window.SITE_CONFIG['baseUrl'] = 'http://localhost:88/api';

但是这样会导致所有的请求都打到 Gateway,例如:由 renren-fast 负责处理的验证码也会被发送到 Gateway。

网关配置:将 renren-fast 注册到 Nacos 注册中心,网关负责将所有访问 /api/** 的请求转发给 renren-fast 处理。

  1. 引入 spring-cloud-starter-alibaba-nacos-discovery 并定义 Spring Cloud 版本;

    <dependencyManagement>
    	<dependencies>
    		<dependency>
    			<groupId>com.alibaba.cloudgroupId>
    			<artifactId>spring-cloud-alibaba-dependenciesartifactId>
    			<version>2.1.0.RELEASEversion>
    			<type>pomtype>
    			<scope>importscope>
    		dependency>
    	dependencies>
    dependencyManagement>
    
    <dependencies>
    	<dependency>
    		<groupId>com.alibaba.cloudgroupId>
    		<artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
    	dependency>
      ...
    dependencies>
    
  2. 在主启动类上标注 @EnbaleDiscoveryClient 注解;

  3. 在配置文件中将 renren-fast 注册到 Nacos 注册中心;

    spring:
    	application:
    		name: renren-fast
    	cloud:
    		nacos:
    			discovery:
    				server_addr: localhost:8848
    
  4. 网关配置。

    server:
      port: 88
    
    spring:
      application:
        name: mall-gateway
    
      cloud:
        gateway:
          routes: 
            - id: admin_route
              uri: lb://renren-fast
              predicates: 
                - Path=/api/**
    
  • 此时从前端项目发送 http://localhost:88/api/captcha.jpg 请求获取验证码,发现该请求的路径为网关中断言的路径 /api/**,于是会将请求转发给 renren-fast 处理,请求地址变为 http://localhost:8080/renren-fast:8080/api/captch.jpg
  • 但是真正能够获取到验证码的路径为 http://localhost:8080/renren-fast/captch.jpg;因此,需要 路径重写
server:
  port: 88

spring:
  application:
    name: mall-gateway

  cloud:
    gateway:
      routes:
        - id: admin_route
          uri: lb://renren-fast
          predicates:
            - Path=/api/**
          filters:
            - RewritePath=/api/(?>.*),/renren-fast/$\{segment}
            # http://localhost:8080/api/renren-fast/captch.jpg ===> http://localhost:8080/renren-fast/captch.jpg

2.1.3 mall-product 网关配置

  • 由于定义了 API 接口的基准路径 localhost:88/api,所有请求都转发给了 renren-fast(负责处理 /api/** 请求);
  • localhost:88/api/product/category/list/tree 树形展示分类数据需要由 mall-product 处理,于是需要为其配置相应的网关路由规则;
  • /api/product/** 开头的所有请求交给 mall-product 处理,其他微服务同理。
server:
  port: 88

spring:
  application:
    name: mall-gateway

  cloud:
    gateway:
      routes:
      	# 路径更为精确的路由放到上方(高优先级)
        - id: product_route
          uri: lb://mall-product
          predicates:
            - Path=/api/product/**
          filters:
            - RewritePath=/api/(?>.*),/$\{segment}
        - id: admin_route
          uri: lb://renren-fast
          predicates:
            - Path=/api/**
          filters:
            - RewritePath=/api/(?>.*),/renren-fast/$\{segment}

2.1.4 前端展示查询结果




总结

只做了五件事!

  1. 编写后端代码;
  2. 根据 URL 地址规则创建并编写 category.vue
  3. 定义 API 接口的基准路径地址,让所有请求全部打到 Gateway
  4. 配置 GateWay 的路由规则,让其精准的找到每个请求所对应的微服务;
  5. 配置 GateWay 的路径重写规则。

2.2 删除

逻辑删除

实体类字段上添加 @Logic 注解并且指定逻辑删除规则(逻辑删除规则也可在 application.yml 中进行全局配置)。

/**
 * 是否显示[0-不显示,1显示]
 */
@TableLogic(value = "1", delval = "0")
private Integer showStatus;

后端代码

/**
 * @ReqeustBody 获取请求体中的内容,只有 POST 请求与请求体
 * Spring MVC 自动将请求体中的数据(JSON)转换为对应的对象 Long[]
 */
@RequestMapping("/delete")
public R delete(@RequestBody Long[] catIds) {
    categoryService.removeCategoriesByIds(Arrays.asList(catIds));
    return R.ok();
}
@Override
public void removeCategoriesByIds(List<Long> catIds) {
    // TODO 判断当前删除菜单是否被其他地方引用
    this.removeByIds(catIds);
}

前端代码

util/httpRequest.js 中封装了一些拦截器:http.adornUrl 处理请求路径;http.adornParams 处理 GET 请求参数;http.adornData 处理 POST 请求参数。

category.vue

<el-tree empty-text="暂无数据..." :highlight-current="true" :data="categories" :props="defaultProps"
         accordion :expand-on-click-node="false" show-checkbox node-key="catId" :default-expanded-keys="expandedKey">
    <span class="custom-tree-node" slot-scope="{ node, data }">
        <span>{{ node.label }}span>
        <span>
            
            <el-button v-if="node.level <= 2" type="text" size="mini" @click="() => append(data)">Appendel-button>
            
            <el-button v-if="node.childNodes.length === 0" type="text" size="mini" @click="() => remove(node, data)">Deleteel-button>
        span>
    span>
el-tree>
data() {
    return {
      	...
        expandedKey: []
      	...
    }
}

remove(node, data) {
    let ids = [data.catId];

    // Confirm 是否删除
    this.$confirm(`此操作将永久删除【${data.name}】分类, 是否继续?`, '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'error'
    }).then(() => {
        this.$http({
            url: this.$http.adornUrl("/product/category/delete"),
            method: "post",
            data: this.$http.adornData(ids, false)
        }).then(() => {
            this.$message({
                type: 'success',
                dangerouslyUseHTMLString: true,     // 使用 HTML 片段
                message: '删除成功!',
                center: true,        // 文字居中
                showClose: true     // 可关闭
            })
            this.load();
            // 设置默认展开的分类
            this.expandedKey = [node.parent.data.catId]
        })
    }).catch(() => {
        this.$message({
            type: 'info',
            dangerouslyUseHTMLString: true,
            message: '已取消删除!',
            center: true,
            showClose: true
        })
    });
}

2.3 新增

后端代码

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

前端代码

<el-dialog title="添加分类" :visible.sync="dialogFormVisible" :center="true" width="30%">
    <el-form :model="category">
        <el-form-item label="分类名称" prop="name">
            <el-input v-model="category.name" autocomplete="off" @keyup.enter.native="addCategory">el-input>
        el-form-item>
    el-form>
    <div slot="footer" class="dialog-footer">
        <el-button @click="dialogFormVisible = false">取 消el-button>
        <el-button type="primary" @click="addCategory">确 定el-button>
    div>
el-dialog>
data() {
    return {
        dialogFormVisible: false,
        category: {name: "", parentCid: 0, catLevel: 0, showStatus: 1, sort: 0},
    }
}

// 弹出对话框
append(data) {
    // 当前要添加的分类的 父分类ID
    this.category.parentCid = data.catId;
    // 当前要添加的分类的 level
    this.category.catLevel = data.catLevel * 1 + 1;
    this.dialogFormVisible = true;
},

// 添加分类
addCategory() {
    this.$http({
        url: this.$http.adornUrl("/product/category/save"),
        method: "post",
        data: this.$http.adornData(this.category, false)
    }).then(() => {
        this.$message({
            type: 'success',
            dangerouslyUseHTMLString: true,
            message: '添加成功!',
            center: true,
            showClose: true
        })
        this.dialogFormVisible = false;
        this.load();
        // 设置默认展开的分类
        this.expandedKey = [this.category.parentCid]
    }).catch(() => {
        this.$message({
            type: 'info',
            dangerouslyUseHTMLString: true,
            message: '添加失败!',
            center: true,
            showClose: true
        })
        this.dialogFormVisible = false;
    })
}

2.4 修改

后端代码

@RequestMapping("/info/{catId}")
public R info(@PathVariable("catId") Long catId) {
    CategoryEntity category = categoryService.getById(catId);
    return R.ok().put("category", category);
}

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

前端代码




2.5 拖拽修改功能

2.5.1 实现拖拽效果

<el-tree ... :default-expanded-keys="expandedKey" draggable :allow-drop="allowDrop">el-tree>
maxLevel: 1


/**
 * 拖拽时判断目标节点能否被放置
 * @param draggingNode  被拖拽的节点
 * @param dropNode  结束拖拽时最后进入的节点
 * @param type 被拖拽节点的放置位置三种情况(prev、inner、next)
 */
allowDrop(draggingNode, dropNode, type) {
    this.maxLevel = draggingNode.level;
    this.countCurrentNodeDeep(draggingNode);
    let maxDeep = Math.abs(this.maxLevel - draggingNode.level) + 1;
    if (type == "inner") {
        return maxDeep + dropNode.level <= 3;
    } else {
        return maxDeep + dropNode.parent.level <= 3;
    }
},

// 求出当前节点的最大深度
countCurrentNodeDeep(node) {
    if (node.childNodes != null && node.childNodes.length > 0) {
        for (let i = 0; i < node.childNodes.length; i++) {
            if (node.childNodes[i].level > this.maxLevel) {
                this.maxLevel = node.childNodes[i].level;
            }
            this.countCurrentNodeDeep(node.childNodes[i]);
        }
    }
}

2.5.2 收集数据

被拖拽节点的 parentId 改变了;目标节点所处的层级顺序改变了;被拖拽节点的层级改变了。

  1. 注意:inner 针对目标节点,beforeafter 针对目标节点的父节点

    let parentId = 0;
    if (dropType == "before" || dropType == "after") {
      	parentId = dropNode.data.parentId;
    		siblings = dropNode.parent.childNodes;
    } else {
      	parentId = dropNode.data.catId;
      	siblings = dropNode.childNodes;
    }
    
  2. 排序:为 被拖拽节点 的 父节点 的 孩子 设置 sort 属性(遍历索引值)。

    for (let i = 0; i < siblings.length; i++) {
      	this.updateNodes.push({catId: siblings[i].data.catId, sort: i, parentCid: parentId});
    }
    
  3. 层级更新

    • 拖拽到目标节点的前或后,则 被拖拽节点的层级不变;

    • 拖拽到目标节点里面,则 被拖拽节点的层级 为 目标节点的层级 + 1(即为 siblings[i].level)。

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

Ultimate Version

updateNodes: []


/**
 * 拖拽成功完成时触发的事件
 * @param draggingNode  被拖拽的节点
 * @param dropNode  结束拖拽时最后进入的节点
 * @param dropType  被拖拽节点的放置位置(before、after、inner)
 * @param ev    event
 */
handleDrop(draggingNode, dropNode, dropType, ev) {
    // 拖拽后的 ParentId 和 兄弟节点
    let parentId = 0;
    let siblings = null;
    if (dropType == "before" || dropType == "after") {
        parentId = dropNode.parent.data.catId == undefined ? 0: dropNode.parent.data.catId;
        siblings = dropNode.parent.childNodes;
    } else {
        parentId = dropNode.data.catId;
        siblings = dropNode.childNodes;
    }
		
    // 遍历所有的兄弟节点:如果是拖拽节点,传入 catId、sort、parentCid、catLevel;如果是兄弟节点传入 catId、sort。
    for (let i = 0; i < siblings.length; i++) {
        // 如果遍历的是当前正在拖拽的节点
        if (siblings[i].data.catId == draggingNode.data.catId){
            let catLevel = draggingNode.level;
            // 当前节点的层级发生变化
            if (siblings[i].level != draggingNode.level){
                catLevel = siblings[i].level;
                // 修改子节点的层级
                this.updateChildNodeLevel(siblings[i]);
            }
            this.updateNodes.push({catId: siblings[i].data.catId, sort: i, parentCid: parentId, catLevel: catLevel});
        }else{
            this.updateNodes.push({catId: siblings[i].data.catId, sort: i});
        }
    }
    this.maxLevel = 1;
},

// 修改拖拽节点的子节点的层级
updateChildNodeLevel(node){
    if (node.childNodes.length > 0){
        for (let i = 0; i < node.childNodes.length; i++){
            // 遍历子节点,传入 catId、catLevel
            let cNode = node.childNodes[i].data;
            this.updateNodes.push({catId: cNode.catId, catLevel: node.childNodes[i].level});
            // 处理子节点的子节点
            this.updateChildNodeLevel(node.childNodes[i]);
        }
    }
}

2.5.3 批量拖拽

/**
 * 批量修改
 */
@RequestMapping("/update/sort")
public R updateBatch(@RequestBody CategoryEntity[] categoryEntities) {
		categoryService.updateBatchById(Arrays.asList(categoryEntities));
		return R.ok();
}
<div style="line-height: 35px; margin-bottom: 20px">
    <el-switch v-model="isDraggable"
               style="margin-right: 10px"
               active-text="开启拖拽"
               inactive-text="关闭拖拽"
               active-color="#13ce66"
               inactive-color="#ff4949">
    el-switch>

    <el-button v-if="isDraggable"
               type="primary"
               size="small"
               round
               @click="batchSave">
        批量保存
    el-button>
div>

<el-tree :draggable="isDraggable" ...>el-tree>
parentId: [],
isDraggable: false

// 批量修改
batchSave() {
    this.$http({
        url: this.$http.adornUrl("/product/category/update/sort"),
        method: "post",
        data: this.$http.adornData(this.updateNodes, false)
    }).then(() => {
        this.successMessage("分类顺序修改成功");
        this.load();
        this.expandedKey = this.parentId;
    }).catch(() => {
        this.errorMessage("分类顺序修改失败");
    });
    // 每次拖拽后把数据清空,否则要修改的节点将会越拖越多
    this.updateNodes = [];
},

/**
 * 拖拽成功完成时触发的事件
 * @param draggingNode  被拖拽的节点
 * @param dropNode  结束拖拽时最后进入的节点
 * @param dropType  被拖拽节点的放置位置(before、after、inner)
 * @param ev    event
 */
handleDrop(draggingNode, dropNode, dropType, ev) {
    // 拖拽后的 ParentId 和 兄弟节点
    let parentId = 0;
    let siblings = null;
    if (dropType == "before" || dropType == "after") {
        parentId = dropNode.parent.data.catId == undefined ? 0: dropNode.parent.data.catId;
        siblings = dropNode.parent.childNodes;
    } else {
        parentId = dropNode.data.catId;
        siblings = dropNode.childNodes;
    }

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

// 修改拖拽节点的子节点的层级
updateChildNodeLevel(node){
    if (node.childNodes.length > 0){
        for (let i = 0; i < node.childNodes.length; i++){
            // 遍历子节点,传入 catId、catLevel
            let cNode = node.childNodes[i].data;
            this.updateNodes.push({catId: cNode.catId, catLevel: node.childNodes[i].level});
            // 处理子节点的子节点
            this.updateChildNodeLevel(node.childNodes[i]);
        }
    }
},

/**
 * 拖拽时判断目标节点能否被放置
 * @param draggingNode  被拖拽的节点
 * @param dropNode  结束拖拽时最后进入的节点
 * @param type 被拖拽节点的放置位置三种情况(prev、inner、next)
 */
allowDrop(draggingNode, dropNode, type) {
    this.maxLevel = draggingNode.level;
    this.countCurrentNodeDeep(draggingNode);
    let maxDeep = Math.abs(this.maxLevel - draggingNode.level) + 1;
    if (type == "inner") {
        return maxDeep + dropNode.level <= 3;
    } else {
        return maxDeep + dropNode.parent.level <= 3;
    }
},

// 求出当前节点的最大深度
countCurrentNodeDeep(node) {
    if (node.childNodes != null && node.childNodes.length > 0) {
        for (let i = 0; i < node.childNodes.length; i++) {
            if (node.childNodes[i].level > this.maxLevel) {
                this.maxLevel = node.childNodes[i].level;
            }
            this.countCurrentNodeDeep(node.childNodes[i]);
        }
    }
}

2.6 批量删除

<el-button type="danger"
           size="small"
           round
           @click="batchDelete">
    批量删除
el-button>

<el-tree ... ref="categoryTree">el-tree>
// 批量删除
batchDelete() {
    // getCheckedNodes(leafOnly, includeHalfChecked):若节点可被选择(即 show-checkbox 为 true),则返回目前被选中的节点所组成的数组。
    let checkedNodes = this.$refs.categoryTree.getCheckedNodes();
    let batchDeleteCatIds = [];
    for (let i = 0; i < checkedNodes.length; i++) {
        batchDeleteCatIds.push(checkedNodes[i].catId);
    }
    console.log(checkedNodes);
    console.log(checkedNodes[0].parentCid);
    console.log(batchDeleteCatIds);

    this.$confirm("此操作将永久删除, 是否继续?", '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'error'
    }).then(() => {
        this.$http({
            url: this.$http.adornUrl("/product/category/delete"),
            method: "post",
            data: this.$http.adornData(batchDeleteCatIds, false)
        }).then(() => {
            this.successMessage("删除成功");
            this.load();
            // 设置默认展开的分类
            this.expandedKey = [checkedNodes[0].parentCid];
        }).catch(() => {
            this.errorMessage("删除失败");
        })
    }).catch(() => {
        this.errorMessage("已取消删除");
    });
}

cateogry.vue




3. 品牌管理

mall_pmspms_brand 表的设计。

字段 类型 长度 注释
brand_id bigint 20 品牌 ID
name char 50 品牌名
logo varchar 2000 品牌 logo 地址
descript longtext 介绍
show_status tinyint 4 显示状态[0-不显示;1-显示]
first_letter char 1 检索首字母
sort int 11 排序

3.1 逆向生成前端代码并优化

导入逆向工程生成的前端代码

  1. 在前端页面的 系统管理 - 菜单管理 中,新增 品牌管理 菜单,该菜单在 商品管理 目录,菜单路由为 product/brand

  2. mall/mall-product/src/main/resources/src/views/modules/product 中的 brand.vuebrand-add-or-update.vue 复制到 mall/renren-fast-vue/src/views/modules/product 处;

  3. 修改 renren-fast-vue/src/utils/index.js 中的 isAuth() 方法。

    export function isAuth (key) {
    		// return JSON.parse(sessionStorage.getItem('permissions') || '[]').indexOf(key) !== -1 || false
        return true;
    }
    

Mybatis Plus 分页插件

@Configuration
@MapperScan("com.sun.mall.product.dao")
public class MyBatisPlusConfiguration {

    @Bean
    public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
        paginationInterceptor.setOverflow(true);
        paginationInterceptor.setLimit(1000);
        return paginationInterceptor;
    }
}

优化

brand-add-or-update.vue:修改新增时的 dialog,修改显示状态为按钮。

<el-dialog ...>
		<el-form :model="dataForm" ... label-position="left" label-width="120px">
      	...
		    <el-form-item label="显示状态" prop="showStatus">
		        <el-switch v-model="dataForm.showStatus"
		                   active-color="#13ce66"
		                   inactive-color="#ff4949">
		        el-switch>
		    el-form-item>
      	...
		el-form>
  	...
el-dialog>

brand.vue:修改显示状态为按钮、前后联调。

<el-table
    :data="dataList" ... style="width: 100%;">
  	...
    <el-table-column
        prop="showStatus"
        header-align="center"
        align="center"
        label="显示状态">
        <template slot-scope="scope">
            <el-switch v-model="scope.row.showStatus"
                       active-color="#13ce66"
                       inactive-color="#ff4949"
                       :active-value="1"
                       :inactive-value="0"
                       @change="updateBrandStatus(scope.row)">
            el-switch>
        template>
    el-table-column>
  	...
el-table>
updateBrandStatus(data) {
    let {brandId, showStatus} = data;
    this.$http({
        url: this.$http.adornUrl("/product/brand/update"),
        method: "post",
        data: this.$http.adornData({brandId, showStatus}, false)
    }).then(() => {
        this.successMessage("修改成功");
    }).catch(() => {
        this.errorMessage("修改失败");
    })
}

3.2 OSS 文件上传

3.2.1 简介

OSS Object Storage Service 对象存储服务

  • Bucket 存储空间:用于存储对象的容器;
  • Object 对象/文件:OSS 存储数据的基本单元;
  • Region 地域:OSS 的数据中心所在的物理位置;
  • Endpoint 访问域名:OSS 对外服务的访问域名;
  • AccessKey 访问密钥:访问身份验证中用到的 AccessKeyId 和 AccessKeySecret。

上传方式

  • 普通上传

    1. 用户将文件上传到应用服务器,上传请求提交到网关,通过网关路由给 mall-product 处理;

    2. mall-product 中通过 Java 代码将文件上传到 OSS。

      • 上传慢:用户数据需先上传到应用服务器,之后再上传到OSS,网络传输时间比直传到OSS多一倍。如果用户数据不通过应用服务器中转,而是直传到OSS,速度将大大提升。

      • 扩展性差:如果后续用户数量逐渐增加,则应用服务器会成为瓶颈。

  • 服务端签名后直传

    1. 用户请求应用服务器获取一个上传策略 Policy;
    2. 应用服务器利用账号和密码生成一个防伪签名(包括授权令牌、上传到哪个位置等的相关信息)和 上传 Policy 返回给用户;
    3. 用户带着这个防伪签名直接上传到 OSS,OSS 对该防伪签名进行验证后接收上传。

3.2.2 普通上传

aliyun-sdk-oss

  1. 导入 aliyun-sdk-oss 依赖;

    <dependency>
        <groupId>com.aliyun.ossgroupId>
        <artifactId>aliyun-sdk-ossartifactId>
        <version>3.5.0version>
    dependency>
    
  2. 编写测试代码。

    @Test
    public void uploadTest() throws FileNotFoundException {
        String endpoint = "xxx";
        String accessKeyId = "xxx";
        String accessKeySecret = "xxx";
        String bucketName = "xxx";
        String objectName = "test/mountain.jpg";
        String filePath = "/Users/sun/Pictures/3.jpg";
    
        // 创建 OSSClient 实例
        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
        // 上传文件流
        InputStream inputStream = new FileInputStream(filePath);
        // 创建 PutObject 请求
        ossClient.putObject(bucketName, objectName, inputStream);
    }
    

/Users/sun/Pictures/3.jpg 被上传到了 OSS 中的 test 目录下,名称为 mountain.jpg

spring-cloud-starter-alicloud-oss

  1. 导入 spring-cloud-starter-alicloud-oss 依赖;

    <dependency>
        <groupId>com.alibaba.cloudgroupId>
        <artifactId>spring-cloud-starter-alicloud-ossartifactId>
    dependency>
    
  2. 在配置文件中配置 OSS 服务对应的 accessKeysecretKeyendpoint

    spring:
      cloud:
        alicloud:
          access-key: ?
          secret-key: ?
          oss:
            endpoint: ?
    
  3. 注入 OSSClient 并进行文件上传下载等操作。

    @Autowired
    private OSSClient ossClient;
    
    @Test
    public void uploadTest() {
        String bucketName = "xxx";
        String objectName = "test/sky.jpg";
        String filePath = "/Users/sun/Pictures/12.jpg";
        ossClient.putObject(bucketName, objectName, new File(filePath));
    }
    

/Users/sun/Pictures/12.jpg 被上传到了 OSS 中的 test 目录下,名称为 sky.jpg

3.2.3 服务端签名后直传

创建 mall-third-party 模块,整合一系列第三方服务。

  1. 导入 mall-common(排除 MP)spring-cloud-starter-alicloud-osswebopenfeign 等相关依赖。

  2. 主启动类上标注 @EnableDiscoveryClient 注解。

  3. 创建并配置 bootstrap.yml

    spring:
      application:
        name: mall-third-party
      cloud:
        nacos:
          discovery:
            server-addr: localhost:8848
          config:
            server-addr: localhost:8848
            file-extension: yml
            namespace: e16311c7-1c51-4902-8866-0245ddcd4dd1
    
  4. 在 Nacos 中创建并配置 mall-third-party.yml

    server:
      port: 30000
    
    spring:
      cloud:
        alicloud:
          access-key: 
          secret-key: 
          oss:
          	bucket: itsawaysu
            endpoint: oss-cn-shanghai.aliyuncs.com
    
  5. 在 Nacos 中的 mall-gateway.yml 对该模块进行网关配置。

    server:
      port: 88
    
    spring:
      application:
        name: mall-gateway
    
      cloud:
        gateway:
          routes:
            - id: third_party_route
              uri: lb://mall-third-party
              predicates:
                - Path=/api/thirdparty/**
              filters:
                - RewritePath=/api/thirdparty(?>.*),/$\{segment}
    

用户请求应用服务器获取上传 Policy

  1. Web 前端请求应用服务器,获取上传所需参数(如 accessKeyIdpolicycallback 等参数);
  2. 应用服务器返回相关参数;
  3. Web 前端直接向 OSS 服务发起上传文件请求;
  4. 文件上传后 OSS 服务会回调应用服务器的回调接口(此处不实现);
  5. 应用服务器返回响应给 OSS 服务(此处不实现);
  6. OSS 服务将应用服务器的回调接口内容返回给 Web 前端。
@RestController
@RequestMapping("/oss")
public class OSSController {

    @Autowired
    private OSS ossClient;

    @Value("${spring.cloud.alicloud.oss.bucket}")
    private String bucket;

    @Value("${spring.cloud.alicloud.oss.endpoint}")
    private String endpoint;

    @Value("${spring.cloud.alicloud.access-key}")
    private String accessId;

    /**
     * 获取上传 Policy
     */
    @RequestMapping("/policy")
    protected R policy() {
        // 文件上传后的访问地址:https://mall-study-sun.oss-cn-shanghai.aliyuncs.com/test/mountain.jpg(https://bucket.endpoint/objectName)
        String host = "https://" + bucket + "." + endpoint; // host = bucket.endpoint
        String dir = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM-dd")) + "/";
        Map<String, String> respMap = null;
        try {
            long expireTime = 30;
            long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
            Date expiration = new Date(expireEndTime);
            PolicyConditions policyConditions = new PolicyConditions();
            policyConditions.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
            policyConditions.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);

            String postPolicy = ossClient.generatePostPolicy(expiration, policyConditions);
            byte[] binaryData = postPolicy.getBytes("utf-8");
            String encodedPolicy = BinaryUtil.toBase64String(binaryData);
            String postSignature = ossClient.calculatePostSignature(postPolicy);

            respMap = new LinkedHashMap<String, String>();
            respMap.put("accessid", accessId);
            respMap.put("policy", encodedPolicy);
            respMap.put("signature", postSignature);
            respMap.put("dir", dir);
            respMap.put("host", host);
            respMap.put("expire", String.valueOf(expireEndTime / 1000));
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
        return R.ok().put("data", respMap);
    }
}

访问 http://localhost:88/api/thirdparty/oss/policy 成功获取到上传 Policy 以及 防伪签名。

{
  "accessid": "LTAI5t5hCVLcicph8qh1A33p",
  "policy": "eyJleHBpcmF0aW9uIjoiMjAyMi0xMS0wM1QwNzoyMzo1Ni4wODNaIiwiY29uZGl0aW9ucyI6W1siY29udGVudC1sZW5ndGgtcmFuZ2UiLDAsMTA0ODU3NjAwMF0sWyJzdGFydHMtd2l0aCIsIiRrZXkiLCIyMDIyLzExLzAzLyJdXX0=",
  "signature": "U8uEOeOOnwbvEizYRVV1jcUb+2g=",
  "dir": "2022/11/03/",
  "host": "https://mall-study-sun.oss-cn-shanghai.aliyuncs.com",
  "expire": "1667460236"
}

3.2.4 前端联调

配置 CORS

客户端进行表单直传到 OSS 时,会从浏览器向 OSS 发送带有 Origin 的请求消息。OSS 对带有 Origin 头的请求消息会进行跨域规则(CORS)的验证。因此需要为 Bucket 设置跨域规则以支持 Post 方法。

  1. 单击 Bucket 列表,然后单击目标 Bucket 名称。
  2. 在左侧导航栏,选择 数据安全 > 跨域设置,然后在 跨域设置 区域,单击 设置
  3. 单击 创建规则

brand.vue:显示上传的图片。

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

brand-add-or-update.vue:导入 SingleUpload 组件;将显示状态按钮的值从 布尔 转换为 数字(0、1)。




SingleUpload.vue




3.3 数据校验

3.3.1 前端表单校验

brand-add-or-update.vue

<el-form :model="dataForm"
         :rules="dataRule" ...>
    <el-form-item label="检索首字母" prop="firstLetter">
        <el-input v-model="dataForm.firstLetter" placeholder="检索首字母">el-input>
    el-form-item>
		
    <el-form-item label="排序" prop="sort">
        <el-input v-model.number="dataForm.sort" placeholder="排序">el-input>
    el-form-item>
el-form>
export default {
    name: "brand-add-or-update",
    components: {singleUpload},
    data() {
        return {
            ...
            },
            dataRule: {
                name: [
                    {required: true, message: '品牌名不能为空', trigger: 'blur'}
                ],
                logo: [
                    {required: true, message: '品牌Logo地址不能为空', trigger: 'blur'}
                ],
                descript: [
                    {required: true, message: '介绍不能为空', trigger: 'blur'}
                ],
                showStatus: [
                    {required: true, message: '显示状态不能为空', 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'}
                ]
            }
        }
    },
  	...
}

3.3.2 JSR303

JSR303 数据校验

  1. 给实体类标注校验注解 —— javax.validation.constraints 包下的注解,并自定义错误提示消息。

    • @NotNull :不能为 null
    • @NotEmpty :该注解标注的 String、Collection、Map、数组 不能为 null 、长度不能为 0;
    • @NotBlank :适用于 String,不能为 null 、不能为空(纯空格的 String)。
    @NotBlank(message = "name 不能为空~")
    private String name;
    
    @URL(message = "logo 必须是一个合法的 URL 地址~")
    @NotBlank(message = "logo 不能为 null~")
    private String logo;
    
    @NotBlank(message = "firstLetter 不能为 null~")
    @Pattern(regexp = "^[a-zA-Z]$", message = "firstLetter 必须是一个字母~")
    private String firstLetter;
    
    @NotNull(message = "sort 不能为 null~")
    @Min(value = 0, message = "sort 的值必须大于 0~")
    private Integer sort;
    
  2. 开启校验功能:发送请求提交数据时,告诉 Spring MVC 数据需要校验 —— 在需要校验的 Bean 前标注 @Valid 注解

    @RequestMapping("/save")
    public R save(@Valid @RequestBody BrandEntity brand) {
        brandService.save(brand);
        return R.ok();
    }
    
  3. 效果:校验错误后有一个默认的响应。

  4. 在需要校验的 Bean 后紧跟一个 BindingResult,能够获取到校验结果。

    @RequestMapping("/save")
    public R save(@Valid @RequestBody BrandEntity brand, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            Map<String, String> map = new HashMap<>();
            // 获取校验的错误结果
            bindingResult.getFieldErrors().forEach(item -> {
                // 错误的属性名称
                String field = item.getField();
                // 错误提示
                String defaultMessage = item.getDefaultMessage();
                map.put(field, defaultMessage);
            });
            return R.error(400, "所提交的数据不合法!").put("data", map);
        } else {
            brandService.save(brand);
        }
        return R.ok();
    }
    

3.3.3 统一异常处理

之后的代码中有很多业务需要使用校验功能,意味着在 Controller 中的代码是重复的,每次都需要重复书写,很麻烦;可以做一个统一的处理,统一异常处理

统一异常处理

  1. Controller 中只需要关注业务代码:

    @RequestMapping("/save")
    public R save(@Valid @RequestBody BrandEntity brand) {
        brandService.save(brand);
        return R.ok();
    }
    
  2. 创建异常处理类,对 MethodArgumentNotValidException 数据校验异常进行统一处理。

    @RestControllerAdvice(basePackages = "com.sun.mall.product.controller")
    @Slf4j
    public class MallExceptionControllerAdvice {
    
        @ExceptionHandler(MethodArgumentNotValidException.class)
        public R handleDataVerificationException(MethodArgumentNotValidException e) {
            log.error("异常类型:{};异常信息:{}", e.getClass(), e.getMessage());
            Map<String, String> map = new HashMap<>();
            BindingResult bindingResult = e.getBindingResult();
            bindingResult.getFieldErrors().forEach(fieldError -> map.put(fieldError.getField(), fieldError.getDefaultMessage()));
            return R.error(400, "数据校验失败").put("data", map);
        }
    }
    

mall-common 创建 BizCodeEnum 错误提示枚举。

public enum BizCodeEnum {
    UNKNOWN_EXCEPTION(50000, "系统异常"),
    VALID_EXCEPTION(50001, "参数格式校验失败");
    
    private Integer code;
    private String msg;

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

    public Integer getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }
}
@RestControllerAdvice(basePackages = "com.sun.mall.product.controller")
@Slf4j
public class MallExceptionControllerAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public R handleDataVerificationException(MethodArgumentNotValidException e) {
        log.error("异常类型:{};异常信息:{}", e.getClass(), e.getMessage());
        Map<String, String> map = new HashMap<>();
        BindingResult bindingResult = e.getBindingResult();
        bindingResult.getFieldErrors().forEach(fieldError -> map.put(fieldError.getField(), fieldError.getDefaultMessage()));
        return R.error(BizCodeEnum.VALID_EXCEPTION.getCode(), BizCodeEnum.VALID_EXCEPTION.getMsg()).put("data", map);
    }

    @ExceptionHandler(Throwable.class)
    public R handleGlobalException(Throwable throwable) {
        log.error("异常类型:{};异常信息:{}", throwable.getClass(), throwable.getMessage());
        return R.error(BizCodeEnum.UNKNOWN_EXCEPTION.getCode(), BizCodeEnum.UNKNOWN_EXCEPTION.getMsg());
    }
}

3.3.4 分组校验

  1. 新增接口作为标识;

    package com.sun.mall.common.validation;
    
    /**
     * @author sun
     * 新增标识
     */
    public interface AddGroup {
    }
    
    /**
     * @author sun
     * 修改标识
     */
    public interface UpdateGroup {
    }
    
  2. 对校验注解的 groups 属性进行编辑(什么情况下需要进行校验);

    @Null(message = "新增时不能指定 ID", groups = {AddGroup.class})
    @NotNull(message = "修改时必须指定 ID", groups = {UpdateGroup.class})
    @TableId
    private Long brandId;
    
    @NotBlank(message = "name 不能为空~", groups = {AddGroup.class, UpdateGroup.class})
    private String name;
    
    @URL(message = "logo 必须是一个合法的 URL 地址~", groups = {UpdateGroup.class})
    @NotBlank(message = "logo 不能为 null~", groups = {AddGroup.class})
    private String logo;
    
    @NotBlank(message = "firstLetter 不能为 null~")
    @Pattern(regexp = "^[a-zA-Z]$", message = "firstLetter 必须是一个字母~")
    private String firstLetter;
    
    @NotNull(message = "sort 不能为 null~")
    @Min(value = 0, message = "sort 的值必须大于 0~")
    private Integer sort;
    
  3. 使用 @Validated 注解,并标注什么情况下对 Bean 进行校验。

    @RequestMapping("/save")
    public R save(@Validated({AddGroup.class}) @RequestBody BrandEntity brand) {
      brandService.save(brand);
      return R.ok();
    }
    
    @RequestMapping("/update")
    public R update(@Validated({UpdateGroup.class}) @RequestBody BrandEntity brand) {
      brandService.updateById(brand);
      return R.ok();
    }
    
  4. 测试

    • /save:校验 brandIdnamelogo 属性;/update:校验 brandIdnamelogo 属性。

    • 注意:若请求接口标注了 @Validated({XxxGroup}),默认未指定的分组的校验注解不生效;例如:firstLettersort 属性。

3.3.5 自定义校验

  1. 编写一个自定义的校验注解;
  2. 编写一个自定义的校验器;
  3. 关联自定义的校验器和自定义的校验注解。
  1. 导入 validation 依赖;
<dependency>
  <groupId>javax.validationgroupId>
  <artifactId>validation-apiartifactId>
  <version>2.0.1.Finalversion>
dependency>
  1. mall-common/src/main/resources 目录下创建 ValidationMessages.properties提供错误提示
com.sun.mall.common.validation.OptionalValue.message="必须提交指定值"
  1. 自定义注解;
@Documented
// 关联此注解和校验器
@Constraint(validatedBy = {OptionalValueConstraintValidator.class})
// 指定此注解可以标记在什么位置
// 此处可以指定多个不同的校验器,若需要校验 Double 类型的数据,再定义一个校验器 implements ConstraintValidator 即可。
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
// 指定此注解的生命周期
@Retention(RetentionPolicy.RUNTIME)
public @interface OptionalValue {
	// 首先需要满足 JSR303 的规范,即要有 message、groups、Payload 三个属性。
	
  // 出错时的错误提示从哪获取
  String message() default "{com.sun.mall.common.validation.OptionalValue.message}";

  // 支持分组校验功能
  Class<?>[] groups() default {};

  // 负载信息
  Class<? extends Payload>[] payload() default {};

  // 注解的配置项
  int[] values() default {};
}
  1. 自定义校验器;
public class OptionalValueConstraintValidator implements ConstraintValidator<OptionalValue, Integer> {

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

  /**
   * 初始化方法
   * @param constraintAnnotation  注解中提交的值
   */
  @Override
  public void initialize(OptionalValue constraintAnnotation) {
      int[] values = constraintAnnotation.values();
      for (int value : values) {
          set.add(value);
      }
  }

  /**
   * 判断是否校验成功
   * @param integer  需要校验的值
   */
  @Override
  public boolean isValid(Integer integer, ConstraintValidatorContext constraintValidatorContext) {
      // 注解中指定的值 是否包含 提交的值
      return set.contains(integer);
  }
}
  1. 标注自定义校验注解。
/**
* 显示状态[0-不显示;1-显示]
* @OptionalValue 可选值为 ?
*/
@OptionalValue(values = {0, 1}, groups = {AddGroup.class, UpdateGroup.class})
private Integer showStatus;

BrandEntity

@Data
@TableName("pms_brand")
public class BrandEntity implements Serializable {
	private static final long serialVersionUID = 1L;

	/**
	 * 品牌id
	 */
	@Null(message = "新增时不能指定 ID", groups = {AddGroup.class})
	@NotNull(message = "修改时必须指定 ID", groups = {UpdateGroup.class})
	@TableId
	private Long brandId;

	/**
	 * 品牌名
	 */
	@NotBlank(message = "name 不能为空~", groups = {AddGroup.class, UpdateGroup.class})
	private String name;

	/**
	 * 品牌logo地址
	 */
	@URL(message = "logo 必须是一个合法的 URL 地址~", groups = {AddGroup.class, UpdateGroup.class})
	@NotBlank(message = "logo 不能为 null~", groups = {AddGroup.class})
	private String logo;

	/**
	 * 介绍
	 */
	private String descript;

	/**
	 * 显示状态[0-不显示;1-显示]
	 * @OptionalValue 可选值为 ?
	 */
	@NotNull(groups = {AddGroup.class, UpdateStatusGroup.class})
	@OptionalValue(values = {0, 1}, groups = {AddGroup.class, UpdateStatusGroup.class})
	private Integer showStatus;

	/**
	 * 检索首字母
	 */
	@NotBlank(message = "firstLetter 不能为 null~", groups = {AddGroup.class})
	@Pattern(regexp = "^[a-zA-Z]$", message = "firstLetter 必须是一个字母~", groups = {AddGroup.class, UpdateGroup.class})
	private String firstLetter;

	/**
	 * 排序
	 */
	@NotNull(message = "sort 不能为 null~", groups = {AddGroup.class})
	@Min(value = 0, message = "sort 的值必须大于 0~", groups = {AddGroup.class, UpdateGroup.class})
	private Integer sort;
}

4. 属性分组

4.1 属性相关概念

SPU Standard Product Unit 标准化产品单元
一组可复用、易检索的标准化信息的集合,该集合 描述了一个产品的特性
例如:iPhone 11 就是一个 SPU。

SKU Stock Keeping Unit 库存量单位
库存进出计量的基本单元,每种产品对应有唯一的 SKU 号。
例如:iPhone 11 白色 128G,可以确定 商品价格 和 库存 的集合,称为 SPU。

规格参数
产品会包含一个 规格参数,规格参数由 属性组属性 组成。

4.2 抽取公共组件 & 属性分组页面

子组件向父组件传递数据

  1. 父组件给子组件绑定自定义事件。

    <category @tree-node-click="treeNodeClick">category>
    
  2. 子组件触发自定义事件并且传递数据;自定义方法被触发后,父组件中的回调函数被调用。

    this.$emit("tree-node-click");
    
  3. 解绑。

    this.$off('tree-node-click');
    

抽取公共组件:src/views/modules/common/category.vue




执行 sys_menus.sql 代码生成菜单;在 src/views/modules/product 目录下 创建 attrgroup.vue;左边显示 三级分类,右边显示 属性分组。




4.3 根据 分类ID 获取 属性分组

BackEnd

/**
 * 根据 分类ID 查询 属性分组
 */
@RequestMapping("/list/{catelogId}")
public R listByCatelogId(@RequestParam Map<String, Object> params, @PathVariable("catelogId") Long catelogId){
    PageUtils page = attrGroupService.queryAttrGroupsByCategoryId(params, catelogId);
    return R.ok().put("page", page);
}
@Override
public PageUtils queryAttrGroupsByCategoryId(Map<String, Object> params, Long catelogId) {
    LambdaQueryWrapper<AttrGroupEntity> wrapper = new LambdaQueryWrapper<>();
    if (catelogId != 0) {
        wrapper.eq(AttrGroupEntity::getCatelogId, catelogId);
    }

    String key = (String) params.get("key");
    if (StrUtil.isNotBlank(key)) {
        // select * from psm_attr_group where catelog_id = catelogId and (attr_group_id = key or attr_group_name like %key%)
        wrapper.and(one -> {
            one.eq(AttrGroupEntity::getAttrGroupId, key).or().like(AttrGroupEntity::getAttrGroupName, key);
        });
    }
    
    IPage<AttrGroupEntity> page = this.page(new Query<AttrGroupEntity>().getPage(params), wrapper);
    return new PageUtils(page);
}

FrontEnd

data() {
    return {
        catId: 0,
      	...
    }
}


treeNodeClick(data, node, component) {
    // console.log("父组件:", data, node, component);
    if (node.level == 3) {
        this.catId = data.catId;
        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
    })
}

4.4 新增分组 & 级联选择器

点击新增的时候,所属分类 应该是一个级联选择器,而不是手动输入一个分类ID。(注意:只能选择 三级分类!)

级联选择器

  • Cascader 的 options 属性指定选项数组后(通过后端获取),即可渲染出一个级联选择器。
  • props 属性
    • value :指定选项的值为选项对象的某个属性值;
    • label :指定选项标签为选项对象的某个属性值;
    • children :指定选项的字选项为选项对象的某个属性值。

		

data() {
  	return {
      	props: {
            // 指定选项的值为对象的某个属性值
            value: "catId",
            // 指定选项标签为对象的某个属性值
            label: "name",
            // 指定选项的子选项为对象的某个属性值
            children: "children"
    		},
    		categories: [],
      	...
    }
},
created() {
    this.getCategories();
},
methods: {
    getCategories() {
        this.$http({
            url: this.$http.adornUrl("/product/category/list/tree")
        }).then(({data}) => {
            this.categories = data.data;
        }).catch(error => {
            console.log(error);
        })
    },
		...
}

三级分类后面还有一个空选项

  • 出现原因:三级分类的 children[],级联选择器会对该空数组进行渲染。
  • 解决方案:如果 children 属性为空,后端不会将该 children 值发送到前端。

通过 @JsonInclude 注解实现,value 常用值:

  • JsonInclude.Include.ALWAYS :默认策略,始终包含该属性;
  • JsonInclude.Include.NON_NULL :不为 null 的时候包含该属性;
  • JsonInclude.Include.NON_EMPTY :不为 null、空字符串、空容器 等情况时包含该属性。
@TableField(exist = false)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private List<CategoryEntity> children;

此时报错:Invalid prop: type check failed for prop "value". Excepted Array, got String.

说明 catelogId 存储的是一个数组;选择一个三级分类,存储的内容包括 一级、二级和三级分类的 ID。因此,只需要选中该数组中的最后一个值(三级分类 ID)即可

  • catelogPath(数组)与级联选择器绑定;
  • 提交 catelogPath 数组最后一个元素。
<el-form-item label="所属分类ID" prop="catelogId">
    <el-cascader v-model="dataForm.catelogPath" :options="categories" :props="props">el-cascader>
el-form-item>

data() {
		catelogId: 0,
    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,
                  	// 提交 catelogPath 数组中的最后一个值
                    catelogId: this.dataForm.catelogPath[this.dataForm.catelogPath.length - 1]
                })
            }).then(({data}) => {
                if (data && data.code === 0) {
                    this.$message({
                        message: '操作成功',
                        type: 'success',
                        duration: 1500,
                        onClose: () => {
                            this.visible = false
                          	// 触发父组件的 refreshDataList 事件后,父组件监听到后调用 getDataList() 函数刷新页面。
                            this.$emit('refreshDataList')
                        }
                    })
                } else {
                    this.$message.error(data.msg)
                }
            })
        }
    })
}

4.5 修改分组 & 级联选择器回显

修改分组时,级联选择器无法成功回显。


<el-button type="text" size="small" @click="addOrUpdateHandle(scope.row.attrGroupId)">修改el-button>
addOrUpdateHandle(id) {
    this.addOrUpdateVisible = true;
    // 当前组件完全渲染完毕(下一次 DOM 更新结束后)后,调用其指定的回调函数
    this.$nextTick(() => {
      	// 
      	// 调用 AddOrUpdate 组件的 init(id) 方法
        this.$refs.addOrUpdate.init(id);
    })
}
init(id) {
    this.dataForm.attrGroupId = id || 0;
    this.visible = true;
    this.$nextTick(() => {
        this.$refs['dataForm'].resetFields();
      	// 如果 groupId 不为空,则通过 groupId 查询该属性分组的信息,并且填入表格进行回显。
        if (this.dataForm.attrGroupId) {
            this.$http({
                url: this.$http.adornUrl(`/product/attrGroup/info/${this.dataForm.attrGroupId}`),
                method: 'get',
                params: this.$http.adornParams()
            }).then(({data}) => {
                if (data && data.code === 0) {
                  	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;
                  	// 查询出当前三级分类的完整路径,即 [一级分类ID, 二级分类ID, 三级分类ID]
                  	this.dataForm.catelogPath = data.attrGroup.catelogPath;
                }
            })
        }
    })
}

后端代码修改

/**
 * AttrGroupEntity
 *
 * 三级分类完整路径 [一级分类ID, 二级分类ID, 三级分类ID]
 */
private Long[] catelogPath;
/**
 * AttrGroupController
 *
 * 根据 属性分组ID 获取属性分组(根据 分类ID 获取其完整路径)
 */
@RequestMapping("/info/{attrGroupId}")
public R info(@PathVariable("attrGroupId") Long attrGroupId) {
    AttrGroupEntity attrGroup = attrGroupService.getById(attrGroupId);
    Long catelogId = attrGroup.getCatelogId();
    Long[] catelogPath = categoryService.getCompletePathByCategoryId(catelogId);
  	attrGroup.setCatelogPath(catelogPath);
    return R.ok().put("attrGroup", attrGroup);
}
@Override
public Long[] getCompletePathByCategoryId(Long catId) {
    List<Long> completePath = new ArrayList<>();
    completePath = getParentCategoryId(catId, completePath);
    Collections.reverse(completePath);
    return completePath.toArray(new Long[completePath.size()]);
}

private List<Long> getParentCategoryId(Long catId, List<Long> completePath) {
    CategoryEntity category = getById(catId);
    completePath.add(category.getCatId());
    if (category.getParentCid().longValue() != 0) {
        getParentCategoryId(category.getParentCid(), completePath);
    }
    return completePath;
}

开启搜索选项 & 清空新增弹窗


<el-cascader v-model="dataForm.catelogPath"
             :options="categories"
             :props="props"
             placeholder="试试搜索:手机"
             filterable>
el-cascader>

<el-dialog ... @closed="closeDialog">

closeDialog() {
		this.dataForm.catelogPath = [];
}

4.6 品牌管理分页功能

配置 MyBatis Plus 分页插件

@Configuration
@MapperScan("com.sun.mall.product.dao")
public class MyBatisPlusConfiguration {

    @Bean
    public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
        paginationInterceptor.setOverflow(true);
        paginationInterceptor.setLimit(1000);
        return paginationInterceptor;
    }
}

模糊查询

@Override
public PageUtils queryPage(Map<String, Object> params) {
    String key = (String) params.get("key");
    LambdaQueryWrapper<BrandEntity> wrapper= new LambdaQueryWrapper<>();
    if (StrUtil.isNotBlank(key)) {
        wrapper.like(BrandEntity::getBrandId, key).or().like(BrandEntity::getName, key);
    }
    IPage<BrandEntity> page = this.page(new Query<BrandEntity>().getPage(params), wrapper);
    return new PageUtils(page);
}

5.关联服务

品牌和分类是多对多的关系:例如小米品牌下有多种类型的产品,而手机、平板分类下又有多种品牌的产品。pms_category_brand_relation 表中保存品牌和分类的多对多关系。

中间表增加冗余字段,brand_namecatelog_name 字段,例如根据 brand_id 查询其对应的分类,就能直接获取到 分类名称,而不需要再去分类表中查询,从而提高查询效率。

名称 类型 说明
id bigint 主键ID
brand_id bigint 品牌ID
catelog_id bigint 分类ID
brand_name varchar 品牌名称
catalog_name varchar 分类名称

根据 brandId 查询所有的分类信息。

@GetMapping("/category/list")
public R list(@RequestParam("brandId") Long brandId){
    List<CategoryBrandRelationEntity> categoryBrandRelationEntityList = categoryBrandRelationService.getCategoriesByBrandId(brandId);
    return R.ok().put("data", categoryBrandRelationEntityList);
}
@Override
public List<CategoryBrandRelationEntity> getCategoriesByBrandId(Long brandId) {
    return lambdaQuery().eq(CategoryBrandRelationEntity::getBrandId, brandId).list();
}

新增关联关系

@RequestMapping("/save")
public R save(@RequestBody CategoryBrandRelationEntity categoryBrandRelation) {
    categoryBrandRelationService.addBrandCategoryRelation(categoryBrandRelation);
    return R.ok();
}
@Override
public void addBrandCategoryRelation(CategoryBrandRelationEntity categoryBrandRelation) {
    Long brandId = categoryBrandRelation.getBrandId();
    Long catelogId = categoryBrandRelation.getCatelogId();
    BrandEntity brand = brandService.getById(brandId);
    CategoryEntity category = categoryService.getById(catelogId);
    categoryBrandRelation.setBrandName(brand.getName());
    categoryBrandRelation.setCatelogName(category.getName());
    this.save(categoryBrandRelation);
}

更新品牌或分类的同时需要一同更新关系表的冗余字段。

/**
 * 更新品牌表,并且更新品牌分类关联表。
 */
@RequestMapping("/update")
public R update(@Validated({UpdateGroup.class}) @RequestBody BrandEntity brand) {
    brandService.updateBrandAndRelation(brand);
    return R.ok();
}
@Transactional
@Override
public void updateBrandAndRelation(BrandEntity brand) {
    this.updateById(brand);
    CategoryBrandRelationEntity relation = new CategoryBrandRelationEntity();
    relation.setBrandId(brand.getBrandId());
    relation.setBrandName(brand.getName());
    if (StrUtil.isNotBlank(brand.getName())) {
        categoryBrandRelationService.update(
                relation,
                new UpdateWrapper<CategoryBrandRelationEntity>().eq("brand_id", brand.getBrandId())
        );
    }
}
/**
 * 更新分类表,并且更新品牌分类关联表。
 */
@RequestMapping("/update")
public R update(@RequestBody CategoryEntity category) {
		categoryService.updateCategoryAndRelation(category);
		return R.ok();
}
@Transactional
@Override
public void updateCategoryAndRelation(CategoryEntity category) {
    this.updateById(category);
    CategoryBrandRelationEntity relation = new CategoryBrandRelationEntity();
    relation.setCatelogId(category.getCatId());
    relation.setCatelogName(category.getName());
    if (StrUtil.isNotBlank(category.getName())) {
        categoryBrandRelationService.update(
                relation,
                new UpdateWrapper<CategoryBrandRelationEntity>().eq("catelog_id", category.getCatId())
        );
    }
}

6. 平台属性

字段 类型 长度 注释
attr_id bigint 属性ID
attr_name char 30 属性名
attr_type tinyint 属性类型[0-销售属性,1-基本属性,2-既是销售属性又是基本属性]
value_type tinyint 值类型[0-单个值,1-多个值]
search_type tinyint 是否需要检索[0-不需要,1-需要]
value_select char 255 可选值列表[逗号隔开]
icon varchar 255 属性图标
catelog_id bigint 所属分类
enable bigint 启用状态[0-禁用,1-启用]
show_desc tinyint 快速展示[是否展示在介绍上:0-否,1-是]

注意:6.1、6.2、6.3 为基本属性(规格参数),6.4、6.5、6.6 为销售属性。

6.1 新增基本属性

  • 新增所请求的 URL 为:/product/attr/save;提交该请求时会多出一个数据库中不存在的 groupId 属性,规范的做法是创建一个 VO
  • VO 视图对象:可以和表对应,也可以不对应,根据业务需求而定。接受页面传递来的数据,封装成对象;将业务处理完成的对象,封装成页面需要的数据。
@Data
public class AttrVo {
  	
    /**
     * 属性id
     */
    private Long attrId;

    /**
     * 属性名
     */
    private String attrName;

    /**
     * 是否需要检索[0-不需要,1-需要]
     */
    private Integer searchType;

    /**
     * 值类型[0-为单个值,1-可以选择多个值]
     */
    private Integer valueType;

    /**
     * 属性图标
     */
    private String icon;

    /**
     * 可选值列表[用逗号分隔]
     */
    private String valueSelect;

    /**
     * 属性类型[0-销售属性,1-基本属性,2-既是销售属性又是基本属性]
     */
    private Integer attrType;

    /**
     * 启用状态[0-禁用,1-启用]
     */
    private Long enable;

    /**
     * 所属分类
     */
    private Long catelogId;

    /**
     * 快速展示【是否展示在介绍上;0-否,1-是】,在 SKU 中仍然可以调整。
     */
    private Integer showDesc;

    /**
     * 属性分组 ID
     */
    private Long attrGroupId;
}
/**
 * 新增属性的同时信息到 属性&属性分组关联表 中
 */
@RequestMapping("/save")
public R save(@RequestBody AttrVo attrVo) {
    attrService.saveAttr(attrVo);
    return R.ok();
}
@Transactional
@Override
public void saveAttr(AttrVo attrVo) {
    AttrEntity attrEntity = new AttrEntity();
    BeanUtil.copyProperties(attrVo, attrEntity);
    this.save(attrEntity);
    AttrAttrgroupRelationEntity relation = new AttrAttrgroupRelationEntity();
    relation.setAttrId(attrEntity.getAttrId());
    relation.setAttrGroupId(attrVo.getAttrGroupId());
    relation.setAttrSort(0);
    attrAttrgroupRelationService.save(relation);
}

6.2 获取分类的基本属性

@GetMapping("/base/list/{catelogId}")
public R baseAttrList(@RequestParam Map<String, Object> params, @PathVariable("catelogId") Long catelogId) {
    PageUtils page = attrService.queryBaseAttrPage(params, catelogId);
    return R.ok().put("page", page);
}

获取到的 AttrEntity 中没有 所属分类 和 所属分组 属性。

@Override
public PageUtils queryBaseAttrPage(Map<String, Object> params, Long catelogId) {
    LambdaQueryWrapper<AttrEntity> wrapper = new LambdaQueryWrapper<>();
    if (catelogId != 0) {
        wrapper.eq(AttrEntity::getCatelogId, catelogId);
    }
    String key = (String) params.get("key");
    if (StrUtil.isNotBlank(key)) {
        wrapper.and(one -> {
            one.eq(AttrEntity::getAttrId, key).or().like(AttrEntity::getAttrName, key);
        });
    }
    IPage<AttrEntity> page = this.page(new Query<AttrEntity>().getPage(params), wrapper);
    return new PageUtils(page);
}

创建 AttrResponseVo,包含 catelogNamegroupName 属性。

@Data
public class AttrResponseVo extends AttrVo {
    /**
     * 所属分类
     */
    private String catelogName;

    /**
     * 所属分组
     */
    private String groupName;
}
@Override
public PageUtils queryBaseAttrPage(Map<String, Object> params, Long catelogId) {
    LambdaQueryWrapper<AttrEntity> wrapper = new LambdaQueryWrapper<AttrEntity>();
    if (catelogId != 0) {
        wrapper.eq(AttrEntity::getCatelogId, catelogId);
    }
    String key = (String) params.get("key");
    if (StrUtil.isNotBlank(key)) {
        wrapper.and(one -> {
            one.eq(AttrEntity::getAttrId, key).or().like(AttrEntity::getAttrName, key);
        });
    }
    IPage<AttrEntity> page = this.page(new Query<AttrEntity>().getPage(params), wrapper);
    PageUtils pageUtils = new PageUtils(page);
    List<AttrResponseVo> attrResponseVoList = page.getRecords()
            .stream()
            .map(attrEntity -> {
                AttrResponseVo attrResponseVo = new AttrResponseVo();
                BeanUtil.copyProperties(attrEntity, attrResponseVo);
              	
                AttrAttrgroupRelationEntity relation = attrAttrgroupRelationService
                  .lambdaQuery()
                  .eq(AttrAttrgroupRelationEntity::getAttrId, attrResponseVo.getAttrId())
                  .one();
                if (ObjectUtil.isNotNull(relation)) {
                  AttrGroupEntity attrGroup = attrGroupService.getById(relation.getAttrGroupId());
                  attrResponseVo.setGroupName(attrGroup.getAttrGroupName());
                }

                CategoryEntity category = categoryService.getById(attrResponseVo.getCatelogId());
                if (ObjectUtil.isNotNull(category)) {
                    attrResponseVo.setCatelogName(category.getName());
                }
                return attrResponseVo;
            })
            .collect(Collectors.toList());
    pageUtils.setList(attrResponseVoList);
    return pageUtils;
}

6.3 修改基本属性

修改属性之前需要先通过 product/attrGroup/info/{attrId} 获取到其详细信息,例如:分组名称、分类的完整路径等。

@Data
public class AttrResponseVo extends AttrVo {
    /**
     * 所属分类
     */
    private String catelogName;

    /**
     * 所属分组
     */
    private String groupName;

    /**
     * 属性的完整路径
     */
    private Long[] catelogPath;
}
/**
 * 通过 属性ID 获取属性的详细信息
 */
@RequestMapping("/info/{attrId}")
public R info(@PathVariable("attrId") Long attrId) {
    AttrResponseVo attr = attrService.getAttrDetailByAttrId(attrId);
    return R.ok().put("attr", attr);
}
@Override
public AttrResponseVo getAttrDetailByAttrId(Long attrId) {
    AttrEntity attrEntity = getById(attrId);
    AttrResponseVo attrResponseVo = new AttrResponseVo();
    BeanUtil.copyProperties(attrEntity, attrResponseVo);

    // 设置分组信息
    AttrAttrgroupRelationEntity relation = attrAttrgroupRelationService
            .lambdaQuery()
            .eq(AttrAttrgroupRelationEntity::getAttrId, attrId)
            .one();

    if (ObjectUtil.isNotNull(relation)) {
        attrResponseVo.setAttrGroupId(relation.getAttrGroupId());
        AttrGroupEntity attrGroup = attrGroupService.getById(relation.getAttrGroupId());
        if (ObjectUtil.isNotNull(attrGroup)) {
            attrResponseVo.setGroupName(attrGroup.getAttrGroupName());
        }
    }

    // 设置分类信息
    Long catelogId = attrEntity.getCatelogId();
    Long[] path = categoryService.getCompletePathByCategoryId(catelogId);
    attrResponseVo.setCatelogPath(path);
    CategoryEntity categoryEntity = categoryService.getById(catelogId);
    if (ObjectUtil.isNotNull(categoryEntity)) {
        attrResponseVo.setCatelogName(categoryEntity.getName());
    }

    return attrResponseVo;
}

修改 attr 的同时还需要修改 attr_attrgroup_relation 表。

@RequestMapping("/update")
public R update(@RequestBody AttrVo attrVo) {
    attrService.updateAttr(attrVo);
    return R.ok();
}
@Transactional
@Override
public void updateAttr(AttrVo attrVo) {
    AttrEntity attrEntity = new AttrEntity();
    BeanUtil.copyProperties(attrVo, attrEntity);
    this.updateById(attrEntity);

    AttrAttrgroupRelationEntity relation = new AttrAttrgroupRelationEntity();
    relation.setAttrId(attrVo.getAttrId());
    relation.setAttrGroupId(attrVo.getAttrGroupId());
    Integer count = attrAttrgroupRelationService.lambdaQuery().eq(AttrAttrgroupRelationEntity::getAttrId, attrVo.getAttrId()).count();
    if (count > 0) {
         attrAttrgroupRelationService.update(
                 relation,
                 new LambdaUpdateWrapper<AttrAttrgroupRelationEntity>().eq(AttrAttrgroupRelationEntity::getAttrId, attrVo.getAttrId())
         );
    } else {
        attrAttrgroupRelationService.save(relation);
    }
}

6.4 获取属性分组关联的销售属性

mall-common 中创建 ProductConstants

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

        private Integer code;
        private String msg;

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

        public Integer getCode() {
            return code;
        }

        public String getMsg() {
            return msg;
        }
    }
}

修改 6.2 处代码(判断是 基础属性 or 销售属性)。

/**
 * 获取分类的 基本属性 or 销售属性
 */
@GetMapping("/{attrType}/list/{catelogId}")
public R baseAttrList(@RequestParam Map<String, Object> params,
                      @PathVariable("attrType") String attrType,
                      @PathVariable("catelogId") Long catelogId) {
    PageUtils page = attrService.queryBaseAttrOrSaleAttrPage(params, catelogId);
    return R.ok().put("page", page);
}
@Override
public PageUtils queryBaseAttrOrSaleAttrPage(Map<String, Object> params, String attrType, Long catelogId) {
    // 请求路径中的 {attrType} 为 base,则查询条件为 attrType = 1;否则,查询条件为 attrType = 0。
    LambdaQueryWrapper<AttrEntity> wrapper = new LambdaQueryWrapper<AttrEntity>()
            .eq(AttrEntity::getAttrType, "base".equals(attrType)
                    ? ProductConstants.AttrEnum.ATTR_TYPE_BASE.getCode()
                    : ProductConstants.AttrEnum.ATTR_TYPE_SALE.getCode());
    if (catelogId != 0) {
        wrapper.eq(AttrEntity::getCatelogId, catelogId);
    }
    String key = (String) params.get("key");
    if (StrUtil.isNotBlank(key)) {
        wrapper.and(one -> {
            one.eq(AttrEntity::getAttrId, key).or().like(AttrEntity::getAttrName, key);
        });
    }
    IPage<AttrEntity> page = this.page(new Query<AttrEntity>().getPage(params), wrapper);
    PageUtils pageUtils = new PageUtils(page);
    List<AttrResponseVo> attrResponseVoList = page.getRecords()
            .stream()
            .map(attrEntity -> {
                AttrResponseVo attrResponseVo = new AttrResponseVo();
                BeanUtil.copyProperties(attrEntity, attrResponseVo);

                if ("base".equalsIgnoreCase(attrType)) {
                    AttrAttrgroupRelationEntity relation = attrAttrgroupRelationService
                            .lambdaQuery()
                            .eq(AttrAttrgroupRelationEntity::getAttrId, attrResponseVo.getAttrId())
                            .one();
                    if (ObjectUtil.isNotNull(relation) && relation.getAttrGroupId() != null) {
                        AttrGroupEntity attrGroup = attrGroupService.getById(relation.getAttrGroupId());
                        attrResponseVo.setGroupName(attrGroup.getAttrGroupName());
                    }
                }

                CategoryEntity category = categoryService.getById(attrResponseVo.getCatelogId());
                if (ObjectUtil.isNotNull(category)) {
                    attrResponseVo.setCatelogName(category.getName());
                }
                return attrResponseVo;
            })
            .collect(Collectors.toList());
    pageUtils.setList(attrResponseVoList);
    return pageUtils;
}

修改 6.1 处代码(判断是否为 基础属性,再同步新增到 属性-属性分组表)。

@Transactional
@Override
public void saveAttr(AttrVo attrVo) {
    AttrEntity attrEntity = new AttrEntity();
    BeanUtil.copyProperties(attrVo, attrEntity);
    this.save(attrEntity);

    if (attrVo.getAttrType().equals(ProductConstants.AttrEnum.ATTR_TYPE_BASE.getCode()) && attrVo.getAttrGroupId() != null) {
        AttrAttrgroupRelationEntity relation = new AttrAttrgroupRelationEntity();
        relation.setAttrId(attrEntity.getAttrId());
        relation.setAttrGroupId(attrVo.getAttrGroupId());
        relation.setAttrSort(0);
        attrAttrgroupRelationService.save(relation);
    }
}

修改 6.3 处代码(判断是否为 基础属性,再设置分组信息;再同步修改 or 新增到 属性-属性分组表)。

@Override
public AttrResponseVo getAttrDetailByAttrId(Long attrId) {
    AttrEntity attrEntity = getById(attrId);
    AttrResponseVo attrResponseVo = new AttrResponseVo();
    BeanUtil.copyProperties(attrEntity, attrResponseVo);

    // 设置分组信息
    if (attrEntity.getAttrType().equals(ProductConstants.AttrEnum.ATTR_TYPE_BASE.getCode())) {
        AttrAttrgroupRelationEntity relation = attrAttrgroupRelationService
                .lambdaQuery()
                .eq(AttrAttrgroupRelationEntity::getAttrId, attrId)
                .one();
        if (ObjectUtil.isNotNull(relation)) {
            attrResponseVo.setAttrGroupId(relation.getAttrGroupId());
            AttrGroupEntity attrGroup = attrGroupService.getById(relation.getAttrGroupId());
            if (ObjectUtil.isNotNull(attrGroup)) {
                attrResponseVo.setGroupName(attrGroup.getAttrGroupName());
            }
        }
    }

    // 设置分类信息
    Long catelogId = attrEntity.getCatelogId();
    Long[] path = categoryService.getCompletePathByCategoryId(catelogId);
    attrResponseVo.setCatelogPath(path);
    CategoryEntity categoryEntity = categoryService.getById(catelogId);
    if (ObjectUtil.isNotNull(categoryEntity)) {
        attrResponseVo.setCatelogName(categoryEntity.getName());
    }

    return attrResponseVo;
}
@Transactional
@Override
public void updateAttr(AttrVo attrVo) {
    AttrEntity attrEntity = new AttrEntity();
    BeanUtil.copyProperties(attrVo, attrEntity);
    this.updateById(attrEntity);

    if (attrEntity.getAttrType().equals(ProductConstants.AttrEnum.ATTR_TYPE_BASE.getCode())) {
        AttrAttrgroupRelationEntity relation = new AttrAttrgroupRelationEntity();
        relation.setAttrId(attrVo.getAttrId());
        relation.setAttrGroupId(attrVo.getAttrGroupId());
        Integer count = attrAttrgroupRelationService.lambdaQuery().eq(AttrAttrgroupRelationEntity::getAttrId, attrVo.getAttrId()).count();
        if (count > 0) {
            attrAttrgroupRelationService.update(
                    relation,
                    new LambdaUpdateWrapper<AttrAttrgroupRelationEntity>().eq(AttrAttrgroupRelationEntity::getAttrId, attrVo.getAttrId())
            );
        } else {
            attrAttrgroupRelationService.save(relation);
        }
    }
}

6.5 删除销售属性与分组的关联

根据 groupId 获取该分组所关联的属性。

/**
 * 根据 groupId 查询 当前分组关联的所有属性
 */
@GetMapping("/{groupId}/attr/relation")
public R getAssociatedAttrByGroupId(@PathVariable("groupId") Long groupId) {
    List<AttrEntity> attrEntityList = attrService.getAssociatedAttrByGroupId(groupId);
    return R.ok().put("data", attrEntityList);
}
@Override
public List<AttrEntity> getAssociatedAttrByGroupId(Long groupId) {
    return attrAttrgroupRelationService
            // 根据 groupId 获取到 List
            .lambdaQuery()
            .eq(AttrAttrgroupRelationEntity::getAttrGroupId, groupId)
            .list()
            .stream()
            // 遍历 List 获取到 List
            .map(relation -> this.getById(relation.getAttrId()))
            .collect(Collectors.toList());
}

删除 属性分组与其关联属性 的 关联关系。

@Data
public class AttrGroupRelationVo {
    private Long attrId;

    private Long attrGroupId;
}
/**
 * 删除 属性分组 和 属性 间的关联
 */
@PostMapping("/attr/relation/delete")
public R deleteTheRelations(@RequestBody List<AttrGroupRelationVo> attrGroupRelationVoList) {
    attrService.deleteTheRelations(attrGroupRelationVoList);
    return R.ok();
}
@Override
public void deleteTheRelations(List<AttrGroupRelationVo> attrGroupRelationVoList) {
    List<AttrAttrgroupRelationEntity> relationList = attrGroupRelationVoList
            .stream()
            .map(item -> {
                AttrAttrgroupRelationEntity relation = new AttrAttrgroupRelationEntity();
                BeanUtil.copyProperties(item, relation);
                return relation;
            })
            .collect(Collectors.toList());

    // DELETE FROM psm_attr_attrgroup_relation WHERE (attr_id = ? AND attr_group_id = ?) OR (attr_id = ? AND attr_group_id = ?) OR ...
    AttrAttrgroupRelationDao.deleteBatchByRelations(relationList);
}
@Mapper
public interface AttrAttrgroupRelationDao extends BaseMapper<AttrAttrgroupRelationEntity> {
		void deleteBatchByRelations(@Param("relationList") List<AttrAttrgroupRelationEntity> relationList);
}
<delete id="deleteBatchByRelations">
    DELETE FROM mall_pms.pms_attr_attrgroup_relation
        <where>
            <foreach collection="relationList" item="item" separator=" OR ">
                (attr_id = #{item.attrId} AND attr_group_id = #{item.attrGroupId})
            foreach>
        where>
delete>

6.6 获取分组未关联的属性

获取属性分组中,还没有关联的、本分类中的其他属性,方便添加新的关联。

  • 当前分组只能关联其所属的分类中的属性;
  • 当前分组只能关联其他分组未引用的属性。
/**
 * 根据 groupId 查询 当前分组未关联的所有属性
 */
@GetMapping("/{groupId}/noAttr/relation")
public R getNotAssociatedAttrByGroupId(@PathVariable("groupId") Long groupId,
                                       @RequestParam Map<String, Object> params) {
    PageUtils page = attrService.getNotAssociatedAttrByGroupId(groupId, params);
    return R.ok().put("page", page);
}
@Override
public PageUtils getNotAssociatedAttrByGroupId(Long groupId, Map<String, Object> params) {
    // 当前分组只能关联 自己所属分类中的属性;当前分组只能关联 其所属分组中其他分组未引用的属性。
    // 找到其他分组关联的属性,并且将其排除。(当前分组所关联的属性也需要排除)
    AttrGroupEntity attrGroup = attrGroupService.getById(groupId);
    Long catelogId = attrGroup.getCatelogId();

    // 找到当前 分类ID 对应的所有 attrGroup
    List<AttrGroupEntity> attrGroupEntityList = attrGroupService
            .lambdaQuery()
            .eq(AttrGroupEntity::getCatelogId, catelogId)
            .list();
    List<Long> attrGroupIdList = attrGroupEntityList
            .stream()
            .map(item -> item.getAttrGroupId())
            .collect(Collectors.toList());

    // 获取该分类下所有分组所关联的属性。
    List<AttrAttrgroupRelationEntity> relationList = attrAttrgroupRelationService
            .lambdaQuery()
            .in(AttrAttrgroupRelationEntity::getAttrGroupId, attrGroupIdList)
            .list();
    List<Long> attrIdList = relationList
            .stream()
            .map(item -> item.getAttrId())
            .collect(Collectors.toList());

    // 排除
    LambdaQueryWrapper<AttrEntity> wrapper = new LambdaQueryWrapper<AttrEntity>()
            .eq(AttrEntity::getCatelogId, catelogId)
            .eq(AttrEntity::getAttrType, ProductConstants.AttrEnum.ATTR_TYPE_BASE.getCode());
    if (!attrIdList.isEmpty() && ObjectUtil.isNotNull(attrIdList)) {
        wrapper.notIn(AttrEntity::getAttrId, attrIdList);
    }

    String key = (String) params.get("key");
    if (StrUtil.isNotBlank(key)) {
        wrapper.and(w -> {
            w.eq(AttrEntity::getAttrId, key).or().like(AttrEntity::getAttrName, key);
        });
    }
    IPage<AttrEntity> page = this.page(new Query<AttrEntity>().getPage(params), wrapper);
    return new PageUtils(page);
}

6.7 添加属性和分组的关联关系

/**
 * 添加属性和分组的关联关系
 */
@PostMapping("/attr/relation")
public R addRelation(@RequestBody List<AttrGroupRelationVo> attrGroupRelationVoList) {
    attrAttrgroupRelationService.saveBatchAssociatedRelation(attrAttrgroupRelationService);
    return R.ok();
}
@Override
public void saveBatchAssociatedRelation(List<AttrGroupRelationVo> attrGroupRelationVoList) {
    List<AttrAttrgroupRelationEntity> relationList = attrGroupRelationVoList
            .stream()
            .map(item -> {
                AttrAttrgroupRelationEntity relation = new AttrAttrgroupRelationEntity();
                BeanUtil.copyProperties(item, relation);
                return relation;
            })
            .collect(Collectors.toList());
    this.saveBatch(relationList);
}

7. 新增商品

7.1 调试会员等级相关接口

用户系统 - 会员等级:/member/memberLevel/list :获取所有会员等级(该方法已经生成,启动 mall-member 服务即可)

配置网关路由:

server:
  port: 9999

spring:
  application:
    name: mall-gateway

  cloud:
    gateway:
      routes: 
        - id: product_route
          uri: lb://mall-product
          predicates:
            - Path=/api/product/**
          filters:
            - RewritePath=/api/(?>.*),/$\{segment}
        - id: third_party_route
          uri: lb://mall-third-party
          predicates:
            - Path=/api/thirdparty/**
          filters:
            - RewritePath=/api/thirdparty(?>.*),/$\{segment}
        - id: member_route
          uri: lb://mall-member
          predicates:
            - Path=/api/member/**
          filters:
            - RewritePath=/api/(?>.*),/$\{segment}
        - id: admin_route
          uri: lb://renren-fast
          predicates:
            - Path=/api/**
          filters:
            - RewritePath=/api/(?>.*),/renren-fast/$\{segment}

7.2 获取分类关联的品牌

/**
 * 根据 catId 获取该分类关联的品牌
 */
@GetMapping("/brands/list")
public R brandRelationList(@RequestParam(value = "catId", required = true) Long catId) {
    List<BrandEntity> brandEntityList = categoryBrandRelationService.getBrandsByCatId(catId);
    List<BrandVo> brandVoList = brandEntityList
            .stream()
            .map(brandEntity -> {
                BrandVo brandVo = new BrandVo();
                brandVo.setBrandId(brandEntity.getBrandId());
                brandVo.setBrandName(brandEntity.getName());
                return brandVo;
            }).collect(Collectors.toList());
    return R.ok().put("data", brandVoList);
}
@Override
public List<BrandEntity> getBrandsByCatId(Long catId) {
    List<CategoryBrandRelationEntity> relationList = this.lambdaQuery()
            .eq(CategoryBrandRelationEntity::getCatelogId, catId)
            .list();
    return relationList
            .stream()
            .map(item -> brandService.getById(item.getBrandId()))
            .collect(Collectors.toList());
}

7.3 获取分类下所有分组及属性

@Data
public class AttrGroupWithAttrsVo {

    /**
     * 分组id
     */
    private Long attrGroupId;

    /**
     * 组名
     */
    private String attrGroupName;

    /**
     * 排序
     */
    private Integer sort;

    /**
     * 描述
     */
    private String descript;

    /**
     * 组图标
     */
    private String icon;

    /**
     * 所属分类id
     */
    private Long catelogId;

    /**
     * 分组所包含的属性
     */
    private List<AttrEntity> attrs;
}
@Override
public List<AttrGroupWithAttrsVo> getAttrGroupWithAttrsByCategoryId(Long catelogId) {
    List<AttrGroupEntity> attrGroupList = this.lambdaQuery().eq(AttrGroupEntity::getCatelogId, catelogId).list();
    return attrGroupList
            .stream()
            .map(attrGroup -> {
                AttrGroupWithAttrsVo attrGroupWithAttrsVo = new AttrGroupWithAttrsVo();
                BeanUtil.copyProperties(attrGroup, attrGroupWithAttrsVo);
                // 根据 groupId 查询当前分组关联的所有属性
                attrGroupWithAttrsVo.setAttrs(attrService.getAssociatedAttrByGroupId(attrGroup.getAttrGroupId()));
                return attrGroupWithAttrsVo;
            })
            .collect(Collectors.toList());
}

7.4 新增商品

抽取 VO

SpuSaveVo

@Data
public class SpuSaveVo {

    private String spuName;

    private String spuDescription;

    private Long catalogId;

    private Long brandId;

    private BigDecimal weight;

    private Integer publishStatus;

    /**
     * 描述信息
     */
    private List<String> decript;

    /**
     * 图片集
     */
    private List<String> images;

    /**
     * 规格参数
     */
    private List<BaseAttrs> baseAttrs;

    /**
     * 积分信息
     */
    private Bounds bounds;


    /**
     * Sku 信息
     */
    private List<Skus> skus;
}

*** BaseAttrs***

/**
 * 规格参数
 */
@Data
public class BaseAttrs {

    private Long attrId;

    private String attrValues;

    private Integer showDesc;
}

Skus

@Data
public class Skus {
    private String skuName;

    private BigDecimal price;

    private String skuTitle;

    private String skuSubtitle;
    
    private Integer fullCount;

    private BigDecimal discount;

    private Integer countStatus;

    private BigDecimal fullPrice;

    private BigDecimal reducePrice;

    private Integer priceStatus;
    
    private List<String> descar;

  	/**
     * 图片信息
     */  
  	private List<Images> images;

    /**
     * 销售属性
     */
    private List<Attr> attr;

    /**
     * 会员价格
     */
    private List<MemberPrice> memberPrice;

}

Attr

/**
 * 销售属性
 */
@Data
public class Attr {
    private Long attrId;

    private String attrName;
    
    private String attrValue;
}

Images

/**
 * 图片信息
 */
@Data
public class Images {
    private String imgUrl;

    private Integer defaultImg;
}

Bounds

/**
 *
 */
@Data
public class Bounds {
    private BigDecimal buyBounds;

    private BigDecimal growBounds;
}

MemberPrice(mall-common

/**
 * 会员价格
 */
@Data
public class MemberPrice {
    private Long id;

    private String name;

    private BigDecimal price;
}

需要保存的信息

  • SPU 基本信息:pms_spu_info
  • SPU 的描述信息:pms_spu_info_desc
  • SPU 的图片集:pms_spu_images
  • SPU 的规格参数:pms_product_attr_value
  • SPU 的积分信息:mall_sms.sms_spu_bounds
  • SPU 的对应的所有 SKU 信息。
    • SKU 的基本信息:pms_sku_info
    • SKU 的图片信息:pms_sku_images
    • SKU 的销售属性信息:pms_sku_sale_attr_value
    • SKU 的优惠、满减等信息:mall_sms.sms_sku_ladder / sms_sku_full_reduction / sms_member_price

StrUtil.join(CharSequence conjunction, Iterable iterable):以 conjunction 为分隔符将多个对象转换为字符串。

@Test
public void strJoinTest() {
    List<String> list = Arrays.asList("你好", "好好学习", "天天向上");
    System.out.println(list);
    String join = StrUtil.join(" - ", list);
    System.out.println(join);

    // [你好, 好好学习, 天天向上]
    // 你好 - 好好学习 - 天天向上
}

远程调用

  1. mall-common 中创建 SpuBoundsDTOSkuReductionDTO

    @Data
    public class SpuBoundsDTO {
        private Long spuId;
    
        private BigDecimal buyBounds;
    
        private BigDecimal growBounds;
    }
    
    @Data
    public class SkuReductionDTO {
        private Long skuId;
    
        private Integer fullCount;
    
        private BigDecimal discount;
    
        private Integer countStatus;
    
        private BigDecimal fullPrice;
    
        private BigDecimal reducePrice;
    
        private Integer priceStatus;
    
        private List<MemberPrice> memberPrice;
    }
    
  2. mall-product 主启动类上标注 @EnableFeignClients(basePackages = "com.sun.mall.product.feign") 注解;

  3. 创建 feign/CouponFeignService,调用 /coupon/spuBounds/save/coupon/spuBounds/saveInfo 两个接口;

    @Component
    @FeignClient("mall-coupon")
    public interface CouponFeignService {
        /**
         * 1. 服务启动,自动扫描主启动类上 @EnableFeignClients 注解所指向的包;即使不指定 basePackages,该注解也会扫描到标注 @FeignClient 注解的接口。
         * 2. 在该包中找到标注 @FeignClient 的接口,该注解会在注册中心中找到对应的服务。
         * 3. 当在 Controller 中调用该接口的方法时,该服务会进行处理。
         *
         * save(@RequestBody SpuBoundsDTO spuBoundsDTO)
         * @RequestBody 将这个对象转化为 JSON,找到 mall-coupon 服务;发送 /coupon/spuBounds/save 请求;将上一步转换的 JSON 放在请求体发送数据。
         *
         * save(@RequestBody SpuBoundsEntity spuBounds)
         * mall-coupon 服务接收到请求,请求体中的 JSON 数据会被 @RequestBody 转换为 SpuBoundsEntity;只要 JSON 数据是兼容的,不需要使用同一个 POJO。
         */
        @PostMapping("/coupon/spuBounds/save")
        R save(@RequestBody SpuBoundsDTO spuBoundsDTO);
    
        @PostMapping("/coupon/skuFullReduction/saveSkuReduction")
        R saveSkuReduction(@RequestBody SkuReductionDTO skuReductionDTO);
    }
    
  4. mall-coupon - SpuBoundsController & SkuFullReductionController

    @RestController
    @RequestMapping("/coupon/spuBounds")
    public class SpuBoundsController {
        @Autowired
        private SpuBoundsService spuBoundsService;
    
      	// 第一个服务已经生成,直接调用即可。
        @RequestMapping("/save")
        public R saveSpuBounds(@RequestBody SpuBoundsEntity spuBoundsEntity) {
            spuBoundsService.save(spuBoundsEntity);
            return R.ok();
        }
    }
    
    @RestController
    @RequestMapping("/coupon/skuFullReduction")
    public class SkuFullReductionController {
        @Autowired
        private SkuFullReductionService skuFullReductionService;
    
        @PostMapping("/coupon/skuFullReduction/saveSkuReduction")
        public R saveSkuReduction(@RequestBody SkuReductionDTO skuReductionDTO) {
            skuFullReductionService.saveSkuReduction(skuReductionDTO);
            return R.ok();
        }
    }
    
    // TODO 待完善
    @Transactional
    @Override
    public void saveSkuReduction(SkuReductionDTO skuReductionDTO) {
        // 满减打折、会员价 `sms_sku_ladder`
        SkuLadderEntity skuLadderEntity = new SkuLadderEntity();
        skuLadderEntity.setSkuId(skuReductionDTO.getSkuId());
        skuLadderEntity.setFullCount(skuReductionDTO.getFullCount());
        skuLadderEntity.setDiscount(skuReductionDTO.getDiscount());
        skuLadderEntity.setAddOther(skuReductionDTO.getCountStatus());
        if (skuLadderEntity.getFullCount() > 0) {
            skuLadderService.save(skuLadderEntity);
        }
    
        // 满减信息 `sms_sku_full_reduction`
        SkuFullReductionEntity skuFullReductionEntity = new SkuFullReductionEntity();
        BeanUtil.copyProperties(skuReductionDTO, skuFullReductionEntity);
        if (skuFullReductionEntity.getFullPrice().compareTo(BigDecimal.ZERO) == 1) {
            this.save(skuFullReductionEntity);
        }
    
        // 会员价格 `sms_member_price`
        List<MemberPrice> memberPriceList = skuReductionDTO.getMemberPrice();
        List<MemberPriceEntity> memberPriceEntityList = memberPriceList
                .stream()
                .filter(memberPrice -> memberPrice.getPrice().compareTo(BigDecimal.ZERO) == 1)
                .map(memberPrice -> {
                    MemberPriceEntity memberPriceEntity = new MemberPriceEntity();
                    memberPriceEntity.setSkuId(skuReductionDTO.getSkuId());
                    memberPriceEntity.setMemberLevelId(memberPrice.getId());
                    memberPriceEntity.setMemberPrice(memberPrice.getPrice());
                    memberPriceEntity.setMemberLevelName(memberPrice.getName());
                    memberPriceEntity.setAddOther(1);
                    return memberPriceEntity;
                }).collect(Collectors.toList());
        memberPriceService.saveBatch(memberPriceEntityList);
    }
    
  5. 为统一返回类 R 添加一个方法。

public Integer getCode() {
		return (Integer) this.get("code");
}

saveSpuInfo

@RequestMapping("/save")
public R save(@RequestBody SpuSaveVo spuSaveVo) {
    spuInfoService.saveSpuInfo(spuSaveVo);
    return R.ok();
}
@Resource
private SpuInfoDescService spuInfoDescService;

@Resource
private SpuImagesService spuImagesService;

@Resource
private ProductAttrValueService productAttrValueService;

@Resource
private AttrService attrService;

@Resource
private SkuInfoService skuInfoService;

@Resource
private SkuImagesService skuImagesService;

@Resource
private SkuSaleAttrValueService skuSaleAttrValueService;

@Resource
private CouponFeignService couponFeignService;

// TODO 待完善
@Transactional
@Override
public void saveSpuInfo(SpuSaveVo spuSaveVo) {
    // 1. 保存 Spu 的基本信息 - `pms_spu_info`;
    SpuInfoEntity spuInfoEntity = new SpuInfoEntity();
    BeanUtil.copyProperties(spuSaveVo, spuInfoEntity);
    spuInfoEntity.setCreateTime(new Date());
    spuInfoEntity.setUpdateTime(new Date());
    this.save(spuInfoEntity);

    // 2. 保存 Spu 的描述信息 - `pms_spu_info_desc`;
    List<String> descList = spuSaveVo.getDecript();
    SpuInfoDescEntity spuInfoDescEntity = new SpuInfoDescEntity();
    spuInfoDescEntity.setSpuId(spuInfoEntity.getId());
    spuInfoDescEntity.setDecript(StrUtil.join(", ", descList));
    spuInfoDescService.save(spuInfoDescEntity);

    // 3. 保存 Spu 的图片集 - `pms_spu_images`;
    List<String> images = spuSaveVo.getImages();
    if (CollectionUtil.isEmpty(images) || ObjectUtil.isNull(images)) {
        List<SpuImagesEntity> spuImagesList = images
                .stream()
                .map(image -> {
                    SpuImagesEntity spuImagesEntity = new SpuImagesEntity();
                    spuImagesEntity.setSpuId(spuInfoEntity.getId());
                    spuImagesEntity.setImgUrl(image);
                    return spuImagesEntity;
                })
                .collect(Collectors.toList());
        spuImagesService.saveBatch(spuImagesList);
    }

    // 4. 保存 Spu 的规格参数 - `pms_product_attr_value`;
    List<BaseAttrs> baseAttrsList = spuSaveVo.getBaseAttrs();
    List<ProductAttrValueEntity> productAttrValueList = baseAttrsList.stream()
            .map(baseAttr -> {
                ProductAttrValueEntity productAttrValue = new ProductAttrValueEntity();
                productAttrValue.setAttrId(baseAttr.getAttrId());
                productAttrValue.setAttrName(attrService.getById(baseAttr.getAttrId()).getAttrName());
                productAttrValue.setAttrValue(baseAttr.getAttrValues());
                productAttrValue.setQuickShow(baseAttr.getShowDesc());
                productAttrValue.setSpuId(spuInfoEntity.getId());
                return productAttrValue;
            }).collect(Collectors.toList());
    productAttrValueService.saveBatch(productAttrValueList);

    // 5. 保存 Spu 的积分信息 - `mall_sms.sms_spu_bounds`;
    Bounds bounds = spuSaveVo.getBounds();
    SpuBoundsDTO spuBoundsDTO = new SpuBoundsDTO();
    BeanUtil.copyProperties(bounds, spuBoundsDTO);
    spuBoundsDTO.setSpuId(spuInfoEntity.getId());
    R rpcResult = couponFeignService.save(spuBoundsDTO);
    if (rpcResult.getCode() != 0) {
        log.error("远程保存 Spu 的积分信息失败");
    }

    // 6. 保存 Spu 对应的 Sku 信息。
    List<Skus> skusList = spuSaveVo.getSkus();
    if (!skusList.isEmpty() && ObjectUtil.isNotNull(skusList)) {
        skusList.forEach(sku -> {
            // 找到默认图片(SkuDefaultImg 为 1)
            String defaultImage = "";
            for (Images image : sku.getImages()) {
                if (image.getDefaultImg() == 1) {
                    defaultImage = image.getImgUrl();
                }
            }

            // 6.1 Sku 的基本信息 - `pms_sku_info`;
            SkuInfoEntity skuInfoEntity = new SkuInfoEntity();
            BeanUtil.copyProperties(sku, skuInfoEntity);
            skuInfoEntity.setSpuId(spuInfoEntity.getId());
            skuInfoEntity.setBrandId(spuInfoEntity.getBrandId());
            skuInfoEntity.setCatalogId(spuInfoEntity.getCatalogId());
            skuInfoEntity.setSaleCount(0L);
            skuInfoEntity.setSkuDefaultImg(defaultImage);
            skuInfoService.save(skuInfoEntity);

            Long skuId = skuInfoEntity.getSkuId();

            // 6.2 Sku 的图片信息 - `pms_sku_images`;
            List<SkuImagesEntity> skuImagesList = sku.getImages()
                    .stream()
                    .filter(image -> StrUtil.isNotBlank(image.getImgUrl()))
                    .map(image -> {
                        SkuImagesEntity skuImagesEntity = new SkuImagesEntity();
                        skuImagesEntity.setSkuId(skuId);
                        skuImagesEntity.setImgUrl(image.getImgUrl());
                        skuImagesEntity.setDefaultImg(image.getDefaultImg());
                        return skuImagesEntity;
                    })
                    .collect(Collectors.toList());
            skuImagesService.saveBatch(skuImagesList);

            // 6.3 Sku 的销售属性信息 - `pms_sku_sale_attr_value`;
            List<Attr> attrList = sku.getAttr();
            List<SkuSaleAttrValueEntity> skuSaleAttrValueList = attrList
                    .stream()
                    .map(attr -> {
                        SkuSaleAttrValueEntity skuSaleAttrValueEntity = new SkuSaleAttrValueEntity();
                        BeanUtil.copyProperties(attr, skuSaleAttrValueEntity);
                        skuSaleAttrValueEntity.setSkuId(skuId);
                        return skuSaleAttrValueEntity;
                    })
                    .collect(Collectors.toList());
            skuSaleAttrValueService.saveBatch(skuSaleAttrValueList);

            // 6.4 Sku 的优惠、满减、会员信息 - `mall_sms.sms_sku_ladder / sms_sku_full_reduction / sms_member_price`。
            SkuReductionDTO skuReductionDTO = new SkuReductionDTO();
            BeanUtil.copyProperties(sku, skuReductionDTO);
            skuReductionDTO.setSkuId(skuId);
            // fullCount - 满多少件;fullPrice - 满多少钱。
            if (skuReductionDTO.getFullCount() > 0 || skuReductionDTO.getFullPrice().compareTo(BigDecimal.ZERO) == 1) {
                R anotherRpcResult = couponFeignService.saveSkuReduction(skuReductionDTO);
                if (rpcResult.getCode() != 0) {
                    log.error("远程保存 Sku 优惠信息失败");
                }
            }
        });
    }
}

8. 商品管理

8.1 SPU 检索

/**
 * SPU 检索
 */
@RequestMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
    PageUtils page = spuInfoService.querySpuInfoPageByParams(params);
    return R.ok().put("page", page);
}
@Override
public PageUtils querySpuInfoPageByParams(Map<String, Object> params) {
    LambdaQueryWrapper<SpuInfoEntity> wrapper = new LambdaQueryWrapper<>();

    String catelogId = (String) params.get("catelogId");
    if (StrUtil.isNotBlank(catelogId) && !"0".equalsIgnoreCase(catelogId)) {
        wrapper.eq(SpuInfoEntity::getCatalogId, catelogId);
    }

    String brandId = (String) params.get("brandId");
    if (StrUtil.isNotBlank(brandId) && !"0".equalsIgnoreCase(brandId)) {
        wrapper.eq(SpuInfoEntity::getBrandId, brandId);
    }

    String status = (String) params.get("status");
    if (StrUtil.isNotBlank(status)) {
        wrapper.eq(SpuInfoEntity::getPublishStatus, status);
    }

    String key = (String) params.get("key");
    if (StrUtil.isNotBlank(key)) {
        wrapper.and(w -> {
            w.eq(SpuInfoEntity::getId, key)
                    .or()
                    .like(SpuInfoEntity::getSpuName, key);
        });
    }

    IPage page = this.page(new Query().getPage(params), wrapper);
    return new PageUtils(page);
}

格式化时间格式:1. 在属性上使用 @JsonFormat 注解配置; 2. 在配置文件中对所有时间数据进行格式化。

//@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private Date createTime;
//@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private Date updateTime;
jackson:
  date-format: yyyy-MM-dd HH:mm:ss

8.2 SKU 检索

/**
 * 检索 SKU
 */
@RequestMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
    PageUtils page = skuInfoService.querySkuInfoPageByParams(params);
    return R.ok().put("page", page);
}
@Override
public PageUtils querySkuInfoPageByParams(Map<String, Object> params) {
    LambdaQueryWrapper<SkuInfoEntity> wrapper = new LambdaQueryWrapper<>();

    String catelogId = (String) params.get("catelogId");
    if (StrUtil.isNotBlank(catelogId) && !"0".equalsIgnoreCase(catelogId)) {
        wrapper.eq(SkuInfoEntity::getCatalogId, catelogId);
    }

    String brandId = (String) params.get("brandId");
    if (StrUtil.isNotBlank(brandId) && !"0".equalsIgnoreCase(brandId)) {
        wrapper.eq(SkuInfoEntity::getBrandId, brandId);
    }

    String min = (String) params.get("min");
    if (StrUtil.isNotBlank(min)) {
        wrapper.ge(SkuInfoEntity::getPrice, min);
    }

    String max = (String) params.get("max");
    if (StrUtil.isNotBlank(max) && new BigDecimal(max).compareTo(BigDecimal.ZERO) == 1) {
        wrapper.le(SkuInfoEntity::getPrice, max);
    }

    String key = (String) params.get("key");
    if (StrUtil.isNotBlank(key)) {
        wrapper.and(w -> {
            w.eq(SkuInfoEntity::getSkuId, key)
                    .or()
                    .like(SkuInfoEntity::getSkuName, key);
        });
    }

    IPage page = this.page(new Query().getPage(params), wrapper);
    return new PageUtils(page);
}

8.3 获取 SPU 规格参数

/**
 * 获取 SPU 规格
 */
@GetMapping("/base/listForSpu/${spuId}")
public R baseListForSpu(@PathVariable("spuId") Long spuId) {
    List<ProductAttrValueEntity> productAttrValueEntityList = productAttrValueService.getBaseListForSpu(spuId);
    return R.ok().put("data", productAttrValueEntityList);
}
@Override
public List<ProductAttrValueEntity> getBaseListForSpu(Long spuId) {
    return lambdaQuery().eq(ProductAttrValueEntity::getSpuId, spuId).list();
}

在 SPU 管理页面点击商品后的 规格,若出现 400 错误页面,需要在 /src/router/index.js 中添加以下代码。

谷粒商城笔记 + 前后端完整代码 + 报错问题汇总(基础篇)_第1张图片
{
    path: '/product-attrupdate',
    component: _import('modules/product/attrupdate'),
    name: 'attr-update',
    meta: {title: '规格维护', isTab: true}
}

8.4 修改 SPU 规格参数

/**
 * 修改商品规格
 */
@PostMapping("/update/{spuId}")
public R updateSpuAttr(Long spuId, List<ProductAttrValueEntity> productAttrValueList) {
    productAttrValueService.updateSpuAttr(spuId, productAttrValueList);
    return R.ok();
}
@Transactional
@Override
public void updateSpuAttr(Long spuId, List<ProductAttrValueEntity> productAttrValueList) {
    // 更新规格参数时,有的数据会新增、有的数据会修改、有的数据会被删除;可以直接删除所有的属性,然后直接新增即可。
    this.remove(lambdaQuery().eq(ProductAttrValueEntity::getSpuId, spuId));
    for (ProductAttrValueEntity productAttrValueEntity : productAttrValueList) {
        productAttrValueEntity.setSpuId(spuId);
    }
    this.saveBatch(productAttrValueList);
}

9. 仓库管理

  • wms_ware_info:每个仓库的信息;
  • wms_ware_sku:每个仓库中 SKU 商品的信息。

9.1 查询仓库信息

/**
 * 根据 params 条件分页查询展示 仓库信息
 */
@RequestMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
    PageUtils page = wareInfoService.queryWareInfoPageByParams(params);
    return R.ok().put("page", page);
}
@Override
public PageUtils queryWareInfoPageByParams(Map<String, Object> params) {
    LambdaQueryWrapper<WareInfoEntity> wrapper = new LambdaQueryWrapper<>();
    String key = (String) params.get("key");
    if (StrUtil.isNotBlank(key)) {
        wrapper.eq(WareInfoEntity::getId, key)
                .or().like(WareInfoEntity::getName, key)
                .or().like(WareInfoEntity::getAddress, key)
                .or().like(WareInfoEntity::getAreacode, key);
    }
    IPage<WareInfoEntity> page = this.page(new Query<WareInfoEntity>().getPage(params), wrapper);
    return new PageUtils(page);
}

9.2 查询商品库存信息

/**
 * 根据 params 条件分页查询展示 商品库存信息
 */
@RequestMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
    PageUtils page = wareSkuService.queryWareSkuPageByParams(params);
    return R.ok().put("page", page);
}
@Override
public PageUtils queryWareSkuPageByParams(Map<String, Object> params) {
    LambdaQueryWrapper<WareSkuEntity> wrapper = new LambdaQueryWrapper<>();

    String wareId = (String) params.get("wareId");
    if (StrUtil.isNotBlank(wareId) && !"0".equalsIgnoreCase(wareId)) {
        wrapper.eq(WareSkuEntity::getWareId, wareId);
    }

    String skuId = (String) params.get("skuId");
    if (StrUtil.isNotBlank(skuId) && !"0".equalsIgnoreCase(skuId)) {
        wrapper.eq(WareSkuEntity::getSkuId, skuId);
    }

    IPage<WareSkuEntity> page = this.page(new Query<WareSkuEntity>().getPage(params), wrapper);
    return new PageUtils(page);
}

9.3 查询采购需求

/**
 * 根据 params 条件分页查询展示 采购需求
 */
@RequestMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
    PageUtils page = purchaseDetailService.queryPurchaseDetailPageByParams(params);
    return R.ok().put("page", page);
}
@Override
public PageUtils queryPurchaseDetailPageByParams(Map<String, Object> params) {
    LambdaQueryWrapper<PurchaseDetailEntity> wrapper = new LambdaQueryWrapper<>();

    String status = (String) params.get("status");
    if (StrUtil.isNotBlank(status)) {
        wrapper.eq(PurchaseDetailEntity::getStatus, status);
    }

    String wareId = (String) params.get("wareId");
    if (StrUtil.isNotBlank(wareId) && !"0".equalsIgnoreCase(wareId)) {
        wrapper.eq(PurchaseDetailEntity::getWareId, wareId);
    }

    String key = (String) params.get("key");
    if (StrUtil.isNotBlank(key)) {
        wrapper.and(w -> {
            w.eq(PurchaseDetailEntity::getSkuId, key)
                    .or().eq(PurchaseDetailEntity::getPurchaseId, key);
        });
    }

    IPage<PurchaseDetailEntity> page = this.page(new Query<PurchaseDetailEntity>().getPage(params), wrapper);
    return new PageUtils(page);
}

9.4 合并采购需求到采购单

采购简要流程

谷粒商城笔记 + 前后端完整代码 + 报错问题汇总(基础篇)_第2张图片

创建 WareConstants 仓储服务相关的枚举类。

public class WareConstants {

    /**
     * 采购单状态枚举
     */
    public enum PurchaseStatusEnum {
        CREATED(0, "新建"),
        ASSIGNED(1, "已分配"),
        RECEIVED(2, "已领取"),
        FINISHED(3, "已完成"),
        HAS_ERROR(4, "有异常");

        private Integer code;
        private String msg;

        PurchaseStatusEnum(Integer code, String msg) {
            this.code = code;
            this.msg = msg;
        }

        public Integer getCode() {
            return code;
        }

        public String getMsg() {
            return msg;
        }
    }

    /**
     * 采购需求枚举
     */
    public enum PurchaseDetailStatusEnum {
        CREATED(0, "新建"),
        ASSIGNED(1, "已分配"),
        BUYING(2, "正在采购"),
        FINISHED(3, "已完成"),
        HAS_ERROR(4, "采购失败");

        private Integer code;
        private String msg;

        PurchaseDetailStatusEnum(Integer code, String msg) {
            this.code = code;
            this.msg = msg;
        }

        public Integer getCode() {
            return code;
        }

        public String getMsg() {
            return msg;
        }
    }

}

点击合并采购单时,需要查询出 新建、已分配 状态的采购单。

/**
 * 根据 params 条件查询展示 新建、已分配 状态的采购单
 */
@GetMapping("/unreceived/list")
public R getUnreceivedPurchase(@RequestParam Map<String, Object> params) {
    PageUtils page = purchaseService.queryUnreceivedPurchasePageByParams(params);
    return R.ok().put("page", page);
}
@Override
public PageUtils queryUnreceivedPurchasePageByParams(Map<String, Object> params) {
    IPage<PurchaseEntity> page = this.page(
            new Query<PurchaseEntity>().getPage(params),
            new LambdaQueryWrapper<PurchaseEntity>()
                    // 0-新建;1-已分配
                    .eq(PurchaseEntity::getStatus, WareConstants.PurchaseStatusEnum.CREATED.getCode())
                    .or()
                    .eq(PurchaseEntity::getStatus, WareConstants.PurchaseStatusEnum.ASSIGNED.getCode())
    );
    return new PageUtils(page);
}

合并采购需求到采购单

  • 创建一个用户,然后将采购单分配给该用户;
  • 此时点击合并采购需求时,即可查询到 分配到该用户的那个采购单。
@Data
public class MergeVo {
    /**
     * 整单 ID
     */
    private Long purchaseId;

    /**
     * 合并项(采购需求)的 ID 集合
     */
    private List<Long> items;
}
/**
 * 合并采购需求到采购单(如果没有采购单则新建一个采购单再合并到里面)
 */
@PostMapping("/merge")
public R mergePurchaseDetails(@RequestBody MergeVo mergeVo) {
    purchaseService.mergePurchaseDetailsToPurchaseOrder(mergeVo);
    return R.ok();
}
@Transactional
@Override
public void mergePurchaseDetailsToPurchaseOrder(MergeVo mergeVo) {
    Long purchaseId = mergeVo.getPurchaseId();
    if (purchaseId == null) {
        PurchaseEntity purchaseEntity = new PurchaseEntity();
        purchaseEntity.setCreateTime(new Date());
        purchaseEntity.setUpdateTime(new Date());
        purchaseEntity.setStatus(WareConstants.PurchaseStatusEnum.CREATED.getCode());
        this.save(purchaseEntity);
        purchaseId = purchaseEntity.getId();
    }

    if (this.getById(purchaseId).getStatus().equals(WareConstants.PurchaseStatusEnum.CREATED.getCode()) || this.getById(purchaseId).getStatus().equals(WareConstants.PurchaseStatusEnum.ASSIGNED.getCode())) {
        Long finalPurchaseId = purchaseId;
        List<Long> items = mergeVo.getItems();
        List<PurchaseDetailEntity> purchaseDetailEntityList = items.stream()
                .map(item -> {
                    PurchaseDetailEntity purchaseDetail = new PurchaseDetailEntity();
                    // 采购单 ID
                    purchaseDetail.setPurchaseId(finalPurchaseId);
                    // 采购商品 ID
                    purchaseDetail.setId(item);
                    purchaseDetail.setStatus(WareConstants.PurchaseDetailStatusEnum.ASSIGNED.getCode());
                    return purchaseDetail;
                })
                .collect(Collectors.toList());
        purchaseDetailService.updateBatchById(purchaseDetailEntityList);

        PurchaseEntity purchaseEntity = new PurchaseEntity();
        purchaseEntity.setId(purchaseId);
        purchaseEntity.setUpdateTime(new Date());
        this.updateById(purchaseEntity);
    }
}

9.5 领取采购单

/**
 * 领取采购单
 */
@PostMapping("/received")
public R receivedThePurchaseOrder(@RequestBody List<Long> purchaseOrderIdList) {
    purchaseService.receivedThePurchaseOrder(purchaseOrderIdList);
    return R.ok();
}
@Transactional
@Override
public void receivedThePurchaseOrder(List<Long> purchaseOrderIdList) {
    // 1. 确认当前采购单状态为 新建 or 已分配
    List<PurchaseEntity> purchaseEntityList = purchaseOrderIdList
            .stream()
            .map(this::getById)
            .filter(purchaseEntity -> purchaseEntity.getStatus().equals(WareConstants.PurchaseStatusEnum.CREATED.getCode()) || purchaseEntity.getStatus().equals(WareConstants.PurchaseStatusEnum.ASSIGNED.getCode()))
            .collect(Collectors.toList());

    if (!purchaseEntityList.isEmpty() && ObjectUtil.isNotNull(purchaseEntityList)) {
        // 2. 改变采购单的状态
        purchaseEntityList.forEach(purchaseEntity -> {
            purchaseEntity.setStatus(WareConstants.PurchaseStatusEnum.RECEIVED.getCode());
            purchaseEntity.setUpdateTime(new Date());
        });
        this.updateBatchById(purchaseEntityList);

        // 3. 改变采购项的状态
        purchaseEntityList.forEach(purchaseEntity -> {
            List<PurchaseDetailEntity> purchaseDetailList = getPurchaseDetailsByPurchaseId(purchaseEntity.getId());
            purchaseDetailList = purchaseDetailList
                    .stream()
                    .map(purchaseDetail -> {
                        purchaseDetail.setStatus(WareConstants.PurchaseDetailStatusEnum.BUYING.getCode());
                        return purchaseDetail;
                    })
                    .collect(Collectors.toList());
            purchaseDetailService.updateBatchById(purchaseDetailList);
        });
    } else {
        throw new RuntimeException(WareConstants.PurchaseDetailStatusEnum.HAS_ERROR.getMsg());
    }
}

private List<PurchaseDetailEntity> getPurchaseDetailsByPurchaseId(Long id) {
    return purchaseDetailService.lambdaQuery().eq(PurchaseDetailEntity::getPurchaseId, id).list();
}

9.6 完成采购

@Data
public class PurchaseItemVo {
    private Long itemId;
    
    private Integer status;
    
    private String reason;
}

@Data
public class PurchaseDoneVo {
    /**
     * 采购单 ID
     */
  	@NotNull
    private Long id;

    /**
     * 采购项
     */
    private List<PurchaseItemVo> items;
}
/**
 * 完成采购
 */
@PostMapping("/done")
public R done(@RequestBody PurchaseDoneVo purchaseDoneVo) {
    purchaseService.completeThePurchase(purchaseDoneVo);
    return R.ok();
}
@Transactional
@Override
public void completeThePurchase(PurchaseDoneVo purchaseDoneVo) {
    // 1. 改变采购项状态
    List<PurchaseItemVo> items = purchaseDoneVo.getItems();
    List<PurchaseDetailEntity> purchaseDetailList = new ArrayList<>();
    Boolean flag = true;
    for (PurchaseItemVo item : items) {
        PurchaseDetailEntity purchaseDetail = new PurchaseDetailEntity();
        if (item.getStatus().equals(WareConstants.PurchaseDetailStatusEnum.HAS_ERROR.getCode())) {
            flag = false;
            purchaseDetail.setStatus(item.getStatus());
        } else {
            purchaseDetail.setStatus(WareConstants.PurchaseDetailStatusEnum.FINISHED.getCode());
            // 将采购成功的入库
            PurchaseDetailEntity detail = purchaseDetailService.getById(item.getItemId());
            wareSkuService.addStock(detail.getSkuId(), detail.getWareId(), detail.getSkuNum());
        }
        purchaseDetail.setId(item.getItemId());
        purchaseDetailList.add(purchaseDetail);
    }
    purchaseDetailService.updateBatchById(purchaseDetailList);

    // 2. 改变采购单状态(其状态的依据是所有采购项的状态)
    Long purchaseId = purchaseDoneVo.getId();
    PurchaseEntity purchase = new PurchaseEntity();
    purchase.setId(purchaseId);
    purchase.setStatus(flag ? WareConstants.PurchaseStatusEnum.FINISHED.getCode() : WareConstants.PurchaseStatusEnum.HAS_ERROR.getCode());
    purchase.setUpdateTime(new Date());
    this.updateById(purchase);
}

addStock(Long skuId, Long wareId, Integer skuNum)

@Override
public void addStock(Long skuId, Long wareId, Integer skuNum) {
    List<WareSkuEntity> wareSkuList = lambdaQuery().eq(WareSkuEntity::getSkuId, skuId).eq(WareSkuEntity::getWareId, wareId).list();

    // 如果没有这个库存记录,则新增;有则更新库存
    if (wareSkuList.isEmpty()) {
        WareSkuEntity wareSku = new WareSkuEntity();
        wareSku.setWareId(wareId);
        wareSku.setSkuId(skuId);
        wareSku.setStock(skuNum);
        wareSku.setStockLocked(0);
        // 远程查询获取 SkuName
        try {
            R info = productFeignService.info(skuId);
            Map<String, Object> skuInfo = (Map<String, Object>) info.get("skuInfo");
            if (info.getCode() == 0) {
                wareSku.setSkuName((String) skuInfo.get("skuName"));
            }
        } catch (Exception e) {
            log.error("error: ", e);
        }
        this.save(wareSku);
    } else {
        this.getBaseMapper().updateStock(skuId, wareId, skuNum);
    }
}
public interface WareSkuDao extends BaseMapper<WareSkuEntity> {

    void updateStock(@Param("skuId") Long skuId, @Param("wareId") Long wareId, @Param("skuNum") Integer skuNum);

}
<update id="updateStock">
     UPDATE `mall_wms`.wms_ware_sku SET stock = stock + #{skuNum}
        <where>
            sku_id = #{skuId} AND ware_id = #{wareId}
        where>
update>

远程调用: 1. 标注 @EnableFeignClients 注解; 2. 编写 XxxFeignService

@Component
@FeignClient("mall-product")
public interface ProductFeignService {
  	@GetMapping("/product/skuInfo/info/{skuId}")
    R info(@PathVariable("skuId") Long skuId);
}

Gateway

server:
  port: 9999

spring:
  application:
    name: mall-gateway

  cloud:
    gateway:
      routes: 
        - id: product_route
          uri: lb://mall-product
          predicates:
            - Path=/api/product/**
          filters:
            - RewritePath=/api/(?>.*),/$\{segment}
        - id: third_party_route
          uri: lb://mall-third-party
          predicates:
            - Path=/api/thirdparty/**
          filters:
            - RewritePath=/api/thirdparty(?>.*),/$\{segment}
        - id: coupon_route
          uri: lb://mall-coupon
          predicates:
            - Path=/api/coupon/**
          filters:
            - RewritePath=/api/(?>.*),/$\{segment}
        - id: member_route
          uri: lb://mall-member
          predicates:
            - Path=/api/member/**
          filters:
            - RewritePath=/api/(?>.*),/$\{segment}
        - id: ware_route
          uri: lb://mall-ware
          predicates:
            - Path=/api/ware/**
          filters:
            - RewritePath=/api/(?>.*),/$\{segment}
        - id: order_route
          uri: lb://mall-order
          predicates:
            - Path=/api/order/**
          filters:
            - RewritePath=/api/(?>.*),/$\{segment}
        - id: admin_route
          uri: lb://renren-fast
          predicates:
            - Path=/api/**
          filters:
            - RewritePath=/api/(?>.*),/renren-fast/$\{segment}

问题汇总

node-sass 报错问题

Mac:Node 版本为:v14.15.0

npm install rimraf -g

rimraf node_modules

npm cache clean --force

npm install

npm run dev

Windows

npm install [email protected]

npm install

npm run dev

‘parent.relativePath’ of POM

'parent.relativePath' of POM io.renren:renren-fast:3.0.0 (/Users/sun/IdeaProjects/mall/renren-fast/pom.xml) points at com.sun.mall:mall instead of org.springframework.boot:spring-boot-starter-parent, please verify your project structure

子模块的 parent 不是父模块,而是继承了 Spring Boot。

只需要在 标签中加上 即可。

renren-fast 注册到 Nacos

  1. renren-fast 的 Spring Boot 版本为 2.6.6,需要修改为 2.1.8.RELEASE;

  2. 依赖引入:不能直接将 mall-common 引入,需要单独引入;

    <dependencyManagement>
    	<dependencies>
    		<dependency>
    			<groupId>com.alibaba.cloudgroupId>
    			<artifactId>spring-cloud-alibaba-dependenciesartifactId>
    			<version>2.1.0.RELEASEversion>
    			<type>pomtype>
    			<scope>importscope>
    		dependency>
    	dependencies>
    dependencyManagement>
    
    <dependencies>
    	<dependency>
    		<groupId>com.alibaba.cloudgroupId>
    		<artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
    	dependency>
    dependencies>
    
  3. CorsConfig 中的 allowedOriginPatterns 报错,需要将其修改为 allowOrigins

  4. 若未报错,但是未注册成功:检查主启动类是否标注 @EnableDiscoveryClient 注解、配置文件中的服务名称 和 Nacos 地址。

P75 publish 报错

# 如果安装时 node-sass 也保存的话
npm uninstall sass-loader 
npm uninstall node-sass

npm install [email protected]
npm install [email protected]

# 安装
npm install [email protected] --save

main.js 中引用:

import Pubsub from 'pubsub-js'

Vue.prototype.PubSub = Pubsub;

你可能感兴趣的:(谷粒商城,java,spring,cloud,vue.js)