feat(upserver): 接入认证与KV存档并完成限定钓鱼闭环

This commit is contained in:
Liubing\LB
2026-09-28 18:14:01 +08:00
parent f40a816cc4
commit 0eb43b2b40
129 changed files with 11612 additions and 573 deletions

View File

@@ -0,0 +1,224 @@
using System;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace game.Account
{
public enum AccountLoginError { None, Validation, Http, Rejected, Protocol, Network, Timeout, Cancelled }
public sealed class AccountLoginResult
{
public bool Success => Error == AccountLoginError.None;
public AccountLoginError Error { get; }
public int? StatusCode { get; }
internal AccountLoginResult(AccountLoginError error, int? status = null)
{ Error = error; StatusCode = status; }
}
/// <summary>
/// Minimal Tuyou-ID authentication, independent of gameplay and PlayFab storage.
/// Each login owns a fresh cookie jar; dispose the session on logout.
/// </summary>
public sealed class S101AccountSession : IDisposable
{
public const string DefaultApiUrl = "http://s101.local:18080/";
readonly object gate = new object();
readonly Uri api;
readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
HttpClient http;
int generation;
bool disposed;
public bool IsAuthenticated { get; private set; }
public string PlayerId { get; private set; }
public uint InternalId { get; private set; }
public DateTime CreateTimeUtc { get; private set; }
public DateTime ServerTimeUtc { get; private set; }
public bool NewlyCreated { get; private set; }
public CookieContainer Cookies { get; private set; }
public Uri ApiBaseUri => api;
public S101AccountSession() : this(new Uri(DefaultApiUrl)) { }
// The factory is optional for transport tests. Returned handlers are owned by this session.
public S101AccountSession(Uri api, Func<CookieContainer, HttpMessageHandler> handlerFactory = null)
{
if (api == null || !api.IsAbsoluteUri ||
(api.Scheme != "http" && api.Scheme != "https") ||
!string.IsNullOrEmpty(api.Query) || !string.IsNullOrEmpty(api.Fragment) ||
!string.IsNullOrEmpty(api.UserInfo))
throw new ArgumentException("An absolute HTTP(S) API base URI is required.", nameof(api));
this.api = new Uri(api.AbsoluteUri.TrimEnd('/') + "/");
this.handlerFactory = handlerFactory ?? (jar => new HttpClientHandler
{
CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false
});
}
public async Task<AccountLoginResult> LoginAsync(string tuyooId, CancellationToken token = default)
{
HttpClient client;
CookieContainer cookies;
int requestGeneration;
string id;
lock (gate)
{
if (disposed) return Result(AccountLoginError.Cancelled);
generation++;
ClearIdentity();
http?.Dispose();
http = null;
Cookies = null;
if (string.IsNullOrEmpty(tuyooId) ||
!long.TryParse(tuyooId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0)
return Result(AccountLoginError.Validation);
if (token.IsCancellationRequested) return Result(AccountLoginError.Cancelled);
id = numericId.ToString(CultureInfo.InvariantCulture);
cookies = new CookieContainer();
client = new HttpClient(handlerFactory(cookies)) { Timeout = TimeSpan.FromSeconds(15) };
http = client;
Cookies = cookies;
requestGeneration = generation;
}
int? status = null;
try
{
using (var request = Request("account/Login", new JObject { ["playerId"] = id }))
using (var response = await client.SendAsync(request, token))
{
status = (int)response.StatusCode;
if (response.StatusCode != HttpStatusCode.OK) return Result(AccountLoginError.Http, status);
var body = Parse(await response.Content.ReadAsStringAsync());
if (body["state"]?.Type != JTokenType.Integer) return Result(AccountLoginError.Protocol, status);
if (body["state"].Value<long>() != 0) return Result(AccountLoginError.Rejected, status);
if (body["internalId"]?.Type != JTokenType.Integer ||
!uint.TryParse(body["internalId"].ToString(), out var internalId) || internalId == 0 ||
body["newCreate"]?.Type != JTokenType.Boolean ||
!ReadUtc(body["createTime"], out var created) || !ReadUtc(body["svrTime"], out var serverTime) ||
cookies.GetCookies(api).Count == 0)
return Result(AccountLoginError.Protocol, status);
lock (gate)
{
if (disposed || generation != requestGeneration || token.IsCancellationRequested)
return Result(AccountLoginError.Cancelled, status);
PlayerId = id;
InternalId = internalId;
CreateTimeUtc = created;
ServerTimeUtc = serverTime;
NewlyCreated = body["newCreate"].Value<bool>();
IsAuthenticated = true;
}
return Result(AccountLoginError.None, status);
}
}
catch (OperationCanceledException)
{
lock (gate)
return Result(disposed || generation != requestGeneration || token.IsCancellationRequested
? AccountLoginError.Cancelled : AccountLoginError.Timeout, status);
}
catch (ObjectDisposedException) { return Result(AccountLoginError.Cancelled, status); }
catch (HttpRequestException) { return Result(AccountLoginError.Network, status); }
catch (JsonException) { return Result(AccountLoginError.Protocol, status); }
catch (OverflowException) { return Result(AccountLoginError.Protocol, status); }
}
/// <summary>Read-only probe: verifies that the server accepts this session cookie; never writes KV.</summary>
public async Task<AccountLoginResult> CheckAuthorizationAsync(CancellationToken token = default)
{
HttpClient client;
int requestGeneration;
lock (gate)
{
if (disposed || !IsAuthenticated) return Result(AccountLoginError.Rejected);
client = http;
requestGeneration = generation;
}
int? status = null;
try
{
using (var request = Request("playerdata/Get", new JObject()))
using (var response = await client.SendAsync(request, token))
{
status = (int)response.StatusCode;
lock (gate)
{
if (disposed || generation != requestGeneration || token.IsCancellationRequested)
return Result(AccountLoginError.Cancelled, status);
if (status == 401 || status == 403) ClearIdentity();
}
if (response.StatusCode != HttpStatusCode.OK) return Result(AccountLoginError.Http, status);
var body = Parse(await response.Content.ReadAsStringAsync());
if (!(body["data"] is JObject data)) return Result(AccountLoginError.Protocol, status);
foreach (var item in data.Properties())
if (item.Value.Type != JTokenType.String) return Result(AccountLoginError.Protocol, status);
lock (gate)
return Result(disposed || generation != requestGeneration || token.IsCancellationRequested
? AccountLoginError.Cancelled : AccountLoginError.None, status);
}
}
catch (OperationCanceledException)
{
lock (gate)
return Result(disposed || generation != requestGeneration || token.IsCancellationRequested
? AccountLoginError.Cancelled : AccountLoginError.Timeout, status);
}
catch (ObjectDisposedException) { return Result(AccountLoginError.Cancelled, status); }
catch (HttpRequestException) { return Result(AccountLoginError.Network, status); }
catch (JsonException) { return Result(AccountLoginError.Protocol, status); }
}
HttpRequestMessage Request(string path, JObject body) => new HttpRequestMessage(HttpMethod.Post, new Uri(api, path))
{ Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json") };
static JObject Parse(string text)
{
using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None })
{
var value = JObject.Load(reader);
if (reader.Read()) throw new JsonReaderException("Trailing JSON content.");
return value;
}
}
static bool ReadUtc(JToken value, out DateTime date)
{
date = default;
return value?.Type == JTokenType.String && DateTime.TryParse(value.Value<string>(), CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out date);
}
static AccountLoginResult Result(AccountLoginError error, int? status = null) => new AccountLoginResult(error, status);
void ClearIdentity()
{
IsAuthenticated = false;
PlayerId = null;
InternalId = 0;
CreateTimeUtc = default;
ServerTimeUtc = default;
NewlyCreated = false;
}
public void Dispose()
{
lock (gate)
{
if (disposed) return;
disposed = true;
generation++;
ClearIdentity();
http?.Dispose();
http = null;
Cookies = null;
}
}
}
}