保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。 保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。 验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。 包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
311 lines
17 KiB
Plaintext
311 lines
17 KiB
Plaintext
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using asap.core;
|
|
using game.PlayerDataStorage;
|
|
using GameCore;
|
|
using tysdk;
|
|
using UnityEngine;
|
|
|
|
namespace game
|
|
{
|
|
// One service belongs to one login lifetime. A revoked instance is never rebound.
|
|
public sealed class KVUserService : IUserService, IKVUserLoadReceiver, IKVUserLifecycle
|
|
{
|
|
[Inject] public ICustomServerMgr customServerMgr { get; set; }
|
|
[Inject] public IPlayerSaveStore SaveStore { get; set; }
|
|
readonly PlayerKVRuntimeSelection selection;
|
|
readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
|
|
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
|
|
readonly Dictionary<string, PlayInfo> profiles = new Dictionary<string, PlayInfo>();
|
|
readonly Dictionary<string, int> levels = new Dictionary<string, int>();
|
|
PlayerKVGameLoadCoordinator coordinator;
|
|
PlayerKVGameSessionHandle handle;
|
|
PlayerKVInitializationIntents intents;
|
|
bool attempted, loading, closing, published, promptsBound;
|
|
Task closeTask;
|
|
long epoch;
|
|
string playerId;
|
|
uint internalId;
|
|
public string CustomId { get; set; }
|
|
public string UserId => IsAuthorized ? playerId : null;
|
|
public string ApiPlayerId => UserId;
|
|
public uint InternalId => IsAuthorized ? internalId : 0;
|
|
public bool IsLogin => IsAuthorized;
|
|
public bool IsAuthorized => published && KVHandoffReady;
|
|
public CancellationToken LoginLifetime => lifetime.Token;
|
|
public DateTime LastLoginTime { get; private set; }
|
|
public DateTime LoginTime { get; private set; }
|
|
public DateTime CreateTime { get; private set; }
|
|
public string DisplayName { get; private set; }
|
|
public string AvatarUrl { get; private set; }
|
|
public string Language { get; private set; }
|
|
public string ContinentCode { get; private set; }
|
|
public Dictionary<string, string> EAccoutUrl { get; private set; } = new Dictionary<string, string>();
|
|
internal int KVTapJoyBalance { get; private set; }
|
|
internal PlayerKVGameLoadError KVLastError { get; private set; }
|
|
internal bool KVHandoffReady => !closing && handle != null && coordinator != null &&
|
|
coordinator.TryGetCurrentGameHandle(epoch, out var current) && ReferenceEquals(current, handle);
|
|
bool IKVUserLifecycle.KVHandoffReady => KVHandoffReady;
|
|
PlayerKVGameLoadError IKVUserLifecycle.KVLastError => KVLastError;
|
|
|
|
public KVUserService() : this(SubBoostrap.RuntimeSelection, jar => new HttpClientHandler
|
|
{ CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { }
|
|
|
|
internal KVUserService(PlayerKVRuntimeSelection selection, Func<CookieContainer, HttpMessageHandler> handlerFactory)
|
|
{
|
|
this.selection = selection;
|
|
this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory));
|
|
Application.quitting += Logout;
|
|
}
|
|
|
|
public async Task<ELoginStep> Login(Action<int> progress, bool tyrLoginByToken = true)
|
|
{
|
|
if (attempted || closing || selection == null || !selection.IsKV ||
|
|
!selection.TryAdmitPlayer(CustomId, out var canonicalId))
|
|
{ KVLastError = PlayerKVGameLoadError.Input; return ELoginStep.Api; }
|
|
// Set before the first await: duplicate calls cannot authenticate or subscribe twice.
|
|
attempted = true;
|
|
var requestedId = CustomId;
|
|
try
|
|
{
|
|
if (customServerMgr is not CustomServerMgr server ||
|
|
server.ApiBaseUri.AbsoluteUri != PlayerKVRuntimeSelection.FormalApiUrl)
|
|
throw new InvalidOperationException("Formal KV transport required.");
|
|
loading = true;
|
|
var transaction = PlayerKVGameplayBindings.Bind(this);
|
|
coordinator = new PlayerKVGameLoadCoordinator(transaction,
|
|
() => loading && !closing && !published && handle == null,
|
|
() => { }, () => intents?.Count ?? 0, server.BindKVForGame, server.ClearKVForGame);
|
|
server.KVUnauthorized += OnUnauthorized;
|
|
var identity = new PlayerKVIdentitySnapshot(null, false, PlayerKVIdentityChannel.none,
|
|
Array.Empty<PlayerKVIdentityChannel>());
|
|
var snapshot = PlayerKVGameplayBindings.Snapshot(DateTime.UtcNow, "formal-v1");
|
|
var capability = PlayerKVGameLoadCapability.CreateForGame(identity, snapshot, selection.Api,
|
|
Application.persistentDataPath, handlerFactory, handlerFactory, handlerFactory);
|
|
var result = await coordinator.LoadForGameAsync(canonicalId, capability, LoginLifetime,
|
|
() => !closing && CustomId == requestedId);
|
|
KVLastError = result.Error;
|
|
if (result.State != PlayerKVGameLoadState.GameLoaded || closing || CustomId != requestedId ||
|
|
result.Account == null || result.Account.PlayerId != canonicalId || result.Account.Epoch != result.Epoch ||
|
|
!coordinator.TryGetCurrentGameHandle(result.Epoch, out var original))
|
|
{ Logout(); return ELoginStep.Api; }
|
|
epoch = result.Epoch;
|
|
handle = original;
|
|
if (SaveStore is not KVPlayerSaveStore store) throw new InvalidOperationException("KV save store required.");
|
|
store.Bind(original, LoginLifetime);
|
|
PrepareProfile(result.Account);
|
|
BindPromptServices(original);
|
|
// Chat subscribes before the unique success event. No SDK or analytics initialization here.
|
|
GContext.container.Resolve<IChatService>().WaitingForLogin();
|
|
if (!KVHandoffReady || CustomId != requestedId) throw new InvalidOperationException("Stale KV login.");
|
|
server.EnableKVBusiness(epoch);
|
|
playerId = result.Account.PlayerId;
|
|
internalId = result.Account.InternalId;
|
|
published = true;
|
|
GContext.Publish(new EvtAPISvrLoginSuccess { playerId = playerId, internalId = internalId });
|
|
if (!IsAuthorized) return ELoginStep.Api;
|
|
_ = RunSaveScheduleAsync(store);
|
|
progress?.Invoke(2);
|
|
return ELoginStep.Done;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
KVLastError = PlayerKVGameLoadError.Apply;
|
|
Debug.LogError("KV login failed: " + ex.GetType().Name);
|
|
Logout();
|
|
return ELoginStep.Api;
|
|
}
|
|
finally { loading = false; }
|
|
}
|
|
|
|
void PrepareProfile(PlayerKVAccountDescriptor account)
|
|
{
|
|
if (account.InternalId == 0 || account.CreateTimeUtc == DateTime.MinValue || account.ServerTimeUtc == DateTime.MinValue)
|
|
throw new InvalidOperationException("Authenticated account metadata required.");
|
|
CreateTime = account.CreateTimeUtc;
|
|
LoginTime = account.ServerTimeUtc;
|
|
var previous = Read("LastLoginTimeUtc");
|
|
LastLoginTime = ParseLastLoginTime(previous.Value, previous.Found.Value);
|
|
DisplayName = Read("DisplayName").Value;
|
|
if (string.IsNullOrEmpty(DisplayName)) DisplayName = GetDefaultName(account.PlayerId);
|
|
AvatarUrl = Read("AvatarUrl").Value ?? string.Empty;
|
|
Language = Read("Language").Value;
|
|
if (string.IsNullOrEmpty(Language)) Language = GContext.container.Resolve<ISettingService>().Lang;
|
|
var continent = Read("ContinentCode").Value;
|
|
ContinentCode = new[] { "AF", "AN", "AS", "EU", "NA", "OC", "SA" }.Contains(continent) ? continent : null;
|
|
}
|
|
|
|
internal static DateTime ParseLastLoginTime(string value, bool found)
|
|
{
|
|
if (!found) return DateTime.MinValue;
|
|
if (!DateTime.TryParseExact(value, "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var time) ||
|
|
time == DateTime.MinValue || time.Kind == DateTimeKind.Unspecified)
|
|
throw new FormatException("Invalid LastLoginTimeUtc.");
|
|
return time.ToUniversalTime();
|
|
}
|
|
|
|
PlayerSaveReadResult Read(string key)
|
|
{
|
|
var result = SaveStore.Read(key);
|
|
if (!result.Success) throw new InvalidOperationException("KV profile read failed: " + result.Error);
|
|
return result;
|
|
}
|
|
|
|
void BindPromptServices(PlayerKVGameSessionHandle original)
|
|
{
|
|
if (promptsBound || !KVHandoffReady) throw new InvalidOperationException("Prompt binding is stale.");
|
|
var settings = GContext.container.Resolve<ISettingService>() as DefaultSettingService;
|
|
var notifications = GContext.container.Resolve<ILocalNotificationService>() as LocalNotificationService;
|
|
var club = GContext.container.Resolve<ClubService>();
|
|
if (settings == null || notifications == null || club == null ||
|
|
!ReferenceEquals(settings, GContext.container.Resolve<ISettingService>()) ||
|
|
!ReferenceEquals(notifications, GContext.container.Resolve<ILocalNotificationService>()) ||
|
|
!ReferenceEquals(club, GContext.container.Resolve<ClubService>()))
|
|
throw new InvalidOperationException("Formal prompt services required.");
|
|
settings.PreflightFormalPromptOwner(original);
|
|
club.PreflightFormalPromptOwner(original);
|
|
notifications.PreflightFormalPromptSettings(settings);
|
|
promptsBound = true;
|
|
settings.BindFormalPromptOwner(original);
|
|
club.BindFormalPromptOwner(original);
|
|
notifications.BindFormalPromptSettings(settings);
|
|
if (!settings.HasFormalPromptOwner(original) || !club.HasFormalPromptOwner(original) ||
|
|
!notifications.HasFormalPromptSettings(settings) || !KVHandoffReady)
|
|
throw new InvalidOperationException("Formal prompt binding changed.");
|
|
}
|
|
|
|
void OnUnauthorized(int version)
|
|
{
|
|
if (version == customServerMgr.SessionVersion) Logout();
|
|
}
|
|
|
|
public void Logout() => ObserveClose(CloseKVForValidationAsync());
|
|
async void ObserveClose(Task task)
|
|
{
|
|
try { await task; }
|
|
catch (Exception ex) { Debug.LogError("KV close failed: " + ex.GetType().Name); }
|
|
}
|
|
internal Task CloseKVForValidationAsync()
|
|
{
|
|
if (closeTask != null) return closeTask;
|
|
closing = true;
|
|
published = false;
|
|
(SaveStore as KVPlayerSaveStore)?.Dispose();
|
|
playerId = null; internalId = 0; handle = null; epoch = 0;
|
|
DisplayName = AvatarUrl = Language = ContinentCode = null;
|
|
ClearPlayInfos();
|
|
Application.quitting -= Logout;
|
|
if (customServerMgr is CustomServerMgr server)
|
|
{
|
|
server.KVUnauthorized -= OnUnauthorized;
|
|
server.DisableKVBusiness();
|
|
}
|
|
// Revoke first; a synchronous cancellation callback must already see invalid identity.
|
|
closeTask = coordinator?.Revoke() ?? Task.CompletedTask;
|
|
lifetime.Cancel();
|
|
return closeTask;
|
|
}
|
|
Task IKVUserLifecycle.CloseKVForValidationAsync() => CloseKVForValidationAsync();
|
|
internal Task<PlayerKVShutdownStatus> ObserveKVQuitAsync() =>
|
|
coordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready);
|
|
Task<PlayerKVShutdownStatus> IKVUserLifecycle.ObserveKVQuitAsync() => ObserveKVQuitAsync();
|
|
|
|
void IKVUserLoadReceiver.ResetForKVLoad()
|
|
{
|
|
if ((!loading && !closing) || published || handle != null)
|
|
throw new InvalidOperationException("KV identity must be unpublished during load/reset.");
|
|
intents?.Discard(); intents = null;
|
|
EAccoutUrl = new Dictionary<string, string>(); KVTapJoyBalance = 0;
|
|
}
|
|
void IKVUserLoadReceiver.ReplaceFromKVDecoded(PlayerKVLoginState state, long loadEpoch, IReadOnlyDictionary<string, string> raw)
|
|
{
|
|
if (!loading || closing || published || handle != null || state == null || raw == null)
|
|
throw new InvalidOperationException("KV load receiver is not current.");
|
|
intents = new PlayerKVInitializationIntents(loadEpoch, state.Channel);
|
|
foreach (var pair in state.Normalizations) intents.Add(loadEpoch, pair.Key, pair.Value);
|
|
EAccoutUrl = state.AccountUrls.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
|
|
KVTapJoyBalance = state.TapJoyBalance;
|
|
}
|
|
void IKVUserLoadReceiver.DiscardKVIntents() => intents?.Discard();
|
|
|
|
async Task SaveProfile(string key, string value)
|
|
{
|
|
var store = SaveStore;
|
|
if (!IsAuthorized || store == null) throw new InvalidOperationException("KV identity unavailable.");
|
|
var result = await store.WriteAndConfirmAsync(key, value, LoginLifetime);
|
|
if (!IsAuthorized || !ReferenceEquals(store, SaveStore) || !store.IsCurrent || !result.Confirmed)
|
|
throw new InvalidOperationException("KV profile save was not confirmed.");
|
|
}
|
|
|
|
async Task RunSaveScheduleAsync(IPlayerSaveStore store)
|
|
{
|
|
try
|
|
{
|
|
while (IsAuthorized && store.IsCurrent)
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(40), LoginLifetime);
|
|
if (!IsAuthorized || !store.IsCurrent) return;
|
|
var result = await store.FlushAsync(LoginLifetime);
|
|
if (!result.Confirmed && !LoginLifetime.IsCancellationRequested)
|
|
Debug.LogWarning("KV scheduled save: " + result.Status);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
catch (Exception ex) { Debug.LogError("KV save scheduler failed: " + ex.GetType().Name); }
|
|
}
|
|
public async Task<string> ChangeDisplayName(string newName)
|
|
{
|
|
await SaveProfile("DisplayName", newName);
|
|
DisplayName = newName; GContext.Publish(new ChangeAvatarEvent()); UpdateProfile(); return newName;
|
|
}
|
|
public async Task<string> UpdateAvatarUrl(string url)
|
|
{
|
|
await SaveProfile("AvatarUrl", url);
|
|
AvatarUrl = url; GContext.Publish(new ChangeAvatarEvent()); UpdateProfile(); return url;
|
|
}
|
|
public async Task<string> UpdateLanguage(string code)
|
|
{ await SaveProfile("Language", code); Language = code; return code; }
|
|
public double CreateTotalSeconds() => (ZZTimeHelper.UtcNow() - CreateTime).TotalSeconds;
|
|
// The non-AGG channel has no account-link operation; never manufacture a successful binding.
|
|
public Task<(int code, int userid)> LinkAccount(EAccoutType type) => Task.FromResult((1, 0));
|
|
public Task<bool> GetLinkState(EAccoutType type) => Task.FromResult(false);
|
|
public string GetDefaultName(string id) => "Angler#" + (string.IsNullOrEmpty(id) ? string.Empty : id.Substring(0, Math.Min(5, id.Length)));
|
|
public void SetPlayerLv(string id, int level) => levels[id] = id == UserId ? GContext.container.Resolve<PlayerData>().lv : level;
|
|
public int GetPlayerLv(string id) => id == UserId && IsAuthorized ? GContext.container.Resolve<PlayerData>().lv :
|
|
id != null && levels.TryGetValue(id, out var level) ? level : -1;
|
|
public Task<(string, int)> GetPlayLv(string id)
|
|
{
|
|
var level = GetPlayerLv(id);
|
|
return level >= 0 ? Task.FromResult((id, level)) :
|
|
Task.FromException<(string, int)>(new InvalidOperationException("Public player level has not been loaded."));
|
|
}
|
|
public PlayInfo GetPlayInfo(string id)
|
|
{
|
|
if (id == UserId && IsAuthorized) return new PlayInfo { name = DisplayName, avatarUrl = AvatarUrl };
|
|
return id != null && profiles.TryGetValue(id, out var profile) ? profile : null;
|
|
}
|
|
public void SetPlayInfo(string id, string name, string avatarUrl)
|
|
{
|
|
if (string.IsNullOrEmpty(id)) return;
|
|
profiles[id] = new PlayInfo { name = string.IsNullOrEmpty(name) ? GetDefaultName(id) : name, avatarUrl = avatarUrl };
|
|
}
|
|
public void ClearPlayInfos() { profiles.Clear(); levels.Clear(); }
|
|
public void UpdateProfile()
|
|
{
|
|
if (IsAuthorized) customServerMgr.AccountRequest("UpdateProfile", new LoginRequest { playerId = ApiPlayerId,
|
|
playerProfile = new PlayerProfile { Level = GContext.container.Resolve<PlayerData>().lv, Avatar = AvatarUrl, DisplayName = DisplayName } });
|
|
}
|
|
public void UpdateProfileLv()
|
|
{
|
|
if (IsAuthorized) customServerMgr.AccountRequest("UpdateProfile", new LoginRequest { playerId = ApiPlayerId,
|
|
playerProfile = new PlayerProfile { Level = GContext.container.Resolve<PlayerData>().lv } });
|
|
}
|
|
}
|
|
}
|