diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/ImageVideoProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/ImageVideoProperties.java new file mode 100644 index 0000000..7e6f471 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/ImageVideoProperties.java @@ -0,0 +1,21 @@ +package com.nanri.aiimage.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "aiimage.image-video") +public class ImageVideoProperties { + private String cozeBaseUrl = "https://api.coze.cn"; + private String cozeToken = ""; + private String cozeWorkflowPath = "/v1/workflow/run"; + private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}"; + private String douyinCopyWorkflowId = "7652941112982798388"; + private String imageVideoWorkflowId = "7659086169628377124"; + private String voiceDeleteWorkflowId = "7652954386453299243"; + private String voiceListWorkflowId = "7652954332346089498"; + private String voiceCloneWorkflowId = "7652954356887961641"; + private String voiceSynthesisWorkflowId = "7652954297530105894"; + private int cozeConnectTimeoutMillis = 10000; + private int cozeReadTimeoutMillis = 600000; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java index 2e00956..4768e5a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java @@ -8,6 +8,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; public class OssProperties { private String region; private String endpoint; + private String publicEndpoint; private String bucket; private String accessKeyId; private String accessKeySecret; diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java index b3b2f0a..46a6f46 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java @@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Configuration; @Configuration -@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, InstanceRoutingProperties.class}) +@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class}) public class PropertiesConfig { } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java index 83568dc..a051d87 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java @@ -339,6 +339,10 @@ public class ConvertRunService { lineValues.add(batchId + "-" + rowIndex[0]); continue; } + if ("leadtime-to-ship".equals(column)) { + lineValues.add("5"); + continue; + } if (fieldMapping.containsKey(column)) { String sourceColumn = fieldMapping.get(column); Integer sourceIndex = resolvedHeaderMap.get(sourceColumn); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateService.java index 778e720..ea80055 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateService.java @@ -52,7 +52,7 @@ public class ConvertTemplateService { entity.setRequiredSourceColumnsJson("[\"ASIN\",\"价格\"]"); entity.setHeaderColumnsJson("[\"sku\",\"price\",\"quantity\",\"product-id\",\"product-id-type\",\"condition-type\",\"condition-note\",\"ASIN-hint\",\"title\",\"product-tax-code\",\"operation-type\",\"sale-price\",\"sale-start-date\",\"sale-end-date\",\"leadtime-to-ship\",\"launch-date\",\"is-giftwrap-available\",\"is-gift-message-available\"]"); entity.setFieldMappingJson("{\"price\":\"价格\",\"product-id\":\"ASIN\"}"); - entity.setDefaultsJson("{\"quantity\":\"100\",\"product-id-type\":\"ASIN\",\"condition-type\":\"new\",\"leadtime-to-ship\":\"4\"}"); + entity.setDefaultsJson("{\"quantity\":\"100\",\"product-id-type\":\"ASIN\",\"condition-type\":\"new\",\"leadtime-to-ship\":\"5\"}"); entity.setPreambleLinesJson("[\"TemplateType=Offer\\tVersion=1.4\"]"); entity.setBlankHeaderRowsAfterSchema(1); entity.setIsDefault(0); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/digitalhuman/service/DigitalHumanVersionService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/digitalhuman/service/DigitalHumanVersionService.java index 4167aeb..6c2a53e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/digitalhuman/service/DigitalHumanVersionService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/digitalhuman/service/DigitalHumanVersionService.java @@ -50,13 +50,22 @@ public class DigitalHumanVersionService { } // 保存临时文件 + long startedAt = System.nanoTime(); + long fileSize = file == null ? -1L : file.getSize(); + String originalFilename = file == null ? "" : file.getOriginalFilename(); + log.info("[digital-human-version] upload start version={} filename={} requestFileSize={}", + version, originalFilename, fileSize); File tempFile = null; try { tempFile = Files.createTempFile("digital-human-", ".zip").toFile(); file.transferTo(tempFile); + log.info("[digital-human-version] temp file saved version={} bytes={} elapsedMs={}", + version, tempFile.length(), elapsedMs(startedAt)); // 计算 MD5 String md5 = calculateMd5(tempFile); + log.info("[digital-human-version] md5 calculated version={} md5={} elapsedMs={}", + version, md5, elapsedMs(startedAt)); // 构建 OSS 路径 String ossObjectKey = OSS_PATH_PREFIX + "v" + version + "/ShuFuDigitalHuman.zip"; @@ -64,7 +73,14 @@ public class DigitalHumanVersionService { // 上传到 OSS OSS ossClient = buildOssClient(); try { + log.info("[digital-human-version] oss upload start version={} objectKey={} bytes={} elapsedMs={}", + version, ossObjectKey, tempFile.length(), elapsedMs(startedAt)); ossClient.putObject(ossProperties.getBucket(), ossObjectKey, tempFile); + if (!ossClient.doesObjectExist(ossProperties.getBucket(), ossObjectKey)) { + throw new BusinessException("数字人版本文件上传后在 OSS 中不可见,请重试"); + } + log.info("[digital-human-version] oss uploaded version={} objectKey={} bytes={} elapsedMs={}", + version, ossObjectKey, tempFile.length(), elapsedMs(startedAt)); } finally { ossClient.shutdown(); } @@ -83,11 +99,17 @@ public class DigitalHumanVersionService { entity.setCreatedAt(LocalDateTime.now()); versionMapper.insert(entity); + log.info("[digital-human-version] upload finished version={} id={} bytes={} elapsedMs={}", + version, entity.getId(), entity.getFileSize(), elapsedMs(startedAt)); return toVo(entity); } catch (IOException e) { log.error("上传数字人版本失败", e); throw new BusinessException("文件处理失败:" + e.getMessage()); + } catch (RuntimeException e) { + log.error("[digital-human-version] upload failed version={} filename={} elapsedMs={} err={}", + version, originalFilename, elapsedMs(startedAt), e.getMessage(), e); + throw e; } finally { if (tempFile != null && tempFile.exists()) { tempFile.delete(); @@ -95,6 +117,10 @@ public class DigitalHumanVersionService { } } + private long elapsedMs(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000L; + } + public List listVersions() { List entities = versionMapper.selectList( new LambdaQueryWrapper() @@ -134,6 +160,10 @@ public class DigitalHumanVersionService { throw new BusinessException("版本已发布"); } + if (!ossStorageService.objectExists(entity.getOssObjectKey())) { + throw new BusinessException("数字人版本文件不存在于 OSS,请重新上传该版本:" + entity.getOssObjectKey()); + } + entity.setStatus(STATUS_RELEASED); entity.setReleasedAt(LocalDateTime.now()); versionMapper.updateById(entity); @@ -153,6 +183,10 @@ public class DigitalHumanVersionService { } // 清除其他版本的 is_latest 标记 + if (!ossStorageService.objectExists(entity.getOssObjectKey())) { + throw new BusinessException("数字人版本文件不存在于 OSS,请重新上传该版本:" + entity.getOssObjectKey()); + } + versionMapper.update(null, new LambdaUpdateWrapper() .set(DigitalHumanVersionEntity::getIsLatest, false) .eq(DigitalHumanVersionEntity::getIsLatest, true)); @@ -192,6 +226,9 @@ public class DigitalHumanVersionService { if (entity == null) { throw new BusinessException("版本不存在:" + version); } + if (!ossStorageService.objectExists(entity.getOssObjectKey())) { + throw new BusinessException("数字人版本文件不存在于 OSS,请重新上传该版本:" + entity.getOssObjectKey()); + } return ossStorageService.generateDownloadUrl(entity.getOssObjectKey()); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java index 12cffdf..46c503b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java @@ -4,6 +4,7 @@ import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.modules.file.model.vo.ExcelInfoVo; import com.nanri.aiimage.modules.file.model.vo.UploadFileVo; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -19,6 +20,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import java.io.File; + @RestController @RequiredArgsConstructor @RequestMapping("/api/files") @@ -26,6 +29,7 @@ import org.springframework.web.multipart.MultipartFile; public class FileUploadController { private final LocalFileStorageService localFileStorageService; + private final OssStorageService ossStorageService; @PostMapping("/upload") @Operation( @@ -47,8 +51,22 @@ public class FileUploadController { public ApiResponse upload( @Parameter(name = "file", description = "待上传的 Excel 或业务源文件", required = true, in = ParameterIn.QUERY) MultipartFile file, - @RequestParam(required = false) String relativePath) throws Exception { - return ApiResponse.success(localFileStorageService.saveTempFile(file, relativePath)); + @RequestParam(required = false) String relativePath, + @RequestParam(defaultValue = "false") boolean uploadToOss, + @RequestParam(defaultValue = "COMMON") String moduleType) throws Exception { + UploadFileVo vo = localFileStorageService.saveTempFile(file, relativePath); + if (uploadToOss) { + File localFile = new File(vo.getLocalPath()); + OssStorageService.UploadedResult uploaded = ossStorageService.uploadPublicFileWithFreshDownloadUrl( + localFile, + moduleType, + file.getOriginalFilename() + ); + vo.setObjectKey(uploaded.objectKey()); + vo.setUrl(uploaded.downloadUrl()); + vo.setMediaType(resolveMediaType(file.getContentType(), file.getOriginalFilename())); + } + return ApiResponse.success(vo); } @GetMapping("/excel-info") @@ -60,4 +78,19 @@ public class FileUploadController { public ApiResponse excelInfo(@RequestParam String fileKey) throws Exception { return ApiResponse.success(localFileStorageService.getExcelInfo(fileKey)); } + + private String resolveMediaType(String contentType, String filename) { + String type = contentType == null ? "" : contentType.toLowerCase(); + String name = filename == null ? "" : filename.toLowerCase(); + if (type.startsWith("image/") || name.matches(".*\\.(png|jpe?g|webp|gif|bmp|svg)$")) { + return "image"; + } + if (type.startsWith("video/") || name.matches(".*\\.(mp4|mov|webm|m4v|ogg|avi|mkv)$")) { + return "video"; + } + if (type.startsWith("audio/") || name.matches(".*\\.(mp3|wav|m4a|aac|flac)$")) { + return "audio"; + } + return "file"; + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/model/vo/UploadFileVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/model/vo/UploadFileVo.java index c500ff9..15ff8e4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/model/vo/UploadFileVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/model/vo/UploadFileVo.java @@ -21,4 +21,13 @@ public class UploadFileVo { @Schema(description = "相对上传目录路径") private String relativePath; + + @Schema(description = "OSS 对象 key。仅 uploadToOss=true 时返回") + private String objectKey; + + @Schema(description = "OSS 公网 URL。仅 uploadToOss=true 时返回") + private String url; + + @Schema(description = "媒体类型:image/video/audio/file。仅 uploadToOss=true 时返回") + private String mediaType; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java index 9c2bc8e..91b6da7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java @@ -47,6 +47,24 @@ public class OssStorageService { } } + public UploadedResult uploadPublicFileWithFreshDownloadUrl(File file, String moduleType, String originalFilename) { + String normalizedModuleType = moduleType == null || moduleType.isBlank() + ? "common" + : moduleType.trim().toLowerCase(); + String objectName = sanitizeObjectName(originalFilename); + if (objectName.isBlank()) { + objectName = file.getName(); + } + String objectKey = String.format("upload/%s/%s/%s", normalizedModuleType, UUID.randomUUID(), objectName); + OSS ossClient = buildClient(); + try { + ossClient.putObject(ossProperties.getBucket(), objectKey, file); + return new UploadedResult(objectKey, getPublicUrl(objectKey)); + } finally { + ossClient.shutdown(); + } + } + public String uploadText(String objectKey, String content) { if (objectKey == null || objectKey.isBlank()) { throw new IllegalArgumentException("objectKey must not be blank"); @@ -109,6 +127,19 @@ public class OssStorageService { } } + public boolean objectExists(String value) { + String objectKey = resolveObjectKey(value); + if (objectKey == null || objectKey.isBlank()) { + return false; + } + OSS ossClient = buildClient(); + try { + return ossClient.doesObjectExist(ossProperties.getBucket(), objectKey); + } finally { + ossClient.shutdown(); + } + } + /** * 根据 objectKey 生成公开直链下载 URL。 */ @@ -125,7 +156,7 @@ public class OssStorageService { } String objectKey = resolveObjectKey(value); String normalizedKey = objectKey.startsWith("/") ? objectKey.substring(1) : objectKey; - String host = String.format("%s.%s", ossProperties.getBucket(), ossProperties.getEndpoint()); + String host = String.format("%s.%s", ossProperties.getBucket(), publicEndpoint()); try { return new URI("https", host, "/" + normalizedKey, null).toASCIIString(); } catch (Exception ignored) { @@ -179,6 +210,32 @@ public class OssStorageService { ); } + private String publicEndpoint() { + String endpoint = ossProperties.getPublicEndpoint(); + if (endpoint == null || endpoint.isBlank()) { + endpoint = ossProperties.getEndpoint(); + } + endpoint = endpoint.trim(); + if (endpoint.startsWith("http://")) { + endpoint = endpoint.substring("http://".length()); + } else if (endpoint.startsWith("https://")) { + endpoint = endpoint.substring("https://".length()); + } + return endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + } + + private String sanitizeObjectName(String filename) { + if (filename == null || filename.isBlank()) { + return ""; + } + String normalized = filename.trim().replace('\\', '/'); + int slashIndex = normalized.lastIndexOf('/'); + if (slashIndex >= 0 && slashIndex + 1 < normalized.length()) { + normalized = normalized.substring(slashIndex + 1); + } + return normalized.replaceAll("[\\r\\n]", "_"); + } + public record UploadedResult(String objectKey, String downloadUrl) { } } 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 new file mode 100644 index 0000000..a018f55 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java @@ -0,0 +1,100 @@ +package com.nanri.aiimage.modules.imagevideo.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.imagevideo.model.dto.DouyinCopyRequest; +import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoSecretSaveRequest; +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.vo.DouyinCopyVo; +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.ImageVideoSecretService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +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") +@Tag(name = "视频复刻/图生视频", description = "视频复刻和图生视频相关 AI 接口") +public class ImageVideoController { + + private final ImageVideoCozeService imageVideoCozeService; + private final ImageVideoSecretService imageVideoSecretService; + + @GetMapping("/secrets") + @Operation(summary = "查询视频密钥配置状态") + public ApiResponse secretStatus(@RequestParam("user_id") Long userId) { + return ApiResponse.success(imageVideoSecretService.status(userId)); + } + + @PutMapping("/secrets") + @Operation(summary = "保存视频密钥配置") + public ApiResponse saveSecrets(@Valid @RequestBody ImageVideoSecretSaveRequest request) { + return ApiResponse.success(imageVideoSecretService.save(request)); + } + + @PostMapping("/douyin-copy") + @Operation(summary = "抖音文案识别和仿写") + public ApiResponse douyinCopy(@Valid @RequestBody DouyinCopyRequest request) { + return ApiResponse.success(imageVideoCozeService.runDouyinCopy(request.getUserId(), request.getUrl())); + } + + @PostMapping("/workflow/run") + @Operation(summary = "提交图生/复刻视频工作流") + public ApiResponse> runWorkflow(@Valid @RequestBody ImageVideoWorkflowRunRequest request) { + return ApiResponse.success(imageVideoCozeService.runImageVideoWorkflow(request.getUserId(), request.getParameters())); + } + + @PostMapping("/workflow/result") + @Operation(summary = "查询图生/复刻视频工作流异步结果") + public ApiResponse> workflowResult(@Valid @RequestBody ImageVideoWorkflowResultRequest request) { + return ApiResponse.success(imageVideoCozeService.getImageVideoWorkflowResult(request.getUserId(), request.getExecuteId())); + } + + @PostMapping("/media/upload") + @Operation(summary = "上传图生/复刻视频媒体素材") + public ApiResponse uploadMedia(@RequestParam("file") MultipartFile file) { + return ApiResponse.success(imageVideoCozeService.uploadMedia(file)); + } + + @PostMapping("/voice/list") + @Operation(summary = "查看音色") + public ApiResponse> listVoices(@Valid @RequestBody ImageVideoVoiceListRequest request) { + return ApiResponse.success(imageVideoCozeService.runVoiceList(request.getUserId())); + } + + @PostMapping("/voice/delete") + @Operation(summary = "删除音色") + public ApiResponse> deleteVoice(@Valid @RequestBody ImageVideoVoiceDeleteRequest request) { + return ApiResponse.success(imageVideoCozeService.runVoiceDelete(request.getUserId(), request.getVoiceId())); + } + + @PostMapping("/voice/clone") + @Operation(summary = "音色克隆") + public ApiResponse> cloneVoice(@Valid @RequestBody ImageVideoVoiceCloneRequest request) { + return ApiResponse.success(imageVideoCozeService.runVoiceClone(request.getUserId(), request.getAudioUrl(), request.getVideoUrl())); + } + + @PostMapping("/voice/synthesis") + @Operation(summary = "语音合成") + public ApiResponse> synthesizeVoice(@Valid @RequestBody ImageVideoVoiceSynthesisRequest request) { + return ApiResponse.success(imageVideoCozeService.runVoiceSynthesis(request.getUserId(), request.getText(), request.getVoiceId())); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoSecretMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoSecretMapper.java new file mode 100644 index 0000000..9580cae --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoSecretMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.imagevideo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoSecretEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ImageVideoSecretMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoWorkflowConfigMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoWorkflowConfigMapper.java new file mode 100644 index 0000000..34d745b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoWorkflowConfigMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.imagevideo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoWorkflowConfigEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ImageVideoWorkflowConfigMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/DouyinCopyRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/DouyinCopyRequest.java new file mode 100644 index 0000000..be2d9c7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/DouyinCopyRequest.java @@ -0,0 +1,19 @@ +package com.nanri.aiimage.modules.imagevideo.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +@Schema(description = "抖音文案解析和仿写请求") +public class DouyinCopyRequest { + + @NotNull(message = "userId 不能为空") + @Schema(description = "用户ID", example = "1") + private Long userId; + + @NotBlank(message = "url 不能为空") + @Schema(description = "抖音分享口令、短链或视频链接", example = "2.38 复制打开抖音,看看") + private String url; +} 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 new file mode 100644 index 0000000..685c63f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoSecretSaveRequest.java @@ -0,0 +1,29 @@ +package com.nanri.aiimage.modules.imagevideo.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +@Schema(description = "视频复刻/图生视频密钥保存请求") +public class ImageVideoSecretSaveRequest { + + @NotNull(message = "userId 不能为空") + @Schema(description = "用户ID", example = "1") + private Long userId; + + @Schema(description = "文案工作流 api_key;首次配置或过期后必填", example = "sk-...") + private String copyApiKey; + + private String t8Key; + + @Schema(description = "音色工作流 api_key;首次配置或过期后必填", example = "sk-api-...") + private String voiceApiKey; + + @Schema(description = "音色 group_id;首次配置或过期后必填", example = "2030928714635682107") + private String voiceGroupId; + + @NotNull(message = "有效期不能为空") + @Schema(description = "有效期天数,只允许 1、7、30", example = "7") + private Integer expireDays; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceCloneRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceCloneRequest.java new file mode 100644 index 0000000..0ac136c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceCloneRequest.java @@ -0,0 +1,12 @@ +package com.nanri.aiimage.modules.imagevideo.model.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class ImageVideoVoiceCloneRequest { + @NotNull(message = "userId 不能为空") + private Long userId; + private String audioUrl; + private String videoUrl; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceDeleteRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceDeleteRequest.java new file mode 100644 index 0000000..8181336 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceDeleteRequest.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.imagevideo.model.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class ImageVideoVoiceDeleteRequest { + @NotNull(message = "userId 不能为空") + private Long userId; + + @NotBlank(message = "voice_id 不能为空") + private String voiceId; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceListRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceListRequest.java new file mode 100644 index 0000000..4b7f1d8 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceListRequest.java @@ -0,0 +1,10 @@ +package com.nanri.aiimage.modules.imagevideo.model.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class ImageVideoVoiceListRequest { + @NotNull(message = "userId 不能为空") + private Long userId; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceSynthesisRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceSynthesisRequest.java new file mode 100644 index 0000000..37aa58c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceSynthesisRequest.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.imagevideo.model.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class ImageVideoVoiceSynthesisRequest { + @NotNull(message = "userId 不能为空") + private Long userId; + + @NotBlank(message = "text 不能为空") + private String text; + + @NotBlank(message = "voice_id 不能为空") + private String voiceId; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoWorkflowResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoWorkflowResultRequest.java new file mode 100644 index 0000000..dee689b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoWorkflowResultRequest.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.imagevideo.model.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class ImageVideoWorkflowResultRequest { + @NotNull(message = "userId cannot be null") + private Long userId; + + @NotBlank(message = "executeId cannot be blank") + private String executeId; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoWorkflowRunRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoWorkflowRunRequest.java new file mode 100644 index 0000000..c40d308 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoWorkflowRunRequest.java @@ -0,0 +1,16 @@ +package com.nanri.aiimage.modules.imagevideo.model.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Data +public class ImageVideoWorkflowRunRequest { + @NotNull(message = "用户ID不能为空") + private Long userId; + + @NotNull(message = "工作流参数不能为空") + private Map parameters = new LinkedHashMap<>(); +} 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 new file mode 100644 index 0000000..70ce8ee --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoSecretEntity.java @@ -0,0 +1,24 @@ +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_secret") +public class ImageVideoSecretEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String copyApiKey; + private String t8Key; + private String voiceApiKey; + private String voiceGroupId; + private LocalDateTime expiresAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoWorkflowConfigEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoWorkflowConfigEntity.java new file mode 100644 index 0000000..fcb3194 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/entity/ImageVideoWorkflowConfigEntity.java @@ -0,0 +1,18 @@ +package com.nanri.aiimage.modules.imagevideo.model.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_image_video_workflow_config") +public class ImageVideoWorkflowConfigEntity { + + @TableId + private String configKey; + private String workflowId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/DouyinCopyVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/DouyinCopyVo.java new file mode 100644 index 0000000..ae88c2a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/DouyinCopyVo.java @@ -0,0 +1,21 @@ +package com.nanri.aiimage.modules.imagevideo.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "抖音文案解析和仿写结果") +public class DouyinCopyVo { + + @Schema(description = "识别出的原始文案") + private String recognizedContent; + + @Schema(description = "仿写后的文案") + private String scriptDraft; + + @Schema(description = "Coze 执行 ID") + private String executeId; + + @Schema(description = "Coze 调试链接") + private String debugUrl; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoMediaUploadVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoMediaUploadVo.java new file mode 100644 index 0000000..c46021d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoMediaUploadVo.java @@ -0,0 +1,11 @@ +package com.nanri.aiimage.modules.imagevideo.model.vo; + +import lombok.Data; + +@Data +public class ImageVideoMediaUploadVo { + private String url; + private String objectKey; + private String originalFilename; + private String mediaType; +} 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 new file mode 100644 index 0000000..e222fa4 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/vo/ImageVideoSecretStatusVo.java @@ -0,0 +1,51 @@ +package com.nanri.aiimage.modules.imagevideo.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "视频复刻/图生视频密钥配置状态") +public class ImageVideoSecretStatusVo { + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "是否已保存完整密钥") + private Boolean configured; + + @Schema(description = "是否在有效期内") + private Boolean valid; + + @Schema(description = "是否已经过期") + private Boolean expired; + + @Schema(description = "文案 key 是否已保存") + private Boolean hasCopyApiKey; + + private Boolean hasT8Key; + + @Schema(description = "音色 key 是否已保存") + private Boolean hasVoiceApiKey; + + @Schema(description = "音色 group_id 是否已保存") + private Boolean hasVoiceGroupId; + + @Schema(description = "脱敏后的文案 key") + private String copyApiKeyMasked; + + private String t8KeyMasked; + + @Schema(description = "脱敏后的音色 key") + private String voiceApiKeyMasked; + + @Schema(description = "脱敏后的音色 group_id") + private String voiceGroupIdMasked; + + @Schema(description = "用户选择的有效期天数,支持 1、7、30") + private Integer expireDays; + + @Schema(description = "过期时间") + private LocalDateTime expiresAt; +} 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 new file mode 100644 index 0000000..2faff1e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java @@ -0,0 +1,663 @@ +package com.nanri.aiimage.modules.imagevideo.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.ImageVideoProperties; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoMediaUploadVo; +import com.nanri.aiimage.modules.imagevideo.model.vo.DouyinCopyVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageVideoCozeService { + + private static final List ORIGINAL_FIELDS = List.of( + "recognizedContent", "recognized_content", "originalContent", "original_content", + "originContent", "origin_content", "rawContent", "raw_content", "description", "content" + ); + private static final List SCRIPT_FIELDS = List.of( + "scriptDraft", "script_draft", "rewrittenContent", "rewritten_content", + "fullContent", "full_content", "copywriting", "script", "output", "text", "result" + ); + + private final ImageVideoProperties properties; + private final ImageVideoSecretService secretService; + private final ImageVideoWorkflowConfigService workflowConfigService; + private final OssStorageService ossStorageService; + private final ObjectMapper objectMapper; + private volatile HttpClient sharedHttpClient; + + public DouyinCopyVo runDouyinCopy(Long userId, String url) { + String normalizedUrl = url == null ? "" : url.trim(); + if (normalizedUrl.isBlank()) { + throw new BusinessException("抖音口令或链接不能为空"); + } + if (looksLikeJsonObject(normalizedUrl)) { + throw new BusinessException("请粘贴抖音分享口令或链接,不要粘贴接口返回内容"); + } + if (!isLikelyDouyinSource(normalizedUrl)) { + throw new BusinessException("请粘贴包含抖音链接的分享口令"); + } + + ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); + String responseText = postWorkflow(normalizedUrl, secret.copyApiKey(), secret.t8Key(), secret.cozeToken(), + workflowConfigService.douyinCopyWorkflowId()); + return parseDouyinCopyResponse(responseText); + } + + public Map runImageVideoWorkflow(Long userId, Map parameters) { + if (parameters == null || parameters.isEmpty()) { + throw new BusinessException("工作流参数不能为空"); + } + + ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); + String cozeToken = firstNonBlank(secret.cozeToken()); + if (!hasText(cozeToken)) { + throw new BusinessException("图生视频 Coze token 未配置"); + } + + Map resolvedParameters = new LinkedHashMap<>(parameters); + Map apiKeyInfo = new LinkedHashMap<>(); + Object incomingApiKeyInfo = resolvedParameters.get("api_key_info"); + if (incomingApiKeyInfo instanceof Map) { + Map incomingMap = (Map) incomingApiKeyInfo; + incomingMap.forEach((key, value) -> apiKeyInfo.put(String.valueOf(key), value)); + } + 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()); + if (!hasText(aiConductorKey)) { + throw new BusinessException("图生视频 ai_conductor_key 未配置"); + } + if (!hasText(t8starKey)) { + throw new BusinessException("图生视频 t8star_key 未配置"); + } + apiKeyInfo.put("t8star_key", t8starKey); + apiKeyInfo.put("ai_conductor_key", aiConductorKey); + resolvedParameters.put("api_key_info", apiKeyInfo); + resolvedParameters.remove("api_key"); + normalizeImageVideoParameters(resolvedParameters); + + String workflowId = workflowConfigService.imageVideoWorkflowId(); + return postWorkflowParameters("image video", workflowId, resolvedParameters, cozeToken, true); + } + + @SuppressWarnings("unchecked") + private void normalizeImageVideoParameters(Map parameters) { + Object faceInfo = parameters.get("face_info"); + if (faceInfo instanceof Map) { + Map normalizedFaceInfo = new LinkedHashMap<>(); + ((Map) faceInfo).forEach((key, value) -> normalizedFaceInfo.put(String.valueOf(key), value)); + normalizedFaceInfo.put("model_image", normalizeStringList(normalizedFaceInfo.get("model_image"))); + parameters.put("face_info", normalizedFaceInfo); + } + + Object procInfo = parameters.get("proc_info"); + if (procInfo instanceof Map) { + Map normalizedProcInfo = new LinkedHashMap<>(); + ((Map) procInfo).forEach((key, value) -> normalizedProcInfo.put(String.valueOf(key), value)); + normalizedProcInfo.put("proc_image", normalizeStringList(normalizedProcInfo.get("proc_image"))); + parameters.put("proc_info", normalizedProcInfo); + } + } + + private List normalizeStringList(Object value) { + List urls = new ArrayList<>(); + if (value instanceof List) { + for (Object item : (List) value) { + String text = firstNonBlank(asText(item)); + if (hasText(text)) { + urls.add(text); + } + } + } else { + String text = firstNonBlank(asText(value)); + if (hasText(text)) { + urls.add(text); + } + } + return urls; + } + + private boolean looksLikeJsonObject(String value) { + String text = firstNonBlank(value); + return text.startsWith("{") && text.endsWith("}"); + } + + private boolean isLikelyDouyinSource(String value) { + String text = firstNonBlank(value).toLowerCase(); + return text.contains("douyin.com") + || text.contains("v.douyin") + || text.contains("iesdouyin") + || text.contains("dou音") + || text.contains("抖音"); + } + + public Map getImageVideoWorkflowResult(Long userId, String executeId) { + String resolvedExecuteId = firstNonBlank(executeId); + if (!hasText(resolvedExecuteId)) { + throw new BusinessException("execute_id cannot be blank"); + } + ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); + String cozeToken = firstNonBlank(secret.cozeToken()); + if (!hasText(cozeToken)) { + throw new BusinessException("图生视频 Coze token 未配置"); + } + String workflowId = workflowConfigService.imageVideoWorkflowId(); + if (!hasText(workflowId)) { + 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("{execute_id}", resolvedExecuteId); + String endpoint = joinUrl(properties.getCozeBaseUrl(), path); + String responseText = getCozeJson("image video history", endpoint, cozeToken); + return parseCozeMap("image video history", responseText); + } + + public ImageVideoMediaUploadVo uploadMedia(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException("请上传媒体文件"); + } + String originalFilename = firstNonBlank(file.getOriginalFilename(), "media"); + String mediaType = resolveMediaType(file.getContentType(), originalFilename); + if (!List.of("image", "video", "audio").contains(mediaType)) { + throw new BusinessException("仅支持图片、视频或音频文件"); + } + File tempFile = null; + try { + String suffix = ""; + int dotIndex = originalFilename.lastIndexOf('.'); + if (dotIndex >= 0 && dotIndex + 1 < originalFilename.length()) { + suffix = originalFilename.substring(dotIndex); + } + tempFile = Files.createTempFile("image-video-", suffix).toFile(); + file.transferTo(tempFile); + OssStorageService.UploadedResult uploaded = ossStorageService.uploadResultFileWithFreshDownloadUrl(tempFile, "IMAGE_VIDEO"); + ImageVideoMediaUploadVo vo = new ImageVideoMediaUploadVo(); + vo.setUrl(uploaded.downloadUrl()); + vo.setObjectKey(uploaded.objectKey()); + vo.setOriginalFilename(originalFilename); + vo.setMediaType(mediaType); + return vo; + } catch (Exception ex) { + throw new BusinessException("媒体上传失败: " + ex.getMessage(), ex); + } finally { + if (tempFile != null && tempFile.exists() && !tempFile.delete()) { + log.warn("[image-video] temp media delete failed path={}", tempFile.getAbsolutePath()); + } + } + } + + public Map runVoiceList(Long userId) { + ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); + Map parameters = new LinkedHashMap<>(); + parameters.put("api_key", secret.voiceApiKey()); + return postWorkflowParameters("voice list", workflowConfigService.voiceListWorkflowId(), parameters, secret.cozeToken(), false); + } + + public Map runVoiceDelete(Long userId, String voiceId) { + ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); + Map parameters = new LinkedHashMap<>(); + parameters.put("api_key", secret.voiceApiKey()); + parameters.put("group_id", secret.voiceGroupId()); + parameters.put("voice_id", firstNonBlank(voiceId)); + return postWorkflowParameters("voice delete", workflowConfigService.voiceDeleteWorkflowId(), parameters, secret.cozeToken(), false); + } + + public Map runVoiceClone(Long userId, String audioUrl, String videoUrl) { + String resolvedAudioUrl = firstNonBlank(audioUrl); + String resolvedVideoUrl = firstNonBlank(videoUrl); + if (!hasText(resolvedAudioUrl) && !hasText(resolvedVideoUrl)) { + throw new BusinessException("请上传音频或填写视频链接"); + } + ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); + Map parameters = new LinkedHashMap<>(); + parameters.put("api_key", secret.voiceApiKey()); + parameters.put("audio_url", resolvedAudioUrl); + parameters.put("video_url", resolvedVideoUrl); + return postWorkflowParameters("voice clone", workflowConfigService.voiceCloneWorkflowId(), parameters, secret.cozeToken(), false); + } + + public Map runVoiceSynthesis(Long userId, String text, String voiceId) { + ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); + Map parameters = new LinkedHashMap<>(); + parameters.put("api_key", secret.voiceApiKey()); + 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); + } + + private String postWorkflow(String url, String apiKey, String t8Key, String token, String workflowId) { + String resolvedApiKey = firstNonBlank(apiKey); + String resolvedT8Key = firstNonBlank(t8Key); + String cozeToken = firstNonBlank(token); + if (!hasText(resolvedT8Key)) { + throw new BusinessException("image-video douyin copy t8_key not configured"); + } + if (!hasText(resolvedApiKey)) { + throw new BusinessException("图生视频抖音文案 api_key 未配置"); + } + if (!hasText(cozeToken)) { + throw new BusinessException("图生视频 Coze token 未配置"); + } + + Map parameters = new LinkedHashMap<>(); + parameters.put("api_key", resolvedApiKey); + parameters.put("t8_key", resolvedT8Key); + parameters.put("url", url); + + Map body = new LinkedHashMap<>(); + String resolvedWorkflowId = firstNonBlank(workflowId, properties.getDouyinCopyWorkflowId()); + body.put("workflow_id", resolvedWorkflowId); + body.put("parameters", parameters); + + String endpoint = joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()); + log.info("[image-video] douyin copy coze request url={} workflowId={} inputLength={}", + endpoint, resolvedWorkflowId, url.length()); + log.info("[image-video] douyin copy coze request detail endpoint={} authorization=Bearer {} body={}", + endpoint, maskForLog(cozeToken), toJsonForLog(body)); + return postCozeJson("douyin copy", endpoint, body, cozeToken); + } + + private Map postWorkflowParameters(String label, String workflowId, Map parameters, + String token, boolean async) { + String cozeToken = firstNonBlank(token); + if (!hasText(cozeToken)) { + throw new BusinessException("图生视频 Coze token 未配置"); + } + String resolvedWorkflowId = firstNonBlank(workflowId); + if (!hasText(resolvedWorkflowId)) { + throw new BusinessException(label + " workflow_id 未配置"); + } + + Map body = new LinkedHashMap<>(); + body.put("workflow_id", resolvedWorkflowId); + body.put("parameters", parameters == null ? Map.of() : parameters); + if (async) { + body.put("is_async", true); + } + + String endpoint = joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()); + log.info("[image-video] {} coze request url={} workflowId={}", label, endpoint, resolvedWorkflowId); + log.info("[image-video] {} coze request detail endpoint={} authorization=Bearer {} body={}", + label, endpoint, maskForLog(cozeToken), toJsonForLog(body)); + String responseText = postCozeJson(label, endpoint, body, cozeToken); + + return parseCozeMap(label, responseText); + } + + private Map parseCozeMap(String label, String responseText) { + try { + Map result = objectMapper.readValue(responseText, new TypeReference<>() {}); + Object code = result.get("code"); + if (code instanceof Number number && number.intValue() != 0) { + throw new BusinessException(firstNonBlank(asText(result.get("msg")), asText(result.get("message")), "Coze 工作流提交失败")); + } + return result; + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException("Coze 返回解析失败: " + ex.getMessage()); + } + } + + private DouyinCopyVo parseDouyinCopyResponse(String responseText) { + try { + JsonNode root = objectMapper.readTree(responseText); + if (root.has("code") && root.path("code").asInt(0) != 0) { + throw new BusinessException(firstNonBlank(root.path("msg").asText(null), root.path("message").asText(null), "Coze 工作流执行失败")); + } + + JsonNode data = root.path("data"); + JsonNode payload = unwrapPossiblyJsonText(data); + String recognized = firstNonBlank(findText(payload, ORIGINAL_FIELDS), findText(root, ORIGINAL_FIELDS)); + String script = firstNonBlank(findText(payload, SCRIPT_FIELDS), findText(root, SCRIPT_FIELDS)); + + if (!hasText(recognized) && !hasText(script)) { + String fallback = flattenText(payload); + recognized = fallback; + script = fallback; + } else if (!hasText(script)) { + script = recognized; + } else if (!hasText(recognized)) { + recognized = script; + } + + if (!hasText(recognized) && !hasText(script)) { + throw new BusinessException("Coze 返回为空,未识别到文案内容"); + } + + DouyinCopyVo vo = new DouyinCopyVo(); + vo.setRecognizedContent(cleanCopyText(recognized)); + vo.setScriptDraft(cleanCopyText(script)); + vo.setExecuteId(root.path("execute_id").asText("")); + vo.setDebugUrl(root.path("debug_url").asText("")); + return vo; + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + log.warn("[image-video] parse douyin copy response failed body={}", responseText, ex); + throw new BusinessException("Coze 返回解析失败: " + ex.getMessage()); + } + } + + private String cleanCopyText(String value) { + String text = firstNonBlank(value); + if (!hasText(text)) { + return ""; + } + return text + .replaceAll("(?m)^\\s*https?://\\S+\\s*$", "") + .replaceAll("https?://\\S+", "") + .replaceAll("(?m)^[ \\t]+|[ \\t]+$", "") + .replaceAll("\\n{3,}", "\n\n") + .trim(); + } + + private JsonNode unwrapPossiblyJsonText(JsonNode node) { + JsonNode current = node; + for (int i = 0; i < 3; i++) { + if (current == null || current.isMissingNode() || !current.isTextual()) { + return current; + } + String text = current.asText("").trim(); + if (!(text.startsWith("{") || text.startsWith("["))) { + return current; + } + try { + current = objectMapper.readTree(text); + } catch (Exception ignored) { + return current; + } + } + return current; + } + + private String findText(JsonNode node, List fieldNames) { + if (node == null || node.isMissingNode() || node.isNull()) { + return ""; + } + for (String fieldName : fieldNames) { + JsonNode value = node.path(fieldName); + String text = normalizeNodeText(value); + if (hasText(text)) { + return text; + } + } + if (node.isObject()) { + for (JsonNode child : node) { + String text = findText(unwrapPossiblyJsonText(child), fieldNames); + if (hasText(text)) { + return text; + } + } + } + if (node.isArray()) { + for (JsonNode child : node) { + String text = findText(unwrapPossiblyJsonText(child), fieldNames); + if (hasText(text)) { + return text; + } + } + } + return ""; + } + + private String normalizeNodeText(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return ""; + } + JsonNode unwrapped = unwrapPossiblyJsonText(node); + if (unwrapped != node) { + return flattenText(unwrapped); + } + if (node.isTextual()) { + return node.asText(""); + } + if (node.isNumber() || node.isBoolean()) { + return node.asText(""); + } + return flattenText(node); + } + + private String flattenText(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return ""; + } + if (node.isTextual() || node.isNumber() || node.isBoolean()) { + return node.asText("").trim(); + } + if (node.isArray()) { + StringBuilder out = new StringBuilder(); + for (JsonNode child : node) { + String text = flattenText(unwrapPossiblyJsonText(child)); + if (hasText(text)) { + if (!out.isEmpty()) { + out.append('\n'); + } + out.append(text.trim()); + } + } + return out.toString(); + } + if (node.isObject()) { + StringBuilder out = new StringBuilder(); + for (JsonNode child : node) { + String text = flattenText(unwrapPossiblyJsonText(child)); + if (hasText(text)) { + if (!out.isEmpty()) { + out.append('\n'); + } + out.append(text.trim()); + } + } + return out.toString(); + } + return ""; + } + + private String postCozeJson(String label, String endpoint, Map body, String cozeToken) { + try { + String requestBody = objectMapper.writeValueAsString(body == null ? Map.of() : body); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(endpoint)) + .timeout(Duration.ofMillis(Math.max(1000, properties.getCozeReadTimeoutMillis()))) + .header("Authorization", "Bearer " + stripBearer(cozeToken)) + .header("Content-Type", "application/json; charset=UTF-8") + .header("Accept-Charset", StandardCharsets.UTF_8.name()) + .POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = httpClient().send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + String responseBody = response.body() == null ? "" : response.body(); + log.info("[image-video] {} coze response status={} bodyLength={}", + label, response.statusCode(), responseBody.length()); + log.info("[image-video] {} coze response detail status={} body={}", + label, response.statusCode(), responseBody); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new BusinessException("Coze 调用失败: HTTP " + response.statusCode()); + } + return responseBody; + } catch (BusinessException ex) { + throw ex; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new BusinessException("Coze 调用被中断", ex); + } catch (Exception ex) { + throw new BusinessException("Coze 调用失败: " + ex.getMessage(), ex); + } + } + + private String getCozeJson(String label, String endpoint, String cozeToken) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(endpoint)) + .timeout(Duration.ofMillis(Math.max(1000, properties.getCozeReadTimeoutMillis()))) + .header("Authorization", "Bearer " + stripBearer(cozeToken)) + .header("Content-Type", "application/json; charset=UTF-8") + .header("Accept-Charset", StandardCharsets.UTF_8.name()) + .GET() + .build(); + HttpResponse response = httpClient().send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + String responseBody = response.body() == null ? "" : response.body(); + log.info("[image-video] {} coze response status={} bodyLength={}", + label, response.statusCode(), responseBody.length()); + log.info("[image-video] {} coze response detail status={} body={}", + label, response.statusCode(), responseBody); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new BusinessException("Coze 调用失败: HTTP " + response.statusCode()); + } + return responseBody; + } catch (BusinessException ex) { + throw ex; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new BusinessException("Coze 调用被中断", ex); + } catch (Exception ex) { + throw new BusinessException("Coze 调用失败: " + ex.getMessage(), ex); + } + } + + private HttpClient httpClient() { + HttpClient client = sharedHttpClient; + if (client == null) { + synchronized (this) { + if (sharedHttpClient == null) { + sharedHttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(Math.max(1000, properties.getCozeConnectTimeoutMillis()))) + .build(); + } + client = sharedHttpClient; + } + } + return client; + } + + private String joinUrl(String baseUrl, String path) { + String base = firstNonBlank(baseUrl, "https://api.coze.cn").trim(); + String suffix = firstNonBlank(path, "/v1/workflow/run").trim(); + if (base.endsWith("/") && suffix.startsWith("/")) { + return base.substring(0, base.length() - 1) + suffix; + } + if (!base.endsWith("/") && !suffix.startsWith("/")) { + return base + "/" + suffix; + } + return base + suffix; + } + + private String stripBearer(String token) { + String normalized = token == null ? "" : token.trim(); + if (normalized.regionMatches(true, 0, "Bearer ", 0, 7)) { + return normalized.substring(7).trim(); + } + return normalized; + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (hasText(value)) { + return value; + } + } + return ""; + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + private String asText(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String resolveMediaType(String contentType, String filename) { + String type = contentType == null ? "" : contentType.toLowerCase(); + String name = filename == null ? "" : filename.toLowerCase(); + if (type.startsWith("image/") || name.matches(".*\\.(png|jpe?g|webp|gif|bmp)$")) { + return "image"; + } + if (type.startsWith("video/") || name.matches(".*\\.(mp4|mov|webm|m4v|avi|mkv)$")) { + return "video"; + } + if (type.startsWith("audio/") || name.matches(".*\\.(mp3|wav|m4a|aac|flac|ogg)$")) { + return "audio"; + } + return "file"; + } + + private String toJsonForLog(Object value) { + try { + return objectMapper.writeValueAsString(maskSecretsForLog(value)); + } catch (Exception ex) { + return String.valueOf(value); + } + } + + private Object maskSecretsForLog(Object value) { + if (value instanceof Map source) { + Map masked = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + String key = String.valueOf(entry.getKey()); + Object rawValue = entry.getValue(); + if (isSensitiveLogKey(key)) { + masked.put(key, maskForLog(asText(rawValue))); + } else { + masked.put(key, maskSecretsForLog(rawValue)); + } + } + return masked; + } + if (value instanceof List source) { + List masked = new ArrayList<>(source.size()); + for (Object item : source) { + masked.add(maskSecretsForLog(item)); + } + return masked; + } + return value; + } + + private boolean isSensitiveLogKey(String key) { + String normalized = key == null ? "" : key.toLowerCase(); + return normalized.contains("token") + || normalized.contains("secret") + || normalized.contains("authorization") + || normalized.endsWith("_key") + || normalized.equals("key") + || normalized.equals("api_key") + || normalized.equals("t8_key"); + } + + private String maskForLog(String value) { + if (!hasText(value)) { + return ""; + } + String text = value.trim(); + if (text.length() <= 10) { + return "***"; + } + return text.substring(0, 6) + "***" + text.substring(text.length() - 4); + } +} 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 new file mode 100644 index 0000000..d86100a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoSecretService.java @@ -0,0 +1,211 @@ +package com.nanri.aiimage.modules.imagevideo.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.common.security.ShopCredentialCryptoService; +import com.nanri.aiimage.config.ImageVideoProperties; +import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoSecretMapper; +import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoSecretSaveRequest; +import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoSecretEntity; +import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoSecretStatusVo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class ImageVideoSecretService { + + private static final Set ALLOWED_EXPIRE_DAYS = Set.of(1, 7, 30); + + private final ImageVideoSecretMapper secretMapper; + private final ShopCredentialCryptoService cryptoService; + private final ImageVideoProperties properties; + + public ImageVideoSecretStatusVo status(Long userId) { + validateUserId(userId); + return toStatus(userId, selectByUserId(userId)); + } + + @Transactional + 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 天"); + } + + LocalDateTime now = LocalDateTime.now(); + ImageVideoSecretEntity existing = selectByUserId(request.getUserId()); + boolean mustInputAll = existing == null || existing.getExpiresAt() == null || !existing.getExpiresAt().isAfter(now); + + String copyApiKey = normalize(request.getCopyApiKey()); + String t8Key = normalize(request.getT8Key()); + String voiceApiKey = normalize(request.getVoiceApiKey()); + String voiceGroupId = normalize(request.getVoiceGroupId()); + if (mustInputAll) { + requireText(copyApiKey, "文案 key 不能为空"); + requireText(t8Key, "t8_key 不能为空"); + requireText(voiceApiKey, "音色 key 不能为空"); + requireText(voiceGroupId, "音色 groupId 不能为空"); + } + + ImageVideoSecretEntity row = existing == null ? new ImageVideoSecretEntity() : existing; + row.setUserId(request.getUserId()); + if (hasText(copyApiKey)) { + row.setCopyApiKey(cryptoService.encrypt(copyApiKey)); + } + if (hasText(t8Key)) { + row.setT8Key(cryptoService.encrypt(t8Key)); + } + if (hasText(voiceApiKey)) { + row.setVoiceApiKey(cryptoService.encrypt(voiceApiKey)); + } + if (hasText(voiceGroupId)) { + row.setVoiceGroupId(cryptoService.encrypt(voiceGroupId)); + } + row.setExpiresAt(now.plusDays(expireDays)); + row.setUpdatedAt(now); + + if (row.getId() == null) { + row.setCreatedAt(now); + secretMapper.insert(row); + } else { + secretMapper.updateById(row); + } + return toStatus(request.getUserId(), row); + } + + public ResolvedSecret requireValid(Long userId) { + validateUserId(userId); + ImageVideoSecretEntity row = selectByUserId(userId); + LocalDateTime now = LocalDateTime.now(); + if (row == null) { + throw new BusinessException("请先配置视频密钥"); + } + if (row.getExpiresAt() == null || !row.getExpiresAt().isAfter(now)) { + throw new BusinessException("视频密钥已过期,请重新配置"); + } + String cozeToken = normalize(properties.getCozeToken()); + String copyApiKey = decrypt(row.getCopyApiKey()); + String t8Key = decrypt(row.getT8Key()); + 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)) { + throw new BusinessException("视频密钥未配置完整,请重新配置"); + } + return new ResolvedSecret(cozeToken, copyApiKey, t8Key, voiceApiKey, voiceGroupId); + } + + private ImageVideoSecretEntity selectByUserId(Long userId) { + return secretMapper.selectOne(new LambdaQueryWrapper() + .eq(ImageVideoSecretEntity::getUserId, userId) + .last("limit 1")); + } + + private ImageVideoSecretStatusVo toStatus(Long userId, ImageVideoSecretEntity row) { + ImageVideoSecretStatusVo vo = new ImageVideoSecretStatusVo(); + vo.setUserId(userId); + if (row == null) { + vo.setConfigured(false); + vo.setValid(false); + vo.setExpired(false); + vo.setHasCopyApiKey(false); + vo.setHasT8Key(false); + vo.setHasVoiceApiKey(false); + vo.setHasVoiceGroupId(false); + return vo; + } + + String copyApiKey = decrypt(row.getCopyApiKey()); + String t8Key = decrypt(row.getT8Key()); + String voiceApiKey = decrypt(row.getVoiceApiKey()); + String voiceGroupId = decrypt(row.getVoiceGroupId()); + boolean hasCopy = hasText(copyApiKey); + boolean hasT8 = hasText(t8Key); + boolean hasVoice = hasText(voiceApiKey); + boolean hasGroup = hasText(voiceGroupId); + boolean expired = row.getExpiresAt() != null && !row.getExpiresAt().isAfter(LocalDateTime.now()); + boolean configured = hasCopy && hasT8 && hasVoice && hasGroup; + + vo.setConfigured(configured); + vo.setValid(configured && !expired && row.getExpiresAt() != null); + vo.setExpired(expired); + vo.setHasCopyApiKey(hasCopy); + vo.setHasT8Key(hasT8); + vo.setHasVoiceApiKey(hasVoice); + vo.setHasVoiceGroupId(hasGroup); + vo.setCopyApiKeyMasked(mask(copyApiKey)); + vo.setT8KeyMasked(mask(t8Key)); + vo.setVoiceApiKeyMasked(mask(voiceApiKey)); + vo.setVoiceGroupIdMasked(mask(voiceGroupId)); + vo.setExpireDays(resolveExpireDays(row)); + vo.setExpiresAt(row.getExpiresAt()); + return vo; + } + + private Integer resolveExpireDays(ImageVideoSecretEntity row) { + if (row.getExpiresAt() == null) { + return 7; + } + LocalDateTime baseTime = row.getUpdatedAt() != null ? row.getUpdatedAt() : row.getCreatedAt(); + if (baseTime == null) { + return 7; + } + long days = java.time.Duration.between(baseTime, row.getExpiresAt()).toDays(); + if (days >= 25) { + return 30; + } + if (days >= 5) { + return 7; + } + return 1; + } + + private String decrypt(String cipherText) { + if (!hasText(cipherText)) { + return ""; + } + return cryptoService.decrypt(cipherText); + } + + private String mask(String value) { + if (!hasText(value)) { + return ""; + } + String text = value.trim(); + if (text.length() <= 8) { + return "****"; + } + return text.substring(0, 4) + "****" + text.substring(text.length() - 4); + } + + private void validateUserId(Long userId) { + if (userId == null || userId <= 0) { + throw new BusinessException("user_id 不合法"); + } + } + + private void requireText(String value, String message) { + if (!hasText(value)) { + throw new BusinessException(message); + } + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + public record ResolvedSecret(String cozeToken, String copyApiKey, String t8Key, String voiceApiKey, String voiceGroupId) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoWorkflowConfigService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoWorkflowConfigService.java new file mode 100644 index 0000000..1029883 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoWorkflowConfigService.java @@ -0,0 +1,60 @@ +package com.nanri.aiimage.modules.imagevideo.service; + +import com.nanri.aiimage.config.ImageVideoProperties; +import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoWorkflowConfigMapper; +import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoWorkflowConfigEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ImageVideoWorkflowConfigService { + + public static final String DOUYIN_COPY = "douyin_copy"; + public static final String IMAGE_VIDEO = "image_video"; + public static final String VOICE_DELETE = "voice_delete"; + public static final String VOICE_LIST = "voice_list"; + public static final String VOICE_CLONE = "voice_clone"; + public static final String VOICE_SYNTHESIS = "voice_synthesis"; + + private final ImageVideoWorkflowConfigMapper configMapper; + private final ImageVideoProperties properties; + + public String douyinCopyWorkflowId() { + return resolve(DOUYIN_COPY, properties.getDouyinCopyWorkflowId()); + } + + public String imageVideoWorkflowId() { + return resolve(IMAGE_VIDEO, properties.getImageVideoWorkflowId()); + } + + public String voiceDeleteWorkflowId() { + return resolve(VOICE_DELETE, properties.getVoiceDeleteWorkflowId()); + } + + public String voiceListWorkflowId() { + return resolve(VOICE_LIST, properties.getVoiceListWorkflowId()); + } + + public String voiceCloneWorkflowId() { + return resolve(VOICE_CLONE, properties.getVoiceCloneWorkflowId()); + } + + public String voiceSynthesisWorkflowId() { + return resolve(VOICE_SYNTHESIS, properties.getVoiceSynthesisWorkflowId()); + } + + private String resolve(String configKey, String fallback) { + ImageVideoWorkflowConfigEntity row = configMapper.selectById(configKey); + String configured = row == null ? "" : normalize(row.getWorkflowId()); + return hasText(configured) ? configured : normalize(fallback); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} 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 a72fd90..ab99aa2 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 @@ -1,35 +1,55 @@ package com.nanri.aiimage.modules.pricetrack.model.vo; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; -import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** - * 跟价跳过 ASIN 分页返回 + * Price-track skipped ASIN page response. */ @Data -@Schema(description = "跟价跳过 ASIN 分页返回") +@Schema(description = "Price-track skipped ASIN page response") public class SkipPriceAsinPageVo { - @Schema(description = "当前页码,从 1 开始") + @Schema(description = "Current page, starting from 1") private Integer page; - @Schema(description = "每页条数") + @Schema(description = "Page size") private Integer pageSize; - @Schema(description = "总记录数") + @Schema(description = "Total records") private Long total; - @Schema(description = "总页数") + @Schema(description = "Total pages") private Integer totalPages; - @Schema(description = "按国家分组的 ASIN 列表(简化版,仅 ASIN)") + @Schema(description = "ASIN list grouped by country") private Map> skipAsinsByCountry = new LinkedHashMap<>(); - @Schema(description = "按国家分组的 ASIN 详情列表(包含 asin 和 minimumPrice)") + @Schema(description = "ASIN detail list grouped by country, including asin and minimumPrice") private Map>> skipAsinDetailsByCountry = new LinkedHashMap<>(); + + @JsonProperty("skip_asins") + @Schema(description = "Legacy Python queue field: ASIN list grouped by country") + private Map> skipAsins = new LinkedHashMap<>(); + + @JsonProperty("skip_asins_by_country") + @Schema(description = "Legacy Python queue field: ASIN list grouped by country") + private Map> skipAsinsByCountryLegacy = new LinkedHashMap<>(); + + @JsonProperty("skip_asin_details_by_country") + @Schema(description = "Legacy Python queue field: ASIN detail list grouped by country") + private Map>> skipAsinDetailsByCountryLegacy = new LinkedHashMap<>(); + + @JsonProperty("asin_rows_by_country") + @Schema(description = "Legacy Python queue field: appointed ASIN rows grouped by country") + 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") + private Map> minimumPriceByCountryAndAsin = new LinkedHashMap<>(); } 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 8426394..83c420c 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 @@ -61,6 +61,7 @@ public class PriceTrackTaskService { private static final String MODULE_TYPE = "PRICE_TRACK"; private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private static final String ASIN_ROWS_PAYLOAD_SCOPE = "price-track-asin-rows"; private final FileTaskMapper fileTaskMapper; private final FileResultMapper fileResultMapper; @@ -333,7 +334,7 @@ public class PriceTrackTaskService { if (taskIds == null || taskIds.isEmpty()) { return batch; } - Map taskMap = loadTaskMapByIds(taskIds); + Map taskMap = loadTaskProgressMapByIds(taskIds); for (Long taskId : taskIds) { if (taskId == null || taskId <= 0) { continue; @@ -350,6 +351,41 @@ public class PriceTrackTaskService { return batch; } + private Map loadTaskProgressMapByIds(List taskIds) { + Map result = new LinkedHashMap<>(); + if (taskIds == null || taskIds.isEmpty()) { + return result; + } + List normalizedTaskIds = taskIds.stream() + .filter(taskId -> taskId != null && taskId > 0) + .distinct() + .toList(); + if (normalizedTaskIds.isEmpty()) { + return result; + } + int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize()); + for (int start = 0; start < normalizedTaskIds.size(); start += batchSize) { + int end = Math.min(start + batchSize, normalizedTaskIds.size()); + List tasks = fileTaskMapper.selectList(new LambdaQueryWrapper() + .select(FileTaskEntity::getId, + FileTaskEntity::getTaskNo, + FileTaskEntity::getStatus, + FileTaskEntity::getErrorMessage, + FileTaskEntity::getCreatedAt, + FileTaskEntity::getUpdatedAt, + FileTaskEntity::getFinishedAt, + FileTaskEntity::getRequestJson) + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .in(FileTaskEntity::getId, normalizedTaskIds.subList(start, end))); + for (FileTaskEntity task : tasks) { + if (task != null && task.getId() != null) { + result.put(task.getId(), task); + } + } + } + return result; + } + @Transactional public PriceTrackCreateTaskVo createTask(PriceTrackCreateTaskRequest request) { if (request.isStatusMode() == request.isAsinMode()) throw new BusinessException("跟价模式必须二选一"); @@ -372,7 +408,11 @@ public class PriceTrackTaskService { Map> skipAsinsByCountry = new LinkedHashMap<>(); Map>> skipAsinDetailsByCountry = new LinkedHashMap<>(); Map>> asinRowsByCountry = new LinkedHashMap<>(); + Map>> asinRowsForPayload = new LinkedHashMap<>(); Map> minimumPriceByCountryAndAsin = new LinkedHashMap<>(); + if (request.isAsinMode()) { + asinRowsForPayload = parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes()); + } log.info("[price-track] createTask skipAsins countries={} asinMode={}", skipAsinsByCountry.keySet(), request.isAsinMode()); @@ -391,6 +431,9 @@ public class PriceTrackTaskService { task.setUpdatedAt(LocalDateTime.now()); fileTaskMapper.insert(task); priceTrackTaskCacheService.touchTaskHeartbeat(task.getId()); + if (request.isAsinMode()) { + saveTaskAsinRowsPayload(task.getId(), asinRowsForPayload); + } if (request.getLoopRunId() != null) { priceTrackLoopRunService.bindChildTask(request.getLoopRunId(), task.getId(), request.getRoundIndex(), request.getShopIndex()); } @@ -507,7 +550,7 @@ public class PriceTrackTaskService { continue; } if (countPayloadRows(merged) <= 0) { - markResultFailed(fr, "no usable price-track rows received"); + markResultFailed(fr, buildNoUsableRowsMessage(payload)); priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey); continue; } @@ -584,7 +627,7 @@ public class PriceTrackTaskService { continue; } if (countPayloadRows(cachedPayload) <= 0) { - markResultFailed(fr, "no usable price-track rows received before interruption"); + markResultFailed(fr, buildNoUsableRowsMessage(cachedPayload)); priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey); changed = true; log.warn("[price-track] stale finalize finished without data taskId={} shop={} fromCompensation={}", @@ -784,21 +827,42 @@ public class PriceTrackTaskService { PriceTrackCreateTaskRequest request = parseTaskRequest(task); - // 从任务中获取国家代码 + // Resolve country codes from task request. List countryCodes = request.getCountryCodes(); - // 判断是全量模式还是文件模式 + // Choose full mode or uploaded-ASIN file mode. boolean isAsinMode = request.isAsinMode(); if (isAsinMode) { - // 文件模式:从任务 requestJson 中的上传文件解析数据并分页返回 + Map>> storedRows = loadTaskAsinRowsPayload(taskId); + if (storedRows != null) { + return getTaskAsinRowsPaginated(storedRows, countryCodes, page, pageSize); + } + // File mode reads parsed uploaded ASIN rows. return getTaskAsinFilePaginated(request, countryCodes, page, pageSize); } else { - // 全量模式:从 biz_skip_price_asin 表查询 - return skipPriceAsinService.listSkipAsinsPaginated(null, countryCodes, page, pageSize); + // Full mode uses all ASINs and minimum prices from biz_skip_price_asin. + return skipPriceAsinService.listSkipAsinsPaginated(List.of(), countryCodes, page, pageSize); } } + private List resolveTaskShopNames(PriceTrackCreateTaskRequest request) { + List shopNames = new ArrayList<>(); + if (request == null || request.getItems() == null) { + return shopNames; + } + for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : request.getItems()) { + if (item == null) { + continue; + } + String normalized = ziniaoShopSwitchService.normalizeShopName(item.getShopName()); + if (!normalized.isBlank() && !shopNames.contains(normalized)) { + shopNames.add(normalized); + } + } + return shopNames; + } + public SkipPriceAsinCheckVo checkTaskSkipAsin(Long taskId, String country, String asin) { if (taskId == null || taskId <= 0) { throw new BusinessException("taskId 不合法"); @@ -909,11 +973,13 @@ public class PriceTrackTaskService { // 提取当前页的数据(跨国家顺序提取) Map> skipAsinsByCountry = new LinkedHashMap<>(); Map>> skipDetailsByCountry = new LinkedHashMap<>(); + Map>> asinRowsByCountry = new LinkedHashMap<>(); // 初始化所有目标国家 for (String country : targetCountries) { skipAsinsByCountry.put(country, new ArrayList<>()); skipDetailsByCountry.put(country, new ArrayList<>()); + asinRowsByCountry.put(country, new ArrayList<>()); } int currentIndex = 0; @@ -938,6 +1004,7 @@ public class PriceTrackTaskService { detail.put("asin", asin); detail.put("minimumPrice", minimumPrice == null ? "" : minimumPrice); skipDetailsByCountry.get(country).add(detail); + asinRowsByCountry.get(country).add(new LinkedHashMap<>(row)); } } @@ -960,10 +1027,168 @@ public class PriceTrackTaskService { vo.setTotalPages(totalPages); vo.setSkipAsinsByCountry(skipAsinsByCountry); vo.setSkipAsinDetailsByCountry(skipDetailsByCountry); + vo.setSkipAsins(skipAsinsByCountry); + vo.setSkipAsinsByCountryLegacy(skipAsinsByCountry); + vo.setSkipAsinDetailsByCountryLegacy(skipDetailsByCountry); + vo.setAsinRowsByCountry(asinRowsByCountry); + vo.setMinimumPriceByCountryAndAsin(buildMinimumPriceByCountryAndAsin(asinRowsByCountry)); return vo; } + private com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo getTaskAsinRowsPaginated( + Map>> allAsinRows, List countryCodes, int page, int pageSize) { + if (page < 1) { + page = 1; + } + if (pageSize < 1 || pageSize > 2000) { + pageSize = 1000; + } + if (allAsinRows == null) { + allAsinRows = new LinkedHashMap<>(); + } + + List targetCountries = (countryCodes == null || countryCodes.isEmpty()) + ? new ArrayList<>(allAsinRows.keySet()) + : countryCodes; + + long totalRows = 0; + for (String country : targetCountries) { + List> rows = allAsinRows.getOrDefault(country, List.of()); + totalRows += rows.size(); + } + + int totalPages = (int) ((totalRows + pageSize - 1) / pageSize); + int startIndex = (page - 1) * pageSize; + int endIndex = Math.min(startIndex + pageSize, (int) totalRows); + + Map> skipAsinsByCountry = new LinkedHashMap<>(); + Map>> skipDetailsByCountry = new LinkedHashMap<>(); + Map>> asinRowsByCountry = new LinkedHashMap<>(); + for (String country : targetCountries) { + skipAsinsByCountry.put(country, new ArrayList<>()); + skipDetailsByCountry.put(country, new ArrayList<>()); + asinRowsByCountry.put(country, new ArrayList<>()); + } + + int currentIndex = 0; + boolean shouldBreak = false; + for (String country : targetCountries) { + if (shouldBreak) { + break; + } + List> countryRows = allAsinRows.getOrDefault(country, List.of()); + for (Map row : countryRows) { + if (currentIndex >= startIndex && currentIndex < endIndex) { + String asin = row.get("asin"); + String minimumPrice = row.get("minimumPrice"); + if (asin != null && !asin.isBlank()) { + skipAsinsByCountry.get(country).add(asin); + Map detail = new LinkedHashMap<>(); + detail.put("asin", asin); + detail.put("minimumPrice", minimumPrice == null ? "" : minimumPrice); + skipDetailsByCountry.get(country).add(detail); + asinRowsByCountry.get(country).add(new LinkedHashMap<>(row)); + } + } + currentIndex++; + if (currentIndex >= endIndex) { + shouldBreak = true; + break; + } + } + } + + 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(totalRows); + vo.setTotalPages(totalPages); + vo.setSkipAsinsByCountry(skipAsinsByCountry); + vo.setSkipAsinDetailsByCountry(skipDetailsByCountry); + vo.setSkipAsins(skipAsinsByCountry); + vo.setSkipAsinsByCountryLegacy(skipAsinsByCountry); + vo.setSkipAsinDetailsByCountryLegacy(skipDetailsByCountry); + vo.setAsinRowsByCountry(asinRowsByCountry); + vo.setMinimumPriceByCountryAndAsin(buildMinimumPriceByCountryAndAsin(asinRowsByCountry)); + return vo; + } + + private void saveTaskAsinRowsPayload(Long taskId, Map>> asinRowsByCountry) { + if (asinRowsByCountry == null) { + return; + } + taskResultPayloadService.saveLatest(taskId, MODULE_TYPE, ASIN_ROWS_PAYLOAD_SCOPE, asinRowsByCountry); + log.info("[price-track] saved asin rows payload taskId={} countries={} rows={}", + taskId, asinRowsByCountry.keySet(), countAsinRows(asinRowsByCountry)); + } + + private Map>> loadTaskAsinRowsPayload(Long taskId) { + try { + Object payload = taskResultPayloadService.getLatest(taskId, MODULE_TYPE, ASIN_ROWS_PAYLOAD_SCOPE, Map.class); + if (payload == null) { + return null; + } + Map>> rows = normalizeAsinRowsPayload(payload); + if (!rows.isEmpty()) { + log.info("[price-track] loaded asin rows payload taskId={} countries={} rows={}", + taskId, rows.keySet(), countAsinRows(rows)); + } + return rows; + } catch (Exception ex) { + log.warn("[price-track] load asin rows payload failed taskId={} err={}", taskId, ex.getMessage()); + return null; + } + } + + private Map>> normalizeAsinRowsPayload(Object payload) { + Map>> out = new LinkedHashMap<>(); + if (!(payload instanceof Map rawMap)) { + return out; + } + for (Map.Entry entry : rawMap.entrySet()) { + String country = Objects.toString(entry.getKey(), "").trim().toUpperCase(Locale.ROOT); + if (country.isBlank()) { + continue; + } + List> rows = new ArrayList<>(); + if (entry.getValue() instanceof List rawRows) { + for (Object rawRow : rawRows) { + if (!(rawRow instanceof Map rawRowMap)) { + continue; + } + Map row = new LinkedHashMap<>(); + for (Map.Entry cell : rawRowMap.entrySet()) { + String key = Objects.toString(cell.getKey(), "").trim(); + if (key.isBlank()) { + continue; + } + row.put(key, Objects.toString(cell.getValue(), "")); + } + if (!row.isEmpty()) { + rows.add(row); + } + } + } + out.put(country, rows); + } + return out; + } + + private long countAsinRows(Map>> asinRowsByCountry) { + if (asinRowsByCountry == null || asinRowsByCountry.isEmpty()) { + return 0L; + } + long total = 0L; + for (List> rows : asinRowsByCountry.values()) { + if (rows != null) { + total += rows.size(); + } + } + return total; + } + private Map>> parseAsinRowsByCountry(List asinFiles, List countryCodes) { Map>> merged = new LinkedHashMap<>(); if (asinFiles == null || asinFiles.isEmpty()) { @@ -1149,7 +1374,7 @@ public class PriceTrackTaskService { } private boolean isEmptyAsinRow(Map row) { - return row.values().stream().allMatch(value -> value == null || value.isBlank()); + return row == null || normalizeCellText(row.get("asin")).isBlank(); } private String mapHeaderKey(String rawHeader) { @@ -1159,7 +1384,9 @@ public class PriceTrackTaskService { } return switch (header) { case "店铺商城名称", "shopmallname" -> "shopMallName"; - case "asin" -> "asin"; + case "asin", "asin码", "asin编码", "商品asin", "商品编码", "商品编号", + "子asin", "父asin", "amazonasin", "amazon_asin", "amazon asin", + "sellerasin", "seller_asin", "seller asin" -> "asin"; case "价格", "price" -> "price"; case "推荐价", "recommendedprice" -> "recommendedPrice"; case "最低价", "minimumprice" -> "minimumPrice"; @@ -1498,7 +1725,30 @@ public class PriceTrackTaskService { if (payload == null) { return 0; } - return excelAssemblyService.countRows(excelAssemblyService.normalizeCountriesMap(payload.getCountries())); + Map> countries = + excelAssemblyService.normalizeCountriesMap(payload.getCountries()); + if (countries.isEmpty()) { + return 0; + } + int count = 0; + for (List rows : countries.values()) { + if (rows == null || rows.isEmpty()) { + continue; + } + for (PriceTrackSubmitResultRequest.AsinResult row : rows) { + if (row != null && hasText(row.getAsin())) { + count++; + } + } + } + return count; + } + + private String buildNoUsableRowsMessage(PriceTrackSubmitResultRequest.ShopResult payload) { + if (payload != null && hasText(payload.getError())) { + return payload.getError().trim(); + } + return "no usable price-track rows received"; } private String firstNonBlank(String preferred, String fallback) { if (preferred != null && !preferred.isBlank()) { 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 b191667..17cb264 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 @@ -1126,6 +1126,16 @@ public class SkipPriceAsinService { */ public com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo listSkipAsinsPaginated( String shopName, List countryCodes, int page, int pageSize) { + String normalizedShopName = normalizeBlank(shopName); + return listSkipAsinsPaginated( + normalizedShopName.isEmpty() ? List.of() : List.of(normalizedShopName), + countryCodes, + page, + pageSize); + } + + public com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo listSkipAsinsPaginated( + List shopNames, List countryCodes, int page, int pageSize) { if (page < 1) { page = 1; } @@ -1138,9 +1148,17 @@ public class SkipPriceAsinService { .orderByDesc(SkipPriceAsinEntity::getId); // 如果指定了店铺名,按店铺过滤 - if (shopName != null && !shopName.trim().isEmpty()) { - String normalizedShopName = normalizeBlank(shopName); - queryWrapper.eq(SkipPriceAsinEntity::getShopName, normalizedShopName); + LinkedHashSet normalizedShopNames = new LinkedHashSet<>(); + if (shopNames != null) { + for (String shopName : shopNames) { + String normalizedShopName = normalizeBlank(shopName); + if (!normalizedShopName.isEmpty()) { + normalizedShopNames.add(normalizedShopName); + } + } + } + if (!normalizedShopNames.isEmpty()) { + queryWrapper.in(SkipPriceAsinEntity::getShopName, normalizedShopNames); } // 查询所有记录(用于内存分页) @@ -1201,6 +1219,11 @@ public class SkipPriceAsinService { vo.setTotalPages(totalPages); 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; } diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index cd08937..8cd2684 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -86,6 +86,7 @@ aiimage: oss: region: ${AIIMAGE_OSS_REGION:cn-hangzhou} endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com} + public-endpoint: ${AIIMAGE_OSS_PUBLIC_ENDPOINT:oss-cn-hangzhou.aliyuncs.com} bucket: ${AIIMAGE_OSS_BUCKET:nanri-ai-images} access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:LTAI5tNpyvzMNz9f2dHarsm8} access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:bQSZnFH455i8tzyOgeahJmUzwmhynz} @@ -229,6 +230,12 @@ aiimage: coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true} coze-use-legacy-item-field-order: ${AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER:false} coze-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED:true} + image-video: + coze-base-url: ${AIIMAGE_IMAGE_VIDEO_COZE_BASE_URL:https://api.coze.cn} + coze-token: ${AIIMAGE_IMAGE_VIDEO_COZE_TOKEN:} + 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:600000} 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/V1__init.sql b/backend-java/src/main/resources/db/V1__init.sql index 5b3efb0..582798e 100644 --- a/backend-java/src/main/resources/db/V1__init.sql +++ b/backend-java/src/main/resources/db/V1__init.sql @@ -67,7 +67,7 @@ SELECT JSON_ARRAY('ASIN', '价格'), JSON_ARRAY('sku', 'price', 'quantity', 'product-id', 'product-id-type', 'condition-type', 'condition-note', 'ASIN-hint', 'title', 'product-tax-code', 'operation-type', 'sale-price', 'sale-start-date', 'sale-end-date', 'leadtime-to-ship', 'launch-date', 'is-giftwrap-available', 'is-gift-message-available'), JSON_OBJECT('price', '价格', 'product-id', 'ASIN'), - JSON_OBJECT('quantity', '100', 'product-id-type', 'ASIN', 'condition-type', 'new', 'leadtime-to-ship', '4'), + JSON_OBJECT('quantity', '100', 'product-id-type', 'ASIN', 'condition-type', 'new', 'leadtime-to-ship', '5'), JSON_ARRAY('TemplateType=Offer\tVersion=1.4'), 1, 1, 1, 1 diff --git a/backend-java/src/main/resources/db/V2__sync_uk_offer_template.sql b/backend-java/src/main/resources/db/V2__sync_uk_offer_template.sql index b8395a8..5c4d10c 100644 --- a/backend-java/src/main/resources/db/V2__sync_uk_offer_template.sql +++ b/backend-java/src/main/resources/db/V2__sync_uk_offer_template.sql @@ -6,7 +6,7 @@ SET required_source_columns_json = JSON_ARRAY('ASIN', '价格'), 'sale-end-date', 'leadtime-to-ship', 'launch-date', 'is-giftwrap-available', 'is-gift-message-available' ), field_mapping_json = JSON_OBJECT('price', '价格', 'product-id', 'ASIN'), - defaults_json = JSON_OBJECT('quantity', '100', 'product-id-type', 'ASIN', 'condition-type', 'new', 'leadtime-to-ship', '4'), + defaults_json = JSON_OBJECT('quantity', '100', 'product-id-type', 'ASIN', 'condition-type', 'new', 'leadtime-to-ship', '5'), preamble_lines_json = JSON_ARRAY('TemplateType=Offer\tVersion=1.4'), blank_header_rows_after_schema = 1, output_filename = '英国.txt', diff --git a/backend-java/src/main/resources/db/V63__image_video_secret.sql b/backend-java/src/main/resources/db/V63__image_video_secret.sql new file mode 100644 index 0000000..9be642f --- /dev/null +++ b/backend-java/src/main/resources/db/V63__image_video_secret.sql @@ -0,0 +1,13 @@ +CREATE TABLE `biz_image_video_secret` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `copy_api_key` VARCHAR(1024) NOT NULL COMMENT '文案工作流 api_key,加密存储', + `voice_api_key` VARCHAR(1024) NOT NULL COMMENT '音色工作流 api_key,加密存储', + `voice_group_id` VARCHAR(512) NOT NULL COMMENT '音色 group_id,加密存储', + `expires_at` DATETIME NOT NULL COMMENT '过期时间', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_id` (`user_id`), + KEY `idx_expires_at` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频复刻/图生视频用户密钥配置'; diff --git a/backend-java/src/main/resources/db/V64__image_video_secret_coze_token.sql b/backend-java/src/main/resources/db/V64__image_video_secret_coze_token.sql new file mode 100644 index 0000000..68d41ac --- /dev/null +++ b/backend-java/src/main/resources/db/V64__image_video_secret_coze_token.sql @@ -0,0 +1,2 @@ +ALTER TABLE `biz_image_video_secret` + ADD COLUMN `coze_token` VARCHAR(1024) NULL COMMENT 'Encrypted Coze Bearer token' AFTER `user_id`; diff --git a/backend-java/src/main/resources/db/V65__image_video_secret_t8_key.sql b/backend-java/src/main/resources/db/V65__image_video_secret_t8_key.sql new file mode 100644 index 0000000..7455a40 --- /dev/null +++ b/backend-java/src/main/resources/db/V65__image_video_secret_t8_key.sql @@ -0,0 +1,2 @@ +ALTER TABLE `biz_image_video_secret` + ADD COLUMN `t8_key` VARCHAR(1024) NULL COMMENT 'Encrypted rewrite workflow t8_key' AFTER `copy_api_key`; diff --git a/backend-java/src/main/resources/db/V66__image_video_workflow_ids.sql b/backend-java/src/main/resources/db/V66__image_video_workflow_ids.sql new file mode 100644 index 0000000..67db117 --- /dev/null +++ b/backend-java/src/main/resources/db/V66__image_video_workflow_ids.sql @@ -0,0 +1,15 @@ +ALTER TABLE `biz_image_video_secret` + ADD COLUMN `douyin_copy_workflow_id` VARCHAR(128) NULL COMMENT 'Douyin copy workflow id' AFTER `voice_group_id`, + ADD COLUMN `image_video_workflow_id` VARCHAR(128) NULL COMMENT 'Image/video generation workflow id' AFTER `douyin_copy_workflow_id`, + ADD COLUMN `voice_delete_workflow_id` VARCHAR(128) NULL COMMENT 'Voice delete workflow id' AFTER `image_video_workflow_id`, + ADD COLUMN `voice_list_workflow_id` VARCHAR(128) NULL COMMENT 'Voice list workflow id' AFTER `voice_delete_workflow_id`, + ADD COLUMN `voice_clone_workflow_id` VARCHAR(128) NULL COMMENT 'Voice clone workflow id' AFTER `voice_list_workflow_id`, + ADD COLUMN `voice_synthesis_workflow_id` VARCHAR(128) NULL COMMENT 'Voice synthesis workflow id' AFTER `voice_clone_workflow_id`; + +UPDATE `biz_image_video_secret` + SET `douyin_copy_workflow_id` = COALESCE(NULLIF(`douyin_copy_workflow_id`, ''), '7658723854722383907'), + `image_video_workflow_id` = COALESCE(NULLIF(`image_video_workflow_id`, ''), '7657925338589544457'), + `voice_delete_workflow_id` = COALESCE(NULLIF(`voice_delete_workflow_id`, ''), '7652954386453299243'), + `voice_list_workflow_id` = COALESCE(NULLIF(`voice_list_workflow_id`, ''), '7652954332346089498'), + `voice_clone_workflow_id` = COALESCE(NULLIF(`voice_clone_workflow_id`, ''), '7652954356887961641'), + `voice_synthesis_workflow_id` = COALESCE(NULLIF(`voice_synthesis_workflow_id`, ''), '7652954297530105894'); diff --git a/backend-java/src/main/resources/db/V67__image_video_public_workflow_config.sql b/backend-java/src/main/resources/db/V67__image_video_public_workflow_config.sql new file mode 100644 index 0000000..191820d --- /dev/null +++ b/backend-java/src/main/resources/db/V67__image_video_public_workflow_config.sql @@ -0,0 +1,85 @@ +CREATE TABLE IF NOT EXISTS `biz_image_video_workflow_config` ( + `config_key` VARCHAR(64) NOT NULL COMMENT 'Workflow config key', + `workflow_id` VARCHAR(128) NOT NULL COMMENT 'Coze workflow id', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`config_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Image video public workflow config'; + +INSERT INTO `biz_image_video_workflow_config` (`config_key`, `workflow_id`) +VALUES + ('douyin_copy', '7658723854722383907'), + ('image_video', '7659086169628377124'), + ('voice_delete', '7652954386453299243'), + ('voice_list', '7652954332346089498'), + ('voice_clone', '7652954356887961641'), + ('voice_synthesis', '7652954297530105894') +ON DUPLICATE KEY UPDATE + `workflow_id` = VALUES(`workflow_id`), + `updated_at` = CURRENT_TIMESTAMP; + +SET @sql_drop_douyin_copy_workflow_id = ( + SELECT IF(COUNT(*) > 0, 'ALTER TABLE `biz_image_video_secret` DROP COLUMN `douyin_copy_workflow_id`', 'SELECT 1') + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_image_video_secret' + AND COLUMN_NAME = 'douyin_copy_workflow_id' +); +PREPARE stmt_drop_douyin_copy_workflow_id FROM @sql_drop_douyin_copy_workflow_id; +EXECUTE stmt_drop_douyin_copy_workflow_id; +DEALLOCATE PREPARE stmt_drop_douyin_copy_workflow_id; + +SET @sql_drop_image_video_workflow_id = ( + SELECT IF(COUNT(*) > 0, 'ALTER TABLE `biz_image_video_secret` DROP COLUMN `image_video_workflow_id`', 'SELECT 1') + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_image_video_secret' + AND COLUMN_NAME = 'image_video_workflow_id' +); +PREPARE stmt_drop_image_video_workflow_id FROM @sql_drop_image_video_workflow_id; +EXECUTE stmt_drop_image_video_workflow_id; +DEALLOCATE PREPARE stmt_drop_image_video_workflow_id; + +SET @sql_drop_voice_delete_workflow_id = ( + SELECT IF(COUNT(*) > 0, 'ALTER TABLE `biz_image_video_secret` DROP COLUMN `voice_delete_workflow_id`', 'SELECT 1') + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_image_video_secret' + AND COLUMN_NAME = 'voice_delete_workflow_id' +); +PREPARE stmt_drop_voice_delete_workflow_id FROM @sql_drop_voice_delete_workflow_id; +EXECUTE stmt_drop_voice_delete_workflow_id; +DEALLOCATE PREPARE stmt_drop_voice_delete_workflow_id; + +SET @sql_drop_voice_list_workflow_id = ( + SELECT IF(COUNT(*) > 0, 'ALTER TABLE `biz_image_video_secret` DROP COLUMN `voice_list_workflow_id`', 'SELECT 1') + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_image_video_secret' + AND COLUMN_NAME = 'voice_list_workflow_id' +); +PREPARE stmt_drop_voice_list_workflow_id FROM @sql_drop_voice_list_workflow_id; +EXECUTE stmt_drop_voice_list_workflow_id; +DEALLOCATE PREPARE stmt_drop_voice_list_workflow_id; + +SET @sql_drop_voice_clone_workflow_id = ( + SELECT IF(COUNT(*) > 0, 'ALTER TABLE `biz_image_video_secret` DROP COLUMN `voice_clone_workflow_id`', 'SELECT 1') + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_image_video_secret' + AND COLUMN_NAME = 'voice_clone_workflow_id' +); +PREPARE stmt_drop_voice_clone_workflow_id FROM @sql_drop_voice_clone_workflow_id; +EXECUTE stmt_drop_voice_clone_workflow_id; +DEALLOCATE PREPARE stmt_drop_voice_clone_workflow_id; + +SET @sql_drop_voice_synthesis_workflow_id = ( + SELECT IF(COUNT(*) > 0, 'ALTER TABLE `biz_image_video_secret` DROP COLUMN `voice_synthesis_workflow_id`', 'SELECT 1') + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_image_video_secret' + AND COLUMN_NAME = 'voice_synthesis_workflow_id' +); +PREPARE stmt_drop_voice_synthesis_workflow_id FROM @sql_drop_voice_synthesis_workflow_id; +EXECUTE stmt_drop_voice_synthesis_workflow_id; +DEALLOCATE PREPARE stmt_drop_voice_synthesis_workflow_id; diff --git a/backend-java/src/main/resources/db/V68__restore_douyin_copy_workflow_id.sql b/backend-java/src/main/resources/db/V68__restore_douyin_copy_workflow_id.sql new file mode 100644 index 0000000..cd5ffa7 --- /dev/null +++ b/backend-java/src/main/resources/db/V68__restore_douyin_copy_workflow_id.sql @@ -0,0 +1,5 @@ +INSERT INTO `biz_image_video_workflow_config` (`config_key`, `workflow_id`) +VALUES ('douyin_copy', '7652941112982798388') +ON DUPLICATE KEY UPDATE + `workflow_id` = VALUES(`workflow_id`), + `updated_at` = CURRENT_TIMESTAMP; diff --git a/backend-java/src/main/resources/db/V69__update_image_video_workflow_id.sql b/backend-java/src/main/resources/db/V69__update_image_video_workflow_id.sql new file mode 100644 index 0000000..100b378 --- /dev/null +++ b/backend-java/src/main/resources/db/V69__update_image_video_workflow_id.sql @@ -0,0 +1,5 @@ +INSERT INTO `biz_image_video_workflow_config` (`config_key`, `workflow_id`) +VALUES ('image_video', '7659086169628377124') +ON DUPLICATE KEY UPDATE + `workflow_id` = VALUES(`workflow_id`), + `updated_at` = CURRENT_TIMESTAMP; diff --git a/backend-java/src/main/resources/db/V70__convert_leadtime_to_ship_default.sql b/backend-java/src/main/resources/db/V70__convert_leadtime_to_ship_default.sql new file mode 100644 index 0000000..add974d --- /dev/null +++ b/backend-java/src/main/resources/db/V70__convert_leadtime_to_ship_default.sql @@ -0,0 +1,9 @@ +UPDATE biz_convert_template +SET defaults_json = JSON_SET( + COALESCE(defaults_json, JSON_OBJECT()), + '$."leadtime-to-ship"', + '5' + ), + updated_at = NOW() +WHERE JSON_CONTAINS_PATH(defaults_json, 'one', '$."leadtime-to-ship"') + OR JSON_CONTAINS(header_columns_json, JSON_QUOTE('leadtime-to-ship')); diff --git a/backend-java/src/main/resources/db/aiimage.sql b/backend-java/src/main/resources/db/aiimage.sql index d33479d..912d9e8 100644 --- a/backend-java/src/main/resources/db/aiimage.sql +++ b/backend-java/src/main/resources/db/aiimage.sql @@ -44,11 +44,11 @@ CREATE TABLE `biz_convert_template` ( -- ---------------------------- -- Records of biz_convert_template -- ---------------------------- -INSERT INTO `biz_convert_template` VALUES (1, 'uk_offer', '五国模板', '英国.txt、法国.txt、德国.txt、西班牙.txt、意大利.txt', '[\"ASIN\", \"价格\"]', '[\"sku\", \"price\", \"quantity\", \"product-id\", \"product-id-type\", \"condition-type\", \"condition-note\", \"ASIN-hint\", \"title\", \"product-tax-code\", \"operation-type\", \"sale-price\", \"sale-start-date\", \"sale-end-date\", \"leadtime-to-ship\", \"launch-date\", \"is-giftwrap-available\", \"is-gift-message-available\"]', '{\"price\": \"价格\", \"product-id\": \"ASIN\"}', '{\"quantity\": \"100\", \"condition-type\": \"new\", \"product-id-type\": \"ASIN\", \"leadtime-to-ship\": \"4\"}', '[\"TemplateType=Offer\\tVersion=1.4\"]', 1, 1, 1, 1, '2026-03-20 12:47:10', '2026-03-22 02:02:20'); -INSERT INTO `biz_convert_template` VALUES (2, 'user_1773982693758', '测试', '测试.txt', '[\"ASIN\", \"价格\"]', '[\"sku\", \"price\", \"quantity\", \"product-id\", \"product-id-type\", \"condition-type\", \"condition-note\", \"ASIN-hint\", \"title\", \"product-tax-code\", \"operation-type\", \"sale-price\", \"sale-start-date\", \"sale-end-date\", \"leadtime-to-ship\", \"launch-date\", \"is-giftwrap-available\", \"is-gift-message-available\"]', '{\"price\": \"价格\", \"product-id\": \"ASIN\"}', '{\"quantity\": \"100\", \"condition-type\": \"new\", \"product-id-type\": \"ASIN\", \"leadtime-to-ship\": \"4\"}', '[\"TemplateType=Offer\\tVersion=1.4\"]', 1, 1, 0, 0, '2026-03-20 12:58:14', '2026-03-20 13:11:11'); -INSERT INTO `biz_convert_template` VALUES (3, 'user_1773983523504', '英国', '英国.txt', '[\"ASIN\", \"价格\"]', '[\"sku\", \"price\", \"quantity\", \"product-id\", \"product-id-type\", \"condition-type\", \"condition-note\", \"ASIN-hint\", \"title\", \"product-tax-code\", \"operation-type\", \"sale-price\", \"sale-start-date\", \"sale-end-date\", \"leadtime-to-ship\", \"launch-date\", \"is-giftwrap-available\", \"is-gift-message-available\"]', '{\"price\": \"价格\", \"product-id\": \"ASIN\"}', '{\"quantity\": \"100\", \"condition-type\": \"new\", \"product-id-type\": \"ASIN\", \"leadtime-to-ship\": \"4\"}', '[\"TemplateType=Offer\\tVersion=1.4\"]', 1, 0, 0, 0, '2026-03-20 13:12:04', '2026-03-20 13:48:59'); -INSERT INTO `biz_convert_template` VALUES (4, 'user_1774113164444', '英国', '英国.txt', '[\"ASIN\", \"价格\"]', '[\"sku\", \"price\", \"quantity\", \"product-id\", \"product-id-type\", \"condition-type\", \"condition-note\", \"ASIN-hint\", \"title\", \"product-tax-code\", \"operation-type\", \"sale-price\", \"sale-start-date\", \"sale-end-date\", \"leadtime-to-ship\", \"launch-date\", \"is-giftwrap-available\", \"is-gift-message-available\"]', '{\"price\": \"价格\", \"product-id\": \"ASIN\"}', '{\"quantity\": \"100\", \"condition-type\": \"new\", \"product-id-type\": \"ASIN\", \"leadtime-to-ship\": \"4\"}', '[\"TemplateType=Offer\\tVersion=1.4\"]', 1, 0, 0, 0, '2026-03-22 01:12:44', '2026-03-22 01:14:37'); -INSERT INTO `biz_convert_template` VALUES (5, 'user_1774113261643', '英国', '英国.txt', '[\"ASIN\", \"价格\"]', '[\"sku\", \"price\", \"quantity\", \"product-id\", \"product-id-type\", \"condition-type\", \"condition-note\", \"ASIN-hint\", \"title\", \"product-tax-code\", \"operation-type\", \"sale-price\", \"sale-start-date\", \"sale-end-date\", \"leadtime-to-ship\", \"launch-date\", \"is-giftwrap-available\", \"is-gift-message-available\"]', '{\"price\": \"价格\", \"product-id\": \"ASIN\"}', '{\"quantity\": \"100\", \"condition-type\": \"new\", \"product-id-type\": \"ASIN\", \"leadtime-to-ship\": \"4\"}', '[\"TemplateType=Offer\\tVersion=1.4\"]', 1, 1, 0, 0, '2026-03-22 01:14:22', '2026-03-22 01:14:33'); +INSERT INTO `biz_convert_template` VALUES (1, 'uk_offer', '五国模板', '英国.txt、法国.txt、德国.txt、西班牙.txt、意大利.txt', '[\"ASIN\", \"价格\"]', '[\"sku\", \"price\", \"quantity\", \"product-id\", \"product-id-type\", \"condition-type\", \"condition-note\", \"ASIN-hint\", \"title\", \"product-tax-code\", \"operation-type\", \"sale-price\", \"sale-start-date\", \"sale-end-date\", \"leadtime-to-ship\", \"launch-date\", \"is-giftwrap-available\", \"is-gift-message-available\"]', '{\"price\": \"价格\", \"product-id\": \"ASIN\"}', '{\"quantity\": \"100\", \"condition-type\": \"new\", \"product-id-type\": \"ASIN\", \"leadtime-to-ship\": \"5\"}', '[\"TemplateType=Offer\\tVersion=1.4\"]', 1, 1, 1, 1, '2026-03-20 12:47:10', '2026-03-22 02:02:20'); +INSERT INTO `biz_convert_template` VALUES (2, 'user_1773982693758', '测试', '测试.txt', '[\"ASIN\", \"价格\"]', '[\"sku\", \"price\", \"quantity\", \"product-id\", \"product-id-type\", \"condition-type\", \"condition-note\", \"ASIN-hint\", \"title\", \"product-tax-code\", \"operation-type\", \"sale-price\", \"sale-start-date\", \"sale-end-date\", \"leadtime-to-ship\", \"launch-date\", \"is-giftwrap-available\", \"is-gift-message-available\"]', '{\"price\": \"价格\", \"product-id\": \"ASIN\"}', '{\"quantity\": \"100\", \"condition-type\": \"new\", \"product-id-type\": \"ASIN\", \"leadtime-to-ship\": \"5\"}', '[\"TemplateType=Offer\\tVersion=1.4\"]', 1, 1, 0, 0, '2026-03-20 12:58:14', '2026-03-20 13:11:11'); +INSERT INTO `biz_convert_template` VALUES (3, 'user_1773983523504', '英国', '英国.txt', '[\"ASIN\", \"价格\"]', '[\"sku\", \"price\", \"quantity\", \"product-id\", \"product-id-type\", \"condition-type\", \"condition-note\", \"ASIN-hint\", \"title\", \"product-tax-code\", \"operation-type\", \"sale-price\", \"sale-start-date\", \"sale-end-date\", \"leadtime-to-ship\", \"launch-date\", \"is-giftwrap-available\", \"is-gift-message-available\"]', '{\"price\": \"价格\", \"product-id\": \"ASIN\"}', '{\"quantity\": \"100\", \"condition-type\": \"new\", \"product-id-type\": \"ASIN\", \"leadtime-to-ship\": \"5\"}', '[\"TemplateType=Offer\\tVersion=1.4\"]', 1, 0, 0, 0, '2026-03-20 13:12:04', '2026-03-20 13:48:59'); +INSERT INTO `biz_convert_template` VALUES (4, 'user_1774113164444', '英国', '英国.txt', '[\"ASIN\", \"价格\"]', '[\"sku\", \"price\", \"quantity\", \"product-id\", \"product-id-type\", \"condition-type\", \"condition-note\", \"ASIN-hint\", \"title\", \"product-tax-code\", \"operation-type\", \"sale-price\", \"sale-start-date\", \"sale-end-date\", \"leadtime-to-ship\", \"launch-date\", \"is-giftwrap-available\", \"is-gift-message-available\"]', '{\"price\": \"价格\", \"product-id\": \"ASIN\"}', '{\"quantity\": \"100\", \"condition-type\": \"new\", \"product-id-type\": \"ASIN\", \"leadtime-to-ship\": \"5\"}', '[\"TemplateType=Offer\\tVersion=1.4\"]', 1, 0, 0, 0, '2026-03-22 01:12:44', '2026-03-22 01:14:37'); +INSERT INTO `biz_convert_template` VALUES (5, 'user_1774113261643', '英国', '英国.txt', '[\"ASIN\", \"价格\"]', '[\"sku\", \"price\", \"quantity\", \"product-id\", \"product-id-type\", \"condition-type\", \"condition-note\", \"ASIN-hint\", \"title\", \"product-tax-code\", \"operation-type\", \"sale-price\", \"sale-start-date\", \"sale-end-date\", \"leadtime-to-ship\", \"launch-date\", \"is-giftwrap-available\", \"is-gift-message-available\"]', '{\"price\": \"价格\", \"product-id\": \"ASIN\"}', '{\"quantity\": \"100\", \"condition-type\": \"new\", \"product-id-type\": \"ASIN\", \"leadtime-to-ship\": \"5\"}', '[\"TemplateType=Offer\\tVersion=1.4\"]', 1, 1, 0, 0, '2026-03-22 01:14:22', '2026-03-22 01:14:33'); -- ---------------------------- -- Table structure for biz_dedupe_total_data diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 2bf11c3..96fead3 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -1478,6 +1478,26 @@ def get_latest_digital_human_version(): return jsonify(result) +@admin_api.route('/digital-human-versions//download-url') +@admin_required +def get_digital_human_version_download_url(version): + """代理:获取数字人版本下载链接""" + _, _, denied = _ensure_admin_menu_access('digital-human-version') + if denied: + return denied + safe_version = quote((version or '').strip(), safe='') + if not safe_version: + return jsonify({'success': False, 'message': '请指定版本号'}), 400 + result, error_response, status_code = _proxy_backend_java( + 'GET', + f'/api/digital-human/versions/{safe_version}/download-url', + timeout=10 + ) + if error_response: + return error_response, status_code + return jsonify(result) + + @admin_api.route('/digital-human-versions/upload', methods=['POST']) @admin_required def upload_digital_human_version(): @@ -1508,7 +1528,7 @@ def upload_digital_human_version(): '/api/digital-human/versions/upload', files=files, data=data, - timeout=300 + timeout=1800 ) if error_response: return error_response, status_code diff --git a/backend/static/admin.js b/backend/static/admin.js index 5a6b3a5..cff93af 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -3583,8 +3583,6 @@ var changelog = (v.changelog || '-').substring(0, 50); if ((v.changelog || '').length > 50) changelog += '...'; var releasedAt = v.releasedAt || '-'; - var downloadUrl = v.downloadUrl || ''; - var actions = ''; if (v.status === 'DRAFT') { actions += ' '; @@ -3592,8 +3590,8 @@ if (v.status === 'RELEASED' && !v.isLatest) { actions += ' '; } - if (downloadUrl) { - actions += '下载 '; + if (v.status === 'RELEASED') { + actions += ' '; } if (!v.isLatest) { actions += ''; @@ -3629,7 +3627,39 @@ document.getElementById('digitalHumanVersionListBody').innerHTML = '请求失败: ' + (err.message || '') + ''; }); } + function downloadDigitalHumanVersion(encodedVersion) { + var version = decodeURIComponent(encodedVersion || ''); + if (!version) { + alert('版本号为空'); + return; + } + fetch('/api/admin/digital-human-versions/' + encodeURIComponent(version) + '/download-url') + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + throw new Error(res.message || res.error || '获取下载链接失败'); + } + var data = res.data || {}; + var downloadUrl = typeof data === 'string' ? data : (data.downloadUrl || data.url || ''); + if (!downloadUrl) { + throw new Error('Java 后端未返回下载链接'); + } + var a = document.createElement('a'); + a.href = downloadUrl; + a.download = 'ShuFuDigitalHuman-' + version + '.zip'; + a.target = '_blank'; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }) + .catch(function (err) { + alert(err.message || '下载失败'); + }); + } + window.loadDigitalHumanVersions = loadDigitalHumanVersions; + window.downloadDigitalHumanVersion = downloadDigitalHumanVersion; document.getElementById('btnUploadVersion').onclick = function () { var version = (document.getElementById('versionNumber').value || '').trim(); @@ -3720,29 +3750,54 @@ msgEl.textContent = '上传中,请稍候...'; msgEl.classList.remove('err', 'ok'); - fetch('/api/admin/digital-human-versions/upload', { - method: 'POST', - body: formData - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success && res.data) { - msgEl.textContent = '上传成功!版本:' + res.data.version + '(状态:草稿,请在列表中点击"发布"按钮)'; - msgEl.classList.add('ok'); - document.getElementById('digitalHumanVersionNumber').value = ''; - document.getElementById('digitalHumanMinClientVersion').value = ''; - document.getElementById('digitalHumanVersionChangelog').value = ''; - fileInput.value = ''; - loadDigitalHumanVersions(); - } else { - msgEl.textContent = res.message || res.error || '上传失败'; - msgEl.classList.add('err'); - } - }) - .catch(function (err) { - msgEl.textContent = '请求失败: ' + (err.message || ''); + var uploadUrl = window.DIGITAL_HUMAN_JAVA_UPLOAD_URL; + if (!uploadUrl) { + uploadUrl = '/api/admin/digital-human-versions/upload'; + } + + var xhr = new XMLHttpRequest(); + xhr.open('POST', uploadUrl, true); + xhr.timeout = 1800000; + xhr.upload.onprogress = function (event) { + if (event.lengthComputable) { + var percent = Math.floor((event.loaded / event.total) * 100); + msgEl.textContent = '正在上传:' + percent + '%'; + } else { + msgEl.textContent = '正在上传,请稍候...'; + } + }; + xhr.onload = function () { + var res = null; + try { + res = JSON.parse(xhr.responseText || '{}'); + } catch (e) { + msgEl.textContent = '上传失败:Java 返回不是 JSON'; msgEl.classList.add('err'); - }); + return; + } + + if (xhr.status >= 200 && xhr.status < 300 && res.success && res.data) { + msgEl.textContent = '上传成功!版本:' + res.data.version + '(状态:草稿,请在列表中点击"发布"按钮)'; + msgEl.classList.add('ok'); + document.getElementById('digitalHumanVersionNumber').value = ''; + document.getElementById('digitalHumanMinClientVersion').value = ''; + document.getElementById('digitalHumanVersionChangelog').value = ''; + fileInput.value = ''; + loadDigitalHumanVersions(); + } else { + msgEl.textContent = (res && (res.message || res.error)) || ('上传失败:HTTP ' + xhr.status); + msgEl.classList.add('err'); + } + }; + xhr.onerror = function () { + msgEl.textContent = '请求失败:无法连接上传服务 ' + uploadUrl; + msgEl.classList.add('err'); + }; + xhr.ontimeout = function () { + msgEl.textContent = '上传超时,请检查 Java 服务或网关超时配置'; + msgEl.classList.add('err'); + }; + xhr.send(formData); }; window.releaseVersion = function(version) { diff --git a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue index ec44c22..6ff512d 100644 --- a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue @@ -284,6 +284,7 @@ import { getPriceTrackResultDownloadUrl, getPriceTrackTaskProgressBatch, getPriceTrackTasksBatch, + getTaskSkipPriceAsinsPaginated, listPriceTrackCandidates, matchPriceTrackShops, putPriceTrackCountryPreference, @@ -844,17 +845,64 @@ function isRecordMissingError(error: unknown) { return /记录不存在|不存在|已删除|not\s*found|404/i.test(message) } -function buildSkipAsinDeletePolicy() { - const enabled = statusModeEnabled.value && !asinModeEnabled.value - return { - enabled, - mode: enabled ? 'DELETE_WHEN_PRICE_BELOW_MINIMUM' : 'NONE', - delete_when_price_below_minimum: enabled, - compare_field: 'price', - threshold_field: 'minimum_price', - target: 'skip_asin', - source: 'frontend-price-track', +function priceTrackModeForAppClient() { + return statusModeEnabled.value ? 'status' : 'asin' +} + +function buildMinimumPriceByCountryAndAsin(rowsByCountry: Record) { + const out: Record> = {} + for (const [country, rows] of Object.entries(rowsByCountry || {})) { + const bucket: Record = {} + for (const row of rows || []) { + const asin = (row.asin || '').trim() + if (!asin) continue + bucket[asin] = row.minimumPrice == null ? '' : String(row.minimumPrice) + } + out[country] = bucket } + return out +} + +function hasCountryRows(rowsByCountry: Record | undefined) { + return Object.values(rowsByCountry || {}).some((rows) => Array.isArray(rows) && rows.length > 0) +} + +function hasMinimumPriceMap(map: Record> | undefined) { + return Object.values(map || {}).some((rows) => rows && Object.keys(rows).length > 0) +} + +function buildMinimumPriceMapForAppClient(taskVo: PriceTrackCreateTaskVo, asinRowsByCountry: Record) { + return hasMinimumPriceMap(taskVo.minimumPriceByCountryAndAsin) + ? taskVo.minimumPriceByCountryAndAsin || {} + : buildMinimumPriceByCountryAndAsin(asinRowsByCountry) +} + +async function loadAsinRowsForAppClient(taskVo: PriceTrackCreateTaskVo) { + const fromTask = taskVo.asinRowsByCountry || {} + if (hasCountryRows(fromTask)) { + return fromTask + } + if (!asinModeEnabled.value) { + return {} + } + + const merged: Record = {} + let page = 1 + let totalPages = 1 + do { + const res = await getTaskSkipPriceAsinsPaginated(taskVo.taskId, page, 2000) + totalPages = Math.max(1, Number(res.totalPages) || 1) + const pageRows = (res.asin_rows_by_country || res.skipAsinDetailsByCountry || res.skip_asin_details_by_country || {}) as Record< + string, + PriceTrackAsinParsedRow[] + > + for (const [country, rows] of Object.entries(pageRows)) { + merged[country] = [...(merged[country] || []), ...(rows || [])] + } + page += 1 + } while (page <= totalPages) + + return merged } function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) { @@ -953,20 +1001,24 @@ async function pushToPythonQueueLegacy() { saveTaskSnapshotsToStorage() saveTaskDetailsToStorage() - // 2. 推送 taskId 给 Python,Python 会从 Java 拉取完整任务上下文 + const firstRow = matchedRows[0] + const asinRowsByCountry = await loadAsinRowsForAppClient(taskVo) + const minimumPriceByCountryAndAsin = buildMinimumPriceMapForAppClient(taskVo, asinRowsByCountry) + + // 2. 推送 app_client 当前消费的字段 const queuePayload = { type: 'price-track-run', ts: Date.now(), data: { task_id: taskVo.taskId, - shop_names: matchedRows.map((i) => i.shopName).filter(Boolean), + shop_name: (firstRow.shopName || '').trim(), + shopName: (firstRow.shopName || '').trim(), + companyName: firstRow.companyName || '', + shopMallName: firstRow.shopMallName || '', country_codes: resolveCountryCodesForRequest(), - use_skip_asin_check: statusModeEnabled.value && !asinModeEnabled.value, - skip_asin_check_url: `/api/price-track/tasks/${taskVo.taskId}/skip-asin/check`, - use_paginated_skip_asins: false, - skip_asin_page_size: 0, - skip_asin_delete_policy: buildSkipAsinDeletePolicy(), - delete_skip_asin_when_price_below_minimum: statusModeEnabled.value && !asinModeEnabled.value, + mode: priceTrackModeForAppClient(), + asin_rows_by_country: asinRowsByCountry, + minimum_price_by_country_and_asin: minimumPriceByCountryAndAsin, }, } queuePayloadText.value = JSON.stringify(queuePayload, null, 2) @@ -990,49 +1042,24 @@ async function pushToPythonQueueLegacy() { } // ========== 任务状态轮询 ========== -function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) { - const taskItem = taskVo.items?.[0] +async function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) { const shopName = (row.shopName || '').trim() - const skipAsinDeletePolicy = buildSkipAsinDeletePolicy() - const skipAsinCheckPath = `/api/price-track/tasks/${taskVo.taskId}/skip-asin/check` - const resultId = taskItem?.resultId ?? null - const loopRunId = taskItem?.loopRunId ?? null - const roundIndex = taskItem?.roundIndex ?? null - const taskStatus = taskItem?.taskStatus || 'RUNNING' - const success = taskItem?.success ?? false - const error = taskItem?.error || null - const outputFilename = taskItem?.outputFilename || null - const downloadUrl = taskItem?.downloadUrl || null + const asinRowsByCountry = await loadAsinRowsForAppClient(taskVo) + const minimumPriceByCountryAndAsin = buildMinimumPriceMapForAppClient(taskVo, asinRowsByCountry) return { type: 'price-track-run', ts: Date.now(), data: { task_id: taskVo.taskId, - result_id: resultId, shop_name: shopName, - shop_id: row.shopId ?? null, - platform: row.platform || '', - company_name: row.companyName || '', - shop_mall_name: row.shopMallName || '', - matched: !!row.matched, - match_status: row.matchStatus || '', - match_message: row.matchMessage || null, - success: success, - error: error, - output_filename: outputFilename, - download_url: downloadUrl, - task_status: taskStatus, - loop_run_id: loopRunId, - round_index: roundIndex, country_codes: resolveCountryCodesForRequest(), - mode: statusModeEnabled.value ? 'status' : 'asin', - use_skip_asin_check: statusModeEnabled.value && !asinModeEnabled.value, - skip_asin_check_url: skipAsinCheckPath, - use_paginated_skip_asins: false, - skip_asin_page_size: 0, - skip_asin_delete_policy: skipAsinDeletePolicy, - delete_skip_asin_when_price_below_minimum: skipAsinDeletePolicy.delete_when_price_below_minimum, + mode: priceTrackModeForAppClient(), + shopName, + companyName: row.companyName || '', + shopMallName: row.shopMallName || '', + asin_rows_by_country: asinRowsByCountry, + minimum_price_by_country_and_asin: minimumPriceByCountryAndAsin, }, } } @@ -1174,7 +1201,7 @@ async function processMatchedQueue() { } saveTaskSnapshotsToStorage() saveTaskDetailsToStorage() - const queuePayload = buildQueuePayload(taskVo, row) + const queuePayload = await buildQueuePayload(taskVo, row) queuePayloadText.value = JSON.stringify(queuePayload, null, 2) const pushResult = await api.enqueue_json(queuePayload) if (!pushResult?.success) { @@ -1375,7 +1402,7 @@ async function runLoopExecution(loopId?: number) { } })() recordCreatedTask(taskVo) - const queuePayload = buildQueuePayload(taskVo, row) + const queuePayload = await buildQueuePayload(taskVo, row) queuePayloadText.value = JSON.stringify(queuePayload, null, 2) const pushResult = await api.enqueue_json(queuePayload) if (!pushResult?.success) { diff --git a/frontend-vue/src/pages/image-video/ImageVideoPage.vue b/frontend-vue/src/pages/image-video/ImageVideoPage.vue index 4cb70e8..d133279 100644 --- a/frontend-vue/src/pages/image-video/ImageVideoPage.vue +++ b/frontend-vue/src/pages/image-video/ImageVideoPage.vue @@ -58,6 +58,7 @@
+ @@ -69,6 +70,7 @@ import PageShell from '@/components/layout/PageShell.vue' import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue' import DeliveryVideoWorkspace from '@/pages/image-video/components/DeliveryVideoWorkspace.vue' import { getPywebviewApi } from '@/shared/bridges/pywebview' +import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue' type ViewMode = 'menu' | 'delivery' @@ -389,8 +391,17 @@ async function launchDesktop() { background: #0f0f0f; } +:global(html), +:global(body), +:global(#app) { + min-height: 100%; + background: #0f0f0f; +} + .delivery-page__body { + min-height: calc(100vh - 64px); padding: 12px 20px 24px; + background: #0f0f0f; } .delivery-page__top-return { diff --git a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue index beecda3..6ff6f87 100644 --- a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue +++ b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue @@ -5,465 +5,606 @@ +
- - +
+
+ + +
+ + + +
+
- + +
+
+ 画面比例 + +
+
+ 输出分辨率 + +
+
+ 视频时长 +
+ +
+ 自定义 + + +
+
+
+
+ 整体风格 / 视频提示词 + +
+
+
-
- 素材推进方式 - -
- - - -
- 细分类目 - + +
+ 处理方式 + +
+
+ 场景提示词 + +
+ +
-
- 核心卖点 - -
-
+
+ +
+
+ 画面比例 + +
+
+ 输出分辨率 + +
+
+ 视频时长 +
+ +
+ 自定义 + + +
+
+
+
+ 整体风格 / 视频提示词 + +
+
+
+ + +
+ 产品类目 + +
+
+ 产品名称 + +
+
+ 核心卖点 / 产品属性 + +
+ +
+ + +
+ 输入方式 + +
+
+ 性别 + +
+
+ 人物类型 + +
+ +
- -
-
- 画面比例 - -
-
- 输出分辨率 - -
-
- 视频时长 - -
-
- 整体风格 - -
-
- 语言 - - - -
-
- - -
-
- 性别 - -
-
- 语气 - -
-
- -
- 语音来源 - -
- - -
- - -
- 话术模式 - -
- -
-
- 话术内容 - -
-
- 微调约束 - -
-
- - -
- - -
- 场景处理方式 - -
- -
-
- 场景提示词 - -
- -
- -
- 镜头节奏 - -
-
- - -
- 收口动作 - -
- -
- 执行说明 - -
-
- - -
- - 开始组装 - -
- - -
- - 生成镜头方案 - +
+ +
+ 产品类目 + +
+
+ 产品名称 + +
+
+ 核心卖点 / 产品属性 + +
+ +
+ + +
+
+ 语言 + + + +
+ +
+ +
+ 口播台词 + +
+ +
+ 文案来源链接 +
+ + + +
+
+ +
+ 配音方式 + +
+ + + + + +
+ {{ voiceStatusMessage }} +
+
+
+
模式 - {{ currentTabCopy.summaryLabel }} + {{ activeTab === 'remake' ? '视频复刻' : '图生视频' }}
规格 - - {{ currentWorkspace.ratio }} / {{ currentWorkspace.resolution }} - + {{ currentWorkspace.ratio }} / {{ currentWorkspace.resolution }} / {{ + currentWorkspace.durationSeconds }}秒
- 口播 - - {{ currentWorkspace.voiceTone }} / {{ currentWorkspace.language }} - -
-
- 产品类目 - - {{ currentWorkspace.categories.join('、') || '未设置' }} - + 产品 + {{ currentWorkspace.productName || '未设置' }}
- -
- -