diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParsedPayloadDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParsedPayloadDto.java index 31cae942..34d18b9b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParsedPayloadDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParsedPayloadDto.java @@ -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 headers = new ArrayList<>(); - @Schema(description = "平铺的全量有效行") + @Schema(description = "平铺的全量有效行(单一规范行集合,写入侧唯一全量行来源)") private List items = new ArrayList<>(); @Schema(description = "按主数据块分组后的完整数据,每个 group.items 都是需要 Python 抓取的全量子行") private List groups = new ArrayList<>(); - @Schema(description = "全量有效行,用于兼容旧链路与结果文件组装") + /** 兼容字段:仅用于反序列化旧版本 payload(items 为空时兜底),写入侧不再输出。 */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @Schema(description = "兼容旧链路字段,仅读取旧 payload 时使用;新写入不再输出", hidden = true) private List allItems = new ArrayList<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 3fc1c1cd..8c40b9e3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -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, "保存解析结果失败"); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePayloadNormalizationTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePayloadNormalizationTest.java new file mode 100644 index 00000000..02ead9ea --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePayloadNormalizationTest.java @@ -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 rows(int count) { + List 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 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 groups(List 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 rows, List 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 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 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 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 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 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 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()); + } +} diff --git a/progress.json b/progress.json index a13dde2e..acbdb684 100644 --- a/progress.json +++ b/progress.json @@ -1,10 +1,10 @@ { "version": 1, "total_tasks": 100, - "completed": 1, - "rounds": 1, + "completed": 2, + "rounds": 2, "started_at": "2026-08-29T14:10:48+08:00", - "updated_at": "2026-08-29T14:20:00+08:00", + "updated_at": "2026-08-29T14:35:00+08:00", "tasks": [ { "id": 1, @@ -18,7 +18,7 @@ "title": "将解析载荷改为单一规范行集合,消除 items/groups/allItems 重复数据结构", "module": "similarasin", "dependency": "1", - "status": "pending" + "status": "done" }, { "id": 3,