diff --git a/doc/implementation-report-2026-05-12-workflow-list-role-data-scope.md b/doc/implementation-report-2026-05-12-workflow-list-role-data-scope.md new file mode 100644 index 0000000..03abf61 --- /dev/null +++ b/doc/implementation-report-2026-05-12-workflow-list-role-data-scope.md @@ -0,0 +1,57 @@ +# 流程列表角色数据权限实施记录 + +## 修改日期 + +2026-05-12 + +## 需求范围 + +- 仅控制 `GET /loanPricing/workflow/list` 流程列表接口。 +- 超级管理员 `user_id=1`、启用角色名为“管理员”或角色标识为 `headAdmin` 的用户可查看全部流程。 +- 非管理员用户只能查看 `loan_pricing_workflow.create_by` 精确等于当前登录人 `昵称-柜员号` 的流程。 +- 列表页“创建者”查询参数继续保留,但只按 `create_by` 中的柜员号部分进行模糊匹配。 + +## 修改内容 + +- `LoanPricingWorkflow` 增加非表字段 `dataScopeCreateBy`,专用于后端内部数据权限精确过滤。 +- `LoanPricingWorkflowServiceImpl.selectLoanPricingPage` 增加流程列表数据权限裁剪: + - 管理员不写入 `dataScopeCreateBy`。 + - 非管理员写入 `dataScopeCreateBy = nickName + "-" + username`。 + - 前端传入 `createBy` 时仍保留原查询参数,但不能扩大非管理员可见范围。 +- `LoanPricingWorkflowMapper.xml` 增加 `lpw.create_by = #{query.dataScopeCreateBy}` 精确权限条件。 +- `LoanPricingWorkflowMapper.xml` 将 `createBy` 查询调整为 `SUBSTRING_INDEX(lpw.create_by, '-', -1) LIKE ...`,即只按柜员号模糊匹配。 +- 补充 `LoanPricingWorkflowServiceImplTest` 和 `LoanPricingWorkflowMapperXmlTest`,覆盖管理员、业务管理员、客户经理、越权创建者查询参数和 XML 条件。 + +## 验证记录 + +- 单元测试通过: + +```bash +mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowServiceImplTest,LoanPricingWorkflowMapperXmlTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +- 后端打包通过: + +```bash +mvn -pl ruoyi-admin -am clean package -DskipTests +``` + +- API 验证通过: + - `admin/admin123` 查询流程列表返回全量数据,包含测试行和 `若依-admin` 历史行。 + - `8929999/123456` 业务管理员查询流程列表返回全量数据。 + - `8920001/123456` 客户经理查询流程列表只返回本人创建的测试行。 + - 客户经理按 `createBy=8920001` 查询可返回本人测试行。 + - 客户经理按昵称 `createBy=测试客户经理` 查询返回 0 条。 + - 客户经理按其他柜员号 `createBy=admin` 查询返回 0 条。 + +- browser-use 真实页面验证通过: + - 管理员登录真实流程列表页,页面显示 `共 40 条`,可见本人测试行和 `若依-admin` 历史行。 + - 客户经理登录真实流程列表页,页面只显示本人创建的测试行。 + - 客户经理在“创建者”输入 `admin` 后页面显示暂无数据。 + - 客户经理在“创建者”输入 `8920001` 后页面重新显示本人测试行。 + +## 验证数据与清理 + +- 因当前开发库创建流程接口依赖的模型输出表缺少 `coupon_rate` 字段,无法通过页面新增生成验证流程。 +- 本次验证使用一条临时 SQL 测试数据:`serial_num = ROLE_SCOPE_20260512_001`,`create_by = 测试客户经理-8920001`。 +- 验证结束后已删除该临时数据,回查剩余数量为 0。 diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java index 6644311..680170b 100644 --- a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java @@ -150,6 +150,10 @@ public class LoanPricingWorkflow implements Serializable @TableField(fill = FieldFill.INSERT) private String createBy; + /** 列表数据权限创建者过滤条件 */ + @TableField(exist = false) + private String dataScopeCreateBy; + /** 创建时间 */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @TableField(fill = FieldFill.INSERT) diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java index c2cfc65..cc355db 100644 --- a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java @@ -3,7 +3,12 @@ package com.ruoyi.loanpricing.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.common.constant.UserConstants; +import com.ruoyi.common.core.domain.entity.SysRole; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.loanpricing.domain.dto.CorporateLoanPricingCreateDTO; import com.ruoyi.loanpricing.domain.dto.PersonalLoanPricingCreateDTO; import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; @@ -38,6 +43,10 @@ import java.util.Objects; @Service public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowService { + private static final String WORKFLOW_ADMIN_ROLE_NAME = "管理员"; + + private static final String WORKFLOW_ADMIN_ROLE_KEY = "headAdmin"; + @Resource private LoanPricingWorkflowMapper loanPricingWorkflowMapper; @@ -209,7 +218,8 @@ public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowServi @Override public IPage selectLoanPricingPage(Page page, LoanPricingWorkflow loanPricingWorkflow) { - IPage pageResult = loanPricingWorkflowMapper.selectWorkflowPageWithRates(page, loanPricingWorkflow); + LoanPricingWorkflow scopedQuery = applyWorkflowListDataScope(loanPricingWorkflow); + IPage pageResult = loanPricingWorkflowMapper.selectWorkflowPageWithRates(page, scopedQuery); pageResult.getRecords().forEach(row -> row.setCustName( loanPricingSensitiveDisplayService.maskCustName( sensitiveFieldCryptoService.decrypt(row.getCustName())))); @@ -292,6 +302,41 @@ public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowServi return wrapper; } + private LoanPricingWorkflow applyWorkflowListDataScope(LoanPricingWorkflow query) + { + LoanPricingWorkflow scopedQuery = query == null ? new LoanPricingWorkflow() : query; + LoginUser loginUser = SecurityUtils.getLoginUser(); + if (!canViewAllWorkflows(loginUser)) + { + scopedQuery.setDataScopeCreateBy(buildCurrentCreateBy(loginUser)); + } + return scopedQuery; + } + + private boolean canViewAllWorkflows(LoginUser loginUser) + { + SysUser user = loginUser.getUser(); + if (user.isAdmin()) + { + return true; + } + List roles = user.getRoles(); + if (roles == null) + { + return false; + } + return roles.stream().anyMatch(role -> role != null + && UserConstants.ROLE_NORMAL.equals(role.getStatus()) + && (WORKFLOW_ADMIN_ROLE_NAME.equals(role.getRoleName()) + || WORKFLOW_ADMIN_ROLE_KEY.equals(role.getRoleKey()))); + } + + private String buildCurrentCreateBy(LoginUser loginUser) + { + SysUser user = loginUser.getUser(); + return user.getNickName() + "-" + loginUser.getUsername(); + } + private void maskModelRetailOutputBasicInfo(ModelRetailOutputFields modelRetailOutputFields) { modelRetailOutputFields.setCustName( diff --git a/ruoyi-loan-pricing/src/main/resources/mapper/loanpricing/LoanPricingWorkflowMapper.xml b/ruoyi-loan-pricing/src/main/resources/mapper/loanpricing/LoanPricingWorkflowMapper.xml index fac11dd..a87af5b 100644 --- a/ruoyi-loan-pricing/src/main/resources/mapper/loanpricing/LoanPricingWorkflowMapper.xml +++ b/ruoyi-loan-pricing/src/main/resources/mapper/loanpricing/LoanPricingWorkflowMapper.xml @@ -23,8 +23,11 @@ LEFT JOIN model_retail_output_fields mr ON lpw.model_output_id = mr.id LEFT JOIN model_corp_output_fields mc ON lpw.model_output_id = mc.id + + AND lpw.create_by = #{query.dataScopeCreateBy} + - AND lpw.create_by LIKE CONCAT('%', #{query.createBy}, '%') + AND SUBSTRING_INDEX(lpw.create_by, '-', -1) LIKE CONCAT('%', #{query.createBy}, '%') AND lpw.cust_isn LIKE CONCAT('%', #{query.custIsn}, '%') diff --git a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/mapper/LoanPricingWorkflowMapperXmlTest.java b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/mapper/LoanPricingWorkflowMapperXmlTest.java index d63a746..fb0f86d 100644 --- a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/mapper/LoanPricingWorkflowMapperXmlTest.java +++ b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/mapper/LoanPricingWorkflowMapperXmlTest.java @@ -18,5 +18,7 @@ class LoanPricingWorkflowMapperXmlTest assertTrue(xml.contains("WHEN lpw.cust_type = '个人' THEN mr.final_calculate_rate")); assertTrue(xml.contains("WHEN lpw.cust_type = '企业' THEN mc.calculate_rate")); + assertTrue(xml.contains("lpw.create_by = #{query.dataScopeCreateBy}")); + assertTrue(xml.contains("SUBSTRING_INDEX(lpw.create_by, '-', -1) LIKE CONCAT('%', #{query.createBy}, '%')")); } } diff --git a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java index 640563e..fa68147 100644 --- a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java +++ b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java @@ -1,6 +1,7 @@ package com.ruoyi.loanpricing.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -13,6 +14,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.common.constant.UserConstants; +import com.ruoyi.common.core.domain.entity.SysRole; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.loanpricing.domain.dto.CorporateLoanPricingCreateDTO; import com.ruoyi.loanpricing.domain.dto.PersonalLoanPricingCreateDTO; @@ -28,12 +33,16 @@ import com.ruoyi.loanpricing.service.LoanPricingSensitiveDisplayService; import com.ruoyi.loanpricing.service.LoanPricingModelService; import com.ruoyi.loanpricing.service.SensitiveFieldCryptoService; import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import java.util.Collections; import java.util.Objects; @@ -62,6 +71,18 @@ class LoanPricingWorkflowServiceImplTest @InjectMocks private LoanPricingWorkflowServiceImpl loanPricingWorkflowService; + @BeforeEach + void setUpLoginUser() + { + setLoginUser(1L, "admin", "若依", role(1L, "超级管理员", "admin")); + } + + @AfterEach + void clearLoginUser() + { + SecurityContextHolder.clearContext(); + } + @Test void shouldEncryptCustNameAndIdNumBeforeInsert() { @@ -116,6 +137,64 @@ class LoanPricingWorkflowServiceImplTest assertEquals("张*", result.getRecords().get(0).getCustName()); } + @Test + void shouldNotSetDataScopeCreateByForSuperAdminWhenReturningPagedWorkflowList() + { + when(loanPricingWorkflowMapper.selectWorkflowPageWithRates(any(), any())).thenReturn(emptyPageResult()); + + loanPricingWorkflowService.selectLoanPricingPage(new Page<>(1, 10), new LoanPricingWorkflow()); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(LoanPricingWorkflow.class); + verify(loanPricingWorkflowMapper).selectWorkflowPageWithRates(any(), queryCaptor.capture()); + + assertNull(queryCaptor.getValue().getDataScopeCreateBy()); + } + + @Test + void shouldNotSetDataScopeCreateByForBusinessAdminWhenReturningPagedWorkflowList() + { + setLoginUser(100L, "8929999", "测试管理员", role(100L, "管理员", "headAdmin")); + when(loanPricingWorkflowMapper.selectWorkflowPageWithRates(any(), any())).thenReturn(emptyPageResult()); + + loanPricingWorkflowService.selectLoanPricingPage(new Page<>(1, 10), new LoanPricingWorkflow()); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(LoanPricingWorkflow.class); + verify(loanPricingWorkflowMapper).selectWorkflowPageWithRates(any(), queryCaptor.capture()); + + assertNull(queryCaptor.getValue().getDataScopeCreateBy()); + } + + @Test + void shouldSetCurrentCreateByForCustomerManagerWhenReturningPagedWorkflowList() + { + setLoginUser(101L, "8920001", "测试客户经理", role(101L, "客户经理", "common")); + when(loanPricingWorkflowMapper.selectWorkflowPageWithRates(any(), any())).thenReturn(emptyPageResult()); + + loanPricingWorkflowService.selectLoanPricingPage(new Page<>(1, 10), new LoanPricingWorkflow()); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(LoanPricingWorkflow.class); + verify(loanPricingWorkflowMapper).selectWorkflowPageWithRates(any(), queryCaptor.capture()); + + assertEquals("测试客户经理-8920001", queryCaptor.getValue().getDataScopeCreateBy()); + } + + @Test + void shouldKeepCustomerManagerWithinOwnDataScopeWhenCreateByQueryIsProvided() + { + setLoginUser(101L, "8920001", "测试客户经理", role(101L, "客户经理", "common")); + LoanPricingWorkflow query = new LoanPricingWorkflow(); + query.setCreateBy("若依-admin"); + when(loanPricingWorkflowMapper.selectWorkflowPageWithRates(any(), any())).thenReturn(emptyPageResult()); + + loanPricingWorkflowService.selectLoanPricingPage(new Page<>(1, 10), query); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(LoanPricingWorkflow.class); + verify(loanPricingWorkflowMapper).selectWorkflowPageWithRates(any(), queryCaptor.capture()); + + assertEquals("若依-admin", queryCaptor.getValue().getCreateBy()); + assertEquals("测试客户经理-8920001", queryCaptor.getValue().getDataScopeCreateBy()); + } + @Test void shouldUseCustIsnInsteadOfCustNameAsQueryCondition() { @@ -399,4 +478,33 @@ class LoanPricingWorkflowServiceImplTest workflow.setBusinessType("新增"); return workflow; } + + private Page emptyPageResult() + { + Page pageResult = new Page<>(1, 10); + pageResult.setRecords(Collections.emptyList()); + return pageResult; + } + + private void setLoginUser(Long userId, String username, String nickName, SysRole... roles) + { + SysUser user = new SysUser(); + user.setUserId(userId); + user.setUserName(username); + user.setNickName(nickName); + user.setRoles(java.util.Arrays.asList(roles)); + LoginUser loginUser = new LoginUser(userId, 100L, user, Collections.emptySet()); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private SysRole role(Long roleId, String roleName, String roleKey) + { + SysRole role = new SysRole(roleId); + role.setRoleName(roleName); + role.setRoleKey(roleKey); + role.setStatus(UserConstants.ROLE_NORMAL); + return role; + } }