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:
+ *
+ * - Agent-level {@code workspaceBasePath} override (resolved under the
+ * workspace {@code basePath}, same rule as
+ * {@code AgentGraphBuilder.resolveAgentBasePath});
+ * - Workspace {@code basePath} (when the agent has no override);
+ * - This {@link #baseDir} fallback — the out-of-the-box default used when
+ * neither the agent nor its workspace configures a base path.
+ *
+ * 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
+ *
+ * - 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}.
+ * - Otherwise, when the workspace has a {@code basePath}, the upload root is
+ * {@code {workspace.basePath}/chat-uploads}.
+ * - Otherwise, the configurable fallback {@link ChatUploadProperties#getBaseDir()}
+ * (default {@code data/chat-uploads}) is used.
+ *
+ *
+ * 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"));
+ }
+}