fix(跟价): 失败任务也产出并保留部分结果文件,不再只剩一条失败记录
会话掉线这类「跑到第 N 页才断」的场景,任务本就该是 FAILED,但已经跑出来的行 (哪些 ASIN 真实改价过)必须随任务一起交付,否则用户无从对账、也无法只重跑漏掉的。 - submitResult 的 error / success=false 分支改走 finalizeFailedShop:先按失败落库 (success=0 + 原因),若合并后仍有可用行就排队组装部分结果文件;一行可用数据都没有时 才退化成原来的纯失败(并清掉合并缓存) - markResultFilePending / assembleShopResult 增加 preserveFailure 语义:失败行只挂文件, 不把 success 洗成 1、也不清 errorMessage - updateTaskStatusFromLatestRows:失败行正在组装文件时不让任务提前终态, 否则前端一停轮询、下载按钮永不出现(失败任务的结果文件同样要能下载) PriceTrackTaskServiceTest 新增 2 条契约测试(部分结果排队 + 组装完成后仍失败且可下载), 跟价模块测试 20/20 通过。
This commit is contained in:
+70
-6
@@ -653,13 +653,11 @@ public class PriceTrackTaskService {
|
|||||||
}
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
if (payload.getError() != null && !payload.getError().isBlank()) {
|
if (payload.getError() != null && !payload.getError().isBlank()) {
|
||||||
markResultFailed(fr, payload.getError());
|
finalizeFailedShop(fr, shopKey, payload, payload.getError());
|
||||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (Boolean.FALSE.equals(payload.getSuccess())) {
|
if (Boolean.FALSE.equals(payload.getSuccess())) {
|
||||||
markResultFailed(fr, "shop processing failed");
|
finalizeFailedShop(fr, shopKey, payload, "shop processing failed");
|
||||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
handleSkipAsinDeletionSignals(shopKey, payload);
|
handleSkipAsinDeletionSignals(shopKey, payload);
|
||||||
@@ -815,6 +813,39 @@ public class PriceTrackTaskService {
|
|||||||
updateTaskStatusFromLatestRows(task, latest);
|
updateTaskStatusFromLatestRows(task, latest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败店铺的收尾:任务照旧标 FAILED(原因回显给用户),但已经把跑出来的行组装成
|
||||||
|
* 部分结果文件随任务交付——否则用户只看到「失败」,却拿不到「哪些 ASIN 实际已被
|
||||||
|
* 跟价/改价」的记录(会话掉线这类"跑了前几页才断"的场景尤其需要)。
|
||||||
|
*
|
||||||
|
* <p>组装出来的行仍是失败态(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) {
|
private void markResultFailed(FileResultEntity fr, String message) {
|
||||||
fr.setSuccess(0);
|
fr.setSuccess(0);
|
||||||
fr.setErrorMessage(message);
|
fr.setErrorMessage(message);
|
||||||
@@ -841,11 +872,18 @@ public class PriceTrackTaskService {
|
|||||||
java.io.File workRoot = cn.hutool.core.io.FileUtil.mkdir(
|
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())));
|
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");
|
java.io.File xlsx = cn.hutool.core.io.FileUtil.file(workRoot, stem + ".xlsx");
|
||||||
|
// 失败店铺的部分结果(errorMessage 已写明原因):组装完成时保留失败态,只挂文件。
|
||||||
|
// 否则组装一落地就把行"洗成成功",用户再也看不到「这个店铺其实没跑完」。
|
||||||
|
boolean partialFailure = result.getErrorMessage() != null && !result.getErrorMessage().isBlank();
|
||||||
try {
|
try {
|
||||||
excelAssemblyService.writeWorkbook(xlsx, countries);
|
excelAssemblyService.writeWorkbook(xlsx, countries);
|
||||||
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
|
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
|
||||||
|
if (partialFailure) {
|
||||||
|
result.setSuccess(0);
|
||||||
|
} else {
|
||||||
result.setSuccess(1);
|
result.setSuccess(1);
|
||||||
result.setErrorMessage(null);
|
result.setErrorMessage(null);
|
||||||
|
}
|
||||||
result.setResultFilename(stem + ".xlsx");
|
result.setResultFilename(stem + ".xlsx");
|
||||||
result.setResultFileUrl(objectKey);
|
result.setResultFileUrl(objectKey);
|
||||||
result.setResultFileSize(xlsx.length());
|
result.setResultFileSize(xlsx.length());
|
||||||
@@ -885,9 +923,20 @@ public class PriceTrackTaskService {
|
|||||||
private void enqueueResultFileAssembly(FileResultEntity result,
|
private void enqueueResultFileAssembly(FileResultEntity result,
|
||||||
String shopKey,
|
String shopKey,
|
||||||
PriceTrackSubmitResultRequest.ShopResult payload) {
|
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);
|
applyServerModifyCounts(result.getTaskId(), payload);
|
||||||
taskResultPayloadService.saveLatest(result.getTaskId(), MODULE_TYPE, shopKey, 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);
|
taskFileJobService.enqueueAssembleResult(result.getTaskId(), MODULE_TYPE, result.getId(), shopKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,15 +1042,24 @@ public class PriceTrackTaskService {
|
|||||||
|
|
||||||
private void markResultFilePending(FileResultEntity result,
|
private void markResultFilePending(FileResultEntity result,
|
||||||
String shopKey,
|
String shopKey,
|
||||||
PriceTrackSubmitResultRequest.ShopResult payload) {
|
PriceTrackSubmitResultRequest.ShopResult payload,
|
||||||
|
boolean preserveFailure) {
|
||||||
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries =
|
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries =
|
||||||
excelAssemblyService.normalizeCountriesMap(payload.getCountries());
|
excelAssemblyService.normalizeCountriesMap(payload.getCountries());
|
||||||
String displayName = payload.getShopName() != null && !payload.getShopName().isBlank()
|
String displayName = payload.getShopName() != null && !payload.getShopName().isBlank()
|
||||||
? payload.getShopName().trim()
|
? payload.getShopName().trim()
|
||||||
: shopKey;
|
: shopKey;
|
||||||
String stem = safeFileStem(displayName);
|
String stem = safeFileStem(displayName);
|
||||||
|
if (preserveFailure) {
|
||||||
|
// 失败店铺的部分结果:成功态与失败原因都不能动,只标「文件名已定、文件待组装」
|
||||||
|
result.setSuccess(0);
|
||||||
|
if (result.getErrorMessage() == null || result.getErrorMessage().isBlank()) {
|
||||||
|
result.setErrorMessage("店铺未跑完,仅产出部分结果");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
result.setSuccess(1);
|
result.setSuccess(1);
|
||||||
result.setErrorMessage(null);
|
result.setErrorMessage(null);
|
||||||
|
}
|
||||||
result.setResultFilename(stem + ".xlsx");
|
result.setResultFilename(stem + ".xlsx");
|
||||||
result.setResultFileUrl(null);
|
result.setResultFileUrl(null);
|
||||||
result.setResultFileSize(0L);
|
result.setResultFileSize(0L);
|
||||||
@@ -2164,6 +2222,12 @@ public class PriceTrackTaskService {
|
|||||||
}
|
}
|
||||||
ok++;
|
ok++;
|
||||||
} else if (failed) {
|
} else if (failed) {
|
||||||
|
// 失败行也可能正在组装"部分结果"文件:文件没落地前不能让任务提前终态,
|
||||||
|
// 否则前端一停轮询,下载按钮永远不出现(失败任务的结果文件同样要能下载)
|
||||||
|
if (isResultAwaitingFileAssembly(fr, jobMap.get(fr.getId()))) {
|
||||||
|
allDone = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
fail++;
|
fail++;
|
||||||
allErrors.add(fr.getSourceFilename() + ": " + fr.getErrorMessage());
|
allErrors.add(fr.getSourceFilename() + ": " + fr.getErrorMessage());
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+97
@@ -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.mapper.FileTaskMapper;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
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.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.TaskDistributedLockService;
|
||||||
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
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.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
@@ -255,6 +257,101 @@ class PriceTrackTaskServiceTest {
|
|||||||
verify(lock, times(2)).close();
|
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) {
|
private FileTaskEntity runningTask(long taskId) {
|
||||||
FileTaskEntity task = new FileTaskEntity();
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
task.setId(taskId);
|
task.setId(taskId);
|
||||||
|
|||||||
Reference in New Issue
Block a user