提交昨晚更新改动

This commit is contained in:
super
2026-07-14 12:50:17 +08:00
parent d8ca8de537
commit 8a9126e626
21 changed files with 1104 additions and 173 deletions

View File

@@ -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<DouyinCopyVo> douyinCopy(@Valid @RequestBody DouyinCopyRequest request) {
return ApiResponse.success(imageVideoCozeService.runDouyinCopy(request));
public ApiResponse<ImageVideoAsyncTaskVo> douyinCopy(@Valid @RequestBody DouyinCopyRequest request) {
return ApiResponse.success(imageVideoAsyncTaskService.submitDouyinCopy(request));
}
@PostMapping("/workflow/run")
@Operation(summary = "提交图生/复刻视频工作流")
public ApiResponse<Map<String, Object>> runWorkflow(@Valid @RequestBody ImageVideoWorkflowRunRequest request) {
return ApiResponse.success(imageVideoCozeService.runImageVideoWorkflow(request.getUserId(), request.getParameters()));
public ApiResponse<ImageVideoAsyncTaskVo> runWorkflow(@Valid @RequestBody ImageVideoWorkflowRunRequest request) {
return ApiResponse.success(imageVideoAsyncTaskService.submitWorkflow(request));
}
@PostMapping("/workflow/result")
@Operation(summary = "查询图生/复刻视频工作流异步结果")
public ApiResponse<Map<String, Object>> workflowResult(@Valid @RequestBody ImageVideoWorkflowResultRequest request) {
return ApiResponse.success(imageVideoCozeService.getImageVideoWorkflowResult(request.getUserId(), request.getExecuteId()));
public ApiResponse<ImageVideoAsyncTaskVo> 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<Map<String, Object>> listVoices(@Valid @RequestBody ImageVideoVoiceListRequest request) {
return ApiResponse.success(imageVideoCozeService.runVoiceList(request.getUserId(), request.getName()));
public ApiResponse<ImageVideoAsyncTaskVo> listVoices(@Valid @RequestBody ImageVideoVoiceListRequest request) {
return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceList(request));
}
@PostMapping("/voice/delete")
@Operation(summary = "删除音色")
public ApiResponse<Map<String, Object>> deleteVoice(@Valid @RequestBody ImageVideoVoiceDeleteRequest request) {
return ApiResponse.success(imageVideoCozeService.runVoiceDelete(request.getUserId(), request.getVoiceId()));
public ApiResponse<ImageVideoAsyncTaskVo> deleteVoice(@Valid @RequestBody ImageVideoVoiceDeleteRequest request) {
return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceDelete(request));
}
@PostMapping("/voice/clone")
@Operation(summary = "音色克隆")
public ApiResponse<Map<String, Object>> cloneVoice(@Valid @RequestBody ImageVideoVoiceCloneRequest request) {
return ApiResponse.success(imageVideoCozeService.runVoiceClone(request.getUserId(), request.getName(), request.getAudioUrl(), request.getVideoUrl()));
public ApiResponse<ImageVideoAsyncTaskVo> cloneVoice(@Valid @RequestBody ImageVideoVoiceCloneRequest request) {
return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceClone(request));
}
@PostMapping("/voice/synthesis")
@Operation(summary = "语音合成")
public ApiResponse<Map<String, Object>> synthesizeVoice(@Valid @RequestBody ImageVideoVoiceSynthesisRequest request) {
return ApiResponse.success(imageVideoCozeService.runVoiceSynthesis(request.getUserId(), request.getText(), request.getVoiceId()));
public ApiResponse<ImageVideoAsyncTaskVo> synthesizeVoice(@Valid @RequestBody ImageVideoVoiceSynthesisRequest request) {
return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceSynthesis(request));
}
@GetMapping("/tasks/{taskId}")
@Operation(summary = "Query image video async task")
public ApiResponse<ImageVideoAsyncTaskVo> getTask(
@PathVariable Long taskId,
@RequestParam("user_id") Long userId) {
return ApiResponse.success(imageVideoAsyncTaskService.getTask(taskId, userId));
}
}

View File

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

View File

@@ -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、00 表示永久", example = "7")
private Integer expireDays;
}

View File

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

View File

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

View File

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

View File

@@ -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、00 表示永久")
private Integer expireDays;
@Schema(description = "过期时间")

View File

@@ -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<String> TERMINAL_STATUSES = Set.of(
"SUCCESS", "SUCCEEDED", "COMPLETED", "DONE", "FINISHED",
"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED"
);
private static final Set<String> FAILED_STATUSES = Set.of("FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED");
private static final Set<String> COZE_EXECUTION_STATUS_FIELDS = Set.of(
"execute_status", "executeStatus", "workflow_status", "workflowStatus", "status"
);
private static final Set<String> 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<ImageVideoAsyncTaskEntity>()
.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<ImageVideoAsyncTaskEntity> tasks = taskMapper.selectList(new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
.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<ImageVideoAsyncTaskEntity> tasks = taskMapper.selectList(new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
.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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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> T readJson(String json, Class<T> 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<String> 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<String> 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
}
}

View File

@@ -52,7 +52,7 @@ public class ImageVideoCozeService {
private final ObjectMapper objectMapper;
private volatile HttpClient sharedHttpClient;
public DouyinCopyVo runDouyinCopy(DouyinCopyRequest request) {
public Map<String, Object> 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<String, Object> runImageVideoWorkflow(Long userId, Map<String, Object> 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<String, Object> getImageVideoWorkflowResult(Long userId, String executeId) {
public Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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)) {

View File

@@ -20,6 +20,8 @@ import java.util.Set;
public class ImageVideoSecretService {
private static final Set<Integer> 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) {
}
}

View File

@@ -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<SkipPriceAsinPageVo> 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<String> shopNames,
@Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔;仅在任务国家范围内生效", example = "DE")
@RequestParam(value = "country_code", required = false) List<String> 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<Void> submitResult(
@Parameter(

View File

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

View File

@@ -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<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
@Schema(description = "ASIN detail list grouped by country, including asin and minimumPrice")
@Schema(description = "按国家分组的 ASIN 明细列表,包含 ASIN 和最低价")
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = new LinkedHashMap<>();
@JsonProperty("skip_asins")
@Schema(description = "Legacy Python queue field: ASIN list grouped by country")
@Schema(description = "兼容旧版队列字段:按国家分组的 ASIN 列表")
private Map<String, List<String>> skipAsins = new LinkedHashMap<>();
@JsonProperty("skip_asins_by_country")
@Schema(description = "Legacy Python queue field: ASIN list grouped by country")
@Schema(description = "兼容旧版队列字段:按国家分组的 ASIN 列表")
private Map<String, List<String>> 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<String, List<Map<String, String>>> 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<String, List<Map<String, String>>> 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<String, Map<String, String>> minimumPriceByCountryAndAsin = new LinkedHashMap<>();
}

View File

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

View File

@@ -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<String> requestedShopNames, List<String> 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<String> countryCodes = request.getCountryCodes();
List<String> 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<String> shopNames = resolveSkipAsinShopNames(request, requestedShopNames);
if (shopNames.isEmpty()) {
return emptySkipAsinPage(countryCodes, page, pageSize);
}
return skipPriceAsinService.listSkipAsinsPaginated(shopNames, countryCodes, page, pageSize);
}
}
private List<String> resolveSkipAsinCountryCodes(PriceTrackCreateTaskRequest request, List<String> requestedCountryCodes) {
List<String> taskCountryCodes = normalizeTaskCountryCodes(request == null ? null : request.getCountryCodes());
LinkedHashSet<String> requested = new LinkedHashSet<>();
for (String country : splitQueryValues(requestedCountryCodes)) {
requested.add(normalizeLookupCountry(country));
}
if (requested.isEmpty()) {
return taskCountryCodes;
}
List<String> result = new ArrayList<>();
for (String country : requested) {
if (isTaskCountryEnabled(taskCountryCodes, country)) {
result.add(country);
}
}
return result;
}
private List<String> normalizeTaskCountryCodes(List<String> countryCodes) {
LinkedHashSet<String> 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<String> resolveSkipAsinShopNames(PriceTrackCreateTaskRequest request, List<String> requestedShopNames) {
List<String> taskShopNames = resolveTaskShopNames(request);
List<String> requested = splitQueryValues(requestedShopNames);
LinkedHashSet<String> 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<String> splitQueryValues(List<String> values) {
List<String> 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<String> countryCodes, int page, int pageSize) {
if (page < 1) {
page = 1;
}
if (pageSize < 1 || pageSize > 2000) {
pageSize = 1000;
}
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
? List.of("DE", "UK", "FR", "IT", "ES")
: countryCodes;
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
Map<String, List<Map<String, String>>> 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<String> resolveTaskShopNames(PriceTrackCreateTaskRequest request) {
List<String> 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());

View File

@@ -1144,6 +1144,10 @@ public class SkipPriceAsinService {
}
// 构建查询条件
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
? List.of("DE", "UK", "FR", "IT", "ES")
: countryCodes;
LambdaQueryWrapper<SkipPriceAsinEntity> queryWrapper = new LambdaQueryWrapper<SkipPriceAsinEntity>()
.orderByDesc(SkipPriceAsinEntity::getId);
@@ -1160,14 +1164,11 @@ public class SkipPriceAsinService {
if (!normalizedShopNames.isEmpty()) {
queryWrapper.in(SkipPriceAsinEntity::getShopName, normalizedShopNames);
}
applyCountryAsinFilter(queryWrapper, targetCountries);
// 查询所有记录(用于内存分页)
List<SkipPriceAsinEntity> allRecords = skipPriceAsinMapper.selectList(queryWrapper);
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
? List.of("DE", "UK", "FR", "IT", "ES")
: countryCodes;
// 展开所有 ASIN 为扁平列表
List<AsinItem> flattenedAsins = new ArrayList<>();
for (SkipPriceAsinEntity row : allRecords) {
@@ -1228,6 +1229,41 @@ public class SkipPriceAsinService {
return vo;
}
private void applyCountryAsinFilter(LambdaQueryWrapper<SkipPriceAsinEntity> queryWrapper, List<String> countries) {
LinkedHashSet<String> 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;

View File

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

View File

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

View File

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