From 02526729973d6506e115750f2bd2bd5129e3b609 Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Sun, 25 Oct 2026 15:55:49 +0800
Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E7=9F=AD=E8=A7=86=E9=A2=91/?=
=?UTF-8?q?=E5=9B=BE=E7=89=87=E4=BA=A7=E7=89=A9=E5=BD=92=E4=B8=80=E5=B9=B6?=
=?UTF-8?q?=E8=BD=AC=E5=AD=98=E4=B8=BA=E5=AF=B9=E5=A4=96=E5=9C=B0=E5=9D=80?=
=?UTF-8?q?=EF=BC=88=E7=A6=85=E9=81=93=20#1=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 归一按内容识别 video_result(此前只认图片):地址字符串、markdown 链接、
文件对象都统一成 {url,name,type:video},端上四个页面才取得到播放地址,
修复「上游接口已通、我方未输出视频、复制链接为空」;
- answer 不再残留裸地址:媒体地址命中时改写成「文件名 + 链接」,
生成记录也补上媒体直链,纯视频分支不再因正文为空而整条不入库;
- 新增 CreativeMediaRelay:上游产物在内网,端上打不开,由后端下载、
存入本项目文件存储,下发 /api/file 对外地址(Range 续传现成);
失败只降级保留原地址,不影响本次生成;
- FileStorageService.storeFile 支持把磁盘文件落入本地/对象存储;
- 开发环境关闭转存(公网 demo 地址本就可直接访问),生产默认开启。
---
.../gxwebsoft/ai/controller/AiController.java | 434 +++++++++++++++++-
.../ai/service/CreativeMediaRelay.java | 342 ++++++++++++++
.../core/storage/FileStorageService.java | 45 ++
src/main/resources/application-dev.yml | 8 +
.../controller/AiCreativeMediaRelayTest.java | 148 ++++++
.../controller/AiCreativeVideoOutputTest.java | 182 ++++++++
.../ai/service/CreativeMediaRelayTest.java | 175 +++++++
7 files changed, 1321 insertions(+), 13 deletions(-)
create mode 100644 src/main/java/com/gxwebsoft/ai/service/CreativeMediaRelay.java
create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiCreativeMediaRelayTest.java
create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiCreativeVideoOutputTest.java
create mode 100644 src/test/java/com/gxwebsoft/ai/service/CreativeMediaRelayTest.java
diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiController.java b/src/main/java/com/gxwebsoft/ai/controller/AiController.java
index 273c1b7..61c9075 100644
--- a/src/main/java/com/gxwebsoft/ai/controller/AiController.java
+++ b/src/main/java/com/gxwebsoft/ai/controller/AiController.java
@@ -22,6 +22,7 @@ import com.gxwebsoft.ai.config.WebSocketServer;
import com.gxwebsoft.ai.constant.AiChatType;
import com.gxwebsoft.ai.entity.ChatMessage;
import com.gxwebsoft.ai.service.AiGenerationRecorder;
+import com.gxwebsoft.ai.service.CreativeMediaRelay;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.slf4j.Logger;
@@ -163,6 +164,28 @@ public class AiController extends BaseController {
"https?://[^\\s)]+\\.(?:png|jpe?g|gif|webp|bmp|svg)(?:\\?[^\\s)]*)?",
Pattern.CASE_INSENSITIVE);
+ /**
+ * markdown 链接语法:[文字](https://...)。
+ *
+ *
视频没法内联成图片,上游给视频结果时用的是链接形式
+ * ({@code [视频](https://.../a.mp4)}),与 {@link #MARKDOWN_IMAGE_PATTERN} 区分开。
+ */
+ private static final Pattern MARKDOWN_LINK_PATTERN =
+ Pattern.compile("\\[[^\\]]*\\]\\((https?://[^)\\s]+)\\)");
+
+ /**
+ * 裸视频 URL(按扩展名判定)。
+ *
+ *
{@code video_result} 里的视频地址可能直接以字符串给出;与图片同理,
+ * 签名参数里的长串会命中 {@link #LONG_TOKEN_PATTERN},视频地址必须单独认。
+ */
+ private static final Pattern VIDEO_URL_PATTERN = Pattern.compile(
+ "https?://[^\\s)]+\\.(?:mp4|mov|m4v|webm|avi|mkv|flv)(?:\\?[^\\s)]*)?",
+ Pattern.CASE_INSENSITIVE);
+
+ /** 正文/markdown 里抽取任意地址,用于判断「这段媒体链接是否已经写在正文里」。 */
+ private static final Pattern URL_IN_TEXT_PATTERN = Pattern.compile("https?://[^)\\s]+");
+
/**
* 一组图片最多取几张。
*
@@ -209,6 +232,8 @@ public class AiController extends BaseController {
private AiGenerationRecorder aiGenerationRecorder;
@Resource
private DifyProperties difyProperties;
+ @Resource
+ private CreativeMediaRelay creativeMediaRelay;
/**
* 用于把入参实体序列化成 Map,尊重 Jackson 注解
@@ -1517,7 +1542,7 @@ public class AiController extends BaseController {
String resolvedType = StrUtil.blankToDefault(chatType, resolveCreativeChatType(requestBody));
Map snapshot = params == null ? creativeParams(requestBody) : params;
Integer recordId = recordGeneration(loginUserId, resolvedType, userContent, snapshot,
- userContent, stringValue(run.result.get("answer")), null);
+ userContent, creativeReplyForHistory(run.result), null);
if (recordId != null) {
run.result.put("chatId", recordId);
}
@@ -2555,13 +2580,25 @@ public class AiController extends BaseController {
Map normalizedOutputs = outputs instanceof Map
? normalizeCreativeOutputs((Map) outputs)
: null;
+ // 归一之后、拼 answer 之前转存:归一已经给出统一的 {url,...} 元素,
+ // 在这里一处把上游地址换成我们自己的对外地址即可,四个页面无需改动。
+ relayMediaFiles(normalizedOutputs);
String answer = extractWorkflowOutputs(responseData);
if (answer == null) {
answer = extractAnswer(responseData);
}
+ if (normalizedOutputs != null && answerIsOnlyMediaUrls(answer, normalizedOutputs)) {
+ // 上游把 video_result 直接给成地址字符串时,findTextValue 会把它们当正文
+ // 拼进 answer(长签名地址绕过了「无空格长 token」判定),气泡里就是几条
+ // 几百字符的地址。这些地址本次都归一成了文件卡片,正文改用带文件名的
+ // markdown 链接,既不重复也不影响点开。
+ answer = mediaMarkdownFromOutputs(normalizedOutputs);
+ }
if (answer == null && normalizedOutputs != null) {
- answer = imageMarkdownFromOutputs(normalizedOutputs);
+ // 图片与视频都算可展示内容:视频分支常常只有 video_result,
+ // 没有文案字段,这里不兜底就落到「未解析到可展示的内容」。
+ answer = mediaMarkdownFromOutputs(normalizedOutputs);
}
if (answer != null) {
result.put("answer", answer);
@@ -2570,10 +2607,12 @@ public class AiController extends BaseController {
// 而这条路径过去在日志里**没有任何痕迹**——上游形状一变,端上就悄悄退化,
// 排查时只能靠猜。这里把 outputs 的键与图片条数记下来,
// 下一次出现时能直接区分「真没内容」还是「又换了字段名」(issue 19)。
- logger.warn("creative assistant returned no displayable answer, status={}, outputKeys={}, normalizedPhotoFiles={}",
+ logger.warn("creative assistant returned no displayable answer, status={}, outputKeys={}, "
+ + "normalizedPhotoFiles={}, normalizedVideoFiles={}",
status,
normalizedOutputs == null ? "<无 outputs>" : normalizedOutputs.keySet(),
- normalizedOutputs == null ? 0 : photoFileCount(normalizedOutputs.get("photo_files")));
+ normalizedOutputs == null ? 0 : fileCount(normalizedOutputs.get("photo_files")),
+ normalizedOutputs == null ? 0 : fileCount(normalizedOutputs.get("video_result")));
}
String workflowRunId = stringValue(responseData.get("workflow_run_id"));
@@ -2616,6 +2655,13 @@ public class AiController extends BaseController {
* 字符串或数组)都收进规范的 {@code photo_files},元素统一成 {@code {url,name,type}}。
* 端上四个页面认的正是 {@code photo_files},因此不必逐个页面加字段。
*
+ * 视频同理:{@code video_result} 是端上「视频生成结果」区块认的字段
+ * (四个页面都按 {@code video_result} 取文件),但上游给的元素可能是
+ * 字符串(裸地址 / markdown 链接)、也可能是文件对象,键名同样会漂移。
+ * 这里一并按内容收进规范的 {@code video_result},元素统一成
+ * {@code {url,name,type:video}} —— 端上的 {@code resolveFileUrl} 才取得到地址,
+ * 否则视频卡片既没有播放地址、复制链接也永远是空的。
+ *
* @param outputs 上游 outputs(不改动原对象,返回值是新 Map)
* @return 归一后的 outputs
*/
@@ -2626,10 +2672,17 @@ public class AiController extends BaseController {
collectPhotoFiles(outputs.get("photo_files"), photoFiles, knownUrls);
collectPhotoFiles(outputs.get("photo_file"), photoFiles, knownUrls);
- // 其余变量按内容兜底:老字段(photo_*)已收过的 URL 不重复收。
+ List