diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/ImageVideoProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/ImageVideoProperties.java index 8ef13c1..abf0d8a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/ImageVideoProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/ImageVideoProperties.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java index 95fbfbc..8ab8366 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java @@ -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()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java index 80deb27..ca2e087 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java @@ -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", diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index 431b1b3..3fecb82 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -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 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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoAsyncTaskEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoAsyncTaskEntity.java index 7b69d32..78d3520 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoAsyncTaskEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoAsyncTaskEntity.java @@ -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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoAsyncTaskVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoAsyncTaskVo.java index 0757476..841f2ca 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoAsyncTaskVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoAsyncTaskVo.java @@ -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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveService.java new file mode 100644 index 0000000..66954af --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveService.java @@ -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 VIDEO_KEYS = Set.of( + "videourl", "video", "outputvideourl", "resultvideourl" + ); + private static final Set 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 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 tasks = taskMapper.selectList( + new LambdaQueryWrapper() + .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() + .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 videoUrls = readStringList(task.getVideoUrlsJson()); + List> archived = readArchivedVideos(task.getArchivedVideosJson()); + Map> existingBySource = new LinkedHashMap<>(); + for (Map item : archived) { + existingBySource.put(String.valueOf(item.getOrDefault("sourceUrl", "")), item); + } + + String firstError = ""; + List> updated = new ArrayList<>(); + for (String sourceUrl : videoUrls) { + Map 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 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 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 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 findUrls(Object value, Set keys) { + LinkedHashSet result = new LinkedHashSet<>(); + collectUrls(value, keys, result, new ObjectMapper()); + return new ArrayList<>(result); + } + + private static void collectUrls(Object value, Set keys, Set 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 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 keys) { + List 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 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> 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); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java index 65a02ab..614e20a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java @@ -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 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 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()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java index 32edd6b..5fb5634 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java @@ -42,7 +42,7 @@ public class ImageVideoCozeService { "fullContent", "full_content", "copywriting", "script", "output", "text", "result" ); private static final Set 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; diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index b6ee3f6..f268058 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -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:} diff --git a/backend-java/src/main/resources/db/V73__image_video_task_admin.sql b/backend-java/src/main/resources/db/V73__image_video_task_admin.sql new file mode 100644 index 0000000..6e3066a --- /dev/null +++ b/backend-java/src/main/resources/db/V73__image_video_task_admin.sql @@ -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' +); + diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java index 4ca45a9..d40bc8d 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java @@ -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)); + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveServiceTest.java new file mode 100644 index 0000000..5b540b6 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveServiceTest.java @@ -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 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 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); + } + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java index ddb10b9..a178e04 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java @@ -33,6 +33,8 @@ class ImageVideoAsyncTaskServiceTest { mock(OssStorageService.class), new ObjectMapper()); Map 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 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 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 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; + } } diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 96fead3..3e37695 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -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/') +@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(): diff --git a/backend/static/admin.js b/backend/static/admin.js index cff93af..0693169 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -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 '' + escapeHtml(status) + ''; + } + 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 = '加载中...'; + fetch('/api/admin/image-video-tasks?' + buildImageVideoTaskQuery(imageVideoTaskPage)) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + tbody.innerHTML = '加载失败:' + escapeHtml(res.error || '') + ''; + return; + } + var items = res.items || []; + document.getElementById('imageVideoTaskTotal').textContent = '共 ' + (res.total || 0) + ' 条'; + if (!items.length) { + tbody.innerHTML = '暂无视频任务'; + } else { + tbody.innerHTML = items.map(function (item) { + var videoUrl = safeAdminUrl(item.video_url); + var videoLink = videoUrl ? '查看视频' : '-'; + return '' + + '' + escapeHtml(item.task_id) + '' + + '' + escapeHtml(item.username || '-') + '
ID ' + escapeHtml(item.user_id) + '
' + + '' + escapeHtml(item.mode || '-') + '' + + '' + renderImageVideoStatus(item.status) + '' + + '' + escapeHtml(item.coze_status || '-') + '' + + '' + escapeHtml(item.coze_execute_id || '-').slice(0, 18) + '' + + '' + videoLink + '' + + '' + escapeHtml(item.archive_status || '-') + '' + + '' + escapeHtml(item.submitted_at || '-') + '' + + '' + + ''; + }).join(''); + } + renderPagination('imageVideoTaskPagination', res.total, res.page, res.page_size, loadImageVideoTasks); + }) + .catch(function () { + tbody.innerHTML = '请求失败'; + }); + } + 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 '
' + + '视频 ' + (index + 1) + '' + + (displayUrl ? '' : '

无可用视频地址

') + + '
' + + (archivedUrl ? 'OSS 视频' : '') + + (sourceUrl ? 'Coze 原始视频' : '') + + '
归档:' + escapeHtml(video.archive_status || '-') + + (video.archive_error ? ' · ' + escapeHtml(video.archive_error) : '') + '
'; + }).join('') : '

当前没有解析到视频地址

'; + var debugUrl = safeAdminUrl(item.debug_url); + return '
' + + '
任务 ID' + escapeHtml(item.task_id) + '
' + + '
用户' + escapeHtml(item.username || '-') + '(' + escapeHtml(item.user_id) + ')
' + + '
任务模式' + escapeHtml(item.mode || '-') + '
' + + '
状态' + escapeHtml(item.status || '-') + ' / ' + escapeHtml(item.coze_status || '-') + '
' + + '
Coze 执行 ID' + escapeHtml(item.coze_execute_id || '-') + '
' + + '
提交时间' + escapeHtml(item.submitted_at || '-') + '
' + + '
完成时间' + escapeHtml(item.completed_at || '-') + '
' + + '
归档状态' + escapeHtml(item.archive_status || '-') + '
' + + '
' + + (debugUrl ? '

调试链接:' + escapeHtml(debugUrl) + '

' : '') + + (item.error_message ? '

任务错误:' + escapeHtml(item.error_message) + '

' : '') + + (item.archive_error ? '

归档错误:' + escapeHtml(item.archive_error) + '

' : '') + + '
' + videoHtml + '
' + + '
请求参数(密钥已脱敏)
' + escapeHtml(formatImageVideoJson(item.request)) + '
' + + '
Coze 首次提交响应
' + escapeHtml(formatImageVideoJson(item.submit_response)) + '
' + + '
Coze 最终响应
' + escapeHtml(formatImageVideoJson(item.result)) + '
'; + } + function openImageVideoTaskDetail(taskId) { + var content = document.getElementById('imageVideoTaskDetailContent'); + content.innerHTML = '

加载中...

'; + 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 || {}) : '

' + escapeHtml(res.error || '加载失败') + '

'; + }) + .catch(function () { content.innerHTML = '

请求失败

'; }); + } + 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 ''; diff --git a/backend/tests/test_image_video_admin.py b/backend/tests/test_image_video_admin.py new file mode 100644 index 0000000..4359fe0 --- /dev/null +++ b/backend/tests/test_image_video_admin.py @@ -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() diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 50f788e..745cf27 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -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)); } } @@ -1502,6 +1517,35 @@ +
+
+

视频任务筛选

+
+
+
+
+
+
+
+ + +
+
+
+
+

视频任务记录

+ +
+
+ + + +
任务 ID用户模式状态Coze 状态执行 ID视频归档提交时间操作
+
+ +
+
+

筛选条件

@@ -1903,6 +1947,15 @@
+ diff --git a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue index 1712ebd..3758f79 100644 --- a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue +++ b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue @@ -46,7 +46,10 @@
模型 - + + +
视频时长 @@ -107,7 +110,10 @@
模型 - + + +
视频时长 @@ -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)) { diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index a71dd1a..7e421df 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -2670,6 +2670,7 @@ export interface ImageVideoAsyncTaskVo { status: ImageVideoAsyncTaskStatus; cozeExecuteId?: string; cozeStatus?: string; + debugUrl?: string; result?: unknown; errorMessage?: string; submittedAt?: string;