FileHelper.cs 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984
  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. #region 写CTQ的csv
  775. public static void WriteDataToCSV(DataFormCtq cd)
  776. {
  777. string s1, s2, s3, s4,
  778. t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14, t15, t16, t17, t18,
  779. r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12, r13, r14, r15, r16, r17, r18;
  780. //
  781. string path = "D:\\Data\\产品数据\\";
  782. if (!Directory.Exists(path))
  783. {
  784. Directory.CreateDirectory(path);
  785. }
  786. //
  787. t1 = "PPID";
  788. t2 = "PROCESS_NAME";
  789. t3 = "PROCESS_START_TIME";
  790. t4 = "CTQ_NAME";
  791. t5 = "CTQ_VALUE";
  792. t6 = "CTQ_LSL";
  793. t7 = "CTQ_USL";
  794. t8 = "PROCESS_END_TIME";
  795. t9 = "SUPPLIER_NAME";
  796. t10 = "COMMODITY_TYPE";
  797. t11 = "REVISION";
  798. t12 = "WO#";
  799. t13 = "MFG_LOT#";
  800. t14 = "MFG_ASSY_LINE";
  801. t15 = "PROCESS_OUTCOME";
  802. t16 = "PROCESS_MACHINE_ID";
  803. t17 = "CTQ_UNIT_OF_MEASURE";
  804. t18 = "ERROR_MESSAGE";
  805. //
  806. r1 = cd.PPID;
  807. r2 = cd.PROCESS_NAME;
  808. r3 = cd.PROCESS_START_TIME;
  809. r4 = cd.CTQ_NAME;
  810. r5 = cd.CTQ_VALUE.ToString();
  811. r6 = cd.CTQ_LSL.ToString();
  812. r7 = cd.CTQ_USL.ToString();
  813. r8 = cd.PROCESS_END_TIME;
  814. r9 = cd.SUPPLIER_NAME;
  815. r10 = cd.COMMODITY_TYPE;
  816. r11 = cd.REVISION;
  817. r12 = cd.WO;
  818. r13 = cd.MFG_LOT;
  819. r14 = cd.MFG_ASSY_LINE;
  820. r15 = cd.PROCESS_OUTCOME;
  821. r16 = cd.PROCESS_MACHINE_ID;
  822. r17 = cd.CTQ_UNIT_OF_MEASURE;
  823. r18 = cd.ERROR_MESSAGE;
  824. //=========================
  825. s1 = path;
  826. s2 = DateTime.Now.ToString("yyyyMMdd"); //表格命名以天记录
  827. s3 = t1 + "," + t2 + "," + t3 + "," + t4 + "," + t5 + "," + t6 + "," + t7 + "," + t8 + "," + t9 + "," + t10
  828. + "," + t11 + "," + t12 + "," + t13 + "," + t14 + "," + t15 + "," + t16 + "," + t17 + "," + t18;
  829. s4 = r1 + "," + r2 + "," + r3 + "," + r4 + "," + r5 + "," + r6 + "," + r7 + "," + r8 + "," + r9 + "," + r10
  830. + "," + r11 + "," + r12 + "," + r13 + "," + r14 + "," + r15 + "," + r16 + "," + r17 + "," + r18;
  831. //
  832. Save(s1, s2, s3, s4);
  833. }
  834. //写csv
  835. public static bool Save(string fullPath, string fileName, string RowName, string Data)
  836. {
  837. bool result = true;
  838. try
  839. {
  840. if (!Directory.Exists(fullPath))
  841. {
  842. Directory.CreateDirectory(fullPath);
  843. }
  844. if (fileName == null)
  845. {
  846. fileName = DateTime.Now.ToString("yyyyMMdd");
  847. }
  848. string text = "";
  849. string path = fullPath + "\\" + text;
  850. string text2 = ".csv";
  851. string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path);
  852. if (!File.Exists(fullPath + "\\" + fileNameWithoutExtension + fileName + text2))
  853. {
  854. using (File.Create(fullPath + "\\" + fileNameWithoutExtension + fileName + text2))
  855. {
  856. }
  857. FileStream fileStream2 = new FileStream(fullPath + "\\" + fileNameWithoutExtension + fileName + text2, FileMode.Append);
  858. StreamWriter streamWriter = new StreamWriter(fileStream2, Encoding.UTF8);
  859. streamWriter.WriteLine(RowName);
  860. streamWriter.WriteLine(Data);
  861. streamWriter.Flush();
  862. streamWriter.Close();
  863. fileStream2.Close();
  864. }
  865. else
  866. {
  867. FileStream fileStream2 = new FileStream(fullPath + "\\" + fileNameWithoutExtension + fileName + text2, FileMode.Append);
  868. StreamWriter streamWriter = new StreamWriter(fileStream2, Encoding.UTF8);
  869. streamWriter.WriteLine(Data);
  870. streamWriter.Flush();
  871. streamWriter.Close();
  872. fileStream2.Close();
  873. }
  874. }
  875. catch
  876. {
  877. result = false;
  878. }
  879. return result;
  880. }
  881. #endregion
  882. }
  883. }