docs/superpowers/plans/2026-07-29-partner-user-permission-scope.md
New file @@ -0,0 +1,51 @@ # Partner User Permission Scope Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Keep partner user-list data limited to users who charged at the partner's sites while allowing all user-management actions strictly according to configured permission keys. **Architecture:** Retain `PartnerAppUserAccessService.getVisibleUserIdsForCurrentPartner()` for list filtering and `assertUserVisible()` for object-level authorization. Remove the blanket partner read-only rule, apply visibility checks to every mutation targeting existing users, and remove frontend `isPartner` overrides so `Access` is the sole button-control mechanism. **Tech Stack:** Java 8, Spring Boot, MyBatis-Plus, React, Umi Access, JUnit 5, Mockito ## Global Constraints - Partner accounts only see users who charged at their authorized sites. - Platform accounts retain unrestricted user-list scope. - View and mutation buttons follow their existing permission keys. - Partner mutations may only target users inside the partner-visible scope. - Do not weaken user-detail visibility checks. - Do not modify unrelated workspace changes. --- ### Task 1: Object-level partner authorization **Files:** - Modify: `ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/PartnerAppUserAccessService.java` - Modify: `ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java` - Test: `ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/PartnerAppUserAccessServiceTest.java` **Interfaces:** - Consumes: `getVisibleUserIdsForCurrentPartner()`. - Produces: `assertUsersVisible(Collection<Long> userIds)` for safe bulk mutations. - [x] **Step 1: Replace the read-only test with tests that allow authorized targets and reject targets outside partner scope.** - [x] **Step 2: Run the focused test and verify it fails before implementation.** - [x] **Step 3: Implement bulk visibility validation and remove `assertWritable()`.** - [x] **Step 4: Apply visibility validation to edit, freeze, unit change, delete, and gift-VIP mutations.** - [x] **Step 5: Run focused tests and compile the account module.** ### Task 2: Frontend permission-only controls **Files:** - Modify: `chargingPile_management/src/pages/person/list/index.jsx` **Interfaces:** - Consumes: existing Umi `access` keys such as `/appUser/select`, `/appUser/update`, `/appUser/freeze`, `/appUser/del`, `/appUser/giftVip`, and `/appUser/updateCompany`. - Produces: buttons and row selection controlled only by configured permissions. - [x] **Step 1: Remove `isPartner` overrides around row actions, row selection, and toolbar rendering.** - [x] **Step 2: Keep every action wrapped by its existing `Access` permission check.** - [x] **Step 3: Run the frontend production build.** - [x] **Step 4: Review diffs to confirm the partner data filter and backend delete protections remain intact.** ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java
@@ -696,8 +696,9 @@ @PostMapping(value = "/user/addOrUpdate") @Log(title = "【用户列表】添加编辑用户", businessType = BusinessType.INSERT,operatorType = OperatorType.MANAGE) public R addOrUpdate(@RequestBody TAppUser tAppUser) { partnerAppUserAccessService.assertWritable(); if (tAppUser.getId() != null){ AuthUtil.checkPermi("/appUser/update"); partnerAppUserAccessService.assertUserVisible(tAppUser.getId()); //为编辑操作,要进行vip的逻辑处理 TAppUser nowUser = appUserService.getById(tAppUser.getId()); //会员结束时间不为空 @@ -731,6 +732,7 @@ } }else { AuthUtil.checkPermi("/appUser/add"); List<TAppUser> list = appUserService.lambdaQuery().eq(TAppUser::getPhone, tAppUser.getPhone()).list(); if (!list.isEmpty()){ return R.fail("该手机号已注册"); @@ -955,7 +957,7 @@ @PostMapping(value = "/user/status/change") @Log(title = "【用户列表】冻结解冻用户", businessType = BusinessType.UPDATE,operatorType = OperatorType.MANAGE) public R pointsDetail(@RequestBody UserChangeDto userChangeDto) { partnerAppUserAccessService.assertWritable(); partnerAppUserAccessService.assertUserVisible(userChangeDto.getUserId()); TAppUser appUser = appUserService.getById(userChangeDto.getUserId()); appUser.setStatus(userChangeDto.getStatus()); appUser.setRemark(userChangeDto.getRemark()); @@ -969,9 +971,10 @@ @PostMapping(value = "/user/unit/change") @Log(title = "【用户列表】修改用户单位", businessType = BusinessType.UPDATE,operatorType = OperatorType.MANAGE) public R unitChange(@RequestBody UnitChangeDto unitChangeDto) { partnerAppUserAccessService.assertWritable(); for (String s : unitChangeDto.getIds().split(",")) { TAppUser byId = appUserService.getById(s); List<Long> userIds = parseUserIds(unitChangeDto.getIds()); partnerAppUserAccessService.assertUsersVisible(userIds); for (Long userId : userIds) { TAppUser byId = appUserService.getById(userId); byId.setCompanyId(unitChangeDto.getUnitId()); appUserService.updateById(byId); } @@ -984,10 +987,10 @@ @DeleteMapping(value = "/user/delete") @Log(title = "【用户列表】删除用户", businessType = BusinessType.DELETE,operatorType = OperatorType.MANAGE) public R userDelete(String ids) { partnerAppUserAccessService.assertWritable(); String[] split = ids.split(","); for (String s : split) { appUserService.removeById(s); List<Long> userIds = parseUserIds(ids); partnerAppUserAccessService.assertUsersVisible(userIds); for (Long userId : userIds) { appUserService.removeById(userId); } return R.ok(); @@ -1164,10 +1167,10 @@ @PostMapping(value = "/user/give/vip") @Log(title = "【用户列表】赠送会员", businessType = BusinessType.OTHER,operatorType = OperatorType.MANAGE) public R giveVip(@RequestBody GiveVipDto giveVipDto) { partnerAppUserAccessService.assertWritable(); String[] split = giveVipDto.getUserIds().split(","); for (String s : split) { TAppUser nowUser = appUserService.getById(s); List<Long> userIds = parseUserIds(giveVipDto.getUserIds()); partnerAppUserAccessService.assertUsersVisible(userIds); for (Long userId : userIds) { TAppUser nowUser = appUserService.getById(userId); int plusDay = 0; if (giveVipDto.getType() == 1) { plusDay = 1; @@ -1205,6 +1208,18 @@ return R.ok(); } private List<Long> parseUserIds(String ids) { if (StringUtils.isBlank(ids)) { return Collections.emptyList(); } return Arrays.stream(ids.split(",")) .map(String::trim) .filter(StringUtils::isNotBlank) .map(Long::valueOf) .distinct() .collect(Collectors.toList()); } @PostMapping(value = "/user/give/vip1") ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/PartnerAppUserAccessService.java
@@ -1,7 +1,7 @@ package com.ruoyi.account.service; import com.ruoyi.chargingPile.api.domain.SiteMenu; import com.ruoyi.chargingPile.api.feignClient.PartnerClient; import com.ruoyi.chargingPile.api.dto.GetSiteListDTO; import com.ruoyi.chargingPile.api.feignClient.SiteClient; import com.ruoyi.common.core.domain.R; import com.ruoyi.common.core.exception.ServiceException; import com.ruoyi.common.security.service.TokenService; @@ -14,10 +14,10 @@ import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; /** @@ -29,14 +29,13 @@ private static final Integer PARTNER_ROLE_TYPE = 2; private static final Long IMPOSSIBLE_USER_ID = -1L; private static final String PARTNER_READ_ONLY_MESSAGE = "合作商账号仅支持查看用户信息"; @Resource private TokenService tokenService; @Resource private SysUserClient sysUserClient; @Resource private PartnerClient partnerClient; private SiteClient siteClient; @Resource private ChargingOrderClient chargingOrderClient; @@ -48,14 +47,16 @@ if (!isPartner(sysUser)) { return null; } if (sysUser.getObjectId() == null) { if (sysUser.getObjectId() == null || sysUser.getUserId() == null) { return impossibleUserIds(); } R<Set<Integer>> siteResult = partnerClient.authSite(sysUser.getObjectId(), SiteMenu.SITE_LIST.getValue()); R<List<GetSiteListDTO>> siteResult = siteClient.getSiteListByUserId(sysUser.getUserId()); if (siteResult == null || !R.isSuccess(siteResult) || siteResult.getData() == null) { return impossibleUserIds(); } List<Integer> siteIds = siteResult.getData().stream() .filter(Objects::nonNull) .map(GetSiteListDTO::getId) .filter(Objects::nonNull) .filter(siteId -> siteId > 0) .distinct() @@ -76,10 +77,6 @@ return userIds.isEmpty() ? impossibleUserIds() : userIds; } public boolean isCurrentPartner() { return isPartner(getCurrentSysUser()); } public void assertUserVisible(Long userId) { List<Long> visibleUserIds = getVisibleUserIdsForCurrentPartner(); if (visibleUserIds != null && (userId == null || !visibleUserIds.contains(userId))) { @@ -87,9 +84,18 @@ } } public void assertWritable() { if (isCurrentPartner()) { throw new ServiceException(PARTNER_READ_ONLY_MESSAGE); public void assertUsersVisible(Collection<Long> userIds) { List<Long> visibleUserIds = getVisibleUserIdsForCurrentPartner(); if (visibleUserIds == null) { return; } if (userIds == null || userIds.isEmpty()) { throw new ServiceException("无权操作所选用户"); } for (Long userId : userIds) { if (userId == null || !visibleUserIds.contains(userId)) { throw new ServiceException("无权操作所选用户"); } } } ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/PartnerAppUserAccessServiceTest.java
@@ -1,7 +1,7 @@ package com.ruoyi.account.service; import com.ruoyi.chargingPile.api.domain.SiteMenu; import com.ruoyi.chargingPile.api.feignClient.PartnerClient; import com.ruoyi.chargingPile.api.dto.GetSiteListDTO; import com.ruoyi.chargingPile.api.feignClient.SiteClient; import com.ruoyi.common.core.domain.R; import com.ruoyi.common.core.exception.ServiceException; import com.ruoyi.common.security.service.TokenService; @@ -18,10 +18,10 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.verify; @@ -33,7 +33,7 @@ @Mock private TokenService tokenService; @Mock private SysUserClient sysUserClient; @Mock private PartnerClient partnerClient; @Mock private SiteClient siteClient; @Mock private ChargingOrderClient chargingOrderClient; private PartnerAppUserAccessService service; @@ -43,15 +43,15 @@ service = new PartnerAppUserAccessService(); ReflectionTestUtils.setField(service, "tokenService", tokenService); ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient); ReflectionTestUtils.setField(service, "partnerClient", partnerClient); ReflectionTestUtils.setField(service, "siteClient", siteClient); ReflectionTestUtils.setField(service, "chargingOrderClient", chargingOrderClient); } @Test void partnerCanSeeOnlyUsersWhoChargedAtAuthorizedSites() { mockCurrentUser(2, 9); when(partnerClient.authSite(9, SiteMenu.SITE_LIST.getValue())) .thenReturn(R.ok(new HashSet<>(Arrays.asList(11, 12)))); when(siteClient.getSiteListByUserId(1L)) .thenReturn(R.ok(Arrays.asList(site(11), site(12)))); when(chargingOrderClient.getAppUserIdsBySiteIds(Arrays.asList(11, 12))) .thenReturn(R.ok(Arrays.asList(101L, 102L, 101L))); @@ -64,8 +64,8 @@ @Test void partnerWithoutAuthorizedSitesGetsImpossibleUserId() { mockCurrentUser(2, 9); when(partnerClient.authSite(9, SiteMenu.SITE_LIST.getValue())) .thenReturn(R.ok(Collections.singleton(-1))); when(siteClient.getSiteListByUserId(1L)) .thenReturn(R.ok(Collections.singletonList(site(-1)))); assertEquals(Collections.singletonList(-1L), service.getVisibleUserIdsForCurrentPartner()); verifyNoInteractions(chargingOrderClient); @@ -74,8 +74,8 @@ @Test void orderServiceFailureDoesNotExposeAllUsers() { mockCurrentUser(2, 9); when(partnerClient.authSite(9, SiteMenu.SITE_LIST.getValue())) .thenReturn(R.ok(Collections.singleton(11))); when(siteClient.getSiteListByUserId(1L)) .thenReturn(R.ok(Collections.singletonList(site(11)))); when(chargingOrderClient.getAppUserIdsBySiteIds(Collections.singletonList(11))) .thenReturn(R.fail("order unavailable")); @@ -87,14 +87,14 @@ mockCurrentUser(1, null); assertNull(service.getVisibleUserIdsForCurrentPartner()); verifyNoInteractions(partnerClient, chargingOrderClient); verifyNoInteractions(siteClient, chargingOrderClient); } @Test void partnerCannotReadUserOutsideVisibleScope() { mockCurrentUser(2, 9); when(partnerClient.authSite(9, SiteMenu.SITE_LIST.getValue())) .thenReturn(R.ok(Collections.singleton(11))); when(siteClient.getSiteListByUserId(1L)) .thenReturn(R.ok(Collections.singletonList(site(11)))); when(chargingOrderClient.getAppUserIdsBySiteIds(Collections.singletonList(11))) .thenReturn(R.ok(Collections.singletonList(101L))); @@ -102,16 +102,33 @@ } @Test void partnerUserManagementIsReadOnly() { void partnerCanOperateUsersInsideVisibleScope() { mockCurrentUser(2, 9); when(siteClient.getSiteListByUserId(1L)) .thenReturn(R.ok(Collections.singletonList(site(11)))); when(chargingOrderClient.getAppUserIdsBySiteIds(Collections.singletonList(11))) .thenReturn(R.ok(Arrays.asList(101L, 102L))); ServiceException exception = assertThrows(ServiceException.class, service::assertWritable); assertDoesNotThrow(() -> service.assertUsersVisible(Arrays.asList(101L, 102L))); } assertEquals("合作商账号仅支持查看用户信息", exception.getMessage()); @Test void partnerCannotOperateBulkUsersWhenOneIsOutsideVisibleScope() { mockCurrentUser(2, 9); when(siteClient.getSiteListByUserId(1L)) .thenReturn(R.ok(Collections.singletonList(site(11)))); when(chargingOrderClient.getAppUserIdsBySiteIds(Collections.singletonList(11))) .thenReturn(R.ok(Collections.singletonList(101L))); ServiceException exception = assertThrows(ServiceException.class, () -> service.assertUsersVisible(Arrays.asList(101L, 999L))); assertEquals("无权操作所选用户", exception.getMessage()); } private void mockCurrentUser(Integer roleType, Integer objectId) { SysUser sysUser = new SysUser(); sysUser.setUserId(1L); sysUser.setRoleType(roleType); sysUser.setObjectId(objectId); LoginUser loginUser = new LoginUser(); @@ -119,4 +136,10 @@ loginUser.setSysUser(sysUser); when(tokenService.getLoginUser()).thenReturn(loginUser); } private GetSiteListDTO site(Integer id) { GetSiteListDTO site = new GetSiteListDTO(); site.setId(id); return site; } }