谷粒商城项目笔记总结(2/2)

文章目录

    • 商城项目 - 高级篇(下)
      • 商城业务 - 认证服务
        • 1、初始化环境搭建
        • 2、开通阿里云的短信服务
        • 3、整合短信服务
        • 4、发送验证码并防刷
        • 5、注册功能
        • 6、用户名密码登录功能
        • 7、OAuth2.0
        • 8、微博社交登录
        • 9、整合微博社交登录
        • 10、分布式session问题
        • 11、整合SpringSession
        • 12、单点登录
      • 商城业务 - 购物车
        • 1、初始化环境
        • 2、购物车模型分析
        • 3、ThreadLocal用户身份鉴别
        • 4、添加商品到购物车
        • 5、获取购物车商品
        • 6、购物车其他操作
      • 商城业务 - 消息队列
        • 1、RabbitMQ简介
        • 2、Exchange类型
        • 3、Spring Boot整合RabbitMQ
        • 4、RabbitMQ消息确认机制
      • 商城业务 - 订单服务
        • 1、初始化环境
        • 2、订单登录拦截
        • 3、订单结算页
        • 4、接口幂等性
        • 5、令牌防止多次提交表单
        • 6、提交表单
      • 商城业务 - 分布式事务
        • 1、本地事务
        • 2、本地事务在分布式下的问题
        • 3、分布式事务理论
        • 4、分布式事务常见解决方案
        • 5、分布式事务Seata【会存在bug 可以略过】
        • 6、最终一致性库存解锁逻辑
        • 7、RabbitMQ延时队列
      • 商城业务 - 库存的解锁
        • 1、创建路由交换机和队列
        • 2、库存自动解锁
        • 3、定时关单和手动库存解锁
        • 4、消息丢失、积压、重复等问题
      • 商城业务 - 支付功能
        • 1、支付成功
        • 2、支付成功同步回调
        • 3、订单列表页渲染
        • 4、异步通知
      • 商城业务 - 秒杀功能
        • 1、定时任务
        • 2、Spring boot整合定时任务
        • 3、秒杀(高并发)系统关注的问题


商城项目 - 高级篇(下)

商城业务 - 认证服务

1、初始化环境搭建

  1. 创建项目 gulimall-auth服务,引入依赖;
  2. 编写application.properties
  3. 主启动类上开启服务注册与发现的注解;
  4. host文件中配置域名;
  5. 将静态资源复制到nginx中;
  6. 配置网关
  7. 编写controller实现登录注册页面跳转
    如果只处理视图页面跳转的逻辑,可以使用下面的方式替代,这样就不用写空方法了。
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
    /**
     * 视图映射,处理试图跳转逻辑,不处理任何业务
     * @param registry
     */
    public void addViewControllers(ViewControllerRegistry registry){
        registry.addViewController("/login.html").setViewName("login");
        registry.addViewController("/reg.html").setViewName("reg");
    }
}

2、开通阿里云的短信服务

  1. 进入阿里云的短信服务:https://www.aliyun.com/product/sms?spm=5176.10695662.J_3717714080.1.27b83583LyzB5o
  2. 开通短信服务
  3. 申请短信模板,需要等待审核
  4. 有了 accessKeyID、accessKeySecret、signName、templateCode 就可以打通短信服务。

注意:短信模板目前审核很严格时间较长,如果单是测试功能可以从已申请成功的同学借用。

3、整合短信服务

  1. 引入 spring-cloud-starter-alicloud-oss 依赖
  2. application.properties中配置属性
spring:
  cloud:
    alicloud:
      sms:
        access-key-i-d: 阿里云头像下拉选中得到
        access-key-secret: 阿里云头像下拉选中得到
        sign-name: 阿里云申请的签名
        template-code: 阿里云申请的模板
  1. 封装发送验证码的接口
  2. 在测试类中调用接口测试

4、发送验证码并防刷

gulimall-auth服务远程调用gulimall-third-party服务来发送验证码:

要解决的问题:

  • 在页面上检查元素时,暴露了验证码的请求路径,那么别人拿到这个请求路径就可以无限制的发送验证码。
  • 尽管我们设置了60秒之后才能再次发送验证码,但是只要刷新页面,还是可以重新发送验证码,因此需要设置验证码防刷功能,即使刷新页面仍然需要等待60秒之后才能再次发送验证码。
  • 验证码在注册时需要再次校验,因此生成验证码之后,需要重新存起来
  • 我们需要设置验证码的过期时间,即验证码在5分钟内有效,即设置redis的过期时间
  1. 在 gulimall-auth服务中引入redis依赖,并配置端口和地址
  2. 改造上述中的LoginController接口
@Controller
public class LoginController {
    @Autowired
    ThirdPartyFeignService thirdPartyFeignService;

    @Autowired
    StringRedisTemplate redisTemplate;

     /**
     * 验证码
     * 调用远程服务gulimall-third-party发送验证码,这个验证码并不是由阿里云生成的,
     * 而是由我们后台产生的,我们把生成的验证码传给阿里云让它发送
     */
    @ResponseBody
    @GetMapping("/sms/sendCode")
    public R sendCode(@RequestParam("phone") String phone) {
        //1. 接口防刷
        //防止同一个phone在60秒内再次发送验证码
        String redisCode = redisTemplate.opsForValue().get(AuthConstant.SMS_CODE_CACHE_PREFIX + phone);
        if (!StringUtils.isEmpty(redisCode)) {
            long l = Long.parseLong(redisCode.split("_")[1]);
            if (System.currentTimeMillis() - l < 60000) {
                return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(),BizCodeEnum.SMS_CODE_EXCEPTION.getMessage());
            }
        }

        //2. 验证码再次校验【存入redis】
        String code = UUID.randomUUID().toString().substring(0, 5);
        String subString = code + "_" + System.currentTimeMillis();
        //key,sms:code:13888888888    value,1234
        redisTemplate.opsForValue().set(AuthConstant.SMS_CODE_CACHE_PREFIX+phone, subString,10, TimeUnit.MINUTES);

        thirdFeignService.sendCode(phone,code);
        return R.ok();
    }

5、注册功能

  1. 用户点击注册提交的数据需要实体类中使用 JSR303 进行校验,如果校验不通过,需要收集错误数据,并传给页面
  2. 在进行注册逻辑之前,首先需要判断验证码是否正确,如果验证码不正确就不需要注册了,即判断页面提交的验证码和我们后台生成的验证码(给手机用户发送的验证码)是否一致。如果一致就说明验证码正确
  3. 进行注册时,需要远程调用用户服务即gulimall-member服务来完成注册功能,同时删除redis中验证码【令牌机制】,如果注册成功,跳转到登录页面,如果不成功,跳转到注册页面。

注意:密码及逆向密码需加密加盐存储

6、用户名密码登录功能

gulimall-auth服务远程调用gulimall-member服务来用户登录

注意

  1. 调用远程服务判断如果登录成功,将用户信息存在session中。但是分布式情况下会出现session共享问题
  2. 如果登录失败,将错误信息放入 RedirectAttributes.addAttribute()中重定向到登录页。

补充:重定向传递数据用 RedirectAttributes

  • 放入了request域中,会自动拼接到url上,地址栏 可以看到
    redirectAttributes.addAttribute(“param1”, “msg1”);
  • 也可以自己直接拼入地址栏
    return “redirect:/hello/getUiModel?param2=msg2”;
  • 放入了session中,地址栏看不到。但页面只能取出一次
    redirectAttributes.addFlashAttribute(“user”, user);

7、OAuth2.0

OAuth2.0: 对于用户相关的 OpenAPI(例如获取用户信息, 动态同步, 照片, 日志, 分享等) , 为了保护用户数据的安全和隐私, 第三方网站访问用户数据前都需要显式的向用户征求授权。
谷粒商城项目笔记总结(2/2)_第1张图片

8、微博社交登录

①、登录新浪微博开发平台进行登录:https://open.weibo.com/,创建个人信息,创建新应用。
谷粒商城项目笔记总结(2/2)_第2张图片
设置高级信息:
谷粒商城项目笔记总结(2/2)_第3张图片
② API文档中的授权流程 :

1、引导需要授权的用户到如下地址:

https://api.weibo.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI

2、如果用户同意授权,页面跳转至 YOUR_REGISTERED_REDIRECT_URI/?code=CODE
3、换取Access Token:

https://api.weibo.com/oauth2/access_token?client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=authorization_code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI&code=CODE

4、使用获得的Access Token调用API

③ 测试如何得到access_token :

1、引导用户到达如下地址:client_id=App Key,redirect_uri=重定向地址

https://api.weibo.com/oauth2/authorize?client_id=2267840155&response_type=code&redirect_uri=http://gulimall.com/success

在登录页面点击微博头像就会引导用户到达上地址 :

<li>
   <a href="https://api.weibo.com/oauth2/authorize?client_id=2267840155&response_type=code&redirect_uri=http://gulimall.com/success">
      <img style="width: 50px;height: 18px" src="/static/login/JD_img/weibo.png" />
   a>
li>

点击微博按钮进行扫码授权登录。

2、如果用户同意授权,页面跳转至 YOUR_REGISTERED_REDIRECT_URI/?code=CODE

用户同意授权后,就会获得一个code,拿到code我们就可以换取access_token
谷粒商城项目笔记总结(2/2)_第4张图片
3、换取Access Token:

使用postman测试:拿到code换取access_token
谷粒商城项目笔记总结(2/2)_第5张图片
4、使用获得的Access Token调用API ,比如调用这个API:https://open.weibo.com/wiki/2/users/show
谷粒商城项目笔记总结(2/2)_第6张图片
谷粒商城项目笔记总结(2/2)_第7张图片
使用postman测试:通过这个接口API我们可以拿到微博用户的详细信息,总之,那么access_token之后就可以为所欲为了,所有开放的API我们都可以访问(所以API可以参考我的应用中的所有权限),只要有了这个access_token,我们相当于有了这个用户的权力。
谷粒商城项目笔记总结(2/2)_第8张图片

9、整合微博社交登录

修改授权回调页地址:
谷粒商城项目笔记总结(2/2)_第9张图片
① 引导需要授权的用户到达如下地址:在登录页点击微博会跳转到回调页面并带上一个请求参数code

<a href="https://api.weibo.com/oauth2/authorize?client_id=2267840155&response_type=code&redirect_uri=http://auth.gulimall.com/oauth2.0/weibo/success">
   <img style="width: 50px;height: 18px" src="/static/login/JD_img/weibo.png" />
a>

② 处理回调页面的请求 :http://gulimall.com/oauth2.0/weibo/success

拿到code后,可以通过code换取access_token ,如果换取成功,就可以得到社交用户信息,远程调用gulimall-member服务,判断这个社交用户是否是第一次登录,如果第一次登录,就要进行注册流程,并给社交用户关联一个本系统的用户id。(保存社交用户uid和用户id之间的对应关系),如果已经登录过,给社交用户关联一个本系统的用户id,并更新access_token和expire_in。

③ 调用远程 gulimall-member 服务实现社交登录。

④ 进行测试。

10、分布式session问题

登录成功后,应该显示登录用户的昵称,而不是 ”你好,请登录“

① 在单体应用中,跨页面共享数据,我们可以使用session来存储,在浏览器打开到关闭期间,session 中存储的数据都能取出来,假如我们现在将登录的用户放在session中会有什么问题 ?
谷粒商城项目笔记总结(2/2)_第10张图片
问题1:session不能跨不同域名进行共享,即使不是分布式情况下,只是使用不用服务部署不同域名:

  • 我们在首页http://gulimall.com/下点击登录,跳转到登录页:http://auth.gulimall.com/login.html
  • 在 http://auth.gulimall.com/login.html 下进行登录,域名为auth.gulimall.com(gulimall-auth服务),在这个域名下会保存服务器给浏览器响应的cookie(sessionID)
  • 登录成功后,会跳转到http://gulimall.com/ 下,域名为gulimall.com(gulimall-product服务),在这个域名下并没有服务器给浏览器响应的cookie(sessionID)

问题2:session不同步问题

即使是同域名的情况下,在分布式部署下,会员服务不可能只部署到一台服务器上去,可能多台服务器同时都有会员服务,假设浏览器第一次登录请求发给了1号服务器,那么1号服务器就把我们的用户保存了,由于我们是分布式集群环境,那么下一次请求可能会落到2号服务器,2号服务器并没有用户数据。

② 分布式session情况下,Session不同步的四种解决方案 :

解决方案1:session复制
谷粒商城项目笔记总结(2/2)_第11张图片

解决方案2:session存储在客户端
谷粒商城项目笔记总结(2/2)_第12张图片

解决方案3 : hash一致性
谷粒商城项目笔记总结(2/2)_第13张图片解决方案4:统一存储 (我们项目中使用的方案)

我们将session数据统一存储在数据库DB或者redis中,解决sesison不同步的问题
谷粒商城项目笔记总结(2/2)_第14张图片
③ Sesison不能跨不同域名进行共享的解决方案:

我们现在的问题是在auth.gulimal.com域名下会保存cookie,但是在gulimall.com中却没有,我们希望只要在子域名下的cookie,父域名也能感知到。

子域:gulimall.com,auth.gulimall.com,order.gulimall.com

父域:gulimall.com
谷粒商城项目笔记总结(2/2)_第15张图片

11、整合SpringSession

解决问题1

使用SpringSesion将session存储在redis中 ,这样所有的服务都能从redis中得到session从而得到用户信息了。

  1. 在 gulimall-auth 服务下导入 springsession依赖;
  2. 在application.properties中配置springsession:spring.session.store-type=redis
  3. 主启动类上开启springsession功能:@EnableRedisHttpSession
  4. 在gulimall-product 服务也引入 springsession相关配置
  5. 在 login.html 页面上取出session中登录信息进行展示。

我们在auth.gulimall.com(gulimall-auth服务)页面中进行授权登录后,会将用户数据保存在redis中,同时浏览器会有一个令牌,名称为SESSION :谷粒商城项目笔记总结(2/2)_第16张图片
但是登录成功后跳转的页面gulimall.com(gulimall-product服务)中却没有这个令牌SESSION :
在这里插入图片描述
通过redis客户端可以看到redis中已经存储了用户数据 :但是数据还需要序列化
在这里插入图片描述
所以我们现在解决了第一个问题,就是将用户数据存在了redis中,但是现在需要解决的第二个问题就是子域名下的令牌SESSION,希望父域名下也能感知到。

  1. 假如我们现在手动的将auth.gulimall.com下的SESSION的作用域domain改为父域下(.gulimall.com):
    谷粒商城项目笔记总结(2/2)_第17张图片
    可以看到在gulimall.com下就有了SESSION,并且可以取出Sesion中存放的用户数据 :
    谷粒商城项目笔记总结(2/2)_第18张图片

解决问题2

自定义SpringSession完成子域session共享 :

原始的session中cookie是自动生成并响应给浏览器的,现在Spring Session我们可以配置cookie的生效路径,将其生效路径放大到父域名:

在gulimall-auth服务和gulimall-product服务下分别加上下面的配置:

@Configuration
public class GulimallSessionConfig {
    //配置cookie的生效路径
    @Bean
    public CookieSerializer cookieSerializer(){
        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
        cookieSerializer.setDomainName("gulimall.com");
        cookieSerializer.setCookieName("GULISESSION");
        return cookieSerializer;
    }

    //配置redis的序列化机制
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}

谷粒商城项目笔记总结(2/2)_第19张图片

12、单点登录

商城业务 - 购物车

1、初始化环境

① 将静态资源上传到nginx中;

② 创建服务gulimall-cart,导入相关依赖

③ 配置端口和注册中心地址

④ 主启动类,开启nacos注册与发现功能,远程调用功能

⑤ 配置gulimall-cart的网关路由映射

2、购物车模型分析

① 需求描述 :

用户可以在登录状态下将商品添加到购物车【用户购物车/在线购物车】,登录以后, 会将临时购物车的数据全部合并过来, 并清空临时购物车:

  • 放入数据库(购物车是读写都高并发的操作,如果使用数据库,会造成数据库压力太大)
  • mongodb(性能并不能带来很大的提升)
  • 放入 redis(采用redis拥有极高的数据读写并发性能,但是redis是内存数据库,一旦redis宕机,数据便没了,因此需要设置redis的持久化策略)

用户可以在未登录状态下将商品添加到购物车【游客购物车/离线购物车/临时购物车】,浏览器即使关闭, 下次进入, 临时购物车数据都在 :

  • 放入 localstorage(客户端存储, 后台不存)、cookie、WebSQL
  • 放入 redis(采用)

因此无论是临时购物车还是在线购物车,都将数据存放在redis中

② 我们要开发的购物车功能 :

  • 用户可以使用购物车一起结算下单
  • 给购物车添加商品
  • 用户可以查询自己的购物车
  • 用户可以在购物车中修改购买商品的数量。
  • 用户可以在购物车中删除商品。
  • 选中不选中商品
  • 在购物车中展示商品优惠信息
  • 提示购物车商品价格变化

③ 购物车的数据结构选择:

谷粒商城项目笔记总结(2/2)_第20张图片
因此每一个购物项信息,都是一个对象。购物车中包含很多购物项信息,所以最终是对象的数据。

Redis 有 5 种不同数据结构, 这里选择哪一种比较合适呢? Map>
首先不同用户应该有独立的购物车, 因此购物车应该以用户的作为 key 来存储, Value 是用户的所有购物车信息。 这样看来基本的k-v结构就可以了。

但是, 我们对购物车中的商品进行增、 删、 改操作, 基本都需要根据商品 id 进行判断,为了方便后期处理, 我们的购物车也应该是k-v结构, key 是商品 id, value 才是这个商品的购物车信息。

综上所述, 我们的购物车结构是一个双层 Map: Map>

第一层 Map, Key 是用户 id
第二层 Map, Key 是购物车中商品 id, 值是购物项数据
谷粒商城项目笔记总结(2/2)_第21张图片
④ Vo对象编写

购物项(购物车中每一件商品)

public class CartItemVo {

    //商品id
    private Long skuId;
    //是否被选中
    private Boolean check = true;
    //商品标题
    private String title;
    //商品图片
    private String image;
    //商品套餐属性
    private List<String> skuAttrValues;
    //商品价格
    private BigDecimal price;
    //商品数量
    private Integer count;
    //商品小计
    private BigDecimal totalPrice;
	
	/**
     * 计算当前购物项总价
     * @return
     */
    public BigDecimal getTotalPrice() {
        return this.price.multiply(new BigDecimal("" + this.count));
    }
}

购物车

public class CartVo {

    //购物车子项信息
    List<CartItemVo> items;
    //商品数量
    private Integer countNum;
    //商品类型数量
    private Integer countType;
    //选中的商品数量
    private Integer checkedNum;
    //商品总价
    private BigDecimal totalAmount;
    //减免价格
    private BigDecimal reduce = new BigDecimal("0.00");

    public List<CartItemVo> getItems() {
        return items;
    }

    public void setItems(List<CartItemVo> items) {
        this.items = items;
    }

    public Integer getCheckedNum() {
        return checkedNum;
    }

    public void setCheckedNum(Integer checkedNum) {
        this.checkedNum = checkedNum;
    }

    public Integer getCountNum() {
        int count = 0;
        if (items != null && items.size() > 0) {
            for (CartItemVo item : items) {
                count += item.getCount();
            }
        }
        return count;
    }

    public Integer getCountType() {
        int count = 0;
        if (items != null && items.size() > 0) {
            for (CartItemVo item : items) {
                count += 1;
            }
        }
        return count;
    }

    public BigDecimal getTotalAmount() {
        BigDecimal amount = new BigDecimal("0");
        // 计算购物项总价
        if (!CollectionUtils.isEmpty(items)) {
            for (CartItemVo cartItem : items) {
                if (cartItem.getCheck()) {
                    amount = amount.add(cartItem.getTotalPrice());
                }
            }
        }
        // 计算优惠后的价格
        return amount.subtract(getReduce());
    }

    public BigDecimal getReduce() {
        return reduce;
    }

    public void setReduce(BigDecimal reduce) {
        this.reduce = reduce;
    }
}

3、ThreadLocal用户身份鉴别

用于判断是临时购物车 还是 用户购物车。

① 需求分析 :

以京东网站为例,点击京东的”我的购物车“ ,就会看到有一个Cookie信息user-key,user-key 是随机生成的 id, 不管有没有登录都会有这个 cookie 信息。

浏览器有一个cookie:user-key; 标识用户身份,一个月后过期,如果第一次使用jd的购物车功能,都会给一个临时的购物车身份,浏览器以后保存,每次访问带上这个cookie 。
谷粒商城项目笔记总结(2/2)_第22张图片
对于购物车的相关功能,比如新增商品到购物车、查询购物车,需要判断用户是否登录:

新增商品:判断是否登录
是:则添加商品到后台redis中,把user的唯一标识作为key。
否:则添加商品到后台redis中,使用随机生成的 user-key作为 key。

查询购物车列表:判断是否登录
否:直接 user-key 查询 redis中数据并显示
是:需要先根据 user-key 查询redis中是否有临时数据。
有:需要提交到后台添加到redis,合并数据,而后查询。
否:直接去后台查询redis,而后返回数据。

② 怎么来判断用户是否登录呢 ?

我们需要判断用户是否登录,来执行购物车的相关功能,如果用户已经登录了,那么session中会有用户数据,如果没登录,按照cookie里面带来的user-key来做,但要是第一次使用京东时,如果没有临时用户,系统会创建一个临时用户。

对于项目中有些功能只有登录后才能访问,那么在访问这个功能时就需要判断用户是否登录了,判断的方法就是使用拦截器。(可以把user-key理解为一个token)

  • 因为不管用户是否登录,cookie中都会存在user-key,就可以从cookie中获取user-key(token)
  • 如果用户登录了,那么session中就会存在用户信息,通过session获取登录用户的sessionId
  • 如果用户第一次使用该网站,没有临时用户就创建一个临时用户的user-key,并将user-key保存在浏览器端,并设置过期时间。
@ToString
@Data
public class UserInfoTo {
    //用户id
    private Long userId;
    //临时用户,一定存在
    private String userKey;
    //是否临时用户
    private Boolean tempUser = false;
}

将UserInfoTo存放在ThreadLocal中,ThreadLocal的作用就是同一个线程共享数据,同一个线程:拦截器 -> controller -> service -> dao

/**
 * 拦截器:在执行目标方法(controller)之前,判断用户的登录状态.并封装传递给controller目标请求
 * @author starsea
 * @date 2022-05-05
 */
@Component
public class CartInterceptor implements HandlerInterceptor {

    //ThreadLocal同一个线程共享数据
    public static ThreadLocal<UserInfoTo> toThreadLocal = new ThreadLocal<>();

    /**
     * 目标方法执行之前
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        UserInfoTo userInfoTo = new UserInfoTo();

        HttpSession session = request.getSession();
        //获得当前登录用户的信息
        MemberRespVo memberRespVo = (MemberRespVo) session.getAttribute(AuthConstant.LOGIN_USER);

        if (memberRespVo != null) {
            //用户登录了
            userInfoTo.setUserId(memberRespVo.getId());
        }

        Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                //user-key
                String name = cookie.getName();
                if (name.equals(CartConstant.TEMP_USER_COOKIE_NAME)) {
                    userInfoTo.setUserKey(cookie.getValue());
                    //标记为已是临时用户
                    userInfoTo.setTempUser(true);
                }
            }
        }

        //如果没有临时用户一定分配一个临时用户
        if (StringUtils.isEmpty(userInfoTo.getUserKey())) {
            String uuid = UUID.randomUUID().toString();
            userInfoTo.setUserKey(uuid);
        }

        //目标方法执行之前
        toThreadLocal.set(userInfoTo);
        return true;
    }

    /**
     * 业务执行之后,分配临时用户让浏览器保存
     * @param request
     * @param response
     * @param handler
     * @param modelAndView
     * @throws Exception
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

        //获取当前用户的值
        UserInfoTo userInfoTo = toThreadLocal.get();

        //如果没有临时用户一定保存一个临时用户
        if (!userInfoTo.getTempUser()) {
            //创建一个cookie
            Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
            //扩大作用域
            cookie.setDomain("gulimall.com");
            //设置过期时间
            cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);
            response.addCookie(cookie);
        }
    }
}

配置自定义拦截器,使之生效:

@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new CartInterceptor())//注册拦截器
                .addPathPatterns("/**");
    }
}

③ 我们可以在业务代码中 使用 UserInfoTo userInfoTo = CartInterceptor.threadLocal.get(); 获取到登录的用户信息

4、添加商品到购物车

这里我们使用redis的hash结构来实现购物车功能:

如果用户已经登录使用,那么使用session中的userId作为hash的key,否则使用user-key作为hash的key。

如果购物车中此商品不存在,就添加新商品,如果购物车中此商品存在,就修改商品的数量。

主要业务代码如下:

/**
 *添加商品到购物车
 * @param skuId
 * @param num
 * @return
 */
@Override
public CartItemVo addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();

    //查询redis是否有当前商品
    String res = (String) cartOps.get(skuId.toString());
    if (StringUtils.isEmpty(res)) {
        //购物车无此商品,进行添加

        //2.添加新商品到购物车
        CartItemVo cartItemVo = new CartItemVo();

        //1.远程查询当前要添加的商品的信息
        CompletableFuture<Void> getSkuInfoTask = CompletableFuture.runAsync(() -> {
            R skuInfo = productFeignService.getSkuInfo(skuId);
            SkuInfoVo data = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
            });

            cartItemVo.setCheck(true);
            cartItemVo.setCount(num);
            cartItemVo.setImage(data.getSkuDefaultImg());
            cartItemVo.setPrice(data.getPrice());
            cartItemVo.setTitle(data.getSkuTitle());
            cartItemVo.setSkuId(skuId);
        }, executor);

        //3.远程查询sku的组合信息
        CompletableFuture<Void> getSkuSaleAttrValues = CompletableFuture.runAsync(() -> {
            List<String> skuSaleAttrValues = productFeignService.getSkuSaleAttrValues(skuId);
            cartItemVo.setSkuAttrValues(skuSaleAttrValues);
        }, executor);

        //等待异步都完成在执行后续
        CompletableFuture.allOf(getSkuInfoTask,getSkuSaleAttrValues).get();
        //4.保存到redis中
        String s = JSON.toJSONString(cartItemVo);
        cartOps.put(skuId.toString(),s);

        return cartItemVo;
    } else {
        //购物车有此商品,修改数量
        // TODO 个人觉得有问题:如果后台修改了价格呢,或者删除了这件商品呢。
        CartItemVo cartItemVo = JSON.parseObject(res, CartItemVo.class);
        cartItemVo.setCount(cartItemVo.getCount() + num);
        cartOps.put(skuId.toString(),JSON.toJSONString(cartItemVo));
        return cartItemVo;
    }
}

/**
 *确认用户是否登录来指定是临时购物车还是真是购物车
 * @return
 */
private BoundHashOperations<String, Object, Object> getCartOps() {
    UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();
    //指定是临时购物车还是真实购物车
    String cartKey = "";//redis中存储的key
    if (userInfoTo.getUserId() != null) {
        //用户登录了
        cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserId();
    } else {
        //用户没登录
        cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();
    }

    BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
    return operations;
}

问题:每次刷新添加商品的接口,购物车中的商品数量就会增加。

解决:添加商品到购物车成功后,重定向到新一个地址,从新地址在跳转到 success 页面。

/**
 * 添加商品到购物车
 * @return
 */
@GetMapping("/addToCart")
public String addToCart(@RequestParam("skuId") Long skuId, @RequestParam("num") Integer num,
                        RedirectAttributes redirectAttributes) throws ExecutionException, InterruptedException {
    cartService.addToCart(skuId,num);
    //将数据放在session里面可以在页面取出,但是只能取一次
//        redirectAttributes.addFlashAttribute("skuId", skuId);
    //将数据放在url后面拼接
    redirectAttributes.addAttribute("skuId", skuId);
    return "redirect:http://cart.gulimall.com/addToCartSuccess.html";
}

/**
 * 跳转到成功页:为了解决页面刷新后无限次添加商品
 * @param skuId
 * @param model
 * @return
 */
@GetMapping("/addToCartSuccess.html")
public String addToCartSuccess(@RequestParam("skuId") Long skuId, Model model) {
    //重定向到成功页面,再次查询购物车数量
    CartItemVo cartItemVo = cartService.getCartItem(skuId);
    model.addAttribute("item", cartItemVo);
    return "success";
}
/**
 * 获取购物车中某个购物项
 * @param skuId
 * @return
 */
@Override
public CartItemVo getCartItem(Long skuId) {
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();
    String str = (String) cartOps.get(skuId.toString());
    CartItemVo cartItemVo = JSON.parseObject(str, CartItemVo.class);
    return cartItemVo;
}

5、获取购物车商品

如果用户没有登录,我们获取到的是临时购物车的商品数据。

如果用户登录了,点击我的购物车页面,或者添加商品到购物车时,会先判断临时购物车中是否有商品数据,如果有数据需要合并到在线购物车中,合并后把临时购物车中的商品数据清除,然后再获取在线购物车中的商品数据。

主要业务代码如下:

/**
 * 获取购物车商品信息
 * @return
 */
@Override
public CartVo getCart() throws ExecutionException, InterruptedException {
    CartVo cartVo = new CartVo();
    UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();
    if (userInfoTo.getUserId() != null) {
        //用户已登录
        String cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserId();
        //临时购物车的键
        String temptCartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();

        //如果临时购物车的数据还未进行合并
        List<CartItemVo> tempCartItems = getCartItems(temptCartKey);
        if (tempCartItems != null) {
            //临时购物车有数据需要进行合并操作
            for (CartItemVo item : tempCartItems) {
                addToCart(item.getSkuId(), item.getCount());
            }
            //清除临时购物车的数据
            clearCartInfo(temptCartKey);
        }

        //获取登录后的购物车数据【包含合并过来的临时购物车的数据和登录后购物车的数据】
        List<CartItemVo> cartItems = getCartItems(cartKey);
        cartVo.setItems(cartItems);

        //设置选中商品数量
        if (cartItems != null && cartItems.size() > 0) {
            int count = cartItems.stream().filter(obj -> {
                return obj.getCheck() == true;
            }).mapToInt(CartItemVo::getCount).sum();
            cartVo.setCheckedNum(count);
        }
    } else {
        //用户未登录
        String userKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();
        //获取临时购物车的所有购物项
        List<CartItemVo> cartItems = getCartItems(userKey);
        cartVo.setItems(cartItems);
    }

    return cartVo;
}

/**
 * 获取购物车里面的数据
 * @param cartKey
 * @return
 */
private List<CartItemVo> getCartItems(String cartKey) {
    //获取购物车里面的所有商品
    BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
    List<Object> values = operations.values();
    if (values != null && values.size() > 0) {
        List<CartItemVo> collect = values.stream().map((obj) -> {
            String str = (String) obj;
            CartItemVo cartItem = JSON.parseObject(str, CartItemVo.class);
            return cartItem;
        }).collect(Collectors.toList());
        return collect;
    }
    return null;
}

/**
 * 清空临时购物车数据
 * @param cartKey
 */
@Override
public void clearCartInfo(String cartKey) {
    redisTemplate.delete(cartKey);
}

6、购物车其他操作

① 购物车中商品默认是选中的,如果我们勾线了购物车的状态,会发送请求更改redis数据。

@GetMapping("/checkItem")
public String checkItem(@RequestParam("skuId") Long skuId, @RequestParam("check") Integer check) {
     cartService.checkItem(skuId, check);
     return "redirect:http://cart.gulimall.com/cart.html";
}

② 改变购物项的数量。

@GetMapping("/countItem")
public String countItem(@RequestParam("skuId") Long skuId, @RequestParam("num") Integer num) {
    cartService.countItem(skuId, num);
    return "redirect:http://cart.gulimall.com/cart.html";
}

③ 删除购物项。

@GetMapping("/deleteItem")
public String deleteItem(@RequestParam("skuId") Long skuId) {
    cartService.deleteItem(skuId);
    return "redirect:http://cart.gulimall.com/cart.html";
}

商城业务 - 消息队列

1、RabbitMQ简介

RabbitMQ简介:RabbitMQ是一个由erlang开发的AMQP(Advanved Message Queue Protocol)的开源实现。

  • Message消息:消息是不具名的,它由消息头和消息体组成。消息体是不透明的,而消息头则由一系列的可选属性组成,
    这些属性包括routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出该消息可能需要持久性存储)等。

  • Publisher消息的生产者:也是一个向交换器发布消息的客户端应用程序。

  • Exchange交换器:用来接收生产者发送的消息并将这些消息路由给服务器中的队列。
    Exchange有4种类型:direct(默认),fanout,topic,和headers,不同类型的Exchange转发消息的策略有所区别。

  • Queue消息队列:用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者连接到这个队列将其取走。

  • Binding绑定:用于消息队列和交换器之间的关联。一个绑定就是基于路由键将交换器和消息队列连接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。Exchange 和Queue的绑定可以是多对多的关系。

  • Connection网络连接:比如一个TCP连接。

  • Channel信道:多路复用连接中的一条独立的双向数据流通道。信道是建立在真实的TCP连接内的虚拟连接,AMQP 命令都是通过信道发出去的,不管是发布消息、订阅队列还是接收消息,这些动作都是通过信道完成。因为对于操作系统来说建立和销毁 TCP 都是非常昂贵的开销,所以引入了信道的概念,以复用一条 TCP 连接。

  • Consumer消息的消费者:表示一个从消息队列中取得消息的客户端应用程序。

  • Virtual Host虚拟主机:表示一批交换器、消息队列和相关对象。虚拟主机是共享相同的身份认证和加密环境的独立服务器域。每个 vhost 本质上就是一个 mini 版的 RabbitMQ 服务器,拥有自己的队列、交换器、绑定和权限机制。vhost 是 AMQP 概念的基础,必须在连接时指定,RabbitMQ 默认的 vhost 是 / 。

  • Broker:表示消息队列服务器实体
    谷粒商城项目笔记总结(2/2)_第23张图片

2、Exchange类型

AMQP 中消息的路由过程和 Java 开发者熟悉的 JMS 存在一些差别,AMQP 中增加了 Exchange 和Binding 的角色。生产者把消息发布到 Exchange 上,消息最终到达队列并被消费者接收,而 Binding 决定交换器的消息应该发送到那个队列。
谷粒商城项目笔记总结(2/2)_第24张图片
Exchange分发消息时根据类型的不同分发策略有区别,目前共四种类型:direct、fanout、topic、headers

  • direct 直接路由:消息中的路由键(routing key)如果和Binding 中的 binding key 一致, 交换器就将消息发到对应的队列中。路由键与队列名完全匹配,如果一个队列绑定到交换机要求路由键为“dog”,则只转发 routing key 标记为“dog”的消息,不会转发“dog.puppy”,也不会转发“dog.guard”等等。它是完全匹配、单播的模式。

  • fanout 广播:每个发到 fanout 类型交换器的消息都会分到所有绑定的队列上去。fanout 交换器不处理路由键,只是简单的将队列绑定到交换器上,每个发送到交换器的消息都会被转发到与该交换器绑定的所有队列上。很像子网广播,每台子网内的主机都获得了一份复制的消息。fanout 类型转发消息是最快的。

  • topic 发布订阅:topic 交换器通过模式匹配分配消息的路由键属性,将路由键和某个模式进行匹配,此时队列需要绑定到一个模式上。它将路由键和绑定键的字符串切分成单词,这些单词之间用点隔开。它同样也会识别两个通配符:符号“#”和符号“*”。#匹配0个或多个单词,*匹配一个单词。
    谷粒商城项目笔记总结(2/2)_第25张图片

下面要根据这个图创建交换机和队列:
谷粒商城项目笔记总结(2/2)_第26张图片
① 创建四个队列 :
谷粒商城项目笔记总结(2/2)_第27张图片
② 创建三个交换机:
在这里插入图片描述
③ 将三个交换机都绑定四个队列:
谷粒商城项目笔记总结(2/2)_第28张图片

3、Spring Boot整合RabbitMQ

1)引入 spring-boot-starter-amqp 依赖

2)在application.yml中配置属性

spring.rabbitmq.host=192.168.187.100
spring.rabbitmq.port=5672
spring.rabbitmq.virtual-host=/

3)主启动类上开启RabbitMQ的相关功能,使用 @EnableRabbit 注解

4)测试创建交换机,队列,将队列绑定交换机

@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class GulimallOrderApplicationTests {

    @Autowired
    AmqpAdmin amqpAdmin;

    @Autowired
    RabbitTemplate rabbitTemplate;

    /**
     * 创建交换机
     */
    @Test
    public void createExchange() {
        //DirectExchange(String name, boolean durable, boolean autoDelete, Map arguments)
        // 创建交换机
        DirectExchange directExchange = new DirectExchange("hello-java-exchange",true,false);
        amqpAdmin.declareExchange(directExchange);
        log.info("Exchange[{}]创建成功", "hello-java-exchange");
    }

    /**
     * 创建队列
     */
    @Test
    public void createQueue(){
        //public Queue(String name, boolean durable, boolean exclusive, boolean autoDelete, Map arguments)
        Queue queue = new Queue("hello-java-queue",true,false,false);
        amqpAdmin.declareQueue(queue);
        log.info("Queue[{}]创建成功", "hello-java-queue");
    }

    /**
     * 将交换机和队列绑定
     */
    @Test
    public void createBinding(){
        // ( String destination【目的地】,
        //   DestinationType destinationType【目的地类型】,
        //   String exchange【交换机】,
        //   String routingKey【路由键】,
        //   Map arguments【自定义参数】)
        //将exchange指定的交换机和destination目的地进行绑定,使用routingKey作为指定的路由键
        Binding binding = new Binding("hello-java-queue",
                Binding.DestinationType.QUEUE, "hello-java-exchange",
                "hello.java", null);
        amqpAdmin.declareBinding(binding);
        log.info("Binding[{}]创建成功", "hello-java-binding");
    }
}

5)测试发送消息

字符串消息

@Test
public void sendMessage(){
   String msg = "hello world";
   rabbitTemplate.convertAndSend("hello.java.exchange","hello.java", msg);
   log.info("消息发送完成{}",msg);
}

对象消息:【对象必须实现 Serializable

@RestController
public class RabbitController {

    @Autowired
    RabbitTemplate rabbitTemplate;

    /**
     * 测试发送 实体类 消息
     * @param num
     * @return
     */
    @GetMapping("/sendMq")
    public String sendMq(@RequestParam(value = "num",defaultValue = "10") Integer num) {
        for (int i = 0; i < num; i++) {
            if (i % 2 == 0) {
                //如果发送的消息是个对象,我们会使用序列化机制,将对象写出去,对象必须实现Serializable接口
                OrderReturnReasonEntity reasonEntity = new OrderReturnReasonEntity();
                reasonEntity.setCreateTime(new Date());
                reasonEntity.setId(1L);
                reasonEntity.setName("退货原因"+i);

                //发送的对象类型的消息是一个json,需要重新定义消息转换器Jackson2JsonMessageConverter
                rabbitTemplate.convertAndSend("hello.java.exchange","hello.java", reasonEntity);
            } else {
                OrderEntity orderEntity = new OrderEntity();
                orderEntity.setOrderSn(UUID.randomUUID().toString());
                rabbitTemplate.convertAndSend("hello.java.exchange","hello.java", orderEntity);
            }
        }
        return "ok";
    }
}

注意:如果发送对象,使用JSON序列化机制,进行消息转换,否则发送出去的就是字节数据。

@Configuration
public class MyRabbitConfig {
    @Bean
    public MessageConverter messageConverter(){
        return new Jackson2JsonMessageConverter();
    }
}

6)监听队列中的消息 :【进行消费】

监听消息:使用@RabbitListener;主启动类必须有@EnableRabbit
@RabbitListener:类+方法上(监听哪些队列即可)
@RabbitHandler:标在方法上(重载区分不同的消息)

Queue:可以很多人都来监听。只要收到消息,队列删除消息,而且只能有一个收到此消息

场景:
① 订单服务启动多个;同一个消息,只能有一个客户端收到
② 只有一个消息完全处理完,方法运行结束,我们就可以接收到下一个消息

@RabbitListener(queues = {"hello-java-queue"})
@Service("orderItemService")
public class OrderItemServiceImpl extends ServiceImpl<OrderItemDao, OrderItemEntity> implements OrderItemService {

    //@RabbitListener(queues = {"hello-java-queue"})
    @RabbitHandler
    public void recieveMessage(Message message, OrderReturnReasonEntity content,
                               Channel channel) throws InterruptedException {
        System.out.println("接收到消息..." + message);
        System.out.println("内容:"+content);
    }

    @RabbitHandler
    public void recieveMessage2(OrderEntity content) {
        System.out.println("内容..." +content);
    }
}

访问:http://localhost:9000/sendMq

4、RabbitMQ消息确认机制

使用消息传递代理(例如RabbitMQ)的系统是分布式的。由于不能保证发送消息可以到达对等方或被其成功处理,因此发布者和使用者都需要一种机制来进行传递和处理确认。

保证消息不丢失,可靠抵达,可以使用事务消息,性能下降250倍,为此引入确认机制
• publisher confirmCallback 确认模式
• publisher returnCallback 未投递到 queue 退回模式
• consumer ack机制
谷粒商城项目笔记总结(2/2)_第29张图片

① 可靠抵达 - ConfirmCallback : 只要消息抵达Broker就ack=true

• 设置 PublisherConfirms(true) 选项,开启confirmcallback 。

# 开启发送者确认模式
spring.rabbitmq.publisher-confirms=true

• 消息只要被 broker 接收到就会执行 confirmCallback,如果是 cluster 模式,需要所有broker 接收到才会调用 confirmCallback。

@Configuration
public class MyRabbitConfig {
    @Autowired
    RabbitTemplate rabbitTemplate;

    /**
     * 定制rabbitMq
     */
    // Constructor(构造方法) -> @Autowired(依赖注入) -> @PostConstruct(注释的方法)
    @PostConstruct 
    public void initRabbitTemplate(){
        //设置确认回调 : 只要消息抵达Broker就ack=true
        rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
            /**
             * @param correlationData 当前消息的唯一关联数据
             * @param ack 消息是否成功收到
             * @param cause 失败的原因
             */
            @Override
            public void confirm(CorrelationData correlationData, boolean ack, String cause) {
                System.out.println("correlationData--》"+correlationData +"ack--》"+ack + "causer--》"+cause);
            }
        });
    }
}

• CorrelationData:用来表示当前消息唯一性。

@RestController
public class RabbitController {
    @Autowired
    RabbitTemplate rabbitTemplate;
    
    @GetMapping("/sendMq")
    public String sendMq(@RequestParam(value = "num",defaultValue = "10") Integer num) {
        for (int i = 0; i < num; i++) {
            if (i % 2 == 0) {
                //如果发送的消息是个对象,我们会使用序列化机制,将对象写出去,对象必须实现Serializable接口
                OrderReturnReasonEntity reasonEntity = new OrderReturnReasonEntity();
                reasonEntity.setCreateTime(new Date());
                reasonEntity.setId(1L);
                reasonEntity.setName("退货原因"+i);

                //发送的对象类型的消息是一个json,需要重新定义消息转换器Jackson2JsonMessageConverter
                rabbitTemplate.convertAndSend("hello.java.exchange","hello.java", reasonEntity, new CorrelationData(UUID.randomUUID().toString()));
            } else {
                OrderEntity orderEntity = new OrderEntity();
                orderEntity.setOrderSn(UUID.randomUUID().toString());
                rabbitTemplate.convertAndSend("hello.java.exchange","hello.java", orderEntity, new CorrelationData(UUID.randomUUID().toString()));
            }
        }
        return "ok";
    }
}

② 可靠抵达 - ReturnCallback

# 开启发送端消息抵达队列的确认
spring.rabbitmq.publisher-returns=true
# 只要抵达队列,以异步发送优先回调我们这个returnconfirm
spring.rabbitmq.template.mandatory=true

• 被 broker 接收到只能表示 message 已经到达服务器,并不能保证消息一定会被投递到目标 queue 里。所以需要用到接下来的returnCallback

@Configuration
public class MyRabbitConfig {
    @Autowired
    RabbitTemplate rabbitTemplate;

    /**
     * 定制rabbitMq
     */
    @PostConstruct  
    public void initRabbitTemplate(){
        //1、设置确认回调 : 只要消息抵达Broker就ack=true
        rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
            /**
             * @param correlationData 当前消息的唯一关联数据
             * @param ack 消息是否成功收到
             * @param cause 失败的原因
             */
            @Override
            public void confirm(CorrelationData correlationData, boolean ack, String cause) {
                System.out.println("correlationData--》"
                                   +correlationData +"ack--》"+ack + "causer--》"+cause);
            }
        });

        //2、设置消息抵达队列的确认回调: 只有失败时才会调用这个消息
        rabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback() {
            /**
             * 只要消息没有投递给指定的队列,就触发这个失败回调
             * @param message   投递失败的消息详细信息
             * @param replyCode 回复的状态码
             * @param replyText 回复的文本内容
             * @param exchange  当时这个消息发给哪个交换机
             * @param routingKey 当时这个消息用哪个路由键
             */
            @Override
            public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
                System.out.println("Fail Message["+message+"]-->replyCode["+replyCode+"]-->replyText["+replyText+"]-->exchange["+exchange+"]-->routingKey["+routingKey+"]");
            }
        });
    }
}

③ 可靠抵达 - Ack消息确认机制

  • 消费者获取到消息,成功处理,可以回复Ack给Broker
    • basic.ack 用于肯定确认;broker将移除此消息
    • basic.nack 用于否定确认;可以指定broker是否丢弃此消息,可以批量
    • basic.reject 用于否定确认;可以指定broker是否丢弃此消息,但不能批量

  • 默认自动ack,消息被消费者收到,就会从broker的queue中移除;queue无消费者,消息依然会被存储,直到消费者消费

  • 消费者收到消息,默认会自动ack。但是如果无法确定此消息是否被处理完成,或者成功处理。我们可以开启手动ack模式

  • 消息确认的类型:

channel.basicAck(deliveryTag, multiple);
consumer处理成功后,通知broker删除队列中的消息,如果设置multiple=true,表示支持批量确认机制以减少网络流量。
例如:有值为5,6,7,8 deliveryTag的投递
如果此时channel.basicAck(8, true);则表示前面未确认的5,6,7投递也一起确认处理完毕。
如果此时channel.basicAck(8, false);则仅表示deliveryTag=8的消息已经成功处理。

channel.basicNack(deliveryTag, multiple, requeue);
consumer处理失败后,例如:有值为5,6,7,8 deliveryTag的投递。
如果channel.basicNack(8, true, true);表示deliveryTag=8之前未确认的消息都处理失败且将这些消息重新放回队列中。
如果channel.basicNack(8, true, false);表示deliveryTag=8之前未确认的消息都处理失败且将这些消息丢弃。
如果channel.basicNack(8, false, true);表示deliveryTag=8的消息处理失败且将该消息重新放回队列。
如果channel.basicNack(8, false, false);表示deliveryTag=8的消息处理失败且将该消息直接丢弃。

channel.basicReject(deliveryTag, requeue);
相比channel.basicNack,除了没有multiple批量确认机制之外,其他语义完全一样。
如果channel.basicReject(8, true);表示deliveryTag=8的消息处理失败且将该消息重新放回队列。
如果channel.basicReject(8, false);表示deliveryTag=8的消息处理失败且将该消息直接丢弃。

# 设置为手动ack方式
spring.rabbitmq.listener.simple.acknowledge-mode=manual

此时监听队列中的消息,并设置手动确认:

@RabbitListener(queues = {"hello-java-queue"})
@Service("orderItemService")
public class OrderItemServiceImpl extends ServiceImpl<OrderItemDao, OrderItemEntity> implements OrderItemService {
	/**
	 * 参数可以写的以下类型
	 * 1. Message message:原生消息详细信息,头+体
	 * 2. T<发送消息的类型> OrderReturnReasonEntity content
	 * 3. Channel channel:当前传输数据的通道
	 */
    @RabbitHandler
    public void recieveMessage(Message message, OrderReturnReasonEntity content,
                               Channel channel) throws InterruptedException {
        //消息体信息
        byte[] body = message.getBody();
        //消息头属性信息
        MessageProperties messageProperties = message.getMessageProperties();
//        Thread.sleep(3000);
        System.out.println("消息处理完==>" + content.getName());

        //deliveryTag 是 channel内按顺序自增的
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        System.out.println("deliveryTag ==> " + deliveryTag);

        //签收获取,非批量模式
        try {
            if (deliveryTag%2 == 0) {
                channel.basicAck(deliveryTag,false);
                System.out.println("签收了货物..." + deliveryTag);
            } else {
                // requeue = false 丢弃, = true 发回服务器,服务器重新入队
                //long deliveryTag, boolean multiple, boolean requeue
                channel.basicNack(deliveryTag,false,true);
                //long deliveryTag, boolean requeue
//                channel.basicReject();
                System.out.println("没有签收了货物..." + deliveryTag);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @RabbitHandler
    public void recieveMessage2(OrderEntity content) {
        System.out.println("接收消息..." +content);
    }
}

商城业务 - 订单服务

1、初始化环境

  1. host文件配置域名
  2. 配置网关
  3. 导入springsession,redis依赖
  4. 在gulimall-order加入注册中心配置
  5. 主启动类开启@EnableRedisHttpSession,@@EnableRedisHttpSession注解

订单的基本概念:

电商系统涉及到 3 流, 分别时信息流, 资金流, 物流, 而订单系统作为中枢将三者有机的集合起来。
订单模块是电商系统的枢纽, 在订单这个环节上需求获取多个模块的数据和信息, 同时对这些信息进行加工处理后流向下个环节, 这一系列就构成了订单的信息流通 。
谷粒商城项目笔记总结(2/2)_第30张图片
订单的创建与支付:

(1) 、 订单创建前需要预览订单, 选择收货信息等
(2) 、 订单创建需要锁定库存, 库存有才可创建, 否则不能创建
(3) 、 订单创建后超时未支付需要解锁库存
(4) 、 支付成功后, 需要进行拆单, 根据商品打包方式, 所在仓库, 物流等进行拆单
(5) 、 支付的每笔流水都需要记录,以待查账
(6) 、 订单创建, 支付成功等状态都需要给 MQ 发送消息, 方便其他系统感知订阅

2、订单登录拦截

订单系统只有登录后才能访问,没有登录,需要用户先去登录,因此需要先设置登录请求拦截判断用户是否登录:

@Component
public class LoginUserInterceptor implements HandlerInterceptor {

    public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

		String uri = request.getRequestURI();
        AntPathMatcher antPathMatcher = new AntPathMatcher();
        //排除掉根据订单号查询订单数据,原因feign需要过滤器登录
        boolean match = antPathMatcher.match("/order/order/status/**", uri);
        boolean match1 = antPathMatcher.match("/payed/notify", uri);
        if (match || match1) {
            return true;
        }
    
        //获取登录的用户信息
        MemberRespVo attribute = (MemberRespVo) request.getSession().getAttribute(AuthConstant.LOGIN_USER);

        if (attribute != null) {
            //把登录后用户的信息放在ThreadLocal里面进行保存
            loginUser.set(attribute);

            return true;
        } else {
            //未登录,返回登录页面
            response.setContentType("text/html;charset=UTF-8");
            PrintWriter out = response.getWriter();
            out.println("");
            // session.setAttribute("msg", "请先进行登录");
            // response.sendRedirect("http://auth.gulimall.com/login.html");
            return false;
        }
    }
}

要使自定的拦截器生效 需要去实现 WebMvcConfigurer 来添加上 addInterceptors 加入拦截器。

3、订单结算页

1、订单确认页需要用的Vo数据

public class OrderConfirmVo {

    /** 会员收获地址列表 **/
    @Getter @Setter
    List<MemberAddressVo> memberAddressVos;

    /** 所有选中的购物项 **/
    @Getter @Setter
    List<OrderItemVo> items;

    /** 发票记录 **/

    /** 优惠券(会员积分) **/
    @Getter @Setter
    private Integer integration;

    /** 防止重复提交的令牌 **/
    @Getter @Setter
    private String orderToken;

    /** 库存 **/
    @Getter @Setter
    Map<Long, Boolean> stocks;

    public Integer getCount() {
        Integer count = 0;
        if (items != null && items.size() > 0) {
            for (OrderItemVo item : items) {
                count += item.getCount();
            }
        }
        return count;
    }

    /** 订单总额 **/
    //BigDecimal total;
    //计算订单总额
    public BigDecimal getTotal() {
        BigDecimal totalNum = BigDecimal.ZERO;
        if (items != null && items.size() > 0) {
            for (OrderItemVo item : items) {
                //计算当前商品的总价格
                BigDecimal itemPrice = item.getPrice().multiply(new BigDecimal(item.getCount().toString()));
                //再计算全部商品的总价格
                totalNum = totalNum.add(itemPrice);
            }
        }
        return totalNum;
    }

    /** 应付价格 **/
    //BigDecimal payPrice;
    public BigDecimal getPayPrice() {
        return getTotal();
    }
}

2、订单确认页数据获取

  • gulimall-order服务远程调用gulimall-member服务的feign接口,远程查询所有的收货地址列表
  • gulimall-order服务远程调用gulimall-cart服务的feign接口,远程查询购物车所有选中的购物项
  • gulimall-cart远程调用gulimall-product服务的feign接口 ,远程查询商品的购物车信息
  • gulimall-order服务远程调用gulimall-ware服务的feign接口,远程批量判断选中的购物项是否有货

3、订单确认页返回需要用的数据

@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {
    @Autowired
    MemberFeignService memberFeignService;

    @Autowired
    CartFeignService cartFeignService;

    @Override
    public OrderConfirmVo confirmOrder() {
        OrderConfirmVo confirmVo = new OrderConfirmVo();

        MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
        //1、远程查询所有的收货地址列表
        List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
        confirmVo.setAddress(address);

        //2、远程查询购物车所有选中的购物项
        List<OrderItemVo> currentCartItems = cartFeignService.getCurrentCartItems();
        confirmVo.setItems(currentCartItems);

        //3、查询用户积分
        Integer integration = memberRespVo.getIntegration();
        confirmVo.setIntegration(integration);

        //其他数据自动计算
        // TODO 防重令牌
        return confirmVo;
    }
}

4、Feign远程调用丢失请求头信息的问题

问题描述:当远程调用gulimall-cart服务时,设置了拦截器判断用户是否登录,但是结果是即使用户登录了,也会显示用户没登录,原因在于cartFeignService.getCurrentUserCartItem();远程调用时,发送的请求是一个新的情求,请求中并不存在cookie,而 http://order.gulimall.com/toTrade 请求中是携带cookie的。

解决:编写远程调用拦截器
谷粒商城项目笔记总结(2/2)_第31张图片

@Configuration
public class GulimallFeignConfig {

    @Bean("requestInterceptor")
    public RequestInterceptor requestInterceptor() {
        return new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate template) {
                ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

                if (requestAttributes != null) {
                    HttpServletRequest request = requestAttributes.getRequest();//老请求
                    if (request != null) {
                        //同步请求头数据
                        String cookie = request.getHeader("Cookie");
                        template.header("Cookie", cookie);
                    }
                }
            }
        };
    }
}

5、Feign异步情况丢失上下问题
谷粒商城项目笔记总结(2/2)_第32张图片

@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
    OrderConfirmVo confirmVo = new OrderConfirmVo();

    MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
    System.out.println("主线程...." + Thread.currentThread().getId());

    //获取之前的请求
    RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
    
    CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
        //1、远程查询所有的收货地址列表
        System.out.println("member线程...." + Thread.currentThread().getId());
        //每一个线程都来共享之前的请求数据
        RequestContextHolder.setRequestAttributes(requestAttributes);
        List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
        confirmVo.setAddress(address);
    }, threadPoolExecutor);

    CompletableFuture<Void> getCartItemsFuture = CompletableFuture.runAsync(() -> {
        //2、远程查询购物车所有选中的购物项
        System.out.println("cart线程...." + Thread.currentThread().getId());
        //每一个线程都来共享之前的请求数据
        RequestContextHolder.setRequestAttributes(requestAttributes);
        List<OrderItemVo> currentCartItems = cartFeignService.getCurrentUserCartItems();
        confirmVo.setItems(currentCartItems);
    }, threadPoolExecutor).thenRunAsync(()->{
        //得到所有的购物项
        List<OrderItemVo> items = confirmVo.getItems();
        //5、远程调用gulimall-ware服务查询每一个购物项是否有货
        List<Long> collect = items.stream().map(item -> item.getSkuId()).collect(Collectors.toList());
        R hasStock = wmsFeignService.getSkusHasStock(collect);
        List<SkuStockVo> data = hasStock.getData(new TypeReference<List<SkuStockVo>>() {
        });
        if(data!=null){
            Map<Long, Boolean> collect1 = data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
            confirmVo.setStocks(collect1);
        }
    },threadPoolExecutor);

    //3、查询用户积分
    Integer integration = memberRespVo.getIntegration();
    confirmVo.setIntegration(integration);

	//4、价格数据自动计算

    // TODO 防重令牌

    CompletableFuture.allOf(getAddressFuture,getCartItemsFuture).get();
    return confirmVo;
}

6、根据收获地址计算运费信息

/**
 *根据用户收货地址计算运费
 * @param addrId
 * @return
 */
@Override
public FareVo getFare(Long addrId) {
    FareVo fareVo = new FareVo();

    // 远程查询收货地址详细信息
    R r = memberFeignService.addrInfo(addrId);
    MemberAddressVo data = r.getData("memberReceiveAddress",new TypeReference<MemberAddressVo>() {
    });
    if (data != null) {
        //TODO 结合第三方快递、物流接口
        String phone = data.getPhone();
        String substring = phone.substring(phone.length() - 1, phone.length());
        BigDecimal decimal = new BigDecimal(substring);
        fareVo.setAddress(data);
        fareVo.setFare(decimal);

        return fareVo;
    }
    return null;
}

4、接口幂等性

1、 什么是幂等性

接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的, 不会因为多次点击而产生了副作用; 比如说支付场景, 用户购买了商品支付扣款成功, 但是返回结果的时候网络异常, 此时钱已经扣了, 用户再次点击按钮, 此时会进行第二次扣款, 返回结果成功, 用户查询余额返发现多扣钱了, 流水记录也变成了两条,这就没有保证接口的幂等性。

2、 哪些情况需要防止

① 用户多次点击按钮
② 用户页面回退再次提交
③ 微服务互相调用, 由于网络问题, 导致请求失败。 feign 触发重试机制
④ 其他业务情况

3、 什么情况下需要幂等,以 SQL 为例, 有些操作是天然幂等

① SELECT * FROM table WHER id=?, 无论执行多少次都不会改变状态, 是天然的幂等。
② UPDATE tab1 SET col1=1 WHERE col2=2, 无论执行成功多少次状态都是一致的, 也是幂等操作。
③ delete from user where userid=1, 多次操作, 结果一样, 具备幂等性
④ insert into user(userid,name) values(1,‘a’) 如 userid 为唯一主键, 即重复操作上面的业务, 只会插入一条用户数据, 具备幂等性。
⑤ UPDATE tab1 SET col1=col1+1 WHERE col2=2, 每次执行的结果都会发生变化, 不是幂等的。
⑥ insert into user(userid,name) values(1,‘a’) 如 userid 不是主键, 可以重复, 那上面业务多次操作, 数据都会新增多条, 不具备幂等性

4、 幂等解决方案

① token 机制:

  • 服务端提供了发送 token 的接口。 我们在分析业务的时候, 哪些业务是存在幂等问题的,就必须在执行业务前, 先去获取 token, 服务器会把 token 保存到 redis 中 。
  • 然后调用业务接口请求时, 把 token 携带过去, 一般放在请求头部。
  • 服务器判断 token 是否存在 redis 中, 存在表示第一次请求, 然后删除 token,继续执行业务。
  • 如果判断 token 不存在 redis 中, 就表示是重复操作, 直接返回重复标记给 client, 这样就保证了业务代码, 不被重复执行

先删除 token 还是后删除 token?

  • 先删除可能导致, 业务确实没有执行, 重试还带上之前 token, 由于防重设计导致,请求还是不能执行。
  • 后删除可能导致, 业务处理成功, 但是服务闪断, 出现超时, 没有删除 token, 别人继续重试, 导致业务被执行两边
  • 我们最好设计为先删除 token, 如果业务调用失败, 就重新获取 token 再次请求。

token 获取、 比较和删除必须是原子性

  • redis.get(token) 、 token.equals、 redis.del(token)如果这两个操作不是原子, 可能导致, 高并发下, 都 get 到同样的数据, 判断都成功, 继续业务并发执行
  • 可以在 redis 使用 lua 脚本完成这个操作
    if redis.call(‘get’, KEYS[1]) == ARGV[1] then return redis.call(‘del’, KEYS[1]) else return 0 end

② 各种锁机制 :

  • 数据库悲观锁
    select * from xxxx where id = 1 for update;
    悲观锁使用时一般伴随事务一起使用, 数据锁定时间可能会很长, 需要根据实际情况选用。另外要注意的是, id 字段一定是主键或者唯一索引, 不然可能造成锁表的结果, 处理起来会非常麻烦。

  • 数据库乐观锁
    这种方法适合在更新的场景中,
    update t_goods set count = count -1 , version = version + 1 where good_id=2 and version = 1
    根据 version 版本, 也就是在操作库存前先获取当前商品的 version 版本号, 然后操作的时候带上此 version 号。 我们梳理下, 我们第一次操作库存时, 得到 version 为 1, 调用库存服务version 变成了 2; 但返回给订单服务出现了问题, 订单服务又一次发起调用库存服务, 当订单服务传如的 version 还是 1, 再执行上面的 sql 语句时, 就不会执行; 因为 version 已经变为 2 了, where 条件就不成立。 这样就保证了不管调用几次, 只会真正的处理一次。乐观锁主要使用于处理读多写少的问题

  • 业务层分布式锁
    如果多个机器可能在同一时间同时处理相同的数据, 比如多台机器定时任务都拿到了相同数据处理, 我们就可以加分布式锁, 锁定此数据, 处理完成后释放锁。 获取到锁的必须先判断这个数据是否被处理过 。

③ 各种唯一约束:

  • 数据库唯一约束
    插入数据, 应该按照唯一索引进行插入, 比如订单号, 相同的订单就不可能有两条记录插入。我们在数据库层面防止重复。这个机制是利用了数据库的主键唯一约束的特性, 解决了在 insert 场景时幂等问题。 但主键的要求不是自增的主键, 这样就需要业务生成全局唯一的主键。如果是分库分表场景下, 路由规则要保证相同请求下, 落地在同一个数据库和同一表中, 要不然数据库主键约束就不起效果了, 因为是不同的数据库和表主键不相关。

  • redis set 防重
    很多数据需要处理, 只能被处理一次, 比如我们可以计算数据的 MD5 将其放入 redis 的 set,每次处理数据, 先看这个 MD5 是否已经存在, 存在就不处理。

④ 防重表

使用订单号 orderNo 做为去重表的唯一索引, 把唯一索引插入去重表, 再进行业务操作, 且他们在同一个事务中。 这个保证了重复请求时, 因为去重表有唯一约束, 导致请求失败, 避免了幂等问题。 这里要注意的是, 去重表和业务表应该在同一库中, 这样就保证了在同一个事务, 即使业务操作失败了, 也会把去重表的数据回滚。 这个很好的保证了数据一致性。之前说的 redis 防重也算

⑤ 全局请求唯一 id

调用接口时, 生成一个唯一 id, redis 将数据保存到集合中(去重) , 存在即处理过。可以使用 nginx 设置每一个请求的唯一 id;proxy_set_header X-Request-Id $request_id;

5、令牌防止多次提交表单

订单结算页完成 :在订单结算页提交订单的相关数据
谷粒商城项目笔记总结(2/2)_第33张图片
① 为了防止订单的多次提交,需要保证接口的幂等性。这里使用令牌机制,在订单确认页到达之前,为订单生成一个令牌保证幂等性。给服务器和浏览器分别存放一个防重令牌 :

@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
    OrderConfirmVo confirmVo = new OrderConfirmVo();

    MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();

    // TODO p268 异步情况下保证主线程的 ThreadLocal 和附线程的用同一个线程
    RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();

    CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {
        RequestContextHolder.setRequestAttributes(requestAttributes);
        //1、远程查询所有会员的收货地址信息
        List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
        confirmVo.setMemberAddressVos(address);
    }, executor);
    
    CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
        RequestContextHolder.setRequestAttributes(requestAttributes);
        //2、远程查询购物车所有选中的购物项信息
        List<OrderItemVo> items = cartFeignService.getCurrentUserCartItem();
        confirmVo.setItems(items);
    }, executor).thenRunAsync(() -> {
        //获取全部商品的id
        List<OrderItemVo> items = confirmVo.getItems();
        List<Long> skuIds = items.stream().map(item -> {
            return item.getSkuId();
        }).collect(Collectors.toList());

        //5、远程查询商品库存信息
        R skuHasStock = wareFeignService.getSkuHasStock(skuIds);
        List<SkuStockVo> skuStockVos = skuHasStock.getData("data", new TypeReference<List<SkuStockVo>>() {});
        if (skuStockVos != null && skuStockVos.size() > 0) {
            //将skuStockVos集合转换为map
            Map<Long, Boolean> skuHasStockMap = skuStockVos.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
            confirmVo.setStocks(skuHasStockMap);
        }
    }, executor);

    //3、查询用户积分信息
    Integer integration = memberRespVo.getIntegration();
    confirmVo.setIntegration(integration);

    //4、价格数据自动计算

    //TODO 6、防重令牌(防止表单重复提交)
    //为用户设置一个token,三十分钟过期时间(存在redis)
    String token = UUID.randomUUID().toString().replace("-", "");
    redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX+memberRespVo.getId(),token,30, TimeUnit.MINUTES);
    confirmVo.setOrderToken(token);

    CompletableFuture.allOf(addressFuture, cartFuture).get();
    return confirmVo;
}

6、提交表单

1、订单提交的Vo数据

@Data
public class OrderSubmitVo {

    /** 收获地址的id **/
    private Long addrId;

    /** 支付方式 **/
    private Integer payType;

    //送货清单

    //无需提交要购买的商品,去购物车再获取一遍

    //优惠、发票

    /** 防重令牌 **/
    private String orderToken;

    /** 应付价格,验价 **/
    private BigDecimal payPrice;

    /** 订单备注 **/
    private String remarks;

    //用户相关的信息,直接去session中取出即可
}

2、提交订单的整体逻辑 :
谷粒商城项目笔记总结(2/2)_第34张图片

@PostMapping("/submitOrder")
public String submitOrder(OrderSubmitVo orderSubmitVo, Model model, RedirectAttributes redirectAttributes) {
    System.out.println("订单提交的数据:" + orderSubmitVo);
    try {
        //下单操作
        SubmitOrderResponseVo responseVo = orderService.submitOrder(orderSubmitVo);
        if (responseVo.getCode() == 0) {
            //下单成功来到支付选择页面
            model.addAttribute("submitOrderResp", responseVo);
            return "pay";
        } else {
            String msg = "下单失败,";
            switch (responseVo.getCode()) {
                case 1: msg += "令牌订单信息过期,请刷新再次提交"; break;
                case 2: msg += "订单商品价格发生变化,请确认后再次提交"; break;
                case 3: msg += "库存锁定失败,商品库存不足"; break;
            }
            redirectAttributes.addFlashAttribute("msg", msg);
            //下单失败回到订单确认页重新确认订单信息
            return "redirect:http://order.gulimall.com/toTrade";
        }
    } catch (Exception e) {
        if (e instanceof NoStockException) {
            String message = ((NoStockException) e).getMessage();
            redirectAttributes.addFlashAttribute("msg", message);
        }
        return "redirect:http://order.gulimall.com/toTrade";
    }
}
@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo orderSubmitVo) {
    //去创建订单,验令牌,验价格,锁库存....
    SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();
    responseVo.setCode(0);

    //保证同一线程数据是一致的
    confirmVoThreadLocal.set(orderSubmitVo);

    MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();

    //1、验证令牌【令牌的对比和删除必须保证原子性】
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    String orderToken = orderSubmitVo.getOrderToken();
    //通过lure脚本原子验证令牌和删除令牌
    Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
            Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId()), orderToken);
    if (result == 0L) {
        //验证令牌失败
        responseVo.setCode(1);
        return responseVo;
    } else {
        //验证令牌成功
        //2、创建订单、订单项等信息
        OrderCreateTo order = createOrder();

        //3、验证价格
        BigDecimal payAmount = order.getOrder().getPayAmount();
        BigDecimal payPrice = orderSubmitVo.getPayPrice();
        if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
            //金额对比
            //4、保存订单
            saveOrder(order);

            //5、库存锁定,只要有异常,回滚订单数据
            //订单号、所有订单项信息(skuId,skuNum,skuName)
            WareSkuLockVo lockVo = new WareSkuLockVo();
            lockVo.setOrderSn(order.getOrder().getOrderSn());

            //获取出要锁定的商品数据信息
            List<OrderItemVo> orderItemVos = order.getOrderItems().stream().map((item) -> {
                OrderItemVo orderItemVo = new OrderItemVo();
                orderItemVo.setSkuId(item.getSkuId());
                orderItemVo.setCount(item.getSkuQuantity());
                orderItemVo.setTitle(item.getSkuName());
                return orderItemVo;
            }).collect(Collectors.toList());
            lockVo.setLocks(orderItemVos);

            //TODO 调用远程锁定库存的方法【利用最终一致性】
            //出现的问题:扣减库存成功了,但是由于网络原因超时,出现异常,导致订单事务回滚,库存事务不回滚(解决方案:seata)
            //为了保证高并发,不推荐使用seata,因为是加锁,并行化,提升不了效率,可以发消息给库存服务
            R r = wareFeignService.orderLockStock(lockVo);
            if (r.getCode() == 0) {
                //锁定成功
                responseVo.setOrder(order.getOrder());

                // TODO 远程扣减积分
//                     int i = 10/0; //订单回滚,库存不回滚

                //订单创建成功,发送消息给MQ
                rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",order.getOrder());

                //删除购物车里的数据
                redisTemplate.delete(CartConstant.CART_PREFIX + memberRespVo.getId());
                return responseVo;
            } else {
                //锁定失败
                String msg = (String) r.get("msg");
                throw new NoStockException(msg);
                //responseVo.setCode(3);
                //return responseVo;
            }
        } else {
            //验价失败
            responseVo.setCode(2);
            return responseVo;
        }
    }
}

/**
 * 创建订单
 * @return
 */
private OrderCreateTo createOrder() {
    OrderCreateTo createTo = new OrderCreateTo();

    //生成订单号
    String orderSn = IdWorker.getTimeId();

    //1、构建订单数据
    OrderEntity orderEntity = builderOrder(orderSn);

    //2、获取到所有的订单项
    List<OrderItemEntity> orderItemEntities = builderOrderItems(orderSn);

    //3、验价(计算价格、积分等信息)
    computePrice(orderEntity, orderItemEntities);

    createTo.setOrder(orderEntity);
    createTo.setOrderItems(orderItemEntities);

    return createTo;
}

/**
 * 构建订单数据
 * @param orderSn 订单号
 * @return
 */
private OrderEntity builderOrder(String orderSn) {
    //获取当前用户登录信息
    MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();

    OrderEntity orderEntity = new OrderEntity();
    orderEntity.setMemberId(memberRespVo.getId());
    orderEntity.setOrderSn(orderSn);
    orderEntity.setMemberUsername(memberRespVo.getUsername());

    OrderSubmitVo orderSubmitVo = confirmVoThreadLocal.get();

    //远程获取收货地址和运费信息
    R fareAddressVo = wareFeignService.getFare(orderSubmitVo.getAddrId());
    FareVo fareResp = fareAddressVo.getData("data", new TypeReference<FareVo>() {});

    //获取到运费信息
    orderEntity.setFreightAmount(fareResp.getFare());

    //获取到收货地址信息
    MemberAddressVo address = fareResp.getAddress();
    //设置收货人信息
    orderEntity.setReceiverName(address.getName());
    orderEntity.setReceiverPhone(address.getPhone());
    orderEntity.setReceiverPostCode(address.getPostCode());
    orderEntity.setReceiverProvince(address.getProvince());
    orderEntity.setReceiverCity(address.getCity());
    orderEntity.setReceiverRegion(address.getRegion());
    orderEntity.setReceiverDetailAddress(address.getDetailAddress());

    //设置订单相关的状态信息
    orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());
    orderEntity.setAutoConfirmDay(7);
    orderEntity.setConfirmStatus(0);

    return orderEntity;
}

/**
 * 构建所有订单项数据
 * @return
 */
public List<OrderItemEntity> builderOrderItems(String orderSn) {
    List<OrderItemEntity> orderItemEntityList = new ArrayList<>();

    //最后确定每个购物项的价格
    List<OrderItemVo> currentCartItems = cartFeignService.getCurrentUserCartItem();
    if (currentCartItems != null && currentCartItems.size() > 0) {
        orderItemEntityList = currentCartItems.stream().map((items) -> {
            //构建订单项数据
            OrderItemEntity orderItemEntity = builderOrderItem(items);
            orderItemEntity.setOrderSn(orderSn);//设置订单号

            return orderItemEntity;
        }).collect(Collectors.toList());
    }

    return orderItemEntityList;
}

/**
 * 构建某一个订单项的数据
 * @param items
 * @return
 */
private OrderItemEntity builderOrderItem(OrderItemVo items) {

    OrderItemEntity orderItemEntity = new OrderItemEntity();

    //1、商品的spu信息
    Long skuId = items.getSkuId();
    //获取spu的信息
    R spuInfo = productFeignService.getSpuInfoBySkuId(skuId);
    SpuInfoVo spuInfoData = spuInfo.getData("data", new TypeReference<SpuInfoVo>() {
    });
    orderItemEntity.setSpuId(spuInfoData.getId());
    orderItemEntity.setSpuName(spuInfoData.getSpuName());
    orderItemEntity.setSpuBrand(spuInfoData.getBrandName());
    orderItemEntity.setCategoryId(spuInfoData.getCatalogId());

    //2、商品的sku信息
    orderItemEntity.setSkuId(skuId);
    orderItemEntity.setSkuName(items.getTitle());
    orderItemEntity.setSkuPic(items.getImage());
    orderItemEntity.setSkuPrice(items.getPrice());
    orderItemEntity.setSkuQuantity(items.getCount());
    //使用StringUtils.collectionToDelimitedString将list集合转换为String
    String skuAttrValues = StringUtils.collectionToDelimitedString(items.getSkuAttrValues(), ";");
    orderItemEntity.setSkuAttrsVals(skuAttrValues);

    //3、商品的优惠信息

    //4、商品的积分信息
    orderItemEntity.setGiftGrowth(items.getPrice().multiply(new BigDecimal(items.getCount())).intValue());
    orderItemEntity.setGiftIntegration(items.getPrice().multiply(new BigDecimal(items.getCount())).intValue());

    //5、订单项的价格信息
    orderItemEntity.setPromotionAmount(BigDecimal.ZERO);
    orderItemEntity.setCouponAmount(BigDecimal.ZERO);
    orderItemEntity.setIntegrationAmount(BigDecimal.ZERO);

    //当前订单项的实际金额.总额 - 各种优惠价格
    //原来的价格
    BigDecimal origin = orderItemEntity.getSkuPrice().multiply(new BigDecimal(orderItemEntity.getSkuQuantity().toString()));
    //原价减去优惠价得到最终的价格
    BigDecimal subtract = origin.subtract(orderItemEntity.getCouponAmount())
            .subtract(orderItemEntity.getPromotionAmount())
            .subtract(orderItemEntity.getIntegrationAmount());
    orderItemEntity.setRealAmount(subtract);

    return orderItemEntity;
}

/**
 * 计算价格的方法
 * @param orderEntity
 * @param orderItemEntities
 */
private void computePrice(OrderEntity orderEntity, List<OrderItemEntity> orderItemEntities) {
    //总价
    BigDecimal total = new BigDecimal("0.0");
    //优惠价
    BigDecimal coupon = new BigDecimal("0.0");
    BigDecimal intergration = new BigDecimal("0.0");
    BigDecimal promotion = new BigDecimal("0.0");

    //积分、成长值
    Integer integrationTotal = 0;
    Integer growthTotal = 0;

    //订单总额,叠加每一个订单项的总额信息
    for (OrderItemEntity orderItem : orderItemEntities) {
        //优惠价格信息
        coupon = coupon.add(orderItem.getCouponAmount());
        promotion = promotion.add(orderItem.getPromotionAmount());
        intergration = intergration.add(orderItem.getIntegrationAmount());

        //总价
        total = total.add(orderItem.getRealAmount());

        //积分信息和成长值信息
        integrationTotal += orderItem.getGiftIntegration();
        growthTotal += orderItem.getGiftGrowth();
    }

    //1、订单价格相关的
    orderEntity.setTotalAmount(total);
    //设置应付总额(总额+运费)
    orderEntity.setPayAmount(total.add(orderEntity.getFreightAmount()));
    orderEntity.setCouponAmount(coupon);
    orderEntity.setPromotionAmount(promotion);
    orderEntity.setIntegrationAmount(intergration);

    //设置积分成长值信息
    orderEntity.setIntegration(integrationTotal);
    orderEntity.setGrowth(growthTotal);

    //设置删除状态(0-未删除,1-已删除)
    orderEntity.setDeleteStatus(0);
}

/**
 * 保存订单所有数据
 * @param orderCreateTo
 */
private void saveOrder(OrderCreateTo orderCreateTo) {
    //获取订单信息
    OrderEntity order = orderCreateTo.getOrder();
    order.setModifyTime(new Date());
    order.setCreateTime(new Date());
    //保存订单
    this.baseMapper.insert(order);

    //获取订单项信息
    List<OrderItemEntity> orderItems = orderCreateTo.getOrderItems();
    //批量保存订单项数据
    orderItemService.saveBatch(orderItems);
}

3、远程锁定库存的逻辑
谷粒商城项目笔记总结(2/2)_第35张图片

@Transactional
@Override
public Boolean orderLockStock(WareSkuLockVo wareSkuLockVo) {
    // 保存库存工作单的详情
    WareOrderTaskEntity wareOrderTaskEntity = new WareOrderTaskEntity();
    wareOrderTaskEntity.setOrderSn(wareSkuLockVo.getOrderSn());
    wareOrderTaskService.save(wareOrderTaskEntity);

    // 按照下单的收获地址,找到一个就近仓库,锁定库存
    // 1、找到每个商品在哪个仓库都有库存
    List<OrderItemVo> orderItemVos = wareSkuLockVo.getLocks();
    List<SkuWareHasStock> collect = orderItemVos.stream().map(item -> {
        SkuWareHasStock stock = new SkuWareHasStock();
        Long skuId = item.getSkuId();
        stock.setSkuId(skuId);
        stock.setNum(item.getCount());
        //查询这个商品在哪里有库存
        List<Long> wareIds = wareSkuDao.listWareIdHasSkuStock(skuId);
        stock.setWareId(wareIds);
        return stock;
    }).collect(Collectors.toList());

    //锁定库存
    for (SkuWareHasStock hasStock : collect) {
        Boolean skuStocked = false;
        Long skuId = hasStock.getSkuId();
        List<Long> wareIds = hasStock.getWareId();
        if (wareIds == null || wareIds.size() == 0) {
            //没有任何仓库有这个商品的库存
            throw new NoStockException(skuId);
        }
        // 减库存
        for (Long wareId : wareIds) {
            Long count = wareSkuDao.lockSkuStock(skuId, wareId, hasStock.getNum());
            if (count == 1) {
                //成功
                skuStocked = true;
                // 保存库存工作单详情
                WareOrderTaskDetailEntity wareOrderTaskDetailEntity = new WareOrderTaskDetailEntity();
                wareOrderTaskDetailEntity.setSkuId(skuId);
                wareOrderTaskDetailEntity.setSkuNum(hasStock.getNum());
                wareOrderTaskDetailEntity.setTaskId(wareOrderTaskEntity.getId());
                wareOrderTaskDetailEntity.setWareId(wareId);
                wareOrderTaskDetailEntity.setLockStatus(WareStatusEnum.LOCK_WARE.getCode());
                wareOrderTaskDetailService.save(wareOrderTaskDetailEntity);

                //将库存锁定成功的消息发给消息队列
                StockLockedTo stockLockedTo = new StockLockedTo();
                stockLockedTo.setId(wareOrderTaskEntity.getId());
                StockDetailTo stockDetailTo = new StockDetailTo();
                BeanUtils.copyProperties(wareOrderTaskDetailEntity, stockDetailTo);
                stockLockedTo.setDetail(stockDetailTo);
                //告诉MQ库存锁定成功
                rabbitTemplate.convertAndSend("stock-event-exchange","stock.locked",stockLockedTo);
                break;
            } else {
                //失败,当前仓库锁定失败,重试下一个仓库
            }
        }
        if (skuStocked == false) {
            //当前商品所有仓库都没锁住
            throw new NoStockException(skuId);
        }
    }
    // 全部锁定成功
    return true;
}

商城业务 - 分布式事务

1、本地事务

① 数据库事务的几个特性: 原子性 、 一致性 、 隔离性和持久性, 简称就是 ACID;

原子性: 一系列的操作整体不可拆分, 要么同时成功, 要么同时失败
一致性: 数据在事务的前后, 业务整体一致。
隔离性: 事务之间互相隔离。
持久性: 一旦事务成功, 数据一定会落盘在数据库

② 在以往的单体应用中, 我们多个业务操作使用同一条连接操作不同的数据表, 一旦有异常,我们可以很容易的整体回滚

比如买东西业务, 扣库存, 下订单, 账户扣款, 是一个整体; 必须同时成功或者失败,一个事务开始, 代表以下的所有操作都在同一个连接里面;

Business: 我们具体的业务代码。Storage: 库存业务代码; 扣库存。Order: 订单业务代码; 保存订单。Account: 账号业务代码; 减账户余额

③ 事务的隔离级别 :

  • READ UNCOMMITTED(读未提交):该隔离级别的事务会读到其它未提交事务的数据, 此现象也称之为脏读。
  • READ COMMITTED( 读提交):一个事务可以读取另一个已提交的事务, 多次读取会造成不一样的结果, 此现象称为不可重复读问题, Oracle 和 SQL Server 的默认隔离级别。
  • REPEATABLE READ( 可重复读):该隔离级别是 MySQL 默认的隔离级别, 在同一个事务里, select 的结果是事务开始时时间点的状态, 因此, 同样的 select 操作读到的结果会是一致的, 但是, 会有幻读现象。 MySQL的 InnoDB 引擎可以通过 next-key locks 机制( 参考下文"行锁的算法"一节) 来避免幻读。
  • SERIALIZABLE( 序列化):在该隔离级别下事务都是串行顺序执行的, MySQL 数据库的 InnoDB 引擎会给读操作隐式加一把读共享锁, 从而避免了脏读、 不可重读复读和幻读问题

④ 事务的传播行为:

  • PROPAGATION_REQUIRED: 如果当前没有事务, 就创建一个新事务, 如果当前存在事务,就加入该事务, 该设置是最常用的设置
  • PROPAGATION_SUPPORTS: 支持当前事务, 如果当前存在事务, 就加入该事务, 如果当前不存在事务, 就以非事务执行。
  • PROPAGATION_MANDATORY: 支持当前事务, 如果当前存在事务, 就加入该事务, 如果当前不存在事务, 就抛出异常。
  • PROPAGATION_REQUIRES_NEW: 创建新事务, 无论当前存不存在事务, 都创建新事务。
  • PROPAGATION_NOT_SUPPORTED: 以非事务方式执行操作, 如果当前存在事务, 就把当前事务挂起
  • PROPAGATION_NEVER: 以非事务方式执行, 如果当前存在事务, 则抛出异常。
  • PROPAGATION_NESTED: 如果当前存在事务, 则在嵌套事务内执行。 如果当前没有事务,则执行与PROPAGATION_REQUIRED 类似的操作。

2、本地事务在分布式下的问题

谷粒商城项目笔记总结(2/2)_第36张图片
业务描述:创建好订单后,需要进行下单,下单完成后远程调用gulimall-ware库存服务,远程锁库存,库存锁定成功后又需要远程调用gulimall-member服务,远程扣减积分,而且整个过程是处在本地事务@Transactional中的,那么这样会出现怎么样的问题?本地事务能解决什么问题??

① 订单服务异常,库存锁定不运行,全部回滚,撤销操作

② 库存服务处异常,库存服务事务自治,锁定失败全部回滚,订单感受到,继续回滚

存在的问题

③ 库存服务锁定成功了,但是网络原因返回数据途中出现问题,远程调用超时抛出异常,因此订单回滚,那么就会出现一个问题,库存扣除成功,但是订单没有下单成功。

④ 库存服务锁定成功了,库存服务下面的逻辑(远程扣积分)发生故障,订单回滚了,怎么处理 ?

订单服务连接的是订单数据库,这是一个连接,库存服务连接的是库存数据库,这是一个新的连接,会员服务链接的是会员数据库,这也是一个新的连接。远程调用实际上是一个新的连接,会员服务发生异常,库存服务是感知不到的,已经执行成功的请求是不能回滚的。

远程服务假失败:远程服务其实成功了,由于网络故障等没有返回,导致:订单回滚,库存却扣减
远程服务执行完成,下面的其他方法出现问题,导致:已执行的远程请求,肯定不能回滚

本地事务只能控制住在同一个连接中的异常,在分布式系统中,A服务远程调用B服务,B服务远程调用C服务,C服务远程调用D服务,任何一个远程服务出现问题,已经成功执行的远程服务没办法通过Transactional来实现事务的回滚,除非这几个服务不是远程服务,操作的是同一个数据库,在同一个连接内。

本地事务在分布式系统下,只能控制住自己数据库的回滚,控制不了其他服务的数据库的回滚。分布式事务的问题:网络问题+分布式机器(数据库不是同一个)。

3、分布式事务理论

1、为什么有分布式事务?

分布式系统经常出现的异常:机器宕机、 网络异常、 消息丢失、 消息乱序、 数据错误、 不可靠的 TCP、 存储数据丢失 。。。。。由于以上问题都会导致分布式系统下,某一个服务的状态不能被其他服务感知到。

分布式事务是企业集成中的一个技术难点, 也是每一个分布式系统架构中都会涉及到的一个东西, 特别是在微服务架构中, 几乎可以说是无法避免。
谷粒商城项目笔记总结(2/2)_第37张图片

2、CAP 定理

CAP 原则又称 CAP 定理, 指的是在一个分布式系统中。

一致性 :在分布式系统中的所有数据备份, 在同一时刻是否都有同样的值。
可用性 :在集群中一部分节点故障后, 集群整体是否还能响应客户端的读写请求。
分区容错性 :大多数分布式系统都分布在多个子网络。 每个子网络就叫做一个区 。分区容错的意思是, 区间通信可能失败。 比如, 一台服务器放在中国, 另一台服务器放在美国, 这就是两个区, 它们之间可能无法通信。

CAP 原则指的是, 这三个要素最多只能同时实现两点, 不可能三者兼顾

一般来说, 分区容错无法避免, 因此CAP 的 P 总是成立。 CAP 定理告诉我们,剩下的 C 和 A 无法同时做到。

分布式系统中实现一致性的 raft 算法:http://thesecretlivesofdata.com/raft/ (领导选举,日志复制)

3、base理论

对于多数大型互联网应用的场景, 主机众多、 部署分散, 而且现在的集群规模越来越大, 所以节点故障、 网络故障是常态, 而且要保证服务可用性达到 99.99999%(N 个 9) , 即保证P 和 A, 舍弃 C。

是对 CAP 理论的延伸, 思想是即使无法做到强一致性(CAP 的一致性就是强一致性) , 但可以采用适当的采取弱一致性, 即最终一致性

① 基本可用(Basically Available):

基本可用是指分布式系统在出现故障的时候, 允许损失部分可用性(例如响应时间、功能上的可用性) , 允许损失部分可用性。 需要注意的是, 基本可用绝不等价于系统不可用。

响应时间上的损失: 正常情况下搜索引擎需要在 0.5 秒之内返回给用户相应的查询结果, 但由于出现故障(比如系统部分机房发生断电或断网故障) , 查询结果的响应时间增加到了 1~2 秒。

功能上的损失: 购物网站在购物高峰(如双十一) 时, 为了保护系统的稳定性,部分消费者可能会被引导到一个降级页面。

② 软状态( Soft State):

软状态是指允许系统存在中间状态, 而该中间状态不会影响系统整体可用性。 分布式存储中一般一份数据会有多个副本, 允许不同副本同步的延时就是软状态的体现。 mysql replication 的异步复制也是一种体现。

③ 最终一致性( Eventual Consistency):

最终一致性是指系统中的所有数据副本经过一定时间后, 最终能够达到一致的状态。 弱一致性和强一致性相反, 最终一致性是弱一致性的一种特殊情况

从客户端角度, 多进程并发访问时, 更新过的数据在不同进程如何获取的不同策略, 决定了不同的一致性。 对于关系型数据库, 要求更新过的数据能被后续的访问都能看到, 这是强一致性。 如果能容忍后续的部分或者全部访问不到, 则是弱一致性。 如果经过一段时间后要求能访问到更新后的数据, 则是最终一致性 。

4、分布式事务常见解决方案

1、2PC 模式

数据库支持的 2PC【 2 phase commit 二阶提交】 , 又叫做 XA Transactions。其中, XA 是一个两阶段提交协议, 该协议分为以下两个阶段:
第一阶段: 事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作, 并反映是否可以提交。
第二阶段: 事务协调器要求每个数据库提交数据。
其中, 如果有任何一个数据库否决此次提交, 那么所有数据库都会被要求回滚它们在此事务中的那部分信息 。
谷粒商城项目笔记总结(2/2)_第38张图片
XA 协议比较简单, 而且一旦商业数据库实现了 XA 协议, 使用分布式事务的成本也比较低。
XA 性能不理想, 特别是在交易下单链路, 往往并发量很高, XA 无法满足高并发场景
XA 目前在商业数据库支持的比较理想, 在 mysql 数据库中支持的不太理想, mysql 的
XA 实现, 没有记录 prepare 阶段日志, 主备切换回导致主库与备库数据不一致。
许多 nosql 也没有支持 XA, 这让 XA 的应用场景变得非常狭隘。

2、柔性事务-TCC 事务补偿型方案

刚性事务: 遵循 ACID 原则, 强一致性。
柔性事务: 遵循 BASE 理论, 最终一致性;
与刚性事务不同, 柔性事务允许一定时间内, 不同节点的数据不一致, 但要求最终一致。
谷粒商城项目笔记总结(2/2)_第39张图片
一阶段 prepare 行为: 调用 自定义 的 prepare 逻辑。
二阶段 commit 行为: 调用 自定义 的 commit 逻辑。
二阶段 rollback 行为: 调用 自定义 的 rollback 逻辑。
所谓 TCC 模式, 是指支持把 自定义 的分支事务纳入到全局事务的管理中。
谷粒商城项目笔记总结(2/2)_第40张图片
3、柔性事务-最大努力通知型方案

按规律进行通知, 不保证数据一定能通知成功, 但会提供可查询操作接口进行核对。 这种方案主要用在与第三方系统通讯时, 比如: 调用微信或支付宝支付后的支付结果通知。 这种方案也是结合 MQ 进行实现, 例如: 通过 MQ 发送 http 请求, 设置最大通知次数。 达到通知次数后即不再通知。

案例: 银行通知、 商户通知等( 各大交易业务平台间的商户通知: 多次通知、 查询校对、 对账文件) ,支付宝的支付成功异步回调

4、柔性事务-可靠消息+最终一致性方案( 异步确保型)

实现: 业务处理服务在业务事务提交之前, 向实时消息服务请求发送消息, 实时消息服务只记录消息数据, 而不是真正的发送。 业务处理服务在业务事务提交之后, 向实时消息服务确认发送。 只有在得到确认发送指令后, 实时消息服务才会真正发送。

5、分布式事务Seata【会存在bug 可以略过】

seata使用的2PC模式。

TC负责协调全局、TM用来控制整个大的事务、每一个微服务中使用RM这个资源管理器来控制的

① TM(下单业务)首先会告诉TC,准备开启一个全局事务

② TM调用远程服务后,不论是成功还是失败,TC都知道

③ 假如一个小事务出现异常回滚了,那么之前成功的事务也要回滚
谷粒商城项目笔记总结(2/2)_第41张图片
1、给每一个服务创建一个undo_log表

CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  `ext` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

2、在需要使用分布式事务的服务引入 spring-cloud-starter-alibaba-seata 依赖

3、启动服务器:安装事务协调器

因为导入依赖的版本为 seata-all:0.7.1 ,所以需要从https://github.com/seata/seata/releases下载v0.7.1服务器软件包,将其解压缩,并运行:
在这里插入图片描述
4、在 registry.conf 文件中指明seata配置中心地址为nacos :

registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa 
  type = "nacos"

  nacos {
    serverAddr = "localhost:8848"
    namespace = "public"
    cluster = "default"
  }
}

5、给主启动类上标注 @GlobalTransactional注解 ,每个小事务使用@Transactional即可

6、需要注入DataSourceProxy代理自己的数据源,因为 Seata 通过代理数据源实现分支事务,如果没有注入,事务无法成功回滚

@Configuration
public class MySeataConfig {

    @Autowired
    DataSourceProperties dataSourceProperties;

    @Bean
    public DataSource dataSource(DataSourceProperties dataSourceProperties){
        HikariDataSource dataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
        if (StringUtils.hasText(dataSourceProperties.getName())) {
            dataSource.setPoolName(dataSourceProperties.getName());
        }
        return new DataSourceProxy(dataSource);
    }
}

7、每个需要用分布式事务的微服务都必须导入file.conf和registry.conf ,(gulimall-order和gulimall-ware)且 file.conf 的 service.vgroup_mapping 配置必须和spring.application.name一致

因为每个服务默认会使用 ${spring.application.name}-fescar-service-group作为服务名注册到 Seata Server上,如果和file.conf中的配置不一致,会提示 no available server to connect 错误

也可以通过配置 spring.cloud.alibaba.seata.tx-service-group 修改后缀,但是必须和file.conf 中的配置保持一致

service {
  # vgroup->rgroup
  # vgroup_mapping.my_test_tx_group = "default"
  vgroup_mapping.gulimall-order-fescar-service-group = "default"
}
service {
  #vgroup->rgroup
  vgroup_mapping.gulimall-ware-fescar-service-group  = "default"
}

8、订单服务远程调用仓储服务,仓储服务远程调用扣减积分服务,其中扣减积分这模拟一个异常,如果没有加@GlobalTransactional,那么出现异常时订单会回滚,但是仓库锁定会锁定失败,如果加了@GlobalTransactional,仓储服务和订单服务都会回滚

注意:整合seata会出现很多问题,可以参考 https://blog.csdn.net/weixin_45606067/article/details/121317918

6、最终一致性库存解锁逻辑

问题 :Seata的分布式事务使用的AT模式 ,下订单是一个高并发的操作,不太适合seata分布式的分布式事务(使用了各种锁,效率太低)。

在高并发场景下,库存的回滚使用 :柔性事务-最大努力通知型方案 或 柔性事务-可靠消息+最终一致性方案( 异步确保型)

谷粒商城项目笔记总结(2/2)_第42张图片

7、RabbitMQ延时队列

场景:比如未付款订单,超过一定时间后,系统自动取消订单并释放占有物品。
常用解决方案:spring的 schedule 定时任务轮询数据库
缺点:消耗系统内存、增加了数据库的压力、存在较大的时间误差
解决:rabbitmq的消息TTL和死信Exchange结合
谷粒商城项目笔记总结(2/2)_第43张图片
消息的TTL就是消息的存活时间 ,RabbitMQ可以对队列和消息分别设置TTL。

  • 队列设置就是队列没有消费者连着的保留时间,也可以对每一个单独的消息做单独的设置。超过了这个时间,我们认为这个消息就死了,称之为死信

  • 如果队列设置了,消息也设置了,那么会取小的。所以一个消息如果被路由到不同的队列中,这个消息死亡的时间有可能不一样(不同的队列设置)。这里单讲单个消息的TTL,因为它才是实现延迟任务的关键。可以通过设置消息的expiration字段或者xmessage-ttl属性来设置时间,两者是一样的效果。

  • 一个消息在满足如下条件下,会进死信路由,记住这里是路由而不是队列,一个路由可以对应很多队列:

    • 一个消息被Consumer拒收了,并且reject方法的参数里requeue是false。也就是说不会被再次放在队列里,被其他消费者使用。(basic.reject/ basic.nack)requeue=false
    • 上面的消息的TTL到了,消息过期了。
    • 队列的长度限制满了。排在前面的消息会被丢弃或者扔到死信路由上。
  • Dead Letter Exchange其实就是一种普通的exchange,和创建其他exchange没有两样。只是在某一个设置Dead Letter Exchange的队列中有消息过期了,会自动触发消息的转发,发送到Dead Letter Exchange中去。

  • 我们既可以控制消息在一段时间后变成死信,又可以控制变成死信的消息被路由到某一个指定的交换机,结合二者,其实就可以实现一个延时队列

  • 手动ack&异常消息统一放在一个队列处理建议的两种方式

    • catch异常后,手动发送到指定队列,然后使用channel给rabbitmq确认消息已消费
    • 给Queue绑定死信队列,使用nack(requque为false)确认消息消费失败
      谷粒商城项目笔记总结(2/2)_第44张图片

延时队列实现方式1 :设置队列的过期时间

消息首先交给交换机,交换机按照路由键发给指定的队列,这个队列设置了过期时间,以及死信路由键,由于没有消费者这监听这个队列,当消息死了扔给指定的队列
谷粒商城项目笔记总结(2/2)_第45张图片

延时队列实现方式2 :设置消息的过期时间

发送消息的时候,单独为消息设置过期时间,消息经过交换机发给延时队列,由于没有消费者这监听这个队列,消息过期之后就会发给死信交换机,通过交换机发给指定的队列。
谷粒商城项目笔记总结(2/2)_第46张图片
模拟关单简单方式

创建两个交换机 :user.order.delay.exchangeuser.order.exchange ,这两个交换机各绑定了一个队列,其中死信队列:user.order.delay.queue 是没有消费者监听的,user.order.queue是有消费者监听的,当订单服务创建一个订单后会将消息发送给user.order.delay.exchange交换机,这个交换机经过指定的路由键order_delay发给user.order.delay.queue队列,由于队列的过期时间x-message-ttl=60000,即为1分钟,当1分钟之后,队列就会过期变为死信,交给x-dead-letter-exchange: user.order.exchange ,通过路由键x-dead-letter-routing-key: order 发给指定的队列user.order.queue
谷粒商城项目笔记总结(2/2)_第47张图片
模拟关单升级方式

消息创建成功后先按照 order.create.order 找到对应的交换机 orer-event-exchange,在按照 order.create.order 找到对应的队列 order.delay.queue ,这个队列是一个延时队列,设置了三个参数:x-dead-letter-exchange: order-event-exchange ,当队列中的消息经过x-message-ttl: 60000 时间后变成死信,然后通过x-dead-letter-routing-key: order.release.order找到这个队列order.release.order,将死信交给它 。
谷粒商城项目笔记总结(2/2)_第48张图片
创建交换机,队列,以及绑定关系:

@Configuration
public class MyMQConfig {

    /**
     * 死信队列
     * @return
     */
    @Bean
    public Queue orderDelayOrderQueue() {
        Map<String, Object> arguments = new HashMap<>();
        /**
         * x-dead-letter-exchange: order-event-exchange
         * x-dead-letter-routing-key: order.release.order
         * x-message-ttl: 60000
         */
        arguments.put("x-dead-letter-exchange", "order-event-exchange");
        arguments.put("x-dead-letter-routing-key", "order.release.order");
        arguments.put("x-message-ttl", 120000);//两分钟
        //String name, boolean durable, boolean exclusive, boolean autoDelete, Map arguments
        Queue queue = new Queue("order.delay.queue", true, false, false, arguments);
        return queue;
    }

    /**
     * 队列
     * @return
     */
    @Bean
    public Queue orderReleaseOrderQueue() {
        Queue queue = new Queue("order.release.order.queue", true, false, false);
        return queue;
    }

    /**
     * 交换机
     * @return
     */
    @Bean
    public Exchange orderEventExchange() {
        //String name, boolean durable, boolean autoDelete, Map arguments
        return new TopicExchange("order-event-exchange", true, false);
    }

    /**
     * 绑定关系
     * @return
     */
    @Bean
    public Binding orderCreateOrderBinding() {
        //String destination, DestinationType destinationType, String exchange, String routingKey,Map arguments
        return new Binding("order.delay.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.create.order",
                null);
    }

    /**
     * 绑定关系
     * @return
     */
    @Bean
    public Binding orderReleaseOrderBinding() {
        return new Binding("order.release.order.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.order",
                null);
    }

    /**
     * 订单释放直接和库存释放进行绑定
     * @return
     */
    @Bean
    public Binding orderReleaseOtherBingding() {
        return new Binding("stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.other.#",
                null);
    }
}

创建订单并给消息队列发送消息测试:

@Controller
public class HelloController {
    @Autowired
    RabbitTemplate rabbitTemplate;

    @ResponseBody
    @GetMapping("/test/createOrder")
    public String createOrderTest(){
        //订单下单成功
        OrderEntity entity = new OrderEntity();
        entity.setOrderSn(UUID.randomUUID().toString());
        entity.setModifyTime(new Date());

        //给MQ发送消息。
        rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",entity);
        return "ok";
    }
}

访问 :http://order.gulimall.com/test/createOrder,发现消息从消息队列order.create.order中到了消息队列order.release.order.queue中。

商城业务 - 库存的解锁

1、创建路由交换机和队列

gulimall-ware服务整合RabbitMQ

① 导入rabbitmq 依赖

② appiliation.properties中配置rabbitmq的参数

③ 创建路由交换机、队列和绑定关系
谷粒商城项目笔记总结(2/2)_第49张图片库存锁定成功后,会根据路由键 stock.locked 根据交换机stock-event-exchange,交换机找到队列stock.delay.queue,并将消息发送到消息队列,由于这个队列是延时队列,50min之后队列中的消息变成死信,然后按照路由键stock.release根据交换机stock-event-exchange找到队列stock.release.stock.queue,将消息发送到该队列,然后接下来的解锁库存服务就来处理stock.release.stock.queue中的消息,因为这个队列中的信息都是超时的死信。

@Configuration
public class MyRabbitConfig {
    @Bean
    public MessageConverter messageConverter() {
        return new Jackson2JsonMessageConverter();
    }

    @Bean
    public Exchange stockEventExchange() {
        //String name, boolean durable, boolean autoDelete, Map arguments
        return new TopicExchange("stock-event-exchange", true, false);
    }

    @Bean
    public Queue stockReleaseStockQueue() {
        //String name【名字】, boolean durable【是否持久化】, boolean exclusive【是否排他】, boolean autoDelete【是否自动删除】, Map arguments【参数】
        return new Queue("stock.release.stock.queue", true, false, false);
    }

    @Bean
    public Queue stockDelayQueue() {
        Map<String, Object> args = new HashMap<>();
        args.put("x-dead-letter-exchange", "stock-event-exchange");
        args.put("x-dead-letter-routing-key", "stock.release");
        args.put("x-message-ttl", 240000);// 4min
        return new Queue("stock.delay.queue", true, false, false, args);
    }

    @Bean
    public Binding stockReleaseBinding() {
        return new Binding("stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "stock-event-exchange",
                "stock.release.#",
                null);
    }

    @Bean
    public Binding stockLockedBinding() {
        return new Binding("stock.delay.queue",
                Binding.DestinationType.QUEUE,
                "stock-event-exchange",
                "stock.locked",
                null);
    }
}

启动服务gulimall-ware,从而可以看到rabbitmq中创建了交换机和队列 :
在这里插入图片描述

2、库存自动解锁

库存解锁的场景 :

  • 下订单成功,订单过期没有支付被系统自动取消,被用户手动取消,都要解锁库存。
  • 下订单成功,库存锁定成功,但是接下来的业务调用失败,导致订单回滚,之前锁定的库存就要解锁。

步骤:

锁库存----》保存库存工作单----》判断库存是否锁定成功,如果锁定成功保存库存工作单详情,然后将库存锁定成功的消息发给消息队列-----》如果有一个没有锁定成功,就要将之前锁定成功的库存全部解锁

//只要解锁库存的消息失败,一定要告诉服务解锁失败。【启用手动ack】
@Override
public void unlockStock(StockLockedTo stockLockedTo) {
   StockDetailTo detail = stockLockedTo.getDetail();
   Long detailId = detail.getId();
   /**
    * 去库存锁定工作单详情查询数据库关于这个订单的锁定库存信息
    *     如果没有这个信息,说明库存锁定失败了,这个商品的库存锁定回滚了,就不需要解锁。
    *     如果有这个信息,说明这个商品的库存锁定成功了,由于其他业务的失败导致订单回滚了
    */
   WareOrderTaskDetailEntity wareOrderTaskDetailEntity = wareOrderTaskDetailService.getById(detailId);
   if(wareOrderTaskDetailEntity!=null){
       /**
        * 解锁:判断订单情况
        *    没有这个订单,必须解锁
        *    有这个订单,判断订单状态:
        *          订单状态为已取消,解锁库存,
        *          没取消订单,不用解锁
        */
       // 库存工作单id
       Long id = stockLockedTo.getId();
       WareOrderTaskEntity wareOrderTaskEntity = wareOrderTaskService.getById(id);
       //订单号
       String orderSn = wareOrderTaskEntity.getOrderSn();
       //根据订单号查询订单的状态
       R r = orderFeignService.getOrderStatus(orderSn);
       if(r.getCode() == 0){
           OrderVo data = r.getData(new TypeReference<OrderVo>() {});
           if(data == null || data.getStatus() == OrderStatusEnum.CANCLED.getCode()){
               //订单不存在 或 订单已经被取消,解锁库存
               if(wareOrderTaskDetailEntity.getLockStatus() == WareStatusEnum.LOCK_WARE.getCode()){
                   //当前库存工作单详情,状态1已锁定 但是未解锁才可以解锁
                   unLockStock(detail.getSkuId(), detail.getWareId(), detail.getSkuNum(), detailId);
               }
           }
       }else{
           //消息拒绝之后重新放到队列,让别人继续消费解锁
           throw new RuntimeException("远程服务失败");
       }
   }else{
       //不需要解锁
   }
}

/**
 * 解锁库存的方法
 * @param skuId
 * @param wareId
 * @param num
 * @param taskDetailId
 */
private void unLockStock(Long skuId, Long wareId, Integer num, Long taskDetailId) {
    //解锁库存
    wareSkuDao.unLockStock(skuId, wareId, num);

    //更新工作单的状态
    WareOrderTaskDetailEntity taskDetailEntity = new WareOrderTaskDetailEntity();
    taskDetailEntity.setId(taskDetailId);
    taskDetailEntity.setLockStatus(WareStatusEnum.UNLOCK_WARE.getCode());//变为已解锁
    wareOrderTaskDetailService.updateById(taskDetailEntity);
}

监听 stock.release.stock.queue 队列,对库存进行解锁:

@RabbitListener(queues = "stock.release.stock.queue")
@Service
public class StockReleaseListener {

    @Autowired
    WareSkuService wareSkuService;

    @RabbitHandler
    public void handleStockLockedRelease(StockLockedTo stockLockedTo, Message message, Channel channel) throws IOException {
        System.out.println("收到解锁库存的消息...");
        try {
            wareSkuService.unlockStock(stockLockedTo);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            //消息拒绝之后重新放到队列,让别人继续消费解锁
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }
}

3、定时关单和手动库存解锁

订单创建成功后,如果30分钟没有支付,那么系统就要自动取消订单

首先订单创建成功后,先给交换机发送一个消息(创建成功的订单消息),交换机通过路由键将消息发送给延时队列,消息30min之后过期,过期的消息会通过交换机和路由键发送给队列order.release.order.queue,从而让没有支付的订单消息关闭。
谷粒商城项目笔记总结(2/2)_第50张图片
① 订单创建成功后就会给 order-event-exchange交换机发送消息 ,通过路由键发送给延时队列 order.delay.queue,延时队列中的消息一旦过过期就会通过交换机的路由键发送给 order.release.order.queue队列,从而实现关闭订单。

② 监听 @RabbitListener(queues = “order.release.order.queue”)这个队列中的消息,实现关闭订单功能。

@RabbitListener(queues = "order.release.order.queue")
@Service
public class OrderCloseListener {

    @Autowired
    OrderService orderService;

    @RabbitHandler
    public void listener(OrderEntity orderEntity, Channel channel, Message message) throws IOException {
        System.out.println("收到过期的订单消息:准备关闭订单" + orderEntity.getOrderSn());
        try {
            orderService.closeOrder(orderEntity);
            //TODO 手动调用支付宝收单

            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (IOException e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
        }
    }
}

③ 库存解锁在订单解锁之后,只要订单解锁成功了,那么库存解锁时看订单已经关单了,库存就自动解锁了。

谷粒商城项目笔记总结(2/2)_第51张图片

@Service
@RabbitListener(queues = "stock.release.stock.queue")
public class StockReleaseListener {
    @Autowired
    WareSkuService wareSkuService;

    @RabbitHandler
    public void handleStockLockedRelease(StockLockedTo stockLockedTo, Message message, Channel channel) throws IOException {
    	System.out.println("订单关闭准备解锁库存...");
        try {
            wareSkuService.unlockStock(stockLockedTo);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }
}

④ 但是还有一种情况,如果订单创建成功后由于机器卡顿,消息延迟等原因,订单还没解锁,库存解锁就先执行了,那么库存就没办法解锁了,因为已经解锁一次了,就不会走解锁逻辑了:

谷粒商城项目笔记总结(2/2)_第52张图片
解决方法:除了订单创建完成后等待它自动解锁库存之外,我们在订单解锁成功后也应该主动的发送一个消息到交换机,交换机通过路由键order.release.other会将消息发送给stock.release.stock.queue队列,从而实现手动解锁库存

谷粒商城项目笔记总结(2/2)_第53张图片

@Configuration
public class MyMQConfig {

    /**
     * 订单释放直接和库存释放进行绑定
     * @return
     */
    @Bean
    public Binding orderReleaseOtherBingding() {
        return new Binding("stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.other.#",
                null);
    }
}

订单解锁成功后给交换机发送一个消息,交换机通过路由键将消息发送给 stock.release.stock.queue队列,监听这个队列实现库存的解锁:

@Override
public void closeOrder(OrderEntity entity) {
   //查询当前这个订单的最新状态
   OrderEntity orderEntity = getById(entity.getId());
   if(orderEntity.getStatus() == OrderStatusEnum.CREATE_NEW.getCode()){
       OrderEntity update = new OrderEntity();
       update.setId(entity.getId());
       update.setStatus(OrderStatusEnum.CANCLED.getCode());
       this.updateById(update);

       OrderTo orderTo = new OrderTo();
       BeanUtils.copyProperties(orderEntity, orderTo);
       //如果订单创建成功后由于机器卡顿,消息延迟等原因,订单还没解锁但是先执行了,然后这个消息消费完了
       //这是订单解锁了,但是库存永远得不到释放。所以要发送给MQ
       try {
           //TODO 保证消息一定发送出去,每一个消息都可以做好日志记录(给数据库保存每一个消息的详细信息)
           //TODO 定期扫描数据库将失败的消息再发送一遍。
           rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTo);
       } catch (Exception e) {
           //TODO 将没法送成功的消息进行重试发送。
       }
   }
}
@RabbitHandler
public void handleOrderCloseRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {
    System.out.println("订单关闭准备解锁库存...");
    try{
        wareSkuService.unlockStock(orderTo);
        channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
    }catch (Exception e){
        channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
    }
}
/**
 * P298 防止订单服务卡顿,导致订单状态消息一直改不了,库存优先到期,查订单状态新建状态,什么都不处理
 * 导致卡顿的订单,永远都不能解锁库存
 * @param orderTo
 */
@Transactional
@Override
public void unlockStock(OrderTo orderTo) {
    String orderSn = orderTo.getOrderSn();
    //查一下最新库存的状态,防止重复解锁库存
    WareOrderTaskEntity task = wareOrderTaskService.getOrderTaskByOrderSn(orderSn);
    Long id = task.getId();
    //按照工作单找到所有 没有解锁的库存,进行解锁
    List<WareOrderTaskDetailEntity> entities = wareOrderTaskDetailService.list(
            new QueryWrapper<WareOrderTaskDetailEntity>()
                    .eq("task_id", id)
                    .eq("lock_status", WareStatusEnum.LOCK_WARE.getCode()));
    //Long skuId, Long wareId, Integer num, Long taskDetailId
    for (WareOrderTaskDetailEntity entity : entities) {
        unLockStock(entity.getSkuId(), entity.getWareId() ,entity.getSkuNum(), entity.getId());
    }
}

4、消息丢失、积压、重复等问题

1、如何保证消息可靠性-消息丢失

  • 消息发送出去,由于网络问题没有抵达服务器
    • 做好容错方法(try-catch),发送消息可能会网络失败,失败后要有重试机制,可记录到数据库,采用定期扫描重发的方式
    • 做好日志记录,每个消息状态是否都被服务器收到都应该记录
    • 做好定期重发,如果消息没有发送成功,定期去数据库扫描未成功的消息进行重发
  • 消息抵达Broker,Broker要将消息写入磁盘(持久化)才算成功。此时Broker尚未持久化完成,宕机。
    • publisher也必须加入确认回调机制,确认成功的消息,修改数据库消息状态。
  • 自动ACK的状态下。消费者收到消息,但没来得及消息然后宕机
    • 一定开启手动ACK,消费成功才移除,失败或者没来得及处理就noAck并重新入队。

2、消息重复

  • 消息消费成功,事务已经提交,ack时,机器宕机。导致没有ack成功,Broker的消息重新由unack变为ready,并发送给其他消费者。
  • 消息消费失败,由于重试机制,自动又将消息发送出去。
  • 成功消费,ack时宕机,消息由unack变为ready,Broker又重新发送。
    • 消费者的业务消费接口应该设计为幂等性的。比如扣库存有工作单的状态标志。
    • 使用防重表(redis/mysql),发送消息每一个都有业务的唯一标识,处理过就不用处理。
    • rabbitMQ的每一个消息都有redelivered字段,可以获取是否是被重新投递过来的,而不是第一次投递过来的。

3、消息积压

  • 消费者宕机积压
  • 消费者消费能力不足积压
  • 发送者发送流量太大
    • 上线更多的消费者,进行正常消费
    • 上线专门的队列消费服务,将消息先批量取出来,记录数据库,离线慢慢处理

商城业务 - 支付功能

1、支付成功

支付宝开放平台:https://open.alipay.com/platform/home.htm

电脑网站支付文档; 下载 demo :https://opendocs.alipay.com/open/270/106291/

① 使用沙箱环境获得商户私钥和公钥以及支付宝公钥:

支付宝沙箱环境配置:https://openhome.alipay.com/platform/appDaily.htm?tab=info

下载密钥生成助手,生成商户的私钥和公钥:https://opendocs.alipay.com/open/009zj5

② 内网穿透:https://natapp.cn/ ,给 localhost:8080 主机和端口设置域名:

③ 整合支付宝功能

1)添加 alipay-sdk-java依赖

2)支付宝的通用配置类

@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {
    //应用ID,您的APPID,收款账号既是您的APPID对应支付宝账号
    public String app_id;

    //商户私钥,您的PKCS8格式RSA2私钥
    public String merchant_private_key;

    //支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。
    public String alipay_public_key;

    //服务器[异步通知]页面路径  需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    //支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
    public String notify_url;

    //页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    //同步通知,支付成功,一般跳转到成功页
    public String return_url;

    //签名方式
    private String sign_type;

    //字符编码格式
    private String charset;

    //订单超时时间【2分钟】
    private String timeout = "2m";

    //支付宝网关
    public String gatewayUrl;

    public String pay(PayVo vo) throws AlipayApiException {

        //AlipayClient alipayClient = new DefaultAlipayClient(AlipayTemplate.gatewayUrl, AlipayTemplate.app_id, AlipayTemplate.merchant_private_key, "json", AlipayTemplate.charset, AlipayTemplate.alipay_public_key, AlipayTemplate.sign_type);
        //1、根据支付宝的配置生成一个支付客户端
        AlipayClient alipayClient = new DefaultAlipayClient(gatewayUrl,
                app_id, merchant_private_key, "json",
                charset, alipay_public_key, sign_type);

        //2、创建一个支付请求 //设置请求参数
        AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest();
        alipayRequest.setReturnUrl(return_url);
        alipayRequest.setNotifyUrl(notify_url);

        //商户订单号,商户网站订单系统中唯一订单号,必填
        String out_trade_no = vo.getOut_trade_no();
        //付款金额,必填
        String total_amount = vo.getTotal_amount();
        //订单名称,必填
        String subject = vo.getSubject();
        //商品描述,可空
        String body = vo.getBody();

        alipayRequest.setBizContent("{\"out_trade_no\":\"" + out_trade_no + "\","
                + "\"total_amount\":\"" + total_amount + "\","
                + "\"subject\":\"" + subject + "\","
                + "\"body\":\"" + body + "\","
                + "\"timeout_express\":\"" + timeout + "\","
                + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");

        String result = alipayClient.pageExecute(alipayRequest).getBody();

        //会收到支付宝的响应,响应的是一个页面,只要浏览器显示这个页面,就会自动来到支付宝的收银台页面
        System.out.println("支付宝的响应:" + result);

        return result;
    }
}

3)将支付页面在浏览器显示出来:

@Controller
public class PayWebController {

    @Autowired
    AlipayTemplate alipayTemplate;

    @Autowired
    OrderService orderService;

    /**
     * 用户下单:支付宝支付
     * 1、让支付页让浏览器展示
     * 2、支付成功以后,跳转到用户的订单列表页
     * @param orderSn
     * @return
     * @throws AlipayApiException
     */
    @ResponseBody
    @GetMapping(value = "/aliPayOrder",produces = "text/html")
    public String aliPayOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {
        //获取当前订单的支付信息
        PayVo payVo = orderService.getOrderPay(orderSn);
        String pay = alipayTemplate.pay(payVo);
        return pay;
    }

}
@Override
public PayVo getOrderPay(String orderSn) {
    PayVo payVo = new PayVo();
    OrderEntity orderEntity = this.getOrderByOrderSn(orderSn);

    BigDecimal bigDecimal = orderEntity.getPayAmount().setScale(2, BigDecimal.ROUND_UP);
    payVo.setTotal_amount(bigDecimal.toString());
    payVo.setOut_trade_no(orderEntity.getOrderSn());

    List<OrderItemEntity> order_sn = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", orderSn));
    OrderItemEntity entity = order_sn.get(0);
    payVo.setSubject(entity.getSkuName());
    payVo.setBody(entity.getSkuAttrsVals());

    return payVo;
}

4)使用沙箱账号 密码进行支付

2、支付成功同步回调

① 支付成功后跳转到用户的订单列表页:

@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {
    // 同步通知,支付成功,一般跳转到成功页,这里支付成功后跳转到会员订单列表页
    public static String return_url = "http://member.gulimall.com/memberOrder.html";
}
@Controller
public class MemberWebController {
    @GetMapping("/memberOrder.html")
    public String memberOrderPage(){

        //查询当前登录用户的所有订单列表数据
        return "orderList";
    }
}

3、订单列表页渲染

① 在gulimall-order服务的OrderController类中分页查询当前登录用户的所有订单以及对应订单项

@Override
public PageUtils queryPageWithItem(Map<String, Object> params) {
   MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
   IPage<OrderEntity> page = this.page(
           new Query<OrderEntity>().getPage(params),
           new QueryWrapper<OrderEntity>().eq("member_id",memberRespVo.getId()).orderByDesc("id")
   );

   List<OrderEntity> order_sn = page.getRecords().stream().map(order -> {
       List<OrderItemEntity> itemEntities = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", order.getOrderSn()));
       order.setOrderItemEntityList(itemEntities);
       return order;
   }).collect(Collectors.toList());

   page.setRecords(order_sn);

   return new PageUtils(page);
}

② 在gulimall-member服务中编写远程调用gulimall-order服务的feign接口,分页查询当前登录用户的所有订单以及对应订单项

4、异步通知

对于 PC 网站支付的交易,在用户支付完成之后,支付宝会根据 API 中商户传入的 notify_url,通过 POST 请求的形式将支付结果作为参数通知到商户系统。

@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {
    // 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
    // 服务器异步通知页面路径
    public static String notify_url = "http://hqqxjyc0q7.52http.tech/payed/notify";
}

配置内网穿透主机和端口:
谷粒商城项目笔记总结(2/2)_第54张图片
配置nginx:

server {
    listen       80;
    server_name  gulimall.com  *.gulimall.com hqqxjyc0q7.52http.tech;

    location /static {
        root    /usr/share/nginx/html;
    }

    location /payed/ {
        proxy_set_header Host order.gulimall.com;
        proxy_pass http://gulimall;
    }

    location / {
        proxy_set_header Host $host;
        proxy_pass http://gulimall;
    }
}

谷粒商城项目笔记总结(2/2)_第55张图片
支付宝支付成功后更新状态

@RestController
public class OrderPayedListener {

    @Autowired
    OrderService orderService;

    @Autowired
    AlipayTemplate alipayTemplate;

    /**
     * P307 异步通知内网穿透
     * 使用postman测试是否成功:http://kgfecg.natappfree.cc/payed/notify
     * 参数:name = hello
     *
     * 在虚拟机的 /mydata/nginx/logs下,通过 cat error.log |grep 'payed' 查看错误日志,
     * 来编gulimall.conf 文件进行修改配置 第三行
     *
     * @return
     */
    @PostMapping("/payed/notify")
    public String handleAlipayed(PayAsyncVo payAsyncVo, HttpServletRequest request) throws AlipayApiException, UnsupportedEncodingException {
        //只要我们收到了支付宝给我们的异步通知,告诉我们订单支付成功,返回success,支付宝就再也不通知
        Map<String, String> params = new HashMap<>();
        Map<String, String[]> requestParams = request.getParameterMap();
        for (String name : requestParams.keySet()) {
            String[] values = requestParams.get(name);
            String valueStr = "";
            for (int i = 0; i < values.length; i++) {
                valueStr = (i == values.length - 1) ? valueStr + values[i]
                        : valueStr + values[i] + ",";
            }
            //乱码解决,这段代码在出现乱码时使用
            //valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
            params.put(name, valueStr);
        }

        //验证签名
        boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayTemplate.getAlipay_public_key(),
                alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //调用SDK验证签名

        if (signVerified) {
            System.out.println("签名验证成功...");
            //去修改订单状态
            String result = orderService.handlePayResult(payAsyncVo);
            return result;
        } else {
            System.out.println("签名验证失败...");
            return "error";
        }
    }
}
@Override
public String handlePayResult(PayAsyncVo payAsyncVo) {
    //1、保存交易流水信息
    PaymentInfoEntity paymentInfoEntity = new PaymentInfoEntity();
    paymentInfoEntity.setOrderSn(payAsyncVo.getOut_trade_no());
    paymentInfoEntity.setAlipayTradeNo(payAsyncVo.getTrade_no());
    paymentInfoEntity.setPaymentStatus(payAsyncVo.getTrade_status());
    paymentInfoEntity.setCallbackTime(payAsyncVo.getNotify_time());
    paymentInfoService.save(paymentInfoEntity);

    //2、修改订单状态
    if (payAsyncVo.getTrade_status().equals("TRADE_SUCCESS") || payAsyncVo.getTrade_status().equals("TRADE_FINISHED")) {
        //支付成功状态
        String outTradeNo = payAsyncVo.getOut_trade_no();
        this.baseMapper.updateOrderStatus(outTradeNo, OrderStatusEnum.PAYED.getCode());
    }

    return "success";
}

商城业务 - 秒杀功能

1、定时任务

① cron表达式

特殊字符:
,:枚举;
	(cron="7,9,23 * * * * ?"):任意时刻的 7,9,23 秒启动这个任务;
-:范围:
	(cron="7-20 * * * * ?"):任意时刻的 7-20 秒之间,每秒启动一次
*:任意;
	指定位置的任意时刻都可以
/:步长;
	(cron="7/5 * * * * ?"):第 7 秒启动,每 5 秒一次;
	(cron="*/5 * * * * ?"):任意秒启动,每 5 秒一次;
?:(出现在日和周几的位置):为了防止日和周冲突,在周和日上如果要写通配符使用?
	(cron="* * * 1 * ?"):每月的 1 号,启动这个任务;
L:(出现在日和周的位置)”,
	last:最后一个
	(cron="* * * ? * 3L"):每月的最后一个周二
W:
	Work Day:工作日
	(cron="* * * W * ?"):每个月的工作日触发
	(cron="* * * LW * ?"):每个月的最后一个工作日触发
#:第几个
(cron	="* * * ? * 5#2"):每个月的第 2 个周 4

2、Spring boot整合定时任务

  • @EnableScheduling:开启其实任务
  • @Scheduled:开启一个定时任务

3、秒杀(高并发)系统关注的问题

  • 服务单一职责 + 独立部署
    • 秒杀服务即使自己扛不住压力,挂掉。不要影响别人。
  • 秒杀链接加密
    • 防止恶意攻击,模拟秒杀请求,1000次/s攻击。
    • 防止链接暴露,自己工作人员,提前秒杀商品。
  • 库存预热 + 快速扣减
    • 秒杀读多血少。无需内次实时校验库存。我们库存预热,放到redis中。信号量控制进来秒杀的请求。
  • 动静分离
    • nginx做好动静分离。保证秒杀和商品详情页的动态请求才打到后端的服务集群。使用CDN网络,分担本集群压力
  • 恶意请求拦截
    • 识别非法攻击请求并进行拦截,网关层
  • 流量错峰
    • 使用各种手段,将流量分担到更大宽度的时间点。比如验证码,加入购物车
  • 限流&熔断&降级
    • 前端限流+后端限流。限制次数,限制总量,快速失败降级运行,熔断隔离防止雪崩
  • 队列削峰
    • 1万个商品,每个1000件秒杀。双11所有秒杀成功的请求,进入队列,慢慢创建订单,扣减库存即可。

如果有收获! 希望老铁们来个三连,点赞、收藏、转发。
创作不易,别忘点个赞,可以让更多的人看到这篇文章,顺便鼓励我写出更好的博客

你可能感兴趣的:(谷粒商城,/,尚医通,谷粒商城)