目录
核销优惠券
一、我的优惠券
需求分析:
小程序用户,进入"我的"-->"我的优惠券"就可以查询自己已抢到的优惠券(按抢券时间降序显示)本查询为滚动查询,向上拖屏幕查询下一屏,一屏显示10条

用户抢到优惠券有三个状态:
-
未使用:未过有效期的优惠券
-
已使用:已经在订单中使用的优惠券
-
已过期:未使用且已过有效期的优惠券

接口文档:
接口地址:GET /market/consumer/coupon/my
请求、响应参数:


根据上述需求,我么可以写出如下的SQL查询语句:
sql
-- 查询指定用户的优惠券
select * from coupon
where user_id = 当前登录用户id
and status = 前端传入的状态
and id < 前端传入的最后一张优惠券id
order by create_time desc
limit 10
where>order>limit 查询会先过滤再排序最后进行拿取,因此不必担心优惠券乱序;优惠券id是通过雪花算法生成的主键ID,且随时间戳增大而增大,也就是说主键ID是递增的;我们通过创建时间降序排序就是主键ID降序排序,所以这里只要主键ID小于前端传入的最后一张优惠券id,即把前端已经展示的那些优惠券过滤掉
代码实现:
CouponController:
java
@ApiOperation("查询我的优惠券列表")
@GetMapping("/my")
public List<CouponInfoResDTO> queryMyCouponForPage(Long lastId, Integer status) {
return couponService.queryForList(lastId, UserContext.currentUserId(), status);
}
ICouponService:
java
/**
* 我的优惠券列表
*
* @param lastId 最后一个优惠券id
* @param userId 用户id
* @param status 状态
* @return 优惠券列表
*/
List<CouponInfoResDTO> queryForList(Long lastId, Long userId, Integer status);
CouponServiceImpl:
java
@Override
public List<CouponInfoResDTO> queryForList(Long lastId, Long userId, Integer status) {
List<Coupon> list = this.lambdaQuery()
.eq(Coupon::getStatus, status)
.eq(Coupon::getUserId, userId)
.lt(lastId != null, Coupon::getId, lastId)
.orderByDesc(Coupon::getCreateTime)
.last("limit 10")
.list();
return BeanUtils.copyToList(list, CouponInfoResDTO.class);
}
二、获取可用优惠券
需求分析:
在下单时,可以先获取当前订单可用的优惠券,也就是要从优惠券表中查询满足下面条件的记录:
-
优惠券属于当前登录用户
-
优惠券还没有过期,还没有使用
-
满减金额<=订单金额,且订单金额>优惠金额

由于优惠券是否可以使用是由订单来决定的,所以前端请求是发给订单微服务,再由订单微服务转给优惠券微服务

优惠券微服务(服务提供者)
接口文档:
优惠券微服务负责查询当前用户当笔订单所有可用的优惠券,并按照优惠金额从大到小排序,条件是:
-
所属用户:当前登录用户
-
状态:未使用,且在有效使用期限内
-
满减金额:小于等于订单总额
-
优惠金额:小于订单金额
接口路径:GET /market/inner/coupon/getAvailable
请求、响应参数:


代码开发:
CouponController:
本接口属于内部调用接口,也就是在下单微服务通过feign进行远程调用
所以需要写在jzo2o-market 模块的com.jzo2o.market.controller.inner包下
java
package com.jzo2o.market.controller.inner;
import com.jzo2o.api.market.dto.response.AvailableCouponsResDTO;
import com.jzo2o.market.service.ICouponService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.List;
@RestController("innerCouponController")
@RequestMapping("/inner/coupon")
@Api(tags = "内部接口-优惠券相关接口")
public class CouponController{
@Autowired
private ICouponService couponService;
@ApiOperation("获取可用优惠券列表")
@GetMapping("/getAvailable")
public List<AvailableCouponsResDTO> getAvailable(@RequestParam("totalAmount") BigDecimal totalAmount) {
return couponService.getAvailable(totalAmount);
}
}
我们有两个请求参数但是参数列表却只有一个参数,这是因为用户Id我们可以从线程当中取:
而这个功能的实现实际上是通过拦截器拦截请求,并将请求头当中的用户信息封装保存到线程当中,并在需要时取出,结束后删除信息释放线程的存储:
ICouponService:
java
/**
* 获取可用优惠券列表
*
* @param totalAmount 订单总金额
* @return 可用的优惠券列表
*/
List<AvailableCouponsResDTO> getAvailable(BigDecimal totalAmount);
CouponServiceImpl:
java
@Override
public List<AvailableCouponsResDTO> getAvailable(BigDecimal totalAmount) {
//- 优惠金额:小于订单金额
//1. 查询优惠券
List<Coupon> list = this.lambdaQuery()
.eq(Coupon::getUserId, UserContext.currentUserId())//- 所属用户:当前登录用户
.eq(Coupon::getStatus, CouponStatusEnum.NO_USE.getStatus())//- 状态:未使用
.ge(Coupon::getValidityTime, LocalDateTime.now())//- 在有效使用期限内
.le(Coupon::getAmountCondition, totalAmount)//- 满减金额:小于等于订单总额
.list();
//2. 优惠金额:小于订单金额
List<Coupon> collect = list.stream()
.map(e -> e.setDiscountAmount(CouponUtils.calDiscountAmount(e, totalAmount))) //获取每个优惠券对应当前订单的优惠金额
.filter(e ->
e.getDiscountAmount().compareTo(new BigDecimal(0)) > 0
&& e.getDiscountAmount().compareTo(totalAmount) < 0
)//0 < 优惠金额:小于订单金额
.sorted(Comparator.comparing(Coupon::getDiscountAmount).reversed())//按照优惠金额从大到小排序
.collect(Collectors.toList());
//3. 返回结果
return BeanUtils.copyToList(collect, AvailableCouponsResDTO.class);
}
这里的有效期validityTime实际上就是过期时间,而这个字段在SeizeCouponSyncProcessHandler类的处理方法中是将当前时间加上活动规定好的过期时间来设置的:
所以这里就是在取当前用户下未使用、未过期、满减金额不大于订单总金额的优惠券信息;
接下来就是通过我们封装好的方法计算到底这张券能省多少钱(包括满减跟折扣【这里关于金额我们有三个字段:满减、折扣、优惠金额】):
CouponUtils.calDiscountAmount(e, totalAmount)的具体实现:
随后通过filter过滤掉那些不符合业务的值(比如优惠金额大于总金额的、优惠金额是负数的),然后通过sorted进行排序(由于comparing方法默认正序即从小到大排序,因此加上reversed()进行从大到小排序)
最后通过copyToList快速拷贝过滤掉不需要的字段并返回前端
暴露接口:
CouponApi:
由于是内部接口需要先在jzo2o-api中定义接口,api编写好需要上传到本地仓库
java
package com.jzo2o.api.market;
import com.jzo2o.api.market.dto.response.AvailableCouponsResDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.util.List;
/**
* 内部接口 - 优惠券相关接口
*
* @author itcast
*/
@FeignClient(contextId = "jzo2o-market", value = "jzo2o-market", path = "/market/inner/coupon")
public interface CouponApi {
/**
* 获取可用优惠券列表
*
* @param totalAmount 订单总金额
* @return 优惠券列表
*/
@GetMapping("/getAvailable")
List<AvailableCouponsResDTO> getAvailable(@RequestParam("totalAmount") BigDecimal totalAmount);
}
将服务提供者提供的接口暴露给nacos,需要将接口注解上@FeignClient以及服务提供者的路径,然后我们可以选择在服务调用者上添加@EnableFeignClients(basePackages = "com.jzo2o.api")注解来扫描提供好的接口也可以选择在api模块下创建配置类添加@EnableFeignClients(basePackages = "com.jzo2o.api")注解:
javapackage com.jzo2o.config; import com.jzo2o.common.handler.RequestIdHandler; import com.jzo2o.common.handler.UserInfoHandler; import com.jzo2o.interceptor.FeignInterceptor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @Slf4j @Configuration @EnableFeignClients(basePackages = "com.jzo2o.api") @Import({com.jzo2o.utils.MyQueryMapEncoder.class}) @ConditionalOnProperty(prefix = "feign", name = "enable", havingValue = "true") public class ClientScanConfiguration { @Bean public FeignInterceptor feignInterceptor(UserInfoHandler userInfoHandler, RequestIdHandler requestIdHandler){ return new FeignInterceptor(userInfoHandler, requestIdHandler); } }
订单微服务(服务调用者)
接口文档:
接口功能:用户下单,小程序请求订单管理服务接口查询可用的优惠券
接口路径:GET /orders-manager/consumer/orders/getAvailableCoupons
请求、响应参数:

代码开发:
ConsumerOrdersController:
java
@ApiOperation("获取可用优惠券")
@GetMapping("/getAvailableCoupons")
public List<AvailableCouponsResDTO> getCoupons(Long serveId,Integer purNum) {
return ordersCreateService.getAvailableCoupons(serveId, purNum);
}
IOrdersCreateService:
java
/**
* 获取可用优惠券
*
* @param serveId 服务项目id
* @param purNum 购买数量
* @return 可用优惠券
*/
List<AvailableCouponsResDTO> getAvailableCoupons(Long serveId, Integer purNum);
OrdersCreateServiceImpl:
java
@Autowired
private CouponApi couponApi;
@Override
public List<AvailableCouponsResDTO> getAvailableCoupons(Long serveId, Integer purNum) {
// 1.获取服务
ServeAggregationResDTO serveResDTO = serveApi.findById(serveId);
if (serveResDTO == null || serveResDTO.getSaleStatus() != 2) {
throw new ForbiddenOperationException("服务不可用");
}
// 2.计算订单总金额
BigDecimal totalAmount = serveResDTO.getPrice().multiply(new BigDecimal(purNum));
// 3.获取可用优惠券,并返回优惠券列表
List<AvailableCouponsResDTO> available = couponApi.getAvailable(totalAmount);
return available;
}
通过远程调用获取服务信息,再用服务信息当中的价格乘以前端过来的购买数量计算出总金额,再通过远程调用获取可用优惠券的信息并返回前端。
效果展示:

三、核销优惠券实现
需求分析:
优惠券核销就是在用户下单的时候,勾选合适的优惠券进行扣减

这个功能,前端请求依旧是发给订单微服务,再由订单微服务转给优惠券微服务的

优惠券微服务(服务提供者)
优惠券微服务的职责:
-
校验优惠券信息: 只有订单金额大于等于满减金额,并且优惠券在有效状态方可使用
-
修改优惠券表中该优惠券的使用状态(已使用)、使用时间(当前时间)、订单id(订单微服务传入)
-
向优惠券核销表添加一条记录
-
核销成功返回最终优惠的金额
接口文档:
接口路径:POST /market/inner/coupon/use
请求、响应参数:

代码开发:
CouponController:
java
@ApiOperation("使用优惠券,并返回优惠金额")
@PostMapping("/use")
public CouponUseResDTO use(@RequestBody CouponUseReqDTO couponUseReqDTO) {
return couponService.use(couponUseReqDTO);
}
ICouponService:
java
/**
* 核销优惠券
*
* @param couponUseReqDTO 优惠券对象
* @return 实际使用金额
*/
CouponUseResDTO use(CouponUseReqDTO couponUseReqDTO);
CouponServiceImpl:
java
@Override
@Transactional(rollbackFor = Exception.class)
public CouponUseResDTO use(CouponUseReqDTO couponUseReqDTO) {
//1. 校验优惠券信息: 只有订单金额大于等于满减金额,并且优惠券在有效状态方可使用
Coupon coupon = this.lambdaQuery()
.eq(Coupon::getUserId, UserContext.currentUserId())//- 所属用户:当前登录用户
.eq(Coupon::getStatus, CouponStatusEnum.NO_USE.getStatus())//- 状态:未使用
.ge(Coupon::getValidityTime, LocalDateTime.now())//- 在有效使用期限内
.le(Coupon::getAmountCondition, couponUseReqDTO.getTotalAmount())//- 满减金额:小于等于订单总额
.eq(Coupon::getId, couponUseReqDTO.getId())//优惠券id
.one();
if (ObjectUtil.isNull(coupon)) {
throw new ForbiddenOperationException("优惠券核销失败");
}
//2. 修改优惠券表中该优惠券的使用状态(已使用)、使用时间(当前时间)、订单id(订单微服务传入)
coupon.setStatus(CouponStatusEnum.USED.getStatus());//使用状态(已使用)
coupon.setUseTime(LocalDateTime.now());//使用时间(当前时间)
coupon.setOrdersId(couponUseReqDTO.getOrdersId().toString());//订单id(订单微服务传入)
this.updateById(coupon);
//3. 向优惠券核销表添加一条记录
CouponWriteOff couponWriteOff = new CouponWriteOff();
couponWriteOff.setCouponId(couponUseReqDTO.getId());
couponWriteOff.setUserId(UserContext.currentUserId());
couponWriteOff.setOrdersId(couponUseReqDTO.getOrdersId());
couponWriteOff.setActivityId(coupon.getActivityId());
couponWriteOff.setWriteOffTime(LocalDateTime.now());
couponWriteOff.setWriteOffManPhone(coupon.getUserPhone());
couponWriteOff.setWriteOffManName(coupon.getUserName());
couponWriteOffService.save(couponWriteOff);
//4. 核销成功返回最终优惠的金额
BigDecimal discountAmount = CouponUtils.calDiscountAmount(coupon, couponUseReqDTO.getTotalAmount());
CouponUseResDTO couponUseResDTO = new CouponUseResDTO();
couponUseResDTO.setDiscountAmount(discountAmount);
return couponUseResDTO;
}
这里进行重复地优惠券信息校验是因为,当我们点击立即预约之后会生成该订单,但支付的这段时间内万一优惠券过期或者优惠券活动过期的话就不应该让优惠券生效,所以这里反复地check满足条件的优惠券并用唯一的id取出我们前端选的那一张优惠券;
而后修改状态以及使用时间、优惠券所用的订单id;再向核销表添加记录:这里除了主键id自动生成,其余字段都得手动填入,最终用save方法插入保存,随后将优惠金额返回;
由于操作了多张表,所以需要添加@Transactional(rollbackFor = Exception.class)注解
接口暴露:
CouponApi:
java
/**
* 使用优惠券,并返回优惠金额
*
* @param couponUseReqDTO 优惠券信息对象
* @return 优惠金额
*/
@PostMapping("/use")
CouponUseResDTO use(@RequestBody CouponUseReqDTO couponUseReqDTO);
订单微服务(服务调用者)
订单微服务的职责:
-
创建订单(前面已经做了)
-
调用优惠券微服务核销优惠券
-
修改订单的优惠金额和实付金额
OrdersCreateServiceImpl:
修改OrdersCreateServiceImpl原有下单方法,根据订单有无优惠券划分不同的逻辑

IOrdersCreateService:
在com.jzo2o.orders.manager.service.IOrdersCreateService中添加优惠券下单的方法
java
/**
* 保存订单(带优惠券)
*
* @param orders 订单信息
* @param couponId 优惠券id
*/
void saveOrdersWithCoupon(Orders orders, Long couponId);
OrdersCreateServiceImpl:
在OrdersCreateServiceImpl中添加优惠券下单的方法
java
@Transactional
public void saveOrdersWithCoupon(Orders orders,Long couponId) {
//1. 调用优惠券微服务核销优惠券
CouponUseReqDTO couponUseReqDTO = new CouponUseReqDTO();
couponUseReqDTO.setId(couponId);//优惠券id
couponUseReqDTO.setOrdersId(orders.getId());//订单id
couponUseReqDTO.setTotalAmount(orders.getTotalAmount());//总金额
CouponUseResDTO couponUseResDTO = couponApi.use(couponUseReqDTO);
//2. 修改订单的优惠金额和实付金额
BigDecimal discountAmount = couponUseResDTO.getDiscountAmount();
orders.setDiscountAmount(discountAmount);//优惠金额
orders.setRealPayAmount(orders.getTotalAmount().subtract(discountAmount));//实付金额
//3. 创建订单
this.save(orders);
}
这里远程调用优惠券微服务的接口是为了得到优惠金额从而方便直接扣减得到实付金额并插入到订单表当中,这又涉及到了分布式事务的问题,我们接下来讲解分布式事务处理:
分布式事务处理
下单时核销优惠券,创建订单和核销优惠券需要保证事务一致性,这就需要分布式事务的控制

-
在订单基础工程jzo2o-orders-base和优惠券工程jzo2o-market中添加seata依赖
XML<dependency> <groupId>com.jzo2o</groupId> <artifactId>jzo2o-seata</artifactId> </dependency> -
修改订单管理服务和优惠券服务的bootstrap.yml,添加seata配置文件

-
由于使用的是AT模式,需要保证order和market两个数据库中都有undo_log的数据表
sqlcreate table undo_log( id bigint auto_increment 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 row_format = DYNAMIC; -
修改下单方法,在核销优惠券方法中开启全局事务

这里要保证事务,所以我们导入了依赖、添加seata配置文件、新增AT模式需要的快照表最后将普通的事务注解改成@GlobalTransactional注解
效果展示:



四、退回优惠券
需求分析:
用户通过小程序取消订单,订单服务执行取消订单逻辑,如果该订单使用了优惠券则请求优惠券服务退回优惠券

优惠券微服务(服务提供者)
优惠券微服务的职责:
-
检查优惠券是否有核销记录,没有则不需要退回
-
在优惠券退回表中添加记录
-
更新优惠券表中的状态字段,并清空订单id及使用时间字段
如果优惠券已过期则标记为已失效,如果未过期,则标记为未使用
如果优惠券对应的活动已作废则标记为已作废
-
删除优惠券核销表中的相关记录
接口文档:
接口功能:取消订单调用此接口退回优惠券
接口路径:POST /market/inner/coupon/useBack


代码开发:
CouponController:
java
@PostMapping("/useBack")
@ApiOperation("退回优惠券")
public void useBack(@RequestBody CouponUseBackReqDTO couponUseBackReqDTO) {
couponService.useBack(couponUseBackReqDTO);
}
ICouponService:
java
/**
* 退回优惠券
*
* @param couponUseBackReqDTO 优惠券
*/
void useBack(CouponUseBackReqDTO couponUseBackReqDTO);
CouponServiceImpl:
java
@Override
@Transactional(rollbackFor = Exception.class)
public void useBack(CouponUseBackReqDTO couponUseBackReqDTO) {
//1. 检查优惠券是否有核销记录,没有则不需要退回
CouponWriteOff couponWriteOff = couponWriteOffService.lambdaQuery()
.eq(CouponWriteOff::getOrdersId, couponUseBackReqDTO.getOrdersId())
.eq(CouponWriteOff::getUserId, couponUseBackReqDTO.getUserId())
.one();
if (ObjectUtil.isNull(couponWriteOff)) {
throw new ForbiddenOperationException("优惠券退回失败,原因: 没有对应的核销记录");
}
//2. 在优惠券退回表中添加记录
CouponUseBack couponUseBack = new CouponUseBack();
couponUseBack.setCouponId(couponWriteOff.getCouponId());//优惠券id 千万不要从couponUseBackReqDTO对象中获取
couponUseBack.setUserId(couponUseBackReqDTO.getUserId());
couponUseBack.setUseBackTime(LocalDateTime.now());
couponUseBack.setWriteOffTime(couponWriteOff.getWriteOffTime());
couponUseBackService.save(couponUseBack);
//3. 更新优惠券表中的状态字段,并清空订单id及使用时间字段
//3-1 根据优惠券id查询信息
Coupon coupon = this.getById(couponWriteOff.getCouponId());
if (ObjectUtil.isNull(coupon)) {
throw new ForbiddenOperationException("优惠券退回失败,原因: 没有对应的优惠券信息");
}
//3-2 根据活动id查询信息
Activity activity = activityService.getById(coupon.getActivityId());
if (ObjectUtil.isNull(activity)) {
throw new ForbiddenOperationException("优惠券退回失败,原因: 没有对应的优惠券活动信息");
}
//3-3 如果优惠券已过期则标记为已失效,如果未过期,则标记为未使用
CouponStatusEnum couponStatusEnum = coupon.getValidityTime().isAfter(LocalDateTime.now())
? CouponStatusEnum.NO_USE : CouponStatusEnum.INVALID;
//3-4 如果优惠券对应的活动已作废则标记为已作废
if (activity.getStatus().equals(ActivityStatusEnum.VOIDED.getStatus())){
couponStatusEnum = CouponStatusEnum.VOIDED;
}
//3-5 执行优惠券的更新
//下面的写法无法对数据表字段进行空值更新,要改为使用lambdaUpdate来处理. 此问题在测试视频中专门有讲解
// coupon.setStatus(couponStatusEnum.getStatus());
// coupon.setOrdersId(null);
// coupon.setUseTime(null);
// boolean b = this.updateById(coupon);
boolean b = this.lambdaUpdate()
.set(Coupon::getStatus, couponStatusEnum.getStatus())
.set(Coupon::getOrdersId, null)
.set(Coupon::getUseTime, null)
.eq(Coupon::getId, coupon.getId())
.update();
if (!b) {
throw new ForbiddenOperationException("优惠券退回失败,原因: 更新优惠券失败");
}
//4. 删除优惠券核销表中的相关记录
boolean b1 = couponWriteOffService.removeById(couponWriteOff.getId());
if (!b1) {
throw new ForbiddenOperationException("优惠券退回失败,原因: 删除优惠券核销记录失败");
}
}
为什么这里说优惠券id 千万不要从couponUseBackReqDTO对象中获取呢?这是因为我们这是一个远程调用接口,对于调用者来说,它不一定会提供这个优惠券id,而我们从数据库拿到的信息肯定是能取到的,因此不允许从参数中获取而是从数据库中获取;
接下来就是往退回表当中插入数据并查询优惠券、优惠券活动的信息来判断我们应该要修改成什么状态(已失效?已作废?);
由于MP当中updateById方法赋值为null的字段会被Mybatis忽略(即不修改),所以不能直接set然后抛给updateById方法,而是使用lambdaUpdate()方法完成空值的设置;
最后再将核销记录删掉完成服务提供
接口暴露:
CouponApi:
java
/**
* 退回优惠券
*
* @param couponUseBackReqDTO 优惠券对象
*/
@PostMapping("/useBack")
@ApiOperation("退回优惠券")
void useBack(@RequestBody CouponUseBackReqDTO couponUseBackReqDTO);
订单微服务(服务调用者)
订单微服务的职责:
在原有的订单取消逻辑之前,先判断是否使用了优惠券,如果是,则先退回优惠券
分布式事务处理:
由于退回优惠卷业务涉及到了订单和优惠券两个微服务,需要使用分布式事务控制
修改OrderCancelStrategyManager原有取消订单方法,添加退回优惠券的逻辑

这里可以看到,我们调用远程方法的时候确实没有设置优惠券id的相关值,所以微服务接口那里不能直接从参数里取
效果展示:








