feat(workspace): chat-uploads 上传目录工作空间/Agent 感知化 (#422)

* feat(workspace): chat-uploads 上传目录工作空间/Agent 感知化 (#421)

把硬编码的 data/chat-uploads/{conversationId}/ 改为按工作空间/Agent 解析,
解析优先级:Agent workspaceBasePath → Workspace basePath → 可配置默认目录
(新配置 mateclaw.chat.upload.base-dir,默认 data/chat-uploads,保持现网零行为变化)。

- 新增 ChatUploadLocationResolver 中央解析器:写路径返回唯一根,读/清理
  路径返回候选根列表(工作空间根 + 默认根)做双重查找,保证迁移前旧附件
  仍可解析/清理;conversationId→ConversationEntity 查询带 5min 缓存。
- 新增 ChatUploadProperties + ChatUploadAutoConfiguration(启动建目录)。
- 复用 AgentGraphBuilder.resolveAgentBasePath(提升为 public)的优先级与
  安全规则(相对路径在 workspace 根下解析,绝对路径逃逸被拒)。
- 所有写入/读取点改为走 resolver;读取点走双重查找。
- 解决 Spring 循环依赖(resolver → agentService → ... → conversationService
  → resolver):resolver 的 AgentService 注入加 @Lazy。

向后兼容:默认目录不变;双重查找覆盖历史消息里的相对路径;
服务端点 URL 契约不变,前端无需改动。

测试:新增 ChatUploadLocationResolverTest(8 用例);修复受影响的现有测试构造。

* refactor(workspace): address review findings on chat-uploads resolver (#421)

应用 code review 的 4 项修复:

1. (correctness) ChatUploadLocationResolver 缓存新增 ConversationDeletedEvent
   监听器,删除会话时立即失效 conversationId→ConversationEntity 映射。
   否则备份恢复后用相同 id 重建会话,会继承最长 5 分钟的过期 workspace/agent
   映射,导致 cleanAttachmentFiles 走错(过期的)上传目录。复用既有
   @EventListener-on-bean 模式(与 AsyncTaskService / WorkspaceLookupCache 一致)。

2. 收紧 resolveWorkspaceScopedRoot 里 3 个过宽的 catch(Exception) →
   MateClawException + warn,让真正的 bug(NPE / DataAccessException)暴露
   而非被静默降级为 debug 日志。

3. 更新 ChatController.upload 过期注释:会话尚未创建时附件暂存默认目录,
   会话创建后读取走双重查找仍能命中。

4. 移除不可达分支(agentWorkspaceId != workspaceId)—— 会话的 agent 必然
   归属会话的 workspace(创建时强约束),直接用会话 workspace 即可,
   少一次冗余 DB 查询与一层推测性逻辑。

测试:ChatUploadLocationResolverTest (8) + ConversationServiceCleanAttachmentFilesTest (2) 全绿。
This commit is contained in:
倪程伟 2026-06-26 14:20:54 +08:00 committed by GitHub
parent 7705778903
commit fcb488c567
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 938 additions and 91 deletions

View File

@ -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) {

View File

@ -1220,7 +1220,9 @@ public abstract class BaseAgent {
/**
* 解析图片文件的绝对路径
* <p>
* 上传文件存储在 data/chat-uploads/ 是相对于 Spring Boot 工作目录的路径
* 上传文件的存储位置由 {@code ChatUploadLocationResolver} 按优先级解析
* Agent workspaceBasePath Workspace basePath 可配置默认目录
* {@code mateclaw.chat.upload.base-dir}默认 {@code data/chat-uploads}
* MCP 工具的工作目录可能不同所以这里直接解析为绝对路径
*/
/**

View File

@ -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<Long, ChannelAdapter> 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);

View File

@ -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;
}
/**
* 判断是否需要为此消息生成语音回复
*/

View File

@ -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<java.nio.file.Path> candidateChatUploadRoots(String conversationId) {
java.util.List<java.nio.file.Path> 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: 2s4s8s16s30s, 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<RecentFileEntry> loadRecentFilesFromDisk(String conversationId) {
long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L;
return loadRecentFilesFromDisk(chatUploadsRoot.resolve(conversationId), cutoff);
List<RecentFileEntry> merged = new java.util.ArrayList<>();
for (Path root : candidateChatUploadRoots(conversationId)) {
merged.addAll(loadRecentFilesFromDisk(root.resolve(conversationId), cutoff));
}
return merged;
}
/**

View File

@ -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;
// 使用虚拟线程池处理 SSEJava 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();
}

View File

@ -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<String> allowedExtensions;
private final int maxFilesPerConversation;
private final long maxTotalBytesPerConversation;
private final ChatUploadLocationResolver uploadLocationResolver;
/** fileId (== storedName) -> staged metadata, pending a /stream reference. */
private final ConcurrentHashMap<String, StagedFile> 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. */

View File

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

View File

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

View File

@ -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}/}).
* <p>
* 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.
* <p>
* 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;
* <p>
* 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<Path> candidateUploadDirs(String conversationId) {
Set<Path> 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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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");
/**
* 清理会话关联的附件文件
* <p>
* 遍历所有候选上传根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:<jobId> 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:<jobId> 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<Path> 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<Path> 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);
}
}
}

View File

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

View File

@ -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.
* <p>
* Chat uploads (files exchanged in a conversation) resolve their storage root
* with this precedence:
* <ol>
* <li>Agent-level {@code workspaceBasePath} override (resolved under the
* workspace {@code basePath}, same rule as
* {@code AgentGraphBuilder.resolveAgentBasePath});</li>
* <li>Workspace {@code basePath} (when the agent has no override);</li>
* <li>This {@link #baseDir} fallback the out-of-the-box default used when
* neither the agent nor its workspace configures a base path.</li>
* </ol>
* 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";
}

View File

@ -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.
*
* <h3>Resolution precedence</h3>
* <ol>
* <li>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}.</li>
* <li>Otherwise, when the workspace has a {@code basePath}, the upload root is
* {@code {workspace.basePath}/chat-uploads}.</li>
* <li>Otherwise, the configurable fallback {@link ChatUploadProperties#getBaseDir()}
* (default {@code data/chat-uploads}) is used.</li>
* </ol>
*
* <h3>Backward compatibility</h3>
* Reads and cleanup use {@link #resolveCandidateUploadRoots(String)} which
* returns <em>both</em> 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)}.
*
* <p>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<String, ConversationEntity> 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<Path> 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<Path> resolveCandidateUploadRoots(Long workspaceId, Long agentId) {
Set<Path> 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.<ConversationEntity>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());
}
}

View File

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

View File

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

View File

@ -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 读。
---

View File

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

View File

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

View File

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

View File

@ -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)) {

View File

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

View File

@ -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()}.
*
* <p>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"));
}
}