diff --git a/src/Snap.Hutao/Snap.Hutao.SourceGeneration/DedendencyInjection/HttpClientGenerator.cs b/src/Snap.Hutao/Snap.Hutao.SourceGeneration/DedendencyInjection/HttpClientGenerator.cs
index 4c104834..a83e2136 100644
--- a/src/Snap.Hutao/Snap.Hutao.SourceGeneration/DedendencyInjection/HttpClientGenerator.cs
+++ b/src/Snap.Hutao/Snap.Hutao.SourceGeneration/DedendencyInjection/HttpClientGenerator.cs
@@ -24,6 +24,7 @@ public class HttpClientGenerator : ISourceGenerator
private const string DefaultName = "Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient.HttpClientConfigration.Default";
private const string XRpcName = "Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient.HttpClientConfigration.XRpc";
private const string XRpc2Name = "Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient.HttpClientConfigration.XRpc2";
+ private const string XRpc3Name = "Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient.HttpClientConfigration.XRpc3";
private const string PrimaryHttpMessageHandlerAttributeName = "Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient.PrimaryHttpMessageHandlerAttribute";
private const string DynamicSecretAttributeName = "Snap.Hutao.Web.Hoyolab.DynamicSecret.UseDynamicSecretAttribute";
@@ -105,6 +106,9 @@ internal static partial class IocHttpClientConfiguration
case XRpc2Name:
lineBuilder.Append("XRpc2Configuration)");
break;
+ case XRpc3Name:
+ lineBuilder.Append("XRpc3Configuration)");
+ break;
default:
throw new InvalidOperationException($"非法的HttpClientConfigration值: [{injectAsName}]");
}
diff --git a/src/Snap.Hutao/Snap.Hutao/Core/CoreEnvironment.cs b/src/Snap.Hutao/Snap.Hutao/Core/CoreEnvironment.cs
index bfc9ad0a..4684dc78 100644
--- a/src/Snap.Hutao/Snap.Hutao/Core/CoreEnvironment.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Core/CoreEnvironment.cs
@@ -29,6 +29,11 @@ internal static class CoreEnvironment
///
public const string HoyolabMobileUA = $"Mozilla/5.0 (Linux; Android 12) Mobile miHoYoBBS/{HoyolabXrpcVersion}";
+ ///
+ /// Hoyolab iPhone 移动端请求UA
+ ///
+ public const string HoyolabOsMobileUA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) miHoYoBBSOversea/2.28.0";
+
///
/// 米游社 Rpc 版本
///
@@ -45,6 +50,7 @@ internal static class CoreEnvironment
[SaltType.X4] = "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs",
[SaltType.X6] = "t0qEgfub6cvueAPgR5m9aQWWVciEer7v",
[SaltType.PROD] = "JwYDpKvLj6MrMqqYU6jTKF17KNO2PXoS",
+ [SaltType.OS] = "6cqshh5dhw73bzxn20oexa9k516chk7s",
}.ToImmutableDictionary();
///
diff --git a/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/Annotation/HttpClient/HttpClientConfigration.cs b/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/Annotation/HttpClient/HttpClientConfigration.cs
index 4d12e220..6bc5419c 100644
--- a/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/Annotation/HttpClient/HttpClientConfigration.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/Annotation/HttpClient/HttpClientConfigration.cs
@@ -23,4 +23,9 @@ internal enum HttpClientConfigration
/// 米游社登录请求配置
///
XRpc2,
+
+ ///
+ /// 国际服Hoyolab请求配置
+ ///
+ XRpc3,
}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/IocHttpClientConfiguration.cs b/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/IocHttpClientConfiguration.cs
index 1a532ad2..dc6de29b 100644
--- a/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/IocHttpClientConfiguration.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/IocHttpClientConfiguration.cs
@@ -60,4 +60,18 @@ internal static partial class IocHttpClientConfiguration
client.DefaultRequestHeaders.Add("x-rpc-game_biz", "bbs_cn");
client.DefaultRequestHeaders.Add("x-rpc-sdk_version", "1.3.1.2");
}
+
+ ///
+ /// 对于需要添加动态密钥的客户端使用此配置
+ /// 国际服 API 测试
+ ///
+ /// 配置后的客户端
+ private static void XRpc3Configuration(HttpClient client)
+ {
+ client.Timeout = Timeout.InfiniteTimeSpan;
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36");
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
+ client.DefaultRequestHeaders.Add("x-rpc-app_version", "1.5.0");
+ client.DefaultRequestHeaders.Add("x-rpc-client_type", "4");
+ }
}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Model/Binding/User/User.cs b/src/Snap.Hutao/Snap.Hutao/Model/Binding/User/User.cs
index 4da00a9b..f3fca7fb 100644
--- a/src/Snap.Hutao/Snap.Hutao/Model/Binding/User/User.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Model/Binding/User/User.cs
@@ -4,6 +4,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
+using Snap.Hutao.Migrations;
using Snap.Hutao.Web.Hoyolab;
using Snap.Hutao.Web.Hoyolab.Bbs.User;
using Snap.Hutao.Web.Hoyolab.Passport;
@@ -103,7 +104,16 @@ internal sealed class User : ObservableObject
internal static async Task ResumeAsync(EntityUser inner, CancellationToken token = default)
{
User user = new(inner);
- bool isOk = await user.InitializeCoreAsync(token).ConfigureAwait(false);
+ bool isOk = false;
+
+ if (!user.Entity.IsOversea)
+ {
+ isOk = await user.InitializeCoreAsync(token).ConfigureAwait(false);
+ }
+ else
+ {
+ isOk = await user.InitializeCoreOsAsync(token).ConfigureAwait(false);
+ }
if (!isOk)
{
@@ -127,6 +137,7 @@ internal sealed class User : ObservableObject
entity.Aid = cookie.GetValueOrDefault(Cookie.STUID);
entity.Mid = cookie.GetValueOrDefault(Cookie.MID);
+ entity.IsOversea = false;
if (entity.Aid != null && entity.Mid != null)
{
@@ -141,6 +152,38 @@ internal sealed class User : ObservableObject
}
}
+ ///
+ /// 创建并初始化国际服用户(临时)
+ ///
+ /// cookie
+ /// 取消令牌
+ /// 用户
+ internal static async Task CreateOsUserAsync(Cookie cookie, CancellationToken token = default)
+ {
+ // 这里只负责创建实体用户,稍后在用户服务中保存到数据库
+ EntityUser entity = EntityUser.CreateOs(cookie);
+
+ entity.Aid = cookie.GetValueOrDefault(Cookie.STUID);
+
+ // Note: Currently we dont know how to get "mid" for hoyolab user,
+ // mid is set as the same value of ltuid(stuid/user id)
+ entity.Mid = entity.Aid;
+
+ entity.IsOversea = true;
+
+ if (entity.Aid != null)
+ {
+ User user = new(entity);
+ bool initialized = await user.InitializeCoreOsAsync(token).ConfigureAwait(false);
+
+ return initialized ? user : null;
+ }
+ else
+ {
+ return null;
+ }
+ }
+
private async Task InitializeCoreAsync(CancellationToken token = default)
{
if (isInitialized)
@@ -268,4 +311,87 @@ internal sealed class User : ObservableObject
return false;
}
}
+
+ private async Task InitializeCoreOsAsync(CancellationToken token = default)
+ {
+ if (isInitialized)
+ {
+ return true;
+ }
+
+ if (SToken == null)
+ {
+ return false;
+ }
+
+ using (IServiceScope scope = Ioc.Default.CreateScope())
+ {
+
+ // 自动填充 Ltoken
+ if (LToken == null)
+ {
+ Response ltokenResponse = await scope.ServiceProvider
+ .GetRequiredService()
+ .GetLtokenBySTokenAsync(Entity, token)
+ .ConfigureAwait(false);
+
+ if (ltokenResponse.IsOk())
+ {
+ Cookie ltokenCookie = Cookie.Parse($"ltuid={Entity.Aid};ltoken={ltokenResponse.Data.Ltoken}");
+ Entity.LToken = ltokenCookie;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ // Fetch user info
+ Response response = await scope.ServiceProvider
+ .GetRequiredService()
+ .GetOsUserFullInfoAsync(Entity, token)
+ .ConfigureAwait(false);
+ UserInfo = response.Data?.UserInfo;
+
+ // 自动填充 CookieToken
+ if (CookieToken == null)
+ {
+ Response cookieTokenResponse = await scope.ServiceProvider
+ .GetRequiredService()
+ .GetCookieAccountInfoBySTokenAsync(Entity, token)
+ .ConfigureAwait(false);
+
+ if (cookieTokenResponse.IsOk())
+ {
+ Cookie cookieTokenCookie = Cookie.Parse($"account_id={Entity.Aid};cookie_token={cookieTokenResponse.Data.CookieToken}");
+ Entity.CookieToken = cookieTokenCookie;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ // 获取游戏角色
+ Response> userGameRolesResponse = await scope.ServiceProvider
+ .GetRequiredService()
+ .GetOsUserGameRolesByCookieAsync(Entity, token)
+ .ConfigureAwait(false);
+
+ if (userGameRolesResponse.IsOk())
+ {
+ UserGameRoles = userGameRolesResponse.Data.List;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ SelectedUserGameRole = UserGameRoles.FirstOrFirstOrDefault(role => role.IsChosen);
+
+ isInitialized = true;
+
+ return UserInfo != null && UserGameRoles.Any();
+ }
}
diff --git a/src/Snap.Hutao/Snap.Hutao/Model/Entity/User.cs b/src/Snap.Hutao/Snap.Hutao/Model/Entity/User.cs
index 4b492b93..d77bfff2 100644
--- a/src/Snap.Hutao/Snap.Hutao/Model/Entity/User.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Model/Entity/User.cs
@@ -3,6 +3,7 @@
using Snap.Hutao.Core.Database;
using Snap.Hutao.Web.Hoyolab;
+using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
@@ -72,4 +73,18 @@ internal sealed class User : ISelectable
return new() { SToken = stoken, LToken = ltoken, CookieToken = cookieToken };
}
+
+ ///
+ /// 创建一个国际服用户
+ ///
+ /// cookie
+ /// 新创建的用户
+ public static User CreateOs(Cookie cookie)
+ {
+ _ = cookie.TryGetAsStokenV1(out Cookie? stoken);
+ _ = cookie.TryGetAsLtoken(out Cookie? ltoken);
+ _ = cookie.TryGetAsCookieToken(out Cookie? cookieToken);
+
+ return new() { SToken = stoken, LToken = ltoken, CookieToken = cookieToken };
+ }
}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Service/AvatarInfo/AvatarInfoDbOperation.cs b/src/Snap.Hutao/Snap.Hutao/Service/AvatarInfo/AvatarInfoDbOperation.cs
index 68b036a9..0a47f975 100644
--- a/src/Snap.Hutao/Snap.Hutao/Service/AvatarInfo/AvatarInfoDbOperation.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Service/AvatarInfo/AvatarInfoDbOperation.cs
@@ -90,48 +90,72 @@ internal sealed class AvatarInfoDbOperation
.ToList();
EnsureItemsAvatarIdDistinct(ref dbInfos, uid);
- GameRecordClient gameRecordClient = Ioc.Default.GetRequiredService();
- Response playerInfoResponse = await gameRecordClient
- .GetPlayerInfoAsync(userAndUid, token)
- .ConfigureAwait(false);
+ Response playerInfoResponse;
+ Response charactersResponse;
+
+ if (userAndUid.Uid.Region == "cn_gf01" || userAndUid.Uid.Region == "cn_qd01")
+ {
+ GameRecordClient gameRecordClient = Ioc.Default.GetRequiredService();
+ playerInfoResponse = await gameRecordClient
+ .GetPlayerInfoAsync(userAndUid, token)
+ .ConfigureAwait(false);
+
+ if (!playerInfoResponse.IsOk())
+ {
+ return GetDbAvatarInfos(uid);
+ }
+
+ charactersResponse = await gameRecordClient
+ .GetCharactersAsync(userAndUid, playerInfoResponse.Data, token)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ GameRecordClientOs gameRecordClientOs = Ioc.Default.GetRequiredService();
+ playerInfoResponse = await gameRecordClientOs
+ .GetPlayerInfoAsync(userAndUid, token)
+ .ConfigureAwait(false);
+
+ if (!playerInfoResponse.IsOk())
+ {
+ return GetDbAvatarInfos(uid);
+ }
+
+ charactersResponse = await gameRecordClientOs
+ .GetCharactersAsync(userAndUid, playerInfoResponse.Data, token)
+ .ConfigureAwait(false);
+ }
token.ThrowIfCancellationRequested();
- if (playerInfoResponse.IsOk())
+ if (charactersResponse.IsOk())
{
- Response charactersResponse = await gameRecordClient
- .GetCharactersAsync(userAndUid, playerInfoResponse.Data, token)
- .ConfigureAwait(false);
+ List characters = charactersResponse.Data.Avatars;
- if (charactersResponse.IsOk())
+ GameRecordCharacterAvatarInfoComposer composer = Ioc.Default.GetRequiredService();
+
+ foreach (RecordCharacter character in characters)
{
- List characters = charactersResponse.Data.Avatars;
-
- GameRecordCharacterAvatarInfoComposer composer = Ioc.Default.GetRequiredService();
-
- foreach (RecordCharacter character in characters)
+ if (AvatarIds.IsPlayer(character.Id))
{
- if (AvatarIds.IsPlayer(character.Id))
- {
- continue;
- }
+ continue;
+ }
- token.ThrowIfCancellationRequested();
+ token.ThrowIfCancellationRequested();
- ModelAvatarInfo? entity = dbInfos.SingleOrDefault(i => i.Info.AvatarId == character.Id);
+ ModelAvatarInfo? entity = dbInfos.SingleOrDefault(i => i.Info.AvatarId == character.Id);
- if (entity == null)
- {
- EnkaAvatarInfo avatarInfo = new() { AvatarId = character.Id };
- avatarInfo = await composer.ComposeAsync(avatarInfo, character).ConfigureAwait(false);
- entity = ModelAvatarInfo.Create(uid, avatarInfo);
- appDbContext.AvatarInfos.AddAndSave(entity);
- }
- else
- {
- entity.Info = await composer.ComposeAsync(entity.Info, character).ConfigureAwait(false);
- appDbContext.AvatarInfos.UpdateAndSave(entity);
- }
+ if (entity == null)
+ {
+ EnkaAvatarInfo avatarInfo = new() { AvatarId = character.Id };
+ avatarInfo = await composer.ComposeAsync(avatarInfo, character).ConfigureAwait(false);
+ entity = ModelAvatarInfo.Create(uid, avatarInfo);
+ appDbContext.AvatarInfos.AddAndSave(entity);
+ }
+ else
+ {
+ entity.Info = await composer.ComposeAsync(entity.Info, character).ConfigureAwait(false);
+ appDbContext.AvatarInfos.UpdateAndSave(entity);
}
}
}
diff --git a/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteNotifier.cs b/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteNotifier.cs
index 8bf52e47..71e6bb59 100644
--- a/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteNotifier.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteNotifier.cs
@@ -60,16 +60,13 @@ internal sealed class DailyNoteNotifier
BindingClient bindingClient = scope.ServiceProvider.GetRequiredService();
AuthClient authClient = scope.ServiceProvider.GetRequiredService();
- Response actionTicketResponse = await authClient
- .GetActionTicketByStokenAsync("game_role", entry.User)
- .ConfigureAwait(false);
-
string? attribution = SH.ServiceDailyNoteNotifierAttribution;
- if (actionTicketResponse.IsOk())
+
+ if (entry.User.IsOversea)
{
Response> rolesResponse = await scope.ServiceProvider
.GetRequiredService()
- .GetUserGameRolesByActionTicketAsync(actionTicketResponse.Data.Ticket, entry.User)
+ .GetOsUserGameRolesByCookieAsync(entry.User)
.ConfigureAwait(false);
if (rolesResponse.IsOk())
@@ -77,6 +74,27 @@ internal sealed class DailyNoteNotifier
List roles = rolesResponse.Data.List;
attribution = roles.SingleOrDefault(r => r.GameUid == entry.Uid)?.ToString() ?? "Unkonwn";
}
+
+ }
+ else
+ {
+ Response actionTicketResponse = await authClient
+ .GetActionTicketByStokenAsync("game_role", entry.User)
+ .ConfigureAwait(false);
+
+ if (actionTicketResponse.IsOk())
+ {
+ Response> rolesResponse = await scope.ServiceProvider
+ .GetRequiredService()
+ .GetUserGameRolesByActionTicketAsync(actionTicketResponse.Data.Ticket, entry.User)
+ .ConfigureAwait(false);
+
+ if (rolesResponse.IsOk())
+ {
+ List roles = rolesResponse.Data.List;
+ attribution = roles.SingleOrDefault(r => r.GameUid == entry.Uid)?.ToString() ?? "Unkonwn";
+ }
+ }
}
ToastContentBuilder builder = new ToastContentBuilder()
diff --git a/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteService.cs b/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteService.cs
index 3add8387..37fca642 100644
--- a/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteService.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteService.cs
@@ -12,6 +12,7 @@ using Snap.Hutao.Model.Entity.Database;
using Snap.Hutao.Service.Abstraction;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.User;
+using Snap.Hutao.Web.Hoyolab;
using Snap.Hutao.Web.Hoyolab.Takumi.GameRecord;
using System.Collections.ObjectModel;
using WebDailyNote = Snap.Hutao.Web.Hoyolab.Takumi.GameRecord.DailyNote.DailyNote;
@@ -61,14 +62,27 @@ internal sealed class DailyNoteService : IDailyNoteService, IRecipient();
GameRecordClient gameRecordClient = scope.ServiceProvider.GetRequiredService();
+ GameRecordClientOs gameRecordClientOs = scope.ServiceProvider.GetRequiredService();
if (!appDbContext.DailyNotes.Any(n => n.Uid == roleUid))
{
DailyNoteEntry newEntry = DailyNoteEntry.Create(role);
- Web.Response.Response dailyNoteResponse = await gameRecordClient
+ // 根据 Uid 的地区选择不同的 API
+ Web.Response.Response dailyNoteResponse;
+ PlayerUid playerUid = new(roleUid);
+ if (playerUid.Region == "cn_gf01" || playerUid.Region == "cn_qd01")
+ {
+ dailyNoteResponse = await gameRecordClient
.GetDailyNoteAsync(role)
.ConfigureAwait(false);
+ }
+ else
+ {
+ dailyNoteResponse = await gameRecordClientOs
+ .GetDailyNoteAsync(role)
+ .ConfigureAwait(false);
+ }
if (dailyNoteResponse.IsOk())
{
@@ -110,11 +124,13 @@ internal sealed class DailyNoteService : IDailyNoteService, IRecipient();
GameRecordClient gameRecordClient = scope.ServiceProvider.GetRequiredService();
+ GameRecordClientOs gameRecordClientOs = scope.ServiceProvider.GetRequiredService();
bool isSilentMode = appDbContext.Settings
.SingleOrAdd(SettingEntry.DailyNoteSilentWhenPlayingGame, Core.StringLiterals.False)
.GetBoolean();
bool isGameRunning = scope.ServiceProvider.GetRequiredService().IsGameRunning();
+
if (isSilentMode && isGameRunning)
{
// Prevent notify when we are in game && silent mode.
@@ -123,9 +139,20 @@ internal sealed class DailyNoteService : IDailyNoteService, IRecipient n.User))
{
- Web.Response.Response dailyNoteResponse = await gameRecordClient
+ Web.Response.Response dailyNoteResponse;
+ PlayerUid playerUid = new(entry.Uid);
+ if (playerUid.Region == "cn_gf01" || playerUid.Region == "cn_qd01")
+ {
+ dailyNoteResponse = await gameRecordClient
.GetDailyNoteAsync(new(entry.User, entry.Uid))
.ConfigureAwait(false);
+ }
+ else
+ {
+ dailyNoteResponse = await gameRecordClientOs
+ .GetDailyNoteAsync(new(entry.User, entry.Uid))
+ .ConfigureAwait(false);
+ }
if (dailyNoteResponse.ReturnCode == 0)
{
diff --git a/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryStokenProvider.cs b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryStokenProvider.cs
index 2db0d111..701129c8 100644
--- a/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryStokenProvider.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryStokenProvider.cs
@@ -38,6 +38,11 @@ internal sealed class GachaLogQueryStokenProvider : IGachaLogQueryProvider
{
if (UserAndUid.TryFromUser(userService.Current, out UserAndUid? userAndUid))
{
+ if (userAndUid.Uid.Region != "cn_gf01" && userAndUid.Uid.Region != "cn_qd01")
+ {
+ return new(false, "Unsupported for hoyoverse account");
+ }
+
GenAuthKeyData data = GenAuthKeyData.CreateForWebViewGacha(userAndUid.Uid);
Response authkeyResponse = await bindingClient2.GenerateAuthenticationKeyAsync(userAndUid.User, data).ConfigureAwait(false);
diff --git a/src/Snap.Hutao/Snap.Hutao/Service/SpiralAbyss/SpiralAbyssRecordService.cs b/src/Snap.Hutao/Snap.Hutao/Service/SpiralAbyss/SpiralAbyssRecordService.cs
index 45365467..f2fd9c83 100644
--- a/src/Snap.Hutao/Snap.Hutao/Service/SpiralAbyss/SpiralAbyssRecordService.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Service/SpiralAbyss/SpiralAbyssRecordService.cs
@@ -21,6 +21,7 @@ internal class SpiralAbyssRecordService : ISpiralAbyssRecordService
{
private readonly AppDbContext appDbContext;
private readonly GameRecordClient gameRecordClient;
+ private readonly GameRecordClientOs gameRecordClientOs;
private string? uid;
private ObservableCollection? spiralAbysses;
@@ -30,10 +31,11 @@ internal class SpiralAbyssRecordService : ISpiralAbyssRecordService
///
/// 数据库上下文
/// 游戏记录客户端
- public SpiralAbyssRecordService(AppDbContext appDbContext, GameRecordClient gameRecordClient)
+ public SpiralAbyssRecordService(AppDbContext appDbContext, GameRecordClient gameRecordClient, GameRecordClientOs gameRecordClientOs)
{
this.appDbContext = appDbContext;
this.gameRecordClient = gameRecordClient;
+ this.gameRecordClientOs = gameRecordClientOs;
}
///
@@ -70,9 +72,21 @@ internal class SpiralAbyssRecordService : ISpiralAbyssRecordService
private async Task RefreshSpiralAbyssCoreAsync(UserAndUid userAndUid, SpiralAbyssSchedule schedule)
{
- Response response = await gameRecordClient
+ Response response;
+
+ // server determination
+ if (userAndUid.Uid.Region == "cn_gf01" || userAndUid.Uid.Region == "cn_qd01")
+ {
+ response = await gameRecordClient
.GetSpiralAbyssAsync(userAndUid, schedule)
.ConfigureAwait(false);
+ }
+ else
+ {
+ response = await gameRecordClientOs
+ .GetSpiralAbyssAsync(userAndUid, schedule)
+ .ConfigureAwait(false);
+ }
if (response.IsOk())
{
diff --git a/src/Snap.Hutao/Snap.Hutao/Service/User/IUserService.cs b/src/Snap.Hutao/Snap.Hutao/Service/User/IUserService.cs
index 8546d4a8..8c4502d7 100644
--- a/src/Snap.Hutao/Snap.Hutao/Service/User/IUserService.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Service/User/IUserService.cs
@@ -48,6 +48,13 @@ internal interface IUserService
/// 处理的结果
Task> ProcessInputCookieAsync(Cookie cookie);
+ ///
+ /// 尝试异步处理国际服 Cookie
+ ///
+ /// Cookie,需包含 stuid, stoken 字段
+ /// 处理的结果
+ Task> ProcessInputOsCookieAsync(Cookie cookie);
+
///
/// 异步刷新 Cookie 的 CookieToken
///
diff --git a/src/Snap.Hutao/Snap.Hutao/Service/User/UserService.cs b/src/Snap.Hutao/Snap.Hutao/Service/User/UserService.cs
index e111713c..a4aa33cd 100644
--- a/src/Snap.Hutao/Snap.Hutao/Service/User/UserService.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Service/User/UserService.cs
@@ -225,7 +225,49 @@ internal class UserService : IUserService
}
else
{
- return await TryCreateUserAndAddAsync(cookie).ConfigureAwait(false);
+ return await TryCreateUserAndAddAsync(cookie, false).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ public async Task> ProcessInputOsCookieAsync(Cookie cookie)
+ {
+ await ThreadHelper.SwitchToBackgroundAsync();
+ string? stuid = cookie.GetValueOrDefault(Cookie.STUID);
+
+ if (stuid == null)
+ {
+ return new(UserOptionResult.Invalid, SH.ServiceUserProcessCookieNoMid);
+ }
+
+ // 检查 stuid 对应用户是否存在
+ if (TryGetUser(userCollection!, stuid, out BindingUser? user))
+ {
+ // Note: Currently we dont know how to get "mid" for hoyolab user,
+ // mid is set as the same value of ltuid(stuid/user id)
+ user.Entity.Mid = stuid;
+ using (IServiceScope scope = scopeFactory.CreateScope())
+ {
+ AppDbContext appDbContext = scope.ServiceProvider.GetRequiredService();
+
+ if (cookie.TryGetAsStoken(out Cookie? stoken))
+ {
+ user.SToken = stoken;
+ user.LToken = cookie.TryGetAsLtoken(out Cookie? ltoken) ? ltoken : user.LToken;
+ user.CookieToken = cookie.TryGetAsCookieToken(out Cookie? cookieToken) ? cookieToken : user.CookieToken;
+
+ await appDbContext.Users.UpdateAndSaveAsync(user.Entity).ConfigureAwait(false);
+ return new(UserOptionResult.Updated, stuid);
+ }
+ else
+ {
+ return new(UserOptionResult.Invalid, SH.ServiceUserProcessCookieNoStoken);
+ }
+ }
+ }
+ else
+ {
+ return await TryCreateUserAndAddAsync(cookie, true).ConfigureAwait(false);
}
}
@@ -234,10 +276,21 @@ internal class UserService : IUserService
{
using (IServiceScope scope = scopeFactory.CreateScope())
{
- Response cookieTokenResponse = await scope.ServiceProvider
+ Response cookieTokenResponse;
+ if (user.Entity.IsOversea)
+ {
+ cookieTokenResponse = await scope.ServiceProvider
+ .GetRequiredService()
+ .GetCookieAccountInfoBySTokenAsync(user.Entity)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ cookieTokenResponse = await scope.ServiceProvider
.GetRequiredService()
.GetCookieAccountInfoBySTokenAsync(user.Entity)
.ConfigureAwait(false);
+ }
if (cookieTokenResponse.IsOk())
{
@@ -265,14 +318,24 @@ internal class UserService : IUserService
return user != null;
}
- private async Task> TryCreateUserAndAddAsync(Cookie cookie)
+ private async Task> TryCreateUserAndAddAsync(Cookie cookie, bool isOversea)
{
await ThreadHelper.SwitchToBackgroundAsync();
using (IServiceScope scope = scopeFactory.CreateScope())
{
AppDbContext appDbContext = scope.ServiceProvider.GetRequiredService();
+ BindingUser? newUser;
+
+ // 判断是否为国际服
+ if (isOversea)
+ {
+ newUser = await BindingUser.CreateOsUserAsync(cookie).ConfigureAwait(false);
+ }
+ else
+ {
+ newUser = await BindingUser.CreateAsync(cookie).ConfigureAwait(false);
+ }
- BindingUser? newUser = await BindingUser.CreateAsync(cookie).ConfigureAwait(false);
if (newUser != null)
{
// Sync cache
diff --git a/src/Snap.Hutao/Snap.Hutao/View/Dialog/SignInWebViewDialog.xaml.cs b/src/Snap.Hutao/Snap.Hutao/View/Dialog/SignInWebViewDialog.xaml.cs
index e712f613..33a686e0 100644
--- a/src/Snap.Hutao/Snap.Hutao/View/Dialog/SignInWebViewDialog.xaml.cs
+++ b/src/Snap.Hutao/Snap.Hutao/View/Dialog/SignInWebViewDialog.xaml.cs
@@ -47,9 +47,18 @@ internal sealed partial class SignInWebViewDialog : ContentDialog
return;
}
- coreWebView2.SetCookie(user.CookieToken, user.LToken, null).SetMobileUserAgent();
- signInJsInterface = new(coreWebView2, scope.ServiceProvider);
- coreWebView2.Navigate("https://webstatic.mihoyo.com/bbs/event/signin-ys/index.html?act_id=e202009291139501");
+ if (user.Entity.IsOversea)
+ {
+ coreWebView2.SetCookie(user.CookieToken, user.LToken, null).SetOsMobileUserAgent();
+ signInJsInterface = new(coreWebView2, scope.ServiceProvider);
+ coreWebView2.Navigate("https://act.hoyolab.com/ys/event/signin-sea-v3/index.html?act_id=e202102251931481&hyl_presentation_style=fullscreen");
+ }
+ else
+ {
+ coreWebView2.SetCookie(user.CookieToken, user.LToken, null).SetMobileUserAgent();
+ signInJsInterface = new(coreWebView2, scope.ServiceProvider);
+ coreWebView2.Navigate("https://webstatic.mihoyo.com/bbs/event/signin-ys/index.html?act_id=e202009291139501");
+ }
}
private void OnContentDialogClosed(ContentDialog sender, ContentDialogClosedEventArgs args)
diff --git a/src/Snap.Hutao/Snap.Hutao/View/Page/LoginHoyoverseUserPage.xaml b/src/Snap.Hutao/Snap.Hutao/View/Page/LoginHoyoverseUserPage.xaml
new file mode 100644
index 00000000..e705ba28
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/View/Page/LoginHoyoverseUserPage.xaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Snap.Hutao/Snap.Hutao/View/Page/LoginHoyoverseUserPage.xaml.cs b/src/Snap.Hutao/Snap.Hutao/View/Page/LoginHoyoverseUserPage.xaml.cs
new file mode 100644
index 00000000..12f15301
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/View/Page/LoginHoyoverseUserPage.xaml.cs
@@ -0,0 +1,124 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+using Microsoft.UI.Xaml;
+using Microsoft.Web.WebView2.Core;
+using Snap.Hutao.Service.Abstraction;
+using Snap.Hutao.Service.Navigation;
+using Snap.Hutao.Service.User;
+using Snap.Hutao.View.Dialog;
+using Snap.Hutao.Web.Hoyolab;
+using Snap.Hutao.Web.Hoyolab.Passport;
+using Snap.Hutao.Web.Hoyolab.Takumi.Auth;
+using Snap.Hutao.Web.Response;
+using System.Diagnostics.Eventing.Reader;
+
+namespace Snap.Hutao.View.Page;
+
+///
+/// ¼֤ͨҳ
+///
+[HighQuality]
+internal sealed partial class LoginHoyoverseUserPage : Microsoft.UI.Xaml.Controls.Page
+{
+ ///
+ /// һµĵ¼֤ͨҳ
+ ///
+ public LoginHoyoverseUserPage()
+ {
+ InitializeComponent();
+ }
+
+ [SuppressMessage("", "VSTHRD100")]
+ private async void OnRootLoaded(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ await WebView.EnsureCoreWebView2Async();
+
+
+ CoreWebView2CookieManager manager = WebView.CoreWebView2.CookieManager;
+ IReadOnlyList cookies = await manager.GetCookiesAsync("https://account.hoyolab.com");
+ foreach (CoreWebView2Cookie item in cookies)
+ {
+ manager.DeleteCookie(item);
+ }
+
+ WebView.CoreWebView2.Navigate("https://account.hoyolab.com/#/login");
+ }
+ catch (Exception ex)
+ {
+ Ioc.Default.GetRequiredService().Error(ex);
+ }
+ }
+
+ private async Task HandleCurrentCookieAsync(CancellationToken token)
+ {
+ CoreWebView2CookieManager manager = WebView.CoreWebView2.CookieManager;
+ IReadOnlyList cookies = await manager.GetCookiesAsync("https://account.hoyolab.com");
+
+ // Get user id from text input, login_uid is missed in cookie
+ string uid = UidInput.Text;
+
+ if (uid.Length != 9)
+ {
+ return;
+ }
+
+ Cookie loginTicketCookie = Cookie.FromCoreWebView2Cookies(cookies);
+ loginTicketCookie["login_uid"] = uid;
+
+ // ʹ loginTicket ȡ stoken
+ Response> multiTokenResponse = await Ioc.Default
+ .GetRequiredService()
+ .GetMultiTokenByLoginTicketAsync(loginTicketCookie, token)
+ .ConfigureAwait(false);
+
+ if (!multiTokenResponse.IsOk())
+ {
+ return;
+ }
+
+ Dictionary multiTokenMap = multiTokenResponse.Data.List.ToDictionary(n => n.Name, n => n.Token);
+ Cookie hoyoLabCookie = Cookie.Parse($"stoken={multiTokenMap["stoken"]}; stuid={uid}");
+
+ // cookie û
+ (UserOptionResult result, string nickname) = await Ioc.Default
+ .GetRequiredService()
+ .ProcessInputOsCookieAsync(hoyoLabCookie)
+ .ConfigureAwait(false);
+
+ Ioc.Default.GetRequiredService().GoBack();
+ IInfoBarService infoBarService = Ioc.Default.GetRequiredService();
+
+ switch (result)
+ {
+ case UserOptionResult.Added:
+ ViewModel.UserViewModel vm = Ioc.Default.GetRequiredService();
+ if (vm.Users!.Count == 1)
+ {
+ await ThreadHelper.SwitchToMainThreadAsync();
+ vm.SelectedUser = vm.Users.Single();
+ }
+
+ infoBarService.Success($"û [{nickname}] ӳɹ");
+ break;
+ case UserOptionResult.Incomplete:
+ infoBarService.Information($" Cookie user id ʧ");
+ break;
+ case UserOptionResult.Invalid:
+ infoBarService.Information($" Cookie Чʧ");
+ break;
+ case UserOptionResult.Updated:
+ infoBarService.Success($"û [{nickname}] ³ɹ");
+ break;
+ default:
+ throw Must.NeverHappen();
+ }
+ }
+
+ private void CookieButtonClick(object sender, RoutedEventArgs e)
+ {
+ HandleCurrentCookieAsync(CancellationToken.None).SafeForget();
+ }
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml b/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml
index 8bd5f811..ddba3e58 100644
--- a/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml
+++ b/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml
@@ -225,6 +225,23 @@
Icon="{shcm:FontIcon Glyph=}"
Label="{shcm:ResourceString Name=ViewUserCookieOperationManualInputAction}"/>
+
+
+
+
+
diff --git a/src/Snap.Hutao/Snap.Hutao/ViewModel/DailyNoteViewModel.cs b/src/Snap.Hutao/Snap.Hutao/ViewModel/DailyNoteViewModel.cs
index dace233f..12ebc139 100644
--- a/src/Snap.Hutao/Snap.Hutao/ViewModel/DailyNoteViewModel.cs
+++ b/src/Snap.Hutao/Snap.Hutao/ViewModel/DailyNoteViewModel.cs
@@ -247,9 +247,17 @@ internal sealed class DailyNoteViewModel : Abstraction.ViewModel
{
if (UserAndUid.TryFromUser(userService.Current, out UserAndUid? userAndUid))
{
- // ContentDialog must be created by main thread.
- await ThreadHelper.SwitchToMainThreadAsync();
- await new DailyNoteVerificationDialog(userAndUid).ShowAsync();
+ // TODO: Add verify support for oversea user
+ if (userAndUid.User.IsOversea)
+ {
+ serviceProvider.GetRequiredService().Warning("Unsupported for hoyoverse account");
+ }
+ else
+ {
+ // ContentDialog must be created by main thread.
+ await ThreadHelper.SwitchToMainThreadAsync();
+ await new DailyNoteVerificationDialog(userAndUid).ShowAsync();
+ }
}
else
{
diff --git a/src/Snap.Hutao/Snap.Hutao/ViewModel/UserViewModel.cs b/src/Snap.Hutao/Snap.Hutao/ViewModel/UserViewModel.cs
index d59490b1..af930519 100644
--- a/src/Snap.Hutao/Snap.Hutao/ViewModel/UserViewModel.cs
+++ b/src/Snap.Hutao/Snap.Hutao/ViewModel/UserViewModel.cs
@@ -46,7 +46,9 @@ internal sealed class UserViewModel : ObservableObject
OpenUICommand = new AsyncRelayCommand(OpenUIAsync);
AddUserCommand = new AsyncRelayCommand(AddUserAsync);
+ AddOsUserCommand = new AsyncRelayCommand(AddOsUserAsync);
LoginMihoyoUserCommand = new RelayCommand(LoginMihoyoUser);
+ LoginHoyoverseUserCommand = new RelayCommand(LoginHoyoverseUser);
RemoveUserCommand = new AsyncRelayCommand(RemoveUserAsync);
CopyCookieCommand = new RelayCommand(CopyCookie);
RefreshCookieTokenCommand = new AsyncRelayCommand(RefreshCookieTokenAsync);
@@ -87,11 +89,21 @@ internal sealed class UserViewModel : ObservableObject
///
public ICommand AddUserCommand { get; }
+ ///
+ /// 添加国际服用户命令
+ ///
+ public ICommand AddOsUserCommand { get; }
+
///
/// 登录米游社命令
///
public ICommand LoginMihoyoUserCommand { get; }
+ ///
+ /// 登录米游社命令
+ ///
+ public ICommand LoginHoyoverseUserCommand { get; }
+
///
/// 移除用户命令
///
@@ -161,6 +173,47 @@ internal sealed class UserViewModel : ObservableObject
}
}
+ private async Task AddOsUserAsync()
+ {
+ // ContentDialog must be created by main thread.
+ await ThreadHelper.SwitchToMainThreadAsync();
+
+ // Get cookie from user input
+ ValueResult result = await new UserDialog().GetInputCookieAsync().ConfigureAwait(false);
+
+ // User confirms the input
+ if (result.IsOk)
+ {
+ Cookie cookie = Cookie.Parse(result.Value);
+
+ (UserOptionResult optionResult, string uid) = await userService.ProcessInputOsCookieAsync(cookie).ConfigureAwait(false);
+
+ switch (optionResult)
+ {
+ case UserOptionResult.Added:
+ if (Users!.Count == 1)
+ {
+ await ThreadHelper.SwitchToMainThreadAsync();
+ SelectedUser = Users.Single();
+ }
+
+ infoBarService.Success(string.Format(SH.ViewModelUserAdded, uid));
+ break;
+ case UserOptionResult.Incomplete:
+ infoBarService.Information(SH.ViewModelUserIncomplete);
+ break;
+ case UserOptionResult.Invalid:
+ infoBarService.Information(SH.ViewModelUserInvalid);
+ break;
+ case UserOptionResult.Updated:
+ infoBarService.Success(string.Format(SH.ViewModelUserUpdated, uid));
+ break;
+ default:
+ throw Must.NeverHappen();
+ }
+ }
+ }
+
private void LoginMihoyoUser()
{
if (Core.WebView2Helper.IsSupported)
@@ -173,6 +226,21 @@ internal sealed class UserViewModel : ObservableObject
}
}
+ ///
+ /// 打开浏览器登录 hoyolab 以获取 cookie
+ ///
+ private void LoginHoyoverseUser()
+ {
+ if (Core.WebView2Helper.IsSupported)
+ {
+ serviceProvider.GetRequiredService().Navigate(INavigationAwaiter.Default);
+ }
+ else
+ {
+ infoBarService.Warning(SH.CoreWebView2HelperVersionUndetected);
+ }
+ }
+
private async Task RemoveUserAsync(User? user)
{
if (user != null)
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/ApiOsEndpoints.cs b/src/Snap.Hutao/Snap.Hutao/Web/ApiOsEndpoints.cs
index 6743bd2d..9f937136 100644
--- a/src/Snap.Hutao/Snap.Hutao/Web/ApiOsEndpoints.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Web/ApiOsEndpoints.cs
@@ -1,7 +1,11 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
+using Microsoft.EntityFrameworkCore.Sqlite.Query.Internal;
+using Microsoft.UI.Xaml;
+using Snap.Hutao.Model.Primitive;
using Snap.Hutao.Service.Game;
+using Snap.Hutao.Web.Hoyolab;
namespace Snap.Hutao.Web;
@@ -13,6 +17,178 @@ namespace Snap.Hutao.Web;
[SuppressMessage("", "SA1124")]
internal static class ApiOsEndpoints
{
+ #region ApiGeetest
+
+ ///
+ /// 获取GT码
+ ///
+ /// gt
+ /// GT码Url
+ public static string GeetestGetType(string gt)
+ {
+ return $"{ApiNaGeetest}/gettype.php?gt={gt}";
+ }
+
+ ///
+ /// 验证接口
+ ///
+ /// gt
+ /// challenge流水号
+ /// 验证接口Url
+ public static string GeetestAjax(string gt, string challenge)
+ {
+ return $"{ApiNaGeetest}/ajax.php?gt={gt}&challenge={challenge}&lang=zh-cn&pt=0&client_type=web";
+ }
+
+ #endregion
+
+ #region ApiOsTakumiAuthApi
+
+ ///
+ /// 获取 stoken 与 ltoken
+ ///
+ /// 登录票证
+ /// uid
+ /// Url
+ public static string AuthMultiToken(string loginTicket, string loginUid)
+ {
+ return $"{ApiAccountOsAuthApi}/getMultiTokenByLoginTicket?login_ticket={loginTicket}&uid={loginUid}&token_types=3";
+ }
+
+ ///
+ /// 获取 stoken 与 ltoken
+ ///
+ /// 操作类型 game_role
+ /// Stoken
+ /// uid
+ /// Url
+ public static string AuthActionTicket(string actionType, string stoken, string uid)
+ {
+ return $"{ApiAccountOsAuthApi}/getActionTicketBySToken?action_type={actionType}&stoken={Uri.EscapeDataString(stoken)}&uid={uid}";
+ }
+
+ #endregion
+
+ #region ApiOsTaKumiApi
+
+ ///
+ /// 用户游戏角色
+ ///
+ /// 用户游戏角色字符串
+ public const string UserGameRolesByCookie = $"{ApiOsTaKumiBindingApi}/getUserGameRolesByCookie?game_biz=hk4e_global";
+
+ ///
+ /// 用户游戏角色
+ ///
+ /// 地区代号
+ /// 用户游戏角色字符串
+ public static string UserGameRolesByLtoken(string region)
+ {
+ return $"{ApiAccountOsBindingApi}/getUserGameRolesByLtoken?game_biz=hk4e_global®ion={region}";
+ }
+
+ #endregion
+
+ #region SgPublicApi
+
+ ///
+ /// 计算器家具计算
+ ///
+ public const string CalculateOsFurnitureCompute = $"{SgPublicApi}/event/calculateos/furniture/list";
+
+ ///
+ /// 计算器角色列表 size 20
+ ///
+ public const string CalculateOsAvatarList = $"{SgPublicApi}/event/calculateos/avatar/list";
+
+ ///
+ /// 计算器武器列表 size 20
+ ///
+ public const string CalculateOsWeaponList = $"{SgPublicApi}/event/calculateos/weapon/list";
+
+ ///
+ /// 计算器结果
+ ///
+ public const string CalculateOsCompute = $"{SgPublicApi}/event/calculateos/compute";
+
+ ///
+ /// 计算器同步角色详情 size 20
+ ///
+ /// 角色Id
+ /// uid
+ /// 角色详情
+ public static string CalculateOsSyncAvatarDetail(AvatarId avatarId, PlayerUid uid)
+ {
+ return $"{SgPublicApi}/event/calculateos/sync/avatar/detail?avatar_id={avatarId.Value}&uid={uid.Value}®ion={uid.Region}";
+ }
+
+ ///
+ /// 计算器同步角色列表 size 20
+ ///
+ public const string CalculateOsSyncAvatarList = $"{SgPublicApi}/event/calculateos/sync/avatar/list";
+
+ #endregion
+
+ #region BbsApiOsApi
+
+ ///
+ /// 查询其他用户详细信息
+ ///
+ /// bbs Uid
+ /// 查询其他用户详细信息字符串
+ public static string UserFullInfoQuery(string bbsUid)
+ {
+ return $"{BbsApiOs}/community/painter/wapi/user/full";
+ }
+
+ ///
+ /// 国际服角色基本信息
+ ///
+ /// uid
+ /// 角色基本信息字符串
+ public static string GameRecordRoleBasicInfo(PlayerUid uid)
+ {
+ return $"{BbsApiOsGameRecordApi}/roleBasicInfo?role_id={uid.Value}&server={uid.Region}";
+ }
+
+ ///
+ /// 国际服角色信息
+ ///
+ public const string GameRecordCharacter = $"{BbsApiOsGameRecordApi}/character";
+
+ ///
+ /// 国际服游戏记录实时便笺
+ ///
+ /// uid
+ /// 游戏记录实时便笺字符串
+ public static string GameRecordDailyNote(PlayerUid uid)
+ {
+ return $"{BbsApiOsGameRecordApi}/dailyNote?server={uid.Region}&role_id={uid.Value}";
+ }
+
+ ///
+ /// 国际服游戏记录主页
+ ///
+ /// uid
+ /// 游戏记录主页字符串
+ public static string GameRecordIndex(PlayerUid uid)
+ {
+ return $"{BbsApiOsGameRecordApi}/index?server={uid.Region}&role_id={uid.Value}";
+ }
+
+ ///
+ /// 国际服深渊信息
+ ///
+ /// 深渊类型
+ /// Uid
+ /// 深渊信息字符串
+ public static string GameRecordSpiralAbyss(Hoyolab.Takumi.GameRecord.SpiralAbyssSchedule scheduleType, PlayerUid uid)
+ {
+ return $"{BbsApiOsGameRecordApi}/spiralAbyss?schedule_type={(int)scheduleType}&role_id={uid.Value}&server={uid.Region}";
+ }
+
+ #endregion
+
#region Hk4eApiOsGachaInfoApi
///
@@ -26,6 +202,25 @@ internal static class ApiOsEndpoints
}
#endregion
+ #region ApiAccountOsApi
+
+ ///
+ /// Hoyolab App Login api
+ /// Can fetch stoken
+ ///
+ public const string WebLoginByPassword = $"{ApiAccountOsAuthApi}/webLoginByPassword";
+
+ ///
+ /// 获取 Ltoken
+ ///
+ public const string AccountGetLtokenByStoken = $"{ApiAccountOsAuthApi}/getLTokenBySToken";
+
+ ///
+ /// fetch CookieToken
+ ///
+ public const string AccountGetCookieTokenBySToken = $"{ApiAccountOsAuthApi}/getCookieAccountInfoBySToken";
+ #endregion
+
#region SdkStaticLauncherApi
///
@@ -40,10 +235,31 @@ internal static class ApiOsEndpoints
#endregion
#region Hosts | Queries
+ private const string ApiNaGeetest = "https://api-na.geetest.com";
+
+ private const string ApiOsTaKumi = "https://api-os-takumi.hoyoverse.com";
+ private const string ApiOsTaKumiBindingApi = $"{ApiOsTaKumi}/binding/api";
+
+ private const string ApiAccountOs = "https://api-account-os.hoyolab.com";
+ private const string ApiAccountOsBindingApi = $"{ApiAccountOs}/binding/api";
+ private const string ApiAccountOsAuthApi = $"{ApiAccountOs}/account/auth/api";
+
+ private const string BbsApiOs = "https://bbs-api-os.hoyolab.com";
+ private const string BbsApiOsGameRecordApi = $"{BbsApiOs}/game_record/genshin/api";
+
private const string Hk4eApiOs = "https://hk4e-api-os.hoyoverse.com";
private const string Hk4eApiOsGachaInfoApi = $"{Hk4eApiOs}/event/gacha_info/api";
private const string SdkOsStatic = "https://sdk-os-static.mihoyo.com";
private const string SdkOsStaticLauncherApi = $"{SdkOsStatic}/hk4e_global/mdk/launcher/api";
+
+ private const string SgPublicApi = "https://sg-public-api.hoyolab.com";
+
+ ///
+ /// Web static referer
+ ///
+ public const string WebStaticSeaMihoyoReferer = "https://webstatic-sea.mihoyo.com";
+ public const string ActHoyolabReferer = "https://act.hoyolab.com/";
+
#endregion
}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Bridge/CoreWebView2Extension.cs b/src/Snap.Hutao/Snap.Hutao/Web/Bridge/CoreWebView2Extension.cs
index 3decfd09..c32bd1ab 100644
--- a/src/Snap.Hutao/Snap.Hutao/Web/Bridge/CoreWebView2Extension.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Bridge/CoreWebView2Extension.cs
@@ -23,6 +23,17 @@ internal static class CoreWebView2Extension
return webView;
}
+ ///
+ /// 设置 移动端UA
+ ///
+ /// webview2
+ /// 链式调用的WebView2
+ public static CoreWebView2 SetOsMobileUserAgent(this CoreWebView2 webView)
+ {
+ webView.Settings.UserAgent = Core.CoreEnvironment.HoyolabOsMobileUA;
+ return webView;
+ }
+
///
/// 设置WebView2的Cookie
///
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Bbs/User/UserClient.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Bbs/User/UserClient.cs
index 240c18bb..d5f1c5a2 100644
--- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Bbs/User/UserClient.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Bbs/User/UserClient.cs
@@ -52,4 +52,22 @@ internal sealed class UserClient
return Response.Response.DefaultIfNull(resp);
}
+
+ ///
+ /// 获取当前用户详细信息,使用 Ltoken
+ ///
+ /// 用户
+ /// 取消令牌
+ /// 详细信息
+ [ApiInformation(Cookie = CookieType.LToken, Salt = SaltType.OS)]
+ public async Task> GetOsUserFullInfoAsync(Model.Entity.User user, CancellationToken token = default)
+ {
+ Response? resp = await httpClient
+ .SetUser(user, CookieType.LToken)
+ .UseDynamicSecret(DynamicSecretVersion.Gen1, SaltType.OS, false)
+ .TryCatchGetFromJsonAsync>(ApiOsEndpoints.UserFullInfoQuery(user.Aid!), options, logger, token)
+ .ConfigureAwait(false);
+
+ return Response.Response.DefaultIfNull(resp);
+ }
}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Cookie.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Cookie.cs
index 846117ea..dc1cce1c 100644
--- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Cookie.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Cookie.cs
@@ -116,6 +116,31 @@ internal sealed partial class Cookie
return false;
}
+ ///
+ /// 提取其中的 stoken 信息
+ ///
+ /// 含有 Stoken 的 cookie
+ /// 是否获取成功
+ public bool TryGetAsStokenV1([NotNullWhen(true)] out Cookie? cookie)
+ {
+ bool hasStoken = TryGetValue(STOKEN, out string? stoken);
+ bool hasStuid = TryGetValue(STUID, out string? stuid);
+
+ if (hasStoken && hasStuid)
+ {
+ cookie = new Cookie(new()
+ {
+ [STOKEN] = stoken!,
+ [STUID] = stuid!,
+ });
+
+ return true;
+ }
+
+ cookie = null;
+ return false;
+ }
+
public bool TryGetAsLtoken([NotNullWhen(true)] out Cookie? cookie)
{
bool hasLtoken = TryGetValue(LTOKEN, out string? ltoken);
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/DynamicSecret/SaltType.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/DynamicSecret/SaltType.cs
index e9c18fcd..2683b1aa 100644
--- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/DynamicSecret/SaltType.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/DynamicSecret/SaltType.cs
@@ -38,4 +38,9 @@ internal enum SaltType
/// LK2
///
LK2,
+
+ ///
+ /// 国际服
+ ///
+ OS,
}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/PassportClientOs.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/PassportClientOs.cs
new file mode 100644
index 00000000..2dd06f7a
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/PassportClientOs.cs
@@ -0,0 +1,103 @@
+using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
+using Snap.Hutao.Model.Entity;
+using Snap.Hutao.Web.Hoyolab.Annotation;
+using Snap.Hutao.Web.Hoyolab.DynamicSecret;
+using Snap.Hutao.Web.Response;
+using System.Net.Http;
+using System.Net.Http.Json;
+using Windows.ApplicationModel.Contacts;
+
+namespace Snap.Hutao.Web.Hoyolab.Passport;
+
+///
+/// 通行证客户端 XRPC 版
+///
+[HighQuality]
+[UseDynamicSecret]
+[HttpClient(HttpClientConfigration.XRpc3)]
+internal sealed class PassportClientOs
+{
+ private readonly HttpClient httpClient;
+ private readonly JsonSerializerOptions options;
+ private readonly ILogger logger;
+
+ ///
+ /// 构造一个新的国际服通行证客户端
+ ///
+ /// http客户端
+ /// json序列化选项
+ /// 日志器
+ public PassportClientOs(HttpClient httpClient, JsonSerializerOptions options, ILogger logger)
+ {
+ this.httpClient = httpClient;
+ this.options = options;
+ this.logger = logger;
+ }
+
+ ///
+ /// 异步获取 CookieToken
+ ///
+ /// 用户
+ /// 取消令牌
+ /// cookie token
+ [ApiInformation(Cookie = CookieType.SToken, Salt = SaltType.None)]
+ public async Task> GetCookieAccountInfoBySTokenAsync(User user, CancellationToken token = default)
+ {
+ Response? resp = null;
+ string? stoken = user.SToken["stoken"];
+
+ if (stoken == null || user.Aid == null)
+ {
+ return Response.Response.DefaultIfNull(resp);
+ }
+
+ StokenData data = new(stoken, user.Aid);
+ resp = await httpClient
+ .SetUser(user, CookieType.SToken)
+ .TryCatchPostAsJsonAsync>(ApiOsEndpoints.AccountGetCookieTokenBySToken, data, options, logger, token)
+ .ConfigureAwait(false);
+
+ return Response.Response.DefaultIfNull(resp);
+ }
+
+ ///
+ /// 异步获取 Ltoken
+ ///
+ /// 用户
+ /// 取消令牌
+ /// uid 与 cookie token
+ [ApiInformation(Cookie = CookieType.SToken, Salt = SaltType.None)]
+ public async Task> GetLtokenBySTokenAsync(User user, CancellationToken token)
+ {
+ Response? resp = null;
+ string? stoken = user.SToken["stoken"];
+
+ if (stoken == null || user.Aid == null)
+ {
+ return Response.Response.DefaultIfNull(resp);
+ }
+
+ StokenData data = new(stoken, user.Aid);
+ resp = await httpClient
+ .SetUser(user, CookieType.SToken)
+ .TryCatchPostAsJsonAsync>(ApiOsEndpoints.AccountGetLtokenByStoken, data, options, logger, token)
+ .ConfigureAwait(false);
+
+ return Response.Response.DefaultIfNull(resp);
+ }
+
+ private class StokenData
+ {
+ public StokenData(string stoken, string uid)
+ {
+ Stoken = stoken;
+ Uid = uid;
+ }
+
+ [JsonPropertyName("stoken")]
+ public string Stoken { get; set; }
+
+ [JsonPropertyName("uid")]
+ public string Uid { get; set; }
+ }
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Auth/AuthClientOs.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Auth/AuthClientOs.cs
new file mode 100644
index 00000000..27027284
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Auth/AuthClientOs.cs
@@ -0,0 +1,53 @@
+using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
+using Snap.Hutao.Model.Entity;
+using Snap.Hutao.Web.Hoyolab.Annotation;
+using Snap.Hutao.Web.Hoyolab.DynamicSecret;
+using Snap.Hutao.Web.Hoyolab.Takumi.Binding;
+using Snap.Hutao.Web.Response;
+using System.Net.Http;
+
+namespace Snap.Hutao.Web.Hoyolab.Takumi.Auth;
+
+///
+/// Hoyolab 授权客户端
+///
+[HighQuality]
+[UseDynamicSecret]
+[HttpClient(HttpClientConfigration.Default)]
+internal sealed class AuthClientOs
+{
+ private readonly HttpClient httpClient;
+ private readonly JsonSerializerOptions options;
+ private readonly ILogger logger;
+
+ ///
+ /// 构造一个新的 Hoyolab 授权客户端
+ ///
+ /// Http客户端
+ /// Json序列化选项
+ /// 日志器
+ public AuthClientOs(HttpClient httpClient, JsonSerializerOptions options, ILogger logger)
+ {
+ this.httpClient = httpClient;
+ this.options = options;
+ this.logger = logger;
+ }
+
+ ///
+ /// 获取 MultiToken
+ ///
+ /// login cookie
+ /// 取消令牌
+ /// 包含token的字典
+ public async Task>> GetMultiTokenByLoginTicketAsync(Cookie cookie, CancellationToken token)
+ {
+ string loginTicket = cookie["login_ticket"];
+ string loginUid = cookie["login_uid"];
+
+ Response>? resp = await httpClient
+ .TryCatchGetFromJsonAsync>>(ApiOsEndpoints.AuthMultiToken(loginTicket, loginUid), options, logger, token)
+ .ConfigureAwait(false);
+
+ return Response.Response.DefaultIfNull(resp);
+ }
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Binding/BindingClient.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Binding/BindingClient.cs
index a8c598fe..fa8b55b9 100644
--- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Binding/BindingClient.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Binding/BindingClient.cs
@@ -52,4 +52,23 @@ internal sealed class BindingClient
return Response.Response.DefaultIfNull(resp);
}
+
+ ///
+ /// 获取国际服用户角色信息
+ ///
+ /// 用户
+ /// 取消令牌
+ /// 用户角色信息
+ [ApiInformation(Cookie = CookieType.LToken)]
+ public async Task>> GetOsUserGameRolesByCookieAsync(User user, CancellationToken token = default)
+ {
+ string url = ApiOsEndpoints.UserGameRolesByCookie;
+
+ Response>? resp = await httpClient
+ .SetUser(user, CookieType.LToken)
+ .TryCatchGetFromJsonAsync>>(url, options, logger, token)
+ .ConfigureAwait(false);
+
+ return Response.Response.DefaultIfNull(resp);
+ }
}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Event/Calculate/CalculateClient.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Event/Calculate/CalculateClient.cs
index 609796c5..79e0ff46 100644
--- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Event/Calculate/CalculateClient.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Event/Calculate/CalculateClient.cs
@@ -43,11 +43,24 @@ internal sealed class CalculateClient
[ApiInformation(Cookie = CookieType.Cookie)]
public async Task> ComputeAsync(Model.Entity.User user, AvatarPromotionDelta delta, CancellationToken token = default)
{
- Response? resp = await httpClient
+ Response? resp;
+
+ if (user.IsOversea)
+ {
+ resp = await httpClient
+ .SetUser(user, CookieType.Cookie)
+ .SetReferer(ApiOsEndpoints.ActHoyolabReferer)
+ .TryCatchPostAsJsonAsync>(ApiOsEndpoints.CalculateOsCompute, delta, options, logger, token)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ resp = await httpClient
.SetUser(user, CookieType.Cookie)
.SetReferer(ApiEndpoints.WebStaticMihoyoReferer)
.TryCatchPostAsJsonAsync>(ApiEndpoints.CalculateCompute, delta, options, logger, token)
.ConfigureAwait(false);
+ }
return Response.Response.DefaultIfNull(resp);
}
@@ -65,14 +78,28 @@ internal sealed class CalculateClient
List avatars = new();
Response>? resp;
- httpClient.SetUser(userAndUid.User, CookieType.CookieToken);
+
+ // 根据 uid 所属服务器选择 referer 与 api
+ string referer = ApiOsEndpoints.ActHoyolabReferer;
+ string endpoint = ApiOsEndpoints.CalculateOsSyncAvatarList;
+
+ if (userAndUid.Uid.Region == "cn_gf01" || userAndUid.Uid.Region == "cn_qd01")
+ {
+ referer = ApiEndpoints.WebStaticMihoyoReferer;
+ endpoint = ApiEndpoints.CalculateSyncAvatarList;
+ httpClient.SetUser(userAndUid.User, CookieType.CookieToken);
+ }
+ else
+ {
+ httpClient.SetUser(userAndUid.User, CookieType.Cookie);
+ }
do
{
filter.Page = currentPage++;
resp = await httpClient
- .SetReferer(ApiEndpoints.WebStaticMihoyoReferer)
- .TryCatchPostAsJsonAsync>>(ApiEndpoints.CalculateSyncAvatarList, filter, options, logger, token)
+ .SetReferer(referer)
+ .TryCatchPostAsJsonAsync>>(endpoint, filter, options, logger, token)
.ConfigureAwait(false);
if (resp != null && resp.IsOk())
@@ -101,11 +128,21 @@ internal sealed class CalculateClient
/// 角色详情
public async Task> GetAvatarDetailAsync(UserAndUid userAndUid, Avatar avatar, CancellationToken token = default)
{
- Response? resp = await httpClient
+ Response? resp;
+ if (userAndUid.Uid.Region == "cn_gf01" || userAndUid.Uid.Region == "cn_qd01")
+ {
+ resp = await httpClient
.SetUser(userAndUid.User, CookieType.CookieToken)
.TryCatchGetFromJsonAsync>(ApiEndpoints.CalculateSyncAvatarDetail(avatar.Id, userAndUid.Uid.Value), options, logger, token)
.ConfigureAwait(false);
-
+ }
+ else
+ {
+ resp = await httpClient
+ .SetUser(userAndUid.User, CookieType.Cookie)
+ .TryCatchGetFromJsonAsync>(ApiOsEndpoints.CalculateOsSyncAvatarDetail(avatar.Id, userAndUid.Uid.Value), options, logger, token)
+ .ConfigureAwait(false);
+ }
return Response.Response.DefaultIfNull(resp);
}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs
new file mode 100644
index 00000000..39f5e55d
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs
@@ -0,0 +1,133 @@
+using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
+using Snap.Hutao.Model.Binding.User;
+using Snap.Hutao.Model.Primitive;
+using Snap.Hutao.Service.Abstraction;
+using Snap.Hutao.Web.Hoyolab.Annotation;
+using Snap.Hutao.Web.Hoyolab.DynamicSecret;
+using Snap.Hutao.Web.Hoyolab.Takumi.GameRecord.Avatar;
+using Snap.Hutao.Web.Response;
+using System.Net.Http;
+
+namespace Snap.Hutao.Web.Hoyolab.Takumi.GameRecord;
+
+///
+/// Hoyoverse game record provider
+///
+[HighQuality]
+[UseDynamicSecret]
+[HttpClient(HttpClientConfigration.XRpc3)]
+[PrimaryHttpMessageHandler(UseCookies = false)]
+internal sealed class GameRecordClientOs
+{
+ private readonly HttpClient httpClient;
+ private readonly JsonSerializerOptions options;
+ private readonly ILogger logger;
+
+ ///
+ /// 构造一个新的游戏记录提供器
+ ///
+ /// 请求器
+ /// json序列化选项
+ /// 日志器
+ public GameRecordClientOs(HttpClient httpClient, JsonSerializerOptions options, ILogger logger)
+ {
+ this.httpClient = httpClient;
+ this.options = options;
+ this.logger = logger;
+ }
+
+ ///
+ /// 异步获取实时便笺
+ ///
+ /// 用户与角色
+ /// 取消令牌
+ /// 实时便笺
+ [ApiInformation(Cookie = CookieType.Cookie, Salt = SaltType.OS)]
+ public async Task> GetDailyNoteAsync(UserAndUid userAndUid, CancellationToken token = default)
+ {
+ Response? resp = await httpClient
+ .SetUser(userAndUid.User, CookieType.Cookie)
+ .UseDynamicSecret(DynamicSecretVersion.Gen1, SaltType.OS, false)
+ .TryCatchGetFromJsonAsync>(ApiOsEndpoints.GameRecordDailyNote(userAndUid.Uid.Value), options, logger, token)
+ .ConfigureAwait(false);
+
+ return Response.Response.DefaultIfNull(resp);
+ }
+
+ ///
+ /// 获取玩家基础信息
+ ///
+ /// 用户与角色
+ /// 取消令牌
+ /// 玩家的基础信息
+ [ApiInformation(Cookie = CookieType.LToken, Salt = SaltType.OS)]
+ public async Task> GetPlayerInfoAsync(UserAndUid userAndUid, CancellationToken token = default)
+ {
+ Response? resp = await httpClient
+ .SetUser(userAndUid.User, CookieType.Cookie)
+ .UseDynamicSecret(DynamicSecretVersion.Gen1, SaltType.OS, false)
+ .TryCatchGetFromJsonAsync>(ApiOsEndpoints.GameRecordIndex(userAndUid.Uid), options, logger, token)
+ .ConfigureAwait(false);
+
+ return Response.Response.DefaultIfNull(resp);
+ }
+
+ ///
+ /// 获取玩家深渊信息
+ ///
+ /// 用户
+ /// 1:当期,2:上期
+ /// 取消令牌
+ /// 深渊信息
+ [ApiInformation(Cookie = CookieType.Cookie, Salt = SaltType.OS)]
+ public async Task> GetSpiralAbyssAsync(UserAndUid userAndUid, SpiralAbyssSchedule schedule, CancellationToken token = default)
+ {
+ Response? resp = await httpClient
+ .SetUser(userAndUid.User, CookieType.Cookie)
+ .UseDynamicSecret(DynamicSecretVersion.Gen1, SaltType.OS, false)
+ .TryCatchGetFromJsonAsync>(ApiOsEndpoints.GameRecordSpiralAbyss(schedule, userAndUid.Uid), options, logger, token)
+ .ConfigureAwait(false);
+
+ return Response.Response.DefaultIfNull(resp);
+ }
+
+ ///
+ /// 获取玩家角色详细信息
+ ///
+ /// 用户与角色
+ /// 玩家的基础信息
+ /// 取消令牌
+ /// 角色列表
+ [ApiInformation(Cookie = CookieType.LToken, Salt = SaltType.X4)]
+ public async Task> GetCharactersAsync(UserAndUid userAndUid, PlayerInfo playerInfo, CancellationToken token = default)
+ {
+ CharacterData data = new(userAndUid.Uid, playerInfo.Avatars.Select(x => x.Id));
+
+ Response? resp = await httpClient
+ .SetUser(userAndUid.User, CookieType.Cookie)
+ .UseDynamicSecret(DynamicSecretVersion.Gen1, SaltType.OS, false)
+ .TryCatchPostAsJsonAsync>(ApiOsEndpoints.GameRecordCharacter, data, options, logger, token)
+ .ConfigureAwait(false);
+
+ return Response.Response.DefaultIfNull(resp);
+ }
+
+ private class CharacterData
+ {
+ public CharacterData(PlayerUid uid, IEnumerable characterIds)
+ {
+ CharacterIds = characterIds;
+ Uid = uid.Value;
+ Server = uid.Region;
+ }
+
+ [JsonPropertyName("character_ids")]
+ public IEnumerable CharacterIds { get; }
+
+ [JsonPropertyName("role_id")]
+ public string Uid { get; }
+
+ [JsonPropertyName("server")]
+ public string Server { get; }
+ }
+}