修复后端BUG

This commit is contained in:
super
2026-07-14 22:11:19 +08:00
parent 8a9126e626
commit 8543dad514
11 changed files with 813 additions and 58 deletions

View File

@@ -416,6 +416,15 @@ public class ImageVideoAsyncTaskService {
private String findDirectText(Object value, Set<String> 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 "";
}

View File

@@ -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<ProductRiskCreateTaskVo> createTask(@Valid @RequestBody ShopMatchCreateTaskRequest request) {
public ApiResponse<ShopMatchCreateTaskVo> 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<ShopMatchSkipAsinPageVo> 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<String> shopNames,
@Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔", example = "DE")
@RequestParam(value = "country_code", required = false) List<String> countryCodes) {
return ApiResponse.success(shopMatchTaskService.getTaskSkipAsinsPaginated(
taskId,
page,
pageSize,
shopNames,
countryCodes));
}
@PostMapping("/tasks/{taskId}/stage-finished")
@Operation(summary = "标记阶段完成", description = "某个定时阶段完成后推进到下一阶段,或等待最终收尾。")
public ApiResponse<Void> completeStage(

View File

@@ -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<ProductRiskShopQueueItemVo> items = new ArrayList<>();
private List<ShopMatchTaskItemDto> items = new ArrayList<>();
@NotEmpty(message = "country_codes cannot be empty")
@JsonProperty("country_codes")

View File

@@ -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;
}

View File

@@ -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<Item> 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;
}
}

View File

@@ -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<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
@Schema(description = "原创建任务 items.skipAsinsByCountry 字段迁移出的数据,按国家分组的跳过 ASIN 列表")
private Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
@Schema(description = "原创建任务 items.skipAsinDetailsByCountry 字段迁移出的数据,按国家分组的跳过 ASIN 明细")
private Map<String, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
}

View File

@@ -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<Long, FileTaskEntity> 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<String> requestedShopNames, List<String> 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<String> countryCodes = resolveSkipAsinCountryCodes(request, requestedCountryCodes);
if (countryCodes.isEmpty()) {
return emptySkipAsinPage(List.of(), page, pageSize);
}
List<String> 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<ProductRiskShopQueueItemVo> uniqueItems = dedupeQueueItems(request.getItems());
List<ProductRiskShopQueueItemVo> uniqueItems = dedupeQueueItems(toQueueItems(request.getItems()));
if (uniqueItems.isEmpty()) {
throw new BusinessException("items 不能为空");
}
@@ -384,13 +415,12 @@ public class ShopMatchTaskService {
}
List<String> countryCodes = normalizeCountryCodes(request.getCountryCodes());
enrichItemsWithSkipAsins(uniqueItems, countryCodes);
List<LocalDateTime> 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<ProductRiskShopQueueItemVo> toQueueItems(List<ShopMatchTaskItemDto> items) {
List<ProductRiskShopQueueItemVo> 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<ShopMatchTaskItemDto> toTaskItems(List<ProductRiskShopQueueItemVo> items) {
List<ShopMatchTaskItemDto> 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<ProductRiskShopQueueItemVo> dedupeQueueItems(List<ProductRiskShopQueueItemVo> raw) {
Map<String, ProductRiskShopQueueItemVo> byNorm = new LinkedHashMap<>();
for (ProductRiskShopQueueItemVo item : raw) {
@@ -1172,34 +1272,269 @@ public class ShopMatchTaskService {
return new ArrayList<>(byNorm.values());
}
private void enrichItemsWithSkipAsins(List<ProductRiskShopQueueItemVo> items, List<String> countryCodes) {
List<String> shopNames = items.stream()
.map(ProductRiskShopQueueItemVo::getShopName)
.filter(name -> name != null && !name.isBlank())
.distinct()
.toList();
Map<String, Map<String, List<SkipPriceAsinDetailDto>>> detailsByShop =
skipPriceAsinService.listSkipAsinDetailsByShopAndCountries(shopNames, countryCodes);
for (ProductRiskShopQueueItemVo item : items) {
Map<String, List<SkipPriceAsinDetailDto>> detailMap =
detailsByShop.getOrDefault(item.getShopName(), Map.of());
Map<String, List<SkipPriceAsinDetailDto>> normalizedDetails = new LinkedHashMap<>();
Map<String, List<String>> asinsByCountry = new LinkedHashMap<>();
for (String countryCode : countryCodes) {
List<SkipPriceAsinDetailDto> 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<String> resolveSkipAsinCountryCodes(ShopMatchCreateTaskRequest request, List<String> requestedCountryCodes) {
List<String> taskCountryCodes = normalizeTaskCountryCodes(request == null ? null : request.getCountryCodes());
LinkedHashSet<String> requested = new LinkedHashSet<>();
for (String country : splitQueryValues(requestedCountryCodes)) {
requested.add(normalizeLookupCountry(country));
}
if (requested.isEmpty()) {
return taskCountryCodes;
}
List<String> result = new ArrayList<>();
for (String country : requested) {
if (isTaskCountryEnabled(taskCountryCodes, country)) {
result.add(country);
}
}
return result;
}
private List<String> normalizeTaskCountryCodes(List<String> countryCodes) {
LinkedHashSet<String> 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<String> resolveSkipAsinShopNames(ShopMatchCreateTaskRequest request, List<String> requestedShopNames) {
List<String> taskShopNames = resolveTaskShopNames(request);
List<String> requested = splitQueryValues(requestedShopNames);
LinkedHashSet<String> 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<String> resolveTaskShopNames(ShopMatchCreateTaskRequest request) {
List<String> 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<String> splitQueryValues(List<String> values) {
List<String> 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<String> 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<String> countryCodes, int page, int pageSize) {
if (page < 1) {
page = 1;
}
if (pageSize < 1 || pageSize > 2000) {
pageSize = 1000;
}
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
? List.of("DE", "UK", "FR", "IT", "ES")
: countryCodes;
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
Map<String, List<SkipPriceAsinDetailDto>> 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<String> shopNames, List<String> countryCodes, int page, int pageSize) {
if (page < 1) {
page = 1;
}
if (pageSize < 1 || pageSize > 2000) {
pageSize = 1000;
}
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
? List.of("DE", "UK", "FR", "IT", "ES")
: countryCodes;
QueryAsinPage result = new QueryAsinPage();
result.page = page;
result.pageSize = pageSize;
Map<String, List<String>> asinsByCountry = new LinkedHashMap<>();
for (String country : targetCountries) {
asinsByCountry.put(country, new ArrayList<>());
}
if (shopNames == null || shopNames.isEmpty()) {
result.queryAsins = toQueryAsinCountryList(asinsByCountry);
return result;
}
List<QueryAsinEntity> rows = queryAsinMapper.selectList(new LambdaQueryWrapper<QueryAsinEntity>()
.in(QueryAsinEntity::getShopName, shopNames)
.orderByAsc(QueryAsinEntity::getId));
List<QueryAsinItem> 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<QueryAsinCountryAsinsDto> toQueryAsinCountryList(Map<String, List<String>> asinsByCountry) {
List<QueryAsinCountryAsinsDto> out = new ArrayList<>();
for (Map.Entry<String, List<String>> 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<String, List<SkipPriceAsinDetailDto>> toSkipAsinDetailDtos(Map<String, List<Map<String, String>>> source) {
Map<String, List<SkipPriceAsinDetailDto>> out = new LinkedHashMap<>();
for (Map.Entry<String, List<Map<String, String>>> entry : source.entrySet()) {
List<SkipPriceAsinDetailDto> details = new ArrayList<>();
if (entry.getValue() != null) {
for (Map<String, String> 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<String, List<String>> skipAsinsByCountry = skipPage == null || skipPage.getSkipAsinsByCountry() == null
? new LinkedHashMap<>()
: skipPage.getSkipAsinsByCountry();
Map<String, List<SkipPriceAsinDetailDto>> 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<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
}
private static class QueryAsinItem {
private String country;
private String asin;
}
private List<String> normalizeCountryCodes(List<String> raw) {

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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;
}
}

View File

@@ -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<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage' | 'skipAsinsByCountry' | 'skip_asins_by_country' | 'skipAsinDetailsByCountry' | 'skip_asin_details_by_country'>, 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<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, 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() })
</script>

View File

@@ -124,7 +124,7 @@
</div>
</div>
<div class="delivery-field">
<span class="delivery-field__label">整体风格 / 视频提示词</span>
<span class="delivery-field__label">视频风格/动作引导</span>
<el-input v-model="currentWorkspace.videoPrompt" type="textarea" :rows="4" resize="none"
placeholder="例如视频画面保持高清运镜平稳整体风格干净明亮" />
</div>
@@ -138,8 +138,8 @@
<el-input v-model="currentWorkspace.categoryName" clearable placeholder="输入产品类目例如服饰鞋包 / 男鞋 / 运动鞋" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">产品名称</span>
<el-input v-model="currentWorkspace.productName" clearable placeholder="例如新款复古运动鞋" />
<span class="delivery-field__label">产品名称 *</span>
<el-input v-model="currentWorkspace.productName" clearable aria-required="true" placeholder="例如新款复古运动鞋" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">核心卖点 / 产品属性</span>
@@ -198,8 +198,8 @@
<el-input v-model="currentWorkspace.categoryName" clearable placeholder="输入产品类目例如服饰鞋包 / 男鞋 / 运动鞋" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">产品名称</span>
<el-input v-model="currentWorkspace.productName" clearable placeholder="例如新款复古运动鞋" />
<span class="delivery-field__label">产品名称 *</span>
<el-input v-model="currentWorkspace.productName" clearable aria-required="true" placeholder="例如新款复古运动鞋" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">核心卖点 / 产品属性</span>
@@ -398,7 +398,8 @@
</el-form-item>
<el-form-item label="T8密钥国内">
<el-input v-model="secretForm.t8VideoKey" type="password" show-password placeholder="不填写则保留当前有效值" />
<div v-if="secretStatus?.t8VideoKeyMasked" class="secret-form__hint">当前:{{ secretStatus.t8VideoKeyMasked }}</div>
<div v-if="secretStatus?.t8VideoKeyMasked" class="secret-form__hint">当前:{{ secretStatus.t8VideoKeyMasked }}
</div>
</el-form-item>
<el-form-item label="音频密钥">
<el-input v-model="secretForm.voiceApiKey" type="password" show-password placeholder="不填写则保留当前有效值" />
@@ -408,7 +409,7 @@
<el-form-item label="音频团队ID">
<el-input v-model="secretForm.voiceGroupId" type="password" show-password placeholder="不填写则保留当前有效值" />
<div v-if="secretStatus?.voiceGroupIdMasked" class="secret-form__hint">当前:{{ secretStatus.voiceGroupIdMasked
}}</div>
}}</div>
</el-form-item>
<el-form-item label="有效期">
<el-select v-model="secretForm.expireDays" class="delivery-select">
@@ -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(),
},

View File

@@ -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<ShopMatchCreateTaskVo>,
{ 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<JavaApiResponse<ShopMatchSkipAsinPageVo>>(
`${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<string, Record<string, string>>;
}
export interface ShopMatchSkipAsinPageVo {
page: number;
pageSize: number;
total: number;
totalPages: number;
queryAsins: QueryAsinCountryAsins[];
skipAsinsByCountry: Record<string, string[]>;
skipAsinDetailsByCountry: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
}
export function deletePendingPriceTrackShopResult(shopName: string) {
return unwrapJavaResponse(
del<JavaApiResponse<PriceTrackPendingDeleteVo>>(