diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java index 94c70050..3ad6d6f3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java @@ -33,4 +33,17 @@ public class TaskHeartbeatController { @Valid @RequestBody(required = false) TaskHeartbeatRequest request) { return ApiResponse.success(taskHeartbeatService.heartbeat(taskId, request)); } + + @PostMapping("/{taskId}/interrupted") + @Operation( + summary = "上报客户端异常中断", + description = "客户端重启后发现自己上次进程崩溃时调用:将仍在 RUNNING 的任务立即标为终态" + + "(file_task→FAILED,brand_crawl_tasks→cancelled),替代最长 30 分钟的 stale 兜底。幂等,非 RUNNING 状态不修改。") + public ApiResponse interrupted( + @Parameter(description = "任务 ID", required = true, example = "200") + @PathVariable Long taskId, + @RequestBody(required = false) TaskHeartbeatRequest request) { + String reason = request == null ? null : request.getPhase(); + return ApiResponse.success(taskHeartbeatService.markInterrupted(taskId, reason)); + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java index dfb2973f..fdaf48d1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java @@ -117,6 +117,56 @@ public class TaskHeartbeatService { return fileTaskMapper.selectOne(fileQuery); } + /** + * 客户端崩溃恢复:客户端重启后发现自己上次进程级崩溃(journal 残留), + * 调此接口把上次还在 RUNNING 的任务立即标终态,替代最长等 30 分钟的 stale 兜底。 + * file_task RUNNING → FAILED;brand_crawl_tasks running → cancelled;其余状态不动(幂等)。 + */ + public TaskHeartbeatVo markInterrupted(Long taskId, String reason) { + if (taskId == null || taskId <= 0) { + log.warn("[task-interrupted] ignored invalid taskId={}", taskId); + return TaskHeartbeatVo.notAlive(null, null, "invalid taskId"); + } + String safeReason = (reason == null || reason.isBlank()) + ? "客户端异常中断,任务已自动失败" + : "客户端异常中断: " + reason.trim(); + FileTaskEntity fileTask = selectFileTask(taskId); + if (fileTask != null) { + String status = fileTask.getStatus(); + int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper() + .eq(FileTaskEntity::getId, taskId) + .eq(FileTaskEntity::getStatus, STATUS_RUNNING) + .set(FileTaskEntity::getStatus, "FAILED") + .set(FileTaskEntity::getErrorMessage, safeReason) + .set(FileTaskEntity::getFinishedAt, LocalDateTime.now())); + if (updated > 0) { + log.warn("[task-interrupted] file task marked failed by client restart taskId={} moduleType={} reason={}", + taskId, fileTask.getModuleType(), safeReason); + return TaskHeartbeatVo.notAlive(fileTask.getModuleType(), "FAILED", "marked failed"); + } + log.info("[task-interrupted] file task not in RUNNING, skipped taskId={} status={}", taskId, status); + return TaskHeartbeatVo.notAlive(fileTask.getModuleType(), status, "task is not running"); + } + BrandCrawlTaskEntity brandTask = selectBrandTask(taskId); + if (brandTask != null) { + String status = brandTask.getStatus(); + if ("running".equalsIgnoreCase(status) || "pending".equalsIgnoreCase(status)) { + brandTask.setStatus("cancelled"); + brandTask.setErrorMessage(safeReason); + int updated = brandCrawlTaskMapper.updateById(brandTask); + if (updated > 0) { + log.warn("[task-interrupted] brand task marked cancelled by client restart taskId={} reason={}", + taskId, safeReason); + return TaskHeartbeatVo.notAlive(MODULE_BRAND, "cancelled", "marked cancelled"); + } + } + log.info("[task-interrupted] brand task not in running/pending, skipped taskId={} status={}", taskId, status); + return TaskHeartbeatVo.notAlive(MODULE_BRAND, status, "task is not running"); + } + log.warn("[task-interrupted] task not found taskId={}", taskId); + return TaskHeartbeatVo.notAlive(null, null, "task not found"); + } + private BrandCrawlTaskEntity selectBrandTask(Long taskId) { LambdaQueryWrapper brandQuery = new LambdaQueryWrapper() .eq(BrandCrawlTaskEntity::getId, taskId)