From 8543dad514f64241efc6c9fd594a320227604f3b Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Tue, 14 Jul 2026 22:11:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E7=AB=AFBUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ImageVideoAsyncTaskService.java | 9 + .../controller/ShopMatchController.java | 25 +- .../model/dto/ShopMatchCreateTaskRequest.java | 3 +- .../model/dto/ShopMatchTaskItemDto.java | 46 ++ .../model/vo/ShopMatchCreateTaskVo.java | 71 +++ .../model/vo/ShopMatchSkipAsinPageVo.java | 37 ++ .../service/ShopMatchTaskService.java | 411 ++++++++++++++++-- .../ImageVideoAsyncTaskServiceTest.java | 121 ++++++ .../brand/components/BrandShopMatchTab.vue | 48 +- .../components/DeliveryVideoWorkspace.vue | 22 +- frontend-vue/src/shared/api/java-modules.ts | 78 +++- 11 files changed, 813 insertions(+), 58 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchTaskItemDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/vo/ShopMatchCreateTaskVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/vo/ShopMatchSkipAsinPageVo.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java index 6612256..65a02ab 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java @@ -416,6 +416,15 @@ public class ImageVideoAsyncTaskService { private String findDirectText(Object value, Set names) { Object parsed = parsePossiblyJson(value); + if (parsed instanceof Collection collection) { + for (Object child : collection) { + String text = findDirectText(child, names); + if (!text.isBlank()) { + return text; + } + } + return ""; + } if (!(parsed instanceof Map map)) { return ""; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java index 362c82a..ccf462d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java @@ -8,7 +8,6 @@ import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequ import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskTaskBatchRequest; import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo; import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCountryPreferenceVo; -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; import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo; @@ -16,6 +15,8 @@ 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.model.vo.ShopMatchCreateTaskVo; +import com.nanri.aiimage.modules.shopmatch.model.vo.ShopMatchSkipAsinPageVo; import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResolveService; import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; import io.swagger.v3.oas.annotations.Operation; @@ -122,7 +123,7 @@ public class ShopMatchController { @PostMapping("/tasks") @Operation(summary = "创建匹配店铺任务", description = "创建任务与各店铺占位结果,支持立即执行或多时间点定时执行。") - public ApiResponse createTask(@Valid @RequestBody ShopMatchCreateTaskRequest request) { + public ApiResponse createTask(@Valid @RequestBody ShopMatchCreateTaskRequest request) { return ApiResponse.success(shopMatchTaskService.createTask(request)); } @@ -146,6 +147,26 @@ public class ShopMatchController { return ApiResponse.success(null); } + @GetMapping("/tasks/{taskId}/skip-asins/paginated") + @Operation(summary = "分页查询匹配店铺任务 ASIN 表数据", description = "Python 按任务、店铺和国家分页拉取从创建任务接口迁移出的 ASIN 表数据。") + public ApiResponse getTaskSkipAsinsPaginated( + @Parameter(description = "任务编号", required = true, example = "200") @PathVariable Long taskId, + @Parameter(description = "页码,从 1 开始", example = "1") + @RequestParam(value = "page", defaultValue = "1") Integer page, + @Parameter(description = "每页条数,默认 1000,最大 2000", example = "1000") + @RequestParam(value = "page_size", defaultValue = "1000") Integer pageSize, + @Parameter(description = "店铺名称过滤条件,可重复传入,也可用英文逗号分隔", example = "测试店铺A") + @RequestParam(value = "shop_name", required = false) List shopNames, + @Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔", example = "DE") + @RequestParam(value = "country_code", required = false) List countryCodes) { + return ApiResponse.success(shopMatchTaskService.getTaskSkipAsinsPaginated( + taskId, + page, + pageSize, + shopNames, + countryCodes)); + } + @PostMapping("/tasks/{taskId}/stage-finished") @Operation(summary = "标记阶段完成", description = "某个定时阶段完成后推进到下一阶段,或等待最终收尾。") public ApiResponse completeStage( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchCreateTaskRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchCreateTaskRequest.java index 4367eeb..23bc7c3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchCreateTaskRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchCreateTaskRequest.java @@ -1,7 +1,6 @@ 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; @@ -24,7 +23,7 @@ public class ShopMatchCreateTaskRequest { @NotEmpty(message = "items cannot be empty") @Valid @Schema(description = "已匹配店铺列表", requiredMode = Schema.RequiredMode.REQUIRED) - private List items = new ArrayList<>(); + private List items = new ArrayList<>(); @NotEmpty(message = "country_codes cannot be empty") @JsonProperty("country_codes") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchTaskItemDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchTaskItemDto.java new file mode 100644 index 0000000..9795619 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchTaskItemDto.java @@ -0,0 +1,46 @@ +package com.nanri.aiimage.modules.shopmatch.model.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "创建匹配店铺任务的单店任务数据") +public class ShopMatchTaskItemDto { + + @JsonProperty("shopName") + @Schema(description = "店铺名称", example = "某某店") + private String shopName; + + @Schema(description = "是否已匹配到紫鸟索引") + private boolean matched; + + @JsonProperty("shopId") + @Schema(description = "紫鸟侧店铺 ID") + private String shopId; + + @Schema(description = "平台") + private String platform; + + @JsonProperty("companyName") + @Schema(description = "公司名称或主体标识") + private String companyName; + + @JsonProperty("openStoreUrl") + @Schema(description = "打开店铺的地址,由 Python 侧消费") + private String openStoreUrl; + + @JsonProperty("matchedUserId") + @Schema(description = "索引关联到的用户 ID") + private Long matchedUserId; + + @JsonProperty("matchStatus") + @Schema(description = "匹配状态码,如 MATCHED、PENDING、CONFLICT、INDEX_STALE") + private String matchStatus; + + @JsonProperty("matchMessage") + @Schema(description = "匹配说明") + private String matchMessage; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/vo/ShopMatchCreateTaskVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/vo/ShopMatchCreateTaskVo.java new file mode 100644 index 0000000..88497da --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/vo/ShopMatchCreateTaskVo.java @@ -0,0 +1,71 @@ +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; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "创建匹配店铺任务响应") +public class ShopMatchCreateTaskVo { + + @Schema(description = "新建任务主键 biz_file_task.id", example = "200") + private Long taskId; + + @Schema(description = "各店铺初始任务快照") + private List items = new ArrayList<>(); + + @Data + @Schema(description = "创建匹配店铺任务后的单店快照") + public static class Item { + + private Long resultId; + + private Long taskId; + + @JsonProperty("shopName") + private String shopName; + + @JsonProperty("shopId") + private String shopId; + + private String platform; + + @JsonProperty("companyName") + private String companyName; + + private boolean matched; + + @JsonProperty("matchStatus") + private String matchStatus; + + @JsonProperty("matchMessage") + private String matchMessage; + + @JsonProperty("taskStatus") + private String taskStatus; + + private Boolean success; + + private String error; + + @JsonProperty("outputFilename") + private String outputFilename; + + @JsonProperty("downloadUrl") + private String downloadUrl; + + private Long fileJobId; + + private String fileStatus; + + private String fileError; + + private Boolean fileReady; + + @JsonProperty("scheduledAt") + private String scheduledAt; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/vo/ShopMatchSkipAsinPageVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/vo/ShopMatchSkipAsinPageVo.java new file mode 100644 index 0000000..1227cb3 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/vo/ShopMatchSkipAsinPageVo.java @@ -0,0 +1,37 @@ +package com.nanri.aiimage.modules.shopmatch.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto; +import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Data +@Schema(description = "分页查询匹配店铺任务 ASIN 表数据响应") +public class ShopMatchSkipAsinPageVo { + + @Schema(description = "当前页码,从 1 开始") + private Integer page; + + @Schema(description = "每页条数") + private Integer pageSize; + + @Schema(description = "总 ASIN 数") + private Long total; + + @Schema(description = "总页数") + private Integer totalPages; + + @Schema(description = "原创建任务 items.queryAsins 字段迁移出的数据,按国家返回 ASIN 清单") + private List queryAsins = new ArrayList<>(); + + @Schema(description = "原创建任务 items.skipAsinsByCountry 字段迁移出的数据,按国家分组的跳过 ASIN 列表") + private Map> skipAsinsByCountry = new LinkedHashMap<>(); + + @Schema(description = "原创建任务 items.skipAsinDetailsByCountry 字段迁移出的数据,按国家分组的跳过 ASIN 明细") + private Map> skipAsinDetailsByCountry = new LinkedHashMap<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java index f19cc9b..7a3eb44 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java @@ -8,7 +8,6 @@ import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.TaskPressureProperties; import com.nanri.aiimage.modules.file.service.oss.OssStorageService; 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; import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskResultItemVo; @@ -16,15 +15,22 @@ 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.pricetrack.model.vo.SkipPriceAsinPageVo; +import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto; 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.dto.ShopMatchTaskItemDto; import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchShopCandidateEntity; +import com.nanri.aiimage.modules.shopmatch.model.vo.ShopMatchCreateTaskVo; +import com.nanri.aiimage.modules.shopmatch.model.vo.ShopMatchSkipAsinPageVo; +import com.nanri.aiimage.modules.shopmatch.model.vo.ShopMatchTaskStageVo; +import com.nanri.aiimage.modules.shopkey.mapper.QueryAsinMapper; import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto; +import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity; import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService; import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; @@ -72,6 +78,7 @@ public class ShopMatchTaskService { private final TaskFileJobService taskFileJobService; private final TaskDistributedLockService taskDistributedLockService; private final SkipPriceAsinService skipPriceAsinService; + private final QueryAsinMapper queryAsinMapper; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = shopMatchTaskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -344,6 +351,30 @@ public class ShopMatchTaskService { return batch; } + public ShopMatchSkipAsinPageVo getTaskSkipAsinsPaginated( + Long taskId, int page, int pageSize, List requestedShopNames, List requestedCountryCodes) { + if (taskId == null || taskId <= 0) { + throw new BusinessException("taskId 涓嶅悎娉?"); + } + FileTaskEntity task = loadTaskForExecution(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("浠诲姟涓嶅瓨鍦?"); + } + + ShopMatchCreateTaskRequest request = parseTaskRequest(task); + List countryCodes = resolveSkipAsinCountryCodes(request, requestedCountryCodes); + if (countryCodes.isEmpty()) { + return emptySkipAsinPage(List.of(), page, pageSize); + } + List shopNames = resolveSkipAsinShopNames(request, requestedShopNames); + if (shopNames.isEmpty()) { + return emptySkipAsinPage(countryCodes, page, pageSize); + } + SkipPriceAsinPageVo skipPage = skipPriceAsinService.listSkipAsinsPaginated(shopNames, countryCodes, page, pageSize); + QueryAsinPage queryPage = listQueryAsinsPaginated(shopNames, countryCodes, page, pageSize); + return toShopMatchAsinPage(skipPage, queryPage); + } + public String resolveResultDownloadUrl(Long resultId, Long userId) { FileResultEntity entity = fileResultMapper.selectById(resultId); if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { @@ -366,14 +397,14 @@ public class ShopMatchTaskService { } @Transactional - public ProductRiskCreateTaskVo createTask(ShopMatchCreateTaskRequest request) { + public ShopMatchCreateTaskVo createTask(ShopMatchCreateTaskRequest request) { if (request.getUserId() == null || request.getUserId() <= 0) { throw new BusinessException("user_id 不合法"); } if (request.getItems() == null || request.getItems().isEmpty()) { throw new BusinessException("items 不能为空"); } - List uniqueItems = dedupeQueueItems(request.getItems()); + List uniqueItems = dedupeQueueItems(toQueueItems(request.getItems())); if (uniqueItems.isEmpty()) { throw new BusinessException("items 不能为空"); } @@ -384,13 +415,12 @@ public class ShopMatchTaskService { } List countryCodes = normalizeCountryCodes(request.getCountryCodes()); - enrichItemsWithSkipAsins(uniqueItems, countryCodes); List scheduleTimes = normalizeScheduleTimes(request.getScheduleTimes()); LocalDateTime now = now(); ShopMatchCreateTaskRequest persistedRequest = new ShopMatchCreateTaskRequest(); persistedRequest.setUserId(request.getUserId()); - persistedRequest.setItems(uniqueItems); + persistedRequest.setItems(toTaskItems(uniqueItems)); persistedRequest.setCountryCodes(countryCodes); persistedRequest.setScheduleTimes(scheduleTimes); if (!scheduleTimes.isEmpty()) { @@ -441,9 +471,9 @@ public class ShopMatchTaskService { shopMatchTaskCacheService.touchTaskHeartbeat(task.getId()); } - ProductRiskCreateTaskVo vo = new ProductRiskCreateTaskVo(); + ShopMatchCreateTaskVo vo = new ShopMatchCreateTaskVo(); vo.setTaskId(task.getId()); - vo.setItems(snapshot); + vo.setItems(snapshot.stream().map(this::toCreateTaskItem).toList()); return vo; } @@ -1073,7 +1103,7 @@ public class ShopMatchTaskService { return; } String key = ziniaoShopSwitchService.normalizeShopName(vo.getShopName()); - for (ProductRiskShopQueueItemVo item : request.getItems()) { + for (ShopMatchTaskItemDto item : request.getItems()) { if (item == null) { continue; } @@ -1084,8 +1114,6 @@ public class ShopMatchTaskService { vo.setCompanyName(item.getCompanyName()); vo.setMatchStatus(item.getMatchStatus()); vo.setMatchMessage(item.getMatchMessage()); - vo.setSkipAsinsByCountry(item.getSkipAsinsByCountry()); - vo.setSkipAsinDetailsByCountry(item.getSkipAsinDetailsByCountry()); break; } } @@ -1158,6 +1186,78 @@ public class ShopMatchTaskService { return LocalDateTime.now(BUSINESS_ZONE); } + private List toQueueItems(List items) { + List out = new ArrayList<>(); + if (items == null) { + return out; + } + for (ShopMatchTaskItemDto item : items) { + if (item == null) { + continue; + } + ProductRiskShopQueueItemVo vo = new ProductRiskShopQueueItemVo(); + vo.setShopName(item.getShopName()); + vo.setMatched(item.isMatched()); + vo.setShopId(item.getShopId()); + vo.setPlatform(item.getPlatform()); + vo.setCompanyName(item.getCompanyName()); + vo.setOpenStoreUrl(item.getOpenStoreUrl()); + vo.setMatchedUserId(item.getMatchedUserId()); + vo.setMatchStatus(item.getMatchStatus()); + vo.setMatchMessage(item.getMatchMessage()); + out.add(vo); + } + return out; + } + + private List toTaskItems(List items) { + List out = new ArrayList<>(); + if (items == null) { + return out; + } + for (ProductRiskShopQueueItemVo item : items) { + if (item == null) { + continue; + } + ShopMatchTaskItemDto dto = new ShopMatchTaskItemDto(); + dto.setShopName(item.getShopName()); + dto.setMatched(item.isMatched()); + dto.setShopId(item.getShopId()); + dto.setPlatform(item.getPlatform()); + dto.setCompanyName(item.getCompanyName()); + dto.setOpenStoreUrl(item.getOpenStoreUrl()); + dto.setMatchedUserId(item.getMatchedUserId()); + dto.setMatchStatus(item.getMatchStatus()); + dto.setMatchMessage(item.getMatchMessage()); + out.add(dto); + } + return out; + } + + private ShopMatchCreateTaskVo.Item toCreateTaskItem(ProductRiskResultItemVo source) { + ShopMatchCreateTaskVo.Item item = new ShopMatchCreateTaskVo.Item(); + item.setResultId(source.getResultId()); + item.setTaskId(source.getTaskId()); + item.setShopName(source.getShopName()); + item.setShopId(source.getShopId()); + item.setPlatform(source.getPlatform()); + item.setCompanyName(source.getCompanyName()); + item.setMatched(source.isMatched()); + item.setMatchStatus(source.getMatchStatus()); + item.setMatchMessage(source.getMatchMessage()); + item.setTaskStatus(source.getTaskStatus()); + item.setSuccess(source.getSuccess()); + item.setError(source.getError()); + item.setOutputFilename(source.getOutputFilename()); + item.setDownloadUrl(source.getDownloadUrl()); + item.setFileJobId(source.getFileJobId()); + item.setFileStatus(source.getFileStatus()); + item.setFileError(source.getFileError()); + item.setFileReady(source.getFileReady()); + item.setScheduledAt(source.getScheduledAt()); + return item; + } + private List dedupeQueueItems(List raw) { Map byNorm = new LinkedHashMap<>(); for (ProductRiskShopQueueItemVo item : raw) { @@ -1172,34 +1272,269 @@ public class ShopMatchTaskService { return new ArrayList<>(byNorm.values()); } - private void enrichItemsWithSkipAsins(List items, List countryCodes) { - List shopNames = items.stream() - .map(ProductRiskShopQueueItemVo::getShopName) - .filter(name -> name != null && !name.isBlank()) - .distinct() - .toList(); - Map>> detailsByShop = - skipPriceAsinService.listSkipAsinDetailsByShopAndCountries(shopNames, countryCodes); - for (ProductRiskShopQueueItemVo item : items) { - Map> detailMap = - detailsByShop.getOrDefault(item.getShopName(), Map.of()); - Map> normalizedDetails = new LinkedHashMap<>(); - Map> asinsByCountry = new LinkedHashMap<>(); - for (String countryCode : countryCodes) { - List details = detailMap.getOrDefault(countryCode, List.of()); - if (details.isEmpty()) { - continue; - } - normalizedDetails.put(countryCode, details); - asinsByCountry.put(countryCode, details.stream() - .map(SkipPriceAsinDetailDto::getAsin) - .filter(asin -> asin != null && !asin.isBlank()) - .distinct() - .toList()); - } - item.setSkipAsinDetailsByCountry(normalizedDetails); - item.setSkipAsinsByCountry(asinsByCountry); + private List resolveSkipAsinCountryCodes(ShopMatchCreateTaskRequest request, List requestedCountryCodes) { + List taskCountryCodes = normalizeTaskCountryCodes(request == null ? null : request.getCountryCodes()); + LinkedHashSet requested = new LinkedHashSet<>(); + for (String country : splitQueryValues(requestedCountryCodes)) { + requested.add(normalizeLookupCountry(country)); } + if (requested.isEmpty()) { + return taskCountryCodes; + } + List result = new ArrayList<>(); + for (String country : requested) { + if (isTaskCountryEnabled(taskCountryCodes, country)) { + result.add(country); + } + } + return result; + } + + private List normalizeTaskCountryCodes(List countryCodes) { + LinkedHashSet normalized = new LinkedHashSet<>(); + if (countryCodes != null) { + for (String country : countryCodes) { + String value = country == null ? "" : country.trim().toUpperCase(Locale.ROOT); + if (!value.isEmpty()) { + normalized.add(value); + } + } + } + return new ArrayList<>(normalized); + } + + private List resolveSkipAsinShopNames(ShopMatchCreateTaskRequest request, List requestedShopNames) { + List taskShopNames = resolveTaskShopNames(request); + List requested = splitQueryValues(requestedShopNames); + LinkedHashSet normalized = new LinkedHashSet<>(); + for (String shopName : requested) { + String value = ziniaoShopSwitchService.normalizeShopName(shopName); + if (!value.isBlank() && taskShopNames.contains(value)) { + normalized.add(value); + } + } + if (!normalized.isEmpty()) { + return new ArrayList<>(normalized); + } + if (!requested.isEmpty()) { + return List.of(); + } + return taskShopNames; + } + + private List resolveTaskShopNames(ShopMatchCreateTaskRequest request) { + List shopNames = new ArrayList<>(); + if (request == null || request.getItems() == null) { + return shopNames; + } + for (ShopMatchTaskItemDto item : request.getItems()) { + if (item == null) { + continue; + } + String normalized = ziniaoShopSwitchService.normalizeShopName(item.getShopName()); + if (!normalized.isBlank() && !shopNames.contains(normalized)) { + shopNames.add(normalized); + } + } + return shopNames; + } + + private List splitQueryValues(List values) { + List result = new ArrayList<>(); + if (values == null) { + return result; + } + for (String value : values) { + if (value == null) { + continue; + } + for (String part : value.split(",")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed); + } + } + } + return result; + } + + private boolean isTaskCountryEnabled(List countryCodes, String country) { + if (countryCodes == null || countryCodes.isEmpty()) { + return true; + } + for (String code : countryCodes) { + if (country.equals(code == null ? "" : code.trim().toUpperCase(Locale.ROOT))) { + return true; + } + } + return false; + } + + private String normalizeLookupCountry(String country) { + String normalized = country == null ? "" : country.trim().toUpperCase(Locale.ROOT); + if (!List.of("DE", "UK", "FR", "IT", "ES").contains(normalized)) { + throw new BusinessException("鍥藉鍙傛暟涓嶆敮鎸? " + country); + } + return normalized; + } + + private ShopMatchSkipAsinPageVo emptySkipAsinPage(List countryCodes, int page, int pageSize) { + if (page < 1) { + page = 1; + } + if (pageSize < 1 || pageSize > 2000) { + pageSize = 1000; + } + List targetCountries = (countryCodes == null || countryCodes.isEmpty()) + ? List.of("DE", "UK", "FR", "IT", "ES") + : countryCodes; + Map> skipAsinsByCountry = new LinkedHashMap<>(); + Map> skipDetailsByCountry = new LinkedHashMap<>(); + for (String country : targetCountries) { + skipAsinsByCountry.put(country, new ArrayList<>()); + skipDetailsByCountry.put(country, new ArrayList<>()); + } + ShopMatchSkipAsinPageVo vo = new ShopMatchSkipAsinPageVo(); + vo.setPage(page); + vo.setPageSize(pageSize); + vo.setTotal(0L); + vo.setTotalPages(0); + vo.setQueryAsins(toQueryAsinCountryList(skipAsinsByCountry)); + vo.setSkipAsinsByCountry(skipAsinsByCountry); + vo.setSkipAsinDetailsByCountry(skipDetailsByCountry); + return vo; + } + + private QueryAsinPage listQueryAsinsPaginated(List shopNames, List countryCodes, int page, int pageSize) { + if (page < 1) { + page = 1; + } + if (pageSize < 1 || pageSize > 2000) { + pageSize = 1000; + } + List targetCountries = (countryCodes == null || countryCodes.isEmpty()) + ? List.of("DE", "UK", "FR", "IT", "ES") + : countryCodes; + QueryAsinPage result = new QueryAsinPage(); + result.page = page; + result.pageSize = pageSize; + + Map> asinsByCountry = new LinkedHashMap<>(); + for (String country : targetCountries) { + asinsByCountry.put(country, new ArrayList<>()); + } + if (shopNames == null || shopNames.isEmpty()) { + result.queryAsins = toQueryAsinCountryList(asinsByCountry); + return result; + } + + List rows = queryAsinMapper.selectList(new LambdaQueryWrapper() + .in(QueryAsinEntity::getShopName, shopNames) + .orderByAsc(QueryAsinEntity::getId)); + List flattened = new ArrayList<>(); + for (QueryAsinEntity row : rows) { + for (String country : targetCountries) { + String asin = getQueryAsinByCountry(row, country); + if (asin != null && !asin.isBlank()) { + QueryAsinItem item = new QueryAsinItem(); + item.country = country; + item.asin = normalizeAsin(asin); + flattened.add(item); + } + } + } + + result.total = flattened.size(); + result.totalPages = (int) ((result.total + pageSize - 1) / pageSize); + int startIndex = (page - 1) * pageSize; + int endIndex = Math.min(startIndex + pageSize, flattened.size()); + for (int i = startIndex; i < endIndex; i++) { + QueryAsinItem item = flattened.get(i); + asinsByCountry.get(item.country).add(item.asin); + } + result.queryAsins = toQueryAsinCountryList(asinsByCountry); + return result; + } + + private List toQueryAsinCountryList(Map> asinsByCountry) { + List out = new ArrayList<>(); + for (Map.Entry> entry : asinsByCountry.entrySet()) { + QueryAsinCountryAsinsDto item = new QueryAsinCountryAsinsDto(); + item.setCountry(entry.getKey()); + item.setAsins(entry.getValue()); + out.add(item); + } + return out; + } + + private String getQueryAsinByCountry(QueryAsinEntity row, String country) { + if (row == null || country == null) { + return null; + } + return switch (country) { + case "DE" -> row.getAsinDe(); + case "UK" -> row.getAsinUk(); + case "FR" -> row.getAsinFr(); + case "IT" -> row.getAsinIt(); + case "ES" -> row.getAsinEs(); + default -> null; + }; + } + + private Map> toSkipAsinDetailDtos(Map>> source) { + Map> out = new LinkedHashMap<>(); + for (Map.Entry>> entry : source.entrySet()) { + List details = new ArrayList<>(); + if (entry.getValue() != null) { + for (Map row : entry.getValue()) { + if (row == null) { + continue; + } + SkipPriceAsinDetailDto detail = new SkipPriceAsinDetailDto(); + detail.setAsin(row.get("asin")); + detail.setMinimumPrice(row.get("minimumPrice")); + details.add(detail); + } + } + out.put(entry.getKey(), details); + } + return out; + } + + private ShopMatchSkipAsinPageVo toShopMatchAsinPage(SkipPriceAsinPageVo skipPage, QueryAsinPage queryPage) { + ShopMatchSkipAsinPageVo vo = new ShopMatchSkipAsinPageVo(); + Map> skipAsinsByCountry = skipPage == null || skipPage.getSkipAsinsByCountry() == null + ? new LinkedHashMap<>() + : skipPage.getSkipAsinsByCountry(); + Map> skipDetailsByCountry = skipPage == null || skipPage.getSkipAsinDetailsByCountry() == null + ? new LinkedHashMap<>() + : toSkipAsinDetailDtos(skipPage.getSkipAsinDetailsByCountry()); + long skipTotal = skipPage == null || skipPage.getTotal() == null ? 0L : skipPage.getTotal(); + int skipTotalPages = skipPage == null || skipPage.getTotalPages() == null ? 0 : skipPage.getTotalPages(); + long queryTotal = queryPage == null ? 0L : queryPage.total; + int queryTotalPages = queryPage == null ? 0 : queryPage.totalPages; + + vo.setPage(skipPage != null ? skipPage.getPage() : queryPage == null ? 1 : queryPage.page); + vo.setPageSize(skipPage != null ? skipPage.getPageSize() : queryPage == null ? 1000 : queryPage.pageSize); + vo.setTotal(Math.max(skipTotal, queryTotal)); + vo.setTotalPages(Math.max(skipTotalPages, queryTotalPages)); + vo.setQueryAsins(queryPage == null ? new ArrayList<>() : queryPage.queryAsins); + vo.setSkipAsinsByCountry(skipAsinsByCountry); + vo.setSkipAsinDetailsByCountry(skipDetailsByCountry); + return vo; + } + + private static class QueryAsinPage { + private int page; + private int pageSize; + private long total; + private int totalPages; + private List queryAsins = new ArrayList<>(); + } + + private static class QueryAsinItem { + private String country; + private String asin; } private List normalizeCountryCodes(List raw) { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java new file mode 100644 index 0000000..ddb10b9 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java @@ -0,0 +1,121 @@ +package com.nanri.aiimage.modules.imagevideo.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.ImageVideoProperties; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoAsyncTaskMapper; +import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoAsyncTaskEntity; +import com.nanri.aiimage.modules.imagevideo.model.vo.DouyinCopyVo; +import org.junit.jupiter.api.Test; +import org.springframework.core.task.TaskExecutor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ImageVideoAsyncTaskServiceTest { + + @Test + void parsesNestedDouyinCopyOutputIntoFrontendTextFields() { + ImageVideoCozeService cozeService = new ImageVideoCozeService( + new ImageVideoProperties(), + mock(ImageVideoSecretService.class), + mock(ImageVideoWorkflowConfigService.class), + mock(OssStorageService.class), + new ObjectMapper()); + Map cozeResult = Map.of("data", List.of(Map.of( + "execute_status", "Success", + "output", "{\"node_status\":\"{}\",\"Output\":\"{\\\"data\\\":{\\\"source\\\":\\\"这条裙裤绝了,遮肉显腿长,快拍!\\\",\\\"video_url\\\":\\\"https://example.com/video.mp4\\\"}}\"}" + ))); + + DouyinCopyVo result = cozeService.parseDouyinCopyResult(cozeResult); + + assertEquals("这条裙裤绝了,遮肉显腿长,快拍!", result.getRecognizedContent()); + assertEquals("这条裙裤绝了,遮肉显腿长,快拍!", result.getScriptDraft()); + } + + @Test + void pollWaitingTaskCompletesWhenCozeStatusIsInsideDataArray() { + ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class); + ImageVideoCozeService cozeService = mock(ImageVideoCozeService.class); + ImageVideoWorkflowConfigService workflowConfigService = mock(ImageVideoWorkflowConfigService.class); + ObjectMapper objectMapper = new ObjectMapper(); + TaskExecutor directExecutor = Runnable::run; + ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService( + taskMapper, cozeService, workflowConfigService, objectMapper, directExecutor); + + ImageVideoAsyncTaskEntity task = waitingDouyinTask(); + Map cozeResult = Map.of("data", List.of(Map.of( + "execute_status", "Success", + "output", "{\"Output\":\"{\\\"data\\\":{\\\"source\\\":\\\"这条裙裤绝了,遮肉显腿长,快拍!\\\"}}\"}" + ))); + DouyinCopyVo copyResult = new DouyinCopyVo(); + copyResult.setRecognizedContent("这条裙裤绝了,遮肉显腿长,快拍!"); + copyResult.setScriptDraft("这条裙裤绝了,遮肉显腿长,快拍!"); + + when(taskMapper.selectList(any())).thenReturn(List.of(task)); + when(taskMapper.claimWaiting(79L)).thenReturn(1); + when(taskMapper.selectById(79L)).thenReturn(task); + when(workflowConfigService.douyinCopyWorkflowId()).thenReturn("7652941112982798388"); + when(cozeService.getWorkflowResult(1L, "7652941112982798388", "7662364426301964329")) + .thenReturn(cozeResult); + when(cozeService.parseDouyinCopyResult(cozeResult)).thenReturn(copyResult); + + service.pollWaitingTasks(); + + assertEquals("SUCCESS", task.getStatus()); + assertEquals("SUCCESS", task.getCozeStatus()); + assertNotNull(task.getCompletedAt()); + assertTrue(task.getResultJson().contains("这条裙裤绝了,遮肉显腿长,快拍!")); + verify(cozeService).parseDouyinCopyResult(cozeResult); + verify(taskMapper).updateById(task); + } + + @Test + void pollWaitingTaskRemainsWaitingWhenCozeStatusIsRunningInsideDataArray() { + ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class); + ImageVideoCozeService cozeService = mock(ImageVideoCozeService.class); + ImageVideoWorkflowConfigService workflowConfigService = mock(ImageVideoWorkflowConfigService.class); + ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService( + taskMapper, cozeService, workflowConfigService, new ObjectMapper(), Runnable::run); + + ImageVideoAsyncTaskEntity task = waitingDouyinTask(); + Map cozeResult = Map.of("data", List.of(Map.of("execute_status", "Running"))); + + when(taskMapper.selectList(any())).thenReturn(List.of(task)); + when(taskMapper.claimWaiting(79L)).thenReturn(1); + when(taskMapper.selectById(79L)).thenReturn(task); + when(workflowConfigService.douyinCopyWorkflowId()).thenReturn("7652941112982798388"); + when(cozeService.getWorkflowResult(1L, "7652941112982798388", "7662364426301964329")) + .thenReturn(cozeResult); + + service.pollWaitingTasks(); + + assertEquals("WAITING", task.getStatus()); + assertEquals("RUNNING", task.getCozeStatus()); + verify(cozeService, never()).parseDouyinCopyResult(any()); + verify(taskMapper).updateById(task); + } + + private ImageVideoAsyncTaskEntity waitingDouyinTask() { + ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity(); + task.setId(79L); + task.setUserId(1L); + task.setTaskType("DOUYIN_COPY"); + task.setStatus("WAITING"); + task.setCozeExecuteId("7662364426301964329"); + task.setSubmittedAt(LocalDateTime.now()); + task.setCreatedAt(LocalDateTime.now()); + task.setUpdatedAt(LocalDateTime.now()); + return task; + } +} diff --git a/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue b/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue index c819e96..66e9dea 100644 --- a/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue @@ -108,7 +108,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' 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 { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules' +import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchCreateTaskItem, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules' import { getPywebviewApi } from '@/shared/bridges/pywebview' import { getTaskPollIntervalMs } from '@/shared/task-progress-config' import { createCategorizedTimers } from '@/shared/utils/categorized-timers' @@ -303,9 +303,47 @@ function sanitizeSchedulePickerValues(values: string[], ensureOne: boolean) { if (!deduped.length && ensureOne) return [getDefaultScheduleValue()] return deduped } -function readSkipAsinsByCountry(item: ShopMatchHistoryItem) { return item.skipAsinsByCountry || item.skip_asins_by_country || {} } -function readSkipAsinDetailsByCountry(item: ShopMatchHistoryItem) { return item.skipAsinDetailsByCountry || item.skip_asin_details_by_country || {} } -function buildQueuePayload(taskId: number, item: Pick, countryCodes: string[], stageIndex?: number, finalStage = true) { const skipAsinsByCountry = readSkipAsinsByCountry(item as ShopMatchHistoryItem); const skipAsinDetailsByCountry = readSkipAsinDetailsByCountry(item as ShopMatchHistoryItem); 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, skip_asins_by_country: skipAsinsByCountry, skipAsinsByCountry, skip_asin_details_by_country: skipAsinDetailsByCountry, skipAsinDetailsByCountry }], country_codes: [...countryCodes], skip_asins_by_country: skipAsinsByCountry, skipAsinsByCountry, skip_asin_details_by_country: skipAsinDetailsByCountry, skipAsinDetailsByCountry, risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } } +function getJavaApiBaseUrl() { return `${window.location.origin}/newApi/api` } +function buildSkipAsinsPageUrl(taskId: number) { return `${getJavaApiBaseUrl()}/shop-match/tasks/${taskId}/skip-asins/paginated` } +function buildTaskCreateItem(item: ShopMatchShopQueueItem): ShopMatchCreateTaskItem { + return { + shopName: item.shopName, + matched: item.matched, + shopId: item.shopId, + platform: item.platform, + companyName: item.companyName, + openStoreUrl: item.openStoreUrl, + matchedUserId: item.matchedUserId, + matchStatus: item.matchStatus, + matchMessage: item.matchMessage, + } +} +function buildQueuePayload(taskId: number, item: Pick, countryCodes: string[], stageIndex?: number, finalStage = true) { + const skipAsinsPageUrl = buildSkipAsinsPageUrl(taskId) + 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], + skip_asins_page_url: skipAsinsPageUrl, + skip_asins_page_size: 1000, + 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 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 scheduledTimestamp(value?: string) { const date = parseScheduleDateTime(value); return date ? date.getTime() : Number.NaN } @@ -459,7 +497,7 @@ function formatMatchStatus(status?: string) { const value = (status || '').trim( 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() { autoQueueEnabled.value = true; await processMatchedQueue() } function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.matched) } -async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; 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 } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { 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) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; 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) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `第 ${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount} 条` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount} 条`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } } +async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; 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 } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(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) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; 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) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `第 ${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount} 条` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount} 条`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } } onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }) onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); clearSleepTimers(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); for (const timer of dispatchTimers.values()) timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.clear(); timers.clearScope() }) diff --git a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue index f3afa2f..1712ebd 100644 --- a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue +++ b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue @@ -124,7 +124,7 @@
- 整体风格 / 视频提示词 + 视频风格/动作引导
@@ -138,8 +138,8 @@
- 产品名称 - + 产品名称 * +
核心卖点 / 产品属性 @@ -198,8 +198,8 @@
- 产品名称 - + 产品名称 * +
核心卖点 / 产品属性 @@ -398,7 +398,8 @@ -
当前:{{ secretStatus.t8VideoKeyMasked }}
+
当前:{{ secretStatus.t8VideoKeyMasked }} +
@@ -408,7 +409,7 @@
当前:{{ secretStatus.voiceGroupIdMasked - }}
+ }}
@@ -1016,6 +1017,11 @@ async function rewriteScriptFromSource() { ElMessage.warning('请粘贴视频链接或文案来源链接,不要粘贴接口返回内容') return } + const productName = currentWorkspace.value.productName.trim() + if (!productName) { + ElMessage.warning('请输入产品名称') + return + } if (activeTab.value === 'remake') currentWorkspace.value.scriptSourceUrl = url copyLearningLoading.value = true try { @@ -1024,7 +1030,7 @@ async function rewriteScriptFromSource() { duration: Math.max(0, Math.round(Number(currentWorkspace.value.durationSeconds) || 0)), proc_info: { type: currentWorkspace.value.categoryName.trim(), - name: currentWorkspace.value.productName.trim(), + name: productName, proc_image: resolveProductImageUrls(currentWorkspace.value), properties: currentWorkspace.value.productFocus.trim(), }, diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index 2aef90f..a71dd1a 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -833,7 +833,45 @@ export type ShopMatchHistoryItem = ProductRiskHistoryItem; export type ShopMatchHistoryVo = ProductRiskHistoryVo; export type ShopMatchTaskDetailVo = ProductRiskTaskDetailVo; export type ShopMatchTaskBatchVo = ProductRiskTaskBatchVo; -export type ShopMatchCreateTaskVo = ProductRiskCreateTaskVo; + +export interface ShopMatchCreateTaskItem { + shopName: string; + matched: boolean; + shopId?: string; + platform?: string; + companyName?: string; + openStoreUrl?: string; + matchedUserId?: number; + matchStatus?: string; + matchMessage?: string; +} + +export interface ShopMatchCreateTaskResultItem { + resultId?: number; + taskId?: number; + shopName?: string; + shopId?: string; + platform?: string; + companyName?: string; + matched?: boolean; + matchStatus?: string; + matchMessage?: string; + taskStatus?: string; + success?: boolean; + error?: string; + outputFilename?: string; + downloadUrl?: string; + fileJobId?: number; + fileStatus?: string; + fileError?: string; + fileReady?: boolean; + scheduledAt?: string; +} + +export interface ShopMatchCreateTaskVo { + taskId: number; + items: ShopMatchCreateTaskResultItem[]; +} export interface ShopMatchTaskStage { stageIndex?: number; @@ -1020,14 +1058,14 @@ export function deleteShopMatchHistory(resultId: number) { } export function createShopMatchTask( - items: ShopMatchShopQueueItem[], + items: ShopMatchCreateTaskItem[], countryCodes: string[], scheduleTimes?: string[], ) { return unwrapJavaResponse( post< JavaApiResponse, - { user_id: number; items: ShopMatchShopQueueItem[]; country_codes: string[]; schedule_times?: string[] } + { user_id: number; items: ShopMatchCreateTaskItem[]; country_codes: string[]; schedule_times?: string[] } >(`${JAVA_API_PREFIX}/shop-match/tasks`, { user_id: getCurrentUserId(), items, @@ -1076,6 +1114,30 @@ export function getShopMatchResultDownloadUrl(resultId: number) { return getJavaDownloadUrl(`/shop-match/results/${resultId}/download`); } +export function getShopMatchTaskSkipAsinsPaginated( + taskId: number, + page: number = 1, + pageSize: number = 1000, + options: { + shopName?: string | string[]; + countryCode?: string | string[]; + } = {}, +) { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/shop-match/tasks/${taskId}/skip-asins/paginated`, + { + params: { + page, + page_size: pageSize, + ...(options.shopName ? { shop_name: options.shopName } : {}), + ...(options.countryCode ? { country_code: options.countryCode } : {}), + }, + }, + ), + ); +} + export type PatrolDeleteCandidateVo = ProductRiskCandidateVo; export type PatrolDeleteDashboardVo = ProductRiskDashboardVo; export type PatrolDeleteShopQueueItem = ProductRiskShopQueueItem; @@ -2474,6 +2536,16 @@ export interface SkipPriceAsinPageVo { minimum_price_by_country_and_asin?: Record>; } +export interface ShopMatchSkipAsinPageVo { + page: number; + pageSize: number; + total: number; + totalPages: number; + queryAsins: QueryAsinCountryAsins[]; + skipAsinsByCountry: Record; + skipAsinDetailsByCountry: Record>; +} + export function deletePendingPriceTrackShopResult(shopName: string) { return unwrapJavaResponse( del>(