feat(online): implement IchniOnline API integration with BestHTTP

- Add BestHTTP reference to IchniOnline.asmdef
- Create ApiClient.cs: HTTP singleton with JWT auto-injection
- Create ApiResponse.cs: ResponseCode enum, GlobalResponse<T>, ApiResult<T>
- Create AuthDtos.cs: ThirdPartyLoginRequestDto, LoginRequestDto, RegisterRequestDto, LoginResponseDto, UserResponseDto
- Create AuthService.cs: TapTap/password/register/logout flows with events
- Extend LoginCacheData.cs: JWT + server user data fields
- Extend LoginCacheManager.cs: SaveAuthSession, ClearSession, HasValidSession
- Extend ThirdPartyServiceManager.cs: OnLoginWithToken event for AuthService
- Update LoginPage.cs: Use AuthService with loading states and null-safe callbacks
- Update StartUIPage.cs: Use HasValidSession for session check

Fixes post-review:
- LoginPage: Add null check for Register success (response may be null)
- AuthService: Add try/catch around TapTap login API call
This commit is contained in:
2026-06-15 16:56:00 +08:00
parent d7d15569c5
commit 97c9fba14e
11 changed files with 604 additions and 0 deletions

View File

@@ -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
{
/// <summary>
/// BestHTTP-based API client singleton for IchniOnline backend communication.
/// Pure HTTP layer — no business logic.
/// </summary>
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<ApiResult<T>> GetAsync<T>(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<T>(resp);
}
catch (Exception ex)
{
return ApiResult<T>.Fail(ResponseCode.InternalServerError, "Network error", ex.Message);
}
}
public async Task<ApiResult<T>> PostAsync<T>(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<T>(resp);
}
catch (Exception ex)
{
return ApiResult<T>.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<T> ProcessResponse<T>(HTTPResponse resp)
{
string json = resp.DataAsText;
if (resp.StatusCode >= 200 && resp.StatusCode < 300)
{
if (string.IsNullOrEmpty(json))
{
return ApiResult<T>.Fail(ResponseCode.InternalServerError, "Empty response body");
}
var response = JsonUtility.FromJson(json, typeof(GlobalResponse<T>)) as GlobalResponse<T>;
if (response == null)
{
return ApiResult<T>.Fail(ResponseCode.InternalServerError, "Failed to parse response JSON");
}
if (response.Code == ResponseCode.Ok)
{
return ApiResult<T>.Ok(response.Data);
}
return ApiResult<T>.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<T>.Fail(errorResponse.Code, errorResponse.Message);
}
}
return ApiResult<T>.Fail(ResponseCode.InternalServerError, $"HTTP error {resp.StatusCode}");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: df4f4d9b75196d348b949150b5acc91b

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 78f43963c5d92354da175c3f74339cac
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
}
/// <summary>
/// Non-generic base class for Unity JsonUtility deserialization.
/// Concrete generic GlobalResponse<T> inherits from this.
/// </summary>
[System.Serializable]
public abstract class GlobalResponseBase
{
public ResponseCode Code;
public string Message;
}
/// <summary>
/// Generic server response wrapper. JsonUtility can deserialize this to the base class,
/// then cast to the concrete type for Data access.
/// </summary>
/// <typeparam name="T">Data payload type</typeparam>
[System.Serializable]
public class GlobalResponse<T> : GlobalResponseBase
{
public T Data;
}
/// <summary>
/// Unified API result wrapper with factory methods.
/// Note: JsonUtility doesn't support generic deserialization directly,
/// so use GlobalResponseBase for deserialization then wrap in ApiResult.
/// </summary>
/// <typeparam name="T">Data payload type</typeparam>
[System.Serializable]
public class ApiResult<T>
{
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<T> Ok(T data)
{
return new ApiResult<T>
{
Data = data,
Code = ResponseCode.Ok,
Message = "Success",
ErrorDetail = null
};
}
public static ApiResult<T> Fail(ResponseCode code, string message, string detail = null)
{
return new ApiResult<T>
{
Data = default(T),
Code = code,
Message = message,
ErrorDetail = detail
};
}
public static ApiResult<T> Fail(int code, string message, string detail = null)
{
return Fail((ResponseCode)code, message, detail);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 476434455b6d65a4494c003a2ae54754

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3d4424c69a64b3a43a48a432b2626ac9