From 6f13a70709ac5e908743336c16acf34c645921f1 Mon Sep 17 00:00:00 2001 From: czhcc Date: Tue, 29 Sep 2026 17:37:11 +0800 Subject: [PATCH] fix(chat): resolve uploaded images from conversation storage Resolve web image attachments by authenticated conversation origin and stored name through ChatUploadLocationResolver. Apply the same resolution to native and recent-image inputs and validate submitted uploads. Refs #641. --- .../vip/mate/agent/AgentGraphBuilder.java | 7 ++++ .../main/java/vip/mate/agent/BaseAgent.java | 29 +++++++++++--- .../vip/mate/channel/web/ChatController.java | 28 ++++++++++++++ .../mate/llm/routing/MediaCaptionService.java | 23 +++++++++-- ...MediaCaptionServiceAttachmentPathTest.java | 38 +++++++++++++++++++ .../ChatUploadLocationResolverTest.java | 16 ++++++++ .../service/MemberFileIntegrationTest.java | 12 ++++++ 7 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/routing/MediaCaptionServiceAttachmentPathTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 45a1c44b..f2b39c2b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -158,6 +158,12 @@ public class AgentGraphBuilder { private final vip.mate.llm.chatmodel.DashScopeChatModelBuilder dashScopeBuilder; private final vip.mate.llm.routing.MultimodalRouter multimodalRouter; private final vip.mate.llm.routing.MediaCaptionService mediaCaptionService; + private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + + @Autowired + public void setChatUploadLocationResolver(vip.mate.workspace.core.service.ChatUploadLocationResolver resolver) { + this.chatUploadLocationResolver = resolver; + } private final vip.mate.goal.service.GoalService goalService; private final vip.mate.goal.service.GoalEvaluationService goalEvaluationService; private final vip.mate.goal.service.GoalFollowupService goalFollowupService; @@ -541,6 +547,7 @@ public class AgentGraphBuilder { agent.goalService = goalService; agent.multimodalRouter = multimodalRouter; agent.mediaCaptionService = mediaCaptionService; + agent.chatUploadLocationResolver = chatUploadLocationResolver; agent.userLocale = resolveLocale(); agent.temperature = runtimeModel.getTemperature(); agent.maxTokens = runtimeModel.getMaxTokens(); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index d1858124..12607cb1 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -29,6 +29,7 @@ import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.conversation.model.MessageEntity; import vip.mate.workspace.core.service.MemberFileAccess; import vip.mate.workspace.core.service.MemberFileIsolation; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.nio.file.Files; import java.nio.file.Path; @@ -132,6 +133,7 @@ public abstract class BaseAgent { */ protected MultimodalRouter multimodalRouter; protected MediaCaptionService mediaCaptionService; + protected ChatUploadLocationResolver chatUploadLocationResolver; /** * RFC 48 — wired by {@link AgentGraphBuilder#build} so the agent's @@ -1007,7 +1009,7 @@ public abstract class BaseAgent { && !contentType.contains("svg"); if (!isImage) continue; MediaCaptionService.CaptionResult result = mediaCaptionService.caption( - decision.sidecarModel(), part, userLocale, userQuestion); + decision.sidecarModel(), part, userLocale, userQuestion, ChatOriginHolder.get()); if (result.isFailure()) { log.warn("[{}] Sidecar caption failed for {}: {}", agentName, part.getFileName(), result.failure().getMessage()); @@ -1103,8 +1105,8 @@ public abstract class BaseAgent { } // 解析媒体文件路径:先尝试 path,再尝试 mediaId(IM 渠道下载后存在 mediaId 中),再拼接工作目录 - Path mediaPath = resolveImagePath(part.getPath()); - if (mediaPath == null && part.getMediaId() != null) { + Path mediaPath = resolveAttachmentPath(part); + if (mediaPath == null && part.getMediaId() != null && !isWebUpload(part)) { mediaPath = resolveImagePath(part.getMediaId()); } if (mediaPath == null) { @@ -1362,8 +1364,8 @@ public abstract class BaseAgent { for (int k = parts.size() - 1; k >= 0; k--) { MessageContentPart part = parts.get(k); if (!isResolvableImagePart(part)) continue; - Path imgPath = resolveImagePath(part.getPath()); - if (imgPath == null && part.getMediaId() != null) imgPath = resolveImagePath(part.getMediaId()); + Path imgPath = resolveAttachmentPath(part); + if (imgPath == null && part.getMediaId() != null && !isWebUpload(part)) imgPath = resolveImagePath(part.getMediaId()); if (imgPath == null) continue; String contentType = part.getContentType(); if (contentType == null || "image/*".equals(contentType)) contentType = "image/jpeg"; @@ -1419,6 +1421,23 @@ public abstract class BaseAgent { return new ByteArrayResource(MemberFileAccess.read(root, path, 32 * 1024 * 1024)); } + private Path resolveAttachmentPath(MessageContentPart part) { + ChatOrigin origin = ChatOriginHolder.get(); + if (chatUploadLocationResolver != null && origin != null + && "web".equals(origin.channelType()) && part.getStoredName() != null) { + // The client-visible path is informational; the stored name is resolved + // under the authenticated conversation's actual upload root. + return chatUploadLocationResolver.resolveExistingFile(origin, part.getStoredName()); + } + return resolveImagePath(part.getPath()); + } + + private boolean isWebUpload(MessageContentPart part) { + ChatOrigin origin = ChatOriginHolder.get(); + return chatUploadLocationResolver != null && origin != null + && "web".equals(origin.channelType()) && part.getStoredName() != null; + } + protected Path resolveImagePath(String relativePath) { if (relativePath == null || relativePath.isBlank()) { return null; diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index c3bc6097..3abe79ed 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -645,6 +645,9 @@ public class ChatController { List requestParts = regenerateSeed != null ? regenerateSeed.parts() : normalizeRequestParts(request); + if (regenerateSeed == null) { + validateUploadedParts(conversationId, selectedTurnOrigin, requestParts); + } String promptText = buildPromptText(message, requestParts); Long originMessageId; if (regenerateSeed == null) { @@ -1199,6 +1202,9 @@ public class ChatController { return R.fail(409, "正在生成回复,请先停止或排队后续消息"); } conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId); + validateUploadedParts(request.getConversationId(), + vip.mate.agent.context.ChatOrigin.web(request.getConversationId(), username, workspaceId, + null, null, requesterUserIdOf(auth)), request.getContentParts()); MessageEntity savedUser = conversationService.saveMessage( request.getConversationId(), "user", request.getMessage(), request.getContentParts()); @@ -2116,6 +2122,28 @@ public class ChatController { return List.of(textPart); } + private void validateUploadedParts(String conversationId, vip.mate.agent.context.ChatOrigin origin, + List parts) { + if (parts == null) return; + for (MessageContentPart part : parts) { + if (part == null || part.getStoredName() == null) continue; + Path file = uploadLocationResolver.resolveExistingFile(origin, part.getStoredName()); + if (file == null) { + throw new IllegalArgumentException("聊天附件不存在或不属于当前会话"); + } + // Do not let a client-supplied filesystem path become an agent prompt. + if (MemberFileIsolation.isEnabled()) { + part.setPath(toRelativeUploadPath(uploadLocationResolver.resolveUploadRoot(origin), file)); + } else { + Path root = uploadLocationResolver.resolveCandidateUploadRoots(conversationId).stream() + .filter(candidate -> file.startsWith(candidate)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("聊天附件目录无效")); + part.setPath(toRelativeUploadPath(root, file)); + } + } + } + private String buildPromptText(String message, List parts) { if (parts == null || parts.isEmpty()) { return message != null ? message : ""; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/MediaCaptionService.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/MediaCaptionService.java index d675d163..4bef0363 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/routing/MediaCaptionService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/MediaCaptionService.java @@ -13,6 +13,8 @@ import org.springframework.util.MimeType; import vip.mate.llm.chatmodel.ProviderChatModelFactory; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.nio.file.Files; import java.nio.file.Path; @@ -40,6 +42,12 @@ public class MediaCaptionService { private final ProviderChatModelFactory chatModelFactory; private final RetryTemplate retryTemplate; + private ChatUploadLocationResolver uploadLocationResolver; + + @org.springframework.beans.factory.annotation.Autowired + public void setUploadLocationResolver(ChatUploadLocationResolver resolver) { + this.uploadLocationResolver = resolver; + } public CaptionResult caption(ModelConfigEntity visionModel, MessageContentPart imagePart, Locale locale) { return caption(visionModel, imagePart, locale, null); @@ -53,11 +61,16 @@ public class MediaCaptionService { * the factual full-description prompt. */ public CaptionResult caption(ModelConfigEntity visionModel, MessageContentPart imagePart, Locale locale, - String userQuestion) { + String userQuestion) { + return caption(visionModel, imagePart, locale, userQuestion, null); + } + + public CaptionResult caption(ModelConfigEntity visionModel, MessageContentPart imagePart, Locale locale, + String userQuestion, ChatOrigin origin) { if (visionModel == null || imagePart == null) { return CaptionResult.failure(0, new IllegalArgumentException("vision model or image part is null")); } - Path mediaPath = resolveMediaPath(imagePart); + Path mediaPath = resolveMediaPath(imagePart, origin); if (mediaPath == null) { return CaptionResult.failure(0, new IllegalStateException( "Image file not found for attachment: " + imagePart.getFileName())); @@ -135,7 +148,11 @@ public class MediaCaptionService { * Mirrors {@code BaseAgent.resolveImagePath} but standalone — caption service * is reused outside the agent context (e.g. tests, future preflight endpoint). */ - private Path resolveMediaPath(MessageContentPart part) { + private Path resolveMediaPath(MessageContentPart part, ChatOrigin origin) { + if (uploadLocationResolver != null && origin != null && "web".equals(origin.channelType()) + && part.getStoredName() != null) { + return uploadLocationResolver.resolveExistingFile(origin, part.getStoredName()); + } Path resolved = tryResolve(part.getPath()); if (resolved != null) return resolved; return tryResolve(part.getMediaId()); diff --git a/mateclaw-server/src/test/java/vip/mate/llm/routing/MediaCaptionServiceAttachmentPathTest.java b/mateclaw-server/src/test/java/vip/mate/llm/routing/MediaCaptionServiceAttachmentPathTest.java new file mode 100644 index 00000000..1bdc5aed --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/routing/MediaCaptionServiceAttachmentPathTest.java @@ -0,0 +1,38 @@ +package vip.mate.llm.routing; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; + +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MediaCaptionServiceAttachmentPathTest { + @TempDir Path tempDir; + + @Test + void webUploadUsesStoredNameInsteadOfClientPath() throws Exception { + ChatUploadLocationResolver locations = mock(ChatUploadLocationResolver.class); + ChatOrigin origin = ChatOrigin.web("conversation", "alice", 7L, null); + Path image = Files.writeString(tempDir.resolve("saved.png"), "image"); + when(locations.resolveExistingFile(origin, "saved.png")).thenReturn(image); + + MessageContentPart part = new MessageContentPart(); + part.setStoredName("saved.png"); + part.setPath("chat-uploads/conversation/wrong.png"); + MediaCaptionService service = new MediaCaptionService(null, null); + service.setUploadLocationResolver(locations); + Method resolve = MediaCaptionService.class.getDeclaredMethod( + "resolveMediaPath", MessageContentPart.class, ChatOrigin.class); + resolve.setAccessible(true); + + assertEquals(image, resolve.invoke(service, part, origin)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java index fbf973d1..759785f0 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java @@ -390,4 +390,20 @@ class ChatUploadLocationResolverTest { .isEqualTo(writeDir.resolve("1777_a.png")); assertThat(r.resolveExistingFile("c-e2e", "nope.png")).isNull(); } + + @Test + @DisplayName("attachment uploaded before conversation creation remains readable after workspace binding") + void preConversationUploadIsFoundInDefaultRoot() throws Exception { + when(conversationMapper.selectOne(any(Wrapper.class))).thenReturn(null); + ChatUploadLocationResolver r = resolver(tempDir.resolve("chat-uploads"), true); + Path writeDir = r.resolveWriteDir("new-conversation"); + Files.createDirectories(writeDir); + Path image = Files.writeString(writeDir.resolve("123_image.png"), "img"); + + stubConversation("new-conversation", 7L, null); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, tempDir.resolve("workspace").toString())); + r.invalidate("new-conversation"); + + assertThat(r.resolveExistingFile("new-conversation", "123_image.png")).isEqualTo(image); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileIntegrationTest.java index b7c42fe5..7863dbf0 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileIntegrationTest.java @@ -57,4 +57,16 @@ class MemberFileIntegrationTest { new ChatUploadProperties(), mock(AgentService.class)); assertThrows(SecurityException.class, () -> resolver.resolveCandidateUploadRoots("missing")); } + @Test void uploadedMediaLookupStaysInsideTheMemberDirectory() throws Exception { + var resolver = new ChatUploadLocationResolver(mock(ConversationMapper.class), mock(WorkspaceService.class), + new ChatUploadProperties(), mock(AgentService.class)); + var alice = ChatOrigin.web("new", "alice", 7L, null, null, 11L); + var bob = ChatOrigin.web("new", "bob", 7L, null, null, 22L); + Path image = resolver.resolveWriteDir(alice).resolve("image.png"); + Files.createDirectories(image.getParent()); + Files.writeString(image, "image"); + + assertEquals(image, resolver.resolveExistingFile(alice, "image.png")); + assertNull(resolver.resolveExistingFile(bob, "image.png")); + } }