|
|
|
|
# 执法码数据权限过滤使用说明
|
|
|
|
|
|
|
|
|
|
## 1. 虚拟单位树(efcode_virtual_unit)数据模型
|
|
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
数据权限过滤的核心依赖是 **虚拟单位树**(表 `efcode_virtual_unit`),它构建了一棵与实际部门关联的逻辑组织树,用于控制数据可见范围。
|
|
|
|
|
|
|
|
|
|
### 1.1 表结构(EfcodeVirtualUnit / EfcodeVirtualUnitVo)
|
|
|
|
|
|
|
|
|
|
| 字段 | 类型 | 说明 |
|
|
|
|
|
|------|------|------|
|
|
|
|
|
| `vir_id` | String | 虚拟单位ID(主键,UUID) |
|
|
|
|
|
| `vir_is_virtual` | String | 是否是虚拟节点("是"/"否") |
|
|
|
|
|
| `vir_parent_id` | String | 父节点ID |
|
|
|
|
|
| `vir_name` | String | 节点名称 |
|
|
|
|
|
| `vir_zone` | Long | 节点类型/层级 |
|
|
|
|
|
| `vir_ancestors` | String | 祖级ID列表(逗号分隔,包含自身) |
|
|
|
|
|
| `vir_display_path` | String | 节点的单位路径(包含自身) |
|
|
|
|
|
| `vir_include_statistics` | String | 是否为统计节点("是"/"否") |
|
|
|
|
|
| `vir_include_search` | String | 是否为查询节点("是"/"否") |
|
|
|
|
|
| `vir_show_at_business` | String | 是否在业务中显示("是"/"否") |
|
|
|
|
|
| `vir_domain` | String | 域 |
|
|
|
|
|
| `vir_status` | String | 状态 |
|
|
|
|
|
| `vir_remark` | String | 备注 |
|
|
|
|
|
| `sys_dept_id` | String | 关联的系统部门ID(sys_user.dept_id) |
|
|
|
|
|
| `srz_organization_id` | String | 市认证的组织ID(srz_dept.organizationId) |
|
|
|
|
|
| `order_num` | Long | 显示顺序 |
|
|
|
|
|
| `cust_order_num` | Long | 自定义显示顺序 |
|
|
|
|
|
|
|
|
|
|
### 1.2 关键字段说明
|
|
|
|
|
|
|
|
|
|
- **`vir_ancestors`**:存储从根到当前节点的完整路径(逗号分隔的 vir_id 列表),用于 `FIND_IN_SET` 子查询实现快速子孙查找
|
|
|
|
|
- **`sys_dept_id`**:将虚拟单位节点与系统用户的 `dept_id` 关联,是数据过滤的核心匹配字段
|
|
|
|
|
- **`vir_include_statistics`**:标记该节点是否参与统计类查询的过滤
|
|
|
|
|
- **`vir_include_search`**:标记该节点是否参与搜索类查询的过滤
|
|
|
|
|
- **`vir_show_at_business`**:标记该节点是否在业务端可见,用于 `getUserBureauDeptId` 方法确定用户所属执法单位
|
|
|
|
|
- **`vir_is_virtual`**:虚拟节点(如"深圳各区")仅用于组织结构,不关联真实部门
|
|
|
|
|
|
|
|
|
|
### 1.3 树结构示例
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
深圳市(根)
|
|
|
|
|
├── ShenZhen_Areas(区) ← 虚拟节点
|
|
|
|
|
│ ├── Area_FuTianQu(福田区)
|
|
|
|
|
│ │ ├── 福田区XX执法队 ← sys_dept_id 关联真实部门
|
|
|
|
|
│ │ └── ...
|
|
|
|
|
│ ├── Area_NanShanQu(南山区)
|
|
|
|
|
│ └── ...
|
|
|
|
|
└── ShenZhen_ShiZhi(市直) ← 虚拟节点
|
|
|
|
|
├── 市XX局 ← sys_dept_id 关联真实部门
|
|
|
|
|
└── ...
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 2. 功能概述
|
|
|
|
|
|
|
|
|
|
执法码数据权限过滤(`@EfcodeDataFilter`)是一套基于 **AOP 切面 + MyBatis-Plus InnerInterceptor** 的数据行级过滤方案。它根据用户角色和所在部门,在 SQL 查询的 WHERE 子句中自动追加 `bureau_dept_id` 过滤条件,实现不同用户看到不同范围的数据。
|
|
|
|
|
|
|
|
|
|
### 2.1 过滤规则
|
|
|
|
|
|
|
|
|
|
| 用户角色 | 行为 | 生成的SQL条件 |
|
|
|
|
|
|----------|------|---------------|
|
|
|
|
|
| 超级管理员 | 全部放行 | 不追加任何条件 |
|
|
|
|
|
| ViewAllDataRole | 全部放行 | 不追加任何条件 |
|
|
|
|
|
| ViewFilterDataRole | 按虚拟单位树过滤 | `bureau_dept_id IN (子查询) OR bureau_dept_id = '用户部门ID'` |
|
|
|
|
|
| 其他用户 | 禁止访问 | `1 = 0` |
|
|
|
|
|
|
|
|
|
|
### 2.2 核心组件清单
|
|
|
|
|
|
|
|
|
|
| 文件 | 职责 |
|
|
|
|
|
|------|------|
|
|
|
|
|
| `EfcodeDataFilter.java` | 注解定义 |
|
|
|
|
|
| `EfcodeDataFilterAdvice.java` | AOP 方法拦截器(设置/清除 ThreadLocal) |
|
|
|
|
|
| `EfcodeDataFilterPointcut.java` | 切入点匹配器 |
|
|
|
|
|
| `EfcodeDataFilterPointcutAdvisor.java` | Advisor 注册器 |
|
|
|
|
|
| `EfcodeDataFilterHelper.java` | ThreadLocal 管理器 |
|
|
|
|
|
| `EfcodeDataFilterInterceptor.java` | MyBatis InnerInterceptor(修改SQL) |
|
|
|
|
|
| `EfcodeDataFilterConfig.java` | Spring 配置类(注册拦截器+切面) |
|
|
|
|
|
| `DataFilterUtil.java` | 过滤 SQL 构建工具类 |
|
|
|
|
|
|
|
|
|
|
所有组件位于:`zdxt-modules/zdxt-enforcement-code/src/main/java/com/zdxt/enforcementcode/common/annotation/`
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 3. 架构设计
|
|
|
|
|
|
|
|
|
|
### 3.1 整体流程
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
用户请求
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌──────────────────┐
|
|
|
|
|
│ Service 方法调用 │ ← @EfcodeDataFilter(可选,覆盖整个方法内的所有Mapper调用)
|
|
|
|
|
└──────────────────┘
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌──────────────────┐
|
|
|
|
|
│ AOP 切面拦截 │ EfcodeDataFilterAdvice → setFilter(注解) 到 ThreadLocal
|
|
|
|
|
└──────────────────┘
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌──────────────────┐
|
|
|
|
|
│ MyBatis 执行SQL │
|
|
|
|
|
└──────────────────┘
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌────────────────────────────────────────────────────────────────┐
|
|
|
|
|
│ MyBatis-Plus 拦截器链(按顺序执行 beforeQuery): │
|
|
|
|
|
│ │
|
|
|
|
|
│ 1. TenantLineInnerInterceptor (多租户) │
|
|
|
|
|
│ 2. PlusDataPermissionInterceptor (通用数据权限) │
|
|
|
|
|
│ 3. EfcodeDataFilterInterceptor (执法码数据过滤) ← 重点 │
|
|
|
|
|
│ 4. PaginationInnerInterceptor (分页:COUNT + LIMIT) │
|
|
|
|
|
│ 5. OptimisticLockerInnerInterceptor(乐观锁) │
|
|
|
|
|
└────────────────────────────────────────────────────────────────┘
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌──────────────────┐
|
|
|
|
|
│ AOP finally 清除 │ EfcodeDataFilterAdvice → removeFilter()
|
|
|
|
|
└──────────────────┘
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3.2 拦截器链顺序(关键)
|
|
|
|
|
|
|
|
|
|
`EfcodeDataFilterInterceptor` **必须在** `PaginationInnerInterceptor` **之前**。
|
|
|
|
|
|
|
|
|
|
原因:`PaginationInnerInterceptor` 在 `beforeQuery` 中会内部执行 COUNT 查询。如果数据过滤在分页之后,COUNT 查询不会被过滤,导致 `total` 不正确(如全表10000条但过滤后仅3000条)。
|
|
|
|
|
|
|
|
|
|
配置由 `EfcodeDataFilterConfig.configureEfcodeDataFilter()` 自动完成。
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 4. 使用方式
|
|
|
|
|
|
|
|
|
|
### 4.1 方式一:@EfcodeDataFilter 注解(推荐)
|
|
|
|
|
|
|
|
|
|
#### 4.1.1 标注在 Service 方法上
|
|
|
|
|
|
|
|
|
|
适用于 Service 方法内调用多个 Mapper 方法的场景(如手动分页需要先 COUNT 再查数据):
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
@EfcodeDataFilter
|
|
|
|
|
@Override
|
|
|
|
|
public TableDataInfo<TestEnfDemoVo> queryPageList(TestEnfDemoBo bo, PageQuery pageQuery) {
|
|
|
|
|
LambdaQueryWrapper<TestEnfDemo> lqw = buildQueryWrapper(bo);
|
|
|
|
|
Page<TestEnfDemoVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
|
|
|
|
|
return TableDataInfo.build(result);
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 4.1.2 标注在 Mapper 方法上
|
|
|
|
|
|
|
|
|
|
适用于自定义 XML SQL 的 Mapper 方法:
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
@EfcodeDataFilter(column = "bureau_dept_id")
|
|
|
|
|
Page<TestEnfDemoVo> customPageList(@Param("page") Page<TestEnfDemo> page,
|
|
|
|
|
@Param("ew") Wrapper<TestEnfDemo> wrapper);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 4.1.3 注解属性说明
|
|
|
|
|
|
|
|
|
|
| 属性 | 默认值 | 说明 |
|
|
|
|
|
|------|--------|------|
|
|
|
|
|
| `column` | `"bureau_dept_id"` | 业务表中用于匹配的列名 |
|
|
|
|
|
| `tableAlias` | `""` | 表别名(SQL 中有别名时使用,如 `"t"` → `t.bureau_dept_id`) |
|
|
|
|
|
| `includeSearch` | `true` | 是否包含虚拟单位树中标记为"查询节点"的数据 |
|
|
|
|
|
| `includeStatistics` | `true` | 是否包含虚拟单位树中标记为"统计节点"的数据 |
|
|
|
|
|
|
|
|
|
|
### 4.2 方式二:直接调用 DataFilterUtil(不依赖AOP)
|
|
|
|
|
|
|
|
|
|
适用于不想使用注解的场景,手动在代码中拼接过滤条件:
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
@Override
|
|
|
|
|
public TableDataInfo<TestEnfDemoVo> directFilterPageList(TestEnfDemoBo bo, PageQuery pageQuery) {
|
|
|
|
|
QueryWrapper<TestEnfDemo> qw = Wrappers.query();
|
|
|
|
|
qw.orderByAsc("id");
|
|
|
|
|
|
|
|
|
|
// 直接调用工具类生成过滤SQL
|
|
|
|
|
String filterSql = DataFilterUtil.buildFilterSql(true, true);
|
|
|
|
|
if (filterSql != null) {
|
|
|
|
|
qw.apply(filterSql); // null表示全放行,不追加条件
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Page<TestEnfDemoVo> result = baseMapper.selectVoPage(pageQuery.build(), qw);
|
|
|
|
|
return TableDataInfo.build(result);
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**注意**:`DataFilterUtil.buildFilterSql()` 返回值:
|
|
|
|
|
- `null` → 全部放行(超管/ViewAllDataRole),不要追加条件
|
|
|
|
|
- `"1 = 0"` → 无权访问
|
|
|
|
|
- 其他字符串 → 过滤条件SQL
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 5. DataFilterUtil 方法列表
|
|
|
|
|
|
|
|
|
|
`DataFilterUtil` 是数据过滤的核心工具类,位于 `com.zdxt.enforcementcode.common.util` 包下,提供权限判断、SQL 构建、子孙节点查询、用户执法单位查询等能力。
|
|
|
|
|
|
|
|
|
|
### 5.1 权限判断方法
|
|
|
|
|
|
|
|
|
|
| 方法签名 | 返回值 | 说明 |
|
|
|
|
|
|----------|--------|------|
|
|
|
|
|
| `isFullAccess()` | `boolean` | 判断当前用户是否全部放行(超管或 ViewAllDataRole) |
|
|
|
|
|
| `needFilter()` | `boolean` | 判断当前用户是否需要数据过滤(拥有 ViewFilterDataRole) |
|
|
|
|
|
| `hasNoAccess()` | `boolean` | 判断当前用户是否无权访问(既非放行也非过滤) |
|
|
|
|
|
| `getUserAccessibleDeptIds(includeStatistics, includeSearch)` | `List<String>` | 获取当前用户可访问的 sys_dept_id 列表(null=全放行,空=无权) |
|
|
|
|
|
| `getFilteredDeptIdsForCurrentUser(includeStatistics, includeSearch)` | `List<String>` | 获取 ViewFilterDataRole 用户在虚拟单位树中对应节点及子孙的 sys_dept_id |
|
|
|
|
|
|
|
|
|
|
### 5.2 子孙节点查询方法
|
|
|
|
|
|
|
|
|
|
| 方法签名 | 返回值 | 说明 |
|
|
|
|
|
|----------|--------|------|
|
|
|
|
|
| `getDescendantDeptIds(virId, includeSearch, includeStatistics, includeBusiness)` | `List<String>` | 获取指定 virId 下所有子孙节点的 sys_dept_id(已去重) |
|
|
|
|
|
| `getDescendantVirIds(virId, includeSearch, includeStatistics, includeBusiness)` | `List<String>` | 获取指定 virId 下所有子孙节点的 vir_id(已去重) |
|
|
|
|
|
|
|
|
|
|
参数说明:
|
|
|
|
|
- `virId`:起始节点的虚拟单位ID(如 `DataFilterUtil.SHENZHEN_AREAS`)
|
|
|
|
|
- `includeSearch`:是否包含 `vir_include_search='是'` 的节点
|
|
|
|
|
- `includeStatistics`:是否包含 `vir_include_statistics='是'` 的节点
|
|
|
|
|
- `includeBusiness`:是否包含 `vir_show_at_business='是'` 的节点
|
|
|
|
|
|
|
|
|
|
### 5.3 SQL 过滤条件构建方法
|
|
|
|
|
|
|
|
|
|
| 方法签名 | 返回值 | 说明 |
|
|
|
|
|
|----------|--------|------|
|
|
|
|
|
| `buildFilterSql(tableAlias, column, includeSearch, includeStatistics)` | `String` | 完整版:指定表别名 + 列名 |
|
|
|
|
|
| `buildFilterSql(tableAlias, includeSearch, includeStatistics)` | `String` | 默认列名 `bureau_dept_id` |
|
|
|
|
|
| `buildFilterSql(includeSearch, includeStatistics)` | `String` | 最简版:无别名 + 默认列名 |
|
|
|
|
|
|
|
|
|
|
返回值含义:
|
|
|
|
|
- `null` → 全部放行(超管/ViewAllDataRole),调用方**不要**追加条件
|
|
|
|
|
- `"1 = 0"` → 无权访问
|
|
|
|
|
- 其他字符串 → 过滤条件SQL片段,通过 `QueryWrapper.apply(filterSql)` 追加
|
|
|
|
|
|
|
|
|
|
### 5.4 用户执法单位查询方法
|
|
|
|
|
|
|
|
|
|
| 方法签名 | 返回值 | 说明 |
|
|
|
|
|
|----------|--------|------|
|
|
|
|
|
| `getUserDeptId(userId)` | `String` | 获取用户的 dept_id(来源 sys_user 表) |
|
|
|
|
|
| `getUserOrganId(userId)` | `String` | 查询用户所属执法单位的 organizationId(来源 srz_dept 表,向上递归) |
|
|
|
|
|
| `getUserBureauDeptId(userId)` | `String` | 查询用户所在执法单位的 sys_dept_id(沿 sys_dept 父链向上递归查找 efcode_virtual_unit) |
|
|
|
|
|
|
|
|
|
|
#### 5.4.1 getUserDeptId 获取用户所在的部门ID
|
|
|
|
|
|
|
|
|
|
通过 `UserService.selectListByIds()` 查询用户的 `dept_id` 并返回字符串。
|
|
|
|
|
|
|
|
|
|
#### 5.4.2 getUserOrganId 获取用户所在的单位ID,有可能和部门ID相同
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
userId → getUserDeptId → deptId
|
|
|
|
|
deptId → srz_dept.organizationId 匹配
|
|
|
|
|
├─ unitTypeCode=99 → 抛出异常(不合法的机构类型)
|
|
|
|
|
├─ unitTypeCode=10 → 返回 organizationId(已是执法单位)
|
|
|
|
|
└─ unitTypeCode=11 → 递归查找 parentid,直到 unitTypeCode=10
|
|
|
|
|
(最大递归深度:4层)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 5.4.3 getUserBureauDeptId 获取用户所在的执法单位ID有可能和部门ID相同,单位ID相同
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
userId → getUserDeptId → deptId
|
|
|
|
|
循环(自身 + 最多4层父节点,共5层):
|
|
|
|
|
currentDeptId → efcode_virtual_unit.sys_dept_id 匹配
|
|
|
|
|
├─ 存在匹配记录
|
|
|
|
|
│ ├─ 筛选 vir_show_at_business='是' 的记录
|
|
|
|
|
│ │ ├─ 恰好1条 → 返回该记录的 sys_dept_id
|
|
|
|
|
│ │ └─ 多条 → 抛出异常
|
|
|
|
|
│ └─ 无 vir_show_at_business='是' 的记录
|
|
|
|
|
│ ├─ 总匹配只有1条 → 返回该记录的 sys_dept_id
|
|
|
|
|
│ └─ 多条 → 抛出异常
|
|
|
|
|
└─ 无匹配记录
|
|
|
|
|
└─ 通过 sys_dept 查找 parentId → currentDeptId = parentId → 继续下一层
|
|
|
|
|
到达根节点或超过4层仍未找到 → 返回 null
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 5.5 常量定义
|
|
|
|
|
|
|
|
|
|
| 常量 | 值 | 说明 |
|
|
|
|
|
|------|----|------|
|
|
|
|
|
| `ROLE_VIEW_ALL_DATA` | `"ViewAllDataRole"` | 全部放行角色 |
|
|
|
|
|
| `ROLE_VIEW_FILTER_DATA` | `"ViewFilterDataRole"` | 需过滤角色 |
|
|
|
|
|
| `SHENZHEN_AREAS` | `"ShenZhen_Areas"` | 区(二级节点) |
|
|
|
|
|
| `ShenZhen_ShiZhi` | `"ShenZhen_ShiZhi"` | 深圳市直 |
|
|
|
|
|
| `AREA_GUANG_MING_QU` | `"Area_GuangMingQu"` | 光明区 |
|
|
|
|
|
| `AREA_NAN_SHAN_QU` | `"Area_NanShanQu"` | 南山区 |
|
|
|
|
|
| `AREA_PING_SHAN_QU` | `"Area_PingsHanQu"` | 坪山区 |
|
|
|
|
|
| `AREA_DA_PENG_XIN_QU` | `"Area_DapengXinQu"` | 大鹏新区 |
|
|
|
|
|
| `AREA_BAO_AN_QU` | `"Area_BaoAnQu"` | 宝安区 |
|
|
|
|
|
| `AREA_SHEN_SHAN_TE_BIE_HE_ZUO_QU` | `"Area_ShenShanTeBieHeZuoQu"` | 深汕特别合作区 |
|
|
|
|
|
| `AREA_YAN_TIAN_QU` | `"Area_YanTianQu"` | 盐田区 |
|
|
|
|
|
| `AREA_FU_TIAN_QU` | `"Area_FuTianQu"` | 福田区 |
|
|
|
|
|
| `AREA_LUO_HU_QU` | `"Area_LuoHuQu"` | 罗湖区 |
|
|
|
|
|
| `AREA_LONG_HUA_QU` | `"Area_LongHuaQu"` | 龙华区 |
|
|
|
|
|
| `AREA_LONG_GANG_QU` | `"Area_LongGangQu"` | 龙岗区 |
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 6. ITestEnfDemoService 接口说明
|
|
|
|
|
|
|
|
|
|
`ITestEnfDemoService` 是数据过滤功能的完整演示接口,展示了多种分页和过滤方式:
|
|
|
|
|
|
|
|
|
|
### 6.1 方法列表
|
|
|
|
|
|
|
|
|
|
| 方法 | 数据过滤方式 | 分页方式 | 说明 |
|
|
|
|
|
|------|-------------|---------|------|
|
|
|
|
|
| `queryPageList` | Service 注解 | MyBatis-Plus自动分页 | 最常用方式,注解覆盖 `selectVoPage` |
|
|
|
|
|
| `queryList` | Service 注解 | 无分页 | 列表查询 |
|
|
|
|
|
| `customPageList` | Mapper 注解 | MyBatis-Plus自动分页 | 自定义XML SQL + 自动分页拦截器 |
|
|
|
|
|
| `manualPageList` | Service 注解 | 全手动分页 | 绕过分页拦截器,自己 COUNT + LIMIT |
|
|
|
|
|
| `directFilterPageList` | DataFilterUtil 直接调用 | MyBatis-Plus自动分页 | 不依赖AOP注解 |
|
|
|
|
|
| `directFilterList` | DataFilterUtil 直接调用 | 无分页 | 不依赖AOP注解 |
|
|
|
|
|
|
|
|
|
|
### 6.2 三种分页方式对比
|
|
|
|
|
|
|
|
|
|
#### 6.2.1 方式A:MyBatis-Plus 自动分页(queryPageList / customPageList)
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
Page<TestEnfDemoVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- 传入 `Page` 对象 → `PaginationInnerInterceptor` 自动拦截
|
|
|
|
|
- 自动执行 COUNT 查询获取 total
|
|
|
|
|
- 自动添加 LIMIT 子句
|
|
|
|
|
|
|
|
|
|
#### 6.2.2 方式B:自定义 XML SQL + 自动分页(customPageList)
|
|
|
|
|
|
|
|
|
|
Mapper XML:
|
|
|
|
|
```xml
|
|
|
|
|
<select id="customPageList" resultType="...">
|
|
|
|
|
SELECT ... FROM test_enf_demo
|
|
|
|
|
WHERE del_flag = 0
|
|
|
|
|
<if test="ew != null and ew.sqlSegment != null and ew.sqlSegment != ''">
|
|
|
|
|
<if test="ew.nonEmptyOfNormal"> AND </if>
|
|
|
|
|
${ew.sqlSegment}
|
|
|
|
|
</if>
|
|
|
|
|
</select>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**注意**:自定义 XML SQL 必须手动添加 `del_flag = 0`,因为 `@TableLogic` 逻辑删除不会对自定义 SQL 生效。
|
|
|
|
|
|
|
|
|
|
#### 6.2.3 方式C:全手动分页(manualPageList)
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
Long total = baseMapper.manualCount(lqw); // 手动 COUNT
|
|
|
|
|
List<TestEnfDemoVo> list = baseMapper.manualSelectList(lqw, offset, pageSize); // 手动 LIMIT
|
|
|
|
|
return new TableDataInfo<>(list, total); // 手动组装
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- 完全绕过 `PaginationInnerInterceptor`
|
|
|
|
|
- 适合需要精确控制分页行为的场景
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 7. 自定义 XML SQL 编写规范
|
|
|
|
|
|
|
|
|
|
当使用 Mapper XML 编写自定义 SQL 时,必须注意以下要点:
|
|
|
|
|
|
|
|
|
|
### 7.1 必须手动添加 del_flag 条件
|
|
|
|
|
|
|
|
|
|
```xml
|
|
|
|
|
WHERE del_flag = 0
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
因为 `@TableLogic` 只对 MyBatis-Plus 内置的 CRUD 方法生效。
|
|
|
|
|
|
|
|
|
|
### 7.2 正确处理 Wrapper 条件 + ORDER BY
|
|
|
|
|
|
|
|
|
|
**推荐模式**(兼容纯 ORDER BY 无 WHERE 条件的情况):
|
|
|
|
|
|
|
|
|
|
```xml
|
|
|
|
|
WHERE del_flag = 0
|
|
|
|
|
<if test="ew != null and ew.sqlSegment != null and ew.sqlSegment != ''">
|
|
|
|
|
<if test="ew.nonEmptyOfNormal"> AND </if>
|
|
|
|
|
${ew.sqlSegment}
|
|
|
|
|
</if>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**工作原理**:
|
|
|
|
|
- 当有 WHERE 条件 + ORDER BY 时:`nonEmptyOfNormal=true` → 输出 `AND condition ORDER BY ...`
|
|
|
|
|
- 当只有 ORDER BY 时:`nonEmptyOfNormal=false` → 直接输出 `ORDER BY ...`(不加 AND)
|
|
|
|
|
- 当都没有时:外层 if 不满足 → 不输出
|
|
|
|
|
|
|
|
|
|
**错误示例**(会丢失 ORDER BY):
|
|
|
|
|
```xml
|
|
|
|
|
<!-- ❌ 错误:当只有 ORDER BY 时条件不满足,ORDER BY 被丢失 -->
|
|
|
|
|
<if test="ew != null and ew.nonEmptyOfNormal">
|
|
|
|
|
AND ${ew.sqlSegment}
|
|
|
|
|
</if>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 8. 单元测试说明
|
|
|
|
|
|
|
|
|
|
### 8.1 TestEnfDemoServiceTest(数据过滤功能测试)
|
|
|
|
|
|
|
|
|
|
#### 8.1.1 测试文件
|
|
|
|
|
|
|
|
|
|
`zdxt-web-server/zdxt-admin-web-server/src/test/java/com/zdxt/service/test/TestEnfDemoServiceTest.java`
|
|
|
|
|
|
|
|
|
|
#### 8.1.2 测试架构
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 允许非静态 @BeforeAll
|
|
|
|
|
@SpringBootTest(classes = ZdxtAdminApplication.class)
|
|
|
|
|
@ActiveProfiles({"dev", "dev-cust-zrb"})
|
|
|
|
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
|
|
|
|
public class TestEnfDemoServiceTest {
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 8.1.3 数据初始化策略
|
|
|
|
|
|
|
|
|
|
`@BeforeAll` 中:
|
|
|
|
|
1. **物理删除** `test_enf_demo` 表全部数据(通过 `JdbcTemplate` 绕过 `@TableLogic`)
|
|
|
|
|
2. **批量插入** 10000 条已知分布的测试数据:
|
|
|
|
|
- 前 3000 条:`bureau_dept_id` = 过滤用户部门ID(可见)
|
|
|
|
|
- 后 7000 条:`bureau_dept_id` = 虚假部门ID(不可见)
|
|
|
|
|
3. **动态查询** 过滤用户实际可见数量(考虑虚拟单位树子孙节点匹配)
|
|
|
|
|
|
|
|
|
|
#### 8.1.4 测试用户角色
|
|
|
|
|
|
|
|
|
|
| 用户 | userId | 角色 | 预期行为 |
|
|
|
|
|
|------|--------|------|---------|
|
|
|
|
|
| 超级管理员 | 1 | admin | 看到全部 10000 条 |
|
|
|
|
|
| ViewAllDataRole | 2 | ViewAllDataRole | 看到全部 10000 条 |
|
|
|
|
|
| ViewFilterDataRole | 3 | ViewFilterDataRole | 看到约 3000 条(按虚拟单位树过滤) |
|
|
|
|
|
| 无权用户 | 4 | common | 看到 0 条 |
|
|
|
|
|
|
|
|
|
|
#### 8.1.5 测试覆盖(37个测试方法)
|
|
|
|
|
|
|
|
|
|
| 序号 | 测试范围 | 验证内容 |
|
|
|
|
|
|------|---------|---------|
|
|
|
|
|
| 1-4 | queryPageList | 四种角色的 total 和 records 数量 |
|
|
|
|
|
| 5-7 | queryList | 列表查询的数据过滤 |
|
|
|
|
|
| 8-10 | manualPageList | 手动分页的数据过滤 |
|
|
|
|
|
| 11-13 | customPageList | 自定义XML + Mapper注解的数据过滤 |
|
|
|
|
|
| 14-17 | directFilterPageList | DataFilterUtil直接调用方式 |
|
|
|
|
|
| 18-19 | directFilterList | 直接调用列表查询 |
|
|
|
|
|
| 20-21 | 数据一致性 | 注解方式 vs 直接调用方式结果一致 |
|
|
|
|
|
| 22-32 | 分页功能 | 翻页、total一致性、过滤用户分页 |
|
|
|
|
|
| 33-35 | 综合验证 | 所有方法在相同角色下结果一致 |
|
|
|
|
|
| 36-37 | 尾页/超出页码 | 最后一页记录数、overflow=true 行为 |
|
|
|
|
|
|
|
|
|
|
#### 8.1.6 运行测试
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
# 运行全部测试
|
|
|
|
|
mvn test -pl zdxt-web-server/zdxt-admin-web-server -Dtest="TestEnfDemoServiceTest" -DfailIfNoTests=false
|
|
|
|
|
|
|
|
|
|
# 运行单个测试方法
|
|
|
|
|
mvn test -pl zdxt-web-server/zdxt-admin-web-server -Dtest="TestEnfDemoServiceTest#testQueryPageList_SuperAdmin" -DfailIfNoTests=false
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 8.1.7 SaToken Mock 上下文
|
|
|
|
|
|
|
|
|
|
单元测试中需要 Mock SaToken 的上下文环境:
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
@BeforeEach
|
|
|
|
|
public void setUp() {
|
|
|
|
|
SaTokenContextMockUtil.setMockContext();
|
|
|
|
|
MockHttpServletRequest mockRequest = new MockHttpServletRequest();
|
|
|
|
|
mockRequest.addHeader("User-Agent", "...");
|
|
|
|
|
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(mockRequest));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@AfterEach
|
|
|
|
|
public void tearDown() {
|
|
|
|
|
StpUtil.logout();
|
|
|
|
|
RequestContextHolder.resetRequestAttributes();
|
|
|
|
|
SaTokenContextMockUtil.clearContext();
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 8.2 DataFilterUtilTest(工具类方法测试)
|
|
|
|
|
|
|
|
|
|
#### 8.2.1 测试文件
|
|
|
|
|
|
|
|
|
|
`zdxt-web-server/zdxt-admin-web-server/src/test/java/com/zdxt/service/test/DataFilterUtilTest.java`
|
|
|
|
|
|
|
|
|
|
#### 8.2.2 测试架构
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
@SpringBootTest(classes = ZdxtAdminApplication.class)
|
|
|
|
|
@ActiveProfiles({"dev", "dev-cust-zrb"})
|
|
|
|
|
@AutoConfigureMockMvc
|
|
|
|
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
|
|
|
|
public class DataFilterUtilTest {
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- 每个测试方法前通过 `@BeforeEach` 模拟 SaToken 登录上下文
|
|
|
|
|
- 测试方法通过 `@Order` 注解控制执行顺序
|
|
|
|
|
- 连接真实数据库验证,非 Mock 测试
|
|
|
|
|
|
|
|
|
|
#### 8.2.3 测试覆盖(24个测试方法)
|
|
|
|
|
|
|
|
|
|
| 序号 | 测试方法 | 验证内容 |
|
|
|
|
|
|------|---------|----------|
|
|
|
|
|
| 1 | testConstants | 所有区域常量值正确性 |
|
|
|
|
|
| 2 | testGetDescendantDeptIds_blankVirId | 空/null/空白 virId 返回空列表 |
|
|
|
|
|
| 3 | testGetDescendantDeptIds_shenzhenAreas | 深圳各区子孙部门ID查询正确性 |
|
|
|
|
|
| 4 | testGetDescendantDeptIds_guangMingQu | 光明区子孙部门ID查询 |
|
|
|
|
|
| 5 | testGetDescendantDeptIds_shiZhi | 市直子孙部门ID查询 |
|
|
|
|
|
| 6 | testGetDescendantDeptIds_nonExistentVirId | 不存在的virId返回空列表 |
|
|
|
|
|
| 7 | testGetDescendantDeptIds_filterCombinations | 不同过滤条件组合对比 |
|
|
|
|
|
| 8 | testGetDescendantVirIds_blankVirId | 空/null/空白 virId 返回空列表 |
|
|
|
|
|
| 9 | testGetDescendantVirIds_shenzhenAreas | 深圳各区子孙虚拟单位ID查询 |
|
|
|
|
|
| 10 | testGetDescendantVirIds_shiZhi | 市直子孙虚拟单位ID查询 |
|
|
|
|
|
| 11 | testGetDescendantVirIds_nonExistentVirId | 不存在的virId返回空列表 |
|
|
|
|
|
| 12 | testGetDescendantDeptIds_allAreaConstants | 遍历所有区域常量查询子孙部门 |
|
|
|
|
|
| 13 | testGetDescendantVirIds_filterCombinations | virId过滤条件组合对比 |
|
|
|
|
|
| 14 | testGetUserBureauDeptId_nullUserId | null userId 返回 null |
|
|
|
|
|
| 15 | testGetUserBureauDeptId_nonExistentUser | 不存在的用户返回 null |
|
|
|
|
|
| 16 | testGetUserBureauDeptId_validUser | 有效用户返回执法单位 virId(含异常场景) |
|
|
|
|
|
| 17 | testGetUserBureauDeptId_multipleUsers | 遍历多个用户验证 getUserBureauDeptId |
|
|
|
|
|
| 18 | testGetUserDeptId_nullUserId | null userId 返回 null |
|
|
|
|
|
| 19 | testGetUserDeptId_nonExistentUser | 不存在的用户返回 null |
|
|
|
|
|
| 20 | testGetUserDeptId_validUser | 有效用户返回正确的 deptId |
|
|
|
|
|
| 21 | testGetUserOrganId_nullUserId | null userId 返回 null |
|
|
|
|
|
| 22 | testGetUserOrganId_nonExistentUser | 不存在的用户返回 null |
|
|
|
|
|
| 23 | testGetUserOrganId_validUser | 有效用户返回执法单位 organizationId(含异常场景) |
|
|
|
|
|
| 24 | testGetUserOrganId_multipleUsers | 遍历多个用户验证 getUserOrganId |
|
|
|
|
|
|
|
|
|
|
#### 8.2.4 测试设计要点
|
|
|
|
|
|
|
|
|
|
1. **边界值测试**:null 入参、不存在的记录、空白字符串
|
|
|
|
|
2. **异常场景覆盖**:`getUserBureauDeptId` 多条匹配抛 `IllegalStateException`、`getUserOrganId` 中 unitTypeCode=99 或递归超深度
|
|
|
|
|
3. **批量验证**:遍历多个真实用户ID,验证方法在不同数据条件下的鲁棒性
|
|
|
|
|
4. **结果断言**:返回值不为空白、不为 null(有效用户场景)
|
|
|
|
|
|
|
|
|
|
#### 8.2.5 运行测试
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
# 运行 DataFilterUtilTest 全部测试
|
|
|
|
|
mvn test -pl zdxt-web-server/zdxt-admin-web-server -Dtest="DataFilterUtilTest" -DfailIfNoTests=false
|
|
|
|
|
|
|
|
|
|
# 运行单个测试方法
|
|
|
|
|
mvn test -pl zdxt-web-server/zdxt-admin-web-server -Dtest="DataFilterUtilTest#testGetUserOrganId_validUser" -DfailIfNoTests=false
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 9. 注意事项
|
|
|
|
|
|
|
|
|
|
### 9.1 分页合理化(overflow=true)
|
|
|
|
|
|
|
|
|
|
`MybatisPlusConfig` 中配置了 `paginationInnerInterceptor.setOverflow(true)`:
|
|
|
|
|
- 当 `pageNum > totalPages` 时,**自动回退到第1页**
|
|
|
|
|
- 不会返回空结果,而是返回第1页数据
|
|
|
|
|
|
|
|
|
|
### 9.2 Service + Mapper 双重注解的注意事项
|
|
|
|
|
|
|
|
|
|
当 Service 和 Mapper 方法都标注了 `@EfcodeDataFilter` 时:
|
|
|
|
|
- Mapper 的注解会**覆盖** Service 的注解设置
|
|
|
|
|
- 每个 Mapper 方法执行完毕后会清除 ThreadLocal
|
|
|
|
|
- **重要**:如果 Service 方法中间调用了没有 `@EfcodeDataFilter` 的 Mapper 方法,该调用**不会被过滤**
|
|
|
|
|
|
|
|
|
|
**推荐做法**:
|
|
|
|
|
- 如果 Service 方法内所有 Mapper 调用都需要过滤:每个 Mapper 方法都加注解
|
|
|
|
|
- 或者只在 Service 层加注解,Mapper 方法不加(前提是 Service 内只有一次 Mapper 调用)
|
|
|
|
|
|
|
|
|
|
### 9.3 不同方式的选择建议
|
|
|
|
|
|
|
|
|
|
| 场景 | 推荐方式 |
|
|
|
|
|
|------|---------|
|
|
|
|
|
| 常规 CRUD 查询 | Service 加 `@EfcodeDataFilter` |
|
|
|
|
|
| 自定义 XML SQL | Mapper 方法加 `@EfcodeDataFilter` |
|
|
|
|
|
| 需要精确控制过滤参数 | `DataFilterUtil.buildFilterSql()` 直接调用 |
|
|
|
|
|
| Service 内多次 Mapper 调用 | Service + 每个 Mapper 都加注解 |
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 10. 文件目录结构
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
zdxt-modules/zdxt-enforcement-code/src/main/java/com/zdxt/enforcementcode/
|
|
|
|
|
├── common/
|
|
|
|
|
│ ├── annotation/
|
|
|
|
|
│ │ ├── EfcodeDataFilter.java # 注解定义
|
|
|
|
|
│ │ ├── EfcodeDataFilterAdvice.java # AOP 方法拦截器
|
|
|
|
|
│ │ ├── EfcodeDataFilterPointcut.java # 切入点匹配器
|
|
|
|
|
│ │ ├── EfcodeDataFilterPointcutAdvisor.java # Advisor 注册器
|
|
|
|
|
│ │ ├── EfcodeDataFilterHelper.java # ThreadLocal 管理
|
|
|
|
|
│ │ ├── EfcodeDataFilterInterceptor.java # MyBatis 拦截器
|
|
|
|
|
│ │ └── EfcodeDataFilterConfig.java # Spring 配置
|
|
|
|
|
│ └── util/
|
|
|
|
|
│ └── DataFilterUtil.java # 过滤SQL构建工具
|
|
|
|
|
├── mapper/
|
|
|
|
|
│ └── TestEnfDemoMapper.java # Mapper接口(Mapper注解示例)
|
|
|
|
|
├── service/
|
|
|
|
|
│ ├── ITestEnfDemoService.java # Service接口
|
|
|
|
|
│ └── impl/
|
|
|
|
|
│ └── TestEnfDemoServiceImpl.java # Service实现(Service注解示例)
|
|
|
|
|
└── resources/mapper/enforcementcode/
|
|
|
|
|
└── TestEnfDemoMapper.xml # 自定义SQL(XML编写规范示例)
|
|
|
|
|
|
|
|
|
|
zdxt-web-server/zdxt-admin-web-server/src/test/java/com/zdxt/service/test/
|
|
|
|
|
├── TestEnfDemoServiceTest.java # 数据过滤功能完整测试(37个测试方法)
|
|
|
|
|
└── DataFilterUtilTest.java # DataFilterUtil 工具类测试(24个测试方法)
|
|
|
|
|
```
|