feat(upserver): 保存M2客户端KV迁移至本人统计写检查点

保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。
保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。

验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。
包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
This commit is contained in:
Liubing\LB
2026-09-30 19:19:05 +08:00
parent 1c0cdf7a99
commit 6813da149a
511 changed files with 151858 additions and 2376 deletions

View File

@@ -58,6 +58,21 @@ namespace game.PlayerDataStorage
internal PlayerKVAccountDescriptor AccountDescriptor =>
Volatile.Read(ref prepared) != 0 && IsCurrent ? accountDescriptor : null;
// Only the coordinator may publish this original handle after Apply and Guard release.
internal bool TryAcquireGameHandle(long expectedEpoch, out PlayerKVGameSessionHandle gameHandle)
{
gameHandle = null;
// IsCurrent can invoke external code and synchronously reenter CloseAsync.
if (expectedEpoch != epoch || !IsCurrent) return false;
lock (gate)
{
if (closing != null || revoked != 0 || prepared != 1 || handle == null || handle.Epoch != expectedEpoch)
return false;
gameHandle = handle;
return true;
}
}
// All issuance and Close admission share gate. The editor factory may not block on Close.
internal bool TryAcquireAccountLease(PlayerKVControlledLoadCapability capability,
out PlayerKVAccountLease lease)
@@ -317,21 +332,26 @@ namespace game.PlayerDataStorage
}
}
/// <summary>Construction is absent from player builds; no default network handler is supplied.</summary>
/// <summary>Controlled transport factory; formal and Editor validation modes remain separate.</summary>
internal sealed class PlayerKVControlledLoadCapability
{
readonly Func<CookieContainer, HttpMessageHandler> leaseHandler;
private PlayerKVControlledLoadCapability(Func<CookieContainer, HttpMessageHandler> leaseHandler)
{ this.leaseHandler = leaseHandler ?? throw new ArgumentNullException(nameof(leaseHandler)); }
readonly bool formal;
private PlayerKVControlledLoadCapability(Func<CookieContainer, HttpMessageHandler> leaseHandler, bool formal)
{ this.leaseHandler = leaseHandler ?? throw new ArgumentNullException(nameof(leaseHandler)); this.formal = formal; }
internal bool IsFormal => formal;
internal static PlayerKVControlledLoadCapability CreateForGame(
Func<CookieContainer, HttpMessageHandler> handler) => new PlayerKVControlledLoadCapability(handler, true);
#if UNITY_EDITOR
internal static PlayerKVControlledLoadCapability CreateForValidation(
Func<CookieContainer, HttpMessageHandler> leaseHandler) => new PlayerKVControlledLoadCapability(leaseHandler);
Func<CookieContainer, HttpMessageHandler> leaseHandler) => new PlayerKVControlledLoadCapability(leaseHandler, false);
#endif
internal HttpMessageHandler CreateHandler(CookieContainer cookies)
{
var handler = leaseHandler(cookies) ?? throw new InvalidOperationException("Synthetic handler required.");
var handler = leaseHandler(cookies) ?? throw new InvalidOperationException("Transport handler required.");
var seen = new HashSet<HttpMessageHandler>();
var node = handler;
while (node != null)
@@ -340,12 +360,23 @@ namespace game.PlayerDataStorage
// It never becomes a lease; the rejecting factory retains responsibility for that graph.
if (!seen.Add(node))
throw new InvalidOperationException("Cyclic transport is not a validation handler.");
if (node is HttpClientHandler ||
node.GetType().FullName == "System.Net.Http.SocketsHttpHandler")
if (!formal && (node is HttpClientHandler ||
node.GetType().FullName == "System.Net.Http.SocketsHttpHandler"))
{
handler.Dispose();
throw new InvalidOperationException("Real or cyclic transport is not a validation handler.");
}
if (formal && node is HttpClientHandler real &&
(!real.UseCookies || !ReferenceEquals(real.CookieContainer, cookies) || real.AllowAutoRedirect))
{
handler.Dispose();
throw new InvalidOperationException("Formal transport must share the session cookies and reject redirects.");
}
if (formal && node.GetType().FullName == "System.Net.Http.SocketsHttpHandler")
{
handler.Dispose();
throw new InvalidOperationException("Unverified formal transport handler.");
}
node = (node as DelegatingHandler)?.InnerHandler;
}
return handler;
@@ -366,6 +397,8 @@ namespace game.PlayerDataStorage
{
readonly object gate = new object();
readonly HttpClient client;
readonly CookieContainer cookies;
readonly bool formal;
readonly Func<bool> current;
readonly Func<bool> admission;
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
@@ -374,6 +407,8 @@ namespace game.PlayerDataStorage
int active;
internal Uri ApiBaseUri { get; }
internal long Epoch { get; }
internal bool IsFormal => formal;
internal CookieContainer FormalCookies => formal && IsCurrent ? cookies : null;
internal bool IsCurrent
{
get
@@ -387,18 +422,24 @@ namespace game.PlayerDataStorage
PlayerKVControlledLoadCapability capability, Func<bool> current, Func<bool> admission)
{
if (capability == null) throw new ArgumentNullException(nameof(capability));
ApiBaseUri = api; Epoch = epoch; this.current = current; this.admission = admission;
ApiBaseUri = api; Epoch = epoch; this.cookies = cookies;
formal = capability.IsFormal; this.current = current; this.admission = admission;
client = new HttpClient(capability.CreateHandler(cookies)) { BaseAddress = api };
if (formal) client.Timeout = TimeSpan.FromSeconds(15);
}
internal async Task<PlayerKVAccountResponse> SendAsync(HttpMethod method, string route,
string body = null, string contentType = "application/json", CancellationToken token = default)
{
// Exact canonical relative paths: no query, fragment, encoding, traversal or absolute URL.
if (route != "__kv_validation/ok" && route != "__kv_validation/unauthorized")
if (formal)
{
if (!IsCanonicalBusinessRoute(route))
throw new ArgumentException("Only canonical relative business routes are allowed.", nameof(route));
}
else if (route != "__kv_validation/ok" && route != "__kv_validation/unauthorized")
throw new ArgumentException("Only canonical validation routes are allowed.", nameof(route));
if (method != HttpMethod.Get && method != HttpMethod.Post)
throw new ArgumentException("Unsupported validation method.", nameof(method));
throw new ArgumentException("Unsupported lease method.", nameof(method));
if (!IsCurrent) return new PlayerKVAccountResponse(false);
lock (gate)
{
@@ -436,6 +477,27 @@ namespace game.PlayerDataStorage
}
}
static bool IsCanonicalBusinessRoute(string route)
{
if (string.IsNullOrEmpty(route) || route[0] == '/' || route[0] == '\\' ||
Uri.TryCreate(route, UriKind.Absolute, out _) ||
route.IndexOf("//", StringComparison.Ordinal) >= 0 ||
route.IndexOfAny(new[] { '?', '#', '%', '\\' }) >= 0)
return false;
var segments = route.Split('/');
foreach (var segment in segments)
{
if (segment.Length == 0 || segment == "." || segment == "..") return false;
foreach (var character in segment)
if (char.IsControl(character)) return false;
}
return !route.Equals("__kv_validation", StringComparison.OrdinalIgnoreCase) &&
!route.StartsWith("__kv_validation/", StringComparison.OrdinalIgnoreCase) &&
!route.Equals("account/Login", StringComparison.OrdinalIgnoreCase) &&
!route.Equals("playerdata", StringComparison.OrdinalIgnoreCase) &&
!route.StartsWith("playerdata/", StringComparison.OrdinalIgnoreCase);
}
internal Task CloseAsync()
{
TaskCompletionSource<bool> completion;