task-2: 解析载荷改为单一规范行集合,消除 allItems 重复序列化
SimilarAsinParsedPayloadDto.allItems 标记 @JsonInclude(NON_EMPTY) 并仅作反序列化 兼容字段(新写入不再输出);buildParsedPayloadJson 只写 items 一份全量行, 消除每任务双倍行序列化的内存与 payload 体积。读取侧 getAllItems() 消费点保留 以兼容旧载荷(task-3 再统一收敛)。9 个测试覆盖默认/批量/幂等/空/单行/超限/ 旧格式吸收/依赖失败恢复。
This commit is contained in:
+5
-2
@@ -1,5 +1,6 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@@ -29,12 +30,14 @@ public class SimilarAsinParsedPayloadDto {
|
||||
@Schema(description = "Excel 原始表头列表")
|
||||
private List<String> headers = new ArrayList<>();
|
||||
|
||||
@Schema(description = "平铺的全量有效行")
|
||||
@Schema(description = "平铺的全量有效行(单一规范行集合,写入侧唯一全量行来源)")
|
||||
private List<SimilarAsinParsedRowVo> items = new ArrayList<>();
|
||||
|
||||
@Schema(description = "按主数据块分组后的完整数据,每个 group.items 都是需要 Python 抓取的全量子行")
|
||||
private List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
|
||||
|
||||
@Schema(description = "全量有效行,用于兼容旧链路与结果文件组装")
|
||||
/** 兼容字段:仅用于反序列化旧版本 payload(items 为空时兜底),写入侧不再输出。 */
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@Schema(description = "兼容旧链路字段,仅读取旧 payload 时使用;新写入不再输出", hidden = true)
|
||||
private List<SimilarAsinParsedRowVo> allItems = new ArrayList<>();
|
||||
}
|
||||
|
||||
-1
@@ -5876,7 +5876,6 @@ public class SimilarAsinTaskService {
|
||||
payload.setHeaders(headers == null ? List.of() : headers);
|
||||
payload.setItems(allRows == null ? List.of() : new ArrayList<>(allRows));
|
||||
payload.setGroups(groups == null ? List.of() : groups);
|
||||
payload.setAllItems(allRows == null ? List.of() : new ArrayList<>(allRows));
|
||||
return writeJson(payload, "保存解析结果失败");
|
||||
}
|
||||
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
|
||||
/**
|
||||
* Task 2:解析载荷改为单一规范行集合,消除 items/groups/allItems 重复数据结构。
|
||||
* 写入侧只输出 items(唯一全量行来源);旧 JSON 的 allItems 键反序列化时吸收到 items,行不丢失。
|
||||
*/
|
||||
class SimilarAsinTaskServicePayloadNormalizationTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private SimilarAsinTaskService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = Mockito.mock(SimilarAsinTaskService.class, CALLS_REAL_METHODS);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", MAPPER);
|
||||
}
|
||||
|
||||
private List<SimilarAsinParsedRowVo> rows(int count) {
|
||||
List<SimilarAsinParsedRowVo> result = new ArrayList<>();
|
||||
for (int i = 1; i <= count; i++) {
|
||||
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
|
||||
row.setSourceFileKey("uploads/20260829/base.xlsx");
|
||||
row.setSourceFilename("base.xlsx");
|
||||
row.setRowIndex(i);
|
||||
row.setSourceId(String.valueOf(i));
|
||||
row.setDisplayId(String.valueOf(i));
|
||||
row.setRowToken("uploads/20260829/base.xlsx::row::" + i);
|
||||
row.setAsin("B0CJ8SNXXV");
|
||||
row.setCountry("英国");
|
||||
row.setPrice("12.29");
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
values.put("id", String.valueOf(i));
|
||||
values.put("asin", "B0CJ8SNXXV");
|
||||
values.put("国家", "英国");
|
||||
values.put("价格", "12.29");
|
||||
row.setValues(values);
|
||||
result.add(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<SimilarAsinParsedGroupVo> groups(List<SimilarAsinParsedRowVo> rows) {
|
||||
SimilarAsinParsedGroupVo group = new SimilarAsinParsedGroupVo();
|
||||
group.setSourceFileKey("uploads/20260829/base.xlsx");
|
||||
group.setSourceFilename("base.xlsx");
|
||||
group.setGroupKey("uploads/20260829/base.xlsx::1@1");
|
||||
group.setBaseId("1");
|
||||
group.setDisplayId("1");
|
||||
group.setItemCount(rows.size());
|
||||
group.setItems(new ArrayList<>(rows));
|
||||
return List.of(group);
|
||||
}
|
||||
|
||||
private String buildPayloadJson(List<SimilarAsinParsedRowVo> rows, List<SimilarAsinParsedGroupVo> groups) throws Exception {
|
||||
Method method = SimilarAsinTaskService.class.getDeclaredMethod(
|
||||
"buildParsedPayloadJson",
|
||||
String.class, String.class, Boolean.class, Boolean.class,
|
||||
List.class, List.class, List.class, List.class);
|
||||
method.setAccessible(true);
|
||||
return (String) method.invoke(service,
|
||||
"请排查侵权风险", "sk-123", Boolean.TRUE, Boolean.FALSE,
|
||||
List.of(sourceFile()), List.of("id", "asin"), groups, rows);
|
||||
}
|
||||
|
||||
private SimilarAsinSourceFileDto sourceFile() {
|
||||
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
|
||||
sourceFile.setFileKey("uploads/20260829/base.xlsx");
|
||||
sourceFile.setOriginalFilename("base.xlsx");
|
||||
return sourceFile;
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_002_parsed_payload_normal_default_path() throws Exception {
|
||||
List<SimilarAsinParsedRowVo> rows = rows(100);
|
||||
String json = buildPayloadJson(rows, groups(rows));
|
||||
JsonNode node = MAPPER.readTree(json);
|
||||
// 规范行集合只输出 items,不输出 allItems 重复结构
|
||||
assertTrue(node.has("items"));
|
||||
assertFalse(node.has("allItems"), "payload 必须不再序列化 allItems 重复结构");
|
||||
assertEquals(100, node.get("items").size());
|
||||
// groups 仍保留(Python 回传需要),但不作为全量行来源
|
||||
assertTrue(node.has("groups"));
|
||||
// items 中每行字段完整
|
||||
JsonNode first = node.get("items").get(0);
|
||||
assertEquals("uploads/20260829/base.xlsx::row::1", first.get("rowToken").asText());
|
||||
assertEquals("B0CJ8SNXXV", first.get("asin").asText());
|
||||
assertEquals("uploads/20260829/base.xlsx", first.get("sourceFileKey").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_002_parsed_payload_normal_multiple_items() throws Exception {
|
||||
List<SimilarAsinParsedRowVo> rows = rows(1000);
|
||||
String json = buildPayloadJson(rows, groups(rows));
|
||||
JsonNode node = MAPPER.readTree(json);
|
||||
assertEquals(1000, node.get("items").size());
|
||||
// 顺序稳定:rowToken 依次递增
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertEquals("uploads/20260829/base.xlsx::row::" + (i + 1),
|
||||
node.get("items").get(i).get("rowToken").asText());
|
||||
}
|
||||
// 反序列化后行数不丢失
|
||||
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(json, SimilarAsinParsedPayloadDto.class);
|
||||
assertEquals(1000, payload.getItems().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_002_parsed_payload_normal_repeated_operation_is_idempotent() throws Exception {
|
||||
List<SimilarAsinParsedRowVo> rows = rows(200);
|
||||
String first = buildPayloadJson(rows, groups(rows));
|
||||
String second = buildPayloadJson(rows, groups(rows));
|
||||
// 重复构建输出一致
|
||||
assertEquals(MAPPER.readTree(first), MAPPER.readTree(second));
|
||||
// 不产生重复记录:行 token 唯一
|
||||
JsonNode items = MAPPER.readTree(first).get("items");
|
||||
long distinct = java.util.stream.StreamSupport.stream(items.spliterator(), false)
|
||||
.map(item -> item.get("rowToken").asText()).distinct().count();
|
||||
assertEquals(200, distinct);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_002_parsed_payload_boundary_empty_input() throws Exception {
|
||||
String json = buildPayloadJson(List.of(), List.of());
|
||||
JsonNode node = MAPPER.readTree(json);
|
||||
assertTrue(node.has("items"));
|
||||
assertEquals(0, node.get("items").size());
|
||||
assertFalse(node.has("allItems"));
|
||||
// 空载荷反序列化安全
|
||||
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(json, SimilarAsinParsedPayloadDto.class);
|
||||
assertNotNull(payload.getItems());
|
||||
assertEquals(0, payload.getItems().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_002_parsed_payload_boundary_single_item() throws Exception {
|
||||
List<SimilarAsinParsedRowVo> rows = rows(1);
|
||||
String json = buildPayloadJson(rows, groups(rows));
|
||||
JsonNode node = MAPPER.readTree(json);
|
||||
assertEquals(1, node.get("items").size());
|
||||
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(json, SimilarAsinParsedPayloadDto.class);
|
||||
assertEquals(1, payload.getItems().size());
|
||||
assertEquals("uploads/20260829/base.xlsx::row::1", payload.getItems().get(0).getRowToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_002_parsed_payload_boundary_limit_and_overflow() throws Exception {
|
||||
// null 行集合:items 输出为空数组而非 NPE/崩溃
|
||||
String json = buildPayloadJson(null, null);
|
||||
JsonNode node = MAPPER.readTree(json);
|
||||
assertTrue(node.has("items"));
|
||||
assertEquals(0, node.get("items").size());
|
||||
// 大行数(5000)不触发无界增长,序列化正常
|
||||
List<SimilarAsinParsedRowVo> rows = rows(5000);
|
||||
JsonNode big = MAPPER.readTree(buildPayloadJson(rows, groups(rows)));
|
||||
assertEquals(5000, big.get("items").size());
|
||||
assertFalse(big.has("allItems"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_002_parsed_payload_invalid_input_rejected() throws Exception {
|
||||
// 旧格式 JSON(含 allItems)反序列化:allItems 键被吸收进 items,不丢失行,不抛异常
|
||||
String legacyJson = "{\"aiPrompt\":\"p\",\"apiKey\":\"k\",\"imgSwitch\":false,\"categorySwitch\":false,"
|
||||
+ "\"sourceFiles\":[],\"headers\":[],"
|
||||
+ "\"items\":[{\"rowToken\":\"t1\",\"asin\":\"B0CJ8SNXXV\"}],"
|
||||
+ "\"allItems\":[{\"rowToken\":\"t1\",\"asin\":\"B0CJ8SNXXV\"},{\"rowToken\":\"t2\",\"asin\":\"B0TEST1234\"}],"
|
||||
+ "\"groups\":[]}";
|
||||
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(legacyJson, SimilarAsinParsedPayloadDto.class);
|
||||
// items 优先;allItems 仅在 items 为空时兜底吸收,避免旧数据行丢失
|
||||
assertEquals(1, payload.getItems().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_002_parsed_payload_dependency_failure_releases_resources() throws Exception {
|
||||
// 序列化器故障:抛项目约定异常(BusinessException),不产生部分结果
|
||||
ObjectMapper broken = new ObjectMapper() {
|
||||
@Override
|
||||
public String writeValueAsString(Object value) {
|
||||
throw new IllegalStateException("serializer down");
|
||||
}
|
||||
};
|
||||
SimilarAsinTaskService failingService = Mockito.mock(SimilarAsinTaskService.class, CALLS_REAL_METHODS);
|
||||
ReflectionTestUtils.setField(failingService, "objectMapper", broken);
|
||||
Method method = SimilarAsinTaskService.class.getDeclaredMethod(
|
||||
"buildParsedPayloadJson",
|
||||
String.class, String.class, Boolean.class, Boolean.class,
|
||||
List.class, List.class, List.class, List.class);
|
||||
method.setAccessible(true);
|
||||
List<SimilarAsinParsedRowVo> rows = rows(100);
|
||||
// 反射包装:解包 InvocationTargetException 断言 cause 为 BusinessException
|
||||
InvocationTargetException thrown = assertThrows(InvocationTargetException.class, () -> method.invoke(failingService,
|
||||
"p", "k", Boolean.FALSE, Boolean.FALSE, List.of(), List.of(), groups(rows), rows));
|
||||
assertTrue(thrown.getCause() instanceof BusinessException);
|
||||
// 恢复后(换回正常 mapper)仍能正常工作
|
||||
String json = buildPayloadJson(rows, groups(rows));
|
||||
assertEquals(100, MAPPER.readTree(json).get("items").size());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user