Stoken upgradable

This commit is contained in:
DismissedLight
2022-09-26 16:55:27 +08:00
parent cb6a9badc0
commit f29bfda4d9
36 changed files with 844 additions and 114 deletions

View File

@@ -15,7 +15,22 @@ public abstract class ValueConverterBase<TFrom, TTo> : IValueConverter
/// <inheritdoc/>
public object? Convert(object value, Type targetType, object parameter, string language)
{
#if DEBUG
try
{
return Convert((TFrom)value);
}
catch (Exception ex)
{
Ioc.Default
.GetRequiredService<ILogger<ValueConverterBase<TFrom, TTo>>>()
.LogError(ex, "值转换器异常");
}
return null;
#else
return Convert((TFrom)value);
#endif
}
/// <inheritdoc/>

View File

@@ -17,7 +17,7 @@ internal static class CoreEnvironment
/// <summary>
/// 动态密钥1的盐
/// </summary>
public const string DynamicSecret1Salt = "n0KjuIrKgLHh08LWSCYP0WXlVXaYvV64";
public const string DynamicSecret1Salt = "Qqx8cyv7kuyD8fTw11SmvXSFHp7iZD29";
/// <summary>
/// 动态密钥2的盐
@@ -32,7 +32,7 @@ internal static class CoreEnvironment
/// <summary>
/// 米游社 Rpc 版本
/// </summary>
public const string HoyolabXrpcVersion = "2.36.1";
public const string HoyolabXrpcVersion = "2.37.1";
/// <summary>
/// 标准UA

View File

@@ -107,7 +107,7 @@ public static partial class EnumerableExtensions
/// <param name="key">键</param>
/// <param name="defaultValue">默认值</param>
/// <returns>结果值</returns>
public static TValue GetValueOrDefault<TKey, TValue>(this IDictionary<TKey, TValue> dictionary, TKey key, TValue defaultValue = default!)
public static TValue? GetValueOrDefault<TKey, TValue>(this IDictionary<TKey, TValue> dictionary, TKey key, TValue? defaultValue = default)
where TKey : notnull
{
if (dictionary.TryGetValue(key, out TValue? value))

View File

@@ -2,8 +2,11 @@
// Licensed under the MIT license.
using Snap.Hutao.Extension;
using Snap.Hutao.Web.Hoyolab;
using Snap.Hutao.Web.Hoyolab.Bbs.User;
using Snap.Hutao.Web.Hoyolab.Takumi.Auth;
using Snap.Hutao.Web.Hoyolab.Takumi.Binding;
using EntityUser = Snap.Hutao.Model.Entity.User;
namespace Snap.Hutao.Model.Binding;
@@ -12,7 +15,7 @@ namespace Snap.Hutao.Model.Binding;
/// </summary>
public class User : Observable
{
private readonly Entity.User inner;
private readonly EntityUser inner;
private UserGameRole? selectedUserGameRole;
private bool isInitialized;
@@ -21,7 +24,7 @@ public class User : Observable
/// 构造一个新的绑定视图用户
/// </summary>
/// <param name="user">用户实体</param>
private User(Entity.User user)
private User(EntityUser user)
{
inner = user;
}
@@ -45,14 +48,14 @@ public class User : Observable
private set => Set(ref selectedUserGameRole, value);
}
/// <inheritdoc cref="Entity.User.IsSelected"/>
/// <inheritdoc cref="EntityUser.IsSelected"/>
public bool IsSelected
{
get => inner.IsSelected;
set => inner.IsSelected = value;
}
/// <inheritdoc cref="Entity.User.Cookie"/>
/// <inheritdoc cref="EntityUser.Cookie"/>
public string? Cookie
{
get => inner.Cookie;
@@ -62,7 +65,7 @@ public class User : Observable
/// <summary>
/// 内部的用户实体
/// </summary>
public Entity.User Entity { get => inner; }
public EntityUser Entity { get => inner; }
/// <summary>
/// 是否初始化完成
@@ -70,27 +73,172 @@ public class User : Observable
public bool IsInitialized { get => isInitialized; }
/// <summary>
/// 初始化用户
/// 将cookie的字符串形式转换为字典
/// </summary>
/// <param name="inner">用户实体</param>
/// <param name="cookie">cookie的字符串形式</param>
/// <returns>包含cookie信息的字典</returns>
public static IDictionary<string, string> ParseCookie(string cookie)
{
SortedDictionary<string, string> cookieDictionary = new();
string[] values = cookie.TrimEnd(';').Split(';');
foreach (string[] parts in values.Select(c => c.Split('=', 2)))
{
string cookieName = parts[0].Trim();
string cookieValue = parts.Length == 1 ? string.Empty : parts[1].Trim();
cookieDictionary.Add(cookieName, cookieValue);
}
return cookieDictionary;
}
/// <summary>
/// 从数据库恢复用户
/// </summary>
/// <param name="inner">数据库实体</param>
/// <param name="userClient">用户客户端</param>
/// <param name="userGameRoleClient">角色客户端</param>
/// <param name="token">取消令牌</param>
/// <returns>用户是否初始化完成若Cookie失效会返回 <see langword="false"/> </returns>
internal static async Task<User?> CreateAsync(Entity.User inner, UserClient userClient, UserGameRoleClient userGameRoleClient, CancellationToken token = default)
internal static async Task<User?> ResumeAsync(
EntityUser inner,
UserClient userClient,
BindingClient userGameRoleClient,
CancellationToken token = default)
{
User user = new(inner);
bool successful = await user.InitializeAsync(userClient, userGameRoleClient, token).ConfigureAwait(false);
bool successful = await user.ResumeInternalAsync(userClient, userGameRoleClient, token).ConfigureAwait(false);
return successful ? user : null;
}
private async Task<bool> InitializeAsync(UserClient userClient, UserGameRoleClient userGameRoleClient, CancellationToken token = default)
/// <summary>
/// 初始化用户
/// </summary>
/// <param name="cookie">cookie</param>
/// <param name="userClient">用户客户端</param>
/// <param name="userGameRoleClient">角色客户端</param>
/// <param name="authClient">授权客户端</param>
/// <param name="token">取消令牌</param>
/// <returns>用户是否初始化完成若Cookie失效会返回 <see langword="false"/> </returns>
internal static async Task<User?> CreateAsync(
IDictionary<string, string> cookie,
UserClient userClient,
BindingClient userGameRoleClient,
AuthClient authClient,
CancellationToken token = default)
{
string simplifiedCookie = ToCookieString(cookie);
EntityUser inner = EntityUser.Create(simplifiedCookie);
User user = new(inner);
bool successful = await user.CreateInternalAsync(cookie, userClient, userGameRoleClient, authClient, token).ConfigureAwait(false);
return successful ? user : null;
}
/// <summary>
/// 尝试升级到Stoken
/// </summary>
/// <param name="addition">额外的token</param>
/// <param name="authClient">验证客户端</param>
/// <param name="token">取消令牌</param>
/// <returns>是否升级成功</returns>
internal async Task<bool> TryUpgradeAsync(IDictionary<string, string> addition, AuthClient authClient, CancellationToken token)
{
IDictionary<string, string> cookie = ParseCookie(Cookie!);
if (addition.TryGetValue(CookieKeys.LOGIN_TICKET, out string? loginTicket))
{
cookie[CookieKeys.LOGIN_TICKET] = loginTicket;
}
if (addition.TryGetValue(CookieKeys.LOGIN_UID, out string? loginUid))
{
cookie[CookieKeys.LOGIN_UID] = loginUid;
}
bool result = await TryAddStokenToCookieAsync(cookie, authClient, token).ConfigureAwait(false);
if (result)
{
Cookie = ToCookieString(cookie);
}
return result;
}
private static string ToCookieString(IDictionary<string, string> cookie)
{
return string.Join(';', cookie.Select(kvp => $"{kvp.Key}={kvp.Value}"));
}
private static async Task<bool> TryAddStokenToCookieAsync(IDictionary<string, string> cookie, AuthClient authClient, CancellationToken token)
{
if (cookie.TryGetValue(CookieKeys.LOGIN_TICKET, out string? loginTicket))
{
string? loginUid = cookie.GetValueOrDefault(CookieKeys.LOGIN_UID) ?? cookie.GetValueOrDefault(CookieKeys.LTUID);
if (loginUid != null)
{
Dictionary<string, string> stokens = await authClient
.GetMultiTokenByLoginTicketAsync(loginTicket, loginUid, token)
.ConfigureAwait(false);
if (stokens.TryGetValue(CookieKeys.STOKEN, out string? stoken) && stokens.TryGetValue(CookieKeys.LTOKEN, out string? ltoken))
{
cookie[CookieKeys.STOKEN] = stoken;
cookie[CookieKeys.LTOKEN] = ltoken;
cookie[CookieKeys.STUID] = cookie[CookieKeys.LTUID];
return true;
}
}
}
return false;
}
private async Task<bool> ResumeInternalAsync(
UserClient userClient,
BindingClient userGameRoleClient,
CancellationToken token = default)
{
if (isInitialized)
{
return true;
}
await PrepareUserInfoAndUserGameRolesAsync(userClient, userGameRoleClient, token).ConfigureAwait(false);
isInitialized = true;
return UserInfo != null && UserGameRoles.Any();
}
private async Task<bool> CreateInternalAsync(
IDictionary<string, string> cookie,
UserClient userClient,
BindingClient userGameRoleClient,
AuthClient authClient,
CancellationToken token = default)
{
if (isInitialized)
{
return true;
}
if (await TryAddStokenToCookieAsync(cookie, authClient, token).ConfigureAwait(false))
{
Cookie = ToCookieString(cookie);
}
await PrepareUserInfoAndUserGameRolesAsync(userClient, userGameRoleClient, token).ConfigureAwait(false);
isInitialized = true;
return UserInfo != null && UserGameRoles.Any();
}
private async Task PrepareUserInfoAndUserGameRolesAsync(UserClient userClient, BindingClient userGameRoleClient, CancellationToken token)
{
UserInfo = await userClient
.GetUserFullInfoAsync(this, token)
.ConfigureAwait(false);
@@ -100,9 +248,5 @@ public class User : Observable
.ConfigureAwait(false);
SelectedUserGameRole = UserGameRoles.FirstOrFirstOrDefault(role => role.IsChosen);
isInitialized = true;
return UserInfo != null && UserGameRoles.Any();
}
}

View File

@@ -25,7 +25,7 @@ internal class ParameterFormat : IFormatProvider, ICustomFormatter
case 'P':
return string.Format($"{{0:P0}}", arg);
case 'I':
return ((int)arg!).ToString();
return arg == null ? "0" : ((IConvertible)arg).ToInt32(null).ToString();
}
break;

View File

@@ -9,7 +9,7 @@
<Identity
Name="7f0db578-026f-4e0b-a75b-d5d06bb0a74d"
Publisher="CN=DGP Studio"
Version="1.1.1.0" />
Version="1.1.3.0" />
<Properties>
<DisplayName>胡桃</DisplayName>

View File

@@ -2,7 +2,6 @@
// Licensed under the MIT license.
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.EntityFrameworkCore;
using Snap.Hutao.Context.Database;
using Snap.Hutao.Core.Abstraction;
using Snap.Hutao.Core.Database;
@@ -168,6 +167,7 @@ internal class GachaLogService : IGachaLogService, ISupportAsyncInitialization
return option switch
{
RefreshOption.WebCache => urlProviders.Single(p => p.Name == nameof(GachaLogUrlWebCacheProvider)),
RefreshOption.Stoken => urlProviders.Single(p => p.Name == nameof(GachaLogUrlStokenProvider)),
RefreshOption.ManualInput => urlProviders.Single(p => p.Name == nameof(GachaLogUrlManualInputProvider)),
_ => null,
};

View File

@@ -20,6 +20,11 @@ public enum RefreshOption
/// </summary>
WebCache,
/// <summary>
/// 通过Stoken刷新
/// </summary>
Stoken,
/// <summary>
/// 手动输入Url刷新
/// </summary>

View File

@@ -0,0 +1,56 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.Threading;
using Snap.Hutao.Service.Abstraction;
using Snap.Hutao.Web.Hoyolab;
using Snap.Hutao.Web.Hoyolab.Hk4e.Event.GachaInfo;
using Snap.Hutao.Web.Hoyolab.Takumi.Binding;
namespace Snap.Hutao.Service.GachaLog;
/// <summary>
/// 使用Stokn提供祈愿Url
/// </summary>
[Injection(InjectAs.Transient, typeof(IGachaLogUrlProvider))]
internal class GachaLogUrlStokenProvider : IGachaLogUrlProvider
{
private readonly IUserService userService;
private readonly BindingClient2 bindingClient2;
/// <summary>
/// 构造一个新的提供器
/// </summary>
/// <param name="userService">用户服务</param>
/// <param name="bindingClient2">绑定客户端</param>
public GachaLogUrlStokenProvider(IUserService userService, BindingClient2 bindingClient2)
{
this.userService = userService;
this.bindingClient2 = bindingClient2;
}
/// <inheritdoc/>
public string Name { get => nameof(GachaLogUrlStokenProvider); }
/// <inheritdoc/>
public async Task<ValueResult<bool, string>> GetQueryAsync()
{
Model.Binding.User? user = userService.CurrentUser;
if (user != null)
{
if (user.Cookie!.Contains(CookieKeys.STOKEN) && user.SelectedUserGameRole != null)
{
PlayerUid uid = (PlayerUid)user.SelectedUserGameRole;
GenAuthKeyData data = GenAuthKeyData.CreateForWebViewGacha(uid);
GameAuthKey? authkey = await bindingClient2.GenerateAuthenticationKeyAsync(user, data).ConfigureAwait(false);
if (authkey != null)
{
return new(true, GachaLogConfigration.AsQuery(data, authkey));
}
}
}
return new(false, null!);
}
}

View File

@@ -7,7 +7,6 @@ using Snap.Hutao.Extension;
using Snap.Hutao.Service.Game;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
namespace Snap.Hutao.Service.GachaLog;
@@ -100,4 +99,4 @@ internal class GachaLogUrlWebCacheProvider : IGachaLogUrlProvider
}
}
}
}
}

View File

@@ -13,6 +13,7 @@ internal interface IGachaLogUrlProvider : INamed
{
/// <summary>
/// 异步获取包含验证密钥的查询语句
/// 查询语句可以仅包含?后的内容
/// </summary>
/// <returns>包含验证密钥的查询语句</returns>
Task<ValueResult<bool, string>> GetQueryAsync();

View File

@@ -1,6 +1,8 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.UI.Xaml.Automation.Provider;
using Snap.Hutao.Core.Threading;
using Snap.Hutao.Model.Binding;
using System.Collections.ObjectModel;
@@ -33,6 +35,14 @@ public interface IUserService
/// <returns>用户初始化是否成功</returns>
Task<UserAddResult> TryAddUserAsync(User user, string uid);
/// <summary>
/// 尝试使用 login_ticket 升级用户
/// </summary>
/// <param name="cookie">额外的Cookie</param>
/// <param name="token">取消令牌</param>
/// <returns>是否升级成功</returns>
Task<ValueResult<bool, string>> TryUpgradeUserAsync(IDictionary<string, string> addiition, CancellationToken token = default);
/// <summary>
/// 异步移除用户
/// </summary>
@@ -40,17 +50,11 @@ public interface IUserService
/// <returns>任务</returns>
Task RemoveUserAsync(User user);
/// <summary>
/// 将cookie的字符串形式转换为字典
/// </summary>
/// <param name="cookie">cookie的字符串形式</param>
/// <returns>包含cookie信息的字典</returns>
IDictionary<string, string> ParseCookie(string cookie);
/// <summary>
/// 创建一个新的绑定用户
/// 若存在 login_ticket 与 login_uid 则 自动获取 stoken
/// </summary>
/// <param name="cookie">cookie的字符串形式</param>
/// <returns>新的绑定用户</returns>
Task<User?> CreateUserAsync(string cookie);
Task<User?> CreateUserAsync(IDictionary<string, string> cookie);
}

View File

@@ -3,11 +3,14 @@
using CommunityToolkit.Mvvm.Messaging;
using Snap.Hutao.Context.Database;
using Snap.Hutao.Model.Binding;
using Snap.Hutao.Core.Threading;
using Snap.Hutao.Service.Abstraction;
using Snap.Hutao.Web.Hoyolab;
using Snap.Hutao.Web.Hoyolab.Bbs.User;
using Snap.Hutao.Web.Hoyolab.Takumi.Auth;
using Snap.Hutao.Web.Hoyolab.Takumi.Binding;
using System.Collections.ObjectModel;
using BindingUser = Snap.Hutao.Model.Binding.User;
namespace Snap.Hutao.Service;
@@ -20,11 +23,12 @@ internal class UserService : IUserService
{
private readonly AppDbContext appDbContext;
private readonly UserClient userClient;
private readonly UserGameRoleClient userGameRoleClient;
private readonly BindingClient userGameRoleClient;
private readonly AuthClient authClient;
private readonly IMessenger messenger;
private User? currentUser;
private ObservableCollection<User>? userCollection = null;
private BindingUser? currentUser;
private ObservableCollection<BindingUser>? userCollection;
/// <summary>
/// 构造一个新的用户服务
@@ -32,17 +36,24 @@ internal class UserService : IUserService
/// <param name="appDbContext">应用程序数据库上下文</param>
/// <param name="userClient">用户客户端</param>
/// <param name="userGameRoleClient">角色客户端</param>
/// <param name="authClient">验证客户端</param>
/// <param name="messenger">消息器</param>
public UserService(AppDbContext appDbContext, UserClient userClient, UserGameRoleClient userGameRoleClient, IMessenger messenger)
public UserService(
AppDbContext appDbContext,
UserClient userClient,
BindingClient userGameRoleClient,
AuthClient authClient,
IMessenger messenger)
{
this.appDbContext = appDbContext;
this.userClient = userClient;
this.userGameRoleClient = userGameRoleClient;
this.authClient = authClient;
this.messenger = messenger;
}
/// <inheritdoc/>
public User? CurrentUser
public BindingUser? CurrentUser
{
get => currentUser;
set
@@ -80,12 +91,12 @@ internal class UserService : IUserService
}
/// <inheritdoc/>
public async Task<UserAddResult> TryAddUserAsync(User newUser, string uid)
public async Task<UserAddResult> TryAddUserAsync(BindingUser newUser, string uid)
{
Must.NotNull(userCollection!);
// 查找是否有相同的uid
if (userCollection.SingleOrDefault(u => u.UserInfo!.Uid == uid) is User userWithSameUid)
if (userCollection.SingleOrDefault(u => u.UserInfo!.Uid == uid) is BindingUser userWithSameUid)
{
// Prevent users from adding a completely same cookie.
if (userWithSameUid.Cookie == newUser.Cookie)
@@ -118,7 +129,7 @@ internal class UserService : IUserService
}
/// <inheritdoc/>
public Task RemoveUserAsync(User user)
public Task RemoveUserAsync(BindingUser user)
{
Must.NotNull(userCollection!);
@@ -131,16 +142,16 @@ internal class UserService : IUserService
}
/// <inheritdoc/>
public async Task<ObservableCollection<User>> GetUserCollectionAsync()
public async Task<ObservableCollection<BindingUser>> GetUserCollectionAsync()
{
if (userCollection == null)
{
List<User> users = new();
List<BindingUser> users = new();
foreach (Model.Entity.User entity in appDbContext.Users)
{
User? initialized = await User
.CreateAsync(entity, userClient, userGameRoleClient)
BindingUser? initialized = await BindingUser
.ResumeAsync(entity, userClient, userGameRoleClient)
.ConfigureAwait(false);
if (initialized != null)
@@ -163,25 +174,30 @@ internal class UserService : IUserService
}
/// <inheritdoc/>
public Task<User?> CreateUserAsync(string cookie)
public Task<BindingUser?> CreateUserAsync(IDictionary<string, string> cookie)
{
return User.CreateAsync(Model.Entity.User.Create(cookie), userClient, userGameRoleClient);
return BindingUser.CreateAsync(cookie, userClient, userGameRoleClient, authClient);
}
/// <inheritdoc/>
public IDictionary<string, string> ParseCookie(string cookie)
public async Task<ValueResult<bool, string>> TryUpgradeUserAsync(IDictionary<string, string> addition, CancellationToken token = default)
{
SortedDictionary<string, string> cookieDictionary = new();
string[] values = cookie.TrimEnd(';').Split(';');
foreach (string[] parts in values.Select(c => c.Split('=', 2)))
Must.NotNull(userCollection!);
if (addition.TryGetValue(CookieKeys.LOGIN_UID, out string? uid))
{
string cookieName = parts[0].Trim();
string cookieValue = parts.Length == 1 ? string.Empty : parts[1].Trim();
cookieDictionary.Add(cookieName, cookieValue);
// 查找是否有相同的uid
if (userCollection.SingleOrDefault(u => u.UserInfo!.Uid == uid) is BindingUser userWithSameUid)
{
// Update user cookie here.
if (await userWithSameUid.TryUpgradeAsync(addition, authClient, token))
{
appDbContext.Users.Update(userWithSameUid.Entity);
await appDbContext.SaveChangesAsync().ConfigureAwait(false);
return new(true, uid);
}
}
}
return cookieDictionary;
return new(false, string.Empty);
}
}

View File

@@ -50,6 +50,7 @@
<None Remove="View\Dialog\AchievementImportDialog.xaml" />
<None Remove="View\Dialog\GachaLogImportDialog.xaml" />
<None Remove="View\Dialog\GachaLogRefreshProgressDialog.xaml" />
<None Remove="View\Dialog\UserAutoCookieDialog.xaml" />
<None Remove="View\Dialog\UserDialog.xaml" />
<None Remove="View\MainView.xaml" />
<None Remove="View\Page\AchievementPage.xaml" />
@@ -105,7 +106,7 @@
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.2.46-beta" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.22621.1" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.1.5" />
<PackageReference Include="MiniExcel" Version="1.26.7" />
<PackageReference Include="MiniExcel" Version="1.28.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -127,6 +128,11 @@
<ItemGroup>
<None Include="..\.editorconfig" Link=".editorconfig" />
</ItemGroup>
<ItemGroup>
<Page Update="View\Dialog\UserAutoCookieDialog.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="View\Dialog\AchievementArchiveCreateDialog.xaml">
<Generator>MSBuild:Compile</Generator>

View File

@@ -53,7 +53,7 @@
Visibility="{Binding IsUp,Converter={StaticResource BoolToVisibilityConverter}}"/>
<TextBlock
Width="16"
Width="20"
TextAlignment="Center"
Text="{Binding LastPull}"
VerticalAlignment="Center"

View File

@@ -0,0 +1,32 @@
<ContentDialog
x:Class="Snap.Hutao.View.Dialog.UserAutoCookieDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="登录米哈游通行证"
DefaultButton="Primary"
PrimaryButtonText="继续"
CloseButtonText="取消"
Style="{StaticResource DefaultContentDialogStyle}">
<ContentDialog.Resources>
<x:Double x:Key="ContentDialogMaxWidth">1600</x:Double>
<x:Double x:Key="ContentDialogMinHeight">200</x:Double>
<x:Double x:Key="ContentDialogMaxHeight">1200</x:Double>
</ContentDialog.Resources>
<Grid Loaded="OnRootLoaded">
<Grid.RowDefinitions>
<RowDefinition Height="auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock
Margin="0,0,0,8"
Text="请在 成功登录米哈游通行证 后点击 [继续] 按钮"/>
<WebView2
Grid.Row="1"
Width="640"
Height="400"
x:Name="WebView"/>
</Grid>
</ContentDialog>

View File

@@ -0,0 +1,75 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.Web.WebView2.Core;
using Snap.Hutao.Core.Threading;
namespace Snap.Hutao.View.Dialog;
/// <summary>
/// 用户自动Cookie对话框
/// </summary>
public sealed partial class UserAutoCookieDialog : ContentDialog
{
private IDictionary<string, string>? cookieString;
/// <summary>
/// 构造一个新的用户自动Cookie对话框
/// </summary>
/// <param name="window">依赖窗口</param>
public UserAutoCookieDialog(Window window)
{
InitializeComponent();
XamlRoot = window.Content.XamlRoot;
}
/// <summary>
/// 获取输入的Cookie
/// </summary>
/// <returns>输入的结果</returns>
public async Task<ValueResult<bool, IDictionary<string, string>>> GetInputCookieAsync()
{
ContentDialogResult result = await ShowAsync();
return new(result == ContentDialogResult.Primary && cookieString != null, cookieString!);
}
[SuppressMessage("", "VSTHRD100")]
private async void OnRootLoaded(object sender, RoutedEventArgs e)
{
await WebView.EnsureCoreWebView2Async();
WebView.CoreWebView2.SourceChanged += OnCoreWebView2SourceChanged;
CoreWebView2CookieManager manager = WebView.CoreWebView2.CookieManager;
IReadOnlyList<CoreWebView2Cookie> cookies = await manager.GetCookiesAsync("https://user.mihoyo.com");
foreach (var item in cookies)
{
manager.DeleteCookie(item);
}
WebView.CoreWebView2.Navigate("https://user.mihoyo.com/#/login/password");
}
[SuppressMessage("", "VSTHRD100")]
private async void OnCoreWebView2SourceChanged(CoreWebView2 sender, CoreWebView2SourceChangedEventArgs args)
{
if (sender != null)
{
if (sender.Source.ToString() == "https://user.mihoyo.com/#/account/home")
{
try
{
CoreWebView2CookieManager manager = WebView.CoreWebView2.CookieManager;
IReadOnlyList<CoreWebView2Cookie> cookies = await manager.GetCookiesAsync("https://user.mihoyo.com");
cookieString = cookies.ToDictionary(c => c.Name, c => c.Value);
WebView.CoreWebView2.SourceChanged -= OnCoreWebView2SourceChanged;
}
catch (Exception)
{
}
}
}
}
}

View File

@@ -22,20 +22,21 @@
<TextBox
Margin="0,0,0,8"
x:Name="InputText"
TextChanged="InputTextChanged"
TextChanged="InputTextChanged"
PlaceholderText="在此处输入"
VerticalAlignment="Top"/>
<settings:Setting
Margin="0,8,0,0"
Icon="&#xEB41;"
Header="手动获取"
Description="进入我们的文档页面并按指示操作"
HorizontalAlignment="Stretch">
<HyperlinkButton
Margin="12,0,0,0"
Padding="4"
Content="立即前往"
NavigateUri="https://www.snapgenshin.com/documents/features/mhy-account-switch.html#%E5%A6%82%E4%BD%95%E8%8E%B7%E5%8F%96-cookie"/>
</settings:Setting>
<settings:SettingsGroup Margin="0,-48,0,0">
<settings:Setting
Icon="&#xEB41;"
Header="操作文档"
Description="进入我们的文档页面并按指示操作"
HorizontalAlignment="Stretch">
<HyperlinkButton
Margin="12,0,0,0"
Padding="4"
Content="立即前往"
NavigateUri="https://www.snapgenshin.com/documents/features/mhy-account-switch.html#%E5%A6%82%E4%BD%95%E8%8E%B7%E5%8F%96-cookie"/>
</settings:Setting>
</settings:SettingsGroup>
</StackPanel>
</ContentDialog>

View File

@@ -9,7 +9,8 @@
xmlns:shcb="using:Snap.Hutao.Control.Behavior"
xmlns:shcm="using:Snap.Hutao.Control.Markup"
xmlns:shv="using:Snap.Hutao.ViewModel"
xmlns:shvc="using:Snap.Hutao.View.Control" xmlns:image="using:Snap.Hutao.Control.Image"
xmlns:shvc="using:Snap.Hutao.View.Control"
xmlns:shci="using:Snap.Hutao.Control.Image"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance shv:GachaLogViewModel}"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
@@ -45,15 +46,19 @@
<MenuFlyout Placement="Bottom">
<MenuFlyoutItem
Text="从缓存刷新"
Icon="{shcm:FontIcon Glyph=&#xE721;}"
Icon="{shcm:FontIcon Glyph=&#xE81E;}"
Command="{Binding RefreshByWebCacheCommand}"/>
<MenuFlyoutItem
Text="Stoken刷新"
Icon="{shcm:FontIcon Glyph=&#xE192;}"
Command="{Binding RefreshByStokenCommand}"/>
<MenuFlyoutItem
Text="手动输入Url"
Icon="{shcm:FontIcon Glyph=&#xE765;}"
Command="{Binding RefreshByManualInputCommand}"/>
<ToggleMenuFlyoutItem
Text="全量刷新"
Icon="{shcm:FontIcon Glyph=&#xEA37;}"
Icon="{shcm:FontIcon Glyph=&#xE1CD;}"
IsChecked="{Binding IsAggressiveRefresh}"/>
</MenuFlyout>
</AppBarButton.Flyout>
@@ -157,7 +162,7 @@
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="2">
<image:CachedImage
<shci:CachedImage
Width="32"
Height="32"
Source="{Binding Icon}"/>
@@ -187,7 +192,7 @@
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="2">
<image:CachedImage
<shci:CachedImage
Width="32"
Height="32"
Source="{Binding Icon}"/>

View File

@@ -45,9 +45,7 @@
<shvc:DescParamComboBox
Grid.Column="0"
HorizontalAlignment="Stretch"
Source="{Binding Proud,Converter={StaticResource DescParamDescriptor}}"/>
Source="{Binding Proud,Mode=OneWay,Converter={StaticResource DescParamDescriptor}}"/>
</StackPanel>
</Grid>

View File

@@ -8,7 +8,9 @@
xmlns:mxim="using:Microsoft.Xaml.Interactions.Media"
xmlns:mxi="using:Microsoft.Xaml.Interactivity"
xmlns:shc="using:Snap.Hutao.Control"
mc:Ignorable="d">
xmlns:shvm="using:Snap.Hutao.ViewModel"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance shvm:UserViewModel}">
<mxi:Interaction.Behaviors>
<mxic:EventTriggerBehavior EventName="Loaded">
<mxic:InvokeCommandAction Command="{Binding OpenUICommand}"/>
@@ -191,10 +193,18 @@
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<TextBlock
Margin="10,6,0,6"
Style="{StaticResource BaseTextBlockStyle}"
Text="Cookie"/>
<CommandBar DefaultLabelPosition="Right">
<AppBarButton
Icon="Add"
Label="添加新用户"
Label="升级Stoken"
Command="{Binding UpgradeToStokenCommand}"/>
<AppBarButton
Icon="Add"
Label="手动添加"
Command="{Binding AddUserCommand}"/>
</CommandBar>
</StackPanel>

View File

@@ -63,6 +63,7 @@ internal class GachaLogViewModel : ObservableObject, ISupportCancellation
OpenUICommand = asyncRelayCommandFactory.Create(OpenUIAsync);
RefreshByWebCacheCommand = asyncRelayCommandFactory.Create(RefreshByWebCacheAsync);
RefreshByStokenCommand = asyncRelayCommandFactory.Create(RefreshByStokenAsync);
RefreshByManualInputCommand = asyncRelayCommandFactory.Create(RefreshByManualInputAsync);
ImportFromUIGFExcelCommand = asyncRelayCommandFactory.Create(ImportFromUIGFExcelAsync);
ImportFromUIGFJsonCommand = asyncRelayCommandFactory.Create(ImportFromUIGFJsonAsync);
@@ -124,6 +125,11 @@ internal class GachaLogViewModel : ObservableObject, ISupportCancellation
/// </summary>
public ICommand RefreshByWebCacheCommand { get; }
/// <summary>
/// Stoken 刷新命令
/// </summary>
public ICommand RefreshByStokenCommand { get; }
/// <summary>
/// 手动输入Url刷新命令
/// </summary>
@@ -188,6 +194,11 @@ internal class GachaLogViewModel : ObservableObject, ISupportCancellation
return RefreshInternalAsync(RefreshOption.WebCache);
}
private Task RefreshByStokenAsync()
{
return RefreshInternalAsync(RefreshOption.Stoken);
}
private Task RefreshByManualInputAsync()
{
return RefreshInternalAsync(RefreshOption.ManualInput);
@@ -206,6 +217,7 @@ internal class GachaLogViewModel : ObservableObject, ISupportCancellation
RefreshStrategy strategy = IsAggressiveRefresh ? RefreshStrategy.AggressiveMerge : RefreshStrategy.LazyMerge;
MainWindow mainWindow = Ioc.Default.GetRequiredService<MainWindow>();
await ThreadHelper.SwitchToMainThreadAsync();
GachaLogRefreshProgressDialog dialog = new(mainWindow);
IAsyncDisposable dialogHider = await dialog.BlockAsync().ConfigureAwait(false);
Progress<FetchState> progress = new(dialog.OnReport);

View File

@@ -3,11 +3,14 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Snap.Hutao.Core.Threading;
using Snap.Hutao.Factory.Abstraction;
using Snap.Hutao.Model.Binding;
using Snap.Hutao.Service.Abstraction;
using Snap.Hutao.View.Dialog;
using Snap.Hutao.Web.Hoyolab;
using System.Collections.ObjectModel;
using System.Net;
using Windows.ApplicationModel.DataTransfer;
namespace Snap.Hutao.ViewModel;
@@ -39,6 +42,7 @@ internal class UserViewModel : ObservableObject
OpenUICommand = asyncRelayCommandFactory.Create(OpenUIAsync);
AddUserCommand = asyncRelayCommandFactory.Create(AddUserAsync);
UpgradeToStokenCommand = asyncRelayCommandFactory.Create(UpgradeToStokenAsync);
RemoveUserCommand = asyncRelayCommandFactory.Create<User>(RemoveUserAsync);
CopyCookieCommand = new RelayCommand<User>(CopyCookie);
}
@@ -73,6 +77,11 @@ internal class UserViewModel : ObservableObject
/// </summary>
public ICommand AddUserCommand { get; }
/// <summary>
/// 升级到Stoken命令
/// </summary>
public ICommand UpgradeToStokenCommand { get; }
/// <summary>
/// 移除用户命令
/// </summary>
@@ -91,11 +100,15 @@ internal class UserViewModel : ObservableObject
foreach ((string key, string value) in map)
{
if (key == AccountIdKey || key == "cookie_token" || key == "ltoken" || key == "ltuid")
if (key == CookieKeys.COOKIE_TOKEN || key == CookieKeys.ACCOUNT_ID || key == CookieKeys.LTOKEN || key == CookieKeys.LTUID)
{
validFlag--;
filter.Add(key, value);
}
else if (key == CookieKeys.STOKEN || key == CookieKeys.STUID || key == CookieKeys.LOGIN_TICKET || key == CookieKeys.LOGIN_UID)
{
filter.Add(key, value);
}
}
if (validFlag == 0)
@@ -112,7 +125,7 @@ internal class UserViewModel : ObservableObject
private async Task OpenUIAsync()
{
Users = await userService.GetUserCollectionAsync();
Users = await userService.GetUserCollectionAsync().ConfigureAwait(true);
SelectedUser = userService.CurrentUser;
}
@@ -120,18 +133,16 @@ internal class UserViewModel : ObservableObject
{
// Get cookie from user input
MainWindow mainWindow = Ioc.Default.GetRequiredService<MainWindow>();
(bool isOk, string cookie) = await new UserDialog(mainWindow).GetInputCookieAsync();
ValueResult<bool, string> result = await new UserDialog(mainWindow).GetInputCookieAsync().ConfigureAwait(false);
// User confirms the input
if (isOk)
if (result.IsOk)
{
if (TryValidateCookie(userService.ParseCookie(cookie), out IDictionary<string, string>? filteredCookie))
if (TryValidateCookie(User.ParseCookie(result.Value), out IDictionary<string, string>? filteredCookie))
{
string simplifiedCookie = string.Join(';', filteredCookie.Select(kvp => $"{kvp.Key}={kvp.Value}"));
if (await userService.CreateUserAsync(simplifiedCookie) is User user)
if (await userService.CreateUserAsync(filteredCookie).ConfigureAwait(false) is User user)
{
switch (await userService.TryAddUserAsync(user, filteredCookie[AccountIdKey]))
switch (await userService.TryAddUserAsync(user, filteredCookie[AccountIdKey]).ConfigureAwait(false))
{
case UserAddResult.Added:
infoBarService.Success($"用户 [{user.UserInfo!.Nickname}] 添加成功");
@@ -158,10 +169,31 @@ internal class UserViewModel : ObservableObject
}
}
private async Task UpgradeToStokenAsync()
{
// Get cookie from user input
MainWindow mainWindow = Ioc.Default.GetRequiredService<MainWindow>();
(bool isOk, IDictionary<string, string> addition) = await new UserAutoCookieDialog(mainWindow).GetInputCookieAsync().ConfigureAwait(false);
// User confirms the input
if (isOk)
{
(bool isUpgradeSucceed, string uid) = await userService.TryUpgradeUserAsync(addition).ConfigureAwait(false);
if (isUpgradeSucceed)
{
infoBarService.Information($"用户 [{uid}] 的 Cookie 已成功添加 Stoken");
}
else
{
infoBarService.Warning("请先添加对应用户的米游社Cookie");
}
}
}
private async Task RemoveUserAsync(User? user)
{
Verify.Operation(user != null, "待删除的用户不应为 null");
await userService.RemoveUserAsync(user);
await userService.RemoveUserAsync(user).ConfigureAwait(false);
infoBarService.Success($"用户 [{user.UserInfo?.Nickname}] 成功移除");
}

View File

@@ -89,16 +89,36 @@ internal static class ApiEndpoints
}
#endregion
#region UserGameRole
#region Binding
/// <summary>
/// 用户游戏角色
/// </summary>
public const string UserGameRoles = $"{ApiTaKumiBindingApi}/getUserGameRolesByCookie?game_biz=hk4e_cn";
/// <summary>
/// 用户游戏角色
/// </summary>
public const string GenAuthKey = $"{ApiTaKumiBindingApi}/genAuthKey";
#endregion
#region Auth
/// <summary>
/// 获取 stoken 与 ltoken
/// </summary>
/// <param name="loginTicket">登录票证</param>
/// <param name="loginUid">uid</param>
/// <returns>Url</returns>
public static string AuthMultiToken(string loginTicket, string loginUid)
{
return $"{ApiTakumiAuthApi}/getMultiTokenByLoginTicket?login_ticket={loginTicket}&uid={loginUid}&token_types=3";
}
#endregion
// consts
private const string ApiTakumi = "https://api-takumi.mihoyo.com";
private const string ApiTakumiAuthApi = $"{ApiTakumi}/auth/api";
private const string ApiTaKumiBindingApi = $"{ApiTakumi}/binding/api";
private const string ApiTakumiRecord = "https://api-takumi-record.mihoyo.com";
private const string ApiTakumiRecordApi = $"{ApiTakumiRecord}/game_record/app/genshin/api";
@@ -111,4 +131,4 @@ internal static class ApiEndpoints
private const string Hk4eApiGachaInfoApi = $"{Hk4eApi}/event/gacha_info/api";
private const string AnnouncementQuery = "game=hk4e&game_biz=hk4e_cn&lang=zh-cn&bundle_id=hk4e_cn&platform=pc&region=cn_gf01&level=55&uid=100000000";
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Web.Hoyolab;
/// <summary>
/// Cookie的键
/// </summary>
[SuppressMessage("", "SA1310")]
[SuppressMessage("", "SA1600")]
internal static class CookieKeys
{
public const string ACCOUNT_ID = "account_id";
public const string COOKIE_TOKEN = "cookie_token";
public const string LOGIN_TICKET = "login_ticket";
public const string LOGIN_UID = "login_uid";
public const string LTOKEN = "ltoken";
public const string LTUID = "ltuid";
public const string STOKEN = "stoken";
public const string STUID = "stuid";
}

View File

@@ -51,7 +51,7 @@ internal class DynamicSecretHttpClient<TValue> : IDynamicSecretHttpClient<TValue
private readonly HttpClient httpClient;
private readonly JsonSerializerOptions options;
private readonly string url;
private readonly TValue? data = null;
private readonly TValue data;
/// <summary>
/// 构造一个新的使用动态密钥2的Http客户端默认实现的实例
@@ -60,7 +60,7 @@ internal class DynamicSecretHttpClient<TValue> : IDynamicSecretHttpClient<TValue
/// <param name="options">Json序列化选项</param>
/// <param name="url">url</param>
/// <param name="data">请求的数据</param>
public DynamicSecretHttpClient(HttpClient httpClient, JsonSerializerOptions options, string url, TValue? data)
public DynamicSecretHttpClient(HttpClient httpClient, JsonSerializerOptions options, string url, TValue data)
{
this.httpClient = httpClient;
this.options = options;
@@ -75,4 +75,11 @@ internal class DynamicSecretHttpClient<TValue> : IDynamicSecretHttpClient<TValue
{
return httpClient.PostAsJsonAsync(url, data, options, token);
}
/// <inheritdoc/>
public Task<TResult?> TryCatchPostAsJsonAsync<TResult>(ILogger logger, CancellationToken token = default(CancellationToken))
where TResult : class
{
return httpClient.TryCatchPostAsJsonAsync<TValue, TResult>(url, data, options, logger, token);
}
}

View File

@@ -32,4 +32,14 @@ internal interface IDynamicSecretHttpClient<TValue>
/// <param name="token">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
/// <returns>The task object representing the asynchronous operation.</returns>
Task<HttpResponseMessage> PostAsJsonAsync(CancellationToken token);
/// <summary>
/// Sends a POST request to the specified Uri containing the value serialized as JSON in the request body.
/// </summary>
/// <typeparam name="TResult">值的类型</typeparam>
/// <param name="logger">日志器</param>
/// <param name="token">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
/// <returns>结果</returns>
Task<TResult?> TryCatchPostAsJsonAsync<TResult>(ILogger logger, CancellationToken token = default)
where TResult : class;
}

View File

@@ -1,6 +1,7 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Web.Hoyolab.Takumi.Binding;
using Snap.Hutao.Web.Request.QueryString;
namespace Snap.Hutao.Web.Hoyolab.Hk4e.Event.GachaInfo;
@@ -15,6 +16,18 @@ public struct GachaLogConfigration
/// </summary>
public const int Size = 20;
/// <summary>
/// Below keys are required:
/// authkey_ver
/// auth_appid
/// authkey
/// sign_type
/// Below keys used as control:
/// lang
/// gacha_type
/// size
/// end_id
/// </summary>
private readonly QueryString innerQuery;
/// <summary>
@@ -46,6 +59,23 @@ public struct GachaLogConfigration
set => innerQuery.Set("end_id", value);
}
/// <summary>
/// 转换到查询字符串
/// </summary>
/// <param name="genAuthKeyData">生成信息</param>
/// <param name="gameAuthKey">验证包装</param>
/// <returns>查询</returns>
public static string AsQuery(GenAuthKeyData genAuthKeyData, GameAuthKey gameAuthKey)
{
QueryString queryString = new();
queryString.Set("auth_appid", genAuthKeyData.AuthAppId);
queryString.Set("authkey", Uri.EscapeDataString(gameAuthKey.AuthKey));
queryString.Set("authkey_ver", gameAuthKey.AuthKeyVersion);
queryString.Set("sign_type", gameAuthKey.SignType);
return queryString.ToString();
}
/// <summary>
/// 转换到查询字符串
/// </summary>

View File

@@ -29,6 +29,22 @@ internal static class HttpClientExtensions
}
}
/// <inheritdoc cref="HttpClientJsonExtensions.PostAsJsonAsync{TValue}(HttpClient, string?, TValue, JsonSerializerOptions?, CancellationToken)"/>
internal static async Task<TResult?> TryCatchPostAsJsonAsync<TValue, TResult>(this HttpClient httpClient, string requestUri, TValue value, JsonSerializerOptions options, ILogger logger, CancellationToken token = default)
where TResult : class
{
try
{
HttpResponseMessage message = await httpClient.PostAsJsonAsync(requestUri, value, options, token).ConfigureAwait(false);
return await message.Content.ReadFromJsonAsync<TResult>(options, token).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
logger.LogWarning(EventIds.HttpException, ex, "请求异常已忽略");
return null;
}
}
/// <summary>
/// 设置用户的Cookie
/// </summary>

View File

@@ -0,0 +1,54 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
using Snap.Hutao.Web.Hoyolab.Takumi.Binding;
using Snap.Hutao.Web.Response;
using System.Net.Http;
namespace Snap.Hutao.Web.Hoyolab.Takumi.Auth;
/// <summary>
/// 授权客户端
/// </summary>
[HttpClient(HttpClientConfigration.Default)]
internal class AuthClient
{
private readonly HttpClient httpClient;
private readonly JsonSerializerOptions options;
private readonly ILogger<BindingClient> logger;
/// <summary>
/// 构造一个新的授权客户端
/// </summary>
/// <param name="httpClient">Http客户端</param>
/// <param name="options">Json序列化选项</param>
/// <param name="logger">日志器</param>
public AuthClient(HttpClient httpClient, JsonSerializerOptions options, ILogger<BindingClient> logger)
{
this.httpClient = httpClient;
this.options = options;
this.logger = logger;
}
/// <summary>
/// 获取 MultiToken
/// </summary>
/// <param name="loginTicket">登录票证</param>
/// <param name="loginUid">uid</param>
/// <param name="token">取消令牌</param>
/// <returns>包含token的字典</returns>
public async Task<Dictionary<string, string>> GetMultiTokenByLoginTicketAsync(string loginTicket, string loginUid, CancellationToken token)
{
Response<ListWrapper<NameToken>>? resp = await httpClient
.TryCatchGetFromJsonAsync<Response<ListWrapper<NameToken>>>(ApiEndpoints.AuthMultiToken(loginTicket, loginUid), options, logger, token)
.ConfigureAwait(false);
if (resp?.Data != null)
{
return resp.Data.List.ToDictionary(n => n.Name, n => n.Token);
}
return new();
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Web.Hoyolab.Takumi.Auth;
/// <summary>
/// 名称与令牌
/// </summary>
public sealed class NameToken
{
/// <summary>
/// Token名称
/// stoken
/// ltoken
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = default!;
/// <summary>
/// 令牌
/// </summary>
[JsonPropertyName("token")]
public string Token { get; set; } = default!;
}

View File

@@ -10,23 +10,22 @@ using System.Net.Http;
namespace Snap.Hutao.Web.Hoyolab.Takumi.Binding;
/// <summary>
/// 用户游戏角色提供器
/// 绑定客户端
/// </summary>
[HttpClient(HttpClientConfigration.Default)]
internal class UserGameRoleClient
internal class BindingClient
{
private readonly HttpClient httpClient;
private readonly JsonSerializerOptions options;
private readonly ILogger<UserGameRoleClient> logger;
private readonly ILogger<BindingClient> logger;
/// <summary>
/// 构造一个新的用户游戏角色提供器
/// </summary>
/// <param name="userService">用户服务</param>
/// <param name="httpClient">请求器</param>
/// <param name="options">Json序列化选项</param>
/// <param name="logger">日志器</param>
public UserGameRoleClient(HttpClient httpClient, JsonSerializerOptions options, ILogger<UserGameRoleClient> logger)
public BindingClient(HttpClient httpClient, JsonSerializerOptions options, ILogger<BindingClient> logger)
{
this.httpClient = httpClient;
this.options = options;
@@ -48,4 +47,4 @@ internal class UserGameRoleClient
return EnumerableExtensions.EmptyIfNull(resp?.Data?.List);
}
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
using Snap.Hutao.Model.Binding;
using Snap.Hutao.Web.Hoyolab.DynamicSecret;
using Snap.Hutao.Web.Response;
using System.Net.Http;
namespace Snap.Hutao.Web.Hoyolab.Takumi.Binding;
/// <summary>
/// Stoken绑定客户端
/// </summary>
[HttpClient(HttpClientConfigration.XRpc)]
internal class BindingClient2
{
private readonly HttpClient httpClient;
private readonly JsonSerializerOptions options;
private readonly ILogger<BindingClient2> logger;
/// <summary>
/// 构造一个新的用户游戏角色提供器
/// </summary>
/// <param name="httpClient">请求器</param>
/// <param name="options">Json序列化选项</param>
/// <param name="logger">日志器</param>
public BindingClient2(HttpClient httpClient, JsonSerializerOptions options, ILogger<BindingClient2> logger)
{
this.httpClient = httpClient;
this.options = options;
this.logger = logger;
}
/// <summary>
/// 异步生成祈愿验证密钥
/// 需要stoken
/// </summary>
/// <param name="user">用户</param>
/// <param name="data">提交数据</param>
/// <param name="token">取消令牌</param>
/// <returns>用户角色信息</returns>
public async Task<GameAuthKey?> GenerateAuthenticationKeyAsync(User user, GenAuthKeyData data, CancellationToken token = default)
{
Response<GameAuthKey>? resp = await httpClient
.SetUser(user)
.SetReferer("https://app.mihoyo.com")
.UsingDynamicSecret()
.TryCatchPostAsJsonAsync<GenAuthKeyData, Response<GameAuthKey>>(ApiEndpoints.GenAuthKey, data, options, logger, token)
.ConfigureAwait(false);
return resp?.Data;
}
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Web.Hoyolab.Takumi.Binding;
/// <summary>
/// 验证密钥
/// </summary>
public class GameAuthKey
{
/// <summary>
/// 验证密钥
/// </summary>
[JsonPropertyName("authkey")]
public string AuthKey { get; set; } = default!;
/// <summary>
/// 验证密钥版本
/// </summary>
[JsonPropertyName("authkey_ver")]
public int AuthKeyVersion { get; set; } = default!;
/// <summary>
/// 签名类型
/// </summary>
[JsonPropertyName("sign_type")]
public int SignType { get; set; } = default!;
}

View File

@@ -0,0 +1,59 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Web.Hoyolab.Takumi.Binding;
/// <summary>
/// 验证密钥提交数据
/// </summary>
public sealed class GenAuthKeyData
{
/// <summary>
/// 构造一个新的验证密钥提交数据
/// </summary>
/// <param name="authAppId">AppId</param>
/// <param name="gameBiz">游戏代号</param>
/// <param name="uid">uid</param>
public GenAuthKeyData(string authAppId, string gameBiz, PlayerUid uid)
{
AuthAppId = authAppId;
GameBiz = gameBiz;
GameUid = int.Parse(uid.Value);
Region = uid.Region;
}
/// <summary>
/// App Id
/// </summary>
[JsonPropertyName("auth_appid")]
public string AuthAppId { get; set; } = default!;
/// <summary>
/// 游戏代号
/// </summary>
[JsonPropertyName("game_biz")]
public string GameBiz { get; set; } = default!;
/// <summary>
/// Uid
/// </summary>
[JsonPropertyName("game_uid")]
public int GameUid { get; set; }
/// <summary>
/// 区域
/// </summary>
[JsonPropertyName("region")]
public string Region { get; set; } = default!;
/// <summary>
/// 创建为祈愿记录验证密钥提交数据
/// </summary>
/// <param name="uid">uid</param>
/// <returns>验证密钥提交数据</returns>
public static GenAuthKeyData CreateForWebViewGacha(PlayerUid uid)
{
return new("webview_gacha", "hk4e_cn", uid);
}
}

View File

@@ -8,7 +8,6 @@ using Snap.Hutao.Web.Hoyolab.DynamicSecret;
using Snap.Hutao.Web.Hoyolab.Takumi.GameRecord.Avatar;
using Snap.Hutao.Web.Response;
using System.Net.Http;
using System.Net.Http.Json;
namespace Snap.Hutao.Web.Hoyolab.Takumi.GameRecord;
@@ -19,17 +18,19 @@ namespace Snap.Hutao.Web.Hoyolab.Takumi.GameRecord;
internal class GameRecordClient
{
private readonly HttpClient httpClient;
private readonly JsonSerializerOptions jsonSerializerOptions;
private readonly JsonSerializerOptions options;
private readonly ILogger<GameRecordClient> logger;
/// <summary>
/// 构造一个新的游戏记录提供器
/// </summary>
/// <param name="httpClient">请求器</param>
/// <param name="jsonSerializerOptions">json序列化选项</param>
public GameRecordClient(HttpClient httpClient, JsonSerializerOptions jsonSerializerOptions)
/// <param name="options">json序列化选项</param>
public GameRecordClient(HttpClient httpClient, JsonSerializerOptions options, ILogger<GameRecordClient> logger)
{
this.httpClient = httpClient;
this.jsonSerializerOptions = jsonSerializerOptions;
this.options = options;
this.logger = logger;
}
/// <summary>
@@ -55,7 +56,7 @@ internal class GameRecordClient
{
Response<PlayerInfo>? resp = await httpClient
.SetUser(user)
.UsingDynamicSecret(jsonSerializerOptions, ApiEndpoints.GameRecordIndex(uid.Value, uid.Region))
.UsingDynamicSecret(options, ApiEndpoints.GameRecordIndex(uid.Value, uid.Region))
.GetFromJsonAsync<Response<PlayerInfo>>(token)
.ConfigureAwait(false);
@@ -87,7 +88,7 @@ internal class GameRecordClient
{
Response<SpiralAbyss.SpiralAbyss>? resp = await httpClient
.SetUser(user)
.UsingDynamicSecret(jsonSerializerOptions, ApiEndpoints.GameRecordSpiralAbyss(schedule, uid))
.UsingDynamicSecret(options, ApiEndpoints.GameRecordSpiralAbyss(schedule, uid))
.GetFromJsonAsync<Response<SpiralAbyss.SpiralAbyss>>(token)
.ConfigureAwait(false);
@@ -119,14 +120,10 @@ internal class GameRecordClient
{
CharacterData data = new(uid, playerInfo.Avatars.Select(x => x.Id));
HttpResponseMessage? response = await httpClient
Response<CharacterWrapper>? resp = await httpClient
.SetUser(user)
.UsingDynamicSecret(jsonSerializerOptions, ApiEndpoints.GameRecordCharacter, data)
.PostAsJsonAsync(token)
.ConfigureAwait(false);
Response<CharacterWrapper>? resp = await response.Content
.ReadFromJsonAsync<Response<CharacterWrapper>>(jsonSerializerOptions, token)
.UsingDynamicSecret(options, ApiEndpoints.GameRecordCharacter, data)
.TryCatchPostAsJsonAsync<Response<CharacterWrapper>>(logger, token)
.ConfigureAwait(false);
return EnumerableExtensions.EmptyIfNull(resp?.Data?.Avatars);