diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/GeneratedFileScrubber.java b/mateclaw-server/src/main/java/vip/mate/channel/media/GeneratedFileScrubber.java index 6caefcb8..2a916c14 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/media/GeneratedFileScrubber.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/GeneratedFileScrubber.java @@ -23,8 +23,9 @@ import java.util.regex.Matcher; * a render tool. {@link GeneratedFileCache#put} logs every real * put, so its absence here is proof the file was never generated * this turn. - *
The cache is process-local and intentionally not persisted: a JVM restart - * invalidates all outstanding download links. The download URL embeds a random - * {@link UUID}, which acts as the only access credential. + *
Persistence is what makes download links durable: the bytes survive both
+ * cache eviction and a JVM restart, so a link a user clicks minutes — or days —
+ * after generation still resolves instead of 404ing. Entries are retained for
+ * {@link #TTL} and a scheduled sweep removes expired files. The download URL
+ * embeds a random {@link UUID}, which acts as the only access credential.
*/
@Slf4j
@Component
public class GeneratedFileCache {
- public static final Duration TTL = Duration.ofMinutes(10);
+ /** How long a generated file remains downloadable after creation. */
+ public static final Duration TTL = Duration.ofDays(7);
+
+ /** Default on-disk location for persisted generated files. */
+ public static final Path DEFAULT_STORAGE_DIR = Paths.get("data", "generated-files");
+
+ /** How often the expired-file sweep runs (6 hours). Must be a compile-time
+ * constant for use in {@link Scheduled#fixedDelay()}. */
+ private static final long CLEANUP_INTERVAL_MS = 6L * 60 * 60 * 1000;
+
+ /** Guards path resolution: only server-issued UUID-shaped ids are accepted. */
+ private static final Pattern ID_RE = Pattern.compile("[a-zA-Z0-9-]{1,64}");
+
+ private static final String META_SUFFIX = ".meta";
/**
- * URL pattern for in-memory generated files served by
- * {@code GeneratedFileController}. Public so channel adapters and graph
- * nodes share a single source of truth.
+ * Upper bound on bytes held in memory. Disk is the source of truth and
+ * retains entries for {@link #TTL}; this map is only a hot-read cache, so
+ * capping it keeps heap bounded regardless of how many files are produced
+ * within the retention window. A miss simply reloads from disk.
+ */
+ private static final int MAX_MEMORY_ENTRIES = 256;
+
+ /**
+ * URL pattern for generated files served by {@code GeneratedFileController}.
+ * Public so channel adapters and graph nodes share a single source of truth.
*/
public static final Pattern GENERATED_URL_PATTERN =
Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)");
@@ -41,7 +73,33 @@ public class GeneratedFileCache {
public static final String MISSING_REFERENCE_NOTICE =
"⚠️ 文件未真正生成(模型未调用文档生成工具),请重新发送请求";
- private final ConcurrentHashMap Cache misses are nearly always LLM hallucinations — the model
* emitted a UUID-shaped string without ever calling a render tool.
@@ -107,8 +257,7 @@ public class GeneratedFileCache {
m.reset();
while (m.find()) {
String id = m.group(1);
- Entry entry = entries.get(id);
- boolean live = entry != null && !entry.expired();
+ boolean live = get(id).isPresent();
String replacement = live ? m.group(0) : MISSING_REFERENCE_NOTICE;
m.appendReplacement(out, Matcher.quoteReplacement(replacement));
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java
index a3eb8902..f8378cec 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java
@@ -23,7 +23,8 @@ public final class GeneratedFileLink {
public static String resultZh(byte[] bytes, String displayName, String mimeType,
GeneratedFileCache cache, String typeLabel) {
String url = stash(bytes, displayName, mimeType, cache);
- return typeLabel + "已生成:[" + displayName + "](" + url + ")(链接 10 分钟内有效)。\n"
+ return typeLabel + "已生成:[" + displayName + "](" + url + ")(链接 "
+ + GeneratedFileCache.TTL.toDays() + " 天内有效)。\n"
+ "重要:回答用户时**必须**使用上述 markdown 链接格式 [" + displayName + "](" + url + "),"
+ "保持相对路径原样,**不要**用反引号包裹路径,也**不要**添加任何 https://、http:// 域名前缀。";
}
@@ -44,7 +45,8 @@ public final class GeneratedFileLink {
String prefix = sourceFileCount > 1
? typeLabel + " generated from " + sourceFileCount + " files"
: typeLabel + " generated";
- return prefix + ": [" + displayName + "](" + url + ") (link valid for 10 minutes).\n"
+ return prefix + ": [" + displayName + "](" + url + ") (link valid for "
+ + GeneratedFileCache.TTL.toDays() + " days).\n"
+ "IMPORTANT: when replying to the user you **must** keep the markdown link form ["
+ displayName + "](" + url + ") above. Keep the relative path verbatim — do **not** "
+ "wrap it in backticks and do **not** prepend any https://, http:// or domain "
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java
new file mode 100644
index 00000000..f929f1d9
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java
@@ -0,0 +1,83 @@
+package vip.mate.tool.document;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Pin the persistence contract that keeps download links durable: bytes are
+ * written to disk so a link still resolves after the in-memory entry is gone
+ * or the JVM has restarted. A regression here reintroduces the
+ * "File not found or expired" page that a user hits minutes after generating
+ * a document.
+ */
+class GeneratedFileCachePersistenceTest {
+
+ @Test
+ @DisplayName("a link survives a 'restart' — a fresh cache over the same dir still serves it")
+ void survivesRestart(@TempDir Path dir) {
+ GeneratedFileCache first = new GeneratedFileCache(dir);
+ byte[] bytes = "report-body".getBytes(StandardCharsets.UTF_8);
+ String id = first.put(bytes, "季度报表.docx",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+
+ // Simulate a JVM restart: a brand-new instance with an empty memory map,
+ // pointing at the same storage directory.
+ GeneratedFileCache afterRestart = new GeneratedFileCache(dir);
+ GeneratedFileCache.Entry entry = afterRestart.get(id).orElse(null);
+
+ assertNotNull(entry, "persisted entry must be reloaded from disk after restart");
+ assertArrayEquals(bytes, entry.bytes(), "reloaded bytes must match the original");
+ assertEquals("季度报表.docx", entry.filename(), "unicode filename must round-trip");
+ assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ entry.mimeType());
+ }
+
+ @Test
+ @DisplayName("unknown id returns empty")
+ void unknownIdEmpty(@TempDir Path dir) {
+ GeneratedFileCache cache = new GeneratedFileCache(dir);
+ assertTrue(cache.get("00000000-0000-0000-0000-000000000000").isEmpty());
+ }
+
+ @Test
+ @DisplayName("malformed / path-traversal ids are rejected without touching disk")
+ void traversalRejected(@TempDir Path dir) {
+ GeneratedFileCache cache = new GeneratedFileCache(dir);
+ assertTrue(cache.get("../secret").isEmpty());
+ assertTrue(cache.get("a/b").isEmpty());
+ assertTrue(cache.get("").isEmpty());
+ assertTrue(cache.get(null).isEmpty());
+ }
+
+ @Test
+ @DisplayName("memory LRU eviction never loses downloadability — old ids reload from disk")
+ void lruEvictionFallsBackToDisk(@TempDir Path dir) {
+ GeneratedFileCache cache = new GeneratedFileCache(dir);
+ // Far exceed the in-memory cap so the first id is evicted from memory.
+ String firstId = cache.put("first".getBytes(StandardCharsets.UTF_8), "first.txt", "text/plain");
+ for (int i = 0; i < 400; i++) {
+ cache.put(("f" + i).getBytes(StandardCharsets.UTF_8), "f" + i + ".txt", "text/plain");
+ }
+ GeneratedFileCache.Entry entry = cache.get(firstId).orElse(null);
+ assertNotNull(entry, "an id evicted from the memory cache must still resolve from disk");
+ assertArrayEquals("first".getBytes(StandardCharsets.UTF_8), entry.bytes());
+ }
+
+ @Test
+ @DisplayName("scrub treats a persisted-but-evicted id as live (reloads from disk)")
+ void scrubReloadsPersisted(@TempDir Path dir) {
+ GeneratedFileCache first = new GeneratedFileCache(dir);
+ String id = first.put("x".getBytes(StandardCharsets.UTF_8), "a.pdf", "application/pdf");
+
+ GeneratedFileCache afterRestart = new GeneratedFileCache(dir);
+ String text = "下载: /api/v1/files/generated/" + id;
+ assertEquals(text, afterRestart.scrubMissingReferences(text),
+ "a still-persisted link must not be scrubbed as missing after restart");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheScrubTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheScrubTest.java
index 0aac0356..330622d3 100644
--- a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheScrubTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheScrubTest.java
@@ -3,6 +3,9 @@ package vip.mate.tool.document;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
@@ -21,8 +24,9 @@ class GeneratedFileCacheScrubTest {
private GeneratedFileCache cache;
@BeforeEach
- void setUp() {
- cache = new GeneratedFileCache();
+ void setUp(@TempDir Path tempDir) {
+ // Hermetic storage so put() does not litter the real data/ dir.
+ cache = new GeneratedFileCache(tempDir);
}
@Test