diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaSkillController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaSkillController.java index 27deff98..7d1d4881 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaSkillController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaSkillController.java @@ -13,6 +13,7 @@ public class ShortDramaSkillController { private final ShortDramaSkillCatalog catalog; private final ShortDramaSkillMarket market; @GetMapping("/market") public R> market() { return R.ok(market.list()); } + @GetMapping("/categories") public R> categories() { return R.ok(catalog.categories()); } @GetMapping public R> list(@RequestParam(required=false) String type, @RequestParam(defaultValue="false") boolean includeDisabled) { return R.ok(catalog.list(type, includeDisabled)); } @GetMapping("/{name}") public R detail(@PathVariable String name) { return R.ok(catalog.detail(name)); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVideoReferenceController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVideoReferenceController.java new file mode 100644 index 00000000..15049d44 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVideoReferenceController.java @@ -0,0 +1,32 @@ +package org.ruoyi.controller.shortdrama; + +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.common.satoken.utils.LoginHelper; +import org.ruoyi.service.shortdrama.impl.ShortDramaVideoReferenceService; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.util.List; + +@RestController @RequiredArgsConstructor +@RequestMapping("/short-drama/{projectId}/video-references") +public class ShortDramaVideoReferenceController { + private final ShortDramaVideoReferenceService references; + @GetMapping public R> list(@PathVariable Long projectId) throws Exception { + return R.ok(references.list(projectId, LoginHelper.getUserId())); + } + @PostMapping(consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + public R upload(@PathVariable Long projectId, @RequestPart("file") MultipartFile file) throws Exception { + return R.ok(references.upload(projectId, LoginHelper.getUserId(), file)); + } + @GetMapping("/{id}/content") public ResponseEntity content(@PathVariable Long projectId, @PathVariable String id) { + references.owner(projectId, LoginHelper.getUserId()); + var resource = new FileSystemResource(references.file(projectId, id)); + if (!resource.exists()) return ResponseEntity.notFound().build(); + return ResponseEntity.ok().contentType(MediaType.valueOf("video/mp4")).body(resource); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasModels.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasModels.java new file mode 100644 index 00000000..e4581303 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasModels.java @@ -0,0 +1,53 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import java.net.URI; +import java.util.Objects; +import java.util.Set; + +/** Resolve short-drama models inside Atlas, never by a name shared across providers. */ +public final class ShortDramaAtlasModels { + private ShortDramaAtlasModels() {} + + public static ChatModelVo require(IChatModelService models, String name, String... categories) { + if (name == null || name.isBlank()) throw new IllegalArgumentException("请选择已配置的 Atlas " + String.join("/", categories) + " 模型"); + return resolve(models, name, categories); + } + + public static ChatModelVo defaultModel(IChatModelService models, String category) { + return resolve(models, null, category); + } + + private static ChatModelVo resolve(IChatModelService models, String name, String... categories) { + var allowed = Set.of(categories); + var query = new ChatModelBo(); + query.setProviderCode("atlas"); query.setCategory(categories.length == 1 ? categories[0] : null); query.setModelName(name); + var matches = models.queryAvailableList(query).stream() + .filter(model -> "atlas".equalsIgnoreCase(model.getProviderCode()) && model.getCategory() != null && allowed.contains(model.getCategory()) + && (name == null || Objects.equals(name, model.getModelName()))) + .toList(); + if (matches.isEmpty()) throw new IllegalArgumentException("短剧仅支持已启用的 Atlas " + String.join("/", categories) + " 模型,请检查后台配置:" + Objects.toString(name, "默认模型")); + var selected = matches.get(0); + if (matches.stream().filter(model -> Objects.equals(selected.getModelName(), model.getModelName())).count() > 1) + throw new IllegalArgumentException("Atlas 同类模型存在重复配置,请保留唯一配置:" + selected.getModelName()); + requireProvider(selected); + return selected; + } + + public static void requireProvider(ChatModelVo model) { + if (model == null || !"atlas".equalsIgnoreCase(model.getProviderCode())) + throw new IllegalArgumentException("短剧仅支持 Atlas 模型"); + try { + var uri = URI.create(Objects.toString(model.getApiHost(), "")); + if (!"https".equalsIgnoreCase(uri.getScheme()) || !"api.atlascloud.ai".equalsIgnoreCase(uri.getHost()) + || (uri.getPort() != -1 && uri.getPort() != 443) || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null) + throw new IllegalArgumentException("短剧 Atlas 模型接口地址必须使用 https://api.atlascloud.ai"); + } catch (IllegalArgumentException error) { + throw new IllegalArgumentException("短剧 Atlas 模型接口地址必须使用 https://api.atlascloud.ai"); + } + if (model.getApiKey() == null || model.getApiKey().isBlank()) + throw new IllegalArgumentException("Atlas 模型 API Key 未配置,请在后台填写后重试"); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCharacterVoiceService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCharacterVoiceService.java index 8b22c2fb..4c6caa51 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCharacterVoiceService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCharacterVoiceService.java @@ -100,7 +100,7 @@ public class ShortDramaCharacterVoiceService { write(profileFile(project,character),updated);return updated; } private ChatModelVo model(String name) { - var model=models.selectModelByName(name); + var model=ShortDramaAtlasModels.require(models,name,"audio"); if (model==null || !MODEL.equals(name) || !"audio".equals(model.getCategory()) || !"atlas".equalsIgnoreCase(model.getProviderCode())) throw new ShortDramaVoiceException("请在模型配置中启用 Atlas Seed Audio 1.0"); return model; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaModelErrors.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaModelErrors.java new file mode 100644 index 00000000..9701f760 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaModelErrors.java @@ -0,0 +1,14 @@ +package org.ruoyi.service.shortdrama.impl; + +import dev.langchain4j.exception.AuthenticationException; + +final class ShortDramaModelErrors { + private ShortDramaModelErrors() {} + static String message(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof AuthenticationException) + return "Atlas 模型鉴权失败,请检查后台该模型的 API Key、接口地址和账号权限"; + } + return failure.getMessage(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaMusicService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaMusicService.java index 123ced21..825e7bdf 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaMusicService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaMusicService.java @@ -47,7 +47,7 @@ public class ShortDramaMusicService { try(var files=Files.list(directory(project))){List jobs=new ArrayList<>();for(Path path:files.filter(p->p.toString().endsWith(".json")).toList()){var job=AtlasMediaSupport.OBJECT_MAPPER.readValue(path.toFile(),Job.class);if("submitting".equals(job.status()) && System.currentTimeMillis()-job.createdAt()>210000)job=new Job(job.id(),job.predictionId(),job.request(),"submission_unknown","提交尚未取得任务编号,请先核对 Atlas 记录;未自动重试",job.variants(),job.createdAt());jobs.add(job);}jobs.sort(Comparator.comparingLong(Job::createdAt).reversed());return jobs;} } private ChatModelVo musicModel(String name) { - var model=models.selectModelByName(name); + var model=ShortDramaAtlasModels.require(models,name,"audio"); if(model==null || !"audio".equals(model.getCategory()) || !"atlas".equalsIgnoreCase(model.getProviderCode()) || !"suno/chirp-v6".equals(name))throw new IllegalArgumentException("请选择已配置的 Atlas Suno Chirp V6 音乐模型"); return model; } @@ -101,7 +101,7 @@ public class ShortDramaMusicService { return selected; } public Brief write(Long project,Long user,BriefRequest request) { - sounds.owner(project,user);var model=models.selectModelByName(request.model());if(model==null || !"chat".equals(model.getCategory()))throw new IllegalArgumentException("请选择写作模型"); + sounds.owner(project,user);var model=ShortDramaAtlasModels.require(models,request.model(),"chat");if(model==null || !"chat".equals(model.getCategory()))throw new IllegalArgumentException("请选择写作模型"); if(!Set.of("bgm","theme","ending").contains(Objects.toString(request.purpose(),"")) || request.direction()!=null && request.direction().length()>2000)throw new IllegalArgumentException("音乐用途或创作要求无效"); var script=scripts.selectOne(new LambdaQueryWrapper().eq(ShortDramaScript::getProjectId,project).orderByDesc(ShortDramaScript::getId).last("limit 1"));if(script==null)throw new IllegalArgumentException("请先保存剧本"); String context=Objects.toString(script.getScriptText(),"");if(context.length()>16000)context=context.substring(0,16000); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java index ab5bd197..ae062592 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java @@ -1075,7 +1075,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (ShortDramaVisualAssetService.directInsert(storyboard.getContinuityJson())) throw new IllegalStateException("本镜使用素材,不能通过AI视频模型重绘;请在剪辑阶段使用已绑定原图"); if ("script_changed".equals(project.getStatus())) throw new IllegalStateException("剧本已修改,请重新分析资产和生成分镜后再生成视频"); if (storyboardGenerationStates.containsKey(storyboard.getScriptId())) throw new IllegalStateException("分镜正在更新,请完成审阅后再生成视频"); - ChatModelVo modelVo = chatModelService.selectModelByName(videoModel); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, videoModel, "video"); if (modelVo == null) throw new IllegalArgumentException("未找到视频模型配置: " + videoModel); Integer videoSeconds = ShortDramaVideoDuration.seconds(storyboard.getContinuityJson()); @@ -1095,7 +1095,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { .replace("/reference-to-video", "/image-to-video"); } if (!targetModel.equals(videoModel)) { - ChatModelVo switched = chatModelService.selectModelByName(targetModel); + ChatModelVo switched = ShortDramaAtlasModels.require(chatModelService, targetModel, "video"); if (switched != null) { modelVo = switched; log.info("参考图({}张) → 切换模型: {}", referenceImages == null ? 0 : referenceImages.size(), targetModel); @@ -1715,8 +1715,10 @@ public class ShortDramaServiceImpl implements IShortDramaService { return MapstructUtils.convert(storyboard, ShortDramaStoryboardVo.class); } // Never reconstruct the queried provider/model from a client dropdown after a switch. + if (receipt != null && !"atlas".equalsIgnoreCase(receipt.providerCode())) + throw new IllegalArgumentException("短剧仅支持查询 Atlas 视频任务;原有视频文件保留"); String actualModel = receipt != null ? receipt.actualModel() : videoModel; - ChatModelVo modelVo = chatModelService.selectModelByName(actualModel); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, actualModel, "video"); if (modelVo == null) { throw new IllegalArgumentException("未找到原视频任务模型配置: " + actualModel); } @@ -2139,7 +2141,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { ShortDramaCharacter character = characterMapper.selectById(characterId); if (character == null) throw new IllegalArgumentException("角色不存在"); validateProjectOwner(character.getProjectId(), userId); - ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, imageModel, "image"); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); String basePrompt = firstNotBlank(character.getVisualDescription(), character.getName()); String finalPrompt = buildCharacterPrompt(basePrompt, character.getProjectId()); @@ -2172,7 +2174,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { ShortDramaLocation location = locationMapper.selectById(locationId); if (location == null) throw new IllegalArgumentException("场景不存在"); validateProjectOwner(location.getProjectId(), userId); - ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, imageModel, "image"); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); String prompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); String finalPrompt = buildLocationPrompt(location.getName(), prompt, location.getProjectId(), revisionRequirements); @@ -2258,7 +2260,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId()); if (character == null) throw new IllegalArgumentException("角色不存在"); validateProjectOwner(character.getProjectId(), userId); - ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, imageModel, "image"); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); String basePrompt = firstNotBlank(appearance.getDescription(), character.getVisualDescription(), character.getName()); String finalPrompt = buildCharacterPrompt(basePrompt, character.getProjectId()); @@ -2292,7 +2294,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId()); if (character == null) throw new IllegalArgumentException("角色不存在"); validateProjectOwner(character.getProjectId(), userId); - ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, imageModel, "image"); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); String basePrompt = firstNotBlank(appearance.getDescription(), character.getVisualDescription(), character.getName()); String finalPrompt = buildCharacterPrompt(basePrompt, character.getProjectId()); @@ -2457,7 +2459,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { || "image/webp".equalsIgnoreCase(contentType))) { throw new IllegalArgumentException("参考图仅支持JPEG、PNG或WebP"); } - ChatModelVo modelVo = chatModelService.selectModelByName(model); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, model, "image", "video"); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model); try { return imageServiceFactory.getOriginalService(modelVo.getProviderCode()) @@ -2493,7 +2495,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (revision && !locationAsset && (!"appearance".equals(assetType) || StrUtil.isBlank(referenceImage))) { throw new IllegalArgumentException("身份修订仅用于角色形象,且必须提供身份参考图"); } - ChatModelVo modelVo = chatModelService.selectModelByName(model); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, model, "image"); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model); String prompt; @@ -2559,7 +2561,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (character == null) throw new IllegalArgumentException("角色不存在"); validateProjectOwner(character.getProjectId(), userId); - ChatModelVo modelVo = chatModelService.selectModelByName(model); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, model, "image"); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model); MediaGenerationResponse result = atlasPredictionService.retrieve(modelVo, predictionId); @@ -2598,7 +2600,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (location == null) throw new IllegalArgumentException("场景不存在"); validateProjectOwner(location.getProjectId(), userId); - ChatModelVo modelVo = chatModelService.selectModelByName(model); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, model, "image"); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model); MediaGenerationResponse result = atlasPredictionService.retrieve(modelVo, predictionId); @@ -3040,7 +3042,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { try { streamDone.join(); } catch (Exception e) { - throw new RuntimeException("剧本生成失败:" + e.getMessage(), e); + throw new RuntimeException("剧本生成失败:" + ShortDramaModelErrors.message(e), e); } return requireFixedScript(result[0]); } @@ -4712,7 +4714,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { // ==================== 辅助方法 ==================== private ChatModelVo validateAndGetModel(String modelName) { - ChatModelVo modelVo = chatModelService.selectModelByName(modelName); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, modelName, "chat"); if (modelVo == null) { throw new IllegalArgumentException("未找到模型配置: " + modelName); } @@ -4720,19 +4722,16 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private ChatModelVo findChatModel() { - org.ruoyi.common.chat.domain.bo.chat.ChatModelBo query = new org.ruoyi.common.chat.domain.bo.chat.ChatModelBo(); - query.setCategory("chat"); - query.setProviderCode("atlas"); - List models = chatModelService.queryAvailableList(query); - if (models != null && !models.isEmpty()) return models.get(0); - throw new IllegalArgumentException("无可用聊天模型"); + return ShortDramaAtlasModels.defaultModel(chatModelService, "chat"); } private AbstractChatService getChatService(ChatModelVo modelVo) { + ShortDramaAtlasModels.requireProvider(modelVo); return chatServiceFactory.getOriginalService(shortDramaProviderCode(modelVo.getProviderCode(), modelVo.getModelName())); } public static String shortDramaProviderCode(String provider, String model) { + if (!"atlas".equalsIgnoreCase(provider)) throw new IllegalArgumentException("短剧仅支持 Atlas 模型"); return (org.ruoyi.enums.ChatModeType.OPEN_AI.getCode().equalsIgnoreCase(provider) || org.ruoyi.enums.ChatModeType.ATLAS.getCode().equalsIgnoreCase(provider)) && StrUtil.containsIgnoreCase(model, "deepseek") @@ -4781,7 +4780,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (audio == null) throw new IllegalArgumentException("语音资产不存在"); validateProjectOwner(audio.getProjectId(), userId); if (StrUtil.isBlank(audio.getText())) throw new IllegalArgumentException("语音文案不能为空"); - ChatModelVo modelVo = chatModelService.selectModelByName(audioModel); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, audioModel, "audio"); if (modelVo == null) throw new IllegalArgumentException("未找到语音模型配置: " + audioModel); if (audioModel.startsWith("suno/")) throw new IllegalArgumentException("Suno 是音乐模型,请使用音乐创作面板;对白配音需选择语音模型"); if (!org.ruoyi.enums.ModelType.AUDIO.getKey().equals(modelVo.getCategory())) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSkillCatalog.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSkillCatalog.java index 87c2f7ce..0ff19ffe 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSkillCatalog.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSkillCatalog.java @@ -16,7 +16,16 @@ import java.util.*; @Service public class ShortDramaSkillCatalog { private static final ObjectMapper JSON = new ObjectMapper(); - private static final Set TYPES = Set.of("aesthetic", "director"); + public record Category(String type, String title, boolean editable, boolean projectSelectable) {} + private static final List CATEGORIES = List.of( + new Category("system", "系统提示词", false, false), + new Category("production", "默认制作技能", false, false), + new Category("screenwriting", "编剧风格", true, true), + new Category("director", "导演风格", true, true), + new Category("aesthetic", "视觉风格", true, true)); + private static final Set TYPES = CATEGORIES.stream().filter(Category::editable) + .map(Category::type).collect(java.util.stream.Collectors.toUnmodifiableSet()); + public List categories() { return CATEGORIES; } private static final Set STYLES = org.ruoyi.constant.ShortDramaImageConstants.ART_STYLE_PROMPTS.keySet(); private static final int MAX_TEXT = 200_000, MAX_FILES = 32, MAX_TOTAL = 1_000_000, MAX_MEDIA_BODY = 2_000; private final Path root; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSoundService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSoundService.java index c2583af0..2f01bf00 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSoundService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSoundService.java @@ -84,6 +84,7 @@ public class ShortDramaSoundService { catch(Exception e){throw new IllegalStateException("参考音频读取失败:"+e.getMessage(),e);} } public synchronized List uploadReferences(List references,org.ruoyi.common.chat.domain.vo.chat.ChatModelVo model) { + ShortDramaAtlasModels.requireProvider(model); try { ShortDramaVoiceContract.validateReferences(model.getModelName(),references.stream().map(Reference::duration).toList()); List result=new ArrayList<>(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoReferenceService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoReferenceService.java new file mode 100644 index 00000000..902187d4 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoReferenceService.java @@ -0,0 +1,122 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.factory.ImageServiceFactory; +import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper; +import org.ruoyi.service.media.AtlasMediaSupport; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Immutable, project-owned motion references. Uploading a clip does not generate media. */ +@Service @RequiredArgsConstructor +public class ShortDramaVideoReferenceService { + private final ShortDramaProjectMapper projects; + private final ImageServiceFactory images; + public record Clip(String id, String name, double duration, int width, int height, double fps, long bytes, String sha256) {} + + private Path directory(Long project) { return Path.of("data", "short-drama-motion", project.toString()).toAbsolutePath(); } + public void owner(Long project, Long user) { + var entity = projects.selectById(project); + if (entity == null || !Objects.equals(entity.getUserId(), user)) throw new IllegalArgumentException("项目不存在或无权限"); + } + static String checkedId(String id) { + if (id == null || !id.matches("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}")) throw new IllegalArgumentException("动作参考ID无效"); + return id; + } + public Path file(Long project, String id) { return directory(project).resolve(checkedId(id) + ".mp4"); } + public List list(Long project, Long user) throws Exception { + owner(project, user); + if (!Files.isDirectory(directory(project))) return List.of(); + try (var files = Files.list(directory(project))) { + var result = new ArrayList(); + for (var path : files.filter(p -> p.toString().endsWith(".json")).sorted().toList()) + result.add(AtlasMediaSupport.OBJECT_MAPPER.readValue(path.toFile(), Clip.class)); + return result; + } + } + public synchronized Clip upload(Long project, Long user, MultipartFile input) throws Exception { + owner(project, user); + if (input.isEmpty() || input.getSize() > 30L * 1024 * 1024) throw new IllegalArgumentException("动作参考视频应小于30MB"); + String name = Objects.toString(input.getOriginalFilename(), "动作参考.mp4"); + if (!name.toLowerCase(Locale.ROOT).matches(".*\\.(mp4|mov)$")) throw new IllegalArgumentException("请选择MP4或MOV视频"); + Files.createDirectories(directory(project)); + String id = UUID.randomUUID().toString(); + Path target = file(project, id), probe = directory(project).resolve(id + ".probe"); + boolean saved = false; + try { + input.transferTo(target); + var process = new ProcessBuilder("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", + "format=duration:stream=codec_name,width,height,avg_frame_rate", "-of", "json", target.toString()) + .redirectErrorStream(true).redirectOutput(probe.toFile()).start(); + if (!process.waitFor(25, TimeUnit.SECONDS)) { process.destroyForcibly(); throw new IllegalStateException("视频检查超时"); } + if (process.exitValue() != 0) throw new IllegalArgumentException("无法读取动作视频"); + JsonNode data = AtlasMediaSupport.OBJECT_MAPPER.readTree(Files.readString(probe)); + var stream = data.path("streams").path(0); + String[] ratio = stream.path("avg_frame_rate").asText("0/1").split("/"); + double fps = Double.parseDouble(ratio[0]) / (ratio.length == 2 ? Double.parseDouble(ratio[1]) : 1); + var clip = new Clip(id, name.substring(0, Math.min(120, name.length())), data.path("format").path("duration").asDouble(), + stream.path("width").asInt(), stream.path("height").asInt(), fps, Files.size(target), + cn.hutool.crypto.digest.DigestUtil.sha256Hex(target.toFile())); + validateClip(clip, stream.path("codec_name").asText()); + AtlasMediaSupport.OBJECT_MAPPER.writeValue(directory(project).resolve(id + ".json").toFile(), clip); + saved = true; return clip; + } finally { + Files.deleteIfExists(probe); + if (!saved) Files.deleteIfExists(target); + } + } + static void validateClip(Clip clip, String codec) { + double aspect = (double)clip.width() / clip.height(); + long pixels = (long)clip.width() * clip.height(); + if (!Set.of("h264", "hevc").contains(codec) || !Double.isFinite(clip.duration()) || clip.duration() < 2 || clip.duration() > 30.05 + || !Double.isFinite(clip.fps()) || clip.fps() < 23.9 || clip.fps() > 60.1 || clip.width() < 300 || clip.height() < 300 + || clip.width() > 6000 || clip.height() > 6000 || aspect < .4 || aspect > 2.5 || pixels < 409600 || pixels > 8295044) + throw new IllegalArgumentException("动作参考须为2–30秒、24–60fps的H.264/H.265视频,画幅0.4–2.5且不低于480p"); + } + public List selected(Long project, Long user, String continuity) { + owner(project, user); + try { + var node = AtlasMediaSupport.OBJECT_MAPPER.readTree(continuity == null || continuity.isBlank() ? "{}" : continuity).path("video_reference_ids"); + if (node.isMissingNode()) return List.of(); + if (!node.isArray() || node.size() > 10) throw new IllegalArgumentException("动作参考应为最多10个视频ID"); + var result = new ArrayList(); var used = new HashSet(); + for (var item : node) { + if (!item.isTextual() || !used.add(checkedId(item.asText()))) throw new IllegalArgumentException("动作参考ID重复或无效"); + var path = directory(project).resolve(item.asText() + ".json"); + if (!Files.isRegularFile(path) || !Files.isRegularFile(file(project, item.asText()))) throw new IllegalArgumentException("动作参考不存在或不属于当前项目"); + var clip = AtlasMediaSupport.OBJECT_MAPPER.readValue(path.toFile(), Clip.class); + if (!clip.id().equals(item.asText())) throw new IllegalArgumentException("动作参考记录不匹配"); + result.add(clip); + } + return result; + } catch (java.io.IOException error) { throw new IllegalArgumentException("动作参考记录无法读取", error); } + } + public static void validateModel(String model, List clips) { + if (clips.isEmpty()) return; + boolean mini = model.startsWith("bytedance/seedance-2.0"), full = model.startsWith("bytedance/seedance-2.5/"); + if ((!mini && !full) || !model.endsWith("/reference-to-video")) throw new IllegalArgumentException("当前模型不支持动作视频参考"); + double sum = clips.stream().mapToDouble(Clip::duration).sum(); + if (clips.size() > (mini ? 3 : 10) || sum > (mini ? 15.05 : 30.05)) throw new IllegalArgumentException(mini ? "Seedance 2.0最多3条视频,合计不超过15秒" : "Seedance 2.5最多10条视频,合计不超过30秒"); + for (var clip : clips) if (clip.bytes() > (mini ? 50L : 200L) * 1024 * 1024 || mini && (clip.duration() > 15.05 || (long)clip.width()*clip.height() > 2086876)) + throw new IllegalArgumentException("动作视频超过当前模型的时长、文件或分辨率限制"); + } + public List transport(Long project, ChatModelVo model, List clips) { + ShortDramaAtlasModels.requireProvider(model); + validateModel(model.getModelName(), clips); + var result = new ArrayList(); + try { + for (var clip : clips) { + var path = file(project, clip.id()); + if (!cn.hutool.crypto.digest.DigestUtil.sha256Hex(path.toFile()).equals(clip.sha256())) throw new IllegalArgumentException("动作参考文件已变更,请重新上传"); + result.add(images.getOriginalService(model.getProviderCode()).uploadMedia(model, Files.readAllBytes(path), clip.id()+".mp4", "video/mp4")); + } + return result; + } catch (java.io.IOException error) { throw new IllegalStateException("动作参考无法上传", error); } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetService.java index c796d1ec..2421a45b 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetService.java @@ -63,7 +63,7 @@ public class ShortDramaVisualAssetService { owner(id,user); var script=scripts.selectById(scriptId); if(script==null || !id.equals(script.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); - var configured=models.selectModelByName(model); + var configured=ShortDramaAtlasModels.require(models,model,"chat"); if(configured==null || !"chat".equals(configured.getCategory())) throw new IllegalArgumentException("后台尚未配置可用写作模型"); var project=projects.selectById(id); var snapshot=skillCatalog.snapshot(project); @@ -175,8 +175,7 @@ public class ShortDramaVisualAssetService { var project = projects.selectById(id); String selectedDirection = skillCatalog.selected(project, "aesthetic") + skillCatalog.selected(project, "director"); String selectedMediaDirection = skillCatalog.selectedVisual(project, "aesthetic") + skillCatalog.selectedVisual(project, "director"); - var m=models.selectModelByName(model); - if(m==null) { var q=new ChatModelBo();q.setCategory("chat");m=models.queryAvailableList(q).stream().findFirst().orElseThrow(); } + var m=model==null || model.isBlank() ? ShortDramaAtlasModels.defaultModel(models,"chat") : ShortDramaAtlasModels.require(models,model,"chat"); var chat=chats.getOriginalService(ShortDramaServiceImpl.shortDramaProviderCode(m.getProviderCode(),m.getModelName())).buildChatModel(m); // Do not invent extra characters or costumes merely to increase image counts. String prompt=PROP_EXTRACTION_PROMPT+script.getScriptText()+"\n镜头列表:\n"+ @@ -492,7 +491,7 @@ public class ShortDramaVisualAssetService { var asset = assets.selectById(assetId); if(asset==null || !projectId.equals(asset.getProjectId()) || !"prop".equals(asset.getKind())) throw new IllegalArgumentException("只能重新生成当前项目的道具"); if(asset.getPrompt()==null || asset.getPrompt().isBlank()) throw new IllegalArgumentException("请先填写道具提示词"); - var configured=models.selectModelByName(model); + var configured=ShortDramaAtlasModels.require(models,model,"image"); if(configured==null || !"image".equals(configured.getCategory())) throw new IllegalArgumentException("请选择已配置的生图模型"); if(!propReferenceImage(asset.getReferenceImages()).isBlank() && "bytedance/seedream-v4.7/text-to-image".equals(model)) throw new IllegalArgumentException("当前 Seedream 4.7 文生图不支持参考图;请在创意设定中改用可编辑的图片模型后重试"); @@ -558,7 +557,7 @@ public class ShortDramaVisualAssetService { /** Generate missing project props only; never generate storyboard frames from the asset button. */ public Map generateMissingProps(Long id, String model, Long user) { owner(id,user); - var configured=models.selectModelByName(model); + var configured=ShortDramaAtlasModels.require(models,model,"image"); if(configured==null || !"image".equals(configured.getCategory())) throw new IllegalArgumentException("后台尚未配置可用图片模型"); if(running.putIfAbsent(id,"生成缺图道具")!=null) throw new IllegalStateException("资产任务正在运行,请等待完成"); errors.remove(id); @@ -579,7 +578,7 @@ public class ShortDramaVisualAssetService { public Map generateRange(Long id,String model,Long user,int start,int end) { if(start<1 || end=40)throw new IllegalArgumentException("每批请选择连续1至40镜"); requireCurrentScript(id,user); - var configured=models.selectModelByName(model); + var configured=ShortDramaAtlasModels.require(models,model,"image"); if(configured==null || !"image".equals(configured.getCategory()))throw new IllegalArgumentException("请选择生图模型"); var selectedShots=boards.selectList(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getProjectId,id).ge(ShortDramaStoryboard::getSceneNo,start).le(ShortDramaStoryboard::getSceneNo,end)); var selected=selectedShots.stream().map(ShortDramaStoryboard::getId).collect(java.util.stream.Collectors.toSet()); @@ -628,7 +627,7 @@ public class ShortDramaVisualAssetService { if (boards.selectCount(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getProjectId,id)) > 0) requireCurrentScript(id,user); synchronizeExistingFrames(id); - var configured=models.selectModelByName(model); + var configured=ShortDramaAtlasModels.require(models,model,"image"); if(configured==null || !"image".equals(configured.getCategory()))throw new IllegalArgumentException("请选择已配置的生图模型"); if(rows(id).isEmpty())throw new IllegalStateException("请先分析镜头资产"); if(running.putIfAbsent(id,"生成关键道具")!=null)throw new IllegalStateException("资产任务正在运行"); @@ -649,7 +648,7 @@ public class ShortDramaVisualAssetService { var asset=assets.selectById(assetId); if(asset==null || !id.equals(asset.getProjectId()) || !"shot_frame".equals(asset.getKind())) throw new IllegalArgumentException("只能修正本项目的镜头关键帧"); if(prompt==null || prompt.isBlank() || prompt.length()>12000) throw new IllegalArgumentException("请填写有效的画面描述(最多12000字)"); - var configured=models.selectModelByName(model); + var configured=ShortDramaAtlasModels.require(models,model,"image"); if(configured==null || !"image".equals(configured.getCategory()))throw new IllegalArgumentException("请选择生图模型"); if(running.putIfAbsent(id,"修正单镜构图")!=null)throw new IllegalStateException("请等待当前资产队列完成后修正单图"); try { @@ -702,7 +701,7 @@ public class ShortDramaVisualAssetService { try { boolean resume=a.getPredictionId()!=null && !a.getPredictionId().isBlank() && !"failed".equals(a.getStatus()); if (resume) { - var original = models.selectModelByName(a.getModel()); + var original = ShortDramaAtlasModels.require(models,a.getModel(),"image"); if (original == null) throw new IllegalStateException("原任务模型配置缺失,无法查询原prediction;没有提交新任务"); awaitImage(a, original); return; } @@ -752,7 +751,7 @@ public class ShortDramaVisualAssetService { throw new IllegalArgumentException("所选Seedream 4.7文生图不接参考图;此首帧需另行选择编辑型号,不会自动转换或丢弃已批准身份参考"); if(!refs.isEmpty() && model.endsWith("/text-to-image"))selected=model.replace("/text-to-image","/edit"); if(refs.isEmpty() && model.endsWith("/edit"))selected=model.replace("/edit","/text-to-image"); - var m=models.selectModelByName(selected);if(m==null)throw new IllegalStateException("缺少对应多参考图模型配置:"+selected); + var m=ShortDramaAtlasModels.require(models,selected,"image");if(m==null)throw new IllegalStateException("缺少对应多参考图模型配置:"+selected); // Resume acknowledged jobs; never submit the same prediction twice. a.setModel(m.getModelName());a.setErrorMessage("");a.setStatus("generating");a.setUpdateTime(new Date());assets.updateById(a); if(!resume) { diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetAnalysisFailureTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetAnalysisFailureTest.java index c745f8e5..8984eb45 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetAnalysisFailureTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetAnalysisFailureTest.java @@ -89,7 +89,7 @@ class ShortDramaAssetAnalysisFailureTest { when(scripts.selectById(20L)).thenReturn(script); when(scripts.selectOne(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(script); var configuration = new ChatModelVo(); configuration.setModelName("writing-model"); configuration.setProviderCode("atlas"); - when(models.selectModelByName("writing-model")).thenReturn(configuration); + ShortDramaAtlasTestModels.register(models, "writing-model", configuration, "chat"); when(factory.getOriginalService("atlas")).thenReturn(chat); when(chat.buildChatModel(eq(configuration), any())).thenReturn(model); when(model.chat(anyString())).thenReturn(COMBINED); diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasIsolationTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasIsolationTest.java new file mode 100644 index 00000000..b099d48d --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasIsolationTest.java @@ -0,0 +1,39 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.entity.chat.ChatModel; +import org.ruoyi.mapper.chat.ChatModelMapper; +import org.ruoyi.service.chat.IChatProviderService; +import org.ruoyi.service.chat.impl.ChatModelServiceImpl; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@Tag("dev") +class ShortDramaAtlasIsolationTest { + @BeforeAll static void metadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test-atlas-isolation"), ChatModel.class); + } + + @Test void sharedBackendStillSelectsOtherProvidersWhileShortDramaRejectsThem() { + for (String providerCode : List.of("openai", "deepseek", "custom_api")) { + var mapper = mock(ChatModelMapper.class); + var providers = mock(IChatProviderService.class); + var model = new ChatModelVo(); + model.setProviderCode(providerCode); model.setModelName("shared-name"); model.setCategory("chat"); + model.setApiHost("https://other-provider.test/v1"); model.setApiKey("test-only-key"); + when(mapper.selectVoOne(any(LambdaQueryWrapper.class))).thenReturn(model); + var sharedService = new ChatModelServiceImpl(mapper, providers); + assertSame(model, sharedService.selectModelByName("shared-name")); + verify(providers).requireEnabled(providerCode); + assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.requireProvider(model)); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasModelsTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasModelsTest.java new file mode 100644 index 00000000..7128ae14 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasModelsTest.java @@ -0,0 +1,74 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@Tag("dev") +class ShortDramaAtlasModelsTest { + private static ChatModelVo model(String provider, String name, String category) { + var model = new ChatModelVo(); + model.setProviderCode(provider); model.setModelName(name); model.setCategory(category); + model.setApiHost("https://api.atlascloud.ai/v1"); model.setApiKey("test-only-key"); + return model; + } + + @Test void scopesLookupByAtlasCategoryAndExactNameWithoutUsingGlobalNameLookup() { + var models = mock(IChatModelService.class); + var expected = model("atlas", "shared-name", "chat"); + when(models.queryAvailableList(any())).thenReturn(List.of(model("openai", "shared-name", "chat"), + model("atlas", "shared-name", "image"), model("atlas", "shared-name-longer", "chat"), expected)); + assertSame(expected, ShortDramaAtlasModels.require(models, "shared-name", "chat")); + var query = ArgumentCaptor.forClass(ChatModelBo.class); + verify(models).queryAvailableList(query.capture()); + assertEquals("atlas", query.getValue().getProviderCode()); + assertEquals("chat", query.getValue().getCategory()); + assertEquals("shared-name", query.getValue().getModelName()); + verify(models, never()).selectModelByName(any()); + } + + @Test void absentOrDisabledAtlasModelNeverFallsBackToAnotherProvider() { + var models = mock(IChatModelService.class); + when(models.queryAvailableList(any())).thenReturn(List.of(model("deepseek", "writer", "chat"))); + assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.require(models, "writer", "chat")); + assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.defaultModel(models, "chat")); + when(models.queryAvailableList(any())).thenReturn(List.of()); + assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.defaultModel(models, "chat")); + } + + @Test void ambiguousAtlasConfigurationMustBeCorrectedRatherThanChoosingAnArbitraryKey() { + var models = mock(IChatModelService.class); + when(models.queryAvailableList(any())).thenReturn(List.of(model("atlas", "writer", "chat"), model("atlas", "writer", "chat"))); + assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.require(models, "writer", "chat")); + } + + @Test void providerLabelCannotSendAtlasCredentialsToAnotherHost() { + for (String host : List.of("https://api.deepseek.com/v1", "https://api.atlascloud.ai.evil.test/v1", + "http://api.atlascloud.ai/v1", "https://key@api.atlascloud.ai/v1", "https://api.atlascloud.ai:8443/v1", + "https://api.atlascloud.ai/v1?key=secret", "")) { + var model = model("atlas", "writer", "chat"); model.setApiHost(host); + assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.requireProvider(model), host); + } + } + + @Test void missingKeyAndNonAtlasProviderAreRejectedBeforeAnyMediaSubmission() { + var model = model("atlas", "writer", "chat"); model.setApiKey(" "); + assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.requireProvider(model)); + assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.requireProvider(model("custom_api", "writer", "chat"))); + } + + @Test void referenceUploadsAcceptAtlasImageOrVideoConfigurationsWithoutAcceptingChatModels() { + var models = mock(IChatModelService.class); + var video = model("atlas", "video", "video"); + when(models.queryAvailableList(any())).thenReturn(List.of(video)); + assertSame(video, ShortDramaAtlasModels.require(models, "video", "image", "video")); + when(models.queryAvailableList(any())).thenReturn(List.of(model("atlas", "writer", "chat"))); + assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.require(models, "writer", "image", "video")); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasTestModels.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasTestModels.java new file mode 100644 index 00000000..255d210b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAtlasTestModels.java @@ -0,0 +1,27 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.WeakHashMap; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; + +/** Test catalog with the same provider/category/name scope as production. No network calls. */ +final class ShortDramaAtlasTestModels { + private static final Map> CATALOGS = new WeakHashMap<>(); + static synchronized void register(IChatModelService service, String name, ChatModelVo model, String category) { + model.setModelName(name); model.setProviderCode("atlas"); model.setCategory(category); + model.setApiHost("https://api.atlascloud.ai/v1"); model.setApiKey("test-only-key"); + var catalog = CATALOGS.computeIfAbsent(service, ignored -> new LinkedHashMap<>()); + catalog.put(category + ":" + name, model); + doAnswer(call -> { + ChatModelBo query = call.getArgument(0); + return catalog.values().stream().filter(entry -> entry.getProviderCode().equals(query.getProviderCode()) + && (query.getCategory() == null || entry.getCategory().equals(query.getCategory())) + && (query.getModelName() == null || entry.getModelName().equals(query.getModelName()))).toList(); + }).when(service).queryAvailableList(any()); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaContinuityTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaContinuityTest.java index 0a248e87..88626ac7 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaContinuityTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaContinuityTest.java @@ -23,11 +23,11 @@ class ShortDramaContinuityTest { @Test void compatibleDeepSeekUsesAdapterThatSendsThinkingSetting() { - assertEquals("deepseek", ShortDramaServiceImpl.shortDramaProviderCode("openai", "deepseek-ai/deepseek-v4.1-flash")); + assertThrows(IllegalArgumentException.class, () -> ShortDramaServiceImpl.shortDramaProviderCode("openai", "deepseek-ai/deepseek-v4.1-flash")); assertEquals("deepseek", ShortDramaServiceImpl.shortDramaProviderCode("atlas", "deepseek-ai/deepseek-v4.1-flash")); assertEquals("atlas", ShortDramaServiceImpl.shortDramaProviderCode("atlas", "gemini-3-flash")); - assertEquals("openai", ShortDramaServiceImpl.shortDramaProviderCode("openai", "gemini-3-flash")); - assertEquals("custom_api", ShortDramaServiceImpl.shortDramaProviderCode("custom_api", "deepseek-v4")); + assertThrows(IllegalArgumentException.class, () -> ShortDramaServiceImpl.shortDramaProviderCode("openai", "gemini-3-flash")); + assertThrows(IllegalArgumentException.class, () -> ShortDramaServiceImpl.shortDramaProviderCode("custom_api", "deepseek-v4")); } @Test diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaImageRevisionTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaImageRevisionTest.java index 756a8202..b3ced58e 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaImageRevisionTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaImageRevisionTest.java @@ -95,7 +95,7 @@ class ShortDramaImageRevisionTest { var locations = mock(ShortDramaLocationMapper.class); when(locations.selectById(50L)).thenReturn(location); var models = mock(IChatModelService.class); model = new ChatModelVo(); model.setModelName("edit-model"); model.setProviderCode("atlas"); - when(models.selectModelByName("edit-model")).thenReturn(model); + ShortDramaAtlasTestModels.register(models, "edit-model", model, "image"); images = mock(IImageGenerationService.class); when(images.startImageGeneration(any())).thenReturn(MediaGenerationResponse.builder().id("prediction-1").status("processing").build()); when(images.generateImage(any())).thenReturn(""); diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaModelErrorsTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaModelErrorsTest.java new file mode 100644 index 00000000..8125b15b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaModelErrorsTest.java @@ -0,0 +1,18 @@ +package org.ruoyi.service.shortdrama.impl; + +import dev.langchain4j.exception.AuthenticationException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import java.util.concurrent.CompletionException; +import static org.junit.jupiter.api.Assertions.*; + +@Tag("dev") +class ShortDramaModelErrorsTest { + @Test void nestedAuthenticationErrorNamesAtlasWithoutExposingSdkOrVendorResponse() { + var error = new CompletionException(new AuthenticationException("{\"code\":401,\"msg\":\"unauthorized\"}")); + assertEquals("Atlas 模型鉴权失败,请检查后台该模型的 API Key、接口地址和账号权限", ShortDramaModelErrors.message(error)); + } + @Test void otherGenerationErrorsKeepTheOriginalActionableMessage() { + assertEquals("正文格式不完整", ShortDramaModelErrors.message(new IllegalArgumentException("正文格式不完整"))); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaPartialPlanRecoveryTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaPartialPlanRecoveryTest.java index 37307130..df9f1632 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaPartialPlanRecoveryTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaPartialPlanRecoveryTest.java @@ -75,7 +75,7 @@ class ShortDramaPartialPlanRecoveryTest { when(appearances.selectList(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(assets.appearances()); when(locations.selectList(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(assets.locations()); when(boards.selectList(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of()); - var model = new ChatModelVo(); model.setModelName("writer"); when(models.selectModelByName("writer")).thenReturn(model); + var model = new ChatModelVo(); model.setModelName("writer"); ShortDramaAtlasTestModels.register(models, "writer", model, "chat"); review = new ShortDramaReviewedPlanBo(); review.setScriptId(20L); review.setModel("writer"); review.setExpectedScriptText(script.getScriptText()); review.setMinimumShotSeconds(4); review.setSceneNumbers(List.of(1)); review.setExpectedAssetSignature(assets.signature()); review.setPanels(List.of(ShortDramaSceneCheckpointTest.panel())); diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaScriptOnlyCreationTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaScriptOnlyCreationTest.java index 5c56c58e..cc8f01fe 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaScriptOnlyCreationTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaScriptOnlyCreationTest.java @@ -91,7 +91,7 @@ class ShortDramaScriptOnlyCreationTest { ReflectionTestUtils.setField(service, "activeEmitters", emitters); ReflectionTestUtils.setField(service, "planningProgress", new ConcurrentHashMap<>()); var configuration = new ChatModelVo(); configuration.setModelName("writing-model"); configuration.setProviderCode("atlas"); - when(models.selectModelByName("writing-model")).thenReturn(configuration); + ShortDramaAtlasTestModels.register(models, "writing-model", configuration, "chat"); when(factory.getOriginalService("atlas")).thenReturn(chat); when(chat.buildChatModel(eq(configuration), any())).thenReturn(model); when(chat.buildStreamingChatModel(eq(configuration), any())).thenReturn(streaming); diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaSkillCategoriesTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaSkillCategoriesTest.java new file mode 100644 index 00000000..6ca10140 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaSkillCategoriesTest.java @@ -0,0 +1,29 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.ruoyi.controller.shortdrama.ShortDramaSkillController; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@Tag("dev") +class ShortDramaSkillCategoriesTest { + @TempDir Path root; + + @Test void categoriesEndpointReturnsCatalogRatherThanLookingUpASkillNamedCategories() throws Exception { + var catalog = spy(new ShortDramaSkillCatalog(root.toString())); + var mvc = MockMvcBuilders.standaloneSetup(new ShortDramaSkillController(catalog, mock(ShortDramaSkillMarket.class))).build(); + mvc.perform(get("/short-drama/skills/categories")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data[2].type").value("screenwriting")); + verify(catalog, never()).detail("categories"); + for (var category : catalog.categories()) { + if (category.editable()) assertDoesNotThrow(() -> catalog.list(category.type(), false)); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoReferenceRoutesTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoReferenceRoutesTest.java new file mode 100644 index 00000000..d3e57fd2 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoReferenceRoutesTest.java @@ -0,0 +1,45 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.factory.ImageServiceFactory; +import org.ruoyi.common.satoken.utils.LoginHelper; +import org.ruoyi.controller.shortdrama.ShortDramaVideoReferenceController; +import org.ruoyi.domain.entity.shortdrama.ShortDramaProject; +import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@Tag("dev") +class ShortDramaVideoReferenceRoutesTest { + @Test void listRouteUsesCurrentUserAndReturnsReferencesWithoutAModelCall() throws Exception { + var references = mock(ShortDramaVideoReferenceService.class); + when(references.list(10L, 1L)).thenReturn(List.of()); + var mvc = MockMvcBuilders.standaloneSetup(new ShortDramaVideoReferenceController(references)).build(); + try (var login = mockStatic(LoginHelper.class)) { + login.when(LoginHelper::getUserId).thenReturn(1L); + mvc.perform(get("/short-drama/10/video-references")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").isEmpty()); + } + verify(references).list(10L, 1L); + verifyNoMoreInteractions(references); + } + + @Test void ownershipAndAtlasChecksRunBeforeReadingOrUploadingReferences() { + var projects = mock(ShortDramaProjectMapper.class); + var images = mock(ImageServiceFactory.class); + var project = new ShortDramaProject(); project.setUserId(2L); + when(projects.selectById(10L)).thenReturn(project); + var references = new ShortDramaVideoReferenceService(projects, images); + assertThrows(IllegalArgumentException.class, () -> references.list(10L, 1L)); + var model = new ChatModelVo(); model.setProviderCode("openai"); + assertThrows(IllegalArgumentException.class, () -> references.transport(10L, model, List.of())); + verifyNoInteractions(images); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoSubmissionTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoSubmissionTest.java index a3114aef..dea7ea74 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoSubmissionTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoSubmissionTest.java @@ -80,9 +80,9 @@ class ShortDramaVideoSubmissionTest { when(mapper.selectById(99L)).thenAnswer(a -> shot); when(projects.selectById(10L)).thenReturn(project); when(assets.readyFrame(99L)).thenReturn("https://example.com/reviewed.png"); - when(models.selectModelByName(MODEL)).thenReturn(model(MODEL)); - when(models.selectModelByName(ACTUAL)).thenReturn(model(ACTUAL)); - when(videos.getOriginalService("fixture")).thenReturn(provider); + ShortDramaAtlasTestModels.register(models, MODEL, model(MODEL), "video"); + ShortDramaAtlasTestModels.register(models, ACTUAL, model(ACTUAL), "video"); + when(videos.getOriginalService("atlas")).thenReturn(provider); when(sounds.references(eq(10L), eq(1), any())).thenReturn(List.of()); when(provider.generateVideo(any())).thenReturn(MediaGenerationResponse.builder().id("prediction-1").status("processing").build()); when(mapper.update(isNull(), any())).thenAnswer(a -> { @@ -113,8 +113,8 @@ class ShortDramaVideoSubmissionTest { var sample=new ShortDramaSoundService.Reference("voice:42:sample","阿青",directory.resolve("sample.mp3"),3.0,"immutable-hash"); var binding=new ShortDramaCharacterVoiceService.Binding("42","阿青","sample",1,3.0,1); when(voices.plan(any(),nullable(String.class),anyBoolean())).thenReturn(new ShortDramaCharacterVoiceService.Plan(List.of(binding),List.of(sample),"\n阿青使用 @音频1 的音色",List.of(),true,List.of("42"))); - when(models.selectModelByName(referenceModel)).thenReturn(model(referenceModel)); - when(models.selectModelByName(requestedModel)).thenReturn(model(requestedModel)); + ShortDramaAtlasTestModels.register(models, referenceModel, model(referenceModel), "video"); + ShortDramaAtlasTestModels.register(models, requestedModel, model(requestedModel), "video"); when(sounds.uploadReferences(anyList(),any())).thenReturn(List.of("https://example.com/voice-a.mp3"),List.of("https://example.com/voice-b.mp3")); String first=request();service.generateVideo(99L,requestedModel,1L,first,false); var capture=org.mockito.ArgumentCaptor.forClass(VideoContext.class);verify(provider).generateVideo(capture.capture()); diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetVersioningTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetVersioningTest.java index 3256e390..ad5ff83c 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetVersioningTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetVersioningTest.java @@ -33,7 +33,7 @@ class ShortDramaVisualAssetVersioningTest { var asset = new ShortDramaVisualAsset(); asset.setId(1L); asset.setProjectId(7L); asset.setStoryboardId(11L); asset.setKind("shot_frame"); asset.setStatus("waiting"); asset.setPredictionId("original-prediction"); asset.setModel("openai/gpt-image-2/edit"); asset.setSourceHash("old-version"); var original = new org.ruoyi.common.chat.domain.vo.chat.ChatModelVo(); original.setModelName(asset.getModel()); - when(models.selectModelByName(asset.getModel())).thenReturn(original); + ShortDramaAtlasTestModels.register(models, asset.getModel(), original, "image"); when(predictions.retrieve(original, "original-prediction")).thenReturn(org.ruoyi.common.chat.entity.media.MediaGenerationResponse.builder() .id("original-prediction").status("completed").url("https://example.com/old-result.png").build()); ReflectionTestUtils.invokeMethod(service, "generateOne", asset, "bytedance/seedream-v4.7/text-to-image");