【Project】云商城 - 订单服务

本文将详细介绍云商城的订单服务。云商城完整项目介绍见文章 【Project】云商城

订单服务

电商系列涉及到 3 流,分别为信息流资金流物流,而订单系统作为中枢将三者有机的集合起来。

订单模块是电商系统的枢纽,在订单这个模块上获取多个模块的数据和信息,同时对这些信息进行加工处理后流向下个环节,这一系列就构成了订单的信息疏通

本项目的订单服务 mall-order 需要实现的功能:

  • 订单服务登录拦截
  • 用户在购物车页点击【去结算】,将购物车内商品信息封装成订单确认页数据 OrderConfirmVo,并跳转到订单确认页 confirm.html
  • 用户在订单确认页确定订单信息后点击【提交订单】,将根据前端传来的订单确认页数据创建出订单实体对象,并持久化到数据库中。30 分钟后关闭失败订单
  • 之后远程调用库存服务锁定库存。并在 50 分钟后进行失败订单的库存解锁
  • 用户点击【支付订单】后,使用支付宝支付服务完成订单支付

在订单服务中使用消息队列保证整体事务一致性(订单和库存事务一致)。其他要求并发性不高的场景可以使用 Seata(例如后台管理系统中的分布式事务)

完整的订单中心依次需要流程:

电商订单流程图

订单创建与支付:

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

逆向流程:

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

image-20201117102129127

订单中心涉及的信息

1. 用户信息

用户信息包括是用户账号、用户等级、用户的收货地址、收货人、收货人电话、用户账号需要绑定手机号码,但是用户绑定的手机号码不一定是收货信息上的电话。用户可以添加多个收货信息,用户等级信息可以用来和促销系统进行匹配,获取商品折扣,同时用户等级还可以获取积分的奖励等。

2. 订单基础信息

订单基础信息是订单流转的核心,其包括订单类型,父/子订单、订单编号、订单状态、订单流转时间等。

3. 商品信息

商品信息从商品数据库中获取商品的 SKU 信息、图片、名称、属性规格、商品单价、商户信息等,从用户下单行为记录的用户下单数量,商品合计价格等。

4. 优惠信息

优惠信息记录用户参与过的优惠活动,包括优惠促销活动,比如满减、满赠、秒杀等,用户使用的优惠卷信息,优惠卷满足条件的优惠卷需要展示出来,另外虚拟币抵扣信息等进行记录

为什么把优惠信息单独拿出来而不放在支付信息里面呢?因为优惠信息只是记录用户使用的条目,而支付信息需要加入数据进行计算,所以做为区分。

5. 支付信息

  • 支付流水单号:这个流水单号是在唤起网关支付后支付通道返回给电商业务平台的支付流水号,财务通过订单号和流水单号与支付通道进行对账使用
  • 支付方式:用户使用的支付方式,比如微信支付、支付宝支付、钱包支付、快捷支付等。支付方式有时候可能有两个一-余额支付+第三方支付
  • 商品总金额:每个商品加总后的金额
  • 运费:物流价格
  • 优惠总金额:包括促销活动的优惠金额
  • 优惠券优惠金额,虚拟积分或者虚拟币抵扣的金额,会员折扣的金额等之和
  • 实付金额:用户实际需要付款的金额

用户实付金额 = 商品总金额 + 运费 - 优惠总金额

6. 物流信息

物流信息包括配送方式,物流公司,物流单号,物流状态,物流状态可以通过第三方接口来
获取和向用户展示物流每个状态节点。

订单状态

1. 待付款

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

2. 已付款 / 代发货

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

3. 待收货 / 已发货

仓库将商品出库后,订单进入物流环节,订单系统需要同步物流信息,便于用户实时熟悉商品的物流状态

4. 已完成

用户确认收货后吗,订单交易完成,后续支付则进行计算,如果订单存在问题进入售后状态

5. 已取消

付款之前取消订单,包括超时未付款或用户商户取消订单都会产生这种订单状态

6. 售后中

用户在付款后申请退款,或商家发货后用户申请退货。

售后也同样存在各种状态,当发起售后申请后生成售后订单,售后订单状态为待审核,等待商家审核,商家审核通过后订单状态变更为待退货,等待用户将商品寄回,商家收货后订单状态更新为待退款状态,退款到用户原账户后订单状态更新为售后成功。

数据模型设计

订单确认项 OrderConfirmVo

一个订单 OrderConfirmVo 中有多个订单项 OrderItemVo。对应着一个购物车 CartVo 中有多个购物项 CartItemVo

OrderConfirmVo 是在点击购物车页面的【去结算】按钮后进行查询构建的。一个订单 OrderConfirmVo 对象包含了所有选中的购物项(订单项)、会员收货地址、优惠券、防重令牌、订单项是否有库存、订单项总数、订单总金额。其中订单项总数和订单总金额需要自动计算。

完整定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class OrderConfirmVo {
/** 所有选中的购物项(抽取成订单项) **/
@Getter @Setter
private List<OrderItemVo> items;

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

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

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

/** 每一个订单项是否有库存 **/
@Getter @Setter
Map<Long, Boolean> stocks;

/** 自动计算订单项总数 **/
public Integer getCount() {
Integer count = 0;
if (items != null && items.size() > 0) {
for (OrderItemVo item : items) {
count += item.getCount();
}
}
return count;
}

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

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

订单页面的展示效果:

image-20220119112046278

订单项 OrderItemVo

订单项 OrderItemVo 是一个订单中的重要部分。一个订单 OrderConfirmVo 由多个订单项 OrderItemVo 组成。

订单项 OrderItemVo 的获取是通过远程调用购物车服务获取到的,因此订单项和购物项的内容是相同的,只不过抽取成了不同的名称而已。订单项 OrderItemVo 的完整定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Data
public class OrderItemVo {
private Long skuId;

private Boolean check = true;

private String title;

private String image;

/**
* 商品套餐属性
*/
private List<String> skuAttrValues;

private BigDecimal price;

private Integer count;

private BigDecimal totalPrice;

/** 商品重量 **/
private BigDecimal weight = new BigDecimal("0.085");
}

会员收货地址 MemberAddressVo

会员收货地址 MemberAddressVo 信息需要远程调用会员服务进行查询。完整定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Data
public class MemberAddressVo {
private Long id;
/**
* member_id
*/
private Long memberId;
/**
* 收货人姓名
*/
private String name;
/**
* 电话
*/
private String phone;
/**
* 邮政编码
*/
private String postCode;
/**
* 省份/直辖市
*/
private String province;
/**
* 城市
*/
private String city;
/**
* 区
*/
private String region;
/**
* 详细地址(街道)
*/
private String detailAddress;
/**
* 省市区代码
*/
private String areacode;
/**
* 是否默认
*/
private Integer defaultStatus;
}

这些 VO 对象都是和前端进行交互时传递的数据结构。实际保存到数据库中的是订单实体类和订单项为 OrderEntityOrderItemEntity

订单实体类 OrderEntity

订单实体类保存了订单的价格、优惠、积分等信息,同时也保存了收货人信息、收货地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
@Data
@TableName("oms_order")
public class OrderEntity implements Serializable {
private static final long serialVersionUID = 1L;

/**
* id
*/
@TableId
private Long id;
/**
* member_id
*/
private Long memberId;
/**
* 订单号
*/
private String orderSn;
/**
* 使用的优惠券
*/
private Long couponId;
/**
* create_time
*/
private Date createTime;
/**
* 用户名
*/
private String memberUsername;
/**
* 订单总额
*/
private BigDecimal totalAmount;
/**
* 应付总额
*/
private BigDecimal payAmount;
/**
* 运费金额
*/
private BigDecimal freightAmount;
/**
* 促销优化金额(促销价、满减、阶梯价)
*/
private BigDecimal promotionAmount;
/**
* 积分抵扣金额
*/
private BigDecimal integrationAmount;
/**
* 优惠券抵扣金额
*/
private BigDecimal couponAmount;
/**
* 后台调整订单使用的折扣金额
*/
private BigDecimal discountAmount;
/**
* 支付方式【1->支付宝;2->微信;3->银联; 4->货到付款;】
*/
private Integer payType;
/**
* 订单来源[0->PC订单;1->app订单]
*/
private Integer sourceType;
/**
* 订单状态【0->待付款;1->待发货;2->已发货;3->已完成;4->已关闭;5->无效订单】
*/
private Integer status;
/**
* 物流公司(配送方式)
*/
private String deliveryCompany;
/**
* 物流单号
*/
private String deliverySn;
/**
* 自动确认时间(天)
*/
private Integer autoConfirmDay;
/**
* 可以获得的积分
*/
private Integer integration;
/**
* 可以获得的成长值
*/
private Integer growth;
/**
* 发票类型[0->不开发票;1->电子发票;2->纸质发票]
*/
private Integer billType;
/**
* 发票抬头
*/
private String billHeader;
/**
* 发票内容
*/
private String billContent;
/**
* 收票人电话
*/
private String billReceiverPhone;
/**
* 收票人邮箱
*/
private String billReceiverEmail;
/**
* 收货人姓名
*/
private String receiverName;
/**
* 收货人电话
*/
private String receiverPhone;
/**
* 收货人邮编
*/
private String receiverPostCode;
/**
* 省份/直辖市
*/
private String receiverProvince;
/**
* 城市
*/
private String receiverCity;
/**
* 区
*/
private String receiverRegion;
/**
* 详细地址
*/
private String receiverDetailAddress;
/**
* 订单备注
*/
private String note;
/**
* 确认收货状态[0->未确认;1->已确认]
*/
private Integer confirmStatus;
/**
* 删除状态【0->未删除;1->已删除】
*/
private Integer deleteStatus;
/**
* 下单时使用的积分
*/
private Integer useIntegration;
/**
* 支付时间
*/
private Date paymentTime;
/**
* 发货时间
*/
private Date deliveryTime;
/**
* 确认收货时间
*/
private Date receiveTime;
/**
* 评价时间
*/
private Date commentTime;
/**
* 修改时间
*/
private Date modifyTime;
}

订单项实体类 OrderItemEntity

订单项实体类同时包含了当前商品的 SKU 信息和 SPU 信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
Data
@TableName("oms_order_item")
public class OrderItemEntity implements Serializable {
private static final long serialVersionUID = 1L;

/**
* id
*/
@TableId
private Long id;
/**
* order_id
*/
private Long orderId;
/**
* order_sn
*/
private String orderSn;
/**
* spu_id
*/
private Long spuId;
/**
* spu_name
*/
private String spuName;
/**
* spu_pic
*/
private String spuPic;
/**
* 品牌
*/
private String spuBrand;
/**
* 商品分类id
*/
private Long categoryId;
/**
* 商品sku编号
*/
private Long skuId;
/**
* 商品sku名字
*/
private String skuName;
/**
* 商品sku图片
*/
private String skuPic;
/**
* 商品sku价格
*/
private BigDecimal skuPrice;
/**
* 商品购买的数量
*/
private Integer skuQuantity;
/**
* 商品销售属性组合(JSON)
*/
private String skuAttrsVals;
/**
* 商品促销分解金额
*/
private BigDecimal promotionAmount;
/**
* 优惠券优惠分解金额
*/
private BigDecimal couponAmount;
/**
* 积分优惠分解金额
*/
private BigDecimal integrationAmount;
/**
* 该商品经过优惠后的分解金额
*/
private BigDecimal realAmount;
/**
* 赠送积分
*/
private Integer giftIntegration;
/**
* 赠送成长值
*/
private Integer giftGrowth;
}

订单汇总 OrderCreateTo

订单汇总类 OrderCreateTo 负责封装订单实体类 OrderEntity 和订单项实体类 List<OrderItemEntity>

1
2
3
4
5
6
7
8
9
10
11
12
@Data
public class OrderCreateTo {
private OrderEntity order;

private List<OrderItemEntity> orderItems;

/** 订单计算的应付价格 **/
private BigDecimal payPrice;

/** 运费 **/
private BigDecimal fare;
}

库存服务核心数据模型

库存服务中的数据模型分析见 库存服务数据模型

订单服务登录拦截

首先为订单服务增加登录拦截器,在每个访问订单服务的请求访问前进行拦截:

  • 允许路径匹配 /order/order/infoByOrderSn/**/payed/** 的请求直接放行。因为这些远程调用请求是和消息队列相关的定时任务(定时删单和定时支付)。这些请求不包含用户信息,因此无法验证身份,可以直接放行
  • 从 Redis 中查找当前用户的 Session(订单服务同样需要配置 Spring Session)
    • 若存在,则查出其会员信息 MemberResponseVo,将该会员信息 MemberResponseVo 保存到 ThreadLocal 中,其将被 Service 层读取
    • 若不存在,则当前用户未登录,重定向到登录页面令其登录

代码:

  1. 自定义登录拦截器,若用户未登录则跳转到登录页面;若已登录则将用户信息存入 ThreadLocal 中在 Service 层获取:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 登录拦截器,未登录的用户不能进入订单服务
*/
public class LoginInterceptor implements HandlerInterceptor {
public static ThreadLocal<MemberResponseVo> loginUserThreadLocal = new ThreadLocal<>();

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 允许路径匹配 /order/order/infoByOrderSn/** 与 /payed/** 的请求直接放行
// 因为这些远程调用请求是和MQ相关的定时任务(定时删单和定时支付),不包含用户信息,可以直接放行
String requestURI = request.getRequestURI();
AntPathMatcher matcher = new AntPathMatcher();
boolean match1 = matcher.match("/order/order/infoByOrderSn/**", requestURI);
boolean match2 = matcher.match("/payed/**", requestURI);
if (match1 || match2) return true;

// Spring Session 包装后的 request 对象使用 getSession() 时将从 Redis 里获取 Session
HttpSession session = request.getSession();
// 从 Redis Session 中查出当前用户的会员信息
MemberResponseVo memberResponseVo = (MemberResponseVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
if (memberResponseVo != null) {
// 保存到 ThreadLocal 中,将被 Service 层读取
loginUserThreadLocal.set(memberResponseVo);
return true;
}else {
session.setAttribute("msg","请先登录");
// 跳转到登录页面
response.sendRedirect("http://auth.yunmall.com/login.html");
return false;
}
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

}
}
  1. 将其注册到容器中:
1
2
3
4
5
6
7
@Configuration
public class MyMallWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**");
}
}

【去结算】:生成订单确认页数据

【去结算】业务将根据购物车中各种信息生成订单确认页数据 OrderConfirmVo 对象,并发送给前端展示

在购物车页点击【去结算】按钮后,将发出请求 http://order.yunmall.com/toTrade 访问订单服务。

image-20220119102244416

跳转后,订单服务将完成:

  • 先进行拦截判断用户是否登录。登录后从 ThreadLocal 中获取用户的会员信息(为了防止 ThreadLocal 内存泄漏,需要在获取用户信息后手动清除数据,从 ThreadLocalMap 中移除该 ThreadLocal 及其保存的会员数据)
  • 异步远程调用购物车服务getCheckedItems() 方法,查询用户勾选上的购物项信息
  • 在上一步的基础上远程调用库存服务getSkuHasStock() 方法,查询每个购物项的库存信息
  • 异步远程调用会员服务getAddressByUserId() 方法,查询当前会员的所有收货地址
  • 设置防重令牌。随机生成一个 UUID 作为令牌保存到 Redis 中,并且保存到 OrderConfirmVo 对象中一并返回给客户端。之后,用户在点击【提交订单】时将带上该令牌保证同一个订单只能提交一次(幂等性

最终生成订单确认页数据返回给前端渲染展示。

流程图:

订单确认页流程

设置防重令牌是为了解决幂等性问题

订单确认页的渲染效果:

image-20220119112046278

业务代码

Contoller 层:

1
2
3
4
5
6
@RequestMapping("/toTrade")
public String toTrade(Model model) {
OrderConfirmVo confirmVo = orderService.confirmOrder();
model.addAttribute("confirmOrder", confirmVo);
return "confirm";
}

Service 层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Override
public OrderConfirmVo confirmOrder() {
// 从 ThreadLocal 中查出当前会员用户的信息
MemberResponseVo memberResponseVo = LoginInterceptor.loginUserThreadLocal.get();
// 防止 ThreadLocal 内存泄漏,需要手动清除数据,从ThreadLocalMap中移除该ThreadLocal及其数据
LoginInterceptor.loginUserThreadLocal.remove();

OrderConfirmVo confirmVo = new OrderConfirmVo();
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();

CompletableFuture<Void> itemAndStockFuture = CompletableFuture.supplyAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
// 任务1. 异步查询所有选中的购物项
List<OrderItemVo> checkedItems = cartFeignService.getCheckedItems();
confirmVo.setItems(checkedItems);
return checkedItems;
}, executor).thenAcceptAsync((items) -> {
// 任务4. 在任务1执行完毕后远程查询每个购物项的库存信息
List<Long> skuIds = items.stream().map(OrderItemVo::getSkuId).collect(Collectors.toList());
Map<Long, Boolean> hasStockMap = wareFeignService.getSkuHasStock(skuIds).stream().collect(Collectors.toMap(SkuHasStockVo::getSkuId, SkuHasStockVo::getHasStock));
confirmVo.setStocks(hasStockMap);
}, executor);

// 任务2. 异步查询当前会员的所有收货地址
CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {
List<MemberAddressVo> addressByUserId = memberFeignService.getAddressByUserId(memberResponseVo.getId());
confirmVo.setMemberAddressVos(addressByUserId);
}, executor);

// 任务3. 设置积分信息
confirmVo.setIntegration(memberResponseVo.getIntegration());

// 任务5. 总价自动计算

// 任务6. 设置防重令牌。随机生成一个 UUID 作为令牌保存到 Reids 中,并且返回给客户端。
// 其在点击【提交订单】时将带上该令牌:key - value = order:token:userId - uuid
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId(),
token, 30, TimeUnit.MINUTES);
confirmVo.setOrderToken(token);

// 等待异步任务完成
try {
CompletableFuture.allOf(itemAndStockFuture, addressFuture).get();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
return confirmVo;
}

该方法执行完毕后,即可构建出订单对象 OrderConfirmVo

Feign 远程调用丢失请求头问题

在远程调用其他服务时会出现 Feign 远程调用丢失请求头问题:在远程调用购物车服务 cartFeignService 时,会发现 Feign 并没有把当前服务的请求头加到远程请求中。

这是因为 Feign 在创建 cartFeignService 接口的代理对象时创建了一个新的 HttpRequest 对象,并且没有给该对象添加订单服务的请求头,从而在远程调用到购物车服务时,因缺少请求头内的 Cookie 信息导致购物车服务的登陆拦截器判定此请求没有登录(无法获取到登录用户信息),从而无法获取到购物车项信息。

解决该问题的方法:向容器中注入自定义的请求拦截器,在 Feign 发出远程调用前先执行该拦截器内的方法,将原始请求中的 Cookie 放到请求头里。

使用拦截器而非过滤器是因为:拦截器是 Spring 的组件,被 Spring 容器管理,可以实现自动注入等功能。

自定义请求拦截器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Configuration
public class MyFeignConfig {
/**
* 向容器中注入自定义的请求拦截器,在 Feign 发出远程调用前先执行该拦截器内的方法
* 将原始请求中的 Cookie 放到请求头里
*/
@Bean
public RequestInterceptor requestInterceptor() {
return new RequestInterceptor() {
/**
* RequestTemplate 就是使用 Feign 时负责发出远程调用请求的工具类
*/
@Override
public void apply(RequestTemplate template) {
// 1. 使用 RequestContextHolder 拿到原始请求的请求数据
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes != null) {
HttpServletRequest request = requestAttributes.getRequest();
if (request != null) {
// 2. 将原始请求的 Cookie 信息放到 RequestTemplate 请求中
String cookie = request.getHeader("Cookie");
template.header("Cookie", cookie);
}
}
}
};
}
}

其中,RequestContextHolder 中保存的数据也是存在 ThreadLocal 中的,可以在同一个线程内共享 HttpServletRequest 对象。

CartFeignService 是一个接口。在 @Autowire 注入时没有实例化代理对象,只有在调用其方法时才会基于代理模式创建代理对象,并执行所有拦截器 RequestInterceptor中的方法,然后才执行远程调用

这样在远程调用购物车服务时,就会把 Cookie 放到请求头中,从而购物车服务也就有了登录用户信息。

【提交订单】:创建订单

image-20201117112328742

用户在跳转到 confirm.html 页面后,点击【提交订单】发送 /submitOrder 请求,并将上一步生成的订单确认页数据 OrderSubmitVo 从前端传递过来,后端将根据该信息构建出完整的订单信息(包括订单项、会员信息、收货地址、优惠信息、价格等信息),并且完成订单持久化库存锁定以及清空购物车

submitOrder 请求处理器依次完成以下逻辑:

  • 原子验证防重令牌
    • 从前端传递过来的 OrderSubmitVo 信息中获取前端此次请求携带的防重令牌
    • 根据用户 id 到 Redis 中查询该用户对应的防重令牌order:token:userId - uuid
    • 对比二者是否相等。若不相等,则校验不通过,当前提交的订单是重复订单,驳回。若相等,则校验通过,继续下面业务
  • 汇总订单信息 OrderCreateTo:根据前端传来的 OrderSubmitVo 信息创建出订单 OrderEntity 与订单项 OrderItemEntity 实体类对象(这些对象将存储到数据库中)并封装到 OrderCreateTo 对象中
  • 价格校验:验证后台计算出的价格和前台传来的价格是否一致。若不一致则直接返回
  • 订单持久化:保存订单信息到数据库中
  • 库存锁定:远程调用库存服务锁定这些订单项的库存(详细分析见【提交订单】库存锁定
    • 若库存锁定失败,则整个本地事务回滚(将导致前面持久化的订单信息回滚)
    • 若库存锁定成功,则持久化库存数据并发送库存工作单详情消息到库存服务的延迟队列中等待 50 分钟后的库存解锁判断(详细分析见【提交订单】库存解锁
  • 定时关单:库存锁定成功后,将订单实体数据 OrderEntity 发送到延迟队列中,在定时 30 分钟后被消费者监听,判断该订单的状态(详细分析见【提交订单】定时关单):
    • 如果订单状态仍为新创建 CREATE_NEW,代表该订单超过三十分钟未支付,那么就关闭该订单(将订单状态设置为取消 CANCLED)。手动回复 Ack 。同时发送消息通知其他服务,本订单已经关闭了,可以进行其他处理了。例如:1. 库存服务可以解锁库存了。2. 优惠券服务可以返回优惠券了
    • 如果该订单的状态不是新建,代表该订单已经支付成功,不再进行关单
    • 如果该过程出现异常,则手动回复 Reject,令该消息重新入队,等待其他消费者再次消费,避免消息丢失
  • 清空购物车:库存锁定成功后清空购物车信息

其中,原子验证防重令牌的原因见幂等性问题

流程图:

image-20220117190942112

image-20220117191148809

image-20220122135431543

业务主线代码

Controller 层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@RequestMapping("/submitOrder")
public String submitOrder(OrderSubmitVo submitVo, Model model, RedirectAttributes attributes) {
try {
// 提交订单。根据返回的 code 值判断是否成功以及失败原因
SubmitOrderResponseVo responseVo = orderService.submitOrder(submitVo);
Integer code = responseVo.getCode();
if (code == 0) {
model.addAttribute("order", responseVo.getOrder());
return "pay";
} else {
// 下单失败跳转回订单页
String msg = "下单失败;";
switch (code) {
case 1:
msg += "防重令牌校验失败";
break;
case 2:
msg += "商品价格发生变化";
break;
}
attributes.addFlashAttribute("msg", msg);
return "redirect:http://order.yunmall.com/toTrade";
}
} catch (Exception e) {
System.out.println(e);
if (e instanceof NoStockException) {
String msg = "下单失败,商品无库存";
attributes.addFlashAttribute("msg", msg);
}
return "redirect:http://order.yunmall.com/toTrade";
}
}

Service 层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
/**
* 提交订单。
* 分别进行防重令牌校验、订单信息汇总、价格校验、订单持久化、库存锁定以及清空购物车
* @param submitVo 前端传来的 OrderSubmitVo 对象
* @return
*/
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo submitVo) {
SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();
responseVo.setCode(0);
// 先从 ThreadLocal 中获取当前登录用户的信息
MemberResponseVo memberResponseVo = LoginInterceptor.loginUserThreadLocal.get();
// 获取前端传来的校验令牌
String orderToken = submitVo.getOrderToken();
// 1. 原子验证防重令牌并删除令牌
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()),
orderToken);
// 返回 0:令牌验证失败
// 返回 1:令牌验证成功(成功删除才返回1,否则都返回0)
if (result == 0L) {
// 验证失败
responseVo.setCode(1);
return responseVo;
}

// 2. 创建出订单 OrderEntity 与订单项 OrderItemEntity 并封装到 OrderCreateTo 对象中
OrderCreateTo order = createOrder(memberResponseVo, submitVo);

// 3. 价格校验:验证后台计算出的价格和前台传来的价格是否一致
BigDecimal payAmount = order.getOrder().getPayAmount();
BigDecimal payPrice = submitVo.getPayPrice();
// 如果后台计算出的价格和前台传来的价格一致(相差小于0.01),则验价成功
if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
// 4. 订单持久化:保存订单到数据库中
saveOrder(order);

// 5. 库存锁定:远程调用库存服务锁定这些订单项的库存
// 先提取出需要锁定的订单项
List<OrderItemVo> orderItemVos = order.getOrderItems().stream().map((item) -> {
OrderItemVo orderItemVo = new OrderItemVo();
orderItemVo.setSkuId(item.getSkuId());
orderItemVo.setCount(item.getSkuQuantity());
return orderItemVo;
}).collect(Collectors.toList());
// 封装出 WareSkuLockVo 对象:设置订单号和订单项 OrderItemVo
WareSkuLockVo lockVo = new WareSkuLockVo();
lockVo.setOrderSn(order.getOrder().getOrderSn());
lockVo.setLocks(orderItemVos);
// 远程调用库存服务锁定这些订单项的库存
R r = wareFeignService.orderLockStock(lockVo);
if (r.getCode() == 0) {
// 5.1 锁定库存成功
responseVo.setOrder(order.getOrder());
responseVo.setCode(0);

// 6. 定时关单:库存锁定成功后,发送 OrderEntity 数据到订单延迟队列
// 待 30 分钟后消息过期即会被路由到死信队列,由监听的 OrderCloseListener 负责判断是否需要关单
rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", order.getOrder());

// 7. 清除 Redis 缓存中的购物车记录
BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(CartConstant.CART_PREFIX + memberResponseVo.getId());
for (OrderItemEntity orderItem : order.getOrderItems()) {
ops.delete(orderItem.getSkuId().toString());
}
return responseVo;
} else {
// 5.2 锁定库存失败,抛异常。本地事务回滚,上面持久化的订单回滚。
String msg = (String) r.get("msg");
throw new NoStockException(msg);
}
} else {
// 如果后台计算出的价格和前台传来的价格不一致(相差0.01),设置错误状态码为2
responseVo.setCode(2);
}

return responseVo;
}

/**
* 计算订单总价
*/
private void compute(OrderEntity entity, List<OrderItemEntity> orderItemEntities) {
// 订单总价
BigDecimal total = BigDecimal.ZERO;
// 订单优惠价格
BigDecimal promotion = new BigDecimal("0.0");
BigDecimal integration = new BigDecimal("0.0");
BigDecimal coupon = new BigDecimal("0.0");
// 积分
Integer integrationTotal = 0;
Integer growthTotal = 0;

// 设置每一个订单项的各种价格信息
for (OrderItemEntity orderItemEntity : orderItemEntities) {
total = total.add(orderItemEntity.getRealAmount());
promotion = promotion.add(orderItemEntity.getPromotionAmount());
integration = integration.add(orderItemEntity.getIntegrationAmount());
coupon = coupon.add(orderItemEntity.getCouponAmount());
integrationTotal += orderItemEntity.getGiftIntegration();
growthTotal += orderItemEntity.getGiftGrowth();
}

entity.setTotalAmount(total);
entity.setPromotionAmount(promotion);
entity.setIntegrationAmount(integration);
entity.setCouponAmount(coupon);
entity.setIntegration(integrationTotal);
entity.setGrowth(growthTotal);

// 付款价格=商品价格+运费
entity.setPayAmount(entity.getFreightAmount().add(total));

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

/**
* 4. 订单持久化:保存订单
*/
private void saveOrder(OrderCreateTo orderCreateTo) {
OrderEntity order = orderCreateTo.getOrder();
order.setCreateTime(new Date());
order.setModifyTime(new Date());
this.save(order);
orderItemService.saveBatch(orderCreateTo.getOrderItems());
}

汇总订单信息 OrderCreateTo

其中,汇总订单信息 OrderCreateTo 的逻辑:

  • 使用 IdWorker.getTimeId() 生成订单号 orderSn(该字段在数据库中添加了唯一索引 UNIQUE保证了数据库层面的幂等性
  • buildOrder():构建订单实体类 OrderEntity(包含订单号、用户信息、收货地址信息以及订单状态等信息)
  • buildOrderItems():构建订单项实体类 List<OrderItemEntity>(包含订单号、SKU信息、SKU销售属性、SPU信息、积分信息、价格信息等信息)
  • 计算价格
  • 将订单 OrderEntity 和订单项信息 List<OrderItemEntity> 封装到 OrderCreateTo 对象中

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
/**
* 2. 汇总订单信息 OrderCreateTo:构建出订单 OrderEntity 与 订单项 OrderItemEntity
* 将二者封装到 OrderCreateTo 对象中
* @param memberResponseVo
* @param submitVo
* @return
*/
private OrderCreateTo createOrder(MemberResponseVo memberResponseVo, OrderSubmitVo submitVo) {
// 1. 用 IdWorker 生成订单号
String orderSn = IdWorker.getTimeId();
// 2. 构建订单 OrderEntity:
OrderEntity entity = buildOrder(memberResponseVo, submitVo, orderSn);
// 3. 构建订单项 OrderItemEntity:远程调用购物车服务获取所有勾选上的购物项,并为其设置订单号
List<OrderItemEntity> orderItemEntities = buildOrderItems(orderSn);
// 4. 计算价格
compute(entity, orderItemEntities);
// 5. 将订单和订单项信息封装到 OrderCreateTo 对象中
OrderCreateTo createTo = new OrderCreateTo();
createTo.setOrder(entity);
createTo.setOrderItems(orderItemEntities);
return createTo;
}

/**
* 构建订单信息 OrderEntity
* @param memberResponseVo
* @param submitVo
* @param orderSn
* @return
*/
private OrderEntity buildOrder(MemberResponseVo memberResponseVo, OrderSubmitVo submitVo, String orderSn) {
OrderEntity orderEntity = new OrderEntity();
// 1. 设置订单号
orderEntity.setOrderSn(orderSn);

// 2. 设置用户信息
orderEntity.setMemberId(memberResponseVo.getId());
orderEntity.setMemberUsername(memberResponseVo.getUsername());

// 3. 远程调用库存服务,获取邮费和收货地址信息并设置到订单中
FareVo fareVo = wareFeignService.getFare(submitVo.getAddrId());
BigDecimal fare = fareVo.getFare();
orderEntity.setFreightAmount(fare);
MemberAddressVo address = fareVo.getAddress();
orderEntity.setReceiverName(address.getName());
orderEntity.setReceiverPhone(address.getPhone());
orderEntity.setReceiverPostCode(address.getPostCode());
orderEntity.setReceiverProvince(address.getProvince());
orderEntity.setReceiverCity(address.getCity());
orderEntity.setReceiverRegion(address.getRegion());
orderEntity.setReceiverDetailAddress(address.getDetailAddress());

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

return orderEntity;
}

/**
* 构建所有订单项数据
* @param orderSn
* @return
*/
private List<OrderItemEntity> buildOrderItems(String orderSn) {
// 远程调用购物车服务查询当前勾选的所有购物项信息,将其转换成订单项OrderItemVo
List<OrderItemVo> checkedItems = cartFeignService.getCheckedItems();
// 根据每一个OrderItemVo构建出对应的OrderItemEntity(数据库中订单项表对应的实体类)
List<OrderItemEntity> orderItemEntities = checkedItems.stream().map((item) -> {
// 根据该购物项信息构建出对应的订单项
OrderItemEntity orderItemEntity = buildOrderItem(item);
// 设置订单号
orderItemEntity.setOrderSn(orderSn);
return orderItemEntity;
}).collect(Collectors.toList());
return orderItemEntities;
}

/**
* 根据购物车服务传来的 OrderItemVo 对象构建出对应的 OrderItemEntity 对象
* @param item
* @return
*/
private OrderItemEntity buildOrderItem(OrderItemVo item) {
OrderItemEntity orderItemEntity = new OrderItemEntity();
Long skuId = item.getSkuId();
// 1. 设置当前订单项的sku相关属性。该信息在购物项里保存,由远程调用购物车服务得到
orderItemEntity.setSkuId(skuId);
orderItemEntity.setSkuName(item.getTitle());
orderItemEntity.setSkuAttrsVals(StringUtils.collectionToDelimitedString(item.getSkuAttrValues(), ";"));
orderItemEntity.setSkuPic(item.getImage());
orderItemEntity.setSkuPrice(item.getPrice());
orderItemEntity.setSkuQuantity(item.getCount());

// 2. 远程调用商品服务,通过skuId查询spu相关属性并设置到订单项中
R r = productFeignService.getSpuBySkuId(skuId);
if (r.getCode() == 0) {
// 解析R对象中的“spuInfo”转换成SpuInfoTo对象
SpuInfoTo spuInfo = JSON.parseObject(JSON.toJSONString(r.get("spuInfo")), SpuInfoTo.class);
// 设置spu信息到订单项中
orderItemEntity.setSpuId(spuInfo.getId());
orderItemEntity.setSpuName(spuInfo.getSpuName());
orderItemEntity.setSpuBrand(spuInfo.getBrandName());
orderItemEntity.setCategoryId(spuInfo.getCatalogId());
}
// 3. 商品的优惠信息(暂时不做)

// 4. 商品的积分成长,为价格x数量
orderItemEntity.setGiftGrowth(item.getPrice().multiply(new BigDecimal(item.getCount())).intValue());
orderItemEntity.setGiftIntegration(item.getPrice().multiply(new BigDecimal(item.getCount())).intValue());

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

// 6. 实际价格
BigDecimal origin = orderItemEntity.getSkuPrice().multiply(new BigDecimal(orderItemEntity.getSkuQuantity()));
BigDecimal realPrice = origin.subtract(orderItemEntity.getPromotionAmount())
.subtract(orderItemEntity.getCouponAmount())
.subtract(orderItemEntity.getIntegrationAmount());
orderItemEntity.setRealAmount(realPrice);

return orderItemEntity;
}

【提交订单】:库存锁定

在订单持久化后,订单服务将远程调用库存服务锁定这些订单项的库存,其将传入一个 WareSkuLockVo 对象,封装了订单号 order_sn订单项 OrderItemVo 信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 5. 库存锁定:远程调用库存服务锁定这些订单项的库存
// 先提取出需要锁定的订单项
List<OrderItemVo> orderItemVos = order.getOrderItems().stream().map((item) -> {
OrderItemVo orderItemVo = new OrderItemVo();
orderItemVo.setSkuId(item.getSkuId());
orderItemVo.setCount(item.getSkuQuantity());
return orderItemVo;
}).collect(Collectors.toList());
// 封装出 WareSkuLockVo 对象:设置订单号和订单项 OrderItemVo
WareSkuLockVo lockVo = new WareSkuLockVo();
lockVo.setOrderSn(order.getOrder().getOrderSn());
lockVo.setLocks(orderItemVos);
// 远程调用库存服务锁定这些订单项的库存
R r = wareFeignService.orderLockStock(lockVo);

库存锁定逻辑

  • 首先库存服务收到订单服务的远程调用后,将先创建一个 WareOrderTaskEntity 对象,其代表 "订单 & 库存 - 工作单" 信息,其主要保存每个订单的订单号 order_sn 信息,方便库存解锁时回溯找到问题订单(因为问题订单可能已经回滚了,如果不保存工作单就找不到需要解锁的订单了)
  • 接着遍历订单服务传来的所有订单项数据,为其创建 “SKU - 仓库号” 信息 SkuLockVo,其包含当前 SKU 在哪些仓库里有库存,并且库存数量(num)是多少
  • 遍历每一个订单项,为其锁定库存:从查询出的还有库存的仓库中找出任意一个仓库,在该仓库中锁定当前商品:
    • 如果每一个商品都能锁定成功,则所有商品的 "商品 & 库存 - 工作单详情" 信息将发送到库存服务的延迟队列 stock.delay.queue 中,等待 50 分钟后过期。将由 StockReleaseListener 监听后解锁(库存解锁的详细分析见【提交订单】库存解锁
    • 如果某一个商品锁定失败,就抛出异常,前面保存的工作单信息都将回滚。这样前面放到延迟队列里的工作单详情数据即使在过期后需要解锁,但由于在数据库中已经查不到该工作单详情信息(因为回滚了),所以也不会解锁这些库存
  • 在数据库中锁定库存:在 wms_ware_sku 表中给 skuId 商品的 stock_locked 字段增加数量 num,代表锁定该商品的 num 个库存
  • 数据库锁定成功后,创建 "商品 & 库存 - 工作单详情" 对象 WareOrderTaskDetailEntity,该对象中保存了订单项的 skuId、下单数量 num、“订单 & 库存 - 工作单” id、仓库 id 以及锁定状态(已锁定)。将其持久化到数据库表 wms_ware_order_task_detail 中。在之后解锁库存时,将去数据库中查找该工作单详情数据,并据此来进行库存解锁
  • 将封装后的 “商品 & 库存 - 工作单详情” 数据发送到 stock.release.stock.queue 库存延迟队列中。该数据将在 50 分钟后过期,被 StockReleaseListener 监听后进行库存解锁

以上只要有一个订单项的库存锁定失败,就会抛出异常,令本地事务整体回滚。数据库中不会存在这些库存工作单以及库存工作详情单,库存解锁时也就不会解锁这些库存了。

以上过程的流程图:

image-20220120142549935

其中,锁定库存成功后,需要将库存工作单和工作单详情数据发送到库存服务的延迟队列 stock.delay.queue 中,并设置过期时间为 50 分钟。当消息过期之后,将该消息路由到解锁队列中,等待进行库存解锁的业务【提交订单】库存解锁

image-20220121164035585

库存服务数据模型

  1. "订单 & 库存 - 工作单":其主要保存每个订单的订单号 order_sn 信息,方便库存解锁时回溯找到问题订单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Data
@TableName("wms_ware_order_task")
public class WareOrderTaskEntity implements Serializable {
private static final long serialVersionUID = 1L;
/**
* id
*/
@TableId
private Long id;
/**
* order_id
*/
private Long orderId;
/**
* order_sn
*/
private String orderSn;
/**
* 收货人
*/
private String consignee;
/**
* 收货人电话
*/
private String consigneeTel;
/**
* 配送地址
*/
private String deliveryAddress;
/**
* 订单备注
*/
private String orderComment;
/**
* 付款方式【 1:在线付款 2:货到付款】
*/
private Integer paymentWay;
/**
* 任务状态
*/
private Integer taskStatus;
/**
* 订单描述
*/
private String orderBody;
/**
* 物流单号
*/
private String trackingNo;
/**
* create_time
*/
private Date createTime;
/**
* 仓库id
*/
private Long wareId;
/**
* 工作单备注
*/
private String taskComment;
}
  1. "商品 & 库存 - 工作单详情":该对象中保存了订单项的 skuId、下单数量 num“订单 & 库存 - 工作单” id、仓库 id 以及锁定状态(已锁定)。工作单详情数据将保存到数据库中,也会被发送到库存延迟队列中。未来在进行库存解锁时将去数据库查看该工作单详情数据,并据此来判断是否需要解锁库存。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Builder
@Data
@TableName("wms_ware_order_task_detail")
public class WareOrderTaskDetailEntity implements Serializable {
private static final long serialVersionUID = 1L;
/**
* id
*/
@TableId
private Long id;
/**
* sku_id
*/
private Long skuId;
/**
* sku_name
*/
private String skuName;
/**
* 购买个数
*/
private Integer skuNum;
/**
* 工作单id
*/
private Long taskId;
/**
* 仓库id
*/
private Long wareId;
/**
* 锁定状态:1-锁定 2-解锁 3-扣减
*/
private Integer lockStatus;
}
  1. SkuLockVo:封装信息,包含当前 SKU 在哪些仓库里有库存,并且库存数量(num)是多少
1
2
3
4
5
6
7
8
9
/**
* 内部类:当前SKU在哪些仓库(wareIds)里有库存,并且库存数量是多少(num)
*/
@Data
class SkuLockVo {
private Long skuId;
private Integer num;
private List<Long> wareIds;
}

库存锁定业务代码

  1. Controller 层:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 下订单时锁库存
* @param lockVo
* @return
*/
@RequestMapping("/lock/order")
public R orderLockStock(@RequestBody WareSkuLockVo lockVo) {
try {
Boolean lock = wareSkuService.orderLockStock(lockVo);
return R.ok();
} catch (NoStockException e) {
return R.error(BizCodeEnum.NO_STOCK_EXCEPTION.getCode(), BizCodeEnum.NO_STOCK_EXCEPTION.getMsg());
}
}
  1. Service 层:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@Transactional
@Override
public Boolean orderLockStock(WareSkuLockVo wareSkuLockVo) {
// 0. 持久化"订单&库存-工作单":主要保存每个订单的订单号 order_sn 信息,方便库存解锁时回溯找到问题订单
// 其存在的意义:备份订单库存信息。可能出现订单回滚后,库存已经锁定,不会回滚的情况:
// 此时订单已经回滚了,无法从订单表中获取到需要解锁的订单信息了,
// 那么就找不到需要解锁的库存信息了,因此需要保存库存工作单,方便解锁库存时能定位到需要解锁的库存
WareOrderTaskEntity taskEntity = new WareOrderTaskEntity();
taskEntity.setOrderSn(wareSkuLockVo.getOrderSn());
taskEntity.setCreateTime(new Date());
// 持久化到数据库中。以便需要解锁库存时可以追溯到需要解锁的订单号以及其库存信息
wareOrderTaskService.save(taskEntity);

// 获取订单服务远程传来的订单项 Vo
List<OrderItemVo> itemVos = wareSkuLockVo.getLocks();
// 1. 遍历所有订单项,为其创建"SKU-仓库号"信息 SkuLockVo:
// 包含当前SKU在哪些仓库(wareIds)里有库存,并且库存数量是多少(num)
List<SkuLockVo> lockVos = itemVos.stream().map((item) -> {
SkuLockVo skuLockVo = new SkuLockVo();
skuLockVo.setSkuId(item.getSkuId());
skuLockVo.setNum(item.getCount());
// 找出所有库存大于该订单中商品数 num 的仓库,这些仓库中有任何一个可以锁定成功,该订单的库存就能锁定成功
List<Long> wareIds = baseMapper.listWareIdsHasStock(item.getSkuId(), item.getCount());
// 保存候选库存 id
skuLockVo.setWareIds(wareIds);
return skuLockVo;
}).collect(Collectors.toList());

// 2. 遍历每一个订单项,为其锁定库存:从查询出的还有库存的仓库中找出任意一个仓库,在该仓库中锁定当前商品
for (SkuLockVo lockVo : lockVos) {
boolean lock = true;
Long skuId = lockVo.getSkuId();
List<Long> wareIds = lockVo.getWareIds();
// 如果没有满足条件的仓库,抛出异常,其他持久化的库存信息全部回滚
if (wareIds == null || wareIds.size() == 0) {
throw new NoStockException(skuId);
} else {
// 3. 遍历每一个仓库,看看哪个能锁库存成功
// 3.1 如果每一个商品都能锁定成功,则所有商品的"商品&库存-工作单详情"信息将发送给库存服务的延迟队列里
// 3.2 如果某一个商品锁定失败。前面保存的工作单信息就回滚了(因为下面手动抛出异常了)
// 这样前面放到延迟队列里的消息即使在之后需要解锁记录,但由于在数据库中查不到 id(因为回滚了),所以也不会重复解锁
for (Long wareId : wareIds) {
// 4. 在数据库中锁定库存:在 wms_ware_sku 表中给 skuId 商品的 stock_locked 字段增加数量 num,代表锁定该商品的num个库存
Long count = baseMapper.lockWareSku(skuId, lockVo.getNum(), wareId);
if (count == 0) {
// 4.1 如果锁定失败(可能库存不足)
lock = false;
} else {
// 4.2 如果锁定成功,保存"商品&库存-工作单详情"信息 WareOrderTaskDetailEntity
// 该信息中保存了下单项的 skuId、下单数量、"订单&库存-工作单"id、仓库 id 以及锁定状态(已锁定)
// 未来解锁库存时,将去数据库中查找该工作单详情数据,并据此来进行库存解锁
WareOrderTaskDetailEntity detailEntity = WareOrderTaskDetailEntity.builder()
.skuId(skuId)
.skuName("")
.skuNum(lockVo.getNum())
.taskId(taskEntity.getId())
.wareId(wareId)
.lockStatus(1).build();
// 4.3 将"商品&库存-工作单详情" WareOrderTaskDetailEntity 持久化到数据库中
wareOrderTaskDetailService.save(detailEntity);

// 封装 StockLockedTo 对象,其将被保存到消息队列中。用于解锁库存时去数据库中定位到库存信息
StockLockedTo lockedTo = new StockLockedTo();
StockDetailTo detailTo = new StockDetailTo();
BeanUtils.copyProperties(detailEntity, detailTo);
// 设置工作单 id 以及"商品&库存-工作单详情"信息
lockedTo.setId(taskEntity.getId());
lockedTo.setDetailTo(detailTo);

// 4.4 发送封装后的"商品&库存-工作单详情"数据到库存延迟队列 stock.release.stock.queue 中,
// 将在 50 分钟后过期,被 StockReleaseListener 监听后进行库存解锁
rabbitTemplate.convertAndSend("stock-event-exchange", "stock.locked", lockedTo);

lock = true;
// 当前订单项锁库存成功,不再尝试其他的仓库了,break
break;
}
}
}
// 如果有某一个订单在所有仓库都没能成功锁定库存(都没库存了),则抛出异常
// 令所有持久化到数据里的库存工作单数据都回滚
if (!lock) throw new NoStockException(skuId);
}
// 能走到这里,所有都锁定成功了
return true;
}

库存锁定后,需要

  1. 锁库存的 MyBatis 配置文件:
1
2
3
4
5
6
7
<update id="lockWareSku">
UPDATE wms_ware_sku
SET stock_locked = stock_locked + #{num}
WHERE sku_id = #{skuId}
AND ware_id = #{wareId}
AND stock - stock_locked > #{num}
</update>

为防止高并发下出现超卖问题,即多个请求同时锁某一个库存导致库存数量超过限制,可以使用乐观锁

例如锁库存前先查询一下目前的库存数量,然后再 UPDATE 时多加一个条件 WHERE stock - stock_locked = 查询时的数量。这样就能保证高并发下不会出现锁库存超出限制的情况。这样会导致如果当前不匹配,则直接后面没法减库存了,需要重复多次尝试减库存,直至成功为止。

当然也可以直接添加写锁保证原子性:UPDATE xxxxx FOR UPDATE

【提交订单】:定时关单

如果库存锁定失败,前面持久化的订单数据就会回滚,也就不需要再关单了。所以只在库存锁定成功后才考虑定时关单问题。

定时关单:库存锁定成功后,将订单实体数据 OrderEntity 发送到延迟队列中,在定时 30 分钟后被消费者监听,判断该订单的状态:

  • 如果订单状态仍为新创建 CREATE_NEW,代表该订单超过三十分钟未支付,那么就关闭该订单(将订单状态设置为取消 CANCLED)。手动回复 Ack 。同时发送消息通知其他服务,本订单已经关闭了,可以进行其他处理了。例如:
    • 库存服务可以解锁库存了(双重保险。确保库存一定能解锁)
    • 优惠券服务可以返回优惠券
  • 如果该订单的状态不是新建,代表该订单已经支付成功,不再进行关单
  • 如果该过程出现异常,则手动回复 Reject,令该消息重新入队,等待其他消费者再次消费,避免消息丢失

其中,在关单后还要发送解锁库存消息的原因是:作为一种双重保险,确保库存一定能解锁。因为可能出现:

  • 库存服务在锁定库存后,还未来得及发送解锁消息到延迟队列就宕机。这时可能导致该库存一直无法解锁
  • 订单服务因网络延迟导致关单的响应晚于库存服务的解锁响应,这样该库存在关单后就不会再解锁了

为防止这两种情况的发生,必须在关单后立即发送一次消息通知库存服务解锁库存,这样就能保证库存一定能解锁。

消息路由过程

  • 首先,订单服务拥有一个总交换机 order-event-exchange,订单服务的所有消息都要经过该交换机。
  • 订单创建成功且库存锁定成功后, 发送订单数据 OrderEntity 到总交换机处,并且指定路由到延迟队列 order.delay.queue 中(路由键为 order.create.order)。该队列中所有消息都将阻塞 30 分钟
  • 消息过期后,将重新回到总交换机,然后指定路由到关单队列 order.release.order.queue 中(路由键为 order.release.order
  • 设置消费者 OrderCloseListener 监听该队列,判断监听到的订单数据的状态,并决定是否需要关闭该订单
  • 消费者需要手动回复消息是否确认收到,保证消息不丢失
  • 在手动回复 Ack 后,订单服务会发送消息通知其他服务,本订单已经关闭了,可以进行其他处理了(路由键为 order.release.other.#)。例如:
    • 发送消息到解锁库存队列 stock.release.stock.queue 通知库存服务可以解锁库存了(双重保险。确保库存一定能解锁,防止因为订单服务的网络延迟导致晚于库存服务关单,这时再发一次消息解锁库存就能保证库存一定能解锁)
    • 发送消息到返券队列 order.release.coupon.queue 通知优惠券服务可以返回优惠券

该过程的流程图:

消息队列流程

其中,订单服务的消息队列配置示意图:

image-20220120101110172

整个订单服务只设置了一个总交换机,延迟队列和死信队列都与其绑定,通过不同的路由键实现路由到不同的队列中。

创建交换机和队列

首先创建配置类@Configuration,为订单服务创建总交换机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 订单服务总交换机 order-event-exchange
* @return
*/
@Bean
public Exchange orderEventExchange() {
/**
* String name,
* boolean durable,
* boolean autoDelete,
* Map<String, Object> arguments
*/
return new TopicExchange("order-event-exchange", true, false);
}

然后创建延迟队列 order.delay.queue关单队列 order.release.order.queue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 订单服务延迟队列 order.delay.queue。
* 每个订单创建成功后,都需要向延迟队列发送消息,等待30分钟后判断是否需要取消订单
* 没有消费者监听该队列
* @return
*/
@Bean
public Queue orderDelayQueue() {
/**
Queue(String name, 队列名字
boolean durable, 是否持久化
boolean exclusive, 是否排他
boolean autoDelete, 是否自动删除
Map<String, Object> arguments) 属性
*/
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");
// 消息过期时间 1分钟
arguments.put("x-message-ttl", 60000);
return new Queue("order.delay.queue", true, false, false, arguments);
}

/**
* 普通队列(死信队列)order.release.order.queue 负责存放30分钟后过期的消息
* 该队列被消费者 OrderCloseListener 监听,在每个消息过期后检查该订单的状态,判断是否需要关单
* @return
*/
@Bean
public Queue orderReleaseQueue() {
return new Queue("order.release.order.queue", true, false, false);
}

绑定总交换机和两个消息队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 绑定延迟队列 order.delay.queue 和总交换机 order-event-exchange
* 路由键为 order.create.order
* @return
*/
@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);
}

/**
* 绑定死信队列 order.release.order.queue 和总交换机o rder-event-exchange
* 路由键为 order.release.order
* @return
*/
@Bean
public Binding orderReleaseBinding() {
return new Binding("order.release.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.order",
null);
}

绑定库存服务的库存解锁队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 绑定库存服务的库存解锁队列 stock.release.stock.queue 和订单服务总交换机 rder-event-exchange
* 路由键为 order.release.other.#
* 在每个订单关闭完毕后会发出消息到库存解锁队列,等待库存服务将该订单解锁(避免订单服务阻塞导致删单比库存服务晚执行)
* 用于确保一定会能在订单关闭后将其对应的库存也解锁
* @return
*/
@Bean
public Binding orderReleaseOrderBinding() {
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.other.#",
null);
}

之所以关单后还要再发消息解锁库存是为了做双重保险,确保库存一定能解锁,防止因为订单服务的网络延迟导致晚于库存服务关单,这时再发一次消息解锁库存就能保证库存一定能解锁)

消息生产者

在主线中的第 6 步发送消息:在库存锁定成功后,发送 OrderEntity 数据到订单延迟队列

1
2
3
// 6. 定时关单:库存锁定成功后,发送 OrderEntity 数据到订单延迟队列
// 待 30 分钟后消息过期即会被路由到死信队列,由监听的 OrderCloseListener 负责判断是否需要关单
rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", order.getOrder());

消息消费者

listener 包下创建消息消费者,负责监听死信队列(关单队列)order.release.order.queue 中的消息,将过期的订单关闭:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 监听死信队列(关单队列) order.release.order.queue,将过期的订单关闭
*/
@Component
@RabbitListener(queues = {"order.release.order.queue"})
public class OrderCloseListener {

@Autowired
private OrderService orderService;

/**
* 从队列中拿到订单实体对象 OrderEntity,调用 OrderService 关闭该订单
* 1. 如果关闭订单成功,就手动回复成功 Ack,从队列中删除该消息;
* 2. 如果关闭订单失败,就回复失败 Reject,并且重新入队:requeue=true,等待其他消费者重新消费该消息
* @param orderEntity
* @param message
* @param channel
* @throws IOException
*/
@RabbitHandler
public void listener(OrderEntity orderEntity, Message message, Channel channel) throws IOException {
System.out.println("收到过期的订单信息,准备关闭订单" + orderEntity.getOrderSn());
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
orderService.closeOrder(orderEntity);
channel.basicAck(deliveryTag,false);
} catch (Exception e){
channel.basicReject(deliveryTag, true);
}
}
}

需要在配置文件中事先开启手动确认机制

closeOrder() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 关闭过期的的订单
* @param orderEntity
*/
@Override
public void closeOrder(OrderEntity orderEntity) {
// 因为消息发送过来的订单已经是很久前的了,中间可能被改动,因此要查询最新的订单
OrderEntity newOrderEntity = this.getById(orderEntity.getId());
// 如果订单还处于新创建的状态,说明超时未支付,进行关单
if (newOrderEntity.getStatus().equals(OrderStatusEnum.CREATE_NEW.getCode())) {
OrderEntity updateOrder = new OrderEntity();
updateOrder.setId(newOrderEntity.getId());
updateOrder.setStatus(OrderStatusEnum.CANCLED.getCode());
// 更新指定订单的状态为取消状态 CANCLED
this.updateById(updateOrder);

// 关单后立即发送消息通知其他服务,本订单已经关闭了,可以进行其他处理了。
// 例如:1. 库存服务可以解锁库存。2. 优惠券服务可以返回优惠券了
OrderTo orderTo = new OrderTo();
BeanUtils.copyProperties(newOrderEntity, orderTo);
// 发送到订单服务总交换机处,路由到其他服务order.release.other进行关单后的处理
rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTo);
}
}

【提交订单】:库存解锁

需要解锁库存的场景:

  • 【提交订单】业务全部执行成功,但成功创建订单过期没有支付,被【提交订单】定时关单自动取消;或者用户手动选择取消订单。此时需要将之前锁定的库存解锁
  • 创建订单成功,库存锁定成功,但在接下来的其他代码执行失败(【提交订单】业务没有全部执行成功),导致前面创建的订单回滚,在数据库中不存在该订单。此时需要将之前锁定的库存解锁

因此,只要库存服务锁定库存成功,就需要立即发送一条消息到库存服务的延迟队列中,在 50 分钟后被消费者监听到消息后,进行库存解锁判断

  • 如果订单表里没有这个订单(说明其他代码抛异常导致订单回滚了),则必须解锁该订单的库存
  • 如果订单表里有这个订单,则不一定需要解锁库存:
    • 如果订单状态为已取消 CANCLED,则该订单要么过期要么被手动取消,需要解锁库存
    • 如果订单状态为已支付 PAYED,则该订单不需要解锁库存

那么如何得知应该判断哪个订单的状态呢?如果订单数据在之前已经回滚了,那么在订单表中就找不到问题订单的信息了。因此我们需要在库存锁定后为该订单和库存信息创建 "订单 & 库存 - 工作单" 对象 WareOrderTaskEntity,其主要保存每个订单的订单号 order_sn 信息,方便库存解锁时回溯找到问题订单(因为问题订单可能已经回滚了,如果不保存工作单就找不到需要解锁的订单了)。根据监听到的订单消息去数据库中重新查找该订单的状态标志位的值,就可判断该订单是否需要解锁库存。

这样在库存锁定成功,就把该信息发送到延迟队列 stock.delay.queue 中,在 50 分钟后该消息过期,就会路由到 stock.release.stock.queue 队列,由库存服务的消费者 OrderCloseListener 进行解锁。

为防止库存被重复解锁,保证解锁库存的幂等性。在解锁前需要首先判断"商品 & 库存 - 工作单"中当前任务单的状态是否为已锁定 WareTaskStatusEnum.Locked,如果不是,说明该工作单已经被解锁了,从而无需重复解锁。

库存解锁的消息队列示意图:

image-20220121232915466

完整示意图:

消息队列流程

消息生产者

库存解锁的消息生产者为【提交订单】库存锁定

1
2
3
// 4.4 发送封装后的"商品&库存-工作单详情"数据到库存延迟队列 stock.release.stock.queue 中,
// 将在 50 分钟后过期,被 StockReleaseListener 监听后进行库存解锁
rabbitTemplate.convertAndSend("stock-event-exchange", "stock.locked", lockedTo);

消息消费者

  1. 库存服务的消费负责监听两个队列,分别为库存服务之前发送的解锁库存队列以及订单服务发来的立即解锁库存队列:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Slf4j
@Component
@RabbitListener(queues = {"stock.release.stock.queue"})
public class StockReleaseListener {

@Autowired
private WareSkuService wareSkuService;

/**
* 库存服务在库存锁定后立即发送库存工作详情单到延迟队列中
* 50 分钟后被该消费者监听到,进行库存解锁
* @param stockLockedTo
* @param message
* @param channel
* @throws IOException
*/
@RabbitHandler
public void handleStockLockedRelease(StockLockedTo stockLockedTo, Message message, Channel channel) throws IOException {
log.info("************************ 收到库存解锁的消息 ********************************");
try {
wareSkuService.unlock(stockLockedTo);
// 手动回复成功
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
// 失败则手动回复拒绝,并要求重新入队
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}

/**
* 订单服务中关单成功后立即发送消息到库存服务,进行解锁库存(双重保险,保证库存一定解锁)
* @param orderTo
* @param message
* @param channel
* @throws IOException
*/
@RabbitHandler
public void handleStockLockedRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {
log.info("************************ 从订单服务收到库存解锁的消息 ********************************");
try {
// 手动回复成功
wareSkuService.unlock(orderTo);
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
// 失败则手动回复拒绝,并要求重新入队
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
  1. Service 层:根据监听到的订单消息去数据库中重新查找订单的状态标志位的值,就可判断该订单是否需要解锁库存(注意一定要重新查找当前该订单的状态信息,而不能直接用消息传来的对象内的旧值):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/**
* 对库存服务延迟队列中过期的消息进行解锁库存
* 1、没有这个订单,必须解锁库存
* 2、有这个订单,不一定解锁库存
* 订单状态:已取消:解锁库存
* 已支付:不能解锁库存
* @param stockLockedTo
*/
@Override
public void unlock(StockLockedTo stockLockedTo) {
// 1. 获取库存工作详情单
StockDetailTo detailTo = stockLockedTo.getDetailTo();
WareOrderTaskDetailEntity detailEntity = wareOrderTaskDetailService.getById(detailTo.getId());
// 如果工作单详情不为空,说明该库存锁定成功
if (detailEntity != null) {
WareOrderTaskEntity taskEntity = wareOrderTaskService.getById(stockLockedTo.getId());
// 2. 远程调用订单服务查询订单信息
R r = orderFeignService.infoByOrderSn(taskEntity.getOrderSn());
if (r.getCode() == 0) {
OrderTo order = JSON.parseObject(JSON.toJSONString(r.get("orderEntity")), OrderTo.class);
// 3. 如果没有这个订单 或 订单状态为已取消,则解锁库存
// 如果该订单存在且状态为新建,或已支付,则不需要解锁库存
if (order == null || order.getStatus().equals(OrderStatusEnum.CANCLED.getCode())) {
// 为保证幂等性,只有当工作单详情处于被锁定的情况下才进行解锁
if (detailEntity.getLockStatus().equals(WareTaskStatusEnum.Locked.getCode())) {
// 真正执行解锁库存的业务代码
unlockStock(detailTo.getSkuId(), detailTo.getSkuNum(), detailTo.getWareId(), detailEntity.getId());
}
}
} else {
// 如果远程调用失败则抛出异常,消费者捕获异常后将消息重新入队等待其他消费者再次消费
throw new RuntimeException("远程调用订单服务失败");
}
}
}

/**
* 订单服务在关单后,将立即发来消息解锁库存
* 作为双重保险,保证库存一定能解锁,防止订单服务因网络延迟而没有将订单状态改为已取消
* @param orderTo
*/
@Override
public void unlock(OrderTo orderTo) {
// 为防止重复解锁,需要重新查询工作单
String orderSn = orderTo.getOrderSn();
WareOrderTaskEntity taskEntity = wareOrderTaskService.getBaseMapper()
.selectOne((new QueryWrapper<WareOrderTaskEntity>()
.eq("order_sn", orderSn)));
// 查询出当前订单相关的且处于锁定状态的工作单详情
List<WareOrderTaskDetailEntity> lockDetails = wareOrderTaskDetailService.list(
new QueryWrapper<WareOrderTaskDetailEntity>()
.eq("task_id", taskEntity.getId())
.eq("lock_status", WareTaskStatusEnum.Locked.getCode()));
for (WareOrderTaskDetailEntity lockDetail : lockDetails) {
// 解锁库存
unlockStock(lockDetail.getSkuId(), lockDetail.getSkuNum(), lockDetail.getWareId(), lockDetail.getId());
}
}

/**
* 去数据库中解锁库存并更改库存工作单状态
* @param skuId
* @param skuNum
* @param wareId
* @param detailId
*/
private void unlockStock(Long skuId, Integer skuNum, Long wareId, Long detailId) {
// 从数据库中解锁库存数据(现有的库存 + 商品数量 skuNum)
baseMapper.unlockStock(skuId, skuNum, wareId);
// 更新库存工作单详情的状态
WareOrderTaskDetailEntity detail = WareOrderTaskDetailEntity.builder()
.id(detailId)
.lockStatus(2).build();
wareOrderTaskDetailService.updateById(detail);
}
  1. WareSkuDao.xml
1
2
3
4
5
6
<update id="unlockStock">
UPDATE wms_ware_sku
SET stock_locked = stock_locked - #{skuNum}
WHERE sku_id = #{skuId}
AND ware_id = #{wareId}
</update>

【立即支付】:支付宝支付

在用户提交订单后,将跳转到支付页面:

image-20220122221157095

用户需要在该页面内选择支付宝支付订单。关于支付宝支付服务的开通和使用见文章【Alibaba】支付宝支付服务

思路:【提交订单】后,将订单信息发送到支付 PayWebController 进行处理:

  • 先根据订单信息封装出支付数据 PayVo(包含订单号以及支付金额等信息)
  • 然后调用支付宝的 AlipayTemplate 远程访问支付宝的服务器,获取一段表单 html(包含 PayVo 信息以及要重定向的支付宝支付页面)
  • 令浏览器跳转到该 html 页面,其会立即提交一个表单,重定向去访问支付宝的支付页面
  • 用户在支付宝页面支付成功后,立即跳转回会员服务,显示该用户支付成功的订单
  • 同时支付宝会异步回调,返回支付成功的详细信息(例如订单号、流水号、交易状态等信息)。当支付成功后支付宝将一直发出该回调请求,返回支付成功相关信息。如果该地址无响应,则会不断发送直到对方应答(最大努力型通知
  • 会员服务监听该异步回调消息,先验签,成功后判断交易状态,若交易状态为 SUCCESS,代表交易成功。保存订单流水,并更改订单的支付状态为已支付

环境配置

  1. 导入 Maven 依赖:
1
2
3
4
5
<dependency>
<groupId>com.alipay.sdk</groupId>
<artifactId>alipay-sdk-java</artifactId>
<version>4.9.28.ALL</version>
</dependency>
  1. 注入 AlipayTemplate
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package com.atguigu.gulimall.order.config;

import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayClient;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.request.AlipayTradePagePayRequest;
import com.atguigu.gulimall.order.vo.PayVo;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {
// 在支付宝创建的应用的id
private String app_id = "2016092200568607";

// 商户私钥,您的PKCS8格式RSA2私钥
private String merchant_private_key = "XXX";
// 支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。
private String alipay_public_key = "XXX";
// 服务器[异步通知]页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
// 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
private String notify_url = "http://member.yunmall.com/memberOrder.html";

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

// 签名方式
private String sign_type = "RSA2";

// 字符编码格式
private String charset = "utf-8";
// 订单超时时间,到达超时时间后自动关闭订单不能再继续支付
private String timeout = "30m";

// 支付宝网关; https://openapi.alipaydev.com/gateway.do
private String gatewayUrl = "https://openapi.alipaydev.com/gateway.do";

public String pay(PayVo vo) throws AlipayApiException {
// 1. 根据支付宝的配置生成一个支付客户端
AlipayClient alipayClient = new DefaultAlipayClient(gatewayUrl,
app_id, merchant_private_key, "json",
charset, alipay_public_key, sign_type);

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

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

// timeout_express 订单支付超时时间
alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no +"\","
+ "\"total_amount\":\""+ total_amount +"\","
+ "\"subject\":\""+ subject +"\","
+ "\"body\":\""+ body +"\","
+ "\"timeout_express\":\"" + timeout + "\","
+ "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");

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

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

return result;
}
}

其中:

  • notify_url:支付成功异步回调,返回支付成功的详细信息(例如订单号、流水号、交易状态等信息)。当支付成功后支付宝将一直发出该回调请求,返回支付成功相关信息。如果该地址无响应,则会不断发送直到对方应答(最大努力型通知
  • return_url同步通知,支付成功后页面跳转到那里

支付数据模型

  1. 定义发送给支付宝的支付对象 PayVo
1
2
3
4
5
6
7
8
9
10
/**
* 支付使用Vo
*/
@Data
public class PayVo {
private String out_trade_no; // 商户订单号 必填
private String subject; // 订单名称 必填
private String total_amount; // 付款金额 必填
private String body; // 商品描述 可空
}
  1. 定义支付宝异步回调返回的信息实体类 PayAsyncVo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 支付宝回调参数Vo
*/
@ToString
@Data
public class PayAsyncVo {
private String gmt_create;
private String charset;
private String gmt_payment;
private Date notify_time;
private String subject;
private String sign;
private String buyer_id; // 支付者的id
private String body; // 订单的信息
private String invoice_amount; // 支付金额
private String version;
private String notify_id; // 通知id
private String fund_bill_list;
private String notify_type; // 通知类型 trade_status_sync
private String out_trade_no; // 订单号
private String total_amount; // 支付的总额
private String trade_status; // 交易状态 TRADE_SUCCESS
private String trade_no; // 流水号
private String auth_app_id;
private String receipt_amount; // 商家收到的款
private String point_amount;
private String app_id; // 应用id
private String buyer_pay_amount;// 最终支付的金额
private String sign_type; // 签名类型
private String seller_id; // 商家的id
}

订单服务收集支付信息跳转到支付页

  1. 编写 PayWebController 跳转到支付宝支付页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Controller
public class PayWebController {

@Autowired
AlipayTemplate alipayTemplate;

@Autowired
OrderService orderService;

/**
* 1. 跳转到支付页面
* 2. 用户支付成功后,我们要跳转到用户的订单列表页
* produces = "text/html":明确方法会返回什么类型,这里返回的是html页面
* @param orderSn
* @return
* @throws AlipayApiException
*/
@ResponseBody
@GetMapping(value = "/payOrder", produces = "text/html")
public String payOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {
// Service 层设置支付信息
PayVo payvo = orderService.payOrder(orderSn);
// 将返回支付宝的支付页面,需要将这个页面进行显示
String pay = alipayTemplate.pay(payvo);
// 支付宝给我们返回的是支付页面表单,是一个html文件
return pay;
}
}

alipayTemplate.pay(payvo) 返回的是一个 html 文本,其内容为:

image-20220122213232794

它其实就是一个表单,配置了订单的数据信息。并且该表单会立即提交,带着我们传入的订单数据重定向到支付宝网关,从而重定向到支付宝的支付页面:

image-20220122213532319

在该页面支付完成后,支付宝就会立即重定向到我们在 AlipayTemplate 中配置的 return_url 地址,即会员服务的订单详情页面:

image-20220122213844309

  1. Service 层封装需要支付的信息 PayVo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 计算商品支付需要的信息
* @param orderSn
* @return
*/
@Override
public PayVo payOrder(String orderSn) {
PayVo payVo = new PayVo();
OrderEntity orderEntity = this.getOrderByOrderSn(orderSn); // 根据订单号查询到商品
// 数据库中付款金额小数有4位,但是支付宝只接受2位,所以向上取整两位数
BigDecimal decimal = orderEntity.getPayAmount().setScale(2, BigDecimal.ROUND_UP);
payVo.setTotal_amount(decimal.toString());
// 商户订单号
payVo.setOut_trade_no(orderSn);
// 查询出订单项,用来设置商品的描述和商品名称
List<OrderItemEntity> itemEntities = orderItemService.list(new QueryWrapper<OrderItemEntity>()
.eq("order_sn", orderSn));
OrderItemEntity itemEntity = itemEntities.get(0);
// 订单名称使用商品项的名字
payVo.setSubject(itemEntity.getSkuName());
// 商品的描述使用商品项的属性
payVo.setBody(itemEntity.getSkuAttrsVals());
return payVo;
}

支付成功后异步回调到会员服务

  1. 支付成功后,支付宝将异步回调到 AlipayTemplate 中配置的 notify_url 地址。先验证签名后,保存订单流水,并更改订单的支付状态为已支付
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* 支付宝成功异步回调
* @author yuyun.zhao
* @Create 2022-01-22
*/
@RestController
public class OrderPayedListener {
@Autowired
AlipayTemplate alipayTemplate;

@Autowired
OrderService orderService;

/**
* 支付宝异步通知回调接口,需要拥有内网穿透或服务器
* @param request
* @return
*/
@PostMapping("/payed/notify")
public String handleAlipayed(PayAsyncVo vo, HttpServletRequest request) throws UnsupportedEncodingException, AlipayApiException {
/**
* 重要一步验签名
* 防止别人通过postman给我们发送一个请求,告诉我们请求成功,为了防止这种效果通过验签
*/
Map<String,String> params = new HashMap<String,String>();
Map<String,String[]> requestParams = request.getParameterMap();
for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) {
String name = (String) iter.next();
String[] values = (String[]) requestParams.get(name);
String valueStr = "";
for (int i = 0; i < values.length; i++) {
valueStr = (i == values.length - 1) ? valueStr + values[i]
: valueStr + values[i] + ",";
}
// 乱码解决,这段代码在出现乱码时使用
valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
params.put(name, valueStr);
}
// 支付宝验签,防止恶意提交
boolean signVerified = AlipaySignature.rsaCheckV1(params
, alipayTemplate.getAlipay_public_key()
, alipayTemplate.getCharset()
, alipayTemplate.getSign_type());
if (signVerified) {
// 处理支付结果:保存订单流水、更改订单的支付状态为已支付
String result = orderService.handleAlipayed(vo);
return result;
} else {
return "error";
}
}
}
  1. Service保存订单流水,并更改订单的支付状态为已支付
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public String handleAlipayed(PayAsyncVo vo) {
// 保存交易流水信息,每个月和支付宝进行对账
PaymentInfoEntity infoEntity = new PaymentInfoEntity();
// 设置核心字段
infoEntity.setOrderSn(vo.getOut_trade_no());
infoEntity.setAlipayTradeNo(vo.getTrade_no());
infoEntity.setPaymentStatus(vo.getTrade_status());
infoEntity.setCallbackTime(vo.getNotify_time());
// 保存订单流水
paymentInfoService.save(infoEntity);
/**
* 支付宝交易状态说明
* https://opendocs.alipay.com/open/270/105902
*/
// TRADE_FINISHED 交易结束、不可退款
// TRADE_SUCCESS 交易支付成功
if (vo.getTrade_status().equals("TRADE_SUCCESS") || vo.getTrade_status().equals("TRADE_FINISHED")) {
String outTradeNo = vo.getOut_trade_no();
// 支付宝回调成功后,更改订单的支付状态为已支付
this.baseMapper.updateOrderStatus(outTradeNo, OrderStatusEnum.PAYED.getCode());
}
return "success";
}

支付宝自动收单

目前可能出现的问题:用户在支付页一直不支付,等到该订单过期后才支付。这时数据库中该订单的库存已经解锁了,订单已经取消了,显然不应该使其支付成功。

此时可以开启支付宝的自动收单功能,使用方法:在 AlipayTemplate 中设置 time_expire = 30m,代表跳转到支付页面后 30 分钟内如果不支付,就禁止继续支付了。这样就能保证订单过期后用户无法在支付宝页面支付。

可能出现的问题

问题一:由于网络延迟等问题。订单在 30 分钟过期后完成关单,正在解锁库存的时候,支付成功的异步通知才到。此时订单已经关闭了,但是用户支付成功了,库存也即将解锁了。

  • 解决方案:在关单后,手动调用支付宝 AlipayTemplate 提供的接口通知支付页面,手动关闭支付页面,提示用户已过期,取消支付。

问题二:交易争议。用户表示自己已经支付,但是系统因各种原因一直没有收到支付宝的成功回调,导致数据库中该用户的订单状态一直未未支付。

  • 解决方案:对这些争议做定时对账。每晚定时调用支付宝提供的交易查询接口,提供订单号或商品号,就能查询该订单在支付宝服务中的实际支付状态,从而修改数据库中这些争议数据的订单状态

消息可靠性保证

本章将介绍如何保证消息的可靠性——消息丢失消息重复消息积压问题。

消息丢失

情景一:消息发送出去,由于网络问题没有抵达服务器。解决方案:

  • 做好容错方案(try-catch),消息因网络原因发送失败后,要有重试机制
  • 可以在 catch 到网络异常后再重试 3 次发送请求,如果仍失败,则代表服务当前网络不可用,暂时停止发送消息,记录该消息到数据库中,之后采用定期扫描失败的消息,重新发送该消息
    • 做好日志记录,每个消息的状态都应该记录到日志中(持久化到数据库)
    • 做好定期重发,如果某条消息没有发送成功,则将其记录到数据库中。定期去数据库中扫描未成功发送的消息进行重发

情景二:消息顺利抵达 Broker,Broker 要将消息写入磁盘(持久化)才算接收成功。但此时 Broker 尚未完成持久化就宕机(消息到了 Broker,但是其还没持久化好消息就宕机)。解决方案:

  • 消息生产者必须开启发布确认功能,在 Broker 回复成功收到消息后,在数据库中保存该消息的状态为已发送;如果未收到成功回复,则保存该消息的状态为未发送成功
  • 未发送成功的消息将定时重新发送

情景三:消息顺利进入队列,但是消费者在自动 Ack 状态还未消费成功就宕机。解决方案:

  • 开启手动 Ack,消费者在消费成功再手动回复 Broker,将其从队列移除。若长时间未回复,则 Broker 会将该消息重新入队
  • 确认消费成功后,修改该消息的状态为已消费

使用以上三种策略即可在一定程度上保证消息不丢失。

最大努力通知型方案

生产者在发送消息到 MQ 后需要将消息持久化到数据库中的一个消息表中,初始状态为未发送

  • 当 Broker 中交换机收到消息后,异步通知生产者(发布确认),此时将该消息状态标记为已发送
  • 如果 Broker 将消息路由到队列时失败,会异步通知生产者消息路由失败(消息回退)。此时生产者需要不断重新发送消息,确保消息能成功路由到队列。
  • 消费者在手动 ACK 后,将该消息状态标记为已消费,代表消息成功消费
  • 定时重新投递发送失败的消息(状态不是已消费就代表发送失败)

消息重复

消息重复消费的情况:

  • 消息成功被消费者消费(例如成功解锁库存),但在手动 Ack 回复 Broker 消费成功时,消费者宕机,消息由 unack 变为 ready,Broker 又重新发送该消息给其他消费者重复解锁库存
  • 消息在消费失败,由于重试机制,自动又将消息发送出去

解决方案:消费者的业务消费接口应该被设计为幂等性的。

  • 比如解锁库存时要先判断库存工作单的状态标志位,如果订单状态是已取消或该订单不存在于订单表中,再进行解锁库存。否则不解锁
  • 使用防重表(Redis / MySQL),发送的每一个消息都设置其业务的唯一标识,消费者在收到消息后先判断该标识是否已存在于防重表中,如果已存在就不再重复处理
  • RabbitMQ 的每一个消息都有 redelivered 字段,可以获取是否是被重新投递过来的,而不是第一次投递过来的。该方案可能存在一定隐患,例如第一次消息没有执行成功,第二次再发送时本应该再次消费,但因为 redelivered 字段表明该消息是重新投递就不再执行了,可能造成消息丢失

消息积压

可能因为消费者消费能力不足或发送者发送瞬时流量太大导致大量消息积压在消息队列中。解决方案:

  • 上线更多消费者,进行正常消费
  • 上线专门的队列消费服务,将消息先批量取出来,存储到数据库中,之后再慢慢处理这些消息

幂等性问题

关于幂等性问题的详细介绍和解决方案见文章【分布式】幂等性问题

订单服务可能出现多个幂等性问题:

  • 页面重复提交问题:使用 Token 令牌机制解决
  • Feign 远程调用问题:远程调用前先生成一个全局唯一 uuid,并存入到 Redis 的 Set 结构中防止重复响应远程调用方法
  • 数据库重复插入订单问题:为订单号添加唯一性索引 UNIQUE,防止插入重复的订单数据
  • 重复解锁库存问题:在解锁库存前需要首先判断"商品 & 库存 - 工作单"中当前任务单的状态是否为已锁定 WareTaskStatusEnum.Locked,如果不是,说明该工作单已经被解锁了,从而无需重复解锁。

页面重复提交问题

现在分析一个问题:当用户在前端连续多次点击【提交订单】,会发出多个创建订单的请求 /submitOrder。这会导致同一个订单被创建多次,从而不满足幂等性。因此我们必须要解决幂等性问题,使得一个用户只能同时处理一个订单,其他订单请求都失效。体现在 Redis 里就是同一时刻只能保存唯一的一个订单的 uuid(order:token:userId - uuid)。

解决方案:使用 token(令牌)机制。在购物车页面点击【去结算】时,就在 /toTrade 业务代码中为当前用户生成一个防重令牌(防止重复提交),并保存到 Redis 中(格式为 order:token:userId - uuid)。同时将该令牌保存到 OrderConfirmVo 对象中返回给前端保存。

代码:

1
2
3
4
5
6
// 任务6. 设置防重令牌。随机生成一个 UUID 作为令牌保存到 Reids 中,并且返回给客户端。
// 其在点击【提交订单】时将带上该令牌:key - value = order:token:userId - uuid
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId(),
token, 30, TimeUnit.MINUTES);
confirmVo.setOrderToken(token);

这样前端页面在点击【提交订单】按钮后跳转到 /submitOrder 业务时,就会将该防重令牌携带上。此时我们只需要再从 Redis 中根据该用户 id 获取到其保存在 Redis 中的令牌,并与客户端携带上来的令牌进行比较即可。流程:

  • 根据用户 order:token:userId 从 Redis 中获取令牌
  • 判断客户端携带的令牌Redis 中查询的令牌是否相等
  • 如果相等,说明验证成功,从 Redis 中删除令牌
  • 如果不相等,说明验证失败,当前请求是重复请求(或者用户同时开两个网页提交两个订单),拒绝访问,直接返回

这三个步骤的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();
// 先从 ThreadLocal 中获取当前登录用户的信息
MemberResponseVo memberResponseVo = LoginInterceptor.loginUserThreadLocal.get();
// 获取前端传来的校验令牌
String orderToken = submitVo.getOrderToken();
// 1. 验证令牌。核心是要保证令牌的获取(1.1)、判断(1.2)与删除(1.3)必须是原子性的
// 1.1 去 Redis 中查找当前用户的令牌
String redisToken = redisTemplate.opsForValue().get(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId());
// 1.2 判断客户端的令牌和服务端的令牌是否相等
if (orderToken != null && orderToken.equals(redisToken)) {
// 1.3 令牌验证通过。从 Redis 中删除令牌
redisTemplate.delete(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId());
} else {
// 令牌不通过
}

注意:这三个操作必须是原子性的。否则高并发场景下会出现问题。因此可以使用 LUA 脚本保证这三个过程的原子性:

1
2
3
4
5
// 原子验证令牌并删除令牌
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()),
orderToken);

Feign 远程调用问题

前提:Feign 一旦远程调用失败后,就会不断重试发出相同请求。可能实际上远程服务已经收到了调用请求并且执行了业务,但是因为网络延迟等因素,Feign 认为没有调用成功,从而发出了同样的请求。

为了避免这种情况的发生,我们可以在 Feign 远程调用接口前,先生成一个唯一的 uuid,并存入到 Redis 的 Set 结构中(例如一个用户保存一个 Set,其内保存了该用户所有远程调用请求的 uuid,能够保证不重复)。远程服务在执行业务前先检查 Redis 中该用户的 Set 里是否存在该 uuid,如果存在即代表当前远程请求之前就被处理过了(该请求被重复调用了),那么就不再执行了。

注意:这种方式只适用于 Feign 的远程调用防重。如果是前端页面发来的重复请求是无法防住的(需要使用上面的令牌机制),因为即使使用 Nginx 给每个前端发来的请求添加唯一 id,点击两次前端页面也会发送两个 id 不同的请求,从而无法做到防重。但是 Nginx 添加 id 的思路可以用于实现链路追踪功能。

总结:Feign 防止重复调用可以使用这个方法。但是页面的重复提交防止不了。


在 Nginx 中为每一个请求设置一个唯一 id:

1
proxy_set_header X-Request-Id $Request_id

数据库重复插入订单问题

为防止在数据库中重复插入同一条订单,可以为订单号 order_sn 添加唯一性索引 UNIQUE,防止在数据库中插入重复的订单数据。

重复解锁库存问题

在解锁库存前,先从工作单详情表中查询当前订单相关的工作单信息,此时需要设置状态为已锁定 WareTaskStatusEnum.Locked,那些已解锁的就无须再重复解锁了:

1
2
3
4
5
// 查询出当前订单相关的且处于锁定状态的工作单详情
List<WareOrderTaskDetailEntity> lockDetails = wareOrderTaskDetailService.list(
new QueryWrapper<WareOrderTaskDetailEntity>()
.eq("task_id", taskEntity.getId())
.eq("lock_status", WareTaskStatusEnum.Locked.getCode()));

在解锁库存后,需要更新该工作单的状态:

1
2
3
4
5
// 更新库存工作单详情的状态
WareOrderTaskDetailEntity detail = WareOrderTaskDetailEntity.builder()
.id(detailId)
.lockStatus(WareTaskStatusEnum.UnLocked.getCode()).build();
wareOrderTaskDetailService.updateById(detail);

这样就能避免重复解锁库存了。