fix: 第四批修复(结果读取并发化/分页钳制/JWT 密钥强校验/凭据按 id)
性能
- 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
This commit is contained in:
+3
-1
@@ -95,7 +95,9 @@ public class AppearancePatentController {
|
|||||||
@RequestParam("user_id") Long userId,
|
@RequestParam("user_id") Long userId,
|
||||||
@Parameter(description = "历史记录条数,默认 50,最大 100", example = "50")
|
@Parameter(description = "历史记录条数,默认 50,最大 100", example = "50")
|
||||||
@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) {
|
@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")
|
@PostMapping("/tasks/progress/batch")
|
||||||
|
|||||||
@@ -26,6 +26,26 @@ public class JwtService {
|
|||||||
|
|
||||||
private volatile SecretKey cachedKey;
|
private volatile SecretKey cachedKey;
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Value("${spring.profiles.active:}")
|
||||||
|
private String activeProfiles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生产 profile 下密钥缺失即拒绝启动(2026-09 全维度审查)。
|
||||||
|
*
|
||||||
|
* <p>此前未配置时静默回退到内置默认密钥——那是公开值,任何人可据此伪造 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() {
|
private SecretKey signingKey() {
|
||||||
SecretKey key = cachedKey;
|
SecretKey key = cachedKey;
|
||||||
if (key == null) {
|
if (key == null) {
|
||||||
|
|||||||
+4
-1
@@ -134,10 +134,13 @@ public class PriceTrackController {
|
|||||||
@RequestParam(value = "shop_name", required = false) List<String> shopNames,
|
@RequestParam(value = "shop_name", required = false) List<String> shopNames,
|
||||||
@Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔;仅在任务国家范围内生效", example = "DE")
|
@Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔;仅在任务国家范围内生效", example = "DE")
|
||||||
@RequestParam(value = "country_code", required = false) List<String> countryCodes) {
|
@RequestParam(value = "country_code", required = false) List<String> countryCodes) {
|
||||||
|
// 上限钳制(2026-09):接口文档写"最大 2000"但此前无实际校验,
|
||||||
|
// 调用方可传超大 page_size 把全量明细一次拉进内存
|
||||||
|
int safePageSize = pageSize == null ? 2000 : Math.min(Math.max(1, pageSize), 2000);
|
||||||
return ApiResponse.success(priceTrackTaskService.getTaskSkipAsinsPaginated(
|
return ApiResponse.success(priceTrackTaskService.getTaskSkipAsinsPaginated(
|
||||||
taskId,
|
taskId,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
safePageSize,
|
||||||
shopNames,
|
shopNames,
|
||||||
countryCodes));
|
countryCodes));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -142,7 +142,9 @@ public class PublishController {
|
|||||||
@RequestParam("user_id") Long userId,
|
@RequestParam("user_id") Long userId,
|
||||||
@Parameter(description = "返回数量上限,默认 50,最大 100", example = "50")
|
@Parameter(description = "返回数量上限,默认 50,最大 100", example = "50")
|
||||||
@RequestParam(value = "limit", defaultValue = "50") Integer limit) {
|
@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}")
|
@DeleteMapping("/tasks/{taskId}")
|
||||||
|
|||||||
+5
-4
@@ -163,13 +163,14 @@ public class ShopManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/credential")
|
@GetMapping("/{id}/credential")
|
||||||
@Operation(summary = "查看店铺明文凭据(管理端)", description = "管理员在后台查看店铺明文凭据;受管理员身份保护(建议后续补充归属/角色细粒度校验)")
|
@Operation(summary = "查看店铺明文凭据(管理端)", description = "管理员在后台查看店铺明文凭据;受管理员身份保护")
|
||||||
public ApiResponse<ShopManageCredentialVo> adminCredential(
|
public ApiResponse<ShopManageCredentialVo> adminCredential(
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
@Parameter(description = "店铺主键 ID") @PathVariable Long id,
|
@Parameter(description = "店铺主键 ID") @PathVariable Long id) {
|
||||||
@Parameter(description = "店铺名") @RequestParam("shop_name") String shopName) {
|
|
||||||
requireOperator(request);
|
requireOperator(request);
|
||||||
return ApiResponse.success(shopManageService.getCredentialByShopName(shopName));
|
// 2026-09 修复:此前忽略路径 id、改用请求参数 shop_name 查询,
|
||||||
|
// 会出现「路径声明的店铺」与「实际读取凭据的店铺」不一致
|
||||||
|
return ApiResponse.success(shopManageService.getCredentialById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Value("${aiimage.security.internal-token:}")
|
@Value("${aiimage.security.internal-token:}")
|
||||||
|
|||||||
+21
@@ -143,6 +143,27 @@ public class ShopManageService {
|
|||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
throw new BusinessException("店铺不存在");
|
throw new BusinessException("店铺不存在");
|
||||||
}
|
}
|
||||||
|
return toCredentialVo(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按主键取明文凭据(2026-09 全维度审查补)。
|
||||||
|
*
|
||||||
|
* <p>管理端 {@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();
|
ShopManageCredentialVo vo = new ShopManageCredentialVo();
|
||||||
vo.setId(entity.getId());
|
vo.setId(entity.getId());
|
||||||
vo.setGroupId(entity.getGroupId());
|
vo.setGroupId(entity.getGroupId());
|
||||||
|
|||||||
+3
-1
@@ -165,10 +165,12 @@ public class ShopMatchController {
|
|||||||
@RequestParam(value = "shop_name", required = false) List<String> shopNames,
|
@RequestParam(value = "shop_name", required = false) List<String> shopNames,
|
||||||
@Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔", example = "DE")
|
@Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔", example = "DE")
|
||||||
@RequestParam(value = "country_code", required = false) List<String> countryCodes) {
|
@RequestParam(value = "country_code", required = false) List<String> countryCodes) {
|
||||||
|
// 上限钳制(2026-09):文档写"最大 2000"但此前无实际校验
|
||||||
|
int safePageSize = pageSize == null ? 2000 : Math.min(Math.max(1, pageSize), 2000);
|
||||||
return ApiResponse.success(shopMatchTaskService.getTaskSkipAsinsPaginated(
|
return ApiResponse.success(shopMatchTaskService.getTaskSkipAsinsPaginated(
|
||||||
taskId,
|
taskId,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
safePageSize,
|
||||||
shopNames,
|
shopNames,
|
||||||
countryCodes));
|
countryCodes));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -31,7 +31,9 @@ public class TaskFileJobController {
|
|||||||
@RequestParam(value = "module_type", required = false) String moduleType,
|
@RequestParam(value = "module_type", required = false) String moduleType,
|
||||||
@RequestParam(value = "task_id", required = false) Long taskId,
|
@RequestParam(value = "task_id", required = false) Long taskId,
|
||||||
@RequestParam(value = "limit", defaultValue = "100") int limit) {
|
@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")
|
@PostMapping("/{jobId}/retry")
|
||||||
|
|||||||
+58
-4
@@ -20,12 +20,21 @@ import java.util.ArrayList;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class TaskResultItemService {
|
public class TaskResultItemService {
|
||||||
|
|
||||||
private final TaskResultItemMapper taskResultItemMapper;
|
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 ObjectMapper objectMapper;
|
||||||
private final TransientPayloadStorageService transientPayloadStorageService;
|
private final TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
|
||||||
@@ -90,11 +99,48 @@ public class TaskResultItemService {
|
|||||||
.likeRight(TaskResultItemEntity::getItemKey, "result:")
|
.likeRight(TaskResultItemEntity::getItemKey, "result:")
|
||||||
.orderByAsc(TaskResultItemEntity::getResultId)
|
.orderByAsc(TaskResultItemEntity::getResultId)
|
||||||
.orderByAsc(TaskResultItemEntity::getId));
|
.orderByAsc(TaskResultItemEntity::getId));
|
||||||
List<T> 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<T> 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<Future<T>> futures = new ArrayList<>(rows.size());
|
||||||
for (TaskResultItemEntity row : rows) {
|
for (TaskResultItemEntity row : rows) {
|
||||||
T value = readPayload(row, clazz);
|
futures.add(snapshotReadPool.submit(() -> readPayload(row, clazz)));
|
||||||
if (value != null) {
|
}
|
||||||
out.add(value);
|
List<T> out = new ArrayList<>(rows.size());
|
||||||
|
for (Future<T> 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;
|
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) {
|
private String firstNonBlank(String primary, String fallback) {
|
||||||
return primary == null || primary.isBlank() ? fallback : primary;
|
return primary == null || primary.isBlank() ? fallback : primary;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user