From 6d6cfcb6ff697c346d1ff328c83b71c7e7b1ca6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Mon, 7 Sep 2026 15:08:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=9D=83=E9=99=90):=20=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E9=87=8D=E5=A4=8D=E6=A3=80=E6=9F=A5=E4=BB=85?= =?UTF-8?q?=E5=8B=BE=E8=8F=9C=E5=8D=95=E6=97=A0=E3=80=8C=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=AE=B0=E5=BD=95=E3=80=8D=E4=BB=8D403?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=A0=A1=E9=AA=8C=E5=8F=A3=E5=BE=84=E4=B8=8E?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=A1=A5=E6=9D=83=E5=AF=B9=E9=BD=90(?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1/=E9=87=8D=E5=A4=8D=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E4=BB=BB=E4=B8=80=E8=8F=9C=E5=8D=95+=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=8D=B3=E5=8F=AF=E6=94=BE=E8=A1=8C,?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E9=AD=8F=E6=8C=AF=E5=B3=B0=E5=8F=8D=E9=A6=88?= =?UTF-8?q?:=20=E5=8F=AA=E5=8B=BE103=E6=9C=AA=E5=8B=BE94=E4=B8=94=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=9D=83=E9=99=90=E5=B7=B2=E8=A1=A5=E4=BB=8D=E8=A2=AB?= =?UTF-8?q?=E6=8B=92);=20=E9=A1=BA=E5=B8=A6=E4=BF=AE=E5=A4=8D=20controller?= =?UTF-8?q?=20=E7=9B=B4=E8=BF=9E=20mapper=20=E6=9E=B6=E6=9E=84=E8=BF=9D?= =?UTF-8?q?=E8=A7=84=20resolveVisibleShopNameKeys=20=E4=B8=8B=E6=B2=89=20S?= =?UTF-8?q?hopDataCrawlAdminTasksService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PermissionMenuService.java | 17 ++- .../AdminShopDataCrawlTasksController.java | 13 +- .../ShopDataCrawlAdminTasksService.java | 15 +++ .../service/PermissionMenuServiceTest.java | 126 ++++++++++++++++++ 4 files changed, 156 insertions(+), 15 deletions(-) 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() { // 用户已通过超管「数据范围授权」配过数据权限时不重复插入。