621 lines
23 KiB
C#
621 lines
23 KiB
C#
#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
|
|
{
|
|
/// <summary>
|
|
/// 机器本地测试存档仓库。目录级 staging/backup 交换保证替换同节点时不会留下半份快照。
|
|
/// </summary>
|
|
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<string, string> _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<TestSaveEntry>();
|
|
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<TestSaveMeta>(
|
|
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<TestSaveMeta>(
|
|
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<string> { _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
|