From 332722c860ca8ffba5e698b87cebd3ab070ca6d4 Mon Sep 17 00:00:00 2001
From: DismissedLight <1686188646@qq.com>
Date: Mon, 5 Sep 2022 13:01:36 +0800
Subject: [PATCH] async queued database logger
---
.../Snap.Hutao.Win32/Snap.Hutao.Win32.csproj | 4 +
.../UI/WindowsAndMessaging/MINMAXINFO.cs | 8 +
src/Snap.Hutao/Snap.Hutao.Win32/Unsafe.cs | 22 --
.../Snap.Hutao/Core/CoreEnvironment.cs | 15 +-
.../Snap.Hutao/Core/Logging/DatebaseLogger.cs | 3 +-
.../Snap.Hutao/Core/Logging/LogEntry.cs | 5 +
.../Snap.Hutao/Core/Logging/LogEntryQueue.cs | 85 +++---
.../DispatherQueueSwitchOperation.cs | 13 +-
.../Snap.Hutao/Core/Threading/Watcher.cs | 80 -----
.../Core/Windowing/WindowSubclassManager.cs | 7 +-
.../Extension/EnumerableExtensions.cs | 21 ++
src/Snap.Hutao/Snap.Hutao/GlobalUsing.cs | 1 +
.../LogDb/20220903071033_LogTime.Designer.cs | 55 ++++
.../LogDb/20220903071033_LogTime.cs | 27 ++
.../LogDb/LogDbContextModelSnapshot.cs | 7 +-
src/Snap.Hutao/Snap.Hutao/Program.cs | 4 +-
.../Service/Achievement/AchievementService.cs | 4 +-
.../Service/GachaLog/GachaLogService.cs | 12 +
.../Snap.Hutao/Web/Hoyolab/ApiEndpoints.cs | 67 ++--
.../DynamicSecret/DynamicSecretProvider.cs | 2 +-
.../Hk4e/Event/GachaInfo/GachaConfigType.cs | 35 +++
.../Hk4e/Event/GachaInfo/GachaInfoClient.cs | 41 +++
.../Event/GachaInfo/GachaLogConfigration.cs | 63 ++++
.../Hk4e/Event/GachaInfo/GachaLogItem.cs | 77 +++++
.../Hk4e/Event/GachaInfo/GachaLogPage.cs | 42 +++
.../Web/Request/QueryString/QueryString.cs | 288 ++++++++++++++++++
.../QueryString/QueryStringParameter.cs | 37 +++
27 files changed, 798 insertions(+), 227 deletions(-)
create mode 100644 src/Snap.Hutao/Snap.Hutao.Win32/UI/WindowsAndMessaging/MINMAXINFO.cs
delete mode 100644 src/Snap.Hutao/Snap.Hutao.Win32/Unsafe.cs
delete mode 100644 src/Snap.Hutao/Snap.Hutao/Core/Threading/Watcher.cs
create mode 100644 src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/20220903071033_LogTime.Designer.cs
create mode 100644 src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/20220903071033_LogTime.cs
create mode 100644 src/Snap.Hutao/Snap.Hutao/Service/GachaLog/GachaLogService.cs
create mode 100644 src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaConfigType.cs
create mode 100644 src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaInfoClient.cs
create mode 100644 src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogConfigration.cs
create mode 100644 src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogItem.cs
create mode 100644 src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogPage.cs
create mode 100644 src/Snap.Hutao/Snap.Hutao/Web/Request/QueryString/QueryString.cs
create mode 100644 src/Snap.Hutao/Snap.Hutao/Web/Request/QueryString/QueryStringParameter.cs
diff --git a/src/Snap.Hutao/Snap.Hutao.Win32/Snap.Hutao.Win32.csproj b/src/Snap.Hutao/Snap.Hutao.Win32/Snap.Hutao.Win32.csproj
index 10c2cd7a..465625c5 100644
--- a/src/Snap.Hutao/Snap.Hutao.Win32/Snap.Hutao.Win32.csproj
+++ b/src/Snap.Hutao/Snap.Hutao.Win32/Snap.Hutao.Win32.csproj
@@ -14,4 +14,8 @@
+
+
+
+
diff --git a/src/Snap.Hutao/Snap.Hutao.Win32/UI/WindowsAndMessaging/MINMAXINFO.cs b/src/Snap.Hutao/Snap.Hutao.Win32/UI/WindowsAndMessaging/MINMAXINFO.cs
new file mode 100644
index 00000000..40ff153e
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao.Win32/UI/WindowsAndMessaging/MINMAXINFO.cs
@@ -0,0 +1,8 @@
+namespace Windows.Win32.UI.WindowsAndMessaging;
+public partial struct MINMAXINFO
+{
+ public static unsafe ref MINMAXINFO FromPointer(nint value)
+ {
+ return ref *(MINMAXINFO*)value;
+ }
+}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao.Win32/Unsafe.cs b/src/Snap.Hutao/Snap.Hutao.Win32/Unsafe.cs
deleted file mode 100644
index 1c57ab21..00000000
--- a/src/Snap.Hutao/Snap.Hutao.Win32/Unsafe.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Windows.Win32.UI.WindowsAndMessaging;
-
-namespace Snap.Hutao.Win32;
-
-///
-/// 包装不安全的代码
-///
-public class Unsafe
-{
- ///
- /// 使用指针操作简化封送
- ///
- /// lParam
- /// 最小宽度
- /// 最小高度
- public static unsafe void SetMinTrackSize(nint lParam, double minWidth, double minHeight)
- {
- MINMAXINFO* info = (MINMAXINFO*)lParam;
- info->ptMinTrackSize.x = (int)Math.Max(minWidth, info->ptMinTrackSize.x);
- info->ptMinTrackSize.y = (int)Math.Max(minHeight, info->ptMinTrackSize.y);
- }
-}
diff --git a/src/Snap.Hutao/Snap.Hutao/Core/CoreEnvironment.cs b/src/Snap.Hutao/Snap.Hutao/Core/CoreEnvironment.cs
index af60d300..9863f0fa 100644
--- a/src/Snap.Hutao/Snap.Hutao/Core/CoreEnvironment.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Core/CoreEnvironment.cs
@@ -11,20 +11,11 @@ namespace Snap.Hutao.Core;
///
internal static class CoreEnvironment
{
- // Used DS1 History
- // 2.34.1 9nQiU3AV0rJSIBWgdynfoGMGKaklfbM7
- // 2.35.2 N50pqm7FSy2AkFz2B3TqtuZMJ5TOl3Ep
-
///
- /// 动态密钥1的盐
- ///
- public const string DynamicSecret1Salt = "N50pqm7FSy2AkFz2B3TqtuZMJ5TOl3Ep";
-
- ///
- /// 动态密钥2的盐
+ /// 动态密钥的盐
/// 计算过程:https://gist.github.com/Lightczx/373c5940b36e24b25362728b52dec4fd
///
- public const string DynamicSecret2Salt = "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs";
+ public const string DynamicSecretSalt = "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs";
///
/// 米游社请求UA
@@ -34,7 +25,7 @@ internal static class CoreEnvironment
///
/// 米游社 Rpc 版本
///
- public const string HoyolabXrpcVersion = "2.35.2";
+ public const string HoyolabXrpcVersion = "2.36.1";
///
/// 标准UA
diff --git a/src/Snap.Hutao/Snap.Hutao/Core/Logging/DatebaseLogger.cs b/src/Snap.Hutao/Snap.Hutao/Core/Logging/DatebaseLogger.cs
index 26988044..c961e1ac 100644
--- a/src/Snap.Hutao/Snap.Hutao/Core/Logging/DatebaseLogger.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Core/Logging/DatebaseLogger.cs
@@ -1,8 +1,6 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
-using Snap.Hutao.Context.Database;
-
namespace Snap.Hutao.Core.Logging;
///
@@ -54,6 +52,7 @@ internal sealed partial class DatebaseLogger : ILogger
LogEntry entry = new()
{
+ Time = DateTimeOffset.Now,
Category = name,
LogLevel = logLevel,
EventId = eventId.Id,
diff --git a/src/Snap.Hutao/Snap.Hutao/Core/Logging/LogEntry.cs b/src/Snap.Hutao/Snap.Hutao/Core/Logging/LogEntry.cs
index cbc4b4d6..6c43298f 100644
--- a/src/Snap.Hutao/Snap.Hutao/Core/Logging/LogEntry.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Core/Logging/LogEntry.cs
@@ -19,6 +19,11 @@ public class LogEntry
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid InnerId { get; set; }
+ ///
+ /// 日志时间
+ ///
+ public DateTimeOffset Time { get; set; }
+
///
/// 类别
///
diff --git a/src/Snap.Hutao/Snap.Hutao/Core/Logging/LogEntryQueue.cs b/src/Snap.Hutao/Snap.Hutao/Core/Logging/LogEntryQueue.cs
index bacdacbd..9733ab00 100644
--- a/src/Snap.Hutao/Snap.Hutao/Core/Logging/LogEntryQueue.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Core/Logging/LogEntryQueue.cs
@@ -4,6 +4,7 @@
using Microsoft.EntityFrameworkCore;
using Snap.Hutao.Context.Database;
using Snap.Hutao.Context.FileSystem;
+using Snap.Hutao.Extension;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
@@ -15,50 +16,19 @@ namespace Snap.Hutao.Core.Logging;
///
public sealed class LogEntryQueue : IDisposable
{
- private static readonly object LogDbContextCreationLock = new();
-
private readonly ConcurrentQueue entryQueue = new();
- private readonly CancellationTokenSource disposeCancellationTokenSource = new();
- private readonly TaskCompletionSource writeDbTaskCompletionSource = new();
-
- // the provider is created per logger, we don't want to create too much
- private volatile LogDbContext? logDbContext;
+ private readonly CancellationTokenSource disposeTokenSource = new();
+ private readonly TaskCompletionSource writeDbCompletionSource = new();
+ private readonly LogDbContext logDbContext;
///
/// 构造一个新的日志队列
///
public LogEntryQueue()
{
- Execute();
- }
+ logDbContext = InitializeDbContext();
- private LogDbContext LogDbContext
- {
- get
- {
- if (logDbContext == null)
- {
- lock (LogDbContextCreationLock)
- {
- // prevent re-entry call
- if (logDbContext == null)
- {
- HutaoContext myDocument = new(new());
- logDbContext = LogDbContext.Create($"Data Source={myDocument.Locate("Log.db")}");
- if (logDbContext.Database.GetPendingMigrations().Any())
- {
- Debug.WriteLine("[Debug] Performing LogDbContext Migrations");
- logDbContext.Database.Migrate();
- }
-
- logDbContext.Logs.RemoveRange(logDbContext.Logs);
- logDbContext.SaveChanges();
- }
- }
- }
-
- return logDbContext;
- }
+ Task.Run(async () => await WritePendingLogsAsync(disposeTokenSource.Token)).SafeForget();
}
///
@@ -74,45 +44,62 @@ public sealed class LogEntryQueue : IDisposable
[SuppressMessage("", "VSTHRD002")]
public void Dispose()
{
- disposeCancellationTokenSource.Cancel();
- writeDbTaskCompletionSource.Task.GetAwaiter().GetResult();
+ // notify the write task to complete.
+ disposeTokenSource.Cancel();
- LogDbContext.Dispose();
+ // Wait the db operation complete.
+ writeDbCompletionSource.Task.GetAwaiter().GetResult();
+
+ logDbContext.Dispose();
}
- [SuppressMessage("", "VSTHRD100")]
- private async void Execute()
+ private static LogDbContext InitializeDbContext()
{
- await Task.Run(async () => await ExecuteCoreAsync(disposeCancellationTokenSource.Token));
+ HutaoContext myDocument = new(new());
+ LogDbContext logDbContext = LogDbContext.Create($"Data Source={myDocument.Locate("Log.db")}");
+ if (logDbContext.Database.GetPendingMigrations().Any())
+ {
+ Debug.WriteLine("[Debug] Performing LogDbContext Migrations");
+ logDbContext.Database.Migrate();
+ }
+
+ logDbContext.Logs.RemoveRange(logDbContext.Logs);
+ logDbContext.SaveChanges();
+
+ return logDbContext;
}
- private async Task ExecuteCoreAsync(CancellationToken token)
+ private async Task WritePendingLogsAsync(CancellationToken token)
{
bool hasAdded = false;
while (true)
{
if (entryQueue.TryDequeue(out LogEntry? logEntry))
{
- LogDbContext.Logs.Add(logEntry);
+ logDbContext.Logs.Add(logEntry);
hasAdded = true;
}
else
{
if (hasAdded)
{
- LogDbContext.SaveChanges();
+ logDbContext.SaveChanges();
hasAdded = false;
}
if (token.IsCancellationRequested)
{
- writeDbTaskCompletionSource.TrySetResult();
+ writeDbCompletionSource.TrySetResult();
break;
}
- await Task
- .Delay(1000, CancellationToken.None)
- .ConfigureAwait(false);
+ try
+ {
+ await Task.Delay(5000, token).ConfigureAwait(false);
+ }
+ catch (TaskCanceledException)
+ {
+ }
}
}
}
diff --git a/src/Snap.Hutao/Snap.Hutao/Core/Threading/DispatherQueueSwitchOperation.cs b/src/Snap.Hutao/Snap.Hutao/Core/Threading/DispatherQueueSwitchOperation.cs
index 2a86b9da..e1ea3071 100644
--- a/src/Snap.Hutao/Snap.Hutao/Core/Threading/DispatherQueueSwitchOperation.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Core/Threading/DispatherQueueSwitchOperation.cs
@@ -14,17 +14,15 @@ public struct DispatherQueueSwitchOperation : IAwaitable
- /// 构造一个新的同步上下文等待器
+ /// 构造一个新的调度器队列等待器
///
- /// 同步上下文
+ /// 调度器队列
public DispatherQueueSwitchOperation(DispatcherQueue dispatherQueue)
{
this.dispatherQueue = dispatherQueue;
}
- ///
- /// 是否完成
- ///
+ ///
public bool IsCompleted
{
get => dispatherQueue.HasThreadAccess;
@@ -41,10 +39,7 @@ public struct DispatherQueueSwitchOperation : IAwaitable
- /// 获取等待器
- ///
- /// 等待器
+ ///
public DispatherQueueSwitchOperation GetAwaiter()
{
return this;
diff --git a/src/Snap.Hutao/Snap.Hutao/Core/Threading/Watcher.cs b/src/Snap.Hutao/Snap.Hutao/Core/Threading/Watcher.cs
deleted file mode 100644
index 70822137..00000000
--- a/src/Snap.Hutao/Snap.Hutao/Core/Threading/Watcher.cs
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (c) DGP Studio. All rights reserved.
-// Licensed under the MIT license.
-
-using Snap.Hutao.Model;
-
-namespace Snap.Hutao.Core.Threading;
-
-///
-/// 提供简单易用的状态提示信息
-/// 用于任务的状态跟踪
-/// 同时继承了
-///
-public class Watcher : Observable
-{
- private readonly bool isReusable;
- private bool hasUsed;
- private bool isWorking;
- private bool isCompleted;
-
- ///
- /// 构造一个新的工作监视器
- ///
- /// 是否可以重用
- public Watcher(bool isReusable = true)
- {
- this.isReusable = isReusable;
- }
-
- ///
- /// 是否正在工作
- ///
- public bool IsWorking
- {
- get => isWorking;
-
- private set => Set(ref isWorking, value);
- }
-
- ///
- /// 工作是否完成
- ///
- public bool IsCompleted
- {
- get => isCompleted;
-
- private set => Set(ref isCompleted, value);
- }
-
- ///
- /// 对某个操作进行监视,
- ///
- /// 一个可释放的对象,用于在操作完成时自动提示监视器工作已经完成
- /// 重用了一个不可重用的监视器
- public IDisposable Watch()
- {
- Verify.Operation(!IsWorking, $"此 {nameof(Watcher)} 已经处于检查状态");
- Verify.Operation(isReusable || !hasUsed, $"此 {nameof(Watcher)} 不允许多次使用");
-
- hasUsed = true;
- IsWorking = true;
-
- return new WatchDisposable(this);
- }
-
- private struct WatchDisposable : IDisposable
- {
- private readonly Watcher watcher;
-
- public WatchDisposable(Watcher watcher)
- {
- this.watcher = watcher;
- }
-
- public void Dispose()
- {
- watcher.IsWorking = false;
- watcher.IsCompleted = true;
- }
- }
-}
diff --git a/src/Snap.Hutao/Snap.Hutao/Core/Windowing/WindowSubclassManager.cs b/src/Snap.Hutao/Snap.Hutao/Core/Windowing/WindowSubclassManager.cs
index 4874d8d8..9b30785d 100644
--- a/src/Snap.Hutao/Snap.Hutao/Core/Windowing/WindowSubclassManager.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Core/Windowing/WindowSubclassManager.cs
@@ -1,9 +1,12 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
+using System;
using System.Diagnostics;
+using System.Runtime.CompilerServices;
using Windows.Win32.Foundation;
using Windows.Win32.UI.Shell;
+using Windows.Win32.UI.WindowsAndMessaging;
using static Windows.Win32.PInvoke;
namespace Snap.Hutao.Core.Windowing;
@@ -85,7 +88,9 @@ internal class WindowSubclassManager : IDisposable
case WM_GETMINMAXINFO:
{
double scalingFactor = Persistence.GetScaleForWindow(hwnd);
- Win32.Unsafe.SetMinTrackSize(lParam, MinWidth * scalingFactor, MinHeight * scalingFactor);
+ ref MINMAXINFO info = ref MINMAXINFO.FromPointer(lParam);
+ info.ptMinTrackSize.x = (int)Math.Max(MinWidth * scalingFactor, info.ptMinTrackSize.x);
+ info.ptMinTrackSize.y = (int)Math.Max(MinHeight * scalingFactor, info.ptMinTrackSize.y);
break;
}
diff --git a/src/Snap.Hutao/Snap.Hutao/Extension/EnumerableExtensions.cs b/src/Snap.Hutao/Snap.Hutao/Extension/EnumerableExtensions.cs
index 4f6722bd..0b3a1793 100644
--- a/src/Snap.Hutao/Snap.Hutao/Extension/EnumerableExtensions.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Extension/EnumerableExtensions.cs
@@ -78,6 +78,27 @@ public static partial class EnumerableExtensions
return source.FirstOrDefault(predicate) ?? source.FirstOrDefault();
}
+ ///
+ /// 移除表中首个满足条件的项
+ ///
+ /// 项的类型
+ /// 表
+ /// 是否应当移除
+ /// 是否移除了元素
+ public static bool RemoveFirstWhere(this IList list, Func shouldRemovePredicate)
+ {
+ for (int i = 0; i < list.Count; i++)
+ {
+ if (shouldRemovePredicate.Invoke(list[i]))
+ {
+ list.RemoveAt(i);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
///
/// 表示一个对 类型的计数器
///
diff --git a/src/Snap.Hutao/Snap.Hutao/GlobalUsing.cs b/src/Snap.Hutao/Snap.Hutao/GlobalUsing.cs
index 25fda613..ada8ec67 100644
--- a/src/Snap.Hutao/Snap.Hutao/GlobalUsing.cs
+++ b/src/Snap.Hutao/Snap.Hutao/GlobalUsing.cs
@@ -18,6 +18,7 @@ global using System;
global using System.Collections.Generic;
global using System.ComponentModel;
global using System.Diagnostics.CodeAnalysis;
+global using System.Linq;
global using System.Text.Json;
global using System.Text.Json.Serialization;
global using System.Threading;
diff --git a/src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/20220903071033_LogTime.Designer.cs b/src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/20220903071033_LogTime.Designer.cs
new file mode 100644
index 00000000..0eba23dc
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/20220903071033_LogTime.Designer.cs
@@ -0,0 +1,55 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Snap.Hutao.Context.Database;
+
+#nullable disable
+
+namespace Snap.Hutao.Migrations.LogDb
+{
+ [DbContext(typeof(LogDbContext))]
+ [Migration("20220903071033_LogTime")]
+ partial class LogTime
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "6.0.8");
+
+ modelBuilder.Entity("Snap.Hutao.Core.Logging.LogEntry", b =>
+ {
+ b.Property("InnerId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("EventId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Exception")
+ .HasColumnType("TEXT");
+
+ b.Property("LogLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Time")
+ .HasColumnType("TEXT");
+
+ b.HasKey("InnerId");
+
+ b.ToTable("logs");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/20220903071033_LogTime.cs b/src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/20220903071033_LogTime.cs
new file mode 100644
index 00000000..74d1e26d
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/20220903071033_LogTime.cs
@@ -0,0 +1,27 @@
+//
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Snap.Hutao.Migrations.LogDb
+{
+ public partial class LogTime : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "Time",
+ table: "logs",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Time",
+ table: "logs");
+ }
+ }
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/LogDbContextModelSnapshot.cs b/src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/LogDbContextModelSnapshot.cs
index a2fba3a3..f79e5a53 100644
--- a/src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/LogDbContextModelSnapshot.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/LogDbContextModelSnapshot.cs
@@ -1,6 +1,8 @@
//
+using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Snap.Hutao.Context.Database;
#nullable disable
@@ -13,7 +15,7 @@ namespace Snap.Hutao.Migrations.LogDb
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
- modelBuilder.HasAnnotation("ProductVersion", "6.0.7");
+ modelBuilder.HasAnnotation("ProductVersion", "6.0.8");
modelBuilder.Entity("Snap.Hutao.Core.Logging.LogEntry", b =>
{
@@ -38,6 +40,9 @@ namespace Snap.Hutao.Migrations.LogDb
.IsRequired()
.HasColumnType("TEXT");
+ b.Property("Time")
+ .HasColumnType("TEXT");
+
b.HasKey("InnerId");
b.ToTable("logs");
diff --git a/src/Snap.Hutao/Snap.Hutao/Program.cs b/src/Snap.Hutao/Snap.Hutao/Program.cs
index c54d55aa..c0702732 100644
--- a/src/Snap.Hutao/Snap.Hutao/Program.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Program.cs
@@ -67,8 +67,8 @@ public static class Program
// Microsoft extension
.AddLogging(builder => builder
- .AddDebug()
- .AddDatabase())
+ .AddDatabase()
+ .AddDebug())
.AddMemoryCache()
// Hutao extensions
diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Achievement/AchievementService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Achievement/AchievementService.cs
index cbb6f93e..158b8276 100644
--- a/src/Snap.Hutao/Snap.Hutao/Service/Achievement/AchievementService.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Service/Achievement/AchievementService.cs
@@ -141,10 +141,10 @@ internal class AchievementService : IAchievementService
case ImportOption.Overwrite:
{
- IEnumerable newData = list
+ IEnumerable orederedUIAF = list
.Select(uiaf => EntityAchievement.Create(archiveId, uiaf))
.OrderBy(a => a.Id);
- return achievementDbOperation.Overwrite(archiveId, newData);
+ return achievementDbOperation.Overwrite(archiveId, orederedUIAF);
}
default:
diff --git a/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/GachaLogService.cs b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/GachaLogService.cs
new file mode 100644
index 00000000..a1928fa7
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/GachaLogService.cs
@@ -0,0 +1,12 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Service.GachaLog;
+
+///
+/// 祈愿记录服务器
+///
+internal class GachaLogService
+{
+
+}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/ApiEndpoints.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/ApiEndpoints.cs
index ba644369..e5a8e846 100644
--- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/ApiEndpoints.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/ApiEndpoints.cs
@@ -15,12 +15,25 @@ internal static class ApiEndpoints
///
/// 公告列表
///
- public const string AnnList = $"{Hk4eApi}/common/hk4e_cn/announcement/api/getAnnList?{AnnouncementQuery}";
+ public const string AnnList = $"{Hk4eApiAnnouncementApi}/getAnnList?{AnnouncementQuery}";
///
/// 公告内容
///
- public const string AnnContent = $"{Hk4eApi}/common/hk4e_cn/announcement/api/getAnnContent?{AnnouncementQuery}";
+ public const string AnnContent = $"{Hk4eApiAnnouncementApi}/getAnnContent?{AnnouncementQuery}";
+ #endregion
+
+ #region GachaInfo
+
+ ///
+ /// 获取祈愿记录
+ ///
+ /// query string
+ /// 祈愿记录信息Url
+ public static string GachaInfoGetGachaLog(string query)
+ {
+ return $"{Hk4eApiGachaInfoApi}/getGachaLog?{query}";
+ }
#endregion
#region GameRecord
@@ -53,49 +66,6 @@ internal static class ApiEndpoints
}
#endregion
- #region SignIn
-
- ///
- /// 签到活动Id
- ///
- public const string SignInRewardActivityId = "e202009291139501";
-
- ///
- /// 签到
- ///
- public const string SignInRewardHome = $"{ApiTakumi}/event/bbs_sign_reward/home?act_id={SignInRewardActivityId}";
-
- ///
- /// 签到信息
- ///
- /// uid
- /// 签到信息字符串
- public static string SignInRewardInfo(PlayerUid uid)
- {
- return $"{ApiTakumi}/event/bbs_sign_reward/info?act_id={SignInRewardActivityId}®ion={uid.Region}&uid={uid.Value}";
- }
-
- ///
- /// 签到
- ///
- public const string SignInRewardReSign = $"{ApiTakumi}/event/bbs_sign_reward/resign";
-
- ///
- /// 补签信息
- ///
- /// uid
- /// 补签信息字符串
- public static string SignInRewardResignInfo(PlayerUid uid)
- {
- return $"{ApiTakumi}/event/bbs_sign_reward/resign_info?act_id=e202009291139501®ion={uid.Region}&uid={uid.Value}";
- }
-
- ///
- /// 签到
- ///
- public const string SignInRewardSign = $"{ApiTakumi}/event/bbs_sign_reward/sign";
- #endregion
-
#region UserFullInfo
///
@@ -119,16 +89,21 @@ internal static class ApiEndpoints
///
/// 用户游戏角色
///
- public const string UserGameRoles = $"{ApiTakumi}/binding/api/getUserGameRolesByCookie?game_biz=hk4e_cn";
+ public const string UserGameRoles = $"{ApiTaKumiBindingApi}/getUserGameRolesByCookie?game_biz=hk4e_cn";
#endregion
// consts
private const string ApiTakumi = "https://api-takumi.mihoyo.com";
+ 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";
+
private const string BbsApi = "https://bbs-api.mihoyo.com";
private const string BbsApiUserApi = $"{BbsApi}/user/wapi";
+
private const string Hk4eApi = "https://hk4e-api.mihoyo.com";
+ private const string Hk4eApiAnnouncementApi = $"{Hk4eApi}/common/hk4e_cn/announcement/api";
+ 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®ion=cn_gf01&level=55&uid=100000000";
}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/DynamicSecret/DynamicSecretProvider.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/DynamicSecret/DynamicSecretProvider.cs
index 197bf6ce..cca5c54c 100644
--- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/DynamicSecret/DynamicSecretProvider.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/DynamicSecret/DynamicSecretProvider.cs
@@ -33,7 +33,7 @@ internal abstract class DynamicSecretProvider : Md5Convert
string q = string.Join("&", new UriBuilder(queryUrl).Query.Split('&').OrderBy(x => x));
// check
- string check = ToHexString($"salt={Core.CoreEnvironment.DynamicSecret2Salt}&t={t}&r={r}&b={b}&q={q}").ToLowerInvariant();
+ string check = ToHexString($"salt={Core.CoreEnvironment.DynamicSecretSalt}&t={t}&r={r}&b={b}&q={q}").ToLowerInvariant();
return $"{t},{r},{check}";
}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaConfigType.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaConfigType.cs
new file mode 100644
index 00000000..e9d6ea01
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaConfigType.cs
@@ -0,0 +1,35 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.Event.GachaInfo;
+
+///
+/// 祈愿配置类型
+///
+public enum GachaConfigType
+{
+ ///
+ /// 新手池
+ ///
+ NoviceWish = 100,
+
+ ///
+ /// 常驻池
+ ///
+ PermanentWish = 200,
+
+ ///
+ /// 角色1池
+ ///
+ AvatarEventWish = 301,
+
+ ///
+ /// 武器池
+ ///
+ WeaponEventWish = 302,
+
+ ///
+ /// 角色2池
+ ///
+ AvatarEventWish2 = 400,
+}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaInfoClient.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaInfoClient.cs
new file mode 100644
index 00000000..977d3b24
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaInfoClient.cs
@@ -0,0 +1,41 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
+using Snap.Hutao.Web.Response;
+using System.Net.Http;
+using System.Net.Http.Json;
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.Event.GachaInfo;
+
+///
+/// 祈愿记录客户端
+///
+[HttpClient(HttpClientConfigration.Default)]
+internal class GachaInfoClient
+{
+ private readonly HttpClient httpClient;
+ private readonly JsonSerializerOptions options;
+
+ ///
+ /// 构造一个新的祈愿记录客户端
+ ///
+ /// http客户端
+ /// Json序列化选项
+ public GachaInfoClient(HttpClient httpClient, JsonSerializerOptions options)
+ {
+ this.httpClient = httpClient;
+ this.options = options;
+ }
+
+ ///
+ /// 获取记录页面
+ ///
+ /// 查询
+ /// 取消令牌
+ /// 单个祈愿记录页面
+ public Task?> GetGachaLogPageAsync(GachaLogConfigration config, CancellationToken token = default)
+ {
+ return httpClient.GetFromJsonAsync>(ApiEndpoints.GachaInfoGetGachaLog(config.AsQuery()), options, token);
+ }
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogConfigration.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogConfigration.cs
new file mode 100644
index 00000000..b1eb1b5e
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogConfigration.cs
@@ -0,0 +1,63 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+using Snap.Hutao.Web.Request.QueryString;
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.Event.GachaInfo;
+
+///
+/// 祈愿记录请求配置
+///
+public struct GachaLogConfigration
+{
+ private readonly QueryString innerQuery;
+
+ ///
+ /// 尺寸
+ ///
+ public int Size
+ {
+ set => innerQuery.Set("size", value);
+ }
+
+ ///
+ /// 类型
+ ///
+ public GachaConfigType Type
+ {
+ set => innerQuery.Set("gacha_type", (int)value);
+ }
+
+ ///
+ /// 结束Id
+ ///
+ public ulong EndId
+ {
+ set => innerQuery.Set("end_id", value);
+ }
+
+ ///
+ /// 构造一个新的祈愿记录请求配置
+ ///
+ /// 原始查询字符串
+ /// 祈愿类型
+ /// 终止Id
+ public GachaLogConfigration(string query, GachaConfigType type, ulong endId = 0UL)
+ {
+ innerQuery = QueryString.Parse(query);
+ innerQuery.Set("lang", "zh-cn");
+
+ Size = 20;
+ Type = type;
+ EndId = endId;
+ }
+
+ ///
+ /// 转换到查询字符串
+ ///
+ /// 匹配的查询字符串
+ public string AsQuery()
+ {
+ return innerQuery.ToString();
+ }
+}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogItem.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogItem.cs
new file mode 100644
index 00000000..466d118d
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogItem.cs
@@ -0,0 +1,77 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+using Snap.Hutao.Model.Intrinsic;
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.Event.GachaInfo;
+
+///
+/// 祈愿记录物品
+///
+public class GachaLogItem
+{
+ ///
+ /// 玩家Uid
+ ///
+ [JsonPropertyName("uid")]
+ public string Uid { get; set; } = default!;
+
+ ///
+ /// 祈愿类型
+ ///
+ [JsonPropertyName("gacha_type")]
+ public GachaConfigType GachaType { get; set; } = default!;
+
+ ///
+ /// 总为
+ ///
+ [Obsolete("API removed this property")]
+ [JsonPropertyName("item_id")]
+ public string ItemId { get; set; } = default!;
+
+ ///
+ /// 个数
+ /// 一般为 1
+ ///
+ [JsonPropertyName("count")]
+ public string? Count { get; set; } = default!;
+
+ ///
+ /// 时间
+ ///
+ [JsonPropertyName("time")]
+ [JsonConverter(typeof(DateTimeOffsetConverter))]
+ public DateTimeOffset Time { get; set; }
+
+ ///
+ /// 物品名称
+ ///
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = default!;
+
+ ///
+ /// 语言
+ ///
+ [JsonPropertyName("lang")]
+ public string Language { get; set; } = default!;
+
+ ///
+ /// 物品类型
+ ///
+ [JsonPropertyName("item_type")]
+ public string ItemType { get; set; } = default!;
+
+ ///
+ /// 物品稀有等级
+ ///
+ [JsonPropertyName("rank_type")]
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public ItemQuality Rank { get; set; } = default!;
+
+ ///
+ /// Id
+ ///
+ [JsonPropertyName("id")]
+ [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
+ public long Id { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogPage.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogPage.cs
new file mode 100644
index 00000000..3b95306c
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Event/GachaInfo/GachaLogPage.cs
@@ -0,0 +1,42 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.Event.GachaInfo;
+
+///
+/// 祈愿记录分页
+///
+public class GachaLogPage
+{
+ ///
+ /// 页码
+ ///
+ [JsonPropertyName("page")]
+ public string Page { get; set; } = default!;
+
+ ///
+ /// 尺寸
+ ///
+ [JsonPropertyName("size")]
+ public string Size { get; set; } = default!;
+
+ ///
+ /// 总页数
+ ///
+ [Obsolete("总是为 0")]
+ [JsonPropertyName("total")]
+ public string Total { get; set; } = default!;
+
+ ///
+ /// 总页数
+ /// 总是为 0
+ ///
+ [JsonPropertyName("list")]
+ public List List { get; set; } = default!;
+
+ ///
+ /// 地区
+ ///
+ [JsonPropertyName("region")]
+ public string Region { get; set; } = default!;
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Request/QueryString/QueryString.cs b/src/Snap.Hutao/Snap.Hutao/Web/Request/QueryString/QueryString.cs
new file mode 100644
index 00000000..3c4941f8
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Request/QueryString/QueryString.cs
@@ -0,0 +1,288 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+using Snap.Hutao.Extension;
+using System.Collections;
+using System.Linq;
+
+namespace Snap.Hutao.Web.Request.QueryString;
+
+///
+/// querystring serializer/deserializer
+///
+public struct QueryString
+{
+ private readonly Dictionary> dictionary = new();
+
+ ///
+ /// Nothing
+ ///
+ public QueryString()
+ {
+ }
+
+ ///
+ /// Gets the first value of the first parameter with the matching name.
+ /// Throws if a parameter with a matching name could not be found.
+ /// O(n) where n = Count of the current object.
+ ///
+ /// The parameter name to find.
+ /// query
+ public string this[string name]
+ {
+ get
+ {
+ if (TryGetValue(name, out string? value))
+ {
+ return value;
+ }
+
+ throw new KeyNotFoundException($"A parameter with name '{name}' could not be found.");
+ }
+ }
+
+ ///
+ /// Parses a query string into a object. Keys/values are automatically URL decoded.
+ ///
+ ///
+ /// The query string to deserialize.
+ /// This should NOT have a leading ? character.
+ /// Valid input would be something like "a=1&b=5".
+ /// URL decoding of keys/values is automatically performed.
+ /// Also supports query strings that are serialized using ; instead of &, like "a=1;b=5"
+ /// A new QueryString represents the url query
+ public static QueryString Parse(string? queryString)
+ {
+ if (string.IsNullOrWhiteSpace(queryString))
+ {
+ return new QueryString();
+ }
+
+ string[] pairs = queryString.Split('&', ';');
+ QueryString answer = new();
+ foreach (string pair in pairs)
+ {
+ string name;
+ string? value;
+ int indexOfEquals = pair.IndexOf('=');
+ if (indexOfEquals == -1)
+ {
+ name = pair;
+ value = string.Empty;
+ }
+ else
+ {
+ name = pair[..indexOfEquals];
+ value = pair[(indexOfEquals + 1)..];
+ }
+
+ answer.Add(name, value);
+ }
+
+ return answer;
+ }
+
+ ///
+ /// Gets the first value of the first parameter with the matching name. If no parameter with a matching name exists, returns false.
+ ///
+ /// The parameter name to find.
+ /// The parameter's value will be written here once found.
+ /// value
+ public bool TryGetValue(string name, [NotNullWhen(true)] out string? value)
+ {
+ if (dictionary.TryGetValue(name, out List? values))
+ {
+ value = values.First();
+ return true;
+ }
+
+ value = null;
+ return false;
+ }
+
+ ///
+ /// Gets the values of the parameter with the matching name. If no parameter with a matching name exists, sets to null and returns false.
+ ///
+ /// The parameter name to find.
+ /// The parameter's values will be written here once found.
+ /// values
+ public bool TryGetValues(string name, [NotNullWhen(true)] out string?[]? values)
+ {
+ if (dictionary.TryGetValue(name, out List? storedValues))
+ {
+ values = storedValues.ToArray();
+ return true;
+ }
+
+ values = null;
+ return false;
+ }
+
+ ///
+ /// Returns the count of parameters in the current query string.
+ ///
+ /// count of the queries
+ public int Count()
+ {
+ return dictionary.Select(i => i.Value.Count).Sum();
+ }
+
+ ///
+ /// Adds a query string parameter to the query string.
+ ///
+ /// The name of the parameter.
+ /// The optional value of the parameter.
+ public void Add(string name, string value)
+ {
+ if (!dictionary.TryGetValue(name, out List? values))
+ {
+ values = new List();
+ dictionary[name] = values;
+ }
+
+ values.Add(value);
+ }
+
+ ///
+ public void Set(string name, object value)
+ {
+ Set(name, value.ToString() ?? string.Empty);
+ }
+
+ ///
+ /// Sets a query string parameter. If there are existing parameters with the same name, they are removed.
+ ///
+ /// The name of the parameter.
+ /// The optional value of the parameter.
+ public void Set(string name, string value)
+ {
+ dictionary[name] = new() { value };
+ }
+
+ ///
+ /// Determines if the query string contains at least one parameter with the specified name.
+ ///
+ /// The parameter name to look for.
+ /// True if the query string contains at least one parameter with the specified name, else false.
+ public bool Contains(string name)
+ {
+ return dictionary.ContainsKey(name);
+ }
+
+ ///
+ /// Determines if the query string contains a parameter with the specified name and value.
+ ///
+ /// The parameter name to look for.
+ /// The value to look for when the name has been matched.
+ /// True if the query string contains a parameter with the specified name and value, else false.
+ public bool Contains(string name, string value)
+ {
+ return dictionary.TryGetValue(name, out List? values) && values.Contains(value);
+ }
+
+ ///
+ /// Removes the first parameter with the specified name.
+ ///
+ /// The name of parameter to remove.
+ /// True if the parameters were removed, else false.
+ public bool Remove(string name)
+ {
+ if (dictionary.TryGetValue(name, out List? values))
+ {
+ if (values.Count == 1)
+ {
+ dictionary.Remove(name);
+ }
+ else
+ {
+ values.RemoveAt(0);
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Removes all parameters with the specified name.
+ ///
+ /// The name of parameters to remove.
+ /// True if the parameters were removed, else false.
+ public bool RemoveAll(string name)
+ {
+ return dictionary.Remove(name);
+ }
+
+ ///
+ /// Removes the first parameter with the specified name and value.
+ ///
+ /// The name of the parameter to remove.
+ /// value
+ /// True if parameter was removed, else false.
+ public bool Remove(string name, string value)
+ {
+ if (dictionary.TryGetValue(name, out List? values))
+ {
+ if (values.RemoveFirstWhere(i => Equals(i, value)))
+ {
+ // If removed last value, remove the key
+ if (values.Count == 0)
+ {
+ dictionary.Remove(name);
+ }
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Removes all parameters with the specified name and value.
+ ///
+ /// The name of parameters to remove.
+ /// The value to match when deciding whether to remove.
+ /// The count of parameters removed.
+ public int RemoveAll(string name, string value)
+ {
+ if (dictionary.TryGetValue(name, out List? values))
+ {
+ int countRemoved = values.RemoveAll(i => Equals(i, value));
+
+ // If removed last value, remove the key
+ if (values.Count == 0)
+ {
+ dictionary.Remove(name);
+ }
+
+ return countRemoved;
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Serializes the key-value pairs into a query string, using the default & separator.
+ /// Produces something like "a=1&b=5".
+ /// URL encoding of keys/values is automatically performed.
+ /// Null values are not written (only their key is written).
+ ///
+ /// query
+ public override string ToString()
+ {
+ return string.Join('&', GetParameters());
+ }
+
+ private IEnumerable GetParameters()
+ {
+ foreach ((string name, List values) in dictionary)
+ {
+ foreach (string value in values)
+ {
+ yield return new QueryStringParameter(name, value);
+ }
+ }
+ }
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Request/QueryString/QueryStringParameter.cs b/src/Snap.Hutao/Snap.Hutao/Web/Request/QueryString/QueryStringParameter.cs
new file mode 100644
index 00000000..641e6f17
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Request/QueryString/QueryStringParameter.cs
@@ -0,0 +1,37 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Request.QueryString;
+
+///
+/// A single query string parameter (name and value pair).
+///
+public struct QueryStringParameter
+{
+ ///
+ /// The name of the parameter. Cannot be null.
+ ///
+ public string Name;
+
+ ///
+ /// The value of the parameter (or null if there's no value).
+ ///
+ public string Value;
+
+ ///
+ /// Initializes a new query string parameter with the specified name and optional value.
+ ///
+ /// The name of the parameter. Cannot be null.
+ /// The optional value of the parameter.
+ internal QueryStringParameter(string name, string value)
+ {
+ Name = name;
+ Value = value;
+ }
+
+ ///
+ public override string ToString()
+ {
+ return $"{Name}={Value}";
+ }
+}