跳到正文
返回

毕业设计代码总结

发表于 更新于
浏览量: --

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

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、创建自定义注解,注解中要有属性 messagegroupspayload,还要在自定义的注解上使用注解 @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">-->

<!--        &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、日志的存放地址:

<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 类中,我们前往这个类查看:

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 ''
  }
}

在浏览器中的使用结果:

date.js使用示例

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 开始(无论前端还是后端),从可行性分析一步步走到测试,在这期间还是学到了很多东西,也没枉费我两个月的实习工资。

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

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

Footnotes

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

  2. Hutool 参考文档:链接

  3. Hutool API 文档:链接

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

  5. localStorage 使用总结:链接

如果这篇文章对你有帮助,可以通过
支付宝
支付宝
微信
微信
请我喝杯 Coffee ☕


上一篇
【设计模式】工厂模式
下一篇
JWT 的基本使用