sync: v2.0.0 dev + 落盘文件按天分目录 (#554)

- 附件 / 生成媒体写入 {conversationDir}/yyyy-MM-dd/{storedName},serve URL 保持平铺
- 读取路径同时探测平铺与日期两种布局,可随时开关 mateclaw.chat.upload.date-folders
- 读取侧强制 storedName 为纯文件名;原始文件名回退取最近修改的匹配
- 修正 Linux / macOS / Windows 的路径与时间戳精度差异
This commit is contained in:
mateaix 2026-08-01 18:33:24 +08:00
parent bdfa8a6f9d
commit 7fb6ba7461
21 changed files with 542 additions and 111 deletions

View File

@ -1913,11 +1913,11 @@ public class ChannelMessageRouter {
*/
private Path resolveVoiceReplyAudio(String conversationId, String fileName) {
if (chatUploadLocationResolver != null) {
for (Path dir : chatUploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
Path candidate = dir.resolve(fileName);
if (Files.exists(candidate)) {
return candidate;
}
// Probes every candidate root and both layouts (flat + date
// sub-directories), so TTS files written under a per-day dir resolve.
Path found = chatUploadLocationResolver.resolveExistingFile(conversationId, fileName);
if (found != null) {
return found;
}
}
// Fallback to the legacy default dir when the resolver is absent

View File

@ -240,19 +240,6 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
*/
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
@ -1791,11 +1778,13 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
: maybeDownloadResource(messageId, fileKey, type, fileName);
if (dl == null) return null;
// Save under the workspace/agent-aware upload root ({convId}/ subdir).
// Sanitize the id for the path segment IM ids like "feishu:xxx"
// Save under the workspace/agent-aware upload root ({convId}/ subdir,
// plus the per-day sub-directory when date folders are enabled).
// The id is sanitized for the path segment IM ids like "feishu:xxx"
// carry a ':' that is illegal in a Windows filename.
Path uploadDir = chatUploadRootFor(conversationId)
.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId));
Path uploadDir = (chatUploadLocationResolver != null)
? chatUploadLocationResolver.resolveWriteDir(conversationId)
: chatUploadsRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId));
Files.createDirectories(uploadDir);
String rawName = (dl.fileName() != null && !dl.fileName().isBlank())
? dl.fileName() : fileKey;
@ -1889,7 +1878,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L;
List<RecentFileEntry> merged = new java.util.ArrayList<>();
for (Path dir : candidateChatUploadDirs(conversationId)) {
merged.addAll(loadRecentFilesFromDisk(dir, cutoff));
// Scan the flat conversation dir plus each yyyy-MM-dd sub-directory
// so staged copies written under either layout are recovered.
for (Path scanDir : ChatUploadLocationResolver.dateScanDirs(dir)) {
merged.addAll(loadRecentFilesFromDisk(scanDir, cutoff));
}
}
return merged;
}

View File

@ -1144,12 +1144,12 @@ public class ChatController {
String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_");
String storedName = System.currentTimeMillis() + "_" + safeFilename;
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId);
// Sanitize the id before using it as a path segment IM-channel ids like
// "wecom:XXXX" carry a ':' that is illegal in a Windows filename and would
// throw InvalidPathException here. Reads use the same sanitization.
Path conversationDir = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId));
Files.createDirectories(conversationDir);
Path target = conversationDir.resolve(storedName);
// resolveWriteDir sanitizes the id (IM-channel ids like "wecom:XXXX"
// carry a ':' illegal on Windows) and appends the per-day sub-directory
// when date folders are enabled. Reads probe both layouts.
Path writeDir = uploadLocationResolver.resolveWriteDir(conversationId);
Files.createDirectories(writeDir);
Path target = writeDir.resolve(storedName);
file.transferTo(target);
log.info("Chat attachment uploaded: conversationId={}, user={}, file={}", conversationId, username, target);
@ -1160,7 +1160,7 @@ public class ChatController {
response.setStoredName(storedName);
response.setUrl("/api/v1/chat/files/" + conversationId + "/" + storedName);
// root 相对路径避免暴露服务端绝对路径uploadRoot 现在恒为绝对路径
response.setPath(toRelativeUploadPath(uploadRoot, conversationId, storedName));
response.setPath(toRelativeUploadPath(uploadRoot, target));
response.setSize(file.getSize());
response.setContentType(file.getContentType());
return R.ok(response);
@ -1248,18 +1248,12 @@ public class ChatController {
/**
* Resolve an uploaded attachment to its on-disk path, probing every
* candidate conversation dir (workspace-scoped + legacy default, sanitized +
* raw id) with a per-candidate path-traversal guard. Returns {@code null}
* when no candidate holds the file.
* raw id) and both layouts (flat + date sub-directories) with a
* path-traversal guard. Returns {@code null} when no candidate holds the
* file.
*/
private Path resolveUploadedFile(String conversationId, String storedName) {
for (Path conversationDir : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
Path normDir = conversationDir.normalize();
Path candidate = normDir.resolve(storedName).normalize();
if (Files.exists(candidate) && candidate.startsWith(normDir)) {
return candidate;
}
}
return null;
return uploadLocationResolver.resolveExistingFile(conversationId, storedName);
}
/**
@ -1662,8 +1656,7 @@ public class ChatController {
* upload sub-directory name is preserved (e.g. {@code chat-uploads/...}), and
* separators are normalized to {@code /} so the value is stable across OSes.
*/
static String toRelativeUploadPath(Path uploadRoot, String conversationId, String storedName) {
Path target = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)).resolve(storedName);
static String toRelativeUploadPath(Path uploadRoot, Path target) {
Path base = uploadRoot.getParent();
Path relative = base != null ? base.relativize(target) : target;
return relative.toString().replace('\\', '/');

View File

@ -120,15 +120,19 @@ public class WebChatFileService {
String storedName = UUID.randomUUID() + "_" + safeName;
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize();
// Sanitize the id for the path segment (IM ids like "wecom:XXXX" carry a
// ':' illegal on Windows); reads use the same sanitization.
Path dir = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)).normalize();
// resolveWriteDir sanitizes the id for the path segment (IM ids like
// "wecom:XXXX" carry a ':' illegal on Windows) and appends the per-day
// sub-directory when date folders are enabled.
Path dir = uploadLocationResolver.resolveWriteDir(conversationId).normalize();
if (!dir.startsWith(uploadRoot)) {
// conversationId is server-derived, so this should never happen; fail closed if it does.
throw new UploadRejectedException("Invalid conversation");
}
Files.createDirectories(dir);
enforceConversationQuota(dir, file.getSize());
// Quota counts the whole conversation tree (flat files + date subdirs),
// not just today's write dir.
enforceConversationQuota(
uploadLocationResolver.resolveConversationDir(conversationId).normalize(), file.getSize());
Path target = dir.resolve(storedName);
file.transferTo(target.toAbsolutePath());
@ -169,19 +173,10 @@ public class WebChatFileService {
* {@code startsWith} guard. Returns empty if missing or escaping the dir.
*/
public Optional<Path> resolve(String conversationId, String storedName) {
if (storedName == null || storedName.isBlank()) {
return Optional.empty();
}
// Check every candidate root (workspace-scoped dir + legacy default dir)
// so files written before the workspace-aware relocation still resolve.
for (Path base : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
Path normBase = base.normalize();
Path file = normBase.resolve(storedName).normalize();
if (file.startsWith(normBase) && Files.exists(file) && Files.isRegularFile(file)) {
return Optional.of(file);
}
}
return Optional.empty();
// resolveExistingFile checks every candidate root (workspace-scoped dir
// + legacy default dir) and both layouts (flat + date sub-directories),
// guarding each candidate against traversal.
return Optional.ofNullable(uploadLocationResolver.resolveExistingFile(conversationId, storedName));
}
/** Map a content type to the MessageContentPart type the agent/UI understands. */
@ -216,15 +211,18 @@ public class WebChatFileService {
}
/**
* Bound a conversation's disk footprint: reject when the dir already holds
* the max file count, or when adding {@code incomingSize} would push the
* total over the cap. Cheap dir scan (these dirs hold at most a few dozen
* files); pairs with the staging TTL sweep that reclaims unreferenced files.
* Bound a conversation's disk footprint: reject when the conversation tree
* already holds the max file count, or when adding {@code incomingSize}
* would push the total over the cap. Walks the tree so files under date
* sub-directories are counted; cheap scan (these dirs hold at most a few
* dozen files), pairs with the staging TTL sweep that reclaims
* unreferenced files.
*/
private void enforceConversationQuota(Path dir, long incomingSize) throws IOException {
private void enforceConversationQuota(Path conversationDir, long incomingSize) throws IOException {
int count = 0;
long total = 0;
try (Stream<Path> files = Files.list(dir)) {
if (Files.isDirectory(conversationDir)) {
try (Stream<Path> files = Files.walk(conversationDir)) {
for (Path p : (Iterable<Path>) files::iterator) {
if (Files.isRegularFile(p)) {
count++;
@ -232,6 +230,7 @@ public class WebChatFileService {
}
}
}
}
if (count >= maxFilesPerConversation) {
throw new UploadRejectedException(
"Too many files in this conversation (max " + maxFilesPerConversation + ")");

View File

@ -3403,7 +3403,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
// dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here
// inside the byte source so a fetch + decrypt is retried as one unit.
Path uploadDir = (chatUploadLocationResolver != null)
? chatUploadLocationResolver.resolveConversationDir(conversationId)
? chatUploadLocationResolver.resolveWriteDir(conversationId)
: Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId));
String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint;
return InboundMediaDownloader.download(

View File

@ -695,7 +695,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
}
Path uploadDir = (chatUploadLocationResolver != null)
? chatUploadLocationResolver.resolveConversationDir(conversationId)
? chatUploadLocationResolver.resolveWriteDir(conversationId)
: Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId));
return InboundMediaDownloader.download(
() -> client.downloadMedia("", aesKey, encryptQueryParam),

View File

@ -8,6 +8,7 @@ import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
@ -128,35 +129,86 @@ public final class ChatUploadResolver {
if (!Files.isDirectory(uploadDir)) {
return null;
}
String basename;
try {
Path requested = Paths.get(rawPath).getFileName();
basename = requested != null ? requested.toString() : null;
} catch (Exception e) {
return null;
}
String basename = basenameOf(rawPath);
if (basename == null || basename.isBlank()) {
return null;
}
Path direct = uploadDir.resolve(basename);
// Attachments may live flat in the conversation dir (legacy layout /
// date-folders off) or under a yyyy-MM-dd sub-directory; scan both.
List<Path> scanDirs = ChatUploadLocationResolver.dateScanDirs(uploadDir);
// An exact stored-name match is unambiguous, so it wins wherever it sits.
for (Path scanDir : scanDirs) {
Path direct = scanDir.resolve(basename);
if (Files.isRegularFile(direct)) {
return direct;
}
}
return newestSuffixMatch(scanDirs, basename);
}
// Stored as "{millis}_{safeFilename}" where safeFilename replaces non-ASCII
// characters with underscores; match by sanitized basename suffix.
String safeBasename = basename.replaceAll("[^a-zA-Z0-9._-]", "_");
String suffix = "_" + safeBasename;
try (var stream = Files.list(uploadDir)) {
return stream
.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(suffix))
.findFirst()
.orElse(null);
} catch (IOException e) {
log.warn("[ChatUploadResolver] Failed to scan chat-upload dir {}: {}", uploadDir, e.getMessage());
/**
* Last segment of a model-supplied path. The separator is not the running
* OS's: a model asked about an attachment routinely answers with a
* hallucinated {@code /app/report.pdf} or {@code C:\Users\me\report.pdf}
* regardless of the server platform, and on Linux a backslash is a legal
* file-name character, so {@code Path.getFileName()} alone would hand back
* the whole Windows-style string. Split on both separators.
*/
private static String basenameOf(String rawPath) {
String candidate;
try {
Path requested = Paths.get(rawPath).getFileName();
candidate = requested != null ? requested.toString() : null;
} catch (Exception e) {
return null;
}
if (candidate == null) {
return null;
}
int backslash = candidate.lastIndexOf('\\');
return backslash >= 0 ? candidate.substring(backslash + 1) : candidate;
}
/**
* Fallback for when the model passes the original filename instead of the
* stored name: attachments are stored as {@code {millis}_{safeFilename}}
* with non-ASCII characters replaced by underscores, so match by sanitized
* basename suffix. The most recently modified match across every scan dir
* wins re-uploading the same filename must resolve to today's copy, not
* to a same-named one left in the flat dir or an earlier day's dir.
* <p>
* Equal timestamps are broken by file name so the pick stays deterministic
* on filesystems with coarse modification-time resolution (HFS+ stores
* whole seconds, FAT two): stored names are {@code {millis}_{name}}, so the
* lexicographically greater name is the later write.
*/
private static Path newestSuffixMatch(List<Path> scanDirs, String basename) {
String suffix = "_" + basename.replaceAll("[^a-zA-Z0-9._-]", "_");
Path newest = null;
FileTime newestTime = null;
for (Path scanDir : scanDirs) {
if (!Files.isDirectory(scanDir)) {
continue;
}
try (var stream = Files.list(scanDir)) {
for (Path p : (Iterable<Path>) stream.filter(Files::isRegularFile)
.filter(f -> f.getFileName().toString().endsWith(suffix))::iterator) {
FileTime modified = Files.getLastModifiedTime(p);
int cmp = (newestTime == null) ? 1 : modified.compareTo(newestTime);
if (cmp == 0) {
cmp = p.getFileName().toString().compareTo(newest.getFileName().toString());
}
if (cmp > 0) {
newest = p;
newestTime = modified;
}
}
} catch (IOException e) {
log.warn("[ChatUploadResolver] Failed to scan chat-upload dir {}: {}", scanDir, e.getMessage());
}
}
return newest;
}
}

View File

@ -40,7 +40,7 @@ public class ImageFileDownloader {
if (imageUrl == null) {
throw new IOException("imageUrl is null");
}
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Path dir = uploadLocationResolver.resolveWriteDir(conversationId);
Files.createDirectories(dir);
if (imageUrl.startsWith("data:")) {
@ -112,7 +112,7 @@ public class ImageFileDownloader {
* Base64 编码的图片保存到本地
*/
public Path saveBase64(String base64Data, String conversationId, String taskId, int index) throws IOException {
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Path dir = uploadLocationResolver.resolveWriteDir(conversationId);
Files.createDirectories(dir);
String fileName = "image_" + taskId + "_" + index + ".png";

View File

@ -24,7 +24,7 @@ public class Model3dFileDownloader {
public Path download(String modelUrl, String conversationId, String taskId,
String preferredExtension) throws IOException {
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Path dir = uploadLocationResolver.resolveWriteDir(conversationId);
Files.createDirectories(dir);
String ext = guessExtension(modelUrl, preferredExtension);

View File

@ -191,7 +191,7 @@ public class MusicGenerationService {
private PersistedAudio persistAudio(String conversationId, String taskId,
MusicGenerationResult result) throws IOException {
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Path dir = uploadLocationResolver.resolveWriteDir(conversationId);
Files.createDirectories(dir);
String fileName = "music_" + taskId + "." + result.getFormat();
Path filePath = dir.resolve(fileName);

View File

@ -31,7 +31,7 @@ public class VideoFileDownloader {
* @return 本地文件路径
*/
public Path download(String videoUrl, String conversationId, String taskId) throws IOException {
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Path dir = uploadLocationResolver.resolveWriteDir(conversationId);
Files.createDirectories(dir);
String extension = guessExtension(videoUrl);

View File

@ -204,7 +204,7 @@ public class TtsService {
private Path saveAudioFile(String conversationId, String fileId, byte[] data, String format)
throws IOException {
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Path dir = uploadLocationResolver.resolveWriteDir(conversationId);
Files.createDirectories(dir);
String fileName = "tts_" + fileId + "." + format;
Path filePath = dir.resolve(fileName);

View File

@ -29,7 +29,26 @@ 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}}.
* one level below: {@code {baseDir}/{conversationId}/} flat, or with a
* date level when {@link #dateFolders} is enabled.
*/
private String baseDir = "data/chat-uploads";
/**
* When {@code true} (the default), new attachments and generated media are
* written under a per-day sub-directory:
* {@code {conversationDir}/yyyy-MM-dd/{storedName}}. Long-lived
* conversations (IM channels keep one conversation per chat indefinitely)
* otherwise accumulate thousands of files in a single flat directory.
* <p>
* Serving URLs stay flat ({@code /api/v1/chat/files/{convId}/{storedName}});
* every read path probes the flat directory first and then each date
* sub-directory, so files written under either layout remain resolvable and
* the flag can be toggled at any time without migration.
* <p>
* The day comes from the server's local date, so a container running in UTC
* groups files by UTC days. Reads never depend on it they scan every date
* directory so a timezone change only affects where the next write lands.
*/
private boolean dateFolders = true;
}

View File

@ -17,14 +17,19 @@ import vip.mate.workspace.core.model.WorkspaceEntity;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Resolves the on-disk root directory where a conversation's chat attachments
@ -50,6 +55,13 @@ import java.util.Set;
* resolvable and cleanable. Writes always target a single root returned by
* {@link #resolveUploadRoot(String)}.
*
* <h3>Date folders</h3>
* When {@link ChatUploadProperties#isDateFolders()} is on (default), writes go
* through {@link #resolveWriteDir(String)} which appends a {@code yyyy-MM-dd}
* level under the conversation dir. Serving URLs stay flat; read paths probe
* both layouts via {@link #findInConversationDir(Path, String)} /
* {@link #dateScanDirs(Path)}.
*
* <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.
@ -63,6 +75,14 @@ public class ChatUploadLocationResolver {
/** Sub-directory appended under a configured base path. */
public static final String UPLOAD_SUBDIR = "chat-uploads";
/**
* Shape of the per-day sub-directory name inserted under a conversation dir
* when {@link ChatUploadProperties#isDateFolders()} is on. Anchored so read
* paths can distinguish date levels from ordinary sub-directories (e.g. the
* office-preview cache dir) when probing.
*/
private static final Pattern DATE_DIR = Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
/**
* Turn a business conversation id into a filesystem-safe path segment.
* <p>
@ -87,13 +107,111 @@ public class ChatUploadLocationResolver {
}
/**
* The single conversation attachment directory (write target):
* {@code {uploadRoot}/{sanitizeSegment(conversationId)}/}.
* The conversation's attachment root directory:
* {@code {uploadRoot}/{sanitizeSegment(conversationId)}/}. This is the
* <em>root</em> of the conversation's files writers must go through
* {@link #resolveWriteDir(String)} instead, which appends the per-day
* sub-directory when date folders are enabled.
*/
public Path resolveConversationDir(String conversationId) {
return resolveUploadRoot(conversationId).resolve(sanitizeSegment(conversationId));
}
/**
* The directory new attachments and generated media must be written into:
* {@code {conversationDir}/yyyy-MM-dd/} when date folders are enabled, else
* the flat {@code {conversationDir}/}. The directory is not created here
* callers keep their existing {@code Files.createDirectories(dir)}.
*/
public Path resolveWriteDir(String conversationId) {
Path conversationDir = resolveConversationDir(conversationId);
return properties.isDateFolders()
? conversationDir.resolve(LocalDate.now().toString())
: conversationDir;
}
/**
* Directories a read path must scan to see every file of a conversation
* dir: the flat dir itself first (legacy layout + date-folders-off writes),
* then each {@code yyyy-MM-dd} sub-directory, newest first. Static so the
* tool-side resolver (no Spring context) can share the exact probing rule.
*
* @param conversationDir a single conversation attachment root
* @return ordered scan list; just {@code [conversationDir]} when it has no
* date sub-directories (or doesn't exist)
*/
public static List<Path> dateScanDirs(Path conversationDir) {
List<Path> dirs = new ArrayList<>();
dirs.add(conversationDir);
if (!Files.isDirectory(conversationDir)) {
return dirs;
}
try (var stream = Files.list(conversationDir)) {
stream.filter(Files::isDirectory)
.filter(p -> DATE_DIR.matcher(p.getFileName().toString()).matches())
.sorted(Comparator.comparing((Path p) -> p.getFileName().toString()).reversed())
.forEach(dirs::add);
} catch (IOException e) {
log.warn("[ChatUpload] Failed to list date sub-directories of {}: {}",
conversationDir, e.getMessage());
}
return dirs;
}
/**
* Traversal-guarded lookup of a stored file under one conversation dir,
* probing the flat layout first and then each date sub-directory (newest
* first). {@code storedName} must be a bare file name every write path
* produces one, and anything carrying a path separator, a root, or
* {@code ..} is rejected outright rather than normalized, so no probe can
* leave the scan dir it was resolved against. The root check matters on
* Windows: {@code Path.resolve} discards the base for a rooted argument, so
* a drive-relative name like {@code C:evil.txt} would otherwise escape (it
* is not {@code isAbsolute()}). The {@code startsWith} assertion after
* resolution keeps the guarantee platform-independent. Returns {@code null}
* when absent or rejected.
*/
public static Path findInConversationDir(Path conversationDir, String storedName) {
if (storedName == null || storedName.isBlank()) {
return null;
}
Path fileName;
try {
fileName = Paths.get(storedName);
} catch (InvalidPathException e) {
// Illegal on this filesystem (e.g. '*' or ':' on Windows) no
// stored file could carry that name here.
return null;
}
if (fileName.getRoot() != null || fileName.getNameCount() != 1 || "..".equals(storedName)) {
return null;
}
Path normDir = conversationDir.normalize();
for (Path scanDir : dateScanDirs(normDir)) {
Path candidate = scanDir.resolve(fileName);
if (candidate.startsWith(scanDir) && Files.isRegularFile(candidate)) {
return candidate;
}
}
return null;
}
/**
* Resolve a stored file across every candidate conversation dir
* (workspace-scoped + legacy default, sanitized + raw id) and both layouts
* (flat + date sub-directories). The single entry point for serving /
* download paths; returns {@code null} when no candidate holds the file.
*/
public Path resolveExistingFile(String conversationId, String storedName) {
for (Path conversationDir : resolveCandidateConversationDirs(conversationId)) {
Path found = findInConversationDir(conversationDir, storedName);
if (found != null) {
return found;
}
}
return null;
}
/**
* Every conversation attachment directory a read / cleanup path should probe,
* ordered: the sanitized dir under each candidate root first, then for

View File

@ -198,6 +198,12 @@ mateclaw:
# 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}
# Organize new attachments/generated media into per-day sub-directories:
# {convDir}/yyyy-MM-dd/{storedName}. Serving URLs stay flat and reads
# probe both layouts, so this can be toggled at any time; files written
# under the previous layout remain resolvable either way. The day is the
# server's local date (a UTC container groups by UTC days).
date-folders: ${MATECLAW_CHAT_UPLOAD_DATE_FOLDERS:true}
skill:
upload:
# Size caps for skill bundle ZIPs (upload endpoint and marketplace

View File

@ -212,7 +212,7 @@ Fix: `AsyncTaskMediaDispatcher.forwardToImIfBound(conversationId, parts)`:
- Slack: via `filesUploadV2` (see [Slack channel](./channels#slack))
- Channels without `sendContentParts` (QQ, etc.): catch UnsupportedOperationException + log; one unsupported channel doesn't block the rest
Files live at `data/chat-uploads/{conversationId}/` 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.
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`). Inside the conversation dir, new files are further grouped into per-day sub-directories by default (`{conversationId}/yyyy-MM-dd/{storedName}`, controlled by `mateclaw.chat.upload.date-folders`; disable to keep the flat layout). Reads and cleanup probe both the new and legacy locations and both layouts (flat + date sub-directories), so pre-migration attachments stay accessible. Served at `/api/v1/chat/files/{conversationId}/{storedName}` — the URL stays flat with no date segment; frontend and channel attachment views all read by this URL.
---

View File

@ -212,7 +212,7 @@ MateClaw 在 link 分支检测到 `mp.weixin.qq.com` 后,会自动给模型追
- Slack通过 `filesUploadV2` 直传(参考 [Slack channel](./channels#slack)
- 不支持 `sendContentParts` 的渠道QQ 等catch UnsupportedOperationException + log不让一个不支持的渠道卡住整批分发
文件路径默认在 `data/chat-uploads/{conversationId}/`,但当会话的 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 读。
文件路径默认在 `data/chat-uploads/{conversationId}/`,但当会话的 Agent / Workspace 配置了 `basePath` 时,附件落在 `{basePath}/chat-uploads/{conversationId}/`解析优先级Agent `workspaceBasePath` → Workspace `basePath` → 默认目录 `mateclaw.chat.upload.base-dir`)。会话目录下默认再按天分文件夹(`{conversationId}/yyyy-MM-dd/{storedName}`,由 `mateclaw.chat.upload.date-folders` 控制,可关闭回平铺布局)。读取与清理会同时探测新旧位置及平铺 / 日期两种布局迁移前的旧附件仍可访问。serve URL 是 `/api/v1/chat/files/{conversationId}/{storedName}`(保持平铺、不含日期段),前端 / 渠道附件视图都按这个 URL 读。
---

View File

@ -100,7 +100,7 @@ class ChatControllerPreviewRouteTest {
when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true);
when(officePreviewService.isConvertible(STORED)).thenReturn(true);
when(officePreviewService.isAvailable()).thenReturn(true);
when(uploadLocationResolver.resolveCandidateConversationDirs(CONV)).thenReturn(List.of());
when(uploadLocationResolver.resolveExistingFile(CONV, STORED)).thenReturn(null);
mockMvc.perform(MockMvcRequestBuilders.get(URL).principal(admin()))
.andExpect(status().isNotFound());
}
@ -115,7 +115,7 @@ class ChatControllerPreviewRouteTest {
when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true);
when(officePreviewService.isConvertible(STORED)).thenReturn(true);
when(officePreviewService.isAvailable()).thenReturn(true);
when(uploadLocationResolver.resolveCandidateConversationDirs(CONV)).thenReturn(List.of(dir));
when(uploadLocationResolver.resolveExistingFile(CONV, STORED)).thenReturn(src);
byte[] pdf = "%PDF-1.4 fake".getBytes();
when(officePreviewService.renderPdf(any(Path.class))).thenReturn(pdf);

View File

@ -16,7 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* root became absolute (the resolver normalizes via {@code toAbsolutePath()}),
* the {@code path} field which is rendered into the LLM prompt and returned to
* the client started leaking the server's absolute filesystem layout. These
* tests lock the value back to {@code chat-uploads/{convId}/{storedName}}.
* tests lock the value to {@code chat-uploads/{convId}/[{date}/]{storedName}}.
*/
class ChatControllerUploadPathTest {
@ -25,21 +25,35 @@ class ChatControllerUploadPathTest {
void defaultRootIsRelative() {
// Mirrors the resolver's default root: absolute + normalized.
Path uploadRoot = Paths.get("data", "chat-uploads").toAbsolutePath().normalize();
Path target = uploadRoot.resolve("conv-1").resolve("1777_a.txt");
String path = ChatController.toRelativeUploadPath(uploadRoot, "conv-1", "1777_a.txt");
String path = ChatController.toRelativeUploadPath(uploadRoot, target);
assertThat(path).isEqualTo("chat-uploads/conv-1/1777_a.txt");
assertThat(Paths.get(path).isAbsolute()).isFalse();
assertThat(path).doesNotContain(uploadRoot.toString());
}
@Test
@DisplayName("date-folder target: date segment is preserved in the relative path")
void dateFolderTargetKeepsDateSegment() {
Path uploadRoot = Paths.get("data", "chat-uploads").toAbsolutePath().normalize();
Path target = uploadRoot.resolve("conv-1").resolve("2026-07-26").resolve("1777_a.txt");
String path = ChatController.toRelativeUploadPath(uploadRoot, target);
assertThat(path).isEqualTo("chat-uploads/conv-1/2026-07-26/1777_a.txt");
assertThat(Paths.get(path).isAbsolute()).isFalse();
}
@Test
@DisplayName("workspace-scoped absolute root: still root-relative, no leak")
void scopedRootIsRelative() {
// An absolute workspace basePath somewhere outside the CWD.
Path uploadRoot = Paths.get("/srv/ws/alpha/chat-uploads").toAbsolutePath().normalize();
Path target = uploadRoot.resolve("conv-2").resolve("9_b.pdf");
String path = ChatController.toRelativeUploadPath(uploadRoot, "conv-2", "9_b.pdf");
String path = ChatController.toRelativeUploadPath(uploadRoot, target);
assertThat(path).isEqualTo("chat-uploads/conv-2/9_b.pdf");
assertThat(path).doesNotContain("/srv/ws/alpha");
@ -49,8 +63,9 @@ class ChatControllerUploadPathTest {
@DisplayName("custom base-dir name is preserved (not hardcoded to chat-uploads)")
void customBaseDirNamePreserved() {
Path uploadRoot = Paths.get("/var/uploads").toAbsolutePath().normalize();
Path target = uploadRoot.resolve("conv-3").resolve("f.bin");
String path = ChatController.toRelativeUploadPath(uploadRoot, "conv-3", "f.bin");
String path = ChatController.toRelativeUploadPath(uploadRoot, target);
assertThat(path).isEqualTo("uploads/conv-3/f.bin");
}

View File

@ -0,0 +1,110 @@
package vip.mate.tool.builtin;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies the tool-side attachment resolver finds files under both the flat
* conversation-dir layout and the per-day ({@code yyyy-MM-dd}) sub-directory
* layout, including the sanitized-basename suffix fallback used when the LLM
* passes the original (non-ASCII) filename instead of the stored name.
*/
class ChatUploadResolverDateFolderTest {
@TempDir
Path tempDir;
@AfterEach
void clearContext() {
ToolExecutionContext.clear();
}
/** Point the resolver at {@code tempDir} as the workspace base path. */
private Path conversationDir(String conversationId) throws Exception {
ToolExecutionContext.set(conversationId, "tester", tempDir.toString());
Path dir = tempDir.resolve("chat-uploads").resolve(conversationId);
Files.createDirectories(dir);
return dir;
}
@Test
@DisplayName("flat layout: direct basename match still resolves")
void resolvesFlatFile() throws Exception {
Path convDir = conversationDir("conv-flat");
Files.writeString(convDir.resolve("1777_report.pdf"), "x");
assertThat(ChatUploadResolver.resolve("1777_report.pdf"))
.isEqualTo(convDir.resolve("1777_report.pdf"));
}
@Test
@DisplayName("date layout: file under yyyy-MM-dd resolves by stored name")
void resolvesDatedFile() throws Exception {
Path convDir = conversationDir("conv-dated");
Path dateDir = convDir.resolve("2026-07-26");
Files.createDirectories(dateDir);
Files.writeString(dateDir.resolve("1777_report.pdf"), "x");
assertThat(ChatUploadResolver.resolve("1777_report.pdf"))
.isEqualTo(dateDir.resolve("1777_report.pdf"));
}
@Test
@DisplayName("date layout: sanitized-suffix fallback matches original filename")
void resolvesDatedFileBySuffixFallback() throws Exception {
Path convDir = conversationDir("conv-suffix");
Path dateDir = convDir.resolve("2026-07-26");
Files.createDirectories(dateDir);
// Stored as "{millis}_{sanitized}": non-ASCII chars become underscores.
Files.writeString(dateDir.resolve("1777391026594_____.docx"), "x");
assertThat(ChatUploadResolver.resolve("人人有虾.docx"))
.isEqualTo(dateDir.resolve("1777391026594_____.docx"));
}
@Test
@DisplayName("suffix fallback: the newest same-named copy wins over a stale flat one")
void suffixFallbackPrefersNewestCopy() throws Exception {
Path convDir = conversationDir("conv-newest");
Path dateDir = convDir.resolve("2026-07-26");
Files.createDirectories(dateDir);
Path stale = convDir.resolve("1777000000000_report.docx");
Path fresh = dateDir.resolve("1777391026594_report.docx");
Files.writeString(stale, "old");
Files.writeString(fresh, "new");
Files.setLastModifiedTime(stale, FileTime.fromMillis(1_777_000_000_000L));
Files.setLastModifiedTime(fresh, FileTime.fromMillis(1_777_391_026_594L));
assertThat(ChatUploadResolver.resolve("report.docx")).isEqualTo(fresh);
}
@Test
@DisplayName("a Windows-style path from the model still resolves on a POSIX host")
void resolvesWindowsStylePathOnPosixHost() throws Exception {
Path convDir = conversationDir("conv-winpath");
Path dateDir = convDir.resolve("2026-07-26");
Files.createDirectories(dateDir);
Files.writeString(dateDir.resolve("1777391026594_report.pdf"), "x");
// A backslash is a legal file-name character on Linux/macOS, so the
// basename has to be split on both separators, not just the host's.
assertThat(ChatUploadResolver.resolve("C:\\Users\\me\\report.pdf"))
.isEqualTo(dateDir.resolve("1777391026594_report.pdf"));
}
@Test
@DisplayName("missing file resolves to null in either layout")
void missingFileIsNull() throws Exception {
conversationDir("conv-missing");
assertThat(ChatUploadResolver.resolve("nope.pdf")).isNull();
}
}

View File

@ -11,7 +11,9 @@ 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.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@ -264,4 +266,128 @@ class ChatUploadLocationResolverTest {
assertThat(dirs).containsExactly(tempDir.toAbsolutePath().normalize().resolve("plainconv"));
}
// ==================== date folders ====================
private ChatUploadLocationResolver resolver(Path defaultDir, boolean dateFolders) {
ChatUploadProperties props = new ChatUploadProperties();
props.setBaseDir(defaultDir.toAbsolutePath().toString());
props.setDateFolders(dateFolders);
return new ChatUploadLocationResolver(conversationMapper, workspaceService, props, agentService);
}
@Test
@DisplayName("resolveWriteDir: date folders on → {convDir}/{yyyy-MM-dd}")
void writeDirAppendsDateSegmentWhenEnabled() {
stubConversation("c-date", null, null);
when(workspaceService.getById(anyLong())).thenReturn(workspace(1L, null));
ChatUploadLocationResolver r = resolver(tempDir, true);
Path dir = r.resolveWriteDir("c-date");
assertThat(dir).isEqualTo(tempDir.toAbsolutePath().normalize()
.resolve("c-date")
.resolve(LocalDate.now().toString()));
}
@Test
@DisplayName("resolveWriteDir: date folders off → flat conversation dir")
void writeDirIsFlatWhenDisabled() {
stubConversation("c-flat", null, null);
when(workspaceService.getById(anyLong())).thenReturn(workspace(1L, null));
ChatUploadLocationResolver r = resolver(tempDir, false);
Path dir = r.resolveWriteDir("c-flat");
assertThat(dir).isEqualTo(tempDir.toAbsolutePath().normalize().resolve("c-flat"));
}
@Test
@DisplayName("dateScanDirs: flat dir first, then date subdirs newest-first; non-date subdirs ignored")
void dateScanDirsOrderedNewestFirst() throws Exception {
Path convDir = tempDir.resolve("c-scan");
Files.createDirectories(convDir.resolve("2026-07-25"));
Files.createDirectories(convDir.resolve("2026-07-26"));
Files.createDirectories(convDir.resolve("preview"));
List<Path> dirs = ChatUploadLocationResolver.dateScanDirs(convDir);
assertThat(dirs).containsExactly(
convDir,
convDir.resolve("2026-07-26"),
convDir.resolve("2026-07-25"));
}
@Test
@DisplayName("findInConversationDir: resolves flat legacy files and date-subdir files")
void findInConversationDirProbesBothLayouts() throws Exception {
Path convDir = tempDir.resolve("c-find");
Files.createDirectories(convDir.resolve("2026-07-26"));
Files.writeString(convDir.resolve("flat.txt"), "legacy");
Files.writeString(convDir.resolve("2026-07-26").resolve("dated.txt"), "new");
assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "flat.txt"))
.isEqualTo(convDir.resolve("flat.txt"));
assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "dated.txt"))
.isEqualTo(convDir.resolve("2026-07-26").resolve("dated.txt"));
assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "missing.txt"))
.isNull();
}
@Test
@DisplayName("findInConversationDir: traversal escaping the conversation dir is rejected")
void findInConversationDirRejectsTraversal() throws Exception {
Path convDir = tempDir.resolve("c-guard");
Files.createDirectories(convDir);
Files.writeString(tempDir.resolve("outside.txt"), "secret");
assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "../outside.txt"))
.isNull();
}
@Test
@DisplayName("findInConversationDir: a date-subdir probe cannot climb back into the conversation root")
void findInConversationDirRejectsClimbOutOfDateDir() throws Exception {
Path convDir = tempDir.resolve("c-climb");
Files.createDirectories(convDir.resolve("2026-07-26"));
Files.writeString(convDir.resolve("flat.txt"), "legacy");
assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "2026-07-26/../flat.txt"))
.isNull();
}
@Test
@DisplayName("findInConversationDir: rooted or multi-segment stored names are rejected outright")
void findInConversationDirRejectsNonBareNames() throws Exception {
Path convDir = tempDir.resolve("c-bare");
Files.createDirectories(convDir.resolve("2026-07-26"));
Files.writeString(convDir.resolve("flat.txt"), "legacy");
// Rooted names matter on Windows, where Path.resolve drops the base for
// a rooted argument; rejecting them keeps the guard platform-agnostic.
assertThat(ChatUploadLocationResolver.findInConversationDir(
convDir, tempDir.toAbsolutePath() + "/flat.txt")).isNull();
assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "2026-07-26/flat.txt"))
.isNull();
assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "..")).isNull();
// The bare name still resolves.
assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "flat.txt"))
.isEqualTo(convDir.resolve("flat.txt"));
}
@Test
@DisplayName("resolveExistingFile: end-to-end lookup across candidate dirs and layouts")
void resolveExistingFileFindsDatedFile() throws Exception {
stubConversation("c-e2e", null, null);
when(workspaceService.getById(anyLong())).thenReturn(workspace(1L, null));
ChatUploadLocationResolver r = resolver(tempDir, true);
Path writeDir = r.resolveWriteDir("c-e2e");
Files.createDirectories(writeDir);
Files.writeString(writeDir.resolve("1777_a.png"), "img");
assertThat(r.resolveExistingFile("c-e2e", "1777_a.png"))
.isEqualTo(writeDir.resolve("1777_a.png"));
assertThat(r.resolveExistingFile("c-e2e", "nope.png")).isNull();
}
}