TH1/Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs
2026-05-20 18:54:56 +08:00

383 lines
14 KiB
C#

/*
* @Author: Codex
* @Description: 玩家主动 Bug 汇报打包服务
* @Date: 2026年05月19日
*/
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using Logic.Config;
using TH1_Logic.Config;
using UnityEngine;
namespace TH1_Logic.Oss
{
public enum PlayerBugArchiveKind
{
Begin,
Continue,
End
}
[Serializable]
public class PlayerBugReportArchiveManifest
{
public string mode;
public uint mapId;
public string kind;
public string sourceFileName;
public string zipEntry;
public long fileSize;
public string lastWriteTimeUtc;
}
[Serializable]
public class PlayerBugReportManifest
{
public string schema = "th1.player-bug-report.v1";
public string reportId;
public string createdAtUtc;
public string steamId;
public string version;
public string unityVersion;
public string platform;
public string description;
public int archiveCount;
public PlayerBugReportArchiveManifest[] archives = Array.Empty<PlayerBugReportArchiveManifest>();
}
public class PlayerBugReportArchiveSession
{
public bool IsMulti;
public uint MapId;
public string BeginPath;
public string CompanionPath;
public PlayerBugArchiveKind CompanionKind;
public DateTime LatestWriteTimeUtc;
public string ModeLabel => IsMulti ? "multi" : "single";
public string ModeDisplay => IsMulti ? "联机" : "单机";
public string CompanionKindLabel => CompanionKind == PlayerBugArchiveKind.End ? "end" : "continue";
public long TotalBytes
{
get
{
long total = 0;
if (!string.IsNullOrEmpty(BeginPath) && File.Exists(BeginPath))
total += new FileInfo(BeginPath).Length;
if (!string.IsNullOrEmpty(CompanionPath) && File.Exists(CompanionPath))
total += new FileInfo(CompanionPath).Length;
return total;
}
}
}
public class PlayerBugReportPackage
{
public byte[] Data;
public PlayerBugReportManifest Manifest;
public List<PlayerBugReportArchiveSession> Sessions;
}
public static class PlayerBugReportService
{
public const int MaxBugReportUploadBytes = 10 * 1024 * 1024;
private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false);
public static string ConfigDirectory =>
Path.GetFullPath(Path.Combine(Application.persistentDataPath, "../Config"));
public static string GetCurrentVersion()
{
try
{
if (ConfigManager.Instance.VersionCfg == null)
ConfigManager.Instance.VersionCfg = Resources.Load<VersionConfig>("Export/VersionConfig");
return ConfigManager.Instance.VersionCfg?.CurVersionInfo?.Version ?? Application.version;
}
catch (Exception)
{
return Application.version;
}
}
public static PlayerBugReportArchiveSession GetMostRecentArchiveSession()
{
return GetLatestArchiveSessions(true, true)
.OrderByDescending(session => session.LatestWriteTimeUtc)
.FirstOrDefault();
}
public static List<PlayerBugReportArchiveSession> GetLatestArchiveSessions(bool includeSingle, bool includeMulti)
{
var sessions = new List<PlayerBugReportArchiveSession>();
if (includeSingle && TryGetLatestArchiveSession(false, out var singleSession))
sessions.Add(singleSession);
if (includeMulti && TryGetLatestArchiveSession(true, out var multiSession))
sessions.Add(multiSession);
sessions.Sort((a, b) => b.LatestWriteTimeUtc.CompareTo(a.LatestWriteTimeUtc));
return sessions;
}
public static PlayerBugReportPackage BuildPackage(string steamId, string description, string version,
bool includeArchives, bool includeSingleArchive, bool includeMultiArchive)
{
var sessions = includeArchives
? GetLatestArchiveSessions(includeSingleArchive, includeMultiArchive)
: new List<PlayerBugReportArchiveSession>();
var reportId = Guid.NewGuid().ToString("N");
var manifestArchives = new List<PlayerBugReportArchiveManifest>();
var manifest = new PlayerBugReportManifest
{
reportId = reportId,
createdAtUtc = DateTime.UtcNow.ToString("O"),
steamId = steamId ?? "",
version = string.IsNullOrWhiteSpace(version) ? GetCurrentVersion() : version.Trim(),
unityVersion = Application.unityVersion,
platform = Application.platform.ToString(),
description = description ?? ""
};
using var stream = new MemoryStream();
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true, Utf8NoBom))
{
WriteTextEntry(zip, "description.txt", manifest.description);
foreach (var session in sessions)
{
AddArchiveFile(zip, session, PlayerBugArchiveKind.Begin, session.BeginPath, manifestArchives);
AddArchiveFile(zip, session, session.CompanionKind, session.CompanionPath, manifestArchives);
}
manifest.archiveCount = manifestArchives.Count;
manifest.archives = manifestArchives.ToArray();
WriteTextEntry(zip, "manifest.json", JsonUtility.ToJson(manifest, true));
}
return new PlayerBugReportPackage
{
Data = stream.ToArray(),
Manifest = manifest,
Sessions = sessions
};
}
private static bool TryGetLatestArchiveSession(bool isMulti, out PlayerBugReportArchiveSession session)
{
session = null;
var files = GetArchiveFiles(isMulti);
if (files.Count == 0) return false;
var groups = new Dictionary<uint, ArchiveGroup>();
foreach (var file in files)
{
if (!groups.TryGetValue(file.MapId, out var group))
{
group = new ArchiveGroup { MapId = file.MapId, IsMulti = isMulti };
groups[file.MapId] = group;
}
group.Add(file);
}
session = groups.Values
.Select(group => group.TryBuildSession())
.Where(value => value != null)
.OrderByDescending(value => value.LatestWriteTimeUtc)
.FirstOrDefault();
return session != null;
}
private static List<ArchiveFile> GetArchiveFiles(bool isMulti)
{
var result = new List<ArchiveFile>();
var directory = ConfigDirectory;
if (!Directory.Exists(directory)) return result;
foreach (var pattern in new[] { "map_archive_*.dat", "map_archive_*.dat.bak" })
{
foreach (var path in Directory.GetFiles(directory, pattern))
{
if (TryParseArchiveFile(path, out var file) && file.IsMulti == isMulti)
result.Add(file);
}
}
return result;
}
private static bool TryParseArchiveFile(string path, out ArchiveFile file)
{
file = null;
var fileName = Path.GetFileName(path);
if (string.IsNullOrEmpty(fileName)) return false;
if (fileName.EndsWith(".bak", StringComparison.OrdinalIgnoreCase))
fileName = fileName.Substring(0, fileName.Length - ".bak".Length);
if (!fileName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) return false;
var stem = fileName.Substring(0, fileName.Length - ".dat".Length);
const string prefix = "map_archive_";
if (!stem.StartsWith(prefix, StringComparison.Ordinal)) return false;
var rest = stem.Substring(prefix.Length);
var kind = PlayerBugArchiveKind.Continue;
if (rest.StartsWith("begin_", StringComparison.Ordinal))
{
kind = PlayerBugArchiveKind.Begin;
rest = rest.Substring("begin_".Length);
}
else if (rest.StartsWith("continue_", StringComparison.Ordinal))
{
kind = PlayerBugArchiveKind.Continue;
rest = rest.Substring("continue_".Length);
}
else if (rest.StartsWith("end_", StringComparison.Ordinal))
{
kind = PlayerBugArchiveKind.End;
rest = rest.Substring("end_".Length);
}
else
{
return false;
}
var isMulti = false;
if (rest.StartsWith("multi_", StringComparison.Ordinal))
{
isMulti = true;
rest = rest.Substring("multi_".Length);
}
if (!uint.TryParse(rest, out var mapId)) return false;
file = new ArchiveFile
{
Path = path,
Kind = kind,
IsMulti = isMulti,
MapId = mapId,
LastWriteTimeUtc = File.GetLastWriteTimeUtc(path)
};
return true;
}
private static void AddArchiveFile(ZipArchive zip, PlayerBugReportArchiveSession session,
PlayerBugArchiveKind kind, string sourcePath, List<PlayerBugReportArchiveManifest> manifestArchives)
{
if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)) return;
var kindLabel = GetKindLabel(kind);
var sourceFileName = Path.GetFileName(sourcePath);
var entryPath = $"saves/{session.ModeLabel}/{sourceFileName}";
var entry = zip.CreateEntry(entryPath, System.IO.Compression.CompressionLevel.Optimal);
using (var entryStream = entry.Open())
using (var fileStream = File.OpenRead(sourcePath))
{
fileStream.CopyTo(entryStream);
}
var fileInfo = new FileInfo(sourcePath);
manifestArchives.Add(new PlayerBugReportArchiveManifest
{
mode = session.ModeLabel,
mapId = session.MapId,
kind = kindLabel,
sourceFileName = sourceFileName,
zipEntry = entryPath,
fileSize = fileInfo.Length,
lastWriteTimeUtc = fileInfo.LastWriteTimeUtc.ToString("O")
});
}
private static void WriteTextEntry(ZipArchive zip, string path, string text)
{
var entry = zip.CreateEntry(path, System.IO.Compression.CompressionLevel.Optimal);
using var entryStream = entry.Open();
var bytes = Utf8NoBom.GetBytes(text ?? "");
entryStream.Write(bytes, 0, bytes.Length);
}
private static string GetKindLabel(PlayerBugArchiveKind kind)
{
return kind switch
{
PlayerBugArchiveKind.Begin => "begin",
PlayerBugArchiveKind.End => "end",
_ => "continue"
};
}
private class ArchiveFile
{
public string Path;
public PlayerBugArchiveKind Kind;
public bool IsMulti;
public uint MapId;
public DateTime LastWriteTimeUtc;
}
private class ArchiveGroup
{
public uint MapId;
public bool IsMulti;
private ArchiveFile _begin;
private ArchiveFile _continue;
private ArchiveFile _end;
public void Add(ArchiveFile file)
{
switch (file.Kind)
{
case PlayerBugArchiveKind.Begin:
if (_begin == null || file.LastWriteTimeUtc > _begin.LastWriteTimeUtc) _begin = file;
break;
case PlayerBugArchiveKind.End:
if (_end == null || file.LastWriteTimeUtc > _end.LastWriteTimeUtc) _end = file;
break;
default:
if (_continue == null || file.LastWriteTimeUtc > _continue.LastWriteTimeUtc) _continue = file;
break;
}
}
public PlayerBugReportArchiveSession TryBuildSession()
{
if (_begin == null) return null;
var companion = PickCompanion();
if (companion == null) return null;
return new PlayerBugReportArchiveSession
{
IsMulti = IsMulti,
MapId = MapId,
BeginPath = _begin.Path,
CompanionPath = companion.Path,
CompanionKind = companion.Kind,
LatestWriteTimeUtc = _begin.LastWriteTimeUtc > companion.LastWriteTimeUtc
? _begin.LastWriteTimeUtc
: companion.LastWriteTimeUtc
};
}
private ArchiveFile PickCompanion()
{
if (_end == null) return _continue;
if (_continue == null) return _end;
return _end.LastWriteTimeUtc >= _continue.LastWriteTimeUtc ? _end : _continue;
}
}
}
}