fix(feishu): recover recent files from disk when in-memory cache misses

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
This commit is contained in:
倪程伟 2026-06-13 13:55:32 +08:00 committed by matevip
parent 32ad11d6c4
commit 39a55db65f

View File

@ -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<String, Object> 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<MessageContentPart> parts, String textContent) {
List<RecentFileEntry> 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<String> 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<RecentFileEntry> 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";
}
// ==================== 消息内容解析 ====================
/**