封面来源:由博主个人绘制,如需使用请联系博主。

0. 前言

不知不觉大学都要毕业了,然而自己还是个菜狗 🐶,写个毕业设计前端不咋会就算了,后端也写得菜 🥬。回望四年,这毕业设计还是我真正写完的一个项目(以前的项目要么半途而废,要么仅仅是一个 demo)。

曾子曰:“吾日三省吾身”,完成了毕业设计后进行一个总结还是很有必要的。

在本文中,我将从前端与后端进行总结,虽说是总结,也仅仅是记录一下自己曾经的知识盲点罢了。

后端技术栈:SpringBoot + SpringSecurity + JWT + MyBatis-Plus + MapStruct + Hutool(默认引入 Lombok)

后端数据库:Redis + MySQL 8.0

前端技术栈:Vue.js + Element-UI


关于后端 JWT 和 SpringSecurity 的相关使用,可以参看【JWT的基本使用】一文。

关于后端 MapStruct 的使用,可以参看【MapStruct使用手册】一文。

关于后端 MyBatis-Plus 的使用,可以参看【MyBatis的“好基友”——MyBatisPlus】一文。

1. 后端 - 自定义类

1.1 JSR-303 异常信息类

JSR-303 异常信息类,在使用 SpringBoot 进行全局异常处理时,针对产生的 MethodArgumentNotValidException 异常进行错误信息组装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Getter
@Setter
public class JSR303ExceptionMessage {
/**
* 错误字段
*/
private String field;
/**
* 错误对象
*/
private String objectName;
/**
* 错误的值
*/
private Object rejectedValue;
/**
* 错误信息
*/
private String errorMsg;
}

1.2 自定义业务异常类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* 自定义业务异常类,用于在业务层抛出异常
*/
public class BusinessException extends RuntimeException {
private static final long serialVersionUID = 5382880814065968592L;
/**
* 默认异常码
*/
private String code = "BOOT-001";

private Set<String> errors = new HashSet<>();

public BusinessException() {
}

public BusinessException(String code) {
this.code = code;
}

public BusinessException(String code, Throwable cause) {
super(cause);
this.code = code;
this.errors.add(cause.getMessage());
}

public BusinessException addError(String msg) {
this.errors.add(msg);
return this;
}

@Override
public String getMessage() {
String msg;
if (this.errors.isEmpty()) {
msg = super.getMessage();
} else {
StringBuilder sb = new StringBuilder(this.errors.size() * 15);

for (String message : this.errors) {
sb.append(message).append(",");
}

sb.deleteCharAt(sb.length() - 1);
msg = sb.toString();
}

return msg == null ? this.code() : msg;
}

public String code() {
return this.code;
}

public String message() {
return this.getMessage();
}
}

1.3 统一返回值

后端 Service 层都将统一返回一个类型,便于后续操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/**
* @author mofan 2021/2/28
* 控制器结果信息返回类
*/
public class Result<T> implements Serializable {
private static final long serialVersionUID = 5244827382777974660L;

public static final String SUCCESS_CODE = "BOOT-000";

private String code;
private String msg;
private T data;
/**
* 是否需要打印日志的标志
*/
private boolean log = false;

public static <T> Result<T> success() {
return new Result<>();
}

public static <T> Result<T> success(T data) {
return new Result<>(data);
}

public static <T> Result<T> error(String code, String msg) {
return new Result<>(code, msg);
}

public Result(String code, String msg) {
this.code = code;
this.msg = msg;
this.data = null;
}

public Result(T data) {
this.code = SUCCESS_CODE;
this.msg = ResultMsg.SUCCESS;
this.data = data;
}

public Result() {
this.code = SUCCESS_CODE;
this.msg = ResultMsg.SUCCESS;
this.data = null;
}

public String getCode() {
return code;
}

public Result<T> setCode(String code) {
this.code = code;
return this;
}

public String getMsg() {
return msg;
}

public Result<T> setMsg(String msg) {
this.msg = msg;
return this;
}

public T getData() {
return data;
}

public void setData(T data) {
this.data = data;
}

public boolean isLog() {
return this.log;
}

public Result<T> setLog(boolean log) {
this.log = log;
return this;
}
}

1.4 分页请求对象

为方便在查询时进行分页操作,规定将前端传递的分页请求进行包装。

注意: 后端使用的数据持久层框架是 Mybatis-Plus。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class PageRequest<T> {
/**
* 当前页数
*/
private Integer currentPage;

/**
* 页面数据大小
*/
private Integer pageSize;

/**
* 请求数据
*/
private Map<String, Object> data;

/**
* 根据 PageRequest 获取 MyBatis-Plus 的分页对象
* @param pageRequest 前端传递的 PageRequest 信息
* @param <T> 泛型
* @return MP 的 Page 分页对象
*/
public static<T> Page<T> getPageInfo(PageRequest<T> pageRequest) {
return new Page<>(pageRequest.getCurrentPage(), pageRequest.getPageSize());
}
}

1.5 枚举接口

后端中的大多数枚举都需要实现这个接口并重写其中的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @author mofan 2021/2/1
* 枚举信息,一般来说,每个枚举类都应该实现这个接口
*/
public interface EnumInfo extends Serializable {
/**
* 获取枚举值对应的代码
* @return 对应的代码
*/
String code();

/**
* 获取枚举值对应的信息
* @return 对应的信息
*/
String message();
}

1.6 实体基类

后端中使用到的 所有 实体都需要继承实体基类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* @author mofan 2021/2/1
* 实体基类,每个实体都需要继承该类
*/
public abstract class BaseEntity implements Serializable {

private static final long serialVersionUID = 6581451145034114785L;

/**
* 主键 雪花算法
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;

/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private Date createTime;

/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;

/**
* 是否被删除 使用逻辑删除
*/
@TableField(fill = FieldFill.INSERT, value = "is_deleted")
@TableLogic
private Integer deleted;

public Integer getDeleted() {
return deleted;
}

public void setDeleted(Integer deleted) {
this.deleted = deleted;
}

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public Date getCreateTime() {
return createTime;
}

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}

public Date getUpdateTime() {
return updateTime;
}

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
public void setUpdateTime(Date updateTime) {
this.updateTime = updateTime;
}
}

1.7 数据传输对象基类

后端使用到的所有 DTO(数据传输对象)都需要继承数据传输对象基类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author mofan 2021/2/1
*/
public abstract class BaseDto implements Serializable {

private static final long serialVersionUID = -6349988895227283284L;

/**
* 主键
*/
@JsonSerialize(using = ToStringSerializer.class)
private Long id;

public BaseDto() {
}

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}
}

2. 后端 - SpringBoot

2.1 统一异常拦截

为了更好地处理后端代码中的异常信息,可以使用 SpringBoot 提供的统一异常拦截。

步骤也很简单:

1、创建一个全局异常拦截器类,假设叫:GlobalExceptionInterceptor

2、在这个类上使用注解:@ControllerAdvice

3、在这个类中编写处理各个异常的方法(通常处理一个异常编写一个方法),并在方法上使用注解: @ExceptionHandler,这个注解的 value 属性值为被处理的异常类。

比如我所使用的全局异常拦截器类是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@Slf4j
@ControllerAdvice
@ResponseBody
public class GlobalExceptionInterceptor {

/**
* 用于 DTO 的 JSR303 校验返回
* @param e 异常
* @return 异常信息
*/
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result<JSR303ExceptionMessage> jsr303ExceptionHandler(MethodArgumentNotValidException e) {
JSR303ExceptionMessage jsr303ExceptionMessage = new JSR303ExceptionMessage();
FieldError fieldError = Objects.requireNonNull(e.getBindingResult().getFieldError());
jsr303ExceptionMessage.setErrorMsg(fieldError.getDefaultMessage());
jsr303ExceptionMessage.setField(fieldError.getField());
jsr303ExceptionMessage.setObjectName(fieldError.getObjectName());
jsr303ExceptionMessage.setRejectedValue(fieldError.getRejectedValue());

Result<JSR303ExceptionMessage> result = new Result<>();
result.setCode(ErrorCode.JSR303_VERIFICATION_FAILED).setMsg("提交的数据错误").setData(jsr303ExceptionMessage);
return result;
}

/**
* 业务层抛出的异常 BusinessException
*
* @param e 异常类型
* @return 错误信息
*/
@ExceptionHandler(value = BusinessException.class)
public Result<BusinessException> businessExceptionHandler(BusinessException e) {
return Result.error(e.code(), e.message());
}

/**
* Excel 解析异常
* @param e 异常类型
* @return 错误信息
*/
@ExceptionHandler(value = ExcelAnalysisException.class)
public Result<ExcelAnalysisException> excelAnalysisExceptionHandler(ExcelAnalysisException e) {
return Result.error(ErrorCode.EXCEL_ANALYSIS_ERROR, e.getMessage());
}

/**
* 统一异常拦截向上抛出 AccessDeniedException 异常,由自定义 AccessDeniedHandler 进行处理
* @param e 异常类型
* @throws AccessDeniedException 访问拒绝异常
*/
@ExceptionHandler(value = AccessDeniedException.class)
public void accessDeniedExceptionHandler(AccessDeniedException e) throws AccessDeniedException{
throw new AccessDeniedException("没有访问权限");
}

/**
* 上传文件尺寸过大,超过默认 10MB 或经过配置的大小
* @param e 异常类型
* @return 错误信息
*/
@ExceptionHandler(value = MaxUploadSizeExceededException.class)
public Result<MaxUploadSizeExceededException> maxUploadSizeExceededException(MaxUploadSizeExceededException e) {
return Result.error(ErrorCode.MAX_UPLOAD_SIZE_EXCEEDED, "上传文件尺寸过大");
}

@ExceptionHandler(value = OSSException.class)
public Result<OSSException> ossException(OSSException e) {
return Result.error(ErrorCode.UPLOAD_FILE_TO_OSS_ERROR, "上传至客户端失败");
}

@ExceptionHandler(value = ClientException.class)
public Result<ClientException> clientException(ClientException e) {
return Result.error(ErrorCode.ALI_OSS_CLIENT_ERROR, "文件客户端异常,请联系系统管理员处理!");
}

/**
* 各种非具体指定异常的统一处理
*
* @param e 异常类型
* @return 错误信息
*/
@ExceptionHandler(value = Exception.class)
public Result<Exception> exceptionHandler(Exception e) {
log.error(e.getMessage());
return Result.error(ErrorCode.UNKNOWN_ERROR, "未知的错误,请联系系统管理员处理!");
}
}

2.2 JSR-303 校验

前端传递数据作为后端方法的入参时,我们需要对这些参数进行校验,比方说检验是否非空。为了使代码简洁而不采取 if - else 的方式进行判断,而是采用 JSR-303 检验。

步骤也很简单:

1、为 Controller 里方法的参数使用 @Valid 注解;

2、在参数对应的类的属性上使用 JSR-303 注解。

比如:

1
2
3
4
5
6
7
8
@PostMapping("submit")
public Result<Long> saveOrUpdate(@Valid @RequestBody DeptDefinitionDto dto) {
if (dto.getId() == null) {
return deptService.createDept(dto);
} else {
return deptService.updateDeptInfo(dto);
}
}

DeptDefinitionDto 对应的部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Getter
@Setter
public class DeptDefinitionDto {

/**
* 部门名称
*/
@NotNull(message = "部门名称不能为空")
private String deptName;

// ...

/**
* 传真电话
*/
@FaxNum(notNull = false)
private String fax;
}

2.3 自定义校验注解

在特定的场景下,使用 JSR-303 提供的注解无法完成我们的校验需求,这个时候就需要自定义注解,比方说前文中 DeptDefinitionDto 类中的 @FaxNum 注解。

自定义注解的步骤也很简单:

1、创建自定义注解,注解中要有属性 messagegroupspayload,还要在自定义的注解上使用注解 @Constraint,并指定其 validatedBy 属性值为自定义的约束验证器(第二步创建)。

2、创建自定义的约束验证器,这个类实现 ConstraintValidator 接口,这个接口的泛型为:自定义的注解和需要被校验的参数类型。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Documented
@Constraint(validatedBy = FaxNumValidator.class)
@Target({ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface FaxNum {
String message() default "传真电话号码格式错误";

Class[] groups() default {};

Class[] payload() default {};

/**
* 是否进行非空验证
*/
boolean notNull() default true;
}

自定义约束验证器 FaxNumValidator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class FaxNumValidator implements ConstraintValidator<FaxNum, String> {

private static final String FAX_NUM_REG_EXP = "\\d{3}-\\d{8}|\\d{4}-\\d{7}";

private static boolean notNull = true;

/**
* 成员变量初始化
*/
@Override
public void initialize(FaxNum constraintAnnotation) {
notNull = constraintAnnotation.notNull();
}

/**
* 检验规则
*/
@Override
public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
if (s == null || "".equals(s)) {
return !notNull;
}
return s.matches(FAX_NUM_REG_EXP);
}
}

在上述代码中使用了正则表达式进行内容验证,常用的正则表达式参考:正则表达式在线测试 [1]

2.4 文件上传限制

在上传文件时,Spring 默认单个文件最大为 1M,单次请求中文件大小合计最大为 10M,如果查过这个数值就会抛出异常。

当然这些数值也是可以修改的,只需要在配置文件追加:

1
2
3
4
5
6
7
8
9
10
11
spring:
servlet:
multipart:
# 单个文件最大大小
max-file-size: 20MB
# 请求中文件大小合计最大
max-request-size: 200MB
server:
tomcat:
# 不限制内嵌 Tomcat 最大吞吐量,用于全局异常处理进行捕获
max-swallow-size: -1

并在全局异常拦截中追加:

1
2
3
4
5
6
7
8
9
10
/**
* 上传文件尺寸过大,超过默认 10MB 或经过配置的大小
*
* @param e 异常类型
* @return 错误信息
*/
@ExceptionHandler(value = MaxUploadSizeExceededException.class)
public Result<MaxUploadSizeExceededException> maxUploadSizeExceededException(MaxUploadSizeExceededException e) {
return Result.error(ErrorCode.MAX_UPLOAD_SIZE_EXCEEDED, "上传文件尺寸过大");
}

3. 后端 - 阿里云 OSS

在本次的项目中使用到了阿里云 OSS,用于存储需要上传的文件。在此介绍一下后端整合阿里云 OSS 的方法,默认已注册阿里云,并成功配置 OSS(获取到公钥和私钥)。

首先需要导入依赖:

1
2
3
4
5
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.10.2</version>
</dependency>

然后在配置文件中追加以下配置:

1
2
3
4
5
6
7
# 阿里oss配置
oss:
endpoint: oss-cn-chengdu.aliyuncs.com # oss地域名
accessKeyId: xxxx # 公钥
accessKeySecret: xxxx # 私钥
bucketName: gsams-files # bucket名称
dirName: exam-outline/ # 文件目录

注意: 导入的依赖并不是 SpringBoot 的 starter,仅仅是 OSS 的 SDK,这里编写的配置文件仅仅是为了方便管理,如果不写在配置文件中,也可以硬编码在 Java 代码中。

读取追加的 OSS 配置信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
* @author mofan
* @date 2021/6/2 13:43
*/
@Getter
@Setter
@Configuration
public class OssConstant implements InitializingBean {

/**
* 文件最大上传大小
*/
public static long FILE_MAX_SIZE = 1024 * 1024 * 10L;

/**
* 图片过期时间 100年
*/
public static long FILE_EXPIRATION_TIME = 1000L * 3600L * 24L * 365L * 100L;

public static String END_POINT;
public static String ACCESS_KEY_ID;
public static String ACCESS_KEY_SECRET;
public static String BUCKET_NAME;
public static String DIR_NAME;

/**
* 阿里云 oss 站点
*/
@Value("${oss.endpoint}")
private String endpoint;

/**
* 阿里云 oss 公钥
*/
@Value("${oss.accessKeyId}")
private String accessKeyId;

/**
* 阿里云 oss 私钥
*/
@Value("${oss.accessKeySecret}")
private String accessKeySecret;

/**
* 阿里云 oss 文件根目录
*/
@Value("${oss.bucketName}")
private String bucketName;
/**
* 文件目录
*/
@Value("${oss.dirName}")
private String dirName;

@Override
public void afterPropertiesSet() {
END_POINT = endpoint;
ACCESS_KEY_ID = accessKeyId;
ACCESS_KEY_SECRET = accessKeySecret;
BUCKET_NAME = bucketName;
DIR_NAME = dirName;
}

}

编写 OSS 的配置类,将读取到的配置信息注入到 Spring 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author mofan
* @date 2021/6/2 13:46
*/
@Configuration
public class OssConfig {

@Bean
public OSSClient ossClient() {
return new OSSClient(
OssConstant.END_POINT,
new DefaultCredentialProvider(OssConstant.ACCESS_KEY_ID, OssConstant.ACCESS_KEY_SECRET),
new ClientConfiguration());
}
}

最后编写具体 Service 服务(仅以文件上传为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
/**
* @author mofan
* @date 2021/6/2 13:48
*/
@Slf4j
@Service
public class OssService {

@Autowired
private OSSClient ossClient;

/**
* 上传 PDF 文件至阿里云 OSS
* 文件上传成功,返回文件完整访问路径
*
* @param file 上传文件
* @return 文件地址
*/
public Result<String> uploadPdf(MultipartFile file) {
if (FileUtil.isNotPdfFile(file)) {
log.error("pdf文件上传失败");
throw new BusinessException(ErrorCode.FILE_TYPE_ERROR).addError("上传文件类型错误");
}
if (file.getSize() <= 0 || file.getSize() > OssConstant.FILE_MAX_SIZE) {
throw new BusinessException(ErrorCode.FILE_SIZE_TOO_LARGE).addError("尺寸过大");
}
// 文件全名称包括后缀
String oldName = file.getOriginalFilename();
Assert.notNull(oldName, "文件全名称不能为空");
// 文件后缀
String suffix = oldName.substring(oldName.lastIndexOf(".") + 1);
// 文件名称 不包括后缀
String prefix = oldName.substring(0, oldName.lastIndexOf("."));
// 新文件名称 文件目录路径 + uuid(不带 -) + 旧文件名
String fileName = OssConstant.DIR_NAME + IdUtil.simpleUUID()
.replace("-", "") + "_" + oldName;
// 获取文件类型
String fileType = contentType(suffix);
try (InputStream inputStream = file.getInputStream()){
return Result.success(putFile(inputStream, fileType, fileName));
} catch (IOException e) {
throw new BusinessException(ErrorCode.FILE_IO_ERROR).addError("PDF上传失败");
}
}


/**
* 上传文件
*
* @param input 文件输入流
* @param fileType 文件类型
* @param fileName 文件名
* @return 文件地址
*/
private String putFile(InputStream input, String fileType, String fileName) {
// 创建上传Object的Metadata
ObjectMetadata meta = new ObjectMetadata();
// 设置上传内容类型
meta.setContentType(fileType);
//被下载时网页的缓存行为
meta.setCacheControl("no-cache");
//创建上传请求
PutObjectRequest request = new PutObjectRequest(OssConstant.BUCKET_NAME, fileName, input, meta);
//上传文件
ossClient.putObject(request);
// 返回文件地址
return getOssUrl(fileName);
}

/**
* 根据文件名生成文件的访问地址
*
* @param fileName 文件名
* @return 文件所在位置
*/
private String getOssUrl(String fileName) {
return "https://" + OssConstant.BUCKET_NAME + "." + OssConstant.END_POINT + "/" + fileName;
}


/**
* 根据 url 获取 fileName
*
* @param fileUrl 文件 url
* @return 文件名称
*/
private String getFileName(String fileUrl) {
String str = "aliyuncs.com/";
int beginIndex = fileUrl.indexOf(str);
if (beginIndex == -1) {
return null;
}
return fileUrl.substring(beginIndex + str.length());
}

/**
* @param fileUrls 文件 url
* @return {@literal List<String>} fileName 集合
*/
private List<String> getFileName(List<String> fileUrls) {
List<String> names = new ArrayList<>();
for (String url : fileUrls) {
names.add(getFileName(url));
}
return names;
}


/**
* @param fileType 文件类型
* @return 文件的 contentType
*/
private String contentType(String fileType) {
fileType = fileType.toLowerCase();
String contentType;
switch (fileType) {
case "bmp":
contentType = "image/bmp";
break;
case "gif":
contentType = "image/gif";
break;
case "png":
case "jpeg":
case "jpg":
contentType = "image/jpeg";
break;
case "html":
contentType = "text/html";
break;
case "txt":
contentType = "text/plain";
break;
case "vsd":
contentType = "application/vnd.visio";
break;
case "pdf":
contentType = "application/pdf";
break;
case "ppt":
case "pptx":
contentType = "application/vnd.ms-powerpoint";
break;
case "doc":
case "docx":
contentType = "application/msword";
break;
case "xml":
contentType = "text/xml";
break;
case "mp4":
contentType = "video/mp4";
break;
default:
contentType = "application/octet-stream";
break;
}
return contentType;
}
}

如果需要使用,接收前端传递的 MultipartFile 即可。比如,在上传 PDF:

1
2
3
4
@PostMapping("upload/pdf")
public Result<String> uploadPdf(@RequestParam("file") MultipartFile file) {
return ossService.uploadPdf(file);
}

4.后端 - logback

在项目中,有时需要打印日志信息并保存到日志文件中,我们可以使用 logback 来实现这个功能。

为了在不同环境下打印不同的日志信息,我们需要使用多环境配置文件。

添加配置文件:application-dev.yml,这个配置文件在开发环境下使用,将现阶段所有配置信息都移到这个配置文件中。修改 application.yml 为:

1
2
3
4
spring:
profiles:
# 启用 dev 配置文件
active: dev

然后在项目的 resources 目录下创建 logback-spring.xml 文件(名称不能变,必须是这个,且仅在 Spring 环境下生效),并添加以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="10 seconds">
<!-- 日志级别从低到高分为TRACE < DEBUG < INFO < WARN < ERROR < FATAL,如果设置为WARN,则低于WARN的信息都不会输出 -->
<!-- scan:当此属性设置为true时,配置文件如果发生改变,将会被重新加载,默认值为true -->
<!-- scanPeriod:设置监测配置文件是否有修改的时间间隔,如果没有给出时间单位,默认单位是毫秒。当scan为true时,此属性生效。默认的时间间隔为1分钟。 -->
<!-- debug:当此属性设置为true时,将打印出logback内部日志信息,实时查看logback运行状态。默认值为false。 -->

<contextName>logback</contextName>
<!-- name的值是变量的名称,value的值时变量定义的值。通过定义的值会被插入到logger上下文中。定义变量后,可以使“${}”来使用变量。 -->
<!--日志文件存放位置,项目同级目录的logs目录下存放日志文件-->
<property name="log.path" value="../logs" />

<!-- 彩色日志 -->
<!-- 配置格式变量:CONSOLE_LOG_PATTERN 彩色日志格式 -->
<!-- magenta:洋红 -->
<!-- boldMagenta:粗红-->
<!-- cyan:青色 -->
<!-- white:白色 -->
<!-- magenta:洋红 -->
<property name="CONSOLE_LOG_PATTERN"
value="%yellow(%date{yyyy-MM-dd HH:mm:ss}) |%highlight(%-5level) |%blue(%thread) |%blue(%file:%line) |%green(%logger) |%cyan(%msg%n)"/>


<!--输出到控制台-->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<!--此日志appender是为开发使用,只配置最底级别,控制台输出的日志级别是大于或等于此级别的日志信息-->
<!-- 例如:如果此处配置了INFO级别,则后面其他位置即使配置了DEBUG级别的日志,也不会被输出 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<encoder>
<Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
<!-- 设置字符集 -->
<charset>UTF-8</charset>
</encoder>
</appender>


<!--输出到文件-->

<!-- 时间滚动输出 level为 INFO 日志 -->
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 正在记录的日志文件的路径及文件名 -->
<file>${log.path}/log_info.log</file>
<!--日志文件输出格式-->
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 每天日志归档路径以及格式 -->
<fileNamePattern>${log.path}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!--日志文件保留天数-->
<maxHistory>15</maxHistory>
</rollingPolicy>
<!-- 此日志文件只记录info级别的 -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>

<!-- 时间滚动输出 level为 WARN 日志 -->
<appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 正在记录的日志文件的路径及文件名 -->
<file>${log.path}/log_warn.log</file>
<!--日志文件输出格式-->
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
<charset>UTF-8</charset> <!-- 此处设置字符集 -->
</encoder>
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${log.path}/warn/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!--日志文件保留天数-->
<maxHistory>15</maxHistory>
</rollingPolicy>
<!-- 此日志文件只记录warn级别的 -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>warn</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>


<!-- 时间滚动输出 level为 ERROR 日志 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 正在记录的日志文件的路径及文件名 -->
<file>${log.path}/log_error.log</file>
<!--日志文件输出格式-->
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
<charset>UTF-8</charset> <!-- 此处设置字符集 -->
</encoder>
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${log.path}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!--日志文件保留天数-->
<maxHistory>15</maxHistory>
</rollingPolicy>
<!-- 此日志文件只记录ERROR级别的 -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>

<!--
<logger>用来设置某一个包或者具体的某一个类的日志打印级别、以及指定<appender>。
它将会覆盖root的输出级别。
<logger>仅有一个name属性,
一个可选的level和一个可选的addtivity属性。
name:用来指定受此logger约束的某一个包或者具体的某一个类。
level:用来设置打印级别,大小写无关:TRACE, DEBUG, INFO, WARN, ERROR, ALL 和 OFF,
如果未设置此属性,那么当前logger将会继承上级的级别。
-->
<!--
使用mybatis的时候,sql语句是debug下才会打印,而这里我们只配置了info,所以想要查看sql语句的话,有以下两种操作:
第一种把<root level="INFO">改成<root level="DEBUG">这样就会打印sql,不过这样日志那边会出现很多其他消息
第二种就是单独给mapper下目录配置DEBUG模式,代码如下,这样配置sql语句会打印,其他还是正常DEBUG级别:
-->
<!--开发环境:打印控制台-->
<springProfile name="dev">
<!--可以输出项目中的debug日志,包括mybatis的sql日志-->
<!--指定 indi.mofan 包下的日志输出级别为 info-->
<logger name="indi.mofan" level="INFO" />

<!--
root节点是必选节点,用来指定最基础的日志输出级别,只有一个level属性
level:用来设置打印级别,大小写无关:TRACE, DEBUG, INFO, WARN, ERROR, ALL 和 OFF,默认是DEBUG
可以包含零个或多个appender元素。
-->
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="INFO_FILE" />
<appender-ref ref="WARN_FILE" />
<appender-ref ref="ERROR_FILE" />
</root>
</springProfile>

<!--生产环境:输出到文件-->
<!-- <springProfile name="pro">-->

<!-- &lt;!&ndash;可以输出项目中的debug日志,包括mybatis的sql日志&ndash;&gt;-->
<!-- <logger name="indi.mofan" level="WARN" />-->

<!-- <root level="INFO">-->
<!-- <appender-ref ref="ERROR_FILE" />-->
<!-- <appender-ref ref="WARN_FILE" />-->
<!-- </root>-->
<!-- </springProfile>-->
</configuration>

如果需要使用上述日志配置,需要修改的地方有:

1、日志的存放地址:

1
<property name="log.path" value="../logs" />

2、指定自己项目的包路径:

1
<logger name="indi.mofan" level="INFO" />

完成以上步骤后,我们就可以愉快地使用 Lombok 提供的 @Slf4j 注解输出日志了。 🎉

5. 后端 - 切面

日志除了输出到日志文件中之外,也可以保存到数据库。

针对将日志保存到数据库这个需求,可以使用 切面 来解决。

导入依赖:

1
2
3
4
5
6
<!--    切面 日志使用    -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.6</version>
</dependency>

编写日志切面服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Aspect
@Slf4j
@Component
public class LogAspect {

@Autowired
private LogService logService;
@Autowired
private CurrentUser currentUser;

/**
* 配置切入点
* ..表示包及子包 该方法代表 controller 层的所有方法
*/
@Pointcut("execution(public * indi.mofan.controller..*.*(..))")
public void controllerMethod() {
}

/**
* 方法执行后
*
* @param object 方法返回结果集
*/
@SuppressWarnings("unchecked")
@AfterReturning(returning = "object", pointcut = "controllerMethod()")
public void logResultInfo(Object object) {
Result<Object> result = (Result<Object>) object;
if (result.isLog() && Result.SUCCESS_CODE.equals(result.getCode())) {
UserInfo currentUser = this.currentUser.getCurrentUser();
LogInfoDefinitionDto dto = new LogInfoDefinitionDto();
dto.setUserAccount(currentUser.getUserAccount());
dto.setUserType(currentUser.getUserType().code());
dto.setAction(result.getMsg());
logService.createOneLog(dto);
log.info("用户类型:{} - 执行账号:{} - 所做操作:{}", dto.getUserType(), dto.getUserAccount(), dto.getAction());
}
}
}

通过上述代码可以看到,在方法执行后才执行配置的切面,由于统一了返回值(参看 【1.3 统一返回值】),配置起来也变得简单。

6. 后端 - 配置文件加密

在后端的配置文件中往往有很多敏感信息,比如各种数据库密码、各种密钥等,如果将这些信息进行明文显示,就有可能让不法分子有可乘之机。

因此,最好能对这些信息进行加密。

导入依赖:

1
2
3
4
5
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>

配置文件中增加配置:

1
2
3
4
jasypt:
encryptor:
# privateKey
password: 12345

然后编写一个测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @author mofan
* @date 2021/4/23 15:40
*/
@SpringBootTest
public class JasyptEncryptorTest {
@Autowired
private StringEncryptor encryptor;

@Test
public void testEncrypt() {
final String originPwd = "123456";
String encryptedPwd = encryptor.encrypt(originPwd);
Assertions.assertEquals(originPwd, encryptor.decrypt(encryptedPwd));
System.out.println("未加密数据:" + originPwd);
System.out.println("加密后的数据:" + encryptedPwd);
}
}

运行后,测试通过,控制台打印出:

未加密数据:123456
加密后的数据:ecqo2ZfOXI3OMMYqNHTePr/uyZHhCyKNFUU4xXjXkoUPpcNDeuZAdX7jhYIQQYWd

上述代码是什么意思呢?

就是使用密钥 12345(privateKey)对原始密码 123456(originPwd)进行加密,最终打印出加密前和加密后的数据,因此密钥是绝不能暴露的,在实际使用时,不能直接将明文密钥填写在配置文件中

那么问题又来了,怎么使用加密后的数据呢?

只需要将配置文件中需要进行加密的数据替换后即可,但是需要加上 加密前后缀

比如:

1
2
3
datasource:
username: root
password: ENC(ecqo2ZfOXI3OMMYqNHTePr/uyZHhCyKNFUU4xXjXkoUPpcNDeuZAdX7jhYIQQYWd)

替换并移除配置文件中的明文密钥后肯定是无法启动项目的,因为并没有告知程序所使用的密钥。那怎么搞?

方式有三:

1、将密钥直接作为程序启动时的 命令行参数

java -jar yourprojectname.jar --jasypt.encryptor.password=12345

2、将密钥作为程序启动时的 应用环境变量

java -Djasypt.encryptor.password=12345 -jar yourprojectname.jar

3、将密钥配置为 系统的环境变量

比如我们提前设置系统环境变量:JASYPT_ENCRYPTOR_PASSWORD = 12345,那么在 SpringBoot 的配置文件中添加以下配置即可:

1
2
3
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD:}

如果使用 IDEA 作为后端开发工具,只需要在虚拟机启动参数追加以下内容即可:

-Djasypt.encryptor.password=12345

如:

添加密钥到虚拟机启动参数

6.1 自定义加密前后缀

jasypt 默认的加密前后缀是 ENC(),那么有没有什么办法可以自定义呢?比如改成 MOFAN()

只需要在配置文件追加以下内容即可:

1
2
3
4
5
jasypt:
encryptor:
property:
prefix: MOFAN(
suffix: )

6.2 更多自定义

默认情况下 jasypt 采用的加密算法为 PBEWITHHMACSHA512ANDAES_256,这些默认信息都在 JasyptEncryptorConfigurationProperties 类中,我们前往这个类查看:

JasyptEncryptorConfigurationProperties

因此,如果我们需要自定义更多的内容,只需要在配置文件中进行修改即可。

7. 后端 - 工具类

7.1 Hutool

在说工具类之前,就不得不介绍一个非常好用的 Java 工具包类库 —— Hutool,这个类库是由国人开发的,因此参考文档和 API 文档都是中文的,十分友好。

你就在此地不要走动,我把参考文档[2]和 API 文档[3]的链接都放在这里。

使用 Hutool 也很简单,只需要导入依赖就可以了:

1
2
3
4
5
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>Latest Version</version>
</dependency>

Hutool 是多模块开发的,当然除了上述这种方式外,也可以按需导入自己需要的模块。

至于 Hutool 的使用方式就查阅参考文档和 API 文档吧,十分详细。

7.2 使用的工具类

ObjectUtil

ObjectUtil:对象判空、Map 非空过滤、Map key 值转换、Map value 整合。

不要吐槽这个命名,我自己都想吐槽,或许叫 MapUtil 或者 CollectionUtil 会好点?🤔

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* @author mofan
* @date 2021/3/12 11:04
*/
public class ObjectUtil {
/**
* 检查对象非空, 检查字符串是否为非空字符串
* @param object 被检测的对象
* @return Boolean
*/
public static boolean checkValue(Object object) {
if (object instanceof String && "".equals((((String) object).trim()))) {
return false;
}
return null != object;
}

/**
* 过滤 Map 中 Value 为 null 或空字符串的数据
* @param data Map 集合
* @return 过滤得到的 Map 集合
*/
public static Map<String, Object> filterEmptyStringValue(Map<String, Object> data) {
return data.entrySet().stream()
.filter(entry -> checkValue(entry.getValue()))
.collect(Collectors.toMap(
(e) -> (String) e.getKey(),
Map.Entry::getValue
));
}

/**
* 将 Map 中驼峰命名的 Key 转换为下划线命名
* @param data Map 集合
* @return 转换后得到的 Map 集合
*/
public static Map<String, Object> toUnderLineCaseKey(Map<String, Object> data) {
return data.entrySet().stream()
.collect(Collectors.toMap((e) -> StrUtil.toUnderlineCase(e.getKey()), Map.Entry::getValue));
}

/**
* 将 Map 中驼峰命名的 Key 转换为下划线命名,并过滤 Value
* @param data Map 集合
* @return 转换后得到的 Map 集合
*/
public static Map<String, Object> toUnderLineCaseKeyAndFilterValue(Map<String, Object> data) {
return toUnderLineCaseKey(filterEmptyStringValue(data));
}

/**
* 将 Map 的 value 存入 List 中并返回
* @param map 原 Map 集合
* @return 得到的 List 集合
*/
public static <K, V> List<V> getMapValueList(Map<K, V> map) {
Collection<V> values = map.values();
return new ArrayList<>(values);
}
}

NumberUtil

NumberUtil:double 转 int。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author mofan
* @date 2021/5/27 16:52
*/
public class NumberUtil {

/**
* double 转 int
* @param d double 类型的数值
* @return 转换后得到的 int 类型数值
*/
public static int doubleToInt(double d) {
return Integer.parseInt(new DecimalFormat("0").format(d));
}
}

FileUtil

FileUtil:判断 MultipartFile 是否为 Excel、判断 MultipartFile 是否为 PDF。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/**
* @author mofan
* @date 2021/3/15 20:49
*/
@Slf4j
public class FileUtil {
private static final String FILE_ZIP = "zip";
private static final String FILE_XLS = ".xls";
private static final String FILE_XLSX = ".xlsx";
private static final String FILE_PDF = ".pdf";

/**
* 判断上传的文件是否是 Excel 类型的
*
* @param file MultipartFile 实例
* @return 判断结果
*/
public static boolean isNotExcelFile(MultipartFile file) {
if (file == null) {
return true;
}
String filename = file.getOriginalFilename();
String fileType = null;
if (filename != null) {
fileType = filename.substring(filename.lastIndexOf("."));
}
// 先判断后缀名
if (FILE_XLS.equalsIgnoreCase(fileType) || FILE_XLSX.equalsIgnoreCase(fileType)) {
String type;
try {
type = FileTypeUtil.getType(file.getInputStream());
// 根据首部字节判断文件类型
if (FILE_ZIP.contains(type) || FILE_XLS.contains(type)) {
return false;
}
} catch (Exception e) {
return true;
}
}
return true;
}

/**
* 判断上传的文件是否是 PDF 类型的
*
* @param file MultipartFile 实例
* @return 判断结果
*/
public static boolean isNotPdfFile(MultipartFile file) {
if (file == null) {
return true;
}
String filename = file.getOriginalFilename();
String fileType = null;
if (filename != null) {
fileType = filename.substring(filename.lastIndexOf("."));
}
// 先判断后缀名
if (FILE_PDF.equalsIgnoreCase(fileType)) {
String type;
try {
return false;
} catch (Exception e) {
return true;
}
}
return true;
}
}

DateUtil

DateUtil:从 yyyy-MM-dd HH:mm:ss 字符串中获取信息、获取当前年份。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* @author mofan
* @date 2021/4/30 22:16
*/
public class DateUtil {

/**
* 将 yyyy-MM-dd HH:mm:ss 字符串格式的时间转换为 yyyy-MM-dd HH:mm 字符串格式
* @param date 原时间字符串
* @return yyyy-MM-dd HH:mm 字符串格式
*/
public static String getHmDateStr(String date) {
if (date == null || "".equals(date)) {
return null;
} else {
int i = date.lastIndexOf(":");
return date.substring(0, i);
}
}

/**
* 获取 yyyy-MM-dd HH:mm:ss 字符串格式的时间中的年份信息
* @param date 原时间字符串
* @return 年份信息
*/
public static String getYear(String date) {
if (date == null || "".equals(date)) {
return null;
} else {
int i = date.indexOf("-");
return date.substring(0, i);
}
}

/**
* 获取当前字符串类型的年份
* @return 字符串类型的年份信息
*/
public static String getThisYear() {
return String.valueOf(LocalDate.now().getYear());
}
}

AssertUtil

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* @author mofan
* @date 2021/5/6 14:01
*/
public class AssertUtil {
/**
* 断言某个对象不为 <code>null</code>
* @param o 对象
* @param message 错误信息
*/
public static void dataNotNull(Object o, String message) {
if (o == null) {
throw new BusinessException(ErrorCode.DATA_IS_NULL).addError(message);
}
}

/**
* 断言抛出了 BusinessException 异常
* @param condition 条件信息
* @param code ErrorCode 信息
* @param msg 错误信息
*/
public static void throwBusinessException(boolean condition, String code, String msg) {
if (condition) {
throw new BusinessException(code).addError(msg);
}
}

}

8. 后端 - 其他

8.1 @DateTimeFormat@JsonFormat 的区别

一般都是使用 @DateTimeFormat 把传给后台的时间字符串转成 Date,使用 @JsonFormat 把后台传出的 Date 转成时间字符串。

但是 @DateTimeFormat 只会在类似 @RequestParam 的请求参数(url拼接的参数才生效,如果是放到RequestBody中的form-data是无效的)上生效,即:@DateTimeFormat 放到 @RequestBody 下是无效的。

@RequestBody 中则可以使用 @JsonFormat 把传给后台的时间字符串转成Date,也就是说 @JsonFormat 其实既可以把传给后台的时间字符串转成Date也可以把后台传出的 Date 转成时间字符串。

8.2 Excel 的导出

在此以联系方式的导出为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Result<Void> exportContact(HttpServletResponse response) throws IOException {
try {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
// 这里 URLEncoder.encode 可以防止中文乱码
String fileName = java.net.URLEncoder.encode("联系方式", "UTF-8")
.replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
// 获取需要导出联系方式信息
List<ContactExportDto> list = this.getContactExportDtoList();
AssertUtil.throwBusinessException(list.size() == 0, ErrorCode.EMPTY_CONTACT_INFO, "暂无可导出的联系方式信息");
// 这里需要设置不关闭流
EasyExcel.write(response.getOutputStream(), ContactExportDto.class).autoCloseStream(Boolean.FALSE).sheet()
.doWrite(list);
} catch (Exception e) {
ExcelExportResponse.exportFailedResponse(response, e);
}
return Result.success();
}

Excel 导出失败的响应信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* @author mofan
* @date 2021/5/29 18:10
*/
@Slf4j
public class ExcelExportResponse {

/**
* Excel 导出失败响应
* @param response 响应信息
* @param e 抛出的异常
* @throws IOException IO 异常
*/
public static void exportFailedResponse(HttpServletResponse response, Exception e) throws IOException {
// 重置response
response.reset();
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
Result<Object> result = null;
if (e instanceof BusinessException) {
log.error(((BusinessException) e).code() + " : " + e.getMessage());
result = new Result<>(((BusinessException) e).code(), e.getMessage());
} else {
String defaultMessage = "Excel导出失败";
log.error(ErrorCode.EXCEL_EXPORT_ERROR + " : " + defaultMessage);
result = new Result<>(ErrorCode.EXCEL_EXPORT_ERROR, defaultMessage);
}
// 再次设置跨域,否则前端出现跨域
response.setHeader("Access-Control-Allow-Origin", "*");
response.getWriter().print(new ObjectMapper().writeValueAsString(result));
}
}

9. 前端 - JS 的封装

9.1 http.js

由于后端使用了 JWT 和 SpringSecurity,要求前端发送的请求都要在请求头中添加上 Token,因此需要对前端使用的 Axios 进行配置,比如添加拦截器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import axios from 'axios';
import router from '../router'
import Vue from '../main'

// Axios 相关配置
/**
* 新开一个文件进行配置
* 防止在刷新页面后,Axios 拦截器不生效
*/

// 配置 axios 请求基础 URL
axios.defaults.baseURL = 'http://127.0.0.1:9090'

// axios 拦截器,为每次请求添加 Token
axios.interceptors.request.use(config => {
if(config.url === '/auth/login') {
return config;
}
// 这里的config包含每次请求的内容
const token = window.localStorage.getItem("token");
if (token != null && token != '') {
// 添加headers
config.headers['Authorization'] = token;
} else { }
return config;
}, err => {
return Promise.reject(err);
})


// 异步请求后,判断token是否过期
axios.interceptors.response.use(res => {
// 判断当前用户登录状态
verifyUserLoginStatus(res);
return res;
}, err => {
return Promise.reject(err);
})

export default axios;

9.2 tools.js

为了更好地使用 Element-UI 中的 Message(消息提示)组件,在这里对这个组件的使用进行简单的封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Element 消息提示组件
* type 可供的取值: success/warning/info/error
*/
function showMsg(_this, msg, type, duration) {
if (duration == null) {
duration = 1500;
}
if (msg == null) return;
_this.$message({
message: msg,
type: type,
duration: duration
})
}

const Tools = {
showMsg
}

export default Tools;

9.3 file.js

对于文件的上传来说,除了在后端进行类型校验外,还需要在前端对文件类型进行校验。这里只对 Excel、PDF 类型的文件进行了类型校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import Tools from './tools'

/**
* 校验当前文件是否是 Excel 文件
*/
export function verifyExcelTypeAndSize(_this, file) {
let fileType = getFileType(file);
let isExcel = (fileType === "xls") || (fileType === "xlsx");
let isLt5M = file.size / 1024 / 1024 < 5;
if (!isExcel) {
Tools.showMsg(_this, "上传文件只能是Excel文件格式", "error", 4000);
return false;
}
if (!isLt5M) {
Tools.showMsg(_this, "上传文件大小不超过5M", "error", 4000);
return false;
}
return true;
}

/**
* 校验当前文件是否是 pdf 文件
*/
export function verifyPdfTypeAndSize(_this, file) {
let fileType = getFileType(file);
let isExcel = (fileType === "pdf");
let isLt10M = file.size / 1024 / 1024 < 10;
if (!isExcel) {
Tools.showMsg(_this, "上传文件只能是PDF文件格式", "error", 4000);
return false;
}
if (!isLt10M) {
Tools.showMsg(_this, "上传文件大小不超过10M", "error", 4000);
return false;
}
return true;
}

/**
* 根据 File 类获取文件类型
*
* @param {*} file File 类
* @return {*} 文件类型
*/
function getFileType(file) {
let fileName = file.name;
let startIndex = fileName.lastIndexOf('.');
if(startIndex != -1) {
return fileName.substring(startIndex + 1, fileName.length).toLowerCase();
} else {
return '';
}
}

9.4 date.js

将 JS 中 Date 类型的格式转换成 yyyy-MM-dd HH:mm:ss 格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 将字符串日期转换为 yyyy-MM-dd HH:mm:ss 格式
*
* @export
* @param {*} date
* @return {*}
*/
export function renderTime(date) {
if(date != null && date != '') {
var dateJson = new Date(date).toJSON();
return new Date(+new Date(dateJson) + 8 * 3600 * 1000).toISOString().replace(/T/g, ' ').replace(/\.[\d]{3}Z/, '')
} else {
return ''
}
}

在浏览器中的使用结果:

date.js使用示例

9.5 request.js

在进行删除等敏感操作时,前端应当显示确认框,当用户点击“确定”后才进行敏感操作。

通过点击按钮实现 Excel 的下载时,只需要改变请求地址即可,因此也对 Excel 的下载进行封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import axios from 'axios';
import Tools from "../utils/tools"

/**
* 敏感操作
* @param {*} _this Vue 实例
* @param {*} warningMsg 错误信息
* @param {*} func 进行的敏感操作方法
*/
export function sensitiveOperation(_this, warningMsg, func) {
_this.$confirm(warningMsg, "提示", {
confirmButtonText: "确定",
concelButtonText: "取消",
type: "warning",
})
.then(() => {
func()
})
.catch(() => {});
}

/**
* 导出 Excel 方法
* @param {*} _this Vue 实例
* @param {*} url 请求路径
*/
export function exportExcel(_this, url) {
axios
.get(url, {
responseType: "blob"
})
.then((res) => {
let type = res.data.type;
const relType = [
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
];
// Excel 下载失败时 --> 显示错误信息
if (type.includes("application/json")) {
let reader = new FileReader();
reader.onload = function (event) {
let errorResult = JSON.parse(reader.result); // 错误信息
let msg = Tools.errMsg(errorResult.code, errorResult.msg);
Tools.showMsg(_this, msg, "error", 3000);
};
reader.readAsText(res.data);
}
// Excel 导出成功
if (relType.includes(type)) {
let blob = new Blob([res.data]);
let downloadElement = document.createElement("a");
let href = window.URL.createObjectURL(blob); //创建下载的链接
downloadElement.href = href;
let contentDisposition = res.headers["content-disposition"];
let fileName = "undefine.xlsx";
if (contentDisposition) {
fileName = window.decodeURI(
res.headers["content-disposition"].split("''")[1],
"UTF-8"
);
}
downloadElement.download = fileName;
document.body.appendChild(downloadElement);
downloadElement.click(); //点击下载
document.body.removeChild(downloadElement); //下载完成移除元素
window.URL.revokeObjectURL(href); //释放掉blob对象
}
})
.catch((err) => {
Tools.showMsg(this, "Excel导出失败!", "error");
});
}

9.6 validate.js

使用 Element-UI 提交 Dialog 组件中的表单数据时,需要对表单中的数据有效性进行校验,比如对电话号码格式、邮箱格式进行校验。如果校验不通过,应当在所在位置显示提示信息,当关闭 Dialog 时,还需要清空校验信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
/**
* 验证并提交表单信息
*/
export function formValidate(_this, refName, errMsg, func) {
_this.$refs[refName].validate((valid) => {
if (valid) {
func()
} else {
_this.$message({
message: errMsg,
type: "error",
duration: 3000,
});
return false;
}
});
}

/**
* 清空表单验证信息
*/
export function resetForm(_this, refName) {
_this.$refs[refName].resetFields();
}

/**
* 用户密码验证
*/
export const passwordValidate = (rule, value, callback, errMsg) => {
const passwordReg = /^[a-zA-Z]\w{4,15}$/;
if (!value) {
if (errMsg != null && errMsg != '' && errMsg != undefined) {
return callback(new Error(errMsg))
}
return callback(new Error('密码不能为空'))
}
setTimeout(() => {
if (passwordReg.test(value)) {
callback()
} else {
callback(new Error('以字母开头,长度在5~16之间,只能包含字母、数字和下划线'))
}
}, 100)
}

/**
* 用户账号验证
*/
export const accountValidate = (rule, value, callback) => {
const accountReg = /^[a-zA-Z0-9][a-zA-Z0-9_]{4,15}$/;
if (!value) {
return callback(new Error('账号不能为空'))
}
setTimeout(() => {
if (accountReg.test(value)) {
callback()
} else {
callback(new Error('以字母或数字开头,长度在5~16之间,允许字母、数字和下划线'))
}
}, 100)
}

/**
* 电话号码验证
*/
export const phoneNumValidate = (rule, value, callback, notNull) => {
const phoneReg = /^(13[0-9]|14[5|7]|15[0|1|2|3|4|5|6|7|8|9]|18[0|1|2|3|5|6|7|8|9])\d{8}$/;
if (!value) {
if (notNull) {
return callback(new Error('电话号码不能为空'))
}
return callback()
}
setTimeout(() => {
if (!Number.isInteger(+value)) {
callback(new Error('请输入数字值'))
} else {
if (phoneReg.test(value)) {
callback()
} else {
callback(new Error('电话号码格式不正确'))
}
}
}, 100)
}

/**
* 传真电话号码验证
*/
export const faxNumValidate = (rule, value, callback, notNull) => {
const faxReg = /\d{3}-\d{8}|\d{4}-\d{7}/;
if (!value) {
if (notNull) {
return callback(new Error('传真电话号码不能为空'))
}
return callback()
}
setTimeout(() => {
if (faxReg.test(value)) {
callback()
} else {
callback(new Error('请输入正确格式的传真电话号码'))
}
}, 100)
}

/**
* 邮箱验证
*/
export const emailValidate = (rule, value, callback, notNull) => {
// 另一个邮箱正则 ^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$
const mailReg = /^[A-Za-z\d]+([-_.][A-Za-z\d]+)*@([A-Za-z\d]+[-.])+[A-Za-z\d]{2,4}$/;
if (!value) {
if (notNull) {
return callback(new Error('邮箱不能为空'))
}
return callback()
}
setTimeout(() => {
if (mailReg.test(value)) {
callback()
} else {
callback(new Error('请输入正确格式的邮箱'))
}
}, 100)
}

/**
* 身份证验证
*/
export const identityNumValidate = (rule, value, callback, notNull) => {
// 身份证正则 (^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)
const mailReg = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/;
if (!value) {
if (notNull) {
return callback(new Error('身份证号码不能为空'))
}
return callback()
}
setTimeout(() => {
if (mailReg.test(value)) {
callback()
} else {
callback(new Error('请输入正确格式的身份证号码'))
}
}, 100)
}

9.7 index.js

路由前置守卫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 全局前置守卫
router.beforeEach((to, from, next) => {
/*
* to 将要访问的路径
* from 从哪个路径跳转而来
* next 一个函数,表示放行
* next() 放行 next('/xxx') 强制跳转
*/
// 界面 Title
if (to.meta.title) {
document.title = to.meta.title;
}

if (to.path === '/') return next();
// 获取 token 信息
const token = window.localStorage.getItem('token');
if (token === null || token === '') {
return next('/');
}
next();
})

10. Element-UI 的使用

10.1 在表单指定字段下提示错误信息

比如,在上传考试科目信息时,对考试科目类型进行校验,并且考试科目类型字段所在的输入框类型为下拉框。

先在 data 中指定错误信息:

1
2
3
4
5
6
data() {
return {
// 错误信息
examTypeError: null,
}
}

然后在 el-form-item 组件上使用 error 属性,绑定错误信息:

1
2
3
4
5
6
<el-form-item :error="examTypeError">
<el-select
@blur="selectValueValidate"
@change="selectValueValidate"
></el-select>
</el-form-item>

由于使用 Element-UI 自带的校验对 Select 组件进行校验时无法准确地显示错误信息,因此在此使用事件的方式进行校验,并显示错误信息。

最后编写错误校验方法即可:

1
2
3
4
5
6
7
8
selectValueValidate() {
if (this.subjectForm.examType == null) {
this.examTypeError = Math.random().toString();
this.$nextTick(() => {
this.examTypeError = "考试科目类型不能为空";
});
}
}

<el-form-item>error 属性监听的是 watch 方法,也就是两次重复的错误操作,提示的错误信息一致,就不会触发 watch 方法。

Element-UI 表单组件规定一旦表单验证通过就会清除原来的错误提示信息,这样就导致了在进行第二次验证时原来的错误信息被清除了,而两次错误信息又一致没法触发 watch 方法,所以也不会重新渲染出新的错误信息出来。

解决方法很简单:使用 $nextTick 来修复这个问题。

vm.$nextTick 可以将回调延迟到下次 DOM 更新循环之后执行。在修改数据之后立即使用它,然后等待 DOM 更新。我们知道错误提示不显示的根本原因是 watch 方法没有被触发。那如果我每次给错误赋值一个随机值,然后使用 $nextTick 在 DOM 被更新之后将随机值改成我们需要的错误信息就可以了。

10.2 el-select 设置 multiple 后出现的问题

设置 <el-select>multiple 属性,并为该组件绑定 Element UI 的表单验证后,会出现 一进界面就校验失败的错误提示信息,为解决这个问题,可以在当前所在 Vue 的生命周期函数中添加如下代码:

1
2
3
4
created() {
// 防止出现一进界面就校验错误的情况
this.$set(this.userForm, "roleList", []);
},

其中,this.userForm 是 data 域中的表单数据,如:

1
2
3
4
5
data() {
return {
userForm: {},
}
}

roleList 是在使用校验时,Element UI <el-select> 组件所在的 <el-form-item> 组件绑定的 prop 的值,如:

1
2
3
4
5
<el-form-item prop="roleList">
<el-select v-model="userForm.roleList" multiple >
<!---->
</el-select>
</el-form-item>

<el-select> 组件被设置了 multiple 属性后,其绑定的数据类型将会变为一个数组,因此需要将其设置为 []

而出现这种问题的原因也很简单,当 <el-select> 组件被设置了 multiple 属性后,初次进入这个这件的所在的页面时,将会自动触发 <el-select> 组件的 change 事件,而由于我们 data 域中表单数据初始化为 userForm: {},Element UI 就会认定值缺失,并显示校验失败的错误提示信息。

补充

而我在使用时,是在 Element UI dialog 对话框中使用了 <el-select> 组件,并且将这个对话框写在了另一个 vue 文件中。

为了在第二次打开 dialog 时,将绑定数据和校验信息清空,因此为 <el-dialog> 组件绑定了 close 方法,使其在关闭 dialog 时,可以清空绑定数据和校验信息清空。如:

1
<el-dialog @close="closeSaveOrUpdateDialog">
1
2
3
4
5
6
closeSaveOrUpdateDialog() {
this.userForm = {};
resetForm(this, "userForm");
// 防止出现一进界面就校验错误的情况
this.$set(this.userForm, "roleList", []);
},

注意,由于这里直接将 this.userForm 设置为 {},又出现了前面的问题,因此在这方法最后也要设置一下。

否则就会出现以下情况:第一次打开 dialog 不会触发异常校验,啥都不干,再次打开时就又会触发异常校验。


如果 <el-select> 组件被设置了 multiple 属性后,出现了数据无法回显,或数据无法编辑的情况,可以为 <el-select> 组件绑定 change 事件,并在这个事件绑定的方法中执行:

1
2
3
changeSelect() {
this.$forceUpdate()
}

这是因为由于一些嵌套特别深的数据,导致数据更新了,但是页面却没有重新渲染,需要使用 this.$forceUpdate() 迫使 Vue 实例重新渲染。

经验之谈

针对页面上 data 域中同一数据的绑定,最好使用一次请求完成,而不是向后台发送多次请求。

比如,data 域中有一如下数据:

1
2
3
4
5
data() {
return {
userForm: {},
}
}

很明显,userForm 是一个对象,这个对象中可能有多个属性,当我们向后台发送请求,为这个对象绑定数据时,最好是在一次请求完成后就为这个对象绑定数据。而不是先发送一次请求,绑定该对象中 A、B 属性的值,再发送一次请求绑定该对象中 C 属性的值。

11. 前端 - 其他

11.1 后端 ID 异常

由于 JS 的精度所限,当后端的 ID 信息(Long 类型)传递到前端时会出现精度缺失,因此后端应当将 Long 类型的 ID 信息转换成字符串传递给前端。

针对这种情况,后端可以在表示 ID 的字段上使用 Jackson 的注解,如:

1
2
3
4
5
/**
* 主键
*/
@JsonSerialize(using = ToStringSerializer.class)
private Long id;

11.2 Token 信息的存储

用户成功登录后,后端会将当前用户的 Token 信息存储在响应头中,前端应当拿到这个 Token 信息,并在每次向后端发送请求时携带这个 Token 信息用于用户验证。

获取响应头中 Token 信息的方式:

1
window.localStorage.setItem("token", res.headers.authorization);

其中,res 为 Axios 请求发送成功后的响应体。

在这里,我将 Token 信息存储到 localStorage 中,当然也可以存储到 sessionStorage 中,这两者与 cookie 的区别[4]如下:

1、cookie 数据始终在同源的 http 请求中携带(即使不需要),即 cookie 在浏览器和服务器间来回传递,而 sessionStorage 和 localStorage 不会自动把数据发送给服务器,仅在本地保存。cookie 数据还有路径(path)的概念,可以限制 cookie 只属于某个路径下

2、存储大小限制也不同。cookie 数据不能超过 4K,同时因为每次 http 请求都会携带 cookie,所以 cookie 只适合保存很小的数据,如会话标识。sessionStorage 和 localStorage 虽然也有存储大小的限制,但比 cookie 大得多,可以达到 5M 或更大

3、数据有效期不同。sessionStorage 仅在当前浏览器窗口关闭之前有效;localStorage 始终有效,窗口或浏览器关闭也一直保存,因此用作持久数据;cookie 只在设置的 cookie 过期时间之前有效,即使窗口关闭或浏览器关闭

4、作用域不同。sessionStorage 不在不同的浏览器窗口中共享,即使是同一个页面;localstorage 在所有同源窗口中都是共享的;cookie 也是在所有同源窗口中都是共享的

5、web Storage 支持事件通知机制,可以将数据更新的通知发送给监听者

6、web Storage 的 api 接口使用更方便

localStorage 的使用方式很简单,参考【localStorage 使用总结】[5]一文。

12. 结语

以上就是我本次毕业设计一个简单的总结,由于代码量比较大,可能有些地方并不完善,如果有不完善的地方后续再补充吧。

回望整个过程,其实这次毕业设计在技术领域上并不难,难的是某些功能的实现逻辑。纵观整个后端代码,由于我初期的设计问题,造成了很多代码冗余(当然,这个问题在前端上更为严重),因此在代码的设计与整洁方面还需要更多的学习。

但庆幸的是,因为本次毕业设计是从 0 开始(无论前端还是后端),从可行性分析一步步走到测试,在这期间还是学到了很多东西,也没枉费我两个月的实习工资。

从本文的封面可以看出,我将我的指导老师姓名添加了上去(虽然你并不认识,但这没有关系),再次向郭老师表示由衷的感谢。本次毕业设计的选题是由我的指导老师所给定的,感谢郭老师在系统前期设计时给予我众多的宝贵意见与参考,指导我分析、设计、实现整个系统。在论文撰写方面尽心尽责的为我讲解撰写规范与注意事项,并对我的论文进行了仔细、反复的检查,准确得给出修改意见,正是因为有郭老师的帮助,系统与论文才得以顺利完成。

我还将我大学四年所在的班级也添加了上去,以此纪念我逝去的大学时光。


  1. 菜鸟工具 正则表达式 在线测试:链接 ↩︎

  2. Hutool 参考文档:链接 ↩︎

  3. Hutool API 文档:链接 ↩︎

  4. Cookie、session和localStorage、以及sessionStorage之间的区别:链接 ↩︎

  5. localStorage使用总结:链接 ↩︎