一个能真正跑起来的在线抽奖平台:管理员建奖品 → 建活动(圈奖品、圈人)→ 现场抽奖 → 异步通知中奖者。 项目里刻意用了几种设计模式来解决"活动 / 奖品 / 人员三方状态联动"这个核心难题,这篇文章把需求、表结构、代码和踩过的坑一次讲清楚。
- 技术栈:Spring Boot 3.2 + MyBatis + MySQL 8 + Redis + RabbitMQ + JWT + QQ 邮箱
- 前端:原生 HTML + jQuery + AJAX
- 代码仓库:https://gitee.com/jinhong-c/java-design-patterns-lottery
目录
- 7.1 注册
- 7.2 创建活动
- 7.3 抽奖:前端滚动 + 后端异步
- 7.4 状态扭转:模板方法 + 策略注册表(重点)
- 7.5 一致性:事务、补偿、重试、死信
- 7.6 Redis 缓存设计
- 7.7 中奖通知:线程池 + 邮件 / 短信
1. 项目背景
随着数字营销的兴起,抽奖几乎是所有 C 端产品的标配:抖音的福袋、拼多多的转盘、公司年会的抽奖箱,本质都是同一件事——在有限的奖品和有限的参与人之间,做一次公平、可追溯、不可撤销的分配。
但把它做成一个"系统",难点并不在随机数,而在这几件事:
- 一场活动里有多档奖品(一等奖 / 二等奖 / 三等奖),每档数量不同,只能抽一次;
- 参与人是运营在后台"圈"进来的,必须保证一个人只能中一次奖;
- 抽奖是现场大屏操作,点下去就不能反悔,但要能扛住网络抖动、邮件服务超时;
- 活动、奖品、人员三方状态必须联动:奖品抽完了,参与人就不能再被抽;所有奖品都抽完了,活动才算结束。
这个项目就是围绕这四件事设计的。前台是给运营用的管理后台,抽奖页是大屏用的"滚动 + 揭晓"界面。
1.1 功能清单
| 管理员 | 注册 / 登录 | 手机号或邮箱 + 密码登录,管理员注册需强制密码 |
| 人员管理 | 创建普通用户、人员列表 | 普通用户由管理员创建,无需密码 |
| 奖品管理 | 创建奖品、奖品列表 | 含名称、描述、价格、图片(本地文件上传) |
| 活动管理 | 创建活动、活动列表 | 圈选奖品(数量 / 等级)、圈选人员 |
| 抽奖 | 抽奖页、中奖名单 | 逐档抽奖、滚动揭晓、名单导出 CSV、结果分享 |
| 通知 | 中奖邮件通知 | 线程池异步发送,短信能力已封装、可扩展 |
| 安全 | JWT 鉴权、密码哈希、手机号加密 | 未登录访问接口一律 401 |
1.2 非功能需求(这几个决定了后面的设计)
2. 系统架构与技术选型
2.1 分层架构

图 1 系统总体架构:表现层 → 控制层 → 业务层 → 持久层 → 数据层,右侧是 Redis / RabbitMQ / 线程池
自上而下五层,另有三个"外部协作者"(Redis、RabbitMQ、线程池):
| 表现层 | 静态页面 + AJAX | src/main/resources/static/*.html |
| 控制层 Controller | 参数校验、结果封装、全局异常 | ActivityController、PrizeController、UserController、DrawPrizeController |
| 业务层 Service | 业务规则、事务边界、缓存读写、状态调度 | ActivityServiceImpl、DrawPrizeServiceImpl、ActivityStatusManagerImpl |
| 持久层 DAO | MyBatis 注解 SQL、加密 TypeHandler、分页 | dao/mapper/*.java、EncryptTypeHandler |
| 数据层 | MySQL 8(6 张业务表) | — |
| 公共层(横切) | JWT 拦截器、全局异常、统一返回、工具类 | LoginInterceptor、GlobalExceptionHandler、CommonResult |
2.2 技术选型理由
| Spring Boot | 3.2.6 | 自动装配省去大量 XML;JDK 17 是 3.x 的硬门槛 |
| MyBatis | 3.0.3 | 注解写 SQL 足够直观,TypeHandler 能把"加密"这件事下沉到 DAO 层 |
| MySQL | 8.x | 强一致的关系模型,活动-奖品-人员天然是多对多,需要关联表 |
| Redis | 7.x | 活动详情 + 中奖名单缓存,抽奖现场读多写少,扛住大屏刷新 |
| RabbitMQ | 3.x | 抽奖是"必须成功、可以慢一点"的任务,用消息队列解耦 + 重试 + 死信兜底 |
| JWT (jjwt) | 0.11.5 | 无状态鉴权,服务端不用存 Session |
| Hutool | 5.8.25 | AES / SHA-256 / 日期格式化,一个依赖全搞定 |
| 阿里云短信 | 2.0.24 | 中奖短信通知(已封装 SMSUtil) |
| QQ 邮箱 SMTP | — | 中奖邮件通知,465 + SSL |
2.3 页面结构

图 2 页面导航结构:登录 → 后台框架 → 活动 / 奖品 / 人员 / 抽奖
共 9 个页面。除登录、注册两个独立页面外,其余页面都在 admin.html 框架里用 iframe 切换,左侧菜单高亮 + 地址栏 hash 定位,刷新后还能停在同一页:
| 登录页 | src/main/resources/static/blogin.html | 登录成功后把 token 存进 localStorage |
| 注册页 | src/main/resources/static/register.html | 管理员注册(需密码) |
| 后台框架 | src/main/resources/static/admin.html | 左菜单 + 右侧 iframe |
| 活动列表 | src/main/resources/static/activities-list.html | 分页、状态、进入抽奖 |
| 新建活动 | src/main/resources/static/create-activity.html | 圈奖品(数量 / 等级)、圈人员 |
| 奖品列表 | src/main/resources/static/prizes-list.html | 分页、图片预览 |
| 创建奖品 | src/main/resources/static/create-prizes.html | 表单 + 图片上传 + 本地预览 |
| 人员列表 | src/main/resources/static/user-list.html | 姓名、身份 |
| 抽奖页 | src/main/resources/static/draw.html | 抽奖三环节 + 名单卡片 + 导出 / 分享 |
前端没用 Vue/React,原因很实在:这个项目 90% 的复杂度在后端状态流转上,前端只需要"表单 + 表格 + 一次大屏动画",jQuery 的 $.ajax 足够,还省掉了打包和部署。
3. 数据库设计
3.1 E-R 图

图 3 数据库 E-R 图:活动是中心,1:N 关联活动-奖品、活动-人员,中奖记录是历史快照
六张表的关系一目了然:
- activity (活动)是中心,1:N 关联 activity_prize(活动里有哪些奖品、各几份、什么等级)和 activity_user(活动圈了哪些人);
- activity_prize 再 1:N 关联 winning_record(哪一轮抽出了谁);
- prize(奖品库)和 user(用户库)是"基础数据",被关联表引用。
为什么要有 activity_prize 这层关联表? 因为同一个奖品可以出现在多场活动里,同一场活动里奖品也各有"份数"和"等级",这些属性属于"活动 × 奖品"这个组合,不属于任何一方。
为什么 activity_user 里冗余了 user_name? 这是有意的:活动名单是"当时的快照",用户后来改了昵称,不该影响历史活动的名单展示。
3.2 建表语句
按代码里 Mapper 的字段整理,六张表(gmt_create / gmt_modified 是公共审计字段,所有表都有):
— 1. 用户表
CREATE TABLE `user` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`user_name` varchar(255) NOT NULL COMMENT '姓名',
`email` varchar(255) NOT NULL COMMENT '邮箱',
`phone_number` varchar(255) NOT NULL COMMENT '手机号(AES 密文,hex)',
`password` varchar(64) DEFAULT NULL COMMENT '密码(SHA-256,64 位 hex)',
`identity` varchar(32) NOT NULL COMMENT '身份:ADMIN / USER',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
— 2. 奖品表(奖品库,与活动无关)
CREATE TABLE `prize` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`name` varchar(255) NOT NULL COMMENT '奖品名称',
`description` varchar(255) DEFAULT NULL COMMENT '奖品描述',
`price` decimal(10,2) NOT NULL COMMENT '奖品价值',
`image_url` varchar(255) DEFAULT NULL COMMENT '奖品图片文件名',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
— 3. 活动表
CREATE TABLE `activity` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`activity_name` varchar(255) NOT NULL COMMENT '活动名称',
`description` varchar(255) NOT NULL COMMENT '活动描述',
`status` varchar(32) NOT NULL COMMENT '活动状态:RUNNING / COMPLETED',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
— 4. 活动-奖品关联表
CREATE TABLE `activity_prize` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`activity_id` bigint NOT NULL COMMENT '活动 id',
`prize_id` bigint NOT NULL COMMENT '奖品 id',
`prize_amount` bigint NOT NULL DEFAULT 1 COMMENT '该奖项的名额数量',
`prize_tiers` varchar(32) NOT NULL COMMENT '奖项等级:FIRST_PRIZE / SECOND_PRIZE / THIRD_PRIZE',
`status` varchar(32) NOT NULL COMMENT '该奖品状态:INIT / COMPLETED',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_prize` (`activity_id`, `prize_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
— 5. 活动-用户关联表
CREATE TABLE `activity_user` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`activity_id` bigint NOT NULL COMMENT '活动 id',
`user_id` bigint NOT NULL COMMENT '用户 id',
`user_name` varchar(255) NOT NULL COMMENT '用户名(快照)',
`status` varchar(32) NOT NULL COMMENT '参与状态:INIT / COMPLETED',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_user` (`activity_id`, `user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
— 6. 中奖记录表
CREATE TABLE `winning_record` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`activity_id` bigint NOT NULL COMMENT '活动 id',
`activity_name` varchar(255) NOT NULL COMMENT '活动名称(快照)',
`prize_id` bigint NOT NULL COMMENT '奖品 id',
`prize_name` varchar(255) NOT NULL COMMENT '奖品名称(快照)',
`prize_tier` varchar(32) NOT NULL COMMENT '奖项等级',
`winner_id` bigint NOT NULL COMMENT '中奖人 id',
`winner_name` varchar(255) NOT NULL COMMENT '中奖人姓名(快照)',
`winner_email` varchar(255) DEFAULT NULL COMMENT '中奖人邮箱(发通知用)',
`winner_phone_number` varchar(255) DEFAULT NULL COMMENT '中奖人手机号(密文)',
`winning_time` datetime NOT NULL COMMENT '中奖时间',
PRIMARY KEY (`id`),
KEY `idx_activity_prize` (`activity_id`, `prize_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
3.3 几个设计取舍
4. 数据加密:AES 可逆 + SHA-256 摘要
项目里有两类"敏感数据",处理方式完全不同:
| 登录密码 | SHA-256 哈希 | ❌ 不可逆 | 系统永远不需要知道明文,只比对摘要 |
| 手机号 | AES 对称加密 | ✅ 可逆 | 中奖后要发短信,必须能解出明文 |
4.1 对称加密(可逆)
加密:明文 + 密钥 ──> 密文 ──> 存库
解密:密文 + 密钥 ──> 明文 ──> 与用户输入比对 / 发短信
4.2 哈希加密(不可逆)
注册:明文 + SHA-256 ──> 摘要 ──> 存库
登录:明文 + SHA-256 ──> 摘要1
从库中取出摘要2
比对 摘要1 == 摘要2
4.3 用 MyBatis TypeHandler 把加密下沉到 DAO 层
最直白的写法是在每个 Service 里手动 aes.encrypt(),但那样"哪些字段要加密"就散落在业务代码里了。项目里用 MyBatis 的 TypeHandler + 一个 Encrypt 包装类来解决:
// dao/dataobject/Encrypt.java —— 一个"标记类型",出现它就代表要加密
@Data
public class Encrypt {
private String value;
public Encrypt(String value) { this.value = value; }
}
// dao/handler/EncryptTypeHandler.java
@MappedTypes(Encrypt.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class EncryptTypeHandler extends BaseTypeHandler<Object> {
private final static byte[] KEY = "****************".getBytes(StandardCharsets.UTF_8);
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType) throws SQLException {
String plainText = null;
if (parameter instanceof Encrypt) {
plainText = ((Encrypt) parameter).getValue();
} else if (parameter instanceof String) {
plainText = (String) parameter;
}
// 写入数据库前统一 AES 加密
ps.setString(i, SecureUtil.aes(KEY).encryptHex(plainText));
}
@Override
public Encrypt getNullableResult(ResultSet rs, String columnName) throws SQLException {
return decrypt(rs.getString(columnName)); // 读出来自动解密
}
private Encrypt decrypt(String str) {
if (str == null || str.trim().isEmpty()) return null;
return new Encrypt(SecureUtil.aes(KEY).decryptStr(str));
}
}
然后 Mapper 里只需要在参数上标注一次:
@Insert("insert into user (user_name, email, phone_number, password, identity) " +
"values (#{userName}, #{email}, " +
"#{phoneNumber, typeHandler=com.example.lotterysystem.dao.handler.EncryptTypeHandler}, " +
"#{password}, #{identity})")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
void insert(UserDO userDO);
好处:Service 层拿到的永远是明文 Encrypt,数据库里躺的永远是密文,中间没有"忘记加密"的机会。用手机号查库时也一样(where phone_number = #{phoneNumber, typeHandler=…}),TypeHandler 会先把明文加密再拼进 SQL。
4.4 密码哈希
// UserServiceImpl#registe
userDO.setPhoneNumber(new Encrypt(param.getPhoneNumber())); // 手机号:AES
if (StringUtils.hasText(param.getPassword())) {
userDO.setPassword(DigestUtil.sha256Hex(param.getPassword())); // 密码:SHA-256
}
登录时:
} else if (!DigestUtil.sha256Hex(loginParam.getPassword()).equals(userDO.getPassword())) {
throw new ServiceException(ServiceErrorCodeConstants.PASSWORD_ERROR);
}
⚠️ 生产建议: 1. AES 密钥、JWT 密钥不要硬编码在代码里,放环境变量或配置中心(KMS); 2. 密码建议升级为 加盐慢哈希(BCrypt / Argon2),SHA-256 太快,抗暴力破解能力弱; 3. 手机号这种"要按密文查等值"的场景,如果库很大,可以再加一列 phone_hash(HMAC)做索引列。
5. 登录与鉴权:JWT + 拦截器
5.1 JWT 是什么
JWT 由三部分组成,用 . 连接:
Header.Payload.Signature
| Header(头部) | 令牌类型 JWT、签名算法(如 HS256) | 否,Base64 |
| Payload(负载) | 自定义信息,如 {"id": 12, "identity": "ADMIN"},以及 iat、exp | 否,Base64 |
| Signature(签名) | 用密钥对前两段做的签名,防篡改 | — |
关键认知:JWT 的内容是"任何人都能看"的,它的安全性来自"任何字符被改动,签名校验就会失败"。就像身份证——信息是公开的,但它不能被伪造。
5.2 生成与校验
// common/utils/JWTUtil.java
private static final String SECRET = "******************************************"; // Base64
private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET));
private static final long EXPIRATION = 60 * 60 * 1000; // 1 小时
public static String genJwt(Map<String, Object> claim) {
return Jwts.builder()
.setClaims(claim) // 载荷:用户 id、身份
.setIssuedAt(new Date()) // 签发时间
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(SECRET_KEY) // HS256 签名
.compact();
}
public static Claims parseJWT(String jwt) {
if (!StringUtils.hasLength(jwt)) return null;
try {
return Jwts.parserBuilder().setSigningKey(SECRET_KEY).build()
.parseClaimsJws(jwt).getBody();
} catch (Exception e) {
logger.error("解析令牌错误, jwt:{}", jwt, e); // 过期 / 篡改都会走到这里
return null;
}
}
登录成功时把用户信息塞进 token:
// UserServiceImpl#loginByUserPassword
Map<String, Object> claim = new HashMap<>();
claim.put("id", userDO.getId());
claim.put("identity", userDO.getIdentity());
String token = JWTUtil.genJwt(claim);
5.3 拦截器:一个入口守住所有接口
// common/interceptor/LoginInterceptor.java
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("user_token"); // 前端统一用这个 header
Claims claims = JWTUtil.parseJWT(token);
if (claims == null) {
response.setStatus(401); // 未登录 / 过期
return false;
}
return true;
}
// common/config/AppConfig.java —— 放行清单
private final List<String> excludes = Arrays.asList(
"/**/*.html", "/css/**", "/js/**", "/lib/**", "/pic/**",
"/*.jpg", "/*.png", "/favicon.ico",
"/**/login", // 登录接口
"/register", // 注册接口
"/verification-code/send",
"/winning-records/show" // 分享出去的名单页需要匿名可读
);
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(excludes);
}
5.4 前端怎么配合
登录成功后:
localStorage.setItem("user_token", result.data.token);
localStorage.setItem("user_identity", "ADMIN");
location.assign("admin.html");
之后每个请求带上 user_token,并统一处理 401:
$.ajax({
url: '/prize/create',
type: 'post',
headers: { "user_token": userToken },
data: formData,
processData: false,
contentType: false,
error: function (err) {
if (err != null && err.status == 401) {
alert("用户未登录,即将跳转到登录页!");
window.location.href = "/blogin.html";
// 抽奖页可能被嵌在 iframe 里,父页面也要一起跳
if (window.parent) window.parent.location.href = "/blogin.html";
}
}
});
5.5 这里的几个坑
6. 核心链路拆解
6.1 注册
public UserRegisterDTO registe(UserRegisterParam param) {
checkRegisterInfo(param); // 格式 + 唯一性校验
UserDO userDO = new UserDO();
userDO.setUserName(param.getName());
userDO.setEmail(param.getMail());
userDO.setPhoneNumber(new Encrypt(param.getPhoneNumber())); // AES
userDO.setIdentity(param.getIdentity());
if (StringUtils.hasText(param.getPassword())) {
userDO.setPassword(DigestUtil.sha256Hex(param.getPassword()));
}
userMapper.insert(userDO);
…
}
校验清单(checkRegisterInfo)值得抄一份:
- 邮箱格式、手机号格式(正则);
- identity 必须是合法枚举值;
- 管理员注册必须有密码(普通用户可以没有密码,由管理员批量创建);
- 密码长度不小于 6 位;
- 邮箱、手机号唯一性(手机号用密文去比对)。
6.2 创建活动
创建活动是整个系统里最"重"的写操作,一次插入三张表,必须在一个事务里:
@Transactional(rollbackFor = Exception.class)
public CreateActivityDTO createActivity(CreateActivityParam param) {
checkActivityInfo(param); // ① 校验
ActivityDO activityDO = new ActivityDO();
activityDO.setStatus(ActivityStatusEnum.RUNNING.name());
activityMapper.insert(activityDO); // ② 活动
// ③ 活动-奖品:每档奖品带数量、等级,初始状态 INIT
List<ActivityPrizeDO> apList = param.getActivityPrizeList().stream().map(p -> {
ActivityPrizeDO ap = new ActivityPrizeDO();
ap.setActivityId(activityDO.getId());
ap.setPrizeId(p.getPrizeId());
ap.setPrizeAmount(p.getPrizeAmount());
ap.setPrizeTiers(p.getPrizeTiers());
ap.setStatus(ActivityPrizeStatusEnum.INIT.name());
return ap;
}).collect(Collectors.toList());
activityPrizeMapper.batchInsert(apList);
// ④ 活动-人员:初始状态 INIT
…
activityUserMapper.batchInsert(auList);
// ⑤ 组装完整活动详情,写进 Redis,抽奖时直接读缓存
cacheActivity(detailDTO);
…
}
checkActivityInfo 里的几条业务校验,是"防运营手滑"的关键:
| 人员 id 都要在 user 表存在 | 防止传了脏 id |
| 奖品 id 都要在 prize 表存在 | 同上 |
| 参与人数 ≥ 奖品总份数 | 否则必然出现"人不够抽" |
| 奖项等级合法 | 只允许一 / 二 / 三等奖 |
6.3 抽奖:前端滚动 + 后端异步

图 4 抽奖流程时序:接口只负责投递消息,校验、状态扭转、落库、通知都在消费端完成
抽奖分三个环节,对应 draw.html 里的状态机(state 变量):
| ① 展示奖品 | 大图 + "一等奖 手机 3 份" | showPic(data) |
| ② 滚动揭晓 | 名字快速跳动,点"点我确定"定格 | showBlink(data) / doBlink() |
| ③ 展示名单 | 中奖名字静态列出,进入下一档 | showList(data) |
滚动效果是 requestAnimationFrame + 随机打乱实现的:
function doBlink() {
if (state == 'showBlink') {
// 随机打乱,取前 N 个名字显示,看起来就像在"滚"
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);
}
}
点"确定"时,把当前显示的前 N 个人 splice 出来作为中奖者,然后提交:
function saveLuck() {
var data = steps[step];
var drawPrizesData = {
activityId: activityId,
prizeId: data.prizeId,
winningTime: new Date(),
winnerList: data.list.map(u => ({ userId: u.userId, userName: u.userName }))
};
$.ajax({ url: '/draw-prize', type: 'POST', contentType: 'application/json',
data: JSON.stringify(drawPrizesData), headers: { "user_token": userToken } });
}
后端 DrawPrizeController 收到请求后不做重活,只做一件事——发消息:
@RequestMapping("/draw-prize")
public CommonResult<Boolean> drawPrize(@RequestBody @Validated DrawPrizeParam param) {
drawPrizeService.drawPrize(param);
return CommonResult.success(true);
}
// DrawPrizeServiceImpl#drawPrize
public void drawPrize(DrawPrizeParam param) {
Map<String, String> map = new HashMap<>();
map.put("messageId", String.valueOf(UUID.randomUUID()));
map.put("messageData", JacksonUtils.writeValueAsString(param));
rabbitTemplate.convertAndSend(EXCHANGE_NAME, ROUTING, map);
logger.info("MQ消息发送成功: map={}", JacksonUtils.writeValueAsString(map));
}
为什么要绕一道 MQ?
现场抽奖时,点"确定"要立刻出结果。而后面还有:校验 → 改三张表的状态 → 批量插中奖记录 → 发 N 封邮件。直接同步做,任何一步慢(尤其发邮件)都会让页面卡住;更糟的是网络抖动导致超时,用户重试就可能重复抽奖。
用 MQ 把"受理"和"处理"分开:接口立刻返回,消费端慢慢跑,失败还能重试。
消费端 MqReceiver 的完整流程:
@RabbitListener(queues = QUEUE_NAME)
public class MqReceiver {
@RabbitHandler
@Transactional(rollbackFor = Exception.class) // 整个消费流程一个本地事务
public void process(Map<String, String> message) {
DrawPrizeParam param = JacksonUtils.readvalue(message.get("messageData"), DrawPrizeParam.class);
try {
// ① 业务校验:活动 / 奖品是否有效、人数是否匹配
if (!drawPrizeService.checkDrawPrizeParam(param)) return;
// ② 状态扭转:奖品 COMPLETED、人员 COMPLETED、活动(抽完时)COMPLETED
statusConvert(param);
// ③ 落库 + 缓存中奖记录
List<WinningRecordDO> records = drawPrizeService.saveWinnerRecords(param);
// ④ 异步通知(线程池发邮件,不阻塞事务)
syncExecute(records);
} catch (ServiceException e) {
rollback(param); // 补偿:状态改回 INIT、删中奖记录与缓存
throw e; // 必须抛!否则消息被 ack,重试和死信全部失效
} catch (Exception e) {
rollback(param);
throw new RuntimeException("处理 MQ 消息失败", e);
}
}
}
消费端为什么要自己再校验一遍?因为 Bean Validation 只在 Controller 层生效,MQ 里的消息可能是任何来源投进来的(甚至人工投递),所以 checkDrawPrizeParam 里把"活动存在、奖品存在、活动未完成、奖品未完成、中奖人数 == 奖品份数"全查了一遍:
public Boolean checkDrawPrizeParam(DrawPrizeParam param) {
ActivityDO activityDO = activityMapper.selectById(param.getActivityId());
ActivityPrizeDO apDO = activityPrizeMapper.selectByAPId(param.getActivityId(), param.getPrizeId());
if (activityDO == null || apDO == null) return false; // 活动/奖品不存在
if (ActivityStatusEnum.COMPLETED.name().equalsIgnoreCase(activityDO.getStatus())) return false;
if (ActivityPrizeStatusEnum.COMPLETED.name().equalsIgnoreCase(apDO.getStatus())) return false;
if (CollectionUtils.isEmpty(param.getWinnerList())
|| apDO.getPrizeAmount().intValue() != param.getWinnerList().size()) return false;
return true;
}
这几行就是"同一奖品不能被抽第二次"的守门员——activity_prize.status 一旦变成 COMPLETED,后面的请求全被拒。
6.4 状态扭转:模板方法 + 策略注册表(重点)
抽奖成功要同时改三张表:
活动: RUNNING ──(全部奖品抽完)──> COMPLETED
奖品: INIT ──(本轮抽出)──────> COMPLETED
人员: INIT ──(本轮中奖)──────> COMPLETED
最直白的写法是 updatePrize(); updateUser(); updateActivity(); 三行硬编码。但这段逻辑有两个特点:
所以项目把它抽象成了 AbstractActivityOperator:
// service/activitystatus/operator/AbstractActivityOperator.java
public abstract class AbstractActivityOperator {
public abstract Integer sequence(); // 处理顺序
public abstract Boolean needConvert(ConvertActivityStatusDTO dto); // 该不该转
public abstract Boolean convert(ConvertActivityStatusDTO dto); // 怎么转
}
三个实现类,各自负责一种实体:
// PrizeOperator:奖品状态扭转,先处理
@Override public Integer sequence() { return 1; }
@Override
public Boolean needConvert(ConvertActivityStatusDTO dto) {
ActivityPrizeDO apDO = activityPrizeMapper.selectByAPId(dto.getActivityId(), dto.getPrizeId());
if (apDO == null) return false;
// 已经是目标状态就不用转(幂等)
return !dto.getTargetPrizeStatus().name().equalsIgnoreCase(apDO.getStatus());
}
@Override
public Boolean convert(ConvertActivityStatusDTO dto) {
activityPrizeMapper.updateStatus(dto.getActivityId(), dto.getPrizeId(), dto.getTargetPrizeStatus().name());
return true;
}
// ActivityOperator:活动状态扭转,后处理
@Override public Integer sequence() { return 2; }
@Override
public Boolean needConvert(ConvertActivityStatusDTO dto) {
if (dto.getActivityId() == null || dto.getTargetActivityStatus() == null) return false;
ActivityDO activityDO = activityMapper.selectById(dto.getActivityId());
if (activityDO == null) return false;
if (dto.getTargetActivityStatus().name().equalsIgnoreCase(activityDO.getStatus())) return false;
// 关键:只有"没有 INIT 状态的奖品"了,活动才能结束
int initCount = activityPrizeMapper.countPrize(dto.getActivityId(), ActivityPrizeStatusEnum.INIT.name());
return initCount == 0;
}
调度中心 ActivityStatusManagerImpl 只负责"按顺序、按规则、带事务"地跑完这些 Operator:
@Component
public class ActivityStatusManagerImpl implements ActivityStatusManager {
// Spring 会把所有 AbstractActivityOperator 类型的 Bean 按 beanName 注入成 Map
@Autowired
private final Map<String, AbstractActivityOperator> operatorMap = new HashMap<>();
@Override
@Transactional(rollbackFor = Exception.class)
public void handlerEvent(ConvertActivityStatusDTO dto) {
Map<String, AbstractActivityOperator> currMap = new HashMap<>(operatorMap);
boolean updated = false;
updated = processConvertStatus(dto, currMap, 1); // 先:奖品、人员
updated = processConvertStatus(dto, currMap, 2) || updated; // 后:活动
if (updated) {
activityService.cacheActivity(dto.getActivityId()); // 最后:刷新活动缓存
}
}
}
handlerEvent 里固定了"先子后父、最后刷缓存"这个算法骨架,子类只实现单个实体的转换规则——这就是模板方法;三个 Operator 是可互换的转换策略,调度中心面向抽象编程——这就是策略模式;而 Map<String, AbstractActivityOperator> 由 Spring 自动注入,又省掉了一个"根据类型 new 对应 Operator"的工厂。
为什么顺序不能乱? 活动只有在"所有奖品都抽完"时才允许结束,所以必须先把奖品状态改完,再判断活动。代码里用 sequence() 把这个约束显式化了,而不是靠调用顺序"约定"。
回滚也是同一套 Operator,只是目标状态反过来:
public void rollbackHandlerEvent(ConvertActivityStatusDTO dto) {
for (AbstractActivityOperator operator : operatorMap.values()) {
operator.convert(dto); // 全部恢复,不做 needConvert 判断
}
activityService.cacheActivity(dto.getActivityId());
}
💡 这段代码还有一个细节:processConvertStatus 里遍历时用的是 currMap 的副本,处理完一个就 iterator.remove(),这样"同一层里的实体"不会重复处理,也避免了 ConcurrentModificationException。
6.5 一致性:事务、补偿、重试、死信
一次抽奖涉及"三张表 + 一张中奖记录表 + 两份 Redis 缓存",出任何异常都要保证不出现"奖品状态已改、记录没落库"这种脏数据。
┌───────────── 本地事务(@Transactional)─────────────┐
校验 ──>│ 改奖品状态 → 改人员状态 → 改活动状态 → 插中奖记录 │──> 提交
└──────────────────────────────────────────────────┘
│ 任意一步抛异常
▼
事务回滚(数据库恢复原样)
+
catch 里显式补偿:状态改回 INIT、删中奖记录、删缓存
+
异常抛出 → Spring Retry 重试(max-attempts=5)
+
重试耗尽 → 进死信队列 DlxDirectQueue → 有限次回投(最多 3 次)
几个配置(application-test.properties):
# 自动确认:消费方法正常返回才 ack,抛异常则不 ack,交给重试
spring.rabbitmq.listener.simple.acknowledge-mode=auto
# 失败重试 5 次
spring.rabbitmq.listener.simple.retry.enabled=true
spring.rabbitmq.listener.simple.retry.max-attempts=5
死信队列还要防"无限回投"。DlxReceiver 的做法是把重投次数塞进消息体,超过上限就停手,留给人工处理:
private static final int MAX_REDELIVERY = 3;
private static final String RETRY_COUNT_KEY = "x-dlx-retry-count";
@RabbitListener(queues = DLX_QUEUE_NAME)
public void process(Map<String, String> message) {
int retryCount = parseRetryCount(message.get(RETRY_COUNT_KEY));
if (retryCount >= MAX_REDELIVERY) {
logger.error("死信消息重投次数已达上限({}), 停止回投, message:{}", MAX_REDELIVERY, message);
return;
}
message.put(RETRY_COUNT_KEY, String.valueOf(retryCount + 1));
rabbitTemplate.convertAndSend(EXCHANGE_NAME, ROUTING, message);
}
⚠️ 一个必须记住的坑:消费者 catch 了异常却不抛,等于告诉 MQ "我处理成功了",重试和死信全部不会触发,数据就这样静默丢了。所以 catch 里做完补偿后一定要 throw。
6.6 Redis 缓存设计
| ACTIVITY_{activityId} | ActivityDetailDTO(活动 + 奖品列表 + 人员列表) | 3 天 | 创建活动、查询未命中、状态变更后 | 状态变更后重写 |
| WINNING_RECORDS_{activityId}_{prizeId} | 该奖品的中奖记录列表 | 2 天 | 抽奖落库后、查询未命中 | 重抽 / 回滚时删除 |
| WINNING_RECORDS_{activityId} | 活动维度全量中奖记录 | 2 天 | 活动所有奖品抽完后 | 回滚时删除 |
读缓存的写法是标准的 Cache-Aside:
public ActivityDetailDTO getActivityDetail(Long activityId) {
ActivityDetailDTO dto = getActivityFromCache(activityId); // ① 先查缓存
if (dto != null) return dto;
// ② 未命中:查三张表 + 奖品表,组装
…
cacheActivity(dto); // ③ 回填缓存
return dto;
}
抽奖页每次刷新都要渲染奖品和人员,抽奖过程中还要反复读中奖记录——这些数据"读多写极少",正是缓存的理想场景。活动详情缓存 3 天,是因为活动的奖品和人员创建后基本不变;中奖记录缓存 2 天,是因为抽奖可能跨天进行。
缓存与数据库的一致性:更新数据库后,ActivityStatusManager 会调用 cacheActivity(activityId) 重写缓存(而不是删除),这样下一个请求直接命中新值,不会出现"删了缓存、并发读又塞回旧值"的窗口。
6.7 中奖通知:线程池 + 邮件 / 短信
发邮件是典型的"耗时且可能失败"的操作,绝不能放在事务里。项目里用自定义线程池异步执行:
// common/config/ExecutorConfig.java
@Bean(name = "asyncServiceExecutor")
public ThreadPoolTaskExecutor asyncServiceExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(corePoolSize); // 10
executor.setMaxPoolSize(maxPoolSize); // 20
executor.setQueueCapacity(queueCapacity); // 20
executor.setKeepAliveSeconds(3);
executor.setThreadNamePrefix(namePrefix); // async-service-
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.initialize();
return executor;
}
// MqReceiver#syncExecute
private void syncExecute(List<WinningRecordDO> records) {
threadPoolExecutor.execute(() -> sendMail(records)); // 有多少中奖者就发多少封
}
private void sendMail(List<WinningRecordDO> records) {
for (WinningRecordDO r : records) {
String context = "Hi," + r.getWinnerName() + "。恭喜你在" + r.getActivityName()
+ "活动中获得" + ActivityPrizeTiersEnum.forName(r.getPrizeTier()).getMessage()
+ ":" + r.getPrizeName() + "。获奖时间为" + DateUtil.formatTime(r.getWinningTime())
+ ",请尽快领取您的奖励!";
mailUtil.sendSampleMail(r.getWinnerEmail(), "中奖通知", context);
}
}
邮件配置里还特意加了三个超时(5s),避免线程池里的任务被 SMTP 挂死:
spring.mail.host=smtp.qq.com
spring.mail.port=465
spring.mail.properties.mail.smtp.ssl.enable=true
spring.mail.properties.mail.smtp.connectiontimeout=5000
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.writetimeout=5000
短信通知的扩差点已经留好了:SMSUtil.sendMessage(templateCode, phoneNumbers, templateParam) 封装了阿里云 SDK,把 sendMail 换成"先邮件后短信"(或并行)即可。
7. 设计模式落地清单
这是项目的副标题"多设计模式"的来源——不是为了用而用,而是每一处都对应一个真实的可变点:
| 模板方法 | AbstractActivityOperator + ActivityStatusManagerImpl#handlerEvent | 固定"先子后父、最后刷缓存"的算法骨架,子类只填空 |
| 策略模式 | PrizeOperator / UserOperator / ActivityOperator | 三种状态转换规则可互换、可扩展(以后加"积分账户"只需加一个 Operator) |
| 注册表 / 简单工厂 | @Autowired Map<String, AbstractActivityOperator> | 用 Spring 的集合注入替代 if-else 工厂,新增实现零改动 |
| 建造者 | QueueBuilder.durable(QUEUE_NAME).deadLetterExchange(…).build() | 队列声明参数多,链式构造更清晰 |
| 模板方法(框架) | EncryptTypeHandler extends BaseTypeHandler | 只实现加解密,JDBC 读写细节交给父类 |
| 包装 / 值对象 | Encrypt 包装 String | 让"这个字段要加密"变成类型信息,编译期可见 |
| 门面(Facade) | ActivityService / DrawPrizeService | 对 Controller 暴露粗粒度业务方法,屏蔽 DAO 与缓存细节 |
| 单例 | 所有 @Service / @Component | Spring 默认单例,无状态 Bean 复用 |
| 责任链思想 | LoginInterceptor(MVC 拦截器链)、processConvertStatus 顺序遍历 | 请求先过鉴权,再进业务;实体的转换按顺序逐个处理 |
| 统一返回 + 全局异常 | CommonResult + @RestControllerAdvice | 前端只需要判断 code == 200 |
一个额外的心得:状态机不一定要上状态机框架。三张表的联动用"抽象 Operator + 顺序 + 事务"就能表达得很清楚,还比引入 Spring StateMachine 轻得多。
8. 接口清单
| POST | /register | JSON:name、mail、phoneNumber、password、identity | userId | 注册(管理员需密码) |
| POST | /password/login | JSON:loginName(手机/邮箱)、password、mandatoryIdentity | token、identity | 密码登录 |
| GET | /base-user/find-list | identity(USER/ADMIN) | 用户列表 | 建活动时圈人 |
| POST | /prize/create | multipart:param(JSON)+ prizePic(文件) | prizeId | 创建奖品(带图) |
| POST | /pic/upload | multipart:file | 文件名 | 单图上传 |
| GET | /prize/find-list | currentPage、pageSize | total、records | 奖品分页列表 |
| POST | /activity/create | JSON:activityName、description、activityPrizeList、activityUserList | activityId | 创建活动 |
| GET | /activity/find-list | currentPage、pageSize | total、records | 活动分页列表 |
| GET | /activity-detail/find | activityId | 活动 + 奖品 + 人员 + valid | 抽奖页初始化 |
| POST | /draw-prize | JSON:activityId、prizeId、winningTime、winnerList | true | 抽奖(投递 MQ) |
| POST | /winning-records/show | JSON:activityId、prizeId(可选) | 中奖名单 | 名单查询(放行,可分享) |
统一返回结构:
{ "code": 200, "data": { "userId": 11 }, "msg": "" }
9. 中间件接入实录
9.1 Redis
为什么需要:抽奖时要把"这场活动的奖品 + 人员"整套读出来,每次都 join 四张表太浪费;而且大屏会反复刷新。Redis 把整份 ActivityDetailDTO 序列化存成一个 String,读一次就够。
依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
连接方式(本地连远程服务器):服务器安全组只放行 22 端口时,用 Xshell 建 SSH 隧道,把本地 8888 转到远程 127.0.0.1:6379:
属性 → SSH → 隧道 → 添加
类型:Local 源主机:localhost 侦听端口:8888 目标主机:localhost 目标端口:6379
配置:
spring.data.redis.host=localhost
spring.data.redis.port=8888 # 本地隧道端口
spring.data.redis.timeout=60s
spring.data.redis.lettuce.pool.max-active=8
spring.data.redis.lettuce.pool.max-idle=8
spring.data.redis.lettuce.pool.max-wait=5s
启动:service redis-server start,然后 redis-cli -p 6379 ping 验证。
踩坑:改了 Redis 端口/配置后一定要 Rebuild Project,否则 IDEA 不会重新复制 target/classes 里的配置文件。
9.2 RabbitMQ
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=admin
spring.rabbitmq.password=admin
spring.rabbitmq.listener.simple.acknowledge-mode=auto
交换机 / 队列 / 死信队列的声明都在 DirectRabbitConfig 里(见 6.5),消息体统一用 JSON 转换器:
@Bean
public MessageConverter jsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}
9.3 阿里云短信
项目需求里"中奖后短信通知中奖者"就是靠它实现的。接入步骤:
<!– 阿里云短信服务 –>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.24</version>
</dependency>
sms.access-key-id=${SMS_AK_ID}
sms.access-key-secret=${SMS_AK_SECRET}
sms.sign-name=你的签名名称
smsUtil.sendMessage("SMS_模板CODE", "138xxxxxxxx", "{\\"code\\":\\"123456\\"}");
本项目当前把短信能力封装在 SMSUtil,抽奖通知主链路走邮件;要接短信只需在 MqReceiver#syncExecute 里再加一个任务。
9.4 邮箱(QQ SMTP)
10. 踩坑记录
这些都是开发过程中真实踩过的、并且代码注释里留了"案发现场"的问题:
| 1 | 中奖名单查询"明明有数据却返回空" | 缓存未命中的判断写反了:if (!StringUtils.hasText(str)) return empty; 反而在命中时返回空 | 改成"缓存为空才回退查库" | ||
| 2 | 状态该转的不转、不该转的乱转 | processConvertStatus 里漏了一个 !:if (operator.needConvert(dto)) continue; | 改成 `if (… | !operator.needConvert(dto)) continue;` | |
| 3 | 抽奖失败后消息"消失",没有任何重试日志 | 消费者 catch 住异常没有 throw,MQ 认为消费成功 | catch 里补偿后必须 throw | ||
| 4 | 死信队列和主队列互相回投,日志疯狂刷 | 死信消费者无脑回投,没有次数上限 | 消息体里记 x-dlx-retry-count,超过 3 次停止回投 | ||
| 5 | 改了 HTML/CSS 刷新没效果 | IDEA 对"只改内容"的静态资源不重新复制到 target/classes | Build → Rebuild Project,或删掉 target/classes/static 再启动 | ||
| 6 | 未登录时接口返回一堆 HTML,前端不跳登录 | 后端返回 302 重定向,AJAX 拿不到状态码 | 拦截器直接 response.setStatus(401),前端在 error 回调里跳转 | ||
| 7 | 抽奖页嵌在 iframe 里,登录页只跳了内层 | location.href 只改当前窗口 | 同时设置 window.parent.location.href | ||
| 8 | MQ 消费端 param.getWinnerList() 空指针 | Bean Validation 只在 Controller 生效,MQ 链路没有校验 | 消费端自己判空 + 数量比对 | ||
| 9 | 邮件发送超时把线程池任务卡死 | SMTP 默认没有超时 | 配 connectiontimeout / timeout / writetimeout = 5000 | ||
| 10 | 手机号长度不够存密文 | AES 输出 hex,长度是明文 2 倍 | 字段给到 varchar(255) |
11. 还可以更好
项目目前是"能跑、能演示、结构清晰"的状态,距离生产还有这些差距,也是我接下来的 TODO:
12. 总结
这个项目最有意思的地方,不是"抽奖"本身,而是它把几个经典问题压进了一个不大的代码库里:
- 状态联动:三张表的状态要一起变,用「抽象 Operator + 顺序 + 事务 + 补偿」把它变成了可读、可扩展的代码;
- 异步:抽奖走 MQ,通知走线程池,把"用户等待"和"后台处理"彻底分开;
- 缓存:活动详情、中奖名单都有明确的 Key 设计、TTL 和失效时机;
- 安全:JWT 无状态鉴权,手机号可逆加密、密码不可逆哈希,各取所需。
设计模式在这里不是"炫技",而是三个真实的可变点撑起来的:实体的种类会变(Operator)、处理顺序有约束(sequence)、校验规则各不同(needConvert)。当你发现自己在写一串 if (type == A) … else if (type == B),并且未来还会加 C,那就是该抽象的时候了。
最后,欢迎交流。如果你也在做类似的活动/抽奖系统,或者对"状态机到底该不该上框架"有不同看法,欢迎在评论区聊。
附:项目结构速览
lottery-system/
├── src/main/java/com/example/lotterysystem/
│ ├── common/ # 统一返回、错误码、异常、JWT/Redis/邮件/短信工具、拦截器
│ │ ├── config/ # AppConfig(拦截器)、DirectRabbitConfig(MQ)、ExecutorConfig(线程池)
│ │ ├── interceptor/ # LoginInterceptor
│ │ └── utils/ # JWTUtil、RedisUtil、MailUtil、SMSUtil、JacksonUtils、RegexUtil
│ ├── controller/ # User / Prize / Activity / DrawPrize 四个 Controller
│ │ ├── param/ result/ # 入参 / 出参对象
│ │ └── handler/ # GlobalExceptionHandler
│ ├── service/ # 业务接口与实现、DTO、枚举
│ │ ├── activitystatus/ # 状态扭转:Manager + AbstractOperator + 3 个 Operator
│ │ └── mq/ # MqReceiver、DlxReceiver
│ └── dao/ # DO、Mapper(注解 SQL)、EncryptTypeHandler
├── src/main/resources/
│ ├── static/ # 9 个页面 + css/js/lib/pic
│ ├── application*.properties # 多环境配置
│ └── logback-spring.xml
└── pom.xml


