using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; using BetterGenshinImpact.Core.Config; using BetterGenshinImpact.Helpers.Security; using LazyCache; using Microsoft.Extensions.Logging; namespace BetterGenshinImpact.Service; public sealed class MemoryFileCache { private readonly IAppCache _memoryCache; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public static readonly string CacheRootDirectory = Global.Absolute(Path.Combine("User", "Cache", "MemoryFileCache")); public MemoryFileCache( IAppCache memoryCache, TimeProvider timeProvider, ILogger logger) { _memoryCache = memoryCache; _timeProvider = timeProvider; _logger = logger; Directory.CreateDirectory(CacheRootDirectory); } public Task GetOrAddAsync( string cacheType, string cacheKey, TimeSpan ttl, Func> factory, Func serialize, Func deserialize, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(cacheKey)) { throw new ArgumentException("cacheKey cannot be empty.", nameof(cacheKey)); } var normalizedCacheType = NormalizeCacheType(cacheType); var memoryCacheKey = $"{normalizedCacheType}:{cacheKey}"; Directory.CreateDirectory(GetCacheTypeDirectory(normalizedCacheType)); return _memoryCache.GetOrAddAsync( memoryCacheKey, async entry => { if (TryReadPayload(normalizedCacheType, cacheKey, ttl, out var payload, out var remaining)) { entry.AbsoluteExpirationRelativeToNow = remaining; try { return deserialize(payload); } catch { TryDeletePayload(normalizedCacheType, cacheKey); } } entry.AbsoluteExpirationRelativeToNow = ttl; var obj = await factory(CancellationToken.None).ConfigureAwait(false); if (obj == null) { return default; } byte[] bytes; try { bytes = serialize(obj); } catch { return obj; } if (bytes is { Length: > 0 }) { TryWritePayload(normalizedCacheType, cacheKey, bytes); } return obj; }) .WaitAsync(ct); } public Task GetOrAddAsync( string cacheKey, TimeSpan ttl, Func> factory, Func serialize, Func deserialize, CancellationToken ct = default) { return GetOrAddAsync( "default", cacheKey, ttl, factory, serialize, deserialize, ct); } private static string NormalizeCacheType(string? cacheType) { if (string.IsNullOrWhiteSpace(cacheType)) { return "default"; } cacheType = cacheType.Trim(); var invalidChars = Path.GetInvalidFileNameChars(); var sb = new StringBuilder(cacheType.Length); for (var i = 0; i < cacheType.Length; i++) { var ch = cacheType[i]; if (ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar) { sb.Append('_'); continue; } var isInvalid = false; for (var j = 0; j < invalidChars.Length; j++) { if (ch == invalidChars[j]) { isInvalid = true; break; } } sb.Append(isInvalid ? '_' : ch); } var normalized = sb.ToString().Trim(); if (normalized.Length == 0 || normalized is "." or "..") { return "default"; } return normalized; } private static string GetCacheTypeDirectory(string cacheType) { return Path.Combine(CacheRootDirectory, cacheType); } public void PurgeCacheTypeByCacheKeys(string cacheType, IReadOnlyCollection keepCacheKeys) { var normalizedCacheType = NormalizeCacheType(cacheType); var dir = GetCacheTypeDirectory(normalizedCacheType); if (!Directory.Exists(dir)) { return; } var keepFileNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var cacheKey in keepCacheKeys) { if (string.IsNullOrWhiteSpace(cacheKey)) { continue; } var hash = ComputeHash(cacheKey); keepFileNames.Add(hash + ".bin"); } try { foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.TopDirectoryOnly)) { var name = Path.GetFileName(file); if (name.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) { TryDeleteFile(file); continue; } if (name.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) && !keepFileNames.Contains(name)) { TryDeleteFile(file); } } } catch { } } private string GetPayloadPath(string cacheType, string cacheKey) { var hash = ComputeHash(cacheKey); return Path.Combine(GetCacheTypeDirectory(cacheType), hash + ".bin"); } private static string ComputeHash(string cacheKey) { return MD5Helper.ComputeMD5(cacheKey); } private bool TryReadPayload(string cacheType, string cacheKey, TimeSpan ttl, out byte[] payload, out TimeSpan remaining) { payload = []; remaining = default; var path = GetPayloadPath(cacheType, cacheKey); try { if (!File.Exists(path)) { return false; } var lastWriteUtc = File.GetLastWriteTimeUtc(path); var nowUtc = _timeProvider.GetUtcNow().UtcDateTime; var age = nowUtc - lastWriteUtc; if (age >= ttl) { TryDeleteFile(path); return false; } remaining = ttl - age; using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); if (fs.Length <= 0 || fs.Length > int.MaxValue) { TryDeleteFile(path); return false; } payload = ReadAllBytesByCopyTo(fs); return true; } catch (Exception ex) { _logger.LogDebug(ex, "读取缓存文件失败: {CacheKey}", cacheKey); return false; } } private void TryWritePayload(string cacheType, string cacheKey, byte[] payload) { TryWritePayload(cacheType, cacheKey, payload, _timeProvider.GetUtcNow().UtcDateTime); } private void TryWritePayload(string cacheType, string cacheKey, byte[] payload, DateTime nowUtc) { var path = GetPayloadPath(cacheType, cacheKey); var dir = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(dir)) { Directory.CreateDirectory(dir); } var tmpPath = path + ".tmp"; try { using (var fs = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None)) { WriteAllBytesByCopyTo(fs, payload); fs.Flush(flushToDisk: true); } File.Move(tmpPath, path, overwrite: true); File.SetLastWriteTimeUtc(path, nowUtc); } catch (Exception ex) { _logger.LogDebug(ex, "写入缓存文件失败: {CacheKey}", cacheKey); TryDeleteFile(tmpPath); } } private void TryDeletePayload(string cacheType, string cacheKey) { TryDeleteFile(GetPayloadPath(cacheType, cacheKey)); } private static unsafe byte[] ReadAllBytesByCopyTo(FileStream fs) { if (fs.Length <= 0 || fs.Length > int.MaxValue) { return []; } var bytes = new byte[(int)fs.Length]; fixed (byte* p = bytes) { using var ms = new UnmanagedMemoryStream(p, fs.Length, fs.Length, FileAccess.Write); fs.Position = 0; fs.CopyTo(ms); } return bytes; } private static unsafe void WriteAllBytesByCopyTo(FileStream fs, byte[] bytes) { if (bytes.Length <= 0) { return; } fixed (byte* p = bytes) { using var ms = new UnmanagedMemoryStream(p, bytes.Length, bytes.Length, FileAccess.Read); ms.CopyTo(fs); } } private static void TryDeleteFile(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch { } } }