mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
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:
parent
bdfa8a6f9d
commit
7fb6ba7461
@ -1913,11 +1913,11 @@ public class ChannelMessageRouter {
|
|||||||
*/
|
*/
|
||||||
private Path resolveVoiceReplyAudio(String conversationId, String fileName) {
|
private Path resolveVoiceReplyAudio(String conversationId, String fileName) {
|
||||||
if (chatUploadLocationResolver != null) {
|
if (chatUploadLocationResolver != null) {
|
||||||
for (Path dir : chatUploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
|
// Probes every candidate root and both layouts (flat + date
|
||||||
Path candidate = dir.resolve(fileName);
|
// sub-directories), so TTS files written under a per-day dir resolve.
|
||||||
if (Files.exists(candidate)) {
|
Path found = chatUploadLocationResolver.resolveExistingFile(conversationId, fileName);
|
||||||
return candidate;
|
if (found != null) {
|
||||||
}
|
return found;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback to the legacy default dir when the resolver is absent
|
// Fallback to the legacy default dir when the resolver is absent
|
||||||
|
|||||||
@ -240,19 +240,6 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
*/
|
*/
|
||||||
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
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
|
* Ordered candidate upload roots for a conversation: workspace-scoped first
|
||||||
* (when the resolver is wired), then the legacy field. Used by read/scan
|
* (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);
|
: maybeDownloadResource(messageId, fileKey, type, fileName);
|
||||||
if (dl == null) return null;
|
if (dl == null) return null;
|
||||||
|
|
||||||
// Save under the workspace/agent-aware upload root ({convId}/ subdir).
|
// Save under the workspace/agent-aware upload root ({convId}/ subdir,
|
||||||
// Sanitize the id for the path segment — IM ids like "feishu:xxx"
|
// 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.
|
// carry a ':' that is illegal in a Windows filename.
|
||||||
Path uploadDir = chatUploadRootFor(conversationId)
|
Path uploadDir = (chatUploadLocationResolver != null)
|
||||||
.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId));
|
? chatUploadLocationResolver.resolveWriteDir(conversationId)
|
||||||
|
: chatUploadsRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(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;
|
||||||
@ -1889,7 +1878,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L;
|
long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L;
|
||||||
List<RecentFileEntry> merged = new java.util.ArrayList<>();
|
List<RecentFileEntry> merged = new java.util.ArrayList<>();
|
||||||
for (Path dir : candidateChatUploadDirs(conversationId)) {
|
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;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1144,12 +1144,12 @@ public class ChatController {
|
|||||||
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 uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId);
|
||||||
// Sanitize the id before using it as a path segment — IM-channel ids like
|
// resolveWriteDir sanitizes the id (IM-channel ids like "wecom:XXXX"
|
||||||
// "wecom:XXXX" carry a ':' that is illegal in a Windows filename and would
|
// carry a ':' illegal on Windows) and appends the per-day sub-directory
|
||||||
// throw InvalidPathException here. Reads use the same sanitization.
|
// when date folders are enabled. Reads probe both layouts.
|
||||||
Path conversationDir = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId));
|
Path writeDir = uploadLocationResolver.resolveWriteDir(conversationId);
|
||||||
Files.createDirectories(conversationDir);
|
Files.createDirectories(writeDir);
|
||||||
Path target = conversationDir.resolve(storedName);
|
Path target = writeDir.resolve(storedName);
|
||||||
file.transferTo(target);
|
file.transferTo(target);
|
||||||
|
|
||||||
log.info("Chat attachment uploaded: conversationId={}, user={}, file={}", conversationId, username, target);
|
log.info("Chat attachment uploaded: conversationId={}, user={}, file={}", conversationId, username, target);
|
||||||
@ -1160,7 +1160,7 @@ public class ChatController {
|
|||||||
response.setStoredName(storedName);
|
response.setStoredName(storedName);
|
||||||
response.setUrl("/api/v1/chat/files/" + conversationId + "/" + storedName);
|
response.setUrl("/api/v1/chat/files/" + conversationId + "/" + storedName);
|
||||||
// 用 root 相对路径,避免暴露服务端绝对路径(uploadRoot 现在恒为绝对路径)。
|
// 用 root 相对路径,避免暴露服务端绝对路径(uploadRoot 现在恒为绝对路径)。
|
||||||
response.setPath(toRelativeUploadPath(uploadRoot, conversationId, storedName));
|
response.setPath(toRelativeUploadPath(uploadRoot, target));
|
||||||
response.setSize(file.getSize());
|
response.setSize(file.getSize());
|
||||||
response.setContentType(file.getContentType());
|
response.setContentType(file.getContentType());
|
||||||
return R.ok(response);
|
return R.ok(response);
|
||||||
@ -1248,18 +1248,12 @@ public class ChatController {
|
|||||||
/**
|
/**
|
||||||
* Resolve an uploaded attachment to its on-disk path, probing every
|
* Resolve an uploaded attachment to its on-disk path, probing every
|
||||||
* candidate conversation dir (workspace-scoped + legacy default, sanitized +
|
* candidate conversation dir (workspace-scoped + legacy default, sanitized +
|
||||||
* raw id) with a per-candidate path-traversal guard. Returns {@code null}
|
* raw id) and both layouts (flat + date sub-directories) with a
|
||||||
* when no candidate holds the file.
|
* path-traversal guard. Returns {@code null} when no candidate holds the
|
||||||
|
* file.
|
||||||
*/
|
*/
|
||||||
private Path resolveUploadedFile(String conversationId, String storedName) {
|
private Path resolveUploadedFile(String conversationId, String storedName) {
|
||||||
for (Path conversationDir : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
|
return uploadLocationResolver.resolveExistingFile(conversationId, storedName);
|
||||||
Path normDir = conversationDir.normalize();
|
|
||||||
Path candidate = normDir.resolve(storedName).normalize();
|
|
||||||
if (Files.exists(candidate) && candidate.startsWith(normDir)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1662,8 +1656,7 @@ public class ChatController {
|
|||||||
* upload sub-directory name is preserved (e.g. {@code chat-uploads/...}), and
|
* upload sub-directory name is preserved (e.g. {@code chat-uploads/...}), and
|
||||||
* separators are normalized to {@code /} so the value is stable across OSes.
|
* separators are normalized to {@code /} so the value is stable across OSes.
|
||||||
*/
|
*/
|
||||||
static String toRelativeUploadPath(Path uploadRoot, String conversationId, String storedName) {
|
static String toRelativeUploadPath(Path uploadRoot, Path target) {
|
||||||
Path target = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)).resolve(storedName);
|
|
||||||
Path base = uploadRoot.getParent();
|
Path base = uploadRoot.getParent();
|
||||||
Path relative = base != null ? base.relativize(target) : target;
|
Path relative = base != null ? base.relativize(target) : target;
|
||||||
return relative.toString().replace('\\', '/');
|
return relative.toString().replace('\\', '/');
|
||||||
|
|||||||
@ -120,15 +120,19 @@ public class WebChatFileService {
|
|||||||
|
|
||||||
String storedName = UUID.randomUUID() + "_" + safeName;
|
String storedName = UUID.randomUUID() + "_" + safeName;
|
||||||
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize();
|
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize();
|
||||||
// Sanitize the id for the path segment (IM ids like "wecom:XXXX" carry a
|
// resolveWriteDir sanitizes the id for the path segment (IM ids like
|
||||||
// ':' illegal on Windows); reads use the same sanitization.
|
// "wecom:XXXX" carry a ':' illegal on Windows) and appends the per-day
|
||||||
Path dir = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)).normalize();
|
// sub-directory when date folders are enabled.
|
||||||
|
Path dir = uploadLocationResolver.resolveWriteDir(conversationId).normalize();
|
||||||
if (!dir.startsWith(uploadRoot)) {
|
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");
|
||||||
}
|
}
|
||||||
Files.createDirectories(dir);
|
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);
|
Path target = dir.resolve(storedName);
|
||||||
file.transferTo(target.toAbsolutePath());
|
file.transferTo(target.toAbsolutePath());
|
||||||
|
|
||||||
@ -169,19 +173,10 @@ public class WebChatFileService {
|
|||||||
* {@code startsWith} guard. Returns empty if missing or escaping the dir.
|
* {@code startsWith} guard. Returns empty if missing or escaping the dir.
|
||||||
*/
|
*/
|
||||||
public Optional<Path> resolve(String conversationId, String storedName) {
|
public Optional<Path> resolve(String conversationId, String storedName) {
|
||||||
if (storedName == null || storedName.isBlank()) {
|
// resolveExistingFile checks every candidate root (workspace-scoped dir
|
||||||
return Optional.empty();
|
// + legacy default dir) and both layouts (flat + date sub-directories),
|
||||||
}
|
// guarding each candidate against traversal.
|
||||||
// Check every candidate root (workspace-scoped dir + legacy default dir)
|
return Optional.ofNullable(uploadLocationResolver.resolveExistingFile(conversationId, storedName));
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map a content type to the MessageContentPart type the agent/UI understands. */
|
/** Map a content type to the MessageContentPart type the agent/UI understands. */
|
||||||
@ -216,19 +211,23 @@ public class WebChatFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bound a conversation's disk footprint: reject when the dir already holds
|
* Bound a conversation's disk footprint: reject when the conversation tree
|
||||||
* the max file count, or when adding {@code incomingSize} would push the
|
* already holds the max file count, or when adding {@code incomingSize}
|
||||||
* total over the cap. Cheap dir scan (these dirs hold at most a few dozen
|
* would push the total over the cap. Walks the tree so files under date
|
||||||
* files); pairs with the staging TTL sweep that reclaims unreferenced files.
|
* 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;
|
int count = 0;
|
||||||
long total = 0;
|
long total = 0;
|
||||||
try (Stream<Path> files = Files.list(dir)) {
|
if (Files.isDirectory(conversationDir)) {
|
||||||
for (Path p : (Iterable<Path>) files::iterator) {
|
try (Stream<Path> files = Files.walk(conversationDir)) {
|
||||||
if (Files.isRegularFile(p)) {
|
for (Path p : (Iterable<Path>) files::iterator) {
|
||||||
count++;
|
if (Files.isRegularFile(p)) {
|
||||||
total += Files.size(p);
|
count++;
|
||||||
|
total += Files.size(p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3403,7 +3403,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
|||||||
// 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 = (chatUploadLocationResolver != null)
|
Path uploadDir = (chatUploadLocationResolver != null)
|
||||||
? chatUploadLocationResolver.resolveConversationDir(conversationId)
|
? chatUploadLocationResolver.resolveWriteDir(conversationId)
|
||||||
: Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId));
|
: Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId));
|
||||||
String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint;
|
String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint;
|
||||||
return InboundMediaDownloader.download(
|
return InboundMediaDownloader.download(
|
||||||
|
|||||||
@ -695,7 +695,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Path uploadDir = (chatUploadLocationResolver != null)
|
Path uploadDir = (chatUploadLocationResolver != null)
|
||||||
? chatUploadLocationResolver.resolveConversationDir(conversationId)
|
? chatUploadLocationResolver.resolveWriteDir(conversationId)
|
||||||
: Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId));
|
: Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId));
|
||||||
return InboundMediaDownloader.download(
|
return InboundMediaDownloader.download(
|
||||||
() -> client.downloadMedia("", aesKey, encryptQueryParam),
|
() -> client.downloadMedia("", aesKey, encryptQueryParam),
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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.nio.file.Paths;
|
||||||
|
import java.nio.file.attribute.FileTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -128,35 +129,86 @@ public final class ChatUploadResolver {
|
|||||||
if (!Files.isDirectory(uploadDir)) {
|
if (!Files.isDirectory(uploadDir)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String basename;
|
String basename = basenameOf(rawPath);
|
||||||
try {
|
|
||||||
Path requested = Paths.get(rawPath).getFileName();
|
|
||||||
basename = requested != null ? requested.toString() : null;
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (basename == null || basename.isBlank()) {
|
if (basename == null || basename.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Path direct = uploadDir.resolve(basename);
|
// Attachments may live flat in the conversation dir (legacy layout /
|
||||||
if (Files.isRegularFile(direct)) {
|
// date-folders off) or under a yyyy-MM-dd sub-directory; scan both.
|
||||||
return direct;
|
List<Path> scanDirs = ChatUploadLocationResolver.dateScanDirs(uploadDir);
|
||||||
}
|
|
||||||
|
|
||||||
// Stored as "{millis}_{safeFilename}" where safeFilename replaces non-ASCII
|
// An exact stored-name match is unambiguous, so it wins wherever it sits.
|
||||||
// characters with underscores; match by sanitized basename suffix.
|
for (Path scanDir : scanDirs) {
|
||||||
String safeBasename = basename.replaceAll("[^a-zA-Z0-9._-]", "_");
|
Path direct = scanDir.resolve(basename);
|
||||||
String suffix = "_" + safeBasename;
|
if (Files.isRegularFile(direct)) {
|
||||||
try (var stream = Files.list(uploadDir)) {
|
return direct;
|
||||||
return stream
|
}
|
||||||
.filter(Files::isRegularFile)
|
}
|
||||||
.filter(p -> p.getFileName().toString().endsWith(suffix))
|
return newestSuffixMatch(scanDirs, basename);
|
||||||
.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;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,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 = uploadLocationResolver.resolveConversationDir(conversationId);
|
Path dir = uploadLocationResolver.resolveWriteDir(conversationId);
|
||||||
Files.createDirectories(dir);
|
Files.createDirectories(dir);
|
||||||
|
|
||||||
if (imageUrl.startsWith("data:")) {
|
if (imageUrl.startsWith("data:")) {
|
||||||
@ -112,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 = uploadLocationResolver.resolveConversationDir(conversationId);
|
Path dir = uploadLocationResolver.resolveWriteDir(conversationId);
|
||||||
Files.createDirectories(dir);
|
Files.createDirectories(dir);
|
||||||
|
|
||||||
String fileName = "image_" + taskId + "_" + index + ".png";
|
String fileName = "image_" + taskId + "_" + index + ".png";
|
||||||
|
|||||||
@ -24,7 +24,7 @@ public class Model3dFileDownloader {
|
|||||||
|
|
||||||
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 = uploadLocationResolver.resolveConversationDir(conversationId);
|
Path dir = uploadLocationResolver.resolveWriteDir(conversationId);
|
||||||
Files.createDirectories(dir);
|
Files.createDirectories(dir);
|
||||||
|
|
||||||
String ext = guessExtension(modelUrl, preferredExtension);
|
String ext = guessExtension(modelUrl, preferredExtension);
|
||||||
|
|||||||
@ -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 = uploadLocationResolver.resolveConversationDir(conversationId);
|
Path dir = uploadLocationResolver.resolveWriteDir(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);
|
||||||
|
|||||||
@ -31,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 = uploadLocationResolver.resolveConversationDir(conversationId);
|
Path dir = uploadLocationResolver.resolveWriteDir(conversationId);
|
||||||
Files.createDirectories(dir);
|
Files.createDirectories(dir);
|
||||||
|
|
||||||
String extension = guessExtension(videoUrl);
|
String extension = guessExtension(videoUrl);
|
||||||
|
|||||||
@ -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 = uploadLocationResolver.resolveConversationDir(conversationId);
|
Path dir = uploadLocationResolver.resolveWriteDir(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);
|
||||||
|
|||||||
@ -29,7 +29,26 @@ public class ChatUploadProperties {
|
|||||||
* Root directory for chat attachments when neither the active agent nor its
|
* Root directory for chat attachments when neither the active agent nor its
|
||||||
* workspace configures a base path. Defaults to {@code data/chat-uploads}
|
* workspace configures a base path. Defaults to {@code data/chat-uploads}
|
||||||
* (relative to the Spring Boot working directory). Conversations are stored
|
* (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";
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,14 +17,19 @@ import vip.mate.workspace.core.model.WorkspaceEntity;
|
|||||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||||
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
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.InvalidPathException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the on-disk root directory where a conversation's chat attachments
|
* 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
|
* resolvable and cleanable. Writes always target a single root returned by
|
||||||
* {@link #resolveUploadRoot(String)}.
|
* {@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
|
* <p>The {@code conversationId → ConversationEntity} lookup is cached for 5
|
||||||
* minutes (the mapping is immutable once a conversation exists), matching the
|
* minutes (the mapping is immutable once a conversation exists), matching the
|
||||||
* TTL of the existing {@code WorkspaceLookupCache} on the tool-call hot path.
|
* 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. */
|
/** Sub-directory appended under a configured base path. */
|
||||||
public static final String UPLOAD_SUBDIR = "chat-uploads";
|
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.
|
* Turn a business conversation id into a filesystem-safe path segment.
|
||||||
* <p>
|
* <p>
|
||||||
@ -87,13 +107,111 @@ public class ChatUploadLocationResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single conversation attachment directory (write target):
|
* The conversation's attachment root directory:
|
||||||
* {@code {uploadRoot}/{sanitizeSegment(conversationId)}/}.
|
* {@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) {
|
public Path resolveConversationDir(String conversationId) {
|
||||||
return resolveUploadRoot(conversationId).resolve(sanitizeSegment(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,
|
* Every conversation attachment directory a read / cleanup path should probe,
|
||||||
* ordered: the sanitized dir under each candidate root first, then — for
|
* ordered: the sanitized dir under each candidate root first, then — for
|
||||||
|
|||||||
@ -198,6 +198,12 @@ mateclaw:
|
|||||||
# instead; reads and cleanup still check this default dir so legacy uploads
|
# instead; reads and cleanup still check this default dir so legacy uploads
|
||||||
# remain resolvable. Defaults to the legacy location for zero-config parity.
|
# remain resolvable. Defaults to the legacy location for zero-config parity.
|
||||||
base-dir: ${MATECLAW_CHAT_UPLOAD_BASE_DIR:data/chat-uploads}
|
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:
|
skill:
|
||||||
upload:
|
upload:
|
||||||
# Size caps for skill bundle ZIPs (upload endpoint and marketplace
|
# Size caps for skill bundle ZIPs (upload endpoint and marketplace
|
||||||
|
|||||||
@ -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}/` 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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}/`,但当会话的 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 读。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -100,7 +100,7 @@ class ChatControllerPreviewRouteTest {
|
|||||||
when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true);
|
when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true);
|
||||||
when(officePreviewService.isConvertible(STORED)).thenReturn(true);
|
when(officePreviewService.isConvertible(STORED)).thenReturn(true);
|
||||||
when(officePreviewService.isAvailable()).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()))
|
mockMvc.perform(MockMvcRequestBuilders.get(URL).principal(admin()))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
@ -115,7 +115,7 @@ class ChatControllerPreviewRouteTest {
|
|||||||
when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true);
|
when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true);
|
||||||
when(officePreviewService.isConvertible(STORED)).thenReturn(true);
|
when(officePreviewService.isConvertible(STORED)).thenReturn(true);
|
||||||
when(officePreviewService.isAvailable()).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();
|
byte[] pdf = "%PDF-1.4 fake".getBytes();
|
||||||
when(officePreviewService.renderPdf(any(Path.class))).thenReturn(pdf);
|
when(officePreviewService.renderPdf(any(Path.class))).thenReturn(pdf);
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
* root became absolute (the resolver normalizes via {@code toAbsolutePath()}),
|
* root became absolute (the resolver normalizes via {@code toAbsolutePath()}),
|
||||||
* the {@code path} field — which is rendered into the LLM prompt and returned to
|
* 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
|
* 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 {
|
class ChatControllerUploadPathTest {
|
||||||
|
|
||||||
@ -25,21 +25,35 @@ class ChatControllerUploadPathTest {
|
|||||||
void defaultRootIsRelative() {
|
void defaultRootIsRelative() {
|
||||||
// Mirrors the resolver's default root: absolute + normalized.
|
// Mirrors the resolver's default root: absolute + normalized.
|
||||||
Path uploadRoot = Paths.get("data", "chat-uploads").toAbsolutePath().normalize();
|
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(path).isEqualTo("chat-uploads/conv-1/1777_a.txt");
|
||||||
assertThat(Paths.get(path).isAbsolute()).isFalse();
|
assertThat(Paths.get(path).isAbsolute()).isFalse();
|
||||||
assertThat(path).doesNotContain(uploadRoot.toString());
|
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
|
@Test
|
||||||
@DisplayName("workspace-scoped absolute root: still root-relative, no leak")
|
@DisplayName("workspace-scoped absolute root: still root-relative, no leak")
|
||||||
void scopedRootIsRelative() {
|
void scopedRootIsRelative() {
|
||||||
// An absolute workspace basePath somewhere outside the CWD.
|
// An absolute workspace basePath somewhere outside the CWD.
|
||||||
Path uploadRoot = Paths.get("/srv/ws/alpha/chat-uploads").toAbsolutePath().normalize();
|
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).isEqualTo("chat-uploads/conv-2/9_b.pdf");
|
||||||
assertThat(path).doesNotContain("/srv/ws/alpha");
|
assertThat(path).doesNotContain("/srv/ws/alpha");
|
||||||
@ -49,8 +63,9 @@ class ChatControllerUploadPathTest {
|
|||||||
@DisplayName("custom base-dir name is preserved (not hardcoded to chat-uploads)")
|
@DisplayName("custom base-dir name is preserved (not hardcoded to chat-uploads)")
|
||||||
void customBaseDirNamePreserved() {
|
void customBaseDirNamePreserved() {
|
||||||
Path uploadRoot = Paths.get("/var/uploads").toAbsolutePath().normalize();
|
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");
|
assertThat(path).isEqualTo("uploads/conv-3/f.bin");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,7 +11,9 @@ import vip.mate.workspace.conversation.repository.ConversationMapper;
|
|||||||
import vip.mate.workspace.core.config.ChatUploadProperties;
|
import vip.mate.workspace.core.config.ChatUploadProperties;
|
||||||
import vip.mate.workspace.core.model.WorkspaceEntity;
|
import vip.mate.workspace.core.model.WorkspaceEntity;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@ -264,4 +266,128 @@ class ChatUploadLocationResolverTest {
|
|||||||
|
|
||||||
assertThat(dirs).containsExactly(tempDir.toAbsolutePath().normalize().resolve("plainconv"));
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user