task-42: 限制采集解析的文件大小、最大行数和单 chunk 行数
新增 CollectDataParseLimits 组件并在 CollectDataService 解析/回传路径 接入:源文件大小(默认 50MB)、累计解析行数(默认 50000)、单 chunk 回传行数(默认 5000)超限即拒绝,配置经 aiimage.collect-data.* 环境变量 可调、0/负值回退默认。8 个用例覆盖正常/批量/幂等/空/单元素/边界超限/ 非法配置/失败后可恢复路径,mvn 全量测试通过。
This commit is contained in:
+45
@@ -33,6 +33,7 @@ import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataSubmitResultVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskDetailVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskSummaryVo;
|
||||
import com.nanri.aiimage.modules.collectdata.util.CollectDataParseLimits;
|
||||
import com.nanri.aiimage.modules.dedupe.mapper.DedupeTotalDataMapper;
|
||||
import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper;
|
||||
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
|
||||
@@ -138,6 +139,46 @@ public class CollectDataService {
|
||||
@Value("${aiimage.collect-data.stale-timeout-minutes:30}")
|
||||
private long staleTimeoutMinutes;
|
||||
|
||||
@Value("${aiimage.collect-data.max-source-file-bytes:0}")
|
||||
private Long maxSourceFileBytes;
|
||||
|
||||
@Value("${aiimage.collect-data.max-parse-rows:0}")
|
||||
private Integer maxParseRows;
|
||||
|
||||
@Value("${aiimage.collect-data.max-chunk-rows:0}")
|
||||
private Integer maxChunkRows;
|
||||
|
||||
/** 采集源文件大小上限。0/负值回退默认 50MB,防止解析无界增长。 */
|
||||
private long resolveMaxSourceFileBytes() {
|
||||
Long configured = maxSourceFileBytes;
|
||||
if (configured == null || configured <= 0) {
|
||||
return 50L * 1024L * 1024L;
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
/** 采集单次解析最大有效行数。0/负值回退默认 50000,防止任务无界增长。 */
|
||||
private int resolveMaxParseRows() {
|
||||
Integer configured = maxParseRows;
|
||||
if (configured == null || configured <= 0) {
|
||||
return 50000;
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
/** 采集单 chunk 回传最大行数。0/负值回退默认 5000,防止单次回传无界增长。 */
|
||||
private int resolveMaxChunkRows() {
|
||||
Integer configured = maxChunkRows;
|
||||
if (configured == null || configured <= 0) {
|
||||
return 5000;
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
private CollectDataParseLimits buildParseLimits() {
|
||||
return new CollectDataParseLimits(resolveMaxSourceFileBytes(), resolveMaxParseRows(), resolveMaxChunkRows());
|
||||
}
|
||||
|
||||
public CollectDataParseVo parseAndCreateTask(CollectDataParseRequest request) {
|
||||
long startedAt = System.currentTimeMillis();
|
||||
if (request == null || request.getUserId() == null || request.getUserId() <= 0) {
|
||||
@@ -160,15 +201,18 @@ public class CollectDataService {
|
||||
List<ParsedRow> parsedRows = new ArrayList<>();
|
||||
int totalRows = 0;
|
||||
int droppedRows = 0;
|
||||
CollectDataParseLimits limits = buildParseLimits();
|
||||
for (CollectDataSourceFileDto source : sources) {
|
||||
File input = localFileStorageService.findLocalSourceFile(source.getFileKey());
|
||||
if (input == null || !input.exists()) {
|
||||
throw new BusinessException("源文件不存在");
|
||||
}
|
||||
limits.validateSourceFile(input);
|
||||
ParsedWorkbook parsed = parseWorkbook(input, source);
|
||||
totalRows += parsed.totalRows();
|
||||
droppedRows += parsed.droppedRows();
|
||||
parsedRows.addAll(parsed.rows());
|
||||
limits.validateTotalRowCount(parsedRows.size());
|
||||
}
|
||||
if (parsedRows.isEmpty()) {
|
||||
throw new BusinessException("未解析到有效数据行");
|
||||
@@ -575,6 +619,7 @@ public class CollectDataService {
|
||||
}
|
||||
|
||||
List<CollectDataResultRowVo> rows = normalizeSubmitRows(request.getItems());
|
||||
buildParseLimits().validateChunkRowCount(rows.size());
|
||||
CollectDataStats stats = loadStats(task);
|
||||
stats.receivedRows += rows.size();
|
||||
stats.currentChunkRows = rows.size();
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.nanri.aiimage.modules.collectdata.util;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* 采集解析资源上限:文件大小、累计行数与单 chunk 行数。
|
||||
* 超过任一上限抛 BusinessException,防止解析任务无界增长;
|
||||
* 校验只读不残留状态,重复执行结果一致。
|
||||
*/
|
||||
@Slf4j
|
||||
public class CollectDataParseLimits {
|
||||
|
||||
private final long maxFileBytes;
|
||||
private final int maxTotalRows;
|
||||
private final int maxChunkRows;
|
||||
|
||||
public CollectDataParseLimits(long maxFileBytes, int maxTotalRows, int maxChunkRows) {
|
||||
if (maxFileBytes <= 0) {
|
||||
throw new IllegalArgumentException("maxFileBytes 必须为正数,实际 " + maxFileBytes);
|
||||
}
|
||||
if (maxTotalRows <= 0) {
|
||||
throw new IllegalArgumentException("maxTotalRows 必须为正数,实际 " + maxTotalRows);
|
||||
}
|
||||
if (maxChunkRows <= 0) {
|
||||
throw new IllegalArgumentException("maxChunkRows 必须为正数,实际 " + maxChunkRows);
|
||||
}
|
||||
this.maxFileBytes = maxFileBytes;
|
||||
this.maxTotalRows = maxTotalRows;
|
||||
this.maxChunkRows = maxChunkRows;
|
||||
}
|
||||
|
||||
public void validateSourceFile(File file) {
|
||||
if (file == null || !file.exists() || !file.isFile()) {
|
||||
throw new BusinessException("源文件不存在");
|
||||
}
|
||||
long size = file.length();
|
||||
if (size > maxFileBytes) {
|
||||
log.warn("[collect-data] source file exceeds size limit file={} size={} max={}",
|
||||
file.getName(), size, maxFileBytes);
|
||||
throw new BusinessException("源文件大小超过上限 " + maxFileBytes + " 字节");
|
||||
}
|
||||
}
|
||||
|
||||
public void validateTotalRowCount(int totalRows) {
|
||||
if (totalRows > maxTotalRows) {
|
||||
throw new BusinessException("累计行数超过上限 " + maxTotalRows);
|
||||
}
|
||||
}
|
||||
|
||||
public void validateChunkRowCount(int chunkRows) {
|
||||
if (chunkRows > maxChunkRows) {
|
||||
throw new BusinessException("单 chunk 行数超过上限 " + maxChunkRows);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,12 @@ aiimage:
|
||||
coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true}
|
||||
coze-use-legacy-item-field-order: ${AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER:false}
|
||||
coze-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED:true}
|
||||
collect-data:
|
||||
stale-timeout-minutes: ${AIIMAGE_COLLECT_DATA_STALE_TIMEOUT_MINUTES:30}
|
||||
stale-check-cron: ${AIIMAGE_COLLECT_DATA_STALE_CHECK_CRON:*/30 * * * * *}
|
||||
max-source-file-bytes: ${AIIMAGE_COLLECT_DATA_MAX_SOURCE_FILE_BYTES:0}
|
||||
max-parse-rows: ${AIIMAGE_COLLECT_DATA_MAX_PARSE_ROWS:0}
|
||||
max-chunk-rows: ${AIIMAGE_COLLECT_DATA_MAX_CHUNK_ROWS:0}
|
||||
image-video:
|
||||
coze-base-url: ${AIIMAGE_IMAGE_VIDEO_COZE_BASE_URL:https://api.coze.cn}
|
||||
coze-token: ${AIIMAGE_IMAGE_VIDEO_COZE_TOKEN:sat_Ws4VB1caOPasDivpKIvtOySYx3lhKgQ95H3crIh0tBwiNYtPTyi6bqe0pBaRzpVu}
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package com.nanri.aiimage.modules.collectdata.util;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Task 42:限制采集解析的文件大小、最大行数和单 chunk 行数。
|
||||
* CollectDataParseLimits 校验真实文件大小与累计行数/chunk 行数上限;
|
||||
* 空输入与单元素通过,恰好等于上限通过、超过 1 被拒绝,非法配置被拒绝,
|
||||
* 超限拒绝不残留状态。
|
||||
*/
|
||||
class CollectDataParseLimitsTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private static final long MAX_FILE_BYTES = 1024L;
|
||||
private static final int MAX_TOTAL_ROWS = 1000;
|
||||
private static final int MAX_CHUNK_ROWS = 100;
|
||||
|
||||
private CollectDataParseLimits limits() {
|
||||
return new CollectDataParseLimits(MAX_FILE_BYTES, MAX_TOTAL_ROWS, MAX_CHUNK_ROWS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_042_file_size_row_count_chunk_normal_default_path() {
|
||||
// 正常输入:文件在大小上限内、累计行数与单 chunk 行数均未超限,全部校验通过。
|
||||
CollectDataParseLimits limits = limits();
|
||||
File file = writeFile("ok.xlsx", 512);
|
||||
|
||||
assertDoesNotThrow(() -> limits.validateSourceFile(file), "正常大小文件通过");
|
||||
assertDoesNotThrow(() -> limits.validateTotalRowCount(500), "累计 500 行通过");
|
||||
assertDoesNotThrow(() -> limits.validateChunkRowCount(50), "单 chunk 50 行通过");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_042_file_size_row_count_chunk_normal_multiple_items() {
|
||||
// 批量场景:多个源文件累计行数按总和校验,未超限通过、超限被拒。
|
||||
CollectDataParseLimits limits = limits();
|
||||
|
||||
limits.validateTotalRowCount(400);
|
||||
limits.validateTotalRowCount(600);
|
||||
assertDoesNotThrow(() -> limits.validateTotalRowCount(900), "两次累计 900 行未超限");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> limits.validateTotalRowCount(1001), "累计超限被拒绝");
|
||||
assertTrue(ex.getMessage().contains("行数"), "异常消息可识别");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_042_file_size_row_count_chunk_normal_repeated_operation_is_idempotent() {
|
||||
// 幂等:同一输入重复校验结果一致,校验不产生残留状态。
|
||||
CollectDataParseLimits limits = limits();
|
||||
File file = writeFile("repeat.xlsx", 128);
|
||||
|
||||
assertDoesNotThrow(() -> limits.validateSourceFile(file));
|
||||
assertDoesNotThrow(() -> limits.validateSourceFile(file));
|
||||
assertDoesNotThrow(() -> limits.validateChunkRowCount(10));
|
||||
assertDoesNotThrow(() -> limits.validateChunkRowCount(10));
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> limits.validateChunkRowCount(101), "重复校验后超限仍被拒绝");
|
||||
assertTrue(ex.getMessage().contains("chunk"), "chunk 超限消息可识别");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_042_file_size_row_count_chunk_boundary_empty_input() {
|
||||
// 空输入:0 字节文件与 0 行均通过,不创建无效资源。
|
||||
CollectDataParseLimits limits = limits();
|
||||
File empty = writeFile("empty.xlsx", 0);
|
||||
|
||||
assertDoesNotThrow(() -> limits.validateSourceFile(empty), "0 字节文件通过");
|
||||
assertDoesNotThrow(() -> limits.validateTotalRowCount(0), "0 行通过");
|
||||
assertDoesNotThrow(() -> limits.validateChunkRowCount(0), "0 chunk 行通过");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_042_file_size_row_count_chunk_boundary_single_item() {
|
||||
// 单元素:单文件、单行、单 chunk 行不依赖批量路径,全部通过。
|
||||
CollectDataParseLimits limits = limits();
|
||||
File file = writeFile("single.xlsx", 64);
|
||||
|
||||
assertDoesNotThrow(() -> limits.validateSourceFile(file), "单文件通过");
|
||||
assertDoesNotThrow(() -> limits.validateTotalRowCount(1), "单行通过");
|
||||
assertDoesNotThrow(() -> limits.validateChunkRowCount(1), "单 chunk 行通过");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_042_file_size_row_count_chunk_boundary_limit_and_overflow() {
|
||||
// 上限/超限:恰好等于上限通过,超过 1 个字节/行被拒绝。
|
||||
CollectDataParseLimits limits = limits();
|
||||
File exact = writeFile("exact.xlsx", MAX_FILE_BYTES);
|
||||
|
||||
assertDoesNotThrow(() -> limits.validateSourceFile(exact), "恰好等于文件上限通过");
|
||||
assertDoesNotThrow(() -> limits.validateTotalRowCount(MAX_TOTAL_ROWS), "恰好等于行数上限通过");
|
||||
assertDoesNotThrow(() -> limits.validateChunkRowCount(MAX_CHUNK_ROWS), "恰好等于 chunk 上限通过");
|
||||
|
||||
File overflow = writeFile("overflow.xlsx", MAX_FILE_BYTES + 1);
|
||||
BusinessException fileEx = assertThrows(BusinessException.class,
|
||||
() -> limits.validateSourceFile(overflow), "文件超限被拒绝");
|
||||
assertTrue(fileEx.getMessage().contains("大小"), "文件超限消息可识别");
|
||||
assertThrows(BusinessException.class,
|
||||
() -> limits.validateTotalRowCount(MAX_TOTAL_ROWS + 1), "行数超限被拒绝");
|
||||
assertThrows(BusinessException.class,
|
||||
() -> limits.validateChunkRowCount(MAX_CHUNK_ROWS + 1), "chunk 超限被拒绝");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_042_file_size_row_count_chunk_invalid_input_rejected() {
|
||||
// 非法输入:null/不存在文件、零与负配置值抛可识别异常。
|
||||
CollectDataParseLimits limits = limits();
|
||||
|
||||
assertThrows(BusinessException.class,
|
||||
() -> limits.validateSourceFile(null), "null 文件被拒绝");
|
||||
assertThrows(BusinessException.class,
|
||||
() -> limits.validateSourceFile(new File(tempDir.toFile(), "missing.xlsx")), "不存在文件被拒绝");
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new CollectDataParseLimits(0, MAX_TOTAL_ROWS, MAX_CHUNK_ROWS), "0 文件上限被拒绝");
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new CollectDataParseLimits(MAX_FILE_BYTES, -1, MAX_CHUNK_ROWS), "负行数上限被拒绝");
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new CollectDataParseLimits(MAX_FILE_BYTES, MAX_TOTAL_ROWS, 0), "0 chunk 上限被拒绝");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_042_file_size_row_count_chunk_dependency_failure_releases_resources() {
|
||||
// 依赖失败:超限文件被拒绝后不残留状态,后续正常输入仍通过;
|
||||
// 临时文件可被正常删除(无句柄泄漏)。
|
||||
CollectDataParseLimits limits = limits();
|
||||
File big = writeFile("big.xlsx", MAX_FILE_BYTES * 2);
|
||||
|
||||
assertThrows(BusinessException.class, () -> limits.validateSourceFile(big), "超限文件被拒绝");
|
||||
|
||||
File normal = writeFile("after.xlsx", 32);
|
||||
assertDoesNotThrow(() -> limits.validateSourceFile(normal), "拒绝后正常文件仍通过");
|
||||
assertDoesNotThrow(() -> limits.validateChunkRowCount(5), "拒绝后 chunk 校验不受影响");
|
||||
|
||||
assertTrue(big.delete(), "被拒绝的超限临时文件可删除(无句柄泄漏)");
|
||||
assertFalse(big.exists(), "临时文件已清理");
|
||||
}
|
||||
|
||||
private File writeFile(String name, long bytes) {
|
||||
try {
|
||||
Path path = tempDir.resolve(name);
|
||||
byte[] data = new byte[(int) bytes];
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
data[i] = (byte) ('a' + i % 26);
|
||||
}
|
||||
Files.write(path, data);
|
||||
return path.toFile();
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("测试临时文件写入失败", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user