mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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
|
* @throws IllegalArgumentException when an absolute override escapes the
|
||||||
* workspace root
|
* workspace root
|
||||||
*/
|
*/
|
||||||
static String resolveAgentBasePath(String agentOverride, String workspaceBase) {
|
public static String resolveAgentBasePath(String agentOverride, String workspaceBase) {
|
||||||
boolean hasOverride = agentOverride != null && !agentOverride.isBlank();
|
boolean hasOverride = agentOverride != null && !agentOverride.isBlank();
|
||||||
boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank();
|
boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank();
|
||||||
if (!hasOverride) {
|
if (!hasOverride) {
|
||||||
|
|||||||
@ -1220,7 +1220,9 @@ public abstract class BaseAgent {
|
|||||||
/**
|
/**
|
||||||
* 解析图片文件的绝对路径。
|
* 解析图片文件的绝对路径。
|
||||||
* <p>
|
* <p>
|
||||||
* 上传文件存储在 data/chat-uploads/ 下,是相对于 Spring Boot 工作目录的路径。
|
* 上传文件的存储位置由 {@code ChatUploadLocationResolver} 按优先级解析:
|
||||||
|
* Agent 的 workspaceBasePath → Workspace 的 basePath → 可配置默认目录
|
||||||
|
* ({@code mateclaw.chat.upload.base-dir},默认 {@code data/chat-uploads})。
|
||||||
* MCP 工具的工作目录可能不同,所以这里直接解析为绝对路径。
|
* MCP 工具的工作目录可能不同,所以这里直接解析为绝对路径。
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -134,6 +134,13 @@ public class ChannelManager {
|
|||||||
*/
|
*/
|
||||||
private final ChannelLeaderElection leaderElection;
|
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 */
|
/** 运行中的渠道适配器:channelId -> adapter */
|
||||||
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
|
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
|
||||||
|
|
||||||
@ -1197,14 +1204,16 @@ public class ChannelManager {
|
|||||||
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
|
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
|
||||||
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
|
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
|
||||||
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager,
|
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager,
|
||||||
feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService);
|
feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService,
|
||||||
|
chatUploadLocationResolver);
|
||||||
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
|
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
|
||||||
approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler,
|
approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler,
|
||||||
generatedFileCache);
|
generatedFileCache, chatUploadLocationResolver);
|
||||||
case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper);
|
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 "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper);
|
case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
default -> throw new IllegalArgumentException("Unsupported channel type: " + type);
|
default -> throw new IllegalArgumentException("Unsupported channel type: " + type);
|
||||||
|
|||||||
@ -70,6 +70,13 @@ public class ChannelMessageRouter {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private ApplicationEventPublisher events;
|
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) {}
|
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
||||||
|
|
||||||
@ -1491,10 +1498,12 @@ public class ChannelMessageRouter {
|
|||||||
// 构建音频 MessageContentPart
|
// 构建音频 MessageContentPart
|
||||||
String audioUrl = (String) result.get("audioUrl");
|
String audioUrl = (String) result.get("audioUrl");
|
||||||
String fileName = Paths.get(audioUrl).getFileName().toString();
|
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)) {
|
if (audioPath == null) {
|
||||||
log.warn("[voice-reply] TTS output file not found: {}", audioPath);
|
log.warn("[voice-reply] TTS output file not found for conversation {} ({})", conversationId, fileName);
|
||||||
return;
|
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();
|
.build();
|
||||||
|
|
||||||
// Package-private for testing: redirect to a temp directory without touching real disk.
|
// 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");
|
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,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
@ -291,6 +331,28 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
FeishuClientFactory clientFactory,
|
FeishuClientFactory clientFactory,
|
||||||
vip.mate.tool.document.GeneratedFileCache generatedFileCache,
|
vip.mate.tool.document.GeneratedFileCache generatedFileCache,
|
||||||
vip.mate.stt.SttService sttService) {
|
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);
|
super(channelEntity, messageRouter, objectMapper);
|
||||||
this.mediaUploader = mediaUploader;
|
this.mediaUploader = mediaUploader;
|
||||||
this.generatedFileScrubber = generatedFileScrubber;
|
this.generatedFileScrubber = generatedFileScrubber;
|
||||||
@ -299,6 +361,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
this.clientFactory = clientFactory;
|
this.clientFactory = clientFactory;
|
||||||
this.generatedFileCache = generatedFileCache;
|
this.generatedFileCache = generatedFileCache;
|
||||||
this.sttService = sttService;
|
this.sttService = sttService;
|
||||||
|
this.chatUploadLocationResolver = chatUploadLocationResolver;
|
||||||
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
|
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
|
||||||
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
|
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);
|
: maybeDownloadResource(messageId, fileKey, type, fileName);
|
||||||
if (dl == null) return null;
|
if (dl == null) return null;
|
||||||
|
|
||||||
// Save to data/chat-uploads/{conversationId}/
|
// Save under the workspace/agent-aware upload root ({convId}/ subdir)
|
||||||
Path uploadDir = chatUploadsRoot.resolve(conversationId);
|
Path uploadDir = chatUploadRootFor(conversationId).resolve(conversationId);
|
||||||
Files.createDirectories(uploadDir);
|
Files.createDirectories(uploadDir);
|
||||||
String rawName = (dl.fileName() != null && !dl.fileName().isBlank())
|
String rawName = (dl.fileName() != null && !dl.fileName().isBlank())
|
||||||
? dl.fileName() : fileKey;
|
? dl.fileName() : fileKey;
|
||||||
@ -1796,15 +1859,20 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan {@code data/chat-uploads/{conversationId}/} on disk and return
|
* Scan the conversation's upload dir(s) on disk and return the most recent
|
||||||
* the most recent files as {@link RecentFileEntry}s. Used as a
|
* files as {@link RecentFileEntry}s. Used as a fallback when the in-memory
|
||||||
* fallback when the in-memory Caffeine cache has been evicted
|
* Caffeine cache has been evicted (process restart, TTL expiry, GC pressure)
|
||||||
* (process restart, TTL expiry, GC pressure) but the staged copies
|
* but the staged copies are still on disk. Probes every candidate root
|
||||||
* are still on disk.
|
* (workspace-scoped + legacy default) so files written before the
|
||||||
|
* workspace-aware relocation are still found.
|
||||||
*/
|
*/
|
||||||
private List<RecentFileEntry> loadRecentFilesFromDisk(String conversationId) {
|
private List<RecentFileEntry> loadRecentFilesFromDisk(String conversationId) {
|
||||||
long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L;
|
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.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import reactor.core.Disposable;
|
import reactor.core.Disposable;
|
||||||
|
|
||||||
@ -62,7 +61,7 @@ public class ChatController {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ConversationCompletionPublisher completionPublisher;
|
private final ConversationCompletionPublisher completionPublisher;
|
||||||
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
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())
|
// 使用虚拟线程池处理 SSE(Java 17+ 兼容,Java 21 可用 Executors.newVirtualThreadPerTaskExecutor())
|
||||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||||
@ -1075,7 +1074,9 @@ public class ChatController {
|
|||||||
Authentication auth) throws IOException {
|
Authentication auth) throws IOException {
|
||||||
|
|
||||||
String username = auth != null ? auth.getName() : "anonymous";
|
String username = auth != null ? auth.getName() : "anonymous";
|
||||||
// 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户)
|
// 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户)。
|
||||||
|
// 注意:会话尚不存在时,附件暂存到默认目录(resolveUploadRoot 查不到会话即回退);
|
||||||
|
// 会话创建后读取走双重查找,仍能命中。
|
||||||
if (conversationService.conversationExists(conversationId)
|
if (conversationService.conversationExists(conversationId)
|
||||||
&& !conversationService.isConversationOwner(conversationId, username)) {
|
&& !conversationService.isConversationOwner(conversationId, username)) {
|
||||||
return R.fail(403, "无权操作该会话");
|
return R.fail(403, "无权操作该会话");
|
||||||
@ -1087,6 +1088,7 @@ public class ChatController {
|
|||||||
String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
|
String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
|
||||||
String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_");
|
String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||||
String storedName = System.currentTimeMillis() + "_" + safeFilename;
|
String storedName = System.currentTimeMillis() + "_" + safeFilename;
|
||||||
|
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId);
|
||||||
Path conversationDir = uploadRoot.resolve(conversationId);
|
Path conversationDir = uploadRoot.resolve(conversationId);
|
||||||
Files.createDirectories(conversationDir);
|
Files.createDirectories(conversationDir);
|
||||||
Path target = conversationDir.resolve(storedName);
|
Path target = conversationDir.resolve(storedName);
|
||||||
@ -1119,8 +1121,20 @@ public class ChatController {
|
|||||||
return ResponseEntity.status(403).build();
|
return ResponseEntity.status(403).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Path filePath = uploadRoot.resolve(conversationId).resolve(storedName).normalize();
|
// Check every candidate root (workspace-scoped dir + legacy default dir)
|
||||||
if (!Files.exists(filePath) || !filePath.startsWith(uploadRoot.resolve(conversationId).normalize())) {
|
// 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();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,11 +5,11 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@ -39,9 +39,6 @@ import java.util.stream.Stream;
|
|||||||
@Service
|
@Service
|
||||||
public class WebChatFileService {
|
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. */
|
/** 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
|
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 Set<String> allowedExtensions;
|
||||||
private final int maxFilesPerConversation;
|
private final int maxFilesPerConversation;
|
||||||
private final long maxTotalBytesPerConversation;
|
private final long maxTotalBytesPerConversation;
|
||||||
|
private final ChatUploadLocationResolver uploadLocationResolver;
|
||||||
|
|
||||||
/** fileId (== storedName) -> staged metadata, pending a /stream reference. */
|
/** fileId (== storedName) -> staged metadata, pending a /stream reference. */
|
||||||
private final ConcurrentHashMap<String, StagedFile> staged = new ConcurrentHashMap<>();
|
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,"
|
+ "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,
|
+ "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-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.enabled = enabled;
|
||||||
this.maxSizeBytes = maxSizeMb * 1024 * 1024;
|
this.maxSizeBytes = maxSizeMb * 1024 * 1024;
|
||||||
this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(","))
|
this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(","))
|
||||||
@ -70,6 +69,7 @@ public class WebChatFileService {
|
|||||||
.collect(Collectors.toUnmodifiableSet());
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
this.maxFilesPerConversation = maxFilesPerConversation;
|
this.maxFilesPerConversation = maxFilesPerConversation;
|
||||||
this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024;
|
this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024;
|
||||||
|
this.uploadLocationResolver = uploadLocationResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Metadata for a staged upload. */
|
/** Metadata for a staged upload. */
|
||||||
@ -111,7 +111,7 @@ public class WebChatFileService {
|
|||||||
|
|
||||||
String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
|
String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
|
||||||
// Strip any directory components, then collapse to a safe charset.
|
// 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);
|
String ext = extensionOf(baseName);
|
||||||
if (ext.isEmpty() || !allowedExtensions.contains(ext)) {
|
if (ext.isEmpty() || !allowedExtensions.contains(ext)) {
|
||||||
throw new UploadRejectedException("File type not allowed: ." + 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 safeName = baseName.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||||
|
|
||||||
String storedName = UUID.randomUUID() + "_" + safeName;
|
String storedName = UUID.randomUUID() + "_" + safeName;
|
||||||
Path dir = UPLOAD_ROOT.resolve(conversationId).normalize();
|
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize();
|
||||||
if (!dir.startsWith(UPLOAD_ROOT.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.
|
// conversationId is server-derived, so this should never happen; fail closed if it does.
|
||||||
throw new UploadRejectedException("Invalid conversation");
|
throw new UploadRejectedException("Invalid conversation");
|
||||||
}
|
}
|
||||||
@ -169,12 +170,16 @@ public class WebChatFileService {
|
|||||||
if (storedName == null || storedName.isBlank()) {
|
if (storedName == null || storedName.isBlank()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
Path base = UPLOAD_ROOT.resolve(conversationId).normalize();
|
// Check every candidate root (workspace-scoped dir + legacy default dir)
|
||||||
Path file = base.resolve(storedName).normalize();
|
// so files written before the workspace-aware relocation still resolve.
|
||||||
if (!file.startsWith(base) || !Files.exists(file) || !Files.isRegularFile(file)) {
|
for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
|
||||||
return Optional.empty();
|
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. */
|
/** 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;
|
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,
|
public WeComChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
@ -297,11 +303,30 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
|
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
|
||||||
WeComKeepaliveScheduler keepaliveScheduler,
|
WeComKeepaliveScheduler keepaliveScheduler,
|
||||||
vip.mate.tool.document.GeneratedFileCache generatedFileCache) {
|
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);
|
super(channelEntity, messageRouter, objectMapper);
|
||||||
this.approvalNotificationService = approvalNotificationService;
|
this.approvalNotificationService = approvalNotificationService;
|
||||||
this.cardDispatcher = cardDispatcher;
|
this.cardDispatcher = cardDispatcher;
|
||||||
this.keepaliveScheduler = keepaliveScheduler;
|
this.keepaliveScheduler = keepaliveScheduler;
|
||||||
this.generatedFileCache = generatedFileCache;
|
this.generatedFileCache = generatedFileCache;
|
||||||
|
this.chatUploadLocationResolver = chatUploadLocationResolver;
|
||||||
// Default to 8 bounded attempts (~4 minutes total at 2s..30s exponential)
|
// Default to 8 bounded attempts (~4 minutes total at 2s..30s exponential)
|
||||||
// so the UI eventually settles in ERROR instead of getting stuck in
|
// so the UI eventually settles in ERROR instead of getting stuck in
|
||||||
// RECONNECTING forever. User config still overrides (-1 = infinite).
|
// 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,
|
private InboundMediaDownloader.DownloadedMedia downloadInboundMedia(String url, String aesKey, String msgId,
|
||||||
String fileNameHint, String conversationId) {
|
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
|
// /api/v1/chat/files/{convId}/{storedName} endpoint serves the file
|
||||||
// back to the chat bubble — the WeCom CDN URL carries a short-lived
|
// back to the chat bubble — the WeCom CDN URL carries a short-lived
|
||||||
// signature that expires before a browser can fetch it. The shared
|
// signature that expires before a browser can fetch it. The shared
|
||||||
// pipeline owns retry/backoff, magic-byte type detection, and the
|
// pipeline owns retry/backoff, magic-byte type detection, and the
|
||||||
// dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here
|
// 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.
|
// 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;
|
String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint;
|
||||||
return InboundMediaDownloader.download(
|
return InboundMediaDownloader.download(
|
||||||
() -> {
|
() -> {
|
||||||
|
|||||||
@ -148,12 +148,32 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
/** 用于文件 URL 下载的 HttpClient */
|
/** 用于文件 URL 下载的 HttpClient */
|
||||||
private HttpClient uploadHttpClient;
|
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,
|
public WeixinChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
super(channelEntity, messageRouter, 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
|
@Override
|
||||||
public String getChannelType() {
|
public String getChannelType() {
|
||||||
return CHANNEL_TYPE;
|
return CHANNEL_TYPE;
|
||||||
@ -666,7 +686,9 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
return null;
|
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(
|
return InboundMediaDownloader.download(
|
||||||
() -> client.downloadMedia("", aesKey, encryptQueryParam),
|
() -> client.downloadMedia("", aesKey, encryptQueryParam),
|
||||||
filenameHint,
|
filenameHint,
|
||||||
|
|||||||
@ -1,15 +1,27 @@
|
|||||||
package vip.mate.tool.builtin;
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
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
|
* 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>
|
* <p>
|
||||||
* Chat attachments are stored as {@code {timestamp}_{safeFilename}} where
|
* Chat attachments are stored as {@code {timestamp}_{safeFilename}} where
|
||||||
* {@code safeFilename} replaces every non-{@code [a-zA-Z0-9._-]} character with
|
* {@code safeFilename} replaces every non-{@code [a-zA-Z0-9._-]} character with
|
||||||
@ -20,14 +32,40 @@ import java.nio.file.Paths;
|
|||||||
* <p>
|
* <p>
|
||||||
* This helper rescues such calls by matching basenames inside the conversation's
|
* This helper rescues such calls by matching basenames inside the conversation's
|
||||||
* upload directory. Used by both {@link ReadFileTool} and {@link DocumentExtractTool}.
|
* 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
|
@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() {}
|
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
|
* @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()) {
|
if (conversationId == null || conversationId.isBlank()) {
|
||||||
return null;
|
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)) {
|
if (!Files.isDirectory(uploadDir)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String basename;
|
String basename;
|
||||||
try {
|
try {
|
||||||
Path requested = Paths.get(rawPath).getFileName();
|
Path requested = Paths.get(rawPath).getFileName();
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
package vip.mate.tool.image;
|
package vip.mate.tool.image;
|
||||||
|
|
||||||
import cn.hutool.http.HttpUtil;
|
import cn.hutool.http.HttpUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URLDecoder;
|
import java.net.URLDecoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -19,9 +20,10 @@ import java.util.Base64;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class ImageFileDownloader {
|
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 /
|
* Persist an image referenced by either a {@code data:} URL (inline base64 /
|
||||||
@ -38,7 +40,7 @@ public class ImageFileDownloader {
|
|||||||
if (imageUrl == null) {
|
if (imageUrl == null) {
|
||||||
throw new IOException("imageUrl is null");
|
throw new IOException("imageUrl is null");
|
||||||
}
|
}
|
||||||
Path dir = UPLOAD_ROOT.resolve(conversationId);
|
Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId);
|
||||||
Files.createDirectories(dir);
|
Files.createDirectories(dir);
|
||||||
|
|
||||||
if (imageUrl.startsWith("data:")) {
|
if (imageUrl.startsWith("data:")) {
|
||||||
@ -110,7 +112,7 @@ public class ImageFileDownloader {
|
|||||||
* 将 Base64 编码的图片保存到本地
|
* 将 Base64 编码的图片保存到本地
|
||||||
*/
|
*/
|
||||||
public Path saveBase64(String base64Data, String conversationId, String taskId, int index) throws IOException {
|
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);
|
Files.createDirectories(dir);
|
||||||
|
|
||||||
String fileName = "image_" + taskId + "_" + index + ".png";
|
String fileName = "image_" + taskId + "_" + index + ".png";
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
package vip.mate.tool.model3d;
|
package vip.mate.tool.model3d;
|
||||||
|
|
||||||
import cn.hutool.http.HttpUtil;
|
import cn.hutool.http.HttpUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads generated 3D-model assets (.glb / .obj / .fbx) from the provider's
|
* Downloads generated 3D-model assets (.glb / .obj / .fbx) from the provider's
|
||||||
@ -16,13 +17,14 @@ import java.nio.file.Paths;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class Model3dFileDownloader {
|
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,
|
public Path download(String modelUrl, String conversationId, String taskId,
|
||||||
String preferredExtension) throws IOException {
|
String preferredExtension) throws IOException {
|
||||||
Path dir = UPLOAD_ROOT.resolve(conversationId);
|
Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId);
|
||||||
Files.createDirectories(dir);
|
Files.createDirectories(dir);
|
||||||
|
|
||||||
String ext = guessExtension(modelUrl, preferredExtension);
|
String ext = guessExtension(modelUrl, preferredExtension);
|
||||||
|
|||||||
@ -12,12 +12,12 @@ import vip.mate.task.AsyncTaskService;
|
|||||||
import vip.mate.task.model.AsyncTaskEntity;
|
import vip.mate.task.model.AsyncTaskEntity;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
|
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||||
|
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -55,8 +55,8 @@ public class MusicGenerationService {
|
|||||||
* audio as a native attachment. Web-class channels keep using SSE only.
|
* audio as a native attachment. Web-class channels keep using SSE only.
|
||||||
*/
|
*/
|
||||||
private final AsyncTaskMediaDispatcher asyncTaskMediaDispatcher;
|
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";
|
private static final String TASK_TYPE = "music_generation";
|
||||||
|
|
||||||
/** Dedicated virtual-thread worker. Music generation blocks on a single
|
/** Dedicated virtual-thread worker. Music generation blocks on a single
|
||||||
@ -191,7 +191,7 @@ public class MusicGenerationService {
|
|||||||
|
|
||||||
private PersistedAudio persistAudio(String conversationId, String taskId,
|
private PersistedAudio persistAudio(String conversationId, String taskId,
|
||||||
MusicGenerationResult result) throws IOException {
|
MusicGenerationResult result) throws IOException {
|
||||||
Path dir = UPLOAD_ROOT.resolve(conversationId);
|
Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId);
|
||||||
Files.createDirectories(dir);
|
Files.createDirectories(dir);
|
||||||
String fileName = "music_" + taskId + "." + result.getFormat();
|
String fileName = "music_" + taskId + "." + result.getFormat();
|
||||||
Path filePath = dir.resolve(fileName);
|
Path filePath = dir.resolve(fileName);
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
package vip.mate.tool.video;
|
package vip.mate.tool.video;
|
||||||
|
|
||||||
import cn.hutool.http.HttpUtil;
|
import cn.hutool.http.HttpUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 视频文件下载器 — 从 provider CDN 下载视频到本地存储
|
* 视频文件下载器 — 从 provider CDN 下载视频到本地存储
|
||||||
@ -16,9 +17,10 @@ import java.nio.file.Paths;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class VideoFileDownloader {
|
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 本地文件路径
|
* @return 本地文件路径
|
||||||
*/
|
*/
|
||||||
public Path download(String videoUrl, String conversationId, String taskId) throws IOException {
|
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);
|
Files.createDirectories(dir);
|
||||||
|
|
||||||
String extension = guessExtension(videoUrl);
|
String extension = guessExtension(videoUrl);
|
||||||
|
|||||||
@ -6,11 +6,11 @@ import org.springframework.stereotype.Service;
|
|||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import vip.mate.system.model.SystemSettingsDTO;
|
import vip.mate.system.model.SystemSettingsDTO;
|
||||||
import vip.mate.system.service.SystemSettingService;
|
import vip.mate.system.service.SystemSettingService;
|
||||||
|
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@ -28,8 +28,8 @@ public class TtsService {
|
|||||||
private final SystemSettingService systemSettingService;
|
private final SystemSettingService systemSettingService;
|
||||||
private final TtsProviderRegistry providerRegistry;
|
private final TtsProviderRegistry providerRegistry;
|
||||||
private final ChatStreamTracker streamTracker;
|
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;
|
private static final int MAX_TEXT_LENGTH = 4096;
|
||||||
|
|
||||||
/** 用于自动 TTS 的异步线程池 */
|
/** 用于自动 TTS 的异步线程池 */
|
||||||
@ -204,7 +204,7 @@ public class TtsService {
|
|||||||
|
|
||||||
private Path saveAudioFile(String conversationId, String fileId, byte[] data, String format)
|
private Path saveAudioFile(String conversationId, String fileId, byte[] data, String format)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
Path dir = UPLOAD_ROOT.resolve(conversationId);
|
Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId);
|
||||||
Files.createDirectories(dir);
|
Files.createDirectories(dir);
|
||||||
String fileName = "tts_" + fileId + "." + format;
|
String fileName = "tts_" + fileId + "." + format;
|
||||||
Path filePath = dir.resolve(fileName);
|
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.repository.MessageMapper;
|
||||||
import vip.mate.workspace.conversation.vo.ConversationVO;
|
import vip.mate.workspace.conversation.vo.ConversationVO;
|
||||||
import vip.mate.workspace.conversation.vo.MessageVO;
|
import vip.mate.workspace.conversation.vo.MessageVO;
|
||||||
|
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||||
import vip.mate.workspace.core.service.WorkspaceService;
|
import vip.mate.workspace.core.service.WorkspaceService;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.InvalidPathException;
|
import java.nio.file.InvalidPathException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
@ -87,6 +87,14 @@ public class ConversationService {
|
|||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
private final WorkspaceService workspaceService;
|
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
|
* 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
|
* 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;
|
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) {
|
public void cleanAttachmentFiles(String conversationId) {
|
||||||
Path dir;
|
boolean cleanedAny = false;
|
||||||
try {
|
for (Path root : chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
|
||||||
dir = UPLOAD_ROOT.resolve(conversationId);
|
Path dir;
|
||||||
} catch (InvalidPathException e) {
|
try {
|
||||||
// Conversation id contains characters illegal on this filesystem
|
dir = root.resolve(conversationId);
|
||||||
// (e.g. ':' in cron:<jobId> on Windows). No attachments could
|
} catch (InvalidPathException e) {
|
||||||
// ever have been written under such an id on this OS, so there
|
// Conversation id contains characters illegal on this filesystem
|
||||||
// is nothing to clean.
|
// (e.g. ':' in cron:<jobId> on Windows). No attachments could
|
||||||
log.debug("Skipping attachment cleanup for non-path-safe conversation id: {}", conversationId);
|
// ever have been written under such an id on this OS, so there
|
||||||
return;
|
// 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)) {
|
if (cleanedAny) {
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
log.info("Cleaned attachment files for conversation: {}", conversationId);
|
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.
|
# unconstrained behaviour for unconfigured conversations.
|
||||||
enabled: ${MATECLAW_WORKSPACE_SANDBOX_ENABLED:true}
|
enabled: ${MATECLAW_WORKSPACE_SANDBOX_ENABLED:true}
|
||||||
root: ${MATECLAW_WORKSPACE_SANDBOX_ROOT:${user.dir}/data/workspace}
|
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:
|
skill:
|
||||||
workspace:
|
workspace:
|
||||||
# Skill workspace root. Override with MATECLAW_SKILL_WORKSPACE_ROOT to
|
# 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))
|
- Slack: via `filesUploadV2` (see [Slack channel](./channels#slack))
|
||||||
- Channels without `sendContentParts` (QQ, etc.): catch UnsupportedOperationException + log; one unsupported channel doesn't block the rest
|
- 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))
|
- Slack:通过 `filesUploadV2` 直传(参考 [Slack channel](./channels#slack))
|
||||||
- 不支持 `sendContentParts` 的渠道(QQ 等):catch UnsupportedOperationException + log,不让一个不支持的渠道卡住整批分发
|
- 不支持 `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.cards.FeishuCardDispatcher.class),
|
||||||
mock(vip.mate.channel.feishu.FeishuClientFactory.class),
|
mock(vip.mate.channel.feishu.FeishuClientFactory.class),
|
||||||
mock(vip.mate.stt.SttService.class),
|
mock(vip.mate.stt.SttService.class),
|
||||||
election);
|
election,
|
||||||
|
mock(vip.mate.workspace.core.service.ChatUploadLocationResolver.class));
|
||||||
adapter = new TrackingAdapter();
|
adapter = new TrackingAdapter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import org.junit.jupiter.api.AfterEach;
|
|||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import vip.mate.workspace.core.service.ChatUploadLocationResolverTestSupport;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@ -27,12 +28,13 @@ class WebChatFileServiceTest {
|
|||||||
private static final String CONV = "webchat:abcd1234:visitor-1";
|
private static final String CONV = "webchat:abcd1234:visitor-1";
|
||||||
|
|
||||||
private WebChatFileService service(boolean enabled, long maxMb, String exts) {
|
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,
|
private WebChatFileService service(boolean enabled, long maxMb, String exts,
|
||||||
int maxFiles, long maxTotalMb) {
|
int maxFiles, long maxTotalMb) {
|
||||||
return new WebChatFileService(enabled, maxMb, exts, maxFiles, maxTotalMb);
|
return new WebChatFileService(enabled, maxMb, exts, maxFiles, maxTotalMb,
|
||||||
|
ChatUploadLocationResolverTestSupport.legacyDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Tag;
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.workspace.core.service.ChatUploadLocationResolverTestSupport;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@ -38,7 +39,7 @@ class ImageFileDownloaderTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
downloader = new ImageFileDownloader();
|
downloader = new ImageFileDownloader(ChatUploadLocationResolverTestSupport.legacyDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package vip.mate.workspace.conversation;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
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.agent.repository.AgentMapper;
|
||||||
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||||
import vip.mate.workspace.conversation.repository.MessageMapper;
|
import vip.mate.workspace.conversation.repository.MessageMapper;
|
||||||
|
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@ -19,12 +21,15 @@ import java.nio.file.InvalidPathException;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
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
|
* Regression coverage for issue #36: deleting a CRON-task conversation throws
|
||||||
@ -47,11 +52,21 @@ class ConversationServiceCleanAttachmentFilesTest {
|
|||||||
@Mock private MessageMapper messageMapper;
|
@Mock private MessageMapper messageMapper;
|
||||||
@Mock private AgentMapper agentMapper;
|
@Mock private AgentMapper agentMapper;
|
||||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
@Mock private ChatUploadLocationResolver chatUploadLocationResolver;
|
||||||
|
|
||||||
@InjectMocks private ConversationService service;
|
@InjectMocks private ConversationService service;
|
||||||
|
|
||||||
private Path createdDir;
|
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
|
@AfterEach
|
||||||
void cleanup() throws IOException {
|
void cleanup() throws IOException {
|
||||||
if (createdDir != null && Files.exists(createdDir)) {
|
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