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();
+ }
+}