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.
This commit is contained in:
czhcc 2026-09-29 17:37:11 +08:00 committed by GitHub
parent 17c87dfa3a
commit 6f13a70709
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 145 additions and 8 deletions

View File

@ -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();

View File

@ -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;

View File

@ -645,6 +645,9 @@ public class ChatController {
List<MessageContentPart> 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<MessageContentPart> 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<MessageContentPart> parts) {
if (parts == null || parts.isEmpty()) {
return message != null ? message : "";

View File

@ -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());

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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"));
}
}