diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskPullSpiImpl.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskPullSpiImpl.java new file mode 100644 index 00000000..5a022f02 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskPullSpiImpl.java @@ -0,0 +1,60 @@ +package com.nanri.aiimage.modules.appearancepatent.service; + +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedPayloadDto; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedGroupVo; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.spi.ClientTaskPullSpi; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 外观专利的客户端兜底拉取实现。 + * + *

Python 消费端需要 groups(解析分组,页面上是现拉 /queue-payload 再入队), + * 这里直接复用同一个 service 方法;payload 与页面保持一致(含 prompt / api_key)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AppearancePatentTaskPullSpiImpl implements ClientTaskPullSpi { + + private static final String QUEUE_TYPE = "appearance-patent-run"; + + private final AppearancePatentTaskService taskService; + private final AppearancePatentTaskCacheService taskCacheService; + + @Override + public String moduleType() { + return AppearancePatentTaskService.MODULE_TYPE; + } + + @Override + public Map buildQueuePayload(FileTaskEntity task) { + AppearancePatentParsedPayloadDto payload = taskService.queuePayload(task.getId(), task.getUserId()); + List groups = payload.getGroups() == null ? List.of() : payload.getGroups(); + if (groups.isEmpty()) { + // 空 groups 在 Python 侧会被静默跳过("groups/rows is empty, skip"),宁可在服务端直接判失败 + log.warn("[appearance-patent] 兜底拉取失败:解析分组为空 taskId={}", task.getId()); + return null; + } + Map data = new LinkedHashMap<>(); + data.put("taskId", task.getId()); + data.put("user_id", task.getUserId()); + data.put("prompt", payload.getAiPrompt()); + data.put("api_key", payload.getApiKey()); + data.put("groups", groups); + log.info("[appearance-patent] 兜底载荷已组装 taskId={} groups={}", task.getId(), groups.size()); + return Map.of("type", QUEUE_TYPE, "data", data); + } + + @Override + public void onClaimed(FileTaskEntity task) { + // 对齐 activate:刷新模块缓存心跳,让页面立刻看到 RUNNING + taskCacheService.touchTaskHeartbeat(task.getId()); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index 2b42b65d..7675e268 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -280,12 +280,20 @@ public class AppearancePatentTaskService { throw new BusinessException("任务不存在"); } ensureTaskOwnedByCurrentInstance(task, "activate"); - if (STATUS_SUCCESS.equals(task.getStatus()) || STATUS_FAILED.equals(task.getStatus())) { + // 只允许 PENDING→RUNNING(条件更新):与客户端「兜底拉取」的原子认领互斥, + // 谁先翻转谁执行,避免页面与客户端重复执行同一任务 + int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper() + .eq(FileTaskEntity::getId, taskId) + .eq(FileTaskEntity::getStatus, STATUS_PENDING) + .set(FileTaskEntity::getStatus, STATUS_RUNNING) + .set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())); + if (updated == 0) { + FileTaskEntity latest = fileTaskMapper.selectById(taskId); + if (latest != null && STATUS_RUNNING.equals(latest.getStatus())) { + throw new BusinessException("任务已在执行中(可能已由客户端自动接管),无需重复启动"); + } throw new BusinessException("任务已结束"); } - task.setStatus(STATUS_RUNNING); - task.setUpdatedAt(LocalDateTime.now()); - fileTaskMapper.updateById(task); taskCacheService.touchTaskHeartbeat(taskId); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java index 49e1e70c..c79e3e30 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java @@ -381,14 +381,18 @@ public class CollectDataService { @Transactional public void activateTask(Long taskId, Long userId) { FileTaskEntity task = requireTask(taskId, userId); - // 条件更新:上面的「已结束」判断与写入之间存在窗口(TOCTOU),期间 /fail 可能已把任务 - // 标为 FAILED —— 整行 updateById 会把它复活成 RUNNING(前端显示"执行中"但无人推进) + // 只允许 PENDING→RUNNING(条件更新):既堵住 TOCTOU(/fail 抢先标 FAILED 后被整行 + // updateById 复活成 RUNNING),又与客户端「兜底拉取」的原子认领互斥,谁先翻转谁执行 int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper() .eq(FileTaskEntity::getId, task.getId()) - .notIn(FileTaskEntity::getStatus, STATUS_SUCCESS, STATUS_FAILED) + .eq(FileTaskEntity::getStatus, STATUS_PENDING) .set(FileTaskEntity::getStatus, STATUS_RUNNING) .set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())); if (updated == 0) { + FileTaskEntity latest = fileTaskMapper.selectById(task.getId()); + if (latest != null && STATUS_RUNNING.equals(latest.getStatus())) { + throw new BusinessException("任务已在执行中(可能已由客户端自动接管),无需重复启动"); + } throw new BusinessException("任务已结束"); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataTaskPullSpiImpl.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataTaskPullSpiImpl.java new file mode 100644 index 00000000..4c92f432 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataTaskPullSpiImpl.java @@ -0,0 +1,71 @@ +package com.nanri.aiimage.modules.collectdata.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.spi.ClientTaskPullSpi; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 集采(collect-data)的客户端兜底拉取实现。 + * + *

Python 消费端需要 taskId / totalRows / pageSize / filters(明细行自行按 /items 分页拉取)。 + * filters 取自任务行 request_json 里 parse 时落库的那份(CollectDataService#persistParsedTask), + * 序列化后就是 Python 读取的 camelCase 键(countryCode/minAmount/...)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CollectDataTaskPullSpiImpl implements ClientTaskPullSpi { + + private static final String QUEUE_TYPE = "collect-data-run"; + private static final String TASK_TYPE = "collect-data"; + + private final ObjectMapper objectMapper; + + @Override + public String moduleType() { + return CollectDataService.MODULE_TYPE; + } + + @Override + public Map buildQueuePayload(FileTaskEntity task) { + JsonNode request = parseJson(task.getRequestJson()); + if (request == null) { + log.warn("[collect-data] 兜底拉取失败:任务请求参数缺失或不可解析 taskId={}", task.getId()); + return null; + } + JsonNode filtersNode = request.get("filters"); + Map filters = filtersNode == null || filtersNode.isNull() + ? Map.of() + : objectMapper.convertValue(filtersNode, Map.class); + JsonNode stats = parseJson(task.getResultJson()); + int totalRows = stats == null ? 0 : stats.path("totalRows").asInt(0); + Map data = new LinkedHashMap<>(); + data.put("taskId", task.getId()); + data.put("taskNo", task.getTaskNo()); + data.put("taskType", TASK_TYPE); + data.put("totalRows", totalRows); + data.put("pageSize", CollectDataService.DEFAULT_PAGE_SIZE); + data.put("filters", filters); + log.info("[collect-data] 兜底载荷已组装 taskId={} totalRows={} filters={}", task.getId(), totalRows, filters); + return Map.of("type", QUEUE_TYPE, "data", data); + } + + private JsonNode parseJson(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readTree(json); + } catch (Exception ex) { + log.warn("[collect-data] 兜底拉取解析任务 JSON 失败 err={}", ex.getMessage()); + return null; + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskPullSpiImpl.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskPullSpiImpl.java new file mode 100644 index 00000000..a80ed058 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskPullSpiImpl.java @@ -0,0 +1,47 @@ +package com.nanri.aiimage.modules.similarasin.service; + +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.spi.ClientTaskPullSpi; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 相似 ASIN 的客户端兜底拉取实现。 + * + *

Python 消费端只需 taskId 即可自行回拉解析载荷(parsed-payload)与本地 aliprice 账号/代理, + * 因此这里只补 user_id(价格回调需要)—— 与页面 payload 相比刻意不带 counts/api_key, + * 避免与 Python 侧已有的拉取、服务端密钥兜底逻辑重复。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SimilarAsinTaskPullSpiImpl implements ClientTaskPullSpi { + + private static final String QUEUE_TYPE = "similar-asin-run"; + + private final SimilarAsinTaskCacheService taskCacheService; + + @Override + public String moduleType() { + return SimilarAsinTaskService.MODULE_TYPE; + } + + @Override + public Map buildQueuePayload(FileTaskEntity task) { + Map data = new LinkedHashMap<>(); + data.put("taskId", task.getId()); + data.put("user_id", task.getUserId()); + log.info("[similar-asin] 兜底载荷已组装 taskId={} userId={}", task.getId(), task.getUserId()); + return Map.of("type", QUEUE_TYPE, "data", data); + } + + @Override + public void onClaimed(FileTaskEntity task) { + // 对齐 activate:刷新模块缓存心跳,让页面立刻看到 RUNNING + taskCacheService.touchTaskHeartbeat(task.getId()); + } +} 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 6d646c61..6a0312ca 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 @@ -532,12 +532,20 @@ public class SimilarAsinTaskService implements SimilarAsinPipelineHost { throw new BusinessException("任务不存在"); } ownershipSupport().ensureTaskOwnedByCurrentInstance(task, "activate"); - if (STATUS_SUCCESS.equals(task.getStatus()) || STATUS_FAILED.equals(task.getStatus())) { + // 只允许 PENDING→RUNNING(条件更新):与客户端「兜底拉取」的原子认领互斥, + // 谁先翻转谁执行,避免页面与客户端重复执行同一任务 + int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper() + .eq(FileTaskEntity::getId, taskId) + .eq(FileTaskEntity::getStatus, STATUS_PENDING) + .set(FileTaskEntity::getStatus, STATUS_RUNNING) + .set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())); + if (updated == 0) { + FileTaskEntity latest = fileTaskMapper.selectById(taskId); + if (latest != null && STATUS_RUNNING.equals(latest.getStatus())) { + throw new BusinessException("任务已在执行中(可能已由客户端自动接管),无需重复启动"); + } throw new BusinessException("任务已结束"); } - task.setStatus(STATUS_RUNNING); - task.setUpdatedAt(LocalDateTime.now()); - fileTaskMapper.updateById(task); taskCacheService.touchTaskHeartbeat(taskId); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskClientPullController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskClientPullController.java new file mode 100644 index 00000000..a6f1bf2b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskClientPullController.java @@ -0,0 +1,42 @@ +package com.nanri.aiimage.modules.task.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.common.model.entity.AdminUserEntity; +import com.nanri.aiimage.common.security.AdminAuthSupport; +import com.nanri.aiimage.modules.task.model.vo.TaskClientPullVo; +import com.nanri.aiimage.modules.task.service.TaskClientPullService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 客户端兜底拉取:桌面客户端定时调用,把"页面没推送成功、长期停在 PENDING"的任务领走执行。 + * + *

用户身份一律从 JWT 解析(客户端 4.0.14+ 的请求钩子自动带 Bearer),未登录直接 401 —— + * 不允许匿名按 user_id 参数领别人的任务。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/tasks") +@Tag(name = "任务兜底拉取", description = "供桌面客户端领取页面未推送成功的待执行任务") +public class TaskClientPullController { + + private final TaskClientPullService taskClientPullService; + private final AdminAuthSupport adminAuthSupport; + + @GetMapping("/pull-pending") + @Operation( + summary = "拉取并认领长期未推送的任务", + description = "只返回当前登录用户、创建超过 N 分钟仍为 PENDING 的任务;服务端原子认领(PENDING→RUNNING)," + + "与页面推送互斥,不会重复执行。开关关闭时返回空列表。") + public ApiResponse> pullPending(HttpServletRequest request) { + AdminUserEntity me = adminAuthSupport.requireUser(request); + return ApiResponse.success(taskClientPullService.pullPendingTasks(me.getId())); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskClientPullVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskClientPullVo.java new file mode 100644 index 00000000..91dce26d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskClientPullVo.java @@ -0,0 +1,27 @@ +package com.nanri.aiimage.modules.task.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Map; + +/** 客户端兜底拉取返回的任务项:任务元信息 + 客户端可直接入队的载荷。 */ +@Data +@Schema(description = "客户端兜底拉取的任务项") +public class TaskClientPullVo { + + @Schema(description = "任务 ID", example = "7004") + private Long taskId; + + @Schema(description = "模块类型", example = "SIMILAR_ASIN") + private String moduleType; + + @Schema(description = "任务编号", example = "SIMILAR_ASIN-2063533925058785280") + private String taskNo; + + @Schema(description = "任务创建时间", example = "2026-09-14 10:00:00") + private String createdAt; + + @Schema(description = "客户端可直接入队的载荷:{type, data}") + private Map queuePayload; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/StaleTaskRepairService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/StaleTaskRepairService.java index 0da71dce..3fdae083 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/StaleTaskRepairService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/StaleTaskRepairService.java @@ -55,6 +55,7 @@ public class StaleTaskRepairService { } try (lock) { repairFileTaskStaleIdle(); + repairFileTaskStaleScheduled(); repairFileTaskStaleRunning(); repairBrandStale(); } catch (Exception ex) { @@ -62,12 +63,12 @@ public class StaleTaskRepairService { } } - /** 中间态 PENDING/SCHEDULED 超时未接单 → FAILED。 */ + /** PENDING 长时间未被领取 → FAILED(页面没推进客户端队列,客户端兜底拉取也没领到)。 */ private void repairFileTaskStaleIdle() { LocalDateTime cutoff = LocalDateTime.now().minusMinutes(STALE_IDLE_MINUTES); List stale = fileTaskMapper.selectList(new LambdaQueryWrapper() .select(FileTaskEntity::getId, FileTaskEntity::getModuleType) - .in(FileTaskEntity::getStatus, STATUS_PENDING, STATUS_SCHEDULED) + .eq(FileTaskEntity::getStatus, STATUS_PENDING) .lt(FileTaskEntity::getUpdatedAt, cutoff) .last("limit 500")); if (stale.isEmpty()) { @@ -77,7 +78,7 @@ public class StaleTaskRepairService { LocalDateTime now = LocalDateTime.now(); int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper() .in(FileTaskEntity::getId, stale.stream().map(FileTaskEntity::getId).toList()) - .in(FileTaskEntity::getStatus, STATUS_PENDING, STATUS_SCHEDULED) + .eq(FileTaskEntity::getStatus, STATUS_PENDING) .set(FileTaskEntity::getStatus, STATUS_FAILED) .set(FileTaskEntity::getErrorMessage, reason) .set(FileTaskEntity::getFinishedAt, now)); @@ -87,6 +88,42 @@ public class StaleTaskRepairService { } } + /** + * SCHEDULED 到时后长时间未启动 → FAILED。 + * + *

只按 updated_at 判死会误杀:定时任务(店铺匹配)到点前本来就不会有人动它, + * 排期在 STALE_IDLE_MINUTES 之后的任务必被判死,而 activate 又被「未到定时执行时间」挡住。 + * 因此有 scheduled_at 时以「到点时间」为判死基准(到点后再宽限 STALE_IDLE_MINUTES), + * scheduled_at 为空时退回 updated_at 逻辑。completeStage 会把 scheduled_at 刷成下一轮时间, + * 多轮定时任务不会被上一轮的时间误判。 + */ + private void repairFileTaskStaleScheduled() { + LocalDateTime cutoff = LocalDateTime.now().minusMinutes(STALE_IDLE_MINUTES); + List stale = fileTaskMapper.selectList(new LambdaQueryWrapper() + .select(FileTaskEntity::getId) + .eq(FileTaskEntity::getStatus, STATUS_SCHEDULED) + .and(w -> w.isNull(FileTaskEntity::getScheduledAt) + .lt(FileTaskEntity::getUpdatedAt, cutoff) + .or() + .lt(FileTaskEntity::getScheduledAt, cutoff)) + .last("limit 500")); + if (stale.isEmpty()) { + return; + } + String reason = "定时任务到点后长时间未启动,已自动失败"; + LocalDateTime now = LocalDateTime.now(); + int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper() + .in(FileTaskEntity::getId, stale.stream().map(FileTaskEntity::getId).toList()) + .eq(FileTaskEntity::getStatus, STATUS_SCHEDULED) + .set(FileTaskEntity::getStatus, STATUS_FAILED) + .set(FileTaskEntity::getErrorMessage, reason) + .set(FileTaskEntity::getFinishedAt, now)); + if (updated > 0) { + log.warn("[stale-task-repair] 陈旧定时任务已标失败 count={} ids={}", + updated, stale.stream().map(FileTaskEntity::getId).toList()); + } + } + /** RUNNING 但心跳超过 2h 未刷新 → FAILED(心跳残留续命即僵尸)。 */ private void repairFileTaskStaleRunning() { LocalDateTime cutoff = LocalDateTime.now().minusMinutes(STALE_IDLE_MINUTES); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskClientPullService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskClientPullService.java new file mode 100644 index 00000000..a388af29 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskClientPullService.java @@ -0,0 +1,202 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.vo.TaskClientPullVo; +import com.nanri.aiimage.modules.task.spi.ClientTaskPullSpi; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 客户端兜底拉取:把"页面没推送成功、长期停在 PENDING"的任务交给在线客户端执行。 + * + *

PENDING 表示任务已解析落库、等页面把它推进本机 Python 队列;页面这一环缺失时任务会一直 + * 停在这里,2 小时后被 {@code StaleTaskRepairService} 标失败。本服务让客户端主动来领: + * 只挑创建超过 N 分钟仍是 PENDING 的本用户任务,逐条用条件更新认领(PENDING→RUNNING), + * 只有把状态翻过来的调用方算领取成功 —— 与页面 activate 天然互斥,不会重复执行。 + * + *

开关 {@code aiimage.client-task-pull.enabled} 默认关闭;模块白名单默认只放三个 + * "执行参数全在服务端"的模块,其余模块实现 {@link ClientTaskPullSpi} 后可逐步加入。 + */ +@Slf4j +@Service +public class TaskClientPullService { + + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_RUNNING = "RUNNING"; + private static final String STATUS_FAILED = "FAILED"; + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + /** 单次拉取上限,防止配置写大后一次认领过多把客户端队列压满。 */ + private static final int MAX_LIMIT = 20; + + private final FileTaskMapper fileTaskMapper; + private final InstanceMetadata instanceMetadata; + /** moduleType → 模块兜底实现(启动时建索引并校验重复注册)。 */ + private final Map pullHandlers; + + @Value("${aiimage.client-task-pull.enabled:false}") + private boolean enabled; + + @Value("${aiimage.client-task-pull.module-types:SIMILAR_ASIN,COLLECT_DATA,APPEARANCE_PATENT}") + private String moduleTypes; + + @Value("${aiimage.client-task-pull.min-pending-minutes:5}") + private long minPendingMinutes; + + @Value("${aiimage.client-task-pull.limit:5}") + private int limit; + + public TaskClientPullService(FileTaskMapper fileTaskMapper, + InstanceMetadata instanceMetadata, + List handlers) { + this.fileTaskMapper = fileTaskMapper; + this.instanceMetadata = instanceMetadata; + Map index = new LinkedHashMap<>(); + for (ClientTaskPullSpi handler : handlers == null ? List.of() : handlers) { + String moduleType = handler.moduleType(); + if (moduleType == null || moduleType.isBlank()) { + throw new IllegalStateException("ClientTaskPullSpi 未声明 moduleType: " + + handler.getClass().getName()); + } + ClientTaskPullSpi exists = index.put(moduleType.trim().toUpperCase(Locale.ROOT), handler); + if (exists != null) { + throw new IllegalStateException("moduleType=" + moduleType + " 注册了多个兜底拉取实现: " + + exists.getClass().getName() + " / " + handler.getClass().getName()); + } + } + this.pullHandlers = Map.copyOf(index); + log.info("[client-task-pull] 模块兜底实现注册完成 count={} modules={}", index.size(), index.keySet()); + } + + /** 拉取并认领该用户"长期未被领取"的任务,按创建时间升序返回,最多 limit 条。 */ + public List pullPendingTasks(Long userId) { + if (!enabled) { + return List.of(); + } + if (userId == null || userId <= 0) { + return List.of(); + } + List types = resolveModuleTypes(); + if (types.isEmpty()) { + log.warn("[client-task-pull] 模块白名单为空,本次不拉取 userId={}", userId); + return List.of(); + } + long startedAt = System.currentTimeMillis(); + LocalDateTime cutoff = LocalDateTime.now().minusMinutes(Math.max(1L, minPendingMinutes)); + int safeLimit = Math.max(1, Math.min(limit, MAX_LIMIT)); + List candidates = fileTaskMapper.selectList(new LambdaQueryWrapper() + .select(FileTaskEntity::getId, FileTaskEntity::getModuleType, FileTaskEntity::getCreatedAt) + .eq(FileTaskEntity::getUserId, userId) + .eq(FileTaskEntity::getStatus, STATUS_PENDING) + .in(FileTaskEntity::getModuleType, types) + .lt(FileTaskEntity::getCreatedAt, cutoff) + .orderByAsc(FileTaskEntity::getCreatedAt) + .last("limit " + safeLimit)); + if (candidates.isEmpty()) { + return List.of(); + } + List claimed = new ArrayList<>(); + for (FileTaskEntity candidate : candidates) { + ClientTaskPullSpi handler = pullHandlers.get(candidate.getModuleType()); + if (handler == null) { + // 白名单配了未实现 SPI 的模块:只告警、不动状态(页面仍可正常推送) + log.warn("[client-task-pull] 模块未实现兜底拉取,跳过 taskId={} moduleType={}", + candidate.getId(), candidate.getModuleType()); + continue; + } + if (!claim(candidate.getId())) { + log.info("[client-task-pull] 任务已被页面领取或状态已变化,跳过 taskId={}", candidate.getId()); + continue; + } + FileTaskEntity task = fileTaskMapper.selectById(candidate.getId()); + if (task == null) { + continue; + } + Map queuePayload = null; + try { + queuePayload = handler.buildQueuePayload(task); + } catch (Exception ex) { + log.error("[client-task-pull] 组装兜底载荷异常 taskId={} moduleType={} err={}", + task.getId(), task.getModuleType(), ex.getMessage(), ex); + } + if (queuePayload == null) { + failClaimedTask(task, "任务数据不完整,无法自动执行,请重新提交"); + continue; + } + try { + handler.onClaimed(task); + } catch (Exception ex) { + // 缓存/心跳刷新失败不影响执行:客户端已拿到载荷,心跳会由执行侧补上 + log.warn("[client-task-pull] 认领后刷新模块缓存失败 taskId={} err={}", task.getId(), ex.getMessage()); + } + TaskClientPullVo vo = new TaskClientPullVo(); + vo.setTaskId(task.getId()); + vo.setModuleType(task.getModuleType()); + vo.setTaskNo(task.getTaskNo()); + vo.setCreatedAt(task.getCreatedAt() == null ? null : TIME_FORMATTER.format(task.getCreatedAt())); + vo.setQueuePayload(queuePayload); + claimed.add(vo); + log.warn("[client-task-pull] 兜底认领成功 taskId={} moduleType={} userId={} taskNo={} 创建于={}", + task.getId(), task.getModuleType(), task.getUserId(), task.getTaskNo(), vo.getCreatedAt()); + } + log.info("[client-task-pull] 拉取完成 userId={} 候选={} 认领={} 耗时={}ms", + userId, candidates.size(), claimed.size(), System.currentTimeMillis() - startedAt); + return claimed; + } + + /** + * 条件更新认领:只有把 PENDING 翻成 RUNNING 的一方算领取成功。 + * PENDING 无活跃 owner(页面尚未执行),认领即接管,因此不做归属转发。 + */ + private boolean claim(Long taskId) { + return fileTaskMapper.update(null, new LambdaUpdateWrapper() + .eq(FileTaskEntity::getId, taskId) + .eq(FileTaskEntity::getStatus, STATUS_PENDING) + .set(FileTaskEntity::getStatus, STATUS_RUNNING) + .set(FileTaskEntity::getUpdatedAt, LocalDateTime.now()) + .set(FileTaskEntity::getOwnerInstanceId, currentInstanceId())) > 0; + } + + /** 认领后组装不出载荷:直接标失败,避免留成 RUNNING 孤儿等 2 小时心跳线。 */ + private void failClaimedTask(FileTaskEntity task, String reason) { + int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper() + .eq(FileTaskEntity::getId, task.getId()) + .eq(FileTaskEntity::getStatus, STATUS_RUNNING) + .set(FileTaskEntity::getStatus, STATUS_FAILED) + .set(FileTaskEntity::getErrorMessage, reason) + .set(FileTaskEntity::getFinishedAt, LocalDateTime.now())); + log.warn("[client-task-pull] 认领后组装载荷失败,已标失败 taskId={} moduleType={} 命中={} 原因={}", + task.getId(), task.getModuleType(), updated, reason); + } + + private List resolveModuleTypes() { + if (moduleTypes == null || moduleTypes.isBlank()) { + return List.of(); + } + List types = new ArrayList<>(); + for (String raw : moduleTypes.split(",")) { + String value = raw == null ? "" : raw.trim().toUpperCase(Locale.ROOT); + if (!value.isEmpty() && !types.contains(value)) { + types.add(value); + } + } + return types; + } + + private String currentInstanceId() { + String instanceId = instanceMetadata == null ? null : instanceMetadata.getInstanceId(); + return instanceId == null || instanceId.isBlank() ? "unknown-instance" : instanceId; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/spi/ClientTaskPullSpi.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/spi/ClientTaskPullSpi.java new file mode 100644 index 00000000..a1831d7e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/spi/ClientTaskPullSpi.java @@ -0,0 +1,36 @@ +package com.nanri.aiimage.modules.task.spi; + +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; + +import java.util.Map; + +/** + * 客户端兜底拉取的模块侧扩展点。 + * + *

背景:任务由页面"解析落库 PENDING → activate → 经 pywebview 桥推给本机 Python 队列"派发, + * 页面这一段缺失(只解析没点启动 / 推送前关页面 / 在纯浏览器打开)时任务永远停在 PENDING, + * 2 小时后被 StaleTaskRepairService 标失败(「任务长期未被领取,已自动失败」)。 + * 本接口让业务模块自行组装"客户端可直接入队"的 payload,由 {@code TaskClientPullService} + * 认领(PENDING→RUNNING 条件更新)后下发给客户端执行 —— 等于替页面补上"推送"这一步。 + * + *

与 {@link TaskModuleHeartbeatSpi} 同理(G5):task 侧不 import 业务模块, + * 各模块实现本接口,Spring 注入 List 后由 task 侧建索引。 + */ +public interface ClientTaskPullSpi { + + /** 本实现负责的 moduleType(与 biz_file_task.module_type 一致)。 */ + String moduleType(); + + /** + * 组装客户端可直接入队的载荷:{@code {"type": "...", "data": {...}}}。 + * 返回 null 表示该任务当前不具备兜底执行条件(调用方会把任务标失败并回写原因)。 + */ + Map buildQueuePayload(FileTaskEntity task); + + /** + * 认领成功后的模块侧动作:对齐各自的 activate(多数模块实现为 + * {@code cacheService.touchTaskHeartbeat(taskId)},让页面立刻看到 RUNNING)。 + */ + default void onClaimed(FileTaskEntity task) { + } +} diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 043bbb4f..08e5b1f4 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -234,6 +234,14 @@ aiimage: stuck-timeout-minutes: ${AIIMAGE_RESULT_FILE_JOB_STUCK_TIMEOUT_MINUTES:30} heartbeat-interval-ms: ${AIIMAGE_RESULT_FILE_JOB_HEARTBEAT_INTERVAL_MS:60000} batch-size: ${AIIMAGE_RESULT_FILE_JOB_BATCH_SIZE:20} + # 客户端兜底拉取:页面没推送成功(只解析没点启动 / 推送前关页面 / 纯浏览器打开)、 + # 长期停在 PENDING 的任务由在线客户端领走执行,替代 2 小时后的"任务长期未被领取"标失败。 + # 默认关闭;打开前需先发布带兜底拉取线程的客户端版本,旧客户端不受影响。 + client-task-pull: + enabled: ${AIIMAGE_CLIENT_TASK_PULL_ENABLED:false} + module-types: ${AIIMAGE_CLIENT_TASK_PULL_MODULE_TYPES:SIMILAR_ASIN,COLLECT_DATA,APPEARANCE_PATENT} + min-pending-minutes: ${AIIMAGE_CLIENT_TASK_PULL_MIN_PENDING_MINUTES:5} + limit: ${AIIMAGE_CLIENT_TASK_PULL_LIMIT:5} coze-task: max-concurrent: ${AIIMAGE_COZE_TASK_MAX_CONCURRENT:12} brand-check: diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataActivateGuardTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataActivateGuardTest.java new file mode 100644 index 00000000..a2b3d74a --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataActivateGuardTest.java @@ -0,0 +1,163 @@ +package com.nanri.aiimage.modules.collectdata.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.common.service.DistributedJobLockService; +import com.nanri.aiimage.modules.collectdata.util.CollectDataBatchQuery; +import com.nanri.aiimage.modules.collectdata.util.CollectDataBrandBatchFilter; +import com.nanri.aiimage.modules.collectdata.util.CollectDataInvalidAsinBatchWriter; +import com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailCodec; +import com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailReader; +import com.nanri.aiimage.modules.collectdata.util.CollectDataResultItemBatchWriter; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper; +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.TaskResultItemMapper; +import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.service.TaskDistributedLockService; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; +import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * activate 收紧契约(2026-09-14):只允许 PENDING→RUNNING。 + * + *

任务被客户端「兜底拉取」领走后已是 RUNNING,页面再点启动必须被拒且不改状态 —— + * 否则同一任务会被执行两次(页面入队一次、客户端一次)。同理,已结束的任务不能被复活。 + */ +class CollectDataActivateGuardTest { + + private static final long USER_ID = 1095L; + private static final long TASK_ID = 11L; + + private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class); + private CollectDataService service; + + @BeforeEach + void setUp() { + // LambdaWrapper 需要实体列缓存,单测无 Spring/MyBatis 上下文,手动初始化 + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FileTaskEntity.class); + service = buildService(fileTaskMapper); + } + + @Test + void 已被客户端领走时拒绝重复启动() { + when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task("RUNNING")); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.activateTask(TASK_ID, USER_ID)); + + assertTrue(error.getMessage().contains("无需重复启动"), "错误信息应说明任务已在执行中:" + error.getMessage()); + // 只有一次条件更新,且谓词限定 PENDING —— 已被客户端认领(RUNNING)的行不可能被再翻一次 + ArgumentCaptor> captor = wrapperCaptor(); + verify(fileTaskMapper).update(isNull(), captor.capture()); + assertTrue(paramsOf(captor.getValue()).containsValue("PENDING"), + "条件更新必须限定 PENDING,否则已认领的任务会被重复执行"); + } + + @Test + void 已结束的任务不能被复活() { + when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task("SUCCESS")); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.activateTask(TASK_ID, USER_ID)); + + assertEquals("任务已结束", error.getMessage()); + ArgumentCaptor> captor = wrapperCaptor(); + verify(fileTaskMapper).update(isNull(), captor.capture()); + assertTrue(paramsOf(captor.getValue()).containsValue("PENDING"), + "已结束的任务不能命中条件更新"); + } + + @Test + void 非本用户的任务被拒() { + when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task("PENDING")); + + assertThrows(BusinessException.class, () -> service.activateTask(TASK_ID, USER_ID + 1)); + verify(fileTaskMapper, never()).update(isNull(), any()); + } + + @Test + void PENDING_任务激活成功且条件更新限定_PENDING() { + when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task("PENDING")); + when(fileTaskMapper.update(isNull(), any())).thenReturn(1); + + service.activateTask(TASK_ID, USER_ID); + + ArgumentCaptor> captor = wrapperCaptor(); + verify(fileTaskMapper).update(isNull(), captor.capture()); + Map params = paramsOf(captor.getValue()); + assertTrue(params.containsValue("PENDING"), "条件更新的谓词必须限定 PENDING"); + assertTrue(params.containsValue("RUNNING"), "条件更新必须写入 RUNNING"); + } + + private static FileTaskEntity task(String status) { + FileTaskEntity task = new FileTaskEntity(); + task.setId(TASK_ID); + task.setModuleType("COLLECT_DATA"); + task.setTaskNo("COLLECT_DATA-11"); + task.setUserId(USER_ID); + task.setStatus(status); + return task; + } + + /** 复用既有测试的服务装配方式:只关心 activateTask 用到的 fileTaskMapper,其余协作者为 mock。 */ + private static CollectDataService buildService(FileTaskMapper fileTaskMapper) { + PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class); + return new CollectDataService(null, fileTaskMapper, mock(FileResultMapper.class), + mock(com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper.class), + mock(com.nanri.aiimage.modules.collectdata.mapper.CollectDataCountryPrefMapper.class), + mock(InvalidAsinDataMapper.class), + mock(TaskChunkMapper.class), mock(TaskScopeStateMapper.class), mock(TaskResultItemMapper.class), + mock(TaskDistributedLockService.class), mock(TaskFileJobService.class), + mock(TransientPayloadStorageService.class), + mock(CollectDataExcelAssemblyService.class), mock(OssStorageService.class), + new ObjectMapper(), new TransactionTemplate(transactionManager), + mock(DistributedJobLockService.class), + mock(CollectDataBatchQuery.class), mock(CollectDataBrandBatchFilter.class), + mock(CollectDataInvalidAsinBatchWriter.class), mock(CollectDataResultDetailCodec.class), + mock(CollectDataResultItemBatchWriter.class), mock(CollectDataResultDetailReader.class), + mock(TaskProgressLightAssembler.class)); + } + + @SuppressWarnings("unchecked") + private static ArgumentCaptor> wrapperCaptor() { + return ArgumentCaptor.forClass(Wrapper.class); + } + + /** 读取 wrapper 参数容器:MyBatis-Plus 在生成 SQL 片段时才填充,故先触发一次。 */ + @SuppressWarnings("unchecked") + private static Map paramsOf(Wrapper wrapper) { + wrapper.getSqlSegment(); + ((com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper) wrapper).getSqlSet(); + Map params = (Map) ReflectionTestUtils.getField(wrapper, "paramNameValuePairs"); + assertNotNull(params, "wrapper 参数容器字段名可能已变化(paramNameValuePairs)"); + return params; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataTaskPullSpiImplTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataTaskPullSpiImplTest.java new file mode 100644 index 00000000..c61ec566 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataTaskPullSpiImplTest.java @@ -0,0 +1,100 @@ +package com.nanri.aiimage.modules.collectdata.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 集采兜底载荷契约(与 Python SearchTask 的读取口径对齐): + * filters 原样透传(Python 读 countryCode/minAmount/maxAmount/rank/fba/fbm 这些 camelCase 键)、 + * totalRows 取任务 result_json 的初始统计、pageSize 固定 50; + * request_json 缺失/不可解析时返回 null —— 由调用方标失败,绝不用默认国家"凑"一次采集。 + */ +class CollectDataTaskPullSpiImplTest { + + private final CollectDataTaskPullSpiImpl spi = new CollectDataTaskPullSpiImpl(new ObjectMapper()); + + @Test + void 载荷包含明细拉取所需的全部字段() { + FileTaskEntity task = task(""" + {"userId":1095,"taskType":"collect-data", + "filters":{"countryCode":"DE","minAmount":10.5,"maxAmount":99.9,"rank":true,"fba":false,"fbm":true}, + "files":[]}""", """ + {"totalRows":81}"""); + + Map payload = spi.buildQueuePayload(task); + + assertEquals("collect-data-run", payload.get("type")); + @SuppressWarnings("unchecked") + Map data = (Map) payload.get("data"); + assertEquals(11L, data.get("taskId")); + assertEquals("COLLECT_DATA-11", data.get("taskNo")); + assertEquals("collect-data", data.get("taskType")); + assertEquals(81, data.get("totalRows")); + assertEquals(CollectDataService.DEFAULT_PAGE_SIZE, data.get("pageSize")); + + @SuppressWarnings("unchecked") + Map filters = (Map) data.get("filters"); + assertEquals("DE", filters.get("countryCode")); + assertEquals(10.5, filters.get("minAmount")); + assertEquals(99.9, filters.get("maxAmount")); + assertEquals(true, filters.get("rank")); + assertEquals(false, filters.get("fba")); + assertEquals(true, filters.get("fbm")); + } + + @Test + void 模块类型与入队类型固定() { + assertEquals("COLLECT_DATA", spi.moduleType()); + assertEquals("collect-data-run", spi.buildQueuePayload(task("{\"filters\":{}}", "{}")).get("type")); + } + + @Test + void 结果统计缺失时_totalRows_为_0() { + Map payload = spi.buildQueuePayload(task("{\"filters\":{}}", null)); + + @SuppressWarnings("unchecked") + Map data = (Map) payload.get("data"); + assertEquals(0, data.get("totalRows")); + } + + @Test + void filters_为空时透传空对象由_python_默认值兜底() { + Map payload = spi.buildQueuePayload(task("{\"filters\":null}", "{}")); + + @SuppressWarnings("unchecked") + Map data = (Map) payload.get("data"); + assertEquals(Map.of(), data.get("filters")); + } + + @Test + void 请求参数缺失或不可解析时返回_null_由调用方标失败() { + assertNull(spi.buildQueuePayload(task(null, "{}"))); + assertNull(spi.buildQueuePayload(task("not-a-json", "{}"))); + } + + private static FileTaskEntity task(String requestJson, String resultJson) { + FileTaskEntity task = new FileTaskEntity(); + task.setId(11L); + task.setTaskNo("COLLECT_DATA-11"); + task.setModuleType("COLLECT_DATA"); + task.setUserId(1095L); + task.setStatus("PENDING"); + task.setRequestJson(requestJson); + task.setResultJson(resultJson); + return task; + } + + /** 防回归:whitelist 里的模块类型必须与业务模块常量一致(配置默认值写的是字符串)。 */ + @Test + void 模块类型常量未被改名() { + assertTrue(List.of("SIMILAR_ASIN", "COLLECT_DATA", "APPEARANCE_PATENT").contains(spi.moduleType())); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/StaleTaskRepairServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/StaleTaskRepairServiceTest.java new file mode 100644 index 00000000..5ff57606 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/StaleTaskRepairServiceTest.java @@ -0,0 +1,149 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.nanri.aiimage.common.service.DistributedJobLockService; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.spi.BrandTaskStaleRepairSpi; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * 陈旧任务判死契约(2026-09-14 修订): + * PENDING 仍按 updated_at 判死;SCHEDULED(店铺匹配定时任务)改以 scheduled_at 为基准 —— + * 只按 updated_at 会误杀排期在 2 小时之后的定时任务,而 activate 又被「未到定时执行时间」挡住。 + */ +class StaleTaskRepairServiceTest { + + private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class); + private final BrandTaskStaleRepairSpi brandTaskStaleRepairSpi = mock(BrandTaskStaleRepairSpi.class); + private final DistributedJobLockService distributedJobLockService = mock(DistributedJobLockService.class); + private final StaleTaskRepairService service = new StaleTaskRepairService( + fileTaskMapper, brandTaskStaleRepairSpi, distributedJobLockService); + + @BeforeEach + void initTableInfo() { + // LambdaWrapper 需要实体列缓存,单测无 Spring/MyBatis 上下文,手动初始化 + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FileTaskEntity.class); + } + + @Test + void PENDING_判死只命中_PENDING_不再波及_SCHEDULED() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task(11L))); + when(fileTaskMapper.update(isNull(), any())).thenReturn(1); + + ReflectionTestUtils.invokeMethod(service, "repairFileTaskStaleIdle"); + + Map selectParams = selectParams(); + assertTrue(selectParams.containsValue("PENDING")); + assertFalse(selectParams.containsValue("SCHEDULED"), "PENDING 分支命中 SCHEDULED 是定时任务被误杀的原因"); + + Map updateParams = updateParams(1); + assertTrue(updateParams.containsValue("FAILED")); + assertTrue(containsMessage(updateParams, "任务长期未被领取")); + } + + @Test + void SCHEDULED_判死以_scheduled_at_为基准并保留_updated_at_兜底() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task(21L))); + when(fileTaskMapper.update(isNull(), any())).thenReturn(1); + + ReflectionTestUtils.invokeMethod(service, "repairFileTaskStaleScheduled"); + + ArgumentCaptor> selectCaptor = wrapperCaptor(); + verify(fileTaskMapper).selectList(selectCaptor.capture()); + String segment = selectCaptor.getValue().getSqlSegment(); + assertTrue(segment.contains("scheduled_at"), "SCHEDULED 判死必须用到 scheduled_at"); + assertTrue(segment.contains("updated_at"), "scheduled_at 为空时要退回 updated_at 逻辑"); + Map selectParams = paramsOf(selectCaptor.getValue()); + assertTrue(selectParams.containsValue("SCHEDULED"), "只处理 SCHEDULED 状态"); + long cutoffs = selectParams.values().stream().filter(LocalDateTime.class::isInstance).count(); + assertEquals(2L, cutoffs, "updated_at 与 scheduled_at 两个分支各有一个判死时间点"); + + Map updateParams = updateParams(1); + assertTrue(updateParams.containsValue("FAILED")); + assertTrue(updateParams.containsValue("SCHEDULED")); + assertTrue(containsMessage(updateParams, "定时任务到点后长时间未启动")); + } + + @Test + void 没有候选任务时不写库() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of()); + + ReflectionTestUtils.invokeMethod(service, "repairFileTaskStaleIdle"); + ReflectionTestUtils.invokeMethod(service, "repairFileTaskStaleScheduled"); + + verify(fileTaskMapper, never()).update(any(), any()); + verifyNoMoreInteractions(brandTaskStaleRepairSpi); + } + + private Map selectParams() { + ArgumentCaptor> captor = wrapperCaptor(); + verify(fileTaskMapper).selectList(captor.capture()); + return paramsOf(captor.getValue()); + } + + private Map updateParams(int times) { + ArgumentCaptor> captor = wrapperCaptor(); + verify(fileTaskMapper, org.mockito.Mockito.times(times)).update(isNull(), captor.capture()); + return paramsOf(captor.getValue()); + } + + @SuppressWarnings("unchecked") + private static ArgumentCaptor> wrapperCaptor() { + return ArgumentCaptor.forClass(Wrapper.class); + } + + /** + * 读取 wrapper 参数容器:用来断言判死谓词与写入值。 + * 注意 MyBatis-Plus 是在生成 SQL 片段时才把参数写进 paramNameValuePairs,故先触发一次。 + */ + @SuppressWarnings("unchecked") + private static Map paramsOf(Wrapper wrapper) { + wrapper.getSqlSegment(); + if (wrapper instanceof LambdaUpdateWrapper) { + ((LambdaUpdateWrapper) wrapper).getSqlSet(); + } + Map params = (Map) ReflectionTestUtils.getField(wrapper, "paramNameValuePairs"); + assertNotNull(params, "wrapper 参数容器字段名可能已变化(paramNameValuePairs)"); + return params; + } + + private static boolean containsMessage(Map params, String keyword) { + return params.values().stream() + .anyMatch(value -> value instanceof String text && text.contains(keyword)); + } + + private static FileTaskEntity task(Long id) { + FileTaskEntity task = new FileTaskEntity(); + task.setId(id); + task.setModuleType("SHOP_MATCH"); + task.setStatus("SCHEDULED"); + task.setCreatedAt(LocalDateTime.now().minusHours(5)); + task.setUpdatedAt(LocalDateTime.now().minusHours(5)); + task.setScheduledAt(LocalDateTime.now().plusHours(3)); + return task; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskClientPullServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskClientPullServiceTest.java new file mode 100644 index 00000000..7c8592d1 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskClientPullServiceTest.java @@ -0,0 +1,260 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.vo.TaskClientPullVo; +import com.nanri.aiimage.modules.task.spi.ClientTaskPullSpi; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +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.anyLong; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 客户端兜底拉取契约: + * 开关关闭不查库;候选限定「本用户 + PENDING + 白名单模块 + 创建超过 N 分钟」; + * 认领走条件更新(限定 PENDING,写 RUNNING 与 owner)——与页面 activate 互斥,认领失败即返回空; + * 认领后组装不出载荷时把任务标失败(不留 RUNNING 孤儿);白名单未实现 SPI 的模块跳过;同模块重复注册直接报错。 + */ +class TaskClientPullServiceTest { + + private static final long USER_ID = 1095L; + + private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class); + + @BeforeEach + void initTableInfo() { + // LambdaWrapper 需要实体列缓存,单测无 Spring/MyBatis 上下文,手动初始化 + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FileTaskEntity.class); + } + + @Test + void 开关关闭时不查库也不返回任务() { + TaskClientPullService service = service(new StubHandler("SIMILAR_ASIN", payload("similar-asin-run"))); + ReflectionTestUtils.setField(service, "enabled", false); + + assertTrue(service.pullPendingTasks(USER_ID).isEmpty()); + verifyNoInteractions(fileTaskMapper); + } + + @Test + void 认领成功后返回载荷并写_RUNNING_与_owner() { + FileTaskEntity candidate = task(11L, "SIMILAR_ASIN"); + when(fileTaskMapper.selectList(any())).thenReturn(List.of(candidate)); + when(fileTaskMapper.update(isNull(), any())).thenReturn(1); + when(fileTaskMapper.selectById(11L)).thenReturn(candidate); + StubHandler handler = new StubHandler("SIMILAR_ASIN", payload("similar-asin-run")); + + List result = service(handler).pullPendingTasks(USER_ID); + + assertEquals(1, result.size(), "应返回一条已认领任务"); + assertEquals(11L, result.get(0).getTaskId()); + assertEquals("similar-asin-run", result.get(0).getQueuePayload().get("type")); + assertEquals("SIMILAR_ASIN", result.get(0).getModuleType()); + assertEquals(1, handler.claimedCalls, "认领成功后必须执行模块侧后置处理"); + + ArgumentCaptor> captor = wrapperCaptor(); + verify(fileTaskMapper).update(isNull(), captor.capture()); + Map params = paramsOf(captor.getValue()); + assertTrue(params.containsValue("PENDING"), "认领条件必须限定 PENDING"); + assertTrue(params.containsValue("RUNNING"), "认领必须写入 RUNNING"); + assertTrue(sqlSetOf(captor.getValue()).contains("owner_instance_id"), "认领必须接管归属实例"); + + ArgumentCaptor> selectCaptor = wrapperCaptor(); + verify(fileTaskMapper).selectList(selectCaptor.capture()); + Map selectParams = paramsOf(selectCaptor.getValue()); + assertTrue(selectParams.containsValue("PENDING"), "候选只挑 PENDING"); + assertTrue(selectParams.containsValue(USER_ID), "候选限定任务所属用户"); + assertTrue(selectCaptor.getValue().getSqlSegment().contains("created_at"), "候选要有创建时间窗口"); + } + + @Test + void 页面抢先认领后不再返回任务() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task(11L, "SIMILAR_ASIN"))); + when(fileTaskMapper.update(isNull(), any())).thenReturn(0); + StubHandler handler = new StubHandler("SIMILAR_ASIN", payload("similar-asin-run")); + + List result = service(handler).pullPendingTasks(USER_ID); + + assertTrue(result.isEmpty(), "条件更新没命中说明页面已接管,不能再返回给客户端"); + assertEquals(0, handler.buildCalls, "未认领成功时不应组装载荷"); + verify(fileTaskMapper, never()).selectById(anyLong()); + } + + @Test + void 组装不出载荷时任务标失败() { + FileTaskEntity candidate = task(11L, "SIMILAR_ASIN"); + when(fileTaskMapper.selectList(any())).thenReturn(List.of(candidate)); + when(fileTaskMapper.update(isNull(), any())).thenReturn(1); + when(fileTaskMapper.selectById(11L)).thenReturn(candidate); + + List result = service(new StubHandler("SIMILAR_ASIN", null)).pullPendingTasks(USER_ID); + + assertTrue(result.isEmpty()); + Map failParams = secondUpdateParams(); + assertTrue(failParams.containsValue("FAILED"), "组装失败必须标 FAILED,不留 RUNNING 孤儿"); + assertTrue(containsMessage(failParams, "无法自动执行")); + } + + @Test + void 组装抛异常时任务标失败且不抛出() { + FileTaskEntity candidate = task(11L, "SIMILAR_ASIN"); + when(fileTaskMapper.selectList(any())).thenReturn(List.of(candidate)); + when(fileTaskMapper.update(isNull(), any())).thenReturn(1); + when(fileTaskMapper.selectById(11L)).thenReturn(candidate); + StubHandler broken = new StubHandler("SIMILAR_ASIN", null, new IllegalStateException("boom")); + + List result = service(broken).pullPendingTasks(USER_ID); + + assertTrue(result.isEmpty()); + Map failParams = secondUpdateParams(); + assertTrue(failParams.containsValue("FAILED")); + assertTrue(containsMessage(failParams, "无法自动执行")); + } + + @Test + void 白名单模块未实现兜底时跳过且不改状态() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task(21L, "COLLECT_DATA"))); + + List result = service(new StubHandler("SIMILAR_ASIN", payload("similar-asin-run"))) + .pullPendingTasks(USER_ID); + + assertTrue(result.isEmpty()); + verify(fileTaskMapper, never()).update(isNull(), any()); + verify(fileTaskMapper, never()).selectById(anyLong()); + } + + @Test + void 同一模块重复注册直接报错() { + assertThrows(IllegalStateException.class, () -> service( + new StubHandler("SIMILAR_ASIN", payload("similar-asin-run")), + new StubHandler("SIMILAR_ASIN", payload("similar-asin-run")))); + } + + private TaskClientPullService service(ClientTaskPullSpi... handlers) { + TaskClientPullService service = new TaskClientPullService( + fileTaskMapper, new InstanceMetadata("server-test"), List.of(handlers)); + ReflectionTestUtils.setField(service, "enabled", true); + ReflectionTestUtils.setField(service, "moduleTypes", "SIMILAR_ASIN,COLLECT_DATA"); + ReflectionTestUtils.setField(service, "minPendingMinutes", 5L); + ReflectionTestUtils.setField(service, "limit", 5); + return service; + } + + private Map secondUpdateParams() { + ArgumentCaptor> captor = wrapperCaptor(); + verify(fileTaskMapper, times(2)).update(isNull(), captor.capture()); + return paramsOf(captor.getAllValues().get(1)); + } + + @SuppressWarnings("unchecked") + private static ArgumentCaptor> wrapperCaptor() { + return ArgumentCaptor.forClass(Wrapper.class); + } + + /** + * 读取 MyBatis-Plus wrapper 里的参数容器:用来断言条件更新的谓词与写入值。 + * 注意 MyBatis-Plus 是在生成 SQL 片段时才把参数写进 paramNameValuePairs,故先触发一次。 + */ + @SuppressWarnings("unchecked") + private static Map paramsOf(Wrapper wrapper) { + wrapper.getSqlSegment(); + if (wrapper instanceof LambdaUpdateWrapper) { + ((LambdaUpdateWrapper) wrapper).getSqlSet(); + } + Map params = (Map) ReflectionTestUtils.getField(wrapper, "paramNameValuePairs"); + assertNotNull(params, "wrapper 参数容器字段名可能已变化(paramNameValuePairs)"); + return params; + } + + @SuppressWarnings("unchecked") + private static String sqlSetOf(Wrapper wrapper) { + return ((LambdaUpdateWrapper) wrapper).getSqlSet(); + } + + private static boolean containsMessage(Map params, String keyword) { + return params.values().stream() + .anyMatch(value -> value instanceof String text && text.contains(keyword)); + } + + private static Map payload(String type) { + Map data = new LinkedHashMap<>(); + data.put("taskId", 11L); + return Map.of("type", type, "data", data); + } + + private static FileTaskEntity task(Long id, String moduleType) { + FileTaskEntity task = new FileTaskEntity(); + task.setId(id); + task.setModuleType(moduleType); + task.setTaskNo(moduleType + "-" + id); + task.setUserId(USER_ID); + task.setStatus("PENDING"); + task.setCreatedAt(LocalDateTime.now().minusMinutes(10)); + task.setUpdatedAt(LocalDateTime.now().minusMinutes(10)); + return task; + } + + private static final class StubHandler implements ClientTaskPullSpi { + + private final String moduleType; + private final Map queuePayload; + private final RuntimeException failure; + private int buildCalls; + private int claimedCalls; + + StubHandler(String moduleType, Map queuePayload) { + this(moduleType, queuePayload, null); + } + + StubHandler(String moduleType, Map queuePayload, RuntimeException failure) { + this.moduleType = moduleType; + this.queuePayload = queuePayload; + this.failure = failure; + } + + @Override + public String moduleType() { + return moduleType; + } + + @Override + public Map buildQueuePayload(FileTaskEntity task) { + buildCalls++; + if (failure != null) { + throw failure; + } + return queuePayload; + } + + @Override + public void onClaimed(FileTaskEntity task) { + claimedCalls++; + } + } +} diff --git a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue index 948def1c..a7b35737 100644 --- a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue @@ -456,7 +456,15 @@ async function pushToPythonQueue() { nonEmptyArrayKeys: ['groups'], }) if (!(await passGuard(guard))) return - await activateAppearancePatentTask(taskId) + // 任务可能已被客户端「兜底拉取」领走(页面长时间没点启动):服务端会拒绝 activate, + // 此时必须直接返回,不能再入队,否则同一任务会被执行两次 + try { + await activateAppearancePatentTask(taskId) + } catch (activateError) { + ElMessage.warning(activateError instanceof Error ? activateError.message : '任务已在执行中,无需重复启动') + await Promise.all([loadDashboard(), loadHistory({ force: true })]) + return + } const result = await api.enqueue_json(payload) if (!result?.success) { ElMessage.error(result?.error || '启动失败') diff --git a/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue b/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue index 5c4ae8ab..42276ff4 100644 --- a/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue @@ -533,7 +533,15 @@ async function pushToPythonQueue() { requiredDataKeys: ['taskId', 'aliprice_usename', 'aliprice_pwd'], }) if (!(await passGuard(guard))) return - await activateSimilarAsinTask(taskId) + // 任务可能已被客户端「兜底拉取」领走(页面长时间没点启动):服务端会拒绝 activate, + // 此时必须直接返回,不能再入队,否则同一任务会被执行两次 + try { + await activateSimilarAsinTask(taskId) + } catch (activateError) { + ElMessage.warning(activateError instanceof Error ? activateError.message : '任务已在执行中,无需重复启动') + await Promise.all([loadDashboard(), loadHistory({ force: true })]) + return + } const result = await api.enqueue_json(payload) if (!result?.success) { ElMessage.error(result?.error || '启动失败')