fix(ai): 短视频/图片产物归一并转存为对外地址(禅道 #1)

- 归一按内容识别 video_result(此前只认图片):地址字符串、markdown 链接、
  文件对象都统一成 {url,name,type:video},端上四个页面才取得到播放地址,
  修复「上游接口已通、我方未输出视频、复制链接为空」;
- answer 不再残留裸地址:媒体地址命中时改写成「文件名 + 链接」,
  生成记录也补上媒体直链,纯视频分支不再因正文为空而整条不入库;
- 新增 CreativeMediaRelay:上游产物在内网,端上打不开,由后端下载、
  存入本项目文件存储,下发 /api/file 对外地址(Range 续传现成);
  失败只降级保留原地址,不影响本次生成;
- FileStorageService.storeFile 支持把磁盘文件落入本地/对象存储;
- 开发环境关闭转存(公网 demo 地址本就可直接访问),生产默认开启。
This commit is contained in:
2026-10-25 15:55:49 +08:00
parent 5626cdd679
commit 0252672997
7 changed files with 1321 additions and 13 deletions
@@ -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://...)。
*
* <p>视频没法内联成图片,上游给视频结果时用的是链接形式
* ({@code [视频](https://.../a.mp4)}),与 {@link #MARKDOWN_IMAGE_PATTERN} 区分开。
*/
private static final Pattern MARKDOWN_LINK_PATTERN =
Pattern.compile("\\[[^\\]]*\\]\\((https?://[^)\\s]+)\\)");
/**
* 裸视频 URL(按扩展名判定)。
*
* <p>{@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<String, Object> 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<String, Object> 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},因此不必逐个页面加字段。
*
* <p>视频同理:{@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<Object> videoFiles = new ArrayList<>();
Set<String> 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<String, Object> 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} 收进统一列表。
*
* <p>元素可能是文件对象({@code {url,name,...}}),也可能直接是视频地址字符串
* (裸地址或 {@code [视频](url)} 链接)。后者必须转成文件对象:四个页面的
* {@code resolveFileUrl} 只认对象上的 url 字段,字符串元素一律取不到地址,
* 表现出来就是「有视频却不播放、复制链接为空」。
*/
private void collectVideoFiles(Object raw, List<Object> target, Set<String> 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}}。
*
* <p>入参为 {@code null} 表示上游只给了地址字符串;否则保留原来的
* {@code name} / {@code contentType} / {@code size},只补齐端上要用的字段。
*/
private Map<String, Object> videoFileEntryFrom(Map<?, ?> file, String url) {
Map<String, Object> 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<String> extractVideoUrls(Object value) {
List<String> urls = new ArrayList<>();
collectVideoUrls(value, urls);
return urls;
}
private void collectVideoUrls(Object value, List<String> 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 链接段落。
*
* <p>视频没法像图片那样内联成 {@code ![](url)}(markdown 渲染器会把 mp4 当破图),
* 所以用链接:{@code [名字](url)}。它同时是历史记录里唯一能点开视频的东西 ——
* 生成记录只存 markdown 正文,链接不写进去,用户回看就只剩一句「已生成」。
*/
private String videoMarkdownFromOutputs(Map<String, Object> outputs) {
Object raw = outputs.get("video_result");
if (!(raw instanceof List)) {
return null;
}
List<String> 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<String> 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<String, Object> outputs) {
if (outputs == null || outputs.isEmpty()) {
return null;
}
List<String> 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 是否「只有一串媒体地址」。
*
* <p>{@code video_result} 以字符串数组下发时,{@link #findDisplayableValueText(List)}
* 会把地址当正文拼进 answer;长签名地址并不比 task_id 短,只是带标点绕过了
* {@link #LONG_TOKEN_PATTERN} 的整串判定。若这些地址本次都已归一到文件列表,
* 正文就该换成“文件名 + 链接”,否则聊天气泡里躺着两条几百字符的签名地址。
*/
private boolean answerIsOnlyMediaUrls(String answer, Map<String, Object> outputs) {
if (StrUtil.isBlank(answer)) {
return false;
}
Set<String> 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<String> mediaUrlsOf(Map<String, Object> outputs) {
Set<String> urls = new LinkedHashSet<>();
collectMediaUrls(outputs.get("photo_files"), urls);
collectMediaUrls(outputs.get("video_result"), urls);
return urls;
}
private void collectMediaUrls(Object raw, Set<String> 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));
}
/**
* 把归一后媒体元素的地址换成我们自己的对外地址。
*
* <p>上游 Dify 部署在内网,返回的 {@code http://10.100.158.x/files/...}
* 后端能取、端上取不到(见 {@link CreativeMediaRelay})。这里在装配结果时
* 就地替换,并保留 {@code sourceUrl}:历史记录与排查都还需要原地址。
*
* <p>空安全:单元测试直接 {@code new AiController()} 反射调用本类方法,
* 此时 Spring 尚未注入依赖,不能因为转存而 NPE。
*/
private void relayMediaFiles(Map<String, Object> outputs) {
if (outputs == null || creativeMediaRelay == null || !creativeMediaRelay.isEnabled()) {
return;
}
// 同一地址一次请求内只下载一次;失败也缓存,避免对坏链接反复超时
Map<String, String> 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<String, String> relayed) {
if (!(raw instanceof List)) {
return;
}
for (Object item : (List<?>) raw) {
if (!(item instanceof Map)) {
continue;
}
//noinspection unchecked
Map<String, Object> file = (Map<String, Object>) 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 加上媒体直链。
*
* <p>视频/图片的产物不写在 answer 里(answer 通常是「已为你生成 1 条视频」这类文案),
* 而生成记录只存 markdown 正文。若原样落库,用户从历史回看只有一句话、
* 没有任何地址;正文为空时甚至整条记录都不入库({@code AiGenerationRecorder}
* 对空白 reply 直接跳过)。这里把 outputs 里的媒体链接补进去,
* answer 里已经出现过的地址不重复追加。
*/
private String creativeReplyForHistory(Map<String, Object> 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<String, Object>) outputs);
if (media == null) {
return answer;
}
if (StrUtil.isBlank(answer)) {
return media;
}
List<String> 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 {
*
* <p>List 里**只取字符串元素**,不递归进 Map:{@code video_result} /
* {@code photo_files} 这类列表装的是文件对象,递归进去会把文件 URL 当正文,
* 挤掉真正的脚本/文案字段。
* 挤掉真正的脚本/文案字段。视频/图片的归一由
* {@link #normalizeCreativeOutputs(Map)} 负责,这里只判「能不能当正文展示」。
*/
private String findDisplayableValueText(Object value) {
if (isDisplayableText(value)) {
@@ -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 等)产物下载到我们自己的文件存储,换成对外可访问的地址。
*
* <p>为什么需要它:生产环境的 Dify 部署在内网({@code http://10.100.158.15/v1}),
* 它返回的图片/视频地址是 {@code http://10.100.158.x/files/...}。
* 后端能访问,但浏览器与小程序不能——端上拿到的是一张永远裂开的图、
* 一个永远转圈的视频。前端拿到地址后再"自己想办法"是死路:跨域、鉴权、
* Range、签名 URL 过期都在对端,我们控不了。
*
* <p>所以在后端收口:产物一下来就由我们下载、落进本项目的文件存储
* (对象存储 RustFS / 本地磁盘,见 {@link FileStorageService}),
* 对外只下发 {@code https://twai-api.gxmu.edu.cn/api/file/...}。
* 这条链路本来就是端上上传/预览走的同一条,Range 断点续传、缩略图、
* 免鉴权 GET 都现成。
*
* <p>边界:
* <ul>
* <li>只处理 http/https;云元数据地址直接拒绝(避免被上游报文当成 SSRF 跳板);</li>
* <li>内网地址**必须放行**,这正是要转存的那一类;</li>
* <li>按流写临时文件并计数,超限立刻中断——视频动辄几百 MB,不能走 byte[];</li>
* <li>任何失败都只记日志并返回 {@code null},调用方保留原地址。
* 转存是增强,不能因为下载失败让本来能用的结果变成不可用。</li>
* </ul>
*
* @author WebSoft
*/
@Service
public class CreativeMediaRelay {
private static final Logger log = LoggerFactory.getLogger(CreativeMediaRelay.class);
/**
* 图片上限:Dify 出图单张通常 &lt; 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,挡掉本机与云元数据地址。
*
* <p>内网与本机地址都不挡:需要转存的正是内网那一类,本机地址在单机部署里也可能是
* 上游自己的文件服务。真正要挡的是云元数据接口——那是唯一能让"下载"变成凭证泄露的地址。
*/
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;
}
}
@@ -116,6 +116,51 @@ public class FileStorageService {
}
}
/**
* 保存一个已经在磁盘上的文件(远端产物转存用)。
*
* <p>与 {@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 图片
*/
+8
View File
@@ -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
@@ -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} 地址。
*
* <p>转存本身在 {@link CreativeMediaRelay} 里做(见 CreativeMediaRelayTest),
* 这里只验证装配契约:
* <ul>
* <li>下发地址换成对外地址,原地址留在 {@code sourceUrl} 供排查;</li>
* <li>正文不再残留内网地址({@code answerIsOnlyMediaUrls} 要认得出 sourceUrl);</li>
* <li>已经是本站地址的不再二次下载;</li>
* <li>转存失败时保持原样,不能比现状更差。</li>
* </ul>
*/
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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object>) method.invoke(controller, responseData, "");
}
}
@@ -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 短视频未生成)。
*
* <p>症状是「上游接口已通、返回正常,我方没有产出可播放的视频文件,也没有回传可访问的地址」。
* 根因不在上游,而在归一环节只认图片:{@code video_result} 里的元素若直接是地址字符串
* (裸地址或 {@code [视频](url)} 链接),四个页面的 {@code resolveFileUrl} 都取不到地址,
* 于是视频卡片既不播放、复制链接也永远是空的;纯视频分支还会因为 answer 为 null
* 连生成记录都不入库({@code AiGenerationRecorder} 跳过空白正文)。
*
* <p>修复口径与出图一致:按**内容**识别视频并归一到 {@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<String, Object> 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<String, Object>) method.invoke(new AiController(), responseData, "");
}
@SuppressWarnings("unchecked")
private static String replyForHistory(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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 不能被改写");
}
}
@@ -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/...},
* 端上打不开)。
*
* <p>这里用本地 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 = "<html>upstream error</html>".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);
}
}