using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using UnityEngine; /// /// BFI测试历史记录管理服务 /// 负责保存、加载和管理历史测试记录 /// public class BFIHistoryManager : MonoBehaviour { private const int MIN_HISTORY_RECORD_LIMIT = 1000; private const string HISTORY_FILE_NAME = "bfi_history.json"; private const string HISTORY_BACKUP_FILE_NAME = "bfi_history_backup.json"; private const string HISTORY_RECORDS_DIR_NAME = "bfi_history_records"; private const string CURRENT_RECORDING_JOURNAL_FILE_NAME = "bfi_current_recording.jsonl"; private const int HISTORY_INDEX_VERSION = 2; private const string HISTORY_STORAGE_MODE_PER_RECORD = "PerRecordFiles"; private const string RECORD_BACKUP_EXTENSION = ".bak"; private static readonly string[] HISTORY_CANDIDATE_FILE_NAMES = { HISTORY_FILE_NAME, HISTORY_BACKUP_FILE_NAME }; private static BFIHistoryManager _instance; public static BFIHistoryManager Instance { get { if (_instance == null) { var go = new GameObject("BFIHistoryManager"); _instance = go.AddComponent(); DontDestroyOnLoad(go); } return _instance; } } [Header("配置")] [SerializeField] private int maxHistoryRecords = 1000; // 最大历史记录数 [SerializeField] private bool autoSave = true; // 自动保存 [SerializeField] private float saveInterval = 60f; // 保存间隔(秒) private List historyRecords; private string historyFilePath; private string historyMirrorFilePath; private string historyRecordsDirectoryPath; private float lastSaveTime; private bool historyFileLoadFailed; private bool androidStoragePermissionRequested; private bool historyIndexDirty; // 事件 public event System.Action OnRecordAdded; public event System.Action OnRecordUpdated; public event System.Action OnRecordRemoved; public event System.Action OnHistoryLoaded; // public bool LastSaveSucceeded { get; private set; } private void Awake() { if (_instance == null) { _instance = this; DontDestroyOnLoad(gameObject); Initialize(); } else if (_instance != this) { Destroy(gameObject); } } private void Initialize() { maxHistoryRecords = Mathf.Max(maxHistoryRecords, MIN_HISTORY_RECORD_LIMIT); historyRecords = new List(); // 历史索引是导出面板的数据源,主文件使用Unity持久化目录,和报警记录一致。 // historyMirrorFilePath仅用于清理旧版本曾写入Download的镜像文件。 historyFilePath = ResolveHistoryFilePath(); historyMirrorFilePath = ResolveHistoryMirrorFilePath(); historyRecordsDirectoryPath = GetRecordFilesDirectory(historyFilePath); EnsureHistoryDirectoryExists(); LoadHistoryRecords(true, true); Debug.Log($"BFI历史管理器已初始化,历史文件路径: {historyFilePath}, 单条记录目录: {historyRecordsDirectoryPath}"); #if UNITY_ANDROID && !UNITY_EDITOR AndroidFileLogger.Instance?.LogInfo("BFIHistory", $"历史文件路径: {historyFilePath}"); AndroidFileLogger.Instance?.LogInfo("BFIHistory", $"单条记录目录: {historyRecordsDirectoryPath}"); #endif } private string ResolveHistoryFilePath() { return Path.Combine(Application.persistentDataPath, HISTORY_FILE_NAME); } private string ResolveHistoryMirrorFilePath() { #if UNITY_ANDROID && !UNITY_EDITOR return Path.Combine(GetAndroidPublicDownloadDirectory(), "DCX_Export", HISTORY_FILE_NAME); #else return null; #endif } public string GetHistoryFilePath() { return historyFilePath; } public string GetHistoryMirrorFilePath() { return historyMirrorFilePath; } public string GetHistoryRecordsDirectoryPath() { return historyRecordsDirectoryPath; } #if UNITY_ANDROID && !UNITY_EDITOR private string GetAndroidPublicDownloadDirectory() { try { using (var environment = new AndroidJavaClass("android.os.Environment")) using (var downloadDir = environment.CallStatic( "getExternalStoragePublicDirectory", environment.GetStatic("DIRECTORY_DOWNLOADS"))) { string path = downloadDir?.Call("getAbsolutePath"); if (!string.IsNullOrEmpty(path)) { return path; } } } catch (Exception ex) { Debug.LogWarning($"获取Android公共Download目录失败,回退persistentDataPath: {ex.Message}"); } return Application.persistentDataPath; } #endif private void EnsureHistoryDirectoryExists() { EnsureHistoryDirectoryExists(historyFilePath, false); EnsureDirectoryExists(historyRecordsDirectoryPath); } private bool EnsureDirectoryExists(string directory) { try { if (string.IsNullOrEmpty(directory)) { return false; } if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); } return true; } catch (Exception ex) { Debug.LogWarning($"创建目录失败: {directory}, {ex.Message}"); return false; } } private bool EnsureHistoryDirectoryExists(string filePath, bool requestExternalPermission) { try { if (string.IsNullOrEmpty(filePath)) { return false; } string dir = Path.GetDirectoryName(filePath); #if UNITY_ANDROID && !UNITY_EDITOR if (requestExternalPermission) { EnsureAndroidDownloadPermission(dir); } #endif if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) { Directory.CreateDirectory(dir); } return true; } catch (Exception ex) { Debug.LogWarning($"创建历史记录目录失败: {filePath}, {ex.Message}"); return false; } } #if UNITY_ANDROID && !UNITY_EDITOR private void EnsureAndroidDownloadPermission(string directory) { if (string.IsNullOrEmpty(directory) || !directory.Contains("/Download")) { return; } try { using (var buildVersion = new AndroidJavaClass("android.os.Build$VERSION")) { int sdkInt = buildVersion.GetStatic("SDK_INT"); if (sdkInt >= 30 && !AndroidExportPermissionManager.HasManageExternalStoragePermission()) { Debug.LogWarning("BFI历史索引镜像保存到Download需要管理外部存储权限;主索引仍保存在应用内部目录"); if (!androidStoragePermissionRequested) { androidStoragePermissionRequested = true; AndroidExportPermissionManager.RequestManageExternalStoragePermission(); } } else if (sdkInt <= 29 && !AndroidExportPermissionManager.HasWriteExternalStoragePermission()) { Debug.LogWarning("BFI历史索引镜像保存到Download需要写入外部存储权限;主索引仍保存在应用内部目录"); if (!androidStoragePermissionRequested) { androidStoragePermissionRequested = true; AndroidExportPermissionManager.RequestWriteExternalStoragePermission(); } } } } catch (Exception ex) { Debug.LogWarning($"检查Download写入权限失败: {ex.Message}"); } } #endif private void Update() { // 自动保存 if (autoSave && Time.time - lastSaveTime > saveInterval) { if (!historyIndexDirty || SaveHistoryIndex()) { historyIndexDirty = false; } lastSaveTime = Time.time; } } /// /// 添加新的测试记录 /// public bool AddTestRecord(BFITestRecord record) { if (record == null) { Debug.LogWarning("尝试添加空的测试记录"); return false; } try { if (historyRecords == null) { historyRecords = new List(); } NormalizeRecordForStorage(record); // 检查是否已存在 var existingRecord = historyRecords.FirstOrDefault(r => r.TestId == record.TestId); if (existingRecord != null) { if (!ShouldReplaceRecord(existingRecord, record)) { bool existingPersisted = IsRecordPersisted(existingRecord.TestId, existingRecord.DataPoints?.Count ?? 0); if (!existingPersisted) { existingPersisted = SaveHistoryRecordFile(existingRecord); } SaveHistoryIndex(); Debug.Log($"忽略较旧的BFI记录快照: {record.TestName}"); return existingPersisted; } } // 关键原则:先写实体文件,写成功后才进入内存历史,避免界面显示“假记录”。 bool recordSaved = SaveHistoryRecordFile(record); if (!recordSaved) { Debug.LogError($"BFI记录实体文件保存失败,已拒绝加入内存历史: {record.TestName}"); return false; } if (existingRecord != null) { int index = historyRecords.IndexOf(existingRecord); historyRecords[index] = record; OnRecordUpdated?.Invoke(record); Debug.Log($"更新测试记录: {record.TestName}"); } else { // 添加新记录 historyRecords.Add(record); OnRecordAdded?.Invoke(record); Debug.Log($"添加新测试记录: {record.TestName}"); // 检查记录数量限制 if (historyRecords.Count > maxHistoryRecords) { RemoveOldestRecord(); } } // 刷新轻量索引。记录实体文件已经先保存,索引失败也不会丢失记录本体。 bool indexSaved = SaveHistoryIndex(); if (!indexSaved) { Debug.LogWarning($"BFI历史索引保存失败,但记录实体文件已保存: {record.TestName}"); } return recordSaved; } catch (Exception ex) { Debug.LogError($"添加测试记录失败: {ex.Message}"); return false; } } /// /// 保存自动记录中的中间快照。实时逐点日志已经承担断电恢复职责, /// 这里避免每次快照都重写索引和反读整条记录,降低安卓板主线程IO压力。 /// public bool AddTestRecordSnapshot(BFITestRecord record) { if (record == null) { return false; } try { if (historyRecords == null) { historyRecords = new List(); } NormalizeRecordForStorage(record); var existingRecord = historyRecords.FirstOrDefault(r => r.TestId == record.TestId); if (existingRecord != null && !ShouldReplaceRecord(existingRecord, record)) { return true; } bool recordSaved = SaveHistoryRecordFile(record, verifyAfterWrite: false, verboseLog: false); if (!recordSaved) { return false; } if (existingRecord != null) { int index = historyRecords.IndexOf(existingRecord); historyRecords[index] = record; OnRecordUpdated?.Invoke(record); } else { historyRecords.Add(record); OnRecordAdded?.Invoke(record); if (historyRecords.Count > maxHistoryRecords) { RemoveOldestRecord(); } } historyIndexDirty = true; return true; } catch (Exception ex) { Debug.LogWarning($"保存BFI记录快照失败: {record?.TestName}, {ex.Message}"); return false; } } /// /// 移除测试记录 /// public bool RemoveTestRecord(string testId) { try { var record = historyRecords.FirstOrDefault(r => r.TestId == testId); if (record != null) { historyRecords.Remove(record); DeleteHistoryRecordFile(record); OnRecordRemoved?.Invoke(testId); SaveHistoryIndex(); Debug.Log($"移除测试记录: {record.TestName}"); return true; } return false; } catch (Exception ex) { Debug.LogError($"移除测试记录失败: {ex.Message}"); return false; } } /// /// 获取所有历史记录 /// public List GetAllRecords() { return GetRecordsSortedByRecentFirst(historyRecords); } /// /// 根据ID获取记录 /// public BFITestRecord GetRecord(string testId) { return historyRecords.FirstOrDefault(r => r.TestId == testId); } public BFITestRecord GetFullRecord(string testId) { if (string.IsNullOrEmpty(testId)) { return null; } var cachedRecord = GetRecord(testId); if (cachedRecord != null && cachedRecord.DataPoints != null && cachedRecord.DataPoints.Count > 0) { return cachedRecord; } string defaultRecordFilePath = GetHistoryRecordFilePath(testId); string recordFilePath = !string.IsNullOrEmpty(cachedRecord?.FilePath) ? cachedRecord.FilePath : defaultRecordFilePath; BFITestRecord bestRecord = cachedRecord; if (TryReadHistoryRecord(recordFilePath, out var record)) { bestRecord = ChooseRecordWithMoreData(bestRecord, record); if (HasDataPoints(record)) { AddOrReplaceRecoveredRecordToList(historyRecords, record); return record; } } if (!string.Equals(recordFilePath, defaultRecordFilePath, StringComparison.OrdinalIgnoreCase) && TryReadHistoryRecord(defaultRecordFilePath, out record)) { bestRecord = ChooseRecordWithMoreData(bestRecord, record); if (HasDataPoints(record)) { AddOrReplaceRecoveredRecordToList(historyRecords, record); return record; } } if (TryFindBestHistoryRecordById(testId, out var recoveredRecord, out var recoveredPath)) { recoveredRecord.FilePath = recoveredPath; AddOrReplaceRecoveredRecordToList(historyRecords, recoveredRecord); if (!string.Equals(recoveredPath, defaultRecordFilePath, StringComparison.OrdinalIgnoreCase)) { SaveHistoryRecordFile(recoveredRecord, verifyAfterWrite: true, verboseLog: false); } Debug.Log($"已为BFI导出恢复完整记录: {recoveredRecord.TestName}, 点数: {recoveredRecord.DataPoints?.Count ?? 0}"); return recoveredRecord; } if (bestRecord != null) { AddOrReplaceRecoveredRecordToList(historyRecords, bestRecord); } Debug.LogWarning($"未找到完整BFI记录明细: ID={testId}, 文件={recordFilePath}"); return bestRecord; } /// /// 根据日期范围获取记录 /// public List GetRecordsByDateRange(DateTime startDate, DateTime endDate) { return GetRecordsSortedByRecentFirst(historyRecords.Where(r => r.StartTime >= startDate && r.StartTime <= endDate)); } /// /// 根据患者ID获取记录 /// public List GetRecordsByPatient(string patientId) { return GetRecordsSortedByRecentFirst(historyRecords.Where(r => r.PatientId == patientId)); } /// /// 搜索记录 /// public List SearchRecords(string keyword) { if (string.IsNullOrEmpty(keyword)) return GetAllRecords(); keyword = keyword.ToLower(); return GetRecordsSortedByRecentFirst(historyRecords.Where(r => r.TestName.ToLower().Contains(keyword) || r.PatientName?.ToLower().Contains(keyword) == true || r.PatientId?.ToLower().Contains(keyword) == true )); } /// /// 获取最近的记录 /// public List GetRecentRecords(int count = 10) { return GetRecordsSortedByRecentFirst(historyRecords).Take(count).ToList(); } private List GetRecordsSortedByRecentFirst(IEnumerable records) { return (records ?? Enumerable.Empty()) .Where(r => r != null) .OrderByDescending(r => r.StartTime) .ThenByDescending(r => r.EndTime) .ToList(); } /// /// 清空历史记录 /// public void ClearHistory() { try { historyRecords.Clear(); historyFileLoadFailed = false; historyIndexDirty = false; DeleteAllHistoryStorageFiles(); SaveHistoryRecords(); OnHistoryLoaded?.Invoke(); Debug.Log("历史记录已清空"); } catch (Exception ex) { Debug.LogError($"清空历史记录失败: {ex.Message}"); } } /// /// 保存历史记录到文件 /// public bool SaveHistoryRecords() { try { if (historyFileLoadFailed && (historyRecords == null || historyRecords.Count == 0)) { Debug.LogWarning("历史文件加载失败,跳过空历史保存,避免覆盖已有索引"); return false; } EnsureHistoryDirectoryExists(); bool allRecordsSaved = true; if (historyRecords != null) { foreach (var record in historyRecords) { if (record == null) { continue; } NormalizeRecordForStorage(record); int pointCount = record.DataPoints?.Count ?? 0; if (!IsRecordPersisted(record.TestId, pointCount)) { allRecordsSaved &= SaveHistoryRecordFile(record); } } } bool indexSaved = SaveHistoryIndex(); historyFileLoadFailed = false; // LastSaveSucceeded = true; Debug.Log($"历史记录已保存,共 {historyRecords?.Count ?? 0} 条记录,单条文件目录: {historyRecordsDirectoryPath}"); return allRecordsSaved && indexSaved; } catch (Exception ex) { // LastSaveSucceeded = false; Debug.LogError($"保存历史记录失败: {ex.Message}"); return false; } } private bool SaveHistoryIndex() { try { EnsureHistoryDirectoryExists(); var indexEntries = new List(); if (historyRecords != null) { foreach (var record in historyRecords.Where(r => r != null)) { NormalizeRecordForStorage(record); indexEntries.Add(CreateIndexEntry(record)); } } var wrapper = new BFIHistoryWrapper { Version = HISTORY_INDEX_VERSION, StorageMode = HISTORY_STORAGE_MODE_PER_RECORD, Records = new List(), RecordFiles = indexEntries }; string json = JsonUtility.ToJson(wrapper, true); WriteAllTextAtomic(historyFilePath, json); historyIndexDirty = false; return true; } catch (Exception ex) { Debug.LogError($"保存BFI历史索引失败: {ex.Message}"); return false; } } private BFIHistoryRecordIndexEntry CreateIndexEntry(BFITestRecord record) { string defaultFilePath = GetHistoryRecordFilePath(record); string filePath = string.IsNullOrEmpty(record.FilePath) ? defaultFilePath : record.FilePath; string fileName = GetIndexEntryFileName(filePath, defaultFilePath); int pointCount = record.DataPoints?.Count ?? 0; bool summaryOnlyRecord = pointCount == 0 && (record.Statistics?.Count ?? 0) > 0; if (summaryOnlyRecord) { pointCount = record.Statistics.Count; } return new BFIHistoryRecordIndexEntry { TestId = record.TestId, TestName = record.TestName, PatientId = record.PatientId, PatientName = record.PatientName, FileName = fileName, StartTimeTicks = record.StartTimeTicks, EndTimeTicks = record.EndTimeTicks, PointCount = pointCount, MinValue = record.Statistics?.MinValue ?? 0f, MaxValue = record.Statistics?.MaxValue ?? 0f, Average = record.Statistics?.Average ?? 0f }; } private string GetIndexEntryFileName(string filePath, string defaultFilePath) { try { if (string.IsNullOrEmpty(filePath)) { return Path.GetFileName(defaultFilePath); } string directory = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directory) && !string.IsNullOrEmpty(historyRecordsDirectoryPath) && string.Equals( Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), Path.GetFullPath(historyRecordsDirectoryPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) { return Path.GetFileName(filePath); } return Path.IsPathRooted(filePath) ? filePath : Path.GetFileName(filePath); } catch { return Path.GetFileName(defaultFilePath); } } private bool SaveHistoryRecordFile(BFITestRecord record, bool verifyAfterWrite = true, bool verboseLog = true) { try { if (record == null) { return false; } NormalizeRecordForStorage(record); string filePath = GetHistoryRecordFilePath(record); record.FilePath = filePath; if ((record.DataPoints == null || record.DataPoints.Count == 0) && (record.Statistics?.Count ?? 0) > 0) { if (TryFindBestHistoryRecordById(record.TestId, out var recoveredRecord, out var recoveredPath)) { recoveredRecord.FilePath = recoveredPath; MergeRecordMetadata(recoveredRecord, record); if (!string.Equals(recoveredPath, filePath, StringComparison.OrdinalIgnoreCase)) { return SaveHistoryRecordFile(recoveredRecord, verifyAfterWrite, verboseLog); } MergeRecordMetadata(record, recoveredRecord); return true; } Debug.LogWarning($"跳过保存仅摘要BFI记录实体文件,避免覆盖明细数据: {record.TestName}, {filePath}"); return IsRecordPersisted(record.TestId, 1); } if (TryPromoteExistingTempRecordFile(record, filePath)) { return true; } string json = JsonUtility.ToJson(record, true); WriteAllTextAtomic(filePath, json); if (!verifyAfterWrite) { return true; } bool persisted = IsRecordPersisted(record.TestId, record.DataPoints?.Count ?? 0); if (!persisted) { Debug.LogWarning($"BFI记录写入后校验失败: {record.TestName}, {filePath}"); } else if (verboseLog) { Debug.Log($"BFI记录实体文件已保存: {filePath}"); #if UNITY_ANDROID && !UNITY_EDITOR AndroidFileLogger.Instance?.LogInfo("BFIHistory", $"记录实体文件已保存: {filePath}, 点数: {record.DataPoints?.Count ?? 0}"); #endif } return persisted; } catch (Exception ex) { Debug.LogError($"保存BFI单条历史记录失败: {record?.TestName}, {ex.Message}"); return false; } } private bool TryPromoteExistingTempRecordFile(BFITestRecord record, string filePath) { try { if (record == null || string.IsNullOrEmpty(filePath)) { return false; } string tempPath = filePath + ".tmp"; if (!File.Exists(tempPath)) { return false; } if (!TryReadHistoryRecordFile(tempPath, out var tempRecord) || !string.Equals(tempRecord.TestId, record.TestId, StringComparison.Ordinal)) { return false; } int tempPointCount = tempRecord.DataPoints?.Count ?? 0; int recordPointCount = record.DataPoints?.Count ?? 0; if (tempPointCount < recordPointCount) { return false; } if (File.Exists(filePath)) { if (IsRecordPersisted(record.TestId, recordPointCount)) { return false; } TryCopyFile(filePath, filePath + RECORD_BACKUP_EXTENSION); File.Copy(tempPath, filePath, true); } else { File.Move(tempPath, filePath); } bool persisted = IsRecordPersisted(record.TestId, recordPointCount); if (persisted) { DeleteFileIfExists(tempPath); Debug.Log($"已将断电遗留BFI临时记录转正: {filePath}"); #if UNITY_ANDROID && !UNITY_EDITOR AndroidFileLogger.Instance?.LogInfo("BFIHistory", $"已将断电遗留BFI临时记录转正: {filePath}"); #endif } return persisted; } catch (Exception ex) { Debug.LogWarning($"转正BFI临时记录失败,将尝试重新写入: {filePath}, {ex.Message}"); return false; } } public bool IsRecordPersisted(string testId, int minPointCount = 0) { try { if (string.IsNullOrEmpty(testId)) { return false; } string filePath = GetHistoryRecordFilePath(testId); if (!TryReadHistoryRecord(filePath, out var savedRecord)) { return false; } if (!string.Equals(savedRecord.TestId, testId, StringComparison.Ordinal)) { return false; } return (savedRecord.DataPoints?.Count ?? 0) >= minPointCount; } catch (Exception ex) { Debug.LogWarning($"确认BFI记录持久化失败: {testId}, {ex.Message}"); return false; } } private bool ShouldReplaceRecord(BFITestRecord existingRecord, BFITestRecord candidateRecord) { if (existingRecord == null) { return true; } if (candidateRecord == null) { return false; } NormalizeRecordForStorage(existingRecord); NormalizeRecordForStorage(candidateRecord); int existingPointCount = existingRecord.DataPoints?.Count ?? 0; int candidatePointCount = candidateRecord.DataPoints?.Count ?? 0; if (candidatePointCount != existingPointCount) { return candidatePointCount > existingPointCount; } return candidateRecord.EndTime >= existingRecord.EndTime; } private bool HasDataPoints(BFITestRecord record) { return record?.DataPoints != null && record.DataPoints.Count > 0; } private BFITestRecord ChooseRecordWithMoreData(BFITestRecord current, BFITestRecord candidate) { if (current == null) { return candidate; } if (candidate == null) { return current; } int currentPointCount = current.DataPoints?.Count ?? 0; int candidatePointCount = candidate.DataPoints?.Count ?? 0; if (candidatePointCount != currentPointCount) { return candidatePointCount > currentPointCount ? candidate : current; } int currentSummaryCount = current.Statistics?.Count ?? 0; int candidateSummaryCount = candidate.Statistics?.Count ?? 0; if (candidateSummaryCount != currentSummaryCount) { return candidateSummaryCount > currentSummaryCount ? candidate : current; } return candidate.EndTime > current.EndTime ? candidate : current; } private bool TryFindBestHistoryRecordById(string testId, out BFITestRecord bestRecord, out string bestRecordPath) { bestRecord = null; bestRecordPath = null; if (string.IsNullOrEmpty(testId)) { return false; } try { int bestPointCount = 0; foreach (var recordFilePath in FindHistoryRecordFiles(true)) { if (!TryReadHistoryRecordFile(recordFilePath, out var candidate) || !string.Equals(candidate.TestId, testId, StringComparison.Ordinal)) { continue; } int candidatePointCount = candidate.DataPoints?.Count ?? 0; if (candidatePointCount <= 0) { continue; } if (bestRecord == null || candidatePointCount > bestPointCount) { bestRecord = candidate; bestRecordPath = recordFilePath; bestPointCount = candidatePointCount; } } } catch (Exception ex) { Debug.LogWarning($"搜索完整BFI记录失败: {testId}, {ex.Message}"); bestRecord = null; bestRecordPath = null; } return bestRecord != null; } private bool AreAllHistoryRecordsPersistedAsFiles() { if (historyRecords == null || historyRecords.Count == 0) { return true; } foreach (var record in historyRecords) { if (record == null) { continue; } NormalizeRecordForStorage(record); if (!IsRecordPersisted(record.TestId, record.DataPoints?.Count ?? 0)) { return false; } } return true; } private void NormalizeRecordForStorage(BFITestRecord record) { if (record == null) { return; } if (record.DataPoints == null) { record.DataPoints = new List(); } if (record.Statistics == null) { record.Statistics = new BFIStatistics(); } bool hasSummaryStatisticsWithoutPoints = record.DataPoints.Count == 0 && record.Statistics.Count > 0; BFIStatistics preservedSummaryStatistics = hasSummaryStatisticsWithoutPoints ? CloneBFIStatistics(record.Statistics) : null; record.RestoreAfterDeserialization(); if (hasSummaryStatisticsWithoutPoints && record.DataPoints.Count == 0) { record.Statistics = preservedSummaryStatistics; } if (string.IsNullOrWhiteSpace(record.TestId)) { record.TestId = GenerateStableRecordId(record); } if (string.IsNullOrWhiteSpace(record.TestName)) { record.TestName = $"BFI测试_{record.StartTime:yyyyMMdd_HHmmss}"; } record.PrepareForSerialization(); } private BFIStatistics CloneBFIStatistics(BFIStatistics source) { if (source == null) { return new BFIStatistics(); } return new BFIStatistics { Count = source.Count, MinValue = source.MinValue, MaxValue = source.MaxValue, Average = source.Average, StandardDeviation = source.StandardDeviation, Duration = source.Duration, LastUpdated = source.LastUpdated }; } private string GenerateStableRecordId(BFITestRecord record) { long startTicks = record.StartTimeTicks > 0 ? record.StartTimeTicks : record.StartTime.Ticks; long endTicks = record.EndTimeTicks; int pointCount = record.DataPoints?.Count ?? 0; long firstPointTicks = 0; long lastPointTicks = 0; float firstBfi = 0f; float lastBfi = 0f; if (record.DataPoints != null && record.DataPoints.Count > 0) { var firstPoint = record.DataPoints.FirstOrDefault(p => p != null); var lastPoint = record.DataPoints.LastOrDefault(p => p != null); firstPointTicks = firstPoint?.TimestampTicks ?? 0; lastPointTicks = lastPoint?.TimestampTicks ?? 0; firstBfi = firstPoint?.BFI ?? 0f; lastBfi = lastPoint?.BFI ?? 0f; } string identity = string.Join("|", record.TestName ?? string.Empty, record.PatientId ?? string.Empty, record.PatientName ?? string.Empty, startTicks.ToString(), endTicks.ToString(), pointCount.ToString(), firstPointTicks.ToString(), lastPointTicks.ToString(), firstBfi.ToString("R"), lastBfi.ToString("R")); return $"legacy_{ComputeStableHash(identity):x16}"; } private ulong ComputeStableHash(string value) { unchecked { const ulong offset = 14695981039346656037UL; const ulong prime = 1099511628211UL; ulong hash = offset; foreach (char c in value ?? string.Empty) { hash ^= c; hash *= prime; } return hash; } } private string GetHistoryRecordFilePath(BFITestRecord record) { NormalizeRecordForStorage(record); return GetHistoryRecordFilePath(record.TestId); } private string GetHistoryRecordFilePath(string testId) { if (string.IsNullOrEmpty(historyRecordsDirectoryPath)) { historyRecordsDirectoryPath = GetRecordFilesDirectory(historyFilePath); } string safeId = SanitizeFileComponent(string.IsNullOrEmpty(testId) ? Guid.NewGuid().ToString() : testId); return Path.Combine(historyRecordsDirectoryPath, $"{safeId}.json"); } private string SanitizeFileComponent(string value) { if (string.IsNullOrEmpty(value)) { return "unknown"; } var invalidChars = new HashSet(Path.GetInvalidFileNameChars()); var builder = new StringBuilder(value.Length); foreach (char c in value) { if (invalidChars.Contains(c)) { builder.Append('_'); } else { builder.Append(c); } } return builder.Length == 0 ? "unknown" : builder.ToString(); } private void DeleteHistoryRecordFile(BFITestRecord record) { if (record == null || string.IsNullOrEmpty(record.TestId)) { return; } DeleteHistoryRecordFile(record.TestId); } private void DeleteHistoryRecordFile(string testId) { string filePath = GetHistoryRecordFilePath(testId); DeleteFileIfExists(filePath); DeleteFileIfExists(filePath + RECORD_BACKUP_EXTENSION); DeleteFileIfExists(filePath + ".tmp"); } private void AddRecordsToMergeMap(Dictionary recordsById, IEnumerable records) { if (records == null) { return; } foreach (var record in records) { AddOrReplaceRecoveredRecord(recordsById, record); } } private void DeleteAllHistoryStorageFiles() { var pathsToDelete = new HashSet(StringComparer.OrdinalIgnoreCase); AddHistoryFileDeleteTargets(pathsToDelete, historyFilePath); AddHistoryFileDeleteTargets(pathsToDelete, historyMirrorFilePath); var indexPaths = new List(); AddIndexPath(indexPaths, historyFilePath); AddIndexPath(indexPaths, historyMirrorFilePath); foreach (var indexPath in FindHistoryIndexFiles(true)) { AddIndexPath(indexPaths, indexPath); AddHistoryFileDeleteTargets(pathsToDelete, indexPath); } foreach (var filePath in pathsToDelete) { DeleteFileIfExists(filePath); } foreach (var indexPath in indexPaths) { DeleteRecordDirectoryForIndex(indexPath); } DeleteAllRecordDirectoriesFromSearchRoots(); } private void AddIndexPath(List indexPaths, string indexPath) { if (string.IsNullOrEmpty(indexPath)) { return; } if (!indexPaths.Any(path => string.Equals(path, indexPath, StringComparison.OrdinalIgnoreCase))) { indexPaths.Add(indexPath); } } private void AddHistoryFileDeleteTargets(HashSet pathsToDelete, string indexFilePath) { if (string.IsNullOrEmpty(indexFilePath)) { return; } pathsToDelete.Add(indexFilePath); pathsToDelete.Add(indexFilePath + ".tmp"); pathsToDelete.Add(indexFilePath + RECORD_BACKUP_EXTENSION); string directory = Path.GetDirectoryName(indexFilePath); if (!string.IsNullOrEmpty(directory)) { pathsToDelete.Add(Path.Combine(directory, HISTORY_BACKUP_FILE_NAME)); pathsToDelete.Add(Path.Combine(directory, CURRENT_RECORDING_JOURNAL_FILE_NAME)); } } private void DeleteRecordDirectoryForIndex(string indexFilePath) { try { string recordsDirectory = GetRecordFilesDirectory(indexFilePath); if (!string.IsNullOrEmpty(recordsDirectory) && Directory.Exists(recordsDirectory)) { Directory.Delete(recordsDirectory, true); Debug.Log($"已删除BFI单条记录目录: {recordsDirectory}"); } } catch (Exception ex) { Debug.LogWarning($"删除BFI单条记录目录失败: {indexFilePath}, {ex.Message}"); } } private void DeleteAllRecordDirectoriesFromSearchRoots() { foreach (var root in GetHistoryIndexSearchRoots()) { DeleteRecordDirectory(root); TryDeleteRecordDirectoriesOneLevel(root); } } private void TryDeleteRecordDirectoriesOneLevel(string root) { try { if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) { return; } foreach (var directory in Directory.GetDirectories(root)) { DeleteRecordDirectory(directory); } } catch (Exception ex) { Debug.LogWarning($"搜索待删除BFI单条记录目录失败: {root}, {ex.Message}"); } } private void DeleteRecordDirectory(string root) { try { if (string.IsNullOrEmpty(root)) { return; } string recordsDirectory = Path.Combine(root, HISTORY_RECORDS_DIR_NAME); if (Directory.Exists(recordsDirectory)) { Directory.Delete(recordsDirectory, true); Debug.Log($"已删除BFI单条记录目录: {recordsDirectory}"); } } catch (Exception ex) { Debug.LogWarning($"删除BFI单条记录目录失败: {root}, {ex.Message}"); } } private void DeleteFileIfExists(string filePath) { try { if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath)) { File.Delete(filePath); Debug.Log($"已删除BFI历史文件: {filePath}"); } } catch (Exception ex) { Debug.LogWarning($"删除BFI历史文件失败: {filePath}, {ex.Message}"); } } private void WriteAllTextAtomic(string filePath, string content) { string directory = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } string tempPath = filePath + ".tmp"; string backupPath = filePath + RECORD_BACKUP_EXTENSION; byte[] bytes = Encoding.UTF8.GetBytes(content ?? string.Empty); EnsureEnoughFreeSpace(filePath, bytes.Length); DeleteFileIfExists(tempPath); using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.Read, 4096)) { stream.Write(bytes, 0, bytes.Length); FlushFileStream(stream); } if (File.Exists(filePath)) { try { TryCopyFile(filePath, backupPath); File.Delete(filePath); } catch (Exception ex) { Debug.LogWarning($"备份旧BFI历史文件失败,将继续尝试覆盖: {filePath}, {ex.Message}"); DeleteFileIfExists(filePath); } } File.Move(tempPath, filePath); } private void FlushFileStream(FileStream stream) { try { stream.Flush(true); } catch { stream.Flush(); } } private void TryCopyFile(string sourcePath, string targetPath) { try { if (!string.IsNullOrEmpty(sourcePath) && File.Exists(sourcePath)) { File.Copy(sourcePath, targetPath, true); } } catch (Exception ex) { Debug.LogWarning($"备份BFI历史文件失败: {sourcePath}, {ex.Message}"); } } private void EnsureEnoughFreeSpace(string filePath, long requiredBytes) { #if UNITY_ANDROID && !UNITY_EDITOR // Android 的 /storage/emulated/0 路径在部分系统上通过 DriveInfo 会返回 0MB, // 但实际 FileStream 仍然可以写入。这里不做硬拦截,真实空间不足交给实际写入报错。 return; #else try { string root = Path.GetPathRoot(Path.GetFullPath(filePath)); if (string.IsNullOrEmpty(root)) { return; } var drive = new DriveInfo(root); const long safetyReserveBytes = 10L * 1024L * 1024L; long availableFreeSpace = drive.AvailableFreeSpace; if (availableFreeSpace <= 0) { Debug.LogWarning($"无法可靠获取BFI历史存储剩余空间,将继续尝试写入: {filePath}"); return; } if (drive.IsReady && availableFreeSpace < requiredBytes + safetyReserveBytes) { throw new IOException($"BFI历史存储空间不足,剩余 {availableFreeSpace / 1024 / 1024}MB,需要至少 {(requiredBytes + safetyReserveBytes) / 1024 / 1024}MB"); } } catch (IOException) { throw; } catch (Exception ex) { Debug.LogWarning($"检查BFI历史存储空间失败,将继续尝试写入: {ex.Message}"); } #endif } private string GetRecordFilesDirectory(string indexFilePath) { if (string.IsNullOrEmpty(indexFilePath)) { return null; } string indexDirectory = Path.GetDirectoryName(indexFilePath); return string.IsNullOrEmpty(indexDirectory) ? null : Path.Combine(indexDirectory, HISTORY_RECORDS_DIR_NAME); } /// /// 从文件加载历史记录 /// public void LoadHistoryRecords(bool notifyListeners = true, bool recoverMissingIndex = true) { try { maxHistoryRecords = Mathf.Max(maxHistoryRecords, MIN_HISTORY_RECORD_LIMIT); if (!File.Exists(historyFilePath)) { historyRecords = SafeRecoverHistoryRecordsFromIndexes(null, recoverMissingIndex); historyFileLoadFailed = false; Debug.Log($"历史索引文件不存在,{(historyRecords.Count > 0 ? "已从单条记录文件恢复" : "创建新列表")}"); if (historyRecords.Count > 0) { SaveHistoryRecords(); } if (notifyListeners) { OnHistoryLoaded?.Invoke(); } return; } if (TryReadHistoryRecords(historyFilePath, out var loadedRecords)) { historyRecords = loadedRecords; historyFileLoadFailed = false; Debug.Log($"历史索引已加载,共 {historyRecords.Count} 条记录"); } else { if (historyRecords == null) { historyRecords = new List(); } historyFileLoadFailed = false; Debug.Log($"历史索引为空或无法解析,保留内存记录 {historyRecords.Count} 条"); } int beforeCount = historyRecords.Count; historyRecords = SafeRecoverHistoryRecordsFromIndexes(historyRecords, recoverMissingIndex); if (historyRecords.Count != beforeCount || !AreAllHistoryRecordsPersistedAsFiles()) { Debug.Log($"历史记录文件恢复完成,恢复前 {beforeCount} 条,恢复后 {historyRecords.Count} 条"); SaveHistoryRecords(); } if (notifyListeners) { OnHistoryLoaded?.Invoke(); } } catch (Exception ex) { Debug.LogError($"加载历史记录失败: {ex.Message}"); historyRecords = SafeRecoverHistoryRecordsFromIndexes(null, recoverMissingIndex); historyFileLoadFailed = historyRecords.Count == 0; if (historyRecords.Count > 0) { SaveHistoryRecords(); if (notifyListeners) { OnHistoryLoaded?.Invoke(); } } } } private List SafeRecoverHistoryRecordsFromIndexes(List baseRecords, bool includeSearchRoots) { try { return RecoverHistoryRecordsFromIndexes(baseRecords, includeSearchRoots); } catch (Exception ex) { Debug.LogError($"恢复BFI历史索引失败: {ex.Message}"); return baseRecords != null ? new List(baseRecords) : new List(); } } private List RecoverHistoryRecordsFromIndexes(List baseRecords, bool includeSearchRoots) { var mergedRecords = new Dictionary(); if (baseRecords != null) { foreach (var record in baseRecords) { AddOrReplaceRecoveredRecord(mergedRecords, record); } } foreach (var indexPath in FindHistoryIndexFiles(includeSearchRoots)) { if (!TryReadHistoryRecords(indexPath, out var recoveredRecords)) { continue; } foreach (var record in recoveredRecords) { AddOrReplaceRecoveredRecord(mergedRecords, record); } } foreach (var recordFilePath in FindHistoryRecordFiles(includeSearchRoots)) { if (TryReadHistoryRecord(recordFilePath, out var recoveredRecord)) { AddOrReplaceRecoveredRecord(mergedRecords, recoveredRecord); } } return mergedRecords.Values .OrderByDescending(r => r.StartTime) .Take(maxHistoryRecords) .OrderBy(r => r.StartTime) .ToList(); } private bool TryReadHistoryRecords(string filePath, out List records) { records = new List(); try { if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { return false; } string json = File.ReadAllText(filePath); if (string.IsNullOrWhiteSpace(json)) { return false; } var wrapper = JsonUtility.FromJson(json); if (wrapper == null) { return false; } bool recognizedIndex = false; if (wrapper.Records != null) { recognizedIndex = true; foreach (var record in wrapper.Records.Where(record => record != null)) { NormalizeRecordForStorage(record); records.Add(record); } } if (wrapper.RecordFiles != null) { recognizedIndex = true; foreach (var entry in wrapper.RecordFiles.Where(entry => entry != null)) { string recordPath = ResolveIndexedRecordPath(filePath, entry); if (TryReadHistoryRecord(recordPath, out var record)) { AddOrReplaceRecoveredRecordToList(records, record); } } } if (!recognizedIndex) { return false; } Debug.Log($"读取BFI历史索引成功: {filePath}, 记录数: {records.Count}"); return true; } catch (Exception ex) { Debug.LogWarning($"读取BFI历史索引失败: {filePath}, {ex.Message}"); records = new List(); return false; } } private bool TryReadHistoryRecordSummaries(string filePath, out List records, out bool repairedSummaries) { records = new List(); repairedSummaries = false; try { if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { return false; } string json = File.ReadAllText(filePath); if (string.IsNullOrWhiteSpace(json)) { return false; } var wrapper = JsonUtility.FromJson(json); if (wrapper == null) { return false; } if (wrapper.RecordFiles != null && wrapper.RecordFiles.Count > 0) { foreach (var entry in wrapper.RecordFiles.Where(entry => entry != null)) { var summary = CreateSummaryRecordFromIndexEntry(entry, filePath); if (summary != null) { if (NeedsSummaryRepair(summary) && TryRepairSummaryFromRecordFile(summary, out var repairedSummary)) { summary = repairedSummary; repairedSummaries = true; } AddOrReplaceRecoveredRecordToList(records, summary); } } Debug.Log($"读取BFI历史摘要成功: {filePath}, 记录数: {records.Count}"); return true; } if (wrapper.Records != null) { foreach (var record in wrapper.Records.Where(record => record != null)) { NormalizeRecordForStorage(record); records.Add(record); } Debug.Log($"读取旧版BFI历史摘要成功: {filePath}, 记录数: {records.Count}"); return true; } return false; } catch (Exception ex) { Debug.LogWarning($"读取BFI历史摘要失败: {filePath}, {ex.Message}"); records = new List(); repairedSummaries = false; return false; } } private bool NeedsSummaryRepair(BFITestRecord summary) { if (summary == null) { return false; } if (summary.DataPoints != null && summary.DataPoints.Count > 0) { return false; } var stats = summary.Statistics; if (stats == null || stats.Count <= 0) { return true; } return Mathf.Approximately(stats.MinValue, 0f) && Mathf.Approximately(stats.MaxValue, 0f) && Mathf.Approximately(stats.Average, 0f); } private bool TryRepairSummaryFromRecordFile(BFITestRecord summary, out BFITestRecord repairedSummary) { repairedSummary = null; string recordPath = summary?.FilePath; if (string.IsNullOrEmpty(recordPath)) { return false; } if (!TryReadHistoryRecord(recordPath, out var fullRecord) || fullRecord?.DataPoints == null || fullRecord.DataPoints.Count == 0) { return false; } fullRecord.FilePath = recordPath; repairedSummary = CreateSummaryRecordFromFullRecord(fullRecord); Debug.Log($"已从实体文件修复BFI历史摘要: {fullRecord.TestName}, 点数: {fullRecord.DataPoints.Count}"); return true; } private BFITestRecord CreateSummaryRecordFromFullRecord(BFITestRecord fullRecord) { if (fullRecord == null) { return null; } NormalizeRecordForStorage(fullRecord); return new BFITestRecord { TestId = fullRecord.TestId, TestName = fullRecord.TestName, PatientId = fullRecord.PatientId ?? string.Empty, PatientName = fullRecord.PatientName ?? string.Empty, PatientGender = fullRecord.PatientGender ?? string.Empty, PatientAge = fullRecord.PatientAge, PatientHeight = fullRecord.PatientHeight, PatientWeight = fullRecord.PatientWeight, StartTime = fullRecord.StartTime, StartTimeTicks = fullRecord.StartTimeTicks, EndTime = fullRecord.EndTime, EndTimeTicks = fullRecord.EndTimeTicks, FilePath = fullRecord.FilePath, DataPoints = new List(), Statistics = CloneBFIStatistics(fullRecord.Statistics) }; } private BFITestRecord CreateSummaryRecordFromIndexEntry(BFIHistoryRecordIndexEntry entry, string indexFilePath) { if (entry == null) { return null; } long startTicks = entry.StartTimeTicks > 0 ? entry.StartTimeTicks : DateTime.Now.Ticks; long endTicks = entry.EndTimeTicks > 0 ? entry.EndTimeTicks : 0; var startTime = new DateTime(startTicks, DateTimeKind.Local); var endTime = endTicks > 0 ? new DateTime(endTicks, DateTimeKind.Local) : DateTime.MinValue; var record = new BFITestRecord { TestId = string.IsNullOrEmpty(entry.TestId) ? Guid.NewGuid().ToString() : entry.TestId, TestName = string.IsNullOrEmpty(entry.TestName) ? $"BFI测试_{startTime:yyyyMMdd_HHmmss}" : entry.TestName, PatientId = entry.PatientId ?? string.Empty, PatientName = entry.PatientName ?? string.Empty, StartTime = startTime, StartTimeTicks = startTicks, EndTime = endTime, EndTimeTicks = endTicks, FilePath = ResolveIndexedRecordPath(indexFilePath, entry), DataPoints = new List(), Statistics = new BFIStatistics { Count = entry.PointCount, MinValue = entry.MinValue, MaxValue = entry.MaxValue, Average = entry.Average, Duration = endTime > startTime ? (endTime - startTime).TotalMinutes : 0, LastUpdated = DateTime.Now } }; return record; } private void AddOrReplaceRecoveredRecordToList(List records, BFITestRecord record) { if (records == null || record == null) { return; } NormalizeRecordForStorage(record); var existingRecord = records.FirstOrDefault(r => r != null && r.TestId == record.TestId); if (existingRecord == null) { records.Add(record); return; } if (ShouldReplaceRecord(existingRecord, record)) { int index = records.IndexOf(existingRecord); records[index] = record; return; } MergeRecordMetadata(existingRecord, record); } private void MergeRecordMetadata(BFITestRecord target, BFITestRecord source) { if (target == null || source == null) { return; } if (string.IsNullOrEmpty(target.FilePath) && !string.IsNullOrEmpty(source.FilePath)) { target.FilePath = source.FilePath; } bool targetHasFullData = target.DataPoints != null && target.DataPoints.Count > 0; bool sourceHasSummaryStats = source.Statistics != null && source.Statistics.Count > 0; if (!targetHasFullData && sourceHasSummaryStats) { target.Statistics = CloneBFIStatistics(source.Statistics); } } private string ResolveIndexedRecordPath(string indexFilePath, BFIHistoryRecordIndexEntry entry) { if (entry == null) { return null; } if (!string.IsNullOrEmpty(entry.FileName) && Path.IsPathRooted(entry.FileName)) { return entry.FileName; } string recordsDirectory = GetRecordFilesDirectory(indexFilePath); if (string.IsNullOrEmpty(recordsDirectory)) { recordsDirectory = historyRecordsDirectoryPath; } string fileName = entry.FileName; if (string.IsNullOrEmpty(fileName)) { fileName = $"{SanitizeFileComponent(entry.TestId)}.json"; } return Path.Combine(recordsDirectory, fileName); } private bool TryReadHistoryRecord(string filePath, out BFITestRecord record) { if (TryReadHistoryRecordFile(filePath, out record)) { return true; } if (!string.IsNullOrEmpty(filePath) && !filePath.EndsWith(RECORD_BACKUP_EXTENSION, StringComparison.OrdinalIgnoreCase)) { string backupPath = filePath + RECORD_BACKUP_EXTENSION; if (TryReadHistoryRecordFile(backupPath, out record)) { Debug.LogWarning($"使用BFI单条记录备份恢复: {backupPath}"); return true; } } return false; } private bool TryReadHistoryRecordFile(string filePath, out BFITestRecord record) { record = null; try { if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { return false; } string json = File.ReadAllText(filePath); if (string.IsNullOrWhiteSpace(json)) { return false; } record = JsonUtility.FromJson(json); if (record == null) { return false; } record.RestoreAfterDeserialization(); NormalizeRecordForStorage(record); return true; } catch (Exception ex) { Debug.LogWarning($"读取BFI单条记录失败: {filePath}, {ex.Message}"); record = null; return false; } } private void AddOrReplaceRecoveredRecord(Dictionary recordsById, BFITestRecord record) { if (record == null) { return; } NormalizeRecordForStorage(record); if (!recordsById.TryGetValue(record.TestId, out var existingRecord)) { recordsById[record.TestId] = record; return; } if (ShouldReplaceRecord(existingRecord, record)) { recordsById[record.TestId] = record; return; } MergeRecordMetadata(existingRecord, record); } private List FindHistoryIndexFiles(bool includeSearchRoots) { var result = new List(); var visited = new HashSet(StringComparer.OrdinalIgnoreCase); AddHistoryIndexFilesFromRoot(Path.GetDirectoryName(historyFilePath), 0, result, visited); if (includeSearchRoots) { foreach (var root in GetHistoryIndexSearchRoots()) { AddHistoryIndexFilesFromRoot(root, 2, result, visited); } } Debug.Log($"BFI历史索引搜索完成,找到 {result.Count} 个候选文件"); return result; } private List FindHistoryRecordFiles(bool includeSearchRoots) { var result = new List(); var visited = new HashSet(StringComparer.OrdinalIgnoreCase); AddHistoryRecordFilesFromDirectory(historyRecordsDirectoryPath, result, visited); if (includeSearchRoots) { foreach (var root in GetHistoryIndexSearchRoots()) { AddHistoryRecordFilesFromDirectory(Path.Combine(root, HISTORY_RECORDS_DIR_NAME), result, visited); } } Debug.Log($"BFI单条历史记录搜索完成,找到 {result.Count} 个候选文件"); return result; } private void AddHistoryRecordFilesFromDirectory(string directory, List result, HashSet visited) { if (string.IsNullOrEmpty(directory)) { return; } try { if (!Directory.Exists(directory)) { return; } string fullDirectory = Path.GetFullPath(directory); if (!visited.Add(fullDirectory)) { return; } AddHistoryRecordFilesByPattern(fullDirectory, "*.json", result); AddHistoryRecordFilesByPattern(fullDirectory, "*.json" + RECORD_BACKUP_EXTENSION, result); AddHistoryRecordFilesByPattern(fullDirectory, "*.json.tmp", result); } catch (Exception ex) { Debug.LogWarning($"搜索BFI单条历史记录目录失败: {directory}, {ex.Message}"); } } private void AddHistoryRecordFilesByPattern(string directory, string pattern, List result) { foreach (var filePath in Directory.GetFiles(directory, pattern)) { if (!result.Any(path => string.Equals(path, filePath, StringComparison.OrdinalIgnoreCase))) { result.Add(filePath); } } } public List LoadHistoryRecordSummaries(bool recoverMissingIndex = false) { maxHistoryRecords = Mathf.Max(maxHistoryRecords, MIN_HISTORY_RECORD_LIMIT); try { if (TryReadHistoryRecordSummaries(historyFilePath, out var summaries, out bool repairedSummaries)) { historyRecords = GetRecordsSortedByRecentFirst(summaries) .Take(maxHistoryRecords) .ToList(); historyFileLoadFailed = false; if (repairedSummaries) { Debug.Log("检测到BFI历史摘要缺少明细统计,已从实体文件修复并刷新索引"); SaveHistoryIndex(); } return GetAllRecords(); } LoadHistoryRecords(false, recoverMissingIndex); return GetAllRecords(); } catch (Exception ex) { Debug.LogWarning($"加载BFI历史摘要失败,回退完整加载: {ex.Message}"); LoadHistoryRecords(false, recoverMissingIndex); return GetAllRecords(); } } private IEnumerable GetHistoryIndexSearchRoots() { var roots = new List(); AddSearchRoot(roots, Path.GetDirectoryName(historyFilePath)); if (!string.IsNullOrEmpty(historyMirrorFilePath)) { AddSearchRoot(roots, Path.GetDirectoryName(historyMirrorFilePath)); } AddSearchRoot(roots, Application.persistentDataPath); AddSearchRoot(roots, Path.Combine(Application.dataPath, "..", "Exports")); AddSearchRoot(roots, Path.Combine(Application.dataPath, "Exports")); #if UNITY_ANDROID && !UNITY_EDITOR AddSearchRoot(roots, AndroidExportPermissionManager.GetStableAppInternalDirectory()); AddSearchRoot(roots, GetAndroidFilesDirectory()); AddSearchRoot(roots, GetAndroidExternalFilesDirectory(null)); AddSearchRoot(roots, GetAndroidExternalFilesDirectory("Documents")); string downloadDirectory = GetAndroidDownloadDirectory(); AddSearchRoot(roots, downloadDirectory); if (!string.IsNullOrEmpty(downloadDirectory)) { AddSearchRoot(roots, Path.Combine(downloadDirectory, "DCX_Export")); } #endif return roots; } private void AddSearchRoot(List roots, string root) { if (string.IsNullOrEmpty(root)) { return; } try { string fullPath = Path.GetFullPath(root); if (!roots.Any(existing => string.Equals(existing, fullPath, StringComparison.OrdinalIgnoreCase))) { roots.Add(fullPath); } } catch { if (!roots.Contains(root)) { roots.Add(root); } } } private void AddHistoryIndexFilesFromRoot(string root, int depth, List result, HashSet visited) { if (string.IsNullOrEmpty(root) || depth < 0) { return; } try { if (!Directory.Exists(root)) { return; } string fullRoot = Path.GetFullPath(root); if (!visited.Add(fullRoot)) { return; } foreach (var candidateFileName in HISTORY_CANDIDATE_FILE_NAMES) { string directFile = Path.Combine(fullRoot, candidateFileName); if (File.Exists(directFile) && !result.Any(path => string.Equals(path, directFile, StringComparison.OrdinalIgnoreCase))) { result.Add(directFile); } string backupFile = directFile + RECORD_BACKUP_EXTENSION; if (File.Exists(backupFile) && !result.Any(path => string.Equals(path, backupFile, StringComparison.OrdinalIgnoreCase))) { result.Add(backupFile); } } if (depth == 0) { return; } foreach (var directory in Directory.GetDirectories(fullRoot)) { AddHistoryIndexFilesFromRoot(directory, depth - 1, result, visited); } } catch (Exception ex) { Debug.LogWarning($"搜索BFI历史索引目录失败: {root}, {ex.Message}"); } } #if UNITY_ANDROID && !UNITY_EDITOR private string GetAndroidFilesDirectory() { try { using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = unityClass.GetStatic("currentActivity")) using (var context = activity.Call("getApplicationContext")) { var filesDir = context.Call("getFilesDir"); return filesDir?.Call("getAbsolutePath"); } } catch (Exception ex) { Debug.LogWarning($"获取Android内部目录失败: {ex.Message}"); return null; } } private string GetAndroidExternalFilesDirectory(string type) { try { using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = unityClass.GetStatic("currentActivity")) using (var context = activity.Call("getApplicationContext")) { var externalDir = context.Call("getExternalFilesDir", type); return externalDir?.Call("getAbsolutePath"); } } catch (Exception ex) { Debug.LogWarning($"获取Android外部应用目录失败: {ex.Message}"); return null; } } private string GetAndroidDownloadDirectory() { try { using (var environment = new AndroidJavaClass("android.os.Environment")) using (var externalStorage = environment.CallStatic("getExternalStorageDirectory")) { string storagePath = externalStorage?.Call("getAbsolutePath"); return string.IsNullOrEmpty(storagePath) ? null : Path.Combine(storagePath, "Download"); } } catch (Exception ex) { Debug.LogWarning($"获取Android Download目录失败: {ex.Message}"); return null; } } #endif /// /// 移除最旧的记录 /// private void RemoveOldestRecord() { if (historyRecords.Count == 0) return; var oldestRecord = historyRecords.OrderBy(r => r.StartTime).First(); historyRecords.Remove(oldestRecord); DeleteHistoryRecordFile(oldestRecord); Debug.Log($"移除最旧的记录: {oldestRecord.TestName}"); } /// /// 获取统计信息 /// public BFIHistoryStatistics GetHistoryStatistics() { var stats = new BFIHistoryStatistics(); if (historyRecords == null || historyRecords.Count == 0) return stats; stats.TotalRecords = historyRecords.Count; stats.TotalTestTime = historyRecords.Sum(r => r.GetDurationMinutes()); var today = DateTime.Today; stats.TodayRecords = historyRecords.Count(r => r.StartTime.Date == today); var thisWeek = today.AddDays(-(int)today.DayOfWeek); stats.WeekRecords = historyRecords.Count(r => r.StartTime >= thisWeek); var thisMonth = new DateTime(today.Year, today.Month, 1); stats.MonthRecords = historyRecords.Count(r => r.StartTime >= thisMonth); return stats; } /// /// 导出历史记录 /// public bool ExportHistory(List selectedRecordIds, ExportFormat format) { try { var selectedRecords = new List(); if (selectedRecordIds == null || selectedRecordIds.Count == 0) { selectedRecords = GetAllRecords(); } else { foreach (var id in selectedRecordIds) { var record = GetRecord(id); if (record != null) selectedRecords.Add(record); } } if (selectedRecords.Count == 0) { Debug.LogWarning("没有可导出的记录"); return false; } var exportService = new DataExportService(); return exportService.ExportHistoryRecords(selectedRecords, format); } catch (Exception ex) { Debug.LogError($"导出历史记录失败: {ex.Message}"); return false; } } private void OnDestroy() { if (autoSave && historyRecords != null) { SaveHistoryIndex(); } } private void OnApplicationPause(bool pauseStatus) { if (pauseStatus && autoSave && historyIndexDirty) { SaveHistoryIndex(); } } private void OnApplicationFocus(bool hasFocus) { if (!hasFocus && autoSave && historyIndexDirty) { SaveHistoryIndex(); } else if (hasFocus && historyRecords != null && historyRecords.Count == 0) { LoadHistoryRecords(true, false); } } } /// /// 历史记录包装类(用于JSON序列化) /// [System.Serializable] public class BFIHistoryWrapper { public int Version; public string StorageMode; public List Records; public List RecordFiles; } [System.Serializable] public class BFIHistoryRecordIndexEntry { public string TestId; public string TestName; public string PatientId; public string PatientName; public string FileName; public long StartTimeTicks; public long EndTimeTicks; public int PointCount; public float MinValue; public float MaxValue; public float Average; } /// /// 历史记录统计信息 /// [System.Serializable] public class BFIHistoryStatistics { public int TotalRecords; // 总记录数 public double TotalTestTime; // 总测试时间(分钟) public int TodayRecords; // 今日记录数 public int WeekRecords; // 本周记录数 public int MonthRecords; // 本月记录数 }