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 负责存储和转发消息。
- 异步解耦: 在业务流程中,一些操作可能非常耗时,但并不需要即时返回结果.可以借助 MQ 把这些操作异步化,比如 用户注册后发送注册短信或邮件通知,可以作为异步任务处理,而不必等待这些操作完成后才告知用户注册成功.
- 流量削峰: 在访问量剧增的情况下,应用仍然需要继续发挥作用,但是是这样的突发流量并不常见.如果以能处理这类峰值为标准而投入资源,无疑是巨大的浪费.使用 MQ 能够使关键组件支撑突发访问压力,不会因为突发流量而崩溃.比如秒杀或者促销活动,可以使用 MQ 来控制流量,将请求排队,然后系统根据自己的处理能力逐步处理这些请求.
- 异步通信: 在很多时候应用不需要立即处理消息,MQ 提供了异步处理机制,允许应用把一些消息放入 MQ 中,但并不立即处理它,在需要的时候再慢慢处理.
- 消息分发: 当多个系统需要对同一数据做出响应时,可以使用 MQ 进行消息分发.比如支付成功后,支付系统可以向 MQ 发送消息,其他系统订阅该消息,而无需轮询数据库.
- 延迟通知: 在需要特定时间后发送通知的场景中,可以使用 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 遇到问题与解决
问题:
- 存在多个处理对象的顺序关系需要维护:奖品+活动状态扭转,活动需要依赖奖品状态改变而改变。可以看出请求依赖于多个决策点,因此处理顺序很重要,不易维护。
- 需要动态改变算法或行为:是否可以扭转状态的条件,若将来会发生改变,在这里不易维护。
- 系统的灵活性和可扩展性无法体现。
- 处理请求的复杂性不易维护。
因此需要对请求处理进行优化:如果请求的处理过程需要根据性能或其他因素进行优化,策略模式可以轻松替换或更新具体的处理策略,而责任链模式可以帮助管理这些策略的应用顺序。
解决方案:
- 策略模式(Strategy Pattern):定义AbstractActivityOperator策略类,和其策略实现类PrizeOperator、ActivityOperator和UserOperator。每个具体的操作类都实现了AbstractActivityOperator定义的接口,代表了不同的状态转换策略。
- 责任链模式(Chain of Responsibility Pattern):定义ActivityStatusManager接口类,在ActivityStatusManagerImpl实现中,通过遍历operatorMap中的所有操作符(策略),并按照一定的顺序执行,形成一个责任链,每个操作符判断是否是自己的责任,如果是,则处理请求。
责任链模式(Chain of Responsibility Pattern)是一种行为设计模式,它允许将一个请求沿着处理者对象组成的链进行传递。每个处理者对象都有责任去处理请求,或者将它传递给链中的下一个处理者。请求的传递一直进行,直到有一个处理者对象对请求进行了处理,或者直到链的末端仍未有处理者处理该请求。
以下是责任链模式在ActivityStatusManagerImpl类中的实现细节:
- 请求的创建:ActivityStatusConvertDTO statusConvertDTO是请求对象,包含了状态转换所需的所有信息。
- 处理者对象:AbstractActivityOperator及其子类PrizeOperator和ActivityOperator是处理者对象,它们实现了needConvert和convertStatus方法,用以判断是否需要处理请求以及执行处理。
- 责任链的维护:operatorMap是一个包含所有处理者对象的映射,它按照sequence()方法返回的顺序维护了责任链。
- 请求的传递:在processStatusConversion方法中,通过迭代器遍历operatorMap,对每个操作符实例调用needConvert方法来判断是否需要由当前操作符处理请求。
- 处理请求:如果needConvert返回true,则调用convertStatus方法来处理请求。
- 终止责任链:一旦请求被某个操作符处理,迭代器中的该操作符将被移除(it.remove()),这防止了请求被重复处理,并且终止了对该操作符的责任链。
- 异常处理:如果在责任链中的任何点上请求处理失败(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 中新增死信队列配置
- 新增死信队列、交换机,且绑定
- 让正常队列绑定死信交换机
注意:这里我们让正常队列绑定了新的交换机,对于正常队列来说,其配置已经修改,我们需要在 mq 的 web 管理端删除已存在的 DirectExchange 与 DirectQueue,然后再启动项目,要不然启动时 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:在本地环境运行没问题后,将程序打包上传到云服务器即可