添加新需求

This commit is contained in:
super
2026-07-16 13:12:46 +08:00
parent 8543dad514
commit 69784b8d32
20 changed files with 1116 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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