更新带货视频工作流接口和页面
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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='视频复刻/图生视频用户密钥配置';
|
||||
@@ -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`;
|
||||
@@ -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`;
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'));
|
||||
@@ -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
|
||||
|
||||
@@ -1478,6 +1478,26 @@ def get_latest_digital_human_version():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@admin_api.route('/digital-human-versions/<version>/download-url')
|
||||
@admin_required
|
||||
def get_digital_human_version_download_url(version):
|
||||
"""代理:获取数字人版本下载链接"""
|
||||
_, _, denied = _ensure_admin_menu_access('digital-human-version')
|
||||
if denied:
|
||||
return denied
|
||||
safe_version = quote((version or '').strip(), safe='')
|
||||
if not safe_version:
|
||||
return jsonify({'success': False, 'message': '请指定版本号'}), 400
|
||||
result, error_response, status_code = _proxy_backend_java(
|
||||
'GET',
|
||||
f'/api/digital-human/versions/{safe_version}/download-url',
|
||||
timeout=10
|
||||
)
|
||||
if error_response:
|
||||
return error_response, status_code
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@admin_api.route('/digital-human-versions/upload', methods=['POST'])
|
||||
@admin_required
|
||||
def upload_digital_human_version():
|
||||
@@ -1508,7 +1528,7 @@ def upload_digital_human_version():
|
||||
'/api/digital-human/versions/upload',
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=300
|
||||
timeout=1800
|
||||
)
|
||||
if error_response:
|
||||
return error_response, status_code
|
||||
|
||||
@@ -3583,8 +3583,6 @@
|
||||
var changelog = (v.changelog || '-').substring(0, 50);
|
||||
if ((v.changelog || '').length > 50) changelog += '...';
|
||||
var releasedAt = v.releasedAt || '-';
|
||||
var downloadUrl = v.downloadUrl || '';
|
||||
|
||||
var actions = '';
|
||||
if (v.status === 'DRAFT') {
|
||||
actions += '<button class="btn btn-sm" onclick="releaseVersion(\'' + v.version + '\')">发布</button> ';
|
||||
@@ -3592,8 +3590,8 @@
|
||||
if (v.status === 'RELEASED' && !v.isLatest) {
|
||||
actions += '<button class="btn btn-sm" onclick="setLatestVersion(\'' + v.version + '\')">设为最新</button> ';
|
||||
}
|
||||
if (downloadUrl) {
|
||||
actions += '<a href="' + downloadUrl + '" class="btn btn-sm" download>下载</a> ';
|
||||
if (v.status === 'RELEASED') {
|
||||
actions += '<button class="btn btn-sm" onclick="downloadDigitalHumanVersion(\'' + encodeURIComponent(v.version || '') + '\')">下载</button> ';
|
||||
}
|
||||
if (!v.isLatest) {
|
||||
actions += '<button class="btn btn-sm btn-danger" onclick="deleteVersion(\'' + v.version + '\')">删除</button>';
|
||||
@@ -3629,7 +3627,39 @@
|
||||
document.getElementById('digitalHumanVersionListBody').innerHTML = '<tr><td colspan="8" class="empty-tip">请求失败: ' + (err.message || '') + '</td></tr>';
|
||||
});
|
||||
}
|
||||
function downloadDigitalHumanVersion(encodedVersion) {
|
||||
var version = decodeURIComponent(encodedVersion || '');
|
||||
if (!version) {
|
||||
alert('版本号为空');
|
||||
return;
|
||||
}
|
||||
fetch('/api/admin/digital-human-versions/' + encodeURIComponent(version) + '/download-url')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || res.error || '获取下载链接失败');
|
||||
}
|
||||
var data = res.data || {};
|
||||
var downloadUrl = typeof data === 'string' ? data : (data.downloadUrl || data.url || '');
|
||||
if (!downloadUrl) {
|
||||
throw new Error('Java 后端未返回下载链接');
|
||||
}
|
||||
var a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = 'ShuFuDigitalHuman-' + version + '.zip';
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
})
|
||||
.catch(function (err) {
|
||||
alert(err.message || '下载失败');
|
||||
});
|
||||
}
|
||||
|
||||
window.loadDigitalHumanVersions = loadDigitalHumanVersions;
|
||||
window.downloadDigitalHumanVersion = downloadDigitalHumanVersion;
|
||||
|
||||
document.getElementById('btnUploadVersion').onclick = function () {
|
||||
var version = (document.getElementById('versionNumber').value || '').trim();
|
||||
@@ -3720,29 +3750,54 @@
|
||||
msgEl.textContent = '上传中,请稍候...';
|
||||
msgEl.classList.remove('err', 'ok');
|
||||
|
||||
fetch('/api/admin/digital-human-versions/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.success && res.data) {
|
||||
msgEl.textContent = '上传成功!版本:' + res.data.version + '(状态:草稿,请在列表中点击"发布"按钮)';
|
||||
msgEl.classList.add('ok');
|
||||
document.getElementById('digitalHumanVersionNumber').value = '';
|
||||
document.getElementById('digitalHumanMinClientVersion').value = '';
|
||||
document.getElementById('digitalHumanVersionChangelog').value = '';
|
||||
fileInput.value = '';
|
||||
loadDigitalHumanVersions();
|
||||
} else {
|
||||
msgEl.textContent = res.message || res.error || '上传失败';
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
msgEl.textContent = '请求失败: ' + (err.message || '');
|
||||
var uploadUrl = window.DIGITAL_HUMAN_JAVA_UPLOAD_URL;
|
||||
if (!uploadUrl) {
|
||||
uploadUrl = '/api/admin/digital-human-versions/upload';
|
||||
}
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', uploadUrl, true);
|
||||
xhr.timeout = 1800000;
|
||||
xhr.upload.onprogress = function (event) {
|
||||
if (event.lengthComputable) {
|
||||
var percent = Math.floor((event.loaded / event.total) * 100);
|
||||
msgEl.textContent = '正在上传:' + percent + '%';
|
||||
} else {
|
||||
msgEl.textContent = '正在上传,请稍候...';
|
||||
}
|
||||
};
|
||||
xhr.onload = function () {
|
||||
var res = null;
|
||||
try {
|
||||
res = JSON.parse(xhr.responseText || '{}');
|
||||
} catch (e) {
|
||||
msgEl.textContent = '上传失败:Java 返回不是 JSON';
|
||||
msgEl.classList.add('err');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300 && res.success && res.data) {
|
||||
msgEl.textContent = '上传成功!版本:' + res.data.version + '(状态:草稿,请在列表中点击"发布"按钮)';
|
||||
msgEl.classList.add('ok');
|
||||
document.getElementById('digitalHumanVersionNumber').value = '';
|
||||
document.getElementById('digitalHumanMinClientVersion').value = '';
|
||||
document.getElementById('digitalHumanVersionChangelog').value = '';
|
||||
fileInput.value = '';
|
||||
loadDigitalHumanVersions();
|
||||
} else {
|
||||
msgEl.textContent = (res && (res.message || res.error)) || ('上传失败:HTTP ' + xhr.status);
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
msgEl.textContent = '请求失败:无法连接上传服务 ' + uploadUrl;
|
||||
msgEl.classList.add('err');
|
||||
};
|
||||
xhr.ontimeout = function () {
|
||||
msgEl.textContent = '上传超时,请检查 Java 服务或网关超时配置';
|
||||
msgEl.classList.add('err');
|
||||
};
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
window.releaseVersion = function(version) {
|
||||
|
||||
@@ -284,6 +284,7 @@ import {
|
||||
getPriceTrackResultDownloadUrl,
|
||||
getPriceTrackTaskProgressBatch,
|
||||
getPriceTrackTasksBatch,
|
||||
getTaskSkipPriceAsinsPaginated,
|
||||
listPriceTrackCandidates,
|
||||
matchPriceTrackShops,
|
||||
putPriceTrackCountryPreference,
|
||||
@@ -844,17 +845,64 @@ function isRecordMissingError(error: unknown) {
|
||||
return /记录不存在|不存在|已删除|not\s*found|404/i.test(message)
|
||||
}
|
||||
|
||||
function buildSkipAsinDeletePolicy() {
|
||||
const enabled = statusModeEnabled.value && !asinModeEnabled.value
|
||||
return {
|
||||
enabled,
|
||||
mode: enabled ? 'DELETE_WHEN_PRICE_BELOW_MINIMUM' : 'NONE',
|
||||
delete_when_price_below_minimum: enabled,
|
||||
compare_field: 'price',
|
||||
threshold_field: 'minimum_price',
|
||||
target: 'skip_asin',
|
||||
source: 'frontend-price-track',
|
||||
function priceTrackModeForAppClient() {
|
||||
return statusModeEnabled.value ? 'status' : 'asin'
|
||||
}
|
||||
|
||||
function buildMinimumPriceByCountryAndAsin(rowsByCountry: Record<string, PriceTrackAsinParsedRow[]>) {
|
||||
const out: Record<string, Record<string, string>> = {}
|
||||
for (const [country, rows] of Object.entries(rowsByCountry || {})) {
|
||||
const bucket: Record<string, string> = {}
|
||||
for (const row of rows || []) {
|
||||
const asin = (row.asin || '').trim()
|
||||
if (!asin) continue
|
||||
bucket[asin] = row.minimumPrice == null ? '' : String(row.minimumPrice)
|
||||
}
|
||||
out[country] = bucket
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function hasCountryRows(rowsByCountry: Record<string, PriceTrackAsinParsedRow[]> | undefined) {
|
||||
return Object.values(rowsByCountry || {}).some((rows) => Array.isArray(rows) && rows.length > 0)
|
||||
}
|
||||
|
||||
function hasMinimumPriceMap(map: Record<string, Record<string, string>> | undefined) {
|
||||
return Object.values(map || {}).some((rows) => rows && Object.keys(rows).length > 0)
|
||||
}
|
||||
|
||||
function buildMinimumPriceMapForAppClient(taskVo: PriceTrackCreateTaskVo, asinRowsByCountry: Record<string, PriceTrackAsinParsedRow[]>) {
|
||||
return hasMinimumPriceMap(taskVo.minimumPriceByCountryAndAsin)
|
||||
? taskVo.minimumPriceByCountryAndAsin || {}
|
||||
: buildMinimumPriceByCountryAndAsin(asinRowsByCountry)
|
||||
}
|
||||
|
||||
async function loadAsinRowsForAppClient(taskVo: PriceTrackCreateTaskVo) {
|
||||
const fromTask = taskVo.asinRowsByCountry || {}
|
||||
if (hasCountryRows(fromTask)) {
|
||||
return fromTask
|
||||
}
|
||||
if (!asinModeEnabled.value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const merged: Record<string, PriceTrackAsinParsedRow[]> = {}
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
do {
|
||||
const res = await getTaskSkipPriceAsinsPaginated(taskVo.taskId, page, 2000)
|
||||
totalPages = Math.max(1, Number(res.totalPages) || 1)
|
||||
const pageRows = (res.asin_rows_by_country || res.skipAsinDetailsByCountry || res.skip_asin_details_by_country || {}) as Record<
|
||||
string,
|
||||
PriceTrackAsinParsedRow[]
|
||||
>
|
||||
for (const [country, rows] of Object.entries(pageRows)) {
|
||||
merged[country] = [...(merged[country] || []), ...(rows || [])]
|
||||
}
|
||||
page += 1
|
||||
} while (page <= totalPages)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
|
||||
@@ -953,20 +1001,24 @@ async function pushToPythonQueueLegacy() {
|
||||
saveTaskSnapshotsToStorage()
|
||||
saveTaskDetailsToStorage()
|
||||
|
||||
// 2. 推送 taskId 给 Python,Python 会从 Java 拉取完整任务上下文
|
||||
const firstRow = matchedRows[0]
|
||||
const asinRowsByCountry = await loadAsinRowsForAppClient(taskVo)
|
||||
const minimumPriceByCountryAndAsin = buildMinimumPriceMapForAppClient(taskVo, asinRowsByCountry)
|
||||
|
||||
// 2. 推送 app_client 当前消费的字段
|
||||
const queuePayload = {
|
||||
type: 'price-track-run',
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
task_id: taskVo.taskId,
|
||||
shop_names: matchedRows.map((i) => i.shopName).filter(Boolean),
|
||||
shop_name: (firstRow.shopName || '').trim(),
|
||||
shopName: (firstRow.shopName || '').trim(),
|
||||
companyName: firstRow.companyName || '',
|
||||
shopMallName: firstRow.shopMallName || '',
|
||||
country_codes: resolveCountryCodesForRequest(),
|
||||
use_skip_asin_check: statusModeEnabled.value && !asinModeEnabled.value,
|
||||
skip_asin_check_url: `/api/price-track/tasks/${taskVo.taskId}/skip-asin/check`,
|
||||
use_paginated_skip_asins: false,
|
||||
skip_asin_page_size: 0,
|
||||
skip_asin_delete_policy: buildSkipAsinDeletePolicy(),
|
||||
delete_skip_asin_when_price_below_minimum: statusModeEnabled.value && !asinModeEnabled.value,
|
||||
mode: priceTrackModeForAppClient(),
|
||||
asin_rows_by_country: asinRowsByCountry,
|
||||
minimum_price_by_country_and_asin: minimumPriceByCountryAndAsin,
|
||||
},
|
||||
}
|
||||
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||||
@@ -990,49 +1042,24 @@ async function pushToPythonQueueLegacy() {
|
||||
}
|
||||
|
||||
// ========== 任务状态轮询 ==========
|
||||
function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) {
|
||||
const taskItem = taskVo.items?.[0]
|
||||
async function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) {
|
||||
const shopName = (row.shopName || '').trim()
|
||||
const skipAsinDeletePolicy = buildSkipAsinDeletePolicy()
|
||||
const skipAsinCheckPath = `/api/price-track/tasks/${taskVo.taskId}/skip-asin/check`
|
||||
const resultId = taskItem?.resultId ?? null
|
||||
const loopRunId = taskItem?.loopRunId ?? null
|
||||
const roundIndex = taskItem?.roundIndex ?? null
|
||||
const taskStatus = taskItem?.taskStatus || 'RUNNING'
|
||||
const success = taskItem?.success ?? false
|
||||
const error = taskItem?.error || null
|
||||
const outputFilename = taskItem?.outputFilename || null
|
||||
const downloadUrl = taskItem?.downloadUrl || null
|
||||
const asinRowsByCountry = await loadAsinRowsForAppClient(taskVo)
|
||||
const minimumPriceByCountryAndAsin = buildMinimumPriceMapForAppClient(taskVo, asinRowsByCountry)
|
||||
|
||||
return {
|
||||
type: 'price-track-run',
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
task_id: taskVo.taskId,
|
||||
result_id: resultId,
|
||||
shop_name: shopName,
|
||||
shop_id: row.shopId ?? null,
|
||||
platform: row.platform || '',
|
||||
company_name: row.companyName || '',
|
||||
shop_mall_name: row.shopMallName || '',
|
||||
matched: !!row.matched,
|
||||
match_status: row.matchStatus || '',
|
||||
match_message: row.matchMessage || null,
|
||||
success: success,
|
||||
error: error,
|
||||
output_filename: outputFilename,
|
||||
download_url: downloadUrl,
|
||||
task_status: taskStatus,
|
||||
loop_run_id: loopRunId,
|
||||
round_index: roundIndex,
|
||||
country_codes: resolveCountryCodesForRequest(),
|
||||
mode: statusModeEnabled.value ? 'status' : 'asin',
|
||||
use_skip_asin_check: statusModeEnabled.value && !asinModeEnabled.value,
|
||||
skip_asin_check_url: skipAsinCheckPath,
|
||||
use_paginated_skip_asins: false,
|
||||
skip_asin_page_size: 0,
|
||||
skip_asin_delete_policy: skipAsinDeletePolicy,
|
||||
delete_skip_asin_when_price_below_minimum: skipAsinDeletePolicy.delete_when_price_below_minimum,
|
||||
mode: priceTrackModeForAppClient(),
|
||||
shopName,
|
||||
companyName: row.companyName || '',
|
||||
shopMallName: row.shopMallName || '',
|
||||
asin_rows_by_country: asinRowsByCountry,
|
||||
minimum_price_by_country_and_asin: minimumPriceByCountryAndAsin,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1174,7 +1201,7 @@ async function processMatchedQueue() {
|
||||
}
|
||||
saveTaskSnapshotsToStorage()
|
||||
saveTaskDetailsToStorage()
|
||||
const queuePayload = buildQueuePayload(taskVo, row)
|
||||
const queuePayload = await buildQueuePayload(taskVo, row)
|
||||
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||||
const pushResult = await api.enqueue_json(queuePayload)
|
||||
if (!pushResult?.success) {
|
||||
@@ -1375,7 +1402,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
}
|
||||
})()
|
||||
recordCreatedTask(taskVo)
|
||||
const queuePayload = buildQueuePayload(taskVo, row)
|
||||
const queuePayload = await buildQueuePayload(taskVo, row)
|
||||
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||||
const pushResult = await api.enqueue_json(queuePayload)
|
||||
if (!pushResult?.success) {
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
<main class="delivery-page__body">
|
||||
<DeliveryVideoWorkspace />
|
||||
</main>
|
||||
<DownloadProgressPanel />
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
@@ -69,6 +70,7 @@ import PageShell from '@/components/layout/PageShell.vue'
|
||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||
import DeliveryVideoWorkspace from '@/pages/image-video/components/DeliveryVideoWorkspace.vue'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue'
|
||||
|
||||
type ViewMode = 'menu' | 'delivery'
|
||||
|
||||
@@ -389,8 +391,17 @@ async function launchDesktop() {
|
||||
background: #0f0f0f;
|
||||
}
|
||||
|
||||
:global(html),
|
||||
:global(body),
|
||||
:global(#app) {
|
||||
min-height: 100%;
|
||||
background: #0f0f0f;
|
||||
}
|
||||
|
||||
.delivery-page__body {
|
||||
min-height: calc(100vh - 64px);
|
||||
padding: 12px 20px 24px;
|
||||
background: #0f0f0f;
|
||||
}
|
||||
|
||||
.delivery-page__top-return {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -100,13 +100,35 @@ export interface UploadFileVo {
|
||||
localPath: string;
|
||||
size: number;
|
||||
relativePath?: string;
|
||||
objectKey?: string;
|
||||
url?: string;
|
||||
mediaType?: "image" | "video" | "audio" | "file" | string;
|
||||
}
|
||||
|
||||
export async function uploadTempFileToJava(file: File, relativePath?: string) {
|
||||
export interface UploadTempFileOptions {
|
||||
relativePath?: string;
|
||||
uploadToOss?: boolean;
|
||||
moduleType?: string;
|
||||
}
|
||||
|
||||
export async function uploadTempFileToJava(
|
||||
file: File,
|
||||
relativePathOrOptions?: string | UploadTempFileOptions,
|
||||
) {
|
||||
const options =
|
||||
typeof relativePathOrOptions === 'string'
|
||||
? { relativePath: relativePathOrOptions }
|
||||
: relativePathOrOptions || {}
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (relativePath) {
|
||||
formData.append('relativePath', relativePath)
|
||||
if (options.relativePath) {
|
||||
formData.append('relativePath', options.relativePath)
|
||||
}
|
||||
if (options.uploadToOss) {
|
||||
formData.append('uploadToOss', 'true')
|
||||
}
|
||||
if (options.moduleType) {
|
||||
formData.append('moduleType', options.moduleType)
|
||||
}
|
||||
const response = await http.post<JavaApiResponse<UploadFileVo>>(
|
||||
`${JAVA_API_PREFIX}/files/upload`,
|
||||
@@ -2437,6 +2459,11 @@ export interface SkipPriceAsinPageVo {
|
||||
totalPages: number;
|
||||
skipAsinsByCountry: Record<string, string[]>;
|
||||
skipAsinDetailsByCountry: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
|
||||
skip_asins?: Record<string, string[]>;
|
||||
skip_asins_by_country?: Record<string, string[]>;
|
||||
skip_asin_details_by_country?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
|
||||
asin_rows_by_country?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
|
||||
minimum_price_by_country_and_asin?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
export function deletePendingPriceTrackShopResult(shopName: string) {
|
||||
@@ -2447,6 +2474,232 @@ export function deletePendingPriceTrackShopResult(shopName: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 视频复刻 / 图生视频 ==========
|
||||
|
||||
export interface ImageVideoDouyinCopyVo {
|
||||
recognizedContent?: string;
|
||||
scriptDraft?: string;
|
||||
executeId?: string;
|
||||
debugUrl?: string;
|
||||
}
|
||||
|
||||
export interface ImageVideoSecretStatusVo {
|
||||
userId?: number;
|
||||
configured?: boolean;
|
||||
valid?: boolean;
|
||||
expired?: boolean;
|
||||
hasCopyApiKey?: boolean;
|
||||
hasT8Key?: boolean;
|
||||
hasVoiceApiKey?: boolean;
|
||||
hasVoiceGroupId?: boolean;
|
||||
copyApiKeyMasked?: string;
|
||||
t8KeyMasked?: string;
|
||||
voiceApiKeyMasked?: string;
|
||||
voiceGroupIdMasked?: string;
|
||||
expireDays?: number;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface ImageVideoSecretSavePayload {
|
||||
copyApiKey?: string;
|
||||
t8Key?: string;
|
||||
voiceApiKey?: string;
|
||||
voiceGroupId?: string;
|
||||
expireDays: number;
|
||||
}
|
||||
|
||||
export interface ImageVideoWorkflowParameters {
|
||||
api_key_info: {
|
||||
t8star_key: string;
|
||||
ai_conductor_key: string;
|
||||
};
|
||||
bg_info: {
|
||||
type: number;
|
||||
prompt: string;
|
||||
bg_image: string;
|
||||
};
|
||||
face_info: {
|
||||
type: number;
|
||||
model_figure: string;
|
||||
model_image: string[];
|
||||
};
|
||||
proc_info: {
|
||||
type: string;
|
||||
name: string;
|
||||
proc_image: string[];
|
||||
properties: string;
|
||||
};
|
||||
text_info: {
|
||||
type: number;
|
||||
language: string;
|
||||
text: string;
|
||||
file_url: string;
|
||||
};
|
||||
video_info: {
|
||||
video_url: string;
|
||||
share_url: string;
|
||||
ref_video_mode: string;
|
||||
mode: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
ratio: string;
|
||||
resolution: string;
|
||||
duration: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageVideoWorkflowResponse {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: string;
|
||||
execute_id?: string;
|
||||
debug_url?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ImageVideoMediaUploadVo {
|
||||
url: string;
|
||||
objectKey: string;
|
||||
originalFilename: string;
|
||||
mediaType: "image" | "video" | "audio" | string;
|
||||
}
|
||||
|
||||
export interface ImageVideoVoiceWorkflowResponse {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: unknown;
|
||||
execute_id?: string;
|
||||
debug_url?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ImageVideoWorkflowRunRequest {
|
||||
userId: number;
|
||||
parameters: ImageVideoWorkflowParameters;
|
||||
}
|
||||
|
||||
export interface ImageVideoWorkflowResultRequest {
|
||||
userId: number;
|
||||
executeId: string;
|
||||
}
|
||||
|
||||
export function getImageVideoSecretStatus() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<ImageVideoSecretStatusVo>>(
|
||||
`${JAVA_API_PREFIX}/image-video/secrets`,
|
||||
{ params: { user_id: getCurrentUserId() } },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function saveImageVideoSecrets(payload: ImageVideoSecretSavePayload) {
|
||||
return unwrapJavaResponse(
|
||||
put<JavaApiResponse<ImageVideoSecretStatusVo>, ImageVideoSecretSavePayload & { userId: number }>(
|
||||
`${JAVA_API_PREFIX}/image-video/secrets`,
|
||||
{ ...payload, userId: getCurrentUserId() },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function runImageVideoDouyinCopy(url: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ImageVideoDouyinCopyVo>, { userId: number; url: string }>(
|
||||
`${JAVA_API_PREFIX}/image-video/douyin-copy`,
|
||||
{ userId: getCurrentUserId(), url },
|
||||
{ timeout: 180000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function runImageVideoWorkflow(parameters: ImageVideoWorkflowParameters) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ImageVideoWorkflowResponse>, ImageVideoWorkflowRunRequest>(
|
||||
`${JAVA_API_PREFIX}/image-video/workflow/run`,
|
||||
{ userId: getCurrentUserId(), parameters },
|
||||
{ timeout: 120000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getImageVideoWorkflowResult(executeId: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ImageVideoWorkflowResponse>, ImageVideoWorkflowResultRequest>(
|
||||
`${JAVA_API_PREFIX}/image-video/workflow/result`,
|
||||
{ userId: getCurrentUserId(), executeId },
|
||||
{ timeout: 600000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadImageVideoMedia(file: File) {
|
||||
const response = await uploadTempFileToJava(file, {
|
||||
uploadToOss: true,
|
||||
moduleType: "IMAGE_VIDEO",
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || "请求失败");
|
||||
}
|
||||
const data = response.data;
|
||||
if (!data?.url || !data.objectKey) {
|
||||
throw new Error("OSS 上传未返回有效 URL");
|
||||
}
|
||||
return {
|
||||
url: data.url,
|
||||
objectKey: data.objectKey,
|
||||
originalFilename: data.originalFilename || file.name,
|
||||
mediaType: data.mediaType || resolveImageVideoMediaType(file),
|
||||
} as ImageVideoMediaUploadVo;
|
||||
}
|
||||
|
||||
function resolveImageVideoMediaType(file: File) {
|
||||
const type = (file.type || "").toLowerCase();
|
||||
const name = (file.name || "").toLowerCase();
|
||||
if (type.startsWith("image/") || /\.(png|jpe?g|webp|gif|bmp|svg)$/.test(name)) return "image";
|
||||
if (type.startsWith("video/") || /\.(mp4|mov|webm|m4v|ogg|avi|mkv)$/.test(name)) return "video";
|
||||
if (type.startsWith("audio/") || /\.(mp3|wav|m4a|aac|flac)$/.test(name)) return "audio";
|
||||
return "file";
|
||||
}
|
||||
|
||||
export function listImageVideoVoices() {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number }>(
|
||||
`${JAVA_API_PREFIX}/image-video/voice/list`,
|
||||
{ userId: getCurrentUserId() },
|
||||
{ timeout: 180000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteImageVideoVoice(voiceId: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number; voiceId: string }>(
|
||||
`${JAVA_API_PREFIX}/image-video/voice/delete`,
|
||||
{ userId: getCurrentUserId(), voiceId },
|
||||
{ timeout: 180000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function cloneImageVideoVoice(payload: { audioUrl?: string; videoUrl?: string }) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number; audioUrl?: string; videoUrl?: string }>(
|
||||
`${JAVA_API_PREFIX}/image-video/voice/clone`,
|
||||
{ userId: getCurrentUserId(), ...payload },
|
||||
{ timeout: 180000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function synthesizeImageVideoVoice(payload: { text: string; voiceId: string }) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number; text: string; voiceId: string }>(
|
||||
`${JAVA_API_PREFIX}/image-video/voice/synthesis`,
|
||||
{ userId: getCurrentUserId(), ...payload },
|
||||
{ timeout: 180000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 采集数据 ==========
|
||||
|
||||
export interface CollectDataSourceFile {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div class="ai-asset-dropzone">
|
||||
<div
|
||||
class="ai-asset-dropzone"
|
||||
:class="{
|
||||
'ai-asset-dropzone--compact': compact,
|
||||
'ai-asset-dropzone--active': Boolean(fileName || sourceUrl),
|
||||
}"
|
||||
>
|
||||
<div class="ai-asset-dropzone__head">
|
||||
<div>
|
||||
<div class="ai-asset-dropzone__title">{{ title }}</div>
|
||||
@@ -21,16 +27,31 @@
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
:disabled="loading"
|
||||
class="ai-asset-dropzone__upload"
|
||||
@change="handleChange"
|
||||
>
|
||||
<div v-if="previewUrl || (fileName && previewKind === 'file')" class="ai-asset-dropzone__preview">
|
||||
<img
|
||||
<div
|
||||
v-if="previewUrl || (fileName && previewKind === 'file')"
|
||||
class="ai-asset-dropzone__preview"
|
||||
:class="{
|
||||
'ai-asset-dropzone__preview--clickable': previewClickable && previewKind === 'image' && previewUrl,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-if="previewKind === 'image'"
|
||||
:src="previewUrl"
|
||||
class="ai-asset-dropzone__media"
|
||||
alt=""
|
||||
/>
|
||||
type="button"
|
||||
class="ai-asset-dropzone__image-preview-btn"
|
||||
@click.stop.prevent="emit('preview', previewUrl)"
|
||||
>
|
||||
<img
|
||||
:src="previewUrl"
|
||||
class="ai-asset-dropzone__media"
|
||||
alt=""
|
||||
/>
|
||||
<span v-if="previewClickable" class="ai-asset-dropzone__preview-tip">点击放大预览</span>
|
||||
</button>
|
||||
<video
|
||||
v-else-if="previewKind === 'video'"
|
||||
:src="previewUrl"
|
||||
@@ -42,24 +63,28 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="ai-asset-dropzone__empty">
|
||||
<div class="ai-asset-dropzone__action">{{ loading ? '上传中...' : actionLabel }}</div>
|
||||
<div class="ai-asset-dropzone__placeholder">{{ placeholder }}</div>
|
||||
<div v-if="helper" class="ai-asset-dropzone__helper">{{ helper }}</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<div v-if="fileName" class="ai-asset-dropzone__footer">
|
||||
<span class="ai-asset-dropzone__selected-label">{{ selectedLabel }}</span>
|
||||
<span class="ai-asset-dropzone__filename">{{ fileName }}</span>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-if="allowUrl"
|
||||
:model-value="sourceUrl"
|
||||
class="ai-asset-dropzone__url"
|
||||
clearable
|
||||
:placeholder="urlPlaceholder"
|
||||
@change="(value) => $emit('url-change', value)"
|
||||
@clear="$emit('url-change', '')"
|
||||
/>
|
||||
<div v-if="allowUrl" class="ai-asset-dropzone__url-wrap">
|
||||
<span v-if="urlLabel" class="ai-asset-dropzone__url-label">{{ urlLabel }}</span>
|
||||
<el-input
|
||||
:model-value="sourceUrl"
|
||||
class="ai-asset-dropzone__url"
|
||||
clearable
|
||||
:placeholder="urlPlaceholder"
|
||||
@change="(value) => $emit('url-change', value)"
|
||||
@clear="$emit('url-change', '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -79,6 +104,13 @@ withDefaults(
|
||||
allowUrl?: boolean
|
||||
sourceUrl?: string
|
||||
urlPlaceholder?: string
|
||||
urlLabel?: string
|
||||
actionLabel?: string
|
||||
selectedLabel?: string
|
||||
compact?: boolean
|
||||
loading?: boolean
|
||||
multiple?: boolean
|
||||
previewClickable?: boolean
|
||||
}>(),
|
||||
{
|
||||
description: '',
|
||||
@@ -91,6 +123,12 @@ withDefaults(
|
||||
allowUrl: false,
|
||||
sourceUrl: '',
|
||||
urlPlaceholder: '也可以粘贴视频在线链接',
|
||||
urlLabel: '',
|
||||
actionLabel: '点击或拖拽上传',
|
||||
selectedLabel: '已选择',
|
||||
compact: false,
|
||||
loading: false,
|
||||
previewClickable: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -98,6 +136,7 @@ const emit = defineEmits<{
|
||||
(event: 'select', file: File): void
|
||||
(event: 'clear'): void
|
||||
(event: 'url-change', value: string): void
|
||||
(event: 'preview', value: string): void
|
||||
}>()
|
||||
|
||||
function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
||||
@@ -114,6 +153,7 @@ function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__head {
|
||||
@@ -121,6 +161,11 @@ function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__head > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__title {
|
||||
@@ -136,6 +181,10 @@ function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__clear {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.ai-asset-dropzone__upload .el-upload) {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -146,13 +195,19 @@ function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
||||
padding: 12px;
|
||||
border: 1px dashed #474747;
|
||||
border-radius: 14px;
|
||||
background: #1f1f1f;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0)),
|
||||
#1f1f1f;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.ai-asset-dropzone__upload .el-upload-dragger:hover) {
|
||||
border-color: #8c63ff;
|
||||
background: #232323;
|
||||
box-shadow: 0 0 0 1px rgba(140, 99, 255, 0.08) inset;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__preview,
|
||||
@@ -168,6 +223,22 @@ function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
||||
.ai-asset-dropzone__empty {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(140, 99, 255, 0.38);
|
||||
border-radius: 999px;
|
||||
background: rgba(140, 99, 255, 0.12);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__placeholder {
|
||||
@@ -188,6 +259,39 @@ function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__image-preview-btn {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__preview--clickable .ai-asset-dropzone__image-preview-btn {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__preview-tip {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__preview--clickable:hover .ai-asset-dropzone__preview-tip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__file {
|
||||
color: #d8d8d8;
|
||||
font-size: 13px;
|
||||
@@ -195,18 +299,42 @@ function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #383838;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__selected-label {
|
||||
flex-shrink: 0;
|
||||
color: #8d8d8d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__filename {
|
||||
min-width: 0;
|
||||
color: #d8d8d8;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__url-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__url-label {
|
||||
color: #9d9d9d;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone__url {
|
||||
--el-input-bg-color: #171717;
|
||||
--el-input-border-color: #383838;
|
||||
@@ -215,4 +343,22 @@ function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
||||
--el-input-text-color: #f2f2f2;
|
||||
--el-input-placeholder-color: #777;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone--compact {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone--compact :deep(.ai-asset-dropzone__upload .el-upload-dragger) {
|
||||
min-height: 138px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone--compact .ai-asset-dropzone__preview,
|
||||
.ai-asset-dropzone--compact .ai-asset-dropzone__empty {
|
||||
min-height: 118px;
|
||||
}
|
||||
|
||||
.ai-asset-dropzone--compact .ai-asset-dropzone__media {
|
||||
height: 118px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -58,7 +58,8 @@ export default defineConfig({
|
||||
'image-video': resolve(__dirname, 'image-video.html'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'assets/[name].js',
|
||||
entryFileNames: (chunkInfo) =>
|
||||
chunkInfo.name === 'image-video' ? 'assets/[name]-[hash].js' : 'assets/[name].js',
|
||||
chunkFileNames: 'assets/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[name]-[hash][extname]',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user