diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java index 494b902..a026029 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java @@ -9,10 +9,11 @@ import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceListRequest import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceSynthesisRequest; import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoWorkflowResultRequest; import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoWorkflowRunRequest; -import com.nanri.aiimage.modules.imagevideo.model.vo.DouyinCopyVo; +import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoAsyncTaskVo; import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoMediaUploadVo; import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoSecretStatusVo; import com.nanri.aiimage.modules.imagevideo.service.ImageVideoCozeService; +import com.nanri.aiimage.modules.imagevideo.service.ImageVideoAsyncTaskService; import com.nanri.aiimage.modules.imagevideo.service.ImageVideoSecretService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -20,6 +21,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,8 +29,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import java.util.Map; - @RestController @RequiredArgsConstructor @RequestMapping("/api/image-video") @@ -37,6 +37,7 @@ public class ImageVideoController { private final ImageVideoCozeService imageVideoCozeService; private final ImageVideoSecretService imageVideoSecretService; + private final ImageVideoAsyncTaskService imageVideoAsyncTaskService; @GetMapping("/secrets") @Operation(summary = "查询视频密钥配置状态") @@ -52,20 +53,20 @@ public class ImageVideoController { @PostMapping("/douyin-copy") @Operation(summary = "抖音文案识别和仿写") - public ApiResponse douyinCopy(@Valid @RequestBody DouyinCopyRequest request) { - return ApiResponse.success(imageVideoCozeService.runDouyinCopy(request)); + public ApiResponse douyinCopy(@Valid @RequestBody DouyinCopyRequest request) { + return ApiResponse.success(imageVideoAsyncTaskService.submitDouyinCopy(request)); } @PostMapping("/workflow/run") @Operation(summary = "提交图生/复刻视频工作流") - public ApiResponse> runWorkflow(@Valid @RequestBody ImageVideoWorkflowRunRequest request) { - return ApiResponse.success(imageVideoCozeService.runImageVideoWorkflow(request.getUserId(), request.getParameters())); + public ApiResponse runWorkflow(@Valid @RequestBody ImageVideoWorkflowRunRequest request) { + return ApiResponse.success(imageVideoAsyncTaskService.submitWorkflow(request)); } @PostMapping("/workflow/result") @Operation(summary = "查询图生/复刻视频工作流异步结果") - public ApiResponse> workflowResult(@Valid @RequestBody ImageVideoWorkflowResultRequest request) { - return ApiResponse.success(imageVideoCozeService.getImageVideoWorkflowResult(request.getUserId(), request.getExecuteId())); + public ApiResponse workflowResult(@Valid @RequestBody ImageVideoWorkflowResultRequest request) { + return ApiResponse.success(imageVideoAsyncTaskService.submitWorkflowResult(request)); } @PostMapping("/media/upload") @@ -76,25 +77,33 @@ public class ImageVideoController { @PostMapping("/voice/list") @Operation(summary = "查看音色") - public ApiResponse> listVoices(@Valid @RequestBody ImageVideoVoiceListRequest request) { - return ApiResponse.success(imageVideoCozeService.runVoiceList(request.getUserId(), request.getName())); + public ApiResponse listVoices(@Valid @RequestBody ImageVideoVoiceListRequest request) { + return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceList(request)); } @PostMapping("/voice/delete") @Operation(summary = "删除音色") - public ApiResponse> deleteVoice(@Valid @RequestBody ImageVideoVoiceDeleteRequest request) { - return ApiResponse.success(imageVideoCozeService.runVoiceDelete(request.getUserId(), request.getVoiceId())); + public ApiResponse deleteVoice(@Valid @RequestBody ImageVideoVoiceDeleteRequest request) { + return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceDelete(request)); } @PostMapping("/voice/clone") @Operation(summary = "音色克隆") - public ApiResponse> cloneVoice(@Valid @RequestBody ImageVideoVoiceCloneRequest request) { - return ApiResponse.success(imageVideoCozeService.runVoiceClone(request.getUserId(), request.getName(), request.getAudioUrl(), request.getVideoUrl())); + public ApiResponse cloneVoice(@Valid @RequestBody ImageVideoVoiceCloneRequest request) { + return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceClone(request)); } @PostMapping("/voice/synthesis") @Operation(summary = "语音合成") - public ApiResponse> synthesizeVoice(@Valid @RequestBody ImageVideoVoiceSynthesisRequest request) { - return ApiResponse.success(imageVideoCozeService.runVoiceSynthesis(request.getUserId(), request.getText(), request.getVoiceId())); + public ApiResponse synthesizeVoice(@Valid @RequestBody ImageVideoVoiceSynthesisRequest request) { + return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceSynthesis(request)); + } + + @GetMapping("/tasks/{taskId}") + @Operation(summary = "Query image video async task") + public ApiResponse getTask( + @PathVariable Long taskId, + @RequestParam("user_id") Long userId) { + return ApiResponse.success(imageVideoAsyncTaskService.getTask(taskId, userId)); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoAsyncTaskMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoAsyncTaskMapper.java new file mode 100644 index 0000000..4cfdf53 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoAsyncTaskMapper.java @@ -0,0 +1,20 @@ +package com.nanri.aiimage.modules.imagevideo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoAsyncTaskEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface ImageVideoAsyncTaskMapper extends BaseMapper { + + @Update("UPDATE biz_image_video_async_task " + + "SET status = 'RUNNING', updated_at = NOW(), attempt_count = attempt_count + 1 " + + "WHERE id = #{taskId} AND status = 'PENDING'") + int claimPending(Long taskId); + + @Update("UPDATE biz_image_video_async_task " + + "SET status = 'POLLING', updated_at = NOW(), attempt_count = attempt_count + 1 " + + "WHERE id = #{taskId} AND status = 'WAITING'") + int claimWaiting(Long taskId); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoSecretSaveRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoSecretSaveRequest.java index 685c63f..ec0760c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoSecretSaveRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoSecretSaveRequest.java @@ -12,18 +12,21 @@ public class ImageVideoSecretSaveRequest { @Schema(description = "用户ID", example = "1") private Long userId; - @Schema(description = "文案工作流 api_key;首次配置或过期后必填", example = "sk-...") + @Schema(description = "爬虫密钥;首次配置或过期后必填", example = "sk-...") private String copyApiKey; private String t8Key; - @Schema(description = "音色工作流 api_key;首次配置或过期后必填", example = "sk-api-...") + @Schema(description = "T8密钥(国内);首次配置或过期后必填") + private String t8VideoKey; + + @Schema(description = "音频密钥;首次配置或过期后必填", example = "sk-api-...") private String voiceApiKey; - @Schema(description = "音色 group_id;首次配置或过期后必填", example = "2030928714635682107") + @Schema(description = "音频团队ID;首次配置或过期后必填", example = "2030928714635682107") private String voiceGroupId; @NotNull(message = "有效期不能为空") - @Schema(description = "有效期天数,只允许 1、7、30", example = "7") + @Schema(description = "有效期天数,只允许 1、7、30、0;0 表示永久", example = "7") private Integer expireDays; } 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 new file mode 100644 index 0000000..7b69d32 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoAsyncTaskEntity.java @@ -0,0 +1,29 @@ +package com.nanri.aiimage.modules.imagevideo.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_image_video_async_task") +public class ImageVideoAsyncTaskEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String taskType; + private String status; + private String requestJson; + private String resultJson; + private String errorMessage; + private String cozeExecuteId; + private String cozeStatus; + private Integer attemptCount; + private LocalDateTime submittedAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoSecretEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoSecretEntity.java index 70ce8ee..56ad564 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoSecretEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoSecretEntity.java @@ -16,6 +16,7 @@ public class ImageVideoSecretEntity { private Long userId; private String copyApiKey; private String t8Key; + private String t8VideoKey; private String voiceApiKey; private String voiceGroupId; private LocalDateTime expiresAt; 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 new file mode 100644 index 0000000..0757476 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoAsyncTaskVo.java @@ -0,0 +1,18 @@ +package com.nanri.aiimage.modules.imagevideo.model.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class ImageVideoAsyncTaskVo { + private Long taskId; + private String taskType; + private String status; + private String cozeExecuteId; + private String cozeStatus; + private Object result; + private String errorMessage; + private LocalDateTime submittedAt; + private LocalDateTime completedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoSecretStatusVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoSecretStatusVo.java index e222fa4..b5a21a4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoSecretStatusVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoSecretStatusVo.java @@ -21,29 +21,33 @@ public class ImageVideoSecretStatusVo { @Schema(description = "是否已经过期") private Boolean expired; - @Schema(description = "文案 key 是否已保存") + @Schema(description = "爬虫密钥是否已保存") private Boolean hasCopyApiKey; private Boolean hasT8Key; - @Schema(description = "音色 key 是否已保存") + private Boolean hasT8VideoKey; + + @Schema(description = "音频密钥是否已保存") private Boolean hasVoiceApiKey; - @Schema(description = "音色 group_id 是否已保存") + @Schema(description = "音频团队ID是否已保存") private Boolean hasVoiceGroupId; - @Schema(description = "脱敏后的文案 key") + @Schema(description = "脱敏后的爬虫密钥") private String copyApiKeyMasked; private String t8KeyMasked; - @Schema(description = "脱敏后的音色 key") + private String t8VideoKeyMasked; + + @Schema(description = "脱敏后的音频密钥") private String voiceApiKeyMasked; - @Schema(description = "脱敏后的音色 group_id") + @Schema(description = "脱敏后的音频团队ID") private String voiceGroupIdMasked; - @Schema(description = "用户选择的有效期天数,支持 1、7、30") + @Schema(description = "用户选择的有效期天数,支持 1、7、30、0;0 表示永久") private Integer expireDays; @Schema(description = "过期时间") 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 new file mode 100644 index 0000000..6612256 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java @@ -0,0 +1,475 @@ +package com.nanri.aiimage.modules.imagevideo.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoAsyncTaskMapper; +import com.nanri.aiimage.modules.imagevideo.model.dto.DouyinCopyRequest; +import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceCloneRequest; +import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceDeleteRequest; +import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceListRequest; +import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceSynthesisRequest; +import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoWorkflowResultRequest; +import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoWorkflowRunRequest; +import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoAsyncTaskEntity; +import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoAsyncTaskVo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Service +public class ImageVideoAsyncTaskService { + + private static final int DISPATCH_BATCH_SIZE = 20; + private static final int POLL_BATCH_SIZE = 50; + private static final Set TERMINAL_STATUSES = Set.of( + "SUCCESS", "SUCCEEDED", "COMPLETED", "DONE", "FINISHED", + "FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED" + ); + private static final Set FAILED_STATUSES = Set.of("FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED"); + private static final Set COZE_EXECUTION_STATUS_FIELDS = Set.of( + "execute_status", "executeStatus", "workflow_status", "workflowStatus", "status" + ); + private static final Set COZE_PRIMARY_STATUS_FIELDS = Set.of( + "execute_status", "executeStatus", "workflow_status", "workflowStatus" + ); + + private final ImageVideoAsyncTaskMapper taskMapper; + private final ImageVideoCozeService cozeService; + private final ImageVideoWorkflowConfigService workflowConfigService; + private final ObjectMapper objectMapper; + private final TaskExecutor cozeTaskExecutor; + + public ImageVideoAsyncTaskService( + ImageVideoAsyncTaskMapper taskMapper, + ImageVideoCozeService cozeService, + ImageVideoWorkflowConfigService workflowConfigService, + ObjectMapper objectMapper, + @Qualifier("cozeTaskExecutor") TaskExecutor cozeTaskExecutor) { + this.taskMapper = taskMapper; + this.cozeService = cozeService; + this.workflowConfigService = workflowConfigService; + this.objectMapper = objectMapper; + this.cozeTaskExecutor = cozeTaskExecutor; + } + + public ImageVideoAsyncTaskVo submitDouyinCopy(DouyinCopyRequest request) { + return submit(TaskType.DOUYIN_COPY, request.getUserId(), request); + } + + public ImageVideoAsyncTaskVo submitWorkflow(ImageVideoWorkflowRunRequest request) { + return submit(TaskType.IMAGE_VIDEO_WORKFLOW, request.getUserId(), request); + } + + public ImageVideoAsyncTaskVo submitWorkflowResult(ImageVideoWorkflowResultRequest request) { + return submit(TaskType.WORKFLOW_RESULT, request.getUserId(), request); + } + + public ImageVideoAsyncTaskVo submitVoiceList(ImageVideoVoiceListRequest request) { + return submit(TaskType.VOICE_LIST, request.getUserId(), request); + } + + public ImageVideoAsyncTaskVo submitVoiceDelete(ImageVideoVoiceDeleteRequest request) { + return submit(TaskType.VOICE_DELETE, request.getUserId(), request); + } + + public ImageVideoAsyncTaskVo submitVoiceClone(ImageVideoVoiceCloneRequest request) { + return submit(TaskType.VOICE_CLONE, request.getUserId(), request); + } + + public ImageVideoAsyncTaskVo submitVoiceSynthesis(ImageVideoVoiceSynthesisRequest request) { + return submit(TaskType.VOICE_SYNTHESIS, request.getUserId(), request); + } + + public ImageVideoAsyncTaskVo getTask(Long taskId, Long userId) { + ImageVideoAsyncTaskEntity task = taskMapper.selectOne(new LambdaQueryWrapper() + .eq(ImageVideoAsyncTaskEntity::getId, taskId) + .eq(ImageVideoAsyncTaskEntity::getUserId, userId)); + if (task == null) { + throw new BusinessException("Image video task not found"); + } + task = recoverFalseFailedTask(task); + return toVo(task); + } + + @Scheduled(fixedDelayString = "${aiimage.image-video.async-task-dispatch-delay-ms:1000}") + public void dispatchPendingTasks() { + List tasks = taskMapper.selectList(new LambdaQueryWrapper() + .eq(ImageVideoAsyncTaskEntity::getStatus, TaskStatus.PENDING.name()) + .orderByAsc(ImageVideoAsyncTaskEntity::getId) + .last("LIMIT " + DISPATCH_BATCH_SIZE)); + tasks.forEach(task -> cozeTaskExecutor.execute(() -> executeTask(task.getId()))); + } + + @Scheduled(fixedDelayString = "${aiimage.image-video.async-task-poll-delay-ms:5000}") + public void pollWaitingTasks() { + List tasks = taskMapper.selectList(new LambdaQueryWrapper() + .eq(ImageVideoAsyncTaskEntity::getStatus, TaskStatus.WAITING.name()) + .orderByAsc(ImageVideoAsyncTaskEntity::getUpdatedAt) + .last("LIMIT " + POLL_BATCH_SIZE)); + tasks.forEach(task -> cozeTaskExecutor.execute(() -> pollTask(task.getId()))); + } + + private ImageVideoAsyncTaskVo submit(TaskType type, Long userId, Object payload) { + if (userId == null || userId <= 0) { + throw new BusinessException("userId is required"); + } + LocalDateTime now = LocalDateTime.now(); + ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity(); + task.setUserId(userId); + task.setTaskType(type.name()); + task.setStatus(TaskStatus.PENDING.name()); + task.setRequestJson(writeJson(payload)); + task.setAttemptCount(0); + task.setSubmittedAt(now); + task.setCreatedAt(now); + task.setUpdatedAt(now); + taskMapper.insert(task); + cozeTaskExecutor.execute(() -> executeTask(task.getId())); + return toVo(task); + } + + private void executeTask(Long taskId) { + if (taskMapper.claimPending(taskId) != 1) { + return; + } + ImageVideoAsyncTaskEntity task = taskMapper.selectById(taskId); + if (task == null) { + return; + } + try { + TaskType type = TaskType.valueOf(task.getTaskType()); + if (type == TaskType.WORKFLOW_RESULT) { + ImageVideoWorkflowResultRequest request = readJson(task.getRequestJson(), ImageVideoWorkflowResultRequest.class); + waitForCoze(task, request.getExecuteId(), ""); + return; + } + + Map response = submitToCoze(type, task.getRequestJson()); + String executeId = findText(response, Set.of("execute_id", "executeId")); + String cozeStatus = resolveCozeExecutionStatus(response); + if (!executeId.isBlank() && !TERMINAL_STATUSES.contains(cozeStatus)) { + waitForCoze(task, executeId, cozeStatus); + return; + } + completeTask(task, transformResult(type, response), cozeStatus); + } catch (Exception ex) { + failTask(task, ex); + } + } + + private void pollTask(Long taskId) { + if (taskMapper.claimWaiting(taskId) != 1) { + return; + } + ImageVideoAsyncTaskEntity task = taskMapper.selectById(taskId); + if (task == null) { + return; + } + if (task.getSubmittedAt() != null && task.getSubmittedAt().plusHours(1).isBefore(LocalDateTime.now())) { + failTask(task, new BusinessException("Coze task exceeded the one-hour polling limit")); + return; + } + try { + TaskType type = TaskType.valueOf(task.getTaskType()); + String workflowId = workflowIdFor(type); + Map result = cozeService.getWorkflowResult(task.getUserId(), workflowId, task.getCozeExecuteId()); + String cozeStatus = resolveCozeExecutionStatus(result); + if (TERMINAL_STATUSES.contains(cozeStatus)) { + if (FAILED_STATUSES.contains(cozeStatus)) { + task.setCozeStatus(cozeStatus); + failTask(task, new BusinessException("Coze workflow finished with status " + cozeStatus)); + } else { + completeTask(task, transformResult(type, result), cozeStatus); + } + return; + } + task.setStatus(TaskStatus.WAITING.name()); + task.setCozeStatus(cozeStatus); + task.setUpdatedAt(LocalDateTime.now()); + taskMapper.updateById(task); + } catch (Exception ex) { + // Coze history calls are retried by the next polling cycle until the task's overall deadline. + task.setStatus(TaskStatus.WAITING.name()); + task.setErrorMessage(truncate(messageOf(ex))); + task.setUpdatedAt(LocalDateTime.now()); + taskMapper.updateById(task); + log.warn("[image-video] async task poll failed taskId={} type={} error={}", + task.getId(), task.getTaskType(), messageOf(ex)); + } + } + + private Map submitToCoze(TaskType type, String requestJson) { + return switch (type) { + case DOUYIN_COPY -> cozeService.submitDouyinCopy(readJson(requestJson, DouyinCopyRequest.class)); + case IMAGE_VIDEO_WORKFLOW -> { + ImageVideoWorkflowRunRequest request = readJson(requestJson, ImageVideoWorkflowRunRequest.class); + yield cozeService.runImageVideoWorkflow(request.getUserId(), request.getParameters()); + } + case VOICE_LIST -> { + ImageVideoVoiceListRequest request = readJson(requestJson, ImageVideoVoiceListRequest.class); + yield cozeService.runVoiceList(request.getUserId(), request.getName()); + } + case VOICE_DELETE -> { + ImageVideoVoiceDeleteRequest request = readJson(requestJson, ImageVideoVoiceDeleteRequest.class); + yield cozeService.runVoiceDelete(request.getUserId(), request.getVoiceId()); + } + case VOICE_CLONE -> { + ImageVideoVoiceCloneRequest request = readJson(requestJson, ImageVideoVoiceCloneRequest.class); + yield cozeService.runVoiceClone(request.getUserId(), request.getName(), request.getAudioUrl(), request.getVideoUrl()); + } + case VOICE_SYNTHESIS -> { + ImageVideoVoiceSynthesisRequest request = readJson(requestJson, ImageVideoVoiceSynthesisRequest.class); + yield cozeService.runVoiceSynthesis(request.getUserId(), request.getText(), request.getVoiceId()); + } + case WORKFLOW_RESULT -> throw new IllegalStateException("Workflow result tasks do not submit a workflow"); + }; + } + + private String workflowIdFor(TaskType type) { + return switch (type) { + case DOUYIN_COPY -> workflowConfigService.douyinCopyWorkflowId(); + case IMAGE_VIDEO_WORKFLOW, WORKFLOW_RESULT -> workflowConfigService.imageVideoWorkflowId(); + case VOICE_LIST -> workflowConfigService.voiceListWorkflowId(); + case VOICE_DELETE -> workflowConfigService.voiceDeleteWorkflowId(); + case VOICE_CLONE -> workflowConfigService.voiceCloneWorkflowId(); + case VOICE_SYNTHESIS -> workflowConfigService.voiceSynthesisWorkflowId(); + }; + } + + private Object transformResult(TaskType type, Map result) { + return type == TaskType.DOUYIN_COPY ? cozeService.parseDouyinCopyResult(result) : result; + } + + private void waitForCoze(ImageVideoAsyncTaskEntity task, String executeId, String cozeStatus) { + task.setStatus(TaskStatus.WAITING.name()); + task.setCozeExecuteId(executeId); + task.setCozeStatus(normalizeStatus(cozeStatus)); + task.setErrorMessage(null); + task.setUpdatedAt(LocalDateTime.now()); + taskMapper.updateById(task); + } + + private void completeTask(ImageVideoAsyncTaskEntity task, Object result, String cozeStatus) { + task.setStatus(TaskStatus.SUCCESS.name()); + task.setResultJson(writeJson(result)); + task.setCozeStatus(normalizeStatus(cozeStatus)); + task.setErrorMessage(null); + task.setCompletedAt(LocalDateTime.now()); + task.setUpdatedAt(task.getCompletedAt()); + taskMapper.updateById(task); + } + + private void failTask(ImageVideoAsyncTaskEntity task, Exception ex) { + task.setStatus(TaskStatus.FAILED.name()); + task.setErrorMessage(truncate(messageOf(ex))); + task.setCompletedAt(LocalDateTime.now()); + task.setUpdatedAt(task.getCompletedAt()); + taskMapper.updateById(task); + log.warn("[image-video] async task failed taskId={} type={} error={}", + task.getId(), task.getTaskType(), messageOf(ex)); + } + + private ImageVideoAsyncTaskEntity recoverFalseFailedTask(ImageVideoAsyncTaskEntity task) { + if (!TaskStatus.FAILED.name().equals(task.getStatus())) { + return task; + } + if (task.getCozeExecuteId() == null || task.getCozeExecuteId().isBlank()) { + return task; + } + String storedCozeStatus = normalizeStatus(task.getCozeStatus()); + if (storedCozeStatus.isBlank() || TERMINAL_STATUSES.contains(storedCozeStatus)) { + return task; + } + String errorMessage = task.getErrorMessage() == null ? "" : task.getErrorMessage(); + if (!errorMessage.startsWith("Coze workflow finished with status ")) { + return task; + } + task.setStatus(TaskStatus.WAITING.name()); + task.setErrorMessage(null); + task.setCompletedAt(null); + task.setUpdatedAt(LocalDateTime.now()); + taskMapper.updateById(task); + log.info("[image-video] recovered false failed async task taskId={} cozeStatus={}", + task.getId(), storedCozeStatus); + return task; + } + + private ImageVideoAsyncTaskVo toVo(ImageVideoAsyncTaskEntity task) { + ImageVideoAsyncTaskVo vo = new ImageVideoAsyncTaskVo(); + vo.setTaskId(task.getId()); + vo.setTaskType(task.getTaskType()); + vo.setStatus(task.getStatus()); + vo.setCozeExecuteId(task.getCozeExecuteId()); + vo.setCozeStatus(task.getCozeStatus()); + vo.setResult(readResult(task.getResultJson())); + vo.setErrorMessage(task.getErrorMessage()); + vo.setSubmittedAt(task.getSubmittedAt()); + vo.setCompletedAt(task.getCompletedAt()); + return vo; + } + + private T readJson(String json, Class type) { + try { + return objectMapper.readValue(json, type); + } catch (Exception ex) { + throw new BusinessException("Image video task payload is invalid", ex); + } + } + + private Object readResult(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception ex) { + return json; + } + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException("Image video task serialization failed", ex); + } + } + + @SuppressWarnings("unchecked") + private String findText(Object value, Set names) { + if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + Object child = entry.getValue(); + if (names.stream().anyMatch(name -> name.equalsIgnoreCase(key)) && child != null) { + String text = String.valueOf(child).trim(); + if (!text.isBlank()) { + return text; + } + } + } + for (Object child : map.values()) { + String nested = findText(child, names); + if (!nested.isBlank()) { + return nested; + } + } + } else if (value instanceof Collection collection) { + for (Object child : collection) { + String nested = findText(child, names); + if (!nested.isBlank()) { + return nested; + } + } + } else if (value instanceof String text) { + String trimmed = text.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return findText(objectMapper.readValue(trimmed, Object.class), names); + } catch (Exception ignored) { + return ""; + } + } + } + return ""; + } + + private String resolveCozeExecutionStatus(Object value) { + Object data = childValue(value, "data"); + String dataPrimaryStatus = findDirectText(parsePossiblyJson(data), COZE_PRIMARY_STATUS_FIELDS); + if (!dataPrimaryStatus.isBlank()) { + return normalizeStatus(dataPrimaryStatus); + } + String directPrimaryStatus = findDirectText(value, COZE_PRIMARY_STATUS_FIELDS); + if (!directPrimaryStatus.isBlank()) { + return normalizeStatus(directPrimaryStatus); + } + String dataStatus = findDirectText(parsePossiblyJson(data), COZE_EXECUTION_STATUS_FIELDS); + if (!dataStatus.isBlank()) { + return normalizeStatus(dataStatus); + } + return normalizeStatus(findDirectText(value, COZE_EXECUTION_STATUS_FIELDS)); + } + + private Object childValue(Object value, String name) { + if (!(value instanceof Map map)) { + return null; + } + for (Map.Entry entry : map.entrySet()) { + if (name.equalsIgnoreCase(String.valueOf(entry.getKey()))) { + return entry.getValue(); + } + } + return null; + } + + private String findDirectText(Object value, Set names) { + Object parsed = parsePossiblyJson(value); + if (!(parsed instanceof Map map)) { + return ""; + } + for (Map.Entry entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + Object child = entry.getValue(); + if (names.stream().anyMatch(name -> name.equalsIgnoreCase(key)) && child != null) { + String text = String.valueOf(child).trim(); + if (!text.isBlank()) { + return text; + } + } + } + return ""; + } + + private Object parsePossiblyJson(Object value) { + if (value instanceof String text) { + String trimmed = text.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return objectMapper.readValue(trimmed, Object.class); + } catch (Exception ignored) { + return value; + } + } + } + return value; + } + + private String normalizeStatus(String value) { + return value == null ? "" : value.trim().toUpperCase(); + } + + private String messageOf(Exception ex) { + String message = ex.getMessage(); + return message == null || message.isBlank() ? ex.getClass().getSimpleName() : message; + } + + private String truncate(String value) { + return value.length() <= 1000 ? value : value.substring(0, 1000); + } + + private enum TaskStatus { + PENDING, RUNNING, WAITING, POLLING, SUCCESS, FAILED + } + + private enum TaskType { + DOUYIN_COPY, + IMAGE_VIDEO_WORKFLOW, + WORKFLOW_RESULT, + VOICE_LIST, + VOICE_DELETE, + VOICE_CLONE, + VOICE_SYNTHESIS + } +} 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 ea33c35..32edd6b 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 @@ -52,7 +52,7 @@ public class ImageVideoCozeService { private final ObjectMapper objectMapper; private volatile HttpClient sharedHttpClient; - public DouyinCopyVo runDouyinCopy(DouyinCopyRequest request) { + public Map submitDouyinCopy(DouyinCopyRequest request) { if (request == null) { throw new BusinessException("请求参数不能为空"); } @@ -69,8 +69,8 @@ public class ImageVideoCozeService { firstNonBlank(request.getApiKey(), secret.copyApiKey()), firstNonBlank(request.getT8Key(), secret.t8Key()), secret.cozeToken(), - workflowConfigService.douyinCopyWorkflowId()); - return parseDouyinCopyResponse(responseText); + workflowConfigService.douyinCopyWorkflowId(), true); + return parseCozeMap("douyin copy", responseText); } public Map runImageVideoWorkflow(Long userId, Map parameters) { @@ -94,13 +94,18 @@ public class ImageVideoCozeService { String aiConductorKey = firstNonBlank(asText(apiKeyInfo.get("ai_conductor_key")), asText(resolvedParameters.get("api_key")), secret.copyApiKey()); String t8starKey = firstNonBlank(asText(apiKeyInfo.get("t8star_key")), secret.t8Key()); + String t8VideoKey = firstNonBlank(asText(apiKeyInfo.get("t8_video_key")), secret.t8VideoKey()); if (!hasText(aiConductorKey)) { throw new BusinessException("图生视频 ai_conductor_key 未配置"); } if (!hasText(t8starKey)) { throw new BusinessException("图生视频 t8star_key 未配置"); } + if (!hasText(t8VideoKey)) { + throw new BusinessException("图生视频 t8_video_key 未配置"); + } apiKeyInfo.put("t8star_key", t8starKey); + apiKeyInfo.put("t8_video_key", t8VideoKey); apiKeyInfo.put("ai_conductor_key", aiConductorKey); resolvedParameters.put("api_key_info", apiKeyInfo); resolvedParameters.remove("api_key"); @@ -150,8 +155,27 @@ public class ImageVideoCozeService { if (!"1".equals(audioType) && !"2".equals(audioType)) { throw new BusinessException("不支持的音频类型: " + audioType); } - normalizedAudioInfo.put("audio_url", firstNonBlank(asText(normalizedAudioInfo.get("audio_url")))); + String audioMode = firstNonBlank(asText(normalizedAudioInfo.get("mode")), "1"); + if (!"1".equals(audioMode) && !"2".equals(audioMode) && !"3".equals(audioMode)) { + throw new BusinessException("不支持的音频模式: " + audioMode); + } + String audioUrl = firstNonBlank(asText(normalizedAudioInfo.get("audio_url"))); + String bgmUrl = firstNonBlank(asText(normalizedAudioInfo.get("bgm_url"))); + String voiceName = firstNonBlank(asText(normalizedAudioInfo.get("voice_name"))); + if ("1".equals(audioMode)) { + bgmUrl = ""; + } else if ("2".equals(audioMode)) { + audioUrl = ""; + voiceName = ""; + } else { + bgmUrl = ""; + voiceName = ""; + } + normalizedAudioInfo.put("audio_url", audioUrl); normalizedAudioInfo.put("type", Integer.valueOf(audioType)); + normalizedAudioInfo.put("bgm_url", bgmUrl); + normalizedAudioInfo.put("mode", Integer.valueOf(audioMode)); + normalizedAudioInfo.put("voice_name", voiceName); parameters.put("audio_info", normalizedAudioInfo); } @@ -178,7 +202,7 @@ public class ImageVideoCozeService { return text.startsWith("{") && text.endsWith("}"); } - public Map getImageVideoWorkflowResult(Long userId, String executeId) { + public Map getWorkflowResult(Long userId, String workflowId, String executeId) { String resolvedExecuteId = firstNonBlank(executeId); if (!hasText(resolvedExecuteId)) { throw new BusinessException("execute_id cannot be blank"); @@ -188,13 +212,13 @@ public class ImageVideoCozeService { if (!hasText(cozeToken)) { throw new BusinessException("图生视频 Coze token 未配置"); } - String workflowId = workflowConfigService.imageVideoWorkflowId(); - if (!hasText(workflowId)) { + String resolvedWorkflowId = firstNonBlank(workflowId); + if (!hasText(resolvedWorkflowId)) { throw new BusinessException("image video workflow_id 未配置"); } String path = firstNonBlank(properties.getCozeWorkflowHistoryPath(), "/v1/workflows/{workflow_id}/run_histories/{execute_id}") - .replace("{workflow_id}", workflowId) + .replace("{workflow_id}", resolvedWorkflowId) .replace("{execute_id}", resolvedExecuteId); String endpoint = joinUrl(properties.getCozeBaseUrl(), path); String responseText = getCozeJson("image video history", endpoint, cozeToken); @@ -241,7 +265,7 @@ public class ImageVideoCozeService { parameters.put("api_key", secret.voiceApiKey()); parameters.put("name", firstNonBlank(name)); parameters.put("user_id", userId); - return postWorkflowParameters("voice list", workflowConfigService.voiceListWorkflowId(), parameters, secret.cozeToken(), false); + return postWorkflowParameters("voice list", workflowConfigService.voiceListWorkflowId(), parameters, secret.cozeToken(), true); } public Map runVoiceDelete(Long userId, String voiceId) { @@ -251,7 +275,7 @@ public class ImageVideoCozeService { parameters.put("group_id", secret.voiceGroupId()); parameters.put("user_id", userId); parameters.put("voice_id", firstNonBlank(voiceId)); - return postWorkflowParameters("voice delete", workflowConfigService.voiceDeleteWorkflowId(), parameters, secret.cozeToken(), false); + return postWorkflowParameters("voice delete", workflowConfigService.voiceDeleteWorkflowId(), parameters, secret.cozeToken(), true); } public Map runVoiceClone(Long userId, String name, String audioUrl, String videoUrl) { @@ -267,7 +291,7 @@ public class ImageVideoCozeService { parameters.put("user_id", userId); parameters.put("audio_url", resolvedAudioUrl); parameters.put("video_url", resolvedVideoUrl); - return postWorkflowParameters("voice clone", workflowConfigService.voiceCloneWorkflowId(), parameters, secret.cozeToken(), false); + return postWorkflowParameters("voice clone", workflowConfigService.voiceCloneWorkflowId(), parameters, secret.cozeToken(), true); } public Map runVoiceSynthesis(Long userId, String text, String voiceId) { @@ -277,10 +301,11 @@ public class ImageVideoCozeService { parameters.put("group_id", secret.voiceGroupId()); parameters.put("text", firstNonBlank(text)); parameters.put("voice_id", firstNonBlank(voiceId)); - return postWorkflowParameters("voice synthesis", workflowConfigService.voiceSynthesisWorkflowId(), parameters, secret.cozeToken(), false); + return postWorkflowParameters("voice synthesis", workflowConfigService.voiceSynthesisWorkflowId(), parameters, secret.cozeToken(), true); } - private String postWorkflow(DouyinCopyRequest request, String url, String apiKey, String t8Key, String token, String workflowId) { + private String postWorkflow(DouyinCopyRequest request, String url, String apiKey, String t8Key, String token, + String workflowId, boolean async) { String resolvedApiKey = firstNonBlank(apiKey); String resolvedT8Key = firstNonBlank(t8Key); String cozeToken = firstNonBlank(token); @@ -305,6 +330,9 @@ public class ImageVideoCozeService { String resolvedWorkflowId = firstNonBlank(workflowId, properties.getDouyinCopyWorkflowId()); body.put("workflow_id", resolvedWorkflowId); body.put("parameters", parameters); + if (async) { + body.put("is_async", true); + } String endpoint = joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()); log.info("[image-video] douyin copy coze request url={} workflowId={} inputLength={}", @@ -420,6 +448,16 @@ public class ImageVideoCozeService { } } + public DouyinCopyVo parseDouyinCopyResult(Map result) { + try { + return parseDouyinCopyResponse(objectMapper.writeValueAsString(result == null ? Map.of() : result)); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException("Coze response parse failed: " + ex.getMessage(), ex); + } + } + private String cleanCopyText(String value) { String text = firstNonBlank(value); if (!hasText(text)) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoSecretService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoSecretService.java index ef30005..c7cc726 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoSecretService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoSecretService.java @@ -20,6 +20,8 @@ import java.util.Set; public class ImageVideoSecretService { private static final Set ALLOWED_EXPIRE_DAYS = Set.of(1, 7, 30); + private static final int PERMANENT_EXPIRE_DAYS = 0; + private static final LocalDateTime PERMANENT_EXPIRES_AT = LocalDateTime.of(9999, 12, 31, 23, 59, 59); private final ImageVideoSecretMapper secretMapper; private final ShopCredentialCryptoService cryptoService; @@ -34,8 +36,8 @@ public class ImageVideoSecretService { public ImageVideoSecretStatusVo save(ImageVideoSecretSaveRequest request) { validateUserId(request.getUserId()); Integer expireDays = request.getExpireDays(); - if (expireDays == null || !ALLOWED_EXPIRE_DAYS.contains(expireDays)) { - throw new BusinessException("有效期只支持 1 天、7 天、30 天"); + if (expireDays == null || (expireDays != PERMANENT_EXPIRE_DAYS && !ALLOWED_EXPIRE_DAYS.contains(expireDays))) { + throw new BusinessException("有效期只支持 1 天、7 天、30 天、永久"); } LocalDateTime now = LocalDateTime.now(); @@ -44,13 +46,15 @@ public class ImageVideoSecretService { String copyApiKey = normalize(request.getCopyApiKey()); String t8Key = normalize(request.getT8Key()); + String t8VideoKey = normalize(request.getT8VideoKey()); String voiceApiKey = normalize(request.getVoiceApiKey()); String voiceGroupId = normalize(request.getVoiceGroupId()); if (mustInputAll) { - requireText(copyApiKey, "文案 key 不能为空"); + requireText(copyApiKey, "爬虫密钥不能为空"); requireText(t8Key, "t8_key 不能为空"); - requireText(voiceApiKey, "音色 key 不能为空"); - requireText(voiceGroupId, "音色 groupId 不能为空"); + requireText(t8VideoKey, "t8_video_key 不能为空"); + requireText(voiceApiKey, "音频密钥不能为空"); + requireText(voiceGroupId, "音频团队ID不能为空"); } ImageVideoSecretEntity row = existing == null ? new ImageVideoSecretEntity() : existing; @@ -61,13 +65,16 @@ public class ImageVideoSecretService { if (hasText(t8Key)) { row.setT8Key(cryptoService.encrypt(t8Key)); } + if (hasText(t8VideoKey)) { + row.setT8VideoKey(cryptoService.encrypt(t8VideoKey)); + } if (hasText(voiceApiKey)) { row.setVoiceApiKey(cryptoService.encrypt(voiceApiKey)); } if (hasText(voiceGroupId)) { row.setVoiceGroupId(cryptoService.encrypt(voiceGroupId)); } - row.setExpiresAt(now.plusDays(expireDays)); + row.setExpiresAt(expireDays == PERMANENT_EXPIRE_DAYS ? PERMANENT_EXPIRES_AT : now.plusDays(expireDays)); row.setUpdatedAt(now); if (row.getId() == null) { @@ -92,15 +99,16 @@ public class ImageVideoSecretService { String cozeToken = normalize(properties.getCozeToken()); String copyApiKey = decrypt(row.getCopyApiKey()); String t8Key = decrypt(row.getT8Key()); + String t8VideoKey = decrypt(row.getT8VideoKey()); String voiceApiKey = decrypt(row.getVoiceApiKey()); String voiceGroupId = decrypt(row.getVoiceGroupId()); if (!hasText(cozeToken)) { throw new BusinessException("图生视频 Coze 访问令牌未配置"); } - if (!hasText(copyApiKey) || !hasText(t8Key) || !hasText(voiceApiKey) || !hasText(voiceGroupId)) { + if (!hasText(copyApiKey) || !hasText(t8Key) || !hasText(t8VideoKey) || !hasText(voiceApiKey) || !hasText(voiceGroupId)) { throw new BusinessException("视频密钥未配置完整,请重新配置"); } - return new ResolvedSecret(cozeToken, copyApiKey, t8Key, voiceApiKey, voiceGroupId); + return new ResolvedSecret(cozeToken, copyApiKey, t8Key, t8VideoKey, voiceApiKey, voiceGroupId); } public ResolvedSecret resolveForDouyinCopy(Long userId) { @@ -109,17 +117,18 @@ public class ImageVideoSecretService { throw new BusinessException("图生视频 Coze 访问令牌未配置"); } if (userId == null || userId <= 0) { - return new ResolvedSecret(cozeToken, "", "", "", ""); + return new ResolvedSecret(cozeToken, "", "", "", "", ""); } ImageVideoSecretEntity row = selectByUserId(userId); if (row == null || row.getExpiresAt() == null || !row.getExpiresAt().isAfter(LocalDateTime.now())) { - return new ResolvedSecret(cozeToken, "", "", "", ""); + return new ResolvedSecret(cozeToken, "", "", "", "", ""); } return new ResolvedSecret( cozeToken, decrypt(row.getCopyApiKey()), decrypt(row.getT8Key()), + decrypt(row.getT8VideoKey()), decrypt(row.getVoiceApiKey()), decrypt(row.getVoiceGroupId()) ); @@ -140,6 +149,7 @@ public class ImageVideoSecretService { vo.setExpired(false); vo.setHasCopyApiKey(false); vo.setHasT8Key(false); + vo.setHasT8VideoKey(false); vo.setHasVoiceApiKey(false); vo.setHasVoiceGroupId(false); return vo; @@ -147,24 +157,28 @@ public class ImageVideoSecretService { String copyApiKey = decrypt(row.getCopyApiKey()); String t8Key = decrypt(row.getT8Key()); + String t8VideoKey = decrypt(row.getT8VideoKey()); String voiceApiKey = decrypt(row.getVoiceApiKey()); String voiceGroupId = decrypt(row.getVoiceGroupId()); boolean hasCopy = hasText(copyApiKey); boolean hasT8 = hasText(t8Key); + boolean hasT8Video = hasText(t8VideoKey); boolean hasVoice = hasText(voiceApiKey); boolean hasGroup = hasText(voiceGroupId); boolean expired = row.getExpiresAt() != null && !row.getExpiresAt().isAfter(LocalDateTime.now()); - boolean configured = hasCopy && hasT8 && hasVoice && hasGroup; + boolean configured = hasCopy && hasT8 && hasT8Video && hasVoice && hasGroup; vo.setConfigured(configured); vo.setValid(configured && !expired && row.getExpiresAt() != null); vo.setExpired(expired); vo.setHasCopyApiKey(hasCopy); vo.setHasT8Key(hasT8); + vo.setHasT8VideoKey(hasT8Video); vo.setHasVoiceApiKey(hasVoice); vo.setHasVoiceGroupId(hasGroup); vo.setCopyApiKeyMasked(mask(copyApiKey)); vo.setT8KeyMasked(mask(t8Key)); + vo.setT8VideoKeyMasked(mask(t8VideoKey)); vo.setVoiceApiKeyMasked(mask(voiceApiKey)); vo.setVoiceGroupIdMasked(mask(voiceGroupId)); vo.setExpireDays(resolveExpireDays(row)); @@ -176,6 +190,9 @@ public class ImageVideoSecretService { if (row.getExpiresAt() == null) { return 7; } + if (!row.getExpiresAt().isBefore(PERMANENT_EXPIRES_AT)) { + return PERMANENT_EXPIRE_DAYS; + } LocalDateTime baseTime = row.getUpdatedAt() != null ? row.getUpdatedAt() : row.getCreatedAt(); if (baseTime == null) { return 7; @@ -228,6 +245,6 @@ public class ImageVideoSecretService { return value != null && !value.trim().isEmpty(); } - public record ResolvedSecret(String cozeToken, String copyApiKey, String t8Key, String voiceApiKey, String voiceGroupId) { + public record ResolvedSecret(String cozeToken, String copyApiKey, String t8Key, String t8VideoKey, String voiceApiKey, String voiceGroupId) { } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java index 46f3001..afbe0eb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java @@ -111,19 +111,28 @@ public class PriceTrackController { @GetMapping("/tasks/{taskId}/skip-asins/paginated") @Operation( summary = "分页查询任务的跳过 ASIN 数据", - description = "根据任务 ID 分页拉取该任务关联的 ASIN 数据。" - + "全量模式:从 biz_skip_price_asin 表查询;" - + "文件模式:从任务 requestJson 中存储的上传文件数据查询。" - + "国家代码从任务的 requestJson.countryCodes 中获取。" + description = "根据任务编号分页拉取该任务关联的 ASIN 数据。" + + "全量模式:按任务店铺和国家从跳过跟价库查询;" + + "文件模式:从任务保存的上传文件解析数据查询。" + + "未传国家过滤条件时,默认使用任务创建时选择的国家。" ) public ApiResponse getTaskSkipAsinsPaginated( - @Parameter(description = "任务 ID", required = true, example = "200") + @Parameter(description = "任务编号", required = true, example = "200") @PathVariable Long taskId, @Parameter(description = "页码,从 1 开始", example = "1") @RequestParam(value = "page", defaultValue = "1") Integer page, @Parameter(description = "每页条数,默认 1000,最大 2000", example = "1000") - @RequestParam(value = "page_size", defaultValue = "1000") Integer pageSize) { - return ApiResponse.success(priceTrackTaskService.getTaskSkipAsinsPaginated(taskId, page, pageSize)); + @RequestParam(value = "page_size", defaultValue = "1000") Integer pageSize, + @Parameter(description = "店铺名称过滤条件,可重复传入,也可用英文逗号分隔;仅在任务关联店铺范围内生效", example = "测试店铺A") + @RequestParam(value = "shop_name", required = false) List shopNames, + @Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔;仅在任务国家范围内生效", example = "DE") + @RequestParam(value = "country_code", required = false) List countryCodes) { + return ApiResponse.success(priceTrackTaskService.getTaskSkipAsinsPaginated( + taskId, + page, + pageSize, + shopNames, + countryCodes)); } @GetMapping("/tasks/{taskId}/skip-asin/check") @@ -251,7 +260,7 @@ public class PriceTrackController { + "未完成/处理中:本次只传增量行数据,shop.success 不传或传 null,后端仅合并缓存继续等待。" + "已完成:传最后一批数据并设置 shop.success=true,后端会使用该店铺累计后的完整数据出结果。" + "失败:传 shop.success=false 或传非空 shop.error。" - + "行级数据只保留表头字段:shopMallName、asin、price、recommendedPrice、shippingFee、minimumPrice、firstPlace、secondPlace、cartShopName、priceChangeStatus、modifyCount、status。" + + "行级数据只保留表头字段:shopMallName、asin、price、recommendedPrice、shippingFee、minimumPrice、firstPlace、firstShop、secondPlace、secondShop、cartShopName、priceChangeStatus、modifyCount、status。" ) public ApiResponse submitResult( @Parameter( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java index f3810a5..544b064 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java @@ -83,9 +83,17 @@ public class PriceTrackSubmitResultRequest { @Schema(description = "第一名", example = "竞对A") private String firstPlace; + @JsonAlias({"first_shop", "firstPlaceShop", "first_place_shop", "第一名店铺名"}) + @Schema(description = "第一名店铺名", example = "店铺A") + private String firstShop; + @Schema(description = "第二名", example = "竞对B") private String secondPlace; + @JsonAlias({"second_shop", "secondPlaceShop", "second_place_shop", "第二名店铺名"}) + @Schema(description = "第二名店铺名", example = "店铺B") + private String secondShop; + @Schema(description = "购物车店铺名", example = "店铺A") private String cartShopName; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinPageVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinPageVo.java index ab99aa2..59837c5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinPageVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinPageVo.java @@ -9,47 +9,47 @@ import java.util.List; import java.util.Map; /** - * Price-track skipped ASIN page response. + * 跟价任务跳过 ASIN 分页响应。 */ @Data -@Schema(description = "Price-track skipped ASIN page response") +@Schema(description = "跟价任务跳过 ASIN 分页响应") public class SkipPriceAsinPageVo { - @Schema(description = "Current page, starting from 1") + @Schema(description = "当前页码,从 1 开始") private Integer page; - @Schema(description = "Page size") + @Schema(description = "每页条数") private Integer pageSize; - @Schema(description = "Total records") + @Schema(description = "总记录数") private Long total; - @Schema(description = "Total pages") + @Schema(description = "总页数") private Integer totalPages; - @Schema(description = "ASIN list grouped by country") + @Schema(description = "按国家分组的 ASIN 列表") private Map> skipAsinsByCountry = new LinkedHashMap<>(); - @Schema(description = "ASIN detail list grouped by country, including asin and minimumPrice") + @Schema(description = "按国家分组的 ASIN 明细列表,包含 ASIN 和最低价") private Map>> skipAsinDetailsByCountry = new LinkedHashMap<>(); @JsonProperty("skip_asins") - @Schema(description = "Legacy Python queue field: ASIN list grouped by country") + @Schema(description = "兼容旧版队列字段:按国家分组的 ASIN 列表") private Map> skipAsins = new LinkedHashMap<>(); @JsonProperty("skip_asins_by_country") - @Schema(description = "Legacy Python queue field: ASIN list grouped by country") + @Schema(description = "兼容旧版队列字段:按国家分组的 ASIN 列表") private Map> skipAsinsByCountryLegacy = new LinkedHashMap<>(); @JsonProperty("skip_asin_details_by_country") - @Schema(description = "Legacy Python queue field: ASIN detail list grouped by country") + @Schema(description = "兼容旧版队列字段:按国家分组的 ASIN 明细列表") private Map>> skipAsinDetailsByCountryLegacy = new LinkedHashMap<>(); @JsonProperty("asin_rows_by_country") - @Schema(description = "Legacy Python queue field: appointed ASIN rows grouped by country") + @Schema(description = "兼容旧版队列字段:按国家分组的指定 ASIN 文件行数据") private Map>> asinRowsByCountry = new LinkedHashMap<>(); @JsonProperty("minimum_price_by_country_and_asin") - @Schema(description = "Legacy Python queue field: minimum price map grouped by country and ASIN") + @Schema(description = "兼容旧版队列字段:按国家和 ASIN 分组的最低价映射") private Map> minimumPriceByCountryAndAsin = new LinkedHashMap<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java index c042541..feb076d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java @@ -27,7 +27,9 @@ public class PriceTrackExcelAssemblyService { "运费", "最低价", "第一名", + "第一名店铺名", "第二名", + "第二名店铺名", "购物车店铺名", "改价价格", "改价情况", @@ -62,12 +64,14 @@ public class PriceTrackExcelAssemblyService { row.createCell(4).setCellValue(valueOf(item.getShippingFee())); row.createCell(5).setCellValue(valueOf(item.getMinimumPrice())); row.createCell(6).setCellValue(valueOf(item.getFirstPlace())); - row.createCell(7).setCellValue(valueOf(item.getSecondPlace())); - row.createCell(8).setCellValue(valueOf(item.getCartShopName())); - row.createCell(9).setCellValue(valueOf(item.getPriceChangePrice())); - row.createCell(10).setCellValue(valueOf(item.getPriceChangeStatus())); - row.createCell(11).setCellValue(valueOf(item.getModifyCount())); - row.createCell(12).setCellValue(valueOf(item.getStatus())); + row.createCell(7).setCellValue(valueOf(item.getFirstShop())); + row.createCell(8).setCellValue(valueOf(item.getSecondPlace())); + row.createCell(9).setCellValue(valueOf(item.getSecondShop())); + row.createCell(10).setCellValue(valueOf(item.getCartShopName())); + row.createCell(11).setCellValue(valueOf(item.getPriceChangePrice())); + row.createCell(12).setCellValue(valueOf(item.getPriceChangeStatus())); + row.createCell(13).setCellValue(valueOf(item.getModifyCount())); + row.createCell(14).setCellValue(valueOf(item.getStatus())); } applyDefaultColumnWidths(sheet); } @@ -127,7 +131,7 @@ public class PriceTrackExcelAssemblyService { } private void applyDefaultColumnWidths(Sheet sheet) { - int[] widths = {22, 18, 12, 12, 12, 12, 20, 20, 22, 14, 18, 12, 12}; + int[] widths = {22, 18, 12, 12, 12, 12, 20, 22, 20, 22, 22, 14, 18, 12, 12}; for (int i = 0; i < widths.length; i++) { sheet.setColumnWidth(i, widths[i] * 256); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index 83c420c..dde33ab 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -48,6 +48,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -816,6 +817,11 @@ public class PriceTrackTaskService { */ public com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo getTaskSkipAsinsPaginated( Long taskId, int page, int pageSize) { + return getTaskSkipAsinsPaginated(taskId, page, pageSize, List.of(), List.of()); + } + + public com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo getTaskSkipAsinsPaginated( + Long taskId, int page, int pageSize, List requestedShopNames, List requestedCountryCodes) { if (taskId == null || taskId <= 0) { throw new BusinessException("taskId 不合法"); } @@ -827,8 +833,10 @@ public class PriceTrackTaskService { PriceTrackCreateTaskRequest request = parseTaskRequest(task); - // Resolve country codes from task request. - List countryCodes = request.getCountryCodes(); + List countryCodes = resolveSkipAsinCountryCodes(request, requestedCountryCodes); + if (countryCodes.isEmpty()) { + return emptySkipAsinPage(List.of(), page, pageSize); + } // Choose full mode or uploaded-ASIN file mode. boolean isAsinMode = request.isAsinMode(); @@ -841,11 +849,116 @@ public class PriceTrackTaskService { // File mode reads parsed uploaded ASIN rows. return getTaskAsinFilePaginated(request, countryCodes, page, pageSize); } else { - // Full mode uses all ASINs and minimum prices from biz_skip_price_asin. - return skipPriceAsinService.listSkipAsinsPaginated(List.of(), countryCodes, page, pageSize); + List shopNames = resolveSkipAsinShopNames(request, requestedShopNames); + if (shopNames.isEmpty()) { + return emptySkipAsinPage(countryCodes, page, pageSize); + } + return skipPriceAsinService.listSkipAsinsPaginated(shopNames, countryCodes, page, pageSize); } } + private List resolveSkipAsinCountryCodes(PriceTrackCreateTaskRequest request, List requestedCountryCodes) { + List taskCountryCodes = normalizeTaskCountryCodes(request == null ? null : request.getCountryCodes()); + LinkedHashSet requested = new LinkedHashSet<>(); + for (String country : splitQueryValues(requestedCountryCodes)) { + requested.add(normalizeLookupCountry(country)); + } + if (requested.isEmpty()) { + return taskCountryCodes; + } + List result = new ArrayList<>(); + for (String country : requested) { + if (isTaskCountryEnabled(taskCountryCodes, country)) { + result.add(country); + } + } + return result; + } + + private List normalizeTaskCountryCodes(List countryCodes) { + LinkedHashSet normalized = new LinkedHashSet<>(); + if (countryCodes != null) { + for (String country : countryCodes) { + String value = country == null ? "" : country.trim().toUpperCase(Locale.ROOT); + if (!value.isEmpty()) { + normalized.add(value); + } + } + } + return new ArrayList<>(normalized); + } + + private List resolveSkipAsinShopNames(PriceTrackCreateTaskRequest request, List requestedShopNames) { + List taskShopNames = resolveTaskShopNames(request); + List requested = splitQueryValues(requestedShopNames); + LinkedHashSet normalized = new LinkedHashSet<>(); + for (String shopName : requested) { + String value = ziniaoShopSwitchService.normalizeShopName(shopName); + if (!value.isBlank() && (taskShopNames.isEmpty() || taskShopNames.contains(value))) { + normalized.add(value); + } + } + if (!normalized.isEmpty()) { + return new ArrayList<>(normalized); + } + if (!requested.isEmpty()) { + return List.of(); + } + return taskShopNames; + } + + private List splitQueryValues(List values) { + List result = new ArrayList<>(); + if (values == null) { + return result; + } + for (String value : values) { + if (value == null) { + continue; + } + for (String part : value.split(",")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed); + } + } + } + return result; + } + + private com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo emptySkipAsinPage( + List countryCodes, int page, int pageSize) { + if (page < 1) { + page = 1; + } + if (pageSize < 1 || pageSize > 2000) { + pageSize = 1000; + } + List targetCountries = (countryCodes == null || countryCodes.isEmpty()) + ? List.of("DE", "UK", "FR", "IT", "ES") + : countryCodes; + Map> skipAsinsByCountry = new LinkedHashMap<>(); + Map>> skipDetailsByCountry = new LinkedHashMap<>(); + for (String country : targetCountries) { + skipAsinsByCountry.put(country, new ArrayList<>()); + skipDetailsByCountry.put(country, new ArrayList<>()); + } + com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo vo = + new com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo(); + vo.setPage(page); + vo.setPageSize(pageSize); + vo.setTotal(0L); + vo.setTotalPages(0); + vo.setSkipAsinsByCountry(skipAsinsByCountry); + vo.setSkipAsinDetailsByCountry(skipDetailsByCountry); + vo.setSkipAsins(skipAsinsByCountry); + vo.setSkipAsinsByCountryLegacy(skipAsinsByCountry); + vo.setSkipAsinDetailsByCountryLegacy(skipDetailsByCountry); + vo.setAsinRowsByCountry(new LinkedHashMap<>()); + vo.setMinimumPriceByCountryAndAsin(new LinkedHashMap<>()); + return vo; + } + private List resolveTaskShopNames(PriceTrackCreateTaskRequest request) { List shopNames = new ArrayList<>(); if (request == null || request.getItems() == null) { @@ -1391,7 +1504,9 @@ public class PriceTrackTaskService { case "推荐价", "recommendedprice" -> "recommendedPrice"; case "最低价", "minimumprice" -> "minimumPrice"; case "第一名", "firstplace" -> "firstPlace"; + case "第一名店铺名", "firstshop", "firstplaceshop" -> "firstShop"; case "第二名", "secondplace" -> "secondPlace"; + case "第二名店铺名", "secondshop", "secondplaceshop" -> "secondShop"; case "购物车店铺名", "cartshopname" -> "cartShopName"; case "改价情况", "pricechangestatus" -> "priceChangeStatus"; case "修改次数", "modifycount" -> "modifyCount"; @@ -1661,7 +1776,9 @@ public class PriceTrackTaskService { merged.setShippingFee(firstNonBlank(incoming.getShippingFee(), merged.getShippingFee())); merged.setMinimumPrice(firstNonBlank(incoming.getMinimumPrice(), merged.getMinimumPrice())); merged.setFirstPlace(firstNonBlank(incoming.getFirstPlace(), merged.getFirstPlace())); + merged.setFirstShop(firstNonBlank(incoming.getFirstShop(), merged.getFirstShop())); merged.setSecondPlace(firstNonBlank(incoming.getSecondPlace(), merged.getSecondPlace())); + merged.setSecondShop(firstNonBlank(incoming.getSecondShop(), merged.getSecondShop())); merged.setCartShopName(firstNonBlank(incoming.getCartShopName(), merged.getCartShopName())); merged.setPriceChangePrice(firstNonBlank(incoming.getPriceChangePrice(), merged.getPriceChangePrice())); merged.setPriceChangeStatus(firstNonBlank(incoming.getPriceChangeStatus(), merged.getPriceChangeStatus())); @@ -1686,7 +1803,9 @@ public class PriceTrackTaskService { out.setShippingFee(row.getShippingFee()); out.setMinimumPrice(row.getMinimumPrice()); out.setFirstPlace(row.getFirstPlace()); + out.setFirstShop(row.getFirstShop()); out.setSecondPlace(row.getSecondPlace()); + out.setSecondShop(row.getSecondShop()); out.setCartShopName(row.getCartShopName()); out.setPriceChangePrice(row.getPriceChangePrice()); out.setPriceChangeStatus(row.getPriceChangeStatus()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java index 17cb264..9431e3a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java @@ -1144,6 +1144,10 @@ public class SkipPriceAsinService { } // 构建查询条件 + List targetCountries = (countryCodes == null || countryCodes.isEmpty()) + ? List.of("DE", "UK", "FR", "IT", "ES") + : countryCodes; + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() .orderByDesc(SkipPriceAsinEntity::getId); @@ -1160,14 +1164,11 @@ public class SkipPriceAsinService { if (!normalizedShopNames.isEmpty()) { queryWrapper.in(SkipPriceAsinEntity::getShopName, normalizedShopNames); } + applyCountryAsinFilter(queryWrapper, targetCountries); // 查询所有记录(用于内存分页) List allRecords = skipPriceAsinMapper.selectList(queryWrapper); - List targetCountries = (countryCodes == null || countryCodes.isEmpty()) - ? List.of("DE", "UK", "FR", "IT", "ES") - : countryCodes; - // 展开所有 ASIN 为扁平列表 List flattenedAsins = new ArrayList<>(); for (SkipPriceAsinEntity row : allRecords) { @@ -1228,6 +1229,41 @@ public class SkipPriceAsinService { return vo; } + private void applyCountryAsinFilter(LambdaQueryWrapper queryWrapper, List countries) { + LinkedHashSet normalizedCountries = new LinkedHashSet<>(); + if (countries != null) { + for (String country : countries) { + String normalized = normalizeBlank(country).toUpperCase(Locale.ROOT); + switch (normalized) { + case "DE", "UK", "FR", "IT", "ES" -> normalizedCountries.add(normalized); + default -> { + } + } + } + } + if (normalizedCountries.isEmpty()) { + return; + } + queryWrapper.and(wrapper -> { + boolean[] first = {true}; + for (String country : normalizedCountries) { + if (!first[0]) { + wrapper.or(); + } + switch (country) { + case "DE" -> wrapper.ne(SkipPriceAsinEntity::getAsinDe, ""); + case "UK" -> wrapper.ne(SkipPriceAsinEntity::getAsinUk, ""); + case "FR" -> wrapper.ne(SkipPriceAsinEntity::getAsinFr, ""); + case "IT" -> wrapper.ne(SkipPriceAsinEntity::getAsinIt, ""); + case "ES" -> wrapper.ne(SkipPriceAsinEntity::getAsinEs, ""); + default -> { + } + } + first[0] = false; + } + }); + } + // 内部辅助类 private static class AsinItem { String country; diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index c2bd0b3..b6ee3f6 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -237,6 +237,8 @@ aiimage: coze-workflow-path: ${AIIMAGE_IMAGE_VIDEO_COZE_WORKFLOW_PATH:/v1/workflow/run} coze-connect-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_COZE_CONNECT_TIMEOUT_MILLIS:10000} 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} 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/V71__image_video_async_task.sql b/backend-java/src/main/resources/db/V71__image_video_async_task.sql new file mode 100644 index 0000000..87aa859 --- /dev/null +++ b/backend-java/src/main/resources/db/V71__image_video_async_task.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS biz_image_video_async_task ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + task_type VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + request_json LONGTEXT NOT NULL, + result_json LONGTEXT NULL, + error_message VARCHAR(1000) NULL, + coze_execute_id VARCHAR(128) NULL, + coze_status VARCHAR(32) NULL, + attempt_count INT NOT NULL DEFAULT 0, + submitted_at DATETIME NOT NULL, + completed_at DATETIME NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_image_video_async_user (user_id, id), + KEY idx_image_video_async_status (status, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Image video Coze async tasks'; diff --git a/backend-java/src/main/resources/db/V72__image_video_secret_t8_video_key.sql b/backend-java/src/main/resources/db/V72__image_video_secret_t8_video_key.sql new file mode 100644 index 0000000..e0f723d --- /dev/null +++ b/backend-java/src/main/resources/db/V72__image_video_secret_t8_video_key.sql @@ -0,0 +1,3 @@ +ALTER TABLE `biz_image_video_secret` + ADD COLUMN `t8_video_key` VARCHAR(1024) NULL COMMENT 'Encrypted domestic T8 video key' AFTER `t8_key`; + diff --git a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue index 5ffdb6c..f3afa2f 100644 --- a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue +++ b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue @@ -63,7 +63,7 @@
- 整体风格 / 视频提示词 + 视频风格/动作引导
@@ -147,16 +147,18 @@ placeholder="例如:防滑耐磨、复古网面透气设计。" />
- - +
@@ -205,16 +207,18 @@ placeholder="例如:防滑耐磨、复古网面透气设计。" />
- - +
@@ -260,12 +264,12 @@