mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
7705778903
commit
fcb488c567
@ -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) {
|
||||
|
||||
@ -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 工具的工作目录可能不同,所以这里直接解析为绝对路径。
|
||||
*/
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要为此消息生成语音回复
|
||||
*/
|
||||
|
||||
@ -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: 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<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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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(
|
||||
() -> {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 读。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user