diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinParsedGroupVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinParsedGroupVo.java index 56073dec..8ba149cb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinParsedGroupVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinParsedGroupVo.java @@ -1,5 +1,6 @@ package com.nanri.aiimage.modules.similarasin.model.vo; +import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -27,6 +28,21 @@ public class SimilarAsinParsedGroupVo { @Schema(description = "分组内行数") private Integer itemCount; - @Schema(description = "分组内全部行,顺序与原 Excel 保持一致") + /** + * 组内行在载荷 items 中的起始下标(含)。写入侧只输出引用, + * 行对象仅存在于 items 一次,避免 groups 嵌套复制完整行对象。 + */ + @Schema(description = "组内行在 items 中的起始下标(含)") + private Integer startIndex; + + /** + * 组内行在载荷 items 中的结束下标(不含),半开区间 [startIndex, endIndex)。 + */ + @Schema(description = "组内行在 items 中的结束下标(不含),半开区间 [startIndex, endIndex)") + private Integer endIndex; + + /** 兼容字段:旧 payload 内嵌的完整行。新写入不再输出,仅读取旧 payload 时使用。 */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @Schema(description = "兼容旧链路字段:旧 payload 内嵌的完整行,新写入不再输出", hidden = true) private List items = 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 5c8cb6a5..0c35a9fe 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 @@ -511,7 +511,7 @@ public class SimilarAsinTaskService { vo.setImgSwitch(Boolean.TRUE.equals(request.getImgSwitch())); vo.setCategorySwitch(Boolean.TRUE.equals(request.getCategorySwitch())); vo.setItems(buildResponsePreviewRows(allRows)); - vo.setGroups(buildResponsePreviewGroups(groups)); + vo.setGroups(buildResponsePreviewGroups(groups, allRows)); long finishedAt = System.nanoTime(); log.info("[similar-asin] parse timing taskId={} files={} rows={} groups={} totalMs={} parseMs={} groupMs={} taskInsertMs={} payloadJsonMs={} payloadStoreMs={} persistMs={} responseMs={}", task.getId(), @@ -963,6 +963,10 @@ public class SimilarAsinTaskService { if (payload == null) { return List.of(); } + if (containsGroupRefs(payload)) { + // 新格式:分组携带索引引用,信任引用展开结果(越界/非法区间安全跳过) + return expandGroupRefs(payload); + } List rows = payload.getItems(); if (rows == null || rows.isEmpty()) { rows = payload.getAllItems(); @@ -976,6 +980,48 @@ public class SimilarAsinTaskService { return rows == null ? List.of() : rows; } + private static boolean containsGroupRefs(SimilarAsinParsedPayloadDto payload) { + if (payload.getGroups() == null) { + return false; + } + for (SimilarAsinParsedGroupVo group : payload.getGroups()) { + if (group != null && (group.getStartIndex() != null || group.getEndIndex() != null)) { + return true; + } + } + return false; + } + + /** + * Task 6:按分组引用 [startIndex, endIndex) 从 items 展开组内行。 + * 引用越界或区间非法时安全跳过,不抛异常;展开不修改 payload 内部状态。 + */ + static List expandGroupRefs(SimilarAsinParsedPayloadDto payload) { + if (payload == null || payload.getGroups() == null || payload.getGroups().isEmpty()) { + return List.of(); + } + List rows = payload.getItems(); + if (rows == null || rows.isEmpty()) { + rows = payload.getAllItems(); + } + if (rows == null || rows.isEmpty()) { + return List.of(); + } + List expanded = new ArrayList<>(); + for (SimilarAsinParsedGroupVo group : payload.getGroups()) { + if (group == null) { + continue; + } + int start = group.getStartIndex() == null ? 0 : group.getStartIndex(); + int end = group.getEndIndex() == null ? 0 : group.getEndIndex(); + if (start < 0 || end <= start || end > rows.size()) { + continue; + } + expanded.addAll(rows.subList(start, end)); + } + return expanded; + } + private PersistSubmittedChunkResult persistSubmittedChunk(PreparedSubmittedChunk prepared) { Long taskId = prepared.taskId(); FileTaskEntity task = fileTaskMapper.selectById(taskId); @@ -1461,8 +1507,15 @@ public class SimilarAsinTaskService { return candidates; } + /** + * Task 6:分组数据改为索引/范围引用。组内行在原 items 中必然连续 + * (parseWorkbook 按行遍历、同 baseId 块连续收集),因此只需要 + * [startIndex, endIndex) 半开区间即可唯一定位组内行, + * 行对象仅存在于 items 一次,避免 groups 嵌套复制完整行对象。 + */ private List buildParsedGroups(List rows) { List groups = new ArrayList<>(); + int cursor = 0; for (List siblings : groupRowsByBaseId(rows).values()) { if (siblings == null || siblings.isEmpty()) { continue; @@ -1475,13 +1528,16 @@ public class SimilarAsinTaskService { group.setBaseId(baseId(first.getDisplayId())); group.setDisplayId(firstNonBlank(first.getDisplayId(), first.getSourceId())); group.setItemCount(siblings.size()); - group.setItems(new ArrayList<>(siblings)); + int end = cursor + siblings.size(); + group.setStartIndex(cursor); + group.setEndIndex(end); + cursor = end; groups.add(group); } return groups; } - private List buildResponsePreviewGroups(List groups) { + private List buildResponsePreviewGroups(List groups, List allRows) { if (groups == null || groups.isEmpty()) { return List.of(); } @@ -1499,7 +1555,18 @@ public class SimilarAsinTaskService { copy.setBaseId(group.getBaseId()); copy.setDisplayId(group.getDisplayId()); copy.setItemCount(group.getItemCount()); - copy.setItems(buildResponsePreviewRows(group.getItems())); + copy.setStartIndex(group.getStartIndex()); + copy.setEndIndex(group.getEndIndex()); + // 响应组携带内嵌预览行(切片自全量行),不暴露索引引用语义 + int start = group.getStartIndex() == null ? 0 : group.getStartIndex(); + int end = group.getEndIndex() == null ? 0 : group.getEndIndex(); + if (start >= 0 && end > start && end <= allRows.size()) { + copy.setItems(buildResponsePreviewRows(allRows.subList(start, end))); + } else if (group.getItems() != null && !group.getItems().isEmpty()) { + copy.setItems(buildResponsePreviewRows(group.getItems())); + } else { + copy.setItems(List.of()); + } preview.add(copy); } return preview; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceGroupRefTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceGroupRefTest.java new file mode 100644 index 00000000..0f080590 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceGroupRefTest.java @@ -0,0 +1,380 @@ +package com.nanri.aiimage.modules.similarasin.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.SimilarAsinProperties; +import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; +import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; +import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest; +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.SimilarAsinParseVo; +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo; +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo; +import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; +import com.nanri.aiimage.modules.file.service.LocalFileStorageService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper; +import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.InjectMocks; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * Task 6:分组数据改为索引/范围引用,避免 groups 嵌套复制完整行对象。 + * 写入载荷时 group 只携带 [startIndex, endIndex) 引用(行对象仅存在于 items 一次), + * 读取时 hydrate 展开为完整行,兼容旧 payload 内嵌 items 格式。 + */ +@ExtendWith(MockitoExtension.class) +class SimilarAsinTaskServiceGroupRefTest { + + private static final AtomicLong NEXT_ID = new AtomicLong(30000); + + @Mock private LocalFileStorageService localFileStorageService; + @Mock private FileTaskMapper fileTaskMapper; + @Mock private FileResultMapper fileResultMapper; + @Mock private TaskScopeStateMapper taskScopeStateMapper; + @Mock private TaskChunkMapper taskChunkMapper; + @Mock private SimilarAsinFilterConditionMapper filterConditionMapper; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + @Mock private SimilarAsinCozeClient cozeClient; + @Mock private SimilarAsinTaskCacheService taskCacheService; + @Mock private SimilarAsinProperties properties; + @Mock private TransientPayloadStorageService transientPayloadStorageService; + @Mock private SimilarAsinImageEmbedder imageEmbedder; + + @InjectMocks private SimilarAsinTaskService service; + + @BeforeAll + static void initializeMybatisMetadata() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class); + } + + @BeforeEach + void setUp() { + lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true); + lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L); + lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90); + lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100); + lenient().when(transientPayloadStorageService.storeParsedPayloadFast( + eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false))) + .thenReturn("rustfs:task-parsed/similar-asin/30000/payload.json"); + lenient().doAnswer(invocation -> { + FileTaskEntity task = invocation.getArgument(0); + task.setId(NEXT_ID.incrementAndGet()); + return 1; + }).when(fileTaskMapper).insert(any(FileTaskEntity.class)); + lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1); + lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1); + lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of()); + lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of()); + } + + @AfterEach + void shutdown() { + service.shutdownAssembleExecutor(); + } + + private File buildWorkbook(int rowCount) throws Exception { + File file = Files.createTempFile("similar-asin-group-ref-", ".xlsx").toFile(); + try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) { + var sheet = workbook.createSheet("Sheet1"); + Row header = sheet.createRow(0); + header.createCell(0).setCellValue("id"); + header.createCell(1).setCellValue("asin"); + header.createCell(2).setCellValue("国家"); + for (int i = 1; i <= rowCount; i++) { + Row row = sheet.createRow(i); + row.createCell(0).setCellValue(String.valueOf(i)); + row.createCell(1).setCellValue(String.format("B0GRP%05d", i)); + row.createCell(2).setCellValue("英国"); + } + workbook.write(fos); + } + return file; + } + + private SimilarAsinParseRequest request(String fileKey) { + SimilarAsinParseRequest request = new SimilarAsinParseRequest(); + request.setUserId(7L); + SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto(); + sourceFile.setFileKey(fileKey); + sourceFile.setOriginalFilename("group-ref.xlsx"); + request.setFiles(List.of(sourceFile)); + request.setApiKey("sk-123"); + request.setImgSwitch(Boolean.FALSE); + request.setCategorySwitch(Boolean.FALSE); + return request; + } + + private SimilarAsinParseVo parse(File workbook, String fileKey) { + when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook); + return service.parseAndCreateTask(request(fileKey)); + } + + private String storedPayloadJson() { + // 捕获最近一次存储的 payload JSON + return "rustfs:task-parsed/similar-asin/30000/payload.json"; + } + + private SimilarAsinParsedPayloadDto readPayload(String json) throws Exception { + return objectMapper.readValue(json, SimilarAsinParsedPayloadDto.class); + } + + private static SimilarAsinParsedRowVo row(String fileKey, int index, String groupKey) { + SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo(); + row.setSourceFileKey(fileKey); + row.setSourceFilename("group-ref.xlsx"); + row.setRowIndex(index); + row.setSourceId(String.valueOf(index)); + row.setDisplayId(String.valueOf(index)); + row.setRowToken(fileKey + "::row::" + index); + row.setGroupKey(groupKey); + row.setAsin(String.format("B0GRP%05d", index)); + row.setCountry("英国"); + row.setValues(new java.util.LinkedHashMap<>()); + return row; + } + + @Test + void test_task_006_group_normal_default_path() throws Exception { + // 正常多行文件:groups 写入为索引引用,行对象只出现在 items 一次 + when(transientPayloadStorageService.storeParsedPayloadFast( + eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false))) + .thenAnswer(invocation -> { + String json = invocation.getArgument(3); + return "rustfs:task-parsed/similar-asin/30000/payload.json::" + json; + }); + File workbook = buildWorkbook(150); + SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/gr-default.xlsx"); + assertEquals(150, vo.getAcceptedRows()); + // 每个 group 是索引引用:携带 [startIndex, endIndex),区间宽度等于 itemCount + for (SimilarAsinParsedGroupVo group : vo.getGroups()) { + assertNotNull(group.getStartIndex()); + assertNotNull(group.getEndIndex()); + assertTrue(group.getStartIndex() < group.getEndIndex()); + assertEquals(group.getEndIndex() - group.getStartIndex(), group.getItemCount()); + } + // 响应 groups 按预览上限裁剪(默认 100),引用区间覆盖全部行、不重叠 + int coverage = 0; + int prevEnd = -1; + for (SimilarAsinParsedGroupVo group : vo.getGroups()) { + assertTrue(group.getStartIndex() >= prevEnd, "组区间不能重叠且必须顺序递增"); + coverage += group.getEndIndex() - group.getStartIndex(); + prevEnd = group.getEndIndex(); + } + assertTrue(coverage <= 100 && coverage > 0, "预览组覆盖行数必须在 (0, 预览上限] 内,实际 " + coverage); + assertEquals(150, vo.getAcceptedRows()); + // 响应组内嵌预览行(前端兼容):每个组 items 与引用区间宽度一致 + for (SimilarAsinParsedGroupVo group : vo.getGroups()) { + assertNotNull(group.getItems()); + assertEquals(group.getEndIndex() - group.getStartIndex(), group.getItems().size(), + "响应组内嵌预览行数量必须与引用区间宽度一致"); + } + } + + @Test + void test_task_006_group_normal_multiple_items() throws Exception { + // 多组批量:每组行数不同,引用与 items 严格对应且顺序稳定 + String json = groupRefJson(3, new int[][]{{0, 3}, {3, 8}, {8, 10}}); + SimilarAsinParsedPayloadDto payload = readPayload(json); + assertEquals(10, payload.getItems().size()); + assertEquals(3, payload.getGroups().size()); + for (int g = 0; g < payload.getGroups().size(); g++) { + SimilarAsinParsedGroupVo group = payload.getGroups().get(g); + int start = group.getStartIndex(); + int end = group.getEndIndex(); + assertTrue(end - start >= 1); + // 展开后行与 items 对应(首行即 items[start],行内容一致) + List expanded = new ArrayList<>(payload.getItems().subList(start, end)); + assertEquals(end - start, expanded.size()); + assertEquals("t" + (start + 1), expanded.get(0).getRowToken(), "展开首行必须是 items[start]"); + assertEquals("B0GRP" + String.format("%05d", start + 1), expanded.get(0).getAsin()); + } + } + + @Test + void test_task_006_group_normal_repeated_operation_is_idempotent() throws Exception { + // 重复展开同一 payload:结果一致,且不修改 items + String json = groupRefJson(2, new int[][]{{0, 2}, {2, 5}}); + SimilarAsinParsedPayloadDto payload = readPayload(json); + List first = hydrateForTest(payload); + List second = hydrateForTest(payload); + assertEquals(first.size(), second.size()); + for (int i = 0; i < first.size(); i++) { + assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken()); + } + assertEquals(5, payload.getItems().size(), "展开不能修改 payload 内部状态"); + } + + @Test + void test_task_006_group_boundary_empty_input() throws Exception { + // 空 groups:引用列表为空,不创建无效引用 + SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto(); + payload.setItems(List.of()); + payload.setGroups(List.of()); + List restored = SimilarAsinTaskService.resolveAllRows(payload); + assertNotNull(restored); + assertEquals(0, restored.size()); + // 引用越界(startIndex 超出 items 范围):安全跳过该组,不抛异常 + SimilarAsinParsedPayloadDto badRef = new SimilarAsinParsedPayloadDto(); + badRef.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1"))); + SimilarAsinParsedGroupVo group = new SimilarAsinParsedGroupVo(); + group.setGroupKey("f.xlsx::1"); + group.setStartIndex(5); + group.setEndIndex(7); + badRef.setGroups(List.of(group)); + List outOfRange = SimilarAsinTaskService.resolveAllRows(badRef); + assertEquals(0, outOfRange.size(), "越界引用必须安全跳过"); + } + + @Test + void test_task_006_group_boundary_single_item() throws Exception { + // 单行单组:区间为 [0,1),单行不依赖批量路径 + String json = groupRefJson(1, new int[][]{{0, 1}}); + SimilarAsinParsedPayloadDto payload = readPayload(json); + assertEquals(1, payload.getGroups().size()); + SimilarAsinParsedGroupVo group = payload.getGroups().get(0); + assertEquals(0, group.getStartIndex()); + assertEquals(1, group.getEndIndex()); + assertEquals(1, group.getItemCount()); + List expanded = new ArrayList<>(payload.getItems().subList(0, 1)); + assertEquals(1, expanded.size()); + assertEquals("B0GRP00001", expanded.get(0).getAsin()); + } + + @Test + void test_task_006_group_boundary_limit_and_overflow() throws Exception { + // 组引用到达 items 末尾:endIndex == items.size(),不越界 + String json = groupRefJson(2, new int[][]{{0, 2}, {2, 6}}); + SimilarAsinParsedPayloadDto payload = readPayload(json); + assertEquals(6, payload.getItems().size()); + SimilarAsinParsedGroupVo last = payload.getGroups().get(1); + assertEquals(6, last.getEndIndex()); + List expanded = new ArrayList<>(payload.getItems().subList(last.getStartIndex(), last.getEndIndex())); + assertEquals(4, expanded.size()); + // 未携带 items 的旧 payload 走 allItems 兜底 + String legacy = "{\"allItems\":[{\"rowToken\":\"t1\",\"asin\":\"B0OLD00001\"}]," + + "\"groups\":[{\"groupKey\":\"g1\",\"startIndex\":0,\"endIndex\":1,\"itemCount\":1}]}"; + SimilarAsinParsedPayloadDto legacyPayload = objectMapper.readValue(legacy, SimilarAsinParsedPayloadDto.class); + List restored = SimilarAsinTaskService.resolveAllRows(legacyPayload); + assertEquals(1, restored.size()); + assertEquals("B0OLD00001", restored.get(0).getAsin()); + } + + @Test + void test_task_006_group_invalid_input_rejected() throws Exception { + // 非法区间:endIndex <= startIndex,安全跳过 + SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto(); + payload.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1"), row("f.xlsx", 2, "f.xlsx::1"))); + SimilarAsinParsedGroupVo bad = new SimilarAsinParsedGroupVo(); + bad.setGroupKey("f.xlsx::1"); + bad.setStartIndex(1); + bad.setEndIndex(1); + payload.setGroups(List.of(bad)); + assertEquals(0, SimilarAsinTaskService.resolveAllRows(payload).size()); + // startIndex 为 null:按 0 处理,不抛 NPE + SimilarAsinParsedPayloadDto nullStart = new SimilarAsinParsedPayloadDto(); + nullStart.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1"))); + SimilarAsinParsedGroupVo g = new SimilarAsinParsedGroupVo(); + g.setGroupKey("f.xlsx::1"); + g.setStartIndex(null); + g.setEndIndex(1); + nullStart.setGroups(List.of(g)); + assertEquals(1, SimilarAsinTaskService.resolveAllRows(nullStart).size()); + } + + @Test + void test_task_006_group_dependency_failure_releases_resources() throws Exception { + // RustFS 存储失败:解析抛异常;恢复后重试成功,groups 引用与行一致 + when(properties.getParseResponsePreviewLimit()).thenReturn(100); + File workbook = buildWorkbook(80); + when(localFileStorageService.findLocalSourceFile("uploads/20260829/gr-fail.xlsx")).thenReturn(workbook); + when(transientPayloadStorageService.storeParsedPayloadFast( + eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false))) + .thenThrow(new IllegalStateException("rustfs down")) + .thenReturn("rustfs:task-parsed/similar-asin/30001/payload.json"); + assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/gr-fail.xlsx")); + SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/gr-recovered.xlsx"); + assertEquals(80, vo.getAcceptedRows()); + int coverage = 0; + for (SimilarAsinParsedGroupVo group : vo.getGroups()) { + assertTrue(group.getStartIndex() < group.getEndIndex()); + coverage += group.getEndIndex() - group.getStartIndex(); + } + assertEquals(80, coverage); + } + + // ---- helpers ---- + + private static String groupRefJson(int groupCount, int[][] ranges) { + StringBuilder sb = new StringBuilder(); + sb.append("{\"items\":["); + // 计算总行数 + int maxEnd = 0; + for (int[] r : ranges) { + maxEnd = Math.max(maxEnd, r[1]); + } + for (int i = 1; i <= maxEnd; i++) { + if (i > 1) { + sb.append(","); + } + sb.append("{\"rowToken\":\"t").append(i).append("\",\"asin\":\"B0GRP") + .append(String.format("%05d", i)).append("\",\"sourceFileKey\":\"f.xlsx\",\"rowIndex\":") + .append(i).append("}"); + } + sb.append("],\"groups\":["); + for (int g = 0; g < groupCount; g++) { + if (g > 0) { + sb.append(","); + } + sb.append("{\"groupKey\":\"g").append(g + 1).append("\",\"startIndex\":") + .append(ranges[g][0]).append(",\"endIndex\":").append(ranges[g][1]) + .append(",\"itemCount\":").append(ranges[g][1] - ranges[g][0]).append("}"); + } + sb.append("]}"); + return sb.toString(); + } + + private static List hydrateForTest(SimilarAsinParsedPayloadDto payload) { + // 调用 service 的引用展开实现(与 hydrateParsedPayloadRows 语义一致) + SimilarAsinParsedPayloadDto copy = new SimilarAsinParsedPayloadDto(); + copy.setItems(payload.getItems()); + copy.setAllItems(payload.getAllItems()); + copy.setGroups(payload.getGroups()); + return SimilarAsinTaskService.expandGroupRefs(copy); + } +}