有一些地方和老师写的不一样(主要是类、数据库表的命名有些不同),菜鸡一个,还需多多努力。
1. Preparation In Advance
1.1 项目架构 & 功能
微服务架构图

微服务划分图

1.2 开发环境
Docker,虚拟化容器技术,基于镜像。pull 的服务为 Images,run 的服务为 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-aliyun</id>
<mirrorOf>central</mirrorOf>
<name>Nexus aliyun</name>
<url>http://maven.aliyun.com/nexus/content/groups/public</url>
</mirror>
</mirrors>
<profiles>
<profile>
<id>jdk-1.8</id>
<activation>
<activeByDefault>true</activeByDefault>
<jdk>1.8</jdk>
</activation>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.compilerVersion>1.8</maven.compiler.compilerVersion>
</properties>
</profile>
</profiles>
Git
# 配置用户名
git config --global user.name iTsawaysu
# 配置邮箱
git config --global user.email jianda_sun@qq.com
# 配置 SSH 免密登录
# 进入 Git Bash,输入以下命令,连续三次回车。
ssh-keygen -t rsa -C "jianda_sun@qq.com"
cat ~/.ssh/id_rsa.pub
# 将 id_rsa.pub 中的 SSH 公钥添加到 Gitee or GitHub 中
# 测试
ssh -T git@gitee.com
ssh -T git@github.com
1.3 项目初始化
创建仓库

创建 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 父项目:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.sun.mall</groupId>
<artifactId>mall</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mall</name>
<description>聚合服务</description>
<packaging>pom</packaging>
<modules>
<module>mall-coupon</module>
<module>mall-member</module>
<module>mall-order</module>
<module>mall-product</module>
<module>mall-ware</module>
</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.
导入 人人开源 & 逆向工程 & 公共模块
- 将
renren-fast导入到mall项目中,在mall-admin数据库中运行 SQL 文件,修改配置,启动。 renren-fast-vue:npm install & npm run dev即可访问后台管理系统。- 导入
renren-generator,修改配置(数据库连接信息)和generator.properties中的信息;再将/resources/template/Controller.java.vm中的@RequiresPermissions注解先注释掉。 - 运行代码生成器,生成对应的代码,将生成的代码中的
main目录导入到 对应的微服务中。 - 创建
mall-common模块,引入公共依赖、公共类,该模块作为公共模块。
各个微服务依次整合 MyBatis Plus
在
mall-cmmon中导入依赖;<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.2.0</version> </dependency>配置数据源;
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/mall_pms?useSSL=false username: root password: root在主启动类上添加
@MapperScan("com.sun.mall.product.dao")注解扫描并注册 Mapper 接口;设置 Mapper 映射文件位置 & 全局设置 主键ID 为自增类型。
mybatis-plus: mapper-locations: classpath:/mapper/**/*.xml global-config: db-config: id-type: auto
1.4 分布式开发配置
在 mall-cmmon 模块中导入 spring-cloud-alibaba指定版本。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.1.0.REALEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
1.4.1 Nacos 注册中心
- 引入
spring-cloud-starter-alibaba-discovery依赖;- 在配置文件中 配置 Nacos Server 的地址:
spring.cloud.nacos.discovery.server-addr;- 使用
@EnableDiscoveryClient注解开启 服务的注册和发现功能;- 下载 Nacos 并且解压;启动 Naocs,再启动微服务;在
localhost:8848/nacos中即可查看服务注册情况。
各个微服务都需要注册到 注册中心,因此可以将 Nacos 的依赖放到
mall-common中。<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency>配置文件:
spring: cloud: nacos: discovery: server-addr: localhost:8848主启动类上添加
@EnableDiscoveryClient注解,开启服务注册与发现功能;启动 Nacos;启动 Nacos 后再启动微服务,将微服务注册到 Nacos 注册中心。
1.4.2 OpenFeign 远程调用
实现步骤:
- 引入
spring-cloud-starter-openfeign依赖;- 开启远程调用功能:
@EnableFeignClients(basePackages = "com.sun.mall.member.feign");- 声明远程接口(FeignService)并标注
@FeignClient("服务名字")注解;- 在 FeignController 中调用 FeignService 中声明的方法。
实现过程:
- 服务启动,自动扫描
@EnableFeignClients注解所指定的包;- 通过
@FeignClient("服务名")注解,在 注册中心 中找到对应的服务;- 最后,调用请求路径所对应的 Controller 方法。
在 mall-coupon 中准备一个 Controller 方法;
@GetMapping("/test")
public R test(){
return R.ok().put("data", "openFeign OK~~~");
}
引入 OpenFeign 的依赖;
在
mall-member的主启动类上标注@EnableFeignClients注解,开启远程调用功能;@EnableFeignClients(basePackages = "com.sun.mall.member.feign")在
mall-member中声明远程接口 CouponFeignService;@Component @FeignClient("mall-coupon") public interface CouponFeignService { /** * 1. 服务启动,会自动扫描 @EnableFeignClients 注解指定的包; * 2. 通过 @FeignClient("服务名") 注解,在 注册中心 中找到对应的服务; * 3. 最后再调用 该请求路径所对应的 Controller 方法。 */ @GetMapping("/coupon/coupon/test") public R test(); }在
mall-member中声明 CouponFeignController;@RestController @RequestMapping("/member/coupon") public class CouponFeignController { @Resource private CouponFeignService couponFeignService; @GetMapping("/test") public R test() { return couponFeignService.test(); } }最后,启动
mall-coupon & mall-member,访问localhost:8000/member/coupon/test{ "msg": "success", "code": 0, "data": "openFeign OK~~~" }
1.4.3 Nacos 配置中心
- 引入
spring-cloud-starter-alibaba-nacos-config依赖;- 在
bootstrap.yml中配置 Nacos Config;- 启动 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空间;- 新建
dev、test、prod命名空间,默认访问的是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_GROUP、TEST_GROUP; - 修改
bootstrap.yml中spring.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为例,其他微服务同理。
Nacos 中创建 命名空间
dev;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: rootNacos 中创建
mybatis.yml,属于public空间;mybatis-plus: mapper-locations: classpath:/mapper/**/*.xml global-config: db-config: id-type: autobootstrap.ymlspring: 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 的区别:
- Nginx 是服务器级别的,Gateway 是项目级别的;
- Nginx 性能更好一些;
- 服务器接收请求,Nginx 负载均衡转发到 Gateway,Gateway 再次负载均衡转发到各个微服务;
- 如果项目中没有 Gateway 集群,则可以省略 Nginx。

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

路由(Route):构建网关的基本模块,由 ID、目标 URI、一系列断言 和 过滤器 组成;
断言(Predicate):可以匹配 HTTP 请求中的任何信息,比如 请求头、请求参数等;
过滤(Filter):可以在请求被路由前或后,对请求进行修改。
网关测试
创建
mall-gateway模块,引入spring-cloud-starter-gateway和mall-common(去除公共模块中的 MP) 依赖;<dependency> <groupId>com.sun.mall</groupId> <artifactId>mall-common</artifactId> <version>0.0.1-SNAPSHOT</version> <exclusions> <exclusion> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency>bootstrap.ymlspring: application: name: mall-gateway cloud: nacos: discovery: server-addr: localhost:8848 config: server-addr: localhost:8848 file-extension: yml在 Nacos 中创建
mall-gateway.ymlserver: 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访问
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_pms中pms_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。
<template>
<el-tree :data="categories" :props="defaultProps" accordion>
</el-tree>
</template>
<script>
export default {
name: "category",
data() {
return {
categories: [],
defaultProps: {
children: 'children',
label: 'label'
}
};
},
methods: {
loadCategories() {
this.$http({
url: this.$http.adornUrl("/product/category/list/tree"),
method: "get"
}).then(response => {
console.log(response);
})
}
},
created() {
this.loadCategories();
}
}
</script>
访问分类维护菜单无法获取到数据,因为发送的请求是 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处理。
引入
spring-cloud-starter-alibaba-nacos-discovery并定义 Spring Cloud 版本;<dependencyManagement> <dependencies> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>2.1.0.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> ... </dependencies>在主启动类上标注
@EnbaleDiscoveryClient注解;在配置文件中将
renren-fast注册到 Nacos 注册中心;spring: application: name: renren-fast cloud: nacos: discovery: server_addr: localhost:8848网关配置。
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/(?<segment>.*),/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>.*),/$\{segment}
- id: admin_route
uri: lb://renren-fast
predicates:
- Path=/api/**
filters:
- RewritePath=/api/(?<segment>.*),/renren-fast/$\{segment}
2.1.4 前端展示查询结果
<template>
<el-tree empty-text="暂无数据..." :highlight-current="true" :data="categories" :props="defaultProps" accordion>
</el-tree>
</template>
<script>
export default {
name: "category",
data() {
return {
categories: [],
defaultProps: {
// 指定哪个属性作为子树节点对象展示(CategoryEntity 的 List<CategoryEntity> children 属性)
children: 'children',
// 指定哪个属性作为标签的值展示(CategoryEntity 的 name 属性)
label: 'name'
}
};
},
methods: {
loadCategories() {
this.$http({
url: this.$http.adornUrl("/product/category/list/tree"),
method: "get"
}).then(response => {
this.categories = response.data.data
})
}
},
created() {
this.loadCategories();
}
}
</script>
总结
只做了五件事!
- 编写后端代码;
- 根据 URL 地址规则创建并编写
category.vue; - 定义 API 接口的基准路径地址,让所有请求全部打到 Gateway;
- 配置 GateWay 的路由规则,让其精准的找到每个请求所对应的微服务;
- 配置 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>
<!-- 当前节点为一、二级分类才显示 Append -->
<el-button v-if="node.level <= 2" type="text" size="mini" @click="() => append(data)">Append</el-button>
<!-- 当前节点没有子节点才显示 Delete -->
<el-button v-if="node.childNodes.length === 0" type="text" size="mini" @click="() => remove(node, data)">Delete</el-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: '<strong><i>删除成功!</i></strong>',
center: true, // 文字居中
showClose: true // 可关闭
})
this.load();
// 设置默认展开的分类
this.expandedKey = [node.parent.data.catId]
})
}).catch(() => {
this.$message({
type: 'info',
dangerouslyUseHTMLString: true,
message: '<strong><i>已取消删除!</i></strong>',
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: '<strong><i>添加成功!</i></strong>',
center: true,
showClose: true
})
this.dialogFormVisible = false;
this.load();
// 设置默认展开的分类
this.expandedKey = [this.category.parentCid]
}).catch(() => {
this.$message({
type: 'info',
dangerouslyUseHTMLString: true,
message: '<strong><i>添加失败!</i></strong>',
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();
}
前端代码
<template>
<div>
<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>
<!-- 当前节点为一、二级分类才显示 Append -->
<el-button v-if="node.level <= 2" type="text" size="mini"
@click="() => append(data)"> Append</el-button>
<!-- Edit -->
<el-button type="text" size="mini" @click="edit(data)"> Edit</el-button>
<!-- 当前节点没有子节点才显示 Delete -->
<el-button v-if="node.childNodes.length === 0" type="text" size="mini"
@click="() => remove(node, data)"> Delete</el-button>
</span>
</span>
</el-tree>
<el-dialog :title="dialogTitle" :visible.sync="dialogFormVisible" :center="true" width="30%" :close-on-click-modal="false">
<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-item label="图标" prop="icon">
<el-input v-model="category.icon" autocomplete="off" @keyup.enter.native="addCategory"></el-input>
</el-form-item>
<el-form-item label="计量单位" prop="productUnit">
<el-input v-model="category.productUnit" 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="submitSaveOrUpdate">确 定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
export default {
name: "category",
data() {
return {
categories: [],
expandedKey: [],
dialogFormVisible: false,
dialogTitle: "",
submitType: "",
category: {name: "", parentCid: 0, catLevel: 0, showStatus: 1, sort: 0, icon: "", productUnit: "", catId: null},
defaultProps: {
// 指定哪个属性作为子树节点对象展示(CategoryEntity 的 List<CategoryEntity> children 属性)
children: 'children',
// 指定哪个属性作为标签的值展示(CategoryEntity 的 name 属性)
label: 'name'
}
};
},
created() {
this.load();
},
methods: {
// 树形展示分类
load() {
this.$http({
url: this.$http.adornUrl("/product/category/list/tree"),
method: "get"
}).then(({data}) => {
this.categories = data.data
})
},
// 弹出对话框(添加)
append(data) {
this.submitType = "add";
this.dialogTitle = "添加分类";
this.dialogFormVisible = true;
// 当前要添加的分类的 父分类ID
this.category.parentCid = data.catId;
// 当前要添加的分类的 level
this.category.catLevel = data.catLevel * 1 + 1;
this.category.catId = null;
this.category.name = "";
this.category.icon = "";
this.category.productUnit = "";
this.category.sort = 0;
this.category.showStatus = 1;
},
// 添加分类
addCategory() {
this.$http({
url: this.$http.adornUrl("/product/category/save"),
method: "post",
data: this.$http.adornData(this.category, false)
}).then(() => {
this.successMessage("添加成功");
this.dialogFormVisible = false;
this.load();
// 设置默认展开的分类
this.expandedKey = [this.category.parentCid]
}).catch(() => {
this.errorMessage("添加失败");
this.dialogFormVisible = false;
})
},
// 弹出对话框(修改)
edit(data) {
this.submitType = "edit";
this.dialogTitle = "修改分类";
this.dialogFormVisible = true;
// 发送请求获取节点的最新数据
this.$http({
url: this.$http.adornUrl(`/product/category/info/${data.catId}`),
method: "get"
}).then(({data}) => {
this.category.name = data.category.name;
this.category.catId = data.category.catId;
this.category.icon = data.category.icon;
this.category.productUnit = data.category.productUnit;
this.category.parentCid = data.category.parentCid;
})
},
// 修改分类
editCategory() {
// this.category 中不包含所有字段的值,例如 show-status 等。
// 因此不能直接将其作为参数发起更新请求,会导致其他字段被默认值覆盖;只发送需要更新的字段。
let {catId, name, icon, productUnit} = this.category;
this.$http({
url: this.$http.adornUrl("/product/category/update"),
method: "post",
data: this.$http.adornData({catId, name, icon, productUnit}, false)
}).then(() => {
this.successMessage("修改成功");
this.dialogFormVisible = false;
this.load();
// 设置默认展开的分类
this.expandedKey = [this.category.parentCid];
}).catch(() => {
this.errorMessage("修改失败")
this.dialogFormVisible = false;
})
},
// 添加 or 修改
submitSaveOrUpdate() {
if (this.submitType === "add") {
this.addCategory();
}
if (this.submitType === "edit") {
this.editCategory();
}
},
// 删除分类
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.successMessage("删除成功");
this.load();
// 设置默认展开的分类
this.expandedKey = [node.parent.data.catId]
})
}).catch(() => {
this.errorMessage("已取消删除");
});
},
// 成功消息提示
successMessage(msg) {
this.$message({
type: 'success',
dangerouslyUseHTMLString: true, // 使用 HTML 片段
message: `<strong><i>${msg}!</i></strong>`,
center: true, // 文字居中
showClose: true // 可关闭
})
},
// 失败消息提示
errorMessage(msg) {
this.$message({
type: 'info',
dangerouslyUseHTMLString: true,
message: `<strong><i>${msg}!</i></strong>`,
center: true,
showClose: true
})
}
}
}
</script>
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改变了;目标节点所处的层级顺序改变了;被拖拽节点的层级改变了。
注意:
inner针对目标节点,before和after针对目标节点的父节点。let parentId = 0; if (dropType == "before" || dropType == "after") { parentId = dropNode.data.parentId; siblings = dropNode.parent.childNodes; } else { parentId = dropNode.data.catId; siblings = dropNode.childNodes; }排序:为 被拖拽节点 的 父节点 的 孩子 设置
sort属性(遍历索引值)。for (let i = 0; i < siblings.length; i++) { this.updateNodes.push({catId: siblings[i].data.catId, sort: i, parentCid: parentId}); }层级更新。
拖拽到目标节点的前或后,则 被拖拽节点的层级不变;
拖拽到目标节点里面,则 被拖拽节点的层级 为 目标节点的层级 + 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
<template>
<div>
<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"
style="margin-right: 10px"
type="primary"
size="small"
round
@click="batchSave">批量保存</el-button>
<el-button type="danger"
size="small"
round
@click="batchDelete">批量删除</el-button>
</div>
<el-tree empty-text="暂无数据..."
:highlight-current="true"
:data="categories"
:props="defaultProps"
:expand-on-click-node="false"
show-checkbox node-key="catId"
:default-expanded-keys="expandedKey"
:draggable="isDraggable"
:allow-drop="allowDrop"
@node-drop="handleDrop"
ref="categoryTree">
<span class="custom-tree-node" slot-scope="{ node, data }">
<span>{{ node.label }}</span>
<span>
<!-- 当前节点为一、二级分类才显示 Append -->
<el-button v-if="node.level <= 2" type="text" size="mini"
@click="() => append(data)"> Append</el-button>
<!-- Edit -->
<el-button type="text" size="mini" @click="edit(data)"> Edit</el-button>
<!-- 当前节点没有子节点才显示 Delete -->
<el-button v-if="node.childNodes.length === 0" type="text" size="mini"
@click="() => remove(node, data)"> Delete</el-button>
</span>
</span>
</el-tree>
<el-dialog :title="dialogTitle"
:visible.sync="dialogFormVisible"
:center="true" width="30%"
:close-on-click-modal="false">
<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-item label="图标" prop="icon">
<el-input v-model="category.icon" autocomplete="off" @keyup.enter.native="addCategory"></el-input>
</el-form-item>
<el-form-item label="计量单位" prop="productUnit">
<el-input v-model="category.productUnit" 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="submitSaveOrUpdate">确 定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
export default {
name: "category",
data() {
return {
parentId: [],
isDraggable: false,
updateNodes: [],
maxLevel: 1,
categories: [],
expandedKey: [],
dialogFormVisible: false,
dialogTitle: "",
submitType: "",
category: {name: "", parentCid: 0, catLevel: 0, showStatus: 1, sort: 0, icon: "", productUnit: "", catId: null},
defaultProps: {
// 指定哪个属性作为子树节点对象展示(CategoryEntity 的 List<CategoryEntity> children 属性)
children: 'children',
// 指定哪个属性作为标签的值展示(CategoryEntity 的 name 属性)
label: 'name'
}
};
},
created() {
this.load();
},
methods: {
// 树形展示分类
load() {
this.$http({
url: this.$http.adornUrl("/product/category/list/tree"),
method: "get"
}).then(({data}) => {
this.categories = data.data
})
},
// 批量删除
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("已取消删除");
});
},
// 批量修改
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]);
}
}
},
// 弹出对话框(添加)
append(data) {
this.submitType = "add";
this.dialogTitle = "添加分类";
this.dialogFormVisible = true;
// 当前要添加的分类的 父分类ID
this.category.parentCid = data.catId;
// 当前要添加的分类的 level
this.category.catLevel = data.catLevel * 1 + 1;
this.category.catId = null;
this.category.name = "";
this.category.icon = "";
this.category.productUnit = "";
this.category.sort = 0;
this.category.showStatus = 1;
},
// 添加分类
addCategory() {
this.$http({
url: this.$http.adornUrl("/product/category/save"),
method: "post",
data: this.$http.adornData(this.category, false)
}).then(() => {
this.successMessage("添加成功");
this.dialogFormVisible = false;
this.load();
// 设置默认展开的分类
this.expandedKey = [this.category.parentCid]
}).catch(() => {
this.errorMessage("添加失败");
this.dialogFormVisible = false;
})
},
// 弹出对话框(修改)
edit(data) {
this.submitType = "edit";
this.dialogTitle = "修改分类";
this.dialogFormVisible = true;
// 发送请求获取节点的最新数据
this.$http({
url: this.$http.adornUrl(`/product/category/info/${data.catId}`),
method: "get"
}).then(({data}) => {
this.category.name = data.category.name;
this.category.catId = data.category.catId;
this.category.icon = data.category.icon;
this.category.productUnit = data.category.productUnit;
this.category.parentCid = data.category.parentCid;
})
},
// 修改分类
editCategory() {
// this.category 中不包含所有字段的值,例如 show-status 等。
// 因此不能直接将其作为参数发起更新请求,会导致其他字段被默认值覆盖;只发送需要更新的字段。
let {catId, name, icon, productUnit} = this.category;
this.$http({
url: this.$http.adornUrl("/product/category/update"),
method: "post",
data: this.$http.adornData({catId, name, icon, productUnit}, false)
}).then(() => {
this.successMessage("修改成功");
this.dialogFormVisible = false;
this.load();
// 设置默认展开的分类
this.expandedKey = [this.category.parentCid];
}).catch(() => {
this.errorMessage("修改失败")
this.dialogFormVisible = false;
})
},
// 添加 or 修改
submitSaveOrUpdate() {
if (this.submitType === "add") {
this.addCategory();
}
if (this.submitType === "edit") {
this.editCategory();
}
},
// 删除分类
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.successMessage("删除成功");
this.load();
// 设置默认展开的分类
this.expandedKey = [node.parent.data.catId]
})
}).catch(() => {
this.errorMessage("已取消删除");
});
},
// 成功消息提示
successMessage(msg) {
this.$message({
type: 'success',
dangerouslyUseHTMLString: true, // 使用 HTML 片段
message: `<strong><i>${msg}!</i></strong>`,
center: true, // 文字居中
showClose: true // 可关闭
})
},
// 失败消息提示
errorMessage(msg) {
this.$message({
type: 'info',
dangerouslyUseHTMLString: true,
message: `<strong><i>${msg}!</i></strong>`,
center: true,
showClose: true
})
}
}
}
</script>
3. 品牌管理
mall_pms中pms_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 逆向生成前端代码并优化
导入逆向工程生成的前端代码
在前端页面的 系统管理 - 菜单管理 中,新增 品牌管理 菜单,该菜单在 商品管理 目录,菜单路由为
product/brand;将
mall/mall-product/src/main/resources/src/views/modules/product中的brand.vue和brand-add-or-update.vue复制到mall/renren-fast-vue/src/views/modules/product处;修改
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。
上传方式
普通上传
用户将文件上传到应用服务器,上传请求提交到网关,通过网关路由给
mall-product处理;在
mall-product中通过 Java 代码将文件上传到 OSS。上传慢:用户数据需先上传到应用服务器,之后再上传到OSS,网络传输时间比直传到OSS多一倍。如果用户数据不通过应用服务器中转,而是直传到OSS,速度将大大提升。
扩展性差:如果后续用户数量逐渐增加,则应用服务器会成为瓶颈。
服务端签名后直传
- 用户请求应用服务器获取一个上传策略 Policy;
- 应用服务器利用账号和密码生成一个防伪签名(包括授权令牌、上传到哪个位置等的相关信息)和 上传 Policy 返回给用户;
- 用户带着这个防伪签名直接上传到 OSS,OSS 对该防伪签名进行验证后接收上传。

3.2.2 普通上传
aliyun-sdk-oss
导入
aliyun-sdk-oss依赖;<dependency> <groupId>com.aliyun.oss</groupId> <artifactId>aliyun-sdk-oss</artifactId> <version>3.5.0</version> </dependency>编写测试代码。
@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
导入
spring-cloud-starter-alicloud-oss依赖;<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alicloud-oss</artifactId> </dependency>在配置文件中配置 OSS 服务对应的
accessKey、secretKey和endpoint;spring: cloud: alicloud: access-key: ? secret-key: ? oss: endpoint: ?注入 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模块,整合一系列第三方服务。
导入
mall-common(排除 MP)、spring-cloud-starter-alicloud-oss、web、openfeign等相关依赖。主启动类上标注
@EnableDiscoveryClient注解。创建并配置
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在 Nacos 中创建并配置
mall-third-party.yml。server: port: 30000 spring: cloud: alicloud: access-key: secret-key: oss: bucket: itsawaysu endpoint: oss-cn-shanghai.aliyuncs.com在 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>.*),/$\{segment}
用户请求应用服务器获取上传 Policy

- Web 前端请求应用服务器,获取上传所需参数(如
accessKeyId、policy、callback等参数); - 应用服务器返回相关参数;
- Web 前端直接向 OSS 服务发起上传文件请求;
- 文件上传后 OSS 服务会回调应用服务器的回调接口(此处不实现);
- 应用服务器返回响应给 OSS 服务(此处不实现);
- 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 方法。
- 单击 Bucket 列表,然后单击目标 Bucket 名称。
- 在左侧导航栏,选择 数据安全 > 跨域设置,然后在 跨域设置 区域,单击 设置。
- 单击 创建规则,配置如下图所示。

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)。
<template>
<el-dialog ...>
<el-form :model="dataForm" ...>
...
<el-form-item label="品牌Logo地址" prop="logo">
<single-upload v-model="dataForm.logo"></single-upload>
</el-form-item>
<el-form-item label="显示状态" prop="showStatus">
<el-switch v-model="dataForm.showStatus"
active-color="#13ce66"
inactive-color="#ff4949"
:active-value="1"
:inactive-value="0">
</el-switch>
</el-form-item>
...
</el-form>
</el-dialog>
</template>
<script>
import singleUpload from "../../../components/upload/singleUpload";
export default {
components: {singleUpload},
...
}
</script>
SingleUpload.vue
<template>
<!-- action:上传的地址;
data:上传时附带的额外参数;
list-type:文件列表的类型;
show-file-list:是否显示已上传文件列表;
accept:接受上传的文件类型;
file-list:上传的文件列表。 -->
<div>
<el-upload
:action="dataObj.host"
:data="dataObj"
list-type="picture"
:multiple="false"
:show-file-list="showFileList"
:file-list="fileList"
:on-preview="handlePreview"
:on-remove="handleRemove"
:on-success="handleUploadSuccess"
:before-upload="beforeUpload">
<el-button size="small" type="primary">点击上传</el-button>
<div slot="tip" class="el-upload__tip">只能上传 JPG/PNG 文件,且不超过 10MB</div>
</el-upload>
<el-dialog :visible.sync="dialogVisible">
<img width="100%" :src="fileList[0].url" alt="">
</el-dialog>
</div>
</template>
<script>
import {policy} from './policy'
import {getUUID} from '@/utils'
export default {
name: 'singleUpload',
props: {
value: String
},
computed: {
imageUrl() {
return this.value;
},
imageName() {
if (this.value != null && this.value !== '') {
return this.value.substr(this.value.lastIndexOf("/") + 1);
} else {
return null;
}
},
fileList() {
return [{
name: this.imageName,
url: this.imageUrl
}]
},
showFileList: {
get: function () {
return this.value !== null && this.value !== '' && this.value !== undefined;
},
set: function (newValue) {
}
}
},
data() {
return {
dataObj: {
policy: '',
signature: '',
OSSAccessKeyId: '',
key: '',
dir: '',
host: '',
},
dialogVisible: false
};
},
methods: {
emitInput(val) {
this.$emit('input', val)
},
// 点击文件列表中已上传的文件时的钩子
handlePreview(file) {
this.dialogVisible = true;
},
// 文件列表移除文件时的钩子
handleRemove(file, fileList) {
this.emitInput('');
},
// 文件上传成功时的钩子
handleUploadSuccess(res, file) {
console.log("上传成功...")
this.showFileList = true;
this.fileList.pop();
this.fileList.push({
name: file.name,
url: this.dataObj.host + '/' + this.dataObj.key.replace("${filename}", file.name)
});
this.emitInput(this.fileList[0].url);
},
// 上传文件之前的钩子,参数为上传的文件,若返回 false 或者返回 Promise 且被 reject,则停止上传。
beforeUpload(file) {
let _self = this;
return new Promise((resolve, reject) => {
policy().then(response => {
_self.dataObj.policy = response.data.policy;
_self.dataObj.signature = response.data.signature;
_self.dataObj.OSSAccessKeyId = response.data.accessid;
_self.dataObj.key = response.data.dir + getUUID() + '_${filename}';
_self.dataObj.dir = response.data.dir;
_self.dataObj.host = response.data.host;
resolve(true)
}).catch(err => {
reject(false)
})
})
}
}
}
</script>
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 数据校验
给实体类标注校验注解 ——
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;开启校验功能:发送请求提交数据时,告诉 Spring MVC 数据需要校验 —— 在需要校验的
Bean前标注@Valid注解。@RequestMapping("/save") public R save(@Valid @RequestBody BrandEntity brand) { brandService.save(brand); return R.ok(); }效果:校验错误后有一个默认的响应。

在需要校验的 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 中的代码是重复的,每次都需要重复书写,很麻烦;可以做一个统一的处理,统一异常处理。
统一异常处理
Controller 中只需要关注业务代码:
@RequestMapping("/save") public R save(@Valid @RequestBody BrandEntity brand) { brandService.save(brand); return R.ok(); }创建异常处理类,对
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 分组校验
新增接口作为标识;
package com.sun.mall.common.validation; /** * @author sun * 新增标识 */ public interface AddGroup { } /** * @author sun * 修改标识 */ public interface UpdateGroup { }对校验注解的
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;使用
@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(); }测试
/save:校验brandId、name、logo属性;/update:校验brandId、name、logo属性。注意:若请求接口标注了
@Validated({XxxGroup}),默认未指定的分组的校验注解不生效;例如:firstLetter和sort属性。
3.3.5 自定义校验
- 编写一个自定义的校验注解;
- 编写一个自定义的校验器;
- 关联自定义的校验器和自定义的校验注解。
- 导入
validation依赖;
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
- 在
mall-common/src/main/resources目录下创建ValidationMessages.properties,提供错误提示;
com.sun.mall.common.validation.OptionalValue.message="必须提交指定值"
- 自定义注解;
@Documented
// 关联此注解和校验器
@Constraint(validatedBy = {OptionalValueConstraintValidator.class})
// 指定此注解可以标记在什么位置
// 此处可以指定多个不同的校验器,若需要校验 Double 类型的数据,再定义一个校验器 implements ConstraintValidator<OptionalValue, Double> 即可。
@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 {};
}
- 自定义校验器;
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);
}
}
- 标注自定义校验注解。
/**
* 显示状态[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。
规格参数
产品会包含一个 规格参数,规格参数由 属性组 和 属性 组成。

三级分类 - 属性分组 - 属性

商品属性 - 属性 - SKU 销售属性值

4.2 抽取公共组件 & 属性分组页面
子组件向父组件传递数据
父组件给子组件绑定自定义事件。
<category @tree-node-click="treeNodeClick"></category>子组件触发自定义事件并且传递数据;自定义方法被触发后,父组件中的回调函数被调用。
this.$emit("tree-node-click");解绑。
this.$off('tree-node-click');
抽取公共组件:
src/views/modules/common/category.vue
<template>
<div>
<el-tree empty-text="暂无数据..."
:highlight-current="true"
:data="categories"
:props="defaultProps"
:expand-on-click-node="false"
node-key="catId"
ref="categoryTree"
@node-click="nodeClick">
<span class="custom-tree-node" slot-scope="{ node }">
<span> {{ node.label }} </span>
</span>
</el-tree>
</div>
</template>
<script>
export default {
name: "category",
data() {
return {
categories: [],
defaultProps: {
// 指定哪个属性作为子树节点对象展示(CategoryEntity 的 List<CategoryEntity> children 属性)
children: 'children',
// 指定哪个属性作为标签的值展示(CategoryEntity 的 name 属性)
label: 'name'
}
}
},
created() {
this.load();
},
methods: {
load() {
this.$http({
url: this.$http.adornUrl("/product/category/list/tree"),
method: "get"
}).then(({data}) => {
this.categories = data.data;
})
},
nodeClick(data, node, component) {
// 触发父组件绑定的事件,并且传递数据。
this.$emit("tree-node-click", data, node, component);
}
}
}
</script>
执行
sys_menus.sql代码生成菜单;在src/views/modules/product目录下 创建attrgroup.vue;左边显示 三级分类,右边显示 属性分组。
<template>
<!-- gutter 属性指定每一栏之间的间隔 -->
<el-row :gutter="20">
<el-col :span="4">
<category @tree-node-click="treeNodeClick"></category>
</el-col>
<el-col :span="20">
<div class="mod-config">
<el-form :inline="true" :model="dataForm" @keyup.enter.native="getDataList()">
<el-form-item>
<el-input v-model="dataForm.key" placeholder="参数名" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
<el-button v-if="isAuth('product:attrgroup:save')"
type="primary"
@click="addOrUpdateHandle()">新增
</el-button>
<el-button v-if="isAuth('product:attrgroup:delete')"
type="danger"
@click="deleteHandle()"
:disabled="dataListSelections.length <= 0">批量删除
</el-button>
</el-form-item>
</el-form>
<el-table :data="dataList"
border
v-loading="dataListLoading"
@selection-change="selectionChangeHandle"
style="width: 100%;">
<el-table-column type="selection" header-align="center" align="center" width="50"></el-table-column>
<el-table-column prop="attrGroupId" header-align="center" align="center" label="分组id"></el-table-column>
<el-table-column prop="attrGroupName" header-align="center" align="center" label="组名"></el-table-column>
<el-table-column prop="sort" header-align="center" align="center" label="排序"></el-table-column>
<el-table-column prop="descript" header-align="center" align="center" label="描述"></el-table-column>
<el-table-column prop="icon" header-align="center" align="center" label="组图标"></el-table-column>
<el-table-column prop="catelogId" header-align="center" align="center" label="所属分类id"></el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="150" label="操作">
<template slot-scope="scope">
<el-button type="text" size="small" @click="addOrUpdateHandle(scope.row.attrGroupId)">修改
</el-button>
<el-button type="text" size="small" @click="deleteHandle(scope.row.attrGroupId)">删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination @size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update v-if="addOrUpdateVisible" ref="addOrUpdate" @refreshDataList="getDataList"></add-or-update>
</div>
</el-col>
</el-row>
</template>
<script>
import category from "../common/category";
import AddOrUpdate from './attrgroup-add-or-update';
export default {
name: "attr-group",
components: {
category, AddOrUpdate
},
data() {
return {
dataForm: {
key: ''
},
dataList: [],
pageIndex: 1,
pageSize: 10,
totalPage: 0,
dataListLoading: false,
dataListSelections: [],
addOrUpdateVisible: false
}
},
methods: {
treeNodeClick(data, node, component) {
console.log("父组件:", data, node, component);
this.$off("tree-node-click");
},
// 获取数据列表
getDataList() {
this.dataListLoading = true
this.$http({
url: this.$http.adornUrl('/product/attrgroup/list'),
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
})
},
// 每页数
sizeChangeHandle(val) {
this.pageSize = val
this.pageIndex = 1
this.getDataList()
},
// 当前页
currentChangeHandle(val) {
this.pageIndex = val
this.getDataList()
},
// 多选
selectionChangeHandle(val) {
this.dataListSelections = val
},
// 新增 / 修改
addOrUpdateHandle(id) {
this.addOrUpdateVisible = true
this.$nextTick(() => {
this.$refs.addOrUpdate.init(id)
})
},
// 删除
deleteHandle(id) {
var ids = id ? [id] : this.dataListSelections.map(item => {
return item.attrGroupId
})
this.$confirm(`确定对[id=${ids.join(',')}]进行[${id ? '删除' : '批量删除'}]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$http({
url: this.$http.adornUrl('/product/attrgroup/delete'),
method: 'post',
data: this.$http.adornData(ids, false)
}).then(({data}) => {
if (data && data.code === 0) {
this.$message({
message: '操作成功',
type: 'success',
duration: 1500,
onClose: () => {
this.getDataList()
}
})
} else {
this.$message.error(data.msg)
}
})
})
}
}
}
</script>
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:指定选项的字选项为选项对象的某个属性值。
<el-form-item label="所属分类ID" prop="catelogId">
<el-cascader v-model="dataForm.catelogId" :options="categories" :props="props"></el-cascader>
</el-form-item>
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 修改分组 & 级联选择器回显
修改分组时,级联选择器无法成功回显。
<!-- 点击 修改,调用 `addOrUpdateHandle(属性分组 ID)` 方法 -->
<el-button type="text" size="small" @click="addOrUpdateHandle(scope.row.attrGroupId)">修改</el-button>
addOrUpdateHandle(id) {
this.addOrUpdateVisible = true;
// 当前组件完全渲染完毕(下一次 DOM 更新结束后)后,调用其指定的回调函数
this.$nextTick(() => {
// <add-or-update ref="addOrUpdate"></add-or-update>
// 调用 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;
}
开启搜索选项 & 清空新增弹窗
<!-- filterable:是否可搜索选项 -->
<el-cascader v-model="dataForm.catelogPath"
:options="categories"
:props="props"
placeholder="试试搜索:手机"
filterable>
</el-cascader>
<!-- 新增 @closed 事件 -->
<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_name 和 catelog_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,包含 catelogName 和 groupName 属性。
@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<AttrAttrgroupRelationEntity>
.lambdaQuery()
.eq(AttrAttrgroupRelationEntity::getAttrGroupId, groupId)
.list()
.stream()
// 遍历 List<AttrAttrgroupRelationEntity> 获取到 List<AttrEntity>
.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>.*),/$\{segment}
- id: third_party_route
uri: lb://mall-third-party
predicates:
- Path=/api/thirdparty/**
filters:
- RewritePath=/api/thirdparty(?<segment>.*),/$\{segment}
- id: member_route
uri: lb://mall-member
predicates:
- Path=/api/member/**
filters:
- RewritePath=/api/(?<segment>.*),/$\{segment}
- id: admin_route
uri: lb://renren-fast
predicates:
- Path=/api/**
filters:
- RewritePath=/api/(?<segment>.*),/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。
- SKU 的基本信息:
StrUtil.join(CharSequence conjunction, Iterable<T> iterable):以 conjunction 为分隔符将多个对象转换为字符串。
@Test
public void strJoinTest() {
List<String> list = Arrays.asList("你好", "好好学习", "天天向上");
System.out.println(list);
String join = StrUtil.join(" - ", list);
System.out.println(join);
// [你好, 好好学习, 天天向上]
// 你好 - 好好学习 - 天天向上
}
远程调用
在
mall-common中创建SpuBoundsDTO和SkuReductionDTO;@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; }在
mall-product主启动类上标注@EnableFeignClients(basePackages = "com.sun.mall.product.feign")注解;创建
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); }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); }为统一返回类
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中添加以下代码。

{
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 合并采购需求到采购单
采购简要流程

创建
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>.*),/$\{segment}
- id: third_party_route
uri: lb://mall-third-party
predicates:
- Path=/api/thirdparty/**
filters:
- RewritePath=/api/thirdparty(?<segment>.*),/$\{segment}
- id: coupon_route
uri: lb://mall-coupon
predicates:
- Path=/api/coupon/**
filters:
- RewritePath=/api/(?<segment>.*),/$\{segment}
- id: member_route
uri: lb://mall-member
predicates:
- Path=/api/member/**
filters:
- RewritePath=/api/(?<segment>.*),/$\{segment}
- id: ware_route
uri: lb://mall-ware
predicates:
- Path=/api/ware/**
filters:
- RewritePath=/api/(?<segment>.*),/$\{segment}
- id: order_route
uri: lb://mall-order
predicates:
- Path=/api/order/**
filters:
- RewritePath=/api/(?<segment>.*),/$\{segment}
- id: admin_route
uri: lb://renren-fast
predicates:
- Path=/api/**
filters:
- RewritePath=/api/(?<segment>.*),/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 node-sass@4.14
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。
只需要在 <parent> 标签中加上 <relativePath /> 即可。
renren-fast 注册到 Nacos
renren-fast的 Spring Boot 版本为 2.6.6,需要修改为 2.1.8.RELEASE;依赖引入:不能直接将
mall-common引入,需要单独引入;<dependencyManagement> <dependencies> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>2.1.0.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> </dependencies>CorsConfig 中的
allowedOriginPatterns报错,需要将其修改为allowOrigins。若未报错,但是未注册成功:检查主启动类是否标注
@EnableDiscoveryClient注解、配置文件中的服务名称 和 Nacos 地址。
P75 publish 报错
# 如果安装时 node-sass 也保存的话
npm uninstall sass-loader
npm uninstall node-sass
npm install sass-loader@7.3.1
npm install node-sass@4.14.1
# 安装
npm install pubsub-js@1.8.0 --save
在 main.js 中引用:
import Pubsub from 'pubsub-js'
Vue.prototype.PubSub = Pubsub;