diff --git a/doc/执法单位设置.png b/doc/执法单位设置.png new file mode 100644 index 00000000..cda70f85 Binary files /dev/null and b/doc/执法单位设置.png differ diff --git a/doc/执法码数据权限过滤使用说明.md b/doc/执法码数据权限过滤使用说明.md new file mode 100644 index 00000000..799b2311 --- /dev/null +++ b/doc/执法码数据权限过滤使用说明.md @@ -0,0 +1,601 @@ +# 执法码数据权限过滤使用说明 + +## 1. 虚拟单位树(efcode_virtual_unit)数据模型 + +![执法单位设置.png](%E6%89%A7%E6%B3%95%E5%8D%95%E4%BD%8D%E8%AE%BE%E7%BD%AE.png)![架构图](./images/architecture.png![执法单位设置.png](%E6%89%A7%E6%B3%95%E5%8D%95%E4%BD%8D%E8%AE%BE%E7%BD%AE.png)) + +数据权限过滤的核心依赖是 **虚拟单位树**(表 `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 queryPageList(TestEnfDemoBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); +} +``` + +#### 4.1.2 标注在 Mapper 方法上 + +适用于自定义 XML SQL 的 Mapper 方法: + +```java +@EfcodeDataFilter(column = "bureau_dept_id") +Page customPageList(@Param("page") Page page, + @Param("ew") Wrapper 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 directFilterPageList(TestEnfDemoBo bo, PageQuery pageQuery) { + QueryWrapper qw = Wrappers.query(); + qw.orderByAsc("id"); + + // 直接调用工具类生成过滤SQL + String filterSql = DataFilterUtil.buildFilterSql(true, true); + if (filterSql != null) { + qw.apply(filterSql); // null表示全放行,不追加条件 + } + + Page 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` | 获取当前用户可访问的 sys_dept_id 列表(null=全放行,空=无权) | +| `getFilteredDeptIdsForCurrentUser(includeStatistics, includeSearch)` | `List` | 获取 ViewFilterDataRole 用户在虚拟单位树中对应节点及子孙的 sys_dept_id | + +### 5.2 子孙节点查询方法 + +| 方法签名 | 返回值 | 说明 | +|----------|--------|------| +| `getDescendantDeptIds(virId, includeSearch, includeStatistics, includeBusiness)` | `List` | 获取指定 virId 下所有子孙节点的 sys_dept_id(已去重) | +| `getDescendantVirIds(virId, includeSearch, includeStatistics, includeBusiness)` | `List` | 获取指定 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` | 查询用户所在执法单位的 vir_id(来源 efcode_virtual_unit 表) | + +#### 5.4.1 getUserDeptId + +通过 `UserService.selectListByIds()` 查询用户的 `dept_id` 并返回字符串。 + +#### 5.4.2 getUserOrganId + +``` +userId → getUserDeptId → deptId +deptId → srz_dept.organizationId 匹配 + ├─ unitTypeCode=99 → 抛出异常(不合法的机构类型) + ├─ unitTypeCode=10 → 返回 organizationId(已是执法单位) + └─ unitTypeCode=11 → 递归查找 parentid,直到 unitTypeCode=10 + (最大递归深度:4层) +``` + +#### 5.4.3 getUserBureauDeptId + +``` +userId → getUserDeptId → deptId +deptId → efcode_virtual_unit.sys_dept_id 匹配 + ├─ 筛选 vir_show_at_business='是' 的记录 + │ ├─ 恰好1条 → 返回 vir_id + │ └─ 多条 → 抛出异常 + └─ 无 vir_show_at_business='是' 的记录 + ├─ 总匹配只有1条 → 返回 vir_id + └─ 多条 → 抛出异常 +``` + +### 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 result = baseMapper.selectVoPage(pageQuery.build(), lqw); +``` + +- 传入 `Page` 对象 → `PaginationInnerInterceptor` 自动拦截 +- 自动执行 COUNT 查询获取 total +- 自动添加 LIMIT 子句 + +#### 6.2.2 方式B:自定义 XML SQL + 自动分页(customPageList) + +Mapper XML: +```xml + +``` + +**注意**:自定义 XML SQL 必须手动添加 `del_flag = 0`,因为 `@TableLogic` 逻辑删除不会对自定义 SQL 生效。 + +#### 6.2.3 方式C:全手动分页(manualPageList) + +```java +Long total = baseMapper.manualCount(lqw); // 手动 COUNT +List 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 + + AND + ${ew.sqlSegment} + +``` + +**工作原理**: +- 当有 WHERE 条件 + ORDER BY 时:`nonEmptyOfNormal=true` → 输出 `AND condition ORDER BY ...` +- 当只有 ORDER BY 时:`nonEmptyOfNormal=false` → 直接输出 `ORDER BY ...`(不加 AND) +- 当都没有时:外层 if 不满足 → 不输出 + +**错误示例**(会丢失 ORDER BY): +```xml + + + AND ${ew.sqlSegment} + +``` + +--- + +## 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个测试方法) +``` diff --git a/zdxt-common/zdxt-common-core/src/main/java/org/dromara/common/core/domain/dto/UserDTO.java b/zdxt-common/zdxt-common-core/src/main/java/org/dromara/common/core/domain/dto/UserDTO.java index 393a0f0d..5f5ef4cd 100644 --- a/zdxt-common/zdxt-common-core/src/main/java/org/dromara/common/core/domain/dto/UserDTO.java +++ b/zdxt-common/zdxt-common-core/src/main/java/org/dromara/common/core/domain/dto/UserDTO.java @@ -28,7 +28,7 @@ public class UserDTO implements Serializable { /** * 部门ID */ - private Long deptId; + private String deptId; /** * 用户账号 diff --git a/zdxt-modules/zdxt-enforcement-code/src/main/java/com/zdxt/enforcementcode/common/util/DataFilterUtil.java b/zdxt-modules/zdxt-enforcement-code/src/main/java/com/zdxt/enforcementcode/common/util/DataFilterUtil.java index 977ed6a4..1856f5df 100644 --- a/zdxt-modules/zdxt-enforcement-code/src/main/java/com/zdxt/enforcementcode/common/util/DataFilterUtil.java +++ b/zdxt-modules/zdxt-enforcement-code/src/main/java/com/zdxt/enforcementcode/common/util/DataFilterUtil.java @@ -2,11 +2,16 @@ package com.zdxt.enforcementcode.common.util; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; +import com.zdxt.enforcementcode.domain.bo.EfcodeVirtualUnitBo; import com.zdxt.enforcementcode.domain.vo.EfcodeVirtualUnitVo; +import com.zdxt.enforcementcode.domain.vo.SrzDeptVo; +import com.zdxt.enforcementcode.mapper.SrzDeptMapper; import com.zdxt.enforcementcode.service.IEfcodeVirtualUnitService; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.dromara.common.core.domain.dto.UserDTO; import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.service.UserService; import org.dromara.common.core.utils.SpringUtils; import org.dromara.common.satoken.utils.LoginHelper; @@ -320,6 +325,156 @@ public class DataFilterUtil { return buildFilterSql("", "bureau_dept_id", includeSearch, includeStatistics); } + /** + * 查询指定用户所在的执法单位的 organizationId。 + *

+ * 通过用户的 deptId 匹配 srz_dept.organizationId,根据 unitTypeCode 判断: + *

    + *
  • unitTypeCode=99 → 抛出异常(不合法的机构类型)
  • + *
  • unitTypeCode=10 → 直接返回当前 organizationId
  • + *
  • unitTypeCode=11 → 递归查找父级,直到找到 unitTypeCode=10 的父级,返回其 organizationId
  • + *
+ * + * @param userId 用户ID + * @return 用户所属执法单位的 organizationId;用户不存在或未分配部门则返回 null + * @throws IllegalStateException 当 unitTypeCode=99 或递归查找失败时抛出 + */ + public static String getUserOrganId(Long userId) { + String deptId = getUserDeptId(userId); + if (deptId == null) { + return null; + } + + SrzDeptMapper srzDeptMapper = SpringUtils.getBean(SrzDeptMapper.class); + return findOrganIdByDeptId(srzDeptMapper, deptId, 0); + } + + /** + * 根据 organizationId 递归查找 unitTypeCode=10 的执法单位 + * + * @param srzDeptMapper Mapper + * @param organizationId 当前机构ID + * @param depth 递归深度(防歿循环) + * @return unitTypeCode=10 的 organizationId + */ + private static String findOrganIdByDeptId(SrzDeptMapper srzDeptMapper, String organizationId, int depth) { + if (StrUtil.isBlank(organizationId)) { + return null; + } + // 防止无限递归 + if (depth > 4) { + throw new IllegalStateException( + "递归查找执法单位超过最大深度(4),可能存在循环引用,organizationId=" + organizationId); + } + + SrzDeptVo srzDept = srzDeptMapper.selectVoById(organizationId); + if (srzDept == null) { + return null; + } + + String unitTypeCode = srzDept.getUnittypecode(); + if ("99".equals(unitTypeCode)) { + throw new IllegalStateException( + "用户所在机构(organizationId=" + organizationId + ")的 unitTypeCode=99,不合法的机构类型"); + } + if ("10".equals(unitTypeCode)) { + return srzDept.getOrganizationid(); + } + if ("11".equals(unitTypeCode)) { + // 递归查找父级 + return findOrganIdByDeptId(srzDeptMapper, srzDept.getParentid(), depth + 1); + } + + // 其他未知的 unitTypeCode,返回 null + return null; + } + + public static String getUserDeptId(Long userId) { + if (userId == null) { + return null; + } + // 1. 通过 userId 获取用户的 deptId + UserService userService = SpringUtils.getBean(UserService.class); + List users = userService.selectListByIds(Collections.singletonList(userId)); + if (CollUtil.isEmpty(users)) { + return null; + } + UserDTO user = users.get(0); + if (user.getDeptId() == null) { + return null; + } + String deptId = String.valueOf(user.getDeptId()); + return deptId; + } + + // ======================== 用户执法单位查询方法 ======================== + /** + * 查询指定用户所在的执法单位的 vir_id。 + *

+ * 逻辑: + *

    + *
  1. 通过 userId 查询用户的 dept_id
  2. + *
  3. 用 dept_id 匹配 efcode_virtual_unit.sys_dept_id
  4. + *
  5. 优先返回 vir_show_at_business='是' 的记录的 vir_id
  6. + *
  7. 如果 vir_show_at_business='是' 的有多条,抛出异常
  8. + *
  9. 如果没有 vir_show_at_business='是' 的记录,且只有一条匹配 sys_dept_id 的记录,也直接返回
  10. + *
+ * + * @param userId 用户ID + * @return 用户所在执法单位的 vir_id;如果用户不存在或未分配部门则返回 null + * @throws IllegalStateException 当匹配到多条 vir_show_at_business='是' 的记录时抛出 + */ + public static String getUserBureauDeptId(Long userId) { + if (userId == null) { + return null; + } + // 1. 通过 userId 获取用户的 deptId + UserService userService = SpringUtils.getBean(UserService.class); + List users = userService.selectListByIds(Collections.singletonList(userId)); + if (CollUtil.isEmpty(users)) { + return null; + } + UserDTO user = users.get(0); + if (user.getDeptId() == null) { + return null; + } + String deptId = String.valueOf(user.getDeptId()); + + // 2. 通过 deptId 查询 efcode_virtual_unit 中所有匹配 sys_dept_id 的记录 + IEfcodeVirtualUnitService virtualUnitService = SpringUtils.getBean(IEfcodeVirtualUnitService.class); + EfcodeVirtualUnitBo bo = new EfcodeVirtualUnitBo(); + bo.setSysDeptId(deptId); + List allMatched = virtualUnitService.queryList(bo); + if (CollUtil.isEmpty(allMatched)) { + return null; + } + + // 3. 筛选 vir_show_at_business='是' 的记录 + List businessUnits = allMatched.stream() + .filter(v -> "是".equals(v.getVirShowAtBusiness())) + .collect(Collectors.toList()); + + if (businessUnits.size() == 1) { + // 恰好一条业务记录,直接返回 + return businessUnits.get(0).getVirId(); + } + if (businessUnits.size() > 1) { + // 多条业务记录,抛出异常 + throw new IllegalStateException( + "用户(userId=" + userId + ")的部门(deptId=" + deptId + + ")在虚拟单位表中匹配到多条 vir_show_at_business='是' 的记录,无法确定唯一执法单位"); + } + + // 4. 没有 vir_show_at_business='是' 的记录,如果只有一条匹配记录则返回 + if (allMatched.size() == 1) { + return allMatched.get(0).getVirId(); + } + + // 多条匹配但无法确定唯一执法单位 + throw new IllegalStateException( + "无法找到当前用户所在的执法单位!"); + } + // ======================== 私有辅助方法 ======================== /** diff --git a/zdxt-web-server/zdxt-admin-web-server/src/test/java/org/dromara/test/DataFilterUtilTest.java b/zdxt-web-server/zdxt-admin-web-server/src/test/java/com/zdxt/service/test/DataFilterUtilTest.java similarity index 67% rename from zdxt-web-server/zdxt-admin-web-server/src/test/java/org/dromara/test/DataFilterUtilTest.java rename to zdxt-web-server/zdxt-admin-web-server/src/test/java/com/zdxt/service/test/DataFilterUtilTest.java index 1293bdb4..1c5918cb 100644 --- a/zdxt-web-server/zdxt-admin-web-server/src/test/java/org/dromara/test/DataFilterUtilTest.java +++ b/zdxt-web-server/zdxt-admin-web-server/src/test/java/com/zdxt/service/test/DataFilterUtilTest.java @@ -1,4 +1,4 @@ -package org.dromara.test; +package com.zdxt.service.test; import cn.dev33.satoken.context.mock.SaTokenContextMockUtil; import cn.dev33.satoken.stp.StpUtil; @@ -285,4 +285,148 @@ public class DataFilterUtilTest { System.out.println("VirIds 不包含业务节点: " + noBusiness.size()); System.out.println("VirIds 全部不包含: " + allFalse.size()); } + + // ======================== getUserEnforcementUnitId 测试 ======================== + + @Test + @Order(14) + @DisplayName("getUserBureauDeptId - null userId返回null") + public void testGetUserBureauDeptId_nullUserId() { + String result = DataFilterUtil.getUserBureauDeptId(null); + assertNull(result, "null userId 应返回 null"); + } + + @Test + @Order(15) + @DisplayName("getUserBureauDeptId - 不存在的用户返回null") + public void testGetUserBureauDeptId_nonExistentUser() { + String result = DataFilterUtil.getUserBureauDeptId(999999999L); + assertNull(result, "不存在的用户应返回 null"); + } + + @Test + @Order(16) + @DisplayName("getUserBureauDeptId - 有效用户返回执法单位virId") + public void testGetUserBureauDeptId_validUser() { + // userId=1 为管理员用户,其 deptId 应在 efcode_virtual_unit 中有匹配 + try { + String virId = DataFilterUtil.getUserBureauDeptId(1L); + if (virId != null) { + assertFalse(virId.isBlank(), "返回的 virId 不应为空白字符串"); + System.out.println("用户(userId=1) 所在执法单位 virId: " + virId); + } else { + System.out.println("用户(userId=1) 未匹配到执法单位(deptId在虚拟单位表中无对应记录)"); + } + } catch (IllegalStateException e) { + // 多条匹配记录时会抛出异常,这也是合法的业务场景 + System.out.println("用户(userId=1) 匹配到多条记录: " + e.getMessage()); + assertTrue(e.getMessage().contains("vir_show_at_business") || e.getMessage().contains("执法单位"), + "异常消息应包含关键信息"); + } + } + + @Test + @Order(17) + @DisplayName("getUserBureauDeptId - 遍历多个用户验证返回结果") + public void testGetUserBureauDeptId_multipleUsers() { + Long[] userIds = {17608604388L, 17608604398L, 17608603588L, 17608603589L, 17608617735L}; + for (Long userId : userIds) { + try { + String virId = DataFilterUtil.getUserBureauDeptId(userId); + System.out.println("用户(userId=" + userId + ") 执法单位virId: " + virId); + if (virId != null) { + assertFalse(virId.isBlank(), "virId不应为空白"); + } + } catch (IllegalStateException e) { + System.out.println("用户(userId=" + userId + ") 异常: " + e.getMessage()); + } + } + } + + // ======================== getUserDeptId 测试 ======================== + + @Test + @Order(18) + @DisplayName("getUserDeptId - null userId返回null") + public void testGetUserDeptId_nullUserId() { + String result = DataFilterUtil.getUserDeptId(null); + assertNull(result, "null userId 应返回 null"); + } + + @Test + @Order(19) + @DisplayName("getUserDeptId - 不存在的用户返回null") + public void testGetUserDeptId_nonExistentUser() { + String result = DataFilterUtil.getUserDeptId(999999999L); + assertNull(result, "不存在的用户应返回 null"); + } + + @Test + @Order(20) + @DisplayName("getUserDeptId - 有效用户返回deptId") + public void testGetUserDeptId_validUser() { + String deptId = DataFilterUtil.getUserDeptId(17608617735L); + if (deptId != null) { + assertFalse(deptId.isBlank(), "返回的 deptId 不应为空白字符串"); + System.out.println("用户(userId=1) deptId: " + deptId); + } else { + System.out.println("用户(userId=1) 未分配部门"); + } + } + + // ======================== getUserOrganId 测试 ======================== + + @Test + @Order(21) + @DisplayName("getUserOrganId - null userId返回null") + public void testGetUserOrganId_nullUserId() { + String result = DataFilterUtil.getUserOrganId(null); + assertNull(result, "null userId 应返回 null"); + } + + @Test + @Order(22) + @DisplayName("getUserOrganId - 不存在的用户返回null") + public void testGetUserOrganId_nonExistentUser() { + String result = DataFilterUtil.getUserOrganId(999999999L); + assertNull(result, "不存在的用户应返回 null"); + } + + @Test + @Order(23) + @DisplayName("getUserOrganId - 有效用户返回执法单位organizationId") + public void testGetUserOrganId_validUser() { + try { + String organId = DataFilterUtil.getUserOrganId(17608619627L); + if (organId != null) { + assertFalse(organId.isBlank(), "返回的 organizationId 不应为空白字符串"); + System.out.println("用户(userId=1) 所属执法单位 organizationId: " + organId); + } else { + System.out.println("用户(userId=1) 在 srz_dept 中未找到对应记录"); + } + } catch (IllegalStateException e) { + // unitTypeCode=99 或递归超深度时抛出异常 + System.out.println("用户(userId=1) 异常: " + e.getMessage()); + assertTrue(e.getMessage().contains("unitTypeCode") || e.getMessage().contains("递归"), + "异常消息应包含关键信息"); + } + } + + @Test + @Order(24) + @DisplayName("getUserOrganId - 遍历多个用户验证返回结果") + public void testGetUserOrganId_multipleUsers() { + Long[] userIds = {17608604388L, 17608604398L, 17608603588L, 17608603589L, 17608617735L}; + for (Long userId : userIds) { + try { + String organId = DataFilterUtil.getUserOrganId(userId); + System.out.println("用户(userId=" + userId + ") organizationId: " + organId); + if (organId != null) { + assertFalse(organId.isBlank(), "organizationId不应为空白"); + } + } catch (IllegalStateException e) { + System.out.println("用户(userId=" + userId + ") 异常: " + e.getMessage()); + } + } + } }