diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index d6f15198..ec5e82e0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -653,13 +653,11 @@ public class PriceTrackTaskService { } changed = true; if (payload.getError() != null && !payload.getError().isBlank()) { - markResultFailed(fr, payload.getError()); - priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey); + finalizeFailedShop(fr, shopKey, payload, payload.getError()); continue; } if (Boolean.FALSE.equals(payload.getSuccess())) { - markResultFailed(fr, "shop processing failed"); - priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey); + finalizeFailedShop(fr, shopKey, payload, "shop processing failed"); continue; } handleSkipAsinDeletionSignals(shopKey, payload); @@ -815,6 +813,39 @@ public class PriceTrackTaskService { updateTaskStatusFromLatestRows(task, latest); } + /** + * 失败店铺的收尾:任务照旧标 FAILED(原因回显给用户),但已经把跑出来的行组装成 + * 部分结果文件随任务交付——否则用户只看到「失败」,却拿不到「哪些 ASIN 实际已被 + * 跟价/改价」的记录(会话掉线这类"跑了前几页才断"的场景尤其需要)。 + * + *

组装出来的行仍是失败态(success=0 + errorMessage),任务状态因此不变, + * 只是多了个可下载的文件;只有一行可用数据都没有时才退化成纯失败。 + */ + private void finalizeFailedShop(FileResultEntity fr, String shopKey, + PriceTrackSubmitResultRequest.ShopResult payload, String errorMessage) { + String finalMessage = errorMessage; + try { + PriceTrackSubmitResultRequest.ShopResult merged = mergeShopPayload(fr.getTaskId(), shopKey, payload); + int rows = countPayloadRows(merged); + if (rows > 0) { + markResultFailed(fr, errorMessage); + enqueueResultFileAssembly(fr, shopKey, merged, true); + priceTrackTaskCacheService.removeShopMergedPayload(fr.getTaskId(), shopKey); + log.info("[price-track] 失败店铺仍产出部分结果 taskId={} shop={} rows={} error={}", + fr.getTaskId(), shopKey, rows, errorMessage); + return; + } + log.info("[price-track] 失败店铺无可用行,不产出结果文件 taskId={} shop={} error={}", + fr.getTaskId(), shopKey, errorMessage); + } catch (Exception ex) { + log.warn("[price-track] 失败店铺部分结果排队失败,仅标记失败 taskId={} shop={} msg={}", + fr.getTaskId(), shopKey, ex.getMessage(), ex); + finalMessage = errorMessage + "(部分结果组装排队失败:" + ex.getMessage() + ")"; + } + markResultFailed(fr, finalMessage); + priceTrackTaskCacheService.removeShopMergedPayload(fr.getTaskId(), shopKey); + } + private void markResultFailed(FileResultEntity fr, String message) { fr.setSuccess(0); fr.setErrorMessage(message); @@ -841,11 +872,18 @@ public class PriceTrackTaskService { java.io.File workRoot = cn.hutool.core.io.FileUtil.mkdir( cn.hutool.core.io.FileUtil.file(System.getProperty("java.io.tmpdir"), "price-track-result", String.valueOf(result.getTaskId()))); java.io.File xlsx = cn.hutool.core.io.FileUtil.file(workRoot, stem + ".xlsx"); + // 失败店铺的部分结果(errorMessage 已写明原因):组装完成时保留失败态,只挂文件。 + // 否则组装一落地就把行"洗成成功",用户再也看不到「这个店铺其实没跑完」。 + boolean partialFailure = result.getErrorMessage() != null && !result.getErrorMessage().isBlank(); try { excelAssemblyService.writeWorkbook(xlsx, countries); String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE); - result.setSuccess(1); - result.setErrorMessage(null); + if (partialFailure) { + result.setSuccess(0); + } else { + result.setSuccess(1); + result.setErrorMessage(null); + } result.setResultFilename(stem + ".xlsx"); result.setResultFileUrl(objectKey); result.setResultFileSize(xlsx.length()); @@ -885,9 +923,20 @@ public class PriceTrackTaskService { private void enqueueResultFileAssembly(FileResultEntity result, String shopKey, PriceTrackSubmitResultRequest.ShopResult payload) { + enqueueResultFileAssembly(result, shopKey, payload, false); + } + + /** + * @param preserveFailure 失败店铺的部分结果:保留 success=0 + 失败原因,只把文件挂上去, + * 任务状态不变(仍 FAILED),用户仍能下载已跑出来的行 + */ + private void enqueueResultFileAssembly(FileResultEntity result, + String shopKey, + PriceTrackSubmitResultRequest.ShopResult payload, + boolean preserveFailure) { applyServerModifyCounts(result.getTaskId(), payload); taskResultPayloadService.saveLatest(result.getTaskId(), MODULE_TYPE, shopKey, payload); - markResultFilePending(result, shopKey, payload); + markResultFilePending(result, shopKey, payload, preserveFailure); taskFileJobService.enqueueAssembleResult(result.getTaskId(), MODULE_TYPE, result.getId(), shopKey); } @@ -993,15 +1042,24 @@ public class PriceTrackTaskService { private void markResultFilePending(FileResultEntity result, String shopKey, - PriceTrackSubmitResultRequest.ShopResult payload) { + PriceTrackSubmitResultRequest.ShopResult payload, + boolean preserveFailure) { Map> countries = excelAssemblyService.normalizeCountriesMap(payload.getCountries()); String displayName = payload.getShopName() != null && !payload.getShopName().isBlank() ? payload.getShopName().trim() : shopKey; String stem = safeFileStem(displayName); - result.setSuccess(1); - result.setErrorMessage(null); + if (preserveFailure) { + // 失败店铺的部分结果:成功态与失败原因都不能动,只标「文件名已定、文件待组装」 + result.setSuccess(0); + if (result.getErrorMessage() == null || result.getErrorMessage().isBlank()) { + result.setErrorMessage("店铺未跑完,仅产出部分结果"); + } + } else { + result.setSuccess(1); + result.setErrorMessage(null); + } result.setResultFilename(stem + ".xlsx"); result.setResultFileUrl(null); result.setResultFileSize(0L); @@ -2164,6 +2222,12 @@ public class PriceTrackTaskService { } ok++; } else if (failed) { + // 失败行也可能正在组装"部分结果"文件:文件没落地前不能让任务提前终态, + // 否则前端一停轮询,下载按钮永远不出现(失败任务的结果文件同样要能下载) + if (isResultAwaitingFileAssembly(fr, jobMap.get(fr.getId()))) { + allDone = false; + continue; + } fail++; allErrors.add(fr.getSourceFilename() + ": " + fr.getErrorMessage()); } else { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java index 2b1f77ec..9be4f1ef 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java @@ -10,6 +10,7 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; import com.nanri.aiimage.modules.task.service.TaskDistributedLockService; import com.nanri.aiimage.modules.task.service.TaskFileJobService; import com.nanri.aiimage.modules.task.service.TaskResultPayloadService; @@ -28,6 +29,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; @@ -255,6 +257,101 @@ class PriceTrackTaskServiceTest { verify(lock, times(2)).close(); } + /** + * 跟价 28443 场景:店铺跑到第 2 页会话掉线(955 件在售只跟了 20 件), + * 客户端收尾回传带 error。失败归失败,但已经跑出来的行必须组装成部分结果文件, + * 否则用户只看到「失败」,拿不到「哪些 ASIN 实际已被改价」的记录。 + */ + @Test + void failedShopWithPartialRowsStillQueuesResultFile() throws Exception { + long taskId = 28443L; + String shopName = "林芳"; + String error = "国家 英国 未跑完:会话失效:页面已跳转到登录页,商品列表为空"; + + FileTaskEntity task = runningTask(taskId); + FileResultEntity result = pendingResult(taskId, shopName); + + PriceTrackSubmitResultRequest.ShopResult shopResult = shopResult( + shopName, + Map.of("UK", List.of(asinRow("B0DWX8WJF8", "改价成功", "改价成功")))); + shopResult.setError(error); + PriceTrackSubmitResultRequest request = new PriceTrackSubmitResultRequest(); + request.setShops(List.of(shopResult)); + + TaskFileJobEntity pendingJob = new TaskFileJobEntity(); + pendingJob.setTaskId(taskId); + pendingJob.setResultId(result.getId()); + pendingJob.setScopeKey(shopName); + pendingJob.setStatus("PENDING"); + + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + when(taskDistributedLockService.acquire("PRICE_TRACK", taskId)).thenReturn(lock); + when(priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId))).thenReturn(Map.of(taskId, task)); + when(fileResultMapper.selectList(any())).thenReturn(List.of(result)); + when(ziniaoShopSwitchService.normalizeShopName(shopName)).thenReturn(shopName); + when(excelAssemblyService.normalizeCountriesMap(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(excelAssemblyService.countRows(any())).thenReturn(1); + when(taskFileJobService.findAssembleJobsByResultIds(anyString(), any())) + .thenReturn(Map.of(result.getId(), pendingJob)); + when(objectMapper.writeValueAsString(any())).thenReturn("[]"); + + service.submitResult(taskId, request); + + // 部分结果文件已排队,且失败态没被"洗成成功" + verify(taskFileJobService).enqueueAssembleResult(taskId, "PRICE_TRACK", result.getId(), shopName); + assertEquals(0, result.getSuccess()); + assertEquals(error, result.getErrorMessage()); + assertEquals(shopName + ".xlsx", result.getResultFilename()); + assertNull(result.getResultFileUrl(), "文件还没组装完,URL 由组装任务回填"); + + // 文件还没落地前任务必须留在 RUNNING:提前终态会让前端停轮询、下载按钮永不出现 + assertEquals("RUNNING", task.getStatus()); + assertNull(task.getFinishedAt()); + } + + /** + * 部分结果文件组装完成后:文件挂上去了,但行/任务仍是失败态, + * 用户既能看到"没跑完",也能下载已跑出来的部分。 + */ + @Test + void assembledPartialResultKeepsFailedStateAndStaysDownloadable() throws Exception { + long taskId = 28444L; + String shopName = "林芳"; + String error = "国家 英国 未跑完:会话失效"; + String objectKey = "result/price_track/abc/林芳.xlsx"; + + FileTaskEntity task = runningTask(taskId); + FileResultEntity result = pendingResult(taskId, shopName); + result.setErrorMessage(error); + result.setResultFilename(shopName + ".xlsx"); + + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(taskId); + job.setResultId(result.getId()); + job.setScopeKey(shopName); + job.setStatus("PENDING"); + + when(priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId))).thenReturn(Map.of(taskId, task)); + when(fileResultMapper.selectById(result.getId())).thenReturn(result); + when(fileResultMapper.selectList(any())).thenReturn(List.of(result)); + when(taskResultPayloadService.getLatest(eq(taskId), eq("PRICE_TRACK"), eq(shopName), + eq(PriceTrackSubmitResultRequest.ShopResult.class))) + .thenReturn(shopResult(shopName, + Map.of("UK", List.of(asinRow("B0DWX8WJF8", "改价成功", "改价成功"))))); + when(excelAssemblyService.normalizeCountriesMap(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(excelAssemblyService.countRows(any())).thenReturn(1); + when(ossStorageService.uploadResultFile(any(), eq("PRICE_TRACK"))).thenReturn(objectKey); + when(objectMapper.writeValueAsString(any())).thenReturn("[]"); + + service.processResultFileJob(job); + + assertEquals(0, result.getSuccess(), "部分结果不改变失败态"); + assertEquals(error, result.getErrorMessage(), "失败原因不能被组装洗掉"); + assertEquals(objectKey, result.getResultFileUrl(), "文件挂上去,失败任务也能下载"); + assertEquals("FAILED", task.getStatus()); + assertTrue(task.getErrorMessage() != null && task.getErrorMessage().contains("未跑完")); + } + private FileTaskEntity runningTask(long taskId) { FileTaskEntity task = new FileTaskEntity(); task.setId(taskId);