一、项目介绍
1.1 背景
现在数字营销越来越火,企业都想通过在线活动吸引和留住客户。抽奖作为有效的营销手段,能大幅提升用户参与度和品牌曝光。为此,我们开发了这个基于 SpringBoot 的抽奖系统,目标是打造一个全面、可靠、好维护的抽奖平台,主要特点如下:
- 整合多种技术:用 MySQL、Redis、RabbitMQ 等常用组件,确保系统稳定、高效、可扩展;
- 全流程管理:管理员可创建配置抽奖活动、管理奖品和人员信息;
- 状态精准控制:通过设计状态机,严格管理活动和奖品的状态转换,让系统更可控;
- 数据可靠:用事务管理和数据同步机制,保证数据一致、完整;
- 安全有保障:做了数据加密、用户认证等安全措施,保护用户数据和系统;
- 好维护易扩展:有完善的日志和异常处理,方便排查问题;模块化设计加设计模式,提升灵活性和扩展性。
这个系统是分布式抽奖解决方案,基于 Java 技术栈,能为各类营销活动提供高效、公平、可扩展的抽奖功能。支持限时抽奖、积分兑换抽奖等多种活动形式,可灵活配置奖品池、中奖概率、参与限制等规则。通过缓存和异步处理,能应对高并发场景。
核心流程:用户发起抽奖请求后,系统先校验活动状态和用户资格,用 Redis 快速查库存和频率限制;通过后,把抽奖任务放进 RabbitMQ 队列,消费端异步执行抽奖算法,更新库存和中奖记录,同步缓存结果;最后用邮件或短信通知用户,后台还能实时监控活动数据和中奖统计。
1.2 需求分析
目标用户
- 管理人员:负责奖品、人员、活动的创建,以及抽奖流程的管理;
- 普通用户:可参与抽奖、查看中奖名单。
需求描述
-
管理员注册与登录
- 注册信息:姓名、邮箱、手机号、密码;
- 登录方式:手机号 + 密码登录,登录时需验证管理员身份。
-
人员管理
- 管理员可创建普通用户(填姓名、邮箱、手机号);
- 人员列表展示:人员 ID、姓名、身份(普通用户 / 管理员)。
-
奖品管理
- 管理员可创建奖品(填名称、描述、价格,支持上传奖品图);
- 奖品列表分页展示:奖品 ID、图片、名称、描述、价值(元)。
-
活动管理
- 创建活动需填:名称、描述;选择奖品(设等级和数量);选择参与人员;
- 活动列表分页展示,含活动名称、描述、状态:
- 进行中:可点击 “活动进行中,去抽奖” 跳转到抽奖页;
- 已完成:可点击 “活动已完成,查看中奖名单” 跳转到结果页。
-
抽奖页面
- 仅管理员能对进行中的活动执行抽奖;
- 每轮中奖人数和当前奖品数量一致,且每人只能中一次奖;
- 多轮抽奖分 3 个环节:
- 展示奖品信息(图片、份数),点 “开始抽奖” 进入人名闪动页;
- 人名闪动时,点 “点我确定” 生成中奖名单;
- 展示中奖名单,点 “已抽完,下一步”:若有未抽奖品,展示下一个;否则展示全部中奖名单;点 “查看上一奖项” 可回看之前的奖品;
- 异常处理:刷新页面后,若奖品已抽完,点 “开始抽奖” 直接展示该奖品的中奖名单;
- 活动完成后:
- 展示所有奖项的中奖名单;
- 有 “分享结果” 按钮,点击可复制链接,打开链接后只显示活动名称、中奖结果和 “分享结果” 按钮。
-
登录限制
- 管理端所有页面(包括抽奖页)都需管理员登录后才能访问,未登录自动跳转到登录页。
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.xmllogback-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); }
- 请求地址:
6112

被折叠的 条评论
为什么被折叠?



