本篇会加入个人的所谓鱼式疯言
❤️❤️❤️鱼式疯言:❤️❤️❤️此疯言非彼疯言
而是理解过并总结出来通俗易懂的大白话,
小编会尽可能的在每个概念后插入鱼式疯言,帮助大家理解的.
🤭🤭🤭可能说的不是那么严谨.但小编初心是能让更多人能接受我们这个概念 !!!
欢迎小伙伴游玩本项目:
💞 💞 💞 抽奖系统项目URL 💞 💞 💞
引言
想体验极致刺激的抽奖乐趣吗?我们的个人抽奖系统,凭借 Spring Boot
快速搭建,依托 MySQL
稳定存储数据,借助 MyBatis 高效操作数据库。Redis 极速缓存,让抽奖响应如闪电般迅速;RabbitMQ
消息队列,实现异步处理,保障系统稳定运行。公平公正,惊喜不断,快来开启你的幸运之旅!
目录
-
项目展示与需求分析
-
需求分析
-
环境配置
-
前端代码模块
-
公共模块
-
注册登录模块
-
人员模块
-
奖品模块
-
活动模块
-
抽奖模块
-
Mapper模块XML实现
-
项目部署与项目代码gitee
一. 项目展示与需求分析
1. 项目产品展示
随着电商的兴起,例如拼多多,淘宝,京东等…都利用在线活动吸引用户,促进产品的营销。
常见的就是抽奖活动的出现,利用抽奖活动可以抽代金券,优惠券,生活用品礼包等… 既吸引顾客又达到促进产品营销的目的~~
- 登录页面
-
手机号登录
-
验证码登录
- 注册
- 活动列表
- 新建抽奖活动
- 奖品列表
- 创建奖品
- 人员列表
- 注册用户
- 抽奖
10. 分享中奖结果
2. 抽奖流程介绍
-
首先用户首先 创建账号和密码 ,创建的用户权限为
管理员
权限! 跳转到 登录页面 表示注册成功 ! -
管理员可进行 密码登录 或者 验证码登录 ,验证码登录 通过 邮件 的形式发送给用户,用户收到短信后,验证通过后, 管理员进入
活动中心页面
! -
在活动中心,管理员用户可以 创建用户,创建的用户权限为
普通用户
权限, 创建成功后会跳转到并出现在 用户列表 上,表示创建用户成功! -
然后, 管理员用户可以 创建奖品,记录对应的奖品信息之后,当跳转到奖品 列表页面 并出现在 奖品列表 ,表示创建奖品成功!
-
继而,管理员用户可以
创建活动
,创建活动过程中,关联的参与抽奖人员为 普通用户 ,管理员用户不能参与抽奖, 关联的奖品为奖品列表中的奖品
,并可以设置 奖品等级…等一系列信息。 -
当提示活动 创建成功 后, 会跳转到 活动列表页 , 当点击活动列表中活动状态为:
“活动进行中,去抽奖”
时, 进入抽奖页面
-
进入 抽奖页面 ,显示相应的
活动信息
,奖品信息
,以 奖品 为维度进行抽奖,点击“开始抽奖”
,就会进入人员闪动,当点击“开始抽奖”
,闪动停止,确定中奖人员 ,并发送 邮件 通知中奖人员尽快领取奖品。 -
抽奖过程中, 可以点击
“查看上一奖项”
,观察 上一奖项的信息以及中奖人员 。 -
当
全部奖品抽完
之后,可以点击“分享结果”
, 分享给 用户 ,普通用户或管理员用户 都可查看分享结果 -
当管理员返回活动列表,已经抽完的活动,就会从
“活动进行中,去抽奖”
状态切换成“活动已完成,查看中奖名单”
, 点击也可查看 所有人的中奖名单 。 -
如果
活动列表
,奖品列表
,人员列表
中,不需要的相关信息,可点击×
进行 删除操作。 -
普通用户可以查看
所有人的中奖名单
, 可以 参与抽奖 ,但是没有进入 活动中心,创建活动,奖品,普通用户 等…支配抽奖流程的权限。
二. 项目分析
1. 架构设计
在线项目——抽奖系统
前端:使用
JS
与用户进行交互,并且AJAX发送请求给后端,并从后端中获取数据
后端:
SpringBoot
构建整个后端体系,实现整个业务逻辑
数据层:以
MySQL
为主数据库,存储用户信息,和活动信息等…
缓存:搭配
Redis
作为缓存,处理热点高访问的数据,减少MySQL
的访问次数
消息队列:以
RabbitMQ
实现异步处理,主要用于抽奖行为。
日志和安全: 使用
logback
和SLF4J
进行日志提示,搭配JWT
令牌验证用户登录认证。
涉及的前端技术:HTML,CSS,JS,JQuery,AJAX
涉及的后端技术:JavaSE,HashMap,Maven,SpringBoot,SpringMVC,SpringIOC,SpringAOP,MySQL,MyBatis,logback,JWT + 加密,Redis,RabbitMQ。
涉及的设计模式:单例模式,工厂模式,生产者消费者模式,责任链模式,策略模式
2. 模块设计
首先用户进入登录页面, 由于还没有账号,去注册账号
- 注册页面:
-
姓名: 用户输入正确的姓名
-
邮箱:输入合法的QQ邮箱
-
手机号: 输入11位正确的手机号
-
密码: 输入6-12位的密码
-
身份: 管理员
注册失败会提示失败原因,
注册成功后会提示弹窗并跳转到登录页面
-
登录页面
密码登录:
-
手机号:输入注册成功的手机号/邮箱
-
密码:输入注册成功的密码
-
点击:登录按钮
验证码登录:
-
邮箱:输入注册成功的邮箱
-
验证码:输入该邮箱接收到的验证码
-
点击:登录按钮(验证码一分钟之内有效)
登录失败会提示失败原因
登录成功后会跳转到活动中心页面
-
-
活动中心页面:
-
活动管理:
-
活动列表选项
1) 分页显示:最新活动从上往下展示,每页十条活动
2) 每条活动内容:活动名,活动描述,活动状态:“活动已完成,查看中奖名单” / “活动进行中,去抽奖”
3). 删除选项:点击是否删除该活动 -
新建抽奖活动选项
1)输入活动名称
2)输入活动描述
3)圈选奖品:勾选需要用于抽奖的奖品列表下的奖品,选择对应奖品的奖品数量,设置奖品等级:一等奖/二等奖/三等级
4)圈选人员:勾选需要参与抽奖的人员列表下的普通用户人员(数量必须大于所有奖品总数量)
5)点击创建奖品:创建失败,提示失败原因,创建成功,弹窗提示并跳转到活动列表并展示最新抽奖活动
- 奖品管理:
-
奖品列表选项
1) 分页显示:最新奖品从上往下展示,每页五种奖品
2)奖品内容:奖品id,奖品图,奖品名,奖品描述,奖品价值
3)删除选项:选择是否删除该奖品 -
创建奖品选项
1)输入奖品名称
2)从文件选择图片进行上传
3)输入奖品价格
4)奖品描述
5)创建奖品选项:创建成功,弹窗提示并跳转到奖品列表,创建失败,提示失败原因。
- 人员管理:
-
人员列表选项
1)显示所有身份的人员信息:人员id,姓名,身份
2) 删除按钮:选择是否删除该人员 -
注册用户选项
1)输入姓名
2)输入正确的QQ邮箱
3)输入11位正确的手机号
4)普通用户
当点击活动列表中某个正在进行的活动,去抽奖就会跳转到抽奖页面
-
-
抽奖页面
-
活动信息展示:活动名,需要抽取的奖品名,奖品等级,奖品名,奖品数量,奖品图片
-
查看上一奖项选项:可查看上一个奖品信息,和对应的中奖人员
-
开始抽奖选项:点击后出现人员闪动
-
点我确定选项:确定中奖人员
-
已抽完,下一步选项:抽取下一个奖品
-
已全部抽完选项:展示中奖名单
-
中奖名单信息:中奖时间,中奖人员,中奖奖品,奖品等级
-
分享结果选项:链接复制到剪切板可分享他人
返回活动列表选项:活动信息由 “活动进行中,去抽奖” -> “活动已完成,查看中奖记录”, 点击该文字,可以进入分享页面(或通过URL也可访问分享页面)
-
分享页面
活动信息:活动名
中奖名单信息:中奖时间,中奖人员,中奖奖品,奖品等级
分享结果选项:链接复制到剪切板可分享他人
3. 数据库设计
连接好MySQL之后,建立 lottery_system
,建立六个表:
user,activity,prize,activity_user,activity_prize,winning_record 。
用户表(user): 主要存储用户的基本信息,如用户名,密码,手机号,邮箱等… 一般用于注册,登录,展示用户模块等…
奖品(prize): 主要存储奖品的基本信息,存储奖品的基本信息, 如奖品名,奖品描述,奖品价格… 用于创建奖品,展示奖品模块等…
活动(activity):主要存储活动的基本信息,如活动名,活动状态等… 用于创建活动,展示活动,抽奖模块等…
活动用户表(activity_user):建立用户信息与活动信息的关联,搭配活动需求使用, 包括活动id,用户id,用户状态等… 用于创建活动,抽奖模块等…
活动奖品表(activity_prize):建立奖品信息与活动信息的关联,搭配活动需求使用,包括活动id,奖品id,奖品状态等… 一般用于创建活动,抽奖模块等…
中奖记录表(winning_record):保存最终活动的中奖人员的信息,包括中奖信息,活动信息,奖品信息等… 一般用于抽奖,分享中奖结果模块等…
三. 环境配置
1. Maven依赖导入
依赖类型:
-
SpringBoot: 提供整个Spring框架 ,版本:
3.4.2
-
Rabbit MQ:实现消息的异步处理
-
MySQL驱动:驱动应用程序连接MySQL
-
Mybatis :操作MySQL的框架, 版本:
3.0.4
-
Spring 参数校验:校验前面传入的Java属性是否为空
-
Spring test :测试代码开发是否有异常BUG
-
配置JSON 数据转化:用于前后端传输对象的JSON序列化格式,版本:
2.13.3
-
JWT 令牌:登录凭证, 版本:
0.11.5
-
Redis :缓存热点数据
-
短信服务:提供发送短信的服务,版本:
3.1.1
-
加密工具包 :加密手机号,密码的工具包, 版本:
5.7.6
-
邮箱服务:提供发送邮箱服务
-
lombok :提供Java类的get方法,set方法等… 工具包
2. application 的配置文件
项目的配置文件主要分为两种:
开发环境下的配置文件:
测试/线上环境下的配置文件:
关于配置文件这些,小编就简单带过一下,不做重点讲解哦~
虽然两个文件的内容有点差异,但是配置的属性基本上都是一样:
-
数据库的相关配置
-
logback 的相关配置
-
Mybatis 的相关配置
-
Redis的相关配置
-
短信服务的相关配置
-
奖品图片路径的相关配置
-
MQ的相关配置
-
邮箱服务的相关配置
-
线程池的相关配置
3.数据库创库表
关于数据库的库表结构,小编在上面的内容中已经讲解过了,这里就不在赘述,更详细的说明,在下面的内容中会多次提及哦~~
4. Mybatis 层的XML 文件操作MySQL
由于操作数据库需要写的代码量比较多,这边小编就同一使用XML 的方式来管理Mapper层
的代码, 个人感觉更高效~
四. 前端代码模块
关于前端代码而已,并不是咱们Java开发程序猿的重点,所以小编至少单独拎出一个章节讲解,主要核心也是讲解JS相关的内容(下面内容细讲),小伙伴们如果对前端代码感兴趣的话,可以进入下面这个网站学习哦~
五. 公共模块
在本项目中采用常见的Spring MVC 三层架构的方式来进行管理业务代码。
`
controller层
负责 前后端请求和响应的交互
service层
实现 具体的业务逻辑
mapper层
建立 对数据库的各种访问
- 除了上述主要的核心功能之外,还需要一个
common层
来辅助这三层的实现,也就是公共模块,公共模块是不属于三层架构,但是对三层架构的服务进行保驾护航,提供便利的独立的一层~~
下面小编介绍本次项目的思路是从模块的角度来剖析,并不是从三层来剖析,个人认为如果只是从三层来解析,会显得太笼统,不够具体,希望小伙伴们能不能理解小编的初衷~
1. Jackson序列化
相信小伙伴们都知道,前后端传输对象数据一般都是JSON格式进行序列化传输。
所以我们就需要用到Spring 给我们提供的 ObjectMapper 的序列化和反序列化。
需要同时也需要处理一些异常相关和类型强化相关的就比较麻烦。
所以这里就需要在公共模块下,创建一个 Jackson
单元封装序列化格式的方法转化。
package com.example.lottery_system.common.utils;
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.boot.json.JsonParseException;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.Callable;
/**
* 单例进行序列化和反序列化
*/
@Component
public class JacksonUtil {
private JacksonUtil() {
}
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static ObjectMapper getObjectMapper() {
OBJECT_MAPPER.registerModule(new JavaTimeModule());
OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return OBJECT_MAPPER;
}
// 尝试转化
private static<T> T tryParse(Callable<T> parser, Class<? extends Exception> check) {
try {
return parser.call();
} catch (Exception e) {
// 如果是这个类的异常
if (check.isAssignableFrom(e.getClass())) {
throw new JsonParseException(e);
}
// 否则就抛出其他异常
throw new IllegalStateException(e);
}
}
private static<T> T tryParse(Callable<T> parser) {
return tryParse(parser, JacksonException.class);
}
/**
* 序列化
* @param object
* @return
*/
public static String writeValueAsString(Object object) {
String ret = tryParse(()->{
return getObjectMapper().writeValueAsString(object);
});
return ret;
}
/**
* 反序列化
* @param content 数据的内容
* @param valueType 数据的类型
* @return 返回该数据的类型
* @param <T>
*/
public static<T> T readValue(String content, Class<T> valueType) {
T ret = tryParse(()->{
return getObjectMapper().readValue(content,valueType);
});
return ret;
}
/**
* list 的反序列化
* 1. 从ObjectMapper 获取类型工厂
* 2. 类型工厂中的 constructType方法(java的类对象, 参数对象)
* 3. 最终得到 JavaType 来转化
* @param content
* @param paramClasses
* @return
* @param <T>
*/
public static<T> T readListValue(String content,Class<?> paramClasses) {
JavaType javaType = getObjectMapper().getTypeFactory().constructParametricType(List.class,paramClasses);
return tryParse(()->{
return getObjectMapper().readValue(content,javaType);
});
}
}
- 首先单例一个
ObjectMapper
类型,只能使用getObjectMapper()这个方法调用,不允许再实例化新的对象
- 然后
tryParse()
方法来对实例化过程进行尝试转化, 并且捕获异常
- 如果是序列化,传入对象参数, 直接调用
tryParse
进行在里面 writeValueAsString序列化 即可
- 需要反序列化时, 传入序列化参数,类型参数,如果是普通类型的话,直接调用
tryParse
进行在里面readValue反序列化
即可 。
- 如果是反序列化成
List
的话, 就需要单独添加一个方法readListValue()
操作, 额外加上getTypeFactory().constructParametricType(List.class,paramClasses)
这个配置转化为Java属性才可转换!
鱼式疯言
用途:
序列化:用于 后端Java对象转化为JSON字符串的格式 传给前端的响应。
反序列化:用于 解析前端传入的JSON数据解析成想要的Java对象 。
List反序列化: 用于 解析前端或Redis传入的JSON数据解析成List中的Java对象 。
这样的作用,就是 统一,统一,统一,统一传输过程都是以JSON字符串的格式来传输数据,以免发生错误!
2. 统一异常处理
package com.example.lottery_system.common.advice;
import com.example.lottery_system.common.errcode.CommonErrorCodeConstant;
import com.example.lottery_system.common.exception.ControllerException;
import com.example.lottery_system.common.exception.ServiceException;
import com.example.lottery_system.common.pojo.CommonResult;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
@ControllerAdvice
@ResponseBody
public class CommonExceptionReturn {
@ExceptionHandler(ControllerException.class)
public CommonResult<?> exception(ControllerException exception) {
/**
* 设置控制层的错误响应
*/
Integer code = exception.getCode();
String message = exception.getMsg();
CommonResult<Object> error = CommonResult.error(code, message);
return error;
}
@ExceptionHandler(ServiceException.class)
public CommonResult<?> exception(ServiceException exception) {
/**
* 设置控制层的错误响应
*/
Integer code = exception.getCode();
String message = exception.getMsg();
CommonResult<Object> error = CommonResult.error(code, message);
return error;
}
@ExceptionHandler(Exception.class)
public CommonResult<?> exception(Exception exception) {
Integer code = CommonErrorCodeConstant.INNER_SERVICE_ERROR.getCode();
String message = exception.getMessage();
CommonResult<Object> error = CommonResult.error(code, message);
return error;
}
}
1) 首先定义异常基本属性类,
code
和msg
属性为基本的自定义的异常常量的使用
2)定义异常接口,方便异常说明:
ServiceErrorCodeConstant
等…
3)然后自定义两个异常类:
ControllerException
和ServiceException
, 当抛出这两个异常,参数就使用上面的接口定义的异常常量,提示用户错误信息。
4)最后创建
CommonExceptionReturn
类,并由@ExceptionHandler
注解标记捕获哪个异常,当程序中的某个功能发生了异常,就能捕获是ControllerException
发生异常,还是ServiceException
,并且 以异常自定义常量的信息告知用户错误原因。
3. 统一结果返回
package com.example.lottery_system.common.advice;
import com.example.lottery_system.common.pojo.CommonResult;
import com.example.lottery_system.common.utils.JacksonUtil;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
@ControllerAdvice
@ResponseBody
public class CommonResultReturn implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
/**
* 讲这个设置为 true 表示需要统一结果返回
*/
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
/**
1. 首先判断是否是CommonResult 类型
*/
if (body instanceof CommonResult<?>) {
return JacksonUtil.writeValueAsString(body);
}
/**
2. 否则就转化成统一数据并返回
*/
CommonResult<Object> result = CommonResult.success(body);
/**
3. 转化为JSON 字符串返回
*/
return JacksonUtil.writeValueAsString(result);
}
}
-
首先,定义一个结果返回的类型
CommonResult
,定义三个属性:data
,code
,msg
. 如果效应成功就返回传入类型的data
, 如果失败就返回code
和msg
-
最后定义
CommonResultReturn
类, 实现ResponseBodyAdvice
接口,先打开中supports方法这个开关(返回true),然后在beforeBodyWrite
这个方法中,最终搭建需要统一返回的类型结果即可~~
鱼式疯言
- 以上两种方式就是
Spring AOP
的原理,统一结果返回 和 统一异常处理 :
- 如果 程序成功效应 , 就一定会被Spring 的
@ControllerAdvice
这个注解捕获走CommonResultReturn
这个类,统一调用beforeBodyWrite 这个方法,统一返回CommonResult
类型数据。
- 如果 程序效应失败 ,就一定会被 Spring的
@ControllerAdvice
这个注解捕获走CommonExceptionReturn
这个类,统一调用对应抛出异常的exception
方法, 统一返回CommonResult
类型数据。
- 小结
以上
-
一方面 JSON 统一传输的数据格式,
-
另一方面 统一结果返回 和 统一异常处理 统一传输的类型。
从而更好的用于 前后端交换数据,规范数据的传输,利于前端数据的接收,更高效的效应给用户。
4. JWT令牌
package com.example.lottery_system.common.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParserBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JWTUtil {
private static final Logger logger = LoggerFactory.getLogger(JWTUtil.class);
/**
* 密钥:Base64编码的密钥
*/
private static final String SECRET = "SDKltwTl3SiWX62dQiSHblEB6O03FG9/vEaivFu6c6g=";
/**
* 生成安全密钥:将一个Base64编码的密钥解码并创建一个HMAC SHA密钥。
*/
private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor(
Decoders.BASE64.decode(SECRET));
/**
* 过期时间(单位: 毫秒)
*/
private static final long EXPIRATION = 60*60*1000;
/**
* 生成密钥
*
* @param claim {"id": 12, "name":"张山"}
* @return
*/
public static String genJwt(Map<String, Object> claim){
//签名算法
String jwt = Jwts.builder()
.setClaims(claim) // 自定义内容(载荷)
.setIssuedAt(new Date()) // 设置签发时间
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION)) // 设置过期时间
.signWith(SECRET_KEY) // 签名算法
.compact();
return jwt;
}
/**
* 验证密钥
*/
public static Claims parseJWT(String jwt){
if (!StringUtils.hasLength(jwt)){
return null;
}
// 创建解析器, 设置签名密钥
JwtParserBuilder jwtParserBuilder = Jwts.parserBuilder().setSigningKey(SECRET_KEY);
Claims claims = null;
try {
//解析token
claims = jwtParserBuilder.build().parseClaimsJws(jwt).getBody();
}catch (Exception e){
// 签名验证失败
logger.error("解析令牌错误,jwt:{}", jwt, e);
}
return claims;
}
/**
* 从token中获取用户ID
*/
public static Integer getUserIdFromToken(String jwtToken) {
Claims claims = JWTUtil.parseJWT(jwtToken);
if (claims != null) {
Map<String, Object> userInfo = new HashMap<>(claims);
if (userInfo.get("userId") == null) {
System.out.println("map 钟不含有userId这个 key!!!");
return null;
}
return (Integer) userInfo.get("userId");
}
return null;
}
}
-
生成密钥:利用传入的Map参数设置JWT的载荷,签发时间,过期时间,签名算法 。
-
验证密钥:通过传入的
JWT
的Token
参数, 解析JWT中的载荷,如果解析成功,说明 Token 存在 ,返回载荷为登录状态
, 如果解析失败,就返回null
,为 未登录状态。 -
从
Token
中获取载荷,进一步从 载荷中获取用户相关的信息 。
鱼式疯言
有小伙伴们就有疑问了 ? ? ?
我们不是可以用服务端存 Session 和 客户端存 Cookie的方式吗? 为什么还要那么麻烦单独写一个类来生成 JWT 令牌的方式来存储并验证 ?
其实小伙伴们想的很正确, Cookie
和 Session
来存储确定很正确。但是那是对于一个主机而言的!
如果是多台主机, 可能用
Cookie
和Session
的方式来存储并不是一件很好的事情。
如上图,如果是多台主机的服务, 就不适用Cookie -Session 的方式来存储。
由于客户端先对主机1访问,进行登录,登录成功后。
第一台服务器存入一个
Session
,并效应给客户端带上SessionId
,存入到客户端的Cookie
中。
但是第二次访问服务器, 就不一定对主机1访问,就有可能对主机2访问,这时客户端带着
SessionId
去寻找Session
, 结果 主机2并没有存在对应的Session , 这台主机就会认为该用户没有进行登录。 就会造成用户重复登录的过程 ! ! !
显然,这样的问题对用户来说,是很影响体验的。 所以我们就不得不引入新的方案来解决 多主机的问题。
- 于是我们就使用
JWT的Token 令牌
的方式, 只要 用户登录成功 。就会生成一个Token令牌,只要用户携带这个令牌,
- 即使 访问多台主机 ,主机就会 根据这个令牌进行解析出载荷,如果解析成功, 就会返回该载荷,表示用户处于 登录状态 ,如果解析失败, 就直接返回 null ,表示用户处于 未登录状态 。
5. 登录验证拦截器
package com.example.lottery_system.common.interceptor;
import com.example.lottery_system.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 {
Logger logger = LoggerFactory.getLogger(LoginInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("user_token");
Claims claims = JWTUtil.parseJWT(token);
logger.info("从拦截器中获取到的token 为 : " + token);
if (claims == null) {
// 设置状态码
logger.error("未登录状态!");
response.setStatus(401);
return false;
}
return true;
}
}
-
首先实现
HandlerInterceptor
接口,重写preHandle
方法。 -
然后从
request
中获取对应 请求头的token信息 。 -
调用前面已经写好的解析Token 的方法, 判断返回是否存在载荷 , 如果 载荷不为空 ,验证通过,为登录状态 。 如果载荷为空 , 验证不通过,返回
401
,让前端来 处理相关的页面跳转,用户重新登录 。
但是这里还是有个问题, Spring怎么知道什么时候拦截, 拦截哪些接口 ? ? ?
难道登录请求一要一并拦截吗? 还有我们之后的分享抽奖结果的请求也要拦截吗?
那么用户还能登录成功吗?
对于这些问题, 所以我们就需要再写一个 拦截器的配置类对拦截器进行配置
梳理逻辑:
- 实现
WebMvcConfigurer
这个接口addInterceptors
这个方式,配置对应需要添加的自身实现的拦截器LoginInterceptor
- 配置需要拦截的接口 , 以及 不需要拦截的接口, 页面,文件 等…
鱼式疯言
话说就有小伙伴有疑问了? 拦截器是怎么拦截器这些请求的?
其实是这样滴 ~
- 在前端发送请求给后端, 拦截器就会先于后端接口,拦截对于的请求进行验证,我们知道, 请求一般都是带有载荷和请求头的。
- 当用户登录成功之后,就会返回一个
Token
, 这时前端就会在每次发送请求的时候, 前端就会设置请求头带上对应的Token
,(就好像我们出门坐火车一样,都要带上身份证一样)
- 所以这个拦截器就从这个请求头中去获取 , 但是能获取到还不够,万一不是有效的Token
(就好像有身份证还不够,万一是假的怎么办?)
这时就需要后端通过解密的方式去解析这个Token(进站闸机去验证这个身份证是否真实)
,如果Token解析出载荷,验证用户是登录状态,就会放行交给后端来接收这个请求(身份证验证通过,正常放行通过闸机,上车~)
,处理相对于的逻辑。
- 如果解析这个Token 失败,就会直接不经过后端接口入口
(身份证是假的,直接拦截,不允许放行过闸机)
,直接返回401
,表示请求失败,直接让前端处理跳转到登录页面。
6. 正则表达式校验格式
搭配正则表达式的使用目的就为了校验手机号,邮箱和密码的格式是否正确
package com.example.lottery_system.common.utils;
import org.springframework.util.StringUtils;
import java.util.regex.Pattern;
/**
* 正则表达式 判断格式
*/
public class RegexUtil {
/**
* 邮箱:xxx@xx.xxx(形如:abc@qq.com)
*
* @param content
* @return
*/
public static boolean checkMail(String content) {
if (!StringUtils.hasText(content)) {
return false;
}
/**
* ^ 表示匹配字符串的开始。
* [a-z0-9]+ 表示匹配一个或多个小写字母或数字。
* ([._\\-]*[a-z0-9])* 表示匹配零次或多次下述模式:一个点、下划线、反斜杠或短横线,后面跟着一个或多个小写字母或数字。这部分是可选的,并且可以重复出现。
* @ 字符字面量,表示电子邮件地址中必须包含的"@"符号。
* ([a-z0-9]+[-a-z0-9]*[a-z0-9]+.) 表示匹配一个或多个小写字母或数字,后面可以跟着零个或多个短横线或小写字母和数字,然后是一个小写字母或数字,最后是一个点。这是匹配域名的一部分。
* {1,63} 表示前面的模式重复1到63次,这是对顶级域名长度的限制。
* [a-z0-9]+ 表示匹配一个或多个小写字母或数字,这是顶级域名的开始部分。
* $ 表示匹配字符串的结束。
*/
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位数字
*
* @param content
* @return
*/
public static boolean checkMobile(String content) {
if (!StringUtils.hasText(content)) {
return false;
}
/**
* ^ 表示匹配字符串的开始。
* 1 表示手机号码以数字1开头。
* [3|4|5|6|7|8|9] 表示接下来的数字是3到9之间的任意一个数字。这是中国大陆手机号码的第二位数字,通常用来区分不同的运营商。
* [0-9]{9} 表示后面跟着9个0到9之间的任意数字,这代表手机号码的剩余部分。
* $ 表示匹配字符串的结束。
*/
String regex = "^1[3|4|5|6|7|8|9][0-9]{9}$";
return Pattern.matches(regex, content);
}
/**
* 密码强度正则,6到12位
*
* @param content
* @return
*/
public static boolean checkPassword(String content){
if (!StringUtils.hasText(content)) {
return false;
}
/**
* ^ 表示匹配字符串的开始。
* [0-9A-Za-z] 表示匹配的字符可以是:
* 0-9:任意一个数字(0到9)。
* A-Z:任意一个大写字母(从A到Z)。
* a-z:任意一个小写字母(从a到z)。
* {6,12} 表示前面的字符集合(数字、大写字母和小写字母)可以重复出现6到12次。
* $ 表示匹配字符串的结束。
*/
String regex= "^[0-9A-Za-z]{6,12}$";
return Pattern.matches(regex, content);
}
}
正则表示式:
-
邮箱必须带 @ 的格式的字符串
-
手机号必须是1 开头,第二位不能为 2 的11 为的数字
-
密码必须是 6 ~ 12 位的任意数字或字符
鱼式疯言
在这里,小编的认为正则表达式没有必要去重点掌握。
这里我们只需要了解校验的结果即可, 还有一点就是正则表达式 只能校验数据的格式是否合法,但是 不能校验输入的内容是否有效,正确,安全 ! ! !
- 短信服务与邮箱服务
短信服务和邮箱服务在本项目中的场景是:
-
发生验证码给用户,以此来验证用户身份登录
-
发送中奖通知给用户,通知中奖者尽快领取奖品
关于短信服务,主要是学习如何调用其中的sendMessage()这个方法,
-
templateCode: 表示模版号,用来区分我们是使用发送验证码模版,还是发送中奖通知的模版
-
phoneNumbers:发送给哪个用户的电话号码
-
templateParam:需要发送传入的参数,比如 code : 验证码等…
getCaptcha()
只需要调用,即可生成一段4位的0~9的数字验证码(下面详解)
sendSampleMail(),注意参数的传递
-
to:向哪个邮件发送的邮件地址
-
subject:邮件标题
-
context:邮件正文
7. Redis单元
package com.example.lottery_system.common.utils;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.util.StringUtils;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
@Configuration
public class RedisUtil {
private static Logger logger = LoggerFactory.getLogger(RedisUtil.class);
/**
* stringRedisTemplate 字符串redis 模版
* RedisTemplate byte数组redis模版
*/
@Resource(name = "stringRedisTemplate")
private StringRedisTemplate stringRedisTemplate ;
/***
* 添加键值对
* @param key <String>
* @param value <String>
* @return
*/
public boolean put(String key, String value) {
// 特判一下
if (!StringUtils.hasLength(key) || !StringUtils.hasLength(value)) {
return false;
}
try {
stringRedisTemplate.opsForValue().set(key,value);
return true;
} catch (Exception e) {
logger.info("RedisUtil->key:{},value:{} " ,key,value );
logger.error(",得到的异常为:" + e.getMessage());
return false;
}
}
/**
*
* 设置超时时间
*
* @param key
* @param value
* @param outTime 单位(分钟)
* @return
*/
public boolean put(String key, String value, long outTime) {
// 特判一下
if (!StringUtils.hasLength(key) || !StringUtils.hasLength(value)) {
return false;
}
try {
stringRedisTemplate.opsForValue().set(key,value,outTime, TimeUnit.MINUTES);
return true;
} catch (Exception e) {
logger.info("RedisUtil->key:{},value:{} " ,key,value );
logger.error(",得到的异常为:" + e.getMessage());
return false;
}
}
/**
* 从key中获取值
* @param key
* @return
*/
public String get(String key) {
// 特判一下
if (!StringUtils.hasLength(key)) {
return new String();
}
try {
return stringRedisTemplate.opsForValue().get(key);
} catch (Exception e) {
logger.info("RedisUtil->key:{} " ,key );
logger.error(",得到的异常为:" + e.getMessage());
return new String();
}
}
/**
* 查找这个key 是否存储
* @param key
* @return
*/
public boolean contains(String key) {
// 特判一下
if (!StringUtils.hasLength(key)) {
return false;
}
try {
Boolean aBoolean = stringRedisTemplate.hasKey(key);
return aBoolean;
} catch (Exception e) {
logger.info("RedisUtil->key:{} " ,key );
logger.error(",得到的异常为:" + e.getMessage());
return false;
}
}
/**
* 删除 key-value
* @param key
* @return
*/
public boolean delete(String key) {
// 特判一下
if (!StringUtils.hasLength(key)) {
return false;
}
try {
Boolean aBoolean = stringRedisTemplate.delete(key);
return aBoolean;
} catch (Exception e) {
logger.error("RedisUtil->key:{}, 得到的异常为:" ,key,e.getMessage());
return false;
}
}
}
Redis 作为我们的缓存,用于查询一点热点数据,减轻MySQL数据库的访问压力。
所以MySQL有的 增删查
操作,对于Redis 来说也必须也有。
-
首先,我们定义RedisUtil 为操作Redis 的一个组件单元,并从
Spring
中获取StringRedisTemplate
这个类 -
增加数据 : 设置put 用于添加数据到Redis 中, 我们知道对于
Redis
来说, 是以键值对的形式 来存储的。 所以我们就需要设置传入的参数为key
,value
, 如果存放成功就返回true, 存放失败就返回捕获异常,返回false
。同时由于业务需求,像验证码这种就需要设置一个超时时间,如果一旦超过了这个超时时间,Redis 就会自动删除这个key 。 -
获取数据: 由于
Redis
是以键值对的形式存储, 所以我们就可以通过 key 的方式来获取value -
判断数据是否存在: 通过key 来搜寻 ,在Redis 中判断是否存在,存在返回
true
, 不存在返回false
-
删除数据:
StringRedisTemplate
这个类中就实现了 删除对应的key
鱼式疯言
相比小伙伴们一定有疑问了,我们都有数据库存储了, 为啥还需要Redis 来存储。
用一个MySQL 存储数据不就可以了吗? 还需要那么麻烦干啥?
其实是有原因的 ! ! !
我们可以设想一个场景, 现在是双十一拼多多需要用这个抽奖系统来进行抽奖。
- 如果我们只用数据库MySQL存储,由于双十一,访问的人数几千万的数据量, 并且由于MySQL 是在硬盘上操作数据, 这时就由于访问量过大, 数据在数据中难得及时的响应, 就会出现卡顿,甚至出现完全不响应 ,由于 MySQL 是一个客户端服务器结构的程序 , MySQL就极有可能出现 服务器崩溃 的情况。
- 所以我们怎么解决这个问题, 这时我们就需要 缓解MySQL 服务器的压力 , 并且替换一个
效应更快
的中间件来存储和访问。
- 那么
Redis
无疑就是比较好的选择, 由于 Redis 的存储是存放在内存中, 相比小伙伴都知晓,从内存中访问的数据就会比硬盘上访问的数据快很多。
- 所以从一方面考虑,
Redis
可以比MySQL
更快的查找并效应给客户端, 从另外一方面考虑, Redis 可以帮MySQL 服务器减少由于数据量访问过大的情况,替MySQL负重前行。
但是这时就有小伙伴问了? 那么我们为何不都让Redis 存所有的数据,而只是存储一部分热点数据给MySQL 呢?
- 那么势必要考虑到 Redis存储在内存中的不足 , 虽然在内存中访问数据要比MySQL 快很多,但是内存的空间是比硬盘的空间小很多,所以不足以存放大量的数据于Redis, 并且当 主机掉电后,内存上的数据是容易丢失的!
- 所以处于Redis 的虽然快但是
空间不足容易丢失数据
的情况,我们一般采用 二八原则 , 就是20%可以效应80%的请求。 所以比较好的方案就是 让数据库存储所有的数据,然后Redis 同步备份存储MySQL中20%的热点数据。
这样的话,当有大量的数据请求时, 提前访问
Redis
, 就可以 大幅度的减缓MySQL的访问压力,降低MySQL服务器崩溃的概率!
8. RabbitMQ的配置
在这个配置文件中,主要绑定两种消息队列:
-
普通消息队列:用于存储客户端发来的请求数据,等到合适的时机 来处理该数据
-
死信队列:当程序处理普通消息队列的请求数据时,发生了异常,就会存放到该队列中,重新想办法处理这些请求。
RabbitMQ的相关配置文件主要的核心:
- 死信队列的配置
- 连接死信队列
- 建立死信队列交换机
- 建立死信队列和死信交换机的连接
- 普通队列的配置
- 建立普通消息队列并绑定死信队列
- 建立交换机
- 建立交换机和消息队列的连接
鱼式疯言
有小伙伴可能问了?
这个RabbitMQ 作为消息队列的作用是什么? 为什么要进行异步处理? 平常同步处理请求也是可以的呀~ 还需要再加一个这个的消息队列岂不是浪费空间。
其实小伙伴们想的也不错,确实同步正常处理请求,处理结束后当即返回给客户端确定不错~
但是试问,如果是现在是一场世界杯的直播,阿根廷队对战法国队的晚间比赛
现在晚间,几亿用户同时打开网页,发起观看世界杯直播的请求。
这时服务器接收到那么多请求数据,如果是同步处理请求,一下子接收那么多请求,就很有可能会出现诸多问题?
- 服务器一下接收那么海量请求,就有可能会导致服务器无法及时的效应给用户
- 服务器一下子要处理那么海量数据,就可能会导致内存泄漏,甚至服务器崩溃的情况
- 一旦如果程序请求这边出现了严重的问题,比如
(同一进程下的不同线程,进程崩溃了,连带着线程也会跟着崩溃)
,也会导致其他服务也同时也附带那么大量问题产生的连带效果。
所以,如果是面对这样高请求量的场景,我们就必须借助对应的工具采取更可行性的方案来解决这样的场景。
RabbitMQ
作为消息队列的使用,无疑是比较好的选择。
消息队列的策略,本质上就是一种设计模式的实现——生产者消费者模型
生产者消费者模型:让生产者把数据放入缓存区,让消费者从缓存区拿数据。
而这个缓存区就是可以是我们使用的 RabbitMQ的消息队列
。
使用生产者消费者模型好处:
- 解耦合: 生产者只需要往消息队列中存数据,消费者只需要从消息队列中取数据,即使生产者这边出现了问题,或消费者这边出现了问题,也不会影响对方的服务。使生产者和消费者两边能够比较独立。
- 削峰填谷: 如果是在
高请求量
的情况,消息队列就可以作为一个缓存
,当请求数据太大的时候,生成者生产的速度大于消费者消费,消费者这时还来不及处理, 就可以暂时存放到消息队列中缓存起来, 以免消费者这边处于阻塞状态。 同时如果请求数据没有那么大的情况, 当消费者消费的速度大于生成者生产的速度,就可以从消息队列中去取数据,以免生产者这边出现空闲状态。
- 缓冲请求:请求量太多的情况下,像上面
高请求量
的场景,就可以暂时存放到消息队列中, 以免服务器一下子接收那么多数据请求,又来不及处理,导致服务器内存泄漏等一系列诸多问题。 以此降低服务器崩溃的风险。
9. 线程池与日志配置
在本项目中,线程池的作用主要用于同时发短信服务和邮箱服务
也就是说,当某个用户抽中奖品之后,就立即同时发送短信和邮箱,通知中奖者已经中奖了(下面详讲)
日志配置文件,目的就在于:
把 info
和 error
两种日志都管理到两个不同的文件中, 当 有异常或程序出现问题 时,便于发生问题并及时处理~
相关的组件和配置说明结束,小伙伴们一定要注意区分哦~ 上面的内容的掌握优先级不是特别高。
接下来小编要细讲的这些模块,才是我们业务逻辑的具体实现,需要掌握的优先级程度:五颗星
六. 注册登录模块
1. 注册模块
<1>. 约定前后端交互接口
如上图:前端发送 POST
请求到后端接口 /register
这个路径,发送相关的注册的人员数据和身份信息
当后端注册成功后,就效应给前端该注册用户的 id
如果后端注册失败,就 响应错误码和错误信息 。
<2>. 梳理实现逻辑
如上方的时序图:
首先用户通过注册页面发送请求后端,由后端
controller层
来接收
- 然后通过
service层
进行继而接收 注册信息 ,先对 用户信息进行校验
校验参数是否为空
校验手机号的格式是否合法
校验手机号是否已经注册过
校验邮箱的格式是否合法
校验邮箱是否被注册过
校验身份信息是否是管理员
校验密码是否在六位到十二位之间
service层
接着对一些 隐私数据进行加密 ,比如 密码,手机号加密(下面详解)
- 最终到
mapper层
调用 数据库插入用户信息到用户表
- 如果落库成功就效应给当前用户的
id
需要注意的一点是:
- 密码的加密,我们一般是采取 Hash加密 的方案
(sha256Hex)
- 而对于手机号而言, 由于手机号需要频繁使用,可能要进行
登录功能
,短信服务
等…所以手机号一般采取的 对称加密 的方案(aes加密)
, 并且为了让方便获取手机号
和加密手机号
,小编这里的措施是 MySQL字段自动转化Java对象 的方式。
鱼式疯言
小伙伴们应该不了解对称加密和Hash加密吧~
对称加密: 明文(原文) + 密钥 -> 密文
(加密后的内容)
对称加密含义就是: 可以通过密钥 和 明文 可以获取密文
同时也可以利用密钥对密文进行解析成明文。
属于(可逆性加密)
- Hash加密:明文(原文)-> 哈希 -> 密文
Hash加密: 可以对明文对象 Hash 加密获取密文,
但是不能对密文解析还原成明文
属于不可逆加密
有小伙伴可能要问了,对于密码这种隐私数据,虽然要加密,但是进行密码登录的时候,也不是需要用到吗?
那么使用Hash加密确定好吗?
因为对于密码这种隐私数据,一般我们很少采取 对称加密
的方案, 由于对称加密需要密钥,如果有黑客等别有用心之人,一旦被他们获取密钥,就有可能出现下面的问题
- 数据库的密文就会被一一破解成
明文
! ! !
- 可以完全像管理员一样进行密码登录,获取管理员的所有信息并篡改! ! !
所以这样对密码这样进行对称加密是比较危险的, 所以这里就采用Hash加密的方式更安全。
但是问题来了,Hash加密是不可逆加密,如果我们要验证密码怎么办?
答案很简单,其实我们只需要当 用户在登录时输入的密码 , 再利用 sha256Hex
对**传入的原文(密码)**进行加密
由于是统一用
sha256Hex
加密后的密文和注册的加密后的密文进行比对
如果 相等就代表是同一组密码 ,验证成功,如果
不相等就验证失败
!!!
2. 登录模块
登录分为两种登录方式:
-
密码登录
-
验证码登录
<1>. 约定前后端交互接口
首先我们看看 密码登录:
- 对于 密码登录请求 来说主要传入参数为 用户名和密码以及对应身份信息。
- 对于 验证码登录请求 来说主要传入参数的为 手机号/邮箱和验证码以及身份信息。
有 校验验证码 的过程就必然有 发送验证码 的过程~
发送验证码只需要传入对应的
手机号/邮箱
即可发送,这里就不需要JSON
格式来穿参了!
<2>. 梳理实现逻辑
这样吧~ 小编先接收短信发送的接口,然后一起介绍登录的相关逻辑
观看上方时序图, 可以分为一下几个逻辑:
-
首先用户传入手机号/邮箱,通过前端发送获取验证码请求给后端
-
后端接收到这个手机号/邮箱, 进行校验是否合法
- 这里不仅需要校验两种是否合法,同时还需要区别用户是通过手机号还是邮箱来发送验证码, 这里的逻辑就可以通过前面我们实现的正则表达式来判断。
- 如果正则表达式判断是 手机号合法 ,就走 手机号的短信发送逻辑 。
- 如果正在表达式判断是 邮箱合法 ,就走 邮箱的的邮件发送逻辑。
- 还可能都不合法, 那么久直接抛出异常,提示给用户输入不合法 。
-
校验成功后,生成随机验证码并发送给用户对应的手机号/邮箱
-
发送的同时进行
Redis缓存处理
,缓存验证码并设定超时时间60s
-
等到后期需要登录来比对这个验证码的时候,就可以随时到
Redis
这边获取进行~
如果是密码登录逻辑:
- 首先用户选择输入电话/邮箱和密码进行登录,由前端传输给后端
- 后端接收到请求之后,由服务层进行校验登录 信息的完整性
- 校验成功之后,
service层
就调用mapper层
来获取 数据库中的用户相关信息
- 获取用户信息之后,还需要 检查用户信息是否是管理员用户
- ==管理员用户才允许登录==, 如果只是 **普通用户就无权限登录**
- 接下来就通过对传入的密码进行
sha256
加密,和 数据库的密文进行比对,
- 如果比对成功, 就进行下一步借助
JWT生成Token令牌
返回给用户,作为用户的身份凭借。
如果是 短信/邮件的登录逻辑:
-
首先在
service层
校验用户传入的手机号/邮箱和验证码 -
然后调用
mapper层
的获取用户信息, 并且检查用户身份是否是管理员- 如果是 管理员用户才允许登录, 如果只是 普通用户就无权限登录
-
之后使用Redis单元从获取验证码, 比对Redis中的验证码和传入的验证码是否一致
-
如果一致我们就借助
JWT
生成Token令牌
,返回给用户,作为 用户的身份凭证 。如果比对失败,就提示用户错误信息。
鱼式疯言
不知道小伙伴们有没有发现:
一般而言:
- Java对象与数据库相关使用
DO
后缀的类名 (体现在Mapper层)
- 前端发送过来的数据一般以
param
为后缀的类名 (体现在controller层)
service层
完成所有的业务逻辑之后一般返回DTO
后缀的类名 (体现到service层)
- 后端接收到
DTO
类型数据需要以Result
为后缀的类名(返回给前端)
有小伙伴可能会问了, 这样的话类型转化来转化去岂不是很麻烦吗?
我们为何要这样设计?
其实都是有原因的~~
对于 param
和 result
这样的参数,想必小伙伴们一定能够理解。
这里的小编主要是介绍 DO 和 DTO 的区别
DO
: 全称—— 数据库持久化对象 , 主要作为数据库映射对象,一般对应数据库的全部的字段, 用于与数据库的数据交互 ,用于传输数据库的数据
。
DTO
:全称——数据传输对象,主要作为系统传输对象,一般根据 前端的数据的字段需求进行定义, 传输部分数据库的字段数据
。
那么有小伙伴就更疑惑了,我们直接使用 DO
不是更好, 直接返回 result
对应的结果不是更方便吗? 还需要中间建立一层来转化成 DTO
, 再转化成 result 对象
, 岂不是更麻烦吗?
答案是肯定的, 但是试想一下, 如果别有用心之人获取到 对应的接口, 从接口中来获取数据, 这样就把数据库表中的全部信息等… 都给暴露在外, 岂不是很危险!
所以小编认为使用DTO / DO 的优势有以下几点来参考
DO 优势:
DO
可以紧密的联系数据库的属性
信息, 高度耦合,这样有便于与数据库进行交互
- 通过 数据库可以直接映射 到
DO
上, 可以有效的 简化数据库的操作
DO
通常与 数据库的字段保持一致 ,便于后期维护~
DTO优势:
DTO
可以根据不同需求来定义属性, 减少冗余字段的传输, 不仅提高传输的性能还能够更加灵活
~
DTO
由于是根据需求来定义属性
信息 ,一般不包含数据库字段的全部信息, 从而 防止数据库表结构的暴露 ,同时能降低耦合程度
~
DTO
并一定是和数据库的类型 一 一 对应的, 对于一些敏感的信息(如手机号, 密码)等一些需要加密的信息, 就会在 DTO 层进行隐藏 ~ 更加安全可靠
!
总而言之,言而总之 ~~
DO
: 只对数据库专一 ,与 数据库的结构耦合紧密 ~
DTO
: 只对数据传输专一, 减少数据库,提高性能
和安全性
~
在实际的开发过程中, 合理使用
DO
和DTO
能够 提高代码的可维护性 ,性能
,安全性
~
七. 人员模块
1. 获取用户列表模块
<1>. 约定前后端交互接口
小编这里设计的 获取用户列表的维度是以成员身份来设计的
如果是获取全部的用户, 那么就直接传入一个 空值的身份信息(用户展示列表)
如果是只获取需要参与抽奖的普通用户, 那么我们就可以传入一个 NORMAL
的身份信息(创建抽奖活动)
而响应就在前面的内容中小编已经交代了,这里就不赘述了~
<2>. 梳理实现逻辑
- 首先我们用户发起请求 传入人员身份信息 ,查询人员列表
server层
接收到对应的参数, 就进入mapper 层
进行查询
- mapper根据查询的身份信息到 user 表中进行查询,返回人员列表, 最终再进一步的效应给用户。
2. 删除用户模块
<1>. 约定前后端交互接口
- 接口映射的路径为
/deleteUser
- 传入的参数为
userId
- 传出结果为 布尔类型, 如果成功删除就返回
true
, 否则直接抛出 错误信息 。
<2>. 梳理实现逻辑
- 首先, 像上面的流程一样, 用户发起 删除请求 , 传入对应
userId
service层
接收到对应的用户id, 就进入到 mapper层进行删除
mapper层
根据对应的 userId 到user 表中进行删除 , 如果 删除成功就返回 true , 并且告知用户删除成功
~
八. 奖品模块
1. 奖品图片上传
在创建奖品之前, 由于我们的奖品中含有上次奖品图片这一功能,作为前置知识先给小伙伴讲解一番:
<1>. 约定前后端交互接口
这里的效应就返回一个 文件名
即可(奖品创建的实现逻辑中讲解)
<2>. 梳理实现逻辑
-
判断 目录是否存在 , 如果不存在就创建一个新的目录
-
修改原有的文件名为
UUID
的随机文件名 -
保存到
本地默认路径
+ 新文件名,保存成功后返回给前端新的文件名, 方便下次调用
2. 奖品创建
<1>. 约定前后端交互接口
还有一个
prizePic
会自动转化为 文件路径 ~
效应
的话, 只需要 返回保存成功后的奖品id
<2>. 梳理实现逻辑
如时序图:
- 首先用户发送单个奖品的创建请求, 传入一个奖品信息和图片信息
- 传入到
service层
之后 , 一方面在讲奖品图片保存在本地, 另一个方面将 奖品信息保存到数据库。
- 若
保存成功效应奖品id
,如果保存异常
,抛出对应的错误信息。
鱼式疯言
对于 图片文件上传 的系列, 就有可能出现 二进制流数据转化被序列化的 的问题
3. 奖品列表展示
<1>. 约定前后端交互接口
注: 本次查询为翻页查询, 需要传入的参数为:
currentage
: 当前页数,pageSize
: 当前页展示条数
所以响应的结果, 就需要对应的
总条数
, 以及 响应数据(奖品id , 奖品名, 奖品描述, 奖品价格, 奖品图片Url)
<2>. 梳理实现逻辑
- 首先前端传入 当前页数和页面条数
service层
接收到当前数据进入到Mapper层
进行 分页查询
- 查询成功 返回总条数和数据结果 ~
4. 奖品删除
<1>. 约定前后端交互接口
如果 删除成功 ,就响应
true
, 否则就 抛出异常信息~
<2>. 梳理实现逻辑
如时序图:
- 首先用户发送 删除奖品信息请求, 带入对应的
奖品id
- ``service 层
获取到
奖品id` , 就到 Mapper 层进行删除操作~
- 删除成功就返回
true
, 否则抛出异常信息~
九. 活动模块
1. 创建活动
<1>. 约定前后端交互接口
如上图, 其中 请求参数就包含活动信息
,以及该活动下 关联的需要抽奖的奖品信息列表和最后需要 关联的参与抽奖的人员信息列表
当活动创建成功之后,后端就会返回一个
活动id
给前端, 表示 活动创建并且保存成功~
<2>. 梳理实现逻辑
如时序图:
- 后端从前端接收到 活动创建的对应的请求和数据
进入到后端的
service 层
之后, 对传入 数据进行校验
- 校验 活动数据是否为空
- 校验 活动奖品是否存在
- 校验 活动用户是否存在
- 校验该 活动下的奖品是否 <= 该活动下抽奖人员的人数
- 创建 活动DO 对象,然后调用
Mapper层
, 保存到数据库中的活动表中~
- 创建 关联活动人员DO 对象, 然后调用
Mapper 层
, 保存到数据库的活动人员表中~
- 创建关联活动奖品DO 对象, 然后调用
Mapper层
, 保存到数据库的活动人员表中~
- 整合活动 + 人员 + 奖品的完整信息 , 并保存到
Redis
中, 作为缓存, 以便下次使用~
2. 活动列表展示
<1>. 约定前后端交互接口
请求参数,主要是在
URL的查询字符串中包含两个参数
: 当前页面currentPage
的 大小和当前页面展示的条数pageSize
的多少~
以此后端效应就根据这两个信息来确定需要查询的哪部分数据, 并把 查询的结果统一封装成列表 , 并且再进一步的都以
PageResult
统一的翻页的结果数据返回效应~
<2>. 梳理实现逻辑
如时序图:
-
首先后端接口从 前端获取到页面请求和数据
-
之后服务层调用
Mapper 层
, 在Mapper层先进行所有总数数量的查询, 并保存到上述的total
中 -
接着根据 当前页数和条数进行活动信息查询, 返回 活动列表响应给前端~
3. 活动详情展示
<1>. 约定前后端交互接口
有活动创建的保存就必然有对 活动详情的请求:
请求是以 活动id
为线索进行发送
响应的话, 我们就以根据该活动, 查询出活动信息, 关联活动奖品信息列表, 以及关联的活动用户信息列表响应给前端~
<2>. 梳理实现逻辑
如时序图:
- 首先前端传入一个
活动id
给后端, 后端传入控制层接口,接口交给服务层来处理。
- 服务层先到
Redis 中
进行查询, 如果存在就直接从Redis中返回活动详情数据~
- 但是 有可能Redis不存在, 那么我们就通过MySQL 查询
MySQL
中就需要查询对应的 活动信息, 关联奖品, 以及关联人员信息~
- 查询成功后,别着急返回, 这时先 整合数据保存到Redis中一份, 以便下一次更高效的查询~ 最终效应活动详情~
鱼式疯言
通过上面的实现逻辑的梳理, 小伙伴们想必也理解,为何要加入Redis?
- Redis 可以明显配合MySQL进行读写分离 :
- 如果要读(查询)数据, 就先去Redis找, 既快又能缓解MySQL的访问压力
- 如果要插入数据, 就需要先插入
MySQL
之后, 并且还需要 在Redis中同时也存一份~- 从而替
MySQL负重前行
,防止MySQL服务器崩溃~
- 但是一定要注意数据同步:
- 如果
MySQL有的数据
, 而Redis
没有, 就需要同步添加到Redis中- 如果
MySQL改了数据(删除数据, 修改数据等...)
, 也需要 同步到Redis中进行修改~ 反正出现未及时更新的数据~
4. 活动删除
<1>. 约定前后端交互接口
传入一个
活动id
, 对该活动
进行删除~
删除成功返回
true
,否则提示 错误信息 ~
<2>. 梳理实现逻辑
如时序图:
- 首先后端从 前端获取到活动id , 进入到
controller层
, 并通过service层
来实现逻辑
- 其次
service层
调用Mapper层
,对 数据库中活动表, 活动关联人员表,活动关联奖品表 进行删除
- 最后对
Redis缓存
中的数据进行删除,保证缓存与数据库中数据的一致性 ~
- 如果
该活动完成抽奖活动
, 就需要 删除数据库的中奖表的数据 ,以及 删除缓存Redis中的中奖记录~ (下面详解)
十. 抽奖模块
1. 抽奖请求
<1>. 约定前后端交互接口
-
请求参数: 发送一个以奖品为维度的抽奖请求, 也就是说, 这个奖品可能数量不止一个, 所有不仅要包含奖品的基本信息,还需要包含中奖人员列表的id和姓名信息传给后端,让后端处理该中奖请求~
-
一定处理成功,一定响应true(下面细讲~)
<2>. 梳理实现逻辑
- 首先
服务层
通过 控制层接口接收到前端发送过来的数据
- 服务层讲请求消息推送给
消息队列RabbitMQ
, 让 消息队列来处理相关逻辑
3.当服务层推送完
请求消息
,就可以立即返回给前端,效应true!
鱼式疯言
在前面的RabbitMQ 的配置中, 我们讲解过消息队列RabbitMQ 这一系列的使用,主要处理高请求量高峰期的场景~
当数据量很高的情况, 然后业务逻辑很复杂的情况下, 使用RabbitMQ就可以实现异步处理, 事务回滚等…
后端可以先保证抽奖请求一定成功: 前端接收到抽奖成功!
后端于是就把
请求消息生产给MQ
, 让 MQ处理相关的逻辑
这时需要保证两点:
-
MQ那边消息一定要
消费成功
-
保证事务的一致性, 如果出现异常,就需要及时处理,并成功消费掉信息,否则会出现堆积
在 MQ
的业务逻辑处理过程中
设想一下
有一天小编和女神出去逛公园,
走着走着就出现想法不和,惹女神不开心了!, 就好比MQ消息消费异常!
如果
MQ消息消费异常(吵架了)
, 就需要一定次数的重发,也就是多次去哄女神, 假如设定哄的次数为 5 次,如果哄的次数超过5次
那么小编就需要重新存放到死信队列 (重新思考女神怎么样才能开心起来的解决策略) , 于是再次去 消息重发, 进行处理异常, 重新再次去哄 。
如此循环往复,直到 女神满意
,保证 消息消费成功 ,不会出现异常 ~
但是出现消息处理异常的之后,还需要保证 事务的一致性 :
比如如果一个抽奖活动,当奖品已经从正在抽奖中,逆转为已被抽取, 但是当逆转人员的抽奖状态的时候发现异常, 这时需要保证
事务的一致性
(把已经修改的数据库表的内容恢复到处理消息之前的内容) , 就需要把奖品状态逆转回正在抽奖中的状态~
怎么理解呢?
就好比
有一天,女神邀请我来向她学习化妆 , 这样以后好给她化妆。
但是,学着学着, 小编不小心把女神的香水瓶给打破了,已经惹女神不高兴了~
那么我首先做的 第一件时除了哄女神之外,还需要恢复之前的状态 (配一瓶香水给女神,恢复原样!)
2. MQ 业务逻辑处理
<1>. MQ普通队列的业务逻辑实现
如时序图:
- 首先
MQ将请求消息参数传给service层
进行校验:
- 查询 活动是否存在
- 查询 活动奖品是否存在
- 查询 活动是否完成
- 查询 奖品是否被抽取完成
- 查询 奖品人员是否抽取完成
- 查询 奖品人数是否小于等于中奖人员人数
- 进行状态扭转(采用
责任链模式
,策略模式
两种设计模式)依次对中奖人员状态,活动关联奖品状态,活动状态依次进行逆转~
- 首先定义需要逆转的目标状态传入状态管理器修改
- 在状态管理器下统一进行事务处理
判空
修改
数据库中的表状态
同步
缓存中的活动状态
逆转成功之后, 保存中奖记录,同时发生短信,邮件通知中奖者 !
保存中奖记录的主要流程:
从MySQL中查询中奖者,活动,奖品消息
整合以上数据,并保存到
MySQL
的中奖记录表中缓存进
Redis
的奖品维度
的记录信息~缓存进
Redis
的活动维度
的记录信息!
- 若逆转失败,为 保证事务一致性,回滚事务初始状态!
有始必有终, 竟然 状态扭转的过程是在状态管理器中进行的, ==那么状态的回滚也必然要通过它来操作!
- 状态管理器回滚事务
- 首先由于是以奖品为维度的状态扭转, 判断奖品状态是否被逆转, 如果逆转了才需要被回滚!
- 然后 全部回滚到处理前的原始状态
- 最后 修改缓存Redis中的状态也为原始状态!
鱼式疯言
在上面的环节中, 小伙伴是否留意状态扭转这个代码的实现是否有点复杂~
是这样的! 小编在这里使用了常用的设计模式——责任链模式, 策略模式
如果不用这两个模式, 逆转状态的话, 可能小伙伴们可以会
- 判断是否需要
逆转奖品
,人员
- 先逆转
奖品和人员
- 然后根据 奖品的状态和人员的状态, 还得判断活动状态是否需要逆转!
- 最后
逆转活动
这样写必然是没有问题的,但是小伙伴是否思考过,如果可能出现多个关联的状态需要扭转, 是否会出现在原来的代码上大幅度的修改,耦合度过大的情况
如果需要 调整状态逆转的顺序,如果要复用一些功能,也达不到方便复用的便捷, 可能还需要扩展一部分功能,也是难以实现的~
所以我们就需要引入责任链,策略模式来解决上述的问题!
首先, 什么是责任链模式,策略模式?
简单来说:责任链模式就是每一个对象都有机会去尝试是否执行请求的要求,如果能符合能执行的条件, 就承担单一责任处理单独的业务逻辑。
好比工作中, 老板考核某个员工,如果某个员工能达到考核要求, 就会分配单独的任务给他, 如果不能通过考核, 就只能寻找下一位员工进行考核,直到知道符合要求才分配任务并完成。
小编认为: 也好比一个链表,从头到尾去找,如果找到某个结果符合要求, 就交给这个节点拥有单一责任去完成请求, 所以才称之为责任链模式
而
策略模式
,就是 将多种的算法逻辑进行各种封装,让算法逻辑变化独立于它的实现者,可以进行替换,复用等优势。
小编认为: 封装着多种不同的算法逻辑,当调用者需要用的话,就可以不断的更新替换策略 , 所以称之为策略模式~
说完概念, 不结合咱们项目的具体实现都是 “耍流氓”
AbstractActivityOperator 活动控制器
如上图:
- 在上层:
AbstractActivityOperator
统一管理每个单一的责任节点
- 到具体每个实现的类中, 可自行调整顺序灵活支配责任链。更好的符合当下业务场景, 先处理奖品, 中奖人员的状态为
1
, 最后处理活动相关状态为2
。
- 无论是 中奖人员,奖品人员,还是活动人员的扭转状态的思路大体都是一样的: 首先判断是否需要扭转,最后进行逆转逻辑。
- 但是在具体的代码的实现中,是有很大差别的,这时就需要借助
策略模式
的思路,封装每一步,各自实现不同状态逆转的代码逻辑。
小伙伴认识到了 责任链和策略模式 的作用!
小编就浅浅的总结下, 这俩大设计模式在等下逆转关联状态下的优势:
责任链模式的小优点:
- 解耦合: 能有效的将请求和效应两端独立开去实现,有效的降低耦合程度
- 扩展性: 首先责任链是可以动态调整顺序的, 不仅是使用多个场景; 并且如果需要修改和添加某个业务逻辑 , 不仅只需要实现
AbstractActivityOperator
这个抽象类, 对于整个系统来说, 每个业务逻辑又是独立的,几乎不影响原有代码的实现,增加可扩展性。
- 可维护性:如果某个实现逻辑出现异常, 可快速对责任链上的异常节点快速查找错误并解决!
策略模式的小优点:
解耦合: 对多种不同的算法逻辑进行实现, 每一种算法逻辑都进行了封装, 不会相互影响各字的实现逻辑。 降低了耦合程度。
可扩展性:对上面的多种实现思路进行了
封装
, 不仅方便调用,而且 能在原有的代码的基础上再创建方法实现新的算法逻辑。
复用性: 封装了多种算法逻辑,每个方法相当于一个个
小盒子
, 不仅方便随时调用, 还和抽离出独立的一个个模块多次复用。
<2>. 死信队列重发
如上图, 当MQ进行多次消息重发之后, 仍然失败!
下一步就会将异常的信息请求加入到死信队列。
死信队列就会实时消息, 如果有异常就需要重新检查逻辑代码的情况, 发现问题并解决!
如果异常解决了,进行MQ就会重新进行消息的重发~保证请求消息一定发送并且处理成功!
- 在本项目中, 当出现了异常,MQ会进行五次消息重发, 都重发失败后存入死信队列。
- 当存入死信队列后, 由于死信队列会实现消费请求数据,请求消息会重新加入到普通队列中。
- 最后普通队列又会进行不断的消费, 如果异常没有及时处理之后,又会 重新进行消息重发~
- 如果 消息没有及时的处理成功, 请求消息就会不断的堆积到死信队列中!
3. 中奖记录展示
<1>. 约定前后端交互接口
如上图:
前端可传入一个 活动id
, 后端 处理完相关业务逻辑后,返回该活动下所以的中奖者以及中奖的相关信息 返回给前端展示~
<2>. 梳理实现逻辑
如时序图:
- 首先用户传入活动id/奖品id, 查看中奖名单
- 判断
Redis
是否存在中奖记录,如果存在, 就去Redis中获取直接返回。
- 如果
Redis
不存在, 就去Mapper层调用MySQL的中奖记录表
进行活动/奖品维度中奖记录的查询
- 正确从MySQL查询到的结果,不要先返回, 还需要同步Redis中的MySQL的数据, 然后才返回!
鱼式疯言
需要注意的是:
怎么辨别是 活动维度的中奖记录还是奖品维度的中奖记录 ?
其实很简单, 小编这里教小伙伴们一招:
如果前端只是传入一个
活动id
, 说明 用户只需要该活动下的所有中奖记录, 必然是活动维度的中奖记录!
如果前端既传入了
活动id
,同时也传入了奖品id
, 说明 用户是既要在当下活动的,并且也要具体到某个奖品的中奖记录, 必然是奖品维度的中奖记录!
十一. Mapper模块XML实现
Mapper层定义接口, Mybatis 的
XML
用于 实现接口的功能 , 书写SQL语句以此来操作 数据库
有一些 重要且常用的动态SQL语句,小编在这里需要重点介绍一下~
1. 常用易错语句详解
主要查询用户的数量, 电话号码,邮箱,身份信息相关的功能,这边基本的小编就不赘述了, 其中数量的查询是可以用
count(1)
这样的调用方式 ~
需要注意的有——> 条件查询, 匹配查询, 分批传入
下面小编拿两个表举例细讲哦~
<1>. 条件查询
<select id="selectUserListByIdentity" resultType="com.example.lottery_system.dao.DataObject.UserDO">
select * from user
<if test =" identity!=null">
where `identity` = #{identity}
</if>
order by id desc
</select>
上面的条件查询, 使用
<if></if> 标签
, 其中标签上使用 test 填入需要判定的条件, 并且需要用双引号包裹, 如果双引号里面标签满足, 就执行<if></if>
标签里面的 whereidentity = #{identity}
内容。
<2>. 匹配查询
<select id="selectDOByUserIds" resultType="com.example.lottery_system.dao.DataObject.UserDO">
select * from user where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
匹配查询的作用, 就是把 多个单一的元素
, 放在一起去查:
就像上面这段语句,
在where id in
后面加上一个<foreach></foreach>
表示 循环遍历元素 ,
- 其中在标签上
collection
表示 传入的列表名 ,item
表示需要操作的 每一个元素的定义 ,open
表示in
的 开始,separator
表示 每一个元素用什么符号分隔 ,close
表示in
的 结束。
等效于SQL语句:
select * from user where id in (id1, id2 , id3 ...);
<3>. 分批插入
将 多条数据一次性插入 到
库表
中
<insert id="insertDoList">
insert into winning_record (activity_id, activity_name,
prize_id,prize_name , prize_tier,
winner_id,winner_name , winner_email, winner_phone_number, winning_time)
values <foreach collection="items" item="item" separator="," >
(#{item.activityId} , #{item.activityName},
#{item.prizeId} , #{item.prizeName} , #{item.prizeTier},
#{item.winnerId} , #{item.winnerName},#{item.winnerEmail},#{item.winnerPhoneNumber}, #{item.winningTime})
</foreach>
</insert>
如上面的语句
- 在
values
后加入<foreach></foreach> 标签
表示需要循环插入数据- 其中在标签上,
collection
表示 设置的列表参数名item
表示需要操作的每一个对象名separator
表示每一个括号之间使用逗号分隔
等效于SQL语句 :
insert into winning_record (activity_id, activity_name,
prize_id,prize_name , prize_tier,
winner_id,winner_name , winner_email,
winner_phone_number, winning_time) values (#{item1.activityId} , #{item1.activityName},
#{item1.prizeId} , #{item1.prizeName} , #{item1.prizeTier},
#{item1.winnerId} , #{item1.winnerName},#{item1.winnerEmail},#{item1.winnerPhoneNumber}, #{item1.winningTime}) ,
(#{item2.activityId} , #{item2.activityName},
#{item2.prizeId} , #{item2.prizeName} , #{item2.prizeTier},
#{item2.winnerId} , #{item2.winnerName},#{item2.winnerEmail},#{item2.winnerPhoneNumber}, #{item2.winningTime});
其他SQL都是比较简单的, 小伙伴们可以细看小编的文章来学习哦~
12. 项目部署与项目代码gitee
1. 项目部署
当我们写完项目代码并成功运行项目, 并没有出现 BUG
的后, 我们就可以 部署到自己的服务器上让所有人访问我们这个项目了~
<1>. 添加配置
- 开发环境配置转化为 测试环境配置
当需要部署项目的时候, 我们就需要 把平常在本地的配置修改成服务器兼容的配置才可正常使用:
-
主配置:
-
dev 自身开发环境的配置:
-
test 测试环境配置:
当需要使用 部署环境, 直接在主配置文件的等号右边写上test:
spring.profiles.active=test
如果是 自身的开发环境, 直接在主配置文件的等号右边写上dev:
spring.profiles.active=dev
鱼式疯言
什么是开发环境, 什么是测试环境, 什么又是线上/生产环境, 想必小伙伴们一定听过很多吧~
这里小编就大致的说明一下 企业级的开发环境:
开发环境: 就是我们 程序猿们自身编写代码,自己测试, 自己玩转的环境~
测试环境:
开发环境完成之后就会进入测试环境
,由 测试人员测试咱们开发人员的写的代码,保存咱们软件的产品质量。
线上/生产环境: 测试环境完成之后就进入线上/生产环境, 真正到达用户的手上, 线上用户使用软件的环境~
由于咱们是个人项目, 所以就不分什么 测试环境
, 和 线上/生产环境
, 都统一称为 test
测试环境
- 日志相关配置
小伙伴们复制即可,这里不做了解哦~
<2>. 打包jar
<3>. 部署项目
- 拖拽上传文件
- 运行项目
使用 java -jar 【jar包名】
运行程序
运行成功,看看效果~
收工,over
~
如果对这边部署有问题的小伙伴们, 可以参考小编写的具体的部署项目的文章详解哦~
2. 项目全代码gitee
相比打开过前面URL的小伙伴应该都明白, 前面的URL 也出自这里小编的个人 gitee