using System.IO; using System.Linq; using System.Text.Json; using AxCopilot.Models; namespace AxCopilot.Services; /// /// 대화 내역을 로컬에 AES-256-GCM 암호화하여 저장/로드합니다. /// 파일 형식: conversations/{id}.axchat (암호화 바이너리) /// 스레드 안전: ReaderWriterLockSlim으로 동시 접근 보호. /// 원자적 쓰기: 임시 파일 → rename 패턴으로 크래시 시 데이터 손실 방지. /// public class ChatStorageService { private static readonly string ConversationsDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "conversations"); private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false, PropertyNameCaseInsensitive = true }; private static readonly ReaderWriterLockSlim Lock = new(); public ChatStorageService() { Directory.CreateDirectory(ConversationsDir); } /// 대화를 암호화하여 저장합니다 (원자적 쓰기). public void Save(ChatConversation conversation) { conversation.UpdatedAt = DateTime.Now; // 검색용 미리보기 자동 갱신 (첫 사용자 메시지 100자) if (string.IsNullOrEmpty(conversation.Preview) && conversation.Messages.Count > 0) { var firstUser = conversation.Messages.FirstOrDefault(m => m.Role == "user"); if (firstUser != null) conversation.Preview = firstUser.Content.Length > 100 ? firstUser.Content[..100] : firstUser.Content; } var json = JsonSerializer.Serialize(conversation, JsonOpts); var path = GetFilePath(conversation.Id); var tempPath = path + ".tmp"; Lock.EnterWriteLock(); try { CryptoService.EncryptToFile(tempPath, json); // 원자적 교체: 기존 파일이 있으면 덮어쓰기 if (File.Exists(path)) File.Delete(path); File.Move(tempPath, path); UpdateMetaCache(conversation); } catch (Exception ex) { // 임시 파일 정리 try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } LogService.Warn($"대화 저장 실패 ({conversation.Id}): {ex.Message}"); throw; } finally { Lock.ExitWriteLock(); } } /// 대화를 복호화하여 로드합니다. public ChatConversation? Load(string id) { var path = GetFilePath(id); Lock.EnterReadLock(); try { if (!File.Exists(path)) return null; var json = CryptoService.DecryptFromFile(path); return JsonSerializer.Deserialize(json, JsonOpts); } catch (Exception ex) { LogService.Warn($"대화 로드 실패 ({id}): {ex.Message}"); return null; } finally { Lock.ExitReadLock(); } } // ── 메타 캐시 ───────────────────────────────────────────────────────── private List? _metaCache; private bool _metaDirty = true; /// 메타 캐시를 무효화합니다. 다음 LoadAllMeta 호출 시 디스크에서 다시 읽습니다. public void InvalidateMetaCache() => _metaDirty = true; /// 메타 캐시에서 특정 항목만 업데이트합니다 (전체 재로드 없이). public void UpdateMetaCache(ChatConversation conv) { if (_metaCache == null) return; var existing = _metaCache.FindIndex(c => c.Id == conv.Id); var meta = new ChatConversation { Id = conv.Id, Title = conv.Title, CreatedAt = conv.CreatedAt, UpdatedAt = conv.UpdatedAt, Pinned = conv.Pinned, Category = conv.Category, Tab = conv.Tab, SystemCommand = conv.SystemCommand, WorkFolder = conv.WorkFolder, Preview = conv.Preview, Messages = new(), ExecutionEvents = conv.ExecutionEvents?.ToList() ?? new() }; if (existing >= 0) _metaCache[existing] = meta; else _metaCache.Add(meta); } /// 메타 캐시에서 항목을 제거합니다. public void RemoveFromMetaCache(string id) { _metaCache?.RemoveAll(c => c.Id == id); } /// 모든 대화의 메타 정보(메시지 미포함)를 로드합니다. 캐시를 사용합니다. public List LoadAllMeta() { if (!_metaDirty && _metaCache != null) { return _metaCache.OrderByDescending(c => c.Pinned) .ThenByDescending(c => c.UpdatedAt) .ToList(); } var result = new List(); if (!Directory.Exists(ConversationsDir)) { _metaCache = result; _metaDirty = false; return result; } Lock.EnterReadLock(); try { foreach (var file in Directory.GetFiles(ConversationsDir, "*.axchat")) { try { var json = CryptoService.DecryptFromFile(file); var conv = JsonSerializer.Deserialize(json, JsonOpts); if (conv != null) { var meta = new ChatConversation { Id = conv.Id, Title = conv.Title, CreatedAt = conv.CreatedAt, UpdatedAt = conv.UpdatedAt, Pinned = conv.Pinned, Category = conv.Category, Tab = conv.Tab, SystemCommand = conv.SystemCommand, WorkFolder = conv.WorkFolder, Preview = conv.Preview, Messages = new(), ExecutionEvents = conv.ExecutionEvents?.ToList() ?? new() }; result.Add(meta); } } catch (Exception ex) { LogService.Warn($"대화 메타 로드 실패 ({Path.GetFileName(file)}): {ex.Message}"); } } } finally { Lock.ExitReadLock(); } _metaCache = result; _metaDirty = false; return result.OrderByDescending(c => c.Pinned) .ThenByDescending(c => c.UpdatedAt) .ToList(); } /// 특정 대화를 삭제합니다. public void Delete(string id) { var path = GetFilePath(id); Lock.EnterWriteLock(); try { if (File.Exists(path)) File.Delete(path); RemoveFromMetaCache(id); } catch (Exception ex) { LogService.Warn($"대화 삭제 실패 ({id}): {ex.Message}"); } finally { Lock.ExitWriteLock(); } } /// 모든 대화를 삭제합니다. public int DeleteAll() { if (!Directory.Exists(ConversationsDir)) return 0; Lock.EnterWriteLock(); try { var files = Directory.GetFiles(ConversationsDir, "*.axchat"); int count = 0; foreach (var f in files) { try { File.Delete(f); count++; } catch (Exception ex) { LogService.Warn($"파일 삭제 실패 ({Path.GetFileName(f)}): {ex.Message}"); } } InvalidateMetaCache(); return count; } finally { Lock.ExitWriteLock(); } } /// 보관 기간을 초과한 대화를 삭제합니다 (핀 고정 제외). public int PurgeExpired(int retentionDays) { if (retentionDays <= 0) return 0; var cutoff = DateTime.Now.AddDays(-retentionDays); int count = 0; Lock.EnterWriteLock(); try { foreach (var file in Directory.GetFiles(ConversationsDir, "*.axchat")) { try { var json = CryptoService.DecryptFromFile(file); var conv = JsonSerializer.Deserialize(json, JsonOpts); if (conv != null && !conv.Pinned && conv.UpdatedAt < cutoff) { File.Delete(file); RemoveFromMetaCache(conv.Id); count++; } } catch (Exception ex) { LogService.Warn($"만료 대화 정리 실패 ({Path.GetFileName(file)}): {ex.Message}"); } } } finally { Lock.ExitWriteLock(); } return count; } /// 드라이브 사용률이 98% 이상이면 오래된 대화부터 삭제합니다 (핀 고정 제외). public int PurgeForDiskSpace(double threshold = 0.98) { try { var drive = new DriveInfo(Path.GetPathRoot(ConversationsDir) ?? "C"); if (drive.TotalSize <= 0) return 0; var usageRatio = 1.0 - (double)drive.AvailableFreeSpace / drive.TotalSize; if (usageRatio < threshold) return 0; LogService.Info($"드라이브 사용률 {usageRatio:P1} — 대화 정리 시작 (임계값: {threshold:P0})"); // 오래된 순으로 정렬하여 삭제 (핀 고정 제외) var files = Directory.GetFiles(ConversationsDir, "*.axchat") .Select(f => new { Path = f, LastWrite = File.GetLastWriteTime(f) }) .OrderBy(f => f.LastWrite) .ToList(); int count = 0; Lock.EnterWriteLock(); try { foreach (var file in files) { // 사용률이 임계값 미만으로 내려가면 중단 var currentUsage = 1.0 - (double)drive.AvailableFreeSpace / drive.TotalSize; if (currentUsage < threshold - 0.02) break; // 2% 여유 확보 후 중단 try { var json = CryptoService.DecryptFromFile(file.Path); var conv = JsonSerializer.Deserialize(json, JsonOpts); if (conv != null && conv.Pinned) continue; // 핀 고정 건너뜀 File.Delete(file.Path); count++; } catch { } } } finally { Lock.ExitWriteLock(); } if (count > 0) LogService.Info($"드라이브 용량 부족으로 대화 {count}개 삭제 완료"); return count; } catch (Exception ex) { LogService.Warn($"디스크 용량 확인 실패: {ex.Message}"); return 0; } } private static string GetFilePath(string id) => Path.Combine(ConversationsDir, $"{id}.axchat"); }