1. 商品服务
1.1 三级分类
三级分类指的是商品导航栏中的商品分类
在gulimall_pms数据库的pms_category表中导入分类数据:
pms_catelog.sql
1.1.1 查询
先把数据按照树形结构查出来,再在前台展示
@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {
@Override
public List<CategoryEntity> listWithTree() {
// 1. 查出所有分类
/**
* 这里为什么可以直接用baseMapper查询?
* @Autowired
* CategoryDao categoryDao;
* categoryDao.selectList(null);
*
* 是因为CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity>
* 所以在ServiceImpl<T,T>中注入的baseMapper当前代表CategoryDao,操作的是CategoryEntity
*/
// 1 查出所有分类
List<CategoryEntity> entities = baseMapper.selectList(null);
// 2 组装成父子的树形结构
// 2.1 找到所有的一级分类
List<CategoryEntity> level1Menus = entities.stream().filter(categoryEntity ->
categoryEntity.getParentCid() == 0
).map((menu)->{
menu.setChildren(getChildrens(menu,entities));
return menu;
}).sorted((menu1,menu2)->{
return (menu1.getSort()==null?0:menu1.getSort()) - (menu2.getSort()==null?0:menu2.getSort());
}).collect(Collectors.toList());
return level1Menus;
}
private List<CategoryEntity> getChildrens(CategoryEntity root,List<CategoryEntity> all){
List<CategoryEntity> children = all.stream().filter(categoryEntity ->
categoryEntity.getParentCid() == root.getCatId()
).map(menu -> {
// 找到子菜单
menu.setChildren(getChildrens(menu, all));
return menu;
}).sorted((menu1, menu2) ->
// 菜单排序
(menu1.getSort() == null ? 0 : menu1.getSort()) - (menu2.getSort() == null ? 0 : menu2.getSort())
).collect(Collectors.toList());
return children;
}
}
1.1.2 配置网关路由与路径重写
启动我们后台管理项目的前端项目和后台项目,在系统管理中新增一个商品管理的目录
在商品管理下新增一个分类维护的菜单
维护成功之后,新增的菜单目录数据保存在gulimall_admin数据库中的sys_menu表中,前台如下图所示,我们要在区域2中展示我们三级分类的数据,并且要对数据进行增删改查操作。新增菜单时维护的菜单路由是product/category,但点击分类维护菜单时,发送的请求是product-category。
分析项目可知,请求中的product-category这个路径与项目文件的映射规则是:项目中\src\views\modules\文件夹\文件名.vue转换为请求路径:文件夹-文件名。所以我们应该在\src\views\modules\文件夹下创建一个product文件夹,并在product文件夹下创建category.vue
在category.vue中发送查询三级分类数据的请求:
getMenus() {
this.$http({
url: this.$http.adornUrl("/product/category/list/tree"),
method: "get"
}).then(({ data }) => {
console.log("成功获取到菜单数据...", data.data);
this.menus = data.data;
});
}
请求发送之后,出现404异常。分析可知是因为这个请求的url是http://localhost:8080/renren-fast/product/category/list/tree,但是查询三级分类数据在gulimall_product服务中,实际的请求应该是:http://localhost:10000/gulimall-product/product/category/list/tree,所以我们应该把renren-fast这个服务也加入到注册中心,之后通过网关的方式访问
将renren-fast加入到注册中心,在application.yml配置服务名与nacos注册中心的地址,并开启服务的注册与发现功能
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
application:
name: renren-fast
注册中心配置好之后,在网关服务的application.yml中配置路由规则
/* id 路由规则的id
* uri lb(Load Balance)指负载均衡://要路由到的服务(注册中心的服务名)
* predicates 断言:前台什么样的请求要做路由,我们把前台请求的基础路径都改为/api
* filters 路径重新
*/
- id: admin_route
uri: lb://renren-fast
predicates:
- Path=/api/**
/* 前端发送请求http://localhost:88/api/captcha.jpg到网关,网关发现满足路由要求,就会到注册中心找到renren-fast
* 的IP地址,把这个请求转到http://renren-fast:8080/api/captcha.jpg(把前端请求端口号后的内容拼到后面),因为
* 实际地址是http://renren-fast:8080/captcha.jpg没有api一层的,所以需要用filters
*/
filters:
- RewritePath=/api/(?<segment>.*),/renren-fast/$\{segment}
/* 以上是请求renren-fast获取验证码(http://localhost:88/api/captcha.jpg),
* 验证码获取之后,我们还要到商品服务中获取分类数据(http://localhost:10000/gulimall-product/product/category/list/tree)
* 所以还应该再配置一个路由规则,是路由到商品服务的
* 商品服务,前台发送http://localhost:88/api/product/category/list/tree,所以我们将/api/product路由到商品服务
*/
# 这里需要注意:admin_route和product_route都是路由/api,要把product_route放在admin_route的前面,
# 否则/api/product/的请求优先适配到admin_route的断言,那么转发的地址就不对
- id: product_route
uri: lb://gulimall-product
predicates:
- Path=/api/product/**
filters:
- RewritePath=/api/(?<segment>.*),/gulimall-product/$\{segment}
修改前台基础路径:在\static\config\index.js中修改
// api接口请求地址
window.SITE_CONFIG['baseUrl'] = 'http://localhost:88/api';
当index.js修改好之后,重新登录后台管理系统,发现控制台报错:这个报错的意思是跨域不能访问
1.1.3 网关统一配置跨域
- 什么是跨域?
跨域:指的是浏览器不能执行其他网站的脚本。它是由浏览器的同源策略造成的,是浏览器对javascript施加的安全限制。
同源策略:是指协议,域名,端口都要相同,其中有一个不同都会产生跨域。
请求的链接端口之前的内容只要不一致,就认为是跨域了,会被同源策略限制
- 跨域的流程是什么?
非简单请求浏览器会先发送预检请求,如果响应可以跨域,才会发送真实的请求。【什么是非简单请求】
- 怎么解决跨域的问题?
a> 使用nginx部署为统一域
假设有一台nginx服务器,我们将前端项目和后台项目都部署在nginx服务器中,浏览器访问前端项目,我们就访问nginx的地址,只要是静态请求,nginx默认代理给前端项目,动态请求nginx反向代理给网关,网关再转到其他服务
b> 添加响应头
跨域请求会发一个预检请求询问服务器能不能跨域,只要服务器返回可以跨域就可以了。所以可以给预检请求一个响应头,允许跨域访问。在响应头加上以下内容:
- Access-Control-Allow-Origin:支持哪些来源的请求跨域
- Access-Control-Allow-Methods:支持哪些方法跨域
- Access-Control-Allow-Credentials:跨域请求默认不包含cookie,设置为true可以包含cookie
Access-Control-Expose Headers:跨域请求暴露的字段
- CORS请求时, XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-L anguage、Content-Type、 Expires、 Last-Modified、 Pragma。 如果想拿到其他字段,就必须在Access-Control- Expose- Headers里面指定。
Access-Control-Max _Age:表明该响应的有效时间为多少秒。在有效时间内,浏览器无须为同一请求再次发起预检请求。请注意,浏览器自身维护了一个最大有效时间,如果该首部字段的值超过了最大有效时间,将不会生效。
- 在网关中处理跨域
每一个项目都可能被远程访问,所以我们可以在网关中处理跨域。
@Configuration
public class GuliMallCrosConfig {
@Bean
public CorsWebFilter corsWebFilter(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
//1、配置跨域
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.setAllowCredentials(true);
source.registerCorsConfiguration("/**",corsConfiguration);
return new CorsWebFilter(source);
}
}
在处理跨域之前,我们发送请求时,请求头是下图这样的
处理跨域之后,我们的请求头中多了允许跨域的内容,OPTIONS只是打头兵,真正的请求在OPTIONS请求成功之后发送的
注意:renren-fast服务中也处理了跨域,要把renren-fast服务中的代码注释掉,否则处理两次跨域,内容重复,会报错
1.1.4 树形展示三级分类数据
引入elementUI的树形组件,展示三级分类数据【elementUI官网】
<template>
// 引入elementui的树形组件
<el-tree :data="menus" :props="defaultProps" @node-click="handleNodeClick" />
</template>
data() {
return {
menus: [],
defaultProps: {
children: "children",
// 哪一个字段作为展示的名称
label: "name"
}
};
},
methods: {
getMenus() {
this.$http({
url: this.$http.adornUrl("/product/category/list/tree"),
method: "get"
}).then(({ data }) => {
console.log("成功获取到菜单数据...", data.data);
// 将后台查到的数据放在menus中,并在elementui的树形组件展示
this.menus = data.data;
});
}
},
1.1.5 增删改查功能完善
具体功能实现看代码吧,学一下ElementUI和Vue
MyBatisPlus的逻辑删除【MyBatisPlus官网】
配置全局的逻辑删除规则
mybatis-plus: global-config: db-config: logic-delete-field: flag # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2) logic-delete-value: 1 # 逻辑已删除值(默认为 1) logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
配置逻辑删除的组件(高版本可省略)
给Bean加上逻辑删除注解
/** * 是否显示[0-不显示,1显示] * value:默认逻辑未删除值(该值可无、会自动获取全局配置) * delval:默认逻辑删除值(该值可无、会自动获取全局配置) */ @TableLogic(value = "1",delval = "0") private Integer showStatus;
1.2 品牌管理
1.2.1 使用逆向工程的前后端代码
在商品系统菜单下新建品牌管理目录,使用逆向工程中品牌管理的Vue代码,并对细节部分做出修改与调整,具体调整看源码。
新增品牌时,需要上传品牌的logo,上传的是一张图片,所以需要考虑上传的图片如何存储的问题。
如果是单体服务,浏览器上传文件之后,将文件存储在部署当前服务的服务器的某个位置,下次需要这个文件从服务器读取出来就行了。分布式情况下,如果也把文件存储在部署当前服务的服务器上,假设第一次上传文件把文件存储在了B服务器,下次请求时请求的是C服务器,但是C服务器上没有之前上传的文件,就会有问题。
可以专门设置一台文件存储的服务器。有两种方式:自建服务器和云存储,云存储显然比自建服务器更合适一些,我们当前使用阿里云对象存储。
1.2.2 云存储OSS开通与使用
要使用阿里云对象存储,首先需要先去阿里云中开通对象存储服务(阿里云官网,搜索对象存储,按照提示开通就行)。
将文件上传到阿里云对象存储有三种方式:
- 普通上传方式:浏览器上传文件到我们的应用服务,应用服务将用户上传的文件再提交到阿里云对象存储。没有必要让文件经过应用服务器,多余
用户直接上传到OSS:用户直接上传给OSS意味着我们要将OSS的用户名密码暴露出来,不安全
签名后直传:如下图,这种方式比较安全,不用暴露OSS的用户名密码,用户向服务器要到签名数据,之后直接把文件上传到OSS。推荐这种方式
签名后直传要怎么实现?
Spring Cloud Alibaba中包含了如何使用阿里云对象存储的组件,可以看看熟悉一下。【Spring Cloud Alibaba官方文档】
在阿里云官网,也有签名后直传的详细实例:【签名后直传】
因为我们后续还有很多使用到第三方服务的地方,所以新建一个专门用来调用第三方服务的工程gulimall_thirdpart,将这个工程加入注册中心,之后新建一个Controller,处理获取签名的请求(代码复制阿里云官网中关于签名后直传的实例,稍作修改)
@RestController
public class OssController {
@Autowired
OSS ossClient;
@Value("${alibaba.cloud.oss.bucket}")
private String bucket;
@Value("${alibaba.cloud.oss.endpoint}")
private String endpoint;
@Value("${alibaba.cloud.access-key}")
private String accessKeyId;
@RequestMapping("/oss/policy")
public R getPolicy(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
//https://gulimall-hello.oss-cn-beijing.aliyuncs.com/hahaha.jpg
String host = "https://" + bucket + "." + endpoint;
// 用户上传文件时指定的前缀
String date = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
String dir = date + "/";
Map<String, String> respMap = null;
try {
long expireTime = 30;
long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
Date expiration = new Date(expireEndTime);
// PostObject请求最大可支持的文件大小为5 GB,即CONTENT_LENGTH_RANGE为5*1024*1024*1024。
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);
String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes("utf-8");
String encodedPolicy = BinaryUtil.toBase64String(binaryData);
String postSignature = ossClient.calculatePostSignature(postPolicy);
respMap = new LinkedHashMap<String, String>();
respMap.put("accessid", accessKeyId);
respMap.put("policy", encodedPolicy);
respMap.put("signature", postSignature);
respMap.put("dir", dir);
respMap.put("host", host);
respMap.put("expire", String.valueOf(expireEndTime / 1000));
} catch (Exception e) {
// Assert.fail(e.getMessage());
System.out.println(e.getMessage());
} finally {
ossClient.shutdown();
}
return R.ok().put("data",respMap);
}
}
因为我们所有的请求都是经过网关,所以在网关中配置gulimall_thirdpart服务的路由规则
- id: third_route
uri: lb://gulimall-thirdpart
predicates:
- Path=/api/third/**
filters:
- RewritePath=/api/(?<segment>.*),/$\{segment}
1.2.3 前后端联调测试文件上传
在使用前端组件进行文件上传时,会产生如下跨域的错误
需要去阿里云的对象存储中设置跨域,设置成功之后再上传
1.2.4 JSR303
JSR-303 是 JAVA EE 6 中的一项子规范,叫做 Bean Validation。它提供了对 Java EE 和 Java SE 中的 Java Bean 进行验证的方式。
1.2.4.1 数据校验
数据不仅需要前端校验,后端也必须校验,因为可能会绕过前端,直接向后台发送请求,比如接口过来的数据。后台校验我们使用JSR303。
要使用校验注解,我们首先得在gulimall_common中引入依赖
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
- 给Bean添加校验注解,并定义自己的message提示,要给哪个Entity的字段添加校验,就在字段上添加注解
不定义的话就会取默认的提示信息,默认提示信息是配置在org\hibernate\validator\ValidationMessages.properties中的
public class BrandEntity implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 品牌名
*/
@NotBlank(message = "品牌名必须提交")
private String name;
/**
* 品牌logo地址
*/
@NotBlank
@URL(message = "logo必须是一个合法的url地址")
private String logo;
}
只给Bean添加校验注解是没有效果的,还需要开启校验功能@Valid; 校验错误以后会有默认的响应;
@RequestMapping("/save") public R save(@Valid @RequestBody CategoryEntity category){ categoryService.save(category); return R.ok(); }
```json // 入参数据 { “name”:””, “logo”:”11” }
// 校验之后默认的响应 { “timestamp”: “2022-01-06T12:16:18.079+0000”, “status”: 400, “error”: “Bad Request”, “errors”: [ { “codes”: [ “NotBlank.brandEntity.name”, “NotBlank.name”, “NotBlank.java.lang.String”, “NotBlank” ], “arguments”: [ { “codes”: [ “brandEntity.name”, “name” ], “arguments”: null, “defaultMessage”: “name”, “code”: “name” } ], “defaultMessage”: “不能为空”, “objectName”: “brandEntity”, “field”: “name”, “rejectedValue”: “”, “bindingFailure”: false, “code”: “NotBlank” }, { “codes”: [ “URL.brandEntity.logo”, “URL.logo”, “URL.java.lang.String”, “URL” ], “arguments”: [ { “codes”: [ “brandEntity.logo”, “logo” ], “arguments”: null, “defaultMessage”: “logo”, “code”: “logo” }, [], { “defaultMessage”: “”, “arguments”: null, “codes”: [ “” ] }, -1, { “defaultMessage”: “”, “arguments”: null, “codes”: [ “” ] }, { “defaultMessage”: “.“, “arguments”: null, “codes”: [ “.“ ] } ], “defaultMessage”: “需要是一个合法的URL”, “objectName”: “brandEntity”, “field”: “logo”, “rejectedValue”: “11”, “bindingFailure”: false, “code”: “URL” } ], “message”: “Validation failed for object=’brandEntity’. Error count: 2”, “path”: “/product/brand/save” }
3. 给校验的bean后紧跟一个BindingResult,就可以获取到校验的结果
虽然校验之后会有默认的响应数据,但是这个响应不符合我们的业务规范,所以还需要对校验的响应进行处理
```java
@RequestMapping("/save")
public R save(@Valid @RequestBody BrandEntity brand, BindingResult result) {
if (result.hasErrors()) {
Map<String, String> map = new HashMap<>();
//1、获取校验的错误结果
result.getFieldErrors().forEach((item) -> {
//FieldError 获取到错误提示
String message = item.getDefaultMessage();
//获取错误的属性的名字
String field = item.getField();
map.put(field, message);
});
return R.error(400, "提交的数据不合法").put("data", map);
} else {
brandService.save(brand);
}
return R.ok();
}
1.2.4.2 统一异常处理
我们不可能在每一个方法里面都用BindingResult去处理校验返回的结果。所以我们可以定义统一处理异常的类。
/**
* 集中处理所有异常
* @RestControllerAdvice:表明这是一个异常处理类
*/
@Slf4j
//@ResponseBody
//@ControllerAdvice(basePackages = "com.yaosy.gulimall.product.controller")
@RestControllerAdvice(basePackages = "com.yaosy.gulimall.product.controller")
public class GulimallExceptionControllerAdvice {
// @ExceptionHandler 标注当前方法可以处理的异常
@ExceptionHandler(value= MethodArgumentNotValidException.class)
public R handleVaildException(MethodArgumentNotValidException e){
log.error("数据校验出现问题{},异常类型:{}",e.getMessage(),e.getClass());
BindingResult bindingResult = e.getBindingResult();
Map<String,String> errorMap = new HashMap<>();
bindingResult.getFieldErrors().forEach((fieldError)->{
errorMap.put(fieldError.getField(),fieldError.getDefaultMessage());
});
return R.error(BizCodeEnume.VAILD_EXCEPTION.getCode(), BizCodeEnume.VAILD_EXCEPTION.getMsg()).put("data",errorMap);
}
@ExceptionHandler(value = Throwable.class)
public R handleException(Throwable throwable){
log.error("错误:",throwable);
return R.error(BizCodeEnume.UNKNOW_EXCEPTION.getCode(),BizCodeEnume.UNKNOW_EXCEPTION.getMsg());
}
}
1.2.4.3 分组校验(多场景的复杂校验)
假设新增时不需要校验品牌ID是否为空,但是修改时需要校验品牌ID是否为空,就可以做分组校验。groups需要定义一个类型值,所以我们可以在common中定义一个接口,不需要有做其他处理
public interface UpdateGroup {}
@NotBlank(groups = UpdateGroup.class)
private Long brandId;
在Controller的方法中给校验注解标注什么情况需要进行校验
@RequestMapping("/save")
public R save(@Validated(UpdateGroup.class) @RequestBody BrandEntity brand){
brandService.save(brand);
return R.ok();
}
需要注意:默认没有指定分组的校验注解@NotBlank,在分组校验情况@Validated({AddGroup.class})下不生效,只会在@Validated生效;
1.2.4.4 自定义校验注解
编写一个自定义的校验注解
@Documented @Constraint(validatedBy = { ListValueConstraintValidator.class }) @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE }) @Retention(RUNTIME) public @interface ListValue { String message() default "{com.atguigu.common.valid.ListValue.message}"; Class<?>[] groups() default { }; Class<? extends Payload>[] payload() default { }; int[] vals() default { }; }
编写一个自定义的校验器 ConstraintValidator
public class ListValueConstraintValidator implements ConstraintValidator<ListValue,Integer> { private Set<Integer> set = new HashSet<>(); //初始化方法 @Override public void initialize(ListValue constraintAnnotation) { int[] vals = constraintAnnotation.vals(); for (int val : vals) { set.add(val); } } /** * * @param value 需要校验的值 * @param context * @return */ @Override public boolean isValid(Integer value, ConstraintValidatorContext context) { return set.contains(value); } }
关联自定义的校验器和自定义的校验注解:用自定义注解上的4个注解进行关联,关联之后我们就可以在需要的地方使用我们自定义的注解
@Documented @Constraint(validatedBy = { ListValueConstraintValidator.class }) @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE }) @Retention(RUNTIME)
1.3 SPU & SKU & 规格参数 & 销售属性
SPU(Standard Product Unit):标准化产品单元,是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。
SKU(Stock Keepilg Unit):库存量单位,即库存进出计量的基本单元,可以是以件,盒,托盘等为单位。SKU这是对于大型连锁超市DC(配送中心)物流管理的一个必要的方法。现在已经被引申为产品统一编号的简称,每种产品均对应有唯一的SKU号。
规格参数(基本属性) & 销售属性:每个分类下的商品共享规格参数,与销售属性。只是有些商品不一定要用这个分类下全部的属性:
- 属性是以三级分类组织起来的
- 规格参数中有些是可以提供检索的
- 规格参数也是基本属性,他们具有自己的分组
- 属性的分组也是以三级分类组织起来的
- 属性名确定的,但是值是每一个商品不同来决定的