task-6: 分组数据改为索引/范围引用,避免 groups 嵌套复制完整行对象

buildParsedGroups 只写 [startIndex, endIndex) 半开区间引用(同 baseId 行在
items 中天然连续),行对象仅存在于 items 一次;resolveAllRows 优先按引用
展开并安全跳过越界/非法区间;响应组仍内嵌预览行兼容前端。旧 payload 内嵌
items 格式继续可读。新增 8 个测试覆盖引用写入、批量、幂等、空输入、单组、
越界 clamp、非法区间、依赖失败恢复。
This commit is contained in:
2026-08-29 15:02:15 +08:00
parent 16bec1c0fa
commit db6e8f0ce8
3 changed files with 468 additions and 5 deletions
@@ -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<>();
}
@@ -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;
@@ -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);
}
}