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:
2026-09-16 20:33:31 +08:00
parent 7a6c3c3fa3
commit 47a9520a82
2 changed files with 171 additions and 10 deletions
@@ -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 实际已被
* 跟价/改价」的记录(会话掉线这类"跑了前几页才断"的场景尤其需要)。
*
* <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) {
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<String, List<PriceTrackSubmitResultRequest.AsinResult>> 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 {