From 2ae13aafd423a9aabd01b284c4169604bf8e4b07 Mon Sep 17 00:00:00 2001 From: "ageerle@163.com" Date: Tue, 14 Jul 2026 21:49:03 +0800 Subject: [PATCH 01/29] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=9F=AD?= =?UTF-8?q?=E5=89=A7/=E5=AA=92=E4=BD=93=E7=94=9F=E6=88=90=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E5=B9=B6=E8=84=B1=E6=95=8F=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增音频/视频/图片生成服务与 Atlas、OpenAI 实现 - 新增短剧脚本生成、分镜与 ffmpeg 合成模块 - 新增 Coze/Dify 聊天 provider 及 Agent 配置 - 脱敏 SQL 脚本:移除真实 DeepSeek API Key,掩码 COS 桶名 - 清理过期的 SQL update 脚本与 chat 文档 Co-Authored-By: Claude --- README.md | 14 +- docs/docker/ruoyi-ai/Dockerfile.backend | 20 +- docs/script/docker/mysql/init/init-db.sh | 10 - docs/script/install-ffmpeg-windows.ps1 | 86 + docs/script/sql/ruoyi-ai-v3_mysql8.sql | 410 +- docs/script/sql/update/updat-0419.sql | 92 - docs/script/sql/update/updat-0420.sql | 46 - docs/script/sql/update/updat-0423.sql | 14 - pom.xml | 16 +- ruoyi-admin/Dockerfile | 48 +- .../src/main/resources/application.yml | 34 + ruoyi-common/ruoyi-common-bom/pom.xml | 2 +- .../chat/domain/dto/request/ChatRequest.java | 22 +- .../chat/domain/dto/request/ReSumeRunner.java | 19 - .../chat/entity/audio/AudioContext.java | 29 + .../chat/entity/image/ImageContext.java | 5 + .../entity/media/MediaGenerationResponse.java | 28 + .../chat/entity/video/VideoContext.java | 35 + .../chat/factory/AudioServiceFactory.java | 40 + .../chat/factory/ImageServiceFactory.java | 3 + .../chat/factory/VideoServiceFactory.java | 40 + .../audio/IAudioGenerationService.java | 15 + .../image/IImageGenerationService.java | 25 +- .../video/IVideoGenerationService.java | 17 + .../ruoyi/common/core/service/OssService.java | 17 + .../org/ruoyi/common/oss/core/OssClient.java | 2 +- .../workflow/workflow/WorkflowEngine.java | 4 - .../ruoyi-chat/docs/MCP工具模块接口文档.md | 336 -- .../ruoyi-chat/docs/数据库智能体实现总结.md | 152 - ruoyi-modules/ruoyi-chat/pom.xml | 36 +- .../ruoyi/agent/ShortDramaScriptAgent.java | 51 + .../config/agent/SkillsPathResolver.java | 32 + .../constant/ShortDramaImageConstants.java | 72 + .../controller/agent/AgentController.java | 130 + .../controller/chat/ChatModelController.java | 5 +- .../chat/MediaGenerationController.java | 141 + .../shortdrama/ShortDramaController.java | 377 ++ .../org/ruoyi/domain/bo/agent/AgentBo.java | 95 + .../bo/media/ImageGenerationRequest.java | 22 + .../bo/media/SpeechGenerationRequest.java | 22 + .../bo/media/VideoGenerationRequest.java | 20 + .../ShortDramaCharacterAppearanceBo.java | 35 + .../bo/shortdrama/ShortDramaCharacterBo.java | 37 + .../shortdrama/ShortDramaComposeVideoBo.java | 29 + .../bo/shortdrama/ShortDramaIdeaBo.java | 20 + .../bo/shortdrama/ShortDramaLocationBo.java | 41 + .../bo/shortdrama/ShortDramaProjectBo.java | 27 + .../bo/shortdrama/ShortDramaScriptBo.java | 27 + .../bo/shortdrama/ShortDramaScriptResult.java | 31 + .../bo/shortdrama/ShortDramaStoryboardBo.java | 49 + .../org/ruoyi/domain/entity/agent/Agent.java | 84 + .../shortdrama/ShortDramaCharacter.java | 43 + .../ShortDramaCharacterAppearance.java | 46 + .../entity/shortdrama/ShortDramaLocation.java | 52 + .../entity/shortdrama/ShortDramaProject.java | 53 + .../entity/shortdrama/ShortDramaScript.java | 33 + .../shortdrama/ShortDramaStoryboard.java | 61 + .../org/ruoyi/domain/vo/agent/AgentVo.java | 121 + .../ruoyi/domain/vo/agent/SkillOptionVo.java | 33 + .../ShortDramaCharacterAppearanceVo.java | 43 + .../vo/shortdrama/ShortDramaCharacterVo.java | 48 + .../shortdrama/ShortDramaComposeVideoVo.java | 45 + .../vo/shortdrama/ShortDramaDetailVo.java | 24 + .../vo/shortdrama/ShortDramaLocationVo.java | 49 + .../vo/shortdrama/ShortDramaProjectVo.java | 52 + .../vo/shortdrama/ShortDramaScriptVo.java | 35 + .../vo/shortdrama/ShortDramaStoryboardVo.java | 63 + .../java/org/ruoyi/enums/ChatModeType.java | 2 + .../org/ruoyi/mapper/agent/AgentMapper.java | 14 + .../ShortDramaCharacterAppearanceMapper.java | 8 + .../shortdrama/ShortDramaCharacterMapper.java | 8 + .../shortdrama/ShortDramaLocationMapper.java | 8 + .../shortdrama/ShortDramaProjectMapper.java | 8 + .../shortdrama/ShortDramaScriptMapper.java | 8 + .../ShortDramaStoryboardMapper.java | 8 + .../ruoyi/service/agent/IAgentService.java | 59 + .../service/agent/impl/AgentServiceImpl.java | 226 + .../audio/AbstractAudioGenerationService.java | 19 + .../OpenAiAudioGenerationServiceImpl.java | 102 + .../service/chat/impl/ChatServiceFacade.java | 475 ++- .../impl/provider/CozeChatServiceImpl.java | 409 ++ .../impl/provider/DifyChatServiceImpl.java | 323 ++ .../chat/impl/provider/OpenAIServiceImpl.java | 2 + .../impl/provider/ZhiPuChatServiceImpl.java | 7 + .../image/AbstractImageGenerationService.java | 8 +- .../AtlasImageGenerationServiceImpl.java | 286 ++ .../OpenAiImageGenerationServiceImpl.java | 93 + .../provider/TongYiWanxImageServiceImpl.java | 2 +- .../service/media/AtlasMediaSupport.java | 38 + .../service/media/AtlasPredictionService.java | 82 + .../service/media/OpenAiMediaSupport.java | 45 + .../shortdrama/IShortDramaService.java | 116 + .../IShortDramaVideoComposeService.java | 19 + .../shortdrama/composition/AspectRatio.java | 46 + .../composition/CompositionArtifact.java | 18 + .../composition/CompositionSource.java | 15 + .../composition/CompositionSpec.java | 29 + .../composition/FfmpegCommandBuilder.java | 72 + .../FfmpegCompositionProperties.java | 68 + .../composition/FfmpegCompositionWorker.java | 27 + .../composition/FfmpegFilterGraph.java | 10 + .../composition/FfmpegFilterGraphBuilder.java | 176 + .../composition/FfmpegMediaProbe.java | 128 + .../composition/FfmpegProcessRunner.java | 96 + .../composition/FfmpegVideoComposer.java | 136 + .../shortdrama/composition/MediaInfo.java | 22 + .../composition/MediaProcessException.java | 12 + .../composition/MediaProcessResult.java | 4 + .../composition/TransitionType.java | 40 + .../shortdrama/composition/VideoCanvas.java | 10 + .../VideoCompositionExecutorConfig.java | 34 + .../download/SafeVideoSourceDownloader.java | 363 ++ .../VideoSourceDownloadException.java | 12 + .../VideoSourceDownloadProperties.java | 30 + .../impl/ShortDramaServiceImpl.java | 3795 +++++++++++++++++ .../impl/ShortDramaVideoComposeJob.java | 28 + .../ShortDramaVideoComposeServiceImpl.java | 299 ++ .../impl/ShortDramaVideoComposeWorker.java | 283 ++ .../video/AbstractVideoGenerationService.java | 26 + .../AtlasVideoGenerationServiceImpl.java | 89 + .../OpenAiVideoGenerationServiceImpl.java | 130 + .../org/ruoyi/enums/ChatModeTypeTest.java | 24 + .../provider/CozeChatServiceImplTest.java | 47 + .../provider/DifyChatServiceImplTest.java | 46 + .../AtlasImageGenerationServiceImplTest.java | 44 + .../composition/FfmpegCommandBuilderTest.java | 51 + .../FfmpegFilterGraphBuilderTest.java | 188 + .../composition/FfmpegMediaProbeTest.java | 105 + .../composition/FfmpegProcessRunnerTest.java | 26 + .../SafeVideoSourceDownloaderTest.java | 89 + ...rtDramaServiceImplStoryboardMergeTest.java | 96 + .../service/impl/SysOssServiceImpl.java | 72 +- 132 files changed, 11949 insertions(+), 977 deletions(-) delete mode 100644 docs/script/docker/mysql/init/init-db.sh create mode 100644 docs/script/install-ffmpeg-windows.ps1 delete mode 100644 docs/script/sql/update/updat-0419.sql delete mode 100644 docs/script/sql/update/updat-0420.sql delete mode 100644 docs/script/sql/update/updat-0423.sql delete mode 100644 ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ReSumeRunner.java create mode 100644 ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/audio/AudioContext.java create mode 100644 ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/media/MediaGenerationResponse.java create mode 100644 ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java create mode 100644 ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/AudioServiceFactory.java create mode 100644 ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/VideoServiceFactory.java create mode 100644 ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/audio/IAudioGenerationService.java create mode 100644 ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/video/IVideoGenerationService.java delete mode 100644 ruoyi-modules/ruoyi-chat/docs/MCP工具模块接口文档.md delete mode 100644 ruoyi-modules/ruoyi-chat/docs/数据库智能体实现总结.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ShortDramaScriptAgent.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/config/agent/SkillsPathResolver.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/constant/ShortDramaImageConstants.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/agent/AgentController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/MediaGenerationController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/agent/AgentBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/ImageGenerationRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/SpeechGenerationRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/VideoGenerationRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterAppearanceBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaComposeVideoBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaLocationBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaProjectBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptResult.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaStoryboardBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/agent/Agent.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacter.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacterAppearance.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaLocation.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaProject.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaScript.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaStoryboard.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/AgentVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/SkillOptionVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterAppearanceVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaComposeVideoVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaDetailVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaLocationVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaProjectVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaScriptVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaStoryboardVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/agent/AgentMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterAppearanceMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaLocationMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaProjectMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaScriptMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaStoryboardMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/IAgentService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/impl/AgentServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/AbstractAudioGenerationService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/OpenAiAudioGenerationServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/OpenAiImageGenerationServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasMediaSupport.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/OpenAiMediaSupport.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaVideoComposeService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/AspectRatio.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionArtifact.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSource.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSpec.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilder.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionProperties.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionWorker.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraph.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilder.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegMediaProbe.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegProcessRunner.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegVideoComposer.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaInfo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessException.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessResult.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/TransitionType.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCanvas.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCompositionExecutorConfig.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/SafeVideoSourceDownloader.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadException.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadProperties.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeJob.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/AbstractVideoGenerationService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/OpenAiVideoGenerationServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImplTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImplTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImplTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegMediaProbeTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegProcessRunnerTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/download/SafeVideoSourceDownloaderTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImplStoryboardMergeTest.java diff --git a/README.md b/README.md index 4e92fb7f..e616e37c 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,13 @@ ## ✨ 核心亮点 -| 模块 | 现有能力 -|:----------:|--- -| **模型管理** | 多模型接入(OpenAI/DeepSeek/通义/智谱/MiniMax)、多模态理解、Coze/DIFY/FastGPT平台集成 -| **知识管理** | 本地RAG + 向量库(Milvus/Weaviate/Qdrant) + 文档解析 -| **工具管理** | Mcp协议集成、Skills能力 + 可扩展工具生态 -| **流程编排** | 可视化工作流设计器、节点拖拽编排、SSE流式执行,目前已经支持模型调用,邮件发送,人工审核等节点 -| **多智能体** | 基于Langchain4j的Agent框架、Supervisor模式编排,支持多种决策模型 +| 模块 | 现有能力 +|:---------:|--- +| **模型管理** | 多模型接入(DeepSeek/智谱/MIMO/百炼/OpenAI)、多模态理解、Coze/DIFY/FastGPT/RAGFlow平台集成 +| **知识管理** | 本地RAG + 向量库(Milvus/Weaviate/Qdrant) + 文档解析 +| **工具管理** | Mcp协议集成、Skills能力 + 可扩展工具生态 +| **流程编排** | 可视化工作流设计器、节点拖拽编排、SSE流式执行,目前已经支持模型调用,邮件发送,人工审核等节点 +| **智能体管理** | 基于Langchain4j的Agent框架、Supervisor模式编排,支持多种决策模型,可以灵活搭配工具,skills ### 项目源码 diff --git a/docs/docker/ruoyi-ai/Dockerfile.backend b/docs/docker/ruoyi-ai/Dockerfile.backend index 87eb5626..3cd7ce5c 100644 --- a/docs/docker/ruoyi-ai/Dockerfile.backend +++ b/docs/docker/ruoyi-ai/Dockerfile.backend @@ -20,6 +20,22 @@ RUN mvn clean package -Pprod -DskipTests # 最终运行镜像 FROM eclipse-temurin:17-jre-alpine +RUN set -eux; \ + apk add --no-cache ffmpeg; \ + for filter in scale pad fps setsar format tpad trim settb setpts xfade \ + aresample aformat apad atrim asetpts anullsrc concat acrossfade; do \ + ffmpeg -hide_banner -filters 2>/dev/null | grep -Eq "[[:space:]]${filter}[[:space:]]"; \ + done; \ + ffmpeg -hide_banner -h filter=xfade 2>&1 | grep -q dissolve; \ + ffmpeg -hide_banner -h filter=xfade 2>&1 | grep -q fadeblack; \ + ffmpeg -hide_banner -h filter=xfade 2>&1 | grep -q slideleft; \ + ffmpeg -hide_banner -encoders 2>/dev/null | grep -Eq '[[:space:]]libx264[[:space:]]'; \ + ffmpeg -hide_banner -encoders 2>/dev/null | grep -Eq '[[:space:]]aac[[:space:]]'; \ + ffprobe -hide_banner -version >/dev/null + +ENV FFMPEG_PATH=/usr/bin/ffmpeg \ + FFPROBE_PATH=/usr/bin/ffprobe + # 设置工作目录 WORKDIR /app @@ -27,10 +43,10 @@ WORKDIR /app COPY --from=builder /build/ruoyi-admin/target/ruoyi-admin.jar ./ruoyi-admin.jar # 创建日志目录 -RUN mkdir -p /ruoyi/server/logs +RUN mkdir -p /ruoyi/server/logs /ruoyi/server/temp # 暴露端口 EXPOSE 6039 # 启动命令 -ENTRYPOINT ["java", "-jar", "ruoyi-admin.jar", "--spring.profiles.active=prod"] +ENTRYPOINT ["java", "-Djava.io.tmpdir=/ruoyi/server/temp", "-jar", "ruoyi-admin.jar", "--spring.profiles.active=prod"] diff --git a/docs/script/docker/mysql/init/init-db.sh b/docs/script/docker/mysql/init/init-db.sh deleted file mode 100644 index 46b6fc6a..00000000 --- a/docs/script/docker/mysql/init/init-db.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -# 数据库初始化脚本 -# 使用 --force 参数确保即使出错也继续执行 - -echo "开始初始化数据库..." - -# 使用 --force 参数忽略错误继续执行 -mysql -uroot -proot ruoyi-ai-agent --force < /docker-entrypoint-initdb.d/ruoyi-ai-v3_mysql8.sql - -echo "数据库初始化完成" diff --git a/docs/script/install-ffmpeg-windows.ps1 b/docs/script/install-ffmpeg-windows.ps1 new file mode 100644 index 00000000..e6b5b63f --- /dev/null +++ b/docs/script/install-ffmpeg-windows.ps1 @@ -0,0 +1,86 @@ +[CmdletBinding()] +param( + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +function Find-Executable([string]$Name) { + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + $roots = @( + (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages'), + (Join-Path $env:USERPROFILE 'scoop\apps\ffmpeg'), + 'C:\ProgramData\chocolatey\bin', + 'C:\ffmpeg' + ) + foreach ($root in $roots) { + if (-not (Test-Path $root)) { continue } + $match = Get-ChildItem -Path $root -Filter "$Name.exe" -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if ($match) { return $match } + } + return $null +} + +$ffmpeg = Find-Executable 'ffmpeg' +$ffprobe = Find-Executable 'ffprobe' + +if ($Force -or -not $ffmpeg -or -not $ffprobe) { + $winget = Get-Command winget -ErrorAction SilentlyContinue + if (-not $winget) { + throw '未找到 winget。请先从 Microsoft Store 安装“应用安装程序”,或使用项目 Dockerfile 运行后端。' + } + + Write-Host '正在通过 winget 安装 FFmpeg...' -ForegroundColor Cyan + & winget install --id Gyan.FFmpeg --exact --source winget ` + --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -ne 0) { + throw "winget 安装失败,退出码: $LASTEXITCODE" + } + + $ffmpeg = Find-Executable 'ffmpeg' + $ffprobe = Find-Executable 'ffprobe' +} + +if (-not $ffmpeg -or -not $ffprobe) { + throw 'FFmpeg 已安装但未能定位 ffmpeg.exe 或 ffprobe.exe。请重新打开 PowerShell 后再次运行脚本。' +} + +$binDirectory = Split-Path -Parent $ffmpeg +[Environment]::SetEnvironmentVariable('FFMPEG_PATH', $ffmpeg, 'User') +[Environment]::SetEnvironmentVariable('FFPROBE_PATH', $ffprobe, 'User') + +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +$pathParts = @($userPath -split ';' | Where-Object { $_ }) +if ($pathParts -notcontains $binDirectory) { + $newPath = (@($pathParts) + $binDirectory) -join ';' + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') +} + +$env:FFMPEG_PATH = $ffmpeg +$env:FFPROBE_PATH = $ffprobe +if (($env:Path -split ';') -notcontains $binDirectory) { + $env:Path = "$binDirectory;$env:Path" +} + +Write-Host "ffmpeg: $ffmpeg" -ForegroundColor Green +Write-Host "ffprobe: $ffprobe" -ForegroundColor Green + +& $ffmpeg -hide_banner -version | Select-Object -First 1 +& $ffprobe -hide_banner -version | Select-Object -First 1 + +$encoders = & $ffmpeg -hide_banner -encoders 2>&1 | Out-String +if ($encoders -notmatch '\blibx264\b') { + throw '当前 FFmpeg 不包含 libx264 编码器,无法满足短剧合成配置。' +} +if ($encoders -notmatch '\baac\b') { + throw '当前 FFmpeg 不包含 AAC 编码器,无法满足短剧合成配置。' +} + +Write-Host '' +Write-Host '安装与配置完成。请完全重启 IntelliJ IDEA 和 ruoyi-ai 后端服务。' -ForegroundColor Yellow +Write-Host '重启后,Spring 将从 FFMPEG_PATH 和 FFPROBE_PATH 读取绝对路径。' \ No newline at end of file diff --git a/docs/script/sql/ruoyi-ai-v3_mysql8.sql b/docs/script/sql/ruoyi-ai-v3_mysql8.sql index 89ea2af7..1f68405f 100644 --- a/docs/script/sql/ruoyi-ai-v3_mysql8.sql +++ b/docs/script/sql/ruoyi-ai-v3_mysql8.sql @@ -11,12 +11,45 @@ Target Server Version : 80045 (8.0.45) File Encoding : 65001 - Date: 21/05/2026 11:03:49 + Date: 14/07/2026 21:36:39 */ SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; +-- ---------------------------- +-- Table structure for agent_info +-- ---------------------------- +DROP TABLE IF EXISTS `agent_info`; +CREATE TABLE `agent_info` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '智能体ID', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户ID', + `agent_name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '智能体名称', + `agent_describe` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '智能体描述(下拉展示用)', + `agent_show` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '展示图标/头像URL', + `model_id` bigint NOT NULL COMMENT '绑定的聊天模型ID(chat_model.id, category=chat)', + `enable_thinking` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '是否启用深度思考(ReAct多子Agent):0 否 1 是', + `system_prompt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '自定义系统提示词', + `mcp_tool_ids` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '关联MCP工具ID列表(JSON数组,[Long])', + `skill_names` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '关联磁盘技能名列表(JSON数组,[String])', + `knowledge_ids` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '关联知识库ID列表(JSON数组,[Long])', + `status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '状态:0 正常 1 停用', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', + `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', + `update_by` bigint NULL DEFAULT NULL COMMENT '更新者', + `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_agent_tenant_id`(`tenant_id` ASC) USING BTREE, + INDEX `idx_agent_model_id`(`model_id` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '智能体信息表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of agent_info +-- ---------------------------- +INSERT INTO `agent_info` VALUES (1, 0, '对话智能体', '对话智能体', NULL, 2000585866022060033, '0', '你是一个乐于助人的通用 AI 助手,请用简洁、准确的中文回答用户的问题。', '[]', '[\"docx\"]', '[]', '0', '系统初始化的默认智能体,自动绑定首个启用的对话模型', 103, 1, '2026-07-14 16:12:21', 1, '2026-07-14 16:18:04'); + -- ---------------------------- -- Table structure for chat_message -- ---------------------------- @@ -42,6 +75,8 @@ CREATE TABLE `chat_message` ( -- ---------------------------- -- Records of chat_message -- ---------------------------- +INSERT INTO `chat_message` VALUES (2076944340894203905, 2076944338398593026, 1, '你好', 'user', 0, 'deepseek-v4-flash', 103, 1, '2026-07-14 16:18:13', 1, '2026-07-14 16:18:13', NULL, 0); +INSERT INTO `chat_message` VALUES (2076944348947267585, 2076944338398593026, 1, '你好!有什么可以帮你的吗?', 'assistant', 0, 'deepseek-v4-flash', -1, -1, '2026-07-14 16:18:15', -1, '2026-07-14 16:18:15', NULL, 0); -- ---------------------------- -- Table structure for chat_model @@ -70,9 +105,16 @@ CREATE TABLE `chat_model` ( -- ---------------------------- -- Records of chat_model -- ---------------------------- -INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) VALUES (2000585866022060033, 'chat', 'zai-org/glm-5.1', 'atlas', 'zai-org/glm-5.1', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', '对话模型', 0); -INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) VALUES (2007528268536287233, 'vector', 'embedding-3', 'zhipu', 'embedding-3', 2048, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-01-04 03:03:32', 1, '2026-03-15 19:18:51', '向量模型', 0); -INSERT INTO `chat_model` (`id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_dimension`, `model_show`, `api_host`, `api_key`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`, `tenant_id`) VALUES (2045071617578237953, 'rerank', 'rerank', 'zhipu', 'rerank', NULL, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-04-17 17:27:24', 1, '2026-04-20 15:21:48', '重排序模型', 0); +INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 'deepseek-v4-flash', 'deepseek', 'deepseek-v4-flash', NULL, 'Y', 'https://api.deepseek.com', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', '对话模型', 0); +INSERT INTO `chat_model` VALUES (2007528268536287233, 'vector', 'embedding-3', 'zhipu', 'embedding-3', 2048, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-01-04 03:03:32', 1, '2026-03-15 19:18:51', '向量模型', 0); +INSERT INTO `chat_model` VALUES (2045071617578237953, 'rerank', 'rerank', 'zhipu', 'rerank', NULL, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-04-17 17:27:24', 1, '2026-04-20 15:21:48', '重排序模型', 0); +INSERT INTO `chat_model` VALUES (2050000000000000001, 'video', 'bytedance/seedance-2.0/text-to-video', 'atlas', 'Seedance 2.0 文生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-17 18:39:20', 1, '2026-06-17 18:39:20', 'Atlas Cloud 视频模型 - Seedance 2.0', 0); +INSERT INTO `chat_model` VALUES (2060622000000000001, 'image', 'openai/gpt-image-2/text-to-image', 'atlas', 'GPT-IMAGE-2 文生图', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 14:45:54', 1, '2026-06-22 14:45:54', 'Atlas Cloud 图片模型 - 文生图,支持三视图、角色设定、场景图', 0); +INSERT INTO `chat_model` VALUES (2060622000000000002, 'image', 'openai/gpt-image-2/edit', 'atlas', 'GPT-IMAGE-2 图生图编辑', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 14:45:54', 1, '2026-06-22 14:45:54', 'Atlas Cloud 图片模型 - 图生图/编辑,支持基于参考图修改风格、纹理等', 0); +INSERT INTO `chat_model` VALUES (2060622000000000003, 'video', 'bytedance/seedance-2.0/image-to-video', 'atlas', 'Seedance 2.0 图生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 17:24:07', 1, '2026-06-22 17:24:07', 'Atlas Cloud 视频模型 - Seedance 2.0 图生视频,接收参考图+提示词生成视频', 0); +INSERT INTO `chat_model` VALUES (2060622000000000004, 'video', 'bytedance/seedance-2.0/reference-to-video', 'atlas', 'Seedance 2.0 多参考图生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 20:24:44', 1, '2026-06-22 20:24:44', 'Atlas Cloud 视频模型 - Seedance 2.0 多参考图生视频,接收多张参考图+带@imageN标记的提示词生成视频', 0); +INSERT INTO `chat_model` VALUES (2070700000000000002, 'chat', 'dify-chat', 'dify', 'Dify Chat App', NULL, 'Y', 'https://api.dify.ai/v1', '替换为你的DIFY_APP_API_KEY', 103, 1, '2026-07-14 11:03:44', 1, '2026-07-14 11:03:44', 'Dify 聊天应用;api_key 为 Dify App API Key,model_name 可按应用名修改', 0); +INSERT INTO `chat_model` VALUES (2070700000000000004, 'chat', '替换为你的COZE_BOT_ID', 'coze', 'Coze Bot', NULL, 'Y', 'https://api.coze.cn', '替换为你的COZE_PAT', 103, 1, '2026-07-14 11:03:38', 1, '2026-07-14 11:03:38', 'Coze 聊天 Bot;model_name 为 Coze Bot ID,api_key 为 PAT 或 OAuth access token', 0); -- ---------------------------- -- Table structure for chat_provider @@ -100,19 +142,21 @@ CREATE TABLE `chat_provider` ( PRIMARY KEY (`id`) USING BTREE, UNIQUE INDEX `unique_provider_code`(`provider_code` ASC, `tenant_id` ASC, `del_flag` ASC) USING BTREE, INDEX `idx_status`(`status` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2045727230803255298 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '厂商管理表' ROW_FORMAT = DYNAMIC; +) ENGINE = InnoDB AUTO_INCREMENT = 2070700000000000004 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '厂商管理表' ROW_FORMAT = DYNAMIC; -- ---------------------------- -- Records of chat_provider -- ---------------------------- -INSERT INTO `chat_provider` VALUES (11, '深度求索', 'deepseek', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/5ba8c30f153246898a4d7dc7b846de8d.png', 'DeepSeek官方API', 'https://api.deepseek.com', '0', 0, 103, '2026-04-19 12:52:34', '1', '1', '2026-04-19 13:13:25', 'DeepSeek官方API', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (12, '智谱AI', 'zhipu', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/da071783c9284fdd9ed1ce1b57b3c75c.png', '智谱AI大模型服务', 'https://open.bigmodel.cn', '0', 4, 103, '2025-12-14 21:48:11', '1', '1', '2026-04-19 13:14:00', '智谱AI厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (13, '小米MIMO', 'xiaomi', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/18dd39365ce244e3ae5e030da036760e.png', '小米官方API', 'https://api.xiaomimimo.com/v1', '0', 3, 103, '2026-04-19 12:48:24', '1', '1', '2026-04-19 13:14:22', '小米官方API', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (14, '阿里云百炼', 'qianwen', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', '阿里云百炼大模型服务', 'https://dashscope.aliyuncs.com', '0', 2, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:49:13', '阿里云厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (15, 'Atlas Cloud', 'atlas', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/atlascloud.png', '全模态AI推理平台', 'https://api.atlascloud.ai/v1', '0', 5, 103, '2025-12-15 23:13:42', '1', '1', '2026-02-25 20:49:01', 'Atlas Cloud AI平台', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (16, 'MiniMax', 'minimax', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/fdc712e90e0e4d78b05862ad230884e5.png', 'MiniMax大模型服务,支持M2.7、M2.5等模型', 'https://api.minimax.io/v1', '0', 6, 103, '2026-04-19 12:50:12', '1', '1', '2026-04-19 13:14:59', 'MiniMax厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (17, 'ollama', 'ollama', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', 'ollama大模型', 'http://127.0.0.1:11434', '0', 7, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:48:48', 'ollama厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (18, '自定义厂商', 'custom_api', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/c1a8e122510f4e2f90deb36958af710b.png', 'OPENAI兼容格式', '自定义', '0', 8, 103, '2026-04-19 12:35:57', '1', '1', '2026-04-19 13:17:20', 'OPENAI兼容格式', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (11, '深度求索', 'deepseek', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/5ba8c30f153246898a4d7dc7b846de8d.png', 'DeepSeek官方API', 'https://api.deepseek.com', '0', 0, 103, '2026-04-19 12:52:34', '1', '1', '2026-04-19 13:13:25', 'DeepSeek官方API', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (12, '智谱AI', 'zhipu', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/da071783c9284fdd9ed1ce1b57b3c75c.png', '智谱AI大模型服务', 'https://open.bigmodel.cn', '0', 4, 103, '2025-12-14 21:48:11', '1', '1', '2026-04-19 13:14:00', '智谱AI厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (13, '小米MIMO', 'xiaomi', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/18dd39365ce244e3ae5e030da036760e.png', '小米官方API', 'https://api.xiaomimimo.com/v1', '0', 3, 103, '2026-04-19 12:48:24', '1', '1', '2026-04-19 13:14:22', '小米官方API', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (14, '阿里云百炼', 'qianwen', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', '阿里云百炼大模型服务', 'https://dashscope.aliyuncs.com', '0', 2, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:49:13', '阿里云厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (15, 'Atlas Cloud', 'atlas', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/atlascloud.png', '全模态AI推理平台', 'https://api.atlascloud.ai/v1', '0', 5, 103, '2025-12-15 23:13:42', '1', '1', '2026-02-25 20:49:01', 'Atlas Cloud AI平台', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (16, 'MiniMax', 'minimax', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/fdc712e90e0e4d78b05862ad230884e5.png', 'MiniMax大模型服务,支持M2.7、M2.5等模型', 'https://api.minimax.io/v1', '0', 6, 103, '2026-04-19 12:50:12', '1', '1', '2026-04-19 13:14:59', 'MiniMax厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (17, 'ollama', 'ollama', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', 'ollama大模型', 'http://127.0.0.1:11434', '0', 7, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:48:48', 'ollama厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (18, '自定义厂商', 'custom_api', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/c1a8e122510f4e2f90deb36958af710b.png', 'OPENAI兼容格式', '自定义', '0', 8, 103, '2026-04-19 12:35:57', '1', '1', '2026-04-19 13:17:20', 'OPENAI兼容格式', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (2070700000000000001, 'Dify', 'dify', NULL, 'Dify 应用 API 服务', 'https://api.dify.ai/v1', '0', 9, 103, '2026-07-14 11:03:44', '1', '1', '2026-07-14 11:03:44', 'Dify 应用 API 服务商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (2070700000000000003, 'Coze', 'coze', NULL, 'Coze Bot API 服务', 'https://api.coze.cn', '0', 10, 103, '2026-07-14 11:03:38', '1', '1', '2026-07-14 11:03:38', 'Coze Bot API 服务商', NULL, '0', NULL, 0); -- ---------------------------- -- Table structure for chat_session @@ -137,6 +181,13 @@ CREATE TABLE `chat_session` ( -- ---------------------------- -- Records of chat_session -- ---------------------------- +INSERT INTO `chat_session` VALUES (2076944338398593026, 1, '你好', '你好', '103', 1, '2026-07-14 16:18:13', 1, '2026-07-14 16:18:13', '你好', NULL, 0); +INSERT INTO `chat_session` VALUES (2076957117012561921, 1, '测试工作流', NULL, '103', 1, '2026-07-14 17:08:59', 1, '2026-07-14 17:08:59', NULL, NULL, 0); +INSERT INTO `chat_session` VALUES (2076958796772593665, 1, '测试工作流', NULL, '103', 1, '2026-07-14 17:15:40', 1, '2026-07-14 17:15:40', NULL, NULL, 0); +INSERT INTO `chat_session` VALUES (2076958822525620225, 1, '测试工作流', NULL, '103', 1, '2026-07-14 17:15:46', 1, '2026-07-14 17:15:46', NULL, NULL, 0); +INSERT INTO `chat_session` VALUES (2076964726549565441, 1, '测试工作流', NULL, '103', 1, '2026-07-14 17:39:14', 1, '2026-07-14 17:39:14', NULL, NULL, 0); +INSERT INTO `chat_session` VALUES (2076964778940616705, 1, '测试工作流', NULL, '103', 1, '2026-07-14 17:39:26', 1, '2026-07-14 17:39:26', NULL, NULL, 0); +INSERT INTO `chat_session` VALUES (2076971705284218881, 1, '你好2阿', '你好2阿', '103', 1, '2026-07-14 18:06:57', 1, '2026-07-14 18:06:57', '你好2阿', NULL, 0); -- ---------------------------- -- Table structure for flow_category @@ -1235,17 +1286,261 @@ CREATE TABLE `mcp_tool_info` ( INDEX `idx_type`(`type` ASC) USING BTREE, INDEX `idx_status`(`status` ASC) USING BTREE, INDEX `idx_tenant_id`(`tenant_id` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'MCP工具表' ROW_FORMAT = DYNAMIC; +) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'MCP工具表' ROW_FORMAT = DYNAMIC; -- ---------------------------- -- Records of mcp_tool_info -- ---------------------------- -INSERT INTO `mcp_tool_info` VALUES (1, 'edit_file', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-02-24 20:19:41', -1, '2026-03-10 21:21:09', '0'); -INSERT INTO `mcp_tool_info` VALUES (2, 'list_directory', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-02-24 20:19:41', -1, '2026-03-10 21:21:09', '0'); -INSERT INTO `mcp_tool_info` VALUES (3, 'read_file', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-02-24 20:19:41', -1, '2026-03-10 21:21:09', '0'); -INSERT INTO `mcp_tool_info` VALUES (4, 'query_all_tables', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-03-10 21:21:09', -1, '2026-03-10 21:21:09', '0'); -INSERT INTO `mcp_tool_info` VALUES (5, 'execute_sql_query', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-03-10 21:21:09', -1, '2026-03-10 21:21:09', '0'); -INSERT INTO `mcp_tool_info` VALUES (6, 'query_table_schema', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-03-10 21:21:09', -1, '2026-03-10 21:21:09', '0'); +INSERT INTO `mcp_tool_info` VALUES (1, 'edit_file', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-02-24 20:19:41', -1, '2026-06-22 16:34:29', '0'); +INSERT INTO `mcp_tool_info` VALUES (2, 'list_directory', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-02-24 20:19:41', -1, '2026-06-22 16:34:29', '0'); +INSERT INTO `mcp_tool_info` VALUES (3, 'read_file', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-02-24 20:19:41', -1, '2026-06-22 16:34:29', '0'); +INSERT INTO `mcp_tool_info` VALUES (4, 'query_all_tables', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-03-10 21:21:09', -1, '2026-06-22 16:34:29', '0'); +INSERT INTO `mcp_tool_info` VALUES (5, 'execute_sql_query', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-03-10 21:21:09', -1, '2026-06-22 16:34:29', '0'); +INSERT INTO `mcp_tool_info` VALUES (6, 'query_table_schema', '', 'BUILTIN', 'ENABLED', NULL, '000000', -1, -1, '2026-03-10 21:21:09', -1, '2026-06-22 16:34:29', '0'); +INSERT INTO `mcp_tool_info` VALUES (7, 'bing-cn-mcp-server', '必应中文联网查询工具,支持实时搜索和网页内容抓取。内置工具:bing_search、fetch_webpage。', 'LOCAL', 'ENABLED', '{\n \"command\": \"npx\",\n \"args\": [\n \"-y\",\n \"bing-cn-mcp\"\n ]\n}\n', '000000', -1, -1, '2026-06-22 16:33:22', -1, '2026-06-22 16:33:22', '0'); + +-- ---------------------------- +-- Table structure for short_drama_character +-- ---------------------------- +DROP TABLE IF EXISTS `short_drama_character`; +CREATE TABLE `short_drama_character` ( + `id` bigint NOT NULL COMMENT '主键', + `project_id` bigint NOT NULL COMMENT '项目ID', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名', + `aliases` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '别名/称呼(逗号分隔)', + `introduction` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '角色介绍(身份、关系、称呼映射)', + `role_level` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'B' COMMENT '角色层级:S/A/B/C/D', + `gender` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '性别', + `age_range` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '年龄段(如:约二十五岁)', + `personality_tags` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '性格标签(逗号分隔)', + `costume_tier` int NULL DEFAULT 2 COMMENT '服装华丽度1-5', + `visual_description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '视觉外貌描述(详细,用于图片生成)', + `reference_image_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '角色参考图URL', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` bigint NULL DEFAULT NULL COMMENT '更新者', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_project_id`(`project_id` ASC) USING BTREE, + INDEX `idx_tenant_id`(`tenant_id` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '短剧角色表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of short_drama_character +-- ---------------------------- +INSERT INTO `short_drama_character` VALUES (2075609273630703616, 2075608659739787264, '队医', '', '峨眉队跟队医师,负责队员伤病的现场处理与康复监督。在钰珑脚踝旧伤复发时为其做紧急处理。无特定人际称呼,队员通常只喊「医生」或「队医」。', 'C', '女', '约三十岁', '温柔,细心', 2, '鹅蛋脸线条柔和,双眼皮杏眼明亮有神,小巧挺拔的鼻梁,薄厚适中的嘴唇微抿,柳叶眉自然舒展。乌黑过肩直发扎成利落低马尾,额前碎发自然垂落。身形修长匀称,穿简洁白色短袖Polo衫,左胸有队徽刺绣,搭配卡其色工装长裤和白色运动鞋,腰间斜挎急救小包。', NULL, -1, -1, '2026-07-10 23:53:08', -1, '2026-07-10 23:53:08', 0); +INSERT INTO `short_drama_character` VALUES (2075609329431724032, 2075608659739787264, '徐风', '徐教练', '峨眉队前任功夫教练,决赛前转投对手东亚商联,成为贯穿剧情的主要对手。曾是双双与钰珑的授业者,后利用对她们的了解反制旧部。被队员普遍称呼为「徐教练」。', 'A', '男', '约三十五岁', '高冷,腹黑,专业,野心', 3, '脸型棱角分明,下颌线条锋利。细长眼型,双眼皮,高挺精致的鼻梁,薄唇平直,眉峰上扬的剑眉浓黑有型,右眼尾点缀一颗小痣。乌黑短发以利落背头后梳,露出饱满额头,发质柔顺服帖。身形修长,高挑匀称。身穿深灰色高支羊毛西装,剪裁修身,肩线平直,内搭黑色精纺高领毛衣,下身同色西裤线条流畅,脚著黑色牛津皮鞋。鼻梁上架一副极细金属框运动墨镜,左手腕佩戴银色纤薄腕表。', NULL, -1, -1, '2026-07-10 23:53:22', -1, '2026-07-10 23:53:22', 0); +INSERT INTO `short_drama_character` VALUES (2075609380031807488, 2075608659739787264, '钰珑', '', '峨眉女足主力前锋,曾深度依赖徐风传授的功夫射门技术,后在双双引导下融入团队体系。与双双为挚友,视徐风为前恩师后对手。被双双及其他队友直呼「钰珑」。', 'A', '女', '约二十四岁', '直爽,冲动,坚韧,热情', 2, '钰珑生着一张轮廓分明的瓜子脸,浓黑剑眉斜抬,衬出几分不驯。眼形偏窄的杏眼微微上挑,单眼皮收紧眼尾,转折处利落带锋。鼻梁小巧挺直,嘴唇薄厚适中,下颌角附近有一道浅淡旧伤痕。一头乌黑长发高高束成马尾,额前碎发桀骜翘起,发尾带着自然微卷。身形修长结实,肩背紧绷,常年奔跑的线条紧致流畅。她穿着白色速干运动背心、深灰色运动短裤,脚踩抓地强劲的黑色跑鞋。右脚踝始终缠着防护绷带,步态间略带拖沓,却像随时要蹬地冲出。手腕戴一只简约的黑色运动手表。', NULL, -1, -1, '2026-07-10 23:53:34', -1, '2026-07-10 23:53:34', 0); +INSERT INTO `short_drama_character` VALUES (2075609601688190976, 2075608659739787264, '双双', '', '故事女主角,峨眉女足队长,球队战术核心与精神领袖。前锋钰珑的队友兼好友,前教练徐风曾执教的队员。被队友称呼为「队长」,被钰珑直接叫名字「双双」。', 'S', '女', '约二十五岁', '冷静,果断,温柔,坚毅', 2, '瓜子脸,线条利落分明。一双眼裂纤长的丹凤眼,眼尾如燕尾般锐利上扬,内眼角尖细,双眼皮折痕深刻清晰,注视时自带压迫感。鼻梁挺直高耸,鼻翼收窄,鼻尖精致小巧。双唇薄而轮廓分明,上唇弓形,下唇微微内收。眉骨高隆,眉形为天然剑眉,眉头略粗,眉峰微挑,眉尾收得细长利落。鼻梁中段偏左有一颗极浅小痣。一头乌黑长发在脑后高高束成利落马尾,扎紧于顶骨后方,用黑色弹力绳固定,发丝柔顺垂坠,发尾齐整落至肩胛骨下缘,额前无一丝刘海,露出光洁额头。体型修长而匀称,身姿高挑挺拔。上身穿一件白色速干圆领运动短袖T恤,面料带有微透气网眼,外罩一件深蓝色立领轻型教练夹克,拉链半开,袖口设有魔术贴收束,左臂佩戴红色弹性针织队长袖标,表面印有白色「CAPTAIN」字样。下身为黑色弹力紧身运动长裤,高腰设计,膝盖处有拼接剪裁,脚踝处有短拉链。脚蹬一双白色轻量跑步鞋,鞋面为透气网布拼接合成革,鞋带系紧,鞋底防滑纹理清晰。左手腕佩戴一块简约黑色运动电子手表。', NULL, -1, -1, '2026-07-10 23:54:27', -1, '2026-07-10 23:54:27', 0); +INSERT INTO `short_drama_character` VALUES (2077008099927412736, 2077007721974484992, '百货经理', '经理', '百货公司经理,在工商检查时出现,对小明提出的信息平台方案表示认可并推动放行。角色身份为商业系统干部,被小明称为「经理」。', 'C', '男', '约四十岁', '务实,深谙利益,精明', 3, '方形脸,浓黑剑眉,双眼皮,眼神锐利,鼻梁高挺,嘴唇线条分明。乌黑三七分背头,整齐服帖。身形匀称,身高适中。身穿藏青色修身西装外套,内搭白色衬衫,深灰色西裤,黑色系带皮鞋,手腕佩戴简约钢带手表。', NULL, -1, -1, '2026-07-14 20:31:35', -1, '2026-07-14 20:31:35', 0); +INSERT INTO `short_drama_character` VALUES (2077008101332504576, 2077007721974484992, '老陈', '陈厂长', '工厂厂长,小明的关键支持者和合作伙伴。性格谨慎但敢于冒险,信任小明的能力。被小明称为「陈厂长」,被工人和下属称为「厂长」或「老陈」。', 'A', '男', '约五十岁', '谨慎,务实,有魄力,正直', 2, '长方脸型,轮廓分明,额间有浅横纹,颧骨平直。浓眉微斜,眉尾稍疏,单眼皮细长眼,眼窝微陷,目光专注。鼻梁高直,鼻翼内收,唇形薄而线条清晰,下唇中央有浅凹痕。下巴方圆,无明显痣疤。发色花白,短发偏分,发质硬而蓬松,略见鬓角银丝。身形匀称中等,肩背挺直。上身着灰色中山装,领口紧扣齐整,左下口袋别一支钢笔。下装深灰西裤,裤线笔直,脚穿黑色系带皮鞋,鞋面光亮。无多余配饰。', NULL, -1, -1, '2026-07-14 20:31:35', -1, '2026-07-14 20:31:35', 0); +INSERT INTO `short_drama_character` VALUES (2077008104104939520, 2077007721974484992, '小明', '无', '故事核心主角,从2025年穿越回1985年的重生者。原本是50岁的失意中年,穿越后成为年轻工人。老陈的合作伙伴,被团队成员称为「小明」或「同志」,被老陈直呼「小明」。', 'S', '男', '约二十五岁(实际心理年龄五十岁)', '远见,执着,果断,坚毅', 2, '轮廓分明的瓜子脸,下颌线条利落。深邃的双眼皮眼睛眼型偏长,目光沉静;鼻梁高挺笔直;嘴唇厚薄适中,唇峰清晰;浓密的剑眉斜飞入鬓。右侧眉尾处一颗小痣。发色乌黑,及耳的长度微卷自然蓬松,额前碎发刘海随意散落。身形修长挺拔,身高约一米七八。穿着简约白色棉质衬衫,袖口卷至小臂,搭配深灰色直筒长裤与黑色帆布鞋。', NULL, -1, -1, '2026-07-14 20:31:36', -1, '2026-07-14 20:31:36', 0); + +-- ---------------------------- +-- Table structure for short_drama_character_appearance +-- ---------------------------- +DROP TABLE IF EXISTS `short_drama_character_appearance`; +CREATE TABLE `short_drama_character_appearance` ( + `id` bigint NOT NULL COMMENT '主键', + `character_id` bigint NOT NULL COMMENT '角色ID', + `appearance_index` int NOT NULL DEFAULT 0 COMMENT '形象序号(0=主形象)', + `change_reason` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '初始形象' COMMENT '变化原因', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '形象视觉描述', + `reference_image_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '形象参考图URL', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` bigint NULL DEFAULT NULL COMMENT '更新者', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', + `image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '生成图片URL列表(JSON数组)', + `image_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '每张图片对应的提示词(JSON数组)', + `selected_image_index` int NULL DEFAULT 0 COMMENT '当前选中的图片索引', + `previous_image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮图片URL列表(撤销用,JSON数组)', + `previous_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_character_appearance`(`character_id` ASC, `appearance_index` ASC) USING BTREE, + INDEX `idx_character_id`(`character_id` ASC) USING BTREE, + INDEX `idx_tenant_id`(`tenant_id` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '短剧角色子形象表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of short_drama_character_appearance +-- ---------------------------- +INSERT INTO `short_drama_character_appearance` VALUES (2075609273836224512, 2075609273630703616, 0, '初始形象', '鹅蛋脸线条柔和,双眼皮杏眼明亮有神,小巧挺拔的鼻梁,薄厚适中的嘴唇微抿,柳叶眉自然舒展。乌黑过肩直发扎成利落低马尾,额前碎发自然垂落。身形修长匀称,穿简洁白色短袖Polo衫,左胸有队徽刺绣,搭配卡其色工装长裤和白色运动鞋,腰间斜挎急救小包。', NULL, -1, -1, '2026-07-10 23:53:08', -1, '2026-07-10 23:53:08', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` VALUES (2075609329570136064, 2075609329431724032, 0, '初始形象', '脸型棱角分明,下颌线条锋利。细长眼型,双眼皮,高挺精致的鼻梁,薄唇平直,眉峰上扬的剑眉浓黑有型,右眼尾点缀一颗小痣。乌黑短发以利落背头后梳,露出饱满额头,发质柔顺服帖。身形修长,高挑匀称。身穿深灰色高支羊毛西装,剪裁修身,肩线平直,内搭黑色精纺高领毛衣,下身同色西裤线条流畅,脚著黑色牛津皮鞋。鼻梁上架一副极细金属框运动墨镜,左手腕佩戴银色纤薄腕表。', NULL, -1, -1, '2026-07-10 23:53:22', -1, '2026-07-10 23:53:22', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` VALUES (2075609380207968256, 2075609380031807488, 0, '初始形象', '钰珑生着一张轮廓分明的瓜子脸,浓黑剑眉斜抬,衬出几分不驯。眼形偏窄的杏眼微微上挑,单眼皮收紧眼尾,转折处利落带锋。鼻梁小巧挺直,嘴唇薄厚适中,下颌角附近有一道浅淡旧伤痕。一头乌黑长发高高束成马尾,额前碎发桀骜翘起,发尾带着自然微卷。身形修长结实,肩背紧绷,常年奔跑的线条紧致流畅。她穿着白色速干运动背心、深灰色运动短裤,脚踩抓地强劲的黑色跑鞋。右脚踝始终缠着防护绷带,步态间略带拖沓,却像随时要蹬地冲出。手腕戴一只简约的黑色运动手表。', NULL, -1, -1, '2026-07-10 23:53:34', -1, '2026-07-10 23:53:34', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` VALUES (2075609601822408704, 2075609601688190976, 0, '初始形象', '瓜子脸,线条利落分明。一双眼裂纤长的丹凤眼,眼尾如燕尾般锐利上扬,内眼角尖细,双眼皮折痕深刻清晰,注视时自带压迫感。鼻梁挺直高耸,鼻翼收窄,鼻尖精致小巧。双唇薄而轮廓分明,上唇弓形,下唇微微内收。眉骨高隆,眉形为天然剑眉,眉头略粗,眉峰微挑,眉尾收得细长利落。鼻梁中段偏左有一颗极浅小痣。一头乌黑长发在脑后高高束成利落马尾,扎紧于顶骨后方,用黑色弹力绳固定,发丝柔顺垂坠,发尾齐整落至肩胛骨下缘,额前无一丝刘海,露出光洁额头。体型修长而匀称,身姿高挑挺拔。上身穿一件白色速干圆领运动短袖T恤,面料带有微透气网眼,外罩一件深蓝色立领轻型教练夹克,拉链半开,袖口设有魔术贴收束,左臂佩戴红色弹性针织队长袖标,表面印有白色「CAPTAIN」字样。下身为黑色弹力紧身运动长裤,高腰设计,膝盖处有拼接剪裁,脚踝处有短拉链。脚蹬一双白色轻量跑步鞋,鞋面为透气网布拼接合成革,鞋带系紧,鞋底防滑纹理清晰。左手腕佩戴一块简约黑色运动电子手表。', NULL, -1, -1, '2026-07-10 23:54:27', -1, '2026-07-10 23:54:27', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` VALUES (2077008100070019072, 2077008099927412736, 0, '初始形象', '方形脸,浓黑剑眉,双眼皮,眼神锐利,鼻梁高挺,嘴唇线条分明。乌黑三七分背头,整齐服帖。身形匀称,身高适中。身穿藏青色修身西装外套,内搭白色衬衫,深灰色西裤,黑色系带皮鞋,手腕佩戴简约钢带手表。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/dd7454cd25a6486aa5c7c3f8c7085041-3742113260bb4ee9.jpg', -1, -1, '2026-07-14 20:31:35', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/dd7454cd25a6486aa5c7c3f8c7085041-3742113260bb4ee9.jpg\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 方形脸,浓黑剑眉,双眼皮,眼神锐利,鼻梁高挺,嘴唇线条分明。乌黑三七分背头,整齐服帖。身形匀称,身高适中。身穿藏青色修身西装外套,内搭白色衬衫,深灰色西裤,黑色系带皮鞋,手腕佩戴简约钢带手表。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` VALUES (2077008101487693824, 2077008101332504576, 0, '初始形象', '长方脸型,轮廓分明,额间有浅横纹,颧骨平直。浓眉微斜,眉尾稍疏,单眼皮细长眼,眼窝微陷,目光专注。鼻梁高直,鼻翼内收,唇形薄而线条清晰,下唇中央有浅凹痕。下巴方圆,无明显痣疤。发色花白,短发偏分,发质硬而蓬松,略见鬓角银丝。身形匀称中等,肩背挺直。上身着灰色中山装,领口紧扣齐整,左下口袋别一支钢笔。下装深灰西裤,裤线笔直,脚穿黑色系带皮鞋,鞋面光亮。无多余配饰。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/4729daf662de42c0a3472bc05043529c-01a8aaf57a08ea21.jpg', -1, -1, '2026-07-14 20:31:35', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/4729daf662de42c0a3472bc05043529c-01a8aaf57a08ea21.jpg\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 长方脸型,轮廓分明,额间有浅横纹,颧骨平直。浓眉微斜,眉尾稍疏,单眼皮细长眼,眼窝微陷,目光专注。鼻梁高直,鼻翼内收,唇形薄而线条清晰,下唇中央有浅凹痕。下巴方圆,无明显痣疤。发色花白,短发偏分,发质硬而蓬松,略见鬓角银丝。身形匀称中等,肩背挺直。上身着灰色中山装,领口紧扣齐整,左下口袋别一支钢笔。下装深灰西裤,裤线笔直,脚穿黑色系带皮鞋,鞋面光亮。无多余配饰。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` VALUES (2077008104272711680, 2077008104104939520, 0, '初始形象', '轮廓分明的瓜子脸,下颌线条利落。深邃的双眼皮眼睛眼型偏长,目光沉静;鼻梁高挺笔直;嘴唇厚薄适中,唇峰清晰;浓密的剑眉斜飞入鬓。右侧眉尾处一颗小痣。发色乌黑,及耳的长度微卷自然蓬松,额前碎发刘海随意散落。身形修长挺拔,身高约一米七八。穿着简约白色棉质衬衫,袖口卷至小臂,搭配深灰色直筒长裤与黑色帆布鞋。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/aab75b6b-865d-4825-8eee-9e3a45ccec62.png', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/aab75b6b-865d-4825-8eee-9e3a45ccec62.png\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 轮廓分明的瓜子脸,下颌线条利落。深邃的双眼皮眼睛眼型偏长,目光沉静;鼻梁高挺笔直;嘴唇厚薄适中,唇峰清晰;浓密的剑眉斜飞入鬓。右侧眉尾处一颗小痣。发色乌黑,及耳的长度微卷自然蓬松,额前碎发刘海随意散落。身形修长挺拔,身高约一米七八。穿着简约白色棉质衬衫,袖口卷至小臂,搭配深灰色直筒长裤与黑色帆布鞋。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); + +-- ---------------------------- +-- Table structure for short_drama_location +-- ---------------------------- +DROP TABLE IF EXISTS `short_drama_location`; +CREATE TABLE `short_drama_location` ( + `id` bigint NOT NULL COMMENT '主键', + `project_id` bigint NOT NULL COMMENT '项目ID', + `name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '场景名(如:客厅_白天)', + `summary` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '场景简要说明', + `has_crowd` tinyint(1) NULL DEFAULT 0 COMMENT '是否有背景人群', + `crowd_description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '背景人群描述', + `available_slots` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '可站位置列表(JSON数组)', + `descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '场景描述列表(JSON数组,3条差异化描述)', + `reference_image_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '场景全景参考图URL', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` bigint NULL DEFAULT NULL COMMENT '更新者', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', + `image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '生成图片URL列表(JSON数组)', + `image_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '每张图片对应的提示词(JSON数组)', + `selected_image_index` int NULL DEFAULT 0 COMMENT '当前选中的图片索引', + `previous_image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮图片URL列表(撤销用,JSON数组)', + `previous_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_project_id`(`project_id` ASC) USING BTREE, + INDEX `idx_tenant_id`(`tenant_id` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '短剧场景表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of short_drama_location +-- ---------------------------- +INSERT INTO `short_drama_location` VALUES (2075609601935654912, 2075608659739787264, '峨眉队训练基地_傍晚', '金色夕阳下的足球训练场,质朴的草地与球门构成核心训练区', 1, '远处有模糊的队员正在进行抢圈练习,人影被拉长', '[\"土场中央中圈开球点的位置\",\"远端球门右侧角旗区附近的位置\",\"场边简易长条凳与战术板之间的空地\"]', '[\"「峨眉队训练基地傍晚」傍晚的余晖将整片土场染成浓郁的金色,宽广的球场向远处延伸,边界由低矮的铁丝围栏框定。左侧设置一座标准尺寸的球门,白色球网微微颤动,右侧对等位置的球门轮廓在逆光中略显模糊。四周几盏简易训练灯尚未点亮,整个空间被暖调的自然光笼罩,光从西侧斜射过来,将球门阴影长长地拖在东侧草坪上。中圈弧线清晰可见,两侧边线外留有宽敞的跑动通道,场边零散放着几个训练标志盘和水瓶。\",\"「峨眉队训练基地傍晚」土质训练场开阔而硬朗,天空半边橙红半边浅紫。镜头从底线角球区拉开,可以看到两座球门、两侧边线以及远处低矮平房的轮廓。西边的斜阳穿过球门网,在泥土地上投出网格状的影子。场地中央被踩实的黄土露出些许草根,四周则还留着稀疏的绿草。场边一侧摆着一条木质长凳,旁边立着一块可移动的战术白板,为这片朴素的空间增添了几分专业气息。空旷的球场除了风声,只剩远处模糊的呼喝与足球撞击的闷响。\",\"「峨眉队训练基地傍晚」广角俯瞰下,整个训练基地被夕阳包裹,像一座独立于时间之外的金色擂台。两座标准足球门遥相对望,球门后的拦网高而完整。中圈向外辐射,中轴线上的草稀而坚实。光线从画面右侧灌入,将左侧的球门区域照得最亮,右侧则稍稍藏在阴影里。场边零散堆放着几件训练背心和几只足球,没有围观的观众,只有空间本身在等待下一场奔跑。远处的训练队员身影模糊成小小的剪影,在远处半场进行着无声的抢圈游戏。\"]', NULL, -1, -1, '2026-07-10 23:54:27', -1, '2026-07-10 23:54:27', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2075609601960820736, 2075608659739787264, '球队更衣室_夜', '赛前赛后使用的长方形更衣室,木凳、储物柜和散落的球衣构成封闭空间', 0, '', '[\"中央长条木凳前方的过道空间\",\"靠近门口储物柜与墙壁之间的角落\",\"另一侧更衣柜前空出的站立区域\"]', '[\"「球队更衣室夜」封闭的长方形房间,天花板上的日光灯管发出冷白色的光,照亮了整间屋子。三面墙壁装着浅绿色金属储物柜,柜门大多虚掩或敞开,里面挂着几件换下的球衣,左侧墙壁上挂着一块白板,上面还留着隐约的战术标记。房间中央横置一条厚重的长条木凳,凳面磨损光滑,周围散落着几双球鞋和护腿板。光从顶部均匀洒下,地面是深灰色的防滑地胶,靠门的角落扔着几件绿色训练背心。门口一方空间相对空阔,连着一条通往淋浴区的走廊。\",\"「球队更衣室夜」灯光通明,更衣室被冷色调充满。正对面的储物柜墙几乎占据了整个视野,柜子编号清晰,其中几个柜门开着,球衣搭在柜沿外边。中央的木凳长度几乎横贯房间,凳下塞着几个运动包。左侧墙角立着一面战术白板,笔迹被擦去大半,残留的弧线隐约可见。右侧是另一排储物柜,柜门反光出冷白的灯管倒影。整个空间静静封闭,只有头顶排风扇发出轻微的嗡鸣,地面清洁但带有些许草屑,门口的通道空出明显的站立空间。\",\"「球队更衣室夜」紧凑但有序的球队更衣室,三面排列着标准体育储物柜,中间一条厚木长凳将空间划为两半。日光灯从天花中央铺开,柜门的金属边框反射出明亮光线。左侧柜组上方的墙壁挂着一面简朴的电子钟,时间显示在晚间。长凳上扔着一双刚脱下的球袜,地板在灯下呈现出深灰色橡胶纹理。靠门口的区域相对整洁,空出的地面足够两人并肩站立,那一侧的门把手还带些微使用过的痕迹。整个房间弥漫着一种刚结束训练的沉默,等待被下一句话打破。\"]', NULL, -1, -1, '2026-07-10 23:54:27', -1, '2026-07-10 23:54:27', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2075609602027929600, 2075608659739787264, '训练场_日', '白天的足球训练场,阳光充足,设有球门、绳梯和战术布置区域,空旷而专注', 0, '', '[\"禁区弧顶正对球门的罚球点附近\",\"中圈开球点周围的开阔区域\",\"场边摆放战术板与训练器材的教练区域\"]', '[\"「训练场日」白日下的标准足球场,草坪修剪整齐,浓绿色向远处铺展。遥相对望的两座白色球门在午后阳光下十分醒目,近端球门的网子绑得紧实。场地中央的中圈白线清晰,禁区线、边线等标记一丝不苟。右侧边线外摆着一架绳梯和几个锥形标志桶,还有一块战术白板支在折叠支架上。阳光从偏南方向照射,将球门立柱的影子投在草皮上。整个训练场除了远处围栏外偶尔经过的人影,场内一片空旷,只剩下训练器材静待使用。\",\"「训练场日」晴朗天气下,训练场被明亮的光线填满,没有观众,只有训练的味道。从底线角度望过去,纵深极强:近处角球弧的弧线、中圈、远方另一侧球门的全景尽收眼底。场地偏左的位置安放着一组红白相间的训练桩,右侧边线附近零星散落着足球。光线柔和地从画面左上方射入,草尖泛出微光。战术白板立在阴凉一侧,上面画着交叉跑位的弧线和点位标记,旁边放着一只医疗箱和几瓶喷雾,这方空间留有足够的站立和讲解余地。\",\"「训练场日」俯瞰的训练场呈现出清晰的几何线条,白色的场地标记在阳光下异常鲜明。两座球门对称分布于两端,禁区的矩形线条和中圈圆环完整。靠近远端球门的一侧,绳梯铺在阴影与阳光的交界处,橙色的标志碟在绿色背景上跳跃。画面中没有风吹动的痕迹,只有光从南偏西的方向大面积洒下,使得左侧球门区域更亮,右侧微暗。边线外的教练区空着,战术板上的弧线图被画得用力,旁边几件荧光训练背心堆在地上,等着一场无声的预演。\"]', NULL, -1, -1, '2026-07-10 23:54:27', -1, '2026-07-10 23:54:27', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2075609602099232768, 2075608659739787264, '亚冠决赛球场_夜', '盛大的亚冠决赛夜球场,灯光璀璨,看台满座,记分牌显示着紧张的下半场比分', 1, '看台上密密麻麻的观众,挥舞着旗帜和围巾,形成人浪与声浪', '[\"中圈弧附近的开阔地带\",\"禁区外正对球门的任意球主罚点\",\"靠近客队教练区一侧的边线区域\"]', '[\"「亚冠决赛球场夜」灯火如昼的巨型体育场,草皮在夜间照明下泛出整齐的墨绿。四面看台坐满了观众,数万人的衣色和旗帜连成涌动的人海,模糊的面孔只看得见张开的嘴巴和举起的围巾。巨大的电子记分牌高悬于一侧看台上方,红色的数字显示下半场72分钟,比分暂为悬殊的落后。两座球门背后,摄影记者长枪短炮已对准禁区。光线来自顶棚密集的灯阵,无死角照亮整个场地,使场边的教练区、替补席和第四官员席都清晰可见。草地中圈弧线被踩得微脏,比赛正进入最窒息的时间。\",\"「亚冠决赛球场夜」从高空俯瞰,这座宏大的足球圣殿被灯光分割成无数块面。绿茵场居于正中,被一圈暗红色的跑道环绕,再往外是层层叠叠的看台,人群如密织的织物铺满每一块区域。左侧球门后方的看台爆发出一片摇动的旗海,右侧观众席则因紧张而微微前倾。记分牌上,时间与比分在夜色中极其刺目。教练区空空地等着下一个指令,边线旁的摄影机摇臂缓缓移动。光从顶棚巨大的灯架倾泻而下,在草皮上几乎没有影子,整个赛场明如白昼,气氛却重如铅水。\",\"「亚冠决赛球场夜」镜头从角球区低角度向上拉升,首先看到近处被蹭掉几条草痕的角旗杆,然后中景闯入密实的禁区线,接着整座球场的壮阔扑面而来。看台组成一道陡峭的人墙,无数手臂在挥舞,助威声浪似有形物质充满空气。正对面远端球门上方,记分牌的数字亮得发烫,时间在流逝,比分残酷。教练区域设置在中线旁,简易的白色画线框内放着水壶和战术纸条。球场灯光从四面八方浇筑,使得场上即将发生的一切都无从遁形,只留下宽广的草皮等待下一脚触球。\"]', NULL, -1, -1, '2026-07-10 23:54:27', -1, '2026-07-10 23:54:27', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2075609602162147328, 2075608659739787264, '球场_伤停补时阶段', '同片决赛球场,但已进入伤停补时,气氛沸腾至顶点,人墙与禁区形成焦点', 1, '全场观众几乎全部站立,紧张地注视着场内,呐喊与屏息交替', '[\"禁区弧前方任意球人墙后的区域\",\"中圈附近等待反击的空旷地带\",\"对方球门远角一侧的包抄路线区域\"]', '[\"「球场伤停补时阶段」伤停补时的灯光仿佛更加灼热,整个体育场被推至沸点。草坪上,禁区前沿排着紧密的人墙,球员们肩并着肩,手臂护住胸前,形成一道移动的屏障。远处的球门在这一刻显得格外宽阔,门柱的影子硬朗地打在草皮上。看台上所有观众都站了起来,无数的身影形成一圈颤抖的轮廓,模糊的旗帜在夜风中猎猎作响。记分牌上的数字显示着3:3的平局,时间刻度停在伤停补时。光从顶棚强力射出,将禁区弧顶的罚球点照得如同舞台中央,四周的空气几乎凝固。\",\"「球场伤停补时阶段」广角定格在球门与禁区之间,人墙严阵以待,守门员在门线前压低重心,整座体育场的视线都聚焦于罚球点。左侧看台无声地亮起一片手机灯光,右侧球迷则咬住围巾。草地被踩踏出无数重叠的足迹,尤其是禁区线附近草皮翻起几道浅痕。球门右上角网窝静止,等待撞击。教练区空无一人,但第四官员举起的电子牌清晰可辨。光从正上方与左右顶棚交织投下,把每一个远近空间填满,唯有远端的底线角旗处在微微的阴影中,为即将可能发生的反抢留出视觉余地。\",\"「球场伤停补时阶段」补时中的决赛草场弥漫着接近终局的肃杀感。中圈到禁区弧这一段区域被无形的手攥紧,人墙背后,球门远角的空间仿佛在主动收缩。看台化为巨大的黑红色背景,点缀着无数晃动的小点。球场中央地带相对空虚,只有几名中场球员的站位拉开宽度,形成随时可以冲刺的空档。远处的角旗区和边线在强烈灯光下泛白,教练区的白线边界依旧清晰,战术纸条散落在地。这个广阔的空间里,下一个呼吸就可能决定所有。顶光无遮无拦,把即将落下的射门路线照得坦坦荡荡。\"]', NULL, -1, -1, '2026-07-10 23:54:27', -1, '2026-07-10 23:54:27', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2077008104339820544, 2077007721974484992, '现代办公室_夜', '深夜的现代办公室,电脑屏幕亮光,桌面杂乱,电水壶和插座位于角落。', 0, '', '[\"办公桌前椅子的正前方位置\",\"门口内侧靠墙的空地\",\"电水壶倾倒处的桌子左侧空地\"]', '[\"「现代办公室_夜」深夜的办公室,空间约二十平米,深灰色地毯覆盖地面,左侧是两扇玻璃窗,窗外城市霓虹灯隐约。右侧墙壁悬挂企业标语,下方是文件柜。中央一张深色办公桌,电脑屏幕为唯一光源,桌面散落纸张和水杯。天花板荧光灯未开,光线从电脑屏幕向右前方散射。前景桌子右侧有空地,左侧靠窗也有站立空间。\",\"「现代办公室_夜」一个中等规模的现代办公室,后墙整面白色,中央挂钟指针指向11点。左侧墙壁有白色百叶窗,光线从窗外微光透入与电脑屏幕蓝光混合。右侧墙壁是嵌入式书架,书本整齐排列。办公桌位于房间中央偏后,桌上一台显示器、电水壶、凌乱文件。天花板吊灯未亮,整体光线昏暗。门口处有一块可站人区域,桌子左侧也有空位。\",\"「现代办公室_夜」约二十平米的封闭办公室,三面墙壁可见:正面墙有白色防火板,左侧墙为玻璃隔断(窗外黑色夜空),右侧墙为木质护墙板。地面铺深色橡胶地板。办公桌位于房间中央稍靠右,桌上电脑亮着,电水壶倒在桌边,插座位在桌下墙角。天花板中央有方形灯槽但未开灯。门口与桌子之间有两米见方的空白区域,可以站立一人;窗户左侧也有约一米宽的通道。\"]', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/e927b16c08d64b72a0039fcc6232c626-d9b1460b47849a88.jpg', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/e927b16c08d64b72a0039fcc6232c626-d9b1460b47849a88.jpg\"]', '[\"宽广空间全景,[\\\"「现代办公室_夜」深夜的办公室,空间约二十平米,深灰色地毯覆盖地面,左侧是两扇玻璃窗,窗外城市霓虹灯隐约。右侧墙壁悬挂企业标语,下方是文件柜。中央一张深色办公桌,电脑屏幕为唯一光源,桌面散落纸张和水杯。天花板荧光灯未开,光线从电脑屏幕向右前方散射。前景桌子右侧有空地,左侧靠窗也有站立空间。\\\",\\\"「现代办公室_夜」一个中等规模的现代办公室,后墙整面白色,中央挂钟指针指向11点。左侧墙壁有白色百叶窗,光线从窗外微光透入与电脑屏幕蓝光混合。右侧墙壁是嵌入式书架,书本整齐排列。办公桌位于房间中央偏后,桌上一台显示器、电水壶、凌乱文件。天花板吊灯未亮,整体光线昏暗。门口处有一块可站人区域,桌子左侧也有空位。\\\",\\\"「现代办公室_夜」约二十平米的封闭办公室,三面墙壁可见:正面墙有白色防火板,左侧墙为玻璃隔断(窗外黑色夜空),右侧墙为木质护墙板。地面铺深色橡胶地板。办公桌位于房间中央稍靠右,桌上电脑亮着,电水壶倒在桌边,插座位在桌下墙角。天花板中央有方形灯槽但未开灯。门口与桌子之间有两米见方的空白区域,可以站立一人;窗户左侧也有约一米宽的通道。\\\"],禁止出现任何角色,纯背景板真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感\"]', 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2077008104394346496, 2077007721974484992, '小巷_日_1985', '八十年代老式小巷,石板路面,墙上有红色标语,二八自行车靠在墙边。', 0, '', '[\"巷口地面中央位置\",\"右侧墙边二八自行车旁的空地\",\"左侧墙标语下方的台阶处\"]', '[\"「小巷_日_1985」一条狭窄的老式小巷,宽约三米,两侧是青砖老墙,墙高约四米,墙面斑驳。右侧墙上用红漆写着「时间就是金钱」的标语,字迹清晰。地面是水泥路面,有些裂缝。巷口方向有一辆黑色的二八自行车斜靠在墙边。光线从巷口一端射入,形成明暗对比。巷子中央地面可以站立,左侧墙根下也有约一米宽的通道。\",\"「小巷_日_1985」巷子长约五十米,两端可见开阔。左侧墙壁爬满半枯的爬山虎,右墙每隔几米有木质门窗(紧闭)。地面是青石板铺成,略有水渍。巷子尽头有一棵老槐树伸出墙头。阳光从头顶偏左照下,在地面投下窗格阴影。巷口方向有一辆老式二八自行车,车把挂着布包。巷子中央空旷处可站人,右侧自行车旁也有空间。\",\"「小巷_日_1985」宽度不足三米的小巷,两侧墙壁均用灰砖砌成,左墙上有模糊的「工业学大庆」标语残迹,右墙新刷了白色石灰。地面为水泥抹平,中央微微隆起。巷口处堆着几个空木箱。远处可见巷子拐角,墙上挂着一个老式电表箱。光线从正上方偏东方向照入,地面光影斑驳。巷口木箱旁、巷子中央靠近右墙位置均有可供落脚的平坦地面。\"]', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/c1ef1eeb2a64424798068602a181c0cc-e1074ab882e7517b.jpg', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/c1ef1eeb2a64424798068602a181c0cc-e1074ab882e7517b.jpg\"]', '[\"宽广空间全景,[\\\"「小巷_日_1985」一条狭窄的老式小巷,宽约三米,两侧是青砖老墙,墙高约四米,墙面斑驳。右侧墙上用红漆写着「时间就是金钱」的标语,字迹清晰。地面是水泥路面,有些裂缝。巷口方向有一辆黑色的二八自行车斜靠在墙边。光线从巷口一端射入,形成明暗对比。巷子中央地面可以站立,左侧墙根下也有约一米宽的通道。\\\",\\\"「小巷_日_1985」巷子长约五十米,两端可见开阔。左侧墙壁爬满半枯的爬山虎,右墙每隔几米有木质门窗(紧闭)。地面是青石板铺成,略有水渍。巷子尽头有一棵老槐树伸出墙头。阳光从头顶偏左照下,在地面投下窗格阴影。巷口方向有一辆老式二八自行车,车把挂着布包。巷子中央空旷处可站人,右侧自行车旁也有空间。\\\",\\\"「小巷_日_1985」宽度不足三米的小巷,两侧墙壁均用灰砖砌成,左墙上有模糊的「工业学大庆」标语残迹,右墙新刷了白色石灰。地面为水泥抹平,中央微微隆起。巷口处堆着几个空木箱。远处可见巷子拐角,墙上挂着一个老式电表箱。光线从正上方偏东方向照入,地面光影斑驳。巷口木箱旁、巷子中央靠近右墙位置均有可供落脚的平坦地面。\\\"],禁止出现任何角色,纯背景板真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感\"]', 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2077008104432095232, 2077007721974484992, '街道办门口_日', '街道办事处门口,有传达室和铁门,门口台阶下是水泥路,对面有行道树。', 0, '', '[\"街道办门口台阶正下方的位置\",\"传达室窗户左侧墙边\",\"路对面人行道靠近电线杆处\"]', '[\"「街道办门口_日」一栋灰砖二层小楼,门口上方挂着「XX街道办事处」木牌。楼前有三级水泥台阶,台阶两侧铁质扶手。左侧是传达室,小窗户上方有遮阳棚。门前空地为水泥路面,约十平方米,对面是普通的居民楼围墙,墙脚种着冬青。光线从右上方斜照,在台阶上形成阴影。台阶正下方中央、传达室窗边、路对面冬青旁都有平坦可站区域。\",\"「街道办门口_日」政府机关式的老旧建筑,门口为两扇对开铁栅栏门,门内是院落。门外台阶较宽,约五级,两侧有花盆。右侧墙面上贴着红色通知栏。地面为水磨石,有些破损。远处街道上有电线杆和停着的二八大杠自行车。阳光从左侧高层建筑缝隙中射下,照亮铁门局部。台阶中段、传达室门口左侧、路对面树下空地均可站立。\",\"「街道办门口_日」典型八十年代街道办公点,单层门厅带歇山顶。门厅前有宽大平台,平台边缘有低矮水泥护栏。传达室位于左侧,窗口很小。平台下是水泥马路,马路对面是砖砌围墙,墙上有「为人民服务」标语。光线从东南方照射,平台右侧有树影。平台上中央区域、传达室窗口下方、马路对面围墙根处均可落位。\"]', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/ee4c0cd60f6b43f3a459937e0272cc88-1f5cd218d1905a4d.jpg', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/ee4c0cd60f6b43f3a459937e0272cc88-1f5cd218d1905a4d.jpg\"]', '[\"宽广空间全景,[\\\"「街道办门口_日」一栋灰砖二层小楼,门口上方挂着「XX街道办事处」木牌。楼前有三级水泥台阶,台阶两侧铁质扶手。左侧是传达室,小窗户上方有遮阳棚。门前空地为水泥路面,约十平方米,对面是普通的居民楼围墙,墙脚种着冬青。光线从右上方斜照,在台阶上形成阴影。台阶正下方中央、传达室窗边、路对面冬青旁都有平坦可站区域。\\\",\\\"「街道办门口_日」政府机关式的老旧建筑,门口为两扇对开铁栅栏门,门内是院落。门外台阶较宽,约五级,两侧有花盆。右侧墙面上贴着红色通知栏。地面为水磨石,有些破损。远处街道上有电线杆和停着的二八大杠自行车。阳光从左侧高层建筑缝隙中射下,照亮铁门局部。台阶中段、传达室门口左侧、路对面树下空地均可站立。\\\",\\\"「街道办门口_日」典型八十年代街道办公点,单层门厅带歇山顶。门厅前有宽大平台,平台边缘有低矮水泥护栏。传达室位于左侧,窗口很小。平台下是水泥马路,马路对面是砖砌围墙,墙上有「为人民服务」标语。光线从东南方照射,平台右侧有树影。平台上中央区域、传达室窗口下方、马路对面围墙根处均可落位。\\\"],禁止出现任何角色,纯背景板真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感\"]', 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2077008104465649664, 2077007721974484992, '废弃仓库_日', '空旷的旧仓库内部,水泥地面,墙面有老标语,中央放置一张木桌和两把椅子。', 0, '', '[\"木桌左侧椅子前方的位置\",\"仓库大门内侧正对门的位置\",\"右侧墙「工业学大庆」标语下方\"]', '[\"「废弃仓库_日」高大空旷的老仓库,内部面积约两百平米,顶棚为弧形钢架,高约八米。地面是水泥抹平,有多处裂缝和油渍。前墙(南墙)有一排高窗,玻璃蒙尘,光线从窗中斜射入形成光柱。后墙正中写有红色大字「工业学大庆」,字迹斑驳。仓库中央放着一张老式木桌,两侧各一把木椅。左侧墙边堆着几个空铁桶。右墙下靠近标语处有一块干净地面,木桌前后也有空间。\",\"「废弃仓库_日」长方形仓库,东西走向,北侧墙面有一扇双开铁皮大门,半开着。室内光线从大门和南墙高窗进入,混合照亮。地面为灰色水泥,有推车压痕。仓库左侧靠墙有木架,架上零散工具。右侧墙有白色涂料脱落,露出红砖。中央木桌桌面放着两盒烟和几张纸。桌椅周围有约两米见方空地。大门内侧、桌子前方、右侧墙根可站人。\",\"「废弃仓库_日」仓库内部宽畅,纵深约四十米,天花板可见裸露木桁架和瓦片。墙面为抹灰白墙,部分脱落,露出砖块。南侧高窗透入冷色天光,地面有光斑。北侧大门关闭,有门缝透光。仓库中央偏左摆放一张旧木桌,桌面摆放烟盒和铅笔。左侧墙边有生锈铁架。右侧墙「工业学大庆」标语下方地面干净。桌子前侧、左侧铁架旁、右侧标语下均有可供人物站立的平整区域。\"]', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/22364ee6ea8649788330827d67015aaf-390fb3c2f4c24ee7.jpg', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/22364ee6ea8649788330827d67015aaf-390fb3c2f4c24ee7.jpg\"]', '[\"宽广空间全景,[\\\"「废弃仓库_日」高大空旷的老仓库,内部面积约两百平米,顶棚为弧形钢架,高约八米。地面是水泥抹平,有多处裂缝和油渍。前墙(南墙)有一排高窗,玻璃蒙尘,光线从窗中斜射入形成光柱。后墙正中写有红色大字「工业学大庆」,字迹斑驳。仓库中央放着一张老式木桌,两侧各一把木椅。左侧墙边堆着几个空铁桶。右墙下靠近标语处有一块干净地面,木桌前后也有空间。\\\",\\\"「废弃仓库_日」长方形仓库,东西走向,北侧墙面有一扇双开铁皮大门,半开着。室内光线从大门和南墙高窗进入,混合照亮。地面为灰色水泥,有推车压痕。仓库左侧靠墙有木架,架上零散工具。右侧墙有白色涂料脱落,露出红砖。中央木桌桌面放着两盒烟和几张纸。桌椅周围有约两米见方空地。大门内侧、桌子前方、右侧墙根可站人。\\\",\\\"「废弃仓库_日」仓库内部宽畅,纵深约四十米,天花板可见裸露木桁架和瓦片。墙面为抹灰白墙,部分脱落,露出砖块。南侧高窗透入冷色天光,地面有光斑。北侧大门关闭,有门缝透光。仓库中央偏左摆放一张旧木桌,桌面摆放烟盒和铅笔。左侧墙边有生锈铁架。右侧墙「工业学大庆」标语下方地面干净。桌子前侧、左侧铁架旁、右侧标语下均有可供人物站立的平整区域。\\\"],禁止出现任何角色,纯背景板真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感\"]', 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2077008104541147136, 2077007721974484992, '工厂办公室_日', '八十年代工厂办公室,木制办公桌、转盘电话、文件柜,窗外可见厂区。', 0, '', '[\"办公桌前椅子正前方的位置\",\"窗台下方靠墙的空地\",\"门口内侧文件柜旁\"]', '[\"「工厂办公室_日」一间十五平米的老式办公室,水泥地面刷了绿色油漆,略有磨损。正面墙有木框玻璃窗,窗外是工厂院落,可见烟囱和车间屋顶。窗下放着一张深褐色办公桌,桌面有玻璃板压着照片,一部黑色转盘电话放在右侧。左侧墙立着铁皮文件柜,绿色漆。右墙贴着生产进度表。阳光从窗外照射进来,在桌面形成光斑。桌前空地约两平米,窗台下、门口文件柜旁也有空间。\",\"「工厂办公室_日」房间方正,南墙整面为窗户,光线充足。窗户为老式钢窗,窗外有梧桐树枝叶。屋顶为白色涂料,日光灯管一支亮着。地面铺设浅色地板革,有磨损。办公桌靠东墙放置,桌上有笔筒、茶杯和电话。西墙有木制衣架和镜子。北墙门边有铁皮柜。桌前方地板、西墙衣架旁、北墙柜前均可落位。\",\"「工厂办公室_日」约十二平米的办公空间,乳胶漆墙面局部发黄。南侧窗户透入明亮日光,照射在东侧白色墙壁上。办公桌为深棕色三抽屉木桌,居中放置,桌上电话、台历、一部老式计算机。右侧墙角有饮水机和水桶。左侧墙角放置扫帚和簸箕。门在西墙。地板为红砖铺成,铺垫草席。桌前方、门口左侧墙角、右墙饮水机旁都有站立空间。\"]', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/86f032880f2e43409a9df446490e7032-6d12c0e8763f4c3e.jpg', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:51', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/86f032880f2e43409a9df446490e7032-6d12c0e8763f4c3e.jpg\"]', '[\"宽广空间全景,[\\\"「工厂办公室_日」一间十五平米的老式办公室,水泥地面刷了绿色油漆,略有磨损。正面墙有木框玻璃窗,窗外是工厂院落,可见烟囱和车间屋顶。窗下放着一张深褐色办公桌,桌面有玻璃板压着照片,一部黑色转盘电话放在右侧。左侧墙立着铁皮文件柜,绿色漆。右墙贴着生产进度表。阳光从窗外照射进来,在桌面形成光斑。桌前空地约两平米,窗台下、门口文件柜旁也有空间。\\\",\\\"「工厂办公室_日」房间方正,南墙整面为窗户,光线充足。窗户为老式钢窗,窗外有梧桐树枝叶。屋顶为白色涂料,日光灯管一支亮着。地面铺设浅色地板革,有磨损。办公桌靠东墙放置,桌上有笔筒、茶杯和电话。西墙有木制衣架和镜子。北墙门边有铁皮柜。桌前方地板、西墙衣架旁、北墙柜前均可落位。\\\",\\\"「工厂办公室_日」约十二平米的办公空间,乳胶漆墙面局部发黄。南侧窗户透入明亮日光,照射在东侧白色墙壁上。办公桌为深棕色三抽屉木桌,居中放置,桌上电话、台历、一部老式计算机。右侧墙角有饮水机和水桶。左侧墙角放置扫帚和簸箕。门在西墙。地板为红砖铺成,铺垫草席。桌前方、门口左侧墙角、右墙饮水机旁都有站立空间。\\\"],禁止出现任何角色,纯背景板真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感\"]', 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2077008104583090176, 2077007721974484992, '出租屋_夜', '狭小的出租屋,室内有床、桌子和黑板,黑板画着时间轴,灯光昏暗。', 0, '', '[\"黑板前方半米处地面\",\"桌子右侧靠墙位置\",\"门口内侧的空地\"]', '[\"「出租屋_夜」一间约十平米的单间,水泥地面,白灰墙面陈旧泛黄。北墙上有小窗户,挂着花布窗帘。东墙摆放一张单人木床,铺着格子床单。西墙靠着一张方桌,桌面散落纸笔和茶杯。南墙立着一块小黑板,黑板用粉笔画着时间轴和文字。屋顶悬着一盏白炽灯泡,发出昏黄光线,向下照射。床前、黑板前、桌子右侧均有可站立空地。\",\"「出租屋_夜」逼仄的出租屋,空间狭小,内有双层铁床靠左墙,下铺放行李。房间中央是一张折叠桌,桌上有电炉和锅。右墙钉着一块黑板,黑板边角有残缺,上面用粉笔写着「1985-2000」等字样。地面为砖地,不平整。灯光从屋顶一盏裸露灯泡发出,光线偏黄,在墙角形成阴影。桌子前方空地、双层床铺下方前端、黑板的左侧均可落位。\",\"「出租屋_夜」低矮的出租屋,层高约两米五,屋顶有木梁。西墙有一扇木门,门边有挂锁。南墙是大面积窗户,但被报纸糊住。北墙放置一个老旧衣柜,柜门敞开。中央一张矮方桌,桌上有手绘图纸和圆珠笔。地面铺着塑料地板革,已有破损。灯光从屋顶日光灯管发出,白色冷光,照亮全屋。黑板挂于东墙,上面时间轴清晰。桌子周围、衣柜前、门口都有站人空间。\"]', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/1c3fe372d8c34a28ad4d157e973a8342-acdc977ee2a76eab.jpg', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:51', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/1c3fe372d8c34a28ad4d157e973a8342-acdc977ee2a76eab.jpg\"]', '[\"宽广空间全景,[\\\"「出租屋_夜」一间约十平米的单间,水泥地面,白灰墙面陈旧泛黄。北墙上有小窗户,挂着花布窗帘。东墙摆放一张单人木床,铺着格子床单。西墙靠着一张方桌,桌面散落纸笔和茶杯。南墙立着一块小黑板,黑板用粉笔画着时间轴和文字。屋顶悬着一盏白炽灯泡,发出昏黄光线,向下照射。床前、黑板前、桌子右侧均有可站立空地。\\\",\\\"「出租屋_夜」逼仄的出租屋,空间狭小,内有双层铁床靠左墙,下铺放行李。房间中央是一张折叠桌,桌上有电炉和锅。右墙钉着一块黑板,黑板边角有残缺,上面用粉笔写着「1985-2000」等字样。地面为砖地,不平整。灯光从屋顶一盏裸露灯泡发出,光线偏黄,在墙角形成阴影。桌子前方空地、双层床铺下方前端、黑板的左侧均可落位。\\\",\\\"「出租屋_夜」低矮的出租屋,层高约两米五,屋顶有木梁。西墙有一扇木门,门边有挂锁。南墙是大面积窗户,但被报纸糊住。北墙放置一个老旧衣柜,柜门敞开。中央一张矮方桌,桌上有手绘图纸和圆珠笔。地面铺着塑料地板革,已有破损。灯光从屋顶日光灯管发出,白色冷光,照亮全屋。黑板挂于东墙,上面时间轴清晰。桌子周围、衣柜前、门口都有站人空间。\\\"],禁止出现任何角色,纯背景板真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感\"]', 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2077008104604061696, 2077007721974484992, '百货大楼门口_日', '八十年代百货大楼正门,台阶宽大,门口有立柱,橱窗陈列商品,街道上有行人。', 1, '几位穿着中山装和军便装的市民站在台阶上下,有的正在看橱窗,有的在交谈。', '[\"百货大楼正门台阶最高一级中央\",\"右侧立柱旁的地面\",\"台阶下左侧报栏前\",\"对面人行道树下\"]', '[\"「百货大楼门口_日」一栋三层百货大楼,正立面贴白色瓷砖,一层为玻璃橱窗,展示布料、搪瓷盆等商品。楼顶立着「百货大楼」红色大字。门前有五级水磨石台阶,两侧有方形立柱,柱身贴着宣传画。台阶下是宽敞的马路,路边有梧桐树。光学从东南方照来,大楼正面明亮,橱窗玻璃反光。台阶上、立柱旁、报栏前、对面树荫下均有可供人物站立的空间。\",\"「百货大楼门口_日」八十年代百货商场大门,门楣上方有遮雨棚,棚下悬挂「为人民服务」标语。大门为三扇玻璃推拉门,门内可见柜台。左右两侧橱窗内陈列着自行车、缝纫机等展品。门前广场铺着六边形水泥砖,有数根电线杆。光线充足,阳光从右后方照射,左前方有阴影。大门正前方台阶、右侧电线杆附近、左侧橱窗旁均可作为人物落位点。\",\"「百货大楼门口_日」大型百货商店正门区域,建筑为现代主义风格,立面简洁。大门前有宽阔平台,平台四角有花坛,内种月季。地面为水磨石,洁净明亮。左侧有一块公告栏,贴着红色海报。远处可见公交站台,有行人等车。光线从正头顶略偏南,地面有微弱阴影。平台中央、左侧公告栏前、右侧花坛旁均有平坦地面适合站立。\"]', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/c94f6cb0c4364d5db271b9606c2bde94-101b8d0c012901fe.jpg', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:51', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/c94f6cb0c4364d5db271b9606c2bde94-101b8d0c012901fe.jpg\"]', '[\"宽广空间全景,[\\\"「百货大楼门口_日」一栋三层百货大楼,正立面贴白色瓷砖,一层为玻璃橱窗,展示布料、搪瓷盆等商品。楼顶立着「百货大楼」红色大字。门前有五级水磨石台阶,两侧有方形立柱,柱身贴着宣传画。台阶下是宽敞的马路,路边有梧桐树。光学从东南方照来,大楼正面明亮,橱窗玻璃反光。台阶上、立柱旁、报栏前、对面树荫下均有可供人物站立的空间。\\\",\\\"「百货大楼门口_日」八十年代百货商场大门,门楣上方有遮雨棚,棚下悬挂「为人民服务」标语。大门为三扇玻璃推拉门,门内可见柜台。左右两侧橱窗内陈列着自行车、缝纫机等展品。门前广场铺着六边形水泥砖,有数根电线杆。光线充足,阳光从右后方照射,左前方有阴影。大门正前方台阶、右侧电线杆附近、左侧橱窗旁均可作为人物落位点。\\\",\\\"「百货大楼门口_日」大型百货商店正门区域,建筑为现代主义风格,立面简洁。大门前有宽阔平台,平台四角有花坛,内种月季。地面为水磨石,洁净明亮。左侧有一块公告栏,贴着红色海报。远处可见公交站台,有行人等车。光线从正头顶略偏南,地面有微弱阴影。平台中央、左侧公告栏前、右侧花坛旁均有平坦地面适合站立。\\\"],禁止出现任何角色,纯背景板真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感\"]', 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2077008104675364864, 2077007721974484992, '厂庆礼堂_夜', '工厂礼堂内部,舞台上有麦克风,台下长凳坐满工人,红灯笼和横幅装饰。', 1, '几十名穿着蓝色工装的工人整齐坐在长凳上,面向舞台,表情喜悦。', '[\"舞台中央麦克风架前方位置\",\"舞台左侧幕布旁\",\"观众席前排中间走道\",\"礼堂后门入口处\"]', '[\"「厂庆礼堂_夜」一座中型礼堂,空间约三百平米,两侧墙壁挂满红色锦旗和奖状。舞台为砖砌,高约一米,铺着红色地毯,中央立着麦克风,背景幕布为深红色绒布,上方悬挂「厂庆联欢晚会」横幅。观众席摆着二十排长木凳,坐满穿工装的工人。屋顶悬挂多盏白炽灯和串串红灯笼,灯光暖黄。舞台侧方、观众席中间走廊、后门口均有空地可站人。\",\"「厂庆礼堂_夜」礼堂内部呈长方形,前高后低,地板为水泥磨光。天花板有木横梁,垂吊着彩带和纸花。舞台左后侧有上台台阶,右侧放置锣鼓乐器。台下长凳密集排列,过道狭窄,工人拥挤而坐,有些站着。礼堂后墙有双开木门,紧闭。光线主要来自舞台上的聚光灯和两侧壁灯,明亮温暖。舞台中央地面、右侧锣鼓旁、后墙门边有可落位空间。\",\"「厂庆礼堂_夜」宽大的老式礼堂,墙面下半部刷绿色墙裙,上半部白墙。窗户用深色帘幕遮住。舞台台口有弧形边缘,台上有两张木椅和一张讲台。台前摆满花篮。观众席中央过道约一米宽,两侧长凳坐满人。天花板中央一盏大吊灯,四周小灯,整体照明均匀。舞台正前方、左侧幕布缺口处、礼堂后门旁均可站立。\"]', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/a9295d479fef45f198bffd3ef850eacd-588538d1e9d8e094.jpg', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:51', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/a9295d479fef45f198bffd3ef850eacd-588538d1e9d8e094.jpg\"]', '[\"宽广空间全景,[\\\"「厂庆礼堂_夜」一座中型礼堂,空间约三百平米,两侧墙壁挂满红色锦旗和奖状。舞台为砖砌,高约一米,铺着红色地毯,中央立着麦克风,背景幕布为深红色绒布,上方悬挂「厂庆联欢晚会」横幅。观众席摆着二十排长木凳,坐满穿工装的工人。屋顶悬挂多盏白炽灯和串串红灯笼,灯光暖黄。舞台侧方、观众席中间走廊、后门口均有空地可站人。\\\",\\\"「厂庆礼堂_夜」礼堂内部呈长方形,前高后低,地板为水泥磨光。天花板有木横梁,垂吊着彩带和纸花。舞台左后侧有上台台阶,右侧放置锣鼓乐器。台下长凳密集排列,过道狭窄,工人拥挤而坐,有些站着。礼堂后墙有双开木门,紧闭。光线主要来自舞台上的聚光灯和两侧壁灯,明亮温暖。舞台中央地面、右侧锣鼓旁、后墙门边有可落位空间。\\\",\\\"「厂庆礼堂_夜」宽大的老式礼堂,墙面下半部刷绿色墙裙,上半部白墙。窗户用深色帘幕遮住。舞台台口有弧形边缘,台上有两张木椅和一张讲台。台前摆满花篮。观众席中央过道约一米宽,两侧长凳坐满人。天花板中央一盏大吊灯,四周小灯,整体照明均匀。舞台正前方、左侧幕布缺口处、礼堂后门旁均可站立。\\\"],禁止出现任何角色,纯背景板真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感\"]', 0, NULL, NULL); +INSERT INTO `short_drama_location` VALUES (2077008104721502208, 2077007721974484992, '工厂大门_晨', '清晨工厂大门,新挂牌「华夏信息服务中心」,自行车棚,远处烟囱冒烟。', 1, '几名穿着蓝色工装的工人推着自行车或步行进入厂门,路边有早点摊的模糊身影。', '[\"厂门口新挂牌下方正中间\",\"右侧自行车棚外侧\",\"左侧传达室门口台阶\",\"马路对面老槐树旁\"]', '[\"「工厂大门_晨」工厂大门为铁制对开栅栏门,门柱为红砖砌成,左侧门柱上挂着崭新白底黑字牌子「华夏信息服务中心」。进门可见一条水泥路通向厂区。右侧有一排自行车棚,棚内停着几十辆自行车。远处工厂烟囱缓缓冒白烟,背景是朦胧的晨曦天空。阳光从东方斜照,拉长门柱影子。大门正下方、自行车棚外面、左侧传达室门口、马路对面树荫下均有平坦地面。\",\"「工厂大门_晨」晨曦中的老工厂入口,灰砖围墙高约三米,大门敞开。左侧传达室为平房,窗户透出灯光。门右侧墙上贴着红色通知。路面是水泥铺设,有些许落叶。厂内远处可见车间屋顶和冒烟烟囱。天空泛鱼肚白,东边有朝霞。光线从右前方射来,门牌上的字清晰反光。大门中央、传达室左侧空地、路边电线杆旁都可以站人。\",\"「工厂大门_晨」工厂正门区域,宽阔的厂前空地上矗立着新立的招牌。大门两侧各有一棵法国梧桐,枝叶繁茂。地面为柏油路,路中央画有白色虚线。厂门内侧两边是花坛,种着冬青。远处厂房轮廓在晨雾中若隐若现,烟囱吐烟。光线柔和,晨光从建筑物缝隙中洒落。招牌下方、左侧花坛旁、右侧梧桐树下均是可站立的空旷位置。\"]', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/50f5cc1fb8e74dac95d589c23dc2daf7-7fb4da35d3075e95.jpg', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:51', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/50f5cc1fb8e74dac95d589c23dc2daf7-7fb4da35d3075e95.jpg\"]', '[\"宽广空间全景,[\\\"「工厂大门_晨」工厂大门为铁制对开栅栏门,门柱为红砖砌成,左侧门柱上挂着崭新白底黑字牌子「华夏信息服务中心」。进门可见一条水泥路通向厂区。右侧有一排自行车棚,棚内停着几十辆自行车。远处工厂烟囱缓缓冒白烟,背景是朦胧的晨曦天空。阳光从东方斜照,拉长门柱影子。大门正下方、自行车棚外面、左侧传达室门口、马路对面树荫下均有平坦地面。\\\",\\\"「工厂大门_晨」晨曦中的老工厂入口,灰砖围墙高约三米,大门敞开。左侧传达室为平房,窗户透出灯光。门右侧墙上贴着红色通知。路面是水泥铺设,有些许落叶。厂内远处可见车间屋顶和冒烟烟囱。天空泛鱼肚白,东边有朝霞。光线从右前方射来,门牌上的字清晰反光。大门中央、传达室左侧空地、路边电线杆旁都可以站人。\\\",\\\"「工厂大门_晨」工厂正门区域,宽阔的厂前空地上矗立着新立的招牌。大门两侧各有一棵法国梧桐,枝叶繁茂。地面为柏油路,路中央画有白色虚线。厂门内侧两边是花坛,种着冬青。远处厂房轮廓在晨雾中若隐若现,烟囱吐烟。光线柔和,晨光从建筑物缝隙中洒落。招牌下方、左侧花坛旁、右侧梧桐树下均是可站立的空旷位置。\\\"],禁止出现任何角色,纯背景板真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感\"]', 0, NULL, NULL); + +-- ---------------------------- +-- Table structure for short_drama_project +-- ---------------------------- +DROP TABLE IF EXISTS `short_drama_project`; +CREATE TABLE `short_drama_project` ( + `id` bigint NOT NULL COMMENT '主键', + `user_id` bigint NOT NULL COMMENT '用户ID', + `project_name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '项目名称', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '项目描述', + `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'draft' COMMENT '状态:draft/active/archived', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` bigint NULL DEFAULT NULL COMMENT '更新者', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', + `art_style` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'realistic' COMMENT '视觉风格:american-comic=美漫, chinese-comic=国漫, japanese-anime=日系, realistic=写实', + `composed_video_oss_id` bigint NULL DEFAULT NULL COMMENT '最新成片OSS对象ID', + `compose_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '合成状态:NULL未合成,pending/processing/done/failed', + `compose_job_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '当前合成任务标识(并发防重)', + `compose_progress` int NOT NULL DEFAULT 0 COMMENT '合成进度:0-100', + `compose_transition_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'dissolve' COMMENT '转场类型:none/dissolve/fade/slide', + `compose_transition_duration_seconds` decimal(6, 3) NOT NULL DEFAULT 0.500 COMMENT '单次转场时长(秒)', + `compose_aspect_ratio` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '9:16' COMMENT '成片画幅:9:16/16:9/1:1', + `composed_video_duration_seconds` decimal(12, 3) NULL DEFAULT NULL COMMENT '最终成片实际时长(秒,ffprobe测量)', + `compose_error_message` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '最近一次合成失败原因', + `composed_at` datetime NULL DEFAULT NULL COMMENT '最近一次合成完成时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_user_id`(`user_id` ASC) USING BTREE, + INDEX `idx_tenant_id`(`tenant_id` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '短剧项目表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of short_drama_project +-- ---------------------------- +INSERT INTO `short_drama_project` VALUES (2077007721974484992, 1, '短剧项目', '50岁失意中年小明意外穿越到1985年,利用未来知识创业,在时代洪流中改写命运,最终留下并影响一代人。', 'draft', -1, -1, '2026-07-14 20:30:05', -1, '2026-07-14 20:51:14', 0, 'realistic', NULL, 'done', NULL, 100, 'fade', 0.300, '9:16', 32.267, NULL, '2026-07-14 20:51:14'); + +-- ---------------------------- +-- Table structure for short_drama_script +-- ---------------------------- +DROP TABLE IF EXISTS `short_drama_script`; +CREATE TABLE `short_drama_script` ( + `id` bigint NOT NULL COMMENT '主键', + `project_id` bigint NOT NULL COMMENT '项目ID', + `script_name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '剧本名称', + `script_text` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '剧本文本', + `outline_text` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '大纲文本', + `tone` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '风格/基调', + `source_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'manual' COMMENT '来源:manual/ai', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` bigint NULL DEFAULT NULL COMMENT '更新者', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_project_id`(`project_id` ASC) USING BTREE, + INDEX `idx_tenant_id`(`tenant_id` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '短剧剧本表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of short_drama_script +-- ---------------------------- +INSERT INTO `short_drama_script` VALUES (2077007722058371072, 2077007721974484992, '重启1985', '1. 内景 现代办公室 夜\n\n办公室昏暗,只有电脑屏幕亮着。50岁的小明穿着皱巴巴的衬衫,盯着屏幕上的辞退邮件。他拿起手机,看到妻子发来的离婚协议截图。手抖着放下手机,碰倒桌上的电水壶,水洒在插座上。火花闪过,小明倒在椅子上。\n\n2. 外景 小巷 日 1985年\n\n小明醒来,发现自己躺在地上,身穿80年代蓝色工装。他摸着脸——没有皱纹。旁边老式自行车铃响,一个年轻人喊:“小明,上班迟到了!”小明看着路口的二八自行车,墙上的标语“时间就是金钱”,愣住。他低头看手腕上的电子表:1985年6月。他深吸一口气,眼神从迷茫变成坚定:“再来一次。”\n\n3. 外景 街道办门口 日\n\n小明拿着一叠手写方案,站在街道办门口。门卫拦住他。小明:“同志,我有商业计划,能帮工厂接全国订单!”门卫:“胡闹!”推他出去。小明捡起散落的纸,抬头看到路对面一个穿中山装的中年男人(老陈)正在看他的方案。老陈:“你这上面写的‘电商’是啥?”小明眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”\n\n4. 内景 废弃仓库 日\n\n老陈和小明对面坐着。桌上摆着两盒烟。老陈:“我信你一次,但怎么弄?”小明在纸上画:“用电话线连电脑,搞个库存信息库。我写程序。”老陈:“钱呢?”小明:“我先拉商户,您出设备。”老陈犹豫后点头。小明看着仓库墙上的“工业学大庆”,低声:“这次一定要成。”\n\n5. 内景 工厂办公室 日\n\n办公桌上电话响起。老陈接起,脸色变白。挂断后对小明:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看账本:“我们走的是信息服务,不是倒卖。”老陈:“没人认可这个。”小明:“明天我来说。”\n\n6. 内景 小明的出租屋 夜\n\n团队成员(两个年轻人)拍桌:“不干了,风险太大!”老陈沉默。小明拿出一块黑板,画出时间轴:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头:“你哪来的这些?”小明:“梦里学的。”\n\n7. 外景 百货大楼门口 日\n\n工商人员和小明、老陈站在门口。小明拿出一份表格:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看,递给旁边一个戴眼镜的中年人(百货公司经理)。经理看了后低声:“这方法能帮我们清库存?”小明:“三天见效。”经理点头。工商人员收起表格:“先观察。”\n\n8. 内景 厂庆礼堂 夜\n\n工人坐满长凳。老陈上台:“感谢小明的信息平台,咱们工厂活了。”台下鼓掌。小明站在侧台,看着灯光下兴奋的面孔。他手摸到口袋里的现代身份证——早已发黄。他默默撕碎。主持人:“请小明同志讲话!”小明上前,拿着话筒停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。\n\n9. 外景 工厂大门 晨\n\n阳光照在新挂牌上:“华夏信息服务中心”。小明推着自行车出来,老陈追上:“下一步?”小明:“跑遍全国,建数据库。”老陈笑:“疯了。”小明跨上车:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟。', '1.现代办公室夜,小明被老板辞退,回家发现妻子要离婚,绝望中碰倒电水壶触电。2.1985年小巷外景清晨,小明醒来发现变成25岁身体,确认穿越,决定利用记忆创业。3.街道办外景日,小明推销‘互联网购物’概念,被当成疯子赶出,遇到工厂副厂长老陈愿意听。4.废弃仓库内景,小明说服老陈入股,用记忆设计简易电商系统,团队组建。5.工厂办公室内景,初获订单,却遭国营百货公司经理举报,面临查封危机。6.小作家中内景夜,团队内讧,老陈动摇,小明用未来管理经验分析市场,决定转型做信息中介。7.百货大楼外景日,小明带团队利用数据库帮商家库存匹配,化解危机,赢取首个大客户。8.厂庆礼堂内景夜,小明站在台上,回想现代生活,决定留下,带领工人转型。9.同场景,小明讲话,镜头拉远,工厂转型成功。', '现实与励志', 'llm', -1, -1, '2026-07-14 20:30:05', -1, '2026-07-14 20:30:05', 0); + +-- ---------------------------- +-- Table structure for short_drama_storyboard +-- ---------------------------- +DROP TABLE IF EXISTS `short_drama_storyboard`; +CREATE TABLE `short_drama_storyboard` ( + `id` bigint NOT NULL COMMENT '主键', + `project_id` bigint NOT NULL COMMENT '项目ID', + `script_id` bigint NOT NULL COMMENT '剧本ID', + `scene_no` int NOT NULL COMMENT '分镜序号', + `scene_title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '分镜标题', + `scene_text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '分镜说明', + `scene_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'daily' COMMENT '场景类型:daily/emotion/action/epic/suspense', + `shot_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '镜头景别:如平视中景/俯拍远景', + `camera_move` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '镜头运动:如缓推/固定/跟随/环绕', + `characters_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '镜头角色列表JSON:[{name,appearance,slot}]', + `location_name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '场景名称(匹配资产库)', + `photography_rules` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '摄影规则JSON', + `acting_notes` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '表演指导JSON', + `continuity_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '镜头连续性JSON:起始状态、结束状态、动作承接、空间锚点、在场人物', + `source_text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '原文片段', + `image_prompt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '图片生成提示词', + `duration_seconds` int NOT NULL DEFAULT 15 COMMENT '时长秒数', + `video_prompt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '视频提示词', + `video_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '视频地址', + `video_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '视频生成任务ID', + `video_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'pending' COMMENT '视频状态:pending/generating/done/failed', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` bigint NULL DEFAULT NULL COMMENT '更新者', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_script_scene`(`script_id` ASC, `scene_no` ASC) USING BTREE, + INDEX `idx_project_id`(`project_id` ASC) USING BTREE, + INDEX `idx_script_id`(`script_id` ASC) USING BTREE, + INDEX `idx_tenant_id`(`tenant_id` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '短剧分镜表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of short_drama_storyboard +-- ---------------------------- +INSERT INTO `short_drama_storyboard` VALUES (2077008643517599744, 2077007721974484992, 2077007722058371072, 1, '辞退邮件与触电穿越', '深夜办公室,电脑屏幕亮光映照50岁小明苍老疲惫的脸,他盯着屏幕上的辞退邮件,眼神失焦。他拿起手机看到离婚协议截图,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边电水壶,水洒向插座,火花迸溅,小明身体后仰倒在椅子上,画面渐黑。', 'suspense', '平视近景', '缓推至固定', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"办公桌前\"}]', '现代办公室_夜', '{\"panel_number\":1,\"scene_summary\":\"深夜办公室,电脑屏幕亮光映照50岁小明苍老疲惫的脸,他盯着屏幕上的辞退邮件,眼神失焦。他拿起手机看到离婚协议截图,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边电水壶,水洒向插座,火花迸溅,小明身体后仰倒在椅子上,画面渐黑。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"低调硬光,保留阴影层次\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"办公桌前\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中浅景深,保留环境压迫感\",\"color_tone\":\"冷暗色调\"}', '[{\"name\":\"小明\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:现代办公室_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"小明倒在椅子上,身体后仰,火花在插座处闪烁,画面渐黑\",\"continuity_action\":\"小明身体失去平衡向后倒,火花仍在闪烁\",\"spatial_anchor\":\"小明位于画面中央偏左,办公桌桌面杂乱的纸张和电脑屏幕占据右半,电水壶翻倒在桌角\",\"present_characters\":[\"小明\"],\"scene_number\":1,\"segment_number\":1,\"segment_goal\":\"建立小明被辞退后的绝望状态并触发穿越\",\"segment_result\":\"小明触电倒下,穿越启动\",\"bridge_in\":\"开场建立办公室环境与人物状态\",\"bridge_out\":\"火花和倒下的动作形成视觉钩子,画面切黑\",\"beat_type\":\"trigger\",\"narrative_cause\":\"小明收到辞退邮件和离婚协议,情绪崩溃导致意外\",\"character_goal\":\"缓解内心痛苦,但无意识动作导致触电\",\"story_action\":\"手抖放下手机,碰倒水壶,水溅到插座引发火花\",\"story_result\":\"小明触电昏迷,穿越时空\",\"next_hook\":\"下一镜头需要展示小明醒来后的新环境\"}', '办公室昏暗,只有电脑屏幕亮着。50岁的小明穿着皱巴巴的衬衫,盯着屏幕上的辞退邮件。他拿起手机,看到妻子发来的离婚协议截图。手抖着放下手机,碰倒桌上的电水壶,水洒在插座上。火花闪过,小明倒在椅子上。', '小明坐在深夜办公室的办公桌前,电脑屏幕蓝光照亮他苍老疲惫的脸,桌上散落纸张,他手中拿着手机看着离婚协议截图,旁边的电水壶摇摇欲坠,背景黑暗,气氛压抑。', 8, '深夜办公室,中年男子坐在办公桌前,电脑屏幕光映照脸庞,他盯着屏幕,眼神失焦。他拿起手机看向屏幕,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边的电水壶,水洒向插座,火花迸溅,他身体后仰倒在椅子上。镜头从平视中景缓缓推近至脸部特写,然后固定,画面渐黑。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203912-pshlw-0.mp4', '472c35ef433744c8a4cbd35a438ad35e', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:41:42', 0); +INSERT INTO `short_drama_storyboard` VALUES (2077008643727314944, 2077007721974484992, 2077007722058371072, 2, '重生1985', '小明躺在地上,睁开眼,阳光刺眼。他坐起身,低头看见自己穿着蓝色工装,手背皮肤光滑无皱纹。他摸自己的脸,感觉紧致年轻。旁边老式自行车铃响,一个年轻人骑车路过喊“小明,上班迟到了!”小明看向路口停着的二八自行车和墙上红色标语“时间就是金钱”,低头看手腕电子表显示1985.6,他深吸一口气,眼神从迷茫变成坚定,低语“再来一次”。', 'emotion', '平视近景转特写', '缓推加环绕', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"地面中央\"}]', '小巷_日_1985', '{\"panel_number\":2,\"scene_summary\":\"小明躺在地上,睁开眼,阳光刺眼。他坐起身,低头看见自己穿着蓝色工装,手背皮肤光滑无皱纹。他摸自己的脸,感觉紧致年轻。旁边老式自行车铃响,一个年轻人骑车路过喊“小明,上班迟到了!”小明看向路口停着的二八自行车和墙上红色标语“时间就是金钱”,低头看手腕电子表显示1985.6,他深吸一口气,眼神从迷茫变成坚定,低语“再来一次”。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"柔和侧光,突出面部情绪\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"地面中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"浅景深,突出角色表情\",\"color_tone\":\"克制柔和色调\"}', '[{\"name\":\"小明\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:小巷_日_1985,重新交代人物位置、朝向与环境\",\"end_state\":\"小明站立,眼神坚定,低头看电子表,嘴唇微动说“再来一次”\",\"continuity_action\":\"小明视线从手表移向前方街道,准备行动\",\"spatial_anchor\":\"小明位于小巷中央地面,左侧墙上有红色标语,右侧有自行车和骑车年轻人,背景是老旧居民楼\",\"present_characters\":[\"小明\",\"年轻人(群众)\"],\"scene_number\":2,\"segment_number\":1,\"segment_goal\":\"展示小明穿越后醒来发现变成年轻模样\",\"segment_result\":\"小明确认自己回到1985年,眼神由迷茫变坚定\",\"bridge_in\":\"画面从黑场渐亮,小明面部特写从模糊到清晰\",\"bridge_out\":\"小明深吸一口气并说出内心决定,视线看向前方街道\",\"beat_type\":\"reaction\",\"narrative_cause\":\"小明触电昏迷,穿越时空\",\"character_goal\":\"确认自身状态和时代\",\"story_action\":\"坐起、摸脸、看周围、看电子表\",\"story_result\":\"小明确定自己重生,产生第二次人生的决心\",\"next_hook\":\"下一镜头展示小明带着方案前往街道办\"}', '小明醒来,发现自己躺在地上,身穿80年代蓝色工装。他摸着脸——没有皱纹。旁边老式自行车铃响,一个年轻人喊:“小明,上班迟到了!”小明看着路口的二八自行车,墙上的标语“时间就是金钱”,愣住。他低头看手腕上的电子表:1985年6月。他深吸一口气,眼神从迷茫变成坚定:“再来一次。”', '小明坐在小巷地面,身穿蓝色工装,阳光从巷口射入照亮他的脸,他摸着自己年轻的脸庞,眼神迷茫;背景是青砖墙和红色标语,一辆老式自行车靠在墙边。', 10, '年轻男子躺在地面上,阳光刺眼,他缓缓睁开眼,然后坐起身,低头看自己双手和蓝色工装,摸自己的脸,表情从迷茫到惊讶。旁边传来自行车铃声,他转头看向巷口的二八自行车和「时间就是金钱」标语,再低头看手腕上的电子表,眼神从迷茫变为坚定。镜头从全景缓慢推近至脸部特写,同时环绕拍摄,最后固定。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203913-lxtrb-0.mp4', 'b3b6843f0b78433893608675852797bd', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:47:08', 0); +INSERT INTO `short_drama_storyboard` VALUES (2077008643781840896, 2077007721974484992, 2077007722058371072, 3, '街道办受阻遇老陈', '街道办门口,小明拿着一叠手写方案快步走向大门。门卫伸出手臂挡住他。小明急切地解释“同志,我有商业计划,能帮工厂接全国订单!”门卫皱眉推他出去,纸张散落一地。小明蹲下慌乱捡纸。路对面一个穿中山装的中年男人(老陈)弯腰捡起一张,看着上面的字,抬头问:“你这上面写的‘电商’是啥?”小明抬头,眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”', 'daily', '平视中景', '轻微跟随', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"门口台阶下\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"路对面人行道\"}]', '街道办门口_日', '{\"panel_number\":3,\"scene_summary\":\"街道办门口,小明拿着一叠手写方案快步走向大门。门卫伸出手臂挡住他。小明急切地解释“同志,我有商业计划,能帮工厂接全国订单!”门卫皱眉推他出去,纸张散落一地。小明蹲下慌乱捡纸。路对面一个穿中山装的中年男人(老陈)弯腰捡起一张,看着上面的字,抬头问:“你这上面写的‘电商’是啥?”小明抬头,眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"门口台阶下\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"路对面人行道\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:街道办门口_日,重新交代人物位置、朝向与环境\",\"end_state\":\"小明蹲在地上抬头看向老陈,老陈站在路对面手持一张纸,两人视线交汇\",\"continuity_action\":\"小明视线锁定老陈,准备进一步解释\",\"spatial_anchor\":\"街道办铁门在画面左侧,门卫站在台阶上;小明在台阶下中央;老陈在画面右侧路对面行道树旁\",\"present_characters\":[\"小明\",\"老陈\",\"门卫(群众)\"],\"scene_number\":3,\"segment_number\":1,\"segment_goal\":\"小明尝试进入街道办被门卫阻拦,吸引老陈注意\",\"segment_result\":\"老陈捡起散落的方案并开始询问\",\"bridge_in\":\"小明拿着方案走向街道办大门,眼神充满希望\",\"bridge_out\":\"老陈目光落在方案上,问题引发后续对话\",\"beat_type\":\"trigger\",\"narrative_cause\":\"小明确定自己重生,产生第二次人生的决心\",\"character_goal\":\"进入街道办寻求支持\",\"story_action\":\"小明上前解释,被推倒,捡纸时发现老陈在看方案\",\"story_result\":\"老陈对方案产生好奇并主动与小明对话\",\"next_hook\":\"下一镜头展示两人在废弃仓库深入讨论\"}', '小明拿着一叠手写方案,站在街道办门口。门卫拦住他。小明:“同志,我有商业计划,能帮工厂接全国订单!”门卫:“胡闹!”推他出去。小明捡起散落的纸,抬头看到路对面一个穿中山装的中年男人(老陈)正在看他的方案。老陈:“你这上面写的‘电商’是啥?”小明眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”', '小明站在街道办门口台阶下,手拿一叠方案,表情急切;门卫站在台阶上伸手阻拦;路对面老陈穿中山装低头看纸,阳光从右上方照射。', 8, '年轻男子拿着一叠手写方案快步走向街道办大门,门卫伸出手臂阻挡。年轻男子急切地解释,门卫皱眉推他出去,纸张散落。年轻男子蹲下慌乱捡纸。路对面一位中年男子弯腰捡起一张纸,看着上面,抬头问话。镜头平视跟随年轻男子移动,然后切换到越肩视角展示门卫的阻拦动作。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203920-6rw8b-0.mp4', '7173c12461574b8abd063c46fc625ebe', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:47:09', 0); +INSERT INTO `short_drama_storyboard` VALUES (2077008643823783936, 2077007721974484992, 2077007722058371072, 4, '仓库达成合作', '废弃仓库内,一张木桌两把椅子,小明和老陈对面而坐。桌上摆着两盒烟,老陈抽出一根点燃:“我信你一次,但怎么弄?”小明拿起笔在纸上画示意图:“用电话线连电脑,搞个库存信息库。我写程序。”老陈皱眉:“钱呢?”小明:“我先拉商户,您出设备。”老陈抖了抖烟灰,沉默几秒后点头。小明转头看向墙上的标语“工业学大庆”,低声:“这次一定要成。”', 'daily', '平视中景', '缓推', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"桌子一侧\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"桌子另一侧\"}]', '废弃仓库_日', '{\"panel_number\":4,\"scene_summary\":\"废弃仓库内,一张木桌两把椅子,小明和老陈对面而坐。桌上摆着两盒烟,老陈抽出一根点燃:“我信你一次,但怎么弄?”小明拿起笔在纸上画示意图:“用电话线连电脑,搞个库存信息库。我写程序。”老陈皱眉:“钱呢?”小明:“我先拉商户,您出设备。”老陈抖了抖烟灰,沉默几秒后点头。小明转头看向墙上的标语“工业学大庆”,低声:“这次一定要成。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"桌子一侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"桌子另一侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:废弃仓库_日,重新交代人物位置、朝向与环境\",\"end_state\":\"老陈点头,小明目光从墙上标语收回,表情坚定\",\"continuity_action\":\"小明视线从标语移回老陈,准备下一步计划\",\"spatial_anchor\":\"桌子位于仓库中央,小明坐画面左侧,老陈坐右侧,后面墙上有标语,水泥地面空旷\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":4,\"segment_number\":1,\"segment_goal\":\"小明与老陈在仓库达成合作意向\",\"segment_result\":\"老陈决定信任小明,提供设备支持\",\"bridge_in\":\"两人对面而坐,桌上两盒烟,老陈面露犹豫\",\"bridge_out\":\"老陈点头,小明看向墙上标语,低声坚定信念\",\"beat_type\":\"action\",\"narrative_cause\":\"老陈对方案产生好奇并主动与小明对话\",\"character_goal\":\"说服老陈提供设备支持\",\"story_action\":\"小明画图解释技术方案,提出资源分工\",\"story_result\":\"老陈同意合作,小明获得初步资源\",\"next_hook\":\"下一镜头展示工厂办公室内新进展与危机\"}', '老陈和小明对面坐着。桌上摆着两盒烟。老陈:“我信你一次,但怎么弄?”小明在纸上画:“用电话线连电脑,搞个库存信息库。我写程序。”老陈:“钱呢?”小明:“我先拉商户,您出设备。”老陈犹豫后点头。小明看着仓库墙上的“工业学大庆”,低声:“这次一定要成。”', '小明和老陈在废弃仓库的旧木桌对面而坐,桌上两盒烟和散落的纸张;老陈夹烟沉思,小明执笔画示意图;背景墙上红色大字「工业学大庆」,高窗透入光线。', 8, '年轻男子和中年男子对面坐在木桌两侧,桌上摆着两盒烟。中年男子抽出一根点燃,年轻男子拿起笔在纸上画图并说话。中年男子皱眉问话,年轻男子抬头回答,伸手指向纸面。中年男子抖烟灰后点头。年轻男子转头看向墙上的标语,低声自语。镜头从两人的中景缓缓推近至年轻男子的脸部,然后固定。', NULL, 'd4665ee140f34673914292855d389d38', 'generating', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:39:20', 0); +INSERT INTO `short_drama_storyboard` VALUES (2077008643874115584, 2077007721974484992, 2077007722058371072, 5, '工商检查危机', '工厂办公室内,木制办公桌上转盘电话响起。老陈拿起听筒,脸色逐渐变白,手指捏紧话筒。挂断后他转身对旁边椅子上的小明说:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看桌上的账本,抬起头:“我们走的是信息服务,不是倒卖。”老陈叹气:“没人认可这个。”小明合上账本,直视老陈:“明天我来说。”', 'suspense', '平视近景', '缓推', '[{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"办公桌后\"},{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"办公桌旁椅子\"}]', '工厂办公室_日', '{\"panel_number\":5,\"scene_summary\":\"工厂办公室内,木制办公桌上转盘电话响起。老陈拿起听筒,脸色逐渐变白,手指捏紧话筒。挂断后他转身对旁边椅子上的小明说:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看桌上的账本,抬起头:“我们走的是信息服务,不是倒卖。”老陈叹气:“没人认可这个。”小明合上账本,直视老陈:“明天我来说。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"低调硬光,保留阴影层次\"},\"characters\":[{\"name\":\"老陈\",\"screen_position\":\"办公桌后\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"小明\",\"screen_position\":\"办公桌旁椅子\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中浅景深,保留环境压迫感\",\"color_tone\":\"冷暗色调\"}', '[{\"name\":\"老陈\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感\"},{\"name\":\"小明\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感\"}]', '{\"start_state\":\"新场景建立:工厂办公室_日,重新交代人物位置、朝向与环境\",\"end_state\":\"小明合上账本,直视老陈,表情自信;老陈眉头紧锁但不再反驳\",\"continuity_action\":\"小明视线坚定,老陈目光仍带忧虑但转向信任\",\"spatial_anchor\":\"老陈位于画面左侧办公桌后,小明在右侧椅子,窗外可见厂区烟囱,文件柜靠墙\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":5,\"segment_number\":1,\"segment_goal\":\"工厂办公室内老陈接到工商查处的电话,小明提出应对\",\"segment_result\":\"小明决定亲自去面对工商检查\",\"bridge_in\":\"办公室电话铃响打破平静,老陈接听表情变化\",\"bridge_out\":\"老陈看向小明,小明坚定地说“明天我来说”\",\"beat_type\":\"trigger\",\"narrative_cause\":\"老陈同意合作,小明获得初步资源\",\"character_goal\":\"让老陈相信自己能解决危机\",\"story_action\":\"小明解释模式合法性,并主动承担交涉责任\",\"story_result\":\"老陈部分安心,小明获得应对危机的授权\",\"next_hook\":\"下一镜头展示出租屋内团队内部争吵与统一思想\"}', '办公桌上电话响起。老陈接起,脸色变白。挂断后对小明:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看账本:“我们走的是信息服务,不是倒卖。”老陈:“没人认可这个。”小明:“明天我来说。”', '工厂办公室内,老陈坐在办公桌后接电话,脸色凝重;小明坐在侧面椅子上翻看账本,窗外可见工厂烟囱;桌上转盘电话和文件散落。', 7, '办公桌上黑色转盘电话响起。中年男子坐在桌后拿起听筒,脸色逐渐变白,手指捏紧话筒。他挂断后转身对旁边的年轻男子说话。年轻男子快速翻看账本,抬起头直视对方回话,然后合上账本。镜头从电话特写缓慢推近至中年男子面部,再缓拉到两人中景。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203917-gnb2m-0.mp4', '61edb37e979a426f9e8272786243c243', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:41:08', 0); +INSERT INTO `short_drama_storyboard` VALUES (2077008643920252928, 2077007721974484992, 2077007722058371072, 6, '出租屋统一思想', '狭小出租屋,灯光昏暗。两个年轻团队成员拍桌站起:“不干了,风险太大!”老陈坐在床边沉默,低头看地面。小明从墙角拿起一块黑板放在桌上,画出一条时间轴,标注1985年,指向电脑图标。他转头对大家说:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头,皱眉问:“你哪来的这些?”小明停顿一下:“梦里学的。”', 'emotion', '平视近景转特写', '缓推加环绕', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"桌边站立\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"床边坐着\"}]', '出租屋_夜', '{\"panel_number\":6,\"scene_summary\":\"狭小出租屋,灯光昏暗。两个年轻团队成员拍桌站起:“不干了,风险太大!”老陈坐在床边沉默,低头看地面。小明从墙角拿起一块黑板放在桌上,画出一条时间轴,标注1985年,指向电脑图标。他转头对大家说:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头,皱眉问:“你哪来的这些?”小明停顿一下:“梦里学的。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"柔和侧光,突出面部情绪\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"桌边站立\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"床边坐着\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"浅景深,突出角色表情\",\"color_tone\":\"克制柔和色调\"}', '[{\"name\":\"小明\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:出租屋_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"老陈抬头看向小明,两个年轻人停止争吵望向黑板,小黑板上画有时间轴和“电脑”字样\",\"continuity_action\":\"小明手指还指着黑板上的“技术咨询”,等待异议\",\"spatial_anchor\":\"画面中央是一张桌子,黑板靠在桌上,小明站在桌子左侧画黑板,老陈坐在右侧床边,两个年轻人站在桌子远端\",\"present_characters\":[\"小明\",\"老陈\",\"两个年轻人(群众)\"],\"scene_number\":6,\"segment_number\":1,\"segment_goal\":\"出租屋内团队成员因风险而退缩,小明用时间轴规划说服大家\",\"segment_result\":\"老陈和小明达成共识,团队重拾信心\",\"bridge_in\":\"出租屋内两个年轻人拍桌起身,气氛紧张\",\"bridge_out\":\"老陈抬头看小明,眼神中带着疑惑与信任\",\"beat_type\":\"decision\",\"narrative_cause\":\"老陈部分安心,小明获得应对危机的授权\",\"character_goal\":\"稳定团队,统一认知\",\"story_action\":\"小明画出时间轴,解释信息差优势和应对策略\",\"story_result\":\"团队情绪稳定,老陈虽疑惑但接受解释\",\"next_hook\":\"下一镜头展示百货大楼门口工商检查的结果\"}', '团队成员(两个年轻人)拍桌:“不干了,风险太大!”老陈沉默。小明拿出一块黑板,画出时间轴:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头:“你哪来的这些?”小明:“梦里学的。”', '出租屋内,小明站在桌子左侧画黑板,黑板上粉笔写着时间轴和「电脑」字样;老陈坐在床边抬头看;两个年轻人站在桌子远端,表情从愤怒转为倾听;昏黄灯光下气氛紧张。', 10, '狭小出租屋内,两个年轻人拍桌站起情绪激动。中年男子坐在床边低头沉默。年轻男子从墙角拿起一块黑板放在桌上,用粉笔画出一条时间轴,标注1985和电脑图标,转头对大家说话并指向黑板。中年男子抬起头皱眉问话,年轻男子停顿后回答。镜头从全景缓慢推近至年轻男子面部特写,然后环绕拍摄黑板上的时间轴。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); +INSERT INTO `short_drama_storyboard` VALUES (2077008643978973184, 2077007721974484992, 2077007722058371072, 7, '工商检查通过', '百货大楼门口台阶上,工商人员和穿藏青色西装的中年经理站在一起。小明从公文包拿出一叠表格递过去:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看后递给旁边的百货经理。经理推了推眼镜细看,低声问小明:“这方法能帮我们清库存?”小明点头:“三天见效。”经理转向工商人员点了点头。工商人员收起表格:“先观察。”', 'daily', '平视中景', '轻微跟随', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"台阶上右侧\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"台阶上小明身后\"},{\"name\":\"百货经理\",\"appearance\":\"初始形象\",\"slot\":\"台阶上中央\"}]', '百货大楼门口_日', '{\"panel_number\":7,\"scene_summary\":\"百货大楼门口台阶上,工商人员和穿藏青色西装的中年经理站在一起。小明从公文包拿出一叠表格递过去:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看后递给旁边的百货经理。经理推了推眼镜细看,低声问小明:“这方法能帮我们清库存?”小明点头:“三天见效。”经理转向工商人员点了点头。工商人员收起表格:“先观察。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"台阶上右侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"台阶上小明身后\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"百货经理\",\"screen_position\":\"台阶上中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"百货经理\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:百货大楼门口_日,重新交代人物位置、朝向与环境\",\"end_state\":\"工商人员收起表格,经理向小明点头,小明露出微笑\",\"continuity_action\":\"小明视线跟随工商人员离开,准备下一步行动\",\"spatial_anchor\":\"百货大楼正门台阶为背景,工商人员在中央偏左,经理在左侧,小明在右侧,老陈在小明后方半步\",\"present_characters\":[\"小明\",\"老陈\",\"百货经理\",\"工商人员(群众)\"],\"scene_number\":7,\"segment_number\":1,\"segment_goal\":\"工商检查现场,小明用订单记录证明信息服务合法性,获得百货经理认可\",\"segment_result\":\"工商同意观察,项目得以继续\",\"bridge_in\":\"百货大楼门口,工商人员和小明、老陈站立,气氛凝重\",\"bridge_out\":\"工商人员收起表格,经理点头,危机解除\",\"beat_type\":\"result\",\"narrative_cause\":\"团队情绪稳定,老陈虽疑惑但接受解释\",\"character_goal\":\"用事实凭证打消工商疑虑\",\"story_action\":\"小明出示订单记录并解释运营模式\",\"story_result\":\"工商暂缓处理,项目获得官方默许\",\"next_hook\":\"下一镜头展示庆功礼堂的成果与小明的情感时刻\"}', '工商人员和小明、老陈站在门口。小明拿出一份表格:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看,递给旁边一个戴眼镜的中年人(百货公司经理)。经理看了后低声:“这方法能帮我们清库存?”小明:“三天见效。”经理点头。工商人员收起表格:“先观察。”', '小明站在百货大楼门口台阶上,从公文包中拿出表格递给工商人员;老陈站在他身后;旁边穿藏青色西装的百货经理低头查看表格;阳光照亮大楼立面。', 8, '百货大楼门口台阶上,工商人员和穿西装的中年男子站在一起。年轻男子从公文包拿出一叠表格递出并说话。工商人员翻看后递给旁边的经理。经理推了推眼镜细看,低声问年轻男子,年轻男子点头回答。经理转向工商人员点头。工商人员收起表格。镜头平视跟随年轻男子的动作,使用越肩视角展示表格内容。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); +INSERT INTO `short_drama_storyboard` VALUES (2077008644041887744, 2077007721974484992, 2077007722058371072, 8, '厂庆宣言互联网愿景', '厂庆礼堂,红灯笼和横幅装饰,舞台上麦克风前,老陈对着台下说:“感谢小明的信息平台,咱们工厂活了。”台下工人鼓掌。小明站在侧台阴影里,手伸进口袋摸到发黄的现代身份证,他看着灯光下兴奋的面孔,默默将身份证撕碎。主持人喊:“请小明同志讲话!”小明走上舞台中央,拿起话筒,停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。', 'epic', '大远景转特写', '俯拍后缓降推近', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"舞台上中央\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"舞台侧边\"}]', '厂庆礼堂_夜', '{\"panel_number\":8,\"scene_summary\":\"厂庆礼堂,红灯笼和横幅装饰,舞台上麦克风前,老陈对着台下说:“感谢小明的信息平台,咱们工厂活了。”台下工人鼓掌。小明站在侧台阴影里,手伸进口袋摸到发黄的现代身份证,他看着灯光下兴奋的面孔,默默将身份证撕碎。主持人喊:“请小明同志讲话!”小明走上舞台中央,拿起话筒,停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"大范围自然光,突出空间规模\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"舞台上中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"舞台侧边\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"深景深,清晰展现环境规模\",\"color_tone\":\"宏大通透色调\"}', '[{\"name\":\"小明\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:厂庆礼堂_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"小明站在舞台中央举起话筒,台下工人站立鼓掌,老陈在侧台微笑\",\"continuity_action\":\"小明视线扫过全场,准备下台开始新阶段\",\"spatial_anchor\":\"舞台在画面中央靠上,台下长凳坐满工人,小明位于舞台中央,老陈在舞台左侧靠边\",\"present_characters\":[\"小明\",\"老陈\",\"工人(群众)\",\"主持人(群众)\"],\"scene_number\":8,\"segment_number\":1,\"segment_goal\":\"厂庆礼堂中老陈表扬小明,小明撕碎现代身份证并发表讲话\",\"segment_result\":\"小明公开宣告互联网未来愿景,全体工人热烈鼓掌\",\"bridge_in\":\"礼堂内工人坐满长凳,老陈在台上讲话\",\"bridge_out\":\"掌声雷动中,小明眼神坚定望向未来\",\"beat_type\":\"action\",\"narrative_cause\":\"工商暂缓处理,项目获得官方默许\",\"character_goal\":\"激励团队并宣告新方向\",\"story_action\":\"小明撕碎现代身份证,上台演讲\",\"story_result\":\"全体工人被鼓舞,小明确立新一代领导地位\",\"next_hook\":\"下一镜头展示清晨新挂牌和下一步计划\"}', '工人坐满长凳。老陈上台:“感谢小明的信息平台,咱们工厂活了。”台下鼓掌。小明站在侧台,看着灯光下兴奋的面孔。他手摸到口袋里的现代身份证——早已发黄。他默默撕碎。主持人:“请小明同志讲话!”小明上前,拿着话筒停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。', '厂庆礼堂内,舞台中央老陈握着话筒讲话,台下工人鼓掌;小明站在侧幕阴影中,手中捏着撕碎的身份证,灯光照亮舞台,红灯笼和横幅装饰,气氛热烈。', 10, '礼堂内工人坐满长凳,舞台中央中年男子在讲话,台下鼓掌。年轻男子站在侧台阴影中,手伸进口袋掏出一张发黄的身份证,默默撕碎。主持人喊话,年轻男子走上舞台中央,拿起话筒停顿三秒后说话,台下掌声雷动。镜头从礼堂大远景俯拍开始,然后缓缓下降并推近至年轻男子面部特写。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); +INSERT INTO `short_drama_storyboard` VALUES (2077008644113190912, 2077007721974484992, 2077007722058371072, 9, '新征程启程', '清晨,阳光照在工厂大门新挂牌上,金边字体“华夏信息服务中心”。小明推着自行车走出来,老陈从后面追上:“下一步?”小明跨上车:“跑遍全国,建数据库。”老陈笑着摇头:“疯了。”小明回以笑容:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。', 'epic', '大远景', '缓拉升起', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"工厂大门前\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"门口台阶上\"}]', '工厂大门_晨', '{\"panel_number\":9,\"scene_summary\":\"清晨,阳光照在工厂大门新挂牌上,金边字体“华夏信息服务中心”。小明推着自行车走出来,老陈从后面追上:“下一步?”小明跨上车:“跑遍全国,建数据库。”老陈笑着摇头:“疯了。”小明回以笑容:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"大范围自然光,突出空间规模\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"工厂大门前\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"门口台阶上\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"深景深,清晰展现环境规模\",\"color_tone\":\"宏大通透色调\"}', '[{\"name\":\"小明\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:工厂大门_晨,重新交代人物位置、朝向与环境\",\"end_state\":\"小明骑车远去的背影在街道尽头,烟囱冒烟,晨光笼罩\",\"continuity_action\":\"小明继续蹬车,老陈目送,街道向前延伸\",\"spatial_anchor\":\"工厂大门在画面左侧,新挂牌醒目;小明骑车向右远去,街道两旁有工厂围墙和烟囱;老陈站在门口台阶上\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":9,\"segment_number\":1,\"segment_goal\":\"新挂牌的清晨,小明与老陈对话后骑车远去,象征新征程\",\"segment_result\":\"小明骑车远去,烟囱冒烟,新的一天开始\",\"bridge_in\":\"阳光照在新挂牌上,小明推车出现\",\"bridge_out\":\"镜头定格在远去的背影和工厂烟囱,留下开放节奏\",\"beat_type\":\"result\",\"narrative_cause\":\"全体工人被鼓舞,小明确立新一代领导地位\",\"character_goal\":\"确定下一阶段行动计划\",\"story_action\":\"小明跨上自行车说出全国数据库计划\",\"story_result\":\"小明开始实际行动,留下奋斗的背影\",\"next_hook\":\"故事结束,无下一镜头\"}', '阳光照在新挂牌上:“华夏信息服务中心”。小明推着自行车出来,老陈追上:“下一步?”小明:“跑遍全国,建数据库。”老陈笑:“疯了。”小明跨上车:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟。', '清晨阳光照射工厂大门新挂牌「华夏信息服务中心」,小明推自行车站在门口,老陈站在台阶上微笑;远处烟囱冒烟,天空泛鱼肚白,街道延伸向远方。', 10, '清晨阳光照在工厂大门新挂牌上,金色字体「华夏信息服务中心」。年轻男子推自行车从门内走出,中年男子从后面追上,两人对话。年轻男子跨上自行车,蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。镜头从新挂牌特写缓缓拉远并升起,展现工厂全貌和远去的背影。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); -- ---------------------------- -- Table structure for sj_distributed_lock @@ -2165,6 +2460,37 @@ CREATE TABLE `sys_logininfor` ( -- ---------------------------- -- Records of sys_logininfor -- ---------------------------- +INSERT INTO `sys_logininfor` VALUES (2067087010153709570, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-17 11:28:42'); +INSERT INTO `sys_logininfor` VALUES (2067120324839292929, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-17 13:41:05'); +INSERT INTO `sys_logininfor` VALUES (2067178210017693697, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-17 17:31:06'); +INSERT INTO `sys_logininfor` VALUES (2067439136780369921, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-18 10:47:56'); +INSERT INTO `sys_logininfor` VALUES (2067509245943230465, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-18 15:26:31'); +INSERT INTO `sys_logininfor` VALUES (2067515184486297601, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-18 15:50:07'); +INSERT INTO `sys_logininfor` VALUES (2067536325229756417, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-18 17:14:08'); +INSERT INTO `sys_logininfor` VALUES (2067599466735149057, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-18 21:25:02'); +INSERT INTO `sys_logininfor` VALUES (2068942142755569665, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-22 14:20:21'); +INSERT INTO `sys_logininfor` VALUES (2068963471252791297, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-22 15:45:06'); +INSERT INTO `sys_logininfor` VALUES (2068986934684389377, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-22 17:18:20'); +INSERT INTO `sys_logininfor` VALUES (2069012061732057090, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '1', '密码输入错误1次', '2026-06-22 18:58:11'); +INSERT INTO `sys_logininfor` VALUES (2069012086394564609, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-22 18:58:16'); +INSERT INTO `sys_logininfor` VALUES (2069034797917339649, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-06-22 20:28:31'); +INSERT INTO `sys_logininfor` VALUES (2075512593476263937, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-10 17:28:58'); +INSERT INTO `sys_logininfor` VALUES (2075536378350891010, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-10 19:03:29'); +INSERT INTO `sys_logininfor` VALUES (2075536785592643586, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-10 19:05:06'); +INSERT INTO `sys_logininfor` VALUES (2075539196780572674, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-10 19:14:41'); +INSERT INTO `sys_logininfor` VALUES (2075539270906507265, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-10 19:14:59'); +INSERT INTO `sys_logininfor` VALUES (2075544342642126850, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-10 19:35:08'); +INSERT INTO `sys_logininfor` VALUES (2075603310492020737, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-10 23:29:27'); +INSERT INTO `sys_logininfor` VALUES (2075608688223178753, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-10 23:50:49'); +INSERT INTO `sys_logininfor` VALUES (2076851040791674882, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-14 10:07:29'); +INSERT INTO `sys_logininfor` VALUES (2076867711472353281, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-14 11:13:43'); +INSERT INTO `sys_logininfor` VALUES (2076916180597719042, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-14 14:26:19'); +INSERT INTO `sys_logininfor` VALUES (2076935225945997313, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-14 15:42:00'); +INSERT INTO `sys_logininfor` VALUES (2076944166545375233, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-14 16:17:32'); +INSERT INTO `sys_logininfor` VALUES (2076955097467768833, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-14 17:00:58'); +INSERT INTO `sys_logininfor` VALUES (2076972033710804993, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-14 18:08:16'); +INSERT INTO `sys_logininfor` VALUES (2077003517935505409, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-14 20:13:22'); +INSERT INTO `sys_logininfor` VALUES (2077008039416188930, '000000', 'admin', 'pc', 'pc', '127.0.0.1', '内网IP', 'MSEdge', 'Windows 10 or Windows Server 2016', '0', '登录成功', '2026-07-14 20:31:20'); -- ---------------------------- -- Table structure for sys_menu @@ -2320,6 +2646,13 @@ INSERT INTO `sys_menu` VALUES (2014, 'MCP市场删除', 2010, 4, '#', '', '', 1, INSERT INTO `sys_menu` VALUES (2015, 'MCP市场刷新', 2010, 5, '#', '', '', 1, 0, 'F', '0', '0', 'mcp:market:refresh', '#', 103, 1, '2026-02-24 20:02:47', NULL, NULL, ''); INSERT INTO `sys_menu` VALUES (2016, 'MCP工具加载', 2010, 6, '#', '', '', 1, 0, 'F', '0', '0', 'mcp:market:load', '#', 103, 1, '2026-02-24 20:02:47', NULL, NULL, ''); INSERT INTO `sys_menu` VALUES (2017, 'MCP市场导出', 2010, 7, '#', '', '', 1, 0, 'F', '0', '0', 'mcp:market:export', '#', 103, 1, '2026-02-24 20:02:47', NULL, NULL, ''); +INSERT INTO `sys_menu` VALUES (3000, '智能体管理', 0, 1, 'agent', '', '', 1, 0, 'M', '0', '0', '', 'mdi:robot', 103, 1, '2026-07-07 21:37:37', NULL, NULL, '智能体管理目录'); +INSERT INTO `sys_menu` VALUES (3001, '智能体列表', 3000, 1, 'agent', 'agent/agent/index', '', 1, 0, 'C', '0', '0', 'agent:agent:list', 'mdi:robot-outline', 103, 1, '2026-07-07 21:37:37', NULL, NULL, '智能体列表菜单'); +INSERT INTO `sys_menu` VALUES (3002, '智能体查询', 3001, 1, '#', '', '', 1, 0, 'F', '0', '0', 'agent:agent:query', '#', 103, 1, '2026-07-07 21:37:37', NULL, NULL, ''); +INSERT INTO `sys_menu` VALUES (3003, '智能体新增', 3001, 2, '#', '', '', 1, 0, 'F', '0', '0', 'agent:agent:add', '#', 103, 1, '2026-07-07 21:37:37', NULL, NULL, ''); +INSERT INTO `sys_menu` VALUES (3004, '智能体修改', 3001, 3, '#', '', '', 1, 0, 'F', '0', '0', 'agent:agent:edit', '#', 103, 1, '2026-07-07 21:37:37', NULL, NULL, ''); +INSERT INTO `sys_menu` VALUES (3005, '智能体删除', 3001, 4, '#', '', '', 1, 0, 'F', '0', '0', 'agent:agent:remove', '#', 103, 1, '2026-07-07 21:37:37', NULL, NULL, ''); +INSERT INTO `sys_menu` VALUES (3006, '智能体导出', 3001, 5, '#', '', '', 1, 0, 'F', '0', '0', 'agent:agent:export', '#', 103, 1, '2026-07-07 21:37:37', NULL, NULL, ''); INSERT INTO `sys_menu` VALUES (11616, '工作流', 0, 6, 'workflow', '', '', 1, 0, 'M', '0', '0', '', 'mdi:workflow-outline', 103, 1, '2026-01-05 14:39:33', 1, '2026-01-05 14:56:07', ''); INSERT INTO `sys_menu` VALUES (11618, '我的任务', 0, 7, 'task', '', '', 1, 0, 'M', '0', '0', '', 'carbon:task-approved', 103, 1, '2026-01-05 14:39:33', NULL, NULL, ''); INSERT INTO `sys_menu` VALUES (11619, '我的待办', 11618, 2, 'taskWaiting', 'workflow/task/taskWaiting', '', 1, 1, 'C', '0', '0', '', 'ri:todo-line', 103, 1, '2026-01-05 14:39:33', NULL, NULL, ''); @@ -2429,6 +2762,15 @@ CREATE TABLE `sys_oper_log` ( -- ---------------------------- -- Records of sys_oper_log -- ---------------------------- +INSERT INTO `sys_oper_log` VALUES (2056756437140963329, '000000', 'OSS对象存储', 1, 'org.ruoyi.system.controller.system.SysOssController.upload()', 'POST', 1, 'admin', '研发部门', '/resource/oss/upload', '127.0.0.1', '内网IP', '', '', 1, '上传文件失败,请检查配置信息:[software.amazon.awssdk.services.s3.model.S3Exception: The access key Id format you provided is invalid. (Service: S3, Status Code: 403, Request ID: NmEwYzdmNTFfOGEzMTI3MGJfOGE2Y19lNDUyZDk=)]', '2026-05-19 23:18:42', 2621); +INSERT INTO `sys_oper_log` VALUES (2076944303757836289, '000000', '智能体管理', 2, 'org.ruoyi.controller.agent.AgentController.edit()', 'PUT', 1, 'admin', '研发部门', '/agent/agent', '127.0.0.1', '内网IP', '{\"id\":\"1\",\"agentName\":\"对话智能体\",\"agentDescribe\":\"对话智能体\",\"modelId\":\"2000585866022060033\",\"enableThinking\":\"0\",\"systemPrompt\":\"你是一个乐于助人的通用 AI 助手,请用简洁、准确的中文回答用户的问题。\",\"mcpToolIds\":[],\"skillNames\":[\"docx\"],\"knowledgeIds\":[],\"status\":\"0\",\"remark\":\"系统初始化的默认智能体,自动绑定首个启用的对话模型\"}', '{\"code\":200,\"msg\":\"操作成功\"}', 0, '', '2026-07-14 16:18:04', 23); +INSERT INTO `sys_oper_log` VALUES (2076944338465701889, '000000', '会话管理', 1, 'org.ruoyi.controller.chat.ChatSessionController.add()', 'POST', 1, 'admin', '研发部门', '/system/session', '127.0.0.1', '内网IP', '{\"id\":\"2076944338398593026\",\"userId\":\"1\",\"sessionTitle\":\"你好\",\"sessionContent\":\"你好\",\"remark\":\"你好\"}', '{\"code\":200,\"msg\":\"操作成功\",\"data\":\"2076944338398593026\"}', 0, '', '2026-07-14 16:18:13', 19); +INSERT INTO `sys_oper_log` VALUES (2076957117130002434, '000000', '会话管理', 1, 'org.ruoyi.controller.chat.ChatSessionController.add()', 'POST', 1, 'admin', '研发部门', '/system/session', '127.0.0.1', '内网IP', '{\"id\":\"2076957117012561921\",\"userId\":\"1\",\"sessionTitle\":\"测试工作流\"}', '{\"code\":200,\"msg\":\"操作成功\",\"data\":\"2076957117012561921\"}', 0, '', '2026-07-14 17:08:59', 24); +INSERT INTO `sys_oper_log` VALUES (2076958796898422785, '000000', '会话管理', 1, 'org.ruoyi.controller.chat.ChatSessionController.add()', 'POST', 1, 'admin', '研发部门', '/system/session', '127.0.0.1', '内网IP', '{\"id\":\"2076958796772593665\",\"userId\":\"1\",\"sessionTitle\":\"测试工作流\"}', '{\"code\":200,\"msg\":\"操作成功\",\"data\":\"2076958796772593665\"}', 0, '', '2026-07-14 17:15:40', 18); +INSERT INTO `sys_oper_log` VALUES (2076958822575951874, '000000', '会话管理', 1, 'org.ruoyi.controller.chat.ChatSessionController.add()', 'POST', 1, 'admin', '研发部门', '/system/session', '127.0.0.1', '内网IP', '{\"id\":\"2076958822525620225\",\"userId\":\"1\",\"sessionTitle\":\"测试工作流\"}', '{\"code\":200,\"msg\":\"操作成功\",\"data\":\"2076958822525620225\"}', 0, '', '2026-07-14 17:15:46', 15); +INSERT INTO `sys_oper_log` VALUES (2076964726599897090, '000000', '会话管理', 1, 'org.ruoyi.controller.chat.ChatSessionController.add()', 'POST', 1, 'admin', '研发部门', '/system/session', '127.0.0.1', '内网IP', '{\"id\":\"2076964726549565441\",\"userId\":\"1\",\"sessionTitle\":\"测试工作流\"}', '{\"code\":200,\"msg\":\"操作成功\",\"data\":\"2076964726549565441\"}', 0, '', '2026-07-14 17:39:14', 9); +INSERT INTO `sys_oper_log` VALUES (2076964779045474305, '000000', '会话管理', 1, 'org.ruoyi.controller.chat.ChatSessionController.add()', 'POST', 1, 'admin', '研发部门', '/system/session', '127.0.0.1', '内网IP', '{\"id\":\"2076964778940616705\",\"userId\":\"1\",\"sessionTitle\":\"测试工作流\"}', '{\"code\":200,\"msg\":\"操作成功\",\"data\":\"2076964778940616705\"}', 0, '', '2026-07-14 17:39:26', 23); +INSERT INTO `sys_oper_log` VALUES (2076971705414242305, '000000', '会话管理', 1, 'org.ruoyi.controller.chat.ChatSessionController.add()', 'POST', 1, 'admin', '研发部门', '/system/session', '127.0.0.1', '内网IP', '{\"id\":\"2076971705284218881\",\"userId\":\"1\",\"sessionTitle\":\"你好2阿\",\"sessionContent\":\"你好2阿\",\"remark\":\"你好2阿\"}', '{\"code\":200,\"msg\":\"操作成功\",\"data\":\"2076971705284218881\"}', 0, '', '2026-07-14 18:06:57', 24); -- ---------------------------- -- Table structure for sys_oss @@ -2454,18 +2796,18 @@ CREATE TABLE `sys_oss` ( -- ---------------------------- -- Records of sys_oss -- ---------------------------- -INSERT INTO `sys_oss` VALUES (2026580908423340033, '000000', '2026/02/25/9219d6d71a6d45e19192014609d92dc9.png', 'logo.png', '.png', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/9219d6d71a6d45e19192014609d92dc9.png', '{\"fileSize\":\"183613\",\"contentType\":\"image/png\"}', 103, '2026-02-25 16:51:55', 1, '2026-02-25 16:51:55', 1, 'qcloud'); -INSERT INTO `sys_oss` VALUES (2026640059920883713, '000000', '2026/02/25/01091be272334383a1efd9bc22b73ee6.png', 'openai.png', '.png', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/01091be272334383a1efd9bc22b73ee6.png', '{\"fileSize\":\"11297\",\"contentType\":\"image/png\"}', 103, '2026-02-25 20:46:58', 1, '2026-02-25 20:46:58', 1, 'qcloud'); -INSERT INTO `sys_oss` VALUES (2026640515967557633, '000000', '2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', 'ollama.png', '.png', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', '{\"fileSize\":\"8746\",\"contentType\":\"image/png\"}', 103, '2026-02-25 20:48:47', 1, '2026-02-25 20:48:47', 1, 'qcloud'); -INSERT INTO `sys_oss` VALUES (2026640548213366785, '000000', '2026/02/25/e16429a462e54e14a1d36673146b9e3c.png', 'ppio-color.png', '.png', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/e16429a462e54e14a1d36673146b9e3c.png', '{\"fileSize\":\"7382\",\"contentType\":\"image/png\"}', 103, '2026-02-25 20:48:55', 1, '2026-02-25 20:48:55', 1, 'qcloud'); -INSERT INTO `sys_oss` VALUES (2026640572443860993, '000000', '2026/02/25/049bb6a507174f73bba4b8d8b9e55b8a.png', 'ppio-color.png', '.png', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/049bb6a507174f73bba4b8d8b9e55b8a.png', '{\"fileSize\":\"7382\",\"contentType\":\"image/png\"}', 103, '2026-02-25 20:49:00', 1, '2026-02-25 20:49:00', 1, 'qcloud'); -INSERT INTO `sys_oss` VALUES (2026640621945036802, '000000', '2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', 'bailian-color.png', '.png', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', '{\"fileSize\":\"5901\",\"contentType\":\"image/png\"}', 103, '2026-02-25 20:49:12', 1, '2026-02-25 20:49:12', 1, 'qcloud'); -INSERT INTO `sys_oss` VALUES (2033120065673043969, '000000', '2026/03/15/68c4d853814e444982c2517ffabac0f3.jpg', '9b75e600b2df6160261a507055eabfdf.jpg', '.jpg', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/03/15/68c4d853814e444982c2517ffabac0f3.jpg', '{\"fileSize\":\"28027\",\"contentType\":\"image/jpeg\"}', 103, '2026-03-15 17:56:12', 1, '2026-03-15 17:56:12', 1, 'qcloud'); -INSERT INTO `sys_oss` VALUES (2033169884118593537, '000000', '2026/03/15/4b7e93a72bf04805ae59985cc0845ef1.png', 'logo.png', '.png', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/03/15/4b7e93a72bf04805ae59985cc0845ef1.png', '{\"fileSize\":\"61537\",\"contentType\":\"image/png\"}', 103, '2026-03-15 21:14:10', 1, '2026-03-15 21:14:10', 1, 'qcloud'); -INSERT INTO `sys_oss` VALUES (2033198191581147137, '000000', '2026/03/15/66d9e6d216c74652bb466a13d24f4440.txt', 'ruoyi-ai介绍.txt', '.txt', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/03/15/66d9e6d216c74652bb466a13d24f4440.txt', '{\"fileSize\":\"1166\",\"contentType\":\"text/plain\"}', 103, '2026-03-15 23:06:39', 1, '2026-03-15 23:06:39', 1, 'qcloud'); -INSERT INTO `sys_oss` VALUES (2033198447232364546, '000000', '2026/03/15/ae1b1e0d363e4bc1b3321d696453fdbf.txt', 'ruoyi-ai介绍.txt', '.txt', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/03/15/ae1b1e0d363e4bc1b3321d696453fdbf.txt', '{\"fileSize\":\"1166\",\"contentType\":\"text/plain\"}', 103, '2026-03-15 23:07:39', 1, '2026-03-15 23:07:39', 1, 'qcloud'); -INSERT INTO `sys_oss` VALUES (2033198841211727874, '000000', '2026/03/15/83564059b4f643b69a1e0ea727d17364.txt', 'ruoyi-ai介绍.txt', '.txt', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/03/15/83564059b4f643b69a1e0ea727d17364.txt', '{\"fileSize\":\"1166\",\"contentType\":\"text/plain\"}', 103, '2026-03-15 23:09:13', 1, '2026-03-15 23:09:13', 1, 'qcloud'); -INSERT INTO `sys_oss` VALUES (2033199209064771586, '000000', '2026/03/15/695360eb380d43d6af34e8a308c09696.txt', 'ruoyi-ai介绍.txt', '.txt', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/03/15/695360eb380d43d6af34e8a308c09696.txt', '{\"fileSize\":\"1166\",\"contentType\":\"text/plain\"}', 103, '2026-03-15 23:10:41', 1, '2026-03-15 23:10:41', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2026580908423340033, '000000', '2026/02/25/9219d6d71a6d45e19192014609d92dc9.png', 'logo.png', '.png', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/9219d6d71a6d45e19192014609d92dc9.png', '{\"fileSize\":\"183613\",\"contentType\":\"image/png\"}', 103, '2026-02-25 16:51:55', 1, '2026-02-25 16:51:55', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2026640059920883713, '000000', '2026/02/25/01091be272334383a1efd9bc22b73ee6.png', 'openai.png', '.png', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/01091be272334383a1efd9bc22b73ee6.png', '{\"fileSize\":\"11297\",\"contentType\":\"image/png\"}', 103, '2026-02-25 20:46:58', 1, '2026-02-25 20:46:58', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2026640515967557633, '000000', '2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', 'ollama.png', '.png', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', '{\"fileSize\":\"8746\",\"contentType\":\"image/png\"}', 103, '2026-02-25 20:48:47', 1, '2026-02-25 20:48:47', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2026640548213366785, '000000', '2026/02/25/e16429a462e54e14a1d36673146b9e3c.png', 'ppio-color.png', '.png', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/e16429a462e54e14a1d36673146b9e3c.png', '{\"fileSize\":\"7382\",\"contentType\":\"image/png\"}', 103, '2026-02-25 20:48:55', 1, '2026-02-25 20:48:55', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2026640572443860993, '000000', '2026/02/25/049bb6a507174f73bba4b8d8b9e55b8a.png', 'ppio-color.png', '.png', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/049bb6a507174f73bba4b8d8b9e55b8a.png', '{\"fileSize\":\"7382\",\"contentType\":\"image/png\"}', 103, '2026-02-25 20:49:00', 1, '2026-02-25 20:49:00', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2026640621945036802, '000000', '2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', 'bailian-color.png', '.png', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', '{\"fileSize\":\"5901\",\"contentType\":\"image/png\"}', 103, '2026-02-25 20:49:12', 1, '2026-02-25 20:49:12', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2033120065673043969, '000000', '2026/03/15/68c4d853814e444982c2517ffabac0f3.jpg', '9b75e600b2df6160261a507055eabfdf.jpg', '.jpg', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/03/15/68c4d853814e444982c2517ffabac0f3.jpg', '{\"fileSize\":\"28027\",\"contentType\":\"image/jpeg\"}', 103, '2026-03-15 17:56:12', 1, '2026-03-15 17:56:12', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2033169884118593537, '000000', '2026/03/15/4b7e93a72bf04805ae59985cc0845ef1.png', 'logo.png', '.png', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/03/15/4b7e93a72bf04805ae59985cc0845ef1.png', '{\"fileSize\":\"61537\",\"contentType\":\"image/png\"}', 103, '2026-03-15 21:14:10', 1, '2026-03-15 21:14:10', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2033198191581147137, '000000', '2026/03/15/66d9e6d216c74652bb466a13d24f4440.txt', 'ruoyi-ai介绍.txt', '.txt', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/03/15/66d9e6d216c74652bb466a13d24f4440.txt', '{\"fileSize\":\"1166\",\"contentType\":\"text/plain\"}', 103, '2026-03-15 23:06:39', 1, '2026-03-15 23:06:39', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2033198447232364546, '000000', '2026/03/15/ae1b1e0d363e4bc1b3321d696453fdbf.txt', 'ruoyi-ai介绍.txt', '.txt', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/03/15/ae1b1e0d363e4bc1b3321d696453fdbf.txt', '{\"fileSize\":\"1166\",\"contentType\":\"text/plain\"}', 103, '2026-03-15 23:07:39', 1, '2026-03-15 23:07:39', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2033198841211727874, '000000', '2026/03/15/83564059b4f643b69a1e0ea727d17364.txt', 'ruoyi-ai介绍.txt', '.txt', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/03/15/83564059b4f643b69a1e0ea727d17364.txt', '{\"fileSize\":\"1166\",\"contentType\":\"text/plain\"}', 103, '2026-03-15 23:09:13', 1, '2026-03-15 23:09:13', 1, 'qcloud'); +INSERT INTO `sys_oss` VALUES (2033199209064771586, '000000', '2026/03/15/695360eb380d43d6af34e8a308c09696.txt', 'ruoyi-ai介绍.txt', '.txt', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/03/15/695360eb380d43d6af34e8a308c09696.txt', '{\"fileSize\":\"1166\",\"contentType\":\"text/plain\"}', 103, '2026-03-15 23:10:41', 1, '2026-03-15 23:10:41', 1, 'qcloud'); -- ---------------------------- -- Table structure for sys_oss_config @@ -2501,7 +2843,7 @@ CREATE TABLE `sys_oss_config` ( INSERT INTO `sys_oss_config` VALUES (1, '000000', 'minio', 'ruoyi', 'ruoyi123', 'ruoyi', '', '127.0.0.1:9000', '', 'N', '', '1', '1', '', 103, 1, '2026-02-03 05:14:52', 1, '2026-02-25 15:44:13', NULL); INSERT INTO `sys_oss_config` VALUES (2, '000000', 'qiniu', 'XXXXXXXXXXXXXXX', 'XXXXXXXXXXXXXXX', 'ruoyi', '', 's3-cn-north-1.qiniucs.com', '', 'N', '', '1', '1', '', 103, 1, '2026-02-03 05:14:52', 1, '2026-02-03 05:14:52', NULL); INSERT INTO `sys_oss_config` VALUES (3, '000000', 'aliyun', 'XXXXXXXXXXXXXXX', 'XXXXXXXXXXXXXXX', 'ruoyi', '', 'oss-cn-beijing.aliyuncs.com', '', 'N', '', '1', '1', '', 103, 1, '2026-02-03 05:14:52', 1, '2026-02-03 05:14:52', NULL); -INSERT INTO `sys_oss_config` VALUES (4, '000000', 'qcloud', 'xx', 'xx', 'ruoyiai-1254149996', '', 'cos.ap-guangzhou.myqcloud.com', '', 'Y', 'ap-guangzhou', '1', '0', '', 103, 1, '2026-02-03 05:14:52', 1, '2026-03-15 17:56:06', ''); +INSERT INTO `sys_oss_config` VALUES (4, '000000', 'qcloud', 'xx', 'xx', 'example-1234567890', '', 'cos.ap-guangzhou.myqcloud.com', '', 'Y', 'ap-guangzhou', '1', '0', '', 103, 1, '2026-02-03 05:14:52', 1, '2026-03-15 17:56:06', ''); INSERT INTO `sys_oss_config` VALUES (5, '000000', 'image', 'ruoyi', 'ruoyi123', 'ruoyi', 'image', '127.0.0.1:9000', '', 'N', '', '1', '1', '', 103, 1, '2026-02-03 05:14:53', 1, '2026-02-03 05:14:53', NULL); -- ---------------------------- @@ -2948,7 +3290,7 @@ CREATE TABLE `sys_user` ( -- ---------------------------- -- Records of sys_user -- ---------------------------- -INSERT INTO `sys_user` VALUES (1, '000000', 103, 'admin', 'admin', 'sys_user', 'ageerle@163.com', '15888888888', '1', NULL, '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', '2026-05-19 23:16:57', 103, 1, '2026-02-05 09:22:12', -1, '2026-05-19 23:16:57', '管理员', NULL, 0.00); +INSERT INTO `sys_user` VALUES (1, '000000', 103, 'admin', 'admin', 'sys_user', 'ageerle@163.com', '15888888888', '1', NULL, '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', '2026-07-14 20:31:20', 103, 1, '2026-02-05 09:22:12', -1, '2026-07-14 20:31:20', '管理员', NULL, 0.00); INSERT INTO `sys_user` VALUES (3, '000000', 108, 'test', '本部门及以下 密码666666', 'sys_user', '', '', '0', NULL, '$2a$10$b8yUzN0C71sbz.PhNOCgJe.Tu1yWC3RNrTyjSQ8p1W0.aaUXUJ.Ne', '0', '0', '127.0.0.1', '2026-02-05 09:22:12', 103, 1, '2026-02-05 09:22:12', 3, '2026-02-05 09:22:12', NULL, NULL, 0.00); INSERT INTO `sys_user` VALUES (4, '000000', 102, 'test1', '仅本人 密码666666', 'sys_user', '', '', '0', NULL, '$2a$10$b8yUzN0C71sbz.PhNOCgJe.Tu1yWC3RNrTyjSQ8p1W0.aaUXUJ.Ne', '0', '0', '127.0.0.1', '2026-02-05 09:22:12', 103, 1, '2026-02-05 09:22:12', 4, '2026-02-05 09:22:12', NULL, NULL, 0.00); diff --git a/docs/script/sql/update/updat-0419.sql b/docs/script/sql/update/updat-0419.sql deleted file mode 100644 index f744f8a5..00000000 --- a/docs/script/sql/update/updat-0419.sql +++ /dev/null @@ -1,92 +0,0 @@ -/* - Navicat Premium Dump SQL - - Source Server : localhost-mysql - Source Server Type : MySQL - Source Server Version : 80045 (8.0.45) - Source Host : localhost:3306 - Source Schema : ruoyi-ai - - Target Server Type : MySQL - Target Server Version : 80045 (8.0.45) - File Encoding : 65001 - - Date: 19/04/2026 13:36:41 -*/ - -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ---------------------------- --- Table structure for chat_model --- ---------------------------- -DROP TABLE IF EXISTS `chat_model`; -CREATE TABLE `chat_model` ( - `id` bigint NOT NULL COMMENT '主键', - `category` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型分类', - `model_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型名称', - `provider_code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型供应商', - `model_describe` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型描述', - `model_dimension` int NULL DEFAULT NULL COMMENT '模型维度', - `model_show` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '是否显示', - `api_host` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '请求地址', - `api_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '密钥', - `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', - `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', - `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', - `update_by` bigint NULL DEFAULT NULL COMMENT '更新者', - `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间', - `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注', - `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', - PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '模型管理' ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Records of chat_model --- ---------------------------- -INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 'zai-org/glm-5', 'ppio', 'zai-org/glm-5', NULL, 'Y', 'https://api.ppio.com/openai', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', 'DeepSeek-V3.2 是一款在高效推理、复杂推理能力与智能体场景中表现突出的领先模型。其基于 DeepSeek Sparse Attention(DSA)稀疏注意力机制,在显著降低计算开销的同时优化长上下文性能;通过可扩展强化学习框架,整体能力达到 GPT-5 同级,高算力版本 V3.2-Speciale 更在推理表现上接近 Gemini-3.0-Pro;同时,模型依托大型智能体任务合成管线,具备更强的工具调用与多步骤决策能力,并在 2025 年 IMO 与 IOI 中取得金牌级表现。作为 MaaS 平台,我们已对 DeepSeek-V3.2 完成深度适配,通过动态调度、批处理加速、低延迟推理与企业级 SLA 保障,进一步增强其在企业生产环境中的稳定性、性价比与可控性,适用于搜索、问答、智能体、代码、数据处理等多类高价值场景。', 0); -INSERT INTO `chat_model` VALUES (2007528268536287233, 'vector', 'baai/bge-m3', 'ppio', 'bge-m3', 1024, 'N', 'https://api.ppio.com/openai', 'sk_xx', 103, 1, '2026-01-04 03:03:32', 1, '2026-03-15 19:18:51', 'BGE-M3 是一款具备多维度能力的文本嵌入模型,可同时实现密集检索、多向量检索和稀疏检索三大核心功能。该模型设计上兼容超过100种语言,并支持从短句到长达8192词元的长文本等多种输入形式。在跨语言检索任务中,BGE-M3展现出显著优势,其性能在MIRACL、MKQA等国际基准测试中位居前列。此外,针对长文档检索场景,该模型在MLDR、NarritiveQA等数据集上的表现同样达到行业领先水平。', 0); -INSERT INTO `chat_model` VALUES (2045735140488847361, 'chat', 'deepseek-chat', 'custom_api', 'deepseek-chat', NULL, NULL, 'https://api.deepseek.com', 'sk_xx', 103, 1, '2026-04-19 13:24:00', 1, '2026-04-19 13:24:00', 'deepseek对话模型', 0); - --- ---------------------------- --- Table structure for chat_provider --- ---------------------------- -DROP TABLE IF EXISTS `chat_provider`; -CREATE TABLE `chat_provider` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `provider_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '厂商名称', - `provider_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '厂商编码', - `provider_icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '厂商图标', - `provider_desc` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '厂商描述', - `api_host` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'API地址', - `status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '状态(0正常 1停用)', - `sort_order` int NULL DEFAULT 0 COMMENT '排序', - `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', - `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', - `create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '创建者', - `update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '更新者', - `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间', - `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注', - `version` int NULL DEFAULT NULL COMMENT '版本', - `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '删除标志', - `update_ip` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '更新IP', - `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `unique_provider_code`(`provider_code` ASC, `tenant_id` ASC, `del_flag` ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2045727230803255298 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '厂商管理表' ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Records of chat_provider --- ---------------------------- -INSERT INTO `chat_provider` VALUES (1, 'OpenAI', 'openai', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/01091be272334383a1efd9bc22b73ee6.png', 'OpenAI官方API服务商', 'https://api.openai.com', '0', 1, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:46:59', 'OpenAI厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (11, '深度求索', 'deepseek', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/5ba8c30f153246898a4d7dc7b846de8d.png', 'DeepSeek官方API', 'https://api.deepseek.com', '0', 0, 103, '2026-04-19 12:52:34', '1', '1', '2026-04-19 13:13:25', 'DeepSeek官方API', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (12, '智谱AI', 'zhipu', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/da071783c9284fdd9ed1ce1b57b3c75c.png', '智谱AI大模型服务', 'https://open.bigmodel.cn', '0', 4, 103, '2025-12-14 21:48:11', '1', '1', '2026-04-19 13:14:00', '智谱AI厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (13, '小米MIMO', 'xiaomi', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/18dd39365ce244e3ae5e030da036760e.png', '小米官方API', 'https://api.xiaomimimo.com/anthropic/v1/messages', '0', 3, 103, '2026-04-19 12:48:24', '1', '1', '2026-04-19 13:14:22', '小米官方API', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (14, '阿里云百炼', 'qianwen', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', '阿里云百炼大模型服务', 'https://dashscope.aliyuncs.com', '0', 2, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:49:13', '阿里云厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (15, 'PPIO', 'ppio', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/049bb6a507174f73bba4b8d8b9e55b8a.png', 'api聚合厂商', 'https://api.ppinfra.com/openai', '0', 5, 103, '2025-12-15 23:13:42', '1', '1', '2026-02-25 20:49:01', 'api聚合厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (16, 'MiniMax', 'minimax', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/fdc712e90e0e4d78b05862ad230884e5.png', 'MiniMax大模型服务,支持M2.7、M2.5等模型', 'https://api.minimax.io/v1', '0', 6, 103, '2026-04-19 12:50:12', '1', '1', '2026-04-19 13:14:59', 'MiniMax厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (17, 'ollama', 'ollama', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', 'ollama大模型', 'http://127.0.0.1:11434', '0', 7, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:48:48', 'ollama厂商', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (18, '自定义厂商', 'custom_api', 'https://ruoyiai-1254149996.cos.ap-guangzhou.myqcloud.com/2026/04/19/c1a8e122510f4e2f90deb36958af710b.png', 'OPENAI兼容格式', '自定义', '0', 8, 103, '2026-04-19 12:35:57', '1', '1', '2026-04-19 13:17:20', 'OPENAI兼容格式', NULL, '0', NULL, 0); - -SET FOREIGN_KEY_CHECKS = 1; diff --git a/docs/script/sql/update/updat-0420.sql b/docs/script/sql/update/updat-0420.sql deleted file mode 100644 index 628a6b5c..00000000 --- a/docs/script/sql/update/updat-0420.sql +++ /dev/null @@ -1,46 +0,0 @@ -/* - Navicat Premium Dump SQL - - Source Server : localhost-mysql - Source Server Type : MySQL - Source Server Version : 80045 (8.0.45) - Source Host : localhost:3306 - Source Schema : ruoyi-ai - - Target Server Type : MySQL - Target Server Version : 80045 (8.0.45) - File Encoding : 65001 - - Date: 20/04/2026 15:30:00 -*/ - -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ---------------------------- --- 新增:重排序模型(chat_model) --- ---------------------------- -INSERT INTO `chat_model` -(id, category, model_name, provider_code, model_describe, model_dimension, model_show, api_host, api_key, create_dept, create_by, create_time, update_by, update_time, remark, tenant_id) -VALUES(2045071617578237953, 'rerank', 'rerank', 'zhipu', '智谱重排序', NULL, 'Y', 'https://open.bigmodel.cn', 'e9xx', 103, 1, '2026-04-17 17:27:24', 1, '2026-04-20 15:21:48', '智谱重排序', 0); - -INSERT INTO `chat_model` -(id, category, model_name, provider_code, model_describe, model_dimension, model_show, api_host, api_key, create_dept, create_by, create_time, update_by, update_time, remark, tenant_id) -VALUES(2046119803482902530, 'rerank', 'qwen3-rerank', 'qianwen', '千问3重排序', NULL, NULL, 'https://dashscope.aliyuncs.com', 'sk-xx', 103, 1, '2026-04-20 14:52:31', 1, '2026-04-20 15:03:13', '千问3文本重排序', 0); - --- ---------------------------- --- 新增:字典类型 - 重排序模型分类 --- ---------------------------- -INSERT INTO `sys_dict_data` -(dict_code, tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, create_dept, create_by, create_time, update_by, update_time, remark) -VALUES(2045070879435259905, '000000', 4, '重排序', 'rerank', 'chat_model_category', NULL, '#000000', 'N', 103, 1, '2026-04-17 17:24:28', 1, '2026-04-19 01:02:20', '重排序模型'); - --- ---------------------------- --- 修改表:knowledge_info 增加重排序相关字段 --- ---------------------------- -ALTER TABLE `knowledge_info` ADD COLUMN `enable_rerank` tinyint DEFAULT 0 NULL COMMENT '是否启用重排序(0否 1是)'; -ALTER TABLE `knowledge_info` ADD COLUMN `rerank_score_threshold` double NULL COMMENT '重排序相关性分数阈值'; -ALTER TABLE `knowledge_info` ADD COLUMN `rerank_top_n` int NULL COMMENT '重排序后返回的文档数量'; -ALTER TABLE `knowledge_info` ADD COLUMN `rerank_model` varchar(100) NULL COMMENT '重排序模型名称'; - -SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/docs/script/sql/update/updat-0423.sql b/docs/script/sql/update/updat-0423.sql deleted file mode 100644 index 4ed14430..00000000 --- a/docs/script/sql/update/updat-0423.sql +++ /dev/null @@ -1,14 +0,0 @@ --- 为知识库信息表新增检索配置字段 (剔除了已存在的重排字段) -ALTER TABLE knowledge_info - ADD COLUMN similarity_threshold DOUBLE DEFAULT 0.5 COMMENT '相似度阈值' -AFTER retrieve_limit; - -ALTER TABLE knowledge_info ADD COLUMN enable_hybrid tinyint(1) DEFAULT 0 COMMENT '是否启用混合检索'; -ALTER TABLE knowledge_info ADD COLUMN hybrid_alpha double DEFAULT 0.5 COMMENT '混合检索权重比例 (0.0=纯向量, 1.0=纯关键词)'; - --- 为知识片段表增加全文索引及关联ID -ALTER TABLE knowledge_fragment ADD COLUMN knowledge_id bigint COMMENT '知识库ID'; -ALTER TABLE knowledge_fragment ADD FULLTEXT INDEX ft_content (content) WITH PARSER ngram; - --- 为知识库附件表增加解析状态字段 -ALTER TABLE `knowledge_attach` ADD COLUMN `status` TINYINT DEFAULT 0 COMMENT '解析状态: 0待解析, 1解析中, 2已解析, 3解析失败'; diff --git a/pom.xml b/pom.xml index 4441852f..9acb4232 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ 全栈式AI开发平台 - 3.0.0 + 3.1.0 3.5.8 UTF-8 UTF-8 @@ -54,17 +54,21 @@ 2.18.2 - 1.13.0 - 1.13.0-beta23 - 1.5.3 + + 1.17.2 + 1.17.2-beta27 + 1.17.0-beta27 + 1.8.20 + 1.19.6 - 1.0.7 + 1.2.7 + 0.4.2 + 1.62.2 1.27.1 - 1.1.0 1.21.2 4.4.0 diff --git a/ruoyi-admin/Dockerfile b/ruoyi-admin/Dockerfile index 0394ccb9..af28020c 100644 --- a/ruoyi-admin/Dockerfile +++ b/ruoyi-admin/Dockerfile @@ -1,31 +1,59 @@ -# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/ +# FFmpeg-enabled variant of ruoyi-admin/Dockerfile. FROM bellsoft/liberica-openjdk-rocky:17.0.16-cds -#FROM bellsoft/liberica-openjdk-rocky:21.0.8-cds -#FROM findepi/graalvm:java17-native LABEL maintainer="Lion Li" +# Rocky base repositories do not provide the full codec build. RPM Fusion Free +# supplies ffmpeg with libx264, while the checks below prevent a reduced build +# from reaching production unnoticed. +RUN set -eux; \ + dnf -y install dnf-plugins-core epel-release; \ + . /etc/os-release; \ + rocky_major="${VERSION_ID%%.*}"; \ + if [ "${rocky_major}" -ge 9 ]; then \ + dnf config-manager --set-enabled crb; \ + else \ + dnf config-manager --set-enabled powertools; \ + fi; \ + dnf -y install "https://mirrors.rpmfusion.org/free/el/rpmfusion-free-release-${rocky_major}.noarch.rpm"; \ + dnf -y install ffmpeg; \ + for filter in scale pad fps setsar format tpad trim settb setpts xfade \ + aresample aformat apad atrim asetpts anullsrc concat acrossfade; do \ + ffmpeg -hide_banner -filters 2>/dev/null | grep -Eq "[[:space:]]${filter}[[:space:]]"; \ + done; \ + ffmpeg -hide_banner -h filter=xfade 2>&1 | grep -q dissolve; \ + ffmpeg -hide_banner -h filter=xfade 2>&1 | grep -q fadeblack; \ + ffmpeg -hide_banner -h filter=xfade 2>&1 | grep -q slideleft; \ + ffmpeg -hide_banner -encoders 2>/dev/null | grep -Eq '[[:space:]]libx264[[:space:]]'; \ + ffmpeg -hide_banner -encoders 2>/dev/null | grep -Eq '[[:space:]]aac[[:space:]]'; \ + ffprobe -hide_banner -version >/dev/null; \ + dnf clean all; \ + rm -rf /var/cache/dnf + RUN mkdir -p /ruoyi/server/logs \ /ruoyi/server/temp \ /ruoyi/skywalking/agent WORKDIR /ruoyi/server -ENV SERVER_PORT=8080 SNAIL_PORT=28080 LANG=C.UTF-8 LC_ALL=C.UTF-8 JAVA_OPTS="" +ENV SERVER_PORT=8080 \ + SNAIL_PORT=28080 \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + JAVA_OPTS="" \ + FFMPEG_PATH=/usr/bin/ffmpeg \ + FFPROBE_PATH=/usr/bin/ffprobe EXPOSE ${SERVER_PORT} -# 暴露 snail job 客户端端口 用于定时任务调度中心通信 EXPOSE ${SNAIL_PORT} ADD ./target/ruoyi-admin.jar ./app.jar SHELL ["/bin/bash", "-c"] -ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -Dserver.port=${SERVER_PORT} \ +ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom \ + -Djava.io.tmpdir=/ruoyi/server/temp \ + -Dserver.port=${SERVER_PORT} \ -Dsnail-job.port=${SNAIL_PORT} \ - # 应用名称 如果想区分集群节点监控 改成不同的名称即可 - #-Dskywalking.agent.service_name=ruoyi-server \ - #-javaagent:/ruoyi/skywalking/agent/skywalking-agent.jar \ -XX:+HeapDumpOnOutOfMemoryError -XX:+UseZGC ${JAVA_OPTS} \ -jar app.jar - diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 14d4faa4..be709192 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -313,3 +313,37 @@ vector-store: collectionname: LocalKnowledge api-key: use-tls: false + +# 短剧成片合成 +short-drama: + composition: + ffmpeg-path: ${FFMPEG_PATH:ffmpeg} + ffprobe-path: ${FFPROBE_PATH:ffprobe} + fps: ${SHORT_DRAMA_COMPOSITION_FPS:30} + audio-sample-rate: ${SHORT_DRAMA_COMPOSITION_AUDIO_SAMPLE_RATE:48000} + video-codec: ${SHORT_DRAMA_COMPOSITION_VIDEO_CODEC:libx264} + audio-codec: ${SHORT_DRAMA_COMPOSITION_AUDIO_CODEC:aac} + preset: ${SHORT_DRAMA_COMPOSITION_PRESET:medium} + crf: ${SHORT_DRAMA_COMPOSITION_CRF:20} + audio-bitrate: ${SHORT_DRAMA_COMPOSITION_AUDIO_BITRATE:192k} + max-clips: ${SHORT_DRAMA_COMPOSITION_MAX_CLIPS:100} + max-transition-seconds: ${SHORT_DRAMA_COMPOSITION_MAX_TRANSITION_SECONDS:2.0} + minimum-output-bytes: ${SHORT_DRAMA_COMPOSITION_MINIMUM_OUTPUT_BYTES:1024} + max-process-output-bytes: ${SHORT_DRAMA_COMPOSITION_MAX_PROCESS_OUTPUT_BYTES:1048576} + max-source-bytes: ${SHORT_DRAMA_COMPOSITION_MAX_SOURCE_BYTES:536870912} + max-total-source-bytes: ${SHORT_DRAMA_COMPOSITION_MAX_TOTAL_SOURCE_BYTES:2147483648} + probe-timeout: ${SHORT_DRAMA_COMPOSITION_PROBE_TIMEOUT:30s} + process-timeout: ${SHORT_DRAMA_COMPOSITION_PROCESS_TIMEOUT:30m} + job-stale-after: ${SHORT_DRAMA_COMPOSITION_JOB_STALE_AFTER:45m} + worker-core-size: ${SHORT_DRAMA_COMPOSITION_WORKER_CORE_SIZE:1} + worker-max-size: ${SHORT_DRAMA_COMPOSITION_WORKER_MAX_SIZE:2} + worker-queue-capacity: ${SHORT_DRAMA_COMPOSITION_WORKER_QUEUE_CAPACITY:8} + storage-mode: ${SHORT_DRAMA_COMPOSITION_STORAGE_MODE:local} + local-output-directory: ${SHORT_DRAMA_COMPOSITION_LOCAL_OUTPUT_DIRECTORY:logs/short-drama-compositions} + download: + connect-timeout: ${SHORT_DRAMA_DOWNLOAD_CONNECT_TIMEOUT:30s} + call-timeout: ${SHORT_DRAMA_DOWNLOAD_CALL_TIMEOUT:10m} + max-redirects: ${SHORT_DRAMA_DOWNLOAD_MAX_REDIRECTS:5} + # 生产环境建议配置为视频供应商或 OSS/CDN 域名,多个值用逗号分隔。 + allowed-hosts: ${SHORT_DRAMA_DOWNLOAD_ALLOWED_HOSTS:} + fake-ip-allowed-hosts: ${SHORT_DRAMA_DOWNLOAD_FAKE_IP_ALLOWED_HOSTS:atlas-media.oss-us-west-1.aliyuncs.com} diff --git a/ruoyi-common/ruoyi-common-bom/pom.xml b/ruoyi-common/ruoyi-common-bom/pom.xml index ec18b08a..89b3356d 100644 --- a/ruoyi-common/ruoyi-common-bom/pom.xml +++ b/ruoyi-common/ruoyi-common-bom/pom.xml @@ -14,7 +14,7 @@ - 3.0.0 + 3.1.0 diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ChatRequest.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ChatRequest.java index 23e42375..ac106ab3 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ChatRequest.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ChatRequest.java @@ -21,9 +21,19 @@ import java.util.List; @Data public class ChatRequest { - @NotEmpty(message = "传入的模型不能为空") + /** + * 模型名称。 + * 智能体模式下可缺省:传 agentId 时后端按智能体绑定的模型解析,此字段仅作回退。 + */ private String model; + /** + * 智能体ID。传入时后端按智能体配置解析模型/工具/技能/知识库/提示词/是否深度思考。 + */ + @JsonSerialize(using = ToStringSerializer.class) + @JSONField(serializeUsing = String.class) + private Long agentId; + /** * 对话消息 */ @@ -35,16 +45,6 @@ public class ChatRequest { */ private WorkFlowRunner workFlowRunner; - /** - * 人机交互信息体 - */ - private ReSumeRunner reSumeRunner; - - /** - * 是否为人机交互用户继续输入 - */ - private Boolean isResume = false; - /** * 是否启用工作流 */ diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ReSumeRunner.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ReSumeRunner.java deleted file mode 100644 index 65b39fd0..00000000 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/dto/request/ReSumeRunner.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.ruoyi.common.chat.domain.dto.request; - -import lombok.Data; - -/** - * 人机交互输入信息 - */ -@Data -public class ReSumeRunner { - /** - * 运行节点UUID - */ - private String runtimeUuid; - - /** - * 人机交互用户输入信息 - */ - private String feedbackContent; -} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/audio/AudioContext.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/audio/AudioContext.java new file mode 100644 index 00000000..22611ffe --- /dev/null +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/audio/AudioContext.java @@ -0,0 +1,29 @@ +package org.ruoyi.common.chat.entity.audio; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Data; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; + +/** + * Text-to-speech context. + */ +@Data +@Builder +public class AudioContext { + + @NotNull(message = "模型配置不能为空") + private ChatModelVo chatModelVo; + + @NotBlank(message = "输入文本不能为空") + private String input; + + private String voice; + + private String responseFormat; + + private Double speed; + + private String instructions; +} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/image/ImageContext.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/image/ImageContext.java index 60be2739..ce6302c0 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/image/ImageContext.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/image/ImageContext.java @@ -42,4 +42,9 @@ public class ImageContext { @Min(value = 0, message = "随机数种子不能小于0") @Max(value = 2147483647, message = "随机数种子不能大于2147483647") private Integer seed; + + /** + * 参考图 URL(图生图用) + */ + private String image; } diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/media/MediaGenerationResponse.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/media/MediaGenerationResponse.java new file mode 100644 index 00000000..5f1960b9 --- /dev/null +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/media/MediaGenerationResponse.java @@ -0,0 +1,28 @@ +package org.ruoyi.common.chat.entity.media; + +import lombok.Builder; +import lombok.Data; + +/** + * Unified media generation response. + */ +@Data +@Builder +public class MediaGenerationResponse { + + private String type; + + private String mimeType; + + private String url; + + private String b64Json; + + private String dataUrl; + + private String id; + + private String status; + + private String rawResponse; +} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java new file mode 100644 index 00000000..b6138862 --- /dev/null +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java @@ -0,0 +1,35 @@ +package org.ruoyi.common.chat.entity.video; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Data; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; + +/** + * Text-to-video context. + */ +@Data +@Builder +public class VideoContext { + + @NotNull(message = "模型配置不能为空") + private ChatModelVo chatModelVo; + + @NotBlank(message = "提示词不能为空") + private String prompt; + + private String size; + + private Integer seconds; + + private String quality; + + /** 参考图 URL(图生视频模式,单图) */ + private String imageUrl; + + /** 多参考图 URL(多参考图生视频模式,配合 @imageN prompt 使用) */ + private java.util.List referenceImages; + + private String videoId; +} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/AudioServiceFactory.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/AudioServiceFactory.java new file mode 100644 index 00000000..7199ced4 --- /dev/null +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/AudioServiceFactory.java @@ -0,0 +1,40 @@ +package org.ruoyi.common.chat.factory; + +import org.ruoyi.common.chat.service.audio.IAudioGenerationService; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Text-to-speech service factory. + */ +@Component +public class AudioServiceFactory implements ApplicationContextAware { + + private final Map audioServiceMap = new ConcurrentHashMap<>(); + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + Map serviceMap = applicationContext.getBeansOfType(IAudioGenerationService.class); + for (IAudioGenerationService service : serviceMap.values()) { + if (service != null) { + audioServiceMap.put(service.getProviderName(), service); + } + } + } + + public IAudioGenerationService getOriginalService(String providerCode) { + IAudioGenerationService service = audioServiceMap.get(providerCode); + if (service == null && "custom_api".equals(providerCode)) { + service = audioServiceMap.get("openai"); + } + if (service == null) { + throw new IllegalArgumentException("不支持的语音模型供应商: " + providerCode); + } + return service; + } +} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/ImageServiceFactory.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/ImageServiceFactory.java index 52d3731b..89c641db 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/ImageServiceFactory.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/ImageServiceFactory.java @@ -37,6 +37,9 @@ public class ImageServiceFactory implements ApplicationContextAware { */ public IImageGenerationService getOriginalService(String category) { IImageGenerationService service = imageSerivceMap.get(category); + if (service == null && "custom_api".equals(category)) { + service = imageSerivceMap.get("openai"); + } if (service == null) { throw new IllegalArgumentException("不支持的模型类别: " + category); } diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/VideoServiceFactory.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/VideoServiceFactory.java new file mode 100644 index 00000000..8cdd9e4e --- /dev/null +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/factory/VideoServiceFactory.java @@ -0,0 +1,40 @@ +package org.ruoyi.common.chat.factory; + +import org.ruoyi.common.chat.service.video.IVideoGenerationService; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Text-to-video service factory. + */ +@Component +public class VideoServiceFactory implements ApplicationContextAware { + + private final Map videoServiceMap = new ConcurrentHashMap<>(); + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + Map serviceMap = applicationContext.getBeansOfType(IVideoGenerationService.class); + for (IVideoGenerationService service : serviceMap.values()) { + if (service != null) { + videoServiceMap.put(service.getProviderName(), service); + } + } + } + + public IVideoGenerationService getOriginalService(String providerCode) { + IVideoGenerationService service = videoServiceMap.get(providerCode); + if (service == null && "custom_api".equals(providerCode)) { + service = videoServiceMap.get("openai"); + } + if (service == null) { + throw new IllegalArgumentException("不支持的视频模型供应商: " + providerCode); + } + return service; + } +} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/audio/IAudioGenerationService.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/audio/IAudioGenerationService.java new file mode 100644 index 00000000..68cb383e --- /dev/null +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/audio/IAudioGenerationService.java @@ -0,0 +1,15 @@ +package org.ruoyi.common.chat.service.audio; + +import jakarta.validation.Valid; +import org.ruoyi.common.chat.entity.audio.AudioContext; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; + +/** + * Public text-to-speech service. + */ +public interface IAudioGenerationService { + + MediaGenerationResponse generateSpeech(@Valid AudioContext audioContext); + + String getProviderName(); +} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/image/IImageGenerationService.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/image/IImageGenerationService.java index 3be0a84f..aa845dbd 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/image/IImageGenerationService.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/image/IImageGenerationService.java @@ -2,6 +2,8 @@ package org.ruoyi.common.chat.service.image; import jakarta.validation.Valid; import org.ruoyi.common.chat.entity.image.ImageContext; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; /** * 公共文生图接口 @@ -9,10 +11,31 @@ import org.ruoyi.common.chat.entity.image.ImageContext; public interface IImageGenerationService { /** - * 根据文字生成图片 + * 根据文字生成图片(同步阻塞,返回 URL) */ String generateImage(@Valid ImageContext imageContext); + /** + * 异步启动图片生成(返回 prediction 信息,用于轮询进度) + * 默认回退到同步模式 + */ + default MediaGenerationResponse startImageGeneration(@Valid ImageContext imageContext) { + String url = generateImage(imageContext); + return MediaGenerationResponse.builder() + .type("image") + .url(url) + .status("completed") + .build(); + } + + /** + * 上传图生图/图生视频使用的临时媒体文件。 + * 不支持上传的供应商保持默认异常,避免静默回退到错误存储。 + */ + default String uploadMedia(ChatModelVo model, byte[] content, String fileName, String contentType) { + throw new UnsupportedOperationException("当前图片供应商不支持临时媒体上传"); + } + /** * 获取服务提供商名称 */ diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/video/IVideoGenerationService.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/video/IVideoGenerationService.java new file mode 100644 index 00000000..5a1aa035 --- /dev/null +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/video/IVideoGenerationService.java @@ -0,0 +1,17 @@ +package org.ruoyi.common.chat.service.video; + +import jakarta.validation.Valid; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.common.chat.entity.video.VideoContext; + +/** + * Public text-to-video service. + */ +public interface IVideoGenerationService { + + MediaGenerationResponse generateVideo(@Valid VideoContext videoContext); + + MediaGenerationResponse retrieveVideo(@Valid VideoContext videoContext); + + String getProviderName(); +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/ruoyi/common/core/service/OssService.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/ruoyi/common/core/service/OssService.java index 884f44e2..d3debd43 100644 --- a/ruoyi-common/ruoyi-common-core/src/main/java/org/ruoyi/common/core/service/OssService.java +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/ruoyi/common/core/service/OssService.java @@ -1,8 +1,11 @@ package org.ruoyi.common.core.service; +import jakarta.servlet.http.HttpServletResponse; import org.ruoyi.common.core.domain.dto.OssDTO; import org.springframework.web.multipart.MultipartFile; +import java.io.File; +import java.io.IOException; import java.util.List; /** @@ -33,5 +36,19 @@ public interface OssService { */ OssDTO uploadFile(MultipartFile file); + /** + * Upload a server-side file without buffering the complete payload in memory. + */ + OssDTO uploadFile(File file); + + /** + * Stream an object as an authenticated attachment response. + */ + void downloadFile(Long ossId, HttpServletResponse response) throws IOException; + + /** + * Delete one object and its sys_oss metadata. + */ + Boolean deleteFile(Long ossId); } diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/ruoyi/common/oss/core/OssClient.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/ruoyi/common/oss/core/OssClient.java index 87a9ca8f..0fbab047 100644 --- a/ruoyi-common/ruoyi-common-oss/src/main/java/org/ruoyi/common/oss/core/OssClient.java +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/ruoyi/common/oss/core/OssClient.java @@ -310,7 +310,7 @@ public class OssClient { client.deleteObject( x -> x.bucket(properties.getBucketName()) .key(removeBaseUrl(path)) - .build()); + .build()).join(); } catch (Exception e) { throw new OssException("删除文件失败,请检查配置信息:[" + e.getMessage() + "]"); } diff --git a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/WorkflowEngine.java b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/WorkflowEngine.java index 87ab1baf..24d0110f 100644 --- a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/WorkflowEngine.java +++ b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/WorkflowEngine.java @@ -265,10 +265,6 @@ public class WorkflowEngine { String node = streamingOutput.node(); String chunk = streamingOutput.chunk(); log.info("node:{},chunk:{}", node, chunk); - Map strMap = new HashMap<>(); - strMap.put("ck", chunk); -// SSEEmitterHelper.parseAndSendPartialMsg(sseEmitter, "[NODE_CHUNK_" + node + "]", strMap.toString()); - SSEEmitterHelper.parseAndSendPartialMsg(sseEmitter, "[NODE_CHUNK_" + node + "]", chunk); } else { AbstractWfNode abstractWfNode = wfState.getCompletedNodes().stream() diff --git a/ruoyi-modules/ruoyi-chat/docs/MCP工具模块接口文档.md b/ruoyi-modules/ruoyi-chat/docs/MCP工具模块接口文档.md deleted file mode 100644 index 9d8460b7..00000000 --- a/ruoyi-modules/ruoyi-chat/docs/MCP工具模块接口文档.md +++ /dev/null @@ -1,336 +0,0 @@ -# MCP工具管理模块 - API接口文档 - -## 概述 - -本文档描述了MCP工具管理模块的REST API接口,供前端开发人员参考。 - -## 基础信息 - -- **Base URL**: `/api/mcp` -- **认证方式**: Bearer Token (SaToken) -- **响应格式**: JSON - ---- - -## 1. MCP工具管理 - -### 1.1 查询工具列表(分页) - -**接口**: `GET /tool/list` - -**权限**: `mcp:tool:list` - -**请求参数**: -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| name | String | 否 | 工具名称(模糊查询) | -| description | String | 否 | 工具描述(模糊查询) | -| type | String | 否 | 工具类型:LOCAL/REMOTE/BUILTIN | -| status | String | 否 | 状态:0-启用, 1-禁用 | -| pageNum | Integer | 是 | 页码,默认1 | -| pageSize | Integer | 是 | 每页数量,默认10 | - -**响应示例**: -```json -{ - "rows": [ - { - "id": 1, - "name": "ReadFileTool", - "description": "读取文件内容工具", - "type": "BUILTIN", - "status": "0", - "configJson": null, - "createTime": "2026-03-08 10:00:00", - "updateTime": "2026-03-08 10:00:00" - } - ], - "total": 1 -} -``` - -### 1.2 查询工具列表(不分页) - -**接口**: `GET /tool/all` - -**权限**: `mcp:tool:list` - -**请求参数**: -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| keyword | String | 否 | 关键词 | -| type | String | 否 | 工具类型 | -| status | String | 否 | 状态 | - -**响应示例**: -```json -{ - "tools": [ - { - "id": 1, - "name": "ReadFileTool", - "description": "读取文件内容工具", - "type": "BUILTIN", - "status": "0" - } - ], - "total": 1 -} -``` - -### 1.3 获取工具详情 - -**接口**: `GET /tool/{id}` - -**权限**: `mcp:tool:query` - -**路径参数**: -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| id | Long | 是 | 工具ID | - -### 1.4 新增工具 - -**接口**: `POST /tool` - -**权限**: `mcp:tool:add` - -**请求体**: -```json -{ - "name": "MyMcpTool", - "description": "我的MCP工具", - "type": "REMOTE", - "status": "0", - "configJson": "{\"baseUrl\": \"http://localhost:8080/mcp\"}" -} -``` - -### 1.5 修改工具 - -**接口**: `PUT /tool` - -**权限**: `mcp:tool:edit` - -**请求体**: 同新增工具 - -### 1.6 删除工具 - -**接口**: `DELETE /tool/{ids}` - -**权限**: `mcp:tool:remove` - -**路径参数**: -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| ids | String | 是 | 工具ID,多个用逗号分隔 | - -### 1.7 更新工具状态 - -**接口**: `PUT /tool/{id}/status` - -**权限**: `mcp:tool:edit` - -**路径参数**: -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| id | Long | 是 | 工具ID | - -**请求参数**: -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| status | String | 是 | 状态:0-启用, 1-禁用 | - -### 1.8 测试工具连接 - -**接口**: `POST /tool/{id}/test` - -**权限**: `mcp:tool:query` - -**响应示例**: -```json -{ - "success": true, - "message": "连接测试成功", - "toolCount": 5, - "tools": ["tool1", "tool2", "tool3", "tool4", "tool5"] -} -``` - ---- - -## 2. MCP市场管理 - -### 2.1 查询市场列表 - -**接口**: `GET /market/list` - -**权限**: `mcp:market:list` - -**请求参数**: -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| name | String | 否 | 市场名称 | -| description | String | 否 | 市场描述 | -| status | String | 否 | 状态 | -| pageNum | Integer | 是 | 页码 | -| pageSize | Integer | 是 | 每页数量 | - -### 2.2 获取市场工具列表 - -**接口**: `GET /market/{marketId}/tools` - -**权限**: `mcp:market:query` - -**路径参数**: -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| marketId | Long | 是 | 市场ID | - -**请求参数**: -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| page | Integer | 否 | 页码,默认1 | -| size | Integer | 否 | 每页数量,默认10 | - -### 2.3 刷新市场工具 - -**接口**: `POST /market/{marketId}/refresh` - -**权限**: `mcp:market:edit` - -**响应示例**: -```json -{ - "success": true, - "message": "刷新成功", - "addedCount": 3, - "updatedCount": 5 -} -``` - -### 2.4 加载工具到本地 - -**接口**: `POST /market/tool/{toolId}/load` - -**权限**: `mcp:market:edit` - -**路径参数**: -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| toolId | Long | 是 | 市场工具ID | - -### 2.5 批量加载工具 - -**接口**: `POST /market/tools/batchLoad` - -**权限**: `mcp:market:edit` - -**请求体**: -```json -{ - "toolIds": [1, 2, 3] -} -``` - ---- - -## 3. 工具调用日志 - -### 3.1 查询调用日志 - -**接口**: `GET /tool/callLog` - -**权限**: `mcp:tool:query` - -**请求参数**: -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| toolId | Long | 否 | 工具ID | -| sessionId | Long | 否 | 会话ID | -| startDate | Date | 否 | 开始日期 | -| endDate | Date | 否 | 结束日期 | -| pageNum | Integer | 是 | 页码 | -| pageSize | Integer | 是 | 每页数量 | - -### 3.2 获取工具统计 - -**接口**: `GET /tool/{toolId}/metrics` - -**权限**: `mcp:tool:query` - -**响应示例**: -```json -{ - "toolId": 1, - "toolName": "ReadFileTool", - "today": { - "callCount": 100, - "successCount": 95, - "failureCount": 5, - "avgDurationMs": 150, - "successRate": 95.0 - }, - "week": { - "callCount": 500, - "successCount": 475, - "failureCount": 25, - "avgDurationMs": 160, - "successRate": 95.0 - } -} -``` - ---- - -## 4. 状态码说明 - -| 状态码 | 说明 | -|--------|------| -| 200 | 请求成功 | -| 401 | 未认证 | -| 403 | 无权限 | -| 404 | 资源不存在 | -| 500 | 服务器错误 | - ---- - -## 5. 前端页面需求 - -### 5.1 MCP工具管理页面 (`/mcp/tool`) - -**功能**: -- 工具列表展示(分页) -- 工具搜索和筛选 -- 新增/编辑/删除工具 -- 工具状态切换 -- 工具连接测试 - -**表格列**: -- 工具名称 -- 工具描述 -- 工具类型(标签显示) -- 状态(开关) -- 创建时间 -- 操作(编辑、删除、测试) - -### 5.2 MCP市场管理页面 (`/mcp/market`) - -**功能**: -- 市场列表展示 -- 市场工具浏览 -- 刷新市场工具 -- 加载工具到本地 - -### 5.3 工具调用日志页面 (`/mcp/log`) - -**功能**: -- 调用日志列表 -- 按工具/日期筛选 -- 成功率统计 -- 响应时间统计 - -**图表**: -- 每日调用次数趋势图 -- 工具调用成功率饼图 -- 平均响应时间柱状图 diff --git a/ruoyi-modules/ruoyi-chat/docs/数据库智能体实现总结.md b/ruoyi-modules/ruoyi-chat/docs/数据库智能体实现总结.md deleted file mode 100644 index 72cd4257..00000000 --- a/ruoyi-modules/ruoyi-chat/docs/数据库智能体实现总结.md +++ /dev/null @@ -1,152 +0,0 @@ -# 数据库操作智能体实现总结 - -## 概述 -基于 LangChain4j 的 **Pure agentic AI** 模式,完成了一个智能数据库查询系统。该系统能够根据用户的自然语言问题,自动分析数据库结构、生成查询计划并执行相应的数据库操作。 - -## 架构设计 - -### 1. 整体框架 -``` -用户请求 → SupervisorAgent(协调器) → SqlAgent(数据库专家) → 数据库工具 → 数据库 - ↓ - 结果处理与响应 -``` - -### 2. 核心组件 - -#### A. SqlAgent (数据库查询专家) -- **文件**: `org.ruoyi.agent.SqlAgent` -- **职责**: 根据用户的自然语言问题,调用相应的工具查询数据库 -- **使用的工具**: - - `QueryAllTablesTool`: 查询所有表名和注释 - - `QueryTableSchemaTool`: 查询表的DDL(CREATE TABLE语句) - - `ExecuteSqlQueryTool`: 执行SELECT查询 - -```java -public interface SqlAgent { - @SystemMessage("...") // 详细的系统提示 - @UserMessage("请回答以下问题:{{query}}") - @Agent("一个智能数据库查询助手...") - String getData(@V("query") String query); -} -``` - -#### B. SupervisorAgent (总体协调器) -- 在 `OpenAIServiceImpl.doAgent()` 中创建 -- 作用:协调 SqlAgent 的执行,管理任务流程 -- 响应策略:`SUMMARY` - 返回所有操作的摘要 - -```java -SupervisorAgent supervisor = AgenticServices - .supervisorBuilder() - .chatModel(PLANNER_MODEL) - .subAgents(sqlAgent) - .responseStrategy(SupervisorResponseStrategy.SUMMARY) - .build(); -``` - -#### C. 数据库工具 (Tools) - -##### 1. QueryAllTablesTool -```java -@Tool("Query all tables in the database and return table names and basic information") -public String queryAllTables() -``` -- 返回数据库中所有表的名称和注释 -- 使用注入的 `agentDataSource` DataSource - -##### 2. QueryTableSchemaTool -```java -@Tool("Query the CREATE TABLE statement (DDL) for a specific table by table name") -public String queryTableSchema(String tableName) -``` -- 返回指定表的建表SQL语句 -- 包含SQL注入防护(表名有效性验证) - -##### 3. ExecuteSqlQueryTool -```java -@Tool("Execute a SELECT SQL query and return the results. Example: SELECT * FROM sys_user") -public String executeSql(String sql) -``` -- 执行SELECT查询(安全性考虑,不允许执行其他操作) -- 格式化查询结果,最多显示前20行 - -### 3. 配置体系 - -#### AgentMysqlProperties -配置文件前缀:`agent.mysql` -```yaml -agent: - mysql: - enabled: true - url: jdbc:mysql://localhost:3306/your_database - username: your_username - password: your_password - max-pool-size: 10 - min-idle: 2 -``` - -#### AgentMysqlConfig -- 创建独立的 DataSource Bean (`agentDataSource`) -- 使用 HikariCP 连接池管理 -- 与项目主数据源隔离 - -#### TableSchemaManager -- 在应用启动时初始化表结构缓存 -- 使用 `ConcurrentHashMap` 存储结构信息 -- 支持按需刷新单个表的结构 - -## 工作流程示例 - -### 用户查询: "数据库有哪些表?" - -``` -1. SupervisorAgent 接收请求 - ↓ -2. SupervisorAgent 分析请求,决定调用 SqlAgent - ↓ -3. SqlAgent 理解需求,调用 QueryAllTablesTool - ↓ -4. QueryAllTablesTool 连接数据库,获取所有表 - ↓ -5. 结果返回给 SqlAgent - ↓ -6. SqlAgent 格式化结果 - ↓ -7. SupervisorAgent 生成最终摘要 - ↓ -8. 结果通过流式处理器返回给用户 -``` - -### 用户查询: "查询 sys_user 表中有多少条记录" - -``` -1. SqlAgent 接收请求 - ↓ -2. SqlAgent 分析需求,可能先调用 QueryTableSchemaTool 了解表结构 - ↓ -3. 然后调用 ExecuteSqlQueryTool 执行 "SELECT COUNT(*) FROM sys_user" - ↓ -4. 获取查询结果并返回 -``` - -## Agentic AI 特性 - -### 自适应决策 -- Agent 能根据上下文和之前的结果决定下一步操作 -- 不是预定义的固定流程,而是动态适应 - -### 例子 -当询问"查询部门表的字段信息时": -- SqlAgent 可能先调用 `QueryTableSchemaTool` 获取建表SQL -- 如果发现需要具体的数据示例,会继续调用 `ExecuteSqlQueryTool` -- 整个决策过程由 LLM 驱动,非硬编码 - -## 安全考虑 - -1. **数据源隔离**: Agent 使用独立的数据源(agentDataSource),与主应用隔离 -2. **SQL验证**: 只允许执行 SELECT 查询 -3. **表名验证**: 表名必须通过正则表达式验证(防止SQL注入) -4. **权限限制**: 可通过 AGENT_ALLOWED_TABLES 环境变量限制可访问的表 -5. **凭证管理**: 数据库凭证通过配置文件管理,不硬编码 - diff --git a/ruoyi-modules/ruoyi-chat/pom.xml b/ruoyi-modules/ruoyi-chat/pom.xml index f19e5a77..566d74be 100644 --- a/ruoyi-modules/ruoyi-chat/pom.xml +++ b/ruoyi-modules/ruoyi-chat/pom.xml @@ -44,7 +44,7 @@ dev.langchain4j langchain4j-agentic - ${langchain4j.community.version} + ${langchain4j.beta.version} @@ -69,7 +69,7 @@ dev.langchain4j langchain4j-document-parser-apache-tika - ${langchain4j.community.version} + ${langchain4j.beta.version} org.apache.commons @@ -82,39 +82,39 @@ dev.langchain4j langchain4j-milvus - ${langchain4j.community.version} + ${langchain4j.beta.version} dev.langchain4j langchain4j-weaviate - ${langchain4j.community.version} + ${langchain4j.beta.version} dev.langchain4j langchain4j-qdrant - ${langchain4j.community.version} + ${langchain4j.beta.version} dev.langchain4j langchain4j-mcp - ${langchain4j.community.version} + ${langchain4j.beta.version} dev.langchain4j langchain4j-skills - ${langchain4j.community.version} + ${langchain4j.beta.version} dev.langchain4j langchain4j-experimental-skills-shell - ${langchain4j.community.version} + ${langchain4j.beta.version} @@ -155,10 +155,24 @@ ${dify.version} - - com.zaxxer - HikariCP + com.coze + coze-api + ${coze.version} + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + + + io.jsonwebtoken + jjwt-jackson + + diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ShortDramaScriptAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ShortDramaScriptAgent.java new file mode 100644 index 00000000..7601feeb --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ShortDramaScriptAgent.java @@ -0,0 +1,51 @@ +package org.ruoyi.agent; + +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; +import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptResult; + +/** + * 短剧剧本打磨 Agent —— 使用 langchain4j AiServices 结构化输出 + *

+ * 框架自动生成 JSON Schema 并强制 LLM 返回符合结构的数据,无需手工 parse。 + * + * @author ageerle + */ +public interface ShortDramaScriptAgent { + + @SystemMessage(""" + 你是顶级短剧编剧和创意总监。请根据用户的一个创意想法,创作完整的短剧剧本。 + + 【核心原则 - 最高优先级】 + 1. 剧本必须完整、有张力、有画面感 + 2. 角色要有鲜明性格,不是工具人 + 3. 情节紧凑,每句台词都推动剧情 + 4. 场景描写具体,让分镜师能直接画出画面 + 5. 对话自然有力,避免废话 + + 【剧本格式】 + 使用标准剧本格式,包含以下元素: + 1. 场景头(Scene Heading):内景/外景+地点+时间,如"内景 客厅 清晨" + 2. 场景描述(Scene Description):简洁描述场景环境、布局、关键道具 + 3. 动作描述(Action):描述角色的动作、表情、行为,连续段落形式 + 4. 对话(Dialogue):角色名: 台词内容 + 5. 画外音(Voiceover):旁白、独白、回忆中的声音 + + 【剧本长度要求】 + - scriptText:1000-3000字,含完整的开场、冲突发展、高潮、结尾 + - outlineText:400-800字,概述完整故事线 + + 【角色塑造要求】 + - 每个角色要有明确的性格标签(如:霸道总裁、温柔女医、腹黑谋士) + - 角色之间要有清晰的关系和冲突 + - 对话要符合角色性格 + + 【情节要求】 + - 必须有清晰的冲突和反转 + - 情绪节奏要有起伏(紧张→舒缓→爆发) + - 结尾要有记忆点(反转/留白/情感升华) + """) + @UserMessage("用户期望项目名:{{projectName}}\n用户创意:{{idea}}") + ShortDramaScriptResult polish(@V("projectName") String projectName, @V("idea") String idea); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/config/agent/SkillsPathResolver.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/config/agent/SkillsPathResolver.java new file mode 100644 index 00000000..83f3cf50 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/config/agent/SkillsPathResolver.java @@ -0,0 +1,32 @@ +package org.ruoyi.config.agent; + +import java.nio.file.Path; + +/** + * 磁盘 Skills 目录路径解析器 + *

+ * langchain4j 的 ShellSkills 通过 FileSystemSkillLoader 从磁盘加载 SKILL.md, + * 路径硬编码在 ChatServiceFacade 中。抽到此工具类供智能体管理端与聊天流程共用, + * 避免两处路径漂移。 + * + * @author ruoyi team + */ +public final class SkillsPathResolver { + + private SkillsPathResolver() { + } + + /** + * skills 目录相对项目根目录的路径 + */ + private static final String SKILLS_RELATIVE_PATH = "ruoyi-admin/src/main/resources/skills"; + + /** + * 返回磁盘 skills 目录的绝对路径 + */ + public static Path resolveSkillsPath() { + String userDir = System.getProperty("user.dir"); + return Path.of(userDir, SKILLS_RELATIVE_PATH); + } + +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/constant/ShortDramaImageConstants.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/constant/ShortDramaImageConstants.java new file mode 100644 index 00000000..619b7bef --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/constant/ShortDramaImageConstants.java @@ -0,0 +1,72 @@ +package org.ruoyi.constant; + +/** + * 短剧图片资产常量 — 三视图 prompt 工程 + * + * @author ruoyi + */ +public final class ShortDramaImageConstants { + + private ShortDramaImageConstants() {} + + /** + * 角色三视图 prompt 前缀,生图时自动拼到用户 prompt 之前。 + * 把风格和构图指令放最前面,避免被角色描述稀释导致画风漂移。 + */ + public static final String CHARACTER_PROMPT_PREFIX = + "character design sheet, multiple views reference sheet, " + + "front view / side view / back view full body, " + + "clean white background, no props, no text. "; + + /** + * 角色三视图 prompt 后缀,生图时自动追加到用户 prompt 之后。 + * 左侧1/3正面特写 + 右侧2/3三视图横向排列(正面全身、侧面全身、背面全身)。 + */ + public static final String CHARACTER_PROMPT_SUFFIX = + "。角色设定图,画面分为左右两个区域:" + + "【左侧区域】占约1/3宽度,是角色的正面特写" + + "(完整正脸,最具辨识度的正面形态);" + + "【右侧区域】占约2/3宽度,是角色三视图横向排列" + + "(从左到右依次为:正面全身、侧面全身、背面全身)," + + "三视图高度一致。纯白色背景,无其他元素。"; + + /** 场景图 prompt 前缀 */ + public static final String LOCATION_PROMPT_PREFIX = "宽广空间全景,"; + + /** 场景图 prompt 后缀 */ + public static final String LOCATION_PROMPT_SUFFIX = ",禁止出现任何角色,纯背景板"; + + /** 每个资产最多保留的图片变体数量 */ + public static final int MAX_IMAGE_VARIANTS = 20; + + // ==================== 视觉风格 ==================== + + /** 项目视觉风格 → 生图 prompt 后缀映射,确保同项目所有图片风格一致 */ + public static final java.util.Map ART_STYLE_PROMPTS = java.util.Map.of( + "american-comic", "美式漫画风格,粗线条,高饱和度色彩,强烈光影对比", + "chinese-comic", "现代国漫动画风格,Chinese donghua 2D comic style,赛璐璐平涂上色,干净锐利的黑色线稿,平面化光影无真实景深,动漫人物比例(略放大双眼、修长身形),皮肤平滑无毛孔无写实肤质,国风服饰剪裁与材质细节清晰,色彩饱满通透,画面精致干净;禁止真人写实、摄影实拍、3D渲染、CGI、厚涂油画、写实皮肤纹理、景深虚化", + "japanese-anime", "现代日系动漫风格,赛璐璐上色,清晰干净的线条,视觉小说CG感,高质量2D风格", + "realistic", "真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感" + ); + + /** 默认视觉风格 */ + public static final String DEFAULT_ART_STYLE = "realistic"; + + /** 查找 artStyle 对应的 prompt 后缀,找不到返回空字符串 */ + public static String artStylePrompt(String artStyle) { + if (artStyle == null) return ""; + return ART_STYLE_PROMPTS.getOrDefault(artStyle, ""); + } + + /** + * 由项目 id 派生一个稳定的生图随机种子。 + * 同项目内所有角色/形象共用同一颗种子,渲染基调(光影、配色、笔触)更趋一致; + * 不同项目派生不同种子,避免跨项目撞图。返回值落在 [0, 2_000_000_000),兼容各供应商。 + */ + public static Integer styleSeed(Long projectId) { + if (projectId == null) return null; + long h = projectId; + h ^= (h >>> 32); + return (int) Math.floorMod(h * 2654435761L, 2_000_000_000L); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/agent/AgentController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/agent/AgentController.java new file mode 100644 index 00000000..972b7d9d --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/agent/AgentController.java @@ -0,0 +1,130 @@ +package org.ruoyi.controller.agent; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.common.excel.utils.ExcelUtil; +import org.ruoyi.common.idempotent.annotation.RepeatSubmit; +import org.ruoyi.common.log.annotation.Log; +import org.ruoyi.common.log.enums.BusinessType; +import org.ruoyi.common.mybatis.core.page.PageQuery; +import org.ruoyi.common.mybatis.core.page.TableDataInfo; +import org.ruoyi.common.web.core.BaseController; +import org.ruoyi.domain.bo.agent.AgentBo; +import org.ruoyi.domain.vo.agent.AgentVo; +import org.ruoyi.domain.vo.agent.SkillOptionVo; +import org.ruoyi.service.agent.IAgentService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; + +import java.util.List; + +/** + * 智能体管理 Controller + * + * @author ruoyi team + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/agent/agent") +public class AgentController extends BaseController { + + private final IAgentService agentService; + + /** + * 分页查询智能体列表 + */ + @SaCheckPermission("agent:agent:list") + @GetMapping("/list") + public TableDataInfo list(AgentBo bo, PageQuery pageQuery) { + return agentService.queryPageList(bo, pageQuery); + } + + /** + * 查询智能体列表(不分页,用于导出) + */ + @SaCheckPermission("agent:agent:list") + @GetMapping("/queryList") + public R> queryList(AgentBo bo) { + return R.ok(agentService.queryList(bo)); + } + + /** + * 导出智能体列表 + */ + @SaCheckPermission("agent:agent:export") + @Log(title = "智能体管理", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(AgentBo bo, HttpServletResponse response) { + List list = agentService.queryList(bo); + ExcelUtil.exportExcel(list, "智能体", AgentVo.class, response); + } + + /** + * 根据ID获取智能体详情 + */ + @SaCheckPermission("agent:agent:query") + @GetMapping("/{id}") + public R getInfo(@PathVariable Long id) { + return R.ok(agentService.queryById(id)); + } + + /** + * 新增智能体 + */ + @SaCheckPermission("agent:agent:add") + @Log(title = "智能体管理", businessType = BusinessType.INSERT) + @RepeatSubmit + @PostMapping + public R add(@Validated @RequestBody AgentBo bo) { + return toAjax(agentService.insertByBo(bo)); + } + + /** + * 修改智能体 + */ + @SaCheckPermission("agent:agent:edit") + @Log(title = "智能体管理", businessType = BusinessType.UPDATE) + @RepeatSubmit + @PutMapping + public R edit(@Validated @RequestBody AgentBo bo) { + return toAjax(agentService.updateByBo(bo)); + } + + /** + * 删除智能体 + */ + @SaCheckPermission("agent:agent:remove") + @Log(title = "智能体管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@PathVariable Long[] ids) { + return toAjax(agentService.deleteByIds(List.of(ids))); + } + + /** + * 用户端聊天页智能体下拉选项(启用状态,不需权限校验) + */ + @GetMapping("/agentOptions") + public R> agentOptions() { + return R.ok(agentService.queryEnabledOptions()); + } + + /** + * 列出磁盘上可用的 Skills(供管理端表单勾选) + */ + @SaCheckPermission("agent:agent:list") + @GetMapping("/skillOptions") + public R> skillOptions() { + return R.ok(agentService.listSkillOptions()); + } + +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java index adf4322b..565cd953 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java @@ -9,6 +9,7 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import org.ruoyi.common.chat.service.chat.IChatModelService; import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.core.utils.StringUtils; import org.ruoyi.enums.ChatModeType; import org.ruoyi.enums.ModelType; import org.springframework.web.bind.annotation.*; @@ -54,7 +55,9 @@ public class ChatModelController extends BaseController { */ @GetMapping("/modelList") public R> modelList(ChatModelBo bo) { - bo.setCategory(ModelType.CHAT.getKey()); + if (StringUtils.isBlank(bo.getCategory())) { + bo.setCategory(ModelType.CHAT.getKey()); + } return R.ok(chatModelService.queryList(bo)); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/MediaGenerationController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/MediaGenerationController.java new file mode 100644 index 00000000..b2f032ab --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/MediaGenerationController.java @@ -0,0 +1,141 @@ +package org.ruoyi.controller.chat; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.entity.audio.AudioContext; +import org.ruoyi.common.chat.entity.image.ImageContext; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.common.chat.entity.video.VideoContext; +import org.ruoyi.common.chat.factory.AudioServiceFactory; +import org.ruoyi.common.chat.factory.ImageServiceFactory; +import org.ruoyi.common.chat.factory.VideoServiceFactory; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.domain.bo.media.ImageGenerationRequest; +import org.ruoyi.domain.bo.media.SpeechGenerationRequest; +import org.ruoyi.domain.bo.media.VideoGenerationRequest; +import org.ruoyi.enums.ModelType; +import org.ruoyi.service.media.AtlasPredictionService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/media") +public class MediaGenerationController { + + private final IChatModelService chatModelService; + private final AudioServiceFactory audioServiceFactory; + private final ImageServiceFactory imageServiceFactory; + private final VideoServiceFactory videoServiceFactory; + private final AtlasPredictionService atlasPredictionService; + + @PostMapping("/speech") + public R speech(@Valid @RequestBody SpeechGenerationRequest request) { + ChatModelVo model = loadModel(request.getModel(), ModelType.AUDIO.getKey()); + MediaGenerationResponse response = audioServiceFactory.getOriginalService(model.getProviderCode()) + .generateSpeech(AudioContext.builder() + .chatModelVo(model) + .input(request.getInput()) + .voice(request.getVoice()) + .responseFormat(request.getResponseFormat()) + .speed(request.getSpeed()) + .instructions(request.getInstructions()) + .build()); + return R.ok(response); + } + + @PostMapping("/image") + public R image(@Valid @RequestBody ImageGenerationRequest request) { + ChatModelVo model = loadModel(request.getModel(), ModelType.IMAGE.getKey()); + String result = imageServiceFactory.getOriginalService(model.getProviderCode()) + .generateImage(ImageContext.builder() + .chatModelVo(model) + .prompt(request.getPrompt()) + .size(request.getSize()) + .seed(request.getSeed()) + .build()); + return R.ok(toImageResponse(result)); + } + + @PostMapping("/video") + public R video(@Valid @RequestBody VideoGenerationRequest request) { + ChatModelVo model = loadModel(request.getModel(), ModelType.VIDEO.getKey()); + MediaGenerationResponse response = videoServiceFactory.getOriginalService(model.getProviderCode()) + .generateVideo(VideoContext.builder() + .chatModelVo(model) + .prompt(request.getPrompt()) + .size(request.getSize()) + .seconds(request.getSeconds()) + .quality(request.getQuality()) + .build()); + return R.ok(response); + } + + @GetMapping("/video") + public R videoResult(@NotBlank(message = "模型不能为空") @RequestParam String model, + @NotBlank(message = "videoId不能为空") @RequestParam String videoId) { + ChatModelVo chatModelVo = loadModel(model, ModelType.VIDEO.getKey()); + MediaGenerationResponse response = videoServiceFactory.getOriginalService(chatModelVo.getProviderCode()) + .retrieveVideo(VideoContext.builder() + .chatModelVo(chatModelVo) + .prompt("retrieve") + .videoId(videoId) + .build()); + return R.ok(response); + } + + @GetMapping("/prediction") + public R prediction(@NotBlank(message = "模型不能为空") @RequestParam String model, + @NotBlank(message = "predictionId不能为空") @RequestParam String predictionId) { + ChatModelVo chatModelVo = chatModelService.selectModelByName(model); + if (chatModelVo == null) { + throw new IllegalArgumentException("未找到模型配置: " + model); + } + return R.ok(atlasPredictionService.retrieve(chatModelVo, predictionId)); + } + + private ChatModelVo loadModel(String modelName, String category) { + ChatModelVo model = chatModelService.selectModelByName(modelName); + if (model == null) { + throw new IllegalArgumentException("未找到模型配置: " + modelName); + } + if (!category.equals(model.getCategory())) { + throw new IllegalArgumentException("模型分类不匹配,期望: " + category + ", 实际: " + model.getCategory()); + } + return model; + } + + private MediaGenerationResponse toImageResponse(String result) { + if (result != null && result.startsWith("{")) { + try { + return atlasPredictionService.toResponse(result, "image"); + } catch (Exception e) { + throw new RuntimeException("图片生成响应解析失败: " + e.getMessage(), e); + } + } + if (result != null && result.startsWith("data:")) { + String mimeType = result.substring("data:".length(), result.indexOf(";base64,")); + String b64 = result.substring(result.indexOf(";base64,") + ";base64,".length()); + return MediaGenerationResponse.builder() + .type("image") + .mimeType(mimeType) + .b64Json(b64) + .dataUrl(result) + .build(); + } + return MediaGenerationResponse.builder() + .type("image") + .mimeType("image/png") + .url(result) + .build(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java new file mode 100644 index 00000000..5e6d6ce6 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java @@ -0,0 +1,377 @@ +package org.ruoyi.controller.shortdrama; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.common.core.exception.ServiceException; +import org.ruoyi.common.core.service.OssService; +import org.ruoyi.common.satoken.utils.LoginHelper; +import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaComposeVideoBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaLocationVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaProjectVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaStoryboardVo; +import org.ruoyi.service.shortdrama.IShortDramaService; +import org.ruoyi.service.shortdrama.IShortDramaVideoComposeService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/short-drama") +public class ShortDramaController { + + private final IShortDramaService shortDramaService; + + private final IShortDramaVideoComposeService videoComposeService; + + private final OssService ossService; + + // ==================== 项目 ==================== + + @GetMapping("/projects") + public R> projects() { + return R.ok(shortDramaService.listProjects(LoginHelper.getUserId())); + } + + @GetMapping("/{projectId}") + public R detail(@PathVariable Long projectId) { + return R.ok(shortDramaService.getDetail(projectId, LoginHelper.getUserId())); + } + + @PostMapping("/create-from-idea") + public R createFromIdea(@Valid @RequestBody ShortDramaIdeaBo bo) { + return R.ok(shortDramaService.createFromIdea(bo, LoginHelper.getUserId())); + } + + /** SSE 流式创建:逐阶段推送进度,避免用户等待焦虑 */ + @PostMapping("/create-from-idea/stream") + public SseEmitter createFromIdeaStream(@Valid @RequestBody ShortDramaIdeaBo bo) { + return shortDramaService.createFromIdeaStream(bo, LoginHelper.getUserId()); + } + + @PostMapping("/project") + public R saveProject(@Valid @RequestBody ShortDramaProjectBo bo) { + return R.ok(shortDramaService.saveProject(bo, LoginHelper.getUserId())); + } + + @PutMapping("/project") + public R updateProject(@Valid @RequestBody ShortDramaProjectBo bo) { + return R.ok(shortDramaService.saveProject(bo, LoginHelper.getUserId())); + } + + @DeleteMapping("/project/{projectId}") + public R deleteProject(@NotNull @PathVariable Long projectId) { + shortDramaService.deleteProject(projectId, LoginHelper.getUserId()); + return R.ok(); + } + + // ==================== 剧本 ==================== + + @PostMapping("/script") + public R saveScript(@Valid @RequestBody ShortDramaScriptBo bo) { + return R.ok(shortDramaService.saveScript(bo, LoginHelper.getUserId())); + } + + // ==================== 分镜 ==================== + + @PostMapping("/storyboards/generate") + public R> generate(@NotNull @RequestParam Long projectId, + @NotNull @RequestParam Long scriptId, + @RequestParam(required = false) String model) { + return R.ok(shortDramaService.generateStoryboards(projectId, scriptId, model, LoginHelper.getUserId())); + } + + @PostMapping("/storyboard") + public R saveStoryboard(@Valid @RequestBody ShortDramaStoryboardBo bo) { + return R.ok(shortDramaService.saveStoryboard(bo, LoginHelper.getUserId())); + } + + @PostMapping("/storyboard/{storyboardId}/generate-video") + public R generateVideo(@NotNull @PathVariable Long storyboardId, + @NotBlank @RequestParam String model) { + return R.ok(shortDramaService.generateVideo(storyboardId, model, LoginHelper.getUserId())); + } + + @GetMapping("/storyboard/{storyboardId}/video-result") + public R videoResult(@NotNull @PathVariable Long storyboardId, + @NotBlank @RequestParam String model) { + return R.ok(shortDramaService.retrieveVideo(storyboardId, model, LoginHelper.getUserId())); + } + + @PostMapping("/{projectId}/generate-all-videos") + public R> generateAllVideos(@NotNull @PathVariable Long projectId, + @NotBlank @RequestParam String model) { + return R.ok(shortDramaService.generateAllVideos(projectId, model, LoginHelper.getUserId())); + } + + @PostMapping("/{projectId}/compose-video") + public R composeVideo(@NotNull @PathVariable Long projectId, + @Valid @RequestBody ShortDramaComposeVideoBo bo) { + return R.ok(videoComposeService.composeVideo(projectId, bo, LoginHelper.getUserId())); + } + + @GetMapping("/{projectId}/compose-video") + public R getComposedVideo(@NotNull @PathVariable Long projectId) { + return R.ok(videoComposeService.getComposedVideo(projectId, LoginHelper.getUserId())); + } + + @GetMapping("/{projectId}/compose-video/download") + public void downloadComposedVideo(@NotNull @PathVariable Long projectId, HttpServletResponse response) + throws IOException { + ShortDramaComposeVideoVo composition = videoComposeService.getComposedVideo(projectId, LoginHelper.getUserId()); + if (composition == null || !"done".equals(composition.getStatus())) { + throw new ServiceException("成片尚未生成完成"); + } + if (composition.getVideoOssId() != null) { + ossService.downloadFile(composition.getVideoOssId(), response); + return; + } + Path localVideo = videoComposeService.getLocalComposedVideo(projectId, LoginHelper.getUserId()); + response.setContentType("video/mp4"); + response.setHeader("Content-Disposition", "attachment; filename=short-drama-" + projectId + ".mp4"); + response.setContentLengthLong(Files.size(localVideo)); + Files.copy(localVideo, response.getOutputStream()); + } + + // ==================== 阶段式流水线端点 ==================== + + /** Phase 1: 剧本打磨 */ + @PostMapping("/{projectId}/polish-script") + public R polishScript(@NotNull @PathVariable Long projectId) { + return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId())); + } + + // ==================== 资产分析 ==================== + + /** Phase 2: 资产分析(角色+场景提取) */ + @PostMapping("/{projectId}/analyze-assets") + public R analyzeAssets(@NotNull @PathVariable Long projectId, + @NotNull @RequestParam Long scriptId) { + return R.ok(shortDramaService.analyzeAssets(projectId, scriptId, LoginHelper.getUserId())); + } + + // ==================== 分镜流水线 ==================== + + /** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */ + @PostMapping("/{projectId}/plan-storyboard") + public R> planStoryboard(@NotNull @PathVariable Long projectId, + @NotNull @RequestParam Long scriptId, + @RequestParam(required = false) String model) { + return R.ok(shortDramaService.planStoryboard(projectId, scriptId, model, LoginHelper.getUserId())); + } + + /** Phase 3-6: SSE 流式生成分镜,持续推送规划和细化进度 */ + @PostMapping("/{projectId}/plan-storyboard/stream") + public SseEmitter planStoryboardStream(@NotNull @PathVariable Long projectId, + @NotNull @RequestParam Long scriptId, + @RequestParam(required = false) String model) { + return shortDramaService.planStoryboardStream(projectId, scriptId, model, LoginHelper.getUserId()); + } + + /** Phase 4: 重新生成摄影规则 */ + @PostMapping("/{projectId}/photography-rules") + public R> generatePhotographyRules(@NotNull @PathVariable Long projectId, + @NotNull @RequestParam Long scriptId) { + return R.ok(shortDramaService.generatePhotographyRules(projectId, scriptId, LoginHelper.getUserId())); + } + + /** Phase 5: 重新生成表演指导 */ + @PostMapping("/{projectId}/acting-directions") + public R> generateActingDirections(@NotNull @PathVariable Long projectId, + @NotNull @RequestParam Long scriptId) { + return R.ok(shortDramaService.generateActingDirections(projectId, scriptId, LoginHelper.getUserId())); + } + + // ==================== 角色管理 ==================== + + @PostMapping("/character") + public R saveCharacter(@Valid @RequestBody ShortDramaCharacterBo bo) { + return R.ok(shortDramaService.saveCharacter(bo, LoginHelper.getUserId())); + } + + @PutMapping("/character") + public R updateCharacter(@Valid @RequestBody ShortDramaCharacterBo bo) { + return R.ok(shortDramaService.saveCharacter(bo, LoginHelper.getUserId())); + } + + @DeleteMapping("/character/{characterId}") + public R deleteCharacter(@NotNull @PathVariable Long characterId) { + shortDramaService.deleteCharacter(characterId, LoginHelper.getUserId()); + return R.ok(); + } + + @PostMapping("/character/{characterId}/generate-image") + public R generateCharacterImage(@NotNull @PathVariable Long characterId, + @NotBlank @RequestParam String model, + @RequestParam(required = false) String referenceImageUrl) { + return R.ok(shortDramaService.generateCharacterImage(characterId, model, referenceImageUrl, LoginHelper.getUserId())); + } + + // ==================== 角色形象管理 ==================== + + @PostMapping("/character-appearance") + public R saveAppearance(@Valid @RequestBody ShortDramaCharacterAppearanceBo bo) { + return R.ok(shortDramaService.saveAppearance(bo, LoginHelper.getUserId())); + } + + @PutMapping("/character-appearance") + public R updateAppearance(@Valid @RequestBody ShortDramaCharacterAppearanceBo bo) { + return R.ok(shortDramaService.saveAppearance(bo, LoginHelper.getUserId())); + } + + @DeleteMapping("/character-appearance/{appearanceId}") + public R deleteAppearance(@NotNull @PathVariable Long appearanceId) { + shortDramaService.deleteAppearance(appearanceId, LoginHelper.getUserId()); + return R.ok(); + } + + @PostMapping("/character-appearance/{appearanceId}/generate-image") + public R generateAppearanceImage(@NotNull @PathVariable Long appearanceId, + @NotBlank @RequestParam String model, + @RequestParam(required = false) String referenceImageUrl) { + return R.ok(shortDramaService.generateAppearanceImage(appearanceId, model, referenceImageUrl, LoginHelper.getUserId())); + } + + @PostMapping("/character-appearance/{appearanceId}/regenerate") + public R regenerateAppearanceImage(@NotNull @PathVariable Long appearanceId, + @NotBlank @RequestParam String model, + @RequestParam(required = false) String referenceImageUrl) { + return R.ok(shortDramaService.regenerateAppearanceImage(appearanceId, model, referenceImageUrl, LoginHelper.getUserId())); + } + + @PostMapping("/character-appearance/{appearanceId}/select-image") + public R selectAppearanceImage(@NotNull @PathVariable Long appearanceId, + @NotNull @RequestParam Integer index) { + return R.ok(shortDramaService.selectAppearanceImage(appearanceId, index, LoginHelper.getUserId())); + } + + @DeleteMapping("/character-appearance/{appearanceId}/image") + public R deleteAppearanceImage(@NotNull @PathVariable Long appearanceId, + @NotNull @RequestParam Integer index) { + return R.ok(shortDramaService.deleteAppearanceImage(appearanceId, index, LoginHelper.getUserId())); + } + + @PostMapping("/character-appearance/{appearanceId}/undo-image") + public R undoAppearanceImage(@NotNull @PathVariable Long appearanceId) { + return R.ok(shortDramaService.undoAppearanceImage(appearanceId, LoginHelper.getUserId())); + } + + // ==================== 场景管理 ==================== + + @PostMapping("/location") + public R saveLocation(@Valid @RequestBody ShortDramaLocationBo bo) { + return R.ok(shortDramaService.saveLocation(bo, LoginHelper.getUserId())); + } + + @PutMapping("/location") + public R updateLocation(@Valid @RequestBody ShortDramaLocationBo bo) { + return R.ok(shortDramaService.saveLocation(bo, LoginHelper.getUserId())); + } + + @DeleteMapping("/location/{locationId}") + public R deleteLocation(@NotNull @PathVariable Long locationId) { + shortDramaService.deleteLocation(locationId, LoginHelper.getUserId()); + return R.ok(); + } + + @PostMapping("/location/{locationId}/generate-image") + public R generateLocationImage(@NotNull @PathVariable Long locationId, + @NotBlank @RequestParam String model, + @RequestParam(required = false) String referenceImageUrl) { + return R.ok(shortDramaService.generateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId())); + } + + @PostMapping("/location/{locationId}/regenerate") + public R regenerateLocationImage(@NotNull @PathVariable Long locationId, + @NotBlank @RequestParam String model, + @RequestParam(required = false) String referenceImageUrl) { + return R.ok(shortDramaService.regenerateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId())); + } + + @PostMapping("/location/{locationId}/select-image") + public R selectLocationImage(@NotNull @PathVariable Long locationId, + @NotNull @RequestParam Integer index) { + return R.ok(shortDramaService.selectLocationImage(locationId, index, LoginHelper.getUserId())); + } + + @DeleteMapping("/location/{locationId}/image") + public R deleteLocationImage(@NotNull @PathVariable Long locationId, + @NotNull @RequestParam Integer index) { + return R.ok(shortDramaService.deleteLocationImage(locationId, index, LoginHelper.getUserId())); + } + + @PostMapping("/location/{locationId}/undo-image") + public R undoLocationImage(@NotNull @PathVariable Long locationId) { + return R.ok(shortDramaService.undoLocationImage(locationId, LoginHelper.getUserId())); + } + + // ==================== 异步图片生成(轮询进度) ==================== + + /** 上传本地照片到图片供应商,返回当前生成会话使用的临时 URL。 */ + @PostMapping(value = "/image/upload-reference", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R uploadReferenceImage(@RequestPart("file") MultipartFile file, + @NotBlank @RequestParam String model) { + String temporaryUrl = shortDramaService.uploadReferenceImage(file, model, LoginHelper.getUserId()); + return R.ok("上传成功", temporaryUrl); + } + + /** 异步启动图片生成,返回 predictionId 供前端轮询 */ + @PostMapping("/image/start") + public R startImage(@NotBlank @RequestParam String assetType, + @NotNull @RequestParam Long assetId, + @NotBlank @RequestParam String model, + @RequestParam(required = false) String referenceImageUrl) { + return R.ok(shortDramaService.startImageGeneration(assetType, assetId, model, referenceImageUrl, LoginHelper.getUserId())); + } + + /** 轮询确认形象图片并保存 */ + @PostMapping("/character-appearance/{id}/confirm-image") + public R confirmAppearanceImage(@NotNull @PathVariable Long id, + @NotBlank @RequestParam String predictionId, + @NotBlank @RequestParam String model) { + return R.ok(shortDramaService.confirmAppearanceImage(id, predictionId, model, LoginHelper.getUserId())); + } + + /** 轮询确认场景图片并保存 */ + @PostMapping("/location/{id}/confirm-image") + public R confirmLocationImage(@NotNull @PathVariable Long id, + @NotBlank @RequestParam String predictionId, + @NotBlank @RequestParam String model) { + return R.ok(shortDramaService.confirmLocationImage(id, predictionId, model, LoginHelper.getUserId())); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/agent/AgentBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/agent/AgentBo.java new file mode 100644 index 00000000..4146dde0 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/agent/AgentBo.java @@ -0,0 +1,95 @@ +package org.ruoyi.domain.bo.agent; + +import io.github.linpeilie.annotations.AutoMapper; +import io.github.linpeilie.annotations.AutoMapping; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; +import org.ruoyi.domain.entity.agent.Agent; + +import java.io.Serial; +import java.util.List; + +/** + * 智能体业务对象 + * + * @author ruoyi team + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = Agent.class, reverseConvertGenerate = false) +public class AgentBo extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 智能体ID + */ + private Long id; + + /** + * 智能体名称 + */ + @NotBlank(message = "智能体名称不能为空") + @Size(min = 0, max = 200, message = "智能体名称不能超过{max}个字符") + private String agentName; + + /** + * 智能体描述 + */ + private String agentDescribe; + + /** + * 展示图标/头像URL + */ + private String agentShow; + + /** + * 绑定的聊天模型ID + */ + @NotNull(message = "绑定模型不能为空") + private Long modelId; + + /** + * 是否启用深度思考:0 否 1 是 + */ + private String enableThinking; + + /** + * 自定义系统提示词 + */ + private String systemPrompt; + + /** + * 关联MCP工具ID列表 + */ + @AutoMapping(target = "mcpToolIds", expression = "java(org.ruoyi.common.json.utils.JsonUtils.toJsonString(source.getMcpToolIds()))") + private List mcpToolIds; + + /** + * 关联磁盘技能名列表 + */ + @AutoMapping(target = "skillNames", expression = "java(org.ruoyi.common.json.utils.JsonUtils.toJsonString(source.getSkillNames()))") + private List skillNames; + + /** + * 关联知识库ID列表 + */ + @AutoMapping(target = "knowledgeIds", expression = "java(org.ruoyi.common.json.utils.JsonUtils.toJsonString(source.getKnowledgeIds()))") + private List knowledgeIds; + + /** + * 状态:0 正常 1 停用 + */ + private String status; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/ImageGenerationRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/ImageGenerationRequest.java new file mode 100644 index 00000000..b3c44665 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/ImageGenerationRequest.java @@ -0,0 +1,22 @@ +package org.ruoyi.domain.bo.media; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Data; + +@Data +public class ImageGenerationRequest { + + @NotBlank(message = "模型不能为空") + private String model; + + @NotBlank(message = "提示词不能为空") + private String prompt; + + private String size; + + @Min(value = 0, message = "随机种子不能小于0") + @Max(value = 2147483647, message = "随机种子不能大于2147483647") + private Integer seed; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/SpeechGenerationRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/SpeechGenerationRequest.java new file mode 100644 index 00000000..1683bb6a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/SpeechGenerationRequest.java @@ -0,0 +1,22 @@ +package org.ruoyi.domain.bo.media; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class SpeechGenerationRequest { + + @NotBlank(message = "模型不能为空") + private String model; + + @NotBlank(message = "输入文本不能为空") + private String input; + + private String voice; + + private String responseFormat; + + private Double speed; + + private String instructions; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/VideoGenerationRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/VideoGenerationRequest.java new file mode 100644 index 00000000..df2f45d5 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/VideoGenerationRequest.java @@ -0,0 +1,20 @@ +package org.ruoyi.domain.bo.media; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class VideoGenerationRequest { + + @NotBlank(message = "模型不能为空") + private String model; + + @NotBlank(message = "提示词不能为空") + private String prompt; + + private String size; + + private Integer seconds; + + private String quality; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterAppearanceBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterAppearanceBo.java new file mode 100644 index 00000000..7ff73f92 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterAppearanceBo.java @@ -0,0 +1,35 @@ +package org.ruoyi.domain.bo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacterAppearance; + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = ShortDramaCharacterAppearance.class, reverseConvertGenerate = false) +public class ShortDramaCharacterAppearanceBo extends BaseEntity { + + private Long id; + + private Long characterId; + + private Integer appearanceIndex; + + private String changeReason; + + private String description; + + private String referenceImageUrl; + + private String imageUrls; + + private String imageDescriptions; + + private Integer selectedImageIndex; + + private String previousImageUrls; + + private String previousDescriptions; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterBo.java new file mode 100644 index 00000000..bc988198 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterBo.java @@ -0,0 +1,37 @@ +package org.ruoyi.domain.bo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter; + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = ShortDramaCharacter.class, reverseConvertGenerate = false) +public class ShortDramaCharacterBo extends BaseEntity { + + private Long id; + + private Long projectId; + + private String name; + + private String aliases; + + private String introduction; + + private String roleLevel; + + private String gender; + + private String ageRange; + + private String personalityTags; + + private Integer costumeTier; + + private String visualDescription; + + private String referenceImageUrl; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaComposeVideoBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaComposeVideoBo.java new file mode 100644 index 00000000..363fb8c4 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaComposeVideoBo.java @@ -0,0 +1,29 @@ +package org.ruoyi.domain.bo.shortdrama; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +@Data +public class ShortDramaComposeVideoBo { + + @NotBlank(message = "转场类型不能为空") + @Pattern(regexp = "none|dissolve|fade|slide", message = "不支持的转场类型") + private String transitionType = "dissolve"; + + @NotNull(message = "转场时长不能为空") + @DecimalMin(value = "0.0", message = "转场时长不能小于0秒") + private BigDecimal transitionDurationSeconds = new BigDecimal("0.5"); + + @NotBlank(message = "成片画幅不能为空") + @Pattern(regexp = "9:16|16:9|1:1", message = "不支持的成片画幅") + private String aspectRatio = "9:16"; + @Size(min = 2, message = "至少选择2个分镜视频") + private List storyboardIds; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java new file mode 100644 index 00000000..b6eeb4f7 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java @@ -0,0 +1,20 @@ +package org.ruoyi.domain.bo.shortdrama; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ShortDramaIdeaBo { + + @NotBlank(message = "创意想法不能为空") + private String idea; + + @NotBlank(message = "模型不能为空") + private String model; + + private String projectName; + + private String artStyle; + + private String aspectRatio; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaLocationBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaLocationBo.java new file mode 100644 index 00000000..23f2aa77 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaLocationBo.java @@ -0,0 +1,41 @@ +package org.ruoyi.domain.bo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; +import org.ruoyi.domain.entity.shortdrama.ShortDramaLocation; + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = ShortDramaLocation.class, reverseConvertGenerate = false) +public class ShortDramaLocationBo extends BaseEntity { + + private Long id; + + private Long projectId; + + private String name; + + private String summary; + + private Boolean hasCrowd; + + private String crowdDescription; + + private String availableSlots; + + private String descriptions; + + private String referenceImageUrl; + + private String imageUrls; + + private String imageDescriptions; + + private Integer selectedImageIndex; + + private String previousImageUrls; + + private String previousDescriptions; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaProjectBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaProjectBo.java new file mode 100644 index 00000000..ce73bec3 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaProjectBo.java @@ -0,0 +1,27 @@ +package org.ruoyi.domain.bo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; +import org.ruoyi.domain.entity.shortdrama.ShortDramaProject; + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = ShortDramaProject.class, reverseConvertGenerate = false) +public class ShortDramaProjectBo extends BaseEntity { + + private Long id; + + private Long userId; + + private String projectName; + + private String description; + + private String status; + + private String artStyle; + + private String composeAspectRatio; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptBo.java new file mode 100644 index 00000000..c4252441 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptBo.java @@ -0,0 +1,27 @@ +package org.ruoyi.domain.bo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; +import org.ruoyi.domain.entity.shortdrama.ShortDramaScript; + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = ShortDramaScript.class, reverseConvertGenerate = false) +public class ShortDramaScriptBo extends BaseEntity { + + private Long id; + + private Long projectId; + + private String scriptName; + + private String scriptText; + + private String outlineText; + + private String tone; + + private String sourceType; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptResult.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptResult.java new file mode 100644 index 00000000..8a61c5a5 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptResult.java @@ -0,0 +1,31 @@ +package org.ruoyi.domain.bo.shortdrama; + +import dev.langchain4j.model.output.structured.Description; +import lombok.Data; + +/** + * Phase 1 剧本打磨结构化响应 —— langchain4j AiServices 自动解析用 + * + * @author ageerle + */ +@Data +public class ShortDramaScriptResult { + + @Description("项目名称(有吸引力的短剧名)") + private String projectName; + + @Description("一句话简介(20-50字)") + private String description; + + @Description("剧本名称") + private String scriptName; + + @Description("风格基调(如:都市甜宠/古装虐恋/悬疑惊悚/喜剧爽文)") + private String tone; + + @Description("剧情大纲(400-800字,完整故事线)") + private String outlineText; + + @Description("完整短剧文本(1000-3000字,标准剧本格式,含场景头、动作描述、对话)") + private String scriptText; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaStoryboardBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaStoryboardBo.java new file mode 100644 index 00000000..8eaf989f --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaStoryboardBo.java @@ -0,0 +1,49 @@ +package org.ruoyi.domain.bo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = ShortDramaStoryboard.class, reverseConvertGenerate = false) +public class ShortDramaStoryboardBo extends BaseEntity { + + private Long id; + + private Long projectId; + + private Long scriptId; + + private Integer sceneNo; + + private String sceneTitle; + + private String sceneText; + + private String sceneType; + + private String shotType; + + private String cameraMove; + + private String charactersJson; + + private String locationName; + + private String photographyRules; + + private String actingNotes; + + private String continuityJson; + + private String sourceText; + + private String imagePrompt; + + private Integer durationSeconds; + + private String videoPrompt; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/agent/Agent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/agent/Agent.java new file mode 100644 index 00000000..098763e4 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/agent/Agent.java @@ -0,0 +1,84 @@ +package org.ruoyi.domain.entity.agent; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.tenant.core.TenantEntity; + +/** + * 智能体信息实体 + *

+ * 一个智能体聚合:一个聊天模型 + 一组 MCP 工具 + 一组磁盘技能 + 一组知识库 + 自定义提示词 + * 关联以 JSON 数组字符串列存储:mcp_tool_ids / skill_names / knowledge_ids + * + * @author ruoyi team + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("agent_info") +public class Agent extends TenantEntity { + + /** + * 智能体ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 智能体名称 + */ + private String agentName; + + /** + * 智能体描述(下拉展示用) + */ + private String agentDescribe; + + /** + * 展示图标/头像URL + */ + private String agentShow; + + /** + * 绑定的聊天模型ID(chat_model.id, category=chat) + */ + private Long modelId; + + /** + * 是否启用深度思考(ReAct多子Agent):0 否 1 是 + */ + private String enableThinking; + + /** + * 自定义系统提示词 + */ + private String systemPrompt; + + /** + * 关联MCP工具ID列表(JSON数组,[Long]) + */ + private String mcpToolIds; + + /** + * 关联磁盘技能名列表(JSON数组,[String]) + */ + private String skillNames; + + /** + * 关联知识库ID列表(JSON数组,[Long]) + */ + private String knowledgeIds; + + /** + * 状态:0 正常 1 停用 + */ + private String status; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacter.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacter.java new file mode 100644 index 00000000..25472e44 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacter.java @@ -0,0 +1,43 @@ +package org.ruoyi.domain.entity.shortdrama; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("short_drama_character") +public class ShortDramaCharacter extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private Long projectId; + + private String name; + + private String aliases; + + private String introduction; + + private String roleLevel; + + private String gender; + + private String ageRange; + + private String personalityTags; + + private Integer costumeTier; + + private String visualDescription; + + private String referenceImageUrl; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacterAppearance.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacterAppearance.java new file mode 100644 index 00000000..b9f28848 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacterAppearance.java @@ -0,0 +1,46 @@ +package org.ruoyi.domain.entity.shortdrama; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("short_drama_character_appearance") +public class ShortDramaCharacterAppearance extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private Long characterId; + + private Integer appearanceIndex; + + private String changeReason; + + private String description; + + private String referenceImageUrl; + + /** 生成图片URL列表(JSON数组) */ + private String imageUrls; + + /** 每张图片对应的提示词(JSON数组) */ + private String imageDescriptions; + + /** 当前选中的图片索引 */ + private Integer selectedImageIndex; + + /** 上一轮图片URL列表(撤销用,JSON数组) */ + private String previousImageUrls; + + /** 上一轮提示词列表(撤销用,JSON数组) */ + private String previousDescriptions; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaLocation.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaLocation.java new file mode 100644 index 00000000..7ae05126 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaLocation.java @@ -0,0 +1,52 @@ +package org.ruoyi.domain.entity.shortdrama; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("short_drama_location") +public class ShortDramaLocation extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private Long projectId; + + private String name; + + private String summary; + + private Boolean hasCrowd; + + private String crowdDescription; + + private String availableSlots; + + private String descriptions; + + private String referenceImageUrl; + + /** 生成图片URL列表(JSON数组) */ + private String imageUrls; + + /** 每张图片对应的提示词(JSON数组) */ + private String imageDescriptions; + + /** 当前选中的图片索引 */ + private Integer selectedImageIndex; + + /** 上一轮图片URL列表(撤销用,JSON数组) */ + private String previousImageUrls; + + /** 上一轮提示词列表(撤销用,JSON数组) */ + private String previousDescriptions; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaProject.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaProject.java new file mode 100644 index 00000000..529c3cdd --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaProject.java @@ -0,0 +1,53 @@ +package org.ruoyi.domain.entity.shortdrama; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; +import java.math.BigDecimal; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("short_drama_project") +public class ShortDramaProject extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private Long userId; + + private String projectName; + + private String description; + + private String status; + + private String artStyle; + + private Long composedVideoOssId; + + private String composeStatus; + + private String composeJobId; + + private Integer composeProgress; + + private String composeTransitionType; + + private BigDecimal composeTransitionDurationSeconds; + + private String composeAspectRatio; + + private BigDecimal composedVideoDurationSeconds; + + private String composeErrorMessage; + + private Date composedAt; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaScript.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaScript.java new file mode 100644 index 00000000..7a5b0e60 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaScript.java @@ -0,0 +1,33 @@ +package org.ruoyi.domain.entity.shortdrama; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("short_drama_script") +public class ShortDramaScript extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private Long projectId; + + private String scriptName; + + private String scriptText; + + private String outlineText; + + private String tone; + + private String sourceType; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaStoryboard.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaStoryboard.java new file mode 100644 index 00000000..63e3cedd --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaStoryboard.java @@ -0,0 +1,61 @@ +package org.ruoyi.domain.entity.shortdrama; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("short_drama_storyboard") +public class ShortDramaStoryboard extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private Long projectId; + + private Long scriptId; + + private Integer sceneNo; + + private String sceneTitle; + + private String sceneText; + + private String sceneType; + + private String shotType; + + private String cameraMove; + + private String charactersJson; + + private String locationName; + + private String photographyRules; + + private String actingNotes; + + private String continuityJson; + + private String sourceText; + + private String imagePrompt; + + private Integer durationSeconds; + + private String videoPrompt; + + private String videoUrl; + + private String videoId; + + private String videoStatus; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/AgentVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/AgentVo.java new file mode 100644 index 00000000..102d83a5 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/AgentVo.java @@ -0,0 +1,121 @@ +package org.ruoyi.domain.vo.agent; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +/** + * 智能体视图对象 + *

+ * 注意:不使用 @AutoMapper,因为 entity 的 mcpToolIds/skillNames/knowledgeIds 是 JSON 字符串列, + * 而 VO 是 List 类型,MapStruct 无法双向自动转换。由 AgentServiceImpl.toVo 手动组装。 + * + * @author ruoyi team + */ +@Data +@ExcelIgnoreUnannotated +public class AgentVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 智能体ID + */ + @ExcelProperty(value = "智能体ID") + private Long id; + + /** + * 智能体名称 + */ + @ExcelProperty(value = "智能体名称") + private String agentName; + + /** + * 智能体描述 + */ + @ExcelProperty(value = "智能体描述") + private String agentDescribe; + + /** + * 展示图标/头像URL + */ + private String agentShow; + + /** + * 绑定的聊天模型ID + */ + @ExcelProperty(value = "绑定模型ID") + private Long modelId; + + /** + * 绑定的聊天模型名称(关联展示) + */ + @ExcelProperty(value = "绑定模型") + private String modelName; + + /** + * 是否启用深度思考:0 否 1 是 + */ + @ExcelProperty(value = "深度思考") + private String enableThinking; + + /** + * 自定义系统提示词 + */ + private String systemPrompt; + + /** + * 关联MCP工具ID列表 + */ + private List mcpToolIds; + + /** + * 关联MCP工具名称列表(关联展示) + */ + private List mcpToolNames; + + /** + * 关联磁盘技能名列表 + */ + private List skillNames; + + /** + * 关联知识库ID列表 + */ + private List knowledgeIds; + + /** + * 关联知识库名称列表(关联展示) + */ + private List knowledgeNames; + + /** + * 状态:0 正常 1 停用 + */ + @ExcelProperty(value = "状态") + private String status; + + /** + * 备注 + */ + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/SkillOptionVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/SkillOptionVo.java new file mode 100644 index 00000000..40959059 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/SkillOptionVo.java @@ -0,0 +1,33 @@ +package org.ruoyi.domain.vo.agent; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 磁盘技能可选项 + * + * @author ruoyi team + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SkillOptionVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 技能名称(对应 SKILL.md front-matter name) + */ + private String name; + + /** + * 技能描述 + */ + private String description; + +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterAppearanceVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterAppearanceVo.java new file mode 100644 index 00000000..5ee0b031 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterAppearanceVo.java @@ -0,0 +1,43 @@ +package org.ruoyi.domain.vo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacterAppearance; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +@Data +@AutoMapper(target = ShortDramaCharacterAppearance.class) +public class ShortDramaCharacterAppearanceVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + private Long characterId; + + private Integer appearanceIndex; + + private String changeReason; + + private String description; + + private String referenceImageUrl; + + private String imageUrls; + + private String imageDescriptions; + + private Integer selectedImageIndex; + + private String previousImageUrls; + + private String previousDescriptions; + + private Date createTime; + + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterVo.java new file mode 100644 index 00000000..50f77b81 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterVo.java @@ -0,0 +1,48 @@ +package org.ruoyi.domain.vo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +@Data +@AutoMapper(target = ShortDramaCharacter.class) +public class ShortDramaCharacterVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + private Long projectId; + + private String name; + + private String aliases; + + private String introduction; + + private String roleLevel; + + private String gender; + + private String ageRange; + + private String personalityTags; + + private Integer costumeTier; + + private String visualDescription; + + private String referenceImageUrl; + + private List appearances; + + private Date createTime; + + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaComposeVideoVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaComposeVideoVo.java new file mode 100644 index 00000000..f3004a52 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaComposeVideoVo.java @@ -0,0 +1,45 @@ +package org.ruoyi.domain.vo.shortdrama; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ShortDramaComposeVideoVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long projectId; + + private String status; + + private Integer progress; + + private String transitionType; + + private BigDecimal transitionDurationSeconds; + + private String aspectRatio; + + /** Actual duration measured from the final MP4 with ffprobe. */ + private BigDecimal outputDurationSeconds; + + private Long videoOssId; + + /** Freshly resolved URL; private-bucket URLs may be short lived. */ + private String videoUrl; + + private String errorMessage; + + private Date composedAt; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaDetailVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaDetailVo.java new file mode 100644 index 00000000..7e25bf86 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaDetailVo.java @@ -0,0 +1,24 @@ +package org.ruoyi.domain.vo.shortdrama; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +@Data +public class ShortDramaDetailVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private ShortDramaProjectVo project; + + private ShortDramaScriptVo script; + + private List characters; + + private List locations; + + private List storyboards; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaLocationVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaLocationVo.java new file mode 100644 index 00000000..9d7353e1 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaLocationVo.java @@ -0,0 +1,49 @@ +package org.ruoyi.domain.vo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.ruoyi.domain.entity.shortdrama.ShortDramaLocation; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +@Data +@AutoMapper(target = ShortDramaLocation.class) +public class ShortDramaLocationVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + private Long projectId; + + private String name; + + private String summary; + + private Boolean hasCrowd; + + private String crowdDescription; + + private String availableSlots; + + private String descriptions; + + private String referenceImageUrl; + + private String imageUrls; + + private String imageDescriptions; + + private Integer selectedImageIndex; + + private String previousImageUrls; + + private String previousDescriptions; + + private Date createTime; + + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaProjectVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaProjectVo.java new file mode 100644 index 00000000..a5cde322 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaProjectVo.java @@ -0,0 +1,52 @@ +package org.ruoyi.domain.vo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.ruoyi.domain.entity.shortdrama.ShortDramaProject; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +@Data +@AutoMapper(target = ShortDramaProject.class) +public class ShortDramaProjectVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + private Long userId; + + private String projectName; + + private String description; + + private String status; + + private String artStyle; + + private Long composedVideoOssId; + + private String composeStatus; + + private Integer composeProgress; + + private String composeTransitionType; + + private BigDecimal composeTransitionDurationSeconds; + + private String composeAspectRatio; + + private BigDecimal composedVideoDurationSeconds; + + private String composeErrorMessage; + + private Date composedAt; + + private Date createTime; + + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaScriptVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaScriptVo.java new file mode 100644 index 00000000..a26e6067 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaScriptVo.java @@ -0,0 +1,35 @@ +package org.ruoyi.domain.vo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.ruoyi.domain.entity.shortdrama.ShortDramaScript; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +@Data +@AutoMapper(target = ShortDramaScript.class) +public class ShortDramaScriptVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + private Long projectId; + + private String scriptName; + + private String scriptText; + + private String outlineText; + + private String tone; + + private String sourceType; + + private Date createTime; + + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaStoryboardVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaStoryboardVo.java new file mode 100644 index 00000000..da0eedc1 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaStoryboardVo.java @@ -0,0 +1,63 @@ +package org.ruoyi.domain.vo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +@Data +@AutoMapper(target = ShortDramaStoryboard.class) +public class ShortDramaStoryboardVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + private Long projectId; + + private Long scriptId; + + private Integer sceneNo; + + private String sceneTitle; + + private String sceneText; + + private String sceneType; + + private String shotType; + + private String cameraMove; + + private String charactersJson; + + private String locationName; + + private String photographyRules; + + private String actingNotes; + + private String continuityJson; + + private String sourceText; + + private String imagePrompt; + + private Integer durationSeconds; + + private String videoPrompt; + + private String videoUrl; + + private String videoId; + + private String videoStatus; + + private Date createTime; + + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java index 6279b107..b7b545bb 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java @@ -18,6 +18,8 @@ public enum ChatModeType { ATLAS("atlas", "Atlas Cloud"), CUSTOM_API("custom_api", "自定义API"), MINIMAX("minimax", "MiniMax"), + DIFY("dify", "Dify"), + COZE("coze", "Coze"), XIAOMI("xiaomi", "小米MiMo"); private final String code; private final String description; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/agent/AgentMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/agent/AgentMapper.java new file mode 100644 index 00000000..e13ecf25 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/agent/AgentMapper.java @@ -0,0 +1,14 @@ +package org.ruoyi.mapper.agent; + +import org.apache.ibatis.annotations.Mapper; +import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus; +import org.ruoyi.domain.entity.agent.Agent; +import org.ruoyi.domain.vo.agent.AgentVo; +/** + * 智能体信息 Mapper + * + * @author ruoyi team + */ +@Mapper +public interface AgentMapper extends BaseMapperPlus { +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterAppearanceMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterAppearanceMapper.java new file mode 100644 index 00000000..5077c8e6 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterAppearanceMapper.java @@ -0,0 +1,8 @@ +package org.ruoyi.mapper.shortdrama; + +import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus; +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacterAppearance; +import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo; + +public interface ShortDramaCharacterAppearanceMapper extends BaseMapperPlus { +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterMapper.java new file mode 100644 index 00000000..49905499 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterMapper.java @@ -0,0 +1,8 @@ +package org.ruoyi.mapper.shortdrama; + +import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus; +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter; +import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo; + +public interface ShortDramaCharacterMapper extends BaseMapperPlus { +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaLocationMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaLocationMapper.java new file mode 100644 index 00000000..a4beea96 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaLocationMapper.java @@ -0,0 +1,8 @@ +package org.ruoyi.mapper.shortdrama; + +import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus; +import org.ruoyi.domain.entity.shortdrama.ShortDramaLocation; +import org.ruoyi.domain.vo.shortdrama.ShortDramaLocationVo; + +public interface ShortDramaLocationMapper extends BaseMapperPlus { +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaProjectMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaProjectMapper.java new file mode 100644 index 00000000..1841ce0e --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaProjectMapper.java @@ -0,0 +1,8 @@ +package org.ruoyi.mapper.shortdrama; + +import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus; +import org.ruoyi.domain.entity.shortdrama.ShortDramaProject; +import org.ruoyi.domain.vo.shortdrama.ShortDramaProjectVo; + +public interface ShortDramaProjectMapper extends BaseMapperPlus { +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaScriptMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaScriptMapper.java new file mode 100644 index 00000000..e36afcc0 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaScriptMapper.java @@ -0,0 +1,8 @@ +package org.ruoyi.mapper.shortdrama; + +import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus; +import org.ruoyi.domain.entity.shortdrama.ShortDramaScript; +import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo; + +public interface ShortDramaScriptMapper extends BaseMapperPlus { +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaStoryboardMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaStoryboardMapper.java new file mode 100644 index 00000000..5f9c0a5d --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaStoryboardMapper.java @@ -0,0 +1,8 @@ +package org.ruoyi.mapper.shortdrama; + +import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import org.ruoyi.domain.vo.shortdrama.ShortDramaStoryboardVo; + +public interface ShortDramaStoryboardMapper extends BaseMapperPlus { +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/IAgentService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/IAgentService.java new file mode 100644 index 00000000..7d48f617 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/IAgentService.java @@ -0,0 +1,59 @@ +package org.ruoyi.service.agent; + +import org.ruoyi.common.mybatis.core.page.PageQuery; +import org.ruoyi.common.mybatis.core.page.TableDataInfo; +import org.ruoyi.domain.bo.agent.AgentBo; +import org.ruoyi.domain.vo.agent.AgentVo; +import org.ruoyi.domain.vo.agent.SkillOptionVo; + +import java.util.Collection; +import java.util.List; + +/** + * 智能体服务接口 + * + * @author ruoyi team + */ +public interface IAgentService { + + /** + * 分页查询智能体列表 + */ + TableDataInfo queryPageList(AgentBo bo, PageQuery pageQuery); + + /** + * 查询符合条件的智能体列表(用于导出) + */ + List queryList(AgentBo bo); + + /** + * 根据ID查询智能体(展开 JSON 数组字段为 List,关联填充模型/工具/知识库名称) + */ + AgentVo queryById(Long id); + + /** + * 新增智能体 + */ + Boolean insertByBo(AgentBo bo); + + /** + * 修改智能体 + */ + Boolean updateByBo(AgentBo bo); + + /** + * 批量删除智能体 + */ + Boolean deleteByIds(Collection ids); + + /** + * 查询启用的智能体下拉选项(用户端聊天页选择用,status=0) + */ + List queryEnabledOptions(); + + /** + * 列出磁盘上可用的 Skills(供管理端表单勾选用) + */ + List listSkillOptions(); + +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/impl/AgentServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/impl/AgentServiceImpl.java new file mode 100644 index 00000000..1a67e3bf --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/impl/AgentServiceImpl.java @@ -0,0 +1,226 @@ +package org.ruoyi.service.agent.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import dev.langchain4j.skills.FileSystemSkill; +import dev.langchain4j.skills.FileSystemSkillLoader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.common.core.utils.MapstructUtils; +import org.ruoyi.common.json.utils.JsonUtils; +import org.ruoyi.common.mybatis.core.page.PageQuery; +import org.ruoyi.common.mybatis.core.page.TableDataInfo; +import org.ruoyi.config.agent.SkillsPathResolver; +import org.ruoyi.domain.entity.agent.Agent; +import org.ruoyi.domain.bo.agent.AgentBo; +import org.ruoyi.domain.entity.mcp.McpTool; +import org.ruoyi.domain.vo.agent.AgentVo; +import org.ruoyi.domain.vo.agent.SkillOptionVo; +import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo; +import org.ruoyi.mapper.agent.AgentMapper; +import org.ruoyi.mapper.mcp.McpToolMapper; +import org.ruoyi.service.agent.IAgentService; +import org.ruoyi.service.knowledge.IKnowledgeInfoService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 智能体服务实现 + *

+ * 注意:entity 的 mcpToolIds/skillNames/knowledgeIds 是 JSON 字符串列, + * VO 中是 List 类型。MapStruct 无法自动 String→List,因此这里查询 entity 后 + * 手动用 JsonUtils 解析并组装 VO。 + * + * @author ruoyi team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AgentServiceImpl implements IAgentService { + + private final AgentMapper baseMapper; + private final McpToolMapper mcpToolMapper; + private final IKnowledgeInfoService knowledgeInfoService; + private final IChatModelService chatModelService; + + @Override + public TableDataInfo queryPageList(AgentBo bo, PageQuery pageQuery) { + LambdaQueryWrapper wrapper = buildQueryWrapper(bo); + Page page = baseMapper.selectPage(pageQuery.build(), wrapper); + List records = page.getRecords() == null ? List.of() + : page.getRecords().stream().map(this::toVo).toList(); + Page voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal()); + voPage.setRecords(records); + return TableDataInfo.build(voPage); + } + + @Override + public List queryList(AgentBo bo) { + LambdaQueryWrapper wrapper = buildQueryWrapper(bo); + List list = baseMapper.selectList(wrapper); + return list.stream().map(this::toVo).toList(); + } + + @Override + public AgentVo queryById(Long id) { + Agent entity = baseMapper.selectById(id); + return entity == null ? null : toVo(entity); + } + + @Override + @Transactional + public Boolean insertByBo(AgentBo bo) { + Agent entity = MapstructUtils.convert(bo, Agent.class); + if (!StringUtils.hasText(entity.getStatus())) { + entity.setStatus("0"); + } + if (!StringUtils.hasText(entity.getEnableThinking())) { + entity.setEnableThinking("0"); + } + entity.setMcpToolIds(JsonUtils.toJsonString(bo.getMcpToolIds())); + entity.setSkillNames(JsonUtils.toJsonString(bo.getSkillNames())); + entity.setKnowledgeIds(JsonUtils.toJsonString(bo.getKnowledgeIds())); + return baseMapper.insert(entity) > 0; + } + + @Override + @Transactional + public Boolean updateByBo(AgentBo bo) { + Agent entity = MapstructUtils.convert(bo, Agent.class); + entity.setMcpToolIds(JsonUtils.toJsonString(bo.getMcpToolIds())); + entity.setSkillNames(JsonUtils.toJsonString(bo.getSkillNames())); + entity.setKnowledgeIds(JsonUtils.toJsonString(bo.getKnowledgeIds())); + return baseMapper.updateById(entity) > 0; + } + + @Override + @Transactional + public Boolean deleteByIds(Collection ids) { + return baseMapper.deleteByIds(ids) > 0; + } + + @Override + public List queryEnabledOptions() { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.eq(Agent::getStatus, "0") + .orderByDesc(Agent::getUpdateTime) + .orderByDesc(Agent::getId); + return baseMapper.selectList(wrapper).stream().map(this::toVo).toList(); + } + + @Override + public List listSkillOptions() { + try { + List skills = FileSystemSkillLoader.loadSkills(SkillsPathResolver.resolveSkillsPath()); + if (skills == null || skills.isEmpty()) { + return Collections.emptyList(); + } + return skills.stream() + .map(s -> new SkillOptionVo(s.name(), s.description())) + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("加载磁盘 Skills 失败: {}", e.getMessage(), e); + return Collections.emptyList(); + } + } + + /** + * entity → vo,展开 JSON 数组并填充关联名称与模型名 + */ + private AgentVo toVo(Agent entity) { + if (entity == null) { + return null; + } + AgentVo vo = new AgentVo(); + vo.setId(entity.getId()); + vo.setAgentName(entity.getAgentName()); + vo.setAgentDescribe(entity.getAgentDescribe()); + vo.setAgentShow(entity.getAgentShow()); + vo.setModelId(entity.getModelId()); + vo.setEnableThinking(entity.getEnableThinking()); + vo.setSystemPrompt(entity.getSystemPrompt()); + vo.setStatus(entity.getStatus()); + vo.setRemark(entity.getRemark()); + vo.setCreateTime(entity.getCreateTime()); + vo.setUpdateTime(entity.getUpdateTime()); + + // 展开 JSON 数组 + List toolIds = parseLongArray(entity.getMcpToolIds()); + List skillNames = parseStringArray(entity.getSkillNames()); + List knowledgeIds = parseLongArray(entity.getKnowledgeIds()); + vo.setMcpToolIds(toolIds); + vo.setSkillNames(skillNames); + vo.setKnowledgeIds(knowledgeIds); + + // 绑定模型名称 + if (entity.getModelId() != null) { + try { + ChatModelVo model = chatModelService.queryById(entity.getModelId()); + if (model != null) { + vo.setModelName(model.getModelName()); + } + } catch (Exception e) { + log.warn("查询模型失败: modelId={}, err={}", entity.getModelId(), e.getMessage()); + } + } + // MCP 工具名称 + if (toolIds != null && !toolIds.isEmpty()) { + List tools = mcpToolMapper.selectByIds(toolIds); + if (tools != null) { + vo.setMcpToolNames(tools.stream().map(McpTool::getName).toList()); + } + } + // 知识库名称 + if (knowledgeIds != null && !knowledgeIds.isEmpty()) { + List names = new ArrayList<>(); + for (Long kid : knowledgeIds) { + try { + KnowledgeInfoVo kb = knowledgeInfoService.queryById(kid); + if (kb != null) { + names.add(kb.getName()); + } + } catch (Exception e) { + log.warn("查询知识库失败: kid={}, err={}", kid, e.getMessage()); + } + } + vo.setKnowledgeNames(names); + } + return vo; + } + + private List parseLongArray(String json) { + if (!StringUtils.hasText(json)) { + return new ArrayList<>(); + } + List list = JsonUtils.parseArray(json, Long.class); + return list == null ? new ArrayList<>() : list; + } + + private List parseStringArray(String json) { + if (!StringUtils.hasText(json)) { + return new ArrayList<>(); + } + List list = JsonUtils.parseArray(json, String.class); + return list == null ? new ArrayList<>() : list; + } + + private LambdaQueryWrapper buildQueryWrapper(AgentBo bo) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.like(StringUtils.hasText(bo.getAgentName()), Agent::getAgentName, bo.getAgentName()) + .like(StringUtils.hasText(bo.getAgentDescribe()), Agent::getAgentDescribe, bo.getAgentDescribe()) + .eq(StringUtils.hasText(bo.getStatus()), Agent::getStatus, bo.getStatus()) + .eq(bo.getModelId() != null, Agent::getModelId, bo.getModelId()) + .orderByDesc(Agent::getUpdateTime); + return wrapper; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/AbstractAudioGenerationService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/AbstractAudioGenerationService.java new file mode 100644 index 00000000..eb1b5675 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/AbstractAudioGenerationService.java @@ -0,0 +1,19 @@ +package org.ruoyi.service.audio; + +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.entity.audio.AudioContext; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.common.chat.service.audio.IAudioGenerationService; +import org.springframework.validation.annotation.Validated; + +@Slf4j +@Validated +public abstract class AbstractAudioGenerationService implements IAudioGenerationService { + + @Override + public MediaGenerationResponse generateSpeech(AudioContext audioContext) { + return doGenerateSpeech(audioContext); + } + + protected abstract MediaGenerationResponse doGenerateSpeech(AudioContext audioContext); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/OpenAiAudioGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/OpenAiAudioGenerationServiceImpl.java new file mode 100644 index 00000000..cc406fa9 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/OpenAiAudioGenerationServiceImpl.java @@ -0,0 +1,102 @@ +package org.ruoyi.service.audio.provider; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.entity.audio.AudioContext; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.service.audio.AbstractAudioGenerationService; +import org.ruoyi.service.media.OpenAiMediaSupport; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Base64; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component("openaiAudio") +public class OpenAiAudioGenerationServiceImpl extends AbstractAudioGenerationService { + + private static final String DEFAULT_VOICE = "alloy"; + private static final String DEFAULT_FORMAT = "mp3"; + + private final OkHttpClient okHttpClient = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(180, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + + @Override + protected MediaGenerationResponse doGenerateSpeech(AudioContext audioContext) { + ChatModelVo model = audioContext.getChatModelVo(); + String format = StrUtil.blankToDefault(audioContext.getResponseFormat(), DEFAULT_FORMAT); + String mimeType = mimeType(format); + ObjectNode payload = OpenAiMediaSupport.OBJECT_MAPPER.createObjectNode(); + payload.put("model", model.getModelName()); + payload.put("input", audioContext.getInput()); + payload.put("voice", StrUtil.blankToDefault(audioContext.getVoice(), DEFAULT_VOICE)); + payload.put("response_format", format); + if (audioContext.getSpeed() != null) { + payload.put("speed", audioContext.getSpeed()); + } + if (StrUtil.isNotBlank(audioContext.getInstructions())) { + payload.put("instructions", audioContext.getInstructions()); + } + + try { + byte[] bytes = postBytes(model, "/audio/speech", payload.toString()); + String b64 = Base64.getEncoder().encodeToString(bytes); + return MediaGenerationResponse.builder() + .type("audio") + .mimeType(mimeType) + .b64Json(b64) + .dataUrl(OpenAiMediaSupport.dataUrl(mimeType, b64)) + .build(); + } catch (IOException e) { + throw new RuntimeException("语音生成失败: " + e.getMessage(), e); + } + } + + @Override + public String getProviderName() { + return ChatModeType.OPEN_AI.getCode(); + } + + private byte[] postBytes(ChatModelVo model, String path, String jsonBody) throws IOException { + Request request = new Request.Builder() + .url(OpenAiMediaSupport.endpoint(model.getApiHost(), path)) + .addHeader("Authorization", "Bearer " + model.getApiKey()) + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(jsonBody, OpenAiMediaSupport.JSON)) + .build(); + try (Response response = okHttpClient.newCall(request).execute()) { + ResponseBody body = response.body(); + if (!response.isSuccessful()) { + String err = body == null ? "" : body.string(); + throw new IllegalArgumentException("OpenAI Audio API调用失败: " + response.code() + " - " + err); + } + if (body == null) { + throw new IllegalArgumentException("OpenAI Audio API响应为空"); + } + return body.bytes(); + } + } + + private String mimeType(String format) { + return switch (format) { + case "opus" -> "audio/opus"; + case "aac" -> "audio/aac"; + case "flac" -> "audio/flac"; + case "wav" -> "audio/wav"; + case "pcm" -> "audio/pcm"; + default -> "audio/mpeg"; + }; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index 16e0750d..020f5006 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -6,6 +6,7 @@ import dev.langchain4j.agentic.supervisor.SupervisorAgent; import dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.mcp.McpToolProvider; import dev.langchain4j.mcp.client.DefaultMcpClient; @@ -17,14 +18,18 @@ import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; -import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.rag.content.Content; +import dev.langchain4j.rag.content.retriever.ContentRetriever; import dev.langchain4j.service.tool.ToolProvider; +import dev.langchain4j.skills.FileSystemSkill; +import dev.langchain4j.skills.FileSystemSkillLoader; import dev.langchain4j.skills.shell.ShellSkills; import dev.langchain4j.rag.AugmentationRequest; import dev.langchain4j.rag.AugmentationResult; import dev.langchain4j.rag.DefaultRetrievalAugmentor; import dev.langchain4j.rag.RetrievalAugmentor; import dev.langchain4j.rag.query.Metadata; +import dev.langchain4j.rag.query.Query; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @@ -38,7 +43,6 @@ import org.ruoyi.agent.tool.QueryAllTablesTool; import org.ruoyi.agent.tool.QueryTableSchemaTool; import org.ruoyi.common.chat.base.ThreadContext; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; -import org.ruoyi.common.chat.domain.dto.request.ReSumeRunner; import org.ruoyi.common.chat.domain.dto.request.WorkFlowRunner; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.common.chat.enums.RoleType; @@ -50,11 +54,15 @@ import org.ruoyi.common.core.utils.StringUtils; import org.ruoyi.common.satoken.utils.LoginHelper; import org.ruoyi.common.sse.core.SseEmitterManager; import org.ruoyi.common.sse.utils.SseMessageUtils; +import org.ruoyi.config.agent.SkillsPathResolver; import org.ruoyi.domain.bo.vector.QueryVectorBo; +import org.ruoyi.domain.vo.agent.AgentVo; import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo; import org.ruoyi.factory.ChatServiceFactory; +import org.ruoyi.mcp.service.core.LangChain4jMcpToolProviderService; import org.ruoyi.mcp.service.core.ToolProviderFactory; import org.ruoyi.observability.*; +import org.ruoyi.service.agent.IAgentService; import org.ruoyi.service.chat.AbstractChatService; import org.ruoyi.service.chat.IChatMessageService; import org.ruoyi.service.chat.impl.memory.PersistentChatMemoryStore; @@ -106,6 +114,10 @@ public class ChatServiceFacade implements IChatService { private final ToolProviderFactory toolProviderFactory; + private final IAgentService agentService; + + private final LangChain4jMcpToolProviderService langChain4jMcpToolProviderService; + /** * 内存实例缓存,避免同一会话重复创建 * Key: sessionId, Value: MessageWindowChatMemory实例 @@ -127,6 +139,21 @@ public class ChatServiceFacade implements IChatService { String tokenValue = StpUtil.getTokenValue(); SseEmitter emitter = sseEmitterManager.connect(userId, tokenValue); + // 0. 智能体解析:传入 agentId 时按智能体绑定的模型覆盖 model 字段 + // (前端默认走智能体;enableThinking 不再作为对话模式开关,Supervisor 多 Agent 编排成为默认智能体路径) + AgentVo agentVo = null; + if (chatRequest.getAgentId() != null) { + agentVo = agentService.queryById(chatRequest.getAgentId()); + if (agentVo != null && agentVo.getModelId() != null) { + ChatModelVo agentModel = chatModelService.queryById(agentVo.getModelId()); + if (agentModel != null) { + chatRequest.setModel(agentModel.getModelName()); + } + } else { + log.warn("智能体不存在或未配置模型,回退到 model 字段: agentId={}", chatRequest.getAgentId()); + } + } + // 1. 根据模型名称查询完整配置 ChatModelVo chatModelVo = chatModelService.selectModelByName(chatRequest.getModel()); if (chatModelVo == null) { @@ -134,7 +161,7 @@ public class ChatServiceFacade implements IChatService { } // 2. 构建上下文消息列表 - List contextMessages = buildContextMessages(chatRequest); + List contextMessages = buildContextMessages(chatRequest, agentVo); chatRequest.setEmitter(emitter); chatRequest.setUserId(userId); @@ -145,37 +172,21 @@ public class ChatServiceFacade implements IChatService { // 保存用户消息 chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), chatRequest.getContent(), RoleType.USER.getName(), chatRequest.getModel()); - // 3. 处理特殊聊天模式(工作流、人机交互恢复、思考模式) - SseEmitter sseEmitter = handleSpecialChatModes(chatRequest); - if (sseEmitter != null) { - return sseEmitter; - } - - // 4. 路由服务提供商 - String providerCode = chatModelVo.getProviderCode(); - log.info("路由到服务提供商: {}, 模型: {}", providerCode, chatRequest.getModel()); - AbstractChatService chatService = chatServiceFactory.getOriginalService(providerCode); - - StreamingChatResponseHandler handler = createResponseHandler(userId, tokenValue,chatRequest); - - - // 5. 发起对话 - StreamingChatModel streamingChatModel = chatService.buildStreamingChatModel(chatModelVo, chatRequest); - streamingChatModel.chat(contextMessages, handler); - return emitter; + // 3. 路由对话模式:工作流对话 / 智能体对话(两者均返回各自的 SseEmitter) + return handleSpecialChatModes(chatRequest, agentVo); } /** - * 处理特殊聊天模式(工作流、人机交互恢复、思考模式) + * 路由对话模式:仅两种情况——工作流对话 / 智能体对话。 * - * @param chatRequest 聊天请求 - * @return 如果需要提前返回则返回SseEmitter,否则返回null + * @param chatRequest 聊天请求 + * @param agentVo 智能体配置(可为 null) + * @return 对应模式的 SseEmitter */ - private SseEmitter handleSpecialChatModes(ChatRequest chatRequest) { - // 处理工作流对话 - if (chatRequest.getEnableWorkFlow()) { + private SseEmitter handleSpecialChatModes(ChatRequest chatRequest, AgentVo agentVo) { + // 模式1:工作流对话(前端应用市场选工作流后携带 workFlowRunner) + if (Boolean.TRUE.equals(chatRequest.getEnableWorkFlow())) { log.info("处理工作流对话,会话: {}", chatRequest.getSessionId()); - WorkFlowRunner runner = chatRequest.getWorkFlowRunner(); if (ObjectUtils.isEmpty(runner)) { log.warn("工作流参数为空"); @@ -187,98 +198,36 @@ public class ChatServiceFacade implements IChatService { chatRequest.getSessionId() ); } - - // 处理人机交互恢复 - if (chatRequest.getIsResume()) { - log.info("处理人机交互恢复"); - ReSumeRunner reSumeRunner = chatRequest.getReSumeRunner(); - if (ObjectUtils.isEmpty(reSumeRunner)) { - log.warn("人机交互恢复参数为空"); - } - workFlowStarterService.resumeFlow( - reSumeRunner.getRuntimeUuid(), - reSumeRunner.getFeedbackContent(), - chatRequest.getEmitter() - ); - - return chatRequest.getEmitter(); - - } - // 处理思考模式 - if (chatRequest.getEnableThinking()) { - return handleThinkingMode(chatRequest); - } - - return null; + // 模式2:智能体对话(默认走 Supervisor 多 Agent 编排) + return handleAgentChat(chatRequest, agentVo); } /** - * 处理思考模式 + * 智能体对话模式(默认):构建 Supervisor 多 Agent 编排并异步执行,结果通过 SSE 推送。 * - * @param chatRequest 聊天请求 - + * @param chatRequest 聊天请求 + * @param agentVo 智能体配置(可为 null,无智能体时用请求 model 兜底) */ - private SseEmitter handleThinkingMode(ChatRequest chatRequest) { - // 配置监督者模型 - OpenAiChatModel plannerModel = OpenAiChatModel.builder() - .baseUrl(chatRequest.getChatModelVo().getApiHost()) - .apiKey(chatRequest.getChatModelVo().getApiKey()) - .modelName(chatRequest.getChatModelVo().getModelName()) - .build(); + private SseEmitter handleAgentChat(ChatRequest chatRequest, AgentVo agentVo) { + ChatModelVo chatModelVo = chatRequest.getChatModelVo(); - // Bing 搜索 MCP 客户端 - McpTransport bingTransport = new StdioMcpTransport.Builder() - .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "bing-cn-mcp")) - .logEvents(true) - .build(); + // 配置监督者模型:统一按 providerCode 走对应 AbstractChatService.buildChatModel, + // 兼容 ZhiPu/QianWen/Ollama/Dify/Coze/CustomApi 等非 OpenAI 协议;默认实现为 OpenAI 兼容。 + AbstractChatService chatService = chatServiceFactory.getOriginalService(chatModelVo.getProviderCode()); + ChatModel plannerModel = chatService.buildChatModel(chatModelVo); Long userId = chatRequest.getUserId(); - McpClient bingMcpClient = new DefaultMcpClient.Builder() - .transport(bingTransport) - .listener(new MyMcpClientListener(userId)) - .build(); - // Playwright MCP 客户端 - 浏览器自动化工具 - McpTransport playwrightTransport = new StdioMcpTransport.Builder() - .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "@playwright/mcp@latest")) - .logEvents(true) - .build(); + // 工具装配:智能体有关联工具ID时按ID装配,否则回退到原有硬编码 MCP 客户端 + ToolProvider toolProvider; + if (agentVo != null && agentVo.getMcpToolIds() != null && !agentVo.getMcpToolIds().isEmpty()) { + toolProvider = langChain4jMcpToolProviderService.getToolProvider(agentVo.getMcpToolIds()); + } else { + toolProvider = buildDefaultMcpToolProvider(userId); + } - McpClient playwrightMcpClient = new DefaultMcpClient.Builder() - .transport(playwrightTransport) - .listener(new MyMcpClientListener(userId)) - .build(); - - // Filesystem MCP 客户端 - 文件管理工具 - // 允许 AI 读取、写入、搜索文件(基于当前项目根目录) - String userDir = System.getProperty("user.dir"); - McpTransport filesystemTransport = new StdioMcpTransport.Builder() - .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", - "@modelcontextprotocol/server-filesystem", userDir)) - .logEvents(true) - - .build(); - - McpClient filesystemMcpClient = new DefaultMcpClient.Builder() - .transport(filesystemTransport) - .listener(new MyMcpClientListener(userId)) - .build(); - - // 合并三个 MCP 客户端的工具 - ToolProvider toolProvider = McpToolProvider.builder() - // bingMcpClient, - .mcpClients(List.of(playwrightMcpClient, filesystemMcpClient)) - .build(); - - // ========== LangChain4j Skills 基本用法 ========== - // 通过 SKILL.md 文件定义,LLM 按需通过 activate_skill 工具加载 - // 加载 Skills - 使用相对路径,基于项目根目录 - java.nio.file.Path skillsPath = java.nio.file.Path.of(userDir, "ruoyi-admin/src/main/resources/skills"); - List skillsList = dev.langchain4j.skills.FileSystemSkillLoader - .loadSkills(skillsPath) - ; - - ShellSkills skills = ShellSkills.from(skillsList); + // Skills 装配:智能体有勾选技能名时按名过滤磁盘 skills,否则加载全部 + ShellSkills skills = buildShellSkills(agentVo); // 构建子 Agent WebSearchAgent searchAgent = AgenticServices.agentBuilder(WebSearchAgent.class) @@ -287,14 +236,16 @@ public class ChatServiceFacade implements IChatService { .listener(new MyAgentListener()) .build(); - // 构建子 Agent 2: SkillsAgent - 负责文档处理技能(docx、pdf、xlsx) - // 独立管理 Skills 工具 - SkillsAgent skillsAgent = AgenticServices.agentBuilder(SkillsAgent.class) - .chatModel(plannerModel) - .systemMessage("You have access to the following skills:\n" + skills.formatAvailableSkills() - + "\nWhen the user's request relates to one of these skills, activate it first using the `activate_skill` tool before proceeding.") - .toolProvider(skills.toolProvider()) - .build(); + // SkillsAgent:仅当有可用 skills 时才注入 systemMessage + toolProvider + var skillsAgentBuilder = AgenticServices.agentBuilder(SkillsAgent.class) + .chatModel(plannerModel); + if (skills != null) { + skillsAgentBuilder + .systemMessage("You have access to the following skills:\n" + skills.formatAvailableSkills() + + "\nWhen the user's request relates to one of these skills, activate it first using the `activate_skill` tool before proceeding.") + .toolProvider(skills.toolProvider()); + } + SkillsAgent skillsAgent = skillsAgentBuilder.build(); // 构建子 Agent 3: SqlAgent - 负责数据库查询 SqlAgent sqlAgent = AgenticServices.agentBuilder(SqlAgent.class) @@ -317,23 +268,32 @@ public class ChatServiceFacade implements IChatService { .build(); // 构建监督者 Agent - 管理多个子 Agent - SupervisorAgent supervisor = AgenticServices.supervisorBuilder() + var supervisorBuilder = AgenticServices.supervisorBuilder() .chatModel(plannerModel) - //.listener(new SupervisorStreamListener(null)) - .subAgents(skillsAgent,searchAgent, sqlAgent, chartGenerationAgent, echartsAgent) - // 加入历史上下文 - 使用 ChatMemoryProvider 提供持久化的聊天内存 - //.chatMemoryProvider(memoryId -> createChatMemory(chatRequest.getSessionId())) - .responseStrategy(SupervisorResponseStrategy.LAST) - .build(); + .subAgents(skillsAgent, searchAgent, sqlAgent, chartGenerationAgent, echartsAgent) + .responseStrategy(SupervisorResponseStrategy.LAST); + SupervisorAgent supervisor = supervisorBuilder.build(); + + // 知识库增强:智能体绑定了知识库时,对 supervisor 输入做一次 RAG 增强 + String augmentedInput = augmentAgentInput(chatRequest, agentVo); + // 智能体自定义系统提示词:supervisor builder 不支持 systemMessage,前置到输入 + String prompt = (agentVo != null && StringUtils.isNotBlank(agentVo.getSystemPrompt())) + ? agentVo.getSystemPrompt() + "\n\n" + augmentedInput + : augmentedInput; String tokenValue = chatRequest.getTokenValue(); // 异步执行 supervisor,避免阻塞 HTTP 请求线程导致 SSE 事件被缓冲 CompletableFuture.runAsync(() -> { try { - String result = supervisor.invoke(chatRequest.getContent()); + String result = supervisor.invoke(prompt); SseMessageUtils.sendContent(userId, result); SseMessageUtils.sendDone(userId); + // 保存助手回复到数据库(智能体对话为默认路径后,需在此落库以保留历史) + if (StringUtils.isNotBlank(result)) { + chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), + result, RoleType.ASSISTANT.getName(), chatRequest.getModel()); + } } catch (Exception e) { log.error("Supervisor 执行失败", e); SseMessageUtils.sendError(userId, e.getMessage()); @@ -344,6 +304,82 @@ public class ChatServiceFacade implements IChatService { return chatRequest.getEmitter(); } + /** + * 兜底 MCP 工具装配(无智能体时使用,保留原有 3 个硬编码客户端逻辑) + */ + private ToolProvider buildDefaultMcpToolProvider(Long userId) { + McpTransport playwrightTransport = new StdioMcpTransport.Builder() + .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "@playwright/mcp@latest")) + .logEvents(true) + .build(); + McpClient playwrightMcpClient = new DefaultMcpClient.Builder() + .transport(playwrightTransport) + .listener(new MyMcpClientListener(userId)) + .build(); + + String userDir = System.getProperty("user.dir"); + McpTransport filesystemTransport = new StdioMcpTransport.Builder() + .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", + "@modelcontextprotocol/server-filesystem", userDir)) + .logEvents(true) + .build(); + McpClient filesystemMcpClient = new DefaultMcpClient.Builder() + .transport(filesystemTransport) + .listener(new MyMcpClientListener(userId)) + .build(); + + return McpToolProvider.builder() + .mcpClients(List.of(playwrightMcpClient, filesystemMcpClient)) + .build(); + } + + /** + * 装配磁盘 ShellSkills:智能体勾选了技能名时按名过滤,否则加载全部。 + * 无 skills 时返回 null(调用方据此跳过 SkillsAgent 的 toolProvider 注入) + */ + private ShellSkills buildShellSkills(AgentVo agentVo) { + java.nio.file.Path skillsPath = SkillsPathResolver.resolveSkillsPath(); + List skillsList = FileSystemSkillLoader.loadSkills(skillsPath); + if (skillsList == null || skillsList.isEmpty()) { + return null; + } + if (agentVo != null && agentVo.getSkillNames() != null && !agentVo.getSkillNames().isEmpty()) { + skillsList = skillsList.stream() + .filter(s -> agentVo.getSkillNames().contains(s.name())) + .toList(); + if (skillsList.isEmpty()) { + return null; + } + } + return ShellSkills.from(skillsList); + } + + /** + * 智能体对话下的输入增强:智能体绑定知识库时,对原始 content 做多知识库 RAG 增强。 + * 无知识库时原样返回 content。 + */ + private String augmentAgentInput(ChatRequest chatRequest, AgentVo agentVo) { + String content = chatRequest.getContent(); + List knowledgeIds = collectKnowledgeIds(chatRequest, agentVo); + if (knowledgeIds == null || knowledgeIds.isEmpty()) { + return content; + } + try { + RetrievalAugmentor augmentor = buildMultiKnowledgeAugmentor(knowledgeIds); + if (augmentor == null) { + return content; + } + UserMessage userMessage = UserMessage.userMessage(content); + Metadata metadata = Metadata.from(userMessage, chatRequest.getSessionId(), new ArrayList<>()); + AugmentationResult result = augmentor.augment(new AugmentationRequest(userMessage, metadata)); + ChatMessage augmented = result.chatMessage(); + return augmented instanceof UserMessage ? ((UserMessage) augmented).singleText() : content; + } catch (Exception e) { + log.warn("智能体对话 RAG 增强失败,回退原始输入: {}", e.getMessage()); + return content; + } + } + /** * 支持外部 handler 的对话接口(跨模块调用) * 同时发送到 SSE 和外部 handler @@ -418,44 +454,36 @@ public class ChatServiceFacade implements IChatService { /** * 构建上下文消息列表 - * 消息顺序:历史消息 → 当前用户消息(确保 AI 正确理解对话上下文) + * 消息顺序:系统提示词 → 历史消息 → 当前用户消息(确保 AI 正确理解对话上下文) * * @param chatRequest 聊天请求 + * @param agentVo 智能体配置(可为 null) * @return 上下文消息列表 */ - private List buildContextMessages(ChatRequest chatRequest) { + private List buildContextMessages(ChatRequest chatRequest, AgentVo agentVo) { List messages = new ArrayList<>(); + // 0. 智能体自定义系统提示词(普通对话今天无 SystemMessage,这里新增注入点) + if (agentVo != null && StringUtils.isNotBlank(agentVo.getSystemPrompt())) { + messages.add(SystemMessage.from(agentVo.getSystemPrompt())); + } + // 1. 初始化当前用户消息 UserMessage userMessage = UserMessage.userMessage(chatRequest.getContent()); - // 2. 知识库检索增强 (RAG) - if (chatRequest.getKnowledgeId() != null) { - KnowledgeInfoVo knowledgeInfoVo = knowledgeInfoService.queryById(Long.valueOf(chatRequest.getKnowledgeId())); - if (knowledgeInfoVo != null) { - ChatModelVo chatModel = chatModelService.selectModelByName(knowledgeInfoVo.getEmbeddingModel()); - if (chatModel != null) { - log.info("执行高级 RAG 流程: kid={}", chatRequest.getKnowledgeId()); - - // 构建自定义检索器 - CustomVectorRetriever retriever = new CustomVectorRetriever( - knowledgeRetrievalService, knowledgeInfoVo, chatModel); - - // 构建增强流水线 - RetrievalAugmentor augmentor = DefaultRetrievalAugmentor.builder() - .contentRetriever(retriever) - .build(); - - // 执行增强:编织上下文到 UserMessage - Metadata metadata = Metadata.from(userMessage, chatRequest.getSessionId(), new ArrayList<>()); - AugmentationRequest augmentationRequest = new AugmentationRequest(userMessage, metadata); - AugmentationResult result = augmentor.augment(augmentationRequest); - - ChatMessage augmented = result.chatMessage(); - if (augmented instanceof UserMessage) { - userMessage = (UserMessage) augmented; - log.debug("RAG 增强完成,UserMessage 已注入背景知识"); - } + // 2. 知识库检索增强 (RAG):智能体的 knowledgeIds 优先,回退到请求的 knowledgeId + List knowledgeIds = collectKnowledgeIds(chatRequest, agentVo); + if (knowledgeIds != null && !knowledgeIds.isEmpty()) { + RetrievalAugmentor augmentor = buildMultiKnowledgeAugmentor(knowledgeIds); + if (augmentor != null) { + log.info("执行多知识库 RAG 流程: kids={}", knowledgeIds); + Metadata metadata = Metadata.from(userMessage, chatRequest.getSessionId(), new ArrayList<>()); + AugmentationRequest augmentationRequest = new AugmentationRequest(userMessage, metadata); + AugmentationResult result = augmentor.augment(augmentationRequest); + ChatMessage augmented = result.chatMessage(); + if (augmented instanceof UserMessage) { + userMessage = (UserMessage) augmented; + log.debug("RAG 增强完成,UserMessage 已注入背景知识"); } } } @@ -478,6 +506,87 @@ public class ChatServiceFacade implements IChatService { return messages; } + /** + * 汇总本次对话要检索的知识库ID列表:智能体绑定的 knowledgeIds 优先,回退到请求的 knowledgeId + */ + private List collectKnowledgeIds(ChatRequest chatRequest, AgentVo agentVo) { + if (agentVo != null && agentVo.getKnowledgeIds() != null && !agentVo.getKnowledgeIds().isEmpty()) { + return agentVo.getKnowledgeIds(); + } + if (StringUtils.isNotBlank(chatRequest.getKnowledgeId())) { + try { + return List.of(Long.valueOf(chatRequest.getKnowledgeId())); + } catch (NumberFormatException ignored) { + } + } + return List.of(); + } + + /** + * 构建多知识库复合检索增强器。 + * 单知识库直接用 DefaultRetrievalAugmentor + CustomVectorRetriever; + * 多知识库用一个复合 ContentRetriever 合并各库检索结果。 + */ + private RetrievalAugmentor buildMultiKnowledgeAugmentor(List knowledgeIds) { + if (knowledgeIds == null || knowledgeIds.isEmpty()) { + return null; + } + List retrievers = new ArrayList<>(); + for (Long kid : knowledgeIds) { + try { + KnowledgeInfoVo kb = knowledgeInfoService.queryById(kid); + if (kb == null) { + continue; + } + ChatModelVo embModel = chatModelService.selectModelByName(kb.getEmbeddingModel()); + if (embModel == null) { + log.warn("知识库向量模型未配置或不存在: kid={}, embeddingModel={}", kid, kb.getEmbeddingModel()); + continue; + } + retrievers.add(new CustomVectorRetriever(knowledgeRetrievalService, kb, embModel)); + } catch (Exception e) { + log.warn("构建知识库检索器失败: kid={}, err={}", kid, e.getMessage()); + } + } + if (retrievers.isEmpty()) { + return null; + } + // 单库直接返回;多库用复合检索器 + ContentRetriever composite = retrievers.size() == 1 + ? retrievers.get(0) + : new CompositeContentRetriever(retrievers); + return DefaultRetrievalAugmentor.builder() + .contentRetriever(composite) + .build(); + } + + /** + * 复合内容检索器:对多个知识库检索器并发查询并合并结果 + */ + private static class CompositeContentRetriever implements ContentRetriever { + private final List delegates; + + CompositeContentRetriever(List delegates) { + this.delegates = delegates; + } + + @Override + public List retrieve(Query query) { + List all = new ArrayList<>(); + for (ContentRetriever r : delegates) { + try { + List part = r.retrieve(query); + if (part != null) { + all.addAll(part); + } + } catch (Exception e) { + log.warn("复合检索子检索器异常: {}", e.getMessage()); + } + } + return all; + } + } + /** * 构建向量查询参数 */ @@ -501,62 +610,6 @@ public class ChatServiceFacade implements IChatService { return queryVectorBo; } - /** - * 创建标准的响应处理器 - * - * @param userId 用户ID - * @param tokenValue 会话令牌 - * @return 标准的流式响应处理器 - */ - protected StreamingChatResponseHandler createResponseHandler(Long userId, String tokenValue,ChatRequest chatRequest) { - return new StreamingChatResponseHandler() { - - private final StringBuilder messageBuffer = new StringBuilder(); - - @SneakyThrows - @Override - public void onPartialResponse(String partialResponse) { - // 将消息片段追加到缓冲区 - messageBuffer.append(partialResponse); - - // 实时发送内容事件到客户端 - SseMessageUtils.sendContent(userId, partialResponse); - log.debug("收到消息片段: {}", partialResponse); - } - - @Override - public void onCompleteResponse(ChatResponse completeResponse) { - try { - // 发送完成事件 - SseMessageUtils.sendDone(userId); - - // 消息流完成,保存消息到数据库和内存 - String fullMessage = messageBuffer.toString(); - - if (fullMessage.isEmpty()) { - log.warn("接收到空消息"); - } else { - // 保存助手回复消息 - chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), fullMessage, RoleType.ASSISTANT.getName(), chatRequest.getModel()); - } - - // 关闭SSE连接 - SseMessageUtils.completeConnection(userId, tokenValue); - log.info("消息结束,已保存到数据库"); - } catch (Exception e) { - log.error("完成响应时出错: {}", e.getMessage(), e); - } - } - - @Override - public void onError(Throwable error) { - // 发送错误事件 - SseMessageUtils.sendError(userId, error.getMessage()); - log.error("流式响应错误: {}", error.getMessage()); - } - }; - } - /** * 创建组合响应处理器 - 同时发送到 SSE 和外部 handler * diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImpl.java new file mode 100644 index 00000000..40bcb23f --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImpl.java @@ -0,0 +1,409 @@ +package org.ruoyi.service.chat.impl.provider; + +import cn.hutool.core.util.StrUtil; +import com.coze.openapi.client.chat.CreateChatReq; +import com.coze.openapi.client.chat.model.Chat; +import com.coze.openapi.client.chat.model.ChatError; +import com.coze.openapi.client.chat.model.ChatEvent; +import com.coze.openapi.client.chat.model.ChatEventType; +import com.coze.openapi.client.chat.model.ChatUsage; +import com.coze.openapi.client.connversations.message.model.Message; +import com.coze.openapi.service.auth.TokenAuth; +import com.coze.openapi.service.service.CozeAPI; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.ModelProvider; +import dev.langchain4j.model.chat.Capability; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.listener.ChatModelListener; +import dev.langchain4j.model.chat.request.ChatRequestParameters; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import dev.langchain4j.model.output.FinishReason; +import dev.langchain4j.model.output.TokenUsage; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.observability.MyChatModelListener; +import org.ruoyi.service.chat.AbstractChatService; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Coze bot chat service. + * + * In model config, modelName stores the Coze bot ID, apiHost stores the Coze API host, + * and apiKey stores the PAT or OAuth access token. + */ +@Service +@Slf4j +public class CozeChatServiceImpl implements AbstractChatService { + + @Override + public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { + return new CozeLangChain4jChatModel(chatModelVo, chatRequest); + } + + @Override + public ChatModel buildChatModel(ChatModelVo chatModelVo) { + return new CozeLangChain4jChatModel(chatModelVo, null); + } + + @Override + public String getProviderName() { + return ChatModeType.COZE.getCode(); + } + + private static class CozeLangChain4jChatModel implements StreamingChatModel, ChatModel { + + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 300_000; + + private final ChatModelVo chatModelVo; + private final ChatRequest runtimeRequest; + private final List listeners = List.of(new MyChatModelListener()); + + private CozeLangChain4jChatModel(ChatModelVo chatModelVo, ChatRequest runtimeRequest) { + this.chatModelVo = chatModelVo; + this.runtimeRequest = runtimeRequest; + } + + @Override + public void doChat(dev.langchain4j.model.chat.request.ChatRequest request, + StreamingChatResponseHandler handler) { + StringBuilder answerBuffer = new StringBuilder(); + AtomicBoolean finished = new AtomicBoolean(false); + AtomicReference messageId = new AtomicReference<>(); + AtomicReference completedChat = new AtomicReference<>(); + CozeAPI coze = null; + try { + coze = createClient(); + coze.chat().stream(toCozeRequest(request)).blockingForEach(event -> + handleEvent(event, answerBuffer, messageId, completedChat, finished, handler, null) + ); + completeOnce(finished, handler, answerBuffer.toString(), messageId.get(), completedChat.get()); + } catch (Throwable e) { + errorOnce(finished, handler, e); + } finally { + shutdown(coze); + } + } + + @Override + public ChatResponse doChat(dev.langchain4j.model.chat.request.ChatRequest request) { + StringBuilder answerBuffer = new StringBuilder(); + AtomicBoolean finished = new AtomicBoolean(false); + AtomicReference messageId = new AtomicReference<>(); + AtomicReference completedChat = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + CozeAPI coze = null; + try { + coze = createClient(); + coze.chat().stream(toCozeRequest(request)).blockingForEach(event -> + handleEvent(event, answerBuffer, messageId, completedChat, finished, null, failure) + ); + if (failure.get() != null) { + throw failure.get(); + } + return toChatResponse(answerBuffer.toString(), messageId.get(), completedChat.get()); + } catch (RuntimeException e) { + throw e; + } catch (Throwable e) { + throw new IllegalStateException("Coze call failed", e); + } finally { + shutdown(coze); + } + } + + @Override + public ChatRequestParameters defaultRequestParameters() { + return ChatRequestParameters.builder() + .modelName(chatModelVo.getModelName()) + .build(); + } + + @Override + public List listeners() { + return listeners; + } + + @Override + public ModelProvider provider() { + return ModelProvider.OTHER; + } + + @Override + public Set supportedCapabilities() { + return Set.of(); + } + + private CozeAPI createClient() { + return new CozeAPI.Builder() + .baseURL(normalizeBaseUrl(chatModelVo.getApiHost())) + .auth(new TokenAuth(requiredApiKey())) + .connectTimeout(CONNECT_TIMEOUT_MS) + .readTimeout(READ_TIMEOUT_MS) + .build(); + } + + private CreateChatReq toCozeRequest(dev.langchain4j.model.chat.request.ChatRequest request) { + return CreateChatReq.builder() + .botID(requiredBotId()) + .userID(resolveUser()) + .messages(toCozeMessages(request.messages())) + .autoSaveHistory(Boolean.FALSE) + .build(); + } + + private List toCozeMessages(List messages) { + if (messages == null || messages.isEmpty()) { + return List.of(Message.buildUserQuestionText("")); + } + + List cozeMessages = new ArrayList<>(); + StringBuilder systemMessages = new StringBuilder(); + for (ChatMessage message : messages) { + if (message instanceof SystemMessage systemMessage) { + appendBlock(systemMessages, systemMessage.text()); + continue; + } + + String text = messageText(message); + if (StrUtil.isBlank(text)) { + continue; + } + if (message instanceof AiMessage) { + cozeMessages.add(Message.buildAssistantAnswer(text)); + } else { + cozeMessages.add(Message.buildUserQuestionText(text)); + } + } + + if (!systemMessages.isEmpty()) { + cozeMessages.add(0, Message.buildUserQuestionText("System:\n" + systemMessages)); + } + if (cozeMessages.isEmpty()) { + cozeMessages.add(Message.buildUserQuestionText("")); + } + return cozeMessages; + } + + private void handleEvent(ChatEvent event, + StringBuilder answerBuffer, + AtomicReference messageId, + AtomicReference completedChat, + AtomicBoolean finished, + StreamingChatResponseHandler handler, + AtomicReference failure) { + if (event == null || finished.get()) { + return; + } + + if (ChatEventType.CONVERSATION_MESSAGE_DELTA.equals(event.getEvent())) { + appendDelta(event, answerBuffer, messageId, handler); + return; + } + + if (ChatEventType.CONVERSATION_MESSAGE_COMPLETED.equals(event.getEvent())) { + rememberMessageId(event, messageId); + if (answerBuffer.isEmpty() && event.getMessage() != null + && StrUtil.isNotEmpty(event.getMessage().getContent())) { + answerBuffer.append(event.getMessage().getContent()); + } + return; + } + + if (ChatEventType.CONVERSATION_CHAT_COMPLETED.equals(event.getEvent())) { + completedChat.set(event.getChat()); + complete(handler, failure, finished, answerBuffer.toString(), messageId.get(), event.getChat()); + return; + } + + if (ChatEventType.CONVERSATION_CHAT_FAILED.equals(event.getEvent()) + || ChatEventType.ERROR.equals(event.getEvent())) { + RuntimeException error = toFailure(event); + fail(handler, failure, finished, error); + } + } + + private void appendDelta(ChatEvent event, StringBuilder answerBuffer, + AtomicReference messageId, + StreamingChatResponseHandler handler) { + if (event.getMessage() == null) { + return; + } + String chunk = event.getMessage().getContent(); + if (StrUtil.isEmpty(chunk)) { + return; + } + answerBuffer.append(chunk); + rememberMessageId(event, messageId); + if (handler != null) { + handler.onPartialResponse(chunk); + } + } + + private void complete(StreamingChatResponseHandler handler, + AtomicReference failure, + AtomicBoolean finished, + String answer, + String messageId, + Chat chat) { + if (handler != null) { + completeOnce(finished, handler, answer, messageId, chat); + } else { + finished.compareAndSet(false, true); + } + } + + private void fail(StreamingChatResponseHandler handler, + AtomicReference failure, + AtomicBoolean finished, + RuntimeException error) { + if (handler != null) { + errorOnce(finished, handler, error); + } else if (finished.compareAndSet(false, true)) { + failure.set(error); + } + } + + private void rememberMessageId(ChatEvent event, AtomicReference messageId) { + if (event.getMessage() != null && StrUtil.isNotBlank(event.getMessage().getId())) { + messageId.set(event.getMessage().getId()); + } + } + + private void completeOnce(AtomicBoolean finished, + StreamingChatResponseHandler handler, + String answer, + String messageId, + Chat chat) { + if (finished.compareAndSet(false, true)) { + handler.onCompleteResponse(toChatResponse(answer, messageId, chat)); + } + } + + private void errorOnce(AtomicBoolean finished, StreamingChatResponseHandler handler, Throwable error) { + if (finished.compareAndSet(false, true)) { + handler.onError(error); + } + } + + private ChatResponse toChatResponse(String answer, String messageId, Chat chat) { + return ChatResponse.builder() + .aiMessage(AiMessage.from(StrUtil.nullToDefault(answer, ""))) + .id(firstNotBlank(messageId, chat == null ? null : chat.getID())) + .modelName(chatModelVo.getModelName()) + .tokenUsage(toTokenUsage(chat == null ? null : chat.getUsage())) + .finishReason(FinishReason.STOP) + .build(); + } + + private TokenUsage toTokenUsage(ChatUsage usage) { + if (usage == null) { + return null; + } + return new TokenUsage( + usage.getInputTokens(), + usage.getOutputTokens(), + usage.getTokenCount() + ); + } + + private RuntimeException toFailure(ChatEvent event) { + Chat chat = event.getChat(); + if (chat != null && chat.getLastError() != null) { + ChatError error = chat.getLastError(); + return new IllegalStateException( + StrUtil.format("Coze call failed: code={}, message={}", error.getCode(), error.getMsg()) + ); + } + return new IllegalStateException("Coze call failed: " + event.getEvent()); + } + + private String messageText(ChatMessage message) { + if (message instanceof UserMessage userMessage) { + if (userMessage.hasSingleText()) { + return userMessage.singleText(); + } + return String.valueOf(userMessage.contents()); + } + if (message instanceof SystemMessage systemMessage) { + return systemMessage.text(); + } + if (message instanceof AiMessage aiMessage) { + return StrUtil.nullToDefault(aiMessage.text(), ""); + } + return String.valueOf(message); + } + + private void appendBlock(StringBuilder builder, String text) { + if (StrUtil.isBlank(text)) { + return; + } + if (!builder.isEmpty()) { + builder.append("\n\n"); + } + builder.append(text); + } + + private String normalizeBaseUrl(String baseUrl) { + if (StrUtil.isBlank(baseUrl)) { + throw new IllegalArgumentException("Coze apiHost cannot be blank"); + } + String value = StrUtil.removeSuffix(baseUrl.trim(), "/"); + if (!value.startsWith("http://") && !value.startsWith("https://")) { + value = "https://" + value; + } + return value; + } + + private String requiredApiKey() { + if (StrUtil.isBlank(chatModelVo.getApiKey())) { + throw new IllegalArgumentException("Coze apiKey cannot be blank"); + } + return chatModelVo.getApiKey(); + } + + private String requiredBotId() { + if (StrUtil.isBlank(chatModelVo.getModelName())) { + throw new IllegalArgumentException("Coze bot ID(modelName) cannot be blank"); + } + return chatModelVo.getModelName(); + } + + private String resolveUser() { + if (runtimeRequest != null && runtimeRequest.getUserId() != null) { + return String.valueOf(runtimeRequest.getUserId()); + } + if (runtimeRequest != null && runtimeRequest.getSessionId() != null) { + return "session-" + runtimeRequest.getSessionId(); + } + return "ruoyi-ai"; + } + + private String firstNotBlank(String first, String second) { + return StrUtil.isNotBlank(first) ? first : second; + } + + private void shutdown(CozeAPI coze) { + if (coze == null) { + return; + } + try { + coze.shutdownExecutor(); + } catch (Exception e) { + log.debug("Coze client shutdown failed: {}", e.getMessage()); + } + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImpl.java new file mode 100644 index 00000000..611ed5f3 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImpl.java @@ -0,0 +1,323 @@ +package org.ruoyi.service.chat.impl.provider; + +import cn.hutool.core.util.StrUtil; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.ModelProvider; +import dev.langchain4j.model.chat.Capability; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.listener.ChatModelListener; +import dev.langchain4j.model.chat.request.ChatRequestParameters; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import dev.langchain4j.model.output.FinishReason; +import dev.langchain4j.model.output.TokenUsage; +import io.github.imfangs.dify.client.DifyChatClient; +import io.github.imfangs.dify.client.DifyClientFactory; +import io.github.imfangs.dify.client.callback.ChatStreamCallback; +import io.github.imfangs.dify.client.enums.ResponseMode; +import io.github.imfangs.dify.client.event.AgentMessageEvent; +import io.github.imfangs.dify.client.event.BaseMessageEvent; +import io.github.imfangs.dify.client.event.ErrorEvent; +import io.github.imfangs.dify.client.event.MessageEndEvent; +import io.github.imfangs.dify.client.event.MessageEvent; +import io.github.imfangs.dify.client.event.MessageReplaceEvent; +import io.github.imfangs.dify.client.exception.DifyApiException; +import io.github.imfangs.dify.client.model.DifyConfig; +import io.github.imfangs.dify.client.model.common.Metadata; +import io.github.imfangs.dify.client.model.common.Usage; +import io.github.imfangs.dify.client.model.chat.ChatMessageResponse; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.observability.MyChatModelListener; +import org.ruoyi.service.chat.AbstractChatService; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Dify 服务调用。 + * + * 使用模型配置中的 apiHost 作为 Dify API 地址,apiKey 作为 Dify App API Key。 + */ +@Service +@Slf4j +public class DifyChatServiceImpl implements AbstractChatService { + + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final int READ_TIMEOUT_MS = 300_000; + private static final int WRITE_TIMEOUT_MS = 30_000; + + @Override + public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { + return new DifyLangChain4jChatModel(chatModelVo, chatRequest); + } + + @Override + public ChatModel buildChatModel(ChatModelVo chatModelVo) { + return new DifyLangChain4jChatModel(chatModelVo, null); + } + + @Override + public String getProviderName() { + return ChatModeType.DIFY.getCode(); + } + + private static class DifyLangChain4jChatModel implements StreamingChatModel, ChatModel { + + private final ChatModelVo chatModelVo; + private final ChatRequest runtimeRequest; + private final List listeners = List.of(new MyChatModelListener()); + + private DifyLangChain4jChatModel(ChatModelVo chatModelVo, ChatRequest runtimeRequest) { + this.chatModelVo = chatModelVo; + this.runtimeRequest = runtimeRequest; + } + + @Override + public void doChat(dev.langchain4j.model.chat.request.ChatRequest request, + StreamingChatResponseHandler handler) { + StringBuilder answerBuffer = new StringBuilder(); + AtomicBoolean finished = new AtomicBoolean(false); + AtomicReference messageId = new AtomicReference<>(); + AtomicReference metadata = new AtomicReference<>(); + + try { + DifyChatClient client = createClient(); + client.sendChatMessageStream(toDifyMessage(request, ResponseMode.STREAMING), new ChatStreamCallback() { + @Override + public void onMessage(MessageEvent event) { + appendChunk(event.getAnswer(), event, answerBuffer, messageId, handler); + } + + @Override + public void onAgentMessage(AgentMessageEvent event) { + appendChunk(event.getAnswer(), event, answerBuffer, messageId, handler); + } + + @Override + public void onMessageReplace(MessageReplaceEvent event) { + if (StrUtil.isBlank(event.getAnswer())) { + return; + } + answerBuffer.setLength(0); + answerBuffer.append(event.getAnswer()); + rememberMessageId(event, messageId); + log.debug("Dify 返回 message_replace 事件,当前 SSE 通道不支持替换已发送内容,仅用于最终消息落库"); + } + + @Override + public void onMessageEnd(MessageEndEvent event) { + metadata.set(event.getMetadata()); + rememberMessageId(event, messageId); + completeOnce(finished, handler, answerBuffer.toString(), messageId.get(), metadata.get()); + } + + @Override + public void onError(ErrorEvent event) { + String message = StrUtil.format("Dify 调用失败: status={}, code={}, message={}", + event.getStatus(), event.getCode(), event.getMessage()); + errorOnce(finished, handler, new IllegalStateException(message)); + } + + @Override + public void onException(Throwable throwable) { + errorOnce(finished, handler, throwable); + } + }); + } catch (IOException | DifyApiException e) { + errorOnce(finished, handler, e); + } + } + + @Override + public ChatResponse doChat(dev.langchain4j.model.chat.request.ChatRequest request) { + try { + DifyChatClient client = createClient(); + ChatMessageResponse response = client.sendChatMessage(toDifyMessage(request, ResponseMode.BLOCKING)); + return toChatResponse( + StrUtil.nullToDefault(response.getAnswer(), ""), + response.getMessageId(), + response.getMetadata() + ); + } catch (IOException | DifyApiException e) { + throw new IllegalStateException("Dify 调用失败", e); + } + } + + @Override + public ChatRequestParameters defaultRequestParameters() { + return ChatRequestParameters.builder() + .modelName(chatModelVo.getModelName()) + .build(); + } + + @Override + public List listeners() { + return listeners; + } + + @Override + public ModelProvider provider() { + return ModelProvider.OTHER; + } + + @Override + public Set supportedCapabilities() { + return Set.of(); + } + + private DifyChatClient createClient() { + DifyConfig config = DifyConfig.builder() + .baseUrl(normalizeBaseUrl(chatModelVo.getApiHost())) + .apiKey(requiredApiKey()) + .connectTimeout(CONNECT_TIMEOUT_MS) + .readTimeout(READ_TIMEOUT_MS) + .writeTimeout(WRITE_TIMEOUT_MS) + .build(); + return DifyClientFactory.createChatClient(config); + } + + private io.github.imfangs.dify.client.model.chat.ChatMessage toDifyMessage( + dev.langchain4j.model.chat.request.ChatRequest request, + ResponseMode responseMode) { + return io.github.imfangs.dify.client.model.chat.ChatMessage.builder() + .query(toDifyQuery(request.messages())) + .inputs(Map.of()) + .responseMode(responseMode) + .user(resolveUser()) + .autoGenerateName(Boolean.TRUE) + .build(); + } + + private String toDifyQuery(List messages) { + if (messages == null || messages.isEmpty()) { + return ""; + } + if (messages.size() == 1) { + return messageText(messages.get(0)); + } + + StringBuilder builder = new StringBuilder(); + builder.append("以下是当前会话上下文,请基于最后一条用户消息回答。").append("\n\n"); + for (ChatMessage message : messages) { + builder.append(roleName(message)).append(": ") + .append(messageText(message)) + .append("\n"); + } + return builder.toString().trim(); + } + + private String messageText(ChatMessage message) { + if (message instanceof UserMessage userMessage) { + if (userMessage.hasSingleText()) { + return userMessage.singleText(); + } + return String.valueOf(userMessage.contents()); + } + if (message instanceof SystemMessage systemMessage) { + return systemMessage.text(); + } + if (message instanceof AiMessage aiMessage) { + return StrUtil.nullToDefault(aiMessage.text(), ""); + } + return String.valueOf(message); + } + + private String roleName(ChatMessage message) { + return switch (message.type()) { + case SYSTEM -> "System"; + case USER -> "User"; + case AI -> "Assistant"; + case TOOL_EXECUTION_RESULT -> "Tool"; + case CUSTOM -> "Custom"; + }; + } + + private String resolveUser() { + if (runtimeRequest != null && runtimeRequest.getUserId() != null) { + return String.valueOf(runtimeRequest.getUserId()); + } + if (runtimeRequest != null && runtimeRequest.getSessionId() != null) { + return "session-" + runtimeRequest.getSessionId(); + } + return "ruoyi-ai"; + } + + private String normalizeBaseUrl(String baseUrl) { + if (StrUtil.isBlank(baseUrl)) { + throw new IllegalArgumentException("Dify 的请求地址(apiHost)不能为空"); + } + return StrUtil.removeSuffix(baseUrl, "/"); + } + + private String requiredApiKey() { + if (StrUtil.isBlank(chatModelVo.getApiKey())) { + throw new IllegalArgumentException("Dify 的 API Key 不能为空"); + } + return chatModelVo.getApiKey(); + } + + private void appendChunk(String chunk, BaseMessageEvent event, StringBuilder answerBuffer, + AtomicReference messageId, + StreamingChatResponseHandler handler) { + if (StrUtil.isBlank(chunk)) { + return; + } + answerBuffer.append(chunk); + rememberMessageId(event, messageId); + handler.onPartialResponse(chunk); + } + + private void rememberMessageId(BaseMessageEvent event, AtomicReference messageId) { + if (event != null && StrUtil.isNotBlank(event.getMessageId())) { + messageId.set(event.getMessageId()); + } + } + + private void completeOnce(AtomicBoolean finished, StreamingChatResponseHandler handler, + String answer, String messageId, Metadata metadata) { + if (finished.compareAndSet(false, true)) { + handler.onCompleteResponse(toChatResponse(answer, messageId, metadata)); + } + } + + private void errorOnce(AtomicBoolean finished, StreamingChatResponseHandler handler, Throwable error) { + if (finished.compareAndSet(false, true)) { + handler.onError(error); + } + } + + private ChatResponse toChatResponse(String answer, String messageId, Metadata metadata) { + return ChatResponse.builder() + .aiMessage(AiMessage.from(StrUtil.nullToDefault(answer, ""))) + .id(messageId) + .modelName(chatModelVo.getModelName()) + .tokenUsage(toTokenUsage(metadata)) + .finishReason(FinishReason.STOP) + .build(); + } + + private TokenUsage toTokenUsage(Metadata metadata) { + if (metadata == null || metadata.getUsage() == null) { + return null; + } + Usage usage = metadata.getUsage(); + return new TokenUsage( + usage.getPromptTokens(), + usage.getCompletionTokens(), + usage.getTotalTokens() + ); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java index eb3ed3ae..0b06614e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java @@ -13,6 +13,7 @@ import org.ruoyi.observability.MyChatModelListener; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; +import java.time.Duration; import java.util.List; @@ -33,6 +34,7 @@ public class OpenAIServiceImpl implements AbstractChatService { .baseUrl(chatModelVo.getApiHost()) .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) + .timeout(Duration.ofMinutes(30)) .listeners(List.of(new MyChatModelListener())) .returnThinking(chatRequest.getEnableThinking()) .build(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java index 920dbaf1..f65b5f32 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java @@ -14,6 +14,7 @@ import org.ruoyi.observability.MyChatModelListener; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; +import java.time.Duration; import java.util.List; @@ -28,11 +29,15 @@ import java.util.List; @RequiredArgsConstructor public class ZhiPuChatServiceImpl implements AbstractChatService { + private static final int MAX_TOKENS = 65536; + @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { return ZhipuAiStreamingChatModel.builder() .apiKey(chatModelVo.getApiKey()) .model(chatModelVo.getModelName()) + .maxToken(MAX_TOKENS) + .readTimeout(Duration.ofSeconds(300)) .listeners(List.of(new MyChatModelListener())) .build(); } @@ -42,6 +47,8 @@ public class ZhiPuChatServiceImpl implements AbstractChatService { return ZhipuAiChatModel.builder() .apiKey(chatModelVo.getApiKey()) .model(chatModelVo.getModelName()) + .maxToken(MAX_TOKENS) + .readTimeout(Duration.ofSeconds(300)) .build(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/AbstractImageGenerationService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/AbstractImageGenerationService.java index 4a9065a7..9ea0b945 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/AbstractImageGenerationService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/AbstractImageGenerationService.java @@ -25,15 +25,15 @@ public abstract class AbstractImageGenerationService implements IImageGeneration String size = imageContext.getSize(); // 获取随机数种子 Integer seed = imageContext.getSeed(); - return doGenerateImage(chatModelVo, prompt, size, seed); + // 获取参考图(图生图用) + String image = imageContext.getImage(); + return doGenerateImage(chatModelVo, prompt, size, seed, image); } /** * 执行生成图片(钩子方法 - 子类必须实现) - * - * @param prompt 提示词 */ - protected abstract String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed); + protected abstract String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image); /** * 构建具体厂商的 ImageModel(原生SDK 非langchain4j-dashscope版) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java new file mode 100644 index 00000000..1d7a478e --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java @@ -0,0 +1,286 @@ +package org.ruoyi.service.image.provider; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.entity.image.ImageContext; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.service.image.AbstractImageGenerationService; +import org.ruoyi.service.media.AtlasMediaSupport; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component("atlasImage") +public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationService { + + /** 比例 → 像素映射(AtlasCloud 要求 width*height,min 768 max 1360) */ + private static final Map SIZE_MAP = Map.of( + "1:1", "1024*1024", + "3:2", "1152*768", + "4:3", "1152*864", + "16:9", "1360*768", + "9:16", "768*1360" + ); + private static final Map GPT_IMAGE_SIZE_MAP = Map.of( + "1:1", "1024x1024", + "3:2", "1536x1024", + "4:3", "1536x1024", + "16:9", "1536x1024", + "9:16", "1024x1536" + ); + + private final OkHttpClient okHttpClient = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(300, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + + @Override + protected String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) { + ObjectNode payload = buildPayload(chatModelVo, prompt, size, seed, image); + payload.put("enable_sync_mode", true); + + Request request = new Request.Builder() + .url(AtlasMediaSupport.endpoint(chatModelVo.getApiHost(), "/model/generateImage")) + .addHeader("Authorization", "Bearer " + chatModelVo.getApiKey()) + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON)) + .build(); + try (Response response = okHttpClient.newCall(request).execute()) { + ResponseBody body = response.body(); + String responseText = body == null ? "" : body.string(); + if (!response.isSuccessful()) { + log.error("AtlasCloud 图片生成 HTTP {}: {}", response.code(), responseText); + throw new IllegalArgumentException("Atlas Cloud图片生成失败: " + response.code() + " - " + responseText); + } + log.info("AtlasCloud 图片生成完成,响应长度: {}", responseText.length()); + String url = extractImageUrl(responseText); + log.info("AtlasCloud 提取图片 URL: {}", url); + return url; + } catch (IOException e) { + throw new RuntimeException("Atlas Cloud图片生成失败: " + e.getMessage(), e); + } + } + + /** 从 AtlasCloud 同步模式响应中提取图片 URL */ + private String extractImageUrl(String responseText) { + if (StrUtil.isBlank(responseText)) return ""; + String preview = responseText.length() > 500 ? responseText.substring(0, 500) + "..." : responseText; + log.info("AtlasCloud 原始响应(前500字符): {}", preview); + try { + JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(responseText); + JsonNode data = root.path("data"); + String status = AtlasMediaSupport.text(data, "status"); + log.info("AtlasCloud 任务状态: {}, 任务ID: {}", status, AtlasMediaSupport.text(data, "id")); + JsonNode outputs = data.path("outputs"); + if (outputs.isArray() && !outputs.isEmpty()) { + String url = outputs.get(0).asText(); + log.info("AtlasCloud outputs[0]: {}", url); + if (StrUtil.isNotBlank(url)) return url; + } + if ("processing".equals(status) || "pending".equals(status)) { + log.warn("AtlasCloud 图片为异步任务,请轮询 prediction/{}", AtlasMediaSupport.text(data, "id")); + } + } catch (IOException e) { + log.warn("解析 AtlasCloud 图片响应失败: {}", e.getMessage()); + } + return ""; + } + + private ObjectNode buildPayload(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) { + String modelName = chatModelVo.getModelName(); + boolean gptImageModel = isGptImageModel(modelName); + ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode(); + payload.put("model", modelName); + payload.put("prompt", prompt); + if (StrUtil.isNotBlank(size)) { + payload.put("size", resolveSize(modelName, size)); + } + if (seed != null && !gptImageModel) { + payload.put("seed", seed); + } + if (gptImageModel) { + payload.put("enable_base64_output", false); + payload.put("output_format", "jpeg"); + payload.put("quality", "high"); + payload.put("moderation", "low"); + } + if (StrUtil.isNotBlank(image)) { + if (gptImageModel) { + payload.putArray("images").add(image); + } else { + // Atlas Cloud 图生图接口使用 image_url 接收 uploadMedia 返回的临时 URL。 + payload.put("image_url", image); + } + } + return payload; + } + + static boolean isGptImageModel(String modelName) { + return StrUtil.isNotBlank(modelName) && modelName.startsWith("openai/gpt-image-"); + } + + /** 根据 AtlasCloud 模型将比例转换为该模型支持的像素格式。 */ + static String resolveSize(String modelName, String size) { + if (StrUtil.isBlank(size)) return null; + String normalizedSize = size.trim(); + if (isGptImageModel(modelName)) { + String mappedSize = GPT_IMAGE_SIZE_MAP.get(normalizedSize); + if (mappedSize != null) return mappedSize; + return resolveGptImagePixelSize(normalizedSize); + } + if (normalizedSize.contains("*") || normalizedSize.contains("x")) return normalizedSize; + return SIZE_MAP.getOrDefault(normalizedSize, "1024*1024"); + } + + private static String resolveGptImagePixelSize(String size) { + String[] dimensions = size.toLowerCase().replace('*', 'x').split("x"); + if (dimensions.length != 2) return "1024x1024"; + try { + int width = Integer.parseInt(dimensions[0].trim()); + int height = Integer.parseInt(dimensions[1].trim()); + if (width == height) return "1024x1024"; + return width > height ? "1536x1024" : "1024x1536"; + } catch (NumberFormatException e) { + return "1024x1024"; + } + } + + @Override + public MediaGenerationResponse startImageGeneration(ImageContext imageContext) { + ChatModelVo chatModelVo = imageContext.getChatModelVo(); + String prompt = imageContext.getPrompt(); + String size = imageContext.getSize(); + Integer seed = imageContext.getSeed(); + String image = imageContext.getImage(); + + ObjectNode payload = buildPayload(chatModelVo, prompt, size, seed, image); + payload.put("enable_sync_mode", false); + + Request request = new Request.Builder() + .url(AtlasMediaSupport.endpoint(chatModelVo.getApiHost(), "/model/generateImage")) + .addHeader("Authorization", "Bearer " + chatModelVo.getApiKey()) + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON)) + .build(); + try (Response response = okHttpClient.newCall(request).execute()) { + ResponseBody body = response.body(); + String responseText = body == null ? "" : body.string(); + if (!response.isSuccessful()) { + log.error("AtlasCloud 异步图片生成 HTTP {}: {}", response.code(), responseText); + throw new IllegalArgumentException("Atlas Cloud图片生成失败: " + response.code() + " - " + responseText); + } + log.info("AtlasCloud 异步图片生成响应: {}", responseText.length() > 300 ? responseText.substring(0, 300) + "..." : responseText); + JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(responseText); + String predictionId = AtlasMediaSupport.text(root.path("data"), "id"); + if (StrUtil.isBlank(predictionId)) predictionId = AtlasMediaSupport.text(root, "id"); + if (StrUtil.isBlank(predictionId)) { + throw new IllegalStateException("Atlas Cloud图片任务启动成功但响应中没有任务ID: " + + (responseText.length() > 500 ? responseText.substring(0, 500) : responseText)); + } + log.info("AtlasCloud 异步任务 predictionId: {}", predictionId); + return MediaGenerationResponse.builder() + .type("image") + .id(predictionId) + .status("pending") + .rawResponse(responseText) + .build(); + } catch (IOException e) { + throw new RuntimeException("Atlas Cloud图片生成失败: " + e.getMessage(), e); + } + } + + @Override + public String uploadMedia(ChatModelVo model, byte[] content, String fileName, String contentType) { + if (content == null || content.length == 0) { + throw new IllegalArgumentException("上传文件不能为空"); + } + MediaType mediaType = MediaType.parse(StrUtil.blankToDefault(contentType, "application/octet-stream")); + RequestBody fileBody = RequestBody.create(content, mediaType); + MultipartBody requestBody = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", StrUtil.blankToDefault(fileName, "reference-image"), fileBody) + .build(); + Request request = new Request.Builder() + .url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/uploadMedia")) + .addHeader("Authorization", "Bearer " + model.getApiKey()) + .post(requestBody) + .build(); + try (Response response = okHttpClient.newCall(request).execute()) { + ResponseBody body = response.body(); + String responseText = body == null ? "" : body.string(); + if (!response.isSuccessful()) { + throw new IllegalArgumentException("Atlas Cloud文件上传失败: " + response.code() + " - " + responseText); + } + JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(responseText); + String url = findUploadedMediaUrl(root); + if (StrUtil.isBlank(url)) { + String preview = responseText.length() > 800 ? responseText.substring(0, 800) + "..." : responseText; + log.error("Atlas Cloud上传响应中未识别到媒体URL: {}", preview); + throw new IllegalStateException("Atlas Cloud文件上传成功但响应中没有可识别的媒体URL: " + preview); + } + return url; + } catch (IOException e) { + throw new RuntimeException("Atlas Cloud文件上传失败: " + e.getMessage(), e); + } + } + + /** 兼容 Atlas 不同网关版本的上传响应字段。 */ + private static String findUploadedMediaUrl(JsonNode node) { + if (node == null || node.isNull() || node.isMissingNode()) return null; + if (node.isTextual()) { + String value = node.asText(); + return isHttpUrl(value) ? value : null; + } + if (node.isObject()) { + for (String key : new String[]{"url", "file_url", "fileUrl", "image_url", "imageUrl", "media_url", "mediaUrl", "download_url", "downloadUrl"}) { + String value = node.path(key).asText(null); + if (isHttpUrl(value)) return value; + } + for (String key : new String[]{"data", "output", "outputs", "urls", "result", "file", "media"}) { + String value = findUploadedMediaUrl(node.path(key)); + if (StrUtil.isNotBlank(value)) return value; + } + java.util.Iterator children = node.elements(); + while (children.hasNext()) { + String value = findUploadedMediaUrl(children.next()); + if (StrUtil.isNotBlank(value)) return value; + } + } else if (node.isArray()) { + for (JsonNode child : node) { + String value = findUploadedMediaUrl(child); + if (StrUtil.isNotBlank(value)) return value; + } + } + return null; + } + + private static boolean isHttpUrl(String value) { + return StrUtil.isNotBlank(value) + && (value.startsWith("https://") || value.startsWith("http://")); + } + + @Override + protected Object buildImageModel(ChatModelVo chatModelVo) { + return chatModelVo; + } + + @Override + public String getProviderName() { + return ChatModeType.ATLAS.getCode(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/OpenAiImageGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/OpenAiImageGenerationServiceImpl.java new file mode 100644 index 00000000..8b2287e2 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/OpenAiImageGenerationServiceImpl.java @@ -0,0 +1,93 @@ +package org.ruoyi.service.image.provider; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.service.image.AbstractImageGenerationService; +import org.ruoyi.service.media.OpenAiMediaSupport; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component("openaiImage") +public class OpenAiImageGenerationServiceImpl extends AbstractImageGenerationService { + + private final OkHttpClient okHttpClient = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(180, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + + @Override + protected String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) { + ObjectNode payload = OpenAiMediaSupport.OBJECT_MAPPER.createObjectNode(); + payload.put("model", chatModelVo.getModelName()); + payload.put("prompt", prompt); + payload.put("n", 1); + if (StrUtil.isNotBlank(size)) { + payload.put("size", size); + } + if (seed != null && !isGptImageModel(chatModelVo.getModelName())) { + payload.put("seed", seed); + } + + try { + String response = postJson(chatModelVo, "/images/generations", payload.toString()); + JsonNode root = OpenAiMediaSupport.OBJECT_MAPPER.readTree(response); + JsonNode first = root.path("data").isArray() && !root.path("data").isEmpty() ? root.path("data").get(0) : null; + if (first == null) { + return ""; + } + String url = OpenAiMediaSupport.text(first, "url"); + if (StrUtil.isNotBlank(url)) { + return url; + } + String b64 = OpenAiMediaSupport.text(first, "b64_json"); + return StrUtil.isBlank(b64) ? "" : OpenAiMediaSupport.dataUrl("image/png", b64); + } catch (IOException e) { + throw new RuntimeException("图片生成失败: " + e.getMessage(), e); + } + } + + /** gpt-image 系列不支持 seed 参数,传入会被拒。 */ + private static boolean isGptImageModel(String modelName) { + return StrUtil.isNotBlank(modelName) && modelName.contains("gpt-image"); + } + + @Override + protected Object buildImageModel(ChatModelVo chatModelVo) { + return chatModelVo; + } + + @Override + public String getProviderName() { + return ChatModeType.OPEN_AI.getCode(); + } + + private String postJson(ChatModelVo model, String path, String jsonBody) throws IOException { + Request request = new Request.Builder() + .url(OpenAiMediaSupport.endpoint(model.getApiHost(), path)) + .addHeader("Authorization", "Bearer " + model.getApiKey()) + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(jsonBody, OpenAiMediaSupport.JSON)) + .build(); + try (Response response = okHttpClient.newCall(request).execute()) { + ResponseBody body = response.body(); + String responseText = body == null ? "" : body.string(); + if (!response.isSuccessful()) { + throw new IllegalArgumentException("OpenAI Images API调用失败: " + response.code() + " - " + responseText); + } + return responseText; + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java index 090ca327..c188cae7 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java @@ -33,7 +33,7 @@ public class TongYiWanxImageServiceImpl extends AbstractImageGenerationService { private final static String IMAGE_DEFAULT_RESOLUTION = "1280*1280"; @Override - protected String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed) { + protected String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) { // 构建万相模型对象 var param = (ImageSynthesisParam) buildImageModel(chatModelVo); // 设置图片大小和提示词以及随机数种子 diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasMediaSupport.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasMediaSupport.java new file mode 100644 index 00000000..b99aa3b6 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasMediaSupport.java @@ -0,0 +1,38 @@ +package org.ruoyi.service.media; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.MediaType; + +public final class AtlasMediaSupport { + + public static final MediaType JSON = MediaType.get("application/json"); + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private AtlasMediaSupport() { + } + + public static String endpoint(String apiHost, String path) { + if (StrUtil.isBlank(apiHost)) { + throw new IllegalArgumentException("apiHost不能为空"); + } + String host = StrUtil.removeSuffix(apiHost.trim(), "/"); + String normalizedPath = path.startsWith("/") ? path : "/" + path; + if (host.endsWith(normalizedPath)) { + return host; + } + if (host.endsWith("/api/v1")) { + return host + normalizedPath; + } + if (host.endsWith("/v1")) { + return StrUtil.removeSuffix(host, "/v1") + "/api/v1" + normalizedPath; + } + return host + "/api/v1" + normalizedPath; + } + + public static String text(JsonNode node, String field) { + JsonNode value = node == null ? null : node.get(field); + return value == null || value.isNull() ? null : value.asText(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java new file mode 100644 index 00000000..bdf4f616 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java @@ -0,0 +1,82 @@ +package org.ruoyi.service.media; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +public class AtlasPredictionService { + + private final OkHttpClient okHttpClient = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(180, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + + public MediaGenerationResponse retrieve(ChatModelVo model, String predictionId) { + if (StrUtil.isBlank(predictionId)) { + throw new IllegalArgumentException("predictionId不能为空"); + } + Request request = new Request.Builder() + .url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/prediction/" + predictionId)) + .addHeader("Authorization", "Bearer " + model.getApiKey()) + .get() + .build(); + try (Response response = okHttpClient.newCall(request).execute()) { + ResponseBody body = response.body(); + String responseText = body == null ? "" : body.string(); + if (!response.isSuccessful()) { + throw new IllegalArgumentException("Atlas Cloud生成结果查询失败: " + response.code() + " - " + responseText); + } + return toResponse(responseText, mediaType(model.getCategory())); + } catch (IOException e) { + throw new RuntimeException("Atlas Cloud生成结果查询失败: " + e.getMessage(), e); + } + } + + public MediaGenerationResponse toResponse(String raw, String type) throws IOException { + JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(raw); + JsonNode data = root.path("data"); + return MediaGenerationResponse.builder() + .type(type) + .mimeType("image".equals(type) ? "image/png" : "video/mp4") + .id(AtlasMediaSupport.text(data, "id")) + .status(AtlasMediaSupport.text(data, "status")) + .url(firstOutput(data)) + .rawResponse(raw) + .build(); + } + + private String firstOutput(JsonNode data) { + JsonNode outputs = data.path("outputs"); + if (outputs.isArray() && !outputs.isEmpty()) { + JsonNode first = outputs.get(0); + // 字符串格式:outputs[0] = "https://..." + if (first.isTextual()) return first.asText(); + // 对象格式:outputs[0] = {"url": "https://..."} + if (first.isObject()) { + String url = first.path("url").asText(null); + if (url != null) return url; + url = first.path("video_url").asText(null); + if (url != null) return url; + log.warn("outputs[0] 为对象但未找到 url 字段: {}", first); + } + } + return null; + } + + private String mediaType(String category) { + return "image".equals(category) ? "image" : "video"; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/OpenAiMediaSupport.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/OpenAiMediaSupport.java new file mode 100644 index 00000000..ef99555a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/OpenAiMediaSupport.java @@ -0,0 +1,45 @@ +package org.ruoyi.service.media; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.MediaType; + +import java.util.Base64; + +public final class OpenAiMediaSupport { + + public static final MediaType JSON = MediaType.get("application/json"); + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private OpenAiMediaSupport() { + } + + public static String endpoint(String apiHost, String path) { + if (StrUtil.isBlank(apiHost)) { + throw new IllegalArgumentException("apiHost不能为空"); + } + String host = StrUtil.removeSuffix(apiHost.trim(), "/"); + String normalizedPath = path.startsWith("/") ? path : "/" + path; + if (host.endsWith(normalizedPath)) { + return host; + } + if (host.endsWith("/v1")) { + return host + normalizedPath; + } + return host + "/v1" + normalizedPath; + } + + public static String text(JsonNode node, String field) { + JsonNode value = node == null ? null : node.get(field); + return value == null || value.isNull() ? null : value.asText(); + } + + public static String dataUrl(String mimeType, byte[] bytes) { + return "data:" + mimeType + ";base64," + Base64.getEncoder().encodeToString(bytes); + } + + public static String dataUrl(String mimeType, String b64Json) { + return StrUtil.isBlank(b64Json) ? null : "data:" + mimeType + ";base64," + b64Json; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java new file mode 100644 index 00000000..b383f567 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java @@ -0,0 +1,116 @@ +package org.ruoyi.service.shortdrama; + +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaLocationVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaProjectVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaStoryboardVo; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public interface IShortDramaService { + + List listProjects(Long userId); + + ShortDramaDetailVo getDetail(Long projectId, Long userId); + + ShortDramaDetailVo createFromIdea(ShortDramaIdeaBo bo, Long userId); + + /** 流式创建短剧,通过 SSE 推送各阶段进度 */ + SseEmitter createFromIdeaStream(ShortDramaIdeaBo bo, Long userId); + + Long saveProject(ShortDramaProjectBo bo, Long userId); + + ShortDramaScriptVo saveScript(ShortDramaScriptBo bo, Long userId); + + List generateStoryboards(Long projectId, Long scriptId, String model, Long userId); + + ShortDramaStoryboardVo saveStoryboard(ShortDramaStoryboardBo bo, Long userId); + + ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId); + + ShortDramaStoryboardVo retrieveVideo(Long storyboardId, String videoModel, Long userId); + + /** 代理流式输出视频,绕过 OSS 强制下载 header */ + StreamingResponseBody streamVideo(Long storyboardId, Long userId); + + List generateAllVideos(Long projectId, String videoModel, Long userId); + + ShortDramaDetailVo analyzeAssets(Long projectId, Long scriptId, Long userId); + + /** Phase 1: 剧本打磨,重新生成更丰富的剧本内容 */ + ShortDramaDetailVo polishScript(Long projectId, Long userId); + + /** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */ + List planStoryboard(Long projectId, Long scriptId, String model, Long userId); + + /** Phase 3-6: 流式生成分镜并推送实时进度 */ + SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId); + + /** Phase 4: 重新生成摄影规则 */ + List generatePhotographyRules(Long projectId, Long scriptId, Long userId); + + /** Phase 5: 重新生成表演指导 */ + List generateActingDirections(Long projectId, Long scriptId, Long userId); + + ShortDramaCharacterVo saveCharacter(ShortDramaCharacterBo bo, Long userId); + + ShortDramaLocationVo saveLocation(ShortDramaLocationBo bo, Long userId); + + ShortDramaCharacterVo generateCharacterImage(Long characterId, String imageModel, String referenceImageUrl, Long userId); + + ShortDramaLocationVo generateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId); + + Boolean deleteCharacter(Long characterId, Long userId); + + Boolean deleteLocation(Long locationId, Long userId); + + ShortDramaCharacterAppearanceVo saveAppearance(ShortDramaCharacterAppearanceBo bo, Long userId); + + Boolean deleteAppearance(Long appearanceId, Long userId); + + ShortDramaCharacterAppearanceVo generateAppearanceImage(Long appearanceId, String imageModel, String referenceImageUrl, Long userId); + + ShortDramaCharacterAppearanceVo regenerateAppearanceImage(Long appearanceId, String imageModel, String referenceImageUrl, Long userId); + + ShortDramaCharacterAppearanceVo selectAppearanceImage(Long appearanceId, Integer index, Long userId); + + ShortDramaCharacterAppearanceVo deleteAppearanceImage(Long appearanceId, Integer index, Long userId); + + ShortDramaCharacterAppearanceVo undoAppearanceImage(Long appearanceId, Long userId); + + ShortDramaLocationVo regenerateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId); + + ShortDramaLocationVo selectLocationImage(Long locationId, Integer index, Long userId); + + ShortDramaLocationVo deleteLocationImage(Long locationId, Integer index, Long userId); + + ShortDramaLocationVo undoLocationImage(Long locationId, Long userId); + + /** 异步启动图片生成,返回 prediction 信息供前端轮询 */ + MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl, Long userId); + + /** 上传到图片模型供应商,返回仅用于当前生成会话的临时参考图 URL。 */ + String uploadReferenceImage(MultipartFile file, String model, Long userId); + + /** 轮询确认形象图片生成结果并保存 */ + ShortDramaCharacterAppearanceVo confirmAppearanceImage(Long appearanceId, String predictionId, String model, Long userId); + + /** 轮询确认场景图片生成结果并保存 */ + ShortDramaLocationVo confirmLocationImage(Long locationId, String predictionId, String model, Long userId); + + Boolean deleteProject(Long projectId, Long userId); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaVideoComposeService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaVideoComposeService.java new file mode 100644 index 00000000..178e5ec7 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaVideoComposeService.java @@ -0,0 +1,19 @@ +package org.ruoyi.service.shortdrama; + +import org.ruoyi.domain.bo.shortdrama.ShortDramaComposeVideoBo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo; + +import java.nio.file.Path; + +public interface IShortDramaVideoComposeService { + + ShortDramaComposeVideoVo composeVideo(Long projectId, ShortDramaComposeVideoBo bo, Long userId); + + ShortDramaComposeVideoVo getComposedVideo(Long projectId, Long userId); + + Path getLocalComposedVideo(Long projectId, Long userId); + + void invalidateComposition(Long projectId); + + void deleteComposition(Long projectId); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/AspectRatio.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/AspectRatio.java new file mode 100644 index 00000000..5c44dfc3 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/AspectRatio.java @@ -0,0 +1,46 @@ +package org.ruoyi.service.shortdrama.composition; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum AspectRatio { + PORTRAIT("9:16", 1080, 1920), + LANDSCAPE("16:9", 1920, 1080), + SQUARE("1:1", 1080, 1080); + + private final String value; + private final int width; + private final int height; + + AspectRatio(String value, int width, int height) { + this.value = value; + this.width = width; + this.height = height; + } + + @JsonValue + public String value() { + return value; + } + + public int width() { + return width; + } + + public int height() { + return height; + } + + @JsonCreator + public static AspectRatio fromValue(String value) { + if (value == null || value.isBlank()) { + return PORTRAIT; + } + for (AspectRatio ratio : values()) { + if (ratio.value.equals(value.trim())) { + return ratio; + } + } + throw new IllegalArgumentException("Unsupported aspect ratio: " + value); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionArtifact.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionArtifact.java new file mode 100644 index 00000000..dd63667a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionArtifact.java @@ -0,0 +1,18 @@ +package org.ruoyi.service.shortdrama.composition; + +import java.nio.file.Path; +import java.util.Objects; + +public record CompositionArtifact( + Path path, + double durationSeconds, + int width, + int height, + TransitionType transitionType +) { + + public CompositionArtifact { + path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + transitionType = Objects.requireNonNull(transitionType, "transitionType"); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSource.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSource.java new file mode 100644 index 00000000..ccc1fe0a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSource.java @@ -0,0 +1,15 @@ +package org.ruoyi.service.shortdrama.composition; + +import java.nio.file.Path; +import java.util.Objects; + +public record CompositionSource(Path path, Double fallbackDurationSeconds) { + + public CompositionSource { + path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + if (fallbackDurationSeconds != null + && (!Double.isFinite(fallbackDurationSeconds) || fallbackDurationSeconds <= 0)) { + throw new IllegalArgumentException("fallbackDurationSeconds must be positive"); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSpec.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSpec.java new file mode 100644 index 00000000..57c61985 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSpec.java @@ -0,0 +1,29 @@ +package org.ruoyi.service.shortdrama.composition; + +import java.util.List; +import java.util.Objects; + +public record CompositionSpec( + List sources, + TransitionType transitionType, + double transitionDurationSeconds, + AspectRatio aspectRatio +) { + + public CompositionSpec { + sources = List.copyOf(Objects.requireNonNull(sources, "sources")); + if (sources.isEmpty()) { + throw new IllegalArgumentException("At least one video source is required"); + } + transitionType = transitionType == null ? TransitionType.NONE : transitionType; + aspectRatio = aspectRatio == null ? AspectRatio.PORTRAIT : aspectRatio; + if (!Double.isFinite(transitionDurationSeconds) || transitionDurationSeconds < 0) { + throw new IllegalArgumentException("transitionDurationSeconds must be non-negative"); + } + if (transitionType == TransitionType.NONE) { + transitionDurationSeconds = 0; + } else if (transitionDurationSeconds <= 0) { + throw new IllegalArgumentException("A transition duration is required for " + transitionType.value()); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilder.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilder.java new file mode 100644 index 00000000..fc140d67 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilder.java @@ -0,0 +1,72 @@ +package org.ruoyi.service.shortdrama.composition; + +import org.springframework.stereotype.Component; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +@Component +public class FfmpegCommandBuilder { + + private final FfmpegCompositionProperties properties; + + public FfmpegCommandBuilder(FfmpegCompositionProperties properties) { + this.properties = properties; + } + + public List build( + CompositionSpec spec, + FfmpegFilterGraph filterGraph, + Path filterScript, + Path output + ) { + List command = new ArrayList<>(); + command.add(properties.getFfmpegPath()); + command.add("-hide_banner"); + command.add("-loglevel"); + command.add("warning"); + command.add("-nostdin"); + command.add("-y"); + for (CompositionSource source : spec.sources()) { + command.add("-i"); + command.add(source.path().toString()); + } + command.add("-filter_complex_script"); + command.add(filterScript.toAbsolutePath().normalize().toString()); + command.add("-map"); + command.add("[" + filterGraph.videoLabel() + "]"); + command.add("-map"); + command.add("[" + filterGraph.audioLabel() + "]"); + command.add("-c:v"); + command.add(properties.getVideoCodec()); + command.add("-preset"); + command.add(properties.getPreset()); + command.add("-crf"); + command.add(Integer.toString(properties.getCrf())); + command.add("-pix_fmt"); + command.add("yuv420p"); + command.add("-r"); + command.add(Integer.toString(filterGraph.canvas().fps())); + command.add("-c:a"); + command.add(properties.getAudioCodec()); + command.add("-b:a"); + command.add(properties.getAudioBitrate()); + command.add("-ar"); + command.add(Integer.toString(properties.getAudioSampleRate())); + command.add("-ac"); + command.add("2"); + command.add("-t"); + command.add(FfmpegFilterGraphBuilder.seconds(filterGraph.expectedDurationSeconds())); + command.add("-map_metadata"); + command.add("-1"); + command.add("-map_chapters"); + command.add("-1"); + command.add("-max_muxing_queue_size"); + command.add("1024"); + command.add("-movflags"); + command.add("+faststart"); + command.add(output.toAbsolutePath().normalize().toString()); + return List.copyOf(command); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionProperties.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionProperties.java new file mode 100644 index 00000000..c71bb3e6 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionProperties.java @@ -0,0 +1,68 @@ +package org.ruoyi.service.shortdrama.composition; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.Duration; + +@Data +@Component +@ConfigurationProperties(prefix = "short-drama.composition") +public class FfmpegCompositionProperties { + + private String ffmpegPath = "ffmpeg"; + private String ffprobePath = "ffprobe"; + private int fps = 30; + private int audioSampleRate = 48_000; + private String videoCodec = "libx264"; + private String audioCodec = "aac"; + private String preset = "medium"; + private int crf = 20; + private String audioBitrate = "192k"; + private int maxClips = 100; + private double maxTransitionSeconds = 2.0; + private long minimumOutputBytes = 1_024; + private int maxProcessOutputBytes = 1_048_576; + private long maxSourceBytes = 512L * 1024 * 1024; + private long maxTotalSourceBytes = 2L * 1024 * 1024 * 1024; + private Duration probeTimeout = Duration.ofSeconds(30); + private Duration processTimeout = Duration.ofMinutes(30); + private Duration jobStaleAfter = Duration.ofMinutes(45); + private int workerCoreSize = 1; + private int workerMaxSize = 2; + private int workerQueueCapacity = 8; + private String storageMode = "local"; + private String localOutputDirectory = "logs/short-drama-compositions"; + + public BigDecimal normalizeTransitionDuration(TransitionType type, BigDecimal requested) { + if (type == null || type == TransitionType.NONE) { + return BigDecimal.ZERO; + } + if (requested == null || requested.signum() <= 0) { + throw new IllegalArgumentException("Transition duration must be positive"); + } + if (fps <= 0) { + throw new IllegalArgumentException("Composition fps must be positive"); + } + if (!Double.isFinite(maxTransitionSeconds) || maxTransitionSeconds <= 0) { + throw new IllegalArgumentException("Maximum transition duration must be positive and finite"); + } + + BigDecimal normalized = requested.stripTrailingZeros(); + if (normalized.scale() > 3) { + throw new IllegalArgumentException("Transition duration supports at most 3 decimal places"); + } + if (normalized.multiply(BigDecimal.valueOf(fps)).compareTo(BigDecimal.ONE) < 0) { + throw new IllegalArgumentException("Transition duration must be at least one output frame"); + } + if (normalized.compareTo(BigDecimal.valueOf(maxTransitionSeconds)) > 0) { + throw new IllegalArgumentException( + "Transition duration exceeds " + BigDecimal.valueOf(maxTransitionSeconds).stripTrailingZeros().toPlainString() + + " seconds" + ); + } + return normalized; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionWorker.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionWorker.java new file mode 100644 index 00000000..9e18c2c3 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionWorker.java @@ -0,0 +1,27 @@ +package org.ruoyi.service.shortdrama.composition; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; + +@Service +@RequiredArgsConstructor +public class FfmpegCompositionWorker { + + private final FfmpegVideoComposer videoComposer; + + @Async("videoCompositionExecutor") + public CompletableFuture composeAsync( + CompositionSpec spec, + Path workDirectory + ) { + try { + return CompletableFuture.completedFuture(videoComposer.compose(spec, workDirectory)); + } catch (Exception ex) { + return CompletableFuture.failedFuture(ex); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraph.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraph.java new file mode 100644 index 00000000..cf5202a6 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraph.java @@ -0,0 +1,10 @@ +package org.ruoyi.service.shortdrama.composition; + +public record FfmpegFilterGraph( + String value, + String videoLabel, + String audioLabel, + double expectedDurationSeconds, + VideoCanvas canvas +) { +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilder.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilder.java new file mode 100644 index 00000000..9b8ae055 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilder.java @@ -0,0 +1,176 @@ +package org.ruoyi.service.shortdrama.composition; + +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +@Component +public class FfmpegFilterGraphBuilder { + + private final FfmpegCompositionProperties properties; + + public FfmpegFilterGraphBuilder(FfmpegCompositionProperties properties) { + this.properties = properties; + } + + public FfmpegFilterGraph build(CompositionSpec spec, List media) { + if (media.size() != spec.sources().size()) { + throw new IllegalArgumentException("Media metadata count does not match source count"); + } + if (media.size() > properties.getMaxClips()) { + throw new IllegalArgumentException("Too many video clips; maximum is " + properties.getMaxClips()); + } + + VideoCanvas canvas = new VideoCanvas( + spec.aspectRatio().width(), + spec.aspectRatio().height(), + properties.getFps() + ); + double frameSeconds = 1.0 / canvas.fps(); + List filters = new ArrayList<>(); + + for (int index = 0; index < media.size(); index++) { + MediaInfo info = media.get(index); + if (info.durationSeconds() < frameSeconds) { + throw new IllegalArgumentException("Video clip " + (index + 1) + " is shorter than one output frame"); + } + String duration = seconds(info.durationSeconds()); + filters.add(normalizeVideo(index, info.videoStreamIndex(), duration, canvas)); + filters.add(normalizeAudio(index, duration, info.hasAudio())); + } + + if (spec.transitionType() == TransitionType.NONE) { + return hardCut(filters, media, canvas); + } + return transition(filters, spec, media, canvas, frameSeconds); + } + + private FfmpegFilterGraph hardCut(List filters, List media, VideoCanvas canvas) { + double totalDuration = media.stream().mapToDouble(MediaInfo::durationSeconds).sum(); + if (media.size() == 1) { + return new FfmpegFilterGraph( + String.join(";", filters), + "v0", + "a0", + totalDuration, + canvas + ); + } + + StringBuilder inputs = new StringBuilder(); + for (int index = 0; index < media.size(); index++) { + inputs.append("[v").append(index).append("][a").append(index).append("]"); + } + filters.add(inputs + "concat=n=" + media.size() + ":v=1:a=1[vout][aout]"); + return new FfmpegFilterGraph( + String.join(";", filters), + "vout", + "aout", + totalDuration, + canvas + ); + } + + private FfmpegFilterGraph transition( + List filters, + CompositionSpec spec, + List media, + VideoCanvas canvas, + double frameSeconds + ) { + BigDecimal normalizedTransition = properties.normalizeTransitionDuration( + spec.transitionType(), + BigDecimal.valueOf(spec.transitionDurationSeconds()) + ); + double transitionSeconds = normalizedTransition.doubleValue(); + BigDecimal twoTransitionWindows = normalizedTransition.multiply(BigDecimal.valueOf(2)); + for (int index = 1; index < media.size() - 1; index++) { + if (BigDecimal.valueOf(media.get(index).durationSeconds()).compareTo(twoTransitionWindows) < 0) { + throw new IllegalArgumentException( + "Transition windows overlap in internal clip " + (index + 1) + ); + } + } + + String currentVideo = "v0"; + String currentAudio = "a0"; + double currentDuration = media.get(0).durationSeconds(); + for (int index = 1; index < media.size(); index++) { + double adjacentLimit = Math.min( + media.get(index - 1).durationSeconds(), + media.get(index).durationSeconds() + ) - frameSeconds; + if (transitionSeconds > adjacentLimit) { + throw new IllegalArgumentException( + "Transition is too long for clips " + index + " and " + (index + 1) + ); + } + + double offset = currentDuration - transitionSeconds; + String nextVideo = "vx" + index; + String nextAudio = "ax" + index; + filters.add("[" + currentVideo + "][v" + index + "]xfade=transition=" + + spec.transitionType().ffmpegName() + + ":duration=" + seconds(transitionSeconds) + + ":offset=" + seconds(offset) + + "[" + nextVideo + "]"); + filters.add("[" + currentAudio + "][a" + index + "]acrossfade=d=" + + seconds(transitionSeconds) + + ":c1=tri:c2=tri[" + nextAudio + "]"); + currentVideo = nextVideo; + currentAudio = nextAudio; + currentDuration += media.get(index).durationSeconds() - transitionSeconds; + } + + return new FfmpegFilterGraph( + String.join(";", filters), + currentVideo, + currentAudio, + currentDuration, + canvas + ); + } + + private String normalizeVideo(int inputIndex, int videoStreamIndex, String duration, VideoCanvas canvas) { + return "[" + inputIndex + ":" + videoStreamIndex + "]" + + "scale=" + canvas.width() + ":" + canvas.height() + + ":force_original_aspect_ratio=decrease:force_divisible_by=2," + + "pad=" + canvas.width() + ":" + canvas.height() + + ":(ow-iw)/2:(oh-ih)/2:color=black," + + "fps=" + canvas.fps() + "," + + "setsar=1,format=yuv420p," + + "tpad=stop_mode=clone:stop_duration=" + duration + "," + + "trim=start=0:duration=" + duration + "," + + "settb=AVTB,setpts=PTS-STARTPTS" + + "[v" + inputIndex + "]"; + } + + private String normalizeAudio(int index, String duration, boolean hasAudio) { + String format = "aformat=sample_fmts=fltp:sample_rates=" + properties.getAudioSampleRate() + + ":channel_layouts=stereo"; + if (hasAudio) { + return "[" + index + ":a:0]" + + "aresample=" + properties.getAudioSampleRate() + ":async=1:first_pts=0," + + format + "," + + "apad=pad_dur=" + duration + "," + + "atrim=start=0:duration=" + duration + "," + + "asetpts=PTS-STARTPTS" + + "[a" + index + "]"; + } + return "anullsrc=r=" + properties.getAudioSampleRate() + ":cl=stereo," + + format + "," + + "atrim=start=0:duration=" + duration + "," + + "asetpts=PTS-STARTPTS" + + "[a" + index + "]"; + } + + static String seconds(double value) { + return BigDecimal.valueOf(value) + .setScale(6, RoundingMode.HALF_UP) + .stripTrailingZeros() + .toPlainString(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegMediaProbe.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegMediaProbe.java new file mode 100644 index 00000000..4ca125d9 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegMediaProbe.java @@ -0,0 +1,128 @@ +package org.ruoyi.service.shortdrama.composition; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class FfmpegMediaProbe { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final FfmpegCompositionProperties properties; + private final FfmpegProcessRunner processRunner; + + public MediaInfo probe(Path input, Double fallbackDurationSeconds, Path logPath) { + List command = new ArrayList<>(); + command.add(properties.getFfprobePath()); + command.add("-v"); + command.add("error"); + command.add("-show_entries"); + command.add("stream=index,codec_type,width,height,duration:stream_tags=rotate:stream_side_data=rotation:stream_disposition=attached_pic:format=duration"); + command.add("-of"); + command.add("json"); + command.add(input.toAbsolutePath().normalize().toString()); + + MediaProcessResult result = processRunner.run( + command, + properties.getProbeTimeout(), + logPath, + properties.getMaxProcessOutputBytes() + ); + return parseProbeOutput(result.output(), fallbackDurationSeconds); + } + + static MediaInfo parseProbeOutput(String json, Double fallbackDurationSeconds) { + JsonNode root; + try { + root = OBJECT_MAPPER.readTree(json); + } catch (Exception ex) { + throw new MediaProcessException("ffprobe returned invalid JSON", ex); + } + if (root == null) { + throw new MediaProcessException("ffprobe returned an empty response"); + } + + JsonNode video = null; + boolean hasAudio = false; + JsonNode streams = root.path("streams"); + if (streams.isArray()) { + for (JsonNode stream : streams) { + String codecType = stream.path("codec_type").asText(""); + if ("audio".equals(codecType)) { + hasAudio = true; + } else if (video == null && "video".equals(codecType) + && stream.path("disposition").path("attached_pic").asInt(0) != 1) { + video = stream; + } + } + } + if (video == null) { + throw new MediaProcessException("Input does not contain a video stream"); + } + int videoStreamIndex = video.path("index").asInt(-1); + if (videoStreamIndex < 0) { + throw new MediaProcessException("ffprobe did not report the selected video stream index"); + } + + int width = video.path("width").asInt(0); + int height = video.path("height").asInt(0); + int rotation = readRotation(video); + if (Math.floorMod(rotation, 180) != 0) { + int originalWidth = width; + width = height; + height = originalWidth; + } + + Double duration = positiveNumber(video.path("duration")); + if (duration == null) { + duration = positiveNumber(root.path("format").path("duration")); + } + if (duration == null && fallbackDurationSeconds != null + && Double.isFinite(fallbackDurationSeconds) && fallbackDurationSeconds > 0) { + duration = fallbackDurationSeconds; + } + if (duration == null) { + throw new MediaProcessException("Unable to determine input video duration"); + } + return new MediaInfo(duration, width, height, hasAudio, videoStreamIndex); + } + + private static int readRotation(JsonNode video) { + JsonNode sideData = video.path("side_data_list"); + if (sideData.isArray()) { + for (JsonNode item : sideData) { + if (item.has("rotation")) { + return item.path("rotation").asInt(0); + } + } + } + String tag = video.path("tags").path("rotate").asText(null); + if (tag != null) { + try { + return Integer.parseInt(tag); + } catch (NumberFormatException ignored) { + return 0; + } + } + return 0; + } + + private static Double positiveNumber(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return null; + } + try { + double value = node.isNumber() ? node.asDouble() : Double.parseDouble(node.asText()); + return Double.isFinite(value) && value > 0 ? value : null; + } catch (NumberFormatException ex) { + return null; + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegProcessRunner.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegProcessRunner.java new file mode 100644 index 00000000..951c6ad8 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegProcessRunner.java @@ -0,0 +1,96 @@ +package org.ruoyi.service.shortdrama.composition; + +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +@Component +public class FfmpegProcessRunner { + + public MediaProcessResult run(List command, Duration timeout, Path logPath, int maxOutputBytes) { + Objects.requireNonNull(command, "command"); + Objects.requireNonNull(timeout, "timeout"); + Objects.requireNonNull(logPath, "logPath"); + if (command.isEmpty()) { + throw new IllegalArgumentException("Command must not be empty"); + } + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("Process timeout must be positive"); + } + if (maxOutputBytes <= 0) { + throw new IllegalArgumentException("maxOutputBytes must be positive"); + } + + Process process = null; + try { + Path absoluteLog = logPath.toAbsolutePath().normalize(); + Files.createDirectories(absoluteLog.getParent()); + ProcessBuilder builder = new ProcessBuilder(List.copyOf(command)); + builder.redirectErrorStream(true); + builder.redirectOutput(absoluteLog.toFile()); + process = builder.start(); + + if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) { + stop(process); + String tail = readTail(absoluteLog, maxOutputBytes); + throw new MediaProcessException(command.get(0) + " timed out after " + timeout + formatOutput(tail)); + } + + int exitCode = process.exitValue(); + String output = readTail(absoluteLog, maxOutputBytes); + if (exitCode != 0) { + throw new MediaProcessException(command.get(0) + " exited with code " + exitCode + formatOutput(output)); + } + return new MediaProcessResult(exitCode, output); + } catch (InterruptedException ex) { + if (process != null) { + process.destroyForcibly(); + } + Thread.currentThread().interrupt(); + throw new MediaProcessException(command.get(0) + " was interrupted", ex); + } catch (IOException ex) { + if (process != null) { + process.destroyForcibly(); + } + throw new MediaProcessException("Unable to execute " + command.get(0) + ": " + ex.getMessage(), ex); + } + } + + private static void stop(Process process) throws InterruptedException { + process.destroy(); + if (!process.waitFor(2, TimeUnit.SECONDS)) { + process.destroyForcibly(); + process.waitFor(2, TimeUnit.SECONDS); + } + } + + static String readTail(Path path, int maxBytes) throws IOException { + if (!Files.exists(path)) { + return ""; + } + long size = Files.size(path); + int bytesToRead = (int) Math.min(size, maxBytes); + ByteBuffer buffer = ByteBuffer.allocate(bytesToRead); + try (SeekableByteChannel channel = Files.newByteChannel(path, StandardOpenOption.READ)) { + channel.position(Math.max(0, size - bytesToRead)); + while (buffer.hasRemaining() && channel.read(buffer) >= 0) { + // Keep reading until the selected tail is complete. + } + } + return new String(buffer.array(), 0, buffer.position(), StandardCharsets.UTF_8).trim(); + } + + private static String formatOutput(String output) { + return output == null || output.isBlank() ? "" : System.lineSeparator() + output; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegVideoComposer.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegVideoComposer.java new file mode 100644 index 00000000..49727e0c --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegVideoComposer.java @@ -0,0 +1,136 @@ +package org.ruoyi.service.shortdrama.composition; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class FfmpegVideoComposer { + + private final FfmpegCompositionProperties properties; + private final FfmpegMediaProbe mediaProbe; + private final FfmpegFilterGraphBuilder filterGraphBuilder; + private final FfmpegCommandBuilder commandBuilder; + private final FfmpegProcessRunner processRunner; + + public CompositionArtifact compose(CompositionSpec spec, Path workDirectory) { + validateSources(spec); + Path workDir = workDirectory.toAbsolutePath().normalize(); + try { + Files.createDirectories(workDir); + } catch (IOException ex) { + throw new MediaProcessException("Unable to create composition work directory", ex); + } + + String jobToken = UUID.randomUUID().toString(); + List media = probeInputs(spec, workDir, jobToken); + FfmpegFilterGraph filterGraph = filterGraphBuilder.build(spec, media); + Path filterScript = workDir.resolve("filter-" + jobToken + ".txt"); + Path partialOutput = workDir.resolve("composed-" + jobToken + ".part.mp4"); + Path finalOutput = workDir.resolve("composed-" + jobToken + ".mp4"); + Path composeLog = workDir.resolve("ffmpeg-" + jobToken + ".log"); + + boolean complete = false; + try { + Files.writeString(filterScript, filterGraph.value(), StandardCharsets.UTF_8); + List command = commandBuilder.build(spec, filterGraph, filterScript, partialOutput); + processRunner.run( + command, + properties.getProcessTimeout(), + composeLog, + properties.getMaxProcessOutputBytes() + ); + validateOutputFile(partialOutput); + + MediaInfo outputInfo = mediaProbe.probe( + partialOutput, + filterGraph.expectedDurationSeconds(), + workDir.resolve("probe-output-" + jobToken + ".log") + ); + validateOutputMedia(outputInfo, filterGraph); + moveCompletedFile(partialOutput, finalOutput); + complete = true; + return new CompositionArtifact( + finalOutput, + outputInfo.durationSeconds(), + outputInfo.width(), + outputInfo.height(), + spec.transitionType() + ); + } catch (IOException ex) { + throw new MediaProcessException("Unable to write composed video artifact", ex); + } finally { + if (!complete) { + try { + Files.deleteIfExists(partialOutput); + } catch (IOException ignored) { + // Keep the original composition failure as the primary error. + } + } + } + } + + private void validateSources(CompositionSpec spec) { + if (spec.sources().size() > properties.getMaxClips()) { + throw new IllegalArgumentException("Too many video clips; maximum is " + properties.getMaxClips()); + } + for (int index = 0; index < spec.sources().size(); index++) { + Path path = spec.sources().get(index).path(); + if (!Files.isRegularFile(path) || !Files.isReadable(path)) { + throw new IllegalArgumentException("Video source " + (index + 1) + " is not a readable file"); + } + } + } + + private List probeInputs(CompositionSpec spec, Path workDir, String jobToken) { + List result = new ArrayList<>(spec.sources().size()); + for (int index = 0; index < spec.sources().size(); index++) { + CompositionSource source = spec.sources().get(index); + result.add(mediaProbe.probe( + source.path(), + source.fallbackDurationSeconds(), + workDir.resolve("probe-input-" + jobToken + "-" + index + ".log") + )); + } + return result; + } + + private void validateOutputFile(Path output) throws IOException { + if (!Files.isRegularFile(output) || Files.size(output) < properties.getMinimumOutputBytes()) { + throw new MediaProcessException("FFmpeg did not produce a valid output file"); + } + } + + private void validateOutputMedia(MediaInfo output, FfmpegFilterGraph expected) { + if (output.width() != expected.canvas().width() || output.height() != expected.canvas().height()) { + throw new MediaProcessException("Composed video dimensions do not match the requested aspect ratio"); + } + if (!output.hasAudio()) { + throw new MediaProcessException("Composed video is missing its normalized audio track"); + } + double tolerance = Math.max(0.25, 3.0 / expected.canvas().fps()); + if (Math.abs(output.durationSeconds() - expected.expectedDurationSeconds()) > tolerance) { + throw new MediaProcessException( + "Composed video duration differs from the expected timeline duration" + ); + } + } + + private static void moveCompletedFile(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaInfo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaInfo.java new file mode 100644 index 00000000..62e72b29 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaInfo.java @@ -0,0 +1,22 @@ +package org.ruoyi.service.shortdrama.composition; + +public record MediaInfo( + double durationSeconds, + int width, + int height, + boolean hasAudio, + int videoStreamIndex +) { + + public MediaInfo { + if (!Double.isFinite(durationSeconds) || durationSeconds <= 0) { + throw new IllegalArgumentException("Video duration must be positive"); + } + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("Video dimensions must be positive"); + } + if (videoStreamIndex < 0) { + throw new IllegalArgumentException("Video stream index must be non-negative"); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessException.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessException.java new file mode 100644 index 00000000..25dc5692 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessException.java @@ -0,0 +1,12 @@ +package org.ruoyi.service.shortdrama.composition; + +public class MediaProcessException extends RuntimeException { + + public MediaProcessException(String message) { + super(message); + } + + public MediaProcessException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessResult.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessResult.java new file mode 100644 index 00000000..de69f937 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessResult.java @@ -0,0 +1,4 @@ +package org.ruoyi.service.shortdrama.composition; + +public record MediaProcessResult(int exitCode, String output) { +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/TransitionType.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/TransitionType.java new file mode 100644 index 00000000..3189ef1b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/TransitionType.java @@ -0,0 +1,40 @@ +package org.ruoyi.service.shortdrama.composition; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Locale; + +public enum TransitionType { + NONE(null), + DISSOLVE("dissolve"), + FADE("fadeblack"), + SLIDE("slideleft"); + + private final String ffmpegName; + + TransitionType(String ffmpegName) { + this.ffmpegName = ffmpegName; + } + + public String ffmpegName() { + return ffmpegName; + } + + @JsonValue + public String value() { + return name().toLowerCase(Locale.ROOT); + } + + @JsonCreator + public static TransitionType fromValue(String value) { + if (value == null || value.isBlank()) { + return NONE; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("Unsupported transition type: " + value, ex); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCanvas.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCanvas.java new file mode 100644 index 00000000..88c1ef8d --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCanvas.java @@ -0,0 +1,10 @@ +package org.ruoyi.service.shortdrama.composition; + +public record VideoCanvas(int width, int height, int fps) { + + public VideoCanvas { + if (width <= 0 || height <= 0 || fps <= 0 || width % 2 != 0 || height % 2 != 0) { + throw new IllegalArgumentException("Canvas dimensions must be positive even numbers and fps must be positive"); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCompositionExecutorConfig.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCompositionExecutorConfig.java new file mode 100644 index 00000000..664f1ddb --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCompositionExecutorConfig.java @@ -0,0 +1,34 @@ +package org.ruoyi.service.shortdrama.composition; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +@Configuration +@RequiredArgsConstructor +public class VideoCompositionExecutorConfig { + + private final FfmpegCompositionProperties properties; + + @Bean(name = "videoCompositionExecutor") + public ThreadPoolTaskExecutor videoCompositionExecutor() { + if (properties.getWorkerCoreSize() <= 0 + || properties.getWorkerMaxSize() < properties.getWorkerCoreSize() + || properties.getWorkerQueueCapacity() < 0) { + throw new IllegalArgumentException("Invalid video composition executor configuration"); + } + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(properties.getWorkerCoreSize()); + executor.setMaxPoolSize(properties.getWorkerMaxSize()); + executor.setQueueCapacity(properties.getWorkerQueueCapacity()); + executor.setThreadNamePrefix("video-compose-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/SafeVideoSourceDownloader.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/SafeVideoSourceDownloader.java new file mode 100644 index 00000000..2274102e --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/SafeVideoSourceDownloader.java @@ -0,0 +1,363 @@ +package org.ruoyi.service.shortdrama.download; + +import okhttp3.Dns; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.IDN; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.Proxy; +import java.net.URI; +import java.net.UnknownHostException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +@Component +public class SafeVideoSourceDownloader { + + private final VideoSourceDownloadProperties properties; + + public SafeVideoSourceDownloader(VideoSourceDownloadProperties properties) { + this.properties = properties; + validateProperties(properties); + } + + public long download(String sourceUrl, Path target, long maxSourceBytes, long remainingTotalBytes) { + if (maxSourceBytes <= 0 || remainingTotalBytes <= 0) { + throw new VideoSourceDownloadException("Video download size limit is invalid"); + } + URI current = parseUri(sourceUrl); + + try { + for (int redirectCount = 0; ; redirectCount++) { + ResolvedTarget resolved = resolvePublicTarget(current); + OkHttpClient client = clientFor(resolved); + try (Response response = client.newCall(new Request.Builder() + .url(current.toASCIIString()) + .header("User-Agent", "ruoyi-ai-short-drama-composer") + .get() + .build()).execute()) { + if (isRedirect(response.code())) { + if (redirectCount >= properties.getMaxRedirects()) { + throw new VideoSourceDownloadException("Video source exceeded the redirect limit"); + } + String location = response.header("Location"); + if (location == null || location.isBlank()) { + throw new VideoSourceDownloadException("Video source redirect has no Location header"); + } + URI redirected = parseUri(current.resolve(location).toString()); + if ("https".equalsIgnoreCase(current.getScheme()) + && "http".equalsIgnoreCase(redirected.getScheme())) { + throw new VideoSourceDownloadException("HTTPS video source cannot redirect to HTTP"); + } + current = redirected; + continue; + } + if (!response.isSuccessful()) { + throw new VideoSourceDownloadException("Video source download failed with HTTP " + response.code()); + } + ResponseBody body = response.body(); + if (body == null) { + throw new VideoSourceDownloadException("Video source response is empty"); + } + assertSize(body.contentLength(), maxSourceBytes, remainingTotalBytes); + try (InputStream input = body.byteStream()) { + long downloaded = copyWithLimit(input, target, maxSourceBytes, remainingTotalBytes); + if (downloaded == 0) { + throw new VideoSourceDownloadException("Downloaded video source is empty"); + } + return downloaded; + } + } finally { + client.connectionPool().evictAll(); + client.dispatcher().executorService().shutdown(); + } + } + } catch (IOException ex) { + deletePartial(target); + throw new VideoSourceDownloadException("Video source download failed", ex); + } catch (RuntimeException ex) { + deletePartial(target); + throw ex; + } + } + + private OkHttpClient clientFor(ResolvedTarget target) { + Dns pinnedDns = hostname -> { + String requested = normalizeHost(hostname); + if (!target.host().equals(requested)) { + throw new UnknownHostException("Unvalidated redirect destination: " + hostname); + } + return target.addresses(); + }; + return new OkHttpClient.Builder() + .proxy(Proxy.NO_PROXY) + .dns(pinnedDns) + .followRedirects(false) + .followSslRedirects(false) + .connectTimeout(properties.getConnectTimeout()) + .callTimeout(properties.getCallTimeout()) + .readTimeout(properties.getCallTimeout()) + .build(); + } + + private ResolvedTarget resolvePublicTarget(URI uri) { + String host = normalizeHost(uri.getHost()); + enforceHostAllowlist(host); + final List addresses; + try { + addresses = List.copyOf(Arrays.asList(InetAddress.getAllByName(host))); + } catch (UnknownHostException ex) { + throw new VideoSourceDownloadException("Video source host cannot be resolved", ex); + } + boolean fakeIpAllowed = matchesHostRules(host, properties.getFakeIpAllowedHosts()); + if (addresses.isEmpty() || addresses.stream().anyMatch(address -> + !isPublicAddress(address) && !(fakeIpAllowed && isFakeProxyAddress(address)))) { + throw new VideoSourceDownloadException("Video source resolves to a private or special-use address"); + } + return new ResolvedTarget(host, addresses); + } + + private boolean matchesHostRules(String host, List rules) { + if (rules == null || rules.isEmpty()) return false; + for (String rule : rules) { + if (rule == null || rule.isBlank()) continue; + String normalizedRule = normalizeHostRule(rule); + if (normalizedRule.startsWith("*.")) { + String suffix = normalizedRule.substring(1); + if (host.endsWith(suffix) && host.length() > suffix.length()) return true; + } else if (host.equals(normalizedRule)) { + return true; + } + } + return false; + } + private void enforceHostAllowlist(String host) { + List rules = properties.getAllowedHosts(); + if (rules == null || rules.stream().allMatch(rule -> rule == null || rule.isBlank())) { + return; + } + for (String rule : rules) { + if (rule == null || rule.isBlank()) { + continue; + } + String normalizedRule = normalizeHostRule(rule); + if (normalizedRule.startsWith("*.")) { + String suffix = normalizedRule.substring(1); + if (host.endsWith(suffix) && host.length() > suffix.length()) { + return; + } + } else if (host.equals(normalizedRule)) { + return; + } + } + throw new VideoSourceDownloadException("Video source host is not allowlisted"); + } + + private static URI parseUri(String value) { + final URI uri; + try { + uri = URI.create(value).normalize(); + } catch (RuntimeException ex) { + throw new VideoSourceDownloadException("Video source URL is invalid", ex); + } + if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) { + throw new VideoSourceDownloadException("Video source URL must use HTTP or HTTPS"); + } + if (uri.getHost() == null || uri.getHost().isBlank() || uri.getRawUserInfo() != null) { + throw new VideoSourceDownloadException("Video source URL authority is invalid"); + } + if (uri.getPort() < -1 || uri.getPort() == 0 || uri.getPort() > 65_535) { + throw new VideoSourceDownloadException("Video source URL port is invalid"); + } + return uri; + } + + static boolean isFakeProxyAddress(InetAddress address) { + if (!(address instanceof Inet4Address)) return false; + byte[] bytes = address.getAddress(); + int first = bytes[0] & 0xFF; + int second = bytes[1] & 0xFF; + return first == 198 && (second == 18 || second == 19); + } + static boolean isPublicAddress(InetAddress address) { + if (address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress()) { + return false; + } + byte[] bytes = address.getAddress(); + if (address instanceof Inet4Address) { + return isPublicIpv4(bytes); + } + if (address instanceof Inet6Address) { + return isPublicIpv6(bytes); + } + return false; + } + + private static boolean isPublicIpv6(byte[] bytes) { + // Only 2000::/3 global unicast is eligible. Mapped/compatible and standard NAT64 are outside it. + if (!matchesPrefix(bytes, 3, 0x20)) { + return false; + } + + // IANA special-purpose entries within 2000::/3, plus the retired 6bone block. + // The 2001::/23 parent also covers Teredo, benchmarking and both ORCHID ranges. + return !matchesPrefix(bytes, 23, 0x20, 0x01, 0x00) + && !matchesPrefix(bytes, 32, 0x20, 0x01, 0x0D, 0xB8) + && !matchesPrefix(bytes, 16, 0x20, 0x02) + && !matchesPrefix(bytes, 48, 0x26, 0x20, 0x00, 0x4F, 0x80, 0x00) + && !matchesPrefix(bytes, 16, 0x3F, 0xFE) + && !matchesPrefix(bytes, 20, 0x3F, 0xFF, 0x00); + } + + private static boolean isPublicIpv4(byte[] bytes) { + int a = bytes[0] & 0xFF; + int b = bytes[1] & 0xFF; + int c = bytes[2] & 0xFF; + if (a == 0 || a == 10 || a == 127 || a >= 224) { + return false; + } + if (a == 100 && b >= 64 && b <= 127) { + return false; + } + if (a == 169 && b == 254) { + return false; + } + if (a == 172 && b >= 16 && b <= 31) { + return false; + } + if (a == 192 && (b == 168 || (b == 0 && c == 0) || (b == 0 && c == 2))) { + return false; + } + if (a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100))) { + return false; + } + return !(a == 203 && b == 0 && c == 113); + } + + private static boolean matchesPrefix(byte[] address, int prefixLength, int... prefixBytes) { + int fullBytes = prefixLength / Byte.SIZE; + int remainingBits = prefixLength % Byte.SIZE; + int requiredBytes = fullBytes + (remainingBits == 0 ? 0 : 1); + if (address.length < requiredBytes || prefixBytes.length < requiredBytes) { + return false; + } + for (int index = 0; index < fullBytes; index++) { + if ((address[index] & 0xFF) != prefixBytes[index]) { + return false; + } + } + if (remainingBits == 0) { + return true; + } + int mask = 0xFF << (Byte.SIZE - remainingBits); + return ((address[fullBytes] & 0xFF) & mask) == (prefixBytes[fullBytes] & mask); + } + + private static String normalizeHost(String host) { + if (host == null) { + throw new VideoSourceDownloadException("Video source host is missing"); + } + String unwrapped = host.startsWith("[") && host.endsWith("]") + ? host.substring(1, host.length() - 1) + : host; + if (unwrapped.indexOf(':') >= 0) { + return unwrapped.toLowerCase(Locale.ROOT); + } + try { + return IDN.toASCII(unwrapped, IDN.USE_STD3_ASCII_RULES) + .toLowerCase(Locale.ROOT) + .replaceFirst("\\.$", ""); + } catch (IllegalArgumentException ex) { + throw new VideoSourceDownloadException("Video source host is invalid", ex); + } + } + + private static String normalizeHostRule(String rule) { + String trimmed = rule.trim().toLowerCase(Locale.ROOT); + if (trimmed.startsWith("*.")) { + return "*." + normalizeHost(trimmed.substring(2)); + } + return normalizeHost(trimmed); + } + + private static boolean isRedirect(int status) { + return status == 301 || status == 302 || status == 303 || status == 307 || status == 308; + } + + private static long copyWithLimit( + InputStream input, + Path target, + long maxSourceBytes, + long remainingTotalBytes + ) throws IOException { + long downloaded = 0; + byte[] buffer = new byte[64 * 1024]; + try (OutputStream output = Files.newOutputStream( + target, + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE + )) { + int read; + while ((read = input.read(buffer)) >= 0) { + if (read == 0) { + continue; + } + downloaded += read; + assertSize(downloaded, maxSourceBytes, remainingTotalBytes); + output.write(buffer, 0, read); + } + } + return downloaded; + } + + private static void assertSize(long bytes, long maxSourceBytes, long remainingTotalBytes) { + if (bytes < 0) { + return; + } + if (bytes > maxSourceBytes) { + throw new VideoSourceDownloadException("A video source exceeds the per-file size limit"); + } + if (bytes > remainingTotalBytes) { + throw new VideoSourceDownloadException("Video sources exceed the total download size limit"); + } + } + + private static void deletePartial(Path target) { + try { + Files.deleteIfExists(target); + } catch (IOException ignored) { + // The enclosing composition job also removes its complete work directory. + } + } + + private static void validateProperties(VideoSourceDownloadProperties properties) { + Duration connectTimeout = properties.getConnectTimeout(); + Duration callTimeout = properties.getCallTimeout(); + if (connectTimeout == null || connectTimeout.isZero() || connectTimeout.isNegative() + || callTimeout == null || callTimeout.isZero() || callTimeout.isNegative() + || properties.getMaxRedirects() < 0 || properties.getMaxRedirects() > 10) { + throw new IllegalArgumentException("Invalid secure video download configuration"); + } + } + + private record ResolvedTarget(String host, List addresses) { + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadException.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadException.java new file mode 100644 index 00000000..0fbcf2fd --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadException.java @@ -0,0 +1,12 @@ +package org.ruoyi.service.shortdrama.download; + +public class VideoSourceDownloadException extends RuntimeException { + + public VideoSourceDownloadException(String message) { + super(message); + } + + public VideoSourceDownloadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadProperties.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadProperties.java new file mode 100644 index 00000000..692c9acf --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadProperties.java @@ -0,0 +1,30 @@ +package org.ruoyi.service.shortdrama.download; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +@Data +@Component +@ConfigurationProperties(prefix = "short-drama.composition.download") +public class VideoSourceDownloadProperties { + + private Duration connectTimeout = Duration.ofSeconds(30); + private Duration callTimeout = Duration.ofMinutes(10); + private int maxRedirects = 5; + + /** + * Optional exact hosts or wildcard suffixes such as *.example.com. + * When empty, any public destination is accepted. + */ + private List allowedHosts = new ArrayList<>(); + /** + * Hosts allowed to resolve into a TUN proxy fake-IP range (198.18.0.0/15). + * This exception never applies to loopback, RFC1918 or link-local addresses. + */ + private List fakeIpAllowedHosts = new ArrayList<>(); +} 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 new file mode 100644 index 00000000..91f9f000 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java @@ -0,0 +1,3795 @@ +package org.ruoyi.service.shortdrama.impl; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; + +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.entity.image.ImageContext; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.common.chat.entity.video.VideoContext; +import org.ruoyi.common.chat.factory.ImageServiceFactory; +import org.ruoyi.common.chat.factory.VideoServiceFactory; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.common.core.utils.MapstructUtils; +import org.ruoyi.common.core.utils.StringUtils; +import org.ruoyi.common.json.utils.JsonUtils; +import org.ruoyi.constant.ShortDramaImageConstants; +import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptResult; +import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo; +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter; +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacterAppearance; +import org.ruoyi.domain.entity.shortdrama.ShortDramaLocation; +import org.ruoyi.domain.entity.shortdrama.ShortDramaProject; +import org.ruoyi.domain.entity.shortdrama.ShortDramaScript; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaLocationVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaProjectVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaStoryboardVo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo; +import org.ruoyi.mapper.shortdrama.ShortDramaCharacterMapper; +import org.ruoyi.mapper.shortdrama.ShortDramaCharacterAppearanceMapper; +import org.ruoyi.mapper.shortdrama.ShortDramaLocationMapper; +import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper; +import org.ruoyi.mapper.shortdrama.ShortDramaScriptMapper; +import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper; +import org.ruoyi.factory.ChatServiceFactory; +import org.ruoyi.service.chat.AbstractChatService; +import org.ruoyi.service.media.AtlasPredictionService; +import org.ruoyi.service.shortdrama.IShortDramaService; +import org.ruoyi.service.shortdrama.IShortDramaVideoComposeService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ShortDramaServiceImpl implements IShortDramaService { + + private final ShortDramaProjectMapper projectMapper; + private final ShortDramaScriptMapper scriptMapper; + private final ShortDramaStoryboardMapper storyboardMapper; + private final ShortDramaCharacterMapper characterMapper; + private final ShortDramaCharacterAppearanceMapper characterAppearanceMapper; + private final ShortDramaLocationMapper locationMapper; + private final IChatModelService chatModelService; + private final ChatServiceFactory chatServiceFactory; + private final VideoServiceFactory videoServiceFactory; + private final ImageServiceFactory imageServiceFactory; + private final AtlasPredictionService atlasPredictionService; + private final IShortDramaVideoComposeService videoComposeService; + private final java.util.Map activeEmitters = new ConcurrentHashMap<>(); + private final java.util.Map storyboardGenerationStates = new ConcurrentHashMap<>(); + + /** + * 代理流式输出视频,绕过 OSS 强制下载 header。 + * TODO: 待实现,当前为占位以满足接口契约。 + */ + @Override + public StreamingResponseBody streamVideo(Long storyboardId, Long userId) { + throw new UnsupportedOperationException("streamVideo 尚未实现"); + } + + // ==================== 查询 ==================== + + @Override + public List listProjects(Long userId) { + return projectMapper.selectVoList(new LambdaQueryWrapper() + .eq(ShortDramaProject::getUserId, userId) + .orderByDesc(ShortDramaProject::getId)); + } + + @Override + public ShortDramaDetailVo getDetail(Long projectId, Long userId) { + ShortDramaProject project = projectMapper.selectOne(new LambdaQueryWrapper() + .eq(ShortDramaProject::getId, projectId) + .eq(ShortDramaProject::getUserId, userId)); + if (project == null) { + return null; + } + ShortDramaScript script = scriptMapper.selectOne(new LambdaQueryWrapper() + .eq(ShortDramaScript::getProjectId, projectId) + .orderByDesc(ShortDramaScript::getId) + .last("limit 1")); + ShortDramaDetailVo detailVo = new ShortDramaDetailVo(); + detailVo.setProject(MapstructUtils.convert(project, ShortDramaProjectVo.class)); + detailVo.setScript(script == null ? null : MapstructUtils.convert(script, ShortDramaScriptVo.class)); + + List characters = characterMapper.selectVoList(new LambdaQueryWrapper() + .eq(ShortDramaCharacter::getProjectId, projectId)); + for (ShortDramaCharacterVo characterVo : characters) { + List appearances = characterAppearanceMapper.selectVoList( + new LambdaQueryWrapper() + .eq(ShortDramaCharacterAppearance::getCharacterId, characterVo.getId()) + .orderByAsc(ShortDramaCharacterAppearance::getAppearanceIndex)); + characterVo.setAppearances(appearances); + } + detailVo.setCharacters(characters); + + List locations = locationMapper.selectVoList(new LambdaQueryWrapper() + .eq(ShortDramaLocation::getProjectId, projectId)); + detailVo.setLocations(locations); + + List storyboards = storyboardMapper.selectVoList(new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getProjectId, projectId) + .orderByAsc(ShortDramaStoryboard::getSceneNo)); + detailVo.setStoryboards(storyboards); + return detailVo; + } + + // ==================== 核心:六阶段流水线 ==================== + + @Override + public ShortDramaDetailVo createFromIdea(ShortDramaIdeaBo bo, Long userId) { + ChatModelVo modelVo = validateAndGetModel(bo.getModel()); + AbstractChatService chatService = getChatService(modelVo); + ChatModel chatModel = chatService.buildChatModel(modelVo); + + // Phase 1: 剧本打磨 + ShortDramaScriptResult polishResult = executePhase1_ScriptPolish(chatModel, bo); + ShortDramaProject project = buildAndInsertProject(userId, polishResult, bo); + ShortDramaScript script = buildAndInsertScript(project.getId(), polishResult); + + // Phase 2: 资产分析(角色+场景提取) + executePhase2_AssetAnalysis(chatModel, project.getId(), script); + + // Phase 3-6: 分镜流水线 + List panels = executeStoryboardPipeline(chatModel, script, project.getId()); + + return getDetail(project.getId(), userId); + } + + @Override + public SseEmitter createFromIdeaStream(ShortDramaIdeaBo bo, Long userId) { + SseEmitter emitter = new SseEmitter(1_800_000L); + AtomicBoolean emitterActive = new AtomicBoolean(true); + activeEmitters.put(emitter, emitterActive); + emitter.onCompletion(() -> closeEmitter(emitter)); + emitter.onTimeout(() -> closeEmitter(emitter)); + emitter.onError(error -> closeEmitter(emitter)); + + CompletableFuture.runAsync(() -> { + Long projectId = null; + try { + ChatModelVo modelVo = validateAndGetModel(bo.getModel()); + AbstractChatService chatService = getChatService(modelVo); + ChatModel chatModel = chatService.buildChatModel(modelVo); + StreamingChatModel streamingModel = chatService.buildStreamingChatModel(modelVo, new ChatRequest()); + + // Phase 1: 单次流式调用(JSON 元信息 → 分隔符 → 剧本正文逐字推送) + emit(emitter, "polish", "running", "正在生成故事大纲..."); + ShortDramaScriptResult polishResult = executePhase1_Streaming(streamingModel, bo, emitter); + ShortDramaProject project = buildAndInsertProject(userId, polishResult, bo); + projectId = project.getId(); + ShortDramaScript script = buildAndInsertScript(projectId, polishResult); + emit(emitter, "polish", "done", "剧本打磨完成"); + + // Phase 2: 资产分析(角色 + 场景 并发,流式输出) + emit(emitter, "assets", "running", "正在分析角色和场景..."); + StreamingChatModel assetsStreamModel = chatService.buildStreamingChatModel(modelVo, new ChatRequest()); + executePhase2_AssetAnalysis(chatModel, assetsStreamModel, projectId, script, emitter); + long charCount = characterMapper.selectCount(new LambdaQueryWrapper() + .eq(ShortDramaCharacter::getProjectId, projectId)); + long locCount = locationMapper.selectCount(new LambdaQueryWrapper() + .eq(ShortDramaLocation::getProjectId, projectId)); + emit(emitter, "assets", "done", "提取了 " + charCount + " 个角色、" + locCount + " 个场景"); + + // Phase 3-6: 分镜流水线(各子阶段内部创建 StreamingChatModel 实现流式输出) + List panels = executeStoryboardPipeline(chatModel, script, projectId, emitter); + emit(emitter, "storyboard", "done", "分镜完成,共 " + panels.size() + " 个镜头"); + + // 完成 + sendEmitterEvent(emitter, SseEmitter.event() + .name("complete") + .data("{\"projectId\":\"" + projectId + "\"}")); + completeEmitter(emitter); + } catch (Exception e) { + log.error("流式创建短剧失败", e); + String errorData = projectId != null + ? "{\"message\":\"" + escapeJson(e.getMessage()) + "\",\"projectId\":\"" + projectId + "\"}" + : "{\"message\":\"" + escapeJson(e.getMessage()) + "\"}"; + sendEmitterEvent(emitter, SseEmitter.event().name("error").data(errorData)); + completeEmitterWithError(emitter, e); + } + }); + + return emitter; + } + + private boolean sendEmitterEvent(SseEmitter emitter, SseEmitter.SseEventBuilder event) { + AtomicBoolean active = activeEmitters.get(emitter); + if (active == null || !active.get()) return false; + try { + emitter.send(event); + return true; + } catch (IOException | IllegalStateException e) { + closeEmitter(emitter); + return false; + } + } + + private void closeEmitter(SseEmitter emitter) { + AtomicBoolean active = activeEmitters.remove(emitter); + if (active != null) active.set(false); + } + + private void completeEmitter(SseEmitter emitter) { + AtomicBoolean active = activeEmitters.get(emitter); + if (active == null || !active.compareAndSet(true, false)) return; + activeEmitters.remove(emitter); + try { emitter.complete(); } catch (IllegalStateException ignored) { } + } + + private void completeEmitterWithError(SseEmitter emitter, Throwable error) { + AtomicBoolean active = activeEmitters.get(emitter); + if (active == null || !active.compareAndSet(true, false)) return; + activeEmitters.remove(emitter); + try { emitter.completeWithError(error); } catch (IllegalStateException ignored) { } + } + + private void emit(SseEmitter emitter, String phase, String status, String message) { + String data = "{\"phase\":\"" + phase + "\",\"status\":\"" + status + "\",\"message\":\"" + escapeJson(message) + "\"}"; + sendEmitterEvent(emitter, SseEmitter.event().name("phase").data(data)); + } + + private void emitStream(SseEmitter emitter, String phase, String text) { + String data = "{\"phase\":\"" + phase + "\",\"text\":\"" + escapeJson(text) + "\"}"; + sendEmitterEvent(emitter, SseEmitter.event().name("stream").data(data)); + } + + private void emitStreamDone(SseEmitter emitter) { + sendEmitterEvent(emitter, SseEmitter.event().name("stream") + .data("{\"phase\":\"script\",\"status\":\"done\"}")); + } + + private static String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private ShortDramaProject buildAndInsertProject(Long userId, ShortDramaScriptResult polishResult, ShortDramaIdeaBo bo) { + ShortDramaProject project = new ShortDramaProject(); + project.setId(IdUtil.getSnowflakeNextId()); + project.setUserId(userId); + project.setProjectName(firstNotBlank(bo.getProjectName(), polishResult.getProjectName(), "短剧项目")); + project.setDescription(firstNotBlank(polishResult.getDescription(), "")); + project.setArtStyle(firstNotBlank(bo.getArtStyle(), ShortDramaImageConstants.DEFAULT_ART_STYLE)); + project.setComposeAspectRatio(normalizeAspectRatio(bo.getAspectRatio())); + project.setStatus("draft"); + projectMapper.insert(project); + return project; + } + + private ShortDramaScript buildAndInsertScript(Long projectId, ShortDramaScriptResult polishResult) { + ShortDramaScript script = new ShortDramaScript(); + script.setId(IdUtil.getSnowflakeNextId()); + script.setProjectId(projectId); + script.setScriptName(firstNotBlank(polishResult.getScriptName(), "剧本")); + script.setOutlineText(firstNotBlank(polishResult.getOutlineText(), "")); + script.setScriptText(firstNotBlank(polishResult.getScriptText(), "")); + script.setTone(firstNotBlank(polishResult.getTone(), "短剧")); + script.setSourceType("llm"); + scriptMapper.insert(script); + return script; + } + + private List executeStoryboardPipeline(ChatModel chatModel, ShortDramaScript script, Long projectId) { + return executeStoryboardPipeline(chatModel, script, projectId, null); + } + + private List executeStoryboardPipeline(ChatModel chatModel, ShortDramaScript script, Long projectId, SseEmitter emitter) { + // Phase 3: 分镜规划(流式输出由 executePhase3_StoryboardPlan 内部处理) + if (emitter != null) emit(emitter, "storyboard_plan", "running", "正在规划分镜镜头..."); + List panels = executePhase3_StoryboardPlan(chatModel, script, projectId, emitter); + // Phase 3 的成功/失败由子方法内部 emit(因为流式输出的 done/error 在 streamingChat 回调中处理) + if (emitter != null && !panels.isEmpty()) emit(emitter, "storyboard_plan", "done", "分镜规划完成,共 " + panels.size() + " 个镜头"); + + if (!panels.isEmpty()) { + List planPanels = panels; + + // 摄影规则与表演指导使用确定性本地规则生成,避免为整组镜头额外发起两次大模型请求。 + if (emitter != null) emit(emitter, "photography", "running", "正在生成摄影规则..."); + List photographyRules = buildLocalPhotographyRules(planPanels); + if (emitter != null) emit(emitter, "photography", "done", "摄影规则生成完成"); + + if (emitter != null) emit(emitter, "acting", "running", "正在生成表演指导..."); + List actingDirections = buildLocalActingDirections(planPanels); + if (emitter != null) emit(emitter, "acting", "done", "表演指导生成完成"); + + // 分镜细化保留一次模型调用,统一生成景别、运镜和时长匹配的视频/图片提示词。 + if (emitter != null) emit(emitter, "storyboard_detail", "running", "正在细化分镜详情..."); + executePhase6_StoryboardDetail(chatModel, planPanels, projectId, emitter); + normalizeContinuityChain(planPanels); + panels = mergePanelsWithRules(planPanels, photographyRules, actingDirections); + } + persistStoryboards(projectId, script.getId(), panels, script); + return panels; + } + + @Override + public ShortDramaDetailVo polishScript(Long projectId, Long userId) { + ShortDramaProject project = validateProjectOwner(projectId, userId); + ShortDramaScript script = scriptMapper.selectOne(new LambdaQueryWrapper() + .eq(ShortDramaScript::getProjectId, projectId) + .orderByDesc(ShortDramaScript::getId).last("limit 1")); + String idea = script != null ? firstNotBlank(script.getScriptText(), script.getOutlineText(), project.getDescription()) : project.getDescription(); + ChatModelVo modelVo = findChatModel(); + AbstractChatService chatService = getChatService(modelVo); + ChatModel chatModel = chatService.buildChatModel(modelVo); + + ShortDramaIdeaBo bo = new ShortDramaIdeaBo(); + bo.setIdea(idea); + bo.setProjectName(project.getProjectName()); + ShortDramaScriptResult result = executePhase1_ScriptPolish(chatModel, bo); + + if (script == null) { + script = new ShortDramaScript(); + script.setId(IdUtil.getSnowflakeNextId()); + script.setProjectId(projectId); + script.setSourceType("llm"); + } + project.setProjectName(firstNotBlank(result.getProjectName(), project.getProjectName())); + project.setDescription(firstNotBlank(result.getDescription(), project.getDescription())); + projectMapper.updateById(project); + script.setScriptName(firstNotBlank(result.getScriptName(), script.getScriptName())); + script.setOutlineText(firstNotBlank(result.getOutlineText(), script.getOutlineText())); + script.setScriptText(firstNotBlank(result.getScriptText(), script.getScriptText())); + script.setTone(firstNotBlank(result.getTone(), script.getTone())); + if (script.getId() != null && scriptMapper.selectById(script.getId()) != null) { + scriptMapper.updateById(script); + } else { + scriptMapper.insert(script); + } + return getDetail(projectId, userId); + } + + // ==================== 项目/剧本 CRUD ==================== + + @Override + public Long saveProject(ShortDramaProjectBo bo, Long userId) { + ShortDramaProject entity = MapstructUtils.convert(bo, ShortDramaProject.class); + entity.setUserId(userId); + if (entity.getId() == null) { + entity.setStatus(StrUtil.blankToDefault(entity.getStatus(), "draft")); + projectMapper.insert(entity); + } else { + projectMapper.updateById(entity); + } + return entity.getId(); + } + + @Override + public ShortDramaScriptVo saveScript(ShortDramaScriptBo bo, Long userId) { + ShortDramaProject project = projectMapper.selectById(bo.getProjectId()); + if (project == null || !userId.equals(project.getUserId())) { + throw new IllegalArgumentException("项目不存在或无权限"); + } + ShortDramaScript entity = MapstructUtils.convert(bo, ShortDramaScript.class); + entity.setSourceType(StrUtil.blankToDefault(entity.getSourceType(), "manual")); + if (entity.getId() == null) { + scriptMapper.insert(entity); + } else { + scriptMapper.updateById(entity); + } + return MapstructUtils.convert(entity, ShortDramaScriptVo.class); + } + + // ==================== 分镜生成与规划 ==================== + + @Override + public List generateStoryboards(Long projectId, Long scriptId, String model, Long userId) { + if (!beginStoryboardGeneration(scriptId)) { + throw new IllegalStateException("该剧本正在生成分镜,请勿重复提交"); + } + try { + ShortDramaProject project = validateProjectOwner(projectId, userId); + ShortDramaScript script = scriptMapper.selectById(scriptId); + if (script == null || !projectId.equals(script.getProjectId())) { + throw new IllegalArgumentException("剧本不存在"); + } + videoComposeService.invalidateComposition(projectId); + // 删除旧分镜 + storyboardMapper.delete(new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getScriptId, scriptId)); + + ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); + AbstractChatService chatService = getChatService(modelVo); + ChatModel chatModel = chatService.buildChatModel(modelVo); + + List panels = executePhase3_StoryboardPlan(chatModel, script, projectId); + if (panels.isEmpty()) { + panels = fallbackPanels(script); + } + if (!panels.isEmpty()) { + List photographyRules = buildLocalPhotographyRules(panels); + List actingDirections = buildLocalActingDirections(panels); + executePhase6_StoryboardDetail(chatModel, panels, projectId); + normalizeContinuityChain(panels); + panels = mergePanelsWithRules(panels, photographyRules, actingDirections); + } + return persistStoryboards(projectId, scriptId, panels, script); + } finally { + endStoryboardGeneration(scriptId); + } + } + + @Override + public List planStoryboard(Long projectId, Long scriptId, String model, Long userId) { + return generateStoryboards(projectId, scriptId, model, userId); + } + + @Override + public SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId) { + SseEmitter emitter = new SseEmitter(1_800_000L); + AtomicBoolean emitterActive = new AtomicBoolean(true); + activeEmitters.put(emitter, emitterActive); + emitter.onCompletion(() -> closeEmitter(emitter)); + emitter.onTimeout(() -> closeEmitter(emitter)); + emitter.onError(error -> closeEmitter(emitter)); + + CompletableFuture.runAsync(() -> { + if (!beginStoryboardGeneration(scriptId)) { + sendEmitterEvent(emitter, SseEmitter.event().name("error") + .data("{\"message\":\"该剧本正在生成分镜,请勿重复提交\"}")); + completeEmitter(emitter); + return; + } + try { + validateProjectOwner(projectId, userId); + ShortDramaScript script = scriptMapper.selectById(scriptId); + if (script == null || !projectId.equals(script.getProjectId())) { + throw new IllegalArgumentException("剧本不存在"); + } + + videoComposeService.invalidateComposition(projectId); + storyboardMapper.delete(new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getScriptId, scriptId)); + + ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); + AbstractChatService chatService = getChatService(modelVo); + ChatModel chatModel = chatService.buildChatModel(modelVo); + StreamingChatModel streamingModel = chatService.buildStreamingChatModel(modelVo, new ChatRequest()); + + log.info("开始流式生成分镜: projectId={}, scriptId={}, model={}", projectId, scriptId, modelVo.getModelName()); + emit(emitter, "storyboard_plan", "running", "正在规划分镜镜头,模型开始输出后会实时显示..."); + List panels = executePhase3_StoryboardPlan(chatModel, streamingModel, script, projectId, emitter); + if (panels.isEmpty()) { + throw new IllegalStateException("分镜规划失败,模型未返回有效镜头"); + } + emit(emitter, "storyboard_plan", "done", "分镜规划完成,共 " + panels.size() + " 个镜头"); + + emit(emitter, "photography", "running", "正在生成摄影规则..."); + List photographyRules = buildLocalPhotographyRules(panels); + emit(emitter, "photography", "done", "摄影规则生成完成"); + + emit(emitter, "acting", "running", "正在生成表演指导..."); + List actingDirections = buildLocalActingDirections(panels); + emit(emitter, "acting", "done", "表演指导生成完成"); + + emit(emitter, "storyboard_detail", "running", "正在细化镜头提示词和时长..."); + executePhase6_StoryboardDetail(chatModel, streamingModel, panels, projectId, emitter); + normalizeContinuityChain(panels); + panels = mergePanelsWithRules(panels, photographyRules, actingDirections); + List storyboards = persistStoryboards(projectId, scriptId, panels, script); + + log.info("流式生成分镜完成: projectId={}, count={}", projectId, storyboards.size()); + sendEmitterEvent(emitter, SseEmitter.event().name("complete") + .data("{\"projectId\":\"" + projectId + "\",\"count\":" + storyboards.size() + "}")); + completeEmitter(emitter); + } catch (Exception e) { + log.error("流式生成分镜失败: projectId={}, scriptId={}", projectId, scriptId, e); + sendEmitterEvent(emitter, SseEmitter.event().name("error") + .data("{\"message\":\"" + escapeJson(e.getMessage()) + "\"}")); + completeEmitterWithError(emitter, e); + } finally { + endStoryboardGeneration(scriptId); + } + }); + return emitter; + } + + private boolean beginStoryboardGeneration(Long scriptId) { + return storyboardGenerationStates.putIfAbsent(scriptId, new AtomicBoolean(true)) == null; + } + + private void endStoryboardGeneration(Long scriptId) { + storyboardGenerationStates.remove(scriptId); + } + + @Override + public List generatePhotographyRules(Long projectId, Long scriptId, Long userId) { + validateProjectOwner(projectId, userId); + List existing = storyboardMapper.selectList( + new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getScriptId, scriptId) + .orderByAsc(ShortDramaStoryboard::getSceneNo)); + if (existing.isEmpty()) { + return List.of(); + } + List panels = toPanelDataList(existing); + ChatModelVo modelVo = findChatModel(); + ChatModel chatModel = getChatService(modelVo).buildChatModel(modelVo); + List photographyRules = executePhase4_PhotographyRules(chatModel, panels, projectId); + mergePhotographyRules(panels, photographyRules); + for (int i = 0; i < existing.size() && i < panels.size(); i++) { + existing.get(i).setPhotographyRules(panels.get(i).getPhotographyRules()); + storyboardMapper.updateById(existing.get(i)); + } + return storyboardMapper.selectVoList(new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getScriptId, scriptId) + .orderByAsc(ShortDramaStoryboard::getSceneNo)); + } + + @Override + public List generateActingDirections(Long projectId, Long scriptId, Long userId) { + validateProjectOwner(projectId, userId); + List existing = storyboardMapper.selectList( + new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getScriptId, scriptId) + .orderByAsc(ShortDramaStoryboard::getSceneNo)); + if (existing.isEmpty()) { + return List.of(); + } + List panels = toPanelDataList(existing); + ChatModelVo modelVo = findChatModel(); + ChatModel chatModel = getChatService(modelVo).buildChatModel(modelVo); + List actingDirections = executePhase5_ActingDirections(chatModel, panels, projectId); + mergeActingDirections(panels, actingDirections); + for (int i = 0; i < existing.size() && i < panels.size(); i++) { + existing.get(i).setActingNotes(panels.get(i).getActingNotes()); + storyboardMapper.updateById(existing.get(i)); + } + return storyboardMapper.selectVoList(new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getScriptId, scriptId) + .orderByAsc(ShortDramaStoryboard::getSceneNo)); + } + + // ==================== 分镜 CRUD / 视频 ==================== + + @Override + public ShortDramaStoryboardVo saveStoryboard(ShortDramaStoryboardBo bo, Long userId) { + ShortDramaStoryboard entity = MapstructUtils.convert(bo, ShortDramaStoryboard.class); + ShortDramaProject project = projectMapper.selectById(entity.getProjectId()); + if (project == null || !userId.equals(project.getUserId())) { + throw new IllegalArgumentException("项目不存在或无权限"); + } + if (entity.getId() != null) { + ShortDramaStoryboard existing = storyboardMapper.selectById(entity.getId()); + if (existing == null || !entity.getProjectId().equals(existing.getProjectId())) { + throw new IllegalArgumentException("分镜不存在或不属于当前项目"); + } + } + if (entity.getDurationSeconds() == null || entity.getDurationSeconds() <= 0) { + entity.setDurationSeconds(defaultDurationForSceneType(entity.getSceneType())); + } + if (entity.getId() == null) { + entity.setVideoStatus("pending"); + storyboardMapper.insert(entity); + } else { + storyboardMapper.updateById(entity); + entity = storyboardMapper.selectById(entity.getId()); + } + videoComposeService.invalidateComposition(entity.getProjectId()); + return MapstructUtils.convert(entity, ShortDramaStoryboardVo.class); + } + + @Override + public ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId) { + ShortDramaStoryboard storyboard = storyboardMapper.selectById(storyboardId); + if (storyboard == null) throw new IllegalArgumentException("分镜不存在"); + ShortDramaProject project = projectMapper.selectById(storyboard.getProjectId()); + if (project == null || !userId.equals(project.getUserId())) throw new IllegalArgumentException("项目不存在或无权限"); + ChatModelVo modelVo = chatModelService.selectModelByName(videoModel); + if (modelVo == null) throw new IllegalArgumentException("未找到视频模型配置: " + videoModel); + + // 收集所有参考图(角色 + 场景) + List referenceImages = findStoryboardReferenceImages(storyboard); + + // 根据参考图数量自动切换模型 + if (referenceImages != null && !referenceImages.isEmpty()) { + String targetModel; + if (referenceImages.size() >= 2) { + targetModel = videoModel.replace("/text-to-video", "/reference-to-video") + .replace("/image-to-video", "/reference-to-video"); + } else { + targetModel = videoModel.replace("/text-to-video", "/image-to-video") + .replace("/reference-to-video", "/image-to-video"); + } + if (!targetModel.equals(videoModel)) { + ChatModelVo switched = chatModelService.selectModelByName(targetModel); + if (switched != null) { + modelVo = switched; + log.info("参考图({}张) → 切换模型: {}", referenceImages.size(), targetModel); + } else { + log.info("参考图({}张)但模型未注册: {},保持原模型", referenceImages.size(), targetModel); + } + } + } + + String enrichedPrompt = buildEnrichedVideoPrompt(storyboard, referenceImages); + + VideoContext ctx = VideoContext.builder() + .chatModelVo(modelVo) + .prompt(enrichedPrompt) + .seconds(storyboard.getDurationSeconds()) + .referenceImages(referenceImages) + .build(); + String generationToken = "local:" + UUID.randomUUID(); + storyboardMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, storyboardId) + .set(ShortDramaStoryboard::getVideoUrl, null) + .set(ShortDramaStoryboard::getVideoId, generationToken) + .set(ShortDramaStoryboard::getVideoStatus, "generating")); + videoComposeService.invalidateComposition(project.getId()); + + MediaGenerationResponse response; + try { + response = videoServiceFactory.getOriginalService(modelVo.getProviderCode()).generateVideo(ctx); + } catch (RuntimeException ex) { + int failed = storyboardMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, storyboardId) + .eq(ShortDramaStoryboard::getVideoId, generationToken) + .set(ShortDramaStoryboard::getVideoId, null) + .set(ShortDramaStoryboard::getVideoStatus, "failed")); + if (failed > 0) { + videoComposeService.invalidateComposition(project.getId()); + } + throw ex; + } + String videoUrl = null; + String videoId = null; + String videoStatus; + if (response != null && StrUtil.isNotBlank(response.getUrl())) { + videoUrl = response.getUrl(); + videoStatus = "done"; + } else if (response != null && StrUtil.isNotBlank(response.getId())) { + videoId = response.getId(); + videoStatus = "generating"; + } else if (response != null && "processing".equals(response.getStatus())) { + videoId = response.getId(); + videoStatus = "generating"; + } else { + videoStatus = "failed"; + } + int completed = storyboardMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, storyboardId) + .eq(ShortDramaStoryboard::getVideoId, generationToken) + .set(ShortDramaStoryboard::getVideoUrl, videoUrl) + .set(ShortDramaStoryboard::getVideoId, videoId) + .set(ShortDramaStoryboard::getVideoStatus, videoStatus)); + if (completed > 0) { + videoComposeService.invalidateComposition(project.getId()); + } + return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); + } + + /** 收集分镜关联的所有参考图:角色形象图(按出场顺序)+ 场景图 */ + private List findStoryboardReferenceImages(ShortDramaStoryboard storyboard) { + List images = new ArrayList<>(); + List chars = parseCharacterRefs(storyboard.getCharactersJson()); + if (chars != null) { + for (CharacterRef ref : chars) { + String img = findCharacterImageUrl(storyboard.getProjectId(), ref.getName()); + if (StrUtil.isNotBlank(img) && !images.contains(img)) { + images.add(img); + } + } + } + if (StrUtil.isNotBlank(storyboard.getLocationName())) { + String img = findLocationImageUrl(storyboard.getProjectId(), storyboard.getLocationName()); + if (StrUtil.isNotBlank(img) && !images.contains(img)) { + images.add(img); + } + } + return images.isEmpty() ? null : images; + } + + /** 构建增强提示词:融合镜头语言、摄影规则、角色信息、表演指导、场景描述 */ + private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard) { + return buildEnrichedVideoPrompt(storyboard, null); + } + + /** 构建增强提示词(含 @imageN 参考图引用,用于 reference-to-video 模型) */ + private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard, java.util.List refImages) { + boolean hasRefImages = refImages != null && !refImages.isEmpty(); + StringBuilder sb = new StringBuilder(); + + // 1. 镜头语言标签 + sb.append("[镜头: ").append(firstNotBlank(storyboard.getSceneType(), "daily")); + if (StrUtil.isNotBlank(storyboard.getShotType())) sb.append(", ").append(storyboard.getShotType()); + if (StrUtil.isNotBlank(storyboard.getCameraMove())) sb.append(", ").append(storyboard.getCameraMove()); + sb.append("]\n"); + + // 2. 摄影规则 + if (StrUtil.isNotBlank(storyboard.getPhotographyRules())) { + try { + JsonNode rules = JsonUtils.parseObject(storyboard.getPhotographyRules(), JsonNode.class); + if (rules != null) { + StringBuilder photo = new StringBuilder("[摄影:"); + JsonNode lighting = rules.path("lighting"); + if (!lighting.isMissingNode()) { + String dir = lighting.path("direction").asText(null); + String qual = lighting.path("quality").asText(null); + if (dir != null) photo.append(" ").append(dir).append(qual != null ? qual : "").append("光"); + } + String dof = rules.path("depth_of_field").asText(null); + if (dof != null) photo.append(", 景深:").append(dof); + String tone = rules.path("color_tone").asText(null); + if (tone != null) photo.append(", 色调:").append(tone); + if (photo.length() > 4) { photo.append("]"); sb.append(photo).append("\n"); } + + JsonNode photoChars = rules.path("characters"); + if (photoChars.isArray() && !photoChars.isEmpty()) { + sb.append("[站位] "); + for (int i = 0; i < photoChars.size(); i++) { + JsonNode pc = photoChars.get(i); + if (i > 0) sb.append(";"); + sb.append(pc.path("name").asText("")) + .append("在").append(pc.path("screen_position").asText("")) + .append(pc.path("posture").asText("")) + .append("面向").append(pc.path("facing").asText("")); + } + sb.append("\n"); + } + } + } catch (Exception e) { log.debug("解析摄影规则失败: {}", e.getMessage()); } + } + + // 3. 角色信息(名字→年龄性别,附加站位 + @imageN 参考图标记) + List chars = parseCharacterRefs(storyboard.getCharactersJson()); + if (chars != null && !chars.isEmpty()) { + sb.append("[角色] "); + for (int i = 0; i < chars.size(); i++) { + CharacterRef ref = chars.get(i); + if (i > 0) sb.append(";"); + ShortDramaCharacter ch = findCharacterByName(storyboard.getProjectId(), ref.getName()); + String desc = ch != null + ? firstNotBlank(ch.getAgeRange(), "") + firstNotBlank(ch.getGender(), "") + : ref.getName(); + sb.append(desc).append("(").append(ref.getName()).append(")"); + // 多参考图模式:附加 @imageN 标记 + if (hasRefImages) { + int imgIdx = findRefImageIndex(refImages, storyboard.getProjectId(), ref.getName()); + if (imgIdx >= 0) sb.append("@image").append(imgIdx + 1); + } + if (StrUtil.isNotBlank(ref.getSlot())) sb.append("在").append(ref.getSlot()); + } + sb.append("\n"); + } + + // 角色参考图很容易被生成模型错误地扩散到背景群众,造成“所有人一张脸”。 + // 在角色声明之后追加全局身份隔离规则,使其覆盖所有项目和所有镜头。 + appendCharacterIdentityIsolationPrompt(sb, storyboard, chars, refImages); + + // 4. 场景描述(+ @imageN 参考图标记) + if (StrUtil.isNotBlank(storyboard.getLocationName())) { + sb.append("[场景] ").append(storyboard.getLocationName()); + ShortDramaLocation loc = findLocationByName(storyboard.getProjectId(), storyboard.getLocationName()); + if (loc != null && StrUtil.isNotBlank(loc.getSummary())) { + sb.append(" — ").append(loc.getSummary()); + } + // 多参考图模式:附加 @imageN 标记 + if (hasRefImages) { + int imgIdx = findLocationRefImageIndex(refImages, storyboard.getProjectId(), storyboard.getLocationName()); + if (imgIdx >= 0) sb.append("@image").append(imgIdx + 1); + } + sb.append("\n"); + } + + // 5. 表演指导 + if (StrUtil.isNotBlank(storyboard.getActingNotes())) { + try { + JsonNode actingArr = JsonUtils.parseObject(storyboard.getActingNotes(), JsonNode.class); + if (actingArr != null && actingArr.isArray() && !actingArr.isEmpty()) { + sb.append("[表演] "); + for (int i = 0; i < actingArr.size(); i++) { + JsonNode an = actingArr.get(i); + if (i > 0) sb.append(";"); + sb.append(an.path("name").asText("")).append(":") + .append(an.path("acting").asText("")); + } + sb.append("\n"); + } + } catch (Exception e) { log.debug("解析表演指导失败: {}", e.getMessage()); } + } + + // 6. 前后镜头连续性状态 + appendContinuityPrompt(sb, storyboard); + + // 7. 画面描述(AI 撰写的分镜画面叙述) + if (StrUtil.isNotBlank(storyboard.getSceneText()) + && !storyboard.getSceneText().equals(storyboard.getVideoPrompt())) { + sb.append("[画面] ").append(storyboard.getSceneText()).append("\n"); + } + + // 8. 视觉参考(image_prompt 含光线/氛围/构图细节) + if (StrUtil.isNotBlank(storyboard.getImagePrompt())) { + sb.append("[视觉] ").append(storyboard.getImagePrompt()).append("\n"); + } + + // 9. 目标时长与节奏 + int durationSeconds = storyboard.getDurationSeconds() != null ? storyboard.getDurationSeconds() : 6; + sb.append("[时长] ").append(durationSeconds).append("秒,动作与运镜必须完整覆盖全程"); + if (durationSeconds >= 8) { + sb.append(",按前段、中段、后段三个连续节拍展开,避免动作提前结束"); + } + sb.append("\n"); + + // 10. 视觉风格 + String artStyle = artStyleSuffix(storyboard.getProjectId()); + if (StrUtil.isNotBlank(artStyle)) { + sb.append("\n[风格] ").append(artStyle); + } + + // 11. 视频描述(核心驱动 prompt) + sb.append("\n").append(storyboard.getVideoPrompt()); + return sb.toString(); + } + + /** + * 为分镜统一追加人物身份隔离规则。 + *

+ * 参考图只绑定对应具名角色,不能被模型当作群众的通用脸部模板; + * 单人近景进一步限制为仅一张可辨认人脸,多角色镜则要求逐人绑定、互不混脸。 + */ + private void appendCharacterIdentityIsolationPrompt(StringBuilder sb, + ShortDramaStoryboard storyboard, + List chars, + List refImages) { + boolean hasRefImages = refImages != null && !refImages.isEmpty(); + int characterCount = chars == null ? 0 : chars.size(); + String shotType = firstNotBlank(storyboard.getShotType(), ""); + boolean closeShot = shotType.contains("特写") || shotType.contains("近景") + || shotType.toLowerCase(Locale.ROOT).contains("close"); + + sb.append("[人物身份隔离] "); + if (characterCount == 0) { + sb.append("本镜没有具名角色参考。若出现群众,每个人必须具有不同的脸型、五官比例、身高、体态、发型和服装细节;") + .append("禁止复制脸、镜像人物、双胞胎式重复、同一人物贴图和整排相同表情。"); + } else if (characterCount == 1) { + String characterName = chars.get(0).getName(); + sb.append("唯一具名角色为").append(characterName).append(";") + .append(hasRefImages ? "该角色参考图只绑定此人,绝不能扩散给背景人物;" : "角色身份只属于此人;"); + if (closeShot) { + sb.append("这是单人近景/特写,画面只允许一张可辨认人脸。其他人物必须留在画外,") + .append("不得出现背景人头、倒影脸、反射脸、画像脸、脸谱幻影或多重曝光;"); + } else { + sb.append("若剧情必须出现无名群众,群众不得复用").append(characterName) + .append("的五官,后景人物应弱化面部并保持彼此差异;"); + } + sb.append("禁止克隆、分身、镜像脸和同脸不同装。"); + } else { + sb.append("本镜有").append(characterCount).append("名具名角色:"); + for (int i = 0; i < chars.size(); i++) { + if (i > 0) sb.append("、"); + CharacterRef ref = chars.get(i); + sb.append(ref.getName()); + if (hasRefImages) { + int imageIndex = findRefImageIndex(refImages, storyboard.getProjectId(), ref.getName()); + if (imageIndex >= 0) sb.append("仅绑定@image").append(imageIndex + 1); + } + } + sb.append("。各角色必须保持各自独立五官、年龄、性别、发型和服装,禁止互相混脸、交换五官或把第一张参考脸复制给全员;") + .append("无名群众同样不得复用任何具名角色的脸。画面中不得额外生成角色副本、镜像分身或重复人头。"); + } + sb.append("\n"); + } + + /** 在参考图列表中找到指定角色图片的索引 */ + private int findRefImageIndex(java.util.List refImages, Long projectId, String characterName) { + String targetUrl = findCharacterImageUrl(projectId, characterName); + if (StrUtil.isBlank(targetUrl)) return -1; + for (int i = 0; i < refImages.size(); i++) { + if (targetUrl.equals(refImages.get(i))) return i; + } + return -1; + } + + /** 在参考图列表中找到指定场景图片的索引 */ + private int findLocationRefImageIndex(java.util.List refImages, Long projectId, String locationName) { + String targetUrl = findLocationImageUrl(projectId, locationName); + if (StrUtil.isBlank(targetUrl)) return -1; + for (int i = 0; i < refImages.size(); i++) { + if (targetUrl.equals(refImages.get(i))) return i; + } + return -1; + } + + private String findCharacterImageUrl(Long projectId, String characterName) { + List characters = findCharactersByName(projectId, characterName); + for (ShortDramaCharacter character : characters) { + List appearances = characterAppearanceMapper.selectList( + new LambdaQueryWrapper() + .eq(ShortDramaCharacterAppearance::getCharacterId, character.getId()) + .orderByAsc(ShortDramaCharacterAppearance::getAppearanceIndex)); + for (ShortDramaCharacterAppearance appearance : appearances) { + List urls = readJsonStringList(appearance.getImageUrls()); + if (urls.isEmpty()) continue; + int index = appearance.getSelectedImageIndex() != null && appearance.getSelectedImageIndex() >= 0 + && appearance.getSelectedImageIndex() < urls.size() ? appearance.getSelectedImageIndex() : 0; + return urls.get(index); + } + if (StrUtil.isNotBlank(character.getReferenceImageUrl())) { + return character.getReferenceImageUrl(); + } + } + return null; + } + + private String findLocationImageUrl(Long projectId, String locationName) { + ShortDramaLocation location = findLocationByName(projectId, locationName); + if (location == null) return null; + List urls = readJsonStringList(location.getImageUrls()); + if (!urls.isEmpty()) { + int idx = location.getSelectedImageIndex() != null && location.getSelectedImageIndex() >= 0 + && location.getSelectedImageIndex() < urls.size() ? location.getSelectedImageIndex() : 0; + if (idx < urls.size()) return urls.get(idx); + } + // 回退到场景级别单图 + return location.getReferenceImageUrl(); + } + + private ShortDramaCharacter findCharacterByName(Long projectId, String name) { + List characters = findCharactersByName(projectId, name); + return characters.isEmpty() ? null : characters.get(0); + } + + private List findCharactersByName(Long projectId, String name) { + return characterMapper.selectList(new LambdaQueryWrapper() + .eq(ShortDramaCharacter::getProjectId, projectId) + .eq(ShortDramaCharacter::getName, name) + .orderByAsc(ShortDramaCharacter::getId)); + } + + private ShortDramaLocation findLocationByName(Long projectId, String name) { + return locationMapper.selectOne(new LambdaQueryWrapper() + .eq(ShortDramaLocation::getProjectId, projectId) + .eq(ShortDramaLocation::getName, name) + .orderByAsc(ShortDramaLocation::getId) + .last("limit 1")); + } + + private List parseCharacterRefs(String charactersJson) { + if (StrUtil.isBlank(charactersJson)) return null; + try { + return JsonUtils.parseArray(charactersJson, CharacterRef.class); + } catch (Exception e) { + log.debug("解析角色引用失败: {}", e.getMessage()); + return null; + } + } + + /** 从原始 JSON 响应中尽力提取视频 URL(firstOutput 解析失败时的兜底) */ + private String extractVideoUrlFromRaw(String raw) { + if (StrUtil.isBlank(raw)) return null; + try { + JsonNode root = JsonUtils.parseObject(raw, JsonNode.class); + if (root == null) return null; + JsonNode outputs = root.path("data").path("outputs"); + if (outputs.isArray() && !outputs.isEmpty()) { + JsonNode first = outputs.get(0); + // 字符串直接返回 + if (first.isTextual()) return first.asText(); + // 对象尝试常见字段名 + if (first.isObject()) { + for (String key : new String[]{"url", "video_url", "videoUrl", "result"}) { + String val = first.path(key).asText(null); + if (StrUtil.isNotBlank(val)) return val; + } + } + } + } catch (Exception e) { log.debug("从原始响应提取URL失败: {}", e.getMessage()); } + return null; + } + + @Override + public ShortDramaStoryboardVo retrieveVideo(Long storyboardId, String videoModel, Long userId) { + ShortDramaStoryboard storyboard = storyboardMapper.selectById(storyboardId); + if (storyboard == null) { + throw new IllegalArgumentException("分镜不存在"); + } + ShortDramaProject project = projectMapper.selectById(storyboard.getProjectId()); + if (project == null || !userId.equals(project.getUserId())) { + throw new IllegalArgumentException("项目不存在或无权限"); + } + if (StrUtil.isBlank(storyboard.getVideoId()) || storyboard.getVideoId().startsWith("local:")) { + return MapstructUtils.convert(storyboard, ShortDramaStoryboardVo.class); + } + String predictionId = storyboard.getVideoId(); + ChatModelVo modelVo = chatModelService.selectModelByName(videoModel); + if (modelVo == null) { + throw new IllegalArgumentException("未找到视频模型配置: " + videoModel); + } + VideoContext ctx = VideoContext.builder() + .chatModelVo(modelVo) + .prompt("retrieve") + .videoId(predictionId) + .build(); + MediaGenerationResponse response = videoServiceFactory.getOriginalService(modelVo.getProviderCode()) + .retrieveVideo(ctx); + String videoUrl = null; + String videoStatus = null; + if (response != null && StrUtil.isNotBlank(response.getUrl())) { + videoUrl = response.getUrl(); + videoStatus = "done"; + } else if (response != null && ("completed".equals(response.getStatus()) || "succeeded".equals(response.getStatus()))) { + // 已完成但 URL 提取失败,尝试从原始响应中提取 + String fallbackUrl = extractVideoUrlFromRaw(response.getRawResponse()); + if (StrUtil.isNotBlank(fallbackUrl)) { + videoUrl = fallbackUrl; + videoStatus = "done"; + } else { + log.warn("视频已完成但无法提取URL, predictionId={}, raw={}", predictionId, + response.getRawResponse() != null ? response.getRawResponse().substring(0, Math.min(300, response.getRawResponse().length())) : "null"); + videoStatus = "failed"; + } + } else if (response != null && "failed".equals(response.getStatus())) { + videoStatus = "failed"; + } + if (videoStatus != null) { + int updated = storyboardMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, storyboardId) + .eq(ShortDramaStoryboard::getVideoId, predictionId) + .set(ShortDramaStoryboard::getVideoUrl, videoUrl) + .set(ShortDramaStoryboard::getVideoStatus, videoStatus)); + if (updated > 0) { + videoComposeService.invalidateComposition(project.getId()); + } + } + return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); + } + + @Override + public List generateAllVideos(Long projectId, String videoModel, Long userId) { + ShortDramaProject project = projectMapper.selectById(projectId); + if (project == null || !userId.equals(project.getUserId())) { + throw new IllegalArgumentException("项目不存在或无权限"); + } + List storyboards = storyboardMapper.selectList( + new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getProjectId, projectId) + .orderByAsc(ShortDramaStoryboard::getSceneNo)); + List result = new ArrayList<>(); + for (ShortDramaStoryboard sb : storyboards) { + try { + result.add(generateVideo(sb.getId(), videoModel, userId)); + } catch (Exception e) { + log.warn("镜头{}视频生成失败: {}", sb.getSceneNo(), e.getMessage()); + ShortDramaStoryboard current = storyboardMapper.selectById(sb.getId()); + result.add(MapstructUtils.convert(current != null ? current : sb, ShortDramaStoryboardVo.class)); + } + } + return result; + } + + // ==================== 资产分析与管理 ==================== + + @Override + public ShortDramaDetailVo analyzeAssets(Long projectId, Long scriptId, Long userId) { + ShortDramaProject project = validateProjectOwner(projectId, userId); + ShortDramaScript script = scriptMapper.selectById(scriptId); + if (script == null || !projectId.equals(script.getProjectId())) { + throw new IllegalArgumentException("剧本不存在"); + } + clearAssets(projectId); + ChatModelVo modelVo = findChatModel(); + AbstractChatService chatService = getChatService(modelVo); + ChatModel chatModel = chatService.buildChatModel(modelVo); + executePhase2_AssetAnalysis(chatModel, projectId, script); + return getDetail(projectId, userId); + } + + @Override + public ShortDramaCharacterVo saveCharacter(ShortDramaCharacterBo bo, Long userId) { + ShortDramaCharacter entity = MapstructUtils.convert(bo, ShortDramaCharacter.class); + validateProjectOwner(entity.getProjectId(), userId); + if (entity.getId() == null) { + characterMapper.insert(entity); + } else { + characterMapper.updateById(entity); + } + ShortDramaCharacterVo vo = MapstructUtils.convert(entity, ShortDramaCharacterVo.class); + vo.setAppearances(characterAppearanceMapper.selectVoList( + new LambdaQueryWrapper() + .eq(ShortDramaCharacterAppearance::getCharacterId, entity.getId()) + .orderByAsc(ShortDramaCharacterAppearance::getAppearanceIndex))); + return vo; + } + + @Override + public ShortDramaLocationVo saveLocation(ShortDramaLocationBo bo, Long userId) { + ShortDramaLocation entity = MapstructUtils.convert(bo, ShortDramaLocation.class); + validateProjectOwner(entity.getProjectId(), userId); + if (entity.getId() == null) { + locationMapper.insert(entity); + } else { + locationMapper.updateById(entity); + } + return MapstructUtils.convert(entity, ShortDramaLocationVo.class); + } + + @Override + public ShortDramaCharacterVo generateCharacterImage(Long characterId, String imageModel, String referenceImageUrl, Long userId) { + ShortDramaCharacter character = characterMapper.selectById(characterId); + if (character == null) throw new IllegalArgumentException("角色不存在"); + validateProjectOwner(character.getProjectId(), userId); + ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); + if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); + String basePrompt = firstNotBlank(character.getVisualDescription(), character.getName()); + String finalPrompt = buildCharacterPrompt(basePrompt, character.getProjectId()); + String referenceImage = validateReferenceImageUrl(referenceImageUrl); + String imageUrl = imageServiceFactory.getOriginalService(modelVo.getProviderCode()) + .generateImage(org.ruoyi.common.chat.entity.image.ImageContext.builder() + .chatModelVo(modelVo).prompt(finalPrompt).size("3:2") + .seed(ShortDramaImageConstants.styleSeed(character.getProjectId())) + .image(referenceImage).build()); + if (StrUtil.isNotBlank(imageUrl)) { + character.setReferenceImageUrl(imageUrl); + characterMapper.updateById(character); + } + ShortDramaCharacterVo vo = MapstructUtils.convert(character, ShortDramaCharacterVo.class); + vo.setAppearances(characterAppearanceMapper.selectVoList( + new LambdaQueryWrapper() + .eq(ShortDramaCharacterAppearance::getCharacterId, characterId) + .orderByAsc(ShortDramaCharacterAppearance::getAppearanceIndex))); + return vo; + } + + @Override + public ShortDramaLocationVo generateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId) { + ShortDramaLocation location = locationMapper.selectById(locationId); + if (location == null) throw new IllegalArgumentException("场景不存在"); + validateProjectOwner(location.getProjectId(), userId); + ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); + if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); + String prompt = firstNotBlank(location.getDescriptions(), location.getSummary(), location.getName()); + String finalPrompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); + String referenceImage = validateReferenceImageUrl(referenceImageUrl); + String imageUrl = imageServiceFactory.getOriginalService(modelVo.getProviderCode()) + .generateImage(org.ruoyi.common.chat.entity.image.ImageContext.builder() + .chatModelVo(modelVo).prompt(finalPrompt).size(projectAspectRatio(location.getProjectId())).image(referenceImage).build()); + if (StrUtil.isNotBlank(imageUrl)) { + location.setReferenceImageUrl(imageUrl); + List urls = readJsonStringList(location.getImageUrls()); + List descs = readJsonStringList(location.getImageDescriptions()); + addImageUrl(urls, imageUrl, descs, finalPrompt); + location.setImageUrls(JsonUtils.toJsonString(urls)); + location.setImageDescriptions(JsonUtils.toJsonString(descs)); + location.setSelectedImageIndex(urls.size() - 1); + locationMapper.updateById(location); + } + return MapstructUtils.convert(location, ShortDramaLocationVo.class); + } + + @Override + public Boolean deleteCharacter(Long characterId, Long userId) { + ShortDramaCharacter character = characterMapper.selectById(characterId); + if (character == null) return false; + validateProjectOwner(character.getProjectId(), userId); + characterAppearanceMapper.delete(new LambdaQueryWrapper() + .eq(ShortDramaCharacterAppearance::getCharacterId, characterId)); + return characterMapper.deleteById(characterId) > 0; + } + + @Override + public Boolean deleteLocation(Long locationId, Long userId) { + ShortDramaLocation location = locationMapper.selectById(locationId); + if (location == null) return false; + validateProjectOwner(location.getProjectId(), userId); + return locationMapper.deleteById(locationId) > 0; + } + + @Override + public ShortDramaCharacterAppearanceVo saveAppearance(ShortDramaCharacterAppearanceBo bo, Long userId) { + ShortDramaCharacterAppearance entity = MapstructUtils.convert(bo, ShortDramaCharacterAppearance.class); + ShortDramaCharacter character = characterMapper.selectById(entity.getCharacterId()); + if (character == null) throw new IllegalArgumentException("角色不存在"); + validateProjectOwner(character.getProjectId(), userId); + if (entity.getAppearanceIndex() == null) entity.setAppearanceIndex(0); + if (entity.getChangeReason() == null) entity.setChangeReason("手动添加"); + if (entity.getId() == null) { + characterAppearanceMapper.insert(entity); + } else { + characterAppearanceMapper.updateById(entity); + } + return MapstructUtils.convert(entity, ShortDramaCharacterAppearanceVo.class); + } + + @Override + public Boolean deleteAppearance(Long appearanceId, Long userId) { + ShortDramaCharacterAppearance appearance = characterAppearanceMapper.selectById(appearanceId); + if (appearance == null) return false; + ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId()); + if (character == null) return false; + validateProjectOwner(character.getProjectId(), userId); + return characterAppearanceMapper.deleteById(appearanceId) > 0; + } + + @Override + public ShortDramaCharacterAppearanceVo generateAppearanceImage(Long appearanceId, String imageModel, String referenceImageUrl, Long userId) { + ShortDramaCharacterAppearance appearance = characterAppearanceMapper.selectById(appearanceId); + if (appearance == null) throw new IllegalArgumentException("形象不存在"); + ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId()); + if (character == null) throw new IllegalArgumentException("角色不存在"); + validateProjectOwner(character.getProjectId(), userId); + ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); + if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); + String basePrompt = firstNotBlank(appearance.getDescription(), character.getVisualDescription(), character.getName()); + String finalPrompt = buildCharacterPrompt(basePrompt, character.getProjectId()); + String referenceImage = validateReferenceImageUrl(referenceImageUrl); + String imageUrl = imageServiceFactory.getOriginalService(modelVo.getProviderCode()) + .generateImage(org.ruoyi.common.chat.entity.image.ImageContext.builder() + .chatModelVo(modelVo).prompt(finalPrompt).size("3:2") + .seed(ShortDramaImageConstants.styleSeed(character.getProjectId())) + .image(referenceImage).build()); + if (StrUtil.isNotBlank(imageUrl)) { + // 备份当前状态到 previous 字段 + appearance.setPreviousImageUrls(appearance.getImageUrls()); + appearance.setPreviousDescriptions(appearance.getImageDescriptions()); + // 追加新图 + appearance.setReferenceImageUrl(imageUrl); + List urls = readJsonStringList(appearance.getImageUrls()); + List descs = readJsonStringList(appearance.getImageDescriptions()); + addImageUrl(urls, imageUrl, descs, finalPrompt); + appearance.setImageUrls(JsonUtils.toJsonString(urls)); + appearance.setImageDescriptions(JsonUtils.toJsonString(descs)); + appearance.setSelectedImageIndex(urls.size() - 1); + characterAppearanceMapper.updateById(appearance); + } + return MapstructUtils.convert(appearance, ShortDramaCharacterAppearanceVo.class); + } + + @Override + public ShortDramaCharacterAppearanceVo regenerateAppearanceImage(Long appearanceId, String imageModel, String referenceImageUrl, Long userId) { + ShortDramaCharacterAppearance appearance = characterAppearanceMapper.selectById(appearanceId); + if (appearance == null) throw new IllegalArgumentException("形象不存在"); + ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId()); + if (character == null) throw new IllegalArgumentException("角色不存在"); + validateProjectOwner(character.getProjectId(), userId); + ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); + if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); + String basePrompt = firstNotBlank(appearance.getDescription(), character.getVisualDescription(), character.getName()); + String finalPrompt = buildCharacterPrompt(basePrompt, character.getProjectId()); + String referenceImage = validateReferenceImageUrl(referenceImageUrl); + String imageUrl = imageServiceFactory.getOriginalService(modelVo.getProviderCode()) + .generateImage(org.ruoyi.common.chat.entity.image.ImageContext.builder() + .chatModelVo(modelVo).prompt(finalPrompt).size("3:2") + .seed(ShortDramaImageConstants.styleSeed(character.getProjectId())) + .image(referenceImage).build()); + if (StrUtil.isNotBlank(imageUrl)) { + appearance.setReferenceImageUrl(imageUrl); + List urls = readJsonStringList(appearance.getImageUrls()); + List descs = readJsonStringList(appearance.getImageDescriptions()); + addImageUrl(urls, imageUrl, descs, finalPrompt); + appearance.setImageUrls(JsonUtils.toJsonString(urls)); + appearance.setImageDescriptions(JsonUtils.toJsonString(descs)); + appearance.setSelectedImageIndex(urls.size() - 1); + characterAppearanceMapper.updateById(appearance); + } + return MapstructUtils.convert(appearance, ShortDramaCharacterAppearanceVo.class); + } + + @Override + public ShortDramaCharacterAppearanceVo selectAppearanceImage(Long appearanceId, Integer index, Long userId) { + ShortDramaCharacterAppearance appearance = characterAppearanceMapper.selectById(appearanceId); + if (appearance == null) throw new IllegalArgumentException("形象不存在"); + ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId()); + if (character == null) throw new IllegalArgumentException("角色不存在"); + validateProjectOwner(character.getProjectId(), userId); + List urls = readJsonStringList(appearance.getImageUrls()); + if (index < 0 || index >= urls.size()) throw new IllegalArgumentException("图片索引无效: " + index); + appearance.setSelectedImageIndex(index); + if (urls.get(index) != null) appearance.setReferenceImageUrl(urls.get(index)); + characterAppearanceMapper.updateById(appearance); + return MapstructUtils.convert(appearance, ShortDramaCharacterAppearanceVo.class); + } + + @Override + public ShortDramaCharacterAppearanceVo deleteAppearanceImage(Long appearanceId, Integer index, Long userId) { + ShortDramaCharacterAppearance appearance = characterAppearanceMapper.selectById(appearanceId); + if (appearance == null) throw new IllegalArgumentException("形象不存在"); + ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId()); + if (character == null) throw new IllegalArgumentException("角色不存在"); + validateProjectOwner(character.getProjectId(), userId); + List urls = readJsonStringList(appearance.getImageUrls()); + List descs = readJsonStringList(appearance.getImageDescriptions()); + if (urls.size() <= 1) throw new IllegalStateException("至少保留一张角色图片,无法删除最后一张"); + if (index < 0 || index >= urls.size()) throw new IllegalArgumentException("图片索引无效: " + index); + appearance.setPreviousImageUrls(appearance.getImageUrls()); + appearance.setPreviousDescriptions(appearance.getImageDescriptions()); + urls.remove((int) index); + if (index < descs.size()) descs.remove((int) index); + int selected = normalizeSelectedIndexAfterDelete(appearance.getSelectedImageIndex(), index, urls.size()); + appearance.setImageUrls(JsonUtils.toJsonString(urls)); + appearance.setImageDescriptions(JsonUtils.toJsonString(descs)); + appearance.setSelectedImageIndex(selected); + appearance.setReferenceImageUrl(urls.get(selected)); + characterAppearanceMapper.updateById(appearance); + return MapstructUtils.convert(appearance, ShortDramaCharacterAppearanceVo.class); + } + + @Override + public ShortDramaCharacterAppearanceVo undoAppearanceImage(Long appearanceId, Long userId) { + ShortDramaCharacterAppearance appearance = characterAppearanceMapper.selectById(appearanceId); + if (appearance == null) throw new IllegalArgumentException("形象不存在"); + ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId()); + if (character == null) throw new IllegalArgumentException("角色不存在"); + validateProjectOwner(character.getProjectId(), userId); + if (StrUtil.isBlank(appearance.getPreviousImageUrls())) throw new IllegalStateException("没有可撤销的图片"); + appearance.setImageUrls(appearance.getPreviousImageUrls()); + appearance.setImageDescriptions(appearance.getPreviousDescriptions()); + appearance.setPreviousImageUrls(null); + appearance.setPreviousDescriptions(null); + List urls = readJsonStringList(appearance.getImageUrls()); + int idx = urls.isEmpty() ? -1 : urls.size() - 1; + appearance.setSelectedImageIndex(idx); + appearance.setReferenceImageUrl(idx >= 0 ? urls.get(idx) : null); + characterAppearanceMapper.updateById(appearance); + return MapstructUtils.convert(appearance, ShortDramaCharacterAppearanceVo.class); + } + + @Override + public ShortDramaLocationVo regenerateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId) { + ShortDramaLocation location = locationMapper.selectById(locationId); + if (location == null) throw new IllegalArgumentException("场景不存在"); + validateProjectOwner(location.getProjectId(), userId); + ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); + if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); + String prompt = firstNotBlank(location.getDescriptions(), location.getSummary(), location.getName()); + String finalPrompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); + String referenceImage = validateReferenceImageUrl(referenceImageUrl); + String imageUrl = imageServiceFactory.getOriginalService(modelVo.getProviderCode()) + .generateImage(org.ruoyi.common.chat.entity.image.ImageContext.builder() + .chatModelVo(modelVo).prompt(finalPrompt).size(projectAspectRatio(location.getProjectId())).image(referenceImage).build()); + if (StrUtil.isNotBlank(imageUrl)) { + location.setReferenceImageUrl(imageUrl); + List urls = readJsonStringList(location.getImageUrls()); + List descs = readJsonStringList(location.getImageDescriptions()); + addImageUrl(urls, imageUrl, descs, finalPrompt); + location.setImageUrls(JsonUtils.toJsonString(urls)); + location.setImageDescriptions(JsonUtils.toJsonString(descs)); + location.setSelectedImageIndex(urls.size() - 1); + locationMapper.updateById(location); + } + return MapstructUtils.convert(location, ShortDramaLocationVo.class); + } + + @Override + public ShortDramaLocationVo selectLocationImage(Long locationId, Integer index, Long userId) { + ShortDramaLocation location = locationMapper.selectById(locationId); + if (location == null) throw new IllegalArgumentException("场景不存在"); + validateProjectOwner(location.getProjectId(), userId); + List urls = readJsonStringList(location.getImageUrls()); + if (index < 0 || index >= urls.size()) throw new IllegalArgumentException("图片索引无效: " + index); + location.setSelectedImageIndex(index); + if (urls.get(index) != null) location.setReferenceImageUrl(urls.get(index)); + locationMapper.updateById(location); + return MapstructUtils.convert(location, ShortDramaLocationVo.class); + } + + @Override + public ShortDramaLocationVo deleteLocationImage(Long locationId, Integer index, Long userId) { + ShortDramaLocation location = locationMapper.selectById(locationId); + if (location == null) throw new IllegalArgumentException("场景不存在"); + validateProjectOwner(location.getProjectId(), userId); + List urls = readJsonStringList(location.getImageUrls()); + List descs = readJsonStringList(location.getImageDescriptions()); + if (urls.size() <= 1) throw new IllegalStateException("至少保留一张场景图片,无法删除最后一张"); + if (index < 0 || index >= urls.size()) throw new IllegalArgumentException("图片索引无效: " + index); + location.setPreviousImageUrls(location.getImageUrls()); + location.setPreviousDescriptions(location.getImageDescriptions()); + urls.remove((int) index); + if (index < descs.size()) descs.remove((int) index); + int selected = normalizeSelectedIndexAfterDelete(location.getSelectedImageIndex(), index, urls.size()); + location.setImageUrls(JsonUtils.toJsonString(urls)); + location.setImageDescriptions(JsonUtils.toJsonString(descs)); + location.setSelectedImageIndex(selected); + location.setReferenceImageUrl(urls.get(selected)); + locationMapper.updateById(location); + return MapstructUtils.convert(location, ShortDramaLocationVo.class); + } + + private static int normalizeSelectedIndexAfterDelete(Integer selectedIndex, int deletedIndex, int remainingSize) { + int selected = selectedIndex != null ? selectedIndex : 0; + if (selected > deletedIndex) selected--; + else if (selected == deletedIndex) selected = Math.min(deletedIndex, remainingSize - 1); + return Math.max(0, Math.min(selected, remainingSize - 1)); + } + + @Override + public ShortDramaLocationVo undoLocationImage(Long locationId, Long userId) { + ShortDramaLocation location = locationMapper.selectById(locationId); + if (location == null) throw new IllegalArgumentException("场景不存在"); + validateProjectOwner(location.getProjectId(), userId); + if (StrUtil.isBlank(location.getPreviousImageUrls())) throw new IllegalStateException("没有可撤销的图片"); + location.setImageUrls(location.getPreviousImageUrls()); + location.setImageDescriptions(location.getPreviousDescriptions()); + location.setPreviousImageUrls(null); + location.setPreviousDescriptions(null); + List urls = readJsonStringList(location.getImageUrls()); + int idx = urls.isEmpty() ? -1 : urls.size() - 1; + location.setSelectedImageIndex(idx); + location.setReferenceImageUrl(idx >= 0 ? urls.get(idx) : null); + locationMapper.updateById(location); + return MapstructUtils.convert(location, ShortDramaLocationVo.class); + } + + // ==================== 异步图片生成(轮询进度) ==================== + + @Override + public String uploadReferenceImage(org.springframework.web.multipart.MultipartFile file, String model, Long userId) { + if (userId == null) throw new IllegalArgumentException("用户未登录"); + if (file == null || file.isEmpty()) throw new IllegalArgumentException("参考图不能为空"); + if (file.getSize() > 10L * 1024 * 1024) throw new IllegalArgumentException("参考图不能超过10MB"); + String contentType = firstNotBlank(file.getContentType(), ""); + if (!("image/jpeg".equalsIgnoreCase(contentType) + || "image/png".equalsIgnoreCase(contentType) + || "image/webp".equalsIgnoreCase(contentType))) { + throw new IllegalArgumentException("参考图仅支持JPEG、PNG或WebP"); + } + ChatModelVo modelVo = chatModelService.selectModelByName(model); + if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model); + try { + return imageServiceFactory.getOriginalService(modelVo.getProviderCode()) + .uploadMedia(modelVo, file.getBytes(), file.getOriginalFilename(), contentType); + } catch (IOException e) { + throw new IllegalStateException("读取参考图失败: " + e.getMessage(), e); + } + } + + @Override + public MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl, Long userId) { + ChatModelVo modelVo = chatModelService.selectModelByName(model); + if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model); + + String prompt; + String size; + Integer seed = null; + if ("appearance".equals(assetType)) { + ShortDramaCharacterAppearance appearance = characterAppearanceMapper.selectById(assetId); + if (appearance == null) throw new IllegalArgumentException("形象不存在"); + ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId()); + if (character == null) throw new IllegalArgumentException("角色不存在"); + validateProjectOwner(character.getProjectId(), userId); + String basePrompt = firstNotBlank(appearance.getDescription(), character.getVisualDescription(), character.getName()); + prompt = buildCharacterPrompt(basePrompt, character.getProjectId()); + size = "3:2"; + seed = ShortDramaImageConstants.styleSeed(character.getProjectId()); + } else if ("location".equals(assetType)) { + ShortDramaLocation location = locationMapper.selectById(assetId); + if (location == null) throw new IllegalArgumentException("场景不存在"); + validateProjectOwner(location.getProjectId(), userId); + String basePrompt = firstNotBlank(location.getDescriptions(), location.getSummary(), location.getName()); + prompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + basePrompt + + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); + size = projectAspectRatio(location.getProjectId()); + seed = ShortDramaImageConstants.styleSeed(location.getProjectId()); + } else { + throw new IllegalArgumentException("不支持的资产类型: " + assetType); + } + + return imageServiceFactory.getOriginalService(modelVo.getProviderCode()) + .startImageGeneration(ImageContext.builder() + .chatModelVo(modelVo).prompt(prompt).size(size).seed(seed) + .image(validateReferenceImageUrl(referenceImageUrl)).build()); + } + + @Override + public ShortDramaCharacterAppearanceVo confirmAppearanceImage(Long appearanceId, String predictionId, String model, Long userId) { + ShortDramaCharacterAppearance appearance = characterAppearanceMapper.selectById(appearanceId); + if (appearance == null) throw new IllegalArgumentException("形象不存在"); + ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId()); + if (character == null) throw new IllegalArgumentException("角色不存在"); + validateProjectOwner(character.getProjectId(), userId); + + ChatModelVo modelVo = chatModelService.selectModelByName(model); + if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model); + + MediaGenerationResponse result = atlasPredictionService.retrieve(modelVo, predictionId); + if (!"completed".equals(result.getStatus())) { + throw new IllegalStateException("图片尚未生成完成,当前状态: " + result.getStatus()); + } + String imageUrl = result.getUrl(); + if (StrUtil.isBlank(imageUrl)) { + throw new IllegalStateException("图片生成完成但未获取到URL"); + } + + String basePrompt = firstNotBlank(appearance.getDescription(), character.getVisualDescription(), character.getName()); + String finalPrompt = buildCharacterPrompt(basePrompt, character.getProjectId()); + + appearance.setPreviousImageUrls(appearance.getImageUrls()); + appearance.setPreviousDescriptions(appearance.getImageDescriptions()); + appearance.setReferenceImageUrl(imageUrl); + List urls = readJsonStringList(appearance.getImageUrls()); + List descs = readJsonStringList(appearance.getImageDescriptions()); + addImageUrl(urls, imageUrl, descs, finalPrompt); + appearance.setImageUrls(JsonUtils.toJsonString(urls)); + appearance.setImageDescriptions(JsonUtils.toJsonString(descs)); + appearance.setSelectedImageIndex(urls.size() - 1); + characterAppearanceMapper.updateById(appearance); + + return MapstructUtils.convert(appearance, ShortDramaCharacterAppearanceVo.class); + } + + @Override + public ShortDramaLocationVo confirmLocationImage(Long locationId, String predictionId, String model, Long userId) { + ShortDramaLocation location = locationMapper.selectById(locationId); + if (location == null) throw new IllegalArgumentException("场景不存在"); + validateProjectOwner(location.getProjectId(), userId); + + ChatModelVo modelVo = chatModelService.selectModelByName(model); + if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model); + + MediaGenerationResponse result = atlasPredictionService.retrieve(modelVo, predictionId); + if (!"completed".equals(result.getStatus())) { + throw new IllegalStateException("图片尚未生成完成,当前状态: " + result.getStatus()); + } + String imageUrl = result.getUrl(); + if (StrUtil.isBlank(imageUrl)) { + throw new IllegalStateException("图片生成完成但未获取到URL"); + } + + String prompt = firstNotBlank(location.getDescriptions(), location.getSummary(), location.getName()); + String finalPrompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); + + location.setPreviousImageUrls(location.getImageUrls()); + location.setPreviousDescriptions(location.getImageDescriptions()); + location.setReferenceImageUrl(imageUrl); + List urls = readJsonStringList(location.getImageUrls()); + List descs = readJsonStringList(location.getImageDescriptions()); + addImageUrl(urls, imageUrl, descs, finalPrompt); + location.setImageUrls(JsonUtils.toJsonString(urls)); + location.setImageDescriptions(JsonUtils.toJsonString(descs)); + location.setSelectedImageIndex(urls.size() - 1); + locationMapper.updateById(location); + + return MapstructUtils.convert(location, ShortDramaLocationVo.class); + } + + /** 追加图片 URL 并校验上限 */ + private static void addImageUrl(List urls, String url, List descs, String desc) { + if (urls.size() >= ShortDramaImageConstants.MAX_IMAGE_VARIANTS) { + throw new IllegalArgumentException("图片已达上限(最多" + ShortDramaImageConstants.MAX_IMAGE_VARIANTS + "张),请先撤销后再生成"); + } + urls.add(url); + descs.add(desc); + } + + /** 安全地将 JSON 字符串解析为 String 列表,解析失败返回空列表 */ + private static List readJsonStringList(String json) { + if (StrUtil.isBlank(json)) return new ArrayList<>(); + try { + List list = JsonUtils.parseArray(json, String.class); + return list != null ? list : new ArrayList<>(); + } catch (Exception e) { + return new ArrayList<>(); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteProject(Long projectId, Long userId) { + validateProjectOwner(projectId, userId); + videoComposeService.deleteComposition(projectId); + storyboardMapper.delete(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getProjectId, projectId)); + List characterIds = characterMapper.selectList(new LambdaQueryWrapper() + .eq(ShortDramaCharacter::getProjectId, projectId) + .select(ShortDramaCharacter::getId)).stream().map(ShortDramaCharacter::getId).toList(); + if (!characterIds.isEmpty()) { + characterAppearanceMapper.delete(new LambdaQueryWrapper() + .in(ShortDramaCharacterAppearance::getCharacterId, characterIds)); + } + characterMapper.delete(new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); + locationMapper.delete(new LambdaQueryWrapper().eq(ShortDramaLocation::getProjectId, projectId)); + scriptMapper.delete(new LambdaQueryWrapper().eq(ShortDramaScript::getProjectId, projectId)); + return projectMapper.deleteById(projectId) > 0; + } + + // ==================== 通用流式调用辅助 ==================== + + /** + * 使用 StreamingChatModel 调用 LLM,原始输出逐字推送到 SSE,返回完整响应文本。 + * 仅当 emitter 和 model 均非 null 时启用流式;否则退化为同步调用。 + */ + private String streamingChat(StreamingChatModel streamingModel, ChatModel chatModel, + String prompt, SseEmitter emitter, String streamPhase) { + if (streamingModel == null || emitter == null) { + return chatModel.chat(prompt); + } + StringBuilder buf = new StringBuilder(); + CompletableFuture done = new CompletableFuture<>(); + ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "short-drama-sse-heartbeat"); + thread.setDaemon(true); + return thread; + }); + heartbeat.scheduleAtFixedRate(() -> emit(emitter, streamPhase, "running", "模型仍在处理中,请稍候..."), + 15, 15, TimeUnit.SECONDS); + List messages = List.of(UserMessage.from(prompt)); + streamingModel.chat(messages, new StreamingChatResponseHandler() { + @Override + public void onPartialResponse(String text) { + buf.append(text); + emitStream(emitter, streamPhase, text); + } + @Override + public void onCompleteResponse(ChatResponse response) { + emitStreamDone(emitter); + done.complete(null); + } + @Override + public void onError(Throwable error) { + done.completeExceptionally(error); + } + }); + try { + done.orTimeout(30, TimeUnit.MINUTES).join(); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + throw new RuntimeException("模型在30分钟内未完成响应,请检查模型服务状态或更换模型后重试", cause); + } finally { + heartbeat.shutdownNow(); + } + return buf.toString(); + } + + // ==================== Phase 1: 剧本打磨(单次流式调用,元信息 JSON + 剧本正文)==================== + + private static final String SCRIPT_DELIMITER = "===SCRIPT==="; + + private static final String PHASE1_COMBINED_SYSTEM = """ + 你是顶级短剧编剧、剧本统筹和场景规划师。根据用户提供的故事资料,创作一部剧情完整、可拍摄、具有多场次推进的短剧,而不是只截取结局或高潮片段。 + + 【输出格式 - 严格按顺序】 + 先输出一行 JSON 元信息,换行后输出 "===SCRIPT===", 再换行后输出完整剧本正文(1200-2500字)。 + + JSON 格式(一行完成,不要换行): + {"projectName":"项目名","description":"简介(30-80字)","scriptName":"剧本名","tone":"类型与基调","outlineText":"完整分场大纲(500-1000字)"} + + 【完整故事弧 - 最高优先级】 + 1. 必须覆盖用户资料中的完整主线,禁止只选择决赛、告白、复仇等最后高潮单独成篇 + 2. 至少包含5个有不同剧情功能的场次;资料跨度较大时建议6-10场 + 3. 场次必须覆盖:人物与困境建立 → 改变契机 → 发展/训练或关系推进 → 重大挫折或背叛 → 主角自主决定与重组 → 高潮行动 → 结果与主题落点 + 4. 每场都必须改变剧情状态,说明角色目标、阻碍、行动和结果;禁止多个场次只是重复训练、重复争吵或重复比赛 + 5. 反派倒戈、关系破裂、能力掌握、团队团结、比赛逆转等重大变化必须提前铺垫,不能突然发生 + 6. 开场不得直接进入最终决战,除非用户明确要求只写高潮片段 + + 【分场要求】 + - 使用标准场景头:内景/外景 + 具体地点 + 时间 + - 地点变化、时间跳变或一个戏剧单元完成时另起新场 + - 每场包含:场景建立、明确冲突、角色行动、可见结果、引向下一场的钩子 + - 场间用动作、决定、视线、声音、时间推进或人物出发进行衔接 + - 闪回最多使用一次,且不能替代正常的前期铺垫 + + 【剧情忠实与提炼】 + - 用户输入可能包含新闻摘要、角色资料、链接来源和重复段落。提取其中的故事事实,不要把网址、媒体名称、点赞按钮或来源标签写进剧本 + - 保留核心人物、核心冲突、关键反转与结局;可压缩次要人物和重复信息,但不得删除导致主线断裂的阶段 + - 不照搬现实演员姓名作为角色说明;只使用故事中的角色名 + - 不虚构用户资料之外的重大设定、冠军、背叛原因或人物关系;必要的过渡动作可以合理补全 + + 【剧本格式】 + 场景头 + 场景环境与人物初始状态 + 连续动作描述 + 角色名:「台词」 + 该场结果与进入下一场的动作钩子 + + 【台词与动作】 + - 对话必须推动认知、关系、决定或行动变化 + - 重要决定先有触发,再有反应,最后付诸行动 + - 将心理活动转译为可见动作、表情、选择或简短独白 + - 比赛、战斗和训练必须写清动作过程与结果,不能只用“经过努力”“最终获胜”概括 + + 【自检 - 输出前内部完成】 + - 是否至少5个场景头,且地点/时间/剧情功能有变化 + - 是否覆盖了用户资料中的起点、发展、低谷、反击和结局 + - 删除任一场是否会造成剧情断裂;若不会,该场应合并或强化 + - 是否存在角色无原因倒戈、突然掌握能力、突然团结或突然获胜 + - 是否误把最终高潮写成了整部剧本 + + 只输出上述格式,不要任何额外说明。 + """; + + /** + * 同步版本:ChatModel 单次调用,解析 "JSON\\n===SCRIPT===\\n剧本" 格式 + */ + private static String sanitizeIdeaInput(String idea) { + if (idea == null) return ""; + String cleaned = idea + .replaceAll("(?im)^\\s*(喜欢|不喜欢)\\s*$", "") + .replaceAll("\\[([^\\]]+)]\\(https?://[^)]+\\)", "$1") + .replaceAll("https?://\\S+", "") + .replaceAll("(?im)^\\s*(ent\\.sina\\.cn|k\\.sina\\.cn|来源[::]?|\\[?\\+\\d+]?)[^\\n]*$", "") + .replaceAll("(?m)^[ \t]+$", "") + .replaceAll("\n{3,}", "\n\n") + .trim(); + return cleaned.length() > 12000 ? cleaned.substring(0, 12000) : cleaned; + } + + private static int countSceneHeadings(String scriptText) { + if (StrUtil.isBlank(scriptText)) return 0; + int count = 0; + for (String line : scriptText.split("\\R")) { + String normalized = line.trim(); + if (normalized.matches("^(内景|外景|内外景|闪回[::]?\\s*(内景|外景)?).*")) count++; + } + return count; + } + + private static boolean scriptNeedsExpansion(ShortDramaScriptResult result) { + if (result == null || StrUtil.isBlank(result.getScriptText())) return true; + return result.getScriptText().length() < 1000 || countSceneHeadings(result.getScriptText()) < 5; + } + + private ShortDramaScriptResult expandIncompleteScript(ChatModel chatModel, ShortDramaIdeaBo bo, + ShortDramaScriptResult initial) { + String prompt = PHASE1_COMBINED_SYSTEM + + "\n\n以下初稿场次不足或过度集中在高潮。请基于原始资料完整重写,不要只修改局部。" + + "\n原始资料:\n" + sanitizeIdeaInput(bo.getIdea()) + + "\n\n不合格初稿:\n" + (initial == null ? "无" : firstNotBlank(initial.getScriptText(), "无")); + ShortDramaScriptResult expanded = parsePhase1Response(chatModel.chat(prompt)); + return expanded != null && !scriptNeedsExpansion(expanded) ? expanded : initial; + } + + private ShortDramaScriptResult executePhase1_ScriptPolish(ChatModel chatModel, ShortDramaIdeaBo bo, + Consumer onPhase) { + String projectName = firstNotBlank(bo.getProjectName(), "短剧项目"); + String prompt = PHASE1_COMBINED_SYSTEM + "\n\n用户期望项目名:" + projectName + "\n用户创意:" + sanitizeIdeaInput(bo.getIdea()); + + long t0 = System.currentTimeMillis(); + String resp = chatModel.chat(prompt); + log.info("Phase 1 剧本打磨: elapsed={}ms len={}", System.currentTimeMillis() - t0, + resp != null ? resp.length() : 0); + + ShortDramaScriptResult result = parsePhase1Response(resp); + if (result == null || StrUtil.isBlank(result.getScriptText())) { + throw new RuntimeException("剧本打磨失败:LLM 返回格式异常,请重试"); + } + if (scriptNeedsExpansion(result)) { + log.warn("Phase 1 剧本场次不足,自动扩写: length={} scenes={}", + result.getScriptText().length(), countSceneHeadings(result.getScriptText())); + result = expandIncompleteScript(chatModel, bo, result); + } + if (onPhase != null) onPhase.accept("outline_done"); + return result; + } + + private ShortDramaScriptResult executePhase1_ScriptPolish(ChatModel chatModel, ShortDramaIdeaBo bo) { + return executePhase1_ScriptPolish(chatModel, bo, null); + } + + /** + * 流式版本:StreamingChatModel 单次调用,边收边解析,JSON 完成后立即推流剧本正文 + */ + private ShortDramaScriptResult executePhase1_Streaming(StreamingChatModel streamingModel, + ShortDramaIdeaBo bo, SseEmitter emitter) { + String projectName = firstNotBlank(bo.getProjectName(), "短剧项目"); + String systemPrompt = PHASE1_COMBINED_SYSTEM; + String userPrompt = "用户期望项目名:" + projectName + "\n用户创意:" + sanitizeIdeaInput(bo.getIdea()); + List messages = List.of( + SystemMessage.from(systemPrompt), UserMessage.from(userPrompt)); + + StringBuilder buf = new StringBuilder(); + ShortDramaScriptResult[] result = {null}; + boolean[] scriptStreaming = {false}; + CompletableFuture streamDone = new CompletableFuture<>(); + + long t0 = System.currentTimeMillis(); + streamingModel.chat(messages, new StreamingChatResponseHandler() { + @Override + public void onPartialResponse(String text) { + buf.append(text); + if (!scriptStreaming[0]) { + // 检查 JSON 元信息是否已完整(由 ===SCRIPT=== 分隔符标记) + int delim = buf.indexOf(SCRIPT_DELIMITER); + if (delim >= 0) { + String jsonPart = buf.substring(0, delim).trim(); + String json = extractJson(jsonPart); + if (json != null) { + result[0] = parseJson(json, ShortDramaScriptResult.class); + } + scriptStreaming[0] = true; + log.info("Phase 1 流式 JSON 解析完成: elapsed={}ms ok={}", + System.currentTimeMillis() - t0, result[0] != null); + emit(emitter, "outline", "done", "大纲已生成,正在展开完整剧本..."); + // 分隔符后面的已有文字作为剧本开头推送 + String tail = buf.substring(delim + SCRIPT_DELIMITER.length()); + if (StrUtil.isNotBlank(tail.trim())) { + emitStream(emitter, "script", tail); + } + } + } else { + emitStream(emitter, "script", text); + } + } + @Override + public void onCompleteResponse(ChatResponse response) { + // 兜底:如果没找到分隔符,尝试从完整响应提取 JSON + if (!scriptStreaming[0] || result[0] == null) { + result[0] = parsePhase1Response(buf.toString()); + } + // 兜底:如果分隔符后有文字但没通过 onPartial 推完,补推 + if (result[0] != null) { + String fullText = buf.toString(); + int delim = fullText.indexOf(SCRIPT_DELIMITER); + String scriptText = delim >= 0 + ? fullText.substring(delim + SCRIPT_DELIMITER.length()).trim() + : extractScriptAfterJson(fullText); + if (StrUtil.isBlank(result[0].getScriptText()) && StrUtil.isNotBlank(scriptText)) { + result[0].setScriptText(StringUtils.strip(scriptText)); + } + } + emitStreamDone(emitter); + log.info("Phase 1 流式完成: totalElapsed={}ms scriptLen={}", + System.currentTimeMillis() - t0, + result[0] != null && result[0].getScriptText() != null ? result[0].getScriptText().length() : 0); + streamDone.complete(null); + } + @Override + public void onError(Throwable error) { + log.error("Phase 1 流式输出错误", error); + streamDone.completeExceptionally(error); + } + }); + + try { + streamDone.join(); + } catch (Exception e) { + throw new RuntimeException("剧本打磨失败:" + e.getMessage(), e); + } + if (result[0] == null || StrUtil.isBlank(result[0].getScriptText())) { + throw new RuntimeException("剧本打磨失败:LLM 返回格式异常,请重试"); + } + if (scriptNeedsExpansion(result[0])) { + emit(emitter, "script", "running", "检测到剧情场次不足,正在扩展完整故事线..."); + ChatModelVo fallbackModel = findChatModel(); + ChatModel fallbackChatModel = getChatService(fallbackModel).buildChatModel(fallbackModel); + result[0] = expandIncompleteScript(fallbackChatModel, bo, result[0]); + emit(emitter, "script", "done", "完整多场次剧本已生成"); + } + return result[0]; + } + + private ShortDramaScriptResult parsePhase1Response(String resp) { + if (StrUtil.isBlank(resp)) return null; + // 尝试分隔符切分 + int delim = resp.indexOf(SCRIPT_DELIMITER); + String jsonPart, scriptPart; + if (delim >= 0) { + jsonPart = resp.substring(0, delim); + scriptPart = resp.substring(delim + SCRIPT_DELIMITER.length()); + } else { + jsonPart = resp; + scriptPart = extractScriptAfterJson(resp); + } + String json = extractJson(jsonPart); + if (json == null) return null; + ShortDramaScriptResult result = parseJson(json, ShortDramaScriptResult.class); + if (result != null && StrUtil.isNotBlank(scriptPart)) { + result.setScriptText(StringUtils.strip(scriptPart)); + } + return result; + } + + /** 兜底:JSON 后面的非 JSON 文本当作剧本 */ + private static String extractScriptAfterJson(String text) { + int end = text.lastIndexOf('}'); + if (end >= 0 && end < text.length() - 1) { + String after = text.substring(end + 1).trim(); + return after.replaceFirst("^[\\s\\-\\n\\r=]+", ""); + } + return ""; + } + + // ==================== Phase 2: 资产分析(并发)==================== + + private void executePhase2_AssetAnalysis(ChatModel chatModel, Long projectId, ShortDramaScript script) { + executePhase2_AssetAnalysis(chatModel, null, projectId, script, null); + } + + private void executePhase2_AssetAnalysis(ChatModel chatModel, StreamingChatModel streamModel, + Long projectId, ShortDramaScript script, SseEmitter emitter) { + String scriptText = firstNotBlank(script.getScriptText(), script.getOutlineText(), ""); + if (StrUtil.isBlank(scriptText)) return; + + try { + List characters; + List locations; + + if (emitter != null && streamModel != null) { + // 流式模式:串行执行,用户能看到每个阶段的原始输出 + emit(emitter, "assets_chars", "running", "正在分析角色档案..."); + String charsResp = streamingChat(streamModel, chatModel, buildCharacterProfilePrompt(scriptText), emitter, "assets"); + characters = parseCharacterList(charsResp); + emit(emitter, "assets_chars", "done", "角色分析完成,提取 " + characters.size() + " 个角色"); + + emit(emitter, "assets_locs", "running", "正在分析场景站位..."); + StreamingChatModel locsStreamModel = buildStreamingChatModel(); + String locsResp = streamingChat(locsStreamModel, chatModel, buildLocationCreatePrompt(scriptText), emitter, "assets"); + locations = parseLocationList(locsResp); + emit(emitter, "assets_locs", "done", "场景分析完成,提取 " + locations.size() + " 个场景"); + } else { + // 非流式:保持原有并发模式,速度更快 + CompletableFuture> charsFuture = + CompletableFuture.supplyAsync(() -> { + String resp = chatModel.chat(buildCharacterProfilePrompt(scriptText)); + return parseCharacterList(resp); + }); + CompletableFuture> locsFuture = + CompletableFuture.supplyAsync(() -> { + String resp = chatModel.chat(buildLocationCreatePrompt(scriptText)); + return parseLocationList(resp); + }); + characters = charsFuture.join(); + locations = locsFuture.join(); + } + + characters = deduplicateCharacters(characters); + locations = deduplicateLocations(locations); + + // 2b: 并发为每个角色生成视觉描述(不论流式与否,均并发执行) + List> visualFutures = new ArrayList<>(); + for (AssetGeneratedCharacter c : characters) { + visualFutures.add(CompletableFuture.runAsync(() -> { + String visualDesc = ""; + try { + String vr = chatModel.chat(buildCharacterVisualPrompt(c, script.getTone())); + AssetCharacterVisualResult visResult = parseJson(extractJson(vr), AssetCharacterVisualResult.class); + if (visResult != null && visResult.getCharacters() != null) { + for (AssetCharVisual cv : visResult.getCharacters()) { + if (cv.getAppearances() != null) { + for (AssetAppearanceDesc ad : cv.getAppearances()) { + if (ad.getDescriptions() != null && !ad.getDescriptions().isEmpty()) { + visualDesc = ad.getDescriptions().get(0); + break; + } + } + } + if (StrUtil.isNotBlank(visualDesc)) break; + } + } + } catch (Exception e) { + log.warn("角色「{}」视觉描述生成失败: {}", c.getName(), e.getMessage()); + } + + synchronized (this) { + insertCharacter(projectId, c, visualDesc); + } + })); + } + CompletableFuture.allOf(visualFutures.toArray(new CompletableFuture[0])).join(); + + // 插入场景 + for (AssetGeneratedLocation loc : locations) { + ShortDramaLocation entity = new ShortDramaLocation(); + entity.setId(IdUtil.getSnowflakeNextId()); + entity.setProjectId(projectId); + entity.setName(firstNotBlank(loc.getName(), "未命名场景")); + entity.setSummary(loc.getSummary()); + entity.setHasCrowd(loc.getHasCrowd() != null ? loc.getHasCrowd() : false); + entity.setCrowdDescription(loc.getCrowdDescription()); + entity.setAvailableSlots(loc.getAvailableSlots() != null ? JsonUtils.toJsonString(loc.getAvailableSlots()) : null); + entity.setDescriptions(loc.getDescriptions() != null ? JsonUtils.toJsonString(loc.getDescriptions()) : null); + locationMapper.insert(entity); + } + } catch (Exception e) { + log.warn("Phase 2 资产分析失败: {}", e.getMessage()); + } + } + + private List deduplicateCharacters(List characters) { + LinkedHashMap unique = new LinkedHashMap<>(); + for (AssetGeneratedCharacter character : characters) { + String name = firstNotBlank(character.getName(), "未命名").trim(); + unique.putIfAbsent(name.toLowerCase(java.util.Locale.ROOT), character); + } + return new ArrayList<>(unique.values()); + } + + private List deduplicateLocations(List locations) { + LinkedHashMap unique = new LinkedHashMap<>(); + for (AssetGeneratedLocation location : locations) { + String name = firstNotBlank(location.getName(), "未命名场景").trim(); + unique.putIfAbsent(name.toLowerCase(java.util.Locale.ROOT), location); + } + return new ArrayList<>(unique.values()); + } + + private void insertCharacter(Long projectId, AssetGeneratedCharacter c, String visualDesc) { + ShortDramaCharacter entity = new ShortDramaCharacter(); + entity.setId(IdUtil.getSnowflakeNextId()); + entity.setProjectId(projectId); + entity.setName(firstNotBlank(c.getName(), "未命名")); + entity.setAliases(c.getAliases()); + entity.setIntroduction(c.getIntroduction()); + entity.setRoleLevel(firstNotBlank(c.getRoleLevel(), "B")); + entity.setGender(c.getGender()); + entity.setAgeRange(c.getAgeRange()); + entity.setPersonalityTags(c.getPersonalityTags()); + entity.setCostumeTier(c.getCostumeTier() != null ? c.getCostumeTier() : 2); + entity.setVisualDescription(firstNotBlank(visualDesc, c.getVisualKeywords())); + characterMapper.insert(entity); + + ShortDramaCharacterAppearance appearance = new ShortDramaCharacterAppearance(); + appearance.setId(IdUtil.getSnowflakeNextId()); + appearance.setCharacterId(entity.getId()); + appearance.setAppearanceIndex(0); + appearance.setChangeReason("初始形象"); + appearance.setDescription(entity.getVisualDescription()); + characterAppearanceMapper.insert(appearance); + } + + private static String buildCharacterProfilePrompt(String scriptText) { + return """ + 你是专业的"选角指导"。请基于提供的文本(小说、剧本或混合格式),分析并输出所有需要制作形象的角色档案信息。 + + 【你的职责】 + - 识别需要在画面中出现的角色 + - 根据剧情发展和角色身份判断每个角色的重要性层级 + - 分析角色的性格和背景 + - 输出结构化的角色档案(供后续视觉生成使用) + - 分析角色之间的关系、称呼映射,生成角色介绍(introduction) + + 【筛选规则】 + ✅ 必须提取:剧本人物行中列出的角色、有台词且参与剧情互动的角色、贯穿故事主线的核心人物、对剧情有实际推动作用的配角、在画面中需要出镜的角色 + ❌ 不提取:无名无特征的纯路人、仅被提及但从未出场的角色、没有台词也没有互动的背景人物 + + 【角色介绍 introduction 规则】 + 每个角色必须有 introduction 字段,包含: + 1. 叙述视角映射:如果是第一人称叙述,明确说明"我"对应此角色 + 2. 角色身份定位:描述角色在故事中的身份(主角/配角/反派等) + 3. 角色关系:与其他主要角色的关系 + 4. 称呼映射:其他角色对此角色的常用称呼 + + 示例:"故事主角,小说以第一人称「我」叙述,真名林墨。苏晴的丈夫,张三的女婿。被苏晴称呼为「老公」、「墨哥」,被下属称呼为「林总」。" + + 【角色重要性层级判断规则】 + S级(绝对主角):故事的核心视角人物,剧情围绕其展开。第一人称叙述中的"我"通常是S级 + A级(核心配角):与主角有大量互动的重要角色,男二号/女二号/主要反派等。对主线剧情有重大影响 + B级(重要配角):多次出场、有名有姓、推动某条支线剧情 + C级(次要角色):偶尔出场、戏份较少但有具体形象 + D级(群众演员):有短暂出镜需求的小角色 + + 【服装华丽度 costume_tier】 + 5级(皇室/顶奢级):皇室成员、顶级富豪,服装极致华丽,有精美的刺绣、镶嵌或定制剪裁 + 4级(贵族/精英级):贵族、企业家,服装精致考究,使用高档面料和精致细节 + 3级(专业/品质级):中产阶级、专业人士,服装得体有品,剪裁讲究 + 2级(日常/普通级):普通人、学生,服装简洁日常,款式普通但整洁 + 1级(朴素/统一级):平民、底层劳动者,服装朴素统一,基础款式 + + 【性格标签 personality_tags】 + 气质类:高冷、温柔、阳光、忧郁、神秘、妩媚、清冷、热情 + 性格类:腹黑、傲娇、毒舌、话痨、闷骚、直爽、圆滑、固执 + 态度类:自信、自卑、孤僻、合群、叛逆、顺从 + 用逗号分隔,至少2个,最多5个 + + 【视觉关键词 visual_keywords】 + 风格类:精英气质、街头潮流、学院风、复古优雅、运动活力、文艺气息、冷淡极简 + 特征类:病弱感、禁欲系、狼狗系、奶狗系、御姐范、萝莉感、大叔味 + 用逗号分隔 + + 【输出格式】 + 只返回JSON,禁止markdown标记或注释: + { + "new_characters": [ + { + "name": "角色名", + "aliases": "别名1,别名2", + "introduction": "角色介绍(身份、关系、称呼映射)", + "roleLevel": "S/A/B/C/D", + "gender": "男/女", + "ageRange": "约二十五岁", + "archetype": "角色原型(如霸道总裁)", + "personalityTags": "高冷,腹黑", + "costumeTier": 3, + "visualKeywords": "精英气质,禁欲系", + "suggestedColors": "深蓝,金色", + "primaryIdentifier": "眼角泪痣(仅S/A级需要)" + } + ] + } + ⚠️ JSON安全:严格遵守JSON标准格式。字符串值内的双引号必须转义为\"。对话引号统一使用「」代替英文双引号。 + + 剧本内容: + %s + """.formatted(scriptText); + } + + private static String buildCharacterVisualPrompt(AssetGeneratedCharacter character, String tone) { + String levelDesc = switch (firstNotBlank(character.getRoleLevel(), "B")) { + case "S" -> "180-220字,必须有极高的视觉辨识度和主角气质,五官精致,禁止用「普通」「平凡」「不起眼」"; + case "A" -> "150-180字,有明显的个人特色和记忆点,长相精致有吸引力"; + case "B" -> "120-150字,有基本的辨识特征"; + case "C" -> "80-120字,简洁但完整的形象描述"; + default -> "50-80字,基础形象即可"; + }; + return """ + 你是专业的"角色视觉设计师"。根据角色档案信息,生成详细的人物外貌描述(用于AI图片生成)。 + + 【视觉层级规范】描述长度要求:%s + + 【描述规范 - 必须按优先级包含以下内容】 + + 🎭 面部特征(最重要!必须详细): + - 脸型:瓜子脸、鹅蛋脸、方脸、长脸等具体脸型 + - 五官组合:眼睛、鼻子、嘴巴、眉毛的形状和特点 + - 眼睛:双眼皮/单眼皮、眼型、大小 + - 鼻子:高挺、小巧、笔直、精致等 + - 嘴唇:薄厚、形状(小巧、丰润) + - 眉毛:浓淡、形状(剑眉、柳叶眉) + - 独特记号:痣(位置)、雀斑、小疤痕等 + + 💇 发型描写(必须详细): + - 发色:乌黑、深棕、栗色、金棕等 + - 发长:齐耳短发、及肩、过肩、及腰 + - 发型:自然披散、高马尾、低马尾、丸子头、盘发、寸头、中分、偏分、背头 + - 发质:柔顺、自然卷、微卷、蓬松、服帖 + - 刘海:齐刘海、空气刘海、无刘海、中分刘海、侧分刘海、碎发刘海 + + 👤 体态:身形(修长、健硕、纤细、匀称)、身高感(高挑、娇小、适中) + + 👔 服装配饰:上衣、下装、鞋子(必填!)、配饰 + + 【禁止描写】皮肤颜色、眼睛颜色、唇色、表情/姿态/动作、背景/环境、情绪形容词、抽象气质、「可能」「或」等不确定描述 + + 【服装华丽度对照】 + 5级:刺绣、镶嵌、定制剪裁、稀有面料 + 4级:高档面料、精致细节、品质配饰 + 3级:得体剪裁、有设计感 + 2级:简洁日常款式 + 1级:基础款式、功能性为主 + + 【输出格式】只返回JSON: + { + "characters": [ + { + "name": "角色名", + "appearances": [ + { + "id": 0, + "descriptions": ["完整外貌描述"], + "change_reason": "初始形象" + } + ] + } + ] + } + ⚠️ JSON安全:严格遵守JSON标准格式。字符串值内的双引号必须转义为\"。对话引号统一使用「」代替英文双引号。 + + 角色名:%s + 性别:%s + 年龄段:%s + 性格标签:%s + 服装华丽度:%s级 + 视觉关键词:%s + 风格基调:%s + 辨识标志:%s + """.formatted(levelDesc, + character.getName(), + firstNotBlank(character.getGender(), "未指定"), + firstNotBlank(character.getAgeRange(), "未指定"), + firstNotBlank(character.getPersonalityTags(), ""), + character.getCostumeTier() != null ? character.getCostumeTier() : 2, + firstNotBlank(character.getVisualKeywords(), ""), + firstNotBlank(tone, "短剧"), + firstNotBlank(character.getPrimaryIdentifier(), "无")); + } + + private static String buildLocationCreatePrompt(String scriptText) { + return """ + 你是"场景资产建立师"。请基于文本筛选需要制作画面的场景,生成用于出图的资产JSON。 + + 【筛选规则】 + ✅ 必须提取:剧本场景头部中出现的地点、角色实际身处产生互动的场所、剧情主线发生的核心地点、多次出现或戏份较重的场景、有明确空间描写需要制作背景画面的地点 + ❌ 不提取:一次性路过仅提及但无剧情发生的地点、意境类比喻类修辞类描述、抽象空间或无法具象化的概念、纯过渡性场景 + + 【场景生成要求 - 全景空间版】 + 核心要求:生成宽广的空间全景,展示场景的完整面貌,而非局部特写! + - 镜头应该使用广角/远景视角,能看到整个空间的全貌 + - 展示空间的完整边界(墙壁、地面、天花板/天空) + - 严格按照原文的场景描述来描写,原文描述的场景是最优先级 + + 每条描述必须包含: + 1. 开头以「场景名」标注空间属性 + 2. 宽广空间感(最重要):室内能看到2-3面墙壁、地板、部分天花板;室外能看到开阔视野、远处地平线 + 3. 空间定位与规模、空间层次(前景/中景/背景)、物体布局(使用位置词:左侧/右侧/中央/角落/靠窗/远处) + 4. 光线方向:光从哪个方向照入 + 5. 可落位空间:必须说明哪些区域留有可供人物站立的空白空间,至少2-3个后续可作为人物落位锚点的关键物体或区域 + + 每个场景生成3条差异化中文环境描述(100-150字),2-6个available_slots + + ⚠️ 场景图禁止出现任何有名有姓的角色!场景图是纯粹的背景板。无名的模糊背景群众(如"宾客""路人")可以出现。 + + available_slots每条必须是完整的位置描述短语,如「皇宫正中龙椅前方台阶下的位置」「教室后排靠窗那组课桌外侧的位置」 + + 命名规则:"地点_时间/状态"如"客厅_白天" + + 【输出格式】只返回JSON: + { + "locations": [ + { + "name": "场景_时间", + "summary": "场景简要说明", + "hasCrowd": true/false, + "crowdDescription": "人群类型描述", + "availableSlots": ["位置1完整描述", "位置2完整描述"], + "descriptions": ["「场景名」描述1", "「场景名」描述2", "「场景名」描述3"] + } + ] + } + ⚠️ JSON安全:严格遵守JSON标准格式。字符串值内的双引号必须转义为\"。对话引号统一使用「」代替英文双引号。 + + 剧本内容: + %s + """.formatted(scriptText); + } + + // ==================== Phase 3: 分镜规划 ==================== + + private List executePhase3_StoryboardPlan(ChatModel chatModel, ShortDramaScript script, Long projectId) { + return executePhase3_StoryboardPlan(chatModel, null, script, projectId, null); + } + + private List executePhase3_StoryboardPlan(ChatModel chatModel, ShortDramaScript script, Long projectId, SseEmitter emitter) { + return executePhase3_StoryboardPlan(chatModel, null, script, projectId, emitter); + } + + private List executePhase3_StoryboardPlan(ChatModel chatModel, StreamingChatModel streamingModel, + ShortDramaScript script, Long projectId, SseEmitter emitter) { + try { + String text = firstNotBlank(script.getScriptText(), script.getOutlineText(), ""); + if (StrUtil.isBlank(text)) return List.of(); + + String charsLib = buildCharactersLibString(projectId); + String locsLib = buildLocationsLibString(projectId); + String charsIntro = buildCharactersIntroString(projectId); + String charsAppearanceList = buildCharactersAppearanceListString(projectId); + String charsFullDesc = buildCharactersFullDescString(projectId); + + String prompt = buildStoryboardPlanPrompt(text, charsLib, locsLib, charsIntro, charsAppearanceList, charsFullDesc, + artStyleSuffix(projectId), projectAspectRatio(projectId)); + String response; + if (emitter != null) { + StreamingChatModel activeStreamingModel = streamingModel != null ? streamingModel : buildStreamingChatModel(); + response = streamingChat(activeStreamingModel, chatModel, prompt, emitter, "storyboard_plan"); + } else { + response = chatModel.chat(prompt); + } + List panels = parsePanelList(response); + if (panels != null && !panels.isEmpty()) { + for (int i = 0; i < panels.size(); i++) { + panels.get(i).setPanelNumber(i + 1); + } + return panels; + } + } catch (Exception e) { + log.warn("Phase 3 分镜规划失败: {}", e.getMessage()); + } + return List.of(); + } + + private static String buildStoryboardPlanPrompt(String text, String charsLib, String locsLib, + String charsIntro, String charsAppearanceList, String charsFullDesc, + String artStyle, String aspectRatio) { + return """ + 你是专业的分镜规划师。请根据剧本内容将故事拆解成连续的分镜头。 + + 【三级规划流程 - 最高优先级】 + 必须先在内部完成以下三级规划,再输出镜头JSON;不要输出分析过程: + 1. 分场 scene:按地点变化、时间跳变或戏剧单元收束切场,同一场是同一时空下的连续戏 + 2. 切片段 segment:每场沿叙事顺序在情绪转折、动作段落、说话人切换或信息揭示处切分;每片段总时长不得超过15秒 + 3. 拆镜头 panel:每个片段内部拆成连续可拍镜头,每镜只承担一个剧情职责 + + 每个片段必须形成小型因果闭环:bridge_in触发 → segment_goal → 连续动作/对话 → segment_result → bridge_out。 + 相邻片段必须通过动作、情绪、视线或声音中的至少一种桥梁衔接,禁止在片段边界冻结后跳转。 + + 【镜头数量控制 - 最高优先级】 + - 目标比例:每15-25个有效剧情字符≈1个镜头,根据动作和台词密度调整 + - 硬上限:最多40个镜头,超过时优先合并重复信息,不得删除因果中间态 + - 普通镜头5-8秒;无台词镜头也应优先保持5-8秒并包含持续可见变化 + - 单镜超过8秒只允许用于不可拆的连续台词、连续动作或史诗建立镜头,并必须包含前段→中段→后段的持续变化;任何单镜不得超过15秒 + - 每个片段内所有镜头duration之和必须≤15秒,超过必须拆成新片段 + - 时长必须由实际动作、对白和运镜容量决定,禁止按场景类型机械给长时长 + - 原文只支持1-3秒内容时,应与相邻因果动作合并,禁止靠环境空转填充成长镜头 + - 对话密集场景:多句对话可合并到一个镜头 + + 【分镜原则】 + 1. 每个场景开始→1个建立镜头 + 2. 每个关键动作→1个镜头 + 3. 每段对话→1-2个镜头(说话者+必要时听者反应) + 4. 情绪高潮点→1个特写镜头 + + 【每个分镜包含】 + - panel_number: 全剧镜头序号 + - scene_number: 场次序号;地点/时间变化时递增 + - segment_number: 当前场内片段序号;每片段总时长≤15秒 + - segment_goal: 当前片段要完成的唯一剧情任务 + - segment_result: 片段结束时完成的剧情变化 + - bridge_in: 本片段如何承接上一片段,首片段写开场建立 + - bridge_out: 留给下一片段承接的动作、情绪、视线或声音 + - description: 画面描述(可视化元素,禁止主观情绪词) + - characters: [{name, appearance, slot}],name必须与资产库一致 + - location: 从场景资产库选择,名字完全一致 + - scene_type: daily/emotion/action/epic/suspense + - source_text: 对应原文片段(必填) + - start_state: 当前镜头第一帧状态,必须继承上一镜头end_state(场景切换除外) + - end_state: 当前镜头最后一帧状态,明确人物位置、朝向、姿态、道具和动作结果 + - continuity_action: 与下一镜头衔接的未完成动作、视线方向或运动趋势 + - spatial_anchor: 场景空间锚点,明确左右、前后、远近和人物相对位置 + - present_characters: 当前场景仍然在场的全部人物;未明确离场不得消失 + - beat_type: 当前镜头唯一的剧情职责,只能是setup/trigger/reaction/decision/action/result/transition之一 + - narrative_cause: 当前镜头发生的直接原因,必须来自上一镜头story_result或剧本中的明确触发 + - character_goal: 当前焦点角色在这一刻想达成的具体目标 + - story_action: 为实现目标采取的可见行动或说出的关键台词 + - story_result: 当前行动造成的新信息、新阻碍、位置变化或关系变化,镜头结束前必须发生 + - next_hook: story_result中必须在下一镜头得到回应的问题、动作或后果 + - duration: 镜头时长(秒),根据scene_type差异化分配: + · daily(日常对话):5-8秒 + · emotion(情绪高潮):6-10秒 + · action(动作冲突):5-8秒 + · epic(史诗大景):8-15秒 + · suspense(悬疑紧张):6-10秒 + + 【片段间过渡设计 - 最高优先级】 + 1. 连续动作:前片段bridge_out写动作起始态,后片段bridge_in必须从动作进行时或完成时开始 + 2. 情绪延续:前片段结尾用反应、眼神、微表情或肢体细节铺垫,后片段首镜强化或反转 + 3. 视线匹配:前片段角色看向某物,后片段首镜展示该物或其造成的结果 + 4. 声音桥接:前片段末尾的台词关键词或物理声源,可由后片段首镜画面回应 + 5. 场景切换:必须由出发、开门、转身离场、时间标记、声音先入或结果揭示触发,禁止无因硬切 + + 【剧情因果链 - 最高优先级】 + 在输出JSON前,先在内部按剧本原文顺序建立完整因果主线,但不要输出分析过程。 + 1. 每个镜头只能承担一个主要剧情职责:建立、触发、反应、决定、行动、结果或转场 + 2. 镜头N必须产生story_result;镜头N+1的narrative_cause必须直接承接该story_result或next_hook + 3. 禁止“因为剧本接下来这样写”式跳跃。角色改变位置、情绪、目标、关系或道具状态时,必须先出现触发和过渡动作 + 4. 若状态从A跳到C,必须增加展示B的镜头,例如站立→坐着必须展示坐下,平静→愤怒必须展示触发与表情变化 + 5. 对话不能只是轮流说话:每句关键台词必须改变对方认知、决定或行动,并在下一镜展示反应 + 6. 场景切换必须有剧情原因和转场钩子,例如人物出发、时间推进、视线落向目标或结果揭示 + 7. source_text必须严格按原文顺序覆盖,不得将后文结果提前,也不得遗漏导致因果断裂的关键动作 + 8. 禁止连续镜头重复同一信息;每一镜结束时剧情状态必须相对开头发生可说明的变化 + 9. 新角色、新道具、新能力、新地点首次出现必须有建立或触发,不得凭空加入 + 10. 最终自检整条链:删除任一镜头后若不影响理解,说明该镜头无剧情作用,应合并或重写 + + 【叙事连续性与空间锚定 - 最高优先级】 + 1. 同一location内,镜头N+1的start_state必须逐项承接镜头N的end_state,不得重置人物姿态或位置 + 2. 角色进入场景后,在明确离场、切为拍不到该角色的特写/反打、或切换场景前,必须持续存在于present_characters + 3. 连续动作必须拆成“动作开始→动作过程→动作结果”,下一镜头从上一镜头尚未完成的动作或结果继续 + 4. 保持180度轴线:同一对话或对峙中,人物左右关系、面对方向不得无理由反转 + 5. 道具归属、手持状态、服装形象、伤势、光线方向和时间必须连续 + 6. 每个镜头生成前自检:人物从哪里来、现在在哪里、面向谁、正在做什么、镜头结束后停在哪里 + 7. 场景切换时start_state明确写“新场景建立”,不得伪装成连续动作 + + 【全局视觉约束 - 最高优先级】 + - 项目视觉风格:%s。所有description必须明确遵守该风格,禁止混入真人写实、摄影棚实拍等冲突表达 + - 项目画面比例:%s。构图和角色站位必须适应该画幅,避免主体被裁切 + + 【输出格式】 + 只返回JSON数组: +[{"panel_number":1,"scene_number":1,"segment_number":1,"segment_goal":"张三进入办公室并打断李四","segment_result":"李四注意力转向张三","bridge_in":"开场建立办公室与人物位置","bridge_out":"张三的话音落下,李四抬头形成反应钩子","description":"...","characters":[{"name":"张三","appearance":"初始形象","slot":"门口"}],"location":"办公室_白天","scene_type":"daily","source_text":"...","start_state":"张三位于门外右侧,面向办公室","end_state":"张三推门后停在门内右侧,身体朝向李四","continuity_action":"张三保持推门手势并准备开口","spatial_anchor":"张三画面右侧,李四画面左侧办公桌后","present_characters":["张三","李四"],"beat_type":"trigger","narrative_cause":"张三收到必须立即开会的消息","character_goal":"让李四停止工作并听取通知","story_action":"张三推门进入并开口宣布开会","story_result":"李四被打断并抬头看向张三","next_hook":"下一镜展示李四对通知的反应","duration":8}] + ⚠️ 严格JSON,不得输出markdown。 + + 角色资产库:%s + 场景资产库:%s + 角色介绍:%s + 角色形象列表:%s + 角色完整描述:%s + + 剧本内容: + %s + """.formatted(artStyle, aspectRatio, charsLib, locsLib, charsIntro, charsAppearanceList, charsFullDesc, text); + } + + // ==================== Phase 4: 摄影规则 ==================== + + private List executePhase4_PhotographyRules(ChatModel chatModel, List panels, Long projectId) { + return executePhase4_PhotographyRules(chatModel, panels, projectId, null); + } + + private List executePhase4_PhotographyRules(ChatModel chatModel, List panels, Long projectId, SseEmitter emitter) { + try { + String panelsJson = JsonUtils.toJsonString(panels); + String locsDesc = buildLocationsDescString(projectId); + String charsInfo = buildCharactersInfoString(projectId); + String prompt = buildCinematographerPrompt(panelsJson, panels.size(), locsDesc, charsInfo); + String response; + if (emitter != null) { + response = streamingChat(buildStreamingChatModel(), chatModel, prompt, emitter, "photography"); + } else { + response = chatModel.chat(prompt); + } + // 用 JsonNode 直接保存原始 JSON,避免 POJO 反序列化丢失字段 + JsonNode array = parseJsonNode(extractJson(response)); + if (array != null && array.isArray()) { + List rules = new ArrayList<>(); + int matched = 0; + for (JsonNode node : array) { + if (!node.isObject()) continue; + rules.add(node); + int pn = node.path("panel_number").asInt(-1); + if (pn <= 0) continue; + int idx = pn - 1; + if (idx >= 0 && idx < panels.size()) { + matched++; + } + } + if (matched == 0 && emitter != null) { + emit(emitter, "photography", "error", "摄影规则JSON解析成功但未匹配到任何镜头"); + } else if (matched > 0 && emitter != null) { + emit(emitter, "photography", "done", "摄影规则设计完成(" + matched + "/" + panels.size() + ")"); + } + return rules; + } else if (emitter != null) { + emit(emitter, "photography", "error", "摄影规则生成失败:LLM返回格式异常"); + } + } catch (Exception e) { + log.warn("Phase 4 摄影规则生成失败: {}", e.getMessage()); + if (emitter != null) emit(emitter, "photography", "error", "摄影规则生成异常:" + e.getMessage()); + } + return List.of(); + } + + private static String buildCinematographerPrompt(String panelsJson, int panelCount, String locsDesc, String charsInfo) { + return """ + 你是一位经验丰富的电影摄影指导(Director of Photography)。你的任务是为一组分镜中的每个镜头分别设计摄影规则。 + + 【核心职责】 + 分析整组分镜后,为每个镜头单独设计以下视觉要素: + 1. 灯光设置 - 光源方向和质感 + 2. 角色位置 - 画面中的具体位置 + 3. 景深设置 - 根据镜头类型确定景深 + 4. 色调风格 - 整体色彩氛围 + + 【景深参考】 + 全景/远景:深景深(T8.0),清晰展现空间 + 中景:中等景深(T4.0) + 近景:浅景深(T2.8),轻微背景虚化 + 特写:极浅景深(T1.8),强烈背景虚化 + 越肩镜头:浅景深,前景肩膀虚化 + + 【对话镜头景深规则 - 口型同步要求】 + 任何角色说话的镜头,如果出现多张脸,必须使用浅景深或极浅景深(T2.8或更小) + 说话者脸部必须清晰聚焦,背景中的其他角色必须虚化 + + 【输出格式】 + 返回JSON数组,每个元素对应一个镜头的摄影规则。数组长度必须=%d。 + + { + "panel_number": 1, + "scene_number": 1, + "segment_number": 1, + "segment_goal": "当前片段唯一剧情任务", + "segment_result": "当前片段结束时的剧情变化", + "bridge_in": "承接上一片段的桥梁", + "bridge_out": "引向下一片段的桥梁", + "scene_summary": "场景描述", + "lighting": {"direction": "主光从画面右侧窗户照入", "quality": "柔和的自然光,暖色调"}, + "characters": [{"name": "角色名", "screen_position": "画面左侧", "posture": "站立", "facing": "面向右侧"}], + "depth_of_field": "深景深(T8.0),清晰展现宫殿空间", + "color_tone": "暖色调,温馨氛围" + } + ⚠️ JSON安全:严格遵守JSON标准格式。字符串值内的双引号必须转义为\"。对话引号统一使用「」代替英文双引号。使用相对方向(画面左侧/右侧),禁止使用东南西北。 + + 分镜数据(共%d个镜头): + %s + + 场景描述: + %s + + 角色信息: + %s + """.formatted(panelCount, panelCount, panelsJson, locsDesc, charsInfo); + } + + // ==================== Phase 5: 表演指导 ==================== + + private List executePhase5_ActingDirections(ChatModel chatModel, List panels, Long projectId) { + return executePhase5_ActingDirections(chatModel, panels, projectId, null); + } + + private List executePhase5_ActingDirections(ChatModel chatModel, List panels, Long projectId, SseEmitter emitter) { + try { + String panelsJson = JsonUtils.toJsonString(panels); + String charsInfo = buildCharactersInfoString(projectId); + String prompt = buildActingDirectionPrompt(panelsJson, panels.size(), charsInfo); + String response; + if (emitter != null) { + response = streamingChat(buildStreamingChatModel(), chatModel, prompt, emitter, "acting"); + } else { + response = chatModel.chat(prompt); + } + List results = parseJsonArray(extractJson(response), ActingDirectionResult.class); + if (results != null) { + int matched = 0; + for (ActingDirectionResult r : results) { + if (r.getPanelNumber() == null) continue; + int idx = r.getPanelNumber() - 1; + if (idx >= 0 && idx < panels.size()) { + matched++; + } + } + if (matched == 0 && emitter != null) { + emit(emitter, "acting", "error", "表演指导JSON解析成功但未匹配到任何镜头"); + } else if (matched > 0 && emitter != null) { + emit(emitter, "acting", "done", "表演指导编写完成(" + matched + "/" + panels.size() + ")"); + } + return results; + } else if (emitter != null) { + emit(emitter, "acting", "error", "表演指导生成失败:LLM返回格式异常"); + } + } catch (Exception e) { + log.warn("Phase 5 表演指导生成失败: {}", e.getMessage()); + if (emitter != null) emit(emitter, "acting", "error", "表演指导生成异常:" + e.getMessage()); + } + return List.of(); + } + + private static String buildActingDirectionPrompt(String panelsJson, int panelCount, String charsInfo) { + return """ + 你是一位经验丰富的表演指导(Acting Director)。你的任务是为一组分镜中的每个镜头设计角色的表演细节。 + + 【核心职责】 + 分析整组分镜后,为每个镜头中的角色用一句话描述完整的表演指令,包含: + - 情绪状态与强度 + - 面部表情细节 + - 肢体语言与姿态 + - 微动作与视线 + + 【表演风格匹配 scene_type】 + daily(日常):自然松弛,微表情为主,动作幅度小 + emotion(情感):细腻层次,眼神戏份重,情绪渐进 + action(动作):爆发力强,动作干脆,表情夸张 + epic(史诗):庄重仪式感,姿态端正,动作缓慢有力 + suspense(悬疑):紧绷警觉,肢体僵硬,眼神游移 + + 【表演描述词库】 + 表情:眼眶泛红、眉头紧锁、嘴角上扬、目光闪躲、瞳孔收缩、嘴唇颤抖、咬紧牙关 + 肢体:握紧拳头、身体前倾、双手交握、肩膀耸起、转身背对、后退一步 + 微动作:轻轻眨眼、咽口水、深呼吸、手指轻颤、舔嘴唇、胸口起伏 + + 【禁止规则】 + 禁止抽象情绪词:悲伤、愤怒、紧张→改用可见表现 + 禁止身份称呼:母亲、父亲→改用角色名 + + 【输出格式】 + 返回JSON数组,每个镜头一个对象。数组长度必须=%d。 + + { + "panel_number": 1, + "characters": [ + {"name": "角色名", "acting": "嘴角微扬眼神柔和地看向对方,身体微微前倾,双手自然垂放,轻轻眨眼"} + ] + } + ⚠️ JSON安全:严格遵守JSON标准格式。字符串值内的双引号必须转义为\"。对话引号统一使用「」代替英文双引号。 + + 分镜数据(共%d个镜头): + %s + + 角色信息: + %s + """.formatted(panelCount, panelCount, panelsJson, charsInfo); + } + + /** + * 按 panel_number 合并细化分镜、摄影规则和表演指导。 + * 与生成阶段解耦,避免并发任务直接修改同一组分镜对象。 + */ + static List mergePanelsWithRules(List finalPanels, + List photographyRules, + List actingDirections) { + if (finalPanels == null) { + throw new IllegalArgumentException("分镜合并失败:细化分镜不能为空"); + } + validatePhotographyRules(finalPanels, photographyRules); + validateActingDirections(finalPanels, actingDirections); + applyPhotographyRules(finalPanels, photographyRules); + applyActingDirections(finalPanels, actingDirections); + return finalPanels; + } + + private static List buildLocalPhotographyRules(List panels) { + List rules = new ArrayList<>(); + for (StoryboardPanelData panel : panels) { + ObjectNode rule = JsonNodeFactory.instance.objectNode(); + rule.put("panel_number", panel.getPanelNumber()); + rule.put("scene_summary", firstNotBlank(panel.getDescription(), panel.getSourceText(), "")); + + ObjectNode lighting = rule.putObject("lighting"); + lighting.put("direction", "根据场景主光方向保持连续"); + lighting.put("quality", switch (firstNotBlank(panel.getSceneType(), "daily")) { + case "suspense" -> "低调硬光,保留阴影层次"; + case "emotion" -> "柔和侧光,突出面部情绪"; + case "action" -> "高反差光线,强化动作轮廓"; + case "epic" -> "大范围自然光,突出空间规模"; + default -> "自然柔光,肤色与环境协调"; + }); + + ArrayNode characters = rule.putArray("characters"); + if (panel.getCharacters() != null) { + for (CharacterRef ref : panel.getCharacters()) { + ObjectNode character = characters.addObject(); + character.put("name", firstNotBlank(ref.getName(), "角色")); + character.put("screen_position", firstNotBlank(ref.getSlot(), "画面主体区域")); + character.put("posture", "保持符合当前动作的自然姿态"); + character.put("facing", "面向动作目标或对话对象"); + } + } + + String sceneType = firstNotBlank(panel.getSceneType(), "daily"); + rule.put("depth_of_field", switch (sceneType) { + case "epic" -> "深景深,清晰展现环境规模"; + case "emotion" -> "浅景深,突出角色表情"; + case "suspense" -> "中浅景深,保留环境压迫感"; + default -> "中等景深,兼顾角色与环境"; + }); + rule.put("color_tone", switch (sceneType) { + case "suspense" -> "冷暗色调"; + case "emotion" -> "克制柔和色调"; + case "action" -> "高对比高饱和色调"; + case "epic" -> "宏大通透色调"; + default -> "自然统一色调"; + }); + rules.add(rule); + } + return rules; + } + + private static List buildLocalActingDirections(List panels) { + List directions = new ArrayList<>(); + for (StoryboardPanelData panel : panels) { + ActingDirectionResult result = new ActingDirectionResult(); + result.setPanelNumber(panel.getPanelNumber()); + ArrayNode characters = JsonNodeFactory.instance.arrayNode(); + if (panel.getCharacters() != null) { + for (CharacterRef ref : panel.getCharacters()) { + ObjectNode character = characters.addObject(); + character.put("name", firstNotBlank(ref.getName(), "角色")); + character.put("acting", localActingText(panel.getSceneType(), panel.getDuration())); + } + } + result.setCharacters(characters); + directions.add(result); + } + return directions; + } + + private static String localActingText(String sceneType, Integer duration) { + String performance = switch (firstNotBlank(sceneType, "daily")) { + case "emotion" -> "表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪"; + case "action" -> "动作预备清晰,发力过程连贯,结束后保留重心与呼吸反馈"; + case "epic" -> "动作沉稳有力量,视线跟随环境或对手变化,保持画面张力"; + case "suspense" -> "控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感"; + default -> "保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态"; + }; + if (duration != null && duration >= 8) { + performance += ",按前段、中段、后段完成三个连续表演节拍"; + } + return performance; + } + + private static void mergePhotographyRules(List panels, List photographyRules) { + validatePhotographyRules(panels, photographyRules); + applyPhotographyRules(panels, photographyRules); + } + + private static void mergeActingDirections(List panels, + List actingDirections) { + validateActingDirections(panels, actingDirections); + applyActingDirections(panels, actingDirections); + } + + private static void validatePhotographyRules(List panels, List photographyRules) { + if (panels == null) { + throw new IllegalArgumentException("分镜合并失败:分镜不能为空"); + } + for (int index = 0; index < panels.size(); index++) { + Integer panelNumber = panels.get(index).getPanelNumber(); + if (findPhotographyRule(photographyRules, panelNumber) == null) { + throw new IllegalStateException("分镜合并失败:镜头 " + panelNumber + + " 缺少摄影规则(index=" + index + ")"); + } + } + } + + private static void validateActingDirections(List panels, + List actingDirections) { + if (panels == null) { + throw new IllegalArgumentException("分镜合并失败:分镜不能为空"); + } + for (int index = 0; index < panels.size(); index++) { + Integer panelNumber = panels.get(index).getPanelNumber(); + if (findActingDirection(actingDirections, panelNumber) == null) { + throw new IllegalStateException("分镜合并失败:镜头 " + panelNumber + + " 缺少表演指导(index=" + index + ")"); + } + } + } + + private static void applyPhotographyRules(List panels, List photographyRules) { + for (StoryboardPanelData panel : panels) { + JsonNode rule = findPhotographyRule(photographyRules, panel.getPanelNumber()); + panel.setPhotographyRules(rule.toString()); + } + } + + private static void applyActingDirections(List panels, + List actingDirections) { + for (StoryboardPanelData panel : panels) { + ActingDirectionResult acting = findActingDirection(actingDirections, panel.getPanelNumber()); + JsonNode characters = acting.getCharacters(); + panel.setActingNotes(characters == null ? null : characters.toString()); + } + } + + private static JsonNode findPhotographyRule(List photographyRules, Integer panelNumber) { + if (photographyRules == null || panelNumber == null) return null; + for (JsonNode rule : photographyRules) { + if (rule != null && rule.path("panel_number").asInt(Integer.MIN_VALUE) == panelNumber) { + return rule; + } + } + return null; + } + + private static ActingDirectionResult findActingDirection(List actingDirections, + Integer panelNumber) { + if (actingDirections == null || panelNumber == null) return null; + for (ActingDirectionResult acting : actingDirections) { + if (acting != null && panelNumber.equals(acting.getPanelNumber())) { + return acting; + } + } + return null; + } + + // ==================== Phase 6: 分镜细化 ==================== + + private void executePhase6_StoryboardDetail(ChatModel chatModel, List panels, Long projectId) { + executePhase6_StoryboardDetail(chatModel, null, panels, projectId, null); + } + + private void executePhase6_StoryboardDetail(ChatModel chatModel, List panels, Long projectId, SseEmitter emitter) { + executePhase6_StoryboardDetail(chatModel, null, panels, projectId, emitter); + } + + private void executePhase6_StoryboardDetail(ChatModel chatModel, StreamingChatModel streamingModel, + List panels, Long projectId, SseEmitter emitter) { + try { + String panelsJson = JsonUtils.toJsonString(panels); + String charsAgeGender = buildCharactersAgeGenderString(projectId); + String locsDesc = buildLocationsDescString(projectId); + String prompt = buildStoryboardDetailPrompt(panelsJson, charsAgeGender, locsDesc, + artStyleSuffix(projectId), projectAspectRatio(projectId)); + String response; + if (emitter != null) { + StreamingChatModel activeStreamingModel = streamingModel != null ? streamingModel : buildStreamingChatModel(); + response = streamingChat(activeStreamingModel, chatModel, prompt, emitter, "storyboard_detail"); + } else { + response = chatModel.chat(prompt); + } + List results = parseJsonArray(extractJson(response), StoryboardDetailResult.class); + if (results != null) { + int matched = 0; + for (StoryboardDetailResult r : results) { + if (r.getPanelNumber() == null) continue; + int idx = r.getPanelNumber() - 1; + if (idx >= 0 && idx < panels.size()) { + StoryboardPanelData panel = panels.get(idx); + panel.setShotType(r.getShotType()); + panel.setCameraMove(r.getCameraMove()); + panel.setVideoPrompt(r.getVideoPrompt()); + panel.setImagePrompt(r.getImagePrompt()); + panel.setSceneTitle(r.getSceneTitle()); + panel.setStartState(firstNotBlank(r.getStartState(), panel.getStartState())); + panel.setEndState(firstNotBlank(r.getEndState(), panel.getEndState())); + panel.setContinuityAction(firstNotBlank(r.getContinuityAction(), panel.getContinuityAction())); + panel.setSpatialAnchor(firstNotBlank(r.getSpatialAnchor(), panel.getSpatialAnchor())); + if (r.getPresentCharacters() != null && !r.getPresentCharacters().isEmpty()) { + panel.setPresentCharacters(r.getPresentCharacters()); + } + panel.setSceneNumber(r.getSceneNumber() != null ? r.getSceneNumber() : panel.getSceneNumber()); + panel.setSegmentNumber(r.getSegmentNumber() != null ? r.getSegmentNumber() : panel.getSegmentNumber()); + panel.setSegmentGoal(firstNotBlank(r.getSegmentGoal(), panel.getSegmentGoal())); + panel.setSegmentResult(firstNotBlank(r.getSegmentResult(), panel.getSegmentResult())); + panel.setBridgeIn(firstNotBlank(r.getBridgeIn(), panel.getBridgeIn())); + panel.setBridgeOut(firstNotBlank(r.getBridgeOut(), panel.getBridgeOut())); + panel.setBeatType(firstNotBlank(r.getBeatType(), panel.getBeatType())); + panel.setNarrativeCause(firstNotBlank(r.getNarrativeCause(), panel.getNarrativeCause())); + panel.setCharacterGoal(firstNotBlank(r.getCharacterGoal(), panel.getCharacterGoal())); + panel.setStoryAction(firstNotBlank(r.getStoryAction(), panel.getStoryAction())); + panel.setStoryResult(firstNotBlank(r.getStoryResult(), panel.getStoryResult())); + panel.setNextHook(firstNotBlank(r.getNextHook(), panel.getNextHook())); + if (StrUtil.isNotBlank(r.getDescription())) { + panel.setDescription(r.getDescription()); + } + matched++; + } + } + if (matched == 0 && emitter != null) { + emit(emitter, "storyboard_detail", "error", "分镜细化JSON解析成功但未匹配到任何镜头"); + } else if (matched > 0 && emitter != null) { + emit(emitter, "storyboard_detail", "done", "分镜细化完成(" + matched + "/" + panels.size() + ")"); + } + } else if (emitter != null) { + emit(emitter, "storyboard_detail", "error", "分镜细化失败:LLM返回格式异常"); + } + } catch (Exception e) { + log.warn("Phase 6 分镜细化失败: {}", e.getMessage()); + if (emitter != null) emit(emitter, "storyboard_detail", "error", "分镜细化异常:" + e.getMessage()); + } + } + + private static String buildStoryboardDetailPrompt(String panelsJson, String charsAgeGender, String locsDesc, + String artStyle, String aspectRatio) { + return """ + 你是顶级电影分镜师。根据分镜规划和场景类型,设计镜头语言和视频提示词。 + + 【你的职责】 + 为每个分镜设计景别、视角、镜头运动,撰写video_prompt(用年龄段+性别替代角色名),撰写image_prompt。 + + 【镜头语言库】 + 景别:大远景/远景/全景/中景/近景/特写/极端特写 + 视角:平视/仰拍/俯拍/越肩/荷兰角/主观视角 + 镜头运动:固定/缓推/缓拉/跟随/急推/急拉/环绕/升起/俯冲/手持晃动 + + 【根据scene_type选择镜头风格】 + daily:中景、近景为主,平视+越肩,优先使用缓推/缓拉/轻微跟随 + emotion:近景、特写捕捉情绪,缓慢推进、环绕运镜 + action:景别快速切换,特写+全景交替,仰拍/俯拍/荷兰角,急推急拉/跟随/手持晃动 + epic:必须有大远景建立规模,俯拍/升起/俯冲 + suspense:主观视角/荷兰角,缓慢推进制造压迫 + + 【全局视觉约束 - 最高优先级】 + - 视觉风格:%s。description、video_prompt、image_prompt都必须显式体现该风格 + - 严禁使用与目标风格冲突的表达;动漫/漫画风格不得写成真人、实拍、照片级人物 + - 画面比例:%s。镜头构图、主体位置和留白必须适应该比例 + + 【三级结构审核与修正 - 最高优先级】 + 在设计镜头语言前,先审核输入的scene_number/segment_number分组并直接在输出中修正字段: + - 每个segment的duration总和必须≤15秒;超过时在合理剧情节点增加segment_number + - 普通单镜5-8秒;超过8秒只允许用于不可拆的连续台词、连续动作或史诗建立镜头;任何镜头≤15秒 + - 同一segment内必须围绕同一个segment_goal,结尾落实segment_result + - 相邻segment的bridge_out与bridge_in必须能直接连读;不匹配时重写桥梁字段和当前镜头描述 + - 检查A→C跳跃:位置、姿态、情绪、信息或道具状态缺少B过程时,必须把B过程融入相邻镜头描述 + - 不得改变镜头顺序或剧本事件顺序,不得新增剧本外剧情 + + 【剧情节拍落实 - 最高优先级】 + - 输入中的beat_type、narrative_cause、character_goal、story_action、story_result、next_hook必须完整保留 + - description和video_prompt必须实际拍出story_action以及story_result,不能只拍角色站立或环境氛围 + - 镜头开头先回应narrative_cause,镜头结尾必须出现story_result,并留下next_hook + - 如果story_result无法在当前时长内可视化,必须简化动作而不是丢掉结果 + - 不得擅自加入输入因果链之外的新事件、台词、能力或角色行为 + + 【前后镜头连续性 - 最高优先级】 + - 输入已经包含start_state、end_state、continuity_action、spatial_anchor、present_characters,必须完整保留并落实到description/video_prompt/image_prompt + - 当前镜头开头必须明确从start_state开始,结尾必须准确停在end_state + - video_prompt必须按“继承上一镜状态→执行当前动作→停在可供下一镜承接的状态”书写 + - 同场景相邻镜头不得改变人物左右关系、朝向、道具所在手、站坐姿态、伤势和光线方向 + - 反打/特写可以不显示其他人,但不得暗示其他人消失或换位 + - 如果输入连续性字段冲突,优先修正当前镜头以承接上一镜头,不得各镜头独立创作 + + 【时长匹配规则 - 最高优先级】 + - 输入中的duration就是目标视频秒数,video_prompt必须完整覆盖该时长 + - duration为4-7秒:至少写清开始动作、持续变化、结束状态 + - duration为8秒及以上:必须按“前段→中段→后段”写至少3个连续可见节拍,并包含持续运镜或环境变化 + - 禁止用一个瞬时动作支撑长镜头,例如仅“吐信、转头、抬手”却标10秒 + - description也必须同步扩写这些节拍,保证画面描述与video_prompt一致 + + 【video_prompt撰写规则 - 重要】 + 视频模型不认识名字,必须用年龄段+性别替代: + - 年龄段:少年/少女(10-16)、年轻男子/年轻女子(17-30)、中年男子/中年女子(31-50)、老年男子/老年女子(50+) + - 格式:年龄性别+动作+镜头运动+环境 + - 必须有动作词:转头、点头、走动、转身、推门、抬手等 + - 必须有镜头运动词:缓缓推近、轻轻跟随、手持跟随、环绕拍摄等 + - 对话场景必须写明"正在说话" + - 禁止纯静态描述 + - 特写镜头必须使用"固定镜头" + + 【动态优先原则 - 核心规则】 + 视频不能僵硬!每个video_prompt必须包含"动"的元素。即使是对话场景也要动起来。 + ✅ 正确:"年轻女子坐在沙发上轻轻转头,镜头缓缓推近她的侧脸" + ❌ 错误:"年轻女子坐在沙发上,镜头固定" + + 【image_prompt撰写规则】 + - 使用角色实际名字(不是年龄段+性别) + - 包含角色位置、场景环境、光线氛围 + - 纯视觉描述,禁止抽象词 + + 【输出格式】 + 只返回JSON数组。保留输入中的所有原始字段,补充shot_type、camera_move、video_prompt、image_prompt、sceneTitle: + + { + "panel_number": 1, + "shot_type": "平视中景", + "camera_move": "缓推", + "description": "从start_state开始并停在end_state的优化画面描述", + "start_state": "继承的镜头起始状态", + "end_state": "可供下一镜承接的镜头结束状态", + "continuity_action": "延续到下一镜头的动作或视线", + "spatial_anchor": "人物左右前后与朝向", + "present_characters": ["张三"], + "beat_type": "action", + "narrative_cause": "上一镜头留下的直接原因", + "character_goal": "当前角色的具体目标", + "story_action": "镜头中执行的关键动作", + "story_result": "动作造成的剧情变化", + "next_hook": "下一镜必须回应的后果", + "video_prompt": "年轻男子站在桌前双手撑在桌面上,正在说话,镜头缓缓推近", + "image_prompt": "张三站在办公室中央,双手撑在深棕色实木桌面上,表情严肃,午后阳光从右侧窗户斜照进来", + "sceneTitle": "张三宣布开会", + "characters": [{"name":"张三","appearance":"初始形象","slot":"办公室中央"}], + "location": "办公室_白天", + "scene_type": "daily", + "source_text": "张三对大家说:开会了" + } + ⚠️ JSON安全:严格遵守JSON标准格式。字符串值内的双引号必须转义为\"。对话引号统一使用「」代替英文双引号。特写镜头必须使用固定镜头。 + + 分镜规划:%s + + 角色年龄性别信息:%s + + 场景描述:%s + """.formatted(artStyle, aspectRatio, panelsJson, charsAgeGender, locsDesc); + } + + // ==================== 持久化 ==================== + + private List persistStoryboards(Long projectId, Long scriptId, List panels, ShortDramaScript script) { + List result = new ArrayList<>(); + int sceneNo = 1; + for (StoryboardPanelData panel : panels) { + ShortDramaStoryboard entity = new ShortDramaStoryboard(); + entity.setId(IdUtil.getSnowflakeNextId()); + entity.setProjectId(projectId); + entity.setScriptId(scriptId); + entity.setSceneNo(sceneNo); + entity.setSceneTitle(firstNotBlank(panel.getSceneTitle(), "镜头 " + sceneNo)); + entity.setSceneText(firstNotBlank(panel.getDescription(), panel.getSourceText(), "")); + entity.setSceneType(firstNotBlank(panel.getSceneType(), "daily")); + entity.setShotType(firstNotBlank(panel.getShotType(), "平视中景")); + entity.setCameraMove(firstNotBlank(panel.getCameraMove(), "缓推")); + entity.setDurationSeconds(panel.getDuration() != null && panel.getDuration() > 0 ? panel.getDuration() : defaultDurationForSceneType(entity.getSceneType())); + entity.setVideoPrompt(firstNotBlank(panel.getVideoPrompt(), buildVideoPromptFallback(script, panel.getDescription(), sceneNo))); + entity.setVideoStatus("pending"); + entity.setLocationName(panel.getLocation()); + entity.setSourceText(panel.getSourceText()); + entity.setImagePrompt(panel.getImagePrompt()); + if (panel.getCharacters() != null && !panel.getCharacters().isEmpty()) { + entity.setCharactersJson(JsonUtils.toJsonString(panel.getCharacters())); + } + entity.setPhotographyRules(panel.getPhotographyRules()); + entity.setActingNotes(panel.getActingNotes()); + entity.setContinuityJson(buildContinuityJson(panel)); + storyboardMapper.insert(entity); + result.add(MapstructUtils.convert(entity, ShortDramaStoryboardVo.class)); + sceneNo++; + } + return result; + } + + // ==================== 辅助方法:资产库字符串构建 ==================== + + private String buildCharactersLibString(Long projectId) { + List chars = characterMapper.selectList( + new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); + if (chars.isEmpty()) return "暂无"; + StringBuilder sb = new StringBuilder(); + for (ShortDramaCharacter c : chars) { + sb.append("- ").append(c.getName()) + .append("(").append(firstNotBlank(c.getRoleLevel(), "B")).append("级") + .append(",").append(firstNotBlank(c.getGender(), "未知")) + .append(",").append(firstNotBlank(c.getAgeRange(), "未知年龄")) + .append(",").append(firstNotBlank(c.getPersonalityTags(), "")) + .append(")\n"); + } + return sb.toString(); + } + + private String buildLocationsLibString(Long projectId) { + List locs = locationMapper.selectList( + new LambdaQueryWrapper().eq(ShortDramaLocation::getProjectId, projectId)); + if (locs.isEmpty()) return "暂无"; + StringBuilder sb = new StringBuilder(); + for (ShortDramaLocation l : locs) { + sb.append("- ").append(l.getName()); + if (StrUtil.isNotBlank(l.getSummary())) { + sb.append(":").append(l.getSummary()); + } + sb.append("\n"); + } + return sb.toString(); + } + + private String buildCharactersIntroString(Long projectId) { + List chars = characterMapper.selectList( + new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); + if (chars.isEmpty()) return "暂无"; + StringBuilder sb = new StringBuilder(); + for (ShortDramaCharacter c : chars) { + sb.append("- ").append(c.getName()); + if (StrUtil.isNotBlank(c.getIntroduction())) { + sb.append(":").append(c.getIntroduction()); + } + sb.append("\n"); + } + return sb.toString(); + } + + private String buildCharactersAppearanceListString(Long projectId) { + List chars = characterMapper.selectList( + new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); + if (chars.isEmpty()) return "暂无"; + StringBuilder sb = new StringBuilder(); + for (ShortDramaCharacter c : chars) { + List appearances = characterAppearanceMapper.selectList( + new LambdaQueryWrapper() + .eq(ShortDramaCharacterAppearance::getCharacterId, c.getId()) + .orderByAsc(ShortDramaCharacterAppearance::getAppearanceIndex)); + sb.append("- ").append(c.getName()).append("的形象:"); + for (ShortDramaCharacterAppearance a : appearances) { + sb.append("[").append(a.getAppearanceIndex()).append("]") + .append(firstNotBlank(a.getChangeReason(), "形象")).append("、"); + } + sb.append("\n"); + } + return sb.toString(); + } + + private String buildCharactersFullDescString(Long projectId) { + List chars = characterMapper.selectList( + new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); + if (chars.isEmpty()) return "暂无"; + StringBuilder sb = new StringBuilder(); + for (ShortDramaCharacter c : chars) { + sb.append("- ").append(c.getName()).append(":") + .append(firstNotBlank(c.getVisualDescription(), c.getIntroduction(), "无描述")) + .append("\n"); + } + return sb.toString(); + } + + private String buildLocationsDescString(Long projectId) { + List locs = locationMapper.selectList( + new LambdaQueryWrapper().eq(ShortDramaLocation::getProjectId, projectId)); + if (locs.isEmpty()) return "暂无"; + StringBuilder sb = new StringBuilder(); + for (ShortDramaLocation l : locs) { + sb.append("- ").append(l.getName()).append(":") + .append(firstNotBlank(l.getDescriptions(), l.getSummary(), "无描述")) + .append("\n"); + } + return sb.toString(); + } + + private String buildCharactersInfoString(Long projectId) { + List chars = characterMapper.selectList( + new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); + if (chars.isEmpty()) return "暂无"; + StringBuilder sb = new StringBuilder(); + for (ShortDramaCharacter c : chars) { + sb.append("- ").append(c.getName()) + .append("(").append(firstNotBlank(c.getGender(), "未知")).append(",") + .append(firstNotBlank(c.getAgeRange(), "未知")).append(",") + .append(firstNotBlank(c.getRoleLevel(), "B")).append("级") + .append(")\n"); + } + return sb.toString(); + } + + private String buildCharactersAgeGenderString(Long projectId) { + List chars = characterMapper.selectList( + new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); + if (chars.isEmpty()) return "暂无"; + StringBuilder sb = new StringBuilder(); + for (ShortDramaCharacter c : chars) { + sb.append("- ").append(c.getName()) + .append(":").append(firstNotBlank(c.getGender(), "未知")).append(",") + .append(firstNotBlank(c.getAgeRange(), "未知")).append("\n"); + } + return sb.toString(); + } + + // ==================== 辅助方法 ==================== + + private ChatModelVo validateAndGetModel(String modelName) { + ChatModelVo modelVo = chatModelService.selectModelByName(modelName); + if (modelVo == null) { + throw new IllegalArgumentException("未找到模型配置: " + modelName); + } + return modelVo; + } + + private ChatModelVo findChatModel() { + org.ruoyi.common.chat.domain.bo.chat.ChatModelBo query = new org.ruoyi.common.chat.domain.bo.chat.ChatModelBo(); + query.setCategory("chat"); + List models = chatModelService.queryList(query); + if (models != null && !models.isEmpty()) { + return models.get(0); + } + throw new IllegalArgumentException("无可用聊天模型"); + } + + private AbstractChatService getChatService(ChatModelVo modelVo) { + return chatServiceFactory.getOriginalService(modelVo.getProviderCode()); + } + + private StreamingChatModel buildStreamingChatModel() { + ChatModelVo modelVo = findChatModel(); + AbstractChatService chatService = getChatService(modelVo); + return chatService.buildStreamingChatModel(modelVo, new ChatRequest()); + } + + private ShortDramaProject validateProjectOwner(Long projectId, Long userId) { + ShortDramaProject project = projectMapper.selectById(projectId); + if (project == null || !userId.equals(project.getUserId())) { + throw new IllegalArgumentException("项目不存在或无权限"); + } + return project; + } + + private void clearAssets(Long projectId) { + List chars = characterMapper.selectList( + new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); + for (ShortDramaCharacter c : chars) { + characterAppearanceMapper.delete(new LambdaQueryWrapper() + .eq(ShortDramaCharacterAppearance::getCharacterId, c.getId())); + } + characterMapper.delete(new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); + locationMapper.delete(new LambdaQueryWrapper().eq(ShortDramaLocation::getProjectId, projectId)); + } + + private static void normalizeContinuityChain(List panels) { + StoryboardPanelData previous = null; + int sceneNumber = 1; + int segmentNumber = 1; + int segmentDuration = 0; + String previousLocation = null; + for (StoryboardPanelData current : panels) { + String currentLocation = firstNotBlank(current.getLocation(), ""); + if (previousLocation != null && !previousLocation.equals(currentLocation)) { + sceneNumber++; + segmentNumber = 1; + segmentDuration = 0; + } + int duration = current.getDuration() != null && current.getDuration() > 0 + ? Math.max(5, Math.min(current.getDuration(), 15)) : 6; + current.setDuration(duration); + boolean requestedNewSegment = current.getSegmentNumber() != null && current.getSegmentNumber() > segmentNumber; + if (previous != null && (requestedNewSegment || segmentDuration + duration > 15)) { + segmentNumber++; + segmentDuration = 0; + } + current.setSceneNumber(sceneNumber); + current.setSegmentNumber(segmentNumber); + segmentDuration += duration; + previousLocation = currentLocation; + if (previous == null) { + current.setStartState(firstNotBlank(current.getStartState(), "新场景建立,按当前描述确定人物初始位置与姿态")); + current.setNarrativeCause(firstNotBlank(current.getNarrativeCause(), "剧本开场建立人物、目标或冲突")); + current.setBridgeIn(firstNotBlank(current.getBridgeIn(), "开场建立")); + } else { + String previousConsequence = firstNotBlank(previous.getStoryResult(), previous.getNextHook(), previous.getContinuityAction(), previous.getEndState()); + current.setNarrativeCause(previousConsequence); + if (current.getSegmentNumber().equals(previous.getSegmentNumber()) && current.getSceneNumber().equals(previous.getSceneNumber())) { + current.setBridgeIn(firstNotBlank(current.getBridgeIn(), previous.getContinuityAction(), previous.getNextHook(), previous.getEndState())); + } else { + current.setBridgeIn(firstNotBlank(current.getBridgeIn(), previous.getBridgeOut(), previous.getNextHook(), previous.getStoryResult())); + previous.setBridgeOut(firstNotBlank(previous.getBridgeOut(), current.getBridgeIn())); + } + } + if (previous != null && firstNotBlank(previous.getLocation(), "").equals(firstNotBlank(current.getLocation(), ""))) { + String inheritedState = firstNotBlank(previous.getEndState(), previous.getDescription(), previous.getSourceText(), ""); + current.setStartState(inheritedState); + if (StrUtil.isBlank(current.getSpatialAnchor())) { + current.setSpatialAnchor(previous.getSpatialAnchor()); + } + if ((current.getPresentCharacters() == null || current.getPresentCharacters().isEmpty()) + && previous.getPresentCharacters() != null) { + current.setPresentCharacters(new ArrayList<>(previous.getPresentCharacters())); + } + } else { + current.setStartState("新场景建立:" + firstNotBlank(current.getLocation(), "新地点") + ",重新交代人物位置、朝向与环境"); + } + current.setEndState(firstNotBlank(current.getEndState(), current.getDescription(), current.getSourceText(), current.getStartState())); + current.setContinuityAction(firstNotBlank(current.getContinuityAction(), "从当前结束姿态自然承接下一镜头")); + current.setBeatType(firstNotBlank(current.getBeatType(), previous == null ? "setup" : "action")); + current.setCharacterGoal(firstNotBlank(current.getCharacterGoal(), "回应当前剧情原因并推动局面变化")); + current.setStoryAction(firstNotBlank(current.getStoryAction(), current.getDescription(), current.getSourceText(), "执行当前剧情动作")); + current.setStoryResult(firstNotBlank(current.getStoryResult(), current.getEndState(), "当前行动形成可见结果")); + current.setNextHook(firstNotBlank(current.getNextHook(), current.getContinuityAction(), "下一镜头回应当前结果")); + current.setSegmentGoal(firstNotBlank(current.getSegmentGoal(), current.getCharacterGoal(), "完成当前连续剧情动作")); + current.setSegmentResult(firstNotBlank(current.getSegmentResult(), current.getStoryResult(), current.getEndState())); + current.setBridgeOut(firstNotBlank(current.getBridgeOut(), current.getNextHook(), current.getContinuityAction())); + if (current.getPresentCharacters() == null || current.getPresentCharacters().isEmpty()) { + List present = new ArrayList<>(); + if (current.getCharacters() != null) { + for (CharacterRef ref : current.getCharacters()) { + if (StrUtil.isNotBlank(ref.getName())) present.add(ref.getName()); + } + } + current.setPresentCharacters(present); + } + previous = current; + } + } + + private static String buildContinuityJson(StoryboardPanelData panel) { + ObjectNode continuity = JsonNodeFactory.instance.objectNode(); + continuity.put("start_state", firstNotBlank(panel.getStartState(), "")); + continuity.put("end_state", firstNotBlank(panel.getEndState(), "")); + continuity.put("continuity_action", firstNotBlank(panel.getContinuityAction(), "")); + continuity.put("spatial_anchor", firstNotBlank(panel.getSpatialAnchor(), "")); + ArrayNode presentCharacters = continuity.putArray("present_characters"); + if (panel.getPresentCharacters() != null) panel.getPresentCharacters().forEach(presentCharacters::add); + continuity.put("scene_number", panel.getSceneNumber() != null ? panel.getSceneNumber() : 1); + continuity.put("segment_number", panel.getSegmentNumber() != null ? panel.getSegmentNumber() : 1); + continuity.put("segment_goal", firstNotBlank(panel.getSegmentGoal(), "")); + continuity.put("segment_result", firstNotBlank(panel.getSegmentResult(), "")); + continuity.put("bridge_in", firstNotBlank(panel.getBridgeIn(), "")); + continuity.put("bridge_out", firstNotBlank(panel.getBridgeOut(), "")); + continuity.put("beat_type", firstNotBlank(panel.getBeatType(), "action")); + continuity.put("narrative_cause", firstNotBlank(panel.getNarrativeCause(), "")); + continuity.put("character_goal", firstNotBlank(panel.getCharacterGoal(), "")); + continuity.put("story_action", firstNotBlank(panel.getStoryAction(), "")); + continuity.put("story_result", firstNotBlank(panel.getStoryResult(), "")); + continuity.put("next_hook", firstNotBlank(panel.getNextHook(), "")); + return continuity.toString(); + } + + private static void applyContinuityJson(StoryboardPanelData panel, String continuityJson) { + if (StrUtil.isBlank(continuityJson)) return; + try { + JsonNode continuity = JsonUtils.parseObject(continuityJson, JsonNode.class); + panel.setStartState(continuity.path("start_state").asText(null)); + panel.setEndState(continuity.path("end_state").asText(null)); + panel.setContinuityAction(continuity.path("continuity_action").asText(null)); + panel.setSpatialAnchor(continuity.path("spatial_anchor").asText(null)); + JsonNode present = continuity.path("present_characters"); + if (present.isArray()) { + List names = new ArrayList<>(); + present.forEach(node -> names.add(node.asText())); + panel.setPresentCharacters(names); + } + if (continuity.has("scene_number")) panel.setSceneNumber(continuity.path("scene_number").asInt(1)); + if (continuity.has("segment_number")) panel.setSegmentNumber(continuity.path("segment_number").asInt(1)); + panel.setSegmentGoal(continuity.path("segment_goal").asText(null)); + panel.setSegmentResult(continuity.path("segment_result").asText(null)); + panel.setBridgeIn(continuity.path("bridge_in").asText(null)); + panel.setBridgeOut(continuity.path("bridge_out").asText(null)); + panel.setBeatType(continuity.path("beat_type").asText(null)); + panel.setNarrativeCause(continuity.path("narrative_cause").asText(null)); + panel.setCharacterGoal(continuity.path("character_goal").asText(null)); + panel.setStoryAction(continuity.path("story_action").asText(null)); + panel.setStoryResult(continuity.path("story_result").asText(null)); + panel.setNextHook(continuity.path("next_hook").asText(null)); + } catch (Exception e) { + log.debug("解析分镜连续性失败: {}", e.getMessage()); + } + } + + private void appendContinuityPrompt(StringBuilder prompt, ShortDramaStoryboard storyboard) { + ShortDramaStoryboard previous = storyboardMapper.selectOne(new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getProjectId, storyboard.getProjectId()) + .lt(ShortDramaStoryboard::getSceneNo, storyboard.getSceneNo()) + .orderByDesc(ShortDramaStoryboard::getSceneNo) + .last("limit 1")); + if (previous != null && StrUtil.isNotBlank(previous.getContinuityJson())) { + prompt.append("[上一镜头结束状态] ").append(previous.getContinuityJson()).append("\n"); + } + if (StrUtil.isNotBlank(storyboard.getContinuityJson())) { + prompt.append("[当前镜头连续性与剧情节拍] ").append(storyboard.getContinuityJson()).append("\n"); + prompt.append("[剧情执行要求] 镜头必须从narrative_cause开始,拍出story_action,并以story_result结束;next_hook必须自然引向下一镜头。\n"); + } + if (previous != null && previous.getLocationName() != null + && previous.getLocationName().equals(storyboard.getLocationName())) { + prompt.append("[强制承接] 当前视频第一帧必须继承上一镜头最后状态,保持人物位置、朝向、姿态、道具、光线和运动方向连续。\n"); + } else if (previous != null) { + prompt.append("[场景切换] 使用明确的新场景建立画面,不要伪装成上一镜头的连续动作。\n"); + } + } + + private List toPanelDataList(List storyboards) { + List panels = new ArrayList<>(); + for (ShortDramaStoryboard sb : storyboards) { + StoryboardPanelData panel = new StoryboardPanelData(); + panel.setPanelNumber(sb.getSceneNo()); + panel.setDescription(sb.getSceneText()); + panel.setSceneType(sb.getSceneType()); + panel.setLocation(sb.getLocationName()); + panel.setSourceText(sb.getSourceText()); + if (StrUtil.isNotBlank(sb.getCharactersJson())) { + try { + panel.setCharacters(JsonUtils.parseArray(sb.getCharactersJson(), CharacterRef.class)); + } catch (Exception ignored) {} + } + panels.add(panel); + } + return panels; + } + + private List fallbackPanels(ShortDramaScript script) { + String text = firstNotBlank(script.getScriptText(), script.getOutlineText(), ""); + List chunks = splitToChunks(text); + List panels = new ArrayList<>(); + int no = 1; + for (String chunk : chunks) { + StoryboardPanelData panel = new StoryboardPanelData(); + panel.setPanelNumber(no); + panel.setDescription(chunk); + panel.setSceneType("daily"); + panel.setSourceText(chunk); + panels.add(panel); + no++; + } + return panels; + } + + private static List splitToChunks(String text) { + if (StrUtil.isBlank(text)) return List.of(); + String normalized = text.replace("\r\n", "\n").replace("\r", "\n"); + String[] lines = normalized.split("\n+"); + List chunks = new ArrayList<>(); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty()) continue; + String[] sentences = trimmed.split("(?<=[。!?!?;;])"); + for (String sentence : sentences) { + String item = sentence.trim(); + if (!item.isEmpty()) chunks.add(item); + } + } + if (chunks.isEmpty()) chunks.add(normalized.trim()); + return chunks.stream().filter(StrUtil::isNotBlank).limit(12).toList(); + } + + private static String buildVideoPromptFallback(ShortDramaScript script, String text, int sceneNo) { + return "短剧镜头" + sceneNo + "," + firstNotBlank(script.getTone(), "短剧") + "风格," + text + ",镜头缓缓推近,自然光线"; + } + + // ==================== JSON 解析 ==================== + + private static String extractJson(String response) { + if (StrUtil.isBlank(response)) return null; + String text = response.trim(); + + // 1. 剥离 markdown 代码块 + if (text.startsWith("```")) { + text = text.replaceFirst("^```(?:json)?\\s*", ""); + text = text.replaceFirst("\\s*```$", ""); + } + + // 2. 定位 JSON 边界 + int start = text.indexOf('{'); + int arrStart = text.indexOf('['); + if (start >= 0 && (arrStart < 0 || start < arrStart)) { + int end = text.lastIndexOf('}'); + if (end > start) return repairJson(text.substring(start, end + 1)); + } + if (arrStart >= 0) { + int end = text.lastIndexOf(']'); + if (end > arrStart) return repairJson(text.substring(arrStart, end + 1)); + } + return repairJson(text); + } + + /** + * 修复 LLM 返回 JSON 的常见问题: + * - 「」被误用作 JSON 结构引号(旧 prompt 遗留的副作用) + * - 尾逗号 + * - 字符串值中未转义的换行符 + */ + private static String repairJson(String json) { + if (StrUtil.isBlank(json)) return json; + // 先试原样 + if (JsonUtils.isJson(json) || JsonUtils.isJsonArray(json)) return json; + String fixed = json; + // 修复:「」→ "(模型照字面执行了旧 prompt 指令) + if (fixed.contains("「") || fixed.contains("」")) { + fixed = fixed.replace("「", "\"").replace("」", "\""); + } + // 修复:尾逗号 + fixed = fixed.replaceAll(",(\\s*[}\\]])", "$1"); + // 修复:JSON 字符串值中未转义的实际换行符(替换为空格,避免破坏 JSON 结构) + if (fixed.contains("\n") || fixed.contains("\r")) { + fixed = fixed.replace("\r\n", " ").replace("\r", " ").replace("\n", " "); + } + return fixed; + } + + private static T parseJson(String json, Class clazz) { + if (StrUtil.isBlank(json)) return null; + try { + return JsonUtils.parseObject(json, clazz); + } catch (Exception e) { + // 原始解析失败时用修复版重试一次 + String repaired = repairJson(json); + if (!repaired.equals(json)) { + try { + return JsonUtils.parseObject(repaired, clazz); + } catch (Exception ignored) {} + } + log.warn("JSON解析失败[{}]: {} raw={}", clazz.getSimpleName(), e.getMessage(), + json.length() > 300 ? json.substring(0, 300) + "..." : json); + return null; + } + } + + private static List parseJsonArray(String json, Class clazz) { + if (StrUtil.isBlank(json)) return null; + try { + if (json.trim().startsWith("[")) { + return JsonUtils.parseArray(json, clazz); + } + } catch (Exception e) { + // 原始解析失败时用修复版重试一次 + String repaired = repairJson(json); + if (!repaired.equals(json)) { + try { + return JsonUtils.parseArray(repaired, clazz); + } catch (Exception ignored) {} + } + log.warn("JSON数组解析失败[{}]: {} raw={}", clazz.getSimpleName(), e.getMessage(), + json.length() > 300 ? json.substring(0, 300) + "..." : json); + } + return null; + } + + /** 从 LLM 响应中提取 JSON 并解析为 JsonNode(保留完整字段,避免 POJO 映射丢失) */ + private static JsonNode parseJsonNode(String json) { + if (StrUtil.isBlank(json)) return null; + try { + return JsonUtils.parseObject(json.trim(), JsonNode.class); + } catch (Exception e) { + String repaired = repairJson(json); + if (!repaired.equals(json)) { + try { return JsonUtils.parseObject(repaired, JsonNode.class); } catch (Exception ignored) {} + } + log.warn("JsonNode解析失败: {}", e.getMessage()); + return null; + } + } + + private List parseCharacterList(String response) { + String json = extractJson(response); + if (StrUtil.isBlank(json)) return List.of(); + try { + if (json.trim().startsWith("[")) { + return JsonUtils.parseArray(json, AssetGeneratedCharacter.class); + } + AssetGeneratedCharacterList obj = JsonUtils.parseObject(json, AssetGeneratedCharacterList.class); + return obj != null && obj.getNew_characters() != null ? obj.getNew_characters() : List.of(); + } catch (Exception e) { + log.warn("角色提取JSON解析失败: {}", e.getMessage()); + return List.of(); + } + } + + private List parseLocationList(String response) { + String json = extractJson(response); + if (StrUtil.isBlank(json)) return List.of(); + try { + if (json.trim().startsWith("[")) { + return JsonUtils.parseArray(json, AssetGeneratedLocation.class); + } + AssetGeneratedLocationList obj = JsonUtils.parseObject(json, AssetGeneratedLocationList.class); + return obj != null && obj.getLocations() != null ? obj.getLocations() : List.of(); + } catch (Exception e) { + log.warn("场景提取JSON解析失败: {}", e.getMessage()); + return List.of(); + } + } + + private List parsePanelList(String response) { + String json = extractJson(response); + if (StrUtil.isBlank(json)) return null; + try { + if (json.trim().startsWith("[")) { + return JsonUtils.parseArray(json, StoryboardPanelData.class); + } + } catch (Exception e) { + log.warn("分镜规划JSON解析失败: {}", e.getMessage()); + } + return null; + } + + // ==================== 内部 DTO ==================== + + @Data + public static class StoryboardPanelData { + @JsonProperty("panel_number") + private Integer panelNumber; + @JsonProperty("scene_number") + private Integer sceneNumber; + @JsonProperty("segment_number") + private Integer segmentNumber; + @JsonProperty("segment_goal") + private String segmentGoal; + @JsonProperty("segment_result") + private String segmentResult; + @JsonProperty("bridge_in") + private String bridgeIn; + @JsonProperty("bridge_out") + private String bridgeOut; + private String description; + private List characters; + private String location; + @JsonProperty("scene_type") + private String sceneType; + @JsonProperty("source_text") + private String sourceText; + private Integer duration; + @JsonProperty("start_state") + private String startState; + @JsonProperty("end_state") + private String endState; + @JsonProperty("continuity_action") + private String continuityAction; + @JsonProperty("spatial_anchor") + private String spatialAnchor; + @JsonProperty("present_characters") + private List presentCharacters; + @JsonProperty("beat_type") + private String beatType; + @JsonProperty("narrative_cause") + private String narrativeCause; + @JsonProperty("character_goal") + private String characterGoal; + @JsonProperty("story_action") + private String storyAction; + @JsonProperty("story_result") + private String storyResult; + @JsonProperty("next_hook") + private String nextHook; + // Phase 4 fills: + private String photographyRules; + // Phase 5 fills: + private String actingNotes; + // Phase 6 fills: + @JsonProperty("shot_type") + private String shotType; + @JsonProperty("camera_move") + private String cameraMove; + @JsonProperty("video_prompt") + private String videoPrompt; + @JsonProperty("image_prompt") + private String imagePrompt; + private String sceneTitle; + } + + @Data + public static class CharacterRef { + private String name; + private String appearance; + private String slot; + } + + @Data + static class ActingDirectionResult { + @JsonProperty("panel_number") + private Integer panelNumber; + private JsonNode characters; + } + + @Data + private static class StoryboardDetailResult { + @JsonProperty("panel_number") + private Integer panelNumber; + @JsonProperty("scene_number") + private Integer sceneNumber; + @JsonProperty("segment_number") + private Integer segmentNumber; + @JsonProperty("segment_goal") + private String segmentGoal; + @JsonProperty("segment_result") + private String segmentResult; + @JsonProperty("bridge_in") + private String bridgeIn; + @JsonProperty("bridge_out") + private String bridgeOut; + @JsonProperty("shot_type") + private String shotType; + @JsonProperty("camera_move") + private String cameraMove; + private String description; + @JsonProperty("video_prompt") + private String videoPrompt; + @JsonProperty("image_prompt") + private String imagePrompt; + private String sceneTitle; + @JsonProperty("start_state") + private String startState; + @JsonProperty("end_state") + private String endState; + @JsonProperty("continuity_action") + private String continuityAction; + @JsonProperty("spatial_anchor") + private String spatialAnchor; + @JsonProperty("present_characters") + private List presentCharacters; + @JsonProperty("beat_type") + private String beatType; + @JsonProperty("narrative_cause") + private String narrativeCause; + @JsonProperty("character_goal") + private String characterGoal; + @JsonProperty("story_action") + private String storyAction; + @JsonProperty("story_result") + private String storyResult; + @JsonProperty("next_hook") + private String nextHook; + } + + @Data + private static class AssetGeneratedCharacter { + private String name; + private String aliases; + private String introduction; + private String roleLevel; + private String gender; + private String ageRange; + private String archetype; + private String personalityTags; + private Integer costumeTier; + private String visualKeywords; + private String suggestedColors; + private String primaryIdentifier; + } + + @Data + private static class AssetGeneratedCharacterList { + private List new_characters; + } + + @Data + private static class AssetCharacterVisualResult { + private List characters; + } + + @Data + private static class AssetCharVisual { + private String name; + private List appearances; + } + + @Data + private static class AssetAppearanceDesc { + private Integer id; + private List descriptions; + private String change_reason; + } + + @Data + private static class AssetGeneratedLocation { + private String name; + private String summary; + private Boolean hasCrowd; + private String crowdDescription; + private List availableSlots; + private List descriptions; + } + + @Data + private static class AssetGeneratedLocationList { + private List locations; + } + + /** 查找项目的视觉风格 prompt 后缀,找不到返回空字符串 */ + private String artStyleSuffix(Long projectId) { + ShortDramaProject project = projectMapper.selectById(projectId); + if (project == null) return ""; + return ShortDramaImageConstants.artStylePrompt(project.getArtStyle()); + } + + /** + * 拼装角色生图最终 prompt:风格前缀 + 构图前缀 + 角色描述 + 三视图后缀 + 风格后缀。 + * 把风格词放在 prompt 最前面,避免被角色描述稀释导致画风漂移、每个角色风格不一致。 + */ + private String buildCharacterPrompt(String basePrompt, Long projectId) { + String styleSuffix = artStyleSuffix(projectId); + String stylePrefix = StrUtil.isNotBlank(styleSuffix) ? styleSuffix + "," : ""; + return stylePrefix + ShortDramaImageConstants.CHARACTER_PROMPT_PREFIX + + basePrompt + ShortDramaImageConstants.CHARACTER_PROMPT_SUFFIX; + } + + private String projectAspectRatio(Long projectId) { + ShortDramaProject project = projectMapper.selectById(projectId); + return project == null ? "9:16" : normalizeAspectRatio(project.getComposeAspectRatio()); + } + + private static String normalizeAspectRatio(String aspectRatio) { + return switch (firstNotBlank(aspectRatio, "9:16")) { + case "16:9", "1:1" -> aspectRatio; + default -> "9:16"; + }; + } + + /** 根据场景类型返回默认时长(秒),取值在各类型推荐范围的中位 */ + private static int defaultDurationForSceneType(String sceneType) { + if (sceneType == null) return 6; + return switch (sceneType) { + case "action" -> 5; + case "daily" -> 6; + case "suspense" -> 7; + case "emotion" -> 8; + case "epic" -> 11; + default -> 6; + }; + } + + /** + * 校验用户传入的图生图参考图。仅允许公开 HTTP(S) URL 或图片 data URL, + * 避免把任意协议和本地文件路径转交给图片供应商。 + */ + private static String validateReferenceImageUrl(String referenceImageUrl) { + if (StrUtil.isBlank(referenceImageUrl)) return null; + String value = referenceImageUrl.trim(); + if (value.length() > 10_000) { + throw new IllegalArgumentException("参考图地址过长"); + } + if (value.startsWith("data:image/")) return value; + try { + java.net.URI uri = java.net.URI.create(value); + String scheme = uri.getScheme(); + if (("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && StrUtil.isNotBlank(uri.getHost())) { + return value; + } + } catch (IllegalArgumentException ignored) { + // 统一在下方返回面向调用方的错误。 + } + throw new IllegalArgumentException("参考图仅支持 HTTP(S) URL 或 data:image URL"); + } + + private static String firstNotBlank(String... values) { + for (String value : values) { + if (StringUtils.isNotBlank(value)) return value; + } + return ""; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeJob.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeJob.java new file mode 100644 index 00000000..6fabfbaa --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeJob.java @@ -0,0 +1,28 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.ruoyi.service.shortdrama.composition.AspectRatio; +import org.ruoyi.service.shortdrama.composition.TransitionType; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; + +record ShortDramaVideoComposeJob( + Long projectId, + String jobId, + String tenantId, + TransitionType transitionType, + BigDecimal transitionDurationSeconds, + AspectRatio aspectRatio, + List storyboardIds +) { + + ShortDramaVideoComposeJob { + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(jobId, "jobId"); + Objects.requireNonNull(transitionType, "transitionType"); + Objects.requireNonNull(transitionDurationSeconds, "transitionDurationSeconds"); + Objects.requireNonNull(aspectRatio, "aspectRatio"); + storyboardIds = List.copyOf(Objects.requireNonNull(storyboardIds, "storyboardIds")); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeServiceImpl.java new file mode 100644 index 00000000..08887878 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeServiceImpl.java @@ -0,0 +1,299 @@ +package org.ruoyi.service.shortdrama.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.core.exception.ServiceException; +import org.ruoyi.common.core.service.OssService; +import org.ruoyi.domain.bo.shortdrama.ShortDramaComposeVideoBo; +import org.ruoyi.domain.entity.shortdrama.ShortDramaProject; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo; +import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper; +import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper; +import org.ruoyi.service.shortdrama.IShortDramaVideoComposeService; +import org.ruoyi.service.shortdrama.composition.AspectRatio; +import org.ruoyi.service.shortdrama.composition.FfmpegCompositionProperties; +import org.ruoyi.service.shortdrama.composition.TransitionType; +import org.ruoyi.common.tenant.helper.TenantHelper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ShortDramaVideoComposeServiceImpl implements IShortDramaVideoComposeService { + + static final String STATUS_PENDING = "pending"; + static final String STATUS_PROCESSING = "processing"; + static final String STATUS_DONE = "done"; + static final String STATUS_FAILED = "failed"; + + private final ShortDramaProjectMapper projectMapper; + private final ShortDramaStoryboardMapper storyboardMapper; + private final ShortDramaVideoComposeWorker composeWorker; + private final OssService ossService; + private final FfmpegCompositionProperties compositionProperties; + + @Override + public ShortDramaComposeVideoVo composeVideo(Long projectId, ShortDramaComposeVideoBo bo, Long userId) { + selectOwnedProject(projectId, userId); + validateReadyStoryboards(projectId, bo.getStoryboardIds()); + + TransitionType transitionType = TransitionType.fromValue(bo.getTransitionType()); + AspectRatio aspectRatio = AspectRatio.fromValue(bo.getAspectRatio()); + BigDecimal transitionDuration = normalizeTransitionDuration(transitionType, bo.getTransitionDurationSeconds()); + String jobId = UUID.randomUUID().toString(); + Date now = new Date(); + Date staleBefore = staleBefore(now); + + int claimed = projectMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaProject::getId, projectId) + .eq(ShortDramaProject::getUserId, userId) + .nested(w -> w.isNull(ShortDramaProject::getComposeStatus) + .or() + .notIn(ShortDramaProject::getComposeStatus, STATUS_PENDING, STATUS_PROCESSING) + .or(stale -> stale + .in(ShortDramaProject::getComposeStatus, STATUS_PENDING, STATUS_PROCESSING) + .and(expired -> expired + .isNull(ShortDramaProject::getUpdateTime) + .or() + .le(ShortDramaProject::getUpdateTime, staleBefore)))) + .set(ShortDramaProject::getComposeStatus, STATUS_PENDING) + .set(ShortDramaProject::getComposeJobId, jobId) + .set(ShortDramaProject::getComposeProgress, 0) + .set(ShortDramaProject::getComposeTransitionType, transitionType.value()) + .set(ShortDramaProject::getComposeTransitionDurationSeconds, transitionDuration) + .set(ShortDramaProject::getComposeAspectRatio, aspectRatio.value()) + .set(ShortDramaProject::getComposedVideoDurationSeconds, null) + .set(ShortDramaProject::getComposeErrorMessage, null) + .set(ShortDramaProject::getComposedAt, null) + .set(ShortDramaProject::getUpdateTime, now)); + if (claimed == 0) { + throw new ServiceException("该项目已有视频合成任务正在排队或处理中"); + } + + ShortDramaVideoComposeJob job = new ShortDramaVideoComposeJob( + projectId, + jobId, + TenantHelper.getTenantId(), + transitionType, + transitionDuration, + aspectRatio, + bo.getStoryboardIds() + ); + try { + composeWorker.composeAsync(job); + } catch (RuntimeException ex) { + markDispatchFailed(job, ex); + throw new ServiceException("视频合成任务提交失败,请稍后重试").setDetailMessage(ex.getMessage()); + } + ShortDramaProject state = projectMapper.selectById(projectId); + if (state == null) { + throw new ServiceException("项目已被删除"); + } + return toVo(state); + } + + @Override + public ShortDramaComposeVideoVo getComposedVideo(Long projectId, Long userId) { + ShortDramaProject project = selectOwnedProject(projectId, userId); + if (StrUtil.isBlank(project.getComposeStatus())) { + return null; + } + return toVo(project); + } + + @Override + public Path getLocalComposedVideo(Long projectId, Long userId) { + ShortDramaProject project = selectOwnedProject(projectId, userId); + if (!STATUS_DONE.equals(project.getComposeStatus())) { + throw new ServiceException("成片尚未生成完成"); + } + Path path = Path.of(compositionProperties.getLocalOutputDirectory()) + .toAbsolutePath().normalize() + .resolve(projectId.toString()) + .resolve("composed.mp4"); + if (!Files.isRegularFile(path)) { + throw new ServiceException("本地成片文件不存在,请重新合成"); + } + return path; + } + @Override + public void invalidateComposition(Long projectId) { + projectMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaProject::getId, projectId) + .set(ShortDramaProject::getComposeStatus, null) + .set(ShortDramaProject::getComposeJobId, null) + .set(ShortDramaProject::getComposeProgress, 0) + .set(ShortDramaProject::getComposeErrorMessage, null) + .set(ShortDramaProject::getComposedVideoDurationSeconds, null) + .set(ShortDramaProject::getComposedAt, null) + .set(ShortDramaProject::getUpdateTime, new Date())); + } + + @Override + public void deleteComposition(Long projectId) { + int invalidated = projectMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaProject::getId, projectId) + .set(ShortDramaProject::getComposeStatus, null) + .set(ShortDramaProject::getComposeJobId, null) + .set(ShortDramaProject::getComposeProgress, 0) + .set(ShortDramaProject::getComposeErrorMessage, null) + .set(ShortDramaProject::getComposedVideoDurationSeconds, null) + .set(ShortDramaProject::getComposedAt, null) + .set(ShortDramaProject::getUpdateTime, new Date())); + if (invalidated == 0) { + return; + } + + ShortDramaProject project = projectMapper.selectById(projectId); + if (project == null) { + return; + } + Long ossId = project.getComposedVideoOssId(); + if (ossId == null) { + return; + } + int cleared = projectMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaProject::getId, projectId) + .eq(ShortDramaProject::getComposedVideoOssId, ossId) + .set(ShortDramaProject::getComposedVideoOssId, null) + .set(ShortDramaProject::getUpdateTime, new Date())); + if (cleared > 0) { + deleteOssAfterCommit(ossId, "删除项目成片"); + } + } + + private ShortDramaProject selectOwnedProject(Long projectId, Long userId) { + ShortDramaProject project = projectMapper.selectOne(new LambdaQueryWrapper() + .eq(ShortDramaProject::getId, projectId) + .eq(ShortDramaProject::getUserId, userId) + .last("limit 1")); + if (project == null) { + throw new ServiceException("项目不存在或无权限"); + } + return project; + } + + private void validateReadyStoryboards(Long projectId, List storyboardIds) { + if (storyboardIds == null || storyboardIds.size() < 2) { + throw new ServiceException("至少选择 2 个分镜视频进行合成"); + } + List distinctIds = storyboardIds.stream().distinct().toList(); + if (distinctIds.size() != storyboardIds.size()) { + throw new ServiceException("所选分镜存在重复项"); + } + List storyboards = storyboardMapper.selectList( + new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getProjectId, projectId) + .in(ShortDramaStoryboard::getId, distinctIds)); + if (storyboards.size() != distinctIds.size()) { + throw new ServiceException("所选分镜不存在或不属于当前项目"); + } + for (ShortDramaStoryboard storyboard : storyboards) { + if (!STATUS_DONE.equals(storyboard.getVideoStatus()) || StrUtil.isBlank(storyboard.getVideoUrl())) { + throw new ServiceException("分镜 " + storyboard.getSceneNo() + " 的视频尚未生成完成"); + } + } + } + + private BigDecimal normalizeTransitionDuration(TransitionType type, BigDecimal requested) { + try { + return compositionProperties.normalizeTransitionDuration(type, requested); + } catch (IllegalArgumentException ex) { + throw new ServiceException("转场时长无效: " + ex.getMessage()); + } + } + + private Date staleBefore(Date now) { + Duration staleAfter = compositionProperties.getJobStaleAfter(); + if (staleAfter == null || staleAfter.isZero() || staleAfter.isNegative()) { + throw new ServiceException("视频合成任务超时租约配置必须大于0"); + } + return Date.from(Instant.ofEpochMilli(now.getTime()).minus(staleAfter)); + } + + private ShortDramaComposeVideoVo toVo(ShortDramaProject project) { + boolean completed = STATUS_DONE.equals(project.getComposeStatus()); + Long ossId = completed ? project.getComposedVideoOssId() : null; + String videoUrl = null; + if (ossId != null) { + try { + videoUrl = ossService.selectUrlByIds(ossId.toString()); + } catch (Exception ex) { + log.warn("解析短剧成片OSS地址失败, projectId={}, ossId={}: {}", + project.getId(), ossId, ex.getMessage()); + } + } + return ShortDramaComposeVideoVo.builder() + .projectId(project.getId()) + .status(project.getComposeStatus()) + .progress(project.getComposeProgress()) + .transitionType(project.getComposeTransitionType()) + .transitionDurationSeconds(project.getComposeTransitionDurationSeconds()) + .aspectRatio(project.getComposeAspectRatio()) + .outputDurationSeconds(project.getComposedVideoDurationSeconds()) + .videoOssId(ossId) + .videoUrl(videoUrl) + .errorMessage(project.getComposeErrorMessage()) + .composedAt(project.getComposedAt()) + .build(); + } + + private void markDispatchFailed(ShortDramaVideoComposeJob job, RuntimeException ex) { + projectMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaProject::getId, job.projectId()) + .eq(ShortDramaProject::getComposeStatus, STATUS_PENDING) + .eq(ShortDramaProject::getComposeJobId, job.jobId()) + .set(ShortDramaProject::getComposeStatus, STATUS_FAILED) + .set(ShortDramaProject::getComposeJobId, null) + .set(ShortDramaProject::getComposeProgress, 0) + .set(ShortDramaProject::getComposeErrorMessage, shortMessage(ex)) + .set(ShortDramaProject::getUpdateTime, new Date())); + } + + private void deleteOssBestEffort(Long ossId, String action) { + if (ossId == null) { + return; + } + try { + ossService.deleteFile(ossId); + } catch (Exception ex) { + log.warn("{}失败, ossId={}: {}", action, ossId, ex.getMessage()); + } + } + + private void deleteOssAfterCommit(Long ossId, String action) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + deleteOssBestEffort(ossId, action); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + deleteOssBestEffort(ossId, action); + } + }); + } + + private static String shortMessage(Throwable throwable) { + String message = throwable.getMessage(); + if (StrUtil.isBlank(message)) { + message = throwable.getClass().getSimpleName(); + } + return message.length() <= 1000 ? message : message.substring(0, 1000); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java new file mode 100644 index 00000000..31587c23 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java @@ -0,0 +1,283 @@ +package org.ruoyi.service.shortdrama.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.core.domain.dto.OssDTO; +import org.ruoyi.common.core.exception.ServiceException; +import org.ruoyi.common.core.service.OssService; +import org.ruoyi.common.core.utils.file.FileUtils; +import org.ruoyi.common.tenant.helper.TenantHelper; +import org.ruoyi.domain.entity.shortdrama.ShortDramaProject; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper; +import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper; +import org.ruoyi.service.shortdrama.composition.CompositionArtifact; +import org.ruoyi.service.shortdrama.composition.CompositionSource; +import org.ruoyi.service.shortdrama.composition.CompositionSpec; +import org.ruoyi.service.shortdrama.composition.FfmpegCompositionProperties; +import org.ruoyi.service.shortdrama.composition.FfmpegVideoComposer; +import org.ruoyi.service.shortdrama.download.SafeVideoSourceDownloader; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Objects; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ShortDramaVideoComposeWorker { + + private final ShortDramaProjectMapper projectMapper; + private final ShortDramaStoryboardMapper storyboardMapper; + private final FfmpegVideoComposer videoComposer; + private final FfmpegCompositionProperties compositionProperties; + private final SafeVideoSourceDownloader sourceDownloader; + private final OssService ossService; + + @Async("videoCompositionExecutor") + public void composeAsync(ShortDramaVideoComposeJob job) { + TenantHelper.dynamic(job.tenantId(), () -> composeWithinTenant(job)); + } + + private void composeWithinTenant(ShortDramaVideoComposeJob job) { + Path workDirectory = null; + Long uploadedOssId = null; + try { + if (!markProcessing(job)) { + return; + } + List storyboards = selectReadyStoryboards(job.projectId(), job.storyboardIds()); + workDirectory = Files.createTempDirectory("short-drama-compose-" + job.projectId() + "-"); + List sources = downloadSources(job, storyboards, workDirectory); + if (!updateProgress(job, 50)) { + return; + } + + CompositionArtifact artifact = videoComposer.compose(new CompositionSpec( + sources, + job.transitionType(), + job.transitionDurationSeconds().doubleValue(), + job.aspectRatio() + ), workDirectory); + if (!updateProgress(job, 85)) { + return; + } + + ShortDramaProject current = selectActiveProject(job); + if (current == null) { + return; + } + Long previousOssId = current.getComposedVideoOssId(); + boolean localStorage = "local".equalsIgnoreCase(compositionProperties.getStorageMode()); + if (localStorage) { + persistLocalComposition(job.projectId(), artifact.path()); + } else { + OssDTO uploaded = ossService.uploadFile(artifact.path().toFile()); + if (uploaded == null || uploaded.getOssId() == null) { + throw new ServiceException("成片上传对象存储失败"); + } + uploadedOssId = uploaded.getOssId(); + } + if (!updateProgress(job, 95)) { + if (localStorage) deleteLocalComposition(job.projectId()); + else deleteOssBestEffort(uploadedOssId, "删除已失效的新成片"); + uploadedOssId = null; + return; + } + + BigDecimal duration = BigDecimal.valueOf(artifact.durationSeconds()) + .setScale(3, RoundingMode.HALF_UP); + int published = projectMapper.update(null, activeJobUpdate(job) + .set(ShortDramaProject::getComposedVideoOssId, localStorage ? null : uploadedOssId) + .set(ShortDramaProject::getComposeStatus, ShortDramaVideoComposeServiceImpl.STATUS_DONE) + .set(ShortDramaProject::getComposeJobId, null) + .set(ShortDramaProject::getComposeProgress, 100) + .set(ShortDramaProject::getComposedVideoDurationSeconds, duration) + .set(ShortDramaProject::getComposeErrorMessage, null) + .set(ShortDramaProject::getComposedAt, new Date()) + .set(ShortDramaProject::getUpdateTime, new Date())); + if (published == 0) { + if (localStorage) deleteLocalComposition(job.projectId()); + else deleteOssBestEffort(uploadedOssId, "删除并发失效的新成片"); + } else { + deleteOssBestEffort(previousOssId, "删除被替换的旧成片"); + } + uploadedOssId = null; + } catch (Throwable ex) { + if (uploadedOssId != null) { + deleteOssBestEffort(uploadedOssId, "清理发布失败的新成片"); + } + markFailed(job, ex); + log.warn("短剧视频合成失败, projectId={}, jobId={}: {}", + job.projectId(), job.jobId(), shortMessage(ex)); + } finally { + deleteWorkDirectory(workDirectory); + } + } + + private boolean markProcessing(ShortDramaVideoComposeJob job) { + return projectMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaProject::getId, job.projectId()) + .eq(ShortDramaProject::getComposeStatus, ShortDramaVideoComposeServiceImpl.STATUS_PENDING) + .eq(ShortDramaProject::getComposeJobId, job.jobId()) + .set(ShortDramaProject::getComposeStatus, ShortDramaVideoComposeServiceImpl.STATUS_PROCESSING) + .set(ShortDramaProject::getComposeProgress, 1) + .set(ShortDramaProject::getUpdateTime, new Date())) > 0; + } + + private List selectReadyStoryboards(Long projectId, List storyboardIds) { + List storyboards = storyboardMapper.selectList( + new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getProjectId, projectId) + .in(ShortDramaStoryboard::getId, storyboardIds) + .eq(ShortDramaStoryboard::getVideoStatus, ShortDramaVideoComposeServiceImpl.STATUS_DONE) + .isNotNull(ShortDramaStoryboard::getVideoUrl) + .ne(ShortDramaStoryboard::getVideoUrl, "") + .orderByAsc(ShortDramaStoryboard::getSceneNo)); + if (storyboards.size() != storyboardIds.size()) { + throw new ServiceException("部分所选分镜视频已失效,请重新选择"); + } + if (storyboards.size() > compositionProperties.getMaxClips()) { + throw new ServiceException("分镜数量超过视频合成上限: " + compositionProperties.getMaxClips()); + } + return storyboards; + } + + private List downloadSources( + ShortDramaVideoComposeJob job, + List storyboards, + Path workDirectory + ) throws IOException { + List sources = new ArrayList<>(storyboards.size()); + long maxSourceBytes = compositionProperties.getMaxSourceBytes(); + long maxTotalSourceBytes = compositionProperties.getMaxTotalSourceBytes(); + if (maxSourceBytes <= 0 || maxTotalSourceBytes <= 0 || maxSourceBytes > maxTotalSourceBytes) { + throw new ServiceException("视频合成下载大小限制配置无效"); + } + long totalBytes = 0; + for (int index = 0; index < storyboards.size(); index++) { + if (!isActive(job)) { + return List.of(); + } + ShortDramaStoryboard storyboard = storyboards.get(index); + Path target = workDirectory.resolve("clip-" + String.format("%04d", index + 1) + ".mp4"); + long remainingTotalBytes = maxTotalSourceBytes - totalBytes; + totalBytes += sourceDownloader.download( + storyboard.getVideoUrl(), + target, + maxSourceBytes, + remainingTotalBytes + ); + Double fallbackDuration = storyboard.getDurationSeconds() == null + ? null + : storyboard.getDurationSeconds().doubleValue(); + sources.add(new CompositionSource(target, fallbackDuration)); + int progress = 10 + (int) Math.round(30.0 * (index + 1) / storyboards.size()); + if (!updateProgress(job, progress)) { + return List.of(); + } + } + return sources; + } + + private boolean updateProgress(ShortDramaVideoComposeJob job, int progress) { + return projectMapper.update(null, activeJobUpdate(job) + .set(ShortDramaProject::getComposeProgress, Math.max(0, Math.min(99, progress))) + .set(ShortDramaProject::getUpdateTime, new Date())) > 0; + } + + private boolean isActive(ShortDramaVideoComposeJob job) { + return selectActiveProject(job) != null; + } + + private ShortDramaProject selectActiveProject(ShortDramaVideoComposeJob job) { + ShortDramaProject project = projectMapper.selectById(job.projectId()); + if (project == null + || !ShortDramaVideoComposeServiceImpl.STATUS_PROCESSING.equals(project.getComposeStatus()) + || !Objects.equals(job.jobId(), project.getComposeJobId())) { + return null; + } + return project; + } + + private LambdaUpdateWrapper activeJobUpdate(ShortDramaVideoComposeJob job) { + return new LambdaUpdateWrapper() + .eq(ShortDramaProject::getId, job.projectId()) + .eq(ShortDramaProject::getComposeStatus, ShortDramaVideoComposeServiceImpl.STATUS_PROCESSING) + .eq(ShortDramaProject::getComposeJobId, job.jobId()); + } + + private void markFailed(ShortDramaVideoComposeJob job, Throwable ex) { + projectMapper.update(null, activeJobUpdate(job) + .set(ShortDramaProject::getComposeStatus, ShortDramaVideoComposeServiceImpl.STATUS_FAILED) + .set(ShortDramaProject::getComposeJobId, null) + .set(ShortDramaProject::getComposeErrorMessage, shortMessage(ex)) + .set(ShortDramaProject::getComposedAt, null) + .set(ShortDramaProject::getUpdateTime, new Date())); + } + + private Path localCompositionPath(Long projectId) { + return Path.of(compositionProperties.getLocalOutputDirectory()) + .toAbsolutePath().normalize() + .resolve(projectId.toString()) + .resolve("composed.mp4"); + } + + private void persistLocalComposition(Long projectId, Path source) throws IOException { + Path target = localCompositionPath(projectId); + Files.createDirectories(target.getParent()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + log.info("短剧成片已保存到本地: {}", target); + } + + private void deleteLocalComposition(Long projectId) { + try { + Files.deleteIfExists(localCompositionPath(projectId)); + } catch (IOException ex) { + log.warn("删除本地成片失败, projectId={}: {}", projectId, ex.getMessage()); + } + } + private void deleteWorkDirectory(Path workDirectory) { + if (workDirectory == null) { + return; + } + try { + if (!FileUtils.del(workDirectory)) { + log.warn("未能完整清理视频合成临时目录: {}", workDirectory); + } + } catch (Exception ex) { + log.warn("清理视频合成临时目录失败, path={}: {}", workDirectory, ex.getMessage()); + } + } + + private void deleteOssBestEffort(Long ossId, String action) { + if (ossId == null) { + return; + } + try { + ossService.deleteFile(ossId); + } catch (Exception ex) { + log.warn("{}失败, ossId={}: {}", action, ossId, ex.getMessage()); + } + } + + private static String shortMessage(Throwable throwable) { + String message = throwable.getMessage(); + if (StrUtil.isBlank(message)) { + message = throwable.getClass().getSimpleName(); + } + return message.length() <= 1000 ? message : message.substring(0, 1000); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/AbstractVideoGenerationService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/AbstractVideoGenerationService.java new file mode 100644 index 00000000..753bf13a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/AbstractVideoGenerationService.java @@ -0,0 +1,26 @@ +package org.ruoyi.service.video; + +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.common.chat.entity.video.VideoContext; +import org.ruoyi.common.chat.service.video.IVideoGenerationService; +import org.springframework.validation.annotation.Validated; + +@Slf4j +@Validated +public abstract class AbstractVideoGenerationService implements IVideoGenerationService { + + @Override + public MediaGenerationResponse generateVideo(VideoContext videoContext) { + return doGenerateVideo(videoContext); + } + + @Override + public MediaGenerationResponse retrieveVideo(VideoContext videoContext) { + return doRetrieveVideo(videoContext); + } + + protected abstract MediaGenerationResponse doGenerateVideo(VideoContext videoContext); + + protected abstract MediaGenerationResponse doRetrieveVideo(VideoContext videoContext); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java new file mode 100644 index 00000000..d24bea18 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java @@ -0,0 +1,89 @@ +package org.ruoyi.service.video.provider; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.common.chat.entity.video.VideoContext; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.service.media.AtlasMediaSupport; +import org.ruoyi.service.media.AtlasPredictionService; +import org.ruoyi.service.video.AbstractVideoGenerationService; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component("atlasVideo") +@RequiredArgsConstructor +public class AtlasVideoGenerationServiceImpl extends AbstractVideoGenerationService { + + private final AtlasPredictionService atlasPredictionService; + + private final OkHttpClient okHttpClient = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(180, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + + @Override + protected MediaGenerationResponse doGenerateVideo(VideoContext videoContext) { + ChatModelVo model = videoContext.getChatModelVo(); + ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode(); + payload.put("model", model.getModelName()); + payload.put("prompt", videoContext.getPrompt()); + if (StrUtil.isNotBlank(videoContext.getSize())) { + payload.put("size", videoContext.getSize()); + } + if (videoContext.getSeconds() != null) { + payload.put("duration", videoContext.getSeconds()); + } + if (StrUtil.isNotBlank(videoContext.getQuality())) { + payload.put("quality", videoContext.getQuality()); + } + java.util.List refImages = videoContext.getReferenceImages(); + if (refImages != null && !refImages.isEmpty()) { + com.fasterxml.jackson.databind.node.ArrayNode arr = payload.putArray("reference_images"); + for (String url : refImages) { + arr.add(url); + } + } else if (StrUtil.isNotBlank(videoContext.getImageUrl())) { + payload.put("image_url", videoContext.getImageUrl()); + } + + Request request = new Request.Builder() + .url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateVideo")) + .addHeader("Authorization", "Bearer " + model.getApiKey()) + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON)) + .build(); + try (Response response = okHttpClient.newCall(request).execute()) { + ResponseBody body = response.body(); + String responseText = body == null ? "" : body.string(); + if (!response.isSuccessful()) { + throw new IllegalArgumentException("Atlas Cloud视频生成任务创建失败: " + response.code() + " - " + responseText); + } + return atlasPredictionService.toResponse(responseText, "video"); + } catch (IOException e) { + throw new RuntimeException("Atlas Cloud视频生成任务创建失败: " + e.getMessage(), e); + } + } + + @Override + protected MediaGenerationResponse doRetrieveVideo(VideoContext videoContext) { + return atlasPredictionService.retrieve(videoContext.getChatModelVo(), videoContext.getVideoId()); + } + + @Override + public String getProviderName() { + return ChatModeType.ATLAS.getCode(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/OpenAiVideoGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/OpenAiVideoGenerationServiceImpl.java new file mode 100644 index 00000000..35ae6222 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/OpenAiVideoGenerationServiceImpl.java @@ -0,0 +1,130 @@ +package org.ruoyi.service.video.provider; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.common.chat.entity.video.VideoContext; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.service.media.OpenAiMediaSupport; +import org.ruoyi.service.video.AbstractVideoGenerationService; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component("openaiVideo") +public class OpenAiVideoGenerationServiceImpl extends AbstractVideoGenerationService { + + private final OkHttpClient okHttpClient = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(180, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + + @Override + protected MediaGenerationResponse doGenerateVideo(VideoContext videoContext) { + ChatModelVo model = videoContext.getChatModelVo(); + ObjectNode payload = OpenAiMediaSupport.OBJECT_MAPPER.createObjectNode(); + payload.put("model", model.getModelName()); + payload.put("prompt", videoContext.getPrompt()); + if (StrUtil.isNotBlank(videoContext.getSize())) { + payload.put("size", videoContext.getSize()); + } + if (videoContext.getSeconds() != null) { + payload.put("seconds", videoContext.getSeconds()); + } + if (StrUtil.isNotBlank(videoContext.getQuality())) { + payload.put("quality", videoContext.getQuality()); + } + + try { + return toResponse(postJson(model, "/videos", payload.toString())); + } catch (IOException e) { + throw new RuntimeException("视频生成任务创建失败: " + e.getMessage(), e); + } + } + + @Override + protected MediaGenerationResponse doRetrieveVideo(VideoContext videoContext) { + if (StrUtil.isBlank(videoContext.getVideoId())) { + throw new IllegalArgumentException("videoId不能为空"); + } + try { + return toResponse(getJson(videoContext.getChatModelVo(), "/videos/" + videoContext.getVideoId())); + } catch (IOException e) { + throw new RuntimeException("视频生成任务查询失败: " + e.getMessage(), e); + } + } + + @Override + public String getProviderName() { + return ChatModeType.OPEN_AI.getCode(); + } + + private MediaGenerationResponse toResponse(String raw) throws IOException { + JsonNode root = OpenAiMediaSupport.OBJECT_MAPPER.readTree(raw); + return MediaGenerationResponse.builder() + .type("video") + .mimeType("video/mp4") + .id(OpenAiMediaSupport.text(root, "id")) + .status(OpenAiMediaSupport.text(root, "status")) + .url(firstAvailableUrl(root)) + .rawResponse(raw) + .build(); + } + + private String firstAvailableUrl(JsonNode root) { + String url = OpenAiMediaSupport.text(root, "url"); + if (StrUtil.isNotBlank(url)) { + return url; + } + JsonNode data = root.path("data"); + if (data.isArray() && !data.isEmpty()) { + JsonNode first = data.get(0); + url = OpenAiMediaSupport.text(first, "url"); + if (StrUtil.isNotBlank(url)) { + return url; + } + } + return null; + } + + private String postJson(ChatModelVo model, String path, String jsonBody) throws IOException { + Request request = new Request.Builder() + .url(OpenAiMediaSupport.endpoint(model.getApiHost(), path)) + .addHeader("Authorization", "Bearer " + model.getApiKey()) + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(jsonBody, OpenAiMediaSupport.JSON)) + .build(); + return execute(request, "OpenAI Videos API调用失败"); + } + + private String getJson(ChatModelVo model, String path) throws IOException { + Request request = new Request.Builder() + .url(OpenAiMediaSupport.endpoint(model.getApiHost(), path)) + .addHeader("Authorization", "Bearer " + model.getApiKey()) + .get() + .build(); + return execute(request, "OpenAI Videos API查询失败"); + } + + private String execute(Request request, String errorMessage) throws IOException { + try (Response response = okHttpClient.newCall(request).execute()) { + ResponseBody body = response.body(); + String responseText = body == null ? "" : body.string(); + if (!response.isSuccessful()) { + throw new IllegalArgumentException(errorMessage + ": " + response.code() + " - " + responseText); + } + return responseText; + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java index 9733af8b..53ac9db4 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/enums/ChatModeTypeTest.java @@ -1,5 +1,6 @@ package org.ruoyi.enums; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; @@ -7,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.*; /** * Unit tests for ChatModeType enum */ +@Tag("dev") class ChatModeTypeTest { @Test @@ -15,6 +17,18 @@ class ChatModeTypeTest { assertNotNull(minimax); } + @Test + void difyEnumExists() { + ChatModeType dify = ChatModeType.DIFY; + assertNotNull(dify); + } + + @Test + void cozeEnumExists() { + ChatModeType coze = ChatModeType.COZE; + assertNotNull(coze); + } + @Test void minimaxCode_isMinimax() { assertEquals("minimax", ChatModeType.MINIMAX.getCode()); @@ -25,6 +39,16 @@ class ChatModeTypeTest { assertEquals("MiniMax", ChatModeType.MINIMAX.getDescription()); } + @Test + void difyDescription_isDify() { + assertEquals("Dify", ChatModeType.DIFY.getDescription()); + } + + @Test + void cozeDescription_isCoze() { + assertEquals("Coze", ChatModeType.COZE.getDescription()); + } + @Test void allProviders_haveUniqueCode() { ChatModeType[] values = ChatModeType.values(); diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImplTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImplTest.java new file mode 100644 index 00000000..89811f3e --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImplTest.java @@ -0,0 +1,47 @@ +package org.ruoyi.service.chat.impl.provider; + +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.StreamingChatModel; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@Tag("dev") +class CozeChatServiceImplTest { + + private final CozeChatServiceImpl service = new CozeChatServiceImpl(); + + @Test + void getProviderName_isCoze() { + assertEquals(ChatModeType.COZE.getCode(), service.getProviderName()); + } + + @Test + void buildStreamingChatModel_returnsModel() { + StreamingChatModel model = service.buildStreamingChatModel(modelVo(), new ChatRequest()); + + assertNotNull(model); + } + + @Test + void buildChatModel_returnsModel() { + ChatModel model = service.buildChatModel(modelVo()); + + assertNotNull(model); + } + + private ChatModelVo modelVo() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setProviderCode(ChatModeType.COZE.getCode()); + modelVo.setModelName("7480000000000000000"); + modelVo.setApiHost("https://api.coze.cn"); + modelVo.setApiKey("pat-test"); + return modelVo; + } +} + diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImplTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImplTest.java new file mode 100644 index 00000000..04d2403b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImplTest.java @@ -0,0 +1,46 @@ +package org.ruoyi.service.chat.impl.provider; + +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.StreamingChatModel; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.enums.ChatModeType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@Tag("dev") +class DifyChatServiceImplTest { + + private final DifyChatServiceImpl service = new DifyChatServiceImpl(); + + @Test + void getProviderName_isDify() { + assertEquals(ChatModeType.DIFY.getCode(), service.getProviderName()); + } + + @Test + void buildStreamingChatModel_returnsModel() { + StreamingChatModel model = service.buildStreamingChatModel(modelVo(), new ChatRequest()); + + assertNotNull(model); + } + + @Test + void buildChatModel_returnsModel() { + ChatModel model = service.buildChatModel(modelVo()); + + assertNotNull(model); + } + + private ChatModelVo modelVo() { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setProviderCode(ChatModeType.DIFY.getCode()); + modelVo.setModelName("dify-chat"); + modelVo.setApiHost("https://api.dify.ai/v1"); + modelVo.setApiKey("app-test"); + return modelVo; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImplTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImplTest.java new file mode 100644 index 00000000..2bde2b94 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImplTest.java @@ -0,0 +1,44 @@ +package org.ruoyi.service.image.provider; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AtlasImageGenerationServiceImplTest { + + @Test + void shouldUseGptImageSupportedLandscapeSize() { + String model = "openai/gpt-image-2/text-to-image"; + + assertEquals("1536x1024", AtlasImageGenerationServiceImpl.resolveSize(model, "3:2")); + assertEquals("1536x1024", AtlasImageGenerationServiceImpl.resolveSize(model, "1152*768")); + assertEquals("1536x1024", AtlasImageGenerationServiceImpl.resolveSize(model, "16:9")); + } + + @Test + void shouldUseGptImageSupportedPortraitAndSquareSizes() { + String model = "openai/gpt-image-2/edit"; + + assertEquals("1024x1536", AtlasImageGenerationServiceImpl.resolveSize(model, "9:16")); + assertEquals("1024x1024", AtlasImageGenerationServiceImpl.resolveSize(model, "1:1")); + } + + @Test + void shouldKeepLegacyAtlasSizeFormatForOtherModels() { + String model = "microsoft/mai-image-2.5-flash/text-to-image"; + + assertEquals("1152*768", AtlasImageGenerationServiceImpl.resolveSize(model, "3:2")); + assertEquals("1360*768", AtlasImageGenerationServiceImpl.resolveSize(model, "16:9")); + } + + @Test + void shouldDetectGptImageModels() { + assertTrue(AtlasImageGenerationServiceImpl.isGptImageModel("openai/gpt-image-2/text-to-image")); + assertTrue(AtlasImageGenerationServiceImpl.isGptImageModel("openai/gpt-image-2/edit")); + assertFalse(AtlasImageGenerationServiceImpl.isGptImageModel("microsoft/mai-image-2.5/edit")); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java new file mode 100644 index 00000000..d00de58a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java @@ -0,0 +1,51 @@ +package org.ruoyi.service.shortdrama.composition; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Tag; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class FfmpegCommandBuilderTest { + + @Test + void keepsWindowsPathsAsIndividualProcessBuilderArguments() { + FfmpegCompositionProperties properties = new FfmpegCompositionProperties(); + properties.setFfmpegPath("C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe"); + FfmpegCommandBuilder builder = new FfmpegCommandBuilder(properties); + CompositionSource source = new CompositionSource( + Path.of("C:\\video input\\clip & one.mp4"), + 5.0 + ); + CompositionSpec spec = new CompositionSpec( + List.of(source), + TransitionType.NONE, + 0, + AspectRatio.PORTRAIT + ); + FfmpegFilterGraph graph = new FfmpegFilterGraph( + "[0:v]null[v0]", + "v0", + "a0", + 5, + new VideoCanvas(1080, 1920, 30) + ); + Path filter = Path.of("C:\\job dir\\filter.txt"); + Path output = Path.of("C:\\job dir\\output.mp4"); + + List command = builder.build(spec, graph, filter, output); + + assertEquals(properties.getFfmpegPath(), command.get(0)); + assertTrue(command.contains(source.path().toString())); + assertTrue(command.contains(filter.toAbsolutePath().normalize().toString())); + assertTrue(command.contains(output.toAbsolutePath().normalize().toString())); + assertFalse(command.contains("cmd.exe")); + assertFalse(command.contains("/c")); + assertFalse(command.stream().anyMatch(argument -> argument.startsWith("\"") || argument.endsWith("\""))); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java new file mode 100644 index 00000000..0bebc075 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java @@ -0,0 +1,188 @@ +package org.ruoyi.service.shortdrama.composition; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Tag; + +import java.math.BigDecimal; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class FfmpegFilterGraphBuilderTest { + + private final FfmpegCompositionProperties properties = new FfmpegCompositionProperties(); + private final FfmpegFilterGraphBuilder builder = new FfmpegFilterGraphBuilder(properties); + + @Test + void buildsHardCutGraphWithNormalizedSilentAudio() { + CompositionSpec spec = spec(TransitionType.NONE, 0, AspectRatio.PORTRAIT, 2); + + FfmpegFilterGraph graph = builder.build(spec, List.of( + new MediaInfo(5, 1280, 720, false, 2), + new MediaInfo(6, 720, 1280, true, 1) + )); + + assertEquals(11, graph.expectedDurationSeconds(), 0.000001); + assertEquals(new VideoCanvas(1080, 1920, 30), graph.canvas()); + assertEquals("vout", graph.videoLabel()); + assertEquals("aout", graph.audioLabel()); + assertTrue(graph.value().contains("scale=1080:1920:force_original_aspect_ratio=decrease")); + assertTrue(graph.value().contains("pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black")); + assertTrue(graph.value().contains("anullsrc=r=48000:cl=stereo")); + assertTrue(graph.value().contains("[0:2]scale=1080:1920")); + assertTrue(graph.value().contains("[1:1]scale=1080:1920")); + assertTrue(graph.value().contains("[1:a:0]aresample=48000")); + assertTrue(graph.value().contains("[v0][a0][v1][a1]concat=n=2:v=1:a=1[vout][aout]")); + } + + @Test + void chainsDissolveOffsetsAgainstCurrentCompositeDuration() { + CompositionSpec spec = spec(TransitionType.DISSOLVE, 0.5, AspectRatio.LANDSCAPE, 3); + + FfmpegFilterGraph graph = builder.build(spec, List.of( + new MediaInfo(5, 1920, 1080, true, 0), + new MediaInfo(6, 1920, 1080, true, 0), + new MediaInfo(4, 1920, 1080, false, 0) + )); + + assertEquals(14, graph.expectedDurationSeconds(), 0.000001); + assertEquals(new VideoCanvas(1920, 1080, 30), graph.canvas()); + assertTrue(graph.value().contains("xfade=transition=dissolve:duration=0.5:offset=4.5[vx1]")); + assertTrue(graph.value().contains("[vx1][v2]xfade=transition=dissolve:duration=0.5:offset=10[vx2]")); + assertTrue(graph.value().contains("acrossfade=d=0.5:c1=tri:c2=tri")); + } + + @Test + void mapsFadeAndSlideToDistinctFfmpegTransitions() { + FfmpegFilterGraph fade = builder.build( + spec(TransitionType.FADE, 0.5, AspectRatio.SQUARE, 2), + List.of(new MediaInfo(3, 512, 512, false, 0), new MediaInfo(3, 512, 512, false, 0)) + ); + FfmpegFilterGraph slide = builder.build( + spec(TransitionType.SLIDE, 0.5, AspectRatio.SQUARE, 2), + List.of(new MediaInfo(3, 512, 512, false, 0), new MediaInfo(3, 512, 512, false, 0)) + ); + + assertTrue(fade.value().contains("xfade=transition=fadeblack")); + assertTrue(slide.value().contains("xfade=transition=slideleft")); + } + + @Test + void rejectsTransitionLongerThanAnAdjacentClip() { + CompositionSpec spec = spec(TransitionType.DISSOLVE, 1, AspectRatio.PORTRAIT, 2); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> builder.build( + spec, + List.of(new MediaInfo(0.5, 720, 1280, false, 0), new MediaInfo(3, 720, 1280, false, 0)) + )); + + assertTrue(error.getMessage().contains("too long")); + } + + @Test + void rejectsOverlappingTransitionsInsideAMiddleClip() { + CompositionSpec spec = spec(TransitionType.DISSOLVE, 0.5, AspectRatio.PORTRAIT, 3); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> builder.build( + spec, + List.of( + new MediaInfo(3, 720, 1280, false, 0), + new MediaInfo(0.999, 720, 1280, false, 0), + new MediaInfo(3, 720, 1280, false, 0) + ) + )); + + assertTrue(error.getMessage().contains("overlap")); + } + + @Test + void acceptsTouchingTransitionWindowsInsideAMiddleClip() { + CompositionSpec spec = spec(TransitionType.DISSOLVE, 0.5, AspectRatio.PORTRAIT, 3); + + FfmpegFilterGraph graph = builder.build(spec, List.of( + new MediaInfo(3, 720, 1280, false, 0), + new MediaInfo(1, 720, 1280, false, 0), + new MediaInfo(3, 720, 1280, false, 0) + )); + + assertEquals(6, graph.expectedDurationSeconds(), 0.000001); + } + + @Test + void rejectsTransitionDurationsWithMoreThanThreeDecimalPlaces() { + CompositionSpec spec = spec(TransitionType.DISSOLVE, 0.1234, AspectRatio.PORTRAIT, 2); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> builder.build( + spec, + List.of( + new MediaInfo(3, 720, 1280, false, 0), + new MediaInfo(3, 720, 1280, false, 0) + ) + )); + + assertTrue(error.getMessage().contains("3 decimal places")); + } + + @Test + void requiresAtLeastOneFrameForATransition() { + CompositionSpec tooShort = spec(TransitionType.DISSOLVE, 0.033, AspectRatio.PORTRAIT, 2); + List media = List.of( + new MediaInfo(3, 720, 1280, false, 0), + new MediaInfo(3, 720, 1280, false, 0) + ); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> builder.build(tooShort, media) + ); + assertTrue(error.getMessage().contains("one output frame")); + + FfmpegFilterGraph graph = builder.build( + spec(TransitionType.DISSOLVE, 0.034, AspectRatio.PORTRAIT, 2), + media + ); + assertTrue(graph.value().contains("duration=0.034")); + } + + @Test + void usesConfiguredMaximumTransitionDuration() { + properties.setMaxTransitionSeconds(0.75); + CompositionSpec spec = spec(TransitionType.DISSOLVE, 0.751, AspectRatio.PORTRAIT, 2); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> builder.build( + spec, + List.of( + new MediaInfo(3, 720, 1280, false, 0), + new MediaInfo(3, 720, 1280, false, 0) + ) + )); + + assertTrue(error.getMessage().contains("0.75 seconds")); + } + + @Test + void normalizesThePersistedAndExecutedTransitionValueWithoutRounding() { + BigDecimal normalized = properties.normalizeTransitionDuration( + TransitionType.DISSOLVE, + new BigDecimal("0.500") + ); + + assertEquals(new BigDecimal("0.5"), normalized); + } + + private static CompositionSpec spec( + TransitionType transition, + double duration, + AspectRatio ratio, + int sourceCount + ) { + List sources = java.util.stream.IntStream.range(0, sourceCount) + .mapToObj(index -> new CompositionSource(Path.of("clip-" + index + ".mp4"), null)) + .toList(); + return new CompositionSpec(sources, transition, duration, ratio); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegMediaProbeTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegMediaProbeTest.java new file mode 100644 index 00000000..a53c51eb --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegMediaProbeTest.java @@ -0,0 +1,105 @@ +package org.ruoyi.service.shortdrama.composition; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Tag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class FfmpegMediaProbeTest { + + @Test + void parsesVideoAudioDurationAndRotation() { + String json = """ + { + "streams": [ + { + "index": 0, + "codec_type": "video", + "width": 600, + "height": 600, + "duration": "5.200", + "disposition": {"attached_pic": 1} + }, + { + "index": 2, + "codec_type": "video", + "width": 1920, + "height": 1080, + "duration": "5.125", + "disposition": {"attached_pic": 0}, + "side_data_list": [{"rotation": -90}] + }, + {"index": 3, "codec_type": "audio", "duration": "5.100"} + ], + "format": {"duration": "5.200"} + } + """; + + MediaInfo info = FfmpegMediaProbe.parseProbeOutput(json, null); + + assertEquals(5.125, info.durationSeconds(), 0.000001); + assertEquals(1080, info.width()); + assertEquals(1920, info.height()); + assertTrue(info.hasAudio()); + assertEquals(2, info.videoStreamIndex()); + } + + @Test + void usesFallbackDurationAndRecognizesMissingAudio() { + String json = """ + { + "streams": [ + { + "index": 1, + "codec_type": "video", + "width": 720, + "height": 1280, + "duration": "N/A", + "disposition": {"attached_pic": 0} + } + ], + "format": {"duration": "N/A"} + } + """; + + MediaInfo info = FfmpegMediaProbe.parseProbeOutput(json, 6.0); + + assertEquals(6, info.durationSeconds(), 0.000001); + assertFalse(info.hasAudio()); + assertEquals(1, info.videoStreamIndex()); + } + + @Test + void rejectsInputsWithoutVideoStreams() { + String json = """ + {"streams": [{"codec_type": "audio"}], "format": {"duration": "3"}} + """; + + assertThrows(MediaProcessException.class, () -> FfmpegMediaProbe.parseProbeOutput(json, null)); + } + + @Test + void rejectsVideoStreamsWithoutAnAbsoluteIndex() { + String json = """ + { + "streams": [{ + "codec_type": "video", + "width": 720, + "height": 1280, + "duration": "3", + "disposition": {"attached_pic": 0} + }] + } + """; + + MediaProcessException error = assertThrows( + MediaProcessException.class, + () -> FfmpegMediaProbe.parseProbeOutput(json, null) + ); + assertTrue(error.getMessage().contains("stream index")); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegProcessRunnerTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegProcessRunnerTest.java new file mode 100644 index 00000000..de5685a9 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegProcessRunnerTest.java @@ -0,0 +1,26 @@ +package org.ruoyi.service.shortdrama.composition; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Tag; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Tag("dev") +class FfmpegProcessRunnerTest { + + @Test + void readsOnlyTheConfiguredOutputTail() throws Exception { + Path log = Path.of("target", "test-process-" + UUID.randomUUID() + ".log"); + try { + Files.writeString(log, "0123456789", StandardCharsets.UTF_8); + assertEquals("56789", FfmpegProcessRunner.readTail(log, 5)); + } finally { + Files.deleteIfExists(log); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/download/SafeVideoSourceDownloaderTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/download/SafeVideoSourceDownloaderTest.java new file mode 100644 index 00000000..761f171b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/download/SafeVideoSourceDownloaderTest.java @@ -0,0 +1,89 @@ +package org.ruoyi.service.shortdrama.download; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.net.Inet6Address; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class SafeVideoSourceDownloaderTest { + + @Test + void rejectsPrivateAndSpecialUseIpv4Addresses() throws Exception { + assertFalse(isPublic("127.0.0.1")); + assertFalse(isPublic("10.0.0.1")); + assertFalse(isPublic("100.64.0.1")); + assertFalse(isPublic("169.254.169.254")); + assertFalse(isPublic("172.16.0.1")); + assertFalse(isPublic("192.168.1.1")); + assertFalse(isPublic("198.18.0.1")); + assertFalse(isPublic("224.0.0.1")); + } + + @Test + void identifiesTunFakeIpRangeWithoutTreatingItAsPublic() throws Exception { + InetAddress fakeIp = InetAddress.getByName("198.18.0.226"); + + assertTrue(SafeVideoSourceDownloader.isFakeProxyAddress(fakeIp)); + assertFalse(SafeVideoSourceDownloader.isPublicAddress(fakeIp)); + assertFalse(SafeVideoSourceDownloader.isFakeProxyAddress(InetAddress.getByName("192.168.1.1"))); + } + @Test + void rejectsPrivateAndSpecialUseIpv6Addresses() throws Exception { + assertFalse(isPublic("::1")); + assertFalse(isPublic("fe80::1")); + assertFalse(isPublic("fd00::1")); + assertFalse(isPublic("100::1")); + assertFalse(isPublic("2001::1")); + assertFalse(isPublic("2001:2::1")); + assertFalse(isPublic("2001:10::1")); + assertFalse(isPublic("2001:20::1")); + assertFalse(isPublic("2001:db8::1")); + assertFalse(isPublic("2002:0808:0808::1")); + assertFalse(isPublic("2620:4f:8000::1")); + assertFalse(isPublic("3ffe::1")); + assertFalse(isPublic("3fff::1")); + assertFalse(isPublic("5f00::1")); + } + + @Test + void rejectsIpv4EmbeddingAndTranslationPrefixes() throws Exception { + assertFalse(isPublic("::8.8.8.8")); + assertFalse(isPublic("64:ff9b::8.8.8.8")); + assertFalse(isPublic("64:ff9b:1::8.8.8.8")); + assertFalse(isPublic(rawMappedIpv6(8, 8, 8, 8))); + assertFalse(isPublic(rawMappedIpv6(127, 0, 0, 1))); + } + + @Test + void acceptsRepresentativePublicAddresses() throws Exception { + assertTrue(isPublic("8.8.8.8")); + assertTrue(isPublic("1.1.1.1")); + assertTrue(isPublic("2001:200::1")); + assertTrue(isPublic("2001:4860:4860::8888")); + assertTrue(isPublic("2606:4700:4700::1111")); + } + + private static boolean isPublic(String value) throws Exception { + return SafeVideoSourceDownloader.isPublicAddress(InetAddress.getByName(value)); + } + + private static boolean isPublic(InetAddress address) { + return SafeVideoSourceDownloader.isPublicAddress(address); + } + + private static Inet6Address rawMappedIpv6(int a, int b, int c, int d) throws Exception { + byte[] bytes = new byte[16]; + bytes[10] = (byte) 0xFF; + bytes[11] = (byte) 0xFF; + bytes[12] = (byte) a; + bytes[13] = (byte) b; + bytes[14] = (byte) c; + bytes[15] = (byte) d; + return Inet6Address.getByAddress(null, bytes, -1); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImplStoryboardMergeTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImplStoryboardMergeTest.java new file mode 100644 index 00000000..223e4f8c --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImplStoryboardMergeTest.java @@ -0,0 +1,96 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class ShortDramaServiceImplStoryboardMergeTest { + + @Test + void mergesPhaseOutputsByPanelNumberInsteadOfArrayIndex() { + ShortDramaServiceImpl.StoryboardPanelData panel1 = panel(1); + ShortDramaServiceImpl.StoryboardPanelData panel2 = panel(2); + List panels = List.of(panel1, panel2); + JsonNode rule1 = photographyRule(1, "暖色"); + JsonNode rule2 = photographyRule(2, "冷色"); + ShortDramaServiceImpl.ActingDirectionResult acting1 = actingDirection(1, "抬头"); + ShortDramaServiceImpl.ActingDirectionResult acting2 = actingDirection(2, "转身"); + + List merged = + ShortDramaServiceImpl.mergePanelsWithRules( + panels, + List.of(rule2, rule1), + List.of(acting2, acting1)); + + assertSame(panels, merged); + assertEquals(rule1.toString(), panel1.getPhotographyRules()); + assertEquals(rule2.toString(), panel2.getPhotographyRules()); + assertEquals(acting1.getCharacters().toString(), panel1.getActingNotes()); + assertEquals(acting2.getCharacters().toString(), panel2.getActingNotes()); + } + + @Test + void rejectsMergeWhenPhotographyRuleIsMissing() { + ShortDramaServiceImpl.StoryboardPanelData panel = panel(1); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> ShortDramaServiceImpl.mergePanelsWithRules( + List.of(panel), + List.of(), + List.of(actingDirection(1, "抬头")))); + + assertTrue(error.getMessage().contains("缺少摄影规则")); + assertNull(panel.getPhotographyRules()); + assertNull(panel.getActingNotes()); + } + + @Test + void rejectsMergeWhenActingDirectionIsMissingWithoutPartialChanges() { + ShortDramaServiceImpl.StoryboardPanelData panel = panel(1); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> ShortDramaServiceImpl.mergePanelsWithRules( + List.of(panel), + List.of(photographyRule(1, "暖色")), + List.of())); + + assertTrue(error.getMessage().contains("缺少表演指导")); + assertNull(panel.getPhotographyRules()); + assertNull(panel.getActingNotes()); + } + + private static ShortDramaServiceImpl.StoryboardPanelData panel(int panelNumber) { + ShortDramaServiceImpl.StoryboardPanelData panel = new ShortDramaServiceImpl.StoryboardPanelData(); + panel.setPanelNumber(panelNumber); + panel.setDescription("镜头 " + panelNumber); + return panel; + } + + private static JsonNode photographyRule(int panelNumber, String colorTone) { + ObjectNode rule = JsonNodeFactory.instance.objectNode(); + rule.put("panel_number", panelNumber); + rule.put("color_tone", colorTone); + return rule; + } + + private static ShortDramaServiceImpl.ActingDirectionResult actingDirection(int panelNumber, String actingText) { + ObjectNode note = JsonNodeFactory.instance.objectNode(); + note.put("name", "角色" + panelNumber); + note.put("acting", actingText); + ShortDramaServiceImpl.ActingDirectionResult result = new ShortDramaServiceImpl.ActingDirectionResult(); + result.setPanelNumber(panelNumber); + result.setCharacters(JsonNodeFactory.instance.arrayNode().add(note)); + return result; + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/ruoyi/system/service/impl/SysOssServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/ruoyi/system/service/impl/SysOssServiceImpl.java index 61accdb8..496a2ffc 100644 --- a/ruoyi-modules/ruoyi-system/src/main/java/org/ruoyi/system/service/impl/SysOssServiceImpl.java +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/ruoyi/system/service/impl/SysOssServiceImpl.java @@ -41,6 +41,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.Cacheable; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.io.File; @@ -205,6 +207,11 @@ public class SysOssServiceImpl implements ISysOssService, OssService { storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong); } + @Override + public void downloadFile(Long ossId, HttpServletResponse response) throws IOException { + download(ossId, response); + } + /** * 上传 MultipartFile 到对象存储服务,并保存文件信息到数据库 * @@ -242,6 +249,18 @@ public class SysOssServiceImpl implements ISysOssService, OssService { return BeanUtil.toBean(sysOssVo, OssDTO.class); } + @Override + public OssDTO uploadFile(File file) { + SysOssVo sysOssVo = upload(file); + return BeanUtil.toBean(sysOssVo, OssDTO.class); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Boolean deleteFile(Long ossId) { + return deleteWithValidByIds(List.of(ossId), false); + } + /** * 上传文件至千问百炼平台 * @param file 要上传的 MultipartFile 对象 @@ -319,12 +338,59 @@ public class SysOssServiceImpl implements ISysOssService, OssService { public SysOssVo upload(File file) { String originalfileName = file.getName(); String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length()); + // OssClient deletes the file after upload, so capture metadata first. + long fileSize = file.length(); OssClient storage = OssFactory.instance(); - UploadResult uploadResult = storage.uploadSuffix(file, suffix); + String configKey = storage.getConfigKey(); SysOssExt ext1 = new SysOssExt(); - ext1.setFileSize(file.length()); + ext1.setFileSize(fileSize); + UploadResult uploadResult = storage.uploadSuffix(file, suffix); // 保存文件信息 - return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult, ext1); + return buildResultEntity(originalfileName, suffix, configKey, uploadResult, ext1, storage); + } + + @NotNull + private SysOssVo buildResultEntity(String originalfileName, String suffix, String configKey, + UploadResult uploadResult, SysOssExt ext1, OssClient storage) { + Long insertedOssId = null; + try { + SysOss oss = new SysOss(); + oss.setUrl(uploadResult.getUrl()); + oss.setFileSuffix(suffix); + oss.setFileName(uploadResult.getFilename()); + oss.setOriginalName(originalfileName); + oss.setService(configKey); + oss.setExt1(JsonUtils.toJsonString(ext1)); + if (baseMapper.insert(oss) != 1) { + throw new ServiceException("保存OSS文件元数据失败"); + } + insertedOssId = oss.getOssId(); + if (insertedOssId == null) { + throw new ServiceException("保存OSS文件元数据失败"); + } + SysOssVo sysOssVo = MapstructUtils.convert(oss, SysOssVo.class); + return this.matchingUrl(sysOssVo); + } catch (RuntimeException | Error failure) { + cleanupFailedUpload(insertedOssId, uploadResult.getFilename(), storage, failure); + throw failure; + } + } + + private void cleanupFailedUpload(Long ossId, String objectKey, OssClient storage, Throwable failure) { + if (ossId != null) { + try { + baseMapper.deleteById(ossId); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + if (StringUtils.isNotBlank(objectKey)) { + try { + storage.delete(objectKey); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } } @NotNull From efc30c3ac816b147b6ddc43adce647ccf95bbe78 Mon Sep 17 00:00:00 2001 From: "ageerle@163.com" Date: Tue, 14 Jul 2026 22:30:10 +0800 Subject: [PATCH 02/29] =?UTF-8?q?docs:=20README=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=9F=AD=E5=89=A7=E6=9C=8D=E5=8A=A1=20ruoyi-drama=20=E6=BA=90?= =?UTF-8?q?=E7=A0=81=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- README.md | 1 + README_EN.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index e616e37c..81e60d0d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ | 🔧 后端服务 | [ruoyi-ai](https://github.com/ageerle/ruoyi-ai) | [ruoyi-ai](https://gitee.com/ageerle/ruoyi-ai) | [ruoyi-ai](https://gitcode.com/ageerle/ruoyi-ai) | | 🎨 用户前端 | [ruoyi-web](https://github.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitee.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitcode.com/ageerle/ruoyi-web) | | 🛠️ 管理后台 | [ruoyi-admin](https://github.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitee.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitcode.com/ageerle/ruoyi-admin) | +| 🎬 短剧服务 | [ruoyi-drama](https://github.com/ageerle/ruoyi-drama) | [ruoyi-drama](https://gitee.com/ageerle/ruoyi-drama) | | ### 合作项目 | 项目名称 | GitHub 仓库 | Gitee 仓库 diff --git a/README_EN.md b/README_EN.md index 8865116b..428f1a33 100644 --- a/README_EN.md +++ b/README_EN.md @@ -47,6 +47,7 @@ | 🔧 Backend | [ruoyi-ai](https://github.com/ageerle/ruoyi-ai) | [ruoyi-ai](https://gitee.com/ageerle/ruoyi-ai) | [ruoyi-ai](https://gitcode.com/ageerle/ruoyi-ai) | | 🎨 User Frontend | [ruoyi-web](https://github.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitee.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitcode.com/ageerle/ruoyi-web) | | 🛠️ Admin Panel | [ruoyi-admin](https://github.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitee.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitcode.com/ageerle/ruoyi-admin) | +| 🎬 Drama | [ruoyi-drama](https://github.com/ageerle/ruoyi-drama) | [ruoyi-drama](https://gitee.com/ageerle/ruoyi-drama) | | ### Partner Projects | Project Name | GitHub Repository | Gitee Repository | From e7b7d1d0844726c82517418e54875055b0e0aecb Mon Sep 17 00:00:00 2001 From: "ageerle@163.com" Date: Wed, 15 Jul 2026 10:52:15 +0800 Subject: [PATCH 03/29] =?UTF-8?q?=E6=9B=B4=E6=96=B0README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- README.md | 8 ++--- docs/image/wx06.png | Bin 130248 -> 114941 bytes docs/script/sql/ruoyi-ai-v3_mysql8.sql | 1 + .../chat/domain/bo/chat/ModelBatchKeyBo.java | 30 ++++++++++++++++++ .../chat/service/chat/IChatModelService.java | 9 ++++++ .../controller/chat/ChatModelController.java | 12 +++++++ .../chat/impl/ChatModelServiceImpl.java | 16 ++++++++++ 7 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ModelBatchKeyBo.java diff --git a/README.md b/README.md index 81e60d0d..26f77a4f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ | 🔧 后端服务 | [ruoyi-ai](https://github.com/ageerle/ruoyi-ai) | [ruoyi-ai](https://gitee.com/ageerle/ruoyi-ai) | [ruoyi-ai](https://gitcode.com/ageerle/ruoyi-ai) | | 🎨 用户前端 | [ruoyi-web](https://github.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitee.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitcode.com/ageerle/ruoyi-web) | | 🛠️ 管理后台 | [ruoyi-admin](https://github.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitee.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitcode.com/ageerle/ruoyi-admin) | -| 🎬 短剧服务 | [ruoyi-drama](https://github.com/ageerle/ruoyi-drama) | [ruoyi-drama](https://gitee.com/ageerle/ruoyi-drama) | | +| 🎬 短剧平台 | [ruoyi-drama](https://github.com/ageerle/ruoyi-drama) | [ruoyi-drama](https://gitee.com/ageerle/ruoyi-drama) | [ruoyi-drama](https://gitcode.com/ageerle/ruoyi-drama) | ### 合作项目 | 项目名称 | GitHub 仓库 | Gitee 仓库 @@ -214,15 +214,15 @@ docker-compose -f docker-compose-all.yaml restart [服务名] Atlas Cloud -[访问Atlas Cloud官网](https://www.atlascloud.ai?ref=89F97E) · [编程计划优惠](https://www.atlascloud.ai/console/coding-plan) +[访问Atlas Cloud官网](https://www.atlascloud.ai?ref=89F97E&utm_source=github&utm_campaign=ruoyi-drama) · [编程计划优惠](https://www.atlascloud.ai/console/coding-plan) 全模态 AI 推理平台,为开发者提供统一的 AI API,支持视频生成、图像生成和大语言模型。一次接入,即可访问 **300+ 精选模型**。 火山引擎 CodingPlan -[火山引擎 CodingPlan 开发者计划](https://www.volcengine.com/activity/codingplan?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ageerle-ruoyi-ai) -火山引擎是字节跳动旗下的云与 AI 服务平台,火山方舟提供豆包大模型、DeepSeek 等多种模型的 API 接入,为开发者提供一站式 AI 开发与推理服务。 +[注册即领2500万Tokens,立即前往](https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ageerle-ruoyi-ai) +享字节自研豆包模型+满血版开源 SOTA模型,覆盖文本、VLM、图像生成,全模态一站配齐:Seed-2.1、Seedream-5.0、GLM-5.2、DeepSeek等。不止编程、更能解决 Agent 复杂长程任务! ## 💬 社区交流 diff --git a/docs/image/wx06.png b/docs/image/wx06.png index 209d3a234fb339da6236cd621ffcaa686222534c..58eb01449a1690b3da3a6f9faf64240da65be003 100644 GIT binary patch literal 114941 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

eI8J&=(`FHr4DaYi!oV7c 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/EP|7FkZ37st*AKNk^?rAd?!r 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 literal 130248 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
T-XnaSvJ0M!{G1}i|?2f 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 diff --git a/docs/script/sql/ruoyi-ai-v3_mysql8.sql b/docs/script/sql/ruoyi-ai-v3_mysql8.sql index 1f68405f..3ddcc310 100644 --- a/docs/script/sql/ruoyi-ai-v3_mysql8.sql +++ b/docs/script/sql/ruoyi-ai-v3_mysql8.sql @@ -108,6 +108,7 @@ CREATE TABLE `chat_model` ( INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 'deepseek-v4-flash', 'deepseek', 'deepseek-v4-flash', NULL, 'Y', 'https://api.deepseek.com', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', '对话模型', 0); INSERT INTO `chat_model` VALUES (2007528268536287233, 'vector', 'embedding-3', 'zhipu', 'embedding-3', 2048, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-01-04 03:03:32', 1, '2026-03-15 19:18:51', '向量模型', 0); INSERT INTO `chat_model` VALUES (2045071617578237953, 'rerank', 'rerank', 'zhipu', 'rerank', NULL, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-04-17 17:27:24', 1, '2026-04-20 15:21:48', '重排序模型', 0); +INSERT INTO `chat_model` VALUES (2000585866022060003, 'chat', 'deepseek-ai/deepseek-v4-flash', 'atlas', 'deepseek-v4-flash', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', '对话模型', 0); INSERT INTO `chat_model` VALUES (2050000000000000001, 'video', 'bytedance/seedance-2.0/text-to-video', 'atlas', 'Seedance 2.0 文生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-17 18:39:20', 1, '2026-06-17 18:39:20', 'Atlas Cloud 视频模型 - Seedance 2.0', 0); INSERT INTO `chat_model` VALUES (2060622000000000001, 'image', 'openai/gpt-image-2/text-to-image', 'atlas', 'GPT-IMAGE-2 文生图', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 14:45:54', 1, '2026-06-22 14:45:54', 'Atlas Cloud 图片模型 - 文生图,支持三视图、角色设定、场景图', 0); INSERT INTO `chat_model` VALUES (2060622000000000002, 'image', 'openai/gpt-image-2/edit', 'atlas', 'GPT-IMAGE-2 图生图编辑', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 14:45:54', 1, '2026-06-22 14:45:54', 'Atlas Cloud 图片模型 - 图生图/编辑,支持基于参考图修改风格、纹理等', 0); diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ModelBatchKeyBo.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ModelBatchKeyBo.java new file mode 100644 index 00000000..683292f0 --- /dev/null +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ModelBatchKeyBo.java @@ -0,0 +1,30 @@ +package org.ruoyi.common.chat.domain.bo.chat; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; + +/** + * 按厂商批量更新密钥业务对象 + * + * @author ageerle + * @date 2025-12-14 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ModelBatchKeyBo extends BaseEntity { + + /** + * 模型供应商 + */ + @NotBlank(message = "厂商编码不能为空") + private String providerCode; + + /** + * 密钥 + */ + @NotBlank(message = "密钥不能为空") + private String apiKey; + +} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/chat/IChatModelService.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/chat/IChatModelService.java index 3c362a52..7f802a30 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/chat/IChatModelService.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/service/chat/IChatModelService.java @@ -73,4 +73,13 @@ public interface IChatModelService { * @return 是否删除成功 */ Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 按厂商批量更新密钥 + * + * @param providerCode 厂商编码 + * @param apiKey 密钥 + * @return 是否更新成功 + */ + Boolean updateApiKeyByProvider(String providerCode, String apiKey); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java index 565cd953..42d3b5fd 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.*; import cn.dev33.satoken.annotation.SaCheckPermission; import org.ruoyi.common.chat.service.chat.IChatModelService; import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo; +import org.ruoyi.common.chat.domain.bo.chat.ModelBatchKeyBo; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.common.core.utils.StringUtils; import org.ruoyi.enums.ChatModeType; @@ -121,6 +122,17 @@ public class ChatModelController extends BaseController { return toAjax(chatModelService.updateByBo(bo)); } + /** + * 按厂商批量更新密钥 + */ + @SaCheckPermission("system:model:edit") + @Log(title = "模型管理", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping("/batchKeyByProvider") + public R batchKeyByProvider(@Validated @RequestBody ModelBatchKeyBo bo) { + return toAjax(chatModelService.updateApiKeyByProvider(bo.getProviderCode(), bo.getApiKey())); + } + /** * 删除模型管理 * diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java index 0c2cf0c4..fd2f7d2c 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java @@ -10,6 +10,7 @@ import org.ruoyi.common.mybatis.core.page.TableDataInfo; import org.ruoyi.common.mybatis.core.page.PageQuery; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -149,4 +150,19 @@ public class ChatModelServiceImpl implements IChatModelService { } return baseMapper.deleteByIds(ids) > 0; } + + /** + * 按厂商批量更新密钥 + * + * @param providerCode 厂商编码 + * @param apiKey 密钥 + * @return 是否更新成功 + */ + @Override + public Boolean updateApiKeyByProvider(String providerCode, String apiKey) { + LambdaUpdateWrapper uw = Wrappers.lambdaUpdate(); + uw.set(ChatModel::getApiKey, apiKey) + .eq(ChatModel::getProviderCode, providerCode); + return baseMapper.update(null, uw) > 0; + } } From 96a1b741df6b995c9d3c0c309d88441121363756 Mon Sep 17 00:00:00 2001 From: "ageerle@163.com" Date: Wed, 15 Jul 2026 11:17:20 +0800 Subject: [PATCH 04/29] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=20Atlas=20?= =?UTF-8?q?=E9=A2=84=E6=B5=8B=E7=BB=93=E6=9E=9C=E6=9F=A5=E8=AF=A2=20404?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E5=89=8D=E7=AB=AF=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E5=88=B7=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas /model/prediction/{id} 对已过期或不存在的任务返回 404 (常见于更换 API Key 后残留的旧任务)。原实现直接抛异常,前端 轮询反复触发错误提示。改为 404 时返回 status=failed 的响应, 让上层走正常失败分支落库,前端识别后终止轮询。 Co-Authored-By: Claude --- .../ruoyi/service/media/AtlasPredictionService.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java index bdf4f616..3c79205d 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java @@ -36,6 +36,17 @@ public class AtlasPredictionService { try (Response response = okHttpClient.newCall(request).execute()) { ResponseBody body = response.body(); String responseText = body == null ? "" : body.string(); + // 404: 预测任务已过期或不存在(常见于更换 API Key 后残留的旧任务),按失败处理, + // 避免前端轮询反复触发异常提示。其余非 2xx 仍按异常抛出。 + if (response.code() == 404) { + log.warn("Atlas Cloud 预测任务不存在或已过期: predictionId={}, category={}", predictionId, model.getCategory()); + return MediaGenerationResponse.builder() + .type(mediaType(model.getCategory())) + .mimeType("image".equals(model.getCategory()) ? "image/png" : "video/mp4") + .status("failed") + .rawResponse(responseText) + .build(); + } if (!response.isSuccessful()) { throw new IllegalArgumentException("Atlas Cloud生成结果查询失败: " + response.code() + " - " + responseText); } From a3d82e092e3df005ba675c5d78c3eae881b1d6c0 Mon Sep 17 00:00:00 2001 From: Octopus Date: Wed, 15 Jul 2026 17:43:51 +0800 Subject: [PATCH 05/29] Refresh MiniMax models and protocol support (#313) Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com> --- README_EN.md | 28 ++++ docs/script/sql/ruoyi-ai-v3_mysql8.sql | 2 +- ruoyi-modules/ruoyi-chat/pom.xml | 6 + .../impl/provider/MinimaxServiceImpl.java | 98 ++++++++++- .../integration/MinimaxIntegrationTest.java | 54 +++--- .../impl/provider/MinimaxServiceImplTest.java | 158 ++++++++++++++---- 6 files changed, 272 insertions(+), 74 deletions(-) diff --git a/README_EN.md b/README_EN.md index 428f1a33..7f193a9d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -176,6 +176,34 @@ docker-compose -f docker-compose-all.yaml logs -f [service-name] docker-compose -f docker-compose-all.yaml restart [service-name] ``` +### MiniMax Configuration + +The built-in MiniMax provider accepts one API Host value and selects the matching protocol adapter. Use a Base URL from this table: + +| Region | OpenAI-compatible Base URL | Anthropic-compatible Base URL | +| --- | --- | --- | +| Global | `https://api.minimax.io/v1` | `https://api.minimax.io/anthropic` | +| China | `https://api.minimaxi.com/v1` | `https://api.minimaxi.com/anthropic` | + +For Anthropic-compatible requests, configure the Base URL ending in `/anthropic`. Do not append `/v1` or `/v1/messages`; the provider adapter derives the request path internally. + +| Model ID | Total context | Input modalities | Thinking | +| --- | ---: | --- | --- | +| `MiniMax-M3` | 1,000,000 tokens | Text, image, video | Adaptive or disabled | +| `MiniMax-M2.7` | 204,800 tokens | Text | Always on | + +Current pay-as-you-go prices are in USD per million tokens: + +| Model | Service tier and input range | Input | Output | Cache read | Cache write | +| --- | --- | ---: | ---: | ---: | ---: | +| `MiniMax-M3` | Standard, up to 512,000 input tokens | $0.30 | $1.20 | $0.06 | Not listed | +| `MiniMax-M3` | Standard, over 512,000 input tokens | $0.60 | $2.40 | $0.12 | Not listed | +| `MiniMax-M3` | Priority, up to 512,000 input tokens | $0.45 | $1.80 | $0.09 | Not listed | +| `MiniMax-M3` | Priority, over 512,000 input tokens | $0.90 | $3.60 | $0.18 | Not listed | +| `MiniMax-M2.7` | Standard | $0.30 | $1.20 | $0.06 | $0.375 | + +See the [official API overview](https://platform.minimax.io/docs/api-reference/api-overview) and [pay-as-you-go pricing](https://platform.minimax.io/docs/guides/pricing-paygo) for current details. + ## 📚 Documentation Want to learn more about installation, deployment, configuration, and secondary development? diff --git a/docs/script/sql/ruoyi-ai-v3_mysql8.sql b/docs/script/sql/ruoyi-ai-v3_mysql8.sql index 3ddcc310..8a659ade 100644 --- a/docs/script/sql/ruoyi-ai-v3_mysql8.sql +++ b/docs/script/sql/ruoyi-ai-v3_mysql8.sql @@ -153,7 +153,7 @@ INSERT INTO `chat_provider` VALUES (12, '智谱AI', 'zhipu', 'https://example-12 INSERT INTO `chat_provider` VALUES (13, '小米MIMO', 'xiaomi', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/18dd39365ce244e3ae5e030da036760e.png', '小米官方API', 'https://api.xiaomimimo.com/v1', '0', 3, 103, '2026-04-19 12:48:24', '1', '1', '2026-04-19 13:14:22', '小米官方API', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (14, '阿里云百炼', 'qianwen', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', '阿里云百炼大模型服务', 'https://dashscope.aliyuncs.com', '0', 2, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:49:13', '阿里云厂商', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (15, 'Atlas Cloud', 'atlas', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/atlascloud.png', '全模态AI推理平台', 'https://api.atlascloud.ai/v1', '0', 5, 103, '2025-12-15 23:13:42', '1', '1', '2026-02-25 20:49:01', 'Atlas Cloud AI平台', NULL, '0', NULL, 0); -INSERT INTO `chat_provider` VALUES (16, 'MiniMax', 'minimax', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/fdc712e90e0e4d78b05862ad230884e5.png', 'MiniMax大模型服务,支持M2.7、M2.5等模型', 'https://api.minimax.io/v1', '0', 6, 103, '2026-04-19 12:50:12', '1', '1', '2026-04-19 13:14:59', 'MiniMax厂商', NULL, '0', NULL, 0); +INSERT INTO `chat_provider` VALUES (16, 'MiniMax', 'minimax', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/fdc712e90e0e4d78b05862ad230884e5.png', 'MiniMax model service supporting MiniMax-M3 and MiniMax-M2.7', 'https://api.minimax.io/v1', '0', 6, 103, '2026-04-19 12:50:12', '1', '1', '2026-04-19 13:14:59', 'MiniMax provider', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (17, 'ollama', 'ollama', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', 'ollama大模型', 'http://127.0.0.1:11434', '0', 7, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:48:48', 'ollama厂商', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (18, '自定义厂商', 'custom_api', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/c1a8e122510f4e2f90deb36958af710b.png', 'OPENAI兼容格式', '自定义', '0', 8, 103, '2026-04-19 12:35:57', '1', '1', '2026-04-19 13:17:20', 'OPENAI兼容格式', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (2070700000000000001, 'Dify', 'dify', NULL, 'Dify 应用 API 服务', 'https://api.dify.ai/v1', '0', 9, 103, '2026-07-14 11:03:44', '1', '1', '2026-07-14 11:03:44', 'Dify 应用 API 服务商', NULL, '0', NULL, 0); diff --git a/ruoyi-modules/ruoyi-chat/pom.xml b/ruoyi-modules/ruoyi-chat/pom.xml index 566d74be..144240a2 100644 --- a/ruoyi-modules/ruoyi-chat/pom.xml +++ b/ruoyi-modules/ruoyi-chat/pom.xml @@ -35,6 +35,12 @@ ${langchain4j.version} + + dev.langchain4j + langchain4j-anthropic + ${langchain4j.version} + + dev.langchain4j langchain4j-ollama diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java index d83d9900..5012e47a 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java @@ -1,6 +1,10 @@ package org.ruoyi.service.chat.impl.provider; +import dev.langchain4j.model.anthropic.AnthropicChatModel; +import dev.langchain4j.model.anthropic.AnthropicStreamingChatModel; +import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import lombok.extern.slf4j.Slf4j; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; @@ -10,13 +14,16 @@ import org.ruoyi.observability.MyChatModelListener; import org.ruoyi.service.chat.AbstractChatService; import org.springframework.stereotype.Service; +import java.net.URI; +import java.time.Duration; import java.util.List; +import java.util.Map; /** - * MiniMax服务调用 + * MiniMax chat service. *

- * MiniMax提供OpenAI兼容的API接口,支持MiniMax-M2.7、MiniMax-M2.5等模型。 - * API地址:https://api.minimax.io/v1 + * Supports MiniMax-M3 and MiniMax-M2.7 through the OpenAI-compatible and + * Anthropic-compatible APIs in both global and China regions. * * @author octopus * @date 2026/3/21 @@ -25,14 +32,57 @@ import java.util.List; @Slf4j public class MinimaxServiceImpl implements AbstractChatService { + private static final String MINIMAX_M3 = "MiniMax-M3"; + private static final String ANTHROPIC_PATH_SUFFIX = "/anthropic"; + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(120); + @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { - return OpenAiStreamingChatModel.builder() - .baseUrl(chatModelVo.getApiHost()) + String baseUrl = normalizeBaseUrl(chatModelVo.getApiHost()); + boolean thinkingEnabled = Boolean.TRUE.equals(chatRequest.getEnableThinking()); + String thinkingType = thinkingType(chatModelVo.getModelName(), thinkingEnabled); + + if (isAnthropicBaseUrl(baseUrl)) { + return AnthropicStreamingChatModel.builder() + .baseUrl(toAnthropicClientBaseUrl(baseUrl)) + .apiKey(chatModelVo.getApiKey()) + .modelName(chatModelVo.getModelName()) + .timeout(DEFAULT_TIMEOUT) + .listeners(List.of(new MyChatModelListener())) + .thinkingType(thinkingType) + .returnThinking(thinkingEnabled) + .build(); + } + + OpenAiStreamingChatModel.OpenAiStreamingChatModelBuilder builder = OpenAiStreamingChatModel.builder() + .baseUrl(baseUrl) .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) + .timeout(DEFAULT_TIMEOUT) .listeners(List.of(new MyChatModelListener())) - .returnThinking(chatRequest.getEnableThinking()) + .returnThinking(thinkingEnabled); + if (thinkingType != null) { + builder.customParameters(Map.of("thinking", Map.of("type", thinkingType))); + } + return builder.build(); + } + + @Override + public ChatModel buildChatModel(ChatModelVo chatModelVo) { + String baseUrl = normalizeBaseUrl(chatModelVo.getApiHost()); + if (isAnthropicBaseUrl(baseUrl)) { + return AnthropicChatModel.builder() + .baseUrl(toAnthropicClientBaseUrl(baseUrl)) + .apiKey(chatModelVo.getApiKey()) + .modelName(chatModelVo.getModelName()) + .timeout(DEFAULT_TIMEOUT) + .build(); + } + return OpenAiChatModel.builder() + .baseUrl(baseUrl) + .apiKey(chatModelVo.getApiKey()) + .modelName(chatModelVo.getModelName()) + .timeout(DEFAULT_TIMEOUT) .build(); } @@ -41,4 +91,40 @@ public class MinimaxServiceImpl implements AbstractChatService { return ChatModeType.MINIMAX.getCode(); } + static String thinkingType(String modelName, boolean thinkingEnabled) { + if (!MINIMAX_M3.equals(modelName)) { + return null; + } + return thinkingEnabled ? "adaptive" : "disabled"; + } + + private static String normalizeBaseUrl(String baseUrl) { + if (baseUrl == null || baseUrl.isBlank()) { + throw new IllegalArgumentException("MiniMax API Host must not be blank"); + } + String normalized = baseUrl.trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + URI uri; + try { + uri = URI.create(normalized); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("MiniMax API Host must be a valid URL", exception); + } + if (uri.getScheme() == null || uri.getHost() == null) { + throw new IllegalArgumentException("MiniMax API Host must be an absolute URL"); + } + return uri.toString(); + } + + private static boolean isAnthropicBaseUrl(String baseUrl) { + return URI.create(baseUrl).getPath().endsWith(ANTHROPIC_PATH_SUFFIX); + } + + private static String toAnthropicClientBaseUrl(String baseUrl) { + // LangChain4j appends /messages, while MiniMax exposes /anthropic/v1/messages. + return baseUrl + "/v1"; + } + } diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java index 7e052e6d..2e866d8e 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/integration/MinimaxIntegrationTest.java @@ -2,8 +2,9 @@ package org.ruoyi.integration; import dev.langchain4j.model.chat.StreamingChatModel; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.service.chat.impl.provider.MinimaxServiceImpl; @@ -26,45 +27,28 @@ class MinimaxIntegrationTest { apiKey = System.getenv("MINIMAX_API_KEY"); } - @Test - void buildStreamingChatModel_withRealApiKey_M27() { + @ParameterizedTest + @CsvSource({ + "https://api.minimax.io/v1, MiniMax-M3, false", + "https://api.minimax.io/v1, MiniMax-M2.7, true", + "https://api.minimaxi.com/v1, MiniMax-M3, true", + "https://api.minimaxi.com/v1, MiniMax-M2.7, true", + "https://api.minimax.io/anthropic, MiniMax-M3, false", + "https://api.minimax.io/anthropic, MiniMax-M2.7, true", + "https://api.minimaxi.com/anthropic, MiniMax-M3, true", + "https://api.minimaxi.com/anthropic, MiniMax-M2.7, true" + }) + void buildStreamingChatModel_withConfiguredApiKey( + String apiHost, String modelName, boolean enableThinking) { ChatModelVo modelVo = new ChatModelVo(); - modelVo.setApiHost("https://api.minimax.io/v1"); + modelVo.setApiHost(apiHost); modelVo.setApiKey(apiKey); - modelVo.setModelName("MiniMax-M2.7"); + modelVo.setModelName(modelName); ChatRequest request = new ChatRequest(); - request.setEnableThinking(false); + request.setEnableThinking(enableThinking); StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); - assertNotNull(model, "Should create streaming model with real API key"); - } - - @Test - void buildStreamingChatModel_withRealApiKey_M25() { - ChatModelVo modelVo = new ChatModelVo(); - modelVo.setApiHost("https://api.minimax.io/v1"); - modelVo.setApiKey(apiKey); - modelVo.setModelName("MiniMax-M2.5"); - - ChatRequest request = new ChatRequest(); - request.setEnableThinking(false); - - StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); - assertNotNull(model, "Should create streaming model with M2.5"); - } - - @Test - void buildStreamingChatModel_withRealApiKey_M25Highspeed() { - ChatModelVo modelVo = new ChatModelVo(); - modelVo.setApiHost("https://api.minimax.io/v1"); - modelVo.setApiKey(apiKey); - modelVo.setModelName("MiniMax-M2.5-highspeed"); - - ChatRequest request = new ChatRequest(); - request.setEnableThinking(false); - - StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); - assertNotNull(model, "Should create streaming model with M2.5-highspeed"); + assertNotNull(model, "Should create streaming model for " + modelName + " at " + apiHost); } } diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java index 697f52e2..ad621086 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImplTest.java @@ -1,12 +1,28 @@ package org.ruoyi.service.chat.impl.provider; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import dev.langchain4j.model.anthropic.AnthropicChatModel; +import dev.langchain4j.model.anthropic.AnthropicStreamingChatModel; +import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.model.openai.OpenAiChatRequestParameters; +import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + import static org.junit.jupiter.api.Assertions.*; /** @@ -14,6 +30,8 @@ import static org.junit.jupiter.api.Assertions.*; */ class MinimaxServiceImplTest { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private MinimaxServiceImpl minimaxService; @BeforeEach @@ -27,50 +45,126 @@ class MinimaxServiceImplTest { assertEquals(ChatModeType.MINIMAX.getCode(), minimaxService.getProviderName()); } - @Test - void buildStreamingChatModel_returnsNonNull() { - ChatModelVo modelVo = new ChatModelVo(); - modelVo.setApiHost("https://api.minimax.io/v1"); - modelVo.setApiKey("test-api-key"); - modelVo.setModelName("MiniMax-M2.7"); - + @ParameterizedTest + @CsvSource({ + "https://api.minimax.io/v1, MiniMax-M3, false, openai", + "https://api.minimax.io/v1, MiniMax-M2.7, true, openai", + "https://api.minimaxi.com/v1, MiniMax-M3, true, openai", + "https://api.minimaxi.com/v1, MiniMax-M2.7, true, openai", + "https://api.minimax.io/anthropic, MiniMax-M3, false, anthropic", + "https://api.minimax.io/anthropic, MiniMax-M2.7, true, anthropic", + "https://api.minimaxi.com/anthropic, MiniMax-M3, true, anthropic", + "https://api.minimaxi.com/anthropic, MiniMax-M2.7, true, anthropic" + }) + void buildStreamingChatModel_supportsCurrentModelsRegionsAndProtocols( + String apiHost, String modelName, boolean enableThinking, String protocol) { ChatRequest request = new ChatRequest(); - request.setEnableThinking(false); + request.setEnableThinking(enableThinking); - StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); - assertNotNull(model); + StreamingChatModel model = minimaxService.buildStreamingChatModel( + modelVo(apiHost, modelName), request); + + if ("anthropic".equals(protocol)) { + assertInstanceOf(AnthropicStreamingChatModel.class, model); + } else { + assertInstanceOf(OpenAiStreamingChatModel.class, model); + } + } + + @ParameterizedTest + @CsvSource({ + "https://api.minimax.io/v1, openai", + "https://api.minimaxi.com/v1, openai", + "https://api.minimax.io/anthropic, anthropic", + "https://api.minimaxi.com/anthropic, anthropic" + }) + void buildChatModel_supportsRegionalProtocolEndpoints(String apiHost, String protocol) { + ChatModel model = minimaxService.buildChatModel(modelVo(apiHost, "MiniMax-M3")); + + if ("anthropic".equals(protocol)) { + assertInstanceOf(AnthropicChatModel.class, model); + } else { + assertInstanceOf(OpenAiChatModel.class, model); + } + } + + @ParameterizedTest + @CsvSource({"true, adaptive", "false, disabled"}) + void buildStreamingChatModel_mapsM3ThinkingSetting(boolean enabled, String expectedType) { + ChatRequest request = new ChatRequest(); + request.setEnableThinking(enabled); + + OpenAiStreamingChatModel model = assertInstanceOf(OpenAiStreamingChatModel.class, + minimaxService.buildStreamingChatModel( + modelVo("https://api.minimax.io/v1", "MiniMax-M3"), request)); + OpenAiChatRequestParameters parameters = assertInstanceOf( + OpenAiChatRequestParameters.class, model.defaultRequestParameters()); + Map thinking = assertInstanceOf( + Map.class, parameters.customParameters().get("thinking")); + + assertEquals(expectedType, thinking.get("type")); + assertEquals(expectedType, MinimaxServiceImpl.thinkingType("MiniMax-M3", enabled)); } @Test - void buildStreamingChatModel_withThinkingEnabled() { - ChatModelVo modelVo = new ChatModelVo(); - modelVo.setApiHost("https://api.minimax.io/v1"); - modelVo.setApiKey("test-api-key"); - modelVo.setModelName("MiniMax-M2.5"); - - ChatRequest request = new ChatRequest(); - request.setEnableThinking(true); - - StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); - assertNotNull(model); - } - - @Test - void buildStreamingChatModel_withHighspeedModel() { - ChatModelVo modelVo = new ChatModelVo(); - modelVo.setApiHost("https://api.minimax.io/v1"); - modelVo.setApiKey("test-api-key"); - modelVo.setModelName("MiniMax-M2.5-highspeed"); - + void buildStreamingChatModel_leavesM27ThinkingAlwaysOn() { ChatRequest request = new ChatRequest(); request.setEnableThinking(false); - StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); - assertNotNull(model); + OpenAiStreamingChatModel model = assertInstanceOf(OpenAiStreamingChatModel.class, + minimaxService.buildStreamingChatModel( + modelVo("https://api.minimax.io/v1", "MiniMax-M2.7"), request)); + OpenAiChatRequestParameters parameters = assertInstanceOf( + OpenAiChatRequestParameters.class, model.defaultRequestParameters()); + + assertNull(MinimaxServiceImpl.thinkingType("MiniMax-M2.7", false)); + assertTrue(parameters.customParameters() == null || parameters.customParameters().isEmpty()); + } + + @Test + void buildChatModel_appendsAnthropicMessagesPathInternally() throws Exception { + AtomicReference requestPath = new AtomicReference<>(); + AtomicReference requestBody = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + requestPath.set(exchange.getRequestURI().getPath()); + requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] response = ("{\"id\":\"msg_test\",\"type\":\"message\"," + + "\"role\":\"assistant\",\"model\":\"MiniMax-M3\"," + + "\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]," + + "\"stop_reason\":\"end_turn\",\"stop_sequence\":null," + + "\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}") + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + + try { + String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/anthropic"; + ChatModel model = minimaxService.buildChatModel(modelVo(baseUrl, "MiniMax-M3")); + + assertEquals("ok", model.chat("Hello")); + assertEquals("/anthropic/v1/messages", requestPath.get()); + JsonNode body = OBJECT_MAPPER.readTree(requestBody.get()); + assertEquals("MiniMax-M3", body.path("model").asText()); + } finally { + server.stop(0); + } } @Test void implementsAbstractChatService() { assertInstanceOf(org.ruoyi.service.chat.AbstractChatService.class, minimaxService); } + + private static ChatModelVo modelVo(String apiHost, String modelName) { + ChatModelVo modelVo = new ChatModelVo(); + modelVo.setApiHost(apiHost); + modelVo.setApiKey("test-api-key"); + modelVo.setModelName(modelName); + return modelVo; + } } From 3c9546ded98a75d3d5ad575d8fc0699e937a50c0 Mon Sep 17 00:00:00 2001 From: Jack Jiang <43378246+Jackjiayou@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:58:12 +0800 Subject: [PATCH 06/29] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Qdrant=20?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E5=88=9B=E5=BB=BA=E9=9B=86=E5=90=88?= =?UTF-8?q?=E9=97=AE=E9=A2=98=20(#298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/ruoyi/service/vector/impl/QdrantVectorStoreStrategy.java | 1 + 1 file changed, 1 insertion(+) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/vector/impl/QdrantVectorStoreStrategy.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/vector/impl/QdrantVectorStoreStrategy.java index da6bca80..573ee08e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/vector/impl/QdrantVectorStoreStrategy.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/vector/impl/QdrantVectorStoreStrategy.java @@ -116,6 +116,7 @@ public class QdrantVectorStoreStrategy extends AbstractVectorStoreStrategy { @Override public void storeEmbeddings(StoreEmbeddingBo storeEmbeddingBo) { + createSchema(storeEmbeddingBo.getKid(), storeEmbeddingBo.getEmbeddingModelName()); EmbeddingModel embeddingModel = getEmbeddingModel(storeEmbeddingBo.getEmbeddingModelName()); List chunkList = storeEmbeddingBo.getChunkList(); List fidList = storeEmbeddingBo.getFids(); From dfcadda2bbe55aad64633a78b5012841d4628585 Mon Sep 17 00:00:00 2001 From: "ageerle@163.com" Date: Wed, 15 Jul 2026 18:00:07 +0800 Subject: [PATCH 07/29] =?UTF-8?q?@style:=20=E4=BF=AE=E6=AD=A3=20QdrantVect?= =?UTF-8?q?orStoreStrategy=20=E7=BC=A9=E8=BF=9B=E4=B8=BA=E7=A9=BA=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createSchema 调用与文件其余代码保持 4 空格缩进统一,避免 checkstyle/spotless 报错。 Co-Authored-By: Claude --- .../ruoyi/service/vector/impl/QdrantVectorStoreStrategy.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/vector/impl/QdrantVectorStoreStrategy.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/vector/impl/QdrantVectorStoreStrategy.java index 573ee08e..aa8cc235 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/vector/impl/QdrantVectorStoreStrategy.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/vector/impl/QdrantVectorStoreStrategy.java @@ -116,7 +116,7 @@ public class QdrantVectorStoreStrategy extends AbstractVectorStoreStrategy { @Override public void storeEmbeddings(StoreEmbeddingBo storeEmbeddingBo) { - createSchema(storeEmbeddingBo.getKid(), storeEmbeddingBo.getEmbeddingModelName()); + createSchema(storeEmbeddingBo.getKid(), storeEmbeddingBo.getEmbeddingModelName()); EmbeddingModel embeddingModel = getEmbeddingModel(storeEmbeddingBo.getEmbeddingModelName()); List chunkList = storeEmbeddingBo.getChunkList(); List fidList = storeEmbeddingBo.getFids(); From 06d110ed16cc3a334f1c4d700dd4b779c32bec43 Mon Sep 17 00:00:00 2001 From: "ageerle@163.com" Date: Wed, 15 Jul 2026 18:25:55 +0800 Subject: [PATCH 08/29] =?UTF-8?q?@docs:=20=E4=BF=AE=E6=AD=A3=20snailjob=20?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E9=87=8C=E5=A4=B1=E6=95=88=E7=9A=84=20ry=5Fj?= =?UTF-8?q?ob.sql=20=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实际建表脚本在 docs/script/sql/ruoyi-ai-v3_mysql8.sql(sj_ 前缀表),仓库中并无 ry_job.sql。 关联 issue #311。 Co-Authored-By: Claude --- ruoyi-admin/src/main/resources/application-dev.yml | 4 ++-- ruoyi-admin/src/main/resources/application-prod.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 5168f7a0..2a775fbc 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -27,12 +27,12 @@ snail-job: enabled: false # 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务 group: "ruoyi_group" - # SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config` 表 + # SnailJob 接入验证令牌 详见 docs/script/sql/ruoyi-ai-v3_mysql8.sql `sj_group_config` 表 token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT" server: host: 127.0.0.1 port: 17888 - # 命名空间UUID 详见 script/sql/ry_job.sql `sj_namespace`表`unique_id`字段 + # 命名空间UUID 详见 docs/script/sql/ruoyi-ai-v3_mysql8.sql `sj_namespace`表`unique_id`字段 namespace: ${spring.profiles.active} # 随主应用端口漂移 port: 2${server.port} diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index 2708e5af..234e81ab 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -28,12 +28,12 @@ snail-job: enabled: false # 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务 group: "ruoyi_group" - # SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config` 表 + # SnailJob 接入验证令牌 详见 docs/script/sql/ruoyi-ai-v3_mysql8.sql `sj_group_config` 表 token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT" server: host: 127.0.0.1 port: 17888 - # 命名空间UUID 详见 script/sql/ry_job.sql `sj_namespace`表`unique_id`字段 + # 命名空间UUID 详见 docs/script/sql/ruoyi-ai-v3_mysql8.sql `sj_namespace`表`unique_id`字段 namespace: ${spring.profiles.active} # 随主应用端口漂移 port: 2${server.port} From fece90b3075e7c16de041d0e797d538925ca6f6f Mon Sep 17 00:00:00 2001 From: "ageerle@163.com" Date: Fri, 17 Jul 2026 11:52:53 +0800 Subject: [PATCH 09/29] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=9F=AD?= =?UTF-8?q?=E5=89=A7=E9=9F=B3=E9=A2=91/=E7=BC=96=E7=A0=81=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E3=80=81MCP=20=E6=96=87=E4=BB=B6=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E4=B8=8E=20WebSocket=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 短剧: 新增 ShortDramaAudio 实体/Mapper/BO/VO,扩展 ShortDramaServiceImpl 合成逻辑与 FfmpegFilterGraphBuilder 滤镜图 - MCP: 新增 WriteFileTool/DeleteFileTool/ExecuteCommandTool,重构 EditFileTool/ReadFileTool/ListDirectoryTool - 编码: 新增 coding 模块(CodingAgent/WorkspaceService/SSE 事件通道) - Atlas: 新增音频生成实现,扩展音视频/媒体实体与预测服务 - WebSocket: 新增公众号聊天 WebSocket 处理器与握手拦截 - 忽略 logs/ 目录,避免合成产物入库 Co-Authored-By: Claude --- .gitignore | 1 + docs/script/sql/ruoyi-ai-v3_mysql8.sql | 30 + .../src/main/resources/application.yml | 6 + .../chat/entity/audio/AudioContext.java | 15 + .../entity/media/MediaGenerationResponse.java | 3 + .../chat/entity/video/VideoContext.java | 12 + ruoyi-modules/ruoyi-chat/pom.xml | 8 + .../controller/coding/CodingController.java | 92 +++ .../shortdrama/ShortDramaController.java | 31 + .../domain/bo/coding/CodingRequestBo.java | 30 + .../bo/shortdrama/ShortDramaAudioBo.java | 37 ++ .../ShortDramaCharacterAppearanceBo.java | 2 + .../shortdrama/ShortDramaComposeVideoBo.java | 12 +- .../entity/shortdrama/ShortDramaAudio.java | 47 ++ .../ShortDramaCharacterAppearance.java | 3 + .../shortdrama/ShortDramaStoryboard.java | 3 + .../vo/shortdrama/ShortDramaAudioVo.java | 41 ++ .../ShortDramaCharacterAppearanceVo.java | 2 + .../vo/shortdrama/ShortDramaDetailVo.java | 2 + .../vo/shortdrama/ShortDramaStoryboardVo.java | 2 + .../shortdrama/ShortDramaAudioMapper.java | 8 + .../org/ruoyi/mcp/tools/DeleteFileTool.java | 166 +++++ .../org/ruoyi/mcp/tools/EditFileTool.java | 57 +- .../ruoyi/mcp/tools/ExecuteCommandTool.java | 266 ++++++++ .../ruoyi/mcp/tools/ListDirectoryTool.java | 37 +- .../org/ruoyi/mcp/tools/ReadFileTool.java | 57 +- .../org/ruoyi/mcp/tools/WriteFileTool.java | 136 +++++ .../AtlasAudioGenerationServiceImpl.java | 98 +++ .../org/ruoyi/service/coding/CodingAgent.java | 29 + .../service/coding/CodingEventChannel.java | 93 +++ .../ruoyi/service/coding/CodingSseEvent.java | 43 ++ .../coding/CodingWorkspaceService.java | 134 ++++ .../ruoyi/service/coding/ICodingService.java | 21 + .../ruoyi/service/coding/WorkspaceGuard.java | 50 ++ .../coding/impl/CodingServiceImpl.java | 198 ++++++ .../service/media/AtlasMediaSupport.java | 8 + .../service/media/AtlasPredictionService.java | 42 +- .../shortdrama/IShortDramaService.java | 13 + .../shortdrama/composition/AspectRatio.java | 5 +- .../composition/CompositionSpec.java | 5 +- .../composition/FfmpegCommandBuilder.java | 5 + .../FfmpegCompositionProperties.java | 11 + .../composition/FfmpegFilterGraphBuilder.java | 106 +++- .../impl/ShortDramaServiceImpl.java | 577 ++++++++++++++++-- .../impl/ShortDramaVideoComposeJob.java | 6 +- .../ShortDramaVideoComposeServiceImpl.java | 10 +- .../impl/ShortDramaVideoComposeWorker.java | 28 +- .../IncrementalJsonArrayExtractor.java | 116 ++++ .../AtlasVideoGenerationServiceImpl.java | 17 + .../chat/MpChatHandshakeInterceptor.java | 81 +++ .../websocket/chat/MpChatWebSocketConfig.java | 33 + .../chat/MpChatWebSocketHandler.java | 378 ++++++++++++ 52 files changed, 3110 insertions(+), 103 deletions(-) create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/coding/CodingController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/coding/CodingRequestBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaAudioBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaAudio.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaAudioVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaAudioMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/DeleteFileTool.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ExecuteCommandTool.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/WriteFileTool.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingAgent.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingEventChannel.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingSseEvent.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingWorkspaceService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/ICodingService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/WorkspaceGuard.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/impl/CodingServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/support/IncrementalJsonArrayExtractor.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatHandshakeInterceptor.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatWebSocketConfig.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatWebSocketHandler.java diff --git a/.gitignore b/.gitignore index e204fe12..979888b5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ nbdist/ *.xml.versionsBackup *.swp data/ +logs/ !*/build/*.java !*/build/*.html diff --git a/docs/script/sql/ruoyi-ai-v3_mysql8.sql b/docs/script/sql/ruoyi-ai-v3_mysql8.sql index 8a659ade..1623e046 100644 --- a/docs/script/sql/ruoyi-ai-v3_mysql8.sql +++ b/docs/script/sql/ruoyi-ai-v3_mysql8.sql @@ -116,6 +116,7 @@ INSERT INTO `chat_model` VALUES (2060622000000000003, 'video', 'bytedance/seedan INSERT INTO `chat_model` VALUES (2060622000000000004, 'video', 'bytedance/seedance-2.0/reference-to-video', 'atlas', 'Seedance 2.0 多参考图生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 20:24:44', 1, '2026-06-22 20:24:44', 'Atlas Cloud 视频模型 - Seedance 2.0 多参考图生视频,接收多张参考图+带@imageN标记的提示词生成视频', 0); INSERT INTO `chat_model` VALUES (2070700000000000002, 'chat', 'dify-chat', 'dify', 'Dify Chat App', NULL, 'Y', 'https://api.dify.ai/v1', '替换为你的DIFY_APP_API_KEY', 103, 1, '2026-07-14 11:03:44', 1, '2026-07-14 11:03:44', 'Dify 聊天应用;api_key 为 Dify App API Key,model_name 可按应用名修改', 0); INSERT INTO `chat_model` VALUES (2070700000000000004, 'chat', '替换为你的COZE_BOT_ID', 'coze', 'Coze Bot', NULL, 'Y', 'https://api.coze.cn', '替换为你的COZE_PAT', 103, 1, '2026-07-14 11:03:38', 1, '2026-07-14 11:03:38', 'Coze 聊天 Bot;model_name 为 Coze Bot ID,api_key 为 PAT 或 OAuth access token', 0); +INSERT INTO `chat_model` VALUES (2070700000000000010, 'audio', 'bytedance/seed-audio-1.0', 'atlas', 'Seed Audio 1.0 语音生成(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-07-16 18:00:00', 1, '2026-07-16 18:00:00', 'Atlas Cloud 语音模型 - 支持多角色对白配音,references 指定 speaker 音色,text 中用 @audioN 引用', 0); -- ---------------------------- -- Table structure for chat_provider @@ -1361,6 +1362,7 @@ CREATE TABLE `short_drama_character_appearance` ( `selected_image_index` int NULL DEFAULT 0 COMMENT '当前选中的图片索引', `previous_image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮图片URL列表(撤销用,JSON数组)', `previous_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)', + `voice` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '音色名(如 zh_male_taocheng_uranus_bigtts),用于该形象对白配音', PRIMARY KEY (`id`) USING BTREE, UNIQUE INDEX `uk_character_appearance`(`character_id` ASC, `appearance_index` ASC) USING BTREE, INDEX `idx_character_id`(`character_id` ASC) USING BTREE, @@ -1426,6 +1428,33 @@ INSERT INTO `short_drama_location` VALUES (2077008104604061696, 2077007721974484 INSERT INTO `short_drama_location` VALUES (2077008104675364864, 2077007721974484992, '厂庆礼堂_夜', '工厂礼堂内部,舞台上有麦克风,台下长凳坐满工人,红灯笼和横幅装饰。', 1, '几十名穿着蓝色工装的工人整齐坐在长凳上,面向舞台,表情喜悦。', '[\"舞台中央麦克风架前方位置\",\"舞台左侧幕布旁\",\"观众席前排中间走道\",\"礼堂后门入口处\"]', '[\"「厂庆礼堂_夜」一座中型礼堂,空间约三百平米,两侧墙壁挂满红色锦旗和奖状。舞台为砖砌,高约一米,铺着红色地毯,中央立着麦克风,背景幕布为深红色绒布,上方悬挂「厂庆联欢晚会」横幅。观众席摆着二十排长木凳,坐满穿工装的工人。屋顶悬挂多盏白炽灯和串串红灯笼,灯光暖黄。舞台侧方、观众席中间走廊、后门口均有空地可站人。\",\"「厂庆礼堂_夜」礼堂内部呈长方形,前高后低,地板为水泥磨光。天花板有木横梁,垂吊着彩带和纸花。舞台左后侧有上台台阶,右侧放置锣鼓乐器。台下长凳密集排列,过道狭窄,工人拥挤而坐,有些站着。礼堂后墙有双开木门,紧闭。光线主要来自舞台上的聚光灯和两侧壁灯,明亮温暖。舞台中央地面、右侧锣鼓旁、后墙门边有可落位空间。\",\"「厂庆礼堂_夜」宽大的老式礼堂,墙面下半部刷绿色墙裙,上半部白墙。窗户用深色帘幕遮住。舞台台口有弧形边缘,台上有两张木椅和一张讲台。台前摆满花篮。观众席中央过道约一米宽,两侧长凳坐满人。天花板中央一盏大吊灯,四周小灯,整体照明均匀。舞台正前方、左侧幕布缺口处、礼堂后门旁均可站立。\"]', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/a9295d479fef45f198bffd3ef850eacd-588538d1e9d8e094.jpg', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:51', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/a9295d479fef45f198bffd3ef850eacd-588538d1e9d8e094.jpg\"]', '[\"宽广空间全景,[\\\"「厂庆礼堂_夜」一座中型礼堂,空间约三百平米,两侧墙壁挂满红色锦旗和奖状。舞台为砖砌,高约一米,铺着红色地毯,中央立着麦克风,背景幕布为深红色绒布,上方悬挂「厂庆联欢晚会」横幅。观众席摆着二十排长木凳,坐满穿工装的工人。屋顶悬挂多盏白炽灯和串串红灯笼,灯光暖黄。舞台侧方、观众席中间走廊、后门口均有空地可站人。\\\",\\\"「厂庆礼堂_夜」礼堂内部呈长方形,前高后低,地板为水泥磨光。天花板有木横梁,垂吊着彩带和纸花。舞台左后侧有上台台阶,右侧放置锣鼓乐器。台下长凳密集排列,过道狭窄,工人拥挤而坐,有些站着。礼堂后墙有双开木门,紧闭。光线主要来自舞台上的聚光灯和两侧壁灯,明亮温暖。舞台中央地面、右侧锣鼓旁、后墙门边有可落位空间。\\\",\\\"「厂庆礼堂_夜」宽大的老式礼堂,墙面下半部刷绿色墙裙,上半部白墙。窗户用深色帘幕遮住。舞台台口有弧形边缘,台上有两张木椅和一张讲台。台前摆满花篮。观众席中央过道约一米宽,两侧长凳坐满人。天花板中央一盏大吊灯,四周小灯,整体照明均匀。舞台正前方、左侧幕布缺口处、礼堂后门旁均可站立。\\\"],禁止出现任何角色,纯背景板真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感\"]', 0, NULL, NULL); INSERT INTO `short_drama_location` VALUES (2077008104721502208, 2077007721974484992, '工厂大门_晨', '清晨工厂大门,新挂牌「华夏信息服务中心」,自行车棚,远处烟囱冒烟。', 1, '几名穿着蓝色工装的工人推着自行车或步行进入厂门,路边有早点摊的模糊身影。', '[\"厂门口新挂牌下方正中间\",\"右侧自行车棚外侧\",\"左侧传达室门口台阶\",\"马路对面老槐树旁\"]', '[\"「工厂大门_晨」工厂大门为铁制对开栅栏门,门柱为红砖砌成,左侧门柱上挂着崭新白底黑字牌子「华夏信息服务中心」。进门可见一条水泥路通向厂区。右侧有一排自行车棚,棚内停着几十辆自行车。远处工厂烟囱缓缓冒白烟,背景是朦胧的晨曦天空。阳光从东方斜照,拉长门柱影子。大门正下方、自行车棚外面、左侧传达室门口、马路对面树荫下均有平坦地面。\",\"「工厂大门_晨」晨曦中的老工厂入口,灰砖围墙高约三米,大门敞开。左侧传达室为平房,窗户透出灯光。门右侧墙上贴着红色通知。路面是水泥铺设,有些许落叶。厂内远处可见车间屋顶和冒烟烟囱。天空泛鱼肚白,东边有朝霞。光线从右前方射来,门牌上的字清晰反光。大门中央、传达室左侧空地、路边电线杆旁都可以站人。\",\"「工厂大门_晨」工厂正门区域,宽阔的厂前空地上矗立着新立的招牌。大门两侧各有一棵法国梧桐,枝叶繁茂。地面为柏油路,路中央画有白色虚线。厂门内侧两边是花坛,种着冬青。远处厂房轮廓在晨雾中若隐若现,烟囱吐烟。光线柔和,晨光从建筑物缝隙中洒落。招牌下方、左侧花坛旁、右侧梧桐树下均是可站立的空旷位置。\"]', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/50f5cc1fb8e74dac95d589c23dc2daf7-7fb4da35d3075e95.jpg', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:51', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/50f5cc1fb8e74dac95d589c23dc2daf7-7fb4da35d3075e95.jpg\"]', '[\"宽广空间全景,[\\\"「工厂大门_晨」工厂大门为铁制对开栅栏门,门柱为红砖砌成,左侧门柱上挂着崭新白底黑字牌子「华夏信息服务中心」。进门可见一条水泥路通向厂区。右侧有一排自行车棚,棚内停着几十辆自行车。远处工厂烟囱缓缓冒白烟,背景是朦胧的晨曦天空。阳光从东方斜照,拉长门柱影子。大门正下方、自行车棚外面、左侧传达室门口、马路对面树荫下均有平坦地面。\\\",\\\"「工厂大门_晨」晨曦中的老工厂入口,灰砖围墙高约三米,大门敞开。左侧传达室为平房,窗户透出灯光。门右侧墙上贴着红色通知。路面是水泥铺设,有些许落叶。厂内远处可见车间屋顶和冒烟烟囱。天空泛鱼肚白,东边有朝霞。光线从右前方射来,门牌上的字清晰反光。大门中央、传达室左侧空地、路边电线杆旁都可以站人。\\\",\\\"「工厂大门_晨」工厂正门区域,宽阔的厂前空地上矗立着新立的招牌。大门两侧各有一棵法国梧桐,枝叶繁茂。地面为柏油路,路中央画有白色虚线。厂门内侧两边是花坛,种着冬青。远处厂房轮廓在晨雾中若隐若现,烟囱吐烟。光线柔和,晨光从建筑物缝隙中洒落。招牌下方、左侧花坛旁、右侧梧桐树下均是可站立的空旷位置。\\\"],禁止出现任何角色,纯背景板真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感\"]', 0, NULL, NULL); +-- ---------------------------- +-- Table structure for short_drama_audio +-- ---------------------------- +DROP TABLE IF EXISTS `short_drama_audio`; +CREATE TABLE `short_drama_audio` ( + `id` bigint NOT NULL COMMENT '主键', + `project_id` bigint NOT NULL COMMENT '项目ID', + `name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '语音资产名称', + `audio_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'narration' COMMENT '语音类型:narration(旁白)/dialogue(对白)', + `text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '语音文案(生成语音用的文本)', + `voice` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '音色(如 alloy/onyx)', + `audio_oss_id` bigint NULL DEFAULT NULL COMMENT '音频文件OSS ID', + `audio_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '音频文件URL', + `linked_storyboard_id` bigint NULL DEFAULT NULL COMMENT '对白关联的分镜ID(NULL=全局旁白)', + `duration_seconds` int NULL DEFAULT NULL COMMENT '音频时长(秒)', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` bigint NULL DEFAULT NULL COMMENT '更新者', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_project_id`(`project_id` ASC) USING BTREE, + INDEX `idx_linked_storyboard_id`(`linked_storyboard_id` ASC) USING BTREE, + INDEX `idx_tenant_id`(`tenant_id` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '短剧语音资产表' ROW_FORMAT = Dynamic; + -- ---------------------------- -- Table structure for short_drama_project -- ---------------------------- @@ -1517,6 +1546,7 @@ CREATE TABLE `short_drama_storyboard` ( `video_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '视频地址', `video_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '视频生成任务ID', `video_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'pending' COMMENT '视频状态:pending/generating/done/failed', + `last_frame_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一镜末帧URL(同场景连续镜头首帧承接用)', `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index be709192..d9aca934 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -20,6 +20,10 @@ server: # 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载 worker: 256 +--- # 小程序对话 WebSocket 兜底默认模型(前端未传 model 且无智能体绑定时使用) +chat: + default-model: deepseek-v4-flash + captcha: # 是否启用验证码校验 enable: false @@ -125,6 +129,7 @@ security: - /*/api-docs/** - /warm-flow-ui/config - /workflow/run + - /coding/** # 多租户配置 tenant: # 是否开启 @@ -335,6 +340,7 @@ short-drama: probe-timeout: ${SHORT_DRAMA_COMPOSITION_PROBE_TIMEOUT:30s} process-timeout: ${SHORT_DRAMA_COMPOSITION_PROCESS_TIMEOUT:30m} job-stale-after: ${SHORT_DRAMA_COMPOSITION_JOB_STALE_AFTER:45m} + watermark-font-file: ${SHORT_DRAMA_COMPOSITION_WATERMARK_FONT_FILE:} worker-core-size: ${SHORT_DRAMA_COMPOSITION_WORKER_CORE_SIZE:1} worker-max-size: ${SHORT_DRAMA_COMPOSITION_WORKER_MAX_SIZE:2} worker-queue-capacity: ${SHORT_DRAMA_COMPOSITION_WORKER_QUEUE_CAPACITY:8} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/audio/AudioContext.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/audio/AudioContext.java index 22611ffe..a096fb54 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/audio/AudioContext.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/audio/AudioContext.java @@ -26,4 +26,19 @@ public class AudioContext { private Double speed; private String instructions; + + /** Atlas seed-audio 参考资源:[{speaker, audioUrl, audioData, imageData}],speaker 为音色名 */ + private java.util.List> references; + + /** Atlas seed-audio 采样率 */ + private Integer sampleRate; + + /** Atlas seed-audio 音调调整 (-12~12) */ + private Integer pitchRate; + + /** Atlas seed-audio 语速调整 (-50~100) */ + private Integer speechRate; + + /** Atlas seed-audio 响度调整 (-50~100) */ + private Integer loudnessRate; } diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/media/MediaGenerationResponse.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/media/MediaGenerationResponse.java index 5f1960b9..870b109f 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/media/MediaGenerationResponse.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/media/MediaGenerationResponse.java @@ -25,4 +25,7 @@ public class MediaGenerationResponse { private String status; private String rawResponse; + + /** 末帧图片 URL(Atlas return_last_frame=true 时返回,用于下一镜首帧承接) */ + private String lastFrameUrl; } diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java index b6138862..c602d4b7 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java @@ -31,5 +31,17 @@ public class VideoContext { /** 多参考图 URL(多参考图生视频模式,配合 @imageN prompt 使用) */ private java.util.List referenceImages; + /** 参考音频 URL 列表(对白口型对齐模式) */ + private java.util.List referenceAudios; + + /** 是否让模型生成同步音频(环境音/动效) */ + private Boolean generateAudio; + + /** 是否要求返回末帧,用于下一镜首帧承接 */ + private Boolean returnLastFrame; + + /** 上一镜末帧 URL(同场景连续镜头首帧承接用,作为额外参考图传入) */ + private String lastFrameUrl; + private String videoId; } diff --git a/ruoyi-modules/ruoyi-chat/pom.xml b/ruoyi-modules/ruoyi-chat/pom.xml index 144240a2..e0bdc473 100644 --- a/ruoyi-modules/ruoyi-chat/pom.xml +++ b/ruoyi-modules/ruoyi-chat/pom.xml @@ -24,6 +24,14 @@ ruoyi-common-sse + + + org.ruoyi + ruoyi-common-websocket + + org.ruoyi ruoyi-common-sensitive diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/coding/CodingController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/coding/CodingController.java new file mode 100644 index 00000000..759cb592 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/coding/CodingController.java @@ -0,0 +1,92 @@ +package org.ruoyi.controller.coding; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.satoken.utils.LoginHelper; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.domain.bo.coding.CodingRequestBo; +import org.ruoyi.service.coding.CodingWorkspaceService; +import org.ruoyi.service.coding.ICodingService; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +/** + * 编程能力接口(B 路径,不走 Supervisor 调度) + * + *

第一阶段 {@code /coding/**} 在 {@code application.yml} 的 security.excludes 中, + * 免鉴权直连。Controller 只做参数绑定 + 同步取 userId(Sa-Token 异步上下文丢失, + * 见 SecurityConfig 注释)+ 转发 Service。 + * + * @author ageerle + */ +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/coding") +public class CodingController { + + private final ICodingService codingService; + private final CodingWorkspaceService workspaceService; + private final IChatModelService chatModelService; + + /** + * 编程对话(SSE 流式) + * + * @param bo 请求参数(prompt / model / workspacePath) + * @return SseEmitter + */ + @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter chat(@Valid @RequestBody CodingRequestBo bo) { + // 同步线程取 userId;第一阶段免鉴权,可能为 null + Long userId = LoginHelper.getUserId(); + return codingService.chat(bo, userId); + } + + @GetMapping("/workspace") + public R workspace( + @RequestParam(required = false) String workspacePath) throws Exception { + return R.ok(workspaceService.list(workspacePath)); + } + + @GetMapping("/models") + public R> models() { + List models = chatModelService.queryList(new ChatModelBo()).stream() + .filter(model -> "1".equals(model.getModelShow())) + .map(model -> new ModelOption(model.getId(), model.getModelName(), model.getProviderCode())) + .toList(); + return R.ok(models); + } + + @GetMapping("/file") + public R file( + @RequestParam(required = false) String workspacePath, + @RequestParam String path) throws Exception { + return R.ok(workspaceService.read(workspacePath, path)); + } + + @PutMapping("/file") + public R saveFile(@RequestBody FileWriteRequest request) throws Exception { + return R.ok(workspaceService.write(request.workspacePath(), request.path(), request.content())); + } + + @PostMapping("/command") + public R command(@RequestBody CommandRequest request) { + return R.ok(workspaceService.execute(request.workspacePath(), request.command())); + } + + public record FileWriteRequest(String workspacePath, String path, String content) { } + public record CommandRequest(String workspacePath, String command) { } + public record ModelOption(Long id, String name, String provider) { } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java index 5e6d6ce6..6ecd69a3 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java @@ -14,6 +14,7 @@ import org.ruoyi.common.satoken.utils.LoginHelper; import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaComposeVideoBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaAudioBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo; @@ -23,6 +24,7 @@ import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaAudioVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaLocationVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaProjectVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo; @@ -340,6 +342,35 @@ public class ShortDramaController { return R.ok(shortDramaService.undoLocationImage(locationId, LoginHelper.getUserId())); } + // ==================== 语音资产管理 ==================== + + @PostMapping("/audio") + public R saveAudio(@Valid @RequestBody ShortDramaAudioBo bo) { + return R.ok(shortDramaService.saveAudio(bo, LoginHelper.getUserId())); + } + + @PutMapping("/audio") + public R updateAudio(@Valid @RequestBody ShortDramaAudioBo bo) { + return R.ok(shortDramaService.saveAudio(bo, LoginHelper.getUserId())); + } + + @DeleteMapping("/audio/{audioId}") + public R deleteAudio(@NotNull @PathVariable Long audioId) { + shortDramaService.deleteAudio(audioId, LoginHelper.getUserId()); + return R.ok(); + } + + @GetMapping("/audio/list") + public R> listAudios(@NotNull @RequestParam Long projectId) { + return R.ok(shortDramaService.listAudios(projectId, LoginHelper.getUserId())); + } + + @PostMapping("/audio/{audioId}/generate-speech") + public R generateAudio(@NotNull @PathVariable Long audioId, + @NotBlank @RequestParam String model) { + return R.ok(shortDramaService.generateAudio(audioId, model, LoginHelper.getUserId())); + } + // ==================== 异步图片生成(轮询进度) ==================== /** 上传本地照片到图片供应商,返回当前生成会话使用的临时 URL。 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/coding/CodingRequestBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/coding/CodingRequestBo.java new file mode 100644 index 00000000..4c479498 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/coding/CodingRequestBo.java @@ -0,0 +1,30 @@ +package org.ruoyi.domain.bo.coding; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 编程能力对话请求 + * + * @author ageerle + */ +@Data +public class CodingRequestBo { + + /** + * 用户指令 + */ + @NotBlank(message = "prompt 不能为空") + private String prompt; + + /** + * 模型名称(走 IChatModelService.selectModelByName) + */ + @NotBlank(message = "model 不能为空") + private String model; + + /** + * 工作目录,可选;为空时默认指向 ruoyi-copilot 前端项目 + */ + private String workspacePath; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaAudioBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaAudioBo.java new file mode 100644 index 00000000..fd00de0c --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaAudioBo.java @@ -0,0 +1,37 @@ +package org.ruoyi.domain.bo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; +import org.ruoyi.domain.entity.shortdrama.ShortDramaAudio; + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = ShortDramaAudio.class, reverseConvertGenerate = false) +public class ShortDramaAudioBo extends BaseEntity { + + private Long id; + + @NotNull(message = "项目ID不能为空") + private Long projectId; + + @NotBlank(message = "语音资产名称不能为空") + private String name; + + @NotBlank(message = "语音类型不能为空") + @Pattern(regexp = "narration|dialogue", message = "语音类型只能是 narration 或 dialogue") + private String audioType; + + @NotBlank(message = "语音文案不能为空") + private String text; + + /** 音色(生成语音时使用,可空,空则用模型默认) */ + private String voice; + + /** 对白关联的分镜ID(旁白类型留空) */ + private Long linkedStoryboardId; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterAppearanceBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterAppearanceBo.java index 7ff73f92..e14b7c09 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterAppearanceBo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterAppearanceBo.java @@ -32,4 +32,6 @@ public class ShortDramaCharacterAppearanceBo extends BaseEntity { private String previousImageUrls; private String previousDescriptions; + + private String voice; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaComposeVideoBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaComposeVideoBo.java index 363fb8c4..d1c7e36b 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaComposeVideoBo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaComposeVideoBo.java @@ -15,15 +15,21 @@ public class ShortDramaComposeVideoBo { @NotBlank(message = "转场类型不能为空") @Pattern(regexp = "none|dissolve|fade|slide", message = "不支持的转场类型") - private String transitionType = "dissolve"; + private String transitionType = "fade"; @NotNull(message = "转场时长不能为空") @DecimalMin(value = "0.0", message = "转场时长不能小于0秒") - private BigDecimal transitionDurationSeconds = new BigDecimal("0.5"); + private BigDecimal transitionDurationSeconds = new BigDecimal("0.3"); @NotBlank(message = "成片画幅不能为空") - @Pattern(regexp = "9:16|16:9|1:1", message = "不支持的成片画幅") + @Pattern(regexp = "9:16|16:9|4:3|3:4|1:1|21:9", message = "不支持的成片画幅") private String aspectRatio = "9:16"; @Size(min = 2, message = "至少选择2个分镜视频") private List storyboardIds; + + /** 旁白语音资产ID(可选,未传则不混入旁白) */ + private Long narrationAudioId; + + /** 是否加水印(null 时用后端默认配置 ruoyi-ai) */ + private Boolean watermark; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaAudio.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaAudio.java new file mode 100644 index 00000000..dd02f16b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaAudio.java @@ -0,0 +1,47 @@ +package org.ruoyi.domain.entity.shortdrama; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("short_drama_audio") +public class ShortDramaAudio extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private Long projectId; + + /** 语音资产名称 */ + private String name; + + /** 语音类型:narration(旁白)/dialogue(对白) */ + private String audioType; + + /** 语音文案(生成语音用的文本) */ + private String text; + + /** 音色(如 alloy/onyx) */ + private String voice; + + /** 音频文件OSS ID */ + private Long audioOssId; + + /** 音频文件URL */ + private String audioUrl; + + /** 对白关联的分镜ID(NULL=全局旁白) */ + private Long linkedStoryboardId; + + /** 音频时长(秒) */ + private Integer durationSeconds; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacterAppearance.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacterAppearance.java index b9f28848..53360596 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacterAppearance.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacterAppearance.java @@ -43,4 +43,7 @@ public class ShortDramaCharacterAppearance extends BaseEntity { /** 上一轮提示词列表(撤销用,JSON数组) */ private String previousDescriptions; + + /** 音色名(如 zh_male_taocheng_uranus_bigtts),用于该形象的对白配音 */ + private String voice; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaStoryboard.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaStoryboard.java index 63e3cedd..5a2e2004 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaStoryboard.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaStoryboard.java @@ -58,4 +58,7 @@ public class ShortDramaStoryboard extends BaseEntity { private String videoId; private String videoStatus; + + /** 上一镜末帧URL(同场景连续镜头首帧承接用) */ + private String lastFrameUrl; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaAudioVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaAudioVo.java new file mode 100644 index 00000000..d61afa2c --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaAudioVo.java @@ -0,0 +1,41 @@ +package org.ruoyi.domain.vo.shortdrama; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.ruoyi.domain.entity.shortdrama.ShortDramaAudio; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +@Data +@AutoMapper(target = ShortDramaAudio.class) +public class ShortDramaAudioVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + private Long projectId; + + private String name; + + private String audioType; + + private String text; + + private String voice; + + private Long audioOssId; + + private String audioUrl; + + private Long linkedStoryboardId; + + private Integer durationSeconds; + + private Date createTime; + + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterAppearanceVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterAppearanceVo.java index 5ee0b031..ac35eb97 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterAppearanceVo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterAppearanceVo.java @@ -37,6 +37,8 @@ public class ShortDramaCharacterAppearanceVo implements Serializable { private String previousDescriptions; + private String voice; + private Date createTime; private Date updateTime; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaDetailVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaDetailVo.java index 7e25bf86..31cdc1cb 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaDetailVo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaDetailVo.java @@ -20,5 +20,7 @@ public class ShortDramaDetailVo implements Serializable { private List locations; + private List audios; + private List storyboards; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaStoryboardVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaStoryboardVo.java index da0eedc1..94680c3c 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaStoryboardVo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaStoryboardVo.java @@ -57,6 +57,8 @@ public class ShortDramaStoryboardVo implements Serializable { private String videoStatus; + private String lastFrameUrl; + private Date createTime; private Date updateTime; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaAudioMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaAudioMapper.java new file mode 100644 index 00000000..35545a50 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaAudioMapper.java @@ -0,0 +1,8 @@ +package org.ruoyi.mapper.shortdrama; + +import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus; +import org.ruoyi.domain.entity.shortdrama.ShortDramaAudio; +import org.ruoyi.domain.vo.shortdrama.ShortDramaAudioVo; + +public interface ShortDramaAudioMapper extends BaseMapperPlus { +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/DeleteFileTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/DeleteFileTool.java new file mode 100644 index 00000000..78b467a8 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/DeleteFileTool.java @@ -0,0 +1,166 @@ +package org.ruoyi.mcp.tools; + +import dev.langchain4j.agent.tool.Tool; +import org.ruoyi.mcp.service.core.BuiltinToolProvider; +import org.ruoyi.service.coding.CodingEventChannel; +import org.ruoyi.service.coding.CodingSseEvent; +import org.ruoyi.service.coding.WorkspaceGuard; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 删除文件/目录工具 + * + *

编程能力专用:通过构造注入工作目录与 SSE 事件通道,操作前后推送 delete-start/delete-end 事件。 + * + * @author ageerle + */ +@Component +public class DeleteFileTool implements BuiltinToolProvider { + + public static final String DESCRIPTION = "Deletes a file or directory. " + + "Set recursive=true to delete a non-empty directory. " + + "Use absolute paths within the workspace directory."; + + private final String rootDirectory; + private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass()); + private final CodingEventChannel channel; + + public DeleteFileTool() { + this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString(); + this.channel = null; + } + + /** + * 编程能力专用构造。 + */ + public DeleteFileTool(Path root, CodingEventChannel channel) { + this.rootDirectory = root.toAbsolutePath().normalize().toString(); + this.channel = channel; + } + + /** + * 删除文件或目录 + * + * @param filePath 路径绝对路径 + * @param recursive 是否递归删除非空目录(可选,默认 false) + * @return 操作结果 + */ + @Tool(DESCRIPTION) + public String deleteFile(String filePath, Boolean recursive) { + try { + if (filePath == null || filePath.trim().isEmpty()) { + return "Error: File path cannot be empty"; + } + + Path path = Paths.get(filePath); + boolean rec = recursive != null && recursive; + + if (!path.isAbsolute()) { + return "Error: File path must be absolute: " + filePath; + } + + if (!WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), path)) { + return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath; + } + + if (!Files.exists(path)) { + return "Error: Path not found: " + filePath; + } + + if (channel != null) { + channel.send(CodingSseEvent.of("delete-start", filePath, null, null, "running")); + } + + String relativePath = getRelativePath(path); + + if (Files.isDirectory(path)) { + if (rec) { + AtomicLong count = new AtomicLong(0); + Files.walkFileTree(path, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + count.incrementAndGet(); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + if (channel != null) { + channel.send(CodingSseEvent.of("delete-end", filePath, null, + "删除目录及 " + count.get() + " 个文件", "done")); + } + return String.format("Successfully deleted directory: %s (%d files)", relativePath, count.get()); + } else { + try { + Files.delete(path); + } catch (IOException e) { + if (channel != null) { + channel.send(CodingSseEvent.of("delete-end", filePath, null, + "Error: 非空目录需 recursive=true", "done")); + } + return "Error: Directory not empty, set recursive=true: " + filePath; + } + if (channel != null) { + channel.send(CodingSseEvent.of("delete-end", filePath, null, null, "done")); + } + return String.format("Successfully deleted directory: %s", relativePath); + } + } else { + Files.delete(path); + if (channel != null) { + channel.send(CodingSseEvent.of("delete-end", filePath, null, null, "done")); + } + return String.format("Successfully deleted file: %s", relativePath); + } + + } catch (IOException e) { + logger.error("Error deleting file: {}", filePath, e); + if (channel != null) { + channel.send(CodingSseEvent.of("delete-end", filePath, null, + "Error: " + e.getMessage(), "done")); + } + return "Error: " + e.getMessage(); + } catch (Exception e) { + logger.error("Unexpected error deleting file: {}", filePath, e); + return "Error: Unexpected error: " + e.getMessage(); + } + } + + private String getRelativePath(Path filePath) { + try { + Path workspaceRoot = Paths.get(rootDirectory); + return workspaceRoot.relativize(filePath).toString(); + } catch (Exception e) { + return filePath.toString(); + } + } + + @Override + public String getToolName() { + return "delete_file"; + } + + @Override + public String getDisplayName() { + return "删除文件"; + } + + @Override + public String getDescription() { + return DESCRIPTION; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java index 26473da2..7f74205c 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java @@ -2,6 +2,9 @@ package org.ruoyi.mcp.tools; import dev.langchain4j.agent.tool.Tool; import org.ruoyi.mcp.service.core.BuiltinToolProvider; +import org.ruoyi.service.coding.CodingEventChannel; +import org.ruoyi.service.coding.CodingSseEvent; +import org.ruoyi.service.coding.WorkspaceGuard; import org.springframework.stereotype.Component; import java.io.IOException; @@ -10,8 +13,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; -import java.util.Arrays; -import java.util.List; /** * 编辑文件工具 @@ -20,16 +21,26 @@ import java.util.List; @Component public class EditFileTool implements BuiltinToolProvider { - public static final String DESCRIPTION = "Edits a file by applying a diff. " + - "Use this tool when you need to make specific changes to a file. " + - "The tool will show the diff before applying changes. " + + public static final String DESCRIPTION = "Edits an existing file by replacing its full content. " + + "ALWAYS read the file first with read_file, then provide the COMPLETE new content here. " + "Use absolute paths within the workspace directory."; private final String rootDirectory; private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass()); + /** 编程能力 SSE 事件通道,可为 null(兼容无参构造的老调用方) */ + private final CodingEventChannel channel; public EditFileTool() { this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString(); + this.channel = null; + } + + /** + * 编程能力专用构造:注入会话工作目录与事件通道。 + */ + public EditFileTool(Path root, CodingEventChannel channel) { + this.rootDirectory = root.toAbsolutePath().normalize().toString(); + this.channel = channel; } /** @@ -59,7 +70,7 @@ public class EditFileTool implements BuiltinToolProvider { } // 验证是否在工作目录内 - if (!isWithinWorkspace(path)) { + if (!WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), path)) { return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath; } @@ -73,30 +84,31 @@ public class EditFileTool implements BuiltinToolProvider { return "Error: Path is a directory, not a file: " + filePath; } - // 读取原始内容 - String originalContent = Files.readString(path, StandardCharsets.UTF_8); - List originalLines = Arrays.asList(originalContent.split("\n")); + // 推送编辑开始事件 + String relativePath = getRelativePath(path); + if (channel != null) { + channel.send(CodingSseEvent.of("edit-start", filePath, null, null, "running")); + } - // 应用diff + // 应用diff(简化:整体替换为新内容) try { - // 这里简化处理,直接用新内容替换 - // 在实际应用中,可能需要更复杂的diff解析 - String newContent = applyDiff(originalContent, diff); + String newContent = applyDiff(null, diff); - // 写入文件 Files.writeString(path, newContent, StandardCharsets.UTF_8, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); - String relativePath = getRelativePath(path); + if (channel != null) { + channel.send(CodingSseEvent.of("edit-end", filePath, null, null, "done")); + } return String.format("Successfully edited file: %s", relativePath); } catch (Exception e) { + if (channel != null) { + channel.send(CodingSseEvent.of("edit-end", filePath, null, "Error: " + e.getMessage(), "done")); + } return "Error: Failed to apply diff: " + e.getMessage(); } - } catch (IOException e) { - logger.error("Error editing file: {}", filePath, e); - return "Error: " + e.getMessage(); } catch (Exception e) { logger.error("Unexpected error editing file: {}", filePath, e); return "Error: Unexpected error: " + e.getMessage(); @@ -115,14 +127,7 @@ public class EditFileTool implements BuiltinToolProvider { } private boolean isWithinWorkspace(Path filePath) { - try { - Path workspaceRoot = Paths.get(rootDirectory).toRealPath(); - Path normalizedPath = filePath.normalize(); - return normalizedPath.startsWith(workspaceRoot.normalize()); - } catch (IOException e) { - logger.warn("Could not resolve workspace path", e); - return false; - } + return WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), filePath); } private String getRelativePath(Path filePath) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ExecuteCommandTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ExecuteCommandTool.java new file mode 100644 index 00000000..7c7d3e19 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ExecuteCommandTool.java @@ -0,0 +1,266 @@ +package org.ruoyi.mcp.tools; + +import dev.langchain4j.agent.tool.Tool; +import org.ruoyi.mcp.service.core.BuiltinToolProvider; +import org.ruoyi.service.coding.CodingEventChannel; +import org.ruoyi.service.coding.CodingSseEvent; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * 命令执行工具 + * + *

在会话工作目录内执行白名单命令,返回 stdout+stderr 尾部(8KB)。 + * 安全四层防御: + *

    + *
  1. 命令白名单:首段必须是允许的命令名
  2. + *
  3. 元字符黑名单:含 shell 元字符 {@code &|;`$<>\n} 直接拒绝(虽走 ProcessBuilder 不经 shell,仍双保险)
  4. + *
  5. 工作目录锁定:ProcessBuilder.directory 锁在会话 workspace
  6. + *
  7. 超时 + 输出截断:30s 超时 destroyForcibly,输出重定向临时文件只读尾部 8KB
  8. + *
+ * + *

实现借鉴 {@code FfmpegProcessRunner}:ProcessBuilder(List) 不走 shell 防注入, + * redirectErrorStream+redirectOutput 到临时文件,waitFor 超时,readTail 截断。 + * + * @author ageerle + */ +@Component +public class ExecuteCommandTool implements BuiltinToolProvider { + + public static final String DESCRIPTION = "Executes a shell command in the workspace directory. " + + "Command must be in the allowed whitelist (npm/pnpm/yarn/git/mvn/gradle/java/javac/" + + "python/pip/node/tsc/eslint/prettier/cat/ls/dir/echo). " + + "Returns combined stdout+stderr tail (8KB). 30s timeout."; + + /** 允许的命令白名单(首段) */ + private static final Set ALLOWED_COMMANDS = Set.of( + "npm", "pnpm", "yarn", "git", "mvn", "gradle", "java", "javac", + "python", "python3", "pip", "node", "tsc", "eslint", "prettier", + "cat", "ls", "dir", "echo" + ); + + /** 禁止的 shell 元字符(防注入) */ + private static final String FORBIDDEN_CHARS = "&|;`$<>\n\r"; + + /** 输出截断上限 */ + private static final int MAX_OUTPUT_BYTES = 8 * 1024; + /** 命令超时(秒) */ + private static final long TIMEOUT_SECONDS = 30; + + private final Path rootDirectory; + private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass()); + private final CodingEventChannel channel; + + public ExecuteCommandTool() { + this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace"); + this.channel = null; + } + + /** + * 编程能力专用构造。 + */ + public ExecuteCommandTool(Path root, CodingEventChannel channel) { + this.rootDirectory = root.toAbsolutePath().normalize(); + this.channel = channel; + } + + /** + * 执行命令 + * + * @param command 完整命令行(如 "npm install" 或 "node -v") + * @return 命令输出尾部,失败返回 "Error: ..." + */ + @Tool(DESCRIPTION) + public String executeCommand(String command) { + if (command == null || command.trim().isEmpty()) { + return "Error: Command cannot be empty"; + } + + // 元字符黑名单校验 + for (int i = 0; i < command.length(); i++) { + if (FORBIDDEN_CHARS.indexOf(command.charAt(i)) >= 0) { + return "Error: Command contains forbidden shell character: '" + command.charAt(i) + "'"; + } + } + + // 按空白拆分(多个空格也兼容) + List parts = splitCommand(command); + if (parts.isEmpty()) { + return "Error: Command is empty after split"; + } + + String cmdName = parts.get(0); + // 白名单校验,Windows 下尝试追加 .cmd/.exe/.bat 后缀 + String resolvedCmd = resolveCommand(cmdName); + if (resolvedCmd == null) { + return "Error: Command not in whitelist: " + cmdName + + ". Allowed: " + ALLOWED_COMMANDS; + } + + List cmdList = new ArrayList<>(parts); + cmdList.set(0, resolvedCmd); + + if (channel != null) { + channel.send(CodingSseEvent.of("cmd", null, command, null, "running")); + } + + Path logFile = null; + Process process = null; + try { + logFile = Files.createTempFile("coding-cmd-", ".log"); + ProcessBuilder builder = new ProcessBuilder(cmdList); + builder.directory(rootDirectory.toFile()); + builder.redirectErrorStream(true); + builder.redirectOutput(logFile.toFile()); + process = builder.start(); + + String result; + if (!process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + stop(process); + String tail = readTail(logFile, MAX_OUTPUT_BYTES); + if (channel != null) { + channel.send(CodingSseEvent.of("cmd", null, command, + "超时(" + TIMEOUT_SECONDS + "s)\n" + tail, "done")); + } + return "Error: command timed out after " + TIMEOUT_SECONDS + "s\n" + tail; + } + + int exitCode = process.exitValue(); + String output = readTail(logFile, MAX_OUTPUT_BYTES); + if (channel != null) { + channel.send(CodingSseEvent.of("cmd", null, command, + output, "done")); + } + result = exitCode == 0 ? output : "Error: exit " + exitCode + "\n" + output; + return result; + + } catch (InterruptedException ex) { + if (process != null) { + process.destroyForcibly(); + } + Thread.currentThread().interrupt(); + return "Error: command interrupted"; + } catch (IOException ex) { + logger.error("Error executing command: {}", command, ex); + return "Error: " + ex.getMessage(); + } finally { + if (logFile != null) { + try { + Files.deleteIfExists(logFile); + } catch (IOException ignored) { + // 临时文件清理失败不影响主流程 + } + } + } + } + + /** + * 解析命令名:白名单匹配,Windows 下追加后缀重试。 + */ + private String resolveCommand(String cmdName) { + if (ALLOWED_COMMANDS.contains(cmdName)) { + return cmdName; + } + // Windows 下 npm/pnpm 等可能是 .cmd + if (isWindows()) { + for (String suffix : new String[]{".cmd", ".exe", ".bat"}) { + String candidate = cmdName + suffix; + String baseName = stripSuffix(cmdName); + if (ALLOWED_COMMANDS.contains(baseName)) { + return candidate; + } + } + } + return null; + } + + private String stripSuffix(String name) { + for (String suffix : new String[]{".cmd", ".exe", ".bat"}) { + if (name.endsWith(suffix)) { + return name.substring(0, name.length() - suffix.length()); + } + } + return name; + } + + private boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase().contains("win"); + } + + /** + * 按空白拆分命令行(不处理引号,保持简单;元字符已在上游拦截)。 + */ + private List splitCommand(String command) { + List parts = new ArrayList<>(); + StringBuilder cur = new StringBuilder(); + for (int i = 0; i < command.length(); i++) { + char c = command.charAt(i); + if (Character.isWhitespace(c)) { + if (cur.length() > 0) { + parts.add(cur.toString()); + cur.setLength(0); + } + } else { + cur.append(c); + } + } + if (cur.length() > 0) { + parts.add(cur.toString()); + } + return parts; + } + + private static void stop(Process process) throws InterruptedException { + process.destroy(); + if (!process.waitFor(2, TimeUnit.SECONDS)) { + process.destroyForcibly(); + process.waitFor(2, TimeUnit.SECONDS); + } + } + + /** + * 读取文件尾部(抄自 FfmpegProcessRunner.readTail)。 + */ + static String readTail(Path path, int maxBytes) throws IOException { + if (!Files.exists(path)) { + return ""; + } + long size = Files.size(path); + int bytesToRead = (int) Math.min(size, maxBytes); + ByteBuffer buffer = ByteBuffer.allocate(bytesToRead); + try (SeekableByteChannel channel = Files.newByteChannel(path, StandardOpenOption.READ)) { + channel.position(Math.max(0, size - bytesToRead)); + while (buffer.hasRemaining() && channel.read(buffer) >= 0) { + // 读取直到尾部 + } + } + return new String(buffer.array(), 0, buffer.position(), StandardCharsets.UTF_8).trim(); + } + + @Override + public String getToolName() { + return "execute_command"; + } + + @Override + public String getDisplayName() { + return "执行命令"; + } + + @Override + public String getDescription() { + return DESCRIPTION; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java index 8f6c0cdd..3104029b 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java @@ -2,6 +2,9 @@ package org.ruoyi.mcp.tools; import dev.langchain4j.agent.tool.Tool; import org.ruoyi.mcp.service.core.BuiltinToolProvider; +import org.ruoyi.service.coding.CodingEventChannel; +import org.ruoyi.service.coding.CodingSseEvent; +import org.ruoyi.service.coding.WorkspaceGuard; import org.springframework.stereotype.Component; import java.io.IOException; @@ -30,9 +33,20 @@ public class ListDirectoryTool implements BuiltinToolProvider { private final String rootDirectory; private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass()); + /** 编程能力 SSE 事件通道,可为 null(兼容无参构造的老调用方) */ + private final CodingEventChannel channel; public ListDirectoryTool() { this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString(); + this.channel = null; + } + + /** + * 编程能力专用构造:注入会话工作目录与事件通道。 + */ + public ListDirectoryTool(Path root, CodingEventChannel channel) { + this.rootDirectory = root.toAbsolutePath().normalize().toString(); + this.channel = channel; } /** @@ -74,11 +88,23 @@ public class ListDirectoryTool implements BuiltinToolProvider { return "Error: Path is not a directory: " + params.filePath; } + // 推送列目录开始事件 + if (channel != null) { + channel.send(CodingSseEvent.of("list-progress", null, null, + "扫描 " + getRelativePath(dirPath), "running")); + } + // 列出文件和目录 List fileInfos = listFiles(dirPath, params); // 生成输出 - return formatFileList(fileInfos, params); + String output = formatFileList(fileInfos, params); + + if (channel != null) { + channel.send(CodingSseEvent.of("list-progress", null, null, + "共 " + fileInfos.size() + " 项", "done")); + } + return output; } catch (IOException e) { logger.error("Error listing directory: {}", params.filePath, e); @@ -233,14 +259,7 @@ public class ListDirectoryTool implements BuiltinToolProvider { } private boolean isWithinWorkspace(Path dirPath) { - try { - Path workspaceRoot = Paths.get(rootDirectory).toRealPath(); - Path normalizedPath = dirPath.normalize(); - return normalizedPath.startsWith(workspaceRoot.normalize()); - } catch (IOException e) { - logger.warn("Could not resolve workspace path", e); - return false; - } + return WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), dirPath); } private String getRelativePath(Path dirPath) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java index 7b4886e7..369c330b 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java @@ -2,6 +2,9 @@ package org.ruoyi.mcp.tools; import dev.langchain4j.agent.tool.Tool; import org.ruoyi.mcp.service.core.BuiltinToolProvider; +import org.ruoyi.service.coding.CodingEventChannel; +import org.ruoyi.service.coding.CodingSseEvent; +import org.ruoyi.service.coding.WorkspaceGuard; import org.springframework.stereotype.Component; import java.io.IOException; @@ -22,10 +25,26 @@ public class ReadFileTool implements BuiltinToolProvider { "Returns the complete file content as a string."; private final String rootDirectory; + /** 读取内容截断上限,避免大文件撑爆 LLM 上下文(32KB) */ + private static final int MAX_BYTES = 32 * 1024; private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass()); + /** 编程能力 SSE 事件通道,可为 null(兼容 BuiltinToolRegistry 无参构造的老调用方) */ + private final CodingEventChannel channel; public ReadFileTool() { this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString(); + this.channel = null; + } + + /** + * 编程能力专用构造:注入会话工作目录与事件通道。 + * + * @param root 工作目录根(绝对路径) + * @param channel SSE 事件通道,工具执行前后推送 read 进度 + */ + public ReadFileTool(Path root, CodingEventChannel channel) { + this.rootDirectory = root.toAbsolutePath().normalize().toString(); + this.channel = channel; } /** @@ -50,7 +69,7 @@ public class ReadFileTool implements BuiltinToolProvider { } // 验证是否在工作目录内 - if (!isWithinWorkspace(path)) { + if (!WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), path)) { return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath; } @@ -64,16 +83,31 @@ public class ReadFileTool implements BuiltinToolProvider { return "Error: Path is a directory, not a file: " + filePath; } - // 读取文件内容 - String content = Files.readString(path, StandardCharsets.UTF_8); - - // 获取相对路径 + // 推送读取开始事件(前端展示为正在读取) String relativePath = getRelativePath(path); + if (channel != null) { + channel.send(CodingSseEvent.of("edit-start", filePath, null, null, "running")); + } + + // 读取文件内容(截断超大文件,避免撑爆上下文) + String content = Files.readString(path, StandardCharsets.UTF_8); + boolean truncated = false; + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAX_BYTES) { + content = new String(bytes, 0, MAX_BYTES, StandardCharsets.UTF_8); + truncated = true; + } + long sizeBytes = content.getBytes(StandardCharsets.UTF_8).length; long lineCount = content.lines().count(); + String header = String.format("File: %s (%d lines, %d bytes)%s\n\n", + relativePath, lineCount, sizeBytes, truncated ? " [truncated]" : ""); - return String.format("File: %s (%d lines, %d bytes)\n\n%s", - relativePath, lineCount, sizeBytes, content); + if (channel != null) { + channel.send(CodingSseEvent.of("edit-end", filePath, null, null, "done")); + } + + return header + content; } catch (IOException e) { logger.error("Error reading file: {}", filePath, e); @@ -85,14 +119,7 @@ public class ReadFileTool implements BuiltinToolProvider { } private boolean isWithinWorkspace(Path filePath) { - try { - Path workspaceRoot = Paths.get(rootDirectory).toRealPath(); - Path normalizedPath = filePath.normalize(); - return normalizedPath.startsWith(workspaceRoot.normalize()); - } catch (IOException e) { - logger.warn("Could not resolve workspace path", e); - return false; - } + return WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), filePath); } private String getRelativePath(Path filePath) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/WriteFileTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/WriteFileTool.java new file mode 100644 index 00000000..615c4aa3 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/WriteFileTool.java @@ -0,0 +1,136 @@ +package org.ruoyi.mcp.tools; + +import dev.langchain4j.agent.tool.Tool; +import org.ruoyi.mcp.service.core.BuiltinToolProvider; +import org.ruoyi.service.coding.CodingEventChannel; +import org.ruoyi.service.coding.CodingSseEvent; +import org.ruoyi.service.coding.WorkspaceGuard; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; + +/** + * 写文件工具 + * 新建或覆盖文件,自动创建父目录 + * + *

编程能力专用:通过构造注入工作目录与 SSE 事件通道,操作前后推送 add-start/add-end 事件。 + * 不注册为 BuiltinToolProvider(无需进 BuiltinToolRegistry),仅由 CodingServiceImpl 按会话 new。 + * + * @author ageerle + */ +@Component +public class WriteFileTool implements BuiltinToolProvider { + + public static final String DESCRIPTION = "Creates or overwrites a file with the given content. " + + "Creates parent directories if missing. Overwrites existing file. " + + "Use absolute paths within the workspace directory."; + + private final String rootDirectory; + private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass()); + private final CodingEventChannel channel; + + public WriteFileTool() { + this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString(); + this.channel = null; + } + + /** + * 编程能力专用构造。 + */ + public WriteFileTool(Path root, CodingEventChannel channel) { + this.rootDirectory = root.toAbsolutePath().normalize().toString(); + this.channel = channel; + } + + /** + * 写文件 + * + * @param filePath 文件绝对路径 + * @param content 文件内容 + * @return 操作结果 + */ + @Tool(DESCRIPTION) + public String writeFile(String filePath, String content) { + try { + if (filePath == null || filePath.trim().isEmpty()) { + return "Error: File path cannot be empty"; + } + if (content == null) { + content = ""; + } + + Path path = Paths.get(filePath); + + if (!path.isAbsolute()) { + return "Error: File path must be absolute: " + filePath; + } + + if (!WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), path)) { + return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath; + } + + if (channel != null) { + channel.send(CodingSseEvent.of("add-start", filePath, null, null, "running")); + } + + // 创建父目录 + Path parent = path.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + + // 写入文件 + Files.writeString(path, content, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + + String relativePath = getRelativePath(path); + int bytes = content.getBytes(StandardCharsets.UTF_8).length; + + if (channel != null) { + channel.send(CodingSseEvent.of("add-end", filePath, null, + "写入 " + bytes + " 字节", "done")); + } + return String.format("Successfully wrote %d bytes to %s", bytes, relativePath); + + } catch (IOException e) { + logger.error("Error writing file: {}", filePath, e); + if (channel != null) { + channel.send(CodingSseEvent.of("add-end", filePath, null, + "Error: " + e.getMessage(), "done")); + } + return "Error: " + e.getMessage(); + } catch (Exception e) { + logger.error("Unexpected error writing file: {}", filePath, e); + return "Error: Unexpected error: " + e.getMessage(); + } + } + + private String getRelativePath(Path filePath) { + try { + Path workspaceRoot = Paths.get(rootDirectory); + return workspaceRoot.relativize(filePath).toString(); + } catch (Exception e) { + return filePath.toString(); + } + } + + @Override + public String getToolName() { + return "write_file"; + } + + @Override + public String getDisplayName() { + return "写入文件"; + } + + @Override + public String getDescription() { + return DESCRIPTION; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java new file mode 100644 index 00000000..bab5ca4e --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java @@ -0,0 +1,98 @@ +package org.ruoyi.service.audio.provider; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.entity.audio.AudioContext; +import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.enums.ChatModeType; +import org.ruoyi.service.audio.AbstractAudioGenerationService; +import org.ruoyi.service.media.AtlasMediaSupport; +import org.ruoyi.service.media.AtlasPredictionService; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Atlas Cloud 音频生成(bytedance/seed-audio-1.0)。异步:提交 /model/generateAudio 返回 predictionId, + * 轮询 /model/prediction/{id} 拿音频 URL。支持 references(speaker 音色 + 参考音频)做多角色对白配音。 + */ +@Slf4j +@Component("atlasAudio") +@RequiredArgsConstructor +public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationService { + + private final AtlasPredictionService atlasPredictionService; + + private final OkHttpClient okHttpClient = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(180, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + + @Override + protected MediaGenerationResponse doGenerateSpeech(AudioContext audioContext) { + ChatModelVo model = audioContext.getChatModelVo(); + ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode(); + payload.put("model", model.getModelName()); + payload.put("text", audioContext.getInput()); + String format = StrUtil.blankToDefault(audioContext.getResponseFormat(), "mp3"); + payload.put("format", format); + + // 参考资源:多角色音色 + 参考音频 + List> refs = audioContext.getReferences(); + if (refs != null && !refs.isEmpty()) { + ArrayNode arr = payload.putArray("references"); + for (Map ref : refs) { + ObjectNode r = arr.addObject(); + if (StrUtil.isNotBlank(ref.get("speaker"))) r.put("speaker", ref.get("speaker")); + if (StrUtil.isNotBlank(ref.get("audioUrl"))) r.put("audio_url", ref.get("audioUrl")); + if (StrUtil.isNotBlank(ref.get("audioData"))) r.put("audio_data", ref.get("audioData")); + if (StrUtil.isNotBlank(ref.get("imageData"))) r.put("image_data", ref.get("imageData")); + } + } else if (StrUtil.isNotBlank(audioContext.getVoice())) { + // 没有显式 references 但指定了 voice 音色名:作为单一 speaker + ArrayNode arr = payload.putArray("references"); + ObjectNode r = arr.addObject(); + r.put("speaker", audioContext.getVoice()); + } + + if (audioContext.getSampleRate() != null) payload.put("sample_rate", audioContext.getSampleRate()); + if (audioContext.getPitchRate() != null) payload.put("pitch_rate", audioContext.getPitchRate()); + if (audioContext.getSpeechRate() != null) payload.put("speech_rate", audioContext.getSpeechRate()); + if (audioContext.getLoudnessRate() != null) payload.put("loudness_rate", audioContext.getLoudnessRate()); + + Request request = new Request.Builder() + .url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateAudio")) + .addHeader("Authorization", "Bearer " + model.getApiKey()) + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON)) + .build(); + try (Response response = okHttpClient.newCall(request).execute()) { + ResponseBody body = response.body(); + String responseText = body == null ? "" : body.string(); + if (!response.isSuccessful()) { + throw new IllegalArgumentException("Atlas Cloud 音频生成任务创建失败: " + response.code() + " - " + responseText); + } + return atlasPredictionService.toResponse(responseText, "audio"); + } catch (IOException e) { + throw new RuntimeException("Atlas Cloud 音频生成任务创建失败: " + e.getMessage(), e); + } + } + + @Override + public String getProviderName() { + return ChatModeType.ATLAS.getCode(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingAgent.java new file mode 100644 index 00000000..d86e9760 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingAgent.java @@ -0,0 +1,29 @@ +package org.ruoyi.service.coding; + +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; + +/** + * 编程智能体 AiServices 接口。 + * + *

配合 {@code AiServices.builder(CodingAgent.class).chatModel(...).tools(...)} 构建。 + * 同步 {@code String chat(...)} 方案(方案 B):工具执行过程中的 add/edit/delete/cmd 事件 + * 由工具内部通过 {@link CodingEventChannel} 实时推送,最终回复文本在 chat() 返回后一次性推 text。 + * + *

{@code @SystemMessage} 约束 LLM 只能操作 workspace 内文件,并明确每个工具的用途, + * 提升工具调用命中率。 + * + * @author ageerle + */ +public interface CodingAgent { + + @SystemMessage(""" + 你是一个编程助手,直接操作用户工作目录内的文件与命令。规则: + 1. 所有文件操作必须在 workspace 目录内,使用绝对路径;不要越界访问外部目录。 + 2. 读取文件用 read_file,新建/覆盖文件用 write_file,修改已存在文件用 edit_file, + 删除文件或目录用 delete_file,查看目录结构用 list_directory,执行构建/运行命令用 execute_command。 + 3. 修改文件前,先用 read_file 读取当前内容,避免覆盖丢失代码。 + 4. 执行命令前说明意图,命令失败时读取输出排查。 + 5. 完成任务后用一两句话总结做了什么。""") + String chat(@UserMessage String userMessage); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingEventChannel.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingEventChannel.java new file mode 100644 index 00000000..cb98c8ba --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingEventChannel.java @@ -0,0 +1,93 @@ +package org.ruoyi.service.coding; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** + * 编程能力跨线程事件通道。 + * + *

结构抄自 {@code OutputChannel},但队列元素是结构化 {@link CodingSseEvent} 而非 String。 + * OutputChannel 的 {@code send(String)} 塞 JSON 字符串再让 drain 端解析是反模式; + * 这里直接传结构化对象,drain 时再由 Service 层序列化。 + * + *

调用链路: + *

+ *   异步线程(工具执行、LLM 回调)-> channel.send(event)
+ *   drain 线程                       -> channel.drain(emitter::send)
+ * 
+ * + * @author ageerle + */ +public class CodingEventChannel { + + /** DONE 哨兵,drain 遇到即退出 */ + private static final CodingSseEvent DONE = new CodingSseEvent("__done__", null, null, null, null); + + private final BlockingQueue queue = new LinkedBlockingQueue<>(4096); + private final AtomicReference error = new AtomicReference<>(); + private final CountDownLatch completed = new CountDownLatch(1); + + /** + * 写入一个事件:线程安全,队列满时 100ms 超时丢弃。 + */ + public void send(CodingSseEvent event) { + if (event == null) { + return; + } + try { + if (!queue.offer(event, 100, TimeUnit.MILLISECONDS)) { + // 队列满,丢弃但不中断流程 + System.err.println("[CodingEventChannel] 队列满,丢弃事件: " + event.eventType()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * 标记正常完成。 + */ + public void complete() { + queue.offer(DONE); + completed.countDown(); + } + + /** + * 标记错误完成,附带一条 error 事件。 + */ + public void completeWithError(Throwable t) { + error.set(t); + if (t != null && t.getMessage() != null) { + queue.offer(CodingSseEvent.error(t.getMessage())); + } + queue.offer(DONE); + completed.countDown(); + } + + /** + * 阻塞读取事件并逐个回调;遇 DONE 退出。 + * + * @param emitter 事件消费回调 + */ + public void drain(Consumer emitter) throws InterruptedException { + while (true) { + CodingSseEvent msg = queue.poll(200, TimeUnit.MILLISECONDS); + if (msg != null) { + if (DONE == msg || "__done__".equals(msg.eventType())) { + break; + } + emitter.accept(msg); + } else if (completed.getCount() == 0 && queue.isEmpty()) { + break; + } + } + } + + public boolean isCompleted() { + return completed.getCount() == 0; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingSseEvent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingSseEvent.java new file mode 100644 index 00000000..db7c610e --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingSseEvent.java @@ -0,0 +1,43 @@ +package org.ruoyi.service.coding; + +/** + * 编程能力 SSE 事件 DTO。 + * + *

事件名与前端 {@code ruoyi-copilot/src/App.vue} 的 {@code applyStreamEvent} 卡片契约对齐: + *

    + *
  • {@code thinking} / {@code text} —— LLM 思考/回复文本增量
  • + *
  • {@code add|edit|delete}-{start|progress|end} —— 文件写入/编辑/删除
  • + *
  • {@code cmd} —— 命令执行
  • + *
  • {@code list-progress} —— 列目录
  • + *
  • {@code done} / {@code error} —— 流结束
  • + *
+ * + *

约束:add/edit/delete 必须带 filePath(前端用 operation.filePath 存在性区分 + * code-change 卡 vs activity-row 卡);cmd/list-progress 不带 filePath。 + * + * @author ageerle + */ +public record CodingSseEvent(String eventType, String filePath, String command, + String content, String status) { + + public static CodingSseEvent of(String eventType, String filePath, String command, + String content, String status) { + return new CodingSseEvent(eventType, filePath, command, content, status); + } + + public static CodingSseEvent text(String content) { + return new CodingSseEvent("text", null, null, content, null); + } + + public static CodingSseEvent thinking(String content) { + return new CodingSseEvent("thinking", null, null, content, null); + } + + public static CodingSseEvent done() { + return new CodingSseEvent("done", null, null, null, null); + } + + public static CodingSseEvent error(String message) { + return new CodingSseEvent("error", null, null, message, null); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingWorkspaceService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingWorkspaceService.java new file mode 100644 index 00000000..10d31465 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/CodingWorkspaceService.java @@ -0,0 +1,134 @@ +package org.ruoyi.service.coding; + +import org.ruoyi.mcp.tools.ExecuteCommandTool; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Comparator; +import java.util.List; + +/** Basic, workspace-scoped file and command operations for the Copilot UI. */ +@Service +public class CodingWorkspaceService { + + public static final String DEFAULT_WORKSPACE = "D:/Project/github/ruoyi-copilot"; + private static final long MAX_FILE_BYTES = 1024 * 1024; + private static final int MAX_ENTRIES = 500; + + public WorkspaceResult list(String workspacePath) throws IOException { + Path root = resolveRoot(workspacePath); + Files.createDirectories(root); + try (var stream = Files.walk(root, 8)) { + List files = stream + .filter(path -> !path.equals(root)) + .filter(path -> !isIgnored(root, path)) + .sorted(Comparator.comparing(path -> root.relativize(path).toString())) + .limit(MAX_ENTRIES) + .map(path -> toEntry(root, path)) + .toList(); + return new WorkspaceResult(root.toString(), files.size(), files); + } + } + + public FileContent read(String workspacePath, String relativePath) throws IOException { + Path root = resolveRoot(workspacePath); + Path file = resolveFile(root, relativePath); + if (!Files.isRegularFile(file)) { + throw new IllegalArgumentException("File does not exist: " + relativePath); + } + long size = Files.size(file); + if (size > MAX_FILE_BYTES) { + throw new IllegalArgumentException("File is larger than 1 MB: " + relativePath); + } + if (isBinary(file)) { + throw new IllegalArgumentException("Binary files cannot be edited: " + relativePath); + } + return new FileContent(normalizeRelative(root, file), Files.readString(file, StandardCharsets.UTF_8), size); + } + + public FileContent write(String workspacePath, String relativePath, String content) throws IOException { + Path root = resolveRoot(workspacePath); + Path file = resolveFile(root, relativePath); + byte[] bytes = (content == null ? "" : content).getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAX_FILE_BYTES) { + throw new IllegalArgumentException("File content is larger than 1 MB"); + } + if (file.getParent() != null) Files.createDirectories(file.getParent()); + Files.writeString(file, content == null ? "" : content, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + return new FileContent(normalizeRelative(root, file), content == null ? "" : content, bytes.length); + } + + public CommandResult execute(String workspacePath, String command) { + Path root = resolveRoot(workspacePath); + String output = new ExecuteCommandTool(root, null).executeCommand(command); + boolean success = !output.startsWith("Error:"); + return new CommandResult(command, output, success); + } + + public Path resolveRoot(String workspacePath) { + Path configured = Paths.get(DEFAULT_WORKSPACE).toAbsolutePath().normalize(); + if (workspacePath == null || workspacePath.isBlank()) return configured; + Path requested = Paths.get(workspacePath).toAbsolutePath().normalize(); + if (!requested.equals(configured)) { + throw new IllegalArgumentException("Workspace is not allowed: " + requested); + } + return configured; + } + + private Path resolveFile(Path root, String relativePath) { + if (relativePath == null || relativePath.isBlank()) { + throw new IllegalArgumentException("File path cannot be empty"); + } + Path supplied = Paths.get(relativePath); + Path target = (supplied.isAbsolute() ? supplied : root.resolve(supplied)).normalize(); + if (!WorkspaceGuard.isWithinWorkspace(root, target)) { + throw new IllegalArgumentException("File must be inside the workspace"); + } + return target; + } + + private boolean isIgnored(Path root, Path path) { + Path relative = root.relativize(path); + for (Path part : relative) { + String name = part.toString(); + if (name.equals(".git") || name.equals("node_modules") || name.equals("dist") || name.equals("target")) { + return true; + } + } + return false; + } + + private FileEntry toEntry(Path root, Path path) { + try { + return new FileEntry(normalizeRelative(root, path), path.getFileName().toString(), + Files.isDirectory(path), Files.isDirectory(path) ? 0 : Files.size(path)); + } catch (IOException e) { + return new FileEntry(normalizeRelative(root, path), path.getFileName().toString(), + Files.isDirectory(path), 0); + } + } + + private String normalizeRelative(Path root, Path path) { + return root.relativize(path).toString().replace('\\', '/'); + } + + private boolean isBinary(Path file) throws IOException { + byte[] sample; + try (var input = Files.newInputStream(file)) { + sample = input.readNBytes(4096); + } + for (byte value : sample) if (value == 0) return true; + return false; + } + + public record FileEntry(String path, String name, boolean directory, long size) { } + public record WorkspaceResult(String root, int fileCount, List files) { } + public record FileContent(String path, String content, long size) { } + public record CommandResult(String command, String output, boolean success) { } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/ICodingService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/ICodingService.java new file mode 100644 index 00000000..f48e1fad --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/ICodingService.java @@ -0,0 +1,21 @@ +package org.ruoyi.service.coding; + +import org.ruoyi.domain.bo.coding.CodingRequestBo; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * 编程能力 Service + * + * @author ageerle + */ +public interface ICodingService { + + /** + * 编程对话(SSE 流式) + * + * @param bo 请求参数 + * @param userId 用户 ID(可为 null,第一阶段免鉴权) + * @return SseEmitter + */ + SseEmitter chat(CodingRequestBo bo, Long userId); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/WorkspaceGuard.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/WorkspaceGuard.java new file mode 100644 index 00000000..d71b2f4a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/WorkspaceGuard.java @@ -0,0 +1,50 @@ +package org.ruoyi.service.coding; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * 工作目录安全守卫。 + * + *

抽取自 {@code ReadFileTool/EditFileTool/ListDirectoryTool} 中重复的 {@code isWithinWorkspace}, + * 五个文件工具共用。强制所有操作路径必须落在工作目录内,防止路径穿越与软链接逃逸。 + * + *

实现要点: + *

    + *
  • {@code root.toRealPath()} 解析符号链接(防软链接逃逸)
  • + *
  • {@code target.normalize()} 消除 {@code ..} 穿越段
  • + *
  • {@code startsWith} 是 Path 段前缀匹配,非字符串前缀({@code /workspace/abc} 不会误判成 {@code /workspace-evil})
  • + *
+ * + * @author ageerle + */ +public final class WorkspaceGuard { + + private WorkspaceGuard() { + } + + /** + * 判断目标路径是否在工作目录内。 + * + * @param root 工作目录根(绝对路径) + * @param target 待校验路径 + * @return true 表示在 workspace 内,安全 + */ + public static boolean isWithinWorkspace(Path root, Path target) { + try { + Path realRoot = root.toRealPath().normalize(); + Path realTarget = target.normalize(); + return realTarget.startsWith(realRoot); + } catch (IOException e) { + // 目标路径不存在或无法解析(如新建文件前其父目录链中有不存在的段) + // 退化为 normalize 后做段前缀匹配,仍能拦住明显的越界 + try { + Path realRoot = root.toRealPath().normalize(); + Path normalizedTarget = target.normalize(); + return normalizedTarget.startsWith(realRoot); + } catch (IOException ignore) { + return false; + } + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/impl/CodingServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/impl/CodingServiceImpl.java new file mode 100644 index 00000000..671ed1b6 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/coding/impl/CodingServiceImpl.java @@ -0,0 +1,198 @@ +package org.ruoyi.service.coding.impl; + +import cn.hutool.core.util.StrUtil; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.service.AiServices; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.common.json.utils.JsonUtils; +import org.ruoyi.domain.bo.coding.CodingRequestBo; +import org.ruoyi.factory.ChatServiceFactory; +import org.ruoyi.mcp.tools.DeleteFileTool; +import org.ruoyi.mcp.tools.EditFileTool; +import org.ruoyi.mcp.tools.ExecuteCommandTool; +import org.ruoyi.mcp.tools.ListDirectoryTool; +import org.ruoyi.mcp.tools.ReadFileTool; +import org.ruoyi.mcp.tools.WriteFileTool; +import org.ruoyi.service.chat.AbstractChatService; +import org.ruoyi.service.coding.CodingAgent; +import org.ruoyi.service.coding.CodingEventChannel; +import org.ruoyi.service.coding.CodingSseEvent; +import org.ruoyi.service.coding.ICodingService; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 编程能力 Service 实现。 + * + *

B 路径:自建 SseEmitter(不进 SseEmitterManager 全局注册表),照 ShortDramaServiceImpl 骨架。 + * 拿模型三步(skill 铁律)→ 解析工作目录 → new 工具实例注入 channel+root → AiServices 构建 → + * 异步执行,工具内部通过 channel 实时推事件,drain 线程把事件写到 emitter。 + * + * @author ageerle + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CodingServiceImpl implements ICodingService { + + /** 默认工作目录:直接指向 ruoyi-copilot 前端项目 */ + private static final String DEFAULT_WORKSPACE = "D:/Project/github/ruoyi-copilot"; + + private final IChatModelService chatModelService; + private final ChatServiceFactory chatServiceFactory; + private final Map activeEmitters = new ConcurrentHashMap<>(); + + @Override + public SseEmitter chat(CodingRequestBo bo, Long userId) { + SseEmitter emitter = new SseEmitter(1_800_000L); + AtomicBoolean emitterActive = new AtomicBoolean(true); + activeEmitters.put(emitter, emitterActive); + emitter.onCompletion(() -> closeEmitter(emitter)); + emitter.onTimeout(() -> closeEmitter(emitter)); + emitter.onError(error -> closeEmitter(emitter)); + + CompletableFuture.runAsync(() -> { + CodingEventChannel channel = new CodingEventChannel(); + Thread drainThread = new Thread(() -> { + try { + channel.drain(event -> sendEmitterEvent(emitter, toSseEvent(event))); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Throwable t) { + log.error("编程 SSE drain 线程异常", t); + } + }, "coding-sse-drain"); + drainThread.start(); + + try { + // 推送思考开始 + channel.send(CodingSseEvent.thinking("正在分析指令...")); + + // 1. 拿模型三步(不硬编码配置) + ChatModelVo modelVo = chatModelService.selectModelByName(bo.getModel()); + if (modelVo == null) { + throw new IllegalStateException("模型未找到: " + bo.getModel() + + ",请在 chat_model 表配置该模型名称"); + } + AbstractChatService chatService = chatServiceFactory.getOriginalService(modelVo.getProviderCode()); + ChatModel chatModel = chatService.buildChatModel(modelVo); + + // 2. 解析工作目录 + Path root = resolveWorkspace(bo.getWorkspacePath()); + Files.createDirectories(root); + + // 3. new 工具实例(不走 BuiltinToolRegistry,注入会话工作目录与 channel) + ReadFileTool read = new ReadFileTool(root, channel); + EditFileTool edit = new EditFileTool(root, channel); + ListDirectoryTool list = new ListDirectoryTool(root, channel); + WriteFileTool write = new WriteFileTool(root, channel); + DeleteFileTool delete = new DeleteFileTool(root, channel); + ExecuteCommandTool exec = new ExecuteCommandTool(root, channel); + + // 4. 构建 AiServices + CodingAgent agent = AiServices.builder(CodingAgent.class) + .chatModel(chatModel) + .tools(read, edit, list, write, delete, exec) + .build(); + + // 5. 同步调用(方案 B):工具执行过程中事件通过 channel 实时推送 + String result = agent.chat(bo.getPrompt()); + + // 6. 推送最终文本 + if (StrUtil.isNotBlank(result)) { + channel.send(CodingSseEvent.text(result)); + } + channel.send(CodingSseEvent.done()); + channel.complete(); + drainThread.join(5_000); + completeEmitter(emitter); + + } catch (Exception e) { + log.error("编程对话失败", e); + String msg = e.getMessage() == null ? e.toString() : e.getMessage(); + channel.send(CodingSseEvent.error(msg)); + channel.completeWithError(e); + try { + drainThread.join(2_000); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + sendEmitterEvent(emitter, SseEmitter.event().name("error") + .data(JsonUtils.toJsonString(Map.of("message", msg)))); + completeEmitterWithError(emitter, e); + } + }); + + return emitter; + } + + /** + * 解析工作目录:前端显式传则用前端的,否则默认 ruoyi-copilot。 + */ + private Path resolveWorkspace(String workspacePath) { + if (StrUtil.isNotBlank(workspacePath)) { + return Paths.get(workspacePath).toAbsolutePath().normalize(); + } + return Paths.get(DEFAULT_WORKSPACE).toAbsolutePath().normalize(); + } + + /** + * 把结构化事件转成 SseEmitter 事件。 + */ + private SseEmitter.SseEventBuilder toSseEvent(CodingSseEvent event) { + Map payload = new LinkedHashMap<>(); + if (event.filePath() != null) payload.put("filePath", event.filePath()); + if (event.command() != null) payload.put("command", event.command()); + if (event.content() != null) payload.put("content", event.content()); + if (event.status() != null) payload.put("status", event.status()); + return SseEmitter.event() + .name(event.eventType()) + .data(JsonUtils.toJsonString(payload)); + } + + // ==================== SSE 发送封装(抄自 ShortDramaServiceImpl) ==================== + + private boolean sendEmitterEvent(SseEmitter emitter, SseEmitter.SseEventBuilder event) { + AtomicBoolean active = activeEmitters.get(emitter); + if (active == null || !active.get()) return false; + try { + emitter.send(event); + return true; + } catch (IOException | IllegalStateException e) { + closeEmitter(emitter); + return false; + } + } + + private void closeEmitter(SseEmitter emitter) { + AtomicBoolean active = activeEmitters.remove(emitter); + if (active != null) active.set(false); + } + + private void completeEmitter(SseEmitter emitter) { + AtomicBoolean active = activeEmitters.get(emitter); + if (active == null || !active.compareAndSet(true, false)) return; + activeEmitters.remove(emitter); + try { emitter.complete(); } catch (IllegalStateException ignored) { } + } + + private void completeEmitterWithError(SseEmitter emitter, Throwable error) { + AtomicBoolean active = activeEmitters.get(emitter); + if (active == null || !active.compareAndSet(true, false)) return; + activeEmitters.remove(emitter); + try { emitter.completeWithError(error); } catch (IllegalStateException ignored) { } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasMediaSupport.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasMediaSupport.java index b99aa3b6..1822fa11 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasMediaSupport.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasMediaSupport.java @@ -35,4 +35,12 @@ public final class AtlasMediaSupport { JsonNode value = node == null ? null : node.get(field); return value == null || value.isNull() ? null : value.asText(); } + + /** + * 截断超长文本,用于日志输出原始响应时防止刷屏。 + */ + public static String truncate(String text, int max) { + if (text == null) return null; + return text.length() <= max ? text : text.substring(0, max) + "...(truncated " + (text.length() - max) + " chars)"; + } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java index 3c79205d..247dd07f 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java @@ -59,16 +59,56 @@ public class AtlasPredictionService { public MediaGenerationResponse toResponse(String raw, String type) throws IOException { JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(raw); JsonNode data = root.path("data"); + String status = AtlasMediaSupport.text(data, "status"); + String lastFrameUrl = firstLastFrame(data); + if ("video".equals(type)) { + // 终态打完整原始响应(确认 Atlas 末帧字段名/结构),轮询中间态只打摘要,避免刷屏 + if ("succeeded".equals(status) || "completed".equals(status) || "failed".equals(status)) { + log.info("Atlas 视频结果[{}]原始响应: {}", status, AtlasMediaSupport.truncate(raw, 2000)); + } + log.info("Atlas 视频结果解析: status={}, lastFrameUrl={}", status, lastFrameUrl); + } return MediaGenerationResponse.builder() .type(type) .mimeType("image".equals(type) ? "image/png" : "video/mp4") .id(AtlasMediaSupport.text(data, "id")) - .status(AtlasMediaSupport.text(data, "status")) + .status(status) .url(firstOutput(data)) + .lastFrameUrl(lastFrameUrl) .rawResponse(raw) .build(); } + /** + * 提取末帧 URL。Atlas return_last_frame=true 时会在 outputs 或顶层节点返回末帧图片, + * 字段名兼容 last_frame_url / end_frame_url / last_frame / last_frame_image。 + */ + private String firstLastFrame(JsonNode data) { + if (data == null || data.isMissingNode()) return null; + String[] keys = {"last_frame_url", "end_frame_url", "last_frame", "last_frame_image"}; + for (String key : keys) { + String val = AtlasMediaSupport.text(data, key); + if (val != null) return val; + } + JsonNode outputs = data.path("outputs"); + if (outputs.isObject()) { + for (String key : keys) { + String val = AtlasMediaSupport.text(outputs, key); + if (val != null) return val; + } + } + if (outputs.isArray() && !outputs.isEmpty()) { + JsonNode first = outputs.get(0); + if (first.isObject()) { + for (String key : keys) { + String val = AtlasMediaSupport.text(first, key); + if (val != null) return val; + } + } + } + return null; + } + private String firstOutput(JsonNode data) { JsonNode outputs = data.path("outputs"); if (outputs.isArray() && !outputs.isEmpty()) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java index b383f567..751a71ec 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java @@ -1,6 +1,7 @@ package org.ruoyi.service.shortdrama; import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; +import org.ruoyi.domain.bo.shortdrama.ShortDramaAudioBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo; @@ -8,6 +9,7 @@ import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo; +import org.ruoyi.domain.vo.shortdrama.ShortDramaAudioVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo; @@ -113,4 +115,15 @@ public interface IShortDramaService { ShortDramaLocationVo confirmLocationImage(Long locationId, String predictionId, String model, Long userId); Boolean deleteProject(Long projectId, Long userId); + + // ==================== 语音资产 ==================== + + ShortDramaAudioVo saveAudio(ShortDramaAudioBo bo, Long userId); + + Boolean deleteAudio(Long audioId, Long userId); + + List listAudios(Long projectId, Long userId); + + /** 生成语音:TTS 合成音频,上传 OSS,回写 audioUrl/audioOssId */ + ShortDramaAudioVo generateAudio(Long audioId, String audioModel, Long userId); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/AspectRatio.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/AspectRatio.java index 5c44dfc3..c46cd394 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/AspectRatio.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/AspectRatio.java @@ -6,7 +6,10 @@ import com.fasterxml.jackson.annotation.JsonValue; public enum AspectRatio { PORTRAIT("9:16", 1080, 1920), LANDSCAPE("16:9", 1920, 1080), - SQUARE("1:1", 1080, 1080); + LANDSCAPE_CLASSIC("4:3", 1440, 1080), + SQUARE("1:1", 1080, 1080), + PORTRAIT_CLASSIC("3:4", 1080, 1440), + ULTRAWIDE("21:9", 2520, 1080); private final String value; private final int width; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSpec.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSpec.java index 57c61985..8b797613 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSpec.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSpec.java @@ -1,5 +1,6 @@ package org.ruoyi.service.shortdrama.composition; +import java.nio.file.Path; import java.util.List; import java.util.Objects; @@ -7,7 +8,9 @@ public record CompositionSpec( List sources, TransitionType transitionType, double transitionDurationSeconds, - AspectRatio aspectRatio + AspectRatio aspectRatio, + Path narrationAudioPath, + boolean watermark ) { public CompositionSpec { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilder.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilder.java index fc140d67..823b367a 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilder.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilder.java @@ -32,6 +32,11 @@ public class FfmpegCommandBuilder { command.add("-i"); command.add(source.path().toString()); } + // 旁白音轨作为额外输入流(index = sources.size()) + if (spec.narrationAudioPath() != null) { + command.add("-i"); + command.add(spec.narrationAudioPath().toString()); + } command.add("-filter_complex_script"); command.add(filterScript.toAbsolutePath().normalize().toString()); command.add("-map"); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionProperties.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionProperties.java index c71bb3e6..368cee28 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionProperties.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionProperties.java @@ -36,6 +36,17 @@ public class FfmpegCompositionProperties { private String storageMode = "local"; private String localOutputDirectory = "logs/short-drama-compositions"; + /** 成片是否默认加水印(前端开关未传时使用该默认值) */ + private boolean watermarkEnabled = true; + /** 水印文字 */ + private String watermarkText = "视频由ruoyi-drama生成"; + /** 水印字体大小 */ + private int watermarkFontSize = 28; + /** 水印透明度 0.0-1.0 */ + private double watermarkAlpha = 0.6; + /** 水印字体文件路径(为空则用系统默认字体) */ + private String watermarkFontFile; + public BigDecimal normalizeTransitionDuration(TransitionType type, BigDecimal requested) { if (type == null || type == TransitionType.NONE) { return BigDecimal.ZERO; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilder.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilder.java index 9b8ae055..6ce2855e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilder.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilder.java @@ -4,6 +4,8 @@ import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.math.RoundingMode; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -42,10 +44,32 @@ public class FfmpegFilterGraphBuilder { filters.add(normalizeAudio(index, duration, info.hasAudio())); } + FfmpegFilterGraph base; if (spec.transitionType() == TransitionType.NONE) { - return hardCut(filters, media, canvas); + base = hardCut(filters, media, canvas); + } else { + base = transition(filters, spec, media, canvas, frameSeconds); } - return transition(filters, spec, media, canvas, frameSeconds); + + // 旁白音轨混入:在最终音轨上 amix 旁白输入(输入 index = sources.size()) + String audioLabel = base.audioLabel(); + if (spec.narrationAudioPath() != null) { + audioLabel = mixNarration(filters, base, media, audioLabel); + } + + // 水印:在最终视频流上 drawtext + String videoLabel = base.videoLabel(); + if (spec.watermark()) { + videoLabel = applyWatermark(filters, videoLabel, canvas); + } + + return new FfmpegFilterGraph( + String.join(";", filters), + videoLabel, + audioLabel, + base.expectedDurationSeconds(), + canvas + ); } private FfmpegFilterGraph hardCut(List filters, List media, VideoCanvas canvas) { @@ -134,6 +158,84 @@ public class FfmpegFilterGraphBuilder { ); } + /** + * 将旁白音轨混入最终音轨。旁白作为额外输入流(index = sources.size()), + * 先归一化再与主音轨 amix,normalize=0 防止原片音被自动压低,duration=first 以原片长度为准。 + */ + private String mixNarration(List filters, FfmpegFilterGraph base, + List media, String audioLabel) { + int narrationIndex = media.size(); + double expectedDuration = base.expectedDurationSeconds(); + String format = "aformat=sample_fmts=fltp:sample_rates=" + properties.getAudioSampleRate() + + ":channel_layouts=stereo"; + filters.add("[" + narrationIndex + ":a:0]" + + "aresample=" + properties.getAudioSampleRate() + ":async=1:first_pts=0," + + format + "," + + "atrim=start=0:duration=" + seconds(expectedDuration) + "," + + "asetpts=PTS-STARTPTS[narr]"); + String mixed = "anarr"; + filters.add("[" + audioLabel + "][narr]amix=inputs=2:duration=first:normalize=0[" + mixed + "]"); + return mixed; + } + + /** + * 在最终视频流右下角叠加水印文字。 + * 优先使用显式配置的字体;未配置时探测各平台的常见字体,避免 Windows 版 FFmpeg + * 在 Fontconfig 配置缺失时因 drawtext 发生原生崩溃。 + */ + private String applyWatermark(List filters, String videoLabel, VideoCanvas canvas) { + String text = properties.getWatermarkText(); + if (text == null || text.isBlank()) { + return videoLabel; + } + String alpha = formatAlpha(properties.getWatermarkAlpha()); + StringBuilder expr = new StringBuilder(); + expr.append("[").append(videoLabel).append("]drawtext=text='").append(escape(text)).append("'"); + String fontFile = resolveWatermarkFontFile(); + expr.append(":fontfile='").append(escape(fontFile)).append("'"); + expr.append(":fontcolor=white@").append(alpha) + .append(":fontsize=").append(properties.getWatermarkFontSize()) + .append(":x=w-tw-20:y=h-th-20[wmark]"); + filters.add(expr.toString()); + return "wmark"; + } + + private String resolveWatermarkFontFile() { + String configured = properties.getWatermarkFontFile(); + if (configured != null && !configured.isBlank()) { + Path configuredPath = Path.of(configured).toAbsolutePath().normalize(); + if (!Files.isRegularFile(configuredPath) || !Files.isReadable(configuredPath)) { + throw new IllegalStateException("Configured watermark font is not a readable file: " + configuredPath); + } + return configuredPath.toString(); + } + + List candidates = List.of( + Path.of("C:/Windows/Fonts/simhei.ttf"), + Path.of("C:/Windows/Fonts/msyh.ttc"), + Path.of("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), + Path.of("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), + Path.of("/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf") + ); + return candidates.stream() + .filter(path -> Files.isRegularFile(path) && Files.isReadable(path)) + .findFirst() + .map(path -> path.toAbsolutePath().normalize().toString()) + .orElseThrow(() -> new IllegalStateException( + "No readable watermark font was found; configure short-drama.composition.watermark-font-file" + )); + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'"); + } + + private static String formatAlpha(double alpha) { + if (alpha <= 0) return "0"; + if (alpha >= 1) return "1"; + return BigDecimal.valueOf(alpha).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString(); + } + private String normalizeVideo(int inputIndex, int videoStreamIndex, String duration, VideoCanvas canvas) { return "[" + inputIndex + ":" + videoStreamIndex + "]" + "scale=" + canvas.width() + ":" + canvas.height() 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 91f9f000..6f07ffa5 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 @@ -27,6 +27,8 @@ import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; import org.ruoyi.common.chat.entity.video.VideoContext; import org.ruoyi.common.chat.factory.ImageServiceFactory; import org.ruoyi.common.chat.factory.VideoServiceFactory; +import org.ruoyi.common.chat.factory.AudioServiceFactory; +import org.ruoyi.common.chat.entity.audio.AudioContext; import org.ruoyi.common.chat.service.chat.IChatModelService; import org.ruoyi.common.core.utils.MapstructUtils; import org.ruoyi.common.core.utils.StringUtils; @@ -37,12 +39,14 @@ import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptResult; import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo; +import org.ruoyi.domain.entity.shortdrama.ShortDramaAudio; import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter; import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacterAppearance; import org.ruoyi.domain.entity.shortdrama.ShortDramaLocation; import org.ruoyi.domain.entity.shortdrama.ShortDramaProject; import org.ruoyi.domain.entity.shortdrama.ShortDramaScript; import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import org.ruoyi.domain.vo.shortdrama.ShortDramaAudioVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo; @@ -50,9 +54,11 @@ import org.ruoyi.domain.vo.shortdrama.ShortDramaLocationVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaProjectVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaStoryboardVo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaAudioBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo; +import org.ruoyi.mapper.shortdrama.ShortDramaAudioMapper; import org.ruoyi.mapper.shortdrama.ShortDramaCharacterMapper; import org.ruoyi.mapper.shortdrama.ShortDramaCharacterAppearanceMapper; import org.ruoyi.mapper.shortdrama.ShortDramaLocationMapper; @@ -95,12 +101,15 @@ public class ShortDramaServiceImpl implements IShortDramaService { private final ShortDramaCharacterMapper characterMapper; private final ShortDramaCharacterAppearanceMapper characterAppearanceMapper; private final ShortDramaLocationMapper locationMapper; + private final ShortDramaAudioMapper audioMapper; private final IChatModelService chatModelService; private final ChatServiceFactory chatServiceFactory; private final VideoServiceFactory videoServiceFactory; private final ImageServiceFactory imageServiceFactory; + private final AudioServiceFactory audioServiceFactory; private final AtlasPredictionService atlasPredictionService; private final IShortDramaVideoComposeService videoComposeService; + private final org.ruoyi.common.core.service.OssService ossService; private final java.util.Map activeEmitters = new ConcurrentHashMap<>(); private final java.util.Map storyboardGenerationStates = new ConcurrentHashMap<>(); @@ -153,6 +162,11 @@ public class ShortDramaServiceImpl implements IShortDramaService { .eq(ShortDramaLocation::getProjectId, projectId)); detailVo.setLocations(locations); + List audios = audioMapper.selectVoList(new LambdaQueryWrapper() + .eq(ShortDramaAudio::getProjectId, projectId) + .orderByAsc(ShortDramaAudio::getId)); + detailVo.setAudios(audios); + List storyboards = storyboardMapper.selectVoList(new LambdaQueryWrapper() .eq(ShortDramaStoryboard::getProjectId, projectId) .orderByAsc(ShortDramaStoryboard::getSceneNo)); @@ -285,6 +299,13 @@ public class ShortDramaServiceImpl implements IShortDramaService { .data("{\"phase\":\"script\",\"status\":\"done\"}")); } + /** 增量推送一个已完成的分镜 panel 给前端(流式规划时第一个完成就展示) */ + private void emitPanel(SseEmitter emitter, StoryboardPanelData panel) { + if (emitter == null || panel == null) return; + String data = "{\"phase\":\"storyboard_plan\",\"status\":\"panel\",\"panel\":" + JsonUtils.toJsonString(panel) + "}"; + sendEmitterEvent(emitter, SseEmitter.event().name("panel").data(data)); + } + private static String escapeJson(String s) { if (s == null) return ""; return s.replace("\\", "\\\\").replace("\"", "\\\""); @@ -618,6 +639,14 @@ public class ShortDramaServiceImpl implements IShortDramaService { @Override public ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId) { + return generateVideo(storyboardId, videoModel, userId, null); + } + + /** + * 生成单镜视频。 + * @param lastFrameUrl 上一镜末帧 URL(仅同场景相邻镜头传入,用于首帧承接);跨场景或首镜传 null + */ + public ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId, String lastFrameUrl) { ShortDramaStoryboard storyboard = storyboardMapper.selectById(storyboardId); if (storyboard == null) throw new IllegalArgumentException("分镜不存在"); ShortDramaProject project = projectMapper.selectById(storyboard.getProjectId()); @@ -625,8 +654,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { ChatModelVo modelVo = chatModelService.selectModelByName(videoModel); if (modelVo == null) throw new IllegalArgumentException("未找到视频模型配置: " + videoModel); - // 收集所有参考图(角色 + 场景) - List referenceImages = findStoryboardReferenceImages(storyboard); + // 收集所有参考图(角色 + 场景 + 末帧承接) + List referenceImages = findStoryboardReferenceImages(storyboard, lastFrameUrl); // 根据参考图数量自动切换模型 if (referenceImages != null && !referenceImages.isEmpty()) { @@ -649,13 +678,17 @@ public class ShortDramaServiceImpl implements IShortDramaService { } } - String enrichedPrompt = buildEnrichedVideoPrompt(storyboard, referenceImages); + String enrichedPrompt = buildEnrichedVideoPrompt(storyboard, referenceImages, lastFrameUrl); VideoContext ctx = VideoContext.builder() .chatModelVo(modelVo) .prompt(enrichedPrompt) + .size(projectAspectRatio(storyboard.getProjectId())) .seconds(storyboard.getDurationSeconds()) .referenceImages(referenceImages) + .generateAudio(Boolean.TRUE) + .returnLastFrame(Boolean.TRUE) + .lastFrameUrl(lastFrameUrl) .build(); String generationToken = "local:" + UUID.randomUUID(); storyboardMapper.update(null, new LambdaUpdateWrapper() @@ -682,14 +715,18 @@ public class ShortDramaServiceImpl implements IShortDramaService { String videoUrl = null; String videoId = null; String videoStatus; + String lastFrame = null; if (response != null && StrUtil.isNotBlank(response.getUrl())) { videoUrl = response.getUrl(); + lastFrame = response.getLastFrameUrl(); videoStatus = "done"; } else if (response != null && StrUtil.isNotBlank(response.getId())) { videoId = response.getId(); + lastFrame = response.getLastFrameUrl(); videoStatus = "generating"; } else if (response != null && "processing".equals(response.getStatus())) { videoId = response.getId(); + lastFrame = response.getLastFrameUrl(); videoStatus = "generating"; } else { videoStatus = "failed"; @@ -699,20 +736,21 @@ public class ShortDramaServiceImpl implements IShortDramaService { .eq(ShortDramaStoryboard::getVideoId, generationToken) .set(ShortDramaStoryboard::getVideoUrl, videoUrl) .set(ShortDramaStoryboard::getVideoId, videoId) - .set(ShortDramaStoryboard::getVideoStatus, videoStatus)); + .set(ShortDramaStoryboard::getVideoStatus, videoStatus) + .set(StrUtil.isNotBlank(lastFrame), ShortDramaStoryboard::getLastFrameUrl, lastFrame)); if (completed > 0) { videoComposeService.invalidateComposition(project.getId()); } return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); } - /** 收集分镜关联的所有参考图:角色形象图(按出场顺序)+ 场景图 */ - private List findStoryboardReferenceImages(ShortDramaStoryboard storyboard) { + /** 收集分镜关联的所有参考图:角色形象图(按出场顺序)+ 场景图 + 可选末帧承接 */ + private List findStoryboardReferenceImages(ShortDramaStoryboard storyboard, String lastFrameUrl) { List images = new ArrayList<>(); List chars = parseCharacterRefs(storyboard.getCharactersJson()); if (chars != null) { for (CharacterRef ref : chars) { - String img = findCharacterImageUrl(storyboard.getProjectId(), ref.getName()); + String img = findCharacterImageUrl(storyboard.getProjectId(), ref.getName(), ref.getAppearance()); if (StrUtil.isNotBlank(img) && !images.contains(img)) { images.add(img); } @@ -724,16 +762,25 @@ public class ShortDramaServiceImpl implements IShortDramaService { images.add(img); } } + // 末帧承接:放在最后一张,@imageN 标记会自动绑定 + if (StrUtil.isNotBlank(lastFrameUrl) && !images.contains(lastFrameUrl)) { + images.add(lastFrameUrl); + } return images.isEmpty() ? null : images; } /** 构建增强提示词:融合镜头语言、摄影规则、角色信息、表演指导、场景描述 */ private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard) { - return buildEnrichedVideoPrompt(storyboard, null); + return buildEnrichedVideoPrompt(storyboard, null, null); } /** 构建增强提示词(含 @imageN 参考图引用,用于 reference-to-video 模型) */ private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard, java.util.List refImages) { + return buildEnrichedVideoPrompt(storyboard, refImages, null); + } + + /** 构建增强提示词(含参考图 + 末帧首帧承接) */ + private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard, java.util.List refImages, String lastFrameUrl) { boolean hasRefImages = refImages != null && !refImages.isEmpty(); StringBuilder sb = new StringBuilder(); @@ -743,6 +790,11 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (StrUtil.isNotBlank(storyboard.getCameraMove())) sb.append(", ").append(storyboard.getCameraMove()); sb.append("]\n"); + // 1.1 核心动作描述前置(最高权重,确保模型优先关注当前镜头的具体可拍内容) + if (StrUtil.isNotBlank(storyboard.getVideoPrompt())) { + sb.append("[核心动作] ").append(storyboard.getVideoPrompt()).append("\n"); + } + // 2. 摄影规则 if (StrUtil.isNotBlank(storyboard.getPhotographyRules())) { try { @@ -800,9 +852,11 @@ public class ShortDramaServiceImpl implements IShortDramaService { sb.append("\n"); } - // 角色参考图很容易被生成模型错误地扩散到背景群众,造成“所有人一张脸”。 - // 在角色声明之后追加全局身份隔离规则,使其覆盖所有项目和所有镜头。 - appendCharacterIdentityIsolationPrompt(sb, storyboard, chars, refImages); + // 角色参考图模式下,参考图容易被模型扩散到背景群众造成"所有人一张脸"。 + // 仅 reference-to-video 模式(有参考图)追加身份隔离规则;纯文生视频不需要。 + if (hasRefImages) { + appendCharacterIdentityIsolationPrompt(sb, storyboard, chars, refImages); + } // 4. 场景描述(+ @imageN 参考图标记) if (StrUtil.isNotBlank(storyboard.getLocationName())) { @@ -839,6 +893,16 @@ public class ShortDramaServiceImpl implements IShortDramaService { // 6. 前后镜头连续性状态 appendContinuityPrompt(sb, storyboard); + // 6.1 首帧承接:当存在上一镜末帧 URL 时,提示模型首帧继承末帧画面 + if (StrUtil.isNotBlank(lastFrameUrl)) { + int lastFrameImageIndex = refImages != null ? refImages.indexOf(lastFrameUrl) : -1; + if (lastFrameImageIndex >= 0) { + sb.append("[首帧承接] 当前视频第一帧必须严格继承 @image").append(lastFrameImageIndex + 1) + .append("(上一镜末帧)的人物位置、姿态、朝向、服装、道具和光线方向,") + .append("不得改变人物左右关系或重置场景,动作从该帧状态自然延续。\n"); + } + } + // 7. 画面描述(AI 撰写的分镜画面叙述) if (StrUtil.isNotBlank(storyboard.getSceneText()) && !storyboard.getSceneText().equals(storyboard.getVideoPrompt())) { @@ -939,18 +1003,27 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private String findCharacterImageUrl(Long projectId, String characterName) { + return findCharacterImageUrl(projectId, characterName, null); + } + + /** + * 按角色名 + appearance 标识查找参考图。appearance 用于在多形象(青年/老年)间切换。 + * 匹配策略:changeReason 精确 → description 包含 → appearanceIndex 数字 → 都失败回退主形象(0)。 + */ + private String findCharacterImageUrl(Long projectId, String characterName, String appearance) { List characters = findCharactersByName(projectId, characterName); for (ShortDramaCharacter character : characters) { List appearances = characterAppearanceMapper.selectList( new LambdaQueryWrapper() .eq(ShortDramaCharacterAppearance::getCharacterId, character.getId()) .orderByAsc(ShortDramaCharacterAppearance::getAppearanceIndex)); - for (ShortDramaCharacterAppearance appearance : appearances) { - List urls = readJsonStringList(appearance.getImageUrls()); - if (urls.isEmpty()) continue; - int index = appearance.getSelectedImageIndex() != null && appearance.getSelectedImageIndex() >= 0 - && appearance.getSelectedImageIndex() < urls.size() ? appearance.getSelectedImageIndex() : 0; - return urls.get(index); + ShortDramaCharacterAppearance matched = matchAppearance(appearances, appearance); + String url = pickAppearanceImage(matched); + if (StrUtil.isNotBlank(url)) return url; + // 匹配形象无图,回退任何有图的形象 + for (ShortDramaCharacterAppearance ap : appearances) { + url = pickAppearanceImage(ap); + if (StrUtil.isNotBlank(url)) return url; } if (StrUtil.isNotBlank(character.getReferenceImageUrl())) { return character.getReferenceImageUrl(); @@ -959,6 +1032,41 @@ public class ShortDramaServiceImpl implements IShortDramaService { return null; } + private ShortDramaCharacterAppearance matchAppearance(List appearances, String appearance) { + if (appearances == null || appearances.isEmpty()) return null; + if (StrUtil.isBlank(appearance) || "初始形象".equals(appearance)) { + return appearances.get(0); + } + // 1. changeReason 精确匹配 + for (ShortDramaCharacterAppearance ap : appearances) { + if (appearance.equals(ap.getChangeReason())) return ap; + } + // 2. changeReason 包含匹配 + for (ShortDramaCharacterAppearance ap : appearances) { + if (StrUtil.isNotBlank(ap.getChangeReason()) && ap.getChangeReason().contains(appearance)) return ap; + } + // 3. description 包含匹配 + for (ShortDramaCharacterAppearance ap : appearances) { + if (StrUtil.isNotBlank(ap.getDescription()) && ap.getDescription().contains(appearance)) return ap; + } + // 4. appearanceIndex 数字匹配 + try { + int idx = Integer.parseInt(appearance); + if (idx >= 0 && idx < appearances.size()) return appearances.get(idx); + } catch (NumberFormatException ignored) {} + // 5. 回退主形象 + return appearances.get(0); + } + + private String pickAppearanceImage(ShortDramaCharacterAppearance appearance) { + if (appearance == null) return null; + List urls = readJsonStringList(appearance.getImageUrls()); + if (urls.isEmpty()) return appearance.getReferenceImageUrl(); + int index = appearance.getSelectedImageIndex() != null && appearance.getSelectedImageIndex() >= 0 + && appearance.getSelectedImageIndex() < urls.size() ? appearance.getSelectedImageIndex() : 0; + return urls.get(index); + } + private String findLocationImageUrl(Long projectId, String locationName) { ShortDramaLocation location = findLocationByName(projectId, locationName); if (location == null) return null; @@ -1052,14 +1160,17 @@ public class ShortDramaServiceImpl implements IShortDramaService { .retrieveVideo(ctx); String videoUrl = null; String videoStatus = null; + String lastFrame = null; if (response != null && StrUtil.isNotBlank(response.getUrl())) { videoUrl = response.getUrl(); + lastFrame = response.getLastFrameUrl(); videoStatus = "done"; } else if (response != null && ("completed".equals(response.getStatus()) || "succeeded".equals(response.getStatus()))) { // 已完成但 URL 提取失败,尝试从原始响应中提取 String fallbackUrl = extractVideoUrlFromRaw(response.getRawResponse()); if (StrUtil.isNotBlank(fallbackUrl)) { videoUrl = fallbackUrl; + lastFrame = response.getLastFrameUrl(); videoStatus = "done"; } else { log.warn("视频已完成但无法提取URL, predictionId={}, raw={}", predictionId, @@ -1074,7 +1185,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { .eq(ShortDramaStoryboard::getId, storyboardId) .eq(ShortDramaStoryboard::getVideoId, predictionId) .set(ShortDramaStoryboard::getVideoUrl, videoUrl) - .set(ShortDramaStoryboard::getVideoStatus, videoStatus)); + .set(ShortDramaStoryboard::getVideoStatus, videoStatus) + .set(StrUtil.isNotBlank(lastFrame), ShortDramaStoryboard::getLastFrameUrl, lastFrame)); if (updated > 0) { videoComposeService.invalidateComposition(project.getId()); } @@ -1092,19 +1204,88 @@ public class ShortDramaServiceImpl implements IShortDramaService { new LambdaQueryWrapper() .eq(ShortDramaStoryboard::getProjectId, projectId) .orderByAsc(ShortDramaStoryboard::getSceneNo)); - List result = new ArrayList<>(); + + // 按 locationName 分组:同场景内串行(保末帧拼接),跨场景组并发 + List> groups = new ArrayList<>(); + List currentGroup = new ArrayList<>(); + String currentLoc = null; for (ShortDramaStoryboard sb : storyboards) { + String loc = StrUtil.blankToDefault(sb.getLocationName(), ""); + if (!loc.equals(currentLoc)) { + if (!currentGroup.isEmpty()) { groups.add(currentGroup); currentGroup = new ArrayList<>(); } + currentLoc = loc; + } + currentGroup.add(sb); + } + if (!currentGroup.isEmpty()) groups.add(currentGroup); + + // 跨场景组并发,组上限 4 + int parallel = Math.min(4, Math.max(1, groups.size())); + java.util.concurrent.ExecutorService pool = Executors.newFixedThreadPool(parallel, + r -> { Thread t = new Thread(r, "short-drama-video-gen"); t.setDaemon(true); return t; }); + try { + List>> futures = new ArrayList<>(); + for (List group : groups) { + futures.add(pool.submit(() -> generateGroupSerial(group, videoModel, userId))); + } + // 按 sceneNo 顺序汇总结果 + List result = new ArrayList<>(); + for (java.util.concurrent.Future> f : futures) { + try { result.addAll(f.get()); } + catch (Exception e) { log.warn("视频生成分组失败: {}", e.getMessage()); } + } + result.sort(java.util.Comparator.comparing(v -> v.getSceneNo() == null ? Integer.MAX_VALUE : v.getSceneNo())); + return result; + } finally { + pool.shutdownNow(); + } + } + + /** 同场景组内串行生成:上一镜末帧喂下一镜首帧。 */ + private List generateGroupSerial(List group, String videoModel, Long userId) { + List result = new ArrayList<>(); + String prevLastFrameUrl = null; + for (ShortDramaStoryboard sb : group) { try { - result.add(generateVideo(sb.getId(), videoModel, userId)); + String lastFrameForThis = StrUtil.isNotBlank(prevLastFrameUrl) ? prevLastFrameUrl : null; + ShortDramaStoryboardVo vo = generateVideo(sb.getId(), videoModel, userId, lastFrameForThis); + if (lastFrameForThis != null) { + vo = ensureVideoDone(sb.getId(), videoModel, userId); + } + result.add(vo); + ShortDramaStoryboard latest = storyboardMapper.selectById(sb.getId()); + prevLastFrameUrl = (latest != null && StrUtil.isNotBlank(latest.getLastFrameUrl())) ? latest.getLastFrameUrl() : null; } catch (Exception e) { log.warn("镜头{}视频生成失败: {}", sb.getSceneNo(), e.getMessage()); ShortDramaStoryboard current = storyboardMapper.selectById(sb.getId()); result.add(MapstructUtils.convert(current != null ? current : sb, ShortDramaStoryboardVo.class)); + prevLastFrameUrl = null; } } return result; } + /** + * 后台同步轮询单镜视频直到 done/failed(用于同场景末帧拼接时拿到末帧再喂下一镜)。 + * 单镜累计轮询不超过 5 分钟,超时按当前状态返回。 + */ + private ShortDramaStoryboardVo ensureVideoDone(Long storyboardId, String videoModel, Long userId) { + long deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5); + try { + while (System.currentTimeMillis() < deadline) { + ShortDramaStoryboardVo vo = retrieveVideo(storyboardId, videoModel, userId); + if (vo == null) return null; + if ("done".equals(vo.getVideoStatus()) || "failed".equals(vo.getVideoStatus())) { + return vo; + } + try { Thread.sleep(2000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return vo; } + } + } catch (Exception e) { + log.warn("镜头{}末帧等待轮询异常: {}", storyboardId, e.getMessage()); + } + return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); + } + // ==================== 资产分析与管理 ==================== @Override @@ -1185,7 +1366,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { validateProjectOwner(location.getProjectId(), userId); ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); - String prompt = firstNotBlank(location.getDescriptions(), location.getSummary(), location.getName()); + String prompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); String finalPrompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); String referenceImage = validateReferenceImageUrl(referenceImageUrl); @@ -1379,7 +1560,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { validateProjectOwner(location.getProjectId(), userId); ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); - String prompt = firstNotBlank(location.getDescriptions(), location.getSummary(), location.getName()); + String prompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); String finalPrompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); String referenceImage = validateReferenceImageUrl(referenceImageUrl); @@ -1504,7 +1685,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { ShortDramaLocation location = locationMapper.selectById(assetId); if (location == null) throw new IllegalArgumentException("场景不存在"); validateProjectOwner(location.getProjectId(), userId); - String basePrompt = firstNotBlank(location.getDescriptions(), location.getSummary(), location.getName()); + String basePrompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); prompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + basePrompt + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); size = projectAspectRatio(location.getProjectId()); @@ -1574,7 +1755,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { throw new IllegalStateException("图片生成完成但未获取到URL"); } - String prompt = firstNotBlank(location.getDescriptions(), location.getSummary(), location.getName()); + String prompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); String finalPrompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); @@ -1612,6 +1793,19 @@ public class ShortDramaServiceImpl implements IShortDramaService { } } + /** + * 场景只使用一个主描述。兼容历史数据:旧记录可能包含三个候选描述,固定取第一个非空项。 + */ + private static String primaryLocationDescription(ShortDramaLocation location) { + if (location == null || StrUtil.isBlank(location.getDescriptions())) { + return null; + } + return readJsonStringList(location.getDescriptions()).stream() + .filter(StrUtil::isNotBlank) + .findFirst() + .orElse(null); + } + @Override @Transactional(rollbackFor = Exception.class) public Boolean deleteProject(Long projectId, Long userId) { @@ -1639,6 +1833,16 @@ public class ShortDramaServiceImpl implements IShortDramaService { */ private String streamingChat(StreamingChatModel streamingModel, ChatModel chatModel, String prompt, SseEmitter emitter, String streamPhase) { + return streamingChat(streamingModel, chatModel, prompt, emitter, streamPhase, null); + } + + /** + * 流式调用模型。onPartial 回调在每次新 token 到来时以当前完整 buffer 调用, + * 调用方可在此做增量解析(如分镜 panel 增量推送)。onPartial 为 null 时行为同旧版。 + */ + private String streamingChat(StreamingChatModel streamingModel, ChatModel chatModel, + String prompt, SseEmitter emitter, String streamPhase, + java.util.function.Consumer onPartial) { if (streamingModel == null || emitter == null) { return chatModel.chat(prompt); } @@ -1657,6 +1861,11 @@ public class ShortDramaServiceImpl implements IShortDramaService { public void onPartialResponse(String text) { buf.append(text); emitStream(emitter, streamPhase, text); + if (onPartial != null) { + try { onPartial.accept(buf.toString()); } catch (Exception ex) { + log.warn("流式增量回调异常: {}", ex.getMessage()); + } + } } @Override public void onCompleteResponse(ChatResponse response) { @@ -2242,7 +2451,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { 4. 光线方向:光从哪个方向照入 5. 可落位空间:必须说明哪些区域留有可供人物站立的空白空间,至少2-3个后续可作为人物落位锚点的关键物体或区域 - 每个场景生成3条差异化中文环境描述(100-150字),2-6个available_slots + 每个场景只生成1条中文环境描述(100-150字),2-6个available_slots。描述可由用户编辑,不要提供相似候选方案。 ⚠️ 场景图禁止出现任何有名有姓的角色!场景图是纯粹的背景板。无名的模糊背景群众(如"宾客""路人")可以出现。 @@ -2259,7 +2468,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { "hasCrowd": true/false, "crowdDescription": "人群类型描述", "availableSlots": ["位置1完整描述", "位置2完整描述"], - "descriptions": ["「场景名」描述1", "「场景名」描述2", "「场景名」描述3"] + "descriptions": ["「场景名」唯一完整描述"] } ] } @@ -2297,7 +2506,15 @@ public class ShortDramaServiceImpl implements IShortDramaService { String response; if (emitter != null) { StreamingChatModel activeStreamingModel = streamingModel != null ? streamingModel : buildStreamingChatModel(); - response = streamingChat(activeStreamingModel, chatModel, prompt, emitter, "storyboard_plan"); + // 流式增量:每解析出一个完整 panel 就推给前端,不等整个数组完成 + final org.ruoyi.service.shortdrama.support.IncrementalJsonArrayExtractor extractor = + new org.ruoyi.service.shortdrama.support.IncrementalJsonArrayExtractor<>(StoryboardPanelData.class); + response = streamingChat(activeStreamingModel, chatModel, prompt, emitter, "storyboard_plan", buf -> { + List fresh = extractor.feed(buf); + for (StoryboardPanelData p : fresh) { + if (p != null) emitPanel(emitter, p); + } + }); } else { response = chatModel.chat(prompt); } @@ -2850,6 +3067,10 @@ public class ShortDramaServiceImpl implements IShortDramaService { matched++; } } + // 节拍校验 + 二次补写:video_prompt 节拍数低于 ⌈duration/3⌉ 时补写一次 + if (matched > 0 && streamingModel == null && emitter == null) { + ensureVideoPromptBeats(chatModel, panels, projectId); + } if (matched == 0 && emitter != null) { emit(emitter, "storyboard_detail", "error", "分镜细化JSON解析成功但未匹配到任何镜头"); } else if (matched > 0 && emitter != null) { @@ -2864,6 +3085,77 @@ public class ShortDramaServiceImpl implements IShortDramaService { } } + /** + * 校验每个 panel 的 video_prompt 节拍数(按顿号/逗号/句号粗估可见动作短语), + * 低于 ⌈duration/3⌉ 时发起一次二次 LLM 调用补写。补写后再次校验,仍不达标则保留并记 warn。 + * 仅在非流式(同步生成)模式下执行,避免流式场景重复请求。 + */ + private void ensureVideoPromptBeats(ChatModel chatModel, List panels, Long projectId) { + List deficient = new ArrayList<>(); + for (StoryboardPanelData panel : panels) { + if (StrUtil.isBlank(panel.getVideoPrompt())) continue; + int duration = panel.getDuration() != null && panel.getDuration() > 0 ? panel.getDuration() : 6; + int required = Math.max(2, (duration + 2) / 3); + int actual = countBeats(panel.getVideoPrompt()); + if (actual < required) { + deficient.add(panel); + } + } + if (deficient.isEmpty()) return; + try { + String supplementPrompt = buildVideoPromptSupplementPrompt(deficient, artStyleSuffix(projectId), projectAspectRatio(projectId)); + String response = chatModel.chat(supplementPrompt); + List results = parseJsonArray(extractJson(response), StoryboardDetailResult.class); + if (results != null) { + for (StoryboardDetailResult r : results) { + if (r.getPanelNumber() == null) continue; + int idx = r.getPanelNumber() - 1; + if (idx >= 0 && idx < panels.size() && StrUtil.isNotBlank(r.getVideoPrompt())) { + StoryboardPanelData panel = panels.get(idx); + if (StrUtil.isNotBlank(r.getDescription())) panel.setDescription(r.getDescription()); + panel.setVideoPrompt(r.getVideoPrompt()); + log.info("Phase 6 节拍补写完成 panel={} 节拍 {}->{}", + r.getPanelNumber(), + countBeats(panel.getVideoPrompt()), + panel.getVideoPrompt()); + } + } + } + } catch (Exception e) { + log.warn("Phase 6 节拍补写失败: {}", e.getMessage()); + } + } + + /** 粗估 video_prompt 的可见节拍数:按顿号、逗号、分号、句号、换行切分的动作短语数。 */ + private static int countBeats(String videoPrompt) { + if (StrUtil.isBlank(videoPrompt)) return 0; + String[] parts = videoPrompt.split("[、,,;;。\n]"); + int count = 0; + for (String p : parts) { + String t = p.trim(); + if (t.length() >= 2) count++; + } + return count; + } + + private static String buildVideoPromptSupplementPrompt(List deficient, String artStyle, String aspectRatio) { + StringBuilder json = new StringBuilder(); + for (StoryboardPanelData p : deficient) { + json.append(JsonUtils.toJsonString(p)).append(","); + } + if (json.length() > 0 && json.charAt(json.length() - 1) == ',') json.deleteCharAt(json.length() - 1); + return """ + 以下是 video_prompt 节拍数不足的分镜,每个镜头的 video_prompt 必须按时长写出足够可见节拍。 + 按导演笔记风格重写 video_prompt(景别+机位、按时序的动作节拍、运镜、光影方向、道具、台词),禁止参数堆砌。 + duration 为 4-7 秒写 3 个连续节拍,8 秒以上按前段/中段/后段写至少 3 节拍。保留原有信息,只扩写动作细节。 + 只返回 JSON 数组,字段:panel_number、video_prompt、description。 + + 视觉风格:%s + 画幅:%s + 待补写分镜:[%s] + """.formatted(artStyle, aspectRatio, json); + } + private static String buildStoryboardDetailPrompt(String panelsJson, String charsAgeGender, String locsDesc, String artStyle, String aspectRatio) { return """ @@ -2921,19 +3213,23 @@ public class ShortDramaServiceImpl implements IShortDramaService { - description也必须同步扩写这些节拍,保证画面描述与video_prompt一致 【video_prompt撰写规则 - 重要】 - 视频模型不认识名字,必须用年龄段+性别替代: + video_prompt 是发给视频模型的核心可拍指令,必须用"导演笔记"风格写,每个字都可拍、按时序展开。视频模型不认识名字,必须用年龄段+性别替代角色: - 年龄段:少年/少女(10-16)、年轻男子/年轻女子(17-30)、中年男子/中年女子(31-50)、老年男子/老年女子(50+) - - 格式:年龄性别+动作+镜头运动+环境 - - 必须有动作词:转头、点头、走动、转身、推门、抬手等 - - 必须有镜头运动词:缓缓推近、轻轻跟随、手持跟随、环绕拍摄等 - - 对话场景必须写明"正在说话" - - 禁止纯静态描述 - - 特写镜头必须使用"固定镜头" + - 必须依次包含以下可拍维度: + 1) 景别+机位架设位置:如"中景,机位架设在店内深处正对门口" + 2) 主体动作(按时序):按 duration 分档写连续可见节拍——4-7秒写"开始动作→持续变化→结束状态";8秒及以上按"前段→中段→后段"写至少3个连续可见动作。每秒至少一个可见动作变化,禁止用一个瞬时动作支撑长镜头 + 3) 运镜:从镜头运动词库选一个主导运镜(推/拉/摇/移/跟/环绕/手持/固定),写明方向与节奏,禁止只用"缓缓"这种无信息量词 + 4) 光影:方向+色温+阴影色,与当前镜头光源位置绑死(如"晨光从卷帘门缝隙逆光射入,发丝边缘泛柔光,店内深处阴影偏冷蓝"),禁止情绪词 + 5) 道具/穿着:从 source_text 和角色设定提取具体道具与穿着,写进动作流 + 6) 台词:若 source_text 有台词,以「角色说的话」标注并标语气(小声/平淡/恳求),供后续口型对齐;无台词则不写 + - 禁止参数堆砌(8K/HDR/fps/Rec.色域这类),视频模型不认 + - 禁止纯静态描述,特写镜头必须使用"固定镜头" + - description 必须与 video_prompt 的节拍、光影、动作一致 【动态优先原则 - 核心规则】 - 视频不能僵硬!每个video_prompt必须包含"动"的元素。即使是对话场景也要动起来。 - ✅ 正确:"年轻女子坐在沙发上轻轻转头,镜头缓缓推近她的侧脸" - ❌ 错误:"年轻女子坐在沙发上,镜头固定" + 视频不能僵硬!每个video_prompt必须按时序含可见动作。即使对话场景也要有动作变化。 + ✅ 正确示例(6秒、2节拍):"中景,机位架设在店内深处正对门口。年轻女子从右侧卷帘门推门进入,门推开约45°,晨光从门缝逆光射入在她发丝边缘形成柔光晕。她站定在门口,身体微前倾又顿住,手里攥着一张揉皱的纸,眼神在店内游移后落向画面中央偏左的座位。镜头手持跟随,从门口缓推至她停步处,保持中景距离。她小声开口:「那个……这里理发吗?」" + ❌ 错误:"年轻女子坐在沙发上,镜头固定"(无节拍、无光影、无运镜节奏) 【image_prompt撰写规则】 - 使用角色实际名字(不是年龄段+性别) @@ -2959,7 +3255,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { "story_action": "镜头中执行的关键动作", "story_result": "动作造成的剧情变化", "next_hook": "下一镜必须回应的后果", - "video_prompt": "年轻男子站在桌前双手撑在桌面上,正在说话,镜头缓缓推近", + "video_prompt": "中景,机位架设在办公室深处正对会议桌。年轻男子站在深棕色实木桌前,双手撑在桌面上,身体微前倾,目光扫过桌前众人后停住。镜头从中景手持缓推至他上半身,保持平视距离,约两秒推到位后固定。午后阳光从右侧窗户斜照进来,在他脸侧形成暖侧光,桌面阴影偏冷。他抬头环视,低声开口:「开会了。」", "image_prompt": "张三站在办公室中央,双手撑在深棕色实木桌面上,表情严肃,午后阳光从右侧窗户斜照进来", "sceneTitle": "张三宣布开会", "characters": [{"name":"张三","appearance":"初始形象","slot":"办公室中央"}], @@ -2994,7 +3290,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { entity.setShotType(firstNotBlank(panel.getShotType(), "平视中景")); entity.setCameraMove(firstNotBlank(panel.getCameraMove(), "缓推")); entity.setDurationSeconds(panel.getDuration() != null && panel.getDuration() > 0 ? panel.getDuration() : defaultDurationForSceneType(entity.getSceneType())); - entity.setVideoPrompt(firstNotBlank(panel.getVideoPrompt(), buildVideoPromptFallback(script, panel.getDescription(), sceneNo))); + entity.setVideoPrompt(firstNotBlank(panel.getVideoPrompt(), buildVideoPromptFallback(script, panel.getDescription(), sceneNo, panel.getSceneType(), panel.getDuration()))); entity.setVideoStatus("pending"); entity.setLocationName(panel.getLocation()); entity.setSourceText(panel.getSourceText()); @@ -3100,7 +3396,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { StringBuilder sb = new StringBuilder(); for (ShortDramaLocation l : locs) { sb.append("- ").append(l.getName()).append(":") - .append(firstNotBlank(l.getDescriptions(), l.getSummary(), "无描述")) + .append(firstNotBlank(primaryLocationDescription(l), l.getSummary(), "无描述")) .append("\n"); } return sb.toString(); @@ -3158,6 +3454,166 @@ public class ShortDramaServiceImpl implements IShortDramaService { return chatServiceFactory.getOriginalService(modelVo.getProviderCode()); } + // ==================== 语音资产 ==================== + + @Override + public ShortDramaAudioVo saveAudio(ShortDramaAudioBo bo, Long userId) { + validateProjectOwner(bo.getProjectId(), userId); + ShortDramaAudio entity = MapstructUtils.convert(bo, ShortDramaAudio.class); + if (entity.getAudioType() == null) entity.setAudioType("narration"); + if (entity.getId() == null) { + entity.setId(IdUtil.getSnowflakeNextId()); + audioMapper.insert(entity); + } else { + ShortDramaAudio existing = audioMapper.selectById(entity.getId()); + if (existing == null || !userId.equals(projectMapper.selectById(existing.getProjectId()).getUserId())) { + throw new IllegalArgumentException("语音资产不存在或无权限"); + } + audioMapper.updateById(entity); + } + return MapstructUtils.convert(entity, ShortDramaAudioVo.class); + } + + @Override + public Boolean deleteAudio(Long audioId, Long userId) { + ShortDramaAudio audio = audioMapper.selectById(audioId); + if (audio == null) return false; + validateProjectOwner(audio.getProjectId(), userId); + return audioMapper.deleteById(audioId) > 0; + } + + @Override + public List listAudios(Long projectId, Long userId) { + validateProjectOwner(projectId, userId); + return audioMapper.selectVoList(new LambdaQueryWrapper() + .eq(ShortDramaAudio::getProjectId, projectId) + .orderByAsc(ShortDramaAudio::getId)); + } + + @Override + public ShortDramaAudioVo generateAudio(Long audioId, String audioModel, Long userId) { + ShortDramaAudio audio = audioMapper.selectById(audioId); + if (audio == null) throw new IllegalArgumentException("语音资产不存在"); + validateProjectOwner(audio.getProjectId(), userId); + if (StrUtil.isBlank(audio.getText())) throw new IllegalArgumentException("语音文案不能为空"); + ChatModelVo modelVo = chatModelService.selectModelByName(audioModel); + if (modelVo == null) throw new IllegalArgumentException("未找到语音模型配置: " + audioModel); + if (!org.ruoyi.enums.ModelType.AUDIO.getKey().equals(modelVo.getCategory())) { + throw new IllegalArgumentException("模型分类不是语音模型: " + audioModel); + } + + // 对白类型:从关联镜头收集出场角色及其子形象音色,作为 references + @audioN 标记 + List> references = buildAudioReferences(audio); + AudioContext ctx = AudioContext.builder() + .chatModelVo(modelVo) + .input(audio.getText()) + .voice(StrUtil.isBlank(audio.getVoice()) ? null : audio.getVoice()) + .responseFormat("mp3") + .references(references) + .build(); + MediaGenerationResponse response = audioServiceFactory.getOriginalService(modelVo.getProviderCode()) + .generateSpeech(ctx); + + String audioUrl; + Long audioOssId; + if (response != null && StrUtil.isNotBlank(response.getB64Json())) { + // OpenAI 同步模式:base64 → OSS + byte[] audioBytes = java.util.Base64.getDecoder().decode(response.getB64Json()); + org.ruoyi.common.core.domain.dto.OssDTO uploaded = uploadAudioBytes(audioBytes); + audioUrl = uploaded.getUrl(); + audioOssId = uploaded.getOssId(); + } else if (response != null && StrUtil.isNotBlank(response.getId())) { + // Atlas 异步模式:轮询拿 URL,再下载转存 OSS(统一存储,避免 Atlas 链路过期) + String predictionId = response.getId(); + if (StrUtil.isNotBlank(response.getUrl())) { + audioUrl = response.getUrl(); + audioOssId = null; + } else { + MediaGenerationResponse polled = pollAudioDone(modelVo, predictionId); + if (polled == null || StrUtil.isBlank(polled.getUrl())) { + throw new RuntimeException("语音异步生成超时或失败,predictionId=" + predictionId); + } + audioUrl = polled.getUrl(); + audioOssId = null; + } + } else { + throw new RuntimeException("语音生成失败,模型未返回音频数据或任务ID"); + } + audio.setAudioOssId(audioOssId); + audio.setAudioUrl(audioUrl); + audioMapper.updateById(audio); + return MapstructUtils.convert(audio, ShortDramaAudioVo.class); + } + + /** + * 对白类型音频:从关联镜头的出场角色收集子形象音色,构造 references(speaker)。 + * text 中可用 @audioN 引用对应角色。旁白类型返回空列表。 + */ + private List> buildAudioReferences(ShortDramaAudio audio) { + if (!"dialogue".equals(audio.getAudioType()) || audio.getLinkedStoryboardId() == null) { + return List.of(); + } + ShortDramaStoryboard sb = storyboardMapper.selectById(audio.getLinkedStoryboardId()); + if (sb == null) return List.of(); + List refs = parseCharacterRefs(sb.getCharactersJson()); + if (refs == null || refs.isEmpty()) return List.of(); + List> result = new ArrayList<>(); + for (CharacterRef ref : refs) { + ShortDramaCharacter ch = findCharacterByName(sb.getProjectId(), ref.getName()); + if (ch == null) continue; + // 取该角色的主形象(appearanceIndex=0)音色 + List aps = characterAppearanceMapper.selectList( + new LambdaQueryWrapper() + .eq(ShortDramaCharacterAppearance::getCharacterId, ch.getId()) + .orderByAsc(ShortDramaCharacterAppearance::getAppearanceIndex)); + for (ShortDramaCharacterAppearance ap : aps) { + if (StrUtil.isNotBlank(ap.getVoice())) { + java.util.Map r = new LinkedHashMap<>(); + r.put("speaker", ap.getVoice()); + result.add(r); + break; + } + } + } + return result; + } + + /** Atlas 异步音频轮询,累计不超过 3 分钟。 */ + private MediaGenerationResponse pollAudioDone(ChatModelVo modelVo, String predictionId) { + long deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(3); + while (System.currentTimeMillis() < deadline) { + MediaGenerationResponse resp = atlasPredictionService.retrieve(modelVo, predictionId); + if (resp != null && ("completed".equals(resp.getStatus()) || "succeeded".equals(resp.getStatus())) + && StrUtil.isNotBlank(resp.getUrl())) { + return resp; + } + if (resp != null && "failed".equals(resp.getStatus())) { + throw new RuntimeException("语音异步生成失败: " + resp.getRawResponse()); + } + try { Thread.sleep(2000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return resp; } + } + return null; + } + + private org.ruoyi.common.core.domain.dto.OssDTO uploadAudioBytes(byte[] bytes) { + java.nio.file.Path tmp = null; + try { + tmp = java.nio.file.Files.createTempFile("short-drama-audio-", ".mp3"); + java.nio.file.Files.write(tmp, bytes); + org.ruoyi.common.core.domain.dto.OssDTO uploaded = ossService.uploadFile(tmp.toFile()); + if (uploaded == null || uploaded.getOssId() == null) { + throw new RuntimeException("语音文件上传对象存储失败"); + } + return uploaded; + } catch (java.io.IOException e) { + throw new RuntimeException("语音文件写入失败: " + e.getMessage(), e); + } finally { + if (tmp != null) { + try { java.nio.file.Files.deleteIfExists(tmp); } catch (java.io.IOException ignored) {} + } + } + } + private StreamingChatModel buildStreamingChatModel() { ChatModelVo modelVo = findChatModel(); AbstractChatService chatService = getChatService(modelVo); @@ -3181,6 +3637,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } characterMapper.delete(new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); locationMapper.delete(new LambdaQueryWrapper().eq(ShortDramaLocation::getProjectId, projectId)); + audioMapper.delete(new LambdaQueryWrapper().eq(ShortDramaAudio::getProjectId, projectId)); } private static void normalizeContinuityChain(List panels) { @@ -3387,8 +3844,44 @@ public class ShortDramaServiceImpl implements IShortDramaService { return chunks.stream().filter(StrUtil::isNotBlank).limit(12).toList(); } + /** 按 scene_type 选具体运镜描述,消除"缓缓推近"这类无信息量词。 */ + private static String cameraMoveForScene(String sceneType) { + return switch (firstNotBlank(sceneType, "daily")) { + case "action" -> "手持跟移,允许轻微晃动,快速横移跟随动作"; + case "emotion" -> "从中景缓推至近景,两秒到位后固定,聚焦面部"; + case "epic" -> "大远景缓拉升起,展现环境规模后停住"; + case "suspense" -> "斯坦尼康式低速缓推,略带左右摇摆模拟紧张"; + default -> "平视中景手持缓推,约两秒到位后固定"; + }; + } + + /** + * Phase 6 失败时的兜底 video_prompt。按 duration 分档写可拍节拍, + * 不再一句"镜头缓缓推近,自然光线"。 + */ + private static String buildVideoPromptFallback(ShortDramaScript script, String text, int sceneNo, + String sceneType, Integer duration) { + String tone = firstNotBlank(script.getTone(), "短剧"); + String camera = cameraMoveForScene(sceneType); + String light = switch (firstNotBlank(sceneType, "daily")) { + case "suspense" -> "低调硬光,保留阴影层次"; + case "emotion" -> "柔和侧光,突出面部情绪"; + case "action" -> "高反差侧光,强化动作轮廓"; + case "epic" -> "大范围自然光,突出空间规模"; + default -> "自然柔光,主光从画面侧上方斜照"; + }; + int dur = duration != null && duration > 0 ? duration : 6; + String beats; + if (dur >= 8) { + beats = "前段:" + text + ";中段:动作持续变化、视线或姿态推进;后段:收束在结束状态,留出下一镜承接"; + } else { + beats = "开始动作:" + text + ";持续变化:动作与视线推进;结束状态:收束停住"; + } + return "中景,镜头" + camera + "。" + beats + "。" + tone + "风格。" + light + "。短剧镜头" + sceneNo; + } + private static String buildVideoPromptFallback(ShortDramaScript script, String text, int sceneNo) { - return "短剧镜头" + sceneNo + "," + firstNotBlank(script.getTone(), "短剧") + "风格," + text + ",镜头缓缓推近,自然光线"; + return buildVideoPromptFallback(script, text, sceneNo, "daily", 6); } // ==================== JSON 解析 ==================== @@ -3744,7 +4237,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { private static String normalizeAspectRatio(String aspectRatio) { return switch (firstNotBlank(aspectRatio, "9:16")) { - case "16:9", "1:1" -> aspectRatio; + case "16:9", "4:3", "1:1", "3:4", "9:16", "21:9" -> aspectRatio; default -> "9:16"; }; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeJob.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeJob.java index 6fabfbaa..e8553fde 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeJob.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeJob.java @@ -4,6 +4,7 @@ import org.ruoyi.service.shortdrama.composition.AspectRatio; import org.ruoyi.service.shortdrama.composition.TransitionType; import java.math.BigDecimal; +import java.nio.file.Path; import java.util.List; import java.util.Objects; @@ -14,7 +15,10 @@ record ShortDramaVideoComposeJob( TransitionType transitionType, BigDecimal transitionDurationSeconds, AspectRatio aspectRatio, - List storyboardIds + List storyboardIds, + Long narrationAudioId, + Path narrationAudioPath, + boolean watermark ) { ShortDramaVideoComposeJob { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeServiceImpl.java index 08887878..f3cc5461 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeServiceImpl.java @@ -92,7 +92,10 @@ public class ShortDramaVideoComposeServiceImpl implements IShortDramaVideoCompos transitionType, transitionDuration, aspectRatio, - bo.getStoryboardIds() + bo.getStoryboardIds(), + bo.getNarrationAudioId(), + null, + resolveWatermark(bo.getWatermark()) ); try { composeWorker.composeAsync(job); @@ -218,6 +221,11 @@ public class ShortDramaVideoComposeServiceImpl implements IShortDramaVideoCompos } } + /** 前端 watermark 为空时回退到配置默认值(默认开启 ruoyi-ai) */ + private boolean resolveWatermark(Boolean requested) { + return requested == null ? compositionProperties.isWatermarkEnabled() : requested; + } + private Date staleBefore(Date now) { Duration staleAfter = compositionProperties.getJobStaleAfter(); if (staleAfter == null || staleAfter.isZero() || staleAfter.isNegative()) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java index 31587c23..a5e77a94 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java @@ -10,8 +10,10 @@ import org.ruoyi.common.core.exception.ServiceException; import org.ruoyi.common.core.service.OssService; import org.ruoyi.common.core.utils.file.FileUtils; import org.ruoyi.common.tenant.helper.TenantHelper; +import org.ruoyi.domain.entity.shortdrama.ShortDramaAudio; import org.ruoyi.domain.entity.shortdrama.ShortDramaProject; import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import org.ruoyi.mapper.shortdrama.ShortDramaAudioMapper; import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper; import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper; import org.ruoyi.service.shortdrama.composition.CompositionArtifact; @@ -41,6 +43,7 @@ public class ShortDramaVideoComposeWorker { private final ShortDramaProjectMapper projectMapper; private final ShortDramaStoryboardMapper storyboardMapper; + private final ShortDramaAudioMapper audioMapper; private final FfmpegVideoComposer videoComposer; private final FfmpegCompositionProperties compositionProperties; private final SafeVideoSourceDownloader sourceDownloader; @@ -65,11 +68,16 @@ public class ShortDramaVideoComposeWorker { return; } + // 旁白语音资产下载到工作目录(ossId → 本地文件) + Path narrationAudioPath = downloadNarration(job, workDirectory); + CompositionArtifact artifact = videoComposer.compose(new CompositionSpec( sources, job.transitionType(), job.transitionDurationSeconds().doubleValue(), - job.aspectRatio() + job.aspectRatio(), + narrationAudioPath, + job.watermark() ), workDirectory); if (!updateProgress(job, 85)) { return; @@ -198,6 +206,24 @@ public class ShortDramaVideoComposeWorker { .set(ShortDramaProject::getUpdateTime, new Date())) > 0; } + /** + * 将旁白语音资产下载为本地文件。语音资产未指定或不存在时返回 null(不混入旁白)。 + */ + private Path downloadNarration(ShortDramaVideoComposeJob job, Path workDirectory) throws IOException { + if (job.narrationAudioId() == null) { + return null; + } + ShortDramaAudio audio = audioMapper.selectById(job.narrationAudioId()); + if (audio == null || StrUtil.isBlank(audio.getAudioUrl())) { + log.warn("旁白语音资产不存在或无音频URL, audioId={}", job.narrationAudioId()); + return null; + } + Path target = workDirectory.resolve("narration.mp3"); + long maxSourceBytes = compositionProperties.getMaxSourceBytes(); + sourceDownloader.download(audio.getAudioUrl(), target, maxSourceBytes, maxSourceBytes); + return target; + } + private boolean isActive(ShortDramaVideoComposeJob job) { return selectActiveProject(job) != null; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/support/IncrementalJsonArrayExtractor.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/support/IncrementalJsonArrayExtractor.java new file mode 100644 index 00000000..b77d7749 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/support/IncrementalJsonArrayExtractor.java @@ -0,0 +1,116 @@ +package org.ruoyi.service.shortdrama.support; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; + +/** + * 增量 JSON 数组解析器:随 LLM 流式输出累积 buffer,逐个提取已闭合的顶层对象, + * 每完成一个就解析成目标类型并返回(已返回的不再重复)。 + * 用于分镜规划流式推送——第一个 panel 解析出来即可展示,不等整个数组完成。 + *

+ * 仅依赖 brace matching + 字符串/转义状态机,对未闭合的尾对象不解析,保证稳定性。 + */ +@Slf4j +public final class IncrementalJsonArrayExtractor { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final Class type; + private final StringBuilder buf = new StringBuilder(); + private int emittedCount = 0; + /** 数组起始 '[' 在 buffer 中的位置,-1 表示尚未找到 */ + private int arrayStart = -1; + + public IncrementalJsonArrayExtractor(Class type) { + this.type = type; + } + + /** + * 喂入新的累积文本,返回本次新解析出的完整对象(去重)。 + * 调用方应每次把"完整 buffer"传入(实现内部不重复追加,而是以最新 buffer 为准)。 + */ + public List feed(String fullBuffer) { + List newly = new ArrayList<>(); + if (StrUtil.isBlank(fullBuffer)) return newly; + buf.setLength(0); + buf.append(fullBuffer); + + if (arrayStart < 0) { + arrayStart = findArrayStart(fullBuffer); + if (arrayStart < 0) return newly; + } + + int scanFrom = arrayStart + 1; + int objIdx = 0; + int i = scanFrom; + int len = buf.length(); + while (i < len) { + char c = buf.charAt(i); + if (c == '{') { + int end = findObjectEnd(i); + if (end < 0) break; // 对象未闭合,等后续 token + if (objIdx >= emittedCount) { + String objJson = buf.substring(i, end + 1); + T parsed = tryParse(objJson); + if (parsed != null) { + newly.add(parsed); + emittedCount++; + } + } + i = end + 1; + objIdx++; + } else if (c == ']') { + break; // 数组结束 + } else if (Character.isWhitespace(c)) { + i++; + } else { + i++; + } + } + return newly; + } + + /** 定位第一个 '['(跳过思考文字、markdown 代码块围栏 ```json 等)。 */ + private int findArrayStart(String s) { + return s.indexOf('['); + } + + /** + * 从 start(指向 '{')开始,找到该对象的闭合 '}',正确处理字符串、转义、嵌套。 + * 返回闭合 '}' 的索引;若未闭合返回 -1。 + */ + private int findObjectEnd(int start) { + int depth = 0; + boolean inString = false; + boolean escape = false; + for (int i = start; i < buf.length(); i++) { + char c = buf.charAt(i); + if (escape) { escape = false; continue; } + if (inString) { + if (c == '\\') { escape = true; } + else if (c == '"') { inString = false; } + continue; + } + if (c == '"') { inString = true; } + else if (c == '{') { depth++; } + else if (c == '}') { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + private T tryParse(String json) { + try { + return MAPPER.readValue(json, type); + } catch (Exception e) { + log.debug("增量解析 panel 失败,跳过: {}", e.getMessage()); + return null; + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java index d24bea18..e7832dad 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java @@ -59,6 +59,23 @@ public class AtlasVideoGenerationServiceImpl extends AbstractVideoGenerationServ payload.put("image_url", videoContext.getImageUrl()); } + // 同步音频生成(环境音/动效) + if (videoContext.getGenerateAudio() != null) { + payload.put("generate_audio", videoContext.getGenerateAudio()); + } + // 参考音频(对白口型对齐) + java.util.List refAudios = videoContext.getReferenceAudios(); + if (refAudios != null && !refAudios.isEmpty()) { + com.fasterxml.jackson.databind.node.ArrayNode arr = payload.putArray("reference_audios"); + for (String url : refAudios) { + arr.add(url); + } + } + // 返回末帧(同场景连续镜头首帧承接用) + if (videoContext.getReturnLastFrame() != null) { + payload.put("return_last_frame", videoContext.getReturnLastFrame()); + } + Request request = new Request.Builder() .url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateVideo")) .addHeader("Authorization", "Bearer " + model.getApiKey()) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatHandshakeInterceptor.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatHandshakeInterceptor.java new file mode 100644 index 00000000..d8498369 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatHandshakeInterceptor.java @@ -0,0 +1,81 @@ +package org.ruoyi.websocket.chat; + +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.core.domain.model.LoginUser; +import org.ruoyi.common.satoken.utils.LoginHelper; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * 小程序对话 WS 握手拦截器。 + *

+ * 无权限:握手始终放行。仅尝试从握手 URL 的 Authorization 参数解析登录用户, + * 解析成功则把 userId 放入 session attributes 供 handler 落库使用;解析失败按匿名处理。 + *

+ * 注意:与公共 {@code PlusWebSocketInterceptor} 不同,这里不做 clientid 一致性校验, + * 也不抛出认证异常——对话端点对未登录用户同样开放。 + * + * @author ruoyi team + */ +@Slf4j +@Component +public class MpChatHandshakeInterceptor implements HandshakeInterceptor { + + public static final String USER_ID_KEY = "mpChatUserId"; + public static final String LOGIN_USER_KEY = "mpChatLoginUser"; + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) { + // 无权限:握手始终放行,且不调用 sa-token(LoginHelper.getLoginUser 会触发 getTokenSessionByToken, + // 在 is-share:false 下有冻结当前 token 的副作用,导致随后 mvc 请求 401 token 已被冻结。 + // 对话端点本就不依赖登录态,userId 留空,落库跳过)。 + log.info("[mp-chat connect] 匿名对话连接"); + return true; + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + // no-op + } + + /** + * 从握手 URL query 中解析 token。 + * 前端约定以 Authorization=Bearer xxx 形式透传,去掉 Bearer 前缀取真实 token。 + */ + private String resolveToken(URI uri) { + String query = uri.getRawQuery(); + if (query == null || query.isEmpty()) { + return null; + } + for (String pair : query.split("&")) { + int idx = pair.indexOf('='); + if (idx <= 0) { + continue; + } + String key = pair.substring(0, idx); + if (!"Authorization".equalsIgnoreCase(key)) { + continue; + } + String value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8); + if (value == null) { + return null; + } + value = value.trim(); + if (value.startsWith("Bearer ")) { + value = value.substring(7).trim(); + } + return value.isEmpty() ? null : value; + } + return null; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatWebSocketConfig.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatWebSocketConfig.java new file mode 100644 index 00000000..dee09927 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatWebSocketConfig.java @@ -0,0 +1,33 @@ +package org.ruoyi.websocket.chat; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * 小程序对话 WebSocket 端点配置。 + *

+ * 独立注册 /chat/ws,无权限(握手拦截器仅做 token 解析、不拦截), + * 与公共 ruoyi-common-websocket 的 /resource/websocket 互不干扰(后者受 websocket.enabled 控制,默认关闭)。 + * + * @author ruoyi team + */ +@Configuration +@EnableWebSocket +@RequiredArgsConstructor +public class MpChatWebSocketConfig { + + private final MpChatWebSocketHandler mpChatWebSocketHandler; + private final MpChatHandshakeInterceptor mpChatHandshakeInterceptor; + + @Bean + public WebSocketConfigurer mpChatWebSocketConfigurer() { + return registry -> registry + .addHandler(mpChatWebSocketHandler, "/chat/ws") + .addInterceptors(mpChatHandshakeInterceptor) + .setAllowedOrigins("*"); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatWebSocketHandler.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatWebSocketHandler.java new file mode 100644 index 00000000..069d20f8 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/websocket/chat/MpChatWebSocketHandler.java @@ -0,0 +1,378 @@ +package org.ruoyi.websocket.chat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import dev.langchain4j.rag.AugmentationRequest; +import dev.langchain4j.rag.AugmentationResult; +import dev.langchain4j.rag.DefaultRetrievalAugmentor; +import dev.langchain4j.rag.RetrievalAugmentor; +import dev.langchain4j.rag.content.Content; +import dev.langchain4j.rag.content.retriever.ContentRetriever; +import dev.langchain4j.rag.query.Metadata; +import dev.langchain4j.rag.query.Query; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.enums.RoleType; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.common.core.utils.StringUtils; +import org.ruoyi.domain.vo.agent.AgentVo; +import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo; +import org.ruoyi.factory.ChatServiceFactory; +import org.ruoyi.service.agent.IAgentService; +import org.ruoyi.service.chat.IChatMessageService; +import org.ruoyi.service.knowledge.IKnowledgeInfoService; +import org.ruoyi.service.knowledge.retriever.CustomVectorRetriever; +import org.ruoyi.service.retrieval.KnowledgeRetrievalService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.AbstractWebSocketHandler; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * 小程序对话 WebSocket 处理器。 + *

+ * 收到前端 JSON 消息后:解析模型(智能体绑定 / 前端传入 / 默认兜底)→ + * 拼装 systemPrompt 与 RAG 增强后的 content → 调用 StreamingChatModel 流式生成 → + * 将增量 token 通过当前 WS session 回推前端。 + *

+ * 输出协议(与前端 index.vue 现有接收逻辑兼容): + *

    + *
  • 增量:{"content":"token片段"}
  • + *
  • 结束:[DONE]
  • + *
  • 错误:{"data":"错误:xxx"}
  • + *
+ * + * @author ruoyi team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MpChatWebSocketHandler extends AbstractWebSocketHandler { + + private final ChatServiceFactory chatServiceFactory; + private final IChatModelService chatModelService; + private final IAgentService agentService; + private final IKnowledgeInfoService knowledgeInfoService; + private final KnowledgeRetrievalService knowledgeRetrievalService; + private final IChatMessageService chatMessageService; + private final ObjectMapper objectMapper; + + @Value("${chat.default-model:}") + private String defaultModel; + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + Map payload; + try { + payload = objectMapper.readValue(message.getPayload(), Map.class); + } catch (Exception e) { + sendError(session, "错误:消息格式不正确"); + return; + } + String content = asString(payload.get("content")); + String agentIdRaw = asString(payload.get("agentId")); + String model = asString(payload.get("model")); + String systemPrompt = asString(payload.get("systemPrompt")); + String knowledgeId = asString(payload.get("knowledgeId")); + String sessionIdRaw = asString(payload.get("sessionId")); + + if (StringUtils.isBlank(content)) { + sendError(session, "错误:对话消息不能为空"); + return; + } + + Long userId = (Long) session.getAttributes().get(MpChatHandshakeInterceptor.USER_ID_KEY); + Long sessionId = parseLong(sessionIdRaw); + + try { + // 1. 解析智能体(若传了 agentId),取其绑定模型与 systemPrompt、知识库 + AgentVo agentVo = null; + if (StringUtils.isNotBlank(agentIdRaw)) { + Long agentId = parseLong(agentIdRaw); + if (agentId != null) { + agentVo = agentService.queryById(agentId); + } + } + + // 2. 解析模型:智能体绑定 > 前端传入 > 默认配置 > 表内首个 chat 模型 + ChatModelVo modelVo = null; + if (agentVo != null && agentVo.getModelId() != null) { + modelVo = chatModelService.queryById(agentVo.getModelId()); + } + if (modelVo == null && StringUtils.isNotBlank(model)) { + modelVo = chatModelService.selectModelByName(model); + } + if (modelVo == null) { + modelVo = resolveDefaultModel(); + } + if (modelVo == null) { + sendError(session, "错误:未找到可用对话模型,请联系管理员配置"); + return; + } + + // 3. 拼装最终输入:RAG 增强 + systemPrompt 前置 + String finalSystemPrompt = (agentVo != null && StringUtils.isNotBlank(agentVo.getSystemPrompt())) + ? agentVo.getSystemPrompt() : systemPrompt; + String augmentedContent = augmentWithKnowledge(content, agentVo, knowledgeId); + String finalContent = StringUtils.isNotBlank(finalSystemPrompt) + ? finalSystemPrompt + "\n\n" + augmentedContent : augmentedContent; + + // 4. 落库用户消息(仅在具备用户与会话标识时) + if (userId != null && sessionId != null) { + try { + chatMessageService.saveChatMessage(userId, sessionId, content, + RoleType.USER.getName(), modelVo.getModelName()); + } catch (Exception e) { + log.warn("落库用户消息失败: {}", e.getMessage()); + } + } + + // 5. 构造流式模型并异步生成 + ChatRequest chatRequest = new ChatRequest(); + chatRequest.setContent(content); + chatRequest.setModel(modelVo.getModelName()); + chatRequest.setKnowledgeId(knowledgeId); + StreamingChatModel streamingModel = chatServiceFactory + .getOriginalService(modelVo.getProviderCode()) + .buildStreamingChatModel(modelVo, chatRequest); + + final String modelName = modelVo.getModelName(); + CompletableFuture.runAsync(() -> { + StringBuilder buffer = new StringBuilder(); + // 是否已向前端发送 [DONE] 结束标记,避免重复发送或遗漏 + boolean[] doneSent = {false}; + StreamingChatResponseHandler handler = new StreamingChatResponseHandler() { + @Override + public void onPartialResponse(String partialResponse) { + buffer.append(partialResponse); + sendJson(session, Map.of("content", partialResponse)); + } + + @Override + public void onCompleteResponse(ChatResponse completeResponse) { + if (!doneSent[0]) { + doneSent[0] = true; + sendRaw(session, "[DONE]"); + } + if (userId != null && sessionId != null && buffer.length() > 0) { + try { + chatMessageService.saveChatMessage(userId, sessionId, buffer.toString(), + RoleType.ASSISTANT.getName(), modelName); + } catch (Exception e) { + log.warn("落库助手回复失败: {}", e.getMessage()); + } + } + } + + @Override + public void onError(Throwable error) { + if (buffer.length() == 0) { + // 一点内容都没输出就出错:向前端报错 + sendError(session, "错误:" + safeMsg(error)); + } else if (!doneSent[0]) { + // 已有部分内容但流式中途异常:补发 [DONE] 让前端正常收尾,不报错 + doneSent[0] = true; + sendRaw(session, "[DONE]"); + log.warn("mp-chat 流式中途异常(已输出内容,补发 [DONE]): {}", safeMsg(error)); + } else { + // onComplete 后的收尾异常:回复已正常结束,静默 + log.warn("mp-chat 流式收尾异常(已结束,忽略): {}", safeMsg(error)); + } + } + }; + try { + streamingModel.chat(finalContent, handler); + } catch (Exception e) { + log.error("mp-chat 调用模型失败", e); + sendError(session, "错误:" + safeMsg(e)); + } + }); + } catch (Exception e) { + log.error("mp-chat 处理消息失败", e); + sendError(session, "错误:" + safeMsg(e)); + } + } + + /** + * 智能体绑定知识库 / 前端传入 knowledgeId 时,对 content 做向量检索增强。 + * 复用 ChatServiceFacade.buildMultiKnowledgeAugmentor 的组装方式(简化为多库复合检索)。 + */ + private String augmentWithKnowledge(String content, AgentVo agentVo, String knowledgeId) { + List kids = new ArrayList<>(); + if (agentVo != null && agentVo.getKnowledgeIds() != null) { + kids.addAll(agentVo.getKnowledgeIds()); + } + if (StringUtils.isBlank(knowledgeId) && kids.isEmpty()) { + return content; + } + if (StringUtils.isNotBlank(knowledgeId)) { + try { + kids.add(Long.valueOf(knowledgeId)); + } catch (NumberFormatException ignored) { + } + } + if (kids.isEmpty()) { + return content; + } + try { + RetrievalAugmentor augmentor = buildMultiKnowledgeAugmentor(kids); + if (augmentor == null) { + return content; + } + UserMessage userMessage = UserMessage.userMessage(content); + Metadata metadata = Metadata.from(userMessage, null, new ArrayList<>()); + AugmentationResult result = augmentor.augment(new AugmentationRequest(userMessage, metadata)); + ChatMessage augmented = result.chatMessage(); + return augmented instanceof UserMessage ? ((UserMessage) augmented).singleText() : content; + } catch (Exception e) { + log.warn("mp-chat RAG 增强失败,回退原文: {}", e.getMessage()); + return content; + } + } + + private RetrievalAugmentor buildMultiKnowledgeAugmentor(List knowledgeIds) { + List retrievers = new ArrayList<>(); + for (Long kid : knowledgeIds) { + try { + KnowledgeInfoVo kb = knowledgeInfoService.queryById(kid); + if (kb == null) { + continue; + } + ChatModelVo embModel = chatModelService.selectModelByName(kb.getEmbeddingModel()); + if (embModel == null) { + log.warn("mp-chat 知识库向量模型未配置: kid={}, emb={}", kid, kb.getEmbeddingModel()); + continue; + } + retrievers.add(new CustomVectorRetriever(knowledgeRetrievalService, kb, embModel)); + } catch (Exception e) { + log.warn("mp-chat 构建检索器失败: kid={}, err={}", kid, e.getMessage()); + } + } + if (retrievers.isEmpty()) { + return null; + } + ContentRetriever composite = retrievers.size() == 1 + ? retrievers.get(0) + : new CompositeContentRetriever(retrievers); + return DefaultRetrievalAugmentor.builder().contentRetriever(composite).build(); + } + + /** + * 默认模型兜底:优先用 chat.default-model 配置,其次取表内首个 chat 类模型。 + */ + private ChatModelVo resolveDefaultModel() { + if (StringUtils.isNotBlank(defaultModel)) { + ChatModelVo vo = chatModelService.selectModelByName(defaultModel); + if (vo != null) { + return vo; + } + } + try { + List list = chatModelService.queryList(new ChatModelBo()); + if (list != null) { + for (ChatModelVo vo : list) { + if ("chat".equalsIgnoreCase(vo.getCategory()) && "Y".equalsIgnoreCase(vo.getModelShow())) { + return vo; + } + } + if (!list.isEmpty()) { + return list.get(0); + } + } + } catch (Exception e) { + log.warn("mp-chat 解析默认模型失败: {}", e.getMessage()); + } + return null; + } + + // ---------- WS 输出辅助 ---------- + + private void sendJson(WebSocketSession session, Map data) { + if (!session.isOpen()) { + return; + } + try { + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(data))); + } catch (Exception e) { + log.warn("mp-chat 发送 WS 消息失败: {}", e.getMessage()); + } + } + + private void sendRaw(WebSocketSession session, String raw) { + if (!session.isOpen()) { + return; + } + try { + session.sendMessage(new TextMessage(raw)); + } catch (Exception e) { + log.warn("mp-chat 发送 WS 消息失败: {}", e.getMessage()); + } + } + + private void sendError(WebSocketSession session, String msg) { + Map err = new HashMap<>(); + err.put("data", msg); + sendJson(session, err); + } + + private static String safeMsg(Throwable e) { + return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + } + + private static String asString(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static Long parseLong(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + try { + return Long.valueOf(raw.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * 多知识库复合检索器:并发查询各库并合并结果。 + * (与 ChatServiceFacade 内部 CompositeContentRetriever 同构,独立保留以解耦公共门面) + */ + private static class CompositeContentRetriever implements ContentRetriever { + private final List delegates; + + CompositeContentRetriever(List delegates) { + this.delegates = delegates; + } + + @Override + public List retrieve(Query query) { + List all = new ArrayList<>(); + for (ContentRetriever r : delegates) { + try { + List part = r.retrieve(query); + if (part != null) { + all.addAll(part); + } + } catch (Exception e) { + log.warn("mp-chat 复合检索子检索器异常: {}", e.getMessage()); + } + } + return all; + } + } +} From 5d834d924af06075040ad2c85583c0783f989f4f Mon Sep 17 00:00:00 2001 From: "ageerle@163.com" Date: Fri, 17 Jul 2026 14:47:18 +0800 Subject: [PATCH 10/29] =?UTF-8?q?feat:=20=E7=BC=96=E7=A8=8B=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E6=8C=89=E8=81=8A=E5=A4=A9=E6=A8=A1=E5=9E=8B=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E5=B9=B6=E8=A1=A5=E5=85=85=20MCP=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=20thinking=20=E4=BA=8B=E4=BB=B6=EF=BC=8CREADME=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=20copilot/uniapp=20=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CodingController.models 按 category=chat 过滤,编程对话只展示聊天模型 - 各 MCP 文件/命令工具在执行前推送 thinking 提示事件 - README/README_EN 项目源码表新增 ruoyi-copilot、ruoyi-uniapp 三端链接 Co-Authored-By: Claude --- README.md | 2 ++ README_EN.md | 2 ++ .../java/org/ruoyi/controller/coding/CodingController.java | 7 +++++-- .../src/main/java/org/ruoyi/mcp/tools/DeleteFileTool.java | 1 + .../src/main/java/org/ruoyi/mcp/tools/EditFileTool.java | 1 + .../main/java/org/ruoyi/mcp/tools/ExecuteCommandTool.java | 1 + .../main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java | 1 + .../src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java | 1 + .../src/main/java/org/ruoyi/mcp/tools/WriteFileTool.java | 1 + 9 files changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 26f77a4f..1b981147 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ | 🎨 用户前端 | [ruoyi-web](https://github.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitee.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitcode.com/ageerle/ruoyi-web) | | 🛠️ 管理后台 | [ruoyi-admin](https://github.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitee.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitcode.com/ageerle/ruoyi-admin) | | 🎬 短剧平台 | [ruoyi-drama](https://github.com/ageerle/ruoyi-drama) | [ruoyi-drama](https://gitee.com/ageerle/ruoyi-drama) | [ruoyi-drama](https://gitcode.com/ageerle/ruoyi-drama) | +| 🤖 编程助手 | [ruoyi-copilot](https://github.com/ageerle/ruoyi-copilot) | [ruoyi-copilot](https://gitee.com/ageerle/ruoyi-copilot) | [ruoyi-copilot](https://gitcode.com/ageerle/ruoyi-copilot) | +| 📱 小程序端 | [ruoyi-uniapp](https://github.com/ageerle/ruoyi-uniapp) | [ruoyi-uniapp](https://gitee.com/ageerle/ruoyi-uniapp) | [ruoyi-uniapp](https://gitcode.com/ageerle/ruoyi-uniapp) | ### 合作项目 | 项目名称 | GitHub 仓库 | Gitee 仓库 diff --git a/README_EN.md b/README_EN.md index 7f193a9d..5c68afa3 100644 --- a/README_EN.md +++ b/README_EN.md @@ -48,6 +48,8 @@ | 🎨 User Frontend | [ruoyi-web](https://github.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitee.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitcode.com/ageerle/ruoyi-web) | | 🛠️ Admin Panel | [ruoyi-admin](https://github.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitee.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitcode.com/ageerle/ruoyi-admin) | | 🎬 Drama | [ruoyi-drama](https://github.com/ageerle/ruoyi-drama) | [ruoyi-drama](https://gitee.com/ageerle/ruoyi-drama) | | +| 🤖 Copilot | [ruoyi-copilot](https://github.com/ageerle/ruoyi-copilot) | [ruoyi-copilot](https://gitee.com/ageerle/ruoyi-copilot) | [ruoyi-copilot](https://gitcode.com/ageerle/ruoyi-copilot) | +| 📱 Mini-App | [ruoyi-uniapp](https://github.com/ageerle/ruoyi-uniapp) | [ruoyi-uniapp](https://gitee.com/ageerle/ruoyi-uniapp) | [ruoyi-uniapp](https://gitcode.com/ageerle/ruoyi-uniapp) | ### Partner Projects | Project Name | GitHub Repository | Gitee Repository | diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/coding/CodingController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/coding/CodingController.java index 759cb592..dc7d1bdf 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/coding/CodingController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/coding/CodingController.java @@ -7,6 +7,7 @@ import org.ruoyi.common.core.domain.R; import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo; import org.ruoyi.common.chat.service.chat.IChatModelService; import org.ruoyi.domain.bo.coding.CodingRequestBo; +import org.ruoyi.enums.ModelType; import org.ruoyi.service.coding.CodingWorkspaceService; import org.ruoyi.service.coding.ICodingService; import org.springframework.http.MediaType; @@ -62,8 +63,10 @@ public class CodingController { @GetMapping("/models") public R> models() { - List models = chatModelService.queryList(new ChatModelBo()).stream() - .filter(model -> "1".equals(model.getModelShow())) + // 编程对话只能用聊天模型,按 category=chat 过滤 + ChatModelBo bo = new ChatModelBo(); + bo.setCategory(ModelType.CHAT.getKey()); + List models = chatModelService.queryList(bo).stream() .map(model -> new ModelOption(model.getId(), model.getModelName(), model.getProviderCode())) .toList(); return R.ok(models); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/DeleteFileTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/DeleteFileTool.java index 78b467a8..b7d16ff7 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/DeleteFileTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/DeleteFileTool.java @@ -77,6 +77,7 @@ public class DeleteFileTool implements BuiltinToolProvider { } if (channel != null) { + channel.send(CodingSseEvent.thinking("正在删除:" + filePath)); channel.send(CodingSseEvent.of("delete-start", filePath, null, null, "running")); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java index 7f74205c..d78bdf6d 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java @@ -87,6 +87,7 @@ public class EditFileTool implements BuiltinToolProvider { // 推送编辑开始事件 String relativePath = getRelativePath(path); if (channel != null) { + channel.send(CodingSseEvent.thinking("正在编辑文件:" + filePath)); channel.send(CodingSseEvent.of("edit-start", filePath, null, null, "running")); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ExecuteCommandTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ExecuteCommandTool.java index 7c7d3e19..2169ab49 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ExecuteCommandTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ExecuteCommandTool.java @@ -113,6 +113,7 @@ public class ExecuteCommandTool implements BuiltinToolProvider { cmdList.set(0, resolvedCmd); if (channel != null) { + channel.send(CodingSseEvent.thinking("正在执行命令:" + command)); channel.send(CodingSseEvent.of("cmd", null, command, null, "running")); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java index 3104029b..43b2216c 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java @@ -90,6 +90,7 @@ public class ListDirectoryTool implements BuiltinToolProvider { // 推送列目录开始事件 if (channel != null) { + channel.send(CodingSseEvent.thinking("正在扫描目录结构:" + getRelativePath(dirPath))); channel.send(CodingSseEvent.of("list-progress", null, null, "扫描 " + getRelativePath(dirPath), "running")); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java index 369c330b..5fcae5b2 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java @@ -86,6 +86,7 @@ public class ReadFileTool implements BuiltinToolProvider { // 推送读取开始事件(前端展示为正在读取) String relativePath = getRelativePath(path); if (channel != null) { + channel.send(CodingSseEvent.thinking("正在读取文件:" + filePath)); channel.send(CodingSseEvent.of("edit-start", filePath, null, null, "running")); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/WriteFileTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/WriteFileTool.java index 615c4aa3..272bb9e4 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/WriteFileTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/WriteFileTool.java @@ -75,6 +75,7 @@ public class WriteFileTool implements BuiltinToolProvider { } if (channel != null) { + channel.send(CodingSseEvent.thinking("正在新建/写入文件:" + filePath)); channel.send(CodingSseEvent.of("add-start", filePath, null, null, "running")); } From 879fe489450f8fc0e4b73efdff0777d927c93077 Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Sun, 19 Jul 2026 12:20:22 +0800 Subject: [PATCH 11/29] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=90=AF=E5=8A=A8=E3=80=81=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E4=B8=8E=E6=9E=84=E5=BB=BA=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docker/ruoyi-ai/Dockerfile.mysql | 4 +- docs/docker/ruoyi-ai/docker-compose-all.yaml | 9 +- docs/docker/ruoyi-ai/docker-compose.yaml | 6 +- .../{ruoyi-ai-v3_mysql8.sql => ruoyi-ai.sql} | 34 +- docs/script/sql/snail_job_mysql.sql | 545 ++++++++++++++++++ pom.xml | 1 + .../src/main/resources/application-dev.yml | 6 +- .../src/main/resources/application-prod.yml | 4 +- .../src/main/resources/application.yml | 4 +- .../chat/domain/bo/chat/ChatModelBo.java | 5 + .../src/main/resources/application.yml | 10 +- .../src/main/resources/application-dev.yml | 8 +- .../src/main/resources/application-prod.yml | 4 +- .../src/main/resources/application.yml | 2 +- ruoyi-modules/ruoyi-aiflow/pom.xml | 15 + .../ruoyi/workflow/workflow/CompileNode.java | 2 + .../workflow/workflow/NodeProcessResult.java | 2 + .../composition/FfmpegCommandBuilderTest.java | 4 +- .../FfmpegFilterGraphBuilderTest.java | 2 +- 19 files changed, 621 insertions(+), 46 deletions(-) rename docs/script/sql/{ruoyi-ai-v3_mysql8.sql => ruoyi-ai.sql} (90%) create mode 100644 docs/script/sql/snail_job_mysql.sql diff --git a/docs/docker/ruoyi-ai/Dockerfile.mysql b/docs/docker/ruoyi-ai/Dockerfile.mysql index d5fcecb7..31d88961 100644 --- a/docs/docker/ruoyi-ai/Dockerfile.mysql +++ b/docs/docker/ruoyi-ai/Dockerfile.mysql @@ -7,7 +7,7 @@ ENV TZ=Asia/Shanghai # 复制初始化脚本和SQL文件到镜像中 COPY docs/script/docker/mysql/init/init-db.sh /docker-entrypoint-initdb.d/init-db.sh -COPY docs/script/sql/ruoyi-ai-v3_mysql8.sql /docker-entrypoint-initdb.d/ruoyi-ai-v3_mysql8.sql +COPY docs/script/sql/ruoyi-ai.sql /docker-entrypoint-initdb.d/ruoyi-ai.sql # 设置脚本可执行权限 RUN chmod +x /docker-entrypoint-initdb.d/init-db.sh @@ -18,4 +18,4 @@ CMD ["--default-authentication-plugin=mysql_native_password", \ "--collation-server=utf8mb4_general_ci", \ "--explicit_defaults_for_timestamp=true", \ "--lower_case_table_names=1", \ - "--skip-ssl"] \ No newline at end of file + "--skip-ssl"] diff --git a/docs/docker/ruoyi-ai/docker-compose-all.yaml b/docs/docker/ruoyi-ai/docker-compose-all.yaml index b9f330c1..21c781a5 100644 --- a/docs/docker/ruoyi-ai/docker-compose-all.yaml +++ b/docs/docker/ruoyi-ai/docker-compose-all.yaml @@ -12,8 +12,6 @@ # # 镜像仓库地址: crpi-31mraxd99y2gqdgr.cn-beijing.personal.cr.aliyuncs.com/ruoyi_ai -version: '3.8' - services: # ==================== MySQL 数据库 ==================== mysql: @@ -30,7 +28,10 @@ services: volumes: - mysql-data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"] + # Force TCP: during first-time initialization MySQL exposes a temporary + # socket-only server. A socket ping would mark the service healthy before + # the real network listener (used by the backend) is ready. + test: ["CMD", "mysqladmin", "ping", "--protocol=tcp", "-h", "127.0.0.1", "-u", "root", "-proot"] interval: 15s timeout: 10s retries: 10 @@ -177,4 +178,4 @@ volumes: weaviate-data: minio-data: logs-data: - upload-data: \ No newline at end of file + upload-data: diff --git a/docs/docker/ruoyi-ai/docker-compose.yaml b/docs/docker/ruoyi-ai/docker-compose.yaml index eac534d8..c5775307 100644 --- a/docs/docker/ruoyi-ai/docker-compose.yaml +++ b/docs/docker/ruoyi-ai/docker-compose.yaml @@ -22,7 +22,7 @@ services: TZ: Asia/Shanghai volumes: - ./docs/script/docker/mysql/init/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro - - ./docs/script/sql/ruoyi-ai-v3_mysql8.sql:/docker-entrypoint-initdb.d/ruoyi-ai-v3_mysql8.sql:ro + - ./docs/script/sql/ruoyi-ai.sql:/docker-entrypoint-initdb.d/ruoyi-ai.sql:ro - mysql-data:/var/lib/mysql command: --default-authentication-plugin=mysql_native_password @@ -32,7 +32,9 @@ services: --lower_case_table_names=1 --skip-ssl healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"] + # Do not let the socket-only initialization server satisfy the check. + # The backend connects over TCP and must wait for that listener. + test: ["CMD", "mysqladmin", "ping", "--protocol=tcp", "-h", "127.0.0.1", "-u", "root", "-proot"] interval: 15s timeout: 10s retries: 10 diff --git a/docs/script/sql/ruoyi-ai-v3_mysql8.sql b/docs/script/sql/ruoyi-ai.sql similarity index 90% rename from docs/script/sql/ruoyi-ai-v3_mysql8.sql rename to docs/script/sql/ruoyi-ai.sql index 1623e046..ce0b0899 100644 --- a/docs/script/sql/ruoyi-ai-v3_mysql8.sql +++ b/docs/script/sql/ruoyi-ai.sql @@ -1,4 +1,4 @@ -/* +/* Navicat Premium Dump SQL Source Server : localhost_3306 @@ -1372,13 +1372,13 @@ CREATE TABLE `short_drama_character_appearance` ( -- ---------------------------- -- Records of short_drama_character_appearance -- ---------------------------- -INSERT INTO `short_drama_character_appearance` VALUES (2075609273836224512, 2075609273630703616, 0, '初始形象', '鹅蛋脸线条柔和,双眼皮杏眼明亮有神,小巧挺拔的鼻梁,薄厚适中的嘴唇微抿,柳叶眉自然舒展。乌黑过肩直发扎成利落低马尾,额前碎发自然垂落。身形修长匀称,穿简洁白色短袖Polo衫,左胸有队徽刺绣,搭配卡其色工装长裤和白色运动鞋,腰间斜挎急救小包。', NULL, -1, -1, '2026-07-10 23:53:08', -1, '2026-07-10 23:53:08', 0, NULL, NULL, 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2075609329570136064, 2075609329431724032, 0, '初始形象', '脸型棱角分明,下颌线条锋利。细长眼型,双眼皮,高挺精致的鼻梁,薄唇平直,眉峰上扬的剑眉浓黑有型,右眼尾点缀一颗小痣。乌黑短发以利落背头后梳,露出饱满额头,发质柔顺服帖。身形修长,高挑匀称。身穿深灰色高支羊毛西装,剪裁修身,肩线平直,内搭黑色精纺高领毛衣,下身同色西裤线条流畅,脚著黑色牛津皮鞋。鼻梁上架一副极细金属框运动墨镜,左手腕佩戴银色纤薄腕表。', NULL, -1, -1, '2026-07-10 23:53:22', -1, '2026-07-10 23:53:22', 0, NULL, NULL, 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2075609380207968256, 2075609380031807488, 0, '初始形象', '钰珑生着一张轮廓分明的瓜子脸,浓黑剑眉斜抬,衬出几分不驯。眼形偏窄的杏眼微微上挑,单眼皮收紧眼尾,转折处利落带锋。鼻梁小巧挺直,嘴唇薄厚适中,下颌角附近有一道浅淡旧伤痕。一头乌黑长发高高束成马尾,额前碎发桀骜翘起,发尾带着自然微卷。身形修长结实,肩背紧绷,常年奔跑的线条紧致流畅。她穿着白色速干运动背心、深灰色运动短裤,脚踩抓地强劲的黑色跑鞋。右脚踝始终缠着防护绷带,步态间略带拖沓,却像随时要蹬地冲出。手腕戴一只简约的黑色运动手表。', NULL, -1, -1, '2026-07-10 23:53:34', -1, '2026-07-10 23:53:34', 0, NULL, NULL, 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2075609601822408704, 2075609601688190976, 0, '初始形象', '瓜子脸,线条利落分明。一双眼裂纤长的丹凤眼,眼尾如燕尾般锐利上扬,内眼角尖细,双眼皮折痕深刻清晰,注视时自带压迫感。鼻梁挺直高耸,鼻翼收窄,鼻尖精致小巧。双唇薄而轮廓分明,上唇弓形,下唇微微内收。眉骨高隆,眉形为天然剑眉,眉头略粗,眉峰微挑,眉尾收得细长利落。鼻梁中段偏左有一颗极浅小痣。一头乌黑长发在脑后高高束成利落马尾,扎紧于顶骨后方,用黑色弹力绳固定,发丝柔顺垂坠,发尾齐整落至肩胛骨下缘,额前无一丝刘海,露出光洁额头。体型修长而匀称,身姿高挑挺拔。上身穿一件白色速干圆领运动短袖T恤,面料带有微透气网眼,外罩一件深蓝色立领轻型教练夹克,拉链半开,袖口设有魔术贴收束,左臂佩戴红色弹性针织队长袖标,表面印有白色「CAPTAIN」字样。下身为黑色弹力紧身运动长裤,高腰设计,膝盖处有拼接剪裁,脚踝处有短拉链。脚蹬一双白色轻量跑步鞋,鞋面为透气网布拼接合成革,鞋带系紧,鞋底防滑纹理清晰。左手腕佩戴一块简约黑色运动电子手表。', NULL, -1, -1, '2026-07-10 23:54:27', -1, '2026-07-10 23:54:27', 0, NULL, NULL, 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2077008100070019072, 2077008099927412736, 0, '初始形象', '方形脸,浓黑剑眉,双眼皮,眼神锐利,鼻梁高挺,嘴唇线条分明。乌黑三七分背头,整齐服帖。身形匀称,身高适中。身穿藏青色修身西装外套,内搭白色衬衫,深灰色西裤,黑色系带皮鞋,手腕佩戴简约钢带手表。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/dd7454cd25a6486aa5c7c3f8c7085041-3742113260bb4ee9.jpg', -1, -1, '2026-07-14 20:31:35', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/dd7454cd25a6486aa5c7c3f8c7085041-3742113260bb4ee9.jpg\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 方形脸,浓黑剑眉,双眼皮,眼神锐利,鼻梁高挺,嘴唇线条分明。乌黑三七分背头,整齐服帖。身形匀称,身高适中。身穿藏青色修身西装外套,内搭白色衬衫,深灰色西裤,黑色系带皮鞋,手腕佩戴简约钢带手表。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2077008101487693824, 2077008101332504576, 0, '初始形象', '长方脸型,轮廓分明,额间有浅横纹,颧骨平直。浓眉微斜,眉尾稍疏,单眼皮细长眼,眼窝微陷,目光专注。鼻梁高直,鼻翼内收,唇形薄而线条清晰,下唇中央有浅凹痕。下巴方圆,无明显痣疤。发色花白,短发偏分,发质硬而蓬松,略见鬓角银丝。身形匀称中等,肩背挺直。上身着灰色中山装,领口紧扣齐整,左下口袋别一支钢笔。下装深灰西裤,裤线笔直,脚穿黑色系带皮鞋,鞋面光亮。无多余配饰。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/4729daf662de42c0a3472bc05043529c-01a8aaf57a08ea21.jpg', -1, -1, '2026-07-14 20:31:35', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/4729daf662de42c0a3472bc05043529c-01a8aaf57a08ea21.jpg\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 长方脸型,轮廓分明,额间有浅横纹,颧骨平直。浓眉微斜,眉尾稍疏,单眼皮细长眼,眼窝微陷,目光专注。鼻梁高直,鼻翼内收,唇形薄而线条清晰,下唇中央有浅凹痕。下巴方圆,无明显痣疤。发色花白,短发偏分,发质硬而蓬松,略见鬓角银丝。身形匀称中等,肩背挺直。上身着灰色中山装,领口紧扣齐整,左下口袋别一支钢笔。下装深灰西裤,裤线笔直,脚穿黑色系带皮鞋,鞋面光亮。无多余配饰。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2077008104272711680, 2077008104104939520, 0, '初始形象', '轮廓分明的瓜子脸,下颌线条利落。深邃的双眼皮眼睛眼型偏长,目光沉静;鼻梁高挺笔直;嘴唇厚薄适中,唇峰清晰;浓密的剑眉斜飞入鬓。右侧眉尾处一颗小痣。发色乌黑,及耳的长度微卷自然蓬松,额前碎发刘海随意散落。身形修长挺拔,身高约一米七八。穿着简约白色棉质衬衫,袖口卷至小臂,搭配深灰色直筒长裤与黑色帆布鞋。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/aab75b6b-865d-4825-8eee-9e3a45ccec62.png', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/aab75b6b-865d-4825-8eee-9e3a45ccec62.png\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 轮廓分明的瓜子脸,下颌线条利落。深邃的双眼皮眼睛眼型偏长,目光沉静;鼻梁高挺笔直;嘴唇厚薄适中,唇峰清晰;浓密的剑眉斜飞入鬓。右侧眉尾处一颗小痣。发色乌黑,及耳的长度微卷自然蓬松,额前碎发刘海随意散落。身形修长挺拔,身高约一米七八。穿着简约白色棉质衬衫,袖口卷至小臂,搭配深灰色直筒长裤与黑色帆布鞋。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2075609273836224512, 2075609273630703616, 0, '初始形象', '鹅蛋脸线条柔和,双眼皮杏眼明亮有神,小巧挺拔的鼻梁,薄厚适中的嘴唇微抿,柳叶眉自然舒展。乌黑过肩直发扎成利落低马尾,额前碎发自然垂落。身形修长匀称,穿简洁白色短袖Polo衫,左胸有队徽刺绣,搭配卡其色工装长裤和白色运动鞋,腰间斜挎急救小包。', NULL, -1, -1, '2026-07-10 23:53:08', -1, '2026-07-10 23:53:08', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2075609329570136064, 2075609329431724032, 0, '初始形象', '脸型棱角分明,下颌线条锋利。细长眼型,双眼皮,高挺精致的鼻梁,薄唇平直,眉峰上扬的剑眉浓黑有型,右眼尾点缀一颗小痣。乌黑短发以利落背头后梳,露出饱满额头,发质柔顺服帖。身形修长,高挑匀称。身穿深灰色高支羊毛西装,剪裁修身,肩线平直,内搭黑色精纺高领毛衣,下身同色西裤线条流畅,脚著黑色牛津皮鞋。鼻梁上架一副极细金属框运动墨镜,左手腕佩戴银色纤薄腕表。', NULL, -1, -1, '2026-07-10 23:53:22', -1, '2026-07-10 23:53:22', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2075609380207968256, 2075609380031807488, 0, '初始形象', '钰珑生着一张轮廓分明的瓜子脸,浓黑剑眉斜抬,衬出几分不驯。眼形偏窄的杏眼微微上挑,单眼皮收紧眼尾,转折处利落带锋。鼻梁小巧挺直,嘴唇薄厚适中,下颌角附近有一道浅淡旧伤痕。一头乌黑长发高高束成马尾,额前碎发桀骜翘起,发尾带着自然微卷。身形修长结实,肩背紧绷,常年奔跑的线条紧致流畅。她穿着白色速干运动背心、深灰色运动短裤,脚踩抓地强劲的黑色跑鞋。右脚踝始终缠着防护绷带,步态间略带拖沓,却像随时要蹬地冲出。手腕戴一只简约的黑色运动手表。', NULL, -1, -1, '2026-07-10 23:53:34', -1, '2026-07-10 23:53:34', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2075609601822408704, 2075609601688190976, 0, '初始形象', '瓜子脸,线条利落分明。一双眼裂纤长的丹凤眼,眼尾如燕尾般锐利上扬,内眼角尖细,双眼皮折痕深刻清晰,注视时自带压迫感。鼻梁挺直高耸,鼻翼收窄,鼻尖精致小巧。双唇薄而轮廓分明,上唇弓形,下唇微微内收。眉骨高隆,眉形为天然剑眉,眉头略粗,眉峰微挑,眉尾收得细长利落。鼻梁中段偏左有一颗极浅小痣。一头乌黑长发在脑后高高束成利落马尾,扎紧于顶骨后方,用黑色弹力绳固定,发丝柔顺垂坠,发尾齐整落至肩胛骨下缘,额前无一丝刘海,露出光洁额头。体型修长而匀称,身姿高挑挺拔。上身穿一件白色速干圆领运动短袖T恤,面料带有微透气网眼,外罩一件深蓝色立领轻型教练夹克,拉链半开,袖口设有魔术贴收束,左臂佩戴红色弹性针织队长袖标,表面印有白色「CAPTAIN」字样。下身为黑色弹力紧身运动长裤,高腰设计,膝盖处有拼接剪裁,脚踝处有短拉链。脚蹬一双白色轻量跑步鞋,鞋面为透气网布拼接合成革,鞋带系紧,鞋底防滑纹理清晰。左手腕佩戴一块简约黑色运动电子手表。', NULL, -1, -1, '2026-07-10 23:54:27', -1, '2026-07-10 23:54:27', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2077008100070019072, 2077008099927412736, 0, '初始形象', '方形脸,浓黑剑眉,双眼皮,眼神锐利,鼻梁高挺,嘴唇线条分明。乌黑三七分背头,整齐服帖。身形匀称,身高适中。身穿藏青色修身西装外套,内搭白色衬衫,深灰色西裤,黑色系带皮鞋,手腕佩戴简约钢带手表。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/dd7454cd25a6486aa5c7c3f8c7085041-3742113260bb4ee9.jpg', -1, -1, '2026-07-14 20:31:35', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/dd7454cd25a6486aa5c7c3f8c7085041-3742113260bb4ee9.jpg\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 方形脸,浓黑剑眉,双眼皮,眼神锐利,鼻梁高挺,嘴唇线条分明。乌黑三七分背头,整齐服帖。身形匀称,身高适中。身穿藏青色修身西装外套,内搭白色衬衫,深灰色西裤,黑色系带皮鞋,手腕佩戴简约钢带手表。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2077008101487693824, 2077008101332504576, 0, '初始形象', '长方脸型,轮廓分明,额间有浅横纹,颧骨平直。浓眉微斜,眉尾稍疏,单眼皮细长眼,眼窝微陷,目光专注。鼻梁高直,鼻翼内收,唇形薄而线条清晰,下唇中央有浅凹痕。下巴方圆,无明显痣疤。发色花白,短发偏分,发质硬而蓬松,略见鬓角银丝。身形匀称中等,肩背挺直。上身着灰色中山装,领口紧扣齐整,左下口袋别一支钢笔。下装深灰西裤,裤线笔直,脚穿黑色系带皮鞋,鞋面光亮。无多余配饰。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/4729daf662de42c0a3472bc05043529c-01a8aaf57a08ea21.jpg', -1, -1, '2026-07-14 20:31:35', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/4729daf662de42c0a3472bc05043529c-01a8aaf57a08ea21.jpg\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 长方脸型,轮廓分明,额间有浅横纹,颧骨平直。浓眉微斜,眉尾稍疏,单眼皮细长眼,眼窝微陷,目光专注。鼻梁高直,鼻翼内收,唇形薄而线条清晰,下唇中央有浅凹痕。下巴方圆,无明显痣疤。发色花白,短发偏分,发质硬而蓬松,略见鬓角银丝。身形匀称中等,肩背挺直。上身着灰色中山装,领口紧扣齐整,左下口袋别一支钢笔。下装深灰西裤,裤线笔直,脚穿黑色系带皮鞋,鞋面光亮。无多余配饰。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2077008104272711680, 2077008104104939520, 0, '初始形象', '轮廓分明的瓜子脸,下颌线条利落。深邃的双眼皮眼睛眼型偏长,目光沉静;鼻梁高挺笔直;嘴唇厚薄适中,唇峰清晰;浓密的剑眉斜飞入鬓。右侧眉尾处一颗小痣。发色乌黑,及耳的长度微卷自然蓬松,额前碎发刘海随意散落。身形修长挺拔,身高约一米七八。穿着简约白色棉质衬衫,袖口卷至小臂,搭配深灰色直筒长裤与黑色帆布鞋。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/aab75b6b-865d-4825-8eee-9e3a45ccec62.png', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/aab75b6b-865d-4825-8eee-9e3a45ccec62.png\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 轮廓分明的瓜子脸,下颌线条利落。深邃的双眼皮眼睛眼型偏长,目光沉静;鼻梁高挺笔直;嘴唇厚薄适中,唇峰清晰;浓密的剑眉斜飞入鬓。右侧眉尾处一颗小痣。发色乌黑,及耳的长度微卷自然蓬松,额前碎发刘海随意散落。身形修长挺拔,身高约一米七八。穿着简约白色棉质衬衫,袖口卷至小臂,搭配深灰色直筒长裤与黑色帆布鞋。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); -- ---------------------------- -- Table structure for short_drama_location @@ -1563,15 +1563,15 @@ CREATE TABLE `short_drama_storyboard` ( -- ---------------------------- -- Records of short_drama_storyboard -- ---------------------------- -INSERT INTO `short_drama_storyboard` VALUES (2077008643517599744, 2077007721974484992, 2077007722058371072, 1, '辞退邮件与触电穿越', '深夜办公室,电脑屏幕亮光映照50岁小明苍老疲惫的脸,他盯着屏幕上的辞退邮件,眼神失焦。他拿起手机看到离婚协议截图,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边电水壶,水洒向插座,火花迸溅,小明身体后仰倒在椅子上,画面渐黑。', 'suspense', '平视近景', '缓推至固定', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"办公桌前\"}]', '现代办公室_夜', '{\"panel_number\":1,\"scene_summary\":\"深夜办公室,电脑屏幕亮光映照50岁小明苍老疲惫的脸,他盯着屏幕上的辞退邮件,眼神失焦。他拿起手机看到离婚协议截图,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边电水壶,水洒向插座,火花迸溅,小明身体后仰倒在椅子上,画面渐黑。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"低调硬光,保留阴影层次\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"办公桌前\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中浅景深,保留环境压迫感\",\"color_tone\":\"冷暗色调\"}', '[{\"name\":\"小明\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:现代办公室_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"小明倒在椅子上,身体后仰,火花在插座处闪烁,画面渐黑\",\"continuity_action\":\"小明身体失去平衡向后倒,火花仍在闪烁\",\"spatial_anchor\":\"小明位于画面中央偏左,办公桌桌面杂乱的纸张和电脑屏幕占据右半,电水壶翻倒在桌角\",\"present_characters\":[\"小明\"],\"scene_number\":1,\"segment_number\":1,\"segment_goal\":\"建立小明被辞退后的绝望状态并触发穿越\",\"segment_result\":\"小明触电倒下,穿越启动\",\"bridge_in\":\"开场建立办公室环境与人物状态\",\"bridge_out\":\"火花和倒下的动作形成视觉钩子,画面切黑\",\"beat_type\":\"trigger\",\"narrative_cause\":\"小明收到辞退邮件和离婚协议,情绪崩溃导致意外\",\"character_goal\":\"缓解内心痛苦,但无意识动作导致触电\",\"story_action\":\"手抖放下手机,碰倒水壶,水溅到插座引发火花\",\"story_result\":\"小明触电昏迷,穿越时空\",\"next_hook\":\"下一镜头需要展示小明醒来后的新环境\"}', '办公室昏暗,只有电脑屏幕亮着。50岁的小明穿着皱巴巴的衬衫,盯着屏幕上的辞退邮件。他拿起手机,看到妻子发来的离婚协议截图。手抖着放下手机,碰倒桌上的电水壶,水洒在插座上。火花闪过,小明倒在椅子上。', '小明坐在深夜办公室的办公桌前,电脑屏幕蓝光照亮他苍老疲惫的脸,桌上散落纸张,他手中拿着手机看着离婚协议截图,旁边的电水壶摇摇欲坠,背景黑暗,气氛压抑。', 8, '深夜办公室,中年男子坐在办公桌前,电脑屏幕光映照脸庞,他盯着屏幕,眼神失焦。他拿起手机看向屏幕,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边的电水壶,水洒向插座,火花迸溅,他身体后仰倒在椅子上。镜头从平视中景缓缓推近至脸部特写,然后固定,画面渐黑。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203912-pshlw-0.mp4', '472c35ef433744c8a4cbd35a438ad35e', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:41:42', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643727314944, 2077007721974484992, 2077007722058371072, 2, '重生1985', '小明躺在地上,睁开眼,阳光刺眼。他坐起身,低头看见自己穿着蓝色工装,手背皮肤光滑无皱纹。他摸自己的脸,感觉紧致年轻。旁边老式自行车铃响,一个年轻人骑车路过喊“小明,上班迟到了!”小明看向路口停着的二八自行车和墙上红色标语“时间就是金钱”,低头看手腕电子表显示1985.6,他深吸一口气,眼神从迷茫变成坚定,低语“再来一次”。', 'emotion', '平视近景转特写', '缓推加环绕', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"地面中央\"}]', '小巷_日_1985', '{\"panel_number\":2,\"scene_summary\":\"小明躺在地上,睁开眼,阳光刺眼。他坐起身,低头看见自己穿着蓝色工装,手背皮肤光滑无皱纹。他摸自己的脸,感觉紧致年轻。旁边老式自行车铃响,一个年轻人骑车路过喊“小明,上班迟到了!”小明看向路口停着的二八自行车和墙上红色标语“时间就是金钱”,低头看手腕电子表显示1985.6,他深吸一口气,眼神从迷茫变成坚定,低语“再来一次”。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"柔和侧光,突出面部情绪\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"地面中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"浅景深,突出角色表情\",\"color_tone\":\"克制柔和色调\"}', '[{\"name\":\"小明\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:小巷_日_1985,重新交代人物位置、朝向与环境\",\"end_state\":\"小明站立,眼神坚定,低头看电子表,嘴唇微动说“再来一次”\",\"continuity_action\":\"小明视线从手表移向前方街道,准备行动\",\"spatial_anchor\":\"小明位于小巷中央地面,左侧墙上有红色标语,右侧有自行车和骑车年轻人,背景是老旧居民楼\",\"present_characters\":[\"小明\",\"年轻人(群众)\"],\"scene_number\":2,\"segment_number\":1,\"segment_goal\":\"展示小明穿越后醒来发现变成年轻模样\",\"segment_result\":\"小明确认自己回到1985年,眼神由迷茫变坚定\",\"bridge_in\":\"画面从黑场渐亮,小明面部特写从模糊到清晰\",\"bridge_out\":\"小明深吸一口气并说出内心决定,视线看向前方街道\",\"beat_type\":\"reaction\",\"narrative_cause\":\"小明触电昏迷,穿越时空\",\"character_goal\":\"确认自身状态和时代\",\"story_action\":\"坐起、摸脸、看周围、看电子表\",\"story_result\":\"小明确定自己重生,产生第二次人生的决心\",\"next_hook\":\"下一镜头展示小明带着方案前往街道办\"}', '小明醒来,发现自己躺在地上,身穿80年代蓝色工装。他摸着脸——没有皱纹。旁边老式自行车铃响,一个年轻人喊:“小明,上班迟到了!”小明看着路口的二八自行车,墙上的标语“时间就是金钱”,愣住。他低头看手腕上的电子表:1985年6月。他深吸一口气,眼神从迷茫变成坚定:“再来一次。”', '小明坐在小巷地面,身穿蓝色工装,阳光从巷口射入照亮他的脸,他摸着自己年轻的脸庞,眼神迷茫;背景是青砖墙和红色标语,一辆老式自行车靠在墙边。', 10, '年轻男子躺在地面上,阳光刺眼,他缓缓睁开眼,然后坐起身,低头看自己双手和蓝色工装,摸自己的脸,表情从迷茫到惊讶。旁边传来自行车铃声,他转头看向巷口的二八自行车和「时间就是金钱」标语,再低头看手腕上的电子表,眼神从迷茫变为坚定。镜头从全景缓慢推近至脸部特写,同时环绕拍摄,最后固定。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203913-lxtrb-0.mp4', 'b3b6843f0b78433893608675852797bd', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:47:08', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643781840896, 2077007721974484992, 2077007722058371072, 3, '街道办受阻遇老陈', '街道办门口,小明拿着一叠手写方案快步走向大门。门卫伸出手臂挡住他。小明急切地解释“同志,我有商业计划,能帮工厂接全国订单!”门卫皱眉推他出去,纸张散落一地。小明蹲下慌乱捡纸。路对面一个穿中山装的中年男人(老陈)弯腰捡起一张,看着上面的字,抬头问:“你这上面写的‘电商’是啥?”小明抬头,眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”', 'daily', '平视中景', '轻微跟随', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"门口台阶下\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"路对面人行道\"}]', '街道办门口_日', '{\"panel_number\":3,\"scene_summary\":\"街道办门口,小明拿着一叠手写方案快步走向大门。门卫伸出手臂挡住他。小明急切地解释“同志,我有商业计划,能帮工厂接全国订单!”门卫皱眉推他出去,纸张散落一地。小明蹲下慌乱捡纸。路对面一个穿中山装的中年男人(老陈)弯腰捡起一张,看着上面的字,抬头问:“你这上面写的‘电商’是啥?”小明抬头,眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"门口台阶下\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"路对面人行道\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:街道办门口_日,重新交代人物位置、朝向与环境\",\"end_state\":\"小明蹲在地上抬头看向老陈,老陈站在路对面手持一张纸,两人视线交汇\",\"continuity_action\":\"小明视线锁定老陈,准备进一步解释\",\"spatial_anchor\":\"街道办铁门在画面左侧,门卫站在台阶上;小明在台阶下中央;老陈在画面右侧路对面行道树旁\",\"present_characters\":[\"小明\",\"老陈\",\"门卫(群众)\"],\"scene_number\":3,\"segment_number\":1,\"segment_goal\":\"小明尝试进入街道办被门卫阻拦,吸引老陈注意\",\"segment_result\":\"老陈捡起散落的方案并开始询问\",\"bridge_in\":\"小明拿着方案走向街道办大门,眼神充满希望\",\"bridge_out\":\"老陈目光落在方案上,问题引发后续对话\",\"beat_type\":\"trigger\",\"narrative_cause\":\"小明确定自己重生,产生第二次人生的决心\",\"character_goal\":\"进入街道办寻求支持\",\"story_action\":\"小明上前解释,被推倒,捡纸时发现老陈在看方案\",\"story_result\":\"老陈对方案产生好奇并主动与小明对话\",\"next_hook\":\"下一镜头展示两人在废弃仓库深入讨论\"}', '小明拿着一叠手写方案,站在街道办门口。门卫拦住他。小明:“同志,我有商业计划,能帮工厂接全国订单!”门卫:“胡闹!”推他出去。小明捡起散落的纸,抬头看到路对面一个穿中山装的中年男人(老陈)正在看他的方案。老陈:“你这上面写的‘电商’是啥?”小明眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”', '小明站在街道办门口台阶下,手拿一叠方案,表情急切;门卫站在台阶上伸手阻拦;路对面老陈穿中山装低头看纸,阳光从右上方照射。', 8, '年轻男子拿着一叠手写方案快步走向街道办大门,门卫伸出手臂阻挡。年轻男子急切地解释,门卫皱眉推他出去,纸张散落。年轻男子蹲下慌乱捡纸。路对面一位中年男子弯腰捡起一张纸,看着上面,抬头问话。镜头平视跟随年轻男子移动,然后切换到越肩视角展示门卫的阻拦动作。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203920-6rw8b-0.mp4', '7173c12461574b8abd063c46fc625ebe', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:47:09', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643823783936, 2077007721974484992, 2077007722058371072, 4, '仓库达成合作', '废弃仓库内,一张木桌两把椅子,小明和老陈对面而坐。桌上摆着两盒烟,老陈抽出一根点燃:“我信你一次,但怎么弄?”小明拿起笔在纸上画示意图:“用电话线连电脑,搞个库存信息库。我写程序。”老陈皱眉:“钱呢?”小明:“我先拉商户,您出设备。”老陈抖了抖烟灰,沉默几秒后点头。小明转头看向墙上的标语“工业学大庆”,低声:“这次一定要成。”', 'daily', '平视中景', '缓推', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"桌子一侧\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"桌子另一侧\"}]', '废弃仓库_日', '{\"panel_number\":4,\"scene_summary\":\"废弃仓库内,一张木桌两把椅子,小明和老陈对面而坐。桌上摆着两盒烟,老陈抽出一根点燃:“我信你一次,但怎么弄?”小明拿起笔在纸上画示意图:“用电话线连电脑,搞个库存信息库。我写程序。”老陈皱眉:“钱呢?”小明:“我先拉商户,您出设备。”老陈抖了抖烟灰,沉默几秒后点头。小明转头看向墙上的标语“工业学大庆”,低声:“这次一定要成。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"桌子一侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"桌子另一侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:废弃仓库_日,重新交代人物位置、朝向与环境\",\"end_state\":\"老陈点头,小明目光从墙上标语收回,表情坚定\",\"continuity_action\":\"小明视线从标语移回老陈,准备下一步计划\",\"spatial_anchor\":\"桌子位于仓库中央,小明坐画面左侧,老陈坐右侧,后面墙上有标语,水泥地面空旷\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":4,\"segment_number\":1,\"segment_goal\":\"小明与老陈在仓库达成合作意向\",\"segment_result\":\"老陈决定信任小明,提供设备支持\",\"bridge_in\":\"两人对面而坐,桌上两盒烟,老陈面露犹豫\",\"bridge_out\":\"老陈点头,小明看向墙上标语,低声坚定信念\",\"beat_type\":\"action\",\"narrative_cause\":\"老陈对方案产生好奇并主动与小明对话\",\"character_goal\":\"说服老陈提供设备支持\",\"story_action\":\"小明画图解释技术方案,提出资源分工\",\"story_result\":\"老陈同意合作,小明获得初步资源\",\"next_hook\":\"下一镜头展示工厂办公室内新进展与危机\"}', '老陈和小明对面坐着。桌上摆着两盒烟。老陈:“我信你一次,但怎么弄?”小明在纸上画:“用电话线连电脑,搞个库存信息库。我写程序。”老陈:“钱呢?”小明:“我先拉商户,您出设备。”老陈犹豫后点头。小明看着仓库墙上的“工业学大庆”,低声:“这次一定要成。”', '小明和老陈在废弃仓库的旧木桌对面而坐,桌上两盒烟和散落的纸张;老陈夹烟沉思,小明执笔画示意图;背景墙上红色大字「工业学大庆」,高窗透入光线。', 8, '年轻男子和中年男子对面坐在木桌两侧,桌上摆着两盒烟。中年男子抽出一根点燃,年轻男子拿起笔在纸上画图并说话。中年男子皱眉问话,年轻男子抬头回答,伸手指向纸面。中年男子抖烟灰后点头。年轻男子转头看向墙上的标语,低声自语。镜头从两人的中景缓缓推近至年轻男子的脸部,然后固定。', NULL, 'd4665ee140f34673914292855d389d38', 'generating', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:39:20', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643874115584, 2077007721974484992, 2077007722058371072, 5, '工商检查危机', '工厂办公室内,木制办公桌上转盘电话响起。老陈拿起听筒,脸色逐渐变白,手指捏紧话筒。挂断后他转身对旁边椅子上的小明说:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看桌上的账本,抬起头:“我们走的是信息服务,不是倒卖。”老陈叹气:“没人认可这个。”小明合上账本,直视老陈:“明天我来说。”', 'suspense', '平视近景', '缓推', '[{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"办公桌后\"},{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"办公桌旁椅子\"}]', '工厂办公室_日', '{\"panel_number\":5,\"scene_summary\":\"工厂办公室内,木制办公桌上转盘电话响起。老陈拿起听筒,脸色逐渐变白,手指捏紧话筒。挂断后他转身对旁边椅子上的小明说:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看桌上的账本,抬起头:“我们走的是信息服务,不是倒卖。”老陈叹气:“没人认可这个。”小明合上账本,直视老陈:“明天我来说。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"低调硬光,保留阴影层次\"},\"characters\":[{\"name\":\"老陈\",\"screen_position\":\"办公桌后\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"小明\",\"screen_position\":\"办公桌旁椅子\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中浅景深,保留环境压迫感\",\"color_tone\":\"冷暗色调\"}', '[{\"name\":\"老陈\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感\"},{\"name\":\"小明\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感\"}]', '{\"start_state\":\"新场景建立:工厂办公室_日,重新交代人物位置、朝向与环境\",\"end_state\":\"小明合上账本,直视老陈,表情自信;老陈眉头紧锁但不再反驳\",\"continuity_action\":\"小明视线坚定,老陈目光仍带忧虑但转向信任\",\"spatial_anchor\":\"老陈位于画面左侧办公桌后,小明在右侧椅子,窗外可见厂区烟囱,文件柜靠墙\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":5,\"segment_number\":1,\"segment_goal\":\"工厂办公室内老陈接到工商查处的电话,小明提出应对\",\"segment_result\":\"小明决定亲自去面对工商检查\",\"bridge_in\":\"办公室电话铃响打破平静,老陈接听表情变化\",\"bridge_out\":\"老陈看向小明,小明坚定地说“明天我来说”\",\"beat_type\":\"trigger\",\"narrative_cause\":\"老陈同意合作,小明获得初步资源\",\"character_goal\":\"让老陈相信自己能解决危机\",\"story_action\":\"小明解释模式合法性,并主动承担交涉责任\",\"story_result\":\"老陈部分安心,小明获得应对危机的授权\",\"next_hook\":\"下一镜头展示出租屋内团队内部争吵与统一思想\"}', '办公桌上电话响起。老陈接起,脸色变白。挂断后对小明:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看账本:“我们走的是信息服务,不是倒卖。”老陈:“没人认可这个。”小明:“明天我来说。”', '工厂办公室内,老陈坐在办公桌后接电话,脸色凝重;小明坐在侧面椅子上翻看账本,窗外可见工厂烟囱;桌上转盘电话和文件散落。', 7, '办公桌上黑色转盘电话响起。中年男子坐在桌后拿起听筒,脸色逐渐变白,手指捏紧话筒。他挂断后转身对旁边的年轻男子说话。年轻男子快速翻看账本,抬起头直视对方回话,然后合上账本。镜头从电话特写缓慢推近至中年男子面部,再缓拉到两人中景。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203917-gnb2m-0.mp4', '61edb37e979a426f9e8272786243c243', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:41:08', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643920252928, 2077007721974484992, 2077007722058371072, 6, '出租屋统一思想', '狭小出租屋,灯光昏暗。两个年轻团队成员拍桌站起:“不干了,风险太大!”老陈坐在床边沉默,低头看地面。小明从墙角拿起一块黑板放在桌上,画出一条时间轴,标注1985年,指向电脑图标。他转头对大家说:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头,皱眉问:“你哪来的这些?”小明停顿一下:“梦里学的。”', 'emotion', '平视近景转特写', '缓推加环绕', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"桌边站立\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"床边坐着\"}]', '出租屋_夜', '{\"panel_number\":6,\"scene_summary\":\"狭小出租屋,灯光昏暗。两个年轻团队成员拍桌站起:“不干了,风险太大!”老陈坐在床边沉默,低头看地面。小明从墙角拿起一块黑板放在桌上,画出一条时间轴,标注1985年,指向电脑图标。他转头对大家说:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头,皱眉问:“你哪来的这些?”小明停顿一下:“梦里学的。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"柔和侧光,突出面部情绪\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"桌边站立\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"床边坐着\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"浅景深,突出角色表情\",\"color_tone\":\"克制柔和色调\"}', '[{\"name\":\"小明\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:出租屋_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"老陈抬头看向小明,两个年轻人停止争吵望向黑板,小黑板上画有时间轴和“电脑”字样\",\"continuity_action\":\"小明手指还指着黑板上的“技术咨询”,等待异议\",\"spatial_anchor\":\"画面中央是一张桌子,黑板靠在桌上,小明站在桌子左侧画黑板,老陈坐在右侧床边,两个年轻人站在桌子远端\",\"present_characters\":[\"小明\",\"老陈\",\"两个年轻人(群众)\"],\"scene_number\":6,\"segment_number\":1,\"segment_goal\":\"出租屋内团队成员因风险而退缩,小明用时间轴规划说服大家\",\"segment_result\":\"老陈和小明达成共识,团队重拾信心\",\"bridge_in\":\"出租屋内两个年轻人拍桌起身,气氛紧张\",\"bridge_out\":\"老陈抬头看小明,眼神中带着疑惑与信任\",\"beat_type\":\"decision\",\"narrative_cause\":\"老陈部分安心,小明获得应对危机的授权\",\"character_goal\":\"稳定团队,统一认知\",\"story_action\":\"小明画出时间轴,解释信息差优势和应对策略\",\"story_result\":\"团队情绪稳定,老陈虽疑惑但接受解释\",\"next_hook\":\"下一镜头展示百货大楼门口工商检查的结果\"}', '团队成员(两个年轻人)拍桌:“不干了,风险太大!”老陈沉默。小明拿出一块黑板,画出时间轴:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头:“你哪来的这些?”小明:“梦里学的。”', '出租屋内,小明站在桌子左侧画黑板,黑板上粉笔写着时间轴和「电脑」字样;老陈坐在床边抬头看;两个年轻人站在桌子远端,表情从愤怒转为倾听;昏黄灯光下气氛紧张。', 10, '狭小出租屋内,两个年轻人拍桌站起情绪激动。中年男子坐在床边低头沉默。年轻男子从墙角拿起一块黑板放在桌上,用粉笔画出一条时间轴,标注1985和电脑图标,转头对大家说话并指向黑板。中年男子抬起头皱眉问话,年轻男子停顿后回答。镜头从全景缓慢推近至年轻男子面部特写,然后环绕拍摄黑板上的时间轴。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643978973184, 2077007721974484992, 2077007722058371072, 7, '工商检查通过', '百货大楼门口台阶上,工商人员和穿藏青色西装的中年经理站在一起。小明从公文包拿出一叠表格递过去:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看后递给旁边的百货经理。经理推了推眼镜细看,低声问小明:“这方法能帮我们清库存?”小明点头:“三天见效。”经理转向工商人员点了点头。工商人员收起表格:“先观察。”', 'daily', '平视中景', '轻微跟随', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"台阶上右侧\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"台阶上小明身后\"},{\"name\":\"百货经理\",\"appearance\":\"初始形象\",\"slot\":\"台阶上中央\"}]', '百货大楼门口_日', '{\"panel_number\":7,\"scene_summary\":\"百货大楼门口台阶上,工商人员和穿藏青色西装的中年经理站在一起。小明从公文包拿出一叠表格递过去:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看后递给旁边的百货经理。经理推了推眼镜细看,低声问小明:“这方法能帮我们清库存?”小明点头:“三天见效。”经理转向工商人员点了点头。工商人员收起表格:“先观察。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"台阶上右侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"台阶上小明身后\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"百货经理\",\"screen_position\":\"台阶上中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"百货经理\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:百货大楼门口_日,重新交代人物位置、朝向与环境\",\"end_state\":\"工商人员收起表格,经理向小明点头,小明露出微笑\",\"continuity_action\":\"小明视线跟随工商人员离开,准备下一步行动\",\"spatial_anchor\":\"百货大楼正门台阶为背景,工商人员在中央偏左,经理在左侧,小明在右侧,老陈在小明后方半步\",\"present_characters\":[\"小明\",\"老陈\",\"百货经理\",\"工商人员(群众)\"],\"scene_number\":7,\"segment_number\":1,\"segment_goal\":\"工商检查现场,小明用订单记录证明信息服务合法性,获得百货经理认可\",\"segment_result\":\"工商同意观察,项目得以继续\",\"bridge_in\":\"百货大楼门口,工商人员和小明、老陈站立,气氛凝重\",\"bridge_out\":\"工商人员收起表格,经理点头,危机解除\",\"beat_type\":\"result\",\"narrative_cause\":\"团队情绪稳定,老陈虽疑惑但接受解释\",\"character_goal\":\"用事实凭证打消工商疑虑\",\"story_action\":\"小明出示订单记录并解释运营模式\",\"story_result\":\"工商暂缓处理,项目获得官方默许\",\"next_hook\":\"下一镜头展示庆功礼堂的成果与小明的情感时刻\"}', '工商人员和小明、老陈站在门口。小明拿出一份表格:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看,递给旁边一个戴眼镜的中年人(百货公司经理)。经理看了后低声:“这方法能帮我们清库存?”小明:“三天见效。”经理点头。工商人员收起表格:“先观察。”', '小明站在百货大楼门口台阶上,从公文包中拿出表格递给工商人员;老陈站在他身后;旁边穿藏青色西装的百货经理低头查看表格;阳光照亮大楼立面。', 8, '百货大楼门口台阶上,工商人员和穿西装的中年男子站在一起。年轻男子从公文包拿出一叠表格递出并说话。工商人员翻看后递给旁边的经理。经理推了推眼镜细看,低声问年轻男子,年轻男子点头回答。经理转向工商人员点头。工商人员收起表格。镜头平视跟随年轻男子的动作,使用越肩视角展示表格内容。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008644041887744, 2077007721974484992, 2077007722058371072, 8, '厂庆宣言互联网愿景', '厂庆礼堂,红灯笼和横幅装饰,舞台上麦克风前,老陈对着台下说:“感谢小明的信息平台,咱们工厂活了。”台下工人鼓掌。小明站在侧台阴影里,手伸进口袋摸到发黄的现代身份证,他看着灯光下兴奋的面孔,默默将身份证撕碎。主持人喊:“请小明同志讲话!”小明走上舞台中央,拿起话筒,停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。', 'epic', '大远景转特写', '俯拍后缓降推近', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"舞台上中央\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"舞台侧边\"}]', '厂庆礼堂_夜', '{\"panel_number\":8,\"scene_summary\":\"厂庆礼堂,红灯笼和横幅装饰,舞台上麦克风前,老陈对着台下说:“感谢小明的信息平台,咱们工厂活了。”台下工人鼓掌。小明站在侧台阴影里,手伸进口袋摸到发黄的现代身份证,他看着灯光下兴奋的面孔,默默将身份证撕碎。主持人喊:“请小明同志讲话!”小明走上舞台中央,拿起话筒,停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"大范围自然光,突出空间规模\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"舞台上中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"舞台侧边\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"深景深,清晰展现环境规模\",\"color_tone\":\"宏大通透色调\"}', '[{\"name\":\"小明\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:厂庆礼堂_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"小明站在舞台中央举起话筒,台下工人站立鼓掌,老陈在侧台微笑\",\"continuity_action\":\"小明视线扫过全场,准备下台开始新阶段\",\"spatial_anchor\":\"舞台在画面中央靠上,台下长凳坐满工人,小明位于舞台中央,老陈在舞台左侧靠边\",\"present_characters\":[\"小明\",\"老陈\",\"工人(群众)\",\"主持人(群众)\"],\"scene_number\":8,\"segment_number\":1,\"segment_goal\":\"厂庆礼堂中老陈表扬小明,小明撕碎现代身份证并发表讲话\",\"segment_result\":\"小明公开宣告互联网未来愿景,全体工人热烈鼓掌\",\"bridge_in\":\"礼堂内工人坐满长凳,老陈在台上讲话\",\"bridge_out\":\"掌声雷动中,小明眼神坚定望向未来\",\"beat_type\":\"action\",\"narrative_cause\":\"工商暂缓处理,项目获得官方默许\",\"character_goal\":\"激励团队并宣告新方向\",\"story_action\":\"小明撕碎现代身份证,上台演讲\",\"story_result\":\"全体工人被鼓舞,小明确立新一代领导地位\",\"next_hook\":\"下一镜头展示清晨新挂牌和下一步计划\"}', '工人坐满长凳。老陈上台:“感谢小明的信息平台,咱们工厂活了。”台下鼓掌。小明站在侧台,看着灯光下兴奋的面孔。他手摸到口袋里的现代身份证——早已发黄。他默默撕碎。主持人:“请小明同志讲话!”小明上前,拿着话筒停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。', '厂庆礼堂内,舞台中央老陈握着话筒讲话,台下工人鼓掌;小明站在侧幕阴影中,手中捏着撕碎的身份证,灯光照亮舞台,红灯笼和横幅装饰,气氛热烈。', 10, '礼堂内工人坐满长凳,舞台中央中年男子在讲话,台下鼓掌。年轻男子站在侧台阴影中,手伸进口袋掏出一张发黄的身份证,默默撕碎。主持人喊话,年轻男子走上舞台中央,拿起话筒停顿三秒后说话,台下掌声雷动。镜头从礼堂大远景俯拍开始,然后缓缓下降并推近至年轻男子面部特写。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008644113190912, 2077007721974484992, 2077007722058371072, 9, '新征程启程', '清晨,阳光照在工厂大门新挂牌上,金边字体“华夏信息服务中心”。小明推着自行车走出来,老陈从后面追上:“下一步?”小明跨上车:“跑遍全国,建数据库。”老陈笑着摇头:“疯了。”小明回以笑容:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。', 'epic', '大远景', '缓拉升起', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"工厂大门前\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"门口台阶上\"}]', '工厂大门_晨', '{\"panel_number\":9,\"scene_summary\":\"清晨,阳光照在工厂大门新挂牌上,金边字体“华夏信息服务中心”。小明推着自行车走出来,老陈从后面追上:“下一步?”小明跨上车:“跑遍全国,建数据库。”老陈笑着摇头:“疯了。”小明回以笑容:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"大范围自然光,突出空间规模\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"工厂大门前\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"门口台阶上\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"深景深,清晰展现环境规模\",\"color_tone\":\"宏大通透色调\"}', '[{\"name\":\"小明\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:工厂大门_晨,重新交代人物位置、朝向与环境\",\"end_state\":\"小明骑车远去的背影在街道尽头,烟囱冒烟,晨光笼罩\",\"continuity_action\":\"小明继续蹬车,老陈目送,街道向前延伸\",\"spatial_anchor\":\"工厂大门在画面左侧,新挂牌醒目;小明骑车向右远去,街道两旁有工厂围墙和烟囱;老陈站在门口台阶上\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":9,\"segment_number\":1,\"segment_goal\":\"新挂牌的清晨,小明与老陈对话后骑车远去,象征新征程\",\"segment_result\":\"小明骑车远去,烟囱冒烟,新的一天开始\",\"bridge_in\":\"阳光照在新挂牌上,小明推车出现\",\"bridge_out\":\"镜头定格在远去的背影和工厂烟囱,留下开放节奏\",\"beat_type\":\"result\",\"narrative_cause\":\"全体工人被鼓舞,小明确立新一代领导地位\",\"character_goal\":\"确定下一阶段行动计划\",\"story_action\":\"小明跨上自行车说出全国数据库计划\",\"story_result\":\"小明开始实际行动,留下奋斗的背影\",\"next_hook\":\"故事结束,无下一镜头\"}', '阳光照在新挂牌上:“华夏信息服务中心”。小明推着自行车出来,老陈追上:“下一步?”小明:“跑遍全国,建数据库。”老陈笑:“疯了。”小明跨上车:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟。', '清晨阳光照射工厂大门新挂牌「华夏信息服务中心」,小明推自行车站在门口,老陈站在台阶上微笑;远处烟囱冒烟,天空泛鱼肚白,街道延伸向远方。', 10, '清晨阳光照在工厂大门新挂牌上,金色字体「华夏信息服务中心」。年轻男子推自行车从门内走出,中年男子从后面追上,两人对话。年轻男子跨上自行车,蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。镜头从新挂牌特写缓缓拉远并升起,展现工厂全貌和远去的背影。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643517599744, 2077007721974484992, 2077007722058371072, 1, '辞退邮件与触电穿越', '深夜办公室,电脑屏幕亮光映照50岁小明苍老疲惫的脸,他盯着屏幕上的辞退邮件,眼神失焦。他拿起手机看到离婚协议截图,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边电水壶,水洒向插座,火花迸溅,小明身体后仰倒在椅子上,画面渐黑。', 'suspense', '平视近景', '缓推至固定', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"办公桌前\"}]', '现代办公室_夜', '{\"panel_number\":1,\"scene_summary\":\"深夜办公室,电脑屏幕亮光映照50岁小明苍老疲惫的脸,他盯着屏幕上的辞退邮件,眼神失焦。他拿起手机看到离婚协议截图,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边电水壶,水洒向插座,火花迸溅,小明身体后仰倒在椅子上,画面渐黑。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"低调硬光,保留阴影层次\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"办公桌前\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中浅景深,保留环境压迫感\",\"color_tone\":\"冷暗色调\"}', '[{\"name\":\"小明\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:现代办公室_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"小明倒在椅子上,身体后仰,火花在插座处闪烁,画面渐黑\",\"continuity_action\":\"小明身体失去平衡向后倒,火花仍在闪烁\",\"spatial_anchor\":\"小明位于画面中央偏左,办公桌桌面杂乱的纸张和电脑屏幕占据右半,电水壶翻倒在桌角\",\"present_characters\":[\"小明\"],\"scene_number\":1,\"segment_number\":1,\"segment_goal\":\"建立小明被辞退后的绝望状态并触发穿越\",\"segment_result\":\"小明触电倒下,穿越启动\",\"bridge_in\":\"开场建立办公室环境与人物状态\",\"bridge_out\":\"火花和倒下的动作形成视觉钩子,画面切黑\",\"beat_type\":\"trigger\",\"narrative_cause\":\"小明收到辞退邮件和离婚协议,情绪崩溃导致意外\",\"character_goal\":\"缓解内心痛苦,但无意识动作导致触电\",\"story_action\":\"手抖放下手机,碰倒水壶,水溅到插座引发火花\",\"story_result\":\"小明触电昏迷,穿越时空\",\"next_hook\":\"下一镜头需要展示小明醒来后的新环境\"}', '办公室昏暗,只有电脑屏幕亮着。50岁的小明穿着皱巴巴的衬衫,盯着屏幕上的辞退邮件。他拿起手机,看到妻子发来的离婚协议截图。手抖着放下手机,碰倒桌上的电水壶,水洒在插座上。火花闪过,小明倒在椅子上。', '小明坐在深夜办公室的办公桌前,电脑屏幕蓝光照亮他苍老疲惫的脸,桌上散落纸张,他手中拿着手机看着离婚协议截图,旁边的电水壶摇摇欲坠,背景黑暗,气氛压抑。', 8, '深夜办公室,中年男子坐在办公桌前,电脑屏幕光映照脸庞,他盯着屏幕,眼神失焦。他拿起手机看向屏幕,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边的电水壶,水洒向插座,火花迸溅,他身体后仰倒在椅子上。镜头从平视中景缓缓推近至脸部特写,然后固定,画面渐黑。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203912-pshlw-0.mp4', '472c35ef433744c8a4cbd35a438ad35e', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:41:42', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643727314944, 2077007721974484992, 2077007722058371072, 2, '重生1985', '小明躺在地上,睁开眼,阳光刺眼。他坐起身,低头看见自己穿着蓝色工装,手背皮肤光滑无皱纹。他摸自己的脸,感觉紧致年轻。旁边老式自行车铃响,一个年轻人骑车路过喊“小明,上班迟到了!”小明看向路口停着的二八自行车和墙上红色标语“时间就是金钱”,低头看手腕电子表显示1985.6,他深吸一口气,眼神从迷茫变成坚定,低语“再来一次”。', 'emotion', '平视近景转特写', '缓推加环绕', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"地面中央\"}]', '小巷_日_1985', '{\"panel_number\":2,\"scene_summary\":\"小明躺在地上,睁开眼,阳光刺眼。他坐起身,低头看见自己穿着蓝色工装,手背皮肤光滑无皱纹。他摸自己的脸,感觉紧致年轻。旁边老式自行车铃响,一个年轻人骑车路过喊“小明,上班迟到了!”小明看向路口停着的二八自行车和墙上红色标语“时间就是金钱”,低头看手腕电子表显示1985.6,他深吸一口气,眼神从迷茫变成坚定,低语“再来一次”。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"柔和侧光,突出面部情绪\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"地面中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"浅景深,突出角色表情\",\"color_tone\":\"克制柔和色调\"}', '[{\"name\":\"小明\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:小巷_日_1985,重新交代人物位置、朝向与环境\",\"end_state\":\"小明站立,眼神坚定,低头看电子表,嘴唇微动说“再来一次”\",\"continuity_action\":\"小明视线从手表移向前方街道,准备行动\",\"spatial_anchor\":\"小明位于小巷中央地面,左侧墙上有红色标语,右侧有自行车和骑车年轻人,背景是老旧居民楼\",\"present_characters\":[\"小明\",\"年轻人(群众)\"],\"scene_number\":2,\"segment_number\":1,\"segment_goal\":\"展示小明穿越后醒来发现变成年轻模样\",\"segment_result\":\"小明确认自己回到1985年,眼神由迷茫变坚定\",\"bridge_in\":\"画面从黑场渐亮,小明面部特写从模糊到清晰\",\"bridge_out\":\"小明深吸一口气并说出内心决定,视线看向前方街道\",\"beat_type\":\"reaction\",\"narrative_cause\":\"小明触电昏迷,穿越时空\",\"character_goal\":\"确认自身状态和时代\",\"story_action\":\"坐起、摸脸、看周围、看电子表\",\"story_result\":\"小明确定自己重生,产生第二次人生的决心\",\"next_hook\":\"下一镜头展示小明带着方案前往街道办\"}', '小明醒来,发现自己躺在地上,身穿80年代蓝色工装。他摸着脸——没有皱纹。旁边老式自行车铃响,一个年轻人喊:“小明,上班迟到了!”小明看着路口的二八自行车,墙上的标语“时间就是金钱”,愣住。他低头看手腕上的电子表:1985年6月。他深吸一口气,眼神从迷茫变成坚定:“再来一次。”', '小明坐在小巷地面,身穿蓝色工装,阳光从巷口射入照亮他的脸,他摸着自己年轻的脸庞,眼神迷茫;背景是青砖墙和红色标语,一辆老式自行车靠在墙边。', 10, '年轻男子躺在地面上,阳光刺眼,他缓缓睁开眼,然后坐起身,低头看自己双手和蓝色工装,摸自己的脸,表情从迷茫到惊讶。旁边传来自行车铃声,他转头看向巷口的二八自行车和「时间就是金钱」标语,再低头看手腕上的电子表,眼神从迷茫变为坚定。镜头从全景缓慢推近至脸部特写,同时环绕拍摄,最后固定。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203913-lxtrb-0.mp4', 'b3b6843f0b78433893608675852797bd', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:47:08', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643781840896, 2077007721974484992, 2077007722058371072, 3, '街道办受阻遇老陈', '街道办门口,小明拿着一叠手写方案快步走向大门。门卫伸出手臂挡住他。小明急切地解释“同志,我有商业计划,能帮工厂接全国订单!”门卫皱眉推他出去,纸张散落一地。小明蹲下慌乱捡纸。路对面一个穿中山装的中年男人(老陈)弯腰捡起一张,看着上面的字,抬头问:“你这上面写的‘电商’是啥?”小明抬头,眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”', 'daily', '平视中景', '轻微跟随', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"门口台阶下\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"路对面人行道\"}]', '街道办门口_日', '{\"panel_number\":3,\"scene_summary\":\"街道办门口,小明拿着一叠手写方案快步走向大门。门卫伸出手臂挡住他。小明急切地解释“同志,我有商业计划,能帮工厂接全国订单!”门卫皱眉推他出去,纸张散落一地。小明蹲下慌乱捡纸。路对面一个穿中山装的中年男人(老陈)弯腰捡起一张,看着上面的字,抬头问:“你这上面写的‘电商’是啥?”小明抬头,眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"门口台阶下\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"路对面人行道\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:街道办门口_日,重新交代人物位置、朝向与环境\",\"end_state\":\"小明蹲在地上抬头看向老陈,老陈站在路对面手持一张纸,两人视线交汇\",\"continuity_action\":\"小明视线锁定老陈,准备进一步解释\",\"spatial_anchor\":\"街道办铁门在画面左侧,门卫站在台阶上;小明在台阶下中央;老陈在画面右侧路对面行道树旁\",\"present_characters\":[\"小明\",\"老陈\",\"门卫(群众)\"],\"scene_number\":3,\"segment_number\":1,\"segment_goal\":\"小明尝试进入街道办被门卫阻拦,吸引老陈注意\",\"segment_result\":\"老陈捡起散落的方案并开始询问\",\"bridge_in\":\"小明拿着方案走向街道办大门,眼神充满希望\",\"bridge_out\":\"老陈目光落在方案上,问题引发后续对话\",\"beat_type\":\"trigger\",\"narrative_cause\":\"小明确定自己重生,产生第二次人生的决心\",\"character_goal\":\"进入街道办寻求支持\",\"story_action\":\"小明上前解释,被推倒,捡纸时发现老陈在看方案\",\"story_result\":\"老陈对方案产生好奇并主动与小明对话\",\"next_hook\":\"下一镜头展示两人在废弃仓库深入讨论\"}', '小明拿着一叠手写方案,站在街道办门口。门卫拦住他。小明:“同志,我有商业计划,能帮工厂接全国订单!”门卫:“胡闹!”推他出去。小明捡起散落的纸,抬头看到路对面一个穿中山装的中年男人(老陈)正在看他的方案。老陈:“你这上面写的‘电商’是啥?”小明眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”', '小明站在街道办门口台阶下,手拿一叠方案,表情急切;门卫站在台阶上伸手阻拦;路对面老陈穿中山装低头看纸,阳光从右上方照射。', 8, '年轻男子拿着一叠手写方案快步走向街道办大门,门卫伸出手臂阻挡。年轻男子急切地解释,门卫皱眉推他出去,纸张散落。年轻男子蹲下慌乱捡纸。路对面一位中年男子弯腰捡起一张纸,看着上面,抬头问话。镜头平视跟随年轻男子移动,然后切换到越肩视角展示门卫的阻拦动作。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203920-6rw8b-0.mp4', '7173c12461574b8abd063c46fc625ebe', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:47:09', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643823783936, 2077007721974484992, 2077007722058371072, 4, '仓库达成合作', '废弃仓库内,一张木桌两把椅子,小明和老陈对面而坐。桌上摆着两盒烟,老陈抽出一根点燃:“我信你一次,但怎么弄?”小明拿起笔在纸上画示意图:“用电话线连电脑,搞个库存信息库。我写程序。”老陈皱眉:“钱呢?”小明:“我先拉商户,您出设备。”老陈抖了抖烟灰,沉默几秒后点头。小明转头看向墙上的标语“工业学大庆”,低声:“这次一定要成。”', 'daily', '平视中景', '缓推', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"桌子一侧\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"桌子另一侧\"}]', '废弃仓库_日', '{\"panel_number\":4,\"scene_summary\":\"废弃仓库内,一张木桌两把椅子,小明和老陈对面而坐。桌上摆着两盒烟,老陈抽出一根点燃:“我信你一次,但怎么弄?”小明拿起笔在纸上画示意图:“用电话线连电脑,搞个库存信息库。我写程序。”老陈皱眉:“钱呢?”小明:“我先拉商户,您出设备。”老陈抖了抖烟灰,沉默几秒后点头。小明转头看向墙上的标语“工业学大庆”,低声:“这次一定要成。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"桌子一侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"桌子另一侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:废弃仓库_日,重新交代人物位置、朝向与环境\",\"end_state\":\"老陈点头,小明目光从墙上标语收回,表情坚定\",\"continuity_action\":\"小明视线从标语移回老陈,准备下一步计划\",\"spatial_anchor\":\"桌子位于仓库中央,小明坐画面左侧,老陈坐右侧,后面墙上有标语,水泥地面空旷\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":4,\"segment_number\":1,\"segment_goal\":\"小明与老陈在仓库达成合作意向\",\"segment_result\":\"老陈决定信任小明,提供设备支持\",\"bridge_in\":\"两人对面而坐,桌上两盒烟,老陈面露犹豫\",\"bridge_out\":\"老陈点头,小明看向墙上标语,低声坚定信念\",\"beat_type\":\"action\",\"narrative_cause\":\"老陈对方案产生好奇并主动与小明对话\",\"character_goal\":\"说服老陈提供设备支持\",\"story_action\":\"小明画图解释技术方案,提出资源分工\",\"story_result\":\"老陈同意合作,小明获得初步资源\",\"next_hook\":\"下一镜头展示工厂办公室内新进展与危机\"}', '老陈和小明对面坐着。桌上摆着两盒烟。老陈:“我信你一次,但怎么弄?”小明在纸上画:“用电话线连电脑,搞个库存信息库。我写程序。”老陈:“钱呢?”小明:“我先拉商户,您出设备。”老陈犹豫后点头。小明看着仓库墙上的“工业学大庆”,低声:“这次一定要成。”', '小明和老陈在废弃仓库的旧木桌对面而坐,桌上两盒烟和散落的纸张;老陈夹烟沉思,小明执笔画示意图;背景墙上红色大字「工业学大庆」,高窗透入光线。', 8, '年轻男子和中年男子对面坐在木桌两侧,桌上摆着两盒烟。中年男子抽出一根点燃,年轻男子拿起笔在纸上画图并说话。中年男子皱眉问话,年轻男子抬头回答,伸手指向纸面。中年男子抖烟灰后点头。年轻男子转头看向墙上的标语,低声自语。镜头从两人的中景缓缓推近至年轻男子的脸部,然后固定。', NULL, 'd4665ee140f34673914292855d389d38', 'generating', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:39:20', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643874115584, 2077007721974484992, 2077007722058371072, 5, '工商检查危机', '工厂办公室内,木制办公桌上转盘电话响起。老陈拿起听筒,脸色逐渐变白,手指捏紧话筒。挂断后他转身对旁边椅子上的小明说:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看桌上的账本,抬起头:“我们走的是信息服务,不是倒卖。”老陈叹气:“没人认可这个。”小明合上账本,直视老陈:“明天我来说。”', 'suspense', '平视近景', '缓推', '[{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"办公桌后\"},{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"办公桌旁椅子\"}]', '工厂办公室_日', '{\"panel_number\":5,\"scene_summary\":\"工厂办公室内,木制办公桌上转盘电话响起。老陈拿起听筒,脸色逐渐变白,手指捏紧话筒。挂断后他转身对旁边椅子上的小明说:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看桌上的账本,抬起头:“我们走的是信息服务,不是倒卖。”老陈叹气:“没人认可这个。”小明合上账本,直视老陈:“明天我来说。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"低调硬光,保留阴影层次\"},\"characters\":[{\"name\":\"老陈\",\"screen_position\":\"办公桌后\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"小明\",\"screen_position\":\"办公桌旁椅子\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中浅景深,保留环境压迫感\",\"color_tone\":\"冷暗色调\"}', '[{\"name\":\"老陈\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感\"},{\"name\":\"小明\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感\"}]', '{\"start_state\":\"新场景建立:工厂办公室_日,重新交代人物位置、朝向与环境\",\"end_state\":\"小明合上账本,直视老陈,表情自信;老陈眉头紧锁但不再反驳\",\"continuity_action\":\"小明视线坚定,老陈目光仍带忧虑但转向信任\",\"spatial_anchor\":\"老陈位于画面左侧办公桌后,小明在右侧椅子,窗外可见厂区烟囱,文件柜靠墙\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":5,\"segment_number\":1,\"segment_goal\":\"工厂办公室内老陈接到工商查处的电话,小明提出应对\",\"segment_result\":\"小明决定亲自去面对工商检查\",\"bridge_in\":\"办公室电话铃响打破平静,老陈接听表情变化\",\"bridge_out\":\"老陈看向小明,小明坚定地说“明天我来说”\",\"beat_type\":\"trigger\",\"narrative_cause\":\"老陈同意合作,小明获得初步资源\",\"character_goal\":\"让老陈相信自己能解决危机\",\"story_action\":\"小明解释模式合法性,并主动承担交涉责任\",\"story_result\":\"老陈部分安心,小明获得应对危机的授权\",\"next_hook\":\"下一镜头展示出租屋内团队内部争吵与统一思想\"}', '办公桌上电话响起。老陈接起,脸色变白。挂断后对小明:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看账本:“我们走的是信息服务,不是倒卖。”老陈:“没人认可这个。”小明:“明天我来说。”', '工厂办公室内,老陈坐在办公桌后接电话,脸色凝重;小明坐在侧面椅子上翻看账本,窗外可见工厂烟囱;桌上转盘电话和文件散落。', 7, '办公桌上黑色转盘电话响起。中年男子坐在桌后拿起听筒,脸色逐渐变白,手指捏紧话筒。他挂断后转身对旁边的年轻男子说话。年轻男子快速翻看账本,抬起头直视对方回话,然后合上账本。镜头从电话特写缓慢推近至中年男子面部,再缓拉到两人中景。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203917-gnb2m-0.mp4', '61edb37e979a426f9e8272786243c243', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:41:08', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643920252928, 2077007721974484992, 2077007722058371072, 6, '出租屋统一思想', '狭小出租屋,灯光昏暗。两个年轻团队成员拍桌站起:“不干了,风险太大!”老陈坐在床边沉默,低头看地面。小明从墙角拿起一块黑板放在桌上,画出一条时间轴,标注1985年,指向电脑图标。他转头对大家说:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头,皱眉问:“你哪来的这些?”小明停顿一下:“梦里学的。”', 'emotion', '平视近景转特写', '缓推加环绕', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"桌边站立\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"床边坐着\"}]', '出租屋_夜', '{\"panel_number\":6,\"scene_summary\":\"狭小出租屋,灯光昏暗。两个年轻团队成员拍桌站起:“不干了,风险太大!”老陈坐在床边沉默,低头看地面。小明从墙角拿起一块黑板放在桌上,画出一条时间轴,标注1985年,指向电脑图标。他转头对大家说:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头,皱眉问:“你哪来的这些?”小明停顿一下:“梦里学的。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"柔和侧光,突出面部情绪\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"桌边站立\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"床边坐着\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"浅景深,突出角色表情\",\"color_tone\":\"克制柔和色调\"}', '[{\"name\":\"小明\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:出租屋_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"老陈抬头看向小明,两个年轻人停止争吵望向黑板,小黑板上画有时间轴和“电脑”字样\",\"continuity_action\":\"小明手指还指着黑板上的“技术咨询”,等待异议\",\"spatial_anchor\":\"画面中央是一张桌子,黑板靠在桌上,小明站在桌子左侧画黑板,老陈坐在右侧床边,两个年轻人站在桌子远端\",\"present_characters\":[\"小明\",\"老陈\",\"两个年轻人(群众)\"],\"scene_number\":6,\"segment_number\":1,\"segment_goal\":\"出租屋内团队成员因风险而退缩,小明用时间轴规划说服大家\",\"segment_result\":\"老陈和小明达成共识,团队重拾信心\",\"bridge_in\":\"出租屋内两个年轻人拍桌起身,气氛紧张\",\"bridge_out\":\"老陈抬头看小明,眼神中带着疑惑与信任\",\"beat_type\":\"decision\",\"narrative_cause\":\"老陈部分安心,小明获得应对危机的授权\",\"character_goal\":\"稳定团队,统一认知\",\"story_action\":\"小明画出时间轴,解释信息差优势和应对策略\",\"story_result\":\"团队情绪稳定,老陈虽疑惑但接受解释\",\"next_hook\":\"下一镜头展示百货大楼门口工商检查的结果\"}', '团队成员(两个年轻人)拍桌:“不干了,风险太大!”老陈沉默。小明拿出一块黑板,画出时间轴:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头:“你哪来的这些?”小明:“梦里学的。”', '出租屋内,小明站在桌子左侧画黑板,黑板上粉笔写着时间轴和「电脑」字样;老陈坐在床边抬头看;两个年轻人站在桌子远端,表情从愤怒转为倾听;昏黄灯光下气氛紧张。', 10, '狭小出租屋内,两个年轻人拍桌站起情绪激动。中年男子坐在床边低头沉默。年轻男子从墙角拿起一块黑板放在桌上,用粉笔画出一条时间轴,标注1985和电脑图标,转头对大家说话并指向黑板。中年男子抬起头皱眉问话,年轻男子停顿后回答。镜头从全景缓慢推近至年轻男子面部特写,然后环绕拍摄黑板上的时间轴。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643978973184, 2077007721974484992, 2077007722058371072, 7, '工商检查通过', '百货大楼门口台阶上,工商人员和穿藏青色西装的中年经理站在一起。小明从公文包拿出一叠表格递过去:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看后递给旁边的百货经理。经理推了推眼镜细看,低声问小明:“这方法能帮我们清库存?”小明点头:“三天见效。”经理转向工商人员点了点头。工商人员收起表格:“先观察。”', 'daily', '平视中景', '轻微跟随', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"台阶上右侧\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"台阶上小明身后\"},{\"name\":\"百货经理\",\"appearance\":\"初始形象\",\"slot\":\"台阶上中央\"}]', '百货大楼门口_日', '{\"panel_number\":7,\"scene_summary\":\"百货大楼门口台阶上,工商人员和穿藏青色西装的中年经理站在一起。小明从公文包拿出一叠表格递过去:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看后递给旁边的百货经理。经理推了推眼镜细看,低声问小明:“这方法能帮我们清库存?”小明点头:“三天见效。”经理转向工商人员点了点头。工商人员收起表格:“先观察。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"台阶上右侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"台阶上小明身后\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"百货经理\",\"screen_position\":\"台阶上中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"百货经理\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:百货大楼门口_日,重新交代人物位置、朝向与环境\",\"end_state\":\"工商人员收起表格,经理向小明点头,小明露出微笑\",\"continuity_action\":\"小明视线跟随工商人员离开,准备下一步行动\",\"spatial_anchor\":\"百货大楼正门台阶为背景,工商人员在中央偏左,经理在左侧,小明在右侧,老陈在小明后方半步\",\"present_characters\":[\"小明\",\"老陈\",\"百货经理\",\"工商人员(群众)\"],\"scene_number\":7,\"segment_number\":1,\"segment_goal\":\"工商检查现场,小明用订单记录证明信息服务合法性,获得百货经理认可\",\"segment_result\":\"工商同意观察,项目得以继续\",\"bridge_in\":\"百货大楼门口,工商人员和小明、老陈站立,气氛凝重\",\"bridge_out\":\"工商人员收起表格,经理点头,危机解除\",\"beat_type\":\"result\",\"narrative_cause\":\"团队情绪稳定,老陈虽疑惑但接受解释\",\"character_goal\":\"用事实凭证打消工商疑虑\",\"story_action\":\"小明出示订单记录并解释运营模式\",\"story_result\":\"工商暂缓处理,项目获得官方默许\",\"next_hook\":\"下一镜头展示庆功礼堂的成果与小明的情感时刻\"}', '工商人员和小明、老陈站在门口。小明拿出一份表格:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看,递给旁边一个戴眼镜的中年人(百货公司经理)。经理看了后低声:“这方法能帮我们清库存?”小明:“三天见效。”经理点头。工商人员收起表格:“先观察。”', '小明站在百货大楼门口台阶上,从公文包中拿出表格递给工商人员;老陈站在他身后;旁边穿藏青色西装的百货经理低头查看表格;阳光照亮大楼立面。', 8, '百货大楼门口台阶上,工商人员和穿西装的中年男子站在一起。年轻男子从公文包拿出一叠表格递出并说话。工商人员翻看后递给旁边的经理。经理推了推眼镜细看,低声问年轻男子,年轻男子点头回答。经理转向工商人员点头。工商人员收起表格。镜头平视跟随年轻男子的动作,使用越肩视角展示表格内容。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008644041887744, 2077007721974484992, 2077007722058371072, 8, '厂庆宣言互联网愿景', '厂庆礼堂,红灯笼和横幅装饰,舞台上麦克风前,老陈对着台下说:“感谢小明的信息平台,咱们工厂活了。”台下工人鼓掌。小明站在侧台阴影里,手伸进口袋摸到发黄的现代身份证,他看着灯光下兴奋的面孔,默默将身份证撕碎。主持人喊:“请小明同志讲话!”小明走上舞台中央,拿起话筒,停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。', 'epic', '大远景转特写', '俯拍后缓降推近', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"舞台上中央\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"舞台侧边\"}]', '厂庆礼堂_夜', '{\"panel_number\":8,\"scene_summary\":\"厂庆礼堂,红灯笼和横幅装饰,舞台上麦克风前,老陈对着台下说:“感谢小明的信息平台,咱们工厂活了。”台下工人鼓掌。小明站在侧台阴影里,手伸进口袋摸到发黄的现代身份证,他看着灯光下兴奋的面孔,默默将身份证撕碎。主持人喊:“请小明同志讲话!”小明走上舞台中央,拿起话筒,停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"大范围自然光,突出空间规模\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"舞台上中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"舞台侧边\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"深景深,清晰展现环境规模\",\"color_tone\":\"宏大通透色调\"}', '[{\"name\":\"小明\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:厂庆礼堂_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"小明站在舞台中央举起话筒,台下工人站立鼓掌,老陈在侧台微笑\",\"continuity_action\":\"小明视线扫过全场,准备下台开始新阶段\",\"spatial_anchor\":\"舞台在画面中央靠上,台下长凳坐满工人,小明位于舞台中央,老陈在舞台左侧靠边\",\"present_characters\":[\"小明\",\"老陈\",\"工人(群众)\",\"主持人(群众)\"],\"scene_number\":8,\"segment_number\":1,\"segment_goal\":\"厂庆礼堂中老陈表扬小明,小明撕碎现代身份证并发表讲话\",\"segment_result\":\"小明公开宣告互联网未来愿景,全体工人热烈鼓掌\",\"bridge_in\":\"礼堂内工人坐满长凳,老陈在台上讲话\",\"bridge_out\":\"掌声雷动中,小明眼神坚定望向未来\",\"beat_type\":\"action\",\"narrative_cause\":\"工商暂缓处理,项目获得官方默许\",\"character_goal\":\"激励团队并宣告新方向\",\"story_action\":\"小明撕碎现代身份证,上台演讲\",\"story_result\":\"全体工人被鼓舞,小明确立新一代领导地位\",\"next_hook\":\"下一镜头展示清晨新挂牌和下一步计划\"}', '工人坐满长凳。老陈上台:“感谢小明的信息平台,咱们工厂活了。”台下鼓掌。小明站在侧台,看着灯光下兴奋的面孔。他手摸到口袋里的现代身份证——早已发黄。他默默撕碎。主持人:“请小明同志讲话!”小明上前,拿着话筒停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。', '厂庆礼堂内,舞台中央老陈握着话筒讲话,台下工人鼓掌;小明站在侧幕阴影中,手中捏着撕碎的身份证,灯光照亮舞台,红灯笼和横幅装饰,气氛热烈。', 10, '礼堂内工人坐满长凳,舞台中央中年男子在讲话,台下鼓掌。年轻男子站在侧台阴影中,手伸进口袋掏出一张发黄的身份证,默默撕碎。主持人喊话,年轻男子走上舞台中央,拿起话筒停顿三秒后说话,台下掌声雷动。镜头从礼堂大远景俯拍开始,然后缓缓下降并推近至年轻男子面部特写。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008644113190912, 2077007721974484992, 2077007722058371072, 9, '新征程启程', '清晨,阳光照在工厂大门新挂牌上,金边字体“华夏信息服务中心”。小明推着自行车走出来,老陈从后面追上:“下一步?”小明跨上车:“跑遍全国,建数据库。”老陈笑着摇头:“疯了。”小明回以笑容:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。', 'epic', '大远景', '缓拉升起', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"工厂大门前\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"门口台阶上\"}]', '工厂大门_晨', '{\"panel_number\":9,\"scene_summary\":\"清晨,阳光照在工厂大门新挂牌上,金边字体“华夏信息服务中心”。小明推着自行车走出来,老陈从后面追上:“下一步?”小明跨上车:“跑遍全国,建数据库。”老陈笑着摇头:“疯了。”小明回以笑容:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"大范围自然光,突出空间规模\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"工厂大门前\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"门口台阶上\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"深景深,清晰展现环境规模\",\"color_tone\":\"宏大通透色调\"}', '[{\"name\":\"小明\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:工厂大门_晨,重新交代人物位置、朝向与环境\",\"end_state\":\"小明骑车远去的背影在街道尽头,烟囱冒烟,晨光笼罩\",\"continuity_action\":\"小明继续蹬车,老陈目送,街道向前延伸\",\"spatial_anchor\":\"工厂大门在画面左侧,新挂牌醒目;小明骑车向右远去,街道两旁有工厂围墙和烟囱;老陈站在门口台阶上\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":9,\"segment_number\":1,\"segment_goal\":\"新挂牌的清晨,小明与老陈对话后骑车远去,象征新征程\",\"segment_result\":\"小明骑车远去,烟囱冒烟,新的一天开始\",\"bridge_in\":\"阳光照在新挂牌上,小明推车出现\",\"bridge_out\":\"镜头定格在远去的背影和工厂烟囱,留下开放节奏\",\"beat_type\":\"result\",\"narrative_cause\":\"全体工人被鼓舞,小明确立新一代领导地位\",\"character_goal\":\"确定下一阶段行动计划\",\"story_action\":\"小明跨上自行车说出全国数据库计划\",\"story_result\":\"小明开始实际行动,留下奋斗的背影\",\"next_hook\":\"故事结束,无下一镜头\"}', '阳光照在新挂牌上:“华夏信息服务中心”。小明推着自行车出来,老陈追上:“下一步?”小明:“跑遍全国,建数据库。”老陈笑:“疯了。”小明跨上车:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟。', '清晨阳光照射工厂大门新挂牌「华夏信息服务中心」,小明推自行车站在门口,老陈站在台阶上微笑;远处烟囱冒烟,天空泛鱼肚白,街道延伸向远方。', 10, '清晨阳光照在工厂大门新挂牌上,金色字体「华夏信息服务中心」。年轻男子推自行车从门内走出,中年男子从后面追上,两人对话。年轻男子跨上自行车,蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。镜头从新挂牌特写缓缓拉远并升起,展现工厂全貌和远去的背影。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); -- ---------------------------- -- Table structure for sj_distributed_lock diff --git a/docs/script/sql/snail_job_mysql.sql b/docs/script/sql/snail_job_mysql.sql new file mode 100644 index 00000000..322cccae --- /dev/null +++ b/docs/script/sql/snail_job_mysql.sql @@ -0,0 +1,545 @@ +SET NAMES utf8mb4; + +DROP DATABASE IF EXISTS snail_job; +CREATE DATABASE snail_job; +USE snail_job; + +CREATE TABLE `sj_namespace` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` varchar(64) NOT NULL COMMENT '名称', + `unique_id` varchar(64) NOT NULL COMMENT '唯一id', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_name` (`name`), + UNIQUE KEY `uk_unique_id` (`unique_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='命名空间'; + +INSERT INTO `sj_namespace` (`id`, `name`, `unique_id`, `create_dt`, `update_dt`, `deleted`) +VALUES (1, 'Default', '764d604ec6fc45f68cd92514c40e9e1a', now(), now(), 0); + + +CREATE TABLE `sj_group_config` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL DEFAULT '' COMMENT '组名称', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '组描述', + `token` varchar(64) NOT NULL DEFAULT 'SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT' COMMENT 'token', + `group_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '组状态 0、未启用 1、启用', + `version` int(11) NOT NULL COMMENT '版本号', + `group_partition` int(11) NOT NULL COMMENT '分区', + `id_generator_mode` tinyint(4) NOT NULL DEFAULT 1 COMMENT '唯一id生成模式 默认号段模式', + `init_scene` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否初始化场景 0:否 1:是', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='组配置' +; + +CREATE TABLE `sj_notify_config` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `notify_name` varchar(64) NOT NULL DEFAULT '' COMMENT '通知名称', + `system_task_type` tinyint(4) NOT NULL DEFAULT 3 COMMENT '任务类型 1. 重试任务 2. 重试回调 3、JOB任务 4、WORKFLOW任务', + `notify_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '通知状态 0、未启用 1、启用', + `recipient_ids` varchar(128) NOT NULL COMMENT '接收人id列表', + `notify_threshold` int(11) NOT NULL DEFAULT 0 COMMENT '通知阈值', + `notify_scene` tinyint(4) NOT NULL DEFAULT 0 COMMENT '通知场景', + `rate_limiter_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '限流状态 0、未启用 1、启用', + `rate_limiter_threshold` int(11) NOT NULL DEFAULT 0 COMMENT '每秒限流阈值', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name_scene_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='通知配置' +; + +CREATE TABLE `sj_notify_recipient` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `recipient_name` varchar(64) NOT NULL COMMENT '接收人名称', + `notify_type` tinyint(4) NOT NULL DEFAULT 0 COMMENT '通知类型 1、钉钉 2、邮件 3、企业微信 4 飞书 5 webhook', + `notify_attribute` varchar(512) NOT NULL COMMENT '配置属性', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id` (`namespace_id`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='告警通知接收人' +; + +CREATE TABLE `sj_retry_dead_letter` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `group_id` bigint(20) NOT NULL COMMENT '组Id', + `scene_name` varchar(64) NOT NULL COMMENT '场景名称', + `scene_id` bigint(20) NOT NULL COMMENT '场景ID', + `idempotent_id` varchar(64) NOT NULL COMMENT '幂等id', + `biz_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务编号', + `executor_name` varchar(512) NOT NULL DEFAULT '' COMMENT '执行器名称', + -- jackson 兼容历史数据 预计1.8.0默认改为fury + `serializer_name` varchar(32) NOT NULL DEFAULT 'jackson' COMMENT '执行方法参数序列化器名称', + `args_str` text NOT NULL COMMENT '执行方法参数', + `ext_attrs` text NOT NULL COMMENT '扩展字段', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name_scene_name` (`namespace_id`, `group_name`, `scene_name`), + KEY `idx_idempotent_id` (`idempotent_id`), + KEY `idx_biz_no` (`biz_no`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='死信队列表' +; + +CREATE TABLE `sj_retry` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `group_id` bigint(20) NOT NULL COMMENT '组Id', + `scene_name` varchar(64) NOT NULL COMMENT '场景名称', + `scene_id` bigint(20) NOT NULL COMMENT '场景ID', + `idempotent_id` varchar(64) NOT NULL COMMENT '幂等id', + `biz_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务编号', + `executor_name` varchar(512) NOT NULL DEFAULT '' COMMENT '执行器名称', + `args_str` text NOT NULL COMMENT '执行方法参数', + `ext_attrs` text NOT NULL COMMENT '扩展字段', + -- jackson 兼容历史数据 预计1.8.0默认改为fury + `serializer_name` varchar(32) NOT NULL DEFAULT 'jackson' COMMENT '执行方法参数序列化器名称', + `next_trigger_at` bigint(13) NOT NULL COMMENT '下次触发时间', + `retry_count` int(11) NOT NULL DEFAULT 0 COMMENT '重试次数', + `retry_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '重试状态 0、重试中 1、成功 2、最大重试次数', + `task_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '任务类型 1、重试数据 2、回调数据', + `bucket_index` int(11) NOT NULL DEFAULT 0 COMMENT 'bucket', + `parent_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '父节点id', + `deleted` bigint(20) NOT NULL DEFAULT 0 COMMENT '逻辑删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_biz_no` (`biz_no`), + KEY `idx_idempotent_id` (`idempotent_id`), + KEY `idx_retry_status_bucket_index` (`retry_status`, `bucket_index`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_create_dt` (`create_dt`), + UNIQUE KEY `uk_scene_tasktype_idempotentid_deleted` (`scene_id`, `task_type`, `idempotent_id`, `deleted`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='重试信息表' +; + +CREATE TABLE `sj_retry_task` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `scene_name` varchar(64) NOT NULL COMMENT '场景名称', + `retry_id` bigint(20) NOT NULL COMMENT '重试信息Id', + `ext_attrs` text NOT NULL COMMENT '扩展字段', + `task_status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '重试状态', + `task_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '任务类型 1、重试数据 2、回调数据', + `operation_reason` tinyint(4) NOT NULL DEFAULT 0 COMMENT '操作原因', + `client_info` varchar(128) DEFAULT NULL COMMENT '客户端地址 clientId#ip:port', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_group_name_scene_name` (`namespace_id`, `group_name`, `scene_name`), + KEY `task_status` (`task_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_retry_id` (`retry_id`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='重试任务表' +; + +CREATE TABLE `sj_retry_task_log_message` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `retry_id` bigint(20) NOT NULL COMMENT '重试信息Id', + `retry_task_id` bigint(20) NOT NULL COMMENT '重试任务Id', + `message` longtext NOT NULL COMMENT '异常信息', + `log_num` int(11) NOT NULL DEFAULT 1 COMMENT '日志数量', + `real_time` bigint(13) NOT NULL DEFAULT 0 COMMENT '上报时间', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name_retry_task_id` (`namespace_id`, `group_name`, `retry_task_id`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='任务调度日志信息记录表' +; + +CREATE TABLE `sj_retry_scene_config` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `scene_name` varchar(64) NOT NULL COMMENT '场景名称', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `scene_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '组状态 0、未启用 1、启用', + `max_retry_count` int(11) NOT NULL DEFAULT 5 COMMENT '最大重试次数', + `back_off` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1、默认等级 2、固定间隔时间 3、CRON 表达式', + `trigger_interval` varchar(16) NOT NULL DEFAULT '' COMMENT '间隔时长', + `notify_ids` varchar(128) NOT NULL DEFAULT '' COMMENT '通知告警场景配置id列表', + `deadline_request` bigint(20) unsigned NOT NULL DEFAULT 60000 COMMENT 'Deadline Request 调用链超时 单位毫秒', + `executor_timeout` int(11) unsigned NOT NULL DEFAULT 5 COMMENT '任务执行超时时间,单位秒', + `route_key` tinyint(4) NOT NULL DEFAULT 4 COMMENT '路由策略', + `block_strategy` tinyint(4) NOT NULL DEFAULT 1 COMMENT '阻塞策略 1、丢弃 2、覆盖 3、并行', + `cb_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '回调状态 0、不开启 1、开启', + `cb_trigger_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1、默认等级 2、固定间隔时间 3、CRON 表达式', + `cb_max_count` int(11) NOT NULL DEFAULT 16 COMMENT '回调的最大执行次数', + `cb_trigger_interval` varchar(16) NOT NULL DEFAULT '' COMMENT '回调的最大执行次数', + `owner_id` bigint(20) NULL DEFAULT NULL COMMENT '负责人id', + `labels` varchar(512) NULL DEFAULT '' COMMENT '标签', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_namespace_id_group_name_scene_name` (`namespace_id`, `group_name`, `scene_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='场景配置' +; + +CREATE TABLE `sj_server_node` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `host_id` varchar(64) NOT NULL COMMENT '主机id', + `host_ip` varchar(64) NOT NULL COMMENT '机器ip', + `host_port` int(16) NOT NULL COMMENT '机器端口', + `expire_at` datetime NOT NULL COMMENT '过期时间', + `node_type` tinyint(4) NOT NULL COMMENT '节点类型 1、客户端 2、是服务端', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `labels` varchar(512) NULL DEFAULT '' COMMENT '标签', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`), + KEY `idx_expire_at_node_type` (`expire_at`, `node_type`), + UNIQUE KEY `uk_host_id_host_ip` (`host_id`, `host_ip`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='服务器节点' +; + +CREATE TABLE `sj_distributed_lock` +( + `name` varchar(64) NOT NULL COMMENT '锁名称', + `lock_until` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '锁定时长', + `locked_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '锁定时间', + `locked_by` varchar(255) NOT NULL COMMENT '锁定者', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='锁定表' +; + +CREATE TABLE `sj_system_user` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `username` varchar(64) NOT NULL COMMENT '账号', + `password` varchar(128) NOT NULL COMMENT '密码', + `role` tinyint(4) NOT NULL DEFAULT 0 COMMENT '角色:1-普通用户、2-管理员', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`) USING BTREE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='系统用户表'; + +-- pwd: admin +INSERT INTO `sj_system_user` (username, password, role) +VALUES ('admin', '465c194afb65670f38322df087f0a9bb225cc257e43eb4ac5a0c98ef5b3173ac', 2); + +CREATE TABLE `sj_system_user_permission` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `system_user_id` bigint(20) NOT NULL COMMENT '系统用户id', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_namespace_id_group_name_system_user_id` (`namespace_id`, `group_name`, `system_user_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='系统用户权限表'; + +-- 分布式调度DDL +CREATE TABLE `sj_job` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `job_name` varchar(64) NOT NULL COMMENT '名称', + `args_str` text DEFAULT NULL COMMENT '执行方法参数', + `args_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '参数类型 ', + `next_trigger_at` bigint(13) NOT NULL COMMENT '下次触发时间', + `job_status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '任务状态 0、关闭、1、开启', + `task_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '任务类型 1、集群 2、广播 3、切片', + `route_key` tinyint(4) NOT NULL DEFAULT 4 COMMENT '路由策略', + `executor_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '执行器类型', + `executor_info` varchar(255) DEFAULT NULL COMMENT '执行器名称', + `trigger_type` tinyint(4) NOT NULL COMMENT '触发类型 1.CRON 表达式 2. 固定时间', + `trigger_interval` varchar(255) NOT NULL COMMENT '间隔时长', + `block_strategy` tinyint(4) NOT NULL DEFAULT 1 COMMENT '阻塞策略 1、丢弃 2、覆盖 3、并行 4、恢复', + `executor_timeout` int(11) NOT NULL DEFAULT 0 COMMENT '任务执行超时时间,单位秒', + `max_retry_times` int(11) NOT NULL DEFAULT 0 COMMENT '最大重试次数', + `parallel_num` int(11) NOT NULL DEFAULT 1 COMMENT '并行数', + `retry_interval` int(11) NOT NULL DEFAULT 0 COMMENT '重试间隔(s)', + `bucket_index` int(11) NOT NULL DEFAULT 0 COMMENT 'bucket', + `resident` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否是常驻任务', + `notify_ids` varchar(128) NOT NULL DEFAULT '' COMMENT '通知告警场景配置id列表', + `owner_id` bigint(20) NULL DEFAULT NULL COMMENT '负责人id', + `labels` varchar(512) NULL DEFAULT '' COMMENT '标签', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`), + KEY `idx_job_status_bucket_index` (`job_status`, `bucket_index`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='任务信息'; + +CREATE TABLE `sj_job_log_message` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `job_id` bigint(20) NOT NULL COMMENT '任务信息id', + `task_batch_id` bigint(20) NOT NULL COMMENT '任务批次id', + `task_id` bigint(20) NOT NULL COMMENT '调度任务id', + `message` longtext NOT NULL COMMENT '调度信息', + `log_num` int(11) NOT NULL DEFAULT 1 COMMENT '日志数量', + `real_time` bigint(13) NOT NULL DEFAULT 0 COMMENT '上报时间', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_task_batch_id_task_id` (`task_batch_id`, `task_id`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='调度日志'; + +CREATE TABLE `sj_job_task` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `job_id` bigint(20) NOT NULL COMMENT '任务信息id', + `task_batch_id` bigint(20) NOT NULL COMMENT '调度任务id', + `parent_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '父执行器id', + `task_status` tinyint NOT NULL DEFAULT 0 COMMENT '执行的状态 0、失败 1、成功', + `retry_count` int(11) NOT NULL DEFAULT 0 COMMENT '重试次数', + `mr_stage` tinyint DEFAULT NULL COMMENT '动态分片所处阶段 1:map 2:reduce 3:mergeReduce', + `leaf` tinyint NOT NULL DEFAULT '1' COMMENT '叶子节点', + `task_name` varchar(255) NOT NULL DEFAULT '' COMMENT '任务名称', + `client_info` varchar(128) DEFAULT NULL COMMENT '客户端地址 clientId#ip:port', + `wf_context` text DEFAULT NULL COMMENT '工作流全局上下文', + `result_message` text NOT NULL COMMENT '执行结果', + `args_str` text DEFAULT NULL COMMENT '执行方法参数', + `args_type` tinyint NOT NULL DEFAULT 1 COMMENT '参数类型 ', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_task_batch_id_task_status` (`task_batch_id`, `task_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='任务实例'; + +CREATE TABLE `sj_job_task_batch` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `job_id` bigint(20) NOT NULL COMMENT '任务id', + `workflow_node_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '工作流节点id', + `parent_workflow_node_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '工作流任务父批次id', + `workflow_task_batch_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '工作流任务批次id', + `task_batch_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '任务批次状态 0、失败 1、成功', + `operation_reason` tinyint(4) NOT NULL DEFAULT 0 COMMENT '操作原因', + `execution_at` bigint(13) NOT NULL DEFAULT 0 COMMENT '任务执行时间', + `system_task_type` tinyint(4) NOT NULL DEFAULT 3 COMMENT '任务类型 3、JOB任务 4、WORKFLOW任务', + `parent_id` varchar(64) NOT NULL DEFAULT '' COMMENT '父节点', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_job_id_task_batch_status` (`job_id`, `task_batch_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`), + KEY `idx_workflow_task_batch_id_workflow_node_id` (`workflow_task_batch_id`, `workflow_node_id`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='任务批次'; + +CREATE TABLE `sj_job_summary` +( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` VARCHAR(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '组名称', + `business_id` bigint NOT NULL COMMENT '业务id (job_id或workflow_id)', + `system_task_type` tinyint(4) NOT NULL DEFAULT 3 COMMENT '任务类型 3、JOB任务 4、WORKFLOW任务', + `trigger_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '统计时间', + `success_num` int NOT NULL DEFAULT 0 COMMENT '执行成功-日志数量', + `fail_num` int NOT NULL DEFAULT 0 COMMENT '执行失败-日志数量', + `fail_reason` varchar(512) NOT NULL DEFAULT '' COMMENT '失败原因', + `stop_num` int NOT NULL DEFAULT 0 COMMENT '执行失败-日志数量', + `stop_reason` varchar(512) NOT NULL DEFAULT '' COMMENT '失败原因', + `cancel_num` int NOT NULL DEFAULT 0 COMMENT '执行失败-日志数量', + `cancel_reason` varchar(512) NOT NULL DEFAULT '' COMMENT '失败原因', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name_business_id` (`namespace_id`, `group_name`, business_id), + UNIQUE KEY `uk_trigger_at_system_task_type_business_id` (`trigger_at`, `system_task_type`, `business_id`) USING BTREE +) ENGINE = InnoDB + AUTO_INCREMENT = 1 + DEFAULT CHARSET = utf8mb4 COMMENT ='DashBoard_Job'; + +CREATE TABLE `sj_retry_summary` +( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` VARCHAR(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '组名称', + `scene_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '场景名称', + `trigger_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '统计时间', + `running_num` int NOT NULL DEFAULT 0 COMMENT '重试中-日志数量', + `finish_num` int NOT NULL DEFAULT 0 COMMENT '重试完成-日志数量', + `max_count_num` int NOT NULL DEFAULT 0 COMMENT '重试到达最大次数-日志数量', + `suspend_num` int NOT NULL DEFAULT 0 COMMENT '暂停重试-日志数量', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_trigger_at` (`trigger_at`), + UNIQUE KEY `uk_scene_name_trigger_at` (`namespace_id`, `group_name`, `scene_name`, `trigger_at`) USING BTREE +) ENGINE = InnoDB + AUTO_INCREMENT = 1 + DEFAULT CHARSET = utf8mb4 COMMENT ='DashBoard_Retry'; + +CREATE TABLE `sj_workflow` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `workflow_name` varchar(64) NOT NULL COMMENT '工作流名称', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `workflow_status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '工作流状态 0、关闭、1、开启', + `trigger_type` tinyint(4) NOT NULL COMMENT '触发类型 1.CRON 表达式 2. 固定时间', + `trigger_interval` varchar(255) NOT NULL COMMENT '间隔时长', + `next_trigger_at` bigint NOT NULL COMMENT '下次触发时间', + `block_strategy` tinyint(4) NOT NULL DEFAULT 1 COMMENT '阻塞策略 1、丢弃 2、覆盖 3、并行', + `executor_timeout` int(11) NOT NULL DEFAULT 0 COMMENT '任务执行超时时间,单位秒', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `flow_info` text DEFAULT NULL COMMENT '流程信息', + `wf_context` text DEFAULT NULL COMMENT '上下文', + `notify_ids` varchar(128) NOT NULL DEFAULT '' COMMENT '通知告警场景配置id列表', + `bucket_index` int(11) NOT NULL DEFAULT 0 COMMENT 'bucket', + `version` int(11) NOT NULL COMMENT '版本号', + `owner_id` bigint(20) NULL DEFAULT NULL COMMENT '负责人id', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='工作流'; + +CREATE TABLE `sj_workflow_node` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `node_name` varchar(64) NOT NULL COMMENT '节点名称', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `job_id` bigint(20) NOT NULL COMMENT '任务信息id', + `workflow_id` bigint(20) NOT NULL COMMENT '工作流ID', + `node_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1、任务节点 2、条件节点', + `expression_type` tinyint(4) NOT NULL DEFAULT 0 COMMENT '1、SpEl、2、Aviator 3、QL', + `fail_strategy` tinyint(4) NOT NULL DEFAULT 1 COMMENT '失败策略 1、跳过 2、阻塞', + `workflow_node_status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '工作流节点状态 0、关闭、1、开启', + `priority_level` int(11) NOT NULL DEFAULT 1 COMMENT '优先级', + `node_info` text DEFAULT NULL COMMENT '节点信息 ', + `version` int(11) NOT NULL COMMENT '版本号', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='工作流节点'; + +CREATE TABLE `sj_workflow_task_batch` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `workflow_id` bigint(20) NOT NULL COMMENT '工作流任务id', + `task_batch_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '任务批次状态 0、失败 1、成功', + `operation_reason` tinyint(4) NOT NULL DEFAULT 0 COMMENT '操作原因', + `flow_info` text DEFAULT NULL COMMENT '流程信息', + `wf_context` text DEFAULT NULL COMMENT '全局上下文', + `execution_at` bigint(13) NOT NULL DEFAULT 0 COMMENT '任务执行时间', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `version` int(11) NOT NULL DEFAULT 1 COMMENT '版本号', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_job_id_task_batch_status` (`workflow_id`, `task_batch_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='工作流批次'; + +CREATE TABLE `sj_job_executor` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `executor_info` varchar(256) NOT NULL COMMENT '任务执行器名称', + `executor_type` varchar(3) NOT NULL COMMENT '1:java 2:python 3:go', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='任务执行器信息'; diff --git a/pom.xml b/pom.xml index 9acb4232..4bef361e 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,7 @@ 1.27.1 1.1.0 + 3.30.2-GA 1.21.2 4.4.0 2.2.8 diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 2a775fbc..fbe7e4cc 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -8,8 +8,8 @@ spring.boot.admin.client: metadata: username: ${spring.boot.admin.client.username} userpassword: ${spring.boot.admin.client.password} - username: @monitor.username@ - password: @monitor.password@ + username: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} --- # mcp配置信息 mcp: @@ -60,7 +60,7 @@ spring: # rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题) url: jdbc:mysql://127.0.0.1:3306/ruoyi-ai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true username: root - password: root + password: 123456 # agent: # url: jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true # # url: jdbc:mysql://localhost:3306/agent_db diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index 234e81ab..ae8372d9 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -9,8 +9,8 @@ spring.boot.admin.client: metadata: username: ${spring.boot.admin.client.username} userpassword: ${spring.boot.admin.client.password} - username: @monitor.username@ - password: @monitor.password@ + username: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} --- # mcp配置信息 mcp: diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index d9aca934..6a053bbd 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -39,7 +39,7 @@ captcha: # 日志配置 logging: level: - org.ruoyi: @logging.level@ + org.ruoyi: ${LOGGING_LEVEL_ORG_RUOYI:info} org.springframework: warn org.mybatis.spring.mapper: error org.apache.fury: warn @@ -79,7 +79,7 @@ spring: # 国际化资源文件路径 basename: i18n/messages profiles: - active: @profiles.active@ + active: ${SPRING_PROFILES_ACTIVE:dev} # 文件上传 servlet: multipart: diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java index e8640887..a9c7c1e5 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java @@ -1,10 +1,12 @@ package org.ruoyi.common.chat.domain.bo.chat; import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; import org.ruoyi.common.chat.entity.chat.ChatModel; +import org.ruoyi.common.core.validate.AddGroup; import org.ruoyi.common.core.validate.EditGroup; import org.ruoyi.common.mybatis.core.domain.BaseEntity; @@ -28,16 +30,19 @@ public class ChatModelBo extends BaseEntity { /** * 模型分类 */ + @NotBlank(message = "模型分类不能为空", groups = { AddGroup.class, EditGroup.class }) private String category; /** * 模型名称 */ + @NotBlank(message = "模型名称不能为空", groups = { AddGroup.class, EditGroup.class }) private String modelName; /** * 模型供应商 */ + @NotBlank(message = "模型供应商不能为空", groups = { AddGroup.class, EditGroup.class }) private String providerCode; /** diff --git a/ruoyi-extend/ruoyi-monitor-admin/src/main/resources/application.yml b/ruoyi-extend/ruoyi-monitor-admin/src/main/resources/application.yml index 72447e95..b2723802 100644 --- a/ruoyi-extend/ruoyi-monitor-admin/src/main/resources/application.yml +++ b/ruoyi-extend/ruoyi-monitor-admin/src/main/resources/application.yml @@ -4,7 +4,7 @@ spring: application: name: ruoyi-monitor-admin profiles: - active: @profiles.active@ + active: ${SPRING_PROFILES_ACTIVE:dev} logging: config: classpath:logback-plus.xml @@ -13,8 +13,8 @@ logging: spring: security: user: - name: @monitor.username@ - password: @monitor.password@ + name: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} boot: admin: ui: @@ -47,5 +47,5 @@ spring.boot.admin.client: metadata: username: ${spring.boot.admin.client.username} userpassword: ${spring.boot.admin.client.password} - username: @monitor.username@ - password: @monitor.password@ + username: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} diff --git a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml index 18a851c7..fc58b56a 100644 --- a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml +++ b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml @@ -2,9 +2,9 @@ spring: datasource: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + url: jdbc:mysql://localhost:3306/snail_job?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root - password: root + password: 123456 hikari: connection-timeout: 30000 validation-timeout: 5000 @@ -47,5 +47,5 @@ spring.boot.admin.client: metadata: username: ${spring.boot.admin.client.username} userpassword: ${spring.boot.admin.client.password} - username: @monitor.username@ - password: @monitor.password@ + username: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} diff --git a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-prod.yml b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-prod.yml index 18a851c7..4510d899 100644 --- a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-prod.yml +++ b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-prod.yml @@ -47,5 +47,5 @@ spring.boot.admin.client: metadata: username: ${spring.boot.admin.client.username} userpassword: ${spring.boot.admin.client.password} - username: @monitor.username@ - password: @monitor.password@ + username: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} diff --git a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application.yml b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application.yml index ba6df45f..bdefb472 100644 --- a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application.yml +++ b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application.yml @@ -7,7 +7,7 @@ spring: application: name: ruoyi-snailjob-server profiles: - active: @profiles.active@ + active: ${SPRING_PROFILES_ACTIVE:dev} web: resources: static-locations: classpath:admin/ diff --git a/ruoyi-modules/ruoyi-aiflow/pom.xml b/ruoyi-modules/ruoyi-aiflow/pom.xml index d7a147c8..04bb9b89 100644 --- a/ruoyi-modules/ruoyi-aiflow/pom.xml +++ b/ruoyi-modules/ruoyi-aiflow/pom.xml @@ -72,6 +72,21 @@ com.talanlabs avatar-generator-cat ${avatar-generator.version} + + + org.javassist + javassist + + +
+ + + + org.javassist + javassist + ${javassist.version} diff --git a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/CompileNode.java b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/CompileNode.java index 20960113..1e6ad419 100644 --- a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/CompileNode.java +++ b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/CompileNode.java @@ -16,6 +16,7 @@ import java.util.List; @AllArgsConstructor public class CompileNode { protected String id; + @Builder.Default protected Boolean conditional = false; /** @@ -24,5 +25,6 @@ public class CompileNode { * 2. 当前节点为条件分支节点,下游节点为多个节点,实际执行时只会执行一条 * 两种节点根据是否GraphCompileNode来区分 */ + @Builder.Default protected List nextNodes = new ArrayList<>(); } diff --git a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java index a62d4218..290245a7 100644 --- a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java +++ b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java @@ -15,6 +15,7 @@ import java.util.List; @NoArgsConstructor public class NodeProcessResult { + @Builder.Default private List content = new ArrayList<>(); /** @@ -25,6 +26,7 @@ public class NodeProcessResult { /** * 是否发生错误 */ + @Builder.Default private boolean error = false; /** diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java index d00de58a..8620967d 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java @@ -26,7 +26,9 @@ class FfmpegCommandBuilderTest { List.of(source), TransitionType.NONE, 0, - AspectRatio.PORTRAIT + AspectRatio.PORTRAIT, + null, + false ); FfmpegFilterGraph graph = new FfmpegFilterGraph( "[0:v]null[v0]", diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java index 0bebc075..903843bc 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java @@ -183,6 +183,6 @@ class FfmpegFilterGraphBuilderTest { List sources = java.util.stream.IntStream.range(0, sourceCount) .mapToObj(index -> new CompositionSource(Path.of("clip-" + index + ".mp4"), null)) .toList(); - return new CompositionSpec(sources, transition, duration, ratio); + return new CompositionSpec(sources, transition, duration, ratio, null, false); } } From 97ce15d116a38693f47094005cb5eca25745a0fe Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Sun, 19 Jul 2026 13:30:09 +0800 Subject: [PATCH 12/29] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dpr=E7=9A=84?= =?UTF-8?q?=E4=B8=80=E4=BA=9B=E9=97=AE=E9=A2=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docker/ruoyi-ai/Dockerfile.mysql | 12 +++++------- docs/docker/ruoyi-ai/docker-compose.yaml | 4 ++-- docs/script/sql/ruoyi-ai.sql | 2 +- pom.xml | 12 ------------ ruoyi-admin/src/main/resources/application-dev.yml | 2 +- .../src/main/resources/application-dev.yml | 2 +- 6 files changed, 10 insertions(+), 24 deletions(-) diff --git a/docs/docker/ruoyi-ai/Dockerfile.mysql b/docs/docker/ruoyi-ai/Dockerfile.mysql index 31d88961..a7b11679 100644 --- a/docs/docker/ruoyi-ai/Dockerfile.mysql +++ b/docs/docker/ruoyi-ai/Dockerfile.mysql @@ -1,16 +1,14 @@ # 基于官方MySQL 8.0镜像构建自定义镜像 # 构建命令: docker build -t registry.cn-hangzhou.aliyuncs.com/ruoyi-ai/mysql:v3 -f Dockerfile.mysql . -FROM mysql:8.0.33 +FROM mysql:8.0 # 设置时区 ENV TZ=Asia/Shanghai -# 复制初始化脚本和SQL文件到镜像中 -COPY docs/script/docker/mysql/init/init-db.sh /docker-entrypoint-initdb.d/init-db.sh -COPY docs/script/sql/ruoyi-ai.sql /docker-entrypoint-initdb.d/ruoyi-ai.sql - -# 设置脚本可执行权限 -RUN chmod +x /docker-entrypoint-initdb.d/init-db.sh +# MySQL 官方入口会按文件名顺序执行初始化目录中的 SQL。 +# ruoyi-ai.sql 会导入 MYSQL_DATABASE,snail_job_mysql.sql 会自行创建并切换数据库。 +COPY docs/script/sql/ruoyi-ai.sql /docker-entrypoint-initdb.d/01-ruoyi-ai.sql +COPY docs/script/sql/snail_job_mysql.sql /docker-entrypoint-initdb.d/02-snail-job.sql # MySQL启动参数 CMD ["--default-authentication-plugin=mysql_native_password", \ diff --git a/docs/docker/ruoyi-ai/docker-compose.yaml b/docs/docker/ruoyi-ai/docker-compose.yaml index c5775307..96c4e949 100644 --- a/docs/docker/ruoyi-ai/docker-compose.yaml +++ b/docs/docker/ruoyi-ai/docker-compose.yaml @@ -21,8 +21,8 @@ services: MYSQL_DATABASE: ruoyi-ai-agent TZ: Asia/Shanghai volumes: - - ./docs/script/docker/mysql/init/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro - - ./docs/script/sql/ruoyi-ai.sql:/docker-entrypoint-initdb.d/ruoyi-ai.sql:ro + - ../../script/sql/ruoyi-ai.sql:/docker-entrypoint-initdb.d/01-ruoyi-ai.sql:ro + - ../../script/sql/snail_job_mysql.sql:/docker-entrypoint-initdb.d/02-snail-job.sql:ro - mysql-data:/var/lib/mysql command: --default-authentication-plugin=mysql_native_password diff --git a/docs/script/sql/ruoyi-ai.sql b/docs/script/sql/ruoyi-ai.sql index ce0b0899..99c2f027 100644 --- a/docs/script/sql/ruoyi-ai.sql +++ b/docs/script/sql/ruoyi-ai.sql @@ -1,4 +1,4 @@ -/* +/* Navicat Premium Dump SQL Source Server : localhost_3306 diff --git a/pom.xml b/pom.xml index 4bef361e..625cb379 100644 --- a/pom.xml +++ b/pom.xml @@ -504,20 +504,8 @@ src/main/resources - false - - src/main/resources - - - application* - bootstrap* - banner* - - - true - diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index fbe7e4cc..83f56c43 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -60,7 +60,7 @@ spring: # rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题) url: jdbc:mysql://127.0.0.1:3306/ruoyi-ai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true username: root - password: 123456 + password: root # agent: # url: jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true # # url: jdbc:mysql://localhost:3306/agent_db diff --git a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml index fc58b56a..67b4d4e2 100644 --- a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml +++ b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml @@ -4,7 +4,7 @@ spring: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/snail_job?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root - password: 123456 + password: root hikari: connection-timeout: 30000 validation-timeout: 5000 From afc9d3d17dfa380da5556a277aee79d7562ac892 Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Sun, 19 Jul 2026 13:42:43 +0800 Subject: [PATCH 13/29] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=90=AF=E5=8A=A8=E3=80=81=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E4=B8=8E=E6=9E=84=E5=BB=BA=E9=97=AE?= =?UTF-8?q?=E9=A2=98=20(#314)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 修复本地启动、数据库初始化与构建问题 * fix: 修复pr的一些问题提交 --- docs/docker/ruoyi-ai/Dockerfile.mysql | 14 +- docs/docker/ruoyi-ai/docker-compose-all.yaml | 9 +- docs/docker/ruoyi-ai/docker-compose.yaml | 8 +- .../{ruoyi-ai-v3_mysql8.sql => ruoyi-ai.sql} | 32 +- docs/script/sql/snail_job_mysql.sql | 545 ++++++++++++++++++ pom.xml | 13 +- .../src/main/resources/application-dev.yml | 4 +- .../src/main/resources/application-prod.yml | 4 +- .../src/main/resources/application.yml | 4 +- .../chat/domain/bo/chat/ChatModelBo.java | 5 + .../src/main/resources/application.yml | 10 +- .../src/main/resources/application-dev.yml | 6 +- .../src/main/resources/application-prod.yml | 4 +- .../src/main/resources/application.yml | 2 +- ruoyi-modules/ruoyi-aiflow/pom.xml | 15 + .../ruoyi/workflow/workflow/CompileNode.java | 2 + .../workflow/workflow/NodeProcessResult.java | 2 + .../composition/FfmpegCommandBuilderTest.java | 4 +- .../FfmpegFilterGraphBuilderTest.java | 2 +- 19 files changed, 623 insertions(+), 62 deletions(-) rename docs/script/sql/{ruoyi-ai-v3_mysql8.sql => ruoyi-ai.sql} (90%) create mode 100644 docs/script/sql/snail_job_mysql.sql diff --git a/docs/docker/ruoyi-ai/Dockerfile.mysql b/docs/docker/ruoyi-ai/Dockerfile.mysql index d5fcecb7..a7b11679 100644 --- a/docs/docker/ruoyi-ai/Dockerfile.mysql +++ b/docs/docker/ruoyi-ai/Dockerfile.mysql @@ -1,16 +1,14 @@ # 基于官方MySQL 8.0镜像构建自定义镜像 # 构建命令: docker build -t registry.cn-hangzhou.aliyuncs.com/ruoyi-ai/mysql:v3 -f Dockerfile.mysql . -FROM mysql:8.0.33 +FROM mysql:8.0 # 设置时区 ENV TZ=Asia/Shanghai -# 复制初始化脚本和SQL文件到镜像中 -COPY docs/script/docker/mysql/init/init-db.sh /docker-entrypoint-initdb.d/init-db.sh -COPY docs/script/sql/ruoyi-ai-v3_mysql8.sql /docker-entrypoint-initdb.d/ruoyi-ai-v3_mysql8.sql - -# 设置脚本可执行权限 -RUN chmod +x /docker-entrypoint-initdb.d/init-db.sh +# MySQL 官方入口会按文件名顺序执行初始化目录中的 SQL。 +# ruoyi-ai.sql 会导入 MYSQL_DATABASE,snail_job_mysql.sql 会自行创建并切换数据库。 +COPY docs/script/sql/ruoyi-ai.sql /docker-entrypoint-initdb.d/01-ruoyi-ai.sql +COPY docs/script/sql/snail_job_mysql.sql /docker-entrypoint-initdb.d/02-snail-job.sql # MySQL启动参数 CMD ["--default-authentication-plugin=mysql_native_password", \ @@ -18,4 +16,4 @@ CMD ["--default-authentication-plugin=mysql_native_password", \ "--collation-server=utf8mb4_general_ci", \ "--explicit_defaults_for_timestamp=true", \ "--lower_case_table_names=1", \ - "--skip-ssl"] \ No newline at end of file + "--skip-ssl"] diff --git a/docs/docker/ruoyi-ai/docker-compose-all.yaml b/docs/docker/ruoyi-ai/docker-compose-all.yaml index b9f330c1..21c781a5 100644 --- a/docs/docker/ruoyi-ai/docker-compose-all.yaml +++ b/docs/docker/ruoyi-ai/docker-compose-all.yaml @@ -12,8 +12,6 @@ # # 镜像仓库地址: crpi-31mraxd99y2gqdgr.cn-beijing.personal.cr.aliyuncs.com/ruoyi_ai -version: '3.8' - services: # ==================== MySQL 数据库 ==================== mysql: @@ -30,7 +28,10 @@ services: volumes: - mysql-data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"] + # Force TCP: during first-time initialization MySQL exposes a temporary + # socket-only server. A socket ping would mark the service healthy before + # the real network listener (used by the backend) is ready. + test: ["CMD", "mysqladmin", "ping", "--protocol=tcp", "-h", "127.0.0.1", "-u", "root", "-proot"] interval: 15s timeout: 10s retries: 10 @@ -177,4 +178,4 @@ volumes: weaviate-data: minio-data: logs-data: - upload-data: \ No newline at end of file + upload-data: diff --git a/docs/docker/ruoyi-ai/docker-compose.yaml b/docs/docker/ruoyi-ai/docker-compose.yaml index eac534d8..96c4e949 100644 --- a/docs/docker/ruoyi-ai/docker-compose.yaml +++ b/docs/docker/ruoyi-ai/docker-compose.yaml @@ -21,8 +21,8 @@ services: MYSQL_DATABASE: ruoyi-ai-agent TZ: Asia/Shanghai volumes: - - ./docs/script/docker/mysql/init/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro - - ./docs/script/sql/ruoyi-ai-v3_mysql8.sql:/docker-entrypoint-initdb.d/ruoyi-ai-v3_mysql8.sql:ro + - ../../script/sql/ruoyi-ai.sql:/docker-entrypoint-initdb.d/01-ruoyi-ai.sql:ro + - ../../script/sql/snail_job_mysql.sql:/docker-entrypoint-initdb.d/02-snail-job.sql:ro - mysql-data:/var/lib/mysql command: --default-authentication-plugin=mysql_native_password @@ -32,7 +32,9 @@ services: --lower_case_table_names=1 --skip-ssl healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"] + # Do not let the socket-only initialization server satisfy the check. + # The backend connects over TCP and must wait for that listener. + test: ["CMD", "mysqladmin", "ping", "--protocol=tcp", "-h", "127.0.0.1", "-u", "root", "-proot"] interval: 15s timeout: 10s retries: 10 diff --git a/docs/script/sql/ruoyi-ai-v3_mysql8.sql b/docs/script/sql/ruoyi-ai.sql similarity index 90% rename from docs/script/sql/ruoyi-ai-v3_mysql8.sql rename to docs/script/sql/ruoyi-ai.sql index 1623e046..99c2f027 100644 --- a/docs/script/sql/ruoyi-ai-v3_mysql8.sql +++ b/docs/script/sql/ruoyi-ai.sql @@ -1372,13 +1372,13 @@ CREATE TABLE `short_drama_character_appearance` ( -- ---------------------------- -- Records of short_drama_character_appearance -- ---------------------------- -INSERT INTO `short_drama_character_appearance` VALUES (2075609273836224512, 2075609273630703616, 0, '初始形象', '鹅蛋脸线条柔和,双眼皮杏眼明亮有神,小巧挺拔的鼻梁,薄厚适中的嘴唇微抿,柳叶眉自然舒展。乌黑过肩直发扎成利落低马尾,额前碎发自然垂落。身形修长匀称,穿简洁白色短袖Polo衫,左胸有队徽刺绣,搭配卡其色工装长裤和白色运动鞋,腰间斜挎急救小包。', NULL, -1, -1, '2026-07-10 23:53:08', -1, '2026-07-10 23:53:08', 0, NULL, NULL, 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2075609329570136064, 2075609329431724032, 0, '初始形象', '脸型棱角分明,下颌线条锋利。细长眼型,双眼皮,高挺精致的鼻梁,薄唇平直,眉峰上扬的剑眉浓黑有型,右眼尾点缀一颗小痣。乌黑短发以利落背头后梳,露出饱满额头,发质柔顺服帖。身形修长,高挑匀称。身穿深灰色高支羊毛西装,剪裁修身,肩线平直,内搭黑色精纺高领毛衣,下身同色西裤线条流畅,脚著黑色牛津皮鞋。鼻梁上架一副极细金属框运动墨镜,左手腕佩戴银色纤薄腕表。', NULL, -1, -1, '2026-07-10 23:53:22', -1, '2026-07-10 23:53:22', 0, NULL, NULL, 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2075609380207968256, 2075609380031807488, 0, '初始形象', '钰珑生着一张轮廓分明的瓜子脸,浓黑剑眉斜抬,衬出几分不驯。眼形偏窄的杏眼微微上挑,单眼皮收紧眼尾,转折处利落带锋。鼻梁小巧挺直,嘴唇薄厚适中,下颌角附近有一道浅淡旧伤痕。一头乌黑长发高高束成马尾,额前碎发桀骜翘起,发尾带着自然微卷。身形修长结实,肩背紧绷,常年奔跑的线条紧致流畅。她穿着白色速干运动背心、深灰色运动短裤,脚踩抓地强劲的黑色跑鞋。右脚踝始终缠着防护绷带,步态间略带拖沓,却像随时要蹬地冲出。手腕戴一只简约的黑色运动手表。', NULL, -1, -1, '2026-07-10 23:53:34', -1, '2026-07-10 23:53:34', 0, NULL, NULL, 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2075609601822408704, 2075609601688190976, 0, '初始形象', '瓜子脸,线条利落分明。一双眼裂纤长的丹凤眼,眼尾如燕尾般锐利上扬,内眼角尖细,双眼皮折痕深刻清晰,注视时自带压迫感。鼻梁挺直高耸,鼻翼收窄,鼻尖精致小巧。双唇薄而轮廓分明,上唇弓形,下唇微微内收。眉骨高隆,眉形为天然剑眉,眉头略粗,眉峰微挑,眉尾收得细长利落。鼻梁中段偏左有一颗极浅小痣。一头乌黑长发在脑后高高束成利落马尾,扎紧于顶骨后方,用黑色弹力绳固定,发丝柔顺垂坠,发尾齐整落至肩胛骨下缘,额前无一丝刘海,露出光洁额头。体型修长而匀称,身姿高挑挺拔。上身穿一件白色速干圆领运动短袖T恤,面料带有微透气网眼,外罩一件深蓝色立领轻型教练夹克,拉链半开,袖口设有魔术贴收束,左臂佩戴红色弹性针织队长袖标,表面印有白色「CAPTAIN」字样。下身为黑色弹力紧身运动长裤,高腰设计,膝盖处有拼接剪裁,脚踝处有短拉链。脚蹬一双白色轻量跑步鞋,鞋面为透气网布拼接合成革,鞋带系紧,鞋底防滑纹理清晰。左手腕佩戴一块简约黑色运动电子手表。', NULL, -1, -1, '2026-07-10 23:54:27', -1, '2026-07-10 23:54:27', 0, NULL, NULL, 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2077008100070019072, 2077008099927412736, 0, '初始形象', '方形脸,浓黑剑眉,双眼皮,眼神锐利,鼻梁高挺,嘴唇线条分明。乌黑三七分背头,整齐服帖。身形匀称,身高适中。身穿藏青色修身西装外套,内搭白色衬衫,深灰色西裤,黑色系带皮鞋,手腕佩戴简约钢带手表。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/dd7454cd25a6486aa5c7c3f8c7085041-3742113260bb4ee9.jpg', -1, -1, '2026-07-14 20:31:35', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/dd7454cd25a6486aa5c7c3f8c7085041-3742113260bb4ee9.jpg\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 方形脸,浓黑剑眉,双眼皮,眼神锐利,鼻梁高挺,嘴唇线条分明。乌黑三七分背头,整齐服帖。身形匀称,身高适中。身穿藏青色修身西装外套,内搭白色衬衫,深灰色西裤,黑色系带皮鞋,手腕佩戴简约钢带手表。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2077008101487693824, 2077008101332504576, 0, '初始形象', '长方脸型,轮廓分明,额间有浅横纹,颧骨平直。浓眉微斜,眉尾稍疏,单眼皮细长眼,眼窝微陷,目光专注。鼻梁高直,鼻翼内收,唇形薄而线条清晰,下唇中央有浅凹痕。下巴方圆,无明显痣疤。发色花白,短发偏分,发质硬而蓬松,略见鬓角银丝。身形匀称中等,肩背挺直。上身着灰色中山装,领口紧扣齐整,左下口袋别一支钢笔。下装深灰西裤,裤线笔直,脚穿黑色系带皮鞋,鞋面光亮。无多余配饰。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/4729daf662de42c0a3472bc05043529c-01a8aaf57a08ea21.jpg', -1, -1, '2026-07-14 20:31:35', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/4729daf662de42c0a3472bc05043529c-01a8aaf57a08ea21.jpg\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 长方脸型,轮廓分明,额间有浅横纹,颧骨平直。浓眉微斜,眉尾稍疏,单眼皮细长眼,眼窝微陷,目光专注。鼻梁高直,鼻翼内收,唇形薄而线条清晰,下唇中央有浅凹痕。下巴方圆,无明显痣疤。发色花白,短发偏分,发质硬而蓬松,略见鬓角银丝。身形匀称中等,肩背挺直。上身着灰色中山装,领口紧扣齐整,左下口袋别一支钢笔。下装深灰西裤,裤线笔直,脚穿黑色系带皮鞋,鞋面光亮。无多余配饰。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); -INSERT INTO `short_drama_character_appearance` VALUES (2077008104272711680, 2077008104104939520, 0, '初始形象', '轮廓分明的瓜子脸,下颌线条利落。深邃的双眼皮眼睛眼型偏长,目光沉静;鼻梁高挺笔直;嘴唇厚薄适中,唇峰清晰;浓密的剑眉斜飞入鬓。右侧眉尾处一颗小痣。发色乌黑,及耳的长度微卷自然蓬松,额前碎发刘海随意散落。身形修长挺拔,身高约一米七八。穿着简约白色棉质衬衫,袖口卷至小臂,搭配深灰色直筒长裤与黑色帆布鞋。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/aab75b6b-865d-4825-8eee-9e3a45ccec62.png', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/aab75b6b-865d-4825-8eee-9e3a45ccec62.png\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 轮廓分明的瓜子脸,下颌线条利落。深邃的双眼皮眼睛眼型偏长,目光沉静;鼻梁高挺笔直;嘴唇厚薄适中,唇峰清晰;浓密的剑眉斜飞入鬓。右侧眉尾处一颗小痣。发色乌黑,及耳的长度微卷自然蓬松,额前碎发刘海随意散落。身形修长挺拔,身高约一米七八。穿着简约白色棉质衬衫,袖口卷至小臂,搭配深灰色直筒长裤与黑色帆布鞋。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2075609273836224512, 2075609273630703616, 0, '初始形象', '鹅蛋脸线条柔和,双眼皮杏眼明亮有神,小巧挺拔的鼻梁,薄厚适中的嘴唇微抿,柳叶眉自然舒展。乌黑过肩直发扎成利落低马尾,额前碎发自然垂落。身形修长匀称,穿简洁白色短袖Polo衫,左胸有队徽刺绣,搭配卡其色工装长裤和白色运动鞋,腰间斜挎急救小包。', NULL, -1, -1, '2026-07-10 23:53:08', -1, '2026-07-10 23:53:08', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2075609329570136064, 2075609329431724032, 0, '初始形象', '脸型棱角分明,下颌线条锋利。细长眼型,双眼皮,高挺精致的鼻梁,薄唇平直,眉峰上扬的剑眉浓黑有型,右眼尾点缀一颗小痣。乌黑短发以利落背头后梳,露出饱满额头,发质柔顺服帖。身形修长,高挑匀称。身穿深灰色高支羊毛西装,剪裁修身,肩线平直,内搭黑色精纺高领毛衣,下身同色西裤线条流畅,脚著黑色牛津皮鞋。鼻梁上架一副极细金属框运动墨镜,左手腕佩戴银色纤薄腕表。', NULL, -1, -1, '2026-07-10 23:53:22', -1, '2026-07-10 23:53:22', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2075609380207968256, 2075609380031807488, 0, '初始形象', '钰珑生着一张轮廓分明的瓜子脸,浓黑剑眉斜抬,衬出几分不驯。眼形偏窄的杏眼微微上挑,单眼皮收紧眼尾,转折处利落带锋。鼻梁小巧挺直,嘴唇薄厚适中,下颌角附近有一道浅淡旧伤痕。一头乌黑长发高高束成马尾,额前碎发桀骜翘起,发尾带着自然微卷。身形修长结实,肩背紧绷,常年奔跑的线条紧致流畅。她穿着白色速干运动背心、深灰色运动短裤,脚踩抓地强劲的黑色跑鞋。右脚踝始终缠着防护绷带,步态间略带拖沓,却像随时要蹬地冲出。手腕戴一只简约的黑色运动手表。', NULL, -1, -1, '2026-07-10 23:53:34', -1, '2026-07-10 23:53:34', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2075609601822408704, 2075609601688190976, 0, '初始形象', '瓜子脸,线条利落分明。一双眼裂纤长的丹凤眼,眼尾如燕尾般锐利上扬,内眼角尖细,双眼皮折痕深刻清晰,注视时自带压迫感。鼻梁挺直高耸,鼻翼收窄,鼻尖精致小巧。双唇薄而轮廓分明,上唇弓形,下唇微微内收。眉骨高隆,眉形为天然剑眉,眉头略粗,眉峰微挑,眉尾收得细长利落。鼻梁中段偏左有一颗极浅小痣。一头乌黑长发在脑后高高束成利落马尾,扎紧于顶骨后方,用黑色弹力绳固定,发丝柔顺垂坠,发尾齐整落至肩胛骨下缘,额前无一丝刘海,露出光洁额头。体型修长而匀称,身姿高挑挺拔。上身穿一件白色速干圆领运动短袖T恤,面料带有微透气网眼,外罩一件深蓝色立领轻型教练夹克,拉链半开,袖口设有魔术贴收束,左臂佩戴红色弹性针织队长袖标,表面印有白色「CAPTAIN」字样。下身为黑色弹力紧身运动长裤,高腰设计,膝盖处有拼接剪裁,脚踝处有短拉链。脚蹬一双白色轻量跑步鞋,鞋面为透气网布拼接合成革,鞋带系紧,鞋底防滑纹理清晰。左手腕佩戴一块简约黑色运动电子手表。', NULL, -1, -1, '2026-07-10 23:54:27', -1, '2026-07-10 23:54:27', 0, NULL, NULL, 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2077008100070019072, 2077008099927412736, 0, '初始形象', '方形脸,浓黑剑眉,双眼皮,眼神锐利,鼻梁高挺,嘴唇线条分明。乌黑三七分背头,整齐服帖。身形匀称,身高适中。身穿藏青色修身西装外套,内搭白色衬衫,深灰色西裤,黑色系带皮鞋,手腕佩戴简约钢带手表。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/dd7454cd25a6486aa5c7c3f8c7085041-3742113260bb4ee9.jpg', -1, -1, '2026-07-14 20:31:35', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/dd7454cd25a6486aa5c7c3f8c7085041-3742113260bb4ee9.jpg\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 方形脸,浓黑剑眉,双眼皮,眼神锐利,鼻梁高挺,嘴唇线条分明。乌黑三七分背头,整齐服帖。身形匀称,身高适中。身穿藏青色修身西装外套,内搭白色衬衫,深灰色西裤,黑色系带皮鞋,手腕佩戴简约钢带手表。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2077008101487693824, 2077008101332504576, 0, '初始形象', '长方脸型,轮廓分明,额间有浅横纹,颧骨平直。浓眉微斜,眉尾稍疏,单眼皮细长眼,眼窝微陷,目光专注。鼻梁高直,鼻翼内收,唇形薄而线条清晰,下唇中央有浅凹痕。下巴方圆,无明显痣疤。发色花白,短发偏分,发质硬而蓬松,略见鬓角银丝。身形匀称中等,肩背挺直。上身着灰色中山装,领口紧扣齐整,左下口袋别一支钢笔。下装深灰西裤,裤线笔直,脚穿黑色系带皮鞋,鞋面光亮。无多余配饰。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/4729daf662de42c0a3472bc05043529c-01a8aaf57a08ea21.jpg', -1, -1, '2026-07-14 20:31:35', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/4729daf662de42c0a3472bc05043529c-01a8aaf57a08ea21.jpg\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 长方脸型,轮廓分明,额间有浅横纹,颧骨平直。浓眉微斜,眉尾稍疏,单眼皮细长眼,眼窝微陷,目光专注。鼻梁高直,鼻翼内收,唇形薄而线条清晰,下唇中央有浅凹痕。下巴方圆,无明显痣疤。发色花白,短发偏分,发质硬而蓬松,略见鬓角银丝。身形匀称中等,肩背挺直。上身着灰色中山装,领口紧扣齐整,左下口袋别一支钢笔。下装深灰西裤,裤线笔直,脚穿黑色系带皮鞋,鞋面光亮。无多余配饰。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); +INSERT INTO `short_drama_character_appearance` (`id`, `character_id`, `appearance_index`, `change_reason`, `description`, `reference_image_url`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `image_urls`, `image_descriptions`, `selected_image_index`, `previous_image_urls`, `previous_descriptions`) VALUES (2077008104272711680, 2077008104104939520, 0, '初始形象', '轮廓分明的瓜子脸,下颌线条利落。深邃的双眼皮眼睛眼型偏长,目光沉静;鼻梁高挺笔直;嘴唇厚薄适中,唇峰清晰;浓密的剑眉斜飞入鬓。右侧眉尾处一颗小痣。发色乌黑,及耳的长度微卷自然蓬松,额前碎发刘海随意散落。身形修长挺拔,身高约一米七八。穿着简约白色棉质衬衫,袖口卷至小臂,搭配深灰色直筒长裤与黑色帆布鞋。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/images/aab75b6b-865d-4825-8eee-9e3a45ccec62.png', -1, -1, '2026-07-14 20:31:36', 1, '2026-07-14 20:38:50', 0, '[\"https://atlas-media.oss-us-west-1.aliyuncs.com/images/aab75b6b-865d-4825-8eee-9e3a45ccec62.png\"]', '[\"真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感,character design sheet, multiple views reference sheet, front view / side view / back view full body, clean white background, no props, no text. 轮廓分明的瓜子脸,下颌线条利落。深邃的双眼皮眼睛眼型偏长,目光沉静;鼻梁高挺笔直;嘴唇厚薄适中,唇峰清晰;浓密的剑眉斜飞入鬓。右侧眉尾处一颗小痣。发色乌黑,及耳的长度微卷自然蓬松,额前碎发刘海随意散落。身形修长挺拔,身高约一米七八。穿着简约白色棉质衬衫,袖口卷至小臂,搭配深灰色直筒长裤与黑色帆布鞋。。角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(完整正脸,最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。\"]', 0, NULL, NULL); -- ---------------------------- -- Table structure for short_drama_location @@ -1563,15 +1563,15 @@ CREATE TABLE `short_drama_storyboard` ( -- ---------------------------- -- Records of short_drama_storyboard -- ---------------------------- -INSERT INTO `short_drama_storyboard` VALUES (2077008643517599744, 2077007721974484992, 2077007722058371072, 1, '辞退邮件与触电穿越', '深夜办公室,电脑屏幕亮光映照50岁小明苍老疲惫的脸,他盯着屏幕上的辞退邮件,眼神失焦。他拿起手机看到离婚协议截图,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边电水壶,水洒向插座,火花迸溅,小明身体后仰倒在椅子上,画面渐黑。', 'suspense', '平视近景', '缓推至固定', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"办公桌前\"}]', '现代办公室_夜', '{\"panel_number\":1,\"scene_summary\":\"深夜办公室,电脑屏幕亮光映照50岁小明苍老疲惫的脸,他盯着屏幕上的辞退邮件,眼神失焦。他拿起手机看到离婚协议截图,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边电水壶,水洒向插座,火花迸溅,小明身体后仰倒在椅子上,画面渐黑。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"低调硬光,保留阴影层次\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"办公桌前\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中浅景深,保留环境压迫感\",\"color_tone\":\"冷暗色调\"}', '[{\"name\":\"小明\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:现代办公室_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"小明倒在椅子上,身体后仰,火花在插座处闪烁,画面渐黑\",\"continuity_action\":\"小明身体失去平衡向后倒,火花仍在闪烁\",\"spatial_anchor\":\"小明位于画面中央偏左,办公桌桌面杂乱的纸张和电脑屏幕占据右半,电水壶翻倒在桌角\",\"present_characters\":[\"小明\"],\"scene_number\":1,\"segment_number\":1,\"segment_goal\":\"建立小明被辞退后的绝望状态并触发穿越\",\"segment_result\":\"小明触电倒下,穿越启动\",\"bridge_in\":\"开场建立办公室环境与人物状态\",\"bridge_out\":\"火花和倒下的动作形成视觉钩子,画面切黑\",\"beat_type\":\"trigger\",\"narrative_cause\":\"小明收到辞退邮件和离婚协议,情绪崩溃导致意外\",\"character_goal\":\"缓解内心痛苦,但无意识动作导致触电\",\"story_action\":\"手抖放下手机,碰倒水壶,水溅到插座引发火花\",\"story_result\":\"小明触电昏迷,穿越时空\",\"next_hook\":\"下一镜头需要展示小明醒来后的新环境\"}', '办公室昏暗,只有电脑屏幕亮着。50岁的小明穿着皱巴巴的衬衫,盯着屏幕上的辞退邮件。他拿起手机,看到妻子发来的离婚协议截图。手抖着放下手机,碰倒桌上的电水壶,水洒在插座上。火花闪过,小明倒在椅子上。', '小明坐在深夜办公室的办公桌前,电脑屏幕蓝光照亮他苍老疲惫的脸,桌上散落纸张,他手中拿着手机看着离婚协议截图,旁边的电水壶摇摇欲坠,背景黑暗,气氛压抑。', 8, '深夜办公室,中年男子坐在办公桌前,电脑屏幕光映照脸庞,他盯着屏幕,眼神失焦。他拿起手机看向屏幕,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边的电水壶,水洒向插座,火花迸溅,他身体后仰倒在椅子上。镜头从平视中景缓缓推近至脸部特写,然后固定,画面渐黑。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203912-pshlw-0.mp4', '472c35ef433744c8a4cbd35a438ad35e', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:41:42', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643727314944, 2077007721974484992, 2077007722058371072, 2, '重生1985', '小明躺在地上,睁开眼,阳光刺眼。他坐起身,低头看见自己穿着蓝色工装,手背皮肤光滑无皱纹。他摸自己的脸,感觉紧致年轻。旁边老式自行车铃响,一个年轻人骑车路过喊“小明,上班迟到了!”小明看向路口停着的二八自行车和墙上红色标语“时间就是金钱”,低头看手腕电子表显示1985.6,他深吸一口气,眼神从迷茫变成坚定,低语“再来一次”。', 'emotion', '平视近景转特写', '缓推加环绕', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"地面中央\"}]', '小巷_日_1985', '{\"panel_number\":2,\"scene_summary\":\"小明躺在地上,睁开眼,阳光刺眼。他坐起身,低头看见自己穿着蓝色工装,手背皮肤光滑无皱纹。他摸自己的脸,感觉紧致年轻。旁边老式自行车铃响,一个年轻人骑车路过喊“小明,上班迟到了!”小明看向路口停着的二八自行车和墙上红色标语“时间就是金钱”,低头看手腕电子表显示1985.6,他深吸一口气,眼神从迷茫变成坚定,低语“再来一次”。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"柔和侧光,突出面部情绪\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"地面中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"浅景深,突出角色表情\",\"color_tone\":\"克制柔和色调\"}', '[{\"name\":\"小明\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:小巷_日_1985,重新交代人物位置、朝向与环境\",\"end_state\":\"小明站立,眼神坚定,低头看电子表,嘴唇微动说“再来一次”\",\"continuity_action\":\"小明视线从手表移向前方街道,准备行动\",\"spatial_anchor\":\"小明位于小巷中央地面,左侧墙上有红色标语,右侧有自行车和骑车年轻人,背景是老旧居民楼\",\"present_characters\":[\"小明\",\"年轻人(群众)\"],\"scene_number\":2,\"segment_number\":1,\"segment_goal\":\"展示小明穿越后醒来发现变成年轻模样\",\"segment_result\":\"小明确认自己回到1985年,眼神由迷茫变坚定\",\"bridge_in\":\"画面从黑场渐亮,小明面部特写从模糊到清晰\",\"bridge_out\":\"小明深吸一口气并说出内心决定,视线看向前方街道\",\"beat_type\":\"reaction\",\"narrative_cause\":\"小明触电昏迷,穿越时空\",\"character_goal\":\"确认自身状态和时代\",\"story_action\":\"坐起、摸脸、看周围、看电子表\",\"story_result\":\"小明确定自己重生,产生第二次人生的决心\",\"next_hook\":\"下一镜头展示小明带着方案前往街道办\"}', '小明醒来,发现自己躺在地上,身穿80年代蓝色工装。他摸着脸——没有皱纹。旁边老式自行车铃响,一个年轻人喊:“小明,上班迟到了!”小明看着路口的二八自行车,墙上的标语“时间就是金钱”,愣住。他低头看手腕上的电子表:1985年6月。他深吸一口气,眼神从迷茫变成坚定:“再来一次。”', '小明坐在小巷地面,身穿蓝色工装,阳光从巷口射入照亮他的脸,他摸着自己年轻的脸庞,眼神迷茫;背景是青砖墙和红色标语,一辆老式自行车靠在墙边。', 10, '年轻男子躺在地面上,阳光刺眼,他缓缓睁开眼,然后坐起身,低头看自己双手和蓝色工装,摸自己的脸,表情从迷茫到惊讶。旁边传来自行车铃声,他转头看向巷口的二八自行车和「时间就是金钱」标语,再低头看手腕上的电子表,眼神从迷茫变为坚定。镜头从全景缓慢推近至脸部特写,同时环绕拍摄,最后固定。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203913-lxtrb-0.mp4', 'b3b6843f0b78433893608675852797bd', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:47:08', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643781840896, 2077007721974484992, 2077007722058371072, 3, '街道办受阻遇老陈', '街道办门口,小明拿着一叠手写方案快步走向大门。门卫伸出手臂挡住他。小明急切地解释“同志,我有商业计划,能帮工厂接全国订单!”门卫皱眉推他出去,纸张散落一地。小明蹲下慌乱捡纸。路对面一个穿中山装的中年男人(老陈)弯腰捡起一张,看着上面的字,抬头问:“你这上面写的‘电商’是啥?”小明抬头,眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”', 'daily', '平视中景', '轻微跟随', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"门口台阶下\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"路对面人行道\"}]', '街道办门口_日', '{\"panel_number\":3,\"scene_summary\":\"街道办门口,小明拿着一叠手写方案快步走向大门。门卫伸出手臂挡住他。小明急切地解释“同志,我有商业计划,能帮工厂接全国订单!”门卫皱眉推他出去,纸张散落一地。小明蹲下慌乱捡纸。路对面一个穿中山装的中年男人(老陈)弯腰捡起一张,看着上面的字,抬头问:“你这上面写的‘电商’是啥?”小明抬头,眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"门口台阶下\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"路对面人行道\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:街道办门口_日,重新交代人物位置、朝向与环境\",\"end_state\":\"小明蹲在地上抬头看向老陈,老陈站在路对面手持一张纸,两人视线交汇\",\"continuity_action\":\"小明视线锁定老陈,准备进一步解释\",\"spatial_anchor\":\"街道办铁门在画面左侧,门卫站在台阶上;小明在台阶下中央;老陈在画面右侧路对面行道树旁\",\"present_characters\":[\"小明\",\"老陈\",\"门卫(群众)\"],\"scene_number\":3,\"segment_number\":1,\"segment_goal\":\"小明尝试进入街道办被门卫阻拦,吸引老陈注意\",\"segment_result\":\"老陈捡起散落的方案并开始询问\",\"bridge_in\":\"小明拿着方案走向街道办大门,眼神充满希望\",\"bridge_out\":\"老陈目光落在方案上,问题引发后续对话\",\"beat_type\":\"trigger\",\"narrative_cause\":\"小明确定自己重生,产生第二次人生的决心\",\"character_goal\":\"进入街道办寻求支持\",\"story_action\":\"小明上前解释,被推倒,捡纸时发现老陈在看方案\",\"story_result\":\"老陈对方案产生好奇并主动与小明对话\",\"next_hook\":\"下一镜头展示两人在废弃仓库深入讨论\"}', '小明拿着一叠手写方案,站在街道办门口。门卫拦住他。小明:“同志,我有商业计划,能帮工厂接全国订单!”门卫:“胡闹!”推他出去。小明捡起散落的纸,抬头看到路对面一个穿中山装的中年男人(老陈)正在看他的方案。老陈:“你这上面写的‘电商’是啥?”小明眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”', '小明站在街道办门口台阶下,手拿一叠方案,表情急切;门卫站在台阶上伸手阻拦;路对面老陈穿中山装低头看纸,阳光从右上方照射。', 8, '年轻男子拿着一叠手写方案快步走向街道办大门,门卫伸出手臂阻挡。年轻男子急切地解释,门卫皱眉推他出去,纸张散落。年轻男子蹲下慌乱捡纸。路对面一位中年男子弯腰捡起一张纸,看着上面,抬头问话。镜头平视跟随年轻男子移动,然后切换到越肩视角展示门卫的阻拦动作。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203920-6rw8b-0.mp4', '7173c12461574b8abd063c46fc625ebe', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:47:09', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643823783936, 2077007721974484992, 2077007722058371072, 4, '仓库达成合作', '废弃仓库内,一张木桌两把椅子,小明和老陈对面而坐。桌上摆着两盒烟,老陈抽出一根点燃:“我信你一次,但怎么弄?”小明拿起笔在纸上画示意图:“用电话线连电脑,搞个库存信息库。我写程序。”老陈皱眉:“钱呢?”小明:“我先拉商户,您出设备。”老陈抖了抖烟灰,沉默几秒后点头。小明转头看向墙上的标语“工业学大庆”,低声:“这次一定要成。”', 'daily', '平视中景', '缓推', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"桌子一侧\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"桌子另一侧\"}]', '废弃仓库_日', '{\"panel_number\":4,\"scene_summary\":\"废弃仓库内,一张木桌两把椅子,小明和老陈对面而坐。桌上摆着两盒烟,老陈抽出一根点燃:“我信你一次,但怎么弄?”小明拿起笔在纸上画示意图:“用电话线连电脑,搞个库存信息库。我写程序。”老陈皱眉:“钱呢?”小明:“我先拉商户,您出设备。”老陈抖了抖烟灰,沉默几秒后点头。小明转头看向墙上的标语“工业学大庆”,低声:“这次一定要成。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"桌子一侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"桌子另一侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:废弃仓库_日,重新交代人物位置、朝向与环境\",\"end_state\":\"老陈点头,小明目光从墙上标语收回,表情坚定\",\"continuity_action\":\"小明视线从标语移回老陈,准备下一步计划\",\"spatial_anchor\":\"桌子位于仓库中央,小明坐画面左侧,老陈坐右侧,后面墙上有标语,水泥地面空旷\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":4,\"segment_number\":1,\"segment_goal\":\"小明与老陈在仓库达成合作意向\",\"segment_result\":\"老陈决定信任小明,提供设备支持\",\"bridge_in\":\"两人对面而坐,桌上两盒烟,老陈面露犹豫\",\"bridge_out\":\"老陈点头,小明看向墙上标语,低声坚定信念\",\"beat_type\":\"action\",\"narrative_cause\":\"老陈对方案产生好奇并主动与小明对话\",\"character_goal\":\"说服老陈提供设备支持\",\"story_action\":\"小明画图解释技术方案,提出资源分工\",\"story_result\":\"老陈同意合作,小明获得初步资源\",\"next_hook\":\"下一镜头展示工厂办公室内新进展与危机\"}', '老陈和小明对面坐着。桌上摆着两盒烟。老陈:“我信你一次,但怎么弄?”小明在纸上画:“用电话线连电脑,搞个库存信息库。我写程序。”老陈:“钱呢?”小明:“我先拉商户,您出设备。”老陈犹豫后点头。小明看着仓库墙上的“工业学大庆”,低声:“这次一定要成。”', '小明和老陈在废弃仓库的旧木桌对面而坐,桌上两盒烟和散落的纸张;老陈夹烟沉思,小明执笔画示意图;背景墙上红色大字「工业学大庆」,高窗透入光线。', 8, '年轻男子和中年男子对面坐在木桌两侧,桌上摆着两盒烟。中年男子抽出一根点燃,年轻男子拿起笔在纸上画图并说话。中年男子皱眉问话,年轻男子抬头回答,伸手指向纸面。中年男子抖烟灰后点头。年轻男子转头看向墙上的标语,低声自语。镜头从两人的中景缓缓推近至年轻男子的脸部,然后固定。', NULL, 'd4665ee140f34673914292855d389d38', 'generating', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:39:20', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643874115584, 2077007721974484992, 2077007722058371072, 5, '工商检查危机', '工厂办公室内,木制办公桌上转盘电话响起。老陈拿起听筒,脸色逐渐变白,手指捏紧话筒。挂断后他转身对旁边椅子上的小明说:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看桌上的账本,抬起头:“我们走的是信息服务,不是倒卖。”老陈叹气:“没人认可这个。”小明合上账本,直视老陈:“明天我来说。”', 'suspense', '平视近景', '缓推', '[{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"办公桌后\"},{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"办公桌旁椅子\"}]', '工厂办公室_日', '{\"panel_number\":5,\"scene_summary\":\"工厂办公室内,木制办公桌上转盘电话响起。老陈拿起听筒,脸色逐渐变白,手指捏紧话筒。挂断后他转身对旁边椅子上的小明说:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看桌上的账本,抬起头:“我们走的是信息服务,不是倒卖。”老陈叹气:“没人认可这个。”小明合上账本,直视老陈:“明天我来说。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"低调硬光,保留阴影层次\"},\"characters\":[{\"name\":\"老陈\",\"screen_position\":\"办公桌后\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"小明\",\"screen_position\":\"办公桌旁椅子\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中浅景深,保留环境压迫感\",\"color_tone\":\"冷暗色调\"}', '[{\"name\":\"老陈\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感\"},{\"name\":\"小明\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感\"}]', '{\"start_state\":\"新场景建立:工厂办公室_日,重新交代人物位置、朝向与环境\",\"end_state\":\"小明合上账本,直视老陈,表情自信;老陈眉头紧锁但不再反驳\",\"continuity_action\":\"小明视线坚定,老陈目光仍带忧虑但转向信任\",\"spatial_anchor\":\"老陈位于画面左侧办公桌后,小明在右侧椅子,窗外可见厂区烟囱,文件柜靠墙\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":5,\"segment_number\":1,\"segment_goal\":\"工厂办公室内老陈接到工商查处的电话,小明提出应对\",\"segment_result\":\"小明决定亲自去面对工商检查\",\"bridge_in\":\"办公室电话铃响打破平静,老陈接听表情变化\",\"bridge_out\":\"老陈看向小明,小明坚定地说“明天我来说”\",\"beat_type\":\"trigger\",\"narrative_cause\":\"老陈同意合作,小明获得初步资源\",\"character_goal\":\"让老陈相信自己能解决危机\",\"story_action\":\"小明解释模式合法性,并主动承担交涉责任\",\"story_result\":\"老陈部分安心,小明获得应对危机的授权\",\"next_hook\":\"下一镜头展示出租屋内团队内部争吵与统一思想\"}', '办公桌上电话响起。老陈接起,脸色变白。挂断后对小明:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看账本:“我们走的是信息服务,不是倒卖。”老陈:“没人认可这个。”小明:“明天我来说。”', '工厂办公室内,老陈坐在办公桌后接电话,脸色凝重;小明坐在侧面椅子上翻看账本,窗外可见工厂烟囱;桌上转盘电话和文件散落。', 7, '办公桌上黑色转盘电话响起。中年男子坐在桌后拿起听筒,脸色逐渐变白,手指捏紧话筒。他挂断后转身对旁边的年轻男子说话。年轻男子快速翻看账本,抬起头直视对方回话,然后合上账本。镜头从电话特写缓慢推近至中年男子面部,再缓拉到两人中景。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203917-gnb2m-0.mp4', '61edb37e979a426f9e8272786243c243', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:41:08', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643920252928, 2077007721974484992, 2077007722058371072, 6, '出租屋统一思想', '狭小出租屋,灯光昏暗。两个年轻团队成员拍桌站起:“不干了,风险太大!”老陈坐在床边沉默,低头看地面。小明从墙角拿起一块黑板放在桌上,画出一条时间轴,标注1985年,指向电脑图标。他转头对大家说:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头,皱眉问:“你哪来的这些?”小明停顿一下:“梦里学的。”', 'emotion', '平视近景转特写', '缓推加环绕', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"桌边站立\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"床边坐着\"}]', '出租屋_夜', '{\"panel_number\":6,\"scene_summary\":\"狭小出租屋,灯光昏暗。两个年轻团队成员拍桌站起:“不干了,风险太大!”老陈坐在床边沉默,低头看地面。小明从墙角拿起一块黑板放在桌上,画出一条时间轴,标注1985年,指向电脑图标。他转头对大家说:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头,皱眉问:“你哪来的这些?”小明停顿一下:“梦里学的。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"柔和侧光,突出面部情绪\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"桌边站立\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"床边坐着\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"浅景深,突出角色表情\",\"color_tone\":\"克制柔和色调\"}', '[{\"name\":\"小明\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:出租屋_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"老陈抬头看向小明,两个年轻人停止争吵望向黑板,小黑板上画有时间轴和“电脑”字样\",\"continuity_action\":\"小明手指还指着黑板上的“技术咨询”,等待异议\",\"spatial_anchor\":\"画面中央是一张桌子,黑板靠在桌上,小明站在桌子左侧画黑板,老陈坐在右侧床边,两个年轻人站在桌子远端\",\"present_characters\":[\"小明\",\"老陈\",\"两个年轻人(群众)\"],\"scene_number\":6,\"segment_number\":1,\"segment_goal\":\"出租屋内团队成员因风险而退缩,小明用时间轴规划说服大家\",\"segment_result\":\"老陈和小明达成共识,团队重拾信心\",\"bridge_in\":\"出租屋内两个年轻人拍桌起身,气氛紧张\",\"bridge_out\":\"老陈抬头看小明,眼神中带着疑惑与信任\",\"beat_type\":\"decision\",\"narrative_cause\":\"老陈部分安心,小明获得应对危机的授权\",\"character_goal\":\"稳定团队,统一认知\",\"story_action\":\"小明画出时间轴,解释信息差优势和应对策略\",\"story_result\":\"团队情绪稳定,老陈虽疑惑但接受解释\",\"next_hook\":\"下一镜头展示百货大楼门口工商检查的结果\"}', '团队成员(两个年轻人)拍桌:“不干了,风险太大!”老陈沉默。小明拿出一块黑板,画出时间轴:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头:“你哪来的这些?”小明:“梦里学的。”', '出租屋内,小明站在桌子左侧画黑板,黑板上粉笔写着时间轴和「电脑」字样;老陈坐在床边抬头看;两个年轻人站在桌子远端,表情从愤怒转为倾听;昏黄灯光下气氛紧张。', 10, '狭小出租屋内,两个年轻人拍桌站起情绪激动。中年男子坐在床边低头沉默。年轻男子从墙角拿起一块黑板放在桌上,用粉笔画出一条时间轴,标注1985和电脑图标,转头对大家说话并指向黑板。中年男子抬起头皱眉问话,年轻男子停顿后回答。镜头从全景缓慢推近至年轻男子面部特写,然后环绕拍摄黑板上的时间轴。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008643978973184, 2077007721974484992, 2077007722058371072, 7, '工商检查通过', '百货大楼门口台阶上,工商人员和穿藏青色西装的中年经理站在一起。小明从公文包拿出一叠表格递过去:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看后递给旁边的百货经理。经理推了推眼镜细看,低声问小明:“这方法能帮我们清库存?”小明点头:“三天见效。”经理转向工商人员点了点头。工商人员收起表格:“先观察。”', 'daily', '平视中景', '轻微跟随', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"台阶上右侧\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"台阶上小明身后\"},{\"name\":\"百货经理\",\"appearance\":\"初始形象\",\"slot\":\"台阶上中央\"}]', '百货大楼门口_日', '{\"panel_number\":7,\"scene_summary\":\"百货大楼门口台阶上,工商人员和穿藏青色西装的中年经理站在一起。小明从公文包拿出一叠表格递过去:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看后递给旁边的百货经理。经理推了推眼镜细看,低声问小明:“这方法能帮我们清库存?”小明点头:“三天见效。”经理转向工商人员点了点头。工商人员收起表格:“先观察。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"台阶上右侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"台阶上小明身后\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"百货经理\",\"screen_position\":\"台阶上中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"百货经理\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:百货大楼门口_日,重新交代人物位置、朝向与环境\",\"end_state\":\"工商人员收起表格,经理向小明点头,小明露出微笑\",\"continuity_action\":\"小明视线跟随工商人员离开,准备下一步行动\",\"spatial_anchor\":\"百货大楼正门台阶为背景,工商人员在中央偏左,经理在左侧,小明在右侧,老陈在小明后方半步\",\"present_characters\":[\"小明\",\"老陈\",\"百货经理\",\"工商人员(群众)\"],\"scene_number\":7,\"segment_number\":1,\"segment_goal\":\"工商检查现场,小明用订单记录证明信息服务合法性,获得百货经理认可\",\"segment_result\":\"工商同意观察,项目得以继续\",\"bridge_in\":\"百货大楼门口,工商人员和小明、老陈站立,气氛凝重\",\"bridge_out\":\"工商人员收起表格,经理点头,危机解除\",\"beat_type\":\"result\",\"narrative_cause\":\"团队情绪稳定,老陈虽疑惑但接受解释\",\"character_goal\":\"用事实凭证打消工商疑虑\",\"story_action\":\"小明出示订单记录并解释运营模式\",\"story_result\":\"工商暂缓处理,项目获得官方默许\",\"next_hook\":\"下一镜头展示庆功礼堂的成果与小明的情感时刻\"}', '工商人员和小明、老陈站在门口。小明拿出一份表格:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看,递给旁边一个戴眼镜的中年人(百货公司经理)。经理看了后低声:“这方法能帮我们清库存?”小明:“三天见效。”经理点头。工商人员收起表格:“先观察。”', '小明站在百货大楼门口台阶上,从公文包中拿出表格递给工商人员;老陈站在他身后;旁边穿藏青色西装的百货经理低头查看表格;阳光照亮大楼立面。', 8, '百货大楼门口台阶上,工商人员和穿西装的中年男子站在一起。年轻男子从公文包拿出一叠表格递出并说话。工商人员翻看后递给旁边的经理。经理推了推眼镜细看,低声问年轻男子,年轻男子点头回答。经理转向工商人员点头。工商人员收起表格。镜头平视跟随年轻男子的动作,使用越肩视角展示表格内容。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008644041887744, 2077007721974484992, 2077007722058371072, 8, '厂庆宣言互联网愿景', '厂庆礼堂,红灯笼和横幅装饰,舞台上麦克风前,老陈对着台下说:“感谢小明的信息平台,咱们工厂活了。”台下工人鼓掌。小明站在侧台阴影里,手伸进口袋摸到发黄的现代身份证,他看着灯光下兴奋的面孔,默默将身份证撕碎。主持人喊:“请小明同志讲话!”小明走上舞台中央,拿起话筒,停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。', 'epic', '大远景转特写', '俯拍后缓降推近', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"舞台上中央\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"舞台侧边\"}]', '厂庆礼堂_夜', '{\"panel_number\":8,\"scene_summary\":\"厂庆礼堂,红灯笼和横幅装饰,舞台上麦克风前,老陈对着台下说:“感谢小明的信息平台,咱们工厂活了。”台下工人鼓掌。小明站在侧台阴影里,手伸进口袋摸到发黄的现代身份证,他看着灯光下兴奋的面孔,默默将身份证撕碎。主持人喊:“请小明同志讲话!”小明走上舞台中央,拿起话筒,停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"大范围自然光,突出空间规模\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"舞台上中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"舞台侧边\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"深景深,清晰展现环境规模\",\"color_tone\":\"宏大通透色调\"}', '[{\"name\":\"小明\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:厂庆礼堂_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"小明站在舞台中央举起话筒,台下工人站立鼓掌,老陈在侧台微笑\",\"continuity_action\":\"小明视线扫过全场,准备下台开始新阶段\",\"spatial_anchor\":\"舞台在画面中央靠上,台下长凳坐满工人,小明位于舞台中央,老陈在舞台左侧靠边\",\"present_characters\":[\"小明\",\"老陈\",\"工人(群众)\",\"主持人(群众)\"],\"scene_number\":8,\"segment_number\":1,\"segment_goal\":\"厂庆礼堂中老陈表扬小明,小明撕碎现代身份证并发表讲话\",\"segment_result\":\"小明公开宣告互联网未来愿景,全体工人热烈鼓掌\",\"bridge_in\":\"礼堂内工人坐满长凳,老陈在台上讲话\",\"bridge_out\":\"掌声雷动中,小明眼神坚定望向未来\",\"beat_type\":\"action\",\"narrative_cause\":\"工商暂缓处理,项目获得官方默许\",\"character_goal\":\"激励团队并宣告新方向\",\"story_action\":\"小明撕碎现代身份证,上台演讲\",\"story_result\":\"全体工人被鼓舞,小明确立新一代领导地位\",\"next_hook\":\"下一镜头展示清晨新挂牌和下一步计划\"}', '工人坐满长凳。老陈上台:“感谢小明的信息平台,咱们工厂活了。”台下鼓掌。小明站在侧台,看着灯光下兴奋的面孔。他手摸到口袋里的现代身份证——早已发黄。他默默撕碎。主持人:“请小明同志讲话!”小明上前,拿着话筒停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。', '厂庆礼堂内,舞台中央老陈握着话筒讲话,台下工人鼓掌;小明站在侧幕阴影中,手中捏着撕碎的身份证,灯光照亮舞台,红灯笼和横幅装饰,气氛热烈。', 10, '礼堂内工人坐满长凳,舞台中央中年男子在讲话,台下鼓掌。年轻男子站在侧台阴影中,手伸进口袋掏出一张发黄的身份证,默默撕碎。主持人喊话,年轻男子走上舞台中央,拿起话筒停顿三秒后说话,台下掌声雷动。镜头从礼堂大远景俯拍开始,然后缓缓下降并推近至年轻男子面部特写。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); -INSERT INTO `short_drama_storyboard` VALUES (2077008644113190912, 2077007721974484992, 2077007722058371072, 9, '新征程启程', '清晨,阳光照在工厂大门新挂牌上,金边字体“华夏信息服务中心”。小明推着自行车走出来,老陈从后面追上:“下一步?”小明跨上车:“跑遍全国,建数据库。”老陈笑着摇头:“疯了。”小明回以笑容:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。', 'epic', '大远景', '缓拉升起', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"工厂大门前\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"门口台阶上\"}]', '工厂大门_晨', '{\"panel_number\":9,\"scene_summary\":\"清晨,阳光照在工厂大门新挂牌上,金边字体“华夏信息服务中心”。小明推着自行车走出来,老陈从后面追上:“下一步?”小明跨上车:“跑遍全国,建数据库。”老陈笑着摇头:“疯了。”小明回以笑容:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"大范围自然光,突出空间规模\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"工厂大门前\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"门口台阶上\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"深景深,清晰展现环境规模\",\"color_tone\":\"宏大通透色调\"}', '[{\"name\":\"小明\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:工厂大门_晨,重新交代人物位置、朝向与环境\",\"end_state\":\"小明骑车远去的背影在街道尽头,烟囱冒烟,晨光笼罩\",\"continuity_action\":\"小明继续蹬车,老陈目送,街道向前延伸\",\"spatial_anchor\":\"工厂大门在画面左侧,新挂牌醒目;小明骑车向右远去,街道两旁有工厂围墙和烟囱;老陈站在门口台阶上\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":9,\"segment_number\":1,\"segment_goal\":\"新挂牌的清晨,小明与老陈对话后骑车远去,象征新征程\",\"segment_result\":\"小明骑车远去,烟囱冒烟,新的一天开始\",\"bridge_in\":\"阳光照在新挂牌上,小明推车出现\",\"bridge_out\":\"镜头定格在远去的背影和工厂烟囱,留下开放节奏\",\"beat_type\":\"result\",\"narrative_cause\":\"全体工人被鼓舞,小明确立新一代领导地位\",\"character_goal\":\"确定下一阶段行动计划\",\"story_action\":\"小明跨上自行车说出全国数据库计划\",\"story_result\":\"小明开始实际行动,留下奋斗的背影\",\"next_hook\":\"故事结束,无下一镜头\"}', '阳光照在新挂牌上:“华夏信息服务中心”。小明推着自行车出来,老陈追上:“下一步?”小明:“跑遍全国,建数据库。”老陈笑:“疯了。”小明跨上车:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟。', '清晨阳光照射工厂大门新挂牌「华夏信息服务中心」,小明推自行车站在门口,老陈站在台阶上微笑;远处烟囱冒烟,天空泛鱼肚白,街道延伸向远方。', 10, '清晨阳光照在工厂大门新挂牌上,金色字体「华夏信息服务中心」。年轻男子推自行车从门内走出,中年男子从后面追上,两人对话。年轻男子跨上自行车,蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。镜头从新挂牌特写缓缓拉远并升起,展现工厂全貌和远去的背影。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643517599744, 2077007721974484992, 2077007722058371072, 1, '辞退邮件与触电穿越', '深夜办公室,电脑屏幕亮光映照50岁小明苍老疲惫的脸,他盯着屏幕上的辞退邮件,眼神失焦。他拿起手机看到离婚协议截图,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边电水壶,水洒向插座,火花迸溅,小明身体后仰倒在椅子上,画面渐黑。', 'suspense', '平视近景', '缓推至固定', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"办公桌前\"}]', '现代办公室_夜', '{\"panel_number\":1,\"scene_summary\":\"深夜办公室,电脑屏幕亮光映照50岁小明苍老疲惫的脸,他盯着屏幕上的辞退邮件,眼神失焦。他拿起手机看到离婚协议截图,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边电水壶,水洒向插座,火花迸溅,小明身体后仰倒在椅子上,画面渐黑。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"低调硬光,保留阴影层次\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"办公桌前\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中浅景深,保留环境压迫感\",\"color_tone\":\"冷暗色调\"}', '[{\"name\":\"小明\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:现代办公室_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"小明倒在椅子上,身体后仰,火花在插座处闪烁,画面渐黑\",\"continuity_action\":\"小明身体失去平衡向后倒,火花仍在闪烁\",\"spatial_anchor\":\"小明位于画面中央偏左,办公桌桌面杂乱的纸张和电脑屏幕占据右半,电水壶翻倒在桌角\",\"present_characters\":[\"小明\"],\"scene_number\":1,\"segment_number\":1,\"segment_goal\":\"建立小明被辞退后的绝望状态并触发穿越\",\"segment_result\":\"小明触电倒下,穿越启动\",\"bridge_in\":\"开场建立办公室环境与人物状态\",\"bridge_out\":\"火花和倒下的动作形成视觉钩子,画面切黑\",\"beat_type\":\"trigger\",\"narrative_cause\":\"小明收到辞退邮件和离婚协议,情绪崩溃导致意外\",\"character_goal\":\"缓解内心痛苦,但无意识动作导致触电\",\"story_action\":\"手抖放下手机,碰倒水壶,水溅到插座引发火花\",\"story_result\":\"小明触电昏迷,穿越时空\",\"next_hook\":\"下一镜头需要展示小明醒来后的新环境\"}', '办公室昏暗,只有电脑屏幕亮着。50岁的小明穿着皱巴巴的衬衫,盯着屏幕上的辞退邮件。他拿起手机,看到妻子发来的离婚协议截图。手抖着放下手机,碰倒桌上的电水壶,水洒在插座上。火花闪过,小明倒在椅子上。', '小明坐在深夜办公室的办公桌前,电脑屏幕蓝光照亮他苍老疲惫的脸,桌上散落纸张,他手中拿着手机看着离婚协议截图,旁边的电水壶摇摇欲坠,背景黑暗,气氛压抑。', 8, '深夜办公室,中年男子坐在办公桌前,电脑屏幕光映照脸庞,他盯着屏幕,眼神失焦。他拿起手机看向屏幕,手开始颤抖,手机滑落。他试图扶住桌子,手肘碰倒旁边的电水壶,水洒向插座,火花迸溅,他身体后仰倒在椅子上。镜头从平视中景缓缓推近至脸部特写,然后固定,画面渐黑。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203912-pshlw-0.mp4', '472c35ef433744c8a4cbd35a438ad35e', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:41:42', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643727314944, 2077007721974484992, 2077007722058371072, 2, '重生1985', '小明躺在地上,睁开眼,阳光刺眼。他坐起身,低头看见自己穿着蓝色工装,手背皮肤光滑无皱纹。他摸自己的脸,感觉紧致年轻。旁边老式自行车铃响,一个年轻人骑车路过喊“小明,上班迟到了!”小明看向路口停着的二八自行车和墙上红色标语“时间就是金钱”,低头看手腕电子表显示1985.6,他深吸一口气,眼神从迷茫变成坚定,低语“再来一次”。', 'emotion', '平视近景转特写', '缓推加环绕', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"地面中央\"}]', '小巷_日_1985', '{\"panel_number\":2,\"scene_summary\":\"小明躺在地上,睁开眼,阳光刺眼。他坐起身,低头看见自己穿着蓝色工装,手背皮肤光滑无皱纹。他摸自己的脸,感觉紧致年轻。旁边老式自行车铃响,一个年轻人骑车路过喊“小明,上班迟到了!”小明看向路口停着的二八自行车和墙上红色标语“时间就是金钱”,低头看手腕电子表显示1985.6,他深吸一口气,眼神从迷茫变成坚定,低语“再来一次”。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"柔和侧光,突出面部情绪\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"地面中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"浅景深,突出角色表情\",\"color_tone\":\"克制柔和色调\"}', '[{\"name\":\"小明\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:小巷_日_1985,重新交代人物位置、朝向与环境\",\"end_state\":\"小明站立,眼神坚定,低头看电子表,嘴唇微动说“再来一次”\",\"continuity_action\":\"小明视线从手表移向前方街道,准备行动\",\"spatial_anchor\":\"小明位于小巷中央地面,左侧墙上有红色标语,右侧有自行车和骑车年轻人,背景是老旧居民楼\",\"present_characters\":[\"小明\",\"年轻人(群众)\"],\"scene_number\":2,\"segment_number\":1,\"segment_goal\":\"展示小明穿越后醒来发现变成年轻模样\",\"segment_result\":\"小明确认自己回到1985年,眼神由迷茫变坚定\",\"bridge_in\":\"画面从黑场渐亮,小明面部特写从模糊到清晰\",\"bridge_out\":\"小明深吸一口气并说出内心决定,视线看向前方街道\",\"beat_type\":\"reaction\",\"narrative_cause\":\"小明触电昏迷,穿越时空\",\"character_goal\":\"确认自身状态和时代\",\"story_action\":\"坐起、摸脸、看周围、看电子表\",\"story_result\":\"小明确定自己重生,产生第二次人生的决心\",\"next_hook\":\"下一镜头展示小明带着方案前往街道办\"}', '小明醒来,发现自己躺在地上,身穿80年代蓝色工装。他摸着脸——没有皱纹。旁边老式自行车铃响,一个年轻人喊:“小明,上班迟到了!”小明看着路口的二八自行车,墙上的标语“时间就是金钱”,愣住。他低头看手腕上的电子表:1985年6月。他深吸一口气,眼神从迷茫变成坚定:“再来一次。”', '小明坐在小巷地面,身穿蓝色工装,阳光从巷口射入照亮他的脸,他摸着自己年轻的脸庞,眼神迷茫;背景是青砖墙和红色标语,一辆老式自行车靠在墙边。', 10, '年轻男子躺在地面上,阳光刺眼,他缓缓睁开眼,然后坐起身,低头看自己双手和蓝色工装,摸自己的脸,表情从迷茫到惊讶。旁边传来自行车铃声,他转头看向巷口的二八自行车和「时间就是金钱」标语,再低头看手腕上的电子表,眼神从迷茫变为坚定。镜头从全景缓慢推近至脸部特写,同时环绕拍摄,最后固定。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203913-lxtrb-0.mp4', 'b3b6843f0b78433893608675852797bd', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:47:08', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643781840896, 2077007721974484992, 2077007722058371072, 3, '街道办受阻遇老陈', '街道办门口,小明拿着一叠手写方案快步走向大门。门卫伸出手臂挡住他。小明急切地解释“同志,我有商业计划,能帮工厂接全国订单!”门卫皱眉推他出去,纸张散落一地。小明蹲下慌乱捡纸。路对面一个穿中山装的中年男人(老陈)弯腰捡起一张,看着上面的字,抬头问:“你这上面写的‘电商’是啥?”小明抬头,眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”', 'daily', '平视中景', '轻微跟随', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"门口台阶下\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"路对面人行道\"}]', '街道办门口_日', '{\"panel_number\":3,\"scene_summary\":\"街道办门口,小明拿着一叠手写方案快步走向大门。门卫伸出手臂挡住他。小明急切地解释“同志,我有商业计划,能帮工厂接全国订单!”门卫皱眉推他出去,纸张散落一地。小明蹲下慌乱捡纸。路对面一个穿中山装的中年男人(老陈)弯腰捡起一张,看着上面的字,抬头问:“你这上面写的‘电商’是啥?”小明抬头,眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"门口台阶下\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"路对面人行道\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:街道办门口_日,重新交代人物位置、朝向与环境\",\"end_state\":\"小明蹲在地上抬头看向老陈,老陈站在路对面手持一张纸,两人视线交汇\",\"continuity_action\":\"小明视线锁定老陈,准备进一步解释\",\"spatial_anchor\":\"街道办铁门在画面左侧,门卫站在台阶上;小明在台阶下中央;老陈在画面右侧路对面行道树旁\",\"present_characters\":[\"小明\",\"老陈\",\"门卫(群众)\"],\"scene_number\":3,\"segment_number\":1,\"segment_goal\":\"小明尝试进入街道办被门卫阻拦,吸引老陈注意\",\"segment_result\":\"老陈捡起散落的方案并开始询问\",\"bridge_in\":\"小明拿着方案走向街道办大门,眼神充满希望\",\"bridge_out\":\"老陈目光落在方案上,问题引发后续对话\",\"beat_type\":\"trigger\",\"narrative_cause\":\"小明确定自己重生,产生第二次人生的决心\",\"character_goal\":\"进入街道办寻求支持\",\"story_action\":\"小明上前解释,被推倒,捡纸时发现老陈在看方案\",\"story_result\":\"老陈对方案产生好奇并主动与小明对话\",\"next_hook\":\"下一镜头展示两人在废弃仓库深入讨论\"}', '小明拿着一叠手写方案,站在街道办门口。门卫拦住他。小明:“同志,我有商业计划,能帮工厂接全国订单!”门卫:“胡闹!”推他出去。小明捡起散落的纸,抬头看到路对面一个穿中山装的中年男人(老陈)正在看他的方案。老陈:“你这上面写的‘电商’是啥?”小明眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”', '小明站在街道办门口台阶下,手拿一叠方案,表情急切;门卫站在台阶上伸手阻拦;路对面老陈穿中山装低头看纸,阳光从右上方照射。', 8, '年轻男子拿着一叠手写方案快步走向街道办大门,门卫伸出手臂阻挡。年轻男子急切地解释,门卫皱眉推他出去,纸张散落。年轻男子蹲下慌乱捡纸。路对面一位中年男子弯腰捡起一张纸,看着上面,抬头问话。镜头平视跟随年轻男子移动,然后切换到越肩视角展示门卫的阻拦动作。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203920-6rw8b-0.mp4', '7173c12461574b8abd063c46fc625ebe', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:47:09', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643823783936, 2077007721974484992, 2077007722058371072, 4, '仓库达成合作', '废弃仓库内,一张木桌两把椅子,小明和老陈对面而坐。桌上摆着两盒烟,老陈抽出一根点燃:“我信你一次,但怎么弄?”小明拿起笔在纸上画示意图:“用电话线连电脑,搞个库存信息库。我写程序。”老陈皱眉:“钱呢?”小明:“我先拉商户,您出设备。”老陈抖了抖烟灰,沉默几秒后点头。小明转头看向墙上的标语“工业学大庆”,低声:“这次一定要成。”', 'daily', '平视中景', '缓推', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"桌子一侧\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"桌子另一侧\"}]', '废弃仓库_日', '{\"panel_number\":4,\"scene_summary\":\"废弃仓库内,一张木桌两把椅子,小明和老陈对面而坐。桌上摆着两盒烟,老陈抽出一根点燃:“我信你一次,但怎么弄?”小明拿起笔在纸上画示意图:“用电话线连电脑,搞个库存信息库。我写程序。”老陈皱眉:“钱呢?”小明:“我先拉商户,您出设备。”老陈抖了抖烟灰,沉默几秒后点头。小明转头看向墙上的标语“工业学大庆”,低声:“这次一定要成。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"桌子一侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"桌子另一侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:废弃仓库_日,重新交代人物位置、朝向与环境\",\"end_state\":\"老陈点头,小明目光从墙上标语收回,表情坚定\",\"continuity_action\":\"小明视线从标语移回老陈,准备下一步计划\",\"spatial_anchor\":\"桌子位于仓库中央,小明坐画面左侧,老陈坐右侧,后面墙上有标语,水泥地面空旷\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":4,\"segment_number\":1,\"segment_goal\":\"小明与老陈在仓库达成合作意向\",\"segment_result\":\"老陈决定信任小明,提供设备支持\",\"bridge_in\":\"两人对面而坐,桌上两盒烟,老陈面露犹豫\",\"bridge_out\":\"老陈点头,小明看向墙上标语,低声坚定信念\",\"beat_type\":\"action\",\"narrative_cause\":\"老陈对方案产生好奇并主动与小明对话\",\"character_goal\":\"说服老陈提供设备支持\",\"story_action\":\"小明画图解释技术方案,提出资源分工\",\"story_result\":\"老陈同意合作,小明获得初步资源\",\"next_hook\":\"下一镜头展示工厂办公室内新进展与危机\"}', '老陈和小明对面坐着。桌上摆着两盒烟。老陈:“我信你一次,但怎么弄?”小明在纸上画:“用电话线连电脑,搞个库存信息库。我写程序。”老陈:“钱呢?”小明:“我先拉商户,您出设备。”老陈犹豫后点头。小明看着仓库墙上的“工业学大庆”,低声:“这次一定要成。”', '小明和老陈在废弃仓库的旧木桌对面而坐,桌上两盒烟和散落的纸张;老陈夹烟沉思,小明执笔画示意图;背景墙上红色大字「工业学大庆」,高窗透入光线。', 8, '年轻男子和中年男子对面坐在木桌两侧,桌上摆着两盒烟。中年男子抽出一根点燃,年轻男子拿起笔在纸上画图并说话。中年男子皱眉问话,年轻男子抬头回答,伸手指向纸面。中年男子抖烟灰后点头。年轻男子转头看向墙上的标语,低声自语。镜头从两人的中景缓缓推近至年轻男子的脸部,然后固定。', NULL, 'd4665ee140f34673914292855d389d38', 'generating', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:39:20', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643874115584, 2077007721974484992, 2077007722058371072, 5, '工商检查危机', '工厂办公室内,木制办公桌上转盘电话响起。老陈拿起听筒,脸色逐渐变白,手指捏紧话筒。挂断后他转身对旁边椅子上的小明说:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看桌上的账本,抬起头:“我们走的是信息服务,不是倒卖。”老陈叹气:“没人认可这个。”小明合上账本,直视老陈:“明天我来说。”', 'suspense', '平视近景', '缓推', '[{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"办公桌后\"},{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"办公桌旁椅子\"}]', '工厂办公室_日', '{\"panel_number\":5,\"scene_summary\":\"工厂办公室内,木制办公桌上转盘电话响起。老陈拿起听筒,脸色逐渐变白,手指捏紧话筒。挂断后他转身对旁边椅子上的小明说:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看桌上的账本,抬起头:“我们走的是信息服务,不是倒卖。”老陈叹气:“没人认可这个。”小明合上账本,直视老陈:“明天我来说。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"低调硬光,保留阴影层次\"},\"characters\":[{\"name\":\"老陈\",\"screen_position\":\"办公桌后\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"小明\",\"screen_position\":\"办公桌旁椅子\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中浅景深,保留环境压迫感\",\"color_tone\":\"冷暗色调\"}', '[{\"name\":\"老陈\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感\"},{\"name\":\"小明\",\"acting\":\"控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感\"}]', '{\"start_state\":\"新场景建立:工厂办公室_日,重新交代人物位置、朝向与环境\",\"end_state\":\"小明合上账本,直视老陈,表情自信;老陈眉头紧锁但不再反驳\",\"continuity_action\":\"小明视线坚定,老陈目光仍带忧虑但转向信任\",\"spatial_anchor\":\"老陈位于画面左侧办公桌后,小明在右侧椅子,窗外可见厂区烟囱,文件柜靠墙\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":5,\"segment_number\":1,\"segment_goal\":\"工厂办公室内老陈接到工商查处的电话,小明提出应对\",\"segment_result\":\"小明决定亲自去面对工商检查\",\"bridge_in\":\"办公室电话铃响打破平静,老陈接听表情变化\",\"bridge_out\":\"老陈看向小明,小明坚定地说“明天我来说”\",\"beat_type\":\"trigger\",\"narrative_cause\":\"老陈同意合作,小明获得初步资源\",\"character_goal\":\"让老陈相信自己能解决危机\",\"story_action\":\"小明解释模式合法性,并主动承担交涉责任\",\"story_result\":\"老陈部分安心,小明获得应对危机的授权\",\"next_hook\":\"下一镜头展示出租屋内团队内部争吵与统一思想\"}', '办公桌上电话响起。老陈接起,脸色变白。挂断后对小明:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看账本:“我们走的是信息服务,不是倒卖。”老陈:“没人认可这个。”小明:“明天我来说。”', '工厂办公室内,老陈坐在办公桌后接电话,脸色凝重;小明坐在侧面椅子上翻看账本,窗外可见工厂烟囱;桌上转盘电话和文件散落。', 7, '办公桌上黑色转盘电话响起。中年男子坐在桌后拿起听筒,脸色逐渐变白,手指捏紧话筒。他挂断后转身对旁边的年轻男子说话。年轻男子快速翻看账本,抬起头直视对方回话,然后合上账本。镜头从电话特写缓慢推近至中年男子面部,再缓拉到两人中景。', 'https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260714203917-gnb2m-0.mp4', '61edb37e979a426f9e8272786243c243', 'done', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:41:08', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643920252928, 2077007721974484992, 2077007722058371072, 6, '出租屋统一思想', '狭小出租屋,灯光昏暗。两个年轻团队成员拍桌站起:“不干了,风险太大!”老陈坐在床边沉默,低头看地面。小明从墙角拿起一块黑板放在桌上,画出一条时间轴,标注1985年,指向电脑图标。他转头对大家说:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头,皱眉问:“你哪来的这些?”小明停顿一下:“梦里学的。”', 'emotion', '平视近景转特写', '缓推加环绕', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"桌边站立\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"床边坐着\"}]', '出租屋_夜', '{\"panel_number\":6,\"scene_summary\":\"狭小出租屋,灯光昏暗。两个年轻团队成员拍桌站起:“不干了,风险太大!”老陈坐在床边沉默,低头看地面。小明从墙角拿起一块黑板放在桌上,画出一条时间轴,标注1985年,指向电脑图标。他转头对大家说:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头,皱眉问:“你哪来的这些?”小明停顿一下:“梦里学的。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"柔和侧光,突出面部情绪\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"桌边站立\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"床边坐着\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"浅景深,突出角色表情\",\"color_tone\":\"克制柔和色调\"}', '[{\"name\":\"小明\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:出租屋_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"老陈抬头看向小明,两个年轻人停止争吵望向黑板,小黑板上画有时间轴和“电脑”字样\",\"continuity_action\":\"小明手指还指着黑板上的“技术咨询”,等待异议\",\"spatial_anchor\":\"画面中央是一张桌子,黑板靠在桌上,小明站在桌子左侧画黑板,老陈坐在右侧床边,两个年轻人站在桌子远端\",\"present_characters\":[\"小明\",\"老陈\",\"两个年轻人(群众)\"],\"scene_number\":6,\"segment_number\":1,\"segment_goal\":\"出租屋内团队成员因风险而退缩,小明用时间轴规划说服大家\",\"segment_result\":\"老陈和小明达成共识,团队重拾信心\",\"bridge_in\":\"出租屋内两个年轻人拍桌起身,气氛紧张\",\"bridge_out\":\"老陈抬头看小明,眼神中带着疑惑与信任\",\"beat_type\":\"decision\",\"narrative_cause\":\"老陈部分安心,小明获得应对危机的授权\",\"character_goal\":\"稳定团队,统一认知\",\"story_action\":\"小明画出时间轴,解释信息差优势和应对策略\",\"story_result\":\"团队情绪稳定,老陈虽疑惑但接受解释\",\"next_hook\":\"下一镜头展示百货大楼门口工商检查的结果\"}', '团队成员(两个年轻人)拍桌:“不干了,风险太大!”老陈沉默。小明拿出一块黑板,画出时间轴:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头:“你哪来的这些?”小明:“梦里学的。”', '出租屋内,小明站在桌子左侧画黑板,黑板上粉笔写着时间轴和「电脑」字样;老陈坐在床边抬头看;两个年轻人站在桌子远端,表情从愤怒转为倾听;昏黄灯光下气氛紧张。', 10, '狭小出租屋内,两个年轻人拍桌站起情绪激动。中年男子坐在床边低头沉默。年轻男子从墙角拿起一块黑板放在桌上,用粉笔画出一条时间轴,标注1985和电脑图标,转头对大家说话并指向黑板。中年男子抬起头皱眉问话,年轻男子停顿后回答。镜头从全景缓慢推近至年轻男子面部特写,然后环绕拍摄黑板上的时间轴。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008643978973184, 2077007721974484992, 2077007722058371072, 7, '工商检查通过', '百货大楼门口台阶上,工商人员和穿藏青色西装的中年经理站在一起。小明从公文包拿出一叠表格递过去:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看后递给旁边的百货经理。经理推了推眼镜细看,低声问小明:“这方法能帮我们清库存?”小明点头:“三天见效。”经理转向工商人员点了点头。工商人员收起表格:“先观察。”', 'daily', '平视中景', '轻微跟随', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"台阶上右侧\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"台阶上小明身后\"},{\"name\":\"百货经理\",\"appearance\":\"初始形象\",\"slot\":\"台阶上中央\"}]', '百货大楼门口_日', '{\"panel_number\":7,\"scene_summary\":\"百货大楼门口台阶上,工商人员和穿藏青色西装的中年经理站在一起。小明从公文包拿出一叠表格递过去:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看后递给旁边的百货经理。经理推了推眼镜细看,低声问小明:“这方法能帮我们清库存?”小明点头:“三天见效。”经理转向工商人员点了点头。工商人员收起表格:“先观察。”\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"自然柔光,肤色与环境协调\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"台阶上右侧\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"台阶上小明身后\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"百货经理\",\"screen_position\":\"台阶上中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"中等景深,兼顾角色与环境\",\"color_tone\":\"自然统一色调\"}', '[{\"name\":\"小明\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"百货经理\",\"acting\":\"保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:百货大楼门口_日,重新交代人物位置、朝向与环境\",\"end_state\":\"工商人员收起表格,经理向小明点头,小明露出微笑\",\"continuity_action\":\"小明视线跟随工商人员离开,准备下一步行动\",\"spatial_anchor\":\"百货大楼正门台阶为背景,工商人员在中央偏左,经理在左侧,小明在右侧,老陈在小明后方半步\",\"present_characters\":[\"小明\",\"老陈\",\"百货经理\",\"工商人员(群众)\"],\"scene_number\":7,\"segment_number\":1,\"segment_goal\":\"工商检查现场,小明用订单记录证明信息服务合法性,获得百货经理认可\",\"segment_result\":\"工商同意观察,项目得以继续\",\"bridge_in\":\"百货大楼门口,工商人员和小明、老陈站立,气氛凝重\",\"bridge_out\":\"工商人员收起表格,经理点头,危机解除\",\"beat_type\":\"result\",\"narrative_cause\":\"团队情绪稳定,老陈虽疑惑但接受解释\",\"character_goal\":\"用事实凭证打消工商疑虑\",\"story_action\":\"小明出示订单记录并解释运营模式\",\"story_result\":\"工商暂缓处理,项目获得官方默许\",\"next_hook\":\"下一镜头展示庆功礼堂的成果与小明的情感时刻\"}', '工商人员和小明、老陈站在门口。小明拿出一份表格:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看,递给旁边一个戴眼镜的中年人(百货公司经理)。经理看了后低声:“这方法能帮我们清库存?”小明:“三天见效。”经理点头。工商人员收起表格:“先观察。”', '小明站在百货大楼门口台阶上,从公文包中拿出表格递给工商人员;老陈站在他身后;旁边穿藏青色西装的百货经理低头查看表格;阳光照亮大楼立面。', 8, '百货大楼门口台阶上,工商人员和穿西装的中年男子站在一起。年轻男子从公文包拿出一叠表格递出并说话。工商人员翻看后递给旁边的经理。经理推了推眼镜细看,低声问年轻男子,年轻男子点头回答。经理转向工商人员点头。工商人员收起表格。镜头平视跟随年轻男子的动作,使用越肩视角展示表格内容。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008644041887744, 2077007721974484992, 2077007722058371072, 8, '厂庆宣言互联网愿景', '厂庆礼堂,红灯笼和横幅装饰,舞台上麦克风前,老陈对着台下说:“感谢小明的信息平台,咱们工厂活了。”台下工人鼓掌。小明站在侧台阴影里,手伸进口袋摸到发黄的现代身份证,他看着灯光下兴奋的面孔,默默将身份证撕碎。主持人喊:“请小明同志讲话!”小明走上舞台中央,拿起话筒,停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。', 'epic', '大远景转特写', '俯拍后缓降推近', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"舞台上中央\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"舞台侧边\"}]', '厂庆礼堂_夜', '{\"panel_number\":8,\"scene_summary\":\"厂庆礼堂,红灯笼和横幅装饰,舞台上麦克风前,老陈对着台下说:“感谢小明的信息平台,咱们工厂活了。”台下工人鼓掌。小明站在侧台阴影里,手伸进口袋摸到发黄的现代身份证,他看着灯光下兴奋的面孔,默默将身份证撕碎。主持人喊:“请小明同志讲话!”小明走上舞台中央,拿起话筒,停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"大范围自然光,突出空间规模\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"舞台上中央\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"舞台侧边\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"深景深,清晰展现环境规模\",\"color_tone\":\"宏大通透色调\"}', '[{\"name\":\"小明\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:厂庆礼堂_夜,重新交代人物位置、朝向与环境\",\"end_state\":\"小明站在舞台中央举起话筒,台下工人站立鼓掌,老陈在侧台微笑\",\"continuity_action\":\"小明视线扫过全场,准备下台开始新阶段\",\"spatial_anchor\":\"舞台在画面中央靠上,台下长凳坐满工人,小明位于舞台中央,老陈在舞台左侧靠边\",\"present_characters\":[\"小明\",\"老陈\",\"工人(群众)\",\"主持人(群众)\"],\"scene_number\":8,\"segment_number\":1,\"segment_goal\":\"厂庆礼堂中老陈表扬小明,小明撕碎现代身份证并发表讲话\",\"segment_result\":\"小明公开宣告互联网未来愿景,全体工人热烈鼓掌\",\"bridge_in\":\"礼堂内工人坐满长凳,老陈在台上讲话\",\"bridge_out\":\"掌声雷动中,小明眼神坚定望向未来\",\"beat_type\":\"action\",\"narrative_cause\":\"工商暂缓处理,项目获得官方默许\",\"character_goal\":\"激励团队并宣告新方向\",\"story_action\":\"小明撕碎现代身份证,上台演讲\",\"story_result\":\"全体工人被鼓舞,小明确立新一代领导地位\",\"next_hook\":\"下一镜头展示清晨新挂牌和下一步计划\"}', '工人坐满长凳。老陈上台:“感谢小明的信息平台,咱们工厂活了。”台下鼓掌。小明站在侧台,看着灯光下兴奋的面孔。他手摸到口袋里的现代身份证——早已发黄。他默默撕碎。主持人:“请小明同志讲话!”小明上前,拿着话筒停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。', '厂庆礼堂内,舞台中央老陈握着话筒讲话,台下工人鼓掌;小明站在侧幕阴影中,手中捏着撕碎的身份证,灯光照亮舞台,红灯笼和横幅装饰,气氛热烈。', 10, '礼堂内工人坐满长凳,舞台中央中年男子在讲话,台下鼓掌。年轻男子站在侧台阴影中,手伸进口袋掏出一张发黄的身份证,默默撕碎。主持人喊话,年轻男子走上舞台中央,拿起话筒停顿三秒后说话,台下掌声雷动。镜头从礼堂大远景俯拍开始,然后缓缓下降并推近至年轻男子面部特写。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); +INSERT INTO `short_drama_storyboard` (`id`, `project_id`, `script_id`, `scene_no`, `scene_title`, `scene_text`, `scene_type`, `shot_type`, `camera_move`, `characters_json`, `location_name`, `photography_rules`, `acting_notes`, `continuity_json`, `source_text`, `image_prompt`, `duration_seconds`, `video_prompt`, `video_url`, `video_id`, `video_status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077008644113190912, 2077007721974484992, 2077007722058371072, 9, '新征程启程', '清晨,阳光照在工厂大门新挂牌上,金边字体“华夏信息服务中心”。小明推着自行车走出来,老陈从后面追上:“下一步?”小明跨上车:“跑遍全国,建数据库。”老陈笑着摇头:“疯了。”小明回以笑容:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。', 'epic', '大远景', '缓拉升起', '[{\"name\":\"小明\",\"appearance\":\"初始形象\",\"slot\":\"工厂大门前\"},{\"name\":\"老陈\",\"appearance\":\"初始形象\",\"slot\":\"门口台阶上\"}]', '工厂大门_晨', '{\"panel_number\":9,\"scene_summary\":\"清晨,阳光照在工厂大门新挂牌上,金边字体“华夏信息服务中心”。小明推着自行车走出来,老陈从后面追上:“下一步?”小明跨上车:“跑遍全国,建数据库。”老陈笑着摇头:“疯了。”小明回以笑容:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。\",\"lighting\":{\"direction\":\"根据场景主光方向保持连续\",\"quality\":\"大范围自然光,突出空间规模\"},\"characters\":[{\"name\":\"小明\",\"screen_position\":\"工厂大门前\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"},{\"name\":\"老陈\",\"screen_position\":\"门口台阶上\",\"posture\":\"保持符合当前动作的自然姿态\",\"facing\":\"面向动作目标或对话对象\"}],\"depth_of_field\":\"深景深,清晰展现环境规模\",\"color_tone\":\"宏大通透色调\"}', '[{\"name\":\"小明\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"},{\"name\":\"老陈\",\"acting\":\"动作沉稳有力量,视线跟随环境或对手变化,保持画面张力,按前段、中段、后段完成三个连续表演节拍\"}]', '{\"start_state\":\"新场景建立:工厂大门_晨,重新交代人物位置、朝向与环境\",\"end_state\":\"小明骑车远去的背影在街道尽头,烟囱冒烟,晨光笼罩\",\"continuity_action\":\"小明继续蹬车,老陈目送,街道向前延伸\",\"spatial_anchor\":\"工厂大门在画面左侧,新挂牌醒目;小明骑车向右远去,街道两旁有工厂围墙和烟囱;老陈站在门口台阶上\",\"present_characters\":[\"小明\",\"老陈\"],\"scene_number\":9,\"segment_number\":1,\"segment_goal\":\"新挂牌的清晨,小明与老陈对话后骑车远去,象征新征程\",\"segment_result\":\"小明骑车远去,烟囱冒烟,新的一天开始\",\"bridge_in\":\"阳光照在新挂牌上,小明推车出现\",\"bridge_out\":\"镜头定格在远去的背影和工厂烟囱,留下开放节奏\",\"beat_type\":\"result\",\"narrative_cause\":\"全体工人被鼓舞,小明确立新一代领导地位\",\"character_goal\":\"确定下一阶段行动计划\",\"story_action\":\"小明跨上自行车说出全国数据库计划\",\"story_result\":\"小明开始实际行动,留下奋斗的背影\",\"next_hook\":\"故事结束,无下一镜头\"}', '阳光照在新挂牌上:“华夏信息服务中心”。小明推着自行车出来,老陈追上:“下一步?”小明:“跑遍全国,建数据库。”老陈笑:“疯了。”小明跨上车:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟。', '清晨阳光照射工厂大门新挂牌「华夏信息服务中心」,小明推自行车站在门口,老陈站在台阶上微笑;远处烟囱冒烟,天空泛鱼肚白,街道延伸向远方。', 10, '清晨阳光照在工厂大门新挂牌上,金色字体「华夏信息服务中心」。年轻男子推自行车从门内走出,中年男子从后面追上,两人对话。年轻男子跨上自行车,蹬车远去,街道两旁的工厂烟囱开始冒烟,晨光中骑车背影越来越小。镜头从新挂牌特写缓缓拉远并升起,展现工厂全貌和远去的背影。', NULL, NULL, 'pending', -1, -1, '2026-07-14 20:33:44', -1, '2026-07-14 20:33:44', 0); -- ---------------------------- -- Table structure for sj_distributed_lock diff --git a/docs/script/sql/snail_job_mysql.sql b/docs/script/sql/snail_job_mysql.sql new file mode 100644 index 00000000..322cccae --- /dev/null +++ b/docs/script/sql/snail_job_mysql.sql @@ -0,0 +1,545 @@ +SET NAMES utf8mb4; + +DROP DATABASE IF EXISTS snail_job; +CREATE DATABASE snail_job; +USE snail_job; + +CREATE TABLE `sj_namespace` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` varchar(64) NOT NULL COMMENT '名称', + `unique_id` varchar(64) NOT NULL COMMENT '唯一id', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_name` (`name`), + UNIQUE KEY `uk_unique_id` (`unique_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='命名空间'; + +INSERT INTO `sj_namespace` (`id`, `name`, `unique_id`, `create_dt`, `update_dt`, `deleted`) +VALUES (1, 'Default', '764d604ec6fc45f68cd92514c40e9e1a', now(), now(), 0); + + +CREATE TABLE `sj_group_config` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL DEFAULT '' COMMENT '组名称', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '组描述', + `token` varchar(64) NOT NULL DEFAULT 'SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT' COMMENT 'token', + `group_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '组状态 0、未启用 1、启用', + `version` int(11) NOT NULL COMMENT '版本号', + `group_partition` int(11) NOT NULL COMMENT '分区', + `id_generator_mode` tinyint(4) NOT NULL DEFAULT 1 COMMENT '唯一id生成模式 默认号段模式', + `init_scene` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否初始化场景 0:否 1:是', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='组配置' +; + +CREATE TABLE `sj_notify_config` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `notify_name` varchar(64) NOT NULL DEFAULT '' COMMENT '通知名称', + `system_task_type` tinyint(4) NOT NULL DEFAULT 3 COMMENT '任务类型 1. 重试任务 2. 重试回调 3、JOB任务 4、WORKFLOW任务', + `notify_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '通知状态 0、未启用 1、启用', + `recipient_ids` varchar(128) NOT NULL COMMENT '接收人id列表', + `notify_threshold` int(11) NOT NULL DEFAULT 0 COMMENT '通知阈值', + `notify_scene` tinyint(4) NOT NULL DEFAULT 0 COMMENT '通知场景', + `rate_limiter_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '限流状态 0、未启用 1、启用', + `rate_limiter_threshold` int(11) NOT NULL DEFAULT 0 COMMENT '每秒限流阈值', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name_scene_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='通知配置' +; + +CREATE TABLE `sj_notify_recipient` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `recipient_name` varchar(64) NOT NULL COMMENT '接收人名称', + `notify_type` tinyint(4) NOT NULL DEFAULT 0 COMMENT '通知类型 1、钉钉 2、邮件 3、企业微信 4 飞书 5 webhook', + `notify_attribute` varchar(512) NOT NULL COMMENT '配置属性', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id` (`namespace_id`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='告警通知接收人' +; + +CREATE TABLE `sj_retry_dead_letter` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `group_id` bigint(20) NOT NULL COMMENT '组Id', + `scene_name` varchar(64) NOT NULL COMMENT '场景名称', + `scene_id` bigint(20) NOT NULL COMMENT '场景ID', + `idempotent_id` varchar(64) NOT NULL COMMENT '幂等id', + `biz_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务编号', + `executor_name` varchar(512) NOT NULL DEFAULT '' COMMENT '执行器名称', + -- jackson 兼容历史数据 预计1.8.0默认改为fury + `serializer_name` varchar(32) NOT NULL DEFAULT 'jackson' COMMENT '执行方法参数序列化器名称', + `args_str` text NOT NULL COMMENT '执行方法参数', + `ext_attrs` text NOT NULL COMMENT '扩展字段', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name_scene_name` (`namespace_id`, `group_name`, `scene_name`), + KEY `idx_idempotent_id` (`idempotent_id`), + KEY `idx_biz_no` (`biz_no`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='死信队列表' +; + +CREATE TABLE `sj_retry` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `group_id` bigint(20) NOT NULL COMMENT '组Id', + `scene_name` varchar(64) NOT NULL COMMENT '场景名称', + `scene_id` bigint(20) NOT NULL COMMENT '场景ID', + `idempotent_id` varchar(64) NOT NULL COMMENT '幂等id', + `biz_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务编号', + `executor_name` varchar(512) NOT NULL DEFAULT '' COMMENT '执行器名称', + `args_str` text NOT NULL COMMENT '执行方法参数', + `ext_attrs` text NOT NULL COMMENT '扩展字段', + -- jackson 兼容历史数据 预计1.8.0默认改为fury + `serializer_name` varchar(32) NOT NULL DEFAULT 'jackson' COMMENT '执行方法参数序列化器名称', + `next_trigger_at` bigint(13) NOT NULL COMMENT '下次触发时间', + `retry_count` int(11) NOT NULL DEFAULT 0 COMMENT '重试次数', + `retry_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '重试状态 0、重试中 1、成功 2、最大重试次数', + `task_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '任务类型 1、重试数据 2、回调数据', + `bucket_index` int(11) NOT NULL DEFAULT 0 COMMENT 'bucket', + `parent_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '父节点id', + `deleted` bigint(20) NOT NULL DEFAULT 0 COMMENT '逻辑删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_biz_no` (`biz_no`), + KEY `idx_idempotent_id` (`idempotent_id`), + KEY `idx_retry_status_bucket_index` (`retry_status`, `bucket_index`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_create_dt` (`create_dt`), + UNIQUE KEY `uk_scene_tasktype_idempotentid_deleted` (`scene_id`, `task_type`, `idempotent_id`, `deleted`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='重试信息表' +; + +CREATE TABLE `sj_retry_task` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `scene_name` varchar(64) NOT NULL COMMENT '场景名称', + `retry_id` bigint(20) NOT NULL COMMENT '重试信息Id', + `ext_attrs` text NOT NULL COMMENT '扩展字段', + `task_status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '重试状态', + `task_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '任务类型 1、重试数据 2、回调数据', + `operation_reason` tinyint(4) NOT NULL DEFAULT 0 COMMENT '操作原因', + `client_info` varchar(128) DEFAULT NULL COMMENT '客户端地址 clientId#ip:port', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_group_name_scene_name` (`namespace_id`, `group_name`, `scene_name`), + KEY `task_status` (`task_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_retry_id` (`retry_id`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='重试任务表' +; + +CREATE TABLE `sj_retry_task_log_message` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `retry_id` bigint(20) NOT NULL COMMENT '重试信息Id', + `retry_task_id` bigint(20) NOT NULL COMMENT '重试任务Id', + `message` longtext NOT NULL COMMENT '异常信息', + `log_num` int(11) NOT NULL DEFAULT 1 COMMENT '日志数量', + `real_time` bigint(13) NOT NULL DEFAULT 0 COMMENT '上报时间', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name_retry_task_id` (`namespace_id`, `group_name`, `retry_task_id`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='任务调度日志信息记录表' +; + +CREATE TABLE `sj_retry_scene_config` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `scene_name` varchar(64) NOT NULL COMMENT '场景名称', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `scene_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '组状态 0、未启用 1、启用', + `max_retry_count` int(11) NOT NULL DEFAULT 5 COMMENT '最大重试次数', + `back_off` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1、默认等级 2、固定间隔时间 3、CRON 表达式', + `trigger_interval` varchar(16) NOT NULL DEFAULT '' COMMENT '间隔时长', + `notify_ids` varchar(128) NOT NULL DEFAULT '' COMMENT '通知告警场景配置id列表', + `deadline_request` bigint(20) unsigned NOT NULL DEFAULT 60000 COMMENT 'Deadline Request 调用链超时 单位毫秒', + `executor_timeout` int(11) unsigned NOT NULL DEFAULT 5 COMMENT '任务执行超时时间,单位秒', + `route_key` tinyint(4) NOT NULL DEFAULT 4 COMMENT '路由策略', + `block_strategy` tinyint(4) NOT NULL DEFAULT 1 COMMENT '阻塞策略 1、丢弃 2、覆盖 3、并行', + `cb_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '回调状态 0、不开启 1、开启', + `cb_trigger_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1、默认等级 2、固定间隔时间 3、CRON 表达式', + `cb_max_count` int(11) NOT NULL DEFAULT 16 COMMENT '回调的最大执行次数', + `cb_trigger_interval` varchar(16) NOT NULL DEFAULT '' COMMENT '回调的最大执行次数', + `owner_id` bigint(20) NULL DEFAULT NULL COMMENT '负责人id', + `labels` varchar(512) NULL DEFAULT '' COMMENT '标签', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_namespace_id_group_name_scene_name` (`namespace_id`, `group_name`, `scene_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='场景配置' +; + +CREATE TABLE `sj_server_node` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `host_id` varchar(64) NOT NULL COMMENT '主机id', + `host_ip` varchar(64) NOT NULL COMMENT '机器ip', + `host_port` int(16) NOT NULL COMMENT '机器端口', + `expire_at` datetime NOT NULL COMMENT '过期时间', + `node_type` tinyint(4) NOT NULL COMMENT '节点类型 1、客户端 2、是服务端', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `labels` varchar(512) NULL DEFAULT '' COMMENT '标签', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`), + KEY `idx_expire_at_node_type` (`expire_at`, `node_type`), + UNIQUE KEY `uk_host_id_host_ip` (`host_id`, `host_ip`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='服务器节点' +; + +CREATE TABLE `sj_distributed_lock` +( + `name` varchar(64) NOT NULL COMMENT '锁名称', + `lock_until` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '锁定时长', + `locked_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '锁定时间', + `locked_by` varchar(255) NOT NULL COMMENT '锁定者', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='锁定表' +; + +CREATE TABLE `sj_system_user` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `username` varchar(64) NOT NULL COMMENT '账号', + `password` varchar(128) NOT NULL COMMENT '密码', + `role` tinyint(4) NOT NULL DEFAULT 0 COMMENT '角色:1-普通用户、2-管理员', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`) USING BTREE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='系统用户表'; + +-- pwd: admin +INSERT INTO `sj_system_user` (username, password, role) +VALUES ('admin', '465c194afb65670f38322df087f0a9bb225cc257e43eb4ac5a0c98ef5b3173ac', 2); + +CREATE TABLE `sj_system_user_permission` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `system_user_id` bigint(20) NOT NULL COMMENT '系统用户id', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_namespace_id_group_name_system_user_id` (`namespace_id`, `group_name`, `system_user_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='系统用户权限表'; + +-- 分布式调度DDL +CREATE TABLE `sj_job` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `job_name` varchar(64) NOT NULL COMMENT '名称', + `args_str` text DEFAULT NULL COMMENT '执行方法参数', + `args_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '参数类型 ', + `next_trigger_at` bigint(13) NOT NULL COMMENT '下次触发时间', + `job_status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '任务状态 0、关闭、1、开启', + `task_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '任务类型 1、集群 2、广播 3、切片', + `route_key` tinyint(4) NOT NULL DEFAULT 4 COMMENT '路由策略', + `executor_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '执行器类型', + `executor_info` varchar(255) DEFAULT NULL COMMENT '执行器名称', + `trigger_type` tinyint(4) NOT NULL COMMENT '触发类型 1.CRON 表达式 2. 固定时间', + `trigger_interval` varchar(255) NOT NULL COMMENT '间隔时长', + `block_strategy` tinyint(4) NOT NULL DEFAULT 1 COMMENT '阻塞策略 1、丢弃 2、覆盖 3、并行 4、恢复', + `executor_timeout` int(11) NOT NULL DEFAULT 0 COMMENT '任务执行超时时间,单位秒', + `max_retry_times` int(11) NOT NULL DEFAULT 0 COMMENT '最大重试次数', + `parallel_num` int(11) NOT NULL DEFAULT 1 COMMENT '并行数', + `retry_interval` int(11) NOT NULL DEFAULT 0 COMMENT '重试间隔(s)', + `bucket_index` int(11) NOT NULL DEFAULT 0 COMMENT 'bucket', + `resident` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否是常驻任务', + `notify_ids` varchar(128) NOT NULL DEFAULT '' COMMENT '通知告警场景配置id列表', + `owner_id` bigint(20) NULL DEFAULT NULL COMMENT '负责人id', + `labels` varchar(512) NULL DEFAULT '' COMMENT '标签', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`), + KEY `idx_job_status_bucket_index` (`job_status`, `bucket_index`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='任务信息'; + +CREATE TABLE `sj_job_log_message` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `job_id` bigint(20) NOT NULL COMMENT '任务信息id', + `task_batch_id` bigint(20) NOT NULL COMMENT '任务批次id', + `task_id` bigint(20) NOT NULL COMMENT '调度任务id', + `message` longtext NOT NULL COMMENT '调度信息', + `log_num` int(11) NOT NULL DEFAULT 1 COMMENT '日志数量', + `real_time` bigint(13) NOT NULL DEFAULT 0 COMMENT '上报时间', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_task_batch_id_task_id` (`task_batch_id`, `task_id`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='调度日志'; + +CREATE TABLE `sj_job_task` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `job_id` bigint(20) NOT NULL COMMENT '任务信息id', + `task_batch_id` bigint(20) NOT NULL COMMENT '调度任务id', + `parent_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '父执行器id', + `task_status` tinyint NOT NULL DEFAULT 0 COMMENT '执行的状态 0、失败 1、成功', + `retry_count` int(11) NOT NULL DEFAULT 0 COMMENT '重试次数', + `mr_stage` tinyint DEFAULT NULL COMMENT '动态分片所处阶段 1:map 2:reduce 3:mergeReduce', + `leaf` tinyint NOT NULL DEFAULT '1' COMMENT '叶子节点', + `task_name` varchar(255) NOT NULL DEFAULT '' COMMENT '任务名称', + `client_info` varchar(128) DEFAULT NULL COMMENT '客户端地址 clientId#ip:port', + `wf_context` text DEFAULT NULL COMMENT '工作流全局上下文', + `result_message` text NOT NULL COMMENT '执行结果', + `args_str` text DEFAULT NULL COMMENT '执行方法参数', + `args_type` tinyint NOT NULL DEFAULT 1 COMMENT '参数类型 ', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_task_batch_id_task_status` (`task_batch_id`, `task_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='任务实例'; + +CREATE TABLE `sj_job_task_batch` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `job_id` bigint(20) NOT NULL COMMENT '任务id', + `workflow_node_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '工作流节点id', + `parent_workflow_node_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '工作流任务父批次id', + `workflow_task_batch_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '工作流任务批次id', + `task_batch_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '任务批次状态 0、失败 1、成功', + `operation_reason` tinyint(4) NOT NULL DEFAULT 0 COMMENT '操作原因', + `execution_at` bigint(13) NOT NULL DEFAULT 0 COMMENT '任务执行时间', + `system_task_type` tinyint(4) NOT NULL DEFAULT 3 COMMENT '任务类型 3、JOB任务 4、WORKFLOW任务', + `parent_id` varchar(64) NOT NULL DEFAULT '' COMMENT '父节点', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_job_id_task_batch_status` (`job_id`, `task_batch_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`), + KEY `idx_workflow_task_batch_id_workflow_node_id` (`workflow_task_batch_id`, `workflow_node_id`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='任务批次'; + +CREATE TABLE `sj_job_summary` +( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` VARCHAR(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '组名称', + `business_id` bigint NOT NULL COMMENT '业务id (job_id或workflow_id)', + `system_task_type` tinyint(4) NOT NULL DEFAULT 3 COMMENT '任务类型 3、JOB任务 4、WORKFLOW任务', + `trigger_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '统计时间', + `success_num` int NOT NULL DEFAULT 0 COMMENT '执行成功-日志数量', + `fail_num` int NOT NULL DEFAULT 0 COMMENT '执行失败-日志数量', + `fail_reason` varchar(512) NOT NULL DEFAULT '' COMMENT '失败原因', + `stop_num` int NOT NULL DEFAULT 0 COMMENT '执行失败-日志数量', + `stop_reason` varchar(512) NOT NULL DEFAULT '' COMMENT '失败原因', + `cancel_num` int NOT NULL DEFAULT 0 COMMENT '执行失败-日志数量', + `cancel_reason` varchar(512) NOT NULL DEFAULT '' COMMENT '失败原因', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name_business_id` (`namespace_id`, `group_name`, business_id), + UNIQUE KEY `uk_trigger_at_system_task_type_business_id` (`trigger_at`, `system_task_type`, `business_id`) USING BTREE +) ENGINE = InnoDB + AUTO_INCREMENT = 1 + DEFAULT CHARSET = utf8mb4 COMMENT ='DashBoard_Job'; + +CREATE TABLE `sj_retry_summary` +( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` VARCHAR(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '组名称', + `scene_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '场景名称', + `trigger_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '统计时间', + `running_num` int NOT NULL DEFAULT 0 COMMENT '重试中-日志数量', + `finish_num` int NOT NULL DEFAULT 0 COMMENT '重试完成-日志数量', + `max_count_num` int NOT NULL DEFAULT 0 COMMENT '重试到达最大次数-日志数量', + `suspend_num` int NOT NULL DEFAULT 0 COMMENT '暂停重试-日志数量', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_trigger_at` (`trigger_at`), + UNIQUE KEY `uk_scene_name_trigger_at` (`namespace_id`, `group_name`, `scene_name`, `trigger_at`) USING BTREE +) ENGINE = InnoDB + AUTO_INCREMENT = 1 + DEFAULT CHARSET = utf8mb4 COMMENT ='DashBoard_Retry'; + +CREATE TABLE `sj_workflow` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `workflow_name` varchar(64) NOT NULL COMMENT '工作流名称', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `workflow_status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '工作流状态 0、关闭、1、开启', + `trigger_type` tinyint(4) NOT NULL COMMENT '触发类型 1.CRON 表达式 2. 固定时间', + `trigger_interval` varchar(255) NOT NULL COMMENT '间隔时长', + `next_trigger_at` bigint NOT NULL COMMENT '下次触发时间', + `block_strategy` tinyint(4) NOT NULL DEFAULT 1 COMMENT '阻塞策略 1、丢弃 2、覆盖 3、并行', + `executor_timeout` int(11) NOT NULL DEFAULT 0 COMMENT '任务执行超时时间,单位秒', + `description` varchar(256) NOT NULL DEFAULT '' COMMENT '描述', + `flow_info` text DEFAULT NULL COMMENT '流程信息', + `wf_context` text DEFAULT NULL COMMENT '上下文', + `notify_ids` varchar(128) NOT NULL DEFAULT '' COMMENT '通知告警场景配置id列表', + `bucket_index` int(11) NOT NULL DEFAULT 0 COMMENT 'bucket', + `version` int(11) NOT NULL COMMENT '版本号', + `owner_id` bigint(20) NULL DEFAULT NULL COMMENT '负责人id', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='工作流'; + +CREATE TABLE `sj_workflow_node` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `node_name` varchar(64) NOT NULL COMMENT '节点名称', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `job_id` bigint(20) NOT NULL COMMENT '任务信息id', + `workflow_id` bigint(20) NOT NULL COMMENT '工作流ID', + `node_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1、任务节点 2、条件节点', + `expression_type` tinyint(4) NOT NULL DEFAULT 0 COMMENT '1、SpEl、2、Aviator 3、QL', + `fail_strategy` tinyint(4) NOT NULL DEFAULT 1 COMMENT '失败策略 1、跳过 2、阻塞', + `workflow_node_status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '工作流节点状态 0、关闭、1、开启', + `priority_level` int(11) NOT NULL DEFAULT 1 COMMENT '优先级', + `node_info` text DEFAULT NULL COMMENT '节点信息 ', + `version` int(11) NOT NULL COMMENT '版本号', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='工作流节点'; + +CREATE TABLE `sj_workflow_task_batch` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `workflow_id` bigint(20) NOT NULL COMMENT '工作流任务id', + `task_batch_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '任务批次状态 0、失败 1、成功', + `operation_reason` tinyint(4) NOT NULL DEFAULT 0 COMMENT '操作原因', + `flow_info` text DEFAULT NULL COMMENT '流程信息', + `wf_context` text DEFAULT NULL COMMENT '全局上下文', + `execution_at` bigint(13) NOT NULL DEFAULT 0 COMMENT '任务执行时间', + `ext_attrs` varchar(256) NULL DEFAULT '' COMMENT '扩展字段', + `version` int(11) NOT NULL DEFAULT 1 COMMENT '版本号', + `deleted` tinyint(4) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1、删除', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_job_id_task_batch_status` (`workflow_id`, `task_batch_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='工作流批次'; + +CREATE TABLE `sj_job_executor` +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a' COMMENT '命名空间id', + `group_name` varchar(64) NOT NULL COMMENT '组名称', + `executor_info` varchar(256) NOT NULL COMMENT '任务执行器名称', + `executor_type` varchar(3) NOT NULL COMMENT '1:java 2:python 3:go', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name` (`namespace_id`, `group_name`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE = InnoDB + AUTO_INCREMENT = 0 + DEFAULT CHARSET = utf8mb4 COMMENT ='任务执行器信息'; diff --git a/pom.xml b/pom.xml index 9acb4232..625cb379 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,7 @@ 1.27.1 1.1.0 + 3.30.2-GA 1.21.2 4.4.0 2.2.8 @@ -503,20 +504,8 @@ src/main/resources - false - - src/main/resources - - - application* - bootstrap* - banner* - - - true - diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 2a775fbc..83f56c43 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -8,8 +8,8 @@ spring.boot.admin.client: metadata: username: ${spring.boot.admin.client.username} userpassword: ${spring.boot.admin.client.password} - username: @monitor.username@ - password: @monitor.password@ + username: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} --- # mcp配置信息 mcp: diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index 234e81ab..ae8372d9 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -9,8 +9,8 @@ spring.boot.admin.client: metadata: username: ${spring.boot.admin.client.username} userpassword: ${spring.boot.admin.client.password} - username: @monitor.username@ - password: @monitor.password@ + username: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} --- # mcp配置信息 mcp: diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index d9aca934..6a053bbd 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -39,7 +39,7 @@ captcha: # 日志配置 logging: level: - org.ruoyi: @logging.level@ + org.ruoyi: ${LOGGING_LEVEL_ORG_RUOYI:info} org.springframework: warn org.mybatis.spring.mapper: error org.apache.fury: warn @@ -79,7 +79,7 @@ spring: # 国际化资源文件路径 basename: i18n/messages profiles: - active: @profiles.active@ + active: ${SPRING_PROFILES_ACTIVE:dev} # 文件上传 servlet: multipart: diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java index e8640887..a9c7c1e5 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java @@ -1,10 +1,12 @@ package org.ruoyi.common.chat.domain.bo.chat; import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; import org.ruoyi.common.chat.entity.chat.ChatModel; +import org.ruoyi.common.core.validate.AddGroup; import org.ruoyi.common.core.validate.EditGroup; import org.ruoyi.common.mybatis.core.domain.BaseEntity; @@ -28,16 +30,19 @@ public class ChatModelBo extends BaseEntity { /** * 模型分类 */ + @NotBlank(message = "模型分类不能为空", groups = { AddGroup.class, EditGroup.class }) private String category; /** * 模型名称 */ + @NotBlank(message = "模型名称不能为空", groups = { AddGroup.class, EditGroup.class }) private String modelName; /** * 模型供应商 */ + @NotBlank(message = "模型供应商不能为空", groups = { AddGroup.class, EditGroup.class }) private String providerCode; /** diff --git a/ruoyi-extend/ruoyi-monitor-admin/src/main/resources/application.yml b/ruoyi-extend/ruoyi-monitor-admin/src/main/resources/application.yml index 72447e95..b2723802 100644 --- a/ruoyi-extend/ruoyi-monitor-admin/src/main/resources/application.yml +++ b/ruoyi-extend/ruoyi-monitor-admin/src/main/resources/application.yml @@ -4,7 +4,7 @@ spring: application: name: ruoyi-monitor-admin profiles: - active: @profiles.active@ + active: ${SPRING_PROFILES_ACTIVE:dev} logging: config: classpath:logback-plus.xml @@ -13,8 +13,8 @@ logging: spring: security: user: - name: @monitor.username@ - password: @monitor.password@ + name: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} boot: admin: ui: @@ -47,5 +47,5 @@ spring.boot.admin.client: metadata: username: ${spring.boot.admin.client.username} userpassword: ${spring.boot.admin.client.password} - username: @monitor.username@ - password: @monitor.password@ + username: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} diff --git a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml index 18a851c7..67b4d4e2 100644 --- a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml +++ b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml @@ -2,7 +2,7 @@ spring: datasource: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + url: jdbc:mysql://localhost:3306/snail_job?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: root hikari: @@ -47,5 +47,5 @@ spring.boot.admin.client: metadata: username: ${spring.boot.admin.client.username} userpassword: ${spring.boot.admin.client.password} - username: @monitor.username@ - password: @monitor.password@ + username: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} diff --git a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-prod.yml b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-prod.yml index 18a851c7..4510d899 100644 --- a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-prod.yml +++ b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-prod.yml @@ -47,5 +47,5 @@ spring.boot.admin.client: metadata: username: ${spring.boot.admin.client.username} userpassword: ${spring.boot.admin.client.password} - username: @monitor.username@ - password: @monitor.password@ + username: ${MONITOR_USERNAME:ruoyi} + password: ${MONITOR_PASSWORD:123456} diff --git a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application.yml b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application.yml index ba6df45f..bdefb472 100644 --- a/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application.yml +++ b/ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application.yml @@ -7,7 +7,7 @@ spring: application: name: ruoyi-snailjob-server profiles: - active: @profiles.active@ + active: ${SPRING_PROFILES_ACTIVE:dev} web: resources: static-locations: classpath:admin/ diff --git a/ruoyi-modules/ruoyi-aiflow/pom.xml b/ruoyi-modules/ruoyi-aiflow/pom.xml index d7a147c8..04bb9b89 100644 --- a/ruoyi-modules/ruoyi-aiflow/pom.xml +++ b/ruoyi-modules/ruoyi-aiflow/pom.xml @@ -72,6 +72,21 @@ com.talanlabs avatar-generator-cat ${avatar-generator.version} + + + org.javassist + javassist + + + + + + + org.javassist + javassist + ${javassist.version} diff --git a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/CompileNode.java b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/CompileNode.java index 20960113..1e6ad419 100644 --- a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/CompileNode.java +++ b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/CompileNode.java @@ -16,6 +16,7 @@ import java.util.List; @AllArgsConstructor public class CompileNode { protected String id; + @Builder.Default protected Boolean conditional = false; /** @@ -24,5 +25,6 @@ public class CompileNode { * 2. 当前节点为条件分支节点,下游节点为多个节点,实际执行时只会执行一条 * 两种节点根据是否GraphCompileNode来区分 */ + @Builder.Default protected List nextNodes = new ArrayList<>(); } diff --git a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java index a62d4218..290245a7 100644 --- a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java +++ b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/NodeProcessResult.java @@ -15,6 +15,7 @@ import java.util.List; @NoArgsConstructor public class NodeProcessResult { + @Builder.Default private List content = new ArrayList<>(); /** @@ -25,6 +26,7 @@ public class NodeProcessResult { /** * 是否发生错误 */ + @Builder.Default private boolean error = false; /** diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java index d00de58a..8620967d 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilderTest.java @@ -26,7 +26,9 @@ class FfmpegCommandBuilderTest { List.of(source), TransitionType.NONE, 0, - AspectRatio.PORTRAIT + AspectRatio.PORTRAIT, + null, + false ); FfmpegFilterGraph graph = new FfmpegFilterGraph( "[0:v]null[v0]", diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java index 0bebc075..903843bc 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilderTest.java @@ -183,6 +183,6 @@ class FfmpegFilterGraphBuilderTest { List sources = java.util.stream.IntStream.range(0, sourceCount) .mapToObj(index -> new CompositionSource(Path.of("clip-" + index + ".mp4"), null)) .toList(); - return new CompositionSpec(sources, transition, duration, ratio); + return new CompositionSpec(sources, transition, duration, ratio, null, false); } } From 24bee53f9edaeda28988a6facad3c49af60f496d Mon Sep 17 00:00:00 2001 From: "ageerle@163.com" Date: Mon, 20 Jul 2026 16:08:48 +0800 Subject: [PATCH 14/29] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20ChitChatAgen?= =?UTF-8?q?t=20=E9=97=B2=E8=81=8A=E5=85=9C=E5=BA=95=E5=AD=90=20Agent=20?= =?UTF-8?q?=E5=B9=B6=E6=8E=A5=E5=85=A5=20supervisor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 简单任务无专业子 Agent 可用时由闲聊 Agent 兜底,避免 supervisor 空转返回空内容;通过 supervisorContext 约束仅在问候/闲聊场景路由到该 Agent。 Co-Authored-By: Claude --- .../java/org/ruoyi/agent/ChitChatAgent.java | 28 +++++++++++++++++++ .../service/chat/impl/ChatServiceFacade.java | 12 ++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ChitChatAgent.java diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ChitChatAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ChitChatAgent.java new file mode 100644 index 00000000..7ec1e29e --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ChitChatAgent.java @@ -0,0 +1,28 @@ +package org.ruoyi.agent; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +/** + * 闲聊兜底 Agent + * 负责问候、日常闲聊和常识性问答,不挂任何工具。 + * 作为 supervisor 的默认落脚点,避免简单任务无子 Agent 可用导致输出为空。 + * + * @author ageerle@163.com + */ +public interface ChitChatAgent { + + @SystemMessage(""" + 你是一个友好、自然的对话助手,负责问候、闲聊和常识性问答。 + 要求: + - 用与用户相同的语言回答,简洁自然 + - 不要编造需要实时数据或专业工具才能得到的事实 + - 如果用户的问题实际需要联网搜索、查数据库、执行技能或生成图表,直接说明这超出你的职责, + 让用户重新描述需求 + """) + @UserMessage("{{query}}") + @Agent("闲聊兜底助手:仅用于问候、日常闲聊和不需要联网搜索、数据库、技能或图表的通用问题") + String chat(@V("query") String query); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index 020f5006..e22b98a4 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -34,6 +34,7 @@ import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.ruoyi.agent.ChartGenerationAgent; +import org.ruoyi.agent.ChitChatAgent; import org.ruoyi.agent.EchartsAgent; import org.ruoyi.agent.SkillsAgent; import org.ruoyi.agent.SqlAgent; @@ -267,11 +268,18 @@ public class ChatServiceFacade implements IChatService { .listener(new MyAgentListener()) .build(); + // 构建子 Agent 6: ChitChatAgent - 简单闲聊兜底,避免无子 Agent 可用时 supervisor 空转 + ChitChatAgent chitChatAgent = AgenticServices.agentBuilder(ChitChatAgent.class) + .chatModel(plannerModel) + .build(); + // 构建监督者 Agent - 管理多个子 Agent var supervisorBuilder = AgenticServices.supervisorBuilder() .chatModel(plannerModel) - .subAgents(skillsAgent, searchAgent, sqlAgent, chartGenerationAgent, echartsAgent) - .responseStrategy(SupervisorResponseStrategy.LAST); + .subAgents(skillsAgent, searchAgent, sqlAgent, chartGenerationAgent, echartsAgent, chitChatAgent) + .supervisorContext("仅当请求是问候或简单闲聊、不需要任何数据、搜索、技能或图表时,才使用 chitChatAgent;" + + "其余情况必须使用对应的专业 Agent") + .responseStrategy(SupervisorResponseStrategy.SUMMARY); SupervisorAgent supervisor = supervisorBuilder.build(); // 知识库增强:智能体绑定了知识库时,对 supervisor 输入做一次 RAG 增强 From a6a55202a355fc0bb429d62fa794db104000ae30 Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Tue, 21 Jul 2026 09:34:47 +0800 Subject: [PATCH 15/29] =?UTF-8?q?fix(rag):=20=E4=BF=AE=E5=A4=8D=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E5=88=86=E7=89=87=E8=BE=B9=E7=95=8C=E4=B8=8E=E5=AD=97?= =?UTF-8?q?=E9=9D=A2=E9=87=8F=E5=88=86=E9=9A=94=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/split/CharacterTextSplitter.java | 12 ++- .../impl/split/CodeTextSplitter.java | 31 +++++++- .../impl/split/MarkdownTextSplitter.java | 31 +++++++- .../knowledge/impl/split/SplitterSupport.java | 71 +++++++++++++++++ .../split/RagTextSplitterRegressionTest.java | 78 +++++++++++++++++++ 5 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/SplitterSupport.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/knowledge/impl/split/RagTextSplitterRegressionTest.java diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/CharacterTextSplitter.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/CharacterTextSplitter.java index 107f0dc1..950608f6 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/CharacterTextSplitter.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/CharacterTextSplitter.java @@ -49,10 +49,14 @@ public class CharacterTextSplitter implements TextSplitter { } List chunkList = new ArrayList<>(); - if (content.contains(knowledgeSeparator) && StringUtils.isNotBlank(knowledgeSeparator)) { - // 按自定义分隔符切分 - String[] chunks = content.split(knowledgeSeparator); - chunkList.addAll(Arrays.asList(chunks)); + if (StringUtils.isNotBlank(knowledgeSeparator) && content.contains(knowledgeSeparator)) { + // 按自定义分隔符切分(字面量匹配,避免分隔符被当作正则) + String[] chunks = content.split(java.util.regex.Pattern.quote(knowledgeSeparator)); + for (String chunk : chunks) { + if (StringUtils.isNotBlank(chunk)) { + chunkList.add(chunk.trim()); + } + } } else { int indexMin = 0; int len = content.length(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/CodeTextSplitter.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/CodeTextSplitter.java index 5cba55c5..98badf0b 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/CodeTextSplitter.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/CodeTextSplitter.java @@ -2,17 +2,46 @@ package org.ruoyi.service.knowledge.impl.split; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.core.utils.StringUtils; +import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo; +import org.ruoyi.service.knowledge.IKnowledgeInfoService; import org.ruoyi.service.knowledge.TextSplitter; import org.springframework.stereotype.Component; import java.util.List; +/** + * 代码文件分片器:按空行(函数/类之间的自然边界)切分, + * 块合并到不超过块大小,超大块再按滑动窗口切分 + */ @Component @AllArgsConstructor @Slf4j public class CodeTextSplitter implements TextSplitter { + + private final IKnowledgeInfoService knowledgeInfoService; + @Override public List split(String content, String kid) { - return null; + int textBlockSize = 1000; + int overlapChar = 50; + if (StringUtils.isNotBlank(kid)) { + try { + KnowledgeInfoVo info = knowledgeInfoService.queryById(Long.parseLong(kid)); + if (info != null) { + if (info.getTextBlockSize() != null && info.getTextBlockSize() > 0) { + textBlockSize = info.getTextBlockSize().intValue(); + } + if (info.getOverlapChar() != null && info.getOverlapChar() > 0) { + overlapChar = info.getOverlapChar().intValue(); + } + } + } catch (Exception e) { + log.warn("查询知识库配置失败,使用默认配置, kid={}", kid, e); + } + } + // 按空行切分,保留段落间的自然语义边界 + String[] sections = content.split("\\n\\s*\\n"); + return SplitterSupport.mergeAndSplit(sections, textBlockSize, overlapChar); } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/MarkdownTextSplitter.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/MarkdownTextSplitter.java index a371273d..38358749 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/MarkdownTextSplitter.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/MarkdownTextSplitter.java @@ -2,17 +2,46 @@ package org.ruoyi.service.knowledge.impl.split; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.core.utils.StringUtils; +import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo; +import org.ruoyi.service.knowledge.IKnowledgeInfoService; import org.ruoyi.service.knowledge.TextSplitter; import org.springframework.stereotype.Component; import java.util.List; +/** + * Markdown 分片器:优先按标题(# ~ ######)切分,保持章节语义完整; + * 小节合并到不超过块大小,超大章节再按滑动窗口切分 + */ @Component @AllArgsConstructor @Slf4j public class MarkdownTextSplitter implements TextSplitter { + + private final IKnowledgeInfoService knowledgeInfoService; + @Override public List split(String content, String kid) { - return null; + int textBlockSize = 1000; + int overlapChar = 50; + if (StringUtils.isNotBlank(kid)) { + try { + KnowledgeInfoVo info = knowledgeInfoService.queryById(Long.parseLong(kid)); + if (info != null) { + if (info.getTextBlockSize() != null && info.getTextBlockSize() > 0) { + textBlockSize = info.getTextBlockSize().intValue(); + } + if (info.getOverlapChar() != null && info.getOverlapChar() > 0) { + overlapChar = info.getOverlapChar().intValue(); + } + } + } catch (Exception e) { + log.warn("查询知识库配置失败,使用默认配置, kid={}", kid, e); + } + } + // 按标题行切分(标题保留在各自小节开头) + String[] sections = content.split("(?m)(?=^#{1,6}\\s)"); + return SplitterSupport.mergeAndSplit(sections, textBlockSize, overlapChar); } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/SplitterSupport.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/SplitterSupport.java new file mode 100644 index 00000000..dbf91360 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/impl/split/SplitterSupport.java @@ -0,0 +1,71 @@ +package org.ruoyi.service.knowledge.impl.split; + +import java.util.ArrayList; +import java.util.List; + +/** + * 分片工具:提供各 Splitter 共用的滑动窗口切分与片段合并能力 + */ +public final class SplitterSupport { + + private SplitterSupport() { + } + + /** + * 滑动窗口切分:每块约 blockSize 字符,相邻块保留 overlap 字符重叠 + */ + public static List slidingWindow(String content, int blockSize, int overlap) { + List chunkList = new ArrayList<>(); + int len = content.length(); + int right = 0; + int i = 0; + while (len > right) { + int begin = i * blockSize - overlap; + if (begin < 0) { + begin = 0; + } + int end = blockSize * (i + 1) + overlap; + if (end > len) { + end = len; + } + String chunk = content.substring(begin, end).trim(); + if (!chunk.isEmpty()) { + chunkList.add(chunk); + } + i++; + right = right + blockSize; + } + return chunkList; + } + + /** + * 将小段按顺序合并到不超过 blockSize,超过 blockSize 的单段再用滑动窗口切分 + */ + public static List mergeAndSplit(String[] sections, int blockSize, int overlap) { + List chunkList = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + for (String section : sections) { + if (section == null || section.isBlank()) { + continue; + } + if (section.length() > blockSize) { + // 超长段先冲刷当前缓冲,再单独窗口切分 + if (current.length() > 0) { + chunkList.add(current.toString().trim()); + current.setLength(0); + } + chunkList.addAll(slidingWindow(section, blockSize, overlap)); + } else if (current.length() + section.length() > blockSize) { + chunkList.add(current.toString().trim()); + current.setLength(0); + current.append(section); + } else { + current.append(section); + } + } + if (current.length() > 0) { + chunkList.add(current.toString().trim()); + } + return chunkList; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/knowledge/impl/split/RagTextSplitterRegressionTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/knowledge/impl/split/RagTextSplitterRegressionTest.java new file mode 100644 index 00000000..b3f2e151 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/knowledge/impl/split/RagTextSplitterRegressionTest.java @@ -0,0 +1,78 @@ +package org.ruoyi.service.knowledge.impl.split; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Tag; +import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo; +import org.ruoyi.service.knowledge.IKnowledgeInfoService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Tag("dev") +class RagTextSplitterRegressionTest { + + private static IKnowledgeInfoService knowledgeService(String separator, long blockSize, long overlap) { + IKnowledgeInfoService service = mock(IKnowledgeInfoService.class); + KnowledgeInfoVo info = new KnowledgeInfoVo(); + info.setSeparator(separator); + info.setTextBlockSize(blockSize); + info.setOverlapChar(overlap); + when(service.queryById(1L)).thenReturn(info); + return service; + } + + @Test + void characterSplitterTreatsRegexMetacharactersLiterally() { + CharacterTextSplitter pipe = new CharacterTextSplitter(knowledgeService("|", 1000, 50)); + assertEquals(List.of("alpha", "beta", "gamma"), pipe.split("alpha|beta|gamma", "1")); + + CharacterTextSplitter dot = new CharacterTextSplitter(knowledgeService(".", 1000, 50)); + assertEquals(List.of("alpha", "beta", "gamma"), dot.split("alpha.beta.gamma", "1")); + + CharacterTextSplitter star = new CharacterTextSplitter(knowledgeService("*", 1000, 50)); + assertEquals(List.of("alpha", "beta", "gamma"), star.split("alpha*beta*gamma", "1")); + } + + @Test + void markdownSplitterReturnsNonEmptyBoundedChunks() { + MarkdownTextSplitter splitter = new MarkdownTextSplitter(knowledgeService(null, 40, 5)); + String markdown = "# Title\nintro text\n## Details\n" + "detail ".repeat(20); + + List chunks = splitter.split(markdown, "1"); + + assertFalse(chunks.isEmpty()); + assertTrue(chunks.stream().noneMatch(String::isBlank)); + assertTrue(chunks.stream().allMatch(chunk -> chunk.length() <= 50), + "window size may include overlap on both sides"); + assertTrue(chunks.stream().anyMatch(chunk -> chunk.contains("# Title"))); + } + + @Test + void codeSplitterReturnsNonEmptyChunksAndPreservesContent() { + CodeTextSplitter splitter = new CodeTextSplitter(knowledgeService(null, 45, 5)); + String code = "class A {\n void a() {}\n}\n\nclass B {\n" + " int value = 1;\n".repeat(8) + "}"; + + List chunks = splitter.split(code, "1"); + + assertFalse(chunks.isEmpty()); + assertTrue(chunks.stream().noneMatch(String::isBlank)); + assertTrue(chunks.stream().anyMatch(chunk -> chunk.contains("class A"))); + assertTrue(chunks.stream().anyMatch(chunk -> chunk.contains("class B") || chunk.contains("int value"))); + } + + @Test + void splitterSupportHandlesEmptyAndOversizedSections() { + assertTrue(SplitterSupport.mergeAndSplit(new String[]{"", " "}, 20, 3).isEmpty()); + + List chunks = SplitterSupport.mergeAndSplit( + new String[]{"short", "x".repeat(55)}, 20, 3); + + assertEquals("short", chunks.get(0)); + assertTrue(chunks.size() >= 4); + assertTrue(chunks.stream().noneMatch(String::isBlank)); + assertTrue(chunks.stream().allMatch(chunk -> chunk.length() <= 26)); + } +} From 6f6d0893ecd96da167aa81816374fbc085cbb69c Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Tue, 21 Jul 2026 09:34:56 +0800 Subject: [PATCH 16/29] =?UTF-8?q?fix(rag):=20=E7=BB=9F=E4=B8=80=E6=A3=80?= =?UTF-8?q?=E7=B4=A2=E9=98=88=E5=80=BC=E9=87=8D=E6=8E=92=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E4=B8=8E=E7=BC=93=E5=AD=98=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../retriever/CustomVectorRetriever.java | 11 ++- .../retrieval/KnowledgeRetrievalService.java | 2 + .../impl/KnowledgeRetrievalServiceImpl.java | 83 ++++++++++++++++--- ...owledgeRetrievalServiceRegressionTest.java | 66 +++++++++++++++ 4 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceRegressionTest.java diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/retriever/CustomVectorRetriever.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/retriever/CustomVectorRetriever.java index f79206bc..a160b2a4 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/retriever/CustomVectorRetriever.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/knowledge/retriever/CustomVectorRetriever.java @@ -1,6 +1,7 @@ package org.ruoyi.service.knowledge.retriever; import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.data.document.Metadata; import dev.langchain4j.rag.content.Content; import dev.langchain4j.rag.content.retriever.ContentRetriever; import dev.langchain4j.rag.query.Query; @@ -55,11 +56,17 @@ public class CustomVectorRetriever implements ContentRetriever { queryVectorBo.setRerankScoreThreshold(knowledgeInfoVo.getRerankScoreThreshold()); // 通过统一服务执行检索 - List nearestList = knowledgeRetrievalService.retrieveTexts(queryVectorBo); + var nearestList = knowledgeRetrievalService.retrieve(queryVectorBo); // 将结果包装为标准的 Content 返回 return nearestList.stream() - .map(text -> Content.from(TextSegment.from(text))) + .map(vo -> { + Metadata metadata = new Metadata(); + metadata.put("kid", String.valueOf(knowledgeInfoVo.getId())); + metadata.put("docId", Objects.toString(vo.getDocId(), "")); + metadata.put("fid", Objects.toString(vo.getId(), "")); + return Content.from(TextSegment.from(vo.getContent(), metadata)); + }) .collect(Collectors.toList()); } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/KnowledgeRetrievalService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/KnowledgeRetrievalService.java index 3e0a6cab..398d3601 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/KnowledgeRetrievalService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/KnowledgeRetrievalService.java @@ -31,4 +31,6 @@ public interface KnowledgeRetrievalService { * @return 检索结果列表 */ List retrieve(QueryVectorBo queryVectorBo); + + void invalidateKnowledge(String kid); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java index b6841ef1..3d9f5850 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceImpl.java @@ -3,6 +3,7 @@ package org.ruoyi.service.retrieval.impl; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.ruoyi.common.core.utils.StringUtils; +import org.ruoyi.common.core.exception.ServiceException; import org.ruoyi.domain.bo.rerank.RerankRequest; import org.ruoyi.domain.bo.rerank.RerankResult; import org.ruoyi.domain.bo.vector.QueryVectorBo; @@ -17,6 +18,8 @@ import org.springframework.stereotype.Service; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -40,6 +43,9 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService * 如果启用重排序,粗召回会获取更多结果供重排序筛选 */ private static final int RERANK_EXPANSION_FACTOR = 3; + private static final long CACHE_TTL_MILLIS = TimeUnit.MINUTES.toMillis(5); + private static final int CACHE_MAX_ENTRIES = 1000; + private final Map retrievalCache = new ConcurrentHashMap<>(); @Override public List retrieveTexts(QueryVectorBo queryVectorBo) { @@ -51,6 +57,11 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService @Override public List retrieve(QueryVectorBo queryVectorBo) { + String cacheKey = cacheKey(queryVectorBo); + CacheEntry cached = retrievalCache.get(cacheKey); + if (cached != null && System.currentTimeMillis() - cached.createdAt < CACHE_TTL_MILLIS) { + return copyResults(cached.results); + } log.info("开始知识库检索, kid={}, query={}", queryVectorBo.getKid(), queryVectorBo.getQuery()); // 1. 粗召回阶段 (向量检索 + 关键词搜索) @@ -68,18 +79,21 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService // 3. 重排序阶段 (可选) List finalResults = coarseResults; - if (Boolean.TRUE.equals(queryVectorBo.getEnableRerank()) && - StringUtils.isNotBlank(queryVectorBo.getRerankModelName())) { + boolean rerankApplied = Boolean.TRUE.equals(queryVectorBo.getEnableRerank()) && + StringUtils.isNotBlank(queryVectorBo.getRerankModelName()); + if (rerankApplied) { finalResults = performRerank(queryVectorBo, coarseResults); } // 4. 应用分值阈值过滤 (重排分值或 RRF 分值) - double threshold = queryVectorBo.getRerankScoreThreshold() != null ? - queryVectorBo.getRerankScoreThreshold() : 0.0; - - return finalResults.stream() - .filter(res -> res.getScore() >= threshold) - .collect(Collectors.toList()); + if (rerankApplied && queryVectorBo.getRerankScoreThreshold() != null) { + double threshold = queryVectorBo.getRerankScoreThreshold(); + finalResults = finalResults.stream() + .filter(res -> res.getScore() != null && res.getScore() >= threshold) + .collect(Collectors.toList()); + } + cache(cacheKey, finalResults); + return copyResults(finalResults); } /** @@ -132,7 +146,8 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService List fragments = fragmentMapper.searchByKeyword(kid, queryVectorBo.getQuery(), finalTargetMaxResults); return fragments.stream().map(f -> { KnowledgeRetrievalVo vo = new KnowledgeRetrievalVo(); - vo.setId(f.getId().toString()); + // 优先使用 fid 作为融合标识(与向量侧一致),历史数据无 fid 时回退主键 + vo.setId(StringUtils.isNotBlank(f.getFid()) ? f.getFid() : f.getId().toString()); vo.setContent(f.getContent()); vo.setDocId(f.getDocId()); vo.setIdx(f.getIdx()); @@ -155,7 +170,11 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService } catch (Exception e) { log.error("混合检索执行失败,回退到纯向量检索: {}", e.getMessage(), e); - return vectorStoreService.search(copyOf(queryVectorBo, targetMaxResults)); + try { + return vectorStoreService.search(copyOf(queryVectorBo, targetMaxResults)); + } catch (Exception vectorError) { + throw new ServiceException("知识库检索不可用:向量与混合检索均失败"); + } } } @@ -182,19 +201,21 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService RerankResult rerankResult = rerankModel.rerank(rerankRequest); // 写回分数并记录原始分 + List reranked = new ArrayList<>(); for (RerankResult.RerankDocument doc : rerankResult.getDocuments()) { if (doc.getIndex() != null && doc.getIndex() < coarseResults.size()) { KnowledgeRetrievalVo vo = coarseResults.get(doc.getIndex()); vo.setRawScore(vo.getScore()); vo.setScore(doc.getRelevanceScore()); + reranked.add(vo); } } // 按新分排序 - coarseResults.sort((a, b) -> b.getScore().compareTo(a.getScore())); + reranked.sort((a, b) -> b.getScore().compareTo(a.getScore())); // 截断到 topN - return coarseResults.subList(0, Math.min(topN, coarseResults.size())); + return reranked.subList(0, Math.min(topN, reranked.size())); } catch (Exception e) { log.error("重排序流程失败: {}", e.getMessage()); @@ -253,4 +274,42 @@ public class KnowledgeRetrievalServiceImpl implements KnowledgeRetrievalService copy.setBaseUrl(original.getBaseUrl()); return copy; } + + @Override + public void invalidateKnowledge(String kid) { + if (StringUtils.isBlank(kid)) { + retrievalCache.clear(); + } else { + retrievalCache.keySet().removeIf(key -> key.startsWith(kid + "|")); + } + } + + private void cache(String key, List results) { + if (retrievalCache.size() >= CACHE_MAX_ENTRIES) { + long now = System.currentTimeMillis(); + retrievalCache.entrySet().removeIf(e -> now - e.getValue().createdAt >= CACHE_TTL_MILLIS); + if (retrievalCache.size() >= CACHE_MAX_ENTRIES) { + retrievalCache.clear(); + } + } + retrievalCache.put(key, new CacheEntry(System.currentTimeMillis(), copyResults(results))); + } + + private String cacheKey(QueryVectorBo bo) { + return String.join("|", Objects.toString(bo.getKid(), ""), Objects.toString(bo.getQuery(), ""), + Objects.toString(bo.getMaxResults(), ""), Objects.toString(bo.getVectorModelName(), ""), + Objects.toString(bo.getEmbeddingModelName(), ""), Objects.toString(bo.getSimilarityThreshold(), ""), + Objects.toString(bo.getEnableHybrid(), ""), Objects.toString(bo.getHybridAlpha(), ""), + Objects.toString(bo.getEnableRerank(), ""), Objects.toString(bo.getRerankModelName(), ""), + Objects.toString(bo.getRerankTopN(), ""), Objects.toString(bo.getRerankScoreThreshold(), "")); + } + + private List copyResults(List source) { + return source.stream().map(vo -> KnowledgeRetrievalVo.builder() + .id(vo.getId()).docId(vo.getDocId()).knowledgeId(vo.getKnowledgeId()).idx(vo.getIdx()) + .content(vo.getContent()).score(vo.getScore()).originalIndex(vo.getOriginalIndex()) + .rawScore(vo.getRawScore()).sourceName(vo.getSourceName()).build()).collect(Collectors.toList()); + } + + private record CacheEntry(long createdAt, List results) { } } diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceRegressionTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceRegressionTest.java new file mode 100644 index 00000000..0841af78 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/retrieval/impl/KnowledgeRetrievalServiceRegressionTest.java @@ -0,0 +1,66 @@ +package org.ruoyi.service.retrieval.impl; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.domain.bo.vector.QueryVectorBo; +import org.ruoyi.domain.vo.knowledge.KnowledgeRetrievalVo; +import org.ruoyi.factory.RerankModelFactory; +import org.ruoyi.mapper.knowledge.KnowledgeFragmentMapper; +import org.ruoyi.service.vector.VectorStoreService; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Tag("dev") +class KnowledgeRetrievalServiceRegressionTest { + + @Test + void rrfMergesSameStableIdAndKeepsDistinctItems() throws Exception { + KnowledgeRetrievalServiceImpl service = new KnowledgeRetrievalServiceImpl( + mock(VectorStoreService.class), mock(RerankModelFactory.class), mock(KnowledgeFragmentMapper.class)); + KnowledgeRetrievalVo vectorA = item("fid-a", "A", 0.9); + KnowledgeRetrievalVo vectorB = item("fid-b", "B", 0.8); + KnowledgeRetrievalVo keywordA = item("fid-a", "A", 10.0); + KnowledgeRetrievalVo keywordC = item("fid-c", "C", 10.0); + + Method method = KnowledgeRetrievalServiceImpl.class.getDeclaredMethod( + "calculateRRF", List.class, List.class, double.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + List result = (List) method.invoke( + service, List.of(vectorA, vectorB), List.of(keywordA, keywordC), 0.5); + + assertEquals(3, result.size()); + assertEquals("fid-a", result.get(0).getId(), "an item present in both rankings must win"); + assertEquals(3, result.stream().map(KnowledgeRetrievalVo::getId).distinct().count()); + } + + @Test + void rerankThresholdDoesNotLeakIntoNonRerankRetrieval() { + VectorStoreService vectorStore = mock(VectorStoreService.class); + when(vectorStore.search(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(item("fid-a", "A", 0.75))); + KnowledgeRetrievalServiceImpl service = new KnowledgeRetrievalServiceImpl( + vectorStore, mock(RerankModelFactory.class), mock(KnowledgeFragmentMapper.class)); + QueryVectorBo query = new QueryVectorBo(); + query.setKid("1"); + query.setQuery("test"); + query.setMaxResults(5); + query.setEnableHybrid(false); + query.setEnableRerank(false); + query.setSimilarityThreshold(0.5); + query.setRerankScoreThreshold(0.9); + + List result = service.retrieve(query); + + assertEquals(1, result.size()); + assertEquals("fid-a", result.get(0).getId()); + } + + private static KnowledgeRetrievalVo item(String id, String content, double score) { + return KnowledgeRetrievalVo.builder().id(id).content(content).score(score).build(); + } +} From c4fc1e6fcfafb6ae4397d433e1ca9d23e4bd252c Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Tue, 21 Jul 2026 09:35:07 +0800 Subject: [PATCH 17/29] =?UTF-8?q?fix(rag):=20=E9=9A=94=E7=A6=BB=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=AE=9E=E4=BE=8B=E5=B9=B6=E4=BF=AE=E6=AD=A3=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/ruoyi/factory/EmbeddingModelFactory.java | 15 ++++++++++++--- .../org/ruoyi/factory/RerankModelFactory.java | 14 ++++++++++++-- .../embed/impl/AliBaiLianBaseEmbedProvider.java | 1 + .../impl/AliBaiLianMultiEmbeddingProvider.java | 1 + .../embed/impl/MinimaxEmbeddingProvider.java | 1 + .../embed/impl/OllamaEmbeddingProvider.java | 1 + .../embed/impl/OpenAiEmbeddingProvider.java | 1 + .../embed/impl/SiliconFlowEmbeddingProvider.java | 1 + .../embed/impl/ZhipuAiEmbeddingProvider.java | 1 + .../rerank/impl/AliBaiLianRerankModelService.java | 1 + .../impl/SiliconFlowRerankModelService.java | 1 + .../rerank/impl/ZhiPuRerankModelService.java | 1 + 12 files changed, 34 insertions(+), 5 deletions(-) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java index b123e6bb..19ef4337 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java @@ -77,13 +77,22 @@ public class EmbeddingModelFactory { /** * 刷新模型缓存 - * 根据给定的嵌入模型ID从缓存中移除对应的模型 + * 根据给定的嵌入模型ID解析模型名称后,从缓存中移除对应的模型 * * @param embeddingModelId 嵌入模型的唯一标识ID */ public void refreshModel(Long embeddingModelId) { - // 从模型缓存中移除指定ID的模型 - modelCache.remove(embeddingModelId); + ChatModelVo modelConfig = chatModelService.queryById(embeddingModelId); + if (modelConfig != null) { + modelCache.remove(modelConfig.getModelName()); + } + } + + /** + * 按模型名称刷新缓存 + */ + public void refreshModelByName(String embeddingModelName) { + modelCache.remove(embeddingModelName); } /** diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java index 2b9ff9fe..62ede6ef 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java @@ -55,12 +55,22 @@ public class RerankModelFactory { /** * 刷新模型缓存 - * 根据给定的模型ID从缓存中移除对应的模型 + * 根据给定的模型ID解析模型名称后,从缓存中移除对应的模型 * * @param modelId 模型的唯一标识ID */ public void refreshModel(Long modelId) { - modelCache.remove(modelId); + ChatModelVo modelConfig = chatModelService.queryById(modelId); + if (modelConfig != null) { + modelCache.remove(modelConfig.getModelName()); + } + } + + /** + * 按模型名称刷新缓存 + */ + public void refreshModelByName(String modelName) { + modelCache.remove(modelName); } /** diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java index 01709e45..68292d5e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java @@ -19,6 +19,7 @@ import java.util.Set; * @Description: 阿里百炼基础嵌入模型(兼容openai) */ @Component("alibailian") +@org.springframework.context.annotation.Scope("prototype") public class AliBaiLianBaseEmbedProvider extends OpenAiEmbeddingProvider { private ChatModelVo chatModelVo; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianMultiEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianMultiEmbeddingProvider.java index 38cc7323..78447a47 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianMultiEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianMultiEmbeddingProvider.java @@ -24,6 +24,7 @@ import java.util.concurrent.TimeUnit; * 实现了MultiModalEmbedModelService接口,提供文本、图像和视频的嵌入向量生成服务 */ @Component("bailianMultiModel") +@org.springframework.context.annotation.Scope("prototype") @Slf4j public class AliBaiLianMultiEmbeddingProvider implements MultiModalEmbedModelService { private final OkHttpClient okHttpClient; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java index 4d2dbec5..c92ac2b8 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/MinimaxEmbeddingProvider.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Component; * @date 2026/3/21 */ @Component("minimax") +@org.springframework.context.annotation.Scope("prototype") public class MinimaxEmbeddingProvider extends OpenAiEmbeddingProvider { } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java index 905b9e73..502347bf 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OllamaEmbeddingProvider.java @@ -19,6 +19,7 @@ import java.util.Set; * @Description: Ollama嵌入模型 */ @Component("ollama") +@org.springframework.context.annotation.Scope("prototype") public class OllamaEmbeddingProvider implements BaseEmbedModelService { private ChatModelVo chatModelVo; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java index 6c583f6f..ea7f1d99 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java @@ -19,6 +19,7 @@ import java.util.Set; * @Description: OpenAi嵌入模型 */ @Component("openai") +@org.springframework.context.annotation.Scope("prototype") public class OpenAiEmbeddingProvider implements BaseEmbedModelService { protected ChatModelVo chatModelVo; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/SiliconFlowEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/SiliconFlowEmbeddingProvider.java index 0c0dd326..4e593b54 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/SiliconFlowEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/SiliconFlowEmbeddingProvider.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Component; * @Description: 硅基流动(兼容 OpenAi) */ @Component("siliconflow") +@org.springframework.context.annotation.Scope("prototype") public class SiliconFlowEmbeddingProvider extends OpenAiEmbeddingProvider { } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java index f7f6a048..275c0b82 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java @@ -21,6 +21,7 @@ import java.util.Set; * @Description: 智谱AI嵌入模型 */ @Component("zhipu") +@org.springframework.context.annotation.Scope("prototype") public class ZhipuAiEmbeddingProvider implements BaseEmbedModelService { protected ChatModelVo chatModelVo; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java index a7975bf8..18531a50 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java @@ -23,6 +23,7 @@ import java.util.concurrent.TimeUnit; */ @Slf4j @Component("qianwenRerank") +@org.springframework.context.annotation.Scope("prototype") public class AliBaiLianRerankModelService implements RerankModelService { private final OkHttpClient okHttpClient; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/SiliconFlowRerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/SiliconFlowRerankModelService.java index 49c5aecf..2aedd017 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/SiliconFlowRerankModelService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/SiliconFlowRerankModelService.java @@ -25,6 +25,7 @@ import java.util.concurrent.TimeUnit; */ @Slf4j @Component("siliconflowRerank") +@org.springframework.context.annotation.Scope("prototype") public class SiliconFlowRerankModelService implements RerankModelService { private static final String DEFAULT_BASE_URL = "https://api.siliconflow.cn/v1/rerank"; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java index 54054091..e0f7d8b3 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java @@ -28,6 +28,7 @@ import java.util.concurrent.TimeUnit; */ @Slf4j @Component("zhipuRerank") +@org.springframework.context.annotation.Scope("prototype") public class ZhiPuRerankModelService implements RerankModelService { private final OkHttpClient okHttpClient; From 42bc8fea95ce12d3f30491740aaee8791faa14cf Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Tue, 21 Jul 2026 09:35:18 +0800 Subject: [PATCH 18/29] =?UTF-8?q?fix(rag):=20=E5=AE=8C=E5=96=84=E5=90=91?= =?UTF-8?q?=E9=87=8F=E8=B7=AF=E7=94=B1=E6=89=B9=E9=87=8F=E5=86=99=E5=85=A5?= =?UTF-8?q?=E4=B8=8E=E6=95=B0=E6=8D=AE=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/script/sql/ruoyi-ai.sql | 16 +++- .../2026-07-20-knowledge-fragment-fid.sql | 44 ++++++++++ .../knowledge/KnowledgeAttachController.java | 6 ++ .../KnowledgeFragmentController.java | 2 + .../entity/knowledge/KnowledgeAttach.java | 3 + .../entity/knowledge/KnowledgeFragment.java | 5 ++ .../vo/knowledge/KnowledgeFragmentVo.java | 5 ++ .../factory/VectorStoreStrategyFactory.java | 13 ++- .../knowledge/KnowledgeAttachMapper.java | 8 ++ .../knowledge/KnowledgeFragmentMapper.java | 2 +- .../impl/KnowledgeAttachServiceImpl.java | 88 +++++++++++++++---- .../impl/KnowledgeFragmentServiceImpl.java | 15 +++- .../impl/KnowledgeInfoServiceImpl.java | 46 +++++++++- .../impl/MilvusVectorStoreStrategy.java | 67 +++++++++++--- .../impl/QdrantVectorStoreStrategy.java | 24 +++-- .../vector/impl/VectorStoreServiceImpl.java | 31 +++++-- .../impl/WeaviateVectorStoreStrategy.java | 76 +++++++++++----- 17 files changed, 373 insertions(+), 78 deletions(-) create mode 100644 docs/script/sql/update/2026-07-20-knowledge-fragment-fid.sql diff --git a/docs/script/sql/ruoyi-ai.sql b/docs/script/sql/ruoyi-ai.sql index 99c2f027..1a874e81 100644 --- a/docs/script/sql/ruoyi-ai.sql +++ b/docs/script/sql/ruoyi-ai.sql @@ -1131,7 +1131,8 @@ CREATE TABLE `knowledge_attach` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `knowledge_id` bigint NOT NULL COMMENT '知识库ID', `oss_id` bigint NULL DEFAULT NULL COMMENT '对象存储ID', - `doc_id` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文档ID', + `doc_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文档ID', + `file_hash` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件SHA-256摘要', `name` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '附件名称', `type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '附件类型', `create_dept` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '部门', @@ -1143,7 +1144,8 @@ CREATE TABLE `knowledge_attach` ( `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', `status` tinyint NULL DEFAULT 0 COMMENT '解析状态: 0待解析, 1解析中, 2已解析, 3解析失败', PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `idx_kname`(`knowledge_id` ASC, `name` ASC) USING BTREE + UNIQUE INDEX `idx_kname`(`knowledge_id` ASC, `name` ASC) USING BTREE, + UNIQUE INDEX `uk_knowledge_file_hash`(`knowledge_id`, `file_hash`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 2033199209203183619 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '知识库附件' ROW_FORMAT = DYNAMIC; -- ---------------------------- @@ -1156,8 +1158,9 @@ CREATE TABLE `knowledge_attach` ( DROP TABLE IF EXISTS `knowledge_fragment`; CREATE TABLE `knowledge_fragment` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `fid` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '向量库片段ID', `idx` int NOT NULL COMMENT '片段索引下标', - `doc_id` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文档ID', + `doc_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文档ID', `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文档内容', `create_dept` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '部门', `create_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '创建人', @@ -1168,6 +1171,9 @@ CREATE TABLE `knowledge_fragment` ( `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', `knowledge_id` bigint NULL DEFAULT NULL COMMENT '知识库ID', PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_fid`(`fid`) USING BTREE, + INDEX `idx_doc_id`(`doc_id`) USING BTREE, + INDEX `idx_knowledge_id`(`knowledge_id`) USING BTREE, FULLTEXT INDEX `ft_content`(`content`) WITH PARSER `ngram` ) ENGINE = InnoDB AUTO_INCREMENT = 2033199209131880451 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '知识片段' ROW_FORMAT = DYNAMIC; @@ -1206,7 +1212,9 @@ CREATE TABLE `knowledge_info` ( `enable_hybrid` tinyint(1) NULL DEFAULT 0 COMMENT '是否启用混合检索', `hybrid_alpha` double NULL DEFAULT 0.5 COMMENT '混合检索权重比例 (0.0=纯向量, 1.0=纯关键词)', `system_prompt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '系统提示词', - PRIMARY KEY (`id`) USING BTREE + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_tenant_user` (`tenant_id`, `user_id`) USING BTREE, + INDEX `idx_tenant_share` (`tenant_id`, `share`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 2033198818050781187 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '知识库' ROW_FORMAT = DYNAMIC; -- ---------------------------- diff --git a/docs/script/sql/update/2026-07-20-knowledge-fragment-fid.sql b/docs/script/sql/update/2026-07-20-knowledge-fragment-fid.sql new file mode 100644 index 00000000..58b83adb --- /dev/null +++ b/docs/script/sql/update/2026-07-20-knowledge-fragment-fid.sql @@ -0,0 +1,44 @@ +-- RAG metadata migration (MySQL 8). Safe to execute repeatedly. +ALTER TABLE `knowledge_attach` + ADD COLUMN IF NOT EXISTS `file_hash` varchar(64) NULL DEFAULT NULL COMMENT '文件SHA-256摘要' AFTER `doc_id`, + MODIFY COLUMN `doc_id` varchar(32) NULL DEFAULT NULL COMMENT '文档ID'; + +SET @add_file_hash = IF(EXISTS( + SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'knowledge_attach' AND index_name = 'uk_knowledge_file_hash'), + 'SELECT 1', 'ALTER TABLE `knowledge_attach` ADD UNIQUE INDEX `uk_knowledge_file_hash` (`knowledge_id`, `file_hash`)'); +PREPARE stmt FROM @add_file_hash; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE `knowledge_fragment` + ADD COLUMN IF NOT EXISTS `fid` varchar(32) NULL DEFAULT NULL COMMENT '向量库片段ID' AFTER `id`, + MODIFY COLUMN `doc_id` varchar(32) NULL DEFAULT NULL COMMENT '文档ID'; + +UPDATE `knowledge_fragment` +SET `fid` = LOWER(MD5(CONCAT('knowledge_fragment:', `id`))) +WHERE `fid` IS NULL OR `fid` = ''; + +SET @drop_idx_fid = IF(EXISTS( + SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'knowledge_fragment' AND index_name = 'idx_fid'), + 'ALTER TABLE `knowledge_fragment` DROP INDEX `idx_fid`', 'SELECT 1'); +PREPARE stmt FROM @drop_idx_fid; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @add_uk_fid = IF(EXISTS( + SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'knowledge_fragment' AND index_name = 'uk_fid'), + 'SELECT 1', 'ALTER TABLE `knowledge_fragment` ADD UNIQUE INDEX `uk_fid` (`fid`)'); +PREPARE stmt FROM @add_uk_fid; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE `knowledge_fragment` MODIFY COLUMN `fid` varchar(32) NOT NULL COMMENT '向量库片段ID'; + +SET @add_tenant_user = IF(EXISTS( + SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'knowledge_info' AND index_name = 'idx_tenant_user'), + 'SELECT 1', 'ALTER TABLE `knowledge_info` ADD INDEX `idx_tenant_user` (`tenant_id`, `user_id`)'); +PREPARE stmt FROM @add_tenant_user; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @add_tenant_share = IF(EXISTS( + SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'knowledge_info' AND index_name = 'idx_tenant_share'), + 'SELECT 1', 'ALTER TABLE `knowledge_info` ADD INDEX `idx_tenant_share` (`tenant_id`, `share`)'); +PREPARE stmt FROM @add_tenant_share; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeAttachController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeAttachController.java index e2e1b4e8..b5bab948 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeAttachController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeAttachController.java @@ -106,7 +106,10 @@ public class KnowledgeAttachController extends BaseController { /** * 上传知识库附件 + * 注意:multipart 上传不能加 @RepeatSubmit(其参数序列化不支持 MultipartFile) */ + @SaCheckPermission("system:attach:add") + @Log(title = "知识库附件", businessType = BusinessType.INSERT) @PostMapping(value = "/upload") public R upload(KnowledgeInfoUploadBo bo){ knowledgeAttachService.upload(bo); @@ -118,7 +121,10 @@ public class KnowledgeAttachController extends BaseController { * * @param id 附件ID */ + @SaCheckPermission("system:attach:edit") + @Log(title = "知识库附件", businessType = BusinessType.UPDATE) @PostMapping("/parse/{id}") + @RepeatSubmit() public R parse(@PathVariable Long id) { knowledgeAttachService.parse(id); return R.ok(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeFragmentController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeFragmentController.java index d739a30f..c3a5a13e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeFragmentController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeFragmentController.java @@ -107,7 +107,9 @@ public class KnowledgeFragmentController extends BaseController { /** * 检索测试 */ + @SaCheckPermission("system:fragment:list") @PostMapping("/retrieval") + @RepeatSubmit() public R> retrieval(@RequestBody KnowledgeFragmentBo bo) { return R.ok(knowledgeFragmentService.retrieval(bo)); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeAttach.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeAttach.java index 2d836f88..4b244efa 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeAttach.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeAttach.java @@ -37,6 +37,9 @@ public class KnowledgeAttach extends BaseEntity { */ private String docId; + /** SHA-256 content digest used for upload idempotency. */ + private String fileHash; + /** * 附件名称 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeFragment.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeFragment.java index d184a10a..1fa84aca 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeFragment.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeFragment.java @@ -27,6 +27,11 @@ public class KnowledgeFragment extends BaseEntity { @TableId(value = "id") private Long id; + /** + * 向量库片段ID(与向量库中的 fid 元数据对应,用于向量定位与混合检索融合) + */ + private String fid; + /** * 文档ID-用于关联文本块信息 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeFragmentVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeFragmentVo.java index 45b4a9ab..a0c7bd8b 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeFragmentVo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeFragmentVo.java @@ -30,6 +30,11 @@ public class KnowledgeFragmentVo implements Serializable { @ExcelProperty(value = "主键") private Long id; + /** + * 向量库片段ID + */ + private String fid; + /** * 文档ID-用于关联文本块信息 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/VectorStoreStrategyFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/VectorStoreStrategyFactory.java index a66ea66c..4c4fe536 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/VectorStoreStrategyFactory.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/VectorStoreStrategyFactory.java @@ -10,6 +10,7 @@ import org.ruoyi.service.vector.impl.MilvusVectorStoreStrategy; import org.ruoyi.service.vector.impl.QdrantVectorStoreStrategy; import org.ruoyi.service.vector.impl.WeaviateVectorStoreStrategy; import org.springframework.stereotype.Component; +import org.ruoyi.common.core.exception.ServiceException; import java.util.HashMap; import java.util.Map; @@ -45,14 +46,20 @@ public class VectorStoreStrategyFactory { * 获取当前配置的向量库策略 */ public VectorStoreService getStrategy() { - String vectorStoreType = vectorStoreProperties.getType(); + return getStrategy(null); + } + + public VectorStoreService getStrategy(String requestedType) { + String vectorStoreType = requestedType; + if (vectorStoreType == null || vectorStoreType.trim().isEmpty()) { + vectorStoreType = vectorStoreProperties.getType(); + } if (vectorStoreType == null || vectorStoreType.trim().isEmpty()) { vectorStoreType = "weaviate"; // 默认使用weaviate } VectorStoreService strategy = strategies.get(vectorStoreType.toLowerCase()); if (strategy == null) { - log.warn("未找到向量库策略: {}, 使用默认策略: weaviate", vectorStoreType); - strategy = strategies.get("weaviate"); + throw new ServiceException("不支持的向量库类型: " + vectorStoreType); } log.debug("使用向量库策略: {}", vectorStoreType); return strategy; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeAttachMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeAttachMapper.java index 05b4d780..f6c7aeff 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeAttachMapper.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeAttachMapper.java @@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Select; import org.ruoyi.domain.entity.knowledge.KnowledgeAttach; import org.ruoyi.domain.vo.knowledge.KnowledgeAttachVo; import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus; +import java.util.List; +import java.util.Map; /** * 知识库附件Mapper接口 @@ -21,4 +23,10 @@ public interface KnowledgeAttachMapper extends BaseMapperPlusSELECT knowledge_id AS knowledgeId, COUNT(*) AS documentCount " + + "FROM knowledge_attach WHERE knowledge_id IN " + + "#{id} " + + "GROUP BY knowledge_id") + List> countByKnowledgeIds(@Param("knowledgeIds") List knowledgeIds); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeFragmentMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeFragmentMapper.java index 304bb7d5..f710b9ac 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeFragmentMapper.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeFragmentMapper.java @@ -34,7 +34,7 @@ public interface KnowledgeFragmentMapper extends BaseMapperPlus") List selectFragmentCountByDocIds(@Param("docIds") List docIds); @Select("