【云岚到家】-day09-3-优惠券核销
4 优惠券核销
4.1 需求分析
1)界面原型
用户在下单时使用优惠券得到优惠金额,实付金额等于订单金额减去优惠金额,下单成功优惠券核销成功。
下边通过界面原型进行分析。
在下单时选择可用的优惠券:
可用优惠券列表:是用户抢到的优惠券且符合满减规则的优惠券,如:满200减20元优惠券只能用在订单金额大于200元的订单。
用户选择一张优惠券,界面显示优惠金额,订单金额减去优惠金额为实际支付金额:
一张优惠券使用完毕将不能用在其它订单。
当订单取消后已使用的优惠券会重新退回到我的优惠券,此时用户又可以使用该优惠券。
2)可用优惠券列表数据分析
可用优惠券列表根据以下条件对用户的优惠券进行筛选:
- 属于当前用户的优惠券
- 符合下边条件的优惠券:
订单金额大于等于满减金额
优惠金额小于订单金额
优惠券还没有过期
优惠券还没有使用
可用优惠券列表的信息包括:
- 活动名称
- 优惠券类型
- 满减金额
- 折扣率
- 针对该订单的优惠金额(需要根据订单金额和优惠券去计算得出,前端需要此数据)
- 优惠券过期时间(已过期的优惠券无法使用)
在可用优惠券列表中前端需要拿到优惠金额,从而计算出订单实付金额。
订单的实付金额=订单金额-优惠金额。
根据订单金额和优惠券的优惠金额可以计算优惠金额:
- 针对满减:优惠券的优惠金额
- 针对折扣:订单金额乘以(1-折扣率)
3)核销优惠券
优惠券核销是指:顾客在购买商品时使用优惠券,当此次订单的消费符合优惠券的条件时在下单会使用该优惠券在原有订单金额基础上进行优惠,优惠券使用后标记为“已使用”。
优惠券核销后还可以取消核销,如果用户取消订单会将优惠券的状态改为“未使用” 退回的优惠券可以继续使用。
4)小结
可用优惠券列表的数据信息有哪些?
用户在下单时根据订单的金额进行筛选,满足优惠券规则的有效优惠券列表。
- 属于当前用户的优惠券
- 符合下边条件的优惠券:
订单金额大于等于满减金额
优惠金额小于订单金额
优惠券还没有过期
优惠券还没有使用
可用优惠券列表的信息包括:
- 活动名称
- 优惠券类型
- 满减金额
- 折扣率
- 针对该订单的优惠金额(需要根据订单金额和优惠券去计算得出,前端需要此数据)
- 优惠券过期时间(已过期的优惠券无法使用)
优惠券核销是什么意思?
顾客在购买商品时使用优惠券,当此次订单的消费符合优惠券的条件时在下单会使用该优惠券在原有订单金额基础上进行优惠,优惠券使用后标记为“已使用”。
优惠券核销后还可以取消核销,如果用户取消订单会将优惠券的状态改为“未使用” 退回的优惠券可以继续使用。
4.2 获取可用优惠券
根据需求分析,用户在下单时首先选择一张可用的优惠券,再提交订单进行优惠券核销。
下边先开发获取可以优惠券功能。
4.2.1 查询可用优惠券接口
1)交互流程
根据需求,查询可用的优惠券列表从优惠券表(coupon)中进行查询,筛选规则如下:
- 属于当前用户的优惠券
- 符合下边条件的优惠券:
订单金额大于等于满减金额
优惠金额小于订单金额
优惠券还没有过期
优惠券还没有使用
要筛选到用户可用的优惠券列表需要根据订单金额和用户ID进行筛选,现在有一个问题是:
是前端直接请求优惠券服务查询可用的优惠券列表还是请求订单管理服务再由订单管理服务通过内部接口调用服务拿结果,下图是左边的方案还是右边的方案?
本项目用右边的方案。
因为订单管理服务和优惠券服务的基本职责不同,订单管理服务属于业务方,优惠券服务为业务方提供优惠券服务。
虽然优惠券服务中管理了优惠券,但是最终决定是否给用户优惠的是订单服务,比如:用户购买VIP会员,用户是否可以用优惠券是由会员系统去决定。
所以前端请求查询可用优惠券列表的交互流程如下:
用户端进入下单页面,请求订单管理服务获取可用优惠券,订单管理服务远程请求优惠券服务获取可用优惠券。
2)查询可用优惠券接口定义
根据交互流程,先在优惠券服务定义“查询可用优惠券接口”。
根据刚才分析的需求,要筛选到用户可用的优惠券列表需要根据订单金额和用户ID。
当前用户ID是通过token传递。
订单金额通过参数传入。
接口定义如下:
接口名称:获取可用优惠券(内部接口)
接口功能:根据用户ID和订单金额查询可用优惠券列表
接口路径:GET/market/inner/coupon/getAvailable
请求数据类型 application/x-www-form-urlencoded
由于是内部接口需要先在jzo2o-api中定义接口:
@FeignClient(contextId = "jzo2o-market", value = "jzo2o-market", path = "/market/inner/coupon")
public interface CouponApi {
/**
* 获取可用优惠券列表,并按照优惠金额从大到小排序
* @param totalAmount 总金额,单位分
*/
@GetMapping("/getAvailable")
List<AvailableCouponsResDTO> getAvailable(@RequestParam("totalAmount") BigDecimal totalAmount);
...
api编写好需要上传到本地仓库
下边在优惠券服务进行实现:
创建com.jzo2o.market.controller.inner.CouponController
@RestController("innerCouponController")
@RequestMapping("/inner/coupon")
@Api(tags = "内部接口-优惠券相关接口")
public class CouponController implements CouponApi {
@Override
@GetMapping("/getAvailable")
@ApiOperation("获取可用优惠券列表")
@ApiImplicitParam(name = "totalAmount", value = "总金额,单位分", required = true, dataTypeClass = BigDecimal.class)
public List<AvailableCouponsResDTO> getAvailable(@RequestParam("totalAmount") BigDecimal totalAmount) {
return null;
}
}
重启优惠券服务查看接口文档是否正确。
访问正常
3)定义service接口
下边定义service接口,查询可用优惠券列表:
/**
* <p>
* 服务类
* </p>
*
* @author itcast
* @since 2023-09-16
*/
public interface ICouponService extends IService<Coupon> {
/**
* 获取可用优惠券列表
* @param totalAmount
* @return
*/
List<AvailableCouponsResDTO> getAvailable(BigDecimal totalAmount);
实现:
@Override
public List<AvailableCouponsResDTO> getAvailable(BigDecimal totalAmount) {
Long userId = UserContext.currentUserId();
// 1.查询优惠券
List<Coupon> coupons = lambdaQuery()
.eq(Coupon::getUserId, userId)
.eq(Coupon::getStatus, CouponStatusEnum.NO_USE.getStatus())
.gt(Coupon::getValidityTime, DateUtils.now())
.le(Coupon::getAmountCondition, totalAmount)
.list();
// 判空
if (CollUtils.isEmpty(coupons)) {
return new ArrayList<>();
}
// 2.组装数据计算优惠金额
List<AvailableCouponsResDTO> collect = coupons.stream()
.peek(coupon -> coupon.setDiscountAmount(CouponUtils.calDiscountAmount(coupon, totalAmount)))
//过滤优惠金额大于0且小于订单金额的优惠券
.filter(coupon -> coupon.getDiscountAmount().compareTo(new BigDecimal(0)) > 0 && coupon.getDiscountAmount().compareTo(totalAmount) < 0)
// 类型转换
.map(coupon -> BeanUtils.copyBean(coupon, AvailableCouponsResDTO.class))
//按优惠金额降序排
.sorted(Comparator.comparing(AvailableCouponsResDTO::getDiscountAmount).reversed())
.collect(Collectors.toList());
return collect;
}
阅读根据订单金额和优惠券信息计算优惠券金额的工具类:
/**
* 优惠券相关工具
*/
public class CouponUtils {
/**
* 计算优惠金额
*
* @param coupon 优惠券
* @param totalAmount 订单金额
* @return
*/
public static BigDecimal calDiscountAmount(Coupon coupon, BigDecimal totalAmount) {
// 满减优惠
if (ActivityTypeEnum.AMOUNT_DISCOUNT.equals(coupon.getType())) {
//满减金额
BigDecimal amountCondition = coupon.getAmountCondition();
//优惠金额
BigDecimal discountAmount = coupon.getDiscountAmount();
//如果订单金额小于满减金额不满足优惠条件
if(totalAmount.compareTo(amountCondition) < 0 ){
return BigDecimal.ZERO;
}
return discountAmount;
} else {//折扣优惠
//折扣率
Integer discountRate = coupon.getDiscountRate();
//折扣率非法
if(discountRate>=100 || discountRate<=0) return BigDecimal.ZERO;
//优惠率
BigDecimal rate = new BigDecimal(100 - coupon.getDiscountRate()).divide(new BigDecimal(100), 2, RoundingMode.DOWN);
//优惠金额
BigDecimal discountAmount = totalAmount.multiply(rate);
return discountAmount;
}
}
}
4)controller方法
@Override
@GetMapping("/getAvailable")
@ApiOperation("获取可用优惠券列表")
@ApiImplicitParam(name = "totalAmount", value = "总金额,单位分", required = true, dataTypeClass = BigDecimal.class)
public List<AvailableCouponsResDTO> getAvailable(@RequestParam("totalAmount") BigDecimal totalAmount) {
return couponService.getAvailable(totalAmount);
}
测试比较简单就不测试了
4.2.2 订单管理服务查询可用优惠券
下边在订单管理服务定义可用优惠券查询接口。
1)查询可用优惠券接口
订单管理需要调用优惠券服务,传入订单金额。
用户还没有下单,订单金额需要根据服务ID和数量获取。
如下图,购买1台空调维护的订单金额是17元。
前端需要将服务ID和数量传给订单管理服务,计算出订单金额,再调用优惠券服务获取可用优惠券列表。
接口定义如下:
接口名称:获取可用优惠券
接口功能:用户下单,小程序请求订单管理服务接口查询可用的优惠券
接口路径:GET /orders-manager/consumer/orders/getAvailableCoupons
请求数据类型 application/x-www-form-urlencoded
在com.jzo2o.orders.manager.controller.consumer.ConsumerOrdersController中:
在订单管理服务编写controller定义可用优惠券查询接口:
@GetMapping("/getAvailableCoupons")
@ApiOperation("获取可用优惠券")
@ApiImplicitParams({
@ApiImplicitParam(name = "serveId", value = "服务id", required = true, dataTypeClass = Integer.class),
@ApiImplicitParam(name = "purNum", value = "购买数量,默认1", required = false, dataTypeClass = Long.class)
})
public List<AvailableCouponsResDTO> getCoupons(@RequestParam(value = "serveId", required = true) Long serveId,
@RequestParam(value = "purNum", required = false, defaultValue = "1") Integer purNum) {
return null;
}
重启订单管理服务查看接口文档是否正确。
2)定义service接口
在com.jzo2o.orders.manager.service.IOrdersCreateService中
/**
* 获取可用优惠券
*
* @param serveId 服务id
* @param purNum 购买数量
* @return 可用优惠券列表
*/
List<AvailableCouponsResDTO> getAvailableCoupons(Long serveId, Integer purNum);
定义降级类
在com.jzo2o.orders.manager.service.impl.client.MarketClient中定义降级类
@Component
@Slf4j
public class MarketClient {
@Resource
private CouponApi couponApi;
@SentinelResource(value = "getAvailableByCouponApi", fallback = "getAvailableFallback", blockHandler = "getAvailableBlockHandler")
public List<AvailableCouponsResDTO> getAvailable(BigDecimal totalAmount) {
log.error("查询可用优惠券,订单金额:{}",totalAmount);
//调用可用优惠券
List<AvailableCouponsResDTO> available = couponApi.getAvailable(totalAmount);
return available;
}
//执行异常走
public List<AvailableCouponsResDTO> getAvailableFallback(BigDecimal totalAmount, Throwable throwable) {
log.error("非限流、熔断等导致的异常执行的降级方法,totalAmount:{},throwable:", totalAmount, throwable);
return Collections.emptyList();
}
//熔断后的降级逻辑
public List<AvailableCouponsResDTO> getAvailableBlockHandler(BigDecimal totalAmount, BlockException blockException) {
log.error("触发限流、熔断时执行的降级方法,totalAmount:{},blockException:", totalAmount, blockException);
return Collections.emptyList();
}
}
定义service接口实现
@Override
public List<AvailableCouponsResDTO> getAvailableCoupons(Long serveId, Integer purNum) {
// 1.获取服务
ServeAggregationResDTO serveResDTO = serveApi.findById(serveId);
if (serveResDTO == null || serveResDTO.getSaleStatus() != 2) {
throw new BadRequestException("服务不可用");
}
// 2.计算订单总金额
BigDecimal totalAmount = serveResDTO.getPrice().multiply(new BigDecimal(purNum));
// 3.获取可用优惠券,并返回优惠券列表
List<AvailableCouponsResDTO> available = marketClient.getAvailable(totalAmount);
return available;
}
3)定义controller方法
@GetMapping("/getAvailableCoupons")
@ApiOperation("获取可用优惠券")
@ApiImplicitParams({
@ApiImplicitParam(name = "serveId", value = "服务id", required = true, dataTypeClass = Integer.class),
@ApiImplicitParam(name = "purNum", value = "购买数量,默认1", required = false, dataTypeClass = Long.class)
})
public List<AvailableCouponsResDTO> getCoupons(@RequestParam(value = "serveId", required = true) Long serveId,
@RequestParam(value = "purNum", required = false, defaultValue = "1") Integer purNum) {
return ordersCreateService.getAvailableCoupons(serveId, purNum);
}
4)接口测试
测试流程:
启动优惠券服务
启动客户管理服务
启动订单服务
启动网关
启动运营管理前端
创建优惠券活动,设置门槛比较低或者创建折扣类的优惠券。
用户抢券成功
打开小程序进行下单,在选择优惠券界面跟踪“查询可用优惠券”接口。
预期:
查到可用的优惠券。
示例:
创建两个优惠券活动:全场洗澡八折、全场洗脚七折,注意活动开始时间设置和当前时间很近。
抢券前记得先开xxl-job的任务,虽然创建成功但是没有进行预热,还有redis的刷新同步也要开启
查看redis,预热成功
打开小程序开始抢券
有点小问题,未同步的10s内,同步队列里有用户id和活动id,但是redis的hash结构只允许一个用户id存在,也就是说同一时间10s内不能同时抢两张券
2024-11-07 17:26:08.780 DEBUG 17144 --- [o-11510-exec-10] com.jzo2o.mvc.filter.PackResultFilter : result : {"code":200,"msg":"OK","data": [{"id":"1851921214852177920","name":"码农洗澡大派送","type":1,"amountCondition":"100.00","discountRate":0,"discountAmount":"10.00","distributeStartTime":"2024-11-06 00:00:00","distributeEndTime":"2024-11-30 00:00:00","status":2,"remainNum":99,"totalNum":100,"stockNum":99},{"id":"1854453540735909888","name":"全场洗澡八折","type":2,"amountCondition":"0.00","discountRate":80,"discountAmount":null,"distributeStartTime":"2024-11-07 17:20:00","distributeEndTime":"2024-11-30 00:00:00","status":2,"remainNum":100,"totalNum":100,"stockNum":100},{"id":"1854453704900968448","name":"全场洗脚七折","type":2,"amountCondition":"0.00","discountRate":70,"discountAmount":null,"distributeStartTime":"2024-11-07 17:20:00","distributeEndTime":"2024-11-30 00:00:00","status":2,"remainNum":100,"totalNum":100,"stockNum":100}]}
2024-11-07 17:26:11.162 DEBUG 17144 --- [io-11510-exec-1] c.j.m.service.impl.CouponServiceImpl : seize coupon keys -> couponSeizeListRedisKey->COUPON:SEIZE:LIST:1854453540735909888_{8},resourceStockRedisKey->COUPON:RESOURCE:STOCK:{8},couponSeizeListRedisKey->COUPON:SEIZE:LIST:1854453540735909888_{8},seizeCouponReqDTO.getId()->1854453540735909888,UserContext.currentUserId():1837769073443094528
2024-11-07 17:26:11.203 DEBUG 17144 --- [io-11510-exec-1] c.j.m.service.impl.CouponServiceImpl : seize coupon result : 1854453540735909888
2024-11-07 17:26:11.203 DEBUG 17144 --- [io-11510-exec-1] com.jzo2o.mvc.filter.PackResultFilter : result : {"code":200,"msg":"OK","data": {}}
2024-11-07 17:26:12.930 DEBUG 17144 --- [io-11510-exec-2] c.j.m.service.impl.CouponServiceImpl : seize coupon keys -> couponSeizeListRedisKey->COUPON:SEIZE:LIST:1854453704900968448_{8},resourceStockRedisKey->COUPON:RESOURCE:STOCK:{8},couponSeizeListRedisKey->COUPON:SEIZE:LIST:1854453704900968448_{8},seizeCouponReqDTO.getId()->1854453704900968448,UserContext.currentUserId():1837769073443094528
2024-11-07 17:26:12.932 DEBUG 17144 --- [io-11510-exec-2] c.j.m.service.impl.CouponServiceImpl : seize coupon result : -5
2024-11-07 17:26:12.941 ERROR 17144 --- [io-11510-exec-2] c.j.mvc.advice.CommonExceptionAdvice : 请求异常,message:抢券失败,e
这个问题等会处理,七折券已经拿到,看能不能用
下单页面,能够使用,说明没问题
下来解决一下同步问题,思考了一下,如果把xxl-job的同步时间从10s改成1s,这样还是有这样的问题,如果想彻底解决这个问题,就必须修改同步表的结构和lua脚本。还是用hash,但是小key使用时间戳+用户id,value还是活动id,这样就能解决了。
在com.jzo2o.market.service.impl.CouponServiceImpl中
// 同步队列redisKey
String couponSeizeSyncRedisKey = RedisSyncQueueUtils.getQueueRedisKey(COUPON_SEIZE_SYNC_QUEUE_NAME, index);
//同步队列的field采用时间戳+用户id
String syn_field = Instant.now().getEpochSecond() + "_" + UserContext.currentUserId();
// 资源库存redisKey
String resourceStockRedisKey = String.format(COUPON_RESOURCE_STOCK, index);
// 抢券列表
String couponSeizeListRedisKey = String.format(COUPON_SEIZE_LIST, activity.getId(), index);
log.debug("seize coupon keys -> couponSeizeListRedisKey->{},resourceStockRedisKey->{},couponSeizeListRedisKey->{},seizeCouponReqDTO.getId()->{},UserContext.currentUserId():{}",
couponSeizeListRedisKey, resourceStockRedisKey, couponSeizeListRedisKey, seizeCouponReqDTO.getId(), UserContext.currentUserId());
// 3.抢券结果
Object execute = redisTemplate.execute(seizeCouponScript, Arrays.asList(couponSeizeSyncRedisKey, resourceStockRedisKey, couponSeizeListRedisKey),
seizeCouponReqDTO.getId(), UserContext.currentUserId(),syn_field);
lua脚本,使用第三个参数作为field
-- 抢单结果写入同步队列
local result = redis.call("HSETNX", KEYS[1], ARGV[3],ARGV[1])
if result > 0
then
return ARGV[1] ..""
还要修改com.jzo2o.market.handler.SeizeCouponSyncProcessHandler中的处理方法
@Override
@Transactional(rollbackFor = Exception.class)
public void singleProcess(SyncMessage<Object> singleData) {
log.info("获取要同步的数据: {}",singleData);
//用户id
String field = singleData.getKey();
//分割字符串
String[] split = field.split("_");
if(split.length != 2){
throw new RuntimeException("同步数据格式错误");
}
long userId = NumberUtils.parseLong(split[1]);
//活动id
long activityId = NumberUtils.parseLong(singleData.getValue().toString());
测试
完美解决
4.3 优惠券核销
4.3.1 优惠券核销接口设计
1)系统交互流程
说明如下:
- 优惠券核销
用户端请求订单管理服务创建订单信息,订单管理服务远程调用优惠券服务核销优惠券,下单成功且优惠券核销成功。
优惠券核销执行以下操作:
- 限制:订单金额大于等于满减金额。
- 限制:优惠券有效
- 根据优惠券id标记优惠券表中该优惠券已使用、使用时间、订单id等。
- 向优惠券核销表添加记录。
核销成功返回最终优惠的金额。
- 优惠券退回
用户端取消订单,订单管理服务执行取消订单逻辑,如果该订单使用了优惠券则请求优惠券服务退回优惠券。
优惠券退回执行以下操作:
- 添加优惠券退回记录。
- 更新优惠券,如果优惠券已过期则标记该优惠券已作废
- 更新优惠券,如果优惠券未过期,标记该优惠券未使用,清空订单id字段及使用时间字段。
- 删除核销记录。
2)数据流
3)表设计
优惠券核销表:
create table `jzo2o-market`.coupon_write_off
(
id bigint not null
primary key,
coupon_id bigint not null comment '优惠券id',
user_id bigint not null comment '用户id',
orders_id bigint not null comment '核销时使用的订单号',
activity_id bigint not null comment '活动id',
write_off_time datetime not null comment '核销时间',
write_off_man_phone varchar(20) null comment '核销人手机号',
write_off_man_name varchar(50) null comment '核销人姓名'
)
comment '优惠券核销表' charset = utf8mb4;
优惠券退回表:
create table `jzo2o-market`.coupon_use_back
(
id bigint not null comment '回退记录id'
primary key,
coupon_id bigint not null comment '优惠券id',
user_id bigint not null comment '用户id',
use_back_time datetime not null comment '回退时间',
write_off_time datetime null comment '核销时间'
)
comment '优惠券使用回退记录' charset = utf8mb4;
4)优惠券核销接口定义
优惠券服务提供优惠券核销接口、优惠券退回接口。
核销接口执行以下动作:
- 根据优惠券id标记优惠券表中该优惠券已使用。
- 向优惠券核销表添加记录。
请求参数:
根据订单总金额判断是否符合优惠券规则,传入订单金额。
核销接口核销优惠券需要传入优惠券ID。
需要记录具体的订单ID,需要传入订单ID。
响应结果:
核销后返回优惠金额。
核销接口定义如下:
接口名称:核销优惠券(内部接口)
接口功能:下单时调用此接口核销优惠券
接口路径:POST/market/inner/coupon/use
请求数据类型 application/json
在jzo2o-api工程下定义接口:com.jzo2o.api.market.dto.CouponApi
@FeignClient(contextId = "jzo2o-market", value = "jzo2o-market", path = "/market/inner/coupon")
public interface CouponApi {
/**
* 优惠券使用,并返回优惠金额
* @param couponUseReqDTO
*/
@PostMapping("/use")
CouponUseResDTO use(@RequestBody CouponUseReqDTO couponUseReqDTO);
install后在com.jzo2o.market.controller.inner.CouponController中编写controller方法:
@Override
@PostMapping("/use")
@ApiOperation("使用优惠券,并返回优惠金额")
public CouponUseResDTO use(@RequestBody CouponUseReqDTO couponUseReqDTO) {
return null;
}
5)优惠券退回接口定义
请求参数:
优惠券ID
需要记录退回优惠券用户的信息,传入用户ID
需要记录退回优惠券的订单ID,传入订单ID
响应结果:无
退回优惠券接口定义如下:
接口名称:退回优惠券(内部接口)
接口功能:取消订单调用此接口退回优惠券
接口路径:POST/market/inner/coupon/useBack
这些内部接口定义在jzo2o-api下com.jzo2o.api.market.dto.CouponApi
/**
* 优惠券退款回退
*/
@PostMapping("/useBack")
void useBack(@RequestBody CouponUseBackReqDTO couponUseBackReqDTO);
install后在com.jzo2o.market.controller.inner.CouponController编写controller方法:
@Override
@PostMapping("/useBack")
@ApiOperation("优惠券退回接口")
public void useBack(@RequestBody CouponUseBackReqDTO couponUseBackReqDTO) {
return;
}
4.3.2 优惠券核销接口实现
下边根据核销接口定义实现接口。
1)定义service方法
在com.jzo2o.market.service.ICouponService中编写service接口
/**
* 使用优惠券
* @param couponUseReqDTO
*/
CouponUseResDTO use(CouponUseReqDTO couponUseReqDTO);
定义service实现类
@Override
@Transactional(rollbackFor = Exception.class)
public CouponUseResDTO use(CouponUseReqDTO couponUseReqDTO) {
//判空
if (ObjectUtils.isNull(couponUseReqDTO.getOrdersId()) ||
ObjectUtils.isNull(couponUseReqDTO.getTotalAmount()))
{
throw new BadRequestException("优惠券核销的订单信息为空");
}
//用户id
Long userId = UserContext.currentUserId();
//查询优惠券信息
Coupon coupon = baseMapper.selectById(couponUseReqDTO.getId());
// 优惠券判空
if (coupon == null ) {
throw new BadRequestException("优惠券不存在");
}
if ( ObjectUtils.notEqual(coupon.getUserId(),userId)) {
throw new BadRequestException("只允许核销自己的优惠券");
}
//更新优惠券表的状态
boolean update = lambdaUpdate()
.eq(Coupon::getId, couponUseReqDTO.getId())
.eq(Coupon::getStatus, CouponStatusEnum.NO_USE.getStatus())
.gt(Coupon::getValidityTime, DateUtils.now())
.le(Coupon::getAmountCondition, couponUseReqDTO.getTotalAmount())
.set(Coupon::getOrdersId, couponUseReqDTO.getOrdersId())
.set(Coupon::getStatus, CouponStatusEnum.USED.getStatus())
.set(Coupon::getUseTime, DateUtils.now())
.update();
if (!update) {
throw new DBException("优惠券核销失败");
}
//添加核销记录
CouponWriteOff couponWriteOff = CouponWriteOff.builder()
.id(IdUtils.getSnowflakeNextId())
.couponId(couponUseReqDTO.getId())
.userId(userId)
.ordersId(couponUseReqDTO.getOrdersId())
.activityId(coupon.getActivityId())
.writeOffTime(DateUtils.now())
.writeOffManName(coupon.getUserName())
.writeOffManPhone(coupon.getUserPhone())
.build();
if(!couponWriteOffService.save(couponWriteOff)){
throw new DBException("优惠券核销失败");
}
// 计算优惠金额
BigDecimal discountAmount = CouponUtils.calDiscountAmount(coupon, couponUseReqDTO.getTotalAmount());
CouponUseResDTO couponUseResDTO = new CouponUseResDTO();
couponUseResDTO.setDiscountAmount(discountAmount);
return couponUseResDTO;
}
2)定义controller方法
在com.jzo2o.market.controller.inner.CouponController中
@Override
@PostMapping("/use")
@ApiOperation("使用优惠券,并返回优惠金额")
public CouponUseResDTO use(@RequestBody CouponUseReqDTO couponUseReqDTO) {
CouponUseResDTO use = couponService.use(couponUseReqDTO);
return use;
}
3)测试
测试流程:
重启优惠券服务
启动网关服务
抢券成功,确定要核销的优惠券。
使用swagger文档进行接口测试。
示例:
抢券成功,确定要核销的优惠券,下边是一个优惠券测试数据。
找个优惠券
打开http://localhost:11510/market/doc.html#swagger文档,输入测试数据:
id:将上边优惠券ID填入
totalAmount:填写一个满足优惠规则的金额
ordersId:随便填写一个订单id
核销优惠券还需要用户的token,从小程序开发平台获取
在swagger文档中添加全局参数,配置当前用户的认证信息:
参数名:Authorization
参数值:上图复制的token
如下图:
添加成功后需要重新打开接口,查看请求头部分是否有上图添加的信息
注意:在url前加上网关地址,通过网关解析token。
下边开始测试
在核销优惠券service中打断点
点击发送进行测试,通过断点跟踪代码执行过程。
成功拿到当前用户信息:
成功返回优惠金额
4.3.3 下单进行优惠券核销
1)修改下单方法
根据需求,下边在下单接口中调用优惠券核销接口。
定义使用优惠券下单的接口
在com.jzo2o.orders.manager.service.IOrdersCreateService中
/**
* 使用优惠券下单
* @param orders 订单信息
* @param couponId 优惠券id
*/
public void addWithCoupon(Orders orders, Long couponId);
service实现方法:
@Override
@Transactional(rollbackFor = Exception.class)
public void addWithCoupon(Orders orders, Long couponId) {
CouponUseReqDTO couponUseReqDTO = new CouponUseReqDTO();
couponUseReqDTO.setOrdersId(orders.getId());
couponUseReqDTO.setId(couponId);
couponUseReqDTO.setTotalAmount(orders.getTotalAmount());
//优惠券核销
CouponUseResDTO couponUseResDTO = couponApi.use(couponUseReqDTO);
// 设置优惠金额
orders.setDiscountAmount(couponUseResDTO.getDiscountAmount());
// 计算实付金额
BigDecimal realPayAmount = orders.getTotalAmount().subtract(orders.getDiscountAmount());
orders.setRealPayAmount(realPayAmount);
//保存订单
add(orders);
}
修改原有下单方法:
long sortBy = DateUtils.toEpochMilli(orders.getServeStartTime()) + orders.getId() % 100000;
orders.setSortBy(sortBy);
6 插入数据库
//owner.add(orders);
// 使用优惠券下单
if (ObjectUtils.isNotNull(placeOrderReqDTO.getCouponId())) {
// 使用优惠券
owner.addWithCoupon(orders, placeOrderReqDTO.getCouponId());
} else {
// 无优惠券下单,走本地事务
owner.add(orders);
}
//7 返回结果
return new PlaceOrderResDTO(orders.getId());
2) 测试
首先保证用户当前有未使用的优惠券。
技巧:找一个可用的优惠券,可以进入优惠券表找一个已用过的优惠券,修改优惠券的状态为“未使用”,删除核销记录。
测试流程:
启动jzo2o-foundations服务
启动jzo2o-customer服务。
启动jzo2o-publics服务。
启动jzo2o-gateway服务。
启动jzo2o-orders-manager服务。
启动jzo2o-market服务。
打开小程序进行下单。
打开小程序,进入首页,点击一个服务进行预约下单。
用户在下单界面选择一个优惠券
提交订单
启动:
提交订单:
提交成功查看优惠券信息:
status:状态改为已使用
orders_id:使用优惠券的订单id
use_time:使用优惠券时间
查看优惠券核销表:
4.4 分布式事务
4.4.1 什么是分布式事务
1)当前遇到的问题
下单时核销优惠券,创建订单和核销优惠券需要保证事务一致性,要么两者都成功,要么两者都失败,
现在的代码如下:
@Transactional(rollbackFor = Exception.class)
public void addWithCoupon(Orders orders, Long couponId) {
CouponUseReqDTO couponUseReqDTO = new CouponUseReqDTO();
couponUseReqDTO.setOrdersId(orders.getId());
couponUseReqDTO.setId(couponId);
couponUseReqDTO.setTotalAmount(orders.getTotalAmount());
//优惠券核销
CouponUseResDTO couponUseResDTO = couponApi.use(couponUseReqDTO);
// 设置优惠金额
orders.setDiscountAmount(couponUseResDTO.getDiscountAmount());
// 计算实付金额
BigDecimal realPayAmount = orders.getTotalAmount().subtract(orders.getDiscountAmount());
orders.setRealPayAmount(realPayAmount);
//保存订单
add(orders);
}
当调用couponApi.use(couponUseReqDTO)成功表示优惠券核销成功,当此方法向下继续执行如果抛出异常那么订单信息进行回滚,优惠券核销信息是否会回滚?
创建订单在订单管理服务,优惠券核销在优惠券服务,业务数据处在两个数据库中,创建订单和优惠券核销互为分布式事务,对上边addWithCoupon方法进行事务控制只是控制了订单数据库的事务,而没有控制优惠券数据库的事务。
如下图:
我们可以进行测试:
在add(orders);保存订单下边添加模拟异常代码。
@Transactional(rollbackFor = Exception.class)
public void addWithCoupon(Orders orders, Long couponId) {
...
//保存订单
add(orders);
//模拟异常
int i=1/0;
}
下边进行下单发现优惠券核销成功,订单创建失败。
查看优惠券表发现已经核销:
在订单数据库中 2311090000000000034是不存在的,此时数据不一致。
要解决这个问题需要理解什么是分布式事务并且去学习分布式事务的处理方案。
2)什么是本地事务?
要理解什么是分布式事务首先理解什么是本地事务。
平常我们在程序中通过spring去控制事务是利用数据库本身的事务特性来实现的,因此叫数据库事务,由于应用主要靠关系数据库来控制事务,此数据库只属于该应用,所以基于本应用自己的关系型数据库的事务又被称为本地事务。
本地事务具有ACID四大特性,数据库事务在实现时会将一次事务涉及的所有操作全部纳入到一个不可分割的执行单元,该执行单元中的所有操作 要么都成功,要么都失败,只要其中任一操作执行失败,都将导致整个事务的回滚。
回顾一下数据库事务的四大特性 ACID:
A(Atomic):原子性,构成事务的所有操作,要么都执行完成,要么全部不执行,不可能出现部分成功部分失
败的情况。
C(Consistency):一致性,在事务执行前后,数据库的一致性约束没有被破坏。比如:张三向李四转100元,
转账前和转账后的数据是正确状态这叫一致性,如果出现张三转出100元,李四账户没有增加100元这就出现了数
据错误,就没有达到一致性。
I(Isolation):隔离性,数据库中的事务一般都是并发的,隔离性是指并发的两个事务的执行互不干扰,一个事
务不能看到其他事务运行过程的中间状态。通过配置事务隔离级别可以避脏读、重复读等问题。
D(Durability):持久性,事务完成之后,该事务对数据的更改会被持久化到数据库,且不会被回滚。
3)什么是分布式事务
下边我们以下单扣减库存为例来说明什么是分布式事务。
在单体架构下实现下单减库存,如下图:
用户请求订单服务,订单服务请求数据库完成创建订单扣减库存,通过本地事务实现,代码如下:
begin transaction;
//1.本地数据库操作:创建订单
//2.本地数据库操作:减去库存
commit transation;
如果是在微服务架构下,如下图:
用户请求订单服务下单,订单服务请求库存服务扣减库存。
begin transaction;
//1.本地数据库操作:创建订单
//2.远程调用:减去库存
commit transation;
设想: 当远程调用扣减库存成功了,由于网络问题远程调用并没有返回,此时本地事务提交失败就回滚了创建订单的操作,此时订单没有创建成功而库存却扣减了,最终就导致了下单扣减库存整个事务的数据不一致。
因此在分布式架构下,基于数据库的事务控制无法满足要求,下单操作是一次本地事务,扣减库存是一次本地事务,两次本地事务组成一个完整的事务即下单扣减库存,数据库的本地事务只能控制一次本地事务即下单操作控制下单的本地事务,扣减库存操作控制扣减库存的本地事务,无法保证下单和扣减库存整体事务的原子性和一致性。
像这种,在分布式系统环境下由多个服务通过网络通信协作去完成一次事务,这称之为分布式事务。
造成分布式事务无法控制的根本原因是不同业务的数据通常不在一个数据库中或者不在一个系统中,一次事务需要由多个服务或多个系统远程调用协作去完成,远程协作依赖网络,由于网络问题会导致整体事务不能正常完成。
4)非典型分布式事务场景
微服务架构下的分布式事务场景是典型的分布式事务场景,还有非典型的分布式事务场景也需要了解下。
1)单服务请求多数据库完成一次事务
下图中虽然没有跨服务远程调用但一次事务请求两个不同的数据库也属于分布式事务的场景,创建订单会和订单数据库创建数据库连接通过一次本地事务提交数据,减库存会和商品数据库创建数据库连接通过一次本地事务提交数据,这里仍然是多次本地事务共同完成一个完整的事务即下单扣减库存。
2)多服务请求单数据库完成一次事务
下图中虽然用的一个数据库但是通过跨服务远程调用去完成一次事务,也属于分布式事务的场景。
思考下这种场景为什么也属于分布式事务?
分布式事务的场景可以总结为:
1、跨服务完成一次事务
2、跨数据源完成一次事务
5)小结
什么是本地事务?
基于应用自己的关系型数据库的事务称为本地事务,在service方法通过添加@Transactional注解进行本地事务控制。
什么是分布式事务?
在分布式系统环境下由多个服务通过网络通信协作去完成一次事务,这称之为分布式事务。
分布式事务的场景有哪些?
多个微服务之间通过远程调用完成一次分布式事务。
单服务请求多数据库完成一次事务。
多服务请求单数据库完成一次事务。
4.4.2 什么是CAP
遇到了分布式事务的场景我们该如何去进行事务控制呢,本节学习如何选型分布式事务的控制方案。
1)什么是CAP原理
首先需要理解什么是CAP原理,明白了CAP原理有助于我们去选型分布式事务的控制方案。
CAP是 Consistency、Availability、Partition tolerance三个词语的缩写,分别表示一致性、可用性、分区容忍性。
使用下图去理解CAP:
下图表示客户端经过网关访问订单服务,库存服务
一致性: 向系统写一个新数据再次读取到的也一定是这个新数据。拿上图举例,请求订单服务下单,订单服务请求库存服务扣减库存,只要下单成功则库存扣减成功。
**可用性:**任何时间都可以访问订单服务和库存服务,系统保证可用。
**分区容忍性:**也叫分区容错性,分布式系统在部署时服务器可能部署在不同的网络分区,比如上图中订单服务在北京,库存服务在上海,由于处于不同的网络分区如果网络通信异常就会导致节点 之间无法通信,当出现由于网络问题导致节点 之间无法通信,此时仍然是对外提供服务的这叫做满足分区容忍性。
CAP理论要强调在分布式系统中C、A、P这三点不能全部满足。
由于是分布式系统就要满足分区容忍性,因为分布式系统难免存在网络分区,不能因为网络异常导致整个系统不可用,所以P是一定要满足的。
满足P,那么C和A不能同时满足。
拿上图举例说明:
当订单服务与库存服务出现网络通信异常,订单服务无法访问库存服务,此时如果要保证数据一致性则下单接口必须不可用,如果要保证可用性数据将出现不一致。
2)分布式事务控制如何应用CAP原理
学习了CAP理论我们知道进行分布式事务控制要在C和A中作出取舍,进行分布式事务控制要么保证CP要么保证AP。
具体要根据应用场景进行判断,下边举例说明CP和AP业务场景的例子。
符合CP的场景:满足C舍弃A,强调一致性。
金融系统:一般需要在多个账户之间进行交易或资金转移的操作通常需要满足CP,这是因为在这种场景下,数据的一致性是至关重要的,确保不会发生资金丢失、重复扣款或其他意外情况,源账号和目标账号的转账结果要么都成功要么都失败,不会存在一个成功一个失败的情况。
库存系统:在多个仓库之间进行库存转移或销售操作时,需要确保库存的一致性,防止商品超卖或库存混乱。
订票系统:需要确保预订信息的一致性,避免出现同一个资源被多次预订的问题。
Zookeeper:可作为注册中心,支持CP,拿主节点选举举例,当主节点异常进行选举,选举期间所有节点不可用,保证数据的一致性。
Redis:Redis主从模式是CP模式,当主从通信异常此时无法向主节点写数据。
Nacos:Nacos也支持CP,不过它默认是AP模式,当客户端注册为非临时节点时此时为CP模式,注册为非临时节点就需要实时上报心跳,即使在一段时间内未收到心跳信息,该实例仍然会保留在服务列表中,适用于配置中心。
符合AP的场景:满足A舍弃C,强调可用性。
AP强调的是可用性,允许短暂的不一致但是要保证最终一致性,在实际应用中符合AP的场景较多。
订单退款:退款后状态为退款中,24小时后退款金额到帐。
积分系统:注册送积分,注册成功积分在24小时后到账。
跨行转账:一般转账支持CP,还有的支持AP,源账号扣减金额后需要等一段时间目标账户才到账,或者源账号扣款后由于目标账号有问题过一段时间将转账金额退回到源账户。
MySQL主从复制:支持AP,向主节点写数据,异步同步到的从节点。
Nacos:默认支持AP,即临时节点的情况,会实时上报心跳,如果一段时间内未收到心跳信息,Nacos 会将该实例标记为不可用并从服务列表中移除。
在生产中AP场景应用的更多,强调的是可用性,允许出现短暂不一致,最终达到数据一致性。
3)小结
什么是CAP原理?
CAP分别表示一致性、可用性、分区容忍性.
CAP理论要强调在分布式系统中C、A、P这三点不能全部满足,要么满足AP、要么满足CP。
CAP原理对分布式事务控制有什么帮助?
根据需求确定是保证CP还是AP,再选择具体的技术方案。
4.4.3 优惠券核销事务控制方案
1)优惠券核销满足AP还是CP?
根据我们的需求,创建订单和优惠券核销两个操作构成分布式事务,要对它们进行分布式事务控制基于CAP理论我们要满足CP还是AP?
如下图:
满足CP的要求:
创建订单和优惠券核销要么都成功要么都失败,不能存在一个成功一个失败,如果要实现CP需要在下单和核销优惠券操作前进行一次预操作,如果预操作成功将优惠券锁定避免在执行事务期间优惠券被其它订单使用。
满足AP的要求:
创建订单和优惠券核销要么都成功要么都失败,可以暂时存在一个成功一个失败,最终要保证数据的一致性,如果要实现AP,不需要提前锁定资源,在执行事务期间有一个失败则么另一个操作回滚即可,最终实现数据一致性。
基于上边的分析,实现CP更麻烦,实现AP同样满足的需求,本项目优惠券核销操作实现AP。
2)用什么技术方案实现AP
- 一方先成功另一方最终成功
对于一方先成功另一方最终成功的需求,比如:注册成功送积分,支付成功发送短信等这些场景,注册成功后向MQ发送消息,积分服务接收到消息后增加积分。
针对上边的需求还可以使用定时任务完成。
- 一方成功另一个方失败,成功方进行回滚
对于一方成功另一方失败,为保证最终一致性,成功一方需要回滚,如:下单扣库存,下单成功扣减库存失败此时下单业务回滚;优惠券核销,下单成功优惠券核销失败,此时下单操作回滚,或者下单失败,优惠券核销成功此时优惠券核销操作回滚。
针对上边的需求使用Seata进行分布式事务控制。
Seata提供了四种不同的分布式事务解决方案:
- XA模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
- TCC模式:最终一致的分阶段事务模式,有业务侵入
- AT模式:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式
- SAGA模式:长事务模式,有业务侵入
下边说明Seata的AT模式:
首先理解Seata事务管理中有三个重要的角色:
- TC (Transaction Coordinator) - **事务协调者:**维护全局和分支事务的状态,协调全局事务提交或回滚。
- TM (Transaction Manager) - **事务管理器:**定义全局事务的范围、开始全局事务、提交或回滚全局事务。
- RM (Resource Manager) - **资源管理器:**管理分支事务处理的资源,与TC交互注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
如下图
说明:
- 事务管理器(TM)请求TC开启全局事务
- 事务管理器(TM)请求RM执行分支事务
下单RM:先向事务协调器(TC)注册下单分支事务,并执行sql(记录undo log),并向TC报告事务状态
undo log:用于回滚事务,seata根据undo log会生成反向sql进行事务回滚,举例:下单sql正常是向订单插入一条数据,seata生成的反向sql即删除订单记录,通过执行反向sql实现事务回滚。
优惠券RM:先向事务协调器(TC)注册优惠券核销分支事务,并执行sql(记录undo log),并向TC报告事务状态
- 事务管理器(TM)请求TC提交全局事务
- TC检查分支事务状态,如果发现都成功则请求每个RM删除undo log,如果发现其中有失败记录则请求每个RM回滚事务,RM回滚事务的方法是通过undo log执行反向sql。
Seata执行的细节如下:
3)小结
优惠券核销满足AP还是CP?
实现AP的技术方案有哪些?
基于MQ的方案
基于定时任务的方案
使用Seata。
本项目实现优惠券核销使用什么技术方案?
采用Seata实现分布式事务控制
4.4.4 优惠券核销事务控制实现
1)启动seata 的事务协调器
启动seata容器(TC): docker start seata-server
如果没有使用下发的虚拟机需要自行安装Seata
拉取镜像:
docker pull seataio/seata-server:1.5.2
创建目录:
mkdir -p /data/soft/seata-jzo2o/data /data/soft/seata-jzo2o/config
将配置resources.tar 上传到服务器解压到/data/soft/seata-jzo2o/config
解压resources.tar : tar xvf resources.tar
注意修改resources中application.yml中nacos的配置
server:
port: 7091
spring:
application:
name: seata-server
console:
user:
username: seata
password: seata
logging:
config: classpath:logback-spring.xml
file:
path: ${user.home}/logs/seata
seata:
config:
# support: nacos 、 consul 、 apollo 、 zk 、 etcd3
type: nacos
nacos:
server-addr: 192.168.101.68:8848
namespace: 75a593f5-33e6-4c65-b2a0-18c403d20f63
group: DEFAULT_GROUP
username: nacos
password: nacos
##if use MSE Nacos with auth, mutex with username/password attribute
#access-key: ""
#secret-key: ""
data-id: seata-server.properties
registry:
# support: nacos 、 eureka 、 redis 、 zk 、 consul 、 etcd3 、 sofa
type: nacos
#preferred-networks: 30.240.*
nacos:
application: seata-server
server-addr: 192.168.101.68:8848
group: DEFAULT_GROUP
namespace: 75a593f5-33e6-4c65-b2a0-18c403d20f63
cluster: default
username: nacos
password: nacos
##if use MSE Nacos with auth, mutex with username/password attribute
#access-key: ""
#secret-key: ""
server:
service-port: 8091 #If not configured, the default is '${server.port} + 1000'
max-commit-retry-timeout: -1
max-rollback-retry-timeout: -1
rollback-retry-timeout-unlock-enable: false
enableCheckAuth: true
retryDeadThreshold: 130000
xaerNotaRetryTimeout: 60000
recovery:
handle-all-session-period: 1000
undo:
log-save-days: 7
log-delete-period: 86400000
session:
branch-async-queue-size: 5000 #branch async remove queue size
enable-branch-async-remove: false #enable to asynchronous remove branchSession
metrics:
enabled: false
registry-type: compact
exporter-list: prometheus
exporter-prometheus-port: 9898
transport:
rpc-tc-request-timeout: 30000
enable-tc-server-batch-send-response: false
shutdown:
wait: 3
thread-factory:
boss-thread-prefix: NettyBoss
worker-thread-prefix: NettyServerNIOWorker
boss-thread-size: 1
security:
secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017
tokenValidityInMilliseconds: 1800000
ignore:
urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.ico,/console-fe/public/**,/api/v1/auth/login
创建seata数据库,导入seata.sql
登录nacos配置seata-server.properties配置文件,内容如下:
store.mode = db
store.db.datasource = druid
store.db.dbType = mysql
store.db.driverClassName = com.mysql.cj.jdbc.Driver
store.db.url = jdbc:mysql://192.168.101.68:3306/seata?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&useSSL=false
store.db.user = root
store.db.password = mysql
store.db.minConn = 5
store.db.maxConn = 100
store.db.globalTable = global_table
store.db.branchTable = branch_table
store.db.lockTable = lock_table
store.db.distributedLockTable = distributed_lock
store.db.queryLimit = 100
store.db.maxWait = 5000
#seata.tm.global_transaction_timeout = 60000
#seata.tm.beginTimeout = 5000
创建容器,seata端口号:8091(程序交互端口,根据情况进行修改),7091(管理端工具端口,根据情况进行修改)
docker run -d \
--name seata-server \
--restart always \
-p 8091:8091 \
-p 7091:7091 \
-v /data/soft/seata-jzo2o/config/resources:/seata-server/resources \
-e SEATA_IP=192.168.101.65 \
seataio/seata-server:1.5.2
测试,登录地址http://192.168.101.68:7091,账号和密码均为seata/seata,首次登录可能会慢稍等1-2分钟
2)配置seata环境
修改订单管理服务的bootstrap.yml,添加seata配置文件
在jzo2o-orders-manager/src/main/resources/bootstrap.yml中
在订单基础工程jzo2o-orders-base和优惠券工程jzo2o-market中添加seata依赖
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-seata</artifactId>
</dependency>
在订单的三个数据库和优惠券数据库中创建undo_log表,此表记录每个分支事务的undo_log信息。
create table `数据库名`.undo_log
(
id bigint auto_increment
constraint `PRIMARY`
primary key,
branch_id bigint not null,
xid varchar(100) not null,
context varchar(128) not null,
rollback_info longblob not null,
log_status int not null,
log_created datetime not null,
log_modified datetime not null,
ext varchar(100) null,
constraint ux_undo_log
unique (xid, branch_id)
)
charset = utf8;
3)开启全局事务测试
修改下单方法,在核销优惠券方法中开启全局事务
@GlobalTransactional
public void addWithCoupon(Orders orders, Long couponId) {
CouponUseReqDTO couponUseReqDTO = new CouponUseReqDTO();
couponUseReqDTO.setOrdersId(orders.getId());
couponUseReqDTO.setId(couponId);
couponUseReqDTO.setTotalAmount(orders.getTotalAmount());
//优惠券核销
CouponUseResDTO couponUseResDTO = couponApi.use(couponUseReqDTO);
// 设置优惠金额
orders.setDiscountAmount(couponUseResDTO.getDiscountAmount());
// 计算实付金额
BigDecimal realPayAmount = orders.getTotalAmount().subtract(orders.getDiscountAmount());
orders.setRealPayAmount(realPayAmount);
//保存订单
add(orders);
//模拟异常
int i=1/0;
}
上边的方法仍然有模拟异常的代码,下边进行测试分布式事务控制。
重启订单管理服务和优惠券服务
重新下单,选择一个优惠券提交订单
预期结果:
下单异常,优惠券核销回滚
进入优惠券控制台查看关于seata事务回滚的日志:
重新下个单
因为有除0自然是异常的
2024-11-11 20:53:43.450 INFO 18864 --- [h_RMROLE_1_1_24] io.seata.rm.AbstractRMHandler : Branch Rollbacking: 192.168.101.68:8091:18599673733107713 18599673733107726 jdbc:mysql://192.168.101.68:3306/jzo2o-orders-1
2024-11-11 20:53:43.480 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Logic SQL: SELECT * FROM undo_log WHERE branch_id = ? AND xid = ? FOR UPDATE
2024-11-11 20:53:43.480 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-1 ::: SELECT * FROM undo_log WHERE branch_id = ? AND xid = ? FOR UPDATE ::: [18599673733107726, 192.168.101.68:8091:18599673733107713]
2024-11-11 20:53:43.539 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Logic SQL: SELECT * FROM biz_snapshot WHERE (id) in ( (?) ) FOR UPDATE
2024-11-11 20:53:43.540 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-0 ::: SELECT * FROM biz_snapshot_0 WHERE (id) in ( (?) ) FOR UPDATE ::: [1855957074627248128]
2024-11-11 20:53:43.540 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-0 ::: SELECT * FROM biz_snapshot_1 WHERE (id) in ( (?) ) FOR UPDATE ::: [1855957074627248128]
2024-11-11 20:53:43.540 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-0 ::: SELECT * FROM biz_snapshot_2 WHERE (id) in ( (?) ) FOR UPDATE ::: [1855957074627248128]
2024-11-11 20:53:43.540 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-1 ::: SELECT * FROM biz_snapshot_0 WHERE (id) in ( (?) ) FOR UPDATE ::: [1855957074627248128]
2024-11-11 20:53:43.540 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-1 ::: SELECT * FROM biz_snapshot_1 WHERE (id) in ( (?) ) FOR UPDATE ::: [1855957074627248128]
2024-11-11 20:53:43.540 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-1 ::: SELECT * FROM biz_snapshot_2 WHERE (id) in ( (?) ) FOR UPDATE ::: [1855957074627248128]
2024-11-11 20:53:43.540 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-2 ::: SELECT * FROM biz_snapshot_0 WHERE (id) in ( (?) ) FOR UPDATE ::: [1855957074627248128]
2024-11-11 20:53:43.540 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-2 ::: SELECT * FROM biz_snapshot_1 WHERE (id) in ( (?) ) FOR UPDATE ::: [1855957074627248128]
2024-11-11 20:53:43.540 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-2 ::: SELECT * FROM biz_snapshot_2 WHERE (id) in ( (?) ) FOR UPDATE ::: [1855957074627248128]
2024-11-11 20:53:43.573 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Logic SQL: DELETE FROM biz_snapshot WHERE id = ?
2024-11-11 20:53:43.573 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-0 ::: DELETE FROM biz_snapshot_0 WHERE id = ? ::: [1855957074627248128]
2024-11-11 20:53:43.573 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-0 ::: DELETE FROM biz_snapshot_1 WHERE id = ? ::: [1855957074627248128]
2024-11-11 20:53:43.573 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-0 ::: DELETE FROM biz_snapshot_2 WHERE id = ? ::: [1855957074627248128]
2024-11-11 20:53:43.573 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-1 ::: DELETE FROM biz_snapshot_0 WHERE id = ? ::: [1855957074627248128]
2024-11-11 20:53:43.573 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-1 ::: DELETE FROM biz_snapshot_1 WHERE id = ? ::: [1855957074627248128]
2024-11-11 20:53:43.573 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-1 ::: DELETE FROM biz_snapshot_2 WHERE id = ? ::: [1855957074627248128]
2024-11-11 20:53:43.573 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-2 ::: DELETE FROM biz_snapshot_0 WHERE id = ? ::: [1855957074627248128]
2024-11-11 20:53:43.573 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-2 ::: DELETE FROM biz_snapshot_1 WHERE id = ? ::: [1855957074627248128]
2024-11-11 20:53:43.573 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-2 ::: DELETE FROM biz_snapshot_2 WHERE id = ? ::: [1855957074627248128]
2024-11-11 20:53:43.590 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Logic SQL: DELETE FROM undo_log WHERE branch_id = ? AND xid = ?
2024-11-11 20:53:43.590 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-1 ::: DELETE FROM undo_log WHERE branch_id = ? AND xid = ? ::: [18599673733107726, 192.168.101.68:8091:18599673733107713]
2024-11-11 20:53:43.590 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-2 ::: DELETE FROM undo_log WHERE branch_id = ? AND xid = ? ::: [18599673733107726, 192.168.101.68:8091:18599673733107713]
2024-11-11 20:53:43.590 INFO 18864 --- [h_RMROLE_1_1_24] ShardingSphere-SQL : Actual SQL: jzo2o-orders-0 ::: DELETE FROM undo_log WHERE branch_id = ? AND xid = ? ::: [18599673733107726, 192.168.101.68:8091:18599673733107713]
2024-11-11 20:53:43.601 INFO 18864 --- [h_RMROLE_1_1_24] i.s.r.d.undo.AbstractUndoLogManager : xid 192.168.101.68:8091:18599673733107713 branch 18599673733107726, undo_log deleted with GlobalFinished
2024-11-11 20:53:43.617 INFO 18864 --- [h_RMROLE_1_1_24] io.seata.rm.AbstractRMHandler : Branch Rollbacked result: PhaseTwo_Rollbacked
通过日志Branch Rollbacked result: PhaseTwo_Rollbacked可以看出分布式事务回滚完成。
4.5 退回优惠券(实战)
取消订单退回优惠券。
根据需求分析实现优惠券退回逻辑。
4.5.1 优惠券服务中
1)controller
在com.jzo2o.market.controller.inner.CouponController中
@Override
@PostMapping("/useBack")
@ApiOperation("优惠券退回接口")
public void useBack(@RequestBody CouponUseBackReqDTO couponUseBackReqDTO) {
couponService.useBack(couponUseBackReqDTO);
return;
}
2)service
/**
* 优惠券退回
* @param couponUseBackReqDTO
*/
void useBack(CouponUseBackReqDTO couponUseBackReqDTO);
实现
/**
* 优惠券退回
* @param couponUseBackReqDTO
*/
@Override
public void useBack(CouponUseBackReqDTO couponUseBackReqDTO) {
// 1.校验
if (ObjectUtils.isNull(couponUseBackReqDTO.getId())) {
throw new BadRequestException("优惠券id为空");
}
// 2.查询优惠券
Coupon coupon = baseMapper.selectById(couponUseBackReqDTO.getId());
if (coupon == null) {
throw new BadRequestException("优惠券不存在");
}
// 3.校验优惠券状态
if (ObjectUtils.notEqual(coupon.getStatus(), CouponStatusEnum.USED.getStatus())) {
throw new BadRequestException("优惠券状态不正确");
}
// 4.更新优惠券状态
boolean update = lambdaUpdate()
.eq(Coupon::getId, couponUseBackReqDTO.getId())
.eq(Coupon::getStatus, CouponStatusEnum.USED.getStatus())
.set(Coupon::getStatus, CouponStatusEnum.NO_USE.getStatus())
.set(Coupon::getOrdersId, null)
.set(Coupon::getUseTime, null)
.update();
if (!update) {
throw new DBException("优惠券退回失败");
}
// 5.添加退回记录
CouponUseBack couponUseBack = new CouponUseBack();
couponUseBack.setId(IdUtils.getSnowflakeNextId());
couponUseBack.setCouponId(couponUseBackReqDTO.getId());
couponUseBack.setUserId(coupon.getUserId());
couponUseBack.setUseBackTime(DateUtils.now());
couponUseBack.setWriteOffTime(coupon.getUseTime());
if (!couponUseBackService.save(couponUseBack)) {
throw new DBException("优惠券退回失败");
}
}
4.5.2 订单服务中
订单管理中的取消订单会远程调用优惠券服务中的useBack接口,使用seata对其进行管理即可。
在取消订单中,针对使用优惠券的,调用远程接口退回,否则调用本地事务方法。
@Override
public void cancel(OrderCancelDTO orderCancelDTO) {
//查询订单信息
Orders orders = getById(orderCancelDTO.getId());
BeanUtils.copyProperties(orders,orderCancelDTO);
if (ObjectUtil.isNull(orders)) {
throw new DbRuntimeException("找不到要取消的订单,订单号:{}",orderCancelDTO.getId());
}
//订单状态
Integer ordersStatus = orders.getOrdersStatus();
if(Objects.equals(OrderStatusEnum.NO_PAY.getStatus(), ordersStatus)){ //订单状态为待支付
if(orders.getDiscountAmount()!=null){
owner.cancelByNoPayWithCoupon(orderCancelDTO);
}
else{
owner.cancelByNoPay(orderCancelDTO);
}
}else if(Objects.equals(OrderStatusEnum.DISPATCHING.getStatus(), ordersStatus)){ //订单状态为待服务
if(orders.getDiscountAmount()!=null){
owner.cancelByDispatchingWithCoupon(orderCancelDTO);
}
else{
owner.cancelByDispatching(orderCancelDTO);
}
//新启动一个线程请求退款
ordersHandler.requestRefundNewThread(orders.getId());
}else{
throw new CommonException("当前订单状态不支持取消");
}
}
1)待支付退回优惠券
/**
* 未支付状态取消订单(有优惠券)
* @param orderCancelDTO
*/
@GlobalTransactional
private void cancelByNoPayWithCoupon(OrderCancelDTO orderCancelDTO) {
CouponUseBackReqDTO couponUseBackReqDTO = new CouponUseBackReqDTO();
couponUseBackReqDTO.setOrdersId(orderCancelDTO.getId());
couponUseBackReqDTO.setUserId(orderCancelDTO.getUserId());
couponApi.useBack(couponUseBackReqDTO);
cancelByNoPay(orderCancelDTO);
}
2)待服务退回优惠券
/**
* 派单中状态取消订单(有优惠券)
* @param orderCancelDTO
*/
@GlobalTransactional
private void cancelByDispatchingWithCoupon(OrderCancelDTO orderCancelDTO) {
CouponUseBackReqDTO couponUseBackReqDTO = new CouponUseBackReqDTO();
couponUseBackReqDTO.setOrdersId(orderCancelDTO.getId());
couponUseBackReqDTO.setUserId(orderCancelDTO.getUserId());
couponApi.useBack(couponUseBackReqDTO);
cancelByDispatching(orderCancelDTO);
}
4.5.3 测试
重新下个订单,弄个已支付的
我使用的码农洗脚七折券
已经被核销了
小程序已经显示已使用
我们发起退款
显示请求失败,什么情况,我们排查一下
准备发起远程调用,发现是远程调用报空指针异常。
找到问题了,idea自动生成的居然是private,全局事务管理的是公共方法
改了之后发现还是不行,不能不填id,还需要一个请求查询对应订单使用的优惠券id的接口
在api中定义后install
/**
* 获取优惠券id
* @param couponUseReqDTO
* @return
*/
@PostMapping("/getCouponId")
Long getCouponId(@RequestBody CouponUseReqDTO couponUseReqDTO);
market中实现
controller、service想必大家也会写
接口实现
@Override
public Long getCouponId(CouponUseReqDTO couponUseReqDTO) {
//使用订单id查询优惠券id
Coupon coupon = lambdaQuery()
.eq(Coupon::getOrdersId, couponUseReqDTO.getOrdersId())
.one();
if (coupon == null) {
throw new BadRequestException("优惠券不存在");
}
return coupon.getId();
}
重新启动测试,还是退款
取消成功
退款到账,查看数据库
优惠券已经回来了,非常成功,但是还是有点小问题,发现忘了删write_off表里的数据了,但是无伤大雅。加上就好。
在com.jzo2o.market.service.impl.CouponServiceImpl#useBack中
@Override
public void useBack(CouponUseBackReqDTO couponUseBackReqDTO) {
// 1.校验
if (ObjectUtils.isNull(couponUseBackReqDTO.getId())) {
throw new BadRequestException("优惠券id为空");
}
// 2.查询优惠券
Coupon coupon = baseMapper.selectById(couponUseBackReqDTO.getId());
if (coupon == null) {
throw new BadRequestException("优惠券不存在");
}
// 3.校验优惠券状态
if (ObjectUtils.notEqual(coupon.getStatus(), CouponStatusEnum.USED.getStatus())) {
throw new BadRequestException("优惠券状态不正确");
}
// 4.更新优惠券状态
boolean update = lambdaUpdate()
.eq(Coupon::getId, couponUseBackReqDTO.getId())
.eq(Coupon::getStatus, CouponStatusEnum.USED.getStatus())
.set(Coupon::getStatus, CouponStatusEnum.NO_USE.getStatus())
.set(Coupon::getOrdersId, null)
.set(Coupon::getUseTime, null)
.update();
if (!update) {
throw new DBException("优惠券退回失败");
}
// 5.添加退回记录
CouponUseBack couponUseBack = new CouponUseBack();
couponUseBack.setId(IdUtils.getSnowflakeNextId());
couponUseBack.setCouponId(couponUseBackReqDTO.getId());
couponUseBack.setUserId(coupon.getUserId());
couponUseBack.setUseBackTime(DateUtils.now());
couponUseBack.setWriteOffTime(coupon.getUseTime());
//6. 删除writeOff记录
boolean remove = couponWriteOffService.lambdaUpdate()
.eq(CouponWriteOff::getCouponId, couponUseBackReqDTO.getId())
.remove();
if (!remove) {
throw new DBException("核销记录删除失败");
}
if (!couponUseBackService.save(couponUseBack)) {
throw new DBException("优惠券退回失败");
}
}
标签:day09,优惠券,云岚到,核销,事务,id,订单,orders
From: https://blog.csdn.net/qq_45400167/article/details/143749727