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 d42f2cad..a1df4232 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -1327,7 +1327,7 @@ public class AgentGraphBuilder { * @throws IllegalArgumentException when an absolute override escapes the * workspace root */ - static String resolveAgentBasePath(String agentOverride, String workspaceBase) { + public static String resolveAgentBasePath(String agentOverride, String workspaceBase) { boolean hasOverride = agentOverride != null && !agentOverride.isBlank(); boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank(); if (!hasOverride) { 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 344b8640..e126020f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -1220,7 +1220,9 @@ public abstract class BaseAgent { /** * 解析图片文件的绝对路径。 *

- * 上传文件存储在 data/chat-uploads/ 下,是相对于 Spring Boot 工作目录的路径。 + * 上传文件的存储位置由 {@code ChatUploadLocationResolver} 按优先级解析: + * Agent 的 workspaceBasePath → Workspace 的 basePath → 可配置默认目录 + * ({@code mateclaw.chat.upload.base-dir},默认 {@code data/chat-uploads})。 * MCP 工具的工作目录可能不同,所以这里直接解析为绝对路径。 */ /** diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index 30489180..dc3bd96d 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -134,6 +134,13 @@ public class ChannelManager { */ private final ChannelLeaderElection leaderElection; + /** + * Workspace/agent-aware chat-upload resolver, passed into adapters so their + * inbound media downloads land under the channel's workspace base path + * (falling back to the configured default dir). + */ + private final vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + /** 运行中的渠道适配器:channelId -> adapter */ private final Map activeAdapters = new HashMap<>(); @@ -1197,14 +1204,16 @@ public class ChannelManager { case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache); case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper, feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager, - feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService); + feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService, + chatUploadLocationResolver); case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper); case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper); case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper, approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler, - generatedFileCache); + generatedFileCache, chatUploadLocationResolver); case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper); - case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper); + case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper, + chatUploadLocationResolver); case "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper); case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper); default -> throw new IllegalArgumentException("Unsupported channel type: " + type); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 17adde6b..23b8812d 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -70,6 +70,13 @@ public class ChannelMessageRouter { @Autowired(required = false) private ApplicationEventPublisher events; + /** Field-injected for the same reason as {@link #events}: the chat-upload + * resolver resolves the workspace-aware TTS output directory on the + * voice-reply path. Optional so tests that build the router directly + * still work; falls back to the legacy default dir when unset. */ + @Autowired(required = false) + private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + /** 队列条目:封装消息及其路由上下文 */ private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {} @@ -1491,10 +1498,12 @@ public class ChannelMessageRouter { // 构建音频 MessageContentPart String audioUrl = (String) result.get("audioUrl"); String fileName = Paths.get(audioUrl).getFileName().toString(); - Path audioPath = Paths.get("data", "chat-uploads", conversationId, fileName); + // TTS output may live under a workspace-scoped dir or the legacy + // default dir — probe each candidate root to find the file. + Path audioPath = resolveVoiceReplyAudio(conversationId, fileName); - if (!Files.exists(audioPath)) { - log.warn("[voice-reply] TTS output file not found: {}", audioPath); + if (audioPath == null) { + log.warn("[voice-reply] TTS output file not found for conversation {} ({})", conversationId, fileName); return; } @@ -1516,6 +1525,27 @@ public class ChannelMessageRouter { }); } + /** + * Resolve the TTS audio file across every candidate upload root. Returns the + * first existing match, or {@code null} when the file is absent under every + * root. Used by the voice-reply path so workspace-scoped and legacy default + * outputs are both found. + */ + private Path resolveVoiceReplyAudio(String conversationId, String fileName) { + if (chatUploadLocationResolver != null) { + for (Path root : chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId)) { + Path candidate = root.resolve(conversationId).resolve(fileName); + if (Files.exists(candidate)) { + return candidate; + } + } + } + // Fallback to the legacy default dir when the resolver is absent + // (e.g. direct-construction unit tests). + Path legacy = Paths.get("data", "chat-uploads", conversationId, fileName); + return Files.exists(legacy) ? legacy : null; + } + /** * 判断是否需要为此消息生成语音回复 */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index ce44b1ae..61f6d262 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -229,8 +229,48 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre .build(); // Package-private for testing: redirect to a temp directory without touching real disk. + // Used as the fallback upload root when no ChatUploadLocationResolver is wired + // (e.g. direct-construction unit tests set this to a tmp dir). Path chatUploadsRoot = Path.of("data", "chat-uploads"); + /** + * Workspace/agent-aware upload-root resolver. Set by the production factory + * (ChannelManager); null in unit tests, which override {@link #chatUploadsRoot} + * instead. When non-null, attachment reads/writes resolve through it so files + * land under the workspace base path; otherwise the legacy + * {@link #chatUploadsRoot} field applies. + */ + vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + + /** + * Resolve the upload root for a conversation, preferring the wired resolver + * (workspace/agent-aware) and falling back to the legacy field. Read paths + * should use {@link #candidateChatUploadRoots(String)} to probe both the + * workspace-scoped root and the legacy root. + */ + private java.nio.file.Path chatUploadRootFor(String conversationId) { + if (chatUploadLocationResolver != null) { + return chatUploadLocationResolver.resolveUploadRoot(conversationId); + } + return chatUploadsRoot; + } + + /** + * Ordered candidate upload roots for a conversation: workspace-scoped first + * (when the resolver is wired), then the legacy field. Used by read/scan + * paths so attachments written before the workspace-aware relocation are + * still found. + */ + private java.util.List candidateChatUploadRoots(String conversationId) { + java.util.List roots = new java.util.ArrayList<>(); + if (chatUploadLocationResolver != null) { + roots.addAll(chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId)); + } else { + roots.add(chatUploadsRoot); + } + return roots; + } + public FeishuChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { @@ -291,6 +331,28 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre FeishuClientFactory clientFactory, vip.mate.tool.document.GeneratedFileCache generatedFileCache, vip.mate.stt.SttService sttService) { + this(channelEntity, messageRouter, objectMapper, mediaUploader, + generatedFileScrubber, streamingCardManager, cardDispatcher, + clientFactory, generatedFileCache, sttService, null); + } + + /** + * Full constructor used by the production factory (ChannelManager). The + * trailing {@code chatUploadLocationResolver} enables workspace/agent-aware + * attachment storage; {@code null} (or a shorter overload) keeps the legacy + * {@code data/chat-uploads} behaviour. + */ + public FeishuChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper, + FeishuMediaUploader mediaUploader, + GeneratedFileScrubber generatedFileScrubber, + FeishuStreamingCardManager streamingCardManager, + vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher, + FeishuClientFactory clientFactory, + vip.mate.tool.document.GeneratedFileCache generatedFileCache, + vip.mate.stt.SttService sttService, + vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) { super(channelEntity, messageRouter, objectMapper); this.mediaUploader = mediaUploader; this.generatedFileScrubber = generatedFileScrubber; @@ -299,6 +361,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre this.clientFactory = clientFactory; this.generatedFileCache = generatedFileCache; this.sttService = sttService; + this.chatUploadLocationResolver = chatUploadLocationResolver; // Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1); } @@ -1712,8 +1775,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre : maybeDownloadResource(messageId, fileKey, type, fileName); if (dl == null) return null; - // Save to data/chat-uploads/{conversationId}/ - Path uploadDir = chatUploadsRoot.resolve(conversationId); + // Save under the workspace/agent-aware upload root ({convId}/ subdir) + Path uploadDir = chatUploadRootFor(conversationId).resolve(conversationId); Files.createDirectories(uploadDir); String rawName = (dl.fileName() != null && !dl.fileName().isBlank()) ? dl.fileName() : fileKey; @@ -1796,15 +1859,20 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre } /** - * Scan {@code data/chat-uploads/{conversationId}/} on disk and return - * the most recent files as {@link RecentFileEntry}s. Used as a - * fallback when the in-memory Caffeine cache has been evicted - * (process restart, TTL expiry, GC pressure) but the staged copies - * are still on disk. + * Scan the conversation's upload dir(s) on disk and return the most recent + * files as {@link RecentFileEntry}s. Used as a fallback when the in-memory + * Caffeine cache has been evicted (process restart, TTL expiry, GC pressure) + * but the staged copies are still on disk. Probes every candidate root + * (workspace-scoped + legacy default) so files written before the + * workspace-aware relocation are still found. */ private List loadRecentFilesFromDisk(String conversationId) { long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L; - return loadRecentFilesFromDisk(chatUploadsRoot.resolve(conversationId), cutoff); + List merged = new java.util.ArrayList<>(); + for (Path root : candidateChatUploadRoots(conversationId)) { + merged.addAll(loadRecentFilesFromDisk(root.resolve(conversationId), cutoff)); + } + return merged; } /** 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 651c5575..06f384ac 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 @@ -30,7 +30,6 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.io.IOException; import reactor.core.Disposable; @@ -62,7 +61,7 @@ public class ChatController { private final ObjectMapper objectMapper; private final ConversationCompletionPublisher completionPublisher; private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; - private final Path uploadRoot = Paths.get("data", "chat-uploads"); + private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver; // 使用虚拟线程池处理 SSE(Java 17+ 兼容,Java 21 可用 Executors.newVirtualThreadPerTaskExecutor()) private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); @@ -1075,7 +1074,9 @@ public class ChatController { Authentication auth) throws IOException { String username = auth != null ? auth.getName() : "anonymous"; - // 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户) + // 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户)。 + // 注意:会话尚不存在时,附件暂存到默认目录(resolveUploadRoot 查不到会话即回退); + // 会话创建后读取走双重查找,仍能命中。 if (conversationService.conversationExists(conversationId) && !conversationService.isConversationOwner(conversationId, username)) { return R.fail(403, "无权操作该会话"); @@ -1087,6 +1088,7 @@ public class ChatController { String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file"; String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_"); String storedName = System.currentTimeMillis() + "_" + safeFilename; + Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId); Path conversationDir = uploadRoot.resolve(conversationId); Files.createDirectories(conversationDir); Path target = conversationDir.resolve(storedName); @@ -1119,8 +1121,20 @@ public class ChatController { return ResponseEntity.status(403).build(); } - Path filePath = uploadRoot.resolve(conversationId).resolve(storedName).normalize(); - if (!Files.exists(filePath) || !filePath.startsWith(uploadRoot.resolve(conversationId).normalize())) { + // Check every candidate root (workspace-scoped dir + legacy default dir) + // so attachments written before the workspace-aware relocation, and the + // current workspace-scoped ones, are both servable. Each candidate keeps + // its own startsWith traversal guard. + Path filePath = null; + for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) { + Path conversationDir = root.resolve(conversationId).normalize(); + Path candidate = conversationDir.resolve(storedName).normalize(); + if (Files.exists(candidate) && candidate.startsWith(conversationDir)) { + filePath = candidate; + break; + } + } + if (filePath == null) { return ResponseEntity.notFound().build(); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java index defcc725..87832cb2 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java @@ -5,11 +5,11 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Arrays; import java.util.Locale; import java.util.Optional; @@ -39,9 +39,6 @@ import java.util.stream.Stream; @Service public class WebChatFileService { - /** Shared with the JWT chat upload dir so deleteConversation cleanup applies. */ - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); - /** How long an uploaded-but-unreferenced file lingers before the sweep removes it. */ private static final long STAGING_TTL_MS = 60 * 60 * 1000L; // 1 hour @@ -50,6 +47,7 @@ public class WebChatFileService { private final Set allowedExtensions; private final int maxFilesPerConversation; private final long maxTotalBytesPerConversation; + private final ChatUploadLocationResolver uploadLocationResolver; /** fileId (== storedName) -> staged metadata, pending a /stream reference. */ private final ConcurrentHashMap staged = new ConcurrentHashMap<>(); @@ -61,7 +59,8 @@ public class WebChatFileService { + "png,jpg,jpeg,gif,webp,bmp,pdf,txt,md,csv,json,log," + "doc,docx,xls,xlsx,ppt,pptx,zip,mp3,wav,m4a,mp4,mov,webm}") String allowedExtensionsCsv, @Value("${mateclaw.webchat.upload.max-files-per-conversation:50}") int maxFilesPerConversation, - @Value("${mateclaw.webchat.upload.max-total-mb-per-conversation:200}") long maxTotalMbPerConversation) { + @Value("${mateclaw.webchat.upload.max-total-mb-per-conversation:200}") long maxTotalMbPerConversation, + ChatUploadLocationResolver uploadLocationResolver) { this.enabled = enabled; this.maxSizeBytes = maxSizeMb * 1024 * 1024; this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(",")) @@ -70,6 +69,7 @@ public class WebChatFileService { .collect(Collectors.toUnmodifiableSet()); this.maxFilesPerConversation = maxFilesPerConversation; this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024; + this.uploadLocationResolver = uploadLocationResolver; } /** Metadata for a staged upload. */ @@ -111,7 +111,7 @@ public class WebChatFileService { String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file"; // Strip any directory components, then collapse to a safe charset. - String baseName = Paths.get(originalName).getFileName().toString(); + String baseName = Path.of(originalName).getFileName().toString(); String ext = extensionOf(baseName); if (ext.isEmpty() || !allowedExtensions.contains(ext)) { throw new UploadRejectedException("File type not allowed: ." + ext); @@ -119,8 +119,9 @@ public class WebChatFileService { String safeName = baseName.replaceAll("[^a-zA-Z0-9._-]", "_"); String storedName = UUID.randomUUID() + "_" + safeName; - Path dir = UPLOAD_ROOT.resolve(conversationId).normalize(); - if (!dir.startsWith(UPLOAD_ROOT.normalize())) { + Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize(); + Path dir = uploadRoot.resolve(conversationId).normalize(); + if (!dir.startsWith(uploadRoot)) { // conversationId is server-derived, so this should never happen; fail closed if it does. throw new UploadRejectedException("Invalid conversation"); } @@ -169,12 +170,16 @@ public class WebChatFileService { if (storedName == null || storedName.isBlank()) { return Optional.empty(); } - Path base = UPLOAD_ROOT.resolve(conversationId).normalize(); - Path file = base.resolve(storedName).normalize(); - if (!file.startsWith(base) || !Files.exists(file) || !Files.isRegularFile(file)) { - return Optional.empty(); + // Check every candidate root (workspace-scoped dir + legacy default dir) + // so files written before the workspace-aware relocation still resolve. + for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) { + Path base = root.resolve(conversationId).normalize(); + Path file = base.resolve(storedName).normalize(); + if (file.startsWith(base) && Files.exists(file) && Files.isRegularFile(file)) { + return Optional.of(file); + } } - return Optional.of(file); + return Optional.empty(); } /** Map a content type to the MessageContentPart type the agent/UI understands. */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java index 1f690284..284fae60 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java @@ -280,6 +280,12 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { */ private final vip.mate.tool.document.GeneratedFileCache generatedFileCache; + /** + * Workspace/agent-aware upload-root resolver, set by the production factory. + * Null in unit tests (the legacy {@code data/chat-uploads} default applies). + */ + private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + public WeComChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper, @@ -297,11 +303,30 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher, WeComKeepaliveScheduler keepaliveScheduler, vip.mate.tool.document.GeneratedFileCache generatedFileCache) { + this(channelEntity, messageRouter, objectMapper, approvalNotificationService, + cardDispatcher, keepaliveScheduler, generatedFileCache, null); + } + + /** + * Full constructor used by the production factory (ChannelManager). The + * trailing {@code chatUploadLocationResolver} enables workspace/agent-aware + * attachment storage; {@code null} keeps the legacy {@code data/chat-uploads} + * behaviour. + */ + public WeComChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper, + vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService, + vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher, + WeComKeepaliveScheduler keepaliveScheduler, + vip.mate.tool.document.GeneratedFileCache generatedFileCache, + vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) { super(channelEntity, messageRouter, objectMapper); this.approvalNotificationService = approvalNotificationService; this.cardDispatcher = cardDispatcher; this.keepaliveScheduler = keepaliveScheduler; this.generatedFileCache = generatedFileCache; + this.chatUploadLocationResolver = chatUploadLocationResolver; // Default to 8 bounded attempts (~4 minutes total at 2s..30s exponential) // so the UI eventually settles in ERROR instead of getting stuck in // RECONNECTING forever. User config still overrides (-1 = infinite). @@ -2936,14 +2961,17 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { */ private InboundMediaDownloader.DownloadedMedia downloadInboundMedia(String url, String aesKey, String msgId, String fileNameHint, String conversationId) { - // Store under data/chat-uploads/{conversationId} so the existing + // Store under the workspace/agent-aware upload root ({convId}/ subdir), + // falling back to the legacy data/chat-uploads default, so the existing // /api/v1/chat/files/{convId}/{storedName} endpoint serves the file // back to the chat bubble — the WeCom CDN URL carries a short-lived // signature that expires before a browser can fetch it. The shared // pipeline owns retry/backoff, magic-byte type detection, and the // dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here // inside the byte source so a fetch + decrypt is retried as one unit. - Path uploadDir = Path.of("data", "chat-uploads", conversationId); + Path uploadDir = (chatUploadLocationResolver != null) + ? chatUploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId) + : Path.of("data", "chat-uploads", conversationId); String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint; return InboundMediaDownloader.download( () -> { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java index 79e825f0..125d3c31 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java @@ -148,12 +148,32 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { /** 用于文件 URL 下载的 HttpClient */ private HttpClient uploadHttpClient; + /** + * Workspace/agent-aware upload-root resolver, set by the production factory. + * Null in unit tests (the legacy {@code data/chat-uploads} default applies). + */ + private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + public WeixinChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { super(channelEntity, messageRouter, objectMapper); } + /** + * Full constructor used by the production factory (ChannelManager). The + * trailing {@code chatUploadLocationResolver} enables workspace/agent-aware + * attachment storage; {@code null} keeps the legacy {@code data/chat-uploads} + * behaviour. + */ + public WeixinChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper, + vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) { + super(channelEntity, messageRouter, objectMapper); + this.chatUploadLocationResolver = chatUploadLocationResolver; + } + @Override public String getChannelType() { return CHANNEL_TYPE; @@ -666,7 +686,9 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { return null; } - Path uploadDir = Path.of("data", "chat-uploads", conversationId); + Path uploadDir = (chatUploadLocationResolver != null) + ? chatUploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId) + : Path.of("data", "chat-uploads", conversationId); return InboundMediaDownloader.download( () -> client.downloadMedia("", aesKey, encryptQueryParam), filenameHint, diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java index 96a2abf2..cb713d39 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java @@ -1,15 +1,27 @@ package vip.mate.tool.builtin; import lombok.extern.slf4j.Slf4j; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; /** * Resolves a user-supplied file path against the current conversation's chat-upload - * directory ({@code data/chat-uploads/{conversationId}/}). + * directory ({@code {upload-root}/{conversationId}/}). + *

+ * The upload root is workspace/agent-aware. When the active agent has a resolved + * {@code workspaceBasePath} (carried on {@link ToolExecutionContext}), attachments + * live under {@code {workspaceBasePath}/chat-uploads/{conversationId}/}; otherwise + * they live under the configurable default root (legacy {@code data/chat-uploads}). + * Reads check both locations so attachments written before the workspace-aware + * relocation (under the default dir) still resolve. *

* Chat attachments are stored as {@code {timestamp}_{safeFilename}} where * {@code safeFilename} replaces every non-{@code [a-zA-Z0-9._-]} character with @@ -20,14 +32,40 @@ import java.nio.file.Paths; *

* This helper rescues such calls by matching basenames inside the conversation's * upload directory. Used by both {@link ReadFileTool} and {@link DocumentExtractTool}. + * + * @see ChatUploadLocationResolver the Spring-managed resolver that drives the + * same workspace/agent precedence from the off-request path (downloaders, + * cleanup, file-serving). */ @Slf4j -final class ChatUploadResolver { +public final class ChatUploadResolver { - static final Path CHAT_UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + /** + * Sub-directory appended under a workspace/agent base path. Kept in sync + * with {@link ChatUploadLocationResolver#UPLOAD_SUBDIR}. + */ + private static final String UPLOAD_SUBDIR = ChatUploadLocationResolver.UPLOAD_SUBDIR; + + /** + * Configurable default upload root, registered once at startup from + * {@code mateclaw.chat.upload.base-dir}. Defaults to the legacy + * {@code data/chat-uploads} until {@link #setDefaultRoot} is called. + */ + private static volatile Path defaultRoot = Paths.get("data", "chat-uploads"); private ChatUploadResolver() {} + /** + * Register the configurable default upload root. Called once at startup by + * {@code ChatUploadAutoConfiguration}. A {@code null}/blank path restores + * the legacy {@code data/chat-uploads}. + */ + public static void setDefaultRoot(Path path) { + defaultRoot = (path == null) + ? Paths.get("data", "chat-uploads") + : path.toAbsolutePath().normalize(); + } + /** * @return absolute path of the matched attachment, or {@code null} if no match */ @@ -39,11 +77,37 @@ final class ChatUploadResolver { if (conversationId == null || conversationId.isBlank()) { return null; } - Path uploadDir = CHAT_UPLOAD_ROOT.resolve(conversationId).toAbsolutePath().normalize(); + + for (Path uploadDir : candidateUploadDirs(conversationId)) { + Path matched = resolveIn(rawPath, uploadDir); + if (matched != null) { + return matched; + } + } + return null; + } + + /** + * Ordered candidate upload directories for a conversation: the + * workspace-scoped dir first (when a base path is active), then the default + * fallback dir. De-duplicated so the two coincide (no base path configured) + * is a single lookup. + */ + private static List candidateUploadDirs(String conversationId) { + Set dirs = new LinkedHashSet<>(); + String basePath = ToolExecutionContext.workspaceBasePath(); + if (basePath != null && !basePath.isBlank()) { + dirs.add(Paths.get(basePath).toAbsolutePath().normalize() + .resolve(UPLOAD_SUBDIR).resolve(conversationId)); + } + dirs.add(defaultRoot.resolve(conversationId).toAbsolutePath().normalize()); + return new ArrayList<>(dirs); + } + + private static Path resolveIn(String rawPath, Path uploadDir) { if (!Files.isDirectory(uploadDir)) { return null; } - String basename; try { Path requested = Paths.get(rawPath).getFileName(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java index b008e4d1..5cace0df 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java @@ -1,15 +1,16 @@ package vip.mate.tool.image; import cn.hutool.http.HttpUtil; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Base64; /** @@ -19,9 +20,10 @@ import java.util.Base64; */ @Slf4j @Component +@RequiredArgsConstructor public class ImageFileDownloader { - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + private final ChatUploadLocationResolver uploadLocationResolver; /** * Persist an image referenced by either a {@code data:} URL (inline base64 / @@ -38,7 +40,7 @@ public class ImageFileDownloader { if (imageUrl == null) { throw new IOException("imageUrl is null"); } - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); if (imageUrl.startsWith("data:")) { @@ -110,7 +112,7 @@ public class ImageFileDownloader { * 将 Base64 编码的图片保存到本地 */ public Path saveBase64(String base64Data, String conversationId, String taskId, int index) throws IOException { - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); String fileName = "image_" + taskId + "_" + index + ".png"; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java index bd77eddc..326a9f14 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java @@ -1,13 +1,14 @@ package vip.mate.tool.model3d; import cn.hutool.http.HttpUtil; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; /** * Downloads generated 3D-model assets (.glb / .obj / .fbx) from the provider's @@ -16,13 +17,14 @@ import java.nio.file.Paths; */ @Slf4j @Component +@RequiredArgsConstructor public class Model3dFileDownloader { - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + private final ChatUploadLocationResolver uploadLocationResolver; public Path download(String modelUrl, String conversationId, String taskId, String preferredExtension) throws IOException { - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); String ext = guessExtension(modelUrl, preferredExtension); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java b/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java index ad72b750..0a2ddef8 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java @@ -12,12 +12,12 @@ import vip.mate.task.AsyncTaskService; import vip.mate.task.model.AsyncTaskEntity; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import jakarta.annotation.PreDestroy; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -55,8 +55,8 @@ public class MusicGenerationService { * audio as a native attachment. Web-class channels keep using SSE only. */ private final AsyncTaskMediaDispatcher asyncTaskMediaDispatcher; + private final ChatUploadLocationResolver uploadLocationResolver; - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); private static final String TASK_TYPE = "music_generation"; /** Dedicated virtual-thread worker. Music generation blocks on a single @@ -191,7 +191,7 @@ public class MusicGenerationService { private PersistedAudio persistAudio(String conversationId, String taskId, MusicGenerationResult result) throws IOException { - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); String fileName = "music_" + taskId + "." + result.getFormat(); Path filePath = dir.resolve(fileName); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java b/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java index 61bfded6..63e00632 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java @@ -1,13 +1,14 @@ package vip.mate.tool.video; import cn.hutool.http.HttpUtil; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; /** * 视频文件下载器 — 从 provider CDN 下载视频到本地存储 @@ -16,9 +17,10 @@ import java.nio.file.Paths; */ @Slf4j @Component +@RequiredArgsConstructor public class VideoFileDownloader { - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + private final ChatUploadLocationResolver uploadLocationResolver; /** * 下载视频到本地 @@ -29,7 +31,7 @@ public class VideoFileDownloader { * @return 本地文件路径 */ public Path download(String videoUrl, String conversationId, String taskId) throws IOException { - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); String extension = guessExtension(videoUrl); diff --git a/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java b/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java index b5f91520..ee1a8fd4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java +++ b/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java @@ -6,11 +6,11 @@ import org.springframework.stereotype.Service; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.service.SystemSettingService; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -28,8 +28,8 @@ public class TtsService { private final SystemSettingService systemSettingService; private final TtsProviderRegistry providerRegistry; private final ChatStreamTracker streamTracker; + private final ChatUploadLocationResolver uploadLocationResolver; - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); private static final int MAX_TEXT_LENGTH = 4096; /** 用于自动 TTS 的异步线程池 */ @@ -204,7 +204,7 @@ public class TtsService { private Path saveAudioFile(String conversationId, String fileId, byte[] data, String format) throws IOException { - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); String fileName = "tts_" + fileId + "." + format; Path filePath = dir.resolve(fileName); diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index bc9c703b..1715e836 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -31,13 +31,13 @@ import vip.mate.workspace.conversation.repository.ConversationMapper; import vip.mate.workspace.conversation.repository.MessageMapper; import vip.mate.workspace.conversation.vo.ConversationVO; import vip.mate.workspace.conversation.vo.MessageVO; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import vip.mate.workspace.core.service.WorkspaceService; import java.io.IOException; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; -import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; @@ -87,6 +87,14 @@ public class ConversationService { private final AuthService authService; private final WorkspaceService workspaceService; + /** + * Resolves the workspace/agent-aware chat-upload directory. The resolver + * injects {@code AgentService} lazily, which breaks the would-be cycle + * (agentService → agentGraphBuilder → this → resolver → agentService), so a + * plain constructor injection here is sufficient. + */ + private final ChatUploadLocationResolver chatUploadLocationResolver; + /** * Optional spill store. Injected via a setter so the existing @RequiredArgsConstructor * stays stable and tests that build the service directly don't need to wire @@ -1656,38 +1664,46 @@ public class ConversationService { return conv != null ? conv.getStreamStatus() : null; } - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); - /** * 清理会话关联的附件文件 + *

+ * 遍历所有候选上传根(workspace/agent 感知根 + 默认根),逐一删除 + * 各根下该会话的附件目录。这样无论附件落在新的工作空间目录还是 + * 迁移前的默认目录,都能被清理。 */ public void cleanAttachmentFiles(String conversationId) { - Path dir; - try { - dir = UPLOAD_ROOT.resolve(conversationId); - } catch (InvalidPathException e) { - // Conversation id contains characters illegal on this filesystem - // (e.g. ':' in cron: on Windows). No attachments could - // ever have been written under such an id on this OS, so there - // is nothing to clean. - log.debug("Skipping attachment cleanup for non-path-safe conversation id: {}", conversationId); - return; + boolean cleanedAny = false; + for (Path root : chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId)) { + Path dir; + try { + dir = root.resolve(conversationId); + } catch (InvalidPathException e) { + // Conversation id contains characters illegal on this filesystem + // (e.g. ':' in cron: on Windows). No attachments could + // ever have been written under such an id on this OS, so there + // is nothing to clean. + log.debug("Skipping attachment cleanup for non-path-safe conversation id: {}", conversationId); + return; + } + if (!Files.exists(dir)) { + continue; + } + try (Stream walk = Files.walk(dir)) { + walk.sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + log.warn("Failed to delete attachment file: {}", p, e); + } + }); + cleanedAny = true; + } catch (IOException e) { + log.warn("Failed to walk attachment directory for conversation: {}", conversationId, e); + } } - if (!Files.exists(dir)) { - return; - } - try (Stream walk = Files.walk(dir)) { - walk.sorted(Comparator.reverseOrder()) - .forEach(p -> { - try { - Files.deleteIfExists(p); - } catch (IOException e) { - log.warn("Failed to delete attachment file: {}", p, e); - } - }); + if (cleanedAny) { log.info("Cleaned attachment files for conversation: {}", conversationId); - } catch (IOException e) { - log.warn("Failed to walk attachment directory for conversation: {}", conversationId, e); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadAutoConfiguration.java new file mode 100644 index 00000000..480d8fa6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadAutoConfiguration.java @@ -0,0 +1,48 @@ +package vip.mate.workspace.core.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import vip.mate.tool.builtin.ChatUploadResolver; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Normalizes and pre-creates the default chat-upload directory on startup. + *

+ * Mirrors {@link WorkspaceSandboxAutoConfiguration}: the directory is created + * eagerly so the first upload does not race on {@code Files.createDirectories}, + * and a missing/blank value restores the legacy {@code data/chat-uploads}. The + * normalized default is also registered with the static + * {@link ChatUploadResolver} so the tool-side fallback lookup agrees with the + * Spring-managed {@link ChatUploadLocationResolver}. + * + * @author MateClaw Team + */ +@Slf4j +@Configuration +@EnableConfigurationProperties(ChatUploadProperties.class) +public class ChatUploadAutoConfiguration { + + public ChatUploadAutoConfiguration(ChatUploadProperties properties) { + String raw = properties.getBaseDir(); + if (raw == null || raw.isBlank()) { + raw = "data/chat-uploads"; + properties.setBaseDir(raw); + } + Path root = Paths.get(raw).toAbsolutePath().normalize(); + properties.setBaseDir(root.toString()); + try { + Files.createDirectories(root); + } catch (Exception e) { + // The first upload will retry createDirectories; log and continue + // rather than fail startup. + log.warn("[ChatUpload] Failed to create default upload dir {}: {}", + root, e.getMessage()); + } + ChatUploadResolver.setDefaultRoot(root); + log.info("[ChatUpload] Default upload dir: {}", root); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadProperties.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadProperties.java new file mode 100644 index 00000000..2233ec4a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadProperties.java @@ -0,0 +1,35 @@ +package vip.mate.workspace.core.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration for the chat-attachment upload directory. + *

+ * Chat uploads (files exchanged in a conversation) resolve their storage root + * with this precedence: + *

    + *
  1. Agent-level {@code workspaceBasePath} override (resolved under the + * workspace {@code basePath}, same rule as + * {@code AgentGraphBuilder.resolveAgentBasePath});
  2. + *
  3. Workspace {@code basePath} (when the agent has no override);
  4. + *
  5. This {@link #baseDir} fallback — the out-of-the-box default used when + * neither the agent nor its workspace configures a base path.
  6. + *
+ * The default keeps the legacy {@code data/chat-uploads} location so existing + * single-workspace deployments see no behavioural change. + * + * @author MateClaw Team + */ +@Data +@ConfigurationProperties(prefix = "mateclaw.chat.upload") +public class ChatUploadProperties { + + /** + * Root directory for chat attachments when neither the active agent nor its + * workspace configures a base path. Defaults to {@code data/chat-uploads} + * (relative to the Spring Boot working directory). Conversations are stored + * one level below: {@code {baseDir}/{conversationId}/{storedName}}. + */ + private String baseDir = "data/chat-uploads"; +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java new file mode 100644 index 00000000..034f0455 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java @@ -0,0 +1,239 @@ +package vip.mate.workspace.core.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentService; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.agent.model.AgentEntity; +import vip.mate.exception.MateClawException; +import vip.mate.workspace.conversation.event.ConversationDeletedEvent; +import vip.mate.workspace.core.config.ChatUploadProperties; +import vip.mate.workspace.core.model.WorkspaceEntity; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Resolves the on-disk root directory where a conversation's chat attachments + * are stored. Replaces the previously hardcoded {@code data/chat-uploads} + * literal with a workspace/agent-aware location. + * + *

Resolution precedence

+ *
    + *
  1. When the conversation's agent has a {@code workspaceBasePath} override, + * it is resolved under the workspace {@code basePath} (same rule as + * {@link AgentGraphBuilder#resolveAgentBasePath}) and the upload root is + * {@code {resolved}/chat-uploads}.
  2. + *
  3. Otherwise, when the workspace has a {@code basePath}, the upload root is + * {@code {workspace.basePath}/chat-uploads}.
  4. + *
  5. Otherwise, the configurable fallback {@link ChatUploadProperties#getBaseDir()} + * (default {@code data/chat-uploads}) is used.
  6. + *
+ * + *

Backward compatibility

+ * Reads and cleanup use {@link #resolveCandidateUploadRoots(String)} which + * returns both the workspace-scoped root and the default fallback root, + * so attachments written before this change (under the default dir) remain + * resolvable and cleanable. Writes always target a single root returned by + * {@link #resolveUploadRoot(String)}. + * + *

The {@code conversationId → ConversationEntity} lookup is cached for 5 + * minutes (the mapping is immutable once a conversation exists), matching the + * TTL of the existing {@code WorkspaceLookupCache} on the tool-call hot path. + * + * @author MateClaw Team + */ +@Slf4j +@Service +public class ChatUploadLocationResolver { + + /** Sub-directory appended under a configured base path. */ + public static final String UPLOAD_SUBDIR = "chat-uploads"; + + private final ConversationMapper conversationMapper; + private final WorkspaceService workspaceService; + private final ChatUploadProperties properties; + + /** + * {@code AgentService} is injected lazily because the bean graph is cyclic: + * {@code agentService → agentGraphBuilder → conversationService → this}. + * The agent is only consulted at resolve time (never at construction), so a + * lazy proxy is safe and breaks the cycle cleanly. + */ + @Lazy + private final AgentService agentService; + + public ChatUploadLocationResolver(ConversationMapper conversationMapper, + WorkspaceService workspaceService, + ChatUploadProperties properties, + @Lazy AgentService agentService) { + this.conversationMapper = conversationMapper; + this.workspaceService = workspaceService; + this.properties = properties; + this.agentService = agentService; + } + + private final Cache conversationCache = Caffeine.newBuilder() + .maximumSize(5_000) + .expireAfterWrite(Duration.ofMinutes(5)) + .build(); + + // ==================== write path: single root ==================== + + /** + * Resolve the single upload root for a conversation (write target). + * + * @param conversationId business conversation id + * @return absolute, normalized upload root (never {@code null}) + */ + public Path resolveUploadRoot(String conversationId) { + ConversationEntity conv = lookupConversation(conversationId); + Long workspaceId = conv != null ? conv.getWorkspaceId() : null; + Long agentId = conv != null ? conv.getAgentId() : null; + return resolveUploadRoot(workspaceId, agentId); + } + + /** + * Resolve the upload root when the caller already knows the workspace and + * agent (e.g. a web request thread carrying {@code X-Workspace-Id} + the + * picked agent), avoiding a DB lookup. + */ + public Path resolveUploadRoot(Long workspaceId, Long agentId) { + Path resolved = resolveWorkspaceScopedRoot(workspaceId, agentId); + if (resolved != null) { + return resolved; + } + return defaultRoot(); + } + + // ==================== read / cleanup path: candidate roots ==================== + + /** + * Resolve every upload root a conversation's attachments may live under, in + * lookup order: the workspace-scoped root first (if any), then the default + * fallback root. Used by file-serving endpoints, the tool-side resolver, and + * cleanup so legacy uploads stored under the default dir are still found. + * + * @return de-duplicated, ordered list (at least the default root is present) + */ + public List resolveCandidateUploadRoots(String conversationId) { + ConversationEntity conv = lookupConversation(conversationId); + Long workspaceId = conv != null ? conv.getWorkspaceId() : null; + Long agentId = conv != null ? conv.getAgentId() : null; + return resolveCandidateUploadRoots(workspaceId, agentId); + } + + /** + * Variant of {@link #resolveCandidateUploadRoots(String)} for callers that + * already hold the workspace / agent ids. + */ + public List resolveCandidateUploadRoots(Long workspaceId, Long agentId) { + Set roots = new LinkedHashSet<>(); + Path scoped = resolveWorkspaceScopedRoot(workspaceId, agentId); + if (scoped != null) { + roots.add(scoped); + } + roots.add(defaultRoot()); + return new ArrayList<>(roots); + } + + // ==================== internals ==================== + + /** + * Resolve the workspace/agent-scoped root, or {@code null} when neither the + * agent override nor the workspace {@code basePath} is configured (caller + * then falls back to {@link #defaultRoot()}). + */ + private Path resolveWorkspaceScopedRoot(Long workspaceId, Long agentId) { + WorkspaceEntity workspace = null; + if (workspaceId != null) { + try { + workspace = workspaceService.getById(workspaceId); + } catch (MateClawException e) { + // Workspace row missing — fall through to the default root. + log.debug("[ChatUpload] workspace {} not found: {}", workspaceId, e.getMessage()); + } + } + + String agentOverride = null; + if (agentId != null) { + try { + AgentEntity agent = agentService.getAgent(agentId); + agentOverride = agent.getWorkspaceBasePath(); + } catch (MateClawException e) { + log.debug("[ChatUpload] agent {} not found: {}", agentId, e.getMessage()); + } + } + + // A conversation's agent always belongs to the conversation's workspace + // (enforced at creation), so the workspace basePath is the scoping root + // for both the agent override and the no-override case. + String workspaceBase = workspace != null ? workspace.getBasePath() : null; + + String resolvedBase; + try { + resolvedBase = AgentGraphBuilder.resolveAgentBasePath(agentOverride, workspaceBase); + } catch (IllegalArgumentException e) { + // Agent override escapes the workspace root — inherit the workspace + // basePath so chat stays available (mirrors AgentGraphBuilder's own + // fallback). Surface it so the operator can fix the override. + log.warn("[ChatUpload] agent {} basePath override rejected, using workspace root: {}", + agentId, e.getMessage()); + resolvedBase = workspaceBase; + } + + if (resolvedBase == null || resolvedBase.isBlank()) { + return null; + } + return Paths.get(resolvedBase).toAbsolutePath().normalize().resolve(UPLOAD_SUBDIR); + } + + /** The configurable default upload root (legacy location by default). */ + public Path defaultRoot() { + return Paths.get(properties.getBaseDir()).toAbsolutePath().normalize(); + } + + private ConversationEntity lookupConversation(String conversationId) { + if (conversationId == null || conversationId.isEmpty()) { + return null; + } + return conversationCache.get(conversationId, id -> conversationMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ConversationEntity::getConversationId, id) + .eq(ConversationEntity::getDeleted, 0) + .last("LIMIT 1"))); + } + + /** Drop the cached conversation row (test hook / on conversation re-create). */ + public void invalidate(String conversationId) { + if (conversationId != null) { + conversationCache.invalidate(conversationId); + } + } + + /** + * Drop the cached {@code conversationId → ConversationEntity} mapping when a + * conversation is deleted, mirroring {@code WorkspaceLookupCache}'s listener. + * Without this, a re-created conversation with the same id (rare, but + * possible across a backup restore) would inherit the stale workspace/agent + * mapping for up to five minutes — and {@code cleanAttachmentFiles} would + * walk the wrong (stale) upload directory. The delete tx has already + * committed when this fires, so the cache entry is safe to evict. + */ + @EventListener + public void onConversationDeleted(ConversationDeletedEvent event) { + invalidate(event.conversationId()); + } +} diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 8cf397ea..74b4f78e 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -189,6 +189,15 @@ mateclaw: # unconstrained behaviour for unconfigured conversations. enabled: ${MATECLAW_WORKSPACE_SANDBOX_ENABLED:true} root: ${MATECLAW_WORKSPACE_SANDBOX_ROOT:${user.dir}/data/workspace} + chat: + upload: + # Root directory for conversation chat attachments when neither the active + # agent nor its workspace configures a base path. Attachments resolve to + # {baseDir}/{conversationId}/{storedName}. When a workspace/agent base path + # IS configured, attachments land under {basePath}/chat-uploads/{convId}/ + # instead; reads and cleanup still check this default dir so legacy uploads + # remain resolvable. Defaults to the legacy location for zero-config parity. + base-dir: ${MATECLAW_CHAT_UPLOAD_BASE_DIR:data/chat-uploads} skill: workspace: # Skill workspace root. Override with MATECLAW_SKILL_WORKSPACE_ROOT to diff --git a/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md b/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md index 67c7be7c..c6aa92f5 100644 --- a/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md +++ b/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md @@ -212,7 +212,7 @@ Fix: `AsyncTaskMediaDispatcher.forwardToImIfBound(conversationId, parts)`: - Slack: via `filesUploadV2` (see [Slack channel](./channels#slack)) - Channels without `sendContentParts` (QQ, etc.): catch UnsupportedOperationException + log; one unsupported channel doesn't block the rest -Files live at `data/chat-uploads/{conversationId}/`, served at `/api/v1/chat/files/{conversationId}/{storedName}`. Frontend and channel attachment views all read by this URL. +Files live at `data/chat-uploads/{conversationId}/` by default, but when the conversation's Agent / Workspace has a `basePath` configured, attachments land under `{basePath}/chat-uploads/{conversationId}/` (precedence: Agent `workspaceBasePath` → Workspace `basePath` → default dir `mateclaw.chat.upload.base-dir`). Reads and cleanup probe both the new and legacy locations, so pre-migration attachments stay accessible. Served at `/api/v1/chat/files/{conversationId}/{storedName}`; frontend and channel attachment views all read by this URL. --- diff --git a/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md b/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md index 4f194f80..758bff2d 100644 --- a/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md +++ b/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md @@ -212,7 +212,7 @@ MateClaw 在 link 分支检测到 `mp.weixin.qq.com` 后,会自动给模型追 - Slack:通过 `filesUploadV2` 直传(参考 [Slack channel](./channels#slack)) - 不支持 `sendContentParts` 的渠道(QQ 等):catch UnsupportedOperationException + log,不让一个不支持的渠道卡住整批分发 -文件路径在 `data/chat-uploads/{conversationId}/`,serve URL 是 `/api/v1/chat/files/{conversationId}/{storedName}`,前端 / 渠道附件视图都按这个 URL 读。 +文件路径默认在 `data/chat-uploads/{conversationId}/`,但当会话的 Agent / Workspace 配置了 `basePath` 时,附件落在 `{basePath}/chat-uploads/{conversationId}/`(解析优先级:Agent `workspaceBasePath` → Workspace `basePath` → 默认目录 `mateclaw.chat.upload.base-dir`)。读取与清理会同时探测新旧位置,迁移前的旧附件仍可访问。serve URL 是 `/api/v1/chat/files/{conversationId}/{storedName}`,前端 / 渠道附件视图都按这个 URL 读。 --- diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java index ab929f22..7bcb8ef7 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java @@ -62,7 +62,8 @@ class ChannelManagerReconcileTest { mock(vip.mate.channel.feishu.cards.FeishuCardDispatcher.class), mock(vip.mate.channel.feishu.FeishuClientFactory.class), mock(vip.mate.stt.SttService.class), - election); + election, + mock(vip.mate.workspace.core.service.ChatUploadLocationResolver.class)); adapter = new TrackingAdapter(); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java index 6a9fe1b5..03223a72 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockMultipartFile; +import vip.mate.workspace.core.service.ChatUploadLocationResolverTestSupport; import java.io.IOException; import java.nio.file.Files; @@ -27,12 +28,13 @@ class WebChatFileServiceTest { private static final String CONV = "webchat:abcd1234:visitor-1"; private WebChatFileService service(boolean enabled, long maxMb, String exts) { - return new WebChatFileService(enabled, maxMb, exts, 50, 200); + return service(enabled, maxMb, exts, 50, 200); } private WebChatFileService service(boolean enabled, long maxMb, String exts, int maxFiles, long maxTotalMb) { - return new WebChatFileService(enabled, maxMb, exts, maxFiles, maxTotalMb); + return new WebChatFileService(enabled, maxMb, exts, maxFiles, maxTotalMb, + ChatUploadLocationResolverTestSupport.legacyDefault()); } @AfterEach diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/ImageFileDownloaderTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageFileDownloaderTest.java index 6c92d813..e3105b61 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/image/ImageFileDownloaderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageFileDownloaderTest.java @@ -5,6 +5,7 @@ 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; @@ -38,7 +39,7 @@ class ImageFileDownloaderTest { @BeforeEach void setUp() { - downloader = new ImageFileDownloader(); + downloader = new ImageFileDownloader(ChatUploadLocationResolverTestSupport.legacyDefault()); } @AfterEach diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceCleanAttachmentFilesTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceCleanAttachmentFilesTest.java index 36f2251e..992a115f 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceCleanAttachmentFilesTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceCleanAttachmentFilesTest.java @@ -2,6 +2,7 @@ package vip.mate.workspace.conversation; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,6 +13,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import vip.mate.agent.repository.AgentMapper; import vip.mate.workspace.conversation.repository.ConversationMapper; import vip.mate.workspace.conversation.repository.MessageMapper; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; @@ -19,12 +21,15 @@ import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Comparator; +import java.util.List; import java.util.UUID; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; /** * Regression coverage for issue #36: deleting a CRON-task conversation throws @@ -47,11 +52,21 @@ class ConversationServiceCleanAttachmentFilesTest { @Mock private MessageMapper messageMapper; @Mock private AgentMapper agentMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); + @Mock private ChatUploadLocationResolver chatUploadLocationResolver; @InjectMocks private ConversationService service; private Path createdDir; + @BeforeEach + void stubResolver() { + // cleanAttachmentFiles now resolves the upload root via the resolver. + // Point its candidate roots at the legacy default dir so both the + // happy-path and unrepresentable-id cases exercise the real filesystem. + when(chatUploadLocationResolver.resolveCandidateUploadRoots(any())) + .thenReturn(List.of(Paths.get("data", "chat-uploads"))); + } + @AfterEach void cleanup() throws IOException { if (createdDir != null && Files.exists(createdDir)) { 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 new file mode 100644 index 00000000..21b2abd1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java @@ -0,0 +1,186 @@ +package vip.mate.workspace.core.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.agent.AgentService; +import vip.mate.agent.model.AgentEntity; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.core.config.ChatUploadProperties; +import vip.mate.workspace.core.model.WorkspaceEntity; + +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link ChatUploadLocationResolver}'s resolution precedence + * (agent override → workspace basePath → configurable default) and its + * dual-lookup candidate ordering (workspace-scoped root first, then the + * default fallback root). + */ +class ChatUploadLocationResolverTest { + + @TempDir + Path tempDir; + + private ConversationMapper conversationMapper = mock(ConversationMapper.class); + private WorkspaceService workspaceService = mock(WorkspaceService.class); + private AgentService agentService = mock(AgentService.class); + + private ChatUploadLocationResolver resolver(Path defaultDir) { + ChatUploadProperties props = new ChatUploadProperties(); + props.setBaseDir(defaultDir.toAbsolutePath().toString()); + return new ChatUploadLocationResolver(conversationMapper, workspaceService, props, agentService); + } + + private void stubConversation(String convId, Long workspaceId, Long agentId) { + ConversationEntity conv = new ConversationEntity(); + conv.setConversationId(convId); + conv.setWorkspaceId(workspaceId); + conv.setAgentId(agentId); + conv.setDeleted(0); + when(conversationMapper.selectOne(any(Wrapper.class))).thenReturn(conv); + } + + private WorkspaceEntity workspace(Long id, String basePath) { + WorkspaceEntity ws = new WorkspaceEntity(); + ws.setId(id); + ws.setBasePath(basePath); + return ws; + } + + private AgentEntity agent(Long id, String workspaceBasePath, Long workspaceId) { + AgentEntity a = new AgentEntity(); + a.setId(id); + a.setWorkspaceBasePath(workspaceBasePath); + a.setWorkspaceId(workspaceId); + return a; + } + + @Test + @DisplayName("no agent and no workspace basePath → configurable default root") + void resolvesToDefaultWhenNothingConfigured() { + stubConversation("c1", 7L, null); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, null)); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("c1"); + + // The default root IS the chat-uploads dir (no extra subdir appended), + // so conversation dirs land directly under it: {defaultDir}/{convId}/. + assertThat(root).isEqualTo(tempDir.toAbsolutePath().normalize()); + } + + @Test + @DisplayName("workspace basePath set, no agent override → {basePath}/chat-uploads") + void resolvesToWorkspaceBasePath() { + stubConversation("c2", 7L, null); + Path wsBase = tempDir.resolve("ws-root"); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, wsBase.toString())); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("c2"); + + assertThat(root).isEqualTo(wsBase.toAbsolutePath().normalize() + .resolve(ChatUploadLocationResolver.UPLOAD_SUBDIR)); + } + + @Test + @DisplayName("agent override wins over workspace basePath") + void agentOverrideWinsOverWorkspace() { + stubConversation("c3", 7L, 99L); + Path wsBase = tempDir.resolve("ws-root"); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, wsBase.toString())); + // Absolute override that sits inside the workspace root — allowed, and wins. + Path agentOverride = wsBase.resolve("agent-override"); + when(agentService.getAgent(99L)).thenReturn(agent(99L, agentOverride.toString(), 7L)); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("c3"); + + assertThat(root).isEqualTo(agentOverride.toAbsolutePath().normalize() + .resolve(ChatUploadLocationResolver.UPLOAD_SUBDIR)); + } + + @Test + @DisplayName("agent override that escapes the workspace root falls back to workspace basePath") + void agentOverrideEscapingWorkspaceFallsBackToWorkspace() { + stubConversation("c4", 7L, 99L); + Path wsBase = tempDir.resolve("ws-root"); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, wsBase.toString())); + // Override points outside the workspace root — resolveAgentBasePath rejects it; + // the resolver falls back to the workspace basePath. + when(agentService.getAgent(99L)).thenReturn(agent(99L, "/etc", 7L)); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("c4"); + + assertThat(root).isEqualTo(wsBase.toAbsolutePath().normalize() + .resolve(ChatUploadLocationResolver.UPLOAD_SUBDIR)); + } + + @Test + @DisplayName("relative agent override is resolved under the workspace basePath") + void relativeAgentOverrideResolvedUnderWorkspace() { + stubConversation("c5", 7L, 99L); + Path wsBase = tempDir.resolve("ws-root"); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, wsBase.toString())); + when(agentService.getAgent(99L)).thenReturn(agent(99L, "subdir", 7L)); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("c5"); + + assertThat(root).isEqualTo(wsBase.toAbsolutePath().normalize() + .resolve("subdir") + .resolve(ChatUploadLocationResolver.UPLOAD_SUBDIR)); + } + + @Test + @DisplayName("candidate roots: workspace-scoped first, then default (dual-lookup order)") + void candidateRootsOrderedScopedThenDefault() { + stubConversation("c6", 7L, null); + Path wsBase = tempDir.resolve("ws-root"); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, wsBase.toString())); + + ChatUploadLocationResolver r = resolver(tempDir); + List candidates = r.resolveCandidateUploadRoots("c6"); + + assertThat(candidates).hasSize(2); + assertThat(candidates.get(0)).isEqualTo(wsBase.toAbsolutePath().normalize() + .resolve(ChatUploadLocationResolver.UPLOAD_SUBDIR)); + assertThat(candidates.get(1)).isEqualTo(tempDir.toAbsolutePath().normalize()); + } + + @Test + @DisplayName("candidate roots: only the default when nothing configured (no duplicate)") + void candidateRootsOnlyDefaultWhenUnconfigured() { + stubConversation("c7", 7L, null); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, null)); + + ChatUploadLocationResolver r = resolver(tempDir); + List candidates = r.resolveCandidateUploadRoots("c7"); + + assertThat(candidates).hasSize(1); + assertThat(candidates.get(0)).isEqualTo(tempDir.toAbsolutePath().normalize()); + } + + @Test + @DisplayName("unknown conversation → falls back to default root (no NPE)") + void unknownConversationFallsBackToDefault() { + when(conversationMapper.selectOne(any(Wrapper.class))).thenReturn(null); + when(workspaceService.getById(anyLong())).thenReturn(workspace(1L, null)); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("nonexistent"); + + assertThat(root).isEqualTo(tempDir.toAbsolutePath().normalize()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTestSupport.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTestSupport.java new file mode 100644 index 00000000..5ae027b5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTestSupport.java @@ -0,0 +1,47 @@ +package vip.mate.workspace.core.service; + +import vip.mate.agent.AgentService; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.core.config.ChatUploadProperties; + +import java.nio.file.Path; + +import static org.mockito.Mockito.mock; + +/** + * Test helper that builds a {@link ChatUploadLocationResolver} whose default + * upload root points at a caller-chosen directory. Dependencies are Mockito + * mocks — when neither a workspace nor an agent base path is configured (the + * common unit-test case), the resolver never consults them and just returns + * {@link ChatUploadLocationResolver#defaultRoot()}. + * + *

Production code paths that DO resolve against a workspace/agent base path + * should configure the mocks via the accessors below. + */ +public final class ChatUploadLocationResolverTestSupport { + + private ChatUploadLocationResolverTestSupport() {} + + /** + * Build a resolver whose {@link ChatUploadLocationResolver#defaultRoot()} + * is {@code defaultDir}, with mocked {@link ConversationMapper} / + * {@link WorkspaceService} / {@link AgentService}. + */ + public static ChatUploadLocationResolver withDefaultRoot(Path defaultDir) { + ChatUploadProperties props = new ChatUploadProperties(); + props.setBaseDir(defaultDir.toAbsolutePath().normalize().toString()); + return new ChatUploadLocationResolver( + mock(ConversationMapper.class), + mock(WorkspaceService.class), + props, + mock(AgentService.class)); + } + + /** + * Build a resolver whose default root is the legacy {@code data/chat-uploads} + * (matching out-of-the-box behaviour), with mocked dependencies. + */ + public static ChatUploadLocationResolver legacyDefault() { + return withDefaultRoot(Path.of("data", "chat-uploads")); + } +}