Login from hoyolab account website to get stoken

This commit is contained in:
Xhichn
2023-03-22 17:44:56 +08:00
parent 2c162d1fef
commit 805fd31bf8
29 changed files with 1283 additions and 59 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

@@ -29,6 +29,11 @@ internal static class CoreEnvironment
/// </summary>
public const string HoyolabMobileUA = $"Mozilla/5.0 (Linux; Android 12) Mobile miHoYoBBS/{HoyolabXrpcVersion}";
/// <summary>
/// Hoyolab iPhone 移动端请求UA
/// </summary>
public const string HoyolabOsMobileUA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) miHoYoBBSOversea/2.28.0";
/// <summary>
/// 米游社 Rpc 版本
/// </summary>
@@ -45,6 +50,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,16 @@ 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;
if (!user.Entity.IsOversea)
{
isOk = await user.InitializeCoreAsync(token).ConfigureAwait(false);
}
else
{
isOk = await user.InitializeCoreOsAsync(token).ConfigureAwait(false);
}
if (!isOk)
{
@@ -127,6 +137,7 @@ internal sealed class User : ObservableObject
entity.Aid = cookie.GetValueOrDefault(Cookie.STUID);
entity.Mid = cookie.GetValueOrDefault(Cookie.MID);
entity.IsOversea = false;
if (entity.Aid != null && entity.Mid != null)
{
@@ -141,6 +152,38 @@ internal sealed class User : ObservableObject
}
}
/// <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);
entity.Aid = cookie.GetValueOrDefault(Cookie.STUID);
// Note: Currently we dont know how to get "mid" for hoyolab user,
// mid is set as the same value of ltuid(stuid/user id)
entity.Mid = entity.Aid;
entity.IsOversea = true;
if (entity.Aid != null)
{
User user = new(entity);
bool initialized = await user.InitializeCoreOsAsync(token).ConfigureAwait(false);
return initialized ? user : null;
}
else
{
return null;
}
}
private async Task<bool> InitializeCoreAsync(CancellationToken token = default)
{
if (isInitialized)
@@ -268,4 +311,87 @@ internal sealed class User : ObservableObject
return false;
}
}
private async Task<bool> InitializeCoreOsAsync(CancellationToken token = default)
{
if (isInitialized)
{
return true;
}
if (SToken == null)
{
return false;
}
using (IServiceScope scope = Ioc.Default.CreateScope())
{
// 自动填充 Ltoken
if (LToken == null)
{
Response<LtokenWrapper> ltokenResponse = await scope.ServiceProvider
.GetRequiredService<PassportClientOs>()
.GetLtokenBySTokenAsync(Entity, token)
.ConfigureAwait(false);
if (ltokenResponse.IsOk())
{
Cookie ltokenCookie = Cookie.Parse($"ltuid={Entity.Aid};ltoken={ltokenResponse.Data.Ltoken}");
Entity.LToken = ltokenCookie;
}
else
{
return false;
}
}
// Fetch user info
Response<UserFullInfoWrapper> response = await scope.ServiceProvider
.GetRequiredService<UserClient>()
.GetOsUserFullInfoAsync(Entity, token)
.ConfigureAwait(false);
UserInfo = response.Data?.UserInfo;
// 自动填充 CookieToken
if (CookieToken == null)
{
Response<UidCookieToken> cookieTokenResponse = await scope.ServiceProvider
.GetRequiredService<PassportClientOs>()
.GetCookieAccountInfoBySTokenAsync(Entity, token)
.ConfigureAwait(false);
if (cookieTokenResponse.IsOk())
{
Cookie cookieTokenCookie = Cookie.Parse($"account_id={Entity.Aid};cookie_token={cookieTokenResponse.Data.CookieToken}");
Entity.CookieToken = cookieTokenCookie;
}
else
{
return false;
}
}
// 获取游戏角色
Response<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;
@@ -72,4 +73,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)
{
_ = cookie.TryGetAsStokenV1(out Cookie? stoken);
_ = cookie.TryGetAsLtoken(out Cookie? ltoken);
_ = cookie.TryGetAsCookieToken(out Cookie? cookieToken);
return new() { SToken = stoken, 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

@@ -60,16 +60,13 @@ internal sealed class DailyNoteNotifier
BindingClient bindingClient = scope.ServiceProvider.GetRequiredService<BindingClient>();
AuthClient authClient = scope.ServiceProvider.GetRequiredService<AuthClient>();
Response<ActionTicketWrapper> actionTicketResponse = await authClient
.GetActionTicketByStokenAsync("game_role", entry.User)
.ConfigureAwait(false);
string? attribution = SH.ServiceDailyNoteNotifierAttribution;
if (actionTicketResponse.IsOk())
if (entry.User.IsOversea)
{
Response<ListWrapper<UserGameRole>> rolesResponse = await scope.ServiceProvider
.GetRequiredService<BindingClient>()
.GetUserGameRolesByActionTicketAsync(actionTicketResponse.Data.Ticket, entry.User)
.GetOsUserGameRolesByCookieAsync(entry.User)
.ConfigureAwait(false);
if (rolesResponse.IsOk())
@@ -77,6 +74,27 @@ internal sealed class DailyNoteNotifier
List<UserGameRole> roles = rolesResponse.Data.List;
attribution = roles.SingleOrDefault(r => r.GameUid == entry.Uid)?.ToString() ?? "Unkonwn";
}
}
else
{
Response<ActionTicketWrapper> actionTicketResponse = await authClient
.GetActionTicketByStokenAsync("game_role", entry.User)
.ConfigureAwait(false);
if (actionTicketResponse.IsOk())
{
Response<ListWrapper<UserGameRole>> rolesResponse = await scope.ServiceProvider
.GetRequiredService<BindingClient>()
.GetUserGameRolesByActionTicketAsync(actionTicketResponse.Data.Ticket, entry.User)
.ConfigureAwait(false);
if (rolesResponse.IsOk())
{
List<UserGameRole> roles = rolesResponse.Data.List;
attribution = roles.SingleOrDefault(r => r.GameUid == entry.Uid)?.ToString() ?? "Unkonwn";
}
}
}
ToastContentBuilder builder = new ToastContentBuilder()

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

@@ -38,6 +38,11 @@ internal sealed class GachaLogQueryStokenProvider : IGachaLogQueryProvider
{
if (UserAndUid.TryFromUser(userService.Current, out UserAndUid? userAndUid))
{
if (userAndUid.Uid.Region != "cn_gf01" && userAndUid.Uid.Region != "cn_qd01")
{
return new(false, "Unsupported for hoyoverse account");
}
GenAuthKeyData data = GenAuthKeyData.CreateForWebViewGacha(userAndUid.Uid);
Response<GameAuthKey> authkeyResponse = await bindingClient2.GenerateAuthenticationKeyAsync(userAndUid.User, data).ConfigureAwait(false);

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">Cookie需包含 stuid, stoken 字段</param>
/// <returns>处理的结果</returns>
Task<ValueResult<UserOptionResult, string>> ProcessInputOsCookieAsync(Cookie cookie);
/// <summary>
/// 异步刷新 Cookie 的 CookieToken
/// </summary>

View File

@@ -225,7 +225,49 @@ 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? stuid = cookie.GetValueOrDefault(Cookie.STUID);
if (stuid == null)
{
return new(UserOptionResult.Invalid, SH.ServiceUserProcessCookieNoMid);
}
// 检查 stuid 对应用户是否存在
if (TryGetUser(userCollection!, stuid, out BindingUser? user))
{
// Note: Currently we dont know how to get "mid" for hoyolab user,
// mid is set as the same value of ltuid(stuid/user id)
user.Entity.Mid = stuid;
using (IServiceScope scope = scopeFactory.CreateScope())
{
AppDbContext appDbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (cookie.TryGetAsStoken(out Cookie? stoken))
{
user.SToken = stoken;
user.LToken = cookie.TryGetAsLtoken(out Cookie? ltoken) ? ltoken : user.LToken;
user.CookieToken = cookie.TryGetAsCookieToken(out Cookie? cookieToken) ? cookieToken : user.CookieToken;
await appDbContext.Users.UpdateAndSaveAsync(user.Entity).ConfigureAwait(false);
return new(UserOptionResult.Updated, stuid);
}
else
{
return new(UserOptionResult.Invalid, SH.ServiceUserProcessCookieNoStoken);
}
}
}
else
{
return await TryCreateUserAndAddAsync(cookie, true).ConfigureAwait(false);
}
}
@@ -234,10 +276,21 @@ internal class UserService : IUserService
{
using (IServiceScope scope = scopeFactory.CreateScope())
{
Response<UidCookieToken> cookieTokenResponse = await scope.ServiceProvider
Response<UidCookieToken> cookieTokenResponse;
if (user.Entity.IsOversea)
{
cookieTokenResponse = await scope.ServiceProvider
.GetRequiredService<PassportClientOs>()
.GetCookieAccountInfoBySTokenAsync(user.Entity)
.ConfigureAwait(false);
}
else
{
cookieTokenResponse = await scope.ServiceProvider
.GetRequiredService<PassportClient2>()
.GetCookieAccountInfoBySTokenAsync(user.Entity)
.ConfigureAwait(false);
}
if (cookieTokenResponse.IsOk())
{
@@ -265,14 +318,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

@@ -47,9 +47,18 @@ internal sealed partial class SignInWebViewDialog : ContentDialog
return;
}
coreWebView2.SetCookie(user.CookieToken, user.LToken, null).SetMobileUserAgent();
signInJsInterface = new(coreWebView2, scope.ServiceProvider);
coreWebView2.Navigate("https://webstatic.mihoyo.com/bbs/event/signin-ys/index.html?act_id=e202009291139501");
if (user.Entity.IsOversea)
{
coreWebView2.SetCookie(user.CookieToken, user.LToken, null).SetOsMobileUserAgent();
signInJsInterface = new(coreWebView2, scope.ServiceProvider);
coreWebView2.Navigate("https://act.hoyolab.com/ys/event/signin-sea-v3/index.html?act_id=e202102251931481&hyl_presentation_style=fullscreen");
}
else
{
coreWebView2.SetCookie(user.CookieToken, user.LToken, null).SetMobileUserAgent();
signInJsInterface = new(coreWebView2, scope.ServiceProvider);
coreWebView2.Navigate("https://webstatic.mihoyo.com/bbs/event/signin-ys/index.html?act_id=e202009291139501");
}
}
private void OnContentDialogClosed(ContentDialog sender, ContentDialogClosedEventArgs args)

View File

@@ -0,0 +1,50 @@
<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}"/>
<Grid
Grid.Row="0"
Margin="16"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Width="240"
MaxLength="9"
x:Name="UidInput"
Margin="0,0,16,0"
PlaceholderText="Please input your user id here."
HorizontalAlignment="Right"/>
<Button
Grid.Column="1"
RelativePanel.RightOf="UidInput"
Click="CookieButtonClick"
Content="{shcm:ResourceString Name=ViewPageLoginMihoyoUserLoggedInAction}"/>
</Grid>
<WebView2
x:Name="WebView"
Grid.Row="2"
Margin="0,0,0,0"/>
</Grid>
</Page>

View File

@@ -0,0 +1,124 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.UI.Xaml;
using Microsoft.Web.WebView2.Core;
using Snap.Hutao.Service.Abstraction;
using Snap.Hutao.Service.Navigation;
using Snap.Hutao.Service.User;
using Snap.Hutao.View.Dialog;
using Snap.Hutao.Web.Hoyolab;
using Snap.Hutao.Web.Hoyolab.Passport;
using Snap.Hutao.Web.Hoyolab.Takumi.Auth;
using Snap.Hutao.Web.Response;
using System.Diagnostics.Eventing.Reader;
namespace Snap.Hutao.View.Page;
/// <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://account.hoyolab.com");
foreach (CoreWebView2Cookie item in cookies)
{
manager.DeleteCookie(item);
}
WebView.CoreWebView2.Navigate("https://account.hoyolab.com/#/login");
}
catch (Exception ex)
{
Ioc.Default.GetRequiredService<IInfoBarService>().Error(ex);
}
}
private async Task HandleCurrentCookieAsync(CancellationToken token)
{
CoreWebView2CookieManager manager = WebView.CoreWebView2.CookieManager;
IReadOnlyList<CoreWebView2Cookie> cookies = await manager.GetCookiesAsync("https://account.hoyolab.com");
// Get user id from text input, login_uid is missed in cookie
string uid = UidInput.Text;
if (uid.Length != 9)
{
return;
}
Cookie loginTicketCookie = Cookie.FromCoreWebView2Cookies(cookies);
loginTicketCookie["login_uid"] = uid;
// ʹ<><CAB9> loginTicket <20><>ȡ stoken
Response<ListWrapper<NameToken>> multiTokenResponse = await Ioc.Default
.GetRequiredService<AuthClientOs>()
.GetMultiTokenByLoginTicketAsync(loginTicketCookie, token)
.ConfigureAwait(false);
if (!multiTokenResponse.IsOk())
{
return;
}
Dictionary<string, string> multiTokenMap = multiTokenResponse.Data.List.ToDictionary(n => n.Name, n => n.Token);
Cookie hoyoLabCookie = Cookie.Parse($"stoken={multiTokenMap["stoken"]}; stuid={uid}");
// <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> user id <20><><EFBFBD>󣬲<EFBFBD><F3A3ACB2><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

@@ -247,9 +247,17 @@ internal sealed class DailyNoteViewModel : Abstraction.ViewModel
{
if (UserAndUid.TryFromUser(userService.Current, out UserAndUid? userAndUid))
{
// ContentDialog must be created by main thread.
await ThreadHelper.SwitchToMainThreadAsync();
await new DailyNoteVerificationDialog(userAndUid).ShowAsync();
// TODO: Add verify support for oversea user
if (userAndUid.User.IsOversea)
{
serviceProvider.GetRequiredService<IInfoBarService>().Warning("Unsupported for hoyoverse account");
}
else
{
// ContentDialog must be created by main thread.
await ThreadHelper.SwitchToMainThreadAsync();
await new DailyNoteVerificationDialog(userAndUid).ShowAsync();
}
}
else
{

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,11 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.EntityFrameworkCore.Sqlite.Query.Internal;
using Microsoft.UI.Xaml;
using Snap.Hutao.Model.Primitive;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Web.Hoyolab;
namespace Snap.Hutao.Web;
@@ -13,6 +17,178 @@ namespace Snap.Hutao.Web;
[SuppressMessage("", "SA1124")]
internal static class ApiOsEndpoints
{
#region ApiGeetest
/// <summary>
/// 获取GT码
/// </summary>
/// <param name="gt">gt</param>
/// <returns>GT码Url</returns>
public static string GeetestGetType(string gt)
{
return $"{ApiNaGeetest}/gettype.php?gt={gt}";
}
/// <summary>
/// 验证接口
/// </summary>
/// <param name="gt">gt</param>
/// <param name="challenge">challenge流水号</param>
/// <returns>验证接口Url</returns>
public static string GeetestAjax(string gt, string challenge)
{
return $"{ApiNaGeetest}/ajax.php?gt={gt}&challenge={challenge}&lang=zh-cn&pt=0&client_type=web";
}
#endregion
#region ApiOsTakumiAuthApi
/// <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 $"{ApiAccountOsAuthApi}/getMultiTokenByLoginTicket?login_ticket={loginTicket}&uid={loginUid}&token_types=3";
}
/// <summary>
/// 获取 stoken 与 ltoken
/// </summary>
/// <param name="actionType">操作类型 game_role</param>
/// <param name="stoken">Stoken</param>
/// <param name="uid">uid</param>
/// <returns>Url</returns>
public static string AuthActionTicket(string actionType, string stoken, string uid)
{
return $"{ApiAccountOsAuthApi}/getActionTicketBySToken?action_type={actionType}&stoken={Uri.EscapeDataString(stoken)}&uid={uid}";
}
#endregion
#region ApiOsTaKumiApi
/// <summary>
/// 用户游戏角色
/// </summary>
/// <returns>用户游戏角色字符串</returns>
public const string UserGameRolesByCookie = $"{ApiOsTaKumiBindingApi}/getUserGameRolesByCookie?game_biz=hk4e_global";
/// <summary>
/// 用户游戏角色
/// </summary>
/// <param name="region">地区代号</param>
/// <returns>用户游戏角色字符串</returns>
public static string UserGameRolesByLtoken(string region)
{
return $"{ApiAccountOsBindingApi}/getUserGameRolesByLtoken?game_biz=hk4e_global&region={region}";
}
#endregion
#region SgPublicApi
/// <summary>
/// 计算器家具计算
/// </summary>
public const string CalculateOsFurnitureCompute = $"{SgPublicApi}/event/calculateos/furniture/list";
/// <summary>
/// 计算器角色列表 size 20
/// </summary>
public const string CalculateOsAvatarList = $"{SgPublicApi}/event/calculateos/avatar/list";
/// <summary>
/// 计算器武器列表 size 20
/// </summary>
public const string CalculateOsWeaponList = $"{SgPublicApi}/event/calculateos/weapon/list";
/// <summary>
/// 计算器结果
/// </summary>
public const string CalculateOsCompute = $"{SgPublicApi}/event/calculateos/compute";
/// <summary>
/// 计算器同步角色详情 size 20
/// </summary>
/// <param name="avatarId">角色Id</param>
/// <param name="uid">uid</param>
/// <returns>角色详情</returns>
public static string CalculateOsSyncAvatarDetail(AvatarId avatarId, PlayerUid uid)
{
return $"{SgPublicApi}/event/calculateos/sync/avatar/detail?avatar_id={avatarId.Value}&uid={uid.Value}&region={uid.Region}";
}
/// <summary>
/// 计算器同步角色列表 size 20
/// </summary>
public const string CalculateOsSyncAvatarList = $"{SgPublicApi}/event/calculateos/sync/avatar/list";
#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>
@@ -26,6 +202,25 @@ internal static class ApiOsEndpoints
}
#endregion
#region ApiAccountOsApi
/// <summary>
/// Hoyolab App Login api
/// Can fetch stoken
/// </summary>
public const string WebLoginByPassword = $"{ApiAccountOsAuthApi}/webLoginByPassword";
/// <summary>
/// 获取 Ltoken
/// </summary>
public const string AccountGetLtokenByStoken = $"{ApiAccountOsAuthApi}/getLTokenBySToken";
/// <summary>
/// fetch CookieToken
/// </summary>
public const string AccountGetCookieTokenBySToken = $"{ApiAccountOsAuthApi}/getCookieAccountInfoBySToken";
#endregion
#region SdkStaticLauncherApi
/// <summary>
@@ -40,10 +235,31 @@ internal static class ApiOsEndpoints
#endregion
#region Hosts | Queries
private const string ApiNaGeetest = "https://api-na.geetest.com";
private const string ApiOsTaKumi = "https://api-os-takumi.hoyoverse.com";
private const string ApiOsTaKumiBindingApi = $"{ApiOsTaKumi}/binding/api";
private const string ApiAccountOs = "https://api-account-os.hoyolab.com";
private const string ApiAccountOsBindingApi = $"{ApiAccountOs}/binding/api";
private const string ApiAccountOsAuthApi = $"{ApiAccountOs}/account/auth/api";
private const string BbsApiOs = "https://bbs-api-os.hoyolab.com";
private const string BbsApiOsGameRecordApi = $"{BbsApiOs}/game_record/genshin/api";
private const string Hk4eApiOs = "https://hk4e-api-os.hoyoverse.com";
private const string Hk4eApiOsGachaInfoApi = $"{Hk4eApiOs}/event/gacha_info/api";
private const string SdkOsStatic = "https://sdk-os-static.mihoyo.com";
private const string SdkOsStaticLauncherApi = $"{SdkOsStatic}/hk4e_global/mdk/launcher/api";
private const string SgPublicApi = "https://sg-public-api.hoyolab.com";
/// <summary>
/// Web static referer
/// </summary>
public const string WebStaticSeaMihoyoReferer = "https://webstatic-sea.mihoyo.com";
public const string ActHoyolabReferer = "https://act.hoyolab.com/";
#endregion
}

View File

@@ -23,6 +23,17 @@ internal static class CoreWebView2Extension
return webView;
}
/// <summary>
/// 设置 移动端UA
/// </summary>
/// <param name="webView">webview2</param>
/// <returns>链式调用的WebView2</returns>
public static CoreWebView2 SetOsMobileUserAgent(this CoreWebView2 webView)
{
webView.Settings.UserAgent = Core.CoreEnvironment.HoyolabOsMobileUA;
return webView;
}
/// <summary>
/// 设置WebView2的Cookie
/// </summary>

View File

@@ -52,4 +52,22 @@ internal sealed class UserClient
return Response.Response.DefaultIfNull(resp);
}
/// <summary>
/// 获取当前用户详细信息,使用 Ltoken
/// </summary>
/// <param name="user">用户</param>
/// <param name="token">取消令牌</param>
/// <returns>详细信息</returns>
[ApiInformation(Cookie = CookieType.LToken, Salt = SaltType.OS)]
public async Task<Response<UserFullInfoWrapper>> GetOsUserFullInfoAsync(Model.Entity.User user, CancellationToken token = default)
{
Response<UserFullInfoWrapper>? resp = await httpClient
.SetUser(user, CookieType.LToken)
.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

@@ -116,6 +116,31 @@ internal sealed partial class Cookie
return false;
}
/// <summary>
/// 提取其中的 stoken 信息
/// </summary>
/// <param name="cookie">含有 Stoken 的 cookie</param>
/// <returns>是否获取成功</returns>
public bool TryGetAsStokenV1([NotNullWhen(true)] out Cookie? cookie)
{
bool hasStoken = TryGetValue(STOKEN, out string? stoken);
bool hasStuid = TryGetValue(STUID, out string? stuid);
if (hasStoken && hasStuid)
{
cookie = new Cookie(new()
{
[STOKEN] = stoken!,
[STUID] = stuid!,
});
return true;
}
cookie = null;
return false;
}
public bool TryGetAsLtoken([NotNullWhen(true)] out Cookie? cookie)
{
bool hasLtoken = TryGetValue(LTOKEN, out string? ltoken);

View File

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

View File

@@ -0,0 +1,103 @@
using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Web.Hoyolab.Annotation;
using Snap.Hutao.Web.Hoyolab.DynamicSecret;
using Snap.Hutao.Web.Response;
using System.Net.Http;
using System.Net.Http.Json;
using Windows.ApplicationModel.Contacts;
namespace Snap.Hutao.Web.Hoyolab.Passport;
/// <summary>
/// 通行证客户端 XRPC 版
/// </summary>
[HighQuality]
[UseDynamicSecret]
[HttpClient(HttpClientConfigration.XRpc3)]
internal sealed class PassportClientOs
{
private readonly HttpClient httpClient;
private readonly JsonSerializerOptions options;
private readonly ILogger<PassportClient> logger;
/// <summary>
/// 构造一个新的国际服通行证客户端
/// </summary>
/// <param name="httpClient">http客户端</param>
/// <param name="options">json序列化选项</param>
/// <param name="logger">日志器</param>
public PassportClientOs(HttpClient httpClient, JsonSerializerOptions options, ILogger<PassportClient> logger)
{
this.httpClient = httpClient;
this.options = options;
this.logger = logger;
}
/// <summary>
/// 异步获取 CookieToken
/// </summary>
/// <param name="user">用户</param>
/// <param name="token">取消令牌</param>
/// <returns>cookie token</returns>
[ApiInformation(Cookie = CookieType.SToken, Salt = SaltType.None)]
public async Task<Response<UidCookieToken>> GetCookieAccountInfoBySTokenAsync(User user, CancellationToken token = default)
{
Response<UidCookieToken>? resp = null;
string? stoken = user.SToken["stoken"];
if (stoken == null || user.Aid == null)
{
return Response.Response.DefaultIfNull(resp);
}
StokenData data = new(stoken, user.Aid);
resp = await httpClient
.SetUser(user, CookieType.SToken)
.TryCatchPostAsJsonAsync<StokenData, Response<UidCookieToken>>(ApiOsEndpoints.AccountGetCookieTokenBySToken, data, options, logger, token)
.ConfigureAwait(false);
return Response.Response.DefaultIfNull(resp);
}
/// <summary>
/// 异步获取 Ltoken
/// </summary>
/// <param name="user">用户</param>
/// <param name="token">取消令牌</param>
/// <returns>uid 与 cookie token</returns>
[ApiInformation(Cookie = CookieType.SToken, Salt = SaltType.None)]
public async Task<Response<LtokenWrapper>> GetLtokenBySTokenAsync(User user, CancellationToken token)
{
Response<LtokenWrapper>? resp = null;
string? stoken = user.SToken["stoken"];
if (stoken == null || user.Aid == null)
{
return Response.Response.DefaultIfNull(resp);
}
StokenData data = new(stoken, user.Aid);
resp = await httpClient
.SetUser(user, CookieType.SToken)
.TryCatchPostAsJsonAsync<StokenData, Response<LtokenWrapper>>(ApiOsEndpoints.AccountGetLtokenByStoken, data, options, logger, token)
.ConfigureAwait(false);
return Response.Response.DefaultIfNull(resp);
}
private class StokenData
{
public StokenData(string stoken, string uid)
{
Stoken = stoken;
Uid = uid;
}
[JsonPropertyName("stoken")]
public string Stoken { get; set; }
[JsonPropertyName("uid")]
public string Uid { get; set; }
}
}

View File

@@ -0,0 +1,53 @@
using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Web.Hoyolab.Annotation;
using Snap.Hutao.Web.Hoyolab.DynamicSecret;
using Snap.Hutao.Web.Hoyolab.Takumi.Binding;
using Snap.Hutao.Web.Response;
using System.Net.Http;
namespace Snap.Hutao.Web.Hoyolab.Takumi.Auth;
/// <summary>
/// Hoyolab 授权客户端
/// </summary>
[HighQuality]
[UseDynamicSecret]
[HttpClient(HttpClientConfigration.Default)]
internal sealed class AuthClientOs
{
private readonly HttpClient httpClient;
private readonly JsonSerializerOptions options;
private readonly ILogger<BindingClient> logger;
/// <summary>
/// 构造一个新的 Hoyolab 授权客户端
/// </summary>
/// <param name="httpClient">Http客户端</param>
/// <param name="options">Json序列化选项</param>
/// <param name="logger">日志器</param>
public AuthClientOs(HttpClient httpClient, JsonSerializerOptions options, ILogger<BindingClient> logger)
{
this.httpClient = httpClient;
this.options = options;
this.logger = logger;
}
/// <summary>
/// 获取 MultiToken
/// </summary>
/// <param name="cookie">login cookie</param>
/// <param name="token">取消令牌</param>
/// <returns>包含token的字典</returns>
public async Task<Response<ListWrapper<NameToken>>> GetMultiTokenByLoginTicketAsync(Cookie cookie, CancellationToken token)
{
string loginTicket = cookie["login_ticket"];
string loginUid = cookie["login_uid"];
Response<ListWrapper<NameToken>>? resp = await httpClient
.TryCatchGetFromJsonAsync<Response<ListWrapper<NameToken>>>(ApiOsEndpoints.AuthMultiToken(loginTicket, loginUid), options, logger, token)
.ConfigureAwait(false);
return Response.Response.DefaultIfNull(resp);
}
}

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

@@ -43,11 +43,24 @@ internal sealed class CalculateClient
[ApiInformation(Cookie = CookieType.Cookie)]
public async Task<Response<Consumption>> ComputeAsync(Model.Entity.User user, AvatarPromotionDelta delta, CancellationToken token = default)
{
Response<Consumption>? resp = await httpClient
Response<Consumption>? resp;
if (user.IsOversea)
{
resp = await httpClient
.SetUser(user, CookieType.Cookie)
.SetReferer(ApiOsEndpoints.ActHoyolabReferer)
.TryCatchPostAsJsonAsync<AvatarPromotionDelta, Response<Consumption>>(ApiOsEndpoints.CalculateOsCompute, delta, options, logger, token)
.ConfigureAwait(false);
}
else
{
resp = await httpClient
.SetUser(user, CookieType.Cookie)
.SetReferer(ApiEndpoints.WebStaticMihoyoReferer)
.TryCatchPostAsJsonAsync<AvatarPromotionDelta, Response<Consumption>>(ApiEndpoints.CalculateCompute, delta, options, logger, token)
.ConfigureAwait(false);
}
return Response.Response.DefaultIfNull(resp);
}
@@ -65,14 +78,28 @@ internal sealed class CalculateClient
List<Avatar> avatars = new();
Response<ListWrapper<Avatar>>? resp;
httpClient.SetUser(userAndUid.User, CookieType.CookieToken);
// 根据 uid 所属服务器选择 referer 与 api
string referer = ApiOsEndpoints.ActHoyolabReferer;
string endpoint = ApiOsEndpoints.CalculateOsSyncAvatarList;
if (userAndUid.Uid.Region == "cn_gf01" || userAndUid.Uid.Region == "cn_qd01")
{
referer = ApiEndpoints.WebStaticMihoyoReferer;
endpoint = ApiEndpoints.CalculateSyncAvatarList;
httpClient.SetUser(userAndUid.User, CookieType.CookieToken);
}
else
{
httpClient.SetUser(userAndUid.User, CookieType.Cookie);
}
do
{
filter.Page = currentPage++;
resp = await httpClient
.SetReferer(ApiEndpoints.WebStaticMihoyoReferer)
.TryCatchPostAsJsonAsync<SyncAvatarFilter, Response<ListWrapper<Avatar>>>(ApiEndpoints.CalculateSyncAvatarList, filter, options, logger, token)
.SetReferer(referer)
.TryCatchPostAsJsonAsync<SyncAvatarFilter, Response<ListWrapper<Avatar>>>(endpoint, filter, options, logger, token)
.ConfigureAwait(false);
if (resp != null && resp.IsOk())
@@ -101,11 +128,21 @@ internal sealed class CalculateClient
/// <returns>角色详情</returns>
public async Task<Response<AvatarDetail>> GetAvatarDetailAsync(UserAndUid userAndUid, Avatar avatar, CancellationToken token = default)
{
Response<AvatarDetail>? resp = await httpClient
Response<AvatarDetail>? resp;
if (userAndUid.Uid.Region == "cn_gf01" || userAndUid.Uid.Region == "cn_qd01")
{
resp = await httpClient
.SetUser(userAndUid.User, CookieType.CookieToken)
.TryCatchGetFromJsonAsync<Response<AvatarDetail>>(ApiEndpoints.CalculateSyncAvatarDetail(avatar.Id, userAndUid.Uid.Value), options, logger, token)
.ConfigureAwait(false);
}
else
{
resp = await httpClient
.SetUser(userAndUid.User, CookieType.Cookie)
.TryCatchGetFromJsonAsync<Response<AvatarDetail>>(ApiOsEndpoints.CalculateOsSyncAvatarDetail(avatar.Id, userAndUid.Uid.Value), 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; }
}
}