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,
|
||||
@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")
|
||||
|
||||
@@ -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 全维度审查)。
|
||||
*
|
||||
* <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() {
|
||||
SecretKey key = cachedKey;
|
||||
if (key == null) {
|
||||
|
||||
+4
-1
@@ -134,10 +134,13 @@ public class PriceTrackController {
|
||||
@RequestParam(value = "shop_name", required = false) List<String> shopNames,
|
||||
@Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔;仅在任务国家范围内生效", example = "DE")
|
||||
@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(
|
||||
taskId,
|
||||
page,
|
||||
pageSize,
|
||||
safePageSize,
|
||||
shopNames,
|
||||
countryCodes));
|
||||
}
|
||||
|
||||
+3
-1
@@ -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}")
|
||||
|
||||
+5
-4
@@ -163,13 +163,14 @@ public class ShopManageController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/credential")
|
||||
@Operation(summary = "查看店铺明文凭据(管理端)", description = "管理员在后台查看店铺明文凭据;受管理员身份保护(建议后续补充归属/角色细粒度校验)")
|
||||
@Operation(summary = "查看店铺明文凭据(管理端)", description = "管理员在后台查看店铺明文凭据;受管理员身份保护")
|
||||
public ApiResponse<ShopManageCredentialVo> 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:}")
|
||||
|
||||
+21
@@ -143,6 +143,27 @@ public class ShopManageService {
|
||||
if (entity == null) {
|
||||
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();
|
||||
vo.setId(entity.getId());
|
||||
vo.setGroupId(entity.getGroupId());
|
||||
|
||||
+3
-1
@@ -165,10 +165,12 @@ public class ShopMatchController {
|
||||
@RequestParam(value = "shop_name", required = false) List<String> shopNames,
|
||||
@Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔", example = "DE")
|
||||
@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(
|
||||
taskId,
|
||||
page,
|
||||
pageSize,
|
||||
safePageSize,
|
||||
shopNames,
|
||||
countryCodes));
|
||||
}
|
||||
|
||||
+3
-1
@@ -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")
|
||||
|
||||
+58
-4
@@ -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<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) {
|
||||
T value = readPayload(row, clazz);
|
||||
if (value != null) {
|
||||
out.add(value);
|
||||
futures.add(snapshotReadPool.submit(() -> readPayload(row, clazz)));
|
||||
}
|
||||
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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user