2394 lines
76 KiB
C#
2394 lines
76 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using UnityEngine;
|
||
|
||
/// <summary>
|
||
/// BFI测试历史记录管理服务
|
||
/// 负责保存、加载和管理历史测试记录
|
||
/// </summary>
|
||
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<BFIHistoryManager>();
|
||
DontDestroyOnLoad(go);
|
||
}
|
||
return _instance;
|
||
}
|
||
}
|
||
|
||
[Header("配置")]
|
||
[SerializeField] private int maxHistoryRecords = 1000; // 最大历史记录数
|
||
[SerializeField] private bool autoSave = true; // 自动保存
|
||
[SerializeField] private float saveInterval = 60f; // 保存间隔(秒)
|
||
|
||
private List<BFITestRecord> 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<BFITestRecord> OnRecordAdded;
|
||
public event System.Action<BFITestRecord> OnRecordUpdated;
|
||
public event System.Action<string> 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<BFITestRecord>();
|
||
|
||
// 历史索引是导出面板的数据源,主文件使用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<AndroidJavaObject>(
|
||
"getExternalStoragePublicDirectory",
|
||
environment.GetStatic<string>("DIRECTORY_DOWNLOADS")))
|
||
{
|
||
string path = downloadDir?.Call<string>("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<int>("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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 添加新的测试记录
|
||
/// </summary>
|
||
public bool AddTestRecord(BFITestRecord record)
|
||
{
|
||
if (record == null)
|
||
{
|
||
Debug.LogWarning("尝试添加空的测试记录");
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
if (historyRecords == null)
|
||
{
|
||
historyRecords = new List<BFITestRecord>();
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存自动记录中的中间快照。实时逐点日志已经承担断电恢复职责,
|
||
/// 这里避免每次快照都重写索引和反读整条记录,降低安卓板主线程IO压力。
|
||
/// </summary>
|
||
public bool AddTestRecordSnapshot(BFITestRecord record)
|
||
{
|
||
if (record == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
if (historyRecords == null)
|
||
{
|
||
historyRecords = new List<BFITestRecord>();
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 移除测试记录
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取所有历史记录
|
||
/// </summary>
|
||
public List<BFITestRecord> GetAllRecords()
|
||
{
|
||
return GetRecordsSortedByRecentFirst(historyRecords);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 根据ID获取记录
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 根据日期范围获取记录
|
||
/// </summary>
|
||
public List<BFITestRecord> GetRecordsByDateRange(DateTime startDate, DateTime endDate)
|
||
{
|
||
return GetRecordsSortedByRecentFirst(historyRecords.Where(r => r.StartTime >= startDate && r.StartTime <= endDate));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 根据患者ID获取记录
|
||
/// </summary>
|
||
public List<BFITestRecord> GetRecordsByPatient(string patientId)
|
||
{
|
||
return GetRecordsSortedByRecentFirst(historyRecords.Where(r => r.PatientId == patientId));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 搜索记录
|
||
/// </summary>
|
||
public List<BFITestRecord> 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
|
||
));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取最近的记录
|
||
/// </summary>
|
||
public List<BFITestRecord> GetRecentRecords(int count = 10)
|
||
{
|
||
return GetRecordsSortedByRecentFirst(historyRecords).Take(count).ToList();
|
||
}
|
||
|
||
private List<BFITestRecord> GetRecordsSortedByRecentFirst(IEnumerable<BFITestRecord> records)
|
||
{
|
||
return (records ?? Enumerable.Empty<BFITestRecord>())
|
||
.Where(r => r != null)
|
||
.OrderByDescending(r => r.StartTime)
|
||
.ThenByDescending(r => r.EndTime)
|
||
.ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清空历史记录
|
||
/// </summary>
|
||
public void ClearHistory()
|
||
{
|
||
try
|
||
{
|
||
historyRecords.Clear();
|
||
historyFileLoadFailed = false;
|
||
historyIndexDirty = false;
|
||
DeleteAllHistoryStorageFiles();
|
||
SaveHistoryRecords();
|
||
OnHistoryLoaded?.Invoke();
|
||
Debug.Log("历史记录已清空");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"清空历史记录失败: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存历史记录到文件
|
||
/// </summary>
|
||
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<BFIHistoryRecordIndexEntry>();
|
||
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<BFITestRecord>(),
|
||
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<BFIDataPoint>();
|
||
}
|
||
|
||
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<char>(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<string, BFITestRecord> recordsById, IEnumerable<BFITestRecord> records)
|
||
{
|
||
if (records == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
foreach (var record in records)
|
||
{
|
||
AddOrReplaceRecoveredRecord(recordsById, record);
|
||
}
|
||
}
|
||
|
||
private void DeleteAllHistoryStorageFiles()
|
||
{
|
||
var pathsToDelete = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
AddHistoryFileDeleteTargets(pathsToDelete, historyFilePath);
|
||
AddHistoryFileDeleteTargets(pathsToDelete, historyMirrorFilePath);
|
||
|
||
var indexPaths = new List<string>();
|
||
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<string> 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<string> 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从文件加载历史记录
|
||
/// </summary>
|
||
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<BFITestRecord>();
|
||
}
|
||
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<BFITestRecord> SafeRecoverHistoryRecordsFromIndexes(List<BFITestRecord> baseRecords, bool includeSearchRoots)
|
||
{
|
||
try
|
||
{
|
||
return RecoverHistoryRecordsFromIndexes(baseRecords, includeSearchRoots);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"恢复BFI历史索引失败: {ex.Message}");
|
||
return baseRecords != null ? new List<BFITestRecord>(baseRecords) : new List<BFITestRecord>();
|
||
}
|
||
}
|
||
|
||
private List<BFITestRecord> RecoverHistoryRecordsFromIndexes(List<BFITestRecord> baseRecords, bool includeSearchRoots)
|
||
{
|
||
var mergedRecords = new Dictionary<string, BFITestRecord>();
|
||
|
||
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<BFITestRecord> records)
|
||
{
|
||
records = new List<BFITestRecord>();
|
||
|
||
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<BFIHistoryWrapper>(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<BFITestRecord>();
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private bool TryReadHistoryRecordSummaries(string filePath, out List<BFITestRecord> records, out bool repairedSummaries)
|
||
{
|
||
records = new List<BFITestRecord>();
|
||
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<BFIHistoryWrapper>(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<BFITestRecord>();
|
||
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<BFIDataPoint>(),
|
||
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<BFIDataPoint>(),
|
||
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<BFITestRecord> 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<BFITestRecord>(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<string, BFITestRecord> 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<string> FindHistoryIndexFiles(bool includeSearchRoots)
|
||
{
|
||
var result = new List<string>();
|
||
var visited = new HashSet<string>(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<string> FindHistoryRecordFiles(bool includeSearchRoots)
|
||
{
|
||
var result = new List<string>();
|
||
var visited = new HashSet<string>(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<string> result, HashSet<string> 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<string> result)
|
||
{
|
||
foreach (var filePath in Directory.GetFiles(directory, pattern))
|
||
{
|
||
if (!result.Any(path => string.Equals(path, filePath, StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
result.Add(filePath);
|
||
}
|
||
}
|
||
}
|
||
|
||
public List<BFITestRecord> 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<string> GetHistoryIndexSearchRoots()
|
||
{
|
||
var roots = new List<string>();
|
||
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<string> 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<string> result, HashSet<string> 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<AndroidJavaObject>("currentActivity"))
|
||
using (var context = activity.Call<AndroidJavaObject>("getApplicationContext"))
|
||
{
|
||
var filesDir = context.Call<AndroidJavaObject>("getFilesDir");
|
||
return filesDir?.Call<string>("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<AndroidJavaObject>("currentActivity"))
|
||
using (var context = activity.Call<AndroidJavaObject>("getApplicationContext"))
|
||
{
|
||
var externalDir = context.Call<AndroidJavaObject>("getExternalFilesDir", type);
|
||
return externalDir?.Call<string>("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<AndroidJavaObject>("getExternalStorageDirectory"))
|
||
{
|
||
string storagePath = externalStorage?.Call<string>("getAbsolutePath");
|
||
return string.IsNullOrEmpty(storagePath) ? null : Path.Combine(storagePath, "Download");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogWarning($"获取Android Download目录失败: {ex.Message}");
|
||
return null;
|
||
}
|
||
}
|
||
#endif
|
||
|
||
/// <summary>
|
||
/// 移除最旧的记录
|
||
/// </summary>
|
||
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}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取统计信息
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 导出历史记录
|
||
/// </summary>
|
||
public bool ExportHistory(List<string> selectedRecordIds, ExportFormat format)
|
||
{
|
||
try
|
||
{
|
||
var selectedRecords = new List<BFITestRecord>();
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 历史记录包装类(用于JSON序列化)
|
||
/// </summary>
|
||
[System.Serializable]
|
||
public class BFIHistoryWrapper
|
||
{
|
||
public int Version;
|
||
public string StorageMode;
|
||
public List<BFITestRecord> Records;
|
||
public List<BFIHistoryRecordIndexEntry> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 历史记录统计信息
|
||
/// </summary>
|
||
[System.Serializable]
|
||
public class BFIHistoryStatistics
|
||
{
|
||
public int TotalRecords; // 总记录数
|
||
public double TotalTestTime; // 总测试时间(分钟)
|
||
public int TodayRecords; // 今日记录数
|
||
public int WeekRecords; // 本周记录数
|
||
public int MonthRecords; // 本月记录数
|
||
}
|