
在当今的互联网产品中,抽奖活动已成为提升用户活跃度、增加用户粘性的重要手段。从电商平台的节日促销到社交应用的用户召回,从线下门店的消费激励到企业内部的福利发放,抽奖系统无处不在。
然而,看似简单的 "点击抽奖" 背后,隐藏着复杂的技术挑战:如何确保抽奖过程的公平性?如何应对高并发场景下的系统压力?如何防止恶意刷奖行为?如何灵活配置多样化的抽奖规则?
我将在本文中带您深入探讨抽奖系统的设计与实现,从需求分析到架构设计,从核心算法到代码实现,全方位解析一个专业抽奖系统的构建过程。无论您是需要快速搭建一个简单的抽奖功能,还是计划开发一个支持千万级用户的高并发抽奖平台,本文都将为您提供宝贵的参考。
在开始设计之前,我们首先需要明确抽奖系统的核心需求。一个完善的抽奖系统应该具备以下功能和特性:
不同的业务场景对抽奖系统有不同的要求,常见的场景包括:
基于上述需求分析,我们设计如下抽奖系统架构:

抽奖系统的核心流程如下:

基于 MySQL 8.0 设计如下数据库表结构:
CREATE TABLE `lottery_activity` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '活动ID',
`activity_name` varchar(128) NOT NULL COMMENT '活动名称',
`activity_desc` varchar(512) DEFAULT NULL COMMENT '活动描述',
`start_time` datetime NOT NULL COMMENT '开始时间',
`end_time` datetime NOT NULL COMMENT '结束时间',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-草稿,1-已发布,2-已结束',
`total_quota` int NOT NULL DEFAULT '0' COMMENT '总参与次数限制,0表示无限制',
`user_quota` int NOT NULL DEFAULT '0' COMMENT '单用户参与次数限制,0表示无限制',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_status_time` (`status`,`start_time`,`end_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖活动表';
CREATE TABLE `lottery_prize` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '奖品ID',
`prize_name` varchar(128) NOT NULL COMMENT '奖品名称',
`prize_type` tinyint NOT NULL COMMENT '奖品类型:1-实物,2-虚拟物品,3-优惠券,4-积分',
`prize_desc` varchar(512) DEFAULT NULL COMMENT '奖品描述',
`total_count` int NOT NULL COMMENT '总数量',
`surplus_count` int NOT NULL COMMENT '剩余数量',
`image_url` varchar(256) DEFAULT NULL COMMENT '奖品图片URL',
`value` decimal(10,2) DEFAULT NULL COMMENT '奖品价值',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0-下架,1-上架',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='奖品表';
CREATE TABLE `lottery_activity_prize` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`activity_id` bigint NOT NULL COMMENT '活动ID',
`prize_id` bigint NOT NULL COMMENT '奖品ID',
`prize_weight` int NOT NULL COMMENT '奖品权重,用于计算概率',
`max_count_per_user` int NOT NULL DEFAULT '0' COMMENT '单用户最大中奖次数,0表示无限制',
`sort` int NOT NULL DEFAULT '0' COMMENT '排序',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_prize` (`activity_id`,`prize_id`),
KEY `idx_activity` (`activity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动奖品关联表';
CREATE TABLE `lottery_strategy` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '策略ID',
`activity_id` bigint NOT NULL COMMENT '活动ID',
`strategy_type` tinyint NOT NULL COMMENT '策略类型:1-概率型,2-固定中奖型,3-阶梯概率型',
`strategy_config` json DEFAULT NULL COMMENT '策略配置,JSON格式',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity` (`activity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖规则表';
CREATE TABLE `lottery_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`activity_id` bigint NOT NULL COMMENT '活动ID',
`prize_id` bigint DEFAULT NULL COMMENT '奖品ID,NULL表示未中奖',
`draw_time` datetime NOT NULL COMMENT '抽奖时间',
`prize_name` varchar(128) DEFAULT NULL COMMENT '奖品名称',
`status` tinyint NOT NULL COMMENT '状态:1-已抽奖,2-已发奖,3-已取消',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_user_activity` (`user_id`,`activity_id`),
KEY `idx_activity_time` (`activity_id`,`draw_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户抽奖记录表';
CREATE TABLE `prize_delivery` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`record_id` bigint NOT NULL COMMENT '抽奖记录ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`prize_id` bigint NOT NULL COMMENT '奖品ID',
`prize_type` tinyint NOT NULL COMMENT '奖品类型',
`delivery_status` tinyint NOT NULL COMMENT '发放状态:0-待发放,1-已发放,2-发放失败',
`delivery_time` datetime DEFAULT NULL COMMENT '发放时间',
`logistics_info` varchar(512) DEFAULT NULL COMMENT '物流信息,JSON格式',
`virtual_code` varchar(128) DEFAULT NULL COMMENT '虚拟奖品码',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_record` (`record_id`),
KEY `idx_user_status` (`user_id`,`delivery_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='奖品发放表';
基于需求分析和架构设计,我们选择以下技术栈:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.jam</groupId>
<artifactId>lottery-system</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>lottery-system</name>
<description>高并发抽奖系统</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<springdoc.version>2.1.0</springdoc.version>
<lombok.version>1.18.30</lombok.version>
<fastjson2.version>2.0.41</fastjson2.version>
<guava.version>32.1.3-jre</guava.version>
<redisson.version>3.23.3</redisson.version>
<quartz.version>2.3.2</quartz.version>
</properties>
<dependencies>
<!-- Spring Boot 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- 数据库 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>${redisson.version}</version>
</dependency>
<!-- 消息队列 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- API文档 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<!-- 任务调度 -->
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>${quartz.version}</version>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
spring:
profiles:
active: dev
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/lottery_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000
connection-timeout: 20000
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
virtual-host: /
listener:
simple:
acknowledge-mode: manual
concurrency: 5
max-concurrency: 10
publisher-confirm-type: correlated
publisher-returns: true
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: com.jam.lottery.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: ASSIGN_ID
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operationsSorter: method
packages-to-scan: com.jam.lottery.controller
redisson:
single-server-config:
address: redis://localhost:6379
connection-pool-size: 16
connection-minimum-idle-size: 8
idle-connection-timeout: 10000
ping-timeout: 1000
connect-timeout: 10000
timeout: 3000
logging:
level:
root: info
com.jam.lottery: debug
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
file:
name: logs/lottery-system.log
server:
port: 8080
servlet:
context-path: /lottery
tomcat:
threads:
max: 200
connection-timeout: 30000
首先,我们定义核心实体类,对应数据库表结构:
package com.jam.lottery.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 抽奖活动实体类
*
* @author 果酱
*/
@Data
@TableName("lottery_activity")
public class LotteryActivity {
/**
* 活动ID
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* 活动名称
*/
private String activityName;
/**
* 活动描述
*/
private String activityDesc;
/**
* 开始时间
*/
private LocalDateTime startTime;
/**
* 结束时间
*/
private LocalDateTime endTime;
/**
* 状态:0-草稿,1-已发布,2-已结束
*/
private Integer status;
/**
* 总参与次数限制,0表示无限制
*/
private Integer totalQuota;
/**
* 单用户参与次数限制,0表示无限制
*/
private Integer userQuota;
/**
* 备注
*/
private String remark;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
package com.jam.lottery.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 奖品实体类
*
* @author 果酱
*/
@Data
@TableName("lottery_prize")
public class LotteryPrize {
/**
* 奖品ID
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* 奖品名称
*/
private String prizeName;
/**
* 奖品类型:1-实物,2-虚拟物品,3-优惠券,4-积分
*/
private Integer prizeType;
/**
* 奖品描述
*/
private String prizeDesc;
/**
* 总数量
*/
private Integer totalCount;
/**
* 剩余数量
*/
private Integer surplusCount;
/**
* 奖品图片URL
*/
private String imageUrl;
/**
* 奖品价值
*/
private BigDecimal value;
/**
* 状态:0-下架,1-上架
*/
private Integer status;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
package com.jam.lottery.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 活动奖品关联实体类
*
* @author 果酱
*/
@Data
@TableName("lottery_activity_prize")
public class LotteryActivityPrize {
/**
* ID
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* 活动ID
*/
private Long activityId;
/**
* 奖品ID
*/
private Long prizeId;
/**
* 奖品权重,用于计算概率
*/
private Integer prizeWeight;
/**
* 单用户最大中奖次数,0表示无限制
*/
private Integer maxCountPerUser;
/**
* 排序
*/
private Integer sort;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
package com.jam.lottery.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 抽奖规则实体类
*
* @author 果酱
*/
@Data
@TableName("lottery_strategy")
public class LotteryStrategy {
/**
* 策略ID
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* 活动ID
*/
private Long activityId;
/**
* 策略类型:1-概率型,2-固定中奖型,3-阶梯概率型
*/
private Integer strategyType;
/**
* 策略配置,JSON格式
*/
private String strategyConfig;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
package com.jam.lottery.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户抽奖记录实体类
*
* @author 果酱
*/
@Data
@TableName("lottery_record")
public class LotteryRecord {
/**
* 记录ID
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 活动ID
*/
private Long activityId;
/**
* 奖品ID,NULL表示未中奖
*/
private Long prizeId;
/**
* 抽奖时间
*/
private LocalDateTime drawTime;
/**
* 奖品名称
*/
private String prizeName;
/**
* 状态:1-已抽奖,2-已发奖,3-已取消
*/
private Integer status;
/**
* 备注
*/
private String remark;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
package com.jam.lottery.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 奖品发放实体类
*
* @author 果酱
*/
@Data
@TableName("prize_delivery")
public class PrizeDelivery {
/**
* ID
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* 抽奖记录ID
*/
private Long recordId;
/**
* 用户ID
*/
private Long userId;
/**
* 奖品ID
*/
private Long prizeId;
/**
* 奖品类型
*/
private Integer prizeType;
/**
* 发放状态:0-待发放,1-已发放,2-发放失败
*/
private Integer deliveryStatus;
/**
* 发放时间
*/
private LocalDateTime deliveryTime;
/**
* 物流信息,JSON格式
*/
private String logisticsInfo;
/**
* 虚拟奖品码
*/
private String virtualCode;
/**
* 备注
*/
private String remark;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
使用 MyBatis-Plus 的 BaseMapper 简化数据访问层代码:
package com.jam.lottery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.lottery.entity.LotteryActivity;
import org.apache.ibatis.annotations.Mapper;
/**
* 抽奖活动Mapper
*
* @author 果酱
*/
@Mapper
public interface LotteryActivityMapper extends BaseMapper<LotteryActivity> {
}
其他实体的 Mapper 类实现类似,这里省略。
服务层是抽奖系统的核心,包含了业务逻辑的实现。我们采用分层设计,将服务分为接口和实现:
package com.jam.lottery.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.lottery.entity.LotteryRecord;
import com.jam.lottery.vo.DrawResultVO;
/**
* 抽奖服务接口
*
* @author 果酱
*/
public interface LotteryService extends IService<LotteryRecord> {
/**
* 用户参与抽奖
*
* @param userId 用户ID
* @param activityId 活动ID
* @return 抽奖结果
*/
DrawResultVO draw(Long userId, Long activityId);
/**
* 查询用户在某个活动中的抽奖次数
*
* @param userId 用户ID
* @param activityId 活动ID
* @return 抽奖次数
*/
int countUserDraws(Long userId, Long activityId);
/**
* 查询用户在某个活动中某个奖品的中奖次数
*
* @param userId 用户ID
* @param activityId 活动ID
* @param prizeId 奖品ID
* @return 中奖次数
*/
int countUserWins(Long userId, Long activityId, Long prizeId);
}
package com.jam.lottery.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.lottery.entity.LotteryActivity;
import com.jam.lottery.entity.LotteryActivityPrize;
import com.jam.lottery.entity.LotteryRecord;
import com.jam.lottery.entity.LotteryStrategy;
import com.jam.lottery.enums.ActivityStatusEnum;
import com.jam.lottery.enums.ErrorCodeEnum;
import com.jam.lottery.enums.PrizeTypeEnum;
import com.jam.lottery.enums.RecordStatusEnum;
import com.jam.lottery.exception.BusinessException;
import com.jam.lottery.mapper.LotteryRecordMapper;
import com.jam.lottery.mq.producer.LotteryMessageProducer;
import com.jam.lottery.service.*;
import com.jam.lottery.strategy.DrawStrategy;
import com.jam.lottery.strategy.StrategyFactory;
import com.jam.lottery.vo.DrawResultVO;
import com.jam.lottery.vo.PrizeVO;
import com.jam.lottery.vo.UserDrawVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 抽奖服务实现类
*
* @author 果酱
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LotteryServiceImpl extends ServiceImpl<LotteryRecordMapper, LotteryRecord> implements LotteryService {
private final LotteryActivityService activityService;
private final LotteryActivityPrizeService activityPrizeService;
private final LotteryStrategyService strategyService;
private final PrizeInventoryService inventoryService;
private final LotteryMessageProducer messageProducer;
private final RedissonClient redissonClient;
private final StrategyFactory strategyFactory;
/**
* 用户参与抽奖
*
* @param userId 用户ID
* @param activityId 活动ID
* @return 抽奖结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public DrawResultVO draw(Long userId, Long activityId) {
// 参数校验
validateDrawParams(userId, activityId);
// 获取分布式锁,防止重复抽奖
String lockKey = "lottery:draw:" + activityId + ":" + userId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,最多等待3秒,10秒后自动释放
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
log.warn("用户抽奖获取锁失败,userId: {}, activityId: {}", userId, activityId);
throw new BusinessException(ErrorCodeEnum.DRAW_FREQUENTLY);
}
// 检查活动状态和参与条件
checkDrawConditions(userId, activityId);
// 获取抽奖策略
LotteryStrategy strategy = strategyService.getByActivityId(activityId);
if (ObjectUtils.isEmpty(strategy)) {
log.error("活动未配置抽奖策略,activityId: {}", activityId);
throw new BusinessException(ErrorCodeEnum.STRATEGY_NOT_FOUND);
}
// 执行抽奖算法
DrawStrategy drawStrategy = strategyFactory.getStrategy(strategy.getStrategyType());
Long prizeId = drawStrategy.draw(userId, activityId, strategy);
// 构建抽奖结果
DrawResultVO resultVO = new DrawResultVO();
resultVO.setActivityId(activityId);
resultVO.setUserId(userId);
resultVO.setDrawTime(LocalDateTime.now());
// 记录抽奖结果
LotteryRecord record = createLotteryRecord(userId, activityId, prizeId);
resultVO.setRecordId(record.getId());
if (ObjectUtils.isEmpty(prizeId)) {
// 未中奖
resultVO.setWin(false);
log.info("用户未中奖,userId: {}, activityId: {}", userId, activityId);
return resultVO;
}
// 中奖处理
resultVO.setWin(true);
// 检查用户该奖品的中奖次数限制
LotteryActivityPrize activityPrize = activityPrizeService.getByActivityAndPrize(activityId, prizeId);
if (!ObjectUtils.isEmpty(activityPrize) && activityPrize.getMaxCountPerUser() > 0) {
int userWinCount = countUserWins(userId, activityId, prizeId);
if (userWinCount >= activityPrize.getMaxCountPerUser()) {
log.warn("用户该奖品中奖次数已达上限,userId: {}, activityId: {}, prizeId: {}",
userId, activityId, prizeId);
resultVO.setWin(false);
record.setPrizeId(null);
record.setPrizeName(null);
baseMapper.updateById(record);
return resultVO;
}
}
// 扣减库存
boolean deductSuccess = inventoryService.deductInventory(activityId, prizeId);
if (!deductSuccess) {
log.warn("奖品库存不足,扣减失败,activityId: {}, prizeId: {}", activityId, prizeId);
resultVO.setWin(false);
record.setPrizeId(null);
record.setPrizeName(null);
baseMapper.updateById(record);
return resultVO;
}
// 查询奖品信息
PrizeVO prizeVO = activityPrizeService.getPrizeInfo(activityId, prizeId);
resultVO.setPrize(prizeVO);
// 发送中奖消息到MQ,异步处理奖品发放
messageProducer.sendPrizeMessage(buildUserDrawVO(record, prizeVO));
log.info("用户中奖,userId: {}, activityId: {}, prizeId: {}", userId, activityId, prizeId);
return resultVO;
} catch (InterruptedException e) {
log.error("用户抽奖获取锁中断,userId: {}, activityId: {}", userId, activityId, e);
Thread.currentThread().interrupt();
throw new BusinessException(ErrorCodeEnum.SYSTEM_ERROR);
} finally {
// 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 构建用户抽奖信息VO
*/
private UserDrawVO buildUserDrawVO(LotteryRecord record, PrizeVO prizeVO) {
UserDrawVO userDrawVO = new UserDrawVO();
userDrawVO.setRecordId(record.getId());
userDrawVO.setUserId(record.getUserId());
userDrawVO.setActivityId(record.getActivityId());
userDrawVO.setPrizeId(record.getPrizeId());
userDrawVO.setPrizeName(record.getPrizeName());
userDrawVO.setPrizeType(prizeVO.getPrizeType());
userDrawVO.setDrawTime(record.getDrawTime());
return userDrawVO;
}
/**
* 创建抽奖记录
*/
private LotteryRecord createLotteryRecord(Long userId, Long activityId, Long prizeId) {
LotteryRecord record = new LotteryRecord();
record.setUserId(userId);
record.setActivityId(activityId);
record.setDrawTime(LocalDateTime.now());
record.setStatus(RecordStatusEnum.DRAWN.getCode());
if (!ObjectUtils.isEmpty(prizeId)) {
record.setPrizeId(prizeId);
PrizeVO prizeVO = activityPrizeService.getPrizeInfo(activityId, prizeId);
if (!ObjectUtils.isEmpty(prizeVO)) {
record.setPrizeName(prizeVO.getPrizeName());
}
}
baseMapper.insert(record);
return record;
}
/**
* 校验抽奖参数
*/
private void validateDrawParams(Long userId, Long activityId) {
if (ObjectUtils.isEmpty(userId)) {
throw new BusinessException(ErrorCodeEnum.USER_ID_NULL);
}
if (ObjectUtils.isEmpty(activityId)) {
throw new BusinessException(ErrorCodeEnum.ACTIVITY_ID_NULL);
}
}
/**
* 检查抽奖条件
*/
private void checkDrawConditions(Long userId, Long activityId) {
// 查询活动信息
LotteryActivity activity = activityService.getById(activityId);
if (ObjectUtils.isEmpty(activity)) {
log.error("活动不存在,activityId: {}", activityId);
throw new BusinessException(ErrorCodeEnum.ACTIVITY_NOT_FOUND);
}
// 检查活动状态
if (!ActivityStatusEnum.PUBLISHED.getCode().equals(activity.getStatus())) {
log.error("活动状态异常,activityId: {}, status: {}", activityId, activity.getStatus());
throw new BusinessException(ErrorCodeEnum.ACTIVITY_STATUS_INVALID);
}
// 检查活动时间
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(activity.getStartTime())) {
log.error("活动未开始,activityId: {}, startTime: {}", activityId, activity.getStartTime());
throw new BusinessException(ErrorCodeEnum.ACTIVITY_NOT_STARTED);
}
if (now.isAfter(activity.getEndTime())) {
log.error("活动已结束,activityId: {}, endTime: {}", activityId, activity.getEndTime());
throw new BusinessException(ErrorCodeEnum.ACTIVITY_HAS_ENDED);
}
// 检查总参与次数限制
if (activity.getTotalQuota() > 0) {
int totalDrawCount = countActivityDraws(activityId);
if (totalDrawCount >= activity.getTotalQuota()) {
log.error("活动总参与次数已达上限,activityId: {}, totalQuota: {}", activityId, activity.getTotalQuota());
throw new BusinessException(ErrorCodeEnum.ACTIVITY_QUOTA_EXHAUSTED);
}
}
// 检查用户参与次数限制
if (activity.getUserQuota() > 0) {
int userDrawCount = countUserDraws(userId, activityId);
if (userDrawCount >= activity.getUserQuota()) {
log.error("用户参与次数已达上限,userId: {}, activityId: {}, userQuota: {}",
userId, activityId, activity.getUserQuota());
throw new BusinessException(ErrorCodeEnum.USER_QUOTA_EXHAUSTED);
}
}
// 检查活动是否有可用奖品
List<LotteryActivityPrize> activityPrizes = activityPrizeService.listByActivityId(activityId);
if (ObjectUtils.isEmpty(activityPrizes)) {
log.error("活动未配置奖品,activityId: {}", activityId);
throw new BusinessException(ErrorCodeEnum.ACTIVITY_NO_PRIZES);
}
}
/**
* 查询活动总抽奖次数
*/
private int countActivityDraws(Long activityId) {
LambdaQueryWrapper<LotteryRecord> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(LotteryRecord::getActivityId, activityId);
return Math.toIntExact(baseMapper.selectCount(queryWrapper));
}
/**
* 查询用户在某个活动中的抽奖次数
*/
@Override
public int countUserDraws(Long userId, Long activityId) {
LambdaQueryWrapper<LotteryRecord> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(LotteryRecord::getUserId, userId)
.eq(LotteryRecord::getActivityId, activityId);
return Math.toIntExact(baseMapper.selectCount(queryWrapper));
}
/**
* 查询用户在某个活动中某个奖品的中奖次数
*/
@Override
public int countUserWins(Long userId, Long activityId, Long prizeId) {
LambdaQueryWrapper<LotteryRecord> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(LotteryRecord::getUserId, userId)
.eq(LotteryRecord::getActivityId, activityId)
.eq(LotteryRecord::getPrizeId, prizeId)
.ne(LotteryRecord::getStatus, RecordStatusEnum.CANCELLED.getCode());
return Math.toIntExact(baseMapper.selectCount(queryWrapper));
}
}
抽奖策略是抽奖系统的核心算法,我们采用策略模式设计,支持多种抽奖算法:
package com.jam.lottery.strategy;
/**
* 抽奖策略接口
*
* @author 果酱
*/
public interface DrawStrategy {
/**
* 执行抽奖
*
* @param userId 用户ID
* @param activityId 活动ID
* @param strategy 策略配置
* @return 中奖奖品ID,null表示未中奖
*/
Long draw(Long userId, Long activityId, Object strategy);
}
package com.jam.lottery.strategy.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.TypeReference;
import com.google.common.collect.Lists;
import com.jam.lottery.entity.LotteryActivityPrize;
import com.jam.lottery.entity.LotteryStrategy;
import com.jam.lottery.service.LotteryActivityPrizeService;
import com.jam.lottery.strategy.DrawStrategy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import java.util.List;
import java.util.Map;
import java.util.Random;
/**
* 概率型抽奖策略
* 基于奖品权重计算中奖概率
*
* @author 果酱
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ProbabilityDrawStrategy implements DrawStrategy {
private final LotteryActivityPrizeService activityPrizeService;
private final Random random = new Random();
/**
* 执行抽奖
*
* @param userId 用户ID
* @param activityId 活动ID
* @param strategy 策略配置
* @return 中奖奖品ID,null表示未中奖
*/
@Override
public Long draw(Long userId, Long activityId, Object strategy) {
if (!(strategy instanceof LotteryStrategy)) {
log.error("策略类型错误,activityId: {}", activityId);
return null;
}
LotteryStrategy lotteryStrategy = (LotteryStrategy) strategy;
// 解析策略配置
Map<String, Integer> strategyConfig = parseStrategyConfig(lotteryStrategy.getStrategyConfig());
// 获取活动奖品列表
List<LotteryActivityPrize> activityPrizes = activityPrizeService.listByActivityId(activityId);
if (CollectionUtils.isEmpty(activityPrizes)) {
log.warn("活动未配置奖品,activityId: {}", activityId);
return null;
}
// 计算总权重
int totalWeight = calculateTotalWeight(activityPrizes, strategyConfig);
if (totalWeight <= 0) {
log.warn("奖品总权重为0,无法抽奖,activityId: {}", activityId);
return null;
}
// 生成随机数
int randomNum = random.nextInt(totalWeight);
// 根据权重计算中奖奖品
Long prizeId = determinePrize(activityPrizes, strategyConfig, randomNum);
log.debug("概率型抽奖结果,userId: {}, activityId: {}, prizeId: {}", userId, activityId, prizeId);
return prizeId;
}
/**
* 解析策略配置
*/
private Map<String, Integer> parseStrategyConfig(String configJson) {
if (ObjectUtils.isEmpty(configJson)) {
return Maps.newHashMap();
}
try {
return JSON.parseObject(configJson, new TypeReference<Map<String, Integer>>() {});
} catch (Exception e) {
log.error("解析策略配置失败,configJson: {}", configJson, e);
return Maps.newHashMap();
}
}
/**
* 计算总权重
*/
private int calculateTotalWeight(List<LotteryActivityPrize> activityPrizes, Map<String, Integer> strategyConfig) {
int totalWeight = 0;
for (LotteryActivityPrize prize : activityPrizes) {
// 从策略配置中获取权重,如果没有则使用数据库配置的权重
Integer weight = strategyConfig.getOrDefault(prize.getPrizeId().toString(), prize.getPrizeWeight());
totalWeight += weight;
}
return totalWeight;
}
/**
* 根据权重计算中奖奖品
*/
private Long determinePrize(List<LotteryActivityPrize> activityPrizes,
Map<String, Integer> strategyConfig, int randomNum) {
int currentWeight = 0;
for (LotteryActivityPrize prize : activityPrizes) {
Integer weight = strategyConfig.getOrDefault(prize.getPrizeId().toString(), prize.getPrizeWeight());
currentWeight += weight;
if (randomNum < currentWeight) {
return prize.getPrizeId();
}
}
return null;
}
}
package com.jam.lottery.strategy;
import com.jam.lottery.enums.StrategyTypeEnum;
import com.jam.lottery.strategy.impl.FixedDrawStrategy;
import com.jam.lottery.strategy.impl.ProbabilityDrawStrategy;
import com.jam.lottery.strategy.impl.StageProbabilityDrawStrategy;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 抽奖策略工厂
* 用于获取不同类型的抽奖策略
*
* @author 果酱
*/
@Component
@RequiredArgsConstructor
public class StrategyFactory {
private final ProbabilityDrawStrategy probabilityDrawStrategy;
private final FixedDrawStrategy fixedDrawStrategy;
private final StageProbabilityDrawStrategy stageProbabilityDrawStrategy;
/**
* 根据策略类型获取抽奖策略
*
* @param strategyType 策略类型
* @return 抽奖策略
*/
public DrawStrategy getStrategy(Integer strategyType) {
if (StrategyTypeEnum.PROBABILITY.getType().equals(strategyType)) {
return probabilityDrawStrategy;
} else if (StrategyTypeEnum.FIXED.getType().equals(strategyType)) {
return fixedDrawStrategy;
} else if (StrategyTypeEnum.STAGE_PROBABILITY.getType().equals(strategyType)) {
return stageProbabilityDrawStrategy;
} else {
throw new IllegalArgumentException("不支持的抽奖策略类型: " + strategyType);
}
}
}
库存管理是抽奖系统的关键环节,需要保证库存的准确性和并发安全性:
package com.jam.lottery.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.lottery.entity.LotteryPrize;
/**
* 奖品库存服务接口
*
* @author 果酱
*/
public interface PrizeInventoryService extends IService<LotteryPrize> {
/**
* 扣减奖品库存
*
* @param activityId 活动ID
* @param prizeId 奖品ID
* @return 扣减是否成功
*/
boolean deductInventory(Long activityId, Long prizeId);
/**
* 增加奖品库存
*
* @param prizeId 奖品ID
* @param count 增加数量
* @return 增加是否成功
*/
boolean increaseInventory(Long prizeId, int count);
/**
* 获取奖品库存数量
*
* @param prizeId 奖品ID
* @return 库存数量
*/
int getInventoryCount(Long prizeId);
/**
* 预热活动奖品库存到缓存
*
* @param activityId 活动ID
*/
void preloadInventoryToCache(Long activityId);
}
package com.jam.lottery.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.lottery.entity.LotteryActivityPrize;
import com.jam.lottery.entity.LotteryPrize;
import com.jam.lottery.mapper.LotteryPrizeMapper;
import com.jam.lottery.service.LotteryActivityPrizeService;
import com.jam.lottery.service.PrizeInventoryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 奖品库存服务实现类
*
* @author 果酱
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PrizeInventoryServiceImpl extends ServiceImpl<LotteryPrizeMapper, LotteryPrize> implements PrizeInventoryService {
private final RedisTemplate<String, Object> redisTemplate;
private final RedissonClient redissonClient;
private final LotteryActivityPrizeService activityPrizeService;
/**
* 库存缓存前缀
*/
private static final String INVENTORY_CACHE_PREFIX = "lottery:inventory:";
/**
* 扣减奖品库存
*
* @param activityId 活动ID
* @param prizeId 奖品ID
* @return 扣减是否成功
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deductInventory(Long activityId, Long prizeId) {
if (ObjectUtils.isEmpty(prizeId)) {
log.error("奖品ID为空,无法扣减库存");
return false;
}
// 先尝试从缓存扣减
String cacheKey = INVENTORY_CACHE_PREFIX + prizeId;
Long remaining = redisTemplate.opsForValue().decrement(cacheKey);
// 如果缓存扣减成功且剩余库存 >= 0,直接返回成功
if (remaining != null && remaining >= 0) {
log.debug("缓存扣减库存成功,prizeId: {}, remaining: {}", prizeId, remaining);
// 异步更新数据库库存
asyncUpdateDbInventory(prizeId);
return true;
}
// 缓存扣减失败或库存不足,需要从数据库扣减
log.warn("缓存扣减库存失败或库存不足,尝试从数据库扣减,prizeId: {}", prizeId);
// 获取分布式锁,防止并发问题
String lockKey = "lottery:inventory:lock:" + prizeId;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
log.error("获取库存锁失败,prizeId: {}", prizeId);
return false;
}
// 查询数据库库存
LotteryPrize prize = getById(prizeId);
if (ObjectUtils.isEmpty(prize) || prize.getSurplusCount() <= 0) {
log.error("奖品库存不足,prizeId: {}", prizeId);
// 更新缓存为0
redisTemplate.opsForValue().set(cacheKey, 0);
return false;
}
// 扣减数据库库存
int newCount = prize.getSurplusCount() - 1;
prize.setSurplusCount(newCount);
boolean updateSuccess = updateById(prize);
if (updateSuccess) {
log.debug("数据库扣减库存成功,prizeId: {}, newCount: {}", prizeId, newCount);
// 更新缓存
redisTemplate.opsForValue().set(cacheKey, newCount);
return true;
} else {
log.error("数据库扣减库存失败,prizeId: {}", prizeId);
return false;
}
} catch (InterruptedException e) {
log.error("获取库存锁中断,prizeId: {}", prizeId, e);
Thread.currentThread().interrupt();
return false;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 异步更新数据库库存
*/
private void asyncUpdateDbInventory(Long prizeId) {
// 实际项目中可以使用线程池或消息队列异步更新数据库
// 这里简化处理,直接更新
baseMapper.decrementSurplusCount(prizeId);
log.debug("异步更新数据库库存,prizeId: {}", prizeId);
}
/**
* 增加奖品库存
*
* @param prizeId 奖品ID
* @param count 增加数量
* @return 增加是否成功
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean increaseInventory(Long prizeId, int count) {
if (ObjectUtils.isEmpty(prizeId) || count <= 0) {
log.error("参数错误,无法增加库存,prizeId: {}, count: {}", prizeId, count);
return false;
}
// 获取分布式锁
String lockKey = "lottery:inventory:lock:" + prizeId;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
log.error("获取库存锁失败,prizeId: {}", prizeId);
return false;
}
// 更新数据库库存
int rows = baseMapper.incrementSurplusCount(prizeId, count);
if (rows > 0) {
log.debug("增加库存成功,prizeId: {}, count: {}", prizeId, count);
// 更新缓存
String cacheKey = INVENTORY_CACHE_PREFIX + prizeId;
LotteryPrize prize = getById(prizeId);
redisTemplate.opsForValue().set(cacheKey, prize.getSurplusCount());
return true;
} else {
log.error("增加库存失败,prizeId: {}, count: {}", prizeId, count);
return false;
}
} catch (InterruptedException e) {
log.error("获取库存锁中断,prizeId: {}", prizeId, e);
Thread.currentThread().interrupt();
return false;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 获取奖品库存数量
*
* @param prizeId 奖品ID
* @return 库存数量
*/
@Override
public int getInventoryCount(Long prizeId) {
if (ObjectUtils.isEmpty(prizeId)) {
log.error("奖品ID为空,无法获取库存");
return 0;
}
// 先从缓存获取
String cacheKey = INVENTORY_CACHE_PREFIX + prizeId;
Object cacheCount = redisTemplate.opsForValue().get(cacheKey);
if (!ObjectUtils.isEmpty(cacheCount)) {
try {
return Integer.parseInt(cacheCount.toString());
} catch (NumberFormatException e) {
log.error("解析缓存库存失败,prizeId: {}", prizeId, e);
}
}
// 缓存获取失败,从数据库获取
LotteryPrize prize = getById(prizeId);
int count = ObjectUtils.isEmpty(prize) ? 0 : prize.getSurplusCount();
// 更新缓存
redisTemplate.opsForValue().set(cacheKey, count);
log.debug("从数据库获取库存,prizeId: {}, count: {}", prizeId, count);
return count;
}
/**
* 预热活动奖品库存到缓存
*
* @param activityId 活动ID
*/
@Override
public void preloadInventoryToCache(Long activityId) {
if (ObjectUtils.isEmpty(activityId)) {
log.error("活动ID为空,无法预热库存");
return;
}
// 查询活动关联的奖品
List<LotteryActivityPrize> activityPrizes = activityPrizeService.listByActivityId(activityId);
if (CollectionUtils.isEmpty(activityPrizes)) {
log.warn("活动未关联任何奖品,无需预热库存,activityId: {}", activityId);
return;
}
// 批量查询奖品信息
List<Long> prizeIds = activityPrizes.stream()
.map(LotteryActivityPrize::getPrizeId)
.collect(Collectors.toList());
List<LotteryPrize> prizes = listByIds(prizeIds);
if (CollectionUtils.isEmpty(prizes)) {
log.warn("活动关联的奖品不存在,activityId: {}", activityId);
return;
}
// 预热库存到缓存,设置过期时间为24小时
for (LotteryPrize prize : prizes) {
String cacheKey = INVENTORY_CACHE_PREFIX + prize.getId();
redisTemplate.opsForValue().set(cacheKey, prize.getSurplusCount(), 24, TimeUnit.HOURS);
log.debug("预热奖品库存到缓存,prizeId: {}, count: {}", prize.getId(), prize.getSurplusCount());
}
log.info("活动奖品库存预热完成,activityId: {}", activityId);
}
}
控制器负责接收前端请求,调用服务层处理,并返回结果:
package com.jam.lottery.controller;
import com.jam.lottery.common.Result;
import com.jam.lottery.service.LotteryService;
import com.jam.lottery.vo.DrawResultVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 抽奖控制器
*
* @author 果酱
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/lottery")
@RequiredArgsConstructor
@Tag(name = "抽奖接口", description = "提供用户参与抽奖的相关接口")
public class LotteryController {
private final LotteryService lotteryService;
/**
* 用户参与抽奖
*/
@PostMapping("/draw")
@Operation(summary = "用户参与抽奖", description = "用户参与指定活动的抽奖")
public Result<DrawResultVO> draw(
@Parameter(description = "用户ID", required = true)
@RequestParam Long userId,
@Parameter(description = "活动ID", required = true)
@RequestParam Long activityId) {
log.info("用户参与抽奖,userId: {}, activityId: {}", userId, activityId);
try {
DrawResultVO result = lotteryService.draw(userId, activityId);
return Result.success(result);
} catch (Exception e) {
log.error("用户抽奖失败,userId: {}, activityId: {}", userId, activityId, e);
return Result.fail(e.getMessage());
}
}
/**
* 查询用户抽奖次数
*/
@PostMapping("/count")
@Operation(summary = "查询用户抽奖次数", description = "查询用户在指定活动中的抽奖次数")
public Result<Integer> countUserDraws(
@Parameter(description = "用户ID", required = true)
@RequestParam Long userId,
@Parameter(description = "活动ID", required = true)
@RequestParam Long activityId) {
log.info("查询用户抽奖次数,userId: {}, activityId: {}", userId, activityId);
try {
int count = lotteryService.countUserDraws(userId, activityId);
return Result.success(count);
} catch (Exception e) {
log.error("查询用户抽奖次数失败", e);
return Result.fail(e.getMessage());
}
}
}
使用 RabbitMQ 处理异步任务,如奖品发放通知:
package com.jam.lottery.mq.producer;
import com.alibaba.fastjson2.JSON;
import com.jam.lottery.constant.MqConstant;
import com.jam.lottery.vo.UserDrawVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import java.util.UUID;
/**
* 抽奖消息生产者
* 负责发送抽奖相关的消息
*
* @author 果酱
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LotteryMessageProducer {
private final RabbitTemplate rabbitTemplate;
/**
* 发送中奖消息
*
* @param userDrawVO 用户抽奖信息
*/
public void sendPrizeMessage(UserDrawVO userDrawVO) {
if (ObjectUtils.isEmpty(userDrawVO)) {
log.error("发送中奖消息失败,消息内容为空");
return;
}
try {
// 生成消息ID
String messageId = UUID.randomUUID().toString();
CorrelationData correlationData = new CorrelationData(messageId);
// 发送消息
rabbitTemplate.convertAndSend(
MqConstant.LOTTERY_EXCHANGE,
MqConstant.PRIZE_DELIVERY_ROUTING_KEY,
JSON.toJSONString(userDrawVO),
correlationData
);
log.info("发送中奖消息成功,messageId: {}, userId: {}, prizeId: {}",
messageId, userDrawVO.getUserId(), userDrawVO.getPrizeId());
} catch (Exception e) {
log.error("发送中奖消息失败,userDrawVO: {}", JSON.toJSONString(userDrawVO), e);
// 实际项目中可以考虑重试或存入本地消息表
}
}
}
package com.jam.lottery.mq.consumer;
import com.alibaba.fastjson2.JSON;
import com.jam.lottery.constant.MqConstant;
import com.jam.lottery.entity.PrizeDelivery;
import com.jam.lottery.enums.DeliveryStatusEnum;
import com.jam.lottery.enums.PrizeTypeEnum;
import com.jam.lottery.service.PrizeDeliveryService;
import com.jam.lottery.service.PrizeService;
import com.jam.lottery.vo.UserDrawVO;
import com.rabbitmq.client.Channel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import java.io.IOException;
import java.time.LocalDateTime;
/**
* 奖品发放消费者
* 处理中奖消息,负责奖品的发放
*
* @author 果酱
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PrizeDeliveryConsumer {
private final PrizeDeliveryService deliveryService;
private final PrizeService prizeService;
/**
* 处理中奖消息,发放奖品
*/
@RabbitListener(queues = MqConstant.PRIZE_DELIVERY_QUEUE)
public void handlePrizeDeliveryMessage(String message, Channel channel, Message amqpMessage) throws IOException {
log.info("收到中奖消息,开始处理奖品发放: {}", message);
try {
// 解析消息
UserDrawVO userDrawVO = JSON.parseObject(message, UserDrawVO.class);
if (ObjectUtils.isEmpty(userDrawVO)) {
log.error("消息内容解析失败,消息为空");
// 确认消息已消费
channel.basicAck(amqpMessage.getMessageProperties().getDeliveryTag(), false);
return;
}
// 检查消息是否已处理
PrizeDelivery existingDelivery = deliveryService.getByRecordId(userDrawVO.getRecordId());
if (!ObjectUtils.isEmpty(existingDelivery)) {
log.warn("消息已处理,无需重复处理,recordId: {}", userDrawVO.getRecordId());
channel.basicAck(amqpMessage.getMessageProperties().getDeliveryTag(), false);
return;
}
// 创建奖品发放记录
PrizeDelivery delivery = createPrizeDelivery(userDrawVO);
// 根据奖品类型发放奖品
boolean deliverySuccess = false;
if (PrizeTypeEnum.PHYSICAL.getType().equals(userDrawVO.getPrizeType())) {
// 实物奖品,需要物流配送
deliverySuccess = handlePhysicalPrize(delivery, userDrawVO);
} else if (PrizeTypeEnum.VIRTUAL.getType().equals(userDrawVO.getPrizeType())) {
// 虚拟物品,如游戏道具等
deliverySuccess = handleVirtualPrize(delivery, userDrawVO);
} else if (PrizeTypeEnum.COUPON.getType().equals(userDrawVO.getPrizeType())) {
// 优惠券
deliverySuccess = handleCouponPrize(delivery, userDrawVO);
} else if (PrizeTypeEnum.POINT.getType().equals(userDrawVO.getPrizeType())) {
// 积分
deliverySuccess = handlePointPrize(delivery, userDrawVO);
}
// 更新发放状态
updateDeliveryStatus(delivery, deliverySuccess);
// 确认消息已消费
channel.basicAck(amqpMessage.getMessageProperties().getDeliveryTag(), false);
log.info("奖品发放处理完成,recordId: {}, success: {}", userDrawVO.getRecordId(), deliverySuccess);
} catch (Exception e) {
log.error("处理中奖消息异常", e);
// 消息处理失败,拒绝消息并将其重新入队
channel.basicNack(amqpMessage.getMessageProperties().getDeliveryTag(), false, true);
}
}
/**
* 创建奖品发放记录
*/
private PrizeDelivery createPrizeDelivery(UserDrawVO userDrawVO) {
PrizeDelivery delivery = new PrizeDelivery();
delivery.setRecordId(userDrawVO.getRecordId());
delivery.setUserId(userDrawVO.getUserId());
delivery.setActivityId(userDrawVO.getActivityId());
delivery.setPrizeId(userDrawVO.getPrizeId());
delivery.setPrizeType(userDrawVO.getPrizeType());
delivery.setDeliveryStatus(DeliveryStatusEnum.PENDING.getCode());
delivery.setCreateTime(LocalDateTime.now());
deliveryService.save(delivery);
return delivery;
}
/**
* 处理实物奖品发放
*/
private boolean handlePhysicalPrize(PrizeDelivery delivery, UserDrawVO userDrawVO) {
try {
// 实际项目中,这里会调用物流系统API创建物流单
// 这里简化处理,模拟物流信息
String logisticsInfo = "{\"logisticsCompany\":\"顺丰速运\",\"trackingNumber\":\"SF" + System.currentTimeMillis() + "\"}";
delivery.setLogisticsInfo(logisticsInfo);
log.info("实物奖品发放处理,recordId: {}, logisticsInfo: {}", userDrawVO.getRecordId(), logisticsInfo);
return true;
} catch (Exception e) {
log.error("处理实物奖品发放失败,recordId: {}", userDrawVO.getRecordId(), e);
return false;
}
}
/**
* 处理虚拟物品发放
*/
private boolean handleVirtualPrize(PrizeDelivery delivery, UserDrawVO userDrawVO) {
try {
// 实际项目中,这里会调用对应的虚拟物品发放API
// 这里简化处理,生成一个虚拟物品码
String virtualCode = "VIRTUAL" + System.currentTimeMillis() + userDrawVO.getUserId();
delivery.setVirtualCode(virtualCode);
log.info("虚拟物品发放处理,recordId: {}, virtualCode: {}", userDrawVO.getRecordId(), virtualCode);
return true;
} catch (Exception e) {
log.error("处理虚拟物品发放失败,recordId: {}", userDrawVO.getRecordId(), e);
return false;
}
}
/**
* 处理优惠券发放
*/
private boolean handleCouponPrize(PrizeDelivery delivery, UserDrawVO userDrawVO) {
try {
// 实际项目中,这里会调用优惠券系统API为用户发放优惠券
// 这里简化处理,生成一个优惠券码
String couponCode = "COUPON" + System.currentTimeMillis() + userDrawVO.getUserId();
delivery.setVirtualCode(couponCode);
log.info("优惠券发放处理,recordId: {}, couponCode: {}", userDrawVO.getRecordId(), couponCode);
return true;
} catch (Exception e) {
log.error("处理优惠券发放失败,recordId: {}", userDrawVO.getRecordId(), e);
return false;
}
}
/**
* 处理积分发放
*/
private boolean handlePointPrize(PrizeDelivery delivery, UserDrawVO userDrawVO) {
try {
// 实际项目中,这里会调用积分系统API为用户增加积分
// 这里简化处理
log.info("积分发放处理,recordId: {}, userId: {}", userDrawVO.getRecordId(), userDrawVO.getUserId());
return true;
} catch (Exception e) {
log.error("处理积分发放失败,recordId: {}", userDrawVO.getRecordId(), e);
return false;
}
}
/**
* 更新发放状态
*/
private void updateDeliveryStatus(PrizeDelivery delivery, boolean success) {
if (success) {
delivery.setDeliveryStatus(DeliveryStatusEnum.COMPLETED.getCode());
delivery.setDeliveryTime(LocalDateTime.now());
} else {
delivery.setDeliveryStatus(DeliveryStatusEnum.FAILED.getCode());
delivery.setRemark("奖品发放失败,请稍后重试");
}
delivery.setUpdateTime(LocalDateTime.now());
deliveryService.updateById(delivery);
}
}
抽奖系统往往面临高并发场景,特别是在营销活动高峰期,需要从多个维度进行优化:
/**
* 缓存用户抽奖次数示例
*/
private int getUserDrawCountFromCache(Long userId, Long activityId) {
String cacheKey = "lottery:user:draw:count:" + activityId + ":" + userId;
Object countObj = redisTemplate.opsForValue().get(cacheKey);
if (countObj != null) {
return Integer.parseInt(countObj.toString());
}
// 缓存不存在,从数据库查询并更新缓存
int count = lotteryRecordMapper.countUserDraws(userId, activityId);
redisTemplate.opsForValue().set(cacheKey, count, 1, TimeUnit.HOURS);
return count;
}
/**
* 递增用户抽奖次数缓存
*/
private void incrementUserDrawCountCache(Long userId, Long activityId) {
String cacheKey = "lottery:user:draw:count:" + activityId + ":" + userId;
redisTemplate.opsForValue().increment(cacheKey);
// 设置过期时间,防止缓存膨胀
redisTemplate.expire(cacheKey, 1, TimeUnit.HOURS);
}
/**
* 基于Redis的限流实现
*/
@Component
public class RedisRateLimiter {
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public RedisRateLimiter(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 检查是否允许访问
*
* @param key 限流键
* @param maxCount 最大允许次数
* @param period 时间窗口,单位秒
* @return 是否允许访问
*/
public boolean allowAccess(String key, int maxCount, int period) {
String cacheKey = "rate:limiter:" + key;
// 使用Redis的INCR命令实现计数器
Long count = redisTemplate.opsForValue().increment(cacheKey);
if (count != null && count == 1) {
// 第一次访问,设置过期时间
redisTemplate.expire(cacheKey, period, TimeUnit.SECONDS);
}
return count != null && count <= maxCount;
}
}
使用 Redisson 实现分布式锁,解决并发场景下的数据一致性问题,如库存扣减、抽奖次数限制等。
抽奖系统作为一种常见的用户互动和营销工具,其设计和实现涉及多个技术领域的知识。本文从需求分析、架构设计、数据库设计、核心代码实现到高并发处理,全面介绍了一个专业抽奖系统的构建过程。