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