Files
ft/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs
Liubing\LB 6813da149a feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。
保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。

验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。
包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
2026-09-30 19:19:05 +08:00

535 lines
27 KiB
C#

using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using game.Account;
namespace game.PlayerDataStorage
{
/// <summary>
/// Owns exactly one authenticated session and its KV bridge. Does not initialize gameplay or write.
/// The caller must await the previous context's CloseAsync before creating the next context.
/// A failed Close must never be bypassed by constructing another instance.
/// </summary>
public sealed class PlayerKVLoginContext
{
readonly object gate = new object();
readonly string requestedId, cacheRoot;
readonly long epoch;
readonly Uri api;
readonly Func<long, bool> externalCurrent;
readonly Func<S101AccountSession> sessionFactory;
readonly Func<PlayerKVGameSessionBridge> bridgeFactory;
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
readonly TaskCompletionSource<bool> workSettled = NewSignal();
TaskCompletionSource<PlayerKVLoginPrepareResult> preparation;
TaskCompletionSource<bool> closing;
CancellationToken firstToken;
CancellationTokenRegistration registration;
S101AccountSession session;
CookieContainer cookies;
PlayerKVGameSessionBridge bridge;
PlayerKVGameSessionHandle handle;
PlayerKVAccountDescriptor accountDescriptor;
PlayerKVAccountLease accountLease;
bool leaseIssued;
string playerId, predicateExceptionType;
int revoked, prepared, differentTokenCount;
// Internal factories are only for synthetic transport/cleanup faults. No public session or handle escapes.
public PlayerKVLoginContext(string playerId, string cacheRoot, long epoch, Func<long, bool> isCurrent)
: this(playerId, cacheRoot, epoch, isCurrent, new Uri(S101AccountSession.DefaultApiUrl),
() => new S101AccountSession(), () => new PlayerKVGameSessionBridge()) { }
internal PlayerKVLoginContext(string playerId, string cacheRoot, long epoch, Func<long, bool> isCurrent,
Uri api, Func<S101AccountSession> sessionFactory, Func<PlayerKVGameSessionBridge> bridgeFactory)
{
requestedId = playerId; this.cacheRoot = cacheRoot; this.epoch = epoch; externalCurrent = isCurrent;
this.api = api; this.sessionFactory = sessionFactory; this.bridgeFactory = bridgeFactory;
}
internal int DifferentTokenCount => Volatile.Read(ref differentTokenCount);
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)
{
lease = null;
if (capability == null || !IsCurrent) return false;
lock (gate)
{
if (closing != null || revoked != 0 || prepared == 0 || leaseIssued) return false;
leaseIssued = true;
accountLease = new PlayerKVAccountLease(api, epoch, cookies, capability, () => IsCurrent,
() => Volatile.Read(ref revoked) == 0);
// A synthetic factory can synchronously revoke this context while being invoked.
if (revoked != 0 || closing != null) return false;
lease = accountLease;
return true;
}
}
/// <summary>Historical Prepared results and their data survive Close, but their authority does not.</summary>
public bool IsCurrent
{
get
{
if (Volatile.Read(ref prepared) == 0 || !EpochCurrent(epoch)) return false;
var observed = session;
return observed != null && observed.IsAuthenticated && observed.PlayerId == playerId &&
ReferenceEquals(observed.Cookies, cookies) && Volatile.Read(ref revoked) == 0;
}
}
/// <summary>
/// One task per instance. Only the FIRST token owns the lifetime; a different token on later calls
/// is diagnosed but never replaces it. Even after Close this returns the original historical task.
/// Cancellation/failure does not complete until transport and Close have really drained. A transport
/// ignoring cancellation can hold this task indefinitely; observe CloseAsync, never bypass its barrier.
/// </summary>
public Task<PlayerKVLoginPrepareResult> PrepareAsync(CancellationToken lifetimeToken = default)
{
Task<PlayerKVLoginPrepareResult> result;
bool start, alreadyClosing;
lock (gate)
{
start = preparation == null;
if (start)
{
preparation = new TaskCompletionSource<PlayerKVLoginPrepareResult>(TaskCreationOptions.RunContinuationsAsynchronously);
firstToken = lifetimeToken;
}
else if (firstToken != lifetimeToken) Interlocked.Increment(ref differentTokenCount);
result = preparation.Task;
alreadyClosing = closing != null;
}
if (!start)
{
if (firstToken != lifetimeToken) Debug.WriteLine("KV PrepareAsync ignored a different lifetime token; the first token still owns this context.");
return result;
}
if (alreadyClosing) _ = CompleteFailureAsync(new Attempt(), PlayerKVLoginPrepareError.Cancelled);
else _ = PrepareCoreAsync();
return result;
}
sealed class Attempt
{
internal AccountLoginError? AccountError;
internal int? HttpStatus;
internal PlayerKVGameOpenError? OpenError;
internal PlayerKVSessionOpenError? OwnerError;
internal PlayerKVError? KVError;
internal PlayerKVSyncStatus? SyncStatus;
internal string OwnerId, ExceptionType;
}
async Task PrepareCoreAsync()
{
var evidence = new Attempt();
var error = PlayerKVLoginPrepareError.Unexpected;
try
{
registration = firstToken.Register(() => { _ = CloseAsync(); });
if (!Validate()) error = PlayerKVLoginPrepareError.Validation;
else if (!EpochCurrent(epoch)) error = InactiveError();
else
{
session = sessionFactory() ?? throw new InvalidOperationException("Session factory returned null.");
if (session.ApiBaseUri != api) error = PlayerKVLoginPrepareError.Validation;
else
{
var auth = await session.LoginAsync(playerId, lifetime.Token).ConfigureAwait(false);
evidence.AccountError = auth.Error; evidence.HttpStatus = auth.StatusCode;
if (!EpochCurrent(epoch)) error = InactiveError();
else if (!auth.Success) error = PlayerKVLoginPrepareError.AuthenticationFailed;
else
{
cookies = session.Cookies;
bridge = bridgeFactory() ?? throw new InvalidOperationException("Bridge factory returned null.");
var opened = await bridge.OpenAsync(session, cacheRoot, playerId, api, epoch,
EpochCurrent, lifetime.Token).ConfigureAwait(false);
evidence.OpenError = opened.Error;
if (opened.Error == PlayerKVGameOpenError.OwnerRejected)
{ evidence.OwnerError = opened.OwnerError; evidence.KVError = opened.TransportError; }
if (!EpochCurrent(epoch)) error = InactiveError();
else if (!opened.Success) error = PlayerKVLoginPrepareError.OwnerRejected;
else
{
handle = opened.Handle;
evidence.OwnerId = handle.OwnerInstanceId;
var read = handle.ReadLocalSnapshot();
evidence.SyncStatus = handle.Status;
if (!EpochCurrent(epoch)) error = InactiveError();
else if (!read.Success) error = read.Error == PlayerKVLocalReadError.StaleOrClosing
? PlayerKVLoginPrepareError.Stale : PlayerKVLoginPrepareError.NotReady;
else
{
var success = Result(evidence, PlayerKVLoginPrepareError.None, read.Data);
// External callback is never invoked under our monitor. Close/reentry is rechecked at publication.
if (!EpochCurrent(epoch)) error = InactiveError();
else lock (gate)
{
if (revoked == 0)
{
accountDescriptor = new PlayerKVAccountDescriptor(session.PlayerId,
epoch, session.InternalId, session.NewlyCreated,
session.CreateTimeUtc, session.ServerTimeUtc);
Volatile.Write(ref prepared, 1);
preparation.TrySetResult(success);
return;
}
error = PlayerKVLoginPrepareError.Cancelled;
}
}
}
}
}
}
}
catch (Exception ex)
{
evidence.ExceptionType = ex.GetType().Name;
error = Volatile.Read(ref revoked) != 0 ? PlayerKVLoginPrepareError.Cancelled : PlayerKVLoginPrepareError.Unexpected;
}
finally { workSettled.TrySetResult(true); }
evidence.ExceptionType = evidence.ExceptionType ?? Volatile.Read(ref predicateExceptionType);
await CompleteFailureAsync(evidence, error).ConfigureAwait(false);
}
bool Validate()
{
if (epoch < 0 || externalCurrent == null || sessionFactory == null || bridgeFactory == null ||
string.IsNullOrWhiteSpace(cacheRoot) || !Path.IsPathFullyQualified(cacheRoot) ||
!long.TryParse(requestedId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0 ||
api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") ||
api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0) return false;
try { Path.GetFullPath(cacheRoot); }
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException) { return false; }
playerId = id.ToString(CultureInfo.InvariantCulture);
return true;
}
bool EpochCurrent(long observedEpoch)
{
if (Volatile.Read(ref revoked) != 0 || observedEpoch != epoch) return false;
try { return externalCurrent != null && externalCurrent(epoch) && Volatile.Read(ref revoked) == 0; }
catch (Exception ex)
{
Interlocked.CompareExchange(ref predicateExceptionType, ex.GetType().Name, null);
_ = CloseAsync();
return false;
}
}
PlayerKVLoginPrepareError InactiveError() => Volatile.Read(ref predicateExceptionType) != null
? PlayerKVLoginPrepareError.Unexpected : Volatile.Read(ref revoked) != 0
? PlayerKVLoginPrepareError.Cancelled : PlayerKVLoginPrepareError.Stale;
PlayerKVLoginPrepareResult Result(Attempt e, PlayerKVLoginPrepareError error,
System.Collections.Generic.IReadOnlyDictionary<string, string> data = null,
string cleanupException = null, PlayerKVLoginPrepareError? original = null) =>
new PlayerKVLoginPrepareResult(error, playerId, epoch, e.AccountError, e.HttpStatus,
e.OpenError, e.OwnerError, e.KVError, e.SyncStatus, e.OwnerId, data,
e.ExceptionType, cleanupException, original);
async Task CompleteFailureAsync(Attempt evidence, PlayerKVLoginPrepareError error)
{
try
{
await CloseAsync().ConfigureAwait(false);
preparation.TrySetResult(Result(evidence, error));
}
catch (Exception ex)
{
preparation.TrySetResult(Result(evidence, PlayerKVLoginPrepareError.CloseFailed,
cleanupException: ex.GetType().Name, original: error));
}
}
/// <summary>Synchronously revokes admission; drains preparation and bridge before disposing the session.</summary>
public Task CloseAsync()
{
TaskCompletionSource<bool> completion;
lock (gate)
{
if (closing != null) return closing.Task;
Volatile.Write(ref revoked, 1);
completion = closing = NewSignal();
if (preparation == null) workSettled.TrySetResult(true);
}
// A cancellation registration must not execute disposal of itself or user cancellation callbacks inline.
// Observe faults without changing the shared task, even when cancellation initiates a background Close.
_ = completion.Task.ContinueWith(t => { _ = t.Exception; }, CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
_ = Task.Run(() => CloseCoreAsync(completion));
return completion.Task;
}
async Task CloseCoreAsync(TaskCompletionSource<bool> completion)
{
try
{
Exception cancellationFailure = null;
try { lifetime.Cancel(); }
catch (Exception ex) { cancellationFailure = ex; }
await workSettled.Task.ConfigureAwait(false);
PlayerKVAccountLease borrowed;
lock (gate) borrowed = accountLease;
if (borrowed != null) await borrowed.CloseAsync().ConfigureAwait(false);
if (bridge != null) await bridge.CloseAsync().ConfigureAwait(false);
// Never dispose the session on a failed bridge close. References and the faulted task stay retained.
session?.Dispose();
registration.Dispose();
lifetime.Dispose();
if (cancellationFailure != null) throw cancellationFailure;
completion.TrySetResult(true);
}
catch (Exception ex) { completion.TrySetException(ex); }
}
static TaskCompletionSource<bool> NewSignal() =>
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
}
// Kept with the context so its existing explicitly linked regression host retains all dependencies.
internal sealed class PlayerKVAccountDescriptor
{
internal string PlayerId { get; }
internal long Epoch { get; }
internal uint InternalId { get; }
internal bool NewlyCreated { get; }
internal DateTime CreateTimeUtc { get; }
internal DateTime ServerTimeUtc { get; }
internal PlayerKVAccountDescriptor(string playerId, long epoch, uint internalId,
bool newlyCreated, DateTime createTimeUtc, DateTime serverTimeUtc)
{
PlayerId = playerId; Epoch = epoch; InternalId = internalId; NewlyCreated = newlyCreated;
CreateTimeUtc = createTimeUtc; ServerTimeUtc = serverTimeUtc;
}
}
/// <summary>Controlled transport factory; formal and Editor validation modes remain separate.</summary>
internal sealed class PlayerKVControlledLoadCapability
{
readonly Func<CookieContainer, HttpMessageHandler> 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, false);
#endif
internal HttpMessageHandler CreateHandler(CookieContainer cookies)
{
var handler = leaseHandler(cookies) ?? throw new InvalidOperationException("Transport handler required.");
var seen = new HashSet<HttpMessageHandler>();
var node = handler;
while (node != null)
{
// Disposing a cyclic DelegatingHandler graph recursively would overflow the stack.
// 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 (!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;
}
}
internal sealed class PlayerKVAccountResponse
{
internal bool IsCurrent { get; }
internal int? StatusCode { get; }
internal string Body { get; }
internal PlayerKVAccountResponse(bool isCurrent, int? statusCode = null, string body = null)
{ IsCurrent = isCurrent; StatusCode = isCurrent ? statusCode : null; Body = isCurrent ? body : null; }
}
/// <summary>A single context-owned, synthetic-only borrower. Close drains before disposing its client.</summary>
internal sealed class PlayerKVAccountLease
{
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();
TaskCompletionSource<bool> drained;
TaskCompletionSource<bool> closing;
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
{
lock (gate) if (closing != null) return false;
return current();
}
}
internal PlayerKVAccountLease(Uri api, long epoch, CookieContainer cookies,
PlayerKVControlledLoadCapability capability, Func<bool> current, Func<bool> admission)
{
if (capability == null) throw new ArgumentNullException(nameof(capability));
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)
{
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 lease method.", nameof(method));
if (!IsCurrent) return new PlayerKVAccountResponse(false);
lock (gate)
{
if (closing != null || !admission()) return new PlayerKVAccountResponse(false);
active++;
}
try
{
using (var linked = CancellationTokenSource.CreateLinkedTokenSource(token, lifetime.Token))
using (var request = new HttpRequestMessage(method, route))
{
if (body != null) request.Content = new StringContent(body, System.Text.Encoding.UTF8, contentType);
using (var response = await client.SendAsync(request, linked.Token).ConfigureAwait(false))
{
if (!IsCurrent || linked.IsCancellationRequested) return new PlayerKVAccountResponse(false);
var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
// No redirect following, even if a synthetic handler returns a Location header.
if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400)
throw new InvalidOperationException("Redirects are not accepted.");
return IsCurrent && !linked.IsCancellationRequested
? new PlayerKVAccountResponse(true, (int)response.StatusCode, text)
: new PlayerKVAccountResponse(false);
}
}
}
catch (OperationCanceledException) when (!IsCurrent || token.IsCancellationRequested || lifetime.IsCancellationRequested)
{ return new PlayerKVAccountResponse(false); }
finally
{
lock (gate)
{
active--;
if (active == 0) drained?.TrySetResult(true);
}
}
}
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;
lock (gate)
{
if (closing != null) return closing.Task;
completion = closing = Signal();
drained = Signal();
if (active == 0) drained.TrySetResult(true);
}
_ = completion.Task.ContinueWith(t => { _ = t.Exception; }, CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
_ = Task.Run(async () =>
{
try
{
Exception cancellationFailure = null;
try { lifetime.Cancel(); }
catch (Exception ex) { cancellationFailure = ex; }
await drained.Task.ConfigureAwait(false);
client.Dispose();
lifetime.Dispose();
if (cancellationFailure != null) throw cancellationFailure;
completion.TrySetResult(true);
}
catch (Exception ex) { completion.TrySetException(ex); }
});
return completion.Task;
}
static TaskCompletionSource<bool> Signal() =>
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
}
}