视频任务管理更新

This commit is contained in:
super
2026-07-19 22:23:26 +08:00
parent 69784b8d32
commit cbde22ec79
62 changed files with 2164 additions and 280 deletions

View File

@@ -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());

View File

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

View File

@@ -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", "评分"})

View File

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

View File

@@ -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"})

View File

@@ -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)

View File

@@ -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) {

View File

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

View File

@@ -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");

View File

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

View File

@@ -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) {

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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")

View File

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

View File

@@ -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")

View File

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

View File

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

View File

@@ -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紫鸟")

View File

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

View File

@@ -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 不合法");

View File

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

View File

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

View File

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

View File

@@ -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 = "正常")

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import lombok.Data;
@Schema(description = "跳过 ASIN 明细")
public class SkipPriceAsinDetailDto {
@Schema(description = "ASIN")
@Schema(description = "商品 ASIN")
private String asin;
@JsonProperty("minimumPrice")

View File

@@ -9,7 +9,7 @@ import lombok.Data;
@Schema(description = "匹配店铺单行结果")
public class ShopMatchRowDto {
@Schema(description = "ASIN")
@Schema(description = "商品 ASIN")
private String asin;
@JsonAlias("minimum_price")

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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