修复BUG,完善匹配店铺

This commit is contained in:
super
2026-04-11 00:57:34 +08:00
parent 9a59dac6d0
commit b400b494ad
21 changed files with 1057 additions and 1663 deletions

View File

@@ -20,4 +20,10 @@ public class DeleteBrandProgressProperties {
*/
private long productRiskStaleTimeoutMinutes = 10;
private long productRiskInitialTimeoutMinutes = 10;
/**
* Shop match RUNNING timeout auto-finalize/fail threshold in minutes.
*/
private long shopMatchStaleTimeoutMinutes = 10;
private long shopMatchInitialTimeoutMinutes = 10;
}

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.deletebrand.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandRunRequest;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSubmitResultRequest;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandTaskBatchRequest;
@@ -97,9 +98,18 @@ public class DeleteBrandRunController {
@Operation(summary = "提交删除品牌处理结果", description = "插件/前端分片回传处理结果;后端累计分片并推进进度,全部完成后再执行最终组装与落库。")
public ApiResponse<Void> submitResult(
@PathVariable Long taskId,
@Valid @RequestBody DeleteBrandSubmitResultRequest request) {
deleteBrandRunService.submitResult(taskId, request);
return ApiResponse.success(null);
@Valid @RequestBody DeleteBrandSubmitResultRequest request,
jakarta.servlet.http.HttpServletResponse response) {
try {
deleteBrandRunService.submitResult(taskId, request);
return ApiResponse.success(null);
} catch (BusinessException ex) {
response.setStatus(HttpStatus.BAD_REQUEST.value());
return ApiResponse.fail(ex.getMessage());
} catch (Exception ex) {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
return ApiResponse.fail("服务异常:" + ex.getMessage());
}
}
@GetMapping("/tasks/{taskId}/download")

View File

@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import lombok.RequiredArgsConstructor;
@@ -18,7 +20,7 @@ import java.time.ZoneId;
import java.util.Map;
/**
* 删除品牌任务的超时回收与 finalize 补偿并与删除品牌共用同一套定时调度对商品风险PRODUCT_RISK_RESOLVE长时间无回传做失败收尾。
* 鍒犻櫎鍝佺墝浠诲姟鐨勮秴鏃跺洖鏀朵笌 finalize 琛ュ伩锛涘苟涓庡垹闄ゅ搧鐗屽叡鐢ㄥ悓涓€濂楀畾鏃惰皟搴︼紝瀵瑰晢鍝侀闄╋紙PRODUCT_RISK_RESOLVE锛夐暱鏃堕棿鏃犲洖浼犲仛澶辫触鏀跺熬銆?
*/
@Service
@RequiredArgsConstructor
@@ -27,12 +29,15 @@ public class DeleteBrandStaleTaskService {
private static final String MODULE_TYPE_DELETE_BRAND = "DELETE_BRAND";
private static final String MODULE_TYPE_PRODUCT_RISK = "PRODUCT_RISK_RESOLVE";
private static final String MODULE_TYPE_SHOP_MATCH = "SHOP_MATCH";
private final FileTaskMapper fileTaskMapper;
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
private final DeleteBrandRunService deleteBrandRunService;
private final ProductRiskTaskService productRiskTaskService;
private final ProductRiskTaskCacheService productRiskTaskCacheService;
private final ShopMatchTaskService shopMatchTaskService;
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
@Scheduled(cron = "${aiimage.delete-brand-progress.stale-check-cron:0 */2 * * * *}")
@@ -41,6 +46,7 @@ public class DeleteBrandStaleTaskService {
log.info("[stale-check] scan started thread={}", Thread.currentThread().getName());
failStaleDeleteBrandTasks();
ProductRiskStaleCheckStats stats = failStaleProductRiskResolveTasks();
ShopMatchStaleCheckStats shopMatchStats = failStaleShopMatchTasks();
log.info("[stale-check] product-risk summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
stats.scannedTaskCount,
stats.finalizedTaskCount,
@@ -48,12 +54,19 @@ public class DeleteBrandStaleTaskService {
stats.skippedTaskCount,
System.currentTimeMillis() - startedAt,
Thread.currentThread().getName());
log.info("[stale-check] shop-match summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
shopMatchStats.scannedTaskCount,
shopMatchStats.finalizedTaskCount,
shopMatchStats.failedTaskCount,
shopMatchStats.skippedTaskCount,
System.currentTimeMillis() - startedAt,
Thread.currentThread().getName());
}
private void failStaleDeleteBrandTasks() {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(deleteBrandProgressProperties.getHeartbeatTimeoutMinutes());
// 先按 DB updatedAt 粗筛,避免全表扫描
// 鍏堟寜 DB updatedAt 绮楃瓫锛岄伩鍏嶅叏琛ㄦ壂鎻?
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_DELETE_BRAND)
.eq(FileTaskEntity::getStatus, "RUNNING")
@@ -83,19 +96,19 @@ public class DeleteBrandStaleTaskService {
}
LocalDateTime lastActivityTime;
// 只有当 has_progress true 且超过 15 分钟没心跳,才认为是异常卡死
// 鍙湁褰?has_progress 涓?true 涓旇秴杩?15 鍒嗛挓娌″績璺筹紝鎵嶈涓烘槸寮傚父鍗℃
if (lastHeartbeatAt > 0 && isActive) {
// 有心跳,用最后心跳时间对比 15 分钟 (threshold now - 15)
// 鏈夊績璺筹紝鐢ㄦ渶鍚庡績璺虫椂闂村姣?15 鍒嗛挓 (threshold 鏄?now - 15)
lastActivityTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(lastHeartbeatAt), ZoneId.systemDefault());
if (lastActivityTime.isAfter(threshold)) {
continue; // 没超时
continue; // 娌¤秴鏃?
}
} else {
// 没有心跳,或者当前处在 has_progress: false (等待用户点击下一个任务推送的静默期)
// 静默期和排队期的宽限期一样,设为 12 个小时
// 娌℃湁蹇冭烦锛屾垨鑰呭綋鍓嶅鍦?has_progress: false 锛堢瓑寰呯敤鎴风偣鍑讳笅涓€涓换鍔℃帹閫佺殑闈欓粯鏈燂級
// 闈欓粯鏈熷拰鎺掗槦鏈熺殑瀹介檺鏈熶竴鏍凤紝璁句负 12 涓皬鏃?
LocalDateTime queuedThreshold = LocalDateTime.now().minusHours(12);
if (task.getCreatedAt() != null && task.getCreatedAt().isAfter(queuedThreshold)) {
continue; // 等待下个队列推送中,不要杀它!
continue; // 绛夊緟涓嬩釜闃熷垪鎺ㄩ€佷腑锛屼笉瑕佹潃瀹冿紒
}
}
@@ -104,7 +117,7 @@ public class DeleteBrandStaleTaskService {
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_DELETE_BRAND)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "结果回传长时间无响应,任务已自动失败")
.set(FileTaskEntity::getErrorMessage, "缁撴灉鍥炰紶闀挎椂闂存棤鍝嶅簲锛屼换鍔″凡鑷姩澶辫触")
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
@@ -157,7 +170,7 @@ public class DeleteBrandStaleTaskService {
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PRODUCT_RISK)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "长时间未收到 Python 结果,任务已自动失败")
.set(FileTaskEntity::getErrorMessage, "闀挎椂闂存湭鏀跺埌 Python 缁撴灉锛屼换鍔″凡鑷姩澶辫触")
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
if (updated > 0) {
@@ -170,6 +183,63 @@ public class DeleteBrandStaleTaskService {
return stats;
}
private ShopMatchStaleCheckStats failStaleShopMatchTasks() {
ShopMatchStaleCheckStats stats = new ShopMatchStaleCheckStats();
long minutes = Math.max(1L, deleteBrandProgressProperties.getShopMatchStaleTimeoutMinutes());
long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getShopMatchInitialTimeoutMinutes());
LocalDateTime now = LocalDateTime.now();
LocalDateTime threshold = now.minusMinutes(minutes);
LocalDateTime initialThreshold = now.minusMinutes(initialMinutes);
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_SHOP_MATCH)
.eq(FileTaskEntity::getStatus, "RUNNING")
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 200"));
stats.scannedTaskCount = runningTasks.size();
if (runningTasks.isEmpty()) {
return stats;
}
log.info("[stale-check] shop-match candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}",
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
for (FileTaskEntity task : runningTasks) {
boolean hasUploadedPayload = !shopMatchTaskCacheService.getAllShopMergedPayload(task.getId()).isEmpty();
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
boolean hasStartedProgress = hasUploadedPayload || hasFinishedRows;
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
stats.skippedTaskCount++;
log.info("[stale-check] shop-match skip initial-grace taskId={} createdAt={} initialThreshold={}",
task.getId(), task.getCreatedAt(), initialThreshold);
continue;
}
try {
if (shopMatchTaskService.tryFinalizeTask(task.getId(), true)) {
stats.finalizedTaskCount++;
log.info("[stale-check] shop-match finalized taskId={}", task.getId());
continue;
}
} catch (Exception ex) {
log.warn("[stale-check] shop-match finalize threw taskId={} msg={}", task.getId(), ex.getMessage());
}
int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getId, task.getId())
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_SHOP_MATCH)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "长时间未收到 Python 结果回传,任务已自动失败")
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
if (updated > 0) {
stats.failedTaskCount++;
shopMatchTaskCacheService.deleteTaskCache(task.getId());
log.warn("[stale-check] shop-match failed taskId={} reason=timeout", task.getId());
} else {
stats.skippedTaskCount++;
}
}
return stats;
}
@Scheduled(cron = "${aiimage.delete-brand-progress.finalize-check-cron:30 */2 * * * *}")
public void finalizeCompletedRunningTasks() {
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
@@ -182,7 +252,7 @@ public class DeleteBrandStaleTaskService {
try {
deleteBrandRunService.tryFinalizeTask(task.getId(), true);
} catch (Exception ignored) {
// 定时补偿不影响主流程;下次调度或人工重试时再处理
// 瀹氭椂琛ュ伩涓嶅奖鍝嶄富娴佺▼锛涗笅娆¤皟搴︽垨浜哄伐閲嶈瘯鏃跺啀澶勭悊
}
}
}
@@ -193,4 +263,12 @@ public class DeleteBrandStaleTaskService {
private int failedTaskCount;
private int skippedTaskCount;
}
private static final class ShopMatchStaleCheckStats {
private int scannedTaskCount;
private int finalizedTaskCount;
private int failedTaskCount;
private int skippedTaskCount;
}
}

View File

@@ -1,40 +1,60 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.shopmatch.model.vo.ShopMatchTaskStageVo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "商品风险任务概要biz_file_task 核心字段)")
@Schema(description = "Task summary")
public class ProductRiskTaskItemVo {
@Schema(description = "任务主键", example = "200")
@Schema(description = "Task primary key", example = "200")
private Long id;
@JsonProperty("taskNo")
@Schema(description = "业务单号,如 PRODUCT_RISK_RESOLVE-雪花 id")
@Schema(description = "Task number")
private String taskNo;
@Schema(description = "任务状态RUNNING 执行中SUCCESS 已结束且至少一店成功FAILED 已结束且全部失败或整体失败")
@Schema(description = "Task status")
private String status;
@JsonProperty("errorMessage")
@Schema(description = "失败或部分失败时的汇总错误说明;成功且无附加信息时可能为空")
@Schema(description = "Task error message")
private String errorMessage;
@JsonProperty("createdAt")
@Schema(description = "创建时间(字符串格式,服务端 LocalDateTime.toString()")
@Schema(description = "Created at")
private String createdAt;
@JsonProperty("updatedAt")
@Schema(description = "最后更新时间")
@Schema(description = "Updated at")
private String updatedAt;
@JsonProperty("finishedAt")
@Schema(description = "结束时间RUNNING 时通常为空")
@Schema(description = "Finished at")
private String finishedAt;
@JsonProperty("scheduledAt")
@Schema(description = "定时执行时间,未设置时为空")
@Schema(description = "Next scheduled execution time")
private String scheduledAt;
@JsonProperty("countryCodes")
@Schema(description = "Country codes used by shop match task")
private List<String> countryCodes = new ArrayList<>();
@JsonProperty("scheduleStages")
@Schema(description = "Stage schedule of shop match task")
private List<ShopMatchTaskStageVo> scheduleStages = new ArrayList<>();
@JsonProperty("currentStageIndex")
@Schema(description = "Next pending stage index")
private Integer currentStageIndex;
@JsonProperty("activeStageIndex")
@Schema(description = "Current running stage index")
private Integer activeStageIndex;
}

View File

@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.shopmatch.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCountryPreferenceSaveRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCreateTaskRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskTaskBatchRequest;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
@@ -13,9 +12,15 @@ import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskHistoryVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskBatchVo;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchCreateTaskRequest;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchStageCompleteRequest;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchSubmitResultRequest;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResolveService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
@@ -40,95 +45,143 @@ import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/shop-match")
@Tag(name = "匹配店铺", description = "店铺候选、国家顺序、批量匹配、定时任务、Python 回传结果与下载接口。")
public class ShopMatchController {
private final ShopMatchResolveService shopMatchResolveService;
private final ShopMatchTaskService shopMatchTaskService;
@GetMapping("/candidates")
public ApiResponse<List<ProductRiskCandidateVo>> listCandidates(@RequestParam("user_id") Long userId) {
@Operation(summary = "查询候选店铺", description = "返回当前用户在匹配店铺模块下保存的候选店铺列表。")
public ApiResponse<List<ProductRiskCandidateVo>> listCandidates(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(shopMatchResolveService.listCandidates(userId));
}
@PostMapping("/candidates")
@Operation(summary = "新增候选店铺", description = "新增一条候选店铺记录,供前端后续匹配与创建任务使用。")
public ApiResponse<ProductRiskCandidateVo> addCandidate(@Valid @RequestBody ProductRiskCandidateAddRequest request) {
return ApiResponse.success(shopMatchResolveService.addCandidate(request));
}
@DeleteMapping("/candidates/{id}")
public ApiResponse<Void> deleteCandidate(@PathVariable Long id, @RequestParam("user_id") Long userId) {
@Operation(summary = "删除候选店铺", description = "删除当前用户名下的一条候选店铺记录。")
public ApiResponse<Void> deleteCandidate(
@Parameter(description = "候选店铺记录主键", example = "10") @PathVariable Long id,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
shopMatchResolveService.deleteCandidate(userId, id);
return ApiResponse.success(null);
}
@GetMapping("/country-preference")
public ApiResponse<ProductRiskCountryPreferenceVo> getCountryPreference(@RequestParam("user_id") Long userId) {
@Operation(summary = "查询国家配置顺序", description = "返回当前用户保存的国家勾选与执行顺序。")
public ApiResponse<ProductRiskCountryPreferenceVo> getCountryPreference(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(shopMatchResolveService.getCountryPreference(userId));
}
@PutMapping("/country-preference")
@Operation(summary = "保存国家配置顺序", description = "保存当前用户在匹配店铺模块中的国家勾选与执行顺序。")
public ApiResponse<ProductRiskCountryPreferenceVo> saveCountryPreference(@Valid @RequestBody ProductRiskCountryPreferenceSaveRequest request) {
return ApiResponse.success(shopMatchResolveService.saveCountryPreference(request));
}
@PostMapping("/match-shops")
@Operation(summary = "批量匹配店铺", description = "按店铺名批量匹配紫鸟索引,返回店铺 ID、平台、公司与匹配状态。")
public ApiResponse<ProductRiskMatchShopsVo> matchShops(@Valid @RequestBody ProductRiskMatchShopsRequest request) {
return ApiResponse.success(shopMatchResolveService.matchShops(request));
}
@GetMapping("/dashboard")
public ApiResponse<ProductRiskDashboardVo> dashboard(@RequestParam("user_id") Long userId) {
@Operation(summary = "统计看板", description = "返回候选店铺数、已结束任务数、成功数和失败数。")
public ApiResponse<ProductRiskDashboardVo> dashboard(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(shopMatchTaskService.dashboard(userId));
}
@GetMapping("/history")
public ApiResponse<ProductRiskHistoryVo> history(@RequestParam("user_id") Long userId) {
@Operation(summary = "查询任务记录", description = "返回当前用户的运行中与历史任务记录。")
public ApiResponse<ProductRiskHistoryVo> history(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(shopMatchTaskService.listHistory(userId));
}
@DeleteMapping("/history/{resultId}")
public ApiResponse<Void> deleteHistory(@PathVariable Long resultId, @RequestParam("user_id") Long userId) {
@Operation(summary = "删除结果记录", description = "删除一条任务结果记录,并同步重算任务状态。")
public ApiResponse<Void> deleteHistory(
@Parameter(description = "结果记录主键", example = "1001") @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
shopMatchTaskService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
@PostMapping("/tasks")
public ApiResponse<ProductRiskCreateTaskVo> createTask(@Valid @RequestBody ProductRiskCreateTaskRequest request) {
@Operation(summary = "创建匹配店铺任务", description = "创建任务与各店铺占位结果,支持立即执行或多时间点定时执行。")
public ApiResponse<ProductRiskCreateTaskVo> createTask(@Valid @RequestBody ShopMatchCreateTaskRequest request) {
return ApiResponse.success(shopMatchTaskService.createTask(request));
}
@DeleteMapping("/tasks/{taskId}")
public ApiResponse<Void> deleteTask(@PathVariable Long taskId, @RequestParam("user_id") Long userId) {
@Operation(summary = "删除任务", description = "删除整条任务及其下所有结果记录。")
public ApiResponse<Void> deleteTask(
@Parameter(description = "任务主键", example = "200") @PathVariable Long taskId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
shopMatchTaskService.deleteTask(taskId, userId);
return ApiResponse.success(null);
}
@PostMapping("/tasks/{taskId}/activate")
public ApiResponse<Void> activateTask(@PathVariable Long taskId, @RequestParam("user_id") Long userId) {
shopMatchTaskService.activateTask(taskId, userId);
@Operation(summary = "激活定时阶段", description = "在某个定时阶段开始执行前,将任务切换到运行中状态。")
public ApiResponse<Void> activateTask(
@Parameter(description = "任务主键", example = "200") @PathVariable Long taskId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") @RequestParam("user_id") Long userId,
@Parameter(name = "stage_index", description = "阶段下标,从 0 开始", required = true, in = ParameterIn.QUERY, example = "0") @RequestParam("stage_index") Integer stageIndex) {
shopMatchTaskService.activateTask(taskId, userId, stageIndex);
return ApiResponse.success(null);
}
@PostMapping("/tasks/{taskId}/stage-finished")
@Operation(summary = "标记阶段完成", description = "某个定时阶段完成后推进到下一阶段,或等待最终收尾。")
public ApiResponse<Void> completeStage(
@Parameter(description = "任务主键", example = "200") @PathVariable Long taskId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") @RequestParam("user_id") Long userId,
@Valid @RequestBody ShopMatchStageCompleteRequest request) {
shopMatchTaskService.completeStage(taskId, userId, request);
return ApiResponse.success(null);
}
@PostMapping("/tasks/batch")
@Operation(summary = "批量查询任务详情", description = "供前端轮询任务状态与阶段进度使用。")
public ApiResponse<ProductRiskTaskBatchVo> tasksBatch(@Valid @RequestBody ProductRiskTaskBatchRequest request) {
return ApiResponse.success(shopMatchTaskService.getTaskDetailsBatch(request.getTaskIds()));
}
@PostMapping("/tasks/{taskId}/result")
public ApiResponse<Void> submitResult(@PathVariable Long taskId, @Valid @RequestBody ShopMatchSubmitResultRequest request) {
@Operation(summary = "提交匹配结果", description = "Python 可多次分批回传结果;后端会组装文件并在超时场景自动补偿收尾。")
public ApiResponse<Void> submitResult(
@Parameter(description = "任务主键", example = "200") @PathVariable Long taskId,
@Valid @RequestBody ShopMatchSubmitResultRequest request) {
shopMatchTaskService.submitResult(taskId, request);
return ApiResponse.success(null);
}
@GetMapping("/results/{resultId}/download")
@Operation(summary = "下载结果文件", description = "按结果记录下载后端已生成的 Excel 文件。")
public void downloadResult(
@PathVariable Long resultId,
@RequestParam("user_id") Long userId,
@Parameter(description = "结果记录主键", example = "1001") @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") @RequestParam("user_id") Long userId,
jakarta.servlet.http.HttpServletResponse response) {
String url = shopMatchTaskService.resolveResultDownloadUrl(resultId, userId);
String filename = shopMatchTaskService.resolveResultDownloadFilename(resultId, userId);
if (url == null || url.isBlank()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No downloadable result");
}
try {
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
@@ -144,7 +197,7 @@ public class ShopMatchController {
response.getOutputStream().flush();
}
} catch (Exception ex) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Download failed");
}
}
}

View File

@@ -0,0 +1,46 @@
package com.nanri.aiimage.modules.shopmatch.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "创建匹配店铺任务请求")
public class ShopMatchCreateTaskRequest {
@NotNull(message = "user_id cannot be null")
@JsonProperty("user_id")
@Schema(description = "任务所属用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long userId;
@NotEmpty(message = "items cannot be empty")
@Valid
@Schema(description = "已匹配店铺列表", requiredMode = Schema.RequiredMode.REQUIRED)
private List<ProductRiskShopQueueItemVo> items = new ArrayList<>();
@NotEmpty(message = "country_codes cannot be empty")
@JsonProperty("country_codes")
@Schema(description = "每个阶段都要执行的国家代码列表", requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> countryCodes = new ArrayList<>();
@JsonProperty("schedule_times")
@Schema(description = "可选的多时间点执行时间;为空表示立即执行", example = "[\"2026-04-10T18:00:00\",\"2026-04-10T19:00:00\"]")
private List<LocalDateTime> scheduleTimes = new ArrayList<>();
@JsonProperty("current_stage_index")
@Schema(description = "下一次待执行的阶段下标,由后端维护")
private Integer currentStageIndex;
@JsonProperty("active_stage_index")
@Schema(description = "当前运行中的阶段下标,由后端维护")
private Integer activeStageIndex;
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.shopmatch.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "匹配店铺任务阶段完成回调")
public class ShopMatchStageCompleteRequest {
@NotNull(message = "stage_index cannot be null")
@JsonProperty("stage_index")
@Schema(description = "已完成的阶段下标,从 0 开始", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer stageIndex;
}

View File

@@ -0,0 +1,22 @@
package com.nanri.aiimage.modules.shopmatch.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "匹配店铺任务阶段信息")
public class ShopMatchTaskStageVo {
@JsonProperty("stageIndex")
@Schema(description = "阶段下标,从 0 开始", example = "0")
private Integer stageIndex;
@JsonProperty("scheduledAt")
@Schema(description = "该阶段的计划执行时间")
private String scheduledAt;
@Schema(description = "阶段状态SCHEDULED / RUNNING / COMPLETED / FAILED / PENDING")
private String status;
}

View File

@@ -6,7 +6,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCreateTaskRequest;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCreateTaskVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskHistoryVo;
@@ -15,9 +15,12 @@ import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskBatchVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskDetailVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskItemVo;
import com.nanri.aiimage.modules.shopmatch.model.vo.ShopMatchTaskStageVo;
import com.nanri.aiimage.modules.shopmatch.mapper.ShopMatchShopCandidateMapper;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchCreateTaskRequest;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchRowDto;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchShopPayloadDto;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchStageCompleteRequest;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchSubmitResultRequest;
import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchShopCandidateEntity;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
@@ -34,6 +37,7 @@ import java.io.File;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -57,7 +61,7 @@ public class ShopMatchTaskService {
public ProductRiskDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
throw new BusinessException("user_id is invalid");
}
ProductRiskDashboardVo vo = new ProductRiskDashboardVo();
vo.setCandidateCount(candidateMapper.selectCount(new LambdaQueryWrapper<ShopMatchShopCandidateEntity>()
@@ -82,7 +86,7 @@ public class ShopMatchTaskService {
public ProductRiskHistoryVo listHistory(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
throw new BusinessException("user_id is invalid");
}
ProductRiskHistoryVo vo = new ProductRiskHistoryVo();
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
@@ -114,11 +118,11 @@ public class ShopMatchTaskService {
@Transactional
public void deleteHistory(Long resultId, Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
throw new BusinessException("user_id is invalid");
}
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
throw new BusinessException("Invalid request");
}
Long taskId = entity.getTaskId();
fileResultMapper.deleteById(resultId);
@@ -130,11 +134,11 @@ public class ShopMatchTaskService {
@Transactional
public void deleteTask(Long taskId, Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
throw new BusinessException("user_id is invalid");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
throw new BusinessException("Invalid request");
}
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
@@ -182,10 +186,10 @@ public class ShopMatchTaskService {
public String resolveResultDownloadUrl(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
throw new BusinessException("Invalid request");
}
if (entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank()) {
throw new BusinessException("暂无可下载文件");
throw new BusinessException("Invalid request");
}
return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl());
}
@@ -193,7 +197,7 @@ public class ShopMatchTaskService {
public String resolveResultDownloadFilename(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
throw new BusinessException("Invalid request");
}
return entity.getResultFilename() != null && !entity.getResultFilename().isBlank()
? entity.getResultFilename()
@@ -201,34 +205,41 @@ public class ShopMatchTaskService {
}
@Transactional
public ProductRiskCreateTaskVo createTask(ProductRiskCreateTaskRequest request) {
public ProductRiskCreateTaskVo createTask(ShopMatchCreateTaskRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
throw new BusinessException("user_id is invalid");
}
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new BusinessException("items 不能为空");
throw new BusinessException("items 涓嶈兘涓虹┖");
}
List<ProductRiskShopQueueItemVo> uniqueItems = dedupeQueueItems(request.getItems());
if (uniqueItems.isEmpty()) {
throw new BusinessException("items 不能为空");
throw new BusinessException("items 涓嶈兘涓虹┖");
}
for (ProductRiskShopQueueItemVo item : uniqueItems) {
if (item == null || !item.isMatched()) {
throw new BusinessException("存在未匹配店铺,无法创建任务");
throw new BusinessException("瀛樺湪鏈尮閰嶅簵閾猴紝鏃犳硶鍒涘缓浠诲姟");
}
}
List<String> countryCodes = normalizeCountryCodes(request.getCountryCodes());
List<LocalDateTime> scheduleTimes = normalizeScheduleTimes(request.getScheduleTimes());
LocalDateTime now = LocalDateTime.now();
LocalDateTime scheduledAt = request.getScheduleTime();
if (scheduledAt != null && !scheduledAt.isAfter(now)) {
throw new BusinessException("schedule_time 必须晚于当前时间");
ShopMatchCreateTaskRequest persistedRequest = new ShopMatchCreateTaskRequest();
persistedRequest.setUserId(request.getUserId());
persistedRequest.setItems(uniqueItems);
persistedRequest.setCountryCodes(countryCodes);
persistedRequest.setScheduleTimes(scheduleTimes);
if (!scheduleTimes.isEmpty()) {
persistedRequest.setCurrentStageIndex(0);
}
FileTaskEntity task = new FileTaskEntity();
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
task.setModuleType(MODULE_TYPE);
task.setTaskMode("PYTHON_QUEUE");
task.setStatus(scheduledAt != null ? "SCHEDULED" : "RUNNING");
task.setStatus(scheduleTimes.isEmpty() ? "RUNNING" : "SCHEDULED");
task.setSourceFileCount(uniqueItems.size());
task.setSuccessFileCount(0);
task.setFailedFileCount(0);
@@ -236,14 +247,14 @@ public class ShopMatchTaskService {
task.setUserId(request.getUserId());
task.setCreatedAt(now);
task.setUpdatedAt(now);
task.setScheduledAt(scheduledAt);
task.setScheduledAt(scheduleTimes.isEmpty() ? null : scheduleTimes.get(0));
fileTaskMapper.insert(task);
List<ProductRiskResultItemVo> snapshot = new ArrayList<>();
for (ProductRiskShopQueueItemVo item : uniqueItems) {
String normalized = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (normalized.isBlank()) {
throw new BusinessException("店铺名无效");
throw new BusinessException("Invalid request");
}
FileResultEntity result = new FileResultEntity();
result.setTaskId(task.getId());
@@ -257,10 +268,10 @@ public class ShopMatchTaskService {
snapshot.add(toSnapshotVo(result, item, task.getStatus()));
}
try {
task.setRequestJson(objectMapper.writeValueAsString(request));
task.setRequestJson(objectMapper.writeValueAsString(persistedRequest));
task.setResultJson(objectMapper.writeValueAsString(snapshot));
} catch (Exception ex) {
throw new BusinessException("序列化任务数据失败");
throw new BusinessException("Invalid request");
}
fileTaskMapper.updateById(task);
@@ -271,49 +282,96 @@ public class ShopMatchTaskService {
}
@Transactional
public void activateTask(Long taskId, Long userId) {
public void activateTask(Long taskId, Long userId, Integer stageIndex) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
throw new BusinessException("taskId is invalid");
}
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
throw new BusinessException("user_id is invalid");
}
if (stageIndex == null || stageIndex < 0) {
throw new BusinessException("stage_index is invalid");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
throw new BusinessException("Invalid request");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("任务已结束");
}
if ("RUNNING".equals(task.getStatus())) {
return;
throw new BusinessException("Invalid request");
}
if (!"SCHEDULED".equals(task.getStatus())) {
throw new BusinessException("当前任务状态不允许激活");
throw new BusinessException("Invalid request");
}
if (task.getScheduledAt() != null && LocalDateTime.now().isBefore(task.getScheduledAt())) {
throw new BusinessException("未到定时执行时间");
throw new BusinessException("鏈埌瀹氭椂鎵ц鏃堕棿");
}
ShopMatchCreateTaskRequest state = parseTaskRequest(task);
int currentStageIndex = state.getCurrentStageIndex() == null ? 0 : state.getCurrentStageIndex();
if (currentStageIndex != stageIndex) {
throw new BusinessException("褰撳墠杞宸插彉鍖栵紝璇峰埛鏂板悗閲嶈瘯");
}
if (state.getScheduleTimes() == null || stageIndex >= state.getScheduleTimes().size()) {
throw new BusinessException("Invalid request");
}
state.setActiveStageIndex(stageIndex);
task.setStatus("RUNNING");
task.setUpdatedAt(LocalDateTime.now());
persistTaskRequest(task, state);
fileTaskMapper.updateById(task);
}
@Transactional
public void completeStage(Long taskId, Long userId, ShopMatchStageCompleteRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId is invalid");
}
if (userId == null || userId <= 0) {
throw new BusinessException("user_id is invalid");
}
if (request == null || request.getStageIndex() == null || request.getStageIndex() < 0) {
throw new BusinessException("stage_index is invalid");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("Invalid request");
}
if (!"RUNNING".equals(task.getStatus())) {
throw new BusinessException("Invalid request");
}
ShopMatchCreateTaskRequest state = parseTaskRequest(task);
if (state.getActiveStageIndex() == null || !state.getActiveStageIndex().equals(request.getStageIndex())) {
throw new BusinessException("杞鐘舵€佷笉鍖归厤");
}
int nextIndex = request.getStageIndex() + 1;
if (state.getScheduleTimes() == null || nextIndex >= state.getScheduleTimes().size()) {
throw new BusinessException("Current stage is already the final stage");
}
state.setCurrentStageIndex(nextIndex);
state.setActiveStageIndex(null);
task.setStatus("SCHEDULED");
task.setScheduledAt(state.getScheduleTimes().get(nextIndex));
task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(null);
persistTaskRequest(task, state);
fileTaskMapper.updateById(task);
}
@Transactional
public void submitResult(Long taskId, ShopMatchSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
throw new BusinessException("taskId is invalid");
}
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
throw new BusinessException("shops 不能为空");
throw new BusinessException("shops 涓嶈兘涓虹┖");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
throw new BusinessException("Invalid request");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("任务已结束,拒绝重复提交");
throw new BusinessException("浠诲姟宸茬粨鏉燂紝鎷掔粷閲嶅鎻愪氦");
}
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
@@ -350,7 +408,7 @@ public class ShopMatchTaskService {
continue;
}
if (countPayloadRows(merged) <= 0) {
markResultFailed(result, "未收到可组装的结果数据");
markResultFailed(result, "No result rows received for assembly");
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
continue;
}
@@ -373,6 +431,102 @@ public class ShopMatchTaskService {
updateTaskStatusFromLatestRows(task, latest);
}
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) {
return false;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return false;
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
return true;
}
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
if (resultRows.isEmpty()) {
return false;
}
Map<String, ShopMatchShopPayloadDto> cachedPayloadByShop = shopMatchTaskCacheService.getAllShopMergedPayload(taskId);
File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "shop-match-result", String.valueOf(taskId)));
List<String> batchErrors = new ArrayList<>();
boolean changed = false;
for (FileResultEntity result : resultRows) {
boolean success = result.getSuccess() != null && result.getSuccess() == 1;
boolean failed = result.getErrorMessage() != null && !result.getErrorMessage().isBlank();
if (success || failed) {
continue;
}
String shopKey = result.getSourceFilename();
ShopMatchShopPayloadDto cachedPayload = shopKey == null ? null : cachedPayloadByShop.get(shopKey);
if (cachedPayload == null) {
markResultFailed(result, "Python interrupted before this shop finished uploading results");
batchErrors.add(shopKey + ": Python interrupted before this shop finished uploading results");
changed = true;
continue;
}
if (cachedPayload.getError() != null && !cachedPayload.getError().isBlank()) {
markResultFailed(result, cachedPayload.getError());
batchErrors.add(shopKey + ": " + cachedPayload.getError());
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
changed = true;
continue;
}
if (countPayloadRows(cachedPayload) <= 0 && payloadHasAnyDoneTrue(cachedPayload)) {
markResultFailed(result, "No result rows received for assembly");
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
changed = true;
log.warn("[shop-match] stale finalize finished without data taskId={} shop={} fromCompensation={}",
taskId, shopKey, fromCompensation);
continue;
}
if (countPayloadRows(cachedPayload) <= 0) {
markResultFailed(result, "Python interrupted before any assembleable rows were uploaded");
batchErrors.add(shopKey + ": Python interrupted before any assembleable rows were uploaded");
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
changed = true;
continue;
}
try {
assembleShopResult(result, shopKey, cachedPayload, workRoot);
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
changed = true;
log.warn("[shop-match] stale finalize assembled taskId={} shop={} fromCompensation={} completedFlag={}",
taskId, shopKey, fromCompensation, isShopPayloadCompleted(cachedPayload));
} catch (Exception ex) {
String message = ex.getMessage() == null ? "assemble failed" : ex.getMessage();
markResultFailed(result, message);
batchErrors.add(shopKey + ": " + message);
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
changed = true;
log.warn("[shop-match] stale finalize failed taskId={} shop={} msg={}", taskId, shopKey, message);
}
}
if (!changed && cachedPayloadByShop.isEmpty()) {
return false;
}
List<FileResultEntity> latest = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
updateTaskStatusFromLatestRows(task, latest, batchErrors);
return true;
}
private void assembleShopResult(FileResultEntity result, String shopKey, ShopMatchShopPayloadDto payload, File workRoot) {
Map<String, List<ShopMatchRowDto>> countries = excelAssemblyService.normalizeCountriesMap(payload.getCountries());
String displayName = payload.getShopName() != null && !payload.getShopName().isBlank() ? payload.getShopName().trim() : shopKey;
@@ -406,6 +560,12 @@ public class ShopMatchTaskService {
}
private void updateTaskStatusFromLatestRows(FileTaskEntity task, List<FileResultEntity> latest) {
updateTaskStatusFromLatestRows(task, latest, List.of());
}
private void updateTaskStatusFromLatestRows(FileTaskEntity task,
List<FileResultEntity> latest,
List<String> batchErrors) {
int ok = 0;
int fail = 0;
List<String> errors = new ArrayList<>();
@@ -435,7 +595,7 @@ public class ShopMatchTaskService {
task.setFinishedAt(LocalDateTime.now());
} else if (ok > 0) {
task.setStatus("SUCCESS");
task.setErrorMessage(String.join("; ", errors));
task.setErrorMessage(String.join("; ", errors.isEmpty() ? batchErrors : errors));
task.setFinishedAt(LocalDateTime.now());
} else {
task.setStatus("FAILED");
@@ -473,7 +633,7 @@ public class ShopMatchTaskService {
.orderByAsc(FileResultEntity::getId));
List<ProductRiskResultItemVo> items = new ArrayList<>();
for (FileResultEntity row : rows) {
items.add(toHistoryItemVo(row, taskStatus));
throw new BusinessException("Task request payload is missing");
}
return items;
}
@@ -488,10 +648,20 @@ public class ShopMatchTaskService {
vo.setUpdatedAt(fmt(task.getUpdatedAt()));
vo.setFinishedAt(fmt(task.getFinishedAt()));
vo.setScheduledAt(fmt(task.getScheduledAt()));
ShopMatchCreateTaskRequest request = parseTaskRequestSilently(task);
if (request != null) {
vo.setCountryCodes(new ArrayList<>(request.getCountryCodes() == null ? List.of() : request.getCountryCodes()));
vo.setCurrentStageIndex(request.getCurrentStageIndex());
vo.setActiveStageIndex(request.getActiveStageIndex());
vo.setScheduleStages(buildStageVos(task, request));
}
return vo;
}
private ProductRiskResultItemVo toSnapshotVo(FileResultEntity result, ProductRiskShopQueueItemVo item, String taskStatus) {
private ProductRiskResultItemVo toSnapshotVo(FileResultEntity result,
ProductRiskShopQueueItemVo item,
String taskStatus) {
ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
vo.setResultId(result.getId());
vo.setTaskId(result.getTaskId());
@@ -503,8 +673,10 @@ public class ShopMatchTaskService {
vo.setMatchStatus(item.getMatchStatus());
vo.setMatchMessage(item.getMatchMessage());
vo.setTaskStatus(taskStatus);
vo.setScheduledAt(fmt(result.getTaskId() == null ? null : loadScheduledAt(result.getTaskId())));
vo.setSuccess(false);
vo.setError(null);
vo.setOutputFilename(null);
vo.setDownloadUrl(null);
return vo;
}
@@ -515,22 +687,66 @@ public class ShopMatchTaskService {
vo.setShopName(entity.getSourceFilename());
vo.setShopId(entity.getSourceFileUrl());
vo.setTaskStatus(taskStatus);
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
boolean success = entity.getSuccess() != null && entity.getSuccess() == 1;
vo.setSuccess(success);
vo.setError(entity.getErrorMessage());
vo.setOutputFilename(entity.getResultFilename());
vo.setDownloadUrl(entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank()
? null
: ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
vo.setMatched(true);
if (entity.getTaskId() != null) {
vo.setScheduledAt(fmt(loadScheduledAt(entity.getTaskId())));
}
if (entity.getTaskId() != null) {
mergeQueueFieldsFromRequest(vo, entity.getTaskId());
}
return vo;
}
private List<ShopMatchTaskStageVo> buildStageVos(FileTaskEntity task, ShopMatchCreateTaskRequest request) {
List<ShopMatchTaskStageVo> stages = new ArrayList<>();
List<LocalDateTime> scheduleTimes = request.getScheduleTimes() == null ? List.of() : request.getScheduleTimes();
if (scheduleTimes.isEmpty()) {
return stages;
}
Integer currentStageIndex = request.getCurrentStageIndex();
Integer activeStageIndex = request.getActiveStageIndex();
for (int i = 0; i < scheduleTimes.size(); i++) {
ShopMatchTaskStageVo stage = new ShopMatchTaskStageVo();
stage.setStageIndex(i);
stage.setScheduledAt(fmt(scheduleTimes.get(i)));
if ("SUCCESS".equals(task.getStatus())) {
stage.setStatus("COMPLETED");
} else if ("FAILED".equals(task.getStatus())) {
if (activeStageIndex != null && activeStageIndex == i) {
stage.setStatus("FAILED");
} else if (currentStageIndex != null && currentStageIndex > i) {
stage.setStatus("COMPLETED");
} else {
stage.setStatus("PENDING");
}
} else if ("RUNNING".equals(task.getStatus()) && activeStageIndex != null) {
if (i < activeStageIndex) {
stage.setStatus("COMPLETED");
} else if (i == activeStageIndex) {
stage.setStatus("RUNNING");
} else {
stage.setStatus("PENDING");
}
} else if ("SCHEDULED".equals(task.getStatus()) && currentStageIndex != null) {
if (i < currentStageIndex) {
stage.setStatus("COMPLETED");
} else if (i == currentStageIndex) {
stage.setStatus("SCHEDULED");
} else {
stage.setStatus("PENDING");
}
} else {
stage.setStatus("PENDING");
}
stages.add(stage);
}
return stages;
}
private LocalDateTime loadScheduledAt(Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
return task == null ? null : task.getScheduledAt();
@@ -538,27 +754,60 @@ public class ShopMatchTaskService {
private void mergeQueueFieldsFromRequest(ProductRiskResultItemVo vo, Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
ShopMatchCreateTaskRequest request = parseTaskRequestSilently(task);
if (request == null) {
return;
}
try {
ProductRiskCreateTaskRequest request = objectMapper.readValue(task.getRequestJson(), ProductRiskCreateTaskRequest.class);
String key = ziniaoShopSwitchService.normalizeShopName(vo.getShopName());
for (ProductRiskShopQueueItemVo item : request.getItems()) {
if (item == null) {
continue;
}
if (key.equals(ziniaoShopSwitchService.normalizeShopName(item.getShopName()))) {
vo.setShopName(item.getShopName());
vo.setShopId(item.getShopId());
vo.setPlatform(item.getPlatform());
vo.setCompanyName(item.getCompanyName());
vo.setMatchStatus(item.getMatchStatus());
vo.setMatchMessage(item.getMatchMessage());
break;
}
String key = ziniaoShopSwitchService.normalizeShopName(vo.getShopName());
for (ProductRiskShopQueueItemVo item : request.getItems()) {
if (item == null) {
continue;
}
if (key.equals(ziniaoShopSwitchService.normalizeShopName(item.getShopName()))) {
vo.setShopName(item.getShopName());
vo.setShopId(item.getShopId());
vo.setPlatform(item.getPlatform());
vo.setCompanyName(item.getCompanyName());
vo.setMatchStatus(item.getMatchStatus());
vo.setMatchMessage(item.getMatchMessage());
break;
}
}
}
private ShopMatchCreateTaskRequest parseTaskRequest(FileTaskEntity task) {
ShopMatchCreateTaskRequest request = parseTaskRequestSilently(task);
if (request == null) {
throw new BusinessException("浠诲姟閰嶇疆缂哄け");
}
if (request.getItems() == null) {
request.setItems(new ArrayList<>());
}
if (request.getCountryCodes() == null) {
request.setCountryCodes(new ArrayList<>());
}
if (request.getScheduleTimes() == null) {
request.setScheduleTimes(new ArrayList<>());
}
return request;
}
private ShopMatchCreateTaskRequest parseTaskRequestSilently(FileTaskEntity task) {
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
return null;
}
try {
return objectMapper.readValue(task.getRequestJson(), ShopMatchCreateTaskRequest.class);
} catch (Exception ignored) {
return null;
}
}
private void persistTaskRequest(FileTaskEntity task, ShopMatchCreateTaskRequest request) {
try {
task.setRequestJson(objectMapper.writeValueAsString(request));
} catch (Exception ex) {
throw new BusinessException("Invalid request");
}
}
@@ -580,6 +829,55 @@ public class ShopMatchTaskService {
return new ArrayList<>(byNorm.values());
}
private List<String> normalizeCountryCodes(List<String> raw) {
if (raw == null || raw.isEmpty()) {
throw new BusinessException("country_codes 涓嶈兘涓虹┖");
}
LinkedHashSet<String> out = new LinkedHashSet<>();
for (String code : raw) {
if (code == null || code.isBlank()) {
continue;
}
String normalized = code.trim().toUpperCase(Locale.ROOT);
try {
ProductRiskCountryCode.valueOf(normalized);
} catch (IllegalArgumentException ex) {
throw new BusinessException("闈炴硶鍥藉浠g爜: " + code);
}
out.add(normalized);
}
if (out.isEmpty()) {
throw new BusinessException("country_codes 涓嶈兘涓虹┖");
}
return new ArrayList<>(out);
}
private List<LocalDateTime> normalizeScheduleTimes(List<LocalDateTime> raw) {
if (raw == null || raw.isEmpty()) {
return List.of();
}
List<LocalDateTime> out = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
LocalDateTime previous = null;
for (LocalDateTime time : raw) {
if (time == null) {
continue;
}
if (!time.isAfter(now)) {
throw new BusinessException("schedule_times 蹇呴』鏅氫簬褰撳墠鏃堕棿");
}
if (previous != null && !time.isAfter(previous)) {
throw new BusinessException("schedule_times 蹇呴』鎸夋椂闂撮€掑");
}
out.add(time);
previous = time;
}
if (out.isEmpty()) {
throw new BusinessException("schedule_times 涓嶈兘涓虹┖");
}
return out;
}
private static String safeFileStem(String shopName) {
String value = shopName == null ? "shop" : shopName.trim();
if (value.isBlank()) {
@@ -716,3 +1014,5 @@ public class ShopMatchTaskService {
return row == null || row.getAsin() == null || row.getAsin().trim().isEmpty();
}
}

View File

@@ -13,13 +13,18 @@ import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@Slf4j
@Service
@RequiredArgsConstructor
public class ModuleHistoryCleanupService {
private static final Set<String> TERMINAL_STATUSES = Set.of("SUCCESS", "FAILED", "CANCELLED", "CANCELED");
private final ModuleCleanupProperties moduleCleanupProperties;
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
@@ -35,18 +40,50 @@ public class ModuleHistoryCleanupService {
return;
}
List<FileTaskEntity> moduleTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getModuleType, moduleTypes)
.select(FileTaskEntity::getId, FileTaskEntity::getModuleType, FileTaskEntity::getStatus));
List<Long> cleanupTaskIds = new ArrayList<>();
List<Long> skippedActiveTaskIds = new ArrayList<>();
for (FileTaskEntity task : moduleTasks) {
if (task == null || task.getId() == null) {
continue;
}
if (isTerminalStatus(task.getStatus())) {
cleanupTaskIds.add(task.getId());
} else {
skippedActiveTaskIds.add(task.getId());
}
}
if (cleanupTaskIds.isEmpty()) {
log.info("模块历史清理跳过: moduleTypes={}, activeTaskIds={}, reason=no-terminal-tasks",
moduleTypes, skippedActiveTaskIds);
return;
}
int deletedResults = fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.in(FileResultEntity::getModuleType, moduleTypes));
.in(FileResultEntity::getModuleType, moduleTypes)
.in(FileResultEntity::getTaskId, cleanupTaskIds));
int resetTasks = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.in(FileTaskEntity::getModuleType, moduleTypes)
.in(FileTaskEntity::getId, cleanupTaskIds)
.set(FileTaskEntity::getResultJson, null)
.set(FileTaskEntity::getRequestJson, null)
.set(FileTaskEntity::getErrorMessage, null));
int deletedTasks = fileTaskMapper.delete(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getModuleType, moduleTypes));
.in(FileTaskEntity::getId, cleanupTaskIds));
log.info("模块历史清理完成: moduleTypes={}, deletedResults={}, resetTasks={}, deletedTasks={}", moduleTypes, deletedResults, resetTasks, deletedTasks);
log.info("模块历史清理完成: moduleTypes={}, deletedResults={}, resetTasks={}, deletedTasks={}, skippedActiveTaskIds={}",
moduleTypes, deletedResults, resetTasks, deletedTasks, skippedActiveTaskIds);
}
private boolean isTerminalStatus(String status) {
if (status == null || status.isBlank()) {
return false;
}
return TERMINAL_STATUSES.contains(status.trim().toUpperCase(Locale.ROOT));
}
}

View File

@@ -84,6 +84,8 @@ aiimage:
finalize-check-cron: ${AIIMAGE_DELETE_BRAND_FINALIZE_CHECK_CRON:30 */2 * * * *}
product-risk-stale-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_MINUTES:10}
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:10}
shop-match-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:10}
shop-match-initial-timeout-minutes: ${AIIMAGE_SHOP_MATCH_INITIAL_TIMEOUT_MINUTES:10}
module-cleanup:
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}