第11章 订单

角色: 订单模块,后端开发工程师。

课程内容

  1. 完成订单结算页渲染

  2. 完成用户下单实现

  3. 完成库存变更实现

1 订单结算页

image-20211205120105277
image-20211205120105277

1.1 收件地址分析

1展示 打开前台 order.html

image-20211205115918039
image-20211205115918039

收件地址分析 td_address表。表结构分析:

image-20211205120007359
image-20211205120007359
From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE `tb_address` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(50) DEFAULT NULL COMMENT '用户名',
`provinceid` varchar(20) DEFAULT NULL COMMENT '省',
`cityid` varchar(20) DEFAULT NULL COMMENT '市',
`areaid` varchar(20) DEFAULT NULL COMMENT '县/区',
`phone` varchar(20) DEFAULT NULL COMMENT '电话',
`address` varchar(200) DEFAULT NULL COMMENT '详细地址',
`contact` varchar(50) DEFAULT NULL COMMENT '联系人',
`is_default` varchar(1) DEFAULT NULL COMMENT '是否是默认 1默认 0否',
`alias` varchar(50) DEFAULT NULL COMMENT '别名',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=66 DEFAULT CHARSET=utf8;

我们可以根据用户登录名去tb_address表中查询对应的数据。

1.2 实现用户收件地址查询

1.2.1 代码实现 ydles-service-user

1需改com.ydles.user.service.AddressService接口,添加根据用户名字查询用户收件地址信息,代码如下:

From: 元动力
1
2
//根据username 查询
public List<Address> list(String username);

2业务层接口实现类

修改com.ydles.user.service.impl.AddressServiceImpl类,添加根据用户查询用户收件地址信息实现方法,如下代码:

From: 元动力
1
2
3
4
5
6
7
8
9
@Override
public List<Address> list(String username) {
Address address=new Address();
address.setUsername(username);

List<Address> addressList = addressMapper.select(address);

return addressList;
}

3控制层

修改com.ydles.user.controller.AddressController,添加根据用户名查询用户收件信息方法,代码如下:

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
@Autowired
TokenDecode tokenDecode;

//根据username 查询 lsit<Address>
@GetMapping("/list")
public List<Address> list(){
//当前登录人
String username = tokenDecode.getUserInfo().get("username");

List<Address> addressList = addressService.list(username);

return addressList;
}

4创建TokenDecode

com.ydles.UserApplication中创建TokenDecode,代码如下:

From: 元动力
1
2
3
4
@Bean
public TokenDecode tokenDecode(){
return new TokenDecode();
}

测试:通过网关访问,修改网关配置信息

From: 元动力
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
routes:
- id: ydles_goods_route
uri: lb://goods
predicates:
- Path=/api/album/**,/api/brand/**,/api/cache/**,/api/categoryBrand/**,/api/category/**,/api/para/**,/api/pref/**,/api/sku/**,/api/spec/**,/api/spu/**,/api/stockBack/**,/api/template/**
filters:
#- PrefixPath=/brand
- StripPrefix=1
#用户微服务
- id: ydles_user_route
uri: lb://user
predicates:
- Path=/api/user/**,/api/address/**,/api/areas/**,/api/cities/**,/api/provinces/**
filters:
- StripPrefix=1
#认证微服务
- id: ydles_oauth_user
uri: lb://user-auth
predicates:
- Path=/api/oauth/**
filters:
- StripPrefix=1
#订单微服务
- id: ydles_order_route
uri: lb://order
predicates:
- Path=/api/cart/**,/api/categoryReport/**,/api/orderConfig/**,/api/order/**,/api/orderItem/**,/api/orderLog/**,/api/preferential/**,/api/returnCause/**,/api/returnOrder/**,/api/returnOrderItem/**
filters:
- StripPrefix=1
#购物车订单渲染微服务
- id: ydles_order_web_route
uri: lb://order-web
predicates:
- Path=/api/wcart/**,/api/worder/**
filters:
- StripPrefix=1

先登录 Postman访问 http://localhost:8001/api/oauth/loginopen in new window

再请求数据 Postman访问 http://localhost:8001/api/address/listopen in new window

image-20211205121817519
image-20211205121817519

1.3 页面模板渲染

image-20211205120105277
image-20211205120105277

购物车这块也使用的是模板渲染,用户先请求经过微服务网关,微服务网关转发到订单购物车模板渲染服务,模板渲染服务条用用户微服务和订单购物车微服务查询用户收件地址和购物车清单,然后到页面显示。

1.3.1 准备工作

(1)静态资源导入

将资料中的order.html拷贝到ydles-web-order工程的templates中。

image-20211206091251118
image-20211206091251118

(2)页面跳转实现

在ydles-web-order中创建com.ydles.order.controller.OrderController实现页面跳转,代码如下:

From: 元动力
1
2
3
4
5
6
7
8
@Controller
@RequestMapping("/worder")
public class OrderController {
@RequestMapping("/ready/order")
public String readyOrder(Model model) {
return "order";
}
}

(3)网关配置

修改ydles-gateway-web的application.yml文件,将订单的路由过滤地址添加上去,代码如下:

image-20211206091646934
image-20211206091646934
From: 元动力
1
2
3
4
5
6
7
#购物车订单渲染微服务
- id: ydles_order_web_route
uri: lb://order-web
predicates:
- Path=/api/wcart/**,/api/worder/**
filters:
- StripPrefix=1

同时不要忘了把该地址添加到登录过滤地址中,修改com.ydles.filter.URLFilter,在orderFilterPath里添加/api/worder/**过滤,代码如下:

image-20211206091746759
image-20211206091746759

1.3.2 信息查询-重点

需求:

因为一会儿要调用ydles-service-user查询用户的收件地址信息,调用ydles-service-order查询购物车清单信息,所以我们需要创建Feign。购物车的Feign之前已经创建过了,所以只需要创建用户地址相关的即可。

(1)用户地址信息查询

在ydles-service-user-api中创建AddressFeign,代码如下:

From: 元动力
1
2
3
4
5
6
7
8
9
10
@FeignClient(name="user")
public interface AddressFeign {

/***
* 查询用户的收件地址信息
* @return
*/
@GetMapping(value = "/list")
public Result<List<Address>> list();
}

(2) ydles_web_order项目启动类OrderWebApplication加入调用的feign包

From: 元动力
1
2
3
4
5
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_service_user_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
From: 元动力
1
2
3
4
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients(basePackages = {"com.ydles.order.feign","com.ydles.user.feign"})
public class OrderWebApplication {

(3)查询购物车和用户收件地址信息

修改ydles-web-order中的com.ydles.order.controller.OrderController的readyOrder方法,在该方法中,使用feign调用查询收件地址信息和用户购物车信息,代码如下:

From: 元动力
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
@RequestMapping("/worder")
public class OrderController {

@Autowired
private AddressFeign addressFeign;

@Autowired
private CartFeign cartFeign;

@RequestMapping("/ready/order")
public String readyOrder(Model model) {
//1收件人的地址信息
List<Address> addressList = addressFeign.list().getData();
model.addAttribute("address",addressList);

//2购物车信息
Map map = cartFeign.list();
List<OrderItem> orderItemList = (List<OrderItem>) map.get("orderItemList");
Integer totalMoney = (Integer) map.get("totalMoney");
Integer totalNum = (Integer) map.get("totalNum");

model.addAttribute("carts",orderItemList);
model.addAttribute("totalMoney",totalMoney);
model.addAttribute("totalNum",totalNum);
return "order";
}
}

(3)数据回显-了解

修改order.html,与静态原型中的order.html打开对比。

From: 元动力
1
<html xmlns:th="http://www.thymeleaf.org">
From: 元动力
1
<div class="cart py-container" id="app">
From: 元动力
1
2
3
4
5
6
7
8
9
<div class="choose-address" th:each="addr:${address}">
<div class="con name " th:@click="|chooseAddr('${addr.contact}','${addr.phone}','${addr.address}')|" th:classappend="${addr.isDefault}==1?'selected':''"><a href="javascript:;" ><em th:text="${addr.contact}"></em> <span title="点击取消选择"></span></a></div>
<div class="con address">
<span class="place"><em th:text="${addr.address}"></em> </span>
<span class="phone"><em th:text="${addr.phone}"></em> </span>
<span class="base" th:if="${addr.isDefault}==1">默认地址</span>
</div>
<div class="clearfix"></div>
</div>
From: 元动力
1
2
3
4
<ul class="payType">
<li class="selected" th:@click="|order.payType=1|">在线支付<span title="点击取消选择"></span></li>
<li th:@click="|order.payType=0|">货到付款<span title="点击取消选择"></span></li>
</ul>
From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<ul class="yui3-g" th:each="cart,cartsList:${carts}">
<li class="yui3-u-1-9">
<span><img th:src="${cart.image}"/></span>
</li>
<li class="yui3-u-5-12">
<div class="desc" th:text="${cart.name}"></div>
<div class="seven">7天无理由退货</div>
</li>
<li class="yui3-u-1-12">
<div class="price" th:text="${cart.price}"></div>
</li>
<li class="yui3-u-1-12">
<div class="num" th:text="${cart.num}"></div>
</li>
<li class="yui3-u-1-12">
<div class="num" th:text="${cart.num}*${cart.price}"></div>
</li>
<li class="yui3-u-1-12">
<div class="exit">有货</div>
</li>
</ul>
From: 元动力
1
2
3
4
5
6
<div class="fc-receiverInfo">
寄送至:
<span id="receive-address">{{order.receiveAddress}}</span>
收货人:<span id="receive-name">{{order.receiveContact}}</span>
<span id="receive-phone">{{order.receiveMobile}}</span>
</div>
From: 元动力
1
2
3
4
<div class="list">
<span><i class="number"><em th:text="${totalNum}"></em></i>件商品,商品总金额</span>
<em class="allprice">¥<em th:text="${totalMoney}"></em></em>
</div>
From: 元动力
1
2
3
<div class="clearfix trade">
<div class="fc-price">应付金额: <span class="final-price">¥<em th:text="${totalMoney}"></em></span></div>
</div>
From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script th:inline="javascript">
var app = new Vue({
el:"#app",
data:{
order:{
'receiveContact': [[${deAddr.contact}]],
'receiveMobile': [[${deAddr.phone}]],
'receiveAddress': [[${deAddr.address}]],
'payType':1
}
},
methods:{
chooseAddr: function (contact,mobile,address){
app.$set(app.order,'receiveContact',contact);
app.$set(app.order,'receiveMobile',mobile);
app.$set(app.order,'receiveAddress',address);
}
}
})
</script>

测试:先登录,再访问

http://localhost:8001/api/worder/ready/orderopen in new window

image-20211206114046064
image-20211206114046064

1.3.3 记录选中收件人-了解

收件人动态展示

From: 元动力
1
73 <div class="cart py-container" id="app">
From: 元动力
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
<script th:inline="javascript">
var app = new Vue({
el:"#app",
data:{
order:{'receiveContact':[[${deAddr.contact}]],'receiveMobile':[[${deAddr.phone}]],'receiveAddress':[[${deAddr.address}]],'payType':1}
},
methods:{
chooseAddr:function (contact,mobile,address) {
app.$set(app.order,'receiveContact',contact);
app.$set(app.order,'receiveMobile',mobile);
app.$set(app.order,'receiveAddress',address);
},
add:function () {
axios.post('/api/worder/add',this.order).then(function (response) {
if (response.data.flag){
//添加订单成功
alert("添加订单成功");
} else{
alert("添加订单失败");
}
})
}
}
})
</script>
From: 元动力
1
2
3
4
5
6
7
8
9
<div class="clearfix trade">
<div class="fc-price">应付金额: <span class="price">¥<em th:text="${totalMoney}"></em></span></div>
<div class="fc-receiverInfo">
寄送至:
<span id="receive-address">{{order.receiveAddress}}</span>
收货人:<span id="receive-name">{{order.receiveContact}}</span>
<span id="receive-phone">{{order.receiveMobile}}</span>
</div>
</div>

后端设置默认收件人

From: 元动力
1
2
3
4
5
6
7
8
//默认收件人信息
for (Address address : addressList) {
if ("1".equals(address.getIsDefault())){
//默认收件人
model.addAttribute("deAddr",address);
break;
}
}

前端:

From: 元动力
1
order:{'receiveContact':[[${deAddr.contact}]],'receiveMobile':[[${deAddr.phone}]],'receiveAddress':[[${deAddr.address}]],'payType':1}

支付方式

From: 元动力
1
2
3
4
5
6
<div class="step-cont">
<ul class="payType">
<li class="selected" th:@click="|order.payType=1|">在线支付<span title="点击取消选择"></span></li>
<li th:@click="|order.payType=0|">货到付款<span title="点击取消选择"></span></li>
</ul>
</div>

购物车跳转结算页面

cart.html

From: 元动力
1
<a class="sum-btn" href="/api/worder/ready/order" target="_blank">结算</a>

2 下单-重点

2.1 业务分析

点击提交订单的时候,会立即创建订单数据,创建订单数据会将数据存入到2张表中,分别是订单表和订单明细表,此处还需要修改商品对应的库存数量。

image-20211206121803107
image-20211206121803107

订单表结构如下:

From: 元动力
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
CREATE TABLE `tb_order` (
`id` varchar(50) COLLATE utf8_bin NOT NULL COMMENT '订单id',
`total_num` int(11) DEFAULT NULL COMMENT '数量合计',
`total_money` int(11) DEFAULT NULL COMMENT '金额合计',
`pre_money` int(11) DEFAULT NULL COMMENT '优惠金额',
`post_fee` int(11) DEFAULT NULL COMMENT '邮费',
`pay_money` int(11) DEFAULT NULL COMMENT '实付金额',
`pay_type` varchar(1) COLLATE utf8_bin DEFAULT NULL COMMENT '支付类型,1、在线支付、0 货到付款',
`create_time` datetime DEFAULT NULL COMMENT '订单创建时间',
`update_time` datetime DEFAULT NULL COMMENT '订单更新时间',
`pay_time` datetime DEFAULT NULL COMMENT '付款时间',
`consign_time` datetime DEFAULT NULL COMMENT '发货时间',
`end_time` datetime DEFAULT NULL COMMENT '交易完成时间',
`close_time` datetime DEFAULT NULL COMMENT '交易关闭时间',
`shipping_name` varchar(20) COLLATE utf8_bin DEFAULT NULL COMMENT '物流名称',
`shipping_code` varchar(20) COLLATE utf8_bin DEFAULT NULL COMMENT '物流单号',
`username` varchar(50) COLLATE utf8_bin DEFAULT NULL COMMENT '用户名称',
`buyer_message` varchar(1000) COLLATE utf8_bin DEFAULT NULL COMMENT '买家留言',
`buyer_rate` char(1) COLLATE utf8_bin DEFAULT NULL COMMENT '是否评价',
`receiver_contact` varchar(50) COLLATE utf8_bin DEFAULT NULL COMMENT '收货人',
`receiver_mobile` varchar(12) COLLATE utf8_bin DEFAULT NULL COMMENT '收货人手机',
`receiver_address` varchar(200) COLLATE utf8_bin DEFAULT NULL COMMENT '收货人地址',
`source_type` char(1) COLLATE utf8_bin DEFAULT NULL COMMENT '订单来源:1:web,2:app,3:微信公众号,4:微信小程序 5 H5手机页面',
`transaction_id` varchar(30) COLLATE utf8_bin DEFAULT NULL COMMENT '交易流水号',
`order_status` char(1) COLLATE utf8_bin DEFAULT NULL COMMENT '订单状态,0:未完成,1:已完成,2:已退货',
`pay_status` char(1) COLLATE utf8_bin DEFAULT NULL COMMENT '支付状态,0:未支付,1:已支付,2:支付失败',
`consign_status` char(1) COLLATE utf8_bin DEFAULT NULL COMMENT '发货状态,0:未发货,1:已发货,2:已收货',
`is_delete` char(1) COLLATE utf8_bin DEFAULT NULL COMMENT '是否删除',
PRIMARY KEY (`id`),
KEY `create_time` (`create_time`),
KEY `status` (`order_status`),
KEY `payment_type` (`pay_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

订单明细表结构如下:

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
CREATE TABLE `tb_order_item` (
`id` varchar(50) COLLATE utf8_bin NOT NULL COMMENT 'ID',
`category_id1` int(11) DEFAULT NULL COMMENT '1级分类',
`category_id2` int(11) DEFAULT NULL COMMENT '2级分类',
`category_id3` int(11) DEFAULT NULL COMMENT '3级分类',
`spu_id` varchar(20) COLLATE utf8_bin DEFAULT NULL COMMENT 'SPU_ID',
`sku_id` bigint(20) NOT NULL COMMENT 'SKU_ID',
`order_id` bigint(20) NOT NULL COMMENT '订单ID',
`name` varchar(200) COLLATE utf8_bin DEFAULT NULL COMMENT '商品名称',
`price` int(20) DEFAULT NULL COMMENT '单价',
`num` int(10) DEFAULT NULL COMMENT '数量',
`money` int(20) DEFAULT NULL COMMENT '总金额',
`pay_money` int(11) DEFAULT NULL COMMENT '实付金额',
`image` varchar(200) COLLATE utf8_bin DEFAULT NULL COMMENT '图片地址',
`weight` int(11) DEFAULT NULL COMMENT '重量',
`post_fee` int(11) DEFAULT NULL COMMENT '运费',
`is_return` char(1) COLLATE utf8_bin DEFAULT NULL COMMENT '是否退货,0:未退货,1:已退货',
PRIMARY KEY (`id`),
KEY `item_id` (`sku_id`),
KEY `order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

2.2 下单实现

下单的时候,先往tb_order表中增加数据,再往tb_order_item表中增加数据。

2.2.1 代码实现

这里先修改ydles-service-order微服务,实现下单操作,这里会生成订单号,我们首先需要在启动类中创建一个IdWorker对象。

com.ydles.OrderApplication中创建IdWorker,代码如下:

From: 元动力
1
2
3
4
@Bean
public IdWorker idWorker() {
return new IdWorker(workerId, datacenterId);
}

(1)业务层

实现逻辑:

1)获取所有购物车

2)统计计算:总金额,总支付金额,总数量

3)填充订单数据并保存

4)获取每一个购物项保存到orderItem

5)删除购物车中数据

修改订单微服务添加com.ydles.order.service.impl.OrderServiceImpl,代码如下:

From: 元动力
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
/**
* 下单
* @param order
* 缺啥补啥
*/
@Override
public boolean add(Order order){
//1 获取 购物车信息
Map cartMap = cartService.list(order.getUsername());
//map.put("orderItemList",orderItemList);
Integer totalNum = (Integer) cartMap.get("totalNum");
Integer totalMoney = (Integer) cartMap.get("totalMoney");

//2 order表里存数据
String orderId = idWorker.nextId()+"";
order.setId(orderId);
order.setTotalNum(totalNum);
order.setTotalMoney(totalMoney);
//作业:优惠金额 怎么算 本店满50-5 跨店 300-50
//作业:邮费金额 怎么算
order.setPayMoney(totalMoney);
order.setCreateTime(new Date());
order.setUpdateTime(new Date());
//作业:买家留言
order.setBuyerRate("0");
order.setSourceType("1");
order.setOrderStatus("0");
order.setPayStatus("0");
order.setConsignStatus("0");
order.setIsDelete("0");

orderMapper.insertSelective(order);

//3 orderItem表里存数据
List<OrderItem> orderItemList = (List<OrderItem>) cartMap.get("orderItemList");
for (OrderItem orderItem : orderItemList) {
orderItem.setOrderId(orderId);
orderItem.setPostFee(0);
orderItem.setIsReturn("0");
orderItemMapper.insertSelective(orderItem);
}

//4 删除购物车信息
stringRedisTemplate.delete(CART+order.getUsername());

return true;
}

(2)控制层

修改ydles-service-order微服务,修改com.ydles.order.controller.OrderController类,代码如下:

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Autowired
TokenDecode tokenDecode;

/***
* 下单
* @param order
* @return
*/
@PostMapping
public Result add(@RequestBody Order order){
String username = tokenDecode.getUserInfo().get("username");
order.setUsername(username);

orderService.add(order);
return new Result(true,StatusCode.OK,"添加成功");
}

2.2.2 渲染服务对接

image-20211206142024505
image-20211206142024505

我们需要在模板渲染端调用订单微服务实现下单操作,下单操作需要调用订单微服务,所以需要创建对应的Feign。

(1)Feign创建

修改ydles-service-order-api,添加OrderFeign,代码如下:

From: 元动力
1
2
3
4
5
6
@FeignClient(name = "order")
public interface OrderFeign {

@PostMapping("/order")
public Result add(@RequestBody Order order);
}

(2)下单调用

修改ydles-web-order的com.ydles.order.controller.OrderController添加下单方法,代码如下:

From: 元动力
1
2
3
4
5
6
7
8
9
@Autowired
private OrderFeign orderFeign;

@PostMapping("/add")
@ResponseBody
public Result add(@RequestBody Order order){
Result result = orderFeign.add(order);
return result;
}

(3)页面调用

order.html

From: 元动力
1
2
3
4
5
6
7
8
9
10
add:function () {
axios.post('/api/worder/add',this.order).then(function (response) {
if (response.data.flag){
//添加订单成功
alert("添加订单成功");
} else{
alert("添加订单失败");
}
})
}
From: 元动力
1
<a class="sui-btn btn-danger btn-xlarge" href="javascript:void(0)" @click="add()">提交订单</a>

点击提交订单调用

保存订单测试,表数据变化如下:

tb_order表数据:

image-20211206143109177
image-20211206143109177

tb_order_item表数据:

image-20211206143122426
image-20211206143122426

2.3 库存变更

2.3.1 业务分析

上面操作只实现了下单操作,但对应的库存还没跟着一起减少,我们在下单之后,应该调用商品微服务,将下单的商品库存减少,销量增加。每次订单微服务只需要将用户名传到商品微服务,商品微服务通过用户名到Redis中查询对应的购物车数据,然后执行库存减少,库存减少需要控制当前商品库存>=销售数量。

如何控制库存数量>=购买数量呢?其实可以通过SQL语句实现,每次减少数量之前,加个条件判断。

where num>=#{num}即可。

商品服务需要查询购物车数据,所以需要引入订单的api,在pom.xml中添加如下依赖:

From: 元动力
1
2
3
4
5
6
<!--order api 依赖-->
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_service_order_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

2.3.2 代码实现

要调用其他微服务,需要将头文件中的令牌数据携带到其他微服务中取,所以我们不能使用hystrix的多线程模式,修改ydles-service-order的applicatin.yml配置,代码如下:

From: 元动力
1
2
3
4
5
6
7
8
9
#hystrix 配置
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 10000
strategy: SEMAPHORE

每次还需要使用拦截器添加头文件信息,添加拦截器,代码如下:

(1)Dao层

修改ydles-service-goods微服务的com.ydles.goods.dao.SkuMapper接口,增加库存递减方法,代码如下:

From: 元动力
1
2
3
4
5
6
7
/**
* 递减库存
* @param orderItem
* @return
*/
@Update("UPDATE tb_sku SET num=num-#{num},sale_num=sale_num+#{num} WHERE id=#{skuId} AND num>=#{num}")
int decrCount(OrderItem orderItem);

(2)业务层

修改ydles-service-goods微服务的com.ydles.goods.service.SkuService接口,添加如下方法:

From: 元动力
1
2
3
4
5
/***
* 库存递减
* @param username
*/
void decrCount(String username);

修改ydles-service-goods微服务的com.ydles.goods.service.impl.SkuServiceImpl实现类,添加一个实现方法,代码如下:

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Autowired
private RedisTemplate redisTemplate;

/***
* 库存递减
* @param username
*/
@Override
public void decrCount(String username) {
//获取购物车数据
List<OrderItem> orderItems = redisTemplate.boundHashOps("Cart_" + username).values();

//循环递减
for (OrderItem orderItem : orderItems) {
//递减库存
int count = skuMapper.decrCount(orderItem);
if(count<=0){
throw new RuntimeException("库存不足,递减失败!");
}
}
}

(3)控制层

修改ydles-service-goods的com.ydles.goods.controller.SkuController类,添加库存递减方法,代码如下:

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
/***
* 库存递减
* @param username
* @return
*/
@PostMapping(value = "/decr/count")
public Result decrCount(@RequestParam("username") String username){
//库存递减
skuService.decrCount(username);
return new Result(true,StatusCode.OK,"库存递减成功!");
}

(4)创建feign

同时在ydles-service-goods-api工程添加com.ydles.goods.feign.SkuFeign的实现,代码如下:

From: 元动力
1
2
3
4
5
6
7
/***
* 库存递减
* @param username
* @return
*/
@PostMapping(value = "/decr/count")
Result decrCount(@RequestParam(value = "username") String username);

2.3.3 调用库存递减

需求:

ydles_service_order服务调用 ydles_service_goods服务, ydles_web_order服务的Application启动类

都需要添加下面拦截器

From: 元动力
1
2
3
4
@Bean
public FeignInterceptor feignInterceptor(){
return new FeignInterceptor();
}

ydles_service_goods加入配置信息

From: 元动力
1
2
3
4
5
spring:
application:
name: goods
redis:
host: 192.168.200.128

调用库存递减

修改ydles-service-order微服务的com.ydles.order.service.impl.OrderServiceImpl类的add方法,增加库存递减的调用。

先注入SkuFeign

From: 元动力
1
2
@Autowired
private SkuFeign skuFeign;

再调用库存递减方法

From: 元动力
1
2
//库存减库存
skuFeign.decrCount(order.getUsername());

完整代码如下:

From: 元动力
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
/**
* 增加
* @param order
*/
@Override
public Boolean add(Order order){
//查询出用户的所有购物车
Map orderItemMap = cartService.list(order.getUsername());

//统计计算
Integer totalMoney = Integer.parseInt(String.valueOf(orderItemMap.get("totalPrice")));
Integer num = Integer.parseInt(String.valueOf(orderItemMap.get("totalNum")));

//购买商品数量
order.setTotalNum(num);
//购买金额
order.setTotalMoney(totalMoney);
//支付金额
order.setPayMoney(totalMoney);
//优惠金额
order.setPreMoney(totalMoney);

//其他数据完善
order.setCreateTime(new Date());
order.setUpdateTime(order.getCreateTime());
order.setBuyerRate("0"); //0:未评价,1:已评价
order.setSourceType("1"); //来源,1:WEB
order.setOrderStatus("0"); //0:未完成,1:已完成,2:已退货
order.setPayStatus("0"); //0:未支付,1:已支付,2:支付失败
order.setConsignStatus("0"); //0:未发货,1:已发货,2:已收货
order.setId(idWorker.nextId());
int count = orderMapper.insertSelective(order);

//保存待支付订单到redis中
redisTemplate.boundValueOps(Constants.ORDER_PAY + order.getUsername()).set(order);

//添加订单明细
List<OrderItem> orderItemList = (List<OrderItem>)orderItemMap.get("orderItemList");
for (OrderItem orderItem : orderItemList) {
orderItem.setId(idWorker.nextId());
orderItem.setIsReturn("0");
orderItem.setOrderId(order.getId());
orderItemMapper.insertSelective(orderItem);
}

//扣减库存
skuFeign.decrCount(order.getUsername());

//清除Redis缓存购物车数据
redisTemplate.delete("Cart_"+order.getUsername());
return true;
}

测试

1购物车添加商品

http://localhost:8001/api/wcart/add?skuId=1450862568724758528&num=5open in new window

image-20211206152606093
image-20211206152606093

2查看购物车

http://localhost:8001/api/wcart/listopen in new window

image-20211206152627024
image-20211206152627024

3点击结算

image-20211206152646505
image-20211206152646505
image-20211206152654362
image-20211206152654362

4点击提交订单

image-20211206152743660
image-20211206152743660

tb_order 有数据

image-20211206152848108
image-20211206152848108

tb_order_item也有数据

image-20211206152906228
image-20211206152906228

tb_sku表查看库存和销量是否更改

image-20211206152831101
image-20211206152831101

库存减少前,查询数据库Sku数据如下:95库存,5销量 库存94,销量6

image-20211206152957078
image-20211206152957078

2.4 增加积分(学员练习)

tb_user表

From: 元动力
1
`points` int(11) DEFAULT NULL COMMENT '积分',

需求:

在增加订单的时候,同时添加用户积分。与减库存道理相同。

(1)dao层

修改ydles-service-user微服务的com.ydles.user.dao.UserMapper接口,增加用户积分方法,代码如下:

From: 元动力
1
2
3
4
5
6
7
8
/***
* 增加用户积分
* @param username
* @param pint
* @return
*/
@Update("UPDATE tb_user SET points=points+#{point} WHERE username=#{username}")
int addUserPoints(@Param("username") String username, @Param("point") Integer pint);

(2)业务层

修改ydles-service-user微服务的com.ydles.user.service.UserService接口,代码如下:

From: 元动力
1
2
3
4
5
6
7
/***
* 添加用户积分
* @param username
* @param pint
* @return
*/
int addUserPoints(String username,Integer pint);

修改ydles-service-user微服务的com.ydles.user.service.impl.UserServiceImpl,增加添加积分方法实现,代码如下:

From: 元动力
1
2
3
4
5
6
7
8
9
10
/***
* 修改用户积分
* @param username
* @param pint
* @return
*/
@Override
public int addUserPoints(String username, Integer pint) {
return userMapper.addUserPoints(username,pint);
}

(3)控制层

修改ydles-service-user微服务的com.ydles.user.controller.UserController,添加增加用户积分方法,代码如下:

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Autowired
private TokenDecode tokenDecode;

/***
* 增加用户积分
* @param points:要添加的积分
*/
@GetMapping(value = "/points/add")
public Result addPoints(Integer points){
//获取用户名
Map<String, String> userMap = tokenDecode.getUserInfo();
String username = userMap.get("username");

//添加积分
userService.addUserPoints(username,points);
return new Result(true,StatusCode.OK,"添加积分成功!");
}

(4)Feign添加

修改ydles-service-user-api工程,修改com.ydles.user.feign.UserFeign,添加增加用户积分方法,代码如下:

From: 元动力
1
2
3
4
5
6
7
/***
* 添加用户积分
* @param points
* @return
*/
@GetMapping(value = "/points/add")
Result addPoints(@RequestParam(value = "points")Integer points);

4.4.2 增加积分调用

修改ydles-service-order,添加ydles-service-user-api的依赖,修改pom.xml,添加如下依赖:

From: 元动力
1
2
3
4
5
6
<!--user api 依赖-->
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles-service-user-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

在加订单的时候,同时添加用户积分,修改ydles-service-order微服务的com.ydles.order.service.impl.OrderServiceImpl下单方法,增加调用添加积分方法

修改ydles-service-order的启动类com.ydles.OrderApplication,添加feign的包路径

总结:

1订单结算页

image-20211206153405594
image-20211206153405594
image-20211206153523449
image-20211206153523449
image-20211206153555752
image-20211206153555752

2下单

image-20211206153608375
image-20211206153608375
image-20211206153617157
image-20211206153617157

第12章 分布式事务解决方案

角色:架构师

学习目标:

  • 能够说出cap定理
  • 能够说出BASE定理
  • 能够说出常见的分布式事务解决方案
  • 能够说出seata框架如何在项目中实现分布式事务

1.分布式事务解决方案

​ 刚才我们编写的扣减库存与保存订单是在两个服务中存在的,如果扣减库存后订单保存失败了是不会回滚的,这样就会造成数据不一致的情况,这其实就是我们所说的分布式事务的问题,接下来我们来学习分布式事务的解决方案。

1.1 本地事务与分布式事务

1.1.1 事务

数据库事务(简称:事务,Transaction)是指数据库执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。

事务拥有以下四个特性,习惯上被称为ACID特性:

原子性(Atomicity):事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。

一致性(Consistency):事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态是指数据库中的数据应满足完整性约束。除此之外,一致性还有另外一层语义,就是事务的中间状态不能被观察到(这层语义也有说应该属于原子性)。

隔离性(Isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行,如同只有这一个操作在被数据库所执行一样。

持久性(Durability):已被提交的事务对数据库的修改应该永久保存在数据库中。在事务结束时,此操作将不可逆转。

image-20211208154640726
image-20211208154640726

作业:隔离级别 传播机制

1.1.2 本地事务

起初,事务仅限于对单一数据库资源的访问控制,架构服务化以后,事务的概念延伸到了服务中。倘若将一个单一的服务操作作为一个事务,那么整个服务操作只能涉及一个单一的数据库资源,这类基于单个服务单一数据库资源访问的事务,被称为本地事务(Local Transaction)。

解决方案:@Transactional

image-20211208155006738
image-20211208155006738

1.1.3 分布式事务

分布式事务指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上,且属于不同的应用,分布式事务需要保证这些操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。

最早的分布式事务应用架构很简单,不涉及服务间的访问调用,仅仅是服务内操作涉及到对多个数据库资源的访问。

image-20211208155238640
image-20211208155238640

当一个服务操作访问不同的数据库资源,又希望对它们的访问具有事务特性时,就需要采用分布式事务来协调所有的事务参与者。

对于上面介绍的分布式事务应用架构,尽管一个服务操作会访问多个数据库资源,但是毕竟整个事务还是控制在单一服务的内部。如果一个服务操作需要调用另外一个服务,这时的事务就需要跨越多个服务了。在这种情况下,起始于某个服务的事务在调用另外一个服务的时候,需要以某种机制流转到另外一个服务,从而使被调用的服务访问的资源也自动加入到该事务当中来。下图反映了这样一个跨越多个服务的分布式事务:

image-20211208155459473
image-20211208155459473

如果将上面这两种场景(一个服务可以调用多个数据库资源,也可以调用其他服务)结合在一起,对此进行延伸,整个分布式事务的参与者将会组成如下图所示的树形拓扑结构。在一个跨服务的分布式事务中,事务的发起者和提交均系同一个,它可以是整个调用的客户端,也可以是客户端最先调用的那个服务。

image-20211208155810435
image-20211208155810435

较之基于单一数据库资源访问的本地事务,分布式事务的应用架构更为复杂。在不同的分布式应用架构下,实现一个分布式事务要考虑的问题并不完全一样,比如对多资源的协调、事务的跨服务传播等,实现机制也是复杂多变。

只要是涉及到多个微服务之间远程调用的话,那就回涉及到分布式事务。

分布式事务的作用:

From: 元动力
1
保证每个事务的数据一致性。

1.2 分布式事务相关理论

1.2.1 CAP定理-重点

CAP定理是在 1998年加州大学的计算机科学家 Eric Brewer (埃里克.布鲁尔)提出,分布式系统有三个指标

  • Consistency 强一致性
  • Availability 可用性
  • Partition tolerance 分区容错

它们的第一个字母分别是 C、A、P。Eric Brewer 说,这三个指标不可能同时做到。这个结论就叫做 CAP 定理。

因为:强一致性和可用性 互斥!

image-20211208164142661
image-20211208164142661

真实情况

1 ac 传统项目。ssm管理系统,一个程序,一个数据库。

2 cp 信息重要的场景。手机银行转账。转圈圈,在做数据同步,这段时间内,可用性是没有的。

3 ap 互联网。 放弃强一致性,慢慢数据同步。

image-20211208164958537
image-20211208164958537
分区容错 Partition tolerance

理解: 分布式系统集群中, 一个机器坏掉不应该影响其他机器

大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。分区容错的意思是,区间通信可能失败。比如,一台服务器放在中国,另一台服务器放在美国,这就是两个区,它们之间可能无法通信。

上图中,G1 和 G2 是两台跨区的服务器。G1 向 G2 发送一条消息,G2 可能无法收到。系统设计的时候,必须考虑到这种情况。

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

可用性 Availability

理解: 一个请求, 必须返回一个响应

Availability 中文叫做"可用性",意思是只要收到用户的请求,服务器就必须给出回应。

用户可以选择向 G1 或 G2 发起读操作。不管是哪台服务器,只要收到请求,就必须告诉用户,到底是 v0 还是 v1,否则就不满足可用性。

一致性 Consistency

理解: 一定能读取到最新的数据

Consistency 中文叫做"一致性"。意思是,写操作之后的读操作,必须返回该值。

举例来说,某条记录是 v0,用户向 G1 发起一个写操作,将其改为 v1。

问题是,用户有可能向 G2 发起读操作,由于 G2 的值没有发生变化,因此返回的是 v0。G1 和 G2 读操作的结果不一致,这就不满足一致性了。

为了让 G2 也能变为 v1,就要在 G1 写操作的时候,让 G1 向 G2 发送一条消息,要求 G2 也改成 v1。

一致性和可用性的矛盾

一致性(C)和可用性(A),为什么不可能同时成立?答案很简单,因为可能通信失败(即出现分区容错)。

如果保证 G2 的一致性,那么 G1 必须在写操作时,锁定 G2 的读操作和写操作。只有数据同步后,才能重新开放读写。锁定期间,G2 不能读写,没有可用性(CP)。

如果保证 G2 的可用性,那么势必不能锁定 G2,所以一致性不成立(AP)。

综上所述,G2 无法同时做到一致性和可用性。系统设计时只能选择一个目标。如果追求一致性,那么无法保证所有节点的可用性;如果追求所有节点的可用性,那就没法做到一致性。

1.2.2 BASE理论

BASE:全称:Basically Available(基本可用),Soft state(软状态),和 Eventually consistent(最终一致性)三个短语的缩写,来自 ebay 的架构师提出。BASE 理论是对 CAP 中一致性和可用性权衡的结果,其来源于对大型互联网分布式实践的总结,是基于 CAP 定理逐步演化而来的。其核心思想是:

既是无法做到强一致性(Strong consistency),但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。

Basically Available(基本可用)

理解: 允许服务降级或者允许响应时间受到一定损失

什么是基本可用呢?假设系统,出现了不可预知的故障,但还是能用,相比较正常的系统而言:

  1. 响应时间上的损失:正常情况下的搜索引擎 0.5 秒即返回给用户结果,而基本可用的搜索引擎可以在 1 秒作用返回结果。
  2. 功能上的损失:在一个电商网站上,正常情况下,用户可以顺利完成每一笔订单,但是到了大促期间,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。
Soft state(软状态)

理解: 允许同步数据的时候出现一定时间延迟

什么是软状态呢?相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种 “硬状态”。

软状态指的是:允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。

Eventually consistent(最终一致性)

**理解: 经过一段时间的同步数据之后,最终都能够达到一个一致的状态 **

系统能够保证在没有其他新的更新操作的情况下,数据最终一定能够达到一致的状态,因此所有客户端对系统的数据访问最终都能够获取到最新的值。

1.3 分布式事务解决方案-面试

1.XA两段提交(低效率)-2PC

2.TCC三段提交(3段,高效率[不推荐(补偿代码)])

3.本地消息表(MQ+Table)

4.事务消息(RocketMQ[alibaba])

5.Seata(alibaba)

6.RabbitMQ的ACK机制实现分布式事务(作业)

1.3.1 基于XA协议的两阶段提交 2pc

首先我们来简要看下分布式事务处理的XA规范 :

可知XA规范中分布式事务有AP,RM,TM组成:

其中应用程序(Application Program ,简称AP):AP定义事务边界(定义事务开始和结束)并访问事务边界内的资源。

资源管理器(Resource Manager,简称RM):Rm管理计算机共享的资源,许多软件都可以去访问这些资源,资源包含比如数据库、文件系统、打印机服务器等。

事务管理器(Transaction Manager ,简称TM):负责管理全局事务,分配事务唯一标识,监控事务的执行进度,并负责事务的提交、回滚、失败恢复等。

二阶段协议:

第一阶段TM要求所有的RM准备提交对应的事务分支,询问RM是否有能力保证成功的提交事务分支,RM根据自己的情况,如果判断自己进行的工作可以被提交,那就对工作内容进行持久化,并给TM回执OK;否者给TM的回执NO。RM在发送了否定答复并回滚了已经的工作后,就可以丢弃这个事务分支信息了。

第二阶段TM根据阶段1各个RM prepare的结果,决定是提交还是回滚事务。如果所有的RM都prepare成功,那么TM通知所有的RM进行提交;如果有RM prepare回执NO的话,则TM通知所有RM回滚自己的事务分支。

也就是TM与RM之间是通过两阶段提交协议进行交互的.

优点: 尽量保证了数据的强一致,适合对数据强一致要求很高的关键领域。(其实也不能100%保证强一致)

缺点: 实现复杂,牺牲了可用性,对性能影响较大,不适合高并发高性能场景。

流程看我的图

​ mysql

image-20211208174923396
image-20211208174923396

悲观锁 mysql行级锁 oracle表级锁

两阶段提交协议(Two Phase Commitment Protocol)中,涉及到两种角色

一个事务协调者(coordinator):负责协调多个参与者进行事务投票及提交(回滚) 多个事务参与者(participants):即本地事务执行者

总共处理步骤有两个 (1)投票阶段(voting phase):协调者将通知事务参与者准备提交或取消事务,然后进入表决过程。参与者将告知协调者自己的决策:同意(事务参与者本地事务执行成功,但未提交)或取消(本地事务执行故障); (2)提交阶段(commit phase):收到参与者的通知后,协调者再向参与者发出通知,根据反馈情况决定各参与者是否要提交还是回滚;

1.3.2 TCC补偿机制

TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:

  • Try 阶段主要是对业务系统做检测及资源预留
  • Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
  • Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

例如: A要向 B 转账,思路大概是:

From: 元动力
1
2
3
4
我们有一个本地方法,里面依次调用 
1、首先在 Try 阶段,要先调用远程接口把 B和 A的钱给冻结起来。
2、在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。
3、如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。

优点: 相比两阶段提交,可用性比较强

缺点: 数据的一致性要差一些。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。

流程看我的图:

image-20211208180127771
image-20211208180127771

1.3.3 消息最终一致性-重点

消息最终一致性应该是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理,这种思路是来源于ebay。我们可以从下面的流程图中看出其中的一些细节:

基本思路就是:

消息生产方,需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。

消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。

生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。

优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。

缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。

流程看我的图:

需求:下单同时,减库存

参与者A 订单服务

image-20211208221414338
image-20211208221414338

1、订单服务

1.1 订单表插入数据,订单消息表添加数据101。本地事务,可以控制住。

1.2 定时任务:扫描订单消息表---》mq发一条消息,订单信息

2、商品服务

2.1监听消息队列,收到消息订单详情。

2.2sku 减库存,接受到商品订单的消息表。本地事务,可以控制住。

2.3 定时任务:扫描接受到商品订单的消息表---》mq发一条消息,我已经减库存了

2.4 mq发一条消息 接受到商品订单的消息表 101 staus 2 已经发了消息了

3、订单服务

3.1监听消息队列(已经减库存了) 订单消息表 删除

2. 分布式事务框架seata

2.1 seata简介

​ Seata(原名Fescar) 是阿里18年开源的分布式事务的框架。Fescar的开源对分布式事务框架领域影响很大。作为开源大户,Fescar来自阿里的GTS,经历了好几次双十一的考验,一经开源便颇受关注。后来Fescar改名为Seata。 https://github.com/seata/seataopen in new window

Fescar虽然是二阶段提交协议的分布式事务,但是其解决了XA的一些缺点:
  • 单点问题:虽然目前Fescar(0.4.2)还是单server的,但是Fescar官方预计将会在0.5.x中推出HA-Cluster,到时候就可以解决单点问题。
  • 同步阻塞:Fescar的二阶段,其再第一阶段的时候本地事务就已经提交释放资源了,不会像XA会再两个prepare和commit阶段资源都锁住,并且Fescar,commit是异步操作,也是提升性能的一大关键。
  • 数据不一致:如果出现部分commit失败,那么fescar-server会根据当前的事务模式和分支事务的返回状态的结果来进行不同的重试策略。并且fescar的本地事务会在一阶段的时候进行提交,其实单看数据库来说在commit的时候数据库已经是一致的了。
  • 只能用于单一数据库: Fescar提供了三种模式,AT和TCC和混合模式。在AT模式下事务资源可以是任何支持ACID的数据库,在TCC模式下事务资源没有限制,可以是缓存,可以是文件,可以是其他的等等。当然这两个模式也可以混用。

同时Fescar也保留了接近0业务入侵的优点,只需要简单的配置Fescar的数据代理和加个注解,加一个Undolog表,就可以达到我们想要的目的。

2.2 实现原理

Fescar将一个本地事务做为一个分布式事务分支,所以若干个分布在不同微服务中的本地事务共同组成了一个全局事务,结构如下。

官帮助文档: https://seata.io/zh-cn/index.htmlopen in new window

image-20200304154454364
image-20200304154454364

TM:全局事务管理器,在标注开启fescar分布式事务的服务端开启,并将全局事务发送到TC事务控制端管理

TC:事务控制中心,控制全局事务的提交或者回滚。这个组件需要独立部署维护,目前只支持单机版本,后续迭代计划会有集群版本

RM:资源管理器,主要负责分支事务的上报,本地事务的管理

一段话简述其实现过程:服务起始方发起全局事务并注册到TC。在调用协同服务时,协同服务的事务分支事务会先完成阶段一的事务提交或回滚,并生成事务回滚的undo_log日志,同时注册当前协同服务到TC并上报其事务状态,归并到同一个业务的全局事务中。此时若没有问题继续下一个协同服务的调用,期间任何协同服务的分支事务回滚,都会通知到TC,TC在通知全局事务包含的所有已完成一阶段提交的分支事务回滚。如果所有分支事务都正常,最后回到全局事务发起方时,也会通知到TC,TC在通知全局事务包含的所有分支删除回滚日志。在这个过程中为了解决写隔离和度隔离的问题会涉及到TC管理的全局锁。

(增加订单(branchId=901),减库存(branchId=109)) xid=101

2.3 Fescar模式

Fescar对分布式事务的实现提供了3种模式,AT模式和TCC模式、saga模式:

2.3.1 AT模式

AT模式:主要关注多 DB 访问的数据一致性,实现起来比较简单,对业务的侵入较小,但性能没有TCC高,这种模式推荐大家使用。

AT模式部分代码如下:不需要关注执行状态,对业务代码侵入较小。

From: 元动力
1
2
3
4
5
6
7
8
9
10
/**
* 此代码为示例代码, 不需要演示, 主要看AT和TCC代码的区别使用
*/
@GlobalTransactional(timeoutMills = 300000, name = "dubbo-demo-tx")
public void purchase(String userId, String commodityCode, int orderCount) {
LOGGER.info("purchase begin ... xid: " + RootContext.getXID());
storageService.deduct(commodityCode, orderCount);
orderService.create(userId, commodityCode, orderCount);
throw new RuntimeException("AT 模式发生异常,回滚事务");
}

AT模式的核心是对业务无侵入,是一种改进后的两阶段提交,其设计思路如图:

第一阶段:

核心在于对业务sql进行解析,转换成undolog,两阶段提交往往对资源的锁定需要持续到第二阶段实际的提交或者回滚操作,而有了回滚日志之后,可以在第一阶段释放对资源的锁定,降低了锁范围,提高效率,即使第二阶段发生异常需要回滚,只需找对undolog中对应数据并反解析成sql来达到回滚目的。Seata通过代理数据源将业务sql的执行解析成undolog来与业务数据的更新同时入库,达到了对业务无侵入的效果。

第二阶段:

如果决议是全局提交,此时分支事务此时已经完成提交,不需要同步协调处理(只需要异步清理回滚日志),Phase2 可以非常快速地完成。

如果决议是全局回滚,RM 收到协调器发来的回滚请求,通过 XID 和 Branch ID 找到相应的回滚日志记录,通过回滚记录生成反向的更新 SQL 并执行,以完成分支的回滚。

2.3.3 TCC模式

TCC模式:TCC补偿机制,对代码造成一定的侵入,实现难度较大,这种方式不推荐,不过TCC模式的特点是性能高。

TCC模式部分代码如下:可以看到执行事务回滚,都需要根据不同阶段执行的状态判断,侵入了业务代码。

From: 元动力
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
/**
* 此代码为示例代码, 不需要演示, 主要看AT和TCC代码的区别使用
* 转账操作
* @param from 扣钱账户
* @param to 加钱账户
* @param amount 转账金额
* @return
*/
@Override
@GlobalTransactional
public boolean transfer(final String from, final String to, final double amount) {
//扣钱参与者,一阶段执行
boolean ret = firstTccAction.prepareMinus(null, from, amount);

if(!ret){
//扣钱参与者,一阶段失败; 回滚本地事务和分布式事务
throw new RuntimeException("账号:["+from+"] 预扣款失败");
}

//加钱参与者,一阶段执行
ret = secondTccAction.prepareAdd(null, to, amount);

if(!ret){
throw new RuntimeException("账号:["+to+"] 预收款失败");
}

System.out.println(String.format("transfer amount[%s] from [%s] to [%s] finish.", String.valueOf(amount), from, to));
return true;
}

2.3.3 Saga模式

详见 https://seata.io/zh-cn/docs/dev/mode/saga-mode.htmlopen in new window

3 Seata案例

3.1准备工作

​ 1导入资料中的ydles_common_fescar。注意总父工程中加入模块

From: 元动力
1
<module>ydles_common_fescar</module>

​ 2观察模块中的三个类。

image-20211209000033506

​ 3数据库ydles_order中的undo_log表为记录相关操作的表

image-20211209000144424
image-20211209000144424

4资料中的fescar-server-0.4.2解压,bin目录中双击fescar-server.bat。注意:这是fescar的服务,并且放到一个短目录才能执行。

image-20211209000404624
image-20211209000404624

3.2分布式事务错误演示

1order服务 com.ydles.order.service.impl 的 add方法中增加一个错误 本地事务控制注解增加@Transactional

From: 元动力
1
2
3
4
5
6
7
8
//减库存
skuFeign.decrCount(order.getUsername());

int i=1/0;

// 5)删除购物车中数据
redisTemplate.delete("cart_"+order.getUsername());

2goods 服务 com.ydles.goods.service.impl decrCount减库存方法上增加本地事务控制注解@Transactional

3查看数据库 tb_order tb_order_item中没数据。

image-20211209001159153
image-20211209001159153
image-20211209001206890
image-20211209001206890

tb_sku查看一条数据库存量 100

4登陆购物车并登陆 http://localhost:8001/api/wcart/listopen in new window

5往购物车添加数据 http://localhost:8001/api/wcart/add?skuId=1450862568724758528&num=5open in new window

6购物车页面点击结算到订单页面

image-20211209001242965
image-20211209001242965

7点击提交订单

image-20211209001233900
image-20211209001233900

8代码中 order服务报错

image-20211209001252770
image-20211209001252770

9观察数据库

tb_order tb_order_item没变

image-20211209001159153
image-20211209001159153
image-20211209001206890
image-20211209001206890

tb_sku 1450862568724758528商品库存减少了5

image-20211209001328489
image-20211209001328489

10 为什么

order goods服务是两个服务,即使加上@Transactional也是本地事务控制。

image-20211209001403817
image-20211209001403817

3.3 分布式事务正确演示

1 goods order 增加fescar依赖

From: 元动力
1
2
3
4
5
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_common_fescar</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

2在订单微服务的OrderServiceImpl的add方法上增加@GlobalTransactional(name = "order_add")注解

image-20211209001555985
image-20211209001555985

我的购物车

image-20211209001721316
image-20211209001721316

到结算页

image-20211209001737111
image-20211209001737111

下单失败了

image-20211209001813785
image-20211209001813785

3order 服务重启 观察 fescar控制台输出

4goods 服务重启 观察 fescar控制台输出

5重新提交订单 依然失败 但库存不扣减了

image-20211209001946242
image-20211209001946242

4消息队列实现分布式事务---整个项目的重点

1业务流程-重点

需求:(一次下单---》用户积分增加一次) 事务

image-20211209011831937
image-20211209011831937

1 order

​ 1.1 order task.本地事务控制。

​ 1.2 spring-task扫表 task-----》mq发消息

2user

​ 2.1监听消息 6

​ 2.2 8相当于锁 11释放锁

​ 2.3 mq(另一个队列) task 积分加上了

3order

​ 3.1监听消息 task_his 加上,后期查看,task 删除

2代码实现

2.1准备
1order中添加两张表

​ tb_task 任务表

image-20211209012241987

​ tb_task_his 历史任务表

image-20211209012312800

2order-api中添加对应的实体类
From: 元动力
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
package com.ydles.order.pojo;

import javax.persistence.Column;
import javax.persistence.Id;
import javax.persistence.Table;
import java.util.Date;

@Table(name = "tb_task")
public class Task {
@Id
private Long id;

@Column(name = "create_time")
private Date createTime;

@Column(name = "update_time")
private Date updateTime;

@Column(name = "delete_time")
private Date deleteTime;

@Column(name = "task_type")
private String taskType;

@Column(name = "mq_exchange")
private String mqExchange;

@Column(name = "mq_routingkey")
private String mqRoutingkey;

@Column(name = "request_body")
private String requestBody;

@Column(name = "status")
private String status;

@Column(name = "errormsg")
private String errormsg;

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public Date getCreateTime() {
return createTime;
}

public void setCreateTime(Date createTime) {
this.createTime = createTime;
}

public Date getUpdateTime() {
return updateTime;
}

public void setUpdateTime(Date updateTime) {
this.updateTime = updateTime;
}

public Date getDeleteTime() {
return deleteTime;
}

public void setDeleteTime(Date deleteTime) {
this.deleteTime = deleteTime;
}

public String getTaskType() {
return taskType;
}

public void setTaskType(String taskType) {
this.taskType = taskType;
}

public String getMqExchange() {
return mqExchange;
}

public void setMqExchange(String mqExchange) {
this.mqExchange = mqExchange;
}

public String getMqRoutingkey() {
return mqRoutingkey;
}

public void setMqRoutingkey(String mqRoutingkey) {
this.mqRoutingkey = mqRoutingkey;
}

public String getRequestBody() {
return requestBody;
}

public void setRequestBody(String requestBody) {
this.requestBody = requestBody;
}

public String getStatus() {
return status;
}

public void setStatus(String status) {
this.status = status;
}

public String getErrormsg() {
return errormsg;
}

public void setErrormsg(String errormsg) {
this.errormsg = errormsg;
}
}

From: 元动力
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
package com.ydles.order.pojo;

import javax.persistence.Column;
import javax.persistence.Id;
import javax.persistence.Table;
import java.util.Date;

@Table(name = "tb_task_his")
public class TaskHis {

@Id
private Long id;

@Column(name = "create_time")
private Date createTime;

@Column(name = "update_time")
private Date updateTime;

@Column(name = "delete_time")
private Date deleteTime;

@Column(name = "task_type")
private String taskType;

@Column(name = "mq_exchange")
private String mqExchange;

@Column(name = "mq_routingkey")
private String mqRoutingkey;

@Column(name = "request_body")
private String requestBody;

@Column(name = "status")
private String status;

@Column(name = "errormsg")
private String errormsg;

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public Date getCreateTime() {
return createTime;
}

public void setCreateTime(Date createTime) {
this.createTime = createTime;
}

public Date getUpdateTime() {
return updateTime;
}

public void setUpdateTime(Date updateTime) {
this.updateTime = updateTime;
}

public Date getDeleteTime() {
return deleteTime;
}

public void setDeleteTime(Date deleteTime) {
this.deleteTime = deleteTime;
}

public String getTaskType() {
return taskType;
}

public void setTaskType(String taskType) {
this.taskType = taskType;
}

public String getMqExchange() {
return mqExchange;
}

public void setMqExchange(String mqExchange) {
this.mqExchange = mqExchange;
}

public String getMqRoutingkey() {
return mqRoutingkey;
}

public void setMqRoutingkey(String mqRoutingkey) {
this.mqRoutingkey = mqRoutingkey;
}

public String getRequestBody() {
return requestBody;
}

public void setRequestBody(String requestBody) {
this.requestBody = requestBody;
}

public String getStatus() {
return status;
}

public void setStatus(String status) {
this.status = status;
}

public String getErrormsg() {
return errormsg;
}

public void setErrormsg(String errormsg) {
this.errormsg = errormsg;
}
}

3 ydles_user新增积分日志表
image-20211209012706482
image-20211209012706482
image-20211209012742948
image-20211209012742948
4 ydles_service_user_api添加实体类 PointLog
From: 元动力
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
package com.ydles.user.pojo;

import javax.persistence.Table;

@Table(name = "tb_point_log")
public class PointLog {

private String orderId;
private String userId;
private Integer point;

public String getOrderId() {
return orderId;
}

public void setOrderId(String orderId) {
this.orderId = orderId;
}

public String getUserId() {
return userId;
}

public void setUserId(String userId) {
this.userId = userId;
}

public Integer getPoint() {
return point;
}

public void setPoint(Integer point) {
this.point = point;
}
}
5rabbitMQ

1 order 服务 导包

From: 元动力
1
2
3
4
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit</artifactId>
</dependency>

2config 下 rabbitMQ配置类

From: 元动力
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
package com.ydles.order.config;

import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfig {

//添加积分任务交换机
public static final String EX_BUYING_ADDPOINTUSER = "ex_buying_addpointuser";

//添加积分消息队列
public static final String CG_BUYING_ADDPOINT = "cg_buying_addpoint";

//完成添加积分消息队列
public static final String CG_BUYING_FINISHADDPOINT = "cg_buying_finishaddpoint";

//添加积分路由key
public static final String CG_BUYING_ADDPOINT_KEY = "addpoint";

//完成添加积分路由key
public static final String CG_BUYING_FINISHADDPOINT_KEY = "finishaddpoint";

//声明交换机
@Bean(EX_BUYING_ADDPOINTUSER)
public Exchange EX_BUYING_ADDPOINTUSER(){
return ExchangeBuilder.directExchange(EX_BUYING_ADDPOINTUSER).durable(true).build();
}

//声明队列
@Bean(CG_BUYING_ADDPOINT)
public Queue CG_BUYING_ADDPOINT(){
Queue queue = new Queue(CG_BUYING_ADDPOINT);
return queue;
}
@Bean(CG_BUYING_FINISHADDPOINT)
public Queue CG_BUYING_FINISHADDPOINT(){
Queue queue = new Queue(CG_BUYING_FINISHADDPOINT);
return queue;
}

//队列绑定交换机
@Bean
public Binding BINDING_CG_BUYING_ADDPOINT(@Qualifier(CG_BUYING_ADDPOINT) Queue queue, @Qualifier(EX_BUYING_ADDPOINTUSER)Exchange exchange){
return BindingBuilder.bind(queue).to(exchange).with(CG_BUYING_ADDPOINT_KEY).noargs();
}
@Bean
public Binding BINDING_CG_BUYING_FINISHADDPOINT(@Qualifier(CG_BUYING_FINISHADDPOINT) Queue queue,@Qualifier(EX_BUYING_ADDPOINTUSER)Exchange exchange){
return BindingBuilder.bind(queue).to(exchange).with(CG_BUYING_FINISHADDPOINT_KEY).noargs();
}
}

3配置文件 rabbitmq

From: 元动力
1
2
rabbitmq:
host: 192.168.200.128
2.2 订单服务逻辑

需求:

image-20211209014056551
image-20211209014056551
1dao层 增加mapper
From: 元动力
1
2
3
4
5
6
7
package com.ydles.order.dao;

import com.ydles.order.pojo.Task;
import tk.mybatis.mapper.common.Mapper;

public interface TaskMapper extends Mapper<Task> {
}
From: 元动力
1
2
3
4
5
6
7
package com.ydles.order.dao;

import com.ydles.order.pojo.TaskHis;
import tk.mybatis.mapper.common.Mapper;

public interface TaskHisMapper extends Mapper<TaskHis> {
}
2下单逻辑中增加任务
From: 元动力
1
2
@Autowired
TaskMapper taskMapper;
From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// int i=1/0;
//添加任务数据
System.out.println("向订单数据库中的任务表去添加任务数据");
Task task = new Task();
task.setCreateTime(new Date());
task.setUpdateTime(new Date());
task.setMqExchange(RabbitMQConfig.EX_BUYING_ADDPOINTUSER);
task.setMqRoutingkey(RabbitMQConfig.CG_BUYING_ADDPOINT_KEY);

Map map = new HashMap();
map.put("username",order.getUsername());
map.put("orderId",orderId);
map.put("point",order.getPayMoney());
task.setRequestBody(JSON.toJSONString(map));
taskMapper.insertSelective(task);
3定时任务,定时发送任务表信息到mq

启动类增加

From: 元动力
1
@EnableScheduling //开启定时任务

新建task包,加入定时任务类

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class QueryPointTask {
@Autowired
private TaskMapper taskMapper;
@Autowired
private RabbitTemplate rabbitTemplate;
@Scheduled(cron = "0/2 * * * * ?")
public void queryTask(){
//1. 获取小于系统当前时间的数据
List<Task> taskList = taskMapper.findTaskLessThanCurrentTime(new Date());
if (taskList != null && taskList.size()>0){
//2.将任务发送到消息队列上
for (Task task : taskList) {
rabbitTemplate.convertAndSend(RabbitMQConfig.EX_BUYING_ADDPOINTUSER, RabbitMQConfig.CG_BUYING_ADDPOINT_KEY, JSON.toJSONString(task));
System.out.println("订单服务向添加积分队列发送了一条消息");
}
}
}
}

taskmapper中自定义查询小于当前时间的方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
@Select("select * from tb_task where update_time<#{currentTime}")
@Results({@Result(column = "create_time",property = "createTime"),
@Result(column = "update_time",property = "updateTime"),
@Result(column = "delete_time",property = "deleteTime"),
@Result(column = "task_type",property = "taskType"),
@Result(column = "mq_exchange",property = "mqExchange"),
@Result(column = "mq_routingkey",property = "mqRoutingkey"),
@Result(column = "request_body",property = "requestBody"),
@Result(column = "status",property = "status"),
@Result(column = "errormsg",property = "errormsg")})
List<Task> findTaskLessThanCurrentTime(Date currentTime);
2.3 用户服务逻辑

需求:

image-20211209021057873
image-20211209021057873
1配置文件增加redis rabbitMQ配置
From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
application:
name: user
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://192.168.200.128:3306/ydles_user?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
username: root
password: root
main:
allow-bean-definition-overriding: true #当遇到同样名字的时候,是否允许覆盖注册
redis:
host: 192.168.200.128
rabbitmq:
host: 192.168.200.128
2导入mq依赖
From: 元动力
1
2
3
4
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit</artifactId>
</dependency>
3mq配置类

RabbitMQConfig ,从order服务copy

4添加依赖
From: 元动力
1
2
3
4
5
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_service_order_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
4监听mq队列

com.ydles.user.listener创建AddPointListener

From: 元动力
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
package com.ydles.user.listener;

import com.alibaba.fastjson.JSON;
import com.ydles.order.pojo.Task;
import com.ydles.user.config.RabbitMQConfig;
import com.ydles.user.service.UserService;
import org.apache.commons.lang.StringUtils;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class AddPointListener {

@Autowired
private RedisTemplate redisTemplate;

@Autowired
private UserService userService;

@Autowired
private RabbitTemplate rabbitTemplate;

@RabbitListener(queues = RabbitMQConfig.CG_BUYING_ADDPOINT)
public void receiveAddPointMessage(String message){
System.out.println("用户服务接收到了任务消息");

//转换消息
Task task = JSON.parseObject(message, Task.class);
if (task == null || StringUtils.isEmpty(task.getRequestBody())){
return;
}

//判断redis中当前的任务是否存在
Object value = redisTemplate.boundValueOps(task.getId()).get();
if (value != null){
return;
}

}
5更新积分方法实现

1userService 定义接口

From: 元动力
1
int updateUserPoint(Task task);

实现类

From: 元动力
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
@Override
@Transactional
public int updateUserPoint(Task task) {
System.out.println("用户服务现在开始对任务进行处理");
//1.从task中获取相关数据
Map map = JSON.parseObject(task.getRequestBody(), Map.class);
String username = map.get("username").toString();
String orderId = map.get("orderId").toString();
int point = (int) map.get("point");

//2.判断当前的任务是否操作过
PointLog pointLog = pointLogMapper.findPointLogByOrderId(orderId);
if (pointLog != null){
return 0;
}

//3.将任务存入到redis中
redisTemplate.boundValueOps(task.getId()).set("exist",30, TimeUnit.SECONDS);

//4.修改用户积分
int result = userMapper.updateUserPoint(username,point);
if (result<=0){
return 0;
}

//5.记录积分日志信息
pointLog = new PointLog();
pointLog.setUserId(username);
pointLog.setOrderId(orderId);
pointLog.setPoint(point);
result = pointLogMapper.insertSelective(pointLog);
if (result <= 0){
return 0;
}

//6.删除redis中的任务信息
redisTemplate.delete(task.getId());
System.out.println("用户服务完成了更改用户积分的操作");
return 1;
}

2dao层新建mapper,查询当前任务是否操作过

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
package com.ydles.user.dao;

import com.ydles.user.pojo.PointLog;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import tk.mybatis.mapper.common.Mapper;

public interface PointLogMapper extends Mapper<PointLog> {

@Select("select * from tb_point_log where order_id =#{orderId}")
PointLog findPointLogByOrderId(@Param("orderId") String orderId);
}

3userMapper新增一个修改用户积分方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
package com.ydles.user.dao;

import com.ydles.user.pojo.User;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import tk.mybatis.mapper.common.Mapper;

public interface UserMapper extends Mapper<User> {

@Update("update tb_user set points=points+#{point} where username=#{username}")
int updateUserPoint(@Param("username")String username, @Param("point") int point);
}

4AddPointListener完善

From: 元动力
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
package com.ydles.user.listener;

import com.alibaba.fastjson.JSON;
import com.ydles.order.pojo.Task;
import com.ydles.user.config.RabbitMQConfig;
import com.ydles.user.service.UserService;
import org.apache.commons.lang.StringUtils;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class AddPointListener {

@Autowired
private RedisTemplate redisTemplate;

@Autowired
private UserService userService;

@Autowired
private RabbitTemplate rabbitTemplate;

@RabbitListener(queues = RabbitMQConfig.CG_BUYING_ADDPOINT)
public void receiveAddPointMessage(String message){
System.out.println("用户服务接收到了任务消息");

//1转换消息
Task task = JSON.parseObject(message, Task.class);
if (task == null || StringUtils.isEmpty(task.getRequestBody())){
return;
}

//2判断redis中当前的任务是否存在
Object value = redisTemplate.boundValueOps(task.getId()).get();
if (value != null){
return;
}

//3更新用户积分
int result = userService.updateUserPoint(task);
if (result == 0){
return;
}

//4向订单服务返回通知消息
rabbitTemplate.convertAndSend(RabbitMQConfig.EX_BUYING_ADDPOINTUSER,RabbitMQConfig.CG_BUYING_FINISHADDPOINT_KEY,JSON.toJSONString(task));
System.out.println("用户服务向完成添加积分队列发送了一条消息");
}
}
2.4订单服务收尾

需求:

image-20211209025157447
image-20211209025157447

建立监听类DelTaskListener

From: 元动力
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
package com.ydles.order.listener;

import com.alibaba.fastjson.JSON;
import com.ydles.order.config.RabbitMQConfig;
import com.ydles.order.pojo.Task;
import com.ydles.order.service.TaskService;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class DelTaskListener {

@Autowired
private TaskService taskService;

@RabbitListener(queues = RabbitMQConfig.CG_BUYING_FINISHADDPOINT)
public void receiveDelTaskMessage(String message){
System.out.println("订单服务接收到了删除任务操作的消息");

Task task = JSON.parseObject(message, Task.class);

//删除原有的任务数据,并向历史任务表中添加记录
taskService.delTask(task);
}
}

创建taskService写出删除任务的方法

From: 元动力
1
2
3
public interface TaskService {
void delTask(Task task);
}

实现taskService

From: 元动力
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
package com.ydles.order.service.impl;

import com.ydles.order.dao.TaskHisMapper;
import com.ydles.order.dao.TaskMapper;
import com.ydles.order.pojo.Task;
import com.ydles.order.pojo.TaskHis;
import com.ydles.order.service.TaskService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Date;

@Service
public class TaskServiceImpl implements TaskService {

@Autowired
private TaskHisMapper taskHisMapper;

@Autowired
private TaskMapper taskMapper;

@Override
@Transactional
public void delTask(Task task) {

//1.记录删除时间
task.setDeleteTime(new Date());
Long taskId = task.getId();
task.setId(null);

//2bean拷贝
TaskHis taskHis = new TaskHis();
BeanUtils.copyProperties(task,taskHis);

//3记录历史任务数据
taskHisMapper.insertSelective(taskHis);

//4删除原有任务数据
//taskMapper.deleteByPrimaryKey(id);
task.setId(id);
taskMapper.delete(task);
System.out.println("订单服务完成了添加历史任务并删除原有任务的操作");
}
}
2.5效果测试

order user服务以dubug打开。

从头到最后一步一步测试。关键点:

1order下单任务表中数据

image-20211209031308844
image-20211209031308844

2order 定时任务扫描表,发信息

image-20211209031315436
image-20211209031315436

3user收到信息,检查redis,修改积分

image-20211209031335319
image-20211209031335319
image-20211209031344811
image-20211209031344811

4order收到成功消息

image-20211209031259362
image-20211209031259362

总结:

1分布式事务解决方案

事务:ACID 面试必问,隔离级别,传播行为

本地事务:@transactional

分布式事务

2解决方案

2.1xa 2pc mysql

image-20211208174923396
image-20211208174923396

2.2 tcc

image-20211208180127771
image-20211208180127771

2.3 消息队列

image-20211208221414338
image-20211208221414338

3Seata

4下单 使用消息队列实现分布式事务

image-20211209011831937
image-20211209011831937

第13章 微信扫码支付

角色: 支付相关模块的开发工程师,难。

学习目标:

  • 能够根据微信支付的开发文档调用微信支付的api
  • 完成统一下单生成微信支付二维码功能
  • 完成支付回调的逻辑处理
  • 完成推送支付通知功能

1. 微信支付快速入门

native 支付文档:https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=6_1open in new window

1.1 微信支付申请(了解)

第一步:注册公众号(类型须为:服务号)

请根据营业执照类型选择以下主体注册:个体工商户open in new window企业/公司open in new window政府open in new window媒体open in new window其他类型open in new window

第二步:认证公众号

公众号认证后才可申请微信支付,认证费:300元/次

第三步:提交资料申请微信支付

登录公众平台,点击左侧菜单【微信支付】,开始填写资料等待审核,审核时间为1-5个工作日内。

第四步:开户成功,登录商户平台进行验证

资料审核通过后,请登录联系人邮箱查收商户号和密码,并登录商户平台填写财付通备付金打的小额资金数额,完成账户验证。

第五步:在线签署协议

本协议为线上电子协议,签署后方可进行交易及资金结算,签署完立即生效。

本课程已经提供好“元动力教育”的微信支付账号,学员无需申请。

完成上述步骤,你可以得到调用API用到的账号和密钥

!!!!真实地测试,所以,我们写的接口,钱都会给我们元动力。钱一定设置成1分钱。

appid:微信公众账号或开放平台APP的唯一标识 wxababcd122d1618eb

mch_id:商户号  1611671554

key:商户密钥 ydlclass66666688888YDLCLASS66688

1.2 微信支付开发文档与SDK

在线微信支付开发文档:

https://pay.weixin.qq.com/wiki/doc/api/index.htmlopen in new window

https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=9_1open in new window

微信支付接口调用的整体思路:

按API要求组装参数,以XML方式发送(POST)给微信支付接口(URL),微信支付接口也是以XML方式给予响应。程序根据返回的结果(其中包括支付URL)生成二维码或判断订单状态。

我们解压从官网下载的sdk ,安装到本地仓库

com.github.wxpay.sdk.WXPay类下提供了对应的方法:

方法名说明
microPay刷卡支付
unifiedOrder统一下单
orderQuery查询订单
reverse撤销订单
closeOrder关闭订单
refund申请退款
refundQuery查询退款
downloadBill下载对账单
report交易保障
shortUrl转换短链接
authCodeToOpenid授权码查询openid

测试工程

1创建test_pay ,导入依赖。将我的仓库com.github.wxpay相关包发给学生。

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
<dependencies>
<dependency>
<groupId>com.github.wxpay</groupId>
<artifactId>wxpay-sdk</artifactId>
<version>3.0.9</version>
</dependency>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.2</version>
</dependency>
</dependencies>

2创建配置类 注意:包名必须为com.github.wxpay.sdk

From: 元动力
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
package com.github.wxpay.sdk;

import java.io.InputStream;

public class MyConfig extends WXPayConfig {
//正式
public String getAppID() {
return "wxababcd122d1618eb";
}

public String getMchID() {
return "1611671554";
}

//测试
public String getKey() {
return "ydlclass66666688888YDLCLASS66688";
}
//正式
//public String getKey() {
// return "0c804332f41cbf77b014d7096ae900f6";
//}

public InputStream getCertStream() {
return null;
}

public IWXPayDomain getWXPayDomain() {
return new IWXPayDomain() {
public void report(String s, long l, Exception e) {
}

public DomainInfo getDomain(WXPayConfig wxPayConfig) {
return new DomainInfo("api.mch.weixin.qq.com", true);
}
};
}
}

3测试类 包名随意

From: 元动力
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
package ydles.test;

import com.github.wxpay.sdk.MyConfig;
import com.github.wxpay.sdk.WXPay;

import java.util.HashMap;
import java.util.Map;

/**
* creste by ydles.itcast
*/
public class PayTest {
public static void main(String[] args) throws Exception {
MyConfig myConfig=new MyConfig();
WXPay wxPay=new WXPay(myConfig);

//根据官方文档 必填值封装数据
Map<String,String> map = new HashMap<>();
map.put("body", "元动力二奢");
map.put("out_trade_no", "123654");
map.put("total_fee", "1");
map.put("spbill_create_ip", "127.0.0.1");
map.put("notify_url", "http://www.baidu.com");
map.put("trade_type", "NATIVE");

Map<String, String> resultMap = wxPay.unifiedOrder(map);
System.out.println(resultMap);

}
}

4得到

From: 元动力
1
{nonce_str=l1pTBT12JvPIN0lO, code_url=weixin://wxpay/bizpayurl?pr=PFNjrIXzz, appid=wxababcd122d1618eb, sign=FF218956CA8C00145F4BACBC0AA843012E2DE2498FAAD4809769EB367582340A, trade_type=NATIVE, return_msg=OK, result_code=SUCCESS, mch_id=1611671554, return_code=SUCCESS, prepay_id=wx15174918803690023343d15a9737140000}

QRcode

1打开资源文件夹 weixinpay.html

image-20211215175333830
image-20211215175333830

支付图片为qrcode.js生成。修改源代码为本人生成的,即可微信扫描。

From: 元动力
1
qrcode.makeCode("weixin://wxpay/bizpayurl?pr=PFNjrIXzz");
image-20211215175246842
image-20211215175246842

2. 微信支付二维码

2.1 需求分析

用户在提交订单后,如果是选择支付方式为微信支付,那应该跳转到微信支付二维码页面,用户扫描二维码可以进行支付,金额与订单金额相同。

流程:商品详情页-----》购物车-----》订单----》支付方式pay.html------》微信支付页weixinpay.html--------》支付成功

image-20211221110506739
image-20211221110506739

2.2 实现思路

前端页面向后端传递订单号,后端根据订单号查询订单,检查是否为当前用户的未支付订单,如果是则根据订单号和金额生成支付url返给前端,前端得到支付url生成支付二维码。

2.3 代码实现

2.3.1 提交订单跳转支付页

订单----》支付方式pay.html

1更新ydles_service_order

OrderServiceImpl中add() ,设置返回值为订单Id

From: 元动力
1
public String add(Order order) 
From: 元动力
1
2
//最后返回
return order.getId();

OrderService

From: 元动力
1
String add(Order order);

OrderController

From: 元动力
1
2
String orderId = orderService.add(order);
return new Result(true,StatusCode.OK,"添加成功",orderId);

2修改web-order中提交订单页面,下单成功跳转至选择支付页面。

From: 元动力
1
2
3
4
5
6
7
8
if (response.data.flag){
//添加订单成功
alert("添加订单成功");
var orderId = response.data.data;
location.href="/api/worder/toPayPage?orderId="+orderId;
} else{
alert("添加订单失败");
}

3在OrderController中新增方法,用于跳转支付页

From: 元动力
1
2
3
4
5
6
7
8
@GetMapping("/toPayPage")
public String toPayPage(String orderId,Model model){
//获取到订单的相关信息
Order order = orderFeign.findById(orderId).getData();
model.addAttribute("orderId",orderId);
model.addAttribute("payMoney",order.getPayMoney());
return "pay";
}

4order服务中已经有方法:orderController中 根据订单id查询订单信息 findById

From: 元动力
1
2
3
4
5
6
7
8
9
10
/***
* 根据ID查询数据
* @param id
* @return
*/
@GetMapping("/{id}")
public Result findById(@PathVariable String id){
Order order = orderService.findById(id);
return new Result(true,StatusCode.OK,"查询成功",order);
}

把它暴露出去 OrderFeign

From: 元动力
1
2
3
4
5
6
7
8
9
10
@FeignClient(name = "order")
public interface OrderFeign {

@PostMapping("/order")
public Result add(@RequestBody Order order);


@GetMapping("/order/{id}")
public Result<Order> findById(@PathVariable String id);
}

5相关页面放至resources

6测试

登陆 http://localhost:8001/api/oauth/toLoginopen in new window

添加商品: http://localhost:8001/api/wcart/add?skuId=1450862568724758528&num=5open in new window

查看购物车: http://localhost:8001/api/wcart/listopen in new window

image-20211221114601627
image-20211221114601627

点击结算

image-20211221114628720
image-20211221114628720

点击提交订单,跳转至支付页面

image-20211221114637493
image-20211221114637493

2.3.2 支付微服务权限集成

image-20211221144256113
image-20211221144256113

1service二级目录下,新建微服务ydles_service_pay

2依赖

From: 元动力
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
<dependencies>
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.github.wxpay</groupId>
<artifactId>wxpay-sdk</artifactId>
<version>3.0.9</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
</dependencies>

3配置文件 application.yml

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server:
port: 9010
spring:
application:
name: pay
rabbitmq:
host: 192.168.200.128
main:
allow-bean-definition-overriding: true #当遇到同样名字的时候,是否允许覆盖注册
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
instance:
prefer-ip-address: true
wxpay:
notify_url: http://itlils.cross.echosite.cn/wxpay/notify #回调地址

4微信支付配置类,copy。注意包名

From: 元动力
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
package com.github.wxpay.sdk;

import java.io.InputStream;

/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
* 微信支付配置类,放着商户的相关信息
*/
public class MyConfig extends WXPayConfig{

@Override
String getAppID() {
return "wxababcd122d1618eb";
}

@Override
String getMchID() {
return "1611671554";
}

@Override
String getKey() {
return "ydlclass66666688888YDLCLASS66688";
}

@Override
InputStream getCertStream() {
return null;
}

@Override
IWXPayDomain getWXPayDomain() {
return new IWXPayDomain() {
@Override
public void report(String s, long l, Exception e) {

}

@Override
public DomainInfo getDomain(WXPayConfig wxPayConfig) {
return new DomainInfo("api.mch.weixin.qq.com", true);
}
};
}
}

5启动类 com.ydles.pay下

From: 元动力
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
package com.ydles.pay;
import com.github.wxpay.sdk.MyConfig;
import com.github.wxpay.sdk.WXPay;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
@EnableEurekaClient
public class PayApplication {

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

@Bean
public WXPay wxPay(){
try {
return new WXPay(new MyConfig());
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}

2.3.3 支付微服务-下单

ydles_service_pay服务

(1)创建com.ydles.pay.service包,包下创建接口WxPayService

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.ydles.pay.service;

import java.util.Map;

/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
public interface WxPayService {
//微信下单要二维码
public Map nativePay(String orderId,Integer money);
}

(2)创建com.ydles.pay.service.impl 包 ,新增服务类WxPayServiceImpl 拓展:涉及钱的计算,注意用BigDecimal https://www.cnblogs.com/zhangyinhua/p/11545305.htmlopen in new window

From: 元动力
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
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
@Service
public class WxPayServiceImpl implements WxPayService {
@Autowired
WXPay wxPay;

@Override
public Map nativePay(String orderId, Integer money) {
try {
//根据官方文档 请求接口
Map<String, String> reqData=new HashMap<>();
reqData.put("body","动力二奢下单支付");
reqData.put("out_trade_no", orderId);

//金钱计算 BigDecimal:金额和计算都用他的
BigDecimal yuan=new BigDecimal("0.01");
BigDecimal beishu=new BigDecimal(100);
BigDecimal fen = yuan.multiply(beishu);
fen=fen.setScale(0,BigDecimal.ROUND_UP);
reqData.put("total_fee", String.valueOf(fen));

reqData.put("spbill_create_ip", "192.168.1.1");
reqData.put("notify_url", "http://www.baidu.com");
reqData.put("trade_type", "NATIVE");

Map<String, String> resultMap = wxPay.unifiedOrder(reqData);
System.out.println(resultMap);
return resultMap;
}catch (Exception e){
e.printStackTrace();
return null;
}
}
}

(3)创建com.ydles.pay.controller包 ,新增WxPayController

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
@RestController
@RequestMapping("/wxpay")
public class WxPayController {
@Autowired
WxPayService wxPayService;

//微信下单
@GetMapping("/nativePay")
public Result<Map> nativePay(@RequestParam("orderId")String orderId,@RequestParam("money")Integer money){
Map map = wxPayService.nativePay(orderId, money);
return new Result(true, StatusCode.OK,"微信下单成功",map);
}
}

测试:http://localhost:9010/wxpay/nativePay?orderId=123321&money=1open in new window 订单号可能重复,大家换一下就好了。

image-20211221150107575
image-20211221150107575

2.3.3 支付渲染页面微服务

支付方式pay.html------》微信支付页weixinpay.html

页面需要调用我们的微服务,所以:

image-20211221151905932
image-20211221151905932

(1)新增ydles_service_pay_api模块 ,pom.xml中加入依赖

From: 元动力
1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

新增com.ydles.pay.feign包,包下创建接口

From: 元动力
1
2
3
4
5
6
@FeignClient(name = "pay")
public interface PayFeign {

@GetMapping("/wxpay/nativePay")
public Result nativePay(@RequestParam("orderId") String orderId, @RequestParam("money") Integer money);
}

(2)ydles_web_order的pom.xml加入依赖

From: 元动力
1
2
3
4
5
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_service_pay_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

启动类增加扫包

From: 元动力
1
@EnableFeignClients(basePackages = {"com.ydles.pay.feign","com.ydles.order.feign","com.ydles.user.feign"})
image-20211221152256881
image-20211221152256881

ydles_web_order新增PayController

From: 元动力
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
@Controller
@RequestMapping("/wxpay")
public class PayController {

@Autowired
private OrderFeign orderFeign;

@Autowired
private PayFeign payFeign;

//跳转到微信支付二维码页面
@GetMapping
public String wxPay(String orderId , Model model){
//1.根据orderid查询订单,如果订单不存在,跳转到错误页面
Result<Order> orderResult = orderFeign.findById(orderId);
if (orderResult.getData() == null){
return "fail";
}

//2.根据订单的支付状态进行判断,如果不是未支付的订单,跳转到错误页面
Order order = orderResult.getData();
if (!"0".equals(order.getPayStatus())){
return "fail";
}

//3.基于payFeign调用统计下单接口,并获取返回结果
Result payResult = payFeign.nativePay(orderId, order.getPayMoney());
if (payResult.getData() == null){
return "fail";
}

//4.封装结果数据
Map payMap = (Map) payResult.getData();
payMap.put("orderId",orderId);
payMap.put("payMoney",order.getPayMoney());

model.addAllAttributes(payMap);
return "wxpay";
}

(4)将静态原型中wxpay.html拷贝到templates文件夹下作为模板,修改模板,部分代码如下:

二维码地址渲染

From: 元动力
1
qrcode.makeCode([[${code_url}]]);

查看显示订单号与金额

From: 元动力
1
2
<h4 class="fl tit-txt"><span class="success-icon"></span><span  class="success-info" th:text="|订单提交成功,请您及时付款!订单号:${orderId}|"></span></h4>
<span class="fr"><em class="sui-lead">应付金额:</em><em class="orange money" th:text="${#numbers.formatDecimal(payMoney/100.0,1,2)}"></em></span>

pay.html

From: 元动力
1
<li><a th:href="|/api/wxpay?orderId=${orderId}|"><img src="/./image/_/pay3.jpg"></a> </li>
image-20211221154450109
image-20211221154450109

ydles_gateway_web项目的application.yml文件加入

From: 元动力
1
2
3
4
5
6
7
#购物车订单渲染微服务
- id: ydles_order_web_route
uri: lb://order-web
predicates:
- Path=/api/wcart/**,/api/worder/**,/api/wxpay/**
filters:
- StripPrefix=1

查看ydles_gateway_web项目的UrlFilter.java中

From: 元动力
1
public static String orderFilterPath = "/api/wpay,/api/wpay/**,/api/worder/**,/api/user/**,

测试

1重启gatewaty-web service-pay web-order

2添加商品: http://localhost:8001/api/wcart/add?skuId=1450862568724758528&num=5open in new window

image-20211221162207178
image-20211221162207178

3查看购物车: http://localhost:8001/api/wcart/listopen in new window

image-20211221162214876
image-20211221162214876

4点击:结算---》提交订单--》选择微信支付--》跳到二维码页面--》扫描二维码,你将损失1分钱。

image-20211221200858087
image-20211221200858087

3. 支付回调逻辑处理

image-20211221203416360
image-20211221203416360

3.1 需求分析

在完成支付后,修改订单状态为已支付,并记录订单日志。

3.2 实现思路

(1)接受微信支付平台的回调信息(xml)

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<xml><appid><![CDATA[wx8397f8696b538317]]></appid>
<bank_type><![CDATA[CFT]]></bank_type>
<cash_fee><![CDATA[1]]></cash_fee>
<fee_type><![CDATA[CNY]]></fee_type>
<is_subscribe><![CDATA[N]]></is_subscribe>
<mch_id><![CDATA[1473426802]]></mch_id>
<nonce_str><![CDATA[c6bea293399a40e0a873df51e667f45a]]></nonce_str>
<openid><![CDATA[oNpSGwbtNBQROpN_dL8WUZG3wRkM]]></openid>
<out_trade_no><![CDATA[1553063775279]]></out_trade_no>
<result_code><![CDATA[SUCCESS]]></result_code>
<return_code><![CDATA[SUCCESS]]></return_code>
<sign><![CDATA[DD4E5DF5AF8D8D8061B0B8BF210127DE]]></sign>
<time_end><![CDATA[20190320143646]]></time_end>
<total_fee>1</total_fee>
<trade_type><![CDATA[NATIVE]]></trade_type>
<transaction_id><![CDATA[4200000248201903206581106357]]></transaction_id>
</xml>

(2)收到通知后,调用查询接口查询订单。

(3)如果支付结果为成功,则调用修改订单状态和记录订单日志的方法。

3.3 代码实现

3.3.1 内网穿透工具

natapp https://natapp.cn/open in new window

image-20211221204125489
image-20211221204125489

3.3.2下载配置文件和客户端

如何使用:https://natapp.cn/article/natapp_newbieopen in new window

解压资料,将配置文件与程序平行放置。修改配置文件。

image-20211221205259145
image-20211221205259145
image-20211221205320127
image-20211221205320127

3.3.3 启动

双击natapp.exe

image-20211221205424476
image-20211221205424476

外网地址默认映射本地的80端口 可以修改

image-20211221205450080
image-20211221205450080

3.3.4 测试

1ydles_service_pay 微服务 WXPayController 增加一个测试方法

From: 元动力
1
2
3
4
@RequestMapping("/notify")
public void notifyLogic() {
System.out.println("支付成功回调");
}

2重启ydles_service_pay

3访问 http://localhost:9010/wxpay/notifyopen in new window 程序打印输出

image-20211221205220246
image-20211221205220246

4访问 http://lizihao.cross.echosite.cn/wxpay/notifyopen in new window 程序打印输出

image-20211221205221905
image-20211221205221905

3.3.5 接收回调信息

1 修改支付微服务配置文件

From: 元动力
1
2
wxpay:
notify_url: http://mp93g5.natappfree.cc/wxpay/notify #回调地址

2修改WxPayServiceImpl ,引入

From: 元动力
1
2
@Value("${wxpay.notify_url}")
private String notifyUrl;

3修改WxPayServiceImpl 的nativePay方法

From: 元动力
1
map.put("notify_url",notifyUrl);//回调地址

4测试:

4.1重启ydles_service_pay

4.2添加商品: http://localhost:8001/api/wcart/add?skuId=1450862568724758528&num=5open in new window

4.3查看购物车: http://localhost:8001/api/wcart/listopen in new window

image-20211221232224662
image-20211221232224662

4.4点击:结算---》提交订单--》选择微信支付--》跳到二维码页面--》扫描二维码 查看可以回调多次

image-20211221232328432
image-20211221232328432

为什么多次回调? https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=9_7&index=8open in new window

From: 元动力
1
2
3
注意:
1、同样的通知可能会多次发送给商户系统。商户系统必须能够正确处理重复的通知。
2、后台通知交互时,如果微信收到商户的应答不符合规范或超时,微信会判定本次通知失败,重新发送通知,直到成功为止(在通知一直不成功的情况下,微信总共会发起多次通知,通知频率为15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h - 总计 24h4m),但微信不保证通知最终一定能成功。

3.3.6 处理回调通知-重点

1资源文件夹 ConvertUtils 放到 common工程的utils包下

2微信支付平台发送给回调地址的是二进制流,我们需要提取二进制流转换为字符串,这个字符串就是xml格式。

修改notify方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 回调
*/
@RequestMapping("/notify")
public String wxPayNotify(HttpServletRequest request, HttpServletResponse response) throws IOException {
System.out.println("支付成功回调。。。。");
try {
//1输入流转换为字符串
String xml = ConvertUtils.convertToString(request.getInputStream());
System.out.println(xml);

//如果成功,给微信支付一个成功的响应
//响应数据设置
//2给微信一个结果通知
response.setContentType("text/xml");
String data = "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>";
response.getWriter().write(data);

} catch (Exception e) {
e.printStackTrace();
}
}

**注意:**1、同样的通知可能会多次发送给商户系统。商户系统必须能够正确处理重复的通知。2、后台通知交互时,如果微信收到商户的应答不符合规范或超时,微信会判定本次通知失败,重新发送通知,直到成功为止(在通知一直不成功的情况下,微信总共会发起10次通知,通知频率为15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h - 总计 24h4m),但微信不保证通知最终一定能成功。

测试后,在控制台看到输出的消息

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
访问到 notify接口了!
<xml><appid><![CDATA[wxababcd122d1618eb]]></appid>
<bank_type><![CDATA[OTHERS]]></bank_type>
<cash_fee><![CDATA[1]]></cash_fee>
<fee_type><![CDATA[CNY]]></fee_type>
<is_subscribe><![CDATA[Y]]></is_subscribe>
<mch_id><![CDATA[1611671554]]></mch_id>
<nonce_str><![CDATA[WNXFCMMCQSYiM09qrr4nwDH41iJnwuSs]]></nonce_str>
<openid><![CDATA[o9tV755anFQYm27bYNC1ALQ5Ovfs]]></openid>
<out_trade_no><![CDATA[1473316925973991424]]></out_trade_no>
<result_code><![CDATA[SUCCESS]]></result_code>
<return_code><![CDATA[SUCCESS]]></return_code>
<sign><![CDATA[E6A69A88C986B64411ACB2DA7E945EB48BF6391C06B0972DA6AEC80C00FC74F9]]></sign>
<time_end><![CDATA[20211221233851]]></time_end>
<total_fee>1</total_fee>
<trade_type><![CDATA[NATIVE]]></trade_type>
<transaction_id><![CDATA[4200001414202112216382710751]]></transaction_id>
</xml>

我们可以将此xml字符串,转换为map,提取其中的out_trade_no(订单号),根据订单号修改订单状态。

3.3.7 收到微信通知后的逻辑

支付服务:查询订单验证通知

image-20211221235105274
image-20211221235105274

微信方面查询订单如何做 https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=9_2open in new window

image-20211221235203111
image-20211221235203111

(1)WxPayService新增方法定义

From: 元动力
1
2
3
4
5
6
/**
* 查询订单
* @param orderId
* @return
*/
Map queryOrder(String orderId);

(2)WxPayServiceImpl实现方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
@Override
public Map queryOrder(String orderId) {
try{
Map<String ,String> map = new HashMap();
map.put("out_trade_no",orderId);
Map<String, String> resultMap = wxPay.orderQuery(map);
return resultMap;
}catch (Exception e){
e.printStackTrace();
return null;
}
}

(3)修改notify方法

下单:

image-20211222001359665
image-20211222001359665

下单通知成功与否在哪儿? https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=9_7&index=8open in new window

image-20200305014740028
image-20200305014740028

我们的订单号在哪儿? 在下单后微信回调的请求里。

image-20211221235940049
image-20211221235940049
image-20200305012941294
image-20200305012941294

=================================================================================================

查询:https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=9_2open in new window

查询结果如何?

image-20200305015926052
image-20200305015926052

查询订单结果结果如何?

image-20200305015403233
image-20200305015403233

此次查询下单我们商品的订单号是什么?

image-20200305020047787
image-20200305020047787

此次支付动作的id在哪儿?

image-20200305015219298
image-20200305015219298
From: 元动力
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
@RequestMapping("/wxpay")
@RestController
public class WXPayController {

@Autowired
private WXPayService wxPayService;
@Autowired
private RabbitTemplate rabbitTemplate;

//下单
@GetMapping("/nativePay")
public Result nativePay(@RequestParam("orderId") String orderId, @RequestParam("money") Integer money) {
Map resultMap = wxPayService.nativePay(orderId, money);
return new Result(true, StatusCode.OK, "", resultMap);
}

@RequestMapping("/notify")
public void notifyLogic(HttpServletRequest request, HttpServletResponse response) {
System.out.println("支付成功回调");

try {
//1输入流转换为字符串
String xml = ConvertUtils.convertToString(request.getInputStream());
System.out.println(xml);

//3基于微信发送的通知内容,完成后续的业务逻辑处理
Map<String, String> map = WXPayUtil.xmlToMap(xml);
if ("SUCCESS".equals(map.get("result_code"))) {

//查询订单
Map result = wxPayService.queryOrder(map.get("out_trade_no"));
System.out.println("查询订单结果:" + result);

if ("SUCCESS".equals(result.get("result_code"))) {

//将订单的消息发送到mq'
Map message = new HashMap();
message.put("orderId", result.get("out_trade_no"));
message.put("transactionId", result.get("transaction_id"));

//消息的发送
rabbitTemplate.convertAndSend("", RabbitMQConfig.ORDER_PAY, JSON.toJSONString(message));


} else {
//输出错误原因
System.out.println(map.get("err_code_des"));
}
} else {
//输出错误原因
System.out.println(map.get("err_code_des"));
}

//2给微信一个结果通知
response.setContentType("text/xml");
String data = "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>";
response.getWriter().write(data);
} catch (Exception e) {
e.printStackTrace();
}


}
}

config包下增加rebbitMQ配置类。并检查依赖和配置文件。

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.ydles.pay.config;

import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfig {

public static final String ORDER_PAY="order_pay";

@Bean
public Queue queue(){
return new Queue(ORDER_PAY);
}
}

3.3.8 订单服务修改订单状态

需求:

image-20211222001437864
image-20211222001437864

1rabbitMQ配置类,把支付服务定义的队列定义

From: 元动力
1
2
3
4
5
6
public static final String ORDER_PAY="order_pay";

@Bean
public Queue queue(){
return new Queue(ORDER_PAY);
}

2 com.ydles.order.listener包下创建OrderPayListener

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class OrderPayListener {
@Autowired
private OrderService orderService;

@RabbitListener(queues = RabbitMQConfig.ORDER_PAY)
public void receivePayMessage(String message) {
System.out.println("接收到了订单支付的消息:" + message);
Map map = JSON.parseObject(message, Map.class);
//调用业务层,完成订单数据库的修改
orderService.updatePayStatus((String) map.get("orderId"), (String) map.get("transactionId"));
}
}

3OrderService接口新增方法定义

From: 元动力
1
2
3
4
5
6
/**
* 修改订单状态为已支付
* @param orderId
* @param transactionId
*/
void updatePayStatus(String orderId,String transactionId);

4OrderServiceImpl新增方法实现

From: 元动力
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
@Autowired
private OrderLogMapper orderLogMapper;

@Override
public void updatePayStatus(String orderId, String transactionId) {
Order order = orderMapper.selectByPrimaryKey(orderId);
if(order!=null && "0".equals(order.getPayStatus())){ //存在订单且状态为0
order.setPayStatus("1");
order.setOrderStatus("1");
order.setUpdateTime(new Date());
order.setPayTime(new Date());
order.setTransactionId(transactionId);//微信返回的交易流水号
orderMapper.updateByPrimaryKeySelective(order);
//记录订单变动日志
OrderLog orderLog=new OrderLog();
orderLog.setId( idWorker.nextId()+"" );
orderLog.setOperater("system");// 系统
orderLog.setOperateTime(new Date());//当前日期
orderLog.setOrderStatus("1");
orderLog.setPayStatus("1");
orderLog.setRemarks("支付流水号"+transactionId);
orderLog.setOrderId(order.getId());
orderLogMapper.insertSelective(orderLog);
}
}

3.3.9 整个流程测试

1重启ydles_service_pay ydles_service_order

2添加商品: http://localhost:8001/api/cart/addCart?skuId=100000006163&num=10open in new window

3查看购物车: http://localhost:8001/api/wcart/listopen in new window

4点击:结算---》提交订单--》选择微信支付--》跳到二维码页面--》扫描二维码

4.1扫码支付之前,查看订单状态

4.1扫码支付之后,查看订单状态

image-20211222003719250
image-20211222003719250

订单日志表,也有一条数据了

image-20211222003728955
image-20211222003728955

4. 推送支付通知

4.1 需求分析

当用户完成扫码支付后,跳转到支付成功页面

4.2 服务端推送方案

需求:我们需要将支付的结果通知前端页面,其实就是我们通过所说的服务器端推送,主要有三种实现方案

(1)Ajax 短轮询 setInterval Ajax 轮询主要通过页面端的 JS 定时异步刷新任务来实现数据的加载

如果我们使用ajax短轮询方式,需要后端提供方法,通过调用微信支付接口实现根据订单号查询支付状态的方法(参见查询订单API) 。 前端每间隔三秒查询一次,如果后端返回支付成功则执行页面跳转。

缺点:这种方式实时效果较差,而且对服务端的压力也较大。

(2)长轮询

长轮询主要也是通过 Ajax 机制,但区别于传统的 Ajax 应用,长轮询的服务器端会在没有数据时阻塞请求直到有新的数据产生或者请求超时才返回,之后客户端再重新建立连接获取数据。

如果使用长轮询,也同样需要后端提供方法,通过调用微信支付接口实现根据订单号查询支付状态的方法,只不过循环是写在后端的。

缺点:长轮询服务端会长时间地占用资源,如果消息频繁发送的话会给服务端带来较大的压力。

(3)WebSocket 双向通信 WebSocket 是 HTML5 中一种新的通信协议,能够实现浏览器与服务器之间全双工通信。如果浏览器和服务端都支持 WebSocket 协议的话,该方式实现的消息推送无疑是最高效、简洁的。并且最新版本的 IE、Firefox、Chrome 等浏览器都已经支持 WebSocket 协议,Apache Tomcat 7.0.27 以后的版本也开始支持 WebSocket。

4.3 RabbitMQ Web STOMP 插件

借助于 RabbitMQ 的 Web STOMP 插件,实现浏览器与服务端的全双工通信。从本质上说,RabbitMQ 的 Web STOMP 插件也是利用 WebSocket 对 STOMP 协议进行了一次桥接,从而实现浏览器与服务端的双向通信。

4.3.1 STOMP协议

STOMP即Simple (or Streaming) Text Orientated Messaging Protocol简单(流)文本定向消息协议。前身是TTMP协议(一个简单的基于文本的协议),专为消息中间件设计。它提供了一个可互操作的连接格式,允许STOMP客户端与任意STOMP消息代理(Broker)进行交互。STOMP协议由于设计简单,易于开发客户端,因此在多种语言和多种平台上得到广泛地应用。

4.3.2 插件安装

1查询所有docker 容器

From: 元动力
1
docker ps
image-20200305023436779
image-20200305023436779

2我们进入rabbitmq容器

From: 元动力
1
docker exec -it 410a37e15588 /bin/bash
image-20200305023655720
image-20200305023655720

执行下面的命令开启stomp插件

From: 元动力
1
rabbitmq-plugins enable rabbitmq_web_stomp rabbitmq_web_stomp_examples
image-20200305023810038
image-20200305023810038

退出容器

From: 元动力
1
exit
image-20200305023840612
image-20200305023840612

将当前的容器提交为新的镜像

From: 元动力
1
docker commit 410a37e15588 rabbitmq:stomp
image-20200305024558360
image-20200305024558360

停止当前的容器

From: 元动力
1
docker stop 410a37e15588
image-20200305024622094
image-20200305024622094

删除当前的容器

From: 元动力
1
docker rm 410a37e15588
image-20200305024639584
image-20200305024639584

根据新的镜像重新创建容器

From: 元动力
1
docker run -di --name=ydles_rabbitmq1 -p 5671:5617 -p 5672:5672 -p 4369:4369 -p 15671:15671 -p 15672:15672 -p 25672:25672 -p 15670:15670 -p 15674:15674 rabbitmq:stomp

设置容器开机自动启动

From: 元动力
1
docker update --restart=always 54b0eea9edbb #根据上面生成的容器号改变
image-20200305024852480
image-20200305024852480

插件已经安转好了http://192.168.200.128:15670/open in new window

image-20211222005732519
image-20211222005732519

4.3.3 消息推送测试

1观察wxpay.html

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script src="/js/plugins/qrcode.min.js"></script>
<script src="/js/plugins/stomp.min.js"></script>
<script type="text/javascript" th:inline="javascript">
let qrcode = new QRCode(document.getElementById("qrcode"), {
width : 240,
height : 240
});
qrcode.makeCode("weixin://wxpay/bizpayurl?pr=XzofHwG");

let client = Stomp.client('ws://192.168.200.128:15674/ws');
let on_connect = function(x) {
id = client.subscribe("/exchange/paynotify", function(d) {

});
};
let on_error = function() {
console.log('error');
};
client.connect('guest', 'guest', on_connect, on_error, '/');

</script>

所以我们需要创建一个交换机paynotify

destination 在 RabbitMQ Web STOM 中进行了相关的定义,根据使用场景的不同,主要有以下 4 种:

  • 1./exchange/<exchangeName>

对于 SUBCRIBE frame,destination 一般为/exchange/ <exchangeName>/[/pattern] 的形式。该 destination 会创建一个唯一的、自动删除的、名为<exchangeName>的 queue,并根据 pattern 将该 queue 绑定到所给的 exchange,实现对该队列的消息订阅。 对于 SEND frame,destination 一般为/exchange/<exchangeName>/[/routingKey] 的形式。这种情况下消息就会被发送到定义的 exchange 中,并且指定了 routingKey。

  • 2./queue/ <queueName> 对于 SUBCRIBE frame,destination 会定义 <queueName>的共享 queue,并且实现对该队列的消息订阅。 对于 SEND frame,destination 只会在第一次发送消息的时候会定义 <queueName>的共享 queue。该消息会被发送到默认的 exchange 中,routingKey 即为 <queueName>。

  • 3./amq/queue/<queueName> 这种情况下无论是 SUBCRIBE frame 还是 SEND frame 都不会产生 queue。但如果该 queue 不存在,SUBCRIBE frame 会报错。 对于 SUBCRIBE frame,destination 会实现对队列<queueName>的消息订阅。 对于 SEND frame,消息会通过默认的 exhcange 直接被发送到队列<queueName>中。

  • 4./topic/<topicName> 对于 SUBCRIBE frame,destination 创建出自动删除的、非持久的 queue 并根据 routingkey 为<topicName>绑定到 amq.topic exchange 上,同时实现对该 queue 的订阅。 对于 SEND frame,消息会被发送到 amq.topic exchange 中,routingKey 为<topicName>。

2 http://192.168.200.128:15672/#/exchangesopen in new window 新建交换机。名字:paynotify 。类型 :fanout。

image-20200305025600949
image-20200305025600949

3修改wxpay.html

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
&lt;script src="/js/plugins/qrcode.min.js"&gt;&lt;/script&gt;
&lt;script src="/js/plugins/stomp.min.js"&gt;&lt;/script&gt;
&lt;script type="text/javascript" th:inline="javascript"&gt;
let qrcode = new QRCode(document.getElementById("qrcode"), {
width : 240,
height : 240
});
qrcode.makeCode("weixin://wxpay/bizpayurl?pr=XzofHwG"); //自己的微信图片

let client = Stomp.client('ws://192.168.200.128:15674/ws');
let on_connect = function(x) {
id = client.subscribe("/exchange/paynotify", function(d) {
alert(d.body);
});
};
let on_error = function() {
console.log('error');
};
client.connect('guest', 'guest', on_connect, on_error, '/');
&lt;/script&gt;

4将wxpay.html放到static中。因为对比静态页面文件夹,页面获取的是平级的js。打开页面,观察console。如果有错,将static打包,发给大家。

页面效果

image-20211222012152665
image-20211222012152665

5尝试发送一条消息

点击rabbitMQ队列交换机页面的paynotify

image-20211222012132026
image-20211222012132026

点击发送消息

image-20211222012123305
image-20211222012123305

效果

image-20211222012108075
image-20211222012108075

6新建用户

为了安全,我们在页面上不能用我们的rabbitmq的超级管理员用户guest,所以我们需要在rabbitmq中新建一个普通用户webguest(普通用户无法登录管理后台)

image-20200306075452263
image-20200306075452263

设置虚拟目录权限,打开这个用户权限页:

image-20200306075534030
image-20200306075534030

点击设置权限

页面用户名和密码修改

From: 元动力
1
client.connect('webguest', 'webguest', on_connect, on_error, '/');

刷新页面,还可以看到连接信息即可。

image-20200306075942810
image-20200306075942810

4.4 代码实现

实现思路:后端在收到回调通知后发送订单号给mq(paynotify交换器),前端通过stomp连接到mq订阅

image-20211222013147811
image-20211222013147811

1 修改notifyLogic方法,在"SUCCESS".equals(result.get("result_code")) 后添加

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
if ("SUCCESS".equals(result.get("result_code"))) {
//将订单的消息发送到mq'
Map message = new HashMap();
message.put("orderId", result.get("out_trade_no"));
message.put("transactionId", result.get("transaction_id"));
//消息的发送
rabbitTemplate.convertAndSend("", RabbitMQConfig.ORDER_PAY, JSON.toJSONString(message));

//完成双向通信
rabbitTemplate.convertAndSend("paynotify","", result.get("out_trade_no"));
}

2 ydles_web_order的PayController中新增跳转支付成功接口

From: 元动力
1
2
3
4
5
6
//支付成功跳转
@GetMapping("/topaysuccess")
public String topaysuccess(String payMoney, Model model) {
model.addAttribute("payMoney", payMoney);
return "paysuccess";
}
image-20200423120048804
image-20200423120048804

3 修改ydles_web_order项目的wxpay.html ,渲染js代码订单号和支付金额部分

From: 元动力
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
&lt;script src="/js/plugins/qrcode.min.js"&gt;&lt;/script&gt;
&lt;script src="/js/plugins/stomp.min.js"&gt;&lt;/script&gt;
&lt;script type="text/javascript" th:inline="javascript"&gt;
let qrcode = new QRCode(document.getElementById("qrcode"), {
width: 240,
height: 240
});
qrcode.makeCode([[${code_url}]]);


let client = Stomp.client('ws://192.168.200.128:15674/ws');
let on_connect = function (x) {
id = client.subscribe("/exchange/paynotify", function (d) {
alert(d.body);
let orderId = [[${orderId}]]
if (d.body == orderId) {
//跳转页面
location.href = "/api/wxpay/toPaySuccess?payMoney=" + [[${payMoney}]]
}
});
};
let on_error = function () {
console.log('error');
};
client.connect('webguest', 'webguest', on_connect, on_error, '/');
&lt;/script&gt;

(3)将paysuccess.html拷贝到templates文件夹 。

5. 测试访问路径

1重启ydles_service_pay ydles_service_order

2添加商品: http://localhost:8001/api/cart/addCart?skuId=100000006163&num=10open in new window

3查看购物车: http://localhost:8001/api/wcart/listopen in new window

4点击:结算---》提交订单--》选择微信支付--》跳到二维码页面--》扫描二维码

5 支付完成 回传订单数据

6确定后,跳转至支付成功页面

image-20211222014108729
image-20211222014108729

总结:

1了解微信支付怎么做

第三方接口平台对接:第三方官网文档一步一步做

2申请支付的二维码

image-20211221144256113
image-20211221144256113

3支付回推

image-20211221203416360
image-20211221203416360

natapp 内网穿透

image-20211221235105274
image-20211221235105274

4用户回推 支付成功消息

image-20211222013147811
image-20211222013147811
image-20211222014807522
image-20211222014807522

第14章 订单处理

角色:还是订单组,资深架构师。

课程内容:

  • 通过rabbitmq的延迟消息完成超时订单处理
  • 完成批量发货功能,了解第三方物流系统
  • 完成自动收货功能

1. 超时未支付订单处理-重点

1.1 需求分析

超过限定时间并未支付的订单,我们需要进行超时订单的处理:先调用微信支付api,查询该订单的支付状态。如果未支付调用关闭订单的api,并修改订单状态为已关闭,并回滚库存数。如果该订单已经支付,则做补偿操作(修改订单状态和记录)。

1.2 实现思路

如何获取超过限定时间的订单?我们可以使用延迟消息队列(死信队列)来实现。

所谓延迟消息队列,就是消息的生产者发送的消息并不会立刻被消费,而是在设定的时间之后才可以消费。

我们可以在订单创建时发送一个延迟消息,消息为订单号,系统会在限定时间之后取出这个消息,然后查询订单的支付状态,根据结果做出相应的处理。

1.3 rabbitmq延迟消息

使用RabbitMQ来实现延迟消息必须先了解RabbitMQ的两个概念:消息的TTL死信Exchange,通过这两者的组合来实现上述需求。

1.3.1 消息的TTL(Time To Live)

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

我们创建一个队列queue.temp,在Arguments 中添加x-message-ttl 为5000 (单位是毫秒),那每一个进入这个队列的消息在5秒后会消失。

image-20211227105923204
image-20211227105923204

1.3.2 死信交换器 Dead Letter Exchanges

面试:什么时候死信?

一个消息在满足如下条件下,会进死信交换机,记住这里是交换机而不是队列,一个交换机可以对应很多队列。

(1) 一个消息被Consumer拒收了,并且reject方法的参数里requeue是false。也就是说不会被再次放在队列里,被其他消费者使用。

**(2)**上面的消息的TTL到了,消息过期了

**(3)**队列的长度限制满了。排在前面的消息会被丢弃或者扔到死信交换机上。

Dead Letter Exchange其实就是一种普通的exchange,和创建其他exchange没有两样。只是在某一个设置Dead Letter Exchange的队列中有消息过期了,会自动触发消息的转发,发送到Dead Letter Exchange中去。

image-20211227111258327
image-20211227111258327

我们现在可以测试一下延迟队列。

(1)创建死信交换器 exchange.ordertimeout (fanout

(2)创建队列queue.ordertimeout

(3)建立死信交换器 exchange.ordertimeout 与队列queue.ordertimeout 之间的绑定

(4)创建队列queue.ordercreate,Arguments添加

x-message-ttl=10000

x-dead-letter-exchange: exchange.ordertimeout

(5)测试:向queue.ordercreate队列添加消息,等待10秒后消息从queue.ordercreate队列消失,

image-20211227111214185
image-20211227111214185
image-20211227111247704
image-20211227111247704

1.4 代码实现

image-20211227113439128
image-20211227113439128

1.4.1 微信支付-关闭订单

image-20211227114511089
image-20211227114511089

(1)WxPayController新增方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
/**
* 关闭微信订单
* @param orderId
* @return
*/
@PutMapping("/close/{orderId}")
public Result closeOrder(@PathVariable String orderId){
Map map = wxPayService.closeOrder( orderId );
return new Result( true,StatusCode.OK,"",map );
}

(2)ydles_service_pay的WxPayService新增方法定义

From: 元动力
1
2
3
4
5
6
/**
* 关闭订单
* @param orderId
* @return
*/
Map closeOrder(String orderId);

(3)ydles_service_pay的 WxPayServiceImpl实现该方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
@Override
public Map closeOrder(String orderId) {
Map map=new HashMap( );
map.put( "out_trade_no",orderId );
try {
return wxPay.closeOrder( map );
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

(4)ydles_service_pay_api的WxPayFeign新增方法

From: 元动力
1
2
3
4
5
6
7
/**
* 关闭微信订单
* @param orderId
* @return
*/
@PutMapping("/wxpay/close/{orderId}")
public Result closeOrder(@PathVariable("orderId") String orderId);

1.4.2 微信支付-查询订单

(1)WxPayController新增方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
/**
* 查询微信订单
* @param orderId
* @return
*/
@GetMapping("/query/{orderId}")
public Result queryOrder(@PathVariable String orderId){
Map map = wxPayService.queryOrder( orderId );
return new Result( true,StatusCode.OK,"",map );
}

(2)WxPayFeign新增方法

From: 元动力
1
2
3
4
5
6
7
/**
* 查询微信订单
* @param orderId
* @return
*/
@GetMapping("/wxpay/query/{orderId}")
public Result queryOrder(@PathVariable("orderId") String orderId);

商品服务回滚库存

image-20211227115532726
image-20211227115532726

(1)dao

From: 元动力
1
2
3
//回滚库存
@Update("update tb_sku set num=num+#{num},sale_num=sale_num-#{num} where id=#{skuId}")
void resumeStockNum(@Param("skuId") String skuId, @Param("num") Integer num);

(2)service

From: 元动力
1
2
//回滚库存
void resumeStockNum(String skuId,Integer num);

(3)serivceImpl

From: 元动力
1
2
3
4
@Override
public void resumeStockNum(String skuId, Integer num) {
skuMapper.resumeStockNum(skuId,num);
}

(4)controller

From: 元动力
1
2
3
4
5
6
//回滚库存
@PutMapping(value = "/resumeStockNum")
public Result resumeStockNum(@RequestParam("skuId") String skuId, @RequestParam("num")Integer num){
skuService.resumeStockNum(skuId,num);
return new Result(true,StatusCode.OK,"回滚库存成功!");
}

(4)skuFeign

From: 元动力
1
2
@PutMapping(value = "/sku/resumeStockNum")
public Result resumeStockNum(@RequestParam("skuId") String skuId, @RequestParam("num")Integer num);

1.4.3 订单关闭逻辑

image-20211227121318859
image-20211227121318859

如果为未支付,查询微信订单

如果确认为未支付,调用关闭本地订单( 修改订单表的订单状态、记录订单日志、恢复商品表库存)和微信订单的逻辑。

如果为已支付进行状态补偿。

(1)ydles_service_order新增依赖

From: 元动力
1
2
3
4
5
&lt;dependency&gt;
&lt;groupId&gt;com.ydles&lt;/groupId&gt;
&lt;artifactId&gt;ydles_service_pay_api&lt;/artifactId&gt;
&lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;

(2)ydles_service_order的OrderService新增方法定义

From: 元动力
1
2
3
4
5
/**
* 关闭订单
* @param orderId
*/
void closeOrder(String orderId);

(3)OrderServiceImpl实现该方法

实现逻辑:

1)根据id查询订单信息,判断订单是否存在,订单支付状态是否为未支付

2)基于微信查询订单支付状态

2.1)如果为success,则修改订单状态

2.2)如果为未支付,则修改订单,新增日志,恢复库存,关闭订单

From: 元动力
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
@Autowired
PayFeign payFeign;
//关闭订单
@Override
@Transactional
public void closeOrder(String orderId) {
System.out.println("关闭订单开启了:"+orderId);

Order order = orderMapper.selectByPrimaryKey(orderId);
if(order==null){
throw new RuntimeException("这笔订单不存在!");
}
if(!order.getOrderStatus().equals("0")){
System.out.println("这笔订单不用关闭");
return;
}
System.out.println("关闭订单逻辑通过校验:"+orderId);


//1支付服务 微信查询订单
Map&lt;String, String&gt; wxQueryMap = payFeign.queryOrder(orderId).getData();

//2.1支付了 order表修改
if(wxQueryMap.get("trade_state").equals("SUCCESS")){
updatePayStatus(orderId,wxQueryMap.get("transaction_id"));
System.out.println("已支付"+orderId);
}

//2.2未支付 关闭订单微信 内部回滚库存 订单状态关闭
if(wxQueryMap.get("trade_state").equals("NOTPAY")){
//1关闭订单微信
payFeign.closeOrder(orderId);

//2订单状态关闭
System.out.println("本项目关闭订单了");
order.setOrderStatus("9");//订单状态 0下单 1支付 2发货 3收货 4退货 9关闭
order.setCloseTime(new Date());
orderMapper.updateByPrimaryKeySelective(order);

//orderLog表 新增数据
OrderLog orderLog = new OrderLog();
orderLog.setId(idWorker.nextId()+"");
orderLog.setOperater("system");
orderLog.setOperateTime(new Date());
orderLog.setOrderId(orderId);
orderLog.setOrderStatus("9");
orderLog.setPayStatus("0");
orderLog.setConsignStatus("0");
orderLog.setRemarks("超时未支付!");
orderLogMapper.insertSelective(orderLog);


//3内部回滚库存

//查出来这笔订单的所有购物项
OrderItem orderItem=new OrderItem();
orderItem.setOrderId(orderId);
List&lt;OrderItem&gt; orderItemList = orderItemMapper.select(orderItem);

for (OrderItem orderItem1 : orderItemList) {
skuFeign.resumeStock(orderItem1.getSkuId(),orderItem1.getNum());
}

}

//还有各种支付状态 都需要考虑。REFUND CLOSED USERPAYING


}

1.4.4 延迟消息处理

image-20211227170736337
image-20211227170736337

从消息队列queue.ordertimeout 中提取消息

(1)修改OrderServiceImpl的add方法,追加代码,实现mq发送

From: 元动力
1
rabbitTemplate.convertAndSend( "","queue.ordercreate", orderId);

(2)ydles_service_order新建监听类

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
public class OrderTimeoutListener {

@Autowired
private OrderService orderService;

/**
* 更新支付状态
* @param orderId
*/
@RabbitListener(queues = "queue.ordertimeout")
public void closeOrder(String orderId){
System.out.println("接收到关闭订单消息:"+orderId);
try {
orderService.closeOrder( orderId );
} catch (Exception e) {
e.printStackTrace();
}
}
}

测试关键点

1 10秒后,能不能order服务接受消息

2 微信支付了,本地order没支付 ---》下单,回调接口写错。

3 没支付---------》二维码页面,等10秒。

image-20211227172604859
image-20211227172604859

2. 订单批量发货

角色:店主

2.1 批量发货业务逻辑

2.1.1 需求分析

实现批量发货的业务逻辑

image-20211227173827318
image-20211227173827318

2.1.2 代码实现

(1)OrderController新增方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
[
{
orderId:8345,
shipping_name:顺丰,
shipping_code:123,
.......
},{

},{

}
]
From: 元动力
1
2
3
4
5
6
7
8
9
/**
* 批量发货
* @param orders 订单列表
*/
@PostMapping("/batchSend")
public Result batchSend( @RequestBody List&lt;Order&gt; orders){
orderService.batchSend( orders );
return new Result( true,StatusCode.OK,"发货成功" );
}

(2)OrderService新增方法定义

From: 元动力
1
2
3
4
5
/**
* 批量发货
* @param orders
*/
void batchSend(List&lt;Order&gt; orders);

(3)OrderServiceImpl实现该方法

From: 元动力
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
@Override
@Transactional
public void batchSend(List&lt;Order&gt; orderList) {
//循环1 物流公司和物流单号 不能为空
for (Order order : orderList) {
if(order.getId()==null){
throw new RuntimeException("订单号为空!");
}
if(order.getShippingName()==null||order.getShippingCode()==null){
throw new RuntimeException("物流公司或单号为空!");
}
}

//循环2 查询订单状态 校验
for (Order order : orderList) {
Order queryOrder = orderMapper.selectByPrimaryKey(order.getId());
if(!queryOrder.getOrderStatus().equals("1")||!queryOrder.getConsignStatus().equals("0")){
throw new RuntimeException("订单状态不对,不能发货!");
}
}

//循环3 发货
for (Order order : orderList) {
order.setOrderStatus("2");
order.setConsignStatus("1");
order.setUpdateTime(new Date());
order.setConsignTime(new Date());
orderMapper.updateByPrimaryKeySelective(order);

//orderLog表 新增数据
OrderLog orderLog = new OrderLog();
orderLog.setId(idWorker.nextId()+"");
orderLog.setOperater("店小二");
orderLog.setOperateTime(new Date());
orderLog.setOrderId(order.getId());
orderLog.setOrderStatus("2");
orderLog.setPayStatus("1");
orderLog.setConsignStatus("1");
orderLog.setRemarks("批量发货");
orderLogMapper.insertSelective(orderLog);
}

}

2.2 对接第三方物流(了解)

当我们在电商平台购买了商品后,一般会非常关心商品的物流轨迹。那这些信息是如何获取的呢?我们需要对接第三方的物流系统。比较常用的有菜鸟物流、快递鸟等。

我们这里推荐使用快递鸟 http://www.kdniao.comopen in new window

我们可以使用快递鸟提供的以下接口:

(1)预约取件API

预约取件API为用户提供了在线下单,预约快递员上门揽件的功能,为用户解决在线发货需求。

我们可以在实现批量发货功能后调用预约取件API

(2)即时查询API

物流查询API提供实时查询物流轨迹的服务,用户提供运单号和快递公司,即可查询当前时刻的最新物流轨迹。

用户可以在用户中心调用此API完成物流信息的查询,电商平台也可以调用此API完成运单的跟踪。

image-20211227180923988
image-20211227180923988

3. 确认收货与自动收货

3.1 确认收货

3.1.1 需求分析与实现思路

当物流公司将货物送到了用户收货地址之后,需要用户点击确认收货,当用户点击了确认收货之后,会修改订单状态为已完成

3.1.2 代码实现

(1)OrderController新增方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
/**
* 确认收货
* @param orderId 订单号
* @param operator 操作者
* @return
*/
@PutMapping("/take/{orderId}/operator/{operator}")
public Result take(@PathVariable String orderId, @PathVariable String operator){
orderService.take( orderId,operator );
return new Result( true,StatusCode.OK,"" );
}

(2)OrderService新增方法定义

From: 元动力
1
2
3
4
5
6
/**
* 确认收货
* @param orderId
* @param operator
*/
void confirmTask(String orderId,String operator);

(3)OrderServiceImpl实现该方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public void confirmTask(String orderId, String operator) {
Order order = orderMapper.selectByPrimaryKey( orderId );
if(order==null){
throw new RuntimeException( "订单不存在" );
}
if( !"1".equals( order.getConsignStatus() )){
throw new RuntimeException( "订单未发货" );
}
order.setConsignStatus("2"); //已送达
order.setOrderStatus( "3" );//已完成
order.setUpdateTime( new Date() );
order.setEndTime( new Date() );//交易结束
orderMapper.updateByPrimaryKeySelective( order );
//记录订单变动日志
OrderLog orderLog=new OrderLog();
orderLog.setId( idWorker.nextId()+"" );
orderLog.setOperateTime(new Date());//当前日期
orderLog.setOperater( operator );//系统?管理员?用户?
orderLog.setOrderStatus("3");
orderLog.setOrderId(order.getId());
orderLogMapper.insertSelective(orderLog);
}

3.2 自动收货处理

3.2.1 需求分析

如果用户在15天(可以在订单配置表中配置)没有确认收货,系统将自动收货。如何实现?我们这里采用定时任务springTask来实现。

image-20211227184231554
image-20211227184231554

逻辑:每天凌晨2点,查询order,发货15天,自动收货。

​ 每天凌晨1点,删除生产环境,删除log。

技术:

spring--->quartz

spring task 定时任务

xxl-job

3.2.2 Cron表达式

From: 元动力
1
"0 * * * * *"

Cron表达式是一个字符串,字符串分为七个部分,每一个域代表一个含义。

Cron表达式7个域格式为: 秒 分 小时 日 月 星期几 年

Cron表达式6个域格式为: 秒 分 小时 日 月 周

序号说明是否必填允许填写的值允许的通配符
10-59, - * /
20-59, - * /
3小时0-23, - * /
41-31, - * ? / L W
51-12或JAN-DEC, - * /
6星期几1-7或SUN-SAT, - * ? / L W
7empty 或1970-2099, - * /

使用说明:

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
通配符说明:
* 表示所有值. 例如:在分的字段上设置 "*",表示每一分钟都会触发。

? 表示不指定值。使用的场景为不需要关心当前设置这个字段的值。

例如:要在每月的10号触发一个操作,但不关心是周几,所以需要周位置的那个字段设置为"?" 具体设置为 0 0 0 10 * ?

- 表示区间。例如 在小时上设置 "10-12",表示 10,11,12点都会触发。

, 表示指定多个值,例如在周字段上设置 "MON,WED,FRI" 表示周一,周三和周五触发

/ 用于递增触发。如在秒上面设置"5/15" 表示从5秒开始,每增15秒触发(5,20,35,50)。 在月字段上设置'1/3'所示每月1号开始,每隔三天触发一次。

L 表示最后的意思。在日字段设置上,表示当月的最后一天(依据当前月份,如果是二月还会依据是否是润年[leap]), 在周字段上表示星期六,相当于"7"或"SAT"。如果在"L"前加上数字,则表示该数据的最后一个。例如在周字段上设置"6L"这样的格式,则表示“本月最后一个星期五"

W 表示离指定日期的最近那个工作日(周一至周五). 例如在日字段上设置"15W",表示离每月15号最近的那个工作日触发。如果15号正好是周六,则找最近的周五(14号)触发, 如果15号是周未,则找最近的下周一(16号)触发.如果15号正好在工作日(周一至周五),则就在该天触发。如果指定格式为 "1W",它则表示每月1号往后最近的工作日触发。如果1号正是周六,则将在3号下周一触发。(注,"W"前只能设置具体的数字,不允许区间"-").

# 序号(表示每月的第几个周几),例如在周字段上设置"6#3"表示在每月的第三个周六.注意如果指定"#5",正好第五周没有周六,则不会触发该配置(用在母亲节和父亲节再合适不过了) ;

常用表达式

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
0 0 10,14,16 * * ? 每天上午10点,下午2点,4点 
0 0/30 9-17 * * ? 朝九晚五工作时间内每半小时
0 0 12 ? * WED 表示每个星期三中午12点
"0 0 12 * * ?" 每天中午12点触发
"0 15 10 ? * *" 每天上午10:15触发
"0 15 10 * * ?" 每天上午10:15触发
"0 15 10 * * ? *" 每天上午10:15触发
"0 15 10 * * ? 2005" 2005年的每天上午10:15触发
"0 * 14 * * ?" 在每天下午2点到下午2:59期间的每1分钟触发
"0 0/5 14 * * ?" 在每天下午2点到下午2:55期间的每5分钟触发
"0 0/5 14,18 * * ?" 在每天下午2点到2:55期间和下午6点到6:55期间的每5分钟触发
"0 0-5 14 * * ?" 在每天下午2点到下午2:05期间的每1分钟触发
"0 10,44 14 ? 3 WED" 每年三月的星期三的下午2:10和2:44触发
"0 15 10 ? * MON-FRI" 周一至周五的上午10:15触发
"0 15 10 15 * ?" 每月15日上午10:15触发
"0 15 10 L * ?" 每月最后一日的上午10:15触发
"0 15 10 ? * 6L" 每月的最后一个星期五上午10:15触发
"0 15 10 ? * 6L 2002-2005" 2002年至2005年的每月的最后一个星期五上午10:15触发
"0 15 10 ? * 6#3" 每月的第三个星期五上午10:15触发

3.2.3 代码实现

image-20211227195336675
image-20211227195336675
3.2.3.1 发送消息

(1)创建order_tack队列 。

(2)创建工程ydles_task,引入依赖

From: 元动力
1
2
3
4
5
6
7
8
9
10
&lt;dependencies&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.amqp&lt;/groupId&gt;
&lt;artifactId&gt;spring-rabbit&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;/dependencies&gt;

(3)创建配置文件 application.yml

From: 元动力
1
2
3
4
5
6
7
server:
port: 9202
spring:
application:
name: task
rabbitmq:
host: 192.168.200.128

(4)创建启动类 com.ydles.task

From: 元动力
1
2
3
4
5
6
7
8
@SpringBootApplication
@EnableScheduling
public class TaskApplication {

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

@EnableScheduling 注解用于开启任务调度

(5)创建com.ydles.task包,包下创建类OrderTask

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class OrderTask {

@Autowired
private RabbitTemplate rabbitTemplate;

/**
* 订单自动收货
*/
@Scheduled(cron = "0 0 0 * * ?")
public void autoTake(){
System.out.println(new Date( ) );
rabbitTemplate.convertAndSend( "","order_tack","-" );
}

}
3.2.3.2 接收消息
image-20211227200800397
image-20211227200800397

(1)ydles_service_order工程,编写消息监听类

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
@Component
public class OrderTackListener {

@Autowired
private OrderService orderService;

@RabbitListener(queues = "order_tack")
public void autoTack(String message){
System.out.println("收到自动确认收货消息");
orderService.autoTack(); //自动确认收货
}
}

(2)OrderService新增方法定义

From: 元动力
1
2
3
4
/**
* 自动确认收货
*/
void autoTack();

(3)OrderServiceImpl实现此方法

实现思路:

1)从订单配置表中获取订单自动确认期限

2)得到当前日期向前数(订单自动确认期限)天。作为过期时间节点

3)从订单表中获取过期订单(发货时间小于过期时间,且为未确认收货状态)

4)循环批量处理,执行确认收货

From: 元动力
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
@Autowired
OrderConfigMapper orderConfigMapper;

@Override
public void autoTack() {
//1 从配置表中获取15天值
OrderConfig orderConfig = orderConfigMapper.selectByPrimaryKey("1");
Integer takeTimeout = orderConfig.getTakeTimeout();//15

//2 推算拿几号之前发货的订单
LocalDate now=LocalDate.now();//当前
LocalDate date = now.plusDays(-takeTimeout);
System.out.println(date);

//3 查询发货超过15天的订单
Example example=new Example(Order.class);
Example.Criteria criteria = example.createCriteria();
criteria.andEqualTo("orderStatus","2");
criteria.andLessThan("consignTime", date);
List&lt;Order&gt; orderList = orderMapper.selectByExample(example);

//4 循环 把这些订单 收货
for (Order order : orderList) {
take(order.getId(),"system");
}
}

总结:

1超时未支付订单处理

image-20211227113439128
image-20211227113439128

​ 2订单批量发货(店主)

image-20211227173827318
image-20211227173827318

3确认收货与自动收货

手动:/take/{orderId}/operator/

自动:

image-20211227184231554
image-20211227184231554

第15章-秒杀前端

角色:独立模块 秒杀模块。架构师

课程内容:

  • 秒杀业务分析

    From: 元动力
    1
    2
    3
    1.什么是秒杀
    2.秒杀实现的流程-&gt;架构分析流程-&gt;重点
    3.业务流程
  • 秒杀商品压入Redis缓存

    From: 元动力
    1
    2
    3
    秒杀商品存入到Redis来提升访问速度
    1.秒杀列表数据
    2.秒杀详情页数据
  • Spring定时任务了解-定时将秒杀商品存入到Redis中

    From: 元动力
    1
    定时将秒杀商品存入到Redis缓存
  • 秒杀商品频道页实现-秒杀商品列表页

  • 秒杀商品详情页实现

1 秒杀业务分析

1.1 需求分析

所谓“秒杀”,就是网络卖家发布一些超低价格的商品,所有买家在同一时间网上抢购的一种销售方式。通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。

秒杀商品通常有两种限制:库存限制、时间限制。

需求:

From: 元动力
1
2
3
4
(1)秒杀频道首页列出秒杀商品
(4)点击立即抢购实现秒杀下单,下单时扣减库存。当库存为0或不在活动期范围内时无法秒杀。
(5)秒杀下单成功,直接跳转到支付页面(微信扫码),支付成功,跳转到成功页,填写收货地址、电话、收件人等信息,完成订单。
(6)当用户秒杀下单5分钟内未支付,取消预订单,调用微信支付的关闭订单接口,恢复库存。
image-20211227221818494
image-20211227221818494

1.2 表结构说明

秒杀商品信息表

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CREATE TABLE `tb_seckill_goods` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`goods_id` bigint(20) DEFAULT NULL COMMENT 'spu ID',
`item_id` bigint(20) DEFAULT NULL COMMENT 'sku ID',
`title` varchar(100) DEFAULT NULL COMMENT '标题',
`small_pic` varchar(150) DEFAULT NULL COMMENT '商品图片',
`price` decimal(10,2) DEFAULT NULL COMMENT '原价格',
`cost_price` decimal(10,2) DEFAULT NULL COMMENT '秒杀价格',
`seller_id` varchar(100) DEFAULT NULL COMMENT '商家ID',
`create_time` datetime DEFAULT NULL COMMENT '添加日期',
`check_time` datetime DEFAULT NULL COMMENT '审核日期',
`status` char(1) DEFAULT NULL COMMENT '审核状态,0未审核,1审核通过,2审核不通过',
`start_time` datetime DEFAULT NULL COMMENT '开始时间',
`end_time` datetime DEFAULT NULL COMMENT '结束时间',
`num` int(11) DEFAULT NULL COMMENT '秒杀商品数',
`stock_count` int(11) DEFAULT NULL COMMENT '剩余库存数',
`introduction` varchar(2000) DEFAULT NULL COMMENT '描述',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
image-20211227221948208
image-20211227221948208

秒杀订单表

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE `tb_seckill_order` (
`id` bigint(20) NOT NULL COMMENT '主键',
`seckill_id` bigint(20) DEFAULT NULL COMMENT '秒杀商品ID',
`money` decimal(10,2) DEFAULT NULL COMMENT '支付金额',
`user_id` varchar(50) DEFAULT NULL COMMENT '用户',
`seller_id` varchar(50) DEFAULT NULL COMMENT '商家',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`pay_time` datetime DEFAULT NULL COMMENT '支付时间',
`status` char(1) DEFAULT NULL COMMENT '状态,0未支付,1已支付',
`receiver_address` varchar(200) DEFAULT NULL COMMENT '收货人地址',
`receiver_mobile` varchar(20) DEFAULT NULL COMMENT '收货人电话',
`receiver` varchar(20) DEFAULT NULL COMMENT '收货人',
`transaction_id` varchar(30) DEFAULT NULL COMMENT '交易流水',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
image-20211227222002467
image-20211227222002467

1.3 秒杀需求分析==拓展内容

秒杀技术实现核心思想是运用缓存减少数据库瞬间的访问压力!读取商品详细信息时运用缓存,当用户点击抢购时减少缓存中的库存数量,当库存数为0时或活动期结束时,同步到数据库。 产生的秒杀预订单也不会立刻写到数据库中,而是先写到缓存,当用户付款成功后再写入数据库。

当然,上面实现的思路只是一种最简单的方式,并未考虑其中一些问题,例如并发状况容易产生的问题。我们看看下面这张思路更严谨的图:

1578267434606
1578267434606

2 秒杀商品存入缓存-重点

image-20211228160425046
image-20211228160425046

秒杀商品由B端存入Mysql,设置定时任务,每隔一段时间就从Mysql中将符合条件的数据从Mysql中查询出来并存入缓存中,redis以Hash类型进行数据存储。

KEY的设计:

From: 元动力
1
2
3
4
5
6
7
8
9
10
seckill_goods_2021122812 map
id SecKillGoods
1 Prada包包
3 guccy包包
seckill_goods_2021122814 map
id SecKillGoods
2 lv包包
seckill_goods_2021122816 map
seckill_goods_2021122818 map
seckill_goods_2021122820 map
  • 我们这里秒杀商品列表和秒杀商品详情都是从Redis中取出来的,所以我们首先要将符合参与秒杀的商品定时查询出来,并将数据存入到Redis缓存中。

  • 数据存储类型我们可以选择Hash类型。

  • 秒杀分页列表这里可以通过获取redisTemplate.boundHashOps(key).values()获取结果数据。

  • 秒杀商品详情,可以通过redisTemplate.boundHashOps(key).get(key)获取详情。

2.1 秒杀服务搭建

我们将商品数据压入到Reids缓存,可以在秒杀工程的服务工程中完成,可以按照如下步骤实现:

From: 元动力
1
2
3
4
5
6
7
1.查询活动没结束的所有秒杀商品
1)状态必须为审核通过 status=1
2)商品库存个数&gt;0
3)活动没有结束 endTime&gt;=now()
4)在Redis中没有该商品的缓存
5)执行查询获取对应的结果集
2.将活动没有结束的秒杀商品入库

我们首先搭建一个秒杀服务工程,然后按照上面步骤实现。

搭建ydles-service-seckill,作为秒杀工程的服务提供工程。

1)新建服务ydles_service_seckill

2)添加依赖信息,详情如下:

From: 元动力
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
&lt;dependencies&gt;
&lt;dependency&gt;
&lt;groupId&gt;com.ydles&lt;/groupId&gt;
&lt;artifactId&gt;ydles_common_db&lt;/artifactId&gt;
&lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.cloud&lt;/groupId&gt;
&lt;artifactId&gt;spring-cloud-starter-netflix-eureka-client&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;com.ydles&lt;/groupId&gt;
&lt;artifactId&gt;ydles_service_order_api&lt;/artifactId&gt;
&lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;com.ydles&lt;/groupId&gt;
&lt;artifactId&gt;ydles_service_seckill_api&lt;/artifactId&gt;
&lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;com.ydles&lt;/groupId&gt;
&lt;artifactId&gt;ydles_service_goods_api&lt;/artifactId&gt;
&lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.amqp&lt;/groupId&gt;
&lt;artifactId&gt;spring-rabbit&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;!--oauth依赖--&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.cloud&lt;/groupId&gt;
&lt;artifactId&gt;spring-cloud-starter-oauth2&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;/dependencies&gt;
  • ydles_service_seckill_api创建

依赖

From: 元动力
1
2
3
4
5
6
7
&lt;dependencies&gt;
&lt;dependency&gt;
&lt;groupId&gt;com.ydles&lt;/groupId&gt;
&lt;artifactId&gt;ydles_common&lt;/artifactId&gt;
&lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;
&lt;/dependencies&gt;

添加包 com.ydles.seckill.feign com.ydles.seckill.pojo

pojo中将资料中的两个实体类放入,对应数据库的两张表

image-20211228161100576
image-20211228161100576
  1. 回到ydles_service_seckill工程。添加启动类 com.ydles.seckill
From: 元动力
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
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
@MapperScan(basePackages = {"com.ydles.seckill.dao"})
@EnableScheduling
public class SeckillApplication {


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

@Bean
public IdWorker idWorker(){
return new IdWorker(1,1);
}

//设置redistemplate的序列化
@Bean
public RedisTemplate&lt;Object, Object&gt; redisTemplate(RedisConnectionFactory redisConnectionFactory) {
// 1.创建 redisTemplate 模版
RedisTemplate&lt;Object, Object&gt; template = new RedisTemplate&lt;&gt;();
// 2.关联 redisConnectionFactory
template.setConnectionFactory(redisConnectionFactory);
// 3.创建 序列化类
GenericToStringSerializer genericToStringSerializer = new GenericToStringSerializer(Object.class);
// 6.序列化类,对象映射设置
// 7.设置 value 的转化格式和 key 的转化格式
template.setValueSerializer(genericToStringSerializer);
template.setKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
  1. 添加dao层的两个mapper
From: 元动力
1
2
public interface SeckillGoodsMapper extends Mapper&lt;SeckillGoods&gt; {
}
From: 元动力
1
2
public interface SeckillOrderMapper extends Mapper&lt;SeckillOrder&gt; {
}
  1. 添加application.yml
From: 元动力
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
server:
port: 9016
spring:
jackson:
time-zone: GMT+8
application:
name: seckill
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://192.168.200.128:3306/ydles_seckill?useUnicode=true&amp;characterEncoding=utf-8&amp;useSSL=false&amp;allowMultiQueries=true&amp;serverTimezone=GMT%2b8
username: root
password: root
main:
allow-bean-definition-overriding: true #当遇到同样名字的时候,是否允许覆盖注册
redis:
host: 192.168.200.128
rabbitmq:
host: 192.168.200.128
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
instance:
prefer-ip-address: true
feign:
hystrix:
enabled: true
client:
config:
default: #配置全局的feign的调用超时时间 如果 有指定的服务配置 默认的配置不会生效
connectTimeout: 60000 # 指定的是 消费者 连接服务提供者的连接超时时间 是否能连接 单位是毫秒
readTimeout: 20000 # 指定的是调用服务提供者的 服务 的超时时间() 单位是毫秒
#hystrix 配置
hystrix:
command:
default:
execution:
timeout:
#如果enabled设置为false,则请求超时交给ribbon控制
enabled: true
isolation:
strategy: SEMAPHORE
thread:
# 熔断器超时时间,默认:1000/毫秒
timeoutInMilliseconds: 20000
  1. 添加公钥 copy认证服务的公钥

  2. 添加Oauth配置类 config包

From: 元动力
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
@Configuration
@EnableResourceServer
//开启方法上的PreAuthorize注解
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

//公钥
private static final String PUBLIC_KEY = "public.key";

/***
* 定义JwtTokenStore
* @param jwtAccessTokenConverter
* @return
*/
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}

/***
* 定义JJwtAccessTokenConverter
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setVerifierKey(getPubKey());
return converter;
}
/**
* 获取非对称加密公钥 Key
* @return 公钥 Key
*/
private String getPubKey() {
Resource resource = new ClassPathResource(PUBLIC_KEY);
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader br = new BufferedReader(inputStreamReader);
return br.lines().collect(Collectors.joining("\n"));
} catch (IOException ioe) {
return null;
}
}

/***
* Http安全配置,对每个到达系统的http请求链接进行校验
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
.anyRequest().
authenticated(); //其他地址需要认证授权
}
}
  1. 更改网关路径过滤类,添加秒杀工程过滤信息
image-20211228162810677
image-20211228162810677
  1. 更改网关配置文件,添加请求路由转发
From: 元动力
1
2
3
4
5
6
7
#秒杀微服务
- id: ydles_seckill_route
uri: lb://seckill
predicates:
- Path=/api/seckill/**
filters:
- StripPrefix=1

2.2 时间操作

2.2.1 秒杀商品时间段分析

image-20211228171058257
image-20211228171058257

根据产品原型图结合秒杀商品表设计可以得知,秒杀商品是存在开始时间与结束时间的,当前秒杀商品是按照秒杀时间段进行显示,如果当前时间在符合条件的时间段范围之内,则用户可以秒杀购买当前时间段之内的秒杀商品。

缓存数据加载思路:定义定时任务,每天凌晨会进行当天所有时间段秒杀商品预加载。并且在B端进行限制,添加秒杀商品的话,只能添加当前日期+1的时间限制,比如说:当前日期为8月5日,则添加秒杀商品时,开始时间必须为6日的某一个时间段,否则不能添加。

2.2.2 秒杀商品时间段计算

资源/DateUtil.java添加到公共服务中。基于当前工具类可以进行时间段的计算。

在该工具类中,进行时间计算测试:

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
//定义存储结果的集合
List&lt;Date&gt; dateList = new ArrayList&lt;&gt;();

//获取本日凌晨时间点
Date currentData = toDayStartHour(new Date());

//循环12次 (因为要获取每隔两个时间为一个时间段的值)
for (int i=0;i&lt;12;i++){
dateList.add(addDateHour(currentData,i*2));
}

for (Date date : dateList) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String format = simpleDateFormat.format(date);
System.out.println(format);
}
}

测试结果:

image-20211228171813908
image-20211228171813908

2.2.3 当前业务整体流程分析

From: 元动力
1
2
3
4
5
6
7
8
9
10
1.定时任务:查询所有符合条件的秒杀商品
1) 获取时间段集合并循环遍历出每一个时间段
2) 获取每一个时间段名称,用于后续redis中key的设置
3) 状态必须为审核通过 status=1
4) 商品库存个数&gt;0
5) 秒杀商品开始时间&gt;=当前时间段
6) 秒杀商品结束&lt;当前时间段+2小时
7) 排除之前已经加载到Redis缓存中的商品数据
8) 执行查询获取对应的结果集
2.将秒杀商品存入缓存

2.3 代码实现

2.3.1 更改启动类,添加开启定时任务注解

From: 元动力
1
@EnableScheduling

2.3.2 时间菜单分析

image-20211227224705978
image-20211227224705978

我们将商品数据从数据库中查询出来,并存入Redis缓存,但页面每次显示的时候,只显示当前正在秒杀以及往后延时2个小时、4个小时、6个小时、8个小时的秒杀商品数据。我们要做的第一个事是计算出秒杀时间菜单,这个菜单是从后台获取的。

这个时间菜单的计算我们来分析下,可以先求出当前时间的凌晨,然后每2个小时后作为下一个抢购的开始时间,这样可以分出12个抢购时间段,如下:

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
00:00-02:00
02:00-04:00
04:00-06:00
06:00-08:00
08:00-10:00
10:00-12:00
12:00-14:00
14:00-16:00
16:00-18:00
18:00-20:00
20:00-22:00
22:00-00:00

而现实的菜单只需要计算出当前时间在哪个时间段范围,该时间段范围就属于正在秒杀的时间段,而后面即将开始的秒杀时间段的计算也就出来了,可以在当前时间段基础之上+2小时、+4小时、+6小时、+8小时。

关于时间菜单的运算,在给出的DateUtil包里已经实现,代码如下:

From: 元动力
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
/***
* 获取时间菜单
* @return
*/
public static List&lt;Date&gt; getDateMenus(){
//定义一个List&lt;Date&gt;集合,存储所有时间段
List&lt;Date&gt; dates = getDates(12);
//判断当前时间属于哪个时间范围
Date now = new Date();
for (Date cdate : dates) {
//开始时间&lt;=当前时间&lt;开始时间+2小时
if(cdate.getTime()&lt;=now.getTime() &amp;&amp; now.getTime()&lt;addDateHour(cdate,2).getTime()){
now = cdate;
break;
}
}

//当前需要显示的时间菜单
List&lt;Date&gt; dateMenus = new ArrayList&lt;Date&gt;();
for (int i = 0; i &lt;5 ; i++) {
dateMenus.add(addDateHour(now,i*2));
}
return dateMenus;
}

/***
* 指定时间往后N个时间间隔
* @param hours
* @return
*/
public static List&lt;Date&gt; getDates(int hours) {
List&lt;Date&gt; dates = new ArrayList&lt;Date&gt;();
//循环12次
Date date = toDayStartHour(new Date()); //凌晨
for (int i = 0; i &lt;hours ; i++) {
//每次递增2小时,将每次递增的时间存入到List&lt;Date&gt;集合中
dates.add(addDateHour(date,i*2));
}
return dates;
}

2.3.2 定义定时任务类

秒杀工程新建task包,并新建任务类SeckillGoodsPushTask

业务逻辑:

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
/**
* 1.查询所有符合条件的秒杀商品
* 1) 获取时间段集合并循环遍历出每一个时间段
* 2) 获取每一个时间段名称,用于后续redis中key的设置
* 3) 状态必须为审核通过 status=1
* 4) 商品库存个数&gt;0
* 5) 秒杀商品开始时间&gt;=当前时间段
* 6) 秒杀商品结束&lt;当前时间段+2小时
* 7) 排除之前已经加载到Redis缓存中的商品数据
* 8) 执行查询获取对应的结果集
* 2.将秒杀商品存入缓存
*/

注意:用到很多工具类,了解功能即可

From: 元动力
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
/**
* 添加秒杀秒伤定时任务
*/
@Component
public class SeckillGoodsPushTask {

@Autowired
private SeckillGoodsMapper seckillGoodsMapper;

@Autowired
private RedisTemplate redisTemplate;

//redis key开头
public static final String SECKILL_GOODS_KEY = "seckill_goods_";

/**
* 定时将秒杀商品存入redis
* 暂定为30秒一次,正常业务为每天凌晨触发
*/
@Scheduled(cron = "0/30 * * * * ?")
public void loadSecKillGoodsToRedis() {
/**
* 1.查询所有符合条件的秒杀商品
* 1) 获取时间段集合并循环遍历出每一个时间段
* 2) 获取每一个时间段名称,用于后续redis中key的设置
* 3) 状态必须为审核通过 status=1
* 4) 商品库存个数&gt;0
* 5) 秒杀商品开始时间&gt;=当前时间段
* 6) 秒杀商品结束&lt;当前时间段+2小时
* 7) 排除之前已经加载到Redis缓存中的商品数据
* 8) 执行查询获取对应的结果集
* 2.将秒杀商品存入缓存
*/

//1) 获取时间段集合并循环遍历出每一个时间段
List&lt;Date&gt; dateMenus = DateUtil.getDateMenus(); // 5个

for (Date dateMenu : dateMenus) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

// 2) 获取每一个时间段名称,用于后续redis中key的设置
String redisExtName = DateUtil.date2Str(dateMenu);

Example example = new Example(SeckillGoods.class);
Example.Criteria criteria = example.createCriteria();
// 3) 状态必须为审核通过 status=1
criteria.andEqualTo("status", "1");
// 4) 商品库存个数&gt;0 gt
criteria.andGreaterThan("stockCount", 0);
// 5) 秒杀商品开始时间&gt;=当前时间段 gte
criteria.andGreaterThanOrEqualTo("startTime", simpleDateFormat.format(dateMenu));
// 6) 秒杀商品结束&lt;当前时间段+2小时 lt
criteria.andLessThan("endTime", simpleDateFormat.format(DateUtil.addDateHour(dateMenu, 2)));

// 7) 排除之前已经加载到Redis缓存中的商品数据
Set keys = redisTemplate.boundHashOps(SECKILL_GOODS_KEY + redisExtName).keys();//key field value
if (keys != null &amp;&amp; keys.size() &gt; 0) {
criteria.andNotIn("id", keys);
}

// 8) 执行查询获取对应的结果集
List&lt;SeckillGoods&gt; seckillGoodsList = seckillGoodsMapper.selectByExample(example);

//2.将秒杀商品存入缓存
for (SeckillGoods seckillGoods : seckillGoodsList) {
redisTemplate.opsForHash().put(SECKILL_GOODS_KEY + redisExtName, seckillGoods.getId(), seckillGoods);
}
}

}
}

测试

1修改一条数据,满足搜索条件

image-20211228180524421
image-20211228180524421

2结果查看redis

image-20211228180537463
image-20211228180537463

3 秒杀商品-首页-了解

image-20211228181401546
image-20211228181401546

秒杀商品首页会显示处于秒杀中以及未开始秒杀的商品。

3.1 秒杀首页实现分析

image-20211227224737382
image-20211227224737382

秒杀首页需要显示不同时间段的秒杀商品信息,然后当用户选择不同的时间段,查询该时间段下的秒杀商品,实现过程分为两大过程:

From: 元动力
1
2
1) 加载时间菜单
2)加载时间菜单下秒杀商品信息

3.1.1 加载时间菜单分析

image-20211228181433725
image-20211228181433725

每2个小时就会切换一次抢购活动,所以商品发布的时候,我们将时间定格在2小时内抢购,每次发布商品的时候,商品抢购开始时间和结束时间是这2小时的边界。

每2小时会有一批商品参与抢购,所以我们可以将24小时切分为12个菜单,每个菜单都是个2小时的时间段,当前选中的时间菜单需要根据当前时间判断,判断当前时间属于哪个秒杀时间段,然后将该时间段作为选中的第1个时间菜单。

3.1.2 加载对应秒杀商品分析

image-20211228181441279
image-20211228181441279

进入首页时,到后台查询时间菜单信息,然后将第1个菜单的时间段作为key,在Redis中查询秒杀商品集合,并显示到页面,页面每次点击切换不同时间段菜单的时候,都将时间段传入到后台,后台根据时间段获取对应的秒杀商品集合。

3.2 秒杀渲染服务 - 渲染秒杀首页

3.2.1 新建秒杀渲染服务

1)创建工程ydles_web_seckill,用于秒杀页面渲染

  1. 添加依赖
From: 元动力
1
2
3
4
5
6
7
8
9
10
11
&lt;dependencies&gt;
&lt;dependency&gt;
&lt;groupId&gt;com.ydles&lt;/groupId&gt;
&lt;artifactId&gt;ydles_service_seckill_api&lt;/artifactId&gt;
&lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-thymeleaf&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;/dependencies&gt;
  1. 添加启动类 com.ydles.seckill.webopen in new window
From: 元动力
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
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients(basePackages = "com.ydles.seckill.feign")
public class WebSecKillApplication {

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

@Bean
public FeignInterceptor feignInterceptor(){
return new FeignInterceptor();
}

/**
* 设置 redisTemplate 的序列化设置
* @param redisConnectionFactory
* @return
*/
@Bean
public RedisTemplate&lt;Object, Object&gt; redisTemplate(RedisConnectionFactory redisConnectionFactory) {
// 1.创建 redisTemplate 模版
RedisTemplate&lt;Object, Object&gt; template = new RedisTemplate&lt;&gt;();
// 2.关联 redisConnectionFactory
template.setConnectionFactory(redisConnectionFactory);
// 3.创建 序列化类
GenericToStringSerializer genericToStringSerializer = new GenericToStringSerializer(Object.class);
// 6.序列化类,对象映射设置
// 7.设置 value 的转化格式和 key 的转化格式
template.setValueSerializer(genericToStringSerializer);
template.setKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
  1. 添加application.yml
From: 元动力
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
server:
port: 9104
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
instance:
prefer-ip-address: true
feign:
hystrix:
enabled: true
spring:
jackson:
time-zone: GMT+8
thymeleaf:
cache: false
application:
name: seckill-web
main:
allow-bean-definition-overriding: true
redis:
host: 192.168.200.128
#hystrix 配置
hystrix:
command:
default:
execution:
timeout:
#如果enabled设置为false,则请求超时交给ribbon控制
enabled: true
isolation:
strategy: SEMAPHORE
thread:
timeoutInMilliseconds: 60000
#请求处理的超时时间
ribbon:
ReadTimeout: 4000
#请求连接的超时时间
ConnectTimeout: 3000
  1. 添加静态化资源
image-20211228181821971
image-20211228181821971

6)对接网关

From: 元动力
1
2
3
4
5
6
7
#秒杀渲染微服务
- id: ydles_seckill_web_route
uri: lb://seckill-web
predicates:
- Path=/api/wseckillgoods/**
filters:
- StripPrefix=1

3.3 时间菜单实现

时间菜单显示,先运算出每2小时一个抢购,就需要实现12个菜单,可以先计算出每个时间的临界值,然后根据当前时间判断需要显示12个时间段菜单中的哪个菜单,再在该时间菜单的基础之上往后挪4个菜单,一直显示5个时间菜单。

3.3.1 时间菜单获取

ydles_web_seckill com.ydles.seckill.web.controller 新增控制类SecKillGoodsController

From: 元动力
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
@Controller
@RequestMapping("/wseckillgoods")
public class SecKillGoodsController {

@Autowired
private SecKillGoodsFeign secKillGoodsFeign;

//跳转秒杀首页
@RequestMapping("/toIndex")
public String toIndex(){
return "seckill-index";
}

//获取秒杀时间段集合信息
@RequestMapping("/timeMenus")
@ResponseBody
public List&lt;String&gt; dateMenus(){

//获取当前时间段相关的信息集合
List&lt;Date&gt; dateMenus = DateUtil.getDateMenus(); //5个
//返回值
List&lt;String&gt; result = new ArrayList&lt;&gt;();

SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//格式化时间段
for (Date dateMenu : dateMenus) {
String format = simpleDateFormat.format(dateMenu);
result.add(format);
}

return result;

}

}

3.3.2 页面加载时间菜单

修改seckill-index.html

image-20211228183316377
image-20211228183316377
From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var app = new Vue({
el: '#app',
data() {
return {
goodslist: [],
dateMenus:[]
}
},
methods:{
loadMenus:function () {
axios.get("/api/wseckillgoods/timeMenus").then(function (response) {
app.dateMenus=response.data;

//查询当前时间段对应的秒杀商品
})
}
},
created:function () {
this.loadMenus();
}
})

1查看以下代码

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
&lt;div class="item-time active"
v-for="(item,index) in dateMenus"&gt;
&lt;div class="time-clock"&gt;{{item}}&lt;/div&gt;
&lt;div class="time-state-on"&gt;
&lt;span class="on-text" v-if="index==0"&gt;快抢中&lt;/span&gt;
&lt;span class="on-over" v-if="index==0"&gt;距离结束:01:02:03&lt;/span&gt;

&lt;span class="on-text" v-if="index&gt;0"&gt;即将开始&lt;/span&gt;
&lt;span class="on-over" v-if="index&gt;0"&gt;距离开始:03:02:01&lt;/span&gt;
&lt;/div&gt;
&lt;/div&gt;

2通过网关访问,需要将 moment.min.js 放入网关的静态资源中

image-20211228183457892
image-20211228183457892

3启动web-seckill 重启,访问 http://localhost:8001/api/wseckillgoods/toIndexopen in new window

效果如下:

image-20211228184523710
image-20211228184523710

3.3.3 时间格式化

上面菜单循环输出后,会出现如上图效果,时间格式全部不对,我们需要引入一个moment.min.js来格式化时间。

1)引入moment.min.js

2)添加过滤器

From: 元动力
1
2
3
4
//过滤器
Vue.filter("dateFilter", function(date, formatPattern){
return moment(date).format(formatPattern || "YYYY-MM-DD HH:mm:ss");
});
  1. 取值格式化
From: 元动力
1
&lt;div class="time-clock"&gt;{{item | dateFilter('HH:mm')}}&lt;/div&gt;

重启webseckill,重新访问:http://localhost:8001/api/wseckillgoods/toIndexopen in new window 。时间菜单效果如下

image-20211228220700312
image-20211228220700312

3.3.4 选中实现 -了解

3.3.4.1 思路分析
image-20211227225123244
image-20211227225123244

根据原型图,是让当前第一个时间菜单为选中状态,并且加载第一个菜单对应的数据。

我们可以先定义一个ctime=0,用来记录当前选中的菜单下标,因为默认第一个选中,第一个下标为0,所以初始值为0,每次点击对应菜单的时候,将被点击的菜单的下标值赋值给ctime,然后在每个菜单上判断,下标=ctime则让该菜单选中。

3.3.4.2 代码实现

1)定义ctime=0

From: 元动力
1
2
3
4
5
6
7
8
9
10
var app = new Vue({
el: '#app',
data() {
return {
goodslist: [],
dateMenus:[],
ctime:0 //当前时间菜单选中的下标
}
}
})

2)页面样式控制:

From: 元动力
1
2
3
4
5
6
7
8
9
10
&lt;div class="item-time " v-for="(item,index) in dateMenus" :class="['item-time',index==ctime?'active':'']" @click="ctime=index;"&gt;
&lt;div class="time-clock"&gt;{{item | dateFilter('HH:mm')}}&lt;/div&gt;
&lt;div class="time-state-on"&gt;
&lt;span class="on-text" v-if="index==0"&gt;快抢中&lt;/span&gt;
&lt;span class="on-over" v-if="index==0"&gt;距离结束:01:02:34&lt;/span&gt;

&lt;span class="on-text" v-if="index&gt;0"&gt;即将开始&lt;/span&gt;
&lt;span class="on-over" v-if="index&gt;0"&gt;距离开始:01:02:34&lt;/span&gt;
&lt;/div&gt;
&lt;/div&gt;

重启webseckill,重新访问:http://localhost:8001/api/wseckillgoods/toIndexopen in new window 。时间菜单效果如下

image-20211228221742713
image-20211228221742713

3.3.5 倒计时实现

3.3.5.1 倒计时实现
3.3.5.1.1 基础数据显示

定义一个集合,用于存放五个时间段的倒计时时间差,集合中每一个角标都对应一个倒计时时间差,比如:集合角标为0,对应第一个倒计时时间差。集合角标为1,对应第二个倒计时时间差,依次类推。

image-20211228233511074
image-20211228233511074
From: 元动力
1
alltimes:[5555555,66666666,77777777,888888888,9999999999]

2从该集合中获取内容,并更新倒计时时间

image-20211228233617374
image-20211228233617374
From: 元动力
1
2
&lt;span class="on-over" v-if="index==0"&gt;距离结束:{{alltimes[index]}}&lt;/span&gt;
&lt;span class="on-over" v-if="index&gt;0"&gt;距离开始:{{alltimes[index]}}&lt;/span&gt;

重启webseckill,重新访问:http://localhost:8001/api/wseckillgoods/toIndexopen in new window 。时间菜单效果如下

image-20211228222758434
image-20211228222758434
3.3.5.1.2 每个时间差倒计时实现

周期执行函数用法如下:

From: 元动力
1
window.setInterval(function(){//要做的事},1000);

结束执行周期函数用法如下:

From: 元动力
1
window.clearInterval(timers);

具体代码如下:

image-20211228233649926
image-20211228233649926
From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
//时间差递减
let timers = window.setInterval(function () {
for(var i=0;i&lt;app.alltimes.length;i++){
//时间递减
app.$set(app.alltimes,i,app.alltimes[i]-1000);
if(app.alltimes[i]&lt;=0){
//停止倒计时
window.clearInterval(timers);
//当任意一个时间&lt;=0的时候,需要重新刷新菜单,并刷新对应的数据
app.loadMenus();
}
}
},1000);

重启webseckill,重新访问:http://localhost:8001/api/wseckillgoods/toIndexopen in new window 。时间菜单效果如下可以发现每一个时间段的时间都在每秒递减。

image-20211228223420084
image-20211228223420084
3.3.5.1.3 倒计时时间格式化

将此工具引入页面js方法中,用于时间计算。注意和loadMenus方法平行

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//将毫秒转换成时分秒
timedown:function(num) {
var oneSecond = 1000;
var oneMinute=oneSecond*60;
var oneHour=oneMinute*60
//小时
var hours =Math.floor(num/oneHour);
//分钟
var minutes=Math.floor((num%oneHour)/oneMinute);
//秒
var seconds=Math.floor((num%oneMinute)/oneSecond);
//拼接时间格式
var str = hours+':'+minutes+':'+seconds;
return str;
}

修改时间差显示设置

image-20211228233710801
image-20211228233710801
From: 元动力
1
2
3
4
5
6
7
&lt;div class="time-state-on"&gt;
&lt;span class="on-text" v-if="index==0"&gt;快抢中&lt;/span&gt;
&lt;span class="on-over" v-if="index==0"&gt;距离结束:{{timedown(alltimes[index])}}&lt;/span&gt;

&lt;span class="on-text" v-if="index&gt;0"&gt;即将开始&lt;/span&gt;
&lt;span class="on-over" v-if="index&gt;0"&gt;距离开始:{{timedown(alltimes[index])}}&lt;/span&gt;
&lt;/div&gt;

重新访问进行测试。效果如下:

image-20211228223724484
image-20211228223724484
3.3.5.1.4 正确倒计时时间显示

现在页面中,对于倒计时时间集合内的数据,暂时写的为假数据,现在需要让集合内容的数据是经过计算得出的。第一个是距离结束时间倒计时,后面的4个都是距离开始倒计时,每个倒计时其实就是2个时差,计算方式如下:

From: 元动力
1
2
3
4
5
第1个时差:第2个抢购开始时间-当前时间,距离结束时间
第2个时差:第2个抢购开始时间-当前时间,距离开始时间
第3个时差:第3个抢购开始时间-当前时间,距离开始时间
第4个时差:第4个抢购开始时间-当前时间,距离开始时间
第5个时差:第5个抢购开始时间-当前时间,距离开始时间
image-20211228233749415
image-20211228233749415
From: 元动力
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
loadMenus:function () {
axios.get("/wseckill/timeMenus").then(function (response) {
app.dateMenus=response.data;

//查询当前时间段对应的秒杀商品

353-362 //循环所有时间菜单
for(var i=0;i&lt;app.dateMenus.length;i++){
//运算每个时间菜单倒计时时间差
if (i==0){
//距离结束时间
var x =i+1;
app.$set(app.alltimes,i,new Date(app.dateMenus[x]).getTime()-new Date().getTime());
} else{
//距离开始时间
app.$set(app.alltimes,i,new Date(app.dateMenus[i]).getTime()-new Date().getTime());
}
}

//时间差递减
let timers = window.setInterval(function () {
for(var i=0;i&lt;app.alltimes.length;i++){
//时间递减
app.$set(app.alltimes,i,app.alltimes[i]-1000);
}
},1000);
})
}

清空alltimes数据

From: 元动力
1
alltimes:[]

重启webseckill,重新访问:http://localhost:8001/api/wseckillgoods/toIndexopen in new window

image-20211228224140244
image-20211228224140244

3.4 加载秒杀商品实现

image-20211228224817607
image-20211228224817607

当前已经完成了秒杀时间段菜单的显示,那么当用户在切换不同的时间段的时候,需要按照用户所选择的时间去显示相对应时间段下的秒杀商品。

3.4.1 秒杀服务-查询秒杀商品列表

image-20211228224907652
image-20211228224907652
3.4.1.1 秒杀服务- service
From: 元动力
1
2
3
4
public interface SecKillGoodsService {
//查询秒杀商品列表
List&lt;SeckillGoods&gt; list(String time);
}

实现类

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class SecKillGoodsServiceImpl implements SecKillGoodsService {

@Autowired
private RedisTemplate redisTemplate;

private static final String SECKILL_KEY = "seckill_goods_";

/**
* 查询秒杀商品列表
* @param time
* @return
*/
@Override
public List&lt;SeckillGoods&gt; list(String time) {
return redisTemplate.boundHashOps(SECKILL_KEY+time).values();
}
}
3.4.1.2 秒杀服务-controller
From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/seckillgoods")
public class SecKillController {

@Autowired
private SecKillGoodsService secKillGoodsService;

/**
* 查询秒杀商品列表
* @param time
* @return
*/
@RequestMapping("/list")
public Result&lt;List&lt;SeckillGoods&gt;&gt; list(@RequestParam("time") String time){
List&lt;SeckillGoods&gt; seckillGoodsList = secKillGoodsService.list(time);
return new Result&lt;List&lt;SeckillGoods&gt;&gt;(true, StatusCode.OK,"查询秒杀商品成功",seckillGoodsList);
}
}
3.4.1.3 杀服务Api- feign接口定义
From: 元动力
1
2
3
4
5
6
7
8
9
10
11
@FeignClient(name="seckill")
public interface SecKillFeign {

/**
* 查询秒杀商品列表
* @param time
* @return
*/
@RequestMapping("/seckillgoods/list")
public Result&lt;List&lt;SeckillGoods&gt;&gt; list(@RequestParam("time") String time);
}
3.4.1.4 查询秒杀商品放行-想想为什么

更改秒杀微服务的ResourceServerConfig类,对查询方法放行

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
//下边的路径放行
.antMatchers(
"/seckillgoods/list/**"). //配置地址放行
permitAll()
.anyRequest().
authenticated(); //其他地址需要认证授权
}

3.4.2 秒杀渲染服务-查询秒杀商品列表

image-20211228230009990
image-20211228230009990

oauth2 :1 导包 2公钥 3配置类

调用feign:1导包 api 2EnableFeignClients 3feign拦截器

3.4.2.1 更新ydles_web_seckill的启动类

添加feign接口扫描

From: 元动力
1
@EnableFeignClients(basePackages = "com.ydles.seckill.feign")
3.4.2.2 更新ydles_web_seckill的SecKillGoodsController

注入secKillFeign,并添加获取秒杀商品列表方法实现

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
/**
* 获取秒杀商品列表
* 默认当前时间
*/
@RequestMapping("/list")
@ResponseBody
public Result&lt;List&lt;SeckillGoods&gt;&gt; list(String time){
String timeStr = DateUtil.formatStr(time);
System.out.println(timeStr);
return secKillGoodsFeign.list(timeStr);
}
3.4.2.3 更新secKill-index.html。添加按照时间查询方法
image-20200310181130832
image-20200310181130832
From: 元动力
1
2
3
4
5
6
7
8
//按照时间查询秒杀商品列表
searchList:function (time) {
axios.get('/api/wseckillgoods/list?time='+time).then(function (response) {
if (response.data.flag){
app.goodslist = response.data.data;
}
})
}
3.4.2.4 更新secKill-index.html。 加载页面时,默认当前时间查询
image-20211228233810290
image-20211228233810290
From: 元动力
1
2
//查询当前时间段对应的秒杀商品
app.searchList(app.dateMenus[0]);
3.4.2.5 更新secKill-index.html。切换时间菜单,查询秒杀商品
image-20211228233831720
image-20211228233831720
From: 元动力
1
2
3
4
5
&lt;div class="item-time "
v-for="(item,index) in dateMenus"
:class="['item-time',index==ctime?'active':'']"
@click="ctime=index;searchList(item)"&gt;
&lt;/div&gt;

重启秒杀和秒杀页面服务, http://localhost:8001/api/wseckillgoods/toIndexopen in new window

image-20211228232748991
image-20211228232748991

3.5 抢购按钮

因为当前业务设定为用户秒杀商品为sku,所以当用户点击立即抢购按钮的时候,则直接进行下单操作。

3.5.1 js定义

在秒杀首页添加下单方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
//秒杀下单
add:function(id){
app.msg ='正在下单';
axios.get("/api/wseckillorder/add?time="+moment(app.dateMenus[0]).format("YYYYMMDDHH")+"&amp;id="+id).then(function (response) {
if (response.data.flag){
app.msg='抢单成功,即将进入支付!';
}else{
app.msg='抢单失败';
}
})
}

3.5.2 调用下单方法

修改抢购按钮,添加事件

From: 元动力
1
&lt;a class='sui-btn btn-block btn-buy'  href='javascript:void(0)' @click="add(item.id)"&gt;立即抢购&lt;/a&gt;

3.5.3 秒杀web页面 新增Controller

SecKillOrderController

From: 元动力
1
2
3
4
5
6
7
8
9
10
@RestController
@RequestMapping("/wseckillorder")
public class SecKillOrderController {

@RequestMapping("/add")
public Result add(@RequestParam("time") String time, @RequestParam("id")Long id){
System.out.println("进入秒杀订单逻辑了!");
return null;
}
}

网关配置:

From: 元动力
1
2
3
4
5
6
7
#秒杀渲染微服务
- id: ydles_seckill_web_route
uri: lb://seckill-web
predicates:
- Path=/api/wseckillgoods/**,/api/wseckillorder/**
filters:
- StripPrefix=1

测试:重启秒杀页面,网关服务, 访问http://localhost:8001/api/wseckillgoods/toIndexopen in new window ,点击立即抢购

image-20211228235033816
image-20211228235033816

后台打印:

image-20211228235038565
image-20211228235038565

总结:

1秒杀业务

三高:

控制:时间段 数量

单独:数据库 微服务 页面

2秒杀商品 存入缓存

image-20211228160425046
image-20211228160425046

3首页

image-20211228235345701
image-20211228235345701

第16章-秒杀后端

角色:架构师

测试:功能测试 压力测试 1万/s 20万/s

1实现秒杀异步下单,掌握如何保证生产者&消费者消息不丢失

2实现防止恶意刷单

3实现防止相同商品重复秒杀

4实现秒杀下单接口隐藏

5实现下单接口限流

1 秒杀异步下单-重点-难点

用户在下单的时候,需要基于JWT令牌信息进行登陆人信息认证,确定当前订单是属于谁的。

为什么要异步下单:针对秒杀的特殊业务场景,仅仅依靠对象缓存或者页面静态化等技术去解决服务端压力还是远远不够。对于数据库压力还是很大,所以需要异步下单,异步是最好的解决办法,但会带来一些额外的程序上的复杂性

流程:异步 service_seckill接收下单消息---》MQ ------》service_consume 完成剩余操作

image-20220102104824795
image-20220102104824795

1.1 秒杀服务-下单实现

image-20220102115218841
image-20220102115218841

1)将tokenDecode工具类从order工程放入秒杀服务并声明Bean, ydles_service_seckill服务

image-20220102115503524
image-20220102115503524

启动类添加

From: 元动力
1
2
3
4
@Bean
public TokenDecode tokenDecode(){
return new TokenDecode();
}

2)新建下单controller并声明方法

ydles_service_seckill服务

From: 元动力
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
@RestController
@CrossOrigin
@RequestMapping("/seckillorder")
public class SecKillOrderController {

@Autowired
private TokenDecode tokenDecode;

@Autowired
private SecKillOrderService secKillOrderService;

/**
* 秒杀下单
* @param time 当前时间段
* @param id 秒杀商品id
* @return
*/
@RequestMapping("/add")
public Result add(@RequestParam("time") String time, @RequestParam("id") Long id){
//获取当前登陆人
String username = tokenDecode.getUserInfo().get("username");
boolean result = secKillOrderService.add(id,time,username);
if (result){
return new Result(true, StatusCode.OK,"下单成功");
}else{
return new Result(false,StatusCode.ERROR,"下单失败");
}
}
}

3) 新建service接口

ydles_service_seckill服务

From: 元动力
1
2
3
4
5
6
7
8
9
10
public interface SecKillOrderService {
/**
* 秒杀下单
* @param id 商品id
* @param time 时间段
* @param username 登陆人姓名
* @return
*/
boolean add(Long id, String time, String username);
}
image-20220102121935545
image-20220102121935545

4)更改预加载秒杀商品

当预加载秒杀商品的时候,提前加载每一个商品的库存信息,后续减库存操作也会先预扣减缓存中的库存再异步扣减mysql数据

预扣减库存会基于redis原子性操作实现

image-20220102122324659
image-20220102122324659
From: 元动力
1
2
3
4
5
6
//秒杀商品库存头
public static final String SECKILL_GOODS_STOCK_COUNT_KEY="seckill_goods_stock_count_";


//加载秒杀商品的库存
redisTemplate.opsForValue().set(SECKILL_GOODS_STOCK_COUNT_KEY + seckillGoods.getId(), seckillGoods.getStockCount());

5)秒杀下单业务层实现

业务逻辑:

获取秒杀商品数据与库存量数据,如果没有库存则抛出异常

预扣减库存,如果扣完库存量<0,删除商品数据与库存数据

如果库存量>=0,创建秒杀订单,并存入redis

基于mq异步方式完成与mysql数据同步(最终一致性)

注意:库存数据从redis中取出,转换成String

From: 元动力
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
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
@Service
public class SecKillOrderServiceImpl implements SecKillOrderService {
@Autowired
RedisTemplate redisTemplate;
@Autowired
IdWorker idWorker;
//redis 秒杀商品key开头
public static final String SECKILL_GOODS_KEY = "seckill_goods_";
//秒杀商品库存key头
public static final String SECKILL_GOODS_STOCK_COUNT_KEY = "seckill_goods_stock_count_";

//秒杀下单
@Override
public boolean add(Long id, String time, String username) {
//1redis获取商品的数据以及库存量,如果没有,抛出异常
SeckillGoods seckillGoods = (SeckillGoods) redisTemplate.boundHashOps(SECKILL_GOODS_KEY + time).get(id);
if (seckillGoods == null) {
return false;
}
//redisTemplate用的是string序列化
String redisStock = (String) redisTemplate.boundValueOps(SECKILL_GOODS_STOCK_COUNT_KEY + id).get();
if (StringUtils.isEmpty(redisStock)) {
return false;
}
int stock = Integer.parseInt(redisStock);
if (stock &lt;= 0) {
return false;
}

//2预扣减库存,如果扣成0,删除商品信息和库存信息
//Integer integer = (Integer) redisTemplate.opsForValue().get(SECKILL_GOODS_STOCK_COUNT_KEY + id);//100
//integer=integer-1;//99
//redisTemplate.boundValueOps(SECKILL_GOODS_STOCK_COUNT_KEY + id).set(integer);//99
Long decrement = redisTemplate.opsForValue().decrement(SECKILL_GOODS_STOCK_COUNT_KEY + id);
if(decrement&lt;=0){
//如果扣成0,删除商品信息
redisTemplate.boundHashOps(SECKILL_GOODS_KEY + time).delete(id);
//库存信息
redisTemplate.delete(SECKILL_GOODS_STOCK_COUNT_KEY + id);
}

//3生成秒杀订单
SeckillOrder seckillOrder=new SeckillOrder();
seckillOrder.setId(idWorker.nextId());
seckillOrder.setSeckillId(id);
seckillOrder.setMoney(seckillGoods.getCostPrice());
seckillOrder.setUserId(username);
seckillOrder.setSeckillId(Long.parseLong(seckillGoods.getSellerId()));
seckillOrder.setCreateTime(new Date());
seckillOrder.setStatus("0");

//4订单数据往mq发
return false;
}
}

1.2 生产者保证消息不丢失--面试常问问题-重点

image-20220102172807666
image-20220102172807666

1rabbitMQ 工作流程。

生产者:tcp --->channel--> exchang

消费者:tcp ------>channal------>queue 一个消费者监听一个 队列

绑定关系:交换机类型----绑定到队列----routingkey

2持久化

按照现有rabbitMQ的相关知识,生产者会发送消息到达消息服务器。但是在实际生产环境下,消息生产者发送的消息很有可能当到达了消息服务器之后,由于消息服务器的问题导致消息丢失,如宕机。因为消息服务器默认会将消息存储在内存中。一旦消息服务器宕机,则消息会产生丢失。因此要保证生产者的消息不丢失,要开始持久化策略

From: 元动力
1
2
3
4
rabbitMQ持久化:
1. 交换机持久化
2. 队列持久化
3. 消息持久化

3rabbitmq数据保护机制

但是如果仅仅只是开启这两部分的持久化,也很有可能造成消息丢失。因为消息服务器很有可能在持久化的过程中出现宕机。因此需要通过数据保护机制来保证消息一定会成功进行持久化,否则将一直进行消息发送。

From: 元动力
1
2
3
4
5
6
7
8
RabbitMQ数据保护机制
1 事务机制
事务机制采用类数据库的事务机制进行数据保护,当消息到达消息服务器,首先会开启一个事务,接着进行数据磁盘持久化,只有持久化成功才会进行事务提交,向消息生产者返回成功通知,消息生产者一旦接收成功通知则不会再发送此条消息。当出现异常,则返回失败通知.消息生产者一旦接收失败通知,则继续发送该条消息。
事务机制虽然能够保证数据安全,但是此机制采用的是同步机制,会产生系统间消息阻塞,影响整个系统的消息吞吐量。从而导致整个系统的性能下降,因此不建议使用。

2 confirm机制
confirm模式需要基于channel进行设置, 一旦某条消息被投递到队列之后,消息队列就会发送一个确认信息给生产者,如果队列与消息是可持久化的, 那么确认消息会等到消息成功写入到磁盘之后发出.
confirm的性能高,主要得益于它是异步的.生产者在将第一条消息发出之后等待确认消息的同时也可以继续发送后续的消息.当确认消息到达之后,就可以通过回调方法处理这条确认消息. 如果MQ服务宕机了,则会返回nack消息. 生产者同样在回调方法中进行后续处理。
image-20220102174038302
image-20220102174038302

拓展资料: https://www.cnblogs.com/vipstone/p/9350075.htmlopen in new window

4即使我们设计得这么完美,也不能保证数据100%全部发送、接受。

99.9999999% 机器运行成功率。 aws--->paypal youtube

1.2.1 开启confirm机制

ydles_service_seckill服务

1)更改秒杀服务配置文件

From: 元动力
1
2
3
rabbitmq:
host: 192.168.200.128
publisher-confirms: true #开启confirm机制

2)开启队列持久化 rabbit配置文件

rabbitMq使用:1导包 2配置文件 3配置类

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class RabbitMQConfig {

//秒杀商品订单消息
public static final String SECKILL_ORDER_QUEUE="seckill_order";

//声明队列,并开启持久化
@Bean
public Queue queue(){
/**
* 第一个参数:队列名称
* 第二个参数:是否开启队列持久化
*/
return new Queue(SECKILL_ORDER_QUEUE,true);
}
}

3)消息持久化源码查看

image-20220102175146418
image-20220102175146418
image-20220102175205923
image-20220102175205923
image-20220102180304161
image-20220102180304161
image-20220102180334328
image-20220102180334328
image-20220102180355716
image-20220102180355716

的确,在消息convert转换的时候,设置属性是持久化的。

**4)增强rabbitTemplate ** config包下新建。重要:此类相当于我们手动完成confirm逻辑!

From: 元动力
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
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
* 消息待发器
*/
@Component
public class ConfirmMessageSender implements RabbitTemplate.ConfirmCallback{
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
RedisTemplate redisTemplate;

//redis 放的时候 开头的key
public static final String MESSAGE_CONFIRM_KEY="message_confirm_";

//规定 有个构造器
public ConfirmMessageSender(RabbitTemplate rabbitTemplate){
//本类和容器中的rabbitTemplate 建立联系
this.rabbitTemplate=rabbitTemplate;
//使用rabbitTemplate发的消息,必须有回调,回调给本类
rabbitTemplate.setConfirmCallback(this);
}

//回调方法 exchange异步告知这条消息发送成功
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
if(ack){
//发送成功 删除掉存储空间的数据
redisTemplate.delete(correlationData.getId());
redisTemplate.delete(MESSAGE_CONFIRM_KEY+correlationData.getId());
}else {
//发送失败 重发
Map&lt;String, String&gt; map = redisTemplate.boundHashOps(MESSAGE_CONFIRM_KEY + correlationData.getId()).entries();
String exchange = map.get("exchange");
String routingKey = map.get("routingKey");
String message = map.get("message");

//重发
rabbitTemplate.convertAndSend(exchange,routingKey,message);
//预警 调用第三方接口:发短信,发邮件给到运维技术组长。log.warn
}
}

//自定义发送的方法
public void send(String exchange,String routingKey,String message){
//设置消息的唯一id
CorrelationData correlationData=new CorrelationData(UUID.randomUUID().toString());
//运维快速的看哪个msg有问题了
redisTemplate.boundValueOps(correlationData.getId()).set(message);

//保存发送的信息到 redis
Map&lt;String, String&gt; map=new HashMap&lt;&gt;();
map.put("exchange",exchange);
map.put("routingKey",routingKey);
map.put("message",message);
redisTemplate.boundHashOps(MESSAGE_CONFIRM_KEY+correlationData.getId()).putAll(map);

//真正发送
rabbitTemplate.convertAndSend(exchange,routingKey,message,correlationData);
}



}

1.2.2发送消息

更改下单业务层实现

ydles_service_seckill服务SecKillOrderServiceImpl类

From: 元动力
1
2
@Autowired
private ConfirmMessageSender confirmMessageSender;
From: 元动力
1
2
//发送消息
confirmMessageSender.sendMessage("", RabbitMQConfig.SECKILL_ORDER_KEY, JSON.toJSONString(seckillOrder));

测试:三个关键位置打断点:

1add方法

image-20220102183240326
image-20220102183240326

2confirmSender 的send

image-20220102183257423
image-20220102183257423

3confirmSender 回调方法

image-20220102183310057
image-20220102183310057

4基于网关访问 修改网关路由

From: 元动力
1
2
3
4
5
6
7
#秒杀微服务
- id: ydles_seckill_route
uri: lb://seckill
predicates:
- Path=/api/seckill/**,/api/seckillorder/**
filters:
- StripPrefix=1

5重启网关、秒杀服务。

登陆: http://localhost:8001/api/oauth/toLoginopen in new window

根据时间 模拟秒杀: http://localhost:8001/api/seckillorder/add?time=2022-01-02 18:00:00&id=1open in new window

1.3 秒杀下单服务-更新库存库

1.3.1 异步下单服务ydles_service_consume

1)添加依赖

From: 元动力
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
&lt;dependencies&gt;
&lt;dependency&gt;
&lt;groupId&gt;com.ydles&lt;/groupId&gt;
&lt;artifactId&gt;ydles_common_db&lt;/artifactId&gt;
&lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.cloud&lt;/groupId&gt;
&lt;artifactId&gt;spring-cloud-starter-netflix-eureka-client&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;com.ydles&lt;/groupId&gt;
&lt;artifactId&gt;ydles_service_order_api&lt;/artifactId&gt;
&lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;com.ydles&lt;/groupId&gt;
&lt;artifactId&gt;ydles_service_seckill_api&lt;/artifactId&gt;
&lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;com.ydles&lt;/groupId&gt;
&lt;artifactId&gt;ydles_service_goods_api&lt;/artifactId&gt;
&lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.amqp&lt;/groupId&gt;
&lt;artifactId&gt;spring-rabbit&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;/dependencies&gt;

2)新建application.yml

From: 元动力
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
server:
port: 9022
spring:
jackson:
time-zone: GMT+8
application:
name: sec-consume
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://192.168.200.128:3306/ydles_seckill?useUnicode=true&amp;characterEncoding=utf-8&amp;useSSL=false&amp;allowMultiQueries=true&amp;serverTimezone=GMT%2b8
username: root
password: root
main:
allow-bean-definition-overriding: true #当遇到同样名字的时候,是否允许覆盖注册
redis:
host: 192.168.200.128
rabbitmq:
host: 192.168.200.128
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
instance:
prefer-ip-address: true
feign:
hystrix:
enabled: true
client:
config:
default: #配置全局的feign的调用超时时间 如果 有指定的服务配置 默认的配置不会生效
connectTimeout: 60000 # 指定的是 消费者 连接服务提供者的连接超时时间 是否能连接 单位是毫秒
readTimeout: 20000 # 指定的是调用服务提供者的 服务 的超时时间() 单位是毫秒
#hystrix 配置
hystrix:
command:
default:
execution:
timeout:
#如果enabled设置为false,则请求超时交给ribbon控制
enabled: true
isolation:
strategy: SEMAPHORE
thread:
# 熔断器超时时间,默认:1000/毫秒
timeoutInMilliseconds: 20000

3)新建启动类 com.ydles.consume

From: 元动力
1
2
3
4
5
6
7
8
9
@SpringBootApplication
@EnableDiscoveryClient
@MapperScan(basePackages = {"com.ydles.consume.dao"})
public class OrderConsumerApplication {

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

1.3.2 消费者手动ACK下单实现--面试重点-认真听

image-20220102191752824
image-20220102191752824

​ 按照现有RabbitMQ知识,可以得知当消息消费者成功接收到消息后,会进行消费并自动通知消息服务器将该条消息删除。此种方式的实现使用的是消费者自动应答机制。但是此种方式非常的不安全。

​ 在生产环境下,当消息消费者接收到消息,很有可能在处理消息的过程中出现意外情况从而导致消息丢失,因为如果使用自动应答机制是非常不安全。

​ 我们需要确保消费者当把消息成功处理完成之后,消息服务器才会将该条消息删除。此时要实现这种效果的话,就需要将自动应答转换为手动应答,只有在消息消费者将消息处理完,才会通知消息服务器将该条消息删除。

1)更改配置文件

From: 元动力
1
2
3
4
5
rabbitmq:
host: 192.168.200.128
listener:
simple:
acknowledge-mode: manual #手动

mq配置类

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class RabbitMQConfig {

//秒杀商品订单消息
public static final String SECKILL_ORDER_QUEUE="seckill_order";

//声明队列,并开启持久化
@Bean
public Queue queue(){
/**
* 第一个参数:队列名称
* 第二个参数:是否开启队列持久化
*/
return new Queue(SECKILL_ORDER_QUEUE,true);
}
}

2)定义监听类 回顾rabbitMQ与springboot整合时,形参Channel channel, Message message意义。

com.ydles.consumer.listener

From: 元动力
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
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
@Component
public class SecKillOrderListener {
@Autowired
SeckillOrderService seckillOrderService;

@RabbitListener(queues = RabbitMQConfig.SECKILL_ORDER_QUEUE)
public void receiveMsg(Message message, Channel channel){
String msgStr = new String(message.getBody());
System.out.println("接受到了秒杀订单"+msgStr);
//1监听 order
SeckillOrder seckillOrder = JSON.parseObject(message.getBody(), SeckillOrder.class);
//2先做业务逻辑
int result = seckillOrderService.createOrder(seckillOrder);

if(result&gt;0){
//2.1没问题 告诉mq收到消息了,可删
try {
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (IOException e) {
e.printStackTrace();
//log.error
}
}else {
//2.2有问题 告诉mq没收到消息,重回队列
try {
channel.basicNack(message.getMessageProperties().getDeliveryTag(),false,true);
} catch (IOException e) {
e.printStackTrace();
//log.error
}
}

}

}

3)定义业务层接口与实现类

image-20220103094738284
image-20220103094738284
From: 元动力
1
2
3
public interface SecKillOrderService {
int createOrder(SeckillOrder seckillOrder);
}
From: 元动力
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
@Service
public class SecKillOrderServiceImpl implements SecKillOrderService {

@Autowired
private SeckillGoodsMapper seckillGoodsMapper;

@Autowired
private SeckillOrderMapper seckillOrderMapper;

/**
* 同步数据库,更改库存,添加订单
* @param seckillOrder
* @return
*/
@Override
@Transactional
public int createOrder(SeckillOrder seckillOrder) {

//更改库存
int result = seckillGoodsMapper.updateStockCountById(seckillOrder.getId());
if (result&lt;=0){
return result;
}

//添加订单
result = seckillOrderMapper.insertSelective(seckillOrder);
if (result&lt;=0){
return result;
}
return result;
}
}

4)dao层两个mapper从秒杀服务copy

5)启动类扫包

From: 元动力
1
@MapperScan(basePackages = {"com.ydles.consumer.dao"})

6)完善 SeckillGoodsMapper

From: 元动力
1
2
3
4
public interface SeckillGoodsMapper extends Mapper&lt;SeckillGoods&gt; {
@Update("update tb_seckill_goods set stock_count=stock_count-1 where id=#{id} and stock_count&gt;=1")
int updateStockCount(@Param("id") Long id);
}
From: 元动力
1
2
3
4
数据库字段unsigned介绍
unsigned-----无符号,修饰int 、char

ALTER TABLE tb_seckill_goods MODIFY COLUMN stock_count int(11) UNSIGNED DEFAULT NULL COMMENT '剩余库存数';

1.5 流量削峰

在秒杀这种高并发的场景下,每秒都有可能产生几万甚至十几万条消息,如果没有对消息处理量进行任何限制的话,很有可能因为过多的消息堆积从而导致消费者宕机的情况。因此官网建议对每一个消息消费者都设置处理消息总数(消息抓取总数)。

image-20220103100123839
image-20220103100123839

消息抓取总数的值,设置过大或者过小都不好,过小的话,会导致整个系统消息吞吐能力下降,造成性能浪费。过大的话,则很有可能导致消息过多,导致整个系统OOM(out of memory)内存溢出。因此官网建议每一个消费者将该值设置在100-300之间。

1)更新消费者。

ydles_service_consume服务SecKillOrderListener监听器

From: 元动力
1
2
3
4
5
6
7
8
/**
* 预抓取总数
*/
try {
channel.basicQos(300);
} catch (IOException e) {
e.printStackTrace();
}

1.6 秒杀渲染服务-下单实现

image-20220103100219129
image-20220103100219129

1)ydles_service_seckill_api服务定义feign接口

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
@FeignClient(name="seckill")
public interface SecKillOrderFeign {

/**
* 秒杀下单
* @param time 当前时间段
* @param id 秒杀商品id
* @return
*/
@RequestMapping("/seckillorder/add")
public Result add(@RequestParam("time") String time, @RequestParam("id") Long id);
}

2)ydles_web_seckill服务定义controller

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("/wseckillorder")
public class SecKillOrderController {
@Autowired
private SecKillOrderFeign secKillOrderFeign;

@RequestMapping("/add")
public Result add(@RequestParam("time") String time, @RequestParam("id")Long id){
// System.out.println("进入秒杀订单逻辑了!");

Result result = secKillOrderFeign.add(time, id);
return result;
}
}

测试: 重启相关服务,登陆,秒杀 http://localhost:8001/api/wseckillgoods/toIndexopen in new window

查看秒杀商品表和秒杀订单表数据变化。redis变化。

image-20220103101942966
image-20220103101942966
image-20220103101928814
image-20220103101928814

1.7秒杀商品真实数量获取

需求:

image-20220103102444911
image-20220103102444911

1从秒杀任务类中拿出 redis秒杀商品库存头

From: 元动力
1
2
//秒杀商品库存头
public static final String SECKILL_GOODS_STOCK_COUNT_KEY="seckill_goods_stock_count_";

2修改获取数据的业务方法 SecKillGoodsServiceImpl

From: 元动力
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
@Service
public class SecKillGoodsServiceImpl implements SecKillGoodsService {

@Autowired
private RedisTemplate redisTemplate;

private static final String SECKILL_KEY = "seckill_goods_";

//秒杀商品库存头
public static final String SECKILL_GOODS_STOCK_COUNT_KEY="seckill_goods_stock_count_";
/**
* 查询秒杀商品列表
*
* @param time
* @return
*/
@Override
public List&lt;SeckillGoods&gt; list(String time) {
List&lt;SeckillGoods&gt; list = redisTemplate.boundHashOps(SECKILL_KEY + time).values();

//更新库存数据的来源
for (SeckillGoods seckillGoods : list) {
String value = (String) redisTemplate.opsForValue().get(SECKILL_GOODS_STOCK_COUNT_KEY+seckillGoods.getId());
seckillGoods.setStockCount(Integer.parseInt(value));
}
return list;
}
}

测试: 重启相关服务,登陆,秒杀 http://localhost:8001/api/wseckillgoods/toIndexopen in new window

image-20220103102806192
image-20220103102806192

2 防止恶意刷单解决

​ 在生产场景下,很有可能会存在某些用户恶意刷单的情况出现。这样的操作对于系统而言,会导致业务出错、脏数据、后端访问压力大等问题的出现。

​ 一般要解决这个问题的话,需要前端进行控制,同时后端也需要进行控制。后端实现可以通过Redis incrde 原子性递增来进行解决。

2.1 更新秒杀服务下单

image-20220103111001121
image-20220103111001121

ydles_service_seckill服务的SecKillOrderServiceImpl类

From: 元动力
1
2
3
4
5
6
7
8
@Override
public boolean add(Long id, String time, String username) {
//防止用户恶意刷单
String result = this.preventRepeatCommit(username, id);
if ("fail".equals(result)){
return false;
}
}

2.2 防重方法实现

From: 元动力
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 username 用户名
* @param id 秒杀商品id
* @return
*/
private String preventRepeatCommit(String username,Long id){
String redis_key = "seckill_user_"+username+"_id_"+id;

long count = redisTemplate.opsForValue().increment(redis_key, 1);
if (count == 1){
//代表当前用户是第一次访问.
//对当前的key设置一个五分钟的有效期
redisTemplate.expire(redis_key,5, TimeUnit.MINUTES);
return "success";
}

if (count&gt;1){
return "fail";
}

return "fail";
}
}

3 防止相同商品重复秒杀

需求:一个userID,只能买一个秒杀商品。

解决:秒杀订单表根据userName和秒杀商品Id控制。

image-20220103112122408
image-20220103112122408

3.1 修改下单业务层实现

ydles_service_seckill服务的SecKillOrderServiceImpl类

From: 元动力
1
2
3
4
5
//防止重复购买
SeckillOrder querySeckillOrder=seckillOrderMapper.getOrderInfoByUserNameAndGoodsId(username,id);
if(querySeckillOrder!=null){
return false;
}

3.2 dao层新增查询方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
public interface SeckillOrderMapper extends Mapper&lt;SeckillOrder&gt; {

/**
* 查询秒杀订单信息
* @param username
* @param id
* @return
*/
@Select("select * from tb_seckill_order where user_id=#{username} and seckill_id=#{id}")
SeckillOrder getSecKillOrderByUserNameAndGoodsId(String username, Long id);
}

4 秒杀下单接口隐藏

背景:当前虽然可以确保用户只有在登录的情况下才可以进行秒杀下单,但是无法方法有一些恶意的用户在登录了之后,猜测秒杀下单的接口地址进行恶意刷单。所以需要对秒杀接口地址进行隐藏。

需求:在用户每一次点击抢购的时候,都首先去生成一个随机数并存入redis,接着用户携带着这个随机数去访问秒杀下单,下单接口首先会从redis中获取该随机数进行匹配,如果匹配成功,则进行后续下单操作,如果匹配不成功,则认定为非法访问。

这样可有防止黑客恶意大量调取后端的秒杀下单接口。

image-20220103114529621
image-20220103114529621

4.1 将随机数工具类放入common工程中

image-20220103114547263
image-20220103114547263

RandomUtil.java放入util包下

image-20220103114754530
image-20220103114754530
From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class RandomUtil {
public static String getRandomString() {
int length = 15;
String base = "abcdefghijklmnopqrstuvwxyz0123456789";
Random random = new Random();
StringBuffer sb = new StringBuffer();
for (int i = 0; i &lt; length; i++) {
int number = random.nextInt(base.length());
sb.append(base.charAt(number));
}
return sb.toString();
}

public static void main(String[] args) {
String randomString = RandomUtil.getRandomString();
System.out.println(randomString);
}
}

4.2 秒杀渲染服务定义随机数接口

1ydles_web_seckill服务 util包下放入资料中的CookieUtil.java工具类

2ydles_web_seckill服务WSecKillOrderController类

From: 元动力
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
@Autowired
RedisTemplate redisTemplate;
/**
* 生成随机数作为接口令牌, 有效期10秒
* @return
*/
@GetMapping("/getToken")
@ResponseBody
public String getToken() {
//获取随机字符串
String randomString = RandomUtil.getRandomString();
//获取jti
String cookieValue = this.readCookie();
//短令牌作为key, 随机字符串作为value
redisTemplate.opsForValue().set("randomcode_" + cookieValue, randomString, 10, TimeUnit.SECONDS);
//返回随机字符串
return randomString;
}

/**
* 读取cookie获取jti短令牌
*
* @return
*/
private String readCookie() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String jti = CookieUtil.readCookie(request, "uid").get("uid");
return jti;
}

4.3 js修改

修改js下单方法

From: 元动力
1
2
3
4
5
6
7
8
9
10
11
12
13
//秒杀下单
add:function (id) {
axios.get("/api/wseckillorder/getToken").then(function (response) {
var random =response.data;
axios.get("/api/wseckillorder/add?time="+moment(app.dateMenus[0]).format("YYYYMMDDHH")+"&amp;id="+id+"&amp;random="+random).then(function (response) {
if (response.data.flag){
alert("抢单成功,即将进入支付");
} else{
alert("抢单失败");
}
})
})
}

4.4 秒杀渲染服务更改

image-20220103153812440
image-20220103153812440

修改秒杀渲染服务下单接口

From: 元动力
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
@RestController
@RequestMapping("/wseckillorder")
public class WSecKillOrderController {

@Autowired
private SecKillOrderFeign secKillOrderFeign;

@Autowired
private RedisTemplate redisTemplate;

/**
* 秒杀下单
* @param time
* @param id
* @return
*/
@RequestMapping("/add")
public Result add(@RequestParam("time") String time, @RequestParam("id") Long id, String random) {

String cookieValue = this.readCookie();
String redisRandomCode = (String) redisTemplate.opsForValue().get("randomcode_" + cookieValue);
if (StringUtils.isEmpty(redisRandomCode)) {
return new Result(false, StatusCode.ERROR, "下单失败");
}
if (!random.equals(redisRandomCode)) {
return new Result(false, StatusCode.ERROR, "下单失败");
}

Result result = secKillOrderFeign.add(time, id);
return result;
}
}

5秒杀下单接口限流-难点-不是重点

因为秒杀的特殊业务场景,生产场景下,还有可能要对秒杀下单接口进行访问流量控制,防止过多的请求进入到后端服务器。对于限流的实现方式,我们之前已经接触过通过nginx限流网关限流。但是他们都是对一个大的服务进行访问限流,如果现在只是要对某一个服务中的接口方法进行限流呢?这里推荐使用google提供的guava工具包中的RateLimiter进行实现,其内部是基于令牌桶算法进行限流计算。

1限流 技术:guava

2自定义注解:@interface 基础的元注解信息

3aop 注解方式面向切面编程:前置增强、后置增强、环绕增强、最终增强

4springmvc 自带 HttpServletResponse response

5流 首先定义出来,最后关流

ydles_web_seckill服务

1添加依赖

From: 元动力
1
2
3
4
5
&lt;dependency&gt;
&lt;groupId&gt;com.google.guava&lt;/groupId&gt;
&lt;artifactId&gt;guava&lt;/artifactId&gt;
&lt;version&gt;28.0-jre&lt;/version&gt;
&lt;/dependency&gt;

2自定义限流注解

com.ydles.seckill.web.aspect

From: 元动力
1
2
3
4
5
6
@Inherited
@Documented
@Target({ElementType.FIELD,ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME) //不仅保存到class文件中,并且jvm加载class之后,该注解仍然存在
public @interface AccessLimit {
}

3自定义切面类

com.ydles.seckill.web.aspect包下

From: 元动力
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
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
@Component
@Scope
@Aspect //标明切面类
public class AccessLimitAop {
@Autowired
private HttpServletResponse response;
//设置令牌的生成速率
private RateLimiter rateLimiter = RateLimiter.create(2.0); //每秒生成两个令牌存入桶中

@Pointcut("@annotation(com.ydles.seckill.web.aspect.AccessLimit)")
public void limit(){
}

@Around("limit()")
public Object around(ProceedingJoinPoint proceedingJoinPoint){
//限流逻辑
//拿令牌
boolean result = rateLimiter.tryAcquire();
Object object = null;
if(result){
//拿到令牌了
try {
//切面往后走
object = proceedingJoinPoint.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}else {
//拿不到令牌
Result&lt;Object&gt; objectResult = new Result&lt;&gt;(false, StatusCode.ACCESSLIMIT, "限流了,请售后再试");
String msg = JSON.toJSONString(objectResult);
//将信息写回到用户的浏览器
writeMsg(response,msg);
}
return object;
}

//给用户写回数据
public void writeMsg(HttpServletResponse response,String msg){
ServletOutputStream outputStream=null;
try {
response.setContentType("application/json;charset=utf-8");
outputStream = response.getOutputStream();
outputStream.write(msg.getBytes("utf-8"));
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

4使用自定义限流注解

@AccessLimit放在下单方法上即可

image-20220103165242789
image-20220103165242789

总结:

1秒杀后台异步下单

image-20220103165527365
image-20220103165527365

mq 保证消息不丢

​ 1交换机 队列 消息 持久化

​ 2生产者:comfirm机制

image-20220103165641227
image-20220103165641227

​ 3消费者:手动ack

2防止恶意刷单

redis:key username_id 5min

3防止相同商品秒杀

数据库order查询 拒绝

4秒杀接口隐藏

image-20220103170003264
image-20220103170003264

5秒杀 接口限流

自定义注解 aop


本站由 钟意 使用 Stellar 1.28.1 主题创建。
又拍云 提供CDN加速/云存储服务
vercel 提供托管服务
湘ICP备2023019799号-1
总访问 次 | 本页访问