From d85811ee99fbbfd96f3048c4ffd30d00e23fdffe Mon Sep 17 00:00:00 2001 From: xhichn Date: Sun, 12 Mar 2023 23:20:09 +0800 Subject: [PATCH 1/8] Add some hoyolab endpoints --- .../Snap.Hutao/Web/ApiOsEndpoints.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/Snap.Hutao/Snap.Hutao/Web/ApiOsEndpoints.cs b/src/Snap.Hutao/Snap.Hutao/Web/ApiOsEndpoints.cs index 6743bd2d..4dc726ac 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/ApiOsEndpoints.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/ApiOsEndpoints.cs @@ -1,7 +1,9 @@ // Copyright (c) DGP Studio. All rights reserved. // Licensed under the MIT license. +using Snap.Hutao.Model.Primitive; using Snap.Hutao.Service.Game; +using Snap.Hutao.Web.Hoyolab; namespace Snap.Hutao.Web; @@ -13,6 +15,76 @@ namespace Snap.Hutao.Web; [SuppressMessage("", "SA1124")] internal static class ApiOsEndpoints { + #region ApiOsTaKumiApi + + /// + /// 用户游戏角色 + /// + /// 用户游戏角色字符串 + public const string UserGameRolesByCookie = $"{ApiOsTaKumiBindingApi}/getUserGameRolesByCookie?game_biz=hk4e_global"; + + #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 /// @@ -40,6 +112,12 @@ internal static class ApiOsEndpoints #endregion #region Hosts | Queries + private const string ApiOsTaKumi = "https://api-os-takumi.hoyoverse.com"; + private const string ApiOsTaKumiBindingApi = $"{ApiOsTaKumi}/binding/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"; From 0295d4fc228018a364f212844dda65dfcf08d095 Mon Sep 17 00:00:00 2001 From: xhichn Date: Sun, 12 Mar 2023 23:21:52 +0800 Subject: [PATCH 2/8] Add client config and DS salt for oversea server request --- .../DedendencyInjection/HttpClientGenerator.cs | 4 ++++ src/Snap.Hutao/Snap.Hutao/Core/CoreEnvironment.cs | 1 + .../HttpClient/HttpClientConfigration.cs | 5 +++++ .../IocHttpClientConfiguration.cs | 14 ++++++++++++++ .../Web/Hoyolab/DynamicSecret/SaltType.cs | 5 +++++ 5 files changed, 29 insertions(+) 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..1781289c 100644 --- a/src/Snap.Hutao/Snap.Hutao/Core/CoreEnvironment.cs +++ b/src/Snap.Hutao/Snap.Hutao/Core/CoreEnvironment.cs @@ -45,6 +45,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/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 From b89c66fd6bec4ef787dca26bb12588652c9bf28a Mon Sep 17 00:00:00 2001 From: xhichn Date: Sun, 12 Mar 2023 23:25:25 +0800 Subject: [PATCH 3/8] Support adding hoyoverse accounts by cookie input --- .../Snap.Hutao/Model/Binding/User/User.cs | 86 ++++++++++++++++++- .../Snap.Hutao/Model/Entity/User.cs | 15 ++++ .../Snap.Hutao/Service/User/IUserService.cs | 7 ++ .../Snap.Hutao/Service/User/UserService.cs | 55 +++++++++++- src/Snap.Hutao/Snap.Hutao/View/UserView.xaml | 13 +++ .../Snap.Hutao/ViewModel/UserViewModel.cs | 47 ++++++++++ .../Web/Hoyolab/Bbs/User/UserClient.cs | 18 ++++ .../Snap.Hutao/Web/Hoyolab/Cookie.cs | 20 +++++ .../Hoyolab/Takumi/Binding/BindingClient.cs | 19 ++++ 9 files changed, 276 insertions(+), 4 deletions(-) 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 e7427697..274137ec 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,17 @@ 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; + + // TODO: 这里暂时使用是否存在 stoken 来判断是否为国际服,需要改进 + if (user.Entity.Stoken != null) + { + isOk = await user.InitializeCoreAsync(token).ConfigureAwait(false); + } + else + { + isOk = await user.InitializeCoreOsAsync(token).ConfigureAwait(false); + } if (!isOk) { @@ -141,6 +152,34 @@ internal sealed class User : ObservableObject } } + /// + /// 创建并初始化国际服用户(临时) + /// + /// cookie + /// 取消令牌 + /// 用户 + internal static async Task CreateOsUserAsync(Cookie cookie, CancellationToken token = default) + { + // 这里只负责创建实体用户,稍后在用户服务中保存到数据库 + EntityUser entity = EntityUser.CreateOs(cookie); + + // 临时使用 ltuid 代替 aid 与 mid + entity.Aid = cookie.GetValueOrDefault(Cookie.LTUID); + entity.Mid = cookie.GetValueOrDefault(Cookie.LTUID); + + if (entity.Aid != null && entity.Mid != 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) @@ -234,4 +273,49 @@ internal sealed class User : ObservableObject return UserInfo != null && UserGameRoles.Any(); } + + private async Task InitializeCoreOsAsync(CancellationToken token = default) + { + if (isInitialized) + { + return true; + } + + using (IServiceScope scope = Ioc.Default.CreateScope()) + { + // 获取账户信息 + Response response = await scope.ServiceProvider + .GetRequiredService() + .GetOsUserFullInfoAsync(Entity, token) + .ConfigureAwait(false); + UserInfo = response.Data?.UserInfo; + + // Ltoken 和 cookieToken 直接从网页或者输入获取 + if (Ltoken == null || CookieToken == null) + { + 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 71d614a1..0451a335 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; @@ -65,4 +66,18 @@ internal sealed class User : ISelectable return new() { Stoken = stoken, Ltoken = ltoken, CookieToken = cookieToken }; } + + /// + /// 创建一个国际服用户 + /// + /// cookie + /// 新创建的用户 + public static User CreateOs(Cookie cookie) + { + // 不需要 Stoken + _ = cookie.TryGetAsLtoken(out Cookie? ltoken); + _ = cookie.TryGetAsOsCookieToken(out Cookie? cookieToken); + + return new() { Ltoken = ltoken, CookieToken = cookieToken }; + } } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/User/IUserService.cs b/src/Snap.Hutao/Snap.Hutao/Service/User/IUserService.cs index 8546d4a8..e090d37b 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 + /// 处理的结果 + 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 8d2ebc24..346daac3 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/User/UserService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/User/UserService.cs @@ -225,7 +225,46 @@ 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? ltuid = cookie.GetValueOrDefault(Cookie.LTUID); + + if (ltuid == null) + { + return new(UserOptionResult.Invalid, SH.ServiceUserProcessCookieNoMid); + } + + // 检查 ltuid 对应用户是否存在 + if (TryGetUser(userCollection!, ltuid, out BindingUser? user)) + { + using (IServiceScope scope = scopeFactory.CreateScope()) + { + AppDbContext appDbContext = scope.ServiceProvider.GetRequiredService(); + + if (cookie.TryGetAsLtoken(out Cookie? ltoken)) + { + user.Stoken = null; + user.Ltoken = user.Ltoken; + user.CookieToken = cookie.TryGetAsOsCookieToken(out Cookie? cookieToken) ? cookieToken : user.CookieToken; + + await appDbContext.Users.UpdateAndSaveAsync(user.Entity).ConfigureAwait(false); + return new(UserOptionResult.Updated, ltuid); + } + else + { + return new(UserOptionResult.Invalid, SH.ServiceUserProcessCookieNoStoken); + } + } + } + else + { + return await TryCreateUserAndAddAsync(cookie, true).ConfigureAwait(false); } } @@ -265,14 +304,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/UserView.xaml b/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml index 8bd5f811..69f3a20a 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml @@ -225,6 +225,19 @@ Icon="{shcm:FontIcon Glyph=}" Label="{shcm:ResourceString Name=ViewUserCookieOperationManualInputAction}"/> + + + + diff --git a/src/Snap.Hutao/Snap.Hutao/ViewModel/UserViewModel.cs b/src/Snap.Hutao/Snap.Hutao/ViewModel/UserViewModel.cs index 1d8164d1..8cb5eb2d 100644 --- a/src/Snap.Hutao/Snap.Hutao/ViewModel/UserViewModel.cs +++ b/src/Snap.Hutao/Snap.Hutao/ViewModel/UserViewModel.cs @@ -46,6 +46,7 @@ internal sealed class UserViewModel : ObservableObject OpenUICommand = new AsyncRelayCommand(OpenUIAsync); AddUserCommand = new AsyncRelayCommand(AddUserAsync); + AddOsUserCommand = new AsyncRelayCommand(AddOsUserAsync); LoginMihoyoUserCommand = new RelayCommand(LoginMihoyoUser); RemoveUserCommand = new AsyncRelayCommand(RemoveUserAsync); CopyCookieCommand = new RelayCommand(CopyCookie); @@ -87,6 +88,11 @@ internal sealed class UserViewModel : ObservableObject /// public ICommand AddUserCommand { get; } + /// + /// 添加国际服用户命令 + /// + public ICommand AddOsUserCommand { get; } + /// /// 登录米游社命令 /// @@ -161,6 +167,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) 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 8c9f8e06..f09ecbbd 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); } + + /// + /// 获取当前用户详细信息 + /// + /// 用户 + /// 取消令牌 + /// 详细信息 + [ApiInformation(Cookie = CookieType.Stoken, Salt = SaltType.K2)] + public async Task> GetOsUserFullInfoAsync(Model.Entity.User user, CancellationToken token = default) + { + Response? resp = await httpClient + .SetUser(user, CookieType.Cookie) + .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..0fd7ec68 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Cookie.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Cookie.cs @@ -156,6 +156,26 @@ internal sealed partial class Cookie return false; } + public bool TryGetAsOsCookieToken([NotNullWhen(true)] out Cookie? cookie) + { + bool hasLtUid = TryGetValue(LTUID, out string? ltUid); + bool hasCookieToken = TryGetValue(COOKIE_TOKEN, out string? cookieToken); + + if (hasLtUid && hasCookieToken) + { + cookie = new Cookie(new() + { + [LTUID] = ltUid!, + [COOKIE_TOKEN] = cookieToken!, + }); + + return true; + } + + cookie = null; + return false; + } + /// public bool TryGetValue(string key, [NotNullWhen(true)] out string? value) { 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 e41972a0..01217854 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); + } } From 22f4f411ea3d20f827d89a80f8a3bb18c145aa20 Mon Sep 17 00:00:00 2001 From: xhichn Date: Sun, 12 Mar 2023 23:26:00 +0800 Subject: [PATCH 4/8] Support spiral abyss info request for global server players --- .../SpiralAbyss/SpiralAbyssRecordService.cs | 18 +++++- .../Takumi/GameRecord/GameRecordClientOs.cs | 58 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs 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/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs new file mode 100644 index 00000000..375461af --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs @@ -0,0 +1,58 @@ +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; + } + + /// + /// 获取玩家深渊信息 + /// + /// 用户 + /// 1:当期,2:上期 + /// 取消令牌 + /// 深渊信息 + [ApiInformation(Cookie = CookieType.Cookie, Salt = SaltType.OS)] + public async Task> GetSpiralAbyssAsync(UserAndUid userAndUid, SpiralAbyssSchedule schedule, CancellationToken token = default) + { + System.Net.Http.Headers.HttpRequestHeaders headers = httpClient.DefaultRequestHeaders; + 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); + } +} From 388cdf1848be5bc2018bf6ee12054bc22783d133 Mon Sep 17 00:00:00 2001 From: Xhichn Date: Mon, 13 Mar 2023 11:41:18 +0800 Subject: [PATCH 5/8] Support sync from hoyolab my characters --- .../AvatarInfo/AvatarInfoDbOperation.cs | 88 ++++++++++++------- .../Takumi/GameRecord/GameRecordClientOs.cs | 59 ++++++++++++- 2 files changed, 114 insertions(+), 33 deletions(-) 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/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs index 375461af..80bfddcf 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs @@ -36,6 +36,24 @@ internal sealed class GameRecordClientOs this.logger = logger; } + /// + /// 获取玩家基础信息 + /// + /// 用户与角色 + /// 取消令牌 + /// 玩家的基础信息 + [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); + } + /// /// 获取玩家深渊信息 /// @@ -46,7 +64,6 @@ internal sealed class GameRecordClientOs [ApiInformation(Cookie = CookieType.Cookie, Salt = SaltType.OS)] public async Task> GetSpiralAbyssAsync(UserAndUid userAndUid, SpiralAbyssSchedule schedule, CancellationToken token = default) { - System.Net.Http.Headers.HttpRequestHeaders headers = httpClient.DefaultRequestHeaders; Response? resp = await httpClient .SetUser(userAndUid.User, CookieType.Cookie) .UseDynamicSecret(DynamicSecretVersion.Gen1, SaltType.OS, false) @@ -55,4 +72,44 @@ internal sealed class GameRecordClientOs 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; } + } } From b0b3553d0cc0ae70c5c7e969b27692c53d372982 Mon Sep 17 00:00:00 2001 From: Xhichn Date: Mon, 13 Mar 2023 12:16:38 +0800 Subject: [PATCH 6/8] Support daily notes for global server player --- .../Service/DailyNote/DailyNoteService.cs | 31 +++++++++++++++++-- .../Takumi/GameRecord/GameRecordClientOs.cs | 18 +++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) 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/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs index 80bfddcf..63b25753 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/GameRecord/GameRecordClientOs.cs @@ -36,6 +36,24 @@ internal sealed class GameRecordClientOs 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); + } + /// /// 获取玩家基础信息 /// From fdf2311f0ccf63bd413b66b3c1b0f09d7b31ac5d Mon Sep 17 00:00:00 2001 From: Xhichn Date: Mon, 13 Mar 2023 12:36:38 +0800 Subject: [PATCH 7/8] Support website login for adding hoyoverse account --- .../Snap.Hutao/Service/User/IUserService.cs | 4 +- .../View/Page/LoginHoyoverseUserPage.xaml | 31 ++++++ .../View/Page/LoginHoyoverseUserPage.xaml.cs | 98 +++++++++++++++++++ src/Snap.Hutao/Snap.Hutao/View/UserView.xaml | 4 + .../Snap.Hutao/ViewModel/UserViewModel.cs | 21 ++++ 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/Snap.Hutao/Snap.Hutao/View/Page/LoginHoyoverseUserPage.xaml create mode 100644 src/Snap.Hutao/Snap.Hutao/View/Page/LoginHoyoverseUserPage.xaml.cs diff --git a/src/Snap.Hutao/Snap.Hutao/Service/User/IUserService.cs b/src/Snap.Hutao/Snap.Hutao/Service/User/IUserService.cs index e090d37b..e24c9d5a 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/User/IUserService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/User/IUserService.cs @@ -49,9 +49,9 @@ internal interface IUserService Task> ProcessInputCookieAsync(Cookie cookie); /// - /// 尝试异步处理输入的国际服 Cookie + /// 尝试异步处理国际服 Cookie /// - /// Cookie + /// 来自网页 www.hoyolab.com 的 Cookie,需包含 ltuid, ltoken 和 cookie_token 字段 /// 处理的结果 Task> ProcessInputOsCookieAsync(Cookie cookie); 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..a6031a73 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/View/Page/LoginHoyoverseUserPage.xaml @@ -0,0 +1,31 @@ + + + + + + + + +