添加新需求
This commit is contained in:
@@ -18,4 +18,7 @@ public class ImageVideoProperties {
|
||||
private String voiceSynthesisWorkflowId = "7652954297530105894";
|
||||
private int cozeConnectTimeoutMillis = 10000;
|
||||
private int cozeReadTimeoutMillis = 3600000;
|
||||
private int archiveConnectTimeoutMillis = 10000;
|
||||
private int archiveReadTimeoutMillis = 600000;
|
||||
private int archiveMaxAttempts = 3;
|
||||
}
|
||||
|
||||
@@ -675,6 +675,7 @@ public class AppearancePatentCozeClient {
|
||||
row.setAsin(source.getAsin());
|
||||
row.setCountry(source.getCountry());
|
||||
row.setSku(source.getSku());
|
||||
row.setBrand(source.getBrand());
|
||||
row.setUrl(source.getUrl());
|
||||
row.setTitle(source.getTitle());
|
||||
row.setError(source.getError());
|
||||
|
||||
@@ -33,6 +33,10 @@ 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")
|
||||
@JsonAlias({"Brand", "品牌"})
|
||||
private String brand;
|
||||
|
||||
@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",
|
||||
|
||||
@@ -1268,6 +1268,7 @@ public class AppearancePatentTaskService {
|
||||
row.setAsin(sibling.getAsin());
|
||||
row.setCountry(sibling.getCountry());
|
||||
row.setSku(firstNonBlank(representative.getSku(), sibling.getSku()));
|
||||
row.setBrand(representative.getBrand());
|
||||
row.setUrl(firstNonBlank(representative.getUrl(), sibling.getUrl()));
|
||||
row.setTitle(firstNonBlank(representative.getTitle(), sibling.getTitle()));
|
||||
row.setError(representative.getError());
|
||||
@@ -3326,7 +3327,7 @@ public class AppearancePatentTaskService {
|
||||
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), ""));
|
||||
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(readValueByHeader(parsedRow, "品牌", "brand"));
|
||||
row.createCell(col++).setCellValue(resolveBrand(resultRow, parsedRow));
|
||||
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getPrice(), ""));
|
||||
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()));
|
||||
@@ -4172,6 +4173,23 @@ public class AppearancePatentTaskService {
|
||||
return "";
|
||||
}
|
||||
|
||||
static String resolveBrand(AppearancePatentResultRowDto resultRow, AppearancePatentParsedRowVo parsedRow) {
|
||||
String pythonBrand = resultRow == null ? "" : resultRow.getBrand();
|
||||
if (pythonBrand != null && !pythonBrand.isBlank()) {
|
||||
return pythonBrand.trim();
|
||||
}
|
||||
if (parsedRow == null || parsedRow.getValues() == null) {
|
||||
return "";
|
||||
}
|
||||
for (Map.Entry<String, String> entry : parsedRow.getValues().entrySet()) {
|
||||
String header = entry.getKey() == null ? "" : entry.getKey().trim().toLowerCase(Locale.ROOT);
|
||||
if (header.contains("品牌") || header.contains("brand")) {
|
||||
return entry.getValue() == null ? "" : entry.getValue();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private boolean hasInfringingPersistedConclusion(Long taskId) {
|
||||
if (taskId == null) {
|
||||
return false;
|
||||
|
||||
@@ -17,7 +17,15 @@ public class ImageVideoAsyncTaskEntity {
|
||||
private String taskType;
|
||||
private String status;
|
||||
private String requestJson;
|
||||
private String submitResponseJson;
|
||||
private String resultJson;
|
||||
private String videoUrlsJson;
|
||||
private String debugUrl;
|
||||
private String archivedVideosJson;
|
||||
private String archiveStatus;
|
||||
private String archiveError;
|
||||
private Integer archiveAttemptCount;
|
||||
private LocalDateTime archivedAt;
|
||||
private String errorMessage;
|
||||
private String cozeExecuteId;
|
||||
private String cozeStatus;
|
||||
|
||||
@@ -11,6 +11,7 @@ public class ImageVideoAsyncTaskVo {
|
||||
private String status;
|
||||
private String cozeExecuteId;
|
||||
private String cozeStatus;
|
||||
private String debugUrl;
|
||||
private Object result;
|
||||
private String errorMessage;
|
||||
private LocalDateTime submittedAt;
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.ImageVideoProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoAsyncTaskMapper;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoAsyncTaskEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ImageVideoArchiveService {
|
||||
|
||||
static final String TASK_TYPE = "IMAGE_VIDEO_WORKFLOW";
|
||||
private static final int BATCH_SIZE = 20;
|
||||
private static final Set<String> VIDEO_KEYS = Set.of(
|
||||
"videourl", "video", "outputvideourl", "resultvideourl"
|
||||
);
|
||||
private static final Set<String> DEBUG_KEYS = Set.of("debugurl");
|
||||
|
||||
private final ImageVideoAsyncTaskMapper taskMapper;
|
||||
private final OssStorageService ossStorageService;
|
||||
private final ImageVideoProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
void captureSubmitResponse(ImageVideoAsyncTaskEntity task, Object response) {
|
||||
if (!isWorkflowTask(task) || response == null) {
|
||||
return;
|
||||
}
|
||||
task.setSubmitResponseJson(writeJson(response));
|
||||
String debugUrl = findFirstUrl(response, DEBUG_KEYS);
|
||||
if (!debugUrl.isBlank()) {
|
||||
task.setDebugUrl(debugUrl);
|
||||
}
|
||||
}
|
||||
|
||||
void capturePollResponse(ImageVideoAsyncTaskEntity task, Object response) {
|
||||
if (!isWorkflowTask(task) || response == null
|
||||
|| (task.getDebugUrl() != null && !task.getDebugUrl().isBlank())) {
|
||||
return;
|
||||
}
|
||||
String debugUrl = findFirstUrl(response, DEBUG_KEYS);
|
||||
if (!debugUrl.isBlank()) {
|
||||
task.setDebugUrl(debugUrl);
|
||||
}
|
||||
}
|
||||
|
||||
void enrichCompletedTask(ImageVideoAsyncTaskEntity task, Object result) {
|
||||
if (!isWorkflowTask(task)) {
|
||||
return;
|
||||
}
|
||||
List<String> videoUrls = findUrls(result, VIDEO_KEYS);
|
||||
task.setVideoUrlsJson(writeJson(videoUrls));
|
||||
if (task.getDebugUrl() == null || task.getDebugUrl().isBlank()) {
|
||||
String debugUrl = findFirstUrl(result, DEBUG_KEYS);
|
||||
if (!debugUrl.isBlank()) {
|
||||
task.setDebugUrl(debugUrl);
|
||||
}
|
||||
}
|
||||
task.setArchiveStatus(videoUrls.isEmpty() ? "NO_VIDEO" : "PENDING");
|
||||
task.setArchiveError(null);
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.image-video.archive-delay-ms:10000}")
|
||||
public void archivePendingVideos() {
|
||||
int maxAttempts = Math.max(1, properties.getArchiveMaxAttempts());
|
||||
List<ImageVideoAsyncTaskEntity> tasks = taskMapper.selectList(
|
||||
new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
|
||||
.eq(ImageVideoAsyncTaskEntity::getTaskType, TASK_TYPE)
|
||||
.eq(ImageVideoAsyncTaskEntity::getStatus, "SUCCESS")
|
||||
.and(q -> q.isNull(ImageVideoAsyncTaskEntity::getArchiveStatus)
|
||||
.or().in(ImageVideoAsyncTaskEntity::getArchiveStatus, "PENDING", "FAILED"))
|
||||
.lt(ImageVideoAsyncTaskEntity::getArchiveAttemptCount, maxAttempts)
|
||||
.orderByAsc(ImageVideoAsyncTaskEntity::getId)
|
||||
.last("LIMIT " + BATCH_SIZE));
|
||||
for (ImageVideoAsyncTaskEntity task : tasks) {
|
||||
backfillDerivedFields(task);
|
||||
if ("PENDING".equals(task.getArchiveStatus()) || "FAILED".equals(task.getArchiveStatus())) {
|
||||
archiveTask(task.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void backfillDerivedFields(ImageVideoAsyncTaskEntity task) {
|
||||
if (task.getArchiveStatus() != null) {
|
||||
return;
|
||||
}
|
||||
Object result = readJsonValue(task.getResultJson());
|
||||
enrichCompletedTask(task, result);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
taskMapper.updateById(task);
|
||||
}
|
||||
|
||||
private void archiveTask(Long taskId) {
|
||||
int maxAttempts = Math.max(1, properties.getArchiveMaxAttempts());
|
||||
int claimed = taskMapper.update(null, new LambdaUpdateWrapper<ImageVideoAsyncTaskEntity>()
|
||||
.set(ImageVideoAsyncTaskEntity::getArchiveStatus, "ARCHIVING")
|
||||
.set(ImageVideoAsyncTaskEntity::getArchiveError, null)
|
||||
.setSql("archive_attempt_count = archive_attempt_count + 1")
|
||||
.set(ImageVideoAsyncTaskEntity::getUpdatedAt, LocalDateTime.now())
|
||||
.eq(ImageVideoAsyncTaskEntity::getId, taskId)
|
||||
.in(ImageVideoAsyncTaskEntity::getArchiveStatus, "PENDING", "FAILED")
|
||||
.lt(ImageVideoAsyncTaskEntity::getArchiveAttemptCount, maxAttempts));
|
||||
if (claimed != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImageVideoAsyncTaskEntity task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
List<String> videoUrls = readStringList(task.getVideoUrlsJson());
|
||||
List<Map<String, Object>> archived = readArchivedVideos(task.getArchivedVideosJson());
|
||||
Map<String, Map<String, Object>> existingBySource = new LinkedHashMap<>();
|
||||
for (Map<String, Object> item : archived) {
|
||||
existingBySource.put(String.valueOf(item.getOrDefault("sourceUrl", "")), item);
|
||||
}
|
||||
|
||||
String firstError = "";
|
||||
List<Map<String, Object>> updated = new ArrayList<>();
|
||||
for (String sourceUrl : videoUrls) {
|
||||
Map<String, Object> existing = existingBySource.get(sourceUrl);
|
||||
if (existing != null && "SUCCESS".equals(existing.get("status"))) {
|
||||
updated.add(existing);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
updated.add(archiveOne(sourceUrl));
|
||||
} catch (Exception ex) {
|
||||
if (firstError.isBlank()) {
|
||||
firstError = messageOf(ex);
|
||||
}
|
||||
Map<String, Object> failed = new LinkedHashMap<>();
|
||||
failed.put("sourceUrl", sourceUrl);
|
||||
failed.put("status", "FAILED");
|
||||
failed.put("error", messageOf(ex));
|
||||
updated.add(failed);
|
||||
}
|
||||
}
|
||||
|
||||
task.setArchivedVideosJson(writeJson(updated));
|
||||
task.setArchiveStatus(firstError.isBlank() ? "SUCCESS" : "FAILED");
|
||||
task.setArchiveError(firstError.isBlank() ? null : truncate(firstError));
|
||||
task.setArchivedAt(firstError.isBlank() ? LocalDateTime.now() : null);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
taskMapper.updateById(task);
|
||||
}
|
||||
|
||||
private Map<String, Object> archiveOne(String sourceUrl) throws Exception {
|
||||
URI uri = URI.create(sourceUrl);
|
||||
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
throw new IllegalArgumentException("Unsupported video URL scheme");
|
||||
}
|
||||
String suffix = videoSuffix(uri.getPath());
|
||||
File tempFile = Files.createTempFile("image-video-archive-", suffix).toFile();
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
connection = (HttpURLConnection) uri.toURL().openConnection();
|
||||
connection.setConnectTimeout(Math.max(1000, properties.getArchiveConnectTimeoutMillis()));
|
||||
connection.setReadTimeout(Math.max(1000, properties.getArchiveReadTimeoutMillis()));
|
||||
connection.setInstanceFollowRedirects(true);
|
||||
connection.setRequestProperty("User-Agent", "aiimage-video-archive/1.0");
|
||||
int status = connection.getResponseCode();
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new IllegalStateException("Video download returned HTTP " + status);
|
||||
}
|
||||
try (var input = connection.getInputStream(); var output = new FileOutputStream(tempFile)) {
|
||||
input.transferTo(output);
|
||||
}
|
||||
OssStorageService.UploadedResult uploaded =
|
||||
ossStorageService.uploadResultFileWithFreshDownloadUrl(tempFile, "IMAGE_VIDEO");
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("sourceUrl", sourceUrl);
|
||||
item.put("objectKey", uploaded.objectKey());
|
||||
item.put("url", uploaded.downloadUrl());
|
||||
item.put("status", "SUCCESS");
|
||||
return item;
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
if (tempFile.exists() && !tempFile.delete()) {
|
||||
log.warn("[image-video] archive temp file delete failed path={}", tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> findUrls(Object value, Set<String> keys) {
|
||||
LinkedHashSet<String> result = new LinkedHashSet<>();
|
||||
collectUrls(value, keys, result, new ObjectMapper());
|
||||
return new ArrayList<>(result);
|
||||
}
|
||||
|
||||
private static void collectUrls(Object value, Set<String> keys, Set<String> result, ObjectMapper mapper) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
String key = canonicalKey(String.valueOf(entry.getKey()));
|
||||
if (keys.contains(key)) {
|
||||
collectHttpStrings(entry.getValue(), result, mapper);
|
||||
}
|
||||
collectUrls(entry.getValue(), keys, result, mapper);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value instanceof Iterable<?> iterable) {
|
||||
iterable.forEach(item -> collectUrls(item, keys, result, mapper));
|
||||
return;
|
||||
}
|
||||
if (value instanceof JsonNode node) {
|
||||
collectUrls(mapper.convertValue(node, Object.class), keys, result, mapper);
|
||||
return;
|
||||
}
|
||||
if (value instanceof String text && looksLikeJson(text)) {
|
||||
try {
|
||||
collectUrls(mapper.readValue(text, Object.class), keys, result, mapper);
|
||||
} catch (Exception ignored) {
|
||||
// Not every string that starts with a brace is valid JSON.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectHttpStrings(Object value, Set<String> result, ObjectMapper mapper) {
|
||||
if (value instanceof String text) {
|
||||
String normalized = text.trim();
|
||||
if (normalized.startsWith("http://") || normalized.startsWith("https://")) {
|
||||
result.add(normalized);
|
||||
} else if (looksLikeJson(normalized)) {
|
||||
try {
|
||||
collectHttpStrings(mapper.readValue(normalized, Object.class), result, mapper);
|
||||
} catch (Exception ignored) {
|
||||
// Ignore malformed nested JSON.
|
||||
}
|
||||
}
|
||||
} else if (value instanceof Iterable<?> iterable) {
|
||||
iterable.forEach(item -> collectHttpStrings(item, result, mapper));
|
||||
} else if (value instanceof Map<?, ?> map) {
|
||||
map.values().forEach(item -> collectHttpStrings(item, result, mapper));
|
||||
}
|
||||
}
|
||||
|
||||
private static String findFirstUrl(Object value, Set<String> keys) {
|
||||
List<String> urls = findUrls(value, keys);
|
||||
return urls.isEmpty() ? "" : urls.getFirst();
|
||||
}
|
||||
|
||||
private static String canonicalKey(String key) {
|
||||
return key == null ? "" : key.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static boolean looksLikeJson(String value) {
|
||||
String text = value == null ? "" : value.trim();
|
||||
return (text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"));
|
||||
}
|
||||
|
||||
private boolean isWorkflowTask(ImageVideoAsyncTaskEntity task) {
|
||||
return task != null && TASK_TYPE.equals(task.getTaskType());
|
||||
}
|
||||
|
||||
private Object readJsonValue(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, Object.class);
|
||||
} catch (Exception ex) {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> readStringList(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception ex) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> readArchivedVideos(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception ex) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private String writeJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Image video audit serialization failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String videoSuffix(String path) {
|
||||
String normalized = path == null ? "" : path.toLowerCase(Locale.ROOT);
|
||||
for (String extension : List.of(".mp4", ".mov", ".webm", ".m4v")) {
|
||||
if (normalized.endsWith(extension)) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
return ".mp4";
|
||||
}
|
||||
|
||||
private String messageOf(Exception ex) {
|
||||
return ex.getMessage() == null || ex.getMessage().isBlank() ? ex.getClass().getSimpleName() : ex.getMessage();
|
||||
}
|
||||
|
||||
private String truncate(String value) {
|
||||
return value.length() <= 1000 ? value : value.substring(0, 1000);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ public class ImageVideoAsyncTaskService {
|
||||
private final ImageVideoAsyncTaskMapper taskMapper;
|
||||
private final ImageVideoCozeService cozeService;
|
||||
private final ImageVideoWorkflowConfigService workflowConfigService;
|
||||
private final ImageVideoArchiveService archiveService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TaskExecutor cozeTaskExecutor;
|
||||
|
||||
@@ -54,11 +55,13 @@ public class ImageVideoAsyncTaskService {
|
||||
ImageVideoAsyncTaskMapper taskMapper,
|
||||
ImageVideoCozeService cozeService,
|
||||
ImageVideoWorkflowConfigService workflowConfigService,
|
||||
ImageVideoArchiveService archiveService,
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("cozeTaskExecutor") TaskExecutor cozeTaskExecutor) {
|
||||
this.taskMapper = taskMapper;
|
||||
this.cozeService = cozeService;
|
||||
this.workflowConfigService = workflowConfigService;
|
||||
this.archiveService = archiveService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.cozeTaskExecutor = cozeTaskExecutor;
|
||||
}
|
||||
@@ -151,15 +154,16 @@ public class ImageVideoAsyncTaskService {
|
||||
TaskType type = TaskType.valueOf(task.getTaskType());
|
||||
if (type == TaskType.WORKFLOW_RESULT) {
|
||||
ImageVideoWorkflowResultRequest request = readJson(task.getRequestJson(), ImageVideoWorkflowResultRequest.class);
|
||||
waitForCoze(task, request.getExecuteId(), "");
|
||||
waitForCoze(task, request.getExecuteId(), "", null);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> response = submitToCoze(type, task.getRequestJson());
|
||||
archiveService.captureSubmitResponse(task, response);
|
||||
String executeId = findText(response, Set.of("execute_id", "executeId"));
|
||||
String cozeStatus = resolveCozeExecutionStatus(response);
|
||||
if (!executeId.isBlank() && !TERMINAL_STATUSES.contains(cozeStatus)) {
|
||||
waitForCoze(task, executeId, cozeStatus);
|
||||
waitForCoze(task, executeId, cozeStatus, response);
|
||||
return;
|
||||
}
|
||||
completeTask(task, transformResult(type, response), cozeStatus);
|
||||
@@ -184,10 +188,12 @@ public class ImageVideoAsyncTaskService {
|
||||
TaskType type = TaskType.valueOf(task.getTaskType());
|
||||
String workflowId = workflowIdFor(type);
|
||||
Map<String, Object> result = cozeService.getWorkflowResult(task.getUserId(), workflowId, task.getCozeExecuteId());
|
||||
archiveService.capturePollResponse(task, result);
|
||||
String cozeStatus = resolveCozeExecutionStatus(result);
|
||||
if (TERMINAL_STATUSES.contains(cozeStatus)) {
|
||||
if (FAILED_STATUSES.contains(cozeStatus)) {
|
||||
task.setCozeStatus(cozeStatus);
|
||||
task.setResultJson(writeJson(result));
|
||||
failTask(task, new BusinessException("Coze workflow finished with status " + cozeStatus));
|
||||
} else {
|
||||
completeTask(task, transformResult(type, result), cozeStatus);
|
||||
@@ -251,7 +257,8 @@ public class ImageVideoAsyncTaskService {
|
||||
return type == TaskType.DOUYIN_COPY ? cozeService.parseDouyinCopyResult(result) : result;
|
||||
}
|
||||
|
||||
private void waitForCoze(ImageVideoAsyncTaskEntity task, String executeId, String cozeStatus) {
|
||||
private void waitForCoze(ImageVideoAsyncTaskEntity task, String executeId, String cozeStatus, Object submitResponse) {
|
||||
archiveService.captureSubmitResponse(task, submitResponse);
|
||||
task.setStatus(TaskStatus.WAITING.name());
|
||||
task.setCozeExecuteId(executeId);
|
||||
task.setCozeStatus(normalizeStatus(cozeStatus));
|
||||
@@ -263,6 +270,7 @@ public class ImageVideoAsyncTaskService {
|
||||
private void completeTask(ImageVideoAsyncTaskEntity task, Object result, String cozeStatus) {
|
||||
task.setStatus(TaskStatus.SUCCESS.name());
|
||||
task.setResultJson(writeJson(result));
|
||||
archiveService.enrichCompletedTask(task, result);
|
||||
task.setCozeStatus(normalizeStatus(cozeStatus));
|
||||
task.setErrorMessage(null);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
@@ -312,6 +320,7 @@ public class ImageVideoAsyncTaskService {
|
||||
vo.setStatus(task.getStatus());
|
||||
vo.setCozeExecuteId(task.getCozeExecuteId());
|
||||
vo.setCozeStatus(task.getCozeStatus());
|
||||
vo.setDebugUrl(task.getDebugUrl());
|
||||
vo.setResult(readResult(task.getResultJson()));
|
||||
vo.setErrorMessage(task.getErrorMessage());
|
||||
vo.setSubmittedAt(task.getSubmittedAt());
|
||||
|
||||
@@ -42,7 +42,7 @@ public class ImageVideoCozeService {
|
||||
"fullContent", "full_content", "copywriting", "script", "output", "text", "result"
|
||||
);
|
||||
private static final Set<String> IMAGE_VIDEO_MODELS = Set.of(
|
||||
"sdols-2.0", "sdols-2.0-fast", "doubao-seedance-2.0-mini"
|
||||
"sdols-2.0", "sdols-2.0-fast", "doubao-seedance-2.0-mini", "gemini-omini"
|
||||
);
|
||||
|
||||
private final ImageVideoProperties properties;
|
||||
@@ -437,8 +437,12 @@ public class ImageVideoCozeService {
|
||||
DouyinCopyVo vo = new DouyinCopyVo();
|
||||
vo.setRecognizedContent(cleanCopyText(recognized));
|
||||
vo.setScriptDraft(cleanCopyText(script));
|
||||
vo.setExecuteId(root.path("execute_id").asText(""));
|
||||
vo.setDebugUrl(root.path("debug_url").asText(""));
|
||||
vo.setExecuteId(firstNonBlank(
|
||||
root.path("execute_id").asText(""),
|
||||
findText(root, List.of("execute_id", "executeId"))));
|
||||
vo.setDebugUrl(firstNonBlank(
|
||||
root.path("debug_url").asText(""),
|
||||
findText(root, List.of("debug_url", "debugUrl"))));
|
||||
return vo;
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
|
||||
@@ -239,6 +239,10 @@ aiimage:
|
||||
coze-read-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_COZE_READ_TIMEOUT_MILLIS:3600000}
|
||||
async-task-dispatch-delay-ms: ${AIIMAGE_IMAGE_VIDEO_ASYNC_TASK_DISPATCH_DELAY_MS:1000}
|
||||
async-task-poll-delay-ms: ${AIIMAGE_IMAGE_VIDEO_ASYNC_TASK_POLL_DELAY_MS:5000}
|
||||
archive-delay-ms: ${AIIMAGE_IMAGE_VIDEO_ARCHIVE_DELAY_MS:10000}
|
||||
archive-connect-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_ARCHIVE_CONNECT_TIMEOUT_MILLIS:10000}
|
||||
archive-read-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_ARCHIVE_READ_TIMEOUT_MILLIS:600000}
|
||||
archive-max-attempts: ${AIIMAGE_IMAGE_VIDEO_ARCHIVE_MAX_ATTEMPTS:3}
|
||||
security:
|
||||
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
||||
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
ALTER TABLE `biz_image_video_async_task`
|
||||
ADD COLUMN `submit_response_json` LONGTEXT NULL AFTER `request_json`,
|
||||
ADD COLUMN `video_urls_json` LONGTEXT NULL AFTER `result_json`,
|
||||
ADD COLUMN `debug_url` TEXT NULL AFTER `video_urls_json`,
|
||||
ADD COLUMN `archived_videos_json` LONGTEXT NULL AFTER `debug_url`,
|
||||
ADD COLUMN `archive_status` VARCHAR(16) NULL AFTER `archived_videos_json`,
|
||||
ADD COLUMN `archive_error` VARCHAR(1000) NULL AFTER `archive_status`,
|
||||
ADD COLUMN `archive_attempt_count` INT NOT NULL DEFAULT 0 AFTER `archive_error`,
|
||||
ADD COLUMN `archived_at` DATETIME NULL AFTER `archive_attempt_count`,
|
||||
ADD KEY `idx_image_video_type_submitted` (`task_type`, `submitted_at`),
|
||||
ADD KEY `idx_image_video_user_type_submitted` (`user_id`, `task_type`, `submitted_at`);
|
||||
|
||||
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`)
|
||||
SELECT '视频任务管理', 'admin_image_video_tasks', 'admin', 'image-video-tasks', 75
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_image_video_tasks'
|
||||
);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.nanri.aiimage.modules.appearancepatent.service;
|
||||
|
||||
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
|
||||
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedRowVo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class AppearancePatentTaskServiceTest {
|
||||
@@ -13,4 +17,21 @@ class AppearancePatentTaskServiceTest {
|
||||
assertEquals("\u6210\u529f", AppearancePatentTaskService.resolveResultStatus("\u4fb5\u6743"));
|
||||
assertEquals("\u6210\u529f", AppearancePatentTaskService.resolveResultStatus("\u65e0\u4fb5\u6743"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resultBrandPrefersPythonThenFallsBackToSourceFile() {
|
||||
AppearancePatentParsedRowVo parsedRow = new AppearancePatentParsedRowVo();
|
||||
parsedRow.setValues(new LinkedHashMap<>());
|
||||
parsedRow.getValues().put("品牌", "Source Brand");
|
||||
|
||||
AppearancePatentResultRowDto resultRow = new AppearancePatentResultRowDto();
|
||||
resultRow.setBrand("Python Brand");
|
||||
assertEquals("Python Brand", AppearancePatentTaskService.resolveBrand(resultRow, parsedRow));
|
||||
|
||||
resultRow.setBrand(" ");
|
||||
assertEquals("Source Brand", AppearancePatentTaskService.resolveBrand(resultRow, parsedRow));
|
||||
|
||||
parsedRow.getValues().clear();
|
||||
assertEquals("", AppearancePatentTaskService.resolveBrand(resultRow, parsedRow));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.ImageVideoProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoAsyncTaskMapper;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoAsyncTaskEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class ImageVideoArchiveServiceTest {
|
||||
|
||||
@Test
|
||||
void extractsNestedAndMultipleVideoUrlsWithoutDuplicates() {
|
||||
Map<String, Object> result = Map.of(
|
||||
"output", "{\"data\":{\"video_url\":\"https://cdn.example/a.mp4\"}}",
|
||||
"items", List.of(
|
||||
Map.of("outputVideoUrl", "https://cdn.example/b.mp4"),
|
||||
Map.of("video_url", "https://cdn.example/a.mp4")));
|
||||
|
||||
List<String> urls = ImageVideoArchiveService.findUrls(
|
||||
result, Set.of("videourl", "video", "outputvideourl", "resultvideourl"));
|
||||
|
||||
assertThat(urls).containsExactlyInAnyOrder(
|
||||
"https://cdn.example/a.mp4", "https://cdn.example/b.mp4");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesSubmitResponseAndPrefersItsDebugUrl() {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
ImageVideoArchiveService service = new ImageVideoArchiveService(
|
||||
mock(ImageVideoAsyncTaskMapper.class),
|
||||
mock(OssStorageService.class),
|
||||
new ImageVideoProperties(),
|
||||
objectMapper);
|
||||
ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity();
|
||||
task.setTaskType(ImageVideoArchiveService.TASK_TYPE);
|
||||
|
||||
service.captureSubmitResponse(task, Map.of(
|
||||
"execute_id", "exec-1",
|
||||
"debug_url", "https://coze.example/debug/exec-1"));
|
||||
service.enrichCompletedTask(task, Map.of(
|
||||
"debug_url", "https://coze.example/debug/final",
|
||||
"video_url", "https://cdn.example/final.mp4"));
|
||||
|
||||
assertEquals("https://coze.example/debug/exec-1", task.getDebugUrl());
|
||||
assertEquals("PENDING", task.getArchiveStatus());
|
||||
assertEquals("[\"https://cdn.example/final.mp4\"]", task.getVideoUrlsJson());
|
||||
assertEquals("exec-1", objectMapper.convertValue(
|
||||
readJson(objectMapper, task.getSubmitResponseJson()), Map.class).get("execute_id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void capturesDebugUrlFromPollResponseWhenSubmitResponseDidNotContainIt() {
|
||||
ImageVideoArchiveService service = new ImageVideoArchiveService(
|
||||
mock(ImageVideoAsyncTaskMapper.class),
|
||||
mock(OssStorageService.class),
|
||||
new ImageVideoProperties(),
|
||||
new ObjectMapper());
|
||||
ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity();
|
||||
task.setTaskType(ImageVideoArchiveService.TASK_TYPE);
|
||||
|
||||
service.capturePollResponse(task, Map.of("data", List.of(Map.of(
|
||||
"debug_url", "https://coze.example/debug/from-poll"))));
|
||||
|
||||
assertEquals("https://coze.example/debug/from-poll", task.getDebugUrl());
|
||||
}
|
||||
|
||||
private Object readJson(ObjectMapper objectMapper, String json) {
|
||||
try {
|
||||
return objectMapper.readValue(json, Object.class);
|
||||
} catch (Exception ex) {
|
||||
throw new AssertionError(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ class ImageVideoAsyncTaskServiceTest {
|
||||
mock(OssStorageService.class),
|
||||
new ObjectMapper());
|
||||
Map<String, Object> cozeResult = Map.of("data", List.of(Map.of(
|
||||
"execute_id", "7662765765078843411",
|
||||
"debug_url", "https://www.coze.cn/work_flow?execute_id=7662765765078843411",
|
||||
"execute_status", "Success",
|
||||
"output", "{\"node_status\":\"{}\",\"Output\":\"{\\\"data\\\":{\\\"source\\\":\\\"这条裙裤绝了,遮肉显腿长,快拍!\\\",\\\"video_url\\\":\\\"https://example.com/video.mp4\\\"}}\"}"
|
||||
)));
|
||||
@@ -41,6 +43,8 @@ class ImageVideoAsyncTaskServiceTest {
|
||||
|
||||
assertEquals("这条裙裤绝了,遮肉显腿长,快拍!", result.getRecognizedContent());
|
||||
assertEquals("这条裙裤绝了,遮肉显腿长,快拍!", result.getScriptDraft());
|
||||
assertEquals("7662765765078843411", result.getExecuteId());
|
||||
assertEquals("https://www.coze.cn/work_flow?execute_id=7662765765078843411", result.getDebugUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -51,7 +55,7 @@ class ImageVideoAsyncTaskServiceTest {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
TaskExecutor directExecutor = Runnable::run;
|
||||
ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService(
|
||||
taskMapper, cozeService, workflowConfigService, objectMapper, directExecutor);
|
||||
taskMapper, cozeService, workflowConfigService, mock(ImageVideoArchiveService.class), objectMapper, directExecutor);
|
||||
|
||||
ImageVideoAsyncTaskEntity task = waitingDouyinTask();
|
||||
Map<String, Object> cozeResult = Map.of("data", List.of(Map.of(
|
||||
@@ -86,7 +90,7 @@ class ImageVideoAsyncTaskServiceTest {
|
||||
ImageVideoCozeService cozeService = mock(ImageVideoCozeService.class);
|
||||
ImageVideoWorkflowConfigService workflowConfigService = mock(ImageVideoWorkflowConfigService.class);
|
||||
ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService(
|
||||
taskMapper, cozeService, workflowConfigService, new ObjectMapper(), Runnable::run);
|
||||
taskMapper, cozeService, workflowConfigService, mock(ImageVideoArchiveService.class), new ObjectMapper(), Runnable::run);
|
||||
|
||||
ImageVideoAsyncTaskEntity task = waitingDouyinTask();
|
||||
Map<String, Object> cozeResult = Map.of("data", List.of(Map.of("execute_status", "Running")));
|
||||
@@ -106,6 +110,35 @@ class ImageVideoAsyncTaskServiceTest {
|
||||
verify(taskMapper).updateById(task);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWorkflowPollPreservesRawResultAndDebugUrl() {
|
||||
ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class);
|
||||
ImageVideoCozeService cozeService = mock(ImageVideoCozeService.class);
|
||||
ImageVideoWorkflowConfigService workflowConfigService = mock(ImageVideoWorkflowConfigService.class);
|
||||
ImageVideoArchiveService archiveService = mock(ImageVideoArchiveService.class);
|
||||
ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService(
|
||||
taskMapper, cozeService, workflowConfigService, archiveService, new ObjectMapper(), Runnable::run);
|
||||
ImageVideoAsyncTaskEntity task = waitingWorkflowTask();
|
||||
Map<String, Object> cozeResult = Map.of("data", List.of(Map.of(
|
||||
"execute_status", "Fail",
|
||||
"debug_url", "https://www.coze.cn/work_flow?execute_id=exec-98",
|
||||
"error_message", "model unavailable")));
|
||||
|
||||
when(taskMapper.selectList(any())).thenReturn(List.of(task));
|
||||
when(taskMapper.claimWaiting(98L)).thenReturn(1);
|
||||
when(taskMapper.selectById(98L)).thenReturn(task);
|
||||
when(workflowConfigService.imageVideoWorkflowId()).thenReturn("workflow-1");
|
||||
when(cozeService.getWorkflowResult(1L, "workflow-1", "exec-98")).thenReturn(cozeResult);
|
||||
|
||||
service.pollWaitingTasks();
|
||||
|
||||
assertEquals("FAILED", task.getStatus());
|
||||
assertEquals("FAIL", task.getCozeStatus());
|
||||
assertTrue(task.getResultJson().contains("model unavailable"));
|
||||
verify(archiveService).capturePollResponse(task, cozeResult);
|
||||
verify(taskMapper).updateById(task);
|
||||
}
|
||||
|
||||
private ImageVideoAsyncTaskEntity waitingDouyinTask() {
|
||||
ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity();
|
||||
task.setId(79L);
|
||||
@@ -118,4 +151,12 @@ class ImageVideoAsyncTaskServiceTest {
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
return task;
|
||||
}
|
||||
|
||||
private ImageVideoAsyncTaskEntity waitingWorkflowTask() {
|
||||
ImageVideoAsyncTaskEntity task = waitingDouyinTask();
|
||||
task.setId(98L);
|
||||
task.setTaskType("IMAGE_VIDEO_WORKFLOW");
|
||||
task.setCozeExecuteId("exec-98");
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user