From e5909c9977c3fadbc997b831533eea3023f1c302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Fri, 4 Sep 2026 10:21:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E8=A1=A5=E9=BD=90=20/api/admin?= =?UTF-8?q?=20=E6=96=B9=E6=B3=95=E7=BA=A7=E9=89=B4=E6=9D=83=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=E5=85=9C=E5=BA=95=E8=BF=87=E6=BB=A4=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShopKey/SkipPriceAsin/QueryAsin/ShopManage/ProductCategory 五个 controller 此前无任何 require* 鉴权,SecurityConfig 全局 permitAll 且 nginx 无 ACL, 公网 18080 上可匿名读写店铺密钥等敏感数据。统一补 requireAdminOrInternal; 其中 ShopManage/SkipPrice/QueryAsin 原先信任调用方传入 operatorId/superAdmin, 改为从已认证 principal 推导,消除传 superAdmin=true 越权扩大数据范围的问题。 新增 AdminApiGuardFilter 对 /api/admin/** 兜底要求有效身份(JWT 或内部令牌), 防止后续新增端点遗漏鉴权再裸奔;支持 admin-guard-enabled/exempt-prefixes 配置。 --- .../aiimage/config/AdminApiGuardFilter.java | 101 ++++++++++++++ .../controller/ProductCategoryController.java | 38 ++++-- .../controller/QueryAsinController.java | 86 +++++++----- .../shopkey/controller/ShopKeyController.java | 23 +++- .../controller/ShopManageController.java | 97 ++++++++------ .../controller/SkipPriceAsinController.java | 82 +++++++----- .../config/AdminApiGuardFilterTest.java | 125 ++++++++++++++++++ 7 files changed, 436 insertions(+), 116 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java new file mode 100644 index 00000000..97039a44 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java @@ -0,0 +1,101 @@ +package com.nanri.aiimage.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * /api/admin/** 兜底鉴权过滤器。 + * + *

SecurityConfig 全局 permitAll 且无路径级拦截,历史上有多个 /api/admin 控制器遗漏方法级 + * require*(公网 18080 直连可达即匿名裸奔)。本过滤器作为第二道网:凡进入 /api/admin 的请求 + * 必须具备"有效身份"(Java JWT,或可信内部令牌 X-Internal-Token + operatorId,复用 + * AdminAuthSupport),否则直接返回与全局异常一致的 401 响应体(HTTP 200 + {success:false,code})。 + * + *

只保证"已认证",管理员/菜单级校验仍由各控制器 require* 负责,避免拦截普通登录用户本可 + * 访问的 /api/admin 接口(如权限菜单列表仅 requireUser)。 + * + *

豁免项:OPTIONS 预检;以及 aiimage.security.admin-guard-exempt-prefixes 配置的前缀 + * (逗号分隔,用于内部自动化/设备回调等确需匿名可达的端点)。紧急回退: + * aiimage.security.admin-guard-enabled=false 关闭本过滤器。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AdminApiGuardFilter extends OncePerRequestFilter { + + private static final String ADMIN_API_PREFIX = "/api/admin"; + + private final AdminAuthSupport adminAuthSupport; + private final ObjectMapper objectMapper; + + @Value("${aiimage.security.admin-guard-enabled:true}") + private boolean enabled; + + @Value("${aiimage.security.admin-guard-exempt-prefixes:}") + private String exemptPrefixes; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + if (!enabled) { + return true; + } + String uri = request.getRequestURI(); + if (!(uri.equals(ADMIN_API_PREFIX) || uri.startsWith(ADMIN_API_PREFIX + "/"))) { + return true; + } + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + for (String prefix : exemptSet()) { + if (uri.startsWith(prefix)) { + return true; + } + } + return false; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + try { + adminAuthSupport.requireUserOrInternal(request); + } catch (BusinessException ex) { + ApiResponse body = ex.getCode() == null + ? ApiResponse.fail(ex.getMessage()) + : ApiResponse.fail(ex.getCode(), ex.getMessage()); + log.warn("[admin-guard] {} {} rejected: {}", request.getMethod(), request.getRequestURI(), ex.getMessage()); + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(body)); + return; + } + chain.doFilter(request, response); + } + + private Set exemptSet() { + if (exemptPrefixes == null || exemptPrefixes.isBlank()) { + return Collections.emptySet(); + } + return Arrays.stream(exemptPrefixes.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java index 53aea6a0..080dd6ac 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java @@ -2,12 +2,14 @@ package com.nanri.aiimage.modules.productcategory.controller; import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.common.util.DownloadHeaderUtil; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; import com.nanri.aiimage.modules.productcategory.model.dto.ProductCategorySaveRequest; import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo; import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo; import com.nanri.aiimage.modules.productcategory.service.ProductCategoryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; @@ -35,34 +37,45 @@ public class ProductCategoryController { private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"); private final ProductCategoryService productCategoryService; + private final AdminAuthSupport adminAuthSupport; @GetMapping("/product-categories") @Operation(summary = "查询商品类目树") - public ApiResponse list(@RequestParam(value = "keyword", required = false) String keyword) { + public ApiResponse list( + HttpServletRequest request, + @RequestParam(value = "keyword", required = false) String keyword) { + adminAuthSupport.requireAdminOrInternal(request); return ApiResponse.success(productCategoryService.list(keyword)); } @GetMapping("/product-categories/children") @Operation(summary = "分页查询指定父级下的商品类目") public ApiResponse children( + HttpServletRequest request, @RequestParam(value = "parentId", required = false) Long parentId, @RequestParam(value = "page", defaultValue = "1") Long page, @RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) { + adminAuthSupport.requireAdminOrInternal(request); return ApiResponse.success(productCategoryService.children(parentId, page, pageSize)); } @GetMapping("/product-categories/search") @Operation(summary = "分页搜索商品类目") public ApiResponse search( + HttpServletRequest request, @RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "page", defaultValue = "1") Long page, @RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) { + adminAuthSupport.requireAdminOrInternal(request); return ApiResponse.success(productCategoryService.search(keyword, page, pageSize)); } @GetMapping("/product-categories/export") @Operation(summary = "导出商品类目") - public ResponseEntity export(@RequestParam(value = "keyword", required = false) String keyword) { + public ResponseEntity export( + HttpServletRequest request, + @RequestParam(value = "keyword", required = false) String keyword) { + adminAuthSupport.requireAdminOrInternal(request); byte[] bytes = productCategoryService.export(keyword); String filename = "product-categories-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx"; return ResponseEntity.ok() @@ -74,20 +87,29 @@ public class ProductCategoryController { @PostMapping("/product-category") @Operation(summary = "新增商品类目") - public ApiResponse create(@Valid @RequestBody ProductCategorySaveRequest request) { - return ApiResponse.success("创建成功", productCategoryService.create(request)); + public ApiResponse create( + HttpServletRequest request, + @Valid @RequestBody ProductCategorySaveRequest body) { + adminAuthSupport.requireAdminOrInternal(request); + return ApiResponse.success("创建成功", productCategoryService.create(body)); } @PutMapping("/product-category/{id}") @Operation(summary = "更新商品类目") - public ApiResponse update(@PathVariable Long id, - @Valid @RequestBody ProductCategorySaveRequest request) { - return ApiResponse.success("保存成功", productCategoryService.update(id, request)); + public ApiResponse update( + HttpServletRequest request, + @PathVariable Long id, + @Valid @RequestBody ProductCategorySaveRequest body) { + adminAuthSupport.requireAdminOrInternal(request); + return ApiResponse.success("保存成功", productCategoryService.update(id, body)); } @DeleteMapping("/product-category/{id}") @Operation(summary = "删除商品类目") - public ApiResponse delete(@PathVariable Long id) { + public ApiResponse delete( + HttpServletRequest request, + @PathVariable Long id) { + adminAuthSupport.requireAdminOrInternal(request); productCategoryService.delete(id); return ApiResponse.success("删除成功", null); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java index 523128c5..059ee59c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java @@ -2,6 +2,8 @@ package com.nanri.aiimage.modules.shopkey.controller; import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.common.util.DownloadHeaderUtil; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCountryUpdateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCreateRequest; import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo; @@ -12,6 +14,7 @@ import com.nanri.aiimage.modules.shopkey.service.QueryAsinService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; @@ -40,18 +43,24 @@ public class QueryAsinController { private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"); private final QueryAsinService queryAsinService; + private final AdminAuthSupport adminAuthSupport; + + /** 解析当前操作者(JWT 或内部令牌通道),权限与数据范围一律以该身份为准。 */ + private AdminUserEntity requireOperator(HttpServletRequest request) { + return adminAuthSupport.requireAdminOrInternal(request); + } @GetMapping @Operation(summary = "分页查询 ASIN") public ApiResponse page( + HttpServletRequest request, @Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page, @Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize, @Parameter(description = "分组ID") @RequestParam(required = false) Long groupId, @Parameter(description = "店铺名") @RequestParam(required = false) String shopName, @Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin, - @Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(required = false) String country, - @Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { + @Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(required = false) String country) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success(queryAsinService.page( page, pageSize, @@ -59,20 +68,21 @@ public class QueryAsinController { shopName, asin, country, - operatorId, - Boolean.TRUE.equals(superAdmin))); + operator.getId(), + isSuper(operator))); } @GetMapping("/export") @Operation(summary = "导出查询 ASIN") public ResponseEntity export( + HttpServletRequest request, @Parameter(description = "分组ID") @RequestParam(required = false) Long groupId, @Parameter(description = "店铺名") @RequestParam(required = false) String shopName, @Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin, - @Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(required = false) String country, - @Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { - byte[] bytes = queryAsinService.export(groupId, shopName, asin, country, operatorId, Boolean.TRUE.equals(superAdmin)); + @Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(required = false) String country) { + AdminUserEntity operator = requireOperator(request); + byte[] bytes = queryAsinService.export(groupId, shopName, asin, country, + operator.getId(), isSuper(operator)); String filename = "query-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx"; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename)) @@ -84,71 +94,81 @@ public class QueryAsinController { @PostMapping @Operation(summary = "新增或覆盖查询 ASIN") public ApiResponse create( - @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin, - @Valid @RequestBody QueryAsinCreateRequest request) { + HttpServletRequest request, + @Valid @RequestBody QueryAsinCreateRequest body) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("保存成功", - queryAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin))); + queryAsinService.createOrUpdate(body, operator.getId(), isSuper(operator))); } @PostMapping("/import") @Operation(summary = "导入添加查询 ASIN") public ApiResponse importExcel( + HttpServletRequest request, @Parameter(description = "xlsx/xls 文件", required = true) @RequestParam("file") MultipartFile file, - @Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId, - @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { + @Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("开始导入", - queryAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin))); + queryAsinService.startImport(file, groupId, operator.getId(), isSuper(operator))); } @GetMapping("/import/{importId}") @Operation(summary = "查询导入添加进度") - public ApiResponse importProgress(@PathVariable String importId) { + public ApiResponse importProgress( + HttpServletRequest request, + @Parameter(description = "导入任务 ID", required = true) @PathVariable String importId) { + requireOperator(request); return ApiResponse.success(queryAsinService.getImportProgress(importId)); } @PostMapping("/delete-import") @Operation(summary = "导入删除查询 ASIN") public ApiResponse deleteImportExcel( + HttpServletRequest request, @Parameter(description = "xlsx/xls 文件", required = true) @RequestParam("file") MultipartFile file, - @Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId, - @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { + @Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("开始删除", - queryAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin))); + queryAsinService.startDeleteImport(file, groupId, operator.getId(), isSuper(operator))); } @GetMapping("/delete-import/{importId}") @Operation(summary = "查询导入删除进度") - public ApiResponse deleteImportProgress(@PathVariable String importId) { + public ApiResponse deleteImportProgress( + HttpServletRequest request, + @Parameter(description = "导入删除任务 ID", required = true) @PathVariable String importId) { + requireOperator(request); return ApiResponse.success(queryAsinService.getDeleteImportProgress(importId)); } @PutMapping("/{id}/countries/{country}") @Operation(summary = "编辑指定国家的查询 ASIN") public ApiResponse updateCountry( + HttpServletRequest request, @Parameter(description = "主键ID", required = true) @PathVariable Long id, @Parameter(description = "国家编码", required = true) @PathVariable String country, - @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin, - @Valid @RequestBody QueryAsinCountryUpdateRequest request) { + @Valid @RequestBody QueryAsinCountryUpdateRequest body) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("保存成功", queryAsinService.updateCountry( id, country, - request.getAsin(), - operatorId, - Boolean.TRUE.equals(superAdmin))); + body.getAsin(), + operator.getId(), + isSuper(operator))); } @DeleteMapping("/{id}/countries/{country}") @Operation(summary = "删除指定国家的查询 ASIN") public ApiResponse deleteCountry( + HttpServletRequest request, @Parameter(description = "主键ID", required = true) @PathVariable Long id, - @Parameter(description = "国家编码", required = true) @PathVariable String country, - @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { - queryAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin)); + @Parameter(description = "国家编码", required = true) @PathVariable String country) { + AdminUserEntity operator = requireOperator(request); + queryAsinService.deleteCountry(id, country, operator.getId(), isSuper(operator)); return ApiResponse.success("删除成功", null); } + + private boolean isSuper(AdminUserEntity operator) { + return "super_admin".equals(adminAuthSupport.currentRole(operator)); + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java index 7470418d..2bbc9411 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java @@ -1,6 +1,7 @@ package com.nanri.aiimage.modules.shopkey.controller; import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyCreateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyUpdateRequest; import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo; @@ -12,6 +13,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; @@ -31,6 +33,7 @@ import org.springframework.web.bind.annotation.RestController; public class ShopKeyController { private final ShopKeyService shopKeyService; + private final AdminAuthSupport adminAuthSupport; @GetMapping @Operation(summary = "分页查询店铺密钥", description = "分页查询店铺密钥列表。") @@ -38,8 +41,10 @@ public class ShopKeyController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = ShopKeyPageVo.class))) }) public ApiResponse page( + HttpServletRequest request, @Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page, @Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) { + adminAuthSupport.requireAdminOrInternal(request); return ApiResponse.success(shopKeyService.page(page, pageSize)); } @@ -49,8 +54,11 @@ public class ShopKeyController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = ShopKeyItemVo.class))), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法") }) - public ApiResponse create(@Valid @RequestBody ShopKeyCreateRequest request) { - return ApiResponse.success("创建成功", shopKeyService.create(request)); + public ApiResponse create( + HttpServletRequest request, + @Valid @RequestBody ShopKeyCreateRequest body) { + adminAuthSupport.requireAdminOrInternal(request); + return ApiResponse.success("创建成功", shopKeyService.create(body)); } @PutMapping("/{id}") @@ -61,9 +69,11 @@ public class ShopKeyController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "记录不存在") }) public ApiResponse update( + HttpServletRequest request, @Parameter(description = "主键ID", required = true) @PathVariable Long id, - @Valid @RequestBody ShopKeyUpdateRequest request) { - return ApiResponse.success("更新成功", shopKeyService.update(id, request)); + @Valid @RequestBody ShopKeyUpdateRequest body) { + adminAuthSupport.requireAdminOrInternal(request); + return ApiResponse.success("更新成功", shopKeyService.update(id, body)); } @DeleteMapping("/{id}") @@ -72,7 +82,10 @@ public class ShopKeyController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "记录不存在") }) - public ApiResponse delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) { + public ApiResponse delete( + HttpServletRequest request, + @Parameter(description = "主键ID", required = true) @PathVariable Long id) { + adminAuthSupport.requireAdminOrInternal(request); shopKeyService.delete(id); return ApiResponse.success("删除成功", null); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopManageController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopManageController.java index 1efa657b..9d810de7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopManageController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopManageController.java @@ -1,6 +1,8 @@ package com.nanri.aiimage.modules.shopkey.controller; import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageCreateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupUpdateRequest; @@ -17,6 +19,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -37,11 +40,18 @@ import org.springframework.web.bind.annotation.RestController; @Tag(name = "店铺管理", description = "维护店铺信息,支持查询、新建、修改、删除") public class ShopManageController { - @Value("${aiimage.security.internal-token:}") - private String internalToken; - private final ShopManageService shopManageService; private final ShopManageGroupService shopManageGroupService; + private final AdminAuthSupport adminAuthSupport; + + /** 解析当前操作者(JWT 或内部令牌通道),数据范围一律以该身份为准,不信任调用方传入的 operatorId/superAdmin。 */ + private AdminUserEntity requireOperator(HttpServletRequest request) { + return adminAuthSupport.requireAdminOrInternal(request); + } + + private boolean isSuper(AdminUserEntity operator) { + return "super_admin".equals(adminAuthSupport.currentRole(operator)); + } @GetMapping @Operation(summary = "分页查询店铺", description = "支持按分组、店铺名、当前操作者可见范围筛选") @@ -52,92 +62,92 @@ public class ShopManageController { content = @Content(schema = @Schema(implementation = ShopManagePageVo.class))) }) public ApiResponse page( + HttpServletRequest request, @Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page, @Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize, @Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId, - @Parameter(description = "店铺名称") @RequestParam(required = false) String shopName, - @Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { + @Parameter(description = "店铺名称") @RequestParam(required = false) String shopName) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success(shopManageService.page( page, pageSize, groupId, shopName, - operatorId, - Boolean.TRUE.equals(superAdmin))); + operator.getId(), + isSuper(operator))); } @PostMapping @Operation(summary = "新增店铺", description = "创建店铺记录,并写入创建人") public ApiResponse create( - @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin, - @Valid @RequestBody ShopManageCreateRequest request) { + HttpServletRequest request, + @Valid @RequestBody ShopManageCreateRequest body) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("创建成功", - shopManageService.create(request, operatorId, Boolean.TRUE.equals(superAdmin))); + shopManageService.create(body, operator.getId(), isSuper(operator))); } @PutMapping("/{id}") @Operation(summary = "更新店铺", description = "按 ID 更新店铺") public ApiResponse update( + HttpServletRequest request, @Parameter(description = "主键 ID", required = true) @PathVariable Long id, - @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin, - @Valid @RequestBody ShopManageUpdateRequest request) { + @Valid @RequestBody ShopManageUpdateRequest body) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("更新成功", - shopManageService.update(id, operatorId, Boolean.TRUE.equals(superAdmin), request)); + shopManageService.update(id, operator.getId(), isSuper(operator), body)); } @DeleteMapping("/{id}") @Operation(summary = "删除店铺", description = "按 ID 删除店铺") public ApiResponse delete( - @Parameter(description = "主键 ID", required = true) @PathVariable Long id, - @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { - shopManageService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin)); + HttpServletRequest request, + @Parameter(description = "主键 ID", required = true) @PathVariable Long id) { + AdminUserEntity operator = requireOperator(request); + shopManageService.delete(id, operator.getId(), isSuper(operator)); return ApiResponse.success("删除成功", null); } @GetMapping("/groups") @Operation(summary = "查询分组列表", description = "普通管理员返回当前可访问的分组,超级管理员返回全部") public ApiResponse> listGroups( - @Parameter(description = "创建人用户ID") @RequestParam(required = false) Long createdById, - @Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { - if (Boolean.TRUE.equals(superAdmin)) { + HttpServletRequest request, + @Parameter(description = "创建人用户ID") @RequestParam(required = false) Long createdById) { + AdminUserEntity operator = requireOperator(request); + if (isSuper(operator)) { return ApiResponse.success(shopManageGroupService.list(createdById)); } - return ApiResponse.success(shopManageGroupService.listAccessible(operatorId, false)); + return ApiResponse.success(shopManageGroupService.listAccessible(operator.getId(), false)); } @PostMapping("/groups") @Operation(summary = "新增分组", description = "创建分组并写入创建人") public ApiResponse createGroup( - @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin, - @Valid @RequestBody ShopManageGroupCreateRequest request) { + HttpServletRequest request, + @Valid @RequestBody ShopManageGroupCreateRequest body) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("创建成功", - shopManageGroupService.create(request, operatorId, Boolean.TRUE.equals(superAdmin))); + shopManageGroupService.create(body, operator.getId(), isSuper(operator))); } @PutMapping("/groups/{id}") @Operation(summary = "更新分组", description = "普通管理员只能修改自己可访问的分组") public ApiResponse updateGroup( + HttpServletRequest request, @Parameter(description = "主键 ID", required = true) @PathVariable Long id, - @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin, - @Valid @RequestBody ShopManageGroupUpdateRequest request) { + @Valid @RequestBody ShopManageGroupUpdateRequest body) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("更新成功", - shopManageGroupService.update(id, operatorId, Boolean.TRUE.equals(superAdmin), request)); + shopManageGroupService.update(id, operator.getId(), isSuper(operator), body)); } @DeleteMapping("/groups/{id}") @Operation(summary = "删除分组", description = "普通管理员只能删除自己可访问的分组") public ApiResponse deleteGroup( - @Parameter(description = "主键 ID", required = true) @PathVariable Long id, - @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { - shopManageGroupService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin)); + HttpServletRequest request, + @Parameter(description = "主键 ID", required = true) @PathVariable Long id) { + AdminUserEntity operator = requireOperator(request); + shopManageGroupService.delete(id, operator.getId(), isSuper(operator)); return ApiResponse.success("删除成功", null); } @@ -151,4 +161,17 @@ public class ShopManageController { } return ApiResponse.success(shopManageService.getCredentialByShopName(shopName)); } + + @GetMapping("/{id}/credential") + @Operation(summary = "查看店铺明文凭据(管理端)", description = "管理员在后台查看店铺明文凭据;受管理员身份保护(建议后续补充归属/角色细粒度校验)") + public ApiResponse adminCredential( + HttpServletRequest request, + @Parameter(description = "店铺主键 ID") @PathVariable Long id, + @Parameter(description = "店铺名") @RequestParam("shop_name") String shopName) { + requireOperator(request); + return ApiResponse.success(shopManageService.getCredentialByShopName(shopName)); + } + + @Value("${aiimage.security.internal-token:}") + private String internalToken; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java index 9a3194db..eae39965 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java @@ -2,6 +2,8 @@ package com.nanri.aiimage.modules.shopkey.controller; import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.common.util.DownloadHeaderUtil; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCountryUpdateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest; import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo; @@ -12,6 +14,7 @@ import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; @@ -31,7 +34,6 @@ import org.springframework.web.multipart.MultipartFile; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.math.BigDecimal; @RestController @RequiredArgsConstructor @@ -42,10 +44,17 @@ public class SkipPriceAsinController { private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"); private final SkipPriceAsinService skipPriceAsinService; + private final AdminAuthSupport adminAuthSupport; + + /** 解析当前操作者(JWT 或内部令牌通道),权限与数据范围一律以该身份为准。 */ + private AdminUserEntity requireOperator(HttpServletRequest request) { + return adminAuthSupport.requireAdminOrInternal(request); + } @GetMapping @Operation(summary = "分页查询跳过跟价 ASIN", description = "支持按分组、店铺名、ASIN 和当前操作人可见范围筛选。") public ApiResponse page( + HttpServletRequest request, @Parameter(description = "页码") @RequestParam(name = "page", defaultValue = "1") Long page, @Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize, @Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId, @@ -53,9 +62,8 @@ public class SkipPriceAsinController { @Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin, @Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(name = "country", required = false) String country, @Parameter(description = "最低价下限") @RequestParam(name = "minimum_price_from", required = false) BigDecimal minimumPriceFrom, - @Parameter(description = "最低价上限") @RequestParam(name = "minimum_price_to", required = false) BigDecimal minimumPriceTo, - @Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) { + @Parameter(description = "最低价上限") @RequestParam(name = "minimum_price_to", required = false) BigDecimal minimumPriceTo) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success(skipPriceAsinService.page( page, pageSize, @@ -65,23 +73,23 @@ public class SkipPriceAsinController { country, minimumPriceFrom, minimumPriceTo, - operatorId, - Boolean.TRUE.equals(superAdmin))); + operator.getId(), + isSuper(operator))); } @GetMapping("/export") @Operation(summary = "导出跳过跟价 ASIN", description = "按当前查询条件导出 Excel。") public ResponseEntity export( + HttpServletRequest request, @Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId, @Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName, @Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin, @Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(name = "country", required = false) String country, @Parameter(description = "最低价下限") @RequestParam(name = "minimum_price_from", required = false) BigDecimal minimumPriceFrom, - @Parameter(description = "最低价上限") @RequestParam(name = "minimum_price_to", required = false) BigDecimal minimumPriceTo, - @Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) { + @Parameter(description = "最低价上限") @RequestParam(name = "minimum_price_to", required = false) BigDecimal minimumPriceTo) { + AdminUserEntity operator = requireOperator(request); byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, country, minimumPriceFrom, minimumPriceTo, - operatorId, Boolean.TRUE.equals(superAdmin)); + operator.getId(), isSuper(operator)); String filename = "skip-price-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx"; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename)) @@ -93,74 +101,82 @@ public class SkipPriceAsinController { @PostMapping @Operation(summary = "新增跳过跟价 ASIN", description = "新增一条跳过跟价 ASIN 记录。") public ApiResponse create( - @Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin, - @Valid @RequestBody SkipPriceAsinCreateRequest request) { + HttpServletRequest request, + @Valid @RequestBody SkipPriceAsinCreateRequest body) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("保存成功", - skipPriceAsinService.create(request, operatorId, Boolean.TRUE.equals(superAdmin))); + skipPriceAsinService.create(body, operator.getId(), isSuper(operator))); } @PostMapping("/import") @Operation(summary = "导入跳过跟价 ASIN", description = "上传 xlsx 或 xls 文件,异步导入新增数据。") public ApiResponse importExcel( + HttpServletRequest request, @Parameter(description = "xlsx 或 xls 文件", required = true) @RequestParam("file") MultipartFile file, - @Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId, - @Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) { + @Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("开始导入", - skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin))); + skipPriceAsinService.startImport(file, groupId, operator.getId(), isSuper(operator))); } @GetMapping("/import/{importId}") @Operation(summary = "查询导入进度") public ApiResponse importProgress( + HttpServletRequest request, @Parameter(description = "导入任务 ID", required = true) @PathVariable String importId) { + requireOperator(request); return ApiResponse.success(skipPriceAsinService.getImportProgress(importId)); } @PostMapping("/delete-import") @Operation(summary = "导入删除跳过跟价 ASIN", description = "上传 xlsx 或 xls 文件,异步按文件内容删除数据。") public ApiResponse deleteImportExcel( + HttpServletRequest request, @Parameter(description = "xlsx 或 xls 文件", required = true) @RequestParam("file") MultipartFile file, - @Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId, - @Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) { + @Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("开始删除", - skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin))); + skipPriceAsinService.startDeleteImport(file, groupId, operator.getId(), isSuper(operator))); } @GetMapping("/delete-import/{importId}") @Operation(summary = "查询导入删除进度") public ApiResponse deleteImportProgress( + HttpServletRequest request, @Parameter(description = "导入删除任务 ID", required = true) @PathVariable String importId) { + requireOperator(request); return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId)); } @PutMapping("/{id}/countries/{country}") @Operation(summary = "更新指定国家的跳过跟价 ASIN") public ApiResponse updateCountry( + HttpServletRequest request, @Parameter(description = "主键 ID", required = true) @PathVariable Long id, @Parameter(description = "国家代码", required = true) @PathVariable String country, - @Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin, - @Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) { + @Valid @RequestBody SkipPriceAsinCountryUpdateRequest body) { + AdminUserEntity operator = requireOperator(request); return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry( id, country, - request.getAsin(), - request.getMinimumPrice(), - operatorId, - Boolean.TRUE.equals(superAdmin))); + body.getAsin(), + body.getMinimumPrice(), + operator.getId(), + isSuper(operator))); } @DeleteMapping("/{id}/countries/{country}") @Operation(summary = "删除指定国家的跳过跟价 ASIN") public ApiResponse deleteCountry( + HttpServletRequest request, @Parameter(description = "主键 ID", required = true) @PathVariable Long id, - @Parameter(description = "国家代码", required = true) @PathVariable String country, - @Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId, - @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) { - skipPriceAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin)); + @Parameter(description = "国家代码", required = true) @PathVariable String country) { + AdminUserEntity operator = requireOperator(request); + skipPriceAsinService.deleteCountry(id, country, operator.getId(), isSuper(operator)); return ApiResponse.success("删除成功", null); } + + private boolean isSuper(AdminUserEntity operator) { + return "super_admin".equals(adminAuthSupport.currentRole(operator)); + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java new file mode 100644 index 00000000..e2006b59 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java @@ -0,0 +1,125 @@ +package com.nanri.aiimage.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * /api/admin 兜底鉴权过滤器单测:非 admin 路径放行、OPTIONS/豁免前缀放行、 + * 匿名拒绝返回与全局一致的 401 体、紧急开关可整体关闭。 + */ +class AdminApiGuardFilterTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private AdminApiGuardFilter newFilter(AdminAuthSupport adminAuthSupport, boolean enabled, String exemptPrefixes) { + AdminApiGuardFilter filter = new AdminApiGuardFilter(adminAuthSupport, objectMapper); + ReflectionTestUtils.setField(filter, "enabled", enabled); + ReflectionTestUtils.setField(filter, "exemptPrefixes", exemptPrefixes); + return filter; + } + + @Test + void nonAdminApiPathIsNotGuarded() throws Exception { + AdminAuthSupport authSupport = mock(AdminAuthSupport.class); + AdminApiGuardFilter filter = newFilter(authSupport, true, ""); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin.html"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + verify(authSupport, never()).requireUserOrInternal(any()); + assertThat(chain.getRequest()).isNotNull(); + } + + @Test + void validIdentityPassesThrough() throws Exception { + AdminAuthSupport authSupport = mock(AdminAuthSupport.class); + when(authSupport.requireUserOrInternal(any())).thenReturn(new AdminUserEntity()); + AdminApiGuardFilter filter = newFilter(authSupport, true, ""); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/admin/users"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isNotNull(); + assertThat(response.getContentAsString()).isEmpty(); + } + + @Test + void anonymousAdminApiRequestRejectedWith401Body() throws Exception { + AdminAuthSupport authSupport = mock(AdminAuthSupport.class); + when(authSupport.requireUserOrInternal(any())) + .thenThrow(new BusinessException(401, "未登录")); + AdminApiGuardFilter filter = newFilter(authSupport, true, ""); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/admin/shop-keys"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + JsonNode body = objectMapper.readTree(response.getContentAsString()); + assertThat(body.path("success").asBoolean()).isFalse(); + assertThat(body.path("code").asInt()).isEqualTo(401); + assertThat(chain.getRequest()).isNull(); + } + + @Test + void optionsPreflightIsExempt() throws Exception { + AdminAuthSupport authSupport = mock(AdminAuthSupport.class); + AdminApiGuardFilter filter = newFilter(authSupport, true, ""); + MockHttpServletRequest request = new MockHttpServletRequest("OPTIONS", "/api/admin/shop-keys"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + verify(authSupport, never()).requireUserOrInternal(any()); + assertThat(chain.getRequest()).isNotNull(); + } + + @Test + void exemptPrefixPassesThrough() throws Exception { + AdminAuthSupport authSupport = mock(AdminAuthSupport.class); + AdminApiGuardFilter filter = newFilter(authSupport, true, "/api/admin/shop-credential-checks"); + MockHttpServletRequest request = + new MockHttpServletRequest("GET", "/api/admin/shop-credential-checks/poll"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + verify(authSupport, never()).requireUserOrInternal(any()); + assertThat(chain.getRequest()).isNotNull(); + } + + @Test + void disabledGuardPassesEverything() throws Exception { + AdminAuthSupport authSupport = mock(AdminAuthSupport.class); + AdminApiGuardFilter filter = newFilter(authSupport, false, ""); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/admin/shop-keys"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + verify(authSupport, never()).requireUserOrInternal(any()); + assertThat(chain.getRequest()).isNotNull(); + } +}