谷粒商城之高级篇
目录
- 谷粒商城之高级篇
- 前言
- 2 商城业务
- 2.1 商品上架
- 2.1.1 商品Mapping
- 2.1.2 上架细节
- 2.1.3 数据一致性
- 2.1.4 代码实现
- 2.2 商城系统首页
- 2.2.1 渲染首页
- 2.2.2 渲染一级分类数据
- 2.2.3 渲染二级三级分类数据
- 2.2.4 nginx 搭建域名访问环境
- 2.3 检索业务
- 2.3.1 页面环境搭建
- 2.3.2 调整页面跳转
- 2.3.3 检索返回结果模型分析抽取
- 2.3.4 检索DSL语句
- 2.3.5 检索语句构建&&结果提取封装
- 2.3.6 页面基本数据渲染
- 2.3.7 页面筛选条件渲染
- 2.3.8 页面分页数据渲染
- 2.3.9 页面排序功能
- 2.3.10 页面排序字段回显
- 2.3.11 页面价格区间搜索&&仅显示有货
- 2.3.12 面包屑导航
- 2.4 商品详情
- 2.4.1 环境搭建
- 2.4.2 模型抽取
- 2.4.3 规格参数代码实现
- 2.4.4 销售属性组合代码实现
- 2.4.5 详情页渲染
- 2.4.6 销售属性渲染
- 2.4.7 异步编排优化代码
- 2.5 认证服务
- 2.5.1 环境搭建
- 2.5.2 短信验证码
- 2.5.3 验证码之防刷校验
- 2.5.4 一步一坑的注册页环境
- 2.5.5 异常机制
- 2.5.6 [MD5](https://so.csdn.net/so/search?q=MD5&spm=1001.2101.3001.7020)&盐值&BCrypt
- 2.5.7 注册完成
- 2.5.8 账户密码登录完成
- 2.5.9 社交登录
- 2.5.10 分布式session
- 2.5.11 SpringSession 整合 redis 完成 session域问题
- 2.5.12 SpringSession原理--装饰者模式
- 2.5.13 页面效果完善
- 2.5.14 单点登录
前言
高级篇正式开始啦!
PS 第一章 ElasticSearch 参见 另外一篇文章 谷粒商城之高级篇知识补充。
2 商城业务
2.1 商品上架
2.1.1 商品Mapping
ES是将数据存储在内存中,所以在检索中优于mysql。ES也支持集群,数据分片存储。
需求:
上架的商品才可以在网站上展示,没有上架的商品存储在数据库中。
上架的商品需要可以被检索。
分析sku在es中如何存储:
商品mapping
分析:商品上架在es中是存sku还是spu?
1)检索的时候输入名字,是需要按照sku的title进行全文检索的
2)检索使用商品规格,规格是spu的公共属性,每个spu是一样的
3)按照分类id进去的都是直接列出spu的,还可以切换。
4〕我们如果将sku的全量信息保存到es中(包括spu属性〕就太多字段了
方案1:方便检索
{skuId:1spuId:11skyTitile:华为xxprice:999saleCount:99attr:[{尺寸:5},{CPU:高通945},{分辨率:全高清}]
}
缺点:如果每个sku都存储规格参数(如尺寸),会有冗余存储,因为每个spu对应的sku的规格参数都一样
冗余:
举例:100万*20=2000MB=2G方案2:分布式
sku索引
{spuId:1skuId:11xxx
}
attr索引
{skuId:11attr:[{尺寸:5},{CPU:高通945},{分辨率:全高清}]
}举例:
先找到4000个符合要求的spu,再根据4000个spu查询对应的属性,封装了4000个id,long 8B*4000=32000B=32KB
1K个人检索,就是32MB结论:如果将规格参数单独建立索引,会出现检索时出现大量数据传输的问题,会引起网络拥堵
因此选用方案1,以空间换时间
建立product索引
最终选用的数据模型:
PUT product
{"mappings":{"properties": {"skuId":{ "type": "long" },"spuId":{ "type": "keyword" }, # 不可分词"skuTitle": {"type": "text","analyzer": "ik_smart" # 中文分词器},"skuPrice": { "type": "keyword" },"skuImg" : { "type": "keyword" },"saleCount":{ "type":"long" },"hasStock": { "type": "boolean" },"hotScore": { "type": "long" },"brandId": { "type": "long" },"catalogId": { "type": "long" },"brandName": {"type": "keyword"},"brandImg":{"type": "keyword","index": false, # 不可被检索,不生成index"doc_values": false # 不可被聚合},"catalogName": {"type": "keyword" },"attrs": { # attrs:当前sku的属性规格"type": "nested","properties": {"attrId": {"type": "long" },"attrName": {"type": "keyword","index": false,"doc_values": false},"attrValue": {"type": "keyword" }}}}}
}
其中
“type”: “keyword” 保持数据精度问题,可以检索,但不分词
“index”:false 代表不可被检索
“doc_values”: false 不可被聚合,es就不会维护一些聚合的信息
冗余存储的字段:不用来检索,也不用来分析,节省空间
库存是bool。
检索品牌id,但是不检索品牌名字、图片
用skuTitle检索
nested嵌入式对象
属性是"type": “nested”,因为是内部的属性进行检索
数组类型的对象会被扁平化处理(对象的每个属性会分别存储到一起)
user.name=[“aaa”,“bbb”]
user.addr=[“ccc”,“ddd”]
这种存储方式,可能会发生如下错误:
错误检索到{aaa,ddd},这个组合是不存在的
数组的扁平化处理会使检索能检索到本身不存在的,为了解决这个问题,就采用了嵌入式属性,数组里是对象时用嵌入式属性(不是对象无需用嵌入式属性)
nested阅读:https://blog.csdn.net/weixin_40341116/article/details/80778599
参考使用聚合:https://blog.csdn.net/kabike/article/details/101460578
课件内容:
分析:商品上架在es 中是存sku 还是spu?
1)、检索的时候输入名字,是需要按照sku 的title 进行全文检索的
2)、检索使用商品规格,规格是spu 的公共属性,每个spu 是一样的
3)、按照分类id 进去的都是直接列出spu 的,还可以切换。
4)、我们如果将sku 的全量信息保存到es 中(包括spu 属性)就太多量字段了。
5)、我们如果将spu 以及他包含的sku 信息保存到es 中,也可以方便检索。但是sku 属于
spu 的级联对象,在es 中需要nested 模型,这种性能差点。
6)、但是存储与检索我们必须性能折中。
7)、如果我们分拆存储,spu 和attr 一个索引,sku 单独一个索引可能涉及的问题。
检索商品的名字,如“手机”,对应的spu 有很多,我们要分析出这些spu 的所有关联属性,
再做一次查询,就必须将所有spu_id 都发出去。假设有1 万个数据,数据传输一次就
10000*4=4MB;并发情况下假设1000 检索请求,那就是4GB 的数据,,传输阻塞时间会很
长,业务更加无法继续。
所以,我们如下设计,这样才是文档区别于关系型数据库的地方,宽表设计,不能去考虑数
据库范式。
index:
默认true,如果为false,表示该字段不会被索引,但是检索结果里面有,但字段本身不能
当做检索条件。
doc_values:
默认true,设置为false,表示不可以做排序、聚合以及脚本操作,这样更节省磁盘空间。
还可以通过设定doc_values 为true,index 为false 来让字段不能被搜索但可以用于排序、聚
合以及脚本操作:
2.1.2 上架细节
上架是将后台的商品放在es 中可以提供检索和查询功能。
1)、hasStock:代表是否有库存。默认上架的商品都有库存。如果库存无货的时候才需要
更新一下es
2)、库存补上以后,也需要重新更新一下es
3)、hotScore 是热度值,我们只模拟使用点击率更新热度。点击率增加到一定程度才更新
热度值。
4)、下架就是从es 中移除检索项,以及修改mysql 状态
商品上架步骤:
1)、先在es 中按照之前的mapping 信息,建立product 索引。
2)、点击上架,查询出所有sku 的信息,保存到es 中
3)、es 保存成功返回,更新数据库的上架状态信息。
2.1.3 数据一致性
1)、商品无库存的时候需要更新es 的库存信息
2)、商品有库存也要更新es 的信息
2.1.4 代码实现
POST /product/spuinfo/{spuId}/up
- SpuInfoController:
/*** /product/spuinfo/{spuId}/up* 商品上架功能*/@PostMapping("/{spuId}/up")public R spuUp(@PathVariable("spuId") Long spuId){spuInfoService.up(spuId);return R.ok();}
product里组装好,search里保存到es中,进行商品上架
- 商品上架entity
商品上架需要在es中保存spu信息并更新spu的状态信息,由于SpuInfoEntity与索引的数据模型并不对应,所以我们要建立专门的vo进行数据传输。
//商品在 es中保存的数据模型
@Data
public class SkuEsModel {private Long skuId;private Long spuId;private String skuTitle;private BigDecimal skuPrice;private String skuImg;private Long saleCount;private Boolean hasStock;private Long hotScore;private Long brandId;private Long catalogId;private String brandName;private String brandImg;private String catalogName;private List<Attrs> attrs;@Datapublic static class Attrs {private Long attrId;private String attrName;private String attrValue;}}
- 商品上架service
sku的规格参数相同,因此我们要将查询规格参数提前,只查询一次
1)在ware微服务里添加"查询sku是否有库存"的controller
WareSkuController
//查询sku 是否有库存@PostMapping("/hasstock")public R getSkuHasStock(@RequestBody List<Long> skuIds){//sku_id,stockList<SkuHasStockVo> vos = wareSkuService.getSkuHasStock(skuIds);return R.ok().setData(vos);}
WareSkuServiceImpl
@Overridepublic List<SkuHasStockVo> getSkuHasStock(List<Long> skuIds) {List<SkuHasStockVo> collect = skuIds.stream().map(skuId -> {SkuHasStockVo vo = new SkuHasStockVo();//查询当前 sku的总库存量//SELECT SUM(stock-stock_locked) FROM `wms_ware_sku` WHERE sku_id = 1Long count = baseMapper.getSkuStock(skuId);vo.setSkuId(skuId);vo.setHasStock(count==null?false:count>0);return vo;}).collect(Collectors.toList());return collect;}
WareSkuDao
Long getSkuStock(Long skuId);//一个参数的话,可以不用写@Param,多个参数一定要写,方便区分
WareSkuDao.xml
</update><select id="getSkuStock" resultType="java.lang.Long">SELECT SUM(stock-stock_locked) FROM `wms_ware_sku` WHERE sku_id = #{skuId}</select>
SkuHasStockVo
@Data
public class SkuHasStockVo {private Long skuId;private Boolean hasStock;}
然后用feign调用
在 package com.atguigu.gulimall.product.feign下:
@FeignClient("gulimall-ware") //说明调用哪一个 远程服务
public interface WareFeignService {/*** 1、R设计的时候可以加上泛型* 2、直接返回我们想要的结果* 3、自己封装解析结果* @param skuIds* @return*/@PostMapping("/ware/waresku/hasstock")//注意路径复制完全R getSkuHasStock(@RequestBody List<Long> skuIds);
}
2)将 R 工具类进行改装
public class R extends HashMap<String, Object> {private static final long serialVersionUID = 1L;//利用 阿里巴巴提供的fastjson 进行逆转public <T> T getData(TypeReference<T> typeReference){Object data = get("data");//默认是mapString s = JSON.toJSONString(data);T t = JSON.parseObject(s, typeReference);return t;}public R setData(Object data){put("data",data);return this;}
...
3)收集成map的时候,toMap()参数为两个方法,如 SkyHasStockVo::getSkyId,item->item.getHasStock()
将封装好的SkuInfoEntity,调用search的feign,保存到es中
ElasticSaveController
@Slf4j
@RequestMapping("/search/save")
@RestController
public class ElasticSaveController {@AutowiredProductSaveService productSaveService;//上架商品// 添加@RequestBody 将 请求体中的 List<SkuEsModel> 集合转换为json数据,因此请求方式必须为 @PostMapping@PostMapping("/product")public R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels){// 如果返回的是 boolean 类型的false,说明我们的 sku数据有问题//如果返回的是 catch里面的内容,可能是 es 客户端连接不上了boolean b = false;try {b = productSaveService.productStatusUp(skuEsModels);}catch (Exception e){log.error("ElasticSaveController商品上架错误: {}",e);return R.error(BizCodeEnume.PRODUCT_UP_EXCEPTION.getCode(), BizCodeEnume.PRODUCT_UP_EXCEPTION.getMsg());}if (!b){return R.ok();}else {return R.error(BizCodeEnume.PRODUCT_UP_EXCEPTION.getCode(), BizCodeEnume.PRODUCT_UP_EXCEPTION.getMsg());}}
}
ProductSaveServiceImpl
@Slf4j
@Service
public class ProductSaveServiceImpl implements ProductSaveService {@AutowiredRestHighLevelClient restHighLevelClient;@Overridepublic boolean productStatusUp(List<SkuEsModel> skuEsModels) throws IOException {//保存到es//1.给 es 中建立索引。product,建立好映射关系。//2.给 es 中保存这些数据BulkRequest bulkRequest = new BulkRequest();for (SkuEsModel model : skuEsModels) {//1.构造保存请求IndexRequest indexRequest = new IndexRequest(EsConstant.PRODUCT_INDEX);indexRequest.id(model.getSkuId().toString());String s = JSON.toJSONString(model);indexRequest.source(s, XContentType.JSON);bulkRequest.add(indexRequest);}//BulkRequest bulkRequest, RequestOptions optionsBulkResponse bulk = restHighLevelClient.bulk(bulkRequest, GulimallElasticSearchConfig.COMMON_OPTIONS);//TODO 1、如果批量错误boolean b = bulk.hasFailures();List<String> collect = Arrays.stream(bulk.getItems()).map(item -> {return item.getId();}).collect(Collectors.toList());log.info("商品上架完成:{},返回数据:{}",collect,bulk.toString());return b;}
}
EsConstant
public class EsConstant {public static final String PRODUCT_INDEX = "product"; //sku数据在 es中的索引}
fenign 调用: gulimall-product 调用 gulimall-search
SearchFeignService
@FeignClient("gulimall-search")
public interface SearchFeignService {@PostMapping("/search/save/product")R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels);
}
4)上架失败返回R.error(错误码,消息)
此时再定义一个错误码枚举。在接收端获取他返回的状态码
BizCodeEnume
PRODUCT_UP_EXCEPTION(11000,"商品上架异常");
5)上架后再让数据库中变为上架状态
这里在 gulimall-common 包下的 ProductConstant 创建一个新的枚举类
public class ProductConstant {...public enum StatusEnum {NEW_SPU(0,"新建"), SPU_UP(1,"商品上架"),SPU_DOWN(2,"商品下架");private int code;private String msg;StatusEnum(int code, String msg) {this.code = code;this.msg = msg;}public int getCode() {return code;}public String getMsg() {return msg;}}
}
6)mybatis为了能兼容接收null类型,要把long
改为Long
debug时很容易远程调用异常,因为超时了
商品上架代码
SpuInfoController
/*** /product/spuinfo/{spuId}/up* 商品上架功能*/@PostMapping("/{spuId}/up")public R spuUp(@PathVariable("spuId") Long spuId){spuInfoService.up(spuId);return R.ok();}
SpuInfoServiceImpl
@Overridepublic void up(Long spuId) {//1.查出当前 spuid 对应的所有 sku信息、品牌的名字List<SkuInfoEntity> skus = skuInfoService.getSkusBySpuId(spuId);List<Long> skuIdList = skus.stream().map(SkuInfoEntity::getSkuId).collect(Collectors.toList());//TODO 4、查询当前sku的所有可以用来被检索的规格属性,List<ProductAttrValueEntity> baseAttrs = attrValueService.baseAttrlistforspu(spuId);List<Long> attrIds = baseAttrs.stream().map(attr -> { //返回所有属性的idreturn attr.getAttrId();}).collect(Collectors.toList());List<Long> searchAttrIds = attrService.selectSearchAttrIds(attrIds);Set<Long> idSet = new HashSet<>(searchAttrIds);//因为是kv 键值对,转换成 set 集合比较方便// 从 baseAttrs 集合中 过滤 出 attrValueEntities 集合List<SkuEsModel.Attrs> attrsList = baseAttrs.stream().filter(item -> {return idSet.contains(item.getAttrId());}).map(item -> { //将 set集合 映射 成 map集合SkuEsModel.Attrs attrs = new SkuEsModel.Attrs();BeanUtils.copyProperties(item, attrs);//属性对拷:item 是数据库中查出来的数据return attrs;}).collect(Collectors.toList());//TODO 1、发送远程调用,库存系统查询是否有库存//由于远程调用可能出现网络问题,所以需要进行try - catch处理一下Map<Long, Boolean> stockMap = null;try {R r = wareFeignService.getSkuHasStock(skuIdList);TypeReference<List<SkuHasStockVo>> typeReference = new TypeReference<List<SkuHasStockVo>>(){};stockMap = r.getData(typeReference).stream().collect(Collectors.toMap(SkuHasStockVo::getSkuId, item -> item.getHasStock()));}catch (Exception e){log.error("库存服务查询异常:原因{}",e);}//2.封装每个sku的信息Map<Long, Boolean> finalStockMap = stockMap;List<SkuEsModel> upProducts = skus.stream().map(sku -> {//通过 stream API 将 skus中的 数据遍历//组装我们需要的数据SkuEsModel esModel = new SkuEsModel();BeanUtils.copyProperties(sku, esModel);//属性对拷,将 sku中的属性 拷贝到 esmodel中//需要单独处理的数据 ,SkuInfoEntity 和 SkuEsModel中相比少的数据。//skuPrice,skuImgesModel.setSkuPrice(sku.getPrice());esModel.setSkuImg(sku.getSkuDefaultImg());//hotScore(热度评分) hasStock(库存)//设置库存信息//如果远程调用出现问题,默认给 true值;如果没有问题,那就赋真正的值if (finalStockMap == null){esModel.setHasStock(true);}else {esModel.setHasStock(finalStockMap.get(sku.getSkuId()));}//TODO 2、热度评分。0esModel.setHotScore(0L);//这里的热度评分应该是一个比较复杂的操作,这里简单处理一下//TODO 3、查询品牌和分类的名字信息//品牌BrandEntity brand = brandService.getById(esModel.getBrandId());esModel.setBrandName(brand.getName());esModel.setBrandImg(brand.getLogo());//分类CategoryEntity category = categoryService.getById(esModel.getCatalogId());esModel.setCatalogName(category.getName());//设置检索属性esModel.setAttrs(attrsList);return esModel;}).collect(Collectors.toList());//TODO 5、将数据发送给 es 进行保存,gulimall-searchR r = searchFeignService.productStatusUp(upProducts);if (r.getCode() == 0){//远程调用成功//TODO 6、修改当前spu的状态baseMapper.updataSpuStatus(spuId, ProductConstant.StatusEnum.SPU_UP.getCode());}else {//远程调用失败//TODO 7、重复调用?接口幂等性;重试机制? xxx//Feign 调用流程/*** 1.构造请求数据,将对象转为json;* RequestTemplate template = buildTemplateFromArgs.create(argv);* 2.发送请求进行执行(执行成功会解码响应数据);* executeAndDecode(template)'* 3.执行请求会有重试机制* while(true){* try{* executeAndDecode(template);* }catch(){* try{ retryer.continueOrPropagate(e);}catch(){throw ex;* continue;* }* }**/}}
Feign
这里再次 将 feign 接口代码展示出来:
gulimall-product 调用 gulimall-search 将 商品上架内容保存在 ElasticSearch中,方便全文检索:
SearchFeignService
@FeignClient("gulimall-search")
public interface SearchFeignService {@PostMapping("/search/save/product")R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels);
}
gulimall-product 调用 gulimall-ware 将 查询 商品库存:
WareFeignService
@FeignClient("gulimall-ware") //说明调用哪一个 远程服务
public interface WareFeignService {/*** 1、R设计的时候可以加上泛型* 2、直接返回我们想要的结果* 3、自己封装解析结果* @param skuIds* @return*/@PostMapping("/ware/waresku/hasstock")//注意路径复制完全R getSkuHasStock(@RequestBody List<Long> skuIds);
}
ps:
这里可以用到的idea 快捷键:
- ctrl + e 可以快速调出最近使用的(打开最近修改的文件)
快速从 controller 跳转 到 实现类
ctrl + shift + 鼠标左键
从 controller 跳转到 接口
ctrl + 鼠标左键
生成 try-catch等(surround with)
alt + shift +z
生成构造器/get/set/toString
alt + shift + s
7)效果展示
商品成功上架,显示状态 为 已上架
2.2 商城系统首页
不使用前后端分离开发了,管理后台用vue
nginx发给网关集群,网关再路由到微服务静态资源放到nginx中
2.2.1 渲染首页
- 依赖
导入thymeleaf依赖
<!--模板引擎:thymeleaf--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency>
- html\首页资源\index 放到 gulimall-product 下的static文件夹
index.html 放到 templates中
- 关闭thymeleaf缓存,方便开发实时看到更新
thymeleaf:cache: false
- web开发放到web包下,原来的controller是前后分离对接手机等访问的,所以可
以改成app,对接app应用。
web 包:存放专门进行页面跳转的controller
rest 接口对接的使我们分离的项目(比如手机的一些 app ):将controller 改名为 app
- 效果展示:访问首页
2.2.2 渲染一级分类数据
编写 处理首页的controller
gulimall-product的 web 包下新建 IndexController
@Controller
public class IndexController {@AutowiredCategoryService categoryService;@GetMapping({"/","/index.html"})public String indexPage(Model model){//TODO 1.查出所有的1级分类List<CategoryEntity> categoryEntities = categoryService.getLevel1Categorys();//spring mvc提供了一个 model 接口// 给 model 中放的数据,就会默认放到页面的请求域中,因为是转发。所以使用addAttribute//给首页 放一个属性 ,属性名: categorys 属性值:categoryEntities------以后来到 index页面,就可以直接取出 属性。model.addAttribute("categorys",categoryEntities);// 如果返回的 是 逻辑视图(也就是页面地址) ,就会进行拼串//视图解析器进行拼串://classpath:/ 表示类路径下 :resources下:文件夹右下角 有一个小图标//默认规则:默认前缀:public static final String DEFAULT_PREFIX = "classpath:/templates/";// 默认后缀:public static final String DEFAULT_SUFFIX = ".html";// classpath:/templates/ + 返回值 + .htmlreturn "index";}}
编写 获取 1级分类的实现
CategoryServiceImpl
/*** 查找 1级分类* parent_cid = 0 或者 cat_level = 1* @return*/@Overridepublic List<CategoryEntity> getLevel1Categorys() {List<CategoryEntity> categoryEntities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));return categoryEntities;}
引入 热部署依赖devtools使页面实时生效
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-devtools</artifactId><optional>true</optional></dependency>
首页遍历一级分类菜单数据
修改 index.html
<!--轮播主体内容--><div class="header_main">....<div class="header_main_left"><ul><li th:each="category : ${categorys}"><a href="#" class="header_main_left_a" th:attr="ctg-data=${category.catId}" ><b th:text="${category.name}">家用电器</b></a></li></ul></div>......
thymeleaf 知识小补充(复习):
thymeleaf官网:https://www.thymeleaf.org/
- ${}:动态取值
th:text="${category.name}"
- th:each:遍历
<tr th:each="prod : ${prods}">
prod : 当前元素
${prods}:要遍历的对象
th:each="category : ${categorys}"
- 自定义属性:我们需要获得 分类的 id
<input type="submit" value="Subscribe!" th:attr="value=#{subscribe.submit}"/>
value:属性名叫什么
#{subscribe.submit}:属性值叫什么
th:attr="ctg-data=${category.catId}"
原生属性:
th:value="#{subscribe.submit}"
效果展示:
2.2.3 渲染二级三级分类数据
当 鼠标滑到 1级分类时,展示 它的二级分类数据及三级分类数据。
利用 catalogLoader.js
来获取请求,解析展示数据。
按照 此json 数据方式
新建 Catelog2Vo封装 数据
/*** 2级分类 vo** @author wystart* @create 2022-11-24 21:53*/
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Catelog2Vo {private String catalog1Id;//1级分类idprivate List<Catelog3Vo> catalog3List; //三级子分类private String id;private String name;/*** 三级分类 vo* "catalog2Id":"61",* "id":"610",* "name":"商务休闲鞋"*/@NoArgsConstructor@AllArgsConstructor@Datapublic static class Catelog3Vo {private String catalog2Id; //父分类,2级分类 idprivate String id;private String name;}}
IndexController
//index/catalog.json@ResponseBody@GetMapping("/index/catalog.json")public Map<String, List<Catelog2Vo>> getCatalogJson() {Map<String, List<Catelog2Vo>> catalogJson = categoryService.getCatalogJson();return catalogJson;}
CategoryServiceImpl
@Overridepublic Map<String, List<Catelog2Vo>> getCatalogJson() {//1.查出所有1级分类List<CategoryEntity> level1Categorys = getLevel1Categorys();//2.封装数据Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {//1.每一个的一级分类,查到这个一级分类的二级分类List<CategoryEntity> categoryEntities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", v.getParentCid()));//2.封装上面的结果List<Catelog2Vo> catelog2Vos = null;if (categoryEntities != null) {catelog2Vos = categoryEntities.stream().map(l2 -> {Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());//1.找到当前二级分类的三级分类,封装成 voList<CategoryEntity> level3Catelog = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", l2.getCatId()));// 三级分类有数据的情况下if (level3Catelog != null){List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {//2.封装成指定格式Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName());return catelog3Vo;}).collect(Collectors.toList());catelog2Vo.setCatalog3List(collect);}return catelog2Vo;}).collect(Collectors.toList());}return catelog2Vos;}));return parent_cid;}
效果展示:
访问 http://localhost:10000/index/catalog.json
得到 json 数据
首页展示效果:http://localhost:10000
模板引擎总结
* 5.模板引擎 * 1)、thymeleaf-starter: 关闭缓存 * 2)、静态资源都放在static 文件夹下就可以按照路径直接访问 * 3)、页面放在 templates下,直接访问 * SpringBoot,访问项目的时候,默认会找 index * 4)、页面修改不重启服务器实时更新 * 1)、引入 dev-tools * 2)、修改完页面 ctrl + shift + f9 或者 ctrl + f9,重新自动编译下页面(注意:如果代码配置等修改,建议重启) *
2.2.4 nginx 搭建域名访问环境
我们利用反向代理:让 Nginx 配合网关 搭建我们的访问环境,将我们的各个微服务放在内网中,避免端口直接暴露带来的危险。
利用 SwitchHosts软件可以快速修改hosts文件,注意需要以管理员身份运行。
原理:
查看本机localhost对应的IP地址:
ipconfig:查看本机IP:ipconfig:
192.168.1.103 (windows的localhost地址)
192.168.56.1(linux虚拟机的localhost地址)
两者都可以,都算本机
Nginx的配置文件详解:
可以加载 外部配置文件的配置,这样可以避免Nginx的配置文件过大。(总配置文件)
- Nginx 反向代理配置
接下来我们配置 server块:
先复制一份,留作备份:
修改配置文件:
proxy_pass:代理通过:相当于代理给谁(转交给谁)
gulimall下的所有请求都代理给 192.168.56.1下的10000端口。
Nginx的所有配置都以 ; 结尾,否则报错。
通过域名访问:gulimall.com
原理解析:
1.首先浏览器访问 gulimall.com----我们在windows里面指定了 gulimall.com 映射的是虚拟机IP:192.168.56.10,所以浏览器访问 gulimall.com 先会来到我们的虚拟机;
2.虚拟机里面的 Nginx又监听了80端口,在Nginx的配置文件中,它监听了来自80端口的所有请求,而且域名是 gulimall.com;所以符合以上条件,Nginx就会帮我们代理到我们本机:proxy_pass http://192.168.56.1:10000;
3.最后我们就又回到了本机
4.最后总结就是:域名来到 Nginx,Nginx 配置了gulimall.com ,代理到10000端口服务;
分布式情况下:商城系统有很多,不止一个,那需要每次修改 Nginx的代理配置?
太麻烦!!!
让Nginx 将请求代理给网关,由网关自动转发给我们各个服务;网关就能动态发现哪些服务上线,哪些服务下线;而且网关还具有负载均衡功能。
Nginx将请求交给网关,由网关从注册中心动态发现商品服务都在那,进而由网关负载均衡到商品服务;
网关也会部署多个,Nginx可以将请求负载均衡到某一个网关,然后由网关在进行转发。
-
Nginx 搭配网关 实现 负载均衡到网关
-
Nginx
修改 总配置 nginx.conf 在 http 块内:
在server 块内:
修改 server配置:gulimall.conf:相当于 是 负载均衡的配置,直接路由到上游服务器网关,由网关进行转发
效果就是:访问 gulimall.com ,代理 给 Nginx ,Nginx 转交 给网关 ,网关再转给商品服务。
-
网关配置:
- id: gulimall_host_routeuri: lb://gulimall-productpredicates:- Host=**.gulimall.com,gulimall.com # 只要是 gulimall下的所有请求都转给 gulimall-product
注意这个配置 一定要放在 最后:因为如果放在前面 ,它会禁用下面其他的网关配置:比如,http://gulimall.com//product/attrattrgrouprelation/list 这个api 接口访问,它会首先到 gulimall.com,然后因为没有进行 截串 设置(截取 /api前缀),出现 404 访问不到。
-
-
测试效果
这里出现 404 问题:原因:Nginx 转发给网关的时候,会丢失很多请求头信息,这里就缺失了 host 地址,这里我们暂时只配置 上 host 地址,以后缺啥补啥。
重启测试:
直接访问域名成功:gulimall.com
访问接口 也成功。http://gulimall.com//product/attrattrgrouprelation/list
最后总结:
最终原理:
首先浏览器访问 gulimall.com
因为我们在Windows配置了host映射:gulimall.com 映射IP 192.168.56.10(虚拟机Ip)
所以会直接来到虚拟机又因为 浏览器访问 默认不带端口,那就是访问80端口,所以会来到 Nginx,我们又配置 了 80端口监听 gulimall.com 这个域名;此外由于 **location/**下的配置:代理转发:
Nginx 又代理给网关,这里注意一个细节:由于Nginx 转发会丢失 一些请求头信息,所以我们要加上请求头的配置,这里暂时只配置 host地址,之后的其他请求头配置我们用到的时候在进行添加;
网关发现 域名 是gulimall.com,进而就会找到 对应的配置:路由到商品服务,进而就转给了商品服务,这处网关配置一定要放在最后面,避免放在前面禁用后面的其他截串配置。
- 域名映射效果:
- 请求接口 gulimall.com
- 请求页面 gulimall.com
- nginx 直接代理给网关,网关判断
- 如果是/api/***,转交给对应的服务器
- 如果是满足域名,转交给对应的服务
重要!!!!
关于 第3章 性能与压力测试 和 第4章 缓存与分布式锁单独写在另外一篇文档:谷粒商城之高级篇知识补充。
2.3 检索业务
2.3.1 页面环境搭建
①秉承动静分离的原则,我们将 静态资源放到 Nginx下:
在Nginx新建一个 文件夹search,用来存放相关静态资源。
②修改 index页面下的静态资源前缀
静态资源
加上 thymeleaf 的名称空间
③域名映射
④ *.gulimall.com 表示所有请求Nginx都处理,最后的结果就是Nginx转发给 网关
最终的转发效果就是:
⑤网关配置
- id: gulimall_host_routeuri: lb://gulimall-productpredicates:- Host=gulimall.com #这里和之前的相比有修改- id: gulimall_search_routeuri: lb://gulimall-searchpredicates:- Host=search.gulimall.com
⑥访问 http://search.gulimall.com/
2.3.2 调整页面跳转
为了以后开发方便,我们加上 热部署依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-devtools</artifactId></dependency>
关闭thymeleaf 的缓存
spring.thymeleaf.cache=false
我们可以通过检索页面的以下两个地方跳转会商城首页
一个是超链接,一个是图标。
首先是超链接修改:
search服务里面的index页面,改为 http://gulimall.com
接着是图标处:改为 http://gulimall.com
修改域名映射:让gulimall.com和带其子域名的都转发给网关。
测试:成功跳转回首页。
接下来,我们在首页上可以有这么两个地方,可以跳转到我们的检索页面:
①关键字搜索:搜索按钮
②点击分类,跳转到检索页面。
③修改配置:
-
通过分类点击到检索页面
-
将 检索页面 重命名为 list.html
-
创建SearchController
@Controller public class SearchController {@GetMapping("/list.html")public String listPage() {return "list";}}
-
避坑:
如果点击 分类跳转 到 检索页面,报错,然后控制台域名是:search.gmall.com开头,那么我们需要去
Nginx 下的 html/static/index/js,在 catelogLoader中搜索gmall,替换为 gulimall
-
-
通过首页的搜索图标跳转到检索页面
修改 gulimall-product下的index.html页面:
搜索 search:
search方法应该是这样:之前修改前缀的时候多加了/static,所以一直访问不到,下面这个是正确的。
另外图标处修改为:
ps: 注意一定要把product商品服务中的application.yaml配置文件中 thymeleaf 的页面缓存设置为false,之前测试缓存的时候给设为 开启了,开发中我们关闭。
- 测试,都成功跳转到检索页面。
ps:测试的时候,注意浏览器缓存问题,不然有时候测试不成功。
2.3.3 检索返回结果模型分析抽取
1、检索业务分析
商品检索三个入口:
1)、选择分类进入商品检索
2)、输入检索关键字展示检索页
3)、选择筛选条件进入
检索条件&排序条件
- 全文检索:skuTitle
- 排序: saleCount、hotScore、skuPrice
- 过滤:hasStock、skuPrice 区间、brandId、catalogId、attrs
- 聚合:attrs
完整的url 参数
keyword=小米&sort=saleCount_desc/asc&hasStock=0/1&skuPrice=400_1900&brandId=1
&catalogId=1&attrs=1_3G:4G:5G&attrs=2_骁龙845&attrs=4_高清屏
修改 SearchController
@Controller
public class SearchController {@AutowiredMallSearchService mallSearchService;/*** 创建SearchParam:避免controller 方法参数位置接收太多的请求参数* 自动将页面提交过来的所有请求查询参数封装成指定的对象* @param param* @return*/@GetMapping("/list.html")public String listPage(SearchParam param, Model model) {//1、根据传递过来的页面的查询参数,去es中检索商品SearchResult result = mallSearchService.search(param);//放到 model 中,方便页面取值model.addAttribute("result",result);return "list";}}
创建 SearchParam类(vo包下):封装页面所有可能传递过来的查询条件:请求参数模型
/*** 封装页面所有可能传递过来的查询条件** catalog3Id=225&keyword=小米&sort=saleCount_asc&hasStock=0/1&brandId=1&brandId=2&attrs=1_5寸:6寸&attrs=2_16G:8G*/
@Data
public class SearchParam {private String keyword;//页面传递过来的全文匹配关键字private Long catalog3Id;//页面传递过来的三级分类id/*** sort=saleCount_asc/desc* sort=skuPrice_asc/desc* sort=hostScore_asc/desc**/private String sort;//排序条件/*** 好多的过滤条件* hasStock(是否有货)、skuPrice 区间、brandId、catalogId、attrs* hasStock=0/1 :0有货;1无货* skuPrice=1_500/500_/_500* brandId=1* attrs=2_5寸:6寸**/private Integer hasStock = 1;//是否只显示有货private String skuPrice;//价格区间查询private List<Long> brandId;//按照品牌进行查询,可以多选private List<String> attrs;//按照属性进行筛选private Integer pageNum = 1;//页码}
创建 SearchResult :封装页面所有可能返回的结果:响应数据模型
/*** 封装页面所有可能返回的结果*/
@Data
public class SearchResult {//查询到的所有商品信息private List<SkuEsModel> products;/*** 以下是分页信息*/private Integer pageNum;//当前页码private Long total;//总记录数private Integer totalPages;//总页码private List<BrandVo> brands;//当前查询到的结果,所有涉及到的品牌private List<CatalogVo> catalogs;//当前查询到的结果,所有涉及到的所有分类private List<AttrVo> attrs;//当前查询到的结果,所有涉及到的所有属性//============================以上是返回给页面的所有信息============================@Datapublic static class BrandVo{private Long brandId;private String brandName;private String brandImg;}@Datapublic static class CatalogVo{private Long catalogId;private String catalogName;}@Datapublic static class AttrVo{private Long attrId;private String attrName;private List<String> attrValue;}
}
创建 MallSearchService 及其实现
MallSearchService
public interface MallSearchService {/**** @param param 检索的所有参数* @return 返回检索的结果,里面包含页面所需要的所有信息*/SearchResult search(SearchParam param);
}
MallSearchServiceImpl
@Service
public class MallSearchServiceImpl implements MallSearchService {@Overridepublic Object search(SearchParam param) {return null;}
}
分析结果 见 上面的 SearchParam类及SearchResult类。
2.3.4 检索DSL语句
在 Kibana中进行检索DSL语句测试。
- 查询部分
最终检索语句:
GET product/_search
{"query": {"bool": {"must": [{"match": { #模糊匹配-全文检索"skuTitle": "华为"}}],"filter": [ #过滤条件 {"term": {"catalogId": "225"}},{"terms": {"brandId": ["1","2","9"]}},{"nested": { #嵌套查询"path": "attrs","query": {"bool": {"must": [{"term": {"attrs.attrId": {"value": "15"}}},{"terms": {"attrs.attrValue": ["海思(Hisilicon)","以官网信息为准"]}}]}}}},{"term": {"hasStock": {"value": "true"}}},{"range": {"skuPrice": {"gte": 0,"lte": 6000}}}]}},"sort": [ #排序{"skuPrice": {"order": "desc"}}],"from": 0, #分页"size": 1,"highlight": {#高亮"fields": {"skuTitle": {}},"pre_tags": "<b style='color:red'>","post_tags": "</b>"}
}
整个查询条件:模糊匹配,过滤(按照属性,分类,品牌,价格区间,库存),排序,分页,高亮,聚合分析。
- 接下来就是聚合分析部分。
这里我们希望可以通过品牌属性等也可以检索到商品。
所以加上 品牌属性等检索条件。
报错:
修改映射,让他们都可以进行聚合分析。
创建新的映射
PUT gulimall_product
{"mappings": {"properties": {"attrs": {"type": "nested","properties": {"attrId": {"type": "long"},"attrName": {"type": "keyword"},"attrValue": {"type": "keyword"}}},"brandId": {"type": "long"},"brandImg": {"type": "keyword"},"brandName": {"type": "keyword"},"catalogId": {"type": "long"},"catalogName": {"type": "keyword"},"hasStock": {"type": "boolean"},"hotScore": {"type": "long"},"saleCount": {"type": "long"},"skuId": {"type": "long"},"skuImg": {"type": "keyword"},"skuPrice": {"type": "keyword"},"skuTitle": {"type": "text","analyzer": "ik_smart"},"spuId": {"type": "keyword"}}}
}
数据迁移
# 数据迁移
POST _reindex
{"source":{"index":"product"},"dest":{"index":"gulimall_product"}
}
查询
GET gulimall_product/_search
迁移成功。
修改 EsConstant
代码
最终聚合分析语句:
"aggs": {"brand_agg": {"terms": {"field": "brandId","size": 10},"aggs": {"brand_name_agg": {"terms": {"field": "brandName","size": 10}},"brand_img_agg":{"terms": {"field": "brandImg","size": 10}}}},"catalog_agg":{"terms": {"field": "catalogId","size": 10},"aggs": {"catalog_name_agg": {"terms": {"field": "catalogName","size": 10}}}},"attr_agg":{"nested": {"path": "attrs"},"aggs": {"attr_id_agg": {"terms": {"field": "attrs.attrId","size": 10},"aggs": {"attr_name_agg": {"terms": {"field": "attrs.attrName","size": 10}},"attr_value_agg":{"terms": {"field": "attrs.attrValue","size": 10}}}}}}}
- 整个查询的检索DSL语句:
GET product/_search
{"query": {"bool": {"must": [{"match": {"skuTitle": "华为"}}],"filter": [{"term": {"catalogId": "225"}},{"terms": {"brandId": ["1","2","9"]}},{"nested": {"path": "attrs","query": {"bool": {"must": [{"term": {"attrs.attrId": {"value": "15"}}},{"terms": {"attrs.attrValue": ["海思(Hisilicon)","以官网信息为准"]}}]}}}},{"term": {"hasStock": {"value": "true"}}},{"range": {"skuPrice": {"gte": 0,"lte": 6000}}}]}},"sort": [{"skuPrice": {"order": "desc"}}],"from": 0,"size": 1,"highlight": {"fields": {"skuTitle": {}},"pre_tags": "<b style='color:red'>","post_tags": "</b>"},"aggs": {"brand_agg": {"terms": {"field": "brandId","size": 10},"aggs": {"brand_name_agg": {"terms": {"field": "brandName","size": 10}},"brand_img_agg": {"terms": {"field": "brandImg","size": 10}}}},"catalog_agg": {"terms": {"field": "catalogId","size": 10},"aggs": {"catalog_name_agg": {"terms": {"field": "catalogName","size": 10}}}},"attr_agg": {"nested": {"path": "attrs"},"aggs": {"attr_id_agg": {"terms": {"field": "attrs.attrId","size": 10},"aggs": {"attr_name_agg": {"terms": {"field": "attrs.attrName","size": 10}},"attr_value_agg": {"terms": {"field": "attrs.attrValue","size": 10}}}}}}}
}
2.3.5 检索语句构建&&结果提取封装
1、构建请求参数
/*** 准备检索请求* # 整个查询条件* # 模糊匹配,过滤(按照属性,分类,品牌,价格区间,库存),排序,分页,高亮,聚合分析** @return*/private SearchRequest buildSearchRequest(SearchParam param) {SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();//构建DSL语句的/*** 查询:模糊匹配,过滤(按照属性,分类,品牌,价格区间,库存)*///1、构建 bool - queryBoolQueryBuilder boolQuery = QueryBuilders.boolQuery();//1.1、must - 模糊匹配if (!StringUtils.isEmpty(param.getKeyword())) {boolQuery.must(QueryBuilders.matchQuery("skuTitle", param.getKeyword()));}//1.2、bool - filter - 按照三级分类id查询if (param.getCatalog3Id() != null) {boolQuery.filter(QueryBuilders.termQuery("catalogId", param.getCatalog3Id()));}//1.2、bool - filter - 按照品牌id查询if (param.getBrandId() != null) {boolQuery.filter(QueryBuilders.termsQuery("brandId", param.getBrandId()));}//1.2、bool - filter - 按照所有指定的属性进行查询if (param.getAttrs() != null && param.getAttrs().size() > 0) {for (String attrStr : param.getAttrs()) {// attrs=1_5寸:6寸&attrs=2_16G:8GBoolQueryBuilder nestedboolQuery = QueryBuilders.boolQuery();//attrs=1_5寸:6寸String[] s = attrStr.split("_");String attrId = s[0];//检索属性的idString[] attrValues = s[1].split(":");//这个属性的检索用的值nestedboolQuery.must(QueryBuilders.termQuery("attrs.attrId", attrId));nestedboolQuery.must(QueryBuilders.termsQuery("attrs.attrValue", attrValues));//每一个必须都得生成一个nested查询NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery("attrs", nestedboolQuery, ScoreMode.None);boolQuery.filter(nestedQuery);}}//1.2、bool - filter - 按照库存是否有进行查询boolQuery.filter(QueryBuilders.termQuery("hasStock", param.getHasStock() == 1));//1.2、bool - filter - 按照价格区间进行查询if (!StringUtils.isEmpty(param.getSkuPrice())) {//1_500/_500/500_// "range": {// "skuPrice": {// "gte": 0,// "lte": 6000// }// }RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("skuPrice");String[] s = param.getSkuPrice().split("_");if (s.length == 2) {//价格区间rangeQuery.gte(s[0]).lte(s[1]);} else if (s.length == 1) {if (param.getSkuPrice().startsWith("_")) {rangeQuery.lte(s[0]);}if (param.getSkuPrice().endsWith("_")) {rangeQuery.lte(s[0]);}}boolQuery.filter(rangeQuery);}//把以前的所有条件都拿出来进行封装sourceBuilder.query(boolQuery);/*** 排序,分页,高亮*///2.1、排序if (!StringUtils.isEmpty(param.getSort())) {String sort = param.getSort();//sort=hotScore_asc/descString[] s = sort.split("_");SortOrder order = s[1].equalsIgnoreCase("asc") ? SortOrder.ASC : SortOrder.DESC;sourceBuilder.sort(s[0], order);}//2.2、分页 pageSize:5// pageNum:1 from:0 size:5 [0,1,2,3,4]// pageNum:2 from:5 size:5// from = (pageNum - 1)*sizesourceBuilder.from((param.getPageNum() - 1) * EsConstant.PRODUCT_PAGESIZE);sourceBuilder.size(EsConstant.PRODUCT_PAGESIZE);//2.3、高亮if (!StringUtils.isEmpty(param.getKeyword())) {HighlightBuilder builder = new HighlightBuilder();builder.field("skuTitle");builder.preTags("<b style='color:red'>");builder.postTags("</b>");sourceBuilder.highlighter(builder);}/*** 聚合分析*///1.品牌聚合TermsAggregationBuilder brand_agg = AggregationBuilders.terms("brand_agg");brand_agg.field("brandId").size(50);//品牌聚合的子聚合brand_agg.subAggregation(AggregationBuilders.terms("brand_name_agg").field("brandName").size(1));brand_agg.subAggregation(AggregationBuilders.terms("brand_img_agg").field("brandImg").size(1));// TODO 1、聚合 brandsourceBuilder.aggregation(brand_agg);//2.分类聚合 catalog_aggTermsAggregationBuilder catalog_agg = AggregationBuilders.terms("catalog_agg").field("catalogId").size(20);catalog_agg.subAggregation(AggregationBuilders.terms("catalog_name_agg").field("catalogName").size(1));// TODO 2、聚合 catalogsourceBuilder.aggregation(catalog_agg);//3.属性聚合 attr_aggNestedAggregationBuilder attr_agg = AggregationBuilders.nested("attr_agg", "attrs");//聚合出当前所有的attrIdTermsAggregationBuilder attr_id_agg = AggregationBuilders.terms("attr_id_agg").field("attrs.attrId");//聚合分析出当前 attr_id对应的名字attr_id_agg.subAggregation(AggregationBuilders.terms("attr_name_agg").field("attrs.attrName").size(1));//聚合分析出当前attr_id对应的所有可能的属性值attrValueattr_id_agg.subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(50));attr_agg.subAggregation(attr_id_agg);// TODO 3、聚合 attrsourceBuilder.aggregation(attr_agg);String s = sourceBuilder.toString();System.out.println("构建的DSL" + s);SearchRequest searchRequest = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, sourceBuilder);return searchRequest;}
2、响应结果封装
/*** 构建结果数据** @return*/private SearchResult buildSearchResult(SearchResponse response, SearchParam param) {SearchResult result = new SearchResult();//1、返回的所有查询到的商品SearchHits hits = response.getHits();List<SkuEsModel> esModels = new ArrayList<>();if (hits.getHits() != null && hits.getHits().length > 0) {for (SearchHit hit : hits.getHits()) {String sourceAsString = hit.getSourceAsString();SkuEsModel esModel = JSON.parseObject(sourceAsString, SkuEsModel.class);if (!StringUtils.isEmpty(param.getKeyword())){HighlightField skuTitle = hit.getHighlightFields().get("skuTitle");String string = skuTitle.getFragments()[0].string();esModel.setSkuTitle(string);}esModels.add(esModel);};}result.setProducts(esModels);// //2、当前所有商品涉及到的所有属性信息List<SearchResult.AttrVo> attrVos = new ArrayList<>();ParsedNested attr_agg = response.getAggregations().get("attr_agg");ParsedLongTerms attr_id_agg = attr_agg.getAggregations().get("attr_id_agg");for (Terms.Bucket bucket : attr_id_agg.getBuckets()) {SearchResult.AttrVo attrVo = new SearchResult.AttrVo();//1、得到属性的idlong attrId = bucket.getKeyAsNumber().longValue();//2、得到属性的名字String attrName = ((ParsedStringTerms) bucket.getAggregations().get("attr_name_agg")).getBuckets().get(0).getKeyAsString();//3、得到属性的所有值List<String> attrValues = ((ParsedStringTerms) bucket.getAggregations().get("attr_value_agg")).getBuckets().stream().map(item -> {String keyAsString = ((Terms.Bucket) item).getKeyAsString();return keyAsString;}).collect(Collectors.toList());attrVo.setAttrId(attrId);attrVo.setAttrName(attrName);attrVo.setAttrValue(attrValues);attrVos.add(attrVo);}result.setAttrs(attrVos);// //3、当前所有商品涉及到的所有品牌信息List<SearchResult.BrandVo> brandVos = new ArrayList<>();ParsedLongTerms brand_agg = response.getAggregations().get("brand_agg");for (Terms.Bucket bucket : brand_agg.getBuckets()) {SearchResult.BrandVo brandVo = new SearchResult.BrandVo();//1、得到品牌的idlong brandId = bucket.getKeyAsNumber().longValue();//2、得到品牌的名字String brandName = ((ParsedStringTerms) bucket.getAggregations().get("brand_name_agg")).getBuckets().get(0).getKeyAsString();//3、得到品牌的图片String brandImg = ((ParsedStringTerms) bucket.getAggregations().get("brand_img_agg")).getBuckets().get(0).getKeyAsString();brandVo.setBrandId(brandId);brandVo.setBrandName(brandName);brandVo.setBrandImg(brandImg);brandVos.add(brandVo);}result.setBrands(brandVos);// //4、当前所有商品涉及到的所有分类信息ParsedLongTerms catalog_agg = response.getAggregations().get("catalog_agg");List<SearchResult.CatalogVo> catalogVos = new ArrayList<>();List<? extends Terms.Bucket> buckets = catalog_agg.getBuckets();for (Terms.Bucket bucket : buckets) {SearchResult.CatalogVo catalogVo = new SearchResult.CatalogVo();//得到分类idString keyAsString = bucket.getKeyAsString();catalogVo.setCatalogId(Long.parseLong(keyAsString));//得到分类名ParsedStringTerms catalog_name_agg = bucket.getAggregations().get("catalog_name_agg");String catalog_name = catalog_name_agg.getBuckets().get(0).getKeyAsString();catalogVo.setCatalogName(catalog_name);catalogVos.add(catalogVo);}result.setCatalogs(catalogVos);// ===============以上从聚合信息中获取==================// //5、分页信息-页码result.setPageNum(param.getPageNum());// //5、分页信息-总记录数long total = hits.getTotalHits().value;result.setTotal(total);// //5、分页信息-总页码-计算 11/2 = 5 ..1int totalPages = (int) total % EsConstant.PRODUCT_PAGESIZE == 0 ? (int) total / EsConstant.PRODUCT_PAGESIZE : ((int) total / EsConstant.PRODUCT_PAGESIZE + 1);result.setTotalPages(totalPages);return result;}
3、编写这些代码完了之后,利用postman进行测试。
测试过后,以上代码暂时没有错误。
4、总的检索方法:SearchResult
//去 es进行检索@Overridepublic SearchResult search(SearchParam param) {//1.动态构建出查询需要的DSL语句SearchResult result = null;//1、准备检索请求SearchRequest searchRequest = buildSearchRequest(param);try {//2、执行检索请求SearchResponse response = client.search(searchRequest, GulimallElasticSearchConfig.COMMON_OPTIONS);//3、分析响应数据封装成我们需要的格式result = buildSearchResult(response,param);} catch (IOException e) {e.printStackTrace();}return result;}
2.3.6 页面基本数据渲染
修改 list.html页面。
①页面商品展示:
th:utext:不转义,可以让我们搜索的关键字高亮显示。
测试:
②品牌、分类等显示。
效果:
效果:
显示全部:
修改代码:SearchParam
private Integer hasStock;//是否只显示有货
MallSearchServiceImpl -> buildSearchRequest
//1.2、bool - filter - 按照库存是否有进行查询if (param.getHasStock() != null){boolQuery.filter(QueryBuilders.termQuery("hasStock", param.getHasStock() == 1));}
效果:
加粗:
接下来,我们把分类一栏也显示出来。
<div class="JD_pre"><div class="sl_key"><span><b>分类:</b></span></div><div class="sl_value"><ul><li th:each="catalog:${result.catalogs}"><a href="/static/search/#" th:text="${catalog.catalogName}">5.56英寸及以上</a></li></ul></div><div class="sl_ext"><a href="/static/search/#">更多<i style='background: url("image/search.ele.png")no-repeat 3px 7px'></i><b style='background: url("image/search.ele.png")no-repeat 3px -44px'></b></a><a href="/static/search/#">多选<i>+</i><span>+</span></a></div></div>
最终效果:
2.3.7 页面筛选条件渲染
当我们选择比如品牌,分类,型号等自动拼接上参数。
函数:
function searchProducts(name,value){//原来的页面var href = location.href + "";if(href.indexOf("?")!=-1){location.href = location.href + "&"+name+"="+value;}else{location.href = location.href + "?"+name+"="+value;}}
品牌:
th:href="${'javascript:searchProducts("brandId",'+brand.brandId+')'}"
测试:
分类:
th:href="${'javascript:searchProducts("catalog3Id",'+catalog.catalogId+')'}"
其他属性:
th:href="${'javascript:searchProducts("attrs","'+attr.attrId+'_'+val+'")'}"
2.3.8 页面分页数据渲染
①修改搜索导航:搜索的时候地址栏加上关键字。
搜索关键字:华为,地址栏加上华为。
回显搜索过的关键字:
<div class="header_form"><input id="keyword_input" type="text" placeholder="手机" th:value="${param.keyword}"/><a href="javascript:searchByKeyword();">搜索</a></div>
function searchByKeyword() {searchProducts("keyword", $("#keyword_input").val());}
② 分页调整。
因为我的商品这里总共就17个,为了分页显示效果,设为每页显示8条。
页面修改:
<div class="filter_page"><div class="page_wrap"><span class="page_span1"><a class="page_a" th:attr="pn=${result.pageNum - 1}" href="/static/search/#"th:if="${result.pageNum>1}">< 上一页</a><a class="page_a"th:attr="pn=${nav},style=${nav == result.pageNum?'border: 0;color:#ee2222;background: #fff':''}"th:each="nav:${result.pageNavs}">[[${nav}]]</a><a class="page_a" th:attr="pn=${result.pageNum + 1}"th:if="${result.pageNum<result.totalPages}">下一页 ></a></span><span class="page_span2"><em>共<b>[[${result.totalPages}]]</b>页 到第</em><input type="number" value="1"><em>页</em><a>确定</a></span></div>
$(".page_a").click(function () {var pn = $(this).attr("pn");var href = location.href;if (href.indexOf("pageNum") != -1) {//替换pageNum的值location.href = replaceParamVal(href, "pageNum", pn);} else {location.href = location.href + "&pageNum=" + pn;}return false;});function replaceParamVal(url, paramName, replaceVal) {var oUrl = url.toString();var re = eval('/(' + paramName + '=)([^&]*)/gi');var nUrl = oUrl.replace(re, paramName + '=' + replaceVal);return nUrl;};
代码修改:
/*** 构建结果数据** @return*/private SearchResult buildSearchResult(SearchResponse response, SearchParam param) {...//导航页List<Integer> pageNavs = new ArrayList<>();for (int i = 1; i <= totalPages; i++) {pageNavs.add(i);//可遍历的页码}result.setPageNavs(pageNavs);
效果展示:
还有一些分页效果在这里就暂时不做了。
2.3.9 页面排序功能
function replaceAndAddParamVal(url, paramName, replaceVal) {var oUrl = url.toString();//1.如果没有就添加,有就替换;if (oUrl.indexOf(paramName) != -1) {var re = eval('/(' + paramName + '=)([^&]*)/gi');var nUrl = oUrl.replace(re, paramName + '=' + replaceVal);return nUrl;} else {var nUrl = "";if (oUrl.indexOf("?") != -1) {nUrl = oUrl + "&" + paramName + '=' + replaceVal;} else {nUrl = oUrl + "?" + paramName + '=' + replaceVal;}return nUrl;}};$(".sort_a").click(function () {//1.当前被点击的元素变为选中状态// color: #FFF;border-color: #e4393c;background: #e4393c;//改变当前元素以及兄弟元素的样式changeStyle(this);//2.跳转到指定位置 sort=skuPrice_asc/descvar sort = $(this).attr("sort");sort = $(this).hasClass("desc") ? sort + "_desc" : sort + "_asc";location.href = replaceAndAddParamVal(location.href, "sort", sort);//禁用默认行为return false;});function changeStyle(ele) {$(".sort_a").css({"color": "#333", "border-colo": "#CCC", "background": "#FFF"});$(".sort_a").each(function () {var text = $(this).text().replace("↓", "").replace("↑", "");$(this).text(text);});$(ele).css({"color": "#FFF", "border-colo": "#e4393c", "background": "#e4393c"});//改变升降序$(ele).toggleClass("desc");//加上就是降序,不加就是升序if ($(ele).hasClass("desc")) {//降序var text = $(ele).text().replace("↓", "").replace("↑", "");text = text + "↓";$(ele).text(text);} else {var text = $(ele).text().replace("↓", "").replace("↑", "");text = text + "↑";$(ele).text(text);}}
效果:有上下箭头
按价格排序
2.3.10 页面排序字段回显
<div class="filter_top"><div class="filter_top_left" th:with="p = ${param.sort}"><a sort="hotScore"th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'hotScore') && #strings.endsWith(p,'desc')) ? 'sort_a desc' : 'sort_a'}"th:attr="style=${(#strings.isEmpty(p) || #strings.startsWith(p,'hotScore')) ?'color: #fff; border-color: #e4393c; background: #e4393c;':'color: #333; border-color: #ccc; background: #fff;' }">综合排序[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'hotScore') &&#strings.endsWith(p,'desc')) ?'↑':'↓' }]]</a><a sort="saleCount"th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount') && #strings.endsWith(p,'desc')) ? 'sort_a desc' : 'sort_a'}"th:attr="style=${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount')) ?'color: #fff; border-color: #e4393c; background: #e4393c;':'color: #333; border-color: #ccc; background: #fff;' }">销量[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount') &&#strings.endsWith(p,'desc'))?'↑':'↓' }]]</a><a sort="skuPrice"th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice') && #strings.endsWith(p,'desc')) ? 'sort_a desc' : 'sort_a'}"th:attr="style=${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice')) ?'color: #fff; border-color: #e4393c; background: #e4393c;':'color: #333; border-color: #ccc; background: #fff;' }">价格[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice') &&#strings.endsWith(p,'desc'))?'↑':'↓' }]]</a><a href="/static/search/#">评论分</a><a href="/static/search/#">上架时间</a></div>
这里很容易写错!!!!出错的话,可以参考别人的课件:
效果:
2.3.11 页面价格区间搜索&&仅显示有货
- 页面价格区间搜索
<div class="filter_top"><div class="filter_top_left" th:with="p = ${param.sort},priceRange = ${param.skuPrice}"><a sort="hotScore"th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'hotScore') && #strings.endsWith(p,'desc')) ? 'sort_a desc' : 'sort_a'}"th:attr="style=${(#strings.isEmpty(p) || #strings.startsWith(p,'hotScore')) ?'color: #fff; border-color: #e4393c; background: #e4393c;':'color: #333; border-color: #ccc; background: #fff;' }">综合排序[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'hotScore') &&#strings.endsWith(p,'desc')) ?'↑':'↓' }]]</a><a sort="saleCount"th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount') && #strings.endsWith(p,'desc')) ? 'sort_a desc' : 'sort_a'}"th:attr="style=${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount')) ?'color: #fff; border-color: #e4393c; background: #e4393c;':'color: #333; border-color: #ccc; background: #fff;' }">销量[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount') &&#strings.endsWith(p,'desc'))?'↑':'↓' }]]</a><a sort="skuPrice"th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice') && #strings.endsWith(p,'desc')) ? 'sort_a desc' : 'sort_a'}"th:attr="style=${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice')) ?'color: #fff; border-color: #e4393c; background: #e4393c;':'color: #333; border-color: #ccc; background: #fff;' }">价格[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice') &&#strings.endsWith(p,'desc'))?'↑':'↓' }]]</a><a href="/static/search/#">评论分</a><a href="/static/search/#">上架时间</a><input id="skuPriceFrom" type="number"th:value="${#strings.isEmpty(priceRange)?'':#strings.substringBefore(priceRange,'_')}"style="width: 100px; margin-left: 30px">-<input id="skuPriceTo" type="number"th:value="${#strings.isEmpty(priceRange)?'':#strings.substringAfter(priceRange,'_')}"style="width: 100px"><button id="skuPriceSearchBtn">确定</button></div>
$("#skuPriceSearchBtn").click(function () {//1、拼上价格区间的查询条件var from = $("#skuPriceFrom").val();var to = $("#skuPriceTo").val();var query = from + "_" + to;location.href = replaceAndAddParamVal(location.href, "skuPrice", query);});
效果:
- 仅显示有货
<li><a href="#" th:with="check = ${param.hasStock}"><input id="showHasStock" type="checkbox" th:checked="${#strings.equals(check,'1')}">仅显示有货</a></li>
$("#showHasStock").change(function (){if ($(this).prop('checked')){location.href = replaceAndAddParamVal(location.href,"hasStock",1);}else {//没选中var re = eval('/(hasStock=)([^&]*)/gi');location.href = (location.href+"").replace(re,'');}});
效果展示:
bug解决:之前搜索过的关键词在URL地址栏不会被替换,而是一直叠加。
function searchProducts(name, value) {//原来的页面location.href = replaceAndAddParamVal(location.href,name,value);}
2.3.12 面包屑导航
修改 后台代码。
search微服务引入依赖:
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
让spring-cloud版本一致:
<properties><java.version>1.8</java.version><elasticsearch.version>7.4.2</elasticsearch.version><spring-cloud.version>Greenwich.SR3</spring-cloud.version></properties>
引入依赖管理:
<dependencyManagement><dependencies><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-dependencies</artifactId><version>${spring-cloud.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement>
我们需要调用商品的远程服务,获取属性的名字。
gulimall-search下新建feign包:
ProductFeignService
@FeignClient("gulimall-product")
public interface ProductFeignService {@GetMapping ("/product/attr/info/{attrId}")public R attrInfo(@PathVariable("attrId") Long attrId);
}
主启动类添加调用远程服务注解
@EnableFeignClients //开启远程调用
新建 AttrResponseVo封装结果:这里我们暂时不用 gulimall-product下的 AttrRespVo了,可以将其放到公共服务中去。但是如果我们每个人只能修改自己多负责的微服务,我们就新建然后进行封装就行。
@Data
public class AttrResponseVo {/*** 属性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;private Long attrGroupId;private String catelogName;private String groupName;private Long[] catelogPath;
}
SearchResult
//面包屑导航数据private List<NavVo> navs;@Datapublic static class NavVo{private String navName;private String navValue;private String link;}
SearchParam
private String _queryString;//原生的所有查询条件
SearchController
@GetMapping("/list.html")public String listPage(SearchParam param, Model model, HttpServletRequest request) {param.set_queryString(request.getQueryString());//1、根据传递过来的页面的查询参数,去es中检索商品SearchResult result = mallSearchService.search(param);//放到 model 中,方便页面取值model.addAttribute("result",result);return "list";}
MallSearchServiceImpl
@AutowiredProductFeignService productFeignService;/*** 构建结果数据** @return*/private SearchResult buildSearchResult(SearchResponse response, SearchParam param) {.....
//6、构建面包屑导航功能if (param.getAttrs() != null && param.getAttrs().size() > 0) {List<SearchResult.NavVo> collect = param.getAttrs().stream().map(attr -> {//1、分析每个attrs传过来的查询参数值。SearchResult.NavVo navVo = new SearchResult.NavVo();// attrs=2_5存:6寸String[] s = attr.split("_");navVo.setNavValue(s[1]);R r = productFeignService.attrInfo(Long.parseLong(s[0]));if (r.getCode() == 0) {AttrResponseVo data = r.getData("attr", new TypeReference<AttrResponseVo>() {});navVo.setNavName(data.getAttrName());} else {navVo.setNavName(s[0]);}//2、取消了这个面包屑之后,我们要跳转到那个地方,将请求地址的url里面的当前置空//拿到所有的查询条件,去掉当前。//attrs = 15_海思(Hisilicon)String encode = null;try {encode = URLEncoder.encode(attr, "UTF-8");encode = encode.replace("+", "%20");//浏览器对空格编码和java不一样} catch (UnsupportedEncodingException e) {e.printStackTrace();}String replace = param.get_queryString().replace("&attrs=" + encode, "");navVo.setLink("http://search.gulimall.com/list.html?" + replace);return navVo;}).collect(Collectors.toList());result.setNavs(collect);}}
list.html页面修改
<div class="JD_ipone_one c"><!-- 遍历面包屑功能 --><a th:href="${nav.link}" th:each="nav:${result.navs}"><span th:text="${nav.navName}"></span>:<span th:text="${nav.navValue}"></span> x</a></div>
下面两个JS有修改:
function searchByKeyword() {searchProducts("keyword", $("#keyword_input").val());} function replaceAndAddParamVal(url, paramName, replaceVal, forceAdd) {var oUrl = url.toString();//1.如果没有就添加,有就替换;if (oUrl.indexOf(paramName) != -1) {if (forceAdd) {var nUrl = "";if (oUrl.indexOf("?") != -1) {nUrl = oUrl + "&" + paramName + '=' + replaceVal;} else {nUrl = oUrl + "?" + paramName + '=' + replaceVal;}return nUrl;} else {var re = eval('/(' + paramName + '=)([^&]*)/gi');var nUrl = oUrl.replace(re, paramName + '=' + replaceVal);return nUrl;}} else {var nUrl = "";if (oUrl.indexOf("?") != -1) {nUrl = oUrl + "&" + paramName + '=' + replaceVal;} else {nUrl = oUrl + "?" + paramName + '=' + replaceVal;}return nUrl;}};
测试:
地址加上了属性。
点 “ x” 地址栏消失属性。
- 条件筛选联动
商品服务的 BrandController中添加获取品牌id集合的方法:
@GetMapping("/infos")public R info(@RequestParam("brandIds") List<Long> brandIds) {List<BrandEntity> brand = brandService.getBrandsByIds(brandIds);return R.ok().put("brand", brand);}
BrandServiceImpl
@Overridepublic List<BrandEntity> getBrandsByIds(List<Long> brandIds) {return baseMapper.selectList(new QueryWrapper<BrandEntity>().in("brand_id",brandIds));}
因为 这些查询都比较耗费时间:远程调用,所以可以加上缓存。
AttrServiceImpl
@Cacheable(value = "attr",key = "'attrinfo:'+#root.args[0]")@Overridepublic AttrRespVo getAttrInfo(Long attrId) {}
查询服务的ProductSaveService
@GetMapping("/product/brand/infos")public R brandsInfo(@RequestParam("brandIds") List<Long> brandIds);
SearchParam
@Data
public class SearchParam {...
private String _queryString;//原生的所有查询条件
}
SearchResult
@Data
public class SearchResult {//面包屑导航数据private List<NavVo> navs = new ArrayList<>();private List<Long> attrIds = new ArrayList<>();
}
MallSearchServiceImpl
因为我们经常使用编码的方法,所以提取成一个公共方法。
略做修改
private String replaceQueryString(SearchParam param, String value, String key) {String encode = null;try {encode = URLEncoder.encode(value, "UTF-8");encode = encode.replace("+", "%20");//浏览器对空格编码和java不一样} catch (UnsupportedEncodingException e) {e.printStackTrace();}String replace = param.get_queryString().replace("&" + key + "=" + encode, "");return replace;}
上一节的属性面包屑导航增加和修改一些代码:
//6、构建面包屑导航功能if (param.getAttrs() != null && param.getAttrs().size() > 0) {List<SearchResult.NavVo> collect = param.getAttrs().stream().map(attr -> {//1、分析每个attrs传过来的查询参数值。SearchResult.NavVo navVo = new SearchResult.NavVo();// attrs=2_5存:6寸String[] s = attr.split("_");navVo.setNavValue(s[1]);R r = productFeignService.attrInfo(Long.parseLong(s[0]));result.getAttrIds().add(Long.parseLong(s[0]));if (r.getCode() == 0) {AttrResponseVo data = r.getData("attr", new TypeReference<AttrResponseVo>() {});navVo.setNavName(data.getAttrName());} else {navVo.setNavName(s[0]);}//2、取消了这个面包屑之后,我们要跳转到那个地方,将请求地址的url里面的当前置空//拿到所有的查询条件,去掉当前。//attrs = 15_海思(Hisilicon)String replace = replaceQueryString(param, attr, "attrs");navVo.setLink("http://search.gulimall.com/list.html?" + replace);return navVo;}).collect(Collectors.toList());result.setNavs(collect);}
对于品牌,分类的面包屑导航,这里暂时只做 品牌的。
//品牌,分类if (param.getBrandId() != null && param.getBrandId().size() > 0) {List<SearchResult.NavVo> navs = result.getNavs();SearchResult.NavVo navVo = new SearchResult.NavVo();navVo.setNavName("品牌");//TODO 远程查询所有品牌R r = productFeignService.brandsInfo(param.getBrandId());if (r.getCode() == 0) {List<BrandVo> brand = r.getData("brand", new TypeReference<List<BrandVo>>() {});StringBuffer buffer = new StringBuffer();String replace = "";for (BrandVo brandVo : brand) {buffer.append(brandVo.getBrandName() + ";");replace = replaceQueryString(param, brandVo.getBrandId() + "", "brandId");}navVo.setNavValue(buffer.toString());navVo.setLink("http://search.gulimall.com/list.html?" + replace);}navs.add(navVo);}//TODO 分类:不需要导航取消
list.html
<div class="JD_nav_logo" th:with="brandid= ${param.brandId}"><!--品牌--><div th:if="${#strings.isEmpty(brandid)}" class="JD_nav_wrap">
<!--其他所有需要展示的属性--><div class="JD_pre" th:each="attr:${result.attrs}" th:if="${!#lists.contains(result.attrIds,attr.attrId)}">
测试
重要!!!!
关于 第5章 异步和线程池单独写在另外一篇文档:谷粒商城之高级篇知识补充。
2.4 商品详情
详情数据:
2.4.1 环境搭建
- 域名跳转设置
host配置
Nginx配置
之前已经配置了。
网关配置
- 动静资源设置
遵循动静分离的配置,我们将详情页的静态资源上传到Nginx下。
这是因为我们设置了对应的配置:
将详情页的页面复制到 商品服务下。
改名为 item.html
将页面中的相应静态资源加上对应前缀。
- 实现点击商品图片等可以跳转到 商品详情页。
修改 search服务下的 list.html页面
<a th:href="|http://item.gulimall.com/${product.skuId}.html|">
2.4.2 模型抽取
商品服务下新建 SkuItemVo(最终结果,以下均是)
@Data
public class SkuItemVo {//1、sku基本信息获取 pms_sku_infoSkuInfoEntity info;//2、sku的图片信息 pms_sku_imagesList<SkuImagesEntity> images;//3、获取的spu的销售属性组合。List<SkuItemSaleAttrVo> saleAttr;//4、获取spu的介绍SpuInfoDescEntity desp;//5、获取spu的规格参数信息。List<SpuItemAttrGroupVo> groupAttrs;}
SkuItemSaleAttrVo
@Data
@ToString
public class SkuItemSaleAttrVo {private Long attrId;private String attrName;private String attrValues;
}
SpuItemAttrGroupVo
@Data
@ToString
public class SpuItemAttrGroupVo {private String groupName;private List<Attr> attrs;
}
2.4.3 规格参数代码实现
ItemController(最终代码)
/*** 因为这个类做跳转,所以不用写 @RestController,只需要是@Controller。*/
@Controller
public class ItemController {@AutowiredSkuInfoService skuInfoService;/*** 展示当前sku的详情** @param skuId* @return*/@GetMapping("/{skuId}.html")public String skuItem(@PathVariable("skuId") Long skuId, Model model) {System.out.println("准备查询" + skuId + "详情");SkuItemVo vo = skuInfoService.item(skuId);model.addAttribute("item",vo);return "item";}}
SkuInfoServiceImpl
@AutowiredSkuImagesService imagesService;@AutowiredSpuInfoDescService spuInfoDescService;@AutowiredAttrGroupService attrGroupService;@Overridepublic SkuItemVo item(Long skuId) {SkuItemVo skuItemVo = new SkuItemVo();//1、sku基本信息获取 pms_sku_infoSkuInfoEntity info = getById(skuId);skuItemVo.setInfo(info);Long catalogId = info.getCatalogId();Long spuId = info.getSpuId();//2、sku的图片信息 pms_sku_imagesList<SkuImagesEntity> images = imagesService.getImagesBySkuId(skuId);skuItemVo.setImages(images);//3、获取的spu的销售属性组合。//4、获取spu的介绍 pms_spu_info_descSpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(spuId);skuItemVo.setDesp(spuInfoDescEntity);//5、获取spu的规格参数信息。List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(spuId,catalogId);skuItemVo.setGroupAttrs(attrGroupVos);return null;}
SkuImagesServiceImpl
@Overridepublic List<SkuImagesEntity> getImagesBySkuId(Long skuId) {SkuImagesDao imagesDao = this.baseMapper;List<SkuImagesEntity> imagesEntities = imagesDao.selectList(new QueryWrapper<SkuImagesEntity>().eq("sku_id", skuId));return imagesEntities;}
AttrGroupServiceImpl
@Overridepublic List<SpuItemAttrGroupVo> getAttrGroupWithAttrsBySpuId(Long spuId, Long catalogId) {//1、查出当前spu对应的所有属性的分组信息以及当前分组下的所有属性对应的值AttrGroupDao baseMapper = this.baseMapper;List<SpuItemAttrGroupVo> vos = baseMapper.getAttrGroupWithAttrsBySpuId(spuId,catalogId);return vos;}
AttrGroupDao
List<SpuItemAttrGroupVo> getAttrGroupWithAttrsBySpuId(@Param("spuId") Long spuId, @Param("catalogId") Long catalogId);
AttrGroupDao.xml
<!-- resultType 返回集合里面元素的类型,只要有嵌套属性就要封装自定义结果集--><resultMap id="spuItemAttrGroupVo" type="com.atguigu.gulimall.product.vo.SpuItemAttrGroupVo"><result property="groupName" column="attr_group_name"></result><collection property="attrs" ofType="com.atguigu.gulimall.product.vo.Attr"><result column="attr_name" property="attrName"></result><result column="attr_value" property="attrValue"></result></collection></resultMap><select id="getAttrGroupWithAttrsBySpuId"resultMap="spuItemAttrGroupVo">SELECTpav.`spu_id`,ag.`attr_group_name`,ag.`attr_group_id`,aar.`attr_id`,attr.`attr_name`,pav.`attr_value`FROM `pms_attr_group` agLEFT JOIN `pms_attr_attrgroup_relation` aar ON aar.`attr_group_id` = ag.`attr_group_id`LEFT JOIN `pms_attr` attr ON attr.`attr_id` = aar.`attr_id`LEFT JOIN `pms_product_attr_value` pav ON pav.`attr_id` = attr.`attr_id`WHERE ag.`catelog_id` = 225 AND pav.`spu_id` = 6</select>
sql语句
SELECT
pav.`spu_id`,
ag.`attr_group_name`,
ag.`attr_group_id`,
aar.`attr_id`,
attr.`attr_name`,
pav.`attr_value`
FROM `pms_attr_group` ag
LEFT JOIN `pms_attr_attrgroup_relation` aar ON aar.`attr_group_id` = ag.`attr_group_id`
LEFT JOIN `pms_attr` attr ON attr.`attr_id` = aar.`attr_id`
LEFT JOIN `pms_product_attr_value` pav ON pav.`attr_id` = attr.`attr_id`
WHERE ag.`catelog_id` = 225 AND pav.`spu_id` = 6
GulimallProductApplicationTests 测试
@AutowiredAttrGroupDao attrGroupDao;@Testpublic void test() {List<SpuItemAttrGroupVo> attrGroupWithAttrsBySpuId = attrGroupDao.getAttrGroupWithAttrsBySpuId(6L, 225L);System.out.println(attrGroupWithAttrsBySpuId);}
2.4.4 销售属性组合代码实现
SkuInfoServiceImpl(最终代码)
@AutowiredSkuImagesService imagesService;@AutowiredSpuInfoDescService spuInfoDescService;@AutowiredAttrGroupService attrGroupService;@AutowiredSkuSaleAttrValueService skuSaleAttrValueService;@Overridepublic SkuItemVo item(Long skuId) {SkuItemVo skuItemVo = new SkuItemVo();//1、sku基本信息获取 pms_sku_infoSkuInfoEntity info = getById(skuId);skuItemVo.setInfo(info);Long catalogId = info.getCatalogId();Long spuId = info.getSpuId();//2、sku的图片信息 pms_sku_imagesList<SkuImagesEntity> images = imagesService.getImagesBySkuId(skuId);skuItemVo.setImages(images);//3、获取的spu的销售属性组合。List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuId(spuId);skuItemVo.setSaleAttr(saleAttrVos);//4、获取spu的介绍 pms_spu_info_descSpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(spuId);skuItemVo.setDesp(spuInfoDescEntity);//5、获取spu的规格参数信息。List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(spuId,catalogId);skuItemVo.setGroupAttrs(attrGroupVos);return skuItemVo;}
SkuSaleAttrValueServiceImpl
@Overridepublic List<SkuItemSaleAttrVo> getSaleAttrsBySpuId(Long spuId) {SkuSaleAttrValueDao dao = this.baseMapper;List<SkuItemSaleAttrVo> saleAttrVos = dao.getSaleAttrsBySpuId(spuId);return saleAttrVos;}
SkuSaleAttrValueDao
List<SkuItemSaleAttrVo> getSaleAttrsBySpuId(@Param("spuId") Long spuId);
SkuSaleAttrValueDao.xml
<select id="getSaleAttrsBySpuId" resultType="com.atguigu.gulimall.product.vo.SkuItemSaleAttrVo">SELECTssav.`attr_id` attr_id,ssav.`attr_name` attr_name,GROUP_CONCAT(DISTINCT ssav.`attr_value`) attr_valuesFROM `pms_sku_info` infoLEFT JOIN `pms_sku_sale_attr_value` ssav ON ssav.`sku_id` = info.`sku_id`WHERE info.`spu_id`=#{spuId}GROUP BY ssav.`attr_id`,ssav.`attr_name`</select>
sql语句
SELECT
ssav.`attr_id` attr_id,
ssav.`attr_name` attr_name,
GROUP_CONCAT(DISTINCT ssav.`attr_value`) attr_values
FROM `pms_sku_info` info
LEFT JOIN `pms_sku_sale_attr_value` ssav ON ssav.`sku_id` = info.`sku_id`
WHERE info.`spu_id`=#{spuId}
GROUP BY ssav.`attr_id`,ssav.`attr_name`
GulimallProductApplicationTests 测试:
@AutowiredSkuSaleAttrValueDao skuSaleAttrValueDao;@Testpublic void test() {List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueDao.getSaleAttrsBySpuId(7L);System.out.println(saleAttrVos);}
2.4.5 详情页渲染
<div class="box-name" th:text="${item.info.skuTitle}">华为 HUAWEI Mate 10 6GB+128GB 亮黑色 移动联通电信4G手机 双卡双待</div><div class="box-hide" th:text="${item.info.skuSubtitle}">预订用户预计11月30日左右陆续发货!麒麟970芯片!AI智能拍照!<a href="/static/item/"><u></u></a></div>
<div class="probox"><img class="img1" alt="" th:src="${item.info.skuDefaultImg}"><div class="hoverbox"></div></div><div class="showbox"><img class="img1" alt="" th:src="${item.info.skuDefaultImg}"></div>
<span th:text="${#numbers.formatDecimal(item.info.price,3,2)}">4499.00</span>
<li th:each="img : ${item.images}" th:if="${!#strings.isEmpty(img.imgUrl)}"><img th:src="${img.imgUrl}" /></li>
<div class="box-attr clear" th:each="attr:${item.saleAttr}"><dl><dt>选择[[${attr.attrName}]]</dt><dd th:each="val:${#strings.listSplit(attr.attrValues,',')}"><a href="/static/item/#">[[${val}]]<!--<img src="/static/item/img/59ddfcb1Nc3edb8f1.jpg" /> 摩卡金--></a></dd></dl></div>
<img class="xiaoguo" th:src="${descp}" th:each="descp:${#strings.listSplit(item.desp.decript,',')}"/>
<div class="guiGe" th:each="group:${item.groupAttrs}"><h3 th:text="${group.groupName}">主体</h3><dl><div th:each="attr:${group.attrs}"><dt th:text="${attr.attrName}">品牌</dt><dd th:text="${attr.attrValue}">华为(HUAWEI)</dd></div></div>
效果:
2.4.6 销售属性渲染
修改后台代码
SkuItemSaleAttrVo
@Data
@ToString
public class SkuItemSaleAttrVo {private Long attrId;private String attrName;private List<AttrValueWithSkuIdVo> attrValues;
}
AttrValueWithSkuIdVo
@Data
public class AttrValueWithSkuIdVo {private String attrValue;private String skuIds;
}
SkuSaleAttrValueDao.xml
<resultMap id="SkuItemSaleAttrVo" type="com.atguigu.gulimall.product.vo.SkuItemSaleAttrVo"><result column="attr_id" property="attrId"></result><result column="attr_name" property="attrName"></result><collection property="attrValues" ofType="com.atguigu.gulimall.product.vo.AttrValueWithSkuIdVo"><result column="attr_value" property="attrValue"></result><result column="sku_ids" property="skuIds"></result></collection></resultMap><select id="getSaleAttrsBySpuId" resultMap="SkuItemSaleAttrVo">SELECTssav.`attr_id` attr_id,ssav.`attr_name` attr_name,ssav.`attr_value`,GROUP_CONCAT(DISTINCT info.`sku_id`) sku_idsFROM `pms_sku_info` infoLEFT JOIN `pms_sku_sale_attr_value` ssav ON ssav.`sku_id` = info.`sku_id`WHERE info.`spu_id`=#{spuId}GROUP BY ssav.`attr_id`,ssav.`attr_name`,ssav.`attr_value`</select>
sql代码:
SELECTssav.`attr_id` attr_id,ssav.`attr_name` attr_name,ssav.`attr_value`,GROUP_CONCAT(DISTINCT info.`sku_id`) sku_idsFROM `pms_sku_info` infoLEFT JOIN `pms_sku_sale_attr_value` ssav ON ssav.`sku_id` = info.`sku_id`WHERE info.`spu_id`=7GROUP BY ssav.`attr_id`,ssav.`attr_name`,ssav.`attr_value`
item.html
<div class="box-attr clear" th:each="attr:${item.saleAttr}"><dl><!--strings.listSplit 切分#list.contains(A,B) 判断A数组中间 是否包含BskuId 同时设置一个class 在列表中包含的设置为选中 否则不选中--><dt>选择[[${attr.attrName}]]</dt><dd th:each="vals:${attr.attrValues}"><a class="sku_attr_value" th:attr="skus=${vals.skuIds},class=${#lists.contains(#strings.listSplit(vals.skuIds,','),item.info.skuId.toString())? 'sku_attr_value checked':'sku_attr_value'}">[[${vals.attrValue}]]<!--<img src="/static/item/img/59ddfcb1Nc3edb8f1.jpg" /> 摩卡金--></a></dd></dl></div>
$(function () {//页面初始化 给父类id设置样式$(".sku_attr_value").parent().css({"border": "solid 1px #CCC"});//class里面有对应样式的父类设置样式 checked 表示选中$("a[class = 'sku_attr_value checked']").parent().css({"border": "1px solid red"});})
效果:
实现点击 sku 能够动态切换。
$(".sku_attr_value").click(function () {//1、点击的元素先添加上自定义的属性。为了识别我们是刚才被点击的。var skus = new Array();$(this).addClass("checked");//属性skus以逗号拆分var curr = $(this).attr("skus").split(",");//当前被点击的所有sku组合数组放进去skus.push(curr);//去掉同一行的所有checked/*** parent 父类 中查询 拥有class的 然后删除调 checked*/$(this).parent().parent().find(".sku_attr_value").removeClass("checked");$("a[class='sku_attr_value checked']").each(function () {skus.push($(this).attr("skus").split(","));});console.log(skus);//2、取出他们的交集,得到skuIdvar filterEle = skus[0];for (var i = 1; i < skus.length; i++) {filterEle = $(filterEle).filter(skus[i]);}console.log(filterEle[0]);//3、跳转location.href = "http://item.gulimall.com/" + filterEle[0] + ".html";})
效果:点击颜色和版本都可以自动切换。
2.4.7 异步编排优化代码
①引入依赖:配置类可以有提示,这个可以配也可以不配。
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-configuration-processor</artifactId><optional>true</optional></dependency>
② 商品服务下新建 ThreadPoolConfigProperties
@Component
@Data
public class ThreadPoolConfigProperties {private Integer coreSize;private Integer maxSize;private Integer keepAliveTime;}
③ application.properties中添加线程池的相应配置
gulimall.thread.core-size=20
gulimall.thread.max-size=200
gulimall.thread.keep-alive-time=10
④商品服务下新建 MyThreadConfig
@EnableConfigurationProperties(ThreadPoolConfigProperties.class) //因为 ThreadPoolConfigProperties添加了注解@Component,可以不用写这个配置了,直接从容器中拿
@Configuration
public class MyThreadConfig {@Beanpublic ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool){return new ThreadPoolExecutor(pool.getCoreSize(),pool.getMaxSize(),pool.getKeepAliveTime(),TimeUnit.SECONDS,new LinkedBlockingDeque<>(100000),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());}}
⑤ SkuInfoServiceImpl
@Overridepublic SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {SkuItemVo skuItemVo = new SkuItemVo();// 第一步获得的数据,第3步、4步、5步也要使用CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {//1、sku基本信息获取 pms_sku_infoSkuInfoEntity info = getById(skuId);skuItemVo.setInfo(info);return info;}, executor);CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync((res) -> {//3、获取的spu的销售属性组合。List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuId(res.getSpuId());skuItemVo.setSaleAttr(saleAttrVos);}, executor);CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync((res) -> {//4、获取spu的介绍 pms_spu_info_descSpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());skuItemVo.setDesp(spuInfoDescEntity);}, executor);CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync((res) -> {//5、获取spu的规格参数信息。List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(res.getSpuId(), res.getCatalogId());skuItemVo.setGroupAttrs(attrGroupVos);}, executor);CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {//2、sku的图片信息 pms_sku_imagesList<SkuImagesEntity> images = imagesService.getImagesBySkuId(skuId);skuItemVo.setImages(images);}, executor);//等待所有任务都完成CompletableFuture.allOf(saleAttrFuture,descFuture,baseAttrFuture,imageFuture).get();return skuItemVo;}
⑥ItemController
/*** 展示当前sku的详情** @param skuId* @return*/@GetMapping("/{skuId}.html")public String skuItem(@PathVariable("skuId") Long skuId, Model model) throws ExecutionException, InterruptedException {System.out.println("准备查询" + skuId + "详情");SkuItemVo vo = skuInfoService.item(skuId);model.addAttribute("item",vo);return "item";}
⑦测试:一切正常。
2.5 认证服务
2.5.1 环境搭建
①创建 gulimall-auth-server服务
②引入依赖
<dependencies><dependency><groupId>com.atguigu.gulimall</groupId><artifactId>gulimall-common</artifactId><version>0.0.1-SNAPSHOT</version><exclusions><exclusion><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId></exclusion></exclusions></dependency>
这里如果我们修改 spring-boot-starter-parent 版本依然爆红,处理结果:清除缓存:
③ 添加域名
④动静分离。
将登录页面(改名为login.html)和认证页面(改名为 reg.html)放到认证服务下。
修改页面静态资源的前缀。
login.html
reg.html
⑤将认证服务注册进nacos
application.properties
spring.application.name=gulimall-auth-server
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
server.port=20000
GulimallAuthServerApplication(主启动类)
@EnableFeignClients //开启远程调用功能
@EnableDiscoveryClient //开启服务注册发现功能
⑥配置网关
- id: gulimall_auth_routeuri: lb://gulimall-auth-serverpredicates:- Host=auth.gulimall.com
⑦测试访问登录页面
注意:模板下面的页面除了 index页面,springmvc能够自动访问到,所有要去的页面我门都需要设置controller类来处理相应的请求。
为了测试,我们暂时将 login.html改名为 index.html。
⑧ 实现各个页面之间的相互跳转
1、实现登录页面点击”谷粒商城“图标能跳转到首页:
login.html
2、实现首页点击登录和注册能跳转到登录和注册页面:
修改商品服务下的首页index.html
认证服务编写 controller 实现跳转
@Controller
public class LoginController {@GetMapping("/login.html")public String loginPage(){return "login";}@GetMapping("/reg.html")public String regPage(){return "reg";}}
登录页面点击“立即注册”能够跳转到注册页面。
注册页面点击“请登录”能够跳转到登录页面。
ps:这里可以稍微修改一下 登录页面的宽度,让页面更好看一点。
2.5.2 短信验证码
①把 reg.html页面中这一处修改为 “发送验证码”
发送验证码,有60秒倒计时:
$(function (){$("#sendCode").click(function () {//2、倒计时if ($(this).hasClass("disabled")){//正在倒计时。}else{//1、给指定手机号码发送验证码timeoutChangeStyle();}});})var num = 60;function timeoutChangeStyle(){$("#sendCode").attr("class","disabled");if (num == 0){$("#sendCode").text("发送验证码");num = 60;$("#sendCode").attr("class","");}else{var str = num +"s 后再次发送";$("#sendCode").text(str);//每隔1s调用timeoutChangeStyle()setTimeout("timeoutChangeStyle()",1000);}num --;}
效果:
②修改后台代码
如果编写一个接口仅仅是为了跳转页面,没有数据的处理,如果这样的跳转接口多了则可以使用SpringMVC的view Controller(视图控制器)将请求与页面进行绑定
新建 GulimallWebConfig
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {/*** 视图映射* @param registry*/@Overridepublic void addViewControllers(ViewControllerRegistry registry) {/*** * @GetMapping("/login.html")* * public String loginPage(){* ** * return "login";* * }* * @param registry*/registry.addViewController("/login.html").setViewName("login");registry.addViewController("/reg.html").setViewName("reg");}
}
ps:idea快捷键:实现接口方法
alt + shift + p
以前的 LoginController 里面的 方法就可以注释掉了。
@Controller
public class LoginController {/*** 发送一个请求直接跳转到一个页面。* springMVC viewcontroller:将请求和页面映射过来。*/// @GetMapping("/login.html")// public String loginPage(){//// return "login";// }//// @GetMapping("/reg.html")// public String regPage(){//// return "reg";// }}
此外,设置 认证服务的最大内存:
③利用第三方服务进行短信验证码的发送。
这个是老师视频里面购买的短信服务:
https://market.aliyun.com/products/57126001/cmapi024822.html?spm=5176.730005.result.9.633f35248qhhtg&innerSource=search_%E4%B8%89%E7%BD%91%E5%90%88%E4%B8%80#sku=yuncode1882200000
但是现在这个只有企业用户才能购买。
我们就使用这个来进行测试。
https://market.aliyun.com/products/56928004/cmapi023305.html?spm=5176.2020520132.101.2.56bd7218XsusLL#sku=yuncode1730500007
初步测试一下。注意,这里需要开通API网关才可以进行调试。
购买成功后,从网关控制台这里点进去,如果直接从之前购买页面点进去调试,可能出现无法填写 AppCode等情况,继而测试不成功。
下面是调试内容展示:
测试:手机成功收到短信,且内容为code所写号码。
我们使用 postman进行测试。
测试成功。
ps:当我们在页面上点击“发送验证码”,我们不能通过js代码带上我们的APPCODE ,这样就直接将APPCODE 暴露给别人了,然后别人使用它发送大量短信,这样就有危机了。我们通过后台来发送验证码,这样比较保险。
短信验证码属于第三方服务,我们就放在 gulimall-third-party 服务下。
④ 后台代码调试
复制 相应java调试代码进行测试
@Testpublic void sendSms() {String host = "http://dingxin.market.alicloudapi.com";String path = "/dx/sendSms";String method = "POST";String appcode = "8c7b3796b27f44eb9569bfd090e74225";Map<String, String> headers = new HashMap<String, String>();//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105headers.put("Authorization", "APPCODE " + appcode);Map<String, String> querys = new HashMap<String, String>();querys.put("mobile", "15884430987");querys.put("param", "code:123456");querys.put("tpl_id", "TP1711063");Map<String, String> bodys = new HashMap<String, String>();try {/*** 重要提示如下:* HttpUtils请从* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/src/main/java/com/aliyun/api/gateway/demo/util/HttpUtils.java* 下载** 相应的依赖请参照* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/pom.xml*/HttpResponse response = HttpUtils.doPost(host, path, method, headers, querys, bodys);System.out.println(response.toString());//获取response的body//System.out.println(EntityUtils.toString(response.getEntity()));} catch (Exception e) {e.printStackTrace();}}
将 HttpUtils 从页面复制过来:https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/src/main/java/com/aliyun/api/gateway/demo/util/HttpUtils.java
然后在gulimall-third-party服务下新建 utils包存放相应的工具类。
测试:手机能获取到验证码。
我们将 获取验证码的方法 抽取成一个类:SmsComponent
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Data
@Component
public class SmsComponent {private String host;private String path;private String appcode;private String tpl_id;public void sendSmsCode(String mobile,String code) {String host = "http://dingxin.market.alicloudapi.com";String path = "/dx/sendSms";String method = "POST";String appcode = "8c7b3796b27f44eb9569bfd090e74225";Map<String, String> headers = new HashMap<String, String>();//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105headers.put("Authorization", "APPCODE " + appcode);Map<String, String> querys = new HashMap<String, String>();querys.put("mobile", mobile);// querys.put("param", "code:123456");querys.put("param", "code:"+code);querys.put("tpl_id", "TP1711063");Map<String, String> bodys = new HashMap<String, String>();try {/*** 重要提示如下:* HttpUtils请从* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/src/main/java/com/aliyun/api/gateway/demo/util/HttpUtils.java* 下载** 相应的依赖请参照* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/pom.xml*/HttpResponse response = HttpUtils.doPost(host, path, method, headers, querys, bodys);System.out.println(response.toString());//获取response的body//System.out.println(EntityUtils.toString(response.getEntity()));} catch (Exception e) {e.printStackTrace();}}
}
在配置文件 application.yml 中配置相应参数:
sms:host: http://dingxin.market.alicloudapi.compath: /dx/sendSmsappcode: xxxxxxxxxxxxxxx #这里写自己的tpl_id: TP1711063
在测试类中进行测试:
@AutowiredSmsComponent smsComponent;@Testpublic void testSendCode() {smsComponent.sendSmsCode("15884430987","123456");}
测试成功,手机收到验证码。
2.5.3 验证码之防刷校验
1.编写短信验证controller,方便其它服务调用
在 第三方服务下编写:
@RestController
@RequestMapping("/sms")
public class SmsSendController {@AutowiredSmsComponent smsComponent;/*** 提供给别的服务进行调用* @param phone* @param code* @return*/@GetMapping("/sendcode")public R sendCode(@RequestParam("phone") String phone,@RequestParam("code")String code){smsComponent.sendSmsCode(phone,code);return R.ok();}}
2. 认证服务远程调用发送短信验证码功能
①依赖已导入,开启远程服务调用功能
② 远程调用接口编写
在认证服务下新建 feign包:
@FeignClient("gulimall-third-party")
public interface ThirdPartyFeignService {@GetMapping("/sms/sendcode")public R sendCode(@RequestParam("phone") String phone, @RequestParam("code")String code);}
3.认证服务中编写获取短信验证码的controller
@Controller
public class LoginController {/*** 发送一个请求直接跳转到一个页面。* springMVC viewcontroller:将请求和页面映射过来。*/@AutowiredThirdPartyFeignService thirdPartyFeignService;@AutowiredStringRedisTemplate redisTemplate;@ResponseBody@GetMapping("/sms/sendcode")public R sendCode(@RequestParam("phone") String phone){String code = UUID.randomUUID().toString().substring(0, 5);thirdPartyFeignService.sendCode(phone,code);return R.ok();}}
4. 注册页面编写请求发送验证码功能
①为手机号码input框设置id,方便获取
②发送请求,请求后台发送短信验证码
$.get("/sms/sendcode?phone=" + $("#phoneNum").val());
测试:成功发送。
5.防止一个手机号码60s内多次获取短信验证码
解决思路:将短信验证码存储在redis中,key为phoneNum,value为验证码和存储时系统的当前时间。从redis中查询为null则调用发送短信验证码,若查询不为空则判断是否超过60s,是则再次调用发送短信验证码,否则返回提示信息。
①导入redis的依赖,并配置好redis
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
application.properties
spring.redis.host=192.168.56.10
spring.redis.port=6379
②common的constant中编写存储在redis中的验证码前置
public class AuthServerConstant {public static final String SMS_CODE_CACHE_PREFIX = "sms:code:";
}
③编写触发错误时的代码
SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,稍后再试"),
④LoginController修改
@Controller
public class LoginController {/*** 发送一个请求直接跳转到一个页面。* springMVC viewcontroller:将请求和页面映射过来。*/@AutowiredThirdPartyFeignService thirdPartyFeignService;@AutowiredStringRedisTemplate redisTemplate;@ResponseBody@GetMapping("/sms/sendcode")public R sendCode(@RequestParam("phone") String phone){//TODO 1、接口防刷。String redisCode = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);if (!StringUtils.isEmpty(redisCode)){long l = Long.parseLong(redisCode.split("_")[1]);if (System.currentTimeMillis() - l < 60000){//60秒内不能再发return R.error(BizCodeEnume.SMS_CODE_EXCEPTION.getCode(),BizCodeEnume.SMS_CODE_EXCEPTION.getMsg());}}//2、验证码的再次校验。redis。存 key-phone,value-code sms:code:15884430987 -> 45678String code = UUID.randomUUID().toString().substring(0, 5)+"_"+System.currentTimeMillis();// redis缓存验证码,防止同一个phone在60秒内再次发送验证码//set(K var1, V var2, long var3, TimeUnit var5)redisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX+phone,code,10, TimeUnit.MINUTES);thirdPartyFeignService.sendCode(phone,code);return R.ok();}}
⑤ 注册页面的请求发送验证码的回调函数编写
//1、给指定手机号码发送验证码$.get("/sms/sendcode?phone=" + $("#phoneNum").val(),function (data) {if (data.code != 0){alert(data.msg);}});
⑥测试,60秒内发送多次(刷新页面即可)
redis中存储:
2.5.4 一步一坑的注册页环境
①编写 vo封装注册页内容
这里使用后端进行验证: JSR303校验
@Data
public class UserRegistVo {//添加 JR303校验注解@NotEmpty(message = "用户名必须提交")@Length(min = 6, max = 18,message = "用户名必须是6-18位字符")private String userName;@NotEmpty(message = "密码必须提交")@Length(min = 6, max = 18,message = "密码必须是6-18位字符")private String password;@NotEmpty(message = "手机号必须填写")@Pattern(regexp = "^[1]([3-9])[0-9]{9}$",message = "手机号格式不正确")//第一位是1,第二位是3-9,后面9位都是 0-9private String phone;@NotEmpty(message = "验证码必须填写")private String code;}
回顾前面的JSR303校验怎么用:
JSR303校验的结果,被封装到BindingResult
,再结合BindingResult.getFieldErrors()
方法获取错误信息,有错误就重定向至注册页面。
②编写 controller接口
使用@Valid注解开启数据校验功能,将校验后的结果封装到BindingResult中
@PostMapping("/regist")public String regist(@Valid UserRegistVo vo, BindingResult result) {if (result.hasErrors()) {//校验出错,转发到注册页return "redirect:http://auth.gulimall.com/reg.html";}//注册成功回到首页,回到登录页return "redirect:/login.html";}
③ 编写注册页面
为每个input框设置name属性,值需要与Vo的属性名一一对应
点击注册按钮没有发送请求,说明:为注册按钮绑定了单击事件,禁止了默认行为。将绑定的单击事件注释掉
④为Model绑定校验错误信息
方法一:
方法二:
⑤编写前端页面获取错误信息
1. 导入thymeleaf的名称空间
2. 封装错误信息
<form action="/regist" method="post" class="one">0<div class="register-box"><label class="username_label">用 户 名<input name="userName" maxlength="20" type="text" placeholder="您的用户名和登录名"></label><div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'userName')?errors.userName:''):''}"></div></div><div class="register-box"><label class="other_label">设 置 密 码<input name="password" maxlength="20" type="password" placeholder="建议至少使用两种字符组合"></label><div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'password')?errors.password:''):''}"></div></div><div class="register-box"><label class="other_label">确 认 密 码<input maxlength="20" type="password" placeholder="请再次输入密码"></label><div class="tips"></div></div><div class="register-box"><label class="other_label"><span>中国 0086∨</span><input name="phone" class="phone" id="phoneNum" maxlength="20" type="text" placeholder="建议使用常用手机"></label><div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'phone')?errors.phone:''):''}"></div></div><div class="register-box"><label class="other_label">验 证 码<input name="code" maxlength="20" type="text" placeholder="请输入验证码" class="caa"></label><a id="sendCode">发送验证码</a><div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'code')?errors.code:''):''}"></div>
⑥测试–坑集合
1.出现问题: Request method ‘POST’ not supported
出现问题的原因:表单的提交使用的是post请求,会原封不动的转发给reg.html,但是/reg.html(路径映射默认都是get方式访问)
解决方案:如下图所示
2.出现问题:刷新页面,会重复提交表单
出现问题的原因:转发,原封不动转发过去
解决方案:使用重定向
3.出现问题:转发,数据都封装在Model中,而重定向获取不到
解决方案:使用 RedirectAttributes
RedirectAttributes的方法讲解:Spring MVC ---- RedirectAttributes 使用,请求转发携带参数总结
4.出现问题:重定向到服务端口地址
解决方案: 写完整的域名路径
说明: RedirectAttributes的 addFlashAttribute()方法是将errors保存在session中,刷新一次就没了
5.出现问题:分布式下重定向使用session存储数据会出现一些问题
解决方案:后续会说明
6.至此,暂时比较完整的controller接口代码如下:
/*** //TODO 重定向携带数据,利用session原理。将数据放在session中,只要跳到下一个页面取出这个数据以后,session里面的数据就会删掉**** // TODO 1、分布式下的session问题。* RedirectAttributes redirectAttributes : 模拟重定向携带数据* @param vo* @param result* @param redirectAttributes* @return*/@PostMapping("/regist")public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes, HttpSession session) {if (result.hasErrors()) {/*** .map(fieldError ->{* String field = fieldError.getField();* String defaultMessage = fieldError.getDefaultMessage();* errors.put(field,defaultMessage);* return* })***/Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));// model.addAttribute("errors", errors);redirectAttributes.addFlashAttribute("errors",errors);// Request method 'POST' not supported//用户注册 -》/regist[post] ---->转发/reg.html(路径映射默认都是get方法访问的。)//真正注册。调用远程服务进行注册//校验出错,转发到注册页return "redirect:http://auth.gulimall.com/reg.html";}//注册成功回到首页,回到登录页return "redirect:/login.html";}
测试:先全写错的,验证码不写。
后端校验提示出现。
ps: 以上内容是注册用户
在 gulimall-auth-server服务中编写注册的主体逻辑
- 从redis中确认手机验证码是否正确,一致则删除验证码,(令牌机制)
- 会员服务调用成功后,重定向至登录页(防止表单重复提交),否则封装远程服务返回的错误信息返回至注册页面
- 重定向的请求数据,可以利用RedirectAttributes参数转发
- 但是他是利用的session原理,所以后期我们需要解决分布式的session问题
- 重定向取一次后,session数据就消失了,因为使用的是.addFlashAttribute(
- 重定向时,如果不指定host,就直接显示了注册服务的ip,所以我们重定义写http://…
注: RedirectAttributes可以通过session保存信息并在重定向的时候携带过去
2.5.5 异常机制
1.校验验证码
@PostMapping("/regist")public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes, HttpSession session) {if (result.hasErrors()) {/*** .map(fieldError ->{* String field = fieldError.getField();* String defaultMessage = fieldError.getDefaultMessage();* errors.put(field,defaultMessage);* return* })*/Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));// model.addAttribute("errors", errors);redirectAttributes.addFlashAttribute("errors",errors);// Request method 'POST' not supported//用户注册 -》/regist[post] ---->转发/reg.html(路径映射默认都是get方法访问的。)//校验出错,重定向到注册页return "redirect:http://auth.gulimall.com/reg.html";}//1、校验验证码String code = vo.getCode();String s = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());if (!StringUtils.isEmpty(s)){if (code.equals(s.split("_")[0])){//删除验证码;令牌机制redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());//验证通过。//真正注册。调用远程服务进行注册。}else{Map<String, String> errors = new HashMap<>();errors.put("code","验证码错误");redirectAttributes.addFlashAttribute("errors",errors);return "redirect:http://auth.gulimall.com/reg.html";}}else{Map<String, String> errors = new HashMap<>();errors.put("code","验证码错误");redirectAttributes.addFlashAttribute("errors",errors);return "redirect:http://auth.gulimall.com/reg.html";}//注册成功回到首页,回到登录页return "redirect:/login.html";}
验证短信验证码通过,下面开始去数据库保存。
member远程服务
通过gulimall-member
会员服务注册逻辑
- 通过异常机制判断当前注册会员名和电话号码是否已经注册,如果已经注册,则抛出对应的自定义异常,并在返回时封装对应的错误信息
- 如果没有注册,则封装传递过来的会员信息,并设置默认的会员等级、创建时间
2. 会员服务中编写Vo接受数据
@Data
public class MemberRegistVo {/*** 能调用远程服务,说明是auth服务过来的,这个时候就不需要进行校验了。*/private String userName;private String password;private String phone;}
3. 编写会员服务的用户注册接口
MemberController
//因为我们注册会提交很多的东西,所以是 post方式提交@PostMapping("/regist")public R regist(@RequestBody MemberRegistVo vo){memberService.regist(vo);return R.ok();}
MemberServiceImpl
@Overridepublic void regist(MemberRegistVo vo) {MemberDao memberDao = this.baseMapper;MemberEntity entity = new MemberEntity();//设置默认等级MemberLevelEntity levelEntity = memberLevelDao.getDefaultLevel();entity.setLevelId(levelEntity.getId());//检查用户名和手机号是否唯一。为了让controller能够感知异常,使用异常机制:一直往上抛checkPhoneUnique(vo.getPhone());checkUsernameUnique(vo.getUserName());entity.setMobile(vo.getPhone());entity.setUsername(vo.getUserName());//密码要进行加密存储。memberDao.insert(entity);}
MemberLevelDao.xml -> :查询会员的默认等级
<select id="getDefaultLevel" resultType="com.atguigu.gulimall.member.entity.MemberLevelEntity">SELECT * FROM `ums_member_level` WHERE default_status = 1</select>
这里:检查用户名和手机号是否唯一
这里采用异常机制处理,如果查出用户名或密码不唯一则向上抛出异常
异常类的编写
会员服务下创建 exception包
PhoneExistException
public class PhoneExistException extends RuntimeException{public PhoneExistException() {super("手机号存在");}
}
UsernameExistException
public class UsernameExistException extends RuntimeException{public UsernameExistException() {super("用户名存在");}
}
检查方法编写->MemberServiceI
void checkPhoneUnique(String phone) throws PhoneExistException;void checkUsernameUnique(String username) throws UsernameExistException;
MemberServiceImpl
@Overridepublic void checkPhoneUnique(String phone) throws PhoneExistException{MemberDao memberDao = this.baseMapper;Integer mobile = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone));if (mobile > 0){throw new PhoneExistException();}}@Overridepublic void checkUsernameUnique(String username) throws UsernameExistException {MemberDao memberDao = this.baseMapper;Integer count = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("username", username));if (count > 0){throw new UsernameExistException();}}
如果抛出异常,则进行捕获
@PostMapping("/regist")public R regist(@RequestBody MemberRegistVo vo){try{memberService.regist(vo);}catch (Exception e){//不同的异常,有不同的处理}return R.ok();}
密码的设置,前端传来的密码是明文,存储到数据库中需要进行加密。
2.5.6 MD5&盐值&BCrypt
首先,加密分为可逆加密和不可逆加密。密码的加密为不可逆加密
MD5
- Message Digest algorithm 5,信息摘要算法
- 压缩性:任意长度的数据,算出的MD5值长度都是固定的。
- 容易计算:从原数据计算出MD5值很容易。
- 抗修改性:对原数据进行任何改动,哪怕只修改1个字节,所得到的MD5值都有很大区别。
- 强抗碰撞:想找到两个不同的数据,使它们具有相同的MD5值,是非常困难的。
- 不可逆
加盐:
-
通过生成随机数与MD5生成字符串进行组合
-
数据库同时存储MD5值与salt值。验证正确性时使用salt进行MD5即可
@Testpublic void contextLoads() {//e10adc3949ba59abbe56e057f20f883e//抗修改性:彩虹表。 123456 -> xxxxString s = DigestUtils.md5Hex("123456");//MD5不能直接进行密码的加密存储:可以被直接暴力破解// System.out.println(s);}
Apache.common下DigestUtils工具类的md5Hex()方法,将MD5加密后的数据转化为16进制
MD5并安全,很多在线网站都可以破解MD5,通过使用彩虹表,暴力破解。
因此,可以通过使用MD5+盐值进行加密
盐值:随机生成的数
方法1是加默认盐值: 1 1 1xxxxxxxx
方法2是加自定义盐值
//盐值加密:随机值 加盐:$1$+8位字符//$1$qqqqqqqq$AZofg3QwurbxV3KEOzwuI1//验证: 123456进行盐值(去数据库查)加密// String s1 = Md5Crypt.md5Crypt("123456".getBytes(), "$1$qqqqqqqq");// System.out.println(s1);
这种方法需要在数据库添加一个专门来记录注册时系统时间的字段,此外还需额外在数据库中存储盐值。
可以使用Spring家的BCryptPasswordEncoder,它的encode()方法使用的就是MD5+盐值进行加密,盐值是随机产生的,通过matches()方法进行密码是否一致。
//使用 spring家的BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();//$2a$10$R/VBymW1UA.VzeBedBcspe.iypJIyQiWkka/Ds5SDG7h6r0wQsF6GString encode = passwordEncoder.encode("123456");boolean matches = passwordEncoder.matches("123456", "$2a$10$R/VBymW1UA.VzeBedBcspe.iypJIyQiWkka/Ds5SDG7h6r0wQsF6G");// $2a$10$jLJp4edbLb9pnCg9quGk0u2uvsm4E/6TD5zi1wqHY4jz/f1ydS.LS=>trueSystem.out.println(encode+"=>"+matches);
用户注册业务中的密码加密
//密码要进行加密存储。BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();String encode = passwordEncoder.encode(vo.getPassword());entity.setPassword(encode);
2.5.7 注册完成
1.在common的exception包下,编写异常枚举
USER_EXIST_EXCEPTION(15001,"用户存在"),PHONE_EXIST_EXCEPTION(15002,"手机号存在"),
2. 进行异常的捕获
MemberController
//因为我们注册会提交很多的东西,所以是 post方式提交@PostMapping("/regist")public R regist(@RequestBody MemberRegistVo vo){try{memberService.regist(vo);}catch (PhoneExistException e){return R.error(BizCodeEnume.PHONE_EXIST_EXCEPTION.getCode(),BizCodeEnume.PHONE_EXIST_EXCEPTION.getMsg());}catch (UsernameExistException e){return R.error(BizCodeEnume.USER_EXIST_EXCEPTION.getCode(),BizCodeEnume.USER_EXIST_EXCEPTION.getMsg());}return R.ok();}
3. 远程服务接口编写
在 auth 服务下新建 MemberFeignService
@FeignClient("gulimall-member")
public interface MemberFeignService {@PostMapping("/member/member/regist")public R regist(@RequestBody UserRegistVo vo);}
4. 远程服务调用
@AutowiredMemberFeignService memberFeignService;/*** //TODO 重定向携带数据,利用session原理。将数据放在session中,只要跳到下一个页面取出这个数据以后,session里面的数据就会删掉* <p>* <p>* <p>* // TODO 1、分布式下的session问题。* RedirectAttributes redirectAttributes : 模拟重定向携带数据** @param vo* @param result* @param redirectAttributes* @return*/@PostMapping("/regist")public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes, HttpSession session) {if (result.hasErrors()) {/*** .map(fieldError ->{* String field = fieldError.getField();* String defaultMessage = fieldError.getDefaultMessage();* errors.put(field,defaultMessage);* return* })*/Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));// model.addAttribute("errors", errors);redirectAttributes.addFlashAttribute("errors", errors);// Request method 'POST' not supported//用户注册 -》/regist[post] ---->转发/reg.html(路径映射默认都是get方法访问的。)//校验出错,重定向到注册页return "redirect:http://auth.gulimall.com/reg.html";}//1、校验验证码String code = vo.getCode();String s = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());if (!StringUtils.isEmpty(s)) {if (code.equals(s.split("_")[0])) {//删除验证码;令牌机制redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());//验证通过。//真正注册。调用远程服务进行注册。R r = memberFeignService.regist(vo);if (r.getCode() == 0) {//成功return "redirect:http://auth.gulimall.com/login.html";} else {Map<String, String> errors = new HashMap<>();errors.put("msg", r.getData("msg",new TypeReference<String>() {}));redirectAttributes.addFlashAttribute("errors", errors);return "redirect:http://auth.gulimall.com/reg.html";}} else {Map<String, String> errors = new HashMap<>();errors.put("code", "验证码错误");redirectAttributes.addFlashAttribute("errors", errors);return "redirect:http://auth.gulimall.com/reg.html";}} else {Map<String, String> errors = new HashMap<>();errors.put("code", "验证码错误");redirectAttributes.addFlashAttribute("errors", errors);return "redirect:http://auth.gulimall.com/reg.html";}}
5.注册页错误消息提示
<div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'msg')?errors.msg:''):''}"></div>
6.一些测试中发现需要修改的地方
/*** 获取短信验证码** @param phone* @return*/@ResponseBody@GetMapping("/sms/sendcode")public R sendCode(@RequestParam("phone") String phone) {//TODO 1、接口防刷。String redisCode = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);if (!StringUtils.isEmpty(redisCode)) {long l = Long.parseLong(redisCode.split("_")[1]);if (System.currentTimeMillis() - l < 60000) {//60秒内不能再发return R.error(BizCodeEnume.SMS_CODE_EXCEPTION.getCode(), BizCodeEnume.SMS_CODE_EXCEPTION.getMsg());}}//2、验证码的再次校验。redis。存 key-phone,value-code sms:code:15884430987 -> 45678String code = UUID.randomUUID().toString().substring(0, 5);String substring = code + "_" + System.currentTimeMillis();// redis缓存验证码,防止同一个phone在60秒内再次发送验证码//set(K var1, V var2, long var3, TimeUnit var5)redisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone, substring, 10, TimeUnit.MINUTES);thirdPartyFeignService.sendCode(phone, code);return R.ok();}
需要使用重定向,前面代码已经给了。
测试:
成功重定向到 登录页面。
数据库中也有相应记录的保存。
2.5.8 账户密码登录完成
1.编写Vo-> 认证服务下新建 UserLoginVo
@Data
public class UserLoginVo {private String loginacct;private String password;}
2.数据绑定
将ul包在表单里面
3. 编写登录接口
说明:不能加@RequestBody注解,这里是页面直接提交数据,数据类型是map并非json
@PostMapping("/login")public String login(UserLoginVo vo,RedirectAttributes redirectAttributes){//因为请求第一次过来是页面传过来的是kv键值对(表单传过来的),不是JSON,所以不加@RequestBody,调用远程服务时将其又转换为了JSON//远程登录R login = memberFeignService.login(vo);if (login.getCode() == 0){//成功return "redirect:http://gulimall.com";}else{//失败,回到登录页Map<String, String> errors = new HashMap<>();errors.put("msg", login.getData("msg",new TypeReference<String>(){}));redirectAttributes.addFlashAttribute("errors", errors);return "redirect:http://auth.gulimall.com/login.html";}}
接下来我们需要调用 会员服务进行 登录验证。
4.member服务的Vo编写
MemberLoginVo
@Data
public class MemberLoginVo {private String loginacct;private String password;
}
5. member服务用户校验接口编写
MemberController
@PostMapping("/login")public R login(@RequestBody MemberLoginVo vo){MemberEntity entity = memberService.login(vo);if (entity != null){return R.ok();}else{return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getCode(), BizCodeEnume.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getMsg());}}
MemberServiceImpl
@Overridepublic MemberEntity login(MemberLoginVo vo) {String loginacct = vo.getLoginacct();String password = vo.getPassword();//1、去数据库查询 SELECT * FROM `ums_member` WHERE username = ? OR mobile = ?MemberDao memberDao = this.baseMapper;MemberEntity entity = memberDao.selectOne(new QueryWrapper<MemberEntity>().eq("username", loginacct).or().eq("mobile", loginacct));if (entity == null){//登录失败return null;}else{//1、获取到数据库的password $2a$10$OuDQdPAHqJRdzbQvJWeJwu8UQ.mVSw/i0MP8E4CWu2bjQmM3Xvt4mString passwordDb = entity.getPassword();BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();//2、密码匹配boolean matches = passwordEncoder.matches(password, passwordDb);if (matches){return entity;}else{return null;}}}
MemberLevelDao.xml
<select id="getDefaultLevel" resultType="com.atguigu.gulimall.member.entity.MemberLevelEntity">SELECT * FROM `ums_member_level` WHERE default_status = 1</select>
编写异常枚举
BizCodeEnume
LOGINACCT_PASSWORD_INVALID_EXCEPTION(15003,"账号密码错误");
6.远程服务接口编写
认证服务调用会员服务接口进行登录验证。
MemberFeignService.java
@PostMapping("/member/member/login")R login(@RequestBody UserLoginVo vo);
LoginController.java
@PostMapping("/login")public String login(UserLoginVo vo,RedirectAttributes redirectAttributes){//因为请求第一次过来是页面传过来的是kv键值对(表单传过来的),不是JSON,所以不加@RequestBody,调用远程服务时将其又转换为了JSON//远程登录R login = memberFeignService.login(vo);if (login.getCode() == 0){//成功return "redirect:http://gulimall.com";}else{//失败,回到登录页Map<String, String> errors = new HashMap<>();errors.put("msg", login.getData("msg",new TypeReference<String>(){}));redirectAttributes.addFlashAttribute("errors", errors);return "redirect:http://auth.gulimall.com/login.html";}}
7.页面错误消息提示
在 form 表单下面新增一个 div存放错误消息提示
<div class="tips" style="color:red"th:text="${errors!=null?(#maps.containsKey(errors, 'msg')?errors.msg:''):''}"></div>
8.测试
先随便写一些错误的账号密码,看提示是否出来。
ps:注意这里将之前的 注册时 错误的存放修改一下。
LoginController的 regist方法
输入正确的账号密码,成功跳转到首页,至于显示登录用户个人信息,后面在进行补充。
2.5.9 社交登录
1、OAuth 2.0
下面是老师课件内容:
2、下面进入实际操作。
首先我们先明白 OAuth2.0的原理图:
①因为现在微博需要开发者身份认证且耗时较久,所以此次社交登录选择利用能够快速开始项目实践的Gitee进行测试。
注册地址:https://gitee.com/oauth/applications/
对应文档:Gitee OAuth 文档
注册内容(可以根据文档来进行):
下面是随机找的一个图标
本次Gitee OAuth2登录原理:
②修改 login.html页面
对应的是 登录页面第三方登录图标,以及跳转地址。
https://gitee.com/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code
<a href="https://gitee.com/oauth/authorize?client_id=821c228b5c8a2a597419ee2ac8b98d05d387d4a9a8a9f37480d7213f24c64582&redirect_uri=http://gulimall.com/success&response_type=code"><img style="width: 50px;height: 18px" src="/static/login/JD_img/gitee.png"/></a>
根据图片地址将图片上传到 Nginx下。
③测试获取 code 码
点击 Gitee登录
引导到指定页面进行登录。
登录完成获得:code码
④利用 postman进行获取 access_token测试
1.获取 access_token
https://gitee.com/oauth/token?grant_type=authorization_code&code={code}&client_id={client_id}&redirect_uri={redirect_uri}&client_secret={client_secret}
2.有了access_token之后,我们根据 API文档即可获取到我们想要的所有信息。
以上测试都是按照 API文档进行测试的。
3、完成社交登录功能。
像 client_id、client_secret、access_token等应该保密,不应爱直接放在路径地址后,所以我们应该编写后端代码进行获取。
①首先,修改之前设置的回调地址:
回调地址
页面也需要修改:
<a href="https://gitee.com/oauth/authorize?client_id=821c228b5c8a2a597419ee2ac8b98d05d387d4a9a8a9f37480d7213f24c64582&redirect_uri=http://auth.gulimall.com/oauth2.0/gitee/success&response_type=code"><img style="width: 50px;height: 18px" src="/static/login/JD_img/gitee.png"/></a>
②编写后端代码
- 首先为了方便编写请求,我们导入之前 短信验证码使用过的 HttpUtils类,将其放在 common包。
依赖地址: https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/pom.xml
<!--导入 org.apache.http.client.HttpClient等依赖--><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.15</version></dependency><dependency><groupId>org.apache.httpcomponents</groupId><artifactId>httpclient</artifactId><version>4.2.1</version></dependency><dependency><groupId>org.apache.httpcomponents</groupId><artifactId>httpcore</artifactId><version>4.2.1</version></dependency><dependency><groupId>commons-lang</groupId><artifactId>commons-lang</artifactId><version>2.6</version></dependency><dependency><groupId>org.eclipse.jetty</groupId><artifactId>jetty-util</artifactId><version>9.3.7.v20160115</version></dependency><!--<dependency>--><!-- <groupId>junit</groupId>--><!-- <artifactId>junit</artifactId>--><!-- <version>4.5</version>--><!-- <scope>test</scope>--><!--</dependency>-->
test依赖暂时不需要。
-
编写封装社交账户用户信息的实体类(注意,这里老师课件上是将 获取Access token的得到的JSON数据封装成的实体类)。这里我们使用的 Gitee 获取 access_token得到的数据中没有一个能够唯一标识 该社交用户的 字段,因为我们如果使用社交账户登录成功,如果是第一次登录,我们需要将其注册进 会员表中,此时应该有一个能够唯一识别 该用户的字段,通过 微博 登录测试会直接获得 一个 字段“uid”,我们通过Gitee来进行测试,只能再通过一个接口进行获取用户id的信息。
获取授权用户的资料
在 认证服务的vo包下新建 SocialUser(之后我们通过接口文档提供的url地址来将相应信息设置进去)
@Data
public class SocialUser {private String access_token;private long expires_in;private String uid;
}
老师课件上的:
利用在线JSON转换工具获得的一个Javabean实体类。
- 为member表新增三个字段
-
认证服务下编写 处理社交登录请求的类
OAuth2Controller(最终)
/**处理社交登录请求* @author wystart* @create 2022-12-05 21:42*/ @Slf4j @Controller public class OAuth2Controller {@AutowiredMemberFeignService memberFeignService;/*** 社交登录成功回调* @param code* @return* @throws Exception*/@GetMapping("/oauth2.0/gitee/success")public String gitee(@RequestParam("code") String code) throws Exception {Map<String, String> map = new HashMap<>();map.put("grant_type", "authorization_code");map.put("code", code);map.put("client_id", "821c228b5c8a2a597419ee2ac8b98d05d387d4a9a8a9f37480d7213f24c64582");map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/gitee/success");map.put("client_secret", "4fba1e08692dcc06909f213d05d3e5cfa531458815dd494629a9fea92fc25ccc");Map<String,String> headers = new HashMap<>();Map<String,String> querys = new HashMap<>();//1、根据code 换取 access_tokenHttpResponse response = HttpUtils.doPost("https://gitee.com", "/oauth/token", "post", headers, querys, map);//2、处理if (response.getStatusLine().getStatusCode() == 200) {//获取到了 accessTokenString json = EntityUtils.toString(response.getEntity());JSONObject jsonObject = JSON.parseObject(json);String accessToken = jsonObject.getString("access_token");Long expiresIn = Long.valueOf(jsonObject.getString("expires_in"));//通过 access_token 获取用户 idMap<String, String> map1 = new HashMap<>();map1.put("access_token", accessToken);HttpResponse response1 = HttpUtils.doGet("https://gitee.com", "/api/v5/user", "get", new HashMap<>(), map1);String json1 = EntityUtils.toString(response1.getEntity());JSONObject jsonObject1 = JSON.parseObject(json1);String id = jsonObject1.getString("id");//将 access_token expires_in uid封装到 SocialUserSocialUser socialUser = new SocialUser();socialUser.setUid(id);socialUser.setAccess_token(accessToken);socialUser.setExpires_in(expiresIn);//知道当前是哪个社交用户//1)、当前用户如果是第一次进网站,自动注册进来(为当前社交用户生成一个会员信息账号,以后这个社交账号就对应指定的会员账号//登录或者注册这个社交用户R oauthlogin = memberFeignService.oauthlogin(socialUser);if (oauthlogin.getCode() == 0) {MemberRespVo data = oauthlogin.getData("data", new TypeReference<MemberRespVo>() {});log.info("登录成功:用户信息:{}",data.toString());//2、登录成功就跳回首页return "redirect:http://gulimall.com";} else {return "redirect:http://auth.gulimall.com/log.html";}} else {return "redirect:http://auth.gulimall.com/log.html";}}}
-
会员服务下编写 处理社交登录的类
MemberEntity实体类下新增3个字段
private String socialUid;private String accessToken;private Long expiresIn;
MemberController
//社交登录@PostMapping("/oauth2/login")public R oauthlogin(@RequestBody SocialUser socialUser) throws Exception {MemberEntity entity = memberService.login(socialUser);if (entity != null){//TODO 1、登录成功处理return R.ok().setData(entity);}else{return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getCode(), BizCodeEnume.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getMsg());}}
MemberServiceImpl
@Overridepublic MemberEntity login(SocialUser socialUser) throws Exception {//登录和注册合并逻辑String uid = socialUser.getUid();//1、判断当前社交用户是否已经登录过系统;MemberDao memberDao = this.baseMapper;MemberEntity memberEntity = memberDao.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));if (memberEntity != null) {//这个用户已经注册MemberEntity update = new MemberEntity();update.setId(memberEntity.getId());update.setAccessToken(socialUser.getAccess_token());update.setExpiresIn(socialUser.getExpires_in());memberDao.updateById(update);memberEntity.setAccessToken(socialUser.getAccess_token());memberEntity.setExpiresIn(socialUser.getExpires_in());return memberEntity;} else {//2、没有查询到当前社交用户对应的记录我们就需要注册一个MemberEntity regiset = new MemberEntity();try {//3、查询当前社交用户的社交账户信息(昵称,性别等)Map<String, String> query = new HashMap<>();query.put("access_token", socialUser.getAccess_token());HttpResponse response = HttpUtils.doGet("https://gitee.com", "/api/v5/user", "get", new HashMap<>(), query);if (response.getStatusLine().getStatusCode() == 200) {//查询成功String json = EntityUtils.toString(response.getEntity());JSONObject jsonObject = JSON.parseObject(json);//昵称String name = jsonObject.getString("name");regiset.setNickname(name);}} catch (Exception e) {}regiset.setSocialUid(socialUser.getUid());regiset.setAccessToken(socialUser.getAccess_token());regiset.setExpiresIn(socialUser.getExpires_in());memberDao.insert(regiset);return regiset;}}
为了方便调用 ,我们将 HttpUtils放进 common服务中。
此外,我们将 SocialUser类也复制到member服务的vo包下。
-
远程调用:认证服务调用会员服务
认证服务下的MemberFeignService
@PostMapping("/member/member/oauth2/login")R oauthlogin(@RequestBody SocialUser socialUser) throws Exception;
新增 MemberRespVo封装数据
直接将 MemberEntity 复制过来
@ToString @Data public class MemberRespVo {/*** id*/private Long id;/*** 会员等级id*/private Long levelId;/*** 用户名*/private String username;/*** 密码*/private String password;/*** 昵称*/private String nickname;/*** 手机号码*/private String mobile;/*** 邮箱*/private String email;/*** 头像*/private String header;/*** 性别*/private Integer gender;/*** 生日*/private Date birth;/*** 所在城市*/private String city;/*** 职业*/private String job;/*** 个性签名*/private String sign;/*** 用户来源*/private Integer sourceType;/*** 积分*/private Integer integration;/*** 成长值*/private Integer growth;/*** 启用状态*/private Integer status;/*** 注册时间*/private Date createTime;private String socialUid;private String accessToken;private Long expiresIn; }
-
测试
登录成功返回首页(这里很容易出现 超时现象,多试几次即可。)
数据库多出一条记录
控制台成功打印:
- 总体步骤总结:
ps:
注意点:
登录成功得到了code,这不应该提供给用户;
拿着code还有其他信息APP-KEY去获取token,更不应该给用户看到
应该回调的是后台的controller,在后台处理完token逻辑后返回
把成功后回调改为:http://auth.gulimall.com/oauth2.0/gitee/success
通过HttpUtils发送请求获取token,并将token等信息交给member服务进行社交登录
进行账号保存,主要有uid、token、expires_in
若获取token失败或远程调用服务失败,则封装错误信息重新转回登录页
token保存
登录包含两种流程,实际上包括了注册和登录
如果之前未使用该社交账号登录,则使用
token
调用开放api获取社交账号相关信息(头像等),注册并将结果返回如果之前已经使用该社交账号登录,则更新
token
并将结果返回
2.5.10 分布式session
登录成功后,首页NickName的显示
在之前的单体应用中,会将登录成功后的属性保存到session中
Thymeleaf取出session
index.html
出现问题:NickName未显示
出现问题的原因:Session不能跨域使用
auth.gulimall域下的session作用域只限于auth.gulimall域,gulimall域是获取不到的,不共享的
session原理:
session共享问题
1.同域名,同一个服务下,session复制多份:第一个服务器下存的session,在第二个服务器下没有
2.不同域名,不同服务下,session如何共享。
解决方案:
方案一:sessio复制,不采用
方案二:客户端存储,不采用
方案三: 利用hash一致性,进行负载均衡,可以采用但是这里不采用
方案四: 统一存储,这里采用这套方案
每个服务:把session放到redis中存储;发卡(JSESSIONID)并放大域名;------->利用SpringSession解决 :
后端统一存储,前端一个卡去往任何服务都通用。
2.5.11 SpringSession 整合 redis 完成 session域问题
相关文档:
https://spring.io/projects/spring-session-data-redis
https://docs.spring.io/spring-session/docs/2.4.2/reference/html5/#modules
通过SpringSession修改session的作用域
会员服务、订单服务、商品服务,都是去redis里存储session
①整合依赖
认证服务和商品服务均导入此依赖(如果遵循微服务自制原则,我们就不应该将依赖导入到公共包,而是编写我们自己的微服务)
<!--1、整合SpringSession完成session共享问题--><dependency><groupId>org.springframework.session</groupId><artifactId>spring-session-data-redis</artifactId></dependency>
② 配置文件配置
认证服务和商品服务都可以这样配置。当然,最简单的就可以直接配置会话存储类型即可。因为redis相应配置已经配置,可以不配了。
# 会话存储类型
spring.session.store-type=redis
# 会话超时。如果未指定持续时间后缀,则使用秒。
server.servlet.session.timeout=30m
③使用@EnableRedisHttpSession注解开启Spring Session with Redis功能
@EnableRedisHttpSession //整合redis作为session存储
@EnableFeignClients //开启远程调用功能
@EnableDiscoveryClient //开启服务注册发现功能
@SpringBootApplication
public class GulimallAuthServerApplication {public static void main(String[] args) {SpringApplication.run(GulimallAuthServerApplication.class, args);}}
④测试:看是否将session存入redis中
出现问题:DefaultSerializer requires a Serializable payload but received an object of type [com.atguigu.gulimall.auth.vo.MemberRespVo]
出现的原因: MemberRespVo未实现序列化解接口
解决方案:
public class MemberRespVo implements Serializable //实现序列化
这里,我们可以将MemberRespVo复制到commom中,因为,product服务还需要将Session中存储的loginUser反序列化为MemberRespVo对象。
保存成功,数据库有相应数据。
但是,首页依然不显示,这是因为域名范围限制,下面我们就将自定义SpringSession完成子域session共享。
⑤解决子域session共享问题:
认证服务和商品服务下均配置该类:
@Configuration
public class GulimallSessionConfig {//子域共享问题解决@Beanpublic CookieSerializer cookieSerializer() {DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();cookieSerializer.setDomainName("gulimall.com");// 扩大session作用域,也就是cookie的有效域cookieSerializer.setCookieName("GULISESSION");return cookieSerializer;}// 使用json序列化方式来序列化对象数据到redis中@Beanpublic RedisSerializer<Object> springSessionDefaultRedisSerializer() {return new GenericJackson2JsonRedisSerializer();}}
前端页面修改,需要进行非空判断
<a href="http://auth.gulimall.com/login.html">你好,请登录:[[${session.loginUser==null?'':session.loginUser.nickname}]]</a>
测试:成功显示nickname
数据库存入相应的实体类。
2.5.12 SpringSession原理–装饰者模式
@EnableRedisHttpSession注解导入了RedisHttpSessionConfiguration.class这个配置类
在 RedisHttpSessionConfiguration.class这个配置类,为容器中注入了一个组件
sessionRepository -> sessionRedisOperations : redis操作session,实现session的增删改查
调用SpringHttpSessionConfiguration中的springSessionRepositoryFilter()方法,获取一个
SessionRepositoryFilter对象,调用doFilterInternal()对原生的request和response对象进行封装即装饰者模式,request对象调用getSession()方法就会调用wrapperRequest对象的getSession()方法
/*** 核心原理* 1)、@EnableRedisHttpSession导入RedisHttpSessionConfiguration配置* 1、给容器中添加了一个组件* sessionRepository -> 【RedisOperationsSessionRepository】 : redis操作session,实现session的增删改查* 2、SessionRepositoryFilter --Filter:session存储过滤器;每个请求过来都必须经过filter* 1、创建的时候,就自动从容器中获取到了 SessionRepository* 2、原始的request,response都被包装,SessionRepositoryRequestWrapper,SessionRepositoryResponseWrapper* 3、以后获取session。 request.getSession();* 4、wrappedRequest.getSession() --》 SessionRepository中获取到的。*** 装饰者模式** 自动延期;redis中的数据也是有过期时间。*/
网上的相关内容,可以参考下:https://blog.csdn.net/m0_46539364/article/details/110533408
2.5.13 页面效果完善
1、完善社交登录的页面效果
index.html
<li><a th:if="${session.loginUser!=null}">欢迎:[[${session.loginUser==null?'':session.loginUser.nickname}]]</a><a href="http://auth.gulimall.com/login.html" th:if="${session.loginUser ==null}">欢迎登录</a></li><li><a href="http://auth.gulimall.com/reg.html" class="li_2" th:if="${session.loginUser==null}">免费注册</a></li>
2、通过账号密码登录(账户登录)的用户信息也保存到session中
①编写一个可修改的属性key
公共服务下
② 用户信息也保存到session中
会员服务 MemberController下
认证服务LoginController下
③设置默认的昵称
④ 登录后,首页页面细化
<li><a th:if="${session.loginUser!=null}">欢迎:[[${session.loginUser==null?'':session.loginUser.nickname}]]</a><a href="http://auth.gulimall.com/login.html" th:if="${session.loginUser ==null}">欢迎登录</a></li><li><a href="http://auth.gulimall.com/reg.html" class="li_2" th:if="${session.loginUser==null}">免费注册</a></li>
已经登录的话,在进入登录页要实现跳转首页的效果
①自己编写业务逻辑,将自动页面映射注释
②编写接口
LoginController类下
//利用session判断如果登陆过,访问登录页面就直接跳转到首页@GetMapping("/login.html")public String loginPage(HttpSession session){Object attribute = session.getAttribute(AuthServerConstant.LOGIN_USER);if (attribute == null){//没登录return "login";}else{return "redirect:http://gulimall.com";}}
商品详情页,用户昵称显示
详情页显示昵称
点击京东图标返回首页
效果展示:
搜索页,用户昵称显示
完善检索服务
①导入依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>org.springframework.session</groupId><artifactId>spring-session-data-redis</artifactId></dependency>
②配置
application.properties
③ 开启共享session功能
GulimallSearchApplication 添加注解
@EnableRedisHttpSession
④ 复制配置类
将 GulimallSessionConfig 配置类复制到 检索服务下
⑤ 前端代码
<li><a th:if="${session.loginUser == null }" href="http://auth.gulimall.com/login.html" class="li_2">你好,请登录</a><a th:else style="width: 100px">[[${session.loginUser.nickname}]]</a></li><li><a th:if="${session.loginUser == null }" href="http://auth.gulimall.com/reg.html">免费注册</a></li>
效果展示:
前面使用的是Gitee进行登录,现在我们也可以修改成和老师一样的使用 微博登录。下面是需要修改的代码。
OAuth2回调地址:
相应文档:
授权机制说明
获取access_token
postman测试
login页面修改:
<li><!--<a href="https://gitee.com/oauth/authorize?client_id=821c228b5c8a2a597419ee2ac8b98d05d387d4a9a8a9f37480d7213f24c64582&redirect_uri=http://auth.gulimall.com/oauth2.0/gitee/success&response_type=code">--><a href="https://api.weibo.com/oauth2/authorize?client_id=1579098500&response_type=code&redirect_uri=http%3A%2F%2Fauth.gulimall.com%2Foauth2.0%2Fweibo%2Fsuccess"><!--<img style="width: 50px;height: 18px" src="/static/login/JD_img/gitee.png"/>--><img style="width: 50px;height: 18px" src="/static/login/JD_img/weibo.png"/></a></li>
SocialUser
@ToString
@Data
public class SocialUser {//gitee// private String access_token;// private Long expires_in;// private String uid;//weiboprivate String access_token;private String remind_in;private long expires_in;private String uid;private String isRealName;
}
OAuth2Controller
/*** 社交登录成功回调* @param code* @return* @throws Exception*/// @GetMapping("/oauth2.0/gitee/success")@GetMapping("/oauth2.0/weibo/success")public String gitee(@RequestParam("code") String code, HttpSession session, HttpServletResponse servletResponse) throws Exception {Map<String, String> map = new HashMap<>();// gitee测试// map.put("grant_type", "authorization_code");// map.put("code", code);// map.put("client_id", "821c228b5c8a2a597419ee2ac8b98d05d387d4a9a8a9f37480d7213f24c64582");// map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/gitee/success");// map.put("client_secret", "4fba1e08692dcc06909f213d05d3e5cfa531458815dd494629a9fea92fc25ccc");//微博测试map.put("client_id", "1579098500");map.put("client_secret", "7f19b49cbd0803e6fd875b4f96412f2f");map.put("grant_type", "authorization_code");map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/weibo/success");map.put("code", code);Map<String,String> headers = new HashMap<>();Map<String,String> querys = new HashMap<>();//1、根据code 换取 access_token// HttpResponse response = HttpUtils.doPost("https://gitee.com", "/oauth/token", "post", headers, querys, map);HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", headers, querys, map);//2、处理if (response.getStatusLine().getStatusCode() == 200) {//获取到了 accessTokenString json = EntityUtils.toString(response.getEntity());SocialUser socialUser = JSON.parseObject(json, SocialUser.class);//gitee// JSONObject jsonObject = JSON.parseObject(json);// String accessToken = jsonObject.getString("access_token");// Long expiresIn = Long.valueOf(jsonObject.getString("expires_in"));// //通过 access_token 获取用户 id// Map<String, String> map1 = new HashMap<>();// map1.put("access_token", accessToken);// HttpResponse response1 = HttpUtils.doGet("https://gitee.com", "/api/v5/user", "get", new HashMap<>(), map1);// String json1 = EntityUtils.toString(response1.getEntity());// JSONObject jsonObject1 = JSON.parseObject(json1);// String id = jsonObject1.getString("id");// //将 access_token expires_in uid封装到 SocialUser// SocialUser socialUser = new SocialUser();// socialUser.setUid(id);// socialUser.setAccess_token(accessToken);// socialUser.setExpires_in(expiresIn);//知道当前是哪个社交用户//1)、当前用户如果是第一次进网站,自动注册进来(为当前社交用户生成一个会员信息账号,以后这个社交账号就对应指定的会员账号//登录或者注册这个社交用户R oauthlogin = memberFeignService.oauthlogin(socialUser);if (oauthlogin.getCode() == 0) {MemberRespVo data = oauthlogin.getData("data", new TypeReference<MemberRespVo>() {});log.info("登录成功:用户信息:{}",data.toString());//1、第一次使用session:命令浏览器保存卡号。JSESSIONID这个cookie;//以后浏览器访问哪个网站就会带上这个网站的cookie;//子域之间:gulimall.com auth.gulimall.com order.gulimall.com//发卡的时候(指定域名为父域名),即使是子域系统发的卡,也能让父域直接使用。//TODO 1、默认发的令牌。session=xxxxxxx。作用域:当前域;(解决子域session共享问题)//TODO 2、使用JSON的序列化方式来序列化对象数据到redis中session.setAttribute("loginUser",data);// new Cookie("JSESSIONID","dadaa").setDomain("");// servletResponse.addCookie();//2、登录成功就跳回首页return "redirect:http://gulimall.com";} else {return "redirect:http://auth.gulimall.com/login.html";}} else {return "redirect:http://auth.gulimall.com/login.html";}}}
MemberServiceImpl
@Overridepublic MemberEntity login(SocialUser socialUser) throws Exception {//登录和注册合并逻辑String uid = socialUser.getUid();//1、判断当前社交用户是否已经登录过系统;MemberDao memberDao = this.baseMapper;MemberEntity memberEntity = memberDao.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));if (memberEntity != null) {//这个用户已经注册MemberEntity update = new MemberEntity();update.setId(memberEntity.getId());update.setAccessToken(socialUser.getAccess_token());update.setExpiresIn(socialUser.getExpires_in());memberDao.updateById(update);memberEntity.setAccessToken(socialUser.getAccess_token());memberEntity.setExpiresIn(socialUser.getExpires_in());return memberEntity;} else {//2、没有查询到当前社交用户对应的记录我们就需要注册一个MemberEntity regiset = new MemberEntity();try {//3、查询当前社交用户的社交账户信息(昵称,性别等)Map<String, String> query = new HashMap<>();//gitee// query.put("access_token", socialUser.getAccess_token());//weiboquery.put("access_token", socialUser.getAccess_token());query.put("uid", socialUser.getUid());// HttpResponse response = HttpUtils.doGet("https://gitee.com", "/api/v5/user", "get", new HashMap<>(), query);HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<>(), query);if (response.getStatusLine().getStatusCode() == 200) {//查询成功String json = EntityUtils.toString(response.getEntity());// 这个JSON对象什么样的数据都可以直接获取JSONObject jsonObject = JSON.parseObject(json);//昵称//gitee// String name = jsonObject.getString("name");// regiset.setNickname(name);//weiboString name = jsonObject.getString("name");String gender = jsonObject.getString("gender");regiset.setGender("m".equals(gender)?1:0);regiset.setNickname(name);}} catch (Exception e) {}regiset.setSocialUid(socialUser.getUid());regiset.setAccessToken(socialUser.getAccess_token());regiset.setExpiresIn(socialUser.getExpires_in());memberDao.insert(regiset);return regiset;}}
ps:总结:使用springsession解决分布式session不共享问题
①所有登录后的状态信息都存进了session中,每个服务又都整合了springsession,将session存入到了redis中;
②session存数据的时候,会给浏览器发卡(JsessionId)-> 标识了我们存session的id是什么,并且把卡的作用域放大了:比如某一个服务发的卡,让其保存进session中后,可以全服务通用(跨父子域,父域下的所有服务都可以使用Jsessionid)。
ps:分布式登录总结
登录url:http://auth.gulimall.com/login.html
(注意是url,不是页面。)
判断session中是否有user对象
没有user对象,渲染login.html页面
用户输入账号密码后发送给 url:auth.gulimall.com/login
根据表单传过来的VO对象,远程调用memberFeignService验证密码
- 如果验证失败,取出远程调用返回的错误信息,放到新的请求域,重定向到登录url
- 如果验证成功,远程服务就返回了对应的MemberRespVo对象,
然后放到分布式redis-session中,key为"loginUser",重定向到首页gulimall.com,
同时也会带着的GULISESSIONID
- 重定向到非auth项目后,先经过拦截器看session里有没有loginUser对象
- 有,放到静态threadLocal中,这样就可以操作本地内存,无需远程调用sessio
- 没有,重定向到登录页
有user对象,代表登录过了,重定向到首页,session数据还依靠sessionID持有着
额外说明:
问题1:我们有sessionId不就可以了吗?为什么还要在session中放到User对象?
为了其他服务可以根据这个user查数据库,只有session的话不能再次找到登录session的用户问题2:threadlocal的作用?
他是为了放到当前session的线程里,threadlocal就是这个作用,随着线程创建和消亡。把threadlocal定义为static的,这样当前会话的线程中任何代码地方都可以获取到。如果只是在session中的话,一是每次还得去redis查询,二是去调用service还得传入session参数,多麻烦啊
问题3:cookie怎么回事?不是在config中定义了cookie的key和序列化器?
序列化器没什么好讲的,就是为了易读和来回转换。而cookie的key其实是无所谓的,只要两个项目里的key相同,然后访问同一个域名都带着该cookie即可。
2.5.14 单点登录
spring session已经解决不了不同域名的问题了。无法扩大域名
sso思路:
记住一个核心思想:建议一个公共的登陆点server,他登录了代表这个集团的产品就登录过了
比如,我登录过尚硅谷电商系统了,我希望登录其他谷粒系统就不用登录了,也即是:只要注册了登录某一个服务就可以自动登录其它所有服务,例如:注册登录了谷粒商城,则可以自动登录在线教育、众筹系统等
这里可以参考 码云上的 xxl的单点登录的开源框架。
1、开源项目:https://gitee.com/xuxueli0323/xxl-sso/repository/archive/master.zip
修改配置:
①配置本机域名
②修改服务器redis地址:
③修改客户端配置:
④打包,在根目录下。
mvn clean package -Dmaven.skip.test=true
⑤启动服务端和客户端。
当访问客户端1,会自动重定向到服务器认证中心,认证中心登录后,客户端2刷新之后就登录上了。
认证中心访问路径:http://ssoserver.com:8080/xxl-sso-server
客户端1访问路径:http://client1.com:8081/xxl-sso-web-sample-springboot
客户端2访问路径:http://client2.com:8082/xxl-sso-web-sample-springboot
2、下面进行实际编写代码演示:
配置域名:
idea中创建两个模块,一个客户端,一个服务器端。
客户端和服务器端的操作一样,最终效果:
①单点登录流程1:
客户端:
HelloController
@Controller
public class HelloController {@Value("${sso.server.url}")String ssoServerUrl;/*** 无需登录即可访问* @return*/@ResponseBody@GetMapping("/hello")public String hello(){return "hello";}@GetMapping("/employees")public String employees(Model model, HttpSession session){Object loginUser = session.getAttribute("loginUser");if (loginUser == null){//没登录,跳转到登录服务器进行登录//跳转过去以后,使用url上的查询参数标识我们自己是那个页面//redirect:http://client1.com:8081/employeesreturn "redirect:"+ssoServerUrl+"?redirect_url=http://client1.com:8081/employees";}else{List<String> emps = new ArrayList<>();emps.add("张三");emps.add("李四");model.addAttribute("emps",emps);return "list";}}}
list.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"><head><meta charset="UTF-8"><title>员工列表</title></head><body><h1>欢迎:[]</h1><ul><li th:each="emp:${emps}">姓名:[[${emp}]]</li></ul></body>
</html>
application.properties
server.port=8081sso.server.url=http://sso.com:8080/login.html
服务器端
LoginController
@Controller
public class LoginController {@GetMapping("/login.html")public String loginPage(@RequestParam("redirect_url")String url){return "login";}@PostMapping("/doLogin")public String doLogin(){//登录成功跳转,跳回到之前的页面return "";}}
login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"><head><meta charset="UTF-8"><title>登录页</title></head><body><form action="/doLogin" method="post">用户名:<input name="username"/><br/>密码:<input name="password" type="password"/><br/><input type="submit" value="登录"/></form></body>
</html>
application.properties
server.port=8080
演示:访问客户端直接重定向到 服务端。
②单点登录流程2:
服务器端:
加入依赖:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
配置:
spring.redis.host=192.168.56.10
login页面带一个隐藏输入框:用于存储调回的url
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"><head><meta charset="UTF-8"><title>登录页</title></head><body><form action="/doLogin" method="post">用户名:<input name="username"/><br/>密码:<input name="password" type="password"/><br/><input type="hidden" name="url" th:value="${url}"/><input type="submit" value="登录"/></form></body>
</html>
LoginController
登录成功保存用户信息并传递token
@Controller
public class LoginController {@Autowiredprivate StringRedisTemplate redisTemplate;@GetMapping("/login.html")public String loginPage(@RequestParam("redirect_url")String url, Model model){model.addAttribute("url",url);return "login";}@PostMapping("/doLogin")public String doLogin(@RequestParam("username") String username,@RequestParam("password") String password,@RequestParam("url") String url){if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)){//登录成功,跳回之前页面//把登录成功的用户保存起来//登录成功保存用户信息并传递tokenString uuid = UUID.randomUUID().toString().replace("-","");redisTemplate.opsForValue().set(uuid,username);return "redirect:"+url+"?token="+uuid;}//登录失败,展示登录页return "login";}
客户端:
拿到令牌需要去认证中心查询用户的信息,这里只是简单保存了以下并没有模拟
/*** 能够感知这次是在 ssoserver登录成功跳回来的。* @param model* @param session* @param token 只要去ssoserver登录成功跳回来就会带上* @return*/@GetMapping("/employees")public String employees(Model model, HttpSession session,@RequestParam(value = "token",required = false) String token){if (!StringUtils.isEmpty(token)){//去ssoserver登录成功跳回来就会带上//TODO 1、去ssoserver获取当前token真正对应的用户信息session.setAttribute("loginUser","zhangsan");}Object loginUser = session.getAttribute("loginUser");if (loginUser == null){//没登录,跳转到登录服务器进行登录//跳转过去以后,使用url上的查询参数标识我们自己是那个页面//redirect:http://client1.com:8081/employeesreturn "redirect:"+ssoServerUrl+"?redirect_url=http://client1.com:8081/employees";}else{List<String> emps = new ArrayList<>();emps.add("张三");emps.add("李四");model.addAttribute("emps",emps);return "list";}}
测试:客户端1加上了token
③单点登录流程3:
这里流程没有截取完全。
复制客户端,改为客户端2
添加进项目
服务器端:
实现一次登录,处处登录的核心就是认证通过之后给浏览器留下一个痕迹,凡是访ssoserver.com这个域名的都会带上这个痕迹,通过使用cookie实现
详解:
- 子系统都先去 login.html这个请求,
- 这个请求会告诉登录过的系统的令牌,
- 如果没登录过就带着url重新去server端,server给一个登录页,如下
当点击登录之后,server端返回一个cookie,子系统重新返回去重新请去业务。于是又来server端验证,这回server端有cookie了,该cookie里有用户在redis中的key,重定向时把key带到url后面,子系统就知道怎么找用户信息了
@Controller
public class LoginController {@Autowiredprivate StringRedisTemplate redisTemplate;//登录之后保存用户token@ResponseBody@GetMapping("/userInfo")public String userInfo(@RequestParam("token") String token){String s = redisTemplate.opsForValue().get(token);return s;}@GetMapping("/login.html")public String loginPage(@RequestParam("redirect_url")String url, Model model,@CookieValue(value = "sso_token",required = false) String sso_token){if (!StringUtils.isEmpty(sso_token)){//说明之前有人登录过,浏览器留下了痕迹return "redirect:"+url+"?token="+sso_token;}model.addAttribute("url",url);return "login";}@PostMapping("/doLogin")public String doLogin(@RequestParam("username") String username,@RequestParam("password") String password,@RequestParam("url") String url,HttpServletResponse response){if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)){//登录成功,跳回之前页面//把登录成功的用户保存起来// 登录成功保存用户信息并传递tokenString uuid = UUID.randomUUID().toString().replace("-","");redisTemplate.opsForValue().set(uuid,username);Cookie sso_token = new Cookie("sso_token", uuid);response.addCookie(sso_token);return "redirect:"+url+"?token="+uuid;}//登录失败,展示登录页return "login";}}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"><head><meta charset="UTF-8"><title>登录页</title></head><body><form action="/doLogin" method="post">用户名:<input name="username"/><br/>密码:<input name="password" type="password"/><br/><input type="hidden" name="url" th:value="${url}"/><input type="submit" value="登录"/></form></body>
</html>
客户端:
sso解决
client1.com 8081 和 client2.com 8082 都跳转到ssoserver 8080
- 给登录服务器留下痕迹
- 登录服务器要将token信息重定向的时候,带到url地址上
- 其他系统要处理url地址上的token,只要有,将token对应的用户保存到自己的session
- 自己系统将用户保存在自己的session中
@Controller
public class HelloController {@Value("${sso.server.url}")String ssoServerUrl;/*** 无需登录即可访问* @return*/@ResponseBody@GetMapping("/hello")public String hello(){return "hello";}/*** 能够感知这次是在 ssoserver登录成功跳回来的。* @param model* @param session* @param token 只要去ssoserver登录成功跳回来就会带上* @return*/@GetMapping("/employees")public String employees(Model model, HttpSession session,@RequestParam(value = "token",required = false) String token){if (!StringUtils.isEmpty(token)){//去ssoserver登录成功跳回来就会带上//TODO 1、去ssoserver获取当前token真正对应的用户信息RestTemplate restTemplate = new RestTemplate();ResponseEntity<String> forEntity = restTemplate.getForEntity("http://sso.com:8080/userInfo?token=" + token, String.class);String body = forEntity.getBody();session.setAttribute("loginUser",body);}Object loginUser = session.getAttribute("loginUser");if (loginUser == null){//没登录,跳转到登录服务器进行登录//跳转过去以后,使用url上的查询参数标识我们自己是那个页面//redirect:http://client1.com:8081/employeesreturn "redirect:"+ssoServerUrl+"?redirect_url=http://client1.com:8081/employees";}else{List<String> emps = new ArrayList<>();emps.add("张三");emps.add("李四");model.addAttribute("emps",emps);return "list";}}}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"><head><meta charset="UTF-8"><title>员工列表</title></head><body><h1>欢迎:[[${session.loginUser}]]</h1><ul><li th:each="emp:${emps}">姓名:[[${emp}]]</li></ul></body>
</html>
测试:
当客户端1在服务器登录之后,服务器就设置一个sso_token
客户端1正常显示之前访问的页面
客户端2无需登录,即可直接登录页面。
最终流程总结:
- 发送8081/employees请求,判断没登录就跳转到server.com:8080/login.html登录页,并带上现url
- server登录页的时候,有之前带过来的url信息,发送登录请求的时候也把url继续带着
- doLogin登录成功后返回一个token(保存到server域名下)然后重定向
- 登录完后重定向到带的url参数的地址。
- 跳转回业务层的时候,业务层要能感知是登录过的,调回去的时候带个uuid,用uuid去redis里(课上说的是去server里再访问一遍,为了安全性?)看user信息,保存到它系统里自己的session
- 以后无论哪个系统访问,如果session里没有指定的内容的话,就去server登录,登录过的话已经有了server的cookie,所以不用再登录了。回来的时候就告诉了子系统应该去redis里怎么查你的用户内容
ps: 可以借鉴:单点登录(SSO)看这一篇就够了
上图是CAS官网上的标准流程,具体流程如下:有两个子系统app1、app2
用户访问app1系统,app1系统是需要登录的,但用户现在没有登录。
跳转到CAS server,即SSO登录系统,以后图中的CAS Server我们统一叫做SSO系统。 SSO系统也没有登录,弹出用户登录页。
用户填写用户名、密码,SSO系统进行认证后,将登录状态写入SSO的session,浏览器(Browser)中写入SSO域下的Cookie。
SSO系统登录完成后会生成一个ST(Service Ticket),然后跳转到app1系统,同时将ST作为参数传递给app1系统。
app1系统拿到ST后,从后台向SSO发送请求,验证ST是否有效。
验证通过后,app系统将登录状态写入session并设置app域下的Cookie。
至此,跨域单点登录就完成了。以后我们再访问app系统时,app就是登录的。接下来,我们再看看访问app2系统时的流程。
用户访问app2系统,app2系统没有登录,跳转到SSO。
由于SSO已经登录了,不需要重新登录认证。
SSO生成ST,浏览器跳转到app2系统,并将ST作为参数传递给app2。
app2拿到ST,后台访问SSO,验证ST是否有效。
验证成功后,app2将登录状态写入session,并在app2域下写入Cookie。
这样,app2系统不需要走登录流程,就已经是登录了。SSO,app和app2在不同的域,它们之间的session不共享也是没问题的。
SSO系统登录后,跳回原业务系统时,带了个参数ST,业务系统还要拿ST再次访问SSO进行验证,觉得这个步骤有点多余。如果想SSO登录认证通过后,通过回调地址将用户信息返回给原业务系统,原业务系统直接设置登录状态,这样流程简单,也完成了登录,不是很好吗?
其实这样问题时很严重的,如果我在SSO没有登录,而是直接在浏览器中敲入回调的地址,并带上伪造的用户信息,是不是业务系统也认为登录了呢?这是很可怕的。