添加新需求

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

View File

@@ -5,6 +5,7 @@ import json
import os
import re
import threading
from datetime import datetime
from urllib.parse import quote
import requests
@@ -83,6 +84,11 @@ ADMIN_MENU_ACCESS_CONFIG = {
'route_path': 'invalid-asin-data',
'error': '无权访问不符合ASIN数据模块',
},
'image-video-tasks': {
'column_key': 'admin_image_video_tasks',
'route_path': 'image-video-tasks',
'error': '无权访问视频任务管理模块',
},
}
ADMIN_MENU_ACCESS_CONFIG.update({
@@ -190,6 +196,122 @@ def _parse_optional_int(value):
return int(text)
_IMAGE_VIDEO_SECRET_KEYS = {
'api_key', 'apikey', 'token', 'coze_token', 'cozetoken', 't8_key', 't8key',
't8_video_key', 't8videokey', 't8star_key', 't8starkey',
'ai_conductor_key', 'aiconductorkey', 'copy_api_key', 'copyapikey',
'voice_api_key', 'voiceapikey',
}
def _parse_json_value(value, fallback=None):
if value in (None, ''):
return fallback
if isinstance(value, (dict, list)):
return value
try:
return json.loads(value)
except (TypeError, ValueError):
return value
def _mask_image_video_secrets(value):
if isinstance(value, dict):
masked = {}
for key, item in value.items():
canonical = re.sub(r'[^a-z0-9]', '', str(key).lower())
configured_keys = {re.sub(r'[^a-z0-9]', '', item) for item in _IMAGE_VIDEO_SECRET_KEYS}
masked[key] = '******' if canonical in configured_keys and item not in (None, '') else _mask_image_video_secrets(item)
return masked
if isinstance(value, list):
return [_mask_image_video_secrets(item) for item in value]
return value
def _image_video_mode(request_value):
payload = _parse_json_value(request_value, {})
try:
mode = str(payload.get('parameters', {}).get('video_info', {}).get('mode', '')).strip()
except AttributeError:
mode = ''
if mode == '1':
return '图生视频'
if mode == '2':
return '视频复刻'
return '未知'
def _image_video_access_sql(role, current_row, alias='t'):
if role == 'super_admin':
return '1=1', []
admin_id = int(current_row['id'])
return (
f"({alias}.user_id = %s OR {alias}.user_id IN "
"(SELECT id FROM users WHERE role = 'normal' AND created_by_id = %s))",
[admin_id, admin_id],
)
def _format_admin_datetime(value):
if hasattr(value, 'strftime'):
return value.strftime('%Y-%m-%d %H:%M:%S')
return str(value).replace('T', ' ')[:19] if value else ''
def _image_video_urls(row):
originals = _parse_json_value(row.get('video_urls_json'), [])
originals = originals if isinstance(originals, list) else []
archived = _parse_json_value(row.get('archived_videos_json'), [])
archived = archived if isinstance(archived, list) else []
archived_by_source = {
str(item.get('sourceUrl') or ''): item
for item in archived if isinstance(item, dict)
}
items = []
for source_url in originals:
source_url = str(source_url or '')
stored = archived_by_source.get(source_url) or {}
items.append({
'source_url': source_url,
'archived_url': stored.get('url') or '',
'object_key': stored.get('objectKey') or '',
'archive_status': stored.get('status') or '',
'archive_error': stored.get('error') or '',
'display_url': stored.get('url') or source_url,
})
return items
def _image_video_admin_item(row, include_json=False):
videos = _image_video_urls(row)
item = {
'task_id': row.get('id'),
'user_id': row.get('user_id'),
'username': row.get('username') or '',
'mode': _image_video_mode(row.get('request_json')),
'status': row.get('status') or '',
'coze_status': row.get('coze_status') or '',
'coze_execute_id': row.get('coze_execute_id') or '',
'video_url': videos[0]['display_url'] if videos else '',
'debug_url': row.get('debug_url') or '',
'archive_status': row.get('archive_status') or '',
'archive_error': row.get('archive_error') or '',
'archive_attempt_count': row.get('archive_attempt_count') or 0,
'submitted_at': _format_admin_datetime(row.get('submitted_at')),
'completed_at': _format_admin_datetime(row.get('completed_at')),
}
if include_json:
item.update({
'videos': videos,
'error_message': row.get('error_message') or '',
'archived_at': _format_admin_datetime(row.get('archived_at')),
'request': _mask_image_video_secrets(_parse_json_value(row.get('request_json'), {})),
'submit_response': _parse_json_value(row.get('submit_response_json'), {}),
'result': _parse_json_value(row.get('result_json'), {}),
})
return item
def _ensure_column_sort_schema():
global _column_sort_schema_checked
if _column_sort_schema_checked:
@@ -992,6 +1114,120 @@ def history():
# ---------- 栏目权限配置 ----------
_IMAGE_VIDEO_ADMIN_COLUMNS = """
t.id, t.user_id, t.status, t.request_json, t.submit_response_json, t.result_json,
t.video_urls_json, t.debug_url, t.archived_videos_json, t.archive_status,
t.archive_error, t.archive_attempt_count, t.archived_at, t.error_message,
t.coze_execute_id, t.coze_status, t.submitted_at, t.completed_at, u.username
"""
def _parse_admin_datetime_arg(name):
value = (request.args.get(name) or '').strip()
if not value:
return None
try:
return datetime.fromisoformat(value.replace('Z', '+00:00')).replace(tzinfo=None)
except ValueError as exc:
raise ValueError(f'{name} 时间格式无效') from exc
@admin_api.route('/image-video-tasks')
@admin_required
def list_image_video_tasks():
role, current_row, denied = _ensure_admin_menu_access('image-video-tasks')
if denied:
return denied
try:
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(10, int(request.args.get('page_size', 20))))
user_id = request.args.get('user_id', type=int)
username = (request.args.get('username') or '').strip()
status = (request.args.get('status') or '').strip().upper()
execute_id = (request.args.get('coze_execute_id') or '').strip()
submitted_from = _parse_admin_datetime_arg('submitted_from')
submitted_to = _parse_admin_datetime_arg('submitted_to')
access_sql, params = _image_video_access_sql(role, current_row)
conditions = [access_sql, "t.task_type = 'IMAGE_VIDEO_WORKFLOW'"]
if user_id:
conditions.append('t.user_id = %s')
params.append(user_id)
if username:
conditions.append('u.username LIKE %s')
params.append('%' + username + '%')
if status:
conditions.append('t.status = %s')
params.append(status)
if execute_id:
conditions.append('t.coze_execute_id LIKE %s')
params.append('%' + execute_id + '%')
if submitted_from:
conditions.append('t.submitted_at >= %s')
params.append(submitted_from)
if submitted_to:
conditions.append('t.submitted_at <= %s')
params.append(submitted_to)
where_sql = ' AND '.join(conditions)
offset = (page - 1) * page_size
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
'SELECT ' + _IMAGE_VIDEO_ADMIN_COLUMNS +
' FROM biz_image_video_async_task t LEFT JOIN users u ON u.id = t.user_id WHERE ' +
where_sql + ' ORDER BY t.submitted_at DESC, t.id DESC LIMIT %s OFFSET %s',
tuple(params + [page_size, offset]),
)
rows = cur.fetchall()
cur.execute(
'SELECT COUNT(*) AS total FROM biz_image_video_async_task t '
'LEFT JOIN users u ON u.id = t.user_id WHERE ' + where_sql,
tuple(params),
)
total = int((cur.fetchone() or {}).get('total') or 0)
finally:
conn.close()
return jsonify({
'success': True,
'items': [_image_video_admin_item(row) for row in rows],
'total': total,
'page': page,
'page_size': page_size,
})
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
return jsonify({'success': False, 'error': str(exc)}), 500
@admin_api.route('/image-video-tasks/<int:task_id>')
@admin_required
def get_image_video_task(task_id):
role, current_row, denied = _ensure_admin_menu_access('image-video-tasks')
if denied:
return denied
access_sql, params = _image_video_access_sql(role, current_row)
conditions = [access_sql, "t.task_type = 'IMAGE_VIDEO_WORKFLOW'", 't.id = %s']
params.append(task_id)
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
'SELECT ' + _IMAGE_VIDEO_ADMIN_COLUMNS +
' FROM biz_image_video_async_task t LEFT JOIN users u ON u.id = t.user_id WHERE ' +
' AND '.join(conditions) + ' LIMIT 1',
tuple(params),
)
row = cur.fetchone()
finally:
conn.close()
if not row:
return jsonify({'success': False, 'error': '视频任务不存在或无权访问'}), 404
return jsonify({'success': True, 'item': _image_video_admin_item(row, include_json=True)})
@admin_api.route('/columns')
@admin_required
def list_columns():

View File

@@ -107,6 +107,7 @@
'skip-price-asin': 'panel-skip-price-asin',
'query-asin': 'panel-query-asin',
'product-categories': 'panel-product-categories',
'image-video-tasks': 'panel-image-video-tasks',
'history': 'panel-history',
'version': 'panel-version',
'digital-human-version': 'panel-digital-human-version'
@@ -121,6 +122,7 @@
else if (tabName === 'skip-price-asin') loadSkipPriceAsin(1);
else if (tabName === 'query-asin') loadQueryAsin(1);
else if (tabName === 'product-categories') loadProductCategories();
else if (tabName === 'image-video-tasks') loadImageVideoTasks(1);
else if (tabName === 'history') loadHistory(1);
else if (tabName === 'version') loadSoftwareVersions();
else if (tabName === 'digital-human-version') loadDigitalHumanVersions();
@@ -616,6 +618,133 @@
};
// ========== 生成记录 ==========
var imageVideoTaskPage = 1, imageVideoTaskPageSize = 20;
function buildImageVideoTaskQuery(page) {
var params = new URLSearchParams();
params.set('page', String(page || 1));
params.set('page_size', String(imageVideoTaskPageSize));
var values = {
user_id: document.getElementById('imageVideoFilterUserId').value,
username: document.getElementById('imageVideoFilterUsername').value.trim(),
status: document.getElementById('imageVideoFilterStatus').value,
coze_execute_id: document.getElementById('imageVideoFilterExecuteId').value.trim(),
submitted_from: document.getElementById('imageVideoFilterFrom').value,
submitted_to: document.getElementById('imageVideoFilterTo').value
};
Object.keys(values).forEach(function (key) { if (values[key]) params.set(key, values[key]); });
return params.toString();
}
function renderImageVideoStatus(value) {
var status = String(value || '-').toUpperCase();
return '<span class="image-video-status ' + escapeHtml(status) + '">' + escapeHtml(status) + '</span>';
}
function safeAdminUrl(value) {
var url = String(value || '').trim();
return /^https?:\/\//i.test(url) ? url : '';
}
function loadImageVideoTasks(page) {
imageVideoTaskPage = page || 1;
var tbody = document.getElementById('imageVideoTaskListBody');
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">加载中...</td></tr>';
fetch('/api/admin/image-video-tasks?' + buildImageVideoTaskQuery(imageVideoTaskPage))
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) {
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">加载失败:' + escapeHtml(res.error || '') + '</td></tr>';
return;
}
var items = res.items || [];
document.getElementById('imageVideoTaskTotal').textContent = '共 ' + (res.total || 0) + ' 条';
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">暂无视频任务</td></tr>';
} else {
tbody.innerHTML = items.map(function (item) {
var videoUrl = safeAdminUrl(item.video_url);
var videoLink = videoUrl ? '<a href="' + escapeHtml(videoUrl) + '" target="_blank" rel="noreferrer">查看视频</a>' : '-';
return '<tr>' +
'<td>' + escapeHtml(item.task_id) + '</td>' +
'<td>' + escapeHtml(item.username || '-') + '<div style="color:#888;font-size:12px;">ID ' + escapeHtml(item.user_id) + '</div></td>' +
'<td>' + escapeHtml(item.mode || '-') + '</td>' +
'<td>' + renderImageVideoStatus(item.status) + '</td>' +
'<td>' + escapeHtml(item.coze_status || '-') + '</td>' +
'<td title="' + escapeHtml(item.coze_execute_id || '') + '">' + escapeHtml(item.coze_execute_id || '-').slice(0, 18) + '</td>' +
'<td>' + videoLink + '</td>' +
'<td>' + escapeHtml(item.archive_status || '-') + '</td>' +
'<td>' + escapeHtml(item.submitted_at || '-') + '</td>' +
'<td><button class="btn btn-sm" data-image-video-detail="' + escapeHtml(item.task_id) + '">详情</button></td>' +
'</tr>';
}).join('');
}
renderPagination('imageVideoTaskPagination', res.total, res.page, res.page_size, loadImageVideoTasks);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">请求失败</td></tr>';
});
}
function formatImageVideoJson(value) {
try { return JSON.stringify(value == null ? {} : value, null, 2); }
catch (error) { return String(value || ''); }
}
function renderImageVideoTaskDetail(item) {
var videos = item.videos || [];
var videoHtml = videos.length ? videos.map(function (video, index) {
var displayUrl = safeAdminUrl(video.display_url);
var sourceUrl = safeAdminUrl(video.source_url);
var archivedUrl = safeAdminUrl(video.archived_url);
return '<div class="image-video-video-item">' +
'<strong>视频 ' + (index + 1) + '</strong>' +
(displayUrl ? '<video src="' + escapeHtml(displayUrl) + '" controls playsinline preload="metadata"></video>' : '<p class="empty-tip">无可用视频地址</p>') +
'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap;">' +
(archivedUrl ? '<a href="' + escapeHtml(archivedUrl) + '" target="_blank" rel="noreferrer">OSS 视频</a>' : '') +
(sourceUrl ? '<a href="' + escapeHtml(sourceUrl) + '" target="_blank" rel="noreferrer">Coze 原始视频</a>' : '') +
'</div><div style="font-size:12px;color:#777;margin-top:6px;">归档:' + escapeHtml(video.archive_status || '-') +
(video.archive_error ? ' · ' + escapeHtml(video.archive_error) : '') + '</div></div>';
}).join('') : '<p class="empty-tip">当前没有解析到视频地址</p>';
var debugUrl = safeAdminUrl(item.debug_url);
return '<div class="image-video-detail-meta">' +
'<div><span>任务 ID</span>' + escapeHtml(item.task_id) + '</div>' +
'<div><span>用户</span>' + escapeHtml(item.username || '-') + '' + escapeHtml(item.user_id) + '</div>' +
'<div><span>任务模式</span>' + escapeHtml(item.mode || '-') + '</div>' +
'<div><span>状态</span>' + escapeHtml(item.status || '-') + ' / ' + escapeHtml(item.coze_status || '-') + '</div>' +
'<div><span>Coze 执行 ID</span>' + escapeHtml(item.coze_execute_id || '-') + '</div>' +
'<div><span>提交时间</span>' + escapeHtml(item.submitted_at || '-') + '</div>' +
'<div><span>完成时间</span>' + escapeHtml(item.completed_at || '-') + '</div>' +
'<div><span>归档状态</span>' + escapeHtml(item.archive_status || '-') + '</div>' +
'</div>' +
(debugUrl ? '<p><strong>调试链接:</strong><a href="' + escapeHtml(debugUrl) + '" target="_blank" rel="noreferrer">' + escapeHtml(debugUrl) + '</a></p>' : '') +
(item.error_message ? '<p class="msg err">任务错误:' + escapeHtml(item.error_message) + '</p>' : '') +
(item.archive_error ? '<p class="msg err">归档错误:' + escapeHtml(item.archive_error) + '</p>' : '') +
'<div class="image-video-video-grid">' + videoHtml + '</div>' +
'<details class="image-video-json"><summary>请求参数(密钥已脱敏)</summary><pre>' + escapeHtml(formatImageVideoJson(item.request)) + '</pre></details>' +
'<details class="image-video-json"><summary>Coze 首次提交响应</summary><pre>' + escapeHtml(formatImageVideoJson(item.submit_response)) + '</pre></details>' +
'<details class="image-video-json"><summary>Coze 最终响应</summary><pre>' + escapeHtml(formatImageVideoJson(item.result)) + '</pre></details>';
}
function openImageVideoTaskDetail(taskId) {
var content = document.getElementById('imageVideoTaskDetailContent');
content.innerHTML = '<p class="empty-tip">加载中...</p>';
document.getElementById('imageVideoTaskDetailModal').classList.add('show');
fetch('/api/admin/image-video-tasks/' + encodeURIComponent(taskId))
.then(function (r) { return r.json(); })
.then(function (res) {
content.innerHTML = res.success ? renderImageVideoTaskDetail(res.item || {}) : '<p class="msg err">' + escapeHtml(res.error || '加载失败') + '</p>';
})
.catch(function () { content.innerHTML = '<p class="msg err">请求失败</p>'; });
}
document.getElementById('btnFilterImageVideoTasks').onclick = function () { loadImageVideoTasks(1); };
document.getElementById('btnResetImageVideoTasks').onclick = function () {
['imageVideoFilterUserId', 'imageVideoFilterUsername', 'imageVideoFilterStatus', 'imageVideoFilterExecuteId', 'imageVideoFilterFrom', 'imageVideoFilterTo']
.forEach(function (id) { document.getElementById(id).value = ''; });
loadImageVideoTasks(1);
};
document.getElementById('imageVideoTaskListBody').onclick = function (event) {
var button = event.target.closest('[data-image-video-detail]');
if (button) openImageVideoTaskDetail(button.dataset.imageVideoDetail);
};
document.getElementById('btnCloseImageVideoTaskDetail').onclick = function () {
document.getElementById('imageVideoTaskDetailModal').classList.remove('show');
document.getElementById('imageVideoTaskDetailContent').innerHTML = '';
};
var historyPage = 1, historyPageSize = 15;
function toSqlDatetime(val) {
if (!val) return '';

View File

@@ -0,0 +1,119 @@
import json
import unittest
from datetime import datetime
from unittest.mock import patch
from app import app
from blueprints import admin_api as admin_module
class FakeCursor:
def __init__(self, rows):
self.rows = rows
self.last_sql = ''
self.executions = []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, sql, params=()):
self.last_sql = sql
self.executions.append((sql, params))
def fetchall(self):
return self.rows
def fetchone(self):
if 'COUNT(*)' in self.last_sql:
return {'total': len(self.rows)}
return self.rows[0] if self.rows else None
class FakeConnection:
def __init__(self, rows):
self.cursor_value = FakeCursor(rows)
def cursor(self):
return self.cursor_value
def close(self):
pass
class ImageVideoAdminTest(unittest.TestCase):
def setUp(self):
app.config.update(TESTING=True, SECRET_KEY='test')
self.client = app.test_client()
def test_masks_nested_secrets_and_resolves_mode(self):
payload = {
'parameters': {
'api_key_info': {'ai_conductor_key': 'secret', 't8_video_key': 'secret-2'},
'video_info': {'mode': '2'},
},
'token': 'secret-3',
}
masked = admin_module._mask_image_video_secrets(payload)
self.assertEqual('******', masked['parameters']['api_key_info']['ai_conductor_key'])
self.assertEqual('******', masked['parameters']['api_key_info']['t8_video_key'])
self.assertEqual('******', masked['token'])
self.assertEqual('视频复刻', admin_module._image_video_mode(payload))
def test_admin_access_clause_is_limited_to_self_and_direct_users(self):
sql, params = admin_module._image_video_access_sql('admin', {'id': 17})
self.assertIn('created_by_id = %s', sql)
self.assertEqual([17, 17], params)
def test_list_api_applies_pagination_and_returns_task_summary(self):
row = {
'id': 91,
'user_id': 21,
'username': 'demo',
'status': 'SUCCESS',
'request_json': json.dumps({'parameters': {'video_info': {'mode': '1'}}}),
'submit_response_json': '{}',
'result_json': '{}',
'video_urls_json': json.dumps(['https://coze.example/video.mp4']),
'debug_url': 'https://coze.example/debug',
'archived_videos_json': json.dumps([{
'sourceUrl': 'https://coze.example/video.mp4',
'url': 'https://oss.example/video.mp4',
'objectKey': 'result/image_video/1/video.mp4',
'status': 'SUCCESS',
}]),
'archive_status': 'SUCCESS',
'archive_error': None,
'archive_attempt_count': 1,
'archived_at': datetime(2026, 7, 15, 12, 5),
'error_message': None,
'coze_execute_id': 'exec-91',
'coze_status': 'SUCCESS',
'submitted_at': datetime(2026, 7, 15, 12, 0),
'completed_at': datetime(2026, 7, 15, 12, 4),
}
connection = FakeConnection([row])
with self.client.session_transaction() as session:
session['user_id'] = 17
with patch('utils.auth.is_session_user_valid', return_value=True), \
patch('utils.auth.get_current_admin_role', return_value=('admin', {'id': 17})), \
patch.object(admin_module, '_ensure_admin_menu_access', return_value=('admin', {'id': 17}, None)), \
patch.object(admin_module, 'get_db', return_value=connection):
response = self.client.get('/api/admin/image-video-tasks?page=2&page_size=20&status=success')
self.assertEqual(200, response.status_code)
data = response.get_json()
self.assertTrue(data['success'])
self.assertEqual(2, data['page'])
self.assertEqual('图生视频', data['items'][0]['mode'])
self.assertEqual('https://oss.example/video.mp4', data['items'][0]['video_url'])
list_sql, list_params = connection.cursor_value.executions[0]
self.assertIn("t.task_type = 'IMAGE_VIDEO_WORKFLOW'", list_sql)
self.assertIn('created_by_id = %s', list_sql)
self.assertEqual((17, 17, 'SUCCESS', 20, 20), list_params)
if __name__ == '__main__':
unittest.main()

View File

@@ -764,6 +764,21 @@
.column-permission-select option {
padding: 4px 0;
}
.image-video-table-wrap { overflow-x: auto; }
.image-video-table { min-width: 1260px; }
.image-video-status { display:inline-block; padding:3px 8px; border-radius:4px; background:#eef1f4; font-size:12px; }
.image-video-status.SUCCESS { color:#14733b; background:#e5f5eb; }
.image-video-status.FAILED { color:#a82d2d; background:#fbe9e9; }
.image-video-detail-modal { width:min(1080px, calc(100vw - 48px)); max-height:88vh; overflow:auto; }
.image-video-detail-meta { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:14px 0; }
.image-video-detail-meta div { padding:10px; background:#f6f7f8; border-radius:4px; min-width:0; }
.image-video-detail-meta span { display:block; color:#777; font-size:12px; margin-bottom:4px; }
.image-video-video-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:14px; }
.image-video-video-item { border:1px solid #ddd; border-radius:6px; padding:10px; }
.image-video-video-item video { display:block; width:100%; aspect-ratio:16/9; background:#111; }
.image-video-json { margin-top:14px; }
.image-video-json pre { max-height:300px; overflow:auto; padding:12px; background:#172026; color:#e8edf0; border-radius:4px; white-space:pre-wrap; word-break:break-word; font-size:12px; }
@media (max-width: 760px) { .image-video-detail-meta { grid-template-columns:repeat(2,minmax(0,1fr)); } }
</style>
</head>
@@ -1502,6 +1517,35 @@
</div>
</div>
<div id="panel-image-video-tasks" class="tab-panel">
<div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">视频任务筛选</h3>
<div class="form-row">
<div class="form-group" style="min-width:130px;"><label>用户 ID</label><input type="number" id="imageVideoFilterUserId" min="1"></div>
<div class="form-group" style="min-width:150px;"><label>用户名</label><input type="text" id="imageVideoFilterUsername" placeholder="模糊搜索"></div>
<div class="form-group" style="min-width:130px;"><label>任务状态</label><select id="imageVideoFilterStatus"><option value="">全部</option><option value="PENDING">PENDING</option><option value="RUNNING">RUNNING</option><option value="WAITING">WAITING</option><option value="POLLING">POLLING</option><option value="SUCCESS">SUCCESS</option><option value="FAILED">FAILED</option></select></div>
<div class="form-group" style="min-width:180px;"><label>Coze 执行 ID</label><input type="text" id="imageVideoFilterExecuteId"></div>
<div class="form-group" style="min-width:170px;"><label>提交开始</label><input type="datetime-local" id="imageVideoFilterFrom"></div>
<div class="form-group" style="min-width:170px;"><label>提交结束</label><input type="datetime-local" id="imageVideoFilterTo"></div>
<button class="btn" id="btnFilterImageVideoTasks" type="button">查询</button>
<button class="btn btn-secondary" id="btnResetImageVideoTasks" type="button">重置</button>
</div>
</div>
<div class="panel-box">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;gap:12px;">
<h3 style="font-size:15px;">视频任务记录</h3>
<span id="imageVideoTaskTotal" style="font-size:13px;color:#666;"></span>
</div>
<div class="image-video-table-wrap">
<table class="image-video-table">
<thead><tr><th>任务 ID</th><th>用户</th><th>模式</th><th>状态</th><th>Coze 状态</th><th>执行 ID</th><th>视频</th><th>归档</th><th>提交时间</th><th>操作</th></tr></thead>
<tbody id="imageVideoTaskListBody"></tbody>
</table>
</div>
<div class="pagination" id="imageVideoTaskPagination"></div>
</div>
</div>
<div id="panel-history" class="tab-panel">
<div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3>
@@ -1903,6 +1947,15 @@
</div>
</div>
</div>
<div class="modal-mask" id="imageVideoTaskDetailModal">
<div class="modal image-video-detail-modal">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;">
<h3>视频任务详情</h3>
<button class="btn btn-secondary btn-sm" id="btnCloseImageVideoTaskDetail" type="button">关闭</button>
</div>
<div id="imageVideoTaskDetailContent"></div>
</div>
</div>
<script src="/static/admin.js"></script>
</body>

View File

@@ -46,7 +46,10 @@
</div>
<div class="delivery-field">
<span class="delivery-field__label">模型</span>
<AiChoicePills v-model="currentWorkspace.model" :options="modelOptions" />
<el-select v-model="currentWorkspace.model" placeholder="请选择模型">
<el-option v-for="option in modelOptions" :key="option.value" :label="option.label"
:value="option.value" />
</el-select>
</div>
<div class="delivery-field">
<span class="delivery-field__label">视频时长</span>
@@ -107,7 +110,10 @@
</div>
<div class="delivery-field">
<span class="delivery-field__label">模型</span>
<AiChoicePills v-model="currentWorkspace.model" :options="modelOptions" />
<el-select v-model="currentWorkspace.model" placeholder="请选择模型">
<el-option v-for="option in modelOptions" :key="option.value" :label="option.label"
:value="option.value" />
</el-select>
</div>
<div class="delivery-field">
<span class="delivery-field__label">视频时长</span>
@@ -642,6 +648,7 @@ const modelOptions = [
{ label: 'seedance-2.0-mini', value: 'doubao-seedance-2.0-mini' },
{ label: 'seedance-2.0-fast', value: 'sdols-2.0-fast' },
{ label: 'seedance-2.0', value: 'sdols-2.0' },
{ label: 'gemini-omini', value: 'gemini-omini' },
]
const durationOptions = ['15', '30', '45', '60']
const sceneModeOptions = ['提示词微调', '上传场景图替换']
@@ -1261,7 +1268,7 @@ async function pollAssemblyResult(tab: WorkspaceTab, taskId: number) {
const result = task.result
const status = task.cozeStatus || task.status
const videoUrl = findVideoResultUrl(result)
const debugUrl = findTextByKeys(result, ['debug_url', 'debugUrl'])
const debugUrl = task.debugUrl || findTextByKeys(result, ['debug_url', 'debugUrl'])
if (task.cozeExecuteId) assembly.executeId = task.cozeExecuteId
if (videoUrl) assembly.videoUrl = videoUrl
if (debugUrl) assembly.debugUrl = debugUrl
@@ -1272,6 +1279,7 @@ async function pollAssemblyResult(tab: WorkspaceTab, taskId: number) {
poll_count: assembly.pollCount,
status,
video_url: videoUrl || assembly.videoUrl,
debug_url: debugUrl || assembly.debugUrl,
result,
}, null, 2)
if (isTerminalImageVideoTask(task)) {

View File

@@ -2670,6 +2670,7 @@ export interface ImageVideoAsyncTaskVo {
status: ImageVideoAsyncTaskStatus;
cozeExecuteId?: string;
cozeStatus?: string;
debugUrl?: string;
result?: unknown;
errorMessage?: string;
submittedAt?: string;