# 🇨🇳后端手册

# 项目结构

包名 含义
com.eva.api 控制层/接口层
com.eva.biz 业务层
com.eva.service 基础服务层
com.eva.dao 持久层
com.eva.core 项目核心

如果我们划分得更细致些,那么可以得到下表

包名 含义
com.eva.api 控制层/接口层
com.eva.biz 业务层
com.eva.service 基础服务层
com.eva.service.system 系统管理,系统运维基础服务
com.eva.service.common 通用基础服务
com.eva.dao 持久层
com.eva.core 项目核心
com.eva.core.authorize 授权
com.eva.core.cache 缓存
com.eva.core.config 第三方框架、异常等配置
com.eva.core.constants 常量定义
com.eva.core.excel 导入导出Excel
com.eva.core.exception 自定义异常
com.eva.core.initializer 项目初始化器
com.eva.core.model 项目通用实体
com.eva.core.prevent 防止操作,例如接口防重复的处理
com.eva.core.secure 安全处理
com.eva.core.servlet 请求处理
com.eva.core.session 会话处理
com.eva.core.trace 跟踪日志的处理
com.eva.core.utils 通用工具类

设计思想

项目为四层结构,api > biz > service > dao,且biz和service直接使用class实现,无需定义接口。 现在让我们尝试着理解每一层的作用,为了更方便的理解,需要从后往前进行讲解!

  • 持久层

持久层用于将 处理好的数据保存至数据库。这一层的代码可直接生成,不需要手动编写。

  • 基础服务层

基础服务层用于 编写基础服务,例如怎样删除(逻辑删除还是物理删除),怎样批量删除(循环调用还是SQL实现)等。 在这一层中不涉及多表操作(除查询外)。这一层的代码可直接生成,不需要手动编写。

  • 业务层

业务层用于 编写业务,这一层可能涉及到多表的增删改操作,而单表的增删改操作的业务实现在基础层。 所以业务层应该使用颗粒业务层对象,而不应该直接使用持久层对象。 当然,如果你的业务足够简单,例如删除一条单表记录,那么这一层可以不用实现,但这种做法并不推荐。

  • 控制层/接口层

控制层/接口层用于 定义接口并调用相关业务方法,而业务方法可以来自业务层和基础服务层(严格来说,应来自业务层)。

# 服务端口配置

端口配置在application-config.ymlapp.port属性中。调整后需重启服务。

# 接口文档

如果端口没有修改的话,那么接口地址为http://localhost:10010/doc.html (opens new window) 。但通常测试接口需要通过某个账号登录后才能调用, 此时可以访问接口文档认证页,完成认证后即可调用需要登录后才可调用的接口。认证页地址为http://localhost:10010/doc/auth.html (opens new window)

关闭接口文档

您可以通过配置application-config.yml文件内容来关闭接口文档,如下:





 
 

# 应用配置
app:
  # 接口文档配置
  api-doc:
    # 是否启用,生产环境建议关闭
    enabled: false
1
2
3
4
5
6

# 测试模式

某些时候我们为了测试方便,诸如短信、图形、密码等我们暂时不做验证以提高测试效率。为了方便的实现这一点,Eva提供了测试模式,只需要在application-config.yml 文件中配置运行模式即可。如下




 
 

# 应用配置
app:
  ...
  # 模式,testing测试模式
  mode: testing
1
2
3
4
5

当前测试模式开启后,不会校验图片验证码。如果需要获取是否为测试模式,可以使用以下代码

// 如果为测试模式
if (Utils.AppConfig.isTestingMode()) {
  // TODO,执行测试模式下处理逻辑
}
1
2
3
4

# 权限控制

Eva4支持多个权限框架,为了使不同权限框架的用法保持一致,Eva4直接调用权限框架底层方法封装了自定义注解。

# @ContainRoles

@ContainRoles注解用于配置接口要求用户拥有某(些)角色才可访问,它只有1个参数:

  • value: 角色列表

示例1: 以下代码表示必须拥有admin角色才可访问

 




@ContainRoles("admin")
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

示例2: 以下代码表示必须拥有admin manager角色才可访问

 




@ContainRoles({"admin", "manager"})
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

# @ContainAnyRoles

@ContainAnyRoles注解用于配置接口要求用户拥有某些角色之一才可访问,它只有1个参数:

  • value: 角色列表

示例1: 以下代码表示必须拥有admin manager角色才可访问

 




@ContainAnyRoles({"admin", "manager"})
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

# @ContainPermissions

@ContainPermissions注解用于配置接口要求用户拥有某(些)权限才可访问,它只有1个参数:

  • value: 权限列表

示例1: 以下代码表示必须拥有system:user:create权限才可访问

 




@ContainPermissions("system:user:create")
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

示例2: 以下代码表示必须拥有system:user:create system:user:update权限才可访问

 




@ContainPermissions({"system:user:create", "system:user:update"})
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

# @ContainAnyPermissions

@ContainAnyPermissions注解用于配置接口要求用户拥有某些权限之一才可访问,它只有1个参数:

  • value: 权限列表

示例1: 以下代码表示必须拥有system:user:create system:user:update权限才可访问

 




@ContainAnyPermissions({"system:user:create", "system:user:update"})
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

# @AuthorizeExpress

@AuthorizeExpress注解用于配置接口的权限表达式,它只有1个参数:

  • value: 权限表达式

权限表达式用于表达权限关系,可以使用&&||来表达并且和或者关系,结合以下表达式来处理较为复杂的权限关系。

  • isSuperAdmin():是否为超级管理员
  • hasRoles():是否包含某(些)角色
  • hasAnyRoles():是否包含某(些)角色之一
  • hasPermissions():是否包含某(些)权限
  • hasAnyPermissions():是否包含某(些)权限之一

示例1: 以下代码表示必须为超级管理员才可访问

 




@AuthorizeExpress("isSuperAdmin()")
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

示例2: 以下代码表示必须为超级管理员或者拥有system:user:create权限才可访问

 




@AuthorizeExpress("isSuperAdmin() || hasPermissions('system:user:create')")
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

&&和||的优先级

表达式率先会根据||分割成子表达式,子表达式再根据&&分割成最小颗粒的表达式,||两侧的任一表达式成立时权限验证才算通过。例如 A || B && C,则表达式A成立 或者 表达式B && C成立时才算验证通过。

# 无认证接口实现

有些时候我们允许不登录直接访问接口,此时可以直接在application-config.yml中进行配置。如下












 
 
 

# 应用配置
app:
  ...
  # 会话配置
  session:
    ...
    # 会话拦截
    interceptor:
      ...
      # 不需要拦截的路径(不需要登录即可访问)
      exclude-path-patterns:
        # 增加一些不需要进行会话拦截的路径
        - /exclude/mypath
        - /exclude/path/**
1
2
3
4
5
6
7
8
9
10
11
12
13
14

注意

系统中提供的需要认证才能访问的接口,请勿在此放行。这样接口获取不到用户信息导致出现错误。

# 事务处理

在需要进行事务处理的方法上添加@Transactional注解即可。无论是service层还是biz层这种方式都是有效的。

误区

有些同学可能因为"懒"的缘故,直接在Controller中实现biz层做的事情,无论Controller方法上添加@Transactional 是否能使事务生效,这种做法都是不规范的。

# 分页实现

虽然分页代码可以直接生成,但我们仍然需要为您说明分页的实现逻辑。

# 单表的分页实现

当表的Service类继承了BaseService,BaseService提供了findPage方法,可直接实现分页,以下是一个示例

public PageData<MyModel> findPage(PageWrap<QueryMyModelDTO> pageWrap) {
    MyModel model = new MyModel();
    // 将查询参数复制到实体类中
    BeanUtils.copyProperties(pageWrap.getModel(), model);
    return myModelService.findPage(pageWrap.getPage(), pageWrap.getCapacity(), model);
}
1
2
3
4
5
6

其中MyModel为实体类,QueryMyModelDTO为查询条件参数类(该类也可以直接使用实体类)。findPage有多个重载方法,如下

  • service.findPage(int pageIndex, int capacity, Model model)
  • service.findPage(int pageIndex, int capacity, Model model, boolean withDeleted)
  • service.findPage(int pageIndex, int capacity, Model model, QueryWrapper<Model> queryWrapper)
参数 说明
pageIndex 页码
capacity 每页数量
withDeleted 是否包含已删除的数据
model 查询条件
queryWrapper 查询条件的封装

# 多表的分页实现

多表的分页需要编写SQL语句,GoldPanKit提供的多表接口插件可直接生成多表的SQL语句。但您仍然可以自行编写语句,假定编写完以后得到mapper.search 方法,则Biz层可参考以下代码实现分页:

@Override
public PageData<MyListVO> findPage(PageWrap<MyQueryDTO> pageWrap) {
    // 使用MyBatisPlus提供的方法开启分页
    PageHelper.startPage(pageWrap.getPage(), pageWrap.getCapacity());
    // 通过Utils.MP.blankToNull方法将查询参数中的空字符串转为null,避免SQL中缺少非空串判断
    Utils.MP.blankToNull(pageWrap.getModel());
    // 调用SQL语句查询出结果
    List<MyListVO> myList = myMapper.search(pageWrap.getModel());
    // 转为PageData对象
    return PageData.from(new PageInfo<>(myList));
}
1
2
3
4
5
6
7
8
9
10
11

PageData和PageWrap

细心的同学可能已经发现,无论是单表分页还是多表分页,方法参数均为PageWrap,方法返回均为PageData,为什么不使用MyBatis Plus自带的IPage?有经验的同学可能不难理解这是一种包装,这样可以方便的扩展我们想要的字段,并且无论分页方式如何变化,方法的请求参数和响应结构都能始终保持一致。

# 分页字段排序的实现

有时候我们需要实现列表中存在按自定义列排序的需求。Eva做了些简单的封装支持了字段排序的功能。

# 单表的分页字段排序

单表的分页字段排序可以使用MyBatis Plus默认的实现来处理。









 
 
 

 
 
 
 
 
 
 
 



public PageData<MyModel> findPage(PageWrap<QueryMyModelDTO> pageWrap) {
    // 将查询参数复制到实体类中
    MyModel model = new MyModel();
    BeanUtils.copyProperties(pageWrap.getModel(), model);
    // 通过Utils.MP.blankToNull方法将查询参数中的空字符串转为null,避免将空串作为查询条件
    Utils.MP.blankToNull(model);
    QueryWrapper<MyModel> queryWrapper = new QueryWrapper<>(model);
    
    // 查询分页时将使用QueryWrapper作为findPage参数,这里需要手动添加仅查询未删除的数据条件
    queryWrapper.lambda()
            .eq(MyModel::getDeleted, false);
    
    // 增加字段排序
    for(PageWrap.SortData sortData: pageWrap.getSorts()) {
        if (sortData.getDirection().equalsIgnoreCase(PageWrap.DESC)) {
            queryWrapper.orderByDesc(sortData.getProperty());
        } else {
            queryWrapper.orderByAsc(sortData.getProperty());
        }
    }
    return myModelService.findPage(pageWrap.getPage(), pageWrap.getCapacity(), queryWrapper);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 多表的分页字段排序

多表的排序需要结合新的方法参数和SQL处理来完成,不过实现也非常简单。

MyModelMapper.java

增加orderByClause,并通过@Param注解标记SQL参数名称。

public interface MyMapper extends BaseMapper<MyModel> {

    List<MyModelListVO> search(@Param("dto") QueryMyModelDTO dto, @Param("orderByClause") String orderByClause);
}
1
2
3
4

MyModelMapper.xml



























 




<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="...">
  <!-- 结果集映射 -->
  <resultMap
      id="MyModelListVO"
      type="...MyModelListVO"
      autoMapping="true"
  >
    <id column="id" property="id"/>
  </resultMap>

  <!-- 搜索方法 -->
  <select id="search"
          parameterType="...QueryMyModelDTO"
          resultMap="MyModelListVO"
  ...>
  SELECT ...
  FROM ...
  LEFT JOIN ...
  LEFT JOIN ...
  <where>
    <if dto.param
    != null>
    AND ...
  </if>
</where>
    ${orderByClause}
    </select>
    </mapper>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

MyModelBiz.java







 
 




@Override
public PageData<MyModelListVO> findPage(PageWrap<QueryMyModelDTO> pageWrap) {
    // 使用MyBatisPlus提供的方法开启分页
    PageHelper.startPage(pageWrap.getPage(), pageWrap.getCapacity());
    // 通过Utils.MP.blankToNull方法将查询参数中的空字符串转为null,避免SQL中缺少非空串判断
    Utils.MP.blankToNull(pageWrap.getModel());
    // 调用SQL语句查询出结果,给定排序参数
    List<MyListVO> myList = myMapper.search(pageWrap.getModel(), pageWrap.getOrderByClause());
    // 转为PageData对象
    return PageData.from(new PageInfo<>(myList));
}
1
2
3
4
5
6
7
8
9
10
11

注意

MyModelMapper.java中使用@Param注解标识了SQL参数的名称,条件参数对象被标识为dto,因此在使用条件字段时应使用dto.prop (prop为字段名称),而不应直接使用prop

# 系统自带的通用接口

接口 说明 参数
POST /oss/upload/image 上传图片 file:图片文件
POST /oss/upload/attach 上传文件 file:文件
GET /resource/oss/image 访问图片 f:图片文件的fileKey
GET /resource/oss/attach 下载文件 f:文件上传后的fileKey, fn:下载后的文件名称
GET /resource/local/download 下载本地文件 path:文件路径

# 下载本地文件说明

下载本地文件接口用于处理一些固定的文件需要下载的场景,例如从Excel中导入数据时需要一份Excel文件模版!此时可以将文件存储在项目files目录下,并调用GET /resource/local/download 接口来下载文件。 如果要修改本地文件的存储路径,可直接修改application-config.yml文件,如下

# 应用配置
app:
  # 本地文件存储路径,"~"表示当前项目路径或部署路径
  local-file-directory: ~/files
1
2
3
4

# 导入Excel

# 功能说明

导入Excel指的是将Excel中的数据经过系统导入到数据库中。

# 实现步骤

# 1. 制作Excel模版

在做Excel导入之前,应该先提供一个Excel模版用于下载,这样可以避免用户使用错误的Excel格式。模版制作后,可以查看系统自带的通用接口 中的下载本地文件接口来了解如何存放。

# 2. 配置Excel列信息




 


 



@Data
public class MyExcelImportDTO {

    @ExcelImportColumn(name="姓名")
    private String name;
    
    @ExcelImportColumn(name="手机号码")
    private String mobile;
}
1
2
3
4
5
6
7
8
9

# 3. 执行导入

当Controller获取到上传的Excel文件file后,可使用以下代码进行解析和导入。







ExcelImporter.build(MyExcelImportDTO.class).importData(file.getInputStream(), (rows, index) -> {
    for (MyExcelImportDTO row : rows) {
        // 创建或更新数据
    }
}, sync);
1
2
3
4
5

importData方法参数说明

参数 说明
InputStream is 上传的Excel文件流
ExcelImportCallback callback 导入数据的回调方法,参数为导入数据行和导入数据行的索引,返回值为导入数据的成功数。
boolean sync 如果数据已存在,是否更新

# @ExcelImportColumn

@ExcelImportColumn注解用于指定Excel导入时,绑定字段和Excel中的列。

参数 说明
index 列索引,指定字段读取Excel中的第几列,从0开始计数
name 列名,指定字段在Excel中的列名
converter 数据转换器
args 数据转换器参数

# 数据转换

很多时候,Excel中的数据并不是我们真正想要得到的数据,而转换器的目的就是将Excel中的数据转为我们真正想要获取的数据。Eva默认提供了两个数据转换器。即DoubleToStringConverterIntegerToStringConverter。分别用于将Double类型转为String和将Integer类型转为String。使用转换器非常的简单,如下:

public class MyExcelImportDTO {

    @ExcelImportColumn(name="手机号码", converter=IntegerToStringConverter.class)
    private String mobile;
}
1
2
3
4
5

# 自定义数据转换器

为了保证数据转换的可扩展性,Eva支持自定义的数据转换器,实现起来也非常简单,如下

public class MyDataConverter implements ExcelDataConverterAdapter {
    
    @Override
    Object format (Object value, String[] args, Cell cell, Workbook workbook) {
        // value:单元格数据
        // args:ExcelImportColumn或ExcelExportColumn注解的args参数
        // cell:单元格对象
        // workbook 工作簿对象
    }
} 
1
2
3
4
5
6
7
8
9
10

# 导出Excel

# 功能说明

导出Excel指的是将一个列表导出成一个Excel文件,方便其它部门查看或进一步处理。

# 实现步骤

在Eva中实现一个导出功能是非常简单的,如下

# 1. 配置Excel列信息

public class MyExportExcelVO {

    @ExcelExportColumn(name="列名1")
    private String name;
    
    @ExcelExportColumn(name="列名2")
    private String name2;
}
1
2
3
4
5
6
7
8

# 2. 实现导出接口

@Api(tags = "Xxx模块")
@RestController
@RequestMapping("/xx/xxx")
public class MyController extends BaseController {

    @Resource
    private MyService myService;

    @ApiOperation("导出Excel")
    @PostMapping("/export")
    @RequiresPermissions("xx:xxx:xxxx")
    public void exportExcel (HttpServletResponse response) {
        ExcelExporter.build(MyExportExcelVO.class).exportData(myService.find(), "Excel文件名称", response);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# @ExcelExportColumn

@ExcelExportColumn注解用于导出Excel时,绑定字段和Excel中的列。

参数 说明
name 列名
width 列宽
index 列宽(单位为字符),-1按字段反射顺序排序
align 对齐方式
backgroundColor 列头背景色
dataBackgroundColor 数据单元格的背景色
color 字体颜色
fontSize 字体大小(像素)
bold 是否加粗
italic 是否倾斜
valueMapping 值映射,如0=女;1=男
prefix 数据前缀
suffix 数据后缀
dateFormat 日期格式,只有数据为java.util.Date时才生效
dict 字典编码,指定后将自动根据编码获取字典数据,并找到与之匹配的数据标签
converter 数据转换器
args 数据转换器参数
authorize 权限表达式,只有满足权限表达式时才会存在该列

# 数据转换

有时候我们希望数据展现为一个特殊的格式,或者需要对数据进一步的加工。此时我们可以通过数据转换器来实现,如下

public class MyExportExcelVO {

    @ExcelExportColumn(name="列名1", converter=MyConverter.class)
    private String name;
    
}
1
2
3
4
5
6

# 自定义数据转换器

后端手册/导入Excel/自定义数据转换器保持一致。

# 防重复提交

在接口方法上添加@PreventRepeat注解即可,参数如下

参数 说明
handler 防重复规则设定类,默认为PreventRepeatDefaultHandler.class
interval 间隔时间(ms),在此时间再次被调用视为重复调用,默认为800
message 默认为“请求过于频繁”

示例1:采用默认参数

 




@PreventRepeat
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

示例2:指定防重复时间和错误消息

 




@PreventRepeat(interval = 1000, message = "请求过于频繁,请稍后再试!")
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

# 自定义防重复提交规则

默认情况下防重复的规则由PreventRepeatDefaultHandler.class 设定,它将请求路径、用户令牌、客户端IP信息合并在一起并签名作为请求的Key。如果你并不希望这样,那么你可以自定义防重复的规则。实现方式如下

@Component
public class MyPreventRepeatHandler extends PreventRepeatHandlerAdapter {

    @Override
    public String sign(HttpServletRequest request) {
        // 根据您要验证的参数进行签名并返回即可
    }
}
1
2
3
4
5
6
7
8

# 操作日志

# 智能模式和手动模式

Eva的操作日志分为两种模式——智能模式和手动模式,默认情况下使用的是智能模式。您可以在application-config.yml文件中修改配置,如下

# 应用配置
app:
  # 跟踪日志
  trace:
    # 开启智能跟踪模式
    smart: true
    # 排除跟踪的URL正则
    exclude-patterns:
      - .+/list[a-zA-Z0-9\-\_]*$
      - .+/tree[a-zA-Z0-9\-\_]*$
      - .+/page[a-zA-Z0-9\-\_]*$
      - .+/all[a-zA-Z0-9\-\_]*$
      - /swagger-resources.*
      - /v2/api-docs.*
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • 如果您想手动记录操作日志,可修改smartfalse,此时您需要通过@Trace注解来标记出需要记录日志的接口。
  • 智能模式下,如果接口不需要记录日志,可以在exclude-patterns 配置项中增加接口路径或正则。也可以直接使用@Trace(exclude=true)注解来排除。

# @Trace

无论是智能模式还是手动模式,您都可能需要使用@Trace注解,添加该注解后,优先级是最高的。该注解可以添加在接口方法上,也可以添加到Controller类上,以下是注解参数说明。

参数 含义 默认值 说明
module 模块名称 不存在值时读取类上@Trace的module参数,如果类上无@Trace则读取Swagger @Api的tags参数
type 操作类型 TraceType.AUTO 默认自动推测
remark 操作备注 不存在值时读取Swagger @ApiOperation,如果不存在@ApiOperation,则读取操作类型的备注
exclude 是否排除 false 为true时表示不为接口添加日志记录
withRequestParameters 是否写入请求参数 true 为false时表示日志中不记录请求参数
withRequestResult 是否写入请求结果 true 为false时表示日志中不记录请求结果

智能跟踪的原理和约定

智能模式自动识别的原理是通过正则匹配接口路径,为了能够准确的识别,你应该按如下约定进行接口路径的定义:

  • 新增: 满足正则表达式.+/create.*
  • 修改: 满足正则表达式.+/update.**
  • 删除: 满足正则表达式.+/delete.*
  • 批量删除: 满足正则表达式.+/delete/batch$
  • 导入: 满足正则表达式.+/import.*
  • 导出: 满足正则表达式.+/export.*
  • 重置: 满足正则表达式.+/reset.**

# 线程池

有时候我们需要做一些简单的异步处理,也就是说需要开启一个新的线程,而线程池的加入可以让线程更加的安全和可控。在Eva中,你可以使用以下代码来直接通过线程池开启一个新的线程。

Utils.ThreadPool.start(() -> {
    // 线程代码
});
1
2
3

# 获取字典数据和系统配置

# 获取字典

为了方便获取字典数据,在系统启动时会将字典和字典数据一次性加载到缓存中,在缓存中的数据结构如下:

DictCache.java —— 字典缓存类

属性 说明
Integer id 字典ID
String code 字典编码
String name 字典名称
List<DictDataCache> dataList 字典数据列表

DictDataCache.java —— 字典数据缓存类

属性 说明
Integer id 字典数据ID
String value 字典数据值
String label 字典数据标签
String config 字典数据配置

因为字典数据已经被一次性加载,所以可以提供全局方法来直接获取,如下:

// 根据字典编码获取字典对象
DictCache dict = Utils.Dict.getDict("字典编码");

// 根据字典编码和数据值获取字典数据对象
DictDataCache dictData = Utils.Dict.getDictData("字典编码", "数据编码");

// 根据字典编码和数据值获取数据标签
String dataLabel = Utils.Dict.getDictDataLabel("字典编码", "数据编码");
1
2
3
4
5
6
7
8

# 获取系统配置

系统配置和字典一样,在启动项目时会一次性加载到缓存中,可通过全局方法直接获取

// 根据配置编码获取配置值
String config = Utils.AppConfig.get("配置编码");
1
2

# 安全通讯

对于安全性要求较高的项目,我们希望请求参数和响应参数都进行加密处理。这在Eva中实现起来非常简单,只需要配置加密接口即可,如下

application-config.yml










 



 
 
 

# 应用配置
app:
  ...
  # 安全配置
  security:
    # 数据传输安全配置
    transmission:
      # 需要进行加密的接口路径
      path-patterns:
        - /system/login
      # 不需要进行加密的接口路径
      exclude-path-patterns:
      # 密钥,用于请求和响应时传输参数的加解密,该密钥定义需要定义在前端,容易暴露,需和数据库密钥进行区分
      key: 2B7E151628AED2A6
      key-len: 128
      iv: 3D8A9F0BAC4E7D61
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

加密后Swagger如何调试?

Swagger可以正常明文传参,因为在Eva中,如果是Swagger发起的请求,则不会对请求参数进行解密,也不会加密响应内容。

# 2FA二次认证

某些敏感操作可能需要用户进行二次密码确认,例如退款审核、手动充值等。此时则可以利用2FA二次认证来完成。在Eva中实现2FA认证也是非常简单的,只需要在接口上添加@EnableTwoFA 注解即可,如下

 




@EnableTwoFA
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

# 字段加密和脱敏处理

字段加密和脱敏指的是对敏感信息加密后存储,查询时解密后返回。如用户的手机号码、身份证号码均为敏感信息。同样可以通过注解来实现这一功能,如下

接口添加@EnableSecureField注解启用安全字段

 





@EnableSecureField
public ApiResponse<?> create(@RequestBody CreateMyModelDTO dto) {
  myModelBiz.create(dto);
  return ApiResponse.success(null);
}
1
2
3
4
5

DTO/VO类添加@EnableSecureField注解

 



@EnableSecureField
public class CreateMyModelDTO {
}
1
2
3

为DTO/VO类需要加密或解密的字段添加@SecureField注解




 



@EnableSecureField
public class CreateMyModelDTO {

    @SecureField
    private String mobile;
}
1
2
3
4
5
6

通过以上步骤即可完成字段加密和脱敏处理。

为什么DTO/VO类还需要添加`@EnableSecureField`注解?

在DTO/VO类上添加@EnableSecureField注解是为了提高参数解析的速度。因为接口参数没有定值,可能出现非常复杂的结构,此时如果对结构进行全面扫描是无用功。 通过注解标记出“安全字段所在类”,可以显著提高解析速度,快速发现安全字段。

# 实用工具类

所有工具类都通过Utils调用,这样你可以不用去查找工具类就可以方便的获取工具类对象。

# Utils.AppConfig/获取应用配置

配置在application-config.yml中的配置均在AppConfig类中定义了与之匹配的属性,可通过Utils.AppConfig对象来获取,如下

// 获取超级管理员角色编码
String env = Utils.AppConfig.getSuperAdminRole();

// OSS文件访问路径前缀
String accessPrefix = Utils.AppConfig.getOSS().getAccessPrefix();
1
2
3
4
5

也可以获取当前配置是否为测试模式,详见测试模式 。也可直接获取系统配置,详见系统配置

# Utils.OSS/文件上传和下载

Utils.OSS为OSS工具类,用于处理文件的上传与下载。它有如下方法:

/**
 * 上传图片
 *
 * @param imageFile 图片文件
 * @param businessPath 业务路径,如使用"/avatar"表示用户头像路径,"/goods/cover"表示商品封面图片等
 * @return 文件访问路径
 */
public UploadResult uploadImage(MultipartFile imageFile, String businessPath);

/**
 * 上传图片
 *
 * @param imageFile 图片文件
 * @param businessPath 业务路径,如使用"/avatar"表示用户头像路径,"/goods/cover"表示商品封面图片等
 * @return 文件访问路径
 */
public UploadResult uploadImage(String bucket, MultipartFile imageFile, String businessPath);

/**
 * 上传文件
 *
 * @param file 文件
 * @return 文件访问路径
 */
public UploadResult upload(MultipartFile file);

/**
 * 上传文件
 *
 * @param file 文件
 * @param businessPath 业务路径,如使用"/contract"表示合同文件,"/contract/attach"表示合同附件
 * @return UploadResult
 */
public UploadResult upload(MultipartFile file, String businessPath);

/**
 * 上传文件
 *
 * @param bucket 存储空间名称
 * @param file 文件
 * @param businessPath 业务路径,如使用"/contract"表示合同文件,"/contract/attach"表示合同附件
 * @return UploadResult
 */
public UploadResult upload(String bucket, MultipartFile file, String businessPath);

/**
 * 下载
 *
 * @param fileKey 文件在存储空间中的key
 * @return InputStream
 */
public InputStream download (String fileKey);

/**
 * 下载
 *
 * @param bucket 存储空间名称
 * @param fileKey 文件在存储空间中的key
 * @return InputStream
 */
public InputStream download(String bucket, String fileKey);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

OSS.UploadResult属性说明

属性名称 类型 说明
originalFilename String 源文件名称
fileKey String 文件的key
accessUri String 访问路径/下载路径

示例1:文件大小和格式的限制

// 上传文件"avatar.jpg"
Utils.OSS
    // 限定文件大小不超过5M
    .setMaxSize(5)
    // 限定只允许.png和.jpg格式文件的上传
    .setFileTypes('.png,.jpg')
    .uploadImage(file);
// 方法返回: { originalFilename: "avatar.jpg", fileKey: "xxxxx.jpg", accessUri: "/resource/oss/image?f=xxxxx.jpg" }
1
2
3
4
5
6
7
8

示例2:指定业务路径/存储路径

// 上传文件"avatar.jpg"
Utils.OSS.uploadImage(file, "/avatar");
// 方法返回: { originalFilename: "avatar.jpg", fileKey: "avatar/xxxxx.jpg", accessUri: "/avatar?f=xxxxx.jpg" }
1
2
3

业务路径的用途

您可能会遇见在下载/预览文件时需要对文件特殊处理的场景,如下载课程视频时添加水印,且文件存储在一个指定的bucket目录,则可以使用业务路径来实现:

// 上传课程视频
@PostMapping("/cource/video/upload")
public ApiResponse<OSSUtil.UploadResult> uploadVideo(MultipartFile file) {
    return ApiResponse.success(Utils.OSS.upload(file, "/cource/video"));
}
// 下载课程视频
@GetMapping("/cource/video")
public void downloadVideo(@RequestParam(name = "f") String fileKey) {
    // 注意去掉前方的“/”
    InputStream is = Utils.OSS.download("cource/video/" + fileKey);
    // 添加水印
    // TODO
    // 写出文件流到客户端
    response.getOutputStream().write(...);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# Utils.Dict/读取字典和字典数据

详见获取字典

# Utils.Session/会话工具

/**
 * 获取当前登录用户
 *
 * @return 当前登录用户信息
 */
public LoginUserInfo getLoginUser ();

/**
 * 获得session id
 *
 * @return 会话对象
 */
public Serializable getSessionId ();
1
2
3
4
5
6
7
8
9
10
11
12
13

# Utils.SpringContext/Spring上下文工具

/**
 * 获取Bean实例
 *
 * @param name 类注册名称
 * @return Object
 */
public Object getBean(String name);

/**
 * 获取Bean实例
 *
 * @param clazz Class
 * @return T
 */
public <T> T getBean(Class<T> clazz);

/**
 * 获取Bean实例
 *
 * @param name 类注册名称
 * @param clazz Class
 * @return T
 */
public <T> T getBean(String name, Class<T> clazz);

/**
 * 获取环境对象
 *
 * @return Environment
 */
public Environment getEnvironment();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

# Utils.Location/地区工具

Utils.Location为地区工具类,它有如下方法:

/**
 * 获取地区信息
 *
 * @param ip IP
 * @return Info
 */
public Info getLocation (String ip);

/**
 * 获取IP详细地址
 *
 * @param ip IP
 * @return String
 */
public String getLocationString (String ip);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# Utils.Http/请求工具

在Eva中,发起一次请求的基本逻辑如下:

  1. 构造HttpUtil.HttpWrap对象
  2. 请求设置(如设置请求头,请求超时时间等)
  3. 发起请求
  4. 请求结果转换

构造HttpUtil.HttpWrap对象

方法名称 参数 方法返回 说明
build String url HttpUtil.HttpWrap 根据请求地址构造
build String url, String charset HttpUtil.HttpWrap 根据请求地址和编码构造

请求设置

方法名称 参数 方法返回 说明
setRequestProperty String key, String value HttpUtil.HttpWrap 设置请求头
setConnectTimeout int timeout HttpUtil.HttpWrap 设置连接超时时间
setReadTimeout int timeout HttpUtil.HttpWrap 设置读取超时时间
gzip HttpUtil.HttpWrap 开启gzip压缩

发起请求

方法名称 参数 方法返回 说明
get HttpUtil.HttpResult 发起GET请求
post HttpUtil.HttpResult 发起POST请求
post String params HttpUtil.HttpResult 发起POST请求
postJSON Map<String, Object> paramsMap HttpUtil.HttpResult 发起POST请求
postJSON JSONObject paramJSONObject HttpUtil.HttpResult 发起POST请求

请求结果转换

方法名称 参数 方法返回 说明
toStringResult String 转为字符串
toJSONObject JSONObject 转为fastjson JSONObject对象
toClass Class 转为指定class后的对象 转为指定类对象

示例1: 发起GET请求

Utils.Http.build(url)
    .get()
    .toStringResult();
1
2
3

示例2: 发起POST请求

Utils.Http.build(url)
    .post(data)
    .toStringResult();
1
2
3

示例3: 开启gzip压缩

Utils.Http.build(url)
    .gzip()
    .post(data)
    .toStringResult();
1
2
3
4

示例4: 设置请求编码

Utils.Http.build(url, Charset.forName("GBK").toString())
    .get()
    .toStringResult();
1
2
3

# Utils.Digest/获取文本摘要

/**
 * 获取手机号码摘要
 * 逻辑:前3个数字 + **** + 后4个数字
 *
 * @param mobile 手机号码
 * @return 手机号摘要
 */
public String digestMobile (String mobile);

/**
 * 获取邮箱摘要
 * 逻辑:获取前缀,前缀第一个字符 + **** + 前缀最后一个字符 + 邮箱后缀
 *
 * @param email 邮箱
 * @return 邮箱摘要
 */
public String digestEmail (String email);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# Utils.UserClient/获取客户端信息

Utils.UserClient用户获取客户端信息,它有如下方法:

方法名称 参数 方法返回 说明
getOS HttpServletRequest request String 获取客户端操作系统信息
getBrowser HttpServletRequest request String 获取客户端浏览器信息
getIP HttpServletRequest request String 获取客户端IP
getPlatform HttpServletRequest request String 获取用户操作的平台

# Utils.Server/服务器工具

Utils.Server为服务器工具,它有如下方法:

方法名称 参数 方法返回 说明
getIP String 获取当前服务器IP(局域网)
getMAC String 获取当前服务器MAC地址

# Utils.MP/MyBatis Plus工具

目前该工具仅提供了一个public <T> void blankToNull(T object)方法,用于在做查询时,将查询参数对象中的空字符串转为null,避免将空串作为条件。

WHY?

为了防止MyBatis Plus中的blank to null与实际中的blank to null存在差异,因此,单独编写了该方法仅用于对查询条件对象的blank to null处理。

# Utils.Secure/安全工具

安全工具中有诸多关于安全层面的方法,下面仅列出常用的方法,更多方法请参考源码!

/**
 * 加密字段
 *
 * @param plainText 明文
 * @return 加密后的字段值
 */
public String encryptField(String plainText);

/**
 * 解密字段
 *
 * @param cipherText 密文
 * @return 解密后的字段值
 */
public String decryptField(String cipherText);

/**
 * 加密密码,公式为:MD5(MD5(password) + salt)
 *
 * @param password 密码
 * @param salt 密码盐
 * @return String
 */
public String encryptPassword(String password, String salt);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# Utils.Date/日期工具

/**
 * 获取最近N天前的日期
 *
 * @return N天前的日期
 */
public Date getBeforeDay (int recentDay);

/**
 * 获取昨天
 * @return 昨天
 */
public Date getYesterday();

/**
 * 获取当月的开始时间
 *
 * @return 当月开始时间
 */
public Date getMonthStart ();

/**
 * 获取当月的结束时间
 *
 * @return 当月结束时间
 */
public Date getMonthEnd ();

/**
 * 获取当年开始时间
 *
 * @return 当年开始时间
 */
public Date getYearStart ();

/**
 * 获取当年结束时间
 *
 * @return 当年结束时间
 */
public Date getYearEnd ();

/**
 * 获取日期的开始时间
 *
 * @param date 日期
 * @return Date
 */
public Date getStart (Date date);

/**
 * 获取日期的结束时间
 *
 * @param date 日期
 * @return Date
 */
public Date getEnd (Date date);

/**
 * 格式化
 *
 * @param date 日期
 * @return String
 */
public String format (Date date);

/**
 * 格式化
 *
 * @param date 日期
 * @param format 格式
 * @return String
 */
public String format (Date date, String format);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

# Utils.ThreadPool/线程池

详见线程池

# Utils.AES/AES加密工具

/**
 * 加密
 *
 * @param plainText 明文
 * @param key 密钥
 * @param keyLen 密钥长度
 * @param iv 向量
 * @return String
 */
public String encrypt(String plainText, String key, int keyLen, String iv);

/**
 * 解密
 *
 * @param cipherText 密文
 * @param key 密钥
 * @param keyLen 密钥长度
 * @param iv 向量
 * @return String
 */
public String decrypt(String cipherText, String key, int keyLen, String iv);

/**
 * 加密数据
 *
 * @return String
 */
public String encryptData(String plainText) throws SecurityException;

/**
 * 解密传输参数
 *
 * @param cipherText 密文
 * @return String
 */
public String decryptData(String cipherText) throws SecurityException;

/**
 * 加密传输参数
 *
 * @return String
 */
public String encryptTransmission(String plainText) throws SecurityException;

/**
 * 解密数据
 *
 * @param cipherText 密文
 * @return String
 */
public String decryptTransmission(String cipherText) throws SecurityException;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

# 响应状态定义及规范

系统的响应状态定义在ResponseStatus枚举中,如下

@Getter
@AllArgsConstructor
public enum ResponseStatus {
    // 400开头表示参数错误
    BAD_REQUEST(4000, "参数错误"),
    DATA_EMPTY(4001, "找不到数据,该数据可能已被删除"),
    DATA_EXISTS(4002, "记录已存在"),
    DATA_ERROR(4003, "数据错误"),
    PWD_INCORRECT(4004, "密码不正确"),
    VERIFICATION_CODE_INCORRECT(4005, "验证码不正确或已过期"),
    ACCOUNT_INCORRECT(4006, "账号或密码不正确"),
    LOCAL_FILE_NOT_EXISTS(4007, "文件不存在"),
    PRIVILEGE_ERROR(4008, "疑似存在非法权限提升或不是最新数据,请刷新数据后重试"),
    TWO_FA_INCORRECT(4009, "登录密码不正确"),
    TWO_FA_REQUIRED(4010, "需要进行2FA认证"),
    TWO_FA_FAILED(4011, "2FA认证失败"),
    // 500开头表示未知的服务异常
    SERVER_ERROR(5000, "系统繁忙,请联系系统管理员"),
    EXPORT_EXCEL_ERROR(5010, "导出Excel失败,请联系系统管理员"),
    IMPORT_EXCEL_ERROR(5011, "导入Excel失败,请联系系统管理员"),
    // 510开头表示可能导致数据错误的异常
    REPEAT_REQUEST(5100, "请勿重复提交"),
    MASSIVE_REQUEST(5101, "请求过于频繁"),
    NOT_ALLOWED(5110, "不允许的操作"),
    ;

    private final int code;

    private final String message;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

响应码如何定义对程序不会产生直接影响,但我们希望您在使用Eva时可以培养一个好的习惯——定义可识别的响应码。除了目前定义的响应码外,下面再给出一些例子供参考

响应码前缀 说明
10 以10开头表示用户模块错误,如1000表示登录过于频繁
11 以11开头表示会员模块错误,如1100表示会员已到期
12 以12开头表示订单模块错误,如1200表示货源不足
400 以400开头表示参数错误所导致的各类情况的响应码
500 以500开头表示程序因异常终止,不同类型的异常对应不同的响应码
510 以510开头表示请求可能导致数据错误

您可以按照您团队和业务的考量将响应码以其它方式规范化。这样的一个显而易见的好处是,我们仅凭响应码就能识别错误的严重性。

# 自定义全局异常处理

全局异常处理在类GlobalExceptionAdvice中实现,通过spring的@RestControllerAdvice注解实现。如果您需要全局捕获某异常,可以直接在GlobalExceptionAdvice类中继续添加,默认情况下添加了以下异常:

@Slf4j
@RestControllerAdvice
public class GlobalExceptionAdvice {

    /**
     * 业务异常处理
     */
    @ExceptionHandler(BusinessException.class)
    public Object handleBusinessException (BusinessException e);

    /**
     * 无权限异常处理
     */
    @ExceptionHandler(UnauthorizedException.class)
    public Object handleUnauthorizedException (UnauthorizedException e);

    /**
     * 参数验证未通过异常处理
     */
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public Object handleMissingServletRequestParameterException (MissingServletRequestParameterException e);

    /**
     * 其它异常处理
     */
    @ExceptionHandler(Exception.class)
    public Object handleException (Exception e);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

无法捕获异常?

如果您的异常无法捕获,您可以从以下几个方面着手检查

  1. 异常是否已被处理,即抛出异常后被catch,打印了日志或抛出了其它异常
  2. 异常是否非Controller抛出,即在拦截器或过滤器中出现的异常

# 业务异常处理

Eva为业务异常封装了异常对象BusinessException,提供了若干个构造方法以方便构建,定义如下:

@Data
@EqualsAndHashCode(callSuper = false)
public class BusinessException extends RuntimeException {

    private Integer code;

    public BusinessException(Integer code, String message);

    public BusinessException(Integer code, String message, Throwable e);

    public BusinessException(ResponseStatus status);

    public BusinessException(ResponseStatus status, String message);

    public BusinessException(ResponseStatus status, Throwable e);

    public BusinessException(ResponseStatus status, String message, Throwable e);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 会话模式及其配置

Eva提供两种会话模式,分别是:

  • INTERACTIVE:交互模式(默认),每次访问系统接口,都会自动延长会话过期时间至指定的会话过期时间。
  • FIXED:固定模式,无论在会话过期时间内是否有访问系统,会话都会在指定的过期时间后失效。

您可以在application-config.yml中配置,如下

# 应用配置
app:
  # 会话配置
  session:
    # 会话模式
    mode: INTERACTIVE
1
2
3
4
5
6

# 验证码过期时长

默认情况下,系统中自带了图片验证码,您可以在application-config.yml配置图片验证码的过期时长,如下:

# 应用配置
app:
  # 验证码
  captcha:
    # 图片验证码
    image:
      # 过期时长(s)
      expire: 300
1
2
3
4
5
6
7
8