谷粒商城之高级篇

谷粒商城之高级篇

目录

  • 谷粒商城之高级篇
  • 前言
  • 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:1
    spuId:11
    skyTitile:华为xx
    price:999
    saleCount:99
    attr:[
        {尺寸:5},
        {CPU:高通945},
        {分辨率:全高清}
	]
}
缺点:如果每个sku都存储规格参数(如尺寸),会有冗余存储,因为每个spu对应的sku的规格参数都一样
冗余:
举例:100*20=2000MB=2G

方案2:分布式   
sku索引
{
    spuId:1
    skuId:11
    xxx
}
attr索引
{
    skuId:11
    attr:[
        {尺寸:5},
        {CPU:高通945},
        {分辨率:全高清}
	]
}

举例:
先找到4000个符合要求的spu,再根据4000个spu查询对应的属性,封装了4000个id,long 8B*4000=32000B=32KB
1K个人检索,就是32MB

结论:如果将规格参数单独建立索引,会出现检索时出现大量数据传输的问题,会引起网络拥堵
因此选用方案1,以空间换时间

谷粒商城之高级篇_第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},这个组合是不存在的

数组的扁平化处理会使检索能检索到本身不存在的,为了解决这个问题,就采用了嵌入式属性,数组里是对象时用嵌入式属性(不是对象无需用嵌入式属性)

1669204852792

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;


    @Data
    public 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,stock
        List<SkuHasStockVo> vos =  wareSkuService.getSkuHasStock(skuIds);

        return R.ok().setData(vos);

    }

WareSkuServiceImpl

  @Override
    public 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 = 1
            Long 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");//默认是map
		String 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中

谷粒商城之高级篇_第2张图片

ElasticSaveController

@Slf4j
@RequestMapping("/search/save")
@RestController
public class ElasticSaveController {

    @Autowired
    ProductSaveService productSaveService;

    //上架商品
    // 添加@RequestBody 将 请求体中的 List 集合转换为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 {

    @Autowired
    RestHighLevelClient restHighLevelClient;

    @Override
    public 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 options
        BulkResponse 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

 @Override
    public 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 -> { //返回所有属性的id
            return 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,skuImg

            esModel.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、热度评分。0
            esModel.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-search
        R 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;
             *          }
             *    }
             *
             */

        }

    }

谷粒商城之高级篇_第3张图片

Feign

谷粒商城之高级篇_第4张图片

这里再次 将 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 快捷键:

  1. ctrl + e 可以快速调出最近使用的(打开最近修改的文件)

谷粒商城之高级篇_第5张图片

  1. 快速从 controller 跳转 到 实现类

    ctrl + shift + 鼠标左键

  2. 从 controller 跳转到 接口

    ctrl + 鼠标左键

  3. 生成 try-catch等(surround with)

    alt + shift +z

  4. 生成构造器/get/set/toString

    alt + shift + s

7)效果展示

商品成功上架,显示状态 为 已上架

1669277105482

2.2 商城系统首页

谷粒商城之高级篇_第6张图片

不使用前后端分离开发了,管理后台用vue
nginx发给网关集群,网关再路由到微服务

静态资源放到nginx中

2.2.1 渲染首页

  1. 依赖
    导入thymeleaf依赖
  
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-thymeleafartifactId>
        dependency>
  1. html\首页资源\index 放到 gulimall-product 下的static文件夹

​ index.html 放到 templates中

谷粒商城之高级篇_第7张图片

  1. 关闭thymeleaf缓存,方便开发实时看到更新
thymeleaf:
    cache: false
  1. web开发放到web包下,原来的controller是前后分离对接手机等访问的,所以可
    以改成app,对接app应用。

web 包:存放专门进行页面跳转的controller

rest 接口对接的使我们分离的项目(比如手机的一些 app ):将controller 改名为 app

  1. 效果展示:访问首页

2.2.2 渲染一级分类数据

编写 处理首页的controller

gulimall-product的 web 包下新建 IndexController

@Controller
public class IndexController {


    @Autowired
    CategoryService 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/ + 返回值 +   .html
        return "index";
    }

}

编写 获取 1级分类的实现

CategoryServiceImpl

	 /**
     *  查找 1级分类
     *  parent_cid = 0  或者  cat_level = 1
     * @return
     */
    @Override
    public List<CategoryEntity> getLevel1Categorys() {

        List<CategoryEntity> categoryEntities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
        return categoryEntities;

    }

引入 热部署依赖devtools使页面实时生效

     <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-devtoolsartifactId>
            <optional>trueoptional>
        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/

  1. ${}:动态取值
th:text="${category.name}"
  1. th:each:遍历
<tr th:each="prod : ${prods}">

prod : 当前元素

${prods}:要遍历的对象

th:each="category : ${categorys}"
  1. 自定义属性:我们需要获得 分类的 id
<input type="submit" value="Subscribe!" th:attr="value=#{subscribe.submit}"/>

value:属性名叫什么

#{subscribe.submit}:属性值叫什么

 th:attr="ctg-data=${category.catId}"

原生属性:

th:value="#{subscribe.submit}"

效果展示:

谷粒商城之高级篇_第8张图片

2.2.3 渲染二级三级分类数据

当 鼠标滑到 1级分类时,展示 它的二级分类数据及三级分类数据。

利用 catalogLoader.js来获取请求,解析展示数据。

谷粒商城之高级篇_第9张图片

按照 此json 数据方式

谷粒商城之高级篇_第10张图片

新建 Catelog2Vo封装 数据

/**
 * 2级分类 vo
 *
 * @author wystart
 * @create 2022-11-24 21:53
 */
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Catelog2Vo {
    private String catalog1Id;//1级分类id
    private List<Catelog3Vo> catalog3List; //三级子分类
    private String id;
    private String name;


    /**
     * 三级分类 vo
     * "catalog2Id":"61",
     * "id":"610",
     * "name":"商务休闲鞋"
     */
    @NoArgsConstructor
    @AllArgsConstructor
    @Data
    public static class Catelog3Vo {
        private String catalog2Id; //父分类,2级分类 id
        private 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

    @Override
    public 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.找到当前二级分类的三级分类,封装成 vo
                    List<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 数据

谷粒商城之高级篇_第11张图片

首页展示效果:http://localhost:10000

谷粒商城之高级篇_第12张图片

模板引擎总结

* 5.模板引擎
* 1)、thymeleaf-starter: 关闭缓存
* 2)、静态资源都放在static 文件夹下就可以按照路径直接访问
* 3)、页面放在 templates下,直接访问
*   SpringBoot,访问项目的时候,默认会找 index
* 4)、页面修改不重启服务器实时更新
*   1)、引入 dev-tools
*   2)、修改完页面 ctrl + shift + f9  或者  ctrl + f9,重新自动编译下页面(注意:如果代码配置等修改,建议重启)
*

2.2.4 nginx 搭建域名访问环境

谷粒商城之高级篇_第13张图片

我们利用反向代理:让 Nginx 配合网关 搭建我们的访问环境,将我们的各个微服务放在内网中,避免端口直接暴露带来的危险。

利用 SwitchHosts软件可以快速修改hosts文件,注意需要以管理员身份运行

谷粒商城之高级篇_第14张图片

原理:

谷粒商城之高级篇_第15张图片

查看本机localhost对应的IP地址:

ipconfig:查看本机IP:ipconfig:

192.168.1.103 (windows的localhost地址)

192.168.56.1(linux虚拟机的localhost地址)

两者都可以,都算本机

谷粒商城之高级篇_第16张图片

Nginx的配置文件详解:

谷粒商城之高级篇_第17张图片

谷粒商城之高级篇_第18张图片

可以加载 外部配置文件的配置,这样可以避免Nginx的配置文件过大。(总配置文件)

  • Nginx 反向代理配置

接下来我们配置 server块:

先复制一份,留作备份:

谷粒商城之高级篇_第19张图片

修改配置文件:

谷粒商城之高级篇_第20张图片

proxy_pass:代理通过:相当于代理给谁(转交给谁)

gulimall下的所有请求都代理给 192.168.56.1下的10000端口。

谷粒商城之高级篇_第21张图片

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 块内:

      谷粒商城之高级篇_第22张图片

      在server 块内:

      修改 server配置:gulimall.conf:相当于 是 负载均衡的配置,直接路由到上游服务器网关,由网关进行转发

      1669341538959

      效果就是:访问 gulimall.com ,代理 给 Nginx ,Nginx 转交 给网关 ,网关再转给商品服务。

    • 网关配置:

              - id: gulimall_host_route
                uri: lb://gulimall-product
                predicates:
                  - Host=**.gulimall.com,gulimall.com # 只要是 gulimall下的所有请求都转给  gulimall-product
      

      注意这个配置 一定要放在 最后:因为如果放在前面 ,它会禁用下面其他的网关配置:比如,http://gulimall.com//product/attrattrgrouprelation/list 这个api 接口访问,它会首先到 gulimall.com,然后因为没有进行 截串 设置(截取 /api前缀),出现 404 访问不到。

  • 测试效果

    这里出现 404 问题:原因:Nginx 转发给网关的时候,会丢失很多请求头信息,这里就缺失了 host 地址,这里我们暂时只配置 上 host 地址,以后缺啥补啥。

谷粒商城之高级篇_第23张图片

重启测试:

直接访问域名成功:gulimall.com

访问接口 也成功。http://gulimall.com//product/attrattrgrouprelation/list

谷粒商城之高级篇_第24张图片

最后总结:

最终原理:

  1. 首先浏览器访问 gulimall.com
    因为我们在Windows配置了host映射:gulimall.com 映射IP 192.168.56.10(虚拟机Ip)
    所以会直接来到虚拟机

  2. 又因为 浏览器访问 默认不带端口,那就是访问80端口,所以会来到 Nginx,我们又配置 了 80端口监听 gulimall.com 这个域名;此外由于 **location/**下的配置:代理转发:

    Nginx 又代理给网关,这里注意一个细节:由于Nginx 转发会丢失 一些请求头信息,所以我们要加上请求头的配置,这里暂时只配置 host地址,之后的其他请求头配置我们用到的时候在进行添加;

  3. 网关发现 域名 是gulimall.com,进而就会找到 对应的配置:路由到商品服务,进而就转给了商品服务,这处网关配置一定要放在最后面,避免放在前面禁用后面的其他截串配置。

  • 域名映射效果:
    • 请求接口 gulimall.com
    • 请求页面 gulimall.com
    • nginx 直接代理给网关,网关判断
      • 如果是/api/***,转交给对应的服务器
      • 如果是满足域名,转交给对应的服务

重要!!!!

关于 第3章 性能与压力测试 和 第4章 缓存与分布式锁单独写在另外一篇文档:谷粒商城之高级篇知识补充。


2.3 检索业务

2.3.1 页面环境搭建

①秉承动静分离的原则,我们将 静态资源放到 Nginx下:

在Nginx新建一个 文件夹search,用来存放相关静态资源。

谷粒商城之高级篇_第25张图片

②修改 index页面下的静态资源前缀

静态资源

1669625610230

1669625695767

加上 thymeleaf 的名称空间

1669627210900

③域名映射

谷粒商城之高级篇_第26张图片

④ *.gulimall.com 表示所有请求Nginx都处理,最后的结果就是Nginx转发给 网关

谷粒商城之高级篇_第27张图片

最终的转发效果就是:

谷粒商城之高级篇_第28张图片

⑤网关配置

        - id: gulimall_host_route
          uri: lb://gulimall-product
          predicates:
            - Host=gulimall.com #这里和之前的相比有修改

        - id: gulimall_search_route
          uri: lb://gulimall-search
          predicates:
            - Host=search.gulimall.com

⑥访问 http://search.gulimall.com/

谷粒商城之高级篇_第29张图片

2.3.2 调整页面跳转

为了以后开发方便,我们加上 热部署依赖

        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-devtoolsartifactId>
        dependency>

关闭thymeleaf 的缓存

spring.thymeleaf.cache=false

我们可以通过检索页面的以下两个地方跳转会商城首页

一个是超链接,一个是图标。

谷粒商城之高级篇_第30张图片

首先是超链接修改:

search服务里面的index页面,改为 http://gulimall.com

1669638937119

接着是图标处:改为 http://gulimall.com

1669639060928

修改域名映射:让gulimall.com和带其子域名的都转发给网关。

1669628933564

测试:成功跳转回首页。

接下来,我们在首页上可以有这么两个地方,可以跳转到我们的检索页面:

①关键字搜索:搜索按钮1669639381807

②点击分类,跳转到检索页面。谷粒商城之高级篇_第31张图片

③修改配置:

  1. 通过分类点击到检索页面

    • 将 检索页面 重命名为 list.html

      谷粒商城之高级篇_第32张图片

    • 创建SearchController

    @Controller
    public class SearchController {
    
    
        @GetMapping("/list.html")
        public String listPage() {
    
            return "list";
        }
    
    }
    
    
    • 避坑:

      如果点击 分类跳转 到 检索页面,报错,然后控制台域名是:search.gmall.com开头,那么我们需要去

      Nginx 下的 html/static/index/js,在 catelogLoader中搜索gmall,替换为 gulimall

谷粒商城之高级篇_第33张图片

  1. 通过首页的搜索图标跳转到检索页面

    修改 gulimall-product下的index.html页面:

    搜索 search:

    search方法应该是这样:之前修改前缀的时候多加了/static,所以一直访问不到,下面这个是正确的。

    谷粒商城之高级篇_第34张图片

    另外图标处修改为:

谷粒商城之高级篇_第35张图片

ps: 注意一定要把product商品服务中的application.yaml配置文件中 thymeleaf 的页面缓存设置为false,之前测试缓存的时候给设为 开启了,开发中我们关闭。

1669640254206

  1. 测试,都成功跳转到检索页面。

ps:测试的时候,注意浏览器缓存问题,不然有时候测试不成功。

2.3.3 检索返回结果模型分析抽取

1、检索业务分析
商品检索三个入口:
1)、选择分类进入商品检索

谷粒商城之高级篇_第36张图片

2)、输入检索关键字展示检索页谷粒商城之高级篇_第37张图片

3)、选择筛选条件进入

谷粒商城之高级篇_第38张图片

检索条件&排序条件

  • 全文检索: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 {


    @Autowired
    MallSearchService 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;//当前查询到的结果,所有涉及到的所有属性

    //============================以上是返回给页面的所有信息============================

    @Data
    public static class BrandVo{
        private Long brandId;

        private String brandName;

        private String brandImg;

    }

    @Data
    public static class CatalogVo{
        private Long catalogId;

        private String catalogName;

    }

    @Data
    public 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 {
    @Override
    public 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": "",
    "post_tags": ""
  }
}

整个查询条件:模糊匹配,过滤(按照属性,分类,品牌,价格区间,库存),排序,分页,高亮,聚合分析。

  • 接下来就是聚合分析部分。

这里我们希望可以通过品牌属性等也可以检索到商品。

所以加上 品牌属性等检索条件。

报错:

1669691892973

修改映射,让他们都可以进行聚合分析。

谷粒商城之高级篇_第39张图片

创建新的映射

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 代码

1669691730027

最终聚合分析语句:

 "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": "",
    "post_tags": ""
  },
  "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  - query
        BoolQueryBuilder 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:8G
                BoolQueryBuilder nestedboolQuery = QueryBuilders.boolQuery();
                //attrs=1_5寸:6寸
                String[] s = attrStr.split("_");
                String attrId = s[0];//检索属性的id
                String[] 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/desc
            String[] 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)*size
        sourceBuilder.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("");
            builder.postTags("");
            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、聚合 brand
        sourceBuilder.aggregation(brand_agg);
        //2.分类聚合  catalog_agg
        TermsAggregationBuilder catalog_agg = AggregationBuilders.terms("catalog_agg").field("catalogId").size(20);
        catalog_agg.subAggregation(AggregationBuilders.terms("catalog_name_agg").field("catalogName").size(1));
        // TODO 2、聚合 catalog
        sourceBuilder.aggregation(catalog_agg);
        //3.属性聚合 attr_agg
        NestedAggregationBuilder attr_agg = AggregationBuilders.nested("attr_agg", "attrs");

        //聚合出当前所有的attrId
        TermsAggregationBuilder 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对应的所有可能的属性值attrValue
        attr_id_agg.subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(50));
        attr_agg.subAggregation(attr_id_agg);
        // TODO 3、聚合 attr
        sourceBuilder.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、得到属性的id
            long 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、得到品牌的id
            long 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();
            //得到分类id
            String 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 ..1
        int 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进行测试。

谷粒商城之高级篇_第40张图片

测试过后,以上代码暂时没有错误。

4、总的检索方法:SearchResult

//去 es进行检索
    @Override
    public 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页面。

①页面商品展示:

谷粒商城之高级篇_第41张图片

谷粒商城之高级篇_第42张图片

1669732226462

谷粒商城之高级篇_第43张图片

1669732382863

谷粒商城之高级篇_第44张图片

1669733273920

th:utext:不转义,可以让我们搜索的关键字高亮显示。

测试:

谷粒商城之高级篇_第45张图片

②品牌、分类等显示。

谷粒商城之高级篇_第46张图片

效果:

1669733795992

谷粒商城之高级篇_第47张图片

效果:

1669734251149

显示全部:

修改代码:SearchParam

private Integer hasStock;//是否只显示有货

MallSearchServiceImpl -> buildSearchRequest

        //1.2、bool -  filter  - 按照库存是否有进行查询
        if (param.getHasStock() != null){
            boolQuery.filter(QueryBuilders.termQuery("hasStock", param.getHasStock() == 1));
        }

效果:

谷粒商城之高级篇_第48张图片

加粗:

1669734596736

接下来,我们把分类一栏也显示出来。

<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>

最终效果:

1669735609704

谷粒商城之高级篇_第49张图片

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;
                }
            }

品牌:

谷粒商城之高级篇_第50张图片

th:href="${'javascript:searchProducts("brandId",'+brand.brandId+')'}"

测试:

谷粒商城之高级篇_第51张图片

分类:

1669737909309

 th:href="${'javascript:searchProducts("catalog3Id",'+catalog.catalogId+')'}"

其他属性:

谷粒商城之高级篇_第52张图片

th:href="${'javascript:searchProducts("attrs","'+attr.attrId+'_'+val+'")'}"

2.3.8 页面分页数据渲染

①修改搜索导航:搜索的时候地址栏加上关键字。

谷粒商城之高级篇_第53张图片

1669787179361

搜索关键字:华为,地址栏加上华为。

1669787198657

回显搜索过的关键字:

1669790455925

谷粒商城之高级篇_第54张图片

        <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条。

谷粒商城之高级篇_第55张图片

页面修改:

谷粒商城之高级篇_第56张图片

                            <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">
                                    下一页 >
                                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;
            };

代码修改:

谷粒商城之高级篇_第57张图片

谷粒商城之高级篇_第58张图片

 /**
     * 构建结果数据
     *
     * @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);

效果展示:

1669790911567

1669790957966

还有一些分页效果在这里就暂时不做了。

谷粒商城之高级篇_第59张图片

1669791783574

2.3.9 页面排序功能

谷粒商城之高级篇_第60张图片

 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/desc
                var 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);
                }
            }

效果:有上下箭头

1669794825490

按价格排序

谷粒商城之高级篇_第61张图片

2.3.10 页面排序字段回显

谷粒商城之高级篇_第62张图片

 <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>

这里很容易写错!!!!出错的话,可以参考别人的课件:

1669798600150

效果:

1669798646828

2.3.11 页面价格区间搜索&&仅显示有货

  • 页面价格区间搜索

谷粒商城之高级篇_第63张图片

                            <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);
            });

效果:

谷粒商城之高级篇_第64张图片

  • 仅显示有货

1669811235260

                                 <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,'');
                }
            });

效果展示:

谷粒商城之高级篇_第65张图片

bug解决:之前搜索过的关键词在URL地址栏不会被替换,而是一直叠加。

function searchProducts(name, value) {
                //原来的页面
             location.href = replaceAndAddParamVal(location.href,name,value);
            }

谷粒商城之高级篇_第66张图片

2.3.12 面包屑导航

修改 后台代码。

search微服务引入依赖:

        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-openfeignartifactId>
        dependency>

让spring-cloud版本一致:

    <properties>
        <java.version>1.8java.version>
        <elasticsearch.version>7.4.2elasticsearch.version>
        <spring-cloud.version>Greenwich.SR3spring-cloud.version>
    properties>

引入依赖管理:

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloudgroupId>
                <artifactId>spring-cloud-dependenciesartifactId>
                <version>${spring-cloud.version}version>
                <type>pomtype>
                <scope>importscope>
            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;

    @Data
    public 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

  @Autowired
    ProductFeignService 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页面修改

1669817051401

<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> xa>
                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;
                }
            };

测试:

地址加上了属性。

谷粒商城之高级篇_第67张图片

点 “ x” 地址栏消失属性。

谷粒商城之高级篇_第68张图片

  • 条件筛选联动

商品服务的 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

    @Override
    public 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]")
    @Override
    public 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

1669821822199

@Data
public class SearchResult {
    
	//面包屑导航数据
    private List<NavVo> navs = new ArrayList<>();
    private List<Long> attrIds = new ArrayList<>();
} 

MallSearchServiceImpl

因为我们经常使用编码的方法,所以提取成一个公共方法。

谷粒商城之高级篇_第69张图片

略做修改

    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;
    }

上一节的属性面包屑导航增加和修改一些代码:

谷粒商城之高级篇_第70张图片


        //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

1669821670237

                    <div class="JD_nav_logo" th:with="brandid= ${param.brandId}">
                        
                        <div th:if="${#strings.isEmpty(brandid)}" class="JD_nav_wrap">

1669821704171


                        <div class="JD_pre" th:each="attr:${result.attrs}" th:if="${!#lists.contains(result.attrIds,attr.attrId)}">

测试

谷粒商城之高级篇_第71张图片


重要!!!!

关于 第5章 异步和线程池单独写在另外一篇文档:谷粒商城之高级篇知识补充。


2.4 商品详情

详情数据:

谷粒商城之高级篇_第72张图片

2.4.1 环境搭建

  1. 域名跳转设置

host配置

谷粒商城之高级篇_第73张图片

Nginx配置

之前已经配置了。

谷粒商城之高级篇_第74张图片

网关配置

谷粒商城之高级篇_第75张图片

  1. 动静资源设置

谷粒商城之高级篇_第76张图片

遵循动静分离的配置,我们将详情页的静态资源上传到Nginx下。

谷粒商城之高级篇_第77张图片

这是因为我们设置了对应的配置:

谷粒商城之高级篇_第78张图片

将详情页的页面复制到 商品服务下。

谷粒商城之高级篇_第79张图片

改名为 item.html

将页面中的相应静态资源加上对应前缀。

1669898527632

1669898555755

  1. 实现点击商品图片等可以跳转到 商品详情页。

修改 search服务下的 list.html页面

谷粒商城之高级篇_第80张图片

<a th:href="|http://item.gulimall.com/${product.skuId}.html|">

谷粒商城之高级篇_第81张图片

2.4.2 模型抽取

商品服务下新建 SkuItemVo(最终结果,以下均是)

@Data
public class SkuItemVo {


    //1、sku基本信息获取  pms_sku_info
    SkuInfoEntity info;

    //2、sku的图片信息  pms_sku_images
    List<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 {


    @Autowired
    SkuInfoService 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

 	@Autowired
    SkuImagesService imagesService;
    @Autowired
    SpuInfoDescService spuInfoDescService;
    @Autowired
    AttrGroupService attrGroupService;

	@Override
    public SkuItemVo item(Long skuId) {

        SkuItemVo skuItemVo = new SkuItemVo();

        //1、sku基本信息获取  pms_sku_info

        SkuInfoEntity info = getById(skuId);
        skuItemVo.setInfo(info);

        Long catalogId = info.getCatalogId();
        Long spuId = info.getSpuId();
        //2、sku的图片信息  pms_sku_images
        List<SkuImagesEntity> images = imagesService.getImagesBySkuId(skuId);
        skuItemVo.setImages(images);

        //3、获取的spu的销售属性组合。


        //4、获取spu的介绍 pms_spu_info_desc

        SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(spuId);
        skuItemVo.setDesp(spuInfoDescEntity);


        //5、获取spu的规格参数信息。
        List<SpuItemAttrGroupVo> attrGroupVos =  attrGroupService.getAttrGroupWithAttrsBySpuId(spuId,catalogId);
        skuItemVo.setGroupAttrs(attrGroupVos);


        return null;


    }

SkuImagesServiceImpl

  @Override
    public List<SkuImagesEntity> getImagesBySkuId(Long skuId) {

        SkuImagesDao imagesDao = this.baseMapper;
        List<SkuImagesEntity> imagesEntities = imagesDao.selectList(new QueryWrapper<SkuImagesEntity>().eq("sku_id", skuId));

        return imagesEntities;
    }

AttrGroupServiceImpl

  @Override
    public 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

 
    <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">
        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
    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

1669905646810

GulimallProductApplicationTests 测试

 @Autowired
    AttrGroupDao attrGroupDao;

    @Test
    public void test() {

        List<SpuItemAttrGroupVo> attrGroupWithAttrsBySpuId = attrGroupDao.getAttrGroupWithAttrsBySpuId(6L, 225L);
        System.out.println(attrGroupWithAttrsBySpuId);
    }

1669905673080

2.4.4 销售属性组合代码实现

SkuInfoServiceImpl(最终代码)

	@Autowired
    SkuImagesService imagesService;
    @Autowired
    SpuInfoDescService spuInfoDescService;
    @Autowired
    AttrGroupService attrGroupService;

    @Autowired
    SkuSaleAttrValueService skuSaleAttrValueService;

	@Override
    public SkuItemVo item(Long skuId) {

        SkuItemVo skuItemVo = new SkuItemVo();

        //1、sku基本信息获取  pms_sku_info
        SkuInfoEntity info = getById(skuId);
        skuItemVo.setInfo(info);

        Long catalogId = info.getCatalogId();
        Long spuId = info.getSpuId();
        //2、sku的图片信息  pms_sku_images
        List<SkuImagesEntity> images = imagesService.getImagesBySkuId(skuId);
        skuItemVo.setImages(images);

        //3、获取的spu的销售属性组合。
        List<SkuItemSaleAttrVo> saleAttrVos =  skuSaleAttrValueService.getSaleAttrsBySpuId(spuId);
        skuItemVo.setSaleAttr(saleAttrVos);


        //4、获取spu的介绍 pms_spu_info_desc
        SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(spuId);
        skuItemVo.setDesp(spuInfoDescEntity);


        //5、获取spu的规格参数信息。
        List<SpuItemAttrGroupVo> attrGroupVos =  attrGroupService.getAttrGroupWithAttrsBySpuId(spuId,catalogId);
        skuItemVo.setGroupAttrs(attrGroupVos);


        return skuItemVo;


    }

SkuSaleAttrValueServiceImpl

  @Override
    public 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">
        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`
    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`

1669908003591

GulimallProductApplicationTests 测试:

 @Autowired
    SkuSaleAttrValueDao skuSaleAttrValueDao;

    @Test
    public void test() {
        List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueDao.getSaleAttrsBySpuId(7L);
        System.out.println(saleAttrVos);
    }

1669908066622

2.4.5 详情页渲染

1669952027231

<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>

1669952190363

						<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>

谷粒商城之高级篇_第82张图片

	<span th:text="${#numbers.formatDecimal(item.info.price,3,2)}">4499.00span>

1669955432132

<li th:each="img : ${item.images}" th:if="${!#strings.isEmpty(img.imgUrl)}"><img th:src="${img.imgUrl}" />li>

谷粒商城之高级篇_第83张图片

<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}]]
											
										a>
									dd>
								dl>
							div>

1669956284354

<img class="xiaoguo" th:src="${descp}" th:each="descp:${#strings.listSplit(item.desp.decript,',')}"/>

谷粒商城之高级篇_第84张图片

谷粒商城之高级篇_第85张图片

								<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>

效果:

谷粒商城之高级篇_第86张图片

2.4.6 销售属性渲染

修改后台代码

谷粒商城之高级篇_第87张图片

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">
        SELECT
            ssav.`attr_id` attr_id,
            ssav.`attr_name` attr_name,
            ssav.`attr_value`,
            GROUP_CONCAT(DISTINCT info.`sku_id`) sku_ids
        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`,ssav.`attr_value`
    select>

sql代码:

        SELECT
            ssav.`attr_id` attr_id,
            ssav.`attr_name` attr_name,
            ssav.`attr_value`,
            GROUP_CONCAT(DISTINCT info.`sku_id`) sku_ids
        FROM `pms_sku_info` info
        LEFT JOIN `pms_sku_sale_attr_value` ssav ON ssav.`sku_id` = info.`sku_id`
        WHERE info.`spu_id`=7
        GROUP BY ssav.`attr_id`,ssav.`attr_name`,ssav.`attr_value`

谷粒商城之高级篇_第88张图片

item.html

谷粒商城之高级篇_第89张图片

<div class="box-attr clear" th:each="attr:${item.saleAttr}">
                                <dl>
                                    
                                    <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}]]
                                            
                                        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"});
		})

效果:

谷粒商城之高级篇_第90张图片

实现点击 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、取出他们的交集,得到skuId
            var 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";
        })

效果:点击颜色和版本都可以自动切换。

谷粒商城之高级篇_第91张图片

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 {


    @Bean
    public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool){

        return new ThreadPoolExecutor(pool.getCoreSize(),
                pool.getMaxSize(),pool.getKeepAliveTime(),
                TimeUnit.SECONDS,new LinkedBlockingDeque<>(100000),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy());
    }

}

⑤ SkuInfoServiceImpl


    @Override
    public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {

        SkuItemVo skuItemVo = new SkuItemVo();
		// 第一步获得的数据,第3步、4步、5步也要使用
        CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {

            //1、sku基本信息获取  pms_sku_info
            SkuInfoEntity 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_desc
            SpuInfoDescEntity 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_images
            List<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";
    }

⑦测试:一切正常。

谷粒商城之高级篇_第92张图片

2.5 认证服务

2.5.1 环境搭建

①创建 gulimall-auth-server服务

谷粒商城之高级篇_第93张图片

谷粒商城之高级篇_第94张图片

②引入依赖

<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 版本依然爆红,处理结果:清除缓存:

谷粒商城之高级篇_第95张图片

③ 添加域名

谷粒商城之高级篇_第96张图片

④动静分离。

将登录页面(改名为login.html)和认证页面(改名为 reg.html)放到认证服务下。

谷粒商城之高级篇_第97张图片

谷粒商城之高级篇_第98张图片

谷粒商城之高级篇_第99张图片

修改页面静态资源的前缀。

login.html

1669993771455

1669993808463

reg.html

1669993841959

⑤将认证服务注册进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 //开启服务注册发现功能

谷粒商城之高级篇_第100张图片

⑥配置网关

        - id: gulimall_auth_route
          uri: lb://gulimall-auth-server
          predicates:
            - Host=auth.gulimall.com

⑦测试访问登录页面

注意:模板下面的页面除了 index页面,springmvc能够自动访问到,所有要去的页面我门都需要设置controller类来处理相应的请求。

为了测试,我们暂时将 login.html改名为 index.html。

1669994363346

谷粒商城之高级篇_第101张图片

⑧ 实现各个页面之间的相互跳转

1、实现登录页面点击”谷粒商城“图标能跳转到首页:

谷粒商城之高级篇_第102张图片

login.html

1669994603488

2、实现首页点击登录和注册能跳转到登录和注册页面:

1669995167254

修改商品服务下的首页index.html

1669995275064

认证服务编写 controller 实现跳转

@Controller
public class LoginController {


    @GetMapping("/login.html")
    public String loginPage(){

        return "login";
    }

    @GetMapping("/reg.html")
    public String regPage(){

        return "reg";
    }

}

谷粒商城之高级篇_第103张图片

谷粒商城之高级篇_第104张图片

登录页面点击“立即注册”能够跳转到注册页面。

谷粒商城之高级篇_第105张图片

1669997287655

注册页面点击“请登录”能够跳转到登录页面。

谷粒商城之高级篇_第106张图片

1669997384646

ps:这里可以稍微修改一下 登录页面的宽度,让页面更好看一点。

谷粒商城之高级篇_第107张图片

2.5.2 短信验证码

①把 reg.html页面中这一处修改为 “发送验证码”

1670051075825

1670051102425

发送验证码,有60秒倒计时:

谷粒商城之高级篇_第108张图片

 $(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 --;
			}

效果:

谷粒商城之高级篇_第109张图片

②修改后台代码

如果编写一个接口仅仅是为了跳转页面,没有数据的处理,如果这样的跳转接口多了则可以使用SpringMVC的view Controller(视图控制器)将请求与页面进行绑定

新建 GulimallWebConfig

@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {


    /**
     * 视图映射
     * @param registry
     */
    @Override
    public 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";
    // }

}

此外,设置 认证服务的最大内存:

1670054335513

③利用第三方服务进行短信验证码的发送。

这个是老师视频里面购买的短信服务:

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

谷粒商城之高级篇_第110张图片

但是现在这个只有企业用户才能购买。

我们就使用这个来进行测试。

https://market.aliyun.com/products/56928004/cmapi023305.html?spm=5176.2020520132.101.2.56bd7218XsusLL#sku=yuncode1730500007

谷粒商城之高级篇_第111张图片

初步测试一下。注意,这里需要开通API网关才可以进行调试。

购买成功后,从网关控制台这里点进去,如果直接从之前购买页面点进去调试,可能出现无法填写 AppCode等情况,继而测试不成功。

谷粒商城之高级篇_第112张图片

下面是调试内容展示:

谷粒商城之高级篇_第113张图片

测试:手机成功收到短信,且内容为code所写号码。

我们使用 postman进行测试。

谷粒商城之高级篇_第114张图片

谷粒商城之高级篇_第115张图片

1670057863198

测试成功。

ps:当我们在页面上点击“发送验证码”,我们不能通过js代码带上我们的APPCODE ,这样就直接将APPCODE 暴露给别人了,然后别人使用它发送大量短信,这样就有危机了。我们通过后台来发送验证码,这样比较保险。

短信验证码属于第三方服务,我们就放在 gulimall-third-party 服务下。

④ 后台代码调试

复制 相应java调试代码进行测试

 @Test
    public 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 83359fd73fe94948385f570e3c139105
        headers.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包存放相应的工具类。

测试:手机能获取到验证码。

1670059465958

我们将 获取验证码的方法 抽取成一个类: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 83359fd73fe94948385f570e3c139105
        headers.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.com
        path: /dx/sendSms
        appcode: xxxxxxxxxxxxxxx #这里写自己的
        tpl_id: TP1711063

在测试类中进行测试:

  @Autowired
    SmsComponent smsComponent;
 @Test
    public void testSendCode() {

        smsComponent.sendSmsCode("15884430987","123456");
    }

测试成功,手机收到验证码。

2.5.3 验证码之防刷校验

1.编写短信验证controller,方便其它服务调用

在 第三方服务下编写:

@RestController
@RequestMapping("/sms")
public class SmsSendController {


    @Autowired
    SmsComponent 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. 认证服务远程调用发送短信验证码功能

①依赖已导入,开启远程服务调用功能

1670080535151

② 远程调用接口编写

在认证服务下新建 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:将请求和页面映射过来。
     */

     @Autowired
    ThirdPartyFeignService thirdPartyFeignService;

    @Autowired
    StringRedisTemplate 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,方便获取

1670076546938

②发送请求,请求后台发送短信验证码

谷粒商城之高级篇_第116张图片

$.get("/sms/sendcode?phone=" + $("#phoneNum").val());

测试:成功发送。

谷粒商城之高级篇_第117张图片

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:";
}

③编写触发错误时的代码

谷粒商城之高级篇_第118张图片

  SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,稍后再试"),

④LoginController修改

@Controller
public class LoginController {


    /**
     * 发送一个请求直接跳转到一个页面。
     * springMVC viewcontroller:将请求和页面映射过来。
     */

    @Autowired
    ThirdPartyFeignService thirdPartyFeignService;

    @Autowired
    StringRedisTemplate 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  -> 45678
        String 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();
    }


}

⑤ 注册页面的请求发送验证码的回调函数编写

谷粒商城之高级篇_第119张图片

//1、给指定手机号码发送验证码
						$.get("/sms/sendcode?phone=" + $("#phoneNum").val(),function (data) {
							if (data.code != 0){
								alert(data.msg);
							}
						});

⑥测试,60秒内发送多次(刷新页面即可)

谷粒商城之高级篇_第120张图片

redis中存储:

谷粒商城之高级篇_第121张图片

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-9
    private 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的属性名一一对应

1670133742383

1670133759341

1670133780511

1670133806711

点击注册按钮没有发送请求,说明:为注册按钮绑定了单击事件,禁止了默认行为。将绑定的单击事件注释掉

谷粒商城之高级篇_第122张图片

为Model绑定校验错误信息

方法一:

谷粒商城之高级篇_第123张图片

方法二:

谷粒商城之高级篇_第124张图片

编写前端页面获取错误信息

1. 导入thymeleaf的名称空间

1670134048254

2. 封装错误信息

谷粒商城之高级篇_第125张图片

1670134175222

<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方式访问)

解决方案:如下图所示

谷粒商城之高级篇_第126张图片

2.出现问题:刷新页面,会重复提交表单

谷粒商城之高级篇_第127张图片

出现问题的原因:转发,原封不动转发过去

解决方案:使用重定向

谷粒商城之高级篇_第128张图片

3.出现问题:转发,数据都封装在Model中,而重定向获取不到

解决方案:使用 RedirectAttributes

RedirectAttributes的方法讲解:Spring MVC ---- RedirectAttributes 使用,请求转发携带参数总结
谷粒商城之高级篇_第129张图片

4.出现问题:重定向到服务端口地址

谷粒商城之高级篇_第130张图片

解决方案: 写完整的域名路径

谷粒商城之高级篇_第131张图片

说明: RedirectAttributes的 addFlashAttribute()方法是将errors保存在session中,刷新一次就没了

谷粒商城之高级篇_第132张图片

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";
    }

测试:先全写错的,验证码不写。

谷粒商城之高级篇_第133张图片

后端校验提示出现。

谷粒商城之高级篇_第134张图片

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

 @Override
    public 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

 @Override
    public 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();
        }
    }

    @Override
    public 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();
        }
    }

谷粒商城之高级篇_第135张图片

如果抛出异常,则进行捕获

  @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即可

 @Test
    public void contextLoads() {

        //e10adc3949ba59abbe56e057f20f883e
        //抗修改性:彩虹表。 123456 -> xxxx
        String s = DigestUtils.md5Hex("123456");

        //MD5不能直接进行密码的加密存储:可以被直接暴力破解

        // System.out.println(s);

    }

Apache.common下DigestUtils工具类的md5Hex()方法,将MD5加密后的数据转化为16进制

MD5并安全,很多在线网站都可以破解MD5,通过使用彩虹表,暴力破解。

谷粒商城之高级篇_第136张图片

因此,可以通过使用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/Ds5SDG7h6r0wQsF6G
        String encode = passwordEncoder.encode("123456");

        boolean matches = passwordEncoder.matches("123456", "$2a$10$R/VBymW1UA.VzeBedBcspe.iypJIyQiWkka/Ds5SDG7h6r0wQsF6G");

        // $2a$10$jLJp4edbLb9pnCg9quGk0u2uvsm4E/6TD5zi1wqHY4jz/f1ydS.LS=>true
        System.out.println(encode+"=>"+matches);

用户注册业务中的密码加密

谷粒商城之高级篇_第137张图片

		//密码要进行加密存储。
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        String encode = passwordEncoder.encode(vo.getPassword());
        entity.setPassword(encode);

2.5.7 注册完成

1.在common的exception包下,编写异常枚举

谷粒商城之高级篇_第138张图片

    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. 远程服务调用

谷粒商城之高级篇_第139张图片

    @Autowired
    MemberFeignService memberFeignService;


/**
     * //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"; } //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.注册页错误消息提示

1670165495445

<div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'msg')?errors.msg:''):''}">

				div>

6.一些测试中发现需要修改的地方

谷粒商城之高级篇_第140张图片

/**
     * 获取短信验证码
     *
     * @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  -> 45678
        String 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();
    }

谷粒商城之高级篇_第141张图片

需要使用重定向,前面代码已经给了。

测试:

谷粒商城之高级篇_第142张图片

成功重定向到 登录页面。

谷粒商城之高级篇_第143张图片

数据库中也有相应记录的保存。

1670165072298

2.5.8 账户密码登录完成

1.编写Vo-> 认证服务下新建 UserLoginVo

@Data
public class UserLoginVo {

    private String loginacct;
    private String password;

}

2.数据绑定

将ul包在表单里面

谷粒商城之高级篇_第144张图片

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

 @Override
    public 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/i0MP8E4CWu2bjQmM3Xvt4m
            String 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.页面错误消息提示

1670222208092

在 form 表单下面新增一个 div存放错误消息提示

<div class="tips" style="color:red"
                             th:text="${errors!=null?(#maps.containsKey(errors, 'msg')?errors.msg:''):''}">div>

8.测试

先随便写一些错误的账号密码,看提示是否出来。

谷粒商城之高级篇_第145张图片

ps:注意这里将之前的 注册时 错误的存放修改一下。

LoginController的 regist方法

谷粒商城之高级篇_第146张图片

输入正确的账号密码,成功跳转到首页,至于显示登录用户个人信息,后面在进行补充。

2.5.9 社交登录

1、OAuth 2.0

下面是老师课件内容:

谷粒商城之高级篇_第147张图片

谷粒商城之高级篇_第148张图片

谷粒商城之高级篇_第149张图片

谷粒商城之高级篇_第150张图片

谷粒商城之高级篇_第151张图片

谷粒商城之高级篇_第152张图片

谷粒商城之高级篇_第153张图片

谷粒商城之高级篇_第154张图片

2、下面进入实际操作

首先我们先明白 OAuth2.0的原理图:

谷粒商城之高级篇_第155张图片

①因为现在微博需要开发者身份认证且耗时较久,所以此次社交登录选择利用能够快速开始项目实践的Gitee进行测试。

注册地址:https://gitee.com/oauth/applications/

对应文档:Gitee OAuth 文档

注册内容(可以根据文档来进行):

谷粒商城之高级篇_第156张图片

下面是随机找的一个图标

1670244678786

本次Gitee OAuth2登录原理:

谷粒商城之高级篇_第157张图片

②修改 login.html页面

对应的是 登录页面第三方登录图标,以及跳转地址。

https://gitee.com/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code

1670226422558

 <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下。

谷粒商城之高级篇_第158张图片

谷粒商城之高级篇_第159张图片

③测试获取 code 码

点击 Gitee登录

谷粒商城之高级篇_第160张图片

引导到指定页面进行登录。

谷粒商城之高级篇_第161张图片

谷粒商城之高级篇_第162张图片

登录完成获得:code码

1670240450428

利用 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}

谷粒商城之高级篇_第163张图片

2.有了access_token之后,我们根据 API文档即可获取到我们想要的所有信息。

谷粒商城之高级篇_第164张图片

以上测试都是按照 API文档进行测试的。

谷粒商城之高级篇_第165张图片

谷粒商城之高级篇_第166张图片

3、完成社交登录功能。

像 client_id、client_secret、access_token等应该保密,不应爱直接放在路径地址后,所以我们应该编写后端代码进行获取。

①首先,修改之前设置的回调地址:

谷粒商城之高级篇_第167张图片

回调地址

页面也需要修改:

 <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


        <dependency>
            <groupId>com.alibabagroupId>
            <artifactId>fastjsonartifactId>
            <version>1.2.15version>
        dependency>
        <dependency>
            <groupId>org.apache.httpcomponentsgroupId>
            <artifactId>httpclientartifactId>
            <version>4.2.1version>
        dependency>
        <dependency>
            <groupId>org.apache.httpcomponentsgroupId>
            <artifactId>httpcoreartifactId>
            <version>4.2.1version>
        dependency>
        <dependency>
            <groupId>commons-langgroupId>
            <artifactId>commons-langartifactId>
            <version>2.6version>
        dependency>
        <dependency>
            <groupId>org.eclipse.jettygroupId>
            <artifactId>jetty-utilartifactId>
            <version>9.3.7.v20160115version>
        dependency>
        
        
        
        
        
        

test依赖暂时不需要。

  • 编写封装社交账户用户信息的实体类(注意,这里老师课件上是将 获取Access token的得到的JSON数据封装成的实体类)。这里我们使用的 Gitee 获取 access_token得到的数据中没有一个能够唯一标识 该社交用户的 字段,因为我们如果使用社交账户登录成功,如果是第一次登录,我们需要将其注册进 会员表中,此时应该有一个能够唯一识别 该用户的字段,通过 微博 登录测试会直接获得 一个 字段“uid”,我们通过Gitee来进行测试,只能再通过一个接口进行获取用户id的信息。

    获取授权用户的资料

    谷粒商城之高级篇_第168张图片

    在 认证服务的vo包下新建 SocialUser(之后我们通过接口文档提供的url地址来将相应信息设置进去)

@Data
public class SocialUser {

    private String access_token;
    private long expires_in;
    private String uid;
}

老师课件上的:

谷粒商城之高级篇_第169张图片

利用在线JSON转换工具获得的一个Javabean实体类。

谷粒商城之高级篇_第170张图片

  • 为member表新增三个字段

谷粒商城之高级篇_第171张图片

  • 认证服务下编写 处理社交登录请求的类

    OAuth2Controller(最终)

    
    /**处理社交登录请求
     * @author wystart
     * @create 2022-12-05 21:42
     */
    @Slf4j
    @Controller
    public class OAuth2Controller {
    
    
        @Autowired
        MemberFeignService 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_token
            HttpResponse response = HttpUtils.doPost("https://gitee.com", "/oauth/token", "post", headers, querys, map);
    
    
            //2、处理
            if (response.getStatusLine().getStatusCode() == 200) {
                //获取到了 accessToken
                String 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 获取用户 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());
                    //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

     @Override
        public 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;
    }
    
  • 测试

    登录成功返回首页(这里很容易出现 超时现象,多试几次即可。)

数据库多出一条记录

1670308809350

1670308820786

控制台成功打印:

1670308853340

  • 总体步骤总结:

谷粒商城之高级篇_第172张图片

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并将结果返回

    1670309377030

2.5.10 分布式session

登录成功后,首页NickName的显示

谷粒商城之高级篇_第173张图片

在之前的单体应用中,会将登录成功后的属性保存到session中

1670316119321

Thymeleaf取出session

谷粒商城之高级篇_第174张图片

index.html

1670316293920

出现问题:NickName未显示

出现问题的原因:Session不能跨域使用

auth.gulimall域下的session作用域只限于auth.gulimall域,gulimall域是获取不到的,不共享的

谷粒商城之高级篇_第175张图片

谷粒商城之高级篇_第176张图片

session原理:

谷粒商城之高级篇_第177张图片

谷粒商城之高级篇_第178张图片

session共享问题
1.同域名,同一个服务下,session复制多份:第一个服务器下存的session,在第二个服务器下没有
2.不同域名,不同服务下,session如何共享。

解决方案:

方案一:sessio复制,不采用

谷粒商城之高级篇_第179张图片

方案二:客户端存储,不采用

谷粒商城之高级篇_第180张图片

方案三: 利用hash一致性,进行负载均衡,可以采用但是这里不采用

谷粒商城之高级篇_第181张图片

方案四: 统一存储,这里采用这套方案

谷粒商城之高级篇_第182张图片

每个服务:把session放到redis中存储;发卡(JSESSIONID)并放大域名;------->利用SpringSession解决

谷粒商城之高级篇_第183张图片

后端统一存储,前端一个卡去往任何服务都通用。

2.5.11 SpringSession 整合 redis 完成 session域问题

谷粒商城之高级篇_第184张图片

谷粒商城之高级篇_第185张图片

相关文档:

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对象

保存成功,数据库有相应数据。

谷粒商城之高级篇_第186张图片

但是,首页依然不显示,这是因为域名范围限制,下面我们就将自定义SpringSession完成子域session共享

⑤解决子域session共享问题:

认证服务和商品服务下均配置该类:

@Configuration
public class GulimallSessionConfig {


    //子域共享问题解决
    @Bean
    public CookieSerializer cookieSerializer() {


        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();

        cookieSerializer.setDomainName("gulimall.com");// 扩大session作用域,也就是cookie的有效域
        cookieSerializer.setCookieName("GULISESSION");


        return cookieSerializer;


    }


    // 使用json序列化方式来序列化对象数据到redis中
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }

}

前端页面修改,需要进行非空判断

 <a href="http://auth.gulimall.com/login.html">你好,请登录:[[${session.loginUser==null?'':session.loginUser.nickname}]]a>

测试:成功显示nickname

谷粒商城之高级篇_第187张图片

数据库存入相应的实体类。

谷粒商城之高级篇_第188张图片

2.5.12 SpringSession原理–装饰者模式

@EnableRedisHttpSession注解导入了RedisHttpSessionConfiguration.class这个配置类

谷粒商城之高级篇_第189张图片

在 RedisHttpSessionConfiguration.class这个配置类,为容器中注入了一个组件

sessionRepository -> sessionRedisOperations : redis操作session,实现session的增删改查

谷粒商城之高级篇_第190张图片

调用SpringHttpSessionConfiguration中的springSessionRepositoryFilter()方法,获取一个

SessionRepositoryFilter对象,调用doFilterInternal()对原生的request和response对象进行封装即装饰者模式,request对象调用getSession()方法就会调用wrapperRequest对象的getSession()方法
谷粒商城之高级篇_第191张图片

谷粒商城之高级篇_第192张图片

/**
 * 核心原理
 * 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

1670405798497

 <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

公共服务下

谷粒商城之高级篇_第193张图片

② 用户信息也保存到session中

会员服务 MemberController下

谷粒商城之高级篇_第194张图片

认证服务LoginController下

谷粒商城之高级篇_第195张图片

③设置默认的昵称

谷粒商城之高级篇_第196张图片

④ 登录后,首页页面细化

1670339966493

<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>

已经登录的话,在进入登录页要实现跳转首页的效果

①自己编写业务逻辑,将自动页面映射注释

谷粒商城之高级篇_第197张图片

②编写接口

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";
        }
    }

商品详情页,用户昵称显示

详情页显示昵称

1670341583728

点击京东图标返回首页

1670341669687

效果展示:

谷粒商城之高级篇_第198张图片

搜索页,用户昵称显示

完善检索服务

①导入依赖

谷粒商城之高级篇_第199张图片

        <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

谷粒商城之高级篇_第200张图片

③ 开启共享session功能

GulimallSearchApplication 添加注解

@EnableRedisHttpSession

④ 复制配置类

将 GulimallSessionConfig 配置类复制到 检索服务下

谷粒商城之高级篇_第201张图片

⑤ 前端代码

1670405562159

<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>

效果展示:

谷粒商城之高级篇_第202张图片


前面使用的是Gitee进行登录,现在我们也可以修改成和老师一样的使用 微博登录。下面是需要修改的代码。

OAuth2回调地址:

谷粒商城之高级篇_第203张图片

相应文档:

授权机制说明

获取access_token

postman测试

谷粒商城之高级篇_第204张图片

login页面修改:

<li>
                            
                            <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/weibo.png"/>
                            a>
                        li>

SocialUser

@ToString
@Data
public class SocialUser {


    //gitee
    // private String access_token;
    // private Long expires_in;
    // private String uid;

    //weibo
    private 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) {
            //获取到了 accessToken
            String 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 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

@Override
    public 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());
                //weibo
                query.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);

                    //weibo
                    String 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 单点登录

谷粒商城之高级篇_第205张图片

spring session已经解决不了不同域名的问题了。无法扩大域名

sso思路:

记住一个核心思想:建议一个公共的登陆点server,他登录了代表这个集团的产品就登录过了

比如,我登录过尚硅谷电商系统了,我希望登录其他谷粒系统就不用登录了,也即是:只要注册了登录某一个服务就可以自动登录其它所有服务,例如:注册登录了谷粒商城,则可以自动登录在线教育、众筹系统等

这里可以参考 码云上的 xxl的单点登录的开源框架。

1、开源项目:https://gitee.com/xuxueli0323/xxl-sso/repository/archive/master.zip

修改配置:

谷粒商城之高级篇_第206张图片

①配置本机域名

谷粒商城之高级篇_第207张图片

②修改服务器redis地址:

谷粒商城之高级篇_第208张图片

③修改客户端配置:

谷粒商城之高级篇_第209张图片

谷粒商城之高级篇_第210张图片

④打包,在根目录下。

谷粒商城之高级篇_第211张图片

谷粒商城之高级篇_第212张图片

mvn clean package -Dmaven.skip.test=true

⑤启动服务端和客户端。

1670469119661

1670469147507

1670469169582

当访问客户端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

谷粒商城之高级篇_第213张图片

谷粒商城之高级篇_第214张图片

谷粒商城之高级篇_第215张图片

2、下面进行实际编写代码演示:

配置域名:

谷粒商城之高级篇_第216张图片

idea中创建两个模块,一个客户端,一个服务器端。

谷粒商城之高级篇_第217张图片

谷粒商城之高级篇_第218张图片

客户端和服务器端的操作一样,最终效果:

1670418526409

①单点登录流程1:

谷粒商城之高级篇_第219张图片

客户端:

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/employees
            return "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=8081


sso.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

演示:访问客户端直接重定向到 服务端。

谷粒商城之高级篇_第220张图片

②单点登录流程2:

谷粒商城之高级篇_第221张图片

谷粒商城之高级篇_第222张图片

服务器端:

加入依赖:

 <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-data-redisartifactId>
        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 {



    @Autowired
    private  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)){
            //登录成功,跳回之前页面

            //把登录成功的用户保存起来
            //登录成功保存用户信息并传递token
            String 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/employees
            return "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

1670424885711

③单点登录流程3:

这里流程没有截取完全。

谷粒商城之高级篇_第223张图片

复制客户端,改为客户端2

谷粒商城之高级篇_第224张图片

谷粒商城之高级篇_第225张图片

谷粒商城之高级篇_第226张图片

添加进项目

谷粒商城之高级篇_第227张图片

服务器端:

实现一次登录,处处登录的核心就是认证通过之后给浏览器留下一个痕迹,凡是访ssoserver.com这个域名的都会带上这个痕迹,通过使用cookie实现

详解:

  • 子系统都先去 login.html这个请求,
    • 这个请求会告诉登录过的系统的令牌,
    • 如果没登录过就带着url重新去server端,server给一个登录页,如下

当点击登录之后,server端返回一个cookie,子系统重新返回去重新请去业务。于是又来server端验证,这回server端有cookie了,该cookie里有用户在redis中的key,重定向时把key带到url后面,子系统就知道怎么找用户信息了

谷粒商城之高级篇_第228张图片

谷粒商城之高级篇_第229张图片

@Controller
public class LoginController {



    @Autowired
    private  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)){
            //登录成功,跳回之前页面

            //把登录成功的用户保存起来
            // 登录成功保存用户信息并传递token
            String 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中

谷粒商城之高级篇_第230张图片

谷粒商城之高级篇_第231张图片

@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/employees
            return "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

谷粒商城之高级篇_第232张图片

客户端1正常显示之前访问的页面

谷粒商城之高级篇_第233张图片

客户端2无需登录,即可直接登录页面。

谷粒商城之高级篇_第234张图片

最终流程总结:谷粒商城之高级篇_第235张图片

  • 发送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)看这一篇就够了

谷粒商城之高级篇_第236张图片

上图是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没有登录,而是直接在浏览器中敲入回调的地址,并带上伪造的用户信息,是不是业务系统也认为登录了呢?这是很可怕的。

你可能感兴趣的:(谷粒商城,微服务,分布式,java,sql,数据库)