FileHelper.cs 33 KB

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