【抽奖系统开发实战】Spring Boot 抽奖模块全解析:MQ 异步处理、缓存信息、状态扭转与异常回滚
文章目录
一、抽奖设计
抽奖过程是系统的核心环节,设计目标为公平、透明、高效。整体流程分为以下阶段:
- 参与者注册与奖品建立:管理员通过管理端新增用户(姓名、联系方式等),并提前创建奖品信息。
- 抽奖活动设置:管理员创建活动,输入活动名称、描述,圈选参与人员,圈选奖品并设置等级与数量。活动发布后在管理端展示。
- 抽奖请求处理:前端随机选择参与者,管理员发起抽奖请求,包含活动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()发送到指定交换机与路由键。
接口实现示例:
@OverridepublicvoiddrawPrize(DrawPrizeParam param){Map<String,String> map =newHashMap<>(); 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,按顺序执行以下步骤:- 核对抽奖信息有效性。
- 扭转活动状态(奖品、人员、活动)。
- 保存中奖结果。
- 并发处理后续流程(通知)。
- 异常时捕获并调用回滚方法,保证事务一致性。
消费者类示例:
@RabbitHandlerpublicvoidprocess(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。
验证代码示例:
@OverridepublicBooleancheckDrawPrizeParam(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);returnfalse;}// 活动是否有效if(activityDO.getStatus().equals(ActivityStatusEnum.COMPLETED.name())){// throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_COMPLETED); log.info("校验抽奖请求失败!失败原因: {}",ServiceErrorCodeConstants.ACTIVITY_COMPLETED.getMsg());returnfalse;}// 奖品是否有效if(activityPrizeDO.getStatus().equals(ActivityPrizeStatusEnum.COMPLETED.name())){// throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_PRIZE_COMPLETED); log.info("校验抽奖请求失败!失败原因: {}",ServiceErrorCodeConstants.ACTIVITY_PRIZE_COMPLETED.getMsg());returnfalse;}// 中奖者列表和奖品数量if(activityPrizeDO.getPrizeAmount()!= param.getWinnerList().size()){// throw new ServiceException(ServiceErrorCodeConstants.WINNER_PRIZE_AMOUNT_ERROR); log.info("校验抽奖请求失败!失败原因: {}",ServiceErrorCodeConstants.WINNER_PRIZE_AMOUNT_ERROR.getMsg());returnfalse;}returntrue;}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)publicvoidhandlerEvent(ConvertActivityStatusDTO convertActivityStatusDTO){// map<String, AbstractActivityOperator>if(CollectionUtils.isEmpty(operatorMap)){ logger.warn("operatorMap 为空");return;}Map<String,AbstractActivityOperator> currMap =newHashMap<>(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 */privateBooleanprocessConvertStatus(ConvertActivityStatusDTO convertActivityStatusDTO ,Map<String,AbstractActivityOperator> currMap ,int sequence){Boolean update =false;// 遍历 currMapIterator<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());thrownewServiceException(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天。
- 以
保存结果示例:
@OverridepublicList<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 =newWinningRecordDO(); 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创建ThreadPoolTaskExecutorbean,用于并发执行通知任务。
- 在
- 并发通知实现:
- 在
MqReceiver的syncExecute方法中,使用线程池分别执行pushWinningList(邮件)和sendMessage(短信)任务。 - 两个任务遍历中奖记录,调用对应工具类发送通知,异常不会影响主线程。
- 在
邮件服务示例:
/** * 发邮件 * * @param to: 目标邮箱地址 * @param subject: 标题 * @param context: 正文 * @return */publicBooleansendSampleMail(Stringto,String subject,String context){SimpleMailMessage message =newSimpleMailMessage(); message.setFrom(from); message.setTo(to); message.setSubject(subject); message.setText(context);try{ mailSender.send(message);}catch(Exception e){ logger.error("向{}发送邮件失败!",to, e);returnfalse;}returntrue;}4.6 事务一致性——异常回滚
- 抽奖过程涉及数据库多表更新及Redis缓存写入,需保证原子性。
- 回滚策略:
- 在
MqReceiver.process()中捕获ServiceException和通用Exception,调用rollbackWinning方法。 rollbackWinning步骤:- 判断状态是否已扭转(通过查询奖品状态是否变为COMPLETED)。
- 若已扭转,调用
activityStatusManager.rollbackHandleEvent(),将状态回滚至初始(活动->RUNNING,奖品->INIT,人员->INIT),并刷新缓存。 - 判断中奖记录是否已入库(通过查询)。
- 若已入库,调用
drawPrizeService.removeRecords()删除数据库记录及对应缓存。
- 在
- 状态回滚接口:
ActivityStatusManager新增rollbackHandleEvent,遍历所有操作符,构造回滚目标状态(与正向相反),执行convertStatus,并更新缓存。
回滚实现代码:
/** * 恢复状态 * * @param param */privatevoidrollbackStatus(DrawPrizeParam param){// 涉及状态的恢复工作ConvertActivityStatusDTO convertActivityStatusDTO =newConvertActivityStatusDTO(); 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);}@OverridepublicvoidrollbackHandlerEvent(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(过期时间同前),最后返回。
接口实现示例:
@OverridepublicList<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)){returnconvetToWinningRecordDTOList(winningRecordDOList);}// 如果redis不存在, 查库 winningRecordDOList = winningRecordMapper.selectByActivityIdOrPrizeId(param.getActivityId(),param.getPrizeId());// 整合存放记录到redis中if(CollectionUtils.isEmpty(winningRecordDOList)){ log.info("查询的中奖记录为空, param: {}",JacksonUtil.writeValueAsString(param));returnArrays.asList();}cacheWinningRecords(key , winningRecordDOList ,WINNING_RECORDS_TIMEOUT);// 构造返回returnconvetToWinningRecordDTOList(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 */privateActivityDetailDTOgetActivityFromCache(Long activityId){if(null== activityId){ log.warn("获取缓存活动数据activityId为空");returnnull;}try{String str = redisUtil.get(Constants.ACTIVITY_PREFIS+ activityId);if(!StringUtils.hasLength(str)){ log.warn("获取缓存活动数据为空! key: {}",Constants.ACTIVITY_PREFIS);returnnull;}returnJacksonUtil.readValue(str,ActivityDetailDTO.class);}catch(Exception e){ log.error("从缓存中获取活动信息异常,key: {}",Constants.ACTIVITY_PREFIS);returnnull;}}6.3 抽奖页面前端交互逻辑
抽奖页面示例展示:
抽奖结束中奖名单展示:
- 页面初始化时从URL获取活动ID和活动是否有效标识。
- 若活动有效且用户为管理员,调用
reloadConf获取活动详情,初始化奖品列表(steps)和人员列表(names),并进入第一个奖品的状态(state='showPic')。 - 点击“开始抽奖”进入
showBlink状态,人名随机闪烁。 - 点击“点我暂停”确定中奖名单,从人员列表中随机抽取当前奖品数量的人员,保存至
data.list,并调用/draw-prize接口异步提交中奖结果,同时更新data.valid=false。 - 进入
showList状态展示本轮中奖名单,按钮变为“已抽完,下一步”。 - 点击“下一步”切换至下一个奖品或展示全部中奖记录。
- 点击“查看上一奖项”可回退。
- 若刷新页面,重新加载活动详情时,已抽奖品
valid为false,直接展示中奖名单(通过showListByBackEnd从后端查询),防止重复抽取。 - 活动已完成时,直接调用
showRecords展示全量中奖名单,并生成“分享结果”按钮,点击复制带参数(隐藏按钮)的链接。