From 39a55db65f1d62ed8ab7ad8d5282543835e5309d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Sat, 13 Jun 2026 13:55:32 +0800 Subject: [PATCH] fix(feishu): recover recent files from disk when in-memory cache misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-chat recent file cache (Caffeine, 60 min TTL) is purely in-memory. After a process restart, GC eviction, or TTL expiry the cache is empty, but the staged copies under data/chat-uploads/ survive on disk. A follow-up text message that should have seen the cached file instead found nothing — the bot replied as if no file was ever sent. Changes: - injectRecentFiles(): fall back to scanning data/chat-uploads/{id}/ when the Caffeine cache misses, sorted by last-modified time, capped at RECENT_FILE_MAX_PER_CHAT (5). - cacheRecentFile(): promote catch log from debug → warn with full stack trace so silent download failures are visible in production logs. Add entry-level info log for correlation. - New helper loadRecentFilesFromDisk() + guessContentType(). Closes #325 Relates to #201 --- .../channel/feishu/FeishuChannelAdapter.java | 81 ++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 0bea5a1b..01216750 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -1669,6 +1669,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre private String cacheRecentFile(String messageId, String messageType, String contentStr, String conversationId) { try { + log.info("[feishu] cacheRecentFile: type={}, conversationId={}, messageId={}", messageType, conversationId, messageId); Map contentObj = objectMapper.readValue(contentStr, Map.class); String fileKey = null; @@ -1738,7 +1739,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre return dest.toAbsolutePath().toString(); } catch (Exception e) { - log.debug("[feishu] Failed to cache recent file: {}", e.getMessage()); + log.warn("[feishu] Failed to cache recent file for conversation={}: {}", conversationId, e.getMessage(), e); return null; } } @@ -1752,7 +1753,14 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre */ private String injectRecentFiles(String conversationId, List parts, String textContent) { List recent = recentFileCache.getIfPresent(conversationId); - if (recent == null || recent.isEmpty()) return textContent; + if (recent == null || recent.isEmpty()) { + // Fallback: scan data/chat-uploads/{conversationId}/ on disk. + // Survives process restart / Caffeine TTL expiry / GC eviction. + recent = loadRecentFilesFromDisk(conversationId); + if (recent.isEmpty()) return textContent; + log.info("[feishu] injectRecentFiles: cache miss, recovered {} file(s) from disk for conversation={}", + recent.size(), conversationId); + } // Collect paths already in parts to avoid duplicates Set existingPaths = new java.util.HashSet<>(); @@ -1782,6 +1790,75 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre return text.toString(); } + /** + * Scan {@code data/chat-uploads/{conversationId}/} on disk and return + * the most recent files as {@link RecentFileEntry}s. Used as a + * fallback when the in-memory Caffeine cache has been evicted + * (process restart, TTL expiry, GC pressure) but the staged copies + * are still on disk. + */ + private List loadRecentFilesFromDisk(String conversationId) { + Path dir = Path.of("data", "chat-uploads", conversationId); + if (!Files.isDirectory(dir)) return List.of(); + try (var stream = Files.list(dir)) { + return stream + .filter(Files::isRegularFile) + .sorted((a, b) -> { + try { + return Long.compare( + Files.getLastModifiedTime(b).toMillis(), + Files.getLastModifiedTime(a).toMillis()); + } catch (Exception e) { + return 0; + } + }) + .limit(RECENT_FILE_MAX_PER_CHAT) + .map(p -> { + String fileName = p.getFileName().toString(); + // Strip timestamp prefix (e.g. "1777391026594_report.pdf" → "report.pdf") + int sep = fileName.indexOf('_'); + String display = (sep > 0 && sep < 20) ? fileName.substring(sep + 1) : fileName; + String contentType = guessContentType(p); + return new RecentFileEntry(display, p.toAbsolutePath().toString(), null, contentType); + }) + .toList(); + } catch (Exception e) { + log.debug("[feishu] Failed to scan disk for recent files in {}: {}", dir, e.getMessage()); + return List.of(); + } + } + + /** Best-effort content type from file extension. */ + private static String guessContentType(Path p) { + String name = p.getFileName().toString().toLowerCase(); + if (name.endsWith(".pdf")) return "application/pdf"; + if (name.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + if (name.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + if (name.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + if (name.endsWith(".doc")) return "application/msword"; + if (name.endsWith(".xls")) return "application/vnd.ms-excel"; + if (name.endsWith(".ppt")) return "application/vnd.ms-powerpoint"; + if (name.endsWith(".txt")) return "text/plain"; + if (name.endsWith(".csv")) return "text/csv"; + if (name.endsWith(".json")) return "application/json"; + if (name.endsWith(".xml")) return "application/xml"; + if (name.endsWith(".md")) return "text/markdown"; + if (name.endsWith(".png")) return "image/png"; + if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg"; + if (name.endsWith(".gif")) return "image/gif"; + if (name.endsWith(".webp")) return "image/webp"; + if (name.endsWith(".mp3")) return "audio/mpeg"; + if (name.endsWith(".ogg")) return "audio/ogg"; + if (name.endsWith(".opus")) return "audio/opus"; + if (name.endsWith(".wav")) return "audio/wav"; + if (name.endsWith(".mp4")) return "video/mp4"; + if (name.endsWith(".mov")) return "video/quicktime"; + if (name.endsWith(".zip")) return "application/zip"; + if (name.endsWith(".rar")) return "application/x-rar-compressed"; + if (name.endsWith(".7z")) return "application/x-7z-compressed"; + return "application/octet-stream"; + } + // ==================== 消息内容解析 ==================== /**