# 后端手册

# 项目结构

包名 含义
com.eva.api 控制层/接口层
com.eva.biz 复合业务层
com.eva.service 颗粒业务层
com.eva.dao 持久层
com.eva.config 配置
com.eva.core 项目核心

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

包名 含义
com.eva.api 控制层/接口层
com.eva.biz 复合业务层
com.eva.service 颗粒业务层
com.eva.service.aware 数据意识
com.eva.service.common 通用颗粒业务
com.eva.service.proxy 业务代理
com.eva.dao 持久层
com.eva.config 配置
com.eva.core 项目核心
com.eva.core.annotation 项目注解
com.eva.core.aware 通用意识接口、意识默认实现及意识辅助注解
com.eva.core.constants 常量定义
com.eva.core.exception 自定义异常
com.eva.core.model 通用模型
com.eva.core.utils 通用工具类

设计思想

项目为四层结构,api > biz > service > dao,相信很多新手会疑惑,但随着你技术、经验和思想的提升,你很快也会这么去做!现在让我们加快你成长的步伐,尝试着理解每一层的作用,为了更方便的理解,需要从后往前进行讲解!

  • 持久层

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

  • 颗粒业务层

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

  • 复合业务层

复合业务层用于 编写复合业务,这一层可能涉及到多表的增删改操作,而单表的增删改操作的业务实现在颗粒业务层。所以复合业务层应该使用颗粒业务层对象,而不应该直接使用持久层对象。当然,如果你的业务足够简单,例如删除一条单表记录,那么这一层可以不用实现。如果你的业务是一个复合业务,例如新建时需要验证编码字段是否重复,那么此时需要在该层添加创建方法,并在该方法中进行验证。

  • 控制层/接口层

控制层/接口层用于 定义接口并调用相关业务方法,而业务方法可以来自复合业务层和颗粒业务层。

# 服务端口配置

端口配置在application.yml的server.port属性中。调整后需重启服务。

# 接口文档

如果端口没有修改的话,那么接口地址为http://localhost:10010/doc.html

# 测试模式

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








 

# 项目信息配置
project:
  name: 伊娃
  version: 1.0.0
  # 环境,生产环境production,开发环境development
  env: development
  # 模式,testing测试模式
  mode: testing
1
2
3
4
5
6
7
8

当前测试模式开启后,不会校验验证码。

# 权限控制

Eva整合Shiro实现权限控制,通过Shiro提供的@RequiresRoles@RequiresPermissions注解可完成角色或权限的控制。

# @RequiresRoles

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

  • value: 角色列表
  • logical: 角色之间的判断关系,默认为Logical.AND

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

 




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

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

 




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

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

 




@RequiresRoles(value = {"admin", "manager"}, logical = Logical.OR)
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

# @RequiresPermissions

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

  • value: 权限列表
  • logical: 权限之间的判断关系,默认为Logical.AND

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

 




@RequiresPermissions("order:create")
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

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

 




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

示例3: 以下代码表示必须拥有order:create order:update角色才可访问

 




@RequiresPermissions(value = {"order:create", "order:update"}, logical = Logical.OR)
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

# 参数验证

Eva采用spring自带的@Validated注解进行参数验证。为了在使用实体类的情况下更好的区分出新建、编辑和其他操作验证的不同,Eva提供了OperaType操作类型标识类。使用方式如下

MyController.java


 




 



// 新建
public ApiResponse create(@Validated(OperaType.Create.class) @RequestBody MyModel myModel) {
    return ApiResponse.success(...);
}

// 编辑
public ApiResponse updateById(@Validated(OperaType.Update.class) @RequestBody MyModel myModel) {
    return ApiResponse.success(...);
}
1
2
3
4
5
6
7
8
9

MyModel.java




 



 




public class MyModel {
   
   // 仅在编辑时验证
   @NotNull(message = "主键不能为空", groups = {OperaType.Update.class})
   private Integer id;
   
   // 在新建和编辑时验证
   @NotBlank(message = "名称不能为空", groups = {OperaType.Create.class, OperaType.Update.class})
   private String name;
}

1
2
3
4
5
6
7
8
9
10
11

扩展

OperaType类只是用于标识出不同的操作类型,所以如果你有更多的操作类型,你可以在OperaType类中继续添加新的标识。

# 无认证接口实现

有些时候我们允许不登录直接访问接口,此时需要在拦截器中配置接口放行。如下

com.myproject.WebConfigurer.java
























 




@Configuration
public class WebConfigurer implements WebMvcConfigurer {

    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/resource/result/**", 
                        "/resource/image/**", 
                        "/resource/result", 
                        "/resource/record", 
                        "/sms/**", 
                        "/doc.html", 
                        "/swagger-resources/**", 
                        "/webjars/**", 
                        "/user/regis", 
                        "/user/login", 
                        "/user/setPwd", 
                        "/user/findPwd",
                        "/new/interface"
                );
    }
}
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

注意

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

# 事务处理

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

误区

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

# 分页实现

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

# 单表的分页实现

单表的分页使用MyBatis Plus自带的分页方法来实现是再好不过的选择了,这样可方便的实现多条件查询。

public PageData<MyModel> findPage(PageWrap<MyModel> pageWrap) {
    IPage<MyModel> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
    QueryWrapper<MyModel> queryWrapper = new QueryWrapper<>(WrapperUtil.blankToNull(pageWrap.getModel()));
    return PageData.from(myMapper.selectPage(page, queryWrapper));
}
1
2
3
4
5

# 多表的分页实现

多表的分页需要编写SQL语句,使用MyBatis的分页插件来实现会是一个明智的选择。

@Override
public PageData<MyListVO> findPage(PageWrap<MyQueryDTO> pageWrap) {
    PageHelper.startPage(pageWrap.getPage(), pageWrap.getCapacity());
    List<MyListVO> myList = myMapper.selectManageList(pageWrap.getModel());
    return PageData.from(new PageInfo<>(myList));
}
1
2
3
4
5
6

PageData和PageWrap

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

# 分页字段排序的实现

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

# 单表的分页字段排序

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


 

 
 
 
 
 
 
 
 



public PageData<MyModel> findPage(PageWrap<MyModel> pageWrap) {
    IPage<MyModel> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
    QueryWrapper<MyModel> queryWrapper = new QueryWrapper<>(MyBatisPlus.blankToNull(pageWrap.getModel()));
    // 字段排序
    for(PageWrap.SortData sortData: pageWrap.getSorts()) {
        if (sortData.getDirection().equalsIgnoreCase(PageWrap.DESC)) {
            queryWrapper.orderByDesc(sortData.getProperty());
        } else {
            queryWrapper.orderByAsc(sortData.getProperty());
        }
    }
    return PageData.from(myMapper.selectPage(page, queryWrapper));
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 多表的分页字段排序

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

MyMapper.java

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

public interface MyMapper extends BaseMapper<MyModel> {

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

MyMapper.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="...">
  <select id="selectManageList" ...>
    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

MyServiceImpl.java




 



@Override
public PageData<MyListVO> findPage(PageWrap<MyQueryDTO> pageWrap) {
    PageHelper.startPage(pageWrap.getPage(), pageWrap.getCapacity());
    List<MyListVO> myList = myMapper.selectManageList(pageWrap.getModel(), pageWrap.getOrderByClause());
    return PageData.from(new PageInfo<>(myList));
}
1
2
3
4
5
6

注意

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

# OSS文件的上传和访问

# 功能说明

大部分文件,如商品图片,合同签订后的PDF,用户视频等都会存储在文件服务器,也就是OSS。Eva默认提供了常用的文件上传和访问接口。

# 上传图片

接口类:FileUploadController.java

接口:POST /upload/image

参数 说明
file 图片文件

# 访问图片

接口类:FileAccessController.java

接口:GET /resource/image

参数 说明
f 图片上传后的fileKey

# 上传文件

接口类:FileUploadController.java

接口:POST /upload/attach

参数 说明
file 文件

# 下载文件

接口类:FileAccessController.java

接口:GET /resource/attach

参数 说明
f 文件上传后的fileKey

# 其它文件的访问

不同的文件类型对应的contentType不同,可以自行添加对应接口来访问。

# 本地文件的下载

# 功能说明

有时候我们希望某些文件直接存放在部署服务器上,但我们仍需要对这些文件进行下载,例如Excel导入的模版文件。为此Eva也提供了本地文件下载的功能。

# 存储文件

您只需要将文件存放在项目/files路径下即可调用下载文件接口来直接下载。

# 下载文件

接口类:LocalFileAccessController.java

接口:GET /resource/local/download

参数 说明
path 文件路径,如文件路径为项目/files/test.txt,则文件路径参数为"/text.txt"
name 下载后的文件名称

# 导入Excel

# 功能说明

导入Excel指的是将Excel中的数据经过系统导入到数据库中,在Eva中导入Excel数据分成两个步骤

  1. 下载Excel模版
  2. 填写数据并执行导入

所以实现Excel的数据导入分成以下几个步骤进行。

# 1. 制作Excel模版

在做Excel导入之前,应该先提供一个Excel模版用于下载,这样可以避免用户使用错误的Excel格式,也方便企业各个岗位进行协作。模版制作后,可以阅读本地文件的下载来知晓如何存放。

前端ImportButton组件中需要的模版路径参数,即为当前Excel文件存放路径。

# 2. 配置Excel列信息




 


 



@Data
public class MyExcelImportDTO {

    @ExcelImportColumn
    private String name;
    
    @ExcelImportColumn
    private String name2;
}
1
2
3
4
5
6
7
8
9

# 3. 实现ExcelImportCallback接口











 





@Component
public class MyExcelImportCallback implements ExcelImportCallback<MyExcelImportDTO> {

    @Override
    @Transactional
    public int callback(List<MyExcelImportDTO> rows, boolean sync) {
        if (CollectionUtils.isEmpty(rows)) {
            return 0;
        }
        int total = 0;
        // 在此做导入数据逻辑处理
        return total;

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

sync参数

callback方法中的sync参数指的是在导入数据时是否需要同步已存在的数据。

# 4. 实现导入接口

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

    @Autowired
    private MyExcelImportCallback myExcelImportCallback;

    // ... 此处省略其它接口定义

    @PreventRepeat
    @ApiOperation("导入")
    @PostMapping("/import")
    @RequiresPermissions("xx:xxx:xxxx")
    public ApiResponse importData(MultipartFile file, boolean sync) throws IOException {
        return ApiResponse.success(
            ExcelImporter
               .build(MyExcelImportDTO.class)
               .importData(file.getInputStream(), myExcelImportCallback, sync)
        );
    }
    
    // ... 此处省略其它接口定义
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

如果不能理解示例代码中的xxx,并且您在构建框架时选择了部门管理模块,那么可以参考SystemDepartmentController类的importExcel接口方法,这个接口用于从Excel中导入部门数据。

# 数据转换

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

public class MyExcelImportDTO {

    @ExcelImportColumn(converter=IntegerToStringConverter.class)
    private String phone;
}
1
2
3
4
5

# 自定义数据转换器

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

public class MyDataConverter implements ExcelDataConverterAdapter {
    
    @Override
    Object format (Object... args) {
        // 其中args[0]为单元格的数据,arg[1]及其之后为@ExcelColumn的args参数(在这里args[1]的结果为"arg1")
    }
} 
1
2
3
4
5
6
7

# ExcelImportColumn注解参数说明

参数 说明
index 排序,值越小越靠前,-1按字段反射顺序排序
converter 数据转换器
args 自定义数据转换器参数

# 导出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 {

    @Autowired
    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

如果不能理解示例代码中的xxx的话,可以参考SystemTraceLogController类的exportExcel接口方法,这个接口用于导出跟踪日志Excel文件。

# 数据转换

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

public class MyExportExcelVO {

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

# 自定义数据转换器

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

# ExcelExportColumn注解参数说明

参数 说明
name 列名
width 列宽
index 列宽(单位为字符),-1按字段反射顺序排序
align 对齐方式
backgroundColor 列头背景色
dataBackgroundColor 数据单元格的背景色
color 字体颜色
fontSize 字体大小(像素)
bold 是否加粗
italic 是否倾斜
valueMapping 值映射,如0=女;1=男
prefix 数据前缀
suffix 数据后缀
dateFormat 日期格式,只有数据为java.util.Date时才生效
converter 数据转换器
args 数据转换器参数

# 自定义数据权限

默认情况下,Eva实现了部门数据权限和独立的岗位数据权限控制。如果您需要添加新的数据权限,如审批数据权限,物品数据权限等,那么您也可以清晰且方便的实现。实现一套数据权限的步骤如下:

# 1. 定义业务模块和权限类型

文件:core/DataPermissionConstants.java

/**
 * 数据权限模块
 */
@Getter
@AllArgsConstructor
enum Module {
    MY_MODULE("MY_MODULE", "我的模块"),
    ;
    
}

/**
 * 数据权限类型
 */
@Getter
@AllArgsConstructor
enum Type {
    ALL((short)0,"全部", new Module[]{}),
    MY_MODULE_CUSTOM((short)1001, "自定义数据", new Module[]{Module.MY_MODULE}),
    MY_MODULE_CHILD((short)1002, "用户所属及其子数据", new Module[]{Module.MY_MODULE}),
    ;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 2. 编写数据意识类

@Component
public class MyModelDataPermissionAware extends DefaultDataPermissionAware<MyModel> {
    
    /**
     * 覆盖父类方法:返回当前数据权限的模块
     */
    @Override
    public DataPermissionConstants.Module module() {
        return DataPermissionConstants.Module.MY_MODULE;
    }

    /**
     * 覆盖父类方法:当角色未配置数据权限时的默认数据权限。
     */
    @Override
    public List<SystemDepartmentListVO> defaultData(Integer userId) {
        return child(userId);
    }
    
    /**
     * 所有权限
     */
    @DataPermissionMapping(value = DataPermissionConstants.Type.ALL, priority = 1)
    public List<SystemDepartmentListVO> all() {
        // 数据权限为"全部"时的数据获取逻辑。
    }
    
    /**
     * 自定义数据
     */
    @DataPermissionMapping(value = DataPermissionConstants.Type.MY_MODULE_CUSTOM, priority = 2, injectCustomData = true)
    public List<MyModel> custom (String customData) {
        // 数据权限为"自定义数据"时的数据获取逻辑。
    }
    
    /**
     * 用户所属及其子数据
     */
    @DataPermissionMapping(value = DataPermissionConstants.Type.MY_MODULE_CUSTOM, priority = 3, injectUser = true)
    public List<MyModel> child (Integer userId) {
        // 数据权限为"用户所属及其子数据"时的数据获取逻辑。
    }
}
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

# 3. 调用数据权限意识

DefaultDataPermissionAware类提供了execute方法,该方法将自动根据用户角色配置的数据权限执行权限方法。


@Autowired
private MyModelDataPermissionAware myModelDataPermissionAware;

public List<MyModel> findList () {
    return myModelDataPermissionAware.excute();
}
1
2
3
4
5
6
7

# @DataPermissionMapping说明

@DataPermissionMapping注解用于标记和配置数据权限处理方法。参数如下:

参数 说明
value 权限类型
priority 优先级,不允许重复
injectUser 是否注入用户ID参数,为true时方法可接收Integer userId参数
injectCustomData 是否注入自定义数据参数,为true时方法可接收String customData参数

priority/优先级的作用

一个用户可能拥有多个角色,多个角色意味着可能拥有不同的数据权限。priority用于在一个用户拥有多种数据权限时该如何选择使用哪种数据权限,值越小,优先级越高。

# 防重复提交

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

# @PreventRepeat

参数 说明
value 防重复规则设定类,默认为PreventRepeatDefaultHandler.class
interval 间隔时间,小于此时间视为重复提交,默认为800毫秒
message 错误消息
limit 1分钟内限制的请求次数(<=0时表示不限制),默认为0
limitMessage 被限制时的错误消息
lockTime 超出请求限制次数时锁定的时长(ms),默认10分钟

示例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

示例3:指定接口时限调用次数和消息

 




@PreventRepeat(limit = 10, limitMessage = "请求过于频繁,请休息10分钟以后再试")
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

示例4:指定自定义防重复规则实现类

 




@PreventRepeat(value = MyPreventRepeatHandler.class ,interval = 1000, message = "请求过于频繁")
public ApiResponse create(...) {
    return ApiResponse.success(...);
}
1
2
3
4

# 自定义防重复提交规则

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

@Component
public class MyPreventRepeatHandler extends PreventRepeatAdapter {

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

# 操作日志

Eva的操作日志分为两种模式——智能模式和手动模式。您可以在构建项目时选择你喜欢的模式,当然我们推荐智能模式,因为它的确非常方便。无论是手动模式还是智能模式,Eva都通过@Trace注解来描述日志内容。

# @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时表示日志中不记录请求结果

# 智能模式

智能模式下,默认会为所有的非查询接口添加操作日志。并且会根据接口路径来自动识别操作类型。在一个管理系统中通常所有的数据流转操作都需要记录日志,所以我们推荐您使用智能模式来实现操作日志的功能。为了更好的管理不记录操作日志的情况,Eva提供了注解排除方式和路径排除方式。注解排除方式即@Trace(exclude = true),路径排除方式则通过配置文件处理,如下:

application.yml

trace:
  # 开启智能跟踪模式
  smart: true
  # 排除跟踪的URL正则
  exclude-patterns: .*/list$, .*/tree$, .*/page$
1
2
3
4
5

约定

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

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

# 手动模式

手动模式下,接口必须添加@Trace注解才会使接口记录日志。

不要随意切换

由于智能模式和手动模式在使用时刚好是反向作用,所以建议您在项目初期就定好使用哪种模式。在项目后期不要随意切换,防止操作日志的丢失。

# 线程池

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

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

# 定时任务

# 功能说明

定时任务用来在指定时间点执行某些代码来完成业务逻辑。例如每天18点扫描今日未打卡的员工,并给这些员工发送邮件提醒。再例如在租房项目中,每个月1号为租客生成上个月的水电账单等。定时任务在这些情况下就不可或缺了。

# 任务的种类

在Eva中,任务分为普通任务分片任务。无论是普通任务还是分片任务,都是基于CRON表达式,预先安排好一系列时间节点,然后在这些时间节点上定时执行的。

  • 普通任务: 传统的quartz任务,用于处理数据量较小的业务
  • 分片任务: 一旦数据量较大或处理时间过长的业务,那么就需要将数据进行划分。例如在需要在下午2点开始处理100万条用户记录,需要在10分钟内处理完毕,显然1台服务一个线程去运行是远远不够的。此时如果将100万条记录划分成10个分片任务,10台机器分别执行一个分片,那么就可以做到横向扩展机器来提高任务的处理速度,这就是分片任务的作用。

# 认识JobParam

无论是普通任务还是分片任务,都会接受JobParam对象作为参数。

参数 说明
id 任务ID或分片ID
businessTime 业务时间,自动执行时为定时任务调度时间,手动执行时为界面选择的时间
triggerType 触发类型
snippetIndex 分片索引
runtimeData 运行时数据,用于获取当前分片需要处理的数据

# 实现定时任务

在Eva中实现一个定时任务也是非常简单的。如下

1. 编写任务类

@Component("myJob")
public class MyJob extends BaseJob {

    @Override
    public JobContext execute(JobParam param) {
        // 获取业务时间
        Date businessTime = param.getBusinessTime() 
        // 根据业务时间进行业务处理(略)
        // 设置执行上下文内容
        JobContext jobContext = new JobContext();
        jobContext.setHandleSuccessSize(0);
        jobContext.setHandleTotalSize(0);
        jobContext.setContext("MyJob Context");
        return jobContext;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

2. 添加定时任务记录

系统/定时任务/任务管理中新建一条记录。参数如下

参数 说明
任务名称 测试任务
任务描述 测试任务
任务处理器类 myJob 取自MyJob类别名
CRON表达式 CRON表达式,如0/10 * * * * ?
是否启用数据分片 禁用 数据分片可将一个任务需处理的数据分成多个子任务来处理,以提高任务的业务处理速度。
是否启用并发执行 启用或禁用均可 同一任务在同一机器是否允许并发执行,关闭后将使用Quartz提供的@DisallowConcurrentExecution注解实现
是否启用异步执行 禁用 启用后任务将通过创建分片的方式异步执行,提高服务器资源的利用率
是否启用日志 启用 启用日志后,每次任务及其分片任务的执行都会被记录进任务日志中
服务器白名单 无需填写 只有在白名单内的服务器才可执行该任务,不填写时表示所有服务均可执行

3. 重启服务

新增任务后需要重启服务后生效。

# 实现分片任务

1. 编写分片任务类

@Component("myJob")
public class MyJob extends BaseJob {

    @Override
    public JobContext execute(JobParam param) {
        // 获取分片数据
        List<MyModel> data = param.getRuntimeData(MyModel.class);
        // 对分片数据进行业务处理(略)
        // 设置执行上下文内容
        JobContext jobContext = new JobContext();
        jobContext.setHandleSuccessSize(permissions.size());
        jobContext.setHandleTotalSize(permissions.size());
        jobContext.setContext("MyJob Context);
        return jobContext;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

2. 编写分片任务分发类

分发类用于查询出任务所有的业务数据。

@Component("myJobDistributer")
public class MyJobDistributer extends BaseDistributer<MyModel.class> {

    @Autowired
    private MyService myService;

    @Override
    public List<MyModel> getBusinessData(JobParam jobParam) {
        // 获取业务时间
        Date businessTime = jobParam.getBusinessTime();
        // 根据业务时间查询出业务数据并返回
        return myService.find(businessTime);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

3. 添加定时任务记录

系统/定时任务/任务管理中新建一条记录。参数如下

参数 说明
任务名称 测试分片任务
任务描述 测试分片任务
任务处理器类 myJob 取自MyJob类别名
CRON表达式 CRON表达式,如0/10 * * * * ?
是否启用数据分片 启用 数据分片可将一个任务需处理的数据分成多个子任务来处理,以提高任务的业务处理速度。
数据分发处理器类 myJobDistributer 取自MyJobDistributer类别名
是否启用并发执行 启用或禁用均可 同一任务在同一机器是否允许并发执行,关闭后将使用Quartz提供的@DisallowConcurrentExecution注解实现
是否启用异步执行 启用或禁用均可 启用后任务将通过创建分片的方式异步执行,提高服务器资源的利用率
是否启用日志 启用 启用日志后,每次任务及其分片任务的执行都会被记录进任务日志中
服务器白名单 无需填写 只有在白名单内的服务器才可执行该任务,不填写时表示所有服务均可执行

4. 重启服务

新增任务后需要重启服务后生效。

# 关闭任务的自动执行

有时候我们希望定时任务不要自动执行,那么我们可以通过application.yml进行配置,如下








 





# ...此处省略其它配置

spring:
  # ...此处省略其它配置

  quartz:
    # 是否自动启动
    auto-startup: false
    # 调度器名称
    scheduler-name: EvaScheduler

# ...此处省略其它配置
1
2
3
4
5
6
7
8
9
10
11
12

注意

定时任务不自动执行时,分片任务也不会自动执行。

# 安全通讯

# 功能说明

对于安全性要求较高的项目,我们希望请求参数和响应参数都进行加密处理。Eva通过【接口设置】模块可以非常简单和灵活的配置接口是否需要加解密操作。

# 配置接口

系统/接口设置中新建一条记录。假设我们需要加密的接口为/test/a,那么接口配置参数如下

参数
接口名称 测试接口
接口路径正则 /test/a
启用2FA认证 禁用
启用请求加密 启用
权重 0
备注 无需填写

# 密钥配置

application.yml中配置即可,如下






 
 



# ... 此处省略其它配置

# 安全配置
security:
  aes:
    key: 0000111100001111 # 16位
    iv: 1111222211112222 # 16位
    
# ... 此处省略其它配置
1
2
3
4
5
6
7
8
9

# 配置所有接口都需要加密

可以按如下参数添加接口配置记录

参数
接口名称 所有接口
接口路径正则 .*
启用2FA认证 禁用
启用请求加密 启用
权重 0
备注 所有接口加密

# 关闭Swagger安全通讯

当接口启用了加密后,那么在swagger ui中调用接口时请求参数也是需要加密的,响应结果也会被加密返回。这样在swagger ui中调试接口是非常不方便的。可以通过如下配置来关闭Swagger的安全通讯。

application.yml











 





# ...此处省略其它配置

# Swagger配置
swagger:
  host:
  title: ${project.name}接口文档
  description: ${project.name}接口文档
  # 启用Swagger,生产环境建议关闭
  enabled: true
  # 启用swagger加密
  enable-encrypt: false
  # 禁用swagger时的重定向地址
  redirect-uri: /
  
# ...此处省略其它配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 2FA二次认证

# 功能说明

某些敏感操作可能需要用户进行二次密码确认,例如退款审核、手动充值等。此时则可以利用2FA二次认证来完成。和接口加密一样,Eva通过【接口设置】模块可以非常简单和灵活的配置接口是否需要进行2FA认证。

# 配置接口

系统/接口设置中新建一条记录。假设我们需要进行2FA认证的接口为/test/a,那么接口配置参数如下

参数
接口名称 测试接口
接口路径正则 /test/a
启用2FA认证 启用
启用请求加密 禁用
权重 0
备注 无需填写

# 实用工具类

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

# Utils.Http

Utils.Http为Http请求工具类,我们在设计时采用了链式调用的方案,这样可以增强代码可读性,并且使用起来也更加方便。在Eva中,发起一次请求的基本逻辑如下:

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

构造Http.HttpWrap对象

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

请求设置

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

发起请求

方法名称 参数 方法返回 说明
get Http.HttpResult 发起GET请求
post Http.HttpResult 发起POST请求
post String params Http.HttpResult 发起POST请求
postJSON Map<String, Object> paramsMap Http.HttpResult 发起POST请求
postJSON JSONObject paramJSONObject Http.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.OSS

Utils.OSS为OSS工具类,用于处理文件的上传与下载,跟Utils.Http一样,Utils.OSS也采用了链式调用设计。它有如下方法:

方法名称 参数 方法返回 说明
setMaxSize int Utils.OSS 设置文件大小限制,单位为M
setFileTypes String Utils.OSS 设置文件类型限制(多个类型使用","隔开,如".jpg,jpeg,.png")
uploadImage MultipartFile OSS.UploadResult 上传图片
uploadImage MultipartFile, String OSS.UploadResult 上传图片,第二个参数为业务路径,如使用"avatar"表示用户头像路径,"goods/cover"表示商品封面图片等
upload MultipartFile OSS.UploadResult 上传文件
upload MultipartFile, String OSS.UploadResult 上传文件,如使用"contract"表示合同文件,"contract/attach"表示合同附件
download String InputStream 下载文件

可以看到,所有上传的方法均返回OSS.UploadResult对象,这样可以方便我们获取文件相关的重要内容。

OSS.UploadResult属性说明

属性名称 类型 说明
originalFilename String 源文件名称
fileKey String 文件的key
accessUri String 访问路径/下载路径,默认情况下图片访问路径为/resource/image,文件下载路径为/resource/attach

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

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

示例2:指定业务路径

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

下载/预览文件的特殊处理 & 业务路径

您可能会遇见在下载/预览文件时需要对文件特殊处理的场景,如为图片或PDF添加水印,下载时添加下载时间等。此时我们应该通过业务路径将其在业务上进行区分(这也是业务路径的主要用途),如上传文件时设置业务路径为contract(标识为合同文件),那么合同文件的下载路径将变为/resource/contract,我们为此实现接口即可。具体实现可参考FileAccessController.java类。

# Utils.Date

Utils.Date为日期工具类,它有如下方法:

方法名称 参数 方法返回 说明
getStart java.util.Date date java.util.Date date 获取日期的开始时间
getEnd java.util.Date date java.util.Date date 获取日期的结束时间

示例

// 返回时分秒毫秒均为0的当前日期对象
Utils.Date.getStart(new Date());

// 返回时分秒毫秒均为0的下一天日期对象
Utils.Date.getEnd(new Date());
1
2
3
4
5

# Utils.Location

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

方法名称 参数 方法返回 说明
getLocation String ip Location.Info 根据IP获取地区信息
getLocationString String ip Location.Info 根据IP获取地区简要

# 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.Monitor

Utils.Monitor为服务监听工具类,它有如下方法:

方法名称 参数 方法返回 说明
current Monitor 获取当前时刻的监听信息

# Utils.MyBatisPlus

Utils.MyBatisPlus为MyBatis Plus工具类,它有如下方法:

方法名称 参数 方法返回 说明
blankToNull T object T 将对象中所有的空字符串转为null

提示

相信很多人会疑惑,blankToNull方法应该跟MyBatis Plus没关系才对。的确,但Eva期望MyBatis plus中的blankToNull是独立的,因为它可能有别于常见的blankToNull方法的处理逻辑。或许,在Eva 2.0版本将调整它的名称。

# Utils.Secure

Utils.Secure为安全处理工具类,它有如下方法:

方法名称 参数 方法返回 说明
encryptPassword String password, String salt String 根据密码和密码盐加密密码

# 响应状态定义及规范

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

@Getter
@AllArgsConstructor
public enum ResponseStatus {
    // 400开头表示参数错误
    BAD_REQUEST(4000, "参数错误"),
    DATA_EMPTY(4001, "找不到目标数据"),
    DATA_EXISTS(4002, "记录已存在"),
    PWD_INCORRECT(4003, "密码不正确"),
    VERIFICATION_CODE_INCORRECT(4004, "验证码不正确或已过期"),
    ACCOUNT_INCORRECT(4005, "账号或密码不正确"),
    // 510开头表示可能导致数据错误的异常
    DUPLICATE_SUBMIT(5100, "请勿重复提交"),
    NOT_ALLOWED(5101, "不允许的操作"),
    ;

    private int code;

    private String message;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

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

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

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

# 自定义全局异常处理

全局异常处理在类GlobalExceptionHandler中实现,通过spring的@RestControllerAdvice注解实现。如果您需要全局捕获某异常,可以参考以下代码来增加处理。

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 业务异常处理
     */
    @ExceptionHandler(BusinessException.class)
    public <T> ApiResponse<T> handleBusinessException (BusinessException e) {
        log.error(e.getMessage(), e);
        return ApiResponse.failed(e.getCode(), e.getMessage());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

无法捕获异常?

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

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

# 业务异常处理

Eva为业务异常封装了异常对象BusinessException,通过四个构造方法可方便的构造业务异常对象。定义如下

@Data
public class BusinessException extends RuntimeException {

    private Integer code;

    public BusinessException(Integer code, String message) {
        super(message);
        this.code = code;
    }

    public BusinessException(Integer code, String message, Throwable e) {
        super(message, e);
        this.code = code;
    }

    public BusinessException(ResponseStatus status) {
        super(status.getMessage());
        this.code = status.getCode();
    }

    public BusinessException(ResponseStatus status, Throwable e) {
        super(status.getMessage(), e);
        this.code = status.getCode();
    }
}
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

# 登录令牌规则 & 安全验证

登录令牌对于一个系统来说十分重要,因为它标识了是谁在访问系统。所以,您可能需要更为复杂的登录令牌生成规则,并希望可以对令牌进行安全验证。Eva在ShiroDefaultTokenManager类中实现了令牌的生成和验证。实现非常简单,如下

@Component
public class ShiroDefaultTokenManager {

    public String build() {
        return UUID.randomUUID().toString();
    }

    public void check(String token) throws UnSafeSessionException {
        if (token == null || token.length() != 36) {
            throw new UnSafeSessionException();
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

您可以直接修改此类来完成令牌的生成和校验。

# 登录保留时长/会话过期时长

可通过application.yml中的cache.session.expire控制,单位为秒。

# 验证码过期时长

可通过application.yml中的cache.captcha.expire控制,单位为秒。

# Swagger的启用和禁用

可通过application.yml中的swagger.enable控制。为true时表示启用,为false时表示禁用。

记得关闭

为了系统安全,通常生产环境不建议开启swagger。

# Redis高可用配置

在Eva中,Redis的高可用采用主从 + 哨兵模式方案。至于是否需要读写分离,可以根据您的项目需要来更改配置。下面详细的说明如何进行配置。

Redis版本:2.8.17

我将采用一台机器启动三台Redis服务的方式来模拟实际情况下的多台机器。因为无论是一台机器还是多台机器,他们的配置是一样的。所以我将Redis原解压文件拷贝了三份,得到了如下目录:

  • master:主服务redis
  • 7001:从服务redis
  • 7002:从服务redis

每个目录下的内容目前都是一致的(都是原redis解压后的文件)。现在让我们进行主从服务的配置。

# 配置主服务master

文件:master/redis.conf

port 7000 # 主服务端口
requirepass <master-password> # 主服务密码(可为空,安全起见,在生产环境上必须设置)
1
2

# 配置从服务7001

文件:7001/redis.conf

port 7001 # 端口号
slaveof localhost 7000 # 设置本服务为主服务localhost 7000的从服务
slave-read-only yes # 设置从服务只读(读写分离时需设置此项为yes)
masterauth <master-password> # 主服务密码(如果主服务存在密码的话需要设置此项)
1
2
3
4

# 配置从服务7002

文件:7002/redis.conf

port 7002 # 端口号
slaveof localhost 7000 # 设置本服务为主服务localhost 7000的从服务
slave-read-only yes # 设置从服务只读(读写分离时需设置此项为yes)
masterauth <master-password> # 主服务密码(如果主服务存在密码的话需要设置此项)
1
2
3
4

这样,主从服务就配置完成了。下面我们还需要配置哨兵,以实现在主服务宕机时自动选举一个从服务为主服务,避免在读写分离时因主服务宕机而无法继续写入缓存。通常情况下,我们应该为每个服务节点配置一个哨兵。

# 配置主服务master哨兵

文件:master/sentinel.conf

port 27000
sentinel monitor mymaster 127.0.0.1 7000 2
1
2

# 配置从服务7001哨兵

文件:7001/sentinel.conf

port 27001
sentinel monitor mymaster 127.0.0.1 7000 2
1
2

# 配置主服务7002哨兵

文件:7002/sentinel.conf

port 27002
sentinel monitor mymaster 127.0.0.1 7000 2
1
2

其中127.0.0.1 7000表示主服务,最后的数字2为超过多少个哨兵认为主服务宕机时可以决定主服务宕机的常数。在我们的配置中,表示超过2个哨兵认为主服务宕机了就可以确定主服务确实宕机了。通常情况下这个数字不宜太高,当所需的服务不多时,此配置应该要大于服务总数的1/2,但如果所需的服务足够多,则至少需要大于服务总数的1/3

到这里,服务和哨兵都配置完成了。我们需要相继启动服务和哨兵,如下

启动所有redis服务

cd master
./src/redis-server redis.conf

cd 7001
./src/redis-server redis.conf

cd 7002
./src/redis-server redis.conf
1
2
3
4
5
6
7
8

启动所有哨兵

cd master
./src/redis-sentinel sentinel.conf

cd 7001
./src/redis-sentinel sentinel.conf

cd 7002
./src/redis-sentinel sentinel.conf
1
2
3
4
5
6
7
8

现在,你可以对主从同步、自动选举进行测试了,注意留意redis服务和哨兵的日志,相信你很快就会明白。