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