mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
* feat(workspace): chat-uploads 上传目录工作空间/Agent 感知化 (#421) 把硬编码的 data/chat-uploads/{conversationId}/ 改为按工作空间/Agent 解析, 解析优先级:Agent workspaceBasePath → Workspace basePath → 可配置默认目录 (新配置 mateclaw.chat.upload.base-dir,默认 data/chat-uploads,保持现网零行为变化)。 - 新增 ChatUploadLocationResolver 中央解析器:写路径返回唯一根,读/清理 路径返回候选根列表(工作空间根 + 默认根)做双重查找,保证迁移前旧附件 仍可解析/清理;conversationId→ConversationEntity 查询带 5min 缓存。 - 新增 ChatUploadProperties + ChatUploadAutoConfiguration(启动建目录)。 - 复用 AgentGraphBuilder.resolveAgentBasePath(提升为 public)的优先级与 安全规则(相对路径在 workspace 根下解析,绝对路径逃逸被拒)。 - 所有写入/读取点改为走 resolver;读取点走双重查找。 - 解决 Spring 循环依赖(resolver → agentService → ... → conversationService → resolver):resolver 的 AgentService 注入加 @Lazy。 向后兼容:默认目录不变;双重查找覆盖历史消息里的相对路径; 服务端点 URL 契约不变,前端无需改动。 测试:新增 ChatUploadLocationResolverTest(8 用例);修复受影响的现有测试构造。 * refactor(workspace): address review findings on chat-uploads resolver (#421) 应用 code review 的 4 项修复: 1. (correctness) ChatUploadLocationResolver 缓存新增 ConversationDeletedEvent 监听器,删除会话时立即失效 conversationId→ConversationEntity 映射。 否则备份恢复后用相同 id 重建会话,会继承最长 5 分钟的过期 workspace/agent 映射,导致 cleanAttachmentFiles 走错(过期的)上传目录。复用既有 @EventListener-on-bean 模式(与 AsyncTaskService / WorkspaceLookupCache 一致)。 2. 收紧 resolveWorkspaceScopedRoot 里 3 个过宽的 catch(Exception) → MateClawException + warn,让真正的 bug(NPE / DataAccessException)暴露 而非被静默降级为 debug 日志。 3. 更新 ChatController.upload 过期注释:会话尚未创建时附件暂存默认目录, 会话创建后读取走双重查找仍能命中。 4. 移除不可达分支(agentWorkspaceId != workspaceId)—— 会话的 agent 必然 归属会话的 workspace(创建时强约束),直接用会话 workspace 即可, 少一次冗余 DB 查询与一层推测性逻辑。 测试:ChatUploadLocationResolverTest (8) + ConversationServiceCleanAttachmentFilesTest (2) 全绿。
137 lines
5.6 KiB
Java
137 lines
5.6 KiB
Java
package vip.mate.tool.image;
|
|
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.DisplayName;
|
|
import org.junit.jupiter.api.Tag;
|
|
import org.junit.jupiter.api.Test;
|
|
import vip.mate.workspace.core.service.ChatUploadLocationResolverTestSupport;
|
|
|
|
import java.io.IOException;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.nio.file.Paths;
|
|
import java.util.Base64;
|
|
import java.util.Comparator;
|
|
import java.util.stream.Stream;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
/**
|
|
* Unit tests for the data-URL handling added to {@link ImageFileDownloader}.
|
|
*
|
|
* <p>Network-bound HTTP downloads are intentionally not exercised here —
|
|
* the regression we care about is the silent failure that happened when a
|
|
* provider returned a {@code data:image/png;base64,...} URL: callers fed
|
|
* that into {@code HttpUtil.downloadFile}, which mangled it into something
|
|
* like {@code file:/cwd/http:/data:image/...} and threw, so the image
|
|
* never landed on disk and the assistant message rendered empty.
|
|
*
|
|
* <p>The downloader writes under {@code data/chat-uploads/<conv>/...}
|
|
* relative to the JVM's working directory; we sweep that directory after
|
|
* each test so the run leaves no artefacts behind.
|
|
*/
|
|
@Tag("media-gen")
|
|
class ImageFileDownloaderTest {
|
|
|
|
private ImageFileDownloader downloader;
|
|
private final String conv = "test-conv-" + System.nanoTime();
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
downloader = new ImageFileDownloader(ChatUploadLocationResolverTestSupport.legacyDefault());
|
|
}
|
|
|
|
@AfterEach
|
|
void cleanup() throws IOException {
|
|
Path dir = Paths.get("data", "chat-uploads", conv);
|
|
if (!Files.exists(dir)) return;
|
|
try (Stream<Path> walk = Files.walk(dir)) {
|
|
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
|
|
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
|
|
});
|
|
}
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("download writes the decoded bytes when given a base64 data URL")
|
|
void download_baseDataUrl_writesDecodedBytes() throws Exception {
|
|
// 1x1 transparent PNG — the smallest legal payload we can verify byte-for-byte
|
|
byte[] pngBytes = new byte[]{
|
|
(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n',
|
|
0, 0, 0, 13, 'I', 'H', 'D', 'R',
|
|
0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0,
|
|
0x1F, 0x15, (byte) 0xC4, (byte) 0x89
|
|
};
|
|
String dataUrl = "data:image/png;base64," + Base64.getEncoder().encodeToString(pngBytes);
|
|
|
|
Path saved = downloader.download(dataUrl, conv, "task1", 0);
|
|
|
|
assertTrue(Files.exists(saved), "saved file must exist");
|
|
assertTrue(saved.getFileName().toString().endsWith(".png"));
|
|
byte[] readBack = Files.readAllBytes(saved);
|
|
assertArrayEquals(pngBytes, readBack, "stored bytes must match decoded payload");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("download picks extension from the data-URL media type")
|
|
void download_extensionMatchesMediaType() throws Exception {
|
|
Path png = downloader.download(
|
|
"data:image/png;base64," + Base64.getEncoder().encodeToString(new byte[]{1, 2, 3}),
|
|
conv, "ext-png", 0);
|
|
assertTrue(png.getFileName().toString().endsWith(".png"));
|
|
|
|
Path jpg = downloader.download(
|
|
"data:image/jpeg;base64," + Base64.getEncoder().encodeToString(new byte[]{4, 5, 6}),
|
|
conv, "ext-jpg", 0);
|
|
assertTrue(jpg.getFileName().toString().endsWith(".jpg"));
|
|
|
|
Path webp = downloader.download(
|
|
"data:image/webp;base64," + Base64.getEncoder().encodeToString(new byte[]{7, 8, 9}),
|
|
conv, "ext-webp", 0);
|
|
assertTrue(webp.getFileName().toString().endsWith(".webp"));
|
|
|
|
// Unknown / missing media type → default to png
|
|
Path fallback = downloader.download(
|
|
"data:;base64," + Base64.getEncoder().encodeToString(new byte[]{0}),
|
|
conv, "ext-fallback", 0);
|
|
assertTrue(fallback.getFileName().toString().endsWith(".png"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("download accepts the percent-encoded body form (no ;base64)")
|
|
void download_percentEncodedDataUrl() throws Exception {
|
|
// The ";base64" form is the common one but RFC 2397 also allows a raw
|
|
// (URL-encoded) body. Make sure both round-trip safely.
|
|
String dataUrl = "data:image/png,hello%20world";
|
|
Path saved = downloader.download(dataUrl, conv, "raw", 0);
|
|
assertEquals("hello world", Files.readString(saved));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("download rejects malformed data URLs cleanly")
|
|
void download_malformedDataUrlIsRejected() {
|
|
IOException ex = assertThrows(IOException.class,
|
|
() -> downloader.download("data:image/png;base64", conv, "bad", 0));
|
|
assertTrue(ex.getMessage().contains("Malformed data URL"),
|
|
"expected explanatory error, got: " + ex.getMessage());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("download rejects invalid base64 payloads with a wrapped IOException")
|
|
void download_invalidBase64IsWrapped() {
|
|
// !!! is not a legal base64 token
|
|
IOException ex = assertThrows(IOException.class,
|
|
() -> downloader.download("data:image/png;base64,!!!", conv, "badb64", 0));
|
|
assertTrue(ex.getMessage().toLowerCase().contains("base64"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("download rejects null URLs without leaking NPE")
|
|
void download_nullIsRejected() {
|
|
IOException ex = assertThrows(IOException.class,
|
|
() -> downloader.download(null, conv, "null", 0));
|
|
assertTrue(ex.getMessage().contains("null"));
|
|
}
|
|
}
|