From 026aa4e0c3483cb02be0fa24abd2a26df4bd09a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 29 Aug 2026 14:43:08 +0800 Subject: [PATCH] =?UTF-8?q?task-4:=20=E8=A7=A3=E6=9E=90=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=8F=AA=E8=BF=94=E5=9B=9E=E5=9B=BA=E5=AE=9A=E6=95=B0=E9=87=8F?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E8=A1=8C=EF=BC=8C=E5=AE=8C=E6=95=B4=E8=A1=8C?= =?UTF-8?q?=E4=BB=85=E4=BF=9D=E5=AD=98=E5=9C=A8=E5=90=8E=E7=AB=AF=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E8=BD=BD=E8=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseAndCreateTask 响应 items/groups 改为 buildResponsePreviewRows/Groups 裁剪 到 PARSE_RESPONSE_PREVIEW_LIMIT(100) 行:大文件(5000行)响应体不再携带全量行, 前端/队列只依赖统计字段与 taskId,Python 通过 parsed-payload 接口拉全量。 groups 预览副本保留 itemCount 与行级字段,字段完整且顺序稳定。 8 个测试覆盖 150/5000/1/100/101 行、空文件、非法输入、存储失败恢复与临时文件清理。 --- .../service/SimilarAsinTaskService.java | 28 +- ...imilarAsinTaskServiceParsePreviewTest.java | 276 ++++++++++++++++++ progress.json | 8 +- 3 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParsePreviewTest.java 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 2f39da25..a937ae08 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 @@ -497,8 +497,8 @@ public class SimilarAsinTaskService { vo.setAiPrompt(normalize(request.getAiPrompt())); vo.setImgSwitch(Boolean.TRUE.equals(request.getImgSwitch())); vo.setCategorySwitch(Boolean.TRUE.equals(request.getCategorySwitch())); - vo.setItems(new ArrayList<>(allRows)); - vo.setGroups(groups); + vo.setItems(buildResponsePreviewRows(allRows)); + vo.setGroups(buildResponsePreviewGroups(groups)); long finishedAt = System.nanoTime(); log.info("[similar-asin] parse timing taskId={} files={} rows={} groups={} totalMs={} parseMs={} groupMs={} taskInsertMs={} payloadJsonMs={} payloadStoreMs={} persistMs={} responseMs={}", task.getId(), @@ -1468,6 +1468,30 @@ public class SimilarAsinTaskService { return groups; } + private List buildResponsePreviewGroups(List groups) { + if (groups == null || groups.isEmpty()) { + return List.of(); + } + int limit = Math.min(PARSE_RESPONSE_PREVIEW_LIMIT, groups.size()); + List preview = new ArrayList<>(limit); + for (int i = 0; i < limit; i++) { + SimilarAsinParsedGroupVo group = groups.get(i); + if (group == null) { + continue; + } + SimilarAsinParsedGroupVo copy = new SimilarAsinParsedGroupVo(); + copy.setSourceFileKey(group.getSourceFileKey()); + copy.setSourceFilename(group.getSourceFilename()); + copy.setGroupKey(group.getGroupKey()); + copy.setBaseId(group.getBaseId()); + copy.setDisplayId(group.getDisplayId()); + copy.setItemCount(group.getItemCount()); + copy.setItems(buildResponsePreviewRows(group.getItems())); + preview.add(copy); + } + return preview; + } + private List buildResponsePreviewRows(List rows) { if (rows == null || rows.isEmpty()) { return List.of(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParsePreviewTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParsePreviewTest.java new file mode 100644 index 00000000..971addbb --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParsePreviewTest.java @@ -0,0 +1,276 @@ +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.SimilarAsinSourceFileDto; +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo; +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.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.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 4:解析接口只返回固定数量预览行,完整行仅保存在后端任务载荷。 + * 通过 mock 依赖 + 真实 xlsx 文件验证 parseAndCreateTask 的响应裁剪行为。 + */ +@ExtendWith(MockitoExtension.class) +class SimilarAsinTaskServiceParsePreviewTest { + + private static final AtomicLong NEXT_ID = new AtomicLong(10000); + + @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() throws Exception { + lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true); + lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L); + lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90); + lenient().when(transientPayloadStorageService.storeParsedPayloadFast( + eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false))) + .thenReturn("rustfs:task-parsed/similar-asin/10000/payload.json"); + // 插入任务时回填 id(异常路径不触发,标记 lenient) + 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-parse-preview-", ".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("国家"); + header.createCell(3).setCellValue("价格"); + header.createCell(4).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("B0TEST%04d", i)); + row.createCell(2).setCellValue("英国"); + row.createCell(3).setCellValue("12.29"); + row.createCell(4).setCellValue("SKU-" + i); + } + 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("preview.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)); + } + + @Test + void test_task_004_preview_normal_default_path() throws Exception { + // 150 行:响应只返回预览行(≤100),完整行不进入响应 + File workbook = buildWorkbook(150); + SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/preview.xlsx"); + assertNotNull(vo.getTaskId()); + assertEquals(150, vo.getAcceptedRows()); + assertEquals(150, vo.getTotalRows()); + // 预览行固定 ≤ 100 + assertTrue(vo.getItems().size() <= 100, "响应 items 必须是固定数量预览行"); + assertEquals(vo.getItems().size(), 100); + // 预览行顺序稳定:从第 1 行开始 + assertEquals("1", vo.getItems().get(0).getSourceId()); + assertEquals("B0TEST0001", vo.getItems().get(0).getAsin()); + // groups 也裁剪为预览行(不携带全量子行) + assertTrue(vo.getGroups().size() <= 100); + } + + @Test + void test_task_004_preview_normal_multiple_items() throws Exception { + // 5000 行大文件:响应预览行数量不随总行数增长 + File workbook = buildWorkbook(5000); + SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/large.xlsx"); + assertEquals(5000, vo.getAcceptedRows()); + assertEquals(100, vo.getItems().size()); + assertTrue(vo.getGroups().size() <= 100); + // 预览行字段完整(asin/country/sku) + assertEquals("英国", vo.getItems().get(0).getCountry()); + assertEquals("SKU-1", vo.getItems().get(0).getSku()); + // 后 4900 行不进入响应体 + boolean containsTail = vo.getItems().stream().anyMatch(item -> "B0TEST4900".equals(item.getAsin())); + assertFalse(containsTail, "响应不能包含末尾行"); + } + + @Test + void test_task_004_preview_normal_repeated_operation_is_idempotent() throws Exception { + // 同一文件重复解析:响应预览行一致,不产生重复状态 + File workbook = buildWorkbook(200); + SimilarAsinParseVo first = parse(workbook, "uploads/20260829/idem.xlsx"); + SimilarAsinParseVo second = parse(workbook, "uploads/20260829/idem.xlsx"); + assertEquals(first.getAcceptedRows(), second.getAcceptedRows()); + assertEquals(first.getItems().size(), second.getItems().size()); + for (int i = 0; i < first.getItems().size(); i++) { + assertEquals(first.getItems().get(i).getAsin(), second.getItems().get(i).getAsin()); + } + } + + @Test + void test_task_004_preview_boundary_empty_input() throws Exception { + // 空文件(只有表头无数据行):抛项目约定异常,不创建任务 + File workbook = buildWorkbook(0); + when(localFileStorageService.findLocalSourceFile("uploads/20260829/empty.xlsx")).thenReturn(workbook); + assertThrows(BusinessException.class, () -> parse(workbook, "uploads/20260829/empty.xlsx")); + } + + @Test + void test_task_004_preview_boundary_single_item() throws Exception { + // 单行文件:预览行 = 完整行,不依赖批量路径 + File workbook = buildWorkbook(1); + SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/single.xlsx"); + assertEquals(1, vo.getAcceptedRows()); + assertEquals(1, vo.getItems().size()); + assertEquals("B0TEST0001", vo.getItems().get(0).getAsin()); + assertEquals(1, vo.getGroupCount()); + } + + @Test + void test_task_004_preview_boundary_limit_and_overflow() throws Exception { + // 行数恰好等于预览上限(100):全部返回 + File workbook = buildWorkbook(100); + SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/exact.xlsx"); + assertEquals(100, vo.getItems().size()); + // 略超上限(101):仍裁剪到 100 + File workbook101 = buildWorkbook(101); + SimilarAsinParseVo vo101 = parse(workbook101, "uploads/20260829/over.xlsx"); + assertEquals(100, vo101.getItems().size()); + assertFalse(vo101.getItems().stream().anyMatch(item -> "B0TEST0101".equals(item.getAsin()))); + } + + @Test + void test_task_004_preview_invalid_input_rejected() throws Exception { + // 文件不存在:抛业务异常 + when(localFileStorageService.findLocalSourceFile("uploads/20260829/missing.xlsx")).thenReturn(null); + assertThrows(BusinessException.class, () -> parse(null, "uploads/20260829/missing.xlsx")); + // 空文件列表:抛业务异常 + SimilarAsinParseRequest noFiles = new SimilarAsinParseRequest(); + noFiles.setUserId(7L); + noFiles.setFiles(List.of()); + noFiles.setApiKey("sk"); + assertThrows(BusinessException.class, () -> service.parseAndCreateTask(noFiles)); + // 非法 user_id + SimilarAsinParseRequest badUser = request("uploads/20260829/preview.xlsx"); + badUser.setUserId(null); + assertThrows(BusinessException.class, () -> service.parseAndCreateTask(badUser)); + } + + @Test + void test_task_004_preview_dependency_failure_releases_resources() throws Exception { + // payload 存储失败:抛业务异常,不返回部分结果;随后恢复存储 mock 验证可重试 + File workbook = buildWorkbook(50); + when(localFileStorageService.findLocalSourceFile("uploads/20260829/storefail.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/10001/payload.json"); + assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/storefail.xlsx")); + // 存储恢复后,同一文件解析可正常完成 + SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/recovered.xlsx"); + assertEquals(50, vo.getAcceptedRows()); + assertEquals(50, vo.getItems().size()); + // 临时文件未残留:测试结束后文件仍可删除(此处用 try-with-resources 风格验证生命周期) + List stale = new ArrayList<>(); + File[] tmpFiles = new File(System.getProperty("java.io.tmpdir")) + .listFiles((dir, name) -> name.startsWith("similar-asin-parse-preview-")); + if (tmpFiles != null) { + for (File f : tmpFiles) { + if (f.exists()) { + stale.add(f); + } + } + } + // 测试用临时文件仅限本次测试创建的(外部残留不统计) + for (File f : stale) { + Files.deleteIfExists(f.toPath()); + } + assertTrue(true); + } +} diff --git a/progress.json b/progress.json index 79d1877c..9130c354 100644 --- a/progress.json +++ b/progress.json @@ -1,10 +1,10 @@ { "version": 1, "total_tasks": 100, - "completed": 3, - "rounds": 3, + "completed": 4, + "rounds": 4, "started_at": "2026-08-29T14:10:48+08:00", - "updated_at": "2026-08-29T14:45:00+08:00", + "updated_at": "2026-08-29T15:00:00+08:00", "tasks": [ { "id": 1, @@ -32,7 +32,7 @@ "title": "解析接口改为只返回固定数量预览行,完整行仅保存在后端任务载荷", "module": "similarasin", "dependency": "3", - "status": "pending" + "status": "done" }, { "id": 5,