【Java项目-企悦抽】17-抽奖模块02-抽奖接口实现

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

🎯 你正在阅读「Java项目-企悦抽」系列文章 🎯

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

🔥 弹简特 个人主页

❄️ 个人专栏直通车:

✨ 靠热爱去书写自己,靠勇敢去书写生活!

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨


🌟 博主简介:


文章目录


一、前言

铁汁们,抽奖模块重头戏来啦!本期实现抽奖接口,采用MQ异步处理,涉及状态扭转、中奖记录保存、邮箱通知与异常回滚,干货满满,咱们开始吧!


接下来我们就进行抽奖接口的实现:

二、抽奖相关接口的回顾

回顾一下我们之前分析的内容:

后端需要保存中奖信息、扭转状态、邮箱通知用户:前端点击"点我确定"得出中奖人员之后,需要将中奖人员、中什么奖、是几等奖等发给后端存起来,用于到时候前端点击"已抽完、下一步"查看中奖名单的时候显示对应的信息。(此时注意需要扭转活动、奖品、人员状态,因为他们是有状态的,比如活动是否结束?奖品是否抽完?人员是否中过奖?中过奖就不能参与)(与此同时需要完成通知行为:将中奖信息通过邮箱返回给用户)

我们本期博客就是重点来完成这个接口,但是他不是一个单独的接口,涉及多个,我们会逐步将这些后端业务实现~ 咱们继续向下看:


三、抽奖接口的设计

我们要在后端保存中奖信息,前提得是前端告诉我们中奖者是谁。

我们前端在进行抽奖活动的时候,点击点我确定 按钮的时候,比如有10个人在抽奖,我们有三个奖品,那么他就得确定出三个中奖者的信息是谁,也就是说确定完中奖者之后,就应该后端的抽奖接口,去保存对应的中奖信息。

但是我们之前说过,对于抽奖接口,它不仅仅是保存中奖信息;'我们还得去扭转对应的状态,比如将该奖品的状态从未抽取变为已抽取状态,一样的,我们的中奖人员他的状态也要改变,活动的状态是否有效,也仍然需要改变;最后我们将所有的中奖信息存在库里面之后呢,还有一个行为,就是通知行为,因为我们说前端给了你哪些人中奖了,那你存完这些人中奖人的信息的时候,要通知这些中奖人,他们中奖了,我们通过邮箱来通知他们。

1、梳理业务逻辑

1.1 同步接口

我们前端确定中奖人,然后呢,调用后端,后端就完成刚刚所说的那三步,保存中奖信息,扭转对应的状态,完成通知行为。

但是这样的实现不太好,我们要进行一个优化,我们回到抽奖场景来看:

在前端的页面效果中,管理员点击抽奖的时候,那个人员的姓名在一直在跳动,然后管理员点击点我确定 按钮之后呢,立即显示出中奖人的名字,我们希望的是他立即就显示中奖者的名字,不是说要等半天后端返回中奖者的名字然后前端才显示出来。

所以第1个点就是为了用户的体验,我们用户希望它立即就显示出中奖者的名字。

第2个点就是时效性,我希望你中奖,这样的一个过程是立马就生效的。

然后我们基于这2点,再看一下我们上述所说的设计的那个后端逻辑,前端告诉你谁中奖了,那你得先把他的数据真正存起来,才能说这个人真正的中奖了。那存完这个中奖信息,还得扭转状态,还得发送通知,最后呢你再把这个数据返回给前端。此时前端的才去展示真正的中奖者。我们这里边保存中奖信息涉及了表操作,扭转状态也涉及表操作,以及通知行为我们是用邮箱服务去发送邮件。这三步流程走下来,它会非常的耗时,这样的话就没有达到我们上述的两个要求,用户体验和时效性没有达成。

那我们这里面要知道后端一次性要完成保存中奖信息,扭转对应状态,完成通知行为,他一次性做了这几个行为,我们称之为同步接口。所谓的同步接口,就是你前端调用我,然后我按部就班的将这三个步骤给完成,然后再给你返回,也就是说他一次就给你干了那么多事,会很耗时啊。

1.2 异步接口

我们要分析出来这个同步接口,它会影响用户的体验,在抽奖场景下是不符合预期的,此时我们怎么做呢?我们可以将抽奖接口设置为异步接口:

首先是我们前端人员是告诉你中奖人是谁,然后调用后端,但是在我们后端的处理就得是异步处理,不再是之前那样一次性给你全部搞,我希望异步去处理抽奖流程,然后直接返回。

我们现在唯一考虑的是如何去处理,让它变成异步的过程:他另找时机去处理我们这三个过程,也就是说他找合适的时间去处理,总之在前端看来,你直接调用后端,我后端就直接给你返回成功的数据,我们后端提供的接口基本上可以称之为处理业务基本0耗时。

那此时由于是异步的,我们必须让这次中奖成功,也就是说保存中奖信息,扭转状态和完成通知,这样的行为,他们得成功,我们必须保证他们是成功的,必须成功,如果失败的话,我们得想办法让他成功。为什么呢?因为我们先把大话给放出去了,前端一调用后端,立马就返回成功的数据,也就是说,我先给你成功的数据,然后我再去找合适的时间去让保存中奖信息,扭转对应状态,完成通知行为,这三个成功。

但是这里边我们得注意,我们要找时机去实现我们后端的这几个步骤,那这几个步骤我们得怎么做呢?如图所示:


1.3 抽奖接口的逻辑

1)抽检请求处理

首先随机抽取,前端随机选择后端提供的参与者,确保每次抽取的结果是公平的。

然后就是提交请求,在活动进行时管理员可发起抽奖请求,包括活动id奖品id和中奖人员的附加信息。

接下来就是消息队列的通知:这个消息队列的通知就是帮我们做了一个异步的手段,我们有效的抽奖请求被发送到MQ队列中,然后等到MQ消费者真正处理抽奖逻辑。

然后我们抽奖的请求处理接口,将不再完成任何的事情,直接返回。

2)抽奖结果公布

我们前端:中奖名单通过前端随机抽取的人员公布展示出来。

3)抽奖逻辑执行

我们接下来就进入异步处理的业务环节中,对于我们这个MQ消息队列,我们是消息消费。Mq消费者收到异步消息,系统开始执行以下的抽奖逻辑。

a 中奖结果处理

请求验证 :我们系统验证抽奖请求的有效性,看它是否有效,然后我们设计的规定是否是符合预期的,比如奖品的数量、每个人中奖次数限制等等。

幂等性 :我们一个消息在消息队列里面,一个消息发布多次,我们接收的话是可能会接受多次的,那如果我们接收多个一样的请求,要保证,我们抽取的时候不能重复抽取。比如一等奖有一个人员抽取,我们可能会接受这个消息多次,那我们抽取的时候可能会抽出多次的一等奖,但是他只有这个奖品,所以我们要确保他只能抽一次。

状态扭转 :我们根据中奖结果扭转活动奖品参与者的状态,比如奖品是否已经被抽取,看人员是否已经中奖等等。

记过记录:最后呢我们将中奖结果记录到数据库中,并同步更新我们的Redis缓存。

b 中奖者通知

通知中奖者 :我们通过邮箱去通知中奖者

奖品领取:然后中奖者呢,根据通知中的指引领取奖品。

c 抽奖异常处理

回滚处理 :如果在我们的抽奖过程中发生了异常,要保证事物的一致性,我们的回滚。

补救措施:我们的抽奖行为是一次性的,而且我们异步处理奖品的任务必须保证成功,因为我们提前把成功信息给反馈给前端了,所以你必须保证成功,如果这个过程出现了异常,那应该采取补救措施。

4)技术实现细节

异步处理 :我们刚刚说明过了,使用mq做异步处理,它能够提高抽奖性能,而且不影响我们的抽奖流程。将抽奖处理放到队列中进行异步处理,保证了幂等性。

状态扭转处理 :此处我们会涉及到设计模式,因为呢状态扭转,它会涉及活动以及奖品等多个横向维度的扭转,不能确保将来的其他内容不会牵扯进来,因此呢,对于状态扭转处理需要可高扩展性的设计与维护,所以我们引入设计模式。

事务处理:我们在抽奖逻辑执行的时候,如果发生异常,需要确保数据库原子性、事务一致性。因此呢,要做好事务处理。把它看成一个整体,你要么所有的都给我通过才算通过,如果其中有一个不通过,那就不通过了,所有都不通过。


四、MQ的配置

1、什么是MQ

MQ全称消息队列,直白说就是存放消息的排队队列 ,遵循先来先出的规则。

队列里存的不是普通数据,而是各类消息,简单的文字、JSON格式内容,复杂的对象数据都能放。

它主要用在多个项目、系统之间互相传数据,系统之间通信分两种方式:

  1. 同步通信 :直接调用对方服务,消息一发立刻送达,必须等对方处理完才算结束。 A系统直接调用B系统,然后B系统要先处理完成之后才将结果返回A系统。

  2. 异步通信 :消息发出去先暂时存起来,满足条件后再转发给对方,不用当场等待处理。

    而MQ就是用来临时存放消息、实现异步通信的工具,RabbitMQ就是MQ里最常用的一款。 A系统发数据之后呢,消息先进入一个容器,对于b系统来说,它不会立马去处理a系统的消息,我们慢慢从里面拿。

2、MQ的实用作用

把MQ比作中转仓库最好理解:一方放消息进仓库,另一方从仓库取消息处理,核心就是存消息、转消息。

  1. 业务解耦+异步处理
    有些耗时操作不用立刻做完,比如用户注册账号,不用等发短信、发邮箱完成再提示注册成功,把发通知任务丢进MQ后台慢慢处理,提升用户体验。
  2. 抵挡流量高峰
    遇到秒杀、活动抢购等人流量暴增场景,大量请求直接丢进MQ排队,系统按自身能力慢慢处理,避免服务器瞬间崩溃,也不用额外花费大量成本扩容设备。
  3. 消息批量分发
    一个消息能同步发给多个系统,比如用户付款成功后,支付系统发一条消息到MQ,订单、物流等系统自动接收处理,不用反复查询数据库。
  4. 延迟定时处理
    支持延迟消息功能,电商最常用:用户下单后超时没付款,MQ自动触发取消订单操作。

3、主流MQ对比,为何选RabbitMQ

市面上常用MQ有Kafka、RocketMQ、RabbitMQ等,没有绝对好坏,只看项目适配场景:

  1. Kafka
    主打超高吞吐量,处理速度极快,专门用来收集、传输系统运行日志,功能简单,做日志采集首选。
  2. RocketMQ
    阿里开源研发,经过多年大促实战打磨,稳定性、可靠性极强,适合高并发、对数据安全要求高的金融类项目,缺点是适配的编程语言较少。
  3. RabbitMQ
    功能齐全完善,几乎适配所有开发语言,自带简易好用的后台管理页面,上手简单、社区教程多。
    吞吐量适中,足够满足中小型项目日常开发需求。

最终选择原因

我们日常开发项目没有超大流量、超高并发需求,RabbitMQ上手简单、运维方便、资料齐全,综合实用性最强,所以优先学习使用RabbitMQ。

4、RabbitMQ的概念介绍

生产者生产的消息是发到网络上去的,然后我们消费者他去消费消息,也是从网络中来获取的。

因此这里我们的第1个概念就是生产者和消费者之间肯定是通过网络连接的,叫做connection,而且我们还有一个概念叫做信道,那接下来这个消息是在哪里存储的呢?是在我们的队列中存储的。我们所有网络中来的消息都是存在一个一个的队列中,当然你也有多个消息队列。

那接下来我们还有一个概念叫做交换机,那你说这个交换机是用来干什么的呢?它其实就是用来路由我们的消息的。你生产者要把消息发在网络中,那我怎么知道你要把这个消息存到哪一个队列中呢,此时就用这个交换机来进行路由,这个交换机可以绑定我们的队列,当然交换机也可以有多个。

那将来呢,我消费者要消费哪个消息呢?我们直接指定对应的队列就行了。


5、RebbitMQ的配置

1. 配置依赖

xml 复制代码
        <!-- RabbitMQ -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

2. 配置文件

bash 复制代码
## mq ##
spring.rabbitmq.host=mq所在的服务器地址
spring.rabbitmq.port=5672
spring.rabbitmq.username=用户名
spring.rabbitmq.password=密码
#消息确认机制,默认auto
spring.rabbitmq.listener.simple.acknowledge-mode=auto
#设置失败重试 5次
spring.rabbitmq.listener.simple.retry.enabled=true
spring.rabbitmq.listener.simple.retry.max-attempts=5

3. 配置类

java 复制代码
@Configuration
public class DirectRabbitConfig {
    // 普通队列名称:用于接收正常消息
    public static final String QUEUE_NAME = "DirectQueue";
    // 普通直连交换机名称:根据路由键将消息分发到绑定的队列
    public static final String EXCHANGE_NAME = "DirectExchange";
    // 普通路由键:绑定队列与交换机时的匹配规则
    public static final String ROUTING = "DirectRouting";

    // 死信队列名称:用于存放处理失败或超时的消息
    public static final String DLX_QUEUE_NAME = "DlxDirectQueue";
    // 死信交换机名称:专门负责转发死信消息
    public static final String DLX_EXCHANGE_NAME = "DlxDirectExchange";
    // 死信路由键:将死信消息从普通队列路由到死信队列
    public static final String DLX_ROUTING = "DlxDirectRouting";

    /**
     * 普通队列 DirectQueue
     * 
     * 作用:定义一个持久化的队列,用于接收并暂存待处理的消息。
     * 为什么做:
     *   1. 设置 durable=true,保证队列在 RabbitMQ 重启后依然存在,消息不会因为 broker 宕机而丢失队列定义。
     *   2. 通过 QueueBuilder 绑定死信交换机和死信路由键,这样当消息成为死信(如被拒绝、超时、队列长度超限)时,
     *      会自动转发到指定的死信交换机。
     * 目的:实现消息的可靠投递与异常隔离。正常消息先进入此队列,若处理失败则进入死信队列,避免消息丢失,
     *       同时也便于后续对失败消息进行人工或自动化补偿处理。
     */
    @Bean
    public Queue directQueue() {
        return QueueBuilder.durable(QUEUE_NAME)           // 持久化队列
                .deadLetterExchange(DLX_EXCHANGE_NAME)    // 指定死信交换机
                .deadLetterRoutingKey(DLX_ROUTING)        // 指定死信路由键
                .build();
    }

    /**
     * 普通直连交换机 DirectExchange
     * 
     * 作用:创建名为 DirectExchange 的直连型交换机,根据 routing key 精确匹配消息的投递目标。
     * 为什么做:直连交换机(Direct Exchange)适用于需要按单一标识精确路由的场景,比 topic 或 fanout 更简单高效。
     * 目的:提供消息路由的核心中枢,生产者发送消息时只需指定 exchange 和 routingKey,
     *       交换机就会把消息发送到绑定了相同 routingKey 的队列。
     */
    @Bean
    DirectExchange directExchange() {
        // 参数:name, durable, autoDelete
        return new DirectExchange(EXCHANGE_NAME, true, false);
    }

    /**
     * 绑定普通队列与普通交换机
     * 
     * 作用:将 DirectQueue 队列以特定的路由键 DirectRouting 绑定到 DirectExchange 交换机上。
     * 为什么做:没有绑定关系,交换机无法将消息投递到任何队列。通过绑定,声明了"什么样的消息应该进入哪个队列"。
     * 目的:实现消息路由规则:当生产者发送消息到 DirectExchange 并携带 routingKey = "DirectRouting" 时,
     *       该消息就会被放入 DirectQueue 中等待消费者消费。
     */
    @Bean
    Binding bindingDirect() {
        return BindingBuilder.bind(directQueue())
                .to(directExchange())
                .with(ROUTING);
    }

    /**
     * 死信队列 DlxDirectQueue
     * 
     * 作用:存放成为死信的消息。这些消息可能是因为:
     *       - 消息被消费者拒绝(basic.reject / basic.nack)且 requeue=false
     *       - 消息设置了 TTL 并超时
     *       - 队列达到最大长度,排在前面的消息被移出成为死信
     * 为什么做:如果不设死信队列,无法正常处理的消息会被直接丢弃(或者被无限制重入队列),导致业务异常难以追踪。
     * 目的:作为消息的"兜底"或"异常缓冲区",保留处理失败的现场,便于排查问题、重新入队或进行补偿逻辑,
     *       增强系统容错性和可靠性。
     */
    @Bean
    public Queue dlxQueue() {
        // 仅设置持久化,其余使用默认值(exclusive=false, autoDelete=false)
        return new Queue(DLX_QUEUE_NAME, true);
    }

    /**
     * 死信交换机 DlxDirectExchange
     * 
     * 作用:专门接收普通队列转发的死信消息,并按照路由键将其发送到死信队列。
     * 为什么做:死信消息也需要一个独立的路由通道,与正常业务交换机解耦,避免死信处理干扰正常消息流转。
     * 目的:构建死信消息的独立处理链路,使得死信处理逻辑(如记录日志、发送告警、延迟重试)可以单独扩展。
     */
    @Bean
    DirectExchange dlxExchange() {
        return new DirectExchange(DLX_EXCHANGE_NAME, true, false);
    }

    /**
     * 绑定死信队列与死信交换机
     * 
     * 作用:将死信队列 DlxDirectQueue 以路由键 DlxDirectRouting 绑定到死信交换机 DlxDirectExchange。
     * 为什么做:只有绑定了路由关系,死信交换机才知道把收到的死信消息丢到哪个队列。
     * 目的:完成死信链路的路由配置,当普通队列中的消息变为死信并携带 routingKey=DLX_ROUTING 发送给 DLX_EXCHANGE_NAME 时,
     *       就能被正确投递到 dlxQueue 中。
     */
    @Bean
    Binding bindingDlx() {
        return BindingBuilder.bind(dlxQueue())
                .to(dlxExchange())
                .with(DLX_ROUTING);
    }

    /**
     * 消息转换器:Jackson2JsonMessageConverter
     * 
     * 作用:将发送和接收的消息体在 Java 对象与 JSON 格式之间自动转换。
     * 为什么做:默认的 SimpleMessageConverter 只能处理字符串、字节数组等基础类型,对于复杂的业务对象需要手动序列化。
     * 目的:简化开发,让消费者可以直接接收已反序列化的 Java 对象,同时也保证消息内容的可读性和跨语言兼容性(JSON 通用格式)。
     */
    @Bean
    public MessageConverter jsonMessageConverter() {
        return new Jackson2JsonMessageConverter();
    }
}

接下来依次实现生产者和消费这两套方法


五、抽奖异步接口实现之mq生产者

1、时序图

2、约定前后端接口

请求 : /draw-prize

请求方法:POST

json 复制代码
{
   "winnerList": [
      {
           "userId": 15,
           "userName": "弹简特"
       },
       {
           "userId": 21,
           "userName": "范闲"
       }
   ],
   "activityId": 23,
   "prizeId": 13,
   "prizeTiers": "FIRST_PRIZE",
   "winningTime": "2024-05-21T11:55:10.000Z"
}

响应

json 复制代码
{
   "code": 200,
   "data": true,
   "msg": ""
}

首先你应该知道:我后端需要你前端给我什么?根据我们的业务需求,我后端需要你前端给我:哪一个活动下的,哪一个奖品,这个奖品是几等奖,获奖的时间是多少?该活动下的该奖品下:哪些人员中奖了。

3、业务逻辑图

其实这样的逻辑我们已经写过很多次了,基本流程都一致差不多,只不过本次的会更简单:

  • 首先前端会传递JSON参数,那么我们会将他们封装为Param实体类
  • 然后就是我们控制层就接收这个参数并解析,这一步直接一个@RequestBody注解就实现了
  • 其次就是我们开始实现业务逻辑了,一般就是打印日志,调用服务层去处理具体的业务逻辑
  • 最后我们控制层就将数据返回

本次我们的控制层只做的是调用服务层和返回数据,我们的服务层要做的是直接传递消息给我们的消息队列即可,其他的什么都不需要做,也不用返回数据给服务层。

4、请求参数Param实体类

代码 :

注意的是:咱们里面有列表,那么对于列表的话,我们就直接记住列表中的每一个元素就定义为内部类就行了

java 复制代码
@Data
public class DrawPrizeParam {

    /**
     * 活动id
     */
    @NotNull(message = "活动id不能为空")
    private Long activityId;

    /**
     * 奖品id
     */
    @NotNull(message = "奖品id不能为空")
    private Long prizeId;

    /**
     * 奖品等级
     */
    @NotNull(message = "奖品等级不能为空")
    private String prizeLevel;

    /**
     * 中奖时间
     */
    @NotNull(message = "中奖时间不能为空")
    private Date winningTime;

    /**
     * 中奖者列表
     */
    @NotEmpty(message = "中奖者列表不能为空")
    @Valid
    private List<Winner> winnerList;

    @Data
    public static class Winner {
        /**
         * 中奖者id
         */
        @NotNull(message = "中奖者id不能为空")
        private Long userId;

        /**
         * 中奖者姓名
         */
        @NotBlank(message = "中奖者姓名不能为空")
        private String userName;
    }
}

5、控制层实现

代码

java 复制代码
    @RequestMapping("/draw-prize")
    public CommonResult<Boolean> drawPrize(
            @Validated @RequestBody DrawPrizeParam param) {
        //打印日志
        logger.info("DrawPrizeController drawPrize DrawPrizeParam:{}",
                JackSonUtil.writeValueAsString(param));
        //掉用服务层
        drawPrizeService.drawPrize(param);
        //直接返回正确结果
        return CommonResult.success(true);
    }

解释

6、服务层接口

java 复制代码
public interface DrawPrizeService {
    void drawPrize();
}

7、服务层实现

代码

java 复制代码
    /**
     * 我们的MQ的操作都被封装在这个RabbitTemplate类中,所以此处我们就得引入这个类
     */
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    /**
     * 将消息发送给消息队列
     * @param param 消息
     */
    @Override
    public void drawPrize(DrawPrizeParam param) {
        //我们构造一个消息体
        //消息体是一个map
        //这个消息体中 消息ID 和消息数据
        Map<String, String> map = new HashMap<>();
        //消息ID我们使用UUID来实现
        map.put("messageId", String.valueOf(UUID.randomUUID()));
        map.put("messageData", JackSonUtil.writeValueAsString(param));
        // 发消息: 需要交换机、绑定的key(指定队列)、消息体
        rabbitTemplate.convertAndSend(EXCHANGE_NAME, ROUTING, map);
        //打印日志
        logger.info("mq消息发送成功:map={}", JackSonUtil.writeValueAsString(map));
    }

解释:

8、测试

java 复制代码
    @Autowired
    private DrawPrizeService drawPrizeService;

    @Test
    void test(){
        //构造请求参数
        DrawPrizeParam param = new DrawPrizeParam();
        param.setActivityId(23L);
        param.setPrizeId(13L);
        param.setPrizeLevel("FIRST_PRIZE");
        param.setWinningTime(new Date());

        DrawPrizeParam.Winner winner1 = new DrawPrizeParam.Winner();
        winner1.setUserId(15L);
        winner1.setUserName("谈简特");

        DrawPrizeParam.Winner winner2 = new DrawPrizeParam.Winner();
        winner2.setUserId(21L);
        winner2.setUserName("范闲");

        param.setWinnerList(List.of(winner1, winner2));

        //调用发送消息
        drawPrizeService.drawPrize(param);
    }

结果:

出现问题

解决

修改之后结果


OK,生产者的逻辑我们实现了之后,接下来就是实现消费者的逻辑了:


五、mq消费者

他要干两件事情,第1件事情是先要获取消息,第2件事获取到消息之后才能处理抽奖流程的方法。

1、时序图

当然图中的那个并发发送通知我们没有实现啊,只有邮箱通知,对于短信通知他用不了,个人用不了,所以我们就没有管。

正常:

  • 要核对抽奖信息的有效性
  • 扭转我们的活动状态
  • 保存中奖的结果
  • 然后我们呢通过邮箱通知我们的中奖人(短信由于自定义模版问题 使用不了)
  • 最后将数据返回

异常:

  • 回滚活动的状态 回归人员的状态,回归活动关联的奖品的状态。
  • 回滚中奖记录
  • 将消息异常返回给mq,mq识别此次消息异常了,然后我们就会重试,但只能重试5次,如果5次的失败,那我们就会出现一个消息堆积
  • 把异常处理完之后呢,我们得补救消息,得重新试一下。

我们之前说要保证消息必须被成功处理,前端展示中奖者一定要成功。但是如果消息处理过程中发生了异常,我们为了保证事务的一致性,我们会回滚操作,所谓的回滚,就是将已经修改的数据库表的内容进行回滚,撤销掉。

那这样的异常,我们当初配置过,如果呢失败的话,我们就可以重试5次,但是你5次机会都用完之后,我就不能重试了,此时就会将这些失败的消息重新存放进其他消息队列,这种用于存放失败消息的队列称之为死信队列。

那什么会导致我们消息消费失败了呢?

第1个原因就是网络的原因,他甚至都没有到我们的消费者那里了。

那第2种情况就是代码的异常导致的。

第3种是我们的服务器压力。

其中对于第1种网络原因和第3种服务器的压力,这两个都是有办法解决的,网络不好就重新连一下网络,如果服务器压力大,我们就增加多台服务器。

对于第2种代码异常的情况,我们就可以解决,代码中出现了bug,所以这三种情况我们都是可以解决的。

所以如果我们的mq消息出现异常,我们就把这些异常的消息放到死信队列中,等待去重新处理,然后呢,处理完异常之后重新发送消息,当然如果你超过5次的话,我们就让他一直存在死信队列中吧,但是你一直存在这里面的话,我们的死信队列会存在满的情况,那到时候我们也是会使用相应的逻辑手段去处理它,比如设置有效时间,比如手动清空,再比如如果重发超过5次,我们就直接把这个消息丢了,不然呢在死信队列中。


2、第一件事:接收消息

首先就是我们先在服务层加一个mq包,就用去处理消息队列中的的消息吧

然后在这个包下建一个mq消费者类来处理我们的消息

然后,接下来我们要告诉这个类:告诉他,让他去处理哪一个消息队列👇

知道要监听哪一个消息队列之后,我们就来看一下,如果去处理里面的消息👇

那接下来我们的业务怎么处理呢?👇

最后就只有处理抽奖业务流程了:

3、第二件事:处理抽奖流程

3.1 校验抽奖请求参数

其实他就是校验我们的请求参数,那么这个业务逻辑我们将他放在处理抽奖逻辑的服务层中。

  • 判断活动是否存在(看一下你这个活动内容是不是在数据库中存在的)
  • 判断活动是否有效(看你活动是不是结束了)
  • 判断根据奖品id看奖品是否在数据库中
  • 判断奖品是否有效(看是否被抽取过)
  • 中奖者人数是不是和设置的奖品数量一致

代码

java 复制代码
    /**
     * 校验抽奖请求
     * @param param 抽奖请求参数
     * @return 返回校验的信息
     */
    @Override
    public Boolean checkDrawPrizeParam(DrawPrizeParam param) {
        //手下我们需要根据活动id来查询出活动信息 看他是否在我们的数据库中
        ActivityDO activityDO = activityMapper.selectById(param.getActivityId());
        // 奖品是否存在可以从 activity_prize 中来校验,为什么呢?
        // 因为我们的奖品是和活动绑定的(之前存储活动的时候进行一致性的处理),
        // 所以只要活动存在,那么奖品一定存在,而且查询这个表我们将来可以从中直接得出奖品的数量
        ActivityPrizeDO activityPrizeDO = activityPrizeMapper.selectByAadPrizeId(
                param.getActivityId(), param.getPrizeId());

        // 活动或奖品是否存在
        if (null == activityDO || null == activityPrizeDO) {
            throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_OR_PRIZE_IS_EMPTY);
        }

        // 活动是否有效
        if (activityDO.getStatus()
                .equalsIgnoreCase(ActivityStatusEnum.COMPLETED.name())) {
            throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_COMPLETED);
        }

        // 奖品是否有效
        if (activityPrizeDO.getStatus()
                .equalsIgnoreCase(ActivityPrizeStatusEnum.COMPLETED.name())) {
            throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_PRIZE_COMPLETED);
        }

        // 中奖者人数是否和设置奖品数量一致 3 2
        if (activityPrizeDO.getPrizeAmount() != param.getWinnerList().size()) {
            throw new ServiceException(ServiceErrorCodeConstants.WINNER_PRIZE_AMOUNT_ERROR);
        }
        return true;
    }

最后在我们的MqReceiver类中调用

java 复制代码
      //那么接下来就是处理具体的抽奖流程了:
        try {
            // TODO 校验抽奖请求是否有效
            drawPrizeService.checkDrawPrizeParam(param);
            // TODO 转态扭转处理
            // TODO 保存中奖名单
            // TODO 通知中奖者(使用邮箱)
        } catch (ServiceException e) {
            // TODO 如果异常那么首先需要保证事务的一致性,进行回滚,然后为了通知mq重试需要将异常抛出去告诉mq
            //打印日志
            logger.error("处理 MQ 消息出现了异常!{}:{}", e.getCode(), e.getMessage(), e);
            //回滚
            //抛出异常
        } catch (Exception e) { //保证不在我们预期内的异常也被抓到
            // TODO 如果异常那么首先需要保证事务的一致性,进行回滚,然后为了通知mq重试需要将异常抛出去告诉mq
            //打印日志
            logger.error("处理 MQ 消息出现了异常!",e);
            //回滚
            //抛出异常
        }

3.2 扭转状态(重点,引入了设计模式)

3.2.1 扭转状态思路
1)扭转活动关联的奖品状态

奖品初始是:INIT状态 那么这个奖品被抽取之后就变为COMPLETED状态(已完成状态)

过程:

  1. 查询活动关联的奖品信息
  2. 判断奖品的状态是否是已完成的,如果是就不用扭转了
  3. 判断条件通过之后再扭转
2)扭转活动关联的人员状态

人员初始是:INIT状态 那么这写人员抽过奖之后就变为COMPLETED状态(已经抽过状态)

过程:

  1. 查询活动关联的人员信息
  2. 判断人员的状态是否是已完成的,如果是就不用扭转了
  3. 判断条件通过之后再扭转
3)扭转活动状态

对于活动来说:初始他是有效状态RUNNING状态

那么什么时候需要改变活动的状态呢?

就是在活动中的所有奖品都被抽去完之后我们才将活动变为结束状态COMPLETED

就是对于活动状态来说不能直接转换 而是必须判断等待该活动中的所有奖品都抽取完毕我们再扭转活动的状态。

过程:

  1. 查询活动信息
  2. 判断该活动中所有的奖品奖品的状态是否都已经是已完成的,如果是就不用扭转了
  3. 判断条件通过之后再扭转
4)更新缓存

由于我们将数据库中完整活动信息保存到缓存中的,但是现在我们知道这个完整信息里面状态这一栏变了,所以对于缓存我们得更新一下。

不过需要判断 只有真正的修改了状态我们才去更新缓存

那么按照上述的思路我们直接写代码是可以的,但是我们会出现一些问题:

  1. 活动扭转的状态是依赖于问题状态的扭转的比如依赖于我们的奖品的扭转,那么如果只有你一个开发人员来说,你倒是知道这个依赖关系,但是对于我们将来其他的开发人员来说就他是不知道的,而且有可能活动还依赖了其他模块的状态呢?
  2. 将来我们如果状态扭转条件可能会扩展,那么当前的写法扩展性和维护性是很差的

上述两个问题就最终会导致代码的维护性、扩展性、灵活性很差

所以针对这样的一个问题,我们最终的结局方案就是:设计模式,我们会使用责任链设计模式 +策略设计模式

3.2.2 扭转状态实现

首先这个部分的设计模式的实现我们将他给单独建一个包:

大概的业务逻辑,以及有的类:

扭转转态管理类
java 复制代码
public interface ActivityStatusManager {
    /**
     * 处理活动相关状态转换
     *
     * @param convertActivityStatusDTO
     */
    void handlerEvent(ConvertActivityStatusDTO convertActivityStatusDTO);
}


@Component
public class ActivityStatusManagerImpl implements ActivityStatusManager {

    private static final Logger logger = LoggerFactory.getLogger(ActivityStatusManagerImpl.class);

    @Autowired//加入这个注解之后那么我们的map中就会有状态对象
    private final Map<String, AbstractActivityOperator> operatorMap = new HashMap<>();


    @Autowired
    private ActivityService activityService;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handlerEvent(ConvertActivityStatusDTO convertActivityStatusDTO) {
        if (CollectionUtils.isEmpty(operatorMap)) {
            logger.warn("operatorMap 为空!");
            return;
        }
        Map<String, AbstractActivityOperator> currMap = new HashMap<>(operatorMap);
        Boolean update = false;
        // 先处理:人员、奖品
        update = processConvertStatus(convertActivityStatusDTO, currMap, 1);
        // 后处理:活动
        update = processConvertStatus(convertActivityStatusDTO, currMap, 2) || update;

        // 更新缓存
        if (update) {
            activityService.cacheActivity(convertActivityStatusDTO.getActivityId());
        }

    }


    /**
     * 扭转状态
     *
     * @param convertActivityStatusDTO
     * @param currMap
     * @param sequence
     * @return
     */
    private Boolean processConvertStatus(ConvertActivityStatusDTO convertActivityStatusDTO,
                                         Map<String, AbstractActivityOperator> currMap,
                                         int sequence) {
        Boolean update = false;
        // 遍历currMap
        Iterator<Map.Entry<String, AbstractActivityOperator>> iterator = currMap.entrySet().iterator();
        while (iterator.hasNext()) {
            AbstractActivityOperator operator = iterator.next().getValue();
            // Operator 是否需要转换
            if (operator.sequence() != sequence
                    || !operator.needConvert(convertActivityStatusDTO)) {
                continue;
            }

            // 需要转换:转换
            if (!operator.convert(convertActivityStatusDTO)) {
                logger.error("{}状态转换失败!", operator.getClass().getName());
                throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_STATUS_CONVERT_ERROR);
            }

            // currMap 删除当前 Operator
            iterator.remove();
            update = true;
        }

        // 返回
        return update;
    }
}
扭转奖品

首先看是否需要扭转?

如果扭转该怎么做?

那么扭转人员的逻辑也是如此的

扭转活动

首先看一下是否需要扭转状态

代码

java 复制代码
public abstract class AbstractActivityOperator {

    /**
     * 控制处理顺序
     *
     * @return
     */
    public abstract Integer sequence();

    /**
     * 是否需要转换
     *
     * @param convertActivityStatusDTO
     * @return
     */
    public abstract Boolean needConvert(ConvertActivityStatusDTO convertActivityStatusDTO);

    /**
     * 转换方法
     *
     * @param convertActivityStatusDTO
     * @return
     */
    public abstract Boolean convert(ConvertActivityStatusDTO convertActivityStatusDTO);

}


@Component
public class ActivityOperator extends AbstractActivityOperator {

    @Autowired
    private ActivityMapper activityMapper;
    @Autowired
    private ActivityPrizeMapper activityPrizeMapper;

    @Override
    public Integer sequence() {
        return 2;
    }

    @Override
    public Boolean needConvert(ConvertActivityStatusDTO convertActivityStatusDTO) {
        Long activityId = convertActivityStatusDTO.getActivityId();
        ActivityStatusEnum targetStatus = convertActivityStatusDTO.getTargetActivityStatus();
        if (null == activityId
                || null == targetStatus) {
            return false;
        }

        ActivityDO activityDO = activityMapper.selectById(activityId);
        if (null == activityDO) {
            return false;
        }

        // 当前活动状态与传入的状态一致,不处理
        if (targetStatus.name().equalsIgnoreCase(activityDO.getStatus())) {
            return false;
        }

        // 需要判断奖品是否全部抽完
        // 查询 INIT 状态的奖品个数
        int count = activityPrizeMapper.countPrize(activityId, ActivityPrizeStatusEnum.INIT.name());
        if (count > 0) {
            return false;
        }

        return true;
    }

    @Override
    public Boolean convert(ConvertActivityStatusDTO convertActivityStatusDTO) {
        try {
            activityMapper.updateStatus(convertActivityStatusDTO.getActivityId(),
                    convertActivityStatusDTO.getTargetActivityStatus().name());
            return true;
        } catch (Exception e) {
            return false;
        }

    }
}

@Component
public class PrizeOperator extends AbstractActivityOperator {

    @Autowired
    private ActivityPrizeMapper activityPrizeMapper;

    @Override
    public Integer sequence() {
        return 1;
    }

    @Override
    public Boolean needConvert(ConvertActivityStatusDTO convertActivityStatusDTO) {
        Long activityId = convertActivityStatusDTO.getActivityId();
        Long prizeId = convertActivityStatusDTO.getPrizeId();
        ActivityPrizeStatusEnum targetPrizeStatus = convertActivityStatusDTO.getTargetPrizeStatus();
        if (null == activityId
                || null == prizeId
                || null == targetPrizeStatus) {
            return false;
        }

        ActivityPrizeDO activityPrizeDO = activityPrizeMapper.selectByAPId(activityId, prizeId);
        if (null == activityPrizeDO) {
            return false;
        }

        // 判断当前奖品状态和目标状态是否一致
        if (targetPrizeStatus.name().equalsIgnoreCase(activityPrizeDO.getStatus())) {
            return false;
        }

        return true;
    }

    @Override
    public Boolean convert(ConvertActivityStatusDTO convertActivityStatusDTO) {
        Long activityId = convertActivityStatusDTO.getActivityId();
        Long prizeId = convertActivityStatusDTO.getPrizeId();
        ActivityPrizeStatusEnum targetPrizeStatus = convertActivityStatusDTO.getTargetPrizeStatus();
        try {
            activityPrizeMapper.updateStatus(activityId, prizeId, targetPrizeStatus.name());
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}
@Component
public class UserOperator extends AbstractActivityOperator {

    @Autowired
    private ActivityUserMapper activityUserMapper;

    @Override
    public Integer sequence() {
        return 1;
    }

    @Override
    public Boolean needConvert(ConvertActivityStatusDTO convertActivityStatusDTO) {
        Long activityId = convertActivityStatusDTO.getActivityId();
        List<Long> userIds = convertActivityStatusDTO.getUserIds();
        ActivityUserStatusEnum targetUserStatus = convertActivityStatusDTO.getTargetUserStatus();
        if (null == activityId
                || CollectionUtils.isEmpty(userIds)
                || null == targetUserStatus) {
            return false;
        }
        List<ActivityUserDO> activityUserDOList =
                activityUserMapper.batchSelectByAUIds(activityId, userIds);
        if (CollectionUtils.isEmpty(activityUserDOList)) {
            return false;
        }

        for (ActivityUserDO auDO : activityUserDOList) {
            if (auDO.getStatus()
                    .equalsIgnoreCase(targetUserStatus.name())) {
                return false;
            }
        }
        return true;
    }

    @Override
    public Boolean convert(ConvertActivityStatusDTO convertActivityStatusDTO) {
        Long activityId = convertActivityStatusDTO.getActivityId();
        List<Long> userIds = convertActivityStatusDTO.getUserIds();
        ActivityUserStatusEnum targetUserStatus = convertActivityStatusDTO.getTargetUserStatus();
        try {
            activityUserMapper.batchUpdateStatus(activityId, userIds, targetUserStatus.name());
            return true;
        } catch (Exception e) {
            return false;
        }

    }
}
更新缓存

缓存不是随便更新的,只有你数据库mysql中存的状态更新了,那么你才选择更新的

代码

java 复制代码
 /**
     * 扭转完活动状态之后 我们需要将完整的数据存到我们的缓存中
     * @param activityId 活动ID
     */
    @Override
    public void cacheActivity(Long activityId) {
        if (null == activityId) {
            logger.warn("要缓存的活动id为空!");
            throw new ServiceException(ServiceErrorCodeConstants.CACHE_ACTIVITY_ID_IS_EMPTY);
        }

        // 查询表数据:活动表、关联奖品、关联人员、奖品信息表
        ActivityDO aDO = activityMapper.selectById(activityId);
        if (null == aDO) {
            logger.error("要缓存的活动id有误!");
            throw new ServiceException(ServiceErrorCodeConstants.CACHE_ACTIVITY_ID_ERROR);
        }
        // 活动奖品表
        List<ActivityPrizeDO> apDOList =  activityPrizeMapper.selectByActivityId(activityId);
        // 活动人员表
        List<ActivityUserDO> auDOList = activityUserMapper.selectByActivityId(activityId);
        // 奖品表: 先获取要查询的奖品id
        List<Long> prizeIds = new ArrayList<>();
        if (apDOList != null && !apDOList.isEmpty()) {
            for (ActivityPrizeDO activityPrizeDO : apDOList) {
                prizeIds.add(activityPrizeDO.getPrizeId());
            }
        }
        
        List<PrizeDO> pDOList = prizeMapper.batchSelectByIds(prizeIds);
        // 整合活动详细信息,存放redis
        cacheActivity(
                convertToActivityDetailDTO(aDO, auDOList,
                        pDOList, apDOList));
    }

3.3 保持中奖记录

首先我们要查询相关的信息,比如活动信息,人员信息和产品信息等等

然后我们构造中奖者记录,保存到数据库中

最后我们再缓存到我们的Redis中。

为什么缓存这部分信息呢?它主要用于前端的两个展示场景,第1个场景是点击点我确定之后,展示出你中奖的那个人,第二个场景就是等全部人员全部抽奖过后,展示所有中奖信息。

而这个展示的时候,我们把它存在redis中,将来直接从redis中查看就行,提高查询速率。

而我们缓存的话,主要缓存两个维度的记录:

  • 第1个是缓存奖品维度的记录==》应对的是你点击点我确定的时候,展示当前这个奖品,哪个人中奖了。
  • 第2个是缓存活动维度的记录==》他应对的是等所有人都抽奖完成之后,这个活动里面有哪些人中奖了。

那么我们保存数据肯定是要把do实体类保存到数据库中,所以先定义do实体类:而且这个do对应的数据库表我们在最初设计这个数据库里面的这个数据表就是winning_record的。

代码:

java 复制代码
/**
 * @ClassName WinningRecordDO
 * @Description TODO 中奖记录表
 * @Author zhongge
 * @Version 1.0
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class WinningRecordDO extends BaseDO{

    /**
     * 活动id
     */
    private Long activityId;

    /**
     * 活动名称
     */
    private String activityName;

    /**
     * 奖品id
     */
    private Long prizeId;

    /**
     * 奖品名称
     */
    private String prizeName;

    /**
     * 奖品等级
     */
    private String prizeTier;

    /**
     * 中奖者id
     */
    private Long winnerId;

    /**
     * 中奖者姓名
     */
    private String winnerName;

    /**
     * 中奖者邮箱
     */
    private String winnerEmail;

    /**
     * 中奖者电话
     */
    private Encrypt winnerPhoneNumber;

    /**
     * 中奖时间
     */
    private Date winningTime;

}

OK,那么接下来我们就一步一步实现了:

查询相关的信息

查询相关的信息:比如我们的活动、人员、奖品、活动关联的奖品等等信息。

这里你可能有疑问,前端不是传递信息了吗?我们为什么还要从数据库中查询出来呢?首先是因为我们的这个中奖记录表中,有些字段在前端没有,这是第1个点。第2个点呢,你前端传的数据可能它不是实时的数据,我们要的真实的数据是数据库中的真实数据,而你前端是能够传递给后端id的,我们就根据id来查询出真实的数据即可,通常都是这样处理的。

构造中奖者记录然后保存起来

构造中奖者记录然后保存起来,存到mysql中的中奖表中,这个表主键是活动id + 奖品id + 人员id

缓存

1、缓存奖品维度中奖记录 --》应对前端点我确定得出当前奖品的中奖人员

这个奖品维度的话,是当前活动下的该奖品中奖人是谁

(key, 中奖记录) =》 (前缀_activityId_prizeId, winningRecordDDList)

那么有存,自然有取,所以我们就先将去给实现了:

2、缓存活动维度中奖记录 --》应对前端所有人员抽完奖之后得出中奖人员

这个活动维度的话,是当前活动下面有很多个奖品,那我们会有所有的奖品的获奖人,而且这种情况是在我们抽奖结束之后才用到,所以你的判断,等抽奖结束之后,也就是活动状态是已完成状态才能查询。

(key, 中奖记录) =》 (前缀_activityId, winningRecordDDList)

代码
java 复制代码
    /**
     * 保存中奖名单信息
     * @param param 前端传递的中奖信息
     */
    @Override
    public List<WinningRecordDO> saveWinnerRecords(DrawPrizeParam param) {
        //查询相关的信息:比如我们的活动、人员、奖品、活动关联的奖品等等信息
        ActivityDO activityDO = activityMapper.selectById(param.getActivityId());
        // 先取出 winnerList
        List<DrawPrizeParam.Winner> winnerList = param.getWinnerList();

        // 手动遍历提取 userId 集合
        List<Long> userIdList = new ArrayList<>();
        for (DrawPrizeParam.Winner winner : winnerList) {
            userIdList.add(winner.getUserId());
        }

        // 批量查询
        List<UserDO> userDOList = userMapper.batchSelectByIds(userIdList);
        PrizeDO prizeDO = prizeMapper.selectById(param.getPrizeId());
        ActivityPrizeDO activityPrizeDO =
                activityPrizeMapper.selectByAPId(param.getActivityId(), param.getPrizeId());


        //构造中奖者记录然后保存起来,存到mysql中的中奖表中,这个表主键是活动id + 奖品id + 人员id
        List<WinningRecordDO> winningRecordDOList = new ArrayList<>();
        for (UserDO userDO : userDOList) {
            WinningRecordDO winningRecordDO = new WinningRecordDO();
            winningRecordDO.setActivityId(activityDO.getId());
            winningRecordDO.setActivityName(activityDO.getActivityName());
            winningRecordDO.setPrizeId(prizeDO.getId());
            winningRecordDO.setPrizeName(prizeDO.getName());
            winningRecordDO.setPrizeTier(activityPrizeDO.getPrizeTiers());
            winningRecordDO.setWinnerId(userDO.getId());
            winningRecordDO.setWinnerName(userDO.getUserName());
            winningRecordDO.setWinnerEmail(userDO.getEmail());
            winningRecordDO.setWinnerPhoneNumber(userDO.getPhoneNumber());
            winningRecordDO.setWinningTime(param.getWinningTime());

            // 添加到集合
            winningRecordDOList.add(winningRecordDO);
        }

        // 批量插入
        winningRecordMapper.batchInsert(winningRecordDOList);



        //缓存我们的中奖记录:
        //1.缓存奖品维度中奖记录==》应对前端点我确定得出当前奖品的中奖人员
        //(key, 中奖记录) ==》 (activityId_prizeId, winningRecordDDList)
        cacheWinningRecords(param.getActivityId() + "_" + param.getPrizeId(),
                winningRecordDOList,
                WINNING_RECORDS_TIMEOUT);

        //2.缓存活动维度中奖记录==》应对前端所有人员抽完奖之后得出中奖人员
        //(key, 中奖记录) ==》 (activityId, winningRecordDDList)

        if (activityDO.getStatus()
                .equalsIgnoreCase(ActivityStatusEnum.COMPLETED.name())) {
            // 查询活动维度的全量中奖记录
            List<WinningRecordDO> allList = winningRecordMapper.selectByActivityId(param.getActivityId());
            cacheWinningRecords(String.valueOf(param.getActivityId()),
                    allList,
                    WINNING_RECORDS_TIMEOUT);
        }

        return winningRecordDOList;

    }

    /**
     * 将数据缓存到Redis
     * @param key
     * @param winningRecordDOList
     * @param time
     */
    private void cacheWinningRecords(String key,
                                     List<WinningRecordDO> winningRecordDOList,
                                     Long time) {
        String str = "";
        try {
            if (!StringUtils.hasText(key)
                    || CollectionUtils.isEmpty(winningRecordDOList)) {
                logger.warn("要缓存的内容为空!key:{}, value:{}",
                        key, JackSonUtil.writeValueAsString(winningRecordDOList));
                return;
            }

            str = JackSonUtil.writeValueAsString(winningRecordDOList);
            redisUtil.set(WINNING_RECORDS_PREFIX + key,
                    str,
                    time);
        } catch (Exception e) {
            logger.error("缓存中奖记录异常!key:{}, value:{}", WINNING_RECORDS_PREFIX + key, str);
        }

    }

    /**
     * 从缓存中获取中奖记录
     *
     * @param key
     * @return
     */
    private List<WinningRecordDO> getWinningRecords(String key) {
        try {
            if (!StringUtils.hasText(key)) {
                logger.warn("要从缓存中查询中奖记录的key为空!");
                return Arrays.asList();
            }
            String str = redisUtil.get(WINNING_RECORDS_PREFIX + key);
            if (!StringUtils.hasText(str)) {
                return Arrays.asList();
            }

            return JackSonUtil.readListValue(str, WinningRecordDO.class);
        } catch (Exception e) {
            logger.error("从缓存中查询中奖记录异常!key:{}", WINNING_RECORDS_PREFIX + key);
            return Arrays.asList();
        }
    }

3.4 中奖通知

引入邮箱服务的依赖
xml 复制代码
        <!-- 邮箱服务 -->
       <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
       </dependency>
配置配置项
bash 复制代码
## 邮件 ##
# 告诉你需要什么邮箱
spring.mail.host=smtp.qq.com
spring.mail.username=发送者的邮箱,就是谁发的
# 你的授权码:邮箱设置-》第三⽅服务-》开启IMAP/SMTP服务-》获取授权码
spring.mail.password=得到你的qq邮箱的授权码
spring.mail.default-encoding=UTF-8

# 开启SMTP身份验证(必须开启,否则无法登录邮箱)
spring.mail.properties.mail.smtp.auth=true
# 开启STARTTLS加密传输(QQ邮箱强制要求的安全连接)
spring.mail.properties.mail.smtp.starttls.enable=true
# 强制使用STARTTLS加密
spring.mail.properties.mail.smtp.starttls.required=true
# 开启SSL安全连接(解决报错:530 Login fail)
spring.mail.properties.mail.smtp.ssl.enable=true
# 指定SSL套接字工厂类(保证安全连接正常工作)
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
# QQ邮箱SSL专用端口(固定465)
spring.mail.properties.mail.smtp.socketFactory.port=465
邮箱工具类
java 复制代码
@Component
public class MailUtil {
    private static final Logger logger = LoggerFactory.getLogger(MailUtil.class);

    @Value(value = "${spring.mail.username}")//从配置文件中查询
    private String from;//谁发送的,邮箱源

    @Autowired
    private JavaMailSender mailSender;//你要发送的工作都封装在这里面了

    /**
     * 发邮件
     *
     * @param to:  目标邮箱地址
     * @param subject: 标题
     * @param context: 正文
     * @return
     */
    public Boolean sendSampleMail(String to, String subject, String context) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setFrom(from);
        message.setTo(to);
        message.setSubject(subject);
        message.setText(context);
        try {
            mailSender.send(message);
        } catch (Exception e) {
            logger.error("向{}发送邮件失败!", to, e);
            return false;
        }
        return true;
    }
}
开始实现

这里需要注意的是,我们虽然本项目只实现了邮箱发送,但是为了扩展性,我们将来是可以去增加短信发送的。此时我们不用短信发送的原因,是因为短信发送它有一些限制,个人可能用不了,所以呢,申请不了,我们就用邮箱,但是我们可以把扩张的这部分流出来。

而且我们这个发送消息通知中奖者,它属于抽奖之后的行为,也就是说它是后续的流程我们呢可以把它单独用一个方法进行并发的处理。

那么你想要并发操作,我们就用线程池来帮我们做呗,就是说你给一个池子里面呢有很多个人,然后呢,这些人呢去并行的去给我们干活,所以配置如下:

bash 复制代码
## 线程池 ##
# 异步线程池配置
# 核心线程数量,长期存活不销毁
async.executor.thread.core_pool_size=10
# 线程池允许创建的最大线程总数
async.executor.thread.max_pool_size=20
# 任务等待队列最大容量,核心线程忙完任务先进队列排队
async.executor.thread.queue_capacity=20
# 线程名称前缀,方便日志排查问题
async.executor.thread.name.prefix=async-service-

线程池配置类如下:

java 复制代码
@Configuration  // 声明这是一个配置类,Spring启动时加载
@EnableAsync    // 开启Spring异步功能,支持@Async注解
public class ExecutorConfig {

    // 核心线程数(长期保留,即使空闲也不销毁)
    @Value("${async.executor.thread.core_pool_size}")
    private int corePoolSize;

    // 最大线程数(线程池能创建的线程上限)
    @Value("${async.executor.thread.max_pool_size}")
    private int maxPoolSize;

    // 任务队列容量(核心线程满了,任务先放这里排队)
    @Value("${async.executor.thread.queue_capacity}")
    private int queueCapacity;

    // 线程名称前缀,方便日志排查定位
    @Value("${async.executor.thread.name.prefix}")
    private String namePrefix;

    /**
     * 定义一个名为 asyncServiceExecutor 的线程池 Bean
     * 供 @Async("asyncServiceExecutor") 注解使用
     */
    @Bean(name = "asyncServiceExecutor")
    public ThreadPoolTaskExecutor asyncServiceExecutor() {
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();

        // 设置核心线程数
        threadPoolTaskExecutor.setCorePoolSize(corePoolSize);
        // 设置最大线程数
        threadPoolTaskExecutor.setMaxPoolSize(maxPoolSize);
        // 设置工作队列大小
        threadPoolTaskExecutor.setQueueCapacity(queueCapacity);
        // 设置线程空闲时间(超过核心线程数的线程,空闲3秒后回收)
        threadPoolTaskExecutor.setKeepAliveSeconds(3);
        // 设置线程名称前缀
        threadPoolTaskExecutor.setThreadNamePrefix(namePrefix);

        // 设置拒绝策略:当线程池和队列都满了,新任务直接抛出异常
        threadPoolTaskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

        // 线程池初始化
        threadPoolTaskExecutor.initialize();
        return threadPoolTaskExecutor;
    }
}

实现的代码:

java 复制代码
  /**
     * 并发处理抽奖之后的操作
     * @param winningRecordDOS
     */
    private void syncExecute(List<WinningRecordDO> winningRecordDOS) {
        // 通过线程池 threadPoolTaskExecutor
        // 短信通知
        threadPoolTaskExecutor.execute(()->sendMessage(winningRecordDOS));
        // 邮件通知
        threadPoolTaskExecutor.execute(()->sendMail(winningRecordDOS));
        
        //如果不使用拉姆达:
//        threadPoolTaskExecutor.execute(new Runnable() {
//            @Override
//            public void run() {
//                sendMail(winningRecordDOS);
//            }
//        });
    }

    /**
     * 发邮件
     *
     * @param winningRecordDOList
     */
    private void sendMail(List<WinningRecordDO> winningRecordDOList) {
        if (CollectionUtils.isEmpty(winningRecordDOList)) {
            logger.info("中奖列表为空,不用发邮件!");
            return;
        }
        for (WinningRecordDO winningRecordDO : winningRecordDOList) {
            // Hi,弹简特。恭喜你在抽奖活动活动中获得二等奖:吹风机。获奖奖时间为18:18:44,请尽快领取您的奖励
            String context = "你好," + winningRecordDO.getWinnerName() + "。恭喜你在"
                    + winningRecordDO.getActivityName() + "活动中获得"
                    + ActivityPrizeTiersEnum.forName(winningRecordDO.getPrizeTier()).getMessage()
                    + ":" + winningRecordDO.getPrizeName() + "。获奖时间为"
                    + DateUtil.formatTime(winningRecordDO.getWinningTime()) + ",请尽快领 取您的奖励!";
            mailUtil.sendSampleMail(winningRecordDO.getWinnerEmail(),
                    "中奖通知", context);
        }
    }

    /**
     * 发短信
     *
     * @param winningRecordDOList
     */
    private void sendMessage(List<WinningRecordDO> winningRecordDOList) {
        //由于短信服务我们 不好使用各个功能 目前我去申请 个人是使用不的 所以此处我们用于后续的扩展
        logger.info("短信通知中奖 用于后续的扩展。。。");
    }

到此我们就实现完毕邮箱发送了,那么我们现在将抽奖逻辑代码中的异常抛出告诉mq给实现如下:


3.5 抽奖回滚

那么我们怎么回滚?

所谓的回滚就是我们要恢复到之前库表的状态,也就是说你刚刚不是把抽奖信息存起来了吗?因为出现异常,我们就回滚就行了。

那我们需要回归哪些数据呢?首先看我们之前的那些存库操作:

而且回滚的顺序只能是先回滚状态,再回滚中奖者信息,必须是这样,为什么呢?

因为我们这里边是会做一个判断的,为什么做判断呢?就是我们的状态扭转,它里面是一个事务,如果它会出现异常,那所有的状态扭转都不会成功,所以在回滚之前,我们先判断是否需要回滚。而此时你判断不需要回滚的话,你就直接return恰恰这个return就可以跳出这个回滚的操作,我们的中奖者名单就不需要回滚了。为什么中奖者名单就不需要回滚呢?因为你状态扭转那里出现异常,程序就不会到存储中奖者名单那里,所以就不需要回滚。

第一步:回滚状态

1、判断是否需要回滚

而且此处你不用去判断活动,奖品,人员,他们三个的状态。为什么你不需要一个一个的去判断呢?只需要判断其中一个即可。

因为我们当初扭转这三个状态,是把他们作为一个事务来处理的,他们是原子性,如果其中一个都不成功,那其他两个肯定是不成功的。

所以我这里看状态是不是已完成,就只需要看其中一个的状态,比如我们本例中就只需要看这个奖品的状态。所以我们才会去查询这个活动所关联的奖品表,这个中间表中去查询它的状态,因为只有这个中间表才有奖品的状态。

注意哦,原本我们是需要去判断你活动的状态是否需要扭转、你奖品的状态是否变、然后你这个人员的状态是否也是不变,但是由于他们三个我们之前扭转的时候是作为原子性操作的,所以其中一个不对,那另外两个绝对不会对的,所以呢我们此处只需要去判断他们三个当中的其中一个即可。

2、怎么回滚

第二步:回滚中奖者信息

1、判断是否需要回滚

2、怎么回滚


OK,到这里咱们抽奖接口的核心逻辑就实现完毕啦!本期内容比较硬核,涉及MQ异步、设计模式、事务回滚等多个知识点,建议大家多动手敲一遍,理清每一步的业务流转。

下一篇也是咱们企悦抽项目的最后一期,我们将对抽奖逻辑进行完整测试(正向流程、异常回滚、消息重发),实现查询中奖记录接口,并完成抽奖模块的前端页面交互。咱们最后一期见!

老铁们,如果本篇内容对你有帮助,不妨点赞、收藏,也欢迎在评论区留言交流,你的每一份支持都是我持续创作的最大动力~👋

相关推荐
吴声子夜歌1 小时前
Nginx应用与运维——Nginx编译及部署(部署)
java·运维·nginx
HEJOO91 小时前
指针与结构体:C 语言进阶核心详解
c语言·开发语言·网络
乌暮1 小时前
JVM 原理与实践:从运行时数据区到线上故障排查
java·开发语言·jvm·后端·学习·面试
code2cat1 小时前
【随笔】MCP缓存期限与共享范围:让Agent复用资料时记住边界
java·后端·缓存·ai agent·mcp
维克兜率天1 小时前
【维克】配对交易的季节性:哪些品种适合长拿?
android·开发语言·笔记·python·算法·kotlin·量化
沫璃染墨1 小时前
《Qt从零入门系列(十二):Qt文件操作详解——从QFile读写到QFileInfo与记事本实战》
开发语言·网络·c++·qt·交互·信号处理·文件
时间的拾荒人2 小时前
Qt 界面布局与容器控件详解:从分组框到布局管理器
开发语言·qt·面试
逃逸线LOF2 小时前
工具类文件头文件
java
Wang's Blog2 小时前
Java 项目实战: 外卖平台优化-Nginx目录结构与conf配置文件体系
java·nginx