task-49: 结果明细从逐行 RustFS 对象改为 chunk 级 payload 存储,引用 JSON 兼容旧格式
This commit is contained in:
+73
-19
@@ -37,6 +37,7 @@ import com.nanri.aiimage.modules.collectdata.util.CollectDataBrandBatchFilter;
|
||||
import com.nanri.aiimage.modules.collectdata.util.CollectDataExtraJsonCodec;
|
||||
import com.nanri.aiimage.modules.collectdata.util.CollectDataInvalidAsinBatchWriter;
|
||||
import com.nanri.aiimage.modules.collectdata.util.CollectDataParseLimits;
|
||||
import com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailCodec;
|
||||
import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper;
|
||||
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
@@ -77,6 +78,7 @@ import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -143,6 +145,9 @@ public class CollectDataService {
|
||||
/** invalid ASIN 批量写入器:按批次 INSERT IGNORE,替代逐行插入。 */
|
||||
private final CollectDataInvalidAsinBatchWriter invalidAsinBatchWriter;
|
||||
|
||||
/** 结果明细 chunk 级编解码:accepted 行按 chunk 共享一个 RustFS 对象。 */
|
||||
private final CollectDataResultDetailCodec resultDetailCodec;
|
||||
|
||||
@Value("${aiimage.collect-data.stale-timeout-minutes:30}")
|
||||
private long staleTimeoutMinutes;
|
||||
|
||||
@@ -646,8 +651,17 @@ public class CollectDataService {
|
||||
stats.dedupeFilteredCount += filtered.dedupeFilteredCount();
|
||||
stats.invalidFilteredCount += filtered.invalidFilteredCount();
|
||||
List<CollectDataResultRowVo> accepted = filterByBrandCheck(filtered.kept(), stats);
|
||||
for (CollectDataResultRowVo row : accepted) {
|
||||
upsertResultItem(task.getId(), result.getId(), scopeKey, row);
|
||||
// 结果明细改为 chunk 级存储:整个 chunk 的 accepted 行共享一个
|
||||
// RustFS 对象(deterministic key,同 chunk 重提覆盖同一对象),
|
||||
// biz_task_result_item.payload_json 只存 {chunk, offset, payload} 引用。
|
||||
if (!accepted.isEmpty()) {
|
||||
String detailJson = resultDetailCodec.encodeChunk(accepted);
|
||||
String storedDetail = transientPayloadStorageService.storeResultPayload(
|
||||
MODULE_TYPE, taskId, scopeHash, "chunk-" + chunkIndex, detailJson);
|
||||
requireRustfsPayload(storedDetail, "采集结果明细必须写入 RustFS");
|
||||
for (int i = 0; i < accepted.size(); i++) {
|
||||
upsertResultItem(task.getId(), result.getId(), scopeKey, chunkIndex, accepted.get(i), i, storedDetail);
|
||||
}
|
||||
}
|
||||
|
||||
String payloadJson = writeJson(rows, "采集结果序列化失败");
|
||||
@@ -739,11 +753,13 @@ public class CollectDataService {
|
||||
return outcome.accepted();
|
||||
}
|
||||
|
||||
private void upsertResultItem(Long taskId, Long resultId, String scopeKey, CollectDataResultRowVo row) {
|
||||
private void upsertResultItem(Long taskId, Long resultId, String scopeKey, int chunkIndex,
|
||||
CollectDataResultRowVo row, int offset, String storedDetail) {
|
||||
String itemKey = "asin:" + row.getAsin();
|
||||
String scopeHash = hash(scopeKey);
|
||||
String payloadJson = writeJson(row, "采集结果明细序列化失败");
|
||||
String payloadHash = hash(payloadJson);
|
||||
// 引用 JSON 作为 payload_json:内容变化(行 offset/对象变化)即 hash 变化。
|
||||
String refJson = resultDetailCodec.encodeRef(chunkIndex, offset, storedDetail);
|
||||
String payloadHash = hash(refJson);
|
||||
TaskResultItemEntity existing = taskResultItemMapper.selectOne(new LambdaQueryWrapper<TaskResultItemEntity>()
|
||||
.eq(TaskResultItemEntity::getTaskId, taskId)
|
||||
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
|
||||
@@ -753,9 +769,6 @@ public class CollectDataService {
|
||||
if (existing != null && Objects.equals(existing.getPayloadHash(), payloadHash)) {
|
||||
return;
|
||||
}
|
||||
String storedPayload = transientPayloadStorageService.storeResultItemPayload(
|
||||
MODULE_TYPE, taskId, scopeHash, itemKey, payloadJson);
|
||||
requireRustfsPayload(storedPayload, "采集结果明细必须写入 RustFS");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (existing == null) {
|
||||
TaskResultItemEntity entity = new TaskResultItemEntity();
|
||||
@@ -767,7 +780,7 @@ public class CollectDataService {
|
||||
entity.setItemKey(itemKey);
|
||||
entity.setAsin(row.getAsin());
|
||||
entity.setStatus("ACCEPTED");
|
||||
entity.setPayloadJson(storedPayload);
|
||||
entity.setPayloadJson(refJson);
|
||||
entity.setPayloadHash(payloadHash);
|
||||
entity.setCreatedAt(now);
|
||||
entity.setUpdatedAt(now);
|
||||
@@ -786,14 +799,20 @@ public class CollectDataService {
|
||||
if (existing == null) {
|
||||
throw new BusinessException("保存采集结果明细失败");
|
||||
}
|
||||
transientPayloadStorageService.deleteReplacedPayloadIfNeeded(existing.getPayloadJson(), storedPayload);
|
||||
// chunk 级引用共享同一 RustFS 对象(deterministic key,同 chunk 重提
|
||||
// 覆盖同一对象,引用 pointer 稳定,无需删除);旧格式逐行对象在升级
|
||||
// 为引用后不再被任何行持有,直接物理删除,避免泄漏。
|
||||
CollectDataResultDetailCodec.ChunkRef oldRef = resultDetailCodec.parseRef(existing.getPayloadJson());
|
||||
if (oldRef == null) {
|
||||
transientPayloadStorageService.deletePayloadIfPresent(existing.getPayloadJson());
|
||||
}
|
||||
taskResultItemMapper.update(null, new LambdaUpdateWrapper<TaskResultItemEntity>()
|
||||
.eq(TaskResultItemEntity::getId, existing.getId())
|
||||
.set(TaskResultItemEntity::getResultId, resultId)
|
||||
.set(TaskResultItemEntity::getScopeKey, scopeKey)
|
||||
.set(TaskResultItemEntity::getAsin, row.getAsin())
|
||||
.set(TaskResultItemEntity::getStatus, "ACCEPTED")
|
||||
.set(TaskResultItemEntity::getPayloadJson, storedPayload)
|
||||
.set(TaskResultItemEntity::getPayloadJson, refJson)
|
||||
.set(TaskResultItemEntity::getPayloadHash, payloadHash)
|
||||
.set(TaskResultItemEntity::getUpdatedAt, now));
|
||||
}
|
||||
@@ -938,9 +957,34 @@ public class CollectDataService {
|
||||
if (rows == null) {
|
||||
return out;
|
||||
}
|
||||
TypeReference<List<CollectDataResultRowVo>> listType = new TypeReference<>() {
|
||||
};
|
||||
// chunk 级引用:同一 chunk 对象只解析一次,按 offset 取行。
|
||||
Map<String, List<CollectDataResultRowVo>> detailCache = new HashMap<>();
|
||||
for (TaskResultItemEntity row : rows) {
|
||||
try {
|
||||
String payloadJson = transientPayloadStorageService.resolvePayload(row.getPayloadJson(), "read collect data result item failed");
|
||||
CollectDataResultDetailCodec.ChunkRef ref = resultDetailCodec.parseRef(row.getPayloadJson());
|
||||
if (ref != null) {
|
||||
List<CollectDataResultRowVo> details = detailCache.get(ref.pointer());
|
||||
if (details == null) {
|
||||
String detailJson = transientPayloadStorageService.resolvePayload(
|
||||
ref.pointer(), "read collect data result detail failed");
|
||||
if (detailJson != null && !detailJson.isBlank()) {
|
||||
details = objectMapper.readValue(detailJson, listType);
|
||||
}
|
||||
detailCache.put(ref.pointer(), details);
|
||||
}
|
||||
if (details != null && ref.offset() >= 0 && ref.offset() < details.size()) {
|
||||
CollectDataResultRowVo value = details.get(ref.offset());
|
||||
if (value != null) {
|
||||
out.add(value);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 旧格式:payload_json 直接是行 JSON。
|
||||
String payloadJson = transientPayloadStorageService.resolvePayload(
|
||||
row.getPayloadJson(), "read collect data result item failed");
|
||||
CollectDataResultRowVo value = objectMapper.readValue(payloadJson, CollectDataResultRowVo.class);
|
||||
if (value != null) {
|
||||
out.add(value);
|
||||
@@ -1232,8 +1276,22 @@ public class CollectDataService {
|
||||
.select(TaskResultItemEntity::getPayloadJson)
|
||||
.eq(TaskResultItemEntity::getTaskId, taskId)
|
||||
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE));
|
||||
if (items != null) {
|
||||
for (TaskResultItemEntity item : items) {
|
||||
deleteResultItemPayloads(items);
|
||||
}
|
||||
|
||||
/** 删除结果明细 payload:chunk 级引用按对象去重后各删一次,旧格式逐行删。 */
|
||||
private void deleteResultItemPayloads(List<TaskResultItemEntity> items) {
|
||||
if (items == null) {
|
||||
return;
|
||||
}
|
||||
Set<String> deletedPointers = new HashSet<>();
|
||||
for (TaskResultItemEntity item : items) {
|
||||
CollectDataResultDetailCodec.ChunkRef ref = resultDetailCodec.parseRef(item.getPayloadJson());
|
||||
if (ref != null) {
|
||||
if (deletedPointers.add(ref.pointer())) {
|
||||
transientPayloadStorageService.deletePayloadIfPresent(ref.pointer());
|
||||
}
|
||||
} else {
|
||||
transientPayloadStorageService.deletePayloadIfPresent(item.getPayloadJson());
|
||||
}
|
||||
}
|
||||
@@ -1291,11 +1349,7 @@ public class CollectDataService {
|
||||
.eq(TaskResultItemEntity::getTaskId, row.getTaskId())
|
||||
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(TaskResultItemEntity::getResultId, row.getId()));
|
||||
if (resultItems != null) {
|
||||
for (TaskResultItemEntity item : resultItems) {
|
||||
transientPayloadStorageService.deletePayloadIfPresent(item.getPayloadJson());
|
||||
}
|
||||
}
|
||||
deleteResultItemPayloads(resultItems);
|
||||
taskResultItemMapper.delete(new LambdaQueryWrapper<TaskResultItemEntity>()
|
||||
.eq(TaskResultItemEntity::getTaskId, row.getTaskId())
|
||||
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.nanri.aiimage.modules.collectdata.util;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 结果明细 chunk 级编解码:把整 chunk 的 accepted 行序列化为一个明细数组
|
||||
* JSON(每个 chunk 只写一个 RustFS 对象,替代逐行 storeResultItemPayload),
|
||||
* 并为每行生成 {chunk, offset, payload} 引用 JSON 写入
|
||||
* biz_task_result_item.payload_json。读侧按引用一次解析数组、offset 取行,
|
||||
* 避免为每个 accepted 行单独发起对象存储读写。
|
||||
*
|
||||
* 旧格式兼容:parseRef 对非引用内容(逐行行 JSON / 数组 / 裸文本 / 损坏
|
||||
* JSON)返回 null,由调用方按逐行旧路径兜底读取;rowAt 越界/损坏安全
|
||||
* 返回 null。
|
||||
*/
|
||||
@Component
|
||||
public class CollectDataResultDetailCodec {
|
||||
|
||||
public static final String REF_FIELD_CHUNK = "chunk";
|
||||
public static final String REF_FIELD_OFFSET = "offset";
|
||||
public static final String REF_FIELD_PAYLOAD = "payload";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public CollectDataResultDetailCodec(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** 生成引用 JSON:{"chunk":N,"offset":M,"payload":"<RustFS 指针>"}。 */
|
||||
public String encodeRef(int chunkIndex, int offset, String pointer) {
|
||||
if (chunkIndex < 0 || offset < 0 || pointer == null || pointer.isBlank()) {
|
||||
throw new IllegalArgumentException("invalid chunk detail ref chunk=" + chunkIndex
|
||||
+ " offset=" + offset + " pointer=" + pointer);
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(new ChunkRef(chunkIndex, offset, pointer));
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalArgumentException("encode chunk detail ref failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析引用 JSON;内容不是引用格式(旧逐行行 JSON、数组、裸文本、
|
||||
* 损坏 JSON)时返回 null,由调用方按旧格式兜底。
|
||||
*/
|
||||
public ChunkRef parseRef(String refJson) {
|
||||
if (refJson == null || refJson.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(refJson);
|
||||
if (node == null || !node.isObject()
|
||||
|| !node.hasNonNull(REF_FIELD_CHUNK)
|
||||
|| !node.hasNonNull(REF_FIELD_OFFSET)
|
||||
|| !node.hasNonNull(REF_FIELD_PAYLOAD)) {
|
||||
return null;
|
||||
}
|
||||
int chunkIndex = node.get(REF_FIELD_CHUNK).asInt();
|
||||
int offset = node.get(REF_FIELD_OFFSET).asInt();
|
||||
String pointer = node.get(REF_FIELD_PAYLOAD).asText();
|
||||
if (chunkIndex < 0 || offset < 0 || pointer.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return new ChunkRef(chunkIndex, offset, pointer);
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把整 chunk 行序列化为明细数组 JSON;null/空输入编码为空数组。 */
|
||||
public String encodeChunk(List<?> rows) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(rows == null ? List.of() : rows);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalArgumentException("encode chunk detail failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 chunk 明细数组 JSON 中按 offset 取行;offset 越界/负值、明细
|
||||
* 损坏或非数组时返回 null。
|
||||
*/
|
||||
public CollectDataResultRowVo rowAt(String detailJson, int offset) {
|
||||
if (detailJson == null || detailJson.isBlank() || offset < 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(detailJson);
|
||||
if (node == null || !node.isArray() || offset >= node.size()) {
|
||||
return null;
|
||||
}
|
||||
return objectMapper.treeToValue(node.get(offset), CollectDataResultRowVo.class);
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 结果明细行引用:chunk 序号 + 行内 offset + chunk 明细对象指针。 */
|
||||
public record ChunkRef(@JsonProperty(REF_FIELD_CHUNK) int chunkIndex,
|
||||
@JsonProperty(REF_FIELD_OFFSET) int offset,
|
||||
@JsonProperty(REF_FIELD_PAYLOAD) String pointer) {
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package com.nanri.aiimage.modules.collectdata.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Task 49:将结果明细从逐行 RustFS 对象改为 chunk 级 payload 存储。
|
||||
* CollectDataResultDetailCodec 负责:把整 chunk 的 accepted 行序列化为
|
||||
* 一个明细数组 JSON(每 chunk 一个 RustFS 对象),并为每行生成
|
||||
* {chunk, offset, payload} 引用 JSON(写入 biz_task_result_item.payload_json);
|
||||
* 读侧按 chunk 引用一次解析数组、offset 取行。旧格式(payload_json 直接是
|
||||
* 行 JSON)解析返回 null,由调用方按逐行旧路径兼容读取。
|
||||
*/
|
||||
class CollectDataResultDetailCodecTest {
|
||||
|
||||
private CollectDataResultDetailCodec codec;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
codec = new CollectDataResultDetailCodec(new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_049_payload_chunk_rustfs_normal_default_path() {
|
||||
// 正常输入:encodeRef → parseRef 往返一致;chunk 明细数组序列化后
|
||||
// rowAt 按 offset 取回同一行。
|
||||
String ref = codec.encodeRef(2, 3, "rustfs:detail/abc");
|
||||
|
||||
CollectDataResultDetailCodec.ChunkRef parsed = codec.parseRef(ref);
|
||||
|
||||
assertEquals(2, parsed.chunkIndex(), "chunk 序号保留");
|
||||
assertEquals(3, parsed.offset(), "行 offset 保留");
|
||||
assertEquals("rustfs:detail/abc", parsed.pointer(), "RustFS 对象指针保留");
|
||||
|
||||
String detailJson = codec.encodeChunk(List.of(row("B000000001", "Nike"), row("B000000002", "Zara")));
|
||||
CollectDataResultRowVo at1 = codec.rowAt(detailJson, 1);
|
||||
assertEquals("B000000002", at1.getAsin(), "按 offset 取回第二行");
|
||||
assertEquals("Zara", at1.getBrand(), "行字段完整保留(不做规范化)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_049_payload_chunk_rustfs_normal_multiple_items() {
|
||||
// 批量场景:多 chunk 多行,各自引用指向自己的 chunk 与 offset,互不串行。
|
||||
String refChunk0 = codec.encodeRef(0, 1, "rustfs:detail/a");
|
||||
String refChunk2 = codec.encodeRef(2, 0, "rustfs:detail/b");
|
||||
List<CollectDataResultRowVo> rows0 = List.of(row("B000000001", "A"), row("B000000002", "B"));
|
||||
List<CollectDataResultRowVo> rows2 = List.of(row("B000000101", "C"));
|
||||
|
||||
CollectDataResultDetailCodec.ChunkRef parsed0 = codec.parseRef(refChunk0);
|
||||
CollectDataResultDetailCodec.ChunkRef parsed2 = codec.parseRef(refChunk2);
|
||||
assertEquals(0, parsed0.chunkIndex(), "chunk0 序号");
|
||||
assertEquals(1, parsed0.offset(), "chunk0 offset");
|
||||
assertEquals(2, parsed2.chunkIndex(), "chunk2 序号");
|
||||
assertEquals(0, parsed2.offset(), "chunk2 offset");
|
||||
|
||||
assertEquals("B000000002", codec.rowAt(codec.encodeChunk(rows0), parsed0.offset()).getAsin(), "chunk0 行");
|
||||
assertEquals("B000000101", codec.rowAt(codec.encodeChunk(rows2), parsed2.offset()).getAsin(), "chunk2 行");
|
||||
assertNull(codec.rowAt(codec.encodeChunk(rows2), 5), "chunk2 中越界 offset 返回 null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_049_payload_chunk_rustfs_normal_repeated_operation_is_idempotent() {
|
||||
// 幂等:同输入重复编码结果逐字节一致;重复解析引用结果一致;不产生新对象。
|
||||
String first = codec.encodeChunk(List.of(row("B000000001", "Nike"), row("B000000002", "Zara")));
|
||||
String second = codec.encodeChunk(List.of(row("B000000001", "Nike"), row("B000000002", "Zara")));
|
||||
assertEquals(first, second, "重复序列化结果一致");
|
||||
|
||||
String ref = codec.encodeRef(1, 0, "rustfs:detail/x");
|
||||
assertEquals(ref, codec.encodeRef(1, 0, "rustfs:detail/x"), "重复引用编码一致");
|
||||
assertEquals(codec.parseRef(ref), codec.parseRef(ref), "重复解析一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_049_payload_chunk_rustfs_boundary_empty_input() {
|
||||
// 空输入:空 chunk 明细编码为空数组;空/空白引用解析为 null(旧格式兼容)。
|
||||
assertEquals("[]", codec.encodeChunk(List.of()), "空 chunk 编码为空数组");
|
||||
assertEquals("[]", codec.encodeChunk(null), "null 明细编码为空数组");
|
||||
assertNull(codec.parseRef(null), "null 引用返回 null");
|
||||
assertNull(codec.parseRef(" "), "空白引用返回 null");
|
||||
assertNull(codec.rowAt("[]", 0), "空数组取行返回 null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_049_payload_chunk_rustfs_boundary_single_item() {
|
||||
// 单元素:单行单 chunk offset 0 正确;单行明细取行正确。
|
||||
String ref = codec.encodeRef(0, 0, "rustfs:detail/solo");
|
||||
CollectDataResultDetailCodec.ChunkRef parsed = codec.parseRef(ref);
|
||||
assertEquals(0, parsed.chunkIndex(), "单 chunk 序号");
|
||||
assertEquals(0, parsed.offset(), "单行 offset 0");
|
||||
|
||||
String detailJson = codec.encodeChunk(List.of(row("B000000001", "solo")));
|
||||
CollectDataResultRowVo value = codec.rowAt(detailJson, 0);
|
||||
assertEquals("B000000001", value.getAsin(), "单行取回正确");
|
||||
assertEquals("solo", value.getBrand(), "单行字段完整");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_049_payload_chunk_rustfs_boundary_limit_and_overflow() {
|
||||
// 上限/超限:offset 越界或为负时 rowAt 安全返回 null,不抛异常、不越界。
|
||||
String detailJson = codec.encodeChunk(List.of(row("B000000001", "Nike")));
|
||||
assertNull(codec.rowAt(detailJson, 1), "offset 越界返回 null");
|
||||
assertNull(codec.rowAt(detailJson, 100), "offset 远超行数返回 null");
|
||||
assertNull(codec.rowAt(detailJson, -1), "负 offset 返回 null");
|
||||
assertNull(codec.rowAt(null, 0), "null 明细返回 null");
|
||||
assertNull(codec.rowAt("not-json", 0), "损坏明细返回 null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_049_payload_chunk_rustfs_invalid_input_rejected() {
|
||||
// 非法参数:非法 chunk/offset 编码引用时抛可识别异常;
|
||||
// 非引用格式(旧逐行 JSON、数组、裸文本)解析返回 null 而非抛错。
|
||||
assertThrows(IllegalArgumentException.class, () -> codec.encodeRef(-1, 0, "rustfs:x"), "负 chunk 拒绝");
|
||||
assertThrows(IllegalArgumentException.class, () -> codec.encodeRef(0, -1, "rustfs:x"), "负 offset 拒绝");
|
||||
assertThrows(IllegalArgumentException.class, () -> codec.encodeRef(0, 0, null), "null 指针拒绝");
|
||||
|
||||
assertNull(codec.parseRef("{\"brand\":\"nike\"}"), "旧格式行 JSON 返回 null");
|
||||
assertNull(codec.parseRef("[1,2,3]"), "数组 JSON 返回 null");
|
||||
assertNull(codec.parseRef("plain-text"), "裸文本返回 null");
|
||||
assertNull(codec.parseRef("{\"chunk\":1,\"offset\":2}"), "缺 payload 字段返回 null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_049_payload_chunk_rustfs_dependency_failure_releases_resources() {
|
||||
// 依赖失败:引用 JSON 损坏(readTree 抛错)时解析返回 null,
|
||||
// 调用方可按旧格式逐行读取兜底,错误可恢复不泄漏。
|
||||
String malformed = "{\"chunk\":1,\"offset\":";
|
||||
assertNull(codec.parseRef(malformed), "损坏引用解析为 null 不抛异常");
|
||||
|
||||
String detailJson = codec.encodeChunk(new ArrayList<>());
|
||||
CollectDataResultRowVo value = codec.rowAt(detailJson + "{broken", 0);
|
||||
assertNull(value, "损坏明细取行返回 null 不抛异常");
|
||||
assertTrue(codec.parseRef(codec.encodeRef(0, 0, "rustfs:x")) != null, "修复后可正常解析");
|
||||
}
|
||||
|
||||
private static CollectDataResultRowVo row(String asin, String brand) {
|
||||
CollectDataResultRowVo row = new CollectDataResultRowVo();
|
||||
row.setAsin(asin);
|
||||
row.setBrand(brand);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user