From ccce03b4d4ec5121e7172eb7d969d15ec2150cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Mon, 14 Sep 2026 05:02:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC=E5=9B=9B=E6=89=B9=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=EF=BC=88=E7=BB=93=E6=9E=9C=E8=AF=BB=E5=8F=96=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E5=8C=96/=E5=88=86=E9=A1=B5=E9=92=B3=E5=88=B6/JWT=20?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E5=BC=BA=E6=A0=A1=E9=AA=8C/=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E6=8C=89=20id=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 性能 - listResultSnapshots 对指针化载荷改并发读取(有界池 + 保序汇总):此前逐行同步对象存储读, 500 行结果文件生成要多花数十秒。仅在确有指针行时才走池——内联 JSON 直接串行读, 避免为本地读取引入调度抖动(性能基准测试容差会被影响) 安全/正确性 - 管理端 GET /{id}/credential 改为按路径 id 查询(此前忽略 id、改用 shop_name, 会出现「路径声明的店铺」与「实际读取凭据的店铺」不一致);凭据 VO 构造抽公共方法 - server profile 下 JWT 密钥缺失即拒绝启动:此前静默回退到公开默认值(等同无防护, 任何人可伪造 token),只留 warn 日志拦不住发布事故;本地/测试 profile 保持宽松 边界 - 分页/条数参数补齐上限钳制(文档早已声明"最大 N"但无校验): PriceTrack/ShopMatch 的 page_size → 2000;TaskFileJob 的 limit → 1000; AppearancePatent/Publish 的 limit → 100 --- .../AppearancePatentController.java | 4 +- .../modules/auth/service/JwtService.java | 20 ++++++ .../controller/PriceTrackController.java | 5 +- .../publish/controller/PublishController.java | 4 +- .../controller/ShopManageController.java | 9 +-- .../shopkey/service/ShopManageService.java | 21 +++++++ .../controller/ShopMatchController.java | 4 +- .../controller/TaskFileJobController.java | 4 +- .../task/service/TaskResultItemService.java | 62 +++++++++++++++++-- 9 files changed, 120 insertions(+), 13 deletions(-) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java index 491cdbe4..39050fff 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java @@ -95,7 +95,9 @@ public class AppearancePatentController { @RequestParam("user_id") Long userId, @Parameter(description = "历史记录条数,默认 50,最大 100", example = "50") @RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) { - return ApiResponse.success(service.history(userId, limit)); + // 上限钳制(2026-09):文档写"最大 100"但此前无实际校验 + int safeLimit = limit == null ? 100 : Math.min(Math.max(1, limit), 100); + return ApiResponse.success(service.history(userId, safeLimit)); } @PostMapping("/tasks/progress/batch") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java index 52937ee0..f93b170d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java @@ -26,6 +26,26 @@ public class JwtService { private volatile SecretKey cachedKey; + @org.springframework.beans.factory.annotation.Value("${spring.profiles.active:}") + private String activeProfiles; + + /** + * 生产 profile 下密钥缺失即拒绝启动(2026-09 全维度审查)。 + * + *

此前未配置时静默回退到内置默认密钥——那是公开值,任何人可据此伪造 token; + * 一次 env 丢失就会让全站鉴权形同虚设,只留一行 warn 日志不足以拦住发布。 + * 本地/测试 profile 保持宽松(否则开发无法启动)。 + */ + @jakarta.annotation.PostConstruct + void requireSecretInServerProfile() { + boolean serverProfile = activeProfiles != null && activeProfiles.contains("server"); + if (serverProfile && (props.getJwtSecret() == null || props.getJwtSecret().isBlank())) { + throw new IllegalStateException( + "server profile 下必须配置 aiimage.auth.jwt-secret(环境变量 AIIMAGE_JWT_SECRET)——" + + "缺失时会回退到公开的默认密钥,token 可被任意伪造"); + } + } + private SecretKey signingKey() { SecretKey key = cachedKey; if (key == null) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java index eeee7048..d57e0090 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java @@ -134,10 +134,13 @@ public class PriceTrackController { @RequestParam(value = "shop_name", required = false) List shopNames, @Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔;仅在任务国家范围内生效", example = "DE") @RequestParam(value = "country_code", required = false) List countryCodes) { + // 上限钳制(2026-09):接口文档写"最大 2000"但此前无实际校验, + // 调用方可传超大 page_size 把全量明细一次拉进内存 + int safePageSize = pageSize == null ? 2000 : Math.min(Math.max(1, pageSize), 2000); return ApiResponse.success(priceTrackTaskService.getTaskSkipAsinsPaginated( taskId, page, - pageSize, + safePageSize, shopNames, countryCodes)); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java index e606f440..bf5b81b4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java @@ -142,7 +142,9 @@ public class PublishController { @RequestParam("user_id") Long userId, @Parameter(description = "返回数量上限,默认 50,最大 100", example = "50") @RequestParam(value = "limit", defaultValue = "50") Integer limit) { - return ApiResponse.success(publishTaskService.history(userId, limit)); + // 上限钳制(2026-09):文档写"最多返回 100 条"但此前无实际校验 + int safeLimit = limit == null ? 100 : Math.min(Math.max(1, limit), 100); + return ApiResponse.success(publishTaskService.history(userId, safeLimit)); } @DeleteMapping("/tasks/{taskId}") 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 9d810de7..6efba37f 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 @@ -163,13 +163,14 @@ public class ShopManageController { } @GetMapping("/{id}/credential") - @Operation(summary = "查看店铺明文凭据(管理端)", description = "管理员在后台查看店铺明文凭据;受管理员身份保护(建议后续补充归属/角色细粒度校验)") + @Operation(summary = "查看店铺明文凭据(管理端)", description = "管理员在后台查看店铺明文凭据;受管理员身份保护") public ApiResponse adminCredential( HttpServletRequest request, - @Parameter(description = "店铺主键 ID") @PathVariable Long id, - @Parameter(description = "店铺名") @RequestParam("shop_name") String shopName) { + @Parameter(description = "店铺主键 ID") @PathVariable Long id) { requireOperator(request); - return ApiResponse.success(shopManageService.getCredentialByShopName(shopName)); + // 2026-09 修复:此前忽略路径 id、改用请求参数 shop_name 查询, + // 会出现「路径声明的店铺」与「实际读取凭据的店铺」不一致 + return ApiResponse.success(shopManageService.getCredentialById(id)); } @Value("${aiimage.security.internal-token:}") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java index dcbdbffa..c1c3a2f3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java @@ -143,6 +143,27 @@ public class ShopManageService { if (entity == null) { throw new BusinessException("店铺不存在"); } + return toCredentialVo(entity); + } + + /** + * 按主键取明文凭据(2026-09 全维度审查补)。 + * + *

管理端 {@code GET /{id}/credential} 此前忽略路径里的 id、改用 shop_name 查询—— + * 路径声明的对象与实际读取的对象可能不一致(同名的第一条被读写,审计日志却记成另一个 id)。 + */ + public ShopManageCredentialVo getCredentialById(Long id) { + if (id == null || id <= 0) { + throw new BusinessException("店铺 ID 不合法"); + } + ShopManageEntity entity = shopManageMapper.selectById(id); + if (entity == null) { + throw new BusinessException("店铺不存在"); + } + return toCredentialVo(entity); + } + + private ShopManageCredentialVo toCredentialVo(ShopManageEntity entity) { ShopManageCredentialVo vo = new ShopManageCredentialVo(); vo.setId(entity.getId()); vo.setGroupId(entity.getGroupId()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java index 8335c24f..943fef38 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java @@ -165,10 +165,12 @@ public class ShopMatchController { @RequestParam(value = "shop_name", required = false) List shopNames, @Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔", example = "DE") @RequestParam(value = "country_code", required = false) List countryCodes) { + // 上限钳制(2026-09):文档写"最大 2000"但此前无实际校验 + int safePageSize = pageSize == null ? 2000 : Math.min(Math.max(1, pageSize), 2000); return ApiResponse.success(shopMatchTaskService.getTaskSkipAsinsPaginated( taskId, page, - pageSize, + safePageSize, shopNames, countryCodes)); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskFileJobController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskFileJobController.java index 3217cb27..ef3c56b1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskFileJobController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskFileJobController.java @@ -31,7 +31,9 @@ public class TaskFileJobController { @RequestParam(value = "module_type", required = false) String moduleType, @RequestParam(value = "task_id", required = false) Long taskId, @RequestParam(value = "limit", defaultValue = "100") int limit) { - return ApiResponse.success(taskFileJobService.listJobs(status, moduleType, taskId, limit)); + // 上限钳制(2026-09):该运维端点此前对 limit 无校验 + return ApiResponse.success(taskFileJobService.listJobs(status, moduleType, taskId, + Math.min(Math.max(1, limit), 1000))); } @PostMapping("/{jobId}/retry") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java index bb1de696..12ba620e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java @@ -20,12 +20,21 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; @Service @RequiredArgsConstructor public class TaskResultItemService { private final TaskResultItemMapper taskResultItemMapper; + /** + * 结果快照并发读取池(2026-09):readPayload 对指针化载荷是一次同步对象存储读, + * 串行读取会让结果文件生成多花数十秒。有界队列 + daemon 线程,随进程退出。 + */ + private final ExecutorService snapshotReadPool = + com.nanri.aiimage.common.util.ThreadPools.boundedFixed("task-result-read", 8); private final ObjectMapper objectMapper; private final TransientPayloadStorageService transientPayloadStorageService; @@ -90,11 +99,48 @@ public class TaskResultItemService { .likeRight(TaskResultItemEntity::getItemKey, "result:") .orderByAsc(TaskResultItemEntity::getResultId) .orderByAsc(TaskResultItemEntity::getId)); - List out = new ArrayList<>(); + if (rows.isEmpty()) { + return List.of(); + } + // 2026-09 优化:readPayload 对**指针化**载荷才是一次同步对象存储读(10~100ms), + // 串行 N 行会让结果文件生成多花数十秒。 + // 只在确有指针行时才走线程池:内联 JSON(本地/mock 场景)直接串行读, + // 避免为本地读取引入线程池调度抖动(基准测试曾因此波动超容差)。 + boolean hasRemotePointer = rows.stream().anyMatch(this::isPointerPayload); + if (!hasRemotePointer) { + List inlineOut = new ArrayList<>(rows.size()); + for (TaskResultItemEntity row : rows) { + T value = readPayload(row, clazz); + if (value != null) { + inlineOut.add(value); + } + } + return inlineOut; + } + if (rows.size() == 1) { + T value = readPayload(rows.get(0), clazz); + return value == null ? List.of() : List.of(value); + } + List> futures = new ArrayList<>(rows.size()); for (TaskResultItemEntity row : rows) { - T value = readPayload(row, clazz); - if (value != null) { - out.add(value); + futures.add(snapshotReadPool.submit(() -> readPayload(row, clazz))); + } + List out = new ArrayList<>(rows.size()); + for (Future future : futures) { + try { + T value = future.get(); + if (value != null) { + out.add(value); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new BusinessException("读取任务结果明细被中断"); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof BusinessException businessException) { + throw businessException; + } + throw new BusinessException("读取任务结果明细失败", cause); } } return out; @@ -368,6 +414,14 @@ public class TaskResultItemService { } } + /** 载荷值是否是指向对象存储的指针(决定是否需要走并发远程读)。 */ + private boolean isPointerPayload(TaskResultItemEntity row) { + if (row == null) { + return false; + } + return transientPayloadStorageService.extractPointer(row.getPayloadJson()) != null; + } + private String firstNonBlank(String primary, String fallback) { return primary == null || primary.isBlank() ? fallback : primary; }