FileHelper.cs 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871
  1. using ControlzEx.Standard;
  2. using Newtonsoft.Json;
  3. using Newtonsoft.Json.Linq;
  4. using NPOI.OpenXmlFormats.Wordprocessing;
  5. using NPOI.Util;
  6. using System;
  7. using System.Collections.Generic;
  8. using System.Collections.ObjectModel;
  9. using System.IO;
  10. using System.Linq;
  11. using System.Text;
  12. using System.Text.RegularExpressions;
  13. using System.Threading.Tasks;
  14. using TeamAAS_VP;
  15. using TeamAAS_VP.Models;
  16. using TeamAAS_VP.Resources.Languages;
  17. using TeamAAS_VP.Views.Home;
  18. namespace TeamAAS_VP.Core
  19. {
  20. /// <summary>
  21. /// 提供文件读写、备份与恢复相关的静态辅助方法。
  22. /// 功能包括:安全写入(临时文件 + 原子替换)、JSON 读写(含格式校验)、备份版本管理、临时/备份清理与恢复等。
  23. /// 该类内部包含可配置的选项,通过 <see cref="Configure"/> 进行设置。
  24. /// </summary>
  25. public static class FileHelper
  26. {
  27. #region 配置选项
  28. /// <summary>
  29. /// 文件操作辅助类的配置项集合。
  30. /// 可通过 <see cref="Configure"/> 修改。
  31. /// </summary>
  32. public class FileHelperOptions
  33. {
  34. /// <summary>
  35. /// 是否启用自动恢复(读取失败时尝试从备份恢复)。默认值:true。
  36. /// </summary>
  37. public bool EnableAutoRecovery { get; set; } = true;
  38. /// <summary>
  39. /// 是否保留每次写入时生成的 .bak 备份文件。默认值:false。
  40. /// </summary>
  41. public bool KeepBackupFiles { get; set; } = false;
  42. /// <summary>
  43. /// 保留的版本化备份最大数量(超过会清理)。默认值:3。
  44. /// </summary>
  45. public int MaxBackupVersions { get; set; } = 3;
  46. /// <summary>
  47. /// 是否启用内部日志记录(通过 LogHelper)。默认值:false。
  48. /// </summary>
  49. public bool EnableLogging { get; set; } = false;
  50. /// <summary>
  51. /// 版本化备份存放目录名称(相对于原文件目录)。默认值:"Backups"。
  52. /// </summary>
  53. public string BackupDirectory { get; set; } = "Backups";
  54. /// <summary>
  55. /// 文件读写所使用的编码。默认值:UTF8。
  56. /// </summary>
  57. public Encoding FileEncoding { get; set; } = Encoding.UTF8;
  58. }
  59. private static FileHelperOptions _options = new FileHelperOptions();
  60. /// <summary>
  61. /// 配置 FileHelper 的运行时选项。
  62. /// </summary>
  63. /// <param name="configure">接收并修改 <see cref="FileHelperOptions"/> 的委托;如果为 null 则保持默认配置。</param>
  64. public static void Configure(Action<FileHelperOptions> configure)
  65. {
  66. configure?.Invoke(_options);
  67. }
  68. #endregion
  69. #region Json文件操作
  70. /// <summary>
  71. /// 将对象序列化为格式化 JSON 并安全写入指定路径。
  72. /// 写入使用临时文件 + 原子替换以减少损坏风险,并根据配置创建版本化备份。
  73. /// </summary>
  74. /// <param name="obj">要序列化并写入的对象。</param>
  75. /// <param name="path">目标文件完整路径。</param>
  76. public static void WriteJsonFile(object obj, string path)
  77. {
  78. ValidatePath(path);
  79. string json = JsonConvert.SerializeObject(obj, Formatting.Indented);
  80. WriteFileInternal(json, path);
  81. }
  82. /// <summary>
  83. /// 读取 JSON 文件并反序列化为指定类型,支持自动从备份恢复。
  84. /// 如果读取失败且启用了自动恢复,将尝试从 .bak 或版本化备份恢复。
  85. /// </summary>
  86. /// <typeparam name="T">反序列化的目标类型。</typeparam>
  87. /// <param name="path">JSON 文件路径。</param>
  88. /// <param name="autoRecover">可选:覆盖默认的自动恢复行为(null 表示使用配置项)。</param>
  89. /// <returns>反序列化后的对象。</returns>
  90. /// <exception cref="FileNotFoundException">当文件不存在且无法恢复时抛出。</exception>
  91. public static T ReadJsonFile<T>(string path, bool? autoRecover = null)
  92. {
  93. ValidatePath(path);
  94. bool shouldRecover = autoRecover ?? _options.EnableAutoRecovery;
  95. // 检查并清理临时文件
  96. CheckAndCleanTempFile(path);
  97. // 尝试读取主文件
  98. Exception lastException = null;
  99. try
  100. {
  101. if (File.Exists(path))
  102. {
  103. string json = ReadAndValidateJsonFile(path);
  104. return JsonConvert.DeserializeObject<T>(json);
  105. }
  106. }
  107. catch (Exception ex)
  108. {
  109. lastException = ex;
  110. Log($"读取主文件失败: {ex.Message}");
  111. if (shouldRecover)
  112. {
  113. try
  114. {
  115. // 尝试从备份恢复
  116. T recovered = TryRecoverJsonFile<T>(path);
  117. if (recovered != null)
  118. return recovered;
  119. }
  120. catch (Exception recoveryEx)
  121. {
  122. Log($"恢复尝试失败: {recoveryEx.Message}");
  123. }
  124. }
  125. }
  126. // 如果文件不存在且允许恢复,尝试从默认备份位置恢复
  127. if (shouldRecover && !File.Exists(path))
  128. {
  129. T recovered = TryFindAndRecoverJsonFile<T>(path);
  130. if (recovered != null)
  131. return recovered;
  132. }
  133. throw new FileNotFoundException($"文件 {path} 不存在且无法恢复", lastException);
  134. }
  135. #endregion
  136. #region 普通文件操作
  137. /// <summary>
  138. /// 安全写入文本文件(支持目录创建、临时写入、原子替换与可选版本化备份)。
  139. /// </summary>
  140. /// <param name="content">要写入的文本内容。</param>
  141. /// <param name="path">目标文件完整路径。</param>
  142. public static void WriteFile(string content, string path)
  143. {
  144. ValidatePath(path);
  145. WriteFileInternal(content, path);
  146. }
  147. /// <summary>
  148. /// 读取文本文件,支持自动恢复(从 .bak 或版本化备份恢复)。
  149. /// </summary>
  150. /// <param name="path">文件路径。</param>
  151. /// <param name="autoRecover">可选:覆盖默认的自动恢复行为(null 表示使用配置项)。</param>
  152. /// <returns>文件内容字符串。</returns>
  153. /// <exception cref="FileNotFoundException">当文件不存在且无法恢复时抛出。</exception>
  154. public static string ReadFile(string path, bool? autoRecover = null)
  155. {
  156. ValidatePath(path);
  157. bool shouldRecover = autoRecover ?? _options.EnableAutoRecovery;
  158. // 检查并清理临时文件
  159. CheckAndCleanTempFile(path);
  160. // 尝试读取主文件
  161. Exception lastException = null;
  162. try
  163. {
  164. if (File.Exists(path))
  165. {
  166. return ReadFileWithValidation(path);
  167. }
  168. }
  169. catch (Exception ex)
  170. {
  171. lastException = ex;
  172. Log($"读取主文件失败: {ex.Message}");
  173. if (shouldRecover)
  174. {
  175. try
  176. {
  177. // 尝试从备份恢复
  178. string recovered = TryRecoverFile(path);
  179. if (recovered != null)
  180. return recovered;
  181. }
  182. catch (Exception recoveryEx)
  183. {
  184. Log($"恢复尝试失败: {recoveryEx.Message}");
  185. }
  186. }
  187. }
  188. // 如果文件不存在且允许恢复,尝试从默认备份位置恢复
  189. if (shouldRecover && !File.Exists(path))
  190. {
  191. string recovered = TryFindAndRecoverFile(path);
  192. if (recovered != null)
  193. return recovered;
  194. }
  195. throw new FileNotFoundException($"文件 {path} 不存在且无法恢复", lastException);
  196. }
  197. /// <summary>
  198. /// 在不抛出异常的情况下安全读取文件内容,失败时返回提供的默认值。
  199. /// </summary>
  200. /// <param name="path">文件路径。</param>
  201. /// <param name="defaultValue">读取失败时返回的默认值(可为 null)。</param>
  202. /// <returns>文件内容或默认值。</returns>
  203. public static string SafeReadFile(string path, string defaultValue = null)
  204. {
  205. try
  206. {
  207. return ReadFile(path, true);
  208. }
  209. catch
  210. {
  211. return defaultValue;
  212. }
  213. }
  214. #endregion
  215. #region 文件恢复相关
  216. /// <summary>
  217. /// 检查并修复指定目录下的所有相关临时文件与备份文件。
  218. /// - 恢复或清理 .tmp 文件
  219. /// - 清理孤立或过旧的 .bak 文件
  220. /// - 清理版本化备份超过保留数量的旧版本
  221. /// </summary>
  222. /// <param name="directoryPath">要检查的目录路径。</param>
  223. public static void CheckAndRepairDirectory(string directoryPath)
  224. {
  225. if (!Directory.Exists(directoryPath))
  226. return;
  227. // 处理临时文件
  228. var tempFiles = Directory.GetFiles(directoryPath, "*.tmp", SearchOption.AllDirectories);
  229. foreach (var tempFile in tempFiles)
  230. {
  231. TryRecoverFromTempFile(tempFile);
  232. }
  233. // 处理备份文件
  234. var backupFiles = Directory.GetFiles(directoryPath, "*.bak", SearchOption.AllDirectories);
  235. foreach (var backupFile in backupFiles)
  236. {
  237. TryCleanOrphanedBackup(backupFile);
  238. }
  239. // 处理版本化备份
  240. CleanupOldBackupVersions(directoryPath);
  241. }
  242. /// <summary>
  243. /// 强制从备份恢复主文件。优先使用直接的 .bak,其次尝试版本化备份目录中的最新文件。
  244. /// </summary>
  245. /// <param name="originalPath">原文件路径。</param>
  246. /// <returns>成功返回 true,失败返回 false。</returns>
  247. public static bool ForceRecoverFile(string originalPath)
  248. {
  249. try
  250. {
  251. // 尝试从直接备份恢复
  252. string backupPath = originalPath + ".bak";
  253. if (File.Exists(backupPath))
  254. {
  255. File.Copy(backupPath, originalPath, true);
  256. Log($"从备份强制恢复: {originalPath}");
  257. return true;
  258. }
  259. // 尝试从版本化备份恢复
  260. var recovered = TryFindLatestBackup(originalPath);
  261. if (recovered != null)
  262. {
  263. File.Copy(recovered, originalPath, true);
  264. Log($"从版本备份强制恢复: {originalPath}");
  265. return true;
  266. }
  267. return false;
  268. }
  269. catch (Exception ex)
  270. {
  271. Log($"强制恢复失败: {ex.Message}");
  272. return false;
  273. }
  274. }
  275. #endregion
  276. #region 辅助方法
  277. /// <summary>
  278. /// 检测给定的文件名是否合规(不包含 Windows 文件名禁止字符)。
  279. /// </summary>
  280. /// <param name="filename">仅文件名部分(不含路径)。</param>
  281. /// <returns>文件名合法返回 true,否则返回 false。</returns>
  282. public static bool CheckFileName(string filename)
  283. {
  284. if (string.IsNullOrWhiteSpace(filename))
  285. return false;
  286. // 定义文件名合法性的正则表达式
  287. string pattern = @"^[^\\/:*?""<>|\x00-\x1F]*$";
  288. return Regex.IsMatch(filename, pattern);
  289. }
  290. /// <summary>
  291. /// 检查文件是否存在且有效(可读且长度大于 0)。
  292. /// </summary>
  293. /// <param name="path">文件完整路径。</param>
  294. /// <returns>文件存在且有效返回 true,否则返回 false。</returns>
  295. public static bool IsFileValid(string path)
  296. {
  297. try
  298. {
  299. if (!File.Exists(path))
  300. return false;
  301. // 尝试读取一小部分内容来验证文件可访问性
  302. using (var fs = File.OpenRead(path))
  303. {
  304. return fs.CanRead && fs.Length > 0;
  305. }
  306. }
  307. catch
  308. {
  309. return false;
  310. }
  311. }
  312. /// <summary>
  313. /// 为给定原始路径生成一个安全的临时文件路径(放在系统临时目录),包含随机 GUID 前缀。
  314. /// </summary>
  315. /// <param name="originalPath">原文件完整路径,用于生成可识别的临时文件名。</param>
  316. /// <returns>生成的临时文件完整路径。</returns>
  317. public static string GetTempFilePath(string originalPath)
  318. {
  319. // 尝试放在目标目录(确保同一卷)
  320. string dir = Path.GetDirectoryName(originalPath);
  321. try
  322. {
  323. if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir))
  324. {
  325. string safeName = Path.GetFileName(originalPath).Replace(" ", "_").Replace(":", "_");
  326. return Path.Combine(dir, $"{Guid.NewGuid():N}_{safeName}.tmp");
  327. }
  328. }
  329. catch
  330. {
  331. // 忽略并回退到系统临时目录
  332. }
  333. // 回退(极少用到)
  334. string tempDir = Path.GetTempPath();
  335. string fallbackName = Path.GetFileName(originalPath).Replace(" ", "_").Replace(":", "_");
  336. return Path.Combine(tempDir, $"{Guid.NewGuid():N}_{fallbackName}.tmp");
  337. }
  338. #endregion
  339. #region 私有实现方法
  340. /// <summary>
  341. /// 验证路径与文件名合法性(非空且文件名不包含非法字符)。
  342. /// </summary>
  343. /// <param name="path">要验证的文件路径。</param>
  344. private static void ValidatePath(string path)
  345. {
  346. if (string.IsNullOrWhiteSpace(path))
  347. throw new ArgumentException("路径不能为空", nameof(path));
  348. string fileName = Path.GetFileName(path);
  349. if (!CheckFileName(fileName))
  350. throw new ArgumentException($"文件名 '{fileName}' 包含非法字符", nameof(path));
  351. }
  352. /// <summary>
  353. /// 内部写文件实现:创建目录、可选创建版本化备份、写入临时文件并以原子方式替换目标文件。
  354. /// 发生异常时会尝试清理临时文件。
  355. /// </summary>
  356. /// <param name="content">要写入的文本内容。</param>
  357. /// <param name="path">目标文件完整路径。</param>
  358. private static void WriteFileInternal(string content, string path)
  359. {
  360. // 创建目录
  361. string directory = Path.GetDirectoryName(path);
  362. if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
  363. {
  364. Directory.CreateDirectory(directory);
  365. }
  366. // 创建版本化备份
  367. if (_options.KeepBackupFiles && File.Exists(path))
  368. {
  369. CreateVersionedBackup(path);
  370. }
  371. // 临时文件路径
  372. string tempPath = path + ".tmp"; //GetTempFilePath(path);
  373. try
  374. {
  375. // 写入临时文件
  376. using (var fs = new FileStream(tempPath,
  377. FileMode.Create,
  378. FileAccess.Write,
  379. FileShare.None,
  380. bufferSize: 4096,
  381. useAsync: false))
  382. using (var sw = new StreamWriter(fs, _options.FileEncoding))
  383. {
  384. sw.Write(content);
  385. sw.Flush();
  386. fs.Flush(true); // 强制刷新到磁盘
  387. }
  388. // 原子性替换
  389. if (File.Exists(path))
  390. {
  391. string backupPath = path + ".bak";
  392. File.Replace(tempPath, path, backupPath, true);
  393. // 如果不保留备份文件,删除它
  394. if (!_options.KeepBackupFiles && File.Exists(backupPath))
  395. {
  396. try { File.Delete(backupPath); } catch { }
  397. }
  398. }
  399. else
  400. {
  401. File.Move(tempPath, path);
  402. }
  403. }
  404. catch
  405. {
  406. // 清理临时文件
  407. SafeDelete(tempPath);
  408. throw;
  409. }
  410. finally
  411. {
  412. // 确保临时文件被清理
  413. SafeDelete(tempPath);
  414. }
  415. }
  416. /// <summary>
  417. /// 创建版本化备份:将原文件复制到同目录下的备份目录,并带时间戳后缀。
  418. /// 发生异常时仅记录日志,不抛出。
  419. /// </summary>
  420. /// <param name="originalPath">要备份的原文件路径。</param>
  421. private static void CreateVersionedBackup(string originalPath)
  422. {
  423. try
  424. {
  425. string backupDir = Path.Combine(Path.GetDirectoryName(originalPath), _options.BackupDirectory);
  426. if (!Directory.Exists(backupDir))
  427. Directory.CreateDirectory(backupDir);
  428. string fileName = Path.GetFileNameWithoutExtension(originalPath);
  429. string extension = Path.GetExtension(originalPath);
  430. string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff");
  431. string backupName = $"{fileName}_{timestamp}{extension}";
  432. string backupPath = Path.Combine(backupDir, backupName);
  433. File.Copy(originalPath, backupPath, true);
  434. Log($"创建版本备份: {backupPath}");
  435. }
  436. catch (Exception ex)
  437. {
  438. Log($"创建版本备份失败: {ex.Message}");
  439. }
  440. }
  441. /// <summary>
  442. /// 检查并清理与原文件同名的临时文件(使用 .tmp 后缀的临时文件),并在可能的情况下记录发现的信息。
  443. /// </summary>
  444. /// <param name="originalPath">原文件完整路径。</param>
  445. private static void CheckAndCleanTempFile(string originalPath)
  446. {
  447. string tempPath = originalPath + ".tmp";
  448. if (File.Exists(tempPath))
  449. {
  450. try
  451. {
  452. // 检查临时文件是否较新(可能是中断的写入)
  453. if (File.Exists(originalPath))
  454. {
  455. var originalTime = File.GetLastWriteTime(originalPath);
  456. var tempTime = File.GetLastWriteTime(tempPath);
  457. if (tempTime > originalTime)
  458. {
  459. Log($"发现较新的临时文件,可能上次写入未完成: {tempPath}");
  460. }
  461. }
  462. File.Delete(tempPath);
  463. Log($"清理临时文件: {tempPath}");
  464. }
  465. catch (Exception ex)
  466. {
  467. Log($"清理临时文件失败: {ex.Message}");
  468. }
  469. }
  470. }
  471. /// <summary>
  472. /// 尝试从直接的 .bak 文件恢复 JSON 文件并返回反序列化的对象;恢复成功后尝试覆盖主文件。
  473. /// </summary>
  474. /// <typeparam name="T">目标类型。</typeparam>
  475. /// <param name="originalPath">原 JSON 文件路径。</param>
  476. /// <returns>恢复并反序列化后的对象,失败返回 default(T)。</returns>
  477. private static T TryRecoverJsonFile<T>(string originalPath)
  478. {
  479. // 尝试从直接备份恢复
  480. string backupPath = originalPath + ".bak";
  481. if (File.Exists(backupPath))
  482. {
  483. try
  484. {
  485. string json = ReadAndValidateJsonFile(backupPath);
  486. T result = JsonConvert.DeserializeObject<T>(json);
  487. // 尝试恢复主文件
  488. TryRestoreMainFile(backupPath, originalPath);
  489. Log($"从备份恢复Json文件: {originalPath}");
  490. return result;
  491. }
  492. catch (Exception ex)
  493. {
  494. Log($"从备份恢复Json文件失败: {ex.Message}");
  495. }
  496. }
  497. return default;
  498. }
  499. /// <summary>
  500. /// 尝试从直接的 .bak 文件恢复文本文件并返回内容;恢复成功后尝试覆盖主文件。
  501. /// </summary>
  502. /// <param name="originalPath">原文件路径。</param>
  503. /// <returns>恢复后的内容,失败返回 null。</returns>
  504. private static string TryRecoverFile(string originalPath)
  505. {
  506. // 尝试从直接备份恢复
  507. string backupPath = originalPath + ".bak";
  508. if (File.Exists(backupPath))
  509. {
  510. try
  511. {
  512. string content = ReadFileWithValidation(backupPath);
  513. // 尝试恢复主文件
  514. TryRestoreMainFile(backupPath, originalPath);
  515. Log($"从备份恢复文件: {originalPath}");
  516. return content;
  517. }
  518. catch (Exception ex)
  519. {
  520. Log($"从备份恢复文件失败: {ex.Message}");
  521. }
  522. }
  523. return null;
  524. }
  525. /// <summary>
  526. /// 在版本化备份目录中查找最新的备份并尝试恢复 JSON 文件,成功返回反序列化对象并复制到主文件。
  527. /// </summary>
  528. /// <typeparam name="T">目标类型。</typeparam>
  529. /// <param name="originalPath">原 JSON 文件路径。</param>
  530. /// <returns>恢复并反序列化后的对象,失败返回 default(T)。</returns>
  531. private static T TryFindAndRecoverJsonFile<T>(string originalPath)
  532. {
  533. string backup = TryFindLatestBackup(originalPath);
  534. if (backup != null)
  535. {
  536. try
  537. {
  538. string json = ReadAndValidateJsonFile(backup);
  539. T result = JsonConvert.DeserializeObject<T>(json);
  540. File.Copy(backup, originalPath, true);
  541. Log($"从版本备份恢复Json文件: {originalPath}");
  542. return result;
  543. }
  544. catch (Exception ex)
  545. {
  546. Log($"从版本备份恢复Json文件失败: {ex.Message}");
  547. }
  548. }
  549. return default;
  550. }
  551. /// <summary>
  552. /// 在版本化备份目录中查找最新的备份并尝试恢复文本文件,成功后复制到主文件并返回内容。
  553. /// </summary>
  554. /// <param name="originalPath">原文件路径。</param>
  555. /// <returns>恢复后的内容,失败返回 null。</returns>
  556. private static string TryFindAndRecoverFile(string originalPath)
  557. {
  558. string backup = TryFindLatestBackup(originalPath);
  559. if (backup != null)
  560. {
  561. try
  562. {
  563. string content = ReadFileWithValidation(backup);
  564. File.Copy(backup, originalPath, true);
  565. Log($"从版本备份恢复文件: {originalPath}");
  566. return content;
  567. }
  568. catch (Exception ex)
  569. {
  570. Log($"从版本备份恢复文件失败: {ex.Message}");
  571. }
  572. }
  573. return null;
  574. }
  575. /// <summary>
  576. /// 查找最新的版本化备份文件路径。优先检查指定的备份目录,其次检查原目录下同名带时间戳的文件。
  577. /// </summary>
  578. /// <param name="originalPath">原始文件路径。</param>
  579. /// <returns>最新备份文件的完整路径或 null(未找到)。</returns>
  580. private static string TryFindLatestBackup(string originalPath)
  581. {
  582. string directory = Path.GetDirectoryName(originalPath);
  583. string fileName = Path.GetFileNameWithoutExtension(originalPath);
  584. string extension = Path.GetExtension(originalPath);
  585. if (!Directory.Exists(directory))
  586. return null;
  587. // 检查备份目录
  588. string backupDir = Path.Combine(directory, _options.BackupDirectory);
  589. if (Directory.Exists(backupDir))
  590. {
  591. var backups = Directory.GetFiles(backupDir, $"{fileName}_*{extension}")
  592. .OrderByDescending(f => f)
  593. .ToList();
  594. if (backups.Any())
  595. return backups.First();
  596. }
  597. // 检查当前目录的备份文件
  598. var localBackups = Directory.GetFiles(directory, $"{fileName}_*{extension}")
  599. .OrderByDescending(f => f)
  600. .ToList();
  601. if (localBackups.Any())
  602. return localBackups.First();
  603. return null;
  604. }
  605. /// <summary>
  606. /// 读取文件并在读取后进行 JSON 格式校验(适用于 JSON 文件)。如果格式无效则抛出异常。
  607. /// </summary>
  608. /// <param name="filePath">文件路径。</param>
  609. /// <returns>文件文本内容。</returns>
  610. /// <exception cref="InvalidDataException">当 JSON 格式无效时抛出。</exception>
  611. private static string ReadAndValidateJsonFile(string filePath)
  612. {
  613. string json = ReadFileWithValidation(filePath);
  614. // 验证JSON格式
  615. if (!string.IsNullOrWhiteSpace(json))
  616. {
  617. try
  618. {
  619. JToken.Parse(json);
  620. }
  621. catch (JsonException ex)
  622. {
  623. throw new InvalidDataException($"文件 {filePath} 包含无效的JSON格式: {ex.Message}", ex);
  624. }
  625. }
  626. return json;
  627. }
  628. /// <summary>
  629. /// 读取文件并进行基本验证(文件存在且读取结果非 null)。
  630. /// </summary>
  631. /// <param name="filePath">文件路径。</param>
  632. /// <returns>文件内容字符串。</returns>
  633. /// <exception cref="FileNotFoundException">文件不存在时抛出。</exception>
  634. /// <exception cref="InvalidDataException">读取结果为 null 时抛出。</exception>
  635. private static string ReadFileWithValidation(string filePath)
  636. {
  637. if (!File.Exists(filePath))
  638. throw new FileNotFoundException($"文件不存在: {filePath}");
  639. string content = File.ReadAllText(filePath, _options.FileEncoding);
  640. if (content == null)
  641. throw new InvalidDataException($"文件 {filePath} 内容为null");
  642. return content;
  643. }
  644. /// <summary>
  645. /// 将备份文件复制回主文件路径以进行恢复,失败时记录日志但不抛出。
  646. /// </summary>
  647. /// <param name="backupPath">备份文件路径。</param>
  648. /// <param name="originalPath">目标主文件路径。</param>
  649. private static void TryRestoreMainFile(string backupPath, string originalPath)
  650. {
  651. try
  652. {
  653. File.Copy(backupPath, originalPath, true);
  654. Log($"恢复主文件: {originalPath}");
  655. }
  656. catch (Exception ex)
  657. {
  658. Log($"恢复主文件失败: {ex.Message}");
  659. }
  660. }
  661. /// <summary>
  662. /// 尝试根据 .tmp 临时文件恢复原文件:如果临时文件较新或原文件不存在则移动临时文件覆盖原文件,否则删除临时文件。
  663. /// 出错时尝试安全删除临时文件。
  664. /// </summary>
  665. /// <param name="tempPath">临时文件路径。</param>
  666. private static void TryRecoverFromTempFile(string tempPath)
  667. {
  668. try
  669. {
  670. // 获取原文件路径(假设临时文件是 .tmp 扩展名)
  671. string originalPath = tempPath.EndsWith(".tmp")
  672. ? tempPath.Substring(0, tempPath.Length - 4)
  673. : tempPath;
  674. // 如果原文件不存在或临时文件更新,则恢复
  675. if (!File.Exists(originalPath) ||
  676. File.GetLastWriteTime(tempPath) > File.GetLastWriteTime(originalPath))
  677. {
  678. File.Move(tempPath, originalPath);
  679. Log($"从临时文件恢复: {originalPath}");
  680. }
  681. else
  682. {
  683. File.Delete(tempPath);
  684. }
  685. }
  686. catch
  687. {
  688. SafeDelete(tempPath);
  689. }
  690. }
  691. /// <summary>
  692. /// 清理孤立或过旧的 .bak 备份:如果主文件存在且备份比主文件早超过一定时间,则删除备份。
  693. /// </summary>
  694. /// <param name="backupPath">备份文件路径(通常以 .bak 结尾)。</param>
  695. private static void TryCleanOrphanedBackup(string backupPath)
  696. {
  697. try
  698. {
  699. string originalPath = backupPath.EndsWith(".bak")
  700. ? backupPath.Substring(0, backupPath.Length - 4)
  701. : backupPath;
  702. // 如果主文件存在且备份较旧,删除备份
  703. if (File.Exists(originalPath))
  704. {
  705. var originalTime = File.GetLastWriteTime(originalPath);
  706. var backupTime = File.GetLastWriteTime(backupPath);
  707. if (originalTime > backupTime.AddMinutes(5))
  708. {
  709. File.Delete(backupPath);
  710. Log($"清理旧备份: {backupPath}");
  711. }
  712. }
  713. }
  714. catch
  715. {
  716. // 忽略错误
  717. }
  718. }
  719. /// <summary>
  720. /// 清理版本化备份目录中超过保留数量的旧版本(按名称分组并按字符串降序保留最新的若干个)。
  721. /// </summary>
  722. /// <param name="directory">原文件所在目录,该目录下可能包含版本化备份子目录。</param>
  723. private static void CleanupOldBackupVersions(string directory)
  724. {
  725. try
  726. {
  727. string backupDir = Path.Combine(directory, _options.BackupDirectory);
  728. if (!Directory.Exists(backupDir))
  729. return;
  730. var fileGroups = Directory.GetFiles(backupDir)
  731. .GroupBy(f => Path.GetFileNameWithoutExtension(f).Split('_')[0])
  732. .ToList();
  733. foreach (var group in fileGroups)
  734. {
  735. var backups = group.OrderByDescending(f => f).ToList();
  736. for (int i = _options.MaxBackupVersions; i < backups.Count; i++)
  737. {
  738. SafeDelete(backups[i]);
  739. }
  740. }
  741. }
  742. catch
  743. {
  744. // 忽略错误
  745. }
  746. }
  747. /// <summary>
  748. /// 尝试安全删除指定文件,删除失败则忽略异常。
  749. /// </summary>
  750. /// <param name="filePath">要删除的文件路径。</param>
  751. private static void SafeDelete(string filePath)
  752. {
  753. try
  754. {
  755. if (File.Exists(filePath))
  756. File.Delete(filePath);
  757. }
  758. catch
  759. {
  760. // 忽略删除错误
  761. }
  762. }
  763. /// <summary>
  764. /// 内部日志方法,只有在配置启用时才会调用 LogHelper 记录调试日志。
  765. /// </summary>
  766. /// <param name="message">日志消息正文。</param>
  767. private static void Log(string message)
  768. {
  769. if (_options.EnableLogging)
  770. {
  771. //Console.WriteLine($"[FileHelper] {DateTime.Now:HH:mm:ss} - {message}");
  772. LogHelper.WriteLogDebug($"[FileHelper] {DateTime.Now:HH:mm:ss} - {message}");
  773. }
  774. }
  775. #endregion
  776. }
  777. }