谷粒商城项目总结

业务详情

商品上架

接口:/product/spuinfo/{spuId}/up

前端发起某个spu(商品)的上架请求,请求/product/spuinfo/{spuId}/up接口,该接口调用Service层的实现类SpuInfoServiceImplup方法。

up方法的逻辑:

  1. 根据spuId判断该spu是否存在,不存在则抛出异常,交由异常处理器处理,返回统一的异常信息。

  2. 根据spuId获取商品的所有规格信息(sku)列表。

  3. 发起远程调用请求库存服务,查看sku的库存信息。

  4. 查询spu下所有可被检索到的规格参数属性值。

  5. 将以上查询的信息封装到对应Model中。

    查询对应sku的品牌和分类信息。

  6. 发起远程调用请求检索服务,将封装的sku信息保存到ES中。

  7. 修改商品的上架状态。

Service层相关方法:SpuInfoServiceImpl类的void up(Long spuId);方法

/*--------------------- SpuInfoServiceImpl Start ---------------------*/
/**
 * 商品上架
 * @param spuId
 */
@Override
public void up(Long spuId) {
    // 1.判断spu是否存在
    SpuInfoEntity spuInfoEntity = getById(spuId);
    if (spuInfoEntity == null) {
        throw new RRException("spu【" + spuId + "】不存在");
    }

    /* 组装Es中的商品模型 */

    // 2.根据spuId获取所有商品规格信息列表
    List<SkuInfoEntity> skuInfoEntityList = skuInfoService.getSkuListBySpuId(spuId);
    if (CollectionUtils.isEmpty(skuInfoEntityList)) {
        throw new RRException("spu【" + spuId + "】下不存在sku信息");
    }

    // 3.查询sku对应的库存信息
    List<Long> skuIdList = skuInfoEntityList.stream()
        .map(SkuInfoEntity::getSkuId)
        .collect(Collectors.toList());
    R result = wareFeignService.getSkuHasStock(skuIdList);
    if (result == null || result.getCode() != 0) {
        throw new RRException("获取sku的库存信息失败");
    }
    List<SkuStockVo> skuStockVoList = ConvertUtils.convertByJson(result.get("data"), List.class);

    Map<Long, Boolean> stockMap = skuStockVoList.stream()
        .collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));

    // 3.查询对应spu对应可被检索的规格参数
    List<SpuAttrEsModel> spuAttrEsModelList = getSpuAttrEsModelListBySpuId(spuId);

   	// 4.封装sku信息到对应Model
    List<SkuEsModel> skuEsModelList = skuInfoEntityList.stream()
        .map(skuInfoEntity -> {
            SkuEsModel skuEsModel = new SkuEsModel();

            // 4-1.查询sku对应的品牌信息
            CompletableFuture<Void> queryBrandTask = CompletableFuture.runAsync(() -> {
                BrandEntity brand = brandService.getById(skuInfoEntity.getBrandId());
                skuEsModel.setBrandName(brand.getName());
                skuEsModel.setBrandImg(brand.getLogo());
            }, executor);

            // 4-2.查询sku对应的分类信息
            CompletableFuture<Void> queryCategoryTask = CompletableFuture.runAsync(() -> {
                CategoryEntity category = categoryService.getById(skuInfoEntity.getCategoryId());
                skuEsModel.setCategoryName(category.getName());
            }, executor);
	
            // 4-3.设置sku的基本信息
            skuEsModel.setSkuId(skuInfoEntity.getSkuId());
            skuEsModel.setSkuTitle(skuInfoEntity.getSkuTitle());
            skuEsModel.setSkuImg(skuInfoEntity.getSkuDefaultImg());
            skuEsModel.setSkuPrice(skuInfoEntity.getPrice());
            skuEsModel.setSaleCount(skuInfoEntity.getSaleCount());
            skuEsModel.setSpuId(spuId);
            skuEsModel.setBrandId(skuInfoEntity.getBrandId());
            skuEsModel.setCategoryId(skuInfoEntity.getCategoryId());

            // 4-4.查询sku对应的规格参数
            skuEsModel.setAttrs(spuAttrEsModelList);

            skuEsModel.setHotScore(0L);

            // 4-5.查询sku是否有库存
            skuEsModel.setHasStock(Optional.ofNullable(stockMap.get(skuInfoEntity.getSkuId()))
                                   .orElse(Boolean.FALSE));

            CompletableFuture<Void> summaryTask = CompletableFuture.allOf(queryBrandTask, queryCategoryTask);

            summaryTask.join();

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

    // 5.调用检索服务
    result = searchFeignService.saveProduct(skuEsModelList);
    if (result == null || result.getCode() != 0) {
        // TODO 失败重试(分布式事务)
    }

    // 6.修改商品上架状态
    lambdaUpdate()
        .set(SpuInfoEntity::getPublishStatus, ProductConstant.SpuPublishStatus.UP.getStatus())
        .set(SpuInfoEntity::getUpdateTime, new Date())
        .eq(SpuInfoEntity::getId, spuId)
        .update();
}

/**
 * 通过spuId获取该spu下可被检索的spu信息
 * @param spuId
 * @return
 */
private List<SpuAttrEsModel> getSpuAttrEsModelListBySpuId(Long spuId) {
    // 1.根据spuId获取对应的规格参数属性值
    List<SpuAttrValueEntity> spuAttrValueEntityList = spuAttrValueService
        .getBaseAttrListOfSpu(spuId);
    
    // 2. 调用属性服务, 从规格参数属性值中查询可以被检索的属性
    List<Long> attrIdList = spuAttrValueEntityList.stream()
        .map(SpuAttrValueEntity::getAttrId)
        .collect(Collectors.toList());
    Set<Long> selectedIdSet = attrService.selectAttrIdListOfCanBeRetrieved(attrIdList);

    List<SpuAttrEsModel> spuAttrEsModelList = spuAttrValueEntityList.stream()
        .filter(spuAttrValue -> selectedIdSet.contains(spuAttrValue.getAttrId()))
        .map(spuAttrValue -> {
            SpuAttrEsModel spuAttrEsModel = new SpuAttrEsModel();
            spuAttrEsModel.setAttrId(spuAttrValue.getAttrId());
            spuAttrEsModel.setAttrName(spuAttrValue.getAttrName());
            spuAttrEsModel.setAttrValue(spuAttrValue.getAttrValue());
            return spuAttrEsModel;
        })
        .collect(Collectors.toList());

    return spuAttrEsModelList;
}
/*--------------------- SpuInfoServiceImpl End ---------------------*/

/*--------------------- SkuInfoServiceImpl Start ---------------------*/
/**
 * 通过spuId获取sku列表
 * @param spuId
 * @return
 */
@Override
public List<SkuInfoEntity> getSkuListBySpuId(Long spuId) {
    return lambdaQuery()
        .eq(SkuInfoEntity::getSpuId, spuId)
        .list();
}
/*--------------------- SkuInfoServiceImpl End ---------------------*/

/*--------------------- SpuAttrValueServiceImpl Start ---------------------*/
/**
 * 获取spu对应的规格参数
 * @param spuId
 * @return
 */
@Override
public List<SpuAttrValueEntity> getBaseAttrListOfSpu(Long spuId) {
    List<SpuAttrValueEntity> list = this.lambdaQuery()
        .eq(SpuAttrValueEntity::getSpuId, spuId)
        .list();
    return list;
}
/*--------------------- SpuAttrValueServiceImpl End ---------------------*/

远程调用库存服务:WareFeignService

/**
 * 获取sku的库存信息
 * @param skuIdList
 * @return
 */
@Override
public List<SkuStockVo> getSkuHasStock(List<Long> skuIdList) {
    // 1. 根据skuId列表查询对应的库存
    List<WareSkuEntity> wareSkuEntityList = lambdaQuery()
        .in(WareSkuEntity::getSkuId, skuIdList)
        .list();
    if (CollectionUtils.isEmpty(wareSkuEntityList)) {
        return Collections.emptyList();
    }

    List<SkuStockVo> skuStockVoList = wareSkuEntityList.stream()
        .map(wareSkuEntity -> {
            SkuStockVo skuStockVo = new SkuStockVo();
            skuStockVo.setHasStock(wareSkuEntity.getStock() > 0);
            skuStockVo.setSkuId(wareSkuEntity.getSkuId());

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

    return skuStockVoList;
}

调用远程的检索服务:SearchFeignService

/**
 * 保存商品信息到ES
 * @param skuEsModelList
 * @return true 保存成功, false 保存失败
 */
public boolean saveProduct(List<SkuEsModel> skuEsModelList) {
    BulkRequest bulkRequest = new BulkRequest();

    try {
        skuEsModelList.forEach(skuEsModel -> {
            IndexRequest indexRequest = new IndexRequest(SearchConstant.PRODUCT_INDEX);
            indexRequest.id(skuEsModel.getSkuId().toString());
            String json = JsonUtils.toJsonString(skuEsModel);
            indexRequest.source(json, XContentType.JSON);

            bulkRequest.add(indexRequest);
        });
        BulkResponse bulkResponse = client.bulk(bulkRequest, SimpleElasticSearchConfig.COMMON);

        if (bulkResponse.hasFailures()) {
            List<String> idList = Arrays.stream(bulkResponse.getItems())
                .map(BulkItemResponse::getId)
                .collect(Collectors.toList());
            log.info("商品信息保存ES中出现错误, 错误原因: {}", idList);
            throw new RRException("商品信息保存到ES中出现错误, 保存失败的id列表为: " + idList);
        }

        return !bulkResponse.hasFailures();
    } catch (IOException e) {
        log.error("商品信息保存到ES中出现错误, 错误原因: " + e.getCause(), e);
        throw new RRException("商品信息保存到ES中出现错误, 错误原因: " + e.getCause());
    }
}

用户注册和登录

接口:/sms/sendCode(发送验证码)、/register(注册)、/login(登录)

发送验证码

通过Redis的字符串结构来保存验证码来防止恶意请求接口,key是手机号,value是验证码_再次发送验证码的截止时间戳,且设置key的过期时间为10min,即在10min内该验证码有效,同时在value里面保存了时间戳来防止重复请求接口。

/**
 * 发送验证码
 * @param phone 手机号码
 */
@PostMapping("/sms/sendcode")
@ResponseBody
public R sendCode(@RequestParam("phone")
                  @Pattern(regexp = "1[0-9]{10}", message = "手机号码格式错误") String phone) {
    // 1.通过redis判断该手机号是否可以发送验证码
    if (!checkCanSendVerifyCode(phone)) {
        throw new RRException("发送频率太高了, 请稍后重试");
    }

    // 2.生成验证码
    String verifyCode = generateVerifyCode();
	
    // 3.调用第三方服务发送验证码
    R result = thirdPartyFeignService.sendVerifyCodeSms(phone, verifyCode);

    if (result == null || result.getCode() != 0) {
        if (result != null) {
            log.error("调用第三方服务成功, 响应结果: {}", result);
        } else {
            log.error("调用第三方服务失败, 返回结果为null");
        }
        throw new RRException("发送验证码失败, 请稍后重试");
    }
	
    // 4.在redis中保存手机号和验证码
    saveVerifyCode(phone, verifyCode);

    return R.ok();
}

/**
 * 检查是否可以发送验证码
 * @param phone
 * @return
 */
private boolean checkCanSendVerifyCode(String phone) {
    String value = redisUtils.getVerifyCode(phone);
    if (StringUtils.isEmpty(value)) {
        return true;
    }
    String[] strings = value.split("_");
    long timestamp = Long.parseLong(strings[1]);
    if (System.currentTimeMillis() <= timestamp) {
        return false;
    }
    return true;
}

/**
 * 保存验证码
 * @param phone
 * @param code
 */
private void saveVerifyCode(String phone, String code) {
    long canSendTimestamp = System.currentTimeMillis() + 60 * 1000;
    String value = code + "_" + canSendTimestamp;
    redisUtils.setVerifyCode(phone, value);
}
注册用户
校验用户填写的信息 -> 判断验证码是否正确 -> 远程调用会员服务:添加会员信息 -> 重定向到登录页
/**
 * 注册用户
 */
@PostMapping("/register")
public String register(@Validated UserRegisterVo userRegisterVo,
                       BindingResult bindingResult,
                       RedirectAttributes redirectAttributes) {
    // 1.校验待注册的用户信息是否正确
    if (bindingResult.hasErrors()) {
        Map<String, String> errorMsgMap = bindingResult.getFieldErrors()
            .stream()
            .collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
        redirectAttributes.addFlashAttribute("errors", errorMsgMap);
        return "redirect:" + REGISTER_PAGE_URL;
    }

    // 2.从Redis中获取验证码, 判断验证码是否正确
    String verifyCode = getVerifyCodeByPhone(userRegisterVo.getPhone());
    if (verifyCode == null || !verifyCode.equalsIgnoreCase(userRegisterVo.getCode())) {
        Map<String, String> errorMsgMap = new HashMap<>(1);
        errorMsgMap.put("tipMsg", "验证码错误");
        redirectAttributes.addFlashAttribute("errors", errorMsgMap);
        return "redirect:" + REGISTER_PAGE_URL;
    }

    // 3.从Redis中删除验证码
    redisUtils.deleteVerifyCode(userRegisterVo.getPhone());

    MemberRegisterVo memberRegisterVo = new MemberRegisterVo();
    memberRegisterVo.setUsername(userRegisterVo.getUsername());
    memberRegisterVo.setPassword(userRegisterVo.getPassword());
    memberRegisterVo.setPhone(userRegisterVo.getPhone());

    try {
        // 4.发起远程调用,请求会员服务添加会员信息
        R result = memberFeignService.register(memberRegisterVo);

        if (result == null) {
            redirectAttributes.addFlashAttribute("errors", Collections.singletonMap("tipMsg", "注册失败请稍后重试"));
            return "redirect:" + REGISTER_PAGE_URL;
        } else if (result.getCode() != 0) {
            redirectAttributes.addFlashAttribute("errors", Collections.singletonMap("tipMsg", result.get("msg")));
            return "redirect:" + REGISTER_PAGE_URL;
        }

        return "redirect:" + LOGIN_PAGE_URL;

    } catch (Exception e) {
        log.error("调用远程会员服务失败, 异常原因: " + e.getMessage(), e);
        redirectAttributes.addFlashAttribute("errors", Collections.singletonMap("tipMsg", "注册失败请稍后重试"));
        return "redirect:" + REGISTER_PAGE_URL;
    }

}

远程调用会员服务:

用户登录

流程:

判断是否已登录,已登录则跳转到首页 -> 校验用户参数 -> 远程调用会员服务:校验用户名和密码并返回用户信息 -> 保存用户信息到Session -> 重定向到首页

采用分布式Session来解决Session的跨域问题。

  1. 放大域名的作用域:*.mall.com -> mall.com
  2. 将Session保存到Redis中(SpringSession),使用注解@EnableRedisHttpSession

SpringSession的原理:

注入Filter拦截每一个请求,对request进行包装(装饰器模式)并重写getSession方法,获取Session时从Redis获取。

Redis的结构:

  • spring:session:sessions:{sessionId}:hash结构,过期时间是lastAccessedTime + maxInactiveInterval + 5Minute

    createTime(创建时间)、maxInactiveInterval(最大未激活时间间隔)、lastAccessedTime(最近访问时间)

  • spring:session:expirations:{过期时间}:set结构,过期时间是lastAccessedTime + maxInactiveInterval + 5Minute

    存储expires:{sessionId}

  • spring:session:sessions:expires:{sessionId}:string结构,过期时间是lastAccessedTime + maxInactiveInterval

/**
 * 用户登录
 */
@PostMapping("/login")
public String login(@Validated UserLoginVo userLoginVo,
                    BindingResult bindingResult,
                    RedirectAttributes redirectAttributes) {
    // 1.判断是否已登录,如果已登录则跳转到首页
    HttpServletRequest request = WebUtils.getCurrentRequest();
    HttpSession session = request.getSession(true);
    if (session != null && session.getAttribute(USER_OF_SESSION) != null) {
        return "redirect:http://www.mall.com";
    }

    // 2.校验用户传入的参数是否有错
    if (bindingResult.hasErrors()) {
        // 参数校验出现错误
        Map<String, String> errorTipMsg = bindingResult.getFieldErrors()
            .stream()
            .collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));

        redirectAttributes.addFlashAttribute("errors", errorTipMsg);

        return "redirect:" + LOGIN_PAGE_URL;
    }

    try {
        // 3.远程调用会员服务,校验用户名和密码并返回用户信息
        MemberLoginVo memberLoginVo = new MemberLoginVo();
        memberLoginVo.setAccount(userLoginVo.getAccount());
        memberLoginVo.setPassword(userLoginVo.getPassword());
        R result = memberFeignService.login(memberLoginVo);

        if (result == null) {
            redirectAttributes.addFlashAttribute("errors", Collections.singletonMap("tipMsg", "登录失败, 请稍后重试"));
            return "redirect:" + LOGIN_PAGE_URL;
        } else if (result.getCode() != 0) {
            redirectAttributes.addFlashAttribute("errors", Collections.singletonMap("tipMsg", result.get("msg")));
            return "redirect:" + LOGIN_PAGE_URL;
        }

        MemberVo memberVo = result.getWithSpecifiedType("data", MEMBER_VO_TYPE_REFERENCE);

        // 4.设置session
        session.setAttribute(USER_OF_SESSION, memberVo);

        log.info("会员信息为: {}", memberVo);

        // 5.重定向到首页 
        return "redirect:http://www.mall.com";
    } catch (Exception e) {
        log.error("出现异常, 异常原因: " + e.getMessage(), e);

        redirectAttributes.addFlashAttribute("errors", Collections.singletonMap("tipMsg", "登录失败, 请稍后重试"));

        return "redirect:" + LOGIN_PAGE_URL;
    }
}

商品购买

查看商品详情

相关接口:{skuId}.html

Service层相关方法:SkuInfoServiceImpl类下的getItemById方法

使用CompletableFuture进行异步编排,提高查询速度。

根据skuId获取sku的基本信息 -> 根据sku的spuId获取销售属性、规格参数、商品描述 \
																   \
根据skuId获取sku的图片信息 -------------------------------------------------> 封装成Vo返回

注意:第三步通过spuId来查询该商品下所有规格的销售属性是通过连表查询的。

/**
 * 通过skuId获取sku的信息(商品详情)
 * @param skuId
 * @return
 */
@Override
public SkuItemVo getItemById(Long skuId) {
    SkuItemVo skuItemVo = new SkuItemVo();

    // 1. 获取sku的基本信息
    CompletableFuture<Long> getSkuInfoTask = CompletableFuture.supplyAsync(() -> {
        SkuInfoEntity skuInfoEntity = getById(skuId);

        if (skuInfoEntity == null) {
            throw new RRException("sku【" + skuId + "】不存在");
        }
        skuItemVo.setInfo(skuInfoEntity);

        return skuInfoEntity.getSpuId();
    });

    // 2. 获取sku的图片信息
    CompletableFuture<Void> getImageOfSkuTask = CompletableFuture.runAsync(() -> {
        List<SkuImagesEntity> imageList = skuImagesService.listById(skuId);
        skuItemVo.setImageList(imageList);
    });

    CompletableFuture<Void> getSaleAttrCombinationTask = 
        getSkuInfoTask.thenAcceptAsync(spuId -> {
            // 3. 获取该spu下所有sku的销售属性
            List<SkuItemSaleAttrVo> spuItemBaseAttrVoList = 
                skuSaleAttrValueService.getSaleAttrListBySpuId(spuId);
            skuItemVo.setSaleAttrList(spuItemBaseAttrVoList);
        }, executor);

    CompletableFuture<Void> getSpuDescriptionTask = getSkuInfoTask.thenAcceptAsync(spuId -> {
        // 4. 获取spu的介绍
        SpuInfoDescEntity description = spuInfoDescService.getById(spuId);
        skuItemVo.setDescription(description);
    }, executor);

    CompletableFuture<Void> getBaseAttrListOfSpuTask = 
        getSkuInfoTask.thenAcceptAsync(spuId -> {
            // 5. 获取spu的规格参数
            List<SpuItemBaseAttrVo> groupAttrList = 
                spuAttrValueService.getGroupAttrListBySpuId(spuId);
            skuItemVo.setGroupAttrList(groupAttrList);
        }, executor);

    CompletableFuture.allOf(getImageOfSkuTask,
                            getSaleAttrCombinationTask,
                            getSpuDescriptionTask,
                            getBaseAttrListOfSpuTask).join();

    return skuItemVo;
}
添加商品到购物车

相关接口:/addToCart

所需参数:skuId商品规格ID、number购买数量。

Service层相关方法:CartServiceImpl类的addSkuToCart方法。

由于购物车是用户会进行频繁操作的一个场景,因此将购物车的数据全部放入Redis中缓存。

购物车根据登录情况分为:

在进行购物车操作前,所有请求都会被购物车拦截器拦截,如果当前请求中存在Session,则从Session中获取用户信息并保存到用户上下文(使用TheadLocal来保存),否则检查Cookie中是否存在用户标识,如果不存在则添加一个用户标识的Cookie,该用户标识用来获取临时购物车。

  • 已登录:用户购物车
  • 未登录:临时购物车

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SwcSd41K-1654094888627)(https://gitee.com/wenxuan70/drawing-bed/raw/master/image/20220212230152.png)]

购物车在Redis中存储结构

采用hash结构,hash中的key为skuId,value为json格式的商品信息

购物车的key是用户唯一标识,保证了每个用户都有独立的购物车。

/**
 * 添加商品到购物车
 * @param skuId 商品id
 * @param number 购买数量
 */
@Override
public void addSkuToCart(Long skuId, Integer number) {
    // 1.获取当前用户信息
    UserInfoVo userInfoVo = UserContext.getUserInfoVo();
    // 2.从购物车中获取此商品信息
    CartItem cartItem = getCartItemBySkuIdAndUserInfoVo(skuId, userInfoVo);

    if (cartItem == null) {
        // 3.不存在此商品信息,则查询商品信息
        cartItem = new CartItem();
        final CartItem finalCartItem = cartItem;
        // 4.从远程查询商品信息
        CompletableFuture<Void> getSkuInfoTask = CompletableFuture.runAsync(() -> {
            R result = productFeignService.info(skuId);
            if (result == null || result.getCode() != 0) {
                throw new RRException("获取商品[" + skuId + "]的基本信息失败");
            }
            SkuInfoVo skuInfoVo = 
                result.getWithSpecifiedType("skuInfo", SKU_INFO_VO_TYPE_REFERENCE);
            finalCartItem.setTitle(skuInfoVo.getSkuTitle());
            finalCartItem.setSkuId(skuInfoVo.getSkuId());
            finalCartItem.setPrice(skuInfoVo.getPrice());
            finalCartItem.setImageUrl(skuInfoVo.getSkuDefaultImg());
            // 默认选中商品且数量为0
            finalCartItem.setCheck(true);
            finalCartItem.setCount(number);
        });
        // 5.从远程查询商品销售属性值
        CompletableFuture<Void> getSkuAttrListTask = CompletableFuture.runAsync(() -> {
            R result = productFeignService.getSaleAttrValueListBySkuId(skuId);
            if (result == null || result.getCode() != 0) {
                throw new RRException("获取商品[" + skuId + "]的销售属性值列表失败");
            }
            List<String> skuAttrList = 
                result.getWithSpecifiedType("data",
                                            SKU_SALE_ATTR_VALUE_VO_LIST_TYPE_REFERENCE)
                .stream()
                .map(attrValue -> 
                     String.join(":", attrValue.getAttrName(), attrValue.getAttrValue()))
                .collect(Collectors.toList());
            finalCartItem.setSkuAttrList(skuAttrList);
        });

        CompletableFuture<Void> allOfTask = 
            CompletableFuture.allOf(getSkuInfoTask, getSkuAttrListTask);

        allOfTask.join();

        if (allOfTask.isCompletedExceptionally()) {
            throw new RRException("添加商品[" + skuId + "]到购物车失败");
        }
    } else {
        // 6.存在商品信息,则修改商品数量
        cartItem.setCount(cartItem.getCount() + number);
    }

    String skuJsonString = JsonUtils.toJsonString(cartItem);

    if (userInfoVo.isLogged()) {
        // 7.用户已登录,则保存到用户购物车
        redisUtils.setSkuOfUserCart(userInfoVo.getUserId(), skuId, skuJsonString);
    } else {
        // 8.用户未登录,则保存到临时购物车
        redisUtils.setSkuOfTemporaryCart(userInfoVo.getUserKey(), skuId, skuJsonString);
    }
}

/**
 * 根据用户信息和商品id来获取购物车中的商品信息
 * @param skuId 商品id
 * @param userInfoVo 用户信息
 * @return
  */
private CartItem getCartItemBySkuIdAndUserInfoVo(Long skuId, UserInfoVo userInfoVo) {
    CartItem cartItem = null;
	// 如果用户登录,则从用户购物车中获取对应的商品信息(可能不存在);否则,从临时购物车中获取
    if (userInfoVo.isLogged()) {
        String skuJsonString = redisUtils.getSkuOfUserCart(userInfoVo.getUserId(), skuId);
        if (skuJsonString != null) {
            cartItem = JsonUtils.readValue(skuJsonString, CART_ITEM_TYPE_REFERENCE);
        }
    } else {
        String skuJsonString = redisUtils.getSkuOfTemporaryCart(userInfoVo.getUserKey(), skuId);
        if (skuJsonString != null) {
            cartItem = JsonUtils.readValue(skuJsonString, CART_ITEM_TYPE_REFERENCE);
        }
    }

    return cartItem;
}
查看购物车

相关接口:/cart.html

Service层相关方法:CartServiceImpl类的getCart方法。

流程:

  1. 获取用户信息
  2. 获取临时购物车的商品信息
  3. 如果用户已登录,则合并临时购物车和用户购物车
  4. 删除临时购物车,并重新获取用户购物车的商品信息
  5. 封装成Vo返回给前端
/**
 * 获取购物车中的所有信息
 * @return
 */
@Override
public Cart getCart() {
    // 1.获取用户信息
    UserInfoVo userInfoVo = UserContext.getUserInfoVo();

    // 2.获取临时购物车中的商品信息
    List<CartItem> cartItemList = getAllCartItemByUserInfoVo(userInfoVo, true);

    if (userInfoVo.isLogged()) {
        // 3.如果用户已登录,则添加临时购物车的商品到用户购物车
        cartItemList.stream().forEach(cartItem -> addSkuToCart(cartItem.getSkuId(), cartItem.getCount()));

        // 4.删除临时购物车
        redisUtils.removeTemporaryCart(userInfoVo.getUserKey());

        // 5.重新获取用户购物车的商品
        cartItemList = getAllCartItemByUserInfoVo(userInfoVo, false);
    }

    Cart cart = new Cart();
    cart.setItemList(cartItemList);

    return cart;
}

/**
 * 根据用户信息获取购物车中所有的商品信息
 * @param userInfoVo 用户信息
 * @param isTemporary 是否获取临时购物车中的数据
 * @return
 */
private List<CartItem> getAllCartItemByUserInfoVo(UserInfoVo userInfoVo, boolean isTemporary) {
    List<Object> skuList = isTemporary
        ? redisUtils.getSkuListOfTemporaryCart(userInfoVo.getUserKey())
        : redisUtils.getSkuListOfUserCart(userInfoVo.getUserId());
    if (CollectionUtils.isEmpty(skuList)) {
        return Collections.emptyList();
    } else {
        return skuList.stream()
            .map(sku -> JsonUtils.readValue((String) sku, CART_ITEM_TYPE_REFERENCE))
            .collect(Collectors.toList());
    }
}
订单状态
订单确认

在购物车列表点击结算按钮即重定向到http://order.mall.com/toTrace订单确认页。

Service层相关方法:OrderServiceImpl类的confirmOrder方法。

通过拦截器获取当前用户信息(未登录则重定向到登录页),远程查询用户收货地址列表,远程查询购物车中已选中的商品信息,远程查询商品库存信息,计算商品价格,生成一个令牌保存到Redis来防止用户重复提交(过期时间30分钟),key为order:token:{userId},value为token

  • 问题:在CompletableFuture异步任务内进行远程调用会丢失请求头,导致请求被拦截(无法获取用户信息),如何解决?

    RequestContextHolder请求上下文中获取请求头,在开启异步任务时,设置当前线程的请求头信息(请求头信息是保存在ThreadLocal,因此跨线程无效),在CompletableFuture任务启动时,就替换掉请求头信息,保持跟主请求线程一致。

  • 问题:在进行Feign远程调用时,也会丢失请求头,如何解决?

    从当前RequestContextHolder请求上下文中获取请求头,设置其中的Session即可(实际上是设置Cookie)。

/**
 * 订单确认页返回需要用的数据
 * @return
 */
@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
	OrderConfirmVo confirmVo = new OrderConfirmVo();

    // 1.获取当前用户
    MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();

    // 获取当前线程请求头信息(解决Feign异步调用丢失请求头问题)
    RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();

    // 2. 远程查询用户收货地址列表
    CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {

        RequestContextHolder.setRequestAttributes(requestAttributes);

        List<MemberAddressVo> address = 
            memberFeignService.getAddress(memberResponseVo.getId());
        confirmVo.setMemberAddressVos(address);
    }, threadPoolExecutor);

    // 3. 远程查询购物车所有选中的购物项
    CompletableFuture<Void> cartInfoFuture = CompletableFuture.runAsync(() -> {

        RequestContextHolder.setRequestAttributes(requestAttributes);

        List<OrderItemVo> currentCartItems = cartFeignService.getCurrentCartItems();
        confirmVo.setItems(currentCartItems);
    }, threadPoolExecutor).thenRunAsync(() -> {
        // 4. 远程查询商品库存信息
        List<OrderItemVo> items = confirmVo.getItems();
        List<Long> skuIds = items.stream()
            .map(OrderItemVo::getSkuId)
            .collect(Collectors.toList());

        R skuHasStock = wmsFeignService.getSkuHasStock(skuIds);
        List<SkuStockVo> skuStockVos = 
            skuHasStock.getData("data", new TypeReference<List<SkuStockVo>>() {});

        if (skuStockVos != null && skuStockVos.size() > 0) {
            Map<Long, Boolean> skuHasStockMap = skuStockVos.stream()
                .collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
            confirmVo.setStocks(skuHasStockMap);
        }
    },threadPoolExecutor);

    // 5. 设置用户积分
    Integer integration = memberResponseVo.getIntegration();
    confirmVo.setIntegration(integration);

    /* 在vo的getter里面封装好了计算价格 */

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

    CompletableFuture.allOf(addressFuture,cartInfoFuture).get();

    return confirmVo;
}
提交订单

相关接口:http://order.mall.com/submitOrder

下单成功,则跳转到收银台页,下单失败,则返回订单确认页重新确认订单信息。

下单失败的可能原因:

  • 令牌信息过期(用户重复提交,令牌过期时间已到)
  • 订单价格发生变化
  • 库存锁定失败(库存不足)

提交订单所需参数:

  • 收获地址ID
  • 支付方式
  • 防重令牌(从订单确认页获取)
  • 应付价格
  • 订单备注

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CHsXJgO7-1654094888627)(https://gitee.com/wenxuan70/drawing-bed/raw/master/image/20220213145245.png)]

Service层相关方法:OrderServiceImpl类的submitOrder方法。

步骤:

  1. 将请求参数保存到confirmVoThreadLocal(ThreadLocal)线程上下文中。

  2. 获取当前用户信息。

  3. 使用lua脚本校验防重令牌,校验成功则删除令牌。

    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redic.call('del', KEYS[1])
    else
        return 0
    end
    
  4. 创建订单和订单项、并计算总价。

    步骤:

    1. 生成订单流水号,采用MybatisPlus的IdWorker类的getTimeId()方法。

      原理:时间戳+雪花算法生成的64位ID

    2. 创建订单,远程调用库存服务查询运费和收货地址。

    3. 创建订单项,远程调用购物车服务查询选中的订单项,并根据订单项的skuId远程调用商品服务查询商品详情。

  5. 校验价格是否发生变化(误差范围<0.01)。

  6. 保存订单和订单项到数据库。

  7. 远程调用库存服务锁定库存。

  8. 锁定成功,则发送消息给MQ,交换机为:order-event-exchange,路由键为:order.create.order

    发送消息给MQ主要作用是:

    订单到期后用户没有支付(过期时间为30分钟,设置队列order.delay.queue的参数x-message-ttl,RabbitMQ会将消息重新投入指定的队列(通过设置重投的交换机x-dead-letter-exchange: order-event-exchange和路由键x-dead-letter-routing-key: order.release.order),订单服务会修改订单状态为已取消并再次发送消息给交换机为order-event-exchange,路由键为order.release.other的队列来保存库存服务能在订单状态改变为已取消后解锁库存,库存服务也会去消费这个消息,来将库存工作单和库存工作单详情中锁定的库存给释放掉。

  9. 删除购物车中的订单数据。

  10. 锁定失败,则抛出异常。

    锁定失败如何保证被锁的库存正确释放?

    库存服务会在指定时间间隔后收到延迟队列的消息去解锁库存。

/**
 * 提交订单
 * @param vo
 * @return
 */
@Transactional(rollbackFor = Exception.class)
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
	
    // 1. 将请求参数保存到ThreadLocal
    confirmVoThreadLocal.set(vo);

    SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();

    // 2. 获取当前用户登录的信息
    MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();
    responseVo.setCode(0);

    // 使用lua脚本校验防重令牌是否有效,校验成功则删除令牌
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    String orderToken = vo.getOrderToken();
    Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
                                        Arrays.asList(USER_ORDER_TOKEN_PREFIX 
                                                      + memberResponseVo.getId()),
                                        orderToken);

    if (result == 0L) {
        // 令牌验证失败
        responseVo.setCode(1);
        return responseVo;
    } else {
        // 令牌验证成功
        // 3. 创建订单、订单项,并计算应付总价
        OrderCreateTo order = createOrder();

        // 4. 校验价格是否发生变化
        BigDecimal payAmount = order.getOrder().getPayAmount();
        BigDecimal payPrice = vo.getPayPrice();

        if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
            // 5. 实际误差小于0.01时,保存订单和订单项信息到数据库
            saveOrder(order);

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

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

            // 6-2. 调用远程锁定库存的方法
            // 可能出现的问题:
            // 1. 扣减库存成功了,但是由于网络原因超时,出现异常,导致订单事务回滚,
            //     库存事务不回滚(解决方案:seata)
            // 2. 为了保证高并发,不推荐使用seata,因为是加锁,并行化,提升不了效率,
            //    可以发消息给库存服务
            R r = wmsFeignService.orderLockStock(lockVo);
            if (r.getCode() == 0) {
                //锁定成功
                responseVo.setOrder(order.getOrder());
                
                // 7. 订单创建成功,发送消息给MQ
                rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", order.getOrder());

                // 8. 删除购物车里的数据
                redisTemplate.delete(CART_PREFIX + memberResponseVo.getId());
                return responseVo;
            } else {
                // 9. 锁定失败,抛出异常
                String msg = (String) r.get("msg");
                throw new NoStockException(msg);
            }

        } else {
            responseVo.setCode(2);
            return responseVo;
        }
    }
}
自动关单

当创建订单、锁库存步骤完成后,订单服务就会发送订单创建成功消息给MQ中的延时队列,30min后延时队列会将消息重新投递给订单服务的关单队列和库存服务的释放库存队列。

队列监听器(消费者):OrderCloseListener

Service层相关方法:OrderServiceImpl类的closeOrder方法

步骤:

  1. 查询数据库中的订单状态是否为已支付,如果订单已支付则丢弃该消息,否则进入下一步
  2. 修改订单状态为已取消,并重新发送消息给MQ,保证库存服务能解锁库存
// 关闭订单
@Override
public void closeOrder(OrderEntity orderEntity) {

    // 关闭订单之前先查询一下数据库,判断此订单状态是否已支付
    OrderEntity orderInfo = this.getOne(new QueryWrapper<OrderEntity>().
                                        eq("order_sn", orderEntity.getOrderSn()));

    if (orderInfo.getStatus().equals(OrderStatusEnum.CREATE_NEW.getCode())) {
        // 代付款状态进行关单
        OrderEntity orderUpdate = new OrderEntity();
        orderUpdate.setId(orderInfo.getId());
        orderUpdate.setStatus(OrderStatusEnum.CANCLED.getCode());
        this.updateById(orderUpdate);

        // 发送消息给MQ
        OrderTo orderTo = new OrderTo();
        BeanUtils.copyProperties(orderInfo, orderTo);

        try {
            // 确保每个消息发送成功,给每个消息做好日志记录,(给数据库保存每一个详细信息)保存每个消息的详细信息
            rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTo);
        } catch (Exception e) {
            // 定期扫描数据库,重新发送失败的消息
        }
    }
}
🔒锁库存

相关接口:/ware/waresku/lock/order

Service层相关方法:WareSkuServiceImpl类中的addStock方法

步骤:

  1. 保存订单关联的工作单信息

  2. 查询出有需要锁定的订单项库存的仓库

  3. 遍历每一个订单项,依次遍历仓库

    1. 该订单项没有存在有其库存的仓库,抛出异常,回滚所有数据

    2. 遍历完所有仓库后,锁定成功,保存该订单项对应的工作单详情到数据库,并发送消息给MQ

      发送到交换机为stock-event-exchange,路由键为stock.locked的延迟队列中。

      该延迟队列会等待两分钟(主要是为给订单服务一定的缓冲时间),将消息投递到交换机为stock-event-exchange,路由键为stock.release的队列。

      库存服务就会监听该队列中的消息,当收到该消息时

    3. 遍历完所有仓库后,锁定失败,抛出异常,回滚所有数据

锁定库存对应的SQL语句

UPDATE wms_ware_sku
SET stock_locked = stock_locked + #{num}
WHERE sku_id = #{skuId}
   AND ware_id = #{wareId}
   AND stock - stock_locked > 0
/**
 * 为某个订单锁定库存
 */
@Transactional(rollbackFor = Exception.class)
@Override
public boolean orderLockStock(WareSkuLockVo vo) {

    // 1. 保存订单关联的库存工作单
    WareOrderTaskEntity wareOrderTaskEntity = new WareOrderTaskEntity();
    wareOrderTaskEntity.setOrderSn(vo.getOrderSn());
    wareOrderTaskEntity.setCreateTime(new Date());
    wareOrderTaskService.save(wareOrderTaskEntity);


    // 按照下单的收货地址,找到一个就近仓库,锁定库存
    
    List<OrderItemVo> locks = vo.getLocks();

    List<SkuWareHasStock> collect = locks.stream().map((item) -> {
        SkuWareHasStock stock = new SkuWareHasStock();
        Long skuId = item.getSkuId();
        stock.setSkuId(skuId);
        stock.setNum(item.getCount());
        // 2. 查询有指定sku库存的仓库
        List<Long> wareIdList = wareSkuDao.listWareIdHasSkuStock(skuId);
        stock.setWareId(wareIdList);

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

    // 3. 锁定库存
    for (SkuWareHasStock hasStock : collect) {
        boolean skuStocked = false;
        Long skuId = hasStock.getSkuId();
        List<Long> wareIds = hasStock.getWareId();

        if (org.springframework.util.StringUtils.isEmpty(wareIds)) {
            // 没有任何仓库有这个商品的库存,则抛出异常
            throw new NoStockException(skuId);
        }

        // 3-1. 锁定成功, 将当前商品锁定了几件的工作单记录发给MQ
        // 3-2. 锁定失败, 前面保存的工作单信息都回滚了。发送出去的消息,即使要解锁库存,由于在数据库查不到指定的id,所以就不用解锁
        for (Long wareId : wareIds) {
            // 锁定成功就返回1,失败就返回0
            Long count = wareSkuDao.lockSkuStock(skuId, wareId, hasStock.getNum());
            if (count == 1) {
                skuStocked = true;
                WareOrderTaskDetailEntity taskDetailEntity = 
                    WareOrderTaskDetailEntity.builder()
                    .skuId(skuId)
                    .skuName("")
                    .skuNum(hasStock.getNum())
                    .taskId(wareOrderTaskEntity.getId())
                    .wareId(wareId)
                    .lockStatus(1)
                    .build();
                wareOrderTaskDetailService.save(taskDetailEntity);

                // 发送库存锁定成功消息到MQ
                StockLockedTo lockedTo = new StockLockedTo();
                lockedTo.setId(wareOrderTaskEntity.getId());
                StockDetailTo detailTo = new StockDetailTo();
                BeanUtils.copyProperties(taskDetailEntity, detailTo);
                lockedTo.setDetailTo(detailTo);
                rabbitTemplate.convertAndSend("stock-event-exchange", "stock.locked", lockedTo);
                break;
            } else {
                // 当前仓库锁失败,重试下一个仓库
            }
        }

        if (skuStocked == false) {
            // 当前商品所有仓库都没有锁住,抛出异常,回滚所有工作单
            throw new NoStockException(skuId);
        }
    }

    // 肯定全部都是锁定成功的
    return true;
}
🔓解锁库存

解锁库存主要有两个触发点:

  1. 下单成功,锁库存成功,但接下来的业务调用失败,因此要释放掉已锁定的库存。
  2. 订单超时未支付,导致释放库存。

消息投递的流程:

订单服务 -> 订单创建成功消息(自动关单队列) -> (30min后) 解锁库存消息
库存服务 -> 所库存消息(解锁库存延迟队列) -> (50min后) 解锁库存消息

注意:并不是发送了解锁库存消息就一定会解锁库存,在业务逻辑里面会先查询订单或者工作单的状态来判断是否需要解锁库存,来保证幂等性。

队列监听器(消费者):StockReleaseListener

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

    @Autowired
    private WareSkuService wareSkuService;

    /**
     * 1、库存自动解锁
     *  下订单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要自动解锁
     *
     * 2、订单失败
     *  库存锁定失败
     *
     * 注意:只要解锁库存的消息失败,一定要告诉服务解锁失败
     */
    @RabbitHandler
    public void handleStockLockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException {
        log.info("******收到解锁库存的信息******");
        try {
            // 解锁库存
            wareSkuService.unlockStock(to);
            // 手动删除消息
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            // 解锁失败 将消息重新放回队列,让别人消费
            channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
        }
    }

    // 订单超时未支付
    @RabbitHandler
    public void handleOrderCloseRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {
        log.info("******收到订单关闭,准备解锁库存的信息******");
        try {
            wareSkuService.unlockStock(orderTo);
            // 手动删除消息
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            // 解锁失败 将消息重新放回队列,让别人消费
            channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
        }
    }

Service层相关方法:WareSkuServiceImpl类的unlockStock方法

流程:

  • 根据工作单项详情来解释库存

    1. 获取工作单ID
    2. 查询工作单项详情,如果不存在则返回(说明数据已回滚),如果存在则进入下一步
    3. 远程查询订单状态,如果订单不存在或订单状态为已取消则释放库存,否则丢弃该消息
    4. 若远程调用失败,则抛出异常,让其他消费者消费该消息(抛出异常主要是为了让其他消费者能查询到订单的最新状态)
  • 根据订单来解锁库存

    1. 获取订单流水号
    2. 查询工作单的最新状态,如果工作单不存在或工作单状态不为锁定状态,则丢弃该消息,否则进入下一步
    3. 查询出工作单下已锁定的工作单项,根据工作单项解锁库存。

解锁库存SQL语句

UPDATE wms_ware_sku
SET stock_locked = stock_locked - #{num}
WHERE sku_id = ${skuId}
    AND ware_id = #{wareId}
// 根据工作单项详情来解锁库存
@Override
public void unlockStock(StockLockedTo to) {
    // 获取库存工作单的id
    StockDetailTo detail = to.getDetailTo();
    Long detailId = detail.getId();

    /**
      * 查询数据库关于这个订单锁定库存信息
      *   a. 有, 证明库存锁定成功了, 不一定解锁库存
      *      1. 订单状态为已取消, 则解锁库存
      *      2. 订单状态为已支付, 不能解锁库存
      *   b. 没有这个订单,必须解锁库存
      */
    WareOrderTaskDetailEntity taskDetailInfo = wareOrderTaskDetailService.getById(detailId);
    if (taskDetailInfo != null) {
        // 查出 wms_ware_order_task 工作单的信息
        Long id = to.getId();
        WareOrderTaskEntity orderTaskInfo = wareOrderTaskService.getById(id);
        // 获取订单号查询订单状态
        String orderSn = orderTaskInfo.getOrderSn();
        // 远程查询订单信息
        R orderData = orderFeignService.getOrderStatus(orderSn);
        if (orderData.getCode() == 0) {
            // 订单数据返回成功
            OrderVo orderInfo = orderData.getData("data", new TypeReference<OrderVo>() {
            });

            // 判断订单状态是否已取消或者支付或者订单不存在
            if (orderInfo == null || orderInfo.getStatus() == 4) {
                // 订单已被取消,才能解锁库存
                if (taskDetailInfo.getLockStatus() == 1) {
                    //当前库存工作单详情状态1,已锁定,但是未解锁才可以解锁
                    unLockStock(detail.getSkuId(), detail.getWareId(), detail.getSkuNum(), detailId);
                }
            }
        } else {
            // 消息拒绝以后重新放在队列里面,让别人继续消费解锁
            // 远程调用服务失败
            throw new RuntimeException("远程调用服务失败");
        }
    }
}

/**
     * 防止订单服务卡顿,导致订单状态消息一直改不了,库存优先到期,查订单状态新建,什么都不处理
     * 导致卡顿的订单,永远都不能解锁库存
     * @param orderTo
     */
@Transactional(rollbackFor = Exception.class)
@Override
public void unlockStock(OrderTo orderTo) {
    String orderSn = orderTo.getOrderSn();
    WareOrderTaskEntity orderTaskEntity = wareOrderTaskService.getOrderTaskByOrderSn(orderSn);

    // 查一下最新的库存解锁状态,防止重复解锁库存
    if (orderTaskEntity != null && orderTaskEntity.getTaskStatus().equals(1)) {

        // 按照工作单的id找到所有 没有解锁的库存,进行解锁
        Long id = orderTaskEntity.getId();
        List<WareOrderTaskDetailEntity> list =
            wareOrderTaskDetailService.list(new QueryWrapper<WareOrderTaskDetailEntity>()
                                            .eq("task_id", id).eq("lock_status", 1));

        for (WareOrderTaskDetailEntity taskDetailEntity : list) {
            unLockStock(taskDetailEntity.getSkuId(),
                        taskDetailEntity.getWareId(),
                        taskDetailEntity.getSkuNum(),
                        taskDetailEntity.getId());
        }
    }
}

/**
     * 解锁库存的方法
     * @param skuId
     * @param wareId
     * @param num
     * @param taskDetailId
     */
public void unLockStock(Long skuId, Long wareId, Integer num, Long taskDetailId) {
    // 库存解锁
    wareSkuDao.unLockStock(skuId, wareId, num);
    // 更新工作单的状态
    WareOrderTaskDetailEntity taskDetailEntity = new WareOrderTaskDetailEntity();
    taskDetailEntity.setId(taskDetailId);
    taskDetailEntity.setLockStatus(2);
    wareOrderTaskDetailService.updateById(taskDetailEntity);
}
支付

当用户提交订单,跳转到收银台页,用户选择相应的支付宝支付后,会发起对应请求跳转到支付包的付款界面。

相关接口:http://order.mall.com/aliPayOrder

主要流程:

  1. 根据订单号获取支付信息(总金额、商品描述、订单项属性、订单项名称)

  2. 向支付宝网关(https://openapi.alipaydev.com/gateway.do)发起请求,返回一个html页面,后台服务再返回该内容给用户

    发起请求时,需要设置支付成功同步回调接口(支付成功后显示的界面)和支付成功的异步调用接口。

    • 订单支付成功后支付宝会回调商户接口,这个时候需要修改订单状态。
    • 由于同步跳转可能由于网络问题失败,所以使用异步通知。
    • 支付宝使用的是最大努力通知方案,保障数据一致性,隔一段时间会通知商户支付成功,直到返回success

支付宝的加密原理

  • 支付宝加密采用RSA非对称加密,分别在商户端和支付宝端有两对公钥和私钥。
  • 在发送订单数据时,直接使用明文,但会使用商户私钥加一个对应的签名,支付宝端会使用商户公钥对签名进行验签,只有数据明文和签名对应的时候才能说明传输正确。
  • 支付成功后,支付宝发送支付成功数据之外,还会使用支付宝私钥加一个对应的签名,商户端收到支付成功数据之后也会使用支付宝公钥延签,成功后才能确认。

同步调用接口:MemberWebController类的memberOrderPage方法

异步调用接口:

当用户在支付宝支付成功后,支付宝就会调用我们提供的notify接口,直到调用成功(即接口返回success)。

异步接口的处理步骤:

  1. 使用支付宝提供的SDK验证数据和签名
  2. 将交易信息保存数据库中
  3. 检查订单状态,修改订单状态
  4. 返回success
@PostMapping(value = "/payed/notify")
public String handleAlipayed(PayAsyncVo asyncVo, HttpServletRequest request) throws AlipayApiException {
    /* 只要收到支付宝的异步通知,返回 success 支付宝便不再通知, 获取支付宝POST过来反馈信息 */
    // 验证数据和签名
    Map<String, String> params = new HashMap<>();
    Map<String, String[]> requestParams = request.getParameterMap();
    for (String name : requestParams.keySet()) {
        String[] values = requestParams.get(name);
        String valueStr = "";
        for (int i = 0; i < values.length; i++) {
            valueStr = (i == values.length - 1) ? valueStr + values[i]
                : valueStr + values[i] + ",";
        }
        // 乱码解决,这段代码在出现乱码时使用
        params.put(name, valueStr);
    }

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

    if (signVerified) {
        System.out.println("签名验证成功...");
        // 去修改订单状态
        return orderService.handlePayResult(asyncVo);
    } else {
        System.out.println("签名验证失败...");
        return "error";
    }
}

/**
 * 处理支付宝的支付结果
 * @param asyncVo
 * @return
 */
@Transactional(rollbackFor = Exception.class)
@Override
public String handlePayResult(PayAsyncVo asyncVo) {
    // 保存交易流水信息到数据库中
    PaymentInfoEntity paymentInfo = new PaymentInfoEntity();
    paymentInfo.setOrderSn(asyncVo.getOut_trade_no());
    paymentInfo.setAlipayTradeNo(asyncVo.getTrade_no());
    paymentInfo.setTotalAmount(new BigDecimal(asyncVo.getBuyer_pay_amount()));
    paymentInfo.setSubject(asyncVo.getBody());
    paymentInfo.setPaymentStatus(asyncVo.getTrade_status());
    paymentInfo.setCreateTime(new Date());
    paymentInfo.setCallbackTime(asyncVo.getNotify_time());
    this.paymentInfoService.save(paymentInfo);

    // 修改订单状态
    String tradeStatus = asyncVo.getTrade_status();

    if (tradeStatus.equals("TRADE_SUCCESS") || tradeStatus.equals("TRADE_FINISHED")) {
        // 支付成功状态
        String orderSn = asyncVo.getOut_trade_no(); //获取订单号
        this.updateOrderStatus(orderSn, OrderStatusEnum.PAYED.getCode(), PayConstant.ALIPAY);
    }

    return "success";
}
RabbitMQ中涉及到的队列

商品秒杀

秒杀商品上架流程
  1. 秒杀项目单独抽出一个模块,即gulimall-seckill
  2. 使用定时任务每天隔一个小时扫描一次数据库获取最近三天的秒杀商品。
  3. 秒杀链接加密,为秒杀商品添加唯一的商品随机码,在开始秒杀时才暴露接口。
  4. 库存预热,先从数据库中扣减一部分库存,以Redission信号量的形式保存到Redis中。
  5. 使用消息队列削峰,秒杀成功后立即返回,以发送消息的形式创建订单。
秒杀流程
Redis模型设计
  • 场次信息:seckill:sessions:{开始时间 + '_' + 结束时间}list结构

    里面的元素是场次ID + '-' + 商品ID的字符串

  • 秒杀商品信息:seckill:skushash结构,所有的秒杀商品都保存于此

    里面的元素是以场次ID + '-' + 商品ID为key,SeckillSkuRedisTo类的json字符串为value的键值对。

    SeckillSkuRedisTo

    @Data
    public class SeckillSkuRedisTo {
        private Long promotionId; 			// 活动id
        private Long promotionSessionId;     // 活动场次id
        private Long skuId;                  // 商品id
        private BigDecimal seckillPrice;     // 秒杀价格
        private Integer seckillCount;        // 秒杀总量
        private Integer seckillLimit;        // 每人限购数量
        private Integer seckillSort;         // 排序
        private SkuInfoVo skuInfo;           // sku的详细信息
        private Long startTime;              // 当前商品秒杀的开始时间
        private Long endTime;                // 当前商品秒杀的结束时间
        private String randomCode;           // 当前商品秒杀的随机码
    }
    
  • 秒杀库存:seckill:stock:{秒杀随机码}set结构,分布式信号(Redission),存储秒杀商品总量。

定时上架最近的三天秒杀活动

通过SpringBoot自带的@EnableScheduling注解来启用定时任务,并且配合@EnableAsync注解开启异步任务防止定时任务阻塞请求处理线程。

定时任务相关方法:SeckillScheduled类下的uploadSeckillSkuLatest3Days方法。

使用Redission分布式锁seckill:upload:lock来防止多个服务同时上架商品。

把秒杀活动和商品缓存到Redis中:

流程:

  1. 调用远程的优惠卷服务获取最近三天需要参加秒杀活动的场次和商品信息
  2. 缓存场次信息,判断该场次是否已经添加?如果未添加,则以seckill:sessions:{startTime_endTime}为key添加到Redis的队列中
  3. 缓存商品信息,将所有秒杀商品信息都保存到seckill:skus的hash结构中。hash-key为场次ID + '-' + 商品ID,同时把商品秒杀数量放入到以seckill:stock:{秒杀随即码}为key的分布式信号量中。
private final String SESSION__CACHE_PREFIX = "seckill:sessions:";

private final String SECKILL_CHARE_PREFIX = "seckill:skus";

private final String SKU_STOCK_SEMAPHORE = "seckill:stock:";    //+商品随机码

// 上架最近三天需要参加秒杀活动的商品
@Override
public void uploadSeckillSkuLatest3Days() {
    // 1、远程调用优惠卷服务获取最近三天需要参加秒杀活动的商品
    R lates3DaySession = couponFeignService.getLates3DaySession();
    if (lates3DaySession.getCode() == 0) {
        List<SeckillSessionWithSkusVo> sessionData =
            lates3DaySession.getData("data", 
                                     new TypeReference<List<SeckillSessionWithSkusVo>>() {});
        // 2、缓存场次信息
        saveSessionInfos(sessionData);

        // 3、缓存活动的关联商品信息
        saveSessionSkuInfo(sessionData);
    }
}

/**
 * 缓存秒杀活动信息
 * @param sessions
 */
private void saveSessionInfos(List<SeckillSessionWithSkusVo> sessions) {
    sessions.stream().forEach(session -> {
        // 1. 获取当前活动的开始和结束时间的时间戳
        long startTime = session.getStartTime().getTime();
        long endTime = session.getEndTime().getTime();

        // 存入到Redis中的key
        String key = SESSION__CACHE_PREFIX + startTime + "_" + endTime;

        // 2. 判断Redis中是否有该信息,如果没有才进行添加
        Boolean hasKey = redisTemplate.hasKey(key);
        // 3. 缓存活动信息
        if (!hasKey) {
            // 将 `场次ID + '-' + 商品ID` 添加到 `seckill:sessions:{startTime_endTime}` 的队列中
            List<String> skuIds = session.getRelationSkus()
                .stream()
                .map(item -> item.getPromotionSessionId() + "-" + item.getSkuId())
                .collect(Collectors.toList());
            redisTemplate.opsForList().leftPushAll(key, skuIds);
        }
    });

}

/**
 * 缓存秒杀活动所关联的商品信息
 * @param sessions
 */
private void saveSessionSkuInfo(List<SeckillSessionWithSkusVo> sessions) {
    sessions.stream().forEach(session -> {
        // 准备hash操作,绑定hash,key为seckill:skus
        BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
        session.getRelationSkus().stream().forEach(seckillSkuVo -> {
            // 1. 生成随机码
            String token = UUID.randomUUID().toString().replace("-", "");
            String redisKey = seckillSkuVo.getPromotionSessionId() + "-" + seckillSkuVo.getSkuId();
            if (!operations.hasKey(redisKey)) {
                // 如果不存在对应场次的秒杀商品信息,则进行缓存
                SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
                Long skuId = seckillSkuVo.getSkuId();
                // 2. 调用远程商品服务查询sku的基本信息,
                R info = productFeignService.getSkuInfo(skuId);
                if (info.getCode() == 0) {
                    SkuInfoVo skuInfo = info.getData("skuInfo", 
                                                     new TypeReference<SkuInfoVo>() {});
                    redisTo.setSkuInfo(skuInfo);
                }

                // 3-1. 设置需要保存的sku秒杀信息
                BeanUtils.copyProperties(seckillSkuVo, redisTo);

                // 3-2. 设置当前商品的秒杀时间信息
                redisTo.setStartTime(session.getStartTime().getTime());
                redisTo.setEndTime(session.getEndTime().getTime());

                // 3-3. 设置商品的随机码(防止恶意攻击)
                redisTo.setRandomCode(token);

                // 4. 序列化json格式存入Redis中
                String seckillValue = JSON.toJSONString(redisTo);
                String key = String.format("%s-%s", 
                                           seckillSkuVo.getPromotionSessionId(), 
                                           seckillSkuVo.getSkuId());
                operations.put(key, seckillValue);

                // 5. 使用库存作为分布式Redisson信号量(限流)
                // 使用库存作为分布式信号量
                RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
                // 商品的秒杀数量作为信号量
                semaphore.trySetPermits(seckillSkuVo.getSeckillCount());
            }
        });
    });
}
商城首页(显示秒杀活动信息)

相关接口:http://seckill.mall.com/getCurrentSeckillSkus

Service层相关方法:SeckillServiceImpl类的getCurrentSeckillSkus方法

/**
 * 获取到当前可以参加秒杀商品的信息
 * @return
 */
@SentinelResource(value = "getCurrentSeckillSkusResource", blockHandler = "blockHandler")
@Override
public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {

    try (Entry entry = SphU.entry("seckillSkus")) {
        // 1. 确定当前属于哪个秒杀场次
        long currentTime = System.currentTimeMillis();

        //从Redis中查询到所有key以seckill:sessions开头的所有数据
        Set<String> keys = redisTemplate.keys(SESSION__CACHE_PREFIX + "*");
        for (String key : keys) {
            // seckill:sessions:{startTime_endTime}
            String replace = key.replace(SESSION__CACHE_PREFIX, "");
            String[] s = replace.split("_");
            // 获取存入Redis商品的开始时间
            long startTime = Long.parseLong(s[0]);
            // 获取存入Redis商品的结束时间
            long endTime = Long.parseLong(s[1]);

            // 判断是否是当前秒杀场次
            if (currentTime >= startTime && currentTime <= endTime) {
                // 2. 获取这个秒杀场次需要的所有商品信息
                List<String> range = redisTemplate.opsForList().range(key, -100, 100);
                BoundHashOperations<String, String, String> hasOps = 
                    redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
                assert range != null;
                List<String> listValue = hasOps.multiGet(range);
                if (listValue != null && listValue.size() >= 0) {
                    List<SeckillSkuRedisTo> collect = listValue.stream().map(item -> {
                        String items = (String) item;
                        SeckillSkuRedisTo redisTo = 
                            JSON.parseObject(items, SeckillSkuRedisTo.class);
                        return redisTo;
                    }).collect(Collectors.toList());
                    return collect;
                }
                break;
            }
        }
    } catch (BlockException e) {
        log.error("资源被限流{}", e.getMessage());
    }
    return null;
}
商品详情(携带秒杀活动信息)

相关接口:/{skuId}.html

Service层相关方法:SkuInfoServiceImpl类的item方法。

步骤:

  1. 获取sku的基本信息
  2. 获取spu的销售属性集合
  3. 获取spu的介绍
  4. 获取spu的规格参数
  5. 获取spu的图片信息
  6. 远程查询秒杀服务该商品是否有参与秒杀活动
// 查看商品详情
@Override
public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {

    SkuItemVo skuItemVo = new SkuItemVo();

    CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
        //1、sku基本信息的获取  pms_sku_info
        SkuInfoEntity info = this.getById(skuId);
        skuItemVo.setInfo(info);
        return info;
    }, executor);


    CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync((res) -> {
        //3、获取spu的销售属性组合
        List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrBySpuId(res.getSpuId());
        skuItemVo.setSaleAttr(saleAttrVos);
    }, executor);


    CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync((res) -> {
        //4、获取spu的介绍    pms_spu_info_desc
        SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());
        skuItemVo.setDesc(spuInfoDescEntity);
    }, executor);


    CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync((res) -> {
        //5、获取spu的规格参数信息
        List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(res.getSpuId(), res.getCatalogId());
        skuItemVo.setGroupAttrs(attrGroupVos);
    }, executor);


    // Long spuId = info.getSpuId();
    // Long catalogId = info.getCatalogId();

    //2、sku的图片信息    pms_sku_images
    CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
        List<SkuImagesEntity> imagesEntities = skuImagesService.getImagesBySkuId(skuId);
        skuItemVo.setImages(imagesEntities);
    }, executor);

    CompletableFuture<Void> seckillFuture = CompletableFuture.runAsync(() -> {
        //3、远程调用查询当前sku是否参与秒杀优惠活动
        R skuSeckilInfo = seckillFeignService.getSkuSeckilInfo(skuId);
        if (skuSeckilInfo.getCode() == 0) {
            //查询成功
            SeckillSkuVo seckilInfoData = skuSeckilInfo.getData("data", new TypeReference<SeckillSkuVo>() {
            });
            skuItemVo.setSeckillSkuVo(seckilInfoData);

            if (seckilInfoData != null) {
                long currentTime = System.currentTimeMillis();
                if (currentTime > seckilInfoData.getEndTime()) {
                    skuItemVo.setSeckillSkuVo(null);
                }
            }
        }
    }, executor);


    //等到所有任务都完成
    CompletableFuture.allOf(saleAttrFuture,descFuture,baseAttrFuture,imageFuture,seckillFuture).get();

    return skuItemVo;
}
查询商品是否参与秒杀活动

相关接口:mall-seckill/sku/seckill/{skuId}

参数:skuId

Service层相关方法:SeckillServiceImpl类的getSkuSeckilInfo方法

/**
 * 根据skuId查询商品是否参加秒杀活动
 * @param skuId
 * @return
 */
@Override
public SeckillSkuRedisTo getSkuSeckilInfo(Long skuId) {
    // 1. 找到所有需要秒杀的商品的key信息 => `seckill:skus`
    BoundHashOperations<String, String, String> hashOps = 
        redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
    Set<String> keys = hashOps.keys();
    // 2. 遍历所有key, 使用正则表达式匹配
    if (keys != null && keys.size() > 0) {
        // 正则表达式进行匹配
        String reg = "\\d-" + skuId;
        for (String key : keys) {
            // 如果匹配上了
            if (Pattern.matches(reg, key)) {
                // 从Redis中取出数据来
                String redisValue = hashOps.get(key);
                // 进行序列化
                SeckillSkuRedisTo redisTo = JSON.parseObject(redisValue, SeckillSkuRedisTo.class);

                // 随机码
                Long currentTime = System.currentTimeMillis();
                Long startTime = redisTo.getStartTime();
                Long endTime = redisTo.getEndTime();
                // 3. 检查是否到了秒杀时间
                // 如果当前时间大于等于秒杀活动开始时间并且要小于活动结束时间
                if (currentTime >= startTime && currentTime <= endTime) {
                    return redisTo;
                }
                redisTo.setRandomCode(null);
                return redisTo;
            }
        }
    }
    return null;
}

前端展示:

如果不存在秒杀活动,则按照正常逻辑显示,若存在秒杀活动,则进行下一步判断。

如果未到秒杀活动时间,则在页面展示秒杀活动开启时间;否则,显示秒杀价。

如果秒杀活动进行中,则显示立即抢购按钮。

秒杀

相关接口:/kill

参数:kill_id(格式:场次ID-skuID),code(秒杀随机码),num(购买数量)

Service层相关方法:SeckillServiceImpl类的kill方法

步骤:

  1. 获取当前用户信息。
  2. 根据killId从Redis中获取商品详情,如果Redis中不存在则返回null。
  3. 合法性校验
    • 判断当前时间是否在秒杀活动时间范围内
    • 判断随机码是否正确
    • 判断killID是否正确
  4. 获取信号量的值,判断当前用户购买的数量是否在限购范围内且库存量是否充足。
  5. 使用用户id-场次ID-商品ID作为key,判断Redis中是否存在该key,存在则说明该用户已购买过,否则设置该key的值为购买数量,过期时间为当前时间减去活动结束时间。(保证一个用户只能在该场次内购买一次)
  6. 尝试扣减信号量,扣减成功则生成订单号和订单相关信息发送给MQ,返回订单号。
  7. 否则返回null。
/**
 * 当前商品进行秒杀(秒杀开始)
 * @param killId
 * @param key
 * @param num
 * @return
 */
@Override
public String kill(String killId, String key, Integer num) throws InterruptedException {
    // 1. 获取当前用户的信息
    MemberResponseVo user = LoginUserInterceptor.loginUser.get();

    // 2. 获取当前秒杀商品的详细信息从Redis中获取
    BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
    String skuInfoValue = hashOps.get(killId);
    if (StringUtils.isEmpty(skuInfoValue)) {
        return null;
    }
    // (合法性效验)
    SeckillSkuRedisTo redisTo = JSON.parseObject(skuInfoValue, SeckillSkuRedisTo.class);
    Long startTime = redisTo.getStartTime();
    Long endTime = redisTo.getEndTime();
    long currentTime = System.currentTimeMillis();
    // 3. 判断当前这个秒杀请求是否在活动时间区间内(效验时间的合法性)
    if (currentTime >= startTime && currentTime <= endTime) {
        // 4. 效验随机码和商品id
        String randomCode = redisTo.getRandomCode();
        String skuId = redisTo.getPromotionSessionId() + "-" + redisTo.getSkuId();
        if (randomCode.equals(key) && killId.equals(skuId)) {
            // 5. 验证购物数量是否合理和库存量是否充足
            Integer seckillLimit = redisTo.getSeckillLimit();
            // 获取信号量
            String seckillCount = redisTemplate.opsForValue().get(SKU_STOCK_SEMAPHORE + randomCode);
            int count = Integer.parseInt(seckillCount);
            // 判断信号量是否大于0,并且买的数量不能超过库存
            if (count > 0 && num <= seckillLimit && count > num) {
                //4、验证这个人是否已经买过了(幂等性处理),如果秒杀成功,就去占位。userId-sessionId-skuId
                // SETNX 原子性处理
                String redisKey = user.getId() + "-" + skuId;
                // 设置自动过期(活动结束时间-当前时间)
                long ttl = endTime - currentTime;
                if (redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS)) {
                    //占位成功说明从来没有买过,分布式锁(获取信号量-1)
                    RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
                    // 秒杀成功,快速下单
                    boolean semaphoreCount = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
                    // 保证Redis中还有商品库存
                    if (semaphoreCount) {
                        // 创建订单号和订单信息发送给MQ
                        // 秒杀成功 快速下单 发送消息到 MQ 整个操作时间在 10ms 左右
                        String timeId = IdWorker.getTimeId();
                        SeckillOrderTo orderTo = new SeckillOrderTo();
                        orderTo.setOrderSn(timeId);
                        orderTo.setMemberId(user.getId());
                        orderTo.setNum(num);
                        orderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
                        orderTo.setSkuId(redisTo.getSkuId());
                        orderTo.setSeckillPrice(redisTo.getSeckillPrice());
                        rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", orderTo);
                        return timeId;
                    }
                }
            }
        }
    }
    return null;
}

消息队列处理逻辑:

Service层相关方法:OrderServiceImpl类的createSeckillOrder方法

主要就是将订单信息保存到数据库,并保存订单项信息到数据库。

/**
 * 创建秒杀单
 * @param orderTo
 */
@Override
public void createSeckillOrder(SeckillOrderTo orderTo) {
    // 保存订单信息
    OrderEntity orderEntity = new OrderEntity();
    orderEntity.setOrderSn(orderTo.getOrderSn());
    orderEntity.setMemberId(orderTo.getMemberId());
    orderEntity.setCreateTime(new Date());
    BigDecimal totalPrice = orderTo.getSeckillPrice().multiply(BigDecimal.valueOf(orderTo.getNum()));
    orderEntity.setPayAmount(totalPrice);
    orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());

    // 保存订单
    this.save(orderEntity);

    // 保存订单项信息
    OrderItemEntity orderItem = new OrderItemEntity();
    orderItem.setOrderSn(orderTo.getOrderSn());
    orderItem.setRealAmount(totalPrice);

    orderItem.setSkuQuantity(orderTo.getNum());

    // 保存商品的spu信息
    R spuInfo = productFeignService.getSpuInfoBySkuId(orderTo.getSkuId());
    SpuInfoVo spuInfoData = spuInfo.getData("data", new TypeReference<SpuInfoVo>() {});
    orderItem.setSpuId(spuInfoData.getId());
    orderItem.setSpuName(spuInfoData.getSpuName());
    orderItem.setSpuBrand(spuInfoData.getBrandName());
    orderItem.setCategoryId(spuInfoData.getCatalogId());

    // 保存订单项数据
    orderItemService.save(orderItem);
}
  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值