抽奖系统(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-CSDN博客

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 pri
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值