抽奖系统详解

一、项目介绍

1.1 背景

现在数字营销越来越火,企业都想通过在线活动吸引和留住客户。抽奖作为有效的营销手段,能大幅提升用户参与度和品牌曝光。为此,我们开发了这个基于 SpringBoot 的抽奖系统,目标是打造一个全面、可靠、好维护的抽奖平台,主要特点如下:

  • 整合多种技术:用 MySQL、Redis、RabbitMQ 等常用组件,确保系统稳定、高效、可扩展;
  • 全流程管理:管理员可创建配置抽奖活动、管理奖品和人员信息;
  • 状态精准控制:通过设计状态机,严格管理活动和奖品的状态转换,让系统更可控;
  • 数据可靠:用事务管理和数据同步机制,保证数据一致、完整;
  • 安全有保障:做了数据加密、用户认证等安全措施,保护用户数据和系统;
  • 好维护易扩展:有完善的日志和异常处理,方便排查问题;模块化设计加设计模式,提升灵活性和扩展性。

这个系统是分布式抽奖解决方案,基于 Java 技术栈,能为各类营销活动提供高效、公平、可扩展的抽奖功能。支持限时抽奖、积分兑换抽奖等多种活动形式,可灵活配置奖品池、中奖概率、参与限制等规则。通过缓存和异步处理,能应对高并发场景。

核心流程:用户发起抽奖请求后,系统先校验活动状态和用户资格,用 Redis 快速查库存和频率限制;通过后,把抽奖任务放进 RabbitMQ 队列,消费端异步执行抽奖算法,更新库存和中奖记录,同步缓存结果;最后用邮件或短信通知用户,后台还能实时监控活动数据和中奖统计。

1.2 需求分析

目标用户
  • 管理人员:负责奖品、人员、活动的创建,以及抽奖流程的管理;
  • 普通用户:可参与抽奖、查看中奖名单。
需求描述
  1. 管理员注册与登录

    • 注册信息:姓名、邮箱、手机号、密码;
    • 登录方式:手机号 + 密码登录,登录时需验证管理员身份。
  2. 人员管理

    • 管理员可创建普通用户(填姓名、邮箱、手机号);
    • 人员列表展示:人员 ID、姓名、身份(普通用户 / 管理员)。
  3. 奖品管理

    • 管理员可创建奖品(填名称、描述、价格,支持上传奖品图);
    • 奖品列表分页展示:奖品 ID、图片、名称、描述、价值(元)。
  4. 活动管理

    • 创建活动需填:名称、描述;选择奖品(设等级和数量);选择参与人员;
    • 活动列表分页展示,含活动名称、描述、状态:
      • 进行中:可点击 “活动进行中,去抽奖” 跳转到抽奖页;
      • 已完成:可点击 “活动已完成,查看中奖名单” 跳转到结果页。
  5. 抽奖页面

    • 仅管理员能对进行中的活动执行抽奖;
    • 每轮中奖人数和当前奖品数量一致,且每人只能中一次奖;
    • 多轮抽奖分 3 个环节:
      1. 展示奖品信息(图片、份数),点 “开始抽奖” 进入人名闪动页;
      2. 人名闪动时,点 “点我确定” 生成中奖名单;
      3. 展示中奖名单,点 “已抽完,下一步”:若有未抽奖品,展示下一个;否则展示全部中奖名单;点 “查看上一奖项” 可回看之前的奖品;
    • 异常处理:刷新页面后,若奖品已抽完,点 “开始抽奖” 直接展示该奖品的中奖名单;
    • 活动完成后:
      1. 展示所有奖项的中奖名单;
      2. 有 “分享结果” 按钮,点击可复制链接,打开链接后只显示活动名称、中奖结果和 “分享结果” 按钮。
  6. 登录限制

    • 管理端所有页面(包括抽奖页)都需管理员登录后才能访问,未登录自动跳转到登录页。

1.3 系统架构

  • 前端:用 JavaScript 实现界面动态效果,通过 AJAX 从后端接口拿数据;
  • 后端:基于 SpringBoot3 开发,处理业务逻辑;
  • 数据库:MySQL 存用户数据和活动信息;
  • 缓存:Redis 减少数据库访问,提升速度;
  • 消息队列:RabbitMQ 处理异步任务(如抽奖操作);
  • 日志与安全:用 JWT 做用户认证,SLF4J+logback 记录日志。

1.4 业务功能模块

  • 人员业务模块:管理员注册、登录;普通用户创建;
  • 奖品业务模块:奖品的管理和分配(含图片上传);
  • 活动业务模块:活动的创建、管理和状态控制;
  • 抽奖业务模块:执行抽奖流程,展示抽奖结果。

1.5 数据库设计

系统有 6 张核心表,建表时建议用source命令导入.sql 文件(如mysql> source D:\lottery_system.sql)。

  • 用户表(user):存用户信息,包括用户名、密码、邮箱、手机号、身份(管理员 / 普通用户);
  • 奖品表(prize):存奖品信息,包括名称、描述、价格、图片链接;
  • 活动表(activity):存活动信息,包括名称、描述、状态;
  • 活动奖品表(activity_prize):记录活动关联的奖品,含活动 ID、奖品 ID、数量、等级、状态;
  • 活动人员表(activity_user):记录活动关联的参与人员,含活动 ID、用户 ID、用户名、状态;
  • 中奖记录表(winning_record):存中奖信息,含活动 ID、奖品 ID、中奖人信息、中奖时间等。
    -- 设置字符集为utf8mb4(支持所有Unicode字符)
    SET NAMES utf8mb4;
    -- 关闭外键约束检查(避免建表时冲突)
    SET FOREIGN_KEY_CHECKS = 0;
    
    -- 创建数据库
    drop database IF EXISTS `lottery_system`;
    create DATABASE `lottery_system` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
    USE `lottery_system`;
    
    -- 活动表
    drop table IF EXISTS `activity`;
    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) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '活动名称',
      `description` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '活动描述',
      `status` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '活动状态',
      PRIMARY KEY (`id`) USING BTREE,
      UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE
    ) ENGINE = InnoDB AUTO_INCREMENT = 24 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = DYNAMIC;
    
    -- 活动奖品表
    drop table IF EXISTS `activity_prize`;
    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(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '奖品等级',
      `status` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '状态',
      PRIMARY KEY (`id`) USING BTREE,
      UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE,
      UNIQUE INDEX `uk_a_p_id`(`activity_id` ASC, `prize_id` ASC) USING BTREE,
      INDEX `idx_activity_id`(`activity_id` ASC) USING BTREE
    ) ENGINE = InnoDB AUTO_INCREMENT = 32 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = DYNAMIC;
    
    -- 活动人员表
    drop table IF EXISTS `activity_user`;
    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) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '用户名',
      `status` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '状态',
      PRIMARY KEY (`id`) USING BTREE,
      UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE,
      UNIQUE INDEX `uk_a_u_id`(`activity_id` ASC, `user_id` ASC) USING BTREE,
      INDEX `idx_activity_id`(`activity_id` ASC) USING BTREE
    ) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = DYNAMIC;
    
    -- 奖品表
    drop table IF EXISTS `prize`;
    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) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '奖品名称',
      `description` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL comment '奖品描述',
      `price` decimal(10, 2) NOT NULL comment '奖品价值',
      `image_url` varchar(2048) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL comment '奖品图',
      PRIMARY KEY (`id`) USING BTREE,
      UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE
    ) ENGINE = InnoDB AUTO_INCREMENT = 18 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = DYNAMIC;
    
    -- 用户表
    drop table IF EXISTS `user`;
    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) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '姓名',
      `email` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '邮箱',
      `phone_number` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '手机号',
      `password` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL comment '密码',
      `identity` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '身份',
      PRIMARY KEY (`id`) USING BTREE,
      UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE,
      UNIQUE INDEX `uk_email`(`email`(30) ASC) USING BTREE,
      UNIQUE INDEX `uk_phone_number`(`phone_number`(11) ASC) USING BTREE
    ) ENGINE = InnoDB AUTO_INCREMENT = 39 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = DYNAMIC;
    
    -- 中奖记录表
    drop table IF EXISTS `winning_record`;
    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) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '活动名称',
      `prize_id` bigint NOT NULL comment '奖品id',
      `prize_name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '奖品名称',
      `prize_tier` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '奖品等级',
      `winner_id` bigint NOT NULL comment '中奖人id',
      `winner_name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '中奖人姓名',
      `winner_email` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '中奖人邮箱',
      `winner_phone_number` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL comment '中奖人电话',
      `winning_time` datetime NOT NULL comment '中奖时间',
      PRIMARY KEY (`id`) USING BTREE,
      UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE,
      UNIQUE INDEX `uk_w_a_p_id`(`winner_id` ASC, `activity_id` ASC, `prize_id` ASC) USING BTREE,
      INDEX `idx_activity_id`(`activity_id` ASC) USING BTREE
    ) ENGINE = InnoDB AUTO_INCREMENT = 69 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = DYNAMIC;
    
    -- 开启外键约束检查
    SET FOREIGN_KEY_CHECKS = 1;

    1.6 安全设计

  • 用户登录用 JWT 验证身份,部分页面强制登录后才能访问;
  • 敏感信息(如手机号、密码)入库前需加密。
  • 错误码的好处

  • 明确问题所在;
  • 数字形式便于日志检索;
  • 方便分类错误(如用户模块、奖品模块的错误)。
  • 二、功能模块设计

    2.1 错误码

  • 错误码的好处

  • 明确问题所在。

  • 数字形式便于日志检索;

  • 方便分类错误(如用户模块、奖品模块的错误)。
    package com.example.lotterysystem.common.errorcode;
    
    import lombok.Data;
    
    @Data
    public class ErrorCode {
        /** 错误码 */
        private Integer code;
        /** 错误信息 */
        private String msg;
    
        public ErrorCode(Integer code, String msg) {
            this.code = code;
            this.msg = msg;
        }
    }

    全局错误码(放通用层,处理未考虑到的错误):

    package com.example.lotterysystem.common.errorcode;
    
    public interface GlobalErrorCodeConstants {
        ErrorCode SUCCESS = new ErrorCode(200, "成功");
        ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500, "系统异常");
        ErrorCode UNKNOWN = new ErrorCode(999, "未知错误");
    }

    2.2 自定义异常类

    按控制层、服务层分别设计异常类,结合错误码明确异常信息。

     

    服务层异常类示例:

    package com.example.lotterysystem.common.exception;
    
    import com.example.lotterysystem.common.errorcode.ErrorCode;
    import lombok.Data;
    import lombok.EqualsAndHashCode;
    
    // Data自动生成equals和hashCode;callSuper = true表示不调用父类的equals和hashCode
    @Data
    @EqualsAndHashCode(callSuper = true)
    public class ServiceException extends  RuntimeException{
        // 异常码
        private  Integer code;
        // 异常信息
        private  String message;
    
        /** 空构造方法,避免反序列化问题 */
        public ServiceException() {
        }
    
        public ServiceException(Integer code, String message) {
            this.code = code;
            this.message = message;
        }
    
        public ServiceException(ErrorCode errorCode) {
            this.code = errorCode.getCode();
            this.message = errorCode.getMsg();
        }
    }

    2.3 统一结果返回格式(CommonResult<T>)

    好处

  • 规范接口响应格式,降低前后端对接成本;
  • 前端可通过状态码快速判断请求结果(成功 / 失败);
  • 便于问题排查(可包含请求 ID、时间戳等);
  • 保持系统各模块返回风格一致,易维护。
    package com.example.lotterysystem.common.pojo;
    
    import com.example.lotterysystem.common.errorcode.ErrorCode;
    import com.example.lotterysystem.common.errorcode.GlobalErrorCodeConstants;
    import lombok.Data;
    import org.springframework.util.Assert;
    
    import java.io.Serializable;
    
    @Data
    public class CommonResult<T> implements Serializable {
        /** 状态码 */
        private Integer code;
        /** 返回数据 */
        private T data;
        /** 错误信息 */
        private String msg;
    
        /** 创建成功的返回实例 */
        public static <T> CommonResult<T> success(T data) {
            CommonResult<T> result = new CommonResult<>();
            result.code = GlobalErrorCodeConstants.SUCCESS.getCode();
            result.data = data;
            result.msg = "";
            return result;
        }
    
        /** 创建错误的返回实例(通过状态码和信息) */
        public static <T> CommonResult<T> error(Integer code, String msg) {
            // 断言:如果code是200(成功码),则报错
            Assert.isTrue(!GlobalErrorCodeConstants.SUCCESS.getCode().equals(code), "code 不是错误码");
            CommonResult<T> result = new CommonResult<>();
            result.code = code;
            result.msg = msg;
            return result;
        }
    
        /** 创建错误的返回实例(通过错误码对象) */
        public static <T> CommonResult<T> error(ErrorCode errorCode) {
            return error(errorCode.getCode(), errorCode.getMsg());
        }
    }

    2.4 拦截器

    用于拦截请求,验证用户登录状态(如未登录,跳转到登录页)。

     

    定义拦截器

    package com.example.lotterysystem.common.interceptor;
    
    import com.example.lotterysystem.common.utils.JwtUtil;
    import io.jsonwebtoken.Claims;
    import jakarta.servlet.http.HttpServletRequest;
    import jakarta.servlet.http.HttpServletResponse;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.stereotype.Component;
    import org.springframework.web.servlet.HandlerInterceptor;
    
    @Component
    /** 登录拦截器 */
    public class LoginInterceptor implements HandlerInterceptor {
        // 日志对象
        private final static Logger logger = LoggerFactory.getLogger(LoginInterceptor.class);
    
        /** 请求处理前执行拦截 */
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            // 获取请求头中的token
            String token = request.getHeader("user_token");
            logger.info("获取token:{}", token);
            logger.info("请求路径:{}", request.getRequestURI());
    
            // 解析token
            Claims claims = JwtUtil.parseToken(token);
            if (claims == null) {
                logger.error("JWT令牌解析失败");
                // 解析失败,跳转到登录页
                response.sendRedirect(request.getContextPath() + "/blogin.html");
                return false;
            }
    
            logger.info("JWT令牌解析成功,放行");
            return true;
        }
    }

    2.5 Jackson 工具类

    用于对象和字符串的相互转换(序列化 / 反序列化),适用于日志打印、Redis 和 RabbitMQ 数据处理等场景。

    package com.example.lotterysystem.common.utils;
    
    import com.fasterxml.jackson.core.JacksonException;
    import com.fasterxml.jackson.databind.JavaType;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import org.springframework.boot.json.JsonParseException;
    
    import java.util.List;
    import java.util.concurrent.Callable;
    
    /** 序列化工具类 */
    public class JacksonUtil {
    
        private JacksonUtil() {
        }
    
        /** 单例的ObjectMapper */
        private final static ObjectMapper OBJECT_MAPPER;
        static {
            OBJECT_MAPPER = new ObjectMapper();
        }
    
        private static ObjectMapper getObjectMapper() {
            return OBJECT_MAPPER;
        }
    
        // 包装解析方法,处理异常
        private static <T> T tryParse(Callable<T> parser) {
            return tryParse(parser, JacksonException.class);
        }
    
        // 参考Spring Boot源码的异常处理方式
        private static <T> T tryParse(Callable<T> parser, Class<? extends Exception> check) {
            try {
                return parser.call();
            } catch (Exception var4) {
                Exception ex = var4;
                if (check.isAssignableFrom(ex.getClass())) {
                    throw new JsonParseException(ex);
                }
                throw new IllegalStateException(ex);
            }
        }
    
        /** 序列化:对象转字符串 */
        public static String writeValueAsString(Object object) {
            return JacksonUtil.tryParse(() -> {
                return JacksonUtil.getObjectMapper().writeValueAsString(object);
            });
        }
    
        /** 反序列化:字符串转对象 */
        public static <T> T readValue(String content, Class<T> valueType) {
            return JacksonUtil.tryParse(() -> {
                return JacksonUtil.getObjectMapper().readValue(content, valueType);
            });
        }
    
        /** 反序列化:字符串转List */
        public static <T> T readListValue(String content, Class<?> paramClasses) {
            JavaType javaType = JacksonUtil.getObjectMapper().getTypeFactory()
                    .constructParametricType(List.class, paramClasses);
            return JacksonUtil.tryParse(() -> {
                return JacksonUtil.getObjectMapper().readValue(content, javaType);
            });
        }
    }

    2.6 日志配置

    用 SLF4J+logback,本地环境(dev)日志打印到控制台,服务器环境(prod/test)日志存到文件。

     

    application.properties 配置

    ## 日志配置文件路径
    logging.config=classpath:logback-spring.xml

    logback-spring.xml 配置

    <?xml version="1.0" encoding="UTF-8"?>
    <configuration  scan="true" scanPeriod="60 seconds" debug="false">
        <!-- 本地环境(dev):日志输出到控制台 -->
        <springProfile name="dev">
            <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
                <encoder>
                    <pattern>%d{HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n%ex</pattern>
                </encoder>
            </appender>
            <root level="info">
                <appender-ref ref="console" />
            </root>
        </springProfile>
    
        <!-- 服务器环境(prod/test):日志存到文件 -->
        <springProfile name="prod,test">
            <!-- 日志目录:ERROR级放error目录,INFO级放info目录 -->
            <property name="logback.logErrorDir" value="/root/lottery-system/logs/error"/>
            <property name="logback.logInfoDir" value="/root/lottery-system/logs/info"/>
            <property name="logback.appName" value="lotterySystem"/>
            <contextName>${logback.appName}</contextName>
    
            <!-- ERROR级日志配置 -->
            <appender name="fileErrorLog" class="ch.qos.logback.core.rolling.RollingFileAppender">
                <!-- 当天日志文件名 -->
                <File>${logback.logErrorDir}/error.log</File>
                <!-- 只记录ERROR级日志 -->
                <filter class="ch.qos.logback.classic.filter.LevelFilter">
                    <level>ERROR</level>
                    <onMatch>ACCEPT</onMatch>
                    <onMismatch>DENY</onMismatch>
                </filter>
                <!-- 按时间滚动,每天一个日志文件 -->
                <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
                    <FileNamePattern>${logback.logErrorDir}/error.%d{yyyy-MM-dd}.log</FileNamePattern>
                    <maxHistory>14</maxHistory> <!-- 保留14天日志 -->
                </rollingPolicy>
                <!-- 日志格式 -->
                <encoder>
                    <charset>UTF-8</charset>
                    <pattern>%d [%thread] %-5level %logger{36} %line - %msg%n%ex</pattern>
                </encoder>
            </appender>
    
            <!-- INFO级日志配置 -->
            <appender name="fileInfoLog" class="ch.qos.logback.core.rolling.RollingFileAppender">
                <!-- 当天日志文件名 -->
                <File>${logback.logInfoDir}/info.log</File>
                <!-- 只记录INFO级日志(自定义过滤器) -->
                <filter class="com.example.lotterysystem.common.filter.InfoLevelFilter"/>
                <!-- 按时间滚动,每天一个日志文件 -->
                <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
                    <FileNamePattern>${logback.logInfoDir}/info.%d{yyyy-MM-dd}.log</FileNamePattern>
                    <maxHistory>14</maxHistory> <!-- 保留14天日志 -->
                </rollingPolicy>
                <!-- 日志格式 -->
                <encoder>
                    <charset>UTF-8</charset>
                    <pattern>%d [%thread] %-5level %logger{36} %line - %msg%n%ex</pattern>
                </encoder>
            </appender>
    
            <root level="info">
                <appender-ref ref="fileErrorLog" />
                <appender-ref ref="fileInfoLog"/>
            </root>
        </springProfile>
    </configuration>

    自定义 INFO 级别日志过滤器

    public class InfoLevelFilter extends Filter<ILoggingEvent> {
        // 只接受INFO级别的日志
        @Override
        public FilterReply decide(ILoggingEvent iLoggingEvent) {
            if (iLoggingEvent.getLevel().toInt() == Level.INFO.toInt()){
                return FilterReply.ACCEPT;
            }
            return FilterReply.DENY;
        }
    }

    三、人员模块设计说明

    人员模块主要实现用户的注册、登录及相关管理功能,包含管理员和普通用户两种身份的处理,同时通过加密、校验等机制保障数据安全。

    3.1 注册功能

    3.1.1 敏感字段加密处理

    用户注册时的密码、手机号等敏感信息需加密存储,避免明文泄露:

  • 密码加密:采用加盐哈希法。注册时生成随机盐值,将密码与盐值拼接后用 SHA-256 算法哈希,最终存储哈希结果和盐值;
  • 手机号加密:使用 Hutool 工具类库(封装了加密、转码等功能)的 AES 算法加密。
  • 引入 Hutool 依赖(Maven):

    <!-- Hutool工具类库 -->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.25</version>
    </dependency>

    3.1.2 注册接口(Controller 层)

    接收注册请求,调用服务层处理并返回结果:

    package com.example.lotterysystem.controller;
    
    import com.example.lotterysystem.common.pojo.CommonResult;
    import com.example.lotterysystem.common.utils.JacksonUtil;
    import com.example.lotterysystem.controller.param.UserRegisterParam;
    import com.example.lotterysystem.controller.result.UserRegisterResult;
    import com.example.lotterysystem.service.UserService;
    import com.example.lotterysystem.service.dto.UserRegisterDTO;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.validation.annotation.Validated;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    @Slf4j
    public class UserController {
    
        @Autowired
        private UserService userService;
    
        /**
         * 用户注册接口
         */
        @RequestMapping("/register")
        public CommonResult<UserRegisterResult> userRegister(@Validated @RequestBody UserRegisterParam param) {
            log.info("用户注册,参数:{}", JacksonUtil.writeValueAsString(param));
            UserRegisterDTO registerDTO = userService.register(param);
            return CommonResult.success(convertToRegisterResult(registerDTO));
        }
    
        // 转换DTO为返回结果
        private UserRegisterResult convertToRegisterResult(UserRegisterDTO dto) {
            UserRegisterResult result = new UserRegisterResult();
            result.setUserId(dto.getUserId());
            return result;
        }
    }

    3.1.3 注册参数(UserRegisterParam)

    定义注册所需参数及校验规则:

    package com.example.lotterysystem.controller.param;
    
    import jakarta.validation.constraints.NotBlank;
    import lombok.Data;
    import java.io.Serializable;
    
    @Data
    public class UserRegisterParam implements Serializable {
        @NotBlank(message = "姓名不能为空")
        private String name; // 姓名
    
        @NotBlank(message = "邮箱不能为空")
        private String mail; // 邮箱
    
        @NotBlank(message = "手机号不能为空")
        private String phoneNumber; // 手机号
    
        private String password; // 密码(普通用户可不填,管理员必填)
    
        @NotBlank(message = "身份信息不能为空")
        private String identity; // 身份(管理员/普通用户)
    }

    3.1.4 用户身份枚举(UserIdentityEnum)

    统一管理用户身份类型:

    package com.example.lotterysystem.service.enums;
    
    import lombok.Getter;
    
    @Getter
    public enum UserIdentityEnum {
        ADMIN("管理员"), // 管理员身份
        NORMAL("普通用户"); // 普通用户身份
    
        private final String message;
    
        UserIdentityEnum(String message) {
            this.message = message;
        }
    
        // 根据名称获取枚举(忽略大小写)
        public static UserIdentityEnum forName(String name) {
            for (UserIdentityEnum identity : values()) {
                if (identity.name().equalsIgnoreCase(name)) {
                    return identity;
                }
            }
            return null;
        }
    }

    3.1.5 注册返回结果(UserRegisterResult)

    注册成功后返回的数据格式:

    package com.example.lotterysystem.controller.result;
    
    import lombok.Data;
    import java.io.Serializable;
    
    @Data
    public class UserRegisterResult implements Serializable {
        private Long userId; // 注册成功的用户ID
    }

    3.1.6 参数校验(Validation)

    使用 SpringBoot 的 Validation 组件校验入参,需引入依赖:

    <!-- 参数校验依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    3.1.7 服务层接口(UserService)

    定义注册业务逻辑接口:

    package com.example.lotterysystem.service;
    
    import com.example.lotterysystem.controller.param.UserRegisterParam;
    import com.example.lotterysystem.service.dto.UserRegisterDTO;
    
    public interface UserService {
        /**
         * 用户注册
         * @param param 注册参数
         * @return 注册结果DTO
         */
        UserRegisterDTO register(UserRegisterParam param);
    }

    3.1.8 注册业务实现(UserServiceImpl)

    实现注册逻辑,包括参数校验、加密、数据存储:

    package com.example.lotterysystem.service.impl;
    
    import cn.hutool.crypto.digest.DigestUtil;
    import com.example.lotterysystem.common.utils.RegexUtil;
    import com.example.lotterysystem.controller.param.UserRegisterParam;
    import com.example.lotterysystem.service.UserService;
    import com.example.lotterysystem.service.dto.UserRegisterDTO;
    import com.example.lotterysystem.service.enums.UserIdentityEnum;
    import com.example.lotterysystem.service.exception.ServiceException;
    import com.example.lotterysystem.service.exception.ServiceErrorCodeConstants;
    import com.example.lotterysystem.service.mapper.UserMapper;
    import com.example.lotterysystem.service.model.UserDO;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    import org.springframework.util.StringUtils;
    
    @Service
    @Slf4j
    public class UserServiceImpl implements UserService {
    
        @Autowired
        private UserMapper userMapper;
    
        @Override
        public UserRegisterDTO register(UserRegisterParam param) {
            // 1. 校验注册信息
            checkRegisterInfo(param);
    
            // 2. 加密敏感数据,构造数据库对象
            UserDO userDO = new UserDO();
            userDO.setUserName(param.getName());
            userDO.setEmail(param.getMail());
            userDO.setPhoneNumber(new Encrypt(param.getPhoneNumber())); // 加密手机号
            userDO.setIdentity(param.getIdentity());
            // 管理员密码加密
            if (StringUtils.hasText(param.getPassword())) {
                userDO.setPassword(DigestUtil.sha256Hex(param.getPassword()));
            }
    
            // 3. 保存到数据库
            userMapper.insert(userDO);
    
            // 4. 构造返回结果
            UserRegisterDTO result = new UserRegisterDTO();
            result.setUserId(userDO.getId());
            return result;
        }
    
        // 校验注册信息
        private void checkRegisterInfo(UserRegisterParam param) {
            if (param == null) {
                throw new ServiceException(ServiceErrorCodeConstants.REGISTER_INFO_IS_EMPTY);
            }
    
            // 校验邮箱格式
            if (!RegexUtil.checkMail(param.getMail())) {
                throw new ServiceException(ServiceErrorCodeConstants.MAIL_ERROR);
            }
    
            // 校验手机号格式
            if (!RegexUtil.checkMobile(param.getPhoneNumber())) {
                throw new ServiceException(ServiceErrorCodeConstants.PHONE_NUMBER_ERROR);
            }
    
            // 校验身份合法性
            if (UserIdentityEnum.forName(param.getIdentity()) == null) {
                throw new ServiceException(ServiceErrorCodeConstants.IDENTITY_ERROR);
            }
    
            // 管理员必须填密码
            if (param.getIdentity().equalsIgnoreCase(UserIdentityEnum.ADMIN.name()) 
                    && !StringUtils.hasText(param.getPassword())) {
                throw new ServiceException(ServiceErrorCodeConstants.PASSWORD_IS_EMPTY);
            }
    
            // 密码长度校验
            if (StringUtils.hasText(param.getPassword()) && !RegexUtil.checkPassword(param.getPassword())) {
                throw new ServiceException(ServiceErrorCodeConstants.PASSWORD_ERROR);
            }
    
            // 校验邮箱唯一性
            if (checkMailUsed(param.getMail())) {
                throw new ServiceException(ServiceErrorCodeConstants.MAIL_USED);
            }
    
            // 校验手机号唯一性
            if (checkPhoneUsed(param.getPhoneNumber())) {
                throw new ServiceException(ServiceErrorCodeConstants.PHONE_NUMBER_USED);
            }
        }
    
        // 检查邮箱是否已注册
        private boolean checkMailUsed(String mail) {
            return userMapper.countByMail(mail) > 0;
        }
    
        // 检查手机号是否已注册
        private boolean checkPhoneUsed(String phoneNumber) {
            return userMapper.countByPhone(new Encrypt(phoneNumber)) > 0;
        }
    }

    3.1.9 校验工具类(RegexUtil)

    封装格式校验方法(邮箱、手机号、密码等):

    package com.example.lotterysystem.common.utils;
    
    import org.springframework.util.StringUtils;
    import java.util.regex.Pattern;
    
    public class RegexUtil {
        // 校验邮箱格式
        public static boolean checkMail(String content) {
            if (!StringUtils.hasText(content)) {
                return false;
            }
            String regex = "^[a-z0-9]+([._\\\\-]*[a-z0-9])*@([a-z0-9]+[-a-z0-9]*[a-z0-9]+.){1,63}[a-z0-9]+$";
            return Pattern.matches(regex, content);
        }
    
        // 校验手机号格式(1开头的11位数字)
        public static boolean checkMobile(String content) {
            if (!StringUtils.hasText(content)) {
                return false;
            }
            String regex = "^1[3|4|5|6|7|8|9][0-9]{9}$";
            return Pattern.matches(regex, content);
        }
    
        // 校验密码格式(6-12位数字或字母)
        public static boolean checkPassword(String content) {
            if (!StringUtils.hasText(content)) {
                return false;
            }
            String regex = "^[0-9A-Za-z]{6,12}$";
            return Pattern.matches(regex, content);
        }
    }

    3.1.10 全局异常处理

    统一处理异常,返回规范格式:

    package com.example.lotterysystem.controller.handler;
    
    import com.example.lotterysystem.common.errorcode.GlobalErrorCodeConstants;
    import com.example.lotterysystem.common.exception.ControllerException;
    import com.example.lotterysystem.common.exception.ServiceException;
    import com.example.lotterysystem.common.pojo.CommonResult;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    import org.springframework.web.bind.annotation.RestControllerAdvice;
    
    @RestControllerAdvice // 全局异常处理
    @Slf4j
    public class GlobalExceptionHandler {
    
        // 处理服务层异常
        @ExceptionHandler(ServiceException.class)
        public CommonResult<?> handleServiceException(ServiceException e) {
            log.error("服务异常:", e);
            return CommonResult.error(e.getCode(), e.getMessage());
        }
    
        // 处理控制层异常
        @ExceptionHandler(ControllerException.class)
        public CommonResult<?> handleControllerException(ControllerException e) {
            log.error("控制层异常:", e);
            return CommonResult.error(e.getCode(), e.getMessage());
        }
    
        // 处理其他未知异常
        @ExceptionHandler(Exception.class)
        public CommonResult<?> handleException(Exception e) {
            log.error("系统异常:", e);
            return CommonResult.error(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR);
        }
    }

    3.2 登录功能

    3.2.1 Redis 的使用

    Redis 作为内存数据库,用于缓存活动信息、中奖记录等,提升访问速度并减轻数据库压力。

     

    配置 Redis(application.properties):

    # Redis配置
    spring.data.redis.host=localhost
    spring.data.redis.port=6379
    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.min-idle=0 # 最小空闲连接
    spring.data.redis.lettuce.pool.max-wait=5s # 最大等待时间

    Redis 工具类(封装常用操作):

    package com.example.lotterysystem.common.utils;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Component;
    import org.springframework.util.CollectionUtils;
    import org.springframework.util.StringUtils;
    import java.util.Collection;
    import java.util.concurrent.TimeUnit;
    
    @Component
    public class RedisUtil {
        private static final Logger log = LoggerFactory.getLogger(RedisUtil.class);
    
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        // 存值(无过期时间)
        public boolean set(String key, String value) {
            try {
                stringRedisTemplate.opsForValue().set(key, value);
                return true;
            } catch (Exception e) {
                log.error("Redis存值失败,key:{},value:{}", key, value, e);
                return false;
            }
        }
    
        // 存值(带过期时间,单位秒)
        public boolean set(String key, String value, Long time) {
            try {
                stringRedisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
                return true;
            } catch (Exception e) {
                log.error("Redis存值(带过期)失败,key:{},value:{},time:{}", key, value, time, e);
                return false;
            }
        }
    
        // 取值
        public String get(String key) {
            try {
                return StringUtils.hasText(key) ? stringRedisTemplate.opsForValue().get(key) : null;
            } catch (Exception e) {
                log.error("Redis取值失败,key:{}", key, e);
                return null;
            }
        }
    
        // 删除键
        public boolean del(String... key) {
            try {
                if (key != null && key.length > 0) {
                    if (key.length == 1) {
                        stringRedisTemplate.delete(key[0]);
                    } else {
                        stringRedisTemplate.delete(CollectionUtils.arrayToList(key));
                    }
                }
                return true;
            } catch (Exception e) {
                log.error("Redis删除失败,keys:{}", key, e);
                return false;
            }
        }
    
        // 判断键是否存在
        public boolean hasKey(String key) {
            try {
                return StringUtils.hasText(key) && stringRedisTemplate.hasKey(key);
            } catch (Exception e) {
                log.error("Redis判断键存在失败,key:{}", key, e);
                return false;
            }
        }
    }

    3.2.2 JWT 令牌(登录验证)

    采用 JWT 令牌替代传统 Session,解决集群环境登录状态共享问题。登录成功后生成令牌,后续请求通过令牌验证身份。

     

    JWT 工具类

    package com.example.lotterysystem.common.utils;
    
    import io.jsonwebtoken.Claims;
    import io.jsonwebtoken.Jwts;
    import io.jsonwebtoken.io.Decoders;
    import io.jsonwebtoken.security.Keys;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.util.StringUtils;
    import javax.crypto.SecretKey;
    import java.util.Date;
    import java.util.Map;
    
    @Slf4j
    public class JwtUtil {
        // 密钥(Base64编码)
        private static final String SECRET_STR = "5QGoH4qLyxEw7vAccxo2KHg26iJztJvJlaT9MKTatqI=";
        // 生成加密密钥
        private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET_STR));
        // 令牌过期时间(1天)
        private static final long EXPIRATION = 24 * 60 * 60 * 1000;
    
        /**
         * 生成JWT令牌
         * @param claims 自定义数据(如用户ID、身份)
         * @return 令牌字符串
         */
        public static String genJwtToken(Map<String, Object> claims) {
            String token = Jwts.builder()
                    .setClaims(claims) // 自定义数据
                    .setIssuedAt(new Date()) // 签发时间
                    .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION)) // 过期时间
                    .signWith(SECRET_KEY) // 签名加密
                    .compact();
            log.info("生成JWT令牌:{}", token);
            return token;
        }
    
        /**
         * 解析令牌(验证合法性)
         * @param token 令牌
         * @return 令牌中的数据(null表示验证失败)
         */
        public static Claims parseToken(String token) {
            if (!StringUtils.hasLength(token)) {
                return null;
            }
            try {
                return Jwts.parserBuilder()
                        .setSigningKey(SECRET_KEY)
                        .build()
                        .parseClaimsJws(token)
                        .getBody();
            } catch (Exception e) {
                log.error("JWT令牌解析失败", e);
                return null;
            }
        }
    
        /**
         * 从令牌中获取用户ID
         */
        public static Integer getIdByToken(String token) {
            Claims claims = parseToken(token);
            if (claims != null) {
                return (Integer) claims.get("userId");
            }
            return null;
        }
    }

    3.2.3 登录接口(Controller 层)

    接收登录请求,调用服务层验证,返回令牌等信息:

    package com.example.lotterysystem.controller;
    
    import com.example.lotterysystem.common.pojo.CommonResult;
    import com.example.lotterysystem.common.utils.JacksonUtil;
    import com.example.lotterysystem.controller.param.UserPasswordLoginParam;
    import com.example.lotterysystem.controller.result.UserLoginResult;
    import com.example.lotterysystem.service.UserService;
    import com.example.lotterysystem.service.dto.UserLoginDTO;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.validation.annotation.Validated;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    @Slf4j
    public class UserController {
    
        @Autowired
        private UserService userService;
    
        /**
         * 密码登录接口(手机号/邮箱+密码)
         */
        @RequestMapping("/password/login")
        public CommonResult<UserLoginResult> userPasswordLogin(@Validated @RequestBody UserPasswordLoginParam param) {
            log.info("密码登录,参数:{}", JacksonUtil.writeValueAsString(param));
            UserLoginDTO loginDTO = userService.login(param);
            return CommonResult.success(convertToLoginResult(loginDTO));
        }
    
        // 转换DTO为返回结果
        private UserLoginResult convertToLoginResult(UserLoginDTO dto) {
            if (dto == null) {
                throw new ControllerException(ControllerErrorCodeConstants.LOGIN_ERROR);
            }
            UserLoginResult result = new UserLoginResult();
            result.setToken(dto.getToken());
            result.setIdentity(dto.getIdentity().name());
            return result;
        }
    }

    3.2.4 登录参数(UserLoginParam)

    定义登录所需参数:

    package com.example.lotterysystem.controller.param;
    
    import jakarta.validation.constraints.NotBlank;
    import lombok.Data;
    import lombok.EqualsAndHashCode;
    import java.io.Serializable;
    
    // 基础登录参数
    @Data
    public class UserLoginParam implements Serializable {
        /**
         * 强制指定身份登录(可选,不填则不限制)
         * 取值参考UserIdentityEnum的name
         */
        private String mandatoryIdentity;
    }
    
    // 密码登录参数(继承基础参数)
    @Data
    @EqualsAndHashCode(callSuper = true)
    public class UserPasswordLoginParam extends UserLoginParam {
        @NotBlank(message = "手机或邮箱不能为空!")
        private String loginName; // 登录账号(手机号或邮箱)
    
        @NotBlank(message = "密码不能为空!")
        private String password; // 密码
    }

    3.2.5 登录返回结果(UserLoginResult)

    登录成功后返回的信息:

    package com.example.lotterysystem.controller.result;
    
    import lombok.Data;
    import java.io.Serializable;
    
    @Data
    public class UserLoginResult implements Serializable {
        private String token; // JWT令牌
        private String identity; // 用户身份(管理员/普通用户)
    }

    3.2.6 服务层登录接口(UserService)

    定义登录业务逻辑接口:

    package com.example.lotterysystem.service;
    
    import com.example.lotterysystem.controller.param.UserLoginParam;
    import com.example.lotterysystem.service.dto.UserLoginDTO;
    
    public interface UserService {
        /**
         * 用户登录
         * @param param 登录参数
         * @return 登录结果DTO
         */
        UserLoginDTO login(UserLoginParam param);
    }

    3.2.7 登录 DTO(UserLoginDTO)

    服务层内部传递的登录结果数据:

    package com.example.lotterysystem.service.dto;
    
    import com.example.lotterysystem.service.enums.UserIdentityEnum;
    import lombok.Data;
    
    @Data
    public class UserLoginDTO {
        private String token; // JWT令牌
        private UserIdentityEnum identity; // 用户身份枚举
    }

    3.2.8 登录业务实现(UserServiceImpl)

    实现登录逻辑,包括账号验证、密码校验、令牌生成:

    package com.example.lotterysystem.service.impl;
    
    import cn.hutool.crypto.digest.DigestUtil;
    import com.example.lotterysystem.common.utils.JwtUtil;
    import com.example.lotterysystem.common.utils.RegexUtil;
    import com.example.lotterysystem.controller.param.UserLoginParam;
    import com.example.lotterysystem.controller.param.UserPasswordLoginParam;
    import com.example.lotterysystem.service.UserService;
    import com.example.lotterysystem.service.dto.UserLoginDTO;
    import com.example.lotterysystem.service.enums.UserIdentityEnum;
    import com.example.lotterysystem.service.exception.ServiceException;
    import com.example.lotterysystem.service.exception.ServiceErrorCodeConstants;
    import com.example.lotterysystem.service.mapper.UserMapper;
    import com.example.lotterysystem.service.model.UserDO;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    import org.springframework.util.StringUtils;
    
    import java.util.HashMap;
    import java.util.Map;
    
    @Service
    @Slf4j
    public class UserServiceImpl implements UserService {
    
        @Autowired
        private UserMapper userMapper;
    
        @Override
        public UserLoginDTO login(UserLoginParam param) {
            UserLoginDTO loginDTO;
            // 判断登录类型(此处为密码登录)
            if (param instanceof UserPasswordLoginParam passwordLoginParam) {
                loginDTO = loginByPassword(passwordLoginParam);
            } else {
                throw new ServiceException(ServiceErrorCodeConstants.LOGIN_INFO_NOT_EXIST);
            }
            return loginDTO;
        }
    
        // 密码登录逻辑
        private UserLoginDTO loginByPassword(UserPasswordLoginParam param) {
            UserDO userDO = null;
            String loginName = param.getLoginName();
    
            // 邮箱登录
            if (RegexUtil.checkMail(loginName)) {
                userDO = userMapper.selectByMail(loginName);
            }
            // 手机号登录
            else if (RegexUtil.checkMobile(loginName)) {
                userDO = userMapper.selectByPhone(new Encrypt(loginName));
            } else {
                throw new ServiceException(ServiceErrorCodeConstants.LOGIN_NOT_EXIST);
            }
    
            // 校验用户信息
            if (userDO == null) {
                throw new ServiceException(ServiceErrorCodeConstants.USER_INFO_IS_EMPTY);
            }
            // 校验身份(如指定身份登录)
            if (StringUtils.hasText(param.getMandatoryIdentity()) 
                    && !param.getMandatoryIdentity().equalsIgnoreCase(userDO.getIdentity())) {
                throw new ServiceException(ServiceErrorCodeConstants.IDENTITY_ERROR);
            }
            // 校验密码
            if (!DigestUtil.sha256Hex(param.getPassword()).equals(userDO.getPassword())) {
                throw new ServiceException(ServiceErrorCodeConstants.PASSWORD_ERROR);
            }
    
            // 生成JWT令牌
            Map<String, Object> claims = new HashMap<>();
            claims.put("userId", userDO.getId());
            claims.put("identity", userDO.getIdentity());
            String token = JwtUtil.genJwtToken(claims);
    
            // 构造返回结果
            UserLoginDTO loginDTO = new UserLoginDTO();
            loginDTO.setToken(token);
            loginDTO.setIdentity(UserIdentityEnum.forName(userDO.getIdentity()));
            return loginDTO;
        }
    }

    3.2.9 强制登录(拦截器)

    通过拦截器验证用户登录状态,未登录则跳转至登录页:

    package com.example.lotterysystem.common.interceptor;
    
    import com.example.lotterysystem.common.utils.JwtUtil;
    import io.jsonwebtoken.Claims;
    import jakarta.servlet.http.HttpServletRequest;
    import jakarta.servlet.http.HttpServletResponse;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    import org.springframework.web.servlet.HandlerInterceptor;
    
    @Component
    @Slf4j
    public class LoginInterceptor implements HandlerInterceptor {
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            // 获取请求头中的令牌
            String token = request.getHeader("user_token");
            log.info("请求路径:{},令牌:{}", request.getRequestURI(), token);
    
            // 验证令牌
            Claims claims = JwtUtil.parseToken(token);
            if (claims == null) {
                log.error("令牌无效,跳转至登录页");
                response.sendRedirect(request.getContextPath() + "/login.html");
                return false;
            }
    
            // 令牌有效,放行
            return true;
        }
    }

    注册拦截器(指定拦截路径和排除路径):

    package com.example.lotterysystem.common.config;
    
    import com.example.lotterysystem.common.interceptor.LoginInterceptor;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    import java.util.Arrays;
    import java.util.List;
    
    @Configuration
    public class WebConfig implements WebMvcConfigurer {
    
        @Autowired
        private LoginInterceptor loginInterceptor;
    
        // 不需要拦截的路径(静态资源、登录注册接口等)
        private final List<String> excludePaths = Arrays.asList(
                "/**/*.html", "/css/**", "/js/**", "/pic/**",
                "/register", "/password/login"
        );
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(loginInterceptor)
                    .addPathPatterns("/**") // 拦截所有路径
                    .excludePathPatterns(excludePaths); // 排除不需要拦截的路径
        }
    }

    四、奖品管理模块设计说明

    奖品管理模块主要实现奖品图片上传、奖品创建及奖品列表查询等功能,支持图片存储、奖品信息管理及分页查询等操作。

    4.1 奖品图片上传功能

    4.1.1 配置文件设置

    application.properties中配置图片存储路径及静态资源访问路径,确保上传的图片可通过 HTTP 直接访问:

    # 图片本地存储路径
    pic.local-path=./pic
    # Spring Boot 静态资源访问路径(包含本地图片路径)
    spring.web.resources.static-locations=classpath:/static/,file:${pic.local-path}

    4.1.2 静态资源拦截排除配置

    为避免图片等静态资源被登录拦截器拦截,需在拦截器配置类WebConfig的排除路径中添加图片类型:

    // 不需要拦截的路径(新增图片类型)
    private final List<String> excludePaths = Arrays.asList(
            "/**/*.html", "/css/**", "/js/**", "/pic/**",
            "/register", "/password/login",
            "/*.jpg", "/*.png", "/*.jpeg"  // 允许直接访问图片
    );

    4.1.3 Controller 层:图片上传接口

    提供图片上传接口,接收前端传来的图片文件并调用服务层处理:

    @RestController
    public class PictureController {
        private static final Logger logger = LoggerFactory.getLogger(PictureController.class);
        
        @Autowired
        private PictureService pictureService;
    
        /**
         * 上传奖品图片
         * @param file 图片文件
         * @return 图片存储后的唯一文件名(用于后续访问)
         */
        @PostMapping("/pic/upload")
        public String uploadPicture(@RequestParam("file") MultipartFile file) {
            logger.info("接收图片上传请求,文件名:{}", file.getOriginalFilename());
            return pictureService.savePicture(file);
        }
    }

    4.1.4 Service 层:图片存储逻辑

    定义图片存储接口及实现,负责创建存储目录、生成唯一文件名并保存图片:

    // 接口定义
    public interface PictureService {
        /**
         * 保存上传的图片
         * @param file 图片文件
         * @return 图片存储后的唯一文件名(作为访问索引)
         */
        String savePicture(MultipartFile file);
    }
    
    // 实现类
    @Service
    public class PictureServiceImpl implements PictureService {
        // 从配置文件获取图片存储路径
        @Value("${pic.local-path}")
        private String localPath;
    
        @Override
        public String savePicture(MultipartFile file) {
            // 1. 校验文件是否为空
            if (file.isEmpty()) {
                throw new ServiceException(ServiceErrorCodeConstants.PIC_EMPTY_ERROR);
            }
    
            // 2. 创建存储目录(若不存在)
            File dir = new File(localPath);
            if (!dir.exists()) {
                dir.mkdirs(); // 递归创建目录
            }
    
            // 3. 生成唯一文件名(避免重名覆盖)
            String originalFilename = file.getOriginalFilename();
            assert originalFilename != null : "文件名不能为空";
            String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); // 获取文件后缀(如.jpg)
            String uniqueFilename = UUID.randomUUID() + suffix; // 用UUID生成唯一文件名
    
            // 4. 保存图片到本地目录
            try {
                file.transferTo(new File(localPath + "/" + uniqueFilename)); // 写入文件
                logger.info("图片保存成功,路径:{}", localPath + "/" + uniqueFilename);
            } catch (IOException e) {
                logger.error("图片上传失败", e);
                throw new ServiceException(ServiceErrorCodeConstants.PIC_UPLOAD_ERROR);
            }
    
            return uniqueFilename; // 返回唯一文件名(用于后续访问图片)
        }
    }

    4.2 创建奖品功能

    4.2.1 前后端交互接口约定

    • 请求地址/prize/create(POST 方式)
    • 请求类型multipart/form-data(表单包含 JSON 参数和图片文件)
    • 请求参数
      • param:JSON 格式的奖品基本信息(如{"prizeName":"吹风机","description":"家用吹风机","price":100}
      • prizePic:奖品图片文件
        {
          "code": 200,
          "data": 17,  // 新建奖品的ID
          "msg": "success"
        }

        4.2.2 格式转换器(解决表单混合数据问题)

        由于请求同时包含 JSON 参数和文件,需添加转换器处理multipart/form-data格式的 JSON 参数:

        @Component
        public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
            // 构造器:使用ObjectMapper解析JSON,处理二进制流数据
            public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
                super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
            }
        
            // 禁用写入功能(仅用于解析请求,不处理响应)
            @Override
            public boolean canWrite(Class<?> clazz, MediaType mediaType) {
                return false;
            }
        
            @Override
            public boolean canWrite(Type type, Class<?> clazz, MediaType mediaType) {
                return false;
            }
        
            @Override
            protected boolean canWrite(MediaType mediaType) {
                return false;
            }
        }

        4.2.3 入参实体(CreatePrizeParam)

        定义创建奖品的参数及校验规则:

        @Data
        public class CreatePrizeParam implements Serializable {
            @NotBlank(message = "奖品名称不能为空")
            private String prizeName; // 奖品名称
        
            private String description; // 奖品描述(可选)
        
            @NotNull(message = "奖品价格不能为空")
            private BigDecimal price; // 奖品价格
        }

        4.2.4 Controller 层:创建奖品接口

        接收表单数据(包含奖品信息和图片),调用服务层创建奖品:

        @RestController
        public class PrizeController {
            private static final Logger logger = LoggerFactory.getLogger(PrizeController.class);
        
            @Autowired
            private PrizeService prizeService;
        
            /**
             * 创建奖品
             * @param param 奖品基本信息(JSON格式)
             * @param picFile 奖品图片文件
             * @return 新建奖品的ID
             */
            @PostMapping("/prize/create")
            public CommonResult<Long> createPrize(
                    @Valid @RequestPart("param") CreatePrizeParam param,
                    @RequestPart("prizePic") MultipartFile picFile) {
                logger.info("创建奖品,参数:{}", JacksonUtil.writeValueAsString(param));
                Long prizeId = prizeService.createPrize(param, picFile);
                return CommonResult.success(prizeId);
            }
        }

        4.2.5 Service 层:创建奖品逻辑

        调用图片上传服务保存图片,再将奖品信息存入数据库:

        // 接口定义
        public interface PrizeService {
            /**
             * 创建奖品
             * @param param 奖品基本信息
             * @param picFile 奖品图片
             * @return 奖品ID
             */
            Long createPrize(CreatePrizeParam param, MultipartFile picFile);
        }
        
        // 实现类
        @Service
        public class PrizeServiceImpl implements PrizeService {
            @Autowired
            private PictureService pictureService; // 图片上传服务
        
            @Autowired
            private PrizeMapper prizeMapper; // 数据库操作接口
        
            @Override
            public Long createPrize(CreatePrizeParam param, MultipartFile picFile) {
                // 1. 上传奖品图片,获取图片存储的唯一文件名
                String imageUrl = pictureService.savePicture(picFile);
        
                // 2. 封装奖品信息到数据库实体
                PrizeDO prizeDO = new PrizeDO();
                prizeDO.setName(param.getPrizeName());
                prizeDO.setDescription(param.getDescription());
                prizeDO.setPrice(param.getPrice());
                prizeDO.setImageUrl(imageUrl); // 存储图片的唯一文件名(用于访问)
        
                // 3. 保存到数据库
                prizeMapper.insert(prizeDO);
                logger.info("奖品创建成功,ID:{}", prizeDO.getId());
        
                return prizeDO.getId();
            }
        }

        4.2.6 Dao 层:奖品数据存储

        定义数据库操作接口,实现奖品信息的插入:

        @Mapper
        public interface PrizeMapper {
            /**
             * 插入奖品信息
             * @param prizeDO 奖品数据库实体
             * @return 影响行数
             */
            @Insert("INSERT INTO prize (name, description, price, image_url) " +
                    "VALUES (#{name}, #{description}, #{price}, #{imageUrl})")
            @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") // 自动生成ID并返回
            int insert(PrizeDO prizeDO);
        }
        
        // 数据库实体类(PrizeDO)
        @Data
        public class PrizeDO {
            private Long id; // 奖品ID(自增)
            private String name; // 奖品名称
            private String description; // 奖品描述
            private BigDecimal price; // 奖品价格
            private String imageUrl; // 图片存储的唯一文件名
        }

        4.3 奖品列表查询功能

        4.3.1 前后端交互接口约定

      • 请求地址/prize/find-list(GET 方式)
      • 请求参数currentPage(当前页码,默认 1)、pageSize(每页条数,默认 10)
      • 响应格式
        {
          "code": 200,
          "data": {
            "total": 100, // 总记录数
            "records": [
              {
                "prizeId": 17,
                "prizeName": "吹风机",
                "description": "家用吹风机",
                "price": 100,
                "imageUrl": "d11fa79c-9cfb-46b9-8fb6-3226ba1ff6d6.jpg"
              }
            ]
          },
          "msg": "success"
        }

        4.3.2 分页参数(PageParam)

        定义分页查询的参数(页码、每页条数)及偏移量计算:

        @Data
        public class PageParam implements Serializable {
            private Integer currentPage = 1; // 默认第1页
            private Integer pageSize = 10; // 默认每页10条
        
            /**
             * 计算数据库查询的偏移量(从第几条数据开始查)
             * 例如:第1页偏移量=0,第2页偏移量=10(pageSize=10时)
             */
            public Integer offset() {
                return (currentPage - 1) * pageSize;
            }
        }

        4.3.3 响应结果实体(FindPrizeListResult)

        定义奖品列表查询的响应格式:

        @Data
        public class FindPrizeListResult {
            private Integer total; // 总记录数
            private List<PrizeInfo> records; // 当前页奖品列表
        
            // 奖品信息子实体
            @Data
            public static class PrizeInfo implements Serializable {
                private Long prizeId; // 奖品ID
                private String prizeName; // 奖品名称
                private String description; // 奖品描述
                private BigDecimal price; // 奖品价格
                private String imageUrl; // 图片地址(唯一文件名)
            }
        }

        4.3.4 数据传输对象(PrizeDTO)

        服务层与 Controller 层之间传输的奖品数据:

        @Data
        public class PrizeDTO {
            private Long prizeId; // 奖品ID
            private String name; // 奖品名称
            private String description; // 奖品描述
            private BigDecimal price; // 奖品价格
            private String imageUrl; // 图片唯一文件名
        }

        4.3.5 分页数据封装(PageListDTO)

        封装分页查询的总记录数和当前页数据:

        @Data
        public class PageListDTO<T> {
            private Integer total; // 总记录数
            private List<T> records; // 当前页数据列表
        
            public PageListDTO(Integer total, List<T> records) {
                this.total = total;
                this.records = records;
            }
        }

        4.3.6 Controller 层:奖品列表查询接口

        接收分页参数,调用服务层查询并转换结果:

        @RestController
        public class PrizeController {
            private static final Logger logger = LoggerFactory.getLogger(PrizeController.class);
        
            @Autowired
            private PrizeService prizeService;
        
            /**
             * 查询奖品列表(分页)
             * @param param 分页参数
             * @return 分页结果
             */
            @GetMapping("/prize/find-list")
            public CommonResult<FindPrizeListResult> findPrizeList(PageParam param) {
                logger.info("查询奖品列表,分页参数:{}", JacksonUtil.writeValueAsString(param));
                PageListDTO<PrizeDTO> pageList = prizeService.findPrizeList(param);
                return CommonResult.success(convertToResult(pageList));
            }
        
            // 转换分页数据为响应结果
            private FindPrizeListResult convertToResult(PageListDTO<PrizeDTO> pageList) {
                if (pageList == null) {
                    throw new ServiceException(ServiceErrorCodeConstants.PRIZE_LIST_EMPTY);
                }
                FindPrizeListResult result = new FindPrizeListResult();
                result.setTotal(pageList.getTotal());
        
                // 转换DTO列表为响应中的PrizeInfo列表
                List<FindPrizeListResult.PrizeInfo> prizeInfos = pageList.getRecords().stream()
                        .map(dto -> {
                            FindPrizeListResult.PrizeInfo info = new FindPrizeListResult.PrizeInfo();
                            info.setPrizeId(dto.getPrizeId());
                            info.setPrizeName(dto.getName());
                            info.setDescription(dto.getDescription());
                            info.setPrice(dto.getPrice());
                            info.setImageUrl(dto.getImageUrl());
                            return info;
                        })
                        .collect(Collectors.toList());
        
                result.setRecords(prizeInfos);
                return result;
            }
        }

        4.3.7 Service 层:奖品列表查询逻辑

        查询奖品总数及分页数据,并转换为 DTO:

        // 接口定义(补充)
        public interface PrizeService {
            /**
             * 分页查询奖品列表
             * @param param 分页参数
             * @return 分页数据(总记录数+当前页列表)
             */
            PageListDTO<PrizeDTO> findPrizeList(PageParam param);
        }
        
        // 实现类(补充)
        @Service
        public class PrizeServiceImpl implements PrizeService {
            @Autowired
            private PrizeMapper prizeMapper;
        
            @Override
            public PageListDTO<PrizeDTO> findPrizeList(PageParam param) {
                // 1. 查询总记录数
                int total = prizeMapper.count();
                if (total == 0) {
                    return new PageListDTO<>(0, Collections.emptyList());
                }
        
                // 2. 查询当前页数据(按ID降序,最新的在前)
                List<PrizeDO> prizeDOList = prizeMapper.selectByPage(param.offset(), param.getPageSize());
        
                // 3. 转换为DTO列表
                List<PrizeDTO> prizeDTOList = prizeDOList.stream()
                        .map(doObj -> {
                            PrizeDTO dto = new PrizeDTO();
                            dto.setPrizeId(doObj.getId());
                            dto.setName(doObj.getName());
                            dto.setDescription(doObj.getDescription());
                            dto.setPrice(doObj.getPrice());
                            dto.setImageUrl(doObj.getImageUrl());
                            return dto;
                        })
                        .collect(Collectors.toList());
        
                return new PageListDTO<>(total, prizeDTOList);
            }
        }

        4.3.8 Dao 层:奖品列表查询

        定义数据库查询接口,获取奖品总数及分页数据:

        @Mapper
        public interface PrizeMapper {
            /**
             * 查询奖品总数量
             */
            @Select("SELECT COUNT(1) FROM prize")
            int count();
        
            /**
             * 分页查询奖品列表
             * @param offset 偏移量(从第几条开始)
             * @param pageSize 每页条数
             */
            @Select("SELECT * FROM prize ORDER BY id DESC LIMIT #{offset}, #{pageSize}")
            List<PrizeDO> selectByPage(Integer offset, Integer pageSize);
        }

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值