封面来源:由博主个人绘制,如需使用请联系博主。
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 异常进行错误信息组装。
@Getter
@Setter
public class JSR303ExceptionMessage {
/**
* 错误字段
*/
private String field;
/**
* 错误对象
*/
private String objectName;
/**
* 错误的值
*/
private Object rejectedValue;
/**
* 错误信息
*/
private String errorMsg;
}
1.2 自定义业务异常类
/**
* 自定义业务异常类,用于在业务层抛出异常
*/
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 层都将统一返回一个类型,便于后续操作。
/**
* @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。
@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 枚举接口
后端中的大多数枚举都需要实现这个接口并重写其中的方法。
/**
* @author mofan 2021/2/1
* 枚举信息,一般来说,每个枚举类都应该实现这个接口
*/
public interface EnumInfo extends Serializable {
/**
* 获取枚举值对应的代码
* @return 对应的代码
*/
String code();
/**
* 获取枚举值对应的信息
* @return 对应的信息
*/
String message();
}
1.6 实体基类
后端中使用到的 所有 实体都需要继承实体基类。
/**
* @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(数据传输对象)都需要继承数据传输对象基类。
/**
* @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 属性值为被处理的异常类。
比如我所使用的全局异常拦截器类是这样的:
@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 注解。
比如:
@PostMapping("submit")
public Result<Long> saveOrUpdate(@Valid @RequestBody DeptDefinitionDto dto) {
if (dto.getId() == null) {
return deptService.createDept(dto);
} else {
return deptService.updateDeptInfo(dto);
}
}
DeptDefinitionDto 对应的部分代码:
@Getter
@Setter
public class DeptDefinitionDto {
/**
* 部门名称
*/
@NotNull(message = "部门名称不能为空")
private String deptName;
// ...
/**
* 传真电话
*/
@FaxNum(notNull = false)
private String fax;
}
2.3 自定义校验注解
在特定的场景下,使用 JSR-303 提供的注解无法完成我们的校验需求,这个时候就需要自定义注解,比方说前文中 DeptDefinitionDto 类中的 @FaxNum 注解。
自定义注解的步骤也很简单:
1、创建自定义注解,注解中要有属性 message、groups 和 payload,还要在自定义的注解上使用注解 @Constraint,并指定其 validatedBy 属性值为自定义的约束验证器(第二步创建)。
2、创建自定义的约束验证器,这个类实现 ConstraintValidator 接口,这个接口的泛型为:自定义的注解和需要被校验的参数类型。
比如:
@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:
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,如果查过这个数值就会抛出异常。
当然这些数值也是可以修改的,只需要在配置文件追加:
spring:
servlet:
multipart:
# 单个文件最大大小
max-file-size: 20MB
# 请求中文件大小合计最大
max-request-size: 200MB
server:
tomcat:
# 不限制内嵌 Tomcat 最大吞吐量,用于全局异常处理进行捕获
max-swallow-size: -1
并在全局异常拦截中追加:
/**
* 上传文件尺寸过大,超过默认 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(获取到公钥和私钥)。
首先需要导入依赖:
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.10.2</version>
</dependency>
然后在配置文件中追加以下配置:
# 阿里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 配置信息:
/**
* @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 中:
/**
* @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 服务(仅以文件上传为例):
/**
* @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:
@PostMapping("upload/pdf")
public Result<String> uploadPdf(@RequestParam("file") MultipartFile file) {
return ossService.uploadPdf(file);
}
4. 后端 - logback
在项目中,有时需要打印日志信息并保存到日志文件中,我们可以使用 logback 来实现这个功能。
为了在不同环境下打印不同的日志信息,我们需要使用多环境配置文件。
添加配置文件:application-dev.yml,这个配置文件在开发环境下使用,将现阶段所有配置信息都移到这个配置文件中。修改 application.yml 为:
spring:
profiles:
# 启用 dev 配置文件
active: dev
然后在项目的 resources 目录下创建 logback-spring.xml 文件(名称不能变,必须是这个,且仅在 Spring 环境下生效),并添加以下内容:
<?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">-->
<!-- <!–可以输出项目中的debug日志,包括mybatis的sql日志–>-->
<!-- <logger name="indi.mofan" level="WARN" />-->
<!-- <root level="INFO">-->
<!-- <appender-ref ref="ERROR_FILE" />-->
<!-- <appender-ref ref="WARN_FILE" />-->
<!-- </root>-->
<!-- </springProfile>-->
</configuration>
如果需要使用上述日志配置,需要修改的地方有:
1、日志的存放地址:
<property name="log.path" value="../logs" />
2、指定自己项目的包路径:
<logger name="indi.mofan" level="INFO" />
完成以上步骤后,我们就可以愉快地使用 Lombok 提供的 @Slf4j 注解输出日志了。 🎉
5. 后端 - 切面
日志除了输出到日志文件中之外,也可以保存到数据库。
针对将日志保存到数据库这个需求,可以使用 切面 来解决。
导入依赖:
<!-- 切面 日志使用 -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.6</version>
</dependency>
编写日志切面服务:
@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. 后端 - 配置文件加密
在后端的配置文件中往往有很多敏感信息,比如各种数据库密码、各种密钥等,如果将这些信息进行明文显示,就有可能让不法分子有可乘之机。
因此,最好能对这些信息进行加密。
导入依赖:
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
配置文件中增加配置:
jasypt:
encryptor:
# privateKey
password: 12345
然后编写一个测试用例:
/**
* @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)进行加密,最终打印出加密前和加密后的数据,因此密钥是绝不能暴露的,在实际使用时,不能直接将明文密钥填写在配置文件中。
那么问题又来了,怎么使用加密后的数据呢?
只需要将配置文件中需要进行加密的数据替换后即可,但是需要加上 加密前后缀。
比如:
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 的配置文件中添加以下配置即可:
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD:}
如果使用 IDEA 作为后端开发工具,只需要在虚拟机启动参数追加以下内容即可:
-Djasypt.encryptor.password=12345
如:

6.1 自定义加密前后缀
jasypt 默认的加密前后缀是 ENC(),那么有没有什么办法可以自定义呢?比如改成 MOFAN()。
只需要在配置文件追加以下内容即可:
jasypt:
encryptor:
property:
prefix: MOFAN(
suffix: )
6.2 更多自定义
默认情况下 jasypt 采用的加密算法为 PBEWITHHMACSHA512ANDAES_256,这些默认信息都在 JasyptEncryptorConfigurationProperties 类中,我们前往这个类查看:
因此,如果我们需要自定义更多的内容,只需要在配置文件中进行修改即可。
7. 后端 - 工具类
7.1 Hutool
在说工具类之前,就不得不介绍一个非常好用的 Java 工具包类库 —— Hutool,这个类库是由国人开发的,因此参考文档和 API 文档都是中文的,十分友好。
你就在此地不要走动,我把参考文档2和 API 文档3的链接都放在这里。
使用 Hutool 也很简单,只需要导入依赖就可以了:
<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 会好点?🤔
/**
* @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。
/**
* @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。
/**
* @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 字符串中获取信息、获取当前年份。
/**
* @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
/**
* @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 的导出
在此以联系方式的导出为例:
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 导出失败的响应信息:
/**
* @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 进行配置,比如添加拦截器。
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(消息提示)组件,在这里对这个组件的使用进行简单的封装。
/**
* 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 类型的文件进行了类型校验:
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 格式:
/**
* 将字符串日期转换为 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 ''
}
}
在浏览器中的使用结果:

9.5 request.js
在进行删除等敏感操作时,前端应当显示确认框,当用户点击「确定」后才进行敏感操作。
通过点击按钮实现 Excel 的下载时,只需要改变请求地址即可,因此也对 Excel 的下载进行封装。
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 时,还需要清空校验信息。
/**
* 验证并提交表单信息
*/
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
路由前置守卫:
// 全局前置守卫
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 中指定错误信息:
data() {
return {
// 错误信息
examTypeError: null,
}
}
然后在 el-form-item 组件上使用 error 属性,绑定错误信息:
<el-form-item :error="examTypeError">
<el-select
@blur="selectValueValidate"
@change="selectValueValidate"
></el-select>
</el-form-item>
由于使用 Element-UI 自带的校验对 Select 组件进行校验时无法准确地显示错误信息,因此在此使用事件的方式进行校验,并显示错误信息。
最后编写错误校验方法即可:
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 的生命周期函数中添加如下代码:
created() {
// 防止出现一进界面就校验错误的情况
this.$set(this.userForm, "roleList", []);
},
其中,this.userForm 是 data 域中的表单数据,如:
data() {
return {
userForm: {},
}
}
而 roleList 是在使用校验时,Element UI <el-select> 组件所在的 <el-form-item> 组件绑定的 prop 的值,如:
<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 时,可以清空绑定数据和校验信息清空。如:
<el-dialog @close="closeSaveOrUpdateDialog">
closeSaveOrUpdateDialog() {
this.userForm = {};
resetForm(this, "userForm");
// 防止出现一进界面就校验错误的情况
this.$set(this.userForm, "roleList", []);
},
注意,由于这里直接将 this.userForm 设置为 {},又出现了前面的问题,因此在这方法最后也要设置一下。
否则就会出现以下情况:第一次打开 dialog 不会触发异常校验,啥都不干,再次打开时就又会触发异常校验。
如果 <el-select> 组件被设置了 multiple 属性后,出现了数据无法回显,或数据无法编辑的情况,可以为 <el-select> 组件绑定 change 事件,并在这个事件绑定的方法中执行:
changeSelect() {
this.$forceUpdate()
}
这是因为由于一些嵌套特别深的数据,导致数据更新了,但是页面却没有重新渲染,需要使用 this.$forceUpdate() 迫使 Vue 实例重新渲染。
经验之谈
针对页面上 data 域中同一数据的绑定,最好使用一次请求完成,而不是向后台发送多次请求。
比如,data 域中有一如下数据:
data() {
return {
userForm: {},
}
}
很明显,userForm 是一个对象,这个对象中可能有多个属性,当我们向后台发送请求,为这个对象绑定数据时,最好是在一次请求完成后就为这个对象绑定数据。而不是先发送一次请求,绑定该对象中 A、B 属性的值,再发送一次请求绑定该对象中 C 属性的值。
11. 前端 - 其他
11.1 后端 ID 异常
由于 JS 的精度所限,当后端的 ID 信息(Long 类型)传递到前端时会出现精度缺失,因此后端应当将 Long 类型的 ID 信息转换成字符串传递给前端。
针对这种情况,后端可以在表示 ID 的字段上使用 Jackson 的注解,如:
/**
* 主键
*/
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
11.2 Token 信息的存储
用户成功登录后,后端会将当前用户的 Token 信息存储在响应头中,前端应当拿到这个 Token 信息,并在每次向后端发送请求时携带这个 Token 信息用于用户验证。
获取响应头中 Token 信息的方式:
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 开始(无论前端还是后端),从可行性分析一步步走到测试,在这期间还是学到了很多东西,也没枉费我两个月的实习工资。
从本文的封面可以看出,我将我的指导老师姓名添加了上去(虽然你并不认识,但这没有关系),再次向郭老师表示由衷的感谢。本次毕业设计的选题是由我的指导老师所给定的,感谢郭老师在系统前期设计时给予我众多的宝贵意见与参考,指导我分析、设计、实现整个系统。在论文撰写方面尽心尽责的为我讲解撰写规范与注意事项,并对我的论文进行了仔细、反复的检查,准确得给出修改意见,正是因为有郭老师的帮助,系统与论文才得以顺利完成。
我还将我大学四年所在的班级也添加了上去,以此纪念我逝去的大学时光。