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 videoFiles = new ArrayList<>(); + Set knownVideoUrls = new LinkedHashSet<>(); + collectVideoFiles(outputs.get("video_result"), videoFiles, knownVideoUrls); + collectVideoFiles(outputs.get("video_file"), videoFiles, knownVideoUrls); + collectVideoFiles(outputs.get("video_files"), videoFiles, knownVideoUrls); + + // 其余变量按内容兜底:老字段(photo_* / video_*)已收过的 URL 不重复收。 for (Map.Entry entry : outputs.entrySet()) { String key = entry.getKey(); - if ("photo_files".equals(key) || "photo_file".equals(key)) { + if ("photo_files".equals(key) || "photo_file".equals(key) + || "video_result".equals(key) || "video_file".equals(key) || "video_files".equals(key)) { continue; } for (String url : extractImageUrls(entry.getValue())) { @@ -2637,22 +2690,30 @@ public class AiController extends BaseController { photoFiles.add(imageFileEntry(url)); } } + for (String url : extractVideoUrls(entry.getValue())) { + if (knownVideoUrls.add(url)) { + videoFiles.add(videoFileEntryFrom(null, url)); + } + } } if (!photoFiles.isEmpty()) { normalized.put("photo_files", photoFiles); } + if (!videoFiles.isEmpty()) { + normalized.put("video_result", videoFiles); + } return normalized; } /** - * 数一下归一后的 {@code photo_files} 有几个元素(只用于日志)。 + * 数一下归一后的文件列表有几个元素(只用于日志)。 */ - private static int photoFileCount(Object photoFiles) { - if (photoFiles instanceof Collection) { - return ((Collection) photoFiles).size(); + private static int fileCount(Object files) { + if (files instanceof Collection) { + return ((Collection) files).size(); } - return photoFiles == null ? 0 : 1; + return files == null ? 0 : 1; } /** @@ -2686,6 +2747,132 @@ public class AiController extends BaseController { } } + /** + * 把 {@code video_result} / {@code video_file} 收进统一列表。 + * + *

元素可能是文件对象({@code {url,name,...}}),也可能直接是视频地址字符串 + * (裸地址或 {@code [视频](url)} 链接)。后者必须转成文件对象:四个页面的 + * {@code resolveFileUrl} 只认对象上的 url 字段,字符串元素一律取不到地址, + * 表现出来就是「有视频却不播放、复制链接为空」。 + */ + private void collectVideoFiles(Object raw, List target, Set knownUrls) { + if (raw == null) { + return; + } + List items = raw instanceof List ? (List) raw : Collections.singletonList(raw); + for (Object item : items) { + if (target.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) { + return; + } + if (item instanceof Map) { + Map file = (Map) item; + String url = creativeFileUrl(file); + if (StrUtil.isBlank(url)) { + continue; + } + if (knownUrls.add(url)) { + target.add(videoFileEntryFrom(file, url)); + } + continue; + } + for (String url : extractVideoUrls(item)) { + if (knownUrls.add(url)) { + target.add(videoFileEntryFrom(null, url)); + } + } + } + } + + /** + * 把一个文件对象(或裸地址)归一成端上认的 {@code {url,name,type:video}}。 + * + *

入参为 {@code null} 表示上游只给了地址字符串;否则保留原来的 + * {@code name} / {@code contentType} / {@code size},只补齐端上要用的字段。 + */ + private Map videoFileEntryFrom(Map file, String url) { + Map entry = new LinkedHashMap<>(); + entry.put("url", url); + + Object name = file == null ? null : file.get("name"); + if (name == null && file != null) { + name = file.get("filename"); + } + String fileName = stringValue(name); + entry.put("name", StrUtil.isBlank(fileName) ? fileNameFromUrl(url, "生成视频") : fileName.trim()); + + Object contentType = file == null ? null : file.get("contentType"); + if (contentType == null && file != null) { + contentType = file.get("mime_type"); + } + if (contentType == null && file != null) { + contentType = file.get("content_type"); + } + if (contentType != null) { + entry.put("contentType", contentType); + } + Object size = file == null ? null : file.get("size"); + if (size != null) { + entry.put("size", size); + } + entry.put("type", "video"); + return entry; + } + + /** + * 从一个任意形状的 outputs 值里抽出视频地址(保持出现顺序、允许重复)。 + */ + private List extractVideoUrls(Object value) { + List urls = new ArrayList<>(); + collectVideoUrls(value, urls); + return urls; + } + + private void collectVideoUrls(Object value, List urls) { + if (value == null || urls.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) { + return; + } + if (value instanceof String) { + String text = ((String) value).trim(); + if (text.isEmpty()) { + return; + } + Matcher link = MARKDOWN_LINK_PATTERN.matcher(text); + boolean found = false; + while (link.find() && urls.size() < CREATIVE_IMAGE_LIST_MAX_ITEMS) { + if (VIDEO_URL_PATTERN.matcher(link.group(1)).matches()) { + urls.add(link.group(1)); + found = true; + } + } + if (!found && VIDEO_URL_PATTERN.matcher(text).matches()) { + urls.add(text); + } + return; + } + if (value instanceof List) { + for (Object item : (List) value) { + collectVideoUrls(item, urls); + if (urls.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) { + return; + } + } + return; + } + if (value instanceof Map) { + // 嵌套结构里只认能按扩展名判定为视频的字符串,避免把文档/图片地址误当视频。 + for (Object item : ((Map) value).values()) { + if (item instanceof String) { + String text = ((String) item).trim(); + if (VIDEO_URL_PATTERN.matcher(text).matches()) { + urls.add(text); + } + } else if (item instanceof List) { + collectVideoUrls(item, urls); + } + } + } + } + /** * 从一个任意形状的 outputs 值里抽出图片地址(保持出现顺序、允许重复)。 */ @@ -2766,6 +2953,10 @@ public class AiController extends BaseController { } private String fileNameFromUrl(String url) { + return fileNameFromUrl(url, "生成图片"); + } + + private String fileNameFromUrl(String url, String fallback) { String path = url == null ? "" : url.trim(); int query = path.indexOf('?'); if (query >= 0) { @@ -2773,7 +2964,7 @@ public class AiController extends BaseController { } int slash = path.lastIndexOf('/'); String name = slash >= 0 ? path.substring(slash + 1) : path; - return StrUtil.isBlank(name) ? "生成图片" : name; + return StrUtil.isBlank(name) ? fallback : name; } /** @@ -2800,6 +2991,222 @@ public class AiController extends BaseController { return lines.isEmpty() ? null : String.join("\n\n", lines); } + /** + * 归一后的 {@code video_result} 转成 markdown 链接段落。 + * + *

视频没法像图片那样内联成 {@code ![](url)}(markdown 渲染器会把 mp4 当破图), + * 所以用链接:{@code [名字](url)}。它同时是历史记录里唯一能点开视频的东西 —— + * 生成记录只存 markdown 正文,链接不写进去,用户回看就只剩一句「已生成」。 + */ + private String videoMarkdownFromOutputs(Map outputs) { + Object raw = outputs.get("video_result"); + if (!(raw instanceof List)) { + return null; + } + List lines = new ArrayList<>(); + for (Object item : (List) raw) { + if (lines.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) { + break; + } + String url; + String name = null; + if (item instanceof Map) { + url = creativeFileUrl((Map) item); + name = stringValue(((Map) item).get("name")); + } else { + List urls = extractVideoUrls(item); + url = urls.isEmpty() ? null : urls.get(0); + } + if (StrUtil.isBlank(url)) { + continue; + } + String label = StrUtil.isBlank(name) ? "视频" + (lines.size() + 1) : name.trim(); + lines.add("[" + label + "](" + url.trim() + ")"); + } + return lines.isEmpty() ? null : String.join("\n\n", lines); + } + + /** + * 图片与视频的 markdown 段落。给「answer 兜底」和历史记录内容复用。 + */ + private String mediaMarkdownFromOutputs(Map outputs) { + if (outputs == null || outputs.isEmpty()) { + return null; + } + List parts = new ArrayList<>(); + String images = imageMarkdownFromOutputs(outputs); + if (images != null) { + parts.add(images); + } + String videos = videoMarkdownFromOutputs(outputs); + if (videos != null) { + parts.add(videos); + } + return parts.isEmpty() ? null : String.join("\n\n", parts); + } + + /** + * answer 是否「只有一串媒体地址」。 + * + *

{@code video_result} 以字符串数组下发时,{@link #findDisplayableValueText(List)} + * 会把地址当正文拼进 answer;长签名地址并不比 task_id 短,只是带标点绕过了 + * {@link #LONG_TOKEN_PATTERN} 的整串判定。若这些地址本次都已归一到文件列表, + * 正文就该换成“文件名 + 链接”,否则聊天气泡里躺着两条几百字符的签名地址。 + */ + private boolean answerIsOnlyMediaUrls(String answer, Map outputs) { + if (StrUtil.isBlank(answer)) { + return false; + } + Set mediaUrls = mediaUrlsOf(outputs); + if (mediaUrls.isEmpty()) { + return false; + } + Matcher url = URL_IN_TEXT_PATTERN.matcher(answer); + boolean found = false; + while (url.find()) { + found = true; + if (!mediaUrls.contains(url.group())) { + return false; + } + } + // 只有地址、没有别的文字才算「只是一串地址」;带文案的 answer 原样保留。 + return found && StrUtil.isBlank(URL_IN_TEXT_PATTERN.matcher(answer).replaceAll("")); + } + + /** + * 归一后的 outputs 里所有媒体地址(图片 + 视频)。 + */ + private Set mediaUrlsOf(Map outputs) { + Set urls = new LinkedHashSet<>(); + collectMediaUrls(outputs.get("photo_files"), urls); + collectMediaUrls(outputs.get("video_result"), urls); + return urls; + } + + private void collectMediaUrls(Object raw, Set urls) { + if (raw instanceof Collection) { + for (Object item : (Collection) raw) { + collectMediaUrls(item, urls); + } + return; + } + if (raw instanceof Map) { + String url = creativeFileUrl((Map) raw); + if (StrUtil.isNotBlank(url)) { + urls.add(url.trim()); + } + // 转存过的元素保留了上游原地址;answer 是从原始 responseData 里抽的, + // 里面可能还是那个内网链接,两个地址都要认,否则 answerIsOnlyMediaUrls 会判否。 + String sourceUrl = stringValue(((Map) raw).get("sourceUrl")); + if (StrUtil.isNotBlank(sourceUrl)) { + urls.add(sourceUrl.trim()); + } + return; + } + String text = stringValue(raw); + if (StrUtil.isBlank(text)) { + return; + } + urls.addAll(extractVideoUrls(text)); + urls.addAll(extractImageUrls(text)); + } + + /** + * 把归一后媒体元素的地址换成我们自己的对外地址。 + * + *

上游 Dify 部署在内网,返回的 {@code http://10.100.158.x/files/...} + * 后端能取、端上取不到(见 {@link CreativeMediaRelay})。这里在装配结果时 + * 就地替换,并保留 {@code sourceUrl}:历史记录与排查都还需要原地址。 + * + *

空安全:单元测试直接 {@code new AiController()} 反射调用本类方法, + * 此时 Spring 尚未注入依赖,不能因为转存而 NPE。 + */ + private void relayMediaFiles(Map outputs) { + if (outputs == null || creativeMediaRelay == null || !creativeMediaRelay.isEnabled()) { + return; + } + // 同一地址一次请求内只下载一次;失败也缓存,避免对坏链接反复超时 + Map relayed = new HashMap<>(); + relayMediaList(outputs.get("photo_files"), false, relayed); + relayMediaList(outputs.get("video_result"), true, relayed); + } + + private void relayMediaList(Object raw, boolean video, Map relayed) { + if (!(raw instanceof List)) { + return; + } + for (Object item : (List) raw) { + if (!(item instanceof Map)) { + continue; + } + //noinspection unchecked + Map file = (Map) item; + String url = creativeFileUrl(file); + if (StrUtil.isBlank(url) || isOurFileUrl(url)) { + continue; + } + String relayedUrl = relayed.get(url); + if (relayedUrl == null) { + relayedUrl = creativeMediaRelay.relay(url, stringValue(file.get("name")), video); + relayed.put(url, relayedUrl); + } + if (StrUtil.isBlank(relayedUrl)) { + continue; + } + file.put("sourceUrl", url); + file.put("url", relayedUrl); + } + } + + /** + * 已经是本服务的文件地址就不必再转存,避免二次下载 + */ + private boolean isOurFileUrl(String url) { + return url.contains("/api/file/"); + } + + /** + * 生成记录里要存的正文:answer 加上媒体直链。 + * + *

视频/图片的产物不写在 answer 里(answer 通常是「已为你生成 1 条视频」这类文案), + * 而生成记录只存 markdown 正文。若原样落库,用户从历史回看只有一句话、 + * 没有任何地址;正文为空时甚至整条记录都不入库({@code AiGenerationRecorder} + * 对空白 reply 直接跳过)。这里把 outputs 里的媒体链接补进去, + * answer 里已经出现过的地址不重复追加。 + */ + private String creativeReplyForHistory(Map result) { + if (result == null) { + return null; + } + String answer = stringValue(result.get("answer")); + Object outputs = result.get("outputs"); + if (!(outputs instanceof Map)) { + return answer; + } + //noinspection unchecked + String media = mediaMarkdownFromOutputs((Map) outputs); + if (media == null) { + return answer; + } + if (StrUtil.isBlank(answer)) { + return media; + } + List missing = new ArrayList<>(); + for (String line : media.split("\n\n")) { + Matcher url = URL_IN_TEXT_PATTERN.matcher(line); + if (url.find() && answer.contains(url.group())) { + continue; + } + if (StrUtil.isNotBlank(line)) { + missing.add(line); + } + } + if (missing.isEmpty()) { + return answer; + } + return answer + "\n\n" + String.join("\n\n", missing); + } + /** * 取 Dify 工作流事件里的执行状态。 * @@ -3223,7 +3630,8 @@ public class AiController extends BaseController { * *

List 里**只取字符串元素**,不递归进 Map:{@code video_result} / * {@code photo_files} 这类列表装的是文件对象,递归进去会把文件 URL 当正文, - * 挤掉真正的脚本/文案字段。 + * 挤掉真正的脚本/文案字段。视频/图片的归一由 + * {@link #normalizeCreativeOutputs(Map)} 负责,这里只判「能不能当正文展示」。 */ private String findDisplayableValueText(Object value) { if (isDisplayableText(value)) { diff --git a/src/main/java/com/gxwebsoft/ai/service/CreativeMediaRelay.java b/src/main/java/com/gxwebsoft/ai/service/CreativeMediaRelay.java new file mode 100644 index 0000000..53257f1 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/service/CreativeMediaRelay.java @@ -0,0 +1,342 @@ +package com.gxwebsoft.ai.service; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.common.core.storage.FileStorageService; +import com.gxwebsoft.common.core.storage.StoredFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Locale; +import java.util.UUID; + +/** + * 把上游(Dify 等)产物下载到我们自己的文件存储,换成对外可访问的地址。 + * + *

为什么需要它:生产环境的 Dify 部署在内网({@code http://10.100.158.15/v1}), + * 它返回的图片/视频地址是 {@code http://10.100.158.x/files/...}。 + * 后端能访问,但浏览器与小程序不能——端上拿到的是一张永远裂开的图、 + * 一个永远转圈的视频。前端拿到地址后再"自己想办法"是死路:跨域、鉴权、 + * Range、签名 URL 过期都在对端,我们控不了。 + * + *

所以在后端收口:产物一下来就由我们下载、落进本项目的文件存储 + * (对象存储 RustFS / 本地磁盘,见 {@link FileStorageService}), + * 对外只下发 {@code https://twai-api.gxmu.edu.cn/api/file/...}。 + * 这条链路本来就是端上上传/预览走的同一条,Range 断点续传、缩略图、 + * 免鉴权 GET 都现成。 + * + *

边界: + *

    + *
  • 只处理 http/https;云元数据地址直接拒绝(避免被上游报文当成 SSRF 跳板);
  • + *
  • 内网地址**必须放行**,这正是要转存的那一类;
  • + *
  • 按流写临时文件并计数,超限立刻中断——视频动辄几百 MB,不能走 byte[];
  • + *
  • 任何失败都只记日志并返回 {@code null},调用方保留原地址。 + * 转存是增强,不能因为下载失败让本来能用的结果变成不可用。
  • + *
+ * + * @author WebSoft + */ +@Service +public class CreativeMediaRelay { + + private static final Logger log = LoggerFactory.getLogger(CreativeMediaRelay.class); + + /** + * 图片上限:Dify 出图单张通常 < 5MB,留足余量 + */ + static final long MAX_IMAGE_BYTES = 30L * 1024 * 1024; + + /** + * 视频上限:10 秒 1080p 一般在 20MB 内,512MB 足够挡住异常大文件 + */ + static final long MAX_VIDEO_BYTES = 512L * 1024 * 1024; + + private static final int CONNECT_TIMEOUT_MS = 5_000; + + /** + * 出视频时上游生成完后下载本身可能较慢,读超时给足 + */ + private static final int READ_TIMEOUT_MS = 120_000; + + private static final int BUFFER_SIZE = 64 * 1024; + + private static final String[] MEDIA_EXTENSIONS = { + "mp4", "mov", "m4v", "webm", "avi", "mkv", "flv", + "jpg", "jpeg", "png", "gif", "webp", "bmp", "heic" + }; + + @Resource + private FileStorageService fileStorageService; + + @Resource + private ConfigProperties config; + + /** + * 开发环境连的是公网 demo 实例,产物地址本来就能打开,转存只会让本地调试多等几十秒 + */ + @Value("${ai.creative.media-relay.enabled:true}") + private boolean enabled = true; + + /** + * Spring 用字段注入({@code @Resource}),必须保留无参构造 + */ + public CreativeMediaRelay() { + } + + /** + * 仅测试使用:手工装配依赖,不必为一个纯逻辑类起 Spring 容器 + */ + CreativeMediaRelay(FileStorageService fileStorageService, ConfigProperties config, boolean enabled) { + this.fileStorageService = fileStorageService; + this.config = config; + this.enabled = enabled; + } + + public boolean isEnabled() { + return enabled; + } + + /** + * 下载转存一个产物地址。 + * + * @param url 上游给的地址(内网/外网均可) + * @param suggestedName 期望的文件名,可空 + * @param video 是否视频(决定大小上限与兜底后缀) + * @return 对外可访问的新地址;转存失败返回 {@code null},调用方应保留原地址 + */ + public String relay(String url, String suggestedName, boolean video) { + if (!enabled || !isAllowed(url)) { + return null; + } + long limit = video ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES; + HttpURLConnection conn = null; + File tmp = null; + long startedAt = System.currentTimeMillis(); + try { + conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setInstanceFollowRedirects(true); + conn.setRequestProperty("User-Agent", "tuanwei-media-relay/1.0"); + conn.setRequestProperty("Accept", "*/*"); + + int code = conn.getResponseCode(); + if (code < 200 || code >= 300) { + log.warn("[media-relay] 上游返回 {},保留原地址: {}", code, brief(url)); + return null; + } + long declared = conn.getContentLengthLong(); + if (declared > limit) { + log.warn("[media-relay] 声明大小 {} 超过上限 {},保留原地址: {}", + declared, limit, brief(url)); + return null; + } + + String contentType = normalizeContentType(conn.getContentType()); + if (isErrorContentType(contentType)) { + // 上游把错误页/JSON 当文件返回时不能存成媒体,否则端上会得到坏文件 + log.warn("[media-relay] 内容类型 {} 不像媒体,保留原地址: {}", contentType, brief(url)); + return null; + } + + String suffix = suffixOf(url, contentType, video); + String name = safeName(suggestedName, suffix); + tmp = File.createTempFile("media-relay-", suffix); + + long total = 0; + try (InputStream in = conn.getInputStream(); + OutputStream out = new FileOutputStream(tmp)) { + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while ((read = in.read(buffer)) != -1) { + total += read; + if (total > limit) { + log.warn("[media-relay] 实际大小超过上限 {},中断下载: {}", limit, brief(url)); + return null; + } + out.write(buffer, 0, read); + } + } + if (total <= 0) { + log.warn("[media-relay] 下载到 0 字节,保留原地址: {}", brief(url)); + return null; + } + + StoredFile stored = fileStorageService.storeFile(tmp, name, contentType); + String publicUrl = StrUtil.removeSuffix(config.getFileServer(), "/") + + "/api/file" + stored.getPath(); + log.info("[media-relay] 已转存 {} -> {} ({} bytes, {}ms)", + brief(url), stored.getPath(), total, System.currentTimeMillis() - startedAt); + return publicUrl; + } catch (Exception e) { + // 转存失败只降级,不打断本次生成:调用方会继续用它原来的地址 + log.warn("[media-relay] 转存失败 {}: {}", brief(url), e.toString()); + return null; + } finally { + if (conn != null) { + conn.disconnect(); + } + if (tmp != null) { + FileUtil.del(tmp); + } + } + } + + /** + * 只放行 http/https,挡掉本机与云元数据地址。 + * + *

内网与本机地址都不挡:需要转存的正是内网那一类,本机地址在单机部署里也可能是 + * 上游自己的文件服务。真正要挡的是云元数据接口——那是唯一能让"下载"变成凭证泄露的地址。 + */ + private boolean isAllowed(String url) { + if (StrUtil.isBlank(url)) { + return false; + } + String protocol = StrUtil.subBefore(url, "://", false).toLowerCase(Locale.ROOT); + if (!"http".equals(protocol) && !"https".equals(protocol)) { + return false; + } + try { + String host = new URL(url).getHost(); + if (StrUtil.isBlank(host)) { + return false; + } + host = host.toLowerCase(Locale.ROOT); + return !("0.0.0.0".equals(host) + || "169.254.169.254".equals(host) + || "metadata.google.internal".equals(host)); + } catch (Exception e) { + return false; + } + } + + private String normalizeContentType(String contentType) { + if (StrUtil.isBlank(contentType)) { + return null; + } + return StrUtil.trim(StrUtil.subBefore(contentType, ";", false)); + } + + /** + * 上游出错时常返回 HTML/JSON 错误体,存下来会变成"能播放但坏掉"的文件 + */ + private boolean isErrorContentType(String contentType) { + if (StrUtil.isBlank(contentType)) { + return false; + } + String type = contentType.toLowerCase(Locale.ROOT); + return type.startsWith("text/") || type.contains("json") || type.contains("xml"); + } + + /** + * 后缀优先取地址里的扩展名(Dify 的地址带 {@code .mp4}),其次按响应类型推断 + */ + private String suffixOf(String url, String contentType, boolean video) { + String ext = urlExtension(url); + if (ext != null) { + return "." + ext; + } + String byType = extensionOfContentType(contentType); + if (byType != null) { + return byType; + } + return video ? ".mp4" : ".jpg"; + } + + private String urlExtension(String url) { + try { + String path = new URL(url).getPath(); + int dot = path.lastIndexOf('.'); + if (dot < 0) { + return null; + } + String ext = path.substring(dot + 1).toLowerCase(Locale.ROOT); + for (String candidate : MEDIA_EXTENSIONS) { + if (candidate.equals(ext)) { + return ext; + } + } + } catch (Exception ignored) { + // 解析失败走类型推断 + } + return null; + } + + private String extensionOfContentType(String contentType) { + if (StrUtil.isBlank(contentType)) { + return null; + } + switch (contentType.toLowerCase(Locale.ROOT)) { + case "image/jpeg": + return ".jpg"; + case "image/png": + return ".png"; + case "image/gif": + return ".gif"; + case "image/webp": + return ".webp"; + case "image/bmp": + return ".bmp"; + case "image/heic": + return ".heic"; + case "video/mp4": + return ".mp4"; + case "video/quicktime": + return ".mov"; + case "video/webm": + return ".webm"; + case "video/x-msvideo": + return ".avi"; + case "video/x-matroska": + return ".mkv"; + default: + return null; + } + } + + /** + * 文件名来自上游,可能带路径分隔符或奇怪字符,落盘前必须收敛 + */ + private String safeName(String suggestedName, String suffix) { + String name = StrUtil.trimToEmpty(suggestedName); + // 上游有时把整条 URL 当文件名 + if (name.contains("://")) { + name = StrUtil.subAfter(name, "/", true); + name = StrUtil.subBefore(name, "?", false); + } + name = name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_"); + if (name.length() > 80) { + name = name.substring(0, 80); + } + name = StrUtil.trimToEmpty(name); + if (StrUtil.isBlank(name) || "_".equals(name)) { + name = UUID.randomUUID().toString().replaceAll("-", ""); + } + int dot = name.lastIndexOf('.'); + if (dot > 0) { + name = name.substring(0, dot); + } + return name + suffix; + } + + /** + * 日志里只留地址尾部,签名参数既长又含敏感串 + */ + private String brief(String url) { + if (StrUtil.isBlank(url)) { + return "<空>"; + } + String noQuery = StrUtil.subBefore(url, "?", false); + return noQuery.length() > 120 ? "..." + noQuery.substring(noQuery.length() - 120) : noQuery; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java b/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java index 92625cd..8e007d8 100644 --- a/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java +++ b/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java @@ -116,6 +116,51 @@ public class FileStorageService { } } + /** + * 保存一个已经在磁盘上的文件(远端产物转存用)。 + * + *

与 {@link #store(MultipartFile)} 的差别只在来源:上游产物已经是落好的临时文件, + * 不必再包一层 MultipartFile。命名、目录与读取链路保持一致, + * 转存后的地址与用户直接上传的文件同源,{@code /api/file/**} 的预览、下载与 + * Range 断点续传都能直接复用。 + * + * @param src 源文件(由调用方负责清理) + * @param originalName 原始文件名,可空;用于取后缀与展示名 + * @param contentType 内容类型,可空;空时按文件内容探测 + */ + public StoredFile storeFile(File src, String originalName, String contentType) throws IOException { + String dateDir = new SimpleDateFormat("yyyyMMdd").format(new Date()); + String suffix = suffixOf(originalName); + boolean uuidName = isUuidName(); + String name = uuidName || StrUtil.isBlank(originalName) + ? UUID.randomUUID().toString().replaceAll("-", "") + suffix + : originalName; + String path = "/" + dateDir + "/" + name; + + if (!isRemote()) { + File out = new File(getUploadDir(), dateDir + "/" + name); + FileUtil.mkParentDirs(out); + FileUtil.copy(src, out, true); + return new StoredFile(path, out.length(), + StrUtil.isBlank(contentType) ? FileServerUtil.getContentType(out) : contentType, + StrUtil.isBlank(originalName) ? out.getName() : originalName); + } + + if (!uuidName) { + // 与 store(MultipartFile) 一致:非 uuid 命名时手动避让重名 + String prefix = StrUtil.removeSuffix(originalName, suffix); + int sameSize = 2; + while (existsInStore(path)) { + path = "/" + dateDir + "/" + prefix + "(" + sameSize + ")" + suffix; + sameSize++; + } + } + String type = StrUtil.isBlank(contentType) ? FileServerUtil.getContentType(src) : contentType; + putObject(KEY_PREFIX + normalize(path), src, type); + return new StoredFile(path, src.length(), type, + StrUtil.isBlank(originalName) ? StrUtil.subAfter(path, "/", true) : originalName); + } + /** * 保存 base64 图片 */ diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 3e4165a..3528085 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -46,3 +46,11 @@ wxpay: # 要切回内网:把下面一行改成 http://10.100.158.15/v1,或整段删除以复用默认值。 dify: base-url: https://dify-demo.gxmu.edu.cn/v1 + +# AI 产物转存(后端把上游文件下载进我们自己的存储,换发 /api/file 地址)。 +# 生产必须开:内网 Dify 返回的是 http://10.100.158.x/files/...,端上打不开。 +# 开发环境关掉:这里连的是公网 demo,返回的地址本地能直接取,转存只会让每次生成多等一次下载。 +ai: + creative: + media-relay: + enabled: true diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiCreativeMediaRelayTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeMediaRelayTest.java new file mode 100644 index 0000000..6bbd49f --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeMediaRelayTest.java @@ -0,0 +1,148 @@ +package com.gxwebsoft.ai.controller; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.ai.service.CreativeMediaRelay; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 结果装配阶段的地址替换:上游内网地址 → 我方 {@code /api/file} 地址。 + * + *

转存本身在 {@link CreativeMediaRelay} 里做(见 CreativeMediaRelayTest), + * 这里只验证装配契约: + *

    + *
  • 下发地址换成对外地址,原地址留在 {@code sourceUrl} 供排查;
  • + *
  • 正文不再残留内网地址({@code answerIsOnlyMediaUrls} 要认得出 sourceUrl);
  • + *
  • 已经是本站地址的不再二次下载;
  • + *
  • 转存失败时保持原样,不能比现状更差。
  • + *
+ */ +class AiCreativeMediaRelayTest { + + private static final String INNER = "http://10.100.158.15/files/video/2026/a.mp4"; + private static final String PUBLIC = "https://twai-api.gxmu.edu.cn/api/file/20261025/relayed.mp4"; + + @Test + void replacesInnerAddressAndKeepsOriginalForReference() throws Exception { + Map result = buildResult(controllerWithRelay(url -> PUBLIC), "video_result", INNER); + + Map outputs = (Map) result.get("outputs"); + List files = (List) outputs.get("video_result"); + Map file = (Map) files.get(0); + assertEquals(PUBLIC, file.get("url")); + assertEquals(INNER, file.get("sourceUrl")); + + String answer = String.valueOf(result.get("answer")); + assertTrue(answer.contains(PUBLIC), answer); + assertFalse(answer.contains("10.100.158.15"), "正文不能残留内网地址: " + answer); + } + + @Test + void skipsAddressesAlreadyServedByUs() throws Exception { + AtomicInteger calls = new AtomicInteger(); + AiController controller = controllerWithRelay(url -> { + calls.incrementAndGet(); + return PUBLIC; + }); + String own = "https://twai-api.gxmu.edu.cn/api/file/20261025/exists.mp4"; + + Map result = buildResult(controller, "video_result", own); + + assertEquals(0, calls.get(), "本站地址不应再次下载"); + Map file = (Map) ((List) ((Map) result.get("outputs")) + .get("video_result")).get(0); + assertEquals(own, file.get("url")); + assertNull(file.get("sourceUrl")); + } + + @Test + void keepsOriginalAddressWhenRelayFails() throws Exception { + Map result = buildResult(controllerWithRelay(url -> null), "video_result", INNER); + + Map file = (Map) ((List) ((Map) result.get("outputs")) + .get("video_result")).get(0); + assertEquals(INNER, file.get("url")); + assertNull(file.get("sourceUrl")); + String answer = String.valueOf(result.get("answer")); + assertTrue(answer.contains(INNER), answer); + } + + @Test + void relaysPhotoFilesWithImageSemantics() throws Exception { + AtomicInteger videoCalls = new AtomicInteger(); + AtomicInteger imageCalls = new AtomicInteger(); + AiController controller = controllerWithRelay(new CreativeMediaRelay() { + @Override + public String relay(String url, String suggestedName, boolean video) { + (video ? videoCalls : imageCalls).incrementAndGet(); + return PUBLIC; + } + }); + + String image = "http://10.100.158.15/files/image/2026/a.png"; + Map result = buildResult(controller, "photo_files", image); + + assertEquals(1, imageCalls.get(), "photo_files 应按图片口径转存"); + assertEquals(0, videoCalls.get()); + Map file = (Map) ((List) ((Map) result.get("outputs")) + .get("photo_files")).get(0); + assertEquals(PUBLIC, file.get("url")); + } + + private interface RelayStub { + String relay(String url); + } + + private static AiController controllerWithRelay(RelayStub stub) throws Exception { + return controllerWithRelay(new CreativeMediaRelay() { + @Override + public String relay(String url, String suggestedName, boolean video) { + return stub.relay(url); + } + }); + } + + private static AiController controllerWithRelay(CreativeMediaRelay relay) throws Exception { + AiController controller = new AiController(); + Field field = AiController.class.getDeclaredField("creativeMediaRelay"); + field.setAccessible(true); + field.set(controller, relay); + return controller; + } + + private static Map buildResult(AiController controller, String key, String url) + throws Exception { + JSONArray files = new JSONArray(); + files.add(url); + + JSONObject outputs = new JSONObject(true); + outputs.put(key, files); + + JSONObject data = new JSONObject(true); + data.put("status", "succeeded"); + data.put("outputs", outputs); + + JSONObject responseData = new JSONObject(true); + responseData.put("event", "workflow_finished"); + responseData.put("workflow_run_id", "b2e18332-68bd-49e6-90e2-bd9d16d428ff"); + responseData.put("data", data); + + Method method = AiController.class.getDeclaredMethod( + "buildCreativeAssistantResult", Map.class, String.class); + method.setAccessible(true); + //noinspection unchecked + return (Map) method.invoke(controller, responseData, ""); + } +} diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiCreativeVideoOutputTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeVideoOutputTest.java new file mode 100644 index 0000000..e19b047 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeVideoOutputTest.java @@ -0,0 +1,182 @@ +package com.gxwebsoft.ai.controller; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 短视频 outputs 的形状兼容(禅道 Bug #1:AI 短视频未生成)。 + * + *

症状是「上游接口已通、返回正常,我方没有产出可播放的视频文件,也没有回传可访问的地址」。 + * 根因不在上游,而在归一环节只认图片:{@code video_result} 里的元素若直接是地址字符串 + * (裸地址或 {@code [视频](url)} 链接),四个页面的 {@code resolveFileUrl} 都取不到地址, + * 于是视频卡片既不播放、复制链接也永远是空的;纯视频分支还会因为 answer 为 null + * 连生成记录都不入库({@code AiGenerationRecorder} 跳过空白正文)。 + * + *

修复口径与出图一致:按**内容**识别视频并归一到 {@code video_result}, + * 元素统一成 {@code {url,name,type:video}},answer 与生成记录里补上可点开的链接。 + */ +class AiCreativeVideoOutputTest { + + private static final String VIDEO_A = + "https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/doubao-seedance-1-0-pro/2026/a.mp4" + + "?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Signature=0123456789abcdef0123456789abcdef"; + private static final String VIDEO_B = + "https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/doubao-seedance-1-0-pro/2026/b.mp4" + + "?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Signature=fedcba9876543210fedcba9876543210"; + + private static Map buildResult(JSONObject outputs) throws Exception { + JSONObject data = new JSONObject(true); + data.put("status", "succeeded"); + data.put("outputs", outputs); + + JSONObject responseData = new JSONObject(true); + responseData.put("event", "workflow_finished"); + responseData.put("workflow_run_id", "b2e18332-68bd-49e6-90e2-bd9d16d428ff"); + responseData.put("task_id", "0b1fdd88-cc6f-45b8-989d-be4f85bcff74"); + responseData.put("data", data); + + Method method = AiController.class.getDeclaredMethod( + "buildCreativeAssistantResult", Map.class, String.class); + method.setAccessible(true); + //noinspection unchecked + return (Map) method.invoke(new AiController(), responseData, ""); + } + + @SuppressWarnings("unchecked") + private static String replyForHistory(Map result) throws Exception { + Method method = AiController.class.getDeclaredMethod("creativeReplyForHistory", Map.class); + method.setAccessible(true); + return (String) method.invoke(new AiController(), result); + } + + @Test + void 字符串数组_归一到video_result并出可点开的链接() throws Exception { + JSONArray videos = new JSONArray(); + videos.add(VIDEO_A); + videos.add(VIDEO_B); + + JSONObject outputs = new JSONObject(true); + outputs.put("video_result", videos); + + Map result = buildResult(outputs); + Map normalized = (Map) result.get("outputs"); + List videoFiles = (List) normalized.get("video_result"); + assertNotNull(videoFiles, "视频必须归一到 video_result,四个页面才认得:" + normalized); + assertEquals(2, videoFiles.size()); + + Map first = (Map) videoFiles.get(0); + assertEquals(VIDEO_A, first.get("url"), "字符串元素要转成文件对象,端上才取得到地址"); + assertEquals("a.mp4", first.get("name")); + assertEquals("video", first.get("type")); + + String answer = String.valueOf(result.get("answer")); + assertTrue(answer.contains("(" + VIDEO_A + ")"), "answer 里要有可点开的视频链接:" + answer); + assertTrue(answer.contains("(" + VIDEO_B + ")"), "两条视频都要在:" + answer); + } + + @Test + void markdown链接形式的视频_同样归一() throws Exception { + JSONObject outputs = new JSONObject(true); + outputs.put("video_result", "[视频](" + VIDEO_A + ")"); + + Map normalized = (Map) buildResult(outputs).get("outputs"); + List videoFiles = (List) normalized.get("video_result"); + assertNotNull(videoFiles, "markdown 链接形式的视频不能丢:" + normalized); + assertEquals(1, videoFiles.size()); + assertEquals(VIDEO_A, ((Map) videoFiles.get(0)).get("url")); + } + + @Test + void 文件对象_保留地址与文件名并补齐type() throws Exception { + JSONObject file = new JSONObject(true); + file.put("remote_url", VIDEO_A); + file.put("filename", "校园活动宣传.mp4"); + file.put("mime_type", "video/mp4"); + + JSONArray files = new JSONArray(); + files.add(file); + + JSONObject outputs = new JSONObject(true); + outputs.put("video_result", files); + + Map normalized = (Map) buildResult(outputs).get("outputs"); + Map video = (Map) ((List) normalized.get("video_result")).get(0); + assertEquals(VIDEO_A, video.get("url"), "remote_url 也要认,与端上的 getCreativeFileUrl 同口径"); + assertEquals("校园活动宣传.mp4", video.get("name"), "上游给的文件名要保留"); + assertEquals("video", video.get("type")); + assertEquals("video/mp4", video.get("contentType")); + } + + @Test + void 键名漂移_按内容识别成视频() throws Exception { + JSONObject outputs = new JSONObject(true); + outputs.put("video7", VIDEO_B); + + Map normalized = (Map) buildResult(outputs).get("outputs"); + List videoFiles = (List) normalized.get("video_result"); + assertNotNull(videoFiles, "变量名漂移时按内容兜底,而不是写白名单:" + normalized); + assertEquals(VIDEO_B, ((Map) videoFiles.get(0)).get("url")); + } + + @Test + void 有文案时不把文件地址当正文_但记录里要带上链接() throws Exception { + JSONObject file = new JSONObject(true); + file.put("url", VIDEO_A); + JSONArray files = new JSONArray(); + files.add(file); + + JSONObject outputs = new JSONObject(true); + outputs.put("video_message", "已按你的要求生成 45 秒校园活动宣传视频。"); + outputs.put("video_result", files); + + Map result = buildResult(outputs); + assertEquals("已按你的要求生成 45 秒校园活动宣传视频。", result.get("answer"), + "正文不能被文件地址挤掉"); + + String reply = replyForHistory(result); + assertTrue(reply.contains("已按你的要求生成"), "记录正文保留可读文案:" + reply); + assertTrue(reply.contains("(" + VIDEO_A + ")"), + "生成记录只存 markdown,链接不补进去用户回看就没有视频:" + reply); + } + + @Test + void 纯视频无文案_也要能入库() throws Exception { + JSONObject file = new JSONObject(true); + file.put("url", VIDEO_A); + JSONArray files = new JSONArray(); + files.add(file); + + JSONObject outputs = new JSONObject(true); + outputs.put("video_result", files); + + Map result = buildResult(outputs); + assertNotNull(result.get("answer"), "纯视频分支必须有正文,否则整条生成记录不入库"); + + String reply = replyForHistory(result); + assertNotNull(reply); + assertTrue(reply.contains("(" + VIDEO_A + ")"), "记录里要能点开视频:" + reply); + } + + @Test + void 归一只影响下发载荷_不动上游raw() throws Exception { + JSONObject outputs = new JSONObject(true); + outputs.put("video_result", new JSONArray().fluentAdd(VIDEO_A)); + + Map result = buildResult(outputs); + Map rawOutputs = (Map) ((Map) ((Map) result.get("raw")).get("data")).get("outputs"); + assertEquals(VIDEO_A, ((List) rawOutputs.get("video_result")).get(0), + "raw 必须保持上游原样"); + assertFalse(((Map) ((Map) ((Map) result.get("raw")).get("data")) + .get("outputs")).isEmpty(), "raw 不能被改写"); + } +} diff --git a/src/test/java/com/gxwebsoft/ai/service/CreativeMediaRelayTest.java b/src/test/java/com/gxwebsoft/ai/service/CreativeMediaRelayTest.java new file mode 100644 index 0000000..a26e7a9 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/service/CreativeMediaRelayTest.java @@ -0,0 +1,175 @@ +package com.gxwebsoft.ai.service; + +import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.common.core.storage.FileStorageService; +import com.gxwebsoft.common.core.storage.NoopObjectStorage; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 内网产物转存(禅道 Bug #1 的后续:Dify 返回 {@code http://10.100.158.x/files/...}, + * 端上打不开)。 + * + *

这里用本地 HTTP 服务模拟上游:真实走一遍 {@code HttpURLConnection} 下载 → + * {@link FileStorageService#storeFile} 落盘 → 拼对外地址,确认端上最终拿到的是 + * 我们自己 {@code /api/file/...} 的地址,且字节没有被动过。 + */ +class CreativeMediaRelayTest { + + private HttpServer server; + private File tempDir; + private FileStorageService storage; + private CreativeMediaRelay relay; + private String base; + + private byte[] videoBytes; + private byte[] imageBytes; + + @BeforeEach + void setUp() throws Exception { + tempDir = Files.createTempDirectory("media-relay-test").toFile(); + + ConfigProperties config = new ConfigProperties(); + config.setUploadPath(tempDir.getAbsolutePath()); + config.setFileServer("http://localhost:8080"); + config.setUploadUuidName(true); + + storage = new FileStorageService(); + setField(storage, "config", config); + setField(storage, "objectStorage", NoopObjectStorage.INSTANCE); + + relay = new CreativeMediaRelay(storage, config, true); + + videoBytes = new byte[8192]; + for (int i = 0; i < videoBytes.length; i++) { + videoBytes[i] = (byte) (i % 251); + } + imageBytes = "fake-png-bytes".getBytes(); + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/media/a.mp4", exchange -> { + exchange.getResponseHeaders().add("Content-Type", "video/mp4"); + exchange.sendResponseHeaders(200, videoBytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(videoBytes); + } + }); + server.createContext("/media/a.png", exchange -> { + exchange.getResponseHeaders().add("Content-Type", "image/png"); + exchange.sendResponseHeaders(200, imageBytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(imageBytes); + } + }); + server.createContext("/media/missing.mp4", exchange -> { + exchange.sendResponseHeaders(404, -1); + exchange.close(); + }); + server.createContext("/media/huge.jpg", exchange -> { + // 只声明一个超过图片上限的大小,不真的写 40MB 数据 + exchange.getResponseHeaders().add("Content-Type", "image/jpeg"); + try { + exchange.sendResponseHeaders(200, 40L * 1024 * 1024); + } catch (Exception ignored) { + // 客户端在读到声明长度后就放弃了,连接被提前关闭是预期行为 + } + exchange.close(); + }); + server.createContext("/media/error.html", exchange -> { + byte[] body = "upstream error".getBytes(); + exchange.getResponseHeaders().add("Content-Type", "text/html"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.start(); + base = "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + void relaysVideoIntoOwnStorageAndReturnsPublicUrl() throws Exception { + String url = relay.relay(base + "/media/a.mp4", "生成视频", true); + + assertNotNull(url); + assertTrue(url.startsWith("http://localhost:8080/api/file/"), url); + assertTrue(url.endsWith(".mp4"), url); + + String path = url.substring("http://localhost:8080/api/file".length()); + File stored = storage.resolve(path); + assertNotNull(stored, "转存后的文件应能通过 /api/file 的解析路径取到"); + assertArrayEquals(videoBytes, Files.readAllBytes(stored.toPath())); + } + + @Test + void relaysImageAsWell() throws Exception { + String url = relay.relay(base + "/media/a.png", null, false); + + assertNotNull(url); + assertTrue(url.endsWith(".png"), url); + File stored = storage.resolve(url.substring("http://localhost:8080/api/file".length())); + assertNotNull(stored); + assertArrayEquals(imageBytes, Files.readAllBytes(stored.toPath())); + } + + @Test + void keepsOriginalWhenUpstreamReturnsError() { + assertNull(relay.relay(base + "/media/missing.mp4", "视频", true)); + } + + @Test + void keepsOriginalWhenDeclaredSizeExceedsLimit() { + assertNull(relay.relay(base + "/media/huge.jpg", "图片", false)); + } + + @Test + void keepsOriginalWhenUpstreamReturnsErrorPage() { + // 存成视频文件只会让端上拿到一个"能加载但放不了"的坏文件 + assertNull(relay.relay(base + "/media/error.html", "视频", true)); + } + + @Test + void ignoresNonHttpUrl() { + assertNull(relay.relay("ftp://127.0.0.1/a.mp4", "视频", true)); + assertNull(relay.relay("/files/a.mp4", "视频", true)); + assertNull(relay.relay("", "视频", true)); + assertNull(relay.relay(null, "视频", true)); + } + + @Test + void refusesCloudMetadataAddress() { + assertNull(relay.relay("http://169.254.169.254/latest/meta-data/", "视频", true)); + } + + @Test + void doesNothingWhenDisabled() { + CreativeMediaRelay disabled = new CreativeMediaRelay(storage, null, false); + assertNull(disabled.relay(base + "/media/a.mp4", "视频", true)); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +}