更新带货视频工作流接口和页面

This commit is contained in:
super
2026-07-09 00:27:49 +08:00
parent 0ecf5cd45a
commit aab4ce942c
51 changed files with 4777 additions and 820 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<DigitalHumanVersionVo> listVersions() {
List<DigitalHumanVersionEntity> entities = versionMapper.selectList(
new LambdaQueryWrapper<DigitalHumanVersionEntity>()
@@ -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<DigitalHumanVersionEntity>()
.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());
}

View File

@@ -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<UploadFileVo> 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<ExcelInfoVo> 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";
}
}

View File

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

View File

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

View File

@@ -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<ImageVideoSecretStatusVo> secretStatus(@RequestParam("user_id") Long userId) {
return ApiResponse.success(imageVideoSecretService.status(userId));
}
@PutMapping("/secrets")
@Operation(summary = "保存视频密钥配置")
public ApiResponse<ImageVideoSecretStatusVo> saveSecrets(@Valid @RequestBody ImageVideoSecretSaveRequest request) {
return ApiResponse.success(imageVideoSecretService.save(request));
}
@PostMapping("/douyin-copy")
@Operation(summary = "抖音文案识别和仿写")
public ApiResponse<DouyinCopyVo> douyinCopy(@Valid @RequestBody DouyinCopyRequest request) {
return ApiResponse.success(imageVideoCozeService.runDouyinCopy(request.getUserId(), request.getUrl()));
}
@PostMapping("/workflow/run")
@Operation(summary = "提交图生/复刻视频工作流")
public ApiResponse<Map<String, Object>> runWorkflow(@Valid @RequestBody ImageVideoWorkflowRunRequest request) {
return ApiResponse.success(imageVideoCozeService.runImageVideoWorkflow(request.getUserId(), request.getParameters()));
}
@PostMapping("/workflow/result")
@Operation(summary = "查询图生/复刻视频工作流异步结果")
public ApiResponse<Map<String, Object>> workflowResult(@Valid @RequestBody ImageVideoWorkflowResultRequest request) {
return ApiResponse.success(imageVideoCozeService.getImageVideoWorkflowResult(request.getUserId(), request.getExecuteId()));
}
@PostMapping("/media/upload")
@Operation(summary = "上传图生/复刻视频媒体素材")
public ApiResponse<ImageVideoMediaUploadVo> uploadMedia(@RequestParam("file") MultipartFile file) {
return ApiResponse.success(imageVideoCozeService.uploadMedia(file));
}
@PostMapping("/voice/list")
@Operation(summary = "查看音色")
public ApiResponse<Map<String, Object>> listVoices(@Valid @RequestBody ImageVideoVoiceListRequest request) {
return ApiResponse.success(imageVideoCozeService.runVoiceList(request.getUserId()));
}
@PostMapping("/voice/delete")
@Operation(summary = "删除音色")
public ApiResponse<Map<String, Object>> deleteVoice(@Valid @RequestBody ImageVideoVoiceDeleteRequest request) {
return ApiResponse.success(imageVideoCozeService.runVoiceDelete(request.getUserId(), request.getVoiceId()));
}
@PostMapping("/voice/clone")
@Operation(summary = "音色克隆")
public ApiResponse<Map<String, Object>> cloneVoice(@Valid @RequestBody ImageVideoVoiceCloneRequest request) {
return ApiResponse.success(imageVideoCozeService.runVoiceClone(request.getUserId(), request.getAudioUrl(), request.getVideoUrl()));
}
@PostMapping("/voice/synthesis")
@Operation(summary = "语音合成")
public ApiResponse<Map<String, Object>> synthesizeVoice(@Valid @RequestBody ImageVideoVoiceSynthesisRequest request) {
return ApiResponse.success(imageVideoCozeService.runVoiceSynthesis(request.getUserId(), request.getText(), request.getVoiceId()));
}
}

View File

@@ -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<ImageVideoSecretEntity> {
}

View File

@@ -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<ImageVideoWorkflowConfigEntity> {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String, Object> parameters = new LinkedHashMap<>();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String> ORIGINAL_FIELDS = List.of(
"recognizedContent", "recognized_content", "originalContent", "original_content",
"originContent", "origin_content", "rawContent", "raw_content", "description", "content"
);
private static final List<String> 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<String, Object> runImageVideoWorkflow(Long userId, Map<String, Object> 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<String, Object> resolvedParameters = new LinkedHashMap<>(parameters);
Map<String, Object> 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<String, Object> parameters) {
Object faceInfo = parameters.get("face_info");
if (faceInfo instanceof Map<?, ?>) {
Map<String, Object> 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<String, Object> 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<String> normalizeStringList(Object value) {
List<String> 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<String, Object> 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<String, Object> runVoiceList(Long userId) {
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("api_key", secret.voiceApiKey());
return postWorkflowParameters("voice list", workflowConfigService.voiceListWorkflowId(), parameters, secret.cozeToken(), false);
}
public Map<String, Object> runVoiceDelete(Long userId, String voiceId) {
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> runVoiceSynthesis(Long userId, String text, String voiceId) {
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
Map<String, Object> 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<String, Object> parameters = new LinkedHashMap<>();
parameters.put("api_key", resolvedApiKey);
parameters.put("t8_key", resolvedT8Key);
parameters.put("url", url);
Map<String, Object> 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<String, Object> postWorkflowParameters(String label, String workflowId, Map<String, Object> 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<String, Object> 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<String, Object> parseCozeMap(String label, String responseText) {
try {
Map<String, Object> 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<String> 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<String, Object> 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<String> 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<String> 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<String, Object> 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<Object> 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);
}
}

View File

@@ -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<Integer> 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<ImageVideoSecretEntity>()
.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) {
}
}

View File

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

View File

@@ -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<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
@Schema(description = "按国家分组的 ASIN 详情列表(包含 asin minimumPrice")
@Schema(description = "ASIN detail list grouped by country, including asin and minimumPrice")
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = new LinkedHashMap<>();
@JsonProperty("skip_asins")
@Schema(description = "Legacy Python queue field: ASIN list grouped by country")
private Map<String, List<String>> skipAsins = new LinkedHashMap<>();
@JsonProperty("skip_asins_by_country")
@Schema(description = "Legacy Python queue field: ASIN list grouped by country")
private Map<String, List<String>> skipAsinsByCountryLegacy = new LinkedHashMap<>();
@JsonProperty("skip_asin_details_by_country")
@Schema(description = "Legacy Python queue field: ASIN detail list grouped by country")
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountryLegacy = new LinkedHashMap<>();
@JsonProperty("asin_rows_by_country")
@Schema(description = "Legacy Python queue field: appointed ASIN rows grouped by country")
private Map<String, List<Map<String, String>>> asinRowsByCountry = new LinkedHashMap<>();
@JsonProperty("minimum_price_by_country_and_asin")
@Schema(description = "Legacy Python queue field: minimum price map grouped by country and ASIN")
private Map<String, Map<String, String>> minimumPriceByCountryAndAsin = new LinkedHashMap<>();
}

View File

@@ -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<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
Map<Long, FileTaskEntity> taskMap = loadTaskProgressMapByIds(taskIds);
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) {
continue;
@@ -350,6 +351,41 @@ public class PriceTrackTaskService {
return batch;
}
private Map<Long, FileTaskEntity> loadTaskProgressMapByIds(List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
List<Long> 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<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.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<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = new LinkedHashMap<>();
Map<String, List<Map<String, String>>> asinRowsByCountry = new LinkedHashMap<>();
Map<String, List<Map<String, String>>> asinRowsForPayload = new LinkedHashMap<>();
Map<String, Map<String, String>> 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<String> countryCodes = request.getCountryCodes();
// 判断是全量模式还是文件模式
// Choose full mode or uploaded-ASIN file mode.
boolean isAsinMode = request.isAsinMode();
if (isAsinMode) {
// 文件模式:从任务 requestJson 中的上传文件解析数据并分页返回
Map<String, List<Map<String, String>>> 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<String> resolveTaskShopNames(PriceTrackCreateTaskRequest request) {
List<String> 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<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
Map<String, List<Map<String, String>>> skipDetailsByCountry = new LinkedHashMap<>();
Map<String, List<Map<String, String>>> 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<String, List<Map<String, String>>> allAsinRows, List<String> countryCodes, int page, int pageSize) {
if (page < 1) {
page = 1;
}
if (pageSize < 1 || pageSize > 2000) {
pageSize = 1000;
}
if (allAsinRows == null) {
allAsinRows = new LinkedHashMap<>();
}
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
? new ArrayList<>(allAsinRows.keySet())
: countryCodes;
long totalRows = 0;
for (String country : targetCountries) {
List<Map<String, String>> 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<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
Map<String, List<Map<String, String>>> skipDetailsByCountry = new LinkedHashMap<>();
Map<String, List<Map<String, String>>> 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<Map<String, String>> countryRows = allAsinRows.getOrDefault(country, List.of());
for (Map<String, String> 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<String, String> 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<String, List<Map<String, String>>> 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<String, List<Map<String, String>>> loadTaskAsinRowsPayload(Long taskId) {
try {
Object payload = taskResultPayloadService.getLatest(taskId, MODULE_TYPE, ASIN_ROWS_PAYLOAD_SCOPE, Map.class);
if (payload == null) {
return null;
}
Map<String, List<Map<String, String>>> 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<String, List<Map<String, String>>> normalizeAsinRowsPayload(Object payload) {
Map<String, List<Map<String, String>>> 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<Map<String, String>> rows = new ArrayList<>();
if (entry.getValue() instanceof List<?> rawRows) {
for (Object rawRow : rawRows) {
if (!(rawRow instanceof Map<?, ?> rawRowMap)) {
continue;
}
Map<String, String> 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<String, List<Map<String, String>>> asinRowsByCountry) {
if (asinRowsByCountry == null || asinRowsByCountry.isEmpty()) {
return 0L;
}
long total = 0L;
for (List<Map<String, String>> rows : asinRowsByCountry.values()) {
if (rows != null) {
total += rows.size();
}
}
return total;
}
private Map<String, List<Map<String, String>>> parseAsinRowsByCountry(List<String> asinFiles, List<String> countryCodes) {
Map<String, List<Map<String, String>>> merged = new LinkedHashMap<>();
if (asinFiles == null || asinFiles.isEmpty()) {
@@ -1149,7 +1374,7 @@ public class PriceTrackTaskService {
}
private boolean isEmptyAsinRow(Map<String, String> 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<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries =
excelAssemblyService.normalizeCountriesMap(payload.getCountries());
if (countries.isEmpty()) {
return 0;
}
int count = 0;
for (List<PriceTrackSubmitResultRequest.AsinResult> 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()) {

View File

@@ -1126,6 +1126,16 @@ public class SkipPriceAsinService {
*/
public com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo listSkipAsinsPaginated(
String shopName, List<String> 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<String> shopNames, List<String> 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<String> 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;
}

View File

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

View File

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

View File

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

View File

@@ -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='视频复刻/图生视频用户密钥配置';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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