#if UNITY_EDITOR || DEVELOPMENT_BUILD using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using Newtonsoft.Json; namespace AibisDream.SaveSystem { /// /// 机器本地测试存档仓库。目录级 staging/backup 交换保证替换同节点时不会留下半份快照。 /// public sealed class TestSaveRepository { public const string SnapshotFileName = "snapshot.json"; public const string MetaFileName = "meta.json"; public const string ThumbnailFileName = "thumbnail.png"; private const string StagingSuffix = ".__staging"; private const string BackupSuffix = ".__backup"; private readonly object _gate = new(); private readonly string _rootPath; private readonly string _rootPathWithSeparator; private readonly Dictionary _validationFailures = new(StringComparer.OrdinalIgnoreCase); public TestSaveRepository(string rootPath) { if (string.IsNullOrWhiteSpace(rootPath)) { throw new ArgumentException("Test save root path is required.", nameof(rootPath)); } _rootPath = Path.GetFullPath(rootPath); _rootPathWithSeparator = _rootPath.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; } public string RootPath => _rootPath; public TestSaveScanResult Scan(string currentGameVersion = null) { lock (_gate) { RecoverTransactions(); if (!Directory.Exists(_rootPath)) { return new TestSaveScanResult(); } var entries = new List(); foreach (var metaPath in Directory.EnumerateFiles( _rootPath, MetaFileName, SearchOption.AllDirectories)) { var directory = Path.GetDirectoryName(metaPath); if (string.IsNullOrEmpty(directory) || IsTransactionDirectory(directory) || !TryResolveInsideRoot(directory, out var safeDirectory)) { continue; } entries.Add(ReadEntryMeta(safeDirectory, currentGameVersion)); } // 没有 meta 的目录也要保留为可清理的无效条目。 foreach (var snapshotPath in Directory.EnumerateFiles( _rootPath, SnapshotFileName, SearchOption.AllDirectories)) { var directory = Path.GetDirectoryName(snapshotPath); if (string.IsNullOrEmpty(directory) || IsTransactionDirectory(directory) || File.Exists(Path.Combine(directory, MetaFileName)) || entries.Any(item => PathsEqual(item.DirectoryPath, directory))) { continue; } entries.Add(CreateInvalidEntry( directory, TestSaveEntryStatus.CorruptMeta, "缺少 meta.json。")); } var ordered = entries .OrderBy(item => item.Meta?.firstSeenOrder ?? long.MaxValue) .ThenBy(item => item.DirectoryPath, StringComparer.OrdinalIgnoreCase) .ToArray(); return new TestSaveScanResult { Entries = ordered, ValidCount = ordered.Count(item => item.IsValid), InvalidCount = ordered.Count(item => !item.IsValid) }; } } public TestSaveEntry Record(TestSaveRecordRequest request) { if (request?.Snapshot == null) { throw new ArgumentNullException(nameof(request)); } lock (_gate) { Directory.CreateDirectory(_rootPath); RecoverTransactions(); var chapterDirectoryName = $"{Slug(request.ChapterId, "chapter")}-{ShortHash(request.SceneSoName)}"; var entryDirectoryName = $"{Slug(request.NodeName, "node")}-{ShortHash(request.DedupeKey)}"; var chapterDirectory = ResolveInsideRoot(chapterDirectoryName); var finalDirectory = ResolveInsideRoot(chapterDirectoryName, entryDirectoryName); var stagingDirectory = finalDirectory + StagingSuffix; var backupDirectory = finalDirectory + BackupSuffix; Directory.CreateDirectory(chapterDirectory); DeleteDirectoryIfPresent(stagingDirectory); DeleteDirectoryIfPresent(backupDirectory); Directory.CreateDirectory(stagingDirectory); var existingMeta = TryReadMeta(Path.Combine(finalDirectory, MetaFileName)); var now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); var meta = new TestSaveMeta { entryId = request.EntryId, dedupeKey = request.DedupeKey, chapterId = request.ChapterId, chapterTitle = request.ChapterTitle, sceneSoName = request.SceneSoName, yarnProjectId = request.YarnProjectId, nodeName = request.NodeName, sceneName = request.SceneName, firstSeenOrder = existingMeta?.firstSeenOrder ?? NextFirstSeenOrder(), firstRecordedAt = existingMeta?.firstRecordedAt ?? now, lastRecordedAt = now, snapshotSchemaVersion = request.Snapshot.schemaVersion, gameVersion = request.GameVersion, hasThumbnail = request.Thumbnail is { Length: > 0 } }; File.WriteAllText( Path.Combine(stagingDirectory, SnapshotFileName), SnapshotPersistence.Serialize(request.Snapshot), Encoding.UTF8); File.WriteAllText( Path.Combine(stagingDirectory, MetaFileName), JsonConvert.SerializeObject(meta, Formatting.Indented), Encoding.UTF8); if (meta.hasThumbnail) { File.WriteAllBytes(Path.Combine(stagingDirectory, ThumbnailFileName), request.Thumbnail); } if (Directory.Exists(finalDirectory)) { Directory.Move(finalDirectory, backupDirectory); } try { Directory.Move(stagingDirectory, finalDirectory); DeleteDirectoryIfPresent(backupDirectory); _validationFailures.Remove(finalDirectory); } catch { if (!Directory.Exists(finalDirectory) && Directory.Exists(backupDirectory)) { Directory.Move(backupDirectory, finalDirectory); } throw; } return ReadEntryMeta(finalDirectory, request.GameVersion); } } public bool TryLoad(TestSaveEntry entry, out SaveSnapshot snapshot, out string error) { snapshot = null; error = null; lock (_gate) { if (entry == null || !TryResolveInsideRoot(entry.DirectoryPath, out var directory)) { error = "测试存档路径不在仓库目录内。"; return false; } var snapshotPath = Path.Combine(directory, SnapshotFileName); if (!File.Exists(snapshotPath)) { error = "缺少 snapshot.json。"; RememberValidationFailure(directory, error); return false; } try { snapshot = SnapshotPersistence.Deserialize(File.ReadAllText(snapshotPath, Encoding.UTF8)); } catch (Exception ex) { error = $"snapshot.json 无法解析:{ex.Message}"; RememberValidationFailure(directory, error); return false; } if (snapshot?.anchor == null || string.IsNullOrWhiteSpace(snapshot.anchor.sceneSoName) || string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId) || string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)) { error = "快照缺少完整 Yarn anchor。"; snapshot = null; RememberValidationFailure(directory, error); return false; } if (entry.Meta != null && (!string.Equals(snapshot.anchor.sceneSoName, entry.Meta.sceneSoName, StringComparison.Ordinal) || !string.Equals(snapshot.anchor.yarnProjectId, entry.Meta.yarnProjectId, StringComparison.Ordinal) || !string.Equals(snapshot.anchor.nodeName, entry.Meta.nodeName, StringComparison.Ordinal))) { error = "快照 anchor 与 meta 不一致。"; snapshot = null; RememberValidationFailure(directory, error); return false; } _validationFailures.Remove(directory); return true; } } public bool Validate(TestSaveEntry entry, out string error) { return TryLoad(entry, out _, out error); } public bool Delete(TestSaveEntry entry, out string error) { lock (_gate) { if (entry == null || !TryResolveInsideRoot(entry.DirectoryPath, out var directory)) { error = "拒绝删除仓库目录外的路径。"; return false; } try { DeleteDirectoryIfPresent(directory); _validationFailures.Remove(directory); RemoveEmptyParents(Path.GetDirectoryName(directory)); error = null; return true; } catch (Exception ex) { error = ex.Message; return false; } } } public int DeleteInvalid(out string error) { lock (_gate) { try { var invalid = Scan().Entries.Where(item => !item.IsValid).ToArray(); foreach (var entry in invalid) { if (TryResolveInsideRoot(entry.DirectoryPath, out var directory)) { DeleteDirectoryIfPresent(directory); _validationFailures.Remove(directory); } } RemoveEmptyDirectories(); error = null; return invalid.Length; } catch (Exception ex) { error = ex.Message; return 0; } } } public bool Clear(out string error) { lock (_gate) { try { if (Directory.Exists(_rootPath)) { Directory.Delete(_rootPath, true); } _validationFailures.Clear(); error = null; return true; } catch (Exception ex) { error = ex.Message; return false; } } } private TestSaveEntry ReadEntryMeta(string directory, string currentGameVersion) { if (!TryResolveInsideRoot(directory, out var safeDirectory)) { return CreateInvalidEntry(directory, TestSaveEntryStatus.UnsafePath, "路径越过测试存档根目录。"); } TestSaveMeta meta; try { meta = JsonConvert.DeserializeObject( File.ReadAllText(Path.Combine(safeDirectory, MetaFileName), Encoding.UTF8)); } catch (Exception ex) { return CreateInvalidEntry( safeDirectory, TestSaveEntryStatus.CorruptMeta, $"meta.json 无法解析:{ex.Message}"); } if (meta == null) { return CreateInvalidEntry(safeDirectory, TestSaveEntryStatus.CorruptMeta, "meta.json 内容为空。"); } var entry = CreateEntry(safeDirectory, meta); if (meta.libraryVersion != TestSaveMeta.CurrentLibraryVersion) { entry.Status = TestSaveEntryStatus.CorruptMeta; entry.StatusMessage = $"测试存档库版本 {meta.libraryVersion} 不受支持。"; } else if (string.IsNullOrWhiteSpace(meta.sceneSoName) || string.IsNullOrWhiteSpace(meta.yarnProjectId) || string.IsNullOrWhiteSpace(meta.nodeName) || string.IsNullOrWhiteSpace(meta.dedupeKey)) { entry.Status = TestSaveEntryStatus.InvalidAnchor; entry.StatusMessage = "meta 缺少完整 Yarn anchor。"; } else if (!File.Exists(entry.SnapshotPath)) { entry.Status = TestSaveEntryStatus.MissingSnapshot; entry.StatusMessage = "缺少 snapshot.json。"; } else if (meta.snapshotSchemaVersion != SaveSnapshotSchema.CurrentVersion) { entry.Status = TestSaveEntryStatus.SchemaMismatch; entry.StatusMessage = $"快照结构版本 {meta.snapshotSchemaVersion},当前版本 {SaveSnapshotSchema.CurrentVersion}。"; } else { entry.Status = TestSaveEntryStatus.Valid; entry.StatusMessage = string.Empty; } entry.HasVersionWarning = entry.IsValid && !string.IsNullOrWhiteSpace(currentGameVersion) && !string.Equals(meta.gameVersion, currentGameVersion, StringComparison.Ordinal); if (entry.IsValid && _validationFailures.TryGetValue(safeDirectory, out var validationError)) { entry.Status = TestSaveEntryStatus.CorruptSnapshot; entry.StatusMessage = validationError; entry.HasVersionWarning = false; } return entry; } private void RememberValidationFailure(string directory, string error) { if (!string.IsNullOrWhiteSpace(directory)) { _validationFailures[directory] = error ?? "快照验证失败。"; } } private static TestSaveEntry CreateEntry(string directory, TestSaveMeta meta) { return new TestSaveEntry { Meta = meta, DirectoryPath = directory, SnapshotPath = Path.Combine(directory, SnapshotFileName), MetaPath = Path.Combine(directory, MetaFileName), ThumbnailPath = Path.Combine(directory, ThumbnailFileName) }; } private static TestSaveEntry CreateInvalidEntry( string directory, TestSaveEntryStatus status, string message) { var entry = CreateEntry(directory, null); entry.Status = status; entry.StatusMessage = message; return entry; } private long NextFirstSeenOrder() { var max = 0L; foreach (var metaPath in Directory.EnumerateFiles( _rootPath, MetaFileName, SearchOption.AllDirectories)) { var meta = TryReadMeta(metaPath); if (meta != null && meta.firstSeenOrder > max) { max = meta.firstSeenOrder; } } return max + 1; } private static TestSaveMeta TryReadMeta(string metaPath) { try { return !File.Exists(metaPath) ? null : JsonConvert.DeserializeObject( File.ReadAllText(metaPath, Encoding.UTF8)); } catch { return null; } } private void RecoverTransactions() { if (!Directory.Exists(_rootPath)) { return; } foreach (var staging in Directory.EnumerateDirectories( _rootPath, "*" + StagingSuffix, SearchOption.AllDirectories).ToArray()) { var final = staging.Substring(0, staging.Length - StagingSuffix.Length); if (!Directory.Exists(final) && File.Exists(Path.Combine(staging, MetaFileName)) && File.Exists(Path.Combine(staging, SnapshotFileName))) { Directory.Move(staging, final); } else { DeleteDirectoryIfPresent(staging); } } foreach (var backup in Directory.EnumerateDirectories( _rootPath, "*" + BackupSuffix, SearchOption.AllDirectories).ToArray()) { var final = backup.Substring(0, backup.Length - BackupSuffix.Length); if (!Directory.Exists(final)) { Directory.Move(backup, final); } else { DeleteDirectoryIfPresent(backup); } } } private string ResolveInsideRoot(params string[] segments) { var parts = new List { _rootPath }; parts.AddRange(segments); var candidate = Path.GetFullPath(Path.Combine(parts.ToArray())); if (!IsInsideRoot(candidate)) { throw new InvalidOperationException("Resolved path escaped the test save root."); } return candidate; } private bool TryResolveInsideRoot(string path, out string safePath) { safePath = null; if (string.IsNullOrWhiteSpace(path)) { return false; } try { var candidate = Path.GetFullPath(path); if (!IsInsideRoot(candidate)) { return false; } safePath = candidate; return true; } catch { return false; } } private bool IsInsideRoot(string candidate) { return candidate.StartsWith(_rootPathWithSeparator, StringComparison.OrdinalIgnoreCase); } private static bool IsTransactionDirectory(string path) { return path.EndsWith(StagingSuffix, StringComparison.Ordinal) || path.EndsWith(BackupSuffix, StringComparison.Ordinal); } private static string Slug(string value, string fallback) { var source = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); var invalid = Path.GetInvalidFileNameChars(); var builder = new StringBuilder(Math.Min(source.Length, 48)); foreach (var character in source) { if (builder.Length >= 48) break; builder.Append(invalid.Contains(character) || char.IsControl(character) ? '_' : character); } var result = builder.ToString().Trim('.', ' '); return string.IsNullOrWhiteSpace(result) ? fallback : result; } public static string StableHash(string value) { using var sha = SHA256.Create(); return string.Concat(sha.ComputeHash(Encoding.UTF8.GetBytes(value ?? string.Empty)) .Select(valueByte => valueByte.ToString("x2"))); } private static string ShortHash(string value) => StableHash(value).Substring(0, 12); private static bool PathsEqual(string left, string right) { return string.Equals( Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); } private static void DeleteDirectoryIfPresent(string path) { if (Directory.Exists(path)) { Directory.Delete(path, true); } } private void RemoveEmptyParents(string directory) { while (!string.IsNullOrEmpty(directory) && IsInsideRoot(directory) && Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any()) { Directory.Delete(directory); directory = Path.GetDirectoryName(directory); } } private void RemoveEmptyDirectories() { if (!Directory.Exists(_rootPath)) return; foreach (var directory in Directory.EnumerateDirectories( _rootPath, "*", SearchOption.AllDirectories) .OrderByDescending(path => path.Length)) { if (!Directory.EnumerateFileSystemEntries(directory).Any()) { Directory.Delete(directory); } } } } } #endif