task-42: 限制采集解析的文件大小、最大行数和单 chunk 行数

新增 CollectDataParseLimits 组件并在 CollectDataService 解析/回传路径
接入:源文件大小(默认 50MB)、累计解析行数(默认 50000)、单 chunk
回传行数(默认 5000)超限即拒绝,配置经 aiimage.collect-data.* 环境变量
可调、0/负值回退默认。8 个用例覆盖正常/批量/幂等/空/单元素/边界超限/
非法配置/失败后可恢复路径,mvn 全量测试通过。
This commit is contained in:
2026-08-30 13:04:02 +08:00
parent 3f8326afc4
commit fec89b6b7b
4 changed files with 276 additions and 0 deletions
@@ -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);
}
}
}