欢迎光临
我们一直在努力

[项目] --- 基于 Spring Boot + Redis的多设计模式抽奖系统

一个能真正跑起来的在线抽奖平台:管理员建奖品 → 建活动(圈奖品、圈人)→ 现场抽奖 → 异步通知中奖者。 项目里刻意用了几种设计模式来解决"活动 / 奖品 / 人员三方状态联动"这个核心难题,这篇文章把需求、表结构、代码和踩过的坑一次讲清楚。

  • 技术栈:Spring Boot 3.2 + MyBatis + MySQL 8 + Redis + RabbitMQ + JWT + QQ 邮箱
  • 前端:原生 HTML + jQuery + AJAX
  • 代码仓库:https://gitee.com/jinhong-c/java-design-patterns-lottery

目录

  • 项目背景
  • 需求梳理:从"抽个奖"到"可运营的抽奖平台"
  • 系统架构与技术选型
  • 数据库设计
  • 数据加密:AES 可逆加密 + SHA-256 摘要
  • 登录与鉴权:JWT + 拦截器
  • 核心链路拆解
    • 7.1 注册
    • 7.2 创建活动
    • 7.3 抽奖:前端滚动 + 后端异步
    • 7.4 状态扭转:模板方法 + 策略注册表(重点)
    • 7.5 一致性:事务、补偿、重试、死信
    • 7.6 Redis 缓存设计
    • 7.7 中奖通知:线程池 + 邮件 / 短信
  • 设计模式落地清单
  • 接口清单
  • 中间件接入实录(Redis / RabbitMQ / 阿里云短信 / 邮箱)
  • 踩坑记录
  • 还可以更好
  • 总结

  • 1. 项目背景

    随着数字营销的兴起,抽奖几乎是所有 C 端产品的标配:抖音的福袋、拼多多的转盘、公司年会的抽奖箱,本质都是同一件事——在有限的奖品和有限的参与人之间,做一次公平、可追溯、不可撤销的分配。

    但把它做成一个"系统",难点并不在随机数,而在这几件事:

    • 一场活动里有多档奖品(一等奖 / 二等奖 / 三等奖),每档数量不同,只能抽一次;
    • 参与人是运营在后台"圈"进来的,必须保证一个人只能中一次奖;
    • 抽奖是现场大屏操作,点下去就不能反悔,但要能扛住网络抖动、邮件服务超时;
    • 活动、奖品、人员三方状态必须联动:奖品抽完了,参与人就不能再被抽;所有奖品都抽完了,活动才算结束。

    这个项目就是围绕这四件事设计的。前台是给运营用的管理后台,抽奖页是大屏用的"滚动 + 揭晓"界面。

    1.1 功能清单

    模块功能说明
    管理员 注册 / 登录 手机号或邮箱 + 密码登录,管理员注册需强制密码
    人员管理 创建普通用户、人员列表 普通用户由管理员创建,无需密码
    奖品管理 创建奖品、奖品列表 含名称、描述、价格、图片(本地文件上传)
    活动管理 创建活动、活动列表 圈选奖品(数量 / 等级)、圈选人员
    抽奖 抽奖页、中奖名单 逐档抽奖、滚动揭晓、名单导出 CSV、结果分享
    通知 中奖邮件通知 线程池异步发送,短信能力已封装、可扩展
    安全 JWT 鉴权、密码哈希、手机号加密 未登录访问接口一律 401

    1.2 非功能需求(这几个决定了后面的设计)

  • 状态一致性:抽奖一次要改三张表(奖品状态、人员状态、活动状态),要么全成功,要么全失败。
  • 不可重复:同一奖品不能被抽第二次,同一用户不能重复中奖。
  • 响应要快:现场点"开始抽奖"不能等 3 秒,落库、发邮件这些都不能卡住页面。
  • 可追溯:中奖记录要落库,且能按活动 / 奖品维度查回来。

  • 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 图

    数据库 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 几个设计取舍

  • 状态用字符串枚举而不是数字:RUNNING、INIT 比 1、2 可读,出问题时直接看库就能明白;枚举类里再挂 code 做前端展示。
  • 快照字段冗余:winning_record 里同时存 activity_name、prize_name、winner_name,查询名单时不用 join 四张表,也让历史记录不被后续改名污染。
  • 手机号列留 255:AES 加密后是 hex 串,长度是原文的 2 倍,16 字节的明文会变成 32 字符,再留余量。
  • price 用 decimal(10,2):钱不能用 double。

  • 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 这里的几个坑

  • 拦截器返回 401 而不是重定向:因为页面是 AJAX 请求,重定向只会把 HTML 塞进 xhr.responseText,前端拿不到、也不会跳转。让后端回 401,前端自己跳。
  • localStorage 存 token 意味着"JS 可读":有 XSS 就能偷 token。生产环境更推荐 HttpOnly Cookie + CSRF 防护;本项目是内部管理后台,做了取舍。
  • token 只有 1 小时:过期就得重新登录。想做得优雅一点,可以加 refresh token,或在响应头里刷新有效期。

  • 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(); 三行硬编码。但这段逻辑有两个特点:

  • 每种实体"能不能改、怎么改"的规则不一样(活动还要判断"是不是所有奖品都抽完了");
  • 同样的逻辑在回滚时还要反向执行一遍(COMPLETED → INIT)。
  • 所以项目把它抽象成了 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 缓存设计

    KeyValueTTL写入时机失效时机
    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 阿里云短信

    项目需求里"中奖后短信通知中奖者"就是靠它实现的。接入步骤:

  • 创建 AccessKey:阿里云控制台 → 右上角头像 → AccessKey 管理 → 创建 AccessKey(推荐用 RAM 子账号)。AccessKey ID 和 Secret 只显示一次,务必保存好。
  • 开通短信服务:搜索"短信服务" → 快速学习与测试 → 依次完成「申请资质 → 申请签名 → 申请模板 → 系统设置」,签名和模板审核通过后才能发。
  • 加 Maven 依赖:
  • <!– 阿里云短信服务 –>
    <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)

  • QQ 邮箱 → 设置 → 账户 → 开启 IMAP/SMTP 服务 → 生成授权码(不是登录密码);
  • 必须走 465 + SSL,25 端口基本发不出去;
  • 配置见 6.7。

  • 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:

  • 随机性挪到服务端:现在"谁中奖"由前端滚动后 splice 决定,后端只校验数量。公网场景必须改成服务端用 SecureRandom 抽样,前端只负责动画。
  • 幂等:消息体里的 messageId 目前只是打日志用,可以配合 Redis SETNX 做消费幂等,防止重试导致重复中奖。
  • 防并发:同一活动多人同时抽奖,check + update 之间有空窗。可以给 activity_prize 加乐观锁(version)或 update … where status = 'INIT' 判断影响行数。
  • 密钥与配置:AES Key、JWT Secret、数据库密码、邮箱授权码都应该从代码/配置文件里挪到环境变量或配置中心。
  • 密码算法升级:SHA-256 → BCrypt(加盐、慢哈希)。
  • 接口文档:补 Knife4j / OpenAPI,让前端不必翻 Controller。
  • 测试:目前 src/test 下只有手写的验证类(RedisTest、MailTest、DrawPrizeTest 等),应该补 MockMvc 集成测试覆盖"抽奖 → 状态流转 → 回滚"。
  • 列表分页:limit offset, size 在深分页时性能差,数据量大后改游标分页。

  • 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

    赞(0)
    未经允许不得转载:171主机测评 » [项目] --- 基于 Spring Boot + Redis的多设计模式抽奖系统
    分享到: 更多 (0)

    评论 抢沙发

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