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; }