task-49: 结果明细从逐行 RustFS 对象改为 chunk 级 payload 存储,引用 JSON 兼容旧格式

This commit is contained in:
2026-08-30 14:31:04 +08:00
parent ed2900361c
commit 5919ae12e9
3 changed files with 333 additions and 19 deletions
@@ -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)
@@ -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) {
}
}
@@ -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;
}
}