diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java index 005c2793..1c5f898a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java @@ -377,7 +377,15 @@ public class PermissionMenuService { return requestedIds.size(); } - /** Verifies both the visible admin menu and its separately managed data grant. */ + /** + * Verifies a visible shop-data admin menu and its separately managed data grant. + * + *

校验口径与 {@link #autoGrantShopDataDataPermission} 保持一致:「店铺数据记录」 + * {@code admin_shop_data_crawl_tasks} 或「店铺数据重复检查」{@code admin_shop_data_duplicate_check} + * 任一可见菜单,并同时持有内部数据权限 {@code admin_shop_data_crawl_task_data}。 + * 只勾了「店铺数据重复检查」而未勾「店铺数据记录」的管理员(数据权限已被自动补上) + * 不应再被 403 拒绝。

+ */ public void requireShopDataCrawlTaskAccess(AdminUserEntity operator) { ensureAdminOperatorIfPresent(operator); if (operator == null || operator.getId() == null || operator.getId() <= 0) { @@ -388,10 +396,13 @@ public class PermissionMenuService { } PermissionMenuEntity taskMenu = requireDataPermission( "admin_shop_data_crawl_tasks", "店铺数据任务管理菜单"); + PermissionMenuEntity duplicateCheckMenu = requireDataPermission( + "admin_shop_data_duplicate_check", "店铺数据重复检查菜单"); PermissionMenuEntity dataPermission = requireDataPermission( SHOP_DATA_CRAWL_DATA_PERMISSION_KEY, "店铺数据任务数据"); - if (!hasEffectiveColumnPermission(operator.getId(), taskMenu.getId()) - || !hasEffectiveColumnPermission(operator.getId(), dataPermission.getId())) { + boolean hasShopDataMenu = hasEffectiveColumnPermission(operator.getId(), taskMenu.getId()) + || hasEffectiveColumnPermission(operator.getId(), duplicateCheckMenu.getId()); + if (!hasShopDataMenu || !hasEffectiveColumnPermission(operator.getId(), dataPermission.getId())) { throw new BusinessException(403, "无权查看店铺数据任务"); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java index 9db9a143..dfa823c5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java @@ -7,7 +7,6 @@ import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; import com.nanri.aiimage.modules.permission.service.PermissionMenuService; import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlDownloadRowDto; -import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckSourceMapper; import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlAdminTasksService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -39,7 +38,6 @@ import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; @@ -77,7 +75,6 @@ public class AdminShopDataCrawlTasksController { private final AdminAuthSupport adminAuthSupport; private final PermissionMenuService permissionMenuService; private final ShopDataCrawlAdminTasksService adminTasksService; - private final ShopDuplicateCheckSourceMapper duplicateCheckSourceMapper; @GetMapping @Operation(summary = "店铺数据抓取记录:按店铺分组分页列表(每店最新结果文件)") @@ -256,15 +253,7 @@ public class AdminShopDataCrawlTasksController { /** 非超管返回其可管店铺名(trim+小写去重,与撞款可见口径一致);超管调用方传 null 表示全量。 */ private List resolveVisibleShopNameKeys(Long operatorId) { - List names = duplicateCheckSourceMapper.selectManagedShopNames(operatorId); - if (names == null || names.isEmpty()) { - return List.of(); - } - return names.stream() - .filter(name -> name != null && !name.isBlank()) - .map(name -> name.trim().toLowerCase(Locale.ROOT)) - .distinct() - .toList(); + return adminTasksService.resolveVisibleShopNameKeys(operatorId); } private AdminUserEntity resolveInternalOperator(HttpServletRequest request) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java index 4fedd315..c721a57f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java @@ -9,6 +9,7 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminGroup import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminGroupRow; import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminRow; import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlDownloadRowDto; +import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckSourceMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -38,6 +39,7 @@ public class ShopDataCrawlAdminTasksService { private static final Set SUPPORTED_COUNTRIES = Set.of("DE", "UK", "FR", "IT", "ES"); private final ShopDataCrawlAdminTasksMapper adminTasksMapper; + private final ShopDuplicateCheckSourceMapper duplicateCheckSourceMapper; private final ObjectMapper objectMapper; /** @@ -94,6 +96,19 @@ public class ShopDataCrawlAdminTasksService { return adminTasksMapper.selectDownloadRows(p); } + /** 非超管可管店铺名(trim+小写去重,与撞款可见口径一致);超管调用方传 null 表示全量。 */ + public List resolveVisibleShopNameKeys(Long operatorId) { + List names = duplicateCheckSourceMapper.selectManagedShopNames(operatorId); + if (names == null || names.isEmpty()) { + return List.of(); + } + return names.stream() + .filter(name -> name != null && !name.isBlank()) + .map(name -> name.trim().toLowerCase(Locale.ROOT)) + .distinct() + .toList(); + } + private List> assembleGroupItems(Map p, List groups) { List shopNames = new ArrayList<>(); for (ShopDataCrawlAdminGroupRow group : groups) { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java index 26a28afc..795d60a2 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java @@ -739,6 +739,132 @@ class PermissionMenuServiceTest { assertThat(captor.getValue().getColumnId()).isEqualTo(10L); } + @Test + void shopDataTaskAccessAllowsDuplicateCheckMenuOnlyWithDataPermission() { + // 反馈场景(魏振峰):只勾「店铺数据重复检查」菜单、数据权限已自动补上, + // 虽未勾「店铺数据记录」菜单,仍应放行(校验口径与 autoGrant 保持一致)。 + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + + PermissionMenuEntity taskMenu = menu(94L, null, "admin", 1); + taskMenu.setColumnKey("admin_shop_data_crawl_tasks"); + PermissionMenuEntity duplicateCheckMenu = menu(103L, null, "admin", 2); + duplicateCheckMenu.setColumnKey("admin_shop_data_duplicate_check"); + PermissionMenuEntity dataPermission = menu(76L, null, "internal", 0); + dataPermission.setColumnKey("admin_shop_data_crawl_task_data"); + when(userMapper.selectById(653L)).thenReturn(user(653L, "admin", 1)); + when(menuMapper.selectOne(any())).thenReturn(taskMenu, duplicateCheckMenu, dataPermission); + when(menuMapper.selectById(94L)).thenReturn(taskMenu); + when(menuMapper.selectById(103L)).thenReturn(duplicateCheckMenu); + when(menuMapper.selectById(76L)).thenReturn(dataPermission); + when(menuMapper.selectList(any())) + .thenReturn(List.of(taskMenu, duplicateCheckMenu, dataPermission)); + when(permissionMapper.selectByUserId(653L)) + .thenReturn(List.of(grant(653L, 103L), grant(653L, 76L))); + AdminUserEntity operator = user(653L, "admin", 1); + + service.requireShopDataCrawlTaskAccess(operator); + } + + @Test + void shopDataTaskAccessAllowsTaskMenuWithoutDuplicateCheckMenu() { + // 回归:只勾「店铺数据记录」菜单 + 数据权限,放行。 + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + + PermissionMenuEntity taskMenu = menu(94L, null, "admin", 1); + taskMenu.setColumnKey("admin_shop_data_crawl_tasks"); + PermissionMenuEntity duplicateCheckMenu = menu(103L, null, "admin", 2); + duplicateCheckMenu.setColumnKey("admin_shop_data_duplicate_check"); + PermissionMenuEntity dataPermission = menu(76L, null, "internal", 0); + dataPermission.setColumnKey("admin_shop_data_crawl_task_data"); + when(userMapper.selectById(653L)).thenReturn(user(653L, "admin", 1)); + when(menuMapper.selectOne(any())).thenReturn(taskMenu, duplicateCheckMenu, dataPermission); + when(menuMapper.selectById(94L)).thenReturn(taskMenu); + when(menuMapper.selectById(103L)).thenReturn(duplicateCheckMenu); + when(menuMapper.selectById(76L)).thenReturn(dataPermission); + when(menuMapper.selectList(any())) + .thenReturn(List.of(taskMenu, duplicateCheckMenu, dataPermission)); + when(permissionMapper.selectByUserId(653L)) + .thenReturn(List.of(grant(653L, 94L), grant(653L, 76L))); + AdminUserEntity operator = user(653L, "admin", 1); + + service.requireShopDataCrawlTaskAccess(operator); + } + + @Test + void shopDataTaskAccessRejectsWithoutDataPermission() { + // 有店铺数据菜单但缺内部数据权限 → 仍需 403(数据权限走超管「数据范围授权」)。 + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + + PermissionMenuEntity taskMenu = menu(94L, null, "admin", 1); + taskMenu.setColumnKey("admin_shop_data_crawl_tasks"); + PermissionMenuEntity duplicateCheckMenu = menu(103L, null, "admin", 2); + duplicateCheckMenu.setColumnKey("admin_shop_data_duplicate_check"); + PermissionMenuEntity dataPermission = menu(76L, null, "internal", 0); + dataPermission.setColumnKey("admin_shop_data_crawl_task_data"); + when(userMapper.selectById(653L)).thenReturn(user(653L, "admin", 1)); + when(menuMapper.selectOne(any())).thenReturn(taskMenu, duplicateCheckMenu, dataPermission); + when(menuMapper.selectById(94L)).thenReturn(taskMenu); + when(menuMapper.selectById(103L)).thenReturn(duplicateCheckMenu); + when(menuMapper.selectById(76L)).thenReturn(dataPermission); + when(menuMapper.selectList(any())) + .thenReturn(List.of(taskMenu, duplicateCheckMenu, dataPermission)); + when(permissionMapper.selectByUserId(653L)).thenReturn(List.of(grant(653L, 103L))); + AdminUserEntity operator = user(653L, "admin", 1); + + assertThatThrownBy(() -> service.requireShopDataCrawlTaskAccess(operator)) + .isInstanceOf(BusinessException.class) + .hasMessage("无权查看店铺数据任务"); + } + + @Test + void shopDataTaskAccessRejectsWithoutAnyShopDataMenu() { + // 两个店铺数据菜单都没有 → 403。 + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + + PermissionMenuEntity taskMenu = menu(94L, null, "admin", 1); + taskMenu.setColumnKey("admin_shop_data_crawl_tasks"); + PermissionMenuEntity duplicateCheckMenu = menu(103L, null, "admin", 2); + duplicateCheckMenu.setColumnKey("admin_shop_data_duplicate_check"); + PermissionMenuEntity dataPermission = menu(76L, null, "internal", 0); + dataPermission.setColumnKey("admin_shop_data_crawl_task_data"); + when(userMapper.selectById(653L)).thenReturn(user(653L, "admin", 1)); + when(menuMapper.selectOne(any())).thenReturn(taskMenu, duplicateCheckMenu, dataPermission); + when(menuMapper.selectById(94L)).thenReturn(taskMenu); + when(menuMapper.selectById(103L)).thenReturn(duplicateCheckMenu); + when(menuMapper.selectById(76L)).thenReturn(dataPermission); + when(menuMapper.selectList(any())) + .thenReturn(List.of(taskMenu, duplicateCheckMenu, dataPermission)); + when(permissionMapper.selectByUserId(653L)).thenReturn(List.of(grant(653L, 5L))); + AdminUserEntity operator = user(653L, "admin", 1); + + assertThatThrownBy(() -> service.requireShopDataCrawlTaskAccess(operator)) + .isInstanceOf(BusinessException.class) + .hasMessage("无权查看店铺数据任务"); + } + + @Test + void shopDataTaskAccessAllowsSuperAdminWithoutAnyGrant() { + // 超管直通,不依赖任何菜单/数据授权记录。 + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + + service.requireShopDataCrawlTaskAccess(user(1L, "super_admin", 1)); + } + @Test void adminScopedReplacementKeepsExistingShopDataPermissionWithoutDuplicate() { // 用户已通过超管「数据范围授权」配过数据权限时不重复插入。