修复后端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;
}
}