视频任务管理更新
This commit is contained in:
@@ -676,6 +676,7 @@ public class AppearancePatentCozeClient {
|
||||
row.setCountry(source.getCountry());
|
||||
row.setSku(source.getSku());
|
||||
row.setBrand(source.getBrand());
|
||||
row.setPrice(source.getPrice());
|
||||
row.setUrl(source.getUrl());
|
||||
row.setTitle(source.getTitle());
|
||||
row.setError(source.getError());
|
||||
|
||||
@@ -87,7 +87,7 @@ public class AppearancePatentController {
|
||||
public ApiResponse<AppearancePatentHistoryVo> history(
|
||||
@Parameter(description = "当前用户 ID,用于查询该用户自己的历史记录。", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Parameter(description = "history limit, default 50, max 100", example = "50")
|
||||
@Parameter(description = "历史记录条数,默认 50,最大 100", example = "50")
|
||||
@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) {
|
||||
return ApiResponse.success(service.history(userId, limit));
|
||||
}
|
||||
|
||||
@@ -33,10 +33,14 @@ public class AppearancePatentResultRowDto {
|
||||
@JsonAlias({"SKU", "sellerSku", "seller_sku", "merchantSku", "merchant_sku", "商品SKU", "商品 sku", "库存SKU"})
|
||||
private String sku;
|
||||
|
||||
@Schema(description = "Python collected product brand. Preferred over the source Excel brand when exporting.", example = "Nike")
|
||||
@Schema(description = "Python 采集到的商品品牌。导出时优先于源 Excel 中的品牌。", example = "Nike")
|
||||
@JsonAlias({"Brand", "品牌"})
|
||||
private String brand;
|
||||
|
||||
@Schema(description = "Python 采集并通过结果接口回传的商品价格。最终导出不再使用源 Excel 价格。", example = "12.99")
|
||||
@JsonAlias({"Price", "价格"})
|
||||
private String price;
|
||||
|
||||
@Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||
@JsonAlias({
|
||||
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
|
||||
@@ -58,7 +62,7 @@ public class AppearancePatentResultRowDto {
|
||||
private Boolean done;
|
||||
|
||||
@JsonAlias({"row_status", "rowStatus", "Status"})
|
||||
@Schema(description = "Coze row status", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 行处理状态", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String status;
|
||||
|
||||
@Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度(商标)”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@@ -75,15 +79,15 @@ public class AppearancePatentResultRowDto {
|
||||
private String conclusion;
|
||||
|
||||
@JsonAlias({"title_reason", "titleReason"})
|
||||
@Schema(description = "Coze title reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 返回的标题维度原因", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String titleReason;
|
||||
|
||||
@JsonAlias({"appearance_reason", "appearanceReason"})
|
||||
@Schema(description = "Coze appearance reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 返回的外观维度原因", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String appearanceReason;
|
||||
|
||||
@JsonAlias({"patent_reason", "patentReason", "patent reason"})
|
||||
@Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 返回的专利维度原因", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String patentReason;
|
||||
|
||||
@JsonAlias({"score", "Score", "评分"})
|
||||
|
||||
@@ -1036,7 +1036,12 @@ public class AppearancePatentTaskService {
|
||||
}
|
||||
Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk);
|
||||
for (AppearancePatentResultRowDto row : rows) {
|
||||
persistedRows.put(rowKey(row), row);
|
||||
String key = rowKey(row);
|
||||
AppearancePatentResultRowDto submittedRow = persistedRows.get(key);
|
||||
if (submittedRow != null) {
|
||||
row.setPrice(submittedRow.getPrice());
|
||||
}
|
||||
persistedRows.put(key, row);
|
||||
}
|
||||
String payloadJson = writeJson(new ArrayList<>(persistedRows.values()), "appearance patent chunk payload merge failed");
|
||||
String oldPayload = chunk.getPayloadJson();
|
||||
@@ -3328,7 +3333,7 @@ public class AppearancePatentTaskService {
|
||||
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), ""));
|
||||
row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "卖家名称", "卖家名", "卖家", "店铺名称", "店铺名", "seller name", "seller_name", "seller-name", "sellername", "store name", "shop name"));
|
||||
row.createCell(col++).setCellValue(resolveBrand(resultRow, parsedRow));
|
||||
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getPrice(), ""));
|
||||
row.createCell(col++).setCellValue(resolvePrice(resultRow));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getTitle(), "") : firstNonBlank(resultRow.getTitle(), parsedRow.getTitle()));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getUrl(), "") : firstNonBlank(resultRow.getUrl(), parsedRow.getUrl()));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getSku(), "") : firstNonBlank(resultRow.getSku(), parsedRow.getSku()));
|
||||
@@ -4190,6 +4195,10 @@ public class AppearancePatentTaskService {
|
||||
return "";
|
||||
}
|
||||
|
||||
static String resolvePrice(AppearancePatentResultRowDto resultRow) {
|
||||
return resultRow == null || resultRow.getPrice() == null ? "" : resultRow.getPrice().trim();
|
||||
}
|
||||
|
||||
private boolean hasInfringingPersistedConclusion(Long taskId) {
|
||||
if (taskId == null) {
|
||||
return false;
|
||||
|
||||
@@ -14,7 +14,7 @@ public class CollectDataSubmitRowDto {
|
||||
private String brand;
|
||||
|
||||
@JsonAlias({"ASIN", "asin"})
|
||||
@Schema(description = "ASIN", example = "B0XXXXXXX")
|
||||
@Schema(description = "商品 ASIN", example = "B0XXXXXXX")
|
||||
private String asin;
|
||||
|
||||
@JsonAlias({"价格", "price"})
|
||||
|
||||
@@ -149,7 +149,7 @@ public class DeleteBrandRunController {
|
||||
}
|
||||
|
||||
@GetMapping("/results/{resultId}/download")
|
||||
@Operation(summary = "Download one delete-brand result")
|
||||
@Operation(summary = "下载单个删除品牌结果")
|
||||
public void downloadResult(
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
|
||||
|
||||
@@ -100,7 +100,7 @@ public class ImageVideoController {
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "Query image video async task")
|
||||
@Operation(summary = "查询图生视频异步任务")
|
||||
public ApiResponse<ImageVideoAsyncTaskVo> getTask(
|
||||
@PathVariable Long taskId,
|
||||
@RequestParam("user_id") Long userId) {
|
||||
|
||||
@@ -35,6 +35,8 @@ public class ImageVideoArchiveService {
|
||||
|
||||
static final String TASK_TYPE = "IMAGE_VIDEO_WORKFLOW";
|
||||
private static final int BATCH_SIZE = 20;
|
||||
private static final int RETENTION_CLEANUP_BATCH_SIZE = 200;
|
||||
private static final int RETENTION_DAYS = 3;
|
||||
private static final Set<String> VIDEO_KEYS = Set.of(
|
||||
"videourl", "video", "outputvideourl", "resultvideourl"
|
||||
);
|
||||
@@ -103,6 +105,47 @@ public class ImageVideoArchiveService {
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.image-video.retention-cleanup-delay-ms:600000}")
|
||||
public void cleanupExpiredWorkflowTasks() {
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusDays(RETENTION_DAYS);
|
||||
List<ImageVideoAsyncTaskEntity> expiredTasks = taskMapper.selectList(
|
||||
new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
|
||||
.eq(ImageVideoAsyncTaskEntity::getTaskType, TASK_TYPE)
|
||||
.lt(ImageVideoAsyncTaskEntity::getSubmittedAt, cutoff)
|
||||
.orderByAsc(ImageVideoAsyncTaskEntity::getId)
|
||||
.last("LIMIT " + RETENTION_CLEANUP_BATCH_SIZE));
|
||||
int deleted = 0;
|
||||
for (ImageVideoAsyncTaskEntity task : expiredTasks) {
|
||||
if (!deleteArchivedObjects(task)) {
|
||||
continue;
|
||||
}
|
||||
deleted += taskMapper.deleteById(task.getId());
|
||||
}
|
||||
if (deleted > 0) {
|
||||
log.info("[image-video] expired workflow task cleanup deleted={} cutoff={}", deleted, cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean deleteArchivedObjects(ImageVideoAsyncTaskEntity task) {
|
||||
Set<String> objectKeys = new LinkedHashSet<>();
|
||||
for (Map<String, Object> archivedVideo : readArchivedVideos(task.getArchivedVideosJson())) {
|
||||
Object objectKey = archivedVideo.get("objectKey");
|
||||
if (objectKey != null && !String.valueOf(objectKey).isBlank()) {
|
||||
objectKeys.add(String.valueOf(objectKey));
|
||||
}
|
||||
}
|
||||
for (String objectKey : objectKeys) {
|
||||
try {
|
||||
ossStorageService.deleteObject(objectKey);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[image-video] expired task OSS cleanup failed taskId={} objectKey={} error={}",
|
||||
task.getId(), objectKey, ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void backfillDerivedFields(ImageVideoAsyncTaskEntity task) {
|
||||
if (task.getArchiveStatus() != null) {
|
||||
return;
|
||||
|
||||
@@ -32,6 +32,7 @@ public class ImageVideoAsyncTaskService {
|
||||
|
||||
private static final int DISPATCH_BATCH_SIZE = 20;
|
||||
private static final int POLL_BATCH_SIZE = 50;
|
||||
private static final int FAILED_TASK_RETENTION_MINUTES = 10;
|
||||
private static final Set<String> TERMINAL_STATUSES = Set.of(
|
||||
"SUCCESS", "SUCCEEDED", "COMPLETED", "DONE", "FINISHED",
|
||||
"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED"
|
||||
@@ -102,7 +103,11 @@ public class ImageVideoAsyncTaskService {
|
||||
throw new BusinessException("Image video task not found");
|
||||
}
|
||||
task = recoverFalseFailedTask(task);
|
||||
return toVo(task);
|
||||
ImageVideoAsyncTaskVo result = toVo(task);
|
||||
if (TaskStatus.FAILED.name().equals(task.getStatus())) {
|
||||
taskMapper.deleteById(task.getId());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.image-video.async-task-dispatch-delay-ms:1000}")
|
||||
@@ -123,6 +128,17 @@ public class ImageVideoAsyncTaskService {
|
||||
tasks.forEach(task -> cozeTaskExecutor.execute(() -> pollTask(task.getId())));
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.image-video.failed-task-cleanup-delay-ms:60000}")
|
||||
public void cleanupFailedTasks() {
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(FAILED_TASK_RETENTION_MINUTES);
|
||||
int deleted = taskMapper.delete(new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
|
||||
.eq(ImageVideoAsyncTaskEntity::getStatus, TaskStatus.FAILED.name())
|
||||
.lt(ImageVideoAsyncTaskEntity::getUpdatedAt, cutoff));
|
||||
if (deleted > 0) {
|
||||
log.info("[image-video] removed expired failed tasks count={}", deleted);
|
||||
}
|
||||
}
|
||||
|
||||
private ImageVideoAsyncTaskVo submit(TaskType type, Long userId, Object payload) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("userId is required");
|
||||
|
||||
@@ -156,7 +156,7 @@ public class PatrolDeleteController {
|
||||
public ApiResponse<PatrolDeleteHistoryVo> history(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Parameter(description = "history limit, default 30, max 100", example = "30")
|
||||
@Parameter(description = "历史记录条数,默认 30,最大 100", example = "30")
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
return ApiResponse.success(patrolDeleteTaskService.listHistory(userId, limit));
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public class PermissionMenuService {
|
||||
|
||||
public static final String MENU_TYPE_APP = "app";
|
||||
public static final String MENU_TYPE_ADMIN = "admin";
|
||||
private static final String IMAGE_VIDEO_DATA_PERMISSION_KEY = "admin_image_video_task_data";
|
||||
|
||||
private final PermissionMenuMapper permissionMenuMapper;
|
||||
private final UserColumnPermissionMapper userColumnPermissionMapper;
|
||||
@@ -166,6 +167,22 @@ public class PermissionMenuService {
|
||||
}
|
||||
}
|
||||
|
||||
PermissionMenuEntity imageVideoDataPermission = permissionMenuMapper.selectOne(
|
||||
new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||
.eq(PermissionMenuEntity::getColumnKey, IMAGE_VIDEO_DATA_PERMISSION_KEY)
|
||||
.last("LIMIT 1"));
|
||||
if (imageVideoDataPermission != null) {
|
||||
requestedIds = new ArrayList<>(requestedIds);
|
||||
Long existingCount = userColumnPermissionMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getUserId, userId)
|
||||
.eq(UserColumnPermissionEntity::getColumnId, imageVideoDataPermission.getId()));
|
||||
requestedIds.remove(imageVideoDataPermission.getId());
|
||||
if (existingCount != null && existingCount > 0) {
|
||||
requestedIds.add(imageVideoDataPermission.getId());
|
||||
}
|
||||
}
|
||||
|
||||
userColumnPermissionMapper.delete(new LambdaUpdateWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getUserId, userId));
|
||||
for (Long columnId : requestedIds) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackDispatchFailedRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunChildFinishedRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunCreateRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest;
|
||||
@@ -145,7 +146,7 @@ public class PriceTrackController {
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(description = "国家代码", required = true, example = "DE")
|
||||
@RequestParam("country") String country,
|
||||
@Parameter(description = "ASIN", required = true, example = "B0XXXX")
|
||||
@Parameter(description = "商品 ASIN", required = true, example = "B0XXXX")
|
||||
@RequestParam("asin") String asin) {
|
||||
return ApiResponse.success(priceTrackTaskService.checkTaskSkipAsin(taskId, country, asin));
|
||||
}
|
||||
@@ -182,6 +183,16 @@ public class PriceTrackController {
|
||||
return ApiResponse.success(priceTrackTaskService.createTask(request));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/dispatch-failed")
|
||||
@Operation(summary = "标记 Python 入队失败", description = "前端创建任务后若 Python 队列拒绝接收,立即将任务及占位店铺标记为失败。")
|
||||
public ApiResponse<Void> markDispatchFailed(
|
||||
@PathVariable Long taskId,
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Valid @RequestBody PriceTrackDispatchFailedRequest request) {
|
||||
priceTrackTaskService.markDispatchFailed(taskId, userId, request.getErrorMessage());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/loop-runs")
|
||||
@Operation(summary = "创建跟价循环任务", description = "创建整批店铺的多轮循环主任务。")
|
||||
public ApiResponse<PriceTrackLoopRunVo> createLoopRun(@Valid @RequestBody PriceTrackLoopRunCreateRequest request) {
|
||||
|
||||
@@ -33,12 +33,12 @@ public class PriceTrackCreateTaskRequest {
|
||||
@NotEmpty(message = "国家列表不能为空")
|
||||
@Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
|
||||
private List<String> countryCodes;
|
||||
@Schema(description = "optional loop run id", example = "1")
|
||||
@Schema(description = "可选的循环运行 ID", example = "1")
|
||||
private Long loopRunId;
|
||||
|
||||
@Schema(description = "optional round index, 1-based", example = "2")
|
||||
@Schema(description = "可选的轮次序号,从 1 开始", example = "2")
|
||||
private Integer roundIndex;
|
||||
|
||||
@Schema(description = "optional shop index, 0-based", example = "0")
|
||||
@Schema(description = "可选的店铺序号,从 0 开始", example = "0")
|
||||
private Integer shopIndex;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "跟价任务推送 Python 队列失败请求")
|
||||
public class PriceTrackDispatchFailedRequest {
|
||||
|
||||
@NotBlank(message = "errorMessage不能为空")
|
||||
@Schema(description = "Python 队列拒绝或调用失败的原始原因", example = "当前店铺正在执行中")
|
||||
private String errorMessage;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class PriceTrackLoopRunCreateRequest {
|
||||
private List<String> countryCodes = new ArrayList<>();
|
||||
|
||||
@NotBlank(message = "executionMode不能为空")
|
||||
@Schema(description = "FINITE or INFINITE", example = "FINITE")
|
||||
@Schema(description = "循环模式:FINITE 表示有限次数,INFINITE 表示无限循环", example = "FINITE")
|
||||
private String executionMode;
|
||||
|
||||
@Schema(description = "固定轮次时必填", example = "3")
|
||||
|
||||
@@ -8,20 +8,20 @@ import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Price track match shops request")
|
||||
@Schema(description = "跟价店铺匹配请求")
|
||||
public class PriceTrackMatchShopsRequest {
|
||||
|
||||
@NotNull(message = "userId is required")
|
||||
@Schema(description = "Current user id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@Schema(description = "当前用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@NotEmpty(message = "shopNames cannot be empty")
|
||||
@Schema(description = "Shop names to match", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(description = "需要匹配的店铺名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<String> shopNames;
|
||||
|
||||
@Schema(description = "Selected ASIN file local paths")
|
||||
@Schema(description = "已选择的 ASIN 文件本地路径")
|
||||
private List<String> asinFiles;
|
||||
|
||||
@Schema(description = "Selected country codes")
|
||||
@Schema(description = "已选择的国家代码")
|
||||
private List<String> countryCodes;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public class PriceTrackSubmitResultRequest {
|
||||
@Schema(description = "店铺商城名称", example = "Amazon.de")
|
||||
private String shopMallName;
|
||||
|
||||
@Schema(description = "ASIN", example = "B0ABCDE123")
|
||||
@Schema(description = "商品 ASIN", example = "B0ABCDE123")
|
||||
private String asin;
|
||||
|
||||
@Schema(description = "价格", example = "19.99")
|
||||
|
||||
@@ -7,23 +7,23 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "price track create task response")
|
||||
@Schema(description = "跟价任务创建响应")
|
||||
public class PriceTrackCreateTaskVo {
|
||||
@Schema(description = "task id", example = "200")
|
||||
@Schema(description = "任务 ID", example = "200")
|
||||
private Long taskId;
|
||||
|
||||
@Schema(description = "initial task items")
|
||||
@Schema(description = "初始任务项")
|
||||
private List<PriceTrackResultItemVo> items;
|
||||
|
||||
@Schema(description = "all skip asins grouped by country")
|
||||
@Schema(description = "按国家分组的全部跳过跟价 ASIN")
|
||||
private Map<String, List<String>> skipAsinsByCountry;
|
||||
|
||||
@Schema(description = "all skip asin details grouped by country code, each item includes asin and minimumPrice")
|
||||
@Schema(description = "按国家代码分组的全部跳过跟价 ASIN 明细,每项包含 asin 和 minimumPrice")
|
||||
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry;
|
||||
|
||||
@Schema(description = "parsed asin rows by country")
|
||||
@Schema(description = "按国家分组的已解析 ASIN 行")
|
||||
private Map<String, List<Map<String, String>>> asinRowsByCountry;
|
||||
|
||||
@Schema(description = "minimum price grouped by country code and asin")
|
||||
@Schema(description = "按国家代码和 ASIN 分组的最低价格")
|
||||
private Map<String, Map<String, String>> minimumPriceByCountryAndAsin;
|
||||
}
|
||||
|
||||
@@ -8,53 +8,53 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Price track shop match response")
|
||||
@Schema(description = "跟价店铺匹配响应")
|
||||
public class PriceTrackMatchShopsVo {
|
||||
|
||||
@Schema(description = "Match results in the same order as requested shop names")
|
||||
@Schema(description = "匹配结果,顺序与请求中的店铺名称一致")
|
||||
private List<PriceTrackShopQueueItem> items = new ArrayList<>();
|
||||
|
||||
@Schema(description = "All skip ASINs grouped by country code")
|
||||
@Schema(description = "按国家代码分组的全部跳过跟价 ASIN")
|
||||
private Map<String, List<String>> skipAsinsByCountry;
|
||||
|
||||
@Schema(description = "All skip ASIN details grouped by country code, each item includes asin and minimumPrice")
|
||||
@Schema(description = "按国家代码分组的全部跳过跟价 ASIN 明细,每项包含 asin 和 minimumPrice")
|
||||
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry;
|
||||
|
||||
@Schema(description = "Parsed asin rows grouped by country code")
|
||||
@Schema(description = "按国家代码分组的已解析 ASIN 行")
|
||||
private Map<String, List<Map<String, String>>> asinRowsByCountry;
|
||||
|
||||
@Schema(description = "Minimum price grouped by country code and asin")
|
||||
@Schema(description = "按国家代码和 ASIN 分组的最低价格")
|
||||
private Map<String, Map<String, String>> minimumPriceByCountryAndAsin;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Single matched shop item")
|
||||
@Schema(description = "单个店铺匹配结果")
|
||||
public static class PriceTrackShopQueueItem {
|
||||
|
||||
@Schema(description = "Shop name", example = "Demo Shop")
|
||||
@Schema(description = "店铺名称", example = "Demo Shop")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "Shop mall name", example = "Amazon.de")
|
||||
@Schema(description = "店铺商城名称", example = "Amazon.de")
|
||||
private String shopMallName;
|
||||
|
||||
@Schema(description = "Matched shop id")
|
||||
@Schema(description = "匹配到的店铺 ID")
|
||||
private Object shopId;
|
||||
|
||||
@Schema(description = "Platform", example = "Amazon")
|
||||
@Schema(description = "平台", example = "Amazon")
|
||||
private String platform;
|
||||
|
||||
@Schema(description = "Company name")
|
||||
@Schema(description = "公司名称")
|
||||
private String companyName;
|
||||
|
||||
@Schema(description = "Whether the shop matched")
|
||||
@Schema(description = "店铺是否匹配成功")
|
||||
private Boolean matched;
|
||||
|
||||
@Schema(description = "Match status, such as MATCHED or PENDING")
|
||||
@Schema(description = "匹配状态,例如 MATCHED 或 PENDING")
|
||||
private String matchStatus;
|
||||
|
||||
@Schema(description = "Readable match message")
|
||||
@Schema(description = "可读的匹配结果说明")
|
||||
private String matchMessage;
|
||||
|
||||
@Schema(description = "All skip ASINs grouped by country code")
|
||||
@Schema(description = "按国家代码分组的全部跳过跟价 ASIN")
|
||||
private Map<String, List<String>> skipAsins;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public class PriceTrackResultItemVo {
|
||||
@Schema(description = "店铺名称,详情页会尽量恢复创建任务时的展示名")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "Shop mall name")
|
||||
@Schema(description = "店铺商城名称")
|
||||
private String shopMallName;
|
||||
|
||||
@Schema(description = "店铺 ID(紫鸟)")
|
||||
|
||||
@@ -4,18 +4,18 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "single skip price asin lookup response")
|
||||
@Schema(description = "单个跳过跟价 ASIN 查询响应")
|
||||
public class SkipPriceAsinCheckVo {
|
||||
|
||||
@Schema(description = "country code", example = "DE")
|
||||
@Schema(description = "国家代码", example = "DE")
|
||||
private String country;
|
||||
|
||||
@Schema(description = "normalized asin", example = "B0XXXX")
|
||||
@Schema(description = "规范化后的 ASIN", example = "B0XXXX")
|
||||
private String asin;
|
||||
|
||||
@Schema(description = "whether the asin exists in skip price asin table")
|
||||
@Schema(description = "该 ASIN 是否存在于跳过跟价 ASIN 表中")
|
||||
private boolean exists;
|
||||
|
||||
@Schema(description = "configured minimum price, empty when not configured or not found")
|
||||
@Schema(description = "已配置的最低价格;未配置或未找到时为空")
|
||||
private String minimumPrice;
|
||||
}
|
||||
|
||||
@@ -277,6 +277,46 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markDispatchFailed(Long taskId, Long userId, String errorMessage) {
|
||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||
if (errorMessage == null || errorMessage.isBlank()) throw new BusinessException("errorMessage 不能为空");
|
||||
String normalizedError = errorMessage.trim();
|
||||
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||
return;
|
||||
}
|
||||
List<FileResultEntity> results = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
if (results.isEmpty()) {
|
||||
task.setStatus("FAILED");
|
||||
task.setErrorMessage(normalizedError);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
|
||||
priceTrackLoopRunService.syncLoopRunAfterChildTerminal(taskId);
|
||||
return;
|
||||
}
|
||||
for (FileResultEntity result : results) {
|
||||
boolean succeeded = result.getSuccess() != null && result.getSuccess() == 1;
|
||||
boolean failed = result.getErrorMessage() != null && !result.getErrorMessage().isBlank();
|
||||
if (!succeeded && !failed) {
|
||||
markResultFailed(result, normalizedError);
|
||||
}
|
||||
}
|
||||
updateTaskStatusFromLatestRows(task, results);
|
||||
log.warn("[price-track] Python dispatch failed taskId={} userId={} error={}", taskId, userId, normalizedError);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceTrackPendingDeleteVo deletePendingShopResult(Long userId, String shopName) {
|
||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||
|
||||
@@ -4,6 +4,9 @@ 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 = "单国 sheet 内一行数据,与商品风险处理 Excel 模板列一致")
|
||||
public class ProductRiskRowDto {
|
||||
@@ -30,4 +33,19 @@ public class ProductRiskRowDto {
|
||||
@JsonProperty("removeStatus")
|
||||
@Schema(description = "移除状态")
|
||||
private String removeStatus;
|
||||
|
||||
@JsonProperty("violationDetails")
|
||||
@Schema(description = "该 ASIN 的逐项违规处理明细")
|
||||
private List<ViolationDetail> violationDetails = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
@Schema(description = "单项违规处理结果")
|
||||
public static class ViolationDetail {
|
||||
|
||||
@Schema(description = "违规原因标题", example = "商品安全违规")
|
||||
private String reason;
|
||||
|
||||
@Schema(description = "处理状态", allowableValues = {"成功", "失败"})
|
||||
private String status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@ import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskRowDto;
|
||||
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.BorderStyle;
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -15,6 +20,7 @@ import java.io.FileOutputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -30,7 +36,10 @@ public class ProductRiskExcelAssemblyService {
|
||||
"状态",
|
||||
"完成",
|
||||
"移除ASIN",
|
||||
"移除状态"
|
||||
"移除状态",
|
||||
"ASIN",
|
||||
"原因",
|
||||
"状态"
|
||||
};
|
||||
|
||||
public void writeWorkbook(File outputXlsx, String shopDisplayName, Map<String, List<ProductRiskRowDto>> countries) {
|
||||
@@ -45,16 +54,38 @@ public class ProductRiskExcelAssemblyService {
|
||||
headerRow.createCell(c).setCellValue(HEADER[c]);
|
||||
}
|
||||
List<ProductRiskRowDto> rows = safe.getOrDefault(sheetName, List.of());
|
||||
CellStyle detailStyle = createDetailStyle(workbook);
|
||||
CellStyle detailAsinStyle = createDetailAsinStyle(workbook);
|
||||
int rowIdx = 1;
|
||||
for (ProductRiskRowDto dto : rows) {
|
||||
Row row = sheet.createRow(rowIdx++);
|
||||
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName));
|
||||
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku());
|
||||
createTextCell(row, 2, dto == null ? null : dto.getStatus());
|
||||
createTextCell(row, 3, completionDisplayValue(dto));
|
||||
createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin());
|
||||
createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus());
|
||||
List<ProductRiskRowDto.ViolationDetail> details = violationDetails(dto);
|
||||
int groupSize = Math.max(1, details.size());
|
||||
String asin = firstNonBlank(
|
||||
dto == null ? null : dto.getProductAsinSku(),
|
||||
dto == null ? null : dto.getRemoveAsin());
|
||||
for (int offset = 0; offset < groupSize; offset++) {
|
||||
Row row = sheet.createRow(rowIdx + offset);
|
||||
if (offset == 0) {
|
||||
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName));
|
||||
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku());
|
||||
createTextCell(row, 2, dto == null ? null : dto.getStatus());
|
||||
createTextCell(row, 3, completionDisplayValue(dto));
|
||||
createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin());
|
||||
createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus());
|
||||
}
|
||||
if (offset < details.size()) {
|
||||
ProductRiskRowDto.ViolationDetail detail = details.get(offset);
|
||||
createStyledTextCell(row, 6, asin, detailAsinStyle);
|
||||
createStyledTextCell(row, 7, detail.getReason(), detailStyle);
|
||||
createStyledTextCell(row, 8, detail.getStatus(), detailStyle);
|
||||
}
|
||||
}
|
||||
if (details.size() > 1) {
|
||||
sheet.addMergedRegion(new CellRangeAddress(rowIdx, rowIdx + details.size() - 1, 6, 6));
|
||||
}
|
||||
rowIdx += groupSize;
|
||||
}
|
||||
applyDetailHeaderStyle(headerRow, detailStyle);
|
||||
applyDefaultColumnWidths(sheet);
|
||||
}
|
||||
workbook.write(fos);
|
||||
@@ -104,6 +135,41 @@ public class ProductRiskExcelAssemblyService {
|
||||
cell.setCellValue(value == null ? "" : value);
|
||||
}
|
||||
|
||||
private static void createStyledTextCell(Row row, int index, String value, CellStyle style) {
|
||||
Cell cell = row.createCell(index);
|
||||
cell.setCellValue(value == null ? "" : value);
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
|
||||
private static CellStyle createDetailStyle(SXSSFWorkbook workbook) {
|
||||
CellStyle style = workbook.createCellStyle();
|
||||
style.setBorderTop(BorderStyle.THIN);
|
||||
style.setBorderBottom(BorderStyle.THIN);
|
||||
style.setBorderLeft(BorderStyle.THIN);
|
||||
style.setBorderRight(BorderStyle.THIN);
|
||||
style.setVerticalAlignment(VerticalAlignment.CENTER);
|
||||
return style;
|
||||
}
|
||||
|
||||
private static CellStyle createDetailAsinStyle(SXSSFWorkbook workbook) {
|
||||
CellStyle style = createDetailStyle(workbook);
|
||||
style.setAlignment(HorizontalAlignment.CENTER);
|
||||
return style;
|
||||
}
|
||||
|
||||
private static void applyDetailHeaderStyle(Row headerRow, CellStyle style) {
|
||||
for (int column = 6; column <= 8; column++) {
|
||||
headerRow.getCell(column).setCellStyle(style);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ProductRiskRowDto.ViolationDetail> violationDetails(ProductRiskRowDto row) {
|
||||
if (row == null || row.getViolationDetails() == null) {
|
||||
return List.of();
|
||||
}
|
||||
return row.getViolationDetails().stream().filter(Objects::nonNull).toList();
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String primary, String fallback) {
|
||||
if (primary != null && !primary.isBlank()) {
|
||||
return primary;
|
||||
@@ -150,7 +216,7 @@ public class ProductRiskExcelAssemblyService {
|
||||
}
|
||||
|
||||
private void applyDefaultColumnWidths(Sheet sheet) {
|
||||
int[] widths = {20, 22, 18, 10, 18, 18};
|
||||
int[] widths = {20, 22, 18, 10, 18, 18, 18, 24, 12};
|
||||
for (int i = 0; i < widths.length; i++) {
|
||||
sheet.setColumnWidth(i, widths[i] * 256);
|
||||
}
|
||||
|
||||
@@ -1245,6 +1245,8 @@ public class ProductRiskTaskService {
|
||||
merged.setDone(incoming.getDone() != null ? incoming.getDone() : merged.getDone());
|
||||
merged.setRemoveAsin(firstNonBlank(incoming.getRemoveAsin(), merged.getRemoveAsin()));
|
||||
merged.setRemoveStatus(firstNonBlank(incoming.getRemoveStatus(), merged.getRemoveStatus()));
|
||||
merged.setViolationDetails(mergeViolationDetails(
|
||||
merged.getViolationDetails(), incoming.getViolationDetails()));
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -1259,6 +1261,31 @@ public class ProductRiskTaskService {
|
||||
out.setDone(row.getDone());
|
||||
out.setRemoveAsin(row.getRemoveAsin());
|
||||
out.setRemoveStatus(row.getRemoveStatus());
|
||||
out.setViolationDetails(cloneViolationDetails(row.getViolationDetails()));
|
||||
return out;
|
||||
}
|
||||
|
||||
static List<ProductRiskRowDto.ViolationDetail> mergeViolationDetails(
|
||||
List<ProductRiskRowDto.ViolationDetail> existing,
|
||||
List<ProductRiskRowDto.ViolationDetail> incoming) {
|
||||
return cloneViolationDetails(incoming == null || incoming.isEmpty() ? existing : incoming);
|
||||
}
|
||||
|
||||
private static List<ProductRiskRowDto.ViolationDetail> cloneViolationDetails(
|
||||
List<ProductRiskRowDto.ViolationDetail> details) {
|
||||
if (details == null || details.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<ProductRiskRowDto.ViolationDetail> out = new ArrayList<>(details.size());
|
||||
for (ProductRiskRowDto.ViolationDetail detail : details) {
|
||||
if (detail == null) {
|
||||
continue;
|
||||
}
|
||||
ProductRiskRowDto.ViolationDetail copy = new ProductRiskRowDto.ViolationDetail();
|
||||
copy.setReason(detail.getReason());
|
||||
copy.setStatus(detail.getStatus());
|
||||
out.add(copy);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import lombok.Data;
|
||||
@Schema(description = "查询 ASIN 单条结果")
|
||||
public class QueryAsinAsinStatusDto {
|
||||
|
||||
@Schema(description = "ASIN", example = "B0BRZZR3N2")
|
||||
@Schema(description = "商品 ASIN", example = "B0BRZZR3N2")
|
||||
private String asin;
|
||||
|
||||
@Schema(description = "Python 查询后返回的状态", example = "正常")
|
||||
|
||||
@@ -48,7 +48,7 @@ public class QueryAsinController {
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
|
||||
@Parameter(description = "ASIN") @RequestParam(required = false) String asin,
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success(queryAsinService.page(
|
||||
@@ -66,7 +66,7 @@ public class QueryAsinController {
|
||||
public ResponseEntity<byte[]> export(
|
||||
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
|
||||
@Parameter(description = "ASIN") @RequestParam(required = false) String asin,
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
byte[] bytes = queryAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
|
||||
@@ -48,7 +48,7 @@ public class SkipPriceAsinController {
|
||||
@Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||
@Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName,
|
||||
@Parameter(description = "ASIN") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success(skipPriceAsinService.page(
|
||||
@@ -66,7 +66,7 @@ public class SkipPriceAsinController {
|
||||
public ResponseEntity<byte[]> export(
|
||||
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||
@Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName,
|
||||
@Parameter(description = "ASIN") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
|
||||
@@ -7,7 +7,7 @@ import lombok.Data;
|
||||
@Data
|
||||
public class QueryAsinCountryUpdateRequest {
|
||||
|
||||
@Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(description = "商品 ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "ASIN 不能为空")
|
||||
private String asin;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import java.math.BigDecimal;
|
||||
@Data
|
||||
public class SkipPriceAsinCountryUpdateRequest {
|
||||
|
||||
@Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(description = "商品 ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "ASIN 不能为空")
|
||||
private String asin;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import lombok.Data;
|
||||
@Schema(description = "跳过 ASIN 明细")
|
||||
public class SkipPriceAsinDetailDto {
|
||||
|
||||
@Schema(description = "ASIN")
|
||||
@Schema(description = "商品 ASIN")
|
||||
private String asin;
|
||||
|
||||
@JsonProperty("minimumPrice")
|
||||
|
||||
@@ -9,7 +9,7 @@ import lombok.Data;
|
||||
@Schema(description = "匹配店铺单行结果")
|
||||
public class ShopMatchRowDto {
|
||||
|
||||
@Schema(description = "ASIN")
|
||||
@Schema(description = "商品 ASIN")
|
||||
private String asin;
|
||||
|
||||
@JsonAlias("minimum_price")
|
||||
|
||||
@@ -109,7 +109,7 @@ public class SimilarAsinController {
|
||||
public ApiResponse<SimilarAsinHistoryVo> history(
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Parameter(description = "history limit, default 50, max 100", example = "50")
|
||||
@Parameter(description = "历史记录条数,默认 50,最大 100", example = "50")
|
||||
@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) {
|
||||
return ApiResponse.success(service.history(userId, limit));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public class SimilarAsinResultRowDto {
|
||||
private String price;
|
||||
|
||||
@JsonAlias({"alibaba", "alibaba_items", "alibabaItems", "alibaba_products", "alibabaProducts"})
|
||||
@Schema(description = "Alibaba candidates returned by Python, each item contains url and price")
|
||||
@Schema(description = "Python 返回的阿里巴巴候选商品,每项包含 URL 和价格")
|
||||
private List<AlibabaItem> alibaba = new ArrayList<>();
|
||||
|
||||
@Schema(description = "商品主图 URL。Python 端解析的代表图。允许传字符串或数组(数组取首个非空),与 urls 互不影响。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||
@@ -85,7 +85,7 @@ public class SimilarAsinResultRowDto {
|
||||
private Boolean done;
|
||||
|
||||
@JsonAlias({"row_status", "rowStatus", "Status"})
|
||||
@Schema(description = "Coze row status", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 行处理状态", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String status;
|
||||
|
||||
@Schema(description = "兼容旧版 Coze 返回中的标题维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@@ -102,15 +102,15 @@ public class SimilarAsinResultRowDto {
|
||||
private String conclusion;
|
||||
|
||||
@JsonAlias({"title_reason", "titleReason"})
|
||||
@Schema(description = "Coze title reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 返回的标题维度原因", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String titleReason;
|
||||
|
||||
@JsonAlias({"appearance_reason", "appearanceReason"})
|
||||
@Schema(description = "Coze appearance reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 返回的外观维度原因", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String appearanceReason;
|
||||
|
||||
@JsonAlias({"patent_reason", "patentReason", "patent reason"})
|
||||
@Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 返回的专利维度原因", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String patentReason;
|
||||
|
||||
@JsonAlias({"main_url", "mainUrl", "main_image_url", "mainImageUrl", "main_img", "mainImg", "主图URL", "主图链接"})
|
||||
@@ -270,15 +270,15 @@ public class SimilarAsinResultRowDto {
|
||||
}
|
||||
|
||||
@Data
|
||||
@Schema(description = "Alibaba candidate")
|
||||
@Schema(description = "阿里巴巴候选商品")
|
||||
public static class AlibabaItem {
|
||||
|
||||
@JsonAlias({"url", "image_url", "imageUrl", "img_url", "imgUrl", "link"})
|
||||
@Schema(description = "Alibaba image or product URL")
|
||||
@Schema(description = "阿里巴巴商品图片或商品 URL")
|
||||
private String url;
|
||||
|
||||
@JsonAlias({"price", "浠锋牸"})
|
||||
@Schema(description = "Alibaba candidate price")
|
||||
@Schema(description = "阿里巴巴候选商品价格")
|
||||
private Object price;
|
||||
|
||||
public String getUrl() {
|
||||
|
||||
@@ -18,17 +18,17 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/tasks")
|
||||
@Tag(name = "Task heartbeat", description = "Unified heartbeat API for Python task workers")
|
||||
@Tag(name = "任务心跳", description = "供 Python 任务执行器统一上报运行状态和进度的心跳接口")
|
||||
public class TaskHeartbeatController {
|
||||
|
||||
private final TaskHeartbeatService taskHeartbeatService;
|
||||
|
||||
@PostMapping("/{taskId}/heartbeat")
|
||||
@Operation(
|
||||
summary = "Task heartbeat",
|
||||
description = "Python calls this during execution. The backend resolves biz_file_task or brand task by taskId only.")
|
||||
summary = "上报任务心跳",
|
||||
description = "Python 在任务执行期间调用。后端仅根据 taskId 识别文件任务或品牌任务并更新心跳。")
|
||||
public ApiResponse<TaskHeartbeatVo> heartbeat(
|
||||
@Parameter(description = "Task ID", required = true, example = "200")
|
||||
@Parameter(description = "任务 ID", required = true, example = "200")
|
||||
@PathVariable Long taskId,
|
||||
@Valid @RequestBody(required = false) TaskHeartbeatRequest request) {
|
||||
return ApiResponse.success(taskHeartbeatService.heartbeat(taskId, request));
|
||||
|
||||
@@ -6,15 +6,15 @@ import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@Schema(description = "Task heartbeat request")
|
||||
@Schema(description = "任务心跳请求")
|
||||
public class TaskHeartbeatRequest {
|
||||
|
||||
@Schema(description = "Optional phase label", example = "crawling")
|
||||
@Schema(description = "可选的任务阶段标识", example = "crawling")
|
||||
private String phase;
|
||||
|
||||
@Schema(description = "Optional current progress", example = "42")
|
||||
@Schema(description = "可选的当前进度", example = "42")
|
||||
private Integer current;
|
||||
|
||||
@Schema(description = "Optional total progress", example = "1100")
|
||||
@Schema(description = "可选的总进度", example = "1100")
|
||||
private Integer total;
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Task heartbeat response")
|
||||
@Schema(description = "任务心跳响应")
|
||||
public class TaskHeartbeatVo {
|
||||
|
||||
@Schema(description = "Whether the task can continue", example = "true")
|
||||
@Schema(description = "任务是否可以继续执行", example = "true")
|
||||
private boolean alive;
|
||||
|
||||
@Schema(description = "Task status", example = "RUNNING")
|
||||
@Schema(description = "任务状态", example = "RUNNING")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "Resolved task module", example = "PRODUCT_RISK_RESOLVE")
|
||||
@Schema(description = "识别出的任务模块", example = "PRODUCT_RISK_RESOLVE")
|
||||
private String moduleType;
|
||||
|
||||
@Schema(description = "Message", example = "ok")
|
||||
@Schema(description = "提示信息", example = "ok")
|
||||
private String message;
|
||||
|
||||
public static TaskHeartbeatVo alive(String moduleType, String status) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`)
|
||||
SELECT '视频任务数据查看', 'admin_image_video_task_data', 'internal', 'image-video-task-data', 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_image_video_task_data'
|
||||
);
|
||||
|
||||
INSERT IGNORE INTO `user_column_permission` (`user_id`, `column_id`)
|
||||
SELECT old_perm.`user_id`, data_col.`id`
|
||||
FROM `user_column_permission` old_perm
|
||||
INNER JOIN `columns` old_col ON old_col.`id` = old_perm.`column_id`
|
||||
INNER JOIN `columns` data_col ON data_col.`column_key` = 'admin_image_video_task_data'
|
||||
LEFT JOIN `user_column_permission` existing
|
||||
ON existing.`user_id` = old_perm.`user_id`
|
||||
AND existing.`column_id` = data_col.`id`
|
||||
WHERE old_col.`column_key` = 'admin_image_video_tasks'
|
||||
AND existing.`user_id` IS NULL;
|
||||
@@ -149,4 +149,19 @@ class AppearancePatentCozeClientTest {
|
||||
assertThat(merged.get(0).getAppearanceRisk()).isNullOrEmpty();
|
||||
verify(brandCheckClient, never()).checkTitleText(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preserveSubmittedPriceWhenMergingCozeResult() throws Exception {
|
||||
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
||||
row.setId("1");
|
||||
row.setPrice("12.99");
|
||||
String dataText = """
|
||||
{"data":[{"row_id":"1","title":"无","appearance":"无侵权","result":"无侵权"}]}
|
||||
""";
|
||||
|
||||
List<AppearancePatentResultRowDto> merged = client.mergeRowsFromDataText(List.of(row), dataText);
|
||||
|
||||
assertThat(merged).hasSize(1);
|
||||
assertThat(merged.get(0).getPrice()).isEqualTo("12.99");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,4 +34,16 @@ class AppearancePatentTaskServiceTest {
|
||||
parsedRow.getValues().clear();
|
||||
assertEquals("", AppearancePatentTaskService.resolveBrand(resultRow, parsedRow));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resultPriceUsesOnlyPythonSubmittedValue() {
|
||||
assertEquals("", AppearancePatentTaskService.resolvePrice(null));
|
||||
|
||||
AppearancePatentResultRowDto resultRow = new AppearancePatentResultRowDto();
|
||||
resultRow.setPrice(" 12.99 ");
|
||||
assertEquals("12.99", AppearancePatentTaskService.resolvePrice(resultRow));
|
||||
|
||||
resultRow.setPrice(null);
|
||||
assertEquals("", AppearancePatentTaskService.resolvePrice(resultRow));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,53 @@ import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
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 ImageVideoArchiveServiceTest {
|
||||
|
||||
@Test
|
||||
void deletesExpiredWorkflowTasksAndArchivedObjects() {
|
||||
ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class);
|
||||
OssStorageService ossStorageService = mock(OssStorageService.class);
|
||||
ImageVideoArchiveService archiveService = new ImageVideoArchiveService(
|
||||
taskMapper, ossStorageService, new ImageVideoProperties(), new ObjectMapper());
|
||||
ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity();
|
||||
task.setId(10L);
|
||||
task.setTaskType(ImageVideoArchiveService.TASK_TYPE);
|
||||
task.setArchivedVideosJson("[{\"objectKey\":\"result/image_video/10/video.mp4\"}]");
|
||||
when(taskMapper.selectList(any())).thenReturn(List.of(task));
|
||||
when(taskMapper.deleteById(10L)).thenReturn(1);
|
||||
|
||||
archiveService.cleanupExpiredWorkflowTasks();
|
||||
|
||||
verify(ossStorageService).deleteObject("result/image_video/10/video.mp4");
|
||||
verify(taskMapper).deleteById(10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsExpiredTaskWhenArchivedObjectDeletionFails() {
|
||||
ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class);
|
||||
OssStorageService ossStorageService = mock(OssStorageService.class);
|
||||
ImageVideoArchiveService archiveService = new ImageVideoArchiveService(
|
||||
taskMapper, ossStorageService, new ImageVideoProperties(), new ObjectMapper());
|
||||
ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity();
|
||||
task.setId(11L);
|
||||
task.setTaskType(ImageVideoArchiveService.TASK_TYPE);
|
||||
task.setArchivedVideosJson("[{\"objectKey\":\"result/image_video/11/video.mp4\"}]");
|
||||
when(taskMapper.selectList(any())).thenReturn(List.of(task));
|
||||
doThrow(new IllegalStateException("OSS unavailable"))
|
||||
.when(ossStorageService).deleteObject("result/image_video/11/video.mp4");
|
||||
|
||||
archiveService.cleanupExpiredWorkflowTasks();
|
||||
|
||||
verify(taskMapper, never()).deleteById(11L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsNestedAndMultipleVideoUrlsWithoutDuplicates() {
|
||||
Map<String, Object> result = Map.of(
|
||||
|
||||
@@ -139,6 +139,69 @@ class ImageVideoAsyncTaskServiceTest {
|
||||
verify(taskMapper).updateById(task);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedTaskIsReturnedOnceAndThenDeleted() {
|
||||
ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class);
|
||||
ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService(
|
||||
taskMapper,
|
||||
mock(ImageVideoCozeService.class),
|
||||
mock(ImageVideoWorkflowConfigService.class),
|
||||
mock(ImageVideoArchiveService.class),
|
||||
new ObjectMapper(),
|
||||
Runnable::run);
|
||||
ImageVideoAsyncTaskEntity task = waitingWorkflowTask();
|
||||
task.setStatus("FAILED");
|
||||
task.setCozeStatus("FAIL");
|
||||
task.setErrorMessage("model unavailable");
|
||||
when(taskMapper.selectOne(any())).thenReturn(task);
|
||||
|
||||
var result = service.getTask(98L, 1L);
|
||||
|
||||
assertEquals("FAILED", result.getStatus());
|
||||
assertEquals("model unavailable", result.getErrorMessage());
|
||||
verify(taskMapper).deleteById(98L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoverableFailedTaskIsNotDeleted() {
|
||||
ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class);
|
||||
ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService(
|
||||
taskMapper,
|
||||
mock(ImageVideoCozeService.class),
|
||||
mock(ImageVideoWorkflowConfigService.class),
|
||||
mock(ImageVideoArchiveService.class),
|
||||
new ObjectMapper(),
|
||||
Runnable::run);
|
||||
ImageVideoAsyncTaskEntity task = waitingWorkflowTask();
|
||||
task.setStatus("FAILED");
|
||||
task.setCozeStatus("RUNNING");
|
||||
task.setErrorMessage("Coze workflow finished with status RUNNING");
|
||||
when(taskMapper.selectOne(any())).thenReturn(task);
|
||||
|
||||
var result = service.getTask(98L, 1L);
|
||||
|
||||
assertEquals("WAITING", result.getStatus());
|
||||
verify(taskMapper).updateById(task);
|
||||
verify(taskMapper, never()).deleteById(98L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupRemovesFailedTasksThatWereNotPolled() {
|
||||
ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class);
|
||||
ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService(
|
||||
taskMapper,
|
||||
mock(ImageVideoCozeService.class),
|
||||
mock(ImageVideoWorkflowConfigService.class),
|
||||
mock(ImageVideoArchiveService.class),
|
||||
new ObjectMapper(),
|
||||
Runnable::run);
|
||||
when(taskMapper.delete(any())).thenReturn(3);
|
||||
|
||||
service.cleanupFailedTasks();
|
||||
|
||||
verify(taskMapper).delete(any());
|
||||
}
|
||||
|
||||
private ImageVideoAsyncTaskEntity waitingDouyinTask() {
|
||||
ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity();
|
||||
task.setId(79L);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.nanri.aiimage.modules.permission.service;
|
||||
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.mapper.PermissionMenuMapper;
|
||||
import com.nanri.aiimage.modules.permission.mapper.UserColumnPermissionMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.PermissionMenuEntity;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.UserColumnPermissionEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PermissionMenuServiceTest {
|
||||
|
||||
@Test
|
||||
void preservesExistingImageVideoPermissionDuringGenericReplacement() {
|
||||
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
|
||||
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
|
||||
when(userMapper.selectById(7L)).thenReturn(new AdminUserEntity());
|
||||
when(menuMapper.selectCount(any())).thenReturn(1L);
|
||||
when(menuMapper.selectOne(any())).thenReturn(imageVideoPermission());
|
||||
when(permissionMapper.selectCount(any())).thenReturn(1L);
|
||||
|
||||
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
|
||||
request.setColumnIds(List.of(2L));
|
||||
service.updateUserColumnPermissions(7L, request);
|
||||
|
||||
ArgumentCaptor<UserColumnPermissionEntity> captor = ArgumentCaptor.forClass(UserColumnPermissionEntity.class);
|
||||
verify(permissionMapper, times(2)).insert(captor.capture());
|
||||
assertThat(captor.getAllValues())
|
||||
.extracting(UserColumnPermissionEntity::getColumnId)
|
||||
.containsExactly(2L, 75L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotGrantImageVideoPermissionThroughGenericReplacement() {
|
||||
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
|
||||
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
|
||||
when(userMapper.selectById(8L)).thenReturn(new AdminUserEntity());
|
||||
when(menuMapper.selectCount(any())).thenReturn(1L);
|
||||
when(menuMapper.selectOne(any())).thenReturn(imageVideoPermission());
|
||||
when(permissionMapper.selectCount(any())).thenReturn(0L);
|
||||
|
||||
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
|
||||
request.setColumnIds(List.of(75L));
|
||||
service.updateUserColumnPermissions(8L, request);
|
||||
|
||||
verify(permissionMapper, times(0)).insert(any(UserColumnPermissionEntity.class));
|
||||
}
|
||||
|
||||
private PermissionMenuEntity imageVideoPermission() {
|
||||
PermissionMenuEntity entity = new PermissionMenuEntity();
|
||||
entity.setId(75L);
|
||||
entity.setColumnKey("admin_image_video_tasks");
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackShopCandidateMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
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.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PriceTrackTaskServiceTest {
|
||||
|
||||
@Mock private FileTaskMapper fileTaskMapper;
|
||||
@Mock private FileResultMapper fileResultMapper;
|
||||
@Mock private PriceTrackShopCandidateMapper candidateMapper;
|
||||
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||
@Mock private OssStorageService ossStorageService;
|
||||
@Mock private ObjectMapper objectMapper;
|
||||
@Mock private SkipPriceAsinService skipPriceAsinService;
|
||||
@Mock private PriceTrackExcelAssemblyService excelAssemblyService;
|
||||
@Mock private PriceTrackTaskCacheService priceTrackTaskCacheService;
|
||||
@Mock private PriceTrackLoopRunService priceTrackLoopRunService;
|
||||
@Mock private TaskPressureProperties taskPressureProperties;
|
||||
@Mock private TaskResultPayloadService taskResultPayloadService;
|
||||
@Mock private TaskFileJobService taskFileJobService;
|
||||
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||
|
||||
@InjectMocks private PriceTrackTaskService service;
|
||||
|
||||
@Test
|
||||
void markDispatchFailedImmediatelyFinalizesUnqueuedTask() {
|
||||
long taskId = 19788L;
|
||||
long userId = 671L;
|
||||
String error = "当前店铺 张志云 正在执行中,请等待店铺完成之后重试";
|
||||
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(taskId);
|
||||
task.setUserId(userId);
|
||||
task.setModuleType("PRICE_TRACK");
|
||||
task.setStatus("RUNNING");
|
||||
task.setRequestJson("{}");
|
||||
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setId(9001L);
|
||||
result.setTaskId(taskId);
|
||||
result.setModuleType("PRICE_TRACK");
|
||||
result.setSourceFilename("张志云");
|
||||
result.setSuccess(0);
|
||||
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
when(taskDistributedLockService.acquire("PRICE_TRACK", taskId)).thenReturn(lock);
|
||||
when(priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId))).thenReturn(Map.of(taskId, task));
|
||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(result));
|
||||
|
||||
service.markDispatchFailed(taskId, userId, error);
|
||||
|
||||
assertEquals("FAILED", task.getStatus());
|
||||
assertEquals("张志云: " + error, task.getErrorMessage());
|
||||
assertNotNull(task.getFinishedAt());
|
||||
assertEquals(error, result.getErrorMessage());
|
||||
verify(fileResultMapper).updateById(result);
|
||||
verify(fileTaskMapper).updateById(task);
|
||||
verify(priceTrackTaskCacheService).deleteTaskCache(taskId);
|
||||
verify(priceTrackLoopRunService).syncLoopRunAfterChildTerminal(taskId);
|
||||
verify(lock).close();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.nanri.aiimage.modules.productrisk.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskRowDto;
|
||||
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -15,6 +17,9 @@ import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ProductRiskExcelAssemblyServiceTest {
|
||||
|
||||
@@ -47,6 +52,76 @@ class ProductRiskExcelAssemblyServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesViolationDetailsAndMergesRepeatedAsinCells() throws Exception {
|
||||
Path xlsx = Files.createTempFile("product-risk-details-", ".xlsx");
|
||||
try {
|
||||
ProductRiskRowDto row = row("B0D2LG8R11", "处理失败", Boolean.FALSE);
|
||||
row.setViolationDetails(List.of(
|
||||
detail("商品安全违规", "成功"),
|
||||
detail("商品安全违规", "失败"),
|
||||
detail("商品安全违规", "成功")));
|
||||
ProductRiskRowDto secondRow = row("B0987884CV", "处理完成", Boolean.FALSE);
|
||||
secondRow.setViolationDetails(List.of(
|
||||
detail("商品安全违规", "成功"),
|
||||
detail("商品详情页违规", "成功")));
|
||||
|
||||
service.writeWorkbook(
|
||||
xlsx.toFile(),
|
||||
"店铺A",
|
||||
Map.of(ProductRiskCountryCode.DE.getSheetName(), List.of(row, secondRow)));
|
||||
|
||||
try (InputStream in = Files.newInputStream(xlsx);
|
||||
Workbook workbook = new XSSFWorkbook(in)) {
|
||||
Sheet sheet = workbook.getSheet(ProductRiskCountryCode.DE.getSheetName());
|
||||
assertEquals("ASIN", sheet.getRow(0).getCell(6).getStringCellValue());
|
||||
assertEquals("原因", sheet.getRow(0).getCell(7).getStringCellValue());
|
||||
assertEquals("状态", sheet.getRow(0).getCell(8).getStringCellValue());
|
||||
assertEquals("B0D2LG8R11", sheet.getRow(1).getCell(6).getStringCellValue());
|
||||
assertEquals("商品安全违规", sheet.getRow(2).getCell(7).getStringCellValue());
|
||||
assertEquals("失败", sheet.getRow(2).getCell(8).getStringCellValue());
|
||||
assertTrue(sheet.getMergedRegions().contains(new CellRangeAddress(1, 3, 6, 6)));
|
||||
assertTrue(sheet.getMergedRegions().contains(new CellRangeAddress(4, 5, 6, 6)));
|
||||
assertEquals("处理失败", sheet.getRow(1).getCell(2).getStringCellValue());
|
||||
assertEquals("B0987884CV", sheet.getRow(4).getCell(1).getStringCellValue());
|
||||
assertEquals("B0987884CV", sheet.getRow(4).getCell(6).getStringCellValue());
|
||||
assertEquals("商品详情页违规", sheet.getRow(5).getCell(7).getStringCellValue());
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(xlsx);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void violationDetailsUseLatestNonEmptyListAndIgnoreEmptyRetry() {
|
||||
List<ProductRiskRowDto.ViolationDetail> original = List.of(detail("旧原因", "成功"));
|
||||
List<ProductRiskRowDto.ViolationDetail> replacement = List.of(
|
||||
detail("商品安全违规", "成功"),
|
||||
detail("商品安全违规", "失败"));
|
||||
|
||||
List<ProductRiskRowDto.ViolationDetail> kept =
|
||||
ProductRiskTaskService.mergeViolationDetails(original, List.of());
|
||||
List<ProductRiskRowDto.ViolationDetail> replaced =
|
||||
ProductRiskTaskService.mergeViolationDetails(original, replacement);
|
||||
|
||||
assertEquals(1, kept.size());
|
||||
assertEquals("旧原因", kept.get(0).getReason());
|
||||
assertEquals(2, replaced.size());
|
||||
assertEquals("商品安全违规", replaced.get(1).getReason());
|
||||
assertEquals("失败", replaced.get(1).getStatus());
|
||||
assertNotSame(replacement.get(0), replaced.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldPayloadWithoutViolationDetailsRemainsCompatible() throws Exception {
|
||||
ProductRiskRowDto row = new ObjectMapper().readValue(
|
||||
"{\"shopName\":\"店铺A\",\"productAsinSku\":\"B0D2LG8R11\",\"status\":\"处理完成\"}",
|
||||
ProductRiskRowDto.class);
|
||||
|
||||
assertNotNull(row.getViolationDetails());
|
||||
assertTrue(row.getViolationDetails().isEmpty());
|
||||
}
|
||||
|
||||
private static ProductRiskRowDto row(String productAsinSku, String status, Boolean done) {
|
||||
ProductRiskRowDto row = new ProductRiskRowDto();
|
||||
row.setShopName("店铺A");
|
||||
@@ -55,4 +130,11 @@ class ProductRiskExcelAssemblyServiceTest {
|
||||
row.setDone(done);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static ProductRiskRowDto.ViolationDetail detail(String reason, String status) {
|
||||
ProductRiskRowDto.ViolationDetail detail = new ProductRiskRowDto.ViolationDetail();
|
||||
detail.setReason(reason);
|
||||
detail.setStatus(status);
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user