更新跟价相关内容修改
This commit is contained in:
4
app/.env
4
app/.env
@@ -12,8 +12,8 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
|
||||
client_name=ShuFuAI
|
||||
|
||||
|
||||
# java_api_base=http://47.111.163.154:18080
|
||||
java_api_base=http://47.111.163.154:18080
|
||||
# java_api_base=http://127.0.0.1:18080
|
||||
java_api_base=http://8.136.19.173:18080
|
||||
# java_api_base=http://8.136.19.173:18080
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -434,7 +434,7 @@ def main():
|
||||
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder,
|
||||
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new)
|
||||
webview.start(
|
||||
debug=False,
|
||||
debug=True,
|
||||
storage_path=cache_path,
|
||||
private_mode=False
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-ClWy2SbE.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DBpLCgAF.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-hj_EXwAP.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-CdxGWtvK.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BNvh4ru4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version": "1.0.13"}
|
||||
{"version": "1.0.56"}
|
||||
BIN
backend-java/aiimage-backend.log.2026-04-19.0.gz
Normal file
BIN
backend-java/aiimage-backend.log.2026-04-19.0.gz
Normal file
Binary file not shown.
@@ -238,9 +238,10 @@ public class DeleteBrandStaleTaskService {
|
||||
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
|
||||
for (FileTaskEntity task : runningTasks) {
|
||||
long lastHeartbeatMillis = priceTrackTaskCacheService.getTaskHeartbeatMillis(task.getId());
|
||||
boolean hasUploadedPayload = priceTrackTaskCacheService.hasAnyShopMergedPayload(task.getId());
|
||||
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|
||||
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
|
||||
boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasFinishedRows;
|
||||
boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasUploadedPayload || hasFinishedRows;
|
||||
if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) {
|
||||
stats.skippedTaskCount++;
|
||||
continue;
|
||||
|
||||
@@ -209,9 +209,20 @@ public class PriceTrackController {
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(summary = "Python 回传处理结果", description = "同一 taskId 可多次回传。成功时按 countries 生成结果文件,所有店铺进入终态后任务会变为 SUCCESS 或 FAILED。")
|
||||
@Operation(
|
||||
summary = "Python 回传处理结果",
|
||||
description = "同一 taskId 支持多次回传。后端会先按店铺合并缓存,再在该店铺 success=true 时组装最终结果文件。"
|
||||
+ "未完成/处理中:本次只传增量行数据,shop.success 不传或传 null,后端仅合并缓存继续等待。"
|
||||
+ "已完成:传最后一批数据并设置 shop.success=true,后端会使用该店铺累计后的完整数据出结果。"
|
||||
+ "失败:传 shop.success=false 或传非空 shop.error。"
|
||||
+ "行级数据只保留表头字段:shopMallName、asin、price、recommendedPrice、minimumPrice、firstPlace、secondPlace、cartShopName、priceChangeStatus、modifyCount、status。"
|
||||
)
|
||||
public ApiResponse<Void> submitResult(
|
||||
@Parameter(description = "任务主键,必须处于 RUNNING 状态", example = "200") @PathVariable Long taskId,
|
||||
@Parameter(
|
||||
description = "任务主键,必须处于 RUNNING 状态",
|
||||
example = "200"
|
||||
)
|
||||
@PathVariable Long taskId,
|
||||
@Valid @RequestBody PriceTrackSubmitResultRequest request) {
|
||||
priceTrackTaskService.submitResult(taskId, request);
|
||||
return ApiResponse.success(null);
|
||||
|
||||
@@ -7,73 +7,91 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Python submit payload for price track task results")
|
||||
@Schema(
|
||||
description = "跟价任务结果回传请求体。"
|
||||
+ "同一 taskId 支持多次回传。"
|
||||
+ "未完成的店铺可分批回传,shop.success 不传或传 null 即可;"
|
||||
+ "店铺最终完成时传 shop.success=true,后端会基于该店铺累计数据组装结果文件;"
|
||||
+ "店铺失败时传 shop.success=false 或传非空的 shop.error。"
|
||||
)
|
||||
public class PriceTrackSubmitResultRequest {
|
||||
@Schema(description = "Shop result list", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(
|
||||
description = "店铺结果列表。一次请求可以提交一个或多个店铺的数据。",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED
|
||||
)
|
||||
private List<ShopResult> shops;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Per-shop submit result")
|
||||
@Schema(
|
||||
description = "单个店铺的回传结果。"
|
||||
+ "处理中/未完成时,success 不传或传 null;"
|
||||
+ "店铺最终完成时传 success=true;"
|
||||
+ "店铺失败时传 success=false 或 error 非空。"
|
||||
)
|
||||
public static class ShopResult {
|
||||
@Schema(description = "Shop name used to match task rows", example = "Test Shop A")
|
||||
@Schema(description = "店铺名称,用于和任务中的店铺结果行进行匹配", example = "测试店铺A")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "Rows grouped by country code, for example DE/UK/FR/IT/ES")
|
||||
@Schema(
|
||||
description = "按国家代码分组的结果行,例如 DE、UK、FR、IT、ES。"
|
||||
+ "每一行只允许保留最终表头中的字段。"
|
||||
)
|
||||
private Map<String, List<AsinResult>> countries;
|
||||
|
||||
@Schema(description = "Failure message")
|
||||
@Schema(
|
||||
description = "失败信息。非空时后端会将该店铺标记为失败,并停止等待该店铺后续回传。",
|
||||
example = ""
|
||||
)
|
||||
private String error;
|
||||
|
||||
@Schema(description = "Whether the shop finished successfully")
|
||||
@Schema(
|
||||
description = "店铺完成标记。"
|
||||
+ "不传或传 null 表示本次只是部分回传,后端仅合并缓存并继续等待;"
|
||||
+ "true 表示该店铺已全部完成,后端会组装最终结果文件;"
|
||||
+ "false 表示该店铺处理失败。",
|
||||
example = "true"
|
||||
)
|
||||
private Boolean success;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Schema(description = "Per-ASIN price track result row")
|
||||
@Schema(
|
||||
description = "单条 ASIN 跟价结果行。"
|
||||
+ "只保留最终表头字段。"
|
||||
+ "不要再传旧字段,如 currentPrice、competitorPrice、action、done。"
|
||||
)
|
||||
public static class AsinResult {
|
||||
@Schema(description = "Shop mall name", example = "Amazon.de")
|
||||
@Schema(description = "店铺商城名称", example = "Amazon.de")
|
||||
private String shopMallName;
|
||||
|
||||
@Schema(description = "ASIN", example = "B0ABCDE123")
|
||||
private String asin;
|
||||
|
||||
@Schema(description = "Price", example = "19.99")
|
||||
@Schema(description = "价格", example = "19.99")
|
||||
private String price;
|
||||
|
||||
@Schema(description = "Recommended price", example = "18.99")
|
||||
@Schema(description = "推荐价", example = "18.99")
|
||||
private String recommendedPrice;
|
||||
|
||||
@Schema(description = "Lowest price", example = "18.50")
|
||||
@Schema(description = "最低价", example = "18.50")
|
||||
private String minimumPrice;
|
||||
|
||||
@Schema(description = "First place", example = "Competitor A")
|
||||
@Schema(description = "第一名", example = "竞对A")
|
||||
private String firstPlace;
|
||||
|
||||
@Schema(description = "Second place", example = "Competitor B")
|
||||
@Schema(description = "第二名", example = "竞对B")
|
||||
private String secondPlace;
|
||||
|
||||
@Schema(description = "Buy box shop name", example = "Shop A")
|
||||
@Schema(description = "购物车店铺名", example = "店铺A")
|
||||
private String cartShopName;
|
||||
|
||||
@Schema(description = "Price change status", example = "UPDATED")
|
||||
@Schema(description = "改价情况", example = "UPDATED")
|
||||
private String priceChangeStatus;
|
||||
|
||||
@Schema(description = "Modify count", example = "2")
|
||||
@Schema(description = "修改次数", example = "2")
|
||||
private String modifyCount;
|
||||
|
||||
@Schema(description = "Status", example = "SUCCESS")
|
||||
@Schema(description = "状态", example = "SUCCESS")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "Legacy current price", example = "19.99")
|
||||
private String currentPrice;
|
||||
|
||||
@Schema(description = "Legacy competitor price", example = "18.50")
|
||||
private String competitorPrice;
|
||||
|
||||
@Schema(description = "Legacy action", example = "LOWER_PRICE")
|
||||
private String action;
|
||||
|
||||
@Schema(description = "Legacy done flag")
|
||||
private Boolean done;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ public class PriceTrackMatchShopsVo {
|
||||
@Schema(description = "Shop name", example = "Demo Shop")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "Shop mall name", example = "Amazon.de")
|
||||
private String shopMallName;
|
||||
|
||||
@Schema(description = "Matched shop id")
|
||||
private Object shopId;
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ public class PriceTrackResultItemVo {
|
||||
@Schema(description = "店铺名称,详情页会尽量恢复创建任务时的展示名")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "Shop mall name")
|
||||
private String shopMallName;
|
||||
|
||||
@Schema(description = "店铺 ID(紫鸟)")
|
||||
private Object shopId;
|
||||
|
||||
|
||||
@@ -52,15 +52,15 @@ public class PriceTrackExcelAssemblyService {
|
||||
Row row = sheet.createRow(rowIdx++);
|
||||
row.createCell(0).setCellValue(valueOf(item.getShopMallName()));
|
||||
row.createCell(1).setCellValue(valueOf(item.getAsin()));
|
||||
row.createCell(2).setCellValue(firstNonBlank(item.getPrice(), item.getCurrentPrice()));
|
||||
row.createCell(2).setCellValue(valueOf(item.getPrice()));
|
||||
row.createCell(3).setCellValue(valueOf(item.getRecommendedPrice()));
|
||||
row.createCell(4).setCellValue(firstNonBlank(item.getMinimumPrice(), item.getCompetitorPrice()));
|
||||
row.createCell(4).setCellValue(valueOf(item.getMinimumPrice()));
|
||||
row.createCell(5).setCellValue(valueOf(item.getFirstPlace()));
|
||||
row.createCell(6).setCellValue(valueOf(item.getSecondPlace()));
|
||||
row.createCell(7).setCellValue(valueOf(item.getCartShopName()));
|
||||
row.createCell(8).setCellValue(firstNonBlank(item.getPriceChangeStatus(), item.getAction()));
|
||||
row.createCell(8).setCellValue(valueOf(item.getPriceChangeStatus()));
|
||||
row.createCell(9).setCellValue(valueOf(item.getModifyCount()));
|
||||
row.createCell(10).setCellValue(firstNonBlank(item.getStatus(), item.getDone() == null ? null : String.valueOf(item.getDone())));
|
||||
row.createCell(10).setCellValue(valueOf(item.getStatus()));
|
||||
}
|
||||
for (int i = 0; i < RESULT_HEADER.length; i++) {
|
||||
sheet.autoSizeColumn(i);
|
||||
@@ -105,15 +105,4 @@ public class PriceTrackExcelAssemblyService {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String... values) {
|
||||
if (values == null || values.length == 0) {
|
||||
return "";
|
||||
}
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCandidateVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCountryPreferenceVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||
import com.nanri.aiimage.modules.shopkey.service.ShopManageService;
|
||||
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||
@@ -43,6 +44,7 @@ public class PriceTrackService {
|
||||
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||
private final SkipPriceAsinService skipPriceAsinService;
|
||||
private final PriceTrackTaskService priceTrackTaskService;
|
||||
private final ShopManageService shopManageService;
|
||||
|
||||
public List<PriceTrackCandidateVo> listCandidates(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
@@ -220,6 +222,7 @@ public class PriceTrackService {
|
||||
) {
|
||||
PriceTrackMatchShopsVo.PriceTrackShopQueueItem item = new PriceTrackMatchShopsVo.PriceTrackShopQueueItem();
|
||||
item.setShopName(shopName);
|
||||
item.setShopMallName(resolveShopMallName(shopName));
|
||||
item.setSkipAsins(skipAsins == null ? new LinkedHashMap<>() : new LinkedHashMap<>(skipAsins));
|
||||
try {
|
||||
ZiniaoShopMatchResultVo match = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
|
||||
@@ -247,6 +250,14 @@ public class PriceTrackService {
|
||||
return item;
|
||||
}
|
||||
|
||||
private String resolveShopMallName(String shopName) {
|
||||
try {
|
||||
return shopManageService.findMallNameByShopName(shopName);
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> sanitizeStoredCodes(List<String> raw) {
|
||||
List<String> parsed = parseValidCountryCodes(raw);
|
||||
if (parsed.isEmpty()) {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -14,6 +19,7 @@ public class PriceTrackTaskCacheService {
|
||||
private static final long HEARTBEAT_TTL_HOURS = 24;
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public void touchTaskHeartbeat(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
@@ -40,13 +46,80 @@ public class PriceTrackTaskCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
public PriceTrackSubmitResultRequest.ShopResult getShopMergedPayload(Long taskId, String shopKey) {
|
||||
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Object raw = stringRedisTemplate.opsForHash().get(buildShopPayloadKey(taskId), shopKey);
|
||||
if (!(raw instanceof String json) || json.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, PriceTrackSubmitResultRequest.ShopResult.class);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("read price-track cache failed");
|
||||
}
|
||||
}
|
||||
|
||||
public void saveShopMergedPayload(Long taskId, String shopKey, PriceTrackSubmitResultRequest.ShopResult payload) {
|
||||
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stringRedisTemplate.opsForHash().put(buildShopPayloadKey(taskId), shopKey, objectMapper.writeValueAsString(payload));
|
||||
stringRedisTemplate.expire(buildShopPayloadKey(taskId), Duration.ofHours(HEARTBEAT_TTL_HOURS));
|
||||
touchTaskHeartbeat(taskId);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("save price-track cache failed");
|
||||
}
|
||||
}
|
||||
|
||||
public void removeShopMergedPayload(Long taskId, String shopKey) {
|
||||
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.opsForHash().delete(buildShopPayloadKey(taskId), shopKey);
|
||||
}
|
||||
|
||||
public Map<String, PriceTrackSubmitResultRequest.ShopResult> getAllShopMergedPayload(Long taskId) {
|
||||
try {
|
||||
Map<Object, Object> raw = stringRedisTemplate.opsForHash().entries(buildShopPayloadKey(taskId));
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
LinkedHashMap<String, PriceTrackSubmitResultRequest.ShopResult> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<Object, Object> entry : raw.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key) || !(entry.getValue() instanceof String val) || val.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
out.put(key, objectMapper.readValue(val, PriceTrackSubmitResultRequest.ShopResult.class));
|
||||
}
|
||||
return out;
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("read price-track cache failed");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasAnyShopMergedPayload(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId));
|
||||
return size != null && size > 0;
|
||||
}
|
||||
|
||||
public void deleteTaskCache(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
|
||||
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
|
||||
}
|
||||
|
||||
private String buildShopPayloadKey(Long taskId) {
|
||||
return "price-track:task:shop-payload:" + taskId;
|
||||
}
|
||||
|
||||
private String buildTaskHeartbeatKey(Long taskId) {
|
||||
return "price-track:task:heartbeat:" + taskId;
|
||||
}
|
||||
|
||||
@@ -323,49 +323,63 @@ public class PriceTrackTaskService {
|
||||
log.warn("[price-track] submitResult rejected taskId={} status={}", taskId, task.getStatus());
|
||||
throw new BusinessException(40901, "任务已结束,拒绝重复提交");
|
||||
}
|
||||
|
||||
priceTrackTaskCacheService.touchTaskHeartbeat(taskId);
|
||||
List<FileResultEntity> resultRows = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
|
||||
Map<String, PriceTrackSubmitResultRequest.ShopResult> payloadByShop = new LinkedHashMap<>();
|
||||
for (PriceTrackSubmitResultRequest.ShopResult sr : request.getShops()) {
|
||||
if (sr == null) continue;
|
||||
String key = ziniaoShopSwitchService.normalizeShopName(sr.getShopName());
|
||||
if (!key.isBlank()) payloadByShop.put(key, sr);
|
||||
}
|
||||
|
||||
log.info("[price-track] submitResult received taskId={} payloadShopCount={} payloadKeys={}",
|
||||
taskId, payloadByShop.size(), payloadByShop.keySet());
|
||||
boolean changed = false;
|
||||
for (FileResultEntity fr : resultRows) {
|
||||
String shopKey = fr.getSourceFilename();
|
||||
PriceTrackSubmitResultRequest.ShopResult payload = shopKey == null ? null : payloadByShop.get(shopKey);
|
||||
if (payload == null) {
|
||||
// 当前批次没有该店铺的 payload,保持待处理
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
if (payload.getError() != null && !payload.getError().isBlank()) {
|
||||
markResultFailed(fr, payload.getError());
|
||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
continue;
|
||||
}
|
||||
if (Boolean.FALSE.equals(payload.getSuccess())) {
|
||||
markResultFailed(fr, "店铺处理失败");
|
||||
markResultFailed(fr, "shop processing failed");
|
||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
continue;
|
||||
}
|
||||
PriceTrackSubmitResultRequest.ShopResult merged = mergeShopPayload(taskId, shopKey, payload);
|
||||
if (!Boolean.TRUE.equals(merged.getSuccess())) {
|
||||
continue;
|
||||
}
|
||||
if (countPayloadRows(merged) <= 0) {
|
||||
markResultFailed(fr, "no usable price-track rows received");
|
||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
assembleShopResult(fr, shopKey, payload);
|
||||
assembleShopResult(fr, shopKey, merged);
|
||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
} catch (Exception ex) {
|
||||
markResultFailed(fr, ex.getMessage() == null ? "组装结果失败" : ex.getMessage());
|
||||
markResultFailed(fr, ex.getMessage() == null ? "assemble price-track result failed" : ex.getMessage());
|
||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
List<FileResultEntity> latest = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
|
||||
updateTaskStatusFromLatestRows(task, latest);
|
||||
}
|
||||
|
||||
@@ -380,7 +394,6 @@ public class PriceTrackTaskService {
|
||||
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
|
||||
return false;
|
||||
}
|
||||
|
||||
List<FileResultEntity> latest = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
@@ -388,28 +401,61 @@ public class PriceTrackTaskService {
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
if (latest.isEmpty()) {
|
||||
task.setStatus("FAILED");
|
||||
task.setErrorMessage("任务明细缺失,无法自动收尾");
|
||||
task.setErrorMessage("task details missing, cannot finalize automatically");
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
|
||||
return true;
|
||||
}
|
||||
|
||||
Map<String, PriceTrackSubmitResultRequest.ShopResult> cachedPayloadByShop =
|
||||
priceTrackTaskCacheService.getAllShopMergedPayload(taskId);
|
||||
boolean changed = false;
|
||||
for (FileResultEntity fr : latest) {
|
||||
boolean success = fr.getSuccess() != null && fr.getSuccess() == 1;
|
||||
boolean failed = fr.getErrorMessage() != null && !fr.getErrorMessage().isBlank();
|
||||
if (success || failed) continue;
|
||||
String shopKey = fr.getSourceFilename();
|
||||
PriceTrackSubmitResultRequest.ShopResult cachedPayload = shopKey == null ? null : cachedPayloadByShop.get(shopKey);
|
||||
if (cachedPayload == null) {
|
||||
markResultFailed(fr, "Python interrupted before this shop finished uploading results");
|
||||
changed = true;
|
||||
log.warn("[price-track] stale finalize failed taskId={} shop={} fromCompensation={}",
|
||||
taskId, fr.getSourceFilename(), fromCompensation);
|
||||
log.warn("[price-track] stale finalize failed without cache taskId={} shop={} fromCompensation={}",
|
||||
taskId, shopKey, fromCompensation);
|
||||
continue;
|
||||
}
|
||||
if (!changed) {
|
||||
if (cachedPayload.getError() != null && !cachedPayload.getError().isBlank()) {
|
||||
markResultFailed(fr, cachedPayload.getError());
|
||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (countPayloadRows(cachedPayload) <= 0) {
|
||||
markResultFailed(fr, "no usable price-track rows received before interruption");
|
||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
changed = true;
|
||||
log.warn("[price-track] stale finalize finished without data taskId={} shop={} fromCompensation={}",
|
||||
taskId, shopKey, fromCompensation);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
assembleShopResult(fr, shopKey, cachedPayload);
|
||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
changed = true;
|
||||
log.warn("[price-track] stale finalize assembled taskId={} shop={} fromCompensation={} completedFlag={} rowCount={}",
|
||||
taskId, shopKey, fromCompensation, Boolean.TRUE.equals(cachedPayload.getSuccess()), countPayloadRows(cachedPayload));
|
||||
} catch (Exception ex) {
|
||||
String message = ex.getMessage() == null ? "assemble price-track result failed" : ex.getMessage();
|
||||
markResultFailed(fr, message);
|
||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
changed = true;
|
||||
log.warn("[price-track] stale finalize assemble failed taskId={} shop={} msg={}",
|
||||
taskId, shopKey, message);
|
||||
}
|
||||
}
|
||||
if (!changed && cachedPayloadByShop.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<FileResultEntity> refreshed = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
@@ -723,6 +769,7 @@ public class PriceTrackTaskService {
|
||||
vo.setResultId(fr.getId());
|
||||
vo.setTaskId(fr.getTaskId());
|
||||
vo.setShopName(item.getShopName());
|
||||
vo.setShopMallName(item.getShopMallName());
|
||||
vo.setShopId(item.getShopId());
|
||||
vo.setPlatform(item.getPlatform());
|
||||
vo.setCompanyName(item.getCompanyName());
|
||||
@@ -799,6 +846,7 @@ public class PriceTrackTaskService {
|
||||
if (item == null) continue;
|
||||
if (key.equals(ziniaoShopSwitchService.normalizeShopName(item.getShopName()))) {
|
||||
vo.setShopName(item.getShopName());
|
||||
vo.setShopMallName(item.getShopMallName());
|
||||
vo.setShopId(item.getShopId());
|
||||
vo.setPlatform(item.getPlatform());
|
||||
vo.setCompanyName(item.getCompanyName());
|
||||
@@ -852,6 +900,113 @@ public class PriceTrackTaskService {
|
||||
return s.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
}
|
||||
|
||||
private PriceTrackSubmitResultRequest.ShopResult mergeShopPayload(Long taskId,
|
||||
String shopKey,
|
||||
PriceTrackSubmitResultRequest.ShopResult incoming) {
|
||||
PriceTrackSubmitResultRequest.ShopResult merged = priceTrackTaskCacheService.getShopMergedPayload(taskId, shopKey);
|
||||
if (merged == null) {
|
||||
merged = new PriceTrackSubmitResultRequest.ShopResult();
|
||||
}
|
||||
if (incoming.getShopName() != null && !incoming.getShopName().isBlank()) {
|
||||
merged.setShopName(incoming.getShopName().trim());
|
||||
} else if (merged.getShopName() == null || merged.getShopName().isBlank()) {
|
||||
merged.setShopName(shopKey);
|
||||
}
|
||||
if (incoming.getError() != null && !incoming.getError().isBlank()) {
|
||||
merged.setError(incoming.getError().trim());
|
||||
}
|
||||
if (incoming.getSuccess() != null) {
|
||||
merged.setSuccess(incoming.getSuccess());
|
||||
}
|
||||
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> nextCountries =
|
||||
merged.getCountries() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(merged.getCountries());
|
||||
if (incoming.getCountries() != null && !incoming.getCountries().isEmpty()) {
|
||||
for (Map.Entry<String, List<PriceTrackSubmitResultRequest.AsinResult>> entry : incoming.getCountries().entrySet()) {
|
||||
String countryCode = entry.getKey();
|
||||
List<PriceTrackSubmitResultRequest.AsinResult> incomingRows = entry.getValue();
|
||||
if (countryCode == null || countryCode.isBlank() || incomingRows == null || incomingRows.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
nextCountries.put(countryCode, mergeCountryRows(nextCountries.get(countryCode), incomingRows));
|
||||
}
|
||||
}
|
||||
merged.setCountries(nextCountries);
|
||||
priceTrackTaskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
|
||||
return merged;
|
||||
}
|
||||
private List<PriceTrackSubmitResultRequest.AsinResult> mergeCountryRows(
|
||||
List<PriceTrackSubmitResultRequest.AsinResult> existingRows,
|
||||
List<PriceTrackSubmitResultRequest.AsinResult> incomingRows) {
|
||||
LinkedHashMap<String, PriceTrackSubmitResultRequest.AsinResult> byKey = new LinkedHashMap<>();
|
||||
if (existingRows != null) {
|
||||
for (PriceTrackSubmitResultRequest.AsinResult row : existingRows) {
|
||||
if (row != null) {
|
||||
byKey.put(buildRowKey(row), cloneRow(row));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (PriceTrackSubmitResultRequest.AsinResult row : incomingRows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String key = buildRowKey(row);
|
||||
byKey.put(key, mergeRow(byKey.get(key), row));
|
||||
}
|
||||
return new ArrayList<>(byKey.values());
|
||||
}
|
||||
private String buildRowKey(PriceTrackSubmitResultRequest.AsinResult row) {
|
||||
String asin = row == null || row.getAsin() == null ? "" : row.getAsin().trim().toUpperCase(Locale.ROOT);
|
||||
return asin.isBlank() ? "row:" + java.util.UUID.randomUUID() : "asin:" + asin;
|
||||
}
|
||||
private PriceTrackSubmitResultRequest.AsinResult mergeRow(PriceTrackSubmitResultRequest.AsinResult previous,
|
||||
PriceTrackSubmitResultRequest.AsinResult incoming) {
|
||||
if (previous == null) {
|
||||
return cloneRow(incoming);
|
||||
}
|
||||
PriceTrackSubmitResultRequest.AsinResult merged = cloneRow(previous);
|
||||
merged.setShopMallName(firstNonBlank(incoming.getShopMallName(), merged.getShopMallName()));
|
||||
merged.setAsin(firstNonBlank(incoming.getAsin(), merged.getAsin()));
|
||||
merged.setPrice(firstNonBlank(incoming.getPrice(), merged.getPrice()));
|
||||
merged.setRecommendedPrice(firstNonBlank(incoming.getRecommendedPrice(), merged.getRecommendedPrice()));
|
||||
merged.setMinimumPrice(firstNonBlank(incoming.getMinimumPrice(), merged.getMinimumPrice()));
|
||||
merged.setFirstPlace(firstNonBlank(incoming.getFirstPlace(), merged.getFirstPlace()));
|
||||
merged.setSecondPlace(firstNonBlank(incoming.getSecondPlace(), merged.getSecondPlace()));
|
||||
merged.setCartShopName(firstNonBlank(incoming.getCartShopName(), merged.getCartShopName()));
|
||||
merged.setPriceChangeStatus(firstNonBlank(incoming.getPriceChangeStatus(), merged.getPriceChangeStatus()));
|
||||
merged.setModifyCount(firstNonBlank(incoming.getModifyCount(), merged.getModifyCount()));
|
||||
merged.setStatus(firstNonBlank(incoming.getStatus(), merged.getStatus()));
|
||||
return merged;
|
||||
}
|
||||
private PriceTrackSubmitResultRequest.AsinResult cloneRow(PriceTrackSubmitResultRequest.AsinResult row) {
|
||||
PriceTrackSubmitResultRequest.AsinResult out = new PriceTrackSubmitResultRequest.AsinResult();
|
||||
if (row == null) {
|
||||
return out;
|
||||
}
|
||||
out.setShopMallName(row.getShopMallName());
|
||||
out.setAsin(row.getAsin());
|
||||
out.setPrice(row.getPrice());
|
||||
out.setRecommendedPrice(row.getRecommendedPrice());
|
||||
out.setMinimumPrice(row.getMinimumPrice());
|
||||
out.setFirstPlace(row.getFirstPlace());
|
||||
out.setSecondPlace(row.getSecondPlace());
|
||||
out.setCartShopName(row.getCartShopName());
|
||||
out.setPriceChangeStatus(row.getPriceChangeStatus());
|
||||
out.setModifyCount(row.getModifyCount());
|
||||
out.setStatus(row.getStatus());
|
||||
return out;
|
||||
}
|
||||
private int countPayloadRows(PriceTrackSubmitResultRequest.ShopResult payload) {
|
||||
if (payload == null) {
|
||||
return 0;
|
||||
}
|
||||
return excelAssemblyService.countRows(excelAssemblyService.normalizeCountriesMap(payload.getCountries()));
|
||||
}
|
||||
private String firstNonBlank(String preferred, String fallback) {
|
||||
if (preferred != null && !preferred.isBlank()) {
|
||||
return preferred;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
private static String fmt(LocalDateTime t) {
|
||||
return t == null ? null : t.toString();
|
||||
}
|
||||
|
||||
@@ -142,6 +142,15 @@ public class ShopManageService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
public String findMallNameByShopName(String shopName) {
|
||||
String normalizedShopName = normalizeRequired(shopName, "shopName required");
|
||||
ShopManageEntity entity = shopManageMapper.selectOne(new LambdaQueryWrapper<ShopManageEntity>()
|
||||
.select(ShopManageEntity::getMallName)
|
||||
.eq(ShopManageEntity::getShopName, normalizedShopName)
|
||||
.last("limit 1"));
|
||||
return entity == null ? null : entity.getMallName();
|
||||
}
|
||||
|
||||
private ShopManageEntity getById(Long id) {
|
||||
ShopManageEntity entity = shopManageMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
|
||||
@@ -871,8 +871,12 @@ async function refreshTaskDetails(taskIds?: number[]) {
|
||||
if (changed) {
|
||||
syncResultState()
|
||||
}
|
||||
} catch {
|
||||
// ignore polling failures
|
||||
} catch (error) {
|
||||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||||
if (transient && chainStarted.value) {
|
||||
queuePushResult.value = '当前任务运行中,正在等待后端服务恢复...'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,9 +1106,13 @@ async function loadHistory() {
|
||||
await refreshTaskDetails()
|
||||
ensurePolling(true)
|
||||
} catch (err) {
|
||||
const message = (err instanceof Error ? err.message : String(err || '')).toLowerCase()
|
||||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||||
if (!transient) {
|
||||
console.error('loadHistory error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeRecordFromView(resultId: number) {
|
||||
sessionTasks.value.forEach(st => {
|
||||
|
||||
@@ -261,7 +261,7 @@ import { ElMessage } from 'element-plus'
|
||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
||||
import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import {
|
||||
addPriceTrackCandidate,
|
||||
completePriceTrackLoopChild,
|
||||
@@ -327,6 +327,7 @@ const asinModeEnabled = ref(false) // 按指定ASIN文档(自定义)
|
||||
|
||||
// 上传文件
|
||||
const asinFiles = ref<string[]>([]) // ASIN文档
|
||||
const asinUploadedFiles = ref<UploadedJavaFile[]>([])
|
||||
|
||||
// 是否有有效模式
|
||||
const hasValidMode = computed(() => statusModeEnabled.value || asinModeEnabled.value)
|
||||
@@ -499,6 +500,7 @@ async function selectAsinFile() {
|
||||
})
|
||||
if (result?.paths?.length) {
|
||||
asinFiles.value = result.paths
|
||||
asinUploadedFiles.value = await uploadAsinPathsToJava(result.paths)
|
||||
ElMessage.success(`已选择 ${result.paths.length} 个ASIN文件`)
|
||||
}
|
||||
return
|
||||
@@ -507,6 +509,7 @@ async function selectAsinFile() {
|
||||
const paths = await api.select_brand_xlsx_files()
|
||||
if (!paths?.length) return
|
||||
asinFiles.value = paths
|
||||
asinUploadedFiles.value = await uploadAsinPathsToJava(paths)
|
||||
ElMessage.success(`已选择 ${paths.length} 个ASIN文件`)
|
||||
return
|
||||
}
|
||||
@@ -527,7 +530,8 @@ async function selectAsinFolder() {
|
||||
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
|
||||
return
|
||||
}
|
||||
asinFiles.value = result.items.map((item) => item.absolutePath)
|
||||
asinFiles.value = result.items.map((item) => item.relativePath || item.absolutePath)
|
||||
asinUploadedFiles.value = await uploadAsinPathsToJava(result.items)
|
||||
ElMessage.success(`已选择文件夹内 ${result.items.length} 个ASIN文件`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '选择失败')
|
||||
@@ -537,6 +541,31 @@ async function selectAsinFolder() {
|
||||
|
||||
|
||||
// ========== 国家顺序相关 ==========
|
||||
async function uploadAsinPathsToJava(paths: Array<string | { absolutePath: string; relativePath?: string }>) {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.upload_file_to_java) {
|
||||
return []
|
||||
}
|
||||
const uploaded: UploadedJavaFile[] = []
|
||||
for (const item of paths) {
|
||||
const filePath = typeof item === 'string' ? item : item.absolutePath
|
||||
const relativePath = typeof item === 'string' ? undefined : item.relativePath
|
||||
const result = await api.upload_file_to_java(filePath, relativePath)
|
||||
if (!result?.success || !result.data) {
|
||||
throw new Error(result?.error || result?.message || `上传失败:${filePath}`)
|
||||
}
|
||||
uploaded.push(result.data)
|
||||
}
|
||||
return uploaded
|
||||
}
|
||||
|
||||
function resolveAsinRequestPaths() {
|
||||
if (asinUploadedFiles.value.length) {
|
||||
return asinUploadedFiles.value.map((item) => item.localPath).filter((path) => !!path)
|
||||
}
|
||||
return asinFiles.value
|
||||
}
|
||||
|
||||
function selectMode(mode: 'status' | 'asin') {
|
||||
statusModeEnabled.value = mode === 'status'
|
||||
asinModeEnabled.value = mode === 'asin'
|
||||
@@ -744,7 +773,7 @@ async function runMatch() {
|
||||
matching.value = true
|
||||
try {
|
||||
const res = await matchPriceTrackShops(names, {
|
||||
asinFiles: asinModeEnabled.value ? asinFiles.value : [],
|
||||
asinFiles: asinModeEnabled.value ? resolveAsinRequestPaths() : [],
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
})
|
||||
const batch = res.items || []
|
||||
@@ -846,7 +875,7 @@ async function pushToPythonQueueLegacy() {
|
||||
statusMode: statusModeEnabled.value,
|
||||
asinMode: asinModeEnabled.value,
|
||||
items: matchedRows as unknown as Record<string, unknown>[],
|
||||
asinFiles: asinFiles.value,
|
||||
asinFiles: resolveAsinRequestPaths(),
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
}
|
||||
const taskVo = await createPriceTrackTask(taskReq)
|
||||
@@ -906,6 +935,7 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu
|
||||
task_id: taskVo.taskId,
|
||||
shop_name: shopName,
|
||||
shop_id: row.shopId ?? null,
|
||||
shop_mall_name: row.shopMallName || '',
|
||||
country_codes: [...orderedCountryCodes.value],
|
||||
mode: statusModeEnabled.value ? 'status' : 'asin',
|
||||
skip_asins: skipAsinsByCountry,
|
||||
@@ -918,8 +948,12 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu
|
||||
}
|
||||
|
||||
async function waitForTaskTerminal(taskId: number) {
|
||||
let transientErrorCount = 0
|
||||
const maxTransientErrors = 30
|
||||
while (true) {
|
||||
try {
|
||||
const batch = await getPriceTrackTaskProgressBatch([taskId])
|
||||
transientErrorCount = 0
|
||||
if ((batch.missingTaskIds || []).includes(taskId)) {
|
||||
removePollingTask(taskId)
|
||||
await loadHistory()
|
||||
@@ -945,6 +979,21 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
syncPollingIdsWithHistory()
|
||||
return status
|
||||
}
|
||||
} catch (error) {
|
||||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||||
if (!transient) throw error
|
||||
transientErrorCount += 1
|
||||
if (transientErrorCount >= maxTransientErrors) {
|
||||
throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`)
|
||||
}
|
||||
queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors})...`
|
||||
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) {
|
||||
ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`)
|
||||
}
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
continue
|
||||
}
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
}
|
||||
}
|
||||
@@ -973,14 +1022,28 @@ async function pushToPythonQueue() {
|
||||
try {
|
||||
for (let index = 0; index < matchedRows.length; index += 1) {
|
||||
const row = matchedRows[index]
|
||||
const taskVo = await createPriceTrackTask({
|
||||
let attempt = 0
|
||||
const taskVo = await (async () => {
|
||||
while (true) {
|
||||
try {
|
||||
return await createPriceTrackTask({
|
||||
userId: 0,
|
||||
statusMode: statusModeEnabled.value,
|
||||
asinMode: asinModeEnabled.value,
|
||||
items: [row] as unknown as Record<string, unknown>[],
|
||||
asinFiles: asinFiles.value,
|
||||
asinFiles: resolveAsinRequestPaths(),
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
})
|
||||
} catch (error) {
|
||||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||||
attempt += 1
|
||||
if (!transient || attempt >= 10) throw error
|
||||
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/10)...`
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
taskSnapshots.value = {
|
||||
...taskSnapshots.value,
|
||||
[taskVo.taskId]: {
|
||||
@@ -1050,7 +1113,10 @@ async function syncActiveLoopRun() {
|
||||
setLoopRun(loop)
|
||||
clearLoopRunIfTerminal()
|
||||
return activeLoopRun.value
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||||
if (transient) return activeLoopRun.value
|
||||
activeLoopRun.value = null
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
@@ -1107,10 +1173,25 @@ async function runLoopExecution(loopId?: number) {
|
||||
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break
|
||||
|
||||
if (loop.activeTaskId) {
|
||||
addPollingTask(loop.activeTaskId)
|
||||
const activeTaskId = loop.activeTaskId
|
||||
addPollingTask(activeTaskId)
|
||||
scheduleNextPoll(true)
|
||||
const finalStatus = await waitForTaskTerminal(loop.activeTaskId)
|
||||
const updatedLoop = await completePriceTrackLoopChild(loop.id, loop.activeTaskId)
|
||||
const finalStatus = await waitForTaskTerminal(activeTaskId)
|
||||
let completeAttempt = 0
|
||||
const updatedLoop = await (async () => {
|
||||
while (true) {
|
||||
try {
|
||||
return await completePriceTrackLoopChild(loop.id, activeTaskId)
|
||||
} catch (error) {
|
||||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||||
completeAttempt += 1
|
||||
if (!transient || completeAttempt >= 10) throw error
|
||||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后确认子任务完成(${completeAttempt}/10)...`
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
setLoopRun(updatedLoop)
|
||||
await loadHistory()
|
||||
await loadDashboard()
|
||||
@@ -1122,15 +1203,44 @@ async function runLoopExecution(loopId?: number) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dispatch = await dispatchNextPriceTrackLoopRun(loop.id)
|
||||
let dispatchAttempt = 0
|
||||
const dispatch = await (async () => {
|
||||
while (true) {
|
||||
try {
|
||||
return await dispatchNextPriceTrackLoopRun(loop.id)
|
||||
} catch (error) {
|
||||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||||
dispatchAttempt += 1
|
||||
if (!transient || dispatchAttempt >= 10) throw error
|
||||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后继续派发(${dispatchAttempt}/10)...`
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
setLoopRun(dispatch.loopRun || loop)
|
||||
if (!dispatch.childTaskRequest) {
|
||||
clearLoopRunIfTerminal()
|
||||
break
|
||||
}
|
||||
const row = dispatch.childTaskRequest.items?.[0]
|
||||
const childTaskRequest = dispatch.childTaskRequest
|
||||
const row = childTaskRequest.items?.[0]
|
||||
if (!row) throw new Error('循环任务缺少待执行店铺')
|
||||
const taskVo = await createPriceTrackTask(dispatch.childTaskRequest)
|
||||
let createAttempt = 0
|
||||
const taskVo = await (async () => {
|
||||
while (true) {
|
||||
try {
|
||||
return await createPriceTrackTask(childTaskRequest)
|
||||
} catch (error) {
|
||||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||||
createAttempt += 1
|
||||
if (!transient || createAttempt >= 10) throw error
|
||||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后重试创建子任务(${createAttempt}/10)...`
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
recordCreatedTask(taskVo)
|
||||
const queuePayload = buildQueuePayload(taskVo, row)
|
||||
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||||
@@ -1185,7 +1295,7 @@ async function pushToPythonLoopQueue() {
|
||||
statusMode: statusModeEnabled.value,
|
||||
asinMode: asinModeEnabled.value,
|
||||
items: matchedRows,
|
||||
asinFiles: asinFiles.value,
|
||||
asinFiles: resolveAsinRequestPaths(),
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
executionMode: executionMode.value,
|
||||
targetRounds: executionMode.value === 'FINITE' ? Math.max(1, Number(roundCount.value) || 1) : undefined,
|
||||
|
||||
@@ -638,6 +638,44 @@ const historySectionItems = computed(() =>
|
||||
|
||||
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
||||
|
||||
const TRANSIENT_BACKEND_ERROR_PATTERNS = [
|
||||
'无法连接到后端服务',
|
||||
'bad gateway',
|
||||
'gateway timeout',
|
||||
'network error',
|
||||
'timeout',
|
||||
'502',
|
||||
'503',
|
||||
'504',
|
||||
] as const
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(() => resolve(), ms)
|
||||
})
|
||||
}
|
||||
|
||||
function isTransientBackendError(error: unknown) {
|
||||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||||
return TRANSIENT_BACKEND_ERROR_PATTERNS.some((pattern) => message.includes(pattern.toLowerCase()))
|
||||
}
|
||||
|
||||
async function createProductRiskTaskWithRetry(items: ProductRiskShopQueueItem[], maxAttempts = 10) {
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
return await createProductRiskTask(items)
|
||||
} catch (error) {
|
||||
attempt += 1
|
||||
if (!isTransientBackendError(error) || attempt >= maxAttempts) {
|
||||
throw error
|
||||
}
|
||||
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...`
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTaskBatch() {
|
||||
const ids = pollingTaskIds.value.filter((id) => id > 0)
|
||||
if (!ids.length) return
|
||||
@@ -718,8 +756,12 @@ function stopPolling() {
|
||||
}
|
||||
|
||||
async function waitForTaskTerminal(taskId: number) {
|
||||
let transientErrorCount = 0
|
||||
const maxTransientErrors = 30
|
||||
while (true) {
|
||||
try {
|
||||
const batch = await getProductRiskTaskProgressBatch([taskId])
|
||||
transientErrorCount = 0
|
||||
const detail = (batch.items || []).find((d) => d.task?.id === taskId)
|
||||
const status = detail?.task?.status || ''
|
||||
|
||||
@@ -742,10 +784,22 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
await loadDashboard()
|
||||
return status
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(() => resolve(), getPollIntervalMs())
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isTransientBackendError(error)) {
|
||||
throw error
|
||||
}
|
||||
transientErrorCount += 1
|
||||
if (transientErrorCount >= maxTransientErrors) {
|
||||
throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`)
|
||||
}
|
||||
queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors})...`
|
||||
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) {
|
||||
ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`)
|
||||
}
|
||||
await sleep(getPollIntervalMs())
|
||||
continue
|
||||
}
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -943,7 +997,7 @@ async function pushToPythonQueue() {
|
||||
try {
|
||||
for (let i = 0; i < toPush.length; i++) {
|
||||
const item = toPush[i]
|
||||
const created = await createProductRiskTask([item])
|
||||
const created = await createProductRiskTaskWithRetry([item])
|
||||
const taskId = created.taskId
|
||||
taskSnapshots.value = {
|
||||
...taskSnapshots.value,
|
||||
|
||||
@@ -246,7 +246,7 @@ function removeMatchedRowsLocally(rows: ShopMatchHistoryItem[] | ShopMatchShopQu
|
||||
function parseScheduleValues() { if (!scheduleEnabled.value) return undefined; const values = schedulePickerValues.value.map((item) => item.trim()).filter(Boolean); if (!values.length) throw new Error('请至少选择一个执行时间'); const withTime = values.map((value) => { const date = parseScheduleDateTime(value); if (!date) throw new Error(`时间格式无效: ${value}`); return { raw: formatScheduleDateTime(date), time: date.getTime() } }).sort((a, b) => a.time - b.time); for (let i = 1; i < withTime.length; i += 1) if (withTime[i].time === withTime[i - 1].time) throw new Error('执行时间不能重复'); return withTime.map((item) => item.raw) }
|
||||
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, countryCodes: string[], stageIndex?: number, finalStage = true) { return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage }], country_codes: [...countryCodes], risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } }
|
||||
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
|
||||
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await activateShopMatchTask(taskId, stage.stageIndex); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1} 轮`; ensurePolling(true) }
|
||||
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await withTransientRetry(() => activateShopMatchTask(taskId, stage.stageIndex), (attempt, maxAttempts) => { queuePushResult.value = `任务 ${taskId} 等待后端恢复后再激活执行(${attempt}/${maxAttempts})...` }); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1} 轮`; ensurePolling(true) }
|
||||
function hasRunningTask() { return Object.values(taskDetails.value).some((status) => status === 'RUNNING') || Object.values(taskSnapshots.value).some((detail) => detail.task?.status === 'RUNNING') }
|
||||
function scheduledTimestamp(value?: string) { const date = parseScheduleDateTime(value); return date ? date.getTime() : Number.NaN }
|
||||
function findNextReadyScheduledTask() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } const ready = Array.from(taskIds).map((taskId) => { const snapshot = taskSnapshots.value[taskId]; const taskStatus = taskDetails.value[taskId] || snapshot?.task?.status; const item = snapshot?.items?.[0] || historyItems.value.find((row) => normalizeTaskId(row.taskId) === taskId); const stage = nextScheduleStage(taskId); if (taskStatus !== 'SCHEDULED' || !item || !stage?.scheduledAt) return null; const scheduledTime = scheduledTimestamp(stage.scheduledAt); if (!Number.isFinite(scheduledTime) || scheduledTime > Date.now()) return null; return { taskId, item, scheduledTime } }).filter((item): item is { taskId: number; item: ShopMatchHistoryItem; scheduledTime: number } => !!item); ready.sort((a, b) => a.scheduledTime - b.scheduledTime || a.taskId - b.taskId); return ready[0] || null }
|
||||
@@ -256,12 +256,16 @@ function restoreScheduledDispatches() { const taskIds = new Set<number>(pollingT
|
||||
function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) }
|
||||
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
|
||||
function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }
|
||||
const TRANSIENT_BACKEND_ERROR_PATTERNS = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'] as const
|
||||
function sleep(ms: number) { return new Promise<void>((resolve) => { window.setTimeout(() => resolve(), ms) }) }
|
||||
function isTransientBackendError(error: unknown) { const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase(); return TRANSIENT_BACKEND_ERROR_PATTERNS.some((pattern) => message.includes(pattern.toLowerCase())) }
|
||||
async function withTransientRetry<T>(action: () => Promise<T>, onRetry: (attempt: number, maxAttempts: number) => void, maxAttempts = 10) { let attempt = 0; while (true) { try { return await action() } catch (error) { attempt += 1; if (!isTransientBackendError(error) || attempt >= maxAttempts) throw error; onRetry(attempt, maxAttempts); await sleep(getPollIntervalMs()) } } }
|
||||
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTaskProgressBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removeTaskLocally(missingId); delete nextSnapshots[missingId]; settledTaskIds.add(missingId) } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; const prev = nextSnapshots[taskId]; nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
|
||||
function getPollIntervalMs() { return document.visibilityState === 'visible' ? 6000 : 20000 }
|
||||
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
|
||||
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
|
||||
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
|
||||
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTaskProgressBatch([taskId]); if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
|
||||
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { try { const batch = await getShopMatchTaskProgressBatch([taskId]); transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors})...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
|
||||
function resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' }
|
||||
function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined }
|
||||
function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total} 次`; return `执行进度: 共 ${total} 次`}
|
||||
@@ -298,7 +302,7 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) {
|
||||
}
|
||||
function formatMatchStatus(status?: string) { const value = (status || '').trim(); return { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '索引过期' }[value] || value || '—' }
|
||||
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '紫鸟索引已命中,可推送队列'; if (row.matched) return '已关联索引,请结合状态列查看是否可推送'; return '未命中或未就绪,请检查店铺名与索引刷新' }
|
||||
async function pushToPythonQueue() { const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } pushing.value = true; queuePayloadText.value = ''; try { for (let index = 0; index < matched.length; index += 1) { const item = matched[index]; const created = await createShopMatchTask([item], orderedCountryCodes.value, scheduleValues); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) continue; if (scheduleValues?.length) { restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = matched.length > 1 ? `任务 ${taskId} 已入队,等待完成后继续推送下一条(${index + 1}/${matched.length})` : `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { throw new Error(`任务 ${taskId} 执行失败,已停止后续店铺推送`) } queuePushResult.value = index + 1 < matched.length ? `任务 ${taskId} 已完成,继续推送下一条(${index + 1}/${matched.length})` : `任务 ${taskId} 已完成` } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); ElMessage.success(scheduleValues?.length ? '定时任务已创建,后续会按时间串行推入 Python 队列' : `已按顺序完成 ${matched.length} 条店铺推送`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { pushing.value = false } }
|
||||
async function pushToPythonQueue() { const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } pushing.value = true; queuePayloadText.value = ''; try { for (let index = 0; index < matched.length; index += 1) { const item = matched[index]; const created = await withTransientRetry(() => createShopMatchTask([item], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) continue; if (scheduleValues?.length) { restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = matched.length > 1 ? `任务 ${taskId} 已入队,等待完成后继续推送下一条(${index + 1}/${matched.length})` : `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { throw new Error(`任务 ${taskId} 执行失败,已停止后续店铺推送`) } queuePushResult.value = index + 1 < matched.length ? `任务 ${taskId} 已完成,继续推送下一条(${index + 1}/${matched.length})` : `任务 ${taskId} 已完成` } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); ElMessage.success(scheduleValues?.length ? '定时任务已创建,后续会按时间串行推入 Python 队列' : `已按顺序完成 ${matched.length} 条店铺推送`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { pushing.value = false } }
|
||||
|
||||
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); startScheduleHeartbeat(); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
|
||||
onUnmounted(() => { stopPolling(); stopScheduleHeartbeat(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); for (const timer of dispatchTimers.values()) window.clearTimeout(timer); dispatchTimers.clear() })
|
||||
|
||||
@@ -925,6 +925,7 @@ export interface PriceTrackCandidateVo {
|
||||
|
||||
export interface PriceTrackShopQueueItem {
|
||||
shopName?: string;
|
||||
shopMallName?: string;
|
||||
shopId?: number | string | null;
|
||||
matched?: boolean;
|
||||
matchStatus?: string;
|
||||
@@ -958,6 +959,7 @@ export interface PriceTrackHistoryItem {
|
||||
loopRunId?: number;
|
||||
roundIndex?: number;
|
||||
shopName?: string;
|
||||
shopMallName?: string;
|
||||
shopId?: number | string | null;
|
||||
platform?: string;
|
||||
companyName?: string;
|
||||
|
||||
Reference in New Issue
Block a user