在线抽奖系统——通用模块
目录
项目创建
通用功能模块
错误码
自定义异常类
CommonResult
jackson
加密工具
项目创建
使用 Idea 创建 SpringBoot 项目,并引入相关依赖:
导入前端页面
前端页面:前端代码/在线抽奖系统 · Echo/project - 码云 - 开源中国
将其导入到 static 目录下:
通用功能模块
错误码
错误码主要用于标识和处理程序运行中的各种异常情况,能够精确的指出问题所在
创建 errorcode 包,并定义错误码类型:
@Data
public class ErrorCode {
/**
* 错误码
*/
private final Integer code;
/**
* 错误描述
*/
private final String message;
public ErrorCode(Integer code, String message) {
this.code = code;
this.message = message;
}
}
定义全局错误码:
public interface GlobalErrorCodeConstants {
// 成功
ErrorCode SUCCESS = new ErrorCode(200, "成功");
ErrorCode BAD_REQUEST = new ErrorCode(400, "客户端请求错误");
ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500, "系统异常");
ErrorCode NOT_IMPLEMENTED = new ErrorCode(501, "功能未实现/未开启");
ErrorCode ERROR_CONFIGURATION = new ErrorCode(502, "配置项错误");
ErrorCode UNKNOWN = new ErrorCode(999, "未知错误");
}
定义 controller 层业务错误码:
public interface ControllerErrorCodeConstants {
}
其中的错误码信息随着后续业务代码的完成补充
定义 service 层业务错误码:
public interface ServiceErrorCodeConstants {
}
其中的错误码信息随着后续业务代码的完成补充
自定义异常类
自定义异常类是为了在程序中处理特定的错误或异常情境,使得异常处理更加清晰和灵活。通过自定义异常类,可以根据业务需求定义特定的异常类型,方便捕获和处理特定的错误
创建 exception 包,自定义异常类:
controller 层异常类:
@Data
@EqualsAndHashCode(callSuper = true)
public class ControllerException extends RuntimeException {
/**
* controller 层错误码
* @see com.example.lotterysystem.common.errorcode.ControllerErrorCodeConstants
*/
private Integer code;
/**
* 错误描述信息
*/
private String message;
/**
* 无参构造方法,为了后续进行序列化
*/
public ControllerException() {
}
public ControllerException(Integer code, String message) {
this.code = code;
this.message = message;
}
public ControllerException(ErrorCode errorCode) {
this.code = errorCode.getCode();
this.message = errorCode.getMessage();
}
}
service 层异常类:
@Data
@EqualsAndHashCode(callSuper = true)
public class ServiceException extends RuntimeException {
/**
* controller 层错误码
* @see com.example.lotterysystem.common.errorcode.ServiceErrorCodeConstants
*/
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.getMessage();
}
}
CommonResult<T>
CommonResult<T> 作为 控制层 方法的 返回类型,封装接口调用结果,包括成功数据、错误数据 和 状态码,可以被 SpringBoot 框架自动转化为 JSON 或其他格式的响应体,发送给客户端
定义业务处理成功和失败时返回的 CommonResult<T>:
@Data
public class CommonResult<T> implements Serializable {
/**
* 错误码
*/
private Integer code;
/**
* 返回数据
*/
private T data;
/**
* 错误描述信息
*/
private String errorMessage;
public CommonResult() {
}
/**
* 运行成功时返回结果
* @param data
* @return
* @param <T>
*/
public static <T> CommonResult<T> success(T data) {
CommonResult<T> result = new CommonResult<>();
result.code = GlobalErrorCodeConstants.SUCCESS.getCode();
result.data = data;
result.errorMessage = "";
return result;
}
/**
* 运行失败时返回结果
* @param code
* @param errorMessage
* @return
* @param <T>
*/
public static <T> CommonResult<T> fail(Integer code, String errorMessage) {
Assert.isTrue(!GlobalErrorCodeConstants.SUCCESS.getCode().equals(code),
"code = 200, 运行成功");
CommonResult<T> result = new CommonResult<>();
result.code = code;
result.errorMessage = errorMessage;
return result;
}
/**
* 运行失败时返回结果
* @param errorCode
* @return
* @param <T>
*/
public static <T> CommonResult<T> fail(ErrorCode errorCode) {
return fail(errorCode.getCode(), errorCode.getMessage());
}
}
其中,serializable 接口是 java 提供的一个标记接口(空接口),用于指示一个类的对象可以被序列化,无需实现任何方法,定义在 java.io 包中
此外,若想在 idea 中使用断言,需要先开启断言功能,可参考:
如何开启idea中的断言功能?_idea开启断言-CSDN博客
jackson
在前后端交互的过程中,经常会使用 JSON 格式来传递数据,这也就涉及到 序列化 和 反序列化,此外,我们在进行日志打印时,也会涉及到序列化
因此,我们可以定义一个工具类,来专门处理 序列化
在 java 中,通常使用 ObjectMapper 来处理 Java 对象与 JSON 数据之间的转换
查看 SpringBoot 框架中是如何实现的:
不同类型的对象序列化是基本相同的,都是使用 writeValueAsString 方法来进行序列化,因此我们主要来看反序列化:
可以看到,反序列化 Map 和 List 都调用了 tryParse 方法,并传递了两个参数:一个 lambda 表达式,一个 Exception
我们继续看 tryParse 方法:
其中,最主要的方法就是 parse.call(),通过 call() 方法,来执行定义的任务
且 tryParse 方法中对异常进行了处理:
check.isAssignableFrom(var4.getClass()) 判断抛出的异常是否是传入的 check 异常,若是,则抛出 JsonParseException 异常;若不是,则抛出 IllegalStateException 异常
可以看到,框架中通过 tryParse() 方法,巧妙地对异常进行了处理
因此,我们可以借鉴上述方法来进行实现
由于只需要使用一个 ObjectMapper 实例,因此可以创建 单例 ObjectMapper:
public class JacksonUtil {
private JacksonUtil() {}
private final static ObjectMapper OBJECT_MAPPER;
static {
OBJECT_MAPPER = new ObjectMapper();
}
private static ObjectMapper getObjectMapper() {
return OBJECT_MAPPER;
}
}
实现 tryParse 方法:
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, JsonParseException.class);
}
实现序列化方法:
/**
* 序列化
* @param value
* @return
*/
public static String writeValueAsString(Object value) {
return tryParse(() -> getObjectMapper().writeValueAsString(value));
}
反序列化:
/**
* 反序列化
* @param content
* @param valueType
* @return
* @param <T>
*/
public static <T> T readValue(String content, Class<T> valueType) {
return tryParse(() -> {
return getObjectMapper().readValue(content, valueType);
});
}
/**
* 反序列化 List
* @param content
* @param param List 中元素类型
* @return
*/
public static <T> T readListValue(String content, Class<?> param) {
JavaType javaType = getObjectMapper().getTypeFactory()
.constructParametricType(List.class, param);
return tryParse(() -> {
return getObjectMapper().readValue(content, javaType);
});
}
在对 List 类型进行反序列化时,不能直接将 List 类型传递给 valueType,而是需要构造一个 JavaType 类型
完整代码:
public class JacksonUtil {
private JacksonUtil() {}
private final static ObjectMapper OBJECT_MAPPER;
static {
OBJECT_MAPPER = new ObjectMapper();
}
private static ObjectMapper getObjectMapper() {
return OBJECT_MAPPER;
}
/**
* 序列化
* 对象 -> 字符串
* @param value
* @return
*/
public static String writeValueAsString(Object value) {
return JacksonUtil.tryParse(() -> JacksonUtil.getObjectMapper().writeValueAsString(value));
}
/**
* 反序列化
* 字符串 -> 对象
* @param content
* @param valueType
* @return
* @param <T>
*/
public static <T> T readValue(String content, Class<T> valueType) {
return JacksonUtil.tryParse(() -> {
return JacksonUtil.getObjectMapper().readValue(content, valueType);
});
}
/**
* 反序列化 List
* @param content
* @param param
* @return
*/
public static <T> T readListValue(String content, Class<?> param) {
JavaType javaType = JacksonUtil.getObjectMapper().getTypeFactory()
.constructParametricType(List.class, param);
return JacksonUtil.tryParse(() -> {
return JacksonUtil.getObjectMapper().readValue(content, javaType);
});
}
/**
* 进行序列化/反序列化
* @param parser
* @param check
* @return
* @param <T>
*/
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, JsonParseException.class);
}
}
加密工具
在对敏感信息(如密码、手机号等)进行存储时,需要进行加密,从而保证数据的安全性,若直接明文存储,当黑客入侵数据库时,就可以轻松拿到用户的相关信息,从而造成信息泄露或财产损失
在这里,可以使用 md5 对用户密码进行加密:
采用 判断哈希值是否一致 的方法来判断密码是否正确
除了密码以外,手机号也是重要的隐私数据,但手机号与密码不同:对于后端来说,不知道密码的明文也不会对业务逻辑造成影响;但后端需要明文的手机号,在一些情况下给用户发送短信
因此,对于手机号这样的信息,需要采用相对安全的做法:先对手机号进行对称加密,再将加密结果存储在数据库中;在使用时再使用密钥对其进行解密
在这里,我们使用 Hutool 的加密工具:加密解密工具-SecureUtil
引入依赖:
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
上述引入的是所有模块,但其实绝大部分项目可能都用不上,因此可以只引入需要的模块
使用对应方法进行加解密:
@SpringBootTest
public class SecurityTest {
/**
* 使用 aes 对用户手机号进行加密
*/
@Test
void test() {
// 密钥
String key = "1234567890abcdefghijklmn";
AES aes = SecureUtil.aes(key.getBytes());
String encrypt = aes.encryptHex("12345678").toString();
System.out.println("加密结果:" +encrypt);
String decrypt = aes.decryptStr(encrypt);
System.out.println("解密结果:" + decrypt);
}
/**
* 使用 md5 对用户密码进行加密
*/
@Test
void md5Test() {
// 生成随机盐值
String salt = UUID.randomUUID().toString().replace("-", "");
System.out.println("salt: " + salt);
String password = "123456";
// 进行加密
String encrypt = DigestUtil.md5Hex(password +salt);
System.out.println("加密结果:" + encrypt);
}
}
测试结果:
实现 SecurityUtil 工具类:
@Slf4j
public class SecurityUtil {
// 密钥
private static final String AES_KEY = "3416b730f0f244128200c59fd07e6249";
/**
* 使用 md5 对密码进行加密
* @param password 输入的密码
* @return 密码 + 盐值
*/
public static String encipherPassword(String password) {
String salt = UUID.randomUUID().toString().replace("-", "");
String secretPassword = DigestUtil.md5Hex(password + salt);
return secretPassword + salt;
}
/**
* 验证用户输入的密码是否正确
* @param inputPassword 用户输入密码
* @param sqlPassword 数据库中存储密码
* @return
*/
public static Boolean verifyPassword(String inputPassword, String sqlPassword) {
log.info("用户输入密码:{}, 数据库获取密码:{}", inputPassword, sqlPassword);
if (!StringUtils.hasLength(inputPassword)) {
return false;
}
if (!StringUtils.hasLength(sqlPassword) || sqlPassword.length() != 64) {
return false;
}
String salt = sqlPassword.substring(32, 64);
String secretPassword = DigestUtil.md5Hex(inputPassword + salt);
return sqlPassword.substring(0, 32).equals(secretPassword);
}
/**
* 使用 aes 对用户手机号进行加密
* @param phone
* @return
*/
public static String encipherPhone(String phone) {
return SecureUtil.aes(AES_KEY.getBytes()).encryptHex(phone);
}
/**
* 对加密的手机号进行解密
* @param encryptPhone
* @return
*/
public static String decryptPhone(String encryptPhone) {
// log.info("解析 encryptPhone:{}", encryptPhone);
return SecureUtil.aes(AES_KEY.getBytes()).decryptStr(encryptPhone);
}
}
AES 使用的密钥可以使用 UUID 生成
日志配置
在 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>
<!--只保留最近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>
<!--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>
若当前处于开发环境,则将日志直接输出到控制台
若当前位于生产及测试环境,则分开存放 error 级别和 info 级别的日志,并采用基于时间的日志滚动策略,设置日志保留周期为14天
由于需要过滤 info 级别的日志,因此我们需要新增一个自定义过滤器:
在 com.example.lotterysystem.common.filter 路径下新增 InfoLevelFilter:
public class InfoLevelFilter extends Filter<ILoggingEvent> {
@Override
public FilterReply decide(ILoggingEvent iLoggingEvent) {
if (iLoggingEvent.getLevel().toInt() == Level.INFO.toInt()){
return FilterReply.ACCEPT;
}
return FilterReply.DENY;
}
}
在 application.yml 中新增配置:
logging:
config: classpath:logback-spring.xml