欢迎光临
我们一直在努力

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

文章目录

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

一、抽奖设计

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

在这里插入图片描述

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

技术实现细节:

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

二、RabbitMQ的配置与使用

  • 引入Spring Boot AMQP依赖,配置RabbitMQ连接信息(host、port、用户名、密码)。
  • 设置消息确认机制为自动(auto),开启重试(最多5次)。

## 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 请求体:

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

响应体:

{
"code": 200,
"data": true,
"msg": ""
}

Controller层接口设计:

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

请求参数封装:DrawPrizeParam:

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

Service层接口设计:

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

接口实现:

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

接口实现示例:

@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,按顺序执行以下步骤:
  • 核对抽奖信息有效性。
  • 扭转活动状态(奖品、人员、活动)。
  • 保存中奖结果。
  • 并发处理后续流程(通知)。
  • 异常时捕获并调用回滚方法,保证事务一致性。

消费者类示例:

@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)。
  • 实现:通过ActivityPrizeMapper和ActivityMapper查询对应记录,比对状态与数量,不满足则抛出ServiceException。

验证代码示例:

@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 状态转换

> 活动/奖品/参与者状态转换设计
  • 涉及三个维度的状态:活动(进行中/已完成)、活动奖品(初始化/已被抽取)、活动人员(初始化/已被抽取)。
  • 抽奖成功后,对应奖品状态改为“已被抽取”,中奖人员状态改为“已被抽取”,若所有奖品抽完,活动状态改为“已完成”。

在这里插入图片描述

> 常规写法的问题
  • 存在多个处理对象的顺序关系需要维护:奖品+活动状态扭转,活动需要依赖奖品状态改变而改变。可以看出请求依赖于多个决策点,因此处理顺序很重要,不易维护。
  • 需要动态改变算法或行为:是否可以扭转状态的条件,若将来会发生改变,在这里不易维护。
  • 系统的灵活性和可扩展性无法体现。
  • 处理请求的复杂性不易维护。
  • > 问题与解决
    • 引入策略模式与责任链模式进行优化:
      • 策略模式:定义AbstractActivityOperator抽象类,子类实现具体状态转换逻辑(PrizeOperator、UserOperator、ActivityOperator)。
      • 责任链模式: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,判断所有奖品是否抽完,若是则更新活动状态。

    状态扭转示例:

    @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天。

    保存结果示例:

    @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,用于并发执行通知任务。
    • 并发通知实现:
      • 在MqReceiver的syncExecute方法中,使用线程池分别执行pushWinningList(邮件)和sendMessage(短信)任务。
      • 两个任务遍历中奖记录,调用对应工具类发送通知,异常不会影响主线程。

    邮件服务示例:

    /**
    * 发邮件
    *
    * @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步骤:
      • 判断状态是否已扭转(通过查询奖品状态是否变为COMPLETED)。
      • 若已扭转,调用activityStatusManager.rollbackHandleEvent(),将状态回滚至初始(活动->RUNNING,奖品->INIT,人员->INIT),并刷新缓存。
      • 判断中奖记录是否已入库(通过查询)。
      • 若已入库,调用drawPrizeService.removeRecords()删除数据库记录及对应缓存。
    • 状态回滚接口:ActivityStatusManager新增rollbackHandleEvent,遍历所有操作符,构造回滚目标状态(与正向相反),执行convertStatus,并更新缓存。

    回滚实现代码:

    /**
    * 恢复状态
    *
    * @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 请求体:

    {
    "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层接口设计:

    • 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(过期时间同前),最后返回。

    接口实现示例:

    @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

    响应体:

    {
    "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),若存在直接返回。
    • 否则查询数据库:通过ActivityMapper、ActivityPrizeMapper、PrizeMapper、ActivityUserMapper组装完整详情,调用cacheActivity存入Redis后返回。

    接口实现示例:

    /**
    * 根据活动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展示全量中奖名单,并生成“分享结果”按钮,点击复制带参数(隐藏按钮)的链接。
    赞(0)
    未经允许不得转载:171主机测评 » 【抽奖系统开发实战】Spring Boot 抽奖模块全解析:MQ 异步处理、缓存信息、状态扭转与异常回滚
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址