diff --git a/Assets/Scripts/Online/Logic/AuthService.cs b/Assets/Scripts/Online/Logic/AuthService.cs new file mode 100644 index 00000000..f28543cf --- /dev/null +++ b/Assets/Scripts/Online/Logic/AuthService.cs @@ -0,0 +1,327 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using IchniOnline.Online.Network; +using IchniOnline.Online.Network.Models; +using TapSDK.Login; +using UnityEngine; + +namespace IchniOnline.Online.Network.Models +{ + [Serializable] + public class SessionKeyResponseDto + { + public string SessionKey; + public long ExpiresAt; + } +} + +namespace IchniOnline.Online.Logic +{ + /// + /// Authentication orchestration service that coordinates TapTap login, + /// password login, registration, and logout flows. + /// Pure orchestration — no UI state management. Results are delivered via events. + /// + public static class IchniOnlineAuthService + { + /// + /// 登录成功时触发,参数为服务端返回的登录响应(Register 成功时可能为 null) + /// + public static event Action OnLoginSuccess; + + /// + /// 登录失败时触发,参数为错误信息 + /// + public static event Action OnLoginFailed; + + /// + /// 登录被用户取消时触发 + /// + public static event Action OnLoginCanceled; + + /// + /// 是否正在进行登录流程,用于防止并发登录请求 + /// + public static bool IsLoggingIn { get; private set; } + + #region TapTap Login + + /// + /// 使用 TapTap 登录的完整流程: + /// TapTap SDK → 第三方登录 API → JWT → 缓存 → 事件 + /// + public static async void LoginWithTapTap() + { + if (IsLoggingIn) + { + Debug.LogWarning("[IchniOnlineAuthService] 已有登录流程正在进行中"); + return; + } + + IsLoggingIn = true; + + Action onSuccess = null; + Action onCanceled = null; + Action onFailed = null; + + onSuccess = async account => + { + UnsubscribeTapTapEvents(onSuccess, onCanceled, onFailed); + + if (account?.accessToken == null) + { + IsLoggingIn = false; + OnLoginFailed?.Invoke("TapTap 登录成功但 accessToken 为空"); + return; + } + + var dto = new ThirdPartyLoginRequestDto + { + Token = account.accessToken.kid, + TokenType = account.accessToken.tokenType, + MacKey = account.accessToken.macKey, + MacAlgorithm = account.accessToken.macAlgorithm + }; + + try + { + var result = await IchniOnlineApiClient.Instance.PostAsync("/api/auth/third-party/login", dto); + + IsLoggingIn = false; + + if (result.IsSuccess) + { + LoginCacheManager.SaveAuthSession(result.Data.Token, result.Data); + IchniOnlineApiClient.Instance.JwtToken = result.Data.Token; + OnLoginSuccess?.Invoke(result.Data); + } + else + { + string errorMessage = $"第三方登录 API 失败: {result.Message}"; + if (!string.IsNullOrEmpty(result.ErrorDetail)) + errorMessage += $" ({result.ErrorDetail})"; + OnLoginFailed?.Invoke(errorMessage); + } + } + catch (Exception ex) + { + IsLoggingIn = false; + Debug.LogError($"[IchniOnlineAuthService] TapTap 登录 API 异常: {ex}"); + OnLoginFailed?.Invoke($"TapTap 登录 API 异常: {ex.Message}"); + } + }; + + onCanceled = () => + { + UnsubscribeTapTapEvents(onSuccess, onCanceled, onFailed); + IsLoggingIn = false; + OnLoginCanceled?.Invoke(); + }; + + onFailed = errorMessage => + { + UnsubscribeTapTapEvents(onSuccess, onCanceled, onFailed); + IsLoggingIn = false; + OnLoginFailed?.Invoke($"TapTap 登录失败: {errorMessage}"); + }; + + ThirdPartyServiceManager.Instance.OnLoginSuccess += onSuccess; + ThirdPartyServiceManager.Instance.OnLoginCanceled += onCanceled; + ThirdPartyServiceManager.Instance.OnLoginFailed += onFailed; + + ThirdPartyServiceManager.Instance.StartTapTapLogin(); + } + + private static void UnsubscribeTapTapEvents( + Action onSuccess, + Action onCanceled, + Action onFailed) + { + if (ThirdPartyServiceManager.Instance != null) + { + ThirdPartyServiceManager.Instance.OnLoginSuccess -= onSuccess; + ThirdPartyServiceManager.Instance.OnLoginCanceled -= onCanceled; + ThirdPartyServiceManager.Instance.OnLoginFailed -= onFailed; + } + } + + #endregion + + #region Password Login + + /// + /// 使用用户名和密码登录的完整流程: + /// 获取 session-key → XOR 加密密码 → 登录 API → JWT → 缓存 → 事件 + /// + public static async void LoginWithPassword(string username, string password) + { + if (IsLoggingIn) + { + Debug.LogWarning("[IchniOnlineAuthService] 已有登录流程正在进行中"); + return; + } + + if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) + { + OnLoginFailed?.Invoke("用户名或密码不能为空"); + return; + } + + IsLoggingIn = true; + + try + { + // 1. 获取 session-key + var sessionResult = await IchniOnlineApiClient.Instance.GetAsync("/api/auth/session-key"); + if (!sessionResult.IsSuccess) + { + IsLoggingIn = false; + string errorMessage = $"获取 session-key 失败: {sessionResult.Message}"; + if (!string.IsNullOrEmpty(sessionResult.ErrorDetail)) + errorMessage += $" ({sessionResult.ErrorDetail})"; + OnLoginFailed?.Invoke(errorMessage); + return; + } + + string sessionKey = sessionResult.Data.SessionKey; + + // 2. XOR 加密密码 + string encryptedPassword = EncryptPassword(password, sessionKey); + + // 3. 调用登录 API + var loginDto = new LoginRequestDto + { + Username = username, + EncryptedPassword = encryptedPassword, + SessionKey = sessionKey + }; + + var loginResult = await IchniOnlineApiClient.Instance.PostAsync("/api/auth/login", loginDto); + + IsLoggingIn = false; + + if (loginResult.IsSuccess) + { + LoginCacheManager.SaveAuthSession(loginResult.Data.Token, loginResult.Data); + IchniOnlineApiClient.Instance.JwtToken = loginResult.Data.Token; + OnLoginSuccess?.Invoke(loginResult.Data); + } + else + { + string errorMessage = $"登录失败: {loginResult.Message}"; + if (!string.IsNullOrEmpty(loginResult.ErrorDetail)) + errorMessage += $" ({loginResult.ErrorDetail})"; + OnLoginFailed?.Invoke(errorMessage); + } + } + catch (Exception ex) + { + IsLoggingIn = false; + Debug.LogError($"[IchniOnlineAuthService] 密码登录异常: {ex}"); + OnLoginFailed?.Invoke($"登录异常: {ex.Message}"); + } + } + + #endregion + + #region Register + + /// + /// 用户注册流程:POST /api/auth/register + /// 注册成功后触发 OnLoginSuccess(null)(注册不返回 JWT) + /// + public static async void Register(string username, string password, string displayName) + { + if (IsLoggingIn) + { + Debug.LogWarning("[IchniOnlineAuthService] 已有登录流程正在进行中"); + return; + } + + if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) + { + OnLoginFailed?.Invoke("用户名或密码不能为空"); + return; + } + + IsLoggingIn = true; + + try + { + var registerDto = new RegisterRequestDto + { + Username = username, + Password = password, + DisplayName = displayName + }; + + var result = await IchniOnlineApiClient.Instance.PostAsync("/api/auth/register", registerDto); + + IsLoggingIn = false; + + if (result.IsSuccess) + { + OnLoginSuccess?.Invoke(null); + } + else + { + string errorMessage = $"注册失败: {result.Message}"; + if (!string.IsNullOrEmpty(result.ErrorDetail)) + errorMessage += $" ({result.ErrorDetail})"; + OnLoginFailed?.Invoke(errorMessage); + } + } + catch (Exception ex) + { + IsLoggingIn = false; + Debug.LogError($"[IchniOnlineAuthService] 注册异常: {ex}"); + OnLoginFailed?.Invoke($"注册异常: {ex.Message}"); + } + } + + #endregion + + #region Logout + + /// + /// 登出流程:清除本地会话、登出 TapTap、清除 API JWT Token + /// + public static void Logout() + { + LoginCacheManager.ClearSession(); + ThirdPartyServiceManager.Instance?.Logout(); + IchniOnlineApiClient.Instance.JwtToken = null; + Debug.Log("[IchniOnlineAuthService] 已登出"); + } + + #endregion + + #region Encryption + + /// + /// 使用 XOR 算法加密密码,与服务器 UserService.DecryptPassword 对应。 + /// sessionKey 为服务器返回的 Base64 字符串。 + /// + public static string EncryptPassword(string password, string sessionKey) + { + if (string.IsNullOrEmpty(password)) + throw new ArgumentException("密码不能为空", nameof(password)); + if (string.IsNullOrEmpty(sessionKey)) + throw new ArgumentException("sessionKey 不能为空", nameof(sessionKey)); + + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + byte[] sessionBytes = Convert.FromBase64String(sessionKey); + byte[] encrypted = new byte[passwordBytes.Length]; + + for (int i = 0; i < passwordBytes.Length; i++) + { + encrypted[i] = (byte)(passwordBytes[i] ^ sessionBytes[i % sessionBytes.Length]); + } + + return Convert.ToBase64String(encrypted); + } + + #endregion + } +} diff --git a/Assets/Scripts/Online/Logic/AuthService.cs.meta b/Assets/Scripts/Online/Logic/AuthService.cs.meta new file mode 100644 index 00000000..b004416b --- /dev/null +++ b/Assets/Scripts/Online/Logic/AuthService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fd73d635812aee54e88f24a18de8fe4d \ No newline at end of file diff --git a/Assets/Scripts/Online/Network.meta b/Assets/Scripts/Online/Network.meta new file mode 100644 index 00000000..ec9f745e --- /dev/null +++ b/Assets/Scripts/Online/Network.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 90618a8397b3df247aa0b6dff373aa35 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Online/Network/ApiClient.cs b/Assets/Scripts/Online/Network/ApiClient.cs new file mode 100644 index 00000000..7dfac99f --- /dev/null +++ b/Assets/Scripts/Online/Network/ApiClient.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Best.HTTP; +using IchniOnline.Online.Network.Models; +using UnityEngine; + +namespace IchniOnline.Online.Network +{ + /// + /// BestHTTP-based API client singleton for IchniOnline backend communication. + /// Pure HTTP layer — no business logic. + /// + public class IchniOnlineApiClient + { + private static IchniOnlineApiClient _instance; + public static IchniOnlineApiClient Instance => _instance ??= new IchniOnlineApiClient(); + + public string BaseUrl { get; set; } = "http://localhost:5433"; + public string JwtToken { get; set; } + + private IchniOnlineApiClient() { } + + public async Task> GetAsync(string endpoint) + { + string url = BuildUrl(endpoint); + var request = new HTTPRequest(new Uri(url), HTTPMethods.Get); + AddAuthHeader(request); + + try + { + var resp = await request.GetHTTPResponseAsync(); + return ProcessResponse(resp); + } + catch (Exception ex) + { + return ApiResult.Fail(ResponseCode.InternalServerError, "Network error", ex.Message); + } + } + + public async Task> PostAsync(string endpoint, object body) + { + string url = BuildUrl(endpoint); + var request = new HTTPRequest(new Uri(url), HTTPMethods.Post); + request.SetHeader("Content-Type", "application/json"); + AddAuthHeader(request); + + if (body != null) + { + string jsonBody = JsonUtility.ToJson(body); + request.UploadSettings.UploadStream = new MemoryStream(Encoding.UTF8.GetBytes(jsonBody)); + } + + try + { + var resp = await request.GetHTTPResponseAsync(); + return ProcessResponse(resp); + } + catch (Exception ex) + { + return ApiResult.Fail(ResponseCode.InternalServerError, "Network error", ex.Message); + } + } + + private string BuildUrl(string endpoint) + { + if (string.IsNullOrEmpty(BaseUrl)) + throw new InvalidOperationException("BaseUrl is not configured."); + + string baseUrl = BaseUrl.TrimEnd('/'); + string path = endpoint.StartsWith("/") ? endpoint : $"/{endpoint}"; + return baseUrl + path; + } + + private void AddAuthHeader(HTTPRequest request) + { + if (!string.IsNullOrEmpty(JwtToken)) + { + request.SetHeader("Authorization", $"Bearer {JwtToken}"); + } + } + + private ApiResult ProcessResponse(HTTPResponse resp) + { + string json = resp.DataAsText; + + if (resp.StatusCode >= 200 && resp.StatusCode < 300) + { + if (string.IsNullOrEmpty(json)) + { + return ApiResult.Fail(ResponseCode.InternalServerError, "Empty response body"); + } + + var response = JsonUtility.FromJson(json, typeof(GlobalResponse)) as GlobalResponse; + if (response == null) + { + return ApiResult.Fail(ResponseCode.InternalServerError, "Failed to parse response JSON"); + } + + if (response.Code == ResponseCode.Ok) + { + return ApiResult.Ok(response.Data); + } + + return ApiResult.Fail(response.Code, response.Message); + } + + // Non-2xx: try to parse server error body + if (!string.IsNullOrEmpty(json)) + { + var errorResponse = JsonUtility.FromJson(json, typeof(GlobalResponseBase)) as GlobalResponseBase; + if (errorResponse != null) + { + return ApiResult.Fail(errorResponse.Code, errorResponse.Message); + } + } + + return ApiResult.Fail(ResponseCode.InternalServerError, $"HTTP error {resp.StatusCode}"); + } + } +} diff --git a/Assets/Scripts/Online/Network/ApiClient.cs.meta b/Assets/Scripts/Online/Network/ApiClient.cs.meta new file mode 100644 index 00000000..a8e991b3 --- /dev/null +++ b/Assets/Scripts/Online/Network/ApiClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: df4f4d9b75196d348b949150b5acc91b \ No newline at end of file diff --git a/Assets/Scripts/Online/Network/Models.meta b/Assets/Scripts/Online/Network/Models.meta new file mode 100644 index 00000000..c4709c39 --- /dev/null +++ b/Assets/Scripts/Online/Network/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 78f43963c5d92354da175c3f74339cac +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Online/Network/Models/ApiResponse.cs b/Assets/Scripts/Online/Network/Models/ApiResponse.cs new file mode 100644 index 00000000..427856ef --- /dev/null +++ b/Assets/Scripts/Online/Network/Models/ApiResponse.cs @@ -0,0 +1,82 @@ +namespace IchniOnline.Online.Network.Models +{ + using System; + + [System.Serializable] + public enum ResponseCode + { + Ok = 10000, + BadRequest = 10400, + Unauthorized = 10401, + Forbidden = 10403, + NotFound = 10404, + InternalServerError = 10500 + } + + /// + /// Non-generic base class for Unity JsonUtility deserialization. + /// Concrete generic GlobalResponse inherits from this. + /// + [System.Serializable] + public abstract class GlobalResponseBase + { + public ResponseCode Code; + public string Message; + } + + /// + /// Generic server response wrapper. JsonUtility can deserialize this to the base class, + /// then cast to the concrete type for Data access. + /// + /// Data payload type + [System.Serializable] + public class GlobalResponse : GlobalResponseBase + { + public T Data; + } + + /// + /// Unified API result wrapper with factory methods. + /// Note: JsonUtility doesn't support generic deserialization directly, + /// so use GlobalResponseBase for deserialization then wrap in ApiResult. + /// + /// Data payload type + [System.Serializable] + public class ApiResult + { + public bool IsSuccess => Code == ResponseCode.Ok; + public T Data { get; private set; } + public ResponseCode Code { get; private set; } + public string Message { get; private set; } + public string ErrorDetail { get; private set; } + + private ApiResult() { } + + public static ApiResult Ok(T data) + { + return new ApiResult + { + Data = data, + Code = ResponseCode.Ok, + Message = "Success", + ErrorDetail = null + }; + } + + public static ApiResult Fail(ResponseCode code, string message, string detail = null) + { + return new ApiResult + { + Data = default(T), + Code = code, + Message = message, + ErrorDetail = detail + }; + } + + public static ApiResult Fail(int code, string message, string detail = null) + { + return Fail((ResponseCode)code, message, detail); + } + } +} diff --git a/Assets/Scripts/Online/Network/Models/ApiResponse.cs.meta b/Assets/Scripts/Online/Network/Models/ApiResponse.cs.meta new file mode 100644 index 00000000..1af61369 --- /dev/null +++ b/Assets/Scripts/Online/Network/Models/ApiResponse.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 476434455b6d65a4494c003a2ae54754 \ No newline at end of file diff --git a/Assets/Scripts/Online/Network/Models/AuthDtos.cs b/Assets/Scripts/Online/Network/Models/AuthDtos.cs new file mode 100644 index 00000000..f925f300 --- /dev/null +++ b/Assets/Scripts/Online/Network/Models/AuthDtos.cs @@ -0,0 +1,46 @@ +using System; + +namespace IchniOnline.Online.Network.Models +{ + [Serializable] + public class ThirdPartyLoginRequestDto + { + public string Token; + public string TokenType; + public string MacKey; + public string MacAlgorithm; + } + + [Serializable] + public class LoginRequestDto + { + public string Username; + public string EncryptedPassword; + public string SessionKey; + } + + [Serializable] + public class RegisterRequestDto + { + public string Username; + public string Password; + public string DisplayName; + } + + [Serializable] + public class LoginResponseDto + { + public string Token; + public UserResponseDto User; + } + + [Serializable] + public class UserResponseDto + { + public string UserId; + public string Username; + public string DisplayName; + public string AvatarUrl; + public int Permission; + } +} diff --git a/Assets/Scripts/Online/Network/Models/AuthDtos.cs.meta b/Assets/Scripts/Online/Network/Models/AuthDtos.cs.meta new file mode 100644 index 00000000..d885cef7 --- /dev/null +++ b/Assets/Scripts/Online/Network/Models/AuthDtos.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d4424c69a64b3a43a48a432b2626ac9 \ No newline at end of file diff --git a/Assets/Scripts/Online/Resources.meta b/Assets/Scripts/Online/Resources.meta new file mode 100644 index 00000000..2e39673e --- /dev/null +++ b/Assets/Scripts/Online/Resources.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 907170849dc345b29c0f7d072e1ca5db +timeCreated: 1781501503 \ No newline at end of file