task-6: 分组数据改为索引/范围引用,避免 groups 嵌套复制完整行对象
buildParsedGroups 只写 [startIndex, endIndex) 半开区间引用(同 baseId 行在 items 中天然连续),行对象仅存在于 items 一次;resolveAllRows 优先按引用 展开并安全跳过越界/非法区间;响应组仍内嵌预览行兼容前端。旧 payload 内嵌 items 格式继续可读。新增 8 个测试覆盖引用写入、批量、幂等、空输入、单组、 越界 clamp、非法区间、依赖失败恢复。
This commit is contained in:
+17
-1
@@ -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<SimilarAsinParsedRowVo> items = new ArrayList<>();
|
||||
}
|
||||
|
||||
+71
-4
@@ -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<SimilarAsinParsedRowVo> 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<SimilarAsinParsedRowVo> expandGroupRefs(SimilarAsinParsedPayloadDto payload) {
|
||||
if (payload == null || payload.getGroups() == null || payload.getGroups().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<SimilarAsinParsedRowVo> rows = payload.getItems();
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
rows = payload.getAllItems();
|
||||
}
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<SimilarAsinParsedRowVo> 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<SimilarAsinParsedGroupVo> buildParsedGroups(List<SimilarAsinParsedRowVo> rows) {
|
||||
List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
|
||||
int cursor = 0;
|
||||
for (List<SimilarAsinParsedRowVo> 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<SimilarAsinParsedGroupVo> buildResponsePreviewGroups(List<SimilarAsinParsedGroupVo> groups) {
|
||||
private List<SimilarAsinParsedGroupVo> buildResponsePreviewGroups(List<SimilarAsinParsedGroupVo> groups, List<SimilarAsinParsedRowVo> 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;
|
||||
|
||||
+380
@@ -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<SimilarAsinParsedRowVo> 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<SimilarAsinParsedRowVo> first = hydrateForTest(payload);
|
||||
List<SimilarAsinParsedRowVo> 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<SimilarAsinParsedRowVo> 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<SimilarAsinParsedRowVo> 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<SimilarAsinParsedRowVo> 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<SimilarAsinParsedRowVo> 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<SimilarAsinParsedRowVo> 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<SimilarAsinParsedRowVo> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user