Merge branch 'Globalization'

This commit is contained in:
Xhichn
2023-03-13 21:26:18 +08:00
20 changed files with 751 additions and 40 deletions

View File

@@ -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}]");
}

View File

@@ -45,6 +45,7 @@ internal static class CoreEnvironment
[SaltType.X4] = "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs",
[SaltType.X6] = "t0qEgfub6cvueAPgR5m9aQWWVciEer7v",
[SaltType.PROD] = "JwYDpKvLj6MrMqqYU6jTKF17KNO2PXoS",
[SaltType.OS] = "6cqshh5dhw73bzxn20oexa9k516chk7s",
}.ToImmutableDictionary();
/// <summary>

View File

@@ -23,4 +23,9 @@ internal enum HttpClientConfigration
/// 米游社登录请求配置
/// </summary>
XRpc2,
/// <summary>
/// 国际服Hoyolab请求配置
/// </summary>
XRpc3,
}

View File

@@ -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");
}
/// <summary>
/// 对于需要添加动态密钥的客户端使用此配置
/// 国际服 API 测试
/// </summary>
/// <param name="client">配置后的客户端</param>
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");
}
}

View File

@@ -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<User> 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
}
}
/// <summary>
/// 创建并初始化国际服用户(临时)
/// </summary>
/// <param name="cookie">cookie</param>
/// <param name="token">取消令牌</param>
/// <returns>用户</returns>
internal static async Task<User?> 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<bool> InitializeCoreAsync(CancellationToken token = default)
{
if (isInitialized)
@@ -234,4 +273,49 @@ internal sealed class User : ObservableObject
return UserInfo != null && UserGameRoles.Any();
}
private async Task<bool> InitializeCoreOsAsync(CancellationToken token = default)
{
if (isInitialized)
{
return true;
}
using (IServiceScope scope = Ioc.Default.CreateScope())
{
// 获取账户信息
Response<UserFullInfoWrapper> response = await scope.ServiceProvider
.GetRequiredService<UserClient>()
.GetOsUserFullInfoAsync(Entity, token)
.ConfigureAwait(false);
UserInfo = response.Data?.UserInfo;
// Ltoken 和 cookieToken 直接从网页或者输入获取
if (Ltoken == null || CookieToken == null)
{
return false;
}
// 获取游戏角色
Response<ListWrapper<UserGameRole>> userGameRolesResponse = await scope.ServiceProvider
.GetRequiredService<BindingClient>()
.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();
}
}

View File

@@ -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 };
}
/// <summary>
/// 创建一个国际服用户
/// </summary>
/// <param name="cookie">cookie</param>
/// <returns>新创建的用户</returns>
public static User CreateOs(Cookie cookie)
{
// 不需要 Stoken
_ = cookie.TryGetAsLtoken(out Cookie? ltoken);
_ = cookie.TryGetAsCookieToken(out Cookie? cookieToken);
return new() { Ltoken = ltoken, CookieToken = cookieToken };
}
}

View File

@@ -90,48 +90,72 @@ internal sealed class AvatarInfoDbOperation
.ToList();
EnsureItemsAvatarIdDistinct(ref dbInfos, uid);
GameRecordClient gameRecordClient = Ioc.Default.GetRequiredService<GameRecordClient>();
Response<RecordPlayerInfo> playerInfoResponse = await gameRecordClient
.GetPlayerInfoAsync(userAndUid, token)
.ConfigureAwait(false);
Response<RecordPlayerInfo> playerInfoResponse;
Response<Web.Hoyolab.Takumi.GameRecord.Avatar.CharacterWrapper> charactersResponse;
if (userAndUid.Uid.Region == "cn_gf01" || userAndUid.Uid.Region == "cn_qd01")
{
GameRecordClient gameRecordClient = Ioc.Default.GetRequiredService<GameRecordClient>();
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<GameRecordClientOs>();
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<Web.Hoyolab.Takumi.GameRecord.Avatar.CharacterWrapper> charactersResponse = await gameRecordClient
.GetCharactersAsync(userAndUid, playerInfoResponse.Data, token)
.ConfigureAwait(false);
List<RecordCharacter> characters = charactersResponse.Data.Avatars;
if (charactersResponse.IsOk())
GameRecordCharacterAvatarInfoComposer composer = Ioc.Default.GetRequiredService<GameRecordCharacterAvatarInfoComposer>();
foreach (RecordCharacter character in characters)
{
List<RecordCharacter> characters = charactersResponse.Data.Avatars;
GameRecordCharacterAvatarInfoComposer composer = Ioc.Default.GetRequiredService<GameRecordCharacterAvatarInfoComposer>();
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);
}
}
}

View File

@@ -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<UserRemov
{
AppDbContext appDbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
GameRecordClient gameRecordClient = scope.ServiceProvider.GetRequiredService<GameRecordClient>();
GameRecordClientOs gameRecordClientOs = scope.ServiceProvider.GetRequiredService<GameRecordClientOs>();
if (!appDbContext.DailyNotes.Any(n => n.Uid == roleUid))
{
DailyNoteEntry newEntry = DailyNoteEntry.Create(role);
Web.Response.Response<WebDailyNote> dailyNoteResponse = await gameRecordClient
// 根据 Uid 的地区选择不同的 API
Web.Response.Response<WebDailyNote> 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<UserRemov
{
AppDbContext appDbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
GameRecordClient gameRecordClient = scope.ServiceProvider.GetRequiredService<GameRecordClient>();
GameRecordClientOs gameRecordClientOs = scope.ServiceProvider.GetRequiredService<GameRecordClientOs>();
bool isSilentMode = appDbContext.Settings
.SingleOrAdd(SettingEntry.DailyNoteSilentWhenPlayingGame, Core.StringLiterals.False)
.GetBoolean();
bool isGameRunning = scope.ServiceProvider.GetRequiredService<IGameService>().IsGameRunning();
if (isSilentMode && isGameRunning)
{
// Prevent notify when we are in game && silent mode.
@@ -123,9 +139,20 @@ internal sealed class DailyNoteService : IDailyNoteService, IRecipient<UserRemov
foreach (DailyNoteEntry entry in appDbContext.DailyNotes.Include(n => n.User))
{
Web.Response.Response<WebDailyNote> dailyNoteResponse = await gameRecordClient
Web.Response.Response<WebDailyNote> 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)
{

View File

@@ -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<SpiralAbyssEntry>? spiralAbysses;
@@ -30,10 +31,11 @@ internal class SpiralAbyssRecordService : ISpiralAbyssRecordService
/// </summary>
/// <param name="appDbContext">数据库上下文</param>
/// <param name="gameRecordClient">游戏记录客户端</param>
public SpiralAbyssRecordService(AppDbContext appDbContext, GameRecordClient gameRecordClient)
public SpiralAbyssRecordService(AppDbContext appDbContext, GameRecordClient gameRecordClient, GameRecordClientOs gameRecordClientOs)
{
this.appDbContext = appDbContext;
this.gameRecordClient = gameRecordClient;
this.gameRecordClientOs = gameRecordClientOs;
}
/// <inheritdoc/>
@@ -70,9 +72,21 @@ internal class SpiralAbyssRecordService : ISpiralAbyssRecordService
private async Task RefreshSpiralAbyssCoreAsync(UserAndUid userAndUid, SpiralAbyssSchedule schedule)
{
Response<Web.Hoyolab.Takumi.GameRecord.SpiralAbyss.SpiralAbyss> response = await gameRecordClient
Response<Web.Hoyolab.Takumi.GameRecord.SpiralAbyss.SpiralAbyss> 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())
{

View File

@@ -48,6 +48,13 @@ internal interface IUserService
/// <returns>处理的结果</returns>
Task<ValueResult<UserOptionResult, string>> ProcessInputCookieAsync(Cookie cookie);
/// <summary>
/// 尝试异步处理国际服 Cookie
/// </summary>
/// <param name="cookie">来自网页 www.hoyolab.com 的 Cookie需包含 ltuid, ltoken 和 cookie_token 字段</param>
/// <returns>处理的结果</returns>
Task<ValueResult<UserOptionResult, string>> ProcessInputOsCookieAsync(Cookie cookie);
/// <summary>
/// 异步刷新 Cookie 的 CookieToken
/// </summary>

View File

@@ -225,7 +225,46 @@ internal class UserService : IUserService
}
else
{
return await TryCreateUserAndAddAsync(cookie).ConfigureAwait(false);
return await TryCreateUserAndAddAsync(cookie, false).ConfigureAwait(false);
}
}
/// <inheritdoc/>
public async Task<ValueResult<UserOptionResult, string>> 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<AppDbContext>();
if (cookie.TryGetAsLtoken(out Cookie? ltoken))
{
user.Stoken = null;
user.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, 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<ValueResult<UserOptionResult, string>> TryCreateUserAndAddAsync(Cookie cookie)
private async Task<ValueResult<UserOptionResult, string>> TryCreateUserAndAddAsync(Cookie cookie, bool isOversea)
{
await ThreadHelper.SwitchToBackgroundAsync();
using (IServiceScope scope = scopeFactory.CreateScope())
{
AppDbContext appDbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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

View File

@@ -0,0 +1,31 @@
<Page
x:Class="Snap.Hutao.View.Page.LoginHoyoverseUserPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:shcm="using:Snap.Hutao.Control.Markup"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
mc:Ignorable="d">
<Grid Loaded="OnRootLoaded">
<Grid.RowDefinitions>
<RowDefinition Height="auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock
Grid.Row="0"
Margin="12,0,0,0"
VerticalAlignment="Center"
Text="{shcm:ResourceString Name=ViewPageLoginMihoyoUserTitle}"/>
<Button
Margin="16"
HorizontalAlignment="Right"
Click="CookieButtonClick"
Content="{shcm:ResourceString Name=ViewPageLoginMihoyoUserLoggedInAction}"/>
<WebView2
x:Name="WebView"
Grid.Row="2"
Margin="0,0,0,0"/>
</Grid>
</Page>

View File

@@ -0,0 +1,98 @@
// 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.Web.Hoyolab;
using Snap.Hutao.Web.Hoyolab.Passport;
using Snap.Hutao.Web.Hoyolab.Takumi.Auth;
using Snap.Hutao.Web.Response;
namespace Snap.Hutao.View.Page;
/// <summary>
/// <20><>¼<EFBFBD>׹<EFBFBD><D7B9><EFBFBD>ͨ<EFBFBD><CDA8>֤ҳ<D6A4><D2B3>
/// </summary>
[HighQuality]
internal sealed partial class LoginHoyoverseUserPage : Microsoft.UI.Xaml.Controls.Page
{
/// <summary>
/// <20><><EFBFBD><EFBFBD>һ<EFBFBD><D2BB><EFBFBD>µĵ<C2B5>¼<EFBFBD>׹<EFBFBD><D7B9><EFBFBD>ͨ<EFBFBD><CDA8>֤ҳ<D6A4><D2B3>
/// </summary>
public LoginHoyoverseUserPage()
{
InitializeComponent();
}
[SuppressMessage("", "VSTHRD100")]
private async void OnRootLoaded(object sender, RoutedEventArgs e)
{
try
{
await WebView.EnsureCoreWebView2Async();
CoreWebView2CookieManager manager = WebView.CoreWebView2.CookieManager;
IReadOnlyList<CoreWebView2Cookie> cookies = await manager.GetCookiesAsync("https://www.hoyolab.com");
foreach (CoreWebView2Cookie item in cookies)
{
manager.DeleteCookie(item);
}
WebView.CoreWebView2.Navigate("https://www.hoyolab.com");
}
catch (Exception ex)
{
Ioc.Default.GetRequiredService<IInfoBarService>().Error(ex);
}
}
private async Task HandleCurrentCookieAsync(CancellationToken token)
{
CoreWebView2CookieManager manager = WebView.CoreWebView2.CookieManager;
IReadOnlyList<CoreWebView2Cookie> cookies = await manager.GetCookiesAsync("https://www.hoyolab.com");
Cookie hoyoLabCookie = Cookie.FromCoreWebView2Cookies(cookies);
// <20><><EFBFBD><EFBFBD> cookie <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>û<EFBFBD>
(UserOptionResult result, string nickname) = await Ioc.Default
.GetRequiredService<IUserService>()
.ProcessInputOsCookieAsync(hoyoLabCookie)
.ConfigureAwait(false);
Ioc.Default.GetRequiredService<INavigationService>().GoBack();
IInfoBarService infoBarService = Ioc.Default.GetRequiredService<IInfoBarService>();
switch (result)
{
case UserOptionResult.Added:
ViewModel.UserViewModel vm = Ioc.Default.GetRequiredService<ViewModel.UserViewModel>();
if (vm.Users!.Count == 1)
{
await ThreadHelper.SwitchToMainThreadAsync();
vm.SelectedUser = vm.Users.Single();
}
infoBarService.Success($"<22>û<EFBFBD> [{nickname}] <20><><EFBFBD>ӳɹ<D3B3>");
break;
case UserOptionResult.Incomplete:
infoBarService.Information($"<22><> Cookie <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʧ<EFBFBD><CAA7>");
break;
case UserOptionResult.Invalid:
infoBarService.Information($"<22><> Cookie <20><>Ч<EFBFBD><D0A7><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʧ<EFBFBD><CAA7>");
break;
case UserOptionResult.Updated:
infoBarService.Success($"<22>û<EFBFBD> [{nickname}] <20><><EFBFBD>³ɹ<C2B3>");
break;
default:
throw Must.NeverHappen();
}
}
private void CookieButtonClick(object sender, RoutedEventArgs e)
{
HandleCurrentCookieAsync(CancellationToken.None).SafeForget();
}
}

View File

@@ -225,6 +225,23 @@
Icon="{shcm:FontIcon Glyph=&#xE710;}"
Label="{shcm:ResourceString Name=ViewUserCookieOperationManualInputAction}"/>
</StackPanel>
<TextBlock
Margin="10,6,0,6"
Style="{StaticResource BaseTextBlockStyle}"
Text="HoyoVerse Account"/>
<StackPanel
Margin="0,0,6,0"
HorizontalAlignment="Right"
Orientation="Horizontal">
<AppBarButton
Command="{Binding LoginHoyoverseUserCommand}"
Icon="{shcm:FontIcon Glyph=&#xEB41;}"
Label="{shcm:ResourceString Name=ViewUserCookieOperationLoginMihoyoUserAction}"/>
<AppBarButton
Command="{Binding AddOsUserCommand}"
Icon="{shcm:FontIcon Glyph=&#xE710;}"
Label="{shcm:ResourceString Name=ViewUserCookieOperationManualInputAction}"/>
</StackPanel>
</StackPanel>
</Flyout>
</Button.Flyout>

View File

@@ -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<User>(RemoveUserAsync);
CopyCookieCommand = new RelayCommand<User>(CopyCookie);
RefreshCookieTokenCommand = new AsyncRelayCommand(RefreshCookieTokenAsync);
@@ -87,11 +89,21 @@ internal sealed class UserViewModel : ObservableObject
/// </summary>
public ICommand AddUserCommand { get; }
/// <summary>
/// 添加国际服用户命令
/// </summary>
public ICommand AddOsUserCommand { get; }
/// <summary>
/// 登录米游社命令
/// </summary>
public ICommand LoginMihoyoUserCommand { get; }
/// <summary>
/// 登录米游社命令
/// </summary>
public ICommand LoginHoyoverseUserCommand { get; }
/// <summary>
/// 移除用户命令
/// </summary>
@@ -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<bool, string> 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
}
}
/// <summary>
/// 打开浏览器登录 hoyolab 以获取 cookie
/// </summary>
private void LoginHoyoverseUser()
{
if (Core.WebView2Helper.IsSupported)
{
serviceProvider.GetRequiredService<INavigationService>().Navigate<LoginHoyoverseUserPage>(INavigationAwaiter.Default);
}
else
{
infoBarService.Warning(SH.CoreWebView2HelperVersionUndetected);
}
}
private async Task RemoveUserAsync(User? user)
{
if (user != null)

View File

@@ -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
/// <summary>
/// 用户游戏角色
/// </summary>
/// <returns>用户游戏角色字符串</returns>
public const string UserGameRolesByCookie = $"{ApiOsTaKumiBindingApi}/getUserGameRolesByCookie?game_biz=hk4e_global";
#endregion
#region BbsApiOsApi
/// <summary>
/// 查询其他用户详细信息
/// </summary>
/// <param name="bbsUid">bbs Uid</param>
/// <returns>查询其他用户详细信息字符串</returns>
public static string UserFullInfoQuery(string bbsUid)
{
return $"{BbsApiOs}/community/painter/wapi/user/full";
}
/// <summary>
/// 国际服角色基本信息
/// </summary>
/// <param name="uid">uid</param>
/// <returns>角色基本信息字符串</returns>
public static string GameRecordRoleBasicInfo(PlayerUid uid)
{
return $"{BbsApiOsGameRecordApi}/roleBasicInfo?role_id={uid.Value}&server={uid.Region}";
}
/// <summary>
/// 国际服角色信息
/// </summary>
public const string GameRecordCharacter = $"{BbsApiOsGameRecordApi}/character";
/// <summary>
/// 国际服游戏记录实时便笺
/// </summary>
/// <param name="uid">uid</param>
/// <returns>游戏记录实时便笺字符串</returns>
public static string GameRecordDailyNote(PlayerUid uid)
{
return $"{BbsApiOsGameRecordApi}/dailyNote?server={uid.Region}&role_id={uid.Value}";
}
/// <summary>
/// 国际服游戏记录主页
/// </summary>
/// <param name="uid">uid</param>
/// <returns>游戏记录主页字符串</returns>
public static string GameRecordIndex(PlayerUid uid)
{
return $"{BbsApiOsGameRecordApi}/index?server={uid.Region}&role_id={uid.Value}";
}
/// <summary>
/// 国际服深渊信息
/// </summary>
/// <param name="scheduleType">深渊类型</param>
/// <param name="uid">Uid</param>
/// <returns>深渊信息字符串</returns>
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
/// <summary>
@@ -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";

View File

@@ -52,4 +52,22 @@ internal sealed class UserClient
return Response.Response.DefaultIfNull(resp);
}
/// <summary>
/// 获取当前用户详细信息
/// </summary>
/// <param name="user">用户</param>
/// <param name="token">取消令牌</param>
/// <returns>详细信息</returns>
[ApiInformation(Cookie = CookieType.Stoken, Salt = SaltType.K2)]
public async Task<Response<UserFullInfoWrapper>> GetOsUserFullInfoAsync(Model.Entity.User user, CancellationToken token = default)
{
Response<UserFullInfoWrapper>? resp = await httpClient
.SetUser(user, CookieType.Cookie)
.UseDynamicSecret(DynamicSecretVersion.Gen1, SaltType.OS, false)
.TryCatchGetFromJsonAsync<Response<UserFullInfoWrapper>>(ApiOsEndpoints.UserFullInfoQuery(user.Aid!), options, logger, token)
.ConfigureAwait(false);
return Response.Response.DefaultIfNull(resp);
}
}

View File

@@ -38,4 +38,9 @@ internal enum SaltType
/// LK2
/// </summary>
LK2,
/// <summary>
/// 国际服
/// </summary>
OS,
}

View File

@@ -52,4 +52,23 @@ internal sealed class BindingClient
return Response.Response.DefaultIfNull(resp);
}
/// <summary>
/// 获取国际服用户角色信息
/// </summary>
/// <param name="user">用户</param>
/// <param name="token">取消令牌</param>
/// <returns>用户角色信息</returns>
[ApiInformation(Cookie = CookieType.Ltoken)]
public async Task<Response<ListWrapper<UserGameRole>>> GetOsUserGameRolesByCookieAsync(User user, CancellationToken token = default)
{
string url = ApiOsEndpoints.UserGameRolesByCookie;
Response<ListWrapper<UserGameRole>>? resp = await httpClient
.SetUser(user, CookieType.Ltoken)
.TryCatchGetFromJsonAsync<Response<ListWrapper<UserGameRole>>>(url, options, logger, token)
.ConfigureAwait(false);
return Response.Response.DefaultIfNull(resp);
}
}

View File

@@ -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;
/// <summary>
/// Hoyoverse game record provider
/// </summary>
[HighQuality]
[UseDynamicSecret]
[HttpClient(HttpClientConfigration.XRpc3)]
[PrimaryHttpMessageHandler(UseCookies = false)]
internal sealed class GameRecordClientOs
{
private readonly HttpClient httpClient;
private readonly JsonSerializerOptions options;
private readonly ILogger<GameRecordClient> logger;
/// <summary>
/// 构造一个新的游戏记录提供器
/// </summary>
/// <param name="httpClient">请求器</param>
/// <param name="options">json序列化选项</param>
/// <param name="logger">日志器</param>
public GameRecordClientOs(HttpClient httpClient, JsonSerializerOptions options, ILogger<GameRecordClient> logger)
{
this.httpClient = httpClient;
this.options = options;
this.logger = logger;
}
/// <summary>
/// 异步获取实时便笺
/// </summary>
/// <param name="userAndUid">用户与角色</param>
/// <param name="token">取消令牌</param>
/// <returns>实时便笺</returns>
[ApiInformation(Cookie = CookieType.Cookie, Salt = SaltType.OS)]
public async Task<Response<DailyNote.DailyNote>> GetDailyNoteAsync(UserAndUid userAndUid, CancellationToken token = default)
{
Response<DailyNote.DailyNote>? resp = await httpClient
.SetUser(userAndUid.User, CookieType.Cookie)
.UseDynamicSecret(DynamicSecretVersion.Gen1, SaltType.OS, false)
.TryCatchGetFromJsonAsync<Response<DailyNote.DailyNote>>(ApiOsEndpoints.GameRecordDailyNote(userAndUid.Uid.Value), options, logger, token)
.ConfigureAwait(false);
return Response.Response.DefaultIfNull(resp);
}
/// <summary>
/// 获取玩家基础信息
/// </summary>
/// <param name="userAndUid">用户与角色</param>
/// <param name="token">取消令牌</param>
/// <returns>玩家的基础信息</returns>
[ApiInformation(Cookie = CookieType.Ltoken, Salt = SaltType.OS)]
public async Task<Response<PlayerInfo>> GetPlayerInfoAsync(UserAndUid userAndUid, CancellationToken token = default)
{
Response<PlayerInfo>? resp = await httpClient
.SetUser(userAndUid.User, CookieType.Cookie)
.UseDynamicSecret(DynamicSecretVersion.Gen1, SaltType.OS, false)
.TryCatchGetFromJsonAsync<Response<PlayerInfo>>(ApiOsEndpoints.GameRecordIndex(userAndUid.Uid), options, logger, token)
.ConfigureAwait(false);
return Response.Response.DefaultIfNull(resp);
}
/// <summary>
/// 获取玩家深渊信息
/// </summary>
/// <param name="userAndUid">用户</param>
/// <param name="schedule">1当期2上期</param>
/// <param name="token">取消令牌</param>
/// <returns>深渊信息</returns>
[ApiInformation(Cookie = CookieType.Cookie, Salt = SaltType.OS)]
public async Task<Response<SpiralAbyss.SpiralAbyss>> GetSpiralAbyssAsync(UserAndUid userAndUid, SpiralAbyssSchedule schedule, CancellationToken token = default)
{
Response<SpiralAbyss.SpiralAbyss>? resp = await httpClient
.SetUser(userAndUid.User, CookieType.Cookie)
.UseDynamicSecret(DynamicSecretVersion.Gen1, SaltType.OS, false)
.TryCatchGetFromJsonAsync<Response<SpiralAbyss.SpiralAbyss>>(ApiOsEndpoints.GameRecordSpiralAbyss(schedule, userAndUid.Uid), options, logger, token)
.ConfigureAwait(false);
return Response.Response.DefaultIfNull(resp);
}
/// <summary>
/// 获取玩家角色详细信息
/// </summary>
/// <param name="userAndUid">用户与角色</param>
/// <param name="playerInfo">玩家的基础信息</param>
/// <param name="token">取消令牌</param>
/// <returns>角色列表</returns>
[ApiInformation(Cookie = CookieType.Ltoken, Salt = SaltType.X4)]
public async Task<Response<CharacterWrapper>> GetCharactersAsync(UserAndUid userAndUid, PlayerInfo playerInfo, CancellationToken token = default)
{
CharacterData data = new(userAndUid.Uid, playerInfo.Avatars.Select(x => x.Id));
Response<CharacterWrapper>? resp = await httpClient
.SetUser(userAndUid.User, CookieType.Cookie)
.UseDynamicSecret(DynamicSecretVersion.Gen1, SaltType.OS, false)
.TryCatchPostAsJsonAsync<CharacterData, Response<CharacterWrapper>>(ApiOsEndpoints.GameRecordCharacter, data, options, logger, token)
.ConfigureAwait(false);
return Response.Response.DefaultIfNull(resp);
}
private class CharacterData
{
public CharacterData(PlayerUid uid, IEnumerable<AvatarId> characterIds)
{
CharacterIds = characterIds;
Uid = uid.Value;
Server = uid.Region;
}
[JsonPropertyName("character_ids")]
public IEnumerable<AvatarId> CharacterIds { get; }
[JsonPropertyName("role_id")]
public string Uid { get; }
[JsonPropertyName("server")]
public string Server { get; }
}
}