1. 项⽬介绍
1.1 背景
随着数字营销的兴起,企业越来越重视通过在线活动来吸引和留住客⼾。抽奖活动作为⼀种有效的营 销⼿段,能够显著提升⽤⼾参与度和品牌曝光率。于是我们就开发了以抽奖活动作为背景的Spring Boot项⽬,通过这个项⽬提供⼀个全⾯、可靠、易于维护的抽奖平台,该平台将采⽤以下策略:
集成多种技术组件:利⽤MySQL、Redis、RabbitMQ等常⽤组件,构建⼀个稳定、⾼效、可扩展 的抽奖系统。
活动、奖品与⼈员管理:允许管理员创建配置抽奖活动;管理奖品信息;管理⼈员信息。
实现状态机管理:通过精⼼设计的状态机,精确控制活动及奖品状态的转换,提⾼系统的可控性和 可预测性。
保障数据⼀致性:通过事务管理和数据同步机制,确保数据的⼀致性和完整性。
加强安全性:实施安全措施,包括数据加密、⽤⼾认证,保护⽤⼾数据和系统安全。
降低维护成本:提供全⾯的⽇志记录和异常处理机制,简化问题诊断和系统维护。
提⾼扩展性:采⽤模块化设计与设计模式的使⽤,提⾼系统的灵活性和扩展性。
1.2 需求描述
1. 包含管理员的注册与登录。
a. 注册包含:姓名、邮箱、⼿机号、密码
b. 登录包含两种⽅式:
i. 电话+密码登录;
ii. 电话+短信登录; 验证码获取
iii. 登录需要校验管理员⾝份。
2. ⼈员管理:管理员⽀持创建普通⽤⼾,查看⽤⼾列表
a. 创建普通⽤⼾:姓名,邮箱,⼿机号
b. ⼈员列表:⼈员id、姓名、⾝份(普通⽤⼾、管理员)
3. 管理端⽀持创建奖品、奖品列表展⽰功能。
a. 创建的奖品信息包含:奖品名称、描述、价格、奖品图(上传)
b. 奖品列表展⽰(可翻⻚):奖品id、奖品图、奖品名、奖品描述、奖品价值(元)
4. 管理端⽀持创建活动、活动列表展⽰功能。
a. 创建的活动信息包含:
i. 活动名称
ii. 活动描述
iii. 圈选奖品:勾选对应奖品,并设置奖品等级(⼀⼆三等奖),及奖品数量
iv. 圈选⼈员:勾选参与抽奖⼈员
b. 活动列表展⽰(可翻⻚):
i. 活动名称
ii. 描述
iii. 活动状态:
1. 活动状态为进⾏中:点击"活动进⾏中,去抽奖"按钮跳转抽奖⻚
2. 活动状态为已完成:点击"活动已完成,查看中奖名单"按钮跳转抽奖⻚查看结果
5. 抽奖⻚⾯:
a. 对于进⾏中的活动,管理员才可抽奖。
b. 每轮抽奖的中奖⼈数跟随当前奖品数量。
c. 每个⼈只能中⼀次奖
d. 多轮抽奖,每轮抽奖有3个环节:展⽰奖品信息(奖品图、份数),⼈名闪动,停⽌闪动确定中 奖名单
i. 当前⻚展⽰奖品信息,点击‘开始抽奖’按钮,则跳转⾄⼈名闪动画⾯
ii. ⼈员闪动画⾯,点击’点我确定‘按钮,确认中奖名单。
iii. 当前⻚展⽰中奖名单,点击‘已抽完,下⼀步’按钮,若还有奖品未抽取,则展⽰下⼀个奖品 信息,否则展⽰全部中奖名单
iv. 点击’查看上⼀奖项‘按钮,展⽰上⼀个奖品信息
e. 对于抽奖过程中的异常情况,如抽奖过程中刷新⻚⾯,要保证抽取成功的奖项不能重新抽取。
i. 刷新⻚⾯后,若当前奖品已抽完,点击"开始抽奖",则直接展⽰当前奖品中奖名单 f. 如该抽奖活动已完成:
ii. 展⽰所有奖项的全部中奖名单
iii. 新增"分享结果"按钮,点击可复制当前⻚链接,打开后隐藏其他按钮,只展⽰活动名称与中奖 结果,保留"分享结果"按钮
6. 通知部分:抽奖完成需以邮件和短信⽅式通知中奖者。
a. “Hi,xxx。恭喜你在xxx抽奖中获得⼀等奖:⼿机。中奖时间为:xx:xx。请尽快领取您的奖 品。”
7. 管理端涉及的所有⻚⾯,包括抽奖⻚,需强制管理员登录后⽅可访问。
1.3 系统架构
前端:使⽤JavaScript管理各界⾯的动态性,使⽤AJAX技术从后端API获取数据。
后端:采⽤SpringBoot3构建后端应⽤,实现业务逻辑。
数据库:使⽤MySQL作为主数据库,存储⽤⼾数据和活动信息。
缓存:使⽤Redis作为缓存层,减少数据库访问次数。
消息队列:使⽤RabbitMQ处理异步任务,如处理抽奖⾏为。
⽇志与安全:使⽤JWT进⾏⽤⼾认证,使⽤SLF4J+logback完成⽇志。
1.4 项⽬环境
编程语⾔:Java(后端),JavaScript(前端)。
开发⼯具包:JDK17 (⻅《JDK17安装》⽂档)
后端框架:SpringBoot3。
数据库:MySQL。
缓存:Redis。
消息队列:RabbitMQ。
⽇志:logback。
安全:JWT+加密。
1.5 业务功能模块
⼈员业务模块:管理员注册、登录,及普通⽤⼾的创建。
活动业务模块:活动管理及活动状态管理。
奖品业务模块:奖品管理与奖品的分配。还包括奖品图的上传。
通知业务模块:发送短信、邮件等业务,例如验证码发送,中奖通知。
抽奖业务模块:完成抽奖动作,以及抽奖后的结果展⽰;
1.6 数据库设计
⽤⼾表:存储⽤⼾信息,如⽤⼾名、密码、邮箱等。
活动表:存储活动信息,如活动名称、描述、活动状态等。
奖品表:存储奖品信息,如奖品名称、奖品图等。
活动奖品关联表:存储⼀个活动下关联了哪些奖品。
活动⽤⼾关联表:存储⼀个活动下设置的参与⼈员。
中奖记录表:存储⼀个活动的中奖名单,如活动id,奖品id,中奖者id等
2. 数据库设计
2.1 表的关联图
sql代码略;
2.2 使⽤source命令导⼊.sql⽂件
首先使用指令进入mysql:
mysql -u root -p
source指令一个sql脚本文件;
注意:使用的sql文件的绝对路径且在路径中不能有中文;
mysql> source E:\java_xiangmu\lottery_system\lottery_system.sql
结果如下:指令执行成功;
3. 新建项目
3.1 选择相应的框架
pom文件配置略:
3.2 代码结构设计
分层结构如下:
上层依赖下层:
controller层(web层):接收来自前端的请求;
service层:里面含有具体的业务逻辑,是业务逻辑服务层;
数据持久层(dao层):数据访问层,与mysql进行数据交互;
common:通用处理层,只要存放util工具,config配置;
4. 功能模块设计
4.1 错误码
我们对dao层不进行定义错误码(1、许多sql错误定义不出来;2、可以在service层包含一些dao层的错误),对controller层和servoce层进行定义错误码(分别对人员模块,活动模块,奖品模块,抽奖模块进行分别定义)
由于errcode错误码是遍布两个层级,所以放在common通用层里面; errorcode类主要包含两个信息,一个是错误状态码code,另外一个是错误状态的信息msg;其次对于不同的层级来进行定义错误码;
对于抓不到,没有思考到的错误,使用全局通用错误码来定义;
public interface GlobalErrorCodeConstants {
ErrorCode SUCCESS = new ErrorCode(200,"成功!");
ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500,"系统异常!");
ErrorCode UNKNOW = new ErrorCode(999,"未知错误!");
}
4.2 ⾃义异常类
对当前的系统进行设计一套自定义异常类;
根据控制层和服务层来分别写相应的代码,
同时在构建异常时引入错误码,比如controller的异常构造,引入controller层的errorcode来进一步完善该层的异常信息;
@Data
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();
}
}
@EqualsAndHashCode(callSuper = true)注解:
@data注解会生成自己的@equals和hashcode方法,我们想要使用父类的属性和方法,就要使用上面的注解;
4.3. CommonResult<T>
对所有接口返回的结果进行封装的类型;
CommonResult<T>作为控制器层⽅法的返回类型,封装HTTP接⼝调⽤的结果,包括成功数据、错 误信息和状态码。它可以被SpringBoot框架等⾃动转换为JSON或其他格式的响应体,发送给客⼾端。
关于为什么要封装?
1. 统⼀的返回格式:确保客⼾端收到的响应具有⼀致的结构,⽆论处理的是哪个业务逻辑。 2. 错误码和消息:提供错误码(code)和错误消息(msg),帮助客⼾端快速识别和处理错误。
3. 泛型数据返回:使⽤泛型允许返回任何类型的数据,增加了返回对象的灵活性。
4. 静态⽅法:提供了error()和success()静态⽅法,⽅便快速创建错误或成功的响应对象。
5. 错误码常量集成:通过ErrorCode和GlobalErrorCodeConstants使⽤预定义的错误码,保持错 误码的⼀致性和可维护性。
6. 序列化:实现了Serializable接⼝,使得CommonResult对象可以被序列化为多种格式,如 JSON或XML,⽅便⽹络传输。
7. 业务逻辑解耦:将业务逻辑与API的响应格式分离,使得后端开发者可以专注于业务逻辑的实 现,⽽不必关⼼如何构建HTTP响应。
8. 客⼾端友好:客⼾端开发者可以通过统⼀的接⼝获取数据和错误信息,⽆需针对每个API编写特 定的错误处理逻辑。
public class CommonResult<T> implements Serializable {
// CommonResult<T>是在http中进行传输,所以要进行序列化
private Integer code;//返回的错误码
private T data;//返回的数据
private String msg;//对错误码的描述
//返回成功结果的CommonResult<T>静态构造
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;
}
//返回异常结果的CommonResult<T>静态构造
public static <T> CommonResult<T> error(Integer code,String msg){
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());
}
}
4.4 jackson工具实现序列化
方便将对象转化为string类,本项目中适用于日志打印个redis,rabbitmq的使用场景中;
相对而言protobuf(pb实现序列化非常快,但是可视化非常差);
public class JacksonUtil {
private JacksonUtil(){
}
private final static ObjectMapper OBJECT_MAPPER;//1点对objectmapper进行私有化单例操作
static {
OBJECT_MAPPER = new ObjectMapper();
}
private static ObjectMapper getObjectMapper(){
return OBJECT_MAPPER;
}
//2点。使用callable支持使用lamda表达式来进行处理,无论序列化的操作过程中出现的
//异常都可以tryparse进行捕捉
private static <T > T tryParse(Callable<T> parser) {
return tryParse(parser, JacksonException.class);
}
private static <T > T tryParse(Callable<T> parser, Class<? extends Exception> check){
try {
return parser.call();
} catch (Exception ex) {
if (check.isAssignableFrom(ex.getClass())) {
throw new JsonParseException(ex);
}
throw new IllegalStateException(ex);
}
}
/**
*序列化objectmapper
* @param value
* @return
*/
public static String writeValueAsString(Object value) {
return JacksonUtil.tryParse(()->
JacksonUtil.getObjectMapper().writeValueAsString(value));
}
/**
反序列化objectmapper
* @param content
* @param valueType
* @return
* @param <T>
*/
public static <T > T readValue(String content, Class<T> valueType) {
return JacksonUtil.tryParse(()->
JacksonUtil.getObjectMapper().readValue(content, valueType));
}
/**
反序列化list
* @param content
* @param parameterClasses
* @return
* @param <T>
*/
public static <T > T readListValue(String content, Class<?> parameterClasses) {
JavaType javaType = JacksonUtil.getObjectMapper().getTypeFactory().constructParametricType(List.class, parameterClasses);
return JacksonUtil.tryParse(()->
{ return JacksonUtil.getObjectMapper().readValue(content,javaType);
});
}
}
在这里特别注意两点:jackson里面的部分方法是对springboot框架进行学习处理的
1、 对objectmapper进行私有单例化处理;
2、使用tryparse的方法,其第一个参数是callable<T> parse,支持使用lambda表达式进行处理,无论在lambda表达式哪里出现了问题,都可以在tryparse里面使用try-catch对异常进行处理;
4.5. ⽇志处理
使用SLF4J+logback的配置:
在本地(dev),通过控制台来打印出日志;
服务器:日志主要存在目标目录文件;
application.properties配置:
spring.application.name=lottery_system
## logback xml ##
logging.config=classpath:logback-spring.xml
spring.profiles.active=dev
新增logback-spring.xml文件:对于本地和服务器的日志进行配置:
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds" debug="false">
<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>
<springProfile name="prod,test">
<!--ERROR级别的日志放在logErrorDir目录下,INFO级别的日志放在logInfoDir目录下-->
<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 属性,那么只会使用FileNamePattern的文件路径规则
如果同时有<File>和<FileNamePattern>,那么当天日志是<File>,明天会自动把今天
的日志改名为今天的日期。即,<File> 的日志都是当天的。
-->
<File>${logback.logErrorDir}/error.log</File>
<!-- 日志level过滤器,保证error.***.log中只记录ERROR级别的日志-->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<!--滚动策略,按照时间滚动 TimeBasedRollingPolicy-->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!--文件路径,定义了日志的切分方式——把每一天的日志归档到一个文件中,以防止日志填满整个磁盘空间-->
<FileNamePattern>${logback.logErrorDir}/error.%d{yyyy-MM-dd}.log</FileNamePattern>
<!--只保留最近30天的日志-->
<maxHistory>30</maxHistory>
<!--用来指定日志文件的上限大小,那么到了这个值,就会删除旧的日志-->
<!--<totalSizeCap>1GB</totalSizeCap>-->
</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 属性,那么只会使用FileNamePattern的文件路径规则
如果同时有<File>和<FileNamePattern>,那么当天日志是<File>,明天会自动把今天
的日志改名为今天的日期。即,<File> 的日志都是当天的。
-->
<File>${logback.logInfoDir}/info.log</File>
<!--自定义过滤器,保证info.***.log中只打印INFO级别的日志, 填写全限定路径-->
<filter class="com.example.lotterysystem.common.filter.InfoLevelFilter"/>
<!--滚动策略,按照时间滚动 TimeBasedRollingPolicy-->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!--文件路径,定义了日志的切分方式——把每一天的日志归档到一个文件中,以防止日志填满整个磁盘空间-->
<FileNamePattern>${logback.logInfoDir}/info.%d{yyyy-MM-dd}.log</FileNamePattern>
<!--只保留最近14天的日志-->
<maxHistory>14</maxHistory>
<!--用来指定日志文件的上限大小,那么到了这个值,就会删除旧的日志-->
<!--<totalSizeCap>1GB</totalSizeCap>-->
</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级别的日志,将info的日志进行接受;
@Override
public FilterReply decide(ILoggingEvent iLoggingEvent) {
if (iLoggingEvent.getLevel().toInt() == Level.INFO.toInt()){
return FilterReply.ACCEPT;
}
return FilterReply.DENY;
}
}
使用测试类来进行日志打印测试: