【抽奖系统开发实战】Spring Boot 抽奖模块全解析:MQ 异步处理、缓存信息、状态扭转与异常回滚

文章目录

    • 一、抽奖设计
    • 二、RabbitMQ的配置与使用
    • 三、抽奖请求处理
    • 四、MQ异步抽奖逻辑执行
      • [4.1 消费 MQ 消息](#4.1 消费 MQ 消息)
      • [4.2 请求验证(核对抽奖信息有效性)](#4.2 请求验证(核对抽奖信息有效性))
      • [4.3 状态转换](#4.3 状态转换)
        • [> 活动/奖品/参与者状态转换设计](#> 活动/奖品/参与者状态转换设计)
        • [> 常规写法的问题](#> 常规写法的问题)
        • [> 问题与解决](#> 问题与解决)
        • [> 优化写法](#> 优化写法)
      • [4.4 结果记录](#4.4 结果记录)
      • [4.5 中奖者通知](#4.5 中奖者通知)
      • [4.6 事务一致性------异常回滚](#4.6 事务一致性——异常回滚)
      • [4.7 保证消息消费成功(加入死信队列)](#4.7 保证消息消费成功(加入死信队列))
    • 五、中奖名单
    • 六、抽奖页面前端设计
      • [6.1 需求回顾](#6.1 需求回顾)
      • [6.2 新增查询活动详情接口](#6.2 新增查询活动详情接口)
      • [6.3 抽奖页面前端交互逻辑](#6.3 抽奖页面前端交互逻辑)

一、抽奖设计

抽奖过程是系统的核心环节,设计目标为公平、透明、高效。整体流程分为以下阶段:

  • 参与者注册与奖品建立:管理员通过管理端新增用户(姓名、联系方式等),并提前创建奖品信息。
  • 抽奖活动设置:管理员创建活动,输入活动名称、描述,圈选参与人员,圈选奖品并设置等级与数量。活动发布后在管理端展示。
  • 抽奖请求处理:前端随机选择参与者,管理员发起抽奖请求,包含活动ID、奖品ID、中奖人员名单。请求经校验后发送至RabbitMQ消息队列,等待MQ消费者真正处理抽奖逻辑,抽奖请求处理接口不再完成任何事情,直接返回,实现异步处理。
  • 抽奖结果公布:前端展示中奖名单(通过随机闪烁动画确定)。
  • 抽奖逻辑执行:MQ消费者接收消息,系统执行抽奖核心逻辑:验证请求有效性、扭转活动/奖品/人员状态、记录中奖结果。
  • 中奖者通知:并发发送邮件和短信通知中奖者。
  • 异常处理:若抽奖过程发生异常,通过事务回滚保证数据一致性,并利用死信队列对失败消息进行重试或记录。

技术实现细节:

  • 异步处理:将抽奖逻辑放入消息队列异步执行,提高性能,保证幂等性,不影响前端流程。
  • 状态扭转处理:采用设计模式(策略模式 + 责任链模式),提升状态扭转的扩展性与维护性,适配多维度状态关联场景。
  • 并发处理:中奖者通知采用并发设计,解耦多系统通知逻辑,提升处理效率。
  • 事务处理:确保抽奖逻辑执行时的数据库原子性与事务一致性,异常时触发回滚。

二、RabbitMQ的配置与使用

  • 引入Spring Boot AMQP依赖,配置RabbitMQ连接信息(host、port、用户名、密码)。
  • 设置消息确认机制为自动(auto),开启重试(最多5次)。
bash 复制代码
## mq ##
spring.rabbitmq.host=ip
spring.rabbitmq.port=端口
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配置类,声明队列(DirectQueue)、交换机(DirectExchange)、路由键(DirectRouting),并绑定。
  • 配置消息转换器为Jackson2JsonMessageConverter,实现消息的JSON序列化。

三、抽奖请求处理

时序图

约定前后端交互接口

请求:

请求地址/draw-prize
请求方法POST
请求体

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

响应体

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

Controller层接口设计

  • DrawPrizeController提供/draw-prize接口,接收@ValidatedDrawPrizeParam参数。
  • 调用DrawPrizeService.drawPrize()方法,返回CommonResult.success(true)

请求参数封装:DrawPrizeParam

  • 包含活动ID、奖品ID、中奖时间、中奖用户列表(List<Winner>)。
  • 使用@NotNull等注解进行参数校验。

Service层接口设计

  • DrawPrizeService定义drawPrize(DrawPrizeParam param)方法。

接口实现

  • DrawPrizeServiceImpl中注入RabbitTemplate
  • DrawPrizeParam序列化为JSON,与消息ID、创建时间一起封装为Map,通过rabbitTemplate.convertAndSend()发送到指定交换机与路由键。

接口实现示例:

java 复制代码
	@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, 哪个队列, 消息体
        rabbitTemplate.convertAndSend(EXCHANGE_NAME,ROUTING,map);

        log.info("mq消息发送成功, map: {}",JacksonUtil.writeValueAsString(map));
    }

四、MQ异步抽奖逻辑执行

时序图

4.1 消费 MQ 消息

  • 消费者类MqReceiver使用@RabbitListener监听队列DirectQueue
  • 收到消息后,解析出DrawPrizeParam,按顺序执行以下步骤:
    1. 核对抽奖信息有效性。
    2. 扭转活动状态(奖品、人员、活动)。
    3. 保存中奖结果。
    4. 并发处理后续流程(通知)。
  • 异常时捕获并调用回滚方法,保证事务一致性。

消费者类示例:

java 复制代码
	@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 {

            // 校验抽奖请求是否有效
            // 如果有两个一样的抽奖请求,
            if(!drawPrizeService.checkDrawPrizeParam(param)){
                return;
            }

            // 状态扭转处理
            statusConvert(param);
            // 保存中奖者名单
            List<WinningRecordDO> winningRecordDOList =
                    drawPrizeService.saveWinnerRecords(param);

            // 通知中奖者
            syncExecute(winningRecordDOList);

        }catch (ServiceException e){
            logger.error("处理 MQ 消息异常: {} : {}",e.getCode(),e.getMessage(),e);
            // 如果异常, 需要保证事务一致性, 需要回滚; 并且抛出异常
            rollback(param);
            throw e;

        }catch (Exception e){
            logger.error("处理 MQ 消息异常: ",e);
            rollback(param);
            throw e;
        }
    }

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

  • 校验内容
    • 活动是否存在、奖品是否属于该活动。
    • 奖品剩余数量是否足够中奖人数。
    • 活动状态是否为"进行中"。
    • 奖品状态是否为"初始化"(未被抽取)。
  • Service层新增接口checkDrawPrizeValid(DrawPrizeParam param)
  • 实现 :通过ActivityPrizeMapperActivityMapper查询对应记录,比对状态与数量,不满足则抛出ServiceException

验证代码示例:

java 复制代码
	@Override
    public Boolean checkDrawPrizeParam(DrawPrizeParam param) {

        ActivityDO activityDO = activityMapper.selectById(param.getActivityId());

        ActivityPrizeDO activityPrizeDO = activityPrizeMapper.selectByAPId(
                param.getActivityId(), param.getPrizeId()
        );

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

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

            return false;
        }

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

            return false;
        }

        // 中奖者列表和奖品数量
        if(activityPrizeDO.getPrizeAmount() != param.getWinnerList().size()){
            // throw new ServiceException(ServiceErrorCodeConstants.WINNER_PRIZE_AMOUNT_ERROR);
            log.info("校验抽奖请求失败!失败原因: {}"
                    , ServiceErrorCodeConstants.WINNER_PRIZE_AMOUNT_ERROR.getMsg());

            return false;
        }
        return true;
    }

4.3 状态转换

> 活动/奖品/参与者状态转换设计
  • 涉及三个维度的状态:活动(进行中/已完成)、活动奖品(初始化/已被抽取)、活动人员(初始化/已被抽取)。
  • 抽奖成功后,对应奖品状态改为"已被抽取",中奖人员状态改为"已被抽取",若所有奖品抽完,活动状态改为"已完成"。
> 常规写法的问题
  1. 存在多个处理对象的顺序关系需要维护:奖品+活动状态扭转,活动需要依赖奖品状态改变而改变。可以看出请求依赖于多个决策点,因此处理顺序很重要,不易维护。
  2. 需要动态改变算法或行为:是否可以扭转状态的条件,若将来会发生改变,在这里不易维护。
  3. 系统的灵活性和可扩展性无法体现。
  4. 处理请求的复杂性不易维护。
> 问题与解决
  • 引入策略模式责任链模式 进行优化:
    • 策略模式 :定义AbstractActivityOperator抽象类,子类实现具体状态转换逻辑(PrizeOperatorUserOperatorActivityOperator)。
    • 责任链模式ActivityStatusManager管理所有操作符,按指定顺序(sequence)遍历,每个操作符判断是否需要处理(needConvert),若需要则执行转换(convertStatus),并从链中移除,避免重复处理。
> 优化写法
  • 状态转换调用 :在MqReceiver中,构造ActivityStatusConvertDTO(包含活动ID、奖品ID、中奖用户ID列表及目标状态),调用activityStatusManager.handleEvent()
  • ActivityStatusManagerImpl实现
    • 维护Map<String, AbstractActivityOperator>(Spring自动注入所有操作符)。
    • handleEvent方法中,按sequence分两次遍历操作符(先执行sequence=1的奖品和人员状态转换,再执行sequence=2的活动状态转换),每次调用processStatusConversion方法。
    • 转换成功则更新缓存(调用activityService.cacheActivity()刷新Redis)。
  • 操作符示例
    • PrizeOperator:sequence=1,判断奖品状态是否需要更新,调用activityPrizeMapper.updateStatus()
    • UserOperator:sequence=1,批量更新中奖用户状态。
    • ActivityOperator:sequence=2,判断所有奖品是否抽完,若是则更新活动状态。

状态扭转示例:

java 复制代码
	@Override
    @Transactional(rollbackFor = Exception.class)
    public void handlerEvent(ConvertActivityStatusDTO convertActivityStatusDTO) {

        // map<String, AbstractActivityOperator>
        if(CollectionUtils.isEmpty(operatorMap)){
            logger.warn("operatorMap 为空");
            return;
        }

        Map<String, AbstractActivityOperator> currMap = new HashMap<>(operatorMap);
        Boolean update = false;

        // 先处理: 人员, 奖品
        update = processConvertStatus(convertActivityStatusDTO, currMap, PROCESS_TYPE_USER_PRIZE);

        // 后处理: 活动
        update = processConvertStatus(convertActivityStatusDTO, currMap, PROCESS_TYPE_ACTIVITY) || update;

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


    }

	/**
     * 扭转状态
     *
     * @param convertActivityStatusDTO
     * @param currMap
     * @param sequence
     * @return
     */
    private Boolean processConvertStatus(ConvertActivityStatusDTO convertActivityStatusDTO
            , Map<String, AbstractActivityOperator> currMap
            , int sequence) {

        Boolean update = false;

        // 遍历 currMap
        Iterator<Map.Entry<String, AbstractActivityOperator>> iterator = currMap.entrySet().iterator();
        while (iterator.hasNext()){
            AbstractActivityOperator operator = iterator.next().getValue();
            // Operatior 是否需要转换
            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 删除当前Operatior
            iterator.remove();
            update = true;
        }
        // 返回
        return update;
    }

4.4 结果记录

时序图

  • Service层新增接口saveWinningRecords(DrawPrizeParam param)返回中奖记录列表。
  • 实现
    • 根据活动ID、奖品ID查询活动奖品、活动、奖品详情。
    • 根据中奖用户ID列表查询用户信息。
    • 组装WinningRecordDO列表(包含中奖者ID、姓名、邮箱、电话,活动名称,奖品名称、等级,中奖时间)。
    • 批量插入数据库。
    • 缓存处理
      • WINNING_RECORD_PREFIX + activityId + "_" + prizeId为key,存入本轮中奖记录,过期时间1天。
      • 若活动已完成(所有奖品抽完),则查询该活动全部中奖记录,以WINNING_RECORD_PREFIX + activityId为key存入Redis,过期时间2天。

保存结果示例:

java 复制代码
	@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);

        // 缓存中奖者记录
        // 缓存奖品的中奖信息(key:前缀 + activityId + prizeId, winningRecordDOList(奖品维度))
        cacheWinningRecords( param.getActivityId() + "_" + param.getPrizeId()
                , winningRecordDOList
                , WINNING_RECORDS_TIMEOUT);

        // 缓存活动维度的中奖记录(key:前缀 +  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;
    }

4.5 中奖者通知

  • 为提高效率,邮件和短信通知采用并发执行。
  • 邮件服务
    • 引入spring-boot-starter-mail,配置QQ邮箱SMTP。
    • MailUtil封装sendSampleMail方法,发送简单文本邮件。
  • 短信通知
    • 使用阿里云短信服务,申请通知模板(区别于验证码模板)。
    • SMSUtil中配置AccessKey,调用API发送短信。
    • 因为是个人没用企业认证,暂时无法使用此类短信通知。
  • 线程池配置
    • application.properties中配置核心线程数、最大线程数、队列容量等。
    • ExecutorConfig创建ThreadPoolTaskExecutor bean,用于并发执行通知任务。
  • 并发通知实现
    • MqReceiversyncExecute方法中,使用线程池分别执行pushWinningList(邮件)和sendMessage(短信)任务。
    • 两个任务遍历中奖记录,调用对应工具类发送通知,异常不会影响主线程。

邮件服务示例:

java 复制代码
	/**
     * 发邮件
     *
     * @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;
    }

4.6 事务一致性------异常回滚

  • 抽奖过程涉及数据库多表更新及Redis缓存写入,需保证原子性。
  • 回滚策略
    • MqReceiver.process()中捕获ServiceException和通用Exception,调用rollbackWinning方法。
    • rollbackWinning步骤:
      1. 判断状态是否已扭转(通过查询奖品状态是否变为COMPLETED)。
      2. 若已扭转,调用activityStatusManager.rollbackHandleEvent(),将状态回滚至初始(活动->RUNNING,奖品->INIT,人员->INIT),并刷新缓存。
      3. 判断中奖记录是否已入库(通过查询)。
      4. 若已入库,调用drawPrizeService.removeRecords()删除数据库记录及对应缓存。
  • 状态回滚接口ActivityStatusManager新增rollbackHandleEvent,遍历所有操作符,构造回滚目标状态(与正向相反),执行convertStatus,并更新缓存。

回滚实现代码:

java 复制代码
	/**
     * 恢复状态
     *
     * @param param
     */
    private void rollbackStatus(DrawPrizeParam param) {
        // 涉及状态的恢复工作
        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);
    }
	
	@Override
    public void rollbackHandlerEvent(ConvertActivityStatusDTO convertActivityStatusDTO) {
        for (AbstractActivityOperator operator : operatorMap.values()){
            operator.convert(convertActivityStatusDTO);
        }

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

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

  • 为防止消息处理失败后丢失,引入死信队列。
  • 配置
    • DirectRabbitConfig中声明死信队列DLX_QUEUE、死信交换机DLX_EXCHANGE及绑定。
    • 正常队列DirectQueue绑定死信交换机:通过QueueBuilder.durable().deadLetterExchange().deadLetterRoutingKey()设置。
  • 消费失败处理
    • MqReceiver.process()中,发生任何异常并完成回滚后,重新抛出异常,使消息消费失败,自动转入死信队列。
  • 死信消费者
    • DLxReceiver监听死信队列,收到消息后,可将其重新发送到正常队列,或记录到数据库待后续处理。
    • 提示:实际应用中可维护一张异常消息表,由定时任务重新投递,避免实时重试导致循环。

五、中奖名单

时序图

约定前后端交互接口

请求:

请求地址/winning-records/show
请求方法POST
请求体

json 复制代码
{
    "activityId": 23
}

响应体

json 复制代码
{
    "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层接口设计

  • DrawPrizeController提供/winning-records/show接口,接收ShowWinningRecordsParam,调用drawPrizeService.showWinningRecords(),返回CommonResult<List<WinningRecordResult>>

Service层接口设计

  • DrawPrizeService已有showWinningRecords方法(之前用于回滚判断),直接复用。

实现DrawPrizeServiceImpl):

  • 根据参数构造缓存key:若指定奖品ID则为WINNING_RECORD_PREFIX + activityId + "_" + prizeId,否则为WINNING_RECORD_PREFIX + activityId
  • 尝试从Redis获取,命中则反序列化返回。
  • 未命中则查询数据库(按活动ID或活动ID+奖品ID),将结果转换为DTO列表,并存入Redis(过期时间同前),最后返回。

接口实现示例:

java 复制代码
	@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 convetToWinningRecordDTOList(winningRecordDOList);
        }
        // 如果redis不存在, 查库

        winningRecordDOList = winningRecordMapper.selectByActivityIdOrPrizeId(param.getActivityId(),param.getPrizeId());

        // 整合存放记录到redis中
        if(CollectionUtils.isEmpty(winningRecordDOList)){
            log.info("查询的中奖记录为空, param: {}",JacksonUtil.writeValueAsString(param));
            return Arrays.asList();
        }

        cacheWinningRecords(key , winningRecordDOList , WINNING_RECORDS_TIMEOUT);

        // 构造返回
        return convetToWinningRecordDTOList(getWinningRecords(key));
    }

六、抽奖页面前端设计

6.1 需求回顾

  • 抽奖页面功能:
    • 仅进行中的活动且管理员可抽奖。
    • 每轮中奖人数等于当前奖品数量。
    • 每人只能中一次奖。
    • 多轮抽奖包含三个环节:展示奖品信息 → 人名闪动 → 展示中奖名单。
    • 支持查看上一奖项、下一步(已抽完)操作。
    • 刷新页面后,已抽奖项不能重新抽取,应直接展示中奖名单。
    • 活动已完成时,展示全部中奖名单,并支持"分享结果"按钮复制链接(打开后隐藏操作按钮)。

6.2 新增查询活动详情接口

时序图

约定前后端交互接口

请求:

请求地址/activity-detail/find?activityId=24
请求方法GET

响应体

json 复制代码
{
    "code": 200,
    "data": {
        "activityId": 24,
        "activityName": "测试抽奖活动",
        "description": "测试抽奖活动",
        "valid": true,
        "prizes": [
            {
                "prizeId": 18,
                "name": "手机",
                "description": "手机",
                "price": 5000.00,
                "imageUrl": "e606c8db-218a-40c2-8946-0d9f8570626d.jpg",
                "prizeAmount": 1,
                "prizeTierName": "一等奖",
                "valid": true
            }
        ],
        "users": [
            {
                "userId": 44,
                "userName": "郭靖",
                "valid": true
            }
        ]
    },
    "msg": ""
}

Controller层接口设计

  • ActivityController新增/activity-detail/find接口,调用activityService.getActivityDetail(activityId),返回FindActivityDetailResult

Service层接口设计

  • ActivityService新增getActivityDetail(Long activityId),返回ActivityDetailDTO

实现ActivityServiceImpl):

  • 首先从Redis获取缓存(key为ACTIVITY_PREFIX + activityId),若存在直接返回。
  • 否则查询数据库:通过ActivityMapperActivityPrizeMapperPrizeMapperActivityUserMapper组装完整详情,调用cacheActivity存入Redis后返回。

接口实现示例:

java 复制代码
	/**
     * 根据活动ID 从缓存中获取活动详细信息
     * @param activityId
     * @return
     */
    private ActivityDetailDTO getActivityFromCache(Long activityId){
        if(null == activityId){
            log.warn("获取缓存活动数据activityId为空");
            return null;
        }
        try {
            String str = redisUtil.get(Constants.ACTIVITY_PREFIS + activityId);
            if(!StringUtils.hasLength(str)){
                log.warn("获取缓存活动数据为空! key: {}",Constants.ACTIVITY_PREFIS);
                return null;
            }
            return JacksonUtil.readValue(str,ActivityDetailDTO.class);
        }catch (Exception e){
            log.error("从缓存中获取活动信息异常,key: {}",Constants.ACTIVITY_PREFIS);
            return null;
        }
    }

6.3 抽奖页面前端交互逻辑

抽奖页面示例展示:

抽奖结束中奖名单展示:

  • 页面初始化时从URL获取活动ID和活动是否有效标识。
  • 若活动有效且用户为管理员,调用reloadConf获取活动详情,初始化奖品列表(steps)和人员列表(names),并进入第一个奖品的状态(state='showPic')。
  • 点击"开始抽奖"进入showBlink状态,人名随机闪烁。
  • 点击"点我暂停"确定中奖名单,从人员列表中随机抽取当前奖品数量的人员,保存至data.list,并调用/draw-prize接口异步提交中奖结果,同时更新data.valid=false
  • 进入showList状态展示本轮中奖名单,按钮变为"已抽完,下一步"。
  • 点击"下一步"切换至下一个奖品或展示全部中奖记录。
  • 点击"查看上一奖项"可回退。
  • 若刷新页面,重新加载活动详情时,已抽奖品valid为false,直接展示中奖名单(通过showListByBackEnd从后端查询),防止重复抽取。
  • 活动已完成时,直接调用showRecords展示全量中奖名单,并生成"分享结果"按钮,点击复制带参数(隐藏按钮)的链接。
相关推荐
weisian1511 小时前
Java并发编程--12-读写锁与StampedLock:高并发读场景下的性能优化利器
java·开发语言·性能优化·读写锁·stampedlock
RDCJM1 小时前
Spring Boot + Vue 全栈开发实战指南
vue.js·spring boot·后端
卷福同学8 小时前
【养虾日记】Openclaw操作浏览器自动化发文
人工智能·后端·算法
江湖十年9 小时前
Go 并发控制:sync.Pool 详解
后端·面试·go
xdl25999 小时前
Spring Boot中集成MyBatis操作数据库详细教程
数据库·spring boot·mybatis
回到原点的码农9 小时前
Spring Data JDBC 详解
java·数据库·spring
gf13211119 小时前
python_查询并删除飞书多维表格中的记录
java·python·飞书
zb200641209 小时前
Spring Boot 实战:轻松实现文件上传与下载功能
java·数据库·spring boot
一勺菠萝丶10 小时前
Flowable + Spring 集成踩坑:流程结束监听器查询历史任务为空 & 获取不到审批意见
java·数据库·spring