抽奖系统(5——抽奖模块)

1. 抽奖设计

抽奖过程是抽奖系统中最重要的核心环节,它需要确保公平、透明且高效

以下是抽奖过程设计:

1. 参与者注册与奖品建立

  • 参与者注册:管理员通过管理端新增用户,填写必要的信息,如姓名、联系方式等。
  • 奖品建立:奖品需要提前建立好

2. 抽奖活动设置

  • 活动创建:管理员在系统中创建抽奖活动,输入活动名称、描述、奖品列表等信息。
  • 圈选人员:关联该抽奖活动的参与者。
  • 圈选奖品:圈选该抽奖活动的奖品,设置奖品等级、个数等。
  • 活动发布:活动信息发布后,系统通过管理端界面展示活动列表。

3. 抽奖请求处理(重要)

  • 随机抽取:前端随机选择后端提供的参与者,确保每次抽取的结果是公平的。
  • 请求提交:在活动进行时,管理员可发起抽奖请求。请求包含活动ID、奖品ID和中奖人员等附加信息。
  • 消息队列通知:有效的抽奖请求被发送至MQ队列中,等待MQ消费者真正处理抽奖逻辑。
  • 请求返回:抽奖的请求处理接口将不再完成任何的事情,直接返回。

4. 抽奖结果公布

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

5. 抽奖逻辑执行(重要)

  • 消息消费:MQ消费者收到异步消息,系统开始执行以下抽奖逻辑。

6. 中奖结果处理(重要)

  • 请求验证:
    • 系统验证抽奖请求的有效性,如是否满足系统根据设定的规则(如奖品数量、每人中奖次数限制等)等;
    • 幂等性:若消息多发,已抽取的内容不能再次抽取
  • 状态扭转:根据中奖结果扭转活动/奖品/参与者状态,如奖品是否已被抽取,人员是否已中奖等。
  • 结果记录:中奖结果被记录在数据库中,并同步更新Redis缓存。

7. 中奖者通知

  • 通知中奖者:通知中奖者和其他相关系统(如邮件发送服务)。
  • 奖品领取:中奖者根据通知中的指引领取奖品。

8. 抽奖异常处理

  • 回滚处理:当抽奖过程中发生异常,需要保证事务一致性。
  • 补救措施:抽奖行为是一次性的,因此异步处理抽奖任务必须保证成功,若过程异常,需采取补救措施

技术实现细节

  • 异步处理:提高抽奖性能,不影响抽奖流程,将抽奖处理放入队列中进行异步处理,且保证了幂等性。
  • 活动状态扭转处理:状态扭转会涉及活动及奖品等多横向维度扭转,不能避免未来不会有其他内容牵扯进活动中,因此对于状态扭转处理,需要高扩展性(设计模式)与维护性。
  • 并发处理:中奖者通知,可能要通知多系统,但相互解耦,可以设计为并发处理,加快抽奖效率作用。
  • 事务处理:在抽奖逻辑执行时,如若发生异常,需要确保数据库表原子性、事务一致性,因此要做好事务处理。

通过以上流程,抽奖系统能够确保抽奖过程的公平性和高效性,同时提供良好的用户体验。而且还整合了Redis和MQ,进一步提高系统的性能。

2. RabbitMQ 的配置与使用

2.1 什么是 MQ

MQ(Message queue),从字面意思上看,本质是个队列,FIFO 先进先出,只不过队列中存放的内容是消息(message)而已。消息可以非常简单,比如只包含文本字符串、JSON 等,也可以很复杂,比如内嵌对象

MQ 多用于分布式系统之间的通信

系统之间的调用通常有两种方式:

1) 同步通信

直接调用对方的服务,数据从一端发出后立即就可以达到另一端

2) 异步通信

数据从一端发出后,先进入一个容器进行临时存储,当达到某种条件后,再由这个容器发送给另一端。容器的一个具体实现就是 MQ(Message queue)

RabbitMQ 就是 MQ 的一种实现

2.2 MQ 的作用

MQ 主要工作是接受并转发消息,在不同的应用场景下可以展现不同的作用

可以把 MQ 想象成一个仓库,采购部门进货之后把零件放进仓库里,生产部门从仓库中取出零件并加工成产品。MQ 和仓库的区别是,仓库里放的是物品,MQ 里放的是消息。仓库负责存储物品并转发物品,MQ 负责存储和转发消息。

  1. 异步解耦: 在业务流程中,一些操作可能非常耗时,但并不需要即时返回结果.可以借助 MQ 把这些操作异步化,比如 用户注册后发送注册短信或邮件通知,可以作为异步任务处理,而不必等待这些操作完成后才告知用户注册成功.
  2. 流量削峰: 在访问量剧增的情况下,应用仍然需要继续发挥作用,但是是这样的突发流量并不常见.如果以能处理这类峰值为标准而投入资源,无疑是巨大的浪费.使用 MQ 能够使关键组件支撑突发访问压力,不会因为突发流量而崩溃.比如秒杀或者促销活动,可以使用 MQ 来控制流量,将请求排队,然后系统根据自己的处理能力逐步处理这些请求.
  3. 异步通信: 在很多时候应用不需要立即处理消息,MQ 提供了异步处理机制,允许应用把一些消息放入 MQ 中,但并不立即处理它,在需要的时候再慢慢处理.
  4. 消息分发: 当多个系统需要对同一数据做出响应时,可以使用 MQ 进行消息分发.比如支付成功后,支付系统可以向 MQ 发送消息,其他系统订阅该消息,而无需轮询数据库.
  5. 延迟通知: 在需要特定时间后发送通知的场景中,可以使用 MQ 的延迟消息功能,比如在电子商务平台中,如果用户下单后一定时间内未支付,可以使用延迟队列在超时后自动取消订单

2.3 RabbitMQ 下载及代码配置

云服务器下载安装 RabbitMQ 参考:在云服务器上安装 RabbitMQ:从零到一的最佳实践_云服务器安装rabbitmq-优快云博客

pom.xml
        <!-- RabbitMQ -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

application.properties

## mq ##
spring.rabbitmq.host=云服务器公网IP
spring.rabbitmq.port=5672
spring.rabbitmq.username=admin
spring.rabbitmq.password=admin
#消息确认机制,默认auto
spring.rabbitmq.listener.simple.acknowledge-mode=auto
#设置失败重试 5次
spring.rabbitmq.listener.simple.retry.enabled=true
spring.rabbitmq.listener.simple.retry.max-attempts=5

DirectRabbitConfig 配置类

package com.example.lotterysystem.common.config;

import org.springframework.amqp.core.*;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DirectRabbitConfig {
    public static final String QUEUE_NAME = "DirectQueue";
    public static final String EXCHANGE_NAME = "DirectExchange";
    public static final String ROUTING = "DirectRouting";

    /**
     * 队列 起名:DirectQueue
     *
     * @return
     */
    @Bean
    public Queue directQueue() {
        // durable:是否持久化,默认是false,持久化队列:会被存储在磁盘上,当消息代理重启时仍然存在,暂存队列:当前连接有效
        // exclusive:默认也是false,只能被当前创建的连接使⽤,⽽且当连接关闭后队列即被删除。此参考优先级⾼于durable
        // autoDelete:是否⾃动删除,当没有⽣产者或者消费者使⽤此队列,该队列会⾃动删除。
        // return new Queue("DirectQueue",true,true,false);

        // ⼀般设置⼀下队列的持久化就好,其余两个就是默认false
        return new Queue(QUEUE_NAME, true);
    }

    /**
     * Direct交换机起名:DirectExchange
     *
     * @return
     */
    @Bean
    DirectExchange directExchange() {
        return new DirectExchange(EXCHANGE_NAME, true, false);
    }

    /**
     * 绑定 将队列和交换机绑定,并设置⽤于匹配键:DirectRouting
     *
     * @return
     */
    @Bean
    Binding bindingDirect() {
        return BindingBuilder.bind(directQueue())
                .to(directExchange())
                .with(ROUTING);
    }

    @Bean
    public MessageConverter jsonMessageConverter() {
        return new Jackson2JsonMessageConverter();
    }
}

3. 抽奖请求处理

时序图

约定前后端交互接口

[请求]  /draw-prize POST

{}

[响应]

{}

Controller 层接口设计

package com.example.lotterysystem.controller;

import com.example.lotterysystem.common.pojo.CommonResult;
import com.example.lotterysystem.controller.param.DrawPrizeParam;
import com.example.lotterysystem.service.DrawPrizeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DrawPrizeController {

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

    @Autowired
    private DrawPrizeService drawPrizeService;

    @RequestMapping("/draw-prize")
    public CommonResult<Boolean> drawPrize(@Validated @RequestBody DrawPrizeParam param) {
        logger.info("drawPrize DrawPrizeParam:{}", param);

        // service
        drawPrizeService.drawPrize(param);
        return CommonResult.success(true);
    }
}

DrawPrizeParam

package com.example.lotterysystem.controller.param;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

import java.util.Date;
import java.util.List;

@Data
public class DrawPrizeParam {
    // 活动id
    @NotNull(message = "活动 id 不能为空!")
    private Long activityId;

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

    // 奖品等级
    @NotBlank(message = "奖品等级不能为空!")
    private String prizeTiers;

    // 中奖时间
    @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;
    }

}

Service 层接口设计

package com.example.lotterysystem.service;

import com.example.lotterysystem.controller.param.DrawPrizeParam;

public interface DrawPrizeService {
    // 异步抽奖接口
    void drawPrize(DrawPrizeParam param);
}
接口实现
package com.example.lotterysystem.service.impl;

import com.example.lotterysystem.common.utils.JacksonUtil;
import com.example.lotterysystem.controller.param.DrawPrizeParam;
import com.example.lotterysystem.service.DrawPrizeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

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

import static com.example.lotterysystem.common.config.DirectRabbitConfig.EXCHANGE_NAME;
import static com.example.lotterysystem.common.config.DirectRabbitConfig.ROUTING;

@Service
public class DrawPrizeServiceImpl implements DrawPrizeService {

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

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Override
    public void drawPrize(DrawPrizeParam param) {
        Map<String, String> map = new HashMap<>();
        map.put("messageId", String.valueOf(UUID.randomUUID()));
        map.put("messageData", JacksonUtil.writeValueAsString(param));
        // 发消息,需要传入参数:交换机、交换机与队列绑定的 key、消息体(上面定义的 map)
        rabbitTemplate.convertAndSend(EXCHANGE_NAME, ROUTING, map);
        logger.info("MQ 消息发送成功:map={}", JacksonUtil.writeValueAsString(map));
    }
}

测试 DrawPrizeTest

package com.example.lotterysystem;

import com.example.lotterysystem.controller.param.DrawPrizeParam;
import com.example.lotterysystem.service.DrawPrizeService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

@SpringBootTest
public class DrawPrizeTest {

    @Autowired
    private DrawPrizeService drawPrizeService;

    @Test
    void drawPrize() {
        DrawPrizeParam param = new DrawPrizeParam();
        param.setActivityId(1L);
        param.setPrizeId(1L);
        param.setPrizeTiers("FIRST_PRIZE");
        param.setWinningTime(new Date());
        List<DrawPrizeParam.Winner> winnerList = new ArrayList<>();
        DrawPrizeParam.Winner winner = new DrawPrizeParam.Winner();
        winner.setUserId(1L);
        winner.setUserName("xxx");
        winnerList.add(winner);
        param.setWinnerList(winnerList);
        drawPrizeService.drawPrize(param);
    }
}
运行结果:

4. MQ 异步抽奖逻辑执行

时序图

4.1 消费 MQ 消息

消费者类 实现

package com.example.lotterysystem.service.mq;

import com.example.lotterysystem.common.exception.ServiceException;
import com.example.lotterysystem.common.utils.JacksonUtil;
import com.example.lotterysystem.controller.param.DrawPrizeParam;
import com.example.lotterysystem.service.DrawPrizeService;
import com.example.lotterysystem.service.activitystatus.ActivityStatusManager;
import com.example.lotterysystem.service.dto.ConvertActivityStatusDTO;
import com.example.lotterysystem.service.enums.ActivityPrizeStatusEnum;
import com.example.lotterysystem.service.enums.ActivityStatusEnum;
import com.example.lotterysystem.service.enums.ActivityUserStatusEnum;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.stream.Collectors;

import static com.example.lotterysystem.common.config.DirectRabbitConfig.QUEUE_NAME;

@Component
@RabbitListener(queues = QUEUE_NAME)
public class MqReceive {

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

    @Autowired
    private DrawPrizeService drawPrizeService;
    @Autowired
    private ActivityStatusManager activityStatusMapper;

    @RabbitHandler
    public void process(Map<String, String> message) {
        // 成功接收到队列中的消息
        logger.info("MQ 成功接收到消息,message:{}", JacksonUtil.writeValueAsString(message));
        String paramString = message.get("messageData");
        DrawPrizeParam param = JacksonUtil.readValue(paramString, DrawPrizeParam.class);
        // 处理抽奖的流程

        try {
            // 校验抽奖请求是否有效
            // 1. 特殊场景:可能前端发起两个一样的抽奖请求,对于 param 来说也是一样的两个请求
            // 2. 若此时 param 是最后一个奖项,就有以下两种操作
            //      处理 param1:活动完成、奖品完成
            //      处理 param2:回滚活动状态、奖品状态
            if (!drawPrizeService.checkDrawPrizeParam(param)) {
                return;
            }

            // 状态扭转处理(重要!这里有设计模式,代码的扩展性、灵活性在这里体现)
            statusConvert(param);

            // 保存中奖者名单

            // 通知中奖者(邮箱、短信)
        } catch (ServiceException e) {
            logger.error("处理 MQ 消息异常!{}:{}", e.getCode(), e.getMessage(), e);
            // 如果异常,需要保证事务一致性(回滚),抛出异常

        } catch (Exception e) {
            logger.error("处理 MQ 消息异常!", e);
            // 如果异常,需要保证事务一致性(回滚),抛出异常
        }
    }

    // 状态扭转优化后方法
    private void statusConvert(DrawPrizeParam param) {
        ConvertActivityStatusDTO convertActivityStatusDTO = new ConvertActivityStatusDTO();
        convertActivityStatusDTO.setActivityId(param.getActivityId());
        convertActivityStatusDTO.setTargetActivityStatus(ActivityStatusEnum.COMPLETED);
        convertActivityStatusDTO.setPrizeId(param.getPrizeId());
        convertActivityStatusDTO.setTargetPrizeStatus(ActivityPrizeStatusEnum.COMPLETED);
        convertActivityStatusDTO.setUserIds(
                param.getWinnerList().stream()
                        .map(DrawPrizeParam.Winner::getUserId)
                        .collect(Collectors.toList())
        );
        convertActivityStatusDTO.setTargetUserStatus(ActivityUserStatusEnum.COMPLETED);

        activityStatusMapper.handlerEvent(convertActivityStatusDTO);
    }

    // 状态扭转优化前方法
//    private void statusConvert(DrawPrizeParam param) {
//
//        /**
//         * 下面这种写法的问题:
//         * 1. 活动状态扭转有依赖性(扭转活动状态依赖于扭转奖品状态),导致代码维护性差
//         * 2. 状态扭转条件可能会扩展(当前代码中扭转活动状态依赖于扭转奖品状态,未来还可能依赖于其他状态),导致代码扩展性差、维护性差
//         * 3. 代码的灵活性、扩展性、维护性极差
//         * 解决方案:设计模式(责任链模式、策略模式)
//         */
//
//        // 1. 扭转奖品状态
//        // 查询活动关联的奖品信息
//        // 条件判断是否符合扭转奖品状态:判断当前状态是否是 INIT,如果是:
//        // 奖品:INIT-->COMPLETED
//
//        // 2. 扭转人员状态
//        // 查询活动关联的人员信息
//        // 条件判断是否符合扭转人员状态:判断当前状态是否是 INIT,如果是:
//        // 人员列表:INIT-->COMPLETED
//
//        // 3. 扭转活动状态(此操作必须在扭转奖品状态之后进行)
//        // 查询活动信息
//        // 条件判断是否符合扭转活动状态:判断当前状态是否是 RUNNING,如果是,且当前全部奖品以抽取完毕后:
//        // 活动:RUNNING-->COMPLETED
//
//        // 4. 更新完整活动信息的缓存
//
//    }
}

4.2 请求验证(校验抽奖信息有效性)

  • 校验是否存在活动奖品
  • 校验奖品数量是否等于中奖人数
  • 校验活动有效性
  • 校验抽取的奖品有效性
  • ...

DrawPrizeService 新增核对抽奖信息有效性接口

// package com.example.lotterysystem.service;


    // 校验抽奖请求
    Boolean checkDrawPrizeParam(DrawPrizeParam param);
接口实现
// package com.example.lotterysystem.service.impl;


    @Override
    public Boolean checkDrawPrizeParam(DrawPrizeParam param) {
        ActivityDO activityDO = activityMapper.selectById(param.getActivityId());
        // 奖品是否存在可以从 activity_prize 表中查,原因是保存 activity 时做了本地事务,保证了数据一致性
        ActivityPrizeDO activityPrizeDO = activityPrizeMapper.selectByAPId(param.getActivityId(), param.getPrizeId());

        // 活动或奖品是否存在
        if (null == activityDO || null == activityPrizeDO) {
//            throw  new ServiceException(ServiceErrorCodeConstants.ACTIVITY_OR_PRIZE_IS_EMPTY);
            logger.info("校验抽奖请求失败!失败原因:{}",ServiceErrorCodeConstants.ACTIVITY_OR_PRIZE_IS_EMPTY.getMsg());
            return false;
        }

        // 活动是否有效
        if (activityDO.getStatus().equalsIgnoreCase(ActivityStatusEnum.COMPLETED.name())) {
//            throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_COMPLETED);
            logger.info("校验抽奖请求失败!失败原因:{}",ServiceErrorCodeConstants.ACTIVITY_COMPLETED.getMsg());
            return false;
        }

        // 奖品是否有效
        if (activityPrizeDO.getStatus().equalsIgnoreCase(ActivityPrizeStatusEnum.COMPLETED.name())) {
//            throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_PRIZE_COMPLETED);
            logger.info("校验抽奖请求失败!失败原因:{}",ServiceErrorCodeConstants.ACTIVITY_PRIZE_COMPLETED.getMsg());
            return false;
        }

        // 中奖者人数是否和设置奖品数量一致
        if (activityPrizeDO.getPrizeAmount() != param.getWinnerList().size()) {
//            throw new ServiceException(ServiceErrorCodeConstants.WINNER_PRIZE_AMOUNT_ERROR);
            logger.info("校验抽奖请求失败!失败原因:{}",ServiceErrorCodeConstants.WINNER_PRIZE_AMOUNT_ERROR.getMsg());
            return false;
        }
        return true;
    }

ActivityPrizeMapper 新增selectByActivityAndPrizeId 方法

// package com.example.lotterysystem.dao.mapper;


    @Select("select * from activity_prize where activity_id = #{activityId} and prize_id = #{prizeId}")
    ActivityPrizeDO selectByAPId(@Param("activityId") Long activityId,
                                 @Param("prizeId") Long prizeId);

    @Select("select count(1) from activity_prize where activity_id = #{activityId} and status = #{status}")
    int countPrize(@Param("activityId") Long activityId,
                   @Param("status") String status);

    @Update("update activity_prize set status = #{status} where activity_id = #{activityId} and prize_id = #{prizeId}")
    void updateStatus(@Param("activityId") Long activityId, @Param("prizeId") Long prizeId, @Param("status") String status);

ActivityMapper 新增selectById 方法

// package com.example.lotterysystem.dao.mapper;


    @Select("select * from activity where id = #{id}")
    ActivityDO selectById(@Param("id") Long id);

    @Update("update activity set status = #{status} where id = #{id}")
    void updateStatus(@Param("id") Long id, @Param("status") String status);

4.3 状态转换

4.3.1 活动/奖品/参与者状态转换设计

4.3.2 代码实现

新增状态转换接口 ActivityStatusManager
package com.example.lotterysystem.service.activitystatus;

import com.example.lotterysystem.service.dto.ConvertActivityStatusDTO;

public interface ActivityStatusManager {

    // 处理活动相关状态转化
    void handlerEvent(ConvertActivityStatusDTO convertActivityStatusDTO);
}
接口实现
package com.example.lotterysystem.service.activitystatus.impl;

import com.example.lotterysystem.common.errorcode.ServiceErrorCodeConstants;
import com.example.lotterysystem.common.exception.ServiceException;
import com.example.lotterysystem.service.ActivityService;
import com.example.lotterysystem.service.activitystatus.ActivityStatusManager;
import com.example.lotterysystem.service.activitystatus.operator.AbstractActivityOperator;
import com.example.lotterysystem.service.dto.ConvertActivityStatusDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;

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

@Component
public class ActivityStatusManagerImpl implements ActivityStatusManager {

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

    @Autowired
    private final Map<String, AbstractActivityOperator> operatorMap = new HashMap<>();
    @Autowired
    private ActivityService activityService;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handlerEvent(ConvertActivityStatusDTO convertActivityStatusDTO) {
        // 1. 活动状态扭转有依赖性(扭转活动状态依赖于扭转奖品状态),导致代码维护性差
        // 2. 状态扭转条件可能会扩展(当前代码中扭转活动状态依赖于扭转奖品状态,未来还可能依赖于其他状态),导致代码扩展性差、维护性差

        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());
        }
    }

    // 扭转状态
    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();
            // operatorMap 是否需要转换
            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;
    }
}
 ConvertActivityStatusDTO
package com.example.lotterysystem.service.dto;

import com.example.lotterysystem.service.enums.ActivityPrizeStatusEnum;
import com.example.lotterysystem.service.enums.ActivityStatusEnum;
import com.example.lotterysystem.service.enums.ActivityUserStatusEnum;
import lombok.Data;

import java.util.List;

@Data
public class ConvertActivityStatusDTO {
    // 活动id
    private Long activityId;
    // 活动目标状态
    private ActivityStatusEnum targetActivityStatus;
    // 奖品id
    private Long prizeId;
    // 奖品目标状态
    private ActivityPrizeStatusEnum targetPrizeStatus;
    // 人员id列表
    private List<Long> userIds;
    // 奖品目标状态
    private ActivityUserStatusEnum targetUserStatus;
}
 AbstractActivityOperator(策略模式)
package com.example.lotterysystem.service.activitystatus.operator;

import com.example.lotterysystem.service.dto.ConvertActivityStatusDTO;

public abstract class AbstractActivityOperator {

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

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

    // 转换方法,返回成功或失败
    public abstract Boolean convert(ConvertActivityStatusDTO convertActivityStatusDTO);
}
策略一:奖品状态操作
package com.example.lotterysystem.service.activitystatus.operator;

import com.example.lotterysystem.dao.dataobject.ActivityPrizeDO;
import com.example.lotterysystem.dao.mapper.ActivityPrizeMapper;
import com.example.lotterysystem.service.dto.ConvertActivityStatusDTO;
import com.example.lotterysystem.service.enums.ActivityPrizeStatusEnum;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@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;
        }
    }
}
策略二:参与者状态操作
package com.example.lotterysystem.service.activitystatus.operator;

import com.example.lotterysystem.dao.dataobject.ActivityUserDO;
import com.example.lotterysystem.dao.mapper.ActivityUserMapper;
import com.example.lotterysystem.service.dto.ConvertActivityStatusDTO;
import com.example.lotterysystem.service.enums.ActivityUserStatusEnum;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import java.util.List;

@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;
        }
    }
}
策略三:活动状态操作
package com.example.lotterysystem.service.activitystatus.operator;

import com.example.lotterysystem.dao.dataobject.ActivityDO;
import com.example.lotterysystem.dao.mapper.ActivityMapper;
import com.example.lotterysystem.dao.mapper.ActivityPrizeMapper;
import com.example.lotterysystem.service.dto.ConvertActivityStatusDTO;
import com.example.lotterysystem.service.enums.ActivityPrizeStatusEnum;
import com.example.lotterysystem.service.enums.ActivityStatusEnum;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@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 状态的奖品个数,若大于0,则表示还有奖品未被抽取
        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;
        }
    }
}
ActivityUserMapper 新增
// package com.example.lotterysystem.dao.mapper;


    @Select("<script>" +
            " select * from activity_user" +
            " where activity_id = #{activityId}" +
            " and user_id in" +
            " <foreach collection='userIds' item='userId' open='(' separator=',' close=')' >" +
            " #{userId}" +
            " </foreach>" +
            " </script>")
    List<ActivityUserDO> batchSelectByAUIds(@Param("activityId") Long activityId,
                                            @Param("userIds") List<Long> userIds);

    @Update("<script>" +
            " update activity_user set status = #{status}" +
            " where activity_id = #{activityId}" +
            " and user_id in" +
            " <foreach collection='userIds' item='userId' open='(' separator=',' close=')' >" +
            " #{userId}" +
            " </foreach>" +
            " </script>")
    void batchUpdateStatus(
            @Param("activityId") Long activityId,
            @Param("userIds") List<Long> userIds,
            @Param("status") String status);

4.3.3 遇到问题与解决

问题:
  1. 存在多个处理对象的顺序关系需要维护:奖品+活动状态扭转,活动需要依赖奖品状态改变而改变。可以看出请求依赖于多个决策点,因此处理顺序很重要,不易维护。
  2. 需要动态改变算法或行为:是否可以扭转状态的条件,若将来会发生改变,在这里不易维护。
  3. 系统的灵活性和可扩展性无法体现。
  4. 处理请求的复杂性不易维护。

因此需要对请求处理进行优化:如果请求的处理过程需要根据性能或其他因素进行优化,策略模式可以轻松替换或更新具体的处理策略,而责任链模式可以帮助管理这些策略的应用顺序。

解决方案:
  1. 策略模式(Strategy Pattern):定义AbstractActivityOperator策略类,和其策略实现类PrizeOperator、ActivityOperator和UserOperator。每个具体的操作类都实现了AbstractActivityOperator定义的接口,代表了不同的状态转换策略。
  2. 责任链模式(Chain of Responsibility Pattern):定义ActivityStatusManager接口类,在ActivityStatusManagerImpl实现中,通过遍历operatorMap中的所有操作符(策略),并按照一定的顺序执行,形成一个责任链,每个操作符判断是否是自己的责任,如果是,则处理请求。

责任链模式(Chain of Responsibility Pattern)是一种行为设计模式,它允许将一个请求沿着处理者对象组成的链进行传递。每个处理者对象都有责任去处理请求,或者将它传递给链中的下一个处理者。请求的传递一直进行,直到有一个处理者对象对请求进行了处理,或者直到链的末端仍未有处理者处理该请求。

以下是责任链模式在ActivityStatusManagerImpl类中的实现细节:

  1. 请求的创建:ActivityStatusConvertDTO statusConvertDTO是请求对象,包含了状态转换所需的所有信息。
  2. 处理者对象:AbstractActivityOperator及其子类PrizeOperator和ActivityOperator是处理者对象,它们实现了needConvert和convertStatus方法,用以判断是否需要处理请求以及执行处理。
  3. 责任链的维护:operatorMap是一个包含所有处理者对象的映射,它按照sequence()方法返回的顺序维护了责任链。
  4. 请求的传递:在processStatusConversion方法中,通过迭代器遍历operatorMap,对每个操作符实例调用needConvert方法来判断是否需要由当前操作符处理请求。
  5. 处理请求:如果needConvert返回true,则调用convertStatus方法来处理请求。
  6. 终止责任链:一旦请求被某个操作符处理,迭代器中的该操作符将被移除(it.remove()),这防止了请求被重复处理,并且终止了对该操作符的责任链。
  7. 异常处理:如果在责任链中的任何点上请求处理失败(convertStatus返回false),则抛出异常,这可以看作是责任链的终止。

通过这种方式,责任链模式允许系统在运行时根据请求的类型动态的选择处理者,而不需要修改其他处理者的代码,从而提高了系统的灵活性和可维护性。

4.3.4 测试

DrawPrizeTest
// package com.example.lotterysystem;

    @Autowired
    private ActivityStatusManager activityStatusManager;

    @Test
    void statusConvert() {
        ConvertActivityStatusDTO convertActivityStatusDTO = new ConvertActivityStatusDTO();
        convertActivityStatusDTO.setActivityId(1L);
        convertActivityStatusDTO.setTargetActivityStatus(ActivityStatusEnum.COMPLETED);
        convertActivityStatusDTO.setPrizeId(2L);
        convertActivityStatusDTO.setTargetPrizeStatus(ActivityPrizeStatusEnum.COMPLETED);
        List<Long> userIds = Arrays.asList(4L);
        convertActivityStatusDTO.setUserIds(userIds);
        convertActivityStatusDTO.setTargetUserStatus(ActivityUserStatusEnum.COMPLETED);
        activityStatusManager.handlerEvent(convertActivityStatusDTO);
    }
运行结果 

当奖品全部抽完后,活动状态也会改变

4.4 中奖结果记录

时序图

DrawPrizeService 新增saveWinningRecords接口

// package com.example.lotterysystem.service;


    // 保存中奖者名单
    List<WinningRecordDO> saveWinnerRecords(DrawPrizeParam param);
接口实现
package com.example.lotterysystem.service.impl;

import com.example.lotterysystem.common.errorcode.ServiceErrorCodeConstants;
import com.example.lotterysystem.common.exception.ServiceException;
import com.example.lotterysystem.common.utils.JacksonUtil;
import com.example.lotterysystem.common.utils.RedisUtil;
import com.example.lotterysystem.controller.param.DrawPrizeParam;
import com.example.lotterysystem.dao.dataobject.*;
import com.example.lotterysystem.dao.mapper.*;
import com.example.lotterysystem.service.DrawPrizeService;
import com.example.lotterysystem.service.enums.ActivityPrizeStatusEnum;
import com.example.lotterysystem.service.enums.ActivityStatusEnum;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import java.util.*;
import java.util.stream.Collectors;

import static com.example.lotterysystem.common.config.DirectRabbitConfig.EXCHANGE_NAME;
import static com.example.lotterysystem.common.config.DirectRabbitConfig.ROUTING;

@Service
public class DrawPrizeServiceImpl implements DrawPrizeService {

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

    // 约点前缀
    private final String WINNING_RECORDS_PREFIX = "WINNING_RECORDS_";

    // 中奖记录保存两天
    private final Long WINNING_RECORDS_TIMEOUT = 60 * 60 * 24 * 2L;

    @Autowired
    private RabbitTemplate rabbitTemplate;
    @Autowired
    private ActivityMapper activityMapper;
    @Autowired
    private ActivityPrizeMapper activityPrizeMapper;
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private PrizeMapper prizeMapper;
    @Autowired
    private WinningRecordMapper winningRecordMapper;
    @Autowired
    private RedisUtil redisUtil;

    @Override
    public void drawPrize(DrawPrizeParam param) {
        Map<String, String> map = new HashMap<>();
        map.put("messageId", String.valueOf(UUID.randomUUID()));
        map.put("messageData", JacksonUtil.writeValueAsString(param));
        // 发消息,需要传入参数:交换机、交换机与队列绑定的 key、消息体(上面定义的 map)
        rabbitTemplate.convertAndSend(EXCHANGE_NAME, ROUTING, map);
        logger.info("MQ 消息发送成功:map={}", JacksonUtil.writeValueAsString(map));
    }

    @Override
    public void checkDrawPrizeParam(DrawPrizeParam param) {
        ActivityDO activityDO = activityMapper.selectById(param.getActivityId());
        // 奖品是否存在可以从 activity_prize 表中查,原因是保存 activity 时做了本地事务,保证了数据一致性
        ActivityPrizeDO activityPrizeDO = activityPrizeMapper.selectByAPId(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);
        }

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

    @Override
    public List<WinningRecordDO> saveWinnerRecords(DrawPrizeParam param) {
        // 查询相关信息:活动表、人员表、奖品表、活动关联奖品表
        ActivityDO activityDO = activityMapper.selectById(param.getActivityId());
        List<UserDO> userDOList = userMapper.batchSelectByIds(
                param.getWinnerList()
                .stream()
                .map(DrawPrizeParam.Winner::getUserId)
                .collect(Collectors.toList())
        );
        PrizeDO prizeDO = prizeMapper.selectById(param.getPrizeId());
        ActivityPrizeDO activityPrizeDO = activityPrizeMapper.selectByAPId(param.getActivityId(), param.getPrizeId());

        // 构造中奖者记录,保存
        List<WinningRecordDO> winningRecordDOList = userDOList.stream()
                .map(userDO -> {
                    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());
                    return winningRecordDO;
                }).collect(Collectors.toList());
        winningRecordMapper.batchInsert(winningRecordDOList);

        // 缓存中奖者记录
        // 1. 缓存奖品维度中奖记录(WinningRecord_activityId_prizeId, winningRecordDOList(奖品维度的中奖名单))
        cacheWinningRecords(param.getActivityId() + "_" + param.getPrizeId(), winningRecordDOList, WINNING_RECORDS_TIMEOUT);

        // 2. 缓存活动维度中奖记录(WinningRecord_activityId, winningRecordDOList(活动维度的中奖名单))
        // 当活动已完成再去存放活动维度中奖记录
        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;
    }

    // 缓存中奖记录
    private void cacheWinningRecords(String key, List<WinningRecordDO> winningRecordDOList, Long time) {
        String str = "";
        try {
            str = JacksonUtil.writeValueAsString(winningRecordDOList);
            if (!StringUtils.hasText(key) || CollectionUtils.isEmpty(winningRecordDOList)) {
                logger.warn("要缓存的内容为空!key:{},value:{}",key, str);
                return;
            }
            redisUtil.set(WINNING_RECORDS_PREFIX + key, str, time);
        } catch (Exception e) {
            logger.error("缓存中奖记录异常!key:{},value:{}", WINNING_RECORDS_PREFIX + key, str);
        }
    }

    // 从缓存中获取中奖记录
    private List<WinningRecordDO> getWinningRecords(String key) {
        try {
            if (!StringUtils.hasText(key)) {
                logger.error("要从缓存中查询中奖记录的 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();
        }
    }
}

WinningRecordDO

package com.example.lotterysystem.dao.dataobject;

import lombok.Data;
import lombok.EqualsAndHashCode;

import java.util.Date;

@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;

}
MqReceive 新增
// package com.example.lotterysystem.service.mq;


            // 保存中奖者名单
            List<WinningRecordDO> winningRecordDOList = drawPrizeService.saveWinnerRecords(param);

 WinningRecordMapper

package com.example.lotterysystem.dao.mapper;

import com.example.lotterysystem.dao.dataobject.WinningRecordDO;
import org.apache.ibatis.annotations.*;

import java.util.List;

@Mapper
public interface WinningRecordMapper {

    @Insert("<script>" +
            " insert into winning_record (activity_id, activity_name," +
            " prize_id, prize_name, prize_tier," +
            " winner_id, winner_name, winner_email, winner_phone_number, winning_time)" +
            " values <foreach collection = 'items' item='item' index='index' separator=','>" +
            " (#{item.activityId}, #{item.activityName}," +
            " #{item.prizeId}, #{item.prizeName}, #{item.prizeTier}," +
            " #{item.winnerId}, #{item.winnerName}, #{item.winnerEmail}, #{item.winnerPhoneNumber}, #{item.winningTime})" +
            " </foreach>" +
            " </script>")
    @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
    int batchInsert(@Param("items") List<WinningRecordDO> winningRecordDOList);

    @Select("select * from winning_record where activity_id = #{activityId}")
    List<WinningRecordDO> selectByActivityId(@Param("activityId") Long activityId);

    @Select("select count(1) from winning_record where activity_id = #{activityId} and prize_id = #{prizeId}")
    int countByAPId(@Param("activityId") Long activityId, @Param("prizeId") Long prizeId);

    /**
     * 删除活动 或 奖品下的中奖记录
     * @param activityId
     * @param prizeId
     */
    @Delete("<script>" +
            " delete from winning_record" +
            " where activity_id = #{activityId}" +
            " <if test=\"prizeId != null\">" +
            "   and prize_id = #{prizeId}" +
            " </if>" +
            " </script>")
    void deleteRecords(@Param("activityId") Long activityId, @Param("prizeId") Long prizeId);

    @Select("<script>" +
            " select * from winning_record" +
            " where activity_id = #{activityId}" +
            " <if test=\"prizeId != null\">" +
            "   and prize_id = #{prizeId}" +
            " </if>" +
            " </script>")
    List<WinningRecordDO> selectByActivityIdOrPrizeId(@Param("activityId") Long activityId,
                                                      @Param("prizeId")  Long prizeId);
}

测试 DrawPrizeTest 新增

// package com.example.lotterysystem;

    @Test
    void saveWinningRecords(){
        DrawPrizeParam param = new DrawPrizeParam();
        param.setActivityId(1L);
        param.setPrizeId(2L);
        param.setWinningTime(new Date());
        List<DrawPrizeParam.Winner> winnerList = new ArrayList<>();
        DrawPrizeParam.Winner winner = new DrawPrizeParam.Winner();
        winner.setUserId(4L);
        winner.setUserName("郭靖");
        winnerList.add(winner);
        param.setWinnerList(winnerList);
        drawPrizeService.saveWinnerRecords(param);
    }
运行结果:

中奖记录表

活动维度

奖品维度

4.5 中奖者通知

当中奖结果被记录下来后,需要完成给中奖用户发送短信、邮件通知等行为,这俩行为相互独立,设计为并发执行可以提高处理效率,减少响应时间。因为在处理消息队列中的消息时,通常希望尽快响应和处理消息。

4.5.1 邮件服务

pom.xml
        <!-- 邮箱服务 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>
配置 application.properties
## 邮件 ##
spring.mail.host=smtp.qq.com
spring.mail.username=发送者邮箱
# 你的授权码:邮箱设置-》第三⽅服务-》开启IMAP/SMTP服务-》获取授权码
spring.mail.password=你的授权码
# 在邮件发送配置中启用 SSL
spring.mail.properties.mail.smtp.ssl.enable=true
spring.mail.default-encoding=UTF-8

tip:QQ 邮箱获取授权码

先绑定手机号

邮箱工具类 MailUtil
package com.example.lotterysystem.common.utils;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;

@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;
    }
}
测试 MailTest
package com.example.lotterysystem;

import com.example.lotterysystem.common.utils.MailUtil;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class MailTest {

    @Autowired
    private MailUtil mailUtil;

    @Test
    void sendMessage() {
        mailUtil.sendSampleMail("2920389672@qq.com", "标题", "正文");
    }

}
运行结果:

4.5.2 短信通知

前面我们申请了阿里云的短信服务中的验证码模版,这里需要再去申请一套通知模版

申请通知中奖者短信模版参考:

模版内容

Hi,${name}。恭喜你在${activityName}活动中获得${prizeTiers},奖品为${prizeName}。获奖时间为${winningTime},请尽快领取您的奖励!

4.5.3 配置线程池

配置 application.properties
## 线程池 ##
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-
新增线程池配置类
package com.example.lotterysystem.common.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.ThreadPoolExecutor;

@Configuration
@EnableAsync // 用于启动异步方法的调用
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;


    @Bean(name = "asyncServiceExecutor")
    public ThreadPoolTaskExecutor asyncServiceExecutor(){
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new
                ThreadPoolTaskExecutor();
        threadPoolTaskExecutor.setCorePoolSize(corePoolSize);
        threadPoolTaskExecutor.setMaxPoolSize(maxPoolSize);
        threadPoolTaskExecutor.setQueueCapacity(queueCapacity);
        threadPoolTaskExecutor.setKeepAliveSeconds(3);
        threadPoolTaskExecutor.setThreadNamePrefix(namePrefix);
        // rejection-policy:当pool已经达到max size的时候,如何处理新任务
        // CALLER_RUNS:不在新线程中执⾏任务,⽽是由调⽤者所在的线程来执⾏
        threadPoolTaskExecutor.setRejectedExecutionHandler(new
                ThreadPoolExecutor.AbortPolicy());
        //加载
        threadPoolTaskExecutor.initialize();
        return threadPoolTaskExecutor;
    }
}

4.5.4 并发通知

MqReceive 新增
    // 并发处理抽奖完成后的后续流程
    private void syncExecute(List<WinningRecordDO> winningRecordDOList) {
        // 通过线程池 threadPoolTaskExecutor

        // 短信通知
        threadPoolTaskExecutor.execute(() ->sendMessage(winningRecordDOList));

        // 邮件通知
        threadPoolTaskExecutor.execute(() ->sendMail(winningRecordDOList));

    }

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

    // 发短信
    private void sendMessage(List<WinningRecordDO> winningRecordDOList) {
        if (CollectionUtils.isEmpty(winningRecordDOList)) {
            logger.info("中奖列表为空,不用发短信!");
            return;
        }
        for (WinningRecordDO winningRecordDO : winningRecordDOList) {
            Map<String, String> map = new HashMap<>();
            map.put("name", winningRecordDO.getWinnerName());
            map.put("activityName", winningRecordDO.getActivityName());
            map.put("prizeTiers", ActivityPrizeTiersEnum.forName(winningRecordDO.getPrizeTier()).getMessage());
            map.put("prizeName", winningRecordDO.getPrizeName());
            map.put("winningTime", DateUtil.formatTime(winningRecordDO.getWinningTime()));
            smsUtil.sendMessage("填自己的",
                    winningRecordDO.getWinnerPhoneNumber().getValue(),
                    JacksonUtil.writeValueAsString(map));
        }
    }

4.6 事务一致性(异常回滚)

什么是事务?回答这个问题之前,我们先来看一个经典的场景:支付宝等交易平台的转账。假设小明需要用支付宝给小红转账100000元,此时,小明帐号会少100000元,而小红帐号会多100000元。如果在转账过程中系统崩溃了,小明帐号少100000元,而小红帐号金额不变,就会出大问题,因此这个时候我们就需要使用事务了。

这里,体现了事务一个很重要的特性:原子性。

事务的四个基本特性:原子性、一致性、隔离性、持久性。

  • 原子性:即事务内的操作要么全部成功,要么全部失败,不会在中间的某个环节结束。
  • 一致性:即使数据库在一个事务执行之前和执行之后,数据库都必须处于一致性状态。如果事务执行失败,那么需要自动回滚到原始状态,换句话说,事务一旦提交,其他事务查看到的结果一致,事务一旦回滚,其他事务也只能看到回滚前的状态。
  • 隔离性:即在并发环境中,不同的事务同时修改相同的数据时,一个未完成事务不会影响另外一个未完成事务。
  • 持久性:即事务一旦提交,其修改的数据将永久保存到数据库中,其改变是永久性的。

在我们的场景中,MQ消费的过程里,不仅仅修改了数据库表内容,还向Redis缓存中新增了很多热点数据。例如扭转了奖品及活动的状态,还将中奖名单落入库中。那么在这个过程中,一旦出现异常,我们必须要保证该事务的特性。

如何实现事务?一种常用的实现方案是catch异常并实现回滚。

回滚方法定义

    // 处理抽奖异常的回滚行为:恢复处理请求之前的库表状态
    private void rollback(DrawPrizeParam param) {
        // 1. 回滚状态:活动、奖品、人员
        // 状态是否需要回滚
        if (!statusNeedRollback(param)) {
            // 不需要:return
            return;
        }
        // 需要:回滚
        rollbackStatus(param);

        // 2. 回滚中奖者名单
        // 是否需要回滚
        if (!winnerNeedRollback(param)) {
            // 不需要:return
            return;
        }
        // 需要:回滚
        rollbackWinner(param);
    }

    // 回滚中奖记录:删除奖品下的中奖者
    private void rollbackWinner(DrawPrizeParam param) {
        drawPrizeService.deleteRecords(param.getActivityId(), param.getPrizeId());
    }

    private boolean winnerNeedRollback(DrawPrizeParam param) {
        // 判断活动中的奖品是否存在中奖者
        int count = winningRecordMapper.countByAPId(param.getActivityId(), param.getPrizeId());
        return count > 0;
    }

    // 恢复相关状态
    private void rollbackStatus(DrawPrizeParam param) {
        // 涉及状态的恢复,使用 ActivityStatusManager
        ConvertActivityStatusDTO convertActivityStatusDTO = new ConvertActivityStatusDTO();
        convertActivityStatusDTO.setActivityId(param.getActivityId());
        convertActivityStatusDTO.setTargetActivityStatus(ActivityStatusEnum.RUNNING);
        convertActivityStatusDTO.setPrizeId(param.getPrizeId());
        convertActivityStatusDTO.setTargetPrizeStatus(ActivityPrizeStatusEnum.INIT);
        convertActivityStatusDTO.setUserIds(
                param.getWinnerList().stream()
                        .map(DrawPrizeParam.Winner::getUserId)
                        .collect(Collectors.toList())
        );
        convertActivityStatusDTO.setTargetUserStatus(ActivityUserStatusEnum.INIT);
        activityStatusManager.rollbackHandlerEvent(convertActivityStatusDTO);

    }

    // 判断是否需要回滚
    private boolean statusNeedRollback(DrawPrizeParam param) {
        // 常规思路:判断活动+奖品+人员表相关状态是否已经扭转
        // 实际上前面扭转状态时,保证了事务一致性,要么都扭转了,要么都没扭转(不包含活动,因为其是根据所有奖品的状态来决定是否扭转的)
        // 因此,只用判断人员 or 奖品是否扭转过,就能判断出是否全部扭转
        // 不能判断活动是否已经扭转
        // 结论:判断奖品状态是否扭转,就能判断出全部状态是否扭转
        ActivityPrizeDO activityPrizeDO = activityPrizeMapper.selectByAPId(param.getActivityId(), param.getPrizeId());
        return activityPrizeDO.getStatus().equalsIgnoreCase(ActivityPrizeStatusEnum.COMPLETED.name());
    }

ActivityStatusManager 新增回滚接口

// package com.example.lotterysystem.service.activitystatus;


    // 回滚处理活动相关状态
    void rollbackHandlerEvent(ConvertActivityStatusDTO convertActivityStatusDTO);
接口实现
// package com.example.lotterysystem.service.activitystatus.impl;


    // 回滚处理相关状态
    @Override
    public void rollbackHandlerEvent(ConvertActivityStatusDTO convertActivityStatusDTO) {
        // operatorMap:需要回滚 活动、奖品、人员
        for (AbstractActivityOperator operator : operatorMap.values()) {
            operator.convert(convertActivityStatusDTO);
        }
        // 缓存更新
        activityService.cacheActivity(convertActivityStatusDTO.getActivityId());
    }

    // 扭转状态
    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();
            // operatorMap 是否需要转换
            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;
    }

 DrawPrizeService 新增接口

// package com.example.lotterysystem.service;


    // 删除活动/奖品下的中奖记录
    void deleteRecords(Long activityId, Long prizeId);
接口实现
// package com.example.lotterysystem.service.impl;


    @Override
    public void deleteRecords(Long activityId, Long prizeId) {
        if (null == activityId) {
            logger.warn("要删除中奖记录相关的活动id为空!");
            return;
        }
        // 删除数据表
        winningRecordMapper.deleteRecords(activityId, prizeId);

        // 删除缓存(奖品维度、活动维度)
        if (null != prizeId) {
            deleteWinningRecords(activityId + "_" + prizeId);
        }
        // 无论是否传递了 prizeId 都需要删除活动维度的中奖记录缓存
        // 1. 如果传递了 prizeId,说明奖品未被抽取,必须删除活动维度的中奖缓存记录
        // 2. 如果没有传递 prizeId,就只是删除活动维度的缓存
        deleteWinningRecords(String.valueOf(activityId));
    }

    // 从缓存中删除中奖记录
    private void deleteWinningRecords(String key) {
        try {
            if (redisUtil.hasKey(WINNING_RECORDS_PREFIX + key)) {
                redisUtil.del(WINNING_RECORDS_PREFIX + key);
            }
        } catch (Exception e) {
            logger.error("删除中奖记录缓存异常,key:{}", key);
        }
    }

WinningRecordMapper新增dao方法

   /**
     * 删除活动 或 奖品下的中奖记录
     * @param activityId
     * @param prizeId
     */
    @Delete("<script>" +
            " delete from winning_record" +
            " where activity_id = #{activityId}" +
            " <if test=\"prizeId != null\">" +
            "   and prize_id = #{prizeId}" +
            " </if>" +
            " </script>")
    void deleteRecords(@Param("activityId") Long activityId, @Param("prizeId") Long prizeId);

测试 DrawPrizeTest

测试一:正向流程
    @Test
    void drawPrize() {
        // 1. 正向流程
        DrawPrizeParam param = new DrawPrizeParam();
        param.setActivityId(2L);
        param.setPrizeId(2L);
        param.setWinningTime(new Date());
        List<DrawPrizeParam.Winner> winnerList = new ArrayList<>();
        DrawPrizeParam.Winner winner = new DrawPrizeParam.Winner();
        winner.setUserId(6L);
        winner.setUserName("杨康");
        winnerList.add(winner);
        param.setWinnerList(winnerList);
        drawPrizeService.drawPrize(param);
    }

运行结果:

 

测试二:处理过程中发生异常:回滚

手动抛出一个异常模拟出现异常的情况:

运行后出现异常,就会回滚,如果回滚失败,则2号活动状态就会改为 COMPLETED

回滚成功则状态依旧为 RUNNING

将该异常去掉后,正常发送:

测试三:处理过程中发生异常:消息堆积->处理异常->消息重发

测试三之前需要配置好下面的死信队列


4.7 保证消息消费成功(加入死信队列)

虽然我们已经保证了事务的一致性,但目前未能保证该消息被成功消费,因此加入死信队列。

DirectRabbitConfig 中新增死信队列配置

  1. 新增死信队列、交换机,且绑定
  2. 让正常队列绑定死信交换机

注意:这里我们让正常队列绑定了新的交换机,对于正常队列来说,其配置已经修改,我们需要在 mq 的 web 管理端删除已存在的 DirectExchangeDirectQueue,然后再启动项目,要不然启动时 MQ 会报错。

代码实现:

package com.example.lotterysystem.common.config;

import org.springframework.amqp.core.*;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@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
     *
     * @return
     */
    @Bean
    public Queue directQueue() {
        // durable:是否持久化,默认是false,持久化队列:会被存储在磁盘上,当消息代理重启时仍然存在,暂存队列:当前连接有效
        // exclusive:默认也是false,只能被当前创建的连接使用,而且当连接关闭后队列即被删除。此参考优先级高于durable
        // autoDelete:是否自动删除,当没有生产者或者消费者使用此队列,该队列会自动删除。
        //   return new Queue("DirectQueue",true,true,false);

        // 一般设置一下队列的持久化就好,其余两个就是默认false
        // return new Queue(QUEUE_NAME,true);

        // 普通队列绑定死信交换机
        return QueueBuilder.durable(QUEUE_NAME)
                .deadLetterExchange(DLX_EXCHANGE_NAME)
                .deadLetterRoutingKey(DLX_ROUTING).build();
    }

    /**
     * Direct交换机 起名:DirectExchange
     *
     * @return
     */
    @Bean
    DirectExchange directExchange() {
        return new DirectExchange(EXCHANGE_NAME,true,false);
    }

    /**
     * 绑定  将队列和交换机绑定, 并设置用于匹配键:DirectRouting
     *
     * @return
     */
    @Bean
    Binding bindingDirect() {
        return BindingBuilder.bind(directQueue())
                .to(directExchange())
                .with(ROUTING);
    }

    /**
     * 死信队列
     *
     * @return
     */
    @Bean
    public Queue dlxQueue() {
        // durable:是否持久化,默认是false,持久化队列:会被存储在磁盘上,当消息代理重启时仍然存在,暂存队列:当前连接有效
        // exclusive:默认也是false,只能被当前创建的连接使用,而且当连接关闭后队列即被删除。此参考优先级高于durable
        // autoDelete:是否自动删除,当没有生产者或者消费者使用此队列,该队列会自动删除。
        //   return new Queue("DirectQueue",true,true,false);

        // 一般设置一下队列的持久化就好,其余两个就是默认false
        return new Queue(DLX_QUEUE_NAME,true);
    }

    /**
     * 死信交换机
     *
     * @return
     */
    @Bean
    DirectExchange dlxExchange() {
        return new DirectExchange(DLX_EXCHANGE_NAME,true,false);
    }

    /**
     * 绑定死信队列与交换机
     *
     * @return
     */
    @Bean
    Binding bindingDlx() {
        return BindingBuilder.bind(dlxQueue())
                .to(dlxExchange())
                .with(DLX_ROUTING);
    }

    @Bean
    public MessageConverter jsonMessageConverter(){
        return new Jackson2JsonMessageConverter();
    }
}

续上面的测试三:

手动构造异常:

发送两次消息:

可以发现其都堆积到了死信队列中

新增死信队列消费者

package com.example.lotterysystem.service.mq;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.Map;

import static com.example.lotterysystem.common.config.DirectRabbitConfig.*;

@Component
@RabbitListener(queues = DLX_QUEUE_NAME)
public class DlxReceiver {

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

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @RabbitHandler
    public void process(Map<String, String> message) {
        // 死信队列的处理方法
        logger.info("开始处理异常消息!");
        rabbitTemplate.convertAndSend(EXCHANGE_NAME, ROUTING, message);
    }
}
再次运行程序:

 

成功将死信队列中的两条消息重发

5. 中奖名单

时序图

约定前后端交互接口

[请求]  /winning-records/show POST

{

        "activityId":23

}

[响应]

{
    "code": 200,
    "data": [
        {
            "winnerId": 15,
            "winnerName": "胡⼀博",
            "prizeName": "华为⼿机",
            "prizeTier": "⼀等奖",
            "winningTime": "2024-05-21T11:55:10.000+00:00"
        },
        {
            "winnerId": 21,
            "winnerName": "范闲",
            "prizeName": "华为⼿机",
            "prizeTier": "⼀等奖",
            "winningTime": "2024-05-21T11:55:10.000+00:00"
        }
    ],
    "msg": ""
 }

Controller 层接口设计

// package com.example.lotterysystem.controller;


    @RequestMapping("/winning-records/show")
    public CommonResult<List<WinningRecordResult>> showWinningRecords(
            @Validated @RequestBody ShowWinningRecordsParam param) {
        logger.info("showWinningRecords ShowWinningRecordsParam:{}",
                JacksonUtil.writeValueAsString(param));
        List<WinningRecordDTO> winningRecordDTOList = drawPrizeService.getRecords(param);
        return CommonResult.success(
                convertToWinningRecordResultList(winningRecordDTOList));
    }

    private List<WinningRecordResult> convertToWinningRecordResultList(
            List<WinningRecordDTO> winningRecordDTOList) {
        if (CollectionUtils.isEmpty(winningRecordDTOList)) {
            return Arrays.asList();
        }
        return winningRecordDTOList.stream()
                .map(winningRecordDTO -> {
                    WinningRecordResult result = new WinningRecordResult();
                    result.setWinnerId(winningRecordDTO.getWinnerId());
                    result.setWinnerName(winningRecordDTO.getWinnerName());
                    result.setPrizeName(winningRecordDTO.getPrizeName());
                    result.setPrizeTier(winningRecordDTO.getPrizeTier().getMessage());
                    result.setWinningTime(winningRecordDTO.getWinningTime());
                    return result;
                }).collect(Collectors.toList());
    }

WinningRecordResult

package com.example.lotterysystem.controller.result;

import lombok.Data;

import java.io.Serializable;
import java.util.Date;

@Data
public class WinningRecordResult implements Serializable {
    // 中奖者id
    private Long winnerId;

    // 中奖者姓名
    private String winnerName;

    // 奖品名
    private String prizeName;

    // 等级
    private String prizeTier;

    // 中奖时间
    private Date winningTime;

}

ShowWinningRecordsParam

package com.example.lotterysystem.controller.param;

import jakarta.validation.constraints.NotNull;
import lombok.Data;

import java.io.Serializable;

@Data
public class ShowWinningRecordsParam implements Serializable {

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

    // 奖品id
    private Long prizeId;
}

Service 层接口设计

package com.example.lotterysystem.service;
import com.example.lotterysystem.controller.param.DrawPrizeParam;
import com.example.lotterysystem.controller.param.ShowWinningRecordsParam;
import com.example.lotterysystem.dao.dataobject.WinningRecordDO;
import com.example.lotterysystem.service.dto.WinningRecordDTO;

import java.util.List;

public interface DrawPrizeService {

    // 获取中奖记录
    List<WinningRecordDTO> getRecords(ShowWinningRecordsParam param);
}
接口实现
package com.example.lotterysystem.service.impl;

@Service
public class DrawPrizeServiceImpl implements DrawPrizeService {

    @Override
    public List<WinningRecordDTO> getRecords(ShowWinningRecordsParam param) {
        // 查询redis: 奖品、活动
        String key = null == param.getPrizeId()
                ? String.valueOf(param.getActivityId())
                : param.getActivityId() + "_" + param.getPrizeId();
        List<WinningRecordDO> winningRecordDOList = getWinningRecords(key);
        if (!CollectionUtils.isEmpty(winningRecordDOList)) {
            return convertToWinningRecordDTOList(winningRecordDOList);
        }
        // 如果redis不存在,查库
        winningRecordDOList = winningRecordMapper.selectByActivityIdOrPrizeId(
                param.getActivityId(), param.getPrizeId());
        // 存放记录到redis
        if (CollectionUtils.isEmpty(winningRecordDOList)) {
            logger.info("查询的中奖记录为空!param:{}",
                    JacksonUtil.writeValueAsString(param));
            return Arrays.asList();
        }
        cacheWinningRecords(key, winningRecordDOList, WINNING_RECORDS_TIMEOUT);
        return convertToWinningRecordDTOList(winningRecordDOList);
    }

    private List<WinningRecordDTO> convertToWinningRecordDTOList(
            List<WinningRecordDO> winningRecordDOList) {
        if (CollectionUtils.isEmpty(winningRecordDOList)) {
            return Arrays.asList();
        }
        return winningRecordDOList.stream()
                .map(winningRecordDO -> {
                    WinningRecordDTO winningRecordDTO = new WinningRecordDTO();
                    winningRecordDTO.setWinnerId(winningRecordDO.getWinnerId());
                    winningRecordDTO.setWinnerName(winningRecordDO.getWinnerName());
                    winningRecordDTO.setPrizeName(winningRecordDO.getPrizeName());
                    winningRecordDTO.setPrizeTier(
                            ActivityPrizeTiersEnum.forName(winningRecordDO.getPrizeTier()));
                    winningRecordDTO.setWinningTime(winningRecordDO.getWinningTime());
                    return winningRecordDTO;
                }).collect(Collectors.toList());
    }
}

WinningRecordDTO

package com.example.lotterysystem.service.dto;

import com.example.lotterysystem.service.enums.ActivityPrizeTiersEnum;
import lombok.Data;

import java.util.Date;

@Data
public class WinningRecordDTO {
    // 中奖者id
    private Long winnerId;

    // 中奖者姓名
    private String winnerName;

    // 奖品名
    private String prizeName;

    // 等级
    private ActivityPrizeTiersEnum prizeTier;

    // 中奖时间
    private Date winningTime;

}

Dao 层

package com.example.lotterysystem.dao.mapper;

import com.example.lotterysystem.dao.dataobject.WinningRecordDO;
import org.apache.ibatis.annotations.*;

import java.util.List;

@Mapper
public interface WinningRecordMapper {
    @Select("<script>" +
            " select * from winning_record" +
            " where activity_id = #{activityId}" +
            " <if test=\"prizeId != null\">" +
            "   and prize_id = #{prizeId}" +
            " </if>" +
            " </script>")
    List<WinningRecordDO> selectByActivityIdOrPrizeId(@Param("activityId") Long activityId,
                                                      @Param("prizeId")  Long prizeId);
}

测试 DrawPrizeTest

    @Test
    void showWinningRecords() {
        ShowWinningRecordsParam param = new ShowWinningRecordsParam();
        param.setActivityId(2L);
        List<WinningRecordDTO> list = drawPrizeService.getRecords(param);
        for (WinningRecordDTO dto : list) {
            // 中奖者_奖品_等级
            System.out.println(dto.getWinnerName()
                    + "_" + dto.getPrizeName()
                    + "_" +dto.getPrizeTier().getMessage());
        }
    }

运行结果:

6. 抽奖页面前端设计

抽奖页面 draw.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <title>抽奖页面</title>
    <style type="text/css">
        * {
            margin: 0;
            padding: 0;
        }
        html, body {
            width: 100%;
            height: 100%;
        }
        body {
            text-align: center;
            background: url("./pic/bg.png") no-repeat;
            overflow: hidden;
            background-size: 100% 100%;
            font-weight: bold;
            color: #D40000;
        }
        #container {
            min-width: 1000px;
            min-height: 700px;
        }
        #title {
            font-size: 100px;
            margin-top: 80px;
        }
        #disc {
            font-size: 40px;
            margin: 10px 0;
        }
        #image {
            margin-top: 20px;
            max-height: 280px;
            border: 1px solid #E23540FF;
            border-radius: 20px;
        }
        #list {
            margin: 0 auto;
            max-width: 800px;
        }
        #list span {
            display: inline-block;
            width: 160px;
            font-size: 36px;
            margin-top: 8px;
        }
        .records-item {
            text-align: center;
            cursor: pointer;
            padding: 20px;
            transition: background-color 0.3s;
            font-size: 20px;
        }
        .opt-box{
            display: flex;
            align-items: center;
            justify-content: center;
            margin-top: 30px;
        }
        .opt-box .btn{
            width: 120px;
            height: 32px;
            display: flex;
            align-items: center;
            justify-content: center;
            border: 2px solid #D40000;
            margin-inline: 18px;
            cursor: pointer;
            border-radius: 4px;
            font-weight: normal;
            font-size: 15px;
        }
        .opt-box .btn:hover{
            background-color: #D40000;
            color: #fff;
        }
    </style>
</head>

<body>
<div id="container">
    <div id="title"></div>
    <div id="disc"></div>
    <img id="image" />
    <div id="list"></div>
    <div class="opt-box">
        <span class="btn pre-btn" onclick="previousStep()">查看上一奖项</span>
        <span class="btn next-btn" onclick="nextStep()">开始抽奖</span>
    </div>
</div>
<!--<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>-->
<script src="./js/jquery.min.js"></script>

<script>

    var params = new URLSearchParams(window.location.search);
    var activityId = params.get('activityId');
    var valid = params.get('valid');
    var userToken = localStorage.getItem("user_token");


    // 获取网页上通过id为disc、image和list的DOM元素的引用。
    var disc = document.getElementById('disc')
    var image = document.getElementById('image')
    var list = document.getElementById('list')

    // 奖品列表
    var steps = null
    // 抽到第几个了
    var step = 0
    // 人员列表
    var names = null
    // 正在做什么
    var state = ''

    // 等待DOM加载完成
    document.addEventListener('DOMContentLoaded', function() {
        var titleDiv = document.getElementById('title');
        var activityName = params.get('activityName');
        titleDiv.textContent = activityName;
    });

    function formatDate(dateString) {
        var date = new Date(dateString);
        var hour = ('0' + date.getHours()).slice(-2);
        var minute = ('0' + date.getMinutes()).slice(-2);
        var second = ('0' + date.getSeconds()).slice(-2);
        // 构建格式化的日期字符串
        return hour + ':' + minute + ':' + second;
    }

    // showPic函数用于显示图片和奖品信息。
    // 它将data.disc设置为disc元素的innerHTML,将data.image设置为image元素的src属性,然后显示图片并隐藏列表。
    function showPic(data) {
        disc.innerHTML = data.prizeTierName + ' ' + data.name + ' ' + data.prizeAmount + '份'
        image.src = data.imageUrl ? data.imageUrl : '/pic/defaultPrizeImg.png'
        image.style.display = 'inline'
        list.style.display = 'none'
        while (list.hasChildNodes()) {
            list.removeChild(list.firstChild)
        }
    }

    // 查询中奖记录
    function showRecords() {
        disc.innerHTML = "中奖名单"
        image.style.display = 'none'
        list.style.display = 'block'
        while (list.hasChildNodes()) {
            list.removeChild(list.firstChild)
        }
        $.ajax({
            url: '/winning-records/show',
            type: 'POST',
            dataType: 'json',
            contentType: 'application/json',
            data: JSON.stringify({activityId: activityId}),
            headers: {
                'user_token': userToken
            },
            success: function(result) {
                if (result.code != 200) {
                    alert("查询中奖记录失败!" + result.msg);
                } else {
                    var records = result.data
                    list.className = "records-item"
                    for (var i = 0; i < records.length; ++i) {
                        var item = document.createElement('div')
                        item.textContent = formatDate(records[i].winningTime) + ' '
                        + records[i].winnerName + ' '
                        + records[i].prizeName + ' '
                        + records[i].prizeTier + ' '
                        list.appendChild(item)
                    }

                    // 分享链接
                    // 检查是否已经有分享按钮,如果没有则创建
                    var optBox = document.querySelector('.opt-box');
                    var existingBtn = optBox.querySelector('.btn.copy-btn');
                    if (!existingBtn) {
                        var newBtn = document.createElement('span');
                        newBtn.className = 'btn copy-btn';
                        // 设置点击事件处理函数
                        newBtn.onclick = function() {
                            // 创建URL对象
                            var currentUrl = new URL(window.location);
                            // 创建URLSearchParams对象
                            var searchParams = currentUrl.searchParams;
                            // 更新参数
                            searchParams.delete('valid');
                            searchParams.append('valid', false);
                            // 防止分享再分享,拼接多个参数
                            searchParams.delete('hideButton');
                            searchParams.append('hideButton', true);
                            // 更新URL对象的搜索字符串
                            currentUrl.search = searchParams.toString();
                            // 获取当前页面的URL
                            var url = currentUrl;
                            // 执行复制操作
                            copyToClipboard(url);
                        };
                        // 设置span元素的文本内容
                        newBtn.textContent = '分享结果';
                        // 将新的span元素添加到.opt-box容器中
                        optBox.appendChild(newBtn);
                    }

                    // 是否隐藏按钮
                    var hideButton = params.get('hideButton');
                    if ((hideButton == null && valid === 'false')
                        || hideButton === 'true') {
                        // 隐藏所有元素
                        var buttons = document.querySelectorAll('.btn.pre-btn, .btn.next-btn');
                        buttons.forEach(function(button) {
                            button.style.display = 'none';
                        });
                    }
                }
            }
        });
    }

    // 复制当前地址
    function copyToClipboard(textToCopy) {
        // navigator clipboard 需要https等安全上下文
        if (navigator.clipboard && window.isSecureContext) {
            // navigator clipboard 向剪贴板写文本
            return navigator.clipboard.writeText(textToCopy)
                .then(() => {alert("链接已复制到剪贴板");});
        } else {
            // 创建text area
            let textArea = document.createElement("textarea");
            textArea.value = textToCopy;
            // 使text area不在viewport,同时设置不可见
            textArea.style.position = "absolute";
            textArea.style.opacity = 0;
            textArea.style.left = "-999999px";
            textArea.style.top = "-999999px";
            document.body.appendChild(textArea);
            textArea.focus();
            textArea.select();
            return new Promise((res, rej) => {
                // 执行复制命令并移除文本框
                document.execCommand('copy') ? res() : rej();
                textArea.remove();
                alert("链接已复制到剪贴板");
            });
        }
    }

    // showBlink函数用于显示闪烁的文字。
    // 它首先设置奖品信息,隐藏图片,显示列表,然后创建与data.count相等数量的span元素。
    function showBlink(data) {
        disc.innerHTML = data.prizeTierName + ' ' + data.name + ' ' + data.prizeAmount + '份'
        image.style.display = 'none'
        list.style.display = 'block'
        var spans = []
        for (var i = 0; i < data.prizeAmount; ++i) {
            var span = document.createElement('span')
            list.appendChild(span)
            spans.push(span)
        }

        // doBlink函数用于实现文字的闪烁效果,通过window.requestAnimationFrame来循环调用。
        function doBlink() {
            if (state == 'showBlink') {
                // Math.random() 生成一个 [0, 1) 范围内的伪随机数,
                // 所以表达式的结果将在 (-0.5, 0.5] 范围内,这会导致 names 数组的元素随机排序。
                names.sort(function(a, b) {
                    return 0.5 - Math.random()
                })
                // 只展示当前奖项个数的随机人名
                for (var i = 0; i < data.prizeAmount; ++i) {
                    spans[i].innerHTML = names[i].userName
                }
                window.requestAnimationFrame(doBlink)
            }
        }

      // 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。
      window.requestAnimationFrame(doBlink)
    }

    // 通过后端展示奖品维度中奖者,主要是防止页面刷新
    function showWinnerListWithPrize(data) {
        $.ajax({
            url: '/winning-records/show',
            type: 'POST',
            dataType: 'json',
            contentType: 'application/json',
            headers: {
                'user_token': userToken
            },
            data: JSON.stringify({activityId: activityId, prizeId: data.prizeId}),
            success: function(result) {
                if (result.code != 200) {
                    alert("查询中奖记录失败!" + result.msg);
                } else {
                    var luckRecords = result.data
                    // 从names中移除幸运儿
                    var luck = []
                    // 获得需要删除的用户id
                    var needRemoves = luckRecords.map(function(luckRecord) {
                        return luckRecord.winnerId;
                    });
                    console.log("needRemoves" + JSON.stringify(needRemoves))
                    console.log("names1" + JSON.stringify(names))
                    // 从names中移除包含在luckRecords中的name对应的元素
                    for (var i = 0; i < names.length; i++) {
                        if (needRemoves.includes(names[i].userId)) {
                            var tmp = names.splice(i, 1)  // 移除当前项,并更新索引i,因为后面的项会向前移动
                            luck = luck.concat(tmp)
                            console.log("tmp" + JSON.stringify(tmp))
                            i--  // 减1以确保索引i仍然指向当前的项
                        }
                    }
                    console.log("names2" + JSON.stringify(names))
                    console.log("luck" + JSON.stringify(luck))
                    // 只存放当前奖项的人员信息,下次抽奖会被覆盖
                    data.list = luck.map(x => x)
                    for (var i = 0; i < data.list.length; ++i) {
                        var span = document.createElement('span')
                        span.innerHTML = data.list[i].userName
                        list.appendChild(span)
                    }
                }
            }
        });
    }

    // showList函数用于显示静态的文字列表。它设置奖品信息,隐藏图片,显示列表,并将data.list中的数据添加到列表中。
    function showList(data) {
        disc.innerHTML = data.prizeTierName + ' ' + data.name + ' ' + data.prizeAmount + '份'
        image.style.display = 'none'
        list.style.display = 'block'
        while (list.hasChildNodes()) {
            list.removeChild(list.firstChild)
        }

        // data.list是前端保存的临时数据,若当前抽奖页刷新,则数据丢失
        // 因此判断一下是否为空,为空需要查询后端
        data.list = data.list || []
        if (data.list.length > 0) {
            for (var i = 0; i < data.list.length; ++i) {
                var span = document.createElement('span')
                span.innerHTML = data.list[i].userName
                list.appendChild(span)
            }
        } else {
            showWinnerListWithPrize(data)
        }
    }

    // 这是改变next-btn按钮文本的函数
    function changeNextButtonText(text) {
        var nextButton = document.querySelector('.btn.next-btn');
        nextButton.textContent = text;
    }

    function saveLuck() {
        var data = steps[step]

        var luckUserList = []
        for (var i = 0; i < data.list.length; ++i) {
            luckUserList.push({
                userId: data.list[i].userId,
                userName: data.list[i].userName
            })
        }

        var drawPrizesData = {
            activityId: activityId,
            prizeId: data.prizeId,
            winningTime: new Date(),
            winnerList: luckUserList
        };

        // 发送AJAX请求
        $.ajax({
            url: '/draw-prize',
            type: 'POST',
            dataType: 'json',
            contentType: 'application/json',
            headers: {
                'user_token': userToken
            },
            data: JSON.stringify(drawPrizesData),
            success: function(result) {
                if (result.code != 200) {
                    alert("抽奖失败!" + result.msg);
                }
            },
            error:function(err){
                console.log(err);
                if(err!=null && err.status==401){
                    alert("用户未登录, 即将跳转到登录页!");
                    // 已经被拦截器拦截了, 未登录
                    location.href ="/blogin.html";
                }
            }
        });
    }

    // nextStep函数用于根据当前状态显示下一个步骤的内容。
    // 它根据state的值和data.list的长度来决定是显示图片、闪烁文字还是静态文字列表,并更新step和state。
    function nextStep() {
        var data = steps[step]
        console.log("steps[" + step + "]:" + JSON.stringify(data))
        if (state == 'showPic') {
            if (data.valid) {
                // 还没抽:抽奖
                state = 'showBlink'
                showBlink(data)
                changeNextButtonText('点我确定')
            } else {
                // 抽过了:展示
                state = 'showList'
                showList(data)
                changeNextButtonText('已抽完,下一步')
            }
        } else if (state == 'showBlink') {
            // 从names中移除幸运儿,并将幸运儿存放起来
            var luck = names.splice(0, data.prizeAmount)
            // 只存放当前奖项的人员信息,下次抽奖会被覆盖
            data.list = luck.map(x => x)
            console.log("data.list:" + JSON.stringify(data.list))
            // 调用后端抽奖接口,保存中奖信息
            saveLuck()
            data.valid = false
            state = 'showList'
            showList(data)
            changeNextButtonText('已抽完,下一步')
        } else if (state == 'showList') {
            if (step < (steps.length - 1)) {
                ++step
                state = ''
                nextStep()
                changeNextButtonText('开始抽奖')
            } else {
                // 抽奖结束,展示全量中奖列表
                if (step == (steps.length - 1)) {
                    ++step
                }
                showRecords()
                changeNextButtonText('已全部抽完')
            }
        } else {
            state = 'showPic'
            showPic(data)
            changeNextButtonText('开始抽奖')
        }
    }

    // previousStep函数用于返回到上一个步骤。如果当前步骤不是第一个步骤,则减少step的值,然后调用nextStep函数。
    function previousStep() {
        if (step > 0) {
            --step
        }
        state = ''
        nextStep()
    }

    // 抽奖页面加载逻辑
    function drawPage() {
        // 判断活动是否有效:进行中
        if (valid == "true") {
            // 判断是用户还是管理员,只有管理员有权限抽奖
            var identity = localStorage.getItem("user_identity");
            if (identity == "ADMIN") {
                // 在页面加载完成后,调用reloadConf函数并传入nextStep作为回调函数,以初始化页面显示。
                reloadConf(nextStep)
            } else {
                alert("抽奖未结束,由于您不是管理员,无权限限参与抽奖。请耐心等待抽奖结束后查看中奖名单");
            }
        } else {
            showRecords()
        }
    }

    // reloadConf函数用于重新加载配置文件。
    // 如果提供了回调函数func,则在文件读取完成后调用它。
    function reloadConf(func) {
        // 发送AJAX请求
        $.ajax({
            url: '/activity-detail/find',
            type: 'GET',
            data: { activityId: activityId },
            dataType: 'json',
            headers: {
                'user_token': userToken
            },
            success: function(result) {
                if (result.code != 200) {
                    alert("获取活动详情失败!" + result.msg);
                } else {
                    names = result.data.users;
                    steps = result.data.prizes;
                    if (func) func();
                }
            },
            error:function(err){
                console.log(err);
                if(err!=null && err.status==401){
                    alert("用户未登录, 即将跳转到登录页!");
                    // 已经被拦截器拦截了, 未登录
                    location.href ="/blogin.html";
                }
            }
        });
    }

    // 抽奖页起始逻辑
    drawPage()
</script>
</body>
</html>

tip:在本地环境运行没问题后,将程序打包上传到云服务器即可

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值