longz
2026-07-29 61ed739b4ee4267653568a2aa91004ac6c43d248
各类修复
1个文件已添加
3个文件已修改
183 ■■■■■ 已修改文件
docs/superpowers/plans/2026-07-29-partner-user-permission-scope.md 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/PartnerAppUserAccessService.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/PartnerAppUserAccessServiceTest.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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;
    }
}