谷粒商城高级篇下

文章目录

七、购物车(redis实现)

1.游客购物车(京东取消了)

1.未登录状态下加入购物车的商品
2.关闭浏览器后再打开,商品仍然存在
3.采用redis【很好的高并发性能,强于MongoDB】

4.使用user-key【相当于UUID,存在于cookie中】成为临时用户
【如果没有user-key,第一次访问购物车时,会自动分配一个user-key(临时用户身份)】

逻辑:
	1)第一次使用购物车功能,创建user-key(分配临时用户身份)
	2)访问购物车时,判断当前是否登录状态(session是否存在用户信息)
		登录状态则获取用户购物车信息
	3)未登录状态,则获取临时用户身份,获取游客购物车

2.用户购物车

1.会将游客状态下的购物车,整合到登录用户名下的购物车
2.游客购物车被清空(此时退出登录游客购物车已被清空)
3.采用redis

因为要获取用户登录状态,所以需要整合springsession

3.环境搭建

1.搭建模块
name:gulimall-cart
group:com.atguigu
Artifact:gulimall-cart
Package Name:com.atguigu.gulimall.cart

2.上传静态资源到nginx上
/mydata/nginx/html/static/cart

3.配置网关
- id: gulimall_cart_route
  uri: lb://gulimall-cart
  predicates:
    - Host=cart.gulimall.com

4.配置DNS
# gulimall
192.168.56.10 gulimall.com
192.168.56.10 search.gulimall.com
192.168.56.10 item.gulimall.com
192.168.56.10 auth.gulimall.com
192.168.56.10 cart.gulimall.com

5.新增依赖
<!--公共模块-->
<dependency>
    <groupId>com.atguigu.gulimall</groupId>
    <artifactId>gulimall-common</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <exclusions>
        <exclusion>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<!--redis-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

6.添加redis配置
spring.redis.host=192.168.56.10
spring.redis.port=6379

7.启动类增加注解
@EnableRedisHttpSession
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallCartApplication {

    public static void main(String[] args) {
        SpringApplication.run(GulimallCartApplication.class, args);
    }

}

8.springsession配置类
/**
 * springsession配置类
 * @Author: wanzenghui
 * @Date: 2021/11/30 22:21
 */
@Configuration
public class GulimallSessionConfig {
    @Bean
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
        cookieSerializer.setDomainName("gulimall.com");// 放大作用域
        cookieSerializer.setCookieName("GULISESSION");
        cookieSerializer.setCookieMaxAge(60 * 60 * 24 * 7);// 指定cookie有效期7天,会话级关闭浏览器后cookie即失效
        return cookieSerializer;
    }

    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        // 指定session序列化到redis的序列化器
        return new GenericJackson2JsonRedisSerializer();
    }
}

在这里插入图片描述

在这里插入图片描述

4.购物车数据结构与VO

购物车商品列表截图:

在这里插入图片描述

数据结构:

在这里插入图片描述

Map<String k1, Map<String k2, CartItemInfo>>

key:用户标示
	登录态:gulimall:cart:userId
	非登录态:gulimall:cart:userKey

value:
	存储一个Hash结构的值,其中该hash结构的key是SkuId,hash结构的value是商品信息,以json字符串格式存储

购物车VO

/**
 * 购物车VO
 * 需要计算的属性需要重写get方法,保证每次获取属性都会进行计算
 */
public class CartVO {

    private List<CartItemVO> items; // 购物项集合
    private Integer countNum;       // 商品件数(汇总购物车内商品总件数)
    private Integer countType;      // 商品数量(汇总购物车内商品总个数)
    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 getCountNum() {
        int count = 0;
        if (items != null && items.size() > 0) {
            for (CartItemVO item : items) {
                count += item.getCount();
            }
        }
        return count;
    }

    public Integer getCountType() {
        return CollectionUtils.isEmpty(items) ? 0 : items.size();
    }


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

    public BigDecimal getReduce() {
        return reduce;
    }

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

/**
 * 购物项VO(购物车内每一项商品内容)
 */
public class CartItemVO {
    private Long skuId;                     // skuId
    private Boolean check = true;           // 是否选中
    private String title;                   // 标题
    private String image;                   // 图片
    private List<String> skuAttrValues;     // 销售属性
    private BigDecimal price;               // 单价
    private Integer count;                  // 商品件数
    private BigDecimal totalPrice;          // 总价

    public Long getSkuId() {
        return skuId;
    }

    public void setSkuId(Long skuId) {
        this.skuId = skuId;
    }

    public Boolean getCheck() {
        return check;
    }

    public void setCheck(Boolean check) {
        this.check = check;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getImage() {
        return image;
    }

    public void setImage(String image) {
        this.image = image;
    }

    public List<String> getSkuAttrValues() {
        return skuAttrValues;
    }

    public void setSkuAttrValues(List<String> skuAttrValues) {
        this.skuAttrValues = skuAttrValues;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public void setPrice(BigDecimal price) {
        this.price = price;
    }

    public Integer getCount() {
        return count;
    }

    public void setCount(Integer count) {
        this.count = count;
    }

    /**
     * 计算当前购物项总价
     */
    public BigDecimal getTotalPrice() {
        return this.price.multiply(new BigDecimal("" + this.count));
    }

    public void setTotalPrice(BigDecimal totalPrice) {
        this.totalPrice = totalPrice;
    }
}

5.拦截器

业务逻辑:
	1)第一次使用购物车功能,创建user-key(分配临时用户身份)
	2)访问购物车时,判断当前是否登录状态(session是否存在用户信息)
		登录状态则获取用户购物车信息
	3)未登录状态,则获取临时用户身份,获取游客购物车

拦截器功能:
	过滤器(URL拦截)=》拦截器(URL拦截)=》切面(方法拦截)
	1.preHandle
		1)获取用户登录信息userId,封装到ThreadLocal中,controller可以拿到
		2)用户未登录,分配userKey封装到ThreadLocal中,controller可以拿到
	
	2.postHandle
		1)判断客户端是否存在游客用户标识
		不存在则创建cookie,命令客户端保存游客信息user-key

ThreadLocal共享登录用户信息

在这里插入图片描述

public class CartInterceptor implements HandlerInterceptor {

    public static ThreadLocal<UserInfoTO> threadLocal = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 获取会话信息,获取登录用户信息
        HttpSession session = request.getSession();
        MemberResponseVO attribute = (MemberResponseVO) session.getAttribute(AuthConstant.LOGIN_USER);
        // 判断是否登录,并封装User对象给controller使用
        UserInfoTO user = new UserInfoTO();
        if (attribute != null) {
            // 登录状态,封装用户ID,供controller使用
            user.setUserId(attribute.getId());
        }
        // 获取当前请求游客用户标识user-key
        Cookie[] cookies = request.getCookies();
        if (ArrayUtils.isNotEmpty(cookies)) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(CartConstant.TEMP_USER_COOKIE_NAME)) {
                    // 获取user-key值封装到user,供controller使用
                    user.setUserKey(cookie.getValue());
                    user.setTempUser(true);// 不需要重新分配
                    break;
                }
            }
        }

        // 判断当前是否存在游客用户标识
        if (StringUtils.isBlank(user.getUserKey())) {
            // 无游客标识,分配游客标识
            user.setUserKey(UUID.randomUUID().toString());
        }

        // 封装用户信息(登录状态userId非空,游客状态userId空)
        threadLocal.set(user);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        UserInfoTO user = threadLocal.get();
        if (user != null && !user.isTempUser()) {
            // 需要为客户端分配游客信息
            Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, user.getUserKey());
            cookie.setDomain("gulimall.com");// 作用域
            cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);// 过期时间
            response.addCookie(cookie);
        }
    }
}

6.接口API

6.1.添加商品到购物车

在这里插入图片描述

商品详情页(gulimall-product),点击添加购物车, 跳转(gulimall-cart)success.html
/**
 * 添加商品到购物车
 * @param skuId 商品ID
 * @param num   商品数量
 * @param attributes    重定向数据域
 */
@GetMapping(value = "/addCartItem")
public String addCartItem(@RequestParam("skuId") Long skuId,
                          @RequestParam("num") Integer num,
                          RedirectAttributes attributes) throws ExecutionException, InterruptedException {
    // 添加sku商品到购物车
    cartService.addToCart(skuId, num);
    attributes.addAttribute("skuId", skuId);// 会在url后面拼接参数
    // 请求重定向给addToCartSuccessPage.html,防刷
    return "redirect:http://cart.gulimall.com/addToCartSuccessPage.html";
}
/**
 * 添加sku商品到购物车
 */
@Override
public CartItemVO addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
    // 获取购物车redis操作对象
    BoundHashOperations<String, Object, Object> operations = getCartOps();
    // 获取商品
    String cartItemJSONString = (String) operations.get(skuId.toString());
    if (StringUtils.isEmpty(cartItemJSONString)) {
        // 购物车不存在此商品,需要将当前商品添加到购物车中
        CartItemVO cartItem = new CartItemVO();
        CompletableFuture<Void> getSkuInfoFuture = CompletableFuture.runAsync(() -> {
            // 远程查询当前商品信息
            R r = productFeignService.getInfo(skuId);
            SkuInfoVO skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoVO>() {
            });
            cartItem.setSkuId(skuInfo.getSkuId());// 商品ID
            cartItem.setTitle(skuInfo.getSkuTitle());// 商品标题
            cartItem.setImage(skuInfo.getSkuDefaultImg());// 商品默认图片
            cartItem.setPrice(skuInfo.getPrice());// 商品单价
            cartItem.setCount(num);// 商品件数
            cartItem.setCheck(true);// 是否选中
        }, executor);

        CompletableFuture<Void> getSkuAttrValuesFuture = CompletableFuture.runAsync(() -> {
            // 远程查询attrName:attrValue信息
            List<String> skuSaleAttrValues = productFeignService.getSkuSaleAttrValues(skuId);
            cartItem.setSkuAttrValues(skuSaleAttrValues);
        }, executor);

        CompletableFuture.allOf(getSkuInfoFuture, getSkuAttrValuesFuture).get();
        operations.put(skuId.toString(), JSON.toJSONString(cartItem));
        return cartItem;
    } else {
        // 当前购物车已存在此商品,修改当前商品数量
        CartItemVO cartItem = JSON.parseObject(cartItemJSONString, CartItemVO.class);
        cartItem.setCount(cartItem.getCount() + num);
        operations.put(skuId.toString(), JSON.toJSONString(cartItem));
        return cartItem;
    }
}
Hash数据类型操作对象
/**
 * 根据用户信息获取购物车redis操作对象
 */
private BoundHashOperations<String, Object, Object> getCartOps() {
    // 获取用户登录信息
    UserInfoTO userInfo = CartInterceptor.threadLocal.get();
    String cartKey = "";
    if (userInfo.getUserId() != null) {
        // 登录态,使用用户购物车
        cartKey = CartConstant.CART_PREFIX + userInfo.getUserId();
    } else {
        // 非登录态,使用游客购物车
        cartKey = CartConstant.CART_PREFIX + userInfo.getUserKey();
    }
    // 绑定购物车的key操作Redis
    BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
    return operations;
}
接口防刷

在这里插入图片描述

如果刷新cart.gulimall.com/addToCart?skuId=7&num=1该页面,会导致购物车中此商品的数量无限新增
解决方案:
	/addToCart请求使用重定向给/addToCartSuccessPage.html
	由/addToCartSuccessPage.html这个请求跳转"商品已成功加入购物车页面"(浏览器url请求已更改),达到防刷的目的
/**
 * 商品添加购物车成功页(防刷)
 */
@GetMapping(value = "/addToCartSuccessPage.html")
public String addToCartSuccessPage(@RequestParam("skuId") Long skuId, Model model) {
    //重定向到成功页面。再次查询购物车数据即可
    CartItemVO cartItemVo = cartService.getCartItem(skuId);
    model.addAttribute("cartItem",cartItemVo);
    return "success";
}

/**
 * 根据skuId获取购物车商品信息
 */
@Override
public CartItemVO getCartItem(Long skuId) {
    // 获取购物车redis操作对象
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();
    String cartItemJSONString = (String) cartOps.get(skuId.toString());
    CartItemVO cartItemVo = JSON.parseObject(cartItemJSONString, CartItemVO.class);
    return cartItemVo;
}

达到防刷目的的重定向请求:
在这里插入图片描述

6.2.购物车列表

在这里插入图片描述

/**
 * 购物车列表页
 * 1.拦截器封装用户信息
 * 1)已登录状态:封装userId+userKey到ThreadLocal中
 * 2)未登录状态:
 * 2-1)已分配游客标识,封装userKey到ThreadLocal中
 * 2-2)未分配游客标识,命令客户端保存cookie(user-key),并封装userKey到ThreadLocal中
 * 2.根据用户标识获取购物车信息
 * 1)已登录状态
 * 使用userId作为key获取购物车
 * 使用userKey作为key获取游客购物车,如果非空则与用户购物车合并
 * 2)未登录状态
 * 使用userKey作为key获取游客购物车
 * 3.返回cartList列表页
 */
@GetMapping("/cart.html")
public String cartListPage(Model model) throws ExecutionException, InterruptedException {
    CartVO cartVO = cartService.getCart();
    model.addAttribute("cart", cartVO);
    return "cartList";
}
/**
 * 购物车列表
 */
@Override
public CartVO getCart() throws ExecutionException, InterruptedException {
    CartVO cart = new CartVO();
    // 获取用户登录信息
    UserInfoTO userInfo = CartInterceptor.threadLocal.get();
    // 获取游客购物车
    List<CartItemVO> touristItems = getCartItems(CartConstant.CART_PREFIX + userInfo.getUserKey());
    if (userInfo.getUserId() != null) {
        // 登录状态
        if (!CollectionUtils.isEmpty(touristItems)) {
            // 游客购物车非空,需要整合到用户购物车
            for (CartItemVO item : touristItems) {
                // 将商品逐个放到用户购物车
                addToCart(item.getSkuId(), item.getCount());
            }
            // 清楚游客购物车
            clearCart(CartConstant.CART_PREFIX + userInfo.getUserKey());
        }
        // 获取用户购物车(已经合并后的购物车)
        List<CartItemVO> items = getCartItems(CartConstant.CART_PREFIX + userInfo.getUserId());
        cart.setItems(items);
    } else {
        // 未登录状态,返回游客购物车
        cart.setItems(touristItems);
    }
    return cart;
}

/**
 * 根据购物车的key获取
 */
private List<CartItemVO> getCartItems(String cartKey) {
    BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
    List<Object> values = operations.values();
    if (!CollectionUtils.isEmpty(values)) {
        // 购物车非空,反序列化成商品并封装成集合返回
        return values.stream()
                .map(jsonString -> JSONObject.parseObject((String) jsonString, CartItemVO.class))
                .collect(Collectors.toList());
    }
    return null;
}

/**
 * 清空购物车
 */
@Override
public void clearCart(String cartKey) {
    redisTemplate.delete(cartKey);
}

6.3.更改购物车商品选中状态

在这里插入图片描述

/**
 * 更改购物车商品选中状态
 */
@GetMapping(value = "/checkItem")
public String checkItem(@RequestParam(value = "skuId") Long skuId,
                        @RequestParam(value = "checked") Integer check) {
    cartService.checkItem(skuId, check);
    return "redirect:http://cart.gulimall.com/cart.html";
}

/**
 * 更改购物车商品选中状态
 */
@Override
public void checkItem(Long skuId, Integer check) {
    // 查询购物车商品信息
    CartItemVO cartItem = getCartItem(skuId);
    // 修改商品选中状态
    cartItem.setCheck(ObjectConstant.BooleanIntEnum.YES.getCode().equals(check) ? true : false);
    // 更新到redis中
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();
    cartOps.put(skuId.toString(), JSONObject.toJSONStringWithDateFormat(cartItem, DateUtils.DATATIMEF_TIME_STR));
}

6.4.更改商品数量

在这里插入图片描述

/**
 * 改变商品数量
 */
@GetMapping(value = "/countItem")
public String countItem(@RequestParam(value = "skuId") Long skuId,
                        @RequestParam(value = "num") Integer num) {
    cartService.changeItemCount(skuId,num);
    return "redirect:http://cart.gulimall.com/cart.html";
}

/**
 * 改变商品数量
 */
@Override
public void changeItemCount(Long skuId, Integer num) {
    // 查询购物车商品信息
    CartItemVO cartItem = getCartItem(skuId);
    // 修改商品数量
    cartItem.setCount(num);
    // 更新到redis中
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();
    cartOps.put(skuId.toString(), JSONObject.toJSONStringWithDateFormat(cartItem, DateUtils.DATATIMEF_TIME_STR));
}

6.5.删除购物车商品

在这里插入图片描述

/**
 * 删除商品信息
 */
@GetMapping(value = "/deleteItem")
public String deleteItem(@RequestParam("skuId") Integer skuId) {
    cartService.deleteIdCartInfo(skuId);
    return "redirect:http://cart.gulimall.com/cart.html";
}

/**
 * 删除购物项
 */
@Override
public void deleteIdCartInfo(Integer skuId) {
    BoundHashOperations<String, Object, Object> operations = getCartOps();
    operations.delete(skuId.toString());
}

6.6.购物车列表页选中商品

在这里插入图片描述

注意:
    1、全局异常处理的原理
    2、需求解析:购物车列表页选中指定商品获取商品价格信息
/**
 * 获取当前用户的购物车所有商品项
 * 订单服务调用:【购物车列表页面点击确认订单时】
 * 从redis中获取所有选中的商品项
 * 并且要获取最新的商品价格信息,替换redis中的价格信息
 */
@GetMapping(value = "/currentUserCartItems")
@ResponseBody
public List<CartItemVO> getCurrentCartItems() {
    List<CartItemVO> cartItemVoList = cartService.getUserCartItems();
    return cartItemVoList;
}

/**
 * 获取购物车,最新价格
 */
@Override
public List<CartItemVO> getUserCartItems() {
    List<CartItemVO> cartItemVoList = new ArrayList<>();
    // 获取当前用户登录的信息
    UserInfoTO userInfo = CartInterceptor.threadLocal.get();
    if (userInfo.getUserId() == null) {
        // 未登录
        return null;
    } else {
        // 已登录,获取用户购物车
        List<CartItemVO> items = getCartItems(CartConstant.CART_PREFIX + userInfo.getUserId());
        if (CollectionUtils.isEmpty(items)) {
            throw new CartExceptionHandler();
        }
        // 筛选所有选中的sku
        Map<Long, CartItemVO> itemMap = items.stream().filter(item -> item.getCheck())
                .collect(Collectors.toMap(CartItemVO::getSkuId, val -> val));
        // 调用远程获取最新价格
        Map<Long, BigDecimal> priceMap = productFeignService.getPrice(itemMap.keySet());
        // 封装价格返回
        items = itemMap.entrySet().stream().map(entry -> {
            CartItemVO item = entry.getValue();
            item.setPrice(priceMap.get(entry.getKey()));
            return item;
        }).collect(Collectors.toList());
        return items;
    }
}

八、订单模块

1.环境搭建

1.1.整合环境

等待付款(订单详情页):

在这里插入图片描述

订单页(订单确认页):

在这里插入图片描述

结算页:
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

收银页:

在这里插入图片描述

在这里插入图片描述

1.拷贝静态资源到nginx中,html页面的请求资源修改为/static/order/xxx开头
	1)等待付款(订单详情页)
		静态资源拷贝=》/mydata/nginx/html/static/order/detail
		html文件拷贝至order模块,更名为detail.html
	2)订单页(订单列表、订单确认收货页)
		静态资源拷贝=》/mydata/nginx/html/static/order/list
		html文件拷贝至order模块,更名为list.html
	3)结算页(订单提交页)
		静态资源拷贝=》/mydata/nginx/html/static/order/confirm
		html文件拷贝至order模块,更名为confirm.html
	4)收银页(收银台、选择支付方式)
		静态资源拷贝=》/mydata/nginx/html/static/order/pay
		html文件拷贝至order模块,更名为pay.html

2.本地DNS解析配置域名
# gulimall
192.168.56.10 gulimall.com
192.168.56.10 search.gulimall.com
192.168.56.10 item.gulimall.com
192.168.56.10 auth.gulimall.com
192.168.56.10 cart.gulimall.com
192.168.56.10 order.gulimall.com

3.配置好nginx转发规则(前面配置其他模块的时候已经配置)
	静态资源请求:/usr/share/nginx/html(已经映射上了/mydata/nginx/html)
	动态资源请求:转发至192.168.56.1:88(网关)

4.配置网关转发
        - id: gulimall_order_route
          uri: lb://gulimall-order
          predicates:
            - Host=order.gulimall.com

5.引入thymeleaf依赖,并在开发期间禁用缓存
        <!--thymeleaf模板引擎-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        
6.启动类
// 开启rabbit
@EnableRabbit
// 开启SpringSession
@EnableRedisHttpSession
// 开启服务注册功能
@EnableDiscoveryClient
@MapperScan("com.atguigu.gulimall.order.dao")
@SpringBootApplication
public class GulimallOrderApplication {

    public static void main(String[] args) {
        SpringApplication.run(GulimallOrderApplication.class, args);
    }

}
7.配置:
server:
  port: 9000

spring:
  application:
    name: gulimall-order
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
  # 开发期间禁用缓存
  thymeleaf:
    cache: false

1.2.整合springsession

1.在各服务添加springsession依赖(服务自治)【auth、product、search、member、order、】
<!--redis-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--整合springsession,实现session共享-->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

2.属性配置
server:
  servlet:
    session:
      timeout: 30m
spring:
  redis:
    host: 192.168.56.10
    port: 6379
  session:
    store-type: redis

3.启动类添加配置
@EnableRedisHttpSession


6.添加以下配置,放大作用域 + 指定redis序列化器【否则使用默认的jdk序列化器】
/**
 * springsession配置类
 */
@Configuration
public class GulimallSessionConfig {
    @Bean
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
        cookieSerializer.setDomainName("gulimall.com");// 放大作用域
        cookieSerializer.setCookieName("GULISESSION");
        cookieSerializer.setCookieMaxAge(60 * 60 * 24 * 7);// 指定cookie有效期7天,会话级关闭浏览器后cookie即失效
        return cookieSerializer;
    }

    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        // 指定session序列化到redis的序列化器
        return new GenericJackson2JsonRedisSerializer();
    }
}

7.修改product模块gulimall首页,去除session中的loginUser
<li>
  <a th:if="${session.loginUser != null}">欢迎, [[${session.loginUser.nickname}]]</a>
  <a th:if="${session.loginUser == null}" href="http://auth.gulimall.com/login.html">你好,请登录</a>
</li>

8.测试
=》进入auth.gulimall.com并社交登录
=》进入gulimall.com查看cookie作用域是否修改成功
=》查看redis,session是否存储成功
=》查看gulimall首页nickname是否取到值

1.3.整合线程池

@EnableConfigurationProperties(MyThreadConfig.ThreadPoolConfigProperties.class)
@Configuration
public class MyThreadConfig {
    @Bean
    public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool) {
        return new ThreadPoolExecutor(
                pool.getCoreSize(),
                pool.getMaxSize(),
                pool.getKeepAliveTime(),
                TimeUnit.SECONDS,
                new LinkedBlockingDeque<>(100000),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.CallerRunsPolicy()
        );
    }

    @ConfigurationProperties(prefix = "gulimall.thread")
    @Data
    public class ThreadPoolConfigProperties {
        private Integer coreSize;
        private Integer maxSize;
        private Integer keepAliveTime;
    }
}
gulimall:
  thread:
    core-size: 20
    max-size: 200
    keep-alive-time: 10

1.4.application.yml

server:
  port: 9000
  servlet:
    session:
      timeout: 30m

spring:
  application:
    name: gulimall-order
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
  datasource:
    username: root
    password: root
    url: jdbc:mysql://192.168.56.10:3306/gulimall_oms?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
    driver-class-name: com.mysql.cj.jdbc.Driver
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: GMT+8
  redis:
    host: 192.168.56.10
    port: 6379
  session:
    store-type: redis
  rabbitmq:
    host: 192.168.56.10
    port: 5672
    # 虚拟主机
    virtual-host: /
    # 开启发送端发送确认,无论是否到达broker都会触发回调【发送端确认机制+本地事务表】
    publisher-confirm-type: correlated
    # 开启发送端抵达队列确认,消息未被队列接收时触发回调【发送端确认机制+本地事务表】
    publisher-returns: true
    # 消息在没有被队列接收时是否强行退回
    template:
      mandatory: true
    # 消费者手动确认模式,关闭自动确认,否则会消息丢失
    listener:
      simple:
        acknowledge-mode: manual
  # 开发期间禁用缓存
  thymeleaf:
    cache: false

mybatis-plus:
  # 扫描依赖的jar包下的所有mapper.xml
  mapper-locations: classpath:/mapper/**/*.xml
  global-config:
    db-config:
      id-type: auto

gulimall:
  thread:
    core-size: 20
    max-size: 200
    keep-alive-time: 10
    
logging:
  level:
    com.atguigu.gulimall: debug

2.订单服务拆析

2.1.构成

电商系统涉及到3流,分别是信息流,资金流,物流,而订单系统作为中枢将三者有机的集合起来。
	三流:
		1、信息流:商品信息、优惠信息
		2、资金流:退款、付款
		3、物流:发送、退货
		
订单模块是电商系统的枢纽,在订单这个环节上需求获取多个模块的数据和信息,同时对这些信息进行加工处理后流向下个环节,这一系列就构成了订单的信息流通。

在这里插入图片描述

2.2.状态

1、代付款
	用户提交订单后,订单进行预下单,目前主流电商网站都会唤起支付,便于用户快速完成支付,需要注意的是待付款状态下可以对库存进行锁定,锁定库存需要配置支付超时时间,超时后将自动取消订单,订单变更关闭状态。

2、已付款/待发货
	用户完成订单支付,订单系统需要记录支付时间,支付流水单号便于对账,订单下放到WMS系统,仓库进行调拨,配货,分拣,出库等操作。

3、待收货/已发货
	仓储将商品出库后,订单进入物流环节,订单系统需要同步物流信息,便于用户实时知悉物品物流状态

4、已完成
	用尸确认收员后,订单交易完成。后续支付侧进行结算,如果订单存在问题进入售后状态

5、已取消
	付款之前取消订单。包括超时未付款或用户商户取消订单都会产生这种订单状态。
	
6.售后中
	用户在付款后申请退款,或商家发货后用户申请退换货。售后也同样存在各种状态,当发起售后申请后生成售后订单,售后订单状态为待审核,等待商家审核,商家审核通过后订单状态变更为待退货,等待用户将商品寄回,商家收货后订单状态更新为待退款状态,退款到用户原账户后订单状态更新为售后成功。

2.3.订单流程

	订单流程是指从订单产生到完成整个流转的过程,从而行程了一套标准流程规则。而不同的产品类型或业务类型在系统中的流程会千差万别,比如上面提到的线上实物订单和虚拟订单的流程,线上实物订单与o20订单等,所以需要根据不同的类型进行构建订单流程。不管类型如何订单都包括正向流程和逆向流程,对应的场景就是购买商品和退换货流程,正向流程就是一个正常的网购步骤,订单生成->支付订单->卖家发货->确认收货->交易成功。而每个步骤的背后,订单是如何在多系统之间交互流转的,可概括如下图

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

2、逆向流程
(1)、修改订单,用户没有提交订单,可以对订单一些信息进行修改,比如配送信息,
优惠信息,及其他一些订单可修改范围的内容,此时只需对数据进行变更即可。
(2)、订单取消,用户主动取消订单和用户超时未支付,两种情况下订单都会取消订
单,而超时情况是系统自动关闭订单,所以在订单支付的响应机制上面要做支付的

在这里插入图片描述

3.登录拦截器

订单模块需要用户登录后操作

步骤:
	1.添加拦截器
	2.添加配置使拦截器生效
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {

    @Autowired
    private LoginUserInterceptor loginUserInterceptor;

    /**
     * 配置拦截器生效
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");// 访问任何订单请求需要拦截校验登录
    }
}


@Component
public class LoginUserInterceptor implements HandlerInterceptor {

    public static ThreadLocal<MemberResponseVO> threadLocal = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // TODO 待解释
        String uri = request.getRequestURI();
        AntPathMatcher antPathMatcher = new AntPathMatcher();
        boolean match = antPathMatcher.match("/order/order/status/**", uri);
        boolean match1 = antPathMatcher.match("/payed/notify", uri);
        if (match || match1) {
            return true;
        }

        // 获取登录用户信息
        MemberResponseVO attribute = (MemberResponseVO) request.getSession().getAttribute(AuthConstant.LOGIN_USER);
        if (attribute != null) {
            // 已登录,放行
            // 封装用户信息到threadLocal
            threadLocal.set(attribute);
            return true;
        } else {
            // 未登录,跳转登录页面
            response.setContentType("text/html;charset=UTF-8");
            PrintWriter out = response.getWriter();
            out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='http://auth.gulimall.com/login.html'</script>");
            return false;
        }
    }
}

4.结算页(由购物车页跳转)

购物车商品列表页,点击去结算跳转结算页:根据页面所选商品查询商品相关信息返回(金额、优惠等等)

购物车页:

在这里插入图片描述

结算页:

在这里插入图片描述

在这里插入图片描述

bug1_feign丢失登录状态

在这里插入图片描述

在这里插入图片描述

原因:
	浏览器请求时会带上Cookie: GULISESSION
	默认使用feign调用时,会根据拦截器构造请求参数RequestTemplate,而此时请求头没有带上Cookie,导致springsession无法获取用户信息

解决:
	拦截器构造请求头
/**
 * feign配置类
 **/
@Configuration
public class GuliFeignConfig {

    /**
     * 注入拦截器
     * feign调用时根据拦截器构造请求头,封装cookie解决远程调用时无法获取springsession
     */
    @Bean("requestInterceptor")
    public RequestInterceptor requestInterceptor() {
        // 创建拦截器
        return new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate template) {
                System.out.println("feign远程调用,拦截器封装请求头...RequestInterceptor.apply");
                // 1、使用RequestContextHolder拿到原生请求的请求头信(上下文环境保持器)
                // 从ThreadLocal中获取请求头(要保证feign调用与controller请求处在同一线程环境)
                ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
                if (requestAttributes != null) {
                    HttpServletRequest request = requestAttributes.getRequest();// 获取controller请求对象
                    if (request != null) {
                        //2、同步请求头的数据(cookie)
                        String cookie = request.getHeader("Cookie");// 获取Cookie
                        template.header("Cookie", cookie);// 同步Cookie
                    }
                }
            }
        };
    }
}

bug2_异步丢失登录状态

在这里插入图片描述

原因:
	使用异步编排时,非同一线程无法取到RequestContextHolder(上下文环境保持器)
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();// 获取controller请求对象
空指针异常

解决:
	获取主线程ServletRequestAttributes,给每个异步线程复制一份
/**
 * 获取结算页(confirm.html)VO数据
 */
@Override
public OrderConfirmVO OrderConfirmVO() throws ExecutionException, InterruptedException {
    OrderConfirmVO result = new OrderConfirmVO();
    // 获取当前用户
    MemberResponseVO member = LoginUserInterceptor.loginUser.get();

    // 获取当前线程上下文环境器
    ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {
        // 1.查询封装当前用户收货列表
        // 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributes
        RequestContextHolder.setRequestAttributes(requestAttributes);
        List<MemberAddressVO> address = memberFeignService.getAddress(member.getId());
        result.setMemberAddressVos(address);
    }, executor);

    CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
        // 2.查询购物车所有选中的商品
        // 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributes
        RequestContextHolder.setRequestAttributes(requestAttributes);
        // 请求头应该放入GULIMALLSESSION(feign请求会根据requestInterceptors构建请求头)
        List<OrderItemVO> items = cartFeignService.getCurrentCartItems();
        result.setItems(items);
    }, executor);

    // 3.查询用户积分
    Integer integration = member.getIntegration();// 积分
    result.setIntegration(integration);

    // 4.金额数据自动计算

    // 5.TODO 防重令牌

    // 阻塞等待所有异步任务返回
    CompletableFuture.allOf(addressFuture, cartFuture).get();

    return result;
}

计算运费

/**
 * 获取运费
 * @param addrId 会员收货地址ID
 */
@Override
public FareVO getFare(Long addrId) {
    FareVO fareVo = new FareVO();
    //收获地址的详细信息
    R addrInfo = memberFeignService.info(addrId);
    MemberAddressVO memberAddressVo = addrInfo.getData("memberReceiveAddress", new TypeReference<MemberAddressVO>() {
    });
    if (memberAddressVo != null) {
        String phone = memberAddressVo.getPhone();
        //截取用户手机号码最后一位作为我们的运费计算
        //1558022051
        String fare = phone.substring(phone.length() - 1);
        BigDecimal bigDecimal = new BigDecimal(fare);
        fareVo.setFare(bigDecimal);
        fareVo.setAddress(memberAddressVo);
        return fareVo;
    }
    return null;
}

5.幂等性处理

哪些情况需要防止:
	用户多次点击按钮
	用户页面回退再次提交
	微服务互相调用,由于网络问题,导致请求失败。feign触发重试机制
	其他业务情况
	例如update tab1 set col1=col1+1 where col2 = 2,每次执行结果不一样

天然幂等性:
	1.查询接口
	2.更新接口update tab1 set col1=1 where col2=2
	3.delete from user where userId = 1
	4.insert user(userId, name) values(1, 'wan'),其中userId为主键

5.1.token机制

	服务器存储了一个令牌,页面请求时要带上令牌,服务器接收请求后会匹配令牌,匹配成功则删除令牌(再次提交则匹配失败,服务器已删除令牌。但是F5刷新的话就不一样了,会有新的token产生)
	注意:
	1.删除令牌要在执行业务代码之前
	2.获取redis令牌、令牌匹配、令牌删除要保证原子性(lua脚本)

5.2.各种锁机制

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

2.数据库乐观锁【带上版本号】
	这种方法适合在更新的场景中
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,再执行上面的sal语句时,就不会执行﹔因为version已经变为2了,where条件就不成立。这样就保证了不管调用几次,只会真正的处理一次。
    乐观锁主要使用于处理读多写少的问题

3.分布式锁:
	例如集群下多个定时器处理相同的数据,可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断这个数据是否被处理过(double check)

5.3.各种唯一约束

1.数据库唯一约束 order_sn字段【数据库层面】

2.redis set防重【百度网盘妙传功能】
需要处理的数据 计算MD5放入redis的set,每次处理数据,先看MD5是否存在,存在就不处理

5.4.防重表

数据库创建防重表,插入成功才可以操作【不采用,DB慢】
	使用订单号orderNo作为去重表唯一索引,然后将数据插入去重表+业务操作 放在同一事物中,如果插入失败事物回滚导致业务操作也同时回滚,(如果业务操作失败也会导致插入去重表回滚)保证了数据一致性

5.5.全局唯一id

调用接口时,生成一个唯一ID,redis将数据保存到集合中(去重),存在即处理过
	情景1:feign调用
		生成一个请求唯一IDA调用B时带上唯一IDB处理feign请求时判断此唯一ID是否已处理(feign重试时会带上相同ID)
	
	情景2:页面请求
	可以使用nginx设置每一个请求的唯一id,proxy_set_header X-Request-ld $request_id; 【链路追踪】
	但是没办法保证请求幂等性,因为每次请求nginx都会生成一个新的ID

6.结算页

流程图:

在这里插入图片描述

结算页数据:
	获取当时购物车选中商品并计算价格
@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {

    @Autowired
    MemberFeignService memberFeignService;
    @Autowired
    CartFeignService cartFeignService;
    @Autowired
    WmsFeignService wmsFeignService;
    @Autowired
    ThreadPoolExecutor executor;
    @Autowired
    StringRedisTemplate redisTemplate;

    /**
     * 获取结算页(confirm.html)VO数据
     */
    @Override
    public OrderConfirmVO OrderConfirmVO() throws ExecutionException, InterruptedException {
        OrderConfirmVO result = new OrderConfirmVO();
        // 获取当前用户
        MemberResponseVO member = LoginUserInterceptor.loginUser.get();

        // 获取当前线程上下文环境器
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {
            // 1.查询封装当前用户收货列表
            // 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributes
            RequestContextHolder.setRequestAttributes(requestAttributes);
            List<MemberAddressVO> address = memberFeignService.getAddress(member.getId());
            result.setMemberAddressVos(address);
        }, executor);

        CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
            // 2.查询购物车所有选中的商品
            // 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributes
            RequestContextHolder.setRequestAttributes(requestAttributes);
            // 请求头应该放入GULIMALLSESSION(feign请求会根据requestInterceptors构建请求头)
            List<OrderItemVO> items = cartFeignService.getCurrentCartItems();
            result.setItems(items);
        }, executor).thenRunAsync(() -> {
            // 3.批量查询库存(有货/无货)
            List<Long> skuIds = result.getItems().stream().map(item -> item.getSkuId()).collect(Collectors.toList());
            R skuHasStock = wmsFeignService.getSkuHasStock(skuIds);
            List<SkuHasStockTO> skuHasStocks = skuHasStock.getData(new TypeReference<List<SkuHasStockTO>>() {
            });
            Map<Long, Boolean> stocks = skuHasStocks.stream().collect(Collectors.toMap(key -> key.getSkuId(), val -> val.getHasStock()));
            result.setStocks(stocks);
        });

        // 4.查询用户积分
        Integer integration = member.getIntegration();// 积分
        result.setIntegration(integration);

        // 5.金额数据自动计算

        // 6.防重令牌
        String token = tokenUtil.createToken();
        result.setUniqueToken(token);

        // 阻塞等待所有异步任务返回
        CompletableFuture.allOf(addressFuture, cartFuture).get();

        return result;
    }
}

7.提交订单+幂等性处理

在这里插入图片描述

在这里插入图片描述

队列业务规则截图

在这里插入图片描述

在这里插入图片描述

7.1.第一版(无事务)

7.1.1.生成订单
/**
 * @Author: wanzenghui
 * @Date: 2021/12/20 21:59
 */
@Controller
public class OrderWebController {

    @Autowired
    private OrderService orderService;

    /**
     * 创建订单
     * 创建成功,跳转订单支付页
     * 创建失败,跳转结算页
     * 无需提交要购买的商品,提交订单时会实时查询最新的购物车商品选中数据提交
     */
    @TokenVerify
    @PostMapping(value = "/submitOrder")
    public String submitOrder(OrderSubmitVO vo, Model model, RedirectAttributes attributes) {
        try {
            SubmitOrderResponseVO orderVO = orderService.submitOrder(vo);
            // 创建订单成功,跳转收银台
            model.addAttribute("submitOrderResp", orderVO);// 封装VO订单数据,供页面解析[订单号、应付金额]
            return "pay";
        } catch (Exception e) {
            // 下单失败回到订单结算页
            if (e instanceof VerifyPriceException) {
                String message = ((VerifyPriceException) e).getMessage();
                attributes.addFlashAttribute("msg", "下单失败" + message);
            } else if (e instanceof NoStockException) {
                String message = ((NoStockException) e).getMessage();
                attributes.addFlashAttribute("msg", "下单失败" + message);
            }
            return "redirect:http://order.gulimall.com/toTrade";
        }
    }
}
@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {

    // 提交订单共享提交数据
    private ThreadLocal<OrderSubmitVO> confirmVoThreadLocal = new ThreadLocal<>();

    @Autowired
    MemberFeignService memberFeignService;
    @Autowired
    CartFeignService cartFeignService;
    @Autowired
    WmsFeignService wmsFeignService;
    @Autowired
    ProductFeignService productFeignService;
    @Autowired
    OrderItemServiceImpl orderItemService;
    @Autowired
    ThreadPoolExecutor executor;
    @Autowired
    TokenUtil tokenUtil;

    /**
     * 创建订单
     * GlobalTransactional:seata分布式事务,不适合高并发场景(默认基于AT实现)
     * @param vo 收货地址、发票信息、使用的优惠券、备注、应付总额、令牌
     */
    //@GlobalTransactional
    @Transactional
    @Override
    public SubmitOrderResponseVO submitOrder(OrderSubmitVO orderSubmitVO) throws Exception {
        SubmitOrderResponseVO result = new SubmitOrderResponseVO();// 返回值
        // 创建订单线程共享提交数据
        confirmVoThreadLocal.set(orderSubmitVO);
        // 1.生成订单实体对象(订单 + 订单项)
        OrderCreateTO order = createOrder();
        // 2.验价应付金额(允许0.01误差,前后端计算不一致)
        if (Math.abs(orderSubmitVO.getPayPrice().subtract(order.getPayPrice()).doubleValue()) >= 0.01) {
            // 验价不通过
            throw new VerifyPriceException();
        }
        // 验价成功
        // 3.保存订单
        saveOrder(order);
        // 4.库存锁定(wms_ware_sku)
        // 封装待锁定商品项TO
        WareSkuLockTO lockTO = new WareSkuLockTO();
        lockTO.setOrderSn(order.getOrder().getOrderSn());
        List<OrderItemVO> locks = order.getOrderItems().stream().map((item) -> {
            OrderItemVO lock = new OrderItemVO();
            lock.setSkuId(item.getSkuId());
            lock.setCount(item.getSkuQuantity());
            lock.setTitle(item.getSkuName());
            return lock;
        }).collect(Collectors.toList());
        lockTO.setLocks(locks);// 待锁定订单项
        R response = wmsFeignService.orderLockStock(lockTO);
        if (response.getCode() == 0) {
            // 锁定成功
            // TODO 5.远程扣减积分
            // 封装响应数据返回
            System.out.println(10 / 0);
            result.setOrder(order.getOrder());
            return result;
        } else {
            // 锁定失败
            throw new NoStockException("");
        }
    }

    /**
     * 封装订单实体类对象
     * 订单 + 订单项
     */
    private OrderCreateTO createOrder() throws Exception {
        OrderCreateTO result = new OrderCreateTO();// 订单
        // 1.生成订单号
        String orderSn = IdWorker.getTimeId();
        // 2.生成订单实体对象
        OrderEntity orderEntity = buildOrder(orderSn);
        // 3.生成订单项实体对象
        List<OrderItemEntity> orderItemEntities = buildOrderItems(orderSn);
        // 4.汇总封装(封装订单价格[订单项价格之和]、封装订单积分、成长值[订单项积分、成长值之和])
        summaryFillOrder(orderEntity, orderItemEntities);

        // 5.封装TO返回
        result.setOrder(orderEntity);
        result.setOrderItems(orderItemEntities);
        result.setFare(orderEntity.getFreightAmount());
        result.setPayPrice(orderEntity.getPayAmount());// 设置应付金额
        return result;
    }

    /**
     * 生成订单实体对象
     *
     * @param orderSn 订单号
     */
    private OrderEntity buildOrder(String orderSn) {
        OrderEntity orderEntity = new OrderEntity();// 订单实体类
        // 1.封装会员ID
        MemberResponseVO member = LoginUserInterceptor.loginUser.get();// 拦截器获取登录信息
        orderEntity.setMemberId(member.getId());
        // 2.封装订单号
        orderEntity.setOrderSn(orderSn);
        // 3.封装运费
        OrderSubmitVO orderSubmitVO = confirmVoThreadLocal.get();
        R fare = wmsFeignService.getFare(orderSubmitVO.getAddrId());// 获取地址
        FareVO fareVO = fare.getData(new TypeReference<FareVO>() {
        });
        orderEntity.setFreightAmount(fareVO.getFare());
        // 4.封装收货地址信息
        orderEntity.setReceiverName(fareVO.getAddress().getName());// 收货人名字
        orderEntity.setReceiverPhone(fareVO.getAddress().getPhone());// 收货人电话
        orderEntity.setReceiverProvince(fareVO.getAddress().getProvince());// 省
        orderEntity.setReceiverCity(fareVO.getAddress().getCity());// 市
        orderEntity.setReceiverRegion(fareVO.getAddress().getRegion());// 区
        orderEntity.setReceiverDetailAddress(fareVO.getAddress().getDetailAddress());// 详细地址
        orderEntity.setReceiverPostCode(fareVO.getAddress().getPostCode());// 收货人邮编
        // 5.封装订单状态信息
        orderEntity.setStatus(OrderConstant.OrderStatusEnum.CREATE_NEW.getCode());
        // 6.设置自动确认时间
        orderEntity.setAutoConfirmDay(OrderConstant.autoConfirmDay);// 7天
        // 7.设置未删除状态
        orderEntity.setDeleteStatus(ObjectConstant.BooleanIntEnum.NO.getCode());
        // 8.设置时间
        Date now = new Date();
        orderEntity.setCreateTime(now);
        orderEntity.setModifyTime(now);
        return orderEntity;
    }

    /**
     * 生成订单项实体对象
     * 购物车每项选中商品产生一个订单项
     */
    private List<OrderItemEntity> buildOrderItems(String orderSn) throws Exception {
        // 封装订单项(最后确定的价格,不会再改变)
        List<OrderItemVO> currentCartItems = cartFeignService.getCurrentCartItems();// 获取当前用户购物车所有商品
        if (!CollectionUtils.isEmpty(currentCartItems)) {
            // 遍历购物车商品,循环构建每个订单项
            List<OrderItemEntity> itemEntities = currentCartItems.stream()
                    .filter(cartItem -> cartItem.getCheck())
                    .map(cartItem -> buildOrderItem(orderSn, cartItem))
                    .collect(Collectors.toList());
            return itemEntities;
        } else {
            throw new Exception();
        }
    }

    /**
     * 生成单个订单项实体对象
     */
    private OrderItemEntity buildOrderItem(String orderSn, OrderItemVO cartItem) {
        OrderItemEntity itemEntity = new OrderItemEntity();
        // 1.封装订单号
        itemEntity.setOrderSn(orderSn);
        // 2.封装SPU信息
        R spuInfo = productFeignService.getSpuInfoBySkuId(cartItem.getSkuId());// 查询SPU信息
        SpuInfoTO spuInfoTO = spuInfo.getData(new TypeReference<SpuInfoTO>() {
        });
        itemEntity.setSpuId(spuInfoTO.getId());
        itemEntity.setSpuName(spuInfoTO.getSpuName());
        itemEntity.setSpuBrand(spuInfoTO.getSpuName());
        itemEntity.setCategoryId(spuInfoTO.getCatalogId());
        // 3.封装SKU信息
        itemEntity.setSkuId(cartItem.getSkuId());
        itemEntity.setSkuName(cartItem.getTitle());
        itemEntity.setSkuPic(cartItem.getImage());// 商品sku图片
        itemEntity.setSkuPrice(cartItem.getPrice());// 这个是最新价格,购物车模块查询数据库得到
        itemEntity.setSkuQuantity(cartItem.getCount());// 当前商品数量
        String skuAttrsVals = String.join(";", cartItem.getSkuAttrValues());
        itemEntity.setSkuAttrsVals(skuAttrsVals);// 商品销售属性组合["颜色:星河银","版本:8GB+256GB"]
        // 4.优惠信息【不做】

        // 5.积分信息
        int num = cartItem.getPrice().multiply(new BigDecimal(cartItem.getCount())).intValue();// 分值=单价*数量
        itemEntity.setGiftGrowth(num);// 成长值
        itemEntity.setGiftIntegration(num);// 积分

        // 6.价格信息
        itemEntity.setPromotionAmount(BigDecimal.ZERO);// 促销金额
        itemEntity.setCouponAmount(BigDecimal.ZERO);// 优惠券金额
        itemEntity.setIntegrationAmount(BigDecimal.ZERO);// 积分优惠金额
        BigDecimal realAmount = itemEntity.getSkuPrice().multiply(new BigDecimal(itemEntity.getSkuQuantity()))
                .subtract(itemEntity.getPromotionAmount())
                .subtract(itemEntity.getCouponAmount())
                .subtract(itemEntity.getIntegrationAmount());
        itemEntity.setRealAmount(realAmount);// 实际金额,减去所有优惠金额
        return itemEntity;
    }

    /**
     * 汇总封装订单
     * 1.计算订单总金额
     * 2.汇总积分、成长值
     * 3.汇总应付总额 = 订单总金额 + 运费
     *
     * @param orderEntity       订单
     * @param orderItemEntities 订单项
     */
    private void summaryFillOrder(OrderEntity orderEntity, List<OrderItemEntity> orderItemEntities) {
        // 1.订单总额、促销总金额、优惠券总金额、积分优惠总金额
        BigDecimal total = new BigDecimal(0);
        BigDecimal coupon = new BigDecimal(0);
        BigDecimal promotion = new BigDecimal(0);
        BigDecimal integration = new BigDecimal(0);
        // 2.积分、成长值
        Integer giftIntegration = 0;
        Integer giftGrowth = 0;
        for (OrderItemEntity itemEntity : orderItemEntities) {
            total = total.add(itemEntity.getRealAmount());// 订单总额
            coupon = coupon.add(itemEntity.getCouponAmount());// 促销总金额
            promotion = promotion.add(itemEntity.getPromotionAmount());// 优惠券总金额
            integration = integration.add(itemEntity.getIntegrationAmount());// 积分优惠总金额
            giftIntegration = giftIntegration + itemEntity.getGiftIntegration();// 积分
            giftGrowth = giftGrowth + itemEntity.getGiftGrowth();// 成长值
        }
        orderEntity.setTotalAmount(total);
        orderEntity.setCouponAmount(coupon);
        orderEntity.setPromotionAmount(promotion);
        orderEntity.setIntegrationAmount(integration);
        orderEntity.setIntegration(giftIntegration);// 积分
        orderEntity.setGrowth(giftGrowth);// 成长值

        // 3.应付总额
        orderEntity.setPayAmount(orderEntity.getTotalAmount().add(orderEntity.getFreightAmount()));// 订单总额 + 运费
    }

    /**
     * 保存订单
     * 将封装生成的订单对象 + 订单项对象持久化到DB
     * @param order
     */
    private void saveOrder(OrderCreateTO order) {
        // 1.持久化订单对象
        OrderEntity orderEntity = order.getOrder();
        save(orderEntity);

        // 2.持久化订单项对象
        List<OrderItemEntity> itemEntities = order.getOrderItems();
        orderItemService.saveBatch(itemEntities);
    }
}
7.1.2锁定库存

在这里插入图片描述

描述:
	所有商品锁定成功即成功,任一商品锁定失败创建订单回滚
这里没有给出代码,可以查看【第二版】

7.2.第二版(柔性事务)

在这里插入图片描述

7.2.1.实现方案
创建订单是高并发场景,不采用Seata(默认Seata是采用AT模式【2PC模式的变种】,性能低)
采用方案:【柔性事务】
	保证AP,采用本地事务+延时队列+监听死信队列解锁库存 的方案实现最终一致性
订单模块一个延时队列+死信队列,用于30min关闭订单
库存模块一个延时队列+死信队列,用于40min解锁库存

在这里插入图片描述

在这里插入图片描述

优化:

在这里插入图片描述

可靠消息+最终一致性:
	锁库存时,往队列发送一条库存解锁消息(在队列中设置超时时间而不是在消息中设置,具体查看MQ.md)
	消息超时后经过死信路由到达延时队列,解锁库存service监听延时队列,查询订单状态判断是否需要解锁库存
7.2.2.实现步骤
延时队列、死信队列
order模块队列
1.order创建关闭订单的延时队列、死信队列、交换机、绑定关系
/**
 * 创建队列,交换机,延时队列,绑定关系 的configuration
 * Broker中的Queue、Exchange、Binding不存在的情况下,会自动创建(在RabbitMQ),不会重复创建覆盖
 */
@Configuration
public class MyRabbitMQConfig {

    /**
     * 延时队列
     */
    @Bean
    public Queue orderDelayQueue() {
        /**
         * Queue(String name,  队列名字
         *       boolean durable,  是否持久化
         *       boolean exclusive,  是否排他
         *       boolean autoDelete, 是否自动删除
         *       Map<String, Object> arguments) 属性【TTL、死信路由、死信路由键】
         */
        HashMap<String, Object> arguments = new HashMap<>();
        arguments.put("x-dead-letter-exchange", "order-event-exchange");// 死信路由
        arguments.put("x-dead-letter-routing-key", "order.release.order");// 死信路由键
        arguments.put("x-message-ttl", 60000); // 消息过期时间 1分钟
        Queue queue = new Queue("order.delay.queue", true, false, false, arguments);

        return queue;
    }

    /**
     * 交换机(死信路由)
     */
    @Bean
    public Exchange orderEventExchange() {
        return new TopicExchange("order-event-exchange", true, false);
    }

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

    /**
     * 绑定:交换机与延迟队列
     */
    @Bean
    public Binding orderCreateBinding() {
        /**
         * String destination, 目的地(队列名或者交换机名字)
         * DestinationType destinationType, 目的地类型(Queue、Exhcange)
         * String exchange,
         * String routingKey,
         * Map<String, Object> arguments
         **/
        return new Binding("order.delay.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.create.order",
                null);
    }

    /**
     * 绑定:交换机与死信队列
     */
    @Bean
    public Binding orderReleaseBinding() {
        return new Binding("order.release.order.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.order",
                null);
    }

    /**
     * 绑定:订单释放直接和库存释放进行
     */
    @Bean
    public Binding orderReleaseOtherBinding() {
        return new Binding("stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.other.#",
                null);
    }
}
ware模块队列
1.ware模块导入mq依赖
<!--amqp高级消息队列协议,rabbitmq实现-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

2.ware模块导入配置
spring:
  rabbitmq:
    host: 192.168.56.10
    port: 5672
    # 虚拟主机
    virtual-host: /
    # 开启发送端发送确认,无论是否到达broker都会触发回调【发送端确认机制+本地事务表】
    publisher-confirm-type: correlated
    # 开启发送端抵达队列确认,消息未被队列接收时触发回调【发送端确认机制+本地事务表】
    publisher-returns: true
    # 消息在没有被队列接收时是否强行退回
    template:
      mandatory: true
    # 消费者手动确认模式,关闭自动确认,否则会消息丢失
    listener:
      simple:
        acknowledge-mode: manual

3.添加注解
// 开启rabbit
@EnableRabbit

4.创建配置类
/**
 * @Author: wanzenghui
 * @Date: 2021/12/15 0:04
 */
@Configuration
public class MyRabbitConfig {

    @Autowired
    RabbitTemplate rabbitTemplate;

    @Bean
    public MessageConverter messageConverter() {
        // 使用json序列化器来序列化消息,发送消息时,消息对象会被序列化成json格式
        return new Jackson2JsonMessageConverter();
    }

    /**
     * 定制RabbitTemplate
     * 1、服务收到消息就会回调
     * 1、spring.rabbitmq.publisher-confirms: true
     * 2、设置确认回调
     * 2、消息正确抵达队列就会进行回调
     * 1、spring.rabbitmq.publisher-returns: true
     * spring.rabbitmq.template.mandatory: true
     * 2、设置确认回调ReturnCallback
     * <p>
     * 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息)
     */
    @PostConstruct   // (MyRabbitConfig对象创建完成以后,执行这个方法)
    public void initRabbitTemplate() {
        /**
         * 发送消息触发confirmCallback回调
         * @param correlationData:当前消息的唯一关联数据(如果发送消息时未指定此值,则回调时返回null)
         * @param ack:消息是否成功收到(ack=true,消息抵达Broker)
         * @param cause:失败的原因
         */
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            System.out.println("发送消息触发confirmCallback回调" +
                    "\ncorrelationData ===> " + correlationData +
                    "\nack ===> " + ack + "" +
                    "\ncause ===> " + cause);
            System.out.println("=================================================");
        });

        /**
         * 消息未到达队列触发returnCallback回调
         * 只要消息没有投递给指定的队列,就触发这个失败回调
         * @param message:投递失败的消息详细信息
         * @param replyCode:回复的状态码
         * @param replyText:回复的文本内容
         * @param exchange:接收消息的交换机
         * @param routingKey:接收消息的路由键
         */
        rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
            // 需要修改数据库 消息的状态【后期定期重发消息】
            System.out.println("消息未到达队列触发returnCallback回调" +
                    "\nmessage ===> " + message +
                    "\nreplyCode ===> " + replyCode +
                    "\nreplyText ===> " + replyText +
                    "\nexchange ===> " + exchange +
                    "\nroutingKey ===> " + routingKey);
            System.out.println("==================================================");
        });
    }
}

5.创建ware解锁库存的延时队列、死信队列、交换机、绑定关系
/**
 * 创建队列,交换机,延时队列,绑定关系 的configuration
 * 1.Broker中的Queue、Exchange、Binding不存在的情况下,会自动创建(在RabbitMQ),不会重复创建覆盖
 * 2.懒加载,只有第一次使用的时候才会创建(例如监听队列)
 */
@Configuration
public class MyRabbitMQConfig {
    /**
     * 用于首次创建队列、交换机、绑定关系的监听
     * @param message
     */
    @RabbitListener(queues = "stock.release.stock.queue")
    public void handle(Message message) {
    }
    
    /**
     * 交换机
     * Topic,可以绑定多个队列
     */
    @Bean
    public Exchange stockEventExchange() {
        //String name, boolean durable, boolean autoDelete, Map<String, Object> arguments
        return new TopicExchange("stock-event-exchange", true, false);
    }

    /**
     * 死信队列
     */
    @Bean
    public Queue stockReleaseStockQueue() {
        //String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments
        return new Queue("stock.release.stock.queue", true, false, false);
    }

    /**
     * 延时队列
     */
    @Bean
    public Queue stockDelay() {
        HashMap<String, Object> arguments = new HashMap<>();
        arguments.put("x-dead-letter-exchange", "stock-event-exchange");
        arguments.put("x-dead-letter-routing-key", "stock.release");
        // 消息过期时间 2分钟
        arguments.put("x-message-ttl", 120000);
        return new Queue("stock.delay.queue", true, false, false,arguments);
    }

    /**
     * 绑定:交换机与死信队列
     */
    @Bean
    public Binding stockLocked() {
        //String destination, DestinationType destinationType, String exchange, String routingKey,
        // 			Map<String, Object> arguments
        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);
    }
}
7.2.3.解锁场景
场景:
	1.下订单成功,用户手动取消 || 订单过期未支付
	2.其他业务调用失败,订单回滚,但库存锁定成功(最终一致性,需要解锁库存)
7.2.4.锁定库存
1.锁定库存
2.往库存工作单存储当前锁定(本地事务表)
3.往延时队列发送库存锁定成功消息
/**
 * 库存锁定,sql执行锁定锁定
 *
 * @param lockTO
 * @return 锁定结果
 * @Transactional(rollbackFor = NoStockException.class):指定的异常出现会导致回滚
 * 未指定异常,任何运行时异常都会导致回滚,可以省略rollbackFor
 */
@Transactional
@Override
public Boolean orderLockStock(WareSkuLockTO lockTO) {
    // 按照收货地址找到就近仓库,锁定库存(暂未实现)
    // 采用方案:获取每项商品在哪些仓库有库存,轮询尝试锁定,任一商品锁定失败回滚

    // 1.往库存工作单存储当前锁定(本地事务表)
    WareOrderTaskEntity taskEntity = new WareOrderTaskEntity();
    taskEntity.setOrderSn(lockTO.getOrderSn());
    orderTaskService.save(taskEntity);

    // 2.封装待锁定库存项Map
    Map<Long, OrderItemVO> lockItemMap = lockTO.getLocks().stream().collect(Collectors.toMap(key -> key.getSkuId(), val -> val));
    // 3.查询(库存 - 库存锁定 >= 待锁定库存数)的仓库
    List<WareSkuEntity> wareEntities = baseMapper.selectListHasSkuStock(lockItemMap.keySet()).stream().filter(entity -> entity.getStock() - entity.getStockLocked() >= lockItemMap.get(entity.getSkuId()).getCount()).collect(Collectors.toList());
    // 判断是否查询到仓库
    if (CollectionUtils.isEmpty(wareEntities)) {
        // 匹配失败,所有商品项没有库存
        Set<Long> skuIds = lockItemMap.keySet();
        throw new NoStockException(skuIds);
    }
    // 将查询出的仓库数据封装成Map,key:skuId  val:wareId
    Map<Long, List<WareSkuEntity>> wareMap = wareEntities.stream().collect(Collectors.groupingBy(key -> key.getSkuId()));
    // 4.判断是否为每一个商品项至少匹配了一个仓库
    List<WareOrderTaskDetailEntity> taskDetails = new ArrayList<>();// 库存锁定工作单详情
    Map<Long, StockLockedTO> lockedMessageMap = new HashMap<>();// 库存锁定工作单消息
    if (wareMap.size() < lockTO.getLocks().size()) {
        // 匹配失败,部分商品没有库存
        Set<Long> skuIds = lockItemMap.keySet();
        skuIds.removeAll(wareMap.keySet());// 求商品项差集
        throw new NoStockException(skuIds);
    } else {
        // 所有商品都存在有库存的仓库
        // 5.锁定库存
        for (Map.Entry<Long, List<WareSkuEntity>> entry : wareMap.entrySet()) {
            Boolean skuStocked = false;
            Long skuId = entry.getKey();// 商品
            OrderItemVO item = lockItemMap.get(skuId);
            Integer count = item.getCount();// 待锁定个数
            List<WareSkuEntity> hasStockWares = entry.getValue();// 有足够库存的仓库
            for (WareSkuEntity ware : hasStockWares) {
                Long num = baseMapper.lockSkuStock(skuId, ware.getWareId(), count);
                if (num == 1) {
                    // 锁定成功,跳出循环
                    skuStocked = true;
                    // 创建库存锁定工作单详情(每一件商品锁定详情)
                    WareOrderTaskDetailEntity taskDetail = new WareOrderTaskDetailEntity(null, skuId,
                            item.getTitle(), count, taskEntity.getId(), ware.getWareId(),
                            WareOrderTaskConstant.LockStatusEnum.LOCKED.getCode());
                    taskDetails.add(taskDetail);
                    // 创建库存锁定工作单消息(每一件商品一条消息)
                    StockDetailTO detailMessage = new StockDetailTO();
                    BeanUtils.copyProperties(taskDetail, detailMessage);
                    StockLockedTO lockedMessage = new StockLockedTO(taskEntity.getId(), detailMessage);
                    lockedMessageMap.put(skuId, lockedMessage);
                    break;
                }
            }
            if (!skuStocked) {
                // 匹配失败,当前商品所有仓库都未锁定成功
                throw new NoStockException(skuId);
            }
        }
    }

    // 6.往库存工作单详情存储当前锁定(本地事务表)
    orderTaskDetailService.saveBatch(taskDetails);

    // 7.发送消息
    for (WareOrderTaskDetailEntity taskDetail : taskDetails) {
        StockLockedTO message = lockedMessageMap.get(taskDetail.getSkuId());
        message.getDetail().setId(taskDetail.getId());// 存储库存详情ID
        rabbitTemplate.convertAndSend("stock-event-exchange", "stock.locked", message);
    }
    return true;
}
7.2.5.生成订单
下单成功,往订单解锁延时队列发送消息
// 发送创建订单到延时队列
/**
 * 创建订单
 * GlobalTransactional:seata分布式事务,不适合高并发场景(默认基于AT实现)
 *
 * @param vo 收货地址、发票信息、使用的优惠券、备注、应付总额、令牌
 */
//@GlobalTransactional
@Transactional
@Override
public SubmitOrderResponseVO submitOrder(OrderSubmitVO orderSubmitVO) throws Exception {
    SubmitOrderResponseVO result = new SubmitOrderResponseVO();// 返回值
    // 创建订单线程共享提交数据
    confirmVoThreadLocal.set(orderSubmitVO);
    // 1.生成订单实体对象(订单 + 订单项)
    OrderCreateTO order = createOrder();
    // 2.验价应付金额(允许0.01误差,前后端计算不一致)
    if (Math.abs(orderSubmitVO.getPayPrice().subtract(order.getPayPrice()).doubleValue()) >= 0.01) {
        // 验价不通过
        throw new VerifyPriceException();
    }
    // 验价成功
    // 3.保存订单
    saveOrder(order);
    // 4.库存锁定(wms_ware_sku)
    // 封装待锁定商品项TO
    WareSkuLockTO lockTO = new WareSkuLockTO();
    lockTO.setOrderSn(order.getOrder().getOrderSn());
    List<OrderItemVO> locks = order.getOrderItems().stream().map((item) -> {
        OrderItemVO lock = new OrderItemVO();
        lock.setSkuId(item.getSkuId());
        lock.setCount(item.getSkuQuantity());
        lock.setTitle(item.getSkuName());
        return lock;
    }).collect(Collectors.toList());
    lockTO.setLocks(locks);// 待锁定订单项
    R response = wmsFeignService.orderLockStock(lockTO);
    if (response.getCode() == 0) {
        // 锁定成功
        // TODO 5.远程扣减积分
        // 封装响应数据返回
        result.setOrder(order.getOrder());
        //System.out.println(10 / 0); // 模拟订单回滚,库存不会滚
        // 6.发送创建订单到延时队列
        rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", order.getOrder());
        return result;
    } else {
        // 锁定失败
        throw new NoStockException("");
    }
}
bug_解锁订单晚于解锁库存执行

在这里插入图片描述

bug:
	订单解锁晚于库存解锁执行导致库存永远不会被解锁
	
bug重现:
	机器卡顿,订单解锁的消息延迟抵达,造成订单解锁晚于库存解锁执行,此时库存解锁失败,因为订单还处于未支付状态,导致库存未解锁,并且消息已经确认
	
解决方案:
	方案一:
		库存解锁消息重新入队(不建议,因为无法判断消息延迟的具体时间,造成消息空转浪费资源)
	方案二:
		消费订单解锁消息时,往库存解锁的死信队列丢一条消息(同时是消费者和生产者)

在这里插入图片描述

7.2.6.解锁订单
场景:
	1.订单过期未支付

实现:
	生成订单时创建消息放入延时队列
	解锁订单方法监听死信队列
	解锁订单时为了防止订单解锁晚于库存解锁的BUG,此时主动往解锁库存的死信队列发送一条消息
/**
 * 创建队列,交换机,延时队列,绑定关系 的configuration
 * 1.Broker中的Queue、Exchange、Binding不存在的情况下,会自动创建(在RabbitMQ),不会重复创建覆盖
 * 2.懒加载,只有第一次使用的时候才会创建(例如监听队列)
 */
@Configuration
public class MyRabbitMQConfig {

    /**
     * 延时队列
     */
    @Bean
    public Queue orderDelayQueue() {
        /**
         * Queue(String name,  队列名字
         *       boolean durable,  是否持久化
         *       boolean exclusive,  是否排他
         *       boolean autoDelete, 是否自动删除
         *       Map<String, Object> arguments) 属性【TTL、死信路由、死信路由键】
         */
        HashMap<String, Object> arguments = new HashMap<>();
        arguments.put("x-dead-letter-exchange", "order-event-exchange");// 死信路由
        arguments.put("x-dead-letter-routing-key", "order.release.order");// 死信路由键
        arguments.put("x-message-ttl", 60000); // 消息过期时间 1分钟
        return new Queue("order.delay.queue", true, false, false, arguments);
    }

    /**
     * 交换机(死信路由)
     */
    @Bean
    public Exchange orderEventExchange() {
        return new TopicExchange("order-event-exchange", true, false);
    }

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

    /**
     * 绑定:交换机与订单解锁延迟队列
     */
    @Bean
    public Binding orderCreateBinding() {
        /**
         * String destination, 目的地(队列名或者交换机名字)
         * DestinationType destinationType, 目的地类型(Queue、Exhcange)
         * String exchange,
         * String routingKey,
         * Map<String, Object> arguments
         **/
        return new Binding("order.delay.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.create.order",
                null);
    }

    /**
     * 绑定:交换机与订单解锁死信队列
     */
    @Bean
    public Binding orderReleaseBinding() {
        return new Binding("order.release.order.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.order",
                null);
    }

    /**
     * 绑定:交换机与库存解锁
     */
    @Bean
    public Binding orderReleaseOtherBinding() {
        return new Binding("stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.other.#",
                null);
    }
}
/**
 * 定时关单,监听死信队列
 * @Author: wanzenghui
 * @Date: 2022/1/3 17:24
 */
@Slf4j
@RabbitListener(queues = "order.release.order.queue")
@Component
public class OrderCloseListener {

    @Autowired
    OrderService orderService;

    @RabbitHandler
    public void handleOrderRelease(OrderEntity order, Message message, Channel channel) throws IOException {
        log.debug("订单解锁,订单号:" + order.getOrderSn());
        try {
            orderService.closeOrder(order);
            // 手动删除消息
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            // 解锁失败 将消息重新放回队列,让别人消费
            channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
        }
    }
}



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

    /**
     * 关闭订单
     */
    @Override
    public void closeOrder(OrderEntity order) {
        OrderEntity _order = getById(order.getId());
        if (OrderConstant.OrderStatusEnum.CREATE_NEW.getCode().equals(_order.getStatus())) {
            // 待付款状态允许关单
            OrderEntity temp = new OrderEntity();
            temp.setId(order.getId());
            temp.setStatus(OrderConstant.OrderStatusEnum.CANCLED.getCode());
            updateById(temp);

            // 发送消息给MQ
            OrderTO orderTO = new OrderTO();
            BeanUtils.copyProperties(_order, orderTO);
            //TODO 确保每个消息发送成功,给每个消息做好日志记录,(给数据库保存每一个详细信息)保存每个消息的详细信息
            //TODO 定期扫描数据库,重新发送失败的消息
            rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTO);
        }
    }
}
/**
 * 解锁库存,监听死信队列
 *
 * @author: wanzenghui
 **/
@Slf4j
@RabbitListener(queues = "stock.release.stock.queue")
@Component
public class StockReleaseListener {

    @Autowired
    private WareSkuService wareSkuService;

    /**
     * 客户取消订单,监听到消息
     */
    @RabbitHandler
    public void handleOrderCloseRelease(OrderTO orderTo, Message message, Channel channel) throws IOException {
        log.debug("订单关闭准备解锁库存,订单号:" + orderTo.getOrderSn());
        try {
            wareSkuService.unLockStock(orderTo);
            // 手动删除消息
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            // 解锁失败 将消息重新放回队列,让别人消费
            channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
        }
    }
}


@Slf4j
@Service("wareSkuService")
public class WareSkuServiceImpl extends ServiceImpl<WareSkuDao, WareSkuEntity> implements WareSkuService {

    /**
     * 库存解锁
     * 订单解锁触发,防止库存解锁消息优先于订单解锁消息到期,导致库存无法解锁
     */
    @Transactional
    @Override
    public void unLockStock(OrderTO order) {
        String orderSn = order.getOrderSn();// 订单号
        // 1.根据订单号查询库存锁定工作单
        WareOrderTaskEntity task = orderTaskService.getOrderTaskByOrderSn(orderSn);
        // 2.按照工作单查询未解锁的库存,进行解锁
        List<WareOrderTaskDetailEntity> taskDetails = orderTaskDetailService.list(new QueryWrapper<WareOrderTaskDetailEntity>()
                .eq("task_id", task.getId())
                .eq("lock_status", WareOrderTaskConstant.LockStatusEnum.LOCKED.getCode()));// 并发问题
        // 3.解锁库存
        for (WareOrderTaskDetailEntity taskDetail : taskDetails) {
            unLockStock(taskDetail.getSkuId(), taskDetail.getWareId(), taskDetail.getSkuNum(), taskDetail.getId());
        }
    }

    /**
     * 库存解锁
     * 1.sql执行释放锁定
     * 2.更新库存工作单状态为已解锁
     *
     * @param skuId
     * @param wareId
     * @param count
     */
    public void unLockStock(Long skuId, Long wareId, Integer count, Long taskDetailId) {
        // 1.库存解锁
        baseMapper.unLockStock(skuId, wareId, count);

        // 2.更新工作单的状态 已解锁
        WareOrderTaskDetailEntity taskDetail = new WareOrderTaskDetailEntity();
        taskDetail.setId(taskDetailId);
        taskDetail.setLockStatus(WareOrderTaskConstant.LockStatusEnum.UNLOCKED.getCode());
        orderTaskDetailService.updateById(taskDetail);
    }
}
7.2.7.解锁库存
场景:
	1.下订单成功,用户手动取消 || 订单过期未支付
	2.订单回滚,其他业务调用失败,但库存锁定成功(最终一致性,解锁库存)

实现:
	监听死信队列,拿到库存锁定工作单解锁库存(解锁时判断是否允许解锁)
/**
 * 监听死信队列,解锁库存
 * 库存解锁,监听
 *
 * @author: wanzenghui
 **/
@Slf4j
@RabbitListener(queues = "stock.release.stock.queue")
@Component
public class StockReleaseListener {

    @Autowired
    private WareSkuService wareSkuService;

    /**
     * 库存解锁(监听死信队列)
     * 场景:
     * 1.下订单成功【需要解锁】(订单过期未支付、被用户手动取消、其他业务调用失败(订单回滚))
     * 2.下订单失败【无需解锁】(库存锁定失败(库存锁定已回滚,但消息已发出))
     * <p>
     * 注意:需要开启手动确认,不要删除消息,当前解锁失败需要重复解锁
     */
    @RabbitHandler
    public void handleStockLockedRelease(StockLockedTO locked, Message message, Channel channel) throws IOException {
        log.debug("收到解锁库存消息");
        //当前消息是否重新派发过来
        // Boolean redelivered = message.getMessageProperties().getRedelivered();
        try {
            // 解锁库存
            wareSkuService.unLockStock(locked);
            // 解锁成功,手动确认
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            // 解锁失败,消息入队
            channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
        }
    }
}



@Slf4j
@Service("wareSkuService")
public class WareSkuServiceImpl extends ServiceImpl<WareSkuDao, WareSkuEntity> implements WareSkuService {

    @Autowired
    WareOrderTaskServiceImpl orderTaskService;
    @Autowired
    WareOrderTaskDetailServiceImpl orderTaskDetailService;
    @Autowired
    OrderFeignService orderFeignService;

    /**
     * 库存解锁
     */
    @Override
    public void unLockStock(StockLockedTO locked) throws Exception {
        StockDetailTO taskDetailTO = locked.getDetail();// 库存工作单详情TO
        WareOrderTaskDetailEntity taskDetail = orderTaskDetailService.getById(taskDetailTO.getId());// 库存工作单详情Entity
        if (taskDetail != null) {
            // 1.工作单未回滚,需要解锁
            WareOrderTaskEntity task = orderTaskService.getById(locked.getId());// 库存工作单Entity
            R r = orderFeignService.getOrderByOrderSn(task.getOrderSn());// 订单Entity
            if (r.getCode() == 0) {
                // 订单数据返回成功
                OrderTO order = r.getData(new TypeReference<OrderTO>() {
                });
                if (order == null || OrderConstant.OrderStatusEnum.CANCLED.getCode().equals(order.getStatus())) {
                    // 2.订单已回滚 || 订单未回滚已取消状态
                    if (WareOrderTaskConstant.LockStatusEnum.LOCKED.getCode().equals(taskDetail.getLockStatus())) {
                        // 订单已锁定状态,需要解锁(消息确认)
                        unLockStock(taskDetailTO.getSkuId(), taskDetailTO.getWareId(), taskDetailTO.getSkuNum(), taskDetailTO.getId());
                    } else {
                        // 订单其他状态,不可解锁(消息确认)
                    }
                }
            } else {
                // 订单远程调用失败(消息重新入队)
                throw new Exception();
            }
        } else {
            // 3.无库存锁定工作单记录,已回滚,无需解锁(消息确认)
        }
    }

    /**
     * 库存解锁
     * 1.sql执行释放锁定
     * 2.更新库存工作单状态为已解锁
     *
     * @param skuId
     * @param wareId
     * @param count
     */
    @Override
    public void unLockStock(Long skuId, Long wareId, Integer count, Long taskDetailId) {
        // 1.库存解锁
        baseMapper.unLockStock(skuId, wareId, count);

        // 2.更新工作单的状态 已解锁
        WareOrderTaskDetailEntity taskDetail = new WareOrderTaskDetailEntity();
        taskDetail.setId(taskDetailId);
        taskDetail.setLockStatus(WareOrderTaskConstant.LockStatusEnum.UNLOCKED.getCode());
        orderTaskDetailService.updateById(taskDetail);
    }
}
bug_ware远程调用订单被登录拦截
// ware远程调用订单,请求头没有登录消息被拦截,应该放行

/**
 * 登录拦截器
 * 从session中获取了登录信息(redis中),封装到了ThreadLocal中
 *
 * @Author: wanzenghui
 * @Date: 2021/12/20 22:29
 */
@Component
public class LoginUserInterceptor implements HandlerInterceptor {

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

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 放行无需登录的请求
        String uri = request.getRequestURI();
        AntPathMatcher antPathMatcher = new AntPathMatcher();// 匹配器
        boolean match = antPathMatcher.match("/order/order/status/**", uri);// 查询订单消息
        boolean match1 = antPathMatcher.match("/payed/notify", uri);// 支付回调
        if (match || match1) {
            return true;
        }

        // 获取登录用户信息
        MemberResponseVO attribute = (MemberResponseVO) 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("<script>alert('请先进行登录,再进行后续操作!');location.href='http://auth.gulimall.com/login.html'</script>");
            // session.setAttribute("msg", "请先进行登录");
            // response.sendRedirect("http://auth.gulimall.com/login.html");
            return false;
        }
    }
}

7.3.消息丢失、消息重复、消息积压

  • 消息丢失:

    • 情况1:网络连接失败,消息未抵达Broker
    • 解决:发送消息时同时将消息持久化到MQ中并插入DB(DB消息状态为已抵达)
      当出现异常时在catch处修改消息状态为错误抵达
    • 情况2:消息抵达Broker,但为抵达queue,消息会丢失(只有抵达了queue消息才会持久化)
    • 解决:开启生产者确认机制,将触发returnCallback.returnedMessage的消息DB状态修改为错误抵达
    • 情况3:消费者未ack时宕机,导致消息丢失
    • 解决:开启消费者手动ack

  • 消息重复
    • 情况1:业务逻辑已经执行,但是ack时宕机,消息由unack变为ready,消息重新入队
    • 解决:将接口设计成幂等性,例如库存解锁时判断工作单的状态,已解锁则无操作
    • 解决2:防重表

  • 消息积压
    • 情况1:生产者流量太大

    • 解决:减慢发送消息速率(验证码、防刷、重定向、削峰)

    • 情况2:消费者能力不足或宕机

    • 解决:上线更多消费者

    • 解决2:上线专门的队列消费服务,批量取出消息入库,离线处理业务慢慢处理

1.网络宕机修改mq_message消息状态
/**
 * 关闭订单
 */
@Override
public void closeOrder(OrderEntity order) {
    OrderEntity _order = getById(order.getId());
    if (OrderConstant.OrderStatusEnum.CREATE_NEW.getCode().equals(_order.getStatus())) {
        // 代付款状态允许关单
        OrderEntity temp = new OrderEntity();
        temp.setId(order.getId());
        temp.setStatus(OrderConstant.OrderStatusEnum.CANCLED.getCode());
        updateById(temp);

        try {
            // 发送消息给MQ
            OrderTO orderTO = new OrderTO();
            BeanUtils.copyProperties(_order, orderTO);
            //TODO 持久化消息到mq_message表中,并设置消息状态为3-已抵达(保存日志记录)
            rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTO);
        } catch (Exception e) {
            // TODO 消息为抵达Broker,修改mq_message消息状态为2-错误抵达
        }
    }
}

2.消息未抵达queue时修改mq_message消息状态
@Configuration
public class MyRabbitConfig {
    /**
     * 定制RabbitTemplate
     * 1、服务收到消息就会回调
     * 1、spring.rabbitmq.publisher-confirms: true
     * 2、设置确认回调
     * 2、消息正确抵达队列就会进行回调
     * 1、spring.rabbitmq.publisher-returns: true
     * spring.rabbitmq.template.mandatory: true
     * 2、设置确认回调ReturnCallback
     * <p>
     * 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息)
     */
    @PostConstruct   // (MyRabbitConfig对象创建完成以后,执行这个方法)
    public void initRabbitTemplate() {
        /**
         * 消息未到达队列触发returnCallback回调
         * 只要消息没有投递给指定的队列,就触发这个失败回调
         * @param message:投递失败的消息详细信息
         * @param replyCode:回复的状态码
         * @param replyText:回复的文本内容
         * @param exchange:接收消息的交换机
         * @param routingKey:接收消息的路由键
         */
        rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
            System.out.println("消息未到达队列触发returnCallback回调" +
                    "\nmessage ===> " + message +
                    "\nreplyCode ===> " + replyCode +
                    "\nreplyText ===> " + replyText +
                    "\nexchange ===> " + exchange +
                    "\nroutingKey ===> " + routingKey);
            // TODO 修改mq_message,设置消息状态为2-错误抵达【后期定时器重发消息】
        });
    }
}

3.开启消费者手动确认,详见之前属性配置处
    
4.将消费者接口设计成幂等性防止重复消费
优化方案
可以添加一个消息服务,各模块调用发送消息API即可
实现消息存库+异常修改状态

思考:如果feign调用失败不会出现问题,做好本地事务(feign失败即回滚)+接口幂等性即可

8.支付

参考
	支付.md

8.1.member模块

支付成功回调地址member模块,所以这里要作member的相关配置

1.上传静态资源(订单列表页,用于支付成功同步回调)
将订单页 文件夹下静态资源拷贝到=>/mydata/nginx/html/static/member
将orderList.html拷贝到member模块下

2.member模块增加登录拦截器				LoginUserInterceptor
  增加拦截器配置类,将登录拦截器注册进去	  WebMvcConfigurer
  
3.网关配置转发
        - id: gulimall_member_route
          uri: lb://gulimall-member
          predicates:
            - Host=member.gulimall.com

4.增加本地host映射
# gulimall
192.168.56.10 gulimall.com
192.168.56.10 search.gulimall.com
192.168.56.10 item.gulimall.com
192.168.56.10 auth.gulimall.com
192.168.56.10 cart.gulimall.com
192.168.56.10 order.gulimall.com
192.168.56.10 member.gulimall.com

5.Feign请求头丢失问题
	订单支付成功回调:http://member.gulimall.com/memberOrder.html
	此时浏览器访问时member是带了cookie的,但远程请求order查询订单数据时,请求头丢失
	添加feign请求拦截器,封装请求头:GuliFeignConfig

在这里插入图片描述

8.1.1.同步回调
不建议在同步回调直接修改订单状态,推荐在异步回调的时候修改订单状态
8.1.2.异步回调

在这里插入图片描述

1.推荐在异步回调时修改订单状态
2.修改订单状态前要验签sign

程序执行完后必须打印输出“success”(不包含引号)。如果商户反馈给支付宝的字符不是 success 这7个字符,支付宝服务器会不断重发通知,直到超过 24 小时 22 分钟。一般情况下,25 小时以内完成 8 次通知(通知的间隔频率一般是:4m,10m,10m,1h,2h,6h,15h)。

8.2.内网穿透联调BUG

在这里插入图片描述

8.2.1.内网穿透
参考nps、npc相关文档搭建内网穿透
  • bug1:使用nps作内网穿透,无法使用域名必须使用IP:PORT,所以会造成nginx无法根据访问的域名gulimall.com来匹配请求
    • 解决:
      • 方案一:修改nginx配置文件gulimall.conf监听server_name 124.223.7.41

  • bug2:添加以上域名监听后,访问124.223.7.41:8888出现404异常
    • 原因:网关88未拦截到请求
    • 解决:
      • 方案一:在网关增加拦截规则,拦截124.223.7.41,将请求发送到order.gulimall.com
      • 方案二:在nginx转发时,设置host=order.gulimall.com,使网关可以正确拦截【推荐】
      • 方案三:内网穿透的地址直接配成192.168.56.1:9000【缺点:没有负载均衡了】
bug1:
	修改gulimall.conf
server {
    listen       80;
    server_name gulimall.com *.gulimall.com 124.223.7.41;

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

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

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

bug2:
方案一:
        - id: gulimall_order_route2
          uri: lb://gulimall-order
          predicates:
            - Host=124.223.7.41

方案二:
	修改gulimall.conf
server {
    listen       80;
    server_name gulimall.com *.gulimall.com 124.223.7.41;

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

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

在这里插入图片描述

内网穿透配置:

在这里插入图片描述

查看nginx异常命令

cd  /mydata/nginx/logs
cat error.log|grep 'payed'

9.收单

在这里插入图片描述

1.订单超时,不允许支付
	解决:支付时设置超时时间:应该设置订单绝对超时时间,而不是30m,按照创建订单+30m来算截止时间
	time_expire
	
2.订单解锁完成,异步通知才到
	解决:释放库存的时候,手动调用收单功能(参照官方demo的实现)

九、秒杀模块

在这里插入图片描述

1.后台接口

1.1.【新增】秒杀场次

在这里插入图片描述

CREATE TABLE `sms_seckill_session` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `name` varchar(200) DEFAULT NULL COMMENT '场次名称',
  `start_time` datetime DEFAULT NULL COMMENT '每日开始时间',
  `end_time` datetime DEFAULT NULL COMMENT '每日结束时间',
  `status` tinyint(1) DEFAULT NULL COMMENT '启用状态',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动场次';
@RestController
@RequestMapping("coupon/seckillsession")
public class SeckillSessionController {
    
    @Autowired
    private SeckillSessionService seckillSessionService;
    
    /**
     * 保存
     */
    @RequestMapping("/save")
    public R save(@RequestBody SeckillSessionEntity seckillSession){
		seckillSessionService.save(seckillSession);

        return R.ok();
    }
}

1.2.【查询】指定场次关联的商品列表

http://localhost:88/api/coupon/seckillskurelation/list?t=1641391939514&page=1&limit=10&key=&promotionSessionId=1
@Service("seckillSkuRelationService")
public class SeckillSkuRelationServiceImpl extends ServiceImpl<SeckillSkuRelationDao, SeckillSkuRelationEntity> implements SeckillSkuRelationService {

    @Override
    public PageUtils queryPage(Map<String, Object> params) {
        QueryWrapper<SeckillSkuRelationEntity> wrapper = new QueryWrapper<>();
        String promotionSessionId = (String) params.get("promotionSessionId");
        if (StringUtils.isNotBlank(promotionSessionId)) {
            wrapper.eq("promotion_session_id", promotionSessionId);
        }
        IPage<SeckillSkuRelationEntity> page = this.page(
                new Query<SeckillSkuRelationEntity>().getPage(params),
                wrapper
        );

        return new PageUtils(page);
    }
}

1.3.【新增】秒杀场次关联商品

CREATE TABLE `sms_seckill_sku_relation` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `promotion_id` bigint(20) DEFAULT NULL COMMENT '活动id',
  `promotion_session_id` bigint(20) DEFAULT NULL COMMENT '活动场次id',
  `sku_id` bigint(20) DEFAULT NULL COMMENT '商品id',
  `seckill_price` decimal(10,4) DEFAULT NULL COMMENT '秒杀价格',
  `seckill_count` int(11) DEFAULT NULL COMMENT '秒杀总量',
  `seckill_limit` int(11) DEFAULT NULL COMMENT '每人限购数量',
  `seckill_sort` int(11) DEFAULT NULL COMMENT '排序',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动商品关联';
/**
 * 秒杀活动商品关联
 *
 * @author wanzenghui
 * @email lemon_wan@aliyun.com
 * @date 2021-09-02 22:43:18
 */
@RestController
@RequestMapping("coupon/seckillskurelation")
public class SeckillSkuRelationController {
    @Autowired
    private SeckillSkuRelationService seckillSkuRelationService;

    /**
     * 保存
     */
    @RequestMapping("/save")
    public R save(@RequestBody SeckillSkuRelationEntity seckillSkuRelation){
		seckillSkuRelationService.save(seckillSkuRelation);

        return R.ok();
    }
}

2.新增秒杀模块

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

3.【定时上架】秒杀场次+商品

在这里插入图片描述

1.提前将要秒杀的商品上架到redis中(减少db压力)
	从redis获取秒杀商品
	实现:
		使用定时任务,扫描第二天要秒杀的商品上架到redis中
		
2.秒杀商品的库存也上传到redis
	从redis扣除库存(信号量的方式)
/**
 * 定时任务
 * @Description:
 * @Created: with IntelliJ IDEA.
 * @author: wanzenghui
 * @createTime: 2020-07-09 19:22
 */
@Slf4j
@Service
public class SeckillScheduled {

    @Autowired
    SeckillService seckillService;
    @Autowired
    RedissonClient redissonClient;

    /**
     * 秒杀商品定时上架,保证幂等性问题
     *  每天晚上3点,上架最近三天需要秒杀的商品
     *  当天00:00:00 - 23:59:59
     *  明天00:00:00 - 23:59:59
     *  后天00:00:00 - 23:59:59
     */
    @Scheduled(cron = "*/10 * * * * ? ")
    //@Scheduled(cron = "0 0 3 * * ? ")
    public void uploadSeckillSkuLatest3Days() {
        // 重复上架无需处理
        log.info("上架秒杀的商品...");

        // 分布式锁(幂等性)
        RLock lock = redissonClient.getLock(SeckillConstant.UPLOAD_LOCK);
        try {
            lock.lock(10, TimeUnit.SECONDS);
            // 上架最近三天需要秒杀的商品
            seckillService.uploadSeckillSkuLatest3Days();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}
@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {

    @Autowired
    CouponFeignService couponFeignService;
    @Autowired
    ProductFeignService productFeignService;
    @Autowired
    StringRedisTemplate redisTemplate;
    @Autowired
    RabbitTemplate rabbitTemplate;
    @Autowired
    RedissonClient redissonClient;

    /**
     * 上架最近三天需要秒杀的商品
     */
    @Override
    public void uploadSeckillSkuLatest3Days() {
        // 1.查询最近三天需要参加秒杀的场次+商品
        R lates3DaySession = couponFeignService.getLates3DaySession();
        if (lates3DaySession.getCode() == 0) {
            // 获取场次
            List<SeckillSessionWithSkusTO> sessions = lates3DaySession.getData("data", new TypeReference<List<SeckillSessionWithSkusTO>>() {
            });
            // 2.上架场次信息
            saveSessionInfos(sessions);
            // 3.上架商品信息
            saveSessionSkuInfo(sessions);
        }

    }

    /**
     * 上架场次
     */
    private void saveSessionInfos(List<SeckillSessionWithSkusTO> sessions) {
        if (!CollectionUtils.isEmpty(sessions)) {
            sessions.stream().forEach(session -> {
                // 1.遍历场次
                long startTime = session.getStartTime().getTime();// 场次开始时间戳
                long endTime = session.getEndTime().getTime();// 场次结束时间戳
                String key = SeckillConstant.SESSION_CACHE_PREFIX + startTime + "_" + endTime;// 场次的key

                // 2.判断场次是否已上架(幂等性)
                Boolean hasKey = redisTemplate.hasKey(key);
                if (!hasKey) {
                    // 未上架
                    // 3.封装场次信息
                    List<String> skuIds = session.getRelationSkus().stream()
                            .map(item -> item.getPromotionSessionId() + "_" + item.getSkuId().toString())
                            .collect(Collectors.toList());// skuId集合
                    // 4.上架
                    redisTemplate.opsForList().leftPushAll(key, skuIds);
                }
            });
        }
    }

    /**
     * 上架商品信息
     */
    private void saveSessionSkuInfo(List<SeckillSessionWithSkusTO> sessions) {
        if (!CollectionUtils.isEmpty(sessions)) {
            // 查询所有商品信息
            List<Long> skuIds = new ArrayList<>();
            sessions.stream().forEach(session -> {
                List<Long> ids = session.getRelationSkus().stream().map(SeckillSkuVO::getSkuId).collect(Collectors.toList());
                skuIds.addAll(ids);
            });
            R info = productFeignService.getSkuInfos(skuIds);
            if (info.getCode() == 0) {
                // 将查询结果封装成Map集合
                Map<Long, SkuInfoTO> skuMap = info.getData(new TypeReference<List<SkuInfoTO>>() {
                }).stream().collect(Collectors.toMap(SkuInfoTO::getSkuId, val -> val));
                // 绑定秒杀商品hash
                BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(SeckillConstant.SECKILL_CHARE_KEY);
                // 1.遍历场次
                sessions.stream().forEach(session -> {
                    // 2.遍历商品
                    session.getRelationSkus().stream().forEach(seckillSku -> {
                        // 判断商品是否已上架(幂等性)
                        String skuKey = seckillSku.getPromotionSessionId().toString() + "_" + seckillSku.getSkuId().toString();// 商品的key(需要添加场次ID前缀,同一款商品可能场次不同)
                        if (!operations.hasKey(skuKey)) {
                            // 未上架
                            // 3.封装商品信息
                            SeckillSkuRedisTO redisTo = new SeckillSkuRedisTO();// 存储到redis的对象
                            SkuInfoTO sku = skuMap.get(seckillSku.getSkuId());
                            BeanUtils.copyProperties(seckillSku, redisTo);// 商品秒杀信息
                            redisTo.setSkuInfo(sku);// 商品详细信息
                            redisTo.setStartTime(session.getStartTime().getTime());// 秒杀开始时间
                            redisTo.setEndTime(session.getEndTime().getTime());// 秒杀结束时间
                            // 商品随机码:用户参与秒杀时,请求需要带上随机码(防止恶意攻击)
                            String token = UUID.randomUUID().toString().replace("-", "");// 商品随机码(随机码只会在秒杀开始时暴露)
                            redisTo.setRandomCode(token);// 设置商品随机码

                            // 4.上架商品(序列化成json格式存入Redis中)
                            String jsonString = JSONObject.toJSONString(redisTo);
                            operations.put(skuKey, jsonString);

                            // 5.上架商品的分布式信号量,key:商品随机码 值:库存(限流)
                            RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + token);
                            // 信号量(扣减成功才进行后续操作,否则快速返回)
                            semaphore.trySetPermits(seckillSku.getSeckillCount());
                        }
                    });
                });
            }
        }
    }
}

4.【查询】当前可参与的秒杀商品列表

@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {

    @Autowired
    CouponFeignService couponFeignService;
    @Autowired
    ProductFeignService productFeignService;
    @Autowired
    StringRedisTemplate redisTemplate;
    @Autowired
    RabbitTemplate rabbitTemplate;
    @Autowired
    RedissonClient redissonClient;
    
    /**
     * 获取到当前可以参加秒杀商品的信息
     */
    //@SentinelResource(value = "getCurrentSeckillSkusResource", blockHandler = "blockHandler")
    @Override
    public List<SeckillSkuRedisTO> getCurrentSeckillSkus() {
        //try (Entry entry = SphU.entry("seckillSkus")) {
        // 1.查询当前时间所属的秒杀场次
        long currentTime = System.currentTimeMillis();// 当前时间
        // 查询所有秒杀场次的key
        Set<String> keys = redisTemplate.keys(SeckillConstant.SESSION_CACHE_PREFIX + "*");// keys seckill:sessions:*
        for (String key : keys) {
            //seckill:sessions:1594396764000_1594453242000
            String replace = key.replace(SeckillConstant.SESSION_CACHE_PREFIX, "");// 截取时间,去掉前缀
            String[] time = replace.split("_");
            long startTime = Long.parseLong(time[0]);// 开始时间
            long endTime = Long.parseLong(time[1]);// 截止时间
            // 判断是否处于该场次
            if (currentTime >= startTime && currentTime <= endTime) {
                // 2.查询当前场次信息(查询结果List< sessionId_skuId > )
                List<String> sessionIdSkuIds = redisTemplate.opsForList().range(key, -100, 100);// 获取list范围内100条数据
                // 获取商品信息
                BoundHashOperations<String, String, String> skuOps = redisTemplate.boundHashOps(SECKILL_CHARE_KEY);
                assert sessionIdSkuIds != null;
                // 根据List< sessionId_skuId >从Map中批量获取商品信息
                List<String> skus = skuOps.multiGet(sessionIdSkuIds);
                if (!CollectionUtils.isEmpty(skus)) {
                    // 将商品信息反序列成对象
                    List<SeckillSkuRedisTO> skuInfos = skus.stream().map(sku -> {
                        SeckillSkuRedisTO skuInfo = JSON.parseObject(sku.toString(), SeckillSkuRedisTO.class);
                        // redisTo.setRandomCode(null);当前秒杀开始需要随机码
                        return skuInfo;
                    }).collect(Collectors.toList());
                    return skuInfos;
                }
                // 3.匹配场次成功,退出循环
                break;
            }
        }
        //} catch (BlockException e) {
        //    log.error("资源被限流{}", e.getMessage());
        //}
        return null;
    }
}

5.【查询】商品详情页展示秒杀信息

在这里插入图片描述

// product模块获取商品详情的时候,同时查询该商品的秒杀信息

@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {

    @Autowired
    CouponFeignService couponFeignService;
    @Autowired
    ProductFeignService productFeignService;
    @Autowired
    StringRedisTemplate redisTemplate;
    @Autowired
    RabbitTemplate rabbitTemplate;
    @Autowired
    RedissonClient redissonClient;

    /**
     * 根据skuId查询商品当前时间秒杀信息
     *
     * @param skuId
     */
    @Override
    public SeckillSkuRedisTO getSkuSeckilInfo(Long skuId) {
        // 1.匹配查询当前商品的秒杀信息
        BoundHashOperations<String, String, String> skuOps = redisTemplate.boundHashOps(SECKILL_CHARE_KEY);
        // 获取所有商品的key:sessionId_
        Set<String> keys = skuOps.keys();
        if (!CollectionUtils.isEmpty(keys)) {
            String lastIndex = "_" + skuId;
            for (String key : keys) {
                if (key.lastIndexOf(lastIndex) > -1) {
                    // 商品id匹配成功
                    String jsonString = skuOps.get(key);
                    // 进行序列化
                    SeckillSkuRedisTO skuInfo = JSON.parseObject(jsonString, SeckillSkuRedisTO.class);
                    Long currentTime = System.currentTimeMillis();
                    Long endTime = skuInfo.getEndTime();
                    if (currentTime <= endTime) {
                        // 当前时间小于截止时间
                        Long startTime = skuInfo.getStartTime();
                        if (currentTime >= startTime) {
                            // 返回当前正处于秒杀的商品信息
                            return skuInfo;
                        }
                        // 返回预告信息,不返回随机码
                        skuInfo.setRandomCode(null);// 随机码
                        return skuInfo;
                    }
                }
            }
        }
        return null;
    }
}

6.秒杀抢购

6.1.高并发需关注的问题

在这里插入图片描述

在这里插入图片描述

  • 1.单一职责
  • 2.秒杀链接加密
    • 随机码,秒杀开始才暴露
  • 3.库存预热+快速扣减(redis存储库存信号量,最终正常进入购物车的流量最多是库存数)
    • 按照库存信号量原子扣减
  • 4.动静分离
    • nginx/CDN
  • 5.恶意请求拦截
    • 网关层按照访问次数拦截脚本请求【异常请求】
  • 6.流量错峰
    • 【最重要是体现在秒杀开始的那一刻的错峰】判断登录状态、输入验证码、加入购物车、提交订单
  • 7.限流&熔断&降级
    • 前端限流:间隔1秒允许点击
    • 后端限流:
      • 限制次数:同一个用户10次放行2次
      • 限制总量:秒杀服务峰值处理能力10万,网关层放行不得超过10万,超过的等待两秒放行
    • 熔断:A->B->C,链路中B总是失败,则下次调用时直接返回错误不调用B
    • 降级:流量太大,秒杀模块将流量引导到降级页面,服务繁忙页【正常请求】
  • 8.队列削峰(杀手锏)
    • 扣减库存信号量成功的秒杀信息存入队列,订单系统监听队列创建订单(按照自己的处理能力消费)

6.2.【秒杀】队列削峰

两种方案:
	方案一:
		加入购物车(仍然走购物车流程,但价格按照秒杀价格计算),创建订单、锁定库存
		优点:只需要做好适配,无大改动
		缺点:将秒杀的流量带给了其他模块
	方案二:【采用方案二,队列削峰】
		直接发送MQ消息,订单根据消息创建订单(不需要锁定库存,库存预热了【信号量】),订单关闭增加信号量
		优点:没有将秒杀的压力分担给其他模块,只有校验合法性没有远程调用、db操作
		缺点:订单等模块需要提供监听消费信息创建订单,如果订单崩了,会导致支付失败
		
假设一个请求50ms,一个线程1s能处理20个请求
Tomcat开启500个线程,1s能处理10000个请求

方案1:
在这里插入图片描述
在这里插入图片描述

方案2:

在这里插入图片描述

/**
 * @Description:
 * @Created: with IntelliJ IDEA.
 * @author: wanzenghui
 * @createTime: 2020-07-09 19:29
 **/

@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {

    @Autowired
    StringRedisTemplate redisTemplate;
    @Autowired
    RabbitTemplate rabbitTemplate;
    @Autowired
    RedissonClient redissonClient;

    /**
     * 秒杀商品
     * 1.校验登录状态
     * 2.校验秒杀时间
     * 3.校验随机码、场次、商品对应关系
     * 4.校验信号量扣减,校验购物数量是否限购
     * 5.校验是否重复秒杀(幂等性)【秒杀成功SETNX占位  userId_sessionId_skuId】
     * 6.扣减信号量
     * 7.发送消息,创建订单号和订单信息
     * 8.订单模块消费消息,生成订单
     * @param killId    sessionId_skuid
     * @param key   随机码
     * @param num   商品件数
     */
    @Override
    public String kill(String killId, String key, Integer num) throws InterruptedException {
        // TODO 1.拦截器校验登录状态
        long start = System.currentTimeMillis();
        // 获取当前用户信息
        MemberResponseVO user = LoginUserInterceptor.loginUser.get();

        // 获取当前秒杀商品的详细信息
        BoundHashOperations<String, String, String> skuOps = redisTemplate.boundHashOps(SECKILL_CHARE_KEY);
        String jsonString = skuOps.get(killId);// 根据sessionId_skuid获取秒杀商品信息
        if (StringUtils.isEmpty(jsonString)) {
            // 这一步已经默认校验了场次+商品,如果为空表示校验失败
            return null;
        }
        // json反序列化商品信息
        SeckillSkuRedisTO skuInfo = JSON.parseObject(jsonString, SeckillSkuRedisTO.class);
        Long startTime = skuInfo.getStartTime();
        Long endTime = skuInfo.getEndTime();
        long currentTime = System.currentTimeMillis();
        // TODO 2.校验秒杀时间
        if (currentTime >= startTime && currentTime <= endTime) {
            // TODO 3.校验随机码
            String randomCode = skuInfo.getRandomCode();// 随机码
            if (randomCode.equals(key)) {
                // 获取每人限购数量
                Integer seckillLimit = skuInfo.getSeckillLimit();
                // 获取信号量
                String seckillCount = redisTemplate.opsForValue().get(SeckillConstant.SKU_STOCK_SEMAPHORE + randomCode);
                Integer count = Integer.valueOf(seckillCount);
                // TODO 4.校验信号量(库存是否充足)、校验购物数量是否限购
                if (num > 0 && num <= seckillLimit && count > num) {
                    // TODO 5.校验是否重复秒杀(幂等性)【秒杀成功后占位,userId-sessionId-skuId】
                    // SETNX 原子性处理
                    String userKey = SeckillConstant.SECKILL_USER_PREFIX + user.getId() + "_" + killId;
                    // 自动过期时间(活动结束时间 - 当前时间)
                    Long ttl = endTime - currentTime;
                    Boolean isRepeat = redisTemplate.opsForValue().setIfAbsent(userKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
                    if (isRepeat) {
                        // 占位成功
                        // TODO 6.扣减信号量
                        RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + randomCode);
                        boolean isAcquire = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
                        if (isAcquire) {
                            // 信号量扣减成功,秒杀成功,快速下单
                            // TODO 7.发送消息,创建订单号和订单信息
                            // 秒杀成功 快速下单 发送消息到 MQ 整个操作时间在 10ms 左右
                            String orderSn = IdWorker.getTimeId();// 订单号
                            SeckillOrderTO order = new SeckillOrderTO();// 订单
                            order.setOrderSn(orderSn);// 订单号
                            order.setMemberId(user.getId());// 用户ID
                            order.setNum(num);// 商品上来给你
                            order.setPromotionSessionId(skuInfo.getPromotionSessionId());// 场次id
                            order.setSkuId(skuInfo.getSkuId());// 商品id
                            order.setSeckillPrice(skuInfo.getSeckillPrice());// 秒杀价格
                            // TODO 需要保证可靠消息,发送者确认+消费者确认(本地事务的形式)
                            rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", order);
                            long end = System.currentTimeMillis();
                            log.info("秒杀成功,耗时..." + (end - start));
                            return orderSn;
                        }
                    }
                }
            }
        }
        long end = System.currentTimeMillis();
        log.info("秒杀失败,耗时..." + (end - start));
        return null;
    }
}

6.3.TODO释放信号量

1.接收创建秒杀订单的队列也应该做成延时队列,超时未支付,消息进入死信队列释放订单
2.监听释放订单的消费者,释放订单后,发送一条释放信号量的信息到释放信号量的死信队列
3.监听释放信号量的死信队列,逻辑跟释放库存一样(释放订单产生一条释放库存的消息,延时队列产生一条释放库存的消息)

6.4.TODO释放库存

场次超时后,将信号量归还到库存

十、熔断、限流、链路追踪

1.整合步骤

1.1.定义资源

多种定义资源的方法
1.主流框架适配,例如适配feign后所有feign请求都是资源【限流、降级后,触发熔断fallback】
 			 spring所有controller请求都是资源【限流、降级后,触发UrlBlockSentinelHandler处理】
			 适配gateway后,所有routes都是资源【限流、降级后,触发UrlBlockSentinelHandler处理】

2.自定义资源,使用try{}catch{}【限流、降级后,在catch中处理】
3.注解定义资源,使用@SentinelResource(blockHandler = "blockHandlerForGetUser")【流、降级后,在blockHandlerForGetUser中处理】

1.2.定义规则

1.3.检验规则是否生效

2.整合sentinel

参考 12.熔断+降级+限流+链路追踪(sentinel).md

总结

后台请求和前台请求路由

后台请求都添加了/api/member/xxx
 			 /api/coupon/xxx
 			 /api/renren-fast/xx

前台请求都没有/api

结论:
	1.gateway拦截后台请求的时候,要将/api/member/xx  请求拦截,然后将请求替换成/member/xx
	
	2.gateway拦截前台请求按照host拦截,不按照url拦截,
	例如order.gulimall.com
       member.gulimall.com

单元测试

版本2.1.8<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

@RunWith(SpringRunner.class)// 使用spring驱动
@SpringBootTest
public class GulimallSearchApplicationTests {

    @Autowired
    private RestHighLevelClient client;

    @Test
    public void contextLoads() {
    }

    @Test
    void testEs() {
        System.out.println(client);
    }
}

版本2.3.2<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    
@SpringBootTest
class GulimallSearchApplicationTests {

    @Autowired
    private RestHighLevelClient client;

    @Test
    void testEs() {
        System.out.println(client);
    }
}

组件间调用R类型问题

1.方法1:(无法实现,以为R继承自HashMap,data作为私有属性无法使用)
    设计返回类型R的时候加上泛型(feign调用时,springboot底层会根据泛型封装data类型)
public class R<T> extends HashMap<String, Object> {
    private T data;
    
    public T getDate() {
        return data;
    }
    
    public void setData(T data) {
        this.data = data;
    }
}

2.方法2:
    controller返回数据类型不使用R,直接使用List<SkuHasStockTO>

3.方法3public class R<T> extends HashMap<String, Object> {
	public R put(String key, Object value) {
		super.put(key, value);
		return this;
	}
    
	/**
	 * 封装数据
	 */
	public R setData(Object data) {
		return put("data", data);
	}

    /**
	 * 解析数据
	 * 1.@ResponseBody返回类型被封装成了Json格式
	 * 2.feign接收参数时也会封装成json格式,data对象也被解析成json格式的数据([集合对象]或{map对象})
	 * 3.将data转成json字符串格式,然后再解析成对象
	 */
	public <T> T getData(TypeReference<T> type) {
		Object data = get("data");
		String jsonString = JSONObject.toJSONStringWithDateFormat(data, DateConstant.DATE_FORMAT);
		return JSONObject.parseObject(jsonString, type);
	}
}

feign调用源码

/**
* 1、构造请求数据,将对象转为json
*      RequestTemplate template = buildTemplateFromArgs.create(argv);
* 2、发送请求进行执行:【执行成功会解码响应数据】
*      excuteAndDecode(template)
* 3、执行请求会有重试机制
*      while(true){
*          try{
*              excuteAndDecode(template)
*          }catch() {
*              try{
*                  // 默认重试5次【具体是否重试查看重试器的实现】
*                  retryer.continueOrPropagate(e);
*               }catch() {
*                  throw ex;
*               }
*              continue;
*          }
*      }
*
*
*/

查询结果使用包装类型

mapper查询返回使用Long代替long
	原因:查询结果为null时,无法为long封装null值

RedirectAttributes

作用:
	重定向数据域
	1.attributes.addFlashAttribute("errors", errMap);// flash,session中的数据只使用一次
	2.attributes.addAttribute("skuId", skuId);// 会在url后面拼接参数

添加新模块步骤

1.网关转发配置
2.spring-session依赖
  spring-session配置
  spring-session注解(@EnableRedisHttpSession)
3.登录拦截 LoginUserInterceptor、WebMvcConfigurer
4.域名映射
5.添加feign拦截器:GuliFeignConfig
  构造请求头,避免cookie丢失登录拦截
6.添加mybatis拦截器:MybatisConfig
  分页查询时封装总记录数、总页码数
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值